* @package kanban * @version $Id: model.php 5118 2021-10-22 10:18:41Z $ * @link https://www.zentao.net */ class kanbanModel extends model { /** * 创建看板分组。 * Create a kanban group. * * @param int $kanbanID * @param int $regionID * @access public * @return int|false */ public function createGroup(int $kanbanID, int $regionID): int|false { $maxOrder = $this->dao->select('MAX(`order`) AS maxOrder')->from(TABLE_KANBANGROUP) ->where('region')->eq($regionID) ->fetch('maxOrder'); $order = $maxOrder ? $maxOrder + 1 : 1; $group = new stdclass(); $group->kanban = $kanbanID; $group->region = $regionID; $group->order = $order; $this->dao->insert(TABLE_KANBANGROUP)->data($group)->autoCheck()->exec(); if(dao::isError()) return false; return $this->dao->lastInsertID(); } /** * 创建默认看板区域。 * Create a default kanban region. * * @param object $kanban * @access public * @return int|bool */ public function createDefaultRegion(object $kanban): int|bool { $region = new stdclass(); $region->name = $this->lang->kanbanregion->default; $region->kanban = $kanban->id; $region->space = $kanban->space; $region->createdBy = $this->app->user->account; $region->createdDate = helper::now(); return $this->createRegion($kanban, $region); } /** * 复制看板区域。 * Copy kanban regions. * * @param object $kanban * @param int $copyKanbanID * @param string $from kanban|execution * @param string $param * @access public * @return void */ public function copyRegions(object $kanban, int $copyKanbanID, string $from = 'kanban', string $param = 'withArchived') { if(empty($kanban) || empty($copyKanbanID)) return; $regions = $this->getRegionPairs($copyKanbanID, 0, $from); $order = 1; foreach($regions as $copyID => $copyName) { $region = new stdclass(); $region->name = $copyName; $region->kanban = $kanban->id; $region->space = $kanban->space; $region->createdBy = $this->app->user->account; $region->createdDate = helper::now(); $region->order = $order; $this->createRegion($kanban, $region, $copyID, $from, $param); $order ++; } } /** * 创建区域。 * Create a new region. * * @param object $kanban * @param object $fromRegion * @param int $copyRegionID * @param string $from kanban|execution * @param string $param * @access public * @return int */ public function createRegion(object $kanban, ?object $fromRegion = null, int $copyRegionID = 0, string $from = 'kanban', string $param = '') { $account = $this->app->user->account; $order = 1; $region = $fromRegion; if(empty($region)) { $maxOrder = $this->dao->select('MAX(`order`) AS maxOrder')->from(TABLE_KANBANREGION) ->where('kanban')->eq($kanban->id) ->fetch('maxOrder'); $order = $maxOrder ? $maxOrder + 1 : 1; $region = new stdclass(); $region->kanban = $kanban->id; $region->space = $from == 'kanban' ? $kanban->space : 0; $region->name = $this->post->name; $region->createdBy = $account; $region->createdDate = helper::now(); } $region->order = isset($region->order) ? $region->order : $order; $this->dao->insert(TABLE_KANBANREGION)->data($region) ->batchCheck($this->config->kanban->require->createregion, 'notempty') ->check('name', 'unique', "kanban = {$kanban->id} AND deleted = '0' AND space='$region->space'") ->autoCheck() ->exec(); $regionID = $this->dao->lastInsertID(); if(dao::isError()) return false; $this->loadModel('action')->create('kanbanRegion', $regionID, 'Created'); if($copyRegionID) { $this->copyRegion($kanban, $regionID, $copyRegionID, $from, $param); } elseif($from == 'kanban') { $groupID = $this->createGroup((int)$kanban->id, $regionID); if(dao::isError()) return false; $this->createDefaultLane($regionID, $groupID); if(dao::isError()) return false; $this->createDefaultColumns($regionID, $groupID); if(dao::isError()) return false; } return $regionID; } /** * 复制区域。 * Copy a region. * * @param object $kanban * @param int $regionID * @param int $copyRegionID * @param string $from * @param string $param * @access public * @return void */ public function copyRegion(object $kanban, int $regionID, int $copyRegionID, string $from = 'kanban', string $param = '') { /* Gets the groups, lanes and columns of the replication region. */ $copyGroups = $this->getGroupGroupByRegions((array)$copyRegionID); $copyLaneGroup = $this->getLaneGroupByRegions((array)$copyRegionID); $copyColumnGroup = $this->getColumnGroupByRegions((array)$copyRegionID, 'id_asc', $param); /* Create groups, lanes, and columns. */ if(empty($copyGroups)) return $regionID; foreach($copyGroups[$copyRegionID] as $copyGroupID => $copyGroup) { $newGroupID = $this->createGroup((int)$kanban->id, $regionID); if(dao::isError()) return false; $copyLanes = isset($copyLaneGroup[$copyGroupID]) ? $copyLaneGroup[$copyGroupID] : array(); $copyColumns = isset($copyColumnGroup[$copyGroupID]) ? $copyColumnGroup[$copyGroupID] : array(); $lanePairs = $this->copyLanes($kanban, $copyLanes, $regionID, $newGroupID); $this->copyColumns($copyColumns, $regionID, $newGroupID, $from); if(dao::isError()) return false; if($param == 'updateTaskCell') { foreach($lanePairs as $oldLaneID => $newLaneID) { $cards = $this->dao->select('id,cards')->from(TABLE_KANBANCELL)->where('lane')->eq($oldLaneID)->andWhere('type')->eq('task')->fetchPairs(); $cards = implode(',', $cards); $cards = preg_replace('/[,]+/', ',',$cards); $cards = trim($cards, ','); $group = $this->dao->select('`group`')->from(TABLE_KANBANLANE)->where('id')->eq($newLaneID)->fetch(); $waitColumn = $this->dao->select('id')->from(TABLE_KANBANCOLUMN)->where('type')->eq('wait')->andWhere('`group`')->eq($group->group)->fetch(); if(!empty($waitColumn)) $this->addKanbanCell($kanban->id, $newLaneID, $waitColumn->id, 'task', $cards); } } } } /** * 复制区域中的泳道。 * Copy lanes. * * @param object $kanban * @param array $copyLanes * @param int $regionID * @param int $newGroupID * @access public * @return array|false */ public function copyLanes(object $kanban, array $copyLanes, int $regionID, int $newGroupID): array|false { $lanePairs = array(); foreach($copyLanes as $copyLane) { if(is_array($copyLane)) $copyLane = (object)$copyLane; $laneID = $copyLane->id; $copyLane->name = $copyLane->title; unset($copyLane->id); unset($copyLane->actionList); unset($copyLane->title); $copyLane->region = $regionID; $copyLane->group = $newGroupID; $copyLane->lastEditedTime = helper::now(); $lanePairs[$laneID] = $this->createLane((int)$kanban->id, $regionID, $copyLane, 'copy'); if(dao::isError()) return false; } return $lanePairs; } /** * 复制区域中的列。 * Copy columns. * * @param array $copyColumns * @param int $regionID * @param int $newGroupID * @param string $from * @access public * @return void */ public function copyColumns(array $copyColumns, int $regionID, int $newGroupID, string $from = 'kanban') { $parentColumns = array(); foreach($copyColumns as $copyColumn) { if(is_array($copyColumn)) $copyColumn = (object)$copyColumn; $copyColumnID = $copyColumn->id; $copyColumn->name = $copyColumn->title; unset($copyColumn->id); unset($copyColumn->title); unset($copyColumn->actionList); unset($copyColumn->asParent); unset($copyColumn->parentName); $copyColumn->region = $regionID; $copyColumn->group = $newGroupID; if($copyColumn->parent > 0 && isset($parentColumns[$copyColumn->parent])) { $copyColumn->parent = $parentColumns[$copyColumn->parent]; } $parentColumnID = $this->createColumn($regionID, $copyColumn, $from, 'copy'); if($copyColumn->parent < 0) $parentColumns[$copyColumnID] = $parentColumnID; if(dao::isError()) return false; } } /** * 创建默认泳道。 * Create default lane. * * @param int $regionID * @param int $groupID * @access public * @return int */ public function createDefaultLane(int $regionID, int $groupID) { $lane = new stdclass(); $lane->name = $this->lang->kanbanlane->default; $lane->group = $groupID; $lane->region = $regionID; $lane->type = 'common'; $lane->lastEditedTime = helper::now(); $lane->color = '#7ec5ff'; $lane->order = 1; $lane->groupby = ''; $lane->extra = ''; $this->dao->insert(TABLE_KANBANLANE)->data($lane)->exec(); $laneID = $this->dao->lastInsertId(); return $laneID; } /** * 创建默认看板列。 * Create default kanban columns. * * @param int $regionID * @param int $groupID * @access public * @return void */ public function createDefaultColumns(int $regionID, int $groupID) { $order = 1; foreach($this->lang->kanban->defaultColumn as $columnName) { $column = new stdclass(); $column->region = $regionID; $column->group = $groupID; $column->name = $columnName; $column->order = $order; $column->limit = -1; $column->color = '#333'; $column->type = ''; $this->createColumn($regionID, $column, 'kanban', 'copy'); $order ++; } return !dao::isError(); } /** * 创建看板列。 * Create a column. * * @param int $regionID * @param object $column * @param string $from kanban|execution * @param string $mode new|copy * @access public * @return int|false */ public function createColumn(int $regionID, ?object $column = null, string $from = 'kanban', string $mode = 'new'): int|false { if($mode == 'new') { if(!$column->limit && empty($column->noLimit)) dao::$errors['limit'][] = sprintf($this->lang->error->notempty, $this->lang->kanban->WIP); if(!preg_match("/^-?\d+$/", (string)$column->limit) || (!isset($column->noLimit) && $column->limit <= 0)) dao::$errors['limit'] = $this->lang->kanban->error->mustBeInt; if(dao::isError()) return false; } $limit = $column->limit; if(isset($column->parent) && $column->parent > 0) { /* Create a child column. */ $parentColumn = $this->getColumnByID((int)$column->parent); if($parentColumn->limit != -1) { /* The WIP of the child column is infinite or greater than the WIP of the parent column. */ $sumChildLimit = $this->dao->select('SUM(`limit`) AS sumChildLimit')->from(TABLE_KANBANCOLUMN)->where('parent')->eq($column->parent)->andWhere('deleted')->eq(0)->fetch('sumChildLimit'); if($limit == -1 || (((int)$limit + (int)$sumChildLimit) > $parentColumn->limit)) dao::$errors['limit'][] = $this->lang->kanban->error->parentLimitNote; if(dao::isError()) return false; } } if($mode == 'new' && $column->order) { $this->dao->update(TABLE_KANBANCOLUMN) ->set('`order` = `order` + 1') ->where('`group`')->eq($column->group) ->andWhere('`order`')->ge($column->order) ->exec(); } $this->dao->insert(TABLE_KANBANCOLUMN)->data($column, 'noLimit,position,copyItems') ->batchCheck($this->config->kanban->require->createcolumn, 'notempty') ->autoCheck() ->exec(); if(dao::isError()) return false; $columnID = $this->dao->lastInsertID(); $this->loadModel('action')->create('kanbanColumn', $columnID, 'Created'); if($from == 'kanban') $this->dao->update(TABLE_KANBANCOLUMN)->set('type')->eq("column{$columnID}")->where('id')->eq($columnID)->exec(); /* Add kanban cell. */ $lanes = $this->dao->select('id,type')->from(TABLE_KANBANLANE)->where('`group`')->eq($column->group)->fetchPairs(); $kanbanID = $this->dao->select('kanban')->from(TABLE_KANBANREGION)->where('id')->eq($regionID)->fetch('kanban'); foreach($lanes as $laneID => $laneType) $this->addKanbanCell((int)$kanbanID, $laneID, $columnID, $laneType); return $columnID; } /** * 拆分看板列。 * Split column. * * @param int $columnID * @param array $columns * @access public * @return void */ public function splitColumn(int $columnID, array $columns) { $this->loadModel('action'); $column = $this->getColumnByID($columnID); $maxOrder = $this->dao->select('MAX(`order`) AS maxOrder')->from(TABLE_KANBANCOLUMN)->where('`group`')->eq($column->group)->fetch('maxOrder'); $order = $maxOrder ? $maxOrder + 1 : 1; $sumChildLimit = 0; $childrenColumn = array(); foreach($columns as $childColumn) { $childColumn->parent = $column->id; $childColumn->region = $column->region; $childColumn->group = $column->group; $childColumn->limit = $childColumn->noLimit == -1 ? -1 : $childColumn->limit; $childColumn->order = $order; $sumChildLimit += $childColumn->limit == -1 ? 0 : $childColumn->limit; if(!$this->checkChildColumn($column, $childColumn, $sumChildLimit)) return false; $order ++; $childrenColumn[] = $childColumn; } foreach($childrenColumn as $i => $childColumn) { $this->dao->insert(TABLE_KANBANCOLUMN)->data($childColumn, 'noLimit') ->autoCheck() ->batchCheck($this->config->kanban->splitcolumn->requiredFields, 'notempty') ->exec(); if(dao::isError()) return false; $childColumnID = $this->dao->lastInsertID(); $this->dao->update(TABLE_KANBANCOLUMN)->set('type')->eq("column{$childColumnID}")->where('id')->eq($childColumnID)->exec(); $this->action->create('kanbanColumn', $childColumnID, 'created'); $this->kanbanTao->addChildColumnCell($columnID, $childColumnID, $i); } $this->dao->update(TABLE_KANBANCOLUMN)->set('parent')->eq(-1)->where('id')->eq($columnID)->exec(); } /** * 检查看板列信息是否合法。 * Check the column information is legal. * * @param object $column * @param object $childColumn * @param int $sumChildLimit * @access public * @return bool */ public function checkChildColumn(object $column, object $childColumn, int $sumChildLimit): bool { if(empty($childColumn->name)) { dao::$errors['name'] = sprintf($this->lang->error->notempty, $this->lang->kanbancolumn->name); return false; } if(!preg_match("/^-?\d+$/", (string)$childColumn->limit) || (!$childColumn->noLimit && $childColumn->limit <= 0)) { dao::$errors['limit'] = $this->lang->kanban->error->mustBeInt; return false; } if($column->limit != -1 && ($childColumn->limit == -1 || ($column->limit < $sumChildLimit))) { dao::$errors['limit'] = $this->lang->kanban->error->parentLimitNote; return false; } return true; } /** * 创建看板卡片。 * Create a kanban card. * * @param int $columnID * @param object $card * @access public * @return bool|int */ public function createCard(int $columnID, object $card): bool|int { if($card->estimate < 0) { dao::$errors['estimate'] = $this->lang->kanbancard->error->recordMinus; return false; } if($card->end && $card->begin > $card->end) { dao::$errors['end'] = $this->lang->kanbancard->error->endSmall; return false; } $card = $this->loadModel('file')->processImgURL($card, $this->config->kanban->editor->createcard['id'], $this->post->uid); $this->dao->insert(TABLE_KANBANCARD)->data($card)->autoCheck() ->checkIF($card->estimate != '', 'estimate', 'float') ->batchCheck($this->config->kanban->createcard->requiredFields, 'notempty') ->exec(); if(dao::isError()) return false; $cardID = $this->dao->lastInsertID(); $this->loadModel('action')->create('kanbanCard', $cardID, 'created'); $this->file->saveUpload('kanbancard', $cardID); $this->file->updateObjectID($this->post->uid, $cardID, 'kanbancard'); $this->addKanbanCell((int)$card->kanban, (int)$this->post->lane, $columnID, 'common', (string)$cardID); return $cardID; } /** * 转入其它看板的卡片。 * Import card. * * @param int $kanbanID * @param int $regionID * @param int $groupID * @param int $columnID * @access public * @return array|false */ public function importCard(int $kanbanID, int $regionID, int $groupID, int $columnID): array|false { $importIDList = $this->post->cards; $targetLaneID = $this->post->targetLane; if(!$importIDList || !$targetLaneID) return false; $updateData = new stdclass(); $updateData->kanban = $kanbanID; $updateData->region = $regionID; $updateData->group = $groupID; $this->dao->update(TABLE_KANBANCARD)->data($updateData)->where('id')->in($importIDList)->exec(); $kanban = $this->getByID($kanbanID); $oldCardsKanban = array(); $kanbanUsers = trim($kanban->owner) . ',' . trim($kanban->team); $users = $this->loadModel('user')->getPairs('noclosed|nodeleted', '', 0, $kanbanUsers); $cardList = $this->dao->select('*')->from(TABLE_KANBANCARD)->where('id')->in($importIDList)->fetchAll('id'); foreach($cardList as $cardID => $card) { $oldCardsKanban[$cardID] = $card->kanban; if(empty($card->assignedTo)) continue; $this->kanbanTao->updateCardAssignedTo($cardID, $card->assignedTo, $users); } if(!dao::isError()) { $this->removeKanbanCell('common', $importIDList, $oldCardsKanban); $cards = implode(',', $importIDList); $this->addKanbanCell($kanbanID, (int)$targetLaneID, $columnID, 'common', $cards); return $importIDList; } return false; } /** * 转入其它对象作为看板卡片 * Import object. * * @param int $kanbanID * @param int $regionID * @param int $groupID * @param int $columnID * @param string $objectType * @access public * @return array|false */ public function importObject(int $kanbanID, int $regionID, int $groupID, int $columnID, string $objectType): array|false { $objectIDList = $this->post->{$objectType . 's'}; $targetLaneID = $this->post->targetLane; $objectCards = array(); $now = helper::now(); foreach($objectIDList as $objectID) { $cardData = new stdclass(); $cardData->kanban = $kanbanID; $cardData->region = $regionID; $cardData->group = $groupID; $cardData->fromID = $objectID; $cardData->fromType = $objectType; $cardData->createdBy = $this->app->user->account; $cardData->createdDate = $now; $this->dao->insert(TABLE_KANBANCARD)->data($cardData)->exec(); $cardID = $this->dao->lastInsertID(); $objectCards[$cardID] = $objectID; } if(!dao::isError()) { $cards = implode(',', array_keys($objectCards)); $this->addKanbanCell($kanbanID, (int)$targetLaneID, $columnID, 'common', $cards); return $objectCards; } return false; } /** * 批量创建卡片。 * Batch create kanban cards. * * @param int $kanbanID * @param int $regionID * @param int $groupID * @param int $columnID * @param array $cards * @access public * @return void */ public function batchCreateCard(int $kanbanID, int $regionID, int $groupID, int $columnID, array $cards) { $now = helper::now(); foreach($cards as $i => $card) { if($card->estimate < 0) { dao::$errors["estimate[$i]"] = $this->lang->kanbancard->error->recordMinus; return false; } if($card->end && $card->begin > $card->end) { dao::$errors["end[$i]"] = $this->lang->kanbancard->error->endSmall; return false; } } foreach($cards as $card) { $card->kanban = $kanbanID; $card->region = $regionID; $card->group = $groupID; $card->createdBy = $this->app->user->account; $card->createdDate = $now; $card->assignedDate = $now; $card->color = '#fff'; $this->dao->insert(TABLE_KANBANCARD)->data($card, 'lane')->autoCheck() ->checkIF($card->estimate != '', 'estimate', 'float') ->batchCheck($this->config->kanban->createcard->requiredFields, 'notempty') ->exec(); if(!dao::isError()) { $cardID = $this->dao->lastInsertID(); $this->addKanbanCell($kanbanID, $card->lane, $columnID, 'common', (string)$cardID); $this->loadModel('action')->create('kanbancard', $cardID, 'created'); } } } /** * 获取看板数据。 * Get kanban by id. * * @param int $kanbanID * @access public * @return object */ public function getByID(int $kanbanID): object { $kanban = $this->dao->findByID($kanbanID)->from(TABLE_KANBAN)->fetch(); $kanban = $this->loadModel('file')->replaceImgURL($kanban, 'desc'); return $kanban; } /** * 获取看板键值对。 * Get kanban pairs. * * @access public * @return array */ public function getPairs() { $idList = $this->getCanViewObjects(); return $this->dao->select('id,name')->from(TABLE_KANBAN) ->where('id')->in($idList) ->andWhere('deleted')->eq('0') ->fetchPairs(); } /** * 获取看板所有数据。 * Get kanban data. * * @param int $kanbanID * @param mixed $regionIDList * @access public * @return array */ public function getKanbanData(int $kanbanID, mixed $regionIDList = ''): array { $regions = $this->getRegionPairs($kanbanID); if(empty($regionIDList)) { $regionIDList = array_keys($regions); } elseif(!is_array($regionIDList)) { $regionIDList = array($regionIDList); } $groupGroup = $this->getGroupGroupByRegions($regionIDList); $laneGroup = $this->getLaneGroupByRegions($regionIDList); $columnGroup = $this->getColumnGroupByRegions($regionIDList); $cardGroup = $this->getCardGroupByKanban($kanbanID); $kanbanList = array(); foreach($regionIDList as $regionID) { $regionData = array(); $heading = new stdclass(); $heading->title = new stdClass(); $heading->title->html = zget($regions, $regionID, '') . ''; $heading->actions = $this->getRegionActions($kanbanID, $regionID, count($regions)); $regionData['key'] = "region{$regionID}"; $regionData['id'] = $regionID; $regionData['heading'] = $heading; $regionData['toggleFromHeading'] = true; $groups = zget($groupGroup, $regionID, array()); $kanbanList[] = $this->kanbanTao->buildRegionData($regionData, $groups, $laneGroup, $columnGroup, $cardGroup); } return $kanbanList; } /** * 获取看板区域上的操作按钮。 * Get region actions. * * @param int $kanbanID * @param int|string $regionID * @param int $regionCount * @access public * @return array */ public function getRegionActions(int $kanbanID, int|string $regionID, int $regionCount): array { $action = array(); $actions = array(); $action['type'] = 'dropdown'; $action['icon'] = 'ellipsis-v'; $action['caret'] = false; $action['items'] = array(); if(common::hasPriv('kanban', 'createRegion')) $action['items'][] = array('text' => $this->lang->kanban->createRegion, 'url' => helper::createLink('kanban', 'createRegion', "kanbanID=$kanbanID"), 'data-toggle' => 'modal', 'icon' => 'plus'); if(common::hasPriv('kanban', 'editRegion')) $action['items'][] = array('text' => $this->lang->kanban->editRegion, 'url' => helper::createLink('kanban', 'editRegion', "regionID=$regionID"), 'data-toggle' => 'modal', 'icon' => 'edit'); if(common::hasPriv('kanban', 'sortRegion')) $action['items'][] = array('text' => $this->lang->kanban->sortRegion, 'url' => 'javascript:;', 'icon' => 'move', 'data-on' => 'click', 'data-call' => 'sortItems', 'data-params' => 'event', 'data-type' => 'region', 'data-id' => $regionID); if(common::hasPriv('kanban', 'createLane')) $action['items'][] = array('text' => $this->lang->kanban->createLane, 'url' => helper::createLink('kanban', 'createLane', "kanbanID=$kanbanID®ionID=$regionID"), 'data-toggle' => 'modal', 'icon' => 'plus'); if(common::hasPriv('kanban', 'deleteRegion') and ($regionCount > 1)) $action['items'][] = array('text' => $this->lang->kanban->deleteRegion, 'url' => helper::createLink('kanban', 'deleteRegion', "regionID=$regionID"), 'data-confirm' => $this->lang->kanbanregion->confirmDelete, 'icon' => 'trash', 'innerClass' => 'ajax-submit'); $action['items'][] = array('type' => 'divider'); if(commonModel::hasPriv('kanban', 'viewArchivedCard')) $action['items'][] = array('text' => $this->lang->kanban->viewArchivedCard, 'url' => "javascript:loadMore(\"Card\", $regionID)", 'icon' => 'card-archive'); if(commonModel::hasPriv('kanban', 'viewArchivedColumn')) $action['items'][] = array('text' => $this->lang->kanban->viewArchivedColumn, 'url' => "javascript:loadMore(\"Column\", $regionID)", 'icon' => 'col-archive'); $actions[] = $action; return $actions; } /** * 获取专业研发看板区域上的操作按钮。 * Get RD region actions. * * @param int $kanbanID * @param int $regionID * @param int $regionCount * @access public * @return array */ public function getRDRegionActions(int $kanbanID, int $regionID, int $regionCount = 1): array { $action = array(); $actions = array(); $action['type'] = 'dropdown'; $action['icon'] = 'ellipsis-v'; $action['caret'] = false; $action['items'] = array(); if(common::hasPriv('kanban', 'createRegion')) $action['items'][] = array('text' => $this->lang->kanban->createRegion, 'url' => helper::createLink('kanban', 'createRegion', "kanbanID=$kanbanID&from=execution"), 'data-toggle' => 'modal', 'icon' => 'plus', 'class' => 'kanban-createRegion-btn'); if(common::hasPriv('kanban', 'editRegion')) $action['items'][] = array('text' => $this->lang->kanban->editRegion, 'url' => helper::createLink('kanban', 'editRegion', "regionID=$regionID"), 'data-toggle' => 'modal', 'icon' => 'edit'); if(common::hasPriv('kanban', 'createLane')) $action['items'][] = array('text' => $this->lang->kanban->createLane, 'url' => helper::createLink('kanban', 'createLane', "kanbanID=$kanbanID®ionID=$regionID&from=execution"), 'data-toggle' => 'modal', 'icon' => 'plus'); if(common::hasPriv('kanban', 'deleteRegion') && ($regionCount > 1)) $action['items'][] = array('text' => $this->lang->kanban->deleteRegion, 'url' => helper::createLink('kanban', 'deleteRegion', "regionID=$regionID"), 'data-confirm' => $this->lang->kanbanregion->confirmDelete, 'icon' => 'trash', 'innerClass' => 'ajax-submit'); $actions[] = $action; return $actions; } /** * 获取计划看板。 * Get plan kanban. * * @param object $product * @param string $branchID * @param array $planGroup * @access public * @return array */ public function getPlanKanban(object $product, string $branchID = '0', array $planGroup = array()): array { $this->loadModel('productplan'); $lanes = $columns = $kanbanData = $planList = $lanes = $columns = $columnCards = array(); $colorIndex = 0; $laneOrder = 1; $cardActions = array('view', 'createExecution', 'linkStory', 'linkBug', 'edit', 'start', 'finish', 'close', 'activate', 'delete'); $branches = $this->kanbanTao->getBranchesForPlanKanban($product, $branchID); foreach($branches as $id => $name) { if($product->type != 'normal') $plans = isset($planGroup[$product->id][$id]) ? array_filter($planGroup[$product->id][$id]) : array(); if($product->type == 'normal') $plans = $planGroup; foreach($plans as $plan) { if(empty($plan) || $plan->parent == -1) continue; $plan->isParent = false; $planBegin = $plan->begin == $this->config->productplan->future ? $this->lang->productplan->future : date('m-d', strtotime($plan->begin)); $planEnd = $plan->end == $this->config->productplan->future ? $this->lang->productplan->future : date('m-d', strtotime($plan->end)); $item = array('id' => $plan->id, 'name' => $plan->id, 'title' => htmlspecialchars_decode($plan->title), 'status' => $plan->status); $item['statusLabel'] = zget($this->lang->productplan->statusList, $plan->status); $item['delay'] = helper::today() > $plan->end ? true : false; $item['desc'] = strip_tags(htmlspecialchars_decode($plan->desc)); $item['dateLine'] = in_array($this->lang->productplan->future, array($planBegin, $planEnd)) ? $this->lang->productplan->future : $planBegin . ' ' . $this->lang->productplan->to . ' ' . $planEnd; $item['actionList'] = array(); foreach($cardActions as $action) { if($this->productplan->isClickable($plan, $action)) $item['actionList'][] = $action; } $planList['lane' . $id][$plan->status][] = $item; if(!isset($columnCards[$plan->status])) $columnCards[$plan->status] = 0; $columnCards[$plan->status] ++; } $lanes[] = array('id' => $id, 'name' => 'lane' . $id, 'title' => $name, 'color' => $this->config->productplan->laneColorList[$colorIndex], 'order' => $laneOrder); $laneOrder ++; $colorIndex ++; if($colorIndex == count($this->config->productplan->laneColorList)) $colorIndex = 0; } foreach($this->lang->kanban->defaultColumn as $columnType => $columnName) $columns[] = array('id' => $columnType, 'name' => $columnType, 'title' => $columnName); foreach($columns as $key => $column) $columns[$key]['cards'] = !empty($columnCards[$column['name']]) ? $columnCards[$column['name']] : 0; $groupData['key'] = 'planKanban'; $groupData['data'] = array('lanes' => $lanes, 'cols' => $columns, 'items' => $planList); $kanbanData[] = array('items' => array($groupData), 'key' => 'planKanban'); return $kanbanData; } /** * 获取专业研发看板。 * Get a RD kanban data. * * @param int $executionID * @param string $browseType all|story|task|bug * @param string $orderBy * @param int $regionID * @param string $groupBy * @param string $searchValue * @access public * @return array */ public function getRDKanban(int $executionID, string $browseType = 'all', string $orderBy = 'id_desc', int $regionID = 0, string $groupBy = 'default', string $searchValue = '') { $kanbanList = array(); $execution = $this->loadModel('execution')->getByID($executionID); if($groupBy != 'default' && $groupBy != '') return $this->getRDKanbanByGroup($execution, $browseType, $orderBy, $regionID, $groupBy, $searchValue); $regions = $this->getRegionPairs($executionID, $regionID, 'execution'); $regionIDList = $regionID == 0 ? array_keys($regions) : array(0 => $regionID); $groupGroup = $this->getGroupGroupByRegions($regionIDList); $laneGroup = $this->getLaneGroupByRegions($regionIDList, $browseType); foreach($laneGroup as $lanes) { foreach($lanes as $lane) { $lane['execution'] = $executionID; if(in_array($execution->attribute, array('request', 'design', 'review')) && $lane['type'] == 'bug') continue 2; if(in_array($execution->attribute, array('request', 'review')) && $lane['type'] == 'story') continue 2; $this->refreshCards((array)$lane); $lane['defaultCardType'] = $lane['type']; } } $columnGroup = $this->getRDColumnGroupByRegions($regionIDList, array_keys($laneGroup)); $cardGroup = $this->getCardGroupByExecution($executionID, $browseType, $orderBy, $searchValue); foreach($regions as $regionID => $regionName) { $regionData = array(); $heading = new stdclass(); $heading->title = new stdClass(); $heading->title->html = $regionName . ''; $heading->actions = $this->getRDRegionActions($executionID, $regionID, count($regions)); $regionData['key'] = "region{$regionID}"; $regionData['id'] = $regionID; $regionData['heading'] = $heading; $regionData['toggleFromHeading'] = true; $regionData['items'] = array(); $groups = zget($groupGroup, $regionID, array()); $regionData = $this->kanbanTao->buildRDRegionData($regionData, $groups, $laneGroup, $columnGroup, $cardGroup, $searchValue); $kanbanList[] = $regionData; } return $kanbanList; } /** * 获取专业研发看板分组视图下的看板数据。 * Get kanban data for group view of RD kanban. * * @param object $execution * @param string $browseType * @param string $groupBy * @param string $searchValue * @param string $orderBy * @access private * @return array */ private function getRDKanbanByGroup(object $execution, string $browseType, string $orderBy, int $regionID, string $groupBy, string $searchValue): array { $regionData = array(); $heading = new stdclass(); $heading->title = $execution->name; $heading->actions = $this->getRDRegionActions($execution->id, $regionID, 1); $regionData['key'] = "region{$execution->id}"; $regionData['id'] = $execution->id; $regionData['heading'] = $heading; $regionData['toggleFromHeading'] = true; $regionData['items'] = $this->getKanban4Group($execution->id, $browseType, $groupBy, $searchValue, $orderBy); $kanbanList[] = $regionData; return $kanbanList; } /** * 获取看板区域。 * Get region by id. * * @param int $regionID * @access public * @return object */ public function getRegionByID(int $regionID) { return $this->dao->findByID($regionID)->from(TABLE_KANBANREGION)->fetch(); } /** * 获取看板区域的键值对。 * Get ordered region pairs. * * @param int $kanbanID * @param int $regionID * @param string $from kanban|execution * @access public * @return array */ public function getRegionPairs(int $kanbanID, int $regionID = 0, string $from = 'kanban'): array { if(common::isTutorialMode()) return $this->loadModel('tutorial')->getRegionPairs(); return $this->dao->select('id,name')->from(TABLE_KANBANREGION) ->where('kanban')->eq($kanbanID) ->andWhere('deleted')->eq('0') ->beginIF($regionID)->andWhere('id')->eq($regionID)->fi() ->beginIF($from == 'execution')->andWhere('space')->eq(0)->fi() ->beginIF($from == 'kanban')->andWhere('space')->ne(0)->fi() ->orderBy('order_asc') ->fetchPairs(); } /** * 根据区域获取看板ID。 * Get kanban id by region id. * * @param int $regionID * @access public * @return int */ public function getKanbanIDByRegion(int $regionID): int { return (int)$this->dao->select('kanban')->from(TABLE_KANBANREGION)->where('id')->eq($regionID)->fetch('kanban'); } /** * 分组获取看板组。 * Get kanban group by regions. * * @param array $regions * @access public * @return array */ public function getGroupGroupByRegions(array $regions): array { if(common::isTutorialMode()) return $this->loadModel('tutorial')->getGroups(); return $this->dao->select('*')->from(TABLE_KANBANGROUP) ->where('region')->in($regions) ->orderBy('order') ->fetchGroup('region', 'id'); } /** * 获取区域中的泳道分组。 * Get lane group by regions. * * @param array $regions * @param string $browseType * @access public * @return array */ public function getLaneGroupByRegions(array $regions, string $browseType = 'all'): array { if(common::isTutorialMode()) return $this->loadModel('tutorial')->getLaneGroup(); $lanes = $this->dao->select('*')->from(TABLE_KANBANLANE) ->where('deleted')->eq('0') ->andWhere('region')->in($regions) ->beginIf($browseType != 'all')->andWhere('type')->eq($browseType)->fi() ->orderBy('order_asc') ->fetchAll(); $actions = array('sortLane', 'deleteLane', 'editLaneName', 'editLaneColor'); $laneGroup = array(); foreach($lanes as $lane) { $item = array(); $item['id'] = $lane->id; $item['execution'] = $lane->execution; $item['type'] = $lane->type; $item['name'] = $lane->id; $item['region'] = $lane->region; $item['title'] = htmlspecialchars_decode($lane->name); $item['color'] = $lane->color; $item['order'] = $lane->order; foreach($actions as $action) { if($this->isClickable($lane, $action)) $item['actionList'][] = $action; } $laneGroup[$lane->group][] = $item; } return $laneGroup; } /** * 根据分组ID获取泳道键值对。 * Get kanban lane pairs by group id. * * @param int $groupID * @param string $orderBy * @access public * @return array */ public function getLanePairsByGroup(int $groupID, string $orderBy = '`order`_asc'): array { return $this->dao->select('id,name')->from(TABLE_KANBANLANE) ->where('deleted')->eq(0) ->andWhere('`group`')->eq($groupID) ->orderBy($orderBy) ->fetchPairs(); } /** * 根据分组ID获取看板列键值对。 * Get kanban column pairs by group id. * * @param int $groupID * @param int $parentID * @param string $orderBy * @access public * @return array */ public function getColumnPairsByGroup(int $groupID, int $parentID = 0, string $orderBy = '`order`_asc'): array { return $this->dao->select('id,name')->from(TABLE_KANBANCOLUMN) ->where('deleted')->eq(0) ->andWhere('`group`')->eq($groupID) ->beginIf($parentID > 0)->andWhere('parent')->eq($parentID)->fi() ->beginIf($parentID <= 0)->andWhere('parent')->le(0)->fi() ->orderBy($orderBy) ->fetchPairs(); } /** * Get column group by regions. * * @param array $regions * @param string $order order|id_asc * @param string $param * @access public * @return array */ public function getColumnGroupByRegions(array $regions, string $order = '`order`', string $param = ''): array { $columns = $this->dao->select("*")->from(TABLE_KANBANCOLUMN) ->where('deleted')->eq('0') ->andWhere('region')->in($regions) ->beginIF(strpos(",$param,", ',withArchived,') === false)->andWhere('archived')->eq('0')->fi() ->orderBy($order) ->fetchAll(); $actions = array('createColumn', 'setColumn', 'setWIP', 'sortColumn', 'archiveColumn', 'restoreColumn', 'deleteColumn', 'createCard', 'batchCreateCard', 'splitColumn', 'sortColumn'); /* Group by parent. */ $columnGroup = array(); foreach($columns as $column) { $item = array(); $item['title'] = htmlspecialchars_decode($column->name); $item['name'] = $column->id; $item['id'] = $column->id; $item['type'] = $column->type; $item['limit'] = $column->limit; $item['region'] = $column->region; $item['group'] = $column->group; $item['parent'] = $column->parent; $item['color'] = $column->color; $item['order'] = (int)$column->order; if($column->parent > 0) $item['parentName'] = $column->parent; /* Judge column action priv. */ foreach($actions as $action) { if($this->isClickable($column, $action)) $item['actionList'][] = $action; } $columnGroup[$column->group][] = $item; } return $columnGroup; } /** * 获取看板下的卡片。 * Get card group by kanban id. * * @param int $kanbanID * @access public * @return array */ public function getCardGroupByKanban(int $kanbanID): array { /* Get card data.*/ $cards = $this->dao->select('*')->from(TABLE_KANBANCARD) ->where('deleted')->eq(0) ->andWhere('kanban')->eq($kanbanID) ->andWhere('archived')->eq(0) ->andWhere('fromID')->eq(0) ->fetchAll('id', false); foreach($this->config->kanban->fromType as $fromType) $cards = $this->getImportedCards($kanbanID, $cards, $fromType); $cellList = $this->dao->select('*')->from(TABLE_KANBANCELL) ->where('kanban')->eq($kanbanID) ->andWhere('type')->eq('common') ->fetchAll('id', false); $actions = array('editCard', 'archiveCard', 'deleteCard', 'moveCard', 'setCardColor', 'viewCard', 'sortCard', 'viewExecution', 'viewPlan', 'viewRelease', 'viewBuild', 'viewTicket', 'activateCard', 'finishCard'); $cardGroup = array(); $avatarPairs = $this->loadModel('user')->getAvatarPairs(); $users = $this->loadModel('user')->getPairs('noletter'); foreach($cellList as $cell) { $cardIdList = array_filter(explode(',', $cell->cards)); if(empty($cardIdList)) continue; $order = 0; foreach($cardIdList as $cardID) { if(!isset($cards[$cardID])) continue; $card = zget($cards, $cardID); $item = $this->kanbanTao->initCardItem($card, $cell, $order, $avatarPairs, $users); $order ++; foreach($actions as $action) { if(in_array($action, array('viewExecution', 'viewPlan', 'viewRelease', 'viewBuild', 'viewTicket'))) { if($card->fromType == 'execution') { if($card->execType == 'kanban' && common::hasPriv('execution', 'kanban')) $item['actionList'][] = $action; if($card->execType != 'kanban' && common::hasPriv('execution', 'view')) $item['actionList'][] = $action; } else { if(common::hasPriv($fromType, 'view')) $item['actionList'][] = $action; } continue; } if(common::hasPriv('kanban', $action)) $item['actionList'][] = $action; } $cardGroup[$card->group][$cell->lane][$cell->column][] = $item; } } return $cardGroup; } /** * 获取已经导入的卡片。 * Get imported cards. * * @param int $kanbanID * @param array $cards * @param array $fromType * @param int $archived * @param int $regionID * @access public * @return array */ public function getImportedCards(int $kanbanID, array $cards, string $fromType, int $archived = 0, int $regionID = 0): array { /* Get imported cards based on imported object type. */ $objectCards = $this->dao->select('*')->from(TABLE_KANBANCARD) ->where('deleted')->eq(0) ->andWhere('kanban')->eq($kanbanID) ->andWhere('archived')->eq($archived) ->andWhere('fromType')->eq($fromType) ->beginIF($regionID)->andWhere('region')->eq($regionID)->fi() ->fetchGroup('fromID', 'id'); if(!empty($objectCards)) { /* Get imported objects. */ $table = $this->config->objectTables[$fromType]; $objects = $this->dao->select('*')->from($table) ->where('id')->in(array_keys($objectCards)) ->fetchAll('id', false); $creators = array(); if($fromType == 'productplan' || $fromType == 'release') { $creators = $this->dao->select('objectID, actor')->from(TABLE_ACTION) ->where('objectID')->in(array_keys($objectCards)) ->andWhere('objectType')->eq($fromType) ->andWhere('action')->eq('opened') ->fetchPairs(); } /* Data for constructing the card. */ foreach($objectCards as $objectID => $cardsInfo) { foreach($cardsInfo as $cardID => $objectCard) { $object = $objects[$objectID]; $fieldType = $fromType . 'Field'; if($fromType == 'execution') $objectCard->progress = $object->progress; foreach($this->config->kanban->$fieldType as $field) $objectCard->$field = $object->$field; $objectCard = $this->kanbanTao->buildObjectCard($objectCard, $object, $fromType, $creators); $cards[$cardID] = $objectCard; } } } return $cards; } /** * 获取专业研发看板的列。 * Get RD column group by regions. * * @param array $regions * @param array $groupIDList * @access public * @return array */ public function getRDColumnGroupByRegions(array $regions, array $groupIDList = array()) { if(common::isTutorialMode()) return $this->loadModel('tutorial')->getColumns(); $columnGroup = $this->dao->select("*")->from(TABLE_KANBANCOLUMN) ->where('deleted')->eq('0') ->andWhere('region')->in($regions) ->beginIF(!empty($groupIDList))->andWhere('`group`')->in($groupIDList)->fi() ->orderBy('id_asc') ->fetchGroup('group'); $actions = array('setColumn', 'setWIP', 'deleteColumn'); /* Group by parent. */ $columnData = array(); foreach($columnGroup as $group => $columns) { foreach($columns as $column) { $item = array(); $item['title'] = htmlspecialchars_decode($column->name); $item['name'] = $column->id; $item['id'] = $column->id; $item['type'] = $column->type; $item['limit'] = $column->limit; $item['region'] = $column->region; $item['group'] = $column->group; $item['parent'] = $column->parent; $item['color'] = $column->color; if($column->parent > 0) $item['parentName'] = $column->parent; /* Judge column action priv. */ foreach($actions as $action) { if($this->isClickable($column, $action)) $item['actionList'][] = $action; } $columnData[$group][] = $item; } } return $columnData; } /** * 获取执行中的看板卡片分组。 * Get card group by execution id. * * @param int $kanbanID * @param string $browseType all|task|bug|story * @param string $orderBy * @param string $searchValue * * @access public * @return array */ public function getCardGroupByExecution(int $executionID, string $browseType = 'all', string $orderBy = 'id_asc', string $searchValue = ''): array { if(common::isTutorialMode()) return $this->loadModel('tutorial')->getCardGroup(); $cards = $this->dao->select('t1.*, t2.type as columnType, t2.group') ->from(TABLE_KANBANCELL)->alias('t1') ->leftJoin(TABLE_KANBANCOLUMN)->alias('t2')->on('t1.column=t2.id') ->where('t1.kanban')->eq($executionID) ->beginIF($browseType != 'all')->andWhere('t1.type')->eq($browseType)->fi() ->orderby($orderBy) ->fetchgroup('lane', 'column'); /* Get group objects. */ if($browseType == 'all' || $browseType == 'parentStory') $objectGroup['parentStory'] = $this->loadModel('story')->getExecutionStories($executionID, 0, 't1.`order`_desc', 'allStory'); if($browseType == 'all' || $browseType == 'story') $objectGroup['story'] = $this->loadModel('story')->getExecutionStories($executionID, 0, 't1.`order`_desc', 'allStory'); if($browseType == 'all' || $browseType == 'bug') $objectGroup['bug'] = $this->loadModel('bug')->getExecutionBugs($executionID); if($browseType == 'all' || $browseType == 'task') $objectGroup['task'] = $this->loadModel('execution')->getKanbanTasks($executionID, "id"); $cardGroup = array(); $avatarPairs = $this->loadModel('user')->getAvatarPairs(); $users = $this->loadModel('user')->getPairs('noletter'); foreach($cards as $laneID => $cells) { foreach($cells as $cell) { $order = 0; $cardIdList = array_filter(explode(',', $cell->cards)); foreach($cardIdList as $cardID) { $cardData = array(); $objects = zget($objectGroup, $cell->type, array()); $object = zget($objects, $cardID, array()); if(empty($object)) continue; if($cell->type == 'parentStory' && $object->isParent != '1') continue; if($cell->type == 'story' && $object->isParent == '1') continue; $cardData = $this->kanbanTao->initCardItem($object, $cell, $order, $avatarPairs, $users); $cardData['acl'] = 'open'; $order ++; if($searchValue != '' && strpos($cardData['title'], $searchValue) === false) continue; $cardGroup[$cell->group][$laneID][$cell->column][] = $cardData; } } } return $cardGroup; } /** * 获取执行看板的数据。 * Get Kanban by execution id. * * @param int $executionID * @param string $browseType all|story|bug|task * @param string $groupBy default|pri|category|module|source|assignedTo|type|story|severity * @param string $searchValue * @param string $orderBy * @access public * @return array */ public function getExecutionKanban(int $executionID, string $browseType = 'all', string $groupBy = 'default', string $searchValue = '', string $orderBy = 'id_asc'): array { if($groupBy != 'default') return array($this->getKanban4Group($executionID, $browseType, $groupBy, $searchValue, $orderBy), array()); $execution = $this->loadModel('execution')->fetchByID($executionID); $lanes = $this->dao->select('*')->from(TABLE_KANBANLANE) ->where('execution')->eq($executionID) ->andWhere('deleted')->eq(0) ->beginIF($browseType != 'all')->andWhere('type')->eq($browseType)->fi() ->beginIF(!empty($execution->attribute) && !in_array($execution->attribute, array('mix', 'request', 'design')))->andWhere('type')->notIn('epic,requirement')->fi() ->orderBy('order_asc') ->fetchAll('id'); if(empty($lanes)) return array(); foreach($lanes as $lane) $this->refreshCards((array)$lane); $columns = $this->dao->select('t1.cards, t1.lane, t2.id, t2.type, t2.name, t2.color, t2.limit, t2.parent')->from(TABLE_KANBANCELL)->alias('t1') ->leftJoin(TABLE_KANBANCOLUMN)->alias('t2')->on('t1.column = t2.id') ->where('t2.deleted')->eq(0) ->andWhere('t1.lane')->in(array_keys($lanes)) ->orderBy('id_asc') ->fetchGroup('lane', 'id'); $geMax = in_array($this->config->edition, array('max', 'ipd')); /* Get group objects. */ if($browseType == 'all' || $browseType == 'epic') $objectGroup['epic'] = $this->loadModel('story')->getExecutionStories($executionID, 0, 't1.`order`_desc', 'allStory', '0', 'epic'); if($browseType == 'all' || $browseType == 'requirement') $objectGroup['requirement'] = $this->loadModel('story')->getExecutionStories($executionID, 0, 't1.`order`_desc', 'allStory', '0', 'requirement'); if($browseType == 'all' || $browseType == 'parentStory') $objectGroup['parentStory'] = $this->loadModel('story')->getExecutionStories($executionID, 0, 't1.`order`_desc', 'allStory'); if($browseType == 'all' || $browseType == 'story') $objectGroup['story'] = $this->loadModel('story')->getExecutionStories($executionID, 0, 't1.`order`_desc', 'allStory'); if($browseType == 'all' || $browseType == 'bug') $objectGroup['bug'] = $this->loadModel('bug')->getExecutionBugs($executionID); if($browseType == 'all' || $browseType == 'task') $objectGroup['task'] = $this->loadModel('execution')->getKanbanTasks($executionID, "id"); if($geMax && ($browseType == 'all' || $browseType == 'risk')) $objectGroup['risk'] = $this->loadModel('risk')->getKanbanRisks($executionID); /* Get objects cards menus. */ $menus = array(); $menus['epic'] = $browseType == 'all' || $browseType == 'epic' ? $this->getKanbanCardMenu($executionID, $objectGroup['epic'], 'epic') : array(); $menus['requirement'] = $browseType == 'all' || $browseType == 'requirement' ? $this->getKanbanCardMenu($executionID, $objectGroup['requirement'], 'requirement') : array(); $menus['parentStory'] = $browseType == 'all' || $browseType == 'parentStory' ? $this->getKanbanCardMenu($executionID, $objectGroup['parentStory'], 'parentStory') : array(); $menus['story'] = $browseType == 'all' || $browseType == 'story' ? $this->getKanbanCardMenu($executionID, $objectGroup['story'], 'story') : array(); $menus['bug'] = $browseType == 'all' || $browseType == 'bug' ? $this->getKanbanCardMenu($executionID, $objectGroup['bug'], 'bug') : array(); $menus['task'] = $browseType == 'all' || $browseType == 'task' ? $this->getKanbanCardMenu($executionID, $objectGroup['task'], 'task') : array(); $menus['risk'] = ($geMax && ($browseType == 'all' || $browseType == 'risk')) ? $this->getKanbanCardMenu($executionID, $objectGroup['risk'], 'risk') : array(); /* 获取看板连线的fromKanbanID. */ $storyFromKanbanID = ''; $taskFromKanbanID = ''; foreach($lanes as $lane) { if($lane->type == 'parentStory') $storyFromKanbanID = 'group' . $lane->id; if($lane->type == 'task') $taskFromKanbanID = 'group' . $lane->id; } /* Build kanban group data. */ $kanbanGroup = array(); $links = array(); $execution = $this->loadModel('execution')->fetchById($executionID); $project = $this->loadModel('project')->fetchById($execution->project); foreach($lanes as $lane) { $laneType = $lane->type; if(in_array($laneType, array('epic', 'requirement')) && strpos($project->storyType, $laneType) === false) continue; list($laneData, $columnData, $cardsData) = $this->buildExecutionGroup($lane, $columns, $objectGroup, $searchValue, $menus); if($lane->type == 'risk' && in_array($this->config->edition, array('max', 'ipd'))) $cardsData = $this->appendRiskField($cardsData, $objectGroup['risk']); $kanbanID = 'group' . $lane->id; $kanbanGroup[$laneType]['id'] = $lane->id; $kanbanGroup[$laneType]['key'] = $kanbanID; $kanbanGroup[$laneType]['data'] = array(); $kanbanGroup[$laneType]['data']['lanes'] = array($laneData); $kanbanGroup[$laneType]['data']['cols'] = $columnData; $kanbanGroup[$laneType]['data']['items'] = $cardsData; foreach($cardsData as $columnCard) { foreach($columnCard as $cards) { foreach($cards as $card) { if($card['parent'] == 0) continue; /* 获取看板卡片的连线关系,业需、用需和父需求共用同一组看板列,所以fromKanban和toKanban是一样的。 */ $link = array('from' => $card['parent'], 'to' => $card['id']); if($laneType == 'task') { $link['fromKanban'] = $taskFromKanbanID; $link['toKanban'] = $taskFromKanbanID; } else { $link['fromKanban'] = $storyFromKanbanID; $link['toKanban'] = in_array($laneType, array('epic', 'requirement')) ? $storyFromKanbanID : $kanbanID; } $links[] = $link; } } } } /* 展示所有泳道的时候,业务、用需与父需求泳道共用看板列。 */ if($browseType == 'all') { foreach(array('requirement', 'epic') as $laneType) { if(!isset($kanbanGroup[$laneType])) continue; array_unshift($kanbanGroup['parentStory']['data']['lanes'], $kanbanGroup[$laneType]['data']['lanes'][0]); $kanbanGroup['parentStory']['data']['items'] += $kanbanGroup[$laneType]['data']['items']; unset($kanbanGroup[$laneType]); } } return array(array_values($kanbanGroup), $links); } /** * 构建迭代看板的泳道组数据。 * Build the laneGroup data for the execution Kanban. * * @param object $lane * @param array $columns * @param array $objectGroup * @param string $searchValue * @param array $menus array(story => array, bug => array, task => array) * @access public * @return array */ public function buildExecutionGroup(object $lane, array $columns, array $objectGroup, string $searchValue = '', array $menus = array()): array { $laneData = array(); $columnsData = array(); $cardsData = array(); $laneData['id'] = $lane->id; $laneData['type'] = $lane->type; $laneData['name'] = $lane->id; $laneData['region'] = $lane->region; $laneData['title'] = $lane->name; $laneData['color'] = $lane->color; foreach($columns[$lane->id] as $columnID => $column) { $cardIdList = array_unique(array_filter(explode(',', $column->cards))); $columnsData[$column->id]['id'] = $columnID; $columnsData[$column->id]['type'] = $column->type; $columnsData[$column->id]['name'] = $columnID; $columnsData[$column->id]['title'] = $column->name; $columnsData[$column->id]['color'] = $column->color; $columnsData[$column->id]['limit'] = $column->limit; $columnsData[$column->id]['region'] = $lane->region; $columnsData[$column->id]['laneName'] = $column->lane; $columnsData[$column->id]['group'] = $lane->type; $columnsData[$column->id]['cards'] = 0; $columnsData[$column->id]['actionList'] = array('setColumn', 'setWIP'); if($column->parent > 0) $columnsData[$column->id]['parentName'] = $column->parent; if($cardIdList) { $cardsData = $this->buildExecutionCards($cardsData, $column, $lane->type, $cardIdList, $objectGroup, $searchValue, $menus); $columnsData[$column->id]['cards'] = empty($cardsData[$column->lane][$column->id]) ? 0 : count($cardsData[$column->lane][$column->id]); } } foreach($columnsData as $columnData) { if(isset($columnData['parentName'])) $columnsData[$columnData['parentName']]['cards'] += $columnData['cards']; } return array($laneData, array_values($columnsData), $cardsData); } /** * 构建迭代看板的卡片数据。 * Build the card data for the execution Kanban. * * @param object $lane * @param array $laneData * @param string $columnType * @param array $cardIdList * @param array $objectGroup * @param string $searchValue * @param array $menus array(story => array, bug => array, task => array) * @access public * @return array */ public function buildExecutionCards(array $cardsData, object $column, string $laneType, array $cardIdList, array $objectGroup, string $searchValue = '', array $menus = array()): array { foreach($cardIdList as $cardID) { $cardData = array(); $objects = zget($objectGroup, $laneType, array()); $object = zget($objects, $cardID, array()); if(empty($object)) continue; if($laneType == 'story' && $object->isParent == '1') continue; $cardData = $this->buildExecutionCard($object, $column, $laneType, $searchValue, $menus); if(empty($cardData)) continue; $cardsData[$column->lane][$column->id][] = $cardData; } return $cardsData; } /** * 构造执行看板卡片数据。 * Build execution card. * * @param object $object * @param object $column * @param string $laneType * @param string $searchValue * @param array $menus array(story => array, bug => array, task => array) * @access public * @return array */ public function buildExecutionCard(object $object, object $column, string $laneType, string $searchValue = '', $menus = array()): array { if(empty($object)) return array(); $cardData = array(); $cardData['id'] = $object->id; $cardData['lane'] = $column->lane; $cardData['column'] = zget($column, 'column', $column->id); $cardData['pri'] = zget($object, 'pri', 0); $cardData['group'] = $laneType; $cardData['parent'] = zget($object, 'originParent', 0); $cardData['status'] = zget($object, 'status', ''); $cardData['estimate'] = zget($object, 'estimate', 0); $cardData['assignedTo'] = $object->assignedTo; $cardData['deadline'] = zget($object, 'deadline', ''); $cardData['severity'] = zget($object, 'severity', 0); $cardData['actionList'] = zget(zget($menus, $laneType, array()), $object->id, array()); $cardData['canAssignTo'] = true; $cardData['title'] = zget($object, 'title', ''); if($laneType == 'task') { $cardData['title'] = $object->name; $cardData['status'] = $object->status; $cardData['parent'] = $object->parent; $cardData['isParent'] = $object->isParent; $cardData['left'] = $object->left; $cardData['estStarted'] = $object->estStarted; $cardData['mode'] = $object->mode; $cardData['canAssignTo'] = common::hasPriv('task', 'assignTo') && common::hasDBPriv($object, 'task', 'assignto'); if($object->mode == 'multi') $cardData['teamMembers'] = $object->teamMembers; } if($searchValue != '' && !str_contains($cardData['title'], $searchValue)) return array(); return $cardData; } /** * 获取执行看板分组视图的数据。 * Get kanban for group view. * * @param int $executionID * @param string $browseType * @param string $groupBy * @param string $searchValue * @param string $orderBy * * @access public * @return array */ public function getKanban4Group(int $executionID, string $browseType, string $groupBy, string $searchValue = '', string $orderBy = 'id_asc'): array { /* Get card data. */ $cardList = array(); if(in_array($browseType, array('story', 'parentStory'))) $cardList = $this->loadModel('story')->getExecutionStories($executionID, 0, 't1.`order`_desc', 'allStory'); if($browseType == 'epic') $cardList = $this->loadModel('story')->getExecutionStories($executionID, 0, 't1.`order`_desc', 'allStory', '0', 'epic'); if($browseType == 'requirement') $cardList = $this->loadModel('story')->getExecutionStories($executionID, 0, 't1.`order`_desc', 'allStory', '0', 'requirement'); if($browseType == 'bug') $cardList = $this->loadModel('bug')->getExecutionBugs($executionID); if($browseType == 'task') $cardList = $this->loadModel('execution')->getKanbanTasks($executionID); if($browseType == 'task' && $groupBy == 'assignedTo') $cardList = $this->appendTeamMember($cardList); /* Get objects cards menus. */ $menus = array(); if($browseType == 'story') $menus['story'] = $this->getKanbanCardMenu($executionID, $cardList, 'story'); if($browseType == 'bug') $menus['bug'] = $this->getKanbanCardMenu($executionID, $cardList, 'bug'); if($browseType == 'task') $menus['task'] = $this->getKanbanCardMenu($executionID, $cardList, 'task'); if($groupBy == 'story' && $browseType == 'task' && !isset($this->lang->kanban->orderList[$orderBy])) $orderBy = 'id_asc'; $lanes = $this->getLanes4Group($executionID, $browseType, $groupBy, $cardList, $orderBy); if(empty($lanes)) return array(); $columns = $this->getCols4Group($executionID, $browseType); $cardGroup = array(); foreach($columns as $column) { if(empty($column->cards)) continue; foreach($cardList as $card) { if(strpos($column->cards, ",$card->id,") !== false) $cardGroup[$column->columnType][$card->id] = $card; } } /* Build kanban group data. */ $kanbanGroup = array(); list($laneData, $columnData, $cardData) = $this->buildGroupKanban($lanes, $columns, $cardGroup, $searchValue, $groupBy, $browseType, $menus); $kanbanGroup['id'] = $executionID; $kanbanGroup['key'] = 'group' . $executionID; $kanbanGroup['data'] = array(); $kanbanGroup['data']['lanes'] = $laneData; $kanbanGroup['data']['cols'] = array_values($columnData); $kanbanGroup['data']['items'] = $cardData; return array($kanbanGroup); } /** * 构建分组视图的看板泳道。 * Build lanes for group kanban. * * @access public * @param int $executionID * @param string $browseType * @param string $groupBy * @param array $cardList * @param string $orderBy * * @return array */ public function getLanes4Group(int $executionID, string $browseType, string $groupBy, array $cardList, string $orderBy = 'id_asc'): array { $lanes = array(); $groupByList = array(); foreach($cardList as $item) { if(!isset($groupByList[$item->$groupBy])) $groupByList[$item->$groupBy] = $item->$groupBy; if($groupBy == 'assignedTo' && !empty($item->teamMember)) { foreach($item->teamMember as $account => $name) { if(!isset($groupByList[$account])) $groupByList[$account] = $account; } } } $objectPairs = $this->getObjectPairs($groupBy, $groupByList, $browseType, $orderBy); if($groupBy == 'story') $objects = $this->dao->select('*')->from(TABLE_STORY)->where('deleted')->eq(0)->andWhere('id')->in($groupByList)->orderBy($orderBy)->fetchAll('id'); $laneColor = 0; $order = 1; foreach($objectPairs as $objectType => $objectName) { if(!isset($groupByList[$objectType]) && $objectType && !in_array($objectType, array('feature', 'design'))) continue; $lane = new stdclass(); $lane->id = $groupBy . $objectType; $lane->type = $browseType; $lane->execution = $executionID; $lane->name = $objectName; $lane->order = (int)$order; $lane->color = $this->config->kanban->laneColorList[$laneColor]; $lane->pri = (isset($objects) && isset($objects[$objectType]->pri)) ? $objects[$objectType]->pri : ''; $lane->assignedTo = (isset($objects) && isset($objects[$objectType]->assignedTo)) ? $objects[$objectType]->assignedTo : ''; $order += 1; $laneColor += 1; if($laneColor == count($this->config->kanban->laneColorList)) $laneColor = 0; $lanes[$objectType] = $lane; } return $lanes; } /** * 获取分组视图的看板列数据。 * Get columns for group kanban. * * @param int $executionID * @param string $browseType * @access public * @return array */ public function getCols4Group(int $executionID, string $browseType): array { $execution = $this->loadModel('execution')->getByID($executionID); $cards = $this->dao->select('t1.*, t1.cards, t2.`type` as columnType, t2.parent, t2.limit, t2.name as columnName, t2.color')->from(TABLE_KANBANCELL)->alias('t1') ->leftJoin(TABLE_KANBANCOLUMN)->alias('t2')->on('t1.`column` = t2.id') ->leftJoin(TABLE_KANBANLANE)->alias('t3')->on('t1.lane = t3.id') ->leftJoin(TABLE_KANBANREGION)->alias('t4')->on('t1.kanban = t4.kanban') ->where('t1.kanban')->eq($executionID) ->andWhere('t1.`type`')->eq($browseType) ->beginIF(isset($execution->type) && $execution->type == 'kanban') ->andWhere('t3.deleted')->eq(0) ->andWhere('t4.deleted')->eq(0) ->fi() ->orderBy('column_asc') ->fetchAll(); $columns = array(); foreach($cards as $card) { if(!isset($columns[$card->columnType])) $columns[$card->columnType] = $card; $columns[$card->columnType]->cards .= $card->cards; $columns[$card->columnType]->cards = ',' . trim($columns[$card->columnType]->cards, ',') . ','; } return $columns; } /** * 获取看板空间列表。 * Get space list. * * @param string $browseType private|cooperation|public|involved * @param object $pager * @access public * @return array */ public function getSpaceList(string $browseType, ?object $pager = null): array { $account = $this->app->user->account; $spaceIdList = $this->getCanViewObjects('kanbanspace', $browseType); $spaceList = $this->dao->select('*')->from(TABLE_KANBANSPACE) ->where('deleted')->eq(0) ->beginIF(in_array($browseType, array('private', 'cooperation', 'public')))->andWhere('type')->eq($browseType)->fi() ->beginIF($browseType == 'private' && !$this->app->user->admin)->andWhere('owner')->eq($account)->fi() ->beginIF($this->cookie->showClosed == 0)->andWhere('status')->ne('closed')->fi() ->andWhere('id')->in($spaceIdList) ->orderBy('id_desc') ->page($pager) ->fetchAll('id'); $kanbanIdList = $this->getCanViewObjects('kanban', $browseType); $kanbanGroup = $this->getGroupBySpaceList(array_keys($spaceList), $kanbanIdList); foreach($spaceList as $spaceID => $space) { if(isset($kanbanGroup[$spaceID])) $space->kanbans = $kanbanGroup[$spaceID]; } return $spaceList; } /** * 获取看板空间键值对。 * Get space pairs. * * @param string $browseType private|cooperation|public|involved * @access public * @return array */ public function getSpacePairs(string $browseType = 'private'): array { $spaceIdList = $this->getCanViewObjects('kanbanspace', $browseType); return $this->dao->select('id,name')->from(TABLE_KANBANSPACE) ->where('deleted')->eq(0) ->andWhere('id')->in($spaceIdList) ->beginIF(in_array($browseType, array('private', 'cooperation', 'public')))->andWhere('type')->eq($browseType)->fi() ->beginIF($this->cookie->showClosed == 0 && $browseType != 'showClosed')->andWhere('status')->ne('closed')->fi() ->orderBy('id_desc') ->fetchPairs('id'); } /** * 获取看板键值对。 * Get Kanban pairs. * * @access public * @return array */ public function getKanbanPairs(): array { $kanbanIdList = $this->getCanViewObjects('kanban'); return $this->dao->select('id,name')->from(TABLE_KANBAN) ->where('deleted')->eq(0) ->beginIF(!$this->app->user->admin)->andWhere('id')->in($kanbanIdList)->fi() ->orderBy('id_desc') ->fetchPairs('id'); } /** * 获取有权查看的对象。 * Get can view objects. * * @param string $objectType kanbanspace|kanban * @param string $param all|noclosed|private|cooperation|public|involved * @access public * @return array */ public function getCanViewObjects(string $objectType = 'kanban', string $param = 'all'): array { $table = $this->config->objectTables[$objectType]; $objects = $this->dao->select('*')->from($table) ->where('deleted')->eq(0) ->beginIF(strpos($param, 'noclosed') !== false)->andWhere('status')->ne('closed')->fi() ->fetchAll('id', false); $spaceList = $objectType == 'kanban' ? $this->dao->select('id,owner,type')->from(TABLE_KANBANSPACE)->fetchAll('id') : array(); if($param && $this->app->user->admin && strpos('private,involved', $param) === false) return array_keys($objects); $account = $this->app->user->account; foreach($objects as $objectID => $object) { if($objectType == 'kanbanspace' && $object->type == 'public' && $param != 'involved') continue; $remove = true; if($object->owner == $account) $remove = false; if(strpos(",{$object->team},", ",$account,") !== false) $remove = false; if(strpos(",{$object->whitelist},", ",$account,") !== false) $remove = false; if($objectType == 'kanban') { $spaceOwner = isset($spaceList[$object->space]->owner) ? $spaceList[$object->space]->owner : ''; $spaceType = isset($spaceList[$object->space]->type) ? $spaceList[$object->space]->type : ''; if(strpos(",$spaceOwner,", ",$account,") !== false) $remove = false; if($spaceType == 'public' && $param != 'involved') $remove = false; } if($remove) unset($objects[$objectID]); } return array_keys($objects); } /** * 创建看板空间。 * Create a space. * * @param object $space * @access public * @return int|false */ public function createSpace(object $space): int|false { $account = $this->app->user->account; if($space->type == 'private') $space->owner = $account; if($space->type != 'private') $this->config->kanban->createspace->requiredFields .= ',owner'; if(strpos(",{$space->team},", ",$account,") === false) $space->team .= ",$account"; if(strpos(",{$space->team},", ",$space->owner,") === false) $space->team .= ",$space->owner"; $this->dao->insert(TABLE_KANBANSPACE)->data($space) ->autoCheck() ->batchCheck($this->config->kanban->createspace->requiredFields, 'notempty') ->exec(); if(dao::isError()) return false; $spaceID = $this->dao->lastInsertID(); $this->dao->update(TABLE_KANBANSPACE)->set('`order`')->eq($spaceID)->where('id')->eq($spaceID)->exec(); $this->loadModel('file')->saveUpload('kanbanspace', $spaceID); $this->file->updateObjectID($this->post->uid, $spaceID, 'kanbanspace'); $this->loadModel('action')->create('kanbanSpace', $spaceID, 'created'); return $spaceID; } /** * 编辑看板空间。 * Update a space. * * @param object $space * @param int $spaceID * @access public * @return bool */ public function updateSpace(object $space, int $spaceID): bool { $oldSpace = $this->getSpaceById($spaceID); if($space->type == 'cooperation' || $space->type == 'public') $space->whitelist = ''; if($space->type != 'private') $this->config->kanban->editspace->requiredFields .= ',owner'; $this->dao->update(TABLE_KANBANSPACE)->data($space) ->autoCheck() ->batchCheck($this->config->kanban->editspace->requiredFields, 'notempty') ->where('id')->eq($spaceID) ->exec(); if($oldSpace->type == 'private' && (in_array($space->type, array('cooperation', 'public')))) { $this->dao->update(TABLE_KANBAN)->set('team = whitelist')->set('whitelist')->eq('')->where('space')->eq($spaceID)->andWhere('deleted')->eq('0')->exec(); } if(dao::isError()) return false; $this->loadModel('file')->saveUpload('kanbanspace', $spaceID); $this->file->updateObjectID($this->post->uid, $spaceID, 'kanbanspace'); $changes = common::createChanges($oldSpace, $space); if($changes) { $actionID = $this->loadModel('action')->create('kanbanSpace', $spaceID, 'edited'); $this->action->logHistory($actionID, $changes); } return true; } /** * 关闭看板空间。 * Close a space. * * @param int $spaceID * @param object $space * @access public * @return bool */ function closeSpace(int $spaceID, object $space): bool { $oldSpace = $this->getSpaceById($spaceID); $this->dao->update(TABLE_KANBANSPACE)->data($space) ->autoCheck() ->where('id')->eq($spaceID) ->exec(); if(dao::isError()) return false; $changes = common::createChanges($oldSpace, $space); if($changes) { $actionID = $this->loadModel('action')->create('kanbanSpace', $spaceID, 'closed', $this->post->comment); $this->action->logHistory($actionID, $changes); } return true; } /** * 激活看板空间。 * Activate a space. * * @param int $spaceID * @param object $space * @access public * @return bool */ function activateSpace(int $spaceID, object $space): bool { $oldSpace = $this->getSpaceById($spaceID); $this->dao->update(TABLE_KANBANSPACE)->data($space) ->autoCheck() ->where('id')->eq($spaceID) ->exec(); if(dao::isError()) return false; $changes = common::createChanges($oldSpace, $space); if($changes) { $actionID = $this->loadModel('action')->create('kanbanSpace', $spaceID, 'activated', $this->post->comment); $this->action->logHistory($actionID, $changes); } return true; } /** * 获取看板泳道键值对。 * Get lane pairs by region id. * * @param array|int $regionID * @param string $type all|story|task|bug|common * @access public * @return array */ public function getLanePairsByRegion(int $regionID, string $type = 'all'): array { return $this->dao->select('id, name')->from(TABLE_KANBANLANE) ->where('deleted')->eq('0') ->andWhere('region')->in($regionID) ->beginIF($type != 'all')->andWhere('type')->eq($type)->fi() ->fetchPairs(); } /** * 获取看板泳道分组。 * Get lane by region id. * * @param array|int $regionID * @param string $type all|story|task|bug|common * @access public * @return array */ public function getLaneGroupByRegion(array|int $regionID, string $type = 'all'): array { return $this->dao->select('*')->from(TABLE_KANBANLANE) ->where('deleted')->eq('0') ->andWhere('region')->in($regionID) ->beginIF($type != 'all')->andWhere('type')->eq($type)->fi() ->fetchGroup('region'); } /** * 创建看板泳道。 * Create a lane. * * @param int $kanbanID * @param int $regionID * @param object $lane * @param string $mode new|copy * @access public * @return int|bool */ public function createLane(int $kanbanID, int $regionID, ?object $lane = null, string $mode = 'new'): int|bool { if($mode == 'new') { $laneType = isset($_POST['laneType']) ? $_POST['laneType'] : 'common'; if($laneType == 'common') { $sameNameLane = $this->dao->select('id')->from(TABLE_KANBANLANE)->where('region')->eq($regionID)->andWhere('name')->eq($lane->name)->andWhere('deleted')->eq('0')->limit(1)->fetch(); if($sameNameLane) { dao::$errors['name'][] = $this->lang->kanbanlane->error->hasExist; return false; } } /* 共享看板列不能为空。 */ if($lane->mode == 'sameAsOther' && $lane->otherLane == 0) { dao::$errors['otherLane'][] = $this->lang->kanbanlane->error->emptyOtherLane; return false; } $maxOrder = $this->dao->select('MAX(`order`) AS maxOrder')->from(TABLE_KANBANLANE) ->where('region')->eq($regionID) ->fetch('maxOrder'); $lane->order = $maxOrder ? $maxOrder + 1 : 1; $lane->type = $laneType; $lane->execution = isset($_POST['laneType']) ? $kanbanID : 0; if($lane->mode == 'sameAsOther') { if($lane->otherLane) $lane->group = $this->dao->select('`group`')->from(TABLE_KANBANLANE)->where('id')->eq($lane->otherLane)->fetch('group'); } elseif($lane->mode == 'independent') { $lane->group = $this->createGroup($kanbanID, $regionID); if($lane->type == 'common') $this->createDefaultColumns($regionID, $lane->group); } } $this->dao->insert(TABLE_KANBANLANE)->data($lane, $skip = 'mode,otherLane') ->batchCheck($this->config->kanban->require->createlane, 'notempty') ->autoCheck() ->exec(); if(dao::isError()) return false; $laneID = $this->dao->lastInsertID(); if($mode == 'new') { if($lane->type != 'common' && isset($lane->mode) && $lane->mode == 'independent') $this->createRDColumn($regionID, $lane->group, $laneID, $lane->type, $kanbanID); if($lane->mode == 'sameAsOther' || ($lane->type == 'common' && $lane->mode == 'independent')) { $columnIDList = $this->dao->select('id')->from(TABLE_KANBANCOLUMN)->where('deleted')->eq(0)->andWhere('archived')->eq(0)->andWhere('`group`')->eq($lane->group)->fetchPairs(); foreach($columnIDList as $columnID) { $this->addKanbanCell($kanbanID, $laneID, $columnID, $lane->type); if(dao::isError()) return false; } } } $this->loadModel('action')->create('kanbanLane', $laneID, 'created'); return $laneID; } /* * 创建看板。 * Create a kanban. * * @param object $kanban * @access public * @return int|false */ public function create(object $kanban): int|false { $account = $this->app->user->account; if($this->post->import == 'on') $kanban->object = implode(',', $this->post->importObjectList); if(strpos(",{$kanban->team},", ",$account,") === false) $kanban->team .= ",$account"; if(strpos(",{$kanban->team},", ",$kanban->owner,") === false) $kanban->team .= ",$kanban->owner"; if(!empty($kanban->space)) { $maxOrder = $this->dao->select('MAX(`order`) AS maxOrder')->from(TABLE_KANBAN) ->where('space')->eq($kanban->space) ->fetch('maxOrder'); $kanban->order = $maxOrder ? $maxOrder + 1 : 1; $space = $this->getSpaceById($kanban->space); if(!empty($space) && $space->type == 'private') $kanban->owner = $account; } $this->kanbanTao->createKanban($kanban); if(dao::isError()) return false; $kanbanID = $this->dao->lastInsertID(); $kanbanInfo = $this->getByID($kanbanID); $this->loadModel('action')->create('kanban', $kanbanID, 'created'); $this->loadModel('file')->saveUpload('kanban', $kanbanID); $this->file->updateObjectID($this->post->uid, $kanbanID, 'kanban'); !empty($kanban->copyRegion) ? $this->copyRegions($kanbanInfo, $kanban->copyKanbanID): $this->createDefaultRegion($kanbanInfo); if(!empty($kanbanInfo->team) || !empty($kanbanInfo->whitelist)) { $type = !empty($kanbanInfo->team) ? 'team' : 'whitelist'; $kanbanMembers = empty($kanbanInfo->{$type}) ? array() : explode(',', $kanbanInfo->{$type}); $this->addSpaceMembers($kanbanInfo->space, $type, $kanbanMembers); } return $kanbanID; } /** * 编辑看板。 * Update a kanban. * * @param int $kanbanID * @param object $kanban * @access public * @return bool */ public function update(int $kanbanID, object $kanban): bool { $oldKanban = $this->getByID($kanbanID); if(strpos(",{$kanban->team},", ",$kanban->owner,") === false) $kanban->team .= ",$kanban->owner"; $this->dao->update(TABLE_KANBAN)->data($kanban) ->autoCheck() ->batchCheck($this->config->kanban->edit->requiredFields, 'notempty') ->where('id')->eq($kanbanID) ->exec(); if(dao::isError()) return false; $this->loadModel('file')->saveUpload('kanban', $kanbanID); $this->file->updateObjectID($this->post->uid, $kanbanID, 'kanban'); if(!empty($kanban->team) || !empty($kanban->whitelist)) { $type = !empty($kanban->team) ? 'team' : 'whitelist'; $kanbanMembers = empty($kanban->{$type}) ? array() : explode(',', $kanban->{$type}); $this->addSpaceMembers($kanban->space, $type, $kanbanMembers); } $changes = common::createChanges($oldKanban, $kanban); $actionID = $this->loadModel('action')->create('kanban', $kanbanID, 'edited'); $this->action->logHistory($actionID, $changes); return true; } /** * 设置看板。 * Setting kanban. * * @param int $kanbanID * @param object $kanban * @access public * @return bool */ public function setting(int $kanbanID, object $kanban): bool { $oldKanban = $this->getByID($kanbanID); if($this->post->import == 'off') $kanban->object = ''; if($this->post->heightType == 'auto') $kanban->displayCards = 0; if(empty($kanban->displayCards)) $kanban->displayCards = 0; if($this->post->import == 'on') $kanban->object = $this->post->importObjectList ? implode(',', $this->post->importObjectList) : ''; if(isset($_POST['heightType']) && $this->post->heightType == 'custom' && !$this->checkDisplayCards($kanban->displayCards)) return false; $this->dao->update(TABLE_KANBAN)->data($kanban) ->autoCheck() ->batchCheck($this->config->kanban->edit->requiredFields, 'notempty') ->checkIF(!$kanban->fluidBoard, 'colWidth', 'ge', $this->config->minColWidth) ->batchCheckIF($kanban->fluidBoard, 'minColWidth', 'ge', $this->config->minColWidth) ->checkIF($kanban->minColWidth >= $this->config->minColWidth && $kanban->fluidBoard, 'maxColWidth', 'gt', $kanban->minColWidth) ->where('id')->eq($kanbanID) ->exec(); if(dao::isError()) return false; $changes = common::createChanges($oldKanban, $kanban); if($changes) { $actionID = $this->loadModel('action')->create('kanban', $kanbanID, 'edited'); $this->action->logHistory($actionID, $changes); } return true; } /** * 激活看板。 * Activate a kanban. * * @param int $kanbanID * @param object $kanban * @access public * @return bool */ function activate(int $kanbanID, object $kanban): bool { $oldKanban = $this->getByID($kanbanID); $this->dao->update(TABLE_KANBAN)->data($kanban) ->autoCheck() ->where('id')->eq($kanbanID) ->exec(); if(dao::isError()) return false; $changes = common::createChanges($oldKanban, $kanban); $actionID = $this->loadModel('action')->create('kanban', $kanbanID, 'activated', $this->post->comment); $this->action->logHistory($actionID, $changes); return true; } /** * 关闭看板。 * Close a kanban. * * @param int $kanbanID * @param object $kanban * @access public * @return bool */ function close(int $kanbanID, object $kanban): bool { $oldKanban = $this->getByID($kanbanID); $this->dao->update(TABLE_KANBAN)->data($kanban) ->autoCheck() ->where('id')->eq($kanbanID) ->exec(); if(dao::isError()) return false; $changes = common::createChanges($oldKanban, $kanban); if($changes) { $actionID = $this->loadModel('action')->create('kanban', $kanbanID, 'closed', $this->post->comment); $this->action->logHistory($actionID, $changes); } return true; } /* 执行看板新增列和泳道。 * Check whether kanban lane exists. * * @param object $execution * @access public * @return void */ public function createLaneIfNotExist(object $execution): void { $kanbanColumns = $this->dao->select('*')->from(TABLE_KANBANLANE) ->where('execution')->eq($execution->id) ->andWhere('deleted')->eq(0) ->fetchAll('type'); $types = array_keys($kanbanColumns); /* 如果没有任意一种类型的 kanban, 生成一份新的 */ if(empty($types)) { $this->createExecutionLane($execution, 'all'); } else { /* 如果缺少某种类型的 kanban,单独生成缺少的 */ $kanbanTypes = $this->lang->kanban->type; unset($kanbanTypes['all']); unset($kanbanTypes['epic']); unset($kanbanTypes['requirement']); foreach($kanbanTypes as $type => $typeName) { if(!in_array($type, $types)) $this->createExecutionLane($execution, $type); } } } /** * 执行看板新增列和泳道。 * Add execution Kanban lanes and columns. * * @param object $execution * @param string $type all|parentStory|story|bug|task|risk * @access public * @return void */ public function createExecutionLane(object $execution, string $type = 'all'): bool { if(!$execution) return false; /* e.g $defaults = array('risk' => (object)array('name' => 'risk', 'color' => '#FF0000', 'order' => 20)); */ $defaults = ($type != 'all') ? array($type => $this->config->kanban->default->$type) : (array)$this->config->kanban->default; $ERURLanes = array(); $parentStoryLaneID = 0; foreach($defaults as $type => $lane) { /* 只有综合、需求、设计阶段,才可关联业需、用需。 */ if($execution->type != 'stage' && !in_array($execution->attribute, array('mix', 'request', 'design')) && in_array($type, array('epic', 'requirement'))) continue; $lane->type = $type; $lane->execution = $execution->id; $lane->region = 0; $lane->group = 0; $lane->groupby = ''; $lane->extra = ''; $this->dao->insert(TABLE_KANBANLANE)->data($lane)->exec(); $laneID = $this->dao->lastInsertId(); if($type == 'parentStory') $parentStoryLaneID = $laneID; if(in_array($type, array('epic', 'requirement'))) { $ERURLanes[$type] = $laneID; } else { if($type != 'risk') $this->createExecutionColumns($laneID, $type, $execution->id); else $this->createExecutionRiskColumns($laneID, $type, $execution->id); } } if($ERURLanes) { $columnIDList = $this->dao->select('`column`')->from(TABLE_KANBANCELL)->where('lane')->eq($parentStoryLaneID)->fetchPairs(); foreach($ERURLanes as $type => $laneID) { foreach($columnIDList as $columnID) { $this->addKanbanCell($execution->id, $laneID, $columnID, $type); if(dao::isError()) return false; } } } return true; } /** * 创建执行看板列。 * Create execution columns. * * @param int|array $laneID * @param string $type story|bug|task * @param int $executionID * @access public * @return void */ public function createExecutionColumns(int|array $laneID, string $type, int $executionID): void { $designColumnID = $devColumnID = $testColumnID = $resolvingColumnID = 0; $columns = array(); if(in_array($type, array('epic', 'requirement', 'parentStory'))) $columns = $this->lang->kanban->ERURColumn; if($type == 'story') $columns = $this->lang->kanban->storyColumn; if($type == 'bug') $columns = $this->lang->kanban->bugColumn; if($type == 'task') $columns = $this->lang->kanban->taskColumn; if(empty($columns)) return; foreach($columns as $colType => $name) { $data = new stdclass(); $data->name = $name; $data->color = '#333'; $data->type = $colType; $data->region = 0; if(str_contains(',designing,designed,', ",{$colType},")) $data->parent = $designColumnID; if(str_contains(',developing,developed,', ",{$colType},")) $data->parent = $devColumnID; if(str_contains(',testing,tested,', ",{$colType},")) $data->parent = $testColumnID; if(str_contains(',fixing,fixed,', ",{$colType},")) $data->parent = $resolvingColumnID; if(str_contains(',resolving,develop,test,', ",{$colType},")) $data->parent = -1; $this->dao->insert(TABLE_KANBANCOLUMN)->data($data)->exec(); $colID = $this->dao->lastInsertId(); if($colType == 'design') $designColumnID = $colID; if($colType == 'develop') $devColumnID = $colID; if($colType == 'test') $testColumnID = $colID; if($colType == 'resolving') $resolvingColumnID = $colID; if(is_array($laneID)) { foreach($laneID as $id) $this->addKanbanCell($executionID, $id, $colID, $type); } else { $this->addKanbanCell($executionID, $laneID, $colID, $type); } } } /** * 新增看板Cell。 * Add kanban cell for new lane. * * @param int $kanbanID * @param int $laneID * @param int $colID * @param string $type story|task|bug|card * @param string $cardID * @access public * @return void */ public function addKanbanCell(int $kanbanID, int $laneID, int $colID, string $type, string $cardID = ''): void { $cell = $this->dao->select('id, cards')->from(TABLE_KANBANCELL) ->where('kanban')->eq($kanbanID) ->andWhere('lane')->eq($laneID) ->andWhere('`column`')->eq($colID) ->andWhere('type')->eq($type) ->fetch(); if(empty($cell)) { $cell = new stdclass(); $cell->kanban = $kanbanID; $cell->lane = $laneID; $cell->column = $colID; $cell->type = $type; $cell->cards = $cardID ? ",$cardID," : ''; $this->dao->insert(TABLE_KANBANCELL)->data($cell)->exec(); } else { $cell->cards = $cell->cards ? ",$cardID" . $cell->cards : ",$cardID,"; $this->dao->update(TABLE_KANBANCELL)->set('cards')->eq($cell->cards)->where('id')->eq($cell->id)->exec(); } } /** * 向看板空间添加成员。 * Add space members. * * @param int $spaceID * @param string $type team|whitelist * @param array $kanbanMembers * @access public * @return void */ public function addSpaceMembers(int $spaceID, string $type, array $kanbanMembers = array()): void { $space = $this->getSpaceById($spaceID); if(empty($space)) return; $spaceMembers = empty($space->{$type}) ? array() : explode(',', $space->{$type}); $members = $space->{$type}; $addMembers = array_diff($kanbanMembers, $spaceMembers); if(!empty($addMembers)) { $addMembers = implode(',', $addMembers); $members .= ',' . trim($addMembers, ','); $this->dao->update(TABLE_KANBANSPACE)->set($type)->eq($members)->where('id')->eq($spaceID)->exec(); } } /** * 从看板格子中移除卡片。 * Remove kanban cell. * * @param string $type * @param int|array $removeCardID * @param array $kanbanList * @access public * @return void */ public function removeKanbanCell(string $type, int|array $removeCardID, array $kanbanList): void { $removeIDList = is_array($removeCardID) ? $removeCardID : array($removeCardID); foreach($removeIDList as $cardID) { if(empty($cardID)) continue; $this->dao->update(TABLE_KANBANCELL)->set("cards = REPLACE(cards, ',$cardID,', ',')")->where('type')->eq($type)->andWhere('kanban')->eq($kanbanList[$cardID])->exec(); } $this->dao->update(TABLE_KANBANCELL) ->set('cards')->eq('') ->where('cards')->eq(',') ->andWhere('type')->eq($type) ->andWhere('kanban')->in($kanbanList) ->exec(); } /** * 创建默认的专业研发看板。 * Create a default RD kanban. * * @param object $execution * @access public * @return bool */ public function createRDKanban(object $execution): bool { $regionID = $this->createRDRegion($execution); if(dao::isError()) return false; $this->createGroup((int)$execution->id, $regionID); if(dao::isError()) return false; $this->createRDLane((int)$execution->id, $regionID); if(dao::isError()) return false; return true; } /** * 创建默认的专业研发看板区域。 * Create a default RD region. * * @param object $execution * * @access public * @return int|bool */ public function createRDRegion(object $execution): int|bool { $region = new stdclass(); $region->name = $this->lang->kanbanregion->default; $region->kanban = $execution->id; $region->createdBy = $this->app->user->account; $region->createdDate = helper::now(); $region->order = 1; $this->dao->insert(TABLE_KANBANREGION)->data($region) ->check('name', 'unique', "kanban={$execution->id} AND deleted='0' AND space = '0'") ->autoCheck() ->exec(); if(dao::isError()) return false; return $this->dao->lastInsertId(); } /** * 创建默认的专业研发看板泳道。 * Create default RD lanes. * * @param int $executionID * @param int $regionID * * @access public * @return bool */ public function createRDLane(int $executionID, int $regionID): bool { $execution = $this->loadModel('execution')->fetchByID($executionID); $project = $this->loadModel('project')->fetchByID($execution->project); $index = 0; foreach($this->lang->kanban->laneTypeList as $type => $name) { $groupID = $this->createGroup($executionID, $regionID); if(dao::isError()) return false; $lane = new stdclass(); $lane->execution = $executionID; $lane->type = $type; $lane->region = $regionID; $lane->group = $groupID; $lane->name = $name; $lane->color = $this->config->kanban->laneColorList[$index]; $lane->order = $this->config->kanban->RDLaneOrder[$type]; $this->dao->insert(TABLE_KANBANLANE)->data($lane)->autoCheck()->exec(); if(dao::isError()) return false; $laneID = $this->dao->lastInsertId(); $this->createRDColumn($regionID, $groupID, $laneID, $type, $executionID); $index ++; } return true; } /** * 创建默认值的专业研发看板列。 * Create default RD columns. * * @param int $regionID * @param int $groupID * @param int $laneID * @param string $laneType * @param int $executionID * * @access public * @return bool */ public function createRDColumn(int $regionID, int $groupID, int $laneID, string $laneType, int $executionID): bool { $designColumnID = $devColumnID = $testColumnID = $resolvingColumnID = 0; if($laneType == 'parentStory') $columnList = $this->lang->kanban->ERURColumn; if($laneType == 'story') $columnList = $this->lang->kanban->storyColumn; if($laneType == 'bug') $columnList = $this->lang->kanban->bugColumn; if($laneType == 'task') $columnList = $this->lang->kanban->taskColumn; foreach($columnList as $type => $name) { $data = new stdclass(); $data->name = $name; $data->color = '#333'; $data->type = $type; $data->group = $groupID; $data->region = $regionID; if(strpos(',designing,designed,', $type) !== false) $data->parent = $designColumnID; if(strpos(',developing,developed,', $type) !== false) $data->parent = $devColumnID; if(strpos(',testing,tested,', $type) !== false) $data->parent = $testColumnID; if(strpos(',fixing,fixed,', $type) !== false) $data->parent = $resolvingColumnID; if(strpos(',design,develop,test,resolving,', $type) !== false) $data->parent = -1; $this->dao->insert(TABLE_KANBANCOLUMN)->data($data)->exec(); if(dao::isError()) return false; $columnID = $this->dao->lastInsertId(); if($type == 'design') $designColumnID = $columnID; if($type == 'develop') $devColumnID = $columnID; if($type == 'test') $testColumnID = $columnID; if($type == 'resolving') $resolvingColumnID = $columnID; $this->addKanbanCell($executionID, $laneID, $columnID, $laneType); } return true; } /** * 编辑区域。 * Update a region. * * @param int $regionID * @access public * @return bool */ public function updateRegion(int $regionID): bool { $region = new stdclass(); $region->lastEditedBy = $this->app->user->account; $region->lastEditedDate = helper::now(); $region->name = trim($this->post->name); $oldRegion = $this->getRegionById($regionID); $this->dao->update(TABLE_KANBANREGION)->data($region) ->autoCheck() ->batchcheck($this->config->kanban->editregion->requiredFields, 'notempty') ->where('id')->eq($regionID) ->exec(); if(dao::isError()) return false; $changes = common::createChanges($oldRegion, $region); if($changes) { $actionID = $this->loadModel('action')->create('kanbanregion', $regionID, 'edited'); $this->action->logHistory($actionID, $changes); } return true; } /** * 编辑看板泳道。 * Update kanban lane. * * @param int $executionID * @param string $laneType * @param int $cardID * @access public * @return void */ public function updateLane(int $executionID, string $laneType = '', int $cardID = 0): void { $execution = $this->loadModel('execution')->getByID($executionID); if($execution->type == 'kanban') { $lanes = $this->dao->select('t2.*')->from(TABLE_KANBANREGION)->alias('t1') ->leftJoin(TABLE_KANBANLANE)->alias('t2')->on('t1.id=t2.region') ->leftJoin(TABLE_KANBANCELL)->alias('t3')->on('t2.id=t3.lane') ->where('t1.deleted')->eq(0) ->andWhere('t2.deleted')->eq(0) ->andWhere('t1.kanban')->eq($executionID) ->andWhere('t2.execution')->eq($executionID) ->beginIF($laneType)->andWhere('t2.type')->eq($laneType)->fi() ->beginIF(!empty($cardID))->andWhere('t3.cards')->like("%,$cardID,%")->fi() ->orderBy('t1.`order` asc, t2.`order` asc') ->fetchAll('id'); if(count($lanes) > 1) $lanes = array_slice($lanes, 0, 2); } else { $lanes = $this->dao->select('*')->from(TABLE_KANBANLANE) ->where('execution')->eq($executionID) ->beginIF($laneType)->andWhere('type')->eq($laneType)->fi() ->fetchAll('id'); } foreach($lanes as $lane) $this->refreshCards((array)$lane); } /** * 更新看板泳道上的卡片。 * Refresh column cards. * * @param array $lane * @access public * @return void */ public function refreshCards(array $lane): void { if(common::isTutorialMode()) return; $laneID = zget($lane, 'id'); $laneType = zget($lane, 'type'); $executionID = zget($lane, 'execution'); $otherCardList = ''; $otherLanes = $this->dao->select('t2.id, t2.cards')->from(TABLE_KANBANLANE)->alias('t1') ->leftJoin(TABLE_KANBANCELL)->alias('t2')->on('t1.id=t2.lane') ->where('t1.id')->ne($laneID) ->andWhere('t1.execution')->eq($executionID) ->andWhere('t2.`type`')->eq($laneType) ->fetchPairs(); foreach($otherLanes as $cardIDList) { $cardIDList = trim($cardIDList, ','); if(!empty($cardIDList)) $otherCardList .= ',' . $cardIDList; } $cardPairs = $this->dao->select('t2.type, t1.cards')->from(TABLE_KANBANCELL)->alias('t1') ->leftJoin(TABLE_KANBANCOLUMN)->alias('t2')->on('t1.`column` = t2.id') ->where('t1.kanban')->eq($executionID) ->andWhere('t1.lane')->eq($laneID) ->fetchPairs(); if(empty($cardPairs)) return; $sourceCards = $cardPairs; if(in_array($laneType, array('epic', 'requirement', 'parentStory'))) $cardPairs = $this->kanbanTao->refreshERURCards($cardPairs, $executionID, $otherCardList, $laneType); if($laneType == 'story') $cardPairs = $this->kanbanTao->refreshStoryCards($cardPairs, $executionID, $otherCardList); if($laneType == 'bug') $cardPairs = $this->kanbanTao->refreshBugCards($cardPairs, $executionID, $otherCardList); if($laneType == 'task') $cardPairs = $this->kanbanTao->refreshTaskCards($cardPairs, $executionID, $otherCardList); if($laneType == 'risk' && in_array($this->config->edition, array('max', 'ipd'))) $cardPairs = $this->refreshRiskCards($cardPairs, $executionID, $otherCardList); $colPairs = $this->dao->select('t2.type, t2.id')->from(TABLE_KANBANCELL)->alias('t1') ->leftJoin(TABLE_KANBANCOLUMN)->alias('t2')->on('t1.`column` = t2.id') ->where('t1.kanban')->eq($executionID) ->andWhere('t1.lane')->eq($laneID) ->fetchPairs(); $updated = false; foreach($cardPairs as $colType => $cards) { if(!isset($colPairs[$colType])) continue; if($sourceCards[$colType] == $cards) continue; $this->dao->update(TABLE_KANBANCELL)->set('cards')->eq($cards)->where('lane')->eq($laneID)->andWhere('`column`')->eq($colPairs[$colType])->exec(); if(!$updated) $updated = true; } if($updated) $this->dao->update(TABLE_KANBANLANE)->set('lastEditedTime')->eq(helper::now())->where('id')->eq($laneID)->exec(); } /** * 编辑看板列。 * Update column. * * @param int $columnID * @param object $column * @access public * @return array|false */ public function updateColumn(int $columnID, object $column): array|false { $oldColumn = $this->getColumnById($columnID); $this->dao->update(TABLE_KANBANCOLUMN)->data($column) ->autoCheck() ->batchcheck($this->config->kanban->setColumn->requiredFields, 'notempty') ->where('id')->eq($columnID) ->exec(); if(dao::isError()) return false; $changes = common::createChanges($oldColumn, $column); return $changes; } /** * 激活卡片。 * Activate a card. * * @param int $cardID * @access public * @return bool */ public function activateCard(int $cardID): bool { if($this->post->progress >= 100 || $this->post->progress < 0) { dao::$errors['progress'][] = $this->lang->kanbancard->error->progressIllegal; return false; } $oldCard = $this->getCardByID($cardID); $this->dao->update(TABLE_KANBANCARD)->set('progress')->eq($this->post->progress ? $this->post->progress : 0)->set('status')->eq('doing')->where('id')->eq($cardID)->exec(); $card = $this->getCardByID($cardID); $changes = common::createChanges($oldCard, $card); $actionID = $this->loadModel('action')->create('kanbanCard', $cardID, 'activated'); $this->action->logHistory($actionID, $changes); return true; } /** * 编辑看板卡片。 * Update a card. * * @param int $cardID * @param object $card * @access public * @return bool */ public function updateCard(int $cardID, object $card): bool { if($card->estimate < 0) { dao::$errors['estimate'] = $this->lang->kanbancard->error->recordMinus; return false; } if($card->end && ($card->begin > $card->end)) { dao::$errors['end'] = $this->lang->kanbancard->error->endSmall; return false; } if($card->progress > 100 || $card->progress < 0) { dao::$errors['progress'] = $this->lang->kanbancard->error->progressIllegal; return false; } $oldCard = $this->getCardByID($cardID); if(!empty($this->post->assignedTo) && $oldCard->assignedTo != $this->post->assignedTo) $card->assignedDate = helper::now(); $card->status = $card->progress == 100 ? 'done' : 'doing'; $card = $this->loadModel('file')->processImgURL($card, $this->config->kanban->editor->editcard['id'], $this->post->uid); $this->dao->update(TABLE_KANBANCARD)->data($card) ->autoCheck() ->checkIF($card->estimate != '', 'estimate', 'float') ->batchcheck($this->config->kanban->editcard->requiredFields, 'notempty') ->where('id')->eq($cardID) ->exec(); if(dao::isError()) return false; $this->file->saveUpload('kanbancard', $cardID); $this->file->updateObjectID($this->post->uid, $cardID, 'kanbancard'); $changes = common::createChanges($oldCard, $card); if($changes) { $actionID = $this->loadModel('action')->create('kanbanCard', $cardID, 'edited'); $this->action->logHistory($actionID, $changes); } return true; } /** * 设置看板在制品限制。 * Set WIP limit. * * @param int $columnID * @param object $WIP * @access public * @return bool */ public function setWIP(int $columnID, object $WIP): bool { $oldColumn = $this->getColumnById($columnID); if(!preg_match("/^-?\d+$/", (string)$WIP->limit) || (!$WIP->noLimit && $WIP->limit <= 0)) { dao::$errors['limit'] = $this->lang->kanban->error->mustBeInt; return false; } /* Check column limit. */ $sumChildLimit = 0; if($oldColumn->parent == -1 && $WIP->limit != -1) { $childColumns = $this->dao->select('id,`limit`')->from(TABLE_KANBANCOLUMN)->where('parent')->eq($columnID)->andWhere('deleted')->eq(0)->fetchAll(); foreach($childColumns as $childColumn) $sumChildLimit += $childColumn->limit; if($sumChildLimit > $WIP->limit) { dao::$errors['limit'] = $this->lang->kanban->error->parentLimitNote; } } elseif($oldColumn->parent > 0) { $parentColumn = $this->getColumnByID($oldColumn->parent); if($parentColumn->limit != -1) { $siblingLimit = $this->dao->select('`limit`')->from(TABLE_KANBANCOLUMN) ->where('`parent`')->eq($oldColumn->parent) ->andWhere('id')->ne($columnID) ->fetch('limit'); $sumChildLimit = (int)$siblingLimit + (int)$WIP->limit; if($WIP->limit == -1 || $siblingLimit == -1 || $sumChildLimit > $parentColumn->limit) { dao::$errors['limit'] = $this->lang->kanban->error->childLimitNote; } } } if(dao::isError()) return false; $this->dao->update(TABLE_KANBANCOLUMN)->data($WIP, 'noLimit') ->autoCheck() ->checkIF($WIP->limit != -1, 'limit', 'gt', 0) ->where('id')->eq($columnID) ->exec(); return !dao::isError(); } /** * 设置看板列。 * Set lane info. * * @param int $laneID * @param object $lane * @access public * @return bool */ public function setLane(int $laneID, object $lane): bool { $this->dao->update(TABLE_KANBANLANE)->data($lane) ->autoCheck() ->batchcheck($this->config->kanban->setlane->requiredFields, 'notempty') ->where('id')->eq($laneID) ->exec(); return !dao::isError(); } /** * 获取看板右上角的工具栏。 * Get kanban pageToolBar. * * @param object $kanban * @access public * @return string */ public function getPageToolBar(object $kanban) { $actions = ''; $actions .= " {$this->lang->kanban->fullScreen}"; $printKanbanBtn = (common::hasPriv('kanban', 'edit') || ($kanban->status == 'active' && common::hasPriv('kanban', 'close')) || common::hasPriv('kanban', 'delete') || ($kanban->status == 'closed' && common::hasPriv('kanban', 'activate'))); if($printKanbanBtn) { $actions .= "" . ' ' . $this->lang->edit . ''; $actions .= ""; $columnActions = ''; $actions .= $columnActions; $commonActions = ''; if($columnActions && $commonActions) { $actions .= "
  • "; } $actions .= $commonActions; $kanbanActions = ''; if(common::hasPriv('kanban', 'edit')) $kanbanActions .= ''; if(common::hasPriv('kanban', 'close') && $kanban->status == 'active') $kanbanActions .= ''; if(common::hasPriv('kanban', 'activate') && $kanban->status == 'closed') $kanbanActions .= ''; if(common::hasPriv('kanban', 'delete')) $kanbanActions .= ''; if($commonActions && $kanbanActions) { $actions .= "
  • "; } $actions .= $kanbanActions; $actions .= "
    "; } if(common::hasPriv('kanban', 'setting')) { $actions .= html::a(helper::createLink('kanban', 'setting', "kanbanID=$kanban->id", '', true), ' ' . $this->lang->kanban->setting, '', "class='toolbar-item ghost btn btn-default' data-toggle='modal'"); } return $actions; } /** * 排序看板分组。 * Sort kanban group. * * @param int $region * @param array $groups * @access public * @return bool */ public function sortGroup(int $region, array $groups): bool { $groupList = $this->getGroupList($region); $order = 1; foreach($groups as $groupID) { if(!$groupID) continue; if(!isset($groupList[$groupID])) continue; $this->dao->update(TABLE_KANBANGROUP)->set('`order`')->eq($order)->where('id')->eq($groupID)->exec(); $order++; } return !dao::isError(); } /** * 移动看板卡片。 * Move a card. * * @param int $cardID * @param int $fromColID * @param int $toColID * @param int $fromLaneID * @param int $toLaneID * @param int $kanbanID * @access public * @return void */ public function moveCard(int $cardID, int $fromColID, int $toColID, int $fromLaneID, int $toLaneID, int $kanbanID = 0) { $groupBy = ($this->session->execGroupBy && ($this->app->tab == 'execution' || $this->config->vision == 'lite')) ? $this->session->execGroupBy : ''; $fromCell = $this->dao->select('id,cards,lane')->from(TABLE_KANBANCELL) ->where('`column`')->eq($fromColID) ->beginIF((!$groupBy || $groupBy == 'default') && $fromLaneID)->andWhere('lane')->eq($fromLaneID)->fi() ->beginIF($groupBy && $groupBy != 'default') ->andWhere('type')->eq($this->session->execLaneType) ->andWhere('cards')->like("%,$cardID,%") ->fi() ->fetch(); if($groupBy && $groupBy != 'default') $fromLaneID = $toLaneID = $fromCell->lane; if(empty($fromLaneID) && $fromCell) $fromLaneID = $toLaneID = $fromCell->lane; $fromCells[$fromCell->id] = $fromCell; /* Remove all cells with cardID in fromCell. */ $fromLane = $this->getLaneById($fromLaneID); $fromCells += $this->dao->select('t1.id as id,t1.cards,t1.lane')->from(TABLE_KANBANCELL)->alias('t1') ->leftJoin(TABLE_KANBANLANE)->alias('t2')->on('t1.lane=t2.id') ->where('t1.type')->eq($fromLane->type) ->andWhere('t1.id')->ne($fromCell->id) ->andWhere('t2.region')->eq($fromLane->region) ->andWhere('t1.cards')->like("%,$cardID,%") ->fetchAll('id'); foreach($fromCells as $fromCell) { $fromCellCards = explode(',', $fromCell->cards); $fromCellCards = array_unique($fromCellCards); $fromCellCards = array_filter($fromCellCards); $fromCellCards = implode(',', $fromCellCards); $fromCardList = str_replace(",$cardID,", ',', ",$fromCellCards,"); if($fromCardList == ',') $fromCardList = ''; $this->dao->update(TABLE_KANBANCELL)->set('cards')->eq($fromCardList)->where('id')->eq($fromCell->id)->exec(); } /* Add cardID to toCell. */ $toCell = $this->dao->select('*')->from(TABLE_KANBANCELL)->where('lane')->eq($toLaneID)->andWhere('`column`')->eq($toColID)->fetch(); $toCellCards = $this->dao->select('cards')->from(TABLE_KANBANCELL)->where('lane')->eq($toLaneID)->andWhere('`column`')->eq($toColID)->fetch('cards'); $kanbanID = $kanbanID == 0 ? $toCell->kanban : $kanbanID; if(!$toCell) $this->addKanbanCell($kanbanID, $toLaneID, $toColID, 'common'); $toCardList = rtrim($toCellCards, ',') . ",$cardID,"; $this->dao->update(TABLE_KANBANCELL)->set('cards')->eq($toCardList)->where('`column`')->eq($toColID)->andWhere('lane')->eq($toLaneID)->exec(); $toLane = $this->getLaneById($toLaneID); $this->dao->update(TABLE_KANBANCARD)->set('group')->eq($toLane->group)->where('id')->eq($cardID)->exec(); } /** * 更新卡片的颜色。 * Update a card's color. * * @param int $cardID * @param string $color * @access public * @return void */ public function updateCardColor(int $cardID, string $color) { $this->dao->update(TABLE_KANBANCARD)->set('`color`')->eq('#' . $color)->where('id')->eq($cardID)->exec(); } /** * 归档看板列。 * Archive a column. * * @param int $columnID * @access public * @return void */ public function archiveColumn(int $columnID) { $this->dao->update(TABLE_KANBANCOLUMN) ->set('archived')->eq(1) ->where('id')->eq($columnID) ->exec(); if(!dao::isError()) $this->loadModel('action')->create('kanbancolumn', $columnID, 'archived'); } /** * 还原看板列。 * Restore a column. * * @param int $columnID * @access public * @return void */ public function restoreColumn(int $columnID) { $column = $this->getColumnByID($columnID); if($column->parent) { $parent = $this->getColumnByID($column->parent); /* If the parent column is normal now, put its card into child column. */ if($parent && $parent->parent != -1) { $parentCells = $this->dao->select('*')->from(TABLE_KANBANCELL) ->where('`column`')->eq($column->parent) ->andWhere('type')->eq('common') ->fetchAll('id'); foreach($parentCells as $cell) { $cards = $this->dao->select('cards')->from(TABLE_KANBANCELL) ->where('lane')->eq($cell->lane) ->andWhere('`column`')->eq($columnID) ->andWhere('type')->eq('common') ->fetch('cards'); $cards = $cards ? $cards . ltrim($cell->cards, ',') : $cell->cards; $this->dao->update(TABLE_KANBANCELL)->set('cards')->eq($cards) ->where('lane')->eq($cell->lane) ->andWhere('`column`')->eq($columnID) ->andWhere('type')->eq('common') ->exec(); $this->dao->update(TABLE_KANBANCELL)->set('cards')->eq('') ->where('lane')->eq($cell->lane) ->andWhere('`column`')->eq($cell->column) ->andWhere('type')->eq('common') ->exec(); } $this->dao->update(TABLE_KANBANCOLUMN)->set('parent')->eq(-1)->where('id')->eq($column->parent)->exec(); } } $this->dao->update(TABLE_KANBANCOLUMN) ->set('archived')->eq(0) ->where('id')->eq($columnID) ->exec(); if(!dao::isError()) $this->loadModel('action')->create('kanbancolumn', $columnID, 'restore'); } /** * 归档看板卡片。 * Archive a card. * * @param int $cardID * @access public * @return bool */ public function archiveCard(int $cardID): bool { $oldCard = $this->getCardByID($cardID); $this->dao->update(TABLE_KANBANCARD) ->set('archived')->eq(1) ->set('archivedBy')->eq($this->app->user->account) ->set('archivedDate')->eq(helper::now()) ->where('id')->eq($cardID) ->exec(); $card = $this->getCardByID($cardID); if(dao::isError()) return false; $changes = common::createChanges($oldCard, $card); if($changes) { $actionID = $this->loadModel('action')->create('kanbancard', $cardID, 'archived'); $this->action->logHistory($actionID, $changes); } return true; } /** * 还原看板卡片。 * Restore a card. * * @param int $cardID * @access public * @return bool */ public function restoreCard(int $cardID): bool { $oldCard = $this->getCardByID($cardID); $this->dao->update(TABLE_KANBANCARD) ->set('archived')->eq(0) ->set('archivedBy')->eq('') ->set('archivedDate')->eq(null) ->where('id')->eq($cardID) ->exec(); if(dao::isError()) return false; $card = $this->getCardByID($cardID); $changes = common::createChanges($oldCard, $card); if($changes) { $actionID = $this->loadModel('action')->create('kanbancard', $cardID, 'restore'); $this->action->logHistory($actionID, $changes); } return true; } /** * 删除子列时,将子列的卡片移动到第一个子列,或者移动到父列。 * Process cards when delete a column. * * @param object $column * @access public * @return void */ public function processCards(object $column) { $firstColumnID = $this->dao->select('id')->from(TABLE_KANBANCOLUMN) ->where('parent')->eq($column->parent) ->andWhere('id')->ne($column->id) ->andWhere('deleted')->eq('0') ->andWhere('archived')->eq('0') ->orderBy('`order` asc') ->fetch('id'); $extendID = $firstColumnID; if(!$firstColumnID) { $extendID = $column->parent; $this->dao->update(TABLE_KANBANCOLUMN)->set('parent')->eq(0)->where('id')->eq($column->parent)->exec(); } $cells = $this->dao->select('*')->from(TABLE_KANBANCELL) ->where('`column`')->eq($column->id) ->andWhere('type')->eq('common') ->fetchAll('id', false); foreach($cells as $cell) { $extendCards = $this->dao->select('cards')->from(TABLE_KANBANCELL) ->where('lane')->eq($cell->lane) ->andWhere('`column`')->eq($extendID) ->andWhere('type')->eq('common') ->fetch('cards'); $extendCards = $extendCards ? $extendCards . ltrim($cell->cards, ',') : $cell->cards; $this->dao->update(TABLE_KANBANCELL)->set('cards')->eq($extendCards) ->where('lane')->eq($cell->lane) ->andWhere('`column`')->eq($extendID) ->andWhere('type')->eq('common') ->exec(); } } /** * 获取看板空间。 * Get space by id. * * @param int $spaceID * @access public * @return object */ public function getSpaceById(int $spaceID) { $space = $this->dao->findById($spaceID)->from(TABLE_KANBANSPACE)->fetch(); if($space) $space = $this->loadModel('file')->replaceImgURL($space, 'desc'); return $space; } /** * Get kanban group by space id list. * * @param array $spaceIdList * @param array $kanbanIdList * @access public * @return array */ public function getGroupBySpaceList(array $spaceIdList, array $kanbanIdList = array()): array { $spaceList = $this->dao->select('*')->from(TABLE_KANBAN) ->where('deleted')->eq(0) ->andWhere('space')->in($spaceIdList) ->beginIF($kanbanIdList)->andWhere('id')->in($kanbanIdList)->fi() ->fetchGroup('space', 'id'); $kanbanIDList = array(); foreach($spaceList as $kanbanList) $kanbanIDList = array_merge_recursive($kanbanIDList, array_keys($kanbanList)); $cardsCount = $this->dao->select('kanban, COUNT(1) AS count')->from(TABLE_KANBANCARD) ->where('deleted')->eq(0) ->andWhere('kanban')->in($kanbanIDList) ->groupBy('kanban') ->fetchPairs('kanban'); foreach($spaceList as $kanbanList) { foreach($kanbanList as $kanban) { $kanban->cardsCount = zget($cardsCount, $kanban->id, 0); } } return $spaceList; } /** * 获取看板组。 * Get group list by region. * * @param int $regionID * @access public * @return array */ public function getGroupList(int $regionID): array { return $this->dao->select('*')->from(TABLE_KANBANGROUP) ->where('region')->eq($regionID) ->orderBy('order') ->fetchAll('id'); } /** * 获取看板列。 * Get column by id. * * @param int $columnID * @access public * @return object */ public function getColumnByID(int $columnID) { if(common::isTutorialMode()) return $this->loadModel('tutorial')->getColumn(); $column = $this->dao->select('t1.*, t2.type as laneType')->from(TABLE_KANBANCOLUMN)->alias('t1') ->leftJoin(TABLE_KANBANCELL)->alias('t2')->on('t1.id=t2.column') ->where('t1.id')->eq($columnID) ->fetch(); if(empty($column)) return false; if($column->parent > 0) $column->parentName = $this->dao->findById($column->parent)->from(TABLE_KANBANCOLUMN)->fetch('name'); return $column; } /** * 根据字段获取看板列。 * Get columns by field. * * @param string $field parent|region|group * @param int $fieldID * @param string $archived * @param string $deleted * @access public * @return array */ public function getColumnsByField(string $field = '', int $fieldID = 0, string $archived = '0', string $deleted = '0'): array { return $this->dao->select('*')->from(TABLE_KANBANCOLUMN) ->where('1 = 1') ->beginIF($field)->andWhere($field)->eq($fieldID)->fi() ->beginIF($archived != '')->andWhere('archived')->eq($archived)->fi() ->beginIF($deleted != '')->andWhere('deleted')->eq($deleted)->fi() ->orderBy('order') ->fetchAll('id'); } /** * 获取泳道的看板列ID。 * Get column ID by lane ID. * * @param int $laneID * @param string $columnType * @access public * @return string|int */ public function getColumnIDByLaneID(int $laneID, string $columnType) { return $this->dao->select('t1.column')->from(TABLE_KANBANCELL)->alias('t1') ->leftJoin(TABLE_KANBANCOLUMN)->alias('t2')->on('t1.column = t2.id') ->where('t1.lane')->eq($laneID) ->andWhere('t2.type')->eq($columnType) ->fetch('column'); } /** * 根据ID获取看板泳道。 * Get lane by id. * * @param int $laneID * @access public * @return object */ public function getLaneById(int $laneID) { return $this->dao->findById($laneID)->from(TABLE_KANBANLANE)->fetch(); } /** * 获取看板卡片。 * Get card by id. * * @param int $cardID * @access public * @return object|false */ public function getCardByID(int $cardID): object|false { $card = $this->dao->select('*')->from(TABLE_KANBANCARD)->where('id')->eq($cardID)->fetch(); if(!$card) return false; $card = $this->loadModel('file')->replaceImgURL($card, 'desc'); return $card; } /** * 获取某个对象的看板卡片。 * Get cards by object id. * * @param string $objectType kanban|region|group|lane|column * @param int|string $objectID * @param string $archived * @param string $deleted * @access public * @return array */ public function getCardsByObject(string $objectType = '', int|string $objectID = 0, string $archived = '0', string $deleted = '0') { return $this->dao->select('*')->from(TABLE_KANBANCARD) ->where('1 = 1') ->beginIF($objectType)->andWhere($objectType)->eq($objectID)->fi() ->beginIF($archived != '')->andWhere('archived')->eq($archived)->fi() ->beginIF($deleted != '')->andWhere('deleted')->eq($deleted)->fi() ->orderBy('order') ->fetchAll('id', false); } /** * 保存看板卡片到期提醒。 * Save kanban card due reminders. * * @param array $cards * @access public * @return void */ public function saveCardNotice(array $cards) { if(empty($cards)) $cards = $this->loadModel('report')->getUserKanbanCards(); if(empty($cards)) return; $today = helper::today(); foreach($cards as $user => $cardList) { foreach($cardList as $card) { $url = helper::createLink('kanban', 'view', "kanbanID={$card->kanban}") . "#app=kanban"; $data = $today > $card->deadline ? $this->lang->kanban->due : $this->lang->kanban->nearing; $data .= html::a($url, "#{$card->id} {$card->name}"); $notify = new stdclass(); $notify->objectType = 'message'; $notify->action = 0; $notify->toList = ",{$user},"; $notify->data = $data; $notify->status = 'wait'; $notify->createdBy = 'admin'; $notify->createdDate = helper::now(); $notify->sendTime = null; $this->dao->insert(TABLE_NOTIFY)->data($notify)->exec(); } } } /** * 获取可转入的看板卡片。 * Get cards to import. * * @param int $kanbanID * @param int $excludedID * @param object $pager * @access public * @return array */ public function getCards2Import(int $kanbanID = 0, int $excludedID = 0, ?object $pager = null): array { $kanbanIdList = $this->getCanViewObjects(); return $this->dao->select('*')->from(TABLE_KANBANCARD) ->where('deleted')->eq(0) ->andWhere('archived')->eq(0) ->andWhere('fromID')->eq(0) ->andWhere('kanban')->in($kanbanIdList) ->beginIF($kanbanID)->andWhere('kanban')->eq($kanbanID)->fi() ->beginIF($excludedID)->andWhere('kanban')->ne($excludedID)->fi() ->orderBy('order') ->page($pager) ->fetchAll('id', false); } /** * 获取看板卡片的操作按钮。 * Get Kanban cards menus by execution id. * * @param int $executionID * @param array $objects * @param string $objecType story|bug|task * @access public * @return array */ public function getKanbanCardMenu(int $executionID, array $objects, string $objecType): array { $this->app->loadLang('execution'); $execution = $this->loadModel('execution')->getByID($executionID); if(!common::canModify('execution', $execution)) return array(); $menus = array(); switch ($objecType) { case 'epic': $menus = $this->kanbanTao->getERURCardMenu($executionID, $objects); break; case 'requirement': $menus = $this->kanbanTao->getERURCardMenu($executionID, $objects); break; case 'parentStory': $menus = $this->kanbanTao->getERURCardMenu($executionID, $objects); break; case 'story': if($execution) $menus = $this->kanbanTao->getStoryCardMenu($execution, $objects); break; case 'bug': $menus = $this->kanbanTao->getBugCardMenu($objects); break; case 'task': if(!isset($_SESSION['limitedExecutions'])) $this->loadModel('execution')->getLimitedExecution(); $menus = $this->kanbanTao->getTaskCardMenu($objects, $executionID); break; case 'risk': $menus = $this->kanbanTao->getRiskCardMenu($objects); break; } return $menus; } /** * 获取卡片的发送人和抄送人。 * Get toList and ccList. * * @param object $card * @access public * @return bool|array */ public function getToAndCcList(object $card): array|bool { /* Set toList and ccList. */ $toList = $card->createdBy; $ccList = trim($card->assignedTo, ','); if(empty($toList)) { if(empty($ccList)) return false; if(strpos($ccList, ',') === false) { $toList = $ccList; $ccList = ''; } else { $commaPos = strpos($ccList, ','); $toList = substr($ccList, 0, $commaPos); $ccList = substr($ccList, $commaPos + 1); } } return array($toList, $ccList); } /** * 检查操作按钮是否可点。 * Check if user can execute an action. * * @param object $object * @param string $action * @access public * @return bool */ public function isClickable(object $object, string $action): bool { $action = strtolower($action); $clickable = commonModel::hasPriv('kanban', $action); if(!$clickable) return false; switch($action) { case 'sortlane' : case 'deletelane' : if($object->deleted != '0') return false; $count = $this->dao->select('COUNT(id) AS count')->from(TABLE_KANBANLANE) ->where('deleted')->eq('0') ->andWhere('region')->eq($object->region) ->beginIF($action == 'sortlane')->andWhere('`group`')->eq($object->group)->fi() ->fetch('count'); return $count > 1; case 'splitcolumn' : return $object->parent == 0; case 'restorecolumn' : if($object->parent > 0) { $parent = $this->getColumnByID($object->parent); if(!empty($parent) && ($parent->deleted == '1' || $parent->archived == '1')) return false; } return $object->archived == '1'; case 'archivecolumn' : return $object->archived == '0'; // The column has been archived. case 'deletecolumn' : return $object->deleted == '0'; case 'sortcolumn': return $object->deleted == '0'; case 'editcard': return $object->deleted == '0' && $object->archived == '0'; case 'finishcard': return $object->status != 'done' && $object->deleted == '0' && $object->archived == '0'; case 'activatecard': return $object->status == 'done' && $object->deleted == '0' && $object->archived == '0'; case 'deletecard': return $object->deleted == '0'; case 'archivecard': return $object->archived == '0'; case 'restorecard': return $object->archived == '1'; } return true; } /** * 获取泳道的数量。 * Get kanban lane count. * * @param int $kanbanID * @param string $type * @access public * @return int */ public function getLaneCount(int $kanbanID, string $type = 'common') { if($type == 'common' || $type == 'kanban') { return $this->dao->select('COUNT(t2.id) as count')->from(TABLE_KANBANREGION)->alias('t1') ->leftJoin(TABLE_KANBANLANE)->alias('t2')->on('t1.id=t2.region') ->where('t1.kanban')->eq($kanbanID) ->andWhere('t1.deleted')->eq(0) ->andWhere('t2.deleted')->eq(0) ->beginIF($type == 'common')->andWhere('t2.type')->eq('common')->fi() ->beginIF($type != 'common')->andWhere('t2.type')->ne('common')->fi() ->fetch('count'); } else { return $this->dao->select('COUNT(id) as count')->from(TABLE_KANBANLANE) ->where('execution')->eq($kanbanID) ->andWhere('deleted')->eq(0) ->fetch('count'); } } /** * 检查卡片数量是否合法。 * Check display card count. * * @param int $count * @access public * @return bool */ public function checkDisplayCards(int $count): bool { if(!preg_match("/^-?\d+$/", (string)$count) || $count <= DEFAULT_CARDCOUNT || $count > MAX_CARDCOUNT) dao::$errors['displayCards'] = $this->lang->kanbanlane->error->mustBeInt; return !dao::isError(); } /** * 获取看板动态刷新的回调函数数组。 * Get kanban callback data. * * @param int $kanbanID * @param int $regionID * @access public * @return void */ public function getKanbanCallback(int $kanbanID, int $regionID) { $kanbanData = $this->getKanbanData($kanbanID, $regionID); $kanbanData = reset($kanbanData); return array('name' => 'updateKanbanRegion', 'params' => array('region' . $regionID, $kanbanData)); } /** * 获取卡片所在的单元格。 * Get card cell. * * @param int $cardID * @param int $kanbanID * @access public * @return object */ public function getCellByCard(int $cardID, int $kanbanID): object|false { return $this->dao->select('id,cards,lane,`column`')->from(TABLE_KANBANCELL) ->where('kanban')->eq($kanbanID) ->andWhere('type')->eq('common') ->andWhere('cards')->like("%,$cardID,%") ->fetch(); } }