tao.php 120 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637
  1. <?php
  2. /**
  3. * The model file of story module of ZenTaoPMS.
  4. *
  5. * @copyright Copyright 2009-2023 禅道软件(青岛)有限公司(ZenTao Software (Qingdao) Co., Ltd. www.zentao.net)
  6. * @license ZPL(https://zpl.pub/page/zplv12.html) or AGPL(https://www.gnu.org/licenses/agpl-3.0.en.html)
  7. * @author wangyidong<wangyidong@cnezsoft.com>
  8. * @package story
  9. * @link https://www.zentao.net
  10. */
  11. class storyTao extends storyModel
  12. {
  13. /**
  14. * 获取需求的基础数据。
  15. * Fetch base info of a story.
  16. *
  17. * @param int $storyID
  18. * @access protected
  19. * @return object|false
  20. */
  21. protected function fetchBaseInfo($storyID)
  22. {
  23. return $this->dao->select('*')->from(TABLE_STORY)->where('id')->eq($storyID)->fetch();
  24. }
  25. /**
  26. * 获取项目关联的用户需求。
  27. * Get project requirements.
  28. *
  29. * @param int $productID
  30. * @param int $projectID
  31. * @param object|null $pager
  32. * @access protected
  33. * @return array
  34. */
  35. protected function getProjectRequirements($productID, $projectID, $pager = null)
  36. {
  37. return $this->dao->select('t2.*')->from(TABLE_PROJECTSTORY)->alias('t1')
  38. ->leftJoin(TABLE_STORY)->alias('t2')->on("t1.story = t2.id AND t2.type ='requirement'")
  39. ->where('t2.deleted')->eq('0')
  40. ->andWhere('t1.project')->eq($projectID)
  41. ->andWhere('t1.product')->eq($productID)
  42. ->page($pager, 't2.id')
  43. ->fetchAll('id');
  44. }
  45. /**
  46. * 获取需求主动关联、被动关联的需求。
  47. * Get linked stories.
  48. *
  49. * @param int $storyID
  50. * @param string $storyType
  51. * @access public
  52. * @return array
  53. */
  54. public function getRelation($storyID, $storyType)
  55. {
  56. /* 主动关联。*/
  57. $linkedToList = $this->dao->select('BID')->from(TABLE_RELATION)
  58. ->where('AType')->eq($storyType)
  59. ->andWhere('AID')->eq($storyID)
  60. ->andWhere('relation')->eq('linkedto')
  61. ->fetchPairs();
  62. /* 被动关联。*/
  63. $linkedFromList = $this->dao->select('AID')->from(TABLE_RELATION)
  64. ->where('BType')->eq($storyType)
  65. ->andWhere('BID')->eq($storyID)
  66. ->andWhere('relation')->eq('linkedto')
  67. ->fetchPairs();
  68. return $linkedToList + $linkedFromList;
  69. }
  70. /**
  71. * 批量获取产品所有状态对应的需求总数。
  72. * Get stories count of each status by product ID.
  73. *
  74. * @param array $productIDs
  75. * @param string $storyType
  76. * @access protected
  77. * @return array
  78. */
  79. protected function getStoriesCountByProductIDs($productIDs, $storyType = 'requirement')
  80. {
  81. return $this->dao->select('product, status, count(status) AS count')
  82. ->from(TABLE_STORY)
  83. ->where('deleted')->eq(0)
  84. ->andWhere('type')->eq($storyType)
  85. ->andWhere('product')->in($productIDs)
  86. ->groupBy('product, status')
  87. ->fetchGroup('product', 'status');
  88. }
  89. /**
  90. * 获取所有完成的需求数量。
  91. * Get the count of closed stories.
  92. *
  93. * @param string $storyType
  94. * @access protected
  95. * @return array
  96. */
  97. protected function getFinishClosedTotal($storyType = 'story')
  98. {
  99. return $this->dao->select('product, count(1) AS finish')
  100. ->from(TABLE_STORY)
  101. ->where('deleted')->eq(0)
  102. ->andWhere('status')->eq('closed')
  103. ->andWhere('type')->eq($storyType)
  104. ->andWhere('closedReason')->eq('done')
  105. ->groupBy('product')
  106. ->fetchPairs();
  107. }
  108. /**
  109. * 获取所有未完成的需求数量。
  110. * Get the count of unclosed stories.
  111. *
  112. * @param string $storyType
  113. * @access protected
  114. * @return array
  115. */
  116. protected function getUnClosedTotal($storyType = 'story')
  117. {
  118. return $this->dao->select('product, count(1) AS unclosed')
  119. ->from(TABLE_STORY)
  120. ->where('deleted')->eq(0)
  121. ->andWhere('type')->eq($storyType)
  122. ->andWhere('status')->ne('closed')
  123. ->groupBy('product')
  124. ->fetchPairs();
  125. }
  126. /**
  127. * 获取产品评审人。
  128. * Get product reviewers.
  129. *
  130. * @param int $productID
  131. * @param array $storyReviewers
  132. * @access protected
  133. * @return array
  134. */
  135. protected function getProductReviewers($productID, $storyReviewers = array())
  136. {
  137. $this->loadModel('user');
  138. $product = $this->loadModel('product')->getByID($productID);
  139. $reviewers = $product->reviewer;
  140. if(!$reviewers and $product->acl != 'open') $reviewers = $this->user->getProductViewListUsers($product);
  141. return $this->user->getPairs('noclosed|nodeleted|noletter', $storyReviewers, 0, $reviewers);
  142. }
  143. /**
  144. * 构建研发需求的跟踪矩阵信息。
  145. * Build story track.
  146. *
  147. * @param object $story
  148. * @param int $projectID
  149. * @access protected
  150. * @return object
  151. */
  152. protected function buildStoryTrack($story, $projectID = 0)
  153. {
  154. if(count(get_object_vars($story)) == 0) return $story;
  155. /* 获取关联需求的用例、Bug、任务。 */
  156. $track = new stdclass();
  157. $track->parent = $story->parent;
  158. $track->title = $story->title;
  159. $track->cases = $this->loadModel('testcase')->getStoryCases($story->id);
  160. $track->bugs = $this->loadModel('bug')->getStoryBugs($story->id);
  161. $track->tasks = $this->loadModel('task')->getListByStory($story->id, 0, $projectID);
  162. if(!in_array($this->config->edition, array('max', 'ipd'))) return $track;
  163. /* 获取关联需求的设计、关联版本库提交。 */
  164. $track->designs = $this->dao->select('id, name')->from(TABLE_DESIGN)->where('story')->eq($story->id)->andWhere('deleted')->eq('0')->fetchAll('id');
  165. $track->revisions = $this->dao->select('BID, t2.comment')->from(TABLE_RELATION)->alias('t1')
  166. ->leftJoin(TABLE_REPOHISTORY)->alias('t2')->on('t1.BID = t2.id')
  167. ->where('t1.AType')->eq('design')
  168. ->andWhere('t1.BType')->eq('commit')
  169. ->andWhere('t1.AID')->in(array_keys($track->designs))
  170. ->fetchPairs();
  171. return $track;
  172. }
  173. /**
  174. * 根据产品 ID 列表和分支参数,构建查询条件。
  175. * Build products condition.
  176. *
  177. * @param string|int|array $productIdList
  178. * @param array|string|int $branch
  179. * @access protected
  180. * @return string
  181. */
  182. protected function buildProductsCondition($productIdList, $branch = 'all')
  183. {
  184. /* 如果查询所有分支,直接用 idList 条件。 */
  185. if(empty($productIdList)) $productIdList = '0';
  186. if(is_int($productIdList)) $productIdList = (string)$productIdList;
  187. if(empty($productIdList) or $branch === 'all' or $branch === '') return '`product` ' . helper::dbIN($productIdList);
  188. /* 将产品分类为正常产品和多分支产品。 */
  189. $branchProducts = array();
  190. $normalProducts = array();
  191. $productList = $this->dao->select('*')->from(TABLE_PRODUCT)->where('id')->in($productIdList)->fetchAll('id');
  192. foreach($productList as $product)
  193. {
  194. if($product->type != 'normal') $branchProducts[$product->id] = $product->id;
  195. if($product->type == 'normal') $normalProducts[$product->id] = $product->id;
  196. }
  197. /* 如果没有多分支产品,直接返回正常产品 ID 列表。*/
  198. if(empty($branchProducts)) return '`product` ' . helper::dbIN($normalProducts);
  199. /* 构造多分支产品和正常产品的复合条件。 */
  200. if(is_int($branch)) $branch = (string)$branch;
  201. $productQuery = "(`product` " . helper::dbIN($branchProducts) . " AND `branch` " . helper::dbIN($branch) . ')';
  202. if(!empty($normalProducts)) $productQuery .= ' OR `product` ' . helper::dbIN($normalProducts);
  203. return "({$productQuery}) ";
  204. }
  205. /**
  206. * 追加需求所属的计划标题和子需求。
  207. * Merge plan title and children.
  208. *
  209. * @param int|array|string $productID
  210. * @param array $stories
  211. * @param string $type story|requirement
  212. *
  213. * @access protected
  214. * @return array
  215. */
  216. protected function mergePlanTitleAndChildren($productID, $stories, $type = 'story')
  217. {
  218. if(empty($stories)) return array();
  219. $rawQuery = $this->dao->get();
  220. /* Get plans. */
  221. if(empty($productID)) $productID = '0';
  222. if(is_int($productID))$productID = (string)$productID;
  223. $plans = $this->dao->select('id,title')->from(TABLE_PRODUCTPLAN)->Where('deleted')->eq(0)->beginIF($productID)->andWhere('product')->in($productID)->fetchPairs('id', 'title');
  224. $parents = $this->extractParents($stories);
  225. if($parents) $parents = $this->dao->select('id,title,status,version,type')->from(TABLE_STORY)->where('id')->in($parents)->andWhere('deleted')->eq(0)->fetchAll('id');
  226. $childItems = $this->getChildItems($stories);
  227. if($type != 'story')
  228. {
  229. $sameTypeChildren = $this->dao->select('distinct t1.parent')->from(TABLE_STORY)->alias('t1')
  230. ->leftJoin(TABLE_STORY)->alias('t2')->on('t1.parent = t2.id')
  231. ->where('t1.parent')->in(array_keys($stories))
  232. ->andWhere('t1.type = t2.type')
  233. ->andWhere('t2.deleted')->eq(0)
  234. ->fetchPairs();
  235. $otherTypeChildren = $this->dao->select('distinct t1.parent')->from(TABLE_STORY)->alias('t1')
  236. ->leftJoin(TABLE_STORY)->alias('t2')->on('t1.parent = t2.id')
  237. ->where('t1.parent')->in(array_keys($stories))
  238. ->andWhere('t1.type != t2.type')
  239. ->andWhere('t2.deleted')->eq(0)
  240. ->fetchPairs();
  241. }
  242. $demands = $this->dao->select('id,version')->from(TABLE_DEMAND)->where('deleted')->eq(0)->andWhere('status')->eq('active')->fetchPairs();
  243. foreach($stories as $story)
  244. {
  245. /* Judge parent story has changed. */
  246. if($story->parent > 0 && isset($parents[$story->parent]))
  247. {
  248. if($parents[$story->parent]->version > $story->parentVersion && $story->parentVersion > 0 && $parents[$story->parent]->status == 'active') $story->parentChanged = true;
  249. }
  250. /* Judge demand has changed. */
  251. if($story->demand && $story->parent <= 0 && isset($demands[$story->demand]) && $demands[$story->demand] > $story->demandVersion) $story->demandChanged = true;
  252. /* Judge parent story if has same type child or other type child. */
  253. if($story->type != 'story' && $story->isParent == '1')
  254. {
  255. if(isset($sameTypeChildren[$story->id])) $story->hasSameTypeChild = true;
  256. if(isset($otherTypeChildren[$story->id])) $story->hasOtherTypeChild = true;
  257. }
  258. /* Merge plan title. */
  259. $story->planTitle = '';
  260. $storyPlans = explode(',', trim((string)$story->plan, ','));
  261. foreach($storyPlans as $planID) $story->planTitle .= zget($plans, $planID, '') . ' ';
  262. if(isset($childItems[$story->id]))
  263. {
  264. $story->childItem = $childItems[$story->id]['finished'] . ' / ' . $childItems[$story->id]['total'];
  265. $story->childItemTitle = $childItems[$story->id]['title'];
  266. }
  267. $story->originParent = $story->parent;
  268. $story->parent = array();
  269. foreach(explode(',', trim((string)$story->path, ',')) as $parentID)
  270. {
  271. if(!$parentID) continue;
  272. if($parentID == $story->id) continue;
  273. $story->parent[] = (int)$parentID;
  274. }
  275. }
  276. /* For save session query. */
  277. $this->dao->sqlobj->sql = $rawQuery;
  278. return $stories;
  279. }
  280. /**
  281. * 提取需求列表中的父需求 ID 列表。
  282. * Extract parents from stories.
  283. *
  284. * @param array $stories
  285. * @access protected
  286. * @return int[]
  287. */
  288. protected function extractParents($stories)
  289. {
  290. $parent = array_map(function($story)
  291. {
  292. if($story->parent > '0') return $story->parent;
  293. return false;
  294. }, $stories);
  295. return array_values(array_unique(array_filter($parent)));
  296. }
  297. /**
  298. * 获取需求的子项,统计已完成/总数。
  299. * Get child items of stories, and count the finished/total.
  300. *
  301. * @param array $stories
  302. * @access protected
  303. * @return array
  304. */
  305. protected function getChildItems($stories)
  306. {
  307. $childItems = array();
  308. $childStories = $this->dao->select('id, parent, status')->from(TABLE_STORY)
  309. ->where('parent')->in(array_keys($stories))
  310. ->andWhere('deleted')->eq(0)
  311. ->fetchGroup('parent');
  312. foreach($childStories as $parentID => $childStory)
  313. {
  314. $childItems[$parentID]['total'] = count($childStory);
  315. $childItems[$parentID]['finished'] = count(array_filter($childStory, function($story){return $story->status == 'closed';}));
  316. $childItems[$parentID]['title'] = sprintf($this->lang->story->childStoryTitle, $childItems[$parentID]['total'], $childItems[$parentID]['finished']);
  317. }
  318. $childTasks = $this->dao->select('id, story, status')->from(TABLE_TASK)
  319. ->where('story')->in(array_keys($stories))
  320. ->andWhere('deleted')->eq(0)
  321. ->fetchGroup('story');
  322. foreach($childTasks as $parentID => $childTask)
  323. {
  324. $childItems[$parentID]['total'] = count($childTask);
  325. $childItems[$parentID]['finished'] = count(array_filter($childTask, function($task){return in_array($task->status, array('done', 'closed', 'cancel'));}));
  326. $childItems[$parentID]['title'] = sprintf($this->lang->story->childTaskTitle, $childItems[$parentID]['total'], $childItems[$parentID]['finished']);
  327. }
  328. return $childItems;
  329. }
  330. /**
  331. * 通过搜索条件获取关联执行的需求。
  332. * Get execution stories by search.
  333. *
  334. * @param int $executionID
  335. * @param int $queryID
  336. * @param int $productID
  337. * @param string $orderBy
  338. * @param string $storyType
  339. * @param string $sqlCondition
  340. * @param array $excludeStories
  341. * @param object|null $pager
  342. * @access protected
  343. * @return array
  344. */
  345. protected function getExecutionStoriesBySearch($executionID, $queryID, $productID, $orderBy, $storyType = 'story', $sqlCondition = '', $excludeStories = array(), $pager = null)
  346. {
  347. /* 获取查询条件。 */
  348. $rawModule = $this->app->rawModule;
  349. $this->loadModel('search')->setQuery($rawModule == 'projectstory' ? 'projectstory' : 'executionStory', $queryID);
  350. if(!$this->session->executionStoryQuery) $this->session->set('executionStoryQuery', ' 1 = 1');
  351. if($rawModule == 'projectstory') $this->session->set('executionStoryQuery', $this->session->projectstoryQuery);
  352. /* 处理查询条件。 */
  353. $storyQuery = $this->replaceAllProductQuery($this->session->executionStoryQuery);
  354. $storyQuery = $this->replaceRevertQuery($storyQuery, $productID);
  355. $storyQuery = preg_replace('/`(\w+)`/', 't2.`$1`', $storyQuery);
  356. $storyQuery = preg_replace_callback("/t2.`grade` (=|!=) '(\w+)(\d+)'/", function($matches){return "t2.`grade` {$matches[1]} '" . $matches[3] . "' AND t2.`type` = '" . $matches[2] . "'";}, $storyQuery);
  357. if(strpos($storyQuery, 'result') !== false) $storyQuery = str_replace('t2.`result`', 't4.`result`', $storyQuery);
  358. $hasExecution = strpos($storyQuery, 't2.`execution`') !== false;
  359. if($hasExecution) $storyQuery = str_replace('t2.`execution`', 't1.`project`', $storyQuery);
  360. if(strpos($orderBy, 'version_') !== false) $orderBy = str_replace('id_', 't2.version_', $orderBy);
  361. if(strpos($orderBy, 'id_') !== false) $orderBy = str_replace('id_', 't2.id_', $orderBy);
  362. return $this->dao->select("distinct t1.*, t2.*, IF(t2.`pri` = 0, {$this->config->maxPriValue}, t2.`pri`) as priOrder, t3.type as productType, t2.version as version")->from(TABLE_PROJECTSTORY)->alias('t1')
  363. ->leftJoin(TABLE_STORY)->alias('t2')->on('t1.story = t2.id')
  364. ->leftJoin(TABLE_PRODUCT)->alias('t3')->on('t2.product = t3.id')
  365. ->beginIF(strpos($storyQuery, 'result') !== false)->leftJoin(TABLE_STORYREVIEW)->alias('t4')->on('t2.id = t4.story and t2.version = t4.version')->fi()
  366. ->where($storyQuery)
  367. ->beginIF(!$hasExecution)->andWhere('t1.project')->eq($executionID)->fi()
  368. ->andWhere('t2.deleted')->eq(0)
  369. ->andWhere('t3.deleted')->eq(0)
  370. ->beginIF($storyType != 'all')->andWhere('t2.type')->in($storyType)->fi()
  371. ->beginIF($sqlCondition)->andWhere($sqlCondition)->fi()
  372. ->beginIF($excludeStories)->andWhere('t2.id')->notIN($excludeStories)->fi()
  373. ->orderBy($orderBy)
  374. ->page($pager, 't2.id')
  375. ->fetchAll('id');
  376. }
  377. /**
  378. * 替换所有产品的查询条件。
  379. * Replace all product query.
  380. *
  381. * @param string $query
  382. * @access protected
  383. * @return string
  384. */
  385. protected function replaceAllProductQuery($query)
  386. {
  387. $allProduct = "`product` = 'all'";
  388. if(strpos($query, $allProduct) !== false) $query = str_replace($allProduct, '1 = 1', $query);
  389. return $query;
  390. }
  391. /**
  392. * 如果有撤销变更的条件,用撤销变更的 ID 列表做替换
  393. * Replace revert query.
  394. *
  395. * @param string $storyQuery
  396. * @param int $productID
  397. * @access protected
  398. * @return string
  399. */
  400. protected function replaceRevertQuery($storyQuery, $productID)
  401. {
  402. if(strpos($storyQuery, 'result') === false) return $storyQuery;
  403. if(strpos($storyQuery, 'revert') === false) return $storyQuery;
  404. $reviews = $this->getRevertStoryIdList($productID);
  405. $storyQuery = str_replace("`result` = 'revert'", '1 = 1', $storyQuery);
  406. $storyQuery .= " AND `id` " . helper::dbIN($reviews);
  407. return $storyQuery;
  408. }
  409. /**
  410. * 获取撤销变更的 ID 列表。
  411. * Get Story changed Revert ObjectID.
  412. *
  413. * @param int $productID
  414. * @access public
  415. * @return array
  416. */
  417. protected function getRevertStoryIdList($productID)
  418. {
  419. if(empty($productID)) return array();
  420. return $this->dao->select('objectID')->from(TABLE_ACTION)
  421. ->where('product')->like("%,$productID,%")
  422. ->andWhere('action')->eq('reviewed')
  423. ->andWhere('objectType')->eq('story')
  424. ->andWhere('extra')->eq('Revert')
  425. ->groupBy('objectID')
  426. ->orderBy('objectID_desc')
  427. ->fetchPairs('objectID', 'objectID');
  428. }
  429. /**
  430. * 根据请求类型获取查询的模块,如果有模块就获取该模块下的需求,否则获取所有模块下的需求。
  431. * Get modules for query execution stories. If there is a module, get stories under that module, otherwise get the stories under all modules.
  432. *
  433. * @param string $type bymodule|allstory|unclosed
  434. * @param string $param
  435. * @access protected
  436. * @return array
  437. */
  438. protected function getModules4ExecutionStories($type, $param)
  439. {
  440. $moduleID = (int)($type == 'bymodule' && $param !== '' ? $param : $this->cookie->storyModuleParam);
  441. /* 如果模块为空,或者标签不是所有需求,未关闭的需求,某个模块下的需求时,获取所有模块下的需求。*/
  442. /* If the module is empty, or type is not allstory, unclosed, or bymodule, get the stories under all modules. */
  443. if(empty($moduleID) || strpos('allstory,unclosed,bymodule', $type) === false) return [];
  444. /* 从缓存中获取模块路径然后在 LIKE 查询中使用左匹配以利用索引提高性能。Find the path of the module from cache and use left match in like query to improve performance. */
  445. $path = $this->mao->select('path')->from(TABLE_MODULE)->where('id')->eq($moduleID)->fetch('path');
  446. if(empty($path)) return array($moduleID);
  447. return $this->dao->select('id')->from(TABLE_MODULE)->where('deleted')->eq('0')->andWhere('path')->like("$path%")->fetchPairs();
  448. }
  449. /**
  450. * 获取执行下关联的需求。
  451. * Fetch execution stories.
  452. *
  453. * @param dao $storyDAO
  454. * @param int $productID
  455. * @param string $branch
  456. * @param string $type
  457. * @param string $orderBy
  458. * @param object|null $pager
  459. * @access protected
  460. * @return int[]
  461. */
  462. protected function fetchExecutionStories($storyDAO, $productID, $type, $branch, $orderBy, $pager = null)
  463. {
  464. if(strpos($orderBy, 'version_') !== false) $orderBy = str_replace('version_', 't2.version_', $orderBy);
  465. if(strpos($orderBy, 'id_') !== false) $orderBy = str_replace('id_', 't2.id_', $orderBy);
  466. $browseType = $this->session->executionStoryBrowseType;
  467. $unclosedStatus = $this->getUnclosedStatusKeys();
  468. return $storyDAO->beginIF(!empty($productID))->andWhere('t1.product')->eq($productID)->fi()
  469. ->beginIF($type == 'bybranch' && $branch !== '')->andWhere('t2.branch')->in("0,$branch")->fi()
  470. ->beginIF(!empty($browseType) && strpos('draft|reviewing|changing|closed', $browseType) !== false)->andWhere('t2.status')->eq($browseType)->fi()
  471. ->beginIF($browseType == 'unclosed')->andWhere('t2.status')->in($unclosedStatus)->fi()
  472. ->orderBy($orderBy)
  473. ->page($pager, 't2.id')
  474. ->fetchAll('id');
  475. }
  476. /**
  477. * 获取项目下关联的需求。
  478. * Fetch project stories.
  479. *
  480. * @param dao $storyDAO
  481. * @param int $productID
  482. * @param string $type
  483. * @param string $branch
  484. * @param array $executionIdList
  485. * @param string $orderBy
  486. * @param object|null $pager
  487. * @param object|null $project
  488. * @access protected
  489. * @return int[]
  490. * @param mixed[] $executionStoryIdList
  491. */
  492. protected function fetchProjectStories($storyDAO, $productID, $type, $branch, $executionStoryIdList, $orderBy, $pager = null, $project = null)
  493. {
  494. if(strpos($orderBy, 'version_') !== false) $orderBy = str_replace('version_', 't2.version_', $orderBy);
  495. if(strpos($orderBy, 'id_') !== false) $orderBy = str_replace('id_', 't2.id_', $orderBy);
  496. $unclosedStatus = $this->getUnclosedStatusKeys();
  497. $assignProduct = false;
  498. if(!empty($productID) && !empty($project))
  499. {
  500. if(empty($project->charter)) $assignProduct = true;
  501. if(!empty($project->charter) && $project->hasProduct) $assignProduct = true;
  502. }
  503. return $storyDAO->beginIF($assignProduct)->andWhere('t1.product')->eq($productID)->fi()
  504. ->beginIF($type == 'bybranch' and $branch !== '')->andWhere('t2.branch')->in("0,$branch")->fi()
  505. ->beginIF(strpos('draft|reviewing|changing|closed', $type) !== false)->andWhere('t2.status')->eq($type)->fi()
  506. ->beginIF($type == 'unclosed')->andWhere('t2.status')->in($unclosedStatus)->fi()
  507. ->beginIF($type == 'linkedexecution')->andWhere('t2.id')->in($executionStoryIdList)->fi()
  508. ->beginIF($type == 'unlinkedexecution')->andWhere('t2.id')->notIn($executionStoryIdList)->fi()
  509. ->orderBy($orderBy)
  510. ->page($pager, 't2.id')
  511. ->fetchAll('id');
  512. }
  513. /**
  514. * 修正多分支产品需求的阶段,取最靠前的阶段。
  515. * Fix branch story stage.
  516. *
  517. * @param array $stories
  518. * @access protected
  519. * @return array
  520. */
  521. protected function fixBranchStoryStage($stories)
  522. {
  523. if(empty($stories)) return array();
  524. $rawQuery = $this->dao->get();
  525. /* 获取阶段序列和关联的多分支产品需求。 */
  526. $branches = $this->dao->select('t1.story, t2.branch')->from(TABLE_PROJECTSTORY)->alias('t1')
  527. ->leftJoin(TABLE_PROJECTPRODUCT)->alias('t2')->on('t1.project = t2.project')
  528. ->leftJoin(TABLE_PRODUCT)->alias('t3')->on('t1.product = t3.id')
  529. ->where('t1.story')->in(array_keys($stories))
  530. ->andWhere('t1.branch')->eq(BRANCH_MAIN)
  531. ->andWhere('t3.type')->ne('normal')
  532. ->fetchGroup('branch', 'story');
  533. /* Replace branch stage with the story stage. */
  534. foreach($branches as $branchID => $branchList)
  535. {
  536. $stages = $this->dao->select('*')->from(TABLE_STORYSTAGE)->where('story')->in(array_keys($branchList))->andWhere('branch')->eq((int)$branchID)->fetchPairs('story', 'stage');
  537. foreach($stages as $storyID => $stage)
  538. {
  539. if($stories[$storyID]->branch != $branchID) continue;
  540. $stories[$storyID]->stage = $stage;
  541. }
  542. }
  543. $this->dao->sqlobj->sql = $rawQuery;
  544. return $stories;
  545. }
  546. /**
  547. * 获取需求非关闭状态的键值。
  548. * Get unclosed status keys.
  549. *
  550. * @access protected
  551. * @return array
  552. */
  553. protected function getUnclosedStatusKeys()
  554. {
  555. $moduleName = $this->app->rawModule;
  556. if(!in_array($moduleName, array('story', 'epic', 'requirement'))) $moduleName = 'story';
  557. $unclosedStatus = $this->lang->{$moduleName}->statusList;
  558. unset($unclosedStatus['closed']);
  559. return array_keys($unclosedStatus);
  560. }
  561. /**
  562. * 获取需求 ID 列表,这些需求是关联在项目下的执行。
  563. * Get id list of executions by product.
  564. *
  565. * @param string $type
  566. * @param int|array $projectID
  567. * @access protected
  568. * @return array
  569. */
  570. protected function getIdListOfExecutionsByProjectID($type, $projectID)
  571. {
  572. if($type != 'linkedexecution' && $type != 'unlinkedexecution') return array();
  573. $executions = $this->loadModel('execution')->getPairs($projectID);
  574. if(empty($executions)) return array();
  575. return $this->dao->select('story')->from(TABLE_PROJECTSTORY)->where('project')->in(array_keys($executions))->fetchPairs();
  576. }
  577. /**
  578. * 通过产品ID列表批量获取产品关联的需求列表。
  579. * Get story list by product ID list.
  580. *
  581. * @param int[] $productIdList
  582. * @param string $storyType
  583. * @access protected
  584. * @return array
  585. * @param bool $hasParent
  586. */
  587. protected function getStoriesByProductIdList($productIdList, $storyType = '', $hasParent = true)
  588. {
  589. return $this->dao->select('id, product, parent')
  590. ->from(TABLE_STORY)
  591. ->where('deleted')->eq('0')
  592. ->beginIF($storyType)->andWhere('type')->eq($storyType)->fi()
  593. ->beginIF(!$hasParent)->andWhere("isParent")->eq('0')->fi()
  594. ->andWhere('product')->in($productIdList)
  595. ->fetchAll();
  596. }
  597. /**
  598. * 将需求数据插入到需求表。
  599. * Do create story data.
  600. *
  601. * @param object $story
  602. * @access protected
  603. * @return int|false
  604. */
  605. protected function doCreateStory($story)
  606. {
  607. $this->dao->insert(TABLE_STORY)->data($story, 'spec,verify,reviewer,region,lane,branches,plans,modules,uploadImage')
  608. ->autoCheck()
  609. ->checkIF(!empty($story->notifyEmail), 'notifyEmail', 'email')
  610. ->batchCheck($this->config->{$story->type}->create->requiredFields, 'notempty')
  611. ->checkFlow()
  612. ->exec();
  613. if(dao::isError()) return false;
  614. return $this->dao->lastInsertID();
  615. }
  616. /**
  617. * 创建需求描述和验收标准。
  618. * Do create story spec.
  619. *
  620. * @param int $storyID
  621. * @param object $story must has title,spec,verify,version items.
  622. * @param array $files e.g. array(fileID => fileName)
  623. * @access protected
  624. * @return void
  625. */
  626. protected function doCreateSpec($storyID, $story, $files = array())
  627. {
  628. if(empty($storyID)) return;
  629. $spec = new stdclass();
  630. $spec->story = $storyID;
  631. $spec->version = zget($story, 'version', 1);
  632. $spec->title = $story->title;
  633. $spec->spec = $story->spec;
  634. $spec->verify = $story->verify;
  635. $spec->files = is_string($files) ? $files : implode(',', array_keys($files));
  636. if(isset($story->uploadImage)) $spec = $this->doSaveUploadImage($storyID, $story->uploadImage, $spec);
  637. $this->dao->insert(TABLE_STORYSPEC)->data($spec)->exec();
  638. }
  639. /**
  640. * 保存上传图片作为需求内容。
  641. * Do save upload image.
  642. *
  643. * @param int $storyID
  644. * @param string $fileName
  645. * @param object $spec
  646. * @access protected
  647. * @return object
  648. */
  649. protected function doSaveUploadImage($storyID, $fileName, $spec)
  650. {
  651. $storyImageFiles = $this->session->storyImagesFile;
  652. if(empty($storyImageFiles)) return $spec;
  653. if(empty($storyImageFiles[$fileName])) return $spec;
  654. $file = $storyImageFiles[$fileName];
  655. $realPath = $file['realpath'];
  656. unset($file['realpath']);
  657. if(!file_exists($realPath)) return $spec;
  658. $this->loadModel('file');
  659. if(!is_dir($this->file->savePath)) mkdir($this->file->savePath, 0777, true);
  660. if($realPath && rename($realPath, $this->file->savePath . $this->file->getSaveName($file['pathname'])))
  661. {
  662. $file['addedBy'] = $this->app->user->account;
  663. $file['addedDate'] = helper::now();
  664. $file['objectType'] = 'story';
  665. $file['objectID'] = $storyID;
  666. $isImage = in_array($file['extension'], $this->config->file->imageExtensions);
  667. if($isImage) $file['extra'] = 'editor';
  668. $this->dao->insert(TABLE_FILE)->data($file)->exec();
  669. $fileID = $this->dao->lastInsertID();
  670. if($isImage) $spec->spec .= '<img src="{' . $fileID . '.' . $file['extension'] . '}" alt="" />';
  671. if(!$isImage) $spec->files .= ',' . $fileID;
  672. }
  673. return $spec;
  674. }
  675. /**
  676. * 创建需求的时候,关联创建评审人列表。
  677. * Do create reviewer when create story.
  678. *
  679. * @param int $storyID
  680. * @param array $reviewers
  681. * @param int $storyVersion
  682. * @access protected
  683. * @return void
  684. */
  685. protected function doCreateReviewer($storyID, $reviewers, $storyVersion = 1)
  686. {
  687. if(empty($storyID) or empty($reviewers)) return;
  688. foreach($reviewers as $reviewer)
  689. {
  690. if(empty($reviewer)) continue;
  691. $reviewData = new stdclass();
  692. $reviewData->story = $storyID;
  693. $reviewData->version = $storyVersion;
  694. $reviewData->reviewer = $reviewer;
  695. $reviewData->result = '';
  696. $this->dao->insert(TABLE_STORYREVIEW)->data($reviewData)->exec();
  697. }
  698. }
  699. /**
  700. * 更新需求描述。
  701. * Do update story spec.
  702. *
  703. * @param int $storyID
  704. * @param object $story
  705. * @param object $oldStory
  706. * @access protected
  707. * @return void
  708. */
  709. protected function doUpdateSpec($storyID, $story, $oldStory)
  710. {
  711. if(empty($oldStory)) return;
  712. if($story->spec == $oldStory->spec and $story->verify == $oldStory->verify and $story->title == $oldStory->title and empty($story->deleteFiles) and empty($story->addedFiles)) return;
  713. $data = new stdclass();
  714. $data->title = $story->title;
  715. $data->spec = $story->spec;
  716. $data->verify = $story->verify;
  717. $data->files = $story->files;
  718. $this->dao->update(TABLE_STORYSPEC)->data($data)->where('story')->eq((int)$storyID)->andWhere('version')->eq($oldStory->version)->exec();
  719. /* Sync twins. */
  720. if(!empty($oldStory->twins))
  721. {
  722. foreach(explode(',', trim($oldStory->twins, ',')) as $twinID)
  723. {
  724. $this->dao->update(TABLE_STORYSPEC)->data($data)->where('story')->eq((int)$twinID)->andWhere('version')->eq($oldStory->version)->exec();
  725. }
  726. }
  727. }
  728. /**
  729. * Do string when change parent.
  730. *
  731. * @param int $storyID
  732. * @param object $story
  733. * @param object $oldStory
  734. * @access protected
  735. * @return void
  736. */
  737. protected function doChangeParent($storyID, $story, $oldStory)
  738. {
  739. $this->loadModel('action');
  740. if($oldStory->parent > 0)
  741. {
  742. $oldParentStory = $this->dao->select('*')->from(TABLE_STORY)->where('id')->eq($oldStory->parent)->fetch();
  743. $oldChildren = $this->dao->select('id')->from(TABLE_STORY)->where('parent')->eq($oldStory->parent)->andWhere('deleted')->eq(0)->fetchPairs('id', 'id');
  744. if(empty($oldChildren))
  745. {
  746. $this->dao->update(TABLE_STORY)
  747. ->set('isParent')->eq('0')
  748. ->beginIF($oldParentStory->type == 'story')->set('stage')->eq('wait')->fi()
  749. ->where('id')->eq($oldStory->parent)
  750. ->exec();
  751. }
  752. $newParentStory = $this->dao->select('*')->from(TABLE_STORY)->where('id')->eq($oldStory->parent)->fetch();
  753. $this->action->create('story', $storyID, 'unlinkParentStory', '', $oldStory->parent, '', false);
  754. $actionID = $this->action->create('story', $oldStory->parent, 'unLinkChildrenStory', '', $storyID, '', false);
  755. $changes = common::createChanges($oldParentStory, $newParentStory);
  756. if(!empty($changes)) $this->action->logHistory($actionID, $changes);
  757. $this->updateLane($oldParentStory->id, $oldParentStory->type);
  758. if($this->config->edition != 'open')
  759. {
  760. $this->dao->delete()->from(TABLE_RELATION)
  761. ->where('relation')->eq('subdivideinto')
  762. ->andWhere('AID')->eq($oldStory->parent)
  763. ->andWhere('AType')->eq($oldParentStory->type)
  764. ->andWhere('BID')->eq($oldStory->id)
  765. ->andWhere('BType')->eq($oldStory->type)
  766. ->exec();
  767. }
  768. }
  769. if($story->parent > 0)
  770. {
  771. $parentStory = $this->dao->select('*')->from(TABLE_STORY)->where('id')->eq($story->parent)->fetch();
  772. $newRoot = $parentStory->root;
  773. $story->path = rtrim($parentStory->path, ',') . ',' . $storyID . ',';
  774. $this->dao->update(TABLE_STORY)->set('parentVersion')->eq($parentStory->version)->where('id')->eq($storyID)->exec();
  775. $this->dao->update(TABLE_STORY)
  776. ->set('isParent')->eq('1')
  777. ->set('lastEditedBy')->eq($this->app->user->account)
  778. ->set('lastEditedDate')->eq(helper::now())
  779. ->where('id')->eq($story->parent)
  780. ->exec();
  781. $newParentStory = $this->dao->select('*')->from(TABLE_STORY)->where('id')->eq($story->parent)->fetch();
  782. $this->action->create('story', $storyID, 'linkParentStory', '', $story->parent, '', false);
  783. $actionID = $this->action->create('story', $story->parent, 'linkChildStory', '', $storyID, '', false);
  784. $changes = common::createChanges($parentStory, $newParentStory);
  785. if(!empty($changes)) $this->action->logHistory($actionID, $changes);
  786. $this->updateLane($parentStory->id, $parentStory->type);
  787. if($this->config->edition != 'open')
  788. {
  789. $relation = new stdClass();
  790. $relation->AID = $story->parent;
  791. $relation->AType = $parentStory->type;
  792. $relation->relation = 'subdivideinto';
  793. $relation->BID = $oldStory->id;
  794. $relation->BType = $oldStory->type;
  795. $relation->product = 0;
  796. $this->dao->replace(TABLE_RELATION)->data($relation)->exec();
  797. }
  798. }
  799. else
  800. {
  801. $newRoot = $storyID;
  802. $story->path = ",{$storyID},";
  803. }
  804. $childIdList = $this->getAllChildId($storyID, false);
  805. $children = $this->getByList($childIdList);
  806. foreach($children as $child)
  807. {
  808. $newChildPath = str_replace($oldStory->path, $story->path, $child->path);
  809. $this->dao->update(TABLE_STORY)->set('path')->eq($newChildPath)->where('id')->eq($child->id)->exec();
  810. }
  811. if($childIdList) $this->dao->update(TABLE_STORY)->set('root')->eq($newRoot)->where('id')->in($childIdList)->exec();
  812. $this->dao->update(TABLE_STORY)->set('root')->eq($newRoot)->set('path')->eq($story->path)->where('id')->eq($storyID)->exec();
  813. }
  814. /**
  815. * Do update link stories.
  816. *
  817. * @param int $storyID
  818. * @param object $story
  819. * @param object $oldStory
  820. * @access protected
  821. * @return void
  822. */
  823. protected function doUpdateLinkStories($storyID, $story, $oldStory)
  824. {
  825. if($oldStory->type == 'epic') return;
  826. $linkStoryField = $oldStory->type == 'story' ? 'linkStories' : 'linkRequirements';
  827. $linkStories = explode(',', $story->{$linkStoryField});
  828. $oldLinkStories = explode(',', $oldStory->{$linkStoryField});
  829. $addStories = array_diff($linkStories, $oldLinkStories);
  830. $removeStories = array_diff($oldLinkStories, $linkStories);
  831. $changeStories = array_merge($addStories, $removeStories);
  832. $changeStories = $this->dao->select("id,$linkStoryField")->from(TABLE_STORY)->where('id')->in(array_filter($changeStories))->fetchPairs();
  833. foreach($changeStories as $changeStoryID => $changeStory)
  834. {
  835. if(in_array($changeStoryID, $addStories))
  836. {
  837. $stories = empty($changeStory) ? $storyID : $changeStory . ',' . $storyID;
  838. $this->dao->update(TABLE_STORY)->set($linkStoryField)->eq((string)$stories)->where('id')->eq((int)$changeStoryID)->exec();
  839. }
  840. if(in_array($changeStoryID, $removeStories))
  841. {
  842. $linkedStories = str_replace(",$storyID,", ',', ",$changeStory,");
  843. $linkedStories = trim($linkedStories, ',');
  844. $this->dao->update(TABLE_STORY)->set($linkStoryField)->eq((string)$linkedStories)->where('id')->eq((int)$changeStoryID)->exec();
  845. }
  846. }
  847. }
  848. /**
  849. * 在创建需求的时候,将需求关联到项目或执行。
  850. * Link to execution for create story.
  851. *
  852. * @param int $executionID
  853. * @param int $storyID
  854. * @param object $story
  855. * @param string $extra
  856. * @access protected
  857. * @return void
  858. */
  859. protected function linkToExecutionForCreate($executionID, $storyID, $story, $extra = '')
  860. {
  861. if(empty($executionID) || empty($storyID)) return;
  862. $this->linkStory($executionID, $story->product, $storyID);
  863. if(in_array($this->config->systemMode, array('ALM', 'PLM')) && $this->session->project && $executionID != $this->session->project) $this->linkStory((int)$this->session->project, $story->product, $storyID);
  864. $this->loadModel('action');
  865. $extra = $this->parseExtra($extra);
  866. $object = $this->dao->findById($executionID)->from(TABLE_PROJECT)->fetch();
  867. if($object->type == 'project')
  868. {
  869. $this->action->create('story', $storyID, 'linked2project', '', $object->id);
  870. return;
  871. }
  872. if($object->type == 'kanban')
  873. {
  874. $laneID = zget($story, 'lane', 0);
  875. if(empty($laneID)) $laneID = zget($extra, 'laneID', 0);
  876. $columnID = $this->loadModel('kanban')->getColumnIDByLaneID((int)$laneID, 'backlog');
  877. if(empty($columnID)) $columnID = zget($extra, 'columnID', 0);
  878. if(!empty($laneID) && !empty($columnID)) $this->kanban->addKanbanCell($executionID, (int)$laneID, (int)$columnID, 'story', (string)$storyID);
  879. if(empty($laneID) || empty($columnID)) $this->kanban->updateLane($executionID, 'story');
  880. }
  881. $actionType = $object->type == 'kanban' ? 'linked2kanban' : 'linked2execution';
  882. $this->action->create('story', $storyID, 'linked2project', '', (string)$object->project);
  883. if($object->multiple) $this->action->create('story', $storyID, $actionType, '', (string)$executionID);
  884. }
  885. /**
  886. * 当Bug转需求后,关闭Bug。
  887. * Close bug when to story.
  888. *
  889. * @param int $bugID
  890. * @param int $storyID
  891. * @access protected
  892. * @return void
  893. */
  894. protected function closeBugWhenToStory($bugID, $storyID)
  895. {
  896. if(empty($bugID) or empty($storyID)) return;
  897. $oldBug = $this->dao->select('*')->from(TABLE_BUG)->where('id')->eq($bugID)->fetch();
  898. if($this->config->edition != 'open' && $oldBug->feedback) $this->loadModel('feedback')->updateStatus('bug', $oldBug->feedback, 'closed', $oldBug->status, $bugID);
  899. $now = helper::now();
  900. $bug = new stdclass();
  901. $bug->toStory = $storyID;
  902. $bug->status = 'closed';
  903. $bug->resolution = 'tostory';
  904. $bug->resolvedBy = $this->app->user->account;
  905. $bug->resolvedDate = $now;
  906. $bug->closedBy = $this->app->user->account;
  907. $bug->closedDate = $now;
  908. $bug->assignedTo = 'closed';
  909. $bug->assignedDate = $now;
  910. $bug->confirmed = 1;
  911. $this->dao->update(TABLE_BUG)->data($bug)->where('id')->eq($bugID)->exec();
  912. $this->loadModel('action')->create('bug', $bugID, 'ToStory', '', $storyID);
  913. $actionID = $this->action->create('bug', $bugID, 'Closed');
  914. $changes = common::createChanges($oldBug, $bug, 'bug');
  915. $this->action->logHistory($actionID, $changes);
  916. if($this->config->edition != 'open')
  917. {
  918. $relation = new stdClass();
  919. $relation->AID = $bugID;
  920. $relation->AType = 'bug';
  921. $relation->relation = 'transferredto';
  922. $relation->BID = $storyID;
  923. $relation->BType = 'story';
  924. $relation->product = 0;
  925. $this->dao->replace(TABLE_RELATION)->data($relation)->exec();
  926. }
  927. /* add files to story from bug. */
  928. $files = $this->dao->select('*')->from(TABLE_FILE)->where('objectType')->eq('bug')->andWhere('objectID')->eq($bugID)->fetchAll();
  929. if(empty($files)) return;
  930. foreach($files as $file)
  931. {
  932. $file->objectType = 'story';
  933. $file->objectID = $storyID;
  934. unset($file->id);
  935. $this->dao->insert(TABLE_FILE)->data($file)->exec();
  936. }
  937. }
  938. /**
  939. * 当待办转需求后,将待办改为完成。
  940. * Finish todo when to story.
  941. *
  942. * @param int $todoID
  943. * @param int $storyID
  944. * @access protected
  945. * @return void
  946. */
  947. protected function finishTodoWhenToStory($todoID, $storyID)
  948. {
  949. if(empty($todoID) or empty($storyID)) return;
  950. $this->dao->update(TABLE_TODO)->set('status')->eq('done')->where('id')->eq($todoID)->exec();
  951. $this->loadModel('action')->create('todo', $todoID, 'finished', '', "STORY:$storyID");
  952. if($this->config->edition == 'open')return;
  953. $todo = $this->dao->select('type, objectID')->from(TABLE_TODO)->where('id')->eq($todoID)->fetch();
  954. if($todo->type == 'feedback' && $todo->objectID) $this->loadModel('feedback')->updateStatus('todo', $todo->objectID, 'done', '', $todoID);
  955. }
  956. /**
  957. * 更新孪生需求字段。
  958. * Update twins.
  959. *
  960. * @param array $storyIdList
  961. * @param int $mainStoryID
  962. * @access protected
  963. * @return void
  964. */
  965. protected function updateTwins($storyIdList, $mainStoryID)
  966. {
  967. if(count($storyIdList) <= 1) return;
  968. foreach($storyIdList as $storyID)
  969. {
  970. $twinsIdList = $storyIdList;
  971. unset($twinsIdList[$storyID]);
  972. $this->dao->update(TABLE_STORY)->set('twins')->eq(',' . implode(',', $twinsIdList) . ',')->where('id')->eq($storyID)->exec();
  973. }
  974. $storyFiles = $this->dao->select('files')->from(TABLE_STORYSPEC)->where('story')->eq($mainStoryID)->fetch('files');
  975. $this->dao->update(TABLE_STORYSPEC)->set('files')->eq($storyFiles)->where('story')->in($storyIdList)->exec();
  976. }
  977. /**
  978. * 解析extra参数。
  979. * Parse extra param.
  980. *
  981. * @param string $extra
  982. * @access protected
  983. * @return array
  984. */
  985. protected function parseExtra($extra)
  986. {
  987. if(empty($extra)) return array();
  988. /* Whether there is a object to transfer story, for example feedback. */
  989. $extra = str_replace(array(',', ' '), array('&', ''), $extra);
  990. parse_str($extra, $output);
  991. return $output;
  992. }
  993. /**
  994. * Check whether a story can be subdivided.
  995. *
  996. * @param object $story
  997. * @param bool $isShadowProduct
  998. * @access protected
  999. * @return bool
  1000. */
  1001. protected function checkCanSubdivide($story, $isShadowProduct)
  1002. {
  1003. if($this->config->vision == 'lite') return true;
  1004. if($story->type != 'story') return true;
  1005. if(in_array($story->status, array('reviewing', 'closed'))) return false;
  1006. if($story->isParent == '1') return true;
  1007. if(!$isShadowProduct && !in_array($story->stage, array('wait', 'planned', 'projected'))) return false;
  1008. if($isShadowProduct && $story->stage != 'projected') return false;
  1009. return true;
  1010. }
  1011. /**
  1012. * Check whether a story can be split.
  1013. *
  1014. * @param object $story
  1015. * @access protected
  1016. * @return bool
  1017. */
  1018. protected function checkCanSplit($story)
  1019. {
  1020. $sameTypeChild = $this->dao->select('id')->from(TABLE_STORY)
  1021. ->where('parent')->eq($story->id)
  1022. ->andWhere('type')->eq($story->type)
  1023. ->andWhere('deleted')->eq(0)
  1024. ->fetch('id');
  1025. if($sameTypeChild) return false;
  1026. return true;
  1027. }
  1028. /**
  1029. * 获取需求关联的分支和项目。
  1030. * Get linked branches and projects.
  1031. *
  1032. * @param int $storyID
  1033. * @access protected
  1034. * @return array
  1035. */
  1036. protected function getLinkedBranchesAndProjects($storyID)
  1037. {
  1038. $projects = $this->dao->select('t2.id,t2.model,t2.type,t3.branch')->from(TABLE_PROJECTSTORY)->alias('t1')
  1039. ->leftJoin(TABLE_PROJECT)->alias('t2')->on('t1.project = t2.id')
  1040. ->leftJoin(TABLE_PROJECTPRODUCT)->alias('t3')->on('t1.project = t3.project AND t1.product=t3.product')
  1041. ->where('t1.story')->eq($storyID)
  1042. ->andWhere('t2.deleted')->eq(0)
  1043. ->fetchAll();
  1044. $linkedBranches = array();
  1045. $linkedProjects = array();
  1046. foreach($projects as $project)
  1047. {
  1048. $linkedBranches[$project->branch] = $project->branch;
  1049. if(!isset($linkedProjects[$project->id]))
  1050. {
  1051. $project->kanban = ($project->model == 'kanban' || $project->type == 'kanban');
  1052. $project->branches[$project->branch] = $project->branch;
  1053. $linkedProjects[$project->id] = $project;
  1054. }
  1055. else
  1056. {
  1057. $linkedProjects[$project->id]->branches[$project->branch] = $project->branch;
  1058. }
  1059. }
  1060. return array($linkedBranches, $linkedProjects);
  1061. }
  1062. /**
  1063. * 将阶段设置为 planned。
  1064. * Set stage to planned.
  1065. *
  1066. * @param int $storyID
  1067. * @param array $stages
  1068. * @param array $oldStages
  1069. * @access protected
  1070. * @return bool
  1071. */
  1072. protected function setStageToPlanned($storyID, $stages = array(), $oldStages = array())
  1073. {
  1074. $story = $this->dao->findById($storyID)->from(TABLE_STORY)->fetch();
  1075. if(empty($story)) return false;
  1076. /* 当需求有子需求时,需要通过子需求推算当前需求的阶段。*/
  1077. if(!empty($story->isParent))
  1078. {
  1079. $children = $this->dao->select('*')->from(TABLE_STORY)
  1080. ->where('parent')->eq($storyID)
  1081. ->fetch();
  1082. $this->computeParentStage($children);
  1083. return true;
  1084. }
  1085. if(empty($story->plan))
  1086. {
  1087. $this->dao->update(TABLE_STORY)->set('stage')->eq('wait')->where('id')->eq($storyID)->exec();
  1088. return true;
  1089. }
  1090. $this->dao->update(TABLE_STORY)->set('stage')->eq('planned')->where('id')->eq($storyID)->exec();
  1091. foreach($stages as $branchID => $stage)
  1092. {
  1093. $branchID = (int)$branchID;
  1094. $this->dao->replace(TABLE_STORYSTAGE)->set('story')->eq($storyID)->set('branch')->eq($branchID)->set('stage')->eq('planned')->exec();
  1095. if(isset($oldStages[$branchID]) && !empty($oldStages[$branchID]->stagedBy)) $this->dao->replace(TABLE_STORYSTAGE)->data($oldStages[$branchID], 'id')->exec();
  1096. }
  1097. return true;
  1098. }
  1099. /**
  1100. * 将阶段设置为 closed。
  1101. * Set stage to closed.
  1102. *
  1103. * @param int $storyID
  1104. * @param array $linkedBranches
  1105. * @param array $linkedProjects
  1106. * @access protected
  1107. * @return bool
  1108. */
  1109. protected function setStageToClosed($storyID, $linkedBranches = array(), $linkedProjects = array())
  1110. {
  1111. $story = $this->dao->findById($storyID)->from(TABLE_STORY)->fetch();
  1112. if(empty($story)) return false;
  1113. $this->dao->update(TABLE_STORY)->set('stage')->eq('closed')->where('id')->eq($storyID)->exec();
  1114. foreach($linkedBranches as $branchID)
  1115. {
  1116. if(!empty($branchID)) $this->dao->replace(TABLE_STORYSTAGE)->set('story')->eq($storyID)->set('branch')->eq((int)$branchID)->set('stage')->eq('closed')->exec();
  1117. }
  1118. if($story->stage != 'closed') $this->updateLinkedLane($storyID, $linkedProjects);
  1119. $this->computeParentStage($story);
  1120. return true;
  1121. }
  1122. /**
  1123. * 更新需求阶段。
  1124. * Update stage.
  1125. *
  1126. * @param int $storyID
  1127. * @param array $stages
  1128. * @param array $oldStages
  1129. * @param array $linkedProjects
  1130. * @access protected
  1131. * @return bool
  1132. */
  1133. protected function updateStage($storyID, $stages, $oldStages = array(), $linkedProjects = array())
  1134. {
  1135. $story = $this->dao->findById($storyID)->from(TABLE_STORY)->fetch();
  1136. if(empty($stages) && $oldStages) $stages = array_column($oldStages, 'stage', 'branch');
  1137. if(empty($story)) return false;
  1138. $stage = empty($stages) ? $story->stage : current($stages);
  1139. $product = $this->dao->findById($story->product)->from(TABLE_PRODUCT)->fetch();
  1140. if($product and $product->type != 'normal' and empty($story->branch))
  1141. {
  1142. $stageList = implode(',', array_keys($this->lang->story->stageList));
  1143. $minStagePos = strlen($stageList);
  1144. $minStage = '';
  1145. foreach($stages as $branchID => $stage)
  1146. {
  1147. $this->dao->replace(TABLE_STORYSTAGE)->set('story')->eq($storyID)->set('branch')->eq((int)$branchID)->set('stage')->eq($stage)->exec();
  1148. if(isset($oldStages[$branchID]) && !empty($oldStages[$branchID]->stagedBy))
  1149. {
  1150. $this->dao->replace(TABLE_STORYSTAGE)->data($oldStages[$branchID], 'id')->exec();
  1151. $stage = $oldStages[$branchID]->stage;
  1152. }
  1153. $position = strpos($stageList, $stage);
  1154. if($position !== false && $position < $minStagePos)
  1155. {
  1156. $minStage = $stage;
  1157. $minStagePos = $position;
  1158. }
  1159. }
  1160. if($minStage) $stage = $minStage;
  1161. }
  1162. /* 如果是IPD项目,则stage有可能是已设路标或Charter立项。 */
  1163. if($this->config->edition == 'ipd' && $story->type != 'story' && $story->roadmap && $stage == 'wait')
  1164. {
  1165. $stage = 'inroadmap';
  1166. $roadmap = $this->loadModel('roadmap')->getByID($story->roadmap);
  1167. if($roadmap->status == 'launched') $stage = 'incharter';
  1168. }
  1169. $this->dao->update(TABLE_STORY)->set('stage')->eq($stage)->where('id')->eq($storyID)->exec();
  1170. if($story->stage != $stage) $this->updateLinkedLane($storyID, $linkedProjects);
  1171. $this->computeParentStage($story);
  1172. return true;
  1173. }
  1174. /**
  1175. * 通过子需求更新父需求的阶段。
  1176. * Update parent stage by children.
  1177. *
  1178. * @param object $story
  1179. * @access public
  1180. * @return void
  1181. */
  1182. public function computeParentStage($story)
  1183. {
  1184. $demandList = array();
  1185. if(empty($story->parent))
  1186. {
  1187. if($this->config->edition == 'ipd' && !empty($story->demand))
  1188. {
  1189. $demandList[$story->demand] = $story->demand;
  1190. $this->loadModel('demand')->updateDemandStage($demandList);
  1191. }
  1192. return;
  1193. }
  1194. $parent = $this->dao->findById($story->parent)->from(TABLE_STORY)->fetch();
  1195. $children = $this->dao->select('id, stage, closedReason')->from(TABLE_STORY)
  1196. ->where('parent')->eq($story->parent)
  1197. ->andWhere('deleted')->eq(0)
  1198. ->fetchAll('id');
  1199. $computedStage = $this->computeStage($children);
  1200. $parentStage = empty($computedStage) ? $parent->stage : $computedStage;
  1201. if($parentStage == 'wait' && !empty($parent->roadmap))
  1202. {
  1203. $roadmapStatus = $this->dao->select('status')->from(TABLE_ROADMAP)->where('id')->eq($parent->roadmap)->fetch('status');
  1204. $parentStage = $roadmapStatus == 'launched' ? 'incharter' : 'inroadmap';
  1205. }
  1206. if($parentStage != $parent->stage && $parent->status != 'closed')
  1207. {
  1208. $this->dao->update(TABLE_STORY)->set('stage')->eq($parentStage)->where('id')->eq($parent->id)->exec();
  1209. if($this->config->edition == 'ipd')
  1210. {
  1211. if(!empty($parent->demand)) $demandList[$parent->demand] = $parent->demand;
  1212. if(!empty($story->demand)) $demandList[$story->demand] = $story->demand;
  1213. if(!empty($demandList)) $this->loadModel('demand')->updateDemandStage($demandList);
  1214. }
  1215. if($parent->parent > 0) $this->computeParentStage($parent);
  1216. }
  1217. }
  1218. /**
  1219. * 通过子需求推算父需求的阶段。
  1220. * 子需求阶段范围:未开始、已计划、研发立项、设计中、设计完毕、研发中、研发完毕、测试中、测试完毕、已验收、验收失败、已发布、已关闭。
  1221. * 父需求阶段范围:未开始、已计划、研发立项、研发中、交付中、已交付、已关闭。
  1222. *
  1223. * 父需求阶段由子需求阶段决定,规则如下:
  1224. * 1.未开始:
  1225. * 所有子需求都未开始。
  1226. * 2.已计划:
  1227. * 父需求主动关联计划,或有子需求是已计划、其余子需求未开始。
  1228. * 3.研发立项:
  1229. * 父需求主动关联项目,或有子需求是研发立项、其余子需求阶段小于研发立项。
  1230. * 4. 研发中:
  1231. * 排除已关闭(关闭原因不是已完成)后,至少有一个子级需求为设计中、设计完毕、研发中、研发完毕、测试中、测试完毕、已验收、验收失败, 其他子需求阶段仅为未开始、规划中、已立项、已计划。
  1232. * 5. 交付中:
  1233. * 部分子级需求为已发布; 其余子级需求在已发布之前的阶段。
  1234. * 6. 已交付:
  1235. * 所有子需求都已发布; 或部分已发布,其余已关闭(关闭原因是已完成)。
  1236. * 7. 已关闭:
  1237. * 所有子需求都已关闭。
  1238. *
  1239. * Update parent stage by children.
  1240. * Parent stage is decided by children stage, the rules are as follows:
  1241. * 1. Wait:
  1242. * All child stories are in wait.
  1243. * 2. Planned:
  1244. * Parent story is linked to plan, or at least one child story is in planned, and the others are in wait.
  1245. * 3. Projected:
  1246. * Parent story is linked to project, or at least one child story is in projected, and the others are in wait or planned.
  1247. * 4. Developing:
  1248. * At least one child story is in designing, designed, developing, developed, testing, tested, verified, rejected, and the others are in wait, defining, planning, planned.
  1249. * 5. Delivering:
  1250. * Some child stories are released, and the others are in the stage before released.
  1251. * 6. Delivered:
  1252. * All child stories are released, or some are released and the others are closed with reason done.
  1253. * 7. Closed:
  1254. * All child stories are closed.
  1255. *
  1256. * @param array $children
  1257. * @access public
  1258. * @return string
  1259. */
  1260. public function computeStage($children)
  1261. {
  1262. $allWait = true;
  1263. $allClosed = true;
  1264. $hasInRoadmap = false;
  1265. $hasInCharter = false;
  1266. $allIpdStage = true;
  1267. foreach($children as $child)
  1268. {
  1269. if($child->stage != 'closed') $allClosed = false;
  1270. if($child->stage == 'closed' && $child->closedReason != 'done') continue;
  1271. if($child->stage != 'wait') $allWait = false;
  1272. if($child->stage == 'inroadmap') $hasInRoadmap = true;
  1273. if($child->stage == 'incharter') $hasInCharter = true;
  1274. if(strpos(',wait,inroadmap,incharter,', ",{$child->stage},") === false) $allIpdStage = false;
  1275. }
  1276. $parentStage = '';
  1277. if($allClosed)
  1278. {
  1279. $parentStage = 'closed';
  1280. }
  1281. elseif($allWait)
  1282. {
  1283. $parentStage = 'wait';
  1284. }
  1285. elseif(!$allWait && $allIpdStage)
  1286. {
  1287. if($hasInRoadmap) $parentStage = 'inroadmap';
  1288. if($hasInCharter) $parentStage = 'incharter';
  1289. }
  1290. else
  1291. {
  1292. $hasPlanned = false;
  1293. $allBeforePlanned = true;
  1294. foreach($children as $child)
  1295. {
  1296. /* Planned. */
  1297. if($child->stage == 'closed' && $child->closedReason != 'done') continue;
  1298. if($child->stage == 'planned')
  1299. {
  1300. $hasPlanned = true;
  1301. }
  1302. elseif(!in_array($child->stage, array('wait', 'planned')))
  1303. {
  1304. $allBeforePlanned = false;
  1305. }
  1306. }
  1307. if($hasPlanned && $allBeforePlanned)
  1308. {
  1309. $parentStage = 'planned';
  1310. }
  1311. else
  1312. {
  1313. /* Projected. */
  1314. $hasProjected = false;
  1315. $allBeforeProjected = true;
  1316. foreach($children as $child)
  1317. {
  1318. if($child->stage == 'closed' && $child->closedReason != 'done') continue;
  1319. if($child->stage == 'projected') $hasProjected = true;
  1320. if(!in_array($child->stage, array('wait', 'planned', 'projected'))) $allBeforeProjected = false;
  1321. }
  1322. if($hasProjected && $allBeforeProjected)
  1323. {
  1324. $parentStage = 'projected';
  1325. }
  1326. else
  1327. {
  1328. /* Developing. */
  1329. $hasDeveloping = false;
  1330. $allBeforeDeveloping = true;
  1331. foreach($children as $child)
  1332. {
  1333. if($child->stage == 'closed' && $child->closedReason != 'done') continue;
  1334. if(in_array($child->stage, array('designing', 'designed', 'developing', 'developed', 'testing', 'tested', 'verified', 'rejected')))
  1335. {
  1336. $hasDeveloping = true;
  1337. }
  1338. if(in_array($child->stage, array('released', 'delivering', 'delivered', 'closed')))
  1339. {
  1340. $allBeforeDeveloping = false;
  1341. }
  1342. }
  1343. if($hasDeveloping && $allBeforeDeveloping)
  1344. {
  1345. $parentStage = 'developing';
  1346. }
  1347. else
  1348. {
  1349. /* Delivering. */
  1350. $hasDelivering = false;
  1351. $hasDone = false;
  1352. $allDelivered = true;
  1353. foreach($children as $child)
  1354. {
  1355. if($child->stage == 'closed')
  1356. {
  1357. if($child->closedReason != 'done')
  1358. {
  1359. continue;
  1360. }
  1361. else
  1362. {
  1363. $hasDone = true;
  1364. }
  1365. }
  1366. if(in_array($child->stage, array('released', 'delivering', 'delivered'))) $hasDelivering = true;
  1367. if(!in_array($child->stage, array('released', 'closed', 'delivered'))) $allDelivered = false;
  1368. }
  1369. if($allDelivered)
  1370. {
  1371. $parentStage = 'delivered';
  1372. }
  1373. elseif($hasDelivering || $hasDone)
  1374. {
  1375. $parentStage = 'delivering';
  1376. }
  1377. }
  1378. }
  1379. }
  1380. }
  1381. return $parentStage;
  1382. }
  1383. /**
  1384. * 根据计划和关联的项目的分支,获取默认的阶段值。
  1385. * Get default stages by plans and linked branches.
  1386. *
  1387. * @param string $planIdList e.g. 1,2
  1388. * @param array $linkedBranches e.g. array(0, 1, branchID)
  1389. * @access protected
  1390. * @return array
  1391. */
  1392. protected function getDefaultStages($planIdList, $linkedBranches)
  1393. {
  1394. if(empty($planIdList) && empty($linkedBranches)) return array();
  1395. $stages = array();
  1396. if($planIdList)
  1397. {
  1398. $plans = $this->dao->select('*')->from(TABLE_PRODUCTPLAN)->where('id')->in($planIdList)->fetchPairs('branch', 'branch');
  1399. foreach($plans as $branchID) $stages[(int)$branchID] = 'planned';
  1400. }
  1401. if(empty($linkedBranches)) return $stages;
  1402. foreach($linkedBranches as $branchID) $stages[(int)$branchID] = 'projected';
  1403. return $stages;
  1404. }
  1405. /**
  1406. * 根据关联执行,获取关联该需求的任务状态统计数。
  1407. * Get linked task status statistics for this story by linked projects.
  1408. *
  1409. * @param int $storyID
  1410. * @param array $linkedProjects e.g. linkedProjects = array(projectID => stdclass('branch' => array(branchID)))
  1411. * @access protected
  1412. * @return array
  1413. */
  1414. protected function getLinkedTaskStat($storyID, $linkedProjects)
  1415. {
  1416. $tasks = $this->dao->select('type,execution,status')->from(TABLE_TASK)->where('execution')->in(array_keys($linkedProjects))
  1417. ->andWhere('type')->in('devel,test,design')
  1418. ->andWhere('story')->eq($storyID)
  1419. ->andWhere('deleted')->eq(0)
  1420. ->andWhere('status')->ne('cancel')
  1421. ->andWhere('closedReason')->ne('cancel')
  1422. ->fetchGroup('type');
  1423. if(empty($tasks)) return array();
  1424. /* Cycle all tasks, get counts of every type and every status. */
  1425. $branchStatusList = $branchDevelCount = $branchTestCount = $branchDesignCount = array();
  1426. $statusList['design'] = array('wait' => 0, 'doing' => 0, 'done' => 0, 'pause' => 0);
  1427. $statusList['devel'] = array('wait' => 0, 'doing' => 0, 'done' => 0, 'pause' => 0);
  1428. $statusList['test'] = array('wait' => 0, 'doing' => 0, 'done' => 0, 'pause' => 0);
  1429. foreach($tasks as $type => $typeTasks)
  1430. {
  1431. if(!isset($statusList[$type])) continue;
  1432. foreach($typeTasks as $task)
  1433. {
  1434. $status = $task->status ? $task->status : 'wait';
  1435. if(!isset($statusList[$type][$status])) $status = 'done'; //如果任务状态不在默认统计状态列表中,则按照完成状态处理。
  1436. $branches = $linkedProjects[$task->execution]->branches;
  1437. foreach($branches as $branch)
  1438. {
  1439. $branch = (int)$branch;
  1440. if(!isset($branchStatusList[$branch])) $branchStatusList[$branch] = $statusList;
  1441. $branchStatusList[$branch][$type][$status] ++;
  1442. if($type == 'devel') $branchDevelCount[$branch] = !isset($branchDevelCount[$branch]) ? 1 : ($branchDevelCount[$branch] + 1);
  1443. if($type == 'test') $branchTestCount[$branch] = !isset($branchTestCount[$branch]) ? 1 : ($branchTestCount[$branch] + 1);
  1444. if($type == 'design') $branchDesignCount[$branch] = !isset($branchDesignCount[$branch]) ? 1 : ($branchDesignCount[$branch] + 1);
  1445. }
  1446. }
  1447. }
  1448. return array($branchStatusList, $branchDevelCount, $branchTestCount, $branchDesignCount);
  1449. }
  1450. /**
  1451. * 更新关联的看板泳道卡片。
  1452. * Update linked lane.
  1453. *
  1454. * @param int $storyID
  1455. * @param string $storyType
  1456. * @access protected
  1457. * @return int
  1458. */
  1459. protected function updateLane($storyID, $storyType)
  1460. {
  1461. $executionIdList = $this->dao->select('t1.project')->from(TABLE_PROJECTSTORY)->alias('t1')
  1462. ->leftJoin(TABLE_PROJECT)->alias('t2')->on('t1.project = t2.id')
  1463. ->where('t1.story')->eq($storyID)
  1464. ->andWhere('t2.deleted')->eq(0)
  1465. ->andWhere('t2.type')->in('sprint,stage,kanban')
  1466. ->fetchPairs('project', 'project');
  1467. $this->loadModel('kanban');
  1468. foreach($executionIdList as $executionID) $this->kanban->updateLane($executionID, $storyType, $storyID);
  1469. }
  1470. /**
  1471. * 更新关联的看板泳道。
  1472. * Update linked lane.
  1473. *
  1474. * @param int $storyID
  1475. * @param array $linkedProjects
  1476. * @access protected
  1477. * @return int
  1478. */
  1479. protected function updateLinkedLane($storyID, $linkedProjects = array())
  1480. {
  1481. if(empty($storyID) || empty($linkedProjects)) return 0;
  1482. $this->loadModel('kanban');
  1483. $linkedKanbans = array_keys(array_filter(array_map(function($project){return $project->kanban;}, $linkedProjects)));
  1484. foreach($linkedKanbans as $projectID) $this->kanban->updateLane($projectID, 'story', $storyID);
  1485. return count($linkedKanbans);
  1486. }
  1487. /**
  1488. * 根据任务状态统计,计算需求阶段。
  1489. * Compute stages by tasks status statistics.
  1490. *
  1491. * @param int $storyID
  1492. * @param array $taskStat
  1493. * @param array $stages
  1494. * @param array $linkedProjects
  1495. * @access protected
  1496. * @return array
  1497. */
  1498. protected function computeStagesByTasks($storyID, $taskStat = array(), $stages = array(), $linkedProjects = array())
  1499. {
  1500. /* 设置关联的项目的分支阶段为已立项。 */
  1501. if(empty($taskStat))
  1502. {
  1503. foreach($linkedProjects as $linkedProject)
  1504. {
  1505. foreach($linkedProject->branches as $branchID) $stages[(int)$branchID] = 'projected';
  1506. }
  1507. return $stages;
  1508. }
  1509. /* 根据任务状态统计信息,计算该需求所处的阶段。 */
  1510. list($branchStatusList, $branchDevelCount, $branchTestCount, $branchDesignCount) = $taskStat;
  1511. foreach($branchStatusList as $branch => $statusList)
  1512. {
  1513. $branch = (int)$branch;
  1514. $stage = 'projected';
  1515. $desginCount = isset($branchDesignCount[$branch]) ? $branchDesignCount[$branch] : 0;
  1516. $testCount = isset($branchTestCount[$branch]) ? $branchTestCount[$branch] : 0;
  1517. $develCount = isset($branchDevelCount[$branch]) ? $branchDevelCount[$branch] : 0;
  1518. $doingDesignTask = $statusList['design']['wait'] < $desginCount && $statusList['design']['done'] < $desginCount && $desginCount > 0;
  1519. $doneDesignTask = $statusList['design']['done'] == $desginCount && $desginCount > 0;
  1520. $notStartDevTask = $statusList['devel']['wait'] == $develCount;
  1521. $doingDevelTask = $statusList['devel']['wait'] < $develCount && $statusList['devel']['done'] < $develCount && $develCount > 0;
  1522. $doneDevelTask = $statusList['devel']['done'] == $develCount && $develCount > 0;
  1523. $notStartTestTask = $statusList['test']['wait'] == $testCount;
  1524. $doingTestTask = $statusList['test']['wait'] < $testCount && $statusList['test']['done'] < $testCount && $testCount > 0;
  1525. $doneTestTask = $statusList['test']['done'] == $testCount && $testCount > 0;
  1526. $hasDoingTestTask = $statusList['test']['doing'] > 0 || $statusList['test']['pause'] > 0;
  1527. $notDoingTestTask = $statusList['test']['doing'] == 0;
  1528. $hasDevelTask = $develCount > 0;
  1529. if($doingDesignTask && $notStartDevTask) $stage = 'designing'; //设计任务没有全部完成,开发任务还没有开始,阶段为设计中。
  1530. if($doneDesignTask && $notStartDevTask) $stage = 'designed'; //设计任务全部完成,开发任务还没有开始,阶段为设计完成。
  1531. if($doingDevelTask && $notStartTestTask) $stage = 'developing'; //开发任务没有全部完成,测试任务没有开始,阶段为开发中。
  1532. if($doingDevelTask && $notDoingTestTask) $stage = 'developing'; //开发任务没有全部完成,没有测试中的测试任务,阶段为开发中。
  1533. if(($notStartDevTask || $doingDevelTask) && $doneTestTask) $stage = 'testing'; //开发任务没有全部完成,测试任务已经完成,阶段为测试中。
  1534. if($doneDevelTask && $notStartTestTask) $stage = 'developed'; //开发任务已经完成,测试任务还没有开始,阶段为开发完成。
  1535. if($doneDevelTask && $doingTestTask) $stage = 'testing'; //开发任务已经完成,测试任务已经开始,阶段为测试中。
  1536. if($hasDoingTestTask) $stage = 'testing'; //有测试任务正在测试,阶段为测试中。
  1537. if(($doneDevelTask || !$hasDevelTask) && $doneTestTask) $stage = 'tested'; //开发任务已经完成或者没有开发任务,测试任务已经完成,阶段为测试完成。
  1538. $stages[(int)$branch] = $stage;
  1539. }
  1540. return $stages;
  1541. }
  1542. /**
  1543. * 根据需求是否已经发布,计算需求阶段。
  1544. * Compute stages by release.
  1545. *
  1546. * @param int $storyID
  1547. * @param array $stages
  1548. * @access protected
  1549. * @return array
  1550. */
  1551. protected function computeStagesByRelease($storyID, $stages)
  1552. {
  1553. /* 检查该需求是否已经发布,如果已经发布,阶段则为已发布。 */
  1554. $releases = $this->dao->select('branch')->from(TABLE_RELEASE)
  1555. ->where("CONCAT(',', stories, ',')")->like("%,$storyID,%")
  1556. ->andWhere('deleted')->eq(0)
  1557. ->andWhere('status')->eq('normal')
  1558. ->fetchAll();
  1559. foreach($releases as $release)
  1560. {
  1561. $branches = array_unique(explode(',', trim($release->branch, ',')));
  1562. foreach($branches as $branch) $stages[(int)$branch] = 'released';
  1563. }
  1564. return $stages;
  1565. }
  1566. /**
  1567. * 获取该需求影响的项目和任务。
  1568. * Get affected projects and tasks for this story.
  1569. *
  1570. * @param object $story
  1571. * @param array $users
  1572. * @access protected
  1573. * @return object
  1574. */
  1575. protected function getAffectedProjects($story, $users)
  1576. {
  1577. $this->app->loadLang('task');
  1578. $this->config->story->affect = new stdclass();
  1579. $this->config->story->affect->projects = new stdclass();
  1580. $this->config->story->affect->projects->fields['id'] = array('name' => 'id', 'title' => $this->lang->task->id);
  1581. $this->config->story->affect->projects->fields['name'] = array('name' => 'name', 'title' => $this->lang->task->name, 'link' => $this->config->vision != 'or' ? helper::createLink('task', 'view', 'id={id}') : '', 'type' => 'title', 'fixed' => false, 'sortType' => false, 'flex' => false);
  1582. $this->config->story->affect->projects->fields['assignedTo'] = array('name' => 'assignedTo', 'title' => $this->lang->task->assignedTo);
  1583. $this->config->story->affect->projects->fields['consumed'] = array('name' => 'consumed', 'title' => $this->lang->task->consumed);
  1584. $this->config->story->affect->projects->fields['left'] = array('name' => 'left', 'title' => $this->lang->task->left);
  1585. if(empty($story->executions)) return $story;
  1586. $storyExecutions = $story->executions;
  1587. foreach($storyExecutions as $executionID => $execution) if($execution->status == 'done') unset($story->executions[$executionID]);
  1588. $story->teams = $this->dao->select('account, root')->from(TABLE_TEAM)->where('root')->in(array_keys($story->executions))->andWhere('type')->eq('execution')->fetchGroup('root');
  1589. foreach($story->tasks as $executionTasks)
  1590. {
  1591. foreach($executionTasks as $task)
  1592. {
  1593. $task->status = $this->processStatus('task', $task);
  1594. $task->assignedTo = zget($users, $task->assignedTo);
  1595. if(isset($storyExecutions[$task->execution]))
  1596. {
  1597. $taskExecution = $storyExecutions[$task->execution];
  1598. if(!$taskExecution->multiple) $this->config->story->affect->projects->fields['name']['link'] .= '#app=project';
  1599. }
  1600. }
  1601. }
  1602. return $story;
  1603. }
  1604. /**
  1605. * 获取该需求影响的Bug。
  1606. * Get affected bugs for this story.
  1607. *
  1608. * @param object $story
  1609. * @param array $users
  1610. * @access protected
  1611. * @return object
  1612. */
  1613. protected function getAffectedBugs($story, $users)
  1614. {
  1615. $this->app->loadLang('bug');
  1616. $this->app->loadLang('execution');
  1617. if(!isset($this->config->story->affect)) $this->config->story->affect = new stdclass();
  1618. $this->config->story->affect->bugs = new stdclass();
  1619. $this->config->story->affect->bugs->fields[] = array('name' => 'id', 'title' => $this->lang->idAB);
  1620. $this->config->story->affect->bugs->fields[] = array('name' => 'title', 'title' => $this->lang->bug->title, 'link' => $this->config->vision != 'or' ? helper::createLink('bug', 'view', 'id={id}') : '', 'data-toggle' => 'modal', 'data-size' => 'lg', 'fixed' => false, 'sortType' => false, 'flex' => false);
  1621. $this->config->story->affect->bugs->fields[] = array('name' => 'status', 'title' => $this->lang->statusAB);
  1622. $this->config->story->affect->bugs->fields[] = array('name' => 'openedBy', 'title' => $this->lang->bug->openedBy);
  1623. $this->config->story->affect->bugs->fields[] = array('name' => 'resolvedBy', 'title' => $this->lang->bug->resolvedBy);
  1624. $this->config->story->affect->bugs->fields[] = array('name' => 'resolution', 'title' => $this->lang->bug->resolution);
  1625. $this->config->story->affect->bugs->fields[] = array('name' => 'lastEditedBy', 'title' => $this->lang->bug->lastEditedBy);
  1626. /* Get affected bugs. */
  1627. $storyIdList = $story->id . ($story->twins ? ',' . trim($story->twins, ',') : '');
  1628. $story->bugs = $this->dao->select('*')->from(TABLE_BUG)->where('status')->ne('closed')
  1629. ->andWhere('story')->in($storyIdList)
  1630. ->andWhere('status')->ne('closed')
  1631. ->andWhere('deleted')->eq(0)
  1632. ->orderBy('id desc')->fetchAll();
  1633. foreach($story->bugs as $bug)
  1634. {
  1635. $bug->status = $this->processStatus('bug', $bug);
  1636. $bug->openedBy = zget($users, $bug->openedBy);
  1637. $bug->resolvedBy = zget($users, $bug->resolvedBy);
  1638. $bug->lastEditedBy = zget($users, $bug->lastEditedBy);
  1639. $bug->resolution = zget($this->lang->bug->resolutionList, $bug->resolution);
  1640. }
  1641. return $story;
  1642. }
  1643. /**
  1644. * 获取该需求影响的用例。
  1645. * Get affected cases for this story.
  1646. *
  1647. * @param object $story
  1648. * @param array $users
  1649. * @access protected
  1650. * @return object
  1651. */
  1652. protected function getAffectedCases($story, $users)
  1653. {
  1654. $this->app->loadLang('testcase');
  1655. if(!isset($this->config->story->affect)) $this->config->story->affect = new stdclass();
  1656. $this->config->story->affect->cases = new stdclass();
  1657. $this->config->story->affect->cases->fields[] = array('name' => 'id', 'title' => $this->lang->idAB);
  1658. $this->config->story->affect->cases->fields[] = array('name' => 'title', 'title' => $this->lang->testcase->title, 'link' => $this->config->vision != 'or' ? helper::createLink('testcase', 'view', 'id={id}') : '', 'data-toggle' => 'modal', 'data-size' => 'lg', 'fixed' => false, 'sortType' => false, 'flex' => false);
  1659. $this->config->story->affect->cases->fields[] = array('name' => 'status', 'title' => $this->lang->statusAB);
  1660. $this->config->story->affect->cases->fields[] = array('name' => 'openedBy', 'title' => $this->lang->testcase->openedBy);
  1661. $this->config->story->affect->cases->fields[] = array('name' => 'lastEditedBy', 'title' => $this->lang->testcase->lastEditedBy);
  1662. /* Get affected cases. */
  1663. $storyIdList = $story->id . ($story->twins ? ',' . trim($story->twins, ',') : '');
  1664. $story->cases = $this->dao->select('*')->from(TABLE_CASE)->where('deleted')->eq(0)
  1665. ->andWhere('story')->in($storyIdList)
  1666. ->fetchAll();
  1667. foreach($story->cases as $case)
  1668. {
  1669. $case->status = $this->processStatus('testcase', $case);
  1670. $case->openedBy = zget($users, $case->openedBy);
  1671. $case->lastEditedBy = zget($users, $case->lastEditedBy);
  1672. }
  1673. return $story;
  1674. }
  1675. /**
  1676. * 获取该需求影响的子需求。
  1677. * Get affected children for this story.
  1678. *
  1679. * @param object $story
  1680. * @param array $users
  1681. * @access protected
  1682. * @return object
  1683. */
  1684. protected function getAffectedChildren($story, $users)
  1685. {
  1686. $storyType = $story->type;
  1687. foreach($story->children as $child)
  1688. {
  1689. $child->status = $this->processStatus('story', $child);
  1690. $child->openedBy = zget($users, $child->openedBy);
  1691. $storyType = $child->type;
  1692. }
  1693. if(!isset($this->config->story->affect)) $this->config->story->affect = new stdclass();
  1694. $this->config->story->affect->children = new stdclass();
  1695. $this->config->story->affect->children->fields[] = array('name' => 'id', 'title' => $this->lang->idAB, 'type' => 'id', 'sortType' => false);
  1696. $this->config->story->affect->children->fields[] = array('name' => 'title', 'title' => $this->lang->story->name, 'link' => helper::createLink($storyType, 'view', 'id={id}'), 'type' => 'title', 'sortType' => false, 'data-toggle' => 'modal', 'data-size' => 'lg');
  1697. $this->config->story->affect->children->fields[] = array('name' => 'pri', 'title' => $this->lang->priAB, 'type' => 'pri', 'sortType' => false);
  1698. $this->config->story->affect->children->fields[] = array('name' => 'status', 'title' => $this->lang->story->status, 'type' => 'status', 'sortType' => false);
  1699. $this->config->story->affect->children->fields[] = array('name' => 'openedBy', 'title' => $this->lang->story->openedBy, 'type' => 'user', 'sortType' => false);
  1700. return $story;
  1701. }
  1702. /**
  1703. * 获取该需求影响的孪生需求。
  1704. * Get affected twins for this story.
  1705. *
  1706. * @param object $story
  1707. * @param array $users
  1708. * @access protected
  1709. * @return object
  1710. */
  1711. protected function getAffectedTwins($story, $users)
  1712. {
  1713. if(empty($story->twins)) return $story;
  1714. if(!isset($this->config->story->affect)) $this->config->story->affect = new stdclass();
  1715. $this->config->story->affect->twins = new stdclass();
  1716. $this->config->story->affect->twins->fields[] = array('name' => 'id', 'title' => $this->lang->idAB);
  1717. $this->config->story->affect->twins->fields[] = array('name' => 'branch', 'title' => $this->lang->story->branch);
  1718. $this->config->story->affect->twins->fields[] = array('name' => 'title', 'title' => $this->lang->story->title, 'link' => $this->config->vision != 'or' ? helper::createLink('story', 'view', 'id={id}') : '', 'data-toggle' => 'modal', 'data-size' => 'lg', 'fixed' => false, 'sortType' => false, 'flex' => false);
  1719. $this->config->story->affect->twins->fields[] = array('name' => 'status', 'title' => $this->lang->statusAB);
  1720. $this->config->story->affect->twins->fields[] = array('name' => 'stage', 'title' => $this->lang->story->stageAB);
  1721. $this->config->story->affect->twins->fields[] = array('name' => 'openedBy', 'title' => $this->lang->story->openedBy);
  1722. $this->config->story->affect->twins->fields[] = array('name' => 'lastEditedBy', 'title' => $this->lang->story->lastEditedBy);
  1723. $story->twins = $this->getByList($story->twins);
  1724. $branches = $this->loadModel('branch')->getPairs($story->product);
  1725. foreach($story->twins as $twin)
  1726. {
  1727. $twin->branch = zget($branches, $twin->branch, '');
  1728. $twin->status = $this->processStatus('story', $twin);
  1729. $twin->openedBy = zget($users, $twin->openedBy);
  1730. $twin->lastEditedBy = zget($users, $twin->lastEditedBy);
  1731. $twin->stage = zget($this->lang->story->stageList, $twin->stage);
  1732. }
  1733. return $story;
  1734. }
  1735. /**
  1736. * 检查操作按钮的触发条件。
  1737. * Check action conditions.
  1738. *
  1739. * @param string $method
  1740. * @param object $story
  1741. * @access protected
  1742. * @return bool
  1743. */
  1744. protected function checkConditions($method, $story)
  1745. {
  1746. if($this->config->edition == 'open') return true;
  1747. static $flowActions = [];
  1748. if(empty($flowActions)) $flowActions = $this->loadModel('workflowaction')->getList($story->type);
  1749. $this->loadModel('flow');
  1750. $isClickable = true;
  1751. foreach($flowActions as $flowAction)
  1752. {
  1753. if($flowAction->action == $method && $flowAction->extensionType != 'none' && $flowAction->status == 'enable' && !empty($flowAction->conditions))
  1754. {
  1755. $isClickable = $this->flow->checkConditions($flowAction->conditions, $story);
  1756. }
  1757. }
  1758. return $isClickable;
  1759. }
  1760. /**
  1761. * 构建需求列表中的操作按钮。
  1762. * Build action buttons on the browse page.
  1763. *
  1764. * @param object $story
  1765. * @param string $params
  1766. * @param string $storyType story|requirement
  1767. * @param object $execution
  1768. * @param array $maxGradeGroup
  1769. * @access protected
  1770. * @return array
  1771. */
  1772. protected function buildBrowseActionBtnList($story, $params = '', $storyType = 'story', $execution = null, $maxGradeGroup = array())
  1773. {
  1774. global $lang;
  1775. $actions = array();
  1776. if(!empty($execution) && !common::canModify($execution->type == 'project' ? 'project' : 'execution', $execution)) return $actions;
  1777. $tutorialMode = commonModel::isTutorialMode();
  1778. if($this->config->edition == 'ipd' && $storyType == 'story' && !empty($story->confirmeActionType))
  1779. {
  1780. $method = $story->confirmeActionType == 'confirmedretract' ? 'confirmDemandRetract' : 'confirmDemandUnlink';
  1781. $url = helper::createLink('story', $method, "objectID=$story->id&object=story&extra={$story->confirmeObjectID}");
  1782. $actions[] = array('name' => $method, 'icon' => 'search', 'hint' => $this->lang->story->$method, 'url' => $url, 'data-toggle' => 'modal');
  1783. return $actions;
  1784. }
  1785. static $taskGroups = array();
  1786. static $caseGroups = array();
  1787. if(empty($taskGroups)) $taskGroups = $this->dao->select('story, id')->from(TABLE_TASK)->where('deleted')->eq('0')->andWhere('story')->ne(0)->fetchPairs();
  1788. if(empty($caseGroups)) $caseGroups = $this->dao->select('story, id')->from(TABLE_CASE)->where('deleted')->eq('0')->andWhere('story')->ne(0)->fetchPairs();
  1789. $actSubmitreview = array();
  1790. $actReview = array();
  1791. $actRecall = array();
  1792. $storyReviewer = isset($story->reviewer) ? $story->reviewer : array();
  1793. $executionID = empty($execution) ? 0 : $execution->id;
  1794. if(!isset($story->from)) $story->from = '';
  1795. $closeLink = helper::createLink($story->type, 'close', $params . "&from=$story->from");
  1796. $activateLink = helper::createLink($story->type, 'activate', $params . "&from=$story->from");
  1797. $processStoryChangeLink = helper::createLink($story->type, 'processStoryChange', $params);
  1798. $changeLink = helper::createLink($story->type, 'change', $params . "&from=$story->from");
  1799. $submitReviewLink = helper::createLink($story->type, 'submitReview', "storyID=$story->id");
  1800. $reviewLink = helper::createLink($story->type, 'review', $params . "&from=$story->from") . ($this->app->tab == 'project' ? '#app=project' : '');
  1801. $recallLink = helper::createLink($story->type, 'recall', $params . "&from=list&confirm=no");
  1802. $batchCreateStoryLink = helper::createLink($story->type, 'batchCreate', "productID=$story->product&branch=$story->branch&module=$story->module&$params&executionID=$executionID&plan=0");
  1803. $editLink = helper::createLink($story->type, 'edit', $params . "&kanbanGroup=default") . ($this->app->tab == 'project' ? '#app=project' : '');
  1804. $createCaseLink = helper::createLink('testcase', 'create', "productID=$story->product&branch=$story->branch&module=0&from=&param=0&$params");
  1805. /* If the story cannot be changed, render the close button. */
  1806. $canClose = common::hasPriv($story->type, 'close', $story) && $this->isClickable($story, 'close', $taskGroups, $caseGroups) && $this->checkConditions('close', $story);
  1807. $canActivate = common::hasPriv($story->type, 'activate', $story) && $this->isClickable($story, 'activate', $taskGroups, $caseGroups) && $this->checkConditions('activate', $story);
  1808. if(!common::canBeChanged($story->type, $story)) return array(array('name' => 'close', 'hint' => $lang->close, 'data-toggle' => 'modal', 'url' => $canClose ? $closeLink : null, 'disabled' => !$canClose));
  1809. $canProcessChange = common::hasPriv($story->type, 'processStoryChange', $story);
  1810. if(!empty($story->parentChanged) || !empty($story->demandChanged)) return array(array('name' => 'processStoryChange', 'url' => $canProcessChange ? $processStoryChangeLink : null, 'disabled' => !$canProcessChange, 'innerClass' => 'ajax-submit'));
  1811. /* Change button. */
  1812. $canChange = common::hasPriv($story->type, 'change', $story) && $this->isClickable($story, 'change', $taskGroups, $caseGroups) && $this->checkConditions('change', $story);
  1813. $title = $canChange ? $lang->story->change : $this->lang->story->changeTip;
  1814. if(common::hasPriv($story->type, 'change', $story)) $actions[] = array('name' => 'change', 'url' => $canChange ? $changeLink : null, 'hint' => empty($story->frozen) ? $title : sprintf($this->lang->story->frozenTip, $this->lang->story->change), 'disabled' => !$canChange, 'class' => 'story-change-btn');
  1815. /* Submitreview, review, recall buttons. */
  1816. if(strpos('draft,changing', $story->status) !== false)
  1817. {
  1818. $canSubmitReview = common::hasPriv($story->type, 'submitReview', $story);
  1819. $actSubmitreview = array('name' => 'submitreview', 'data-toggle' => 'modal', 'url' => $canSubmitReview ? $submitReviewLink : null, 'disabled' => !$canSubmitReview, 'hint' => $canSubmitReview ? $this->lang->story->submitReview : $this->lang->story->reviewTip['noPriv']);
  1820. }
  1821. else
  1822. {
  1823. $canReview = common::hasPriv($story->type, 'review', $story) && $this->isClickable($story, 'review', $taskGroups, $caseGroups) && $this->checkConditions('review', $story);
  1824. $title = $this->lang->story->review;
  1825. if(!$canReview && $story->status != 'closed')
  1826. {
  1827. if($storyReviewer && !in_array($this->app->user->account, $storyReviewer)) $title = $this->lang->story->reviewTip['notReviewer'];
  1828. if($story->status == 'active')
  1829. {
  1830. if($storyReviewer && in_array($this->app->user->account, $storyReviewer))
  1831. {
  1832. $title = $this->lang->story->reviewTip['reviewed'];
  1833. }
  1834. else
  1835. {
  1836. $title = $this->lang->story->reviewTip['active'];
  1837. }
  1838. }
  1839. }
  1840. $actReview = array('name' => 'review', 'url' => $canReview ? $reviewLink : null, 'hint' => $title, 'disabled' => !$canReview, 'class' => 'story-review-btn');
  1841. }
  1842. $canRecall = common::hasPriv($story->type, 'recall', $story) && $this->isClickable($story, $story->status == 'changing' ? 'recallchange' : 'recall', $taskGroups, $caseGroups);
  1843. $title = $story->status == 'changing' ? $this->lang->story->recallChange : $this->lang->story->recall;
  1844. if(!$canRecall) $title = $this->lang->story->recallTip['actived'];
  1845. $actRecall = array('name' => $story->status == 'changing' ? 'recalledchange' : 'recall', 'url' => $canRecall ? $recallLink : null, 'hint' => $title, 'disabled' => !$canRecall);
  1846. /* Change the render order. */
  1847. if(!empty($actSubmitreview))
  1848. {
  1849. $actions[] = $actSubmitreview;
  1850. $actions[] = array('name' => 'dropdown', 'type' => 'dropdown', 'items' => array($actRecall + array('innerClass' => 'ajax-submit')));
  1851. }
  1852. elseif($actReview['disabled'] && !$actRecall['disabled'])
  1853. {
  1854. $actions[] = $actRecall + array('className' => 'ajax-submit');
  1855. $actions[] = array('name' => 'dropdown', 'type' => 'dropdown', 'items' => array($actReview));
  1856. }
  1857. else
  1858. {
  1859. $actions[] = $actReview;
  1860. $actions[] = array('name' => 'dropdown', 'type' => 'dropdown', 'items' => array($actRecall + array('innerClass' => 'ajax-submit')));
  1861. }
  1862. if($this->config->vision != 'lite' && $story->status != 'closed' && common::hasPriv($story->type, 'close', $story)) $actions[] = array('name' => 'close', 'url' => $canClose ? $closeLink : null, 'data-toggle' => 'modal', 'disabled' => !$canClose);
  1863. if($this->config->vision != 'lite' && $story->status == 'closed' && common::hasPriv($story->type, 'activate', $story)) $actions[] = array('name' => 'activate', 'url' => $canActivate ? $activateLink : null, 'data-toggle' => 'modal');
  1864. /* Render divider line. */
  1865. if(!empty($actions)) $actions[] = array('name' => 'divider', 'type'=>'divider');
  1866. /* Edit button. */
  1867. $canEdit = common::hasPriv($story->type, 'edit') && $this->isClickable($story, 'edit', $taskGroups, $caseGroups) && $this->checkConditions('edit', $story);
  1868. if(common::hasPriv($story->type, 'edit', $story)) $actions[] = array('name' => 'edit', 'url' => $this->isClickable($story, 'edit', $taskGroups, $caseGroups) ? $editLink : null, 'disabled' => !$canEdit, 'hint' => empty($story->frozen) ? $this->lang->story->edit : sprintf($this->lang->story->frozenTip, $this->lang->story->edit));
  1869. /* Create test case button. */
  1870. if($story->type == 'story' && $this->config->vision != 'lite' && common::hasPriv('testcase', 'create')) $actions[] = array('name' => 'testcase', 'url' => $story->isParent == '0' ? $createCaseLink : null, 'disabled' => $story->isParent == '1', 'data-toggle' => 'modal', 'data-size' => 'lg');
  1871. /* Batch create button. */
  1872. $shadow = $this->dao->findByID($story->product)->from(TABLE_PRODUCT)->fetch('shadow');
  1873. $canBatchCreateStory = (common::hasPriv($story->type, 'batchcreate') && $this->isClickable($story, 'batchcreate', $taskGroups, $caseGroups) && $story->grade < $maxGradeGroup[$story->type] && empty($story->hasOtherTypeChild)) || common::isTutorialMode();
  1874. if(!($this->app->rawModule == 'projectstory' && $this->app->rawMethod == 'story') || $this->config->vision == 'lite' || $shadow)
  1875. {
  1876. if($shadow and empty($taskGroups[$story->id])) $taskGroups[$story->id] = $this->dao->select('id')->from(TABLE_TASK)->where('story')->eq($story->id)->fetch('id');
  1877. if(empty($caseGroups[$story->id])) $caseGroups[$story->id] = $this->dao->select('id')->from(TABLE_CASE)->where('story')->eq($story->id)->fetch('id');
  1878. /*
  1879. * 需求的拆分按钮分为两种情况:
  1880. * 1.拆分成相同类型的子需求。
  1881. * 2.拆分成其他类型的子需求。
  1882. * 这两种情况是互斥的,拆分了一种类型的需求后,另一种类型的需求就不能再拆分了。
  1883. * 默认拆分相同类型的需求,如果不能拆分相同类型的需求,则拆分其他类型的需求。
  1884. */
  1885. if($canBatchCreateStory)
  1886. {
  1887. $actions[] = array('name' => 'batchCreate', 'url' => $batchCreateStoryLink, 'hint' => empty($story->frozen) ? $this->lang->story->split : sprintf($this->lang->story->frozenTip, $this->lang->story->split), 'icon' => 'split', 'class' => 'batchCreateStoryBtn');
  1888. }
  1889. elseif($story->type == 'epic' && common::hasPriv('requirement', 'batchCreate') && $this->isClickable($story, 'batchcreate', $taskGroups, $caseGroups) && empty($story->hasSameTypeChild) && !($this->config->epic->gradeRule == 'stepwise' && $story->grade < $maxGradeGroup['epic']))
  1890. {
  1891. $actions[] = array('name' => 'batchCreate', 'url' => helper::createLink('requirement', 'batchCreate', "productID=$story->product&branch=$story->branch&module=$story->module&$params&executionID=$executionID&plan=0"), 'hint' => empty($story->frozen) ? $this->lang->story->split : sprintf($this->lang->story->frozenTip, $this->lang->story->split), 'icon' => 'split', 'class' => 'batchCreateStoryBtn');
  1892. }
  1893. elseif($story->type == 'requirement' && common::hasPriv('story', 'batchCreate') && $this->isClickable($story, 'batchcreate', $taskGroups, $caseGroups) && empty($story->hasSameTypeChild) && !($this->config->requirement->gradeRule == 'stepwise' && $story->grade < $maxGradeGroup['requirement']))
  1894. {
  1895. $actions[] = array('name' => 'batchCreate', 'url' => helper::createLink('story', 'batchCreate', "productID=$story->product&branch=$story->branch&module=$story->module&$params&executionID=$executionID&plan=0"), 'hint' => empty($story->frozen) ? $this->lang->story->split : sprintf($this->lang->story->frozenTip, $this->lang->story->split), 'icon' => 'split', 'class' => 'batchCreateStoryBtn');
  1896. }
  1897. elseif(!$canBatchCreateStory && common::hasPriv($story->type, 'batchcreate'))
  1898. {
  1899. $title = $this->lang->story->split;
  1900. if($story->status == 'active' && $story->stage != 'wait') $title = sprintf($this->lang->story->subDivideTip['notWait'], zget($this->lang->{$story->type}->stageList, $story->stage));
  1901. if(!empty($story->twins)) $title = $this->lang->story->subDivideTip['twinsSplit'];
  1902. if(!empty($taskGroups[$story->id])) $title = sprintf($this->lang->story->subDivideTip['notWait'], $this->lang->story->hasDividedTask);
  1903. if(!empty($caseGroups[$story->id])) $title = sprintf($this->lang->story->subDivideTip['notWait'], $this->lang->story->hasDividedCase);
  1904. if(isset($maxGradeGroup[$story->type]) && $story->grade >= $maxGradeGroup[$story->type]) $title = $this->lang->story->errorMaxGradeSubdivide;
  1905. if($story->status != 'active' && $story->status != 'changing') $title = $this->lang->story->subDivideTip['notActive'];
  1906. if(!empty($story->frozen)) $title = sprintf($this->lang->story->frozenTip, $this->lang->story->split);
  1907. $actions[] = array('name' => 'batchCreate', 'hint' => $title, 'disabled' => true, 'icon' => 'split');
  1908. }
  1909. }
  1910. if(!empty($execution))
  1911. {
  1912. if($execution->type != 'project')
  1913. {
  1914. $createTaskLink = $tutorialMode ? helper::createLink('tutorial', 'wizard', "module=task&method=create&params=" . helper::safe64Encode("executionID={$execution->id}")) : helper::createLink('task', 'create', "executionID={$execution->id}&storyID={$story->id}");
  1915. $batchCreateTaskLink = helper::createLink('task', 'batchCreate', "executionID={$execution->id}&storyID={$story->id}");
  1916. $storyEstimateLink = helper::createLink('execution', 'storyEstimate', "executionID={$execution->id}&storyID={$story->id}");
  1917. $canCreateTask = common::hasPriv('task', 'create') && $story->status == 'active' && $story->isParent == '0' && $story->type == 'story';
  1918. $canBatchCreateTask = common::hasPriv('task', 'batchCreate') && $story->status == 'active' && $story->isParent == '0' && $story->type == 'story';
  1919. $canStoryEstimate = common::hasPriv('execution', 'storyEstimate', $story) && $story->type == 'story';
  1920. $actions[] = array('name' => 'createTask', 'url' => $canCreateTask ? $createTaskLink : null, 'disabled' => !$canCreateTask, 'className' => 'create-task-btn');
  1921. $actions[] = array('name' => 'batchCreateTask', 'url' => $canBatchCreateTask ? $batchCreateTaskLink : null, 'disabled' => !$canBatchCreateTask, 'className' => 'batchcreate-task-btn');
  1922. $actions[] = array('name' => 'storyEstimate', 'url' => $canStoryEstimate ? $storyEstimateLink : null, 'disabled' => !$canStoryEstimate);
  1923. }
  1924. if($this->config->vision != 'lite' && $execution->hasProduct)
  1925. {
  1926. $unlinkModule = $execution->type == 'project' ? 'projectstory' : 'execution';
  1927. $canUnlinkStory = common::hasPriv($unlinkModule, 'unlinkStory', $story);
  1928. $unlinkStoryLink = helper::createLink($unlinkModule, 'unlinkStory', "projectID={$execution->id}&$params&confirm=yes");
  1929. $unlinkStoryTip = $this->lang->execution->confirmUnlinkStory;
  1930. $unlinkHint = $this->lang->story->unlink;
  1931. $disabled = !$canUnlinkStory;
  1932. if($execution->type == 'project')
  1933. {
  1934. $unlinkStoryTip = $this->lang->execution->confirmUnlinkExecutionStory;
  1935. static $executionStories = array();
  1936. if(!isset($executionStories[$execution->id]))
  1937. {
  1938. $executions = $this->dao->select('*')->from(TABLE_EXECUTION)->where('parent')->eq($execution->id)->andWhere('type')->ne('project')->fetchAll('id');
  1939. $executionStories[$execution->id] = $this->dao->select('project,story')->from(TABLE_PROJECTSTORY)->where('project')->in(array_keys($executions))->fetchPairs('story', 'story');
  1940. }
  1941. if(isset($executionStories[$execution->id][$story->id]))
  1942. {
  1943. $disabled = true;
  1944. $unlinkHint = $this->lang->execution->notAllowedUnlinkStory;
  1945. }
  1946. $canBatchCreateStory = common::hasPriv($story->type, 'batchcreate') && $this->isClickable($story, 'batchcreate', $taskGroups, $caseGroups) && $story->grade < $maxGradeGroup[$story->type] && empty($story->hasOtherTypeChild);
  1947. if($canBatchCreateStory)
  1948. {
  1949. $actions[] = array('name' => 'batchCreate', 'url' => $batchCreateStoryLink, 'hint' => empty($story->frozen) ? $this->lang->story->split : sprintf($this->lang->story->frozenTip, $this->lang->story->split), 'icon' => 'split');
  1950. }
  1951. elseif($story->type == 'epic' && common::hasPriv('requirement', 'batchCreate') && $this->isClickable($story, 'batchcreate', $taskGroups, $caseGroups) && empty($story->hasSameTypeChild) && !($this->config->epic->gradeRule == 'stepwise' && $story->grade < $maxGradeGroup['epic']))
  1952. {
  1953. $actions[] = array('name' => 'batchCreate', 'url' => helper::createLink('requirement', 'batchCreate', "productID=$story->product&branch=$story->branch&module=$story->module&$params&executionID=$executionID&plan=0"), 'hint' => empty($story->frozen) ? $this->lang->story->split : sprintf($this->lang->story->frozenTip, $this->lang->story->split), 'icon' => 'split');
  1954. }
  1955. elseif($story->type == 'requirement' && common::hasPriv('story', 'batchCreate') && $this->isClickable($story, 'batchcreate', $taskGroups, $caseGroups) && empty($story->hasSameTypeChild) && !($this->config->requirement->gradeRule == 'stepwise' && $story->grade < $maxGradeGroup['requirement']))
  1956. {
  1957. $actions[] = array('name' => 'batchCreate', 'url' => helper::createLink('story', 'batchCreate', "productID=$story->product&branch=$story->branch&module=$story->module&$params&executionID=$executionID&plan=0"), 'hint' => empty($story->frozen) ? $this->lang->story->split : sprintf($this->lang->story->frozenTip, $this->lang->story->split), 'icon' => 'split');
  1958. }
  1959. elseif(!$canBatchCreateStory && $story->status != 'closed' && common::hasPriv($story->type, 'batchcreate'))
  1960. {
  1961. $title = $this->lang->story->split;
  1962. if($story->status == 'active' && $story->stage != 'wait') $title = sprintf($this->lang->story->subDivideTip['notWait'], zget($this->lang->story->stageList, $story->stage));
  1963. if(!empty($story->twins)) $title = $this->lang->story->subDivideTip['twinsSplit'];
  1964. if(!empty($taskGroups[$story->id])) $title = sprintf($this->lang->story->subDivideTip['notWait'], $this->lang->story->hasDividedTask);
  1965. if(!empty($caseGroups[$story->id])) $title = sprintf($this->lang->story->subDivideTip['notWait'], $this->lang->story->hasDividedCase);
  1966. if(isset($maxGradeGroup[$story->type]) && $story->grade >= $maxGradeGroup[$story->type]) $title = $this->lang->story->errorMaxGradeSubdivide;
  1967. if($story->status != 'active') $title = $this->lang->story->subDivideTip['notActive'];
  1968. if(!empty($story->frozen)) $title = sprintf($this->lang->story->frozenTip, $this->lang->story->split);
  1969. $actions[] = array('name' => 'batchCreate', 'hint' => $title, 'disabled' => true, 'icon' => 'split');
  1970. }
  1971. }
  1972. if($story->type == 'requirement') $unlinkStoryTip = str_replace($this->lang->SRCommon, $this->lang->URCommon, $unlinkStoryTip);
  1973. $unlinkStoryTip = json_encode(array('message' => array('html' => "<i class='icon icon-exclamation-sign text-warning text-lg mr-2'></i>{$unlinkStoryTip}")));
  1974. $actions[] = array('name' => 'unlink', 'className' => 'ajax-submit', 'data-confirm' => $unlinkStoryTip, 'url' => $canUnlinkStory ? $unlinkStoryLink : null, 'disabled' => $disabled || !empty($story->frozen), 'hint' => !empty($story->frozen) ? sprintf($this->lang->story->frozenTip, $this->lang->story->unlink) : $unlinkHint);
  1975. }
  1976. }
  1977. return $actions;
  1978. }
  1979. /**
  1980. * 检查当前账号是否是超级评审人。
  1981. * Check account is super reviewer or not.
  1982. *
  1983. * @access protected
  1984. * @return bool
  1985. */
  1986. protected function isSuperReviewer()
  1987. {
  1988. $moduleName = $this->app->rawModule;
  1989. return strpos(',' . zget($this->config->{$moduleName}, 'superReviewers', '') . ',', ",{$this->app->user->account},") !== false;
  1990. }
  1991. /**
  1992. * 更新父需求的状态。
  1993. * Update parent story status.
  1994. *
  1995. * @param int $parentID
  1996. * @param string $status
  1997. * @access protected
  1998. * @return object|false
  1999. */
  2000. protected function doUpdateParentStatus($parentID, $status)
  2001. {
  2002. if(empty($parentID)) return false;
  2003. $oldParentStory = $this->dao->select('*')->from(TABLE_STORY)->where('id')->eq($parentID)->andWhere('deleted')->eq(0)->fetch();
  2004. $now = helper::now();
  2005. $story = new stdclass();
  2006. $story->status = $status;
  2007. if(strpos('launched,active,changing,draft', $status) !== false)
  2008. {
  2009. $story->assignedTo = $oldParentStory->openedBy;
  2010. $story->assignedDate = $now;
  2011. $story->closedBy = '';
  2012. $story->closedReason = '';
  2013. $story->closedDate = null;
  2014. $story->reviewedBy = '';
  2015. $story->reviewedDate = null;
  2016. }
  2017. if($status == 'closed')
  2018. {
  2019. $closedReason = $this->dao->select('closedReason')->from(TABLE_STORY)
  2020. ->where('parent')->eq($parentID)
  2021. ->andWhere('deleted')->eq(0)
  2022. ->andWhere('closedReason')->eq('done')
  2023. ->fetch('closedReason');
  2024. $story->assignedTo = 'closed';
  2025. $story->assignedDate = $now;
  2026. $story->closedBy = $this->app->user->account;
  2027. $story->closedDate = $now;
  2028. $story->closedReason = $closedReason ? 'done' : 'willnotdo';
  2029. }
  2030. $story->lastEditedBy = $this->app->user->account;
  2031. $story->lastEditedDate = $now;
  2032. $this->dao->update(TABLE_STORY)->data($story)->where('id')->eq($parentID)->exec();
  2033. return $story;
  2034. }
  2035. /**
  2036. * 获取传入所有需求的叶子结点,并按照根节点顺序返回
  2037. * Get leaf node by all stories, and sort by base nodes.
  2038. *
  2039. * @param array $stories
  2040. * @param string $storyType epic|requirement|story
  2041. * @access public
  2042. * @return array
  2043. */
  2044. public function getLeafNodes($stories, $storyType = 'epic')
  2045. {
  2046. $stmt = $this->dao->select('id,project,commit,name as title,status,story,type AS designType')->from(TABLE_DESIGN)->where('story')->in(array_keys($stories))->andWhere('deleted')->eq(0)->orderBy('project')->query();
  2047. $designGroup = array();
  2048. while($design = $stmt->fetch()) $designGroup[$design->story][$design->id] = $design;
  2049. $leafNodes = array();
  2050. foreach($stories as $story)
  2051. {
  2052. if($storyType == 'requirement' && $story->type == 'epic') continue;
  2053. if($storyType == 'story' && ($story->type == 'epic' || $story->type == 'requirement')) continue;
  2054. if(empty($story->isParent)) $leafNodes[$story->id] = $story;
  2055. if(!isset($leafNodes[$story->id]) && isset($designGroup[$story->id])) $leafNodes[$story->id] = $story;
  2056. }
  2057. return $leafNodes;
  2058. }
  2059. /**
  2060. * 根据叶子结点数据,构建看板泳道数据。
  2061. * Build lanes data by leaf node.
  2062. *
  2063. * @param array $leafNodes
  2064. * @param string $storyType epic|requirement|story
  2065. * @access public
  2066. * @return array
  2067. */
  2068. public function buildTrackLanes($leafNodes, $storyType)
  2069. {
  2070. $lanes = array();
  2071. foreach($leafNodes as $story)
  2072. {
  2073. if(($storyType == 'requirement' || $storyType == 'story') && $story->type == 'epic') continue;
  2074. if($storyType == 'story' && $story->type == 'requirement') continue;
  2075. $lanes[] = array('name' => "lane_{$story->id}", 'title' => '');
  2076. }
  2077. return $lanes;
  2078. }
  2079. /**
  2080. * 根据需求类型,构建看板列数据。
  2081. * Build cols data by storyType.
  2082. *
  2083. * @param string $storyType epic|requirement|story
  2084. * @access public
  2085. * @return array
  2086. */
  2087. public function buildTrackCols($storyType)
  2088. {
  2089. $storyGrade = $this->getGradeGroup();
  2090. $cols = array();
  2091. if($storyType == 'epic') $cols['epic'] = $this->buildTrackCol('epic', $this->lang->ERCommon, empty($storyGrade['epic']) ? 0 : -1);
  2092. if($storyType != 'story') $cols['requirement'] = $this->buildTrackCol('requirement', $this->lang->URCommon, empty($storyGrade['requirement']) ? 0 : -1);
  2093. $cols['story'] = $this->buildTrackCol('story', $this->lang->SRCommon, empty($storyGrade['story']) ? 0 : -1);
  2094. $cols['project'] = $this->buildTrackCol('project', $this->lang->story->project);
  2095. $cols['execution'] = $this->buildTrackCol('execution', $this->lang->story->execution);
  2096. $cols['design'] = $this->buildTrackCol('design', $this->lang->story->design);
  2097. $cols['commit'] = $this->buildTrackCol('commit', $this->lang->story->repoCommit);
  2098. $cols['task'] = $this->buildTrackCol('task', $this->lang->story->tasks);
  2099. $cols['bug'] = $this->buildTrackCol('bug', $this->lang->story->bugs);
  2100. $cols['case'] = $this->buildTrackCol('case', $this->lang->story->cases);
  2101. foreach($storyGrade as $type => $grades)
  2102. {
  2103. if(!isset($cols[$type])) continue;
  2104. foreach($grades as $grade) $cols["{$type}_{$grade->grade}"] = $this->buildTrackCol("{$type}_{$grade->grade}", $grade->name, $type);
  2105. }
  2106. return array_values($cols);
  2107. }
  2108. /**
  2109. * 根据单个看板列数据。
  2110. * Build single col data.
  2111. *
  2112. * @param string $name
  2113. * @param string $title
  2114. * @param int|string $parent
  2115. * @access public
  2116. * @return array
  2117. */
  2118. public function buildTrackCol($name, $title, $parent = 0)
  2119. {
  2120. $col = array('name' => $name, 'title' => $title, 'parent' => $parent);
  2121. if($parent != '0' && $parent != -1) $col['parentName'] = $parent;
  2122. return $col;
  2123. }
  2124. /**
  2125. * 构建看板项数据。
  2126. * Build items data by storyType.
  2127. *
  2128. * @param array $allStories
  2129. * @param array $leafNodes
  2130. * @param string $storyType epic|requirement|story
  2131. * @access public
  2132. * @return array
  2133. */
  2134. public function buildTrackItems($allStories, $leafNodes, $storyType)
  2135. {
  2136. $storyIdList = array_keys($leafNodes);
  2137. $projectGroup = $this->getProjectsForTrack($storyIdList);
  2138. $designGroup = $this->getDesignsForTrack($storyIdList);
  2139. $projects = zget($projectGroup, 'project', array());
  2140. $executions = zget($projectGroup, 'execution', array());
  2141. $designs = zget($designGroup, 'design', array());
  2142. $commits = zget($designGroup, 'commit', array());
  2143. $tasks = $this->getTasksForTrack($storyIdList);
  2144. $cases = $this->dao->select('id,project,pri,status,color,title,story,lastRunner,lastRunResult')->from(TABLE_CASE)->where('story')->in($storyIdList)->andWhere('deleted')->eq(0)->orderBy('project')->fetchGroup('story', 'id');
  2145. $bugs = $this->dao->select('id,project,pri,status,color,title,story,assignedTo,severity')->from(TABLE_BUG)->where('story')->in($storyIdList)->andWhere('deleted')->eq(0)->orderBy('project')->fetchGroup('story', 'id');
  2146. $storyGrade = $this->getGradeGroup();
  2147. $items = array();
  2148. foreach($leafNodes as $node)
  2149. {
  2150. $laneName = "lane_{$node->id}";
  2151. foreach(explode(',', trim($node->path, ',')) as $storyID)
  2152. {
  2153. if(!isset($allStories[$storyID])) continue;
  2154. $story = clone $allStories[$storyID];
  2155. if($storyType == 'requirement' && $story->type == 'epic') continue;
  2156. if($storyType == 'story' && ($story->type == 'requirement' || $story->type == 'epic')) continue;
  2157. if(!isset($storyGrade[$story->type][$story->grade])) continue;
  2158. $colName = "{$story->type}_{$story->grade}";
  2159. $story->storyType = $story->type;
  2160. unset($story->type);
  2161. $items[$laneName][$colName][] = $story;
  2162. }
  2163. $items[$laneName]['project'] = array_values(zget($projects, $node->id, array()));
  2164. $items[$laneName]['execution'] = array_values(zget($executions, $node->id, array()));
  2165. $items[$laneName]['design'] = array_values(zget($designs, $node->id, array()));
  2166. $items[$laneName]['commit'] = array_values(zget($commits, $node->id, array()));
  2167. $items[$laneName]['task'] = array_values(zget($tasks, $node->id, array()));
  2168. $items[$laneName]['bug'] = array_values(zget($bugs, $node->id, array()));
  2169. $items[$laneName]['case'] = array_values(zget($cases, $node->id, array()));
  2170. }
  2171. return $items;
  2172. }
  2173. /**
  2174. * 根据需求ID列表获取关联的项目和执行
  2175. * Get linked projects and executions by story id list.
  2176. *
  2177. * @param array $storyIdList
  2178. * @access public
  2179. * @return array
  2180. */
  2181. public function getProjectsForTrack($storyIdList)
  2182. {
  2183. $projectStoryList = array();
  2184. $stmt = $this->dao->select('*')->from(TABLE_PROJECTSTORY)->where('story')->in($storyIdList)->query();
  2185. while($projectStory = $stmt->fetch()) $projectStoryList[$projectStory->project][$projectStory->story] = $projectStory->story;
  2186. $stmt = $this->dao->select('id,type AS projectType,model,parent,path,grade,name as title,hasProduct,begin,end,status,project,progress,multiple')->from(TABLE_PROJECT)->where('id')->in(array_keys($projectStoryList))->andWhere('deleted')->eq(0)->orderBy('id')->query();
  2187. $today = helper::today();
  2188. $storyGroup = array();
  2189. while($project = $stmt->fetch())
  2190. {
  2191. if($project->projectType != 'project' && empty($project->multiple)) continue;
  2192. $delay = 0;
  2193. if($project->status != 'done' && $project->status != 'closed' && $project->status != 'suspended') $delay = helper::diffDate($today, $project->end);
  2194. $project->delay = $delay > 0;
  2195. $projectType = $project->projectType == 'project' ? 'project' : 'execution';
  2196. foreach($projectStoryList[$project->id] as $storyID) $storyGroup[$projectType][$storyID][$project->id] = $project;
  2197. }
  2198. return $storyGroup;
  2199. }
  2200. /**
  2201. * 根据需求ID列表获取关联的设计和提交
  2202. * Get linked designs and commits by story id list.
  2203. *
  2204. * @param array $storyIdList
  2205. * @access public
  2206. * @return array
  2207. */
  2208. public function getDesignsForTrack($storyIdList)
  2209. {
  2210. $storyGroup = array('design' => array(), 'commit' => array());
  2211. $stmt = $this->dao->select('id,project,commit,name as title,status,story,type AS designType')->from(TABLE_DESIGN)->where('story')->in($storyIdList)->andWhere('deleted')->eq(0)->orderBy('project')->query();
  2212. $commitIdList = '';
  2213. while($design = $stmt->fetch())
  2214. {
  2215. $storyGroup['design'][$design->story][$design->id] = $design;
  2216. $commitIdList .= $design->commit ? "{$design->commit}," : '';
  2217. }
  2218. if($commitIdList) $commits = $this->dao->select('id,repo,revision,committer,comment as title')->from(TABLE_REPOHISTORY)->where('id')->in(array_unique(explode(',', $commitIdList)))->fetchAll('id');
  2219. foreach($storyGroup['design'] as $storyID => $designs)
  2220. {
  2221. foreach($designs as $design)
  2222. {
  2223. if(empty($design->commit)) continue;
  2224. foreach(explode(',', $design->commit) as $commitID)
  2225. {
  2226. $commit = zget($commits, $commitID, '');
  2227. if(empty($commit)) continue;
  2228. $commit->project = $design->project;
  2229. $storyGroup['commit'][$storyID][$commitID] = $commit;
  2230. }
  2231. }
  2232. }
  2233. return $storyGroup;
  2234. }
  2235. /**
  2236. * 根据需求ID列表获取关联的任务
  2237. * Get linked tasks by story id list.
  2238. *
  2239. * @param array $storyIdList
  2240. * @access public
  2241. * @return array
  2242. */
  2243. public function getTasksForTrack($storyIdList)
  2244. {
  2245. $stmt = $this->dao->select('id,project,execution,mode,pri,status,color,name as title,assignedTo,story,estimate,consumed,`left`,parent,isParent,path')->from(TABLE_TASK)->where('story')->in($storyIdList)->andWhere('deleted')->eq(0)->orderBy('project,execution,isParent_desc,path')->query();
  2246. $tasks = array();
  2247. $multiTasks = array();
  2248. while($task = $stmt->fetch())
  2249. {
  2250. $task->progress = 0;
  2251. if($task->consumed + $task->left) $task->progress = round(($task->consumed / ($task->consumed + $task->left)) * 100, 2);
  2252. if($task->mode == 'multi') $multiTasks[$task->id] = $task->id;
  2253. $tasks[$task->id] = $task;
  2254. }
  2255. $tasks = $this->loadModel('task')->mergeChildIntoParent($tasks);
  2256. $taskTeams = $this->dao->select('*')->from(TABLE_TASKTEAM)->where('task')->in($multiTasks)->fetchGroup('task', 'account');
  2257. $taskGroup = array();
  2258. $account = $this->app->user->account;
  2259. $preTask = null;
  2260. foreach($tasks as $task)
  2261. {
  2262. $taskGroup[$task->story][$task->id] = $task;
  2263. if(isset($taskTeams[$task->id])) $task->assignedTo = isset($taskTeams[$task->id][$account]) ? $account : $this->lang->task->team;
  2264. if($preTask && $preTask->isParent && ($task->parent == 0 || ($task->parent > 0 && $preTask->id != $task->parent))) $preTask->isParent = 0;
  2265. if($task->parent > 0 && !isset($taskGroup[$task->story][$task->parent]) && isset($tasks[$task->parent])) $tasks[$task->parent]->isParent = 0;
  2266. $preTask = $task;
  2267. }
  2268. if($preTask && $preTask->isParent) $preTask->isParent = 0;
  2269. return $taskGroup;
  2270. }
  2271. /**
  2272. * 根据给出需求,查询并合并子需求,返回新的需求列表。
  2273. * Get with children stories by given stories.
  2274. *
  2275. * @param array $allStories
  2276. * @param array $stories
  2277. * @access public
  2278. * @return array
  2279. * @param string $storyType
  2280. */
  2281. public function mergeChildrenForTrack($allStories, $stories, $storyType)
  2282. {
  2283. $newStories = array();
  2284. $parentGroup = array();
  2285. foreach($allStories as $storyID => $story) $parentGroup[$story->parent][$storyID] = $story;
  2286. $appendChildren = function($parentID) use($parentGroup, &$newStories, &$appendChildren)
  2287. {
  2288. if(!isset($parentGroup[$parentID])) return;
  2289. foreach($parentGroup[$parentID] as $storyID => $story)
  2290. {
  2291. $story->parent = explode(',', trim(str_replace(",{$storyID},", ',', $story->path), ','));
  2292. $newStories[$storyID] = $story;
  2293. $appendChildren($storyID);
  2294. }
  2295. };
  2296. foreach($stories as $storyID => $story)
  2297. {
  2298. if(isset($newStories[$storyID])) continue;
  2299. if($storyType == 'requirement' && $story->type == 'epic') continue;
  2300. if($storyType == 'story' && ($story->type == 'epic' || $story->type == 'requirement')) continue;
  2301. $story = zget($allStories, $storyID, $story);
  2302. $story->parent = explode(',', trim(str_replace(",{$storyID},", ',', $story->path), ','));
  2303. $newStories[$storyID] = $story;
  2304. $appendChildren($storyID);
  2305. }
  2306. return $newStories;
  2307. }
  2308. /**
  2309. * 根据父子关系重新对需求排序。
  2310. * Build track data by stories.
  2311. *
  2312. * @param array $stories
  2313. * @access public
  2314. * @return array
  2315. */
  2316. public function reorderStories($stories)
  2317. {
  2318. $tree = $this->buildStoryTree($stories, 0, $stories);
  2319. $result = array();
  2320. $this->buildReorderResult($tree, $result);
  2321. /* 重排后数量如果和重排之前不一致,说明该列表只有子任务、没有父任务,使用重排之前的顺序。*/
  2322. return count($stories) == count($result) ? $result : array_keys($stories);
  2323. }
  2324. /**
  2325. * 递归将一维需求数组构造成父子关系的嵌套数组。
  2326. * Build story tree by parent and children.
  2327. *
  2328. * @param array $stories
  2329. * @param int $parentId
  2330. * @param array $originStories
  2331. * @return array
  2332. */
  2333. public function buildStoryTree(&$stories, $parentId = 0, $originStories = array())
  2334. {
  2335. $tree = array();
  2336. foreach($stories as $id => $parent)
  2337. {
  2338. if($parent == $parentId || (!isset($originStories[$parent]) && $parentId == 0))
  2339. {
  2340. $tree[$id] = $this->buildStoryTree($stories, $id, $originStories);
  2341. }
  2342. }
  2343. return $tree;
  2344. }
  2345. /**
  2346. * 递归将父子关系的嵌套数组构造成一维需求数组。
  2347. * Build story result by parent and children.
  2348. *
  2349. * @param array $parent
  2350. * @param array $result
  2351. * @access public
  2352. */
  2353. public function buildReorderResult($parent, &$result)
  2354. {
  2355. foreach($parent as $key => $children)
  2356. {
  2357. $result[$key] = $key;
  2358. if(!empty($children)) $this->buildReorderResult($children, $result);
  2359. }
  2360. }
  2361. }