* @package story * @link http://www.zentao.net */ class storyTao extends storyModel { /** * 获取需求的基础数据。 * Fetch base info of a story. * * @param int $storyID * @access protected * @return object|false */ protected function fetchBaseInfo(int $storyID): object|false { return $this->dao->select('*')->from(TABLE_STORY)->where('id')->eq($storyID)->fetch(); } /** * 获取项目研发需求关联的用户需求。 * Get project requirements. * * @param int $productID * @param int $projectID * @param object|null $pager * @access protected * @return array */ protected function getProjectRequirements(int $productID, int $projectID, object|null $pager = null): array { return $this->dao->select('t3.*')->from(TABLE_PROJECTSTORY)->alias('t1') ->leftJoin(TABLE_RELATION)->alias('t2')->on("t1.story=t2.AID && t2.AType='story'") ->leftJoin(TABLE_STORY)->alias('t3')->on("t2.BID=t3.id && t2.BType='requirement' && t3.deleted='0'") ->where('t1.project')->eq($projectID) ->andWhere('t1.product')->eq($productID) ->andWhere('t3.id')->ne('') ->page($pager, 't3.id') ->fetchAll('id'); } /** * 获取产品下细分的研发需求。 * Get subdivided stories by product * * @param int $productID * @access protected * @return array */ protected function getSubdividedStoriesByProduct(int $productID): array { if(empty($this->config->URAndSR)) return array(); return $this->dao->select('t1.BID')->from(TABLE_RELATION)->alias('t1') ->leftJoin(TABLE_STORY)->alias('t2')->on("t1.AID=t2.id") ->where('t2.deleted')->eq('0') ->andWhere('t1.AType')->eq('requirement') ->andWhere('t1.BType')->eq('story') ->andWhere('t1.relation')->eq('subdivideinto') ->andWhere('t1.product')->eq($productID) ->fetchPairs('BID', 'BID'); } /** * 获取用户需求细分的研发需求,或者研发需求关联的用户需求。 * Get associated requirements. * * @param int $storyID * @param string $storyType * @param array $fields * @access public * @return array */ public function getRelation(int $storyID, string $storyType, array $fields = array()): array { /* 初始化查询条件变量。*/ $BType = $storyType == 'story' ? 'requirement' : 'story'; $relation = $storyType == 'story' ? 'subdividedfrom' : 'subdivideinto'; $queryFields = empty($fields) ? 'id,title' : implode(',', $fields); /* 获取对应的关联数据。*/ return $this->dao->select('BID')->from(TABLE_RELATION) ->where('AType')->eq($storyType) ->andWhere('BType')->eq($BType) ->andWhere('relation')->eq($relation) ->andWhere('AID')->eq($storyID) ->fetchPairs(); } /** * 批量获取用户需求细分的研发需求,或者研发需求关联的用户需求。 * Batch get relations. * * @param array $storyIdList * @param string $storyType * @param array $fields * @access protected * @return array */ protected function batchGetRelations(array $storyIdList, string $storyType, array $fields = array()): array { if(empty($storyIdList)) return array(); /* 初始化查询条件变量。*/ $BType = $storyType == 'story' ? 'requirement' : 'story'; $relation = $storyType == 'story' ? 'subdividedfrom' : 'subdivideinto'; $queryFields = empty($fields) ? 'id,title' : implode(',', $fields); /* 获取对应的关联数据。*/ $relations = $this->dao->select('AID,BID')->from(TABLE_RELATION) ->where('AType')->eq($storyType) ->andWhere('BType')->eq($BType) ->andWhere('relation')->eq($relation) ->andWhere('AID')->in($storyIdList) ->fetchAll(); if(empty($relations)) return array(); /* 获取BID列表。*/ $storyIdList = array(); foreach($relations as $relation) $storyIdList[$relation->BID] = $relation->BID; /* 根据关联数据查询详细信息。 */ $query = $this->dao->select($queryFields)->from(TABLE_STORY)->where('id')->in($storyIdList)->andWhere('deleted')->eq(0); $stories = empty($fields) ? $query->fetchPairs() : $query ->fetchAll('id'); /* 将查询的信息合并到关联分组中。 */ $relationGroup = array(); foreach($relations as $relation) $relationGroup[$relation->AID][$relation->BID] = zget($stories, $relation->BID, null); return $relationGroup; } /** * 批量获取产品所有状态对应的需求总数。 * Get stories count of each status by product ID. * * @param array $productIDs * @param string $storyType * @access protected * @return array */ protected function getStoriesCountByProductIDs(array $productIDs, string $storyType = 'requirement'): array { return $this->dao->select('product, status, count(status) AS count') ->from(TABLE_STORY) ->where('deleted')->eq(0) ->andWhere('type')->eq($storyType) ->andWhere('product')->in($productIDs) ->groupBy('product, status') ->fetchGroup('product', 'status'); } /** * 获取所有完成的需求数量。 * Get the count of closed stories. * * @param string $storyType * @access protected * @return array */ protected function getFinishClosedTotal(string $storyType = 'story'): array { return $this->dao->select('product, count(1) AS finish') ->from(TABLE_STORY) ->where('deleted')->eq(0) ->andWhere('status')->eq('closed') ->andWhere('type')->eq($storyType) ->andWhere('closedReason')->eq('done') ->groupBy('product') ->fetchPairs(); } /** * 获取所有未完成的需求数量。 * Get the count of unclosed stories. * * @param string $storyType * @access protected * @return array */ protected function getUnClosedTotal(string $storyType = 'story'): array { return $this->dao->select('product, count(1) AS unclosed') ->from(TABLE_STORY) ->where('deleted')->eq(0) ->andWhere('type')->eq($storyType) ->andWhere('status')->ne('closed') ->groupBy('product') ->fetchPairs(); } /** * 获取产品评审人。 * Get product reviewers. * * @param int $productID * @param array $storyReviewers * @access protected * @return array */ protected function getProductReviewers(int $productID, array $storyReviewers = array()): array { $this->loadModel('user'); $product = $this->loadModel('product')->getByID($productID); $reviewers = $product->reviewer; if(!$reviewers and $product->acl != 'open') $reviewers = $this->user->getProductViewListUsers($product); return $this->user->getPairs('noclosed|nodeleted', $storyReviewers, 0, $reviewers); } /** * 构建研发需求的跟踪矩阵信息。 * Build story track. * * @param object $story * @param int $projectID * @access protected * @return object */ protected function buildStoryTrack(object $story, int $projectID = 0): object { if(count(get_object_vars($story)) == 0) return $story; /* 获取关联需求的用例、Bug、任务。 */ $track = new stdclass(); $track->parent = $story->parent; $track->title = $story->title; $track->cases = $this->loadModel('testcase')->getStoryCases($story->id); $track->bugs = $this->loadModel('bug')->getStoryBugs($story->id); $track->tasks = $this->loadModel('task')->getListByStory($story->id, 0, $projectID); if($this->config->edition != 'max') return $track; /* 获取关联需求的设计、关联版本库提交。 */ $track->designs = $this->dao->select('id, name')->from(TABLE_DESIGN)->where('story')->eq($story->id)->andWhere('deleted')->eq('0')->fetchAll('id'); $track->revisions = $this->dao->select('BID, t2.comment')->from(TABLE_RELATION)->alias('t1') ->leftJoin(TABLE_REPOHISTORY)->alias('t2')->on('t1.BID = t2.id') ->where('t1.AType')->eq('design') ->andWhere('t1.BType')->eq('commit') ->andWhere('t1.AID')->in(array_keys($track->designs)) ->fetchPairs(); return $track; } /** * 根据产品 ID 列表和分支参数,构建查询条件。 * Build products condition. * * @param string|int $productIdList * @param array|string|int $branch * @access protected * @return string */ protected function buildProductsCondition(string|int $productIdList, array|string|int $branch = 'all'): string { /* 如果查询所有分支,直接用 idList 条件。 */ if(empty($productIdList)) $productIdList = '0'; if(is_int($productIdList)) $productIdList = (string)$productIdList; if(empty($productIdList) or $branch === 'all' or $branch === '') return '`product` ' . helper::dbIN($productIdList); /* 将产品分类为正常产品和多分支产品。 */ $branchProducts = array(); $normalProducts = array(); $productList = $this->dao->select('*')->from(TABLE_PRODUCT)->where('id')->in($productIdList)->fetchAll('id'); foreach($productList as $product) { if($product->type != 'normal') $branchProducts[$product->id] = $product->id; if($product->type == 'normal') $normalProducts[$product->id] = $product->id; } /* 如果没有多分支产品,直接返回正常产品 ID 列表。*/ if(empty($branchProducts)) return '`product` ' . helper::dbIN($normalProducts); /* 构造多分支产品和正常产品的复合条件。 */ if(is_int($branch)) $branch = (string)$branch; $productQuery = "(`product` " . helper::dbIN($branchProducts) . " AND `branch` " . helper::dbIN($branch) . ')'; if(!empty($normalProducts)) $productQuery .= ' OR `product` ' . helper::dbIN($normalProducts); return "({$productQuery}) "; } /** * 将需求列表中的子需求合并到列表中的父需求下。 * Merge children to parent. * * @param array $stories * @param string $type * @access protected * @return int[] */ protected function appendSRToChildren(array $stories): array { /* For requirement children. */ $relationGroups = $this->batchGetRelations(array_keys($stories), 'requirement', array('*')); if(empty($stories)); foreach($stories as $storyID => $story) { /* Merge subdivided stories for requirement. */ if(empty($relationGroups[$story->id])) continue; $story->parent = '-1'; foreach($relationGroups[$story->id] as $SRID => $SRStory) { if(empty($SRStory)) continue; $SRStory->parent = $story->id; $story->children[$SRID] = $SRStory; } $story->linkStories = implode(',', array_column($story->children, 'title')); } return $stories; } /** * 追加需求所属的计划标题和子需求。 * Merge plan title and children. * * @param int|array|string $productID * @param array $stories * @param string $type story|requirement * * @access protected * @return array */ protected function mergePlanTitleAndChildren(array|string|int $productID, array $stories, string $type = 'story'): array { if(empty($stories)) return array(); $rawQuery = $this->dao->get(); /* Get plans. */ if(empty($productID)) $productID = '0'; if(is_int($productID))$productID = (string)$productID; $plans = $this->dao->select('id,title')->from(TABLE_PRODUCTPLAN)->Where('deleted')->eq(0)->andWhere('product')->in($productID)->fetchPairs('id', 'title'); $parents = $this->extractParents($stories); if($parents) { $children = $this->dao->select('id,parent,title')->from(TABLE_STORY)->where('parent')->in($parents)->andWhere('deleted')->eq(0)->fetchGroup('parent', 'id'); $parents = $this->dao->select('id,title')->from(TABLE_STORY)->where('id')->in($parents)->andWhere('deleted')->eq(0)->fetchAll('id'); } $mainID = $type == 'story' ? 'BID' : 'AID'; $countID = $type == 'story' ? 'AID' : 'BID'; $relations = $this->dao->select("$mainID, count($countID) as count")->from(TABLE_RELATION) ->where('AType')->eq('requirement') ->andWhere('BType')->eq('story') ->andWhere('relation')->eq('subdivideinto') ->andWhere('product')->in($productID) ->groupBy($mainID) ->fetchAll($mainID); if($type == 'requirement') $stories = $this->appendSRToChildren($stories); foreach($stories as $story) { /* Export story linkstories. */ if(isset($children[$story->id])) $story->linkStories = implode(',', array_column($children[$story->id], 'title')); /* Merge parent story title. */ if($story->parent > 0 and isset($parents[$story->parent])) $story->parentName = $parents[$story->parent]->title; /* Merge plan title. */ $story->planTitle = ''; $storyPlans = explode(',', trim($story->plan, ',')); foreach($storyPlans as $planID) $story->planTitle .= zget($plans, $planID, '') . ' '; if(isset($relations[$story->id])) { $link = helper::createLink('story', 'relation', "storyID=$story->id&storyType=$type"); if($type == 'story') $story->URS = $relations[$story->id]->count; if($type == 'requirement') $story->SRS = $relations[$story->id]->count; } } /* For save session query. */ $this->dao->sqlobj->sql = $rawQuery; return $stories; } /** * 提取需求列表中的父需求 ID 列表。 * Extract parents from stories. * * @param array $stories * @access protected * @return int[] */ protected function extractParents(array $stories): array { $parent = array_map(function($story) { if($story->parent == '-1') return $story->id; if($story->parent > '0') return $story->parent; return false; }, $stories); return array_values(array_unique(array_filter($parent))); } /** * 通过搜索条件获取关联执行的需求。 * Get execution stories by search. * * @param int $executionID * @param int $queryID * @param int $productID * @param string $orderBy * @param string $storyType * @param array $excludeStories * @param object|null $pager * @access protected * @return array */ protected function getExecutionStoriesBySearch(int $executionID, int $queryID, int $productID, string $orderBy, string $storyType = 'story', array $excludeStories = array(), object|null $pager = null): array { /* 获取查询条件。 */ $rawModule = $this->app->rawModule; $this->loadModel('search')->setQuery($rawModule == 'projectstory' ? 'story' : 'executionStory', $queryID); if(!$this->session->executionStoryQuery) $this->session->set('executionStoryQuery', ' 1 = 1'); if($rawModule == 'projectstory') $this->session->set('executionStoryQuery', $this->session->storyQuery); /* 处理查询条件。 */ $storyQuery = $this->replaceAllProductQuery($this->session->executionStoryQuery); $storyQuery = $this->replaceRevertQuery($storyQuery, $productID); $storyQuery = preg_replace('/`(\w+)`/', 't2.`$1`', $storyQuery); if(strpos($storyQuery, 'result') !== false) $storyQuery = str_replace('t2.`result`', 't4.`result`', $storyQuery); return $this->dao->select("distinct t1.*, t2.*, IF(t2.`pri` = 0, {$this->config->maxPriValue}, t2.`pri`) as priOrder, t3.type as productType, t2.version as version")->from(TABLE_PROJECTSTORY)->alias('t1') ->leftJoin(TABLE_STORY)->alias('t2')->on('t1.story = t2.id') ->leftJoin(TABLE_PRODUCT)->alias('t3')->on('t2.product = t3.id') ->beginIF(strpos($storyQuery, 'result') !== false)->leftJoin(TABLE_STORYREVIEW)->alias('t4')->on('t2.id = t4.story and t2.version = t4.version')->fi() ->where($storyQuery) ->andWhere('t1.project')->eq($executionID) ->andWhere('t2.deleted')->eq(0) ->andWhere('t3.deleted')->eq(0) ->andWhere('t2.type')->eq($storyType) ->beginIF($excludeStories)->andWhere('t2.id')->notIN($excludeStories)->fi() ->orderBy($orderBy) ->page($pager, 't2.id') ->fetchAll('id'); } /** * 替换所有产品的查询条件。 * Replace all product query. * * @param string $query * @access protected * @return string */ protected function replaceAllProductQuery(string $query): string { $allProduct = "`product` = 'all'"; if(strpos($query, $allProduct) !== false) $query = str_replace($allProduct, '1 = 1', $query); return $query; } /** * 如果有撤销变更的条件,用撤销变更的 ID 列表做替换 * Replace revert query. * * @param string $storyQuery * @param int $productID * @access protected * @return string */ protected function replaceRevertQuery(string $storyQuery, int $productID): string { if(strpos($storyQuery, 'result') === false) return $storyQuery; if(strpos($storyQuery, 'revert') === false) return $storyQuery; $reviews = $this->getRevertStoryIdList($productID); $storyQuery = str_replace("`result` = 'revert'", '1 = 1', $storyQuery); $storyQuery .= " AND `id` " . helper::dbIN($reviews); return $storyQuery; } /** * 获取撤销变更的 ID 列表。 * Get Story changed Revert ObjectID. * * @param int $productID * @access public * @return array */ protected function getRevertStoryIdList(int $productID): array { if(empty($productID)) return array(); return $this->dao->select('objectID')->from(TABLE_ACTION) ->where('product')->like("%,$productID,%") ->andWhere('action')->eq('reviewed') ->andWhere('objectType')->eq('story') ->andWhere('extra')->eq('Revert') ->groupBy('objectID') ->orderBy('objectID_desc') ->fetchPairs('objectID', 'objectID'); } /** * 根据请求类型获取查询的模块。 * Get modules for query execution stories. * * @param string $type bymodule|allstory|unclosed * @param string $param * @access protected * @return array */ protected function getModules4ExecutionStories(string $type, string $param): array { $moduleParam = ($type == 'bymodule' and $param !== '') ? $param : $this->cookie->storyModuleParam; if(empty($moduleParam) and strpos('allstory,unclosed,bymodule', $type) === false) return array(); return $this->dao->select('id')->from(TABLE_MODULE)->where('path')->like("%,$moduleParam,%")->andWhere('type')->eq('story')->andWhere('deleted')->eq(0)->fetchPairs(); } /** * 获取执行下关联的需求。 * Fetch execution stories. * * @param dao $storyDAO * @param int $productID * @param string $orderBy * @param object|null $pager * @access protected * @return int[] */ protected function fetchExecutionStories(dao $storyDAO, int $productID, string $orderBy, object|null $pager = null): array { $browseType = $this->session->executionStoryBrowseType; $unclosedStatus = $this->getUnclosedStatusKeys(); return $storyDAO->beginIF(!empty($productID))->andWhere('t1.product')->eq($productID)->fi() ->beginIF(!empty($browseType) && strpos('draft|reviewing|changing|closed', $browseType) !== false)->andWhere('t2.status')->eq($browseType)->fi() ->beginIF($browseType == 'unclosed')->andWhere('t2.status')->in($unclosedStatus)->fi() ->orderBy($orderBy) ->page($pager, 't2.id') ->fetchAll('id'); } /** * 获取项目下关联的需求。 * Fetch project stories. * * @param dao $storyDAO * @param int $productID * @param string $type * @param string $branch * @param array $executionIdList * @param string $orderBy * @param object|null $pager * @access protected * @return int[] */ protected function fetchProjectStories(dao $storyDAO, int $productID, string $type, string $branch, array $executionStoryIdList, string $orderBy, object|null $pager = null): array { $unclosedStatus = $this->getUnclosedStatusKeys(); return $storyDAO->beginIF(!empty($productID))->andWhere('t1.product')->eq($productID)->fi() ->beginIF($type == 'bybranch' and $branch !== '')->andWhere('t2.branch')->in("0,$branch")->fi() ->beginIF(strpos('draft|reviewing|changing|closed', $type) !== false)->andWhere('t2.status')->eq($type)->fi() ->beginIF($type == 'unclosed')->andWhere('t2.status')->in($unclosedStatus)->fi() ->beginIF($type == 'linkedexecution')->andWhere('t2.id')->in($executionStoryIdList)->fi() ->beginIF($type == 'unlinkedexecution')->andWhere('t2.id')->notIn($executionStoryIdList)->fi() ->orderBy($orderBy) ->page($pager, 't2.id') ->fetchAll('id'); } /** * 修正多分支产品需求的阶段,取最靠前的阶段。 * Fix branch story stage. * * @param array $stories * @access protected * @return array */ protected function fixBranchStoryStage(array $stories): array { if(empty($stories)) return array(); $rawQuery = $this->dao->get(); /* 获取阶段序列和关联的多分支产品需求。 */ $stageOrderList = implode(',', array_keys($this->lang->story->stageList)); $branchStoryList = $this->dao->select('t1.*,t2.branch as productBranch')->from(TABLE_PROJECTSTORY)->alias('t1') ->leftJoin(TABLE_PROJECTPRODUCT)->alias('t2')->on('t1.project = t2.project') ->leftJoin(TABLE_PRODUCT)->alias('t3')->on('t1.product = t3.id') ->where('t1.story')->in(array_keys($stories)) ->andWhere('t1.branch')->eq(BRANCH_MAIN) ->andWhere('t3.type')->ne('normal') ->fetchAll(); /* 对需求做分组。 */ $branches = array(); foreach($branchStoryList as $story) $branches[$story->productBranch][$story->story] = $story->story; /* Take the earlier stage. */ foreach($branches as $branchID => $storyIdList) { $stages = $this->dao->select('*')->from(TABLE_STORYSTAGE)->where('story')->in($storyIdList)->andWhere('branch')->eq((int)$branchID)->fetchPairs('story', 'stage'); foreach($stages as $storyID => $stage) { if(strpos($stageOrderList, $stories[$storyID]->stage) > strpos($stageOrderList, $stage)) $stories[$storyID]->stage = $stage; } } $this->dao->sqlobj->sql = $rawQuery; return $stories; } /** * 获取需求非关闭状态的键值。 * Get unclosed status keys. * * @access protected * @return array */ protected function getUnclosedStatusKeys(): array { $unclosedStatus = $this->lang->story->statusList; unset($unclosedStatus['closed']); return array_keys($unclosedStatus); } /** * 获取需求 ID 列表,这些需求是关联在项目下的执行。 * Get id list of executions by product. * * @param string $type * @param int $projectID * @access protected * @return array */ protected function getIdListOfExecutionsByProjectID(string $type, int $projectID): array { if($type != 'linkedexecution' && $type != 'unlinkedexecution') return array(); $executions = $this->loadModel('execution')->getPairs($projectID); if(empty($executions)) return array(); return $this->dao->select('story')->from(TABLE_PROJECTSTORY)->where('project')->in(array_keys($executions))->fetchPairs(); } /** * 通过产品ID列表批量获取产品关联的需求列表。 * Get story list by product ID list. * * @param int[] $productIdList * @param string $storyType * @access protected * @return array */ protected function getStoriesByProductIdList(array $productIdList, string $storyType = ''): array { return $this->dao->select('id, product, parent') ->from(TABLE_STORY) ->where('deleted')->eq('0') ->beginIF($storyType)->andWhere('type')->eq($storyType)->fi() ->andWhere('product')->in($productIdList) ->fetchAll(); } /** * 将需求数据插入到需求表。 * Do create story data. * * @param object $story * @access protected * @return int|false */ protected function doCreateStory(object $story): int|false { $this->dao->insert(TABLE_STORY)->data($story, 'spec,verify,reviewer,URS,region,lane,branches,plans,modules,uploadImage') ->autoCheck() ->checkIF(!empty($story->notifyEmail), 'notifyEmail', 'email') ->batchCheck($this->config->story->create->requiredFields, 'notempty') ->checkFlow() ->exec(); if(dao::isError()) return false; return $this->dao->lastInsertID(); } /** * 创建需求描述和验收标准。 * Do create story spec. * * @param int $storyID * @param object $story must has title,spec,verify,version items. * @param array $files e.g. array(fileID => fileName) * @access protected * @return void */ protected function doCreateSpec(int $storyID, object $story, array|string $files = array()): void { if(empty($storyID)) return; $spec = new stdclass(); $spec->story = $storyID; $spec->version = zget($story, 'version', 1); $spec->title = $story->title; $spec->spec = $story->spec; $spec->verify = $story->verify; $spec->files = is_string($files) ? $files : implode(',', array_keys($files)); if(isset($story->uploadImage)) $spec = $this->doSaveUploadImage($storyID, $story->uploadImage, $spec); $this->dao->insert(TABLE_STORYSPEC)->data($spec)->exec(); } /** * 保存上传图片作为需求内容。 * Do save upload image. * * @param int $storyID * @param string $fileName * @param object $spec * @access protected * @return object */ protected function doSaveUploadImage(int $storyID, string $fileName, object $spec): object { $storyImageFiles = $this->session->storyImagesFile; if(empty($storyImageFiles)) return $spec; if(empty($storyImageFiles[$fileName])) return $spec; $file = $storyImageFiles[$fileName]; $realPath = $file['realpath']; unset($file['realpath']); if(!file_exists($realPath)) return $spec; $this->loadModel('file'); if(!is_dir($this->file->savePath)) mkdir($this->file->savePath, 0777, true); if($realPath && rename($realPath, $this->file->savePath . $this->file->getSaveName($file['pathname']))) { $file['addedBy'] = $this->app->user->account; $file['addedDate'] = helper::now(); $file['objectType'] = 'story'; $file['objectID'] = $storyID; $isImage = in_array($file['extension'], $this->config->file->imageExtensions); if($isImage) $file['extra'] = 'editor'; $this->dao->insert(TABLE_FILE)->data($file)->exec(); $fileID = $this->dao->lastInsertID(); if($isImage) $spec->spec .= ''; if(!$isImage) $spec->files .= ',' . $fileID; } return $spec; } /** * 创建需求的时候,关联创建评审人列表。 * Do create reviewer when create story. * * @param int $storyID * @param array $reviewers * @param int $storyVersion * @access protected * @return void */ protected function doCreateReviewer(int $storyID, array $reviewers, int $storyVersion = 1): void { if(empty($storyID) or empty($reviewers)) return; foreach($reviewers as $reviewer) { if(empty($reviewer)) continue; $reviewData = new stdclass(); $reviewData->story = $storyID; $reviewData->version = $storyVersion; $reviewData->reviewer = $reviewer; $reviewData->result = ''; $this->dao->insert(TABLE_STORYREVIEW)->data($reviewData)->exec(); } } /** * 更新需求描述。 * Do update story spec. * * @param int $storyID * @param object $story * @param object $oldStory * @param array $addedFiles * @access protected * @return void */ protected function doUpdateSpec(int $storyID, object $story, object $oldStory, array $addedFiles = array()): void { if(empty($oldStory)) return; if($story->spec == $oldStory->spec and $story->verify == $oldStory->verify and $story->title == $oldStory->title and empty($story->deleteFiles) and empty($addedFiles)) return; $addedFiles = empty($addedFiles) ? '' : implode(',', array_keys($addedFiles)) . ','; $storyFiles = $oldStory->files = implode(',', array_keys($oldStory->files)); foreach($story->deleteFiles as $fileID) $storyFiles = str_replace(",$fileID,", ',', ",$storyFiles,"); $data = new stdclass(); $data->title = $story->title; $data->spec = $story->spec; $data->verify = $story->verify; $data->files = $story->files = trim($addedFiles . trim($storyFiles, ','), ','); $this->dao->update(TABLE_STORYSPEC)->data($data)->where('story')->eq((int)$storyID)->andWhere('version')->eq($oldStory->version)->exec(); /* Sync twins. */ if(!empty($oldStory->twins)) { foreach(explode(',', trim($oldStory->twins, ',')) as $twinID) { $this->dao->update(TABLE_STORYSPEC)->data($data)->where('story')->eq((int)$twinID)->andWhere('version')->eq($oldStory->version)->exec(); } } } /** * Do string when change parent. * * @param int $storyID * @param object $story * @param int $oldStoryParent * @access protected * @return void */ protected function doChangeParent(int $storyID, object $story, int $oldStoryParent) { if($story->product == $oldStoryParent) return; $this->loadModel('action'); $this->updateStoryProduct($storyID, $story->product); if($oldStoryParent == '-1') { $childStories = $this->dao->select('id')->from(TABLE_STORY)->where('parent')->eq($storyID)->andWhere('deleted')->eq(0)->fetchPairs('id'); foreach($childStories as $childStoryID) $this->updateStoryProduct($childStoryID, $story->product); } if($oldStoryParent > 0) { $oldParentStory = $this->dao->select('*')->from(TABLE_STORY)->where('id')->eq($oldStoryParent)->fetch(); $oldChildren = $this->dao->select('id')->from(TABLE_STORY)->where('parent')->eq($oldStoryParent)->andWhere('deleted')->eq(0)->fetchPairs('id', 'id'); $newParentStory = $this->dao->select('*')->from(TABLE_STORY)->where('id')->eq($oldStoryParent)->fetch(); if(empty($oldChildren)) $this->dao->update(TABLE_STORY)->set('parent')->eq(0)->where('id')->eq($oldStoryParent)->exec(); $this->dao->update(TABLE_STORY)->set('childStories')->eq(implode(',', $oldChildren))->set('lastEditedBy')->eq($this->app->user->account)->set('lastEditedDate')->eq(helper::now())->where('id')->eq($oldStoryParent)->exec(); $this->action->create('story', $storyID, 'unlinkParentStory', '', $oldStoryParent, '', false); $actionID = $this->action->create('story', $oldStoryParent, 'unLinkChildrenStory', '', $storyID, '', false); $changes = common::createChanges($oldParentStory, $newParentStory); if(!empty($changes)) $this->action->logHistory($actionID, $changes); } if($story->parent > 0) { $parentStory = $this->dao->select('*')->from(TABLE_STORY)->where('id')->eq($story->parent)->fetch(); $children = $this->dao->select('id')->from(TABLE_STORY)->where('parent')->eq($story->parent)->andWhere('deleted')->eq(0)->fetchPairs('id', 'id'); $newParentStory = $this->dao->select('*')->from(TABLE_STORY)->where('id')->eq($story->parent)->fetch(); $this->dao->update(TABLE_STORY)->set('parent')->eq('-1') ->set('childStories')->eq(implode(',', $children)) ->set('lastEditedBy')->eq($this->app->user->account) ->set('lastEditedDate')->eq(helper::now()) ->where('id')->eq($story->parent) ->exec(); $this->action->create('story', $storyID, 'linkParentStory', '', $story->parent, '', false); $actionID = $this->action->create('story', $story->parent, 'linkChildStory', '', $storyID, '', false); $changes = common::createChanges($parentStory, $newParentStory); if(!empty($changes)) $this->action->logHistory($actionID, $changes); } } /** * Do update link stories. * * @param int $storyID * @param object $story * @param object $oldStory * @access protected * @return void */ protected function doUpdateLinkStories(int $storyID, object $story, object $oldStory) { $linkStoryField = $oldStory->type == 'story' ? 'linkStories' : 'linkRequirements'; $linkStories = explode(',', $story->{$linkStoryField}); $oldLinkStories = explode(',', $oldStory->{$linkStoryField}); $addStories = array_diff($linkStories, $oldLinkStories); $removeStories = array_diff($oldLinkStories, $linkStories); $changeStories = array_merge($addStories, $removeStories); $changeStories = $this->dao->select("id,$linkStoryField")->from(TABLE_STORY)->where('id')->in(array_filter($changeStories))->fetchPairs(); foreach($changeStories as $changeStoryID => $changeStory) { if(in_array($changeStoryID, $addStories)) { $stories = empty($changeStory) ? $storyID : $changeStory . ',' . $storyID; $this->dao->update(TABLE_STORY)->set($linkStoryField)->eq((string)$stories)->where('id')->eq((int)$changeStoryID)->exec(); } if(in_array($changeStoryID, $removeStories)) { $linkedStories = str_replace(",$storyID,", ',', ",$changeStory,"); $linkedStories = trim($linkedStories, ','); $this->dao->update(TABLE_STORY)->set($linkStoryField)->eq((string)$linkedStories)->where('id')->eq((int)$changeStoryID)->exec(); } } } /** * 创建用户需求和软件需求的关联关系。 * Do create UR and SR relations. * * @param int $storyID * @param array $URList * @access protected * @return void */ protected function doCreateURRelations(int $storyID, array $URList): void { if(empty($storyID) || empty($URList)) return; $requirements = $this->getByList($URList); $data = new stdclass(); foreach($URList as $URID) { if(!isset($requirements[$URID])) continue; $requirement = $requirements[$URID]; if($requirement->type != 'requirement') continue; $requirement = $requirements[$URID]; $data->product = $requirement->product; $data->AType = 'requirement'; $data->BType = 'story'; $data->relation = 'subdivideinto'; $data->AID = $URID; $data->BID = $storyID; $data->AVersion = $requirement->version; $data->BVersion = 1; $data->extra = 1; $this->dao->replace(TABLE_RELATION)->data($data)->autoCheck()->exec(); $data->AType = 'story'; $data->BType = 'requirement'; $data->relation = 'subdividedfrom'; $data->AID = $storyID; $data->BID = $URID; $data->AVersion = 1; $data->BVersion = $requirement->version; $this->dao->replace(TABLE_RELATION)->data($data)->autoCheck()->exec(); } } /** * 在创建需求的时候,将需求关联到项目或执行。 * Link to execution for create story. * * @param int $executionID * @param int $storyID * @param object $story * @param string $extra * @access protected * @return void */ protected function linkToExecutionForCreate(int $executionID, int $storyID, object $story, string $extra = ''): void { if(empty($executionID) || empty($storyID)) return; $this->linkStory($executionID, $story->product, $storyID); if($this->config->systemMode == 'ALM' && $this->session->project && $executionID != $this->session->project) $this->linkStory((int)$this->session->project, $story->product, $storyID); $this->loadModel('action'); $extra = $this->parseExtra($extra); $object = $this->dao->findById($executionID)->from(TABLE_PROJECT)->fetch(); if($object->type == 'project') { $this->action->create('story', $storyID, 'linked2project', '', $object->id); return; } if($object->type == 'kanban') { $laneID = zget($story, 'lane', 0); if(empty($laneID)) $laneID = zget($extra, 'laneID', 0); $columnID = $this->loadModel('kanban')->getColumnIDByLaneID($laneID, 'backlog'); if(empty($columnID)) $columnID = zget($extra, 'columnID', 0); if(!empty($laneID) && !empty($columnID)) $this->kanban->addKanbanCell($executionID, (int)$laneID, (int)$columnID, 'story', (string)$storyID); if(empty($laneID) || empty($columnID)) $this->kanban->updateLane($executionID, 'story'); } $actionType = $object->type == 'kanban' ? 'linked2kanban' : 'linked2execution'; $this->action->create('story', $storyID, 'linked2project', '', (string)$object->project); if($object->multiple) $this->action->create('story', $storyID, $actionType, '', (string)$executionID); } /** * 当Bug转需求后,关闭Bug。 * Close bug when to story. * * @param int $bugID * @param int $storyID * @access protected * @return void */ protected function closeBugWhenToStory(int $bugID, int $storyID): void { if(empty($bugID) or empty($storyID)) return; if($this->config->edition != 'open') { $oldBug = $this->dao->select('feedback, status')->from(TABLE_BUG)->where('id')->eq($bugID)->fetch(); if($oldBug->feedback) $this->loadModel('feedback')->updateStatus('bug', $oldBug->feedback, 'closed', $oldBug->status); } $now = helper::now(); $bug = new stdclass(); $bug->toStory = $storyID; $bug->status = 'closed'; $bug->resolution = 'tostory'; $bug->resolvedBy = $this->app->user->account; $bug->resolvedDate = $now; $bug->closedBy = $this->app->user->account; $bug->closedDate = $now; $bug->assignedTo = 'closed'; $bug->assignedDate = $now; $this->dao->update(TABLE_BUG)->data($bug)->where('id')->eq($bugID)->exec(); $this->loadModel('action')->create('bug', $bugID, 'ToStory', '', $storyID); $this->action->create('bug', $bugID, 'Closed'); /* add files to story from bug. */ $files = $this->dao->select('*')->from(TABLE_FILE)->where('objectType')->eq('bug')->andWhere('objectID')->eq($bugID)->fetchAll(); if(empty($files)) return; foreach($files as $file) { $file->objectType = 'story'; $file->objectID = $storyID; unset($file->id); $this->dao->insert(TABLE_FILE)->data($file)->exec(); } } /** * 当待办转需求后,将待办改为完成。 * Finish todo when to story. * * @param int $todoID * @param int $storyID * @access protected * @return void */ protected function finishTodoWhenToStory(int $todoID, int $storyID): void { if(empty($todoID) or empty($storyID)) return; $this->dao->update(TABLE_TODO)->set('status')->eq('done')->where('id')->eq($todoID)->exec(); $this->loadModel('action')->create('todo', $todoID, 'finished', '', "STORY:$storyID"); if($this->config->edition == 'open')return; $todo = $this->dao->select('type, objectID')->from(TABLE_TODO)->where('id')->eq($todoID)->fetch(); if($todo->type == 'feedback' && $todo->objectID) $this->loadModel('feedback')->updateStatus('todo', $todo->objectID, 'done'); } /** * 更新孪生需求字段。 * Update twins. * * @param array $storyIdList * @access protected * @return void */ protected function updateTwins(array $storyIdList): void { if(count($storyIdList) <= 1) return; foreach($storyIdList as $storyID) { $twinsIdList = $storyIdList; unset($twinsIdList[$storyID]); $this->dao->update(TABLE_STORY)->set('twins')->eq(implode(',', $twinsIdList))->where('id')->eq($storyID)->exec(); } } /** * 解析extra参数。 * Parse extra param. * * @param string $extra * @access protected * @return array */ protected function parseExtra(string $extra): array { if(empty($extra)) return array(); /* Whether there is a object to transfer story, for example feedback. */ $extra = str_replace(array(',', ' '), array('&', ''), $extra); parse_str($extra, $output); return $output; } /** * Check whether a story can be subdivided. * * @param object $story * @param bool $isShadowProduct * @access protected * @return bool */ protected function checkCanSubdivide($story, $isShadowProduct): bool { if($this->config->vision == 'lite') return true; if(!in_array($story->status, array('launched', 'developing', 'active'))) return false; if(!$isShadowProduct && $story->stage != 'wait') return false; if($isShadowProduct && $story->stage != 'projected') return false; if($story->parent > 0) return false; return true; } /** * 获取需求关联的分支和项目。 * Get linked branches and projects. * * @param int $storyID * @access protected * @return array */ protected function getLinkedBranchesAndProjects(int $storyID): array { $projects = $this->dao->select('t2.id,t2.model,t2.type,t3.branch')->from(TABLE_PROJECTSTORY)->alias('t1') ->leftJoin(TABLE_PROJECT)->alias('t2')->on('t1.project = t2.id') ->leftJoin(TABLE_PROJECTPRODUCT)->alias('t3')->on('t1.project = t3.project') ->where('t1.story')->eq($storyID) ->andWhere('t2.deleted')->eq(0) ->fetchAll(); $linkedBranches = array(); $linkedProjects = array(); foreach($projects as $project) { $project->kanban = ($project->model == 'kanban' || $project->type == 'kanban'); $project->branches[$project->branch] = $project->branch; $linkedProjects[$project->id] = $project; $linkedBranches[$project->branch] = $project->branch; } return array($linkedBranches, $linkedProjects); } /** * 将阶段设置为 planned。 * Set stage to planned. * * @param int $storyID * @param array $stages * @param array $oldStages * @access protected * @return bool */ protected function setStageToPlanned(int $storyID, array $stages = array(), array $oldStages = array()): bool { $story = $this->dao->findById($storyID)->from(TABLE_STORY)->fetch(); if(empty($story)) return false; if(empty($story->plan)) { $this->dao->update(TABLE_STORY)->set('stage')->eq('wait')->where('id')->eq($storyID)->exec(); return true; } $this->dao->update(TABLE_STORY)->set('stage')->eq('planned')->where('id')->eq($storyID)->exec(); foreach($stages as $branchID => $stage) { $this->dao->replace(TABLE_STORYSTAGE)->set('story')->eq($storyID)->set('branch')->eq((int)$branchID)->set('stage')->eq('planned')->exec(); if(isset($oldStages[$branchID]) && !empty($oldStages[$branchID]->stagedBy)) $this->dao->replace(TABLE_STORYSTAGE)->data($oldStages[$branchID])->exec(); } return true; } /** * 将阶段设置为 closed。 * Set stage to closed. * * @param int $storyID * @param array $linkedBranches * @param array $linkedProjects * @access protected * @return bool */ protected function setStageToClosed(int $storyID, array $linkedBranches = array(), array $linkedProjects = array()): bool { $story = $this->dao->findById($storyID)->from(TABLE_STORY)->fetch(); if(empty($story)) return false; $this->dao->update(TABLE_STORY)->set('stage')->eq('closed')->where('id')->eq($storyID)->exec(); foreach($linkedBranches as $branchID) { if(!empty($branchID)) $this->dao->replace(TABLE_STORYSTAGE)->set('story')->eq($storyID)->set('branch')->eq((int)$branchID)->set('stage')->eq('closed')->exec(); } if($story->stage != 'closed') $this->updateLinkedLane($storyID, $linkedProjects); return true; } /** * 更新需求阶段。 * Update stage. * * @param int $storyID * @param array $stages * @param array $oldStages * @param array $linkedProjects * @access protected * @return bool */ protected function updateStage(int $storyID, array $stages, array $oldStages = array(), array $linkedProjects = array()): bool { if(empty($stages) && $oldStages) $stages = array_column($oldStages, 'stage', 'branch'); if(empty($stages)) return false; $story = $this->dao->findById($storyID)->from(TABLE_STORY)->fetch(); if(empty($story)) return false; $product = $this->dao->findById($story->product)->from(TABLE_PRODUCT)->fetch(); if($product and $product->type != 'normal' and empty($story->branch)) { $stageList = implode(',', array_keys($this->lang->story->stageList)); $minStagePos = strlen($stageList); $minStage = ''; foreach($stages as $branchID => $stage) { $this->dao->replace(TABLE_STORYSTAGE)->set('story')->eq($storyID)->set('branch')->eq((int)$branchID)->set('stage')->eq($stage)->exec(); if(isset($oldStages[$branchID]) && !empty($oldStages[$branchID]->stagedBy)) { $this->dao->replace(TABLE_STORYSTAGE)->data($oldStages[$branchID])->exec(); $stage = $oldStages[$branchID]->stage; } $position = strpos($stageList, $stage); if($position !== false && $position < $minStagePos) { $minStage = $stage; $minStagePos = $position; } } $this->dao->update(TABLE_STORY)->set('stage')->eq($minStage)->where('id')->eq($storyID)->exec(); $stage = $minStage; } else { $stage = current($stages); $this->dao->update(TABLE_STORY)->set('stage')->eq($stage)->where('id')->eq($storyID)->exec(); } if($story->stage != $stage) $this->updateLinkedLane($storyID, $linkedProjects); return true; } /** * 根据计划和关联的项目的分支,获取默认的阶段值。 * Get default stages by plans and linked branches. * * @param string $planIdList e.g. 1,2 * @param array $linkedBranches e.g. array(0, 1, branchID) * @access protected * @return array */ protected function getDefaultStages(string $planIdList, array $linkedBranches): array { if(empty($planIdList) && empty($linkedBranches)) return array(); $stages = array(); if($planIdList) { $plans = $this->dao->select('*')->from(TABLE_PRODUCTPLAN)->where('id')->in($planIdList)->fetchPairs('branch', 'branch'); foreach($plans as $branchID) $stages[$branchID] = 'planned'; } if(empty($linkedBranches)) return $stages; foreach($linkedBranches as $branchID) $stages[$branchID] = 'projected'; return $stages; } /** * 根据关联执行,获取关联该需求的任务状态统计数。 * Get linked task status statistics for this story by linked projects. * * @param int $storyID * @param array $linkedProjects e.g. linkedProjects = array(projectID => stdclass('branch' => array(branchID))) * @access protected * @return array */ protected function getLinkedTaskStat(int $storyID, array $linkedProjects): array { $tasks = $this->dao->select('type,execution,status')->from(TABLE_TASK)->where('execution')->in(array_keys($linkedProjects)) ->andWhere('type')->in('devel,test') ->andWhere('story')->eq($storyID) ->andWhere('deleted')->eq(0) ->andWhere('status')->ne('cancel') ->andWhere('closedReason')->ne('cancel') ->fetchGroup('type'); if(empty($tasks)) return array(); /* Cycle all tasks, get counts of every type and every status. */ $branchStatusList = $branchDevelCount = $branchTestCount = array(); $statusList['devel'] = array('wait' => 0, 'doing' => 0, 'done' => 0, 'pause' => 0); $statusList['test'] = array('wait' => 0, 'doing' => 0, 'done' => 0, 'pause' => 0); foreach($tasks as $type => $typeTasks) { if(!isset($statusList[$type])) continue; foreach($typeTasks as $task) { $status = $task->status ? $task->status : 'wait'; if(!isset($statusList[$type][$status])) $status = 'done'; //如果任务状态不在默认统计状态列表中,则按照完成状态处理。 $branches = $linkedProjects[$task->execution]->branches; foreach($branches as $branch) { if(!isset($branchStatusList[$branch])) $branchStatusList[$branch] = $statusList; $branchStatusList[$branch][$type][$status] ++; if($type == 'devel') $branchDevelCount[$branch] = !isset($branchDevelCount[$branch]) ? 1 : ($branchDevelCount[$branch] + 1); if($type == 'test') $branchTestCount[$branch] = !isset($branchTestCount[$branch]) ? 1 : ($branchTestCount[$branch] + 1); } } } return array($branchStatusList, $branchDevelCount, $branchTestCount); } /** * 更新关联的看板泳道。 * Update linked lane. * * @param int $storyID * @param array $linkedProjects * @access protected * @return int */ protected function updateLinkedLane(int $storyID, array $linkedProjects = array()): int { if(empty($storyID) || empty($linkedProjects)) return 0; $this->loadModel('kanban'); $linkedKanbans = array_keys(array_filter(array_map(function($project){return $project->kanban;}, $linkedProjects))); foreach($linkedKanbans as $projectID) $this->kanban->updateLane($projectID, 'story', $storyID); return count($linkedKanbans); } /** * 根据任务状态统计,计算需求阶段。 * Compute stages by tasks status statistics. * * @param int $storyID * @param array $taskStat * @param array $stages * @param array $linkedProjects * @access protected * @return array */ protected function computeStagesByTasks(int $storyID, array $taskStat = array(), array $stages = array(), array $linkedProjects = array()): array { /* 设置关联的项目的分支阶段为已立项。 */ if(empty($taskStat)) { foreach($linkedProjects as $linkedProject) { foreach($linkedProject->branches as $branchID) $stages[$branchID] = 'projected'; } return $stages; } /* 根据任务状态统计信息,计算该需求所处的阶段。 */ list($branchStatusList, $branchDevelCount, $branchTestCount) = $taskStat; foreach($branchStatusList as $branch => $statusList) { $stage = 'projected'; $testCount = isset($branchTestCount[$branch]) ? $branchTestCount[$branch] : 0; $develCount = isset($branchDevelCount[$branch]) ? $branchDevelCount[$branch] : 0; $doingDevelTask = $statusList['devel']['wait'] < $develCount && $statusList['devel']['done'] < $develCount && $develCount > 0; $doneDevelTask = $statusList['devel']['done'] == $develCount && $develCount > 0; $notStartTestTask = $statusList['test']['wait'] == $testCount; $doingTestTask = $statusList['test']['wait'] < $testCount && $statusList['test']['done'] < $testCount && $testCount > 0; $doneTestTask = $statusList['test']['done'] == $testCount && $testCount > 0; $hasDoingTestTask = $statusList['test']['doing'] > 0 || $statusList['test']['pause'] > 0; $notDoingTestTask = $statusList['test']['doing'] == 0; if($doingDevelTask && $notStartTestTask) $stage = 'developing'; //开发任务没有全部完成,测试任务没有开始,阶段为开发中。 if($doingDevelTask && $notDoingTestTask) $stage = 'developing'; //开发任务没有全部完成,没有测试中的测试任务,阶段为开发中。 if($doingDevelTask && $doneTestTask) $stage = 'testing'; //开发任务没有全部完成,测试任务已经完成,阶段为测试中。 if($doneDevelTask && $notStartTestTask) $stage = 'developed'; //开发任务已经完成,测试任务还没有开始,阶段为开发完成。 if($doneDevelTask && $doingTestTask) $stage = 'testing'; //开发任务已经完成,测试任务已经开始,阶段为测试中。 if($hasDoingTestTask) $stage = 'testing'; //有测试任务正在测试,阶段为测试中。 if($doneDevelTask && $doneTestTask) $stage = 'tested'; //开发任务已经完成,测试任务已经完成,阶段为测试完成。 $stages[$branch] = $stage; } /* 检查该需求是否已经发布,如果已经发布,阶段则为已发布。 */ $releases = $this->dao->select('*')->from(TABLE_RELEASE)->where("CONCAT(',', stories, ',')")->like("%,$storyID,%")->andWhere('deleted')->eq(0)->fetchPairs('branch', 'branch'); foreach($releases as $branches) { $branches = trim($branches, ','); foreach(explode(',', $branches) as $branch) $stages[(int)$branch] = 'released'; } return $stages; } /** * 获取该需求影响的项目和任务。 * Get affected projects and tasks for this story. * * @param object $story * @param array $users * @access protected * @return object */ protected function getAffectedProjects(object $story, array $users): object { $this->app->loadLang('task'); $this->config->story->affect = new stdclass(); $this->config->story->affect->projects = new stdclass(); $this->config->story->affect->projects->fields[] = array('name' => 'id', 'title' => $this->lang->task->id); $this->config->story->affect->projects->fields[] = array('name' => 'name', 'title' => $this->lang->task->name, 'link' => helper::createLink('task', 'view', 'id={id}')); $this->config->story->affect->projects->fields[] = array('name' => 'assignedTo', 'title' => $this->lang->task->assignedTo); $this->config->story->affect->projects->fields[] = array('name' => 'consumed', 'title' => $this->lang->task->consumed); $this->config->story->affect->projects->fields[] = array('name' => 'left', 'title' => $this->lang->task->left); if(empty($story->executions)) return $story; foreach($story->executions as $executionID => $execution) if($execution->status == 'done') unset($story->executions[$executionID]); $story->teams = $this->dao->select('account, root')->from(TABLE_TEAM)->where('root')->in(array_keys($story->executions))->andWhere('type')->eq('project')->fetchGroup('root'); foreach($story->tasks as $executionTasks) { foreach($executionTasks as $task) { $task->status = $this->processStatus('task', $task); $task->assignedTo = zget($users, $task->assignedTo); } } return $story; } /** * 获取该需求影响的Bug。 * Get affected bugs for this story. * * @param object $story * @param array $users * @access protected * @return object */ protected function getAffectedBugs(object $story, array $users): object { $this->app->loadLang('bug'); $this->app->loadLang('execution'); if(!isset($this->config->story->affect)) $this->config->story->affect = new stdclass(); $this->config->story->affect->bugs = new stdclass(); $this->config->story->affect->bugs->fields[] = array('name' => 'id', 'title' => $this->lang->idAB); $this->config->story->affect->bugs->fields[] = array('name' => 'title', 'title' => $this->lang->bug->title, 'link' => helper::createLink('bug', 'view', 'id={id}')); $this->config->story->affect->bugs->fields[] = array('name' => 'status', 'title' => $this->lang->statusAB); $this->config->story->affect->bugs->fields[] = array('name' => 'openedBy', 'title' => $this->lang->bug->openedBy); $this->config->story->affect->bugs->fields[] = array('name' => 'resolvedBy', 'title' => $this->lang->bug->resolvedBy); $this->config->story->affect->bugs->fields[] = array('name' => 'resolution', 'title' => $this->lang->bug->resolution); $this->config->story->affect->bugs->fields[] = array('name' => 'lastEditedBy', 'title' => $this->lang->bug->lastEditedBy); /* Get affected bugs. */ $twinsIdList = $story->id . ($story->twins ? ",{$story->twins}" : ''); $story->bugs = $this->dao->select('*')->from(TABLE_BUG)->where('status')->ne('closed') ->andWhere('story')->in($twinsIdList) ->andWhere('status')->ne('closed') ->andWhere('deleted')->eq(0) ->orderBy('id desc')->fetchAll(); foreach($story->bugs as $bug) { $bug->status = $this->processStatus('bug', $bug); $bug->openedBy = zget($users, $bug->openedBy); $bug->resolvedBy = zget($users, $bug->resolvedBy); $bug->lastEditedBy = zget($users, $bug->lastEditedBy); $bug->resolution = zget($this->lang->bug->resolutionList, $bug->resolution); } return $story; } /** * 获取该需求影响的用例。 * Get affected cases for this story. * * @param object $story * @param array $users * @access protected * @return object */ protected function getAffectedCases(object $story, array $users): object { $this->app->loadLang('testcase'); if(!isset($this->config->story->affect)) $this->config->story->affect = new stdclass(); $this->config->story->affect->cases = new stdclass(); $this->config->story->affect->cases->fields[] = array('name' => 'id', 'title' => $this->lang->idAB); $this->config->story->affect->cases->fields[] = array('name' => 'title', 'title' => $this->lang->testcase->title, 'link' => helper::createLink('testcase', 'view', 'id={id}')); $this->config->story->affect->cases->fields[] = array('name' => 'status', 'title' => $this->lang->statusAB); $this->config->story->affect->cases->fields[] = array('name' => 'openedBy', 'title' => $this->lang->testcase->openedBy); $this->config->story->affect->cases->fields[] = array('name' => 'lastEditedBy', 'title' => $this->lang->testcase->lastEditedBy); /* Get affected cases. */ $twinsIdList = $story->id . ($story->twins ? ",{$story->twins}" : ''); $story->cases = $this->dao->select('*')->from(TABLE_CASE)->where('deleted')->eq(0) ->andWhere('story')->in($twinsIdList) ->fetchAll(); foreach($story->cases as $case) { $case->status = $this->processStatus('testcase', $case); $case->openedBy = zget($users, $case->openedBy); $case->lastEditedBy = zget($users, $case->lastEditedBy); } return $story; } /** * 获取该需求影响的孪生需求。 * Get affected twins for this story. * * @param object $story * @param array $users * @access protected * @return object */ protected function getAffectedTwins(object $story, array $users): object { if(empty($story->twins)) return $story; if(!isset($this->config->story->affect)) $this->config->story->affect = new stdclass(); $this->config->story->affect->twins = new stdclass(); $this->config->story->affect->twins->fields[] = array('name' => 'id', 'title' => $this->lang->idAB); $this->config->story->affect->twins->fields[] = array('name' => 'branch', 'title' => $this->lang->story->branch); $this->config->story->affect->twins->fields[] = array('name' => 'title', 'title' => $this->lang->story->title, 'link' => helper::createLink('story', 'view', 'id={id}')); $this->config->story->affect->twins->fields[] = array('name' => 'status', 'title' => $this->lang->statusAB); $this->config->story->affect->twins->fields[] = array('name' => 'stage', 'title' => $this->lang->story->stageAB); $this->config->story->affect->twins->fields[] = array('name' => 'openedBy', 'title' => $this->lang->story->openedBy); $this->config->story->affect->twins->fields[] = array('name' => 'lastEditedBy', 'title' => $this->lang->story->lastEditedBy); $story->twins = $this->getByList($story->twins); $branches = $this->loadModel('branch')->getPairs($story->product); foreach($story->twins as $twin) { $twin->branch = zget($branches, $twin->branch, ''); $twin->status = $this->processStatus('story', $twin); $twin->openedBy = zget($users, $twin->openedBy); $twin->lastEditedBy = zget($users, $twin->lastEditedBy); $twin->stage = zget($this->lang->story->stageList, $twin->stage); } return $story; } /** * 构建需求列表中的操作按钮。 * Build action buttons on the browse page. * * @param object $story * @param string $params * @param string $storyType story|requirement * @param object $execution * @access protected * @return array */ protected function buildBrowseActionBtnList(object $story, string $params = '', string $storyType = 'story', object $execution = null): array { global $lang; $tutorialMode = commonModel::isTutorialMode(); $actSubmitreview = array(); $actReview = array(); $actRecall = array(); $storyReviewer = isset($story->reviewer) ? $story->reviewer : array(); $executionID = empty($execution) ? 0 : $execution->id; if(!isset($story->from)) $story->from = ''; $closeLink = helper::createLink('story', 'close', $params . "&from=&storyType=$story->type"); $processStoryChangeLink = helper::createLink('story', 'processStoryChange', $params); $changeLink = helper::createLink('story', 'change', $params . "&from=$story->from&storyType=$story->type"); $submitReviewLink = helper::createLink('story', 'submitReview', "storyID=$story->id&storyType=$story->type"); $reviewLink = helper::createLink('story', 'review', $params . "&from=$story->from&storyType=$story->type"); $recallLink = helper::createLink('story', 'recall', $params . "&from=list&confirm=no&storyType=$story->type"); $batchCreateStoryLink = helper::createLink('story', 'batchCreate', "productID=$story->product&branch=$story->branch&module=$story->module&$params&executionID=$executionID&plan=0&storyType=story"); $editLink = helper::createLink('story', 'edit', $params . "&kanbanGroup=default&storyType=$story->type"); $createCaseLink = helper::createLink('testcase', 'create', "productID=$story->product&branch=$story->branch&module=0&from=¶m=0&$params"); /* If the story cannot be changed, render the close button. */ $canClose = common::hasPriv('story', 'close') && $this->isClickable($story, 'close'); if(!common::canBeChanged('story', $story)) return array(array('name' => 'close', 'hint' => $lang->close, 'data-toggle' => 'modal', 'url' => $canClose ? $closeLink : null, 'disabled' => !$canClose)); if($story->URChanged) return array(array('name' => 'processStoryChange', 'data-toggle' => 'modal', 'url' => common::hasPriv('story', 'processStoryChange') ? $processStoryChangeLink : null)); /* Change button. */ $canChange = common::hasPriv('story', 'change') && $this->isClickable($story, 'change'); $title = $canChange ? $lang->story->change : $this->lang->story->changeTip; $actions[] = array('name' => 'change', 'url' => $canChange ? $changeLink : null, 'hint' => $title, 'disabled' => !$canChange); /* Submitreview, review, recall buttons. */ if(strpos('draft,changing', $story->status) !== false) { $canSubmitReview = common::hasPriv('story', 'submitReview'); $actSubmitreview = array('name' => 'submitreview', 'data-toggle' => 'modal', 'url' => $canSubmitReview ? $submitReviewLink : null); } else { $canReview = common::hasPriv('story', 'review') && $this->isClickable($story, 'review'); $title = $this->lang->story->review; if(!$canReview && $story->status != 'closed') { if($story->status == 'active') $title = $this->lang->story->reviewTip['active']; if($storyReviewer && in_array($this->app->user->account, $storyReviewer)) $title = $this->lang->story->reviewTip['reviewed']; if($storyReviewer && !in_array($this->app->user->account, $storyReviewer)) $title = $this->lang->story->reviewTip['notReviewer']; } $actReview = array('name' => 'review', 'url' => $canReview ? $reviewLink : null, 'hint' => $title, 'disabled' => !$canReview); } $canRecall = common::hasPriv('story', 'recall') && $this->isClickable($story, 'recall'); $title = $story->status == 'changing' ? $this->lang->story->recallChange : $this->lang->story->recall; if(!$canRecall) $title = $this->lang->story->recallTip['actived']; $actRecall = array('name' => $story->status == 'changing' ? 'recalledchange' : 'recall', 'url' => $canRecall ? $recallLink : null, 'hint' => $title, 'disabled' => !$canRecall); /* Change the render order. */ if(!empty($actSubmitreview)) { $actions[] = $actSubmitreview; $actions[] = array('name' => 'dropdown', 'type' => 'dropdown', 'items' => array($actRecall + array('innerClass' => 'ajax-submit'))); } elseif($actReview['disabled'] && !$actRecall['disabled']) { $actions[] = $actRecall + array('className' => 'ajax-submit'); $actions[] = array('name' => 'dropdown', 'type' => 'dropdown', 'items' => array($actReview)); } else { $actions[] = $actReview; $actions[] = array('name' => 'dropdown', 'type' => 'dropdown', 'items' => array($actRecall + array('innerClass' => 'ajax-submit'))); } if($this->config->vision != 'lite') $actions[] = array('name' => 'close', 'url' => $canClose ? $closeLink : null, 'data-toggle' => 'modal', 'disabled' => !$canClose); /* Render divider line. */ $actions[] = array('name' => 'divider', 'type'=>'divider'); /* Edit button. */ $canEdit = common::hasPriv('story', 'edit') && $this->isClickable($story, 'edit'); $actions[] = array('name' => 'edit', 'url' => $this->isClickable($story, 'edit') ? $editLink : null, 'disabled' => !$canEdit); /* Create test case button. */ if($story->type != 'requirement' && $this->config->vision != 'lite') $actions[] = array('name' => 'testcase', 'url' => common::hasPriv('testcase', 'create') && $story->parent >= 0 ? $createCaseLink : null, 'disabled' => $story->parent < 0 || !common::hasPriv('testcase', 'create')); /* Batch create button. */ $shadow = $this->dao->findByID($story->product)->from(TABLE_PRODUCT)->fetch('shadow'); $canBatchCreateStory = $this->app->tab == 'product' && (common::hasPriv('story', 'batchCreate', '', 'storyType=story')) && $this->isClickable($story, 'batchcreate'); if($this->app->rawModule != 'projectstory' || $this->config->vision == 'lite' || $shadow) { $title = $story->type == 'story' ? $this->lang->story->subdivideSR : $this->lang->story->subdivide; if(!$canBatchCreateStory && $story->status != 'closed') { if($story->status != 'active') $title = sprintf($this->lang->story->subDivideTip['notActive'], $story->type == 'story' ? $this->lang->SRCommon : $this->lang->URCommon); if($story->status == 'active' && $story->stage != 'wait') $title = sprintf($this->lang->story->subDivideTip['notWait'], zget($this->lang->story->stageList, $story->stage)); if(!empty($story->twins)) $title = $this->lang->story->subDivideTip['twinsSplit']; if($story->parent > 0) $title = $this->lang->story->subDivideTip['subStory']; } $actions[] = array('name' => 'batchCreate', 'url' => $canBatchCreateStory ? $batchCreateStoryLink : null, 'hint' => $title, 'disabled' => !$canBatchCreateStory); } if(!empty($execution)) { if($execution->type != 'project') { $createTaskLink = $tutorialMode ? helper::createLink('tutorial', 'wizard', "module=task&method=create¶ms=" . helper::safe64Encode("executionID={$execution->id}")) : helper::createLink('task', 'create', "executionID={$execution->id}&storyID={$story->id}"); $batchCreateTaskLink = helper::createLink('task', 'batchCreate', "executionID={$execution->id}&storyID={$story->id}"); $storyEstimateLink = helper::createLink('execution', 'storyEstimate', "executionID={$execution->id}&storyID={$story->id}"); $canCreateTask = common::hasPriv('task', 'create') && $story->status == 'active'; $canBatchCreateTask = common::hasPriv('task', 'batchCreate') && $story->status == 'active'; $canStoryEstimate = common::hasPriv('execution', 'storyEstimate'); $actions[] = array('name' => 'createTask', 'url' => $canCreateTask ? $createTaskLink : null, 'disabled' => !$canCreateTask, 'className' => 'create-task-btn'); $actions[] = array('name' => 'batchCreateTask', 'url' => $canBatchCreateTask ? $batchCreateTaskLink : null, 'disabled' => !$canBatchCreateTask); $actions[] = array('name' => 'storyEstimate', 'url' => $canStoryEstimate ? $storyEstimateLink : null, 'disabled' => !$canStoryEstimate); } if($this->config->vision != 'lite' && $execution->hasProduct) { $unlinkModule = 'execution'; $canUnlinkStory = common::hasPriv($unlinkModule, 'unlinkStory'); $unlinkStoryLink = helper::createLink($unlinkModule, 'unlinkStory', "projectID={$execution->id}&$params&confirm=yes"); $unlinkStoryTip = $this->lang->execution->confirmUnlinkStory; $unlinkTitle = $this->lang->story->unlink; $disabled = !$canUnlinkStory; if($execution->type == 'project') { $unlinkModule = 'projectstory'; $unlinkStoryTip = $this->lang->execution->confirmUnlinkExecutionStory; static $executionStories = array(); if(!isset($executionStories[$execution->id])) { $executions = $this->dao->select('*')->from(TABLE_EXECUTION)->where('parent')->eq($execution->id)->andWhere('type')->ne('project')->fetchAll('id'); $executionStories[$execution->id] = $this->dao->select('project,story')->from(TABLE_PROJECTSTORY)->where('project')->in(array_keys($executions))->fetchPairs('story', 'story'); } if(isset($executionStories[$execution->id][$story->id])) { $disabled = true; $unlinkTitle = $this->lang->execution->notAllowedUnlinkStory; } } $unlinkStoryTip = json_encode(array('message' => array('html' => "{$unlinkStoryTip}"))); $actions[] = array('name' => 'unlink', 'className' => 'ajax-submit', 'data-confirm' => $unlinkStoryTip, 'url' => $canUnlinkStory ? $unlinkStoryLink : null, 'disabled' => $disabled, 'title' => $unlinkTitle); } } return $actions; } /** * 检查当前账号是否是超级评审人。 * Check account is super reviewer or not. * * @access protected * @return bool */ protected function isSuperReviewer(): bool { return str_contains(',' . zget($this->config->story, 'superReviewers', '') . ',', ",{$this->app->user->account},"); } /** * 更新父需求的状态。 * Update parent story status. * * @param int $parentID * @param string $status * @access protected * @return object|false */ protected function doUpdateParentStatus(int $parentID, string $status): object|false { if(empty($parentID)) return false; $oldParentStory = $this->dao->select('*')->from(TABLE_STORY)->where('id')->eq($parentID)->andWhere('deleted')->eq(0)->fetch(); $now = helper::now(); $story = new stdclass(); $story->status = $status; $story->stage = 'wait'; if(strpos('launched,active,changing,draft', $status) !== false) { $story->assignedTo = $oldParentStory->openedBy; $story->assignedDate = $now; $story->closedBy = ''; $story->closedReason = ''; $story->closedDate = null; $story->reviewedBy = ''; $story->reviewedDate = null; } if($status == 'closed') { $story->assignedTo = 'closed'; $story->assignedDate = $now; $story->closedBy = $this->app->user->account; $story->closedDate = $now; $story->closedReason = 'done'; $story->closedReason = 'done'; } $story->lastEditedBy = $this->app->user->account; $story->lastEditedDate = $now; $story->parent = '-1'; $this->dao->update(TABLE_STORY)->data($story)->where('id')->eq($parentID)->exec(); return $story; } }