* @package story * @version $Id: model.php 5145 2013-07-15 06:47:26Z chencongzhi520@gmail.com $ * @link http://www.zentao.net */ class storyModel extends model { /** * Get a story by id. * * @param int $storyID * @param int $version * @param bool $setImgSize * @access public * @return object|false */ public function getByID(int $storyID, int $version = 0, bool $setImgSize = false): object|false { $story = $this->dao->select('*')->from(TABLE_STORY)->where('id')->eq($storyID)->andWhere('vision')->eq($this->config->vision)->fetch(); if(!$story) return false; $story->children = array(); if($version == 0) $version = $story->version; $this->loadModel('file'); $spec = $this->dao->select('title,spec,verify,files')->from(TABLE_STORYSPEC)->where('story')->eq($storyID)->andWhere('version')->eq($version)->fetch(); $story->title = !empty($spec->title) ? $spec->title : ''; $story->spec = !empty($spec->spec) ? $spec->spec : ''; $story->verify = !empty($spec->verify) ? $spec->verify : ''; $story->files = !empty($spec->files) ? $this->file->getByIdList($spec->files) : array(); $story->stages = $this->dao->select('*')->from(TABLE_STORYSTAGE)->where('story')->eq($storyID)->fetchPairs('branch', 'stage'); $story = $this->file->replaceImgURL($story, 'spec,verify'); if($setImgSize) $story->spec = $this->file->setImgSize($story->spec); if($setImgSize) $story->verify = $this->file->setImgSize($story->verify); $twinsIdList = $storyID . ($story->twins ? ",{$story->twins}" : ''); $story->executions = $this->dao->select('t1.project, t2.name, t2.status, t2.type, t2.multiple')->from(TABLE_PROJECTSTORY)->alias('t1') ->leftJoin(TABLE_EXECUTION)->alias('t2')->on('t1.project = t2.id') ->where('t2.type')->in('sprint,stage,kanban') ->andWhere('t1.story')->in($twinsIdList) ->orderBy('t1.`order` DESC') ->fetchAll('project'); $story->tasks = $this->dao->select('id,name,assignedTo,execution,project,status,consumed,`left`,type')->from(TABLE_TASK)->where('deleted')->eq(0)->andWhere('story')->in($twinsIdList)->orderBy('id DESC')->fetchGroup('execution'); if($story->toBug) $story->toBugTitle = $this->dao->findById($story->toBug)->from(TABLE_BUG)->fetch('title'); if($story->parent > 0) $story->parentName = $this->dao->findById($story->parent)->from(TABLE_STORY)->fetch('title'); if($story->fromStory) $story->sourceName = $this->dao->select('title')->from(TABLE_STORY)->where('id')->eq($story->fromStory)->fetch('title'); if($story->parent == '-1') $story->children = $this->dao->select('*')->from(TABLE_STORY)->where('parent')->eq($storyID)->andWhere('deleted')->eq(0)->fetchAll('id'); if($story->plan) { $plans = $this->dao->select('id,title,branch')->from(TABLE_PRODUCTPLAN)->where('id')->in($story->plan)->fetchAll('id'); foreach($plans as $planID => $plan) { $story->planTitle[$planID] = $plan->title; if($plan->branch and !isset($story->stages[$plan->branch]) and empty($story->branch)) $story->stages[$plan->branch] = 'planned'; } } $extraStories = array(); if($story->duplicateStory) $extraStories = array($story->duplicateStory); if($story->linkStories) $extraStories = array_merge($extraStories, explode(',', $story->linkStories)); if($story->childStories) $extraStories = array_merge($extraStories, explode(',', $story->childStories)); $extraStories = array_unique($extraStories); if(!empty($extraStories)) $story->extraStories = $this->dao->select('id,title')->from(TABLE_STORY)->where('id')->in($extraStories)->fetchPairs(); $linkStoryField = $story->type == 'story' ? 'linkStories' : 'linkRequirements'; if($story->{$linkStoryField}) $story->linkStoryTitles = $this->dao->select('id,title')->from(TABLE_STORY)->where('id')->in($story->{$linkStoryField})->fetchPairs(); $story->openedDate = helper::isZeroDate($story->openedDate) ? '' : substr($story->openedDate, 0, 19); $story->assignedDate = helper::isZeroDate($story->assignedDate) ? '' : substr($story->assignedDate, 0, 19); $story->reviewedDate = helper::isZeroDate($story->reviewedDate) ? '' : substr($story->reviewedDate, 0, 19); $story->closedDate = helper::isZeroDate($story->closedDate) ? '' : substr($story->closedDate, 0, 19); $story->lastEditedDate = helper::isZeroDate($story->lastEditedDate) ? '' : substr($story->lastEditedDate, 0, 19); return $story; } /** * Get story pairs. * * @param int $productID * @access public * @return array */ public function getPairs(int $productID = 0): array { return $this->dao->select('id, title')->from(TABLE_STORY) ->where('deleted')->eq('0') ->beginIF($productID)->andWhere('product')->eq($productID) ->fetchPairs(); } /** * Get stories by idList. * * @param array|string $storyIdList * @param string $mode all * @access public * @return array */ public function getByList(array|string $storyIdList, string $mode = ''): array { if(empty($storyIdList)) return array(); return $this->dao->select('t1.*, t2.spec, t2.verify, t3.name as productTitle, t3.deleted as productDeleted') ->from(TABLE_STORY)->alias('t1') ->leftJoin(TABLE_STORYSPEC)->alias('t2')->on('t1.id=t2.story') ->leftJoin(TABLE_PRODUCT)->alias('t3')->on('t1.product=t3.id') ->where('t1.version=t2.version') ->andWhere('t1.id')->in($storyIdList) ->beginIF($mode != 'all')->andWhere('t1.deleted')->eq(0)->fi() ->fetchAll('id'); } /** * 获取执行中已经为该需求创建了测试类型任务的需求ID的键值对。 * Get the key-value pairs for story ID which the test type task has been created for this story in the execution. * * @param array $storyIdList * @param int $executionID * @access public * @return array */ public function getTestStories(array $storyIdList, int $executionID): array { return $this->dao->select('story')->from(TABLE_TASK) ->where('execution')->eq($executionID) ->andWhere('type')->eq('test') ->andWhere('story')->in($storyIdList) ->andWhere('deleted')->eq('0') ->fetchPairs('story', 'story'); } /** * Get story specs. * * @param array $storyIdList * @access public * @return array */ public function getStorySpecs($storyIdList) { return $this->dao->select('story,spec,verify')->from(TABLE_STORYSPEC) ->where('story')->in($storyIdList) ->orderBy('version') ->fetchAll('story'); } /** * Get affected things. * * @param object $story * @access public * @return object */ public function getAffectedScope($story) { $users = $this->loadModel('user')->getPairs('pofirst|nodeleted', "{$story->lastEditedBy},{$story->openedBy},{$story->assignedTo}"); $story = $this->storyTao->getAffectedProjects($story, $users); $story = $this->storyTao->getAffectedBugs($story, $users); $story = $this->storyTao->getAffectedCases($story, $users); $story = $this->storyTao->getAffectedTwins($story, $users); return $story; } /** * Get requirements for story. * * @param int $productID * @access public * @return void */ public function getRequirements($productID) { return $this->dao->select('id,title')->from(TABLE_STORY) ->where('deleted')->eq(0) ->andWhere('product')->eq($productID) ->andWhere('type')->eq('requirement') ->andWhere('status')->notIN('draft,closed') ->fetchPairs(); } /** * Get stories list of a execution. * * @param int $executionID * @param int $productID * @param string $orderBy * @param string $type * @param int|string $param * @param string $storyType * @param array|string $excludeStories * @param object|null $pager * @access public * @return array */ public function getExecutionStories(int $executionID = 0, int $productID = 0, string $orderBy = 't1.`order`_desc', string $type = 'byModule', string $param = '0', string $storyType = 'story', array|string $excludeStories = '', object|null $pager = null) { if(commonModel::isTutorialMode()) return $this->loadModel('tutorial')->getExecutionStories(); if(empty($executionID)) return array(); /* 格式化参数。 */ $orderBy = str_replace('branch_', 't2.branch_', $orderBy); $type = strtolower($type); if(is_string($excludeStories))$excludeStories = explode(',', $excludeStories); /* 获取需求。 */ if($type == 'bysearch') $stories = $this->storyTao->getExecutionStoriesBySearch($executionID, (int)$param, $productID, $orderBy, $storyType, $excludeStories, $pager); if($type != 'bysearch') { /* 根据请求类型和参数,获取查询要用到的条件。 */ $execution = $this->dao->select('*')->from(TABLE_PROJECT)->where('id')->eq($executionID)->fetch(); $modules = $this->storyTao->getModules4ExecutionStories($type, $param); $storyIdList = $this->storyTao->getIdListOfExecutionsByProjectID($type, $executionID); $productParam = ($type == 'byproduct' and $param) ? $param : $this->cookie->storyProductParam; $branchParam = ($type == 'bybranch' and $param !== '') ? $param : $this->cookie->storyBranchParam; if(strpos($branchParam, ',') !== false) list($productParam, $branchParam) = explode(',', $branchParam); /* 设置查询需求的公共 DAO 变量。 */ $type = (strpos('bymodule|byproduct', $type) !== false and $this->session->storyBrowseType) ? $this->session->storyBrowseType : $type; $storyDAO = $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') ->where('t1.project')->eq($executionID) ->andWhere('t2.type')->eq($storyType) ->andWhere('t2.deleted')->eq(0) ->andWhere('t3.deleted')->eq(0) ->beginIF($excludeStories)->andWhere('t2.id')->notIN($excludeStories)->fi() ->beginIF($this->session->storyBrowseType and strpos('changing|', $this->session->storyBrowseType) !== false)->andWhere('t2.status')->in($this->storyTao->getUnclosedStatusKeys())->fi() ->beginIF($modules)->andWhere('t2.module')->in($modules)->fi(); /* 根据传入的 ID 是项目还是执行分别查询需求。 */ if($execution->type == 'project') $stories = $this->storyTao->fetchProjectStories($storyDAO, $productID, $type, $branchParam, $storyIdList, $orderBy, $pager); if($execution->type != 'project') $stories = $this->storyTao->fetchExecutionStories($storyDAO, (int)$productParam, $orderBy, $pager); } $stories = $this->storyTao->fixBranchStoryStage($stories); return $this->storyTao->mergePlanTitleAndChildren($productID, $stories, $storyType); } /** * 获取多个执行的关联需求。 * get execution stories by execution id list. * * @param string $executionIdList * @param int $productID * @param string $orderBy * @param string $type bybranch * @param string $param * @param string $storyType story|requirement * @param array|string $excludeStories * @param object|null $pager * @access public * @return void */ public function batchGetExecutionStories(string $executionIdList = '', int $productID = 0, string $orderBy = 't1.`order`_desc', string $type = 'byModule', string $param = '0', string $storyType = 'story', array|string $excludeStories = '', object|null $pager = null): array { if(empty($executionIdList)) return array(); /* 格式化参数。 */ $type = strtolower($type); if(is_string($excludeStories))$excludeStories = explode(',', $excludeStories); $executions = $this->dao->select('*')->from(TABLE_PROJECT)->where('id')->in($executionIdList)->fetchAll('id'); $hasProject = false; $hasExecution = false; foreach($executions as $execution) { if($execution->type == 'project') $hasProject = true; if($execution->type != 'project') $hasExecution = true; } $modules = $this->storyTao->getModules4ExecutionStories($type, $param); $unclosedStatus = $this->lang->story->statusList; unset($unclosedStatus['closed']); $productParam = ''; $branchParam = ($type == 'bybranch' and $param !== '') ? $param : $this->cookie->storyBranchParam; if(strpos($branchParam, ',') !== false) list($productParam, $branchParam) = explode(',', $branchParam); $stories = $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') ->where('t1.project')->in($executionIdList) ->andWhere('t2.type')->eq($storyType) ->andWhere('t2.deleted')->eq(0) ->andWhere('t3.deleted')->eq(0) ->beginIF($excludeStories)->andWhere('t2.id')->notIN($excludeStories)->fi() ->beginIF($this->session->storyBrowseType and strpos('changing|', $this->session->storyBrowseType) !== false)->andWhere('t2.status')->in(array_keys($unclosedStatus))->fi() ->beginIF($modules)->andWhere('t2.module')->in($modules)->fi() ->beginIF($hasProject) ->beginIF(!empty($productID))->andWhere('t1.product')->eq($productID)->fi() ->beginIF($type == 'bybranch' and $branchParam !== '')->andWhere('t2.branch')->in("0,$branchParam")->fi() ->fi() ->beginIF($hasExecution) ->beginIF(!empty($productParam))->andWhere('t1.product')->eq($productParam)->fi() ->beginIF($this->session->executionStoryBrowseType and strpos('changing|', $this->session->executionStoryBrowseType) !== false)->andWhere('t2.status')->in(array_keys($unclosedStatus))->fi() ->fi() ->orderBy($orderBy) ->page($pager, 't2.id') ->fetchAll('id'); return $this->storyTao->mergePlanTitleAndChildren($productID, $stories, $storyType); } /** * Get stories pairs of a execution. * * @param int $executionID * @param int $productID * @param string|int $branch * @param array|string|int $moduleIdList * @param string $type full|short * @param string $status all|unclosed|review * @access public * @return array */ public function getExecutionStoryPairs(int $executionID = 0, int $productID = 0, string|int $branch = 'all', array|string|int $moduleIdList = '', string $type = 'full', string $status = 'all'): array { if(commonModel::isTutorialMode()) return $this->loadModel('tutorial')->getExecutionStoryPairs(); $stories = $this->dao->select('t2.id, t2.title, t2.module, t2.pri, t2.estimate, t3.name AS product') ->from(TABLE_PROJECTSTORY)->alias('t1') ->leftJoin(TABLE_STORY)->alias('t2')->on('t1.story = t2.id') ->leftJoin(TABLE_PRODUCT)->alias('t3')->on('t1.product = t3.id') ->where('t1.project')->eq($executionID) ->andWhere('t2.deleted')->eq(0) ->andWhere('t2.type')->eq('story') ->beginIF($productID)->andWhere('t2.product')->eq($productID)->fi() ->beginIF($branch !== 'all')->andWhere('t2.branch')->in("0,$branch")->fi() ->beginIF($moduleIdList)->andWhere('t2.module')->in($moduleIdList)->fi() ->beginIF($status == 'unclosed')->andWhere('t2.status')->ne('closed')->fi() ->beginIF($status == 'review')->andWhere('t2.status')->in('draft,changing')->fi() ->beginIF($status == 'active')->andWhere('t2.status')->eq('active')->fi() ->orderBy('t1.`order` desc, t1.`story` desc') ->fetchAll('id'); return empty($stories) ? array() : $this->formatStories($stories, $type); } /** * Get stories list of a plan. * * @param int $planID * @param string $status * @param string $orderBy * @param object $pager * @access public * @return array */ public function getPlanStories($planID, $status = 'all', $orderBy = 'id_desc', $pager = null) { if(strpos($orderBy, 'module') !== false) { $orderBy = (strpos($orderBy, 'module_asc') !== false) ? 't3.path asc' : 't3.path desc'; $stories = $this->dao->select('distinct t1.story, t1.plan, t1.order, t2.*') ->from(TABLE_PLANSTORY)->alias('t1') ->leftJoin(TABLE_STORY)->alias('t2')->on('t1.story = t2.id') ->leftJoin(TABLE_MODULE)->alias('t3')->on('t2.module = t3.id') ->where('t1.plan')->eq($planID) ->beginIF($status and $status != 'all')->andWhere('t2.status')->in($status)->fi() ->andWhere('t2.deleted')->eq(0) ->orderBy($orderBy)->page($pager) ->fetchAll('id'); } else { $stories = $this->dao->select("distinct t1.story, t1.plan, t1.order, t2.*, IF(t2.`pri` = 0, {$this->config->maxPriValue}, t2.`pri`) as priOrder") ->from(TABLE_PLANSTORY)->alias('t1') ->leftJoin(TABLE_STORY)->alias('t2')->on('t1.story = t2.id') ->where('t1.plan')->eq($planID) ->beginIF($status and $status != 'all')->andWhere('t2.status')->in($status)->fi() ->andWhere('t2.deleted')->eq(0) ->orderBy($orderBy)->page($pager) ->fetchAll('id'); } $this->loadModel('common')->saveQueryCondition($this->dao->get(), 'story', false); return $stories; } /** * Get stories pairs of a plan. * * @param int $planID * @param string $status * @access public * @return array */ public function getPlanStoryPairs($planID, $status = 'all') { return $this->dao->select('*')->from(TABLE_STORY) ->where('plan')->eq($planID) ->beginIF($status and $status != 'all')->andWhere('status')->in($status)->fi() ->andWhere('deleted')->eq(0) ->fetchAll(); } /** * Get stories by plan id list. * * @param string|array $planIdList * @access public * @return array */ public function getStoriesByPlanIdList($planIdList = '') { return $this->dao->select('t1.plan as planID, t2.*')->from(TABLE_PLANSTORY)->alias('t1') ->leftJoin(TABLE_STORY)->alias('t2')->on('t1.story=t2.id') ->where('t2.deleted')->eq(0) ->beginIF($planIdList)->andWhere('t1.plan')->in($planIdList)->fi() ->fetchGroup('planID', 'id'); } /** * Create a story. * * @param object $story * @param int $bugID * @access public * @return int|false the id of the created story or false when error. */ public function create(object $story, int $executionID = 0, int $bugID = 0, string $extra = ''): int|false { if(commonModel::isTutorialMode()) return false; $storyID = $this->storyTao->doCreateStory($story); if(!$storyID) return false; /* Upload files. */ $this->loadModel('action'); $this->loadModel('file')->updateObjectID($this->post->uid, $storyID, $story->type); $files = $this->file->saveUpload($story->type, $storyID, 1); /* Add story spec verify. */ $this->storyTao->doCreateSpec($storyID, $story, $files); if($executionID) $this->storyTao->linkToExecutionForCreate($executionID, $storyID, $story, $extra); if($bugID) $this->storyTao->closeBugWhenToStory($bugID, $storyID); if(!empty($story->reviewer)) $this->storyTao->doCreateReviewer($storyID, $story->reviewer); if(!empty($story->URS)) $this->storyTao->doCreateURRelations($storyID, $story->URS); if(!empty($story->parent)) $this->subdivide($story->parent, array($storyID)); if(!empty($story->plan)) { $this->updateStoryOrderOfPlan($storyID, $story->plan); // Set story order in this plan. $this->action->create('productplan', $story->plan, 'linkstory', '', $storyID); } $this->setStage($storyID); $this->loadModel('score')->create('story', 'create',$storyID); /* Create actions. */ $action = $bugID == 0 ? 'Opened' : 'Frombug'; $extra = $bugID == 0 ? '' : $bugID; $this->action->create('story', $storyID, $action, '', $extra); /* Record submit review action. */ if($story->status == 'reviewing') $this->action->create('story', $storyID, 'submitReview'); return $storyID; } /** * 创建孪生需求。 * Create twins stories. * * @param object $storyData * @param int $objectID * @param int $bugID * @param string $extra * @access public * @return int|false */ public function createTwins(object $storyData, int $objectID, int $bugID, string $extra = ''): int|false { if(empty($storyData->branches)) return $this->create($storyData, $objectID, $bugID, $extra); $storyIdList = array(); $mainStoryID = 0; foreach($storyData->branches as $key => $branchID) { $storyData->branch = (int)$branchID; $storyData->module = $storyData->modules[$key]; $storyData->plan = $storyData->plans[$key]; $storyID = $this->create($storyData, $objectID, $bugID, $extra); $storyIdList[$storyID] = $storyID; if(empty($mainStoryID)) $mainStoryID = $storyID; } $this->storyTao->updateTwins($storyIdList); return $mainStoryID; } /** * Create story from gitlab issue. * * @param object $story * @param int $executionID * @access public * @return int */ public function createStoryFromGitlabIssue($story, $executionID) { $story->status = 'active'; $story->stage = 'projected'; $story->openedBy = $this->app->user->account; $story->version = 1; $story->pri = 3; $story->assignedDate = isset($story->assignedTo) ? helper::now() : 0; if(isset($story->execution)) unset($story->execution); $requiredFields = $this->config->story->create->requiredFields; $this->dao->insert(TABLE_STORY)->data($story, 'spec,verify,gitlab,gitlabProject')->autoCheck()->batchCheck($requiredFields, 'notempty')->exec(); if(!dao::isError()) { $storyID = $this->dao->lastInsertID(); $data = new stdclass(); $data->story = $storyID; $data->version = 1; $data->title = $story->title; $data->spec = $story->spec; $data->verify = $story->spec; $this->dao->insert(TABLE_STORYSPEC)->data($data)->exec(); /* Link story to execution. */ $this->linkStory($executionID, $story->product, $storyID); return $storyID; } return false; } /** * Batch create stories. * * @param array $stories * @param int $productID * @param string $branch * @param string $type * @param int $URID * @access public * @return array */ public function batchCreate(array $stories, int $productID = 0, string $branch = '', string $type = 'story', int $URID = 0): array { $this->loadModel('action'); $storyIdList = array(); $link2Plans = array(); foreach($stories as $i => $story) { $storyID = $this->storyTao->doCreateStory($story); if(!$storyID) return array(); $this->storyTao->doCreateSpec($storyID, $story); /* Update product plan stories order. */ if(!empty($story->reviewer)) $this->storyTao->doCreateReviewer($storyID, $story->reviewer); if($story->plan) { $this->updateStoryOrderOfPlan($storyID, $story->plan); $link2Plans[$story->plan] = empty($link2Plans[$story->plan]) ? $storyID : "{$link2Plans[$story->plan]},$storyID"; } $this->setStage($storyID); $this->executeHooks($storyID); $this->action->create('story', $storyID, 'Opened', ''); $storyIdList[$i] = $storyID; } if(!dao::isError()) { /* Remove upload image file and session. */ if($this->session->storyImagesFile) { $file = current($_SESSION['storyImagesFile']); $realPath = dirname($file['realpath']); if(is_dir($realPath)) $this->app->loadClass('zfile')->removeDir($realPath); unset($_SESSION['storyImagesFile']); } $this->loadModel('score')->create('story', 'create',$storyID); $this->score->create('ajax', 'batchCreate'); if($URID && $storyIdList) $this->subdivide($URID, $storyIdList); foreach($link2Plans as $planID => $stories) $this->action->create('productplan', $planID, 'linkstory', '', $stories); } return $storyIdList; } /** * Change a story. * * @param int $storyID * @access public * @return array the change of the story. */ public function change($storyID) { $specChanged = false; $oldStory = $this->getById($storyID); if(!empty($_POST['lastEditedDate']) and $oldStory->lastEditedDate != $this->post->lastEditedDate) { dao::$errors[] = $this->lang->error->editedByOther; return false; } if(strpos($this->config->story->change->requiredFields, 'comment') !== false and !$this->post->comment) { dao::$errors[] = sprintf($this->lang->error->notempty, $this->lang->comment); return false; } if(isset($_POST['reviewer'])) $_POST['reviewer'] = array_filter($_POST['reviewer']); if(!$this->post->needNotReview and empty($_POST['reviewer'])) { dao::$errors[] = $this->lang->story->errorEmptyReviewedBy; return false; } $story = fixer::input('post')->stripTags($this->config->story->editor->change['id'], $this->config->allowedTags)->get(); $oldStoryReviewers = $this->getReviewerPairs($storyID, $oldStory->version); $_POST['reviewer'] = isset($_POST['reviewer']) ? $_POST['reviewer'] : array(); $reviewerHasChanged = (array_diff(array_keys($oldStoryReviewers), $_POST['reviewer']) or array_diff($_POST['reviewer'], array_keys($oldStoryReviewers))); if($story->spec != $oldStory->spec or $story->verify != $oldStory->verify or $story->title != $oldStory->title or $this->loadModel('file')->getCount() or $reviewerHasChanged or isset($story->deleteFiles)) $specChanged = true; $now = helper::now(); $story = fixer::input('post') ->callFunc('title', 'trim') ->setDefault('lastEditedBy', $this->app->user->account) ->setDefault('deleteFiles', array()) ->add('id', $storyID) ->add('lastEditedDate', $now) ->setIF($specChanged, 'version', (int)$oldStory->version + 1) ->setIF($specChanged, 'reviewedBy', '') ->setIF($specChanged, 'changedBy', $this->app->user->account) ->setIF($specChanged, 'changedDate', $now) ->setIF($specChanged, 'closedBy', '') ->setIF($specChanged, 'closedReason', '') ->setIF($specChanged and $oldStory->reviewedBy, 'reviewedDate', null) ->setIF($specChanged and $oldStory->closedBy, 'closedDate', null) ->setIF(!$specChanged, 'status', $oldStory->status) ->stripTags($this->config->story->editor->change['id'], $this->config->allowedTags) ->remove('files,labels,reviewer,comment,needNotReview,uid') ->get(); $story = $this->loadModel('file')->processImgURL($story, $this->config->story->editor->change['id'], $this->post->uid); $this->dao->update(TABLE_STORY)->data($story, 'spec,verify,deleteFiles,relievedTwins') ->autoCheck() ->batchCheck($this->config->story->change->requiredFields, 'notempty') ->checkFlow() ->where('id')->eq((int)$storyID)->exec(); if(!dao::isError()) { if($specChanged) { $this->file->updateObjectID($this->post->uid, $storyID, 'story'); $addedFiles = $this->file->saveUpload($oldStory->type, $storyID, $story->version); $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->story = $storyID; $data->version = $story->version; $data->title = $story->title; $data->spec = $story->spec; $data->verify = $story->verify; $data->files = $story->files = $addedFiles . trim($storyFiles, ','); $this->dao->insert(TABLE_STORYSPEC)->data($data)->exec(); /* Sync twins. */ if(!isset($story->relievedTwins) and !empty($oldStory->twins)) { foreach(explode(',', trim($oldStory->twins, ',')) as $twinID) { $data->story = $twinID; $this->dao->insert(TABLE_STORYSPEC)->data($data)->exec(); } } /* IF is story and has changed, update its relation version to new. */ if($oldStory->type == 'story') { $newStory = $this->getById($storyID); $this->dao->update(TABLE_STORY)->set('URChanged')->eq(0)->where('id')->eq($oldStory->id)->exec(); $this->updateStoryVersion($newStory); } else { /* IF is requirement changed, notify its relation. */ $relations = $this->dao->select('BID')->from(TABLE_RELATION) ->where('AType')->eq('requirement') ->andWhere('BType')->eq('story') ->andWhere('relation')->eq('subdivideinto') ->andWhere('AID')->eq($storyID) ->fetchPairs(); foreach($relations as $relationID) $this->dao->update(TABLE_STORY)->set('URChanged')->eq(1)->where('id')->eq($relationID)->exec(); } /* Update the reviewer. */ foreach($_POST['reviewer'] as $reviewer) { $reviewData = new stdclass(); $reviewData->story = $storyID; $reviewData->version = $story->version; $reviewData->reviewer = $reviewer; $this->dao->insert(TABLE_STORYREVIEW)->data($reviewData)->exec(); /* Sync twins. */ if(!isset($story->relievedTwins) and !empty($oldStory->twins)) { foreach(explode(',', trim($oldStory->twins, ',')) as $twinID) { $reviewData->story = $twinID; $this->dao->insert(TABLE_STORYREVIEW)->data($reviewData)->exec(); } } } if($reviewerHasChanged) { $oldStory->reviewers = implode(',', array_keys($oldStoryReviewers)); $story->reviewers = implode(',', $_POST['reviewer']); } } $changes = common::createChanges($oldStory, $story); if(isset($story->relievedTwins)) { $this->dbh->exec("UPDATE " . TABLE_STORY . " SET twins = REPLACE(twins, ',$storyID,', ',') WHERE `product` = $oldStory->product"); $this->dao->update(TABLE_STORY)->set('twins')->eq('')->where('id')->eq($storyID)->orWhere('twins')->eq(',')->exec(); if(!dao::isError()) $this->loadModel('action')->create('story', $storyID, 'relieved'); } elseif(!empty($oldStory->twins)) { $this->syncTwins($oldStory->id, $oldStory->twins, $changes, 'Changed'); } return $changes; } } /** * Update a story. * * @param int $storyID * @access public * @return array the changes of the story. */ public function update($storyID) { $now = helper::now(); $oldStory = $this->getById($storyID); if(!empty($_POST['lastEditedDate']) and $oldStory->lastEditedDate != $this->post->lastEditedDate) { dao::$errors[] = $this->lang->error->editedByOther; return false; } if(strpos('draft,changing', $oldStory->status) !== false and $this->checkForceReview() and empty($_POST['reviewer'])) { dao::$errors[] = $this->lang->story->notice->reviewerNotEmpty; return false; } $storyPlan = array(); if(!empty($_POST['plan'])) $storyPlan = is_array($_POST['plan']) ? array_filter($_POST['plan']) : array($_POST['plan']); if(count($storyPlan) > 1) { $oldStoryPlan = !empty($oldStory->planTitle) ? array_keys($oldStory->planTitle) : array(); $oldPlanDiff = array_diff($storyPlan, $oldStoryPlan); $storyPlanDiff = array_diff($oldStoryPlan, $storyPlan); if(!empty($oldPlanDiff) or !empty($storyPlanDiff)) { dao::$errors[] = $this->lang->story->notice->changePlan; return false; } } /* Unchanged product when editing requirements on site. */ $hasProduct = $this->dao->select('t2.hasProduct')->from(TABLE_PROJECTPRODUCT)->alias('t1') ->leftJoin(TABLE_PROJECT)->alias('t2')->on('t1.project = t2.id') ->where('t1.product')->eq($oldStory->product) ->andWhere('t2.deleted')->eq(0) ->fetch('hasProduct'); $_POST['product'] = (!empty($hasProduct) && !$hasProduct) ? $oldStory->product : $this->post->product; $story = fixer::input('post') ->cleanInt('product,module,pri,duplicateStory,parent') ->cleanFloat('estimate') ->setDefault('lastEditedBy', $this->app->user->account) ->setDefault('reviewedBy', $oldStory->reviewedBy) ->setDefault('mailto', '') ->setDefault('deleteFiles', array()) ->add('id', $storyID) ->add('lastEditedDate', $now) ->setDefault('plan,notifyEmail', '') ->setDefault('product', $oldStory->product) ->setDefault('branch', $oldStory->branch) ->setIF(!$this->post->linkStories, 'linkStories', '') ->setIF($this->post->assignedTo != $oldStory->assignedTo, 'assignedDate', $now) ->setIF($this->post->closedBy && $oldStory->closedDate == '', 'closedDate', $now) ->setIF($this->post->closedReason && $oldStory->closedDate == '', 'closedDate', $now) ->setIF($this->post->closedBy || $this->post->closedReason != false, 'status', 'closed') ->setIF($this->post->closedReason && $this->post->closedBy == false, 'closedBy', $this->app->user->account) ->setIF($this->post->stage == 'released', 'releasedDate', $now) ->setIF(!in_array($this->post->source, $this->config->story->feedbackSource), 'feedbackBy', '') ->setIF(!in_array($this->post->source, $this->config->story->feedbackSource), 'notifyEmail', '') ->setIF(!empty($_POST['plan'][0]) and $oldStory->stage == 'wait', 'stage', 'planned') ->setIF(!isset($_POST['title']), 'title', $oldStory->title) ->setIF(!isset($_POST['spec']), 'spec', $oldStory->spec) ->setIF(!isset($_POST['verify']), 'verify', $oldStory->verify) ->stripTags($this->config->story->editor->edit['id'], $this->config->allowedTags) ->join('mailto', ',') ->join('childStories', ',') ->remove('files,labels,comment,contactListMenu,reviewer,needNotReview') ->get(); if($this->post->linkStories) $story->linkStories = implode(',', array_unique($this->post->linkStories)); if($this->post->linkRequirements) $story->linkRequirements = implode(',', array_unique($this->post->linkRequirements)); /* Relieve twins when change product. */ if(!empty($oldStory->twins) and $story->product != $oldStory->product) { $this->dbh->exec("UPDATE " . TABLE_STORY . " SET twins = REPLACE(twins, ',$storyID,', ',') WHERE `product` = $oldStory->product"); $this->dao->update(TABLE_STORY)->set('twins')->eq('')->where('id')->eq($storyID)->orWhere('twins')->eq(',')->exec(); $oldStory->twins = ''; } if($oldStory->type == 'story' and !isset($story->linkStories)) $story->linkStories = ''; if($oldStory->type == 'requirement' and !isset($story->linkRequirements)) $story->linkRequirements = ''; if($oldStory->status == 'changing' and $story->status == 'draft') $story->status = 'changing'; if(isset($story->plan) and is_array($story->plan)) $story->plan = trim(implode(',', $story->plan), ','); if(isset($_POST['branch']) and $_POST['branch'] == 0) $story->branch = 0; if(isset($story->stage) and $oldStory->stage != $story->stage) $story->stagedBy = (strpos('tested|verified|released|closed', $story->stage) !== false) ? $this->app->user->account : ''; $story = $this->loadModel('file')->processImgURL($story, $this->config->story->editor->edit['id'], $this->post->uid); if(isset($_POST['reviewer']) or isset($_POST['needNotReview'])) { $_POST['reviewer'] = isset($_POST['needNotReview']) ? array() : array_filter($_POST['reviewer']); $oldReviewer = $this->getReviewerPairs($storyID, $oldStory->version); /* Update story reviewer. */ $this->dao->delete()->from(TABLE_STORYREVIEW) ->where('story')->eq($storyID) ->andWhere('version')->eq($oldStory->version) ->beginIF($oldStory->status == 'reviewing')->andWhere('reviewer')->notin(implode(',', $_POST['reviewer'])) ->exec(); /* Sync twins. */ if(!empty($oldStory->twins)) { foreach(explode(',', trim($oldStory->twins, ',')) as $twinID) { $this->dao->delete()->from(TABLE_STORYREVIEW) ->where('story')->eq($twinID) ->andWhere('version')->eq($oldStory->version) ->beginIF($oldStory->status == 'reviewing')->andWhere('reviewer')->notin(implode(',', $_POST['reviewer'])) ->exec(); } } foreach($_POST['reviewer'] as $reviewer) { if($oldStory->status == 'reviewing' and in_array($reviewer, array_keys($oldReviewer))) continue; $reviewData = new stdclass(); $reviewData->story = $storyID; $reviewData->version = $oldStory->version; $reviewData->reviewer = $reviewer; $this->dao->insert(TABLE_STORYREVIEW)->data($reviewData)->exec(); /* Sync twins. */ if(!empty($oldStory->twins)) { foreach(explode(',', trim($oldStory->twins, ',')) as $twinID) { $reviewData->story = $twinID; $this->dao->insert(TABLE_STORYREVIEW)->data($reviewData)->exec(); } } } if($oldStory->status == 'reviewing') $story = $this->updateStoryByReview($storyID, $oldStory, $story); if(strpos('draft,changing', $oldStory->status) != false) $story->reviewedBy = ''; $oldStory->reviewers = implode(',', array_keys($oldReviewer)); $story->reviewers = implode(',', array_keys($this->getReviewerPairs($storyID, $oldStory->version))); } $this->dao->update(TABLE_STORY) ->data($story, 'reviewers,spec,verify,finalResult,deleteFiles') ->autoCheck() ->checkIF(isset($story->closedBy), 'closedReason', 'notempty') ->checkIF(isset($story->closedReason) and $story->closedReason == 'done', 'stage', 'notempty') ->checkIF(isset($story->closedReason) and $story->closedReason == 'duplicate', 'duplicateStory', 'notempty') ->checkIF($story->notifyEmail, 'notifyEmail', 'email') ->checkFlow() ->where('id')->eq((int)$storyID)->exec(); if(dao::isError()) return false; if(!dao::isError()) { $this->file->updateObjectID($this->post->uid, $storyID, 'story'); $addedFiles = $this->file->saveUpload($oldStory->type, $storyID, $oldStory->version); if($story->spec != $oldStory->spec or $story->verify != $oldStory->verify or $story->title != $oldStory->title or !empty($story->deleteFiles) or !empty($addedFiles)) { $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 = $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(); } } } if($story->product != $oldStory->product) { $this->updateStoryProduct($storyID, $story->product); if($oldStory->parent == '-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); } } $this->loadModel('action'); if($story->plan != $oldStory->plan) { if(!empty($oldStory->plan)) $this->action->create('productplan', $oldStory->plan, 'unlinkstory', '', $storyID); if(!empty($story->plan)) $this->action->create('productplan', $story->plan, 'linkstory', '', $storyID); } $changed = $story->parent != $oldStory->parent; if($oldStory->parent > 0) { $oldParentStory = $this->dao->select('*')->from(TABLE_STORY)->where('id')->eq($oldStory->parent)->fetch(); $this->updateParentStatus($storyID, $oldStory->parent, !$changed); if($changed) { $oldChildren = $this->dao->select('id')->from(TABLE_STORY)->where('parent')->eq($oldStory->parent)->andWhere('deleted')->eq(0)->fetchPairs('id', 'id'); if(empty($oldChildren)) $this->dao->update(TABLE_STORY)->set('parent')->eq(0)->where('id')->eq($oldStory->parent)->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($oldStory->parent)->exec(); $this->action->create('story', $storyID, 'unlinkParentStory', '', $oldStory->parent, '', false); $actionID = $this->action->create('story', $oldStory->parent, 'unLinkChildrenStory', '', $storyID, '', false); $newParentStory = $this->dao->select('*')->from(TABLE_STORY)->where('id')->eq($oldStory->parent)->fetch(); $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(); $this->dao->update(TABLE_STORY)->set('parent')->eq(-1)->where('id')->eq($story->parent)->exec(); $this->updateParentStatus($storyID, $story->parent, !$changed); if($changed) { $children = $this->dao->select('id')->from(TABLE_STORY)->where('parent')->eq($story->parent)->andWhere('deleted')->eq(0)->fetchPairs('id', 'id'); $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); $newParentStory = $this->dao->select('*')->from(TABLE_STORY)->where('id')->eq($story->parent)->fetch(); $changes = common::createChanges($parentStory, $newParentStory); if(!empty($changes)) $this->action->logHistory($actionID, $changes); } } if(isset($story->closedReason) and $story->closedReason == 'done') $this->loadModel('score')->create('story', 'close'); /* Set new stage and update story sort of plan when story plan has changed. */ if($oldStory->plan != $story->plan) { $this->updateStoryOrderOfPlan($storyID, $story->plan, $oldStory->plan); // Insert a new story sort in this plan. if(empty($oldStory->plan) or empty($story->plan)) $this->setStage($storyID); // Set new stage for this story. } if(isset($story->stage) and $oldStory->stage != $story->stage) { $executionIdList = $this->dao->select('t1.project')->from(TABLE_PROJECTSTORY)->alias('t1') ->leftJoin(TABLE_PROJECT)->alias('t2')->on('t1.project = t2.id') ->where('t1.story')->eq($storyID) ->andWhere('t2.deleted')->eq(0) ->andWhere('t2.type')->in('sprint,stage,kanban') ->fetchPairs(); $this->loadModel('kanban'); foreach($executionIdList as $executionID) $this->kanban->updateLane($executionID, 'story', $storyID); } unset($oldStory->parent); unset($story->parent); if(($this->config->edition == 'biz' || $this->config->edition == 'max') && $oldStory->feedback) $this->loadModel('feedback')->updateStatus('story', $oldStory->feedback, $story->status, $oldStory->status); $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($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($linkedStories)->where('id')->eq((int)$changeStoryID)->exec(); } } $changes = common::createChanges($oldStory, $story); if($this->post->comment != '' or !empty($changes)) { $action = !empty($changes) ? 'Edited' : 'Commented'; $actionID = $this->action->create('story', $storyID, $action, $this->post->comment); $this->action->logHistory($actionID, $changes); if(isset($story->finalResult)) $this->recordReviewAction($story); } if(!empty($oldStory->twins)) $this->syncTwins($oldStory->id, $oldStory->twins, $changes, 'Edited'); return true; } } /** * Update story product. * * @param int $storyID * @param int $productID * @access public * @return void */ public function updateStoryProduct($storyID, $productID) { $this->dao->update(TABLE_STORY)->set('product')->eq($productID)->where('id')->eq($storyID)->exec(); $this->dao->update(TABLE_PROJECTSTORY)->set('product')->eq($productID)->where('story')->eq($storyID)->exec(); $storyProjects = $this->dao->select('project')->from(TABLE_PROJECTSTORY)->where('story')->eq($storyID)->orderBy('project')->fetchPairs('project', 'project'); $linkedProjects = $this->dao->select('project')->from(TABLE_PROJECTPRODUCT)->where('project')->in($storyProjects)->andWhere('product')->eq($productID)->orderBy('project')->fetchPairs('project','project'); $unlinkedProjects = array_diff($storyProjects, $linkedProjects); foreach($unlinkedProjects as $projectID) { $data = new stdclass(); $data->project = $projectID; $data->product = $productID; $this->dao->replace(TABLE_PROJECTPRODUCT)->data($data)->exec(); } } /** * Update parent status. * * @param int $storyID * @param int $parentID * @param bool $createAction * @access public * @return mixed */ public function updateParentStatus($storyID, $parentID = 0, $createAction = true) { $childStory = $this->dao->select('*')->from(TABLE_STORY)->where('id')->eq($storyID)->fetch(); if(empty($parentID)) $parentID = $childStory->parent; if($parentID <= 0) return true; $oldParentStory = $this->dao->select('*')->from(TABLE_STORY)->where('id')->eq($parentID)->andWhere('deleted')->eq(0)->fetch(); if(empty($oldParentStory)) return $this->dao->update(TABLE_STORY)->set('parent')->eq('0')->where('id')->eq($storyID)->exec(); if($oldParentStory->parent != '-1') $this->dao->update(TABLE_STORY)->set('parent')->eq('-1')->where('id')->eq($parentID)->exec(); $this->computeEstimate($parentID); $childrenStatus = $this->dao->select('id,status')->from(TABLE_STORY)->where('parent')->eq($parentID)->andWhere('deleted')->eq(0)->fetchPairs('status', 'status'); if(empty($childrenStatus)) return $this->dao->update(TABLE_STORY)->set('parent')->eq('0')->where('id')->eq($parentID)->exec(); $status = $oldParentStory->status; if(count($childrenStatus) == 1 and current($childrenStatus) == 'closed') $status = current($childrenStatus); // Close parent story. if($oldParentStory->status == 'closed') $status = $this->getActivateStatus($parentID); // Activate parent story. if($status and $oldParentStory->status != $status) { $now = helper::now(); $story = new stdclass(); $story->status = $status; $story->stage = 'wait'; if(strpos('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(); if(!dao::isError()) { if(!$createAction) return $story; $newParentStory = $this->dao->select('*')->from(TABLE_STORY)->where('id')->eq($parentID)->fetch(); $changes = common::createChanges($oldParentStory, $newParentStory); $action = ''; $preStatus = ''; if(strpos('active,draft,changing', $status) !== false) $action = 'Activated'; if($status == 'closed') { /* Record the status before closed. */ $action = 'closedbysystem'; $preStatus = $oldParentStory->status; $isChanged = $oldParentStory->changedBy ? true : false; if($preStatus == 'reviewing') $preStatus = $isChanged ? 'changing' : 'draft'; } if($action) { $actionID = $this->loadModel('action')->create('story', $parentID, $action, '', $preStatus, '', false); $this->action->logHistory($actionID, $changes); } if(($this->config->edition == 'biz' || $this->config->edition == 'max') && $oldParentStory->feedback) $this->loadModel('feedback')->updateStatus('story', $oldParentStory->feedback, $newParentStory->status, $oldParentStory->status); } } else { if(!dao::isError()) { $newParentStory = $this->dao->select('*')->from(TABLE_STORY)->where('id')->eq($parentID)->fetch(); $changes = common::createChanges($oldParentStory, $newParentStory); if($changes) { $actionID = $this->loadModel('action')->create('story', $parentID, 'Edited', '', '', '', false); $this->action->logHistory($actionID, $changes); } } } } /** * If story changed, update relation table version filed. * * @param object $story * @access public * @return void */ public function updateStoryVersion($story) { $changedStories = $this->getChangedStories($story); if(!empty($changedStories)) { foreach($changedStories as $changedStory) { $this->dao->update(TABLE_RELATION) ->set('AVersion')->eq($changedStory->version) ->where('AType')->eq('requirement') ->andWhere('BType')->eq('story') ->andWhere('relation')->eq('subdivideinto') ->andWhere('AID')->eq($changedStory->id) ->andWhere('BID')->eq($story->id) ->exec(); } } } /** * Update the story order of plan. * * @param int $storyID * @param string $planIdList * @param string $oldPlanIdList * @access public * @return void */ public function updateStoryOrderOfPlan(int $storyID, string $planIdList = '', string $oldPlanIdList = ''): void { $planIdList = $planIdList ? explode(',', $planIdList) : array(); $oldPlanIdList = $oldPlanIdList ? explode(',', $oldPlanIdList) : array(); /* Get the ids to be inserted and deleted by comparing plan ids. */ $plansTobeInsert = array_diff($planIdList, $oldPlanIdList); $plansTobeDelete = array_diff($oldPlanIdList, $planIdList); /* Delete old story sort of plan. */ if(!empty($plansTobeDelete)) $this->dao->delete()->from(TABLE_PLANSTORY)->where('story')->eq($storyID)->andWhere('plan')->in($plansTobeDelete)->exec(); if(!empty($plansTobeInsert)) { /* Get last story order of plan list. */ $maxOrders = $this->dao->select('plan, max(`order`) as `order`')->from(TABLE_PLANSTORY)->where('plan')->in($plansTobeInsert)->groupBy('plan')->fetchPairs(); foreach($plansTobeInsert as $planID) { /* Set story order in new plan. */ $data = new stdClass(); $data->plan = $planID; $data->story = $storyID; $data->order = zget($maxOrders, $planID, 0) + 1; $this->dao->replace(TABLE_PLANSTORY)->data($data)->exec(); } } } /** * Compute parent story estimate. * * @param int $storyID * @access public * @return bool */ public function computeEstimate(int $storyID): bool { if(!$storyID) return true; $estimates = $this->dao->select('`id`,`estimate`')->from(TABLE_STORY)->where('parent')->eq($storyID)->andWhere('deleted')->eq(0)->fetchPairs('id', 'estimate'); if(empty($estimates)) return true; $estimate = round(array_sum($estimates), 2); $this->dao->update(TABLE_STORY)->set('estimate')->eq($estimate)->where('id')->eq($storyID)->exec(); return !dao::isError(); } /** * Batch update stories. * * @param array $stories * @access public * @return array */ public function batchUpdate(array $stories): bool { /* Init vars. */ $oldStories = $this->getByList(array_keys($stories)); $unlinkPlans = array(); $link2Plans = array(); foreach($stories as $storyID => $story) { unset($story->status); $oldStory = $oldStories[$storyID]; if($story->plan != $oldStory->plan) { if(!empty($oldStory->plan)) $unlinkPlans[$oldStory->plan] = empty($unlinkPlans[$oldStory->plan]) ? $storyID : "{$unlinkPlans[$oldStory->plan]},{$storyID}"; if(!empty($story->plan)) $link2Plans[$story->plan] = empty($link2Plans[$story->plan]) ? $storyID : "{$link2Plans[$story->plan]},{$storyID}"; } } $this->loadModel('action'); foreach($stories as $storyID => $story) { $oldStory = $oldStories[$storyID]; $this->dao->update(TABLE_STORY)->data($story) ->autoCheck() ->checkIF($story->closedBy, 'closedReason', 'notempty') ->checkIF($story->closedReason == 'done', 'stage', 'notempty') ->checkIF($story->closedReason == 'duplicate', 'duplicateStory', 'notempty') ->where('id')->eq((int)$storyID) ->exec(); if(dao::isError()) return false; /* Update story sort of plan when story plan has changed. */ if($oldStory->plan != $story->plan) $this->updateStoryOrderOfPlan($storyID, $story->plan, $oldStory->plan); $this->executeHooks($storyID); if($oldStory->type == 'story') $this->batchChangeStage(array($storyID), $story->stage); if($story->closedReason == 'done') $this->loadModel('score')->create('story', 'close'); $changes = common::createChanges($oldStory, $story); if($changes) { $actionID = $this->action->create('story', $storyID, 'Edited'); $this->action->logHistory($actionID, $changes); } if($this->config->edition != 'open' && $oldStory->feedback && !isset($feedbacks[$oldStory->feedback])) { $feedbacks[$oldStory->feedback] = $oldStory->feedback; $this->loadModel('feedback')->updateStatus('story', $oldStory->feedback, $story->status, $oldStory->status); } } $this->loadModel('score')->create('ajax', 'batchEdit'); foreach($unlinkPlans as $planID => $stories) $this->action->create('productplan', $planID, 'unlinkstory', '', $stories); foreach($link2Plans as $planID => $stories) $this->action->create('productplan', $planID, 'linkstory', '', $stories); return true; } /** * Review a story. * * @param int $storyID * @param object $story * @param string $comment * @access public * @return bool */ public function review(int $storyID, object $story, string $comment = ''): bool { $oldStory = $this->dao->findById($storyID)->from(TABLE_STORY)->fetch(); $now = helper::now(); $this->dao->update(TABLE_STORYREVIEW) ->set('result')->eq($story->result) ->set('reviewDate')->eq($now) ->where('story')->in($storyID . ($oldStory->twins ? ",{$oldStory->twins}" : '')) ->andWhere('version')->eq($oldStory->version) ->andWhere('reviewer')->eq($this->app->user->account) ->exec(); $story = $this->updateStoryByReview($storyID, $oldStory, $story); $skipFields = 'finalResult,result'; $isSuperReviewer = $this->storyTao->isSuperReviewer(); if($isSuperReviewer) { $reviewers = $this->getReviewerPairs($storyID, $oldStory->version); if(count($reviewers) > 1) $skipFields .= ',closedReason'; } $this->dao->update(TABLE_STORY)->data($story, $skipFields)->autoCheck()->checkFlow()->where('id')->eq($storyID)->exec(); if(dao::isError()) return false; if($story->result != 'reject') $this->setStage($storyID); $changes = common::createChanges($oldStory, $story); if($changes) { $story->id = $storyID; $actionID = $this->recordReviewAction($story, $comment); if($actionID) $this->action->logHistory($actionID, $changes); } if(!empty($oldStory->twins)) $this->syncTwins($oldStory->id, $oldStory->twins, $changes, 'Reviewed'); return true; } /** * Batch review stories. * * @param array $storyIdList * @param string $result * @param string $reason * @access public * @return string|null */ public function batchReview(array $storyIdList, string $result, string $reason): string|null { $now = helper::now(); $reviewedTwins = array(); $this->loadModel('action'); $oldStories = $this->getByList($storyIdList); $hasResult = $this->dao->select('story,version,result')->from(TABLE_STORYREVIEW)->where('story')->in($storyIdList)->andWhere('reviewer')->eq($this->app->user->account)->andWhere('result')->ne('')->orderBy('version')->fetchAll('story'); $reviewerList = $this->dao->select('story,reviewer,result,version')->from(TABLE_STORYREVIEW)->where('story')->in($storyIdList)->orderBy('version')->fetchGroup('story', 'reviewer'); $isSuperReviewer = $this->storyTao->isSuperReviewer(); $cannotReviewStories = array(); $cannotReviewTips = $this->lang->product->reviewStory; if(!$isSuperReviewer && empty($reviewerList)) return sprintf($cannotReviewTips, implode(',', $cannotReviewStories)); foreach($storyIdList as $storyID) { if(!$storyID) continue; $oldStory = $oldStories[$storyID]; if($oldStory->status != 'reviewing') continue; foreach($reviewerList[$storyID] as $reviewer => $reviewerInfo) { if($reviewerInfo->version != $oldStory->version) unset($reviewerList[$storyID][$reviewer]); } if(!in_array($this->app->user->account, array_keys($reviewerList[$storyID])) && !$isSuperReviewer) { $cannotReviewStories[$storyID] = "#{$storyID}"; continue; } if(isset($hasResult[$storyID]) and $hasResult[$storyID]->version == $oldStories[$storyID]->version) continue; if($oldStory->version > 1 and $result == 'reject') continue; $story = new stdClass(); $story->reviewedDate = $now; $story->lastEditedBy = $this->app->user->account; $story->lastEditedDate = $now; $story->reviewedBy = $oldStory->reviewedBy . ',' . $this->app->user->account; $story->status = $oldStory->status; $twinsIdList = $storyID . ($oldStory->twins ? ",{$oldStory->twins}" : ''); $this->dao->update(TABLE_STORYREVIEW)->set('result')->eq($result)->set('reviewDate')->eq($now)->where('story')->in($twinsIdList)->andWhere('version')->eq($oldStory->version)->andWhere('reviewer')->eq($this->app->user->account)->exec(); /* Update the story status by review rules. */ $reviewedBy = explode(',', trim($story->reviewedBy, ',')); if($isSuperReviewer) $story = $this->superReview($storyID, $oldStory, $story, $result, $reason); if(!array_diff(array_keys($reviewerList[$storyID]), $reviewedBy)) { $reviewerPairs = array(); foreach($reviewerList[$storyID] as $reviewer => $reviewInfo) $reviewerPairs[$reviewer] = $reviewInfo->result; $reviewerPairs[$this->app->user->account] = $result; $reviewResult = $this->getReviewResult($reviewerPairs); $story = $this->setStatusByReviewResult($story, $oldStory, $reviewResult, $reason); } $this->dao->update(TABLE_STORY)->data($story, 'finalResult')->autoCheck()->where('id')->eq($storyID)->exec(); $this->setStage($storyID); $story->id = $storyID; $story->version = $oldStory->version; $this->recordReviewAction($story, $result, $reason); /* Sync twins. */ $changes = common::createChanges($oldStory, $story); if(!empty($oldStory->twins)) { $twins = $oldStory->twins; foreach(explode(',', $twins) as $twinID) { if(in_array($twinID, $storyIdList) or isset($reviewedTwins[$twinID])) $twins = str_replace(",$twinID,", ',', $twins); } $this->syncTwins($storyID, trim($twins, ','), $changes, 'Reviewed'); foreach(explode(',', trim($twins, ',')) as $reviewedID) $reviewedTwins[$reviewedID] = $reviewedID; } } if($cannotReviewStories) return sprintf($cannotReviewTips, implode(',', $cannotReviewStories)); return null; } /** * Recall the story review. * * @param int $storyID * @access public * @return void */ public function recallReview($storyID) { $oldStory = $this->getById($storyID); $isChanged = $oldStory->changedBy ? true : false; $story = clone $oldStory; $story->status = $isChanged ? 'changing' : 'draft'; $this->dao->update(TABLE_STORY)->set('status')->eq($story->status)->where('id')->eq($storyID)->exec(); $twinsIdList = $storyID . ($oldStory->twins ? ",{$oldStory->twins}" : ''); $this->dao->delete()->from(TABLE_STORYREVIEW)->where('story')->in($twinsIdList)->andWhere('version')->eq($oldStory->version)->exec(); $changes = common::createChanges($oldStory, $story); if(!empty($oldStory->twins)) $this->syncTwins($storyID, $oldStory->twins, $changes, 'recalled'); } /** * Recall the story change. * * @param int $storyID * @access public * @return void */ public function recallChange($storyID) { $oldStory = $this->getById($storyID); /* Update story title and version and status. */ $story = clone $oldStory; $story->version = $oldStory->version - 1; $story->title = $this->dao->select('title')->from(TABLE_STORYSPEC)->where('story')->eq($storyID)->andWHere('version')->eq($story->version)->fetch('title'); $story->status = 'active'; $this->dao->update(TABLE_STORY)->set('title')->eq($story->title)->set('version')->eq($story->version)->set('status')->eq($story->status)->where('id')->eq($storyID)->exec(); /* Delete versions that is after this version. */ $twinsIdList = $storyID . ($oldStory->twins ? ",{$oldStory->twins}" : ''); $this->dao->delete()->from(TABLE_STORYSPEC)->where('story')->in($twinsIdList)->andWHere('version')->eq($oldStory->version)->exec(); $this->dao->delete()->from(TABLE_STORYREVIEW)->where('story')->in($twinsIdList)->andWhere('version')->eq($oldStory->version)->exec(); $changes = common::createChanges($oldStory, $story); if(!empty($oldStory->twins)) $this->syncTwins($storyID, $oldStory->twins, $changes, 'recalledChange'); } /** * Submit review. * * @param int $storyID * @access public * @return array|bool */ public function submitReview($storyID) { if(isset($_POST['reviewer'])) $_POST['reviewer'] = array_filter($_POST['reviewer']); if(!$this->post->needNotReview and empty($_POST['reviewer'])) { dao::$errors[] = $this->lang->story->errorEmptyReviewedBy; return false; } $oldStory = $this->dao->findById($storyID)->from(TABLE_STORY)->fetch(); $reviewerList = $this->getReviewerPairs($oldStory->id, $oldStory->version); $oldStory->reviewer = implode(',', array_keys($reviewerList)); $story = fixer::input('post') ->setDefault('status', 'active') ->setDefault('reviewer', '') ->setDefault('reviewedBy', '') ->remove('needNotReview') ->join('reviewer', ',') ->get(); $twinsIdList = $storyID . ($oldStory->twins ? ",{$oldStory->twins}" : ''); $this->dao->delete()->from(TABLE_STORYREVIEW)->where('story')->in($twinsIdList)->andWhere('version')->eq($oldStory->version)->exec(); if(isset($_POST['reviewer'])) { foreach($this->post->reviewer as $reviewer) { if(empty($reviewer)) continue; $reviewData = new stdclass(); $reviewData->story = $storyID; $reviewData->version = $oldStory->version; $reviewData->reviewer = $reviewer; $this->dao->insert(TABLE_STORYREVIEW)->data($reviewData)->exec(); /* Sync twins. */ if(!empty($oldStory->twins)) { foreach(explode(',', trim($oldStory->twins, ',')) as $twinID) { $reviewData->story = $twinID; $this->dao->insert(TABLE_STORYREVIEW)->data($reviewData)->exec(); } } } $story->status = 'reviewing'; } $this->dao->update(TABLE_STORY)->data($story, 'reviewer')->where('id')->eq($storyID)->exec(); $changes = common::createChanges($oldStory, $story); if(!empty($oldStory->twins)) $this->syncTwins($storyID, $oldStory->twins, $changes, 'submitReview'); if(!dao::isError()) return $changes; return false; } /** * Subdivide story * * @param int $storyID * @param array $stories * @access public * @return void */ public function subdivide(int $storyID, array $SRList): void { $now = helper::now(); $oldStory = $this->dao->findById($storyID)->from(TABLE_STORY)->fetch(); /* 如果该需求是用户需求,则为细分操作,记录用户需求和软件需求的关联关系。 */ if($oldStory->type == 'requirement') { foreach($SRList as $SRID) $this->storyTao->doCreateURRelations($SRID, array($storyID)); return; } /* 如果该需求是软件需求,则为分解子需求操作。 */ /* Set parent to child story. */ $this->dao->update(TABLE_STORY)->set('parent')->eq($storyID)->where('id')->in($SRList)->exec(); $this->computeEstimate($storyID); /* Set childStories. */ $childStories = implode(',', $SRList); $newStory = new stdClass(); $newStory->parent = '-1'; $newStory->plan = ''; $newStory->lastEditedBy = $this->app->user->account; $newStory->lastEditedDate = $now; $newStory->childStories = trim($oldStory->childStories . ',' . $childStories, ','); $this->dao->update(TABLE_STORY)->data($newStory)->autoCheck()->where('id')->eq($storyID)->exec(); $actionID = $this->loadModel('action')->create('story', $storyID, 'createChildrenStory', '', $childStories); $this->action->logHistory($actionID, common::createChanges($oldStory, $newStory)); } /** * 关闭需求。 * Close the story. * * @param int $storyID * @param object $postData * @access public * @return array|false */ public function close(int $storyID, object $postData): array|false { $oldStory = $this->dao->findById($storyID)->from(TABLE_STORY)->fetch(); $story = $postData; if(!empty($story->duplicateStory)) { $duplicateStoryID = $this->dao->select('id')->from(TABLE_STORY)->where('id')->eq($story->duplicateStory)->fetch(); if(empty($duplicateStoryID)) { dao::$errors[] = sprintf($this->lang->story->errorDuplicateStory, $story->duplicateStory); return false; } } if($story->closedReason == 'duplicate' and empty($story->duplicateStory)) { dao::$errors[] = sprintf($this->lang->error->notempty, $this->lang->story->duplicateStory); return false; } $this->lang->story->comment = $this->lang->comment; $this->dao->update(TABLE_STORY)->data($story, 'comment,closeSync') ->autoCheck() ->batchCheck($this->config->story->close->requiredFields, 'notempty') ->checkIF($story->closedReason == 'duplicate', 'duplicateStory', 'notempty') ->checkFlow() ->where('id')->eq($storyID) ->exec(); if(dao::isError()) return false; /* Update parent story status and stage. */ if($oldStory->parent > 0) { $this->updateParentStatus($storyID, $oldStory->parent); $this->setStage($oldStory->parent); } if(!dao::isError()) { $this->setStage($storyID); $this->loadModel('score')->create('story', 'close', $storyID); if(($this->config->edition == 'biz' || $this->config->edition == 'max') && $oldStory->feedback) $this->loadModel('feedback')->updateStatus('story', $oldStory->feedback, $story->status, $oldStory->status); } $changes = common::createChanges($oldStory, $story); if(!empty($postData->closeSync)) { /* batchUnset twinID from twins.*/ $replaceSql = "UPDATE " . TABLE_STORY . " SET twins = REPLACE(twins,',$storyID,', ',') WHERE `product` = $oldStory->product"; $this->dbh->exec($replaceSql); /* Update twins to empty by twinID and if twins eq ','.*/ $this->dao->update(TABLE_STORY)->set('twins')->eq('')->where('id')->eq($storyID)->orWhere('twins')->eq(',')->exec(); if(!dao::isError()) $this->loadModel('action')->create('story', $storyID, 'relieved'); } if(!empty($oldStory->twins) and empty($postData->closeSync)) $this->syncTwins($storyID, $oldStory->twins, $changes, 'Closed'); return $changes; } /** * 批量关闭需求。 * Batch close the stories. * * @param array $stories * @access public * @return void */ public function batchClose(array $stories) { $this->loadModel('action'); $oldStories = $this->getByList(array_keys($stories)); foreach($stories as $storyID => $story) { if(empty($story->closedReason)) continue; $oldStory = $oldStories[$storyID]; $this->dao->update(TABLE_STORY)->data($story, 'comment')->autoCheck() ->checkIF($story->closedReason == 'duplicate', 'duplicateStory', 'notempty') ->where('id')->eq($storyID) ->exec(); if(dao::isError()) return dao::$errors[] = 'story#' . $storyID . dao::getError(true); /* Update parent story status. */ if($oldStory->parent > 0) $this->updateParentStatus($storyID, $oldStory->parent); $this->setStage($storyID); $changes = common::createChanges($oldStory, $story); if($changes) { $preStatus = $oldStory->status; $isChanged = $oldStory->changedBy ? true : false; if($preStatus == 'reviewing') $preStatus = $isChanged ? 'changing' : 'draft'; $actionID = $this->action->create('story', $storyID, 'Closed', $story->comment ?? '', ucfirst($story->closedReason) . (!empty($story->duplicateStory) ? ':' . (int)$story->duplicateStory : '') . "|{$preStatus}"); $this->action->logHistory($actionID, $changes); if(!empty($oldStory->twins)) $this->syncTwins($storyID, $oldStory->twins, $changes, 'Closed'); } if($this->config->edition != 'open' && $oldStory->feedback && !isset($feedbacks[$oldStory->feedback])) { $feedbacks[$oldStory->feedback] = $oldStory->feedback; $this->loadModel('feedback')->updateStatus('story', $oldStory->feedback, $story->status, $oldStory->status); } $this->loadModel('score')->create('story', 'close', $storyID); } } /** * Batch change the module of story. * * @param array $storyIdList * @param int $moduleID * @access public * @return array */ public function batchChangeModule($storyIdList, $moduleID) { $now = helper::now(); $allChanges = array(); $oldStories = $this->getByList($storyIdList); foreach($storyIdList as $storyID) { $oldStory = $oldStories[$storyID]; if($moduleID == $oldStory->module) continue; $story = new stdclass(); $story->lastEditedBy = $this->app->user->account; $story->lastEditedDate = $now; $story->module = $moduleID; $this->dao->update(TABLE_STORY)->data($story)->autoCheck()->where('id')->eq((int)$storyID)->exec(); if(!dao::isError()) $allChanges[$storyID] = common::createChanges($oldStory, $story); } return $allChanges; } /** * Batch change the plan of story. * * @param array $storyIdList * @param int $planID * @access public * @return array */ public function batchChangePlan($storyIdList, $planID, $oldPlanID = 0) { /* Prepare data. */ $now = helper::now(); $allChanges = array(); $oldStories = $this->getByList($storyIdList); $plan = $this->loadModel('productplan')->getById($planID); $oldStoryStages = $this->dao->select('*')->from(TABLE_STORYSTAGE)->where('story')->in($storyIdList)->fetchGroup('story', 'branch'); $unlinkPlans = array(); $link2Plans = array(); if(empty($plan)) { $plan = new stdClass(); $plan->branch = BRANCH_MAIN; } /* Cycle every story and process it's plan and stage. */ foreach($storyIdList as $storyID) { $oldStory = $oldStories[$storyID]; if($oldStory->branch != BRANCH_MAIN and !in_array($oldStory->branch, explode(',', $plan->branch)) and $plan->branch != BRANCH_MAIN) continue; /* Ignore parent story, closed story and story linked to this plan already. */ if($oldStory->parent < 0) continue; if($oldStory->status == 'closed') continue; if(strpos(",{$oldStory->plan},", ",$planID,") !== false) continue; /* Init story and set last edited data. */ $story = new stdclass(); $story->lastEditedBy = $this->app->user->account; $story->lastEditedDate = $now; /* Remove old plan from the plan field. */ if($oldPlanID) $story->plan = trim(str_replace(",$oldPlanID,", ',', ",$oldStory->plan,"), ','); /* Update the order of the story in the plan. */ $this->updateStoryOrderOfPlan($storyID, $planID, $oldStory->plan); /* Replace plan field if product is normal or not linked to plan or story linked to a branch. */ if($this->session->currentProductType == 'normal') $story->plan = $planID; if(empty($oldPlanID)) $story->plan = $planID; if($oldStory->branch) $story->plan = $planID; /* Change stage. */ if($planID) { if($oldStory->stage == 'wait') $story->stage = 'planned'; if($this->session->currentProductType and $this->session->currentProductType != 'normal' and $oldStory->branch == 0) { foreach(explode(',', $plan->branch) as $planBranch) { if(!isset($oldStoryStages[$storyID][$planBranch])) { $story->stage = 'planned'; $newStoryStage = new stdclass(); $newStoryStage->story = $storyID; $newStoryStage->branch = $planBranch; $newStoryStage->stage = $story->stage; $this->dao->insert(TABLE_STORYSTAGE)->data($newStoryStage)->autoCheck()->exec(); } } } } /* Update story and recompute stage. */ $this->dao->update(TABLE_STORY)->data($story)->autoCheck()->where('id')->eq((int)$storyID)->exec(); if(!$planID) $this->setStage($storyID); if(!dao::isError()) { $allChanges[$storyID] = common::createChanges($oldStory, $story); if($story->plan != $oldStory->plan and !empty($oldStory->plan) and strpos($story->plan, ',') === false) $unlinkPlans[$oldStory->plan] = empty($unlinkPlans[$oldStory->plan]) ? $storyID : "{$unlinkPlans[$oldStory->plan]},$storyID"; if($story->plan != $oldStory->plan and !empty($story->plan) and strpos($story->plan, ',') === false) $link2Plans[$story->plan] = empty($link2Plans[$story->plan]) ? $storyID : "{$link2Plans[$story->plan]},$storyID"; } } if(!dao::isError()) { $this->loadModel('action'); foreach($unlinkPlans as $planID => $stories) $this->action->create('productplan', $planID, 'unlinkstory', '', $stories); foreach($link2Plans as $planID => $stories) $this->action->create('productplan', $planID, 'linkstory', '', $stories); } return $allChanges; } /** * Batch change branch. * * @param array $storyIdList * @param int $branchID * @param string $confirm * @param array $plans * @access public * @return array */ public function batchChangeBranch($storyIdList, $branchID, $confirm = '', $plans = array()) { $now = helper::now(); $allChanges = array(); $oldStories = $this->getByList($storyIdList); $story = current($oldStories); $productID = $story->product; $mainModules = $this->dao->select('id')->from(TABLE_MODULE) ->where('root')->eq($productID) ->andWhere('branch')->eq(0) ->andWhere('type')->eq('story') ->fetchPairs('id'); foreach($storyIdList as $storyID) { $oldStory = $oldStories[$storyID]; $story = new stdclass(); $story->lastEditedBy = $this->app->user->account; $story->lastEditedDate = $now; $story->branch = $branchID; $story->module = ($oldStory->branch != $branchID and !in_array($oldStory->module, $mainModules)) ? 0 : $oldStory->module; $this->dao->update(TABLE_STORY)->data($story)->autoCheck()->where('id')->eq((int)$storyID)->exec(); if(!dao::isError()) { if($confirm == 'yes') { $planIdList = ''; $conflictPlanIdList = ''; /* Determine whether there is a conflict between the branch of the story and the linked plan. */ if($oldStory->branch != $branchID and $branchID != BRANCH_MAIN and isset($plans[$storyID])) { foreach($plans[$storyID] as $planID => $plan) { if($plan->branch != $branchID) { $conflictPlanIdList .= $planID . ','; } else { $planIdList .= $planID . ','; } } /* If there is a conflict in the linked plan when the branch story to be modified, the linked with the conflicting plan will be removed. */ if($conflictPlanIdList) { $story->plan = $planIdList; $this->dao->delete()->from(TABLE_PLANSTORY)->where('story')->eq($storyID)->andWhere('plan')->in($conflictPlanIdList)->exec(); $this->dao->update(TABLE_STORY)->set('plan')->eq($planIdList)->where('id')->eq($storyID)->exec(); } } } $allChanges[$storyID] = common::createChanges($oldStory, $story); } } return $allChanges; } /** * Batch change the stage of story. * * @param array $storyIdList * @param string $stage * * @access public * @return string|null */ public function batchChangeStage(array $storyIdList, string $stage): string|null { $now = helper::now(); $account = $this->app->user->account; $oldStories = $this->getByList($storyIdList); $ignoreStories = ''; $this->loadModel('action'); foreach($storyIdList as $storyID) { $oldStory = $oldStories[$storyID]; if($oldStory->status == 'draft' or $oldStory->status == 'closed') { $ignoreStories .= "#{$storyID} "; continue; } $story = new stdclass(); $story->lastEditedBy = $account; $story->lastEditedDate = $now; $story->stage = $stage; $story->stagedBy = $account; /* Add for record released date. */ if($story->stage == 'released') $story->releasedDate = $now; $this->dao->update(TABLE_STORY)->data($story)->autoCheck()->where('id')->eq((int)$storyID)->exec(); $this->dao->update(TABLE_STORYSTAGE)->set('stage')->eq($stage)->set('stagedBy')->eq($account)->where('story')->eq((int)$storyID)->exec(); if(!dao::isError()) { $changes = common::createChanges($oldStory, $story); $action = $stage == 'verified' ? 'Verified' : 'Edited'; $actionID = $this->action->create('story', $storyID, $action); $this->action->logHistory($actionID, $changes); } } if($ignoreStories) return sprintf($this->lang->story->ignoreChangeStage, $ignoreStories); return null; } /** * Batch to task. * * @param int $executionID * @param int $projectID * @access public * @return bool|array */ public function batchToTask($executionID, $projectID = 0) { /* load Module and get the data from the post and get the current time. */ $this->loadModel('action'); $this->loadModel('task'); $now = helper::now(); $account = $this->app->user->account; $tasks = fixer::input('post') ->remove('syncFields') ->get(); if(!empty($_POST['syncFields'])) $stories = empty($tasks->story) ? array() : $this->getByList($tasks->story); /* Create tasks. */ $preStory = 0; $storyIDs = array(); $taskNames = array(); foreach($tasks->story as $key => $storyID) { $tasks->name[$key] = trim($tasks->name[$key]); if(empty($tasks->name[$key])) continue; if($tasks->type[$key] == 'affair') continue; if($tasks->type[$key] == 'ditto' and isset($tasks->type[$key - 1]) and $tasks->type[$key - 1] == 'affair') continue; if($storyID == 'ditto') $storyID = $preStory; $preStory = $storyID; if(!isset($tasks->story[$key - 1]) and $key > 1 and !empty($tasks->name[$key - 1])) { $storyIDs[] = 0; $taskNames[] = $tasks->name[$key - 1]; } $inNames = in_array($tasks->name[$key], $taskNames); if(!$inNames or ($inNames && !in_array($storyID, $storyIDs))) { $storyIDs[] = $storyID; $taskNames[] = $tasks->name[$key]; } else { dao::$errors['message'][] = sprintf($this->lang->duplicate, $this->lang->task->common) . ' ' . $tasks->name[$key]; return false; } } $story = 0; $module = 0; $type = ''; $assignedTo = ''; $estStarted = null; $deadline = null; $data = array(); $requiredFields = "," . $this->config->task->create->requiredFields . ","; foreach($tasks->name as $i => $task) { $module = (!isset($tasks->module[$i]) or $tasks->module[$i] == 'ditto') ? $module : $tasks->module[$i]; $story = (!isset($tasks->story[$i]) or $tasks->story[$i] == 'ditto') ? $story : $tasks->story[$i]; $type = (!isset($tasks->type[$i]) or $tasks->type[$i] == 'ditto') ? $type : $tasks->type[$i]; $assignedTo = (!isset($tasks->assignedTo[$i]) or $tasks->assignedTo[$i] == 'ditto') ? $assignedTo : $tasks->assignedTo[$i]; $estStarted = (!isset($tasks->estStarted[$i]) or isset($tasks->estStartedDitto[$i])) ? $estStarted : $tasks->estStarted[$i]; $deadline = (!isset($tasks->deadline[$i]) or isset($tasks->deadlineDitto[$i])) ? $deadline : $tasks->deadline[$i]; if(empty($tasks->name[$i])) continue; $data[$i] = new stdclass(); $data[$i]->story = (int)$story; $data[$i]->type = $type; $data[$i]->module = (int)$module; $data[$i]->assignedTo = $assignedTo; $data[$i]->color = $tasks->color[$i]; $data[$i]->name = $tasks->name[$i]; $data[$i]->pri = $tasks->pri[$i]; $data[$i]->estimate = $tasks->estimate[$i]; $data[$i]->left = $tasks->estimate[$i]; $data[$i]->project = $projectID; $data[$i]->execution = $executionID; $data[$i]->estStarted = $estStarted; $data[$i]->deadline = $deadline; $data[$i]->status = 'wait'; $data[$i]->openedBy = $account; $data[$i]->openedDate = $now; $data[$i]->vision = 'rnd'; if($story) { $data[$i]->storyVersion = $stories[$story]->version; if(strpos(",{$_POST['syncFields']},", ',spec,') !== false) $data[$i]->desc = $stories[$story]->spec; if(strpos(",{$_POST['syncFields']},", 'mailto') !== false) $data[$i]->mailto = $stories[$story]->mailto; } if($assignedTo) $data[$i]->assignedDate = $now; if(strpos($requiredFields, ',estStarted,') !== false and empty($estStarted)) $data[$i]->estStarted = ''; if(strpos($requiredFields, ',deadline,') !== false and empty($deadline)) $data[$i]->deadline = ''; } /* check data. */ foreach($data as $i => $task) { if(!helper::isZeroDate($task->deadline) and $task->deadline < $task->estStarted) { dao::$errors['message'][] = $this->lang->task->error->deadlineSmall; return false; } if($task->estimate and !preg_match("/^[0-9]+(.[0-9]{1,3})?$/", $task->estimate)) { dao::$errors['message'][] = $this->lang->task->error->estimateNumber; return false; } foreach(explode(',', $requiredFields) as $field) { $field = trim($field); if(empty($field)) continue; if(!isset($task->$field)) continue; if(!empty($task->$field)) continue; if($field == 'estimate' and strlen(trim($task->estimate)) != 0) continue; dao::$errors['message'][] = sprintf($this->lang->error->notempty, $this->lang->task->$field); return false; } if(!empty($this->config->limitTaskDate)) { $this->task->checkEstStartedAndDeadline($executionID, $task->estStarted, $task->deadline); if(dao::isError()) return false; } if($task->estimate) $task->estimate = (float)$task->estimate; } $taskIdList = array(); foreach($data as $i => $task) { $task->version = 1; $this->dao->insert(TABLE_TASK)->data($task) ->autoCheck() ->checkIF($task->estimate != '', 'estimate', 'float') ->exec(); if(dao::isError()) return false; $taskID = $this->dao->lastInsertID(); $taskIdList[] = $taskID; $taskSpec = new stdClass(); $taskSpec->task = $taskID; $taskSpec->version = $task->version; $taskSpec->name = $task->name; $taskSpec->estStarted = $task->estStarted; $taskSpec->deadline = $task->deadline; $this->dao->insert(TABLE_TASKSPEC)->data($taskSpec)->autoCheck()->exec(); if(dao::isError()) return false; if($task->story) $this->setStage($task->story); $this->action->create('task', $taskID, 'Opened', ''); } $this->loadModel('kanban')->updateLane($executionID, 'task'); return $taskIdList; } /** * Assign story. * * @param int $storyID * @access public * @return array */ public function assign($storyID) { $oldStory = $this->dao->findById($storyID)->from(TABLE_STORY)->fetch(); $now = helper::now(); $assignedTo = $this->post->assignedTo; if($assignedTo == $oldStory->assignedTo) return array(); $story = fixer::input('post') ->add('id', $storyID) ->add('lastEditedBy', $this->app->user->account) ->add('lastEditedDate', $now) ->add('assignedDate', $now) ->stripTags($this->config->story->editor->assignto['id'], $this->config->allowedTags) ->remove('comment') ->get(); $story = $this->loadModel('file')->processImgURL($story, $this->config->story->editor->assignto['id'], $this->post->uid); $this->dao->update(TABLE_STORY)->data($story)->autoCheck()->checkFlow()->where('id')->eq((int)$storyID)->exec(); $changes = common::createChanges($oldStory, $story); if(!empty($oldStory->twins)) $this->syncTwins($storyID, $oldStory->twins, $changes, 'Assigned'); if(!dao::isError()) return $changes; return false; } /** * Batch assign to. * * @param string $assignedTo * @access public * @return array */ public function batchAssignTo(array $storyIdList, string $assignedTo = '') { $now = helper::now(); $allChanges = array(); $oldStories = $this->getByList($storyIdList); foreach($storyIdList as $storyID) { $oldStory = $oldStories[$storyID]; if($oldStory->status == 'closed') continue; if($assignedTo == $oldStory->assignedTo) continue; $story = new stdclass(); $story->lastEditedBy = $this->app->user->account; $story->lastEditedDate = $now; $story->assignedTo = $assignedTo; $story->assignedDate = $now; $this->dao->update(TABLE_STORY)->data($story)->autoCheck()->where('id')->eq((int)$storyID)->exec(); $allChanges[$storyID] = common::createChanges($oldStory, $story); } return $allChanges; } /** * 激活需求。 * Activate a story. * * @param int $storyID * @access public * @return array|false */ public function activate(int $storyID, object $postData): array|false { $oldStory = $this->dao->findById($storyID)->from(TABLE_STORY)->fetch(); /* Get status after activation. */ $story = $postData; $story->status = $this->getActivateStatus($storyID); $this->dao->update(TABLE_STORY)->data($story, 'comment')->autoCheck()->checkFlow()->where('id')->eq($storyID)->exec(); if($story->status == 'active') { $twinsIdList = $storyID . (!empty($oldStory->twins) ? ",{$oldStory->twins}" : ''); $this->dao->delete()->from(TABLE_STORYREVIEW)->where('story')->in($twinsIdList)->exec(); } $this->setStage($storyID); /* Update parent story status. */ if(!empty($oldStory->parent) && $oldStory->parent > 0) $this->updateParentStatus($storyID, $oldStory->parent); $changes = common::createChanges($oldStory, $story); if(!empty($oldStory->twins)) $this->syncTwins($storyID, $oldStory->twins, $changes, 'Activated'); return $changes; } /** * Set stage of a story. * * @param int $storyID * @access public * @return bool */ public function setStage(int $storyID): bool { $story = $this->dao->findById($storyID)->from(TABLE_STORY)->fetch(); if(empty($story)) return false; /* 获取已经存在的分支阶段. */ $oldStages = $this->dao->select('*')->from(TABLE_STORYSTAGE)->where('story')->eq($storyID)->fetchAll('branch'); $this->dao->delete()->from(TABLE_STORYSTAGE)->where('story')->eq($storyID)->exec(); /* 手动设置了阶段,就不需要字段计算阶段了。 */ $product = $this->dao->findById($story->product)->from(TABLE_PRODUCT)->fetch(); $hasBranch = ($product and $product->type != 'normal' and empty($story->branch)); if(!empty($story->stagedBy) and $story->status != 'closed') return true; /* 获取需求关联的分支和项目。 */ list($linkedBranches, $linkedProjects) = $this->storyTao->getLinkedBranchesAndProjects($storyID); /* 设置默认阶段。 */ $stages = array(); if($hasBranch) $stages = $this->storyTao->getDefaultStages($story->plan, $linkedProjects ? $linkedBranches : array()); /* When the status is closed, stage is also changed to closed. */ if($story->status == 'closed') return $this->storyTao->setStageToClosed($storyID, array_merge($linkedBranches, array_keys($stages)), $linkedProjects); /* If no executions, in plan, stage is planned. No plan, wait. */ if(!$linkedProjects) return $this->storyTao->setStageToPlanned($storyID, $stages, $oldStages); /* 根据需求关联的任务状态统计,计算需求的阶段。 */ $taskStat = $this->storyTao->getLinkedTaskStat($storyID, $linkedProjects); $stages = $this->storyTao->computeStagesByTasks($storyID, $taskStat, $stages, $linkedProjects); $this->storyTao->updateStage($storyID, $stages, $oldStages, $linkedProjects); return true; } /** * Get stories to link. * * @param int $storyID * @param string $type linkStories|linkRelateSR|linkRelateUR * @param string $browseType * @param int $queryID * @param string $storyType * @param object $pager * @param string $excludeStories * @access public * @return array */ public function getStories2Link($storyID, $type = 'linkStories', $browseType = 'bySearch', $queryID = 0, $storyType = 'story', $pager = null, $excludeStories = '') { $story = $this->getById($storyID); $tmpStoryType = $storyType == 'story' ? 'requirement' : 'story'; $stories2Link = array(); if($type == 'linkRelateSR' or $type == 'linkRelateUR') { $tmpStoryType = $story->type; $linkStoryField = $story->type == 'story' ? 'linkStories' : 'linkRequirements'; $storyIDList = $story->id . ',' . $excludeStories . ',' . $story->{$linkStoryField}; } else { $linkedStories = $this->storyTao->getRelation($storyID, $story->type); $linkedStories = empty($linkedStories) ? array() : $linkedStories; $storyIDList = array_keys($linkedStories); } if($browseType == 'bySearch') { $stories2Link = $this->getBySearch($story->product, $story->branch, $queryID, 'id_desc', '', $tmpStoryType, $storyIDList, $pager); } elseif($type != 'linkRelateSR' and $type != 'linkRelateUR') { $status = $storyType == 'story' ? 'active' : 'all'; $stories2Link = $this->getProductStories($story->product, $story->branch, 0, $status, $tmpStoryType, 'id_desc', true, $storyIDList, $pager); } if($type != 'linkRelateSR' and $type != 'linkRelateUR') { foreach($stories2Link as $id => $story) { if($storyType == 'story' and $story->status == 'draft') unset($stories2Link[$id]); } } return $stories2Link; } /** * Get stories list of a product. * * @param string|int $productID * @param array|string|int $branch * @param array|string $moduleIdList * @param array|string $status * @param string $type requirement|story * @param string $orderBy * @param array|string $excludeStories * @param object|null $pager * @param bool $hasParent * * @access public * @return array */ public function getProductStories(string|int $productID = 0, array|string|int $branch = 0, array|string $moduleIdList = '', array|string $status = 'all', string $type = 'story', string $orderBy = 'id_desc', bool $hasParent = true, array|string $excludeStories = '', object|null $pager = null): array { if(commonModel::isTutorialMode()) return $this->loadModel('tutorial')->getStories(); $productQuery = $this->storyTao->buildProductsCondition($productID, $branch); $stories = $this->dao->select("*, IF(`pri` = 0, {$this->config->maxPriValue}, `pri`) as priOrder")->from(TABLE_STORY) ->where('deleted')->eq(0) ->andWhere($productQuery) ->beginIF(!$hasParent)->andWhere("parent")->ge(0)->fi() ->beginIF(!empty($moduleIdList))->andWhere('module')->in($moduleIdList)->fi() ->beginIF(!empty($excludeStories))->andWhere('id')->notIN($excludeStories)->fi() ->beginIF($status and $status != 'all')->andWhere('status')->in($status)->fi() ->andWhere('vision')->eq($this->config->vision) ->andWhere('type')->eq($type) ->orderBy($orderBy) ->page($pager) ->fetchAll('id'); return $this->storyTao->mergePlanTitleAndChildren($productID, $stories, $type); } /** * 通过产品获取需求ID和需求信息的键值对。 * Get stories pairs of a product. * * @param int $productID * @param string|int $branch * @param array|string|int $moduleIdList * @param string $status * @param string $order * @param int $limit * @param string $type * @param string $storyType requirement|story * @param bool|string $hasParent * @access public * @return array */ public function getProductStoryPairs(int $productID = 0, string|int $branch = 'all', array|string|int $moduleIdList = '', string $status = 'all', string $order = 'id_desc', int $limit = 0, string $type = 'full', string $storyType = 'story', bool|string $hasParent = true): array { $stories = $this->dao->select('t1.id, t1.title, t1.module, t1.pri, t1.estimate, t2.name AS product') ->from(TABLE_STORY)->alias('t1')->leftJoin(TABLE_PRODUCT)->alias('t2')->on('t1.product = t2.id') ->where('1=1') ->beginIF($productID)->andWhere('t1.product')->in($productID)->fi() ->beginIF($moduleIdList)->andWhere('t1.module')->in($moduleIdList)->fi() ->beginIF($branch !== 'all')->andWhere('t1.branch')->in("0,$branch")->fi() ->beginIF(!$hasParent or $hasParent == 'false')->andWhere('t1.parent')->ge(0)->fi() ->beginIF($status and $status != 'all')->andWhere('t1.status')->in($status)->fi() ->andWhere('t1.type')->eq($storyType) ->andWhere('t1.deleted')->eq(0) ->orderBy($order) ->fetchAll(); if(!$stories) return array(); return $this->formatStories($stories, $type, $limit); } /** * Get stories by assignedTo. * * @param int $productID * @param string $branch * @param string $account * @param string $type requirement|story * @param string $orderBy * @param object $pager * @access public * @return array */ public function getByAssignedTo($productID, $branch, $modules, $account, $type = 'story', $orderBy = '', $pager = null) { return $this->getByField($productID, $branch, $modules, 'assignedTo', $account, $type, $orderBy, $pager); } /** * Get stories by openedBy. * * @param int $productID * @param int $branch * @param string $modules * @param string $account * @param string $type requirement|story * @param string $orderBy * @param object $pager * @access public * @return array */ public function getByOpenedBy($productID, $branch, $modules, $account, $type = 'story', $orderBy = '', $pager = null) { return $this->getByField($productID, $branch, $modules, 'openedBy', $account, $type, $orderBy, $pager); } /** * Get stories by reviewedBy. * * @param int $productID * @param int $branch * @param string $modules * @param string $account * @param string $type requirement|story * @param string $orderBy * @param object $pager * @access public * @return array */ public function getByReviewedBy($productID, $branch, $modules, $account, $type = 'story', $orderBy = '', $pager = null) { return $this->getByField($productID, $branch, $modules, 'reviewedBy', $account, $type, $orderBy, $pager, 'include'); } /** * Get stories which need to review. * * @param int $productID * @param int $branch * @param string $modules * @param string $account * @param string $type requirement|story * @param string $orderBy * @param object $pager * @access public * @return array */ public function getByReviewBy($productID, $branch, $modules, $account, $type = 'story', $orderBy = '', $pager = null) { return $this->getByField($productID, $branch, $modules, 'reviewBy', $account, $type, $orderBy, $pager); } /** * Get stories by closedBy. * * @param int $productID * @param int $branch * @param string $modules * @param string $account * @param string $type requirement|story * @param string $orderBy * @param object $pager * @return array */ public function getByClosedBy($productID, $branch, $modules, $account, $type = 'story', $orderBy = '', $pager = null) { return $this->getByField($productID, $branch, $modules, 'closedBy', $account, $type, $orderBy, $pager); } /** * Get stories by status. * * @param int $productID * @param int $branch * @param string $modules * @param string $status * @param string $type requirement|story * @param string $orderBy * @param object $pager * @access public * @return array */ public function getByStatus($productID, $branch, $modules, $status, $type = 'story', $orderBy = '', $pager = null) { return $this->getByField($productID, $branch, $modules, 'status', $status, $type, $orderBy, $pager); } /** * Get stories by plan. * * @param int $productID * @param int $branch * @param array $modules * @param int $plan * @param string $type requirement|story * @param string $orderBy * @param object $pager * * @access public * @return array */ public function getByPlan($productID, $branch, $modules, $plan, $type = 'story', $orderBy = '', $pager = null) { return $this->getByField($productID, $branch, $modules, 'plan', $plan, $type, $orderBy, $pager); } /** * Get stories by assignedBy. * * @param int $productID * @param int $branch * @param string $modules * @param string $account * @param string $type requirement|story * @param string $orderBy * @param object $pager * @access public * @return array */ public function getByAssignedBy($productID, $branch, $modules, $account, $type = 'story', $orderBy = '', $pager = null) { return $this->getByField($productID, $branch, $modules, 'assignedBy', $account, $type, $orderBy, $pager); } /** * Get stories by a field. * * @param int $productID * @param int|string $branch * @param string $modules * @param string $fieldName * @param mixed $fieldValue * @param string $type requirement|story * @param string $orderBy * @param object $pager * @param string $operator equal|include * @access public * @return array */ public function getByField($productID, $branch, $modules, $fieldName, $fieldValue, $type = 'story', $orderBy = '', $pager = null, $operator = 'equal') { if(!$this->loadModel('common')->checkField(TABLE_STORY, $fieldName) and $fieldName != 'reviewBy' and $fieldName != 'assignedBy') return array(); $actionIDList = array(); if($fieldName == 'assignedBy') $actionIDList = $this->dao->select('objectID')->from(TABLE_ACTION)->where('objectType')->eq('story')->andWhere('action')->eq('assigned')->andWhere('actor')->eq($fieldValue)->fetchPairs('objectID', 'objectID'); $sql = $this->dao->select("t1.*, IF(t1.`pri` = 0, {$this->config->maxPriValue}, t1.`pri`) as priOrder")->from(TABLE_STORY)->alias('t1'); if($fieldName == 'reviewBy') $sql = $sql->leftJoin(TABLE_STORYREVIEW)->alias('t2')->on('t1.id = t2.story and t1.version = t2.version'); $stories = $sql->where('t1.product')->in($productID) ->andWhere('t1.deleted')->eq(0) ->andWhere('t1.vision')->eq($this->config->vision) ->andWhere('t1.type')->eq($type) ->beginIF($branch != 'all')->andWhere("t1.branch")->eq($branch)->fi() ->beginIF($modules)->andWhere("t1.module")->in($modules)->fi() ->beginIF($operator == 'equal' and $fieldName != 'reviewBy' and $fieldName != 'assignedBy')->andWhere('t1.' . $fieldName)->eq($fieldValue)->fi() ->beginIF($operator == 'include' and $fieldName != 'reviewBy' and $fieldName != 'assignedBy')->andWhere('t1.' . $fieldName)->like("%$fieldValue%")->fi() ->beginIF($fieldName == 'reviewBy') ->andWhere('t2.reviewer')->eq($this->app->user->account) ->andWhere('t2.result')->eq('') ->andWhere('t1.status')->eq('reviewing') ->fi() ->beginIF($fieldName == 'assignedBy')->andWhere('t1.id')->in($actionIDList)->andWhere('t1.status')->ne('closed')->fi() ->orderBy($orderBy) ->page($pager) ->fetchAll('id'); return $this->storyTao->mergePlanTitleAndChildren($productID, $stories, $type); } /** * Get to be closed stories. * * @param int $productID * @param int $branch * @param string $modules * @param string $type requirement|story * @param string $orderBy * @param object $pager * @access public * @return array */ public function get2BeClosed($productID, $branch, $modules, $type = 'story', $orderBy = '', $pager = null) { $stories = $this->dao->select("*,IF(`pri` = 0, {$this->config->maxPriValue}, `pri`) as priOrder")->from(TABLE_STORY) ->where('product')->in($productID) ->andWhere('type')->eq($type) ->beginIF($branch)->andWhere("branch")->eq($branch)->fi() ->beginIF($modules)->andWhere("module")->in($modules)->fi() ->andWhere('deleted')->eq(0) ->andWhere('vision')->eq($this->config->vision) ->andWhere('stage')->in('developed,released') ->andWhere('status')->ne('closed') ->orderBy($orderBy) ->page($pager) ->fetchAll('id'); return $this->storyTao->mergePlanTitleAndChildren($productID, $stories, $type); } /** * Get stories through search. * * @access public * @param int $productID * @param int|string $branch * @param int $queryID * @param string $orderBy * @param string $executionID * @param string $type requirement|story * @param string $excludeStories * @param object $pager * @access public * @return array */ public function getBySearch($productID, $branch = '', $queryID = 0, $orderBy = '', $executionID = '', $type = 'story', $excludeStories = '', $pager = null) { $this->loadModel('product'); $executionID = empty($executionID) ? 0 : $executionID; $products = empty($executionID) ? $this->product->getList(0, 'all', 0, 0, 'all') : $this->product->getProducts($executionID); $this->loadModel('search')->setQuery('story', $queryID); $allProduct = "`product` = 'all'"; $storyQuery = $this->session->storyQuery; $queryProductID = $productID; if(strpos($storyQuery, $allProduct) !== false) { $storyQuery = str_replace($allProduct, '1', $storyQuery); $queryProductID = 'all'; } $storyQuery = $storyQuery . ' AND `product` ' . helper::dbIN(array_keys($products)); if($excludeStories) $storyQuery = $storyQuery . ' AND `id` NOT ' . helper::dbIN($excludeStories); if($this->app->moduleName == 'productplan') $storyQuery .= " AND `status` NOT IN ('closed') AND `parent` >= 0 "; $allBranch = "`branch` = 'all'"; if(!empty($executionID)) { $normalProducts = array(); $branchProducts = array(); foreach($products as $product) { if($product->type != 'normal') { $branchProducts[$product->id] = $product; continue; } $normalProducts[$product->id] = $product; } $storyQuery .= ' AND ('; if(!empty($normalProducts)) $storyQuery .= '`product` ' . helper::dbIN(array_keys($normalProducts)); if(!empty($branchProducts)) { $branches = array(BRANCH_MAIN => BRANCH_MAIN); if($branch === '') { foreach($branchProducts as $product) { foreach($product->branches as $branchID) $branches[$branchID] = $branchID; } } else { $branches[$branch] = $branch; } $branches = implode(',', $branches); if(!empty($normalProducts)) $storyQuery .= " OR "; $storyQuery .= "(`product` " . helper::dbIN(array_keys($branchProducts)) . " AND `branch` " . helper::dbIN($branches) . ")"; } if(empty($normalProducts) and empty($branchProducts)) $storyQuery .= '1 = 1'; $storyQuery .= ') '; if($this->app->moduleName == 'release' or $this->app->moduleName == 'build') { $storyQuery .= " AND `status` NOT IN ('draft')"; // Fix bug #990. } else { $storyQuery .= " AND `status` NOT IN ('draft', 'reviewing', 'changing', 'closed')"; } if($this->app->rawModule == 'build' and $this->app->rawMethod == 'linkstory') $storyQuery .= " AND `parent` != '-1'"; } elseif(strpos($storyQuery, $allBranch) !== false) { $storyQuery = str_replace($allBranch, '1', $storyQuery); } elseif($branch !== 'all' and $branch !== '' and strpos($storyQuery, '`branch` =') === false and $queryProductID != 'all') { if($branch and strpos($storyQuery, '`branch` =') === false) $storyQuery .= " AND `branch` " . helper::dbIN($branch); } $storyQuery = preg_replace("/`plan` +LIKE +'%([0-9]+)%'/i", "CONCAT(',', `plan`, ',') LIKE '%,$1,%'", $storyQuery); return $this->getBySQL($queryProductID, $storyQuery, $orderBy, $pager, $type); } /** * Get stories by a sql. * * @param int $productID * @param string $sql * @param string $orderBy * @param object $pager * @param string $type requirement|story * @access public * @return array */ public function getBySQL($productID, $sql, $orderBy, $pager = null, $type = 'story') { /* Get plans. */ $plans = $this->dao->select('id,title')->from(TABLE_PRODUCTPLAN) ->where('deleted')->eq('0') ->beginIF($productID != 'all' and $productID != '')->andWhere('product')->eq((int)$productID)->fi() ->fetchPairs(); $review = $this->storyTao->getRevertStoryIdList((int)$productID); $sql = str_replace(array('`product`', '`version`', '`branch`'), array('t1.`product`', 't1.`version`', 't1.`branch`'), $sql); if(strpos($sql, 'result') !== false) { if(strpos($sql, 'revert') !== false) { $sql = str_replace("AND `result` = 'revert'", '', $sql); $sql .= " AND t1.`id` " . helper::dbIN($review); } else { $sql = str_replace(array('`result`'), array('t3.`result`'), $sql); } } $tmpStories = $this->dao->select("DISTINCT t1.*, IF(t1.`pri` = 0, {$this->config->maxPriValue}, t1.`pri`) as priOrder")->from(TABLE_STORY)->alias('t1') ->leftJoin(TABLE_PROJECTSTORY)->alias('t2')->on('t1.id=t2.story') ->beginIF(strpos($sql, 'result') !== false)->leftJoin(TABLE_STORYREVIEW)->alias('t3')->on('t1.id = t3.story and t1.version = t3.version')->fi() ->where($sql) ->beginIF($productID != 'all' and $productID != '')->andWhere('t1.`product`')->eq((int)$productID)->fi() ->andWhere('t1.deleted')->eq(0) ->andWhere('t1.vision')->eq($this->config->vision) ->andWhere('t1.type')->eq($type) ->orderBy($orderBy) ->page($pager, 't1.id') ->fetchAll('id'); if(!$tmpStories) return array(); /* Process plans. */ $stories = array(); foreach($tmpStories as $story) { $story->planTitle = ''; $storyPlans = explode(',', trim($story->plan, ',')); foreach($storyPlans as $planID) $story->planTitle .= zget($plans, $planID, '') . ' '; $stories[$story->id] = $story; } return $stories; } /** * Get parent story pairs. * * @param int $productID * @param string $append * @access public * @return void */ public function getParentStoryPairs($productID, $append = '') { $stories = $this->dao->select('id, title')->from(TABLE_STORY) ->where('deleted')->eq(0) ->andWhere('parent')->le(0) ->andWhere('type')->eq('story') ->andWhere('stage')->eq('wait') ->andWhere('status')->eq('active') ->andWhere('product')->eq($productID) ->andWhere('plan')->in('0,') ->andWhere('twins')->eq('') ->beginIF($append)->orWhere('id')->in($append)->fi() ->fetchPairs(); return array(0 => '') + $stories ; } /** * 关闭用户需求,如果所有细分的软件需求已被关闭。 * Close the parent user requirement if all divided stories has been closed. * * @param int $storyID * @access public * @return void */ public function closeParentRequirement(int $storyID) { $parentID = $this->dao->select('BID')->from(TABLE_RELATION)->where('AID')->eq($storyID)->fetch(); if(empty($parentID)) return; $stories = $this->dao->select('t2.id, t2.status')->from(TABLE_RELATION)->alias('t1') ->leftJoin(TABLE_STORY)->alias('t2')->on('t2.id=t1.AID') ->where('t1.BType')->eq('requirement') ->andWhere('t2.status')->ne('closed') ->andWhere('t2.type')->eq('story') ->fetchPairs(); if(empty($stories)) $this->close($parentID->BID); } /** * Get stories of a user. * * @param string $account * @param string $type the query type * @param string $orderBy * @param object $pager * @param string $storyType requirement|story * @param string|int $shadow all | 0 | 1 * @param int $productID * @access public * @return array */ public function getUserStories($account, $type = 'assignedTo', $orderBy = 'id_desc', $pager = null, $storyType = 'story', $includeLibStories = true, $shadow = 0, $productID = 0) { $sql = $this->dao->select("t1.*, IF(t1.`pri` = 0, {$this->config->maxPriValue}, t1.`pri`) as priOrder, t2.name as productTitle, t2.shadow as shadow")->from(TABLE_STORY)->alias('t1') ->leftJoin(TABLE_PRODUCT)->alias('t2')->on('t1.product = t2.id'); if($type == 'reviewBy') $sql = $sql->leftJoin(TABLE_STORYREVIEW)->alias('t3')->on('t1.id = t3.story and t1.version = t3.version'); $stories = $sql->where('t1.deleted')->eq(0) ->andWhere('t2.deleted')->eq('0') ->andWhere('t1.type')->eq($storyType) ->andWhere('t1.vision')->eq($this->config->vision) ->beginIF($type != 'closedBy' and $this->app->moduleName == 'block')->andWhere('t1.status')->ne('closed')->fi() ->beginIF($type != 'all') ->beginIF($type == 'assignedTo')->andWhere('t1.assignedTo')->eq($account)->fi() ->beginIF($type == 'reviewBy')->andWhere('t3.reviewer')->eq($account)->andWhere('t3.result')->eq('')->andWhere('t1.status')->in('reviewing,changing')->fi() ->beginIF($type == 'openedBy')->andWhere('t1.openedBy')->eq($account)->fi() ->beginIF($type == 'reviewedBy')->andWhere("CONCAT(',', t1.reviewedBy, ',')")->like("%,$account,%")->fi() ->beginIF($type == 'closedBy')->andWhere('t1.closedBy')->eq($account)->fi() ->fi() ->beginIF($includeLibStories == false and $this->config->edition == 'max')->andWhere('t1.lib')->eq('0')->fi() ->beginIF($shadow !== 'all')->andWhere('t2.shadow')->eq((int)$shadow)->fi() ->beginIF($productID)->andWhere('t1.product')->eq((int)$productID)->fi() ->orderBy($orderBy) ->page($pager) ->fetchAll('id'); $this->loadModel('common')->saveQueryCondition($this->dao->get(), 'story', false); $productIdList = array(); foreach($stories as $story) $productIdList[$story->product] = $story->product; return $this->storyTao->mergePlanTitleAndChildren($productIdList, $stories, $storyType); } /** * Get story pairs of a user. * * @param string $account * @param string $limit * @param string $type requirement|story * @param array $skipProductIDList * @param int|array $appendStoryID * @access public * @return array */ public function getUserStoryPairs($account, $limit = 10, $type = 'story', $skipProductIDList = array(), $appendStoryID = 0) { return $this->dao->select('id, title') ->from(TABLE_STORY) ->where('deleted')->eq(0) ->andWhere('status')->ne('closed') ->andWhere('type')->eq($type) ->andWhere('vision')->eq($this->config->vision) ->andWhere('assignedTo')->eq($account) ->andWhere('product')->ne(0) ->beginIF(!empty($skipProductIDList))->andWhere('product')->notin($skipProductIDList)->fi() ->beginIF(!empty($appendStoryID))->orWhere('id')->in($appendStoryID)->fi() ->orderBy('id_desc') ->limit($limit) ->fetchPairs('id', 'title'); } /** * Get the story ID list of the linked to task. * * @param int $executionID * @access public * @return array */ public function getIdListWithTask($executionID) { return $this->dao->select('story')->from(TABLE_TASK) ->where('execution')->eq($executionID) ->andWhere('story')->ne(0) ->andWhere('deleted')->eq(0) ->fetchPairs(); } /** * Get team members for a project or execution. * * @param int $storyID * @param string $actionType * @access public * @return array */ public function getTeamMembers($storyID, $actionType) { $teamMembers = array(); if($actionType == 'changed') { $executions = $this->dao->select('execution')->from(TABLE_TASK) ->where('story')->eq($storyID) ->andWhere('status')->ne('cancel') ->andWhere('deleted')->eq(0) ->fetchPairs(); if($executions) $teamMembers = $this->dao->select('account')->from(TABLE_TEAM)->where('root')->in($executions)->andWhere('type')->eq('execution')->fetchPairs('account'); } else { $projects = $this->dao->select('t1.project') ->from(TABLE_PROJECTSTORY)->alias('t1') ->leftJoin(TABLE_PROJECT)->alias('t2')->on('t1.project = t2.id') ->where('t1.story')->eq((int)$storyID) ->andWhere('t2.status')->eq('doing') ->andWhere('t2.deleted')->eq(0) ->fetchPairs(); if($projects) $teamMembers = $this->dao->select('account')->from(TABLE_TEAM)->where('root')->in($projects)->andWhere('type')->eq('project')->fetchPairs('account'); } return $teamMembers; } /** * Get version of a story. * * @param int $storyID * @access public * @return int */ public function getVersion($storyID) { return (int)$this->dao->select('version')->from(TABLE_STORY)->where('id')->eq((int)$storyID)->fetch('version'); } /** * Get versions of some stories. * * @param array|string story id list * @access public * @return array */ public function getVersions($storyID) { return $this->dao->select('id, version')->from(TABLE_STORY)->where('id')->in($storyID)->fetchPairs(); } /** * 获取零用例需求。 * Get zero case. * * @param int $productID * @param int $branchID * @param string $orderBy * @param object $pager * @access public * @return array */ public function getZeroCase(int $productID, int $branchID = 0, string $orderBy = 'id_desc', object $pager = null): array { $casedStories = $this->dao->select('DISTINCT story')->from(TABLE_CASE)->where('product')->eq($productID)->andWhere('story')->ne(0)->andWhere('deleted')->eq(0)->fetchAll('story'); $allStories = $this->getProductStories($productID, $branchID, 0, 'all', 'story', $orderBy, false, array_keys($casedStories), $pager); return $allStories; } /** * Get changed stories. * * @param object $story * @access public * @return array */ public function getChangedStories($story): array { if($story->type == 'requirement') return array(); $relations = $this->dao->select('*')->from(TABLE_RELATION) ->where('AType')->eq('requirement') ->andWhere('BType')->eq('story') ->andWhere('relation')->eq('subdivideinto') ->andWhere('BID')->eq($story->id) ->fetchAll('AID'); if(empty($relations)) return array(); $stories = $this->getByList(array_keys($relations)); foreach($stories as $id => $story) { $version = $relations[$story->id]->AVersion; if($version > $story->version) unset($stories[$id]); } return $stories; } public function getAllStorySort($planID, $planOrder) { $orderBy = $this->post->orderBy; if(strpos($orderBy, 'order') !== false) $orderBy = str_replace('order', 'id', $orderBy); $stories = $this->loadModel('story')->getPlanStories($planID, 'all'); $storyIDList = array_keys($stories); if(strpos($this->post->orderBy, 'order') !== false and !empty($planOrder)) $stories = $this->sortPlanStory($stories, $planOrder, $orderBy); $frontCount = (int)$this->post->recPerPage * ((int)$this->post->pageID - 1); $behindCount = (int)$this->post->recPerPage * (int)$this->post->pageID; $frontIDList = array_slice($storyIDList, 0, $frontCount); $behindIDList = array_slice($storyIDList, $behindCount, count($storyIDList) - $behindCount); $frontIDList = !empty($frontIDList) ? implode(',', $frontIDList) . ',' : ''; $behindIDList = !empty($behindIDList) ? implode(',', $behindIDList) : ''; return $frontIDList . $this->post->stories . $behindIDList; } /** * Batch get story stage. * * @param array $stories * @access public * @return array */ public function batchGetStoryStage($stories) { return $this->dao->select('*')->from(TABLE_STORYSTAGE) ->where('story')->in($stories) ->fetchGroup('story', 'branch'); } /** * Check need confirm. * * @param array|object $object * @access public * @return array|object */ public function checkNeedConfirm($data) { $objectList = is_object($data) ? array($data->id => $data) : $data; $storyIdList = array(); $storyVersionList = array(); foreach($objectList as $key => $object) { $object->needconfirm = false; if($object->story) { $storyIdList[$key] = $object->story; $storyVersionList[$key] = $object->storyVersion; } } $stories = $this->dao->select('id,version')->from(TABLE_STORY)->where('id')->in($storyIdList)->andWhere('status')->eq('active')->fetchPairs('id', 'version'); foreach($storyIdList as $key => $storyID) { if(isset($stories[$storyID]) and $stories[$storyID] > $storyVersionList[$key]) $objectList[$key]->needconfirm = true; } return is_object($data) ? reset($objectList) : $objectList; } /** * 格式化需求的概要信息。 * Format stories pairs. * * @param array $stories * @param string $type full|short|story|requirement * @param int $limit * @access public * @return array */ public function formatStories(array $stories, string $type = 'full', int $limit = 0): array { /* Format these stories. */ $storyPairs = array(0 => ''); foreach($stories as $story) { if($type == 'short') { $property = '[p' . (!empty($this->lang->story->priList[$story->pri]) ? $this->lang->story->priList[$story->pri] : 0) . ', ' . $story->estimate . "{$this->config->hourUnit}]"; } elseif($type == 'full') { $property = '(' . $this->lang->story->pri . ':' . (!empty($this->lang->story->priList[$story->pri]) ? $this->lang->story->priList[$story->pri] : 0) . ',' . $this->lang->story->estimate . ':' . $story->estimate . ')'; } else { $property = ''; } $storyPairs[$story->id] = $story->id . ':' . $story->title . ' ' . $property; } return $storyPairs; } /** * Extract accounts from some stories. * * @param array $stories * @access public * @return array */ public function extractAccountsFromList($stories) { $accounts = array(); foreach($stories as $story) { if(!empty($story->openedBy)) $accounts[] = $story->openedBy; if(!empty($story->assignedTo)) $accounts[] = $story->assignedTo; if(!empty($story->closedBy)) $accounts[] = $story->closedBy; if(!empty($story->lastEditedBy)) $accounts[] = $story->lastEditedBy; } return array_unique($accounts); } /** * Extract accounts from a story. * * @param object $story * @access public * @return array */ public function extractAccountsFromSingle($story) { $accounts = array(); if(!empty($story->openedBy)) $accounts[] = $story->openedBy; if(!empty($story->assignedTo)) $accounts[] = $story->assignedTo; if(!empty($story->closedBy)) $accounts[] = $story->closedBy; if(!empty($story->lastEditedBy)) $accounts[] = $story->lastEditedBy; return array_unique($accounts); } /** * Merge the default chart settings and the settings of current chart. * * @param string $chartType * @access public * @return void */ public function mergeChartOption($chartType) { $chartOption = $this->lang->story->report->$chartType; $commonOption = $this->lang->story->report->options; $chartOption->graph->caption = $this->lang->story->report->charts[$chartType]; if(!isset($chartOption->type)) $chartOption->type = $commonOption->type; if(!isset($chartOption->width)) $chartOption->width = $commonOption->width; if(!isset($chartOption->height)) $chartOption->height = $commonOption->height; foreach($commonOption->graph as $key => $value) if(!isset($chartOption->graph->$key)) $chartOption->graph->$key = $value; } /** * Get report data of storys per product * * @access public * @return array */ public function getDataOfStorysPerProduct() { $datas = $this->dao->select('product as name, count(product) as value')->from(TABLE_STORY) ->where($this->reportCondition()) ->groupBy('product')->orderBy('value DESC')->fetchAll('name'); if(!$datas) return array(); $products = $this->loadModel('product')->getPairs(); foreach($datas as $productID => $data) $data->name = isset($products[$productID]) ? $products[$productID] : $this->lang->report->undefined; return $datas; } /** * Get report data of storys per module * * @access public * @return array */ public function getDataOfStorysPerModule() { $datas = $this->dao->select('module as name, count(module) as value, product, branch') ->from(TABLE_STORY) ->where($this->reportCondition()) ->groupBy('module,product,branch') ->orderBy('value DESC') ->fetchAll('name'); if(!$datas) return array(); $branchIDList = array(); foreach($datas as $project) { if(!$project->branch) continue; $branchIDList[$project->branch] = $project->branch; } $branchs = $this->dao->select('id, name')->from(TABLE_BRANCH)->where('id')->in($branchIDList)->andWhere('deleted')->eq(0)->fetchALL('id'); $modules = $this->loadModel('tree')->getModulesName(array_keys($datas)); foreach($datas as $moduleID => $data) { $branch = ''; if(isset($branchs[$data->branch]->name)) { $branch = '/' . $branchs[$data->branch]->name; } $data->name = $branch . (isset($modules[$moduleID]) ? $modules[$moduleID] : '/'); } return $datas; } /** * Get report data of storys per source * * @access public * @return array */ public function getDataOfStorysPerSource() { $datas = $this->dao->select('source as name, count(source) as value')->from(TABLE_STORY) ->where($this->reportCondition()) ->groupBy('source')->orderBy('value DESC')->fetchAll('name'); if(!$datas) return array(); $this->lang->story->sourceList[''] = $this->lang->report->undefined; foreach($datas as $key => $data) $data->name = isset($this->lang->story->sourceList[$key]) ? $this->lang->story->sourceList[$key] : $this->lang->report->undefined; return $datas; } /** * Get report data of storys per plan * * @access public * @return array */ public function getDataOfStorysPerPlan() { $datas = $this->dao->select('plan as name, count(plan) as value')->from(TABLE_STORY) ->where($this->reportCondition()) ->groupBy('plan')->orderBy('value DESC')->fetchAll('name'); if(!$datas) return array(); /* Separate for multi-plan key. */ foreach($datas as $planID => $data) { if(strpos($planID, ',') !== false) { $planIdList = explode(',', $planID); foreach($planIdList as $multiPlanID) { if(empty($datas[$multiPlanID])) { $datas[$multiPlanID] = new stdclass(); $datas[$multiPlanID]->name = $multiPlanID; $datas[$multiPlanID]->value = 0; } $datas[$multiPlanID]->value += $data->value; } unset($datas[$planID]); } } /* Fix bug #2697. */ if(isset($datas[''])) { if(empty($datas[0])) { $datas[0] = new stdclass(); $datas[0]->name = 0; $datas[0]->value = 0; } $datas[0]->value += $datas['']->value; unset($datas['']); } $plans = $this->dao->select('id, title')->from(TABLE_PRODUCTPLAN)->where('id')->in(array_keys($datas))->fetchPairs(); foreach($datas as $planID => $data) $data->name = isset($plans[$planID]) ? $plans[$planID] : $this->lang->report->undefined; return $datas; } /** * Get report data of storys per status * * @access public * @return array */ public function getDataOfStorysPerStatus() { $datas = $this->dao->select('status as name, count(status) as value')->from(TABLE_STORY) ->where($this->reportCondition()) ->groupBy('status')->orderBy('value DESC')->fetchAll('name'); if(!$datas) return array(); foreach($datas as $status => $data) if(isset($this->lang->story->statusList[$status])) $data->name = $this->lang->story->statusList[$status]; return $datas; } /** * Get report data of storys per stage * * @access public * @return array */ public function getDataOfStorysPerStage() { $datas = $this->dao->select('stage as name, count(stage) as value')->from(TABLE_STORY) ->where($this->reportCondition()) ->groupBy('stage')->orderBy('value DESC')->fetchAll('name'); if(!$datas) return array(); foreach($datas as $stage => $data) $data->name = $this->lang->story->stageList[$stage] != '' ? $this->lang->story->stageList[$stage] : $this->lang->report->undefined; return $datas; } /** * Get report data of storys per pri * * @access public * @return array */ public function getDataOfStorysPerPri() { $datas = $this->dao->select('pri as name, count(pri) as value')->from(TABLE_STORY) ->where($this->reportCondition()) ->groupBy('pri')->orderBy('value DESC')->fetchAll('name'); if(!$datas) return array(); foreach($datas as $pri => $data) $data->name = $this->lang->story->priList[$pri] != '' ? $this->lang->story->priList[$pri] : $this->lang->report->undefined; return $datas; } /** * Get report data of storys per estimate * * @access public * @return array */ public function getDataOfStorysPerEstimate() { return $this->dao->select('estimate as name, count(estimate) as value')->from(TABLE_STORY) ->where($this->reportCondition()) ->groupBy('estimate')->orderBy('value')->fetchAll(); } /** * Get report data of storys per openedBy * * @access public * @return array */ public function getDataOfStorysPerOpenedBy() { $datas = $this->dao->select('openedBy as name, count(openedBy) as value')->from(TABLE_STORY) ->where($this->reportCondition()) ->groupBy('openedBy')->orderBy('value DESC')->fetchAll('name'); if(!$datas) return array(); if(!isset($this->users)) $this->users = $this->loadModel('user')->getPairs('noletter'); foreach($datas as $account => $data) $data->name = isset($this->users[$account]) ? $this->users[$account] : $this->lang->report->undefined; return $datas; } /** * Get report data of storys per assignedTo * * @access public * @return array */ public function getDataOfStorysPerAssignedTo() { $datas = $this->dao->select('assignedTo as name, count(assignedTo) as value')->from(TABLE_STORY) ->where($this->reportCondition()) ->groupBy('assignedTo')->orderBy('value DESC')->fetchAll('name'); if(!$datas) return array(); if(!isset($this->users)) $this->users = $this->loadModel('user')->getPairs('noletter'); foreach($datas as $account => $data) $data->name = (isset($this->users[$account]) and $this->users[$account] != '') ? $this->users[$account] : $this->lang->report->undefined; return $datas; } /** * Get report data of storys per closedReason * * @access public * @return array */ public function getDataOfStorysPerClosedReason() { $datas = $this->dao->select('closedReason as name, count(closedReason) as value')->from(TABLE_STORY) ->where($this->reportCondition()) ->groupBy('closedReason')->orderBy('value DESC')->fetchAll('name'); if(!$datas) return array(); foreach($datas as $reason => $data) $data->name = $this->lang->story->reasonList[$reason] != '' ? $this->lang->story->reasonList[$reason] : $this->lang->report->undefined; return $datas; } /** * Get report data of storys per change * * @access public * @return array */ public function getDataOfStorysPerChange() { return $this->dao->select('(version-1) as name, count(*) as value')->from(TABLE_STORY) ->where($this->reportCondition()) ->groupBy('version')->orderBy('value')->fetchAll(); } /** * Get kanban group data. * * @param array $stories * @access public * @return array */ public function getKanbanGroupData($stories) { $storyGroup = array(); foreach($stories as $story) $storyGroup[$story->stage][$story->id] = $story; return $storyGroup; } /** * Get toList and ccList. * * @param object $story * @param string $actionType * @access public * @return bool|array */ public function getToAndCcList($story, $actionType) { /* Set toList and ccList. */ $toList = $story->assignedTo; $ccList = isset($story->mailto) ? str_replace(' ', '', trim($story->mailto, ',')) : ''; /* If the action is changed or reviewed, mail to the project or execution team. */ if(strtolower($actionType) == 'changed' or strtolower($actionType) == 'reviewed') { $teamMembers = $this->getTeamMembers($story->id, $actionType); if($teamMembers) { $ccList .= ',' . implode(',', $teamMembers); $ccList = ltrim($ccList, ','); } } if(strtolower($actionType) == 'changed' or strtolower($actionType) == 'opened') { $reviewerList = $this->getReviewerPairs($story->id, $story->version); unset($reviewerList[$story->assignedTo]); $ccList .= ',' . implode(',', array_keys($reviewerList)); } 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); } } elseif($story->status == 'closed') { $ccList .= ',' . $story->openedBy; } return array($toList, $ccList); } /** * Adjust the action clickable. * * @param object $story * @param string $action * @access public * @return void */ public static function isClickable($story, $action) { static $shadowProducts = array(); if(empty($shadowProducts[$story->product])) { global $dbh; $stmt = $dbh->query('SELECT id FROM ' . TABLE_PRODUCT . " WHERE shadow = 1")->fetchAll(); foreach($stmt as $row) $shadowProducts[$row->id] = $row->id; } $action = strtolower($action); global $app, $config; if($story->parent < 0 and strpos($config->story->list->actionsOpratedParentStory, ",$action,") === false) return false; $story->reviewer = isset($story->reviewer) ? $story->reviewer : array(); $story->notReview = isset($story->notReview) ? $story->notReview : array(); $isSuperReviewer = str_contains(',' . zget($config->story, 'superReviewers', '') . ',', ",{$app->user->account},"); if($action == 'change') return (($isSuperReviewer or count($story->reviewer) == 0 or count($story->notReview) == 0) and $story->status == 'active'); if($action == 'submitreview') return strpos('draft,changing', $story->status) !== false; if($action == 'review') return (($isSuperReviewer or in_array($app->user->account, $story->notReview)) and $story->status == 'reviewing'); if($action == 'recall') return strpos('reviewing,changing', $story->status) !== false; if($action == 'close') return $story->status != 'closed'; if($action == 'activate') return $story->status == 'closed'; if($action == 'assignto') return $story->status != 'closed'; if($action == 'batchcreate' and $story->parent > 0) return false; if($action == 'batchcreate' and !empty($story->twins)) return false; if($action == 'batchcreate' and $story->type == 'requirement' and $story->status != 'closed') return strpos('draft,reviewing,changing', $story->status) === false; if($action == 'batchcreate' and $config->vision == 'lite' and ($story->status == 'active' and ($story->stage == 'wait' or $story->stage == 'projected'))) return true; /* Adjust code, hide split entry. */ if($action == 'batchcreate' and ($story->status != 'active' or (isset($shadowProducts[$story->product])) or (!isset($shadowProducts[$story->product]) && $story->stage != 'wait') or !empty($story->plan))) return false; return true; } /** * Build operate menu. * * @param object $story * @param string $type * @param object $execution * @param string $storyType story|requirement * @access public * @return string */ public function buildOperateMenu($story, $type = 'view', $execution = '', $storyType = 'story') { $menu = ''; $mainMenu = array(); $dropMenus = array(); $suffixMenu = array(); $params = "storyID=$story->id"; if($type == 'view') { $mainMenu[] = commonModel::buildActionItem('story', 'change', $params, $story, array('icon' => 'alter', 'text' => $this->lang->story->change)); if(str_contains('draft,changing', $story->status)) $mainMenu[] = commonModel::buildActionItem('story', 'submitReview', $params . "&storyType=$story->type", $story, array('icon' => 'confirm', 'text' => $this->lang->story->submitReview, 'data-toggle' => 'modal')); $title = $story->status == 'changing' ? $this->lang->story->recallChange : $this->lang->story->recall; $mainMenu[] = commonModel::buildActionItem('story', 'recall', $params . "&from=view&confirm=no&storyType={$story->type}", $story, array('icon' => 'undo', 'text' => $title, 'class' => 'ajax-submit')); $mainMenu[] = commonModel::buildActionItem('story', 'review', $params . "&from={$this->app->tab}&storyType={$story->type}", $story, array('icon' => 'search', 'text' => $this->lang->story->review)); $executionID = empty($execution) ? 0 : $execution->id; if(!isonlybody()) { $mainMenu[] = commonModel::buildActionItem('story', 'batchCreate', "productID=$story->product&branch=$story->branch&moduleID=$story->module&$params&executionID=$executionID&plan=0&storyType=story", $story, array('icon' => 'search', 'text' => $this->lang->story->subdivide, 'data-toggle' => 'modal')); } $mainMenu[] = commonModel::buildActionItem('story', 'assignTo', $params . "&kanbanGroup=default&from=&storyType=$story->type", $story, array('icon' => 'hand-right', 'text' => $this->lang->story->assignTo, 'data-toggle' => 'modal')); $mainMenu[] = commonModel::buildActionItem('story', 'close', $params . "&from=&storyType=$story->type", $story, array('icon' => 'off', 'text' => $this->lang->story->close, 'data-toggle' => 'modal')); $mainMenu[] = commonModel::buildActionItem('story', 'activate', $params . "&storyType=$story->type", $story, array('icon' => 'magic', 'text' => $this->lang->story->activate, 'data-toggle' => 'modal')); $disabledFeatures = ",{$this->config->disabledFeatures},"; if($this->config->edition == 'max' and $this->app->tab == 'project' and common::hasPriv('story', 'importToLib') and strpos($disabledFeatures, ',assetlibStorylib,') === false and strpos($disabledFeatures, ',assetlib,') === false) { $mainMenu[] = array('url' => '#importToLib', 'icon' => 'assets', 'text' => $this->lang->story->importToLib, 'data-toggle' => 'modal'); } /* Print testcate actions. */ if($story->parent >= 0 and $story->type != 'requirement' and (common::hasPriv('testcase', 'create', $story) or common::hasPriv('testcase', 'batchCreate', $story))) { $this->app->loadLang('testcase'); $mainMenu[] = array('url' => '#caseActions', 'text' => $this->lang->testcase->common, 'data-toggle' => 'dropdown', 'data-placement' => 'top-end', 'caret' => 'up'); $dropMenus['caseActions'][] = commonModel::buildActionItem('testcase', 'create', "productID=$story->product&branch=$story->branch&moduleID=0&from=¶m=0&$params", $story, array('text' => $this->lang->testcase->create, 'data-toggle' => 'modal', 'data-size' => 'lg')); $dropMenus['caseActions'][] = commonModel::buildActionItem('testcase', 'batchCreate', "productID=$story->product&branch=$story->branch&moduleID=0&$params", null, array('text' => $this->lang->testcase->batchCreate, 'data-toggle' => 'modal', 'data-size' => 'lg')); } if(($this->app->tab == 'execution' or (!empty($execution) and $execution->multiple === '0')) and $story->status == 'active' and $story->type == 'story') $mainMenu[] = commonModel::buildActionItem('task', 'create', "execution={$this->session->execution}&{$params}&moduleID=$story->module", $story, array('icon' => 'plus', 'text' => $this->lang->task->create)); //$menu .= "
"; //$menu .= $this->buildFlowMenu('story', $story, $type, 'direct'); //$menu .= ""; $suffixMenu[] = commonModel::buildActionItem('story', 'edit', $params . "&kanbanGroup=default&storyType=$story->type", $story, array('icon' => 'edit')); $suffixMenu[] = commonModel::buildActionItem('story', 'create', "productID=$story->product&branch=$story->branch&moduleID=$story->module&{$params}&executionID=0&bugID=0&planID=0&todoID=0&extra=&storyType=$story->type", $story, array('icon' => 'copy')); if(common::hasPriv('story', 'delete')) $suffixMenu[] = array('url' => helper::createLink('story', 'delete', $params), 'class' => 'ajax-submit', 'icon' => 'trash'); } return array('mainMenu' => array_values(array_filter($mainMenu)), 'suffixMenu' => array_values(array_filter($suffixMenu)), 'dropMenus' => $dropMenus); } /** * Merge story reviewers. * * @param array|object $stories * @param bool $isObject * @access public * @return array|object */ public function mergeReviewer($stories, $isObject = false) { $rawQuery = $this->dao->get(); if($isObject) { $story = $stories; $stories = (array)$stories; $stories[$story->id] = $story; } /* Set child story id into array. */ $storyIdList = isset($stories['id']) ? array($stories['id'] => $stories['id']) : array_keys($stories); if(isset($stories['id']) and isset($story->children)) $storyIdList = array_merge($storyIdList, array_keys($story->children)); if(!isset($stories['id'])) { foreach($stories as $story) { if(isset($story->children)) $storyIdList = array_merge($storyIdList, array_keys($story->children)); } } $allReviewers = $this->dao->select('story,reviewer,result')->from(TABLE_STORY)->alias('t1') ->leftJoin(TABLE_STORYREVIEW)->alias('t2')->on('t1.version=t2.version and t1.id=t2.story') ->where('story')->in($storyIdList) ->fetchGroup('story', 'reviewer'); foreach($allReviewers as $storyID => $reviewerList) { if(isset($stories[$storyID])) { $stories[$storyID]->reviewer = array_keys($reviewerList); $stories[$storyID]->notReview = array(); foreach($reviewerList as $reviewer => $reviewInfo) { if($reviewInfo->result == '') $stories[$storyID]->notReview[] = $reviewer; } } else { foreach($stories as $story) { if(!isset($story->children)) continue; if(isset($story->children[$storyID])) { $story->children[$storyID]->reviewer = array_keys($reviewerList); $story->children[$storyID]->notReview = array(); foreach($reviewerList as $reviewer => $reviewInfo) { if($reviewInfo->result == '') $story->children[$storyID]->notReview[] = $reviewer; } } } } } $this->dao->sqlobj->sql = $rawQuery; if($isObject) return $stories[$story->id]; return $stories; } /** * Print cell data * * @param object $col * @param object $story * @param array $users * @param array $branches * @param array $storyStages * @param array $modulePairs * @param array $storyTasks * @param array $storyBugs * @param array $storyCases * @param string $mode * @param string $storyType * @param object $execution * @param string $isShowBranch * @access public * @return void */ public function printCell($col, $story, $users, $branches, $storyStages, $modulePairs = array(), $storyTasks = array(), $storyBugs = array(), $storyCases = array(), $mode = 'datatable', $storyType = 'story', $execution = null, $isShowBranch = '') { $tab = $this->app->tab; $executionID = empty($execution) ? $this->session->execution : $execution->id; $account = $this->app->user->account; $storyLink = helper::createLink('story', 'view', "storyID=$story->id&version=0¶m=&storyType=$story->type") . "#app=$tab"; $canView = common::hasPriv($story->type, 'view', null, "storyType=$story->type"); if($tab == 'project') { if($this->session->multiple) { $storyLink = helper::createLink('projectstory', 'view', "storyID=$story->id&project={$this->session->project}"); $canView = common::hasPriv('projectstory', 'view'); } else { $storyLink = helper::createLink('story', 'view', "storyID=$story->id&version=0¶m={$this->session->execution}&storyType=$story->type"); } } elseif($tab == 'execution') { $storyLink = helper::createLink('execution', 'storyView', "storyID=$story->id&execution={$this->session->execution}"); $canView = common::hasPriv('execution', 'storyView'); } /* Check the product is closed. */ $canBeChanged = common::canBeChanged('story', $story); $canBatchEdit = common::hasPriv('story', 'batchEdit'); $canBatchClose = common::hasPriv($story->type, 'batchClose'); $canBatchReview = common::hasPriv('story', 'batchReview'); $canBatchChangeStage = common::hasPriv('story', 'batchChangeStage'); $canBatchChangeBranch = common::hasPriv($story->type, 'batchChangeBranch'); $canBatchChangeModule = common::hasPriv($story->type, 'batchChangeModule'); $canBatchChangePlan = common::hasPriv('story', 'batchChangePlan'); $canBatchAssignTo = common::hasPriv($story->type, 'batchAssignTo'); $canBatchUnlinkStory = common::hasPriv('projectstory', 'batchUnlinkStory'); $canBatchUnlink = common::hasPriv('execution', 'batchUnlinkStory'); if($tab == 'execution') { $checkObject = new stdclass(); $checkObject->execution = $executionID; $canBatchToTask = common::hasPriv('story', 'batchToTask', $checkObject); } if($tab == 'execution') { $canBatchAction = ($canBeChanged and ($canBatchEdit or $canBatchClose or $canBatchChangeStage or $canBatchUnlink or $canBatchToTask)); } elseif($tab == 'project') { $canBatchAction = ($canBatchEdit or $canBatchClose or $canBatchReview or $canBatchChangeStage or $canBatchChangeBranch or $canBatchChangeModule or $canBatchChangePlan or $canBatchAssignTo or $canBatchUnlinkStory); } else { $canBatchAction = ($canBatchEdit or $canBatchClose or $canBatchReview or $canBatchChangeStage or $canBatchChangeBranch or $canBatchChangeModule or $canBatchChangePlan or $canBatchAssignTo); } $id = $col->id; if($col->show) { $class = "c-{$id}"; $title = ''; $style = ''; if($id == 'assignedTo') { $title = zget($users, $story->assignedTo, $story->assignedTo); if($story->assignedTo == $account) $class .= ' red'; } elseif($id == 'openedBy') { $title = zget($users, $story->openedBy, $story->openedBy); } elseif($id == 'title') { $title = $story->title; $class .= ' text-ellipsis'; if(!empty($story->children)) $class .= ' has-child'; } elseif($id == 'plan') { $title = isset($story->planTitle) ? $story->planTitle : ''; $class .= ' text-ellipsis'; } elseif($id == 'branch') { $title = zget($branches, $story->branch, ''); $class .= ' text-ellipsis'; } elseif($id == 'sourceNote') { $title = $story->sourceNote; $class .= ' text-ellipsis'; } elseif($id == 'category') { $title = zget($this->lang->story->categoryList, $story->category); } elseif($id == 'estimate') { $title = $story->estimate . ' ' . $this->lang->hourCommon; } elseif($id == 'reviewedBy') { $reviewedBy = ''; foreach(explode(',', $story->reviewedBy) as $user) $reviewedBy .= zget($users, $user) . ' '; $story->reviewedBy = trim($reviewedBy); $title = $reviewedBy; $class .= ' text-ellipsis'; } elseif($id == 'stage') { $style .= 'overflow: visible;'; $maxStage = $story->stage; $stageList = implode(',', array_keys($this->lang->story->stageList)); $maxStagePos = strpos($stageList, $maxStage); if(isset($storyStages[$story->id])) { foreach($storyStages[$story->id] as $storyStage) { if(strpos($stageList, $storyStage->stage) !== false and strpos($stageList, $storyStage->stage) > $maxStagePos) { $maxStage = $storyStage->stage; $maxStagePos = strpos($stageList, $storyStage->stage); } } } $title .= $this->lang->story->stageList[$maxStage]; } elseif($id == 'feedbackBy') { $title = $story->feedbackBy; } elseif($id =='version') { $title = $story->version; $class = 'text-center'; } elseif($id == 'notifyEmail') { $title = $story->notifyEmail; } elseif($id == 'actions') { $class .= ($tab == 'project' and $story->type == 'requirement') ? ' text-center' : ' text-left'; } elseif($id == 'order') { $class = 'sort-handler c-sort'; } echo "