model.php 233 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924392539263927392839293930393139323933393439353936393739383939394039413942394339443945394639473948394939503951395239533954395539563957395839593960396139623963396439653966396739683969397039713972397339743975397639773978397939803981398239833984398539863987398839893990399139923993399439953996399739983999400040014002400340044005400640074008400940104011401240134014401540164017401840194020402140224023402440254026402740284029403040314032403340344035403640374038403940404041404240434044404540464047404840494050405140524053405440554056405740584059406040614062406340644065406640674068406940704071407240734074407540764077407840794080408140824083408440854086408740884089409040914092409340944095409640974098409941004101410241034104410541064107410841094110411141124113411441154116411741184119412041214122412341244125412641274128412941304131413241334134413541364137413841394140414141424143414441454146414741484149415041514152415341544155415641574158415941604161416241634164416541664167416841694170417141724173417441754176417741784179418041814182418341844185418641874188418941904191419241934194419541964197419841994200420142024203420442054206420742084209421042114212421342144215421642174218421942204221422242234224422542264227422842294230423142324233423442354236423742384239424042414242424342444245424642474248424942504251425242534254425542564257425842594260426142624263426442654266426742684269427042714272427342744275427642774278427942804281428242834284428542864287428842894290429142924293429442954296429742984299430043014302430343044305430643074308430943104311431243134314431543164317431843194320432143224323432443254326432743284329433043314332433343344335433643374338433943404341434243434344434543464347434843494350435143524353435443554356435743584359436043614362436343644365436643674368436943704371437243734374437543764377437843794380438143824383438443854386438743884389439043914392439343944395439643974398439944004401440244034404440544064407440844094410441144124413441444154416441744184419442044214422442344244425442644274428442944304431443244334434443544364437443844394440444144424443444444454446444744484449445044514452445344544455445644574458445944604461446244634464446544664467446844694470447144724473447444754476447744784479448044814482448344844485448644874488448944904491449244934494449544964497449844994500450145024503450445054506450745084509451045114512451345144515451645174518451945204521452245234524452545264527452845294530453145324533453445354536453745384539454045414542454345444545454645474548454945504551455245534554455545564557455845594560456145624563456445654566456745684569457045714572457345744575457645774578457945804581458245834584458545864587458845894590459145924593459445954596459745984599460046014602460346044605460646074608460946104611461246134614461546164617461846194620462146224623462446254626462746284629463046314632463346344635463646374638463946404641464246434644464546464647464846494650465146524653465446554656465746584659466046614662466346644665466646674668466946704671467246734674467546764677467846794680468146824683468446854686468746884689469046914692469346944695469646974698469947004701470247034704470547064707470847094710471147124713471447154716471747184719472047214722472347244725472647274728472947304731473247334734473547364737473847394740474147424743474447454746474747484749475047514752475347544755475647574758475947604761476247634764476547664767476847694770477147724773477447754776477747784779478047814782478347844785478647874788478947904791479247934794479547964797479847994800480148024803480448054806480748084809481048114812481348144815481648174818481948204821482248234824482548264827482848294830483148324833483448354836483748384839484048414842484348444845484648474848484948504851485248534854485548564857485848594860486148624863486448654866486748684869487048714872487348744875487648774878487948804881488248834884488548864887488848894890489148924893489448954896489748984899490049014902490349044905490649074908490949104911491249134914491549164917491849194920492149224923492449254926492749284929493049314932493349344935493649374938493949404941494249434944494549464947494849494950495149524953495449554956495749584959496049614962496349644965496649674968496949704971497249734974497549764977497849794980498149824983498449854986498749884989499049914992499349944995499649974998499950005001500250035004500550065007500850095010501150125013501450155016501750185019502050215022502350245025502650275028502950305031503250335034503550365037503850395040504150425043504450455046504750485049505050515052505350545055505650575058505950605061506250635064506550665067506850695070507150725073507450755076507750785079508050815082508350845085508650875088508950905091509250935094509550965097509850995100510151025103510451055106510751085109511051115112511351145115511651175118511951205121512251235124512551265127512851295130513151325133513451355136513751385139514051415142514351445145514651475148514951505151515251535154515551565157515851595160516151625163516451655166516751685169517051715172517351745175517651775178517951805181518251835184518551865187518851895190519151925193519451955196519751985199520052015202520352045205520652075208520952105211521252135214521552165217521852195220522152225223522452255226522752285229523052315232523352345235523652375238523952405241524252435244524552465247524852495250525152525253525452555256525752585259526052615262526352645265526652675268526952705271527252735274527552765277527852795280528152825283528452855286528752885289529052915292529352945295529652975298529953005301530253035304530553065307530853095310531153125313531453155316531753185319532053215322532353245325532653275328532953305331533253335334533553365337533853395340534153425343534453455346534753485349535053515352535353545355535653575358535953605361536253635364536553665367536853695370537153725373537453755376537753785379538053815382538353845385538653875388538953905391539253935394539553965397539853995400540154025403540454055406540754085409541054115412541354145415541654175418541954205421542254235424542554265427542854295430543154325433543454355436543754385439544054415442544354445445544654475448544954505451545254535454545554565457545854595460546154625463546454655466546754685469547054715472547354745475547654775478547954805481548254835484548554865487548854895490549154925493549454955496549754985499550055015502550355045505550655075508550955105511551255135514551555165517551855195520552155225523552455255526552755285529553055315532553355345535553655375538553955405541554255435544554555465547554855495550555155525553555455555556555755585559556055615562556355645565556655675568556955705571557255735574557555765577557855795580558155825583558455855586558755885589559055915592559355945595559655975598559956005601560256035604560556065607560856095610561156125613561456155616561756185619562056215622562356245625562656275628562956305631563256335634563556365637563856395640564156425643564456455646564756485649565056515652565356545655565656575658565956605661566256635664566556665667566856695670567156725673567456755676567756785679568056815682568356845685
  1. <?php
  2. /**
  3. * The model file of story module of ZenTaoPMS.
  4. *
  5. * @copyright Copyright 2009-2023 禅道软件(青岛)有限公司(ZenTao Software (Qingdao) Co., Ltd. www.cnezsoft.com)
  6. * @license ZPL(http://zpl.pub/page/zplv12.html) or AGPL(https://www.gnu.org/licenses/agpl-3.0.en.html)
  7. * @author Chunsheng Wang <chunsheng@cnezsoft.com>
  8. * @package story
  9. * @version $Id: model.php 5145 2013-07-15 06:47:26Z chencongzhi520@gmail.com $
  10. * @link https://www.zentao.net
  11. */
  12. class storyModel extends model
  13. {
  14. /**
  15. * Get a story by id.
  16. *
  17. * @param int $storyID
  18. * @param int $version
  19. * @param bool $setImgSize
  20. * @access public
  21. * @return object|false
  22. */
  23. public function getByID($storyID, $version = 0, $setImgSize = false)
  24. {
  25. if(common::isTutorialMode()) return $this->loadModel('tutorial')->getStoryByID($storyID);
  26. $story = $this->dao->select('*')->from(TABLE_STORY)->where('id')->eq($storyID)->fetch();
  27. if(!$story) return false;
  28. if($version == 0) $version = $story->version;
  29. $this->loadModel('file');
  30. $spec = $this->dao->select('title,spec,verify,files,docs,docVersions')->from(TABLE_STORYSPEC)->where('story')->eq($storyID)->andWhere('version')->eq($version)->fetch();
  31. $story->title = !empty($spec->title) ? $spec->title : '';
  32. $story->spec = !empty($spec->spec) ? $spec->spec : '';
  33. $story->verify = !empty($spec->verify) ? $spec->verify : '';
  34. $story->files = !empty($spec->files) ? $this->file->getByIdList($spec->files) : array();
  35. $story->docs = !empty($spec->docs) ? $spec->docs : '';
  36. $story->docVersions = !empty($spec->docVersions) ? json_decode($spec->docVersions, true) : array();
  37. $story->stages = $this->dao->select('*')->from(TABLE_STORYSTAGE)->where('story')->eq($storyID)->fetchPairs('branch', 'stage');
  38. /* Clear the extra field to display file. */
  39. foreach($story->files as $file) $file->extra = '';
  40. $story = $this->file->replaceImgURL($story, 'spec,verify');
  41. if($setImgSize) $story->spec = $this->file->setImgSize($story->spec);
  42. if($setImgSize) $story->verify = $this->file->setImgSize($story->verify);
  43. $storyIdList = $storyID . ($story->twins ? "," . trim($story->twins, ',') : '');
  44. $story->executions = $this->dao->select('t2.project, t2.id, t2.name, t2.status, t2.type, t2.multiple')->from(TABLE_PROJECTSTORY)->alias('t1')
  45. ->leftJoin(TABLE_EXECUTION)->alias('t2')->on('t1.project = t2.id')
  46. ->where('t2.type')->in('sprint,stage,kanban')
  47. ->andWhere('t1.story')->in($storyIdList)
  48. ->orderBy('t1.`order` DESC')
  49. ->fetchAll('id');
  50. $story->tasks = $this->dao->select('id,name,assignedTo,execution,project,status,consumed,`left`,type')->from(TABLE_TASK)->where('deleted')->eq(0)->andWhere('story')->in($storyIdList)->orderBy('id DESC')->fetchGroup('execution');
  51. if($this->config->vision == 'lite' && $story->tasks) $story->executions += $this->dao->select('project,id,name,status,type,multiple')->from(TABLE_EXECUTION)->where('id')->in(array_keys($story->tasks))->orderBy('`order` DESC')->fetchAll('id');
  52. if($story->parent > 0)
  53. {
  54. $parent = $this->dao->findById($story->parent)->from(TABLE_STORY)->fetch();
  55. $story->parentName = $parent->title;
  56. $story->parentType = $parent->type;
  57. $story->parentChanged = $story->parentVersion > 0 && $parent->version > $story->parentVersion && $parent->status == 'active';
  58. }
  59. if($story->toBug) $story->toBugTitle = $this->dao->findById($story->toBug)->from(TABLE_BUG)->fetch('title');
  60. if($story->fromStory) $story->sourceName = $this->dao->select('title')->from(TABLE_STORY)->where('id')->eq($story->fromStory)->fetch('title');
  61. $story->children = array();
  62. if($story->isParent == '1')
  63. {
  64. $childIdList = $this->getAllChildId($storyID, false);
  65. $story->children = $this->dao->select('*')->from(TABLE_STORY)->where('id')->in($childIdList)->andWhere('deleted')->eq(0)->orderBy('id_desc')->fetchAll('id', false);
  66. if(!empty($story->children)) $story->children = $this->storyTao->mergePlanTitleAndChildren($story->product, $story->children);
  67. }
  68. if($story->plan)
  69. {
  70. $plans = $this->dao->select('id,title,branch')->from(TABLE_PRODUCTPLAN)->where('id')->in($story->plan)->fetchAll('id');
  71. foreach($plans as $planID => $plan)
  72. {
  73. $story->planTitle[$planID] = $plan->title;
  74. if($plan->branch and !isset($story->stages[$plan->branch]) and empty($story->branch)) $story->stages[$plan->branch] = 'planned';
  75. }
  76. }
  77. $extraStories = $story->duplicateStory ? array($story->duplicateStory) : array();
  78. if(!empty($extraStories)) $story->extraStories = $this->dao->select('id,title,type')->from(TABLE_STORY)->where('id')->in($extraStories)->fetchAll('id');
  79. $story->hasOtherTypeChild = $this->dao->select('id')->from(TABLE_STORY)->where('parent')->eq($story->id)->andWhere('type')->ne($story->type)->andWhere('deleted')->eq('0')->fetch('id');
  80. $story->hasSameTypeChild = $this->dao->select('id')->from(TABLE_STORY)->where('parent')->eq($story->id)->andWhere('type')->eq($story->type)->andWhere('deleted')->eq('0')->fetch('id');
  81. $story->openedDate = helper::isZeroDate($story->openedDate) ? '' : substr($story->openedDate, 0, 19);
  82. $story->assignedDate = helper::isZeroDate($story->assignedDate) ? '' : substr($story->assignedDate, 0, 19);
  83. $story->reviewedDate = helper::isZeroDate($story->reviewedDate) ? '' : substr($story->reviewedDate, 0, 19);
  84. $story->closedDate = helper::isZeroDate($story->closedDate) ? '' : substr($story->closedDate, 0, 19);
  85. $story->lastEditedDate = helper::isZeroDate($story->lastEditedDate) ? '' : substr($story->lastEditedDate, 0, 19);
  86. return $story;
  87. }
  88. /**
  89. * Get story pairs.
  90. *
  91. * @param int $productID
  92. * @param int $planID
  93. * @param string $field
  94. * @param bool $hasParent true|false
  95. * @access public
  96. * @return array
  97. */
  98. public function getPairs($productID = 0, $planID = 0, $field = 'title', $hasParent = false)
  99. {
  100. return $this->dao->select("id, {$field}")->from(TABLE_STORY)
  101. ->where('deleted')->eq('0')
  102. ->beginIF($productID)->andWhere('product')->eq($productID)->fi()
  103. ->beginIF($planID)
  104. ->andWhere("CONCAT(',', `plan`, ',')")->like("%,{$planID},%")
  105. ->beginIF(!$hasParent)->andWhere("isParent")->eq('0')->fi()
  106. ->fi()
  107. ->fetchPairs();
  108. }
  109. /**
  110. * Get stories by idList.
  111. *
  112. * @param array|string $storyIdList
  113. * @param string $mode all
  114. * @access public
  115. * @return array
  116. */
  117. public function getByList($storyIdList, $mode = '')
  118. {
  119. if(empty($storyIdList)) return array();
  120. if(common::isTutorialMode())
  121. {
  122. $stories = $this->loadModel('tutorial')->getStories();
  123. foreach($stories as $story)
  124. {
  125. if(!in_array($story->id, $storyIdList)) unset($stories[$story->id]);
  126. }
  127. return $stories;
  128. }
  129. return $this->dao->select('t1.*, t1.mailto, t2.spec, t2.verify, t3.name as productTitle, t3.deleted as productDeleted')
  130. ->from(TABLE_STORY)->alias('t1')
  131. ->leftJoin(TABLE_STORYSPEC)->alias('t2')->on('t1.id=t2.story')
  132. ->leftJoin(TABLE_PRODUCT)->alias('t3')->on('t1.product=t3.id')
  133. ->where('t1.version=t2.version')
  134. ->andWhere('t1.id')->in($storyIdList)
  135. ->beginIF($mode != 'all')->andWhere('t1.deleted')->eq('0')->fi()
  136. ->beginIF($this->config->vision == 'or')->andWhere("FIND_IN_SET('or', t1.vision)")->fi()
  137. ->fetchAll('id', false);
  138. }
  139. /**
  140. * 获取指定的需求 id name 的键值对。
  141. * Get pairs by list.
  142. *
  143. * @param array|string $storyIdList
  144. * @access public
  145. * @return array
  146. */
  147. public function getPairsByList($storyIdList)
  148. {
  149. $storyPairs = $this->dao->select('id, title')->from(TABLE_STORY)->where('id')->in($storyIdList)->beginIF($this->config->vision == 'or')->andWhere("FIND_IN_SET('or', t1.vision)")->fi()->fetchPairs();
  150. return array(0 => '') + $storyPairs;
  151. }
  152. /**
  153. * 获取执行中已经为该需求创建了测试类型任务的需求ID的键值对。
  154. * Get the key-value pairs for story ID which the test type task has been created for this story in the execution.
  155. *
  156. * @param array $storyIdList
  157. * @param int $executionID
  158. * @access public
  159. * @return array
  160. */
  161. public function getTestStories($storyIdList, $executionID)
  162. {
  163. return $this->dao->select('story')->from(TABLE_TASK)
  164. ->where('execution')->eq($executionID)
  165. ->andWhere('type')->eq('test')
  166. ->andWhere('story')->in($storyIdList)
  167. ->andWhere('deleted')->eq('0')
  168. ->fetchPairs('story', 'story');
  169. }
  170. /**
  171. * Get story specs.
  172. *
  173. * @param array $storyIdList
  174. * @access public
  175. * @return array
  176. */
  177. public function getStorySpecs($storyIdList)
  178. {
  179. return $this->dao->select('story,spec,verify')->from(TABLE_STORYSPEC)
  180. ->where('story')->in($storyIdList)
  181. ->orderBy('version')
  182. ->fetchAll('story');
  183. }
  184. /**
  185. * Get affected things.
  186. *
  187. * @param object $story
  188. * @access public
  189. * @return object
  190. */
  191. public function getAffectedScope($story)
  192. {
  193. $users = $this->loadModel('user')->getPairs('pofirst|nodeleted', "{$story->lastEditedBy},{$story->openedBy},{$story->assignedTo}");
  194. if($story->type == 'story' && $story->isParent == '0')
  195. {
  196. $story = $this->storyTao->getAffectedProjects($story, $users);
  197. $story = $this->storyTao->getAffectedBugs($story, $users);
  198. $story = $this->storyTao->getAffectedCases($story, $users);
  199. $story = $this->storyTao->getAffectedTwins($story, $users);
  200. }
  201. else
  202. {
  203. $story = $this->storyTao->getAffectedChildren($story, $users);
  204. }
  205. return $story;
  206. }
  207. /**
  208. * Get requirements for story.
  209. *
  210. * @param int $productID
  211. * @access public
  212. * @return array
  213. */
  214. public function getRequirements($productID)
  215. {
  216. return $this->dao->select('id,title')->from(TABLE_STORY)
  217. ->where('deleted')->eq(0)
  218. ->andWhere('product')->eq($productID)
  219. ->andWhere('type')->eq('requirement')
  220. ->andWhere('status')->notIN('draft,closed')
  221. ->fetchPairs('id', 'title');
  222. }
  223. /**
  224. * Get stories list of a execution.
  225. *
  226. * @param int|array $executionID
  227. * @param int $productID
  228. * @param string $orderBy
  229. * @param string $browseType
  230. * @param string $param
  231. * @param string $storyType
  232. * @param array|string $excludeStories
  233. * @param object|null $pager
  234. * @access public
  235. * @return array
  236. */
  237. public function getExecutionStories($executionID = 0, $productID = 0, $orderBy = 't1.`order`_desc', $browseType = 'byModule', $param = '0', $storyType = 'story', $excludeStories = '', $pager = null)
  238. {
  239. if(commonModel::isTutorialMode()) return $this->loadModel('tutorial')->getExecutionStories();
  240. if(empty($executionID)) return array();
  241. if(is_array($executionID))
  242. {
  243. $module = 'execution';
  244. }
  245. else
  246. {
  247. $execution = $this->dao->select('*')->from(TABLE_PROJECT)->where('id')->eq($executionID)->fetch();
  248. $module = $execution->type == 'project' ? 'project' : 'execution';
  249. }
  250. $sqlCondition = '';
  251. $showGrades = isset($this->config->{$module}->showGrades) ? $this->config->{$module}->showGrades : null;
  252. if($showGrades && $this->config->vision != 'lite')
  253. {
  254. $items = explode(',', $showGrades);
  255. $conditions = array();
  256. foreach($items as $item)
  257. {
  258. preg_match('/^([a-zA-Z]+)(\d+)$/', $item, $matches);
  259. $type = $matches[1];
  260. $grade = $matches[2];
  261. $conditions[$type][] = $grade;
  262. }
  263. $sqlCondition = '(';
  264. foreach($conditions as $type => $grades) $sqlCondition .= "(t2.type = '$type' AND t2.grade " . helper::dbIN($grades) . ") OR ";
  265. $sqlCondition = rtrim($sqlCondition, 'OR ');
  266. $sqlCondition .= ')';
  267. }
  268. /* 格式化参数。 */
  269. $orderBy = str_replace('branch_', 't2.branch_', $orderBy);
  270. $browseType = strtolower($browseType);
  271. if(is_string($excludeStories)) $excludeStories = array_filter(explode(',', $excludeStories));
  272. /* 获取需求。 */
  273. if($browseType == 'bysearch') $stories = $this->storyTao->getExecutionStoriesBySearch($executionID, (int)$param, $productID, $orderBy, $storyType, $sqlCondition, $excludeStories, $pager);
  274. if($browseType != 'bysearch')
  275. {
  276. /* 根据请求类型和参数,获取查询要用到的条件。 */
  277. $modules = $this->storyTao->getModules4ExecutionStories($browseType, $param);
  278. $storyIdList = is_array($executionID) ? array() : $this->storyTao->getIdListOfExecutionsByProjectID($browseType, $executionID);
  279. $productParam = ($browseType == 'byproduct' and $param) ? $param : $productID;
  280. $branchParam = ($browseType == 'bybranch' and $param !== '') ? $param : (string)$this->cookie->storyBranchParam;
  281. /* 设置查询需求的公共 DAO 变量。 */
  282. $browseType = (!empty($browseType) and strpos('bymodule|byproduct', $browseType) !== false and $this->session->storyBrowseType) ? $this->session->storyBrowseType : $browseType;
  283. $storyDAO = $this->dao->select("DISTINCT t1.*, t2.*, t2.`path`, t2.`plan`, IF(t2.`pri` = 0, {$this->config->maxPriValue}, t2.`pri`) as priOrder, t3.type as productType, t2.version as version")->from(TABLE_PROJECTSTORY)->alias('t1')
  284. ->leftJoin(TABLE_STORY)->alias('t2')->on('t1.story = t2.id')
  285. ->leftJoin(TABLE_PRODUCT)->alias('t3')->on('t2.product = t3.id')
  286. ->where('t1.project')->in($executionID)
  287. ->andWhere('t2.deleted')->eq(0)
  288. ->andWhere('t3.deleted')->eq(0)
  289. ->beginIF(strpos('withoutparent', $browseType) !== false)->andWhere('t2.isParent')->eq('0')->fi()
  290. ->beginIF($storyType != 'all')->andWhere('t2.type')->in($storyType)->fi()
  291. ->beginIF($sqlCondition)->andWhere($sqlCondition)->fi()
  292. ->beginIF($excludeStories)->andWhere('t2.id')->notIN($excludeStories)->fi()
  293. ->beginIF($this->session->storyBrowseType and strpos('changing|', $this->session->storyBrowseType) !== false)->andWhere('t2.status')->in($this->storyTao->getUnclosedStatusKeys())->fi()
  294. ->beginIF($modules)->andWhere('t2.module')->in($modules)->fi();
  295. /* 根据传入的 ID 是项目还是执行分别查询需求。 */
  296. if($module == 'project') $stories = $this->storyTao->fetchProjectStories($storyDAO, $productID, $browseType, $branchParam, $storyIdList, $orderBy, $pager, $execution);
  297. if($module != 'project') $stories = $this->storyTao->fetchExecutionStories($storyDAO, (int)$productParam, $browseType, $branchParam, $orderBy, $pager);
  298. }
  299. $stories = $this->storyTao->fixBranchStoryStage($stories);
  300. return $this->storyTao->mergePlanTitleAndChildren($productID, $stories, $storyType);
  301. }
  302. /**
  303. * 获取多个执行的关联需求。
  304. * get execution stories by execution id list.
  305. *
  306. * @param string $executionIdList
  307. * @param int $productID
  308. * @param string $orderBy
  309. * @param string $type bybranch
  310. * @param string $param
  311. * @param string $storyType story|requirement
  312. * @param array|string $excludeStories
  313. * @param object|null $pager
  314. * @access public
  315. * @return void
  316. */
  317. public function batchGetExecutionStories($executionIdList = '', $productID = 0, $orderBy = 't1.`order`_desc', $type = 'byModule', $param = '0', $storyType = 'story', $excludeStories = '', $pager = null)
  318. {
  319. if(common::isTutorialMode()) return $this->loadModel('tutorial')->getStories();
  320. if(empty($executionIdList)) return array();
  321. /* 格式化参数。 */
  322. $type = strtolower($type);
  323. if(is_string($excludeStories))$excludeStories = explode(',', $excludeStories);
  324. $executions = $this->dao->select('*')->from(TABLE_PROJECT)->where('id')->in($executionIdList)->fetchAll('id');
  325. $hasExecution = false;
  326. foreach($executions as $execution)
  327. {
  328. if($execution->type != 'project') $hasExecution = true;
  329. }
  330. $modules = $this->storyTao->getModules4ExecutionStories($type, $param);
  331. $unclosedStatus = $this->lang->story->statusList;
  332. unset($unclosedStatus['closed']);
  333. $branchParam = ($type == 'bybranch' and $param !== '') ? $param : (string)$this->cookie->storyBranchParam;
  334. if(strpos($orderBy, 'version_') !== false) $orderBy = str_replace('id_', 't2.version_', $orderBy);
  335. if(strpos($orderBy, 'id_') !== false) $orderBy = str_replace('id_', 't2.id_', $orderBy);
  336. $stories = $this->dao->select("distinct t1.*, t2.`path`, t2.`plan`, 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')
  337. ->leftJoin(TABLE_STORY)->alias('t2')->on('t1.story = t2.id')
  338. ->leftJoin(TABLE_PRODUCT)->alias('t3')->on('t2.product = t3.id')
  339. ->where('t1.project')->in($executionIdList)
  340. ->andWhere('t2.type')->eq($storyType)
  341. ->andWhere('t2.parent')->ge(0)
  342. ->andWhere('t2.deleted')->eq(0)
  343. ->andWhere('t3.deleted')->eq(0)
  344. ->beginIF($excludeStories)->andWhere('t2.id')->notIN($excludeStories)->fi()
  345. ->beginIF($this->session->storyBrowseType and strpos('changing|', $this->session->storyBrowseType) !== false)->andWhere('t2.status')->in(array_keys($unclosedStatus))->fi()
  346. ->beginIF($modules)->andWhere('t2.module')->in($modules)->fi()
  347. ->beginIF(!empty($productID))->andWhere('t1.product')->eq($productID)->fi()
  348. ->beginIF($type == 'bybranch' and $branchParam !== '')->andWhere('t2.branch')->in("0,$branchParam")->fi()
  349. ->beginIF($hasExecution)
  350. ->beginIF($this->session->executionStoryBrowseType and strpos('changing|', $this->session->executionStoryBrowseType) !== false)->andWhere('t2.status')->in(array_keys($unclosedStatus))->fi()
  351. ->fi()
  352. ->orderBy($orderBy)
  353. ->page($pager, 't2.id')
  354. ->fetchAll('id');
  355. return $this->storyTao->mergePlanTitleAndChildren($productID, $stories, $storyType);
  356. }
  357. /**
  358. * 获取执行下的需求键值对。
  359. * Get stories pairs of a execution.
  360. *
  361. * @param int $executionID
  362. * @param int $productID
  363. * @param string|int $branch 0|all|integer
  364. * @param array|string|int $moduleIdList
  365. * @param string $type full|short
  366. * @param string $status all|noclosed|changing|active|draft|closed|reviewing
  367. * @param string $storyType story|epic|requirement
  368. * @param bool $hasParent
  369. * @access public
  370. * @return array
  371. */
  372. public function getExecutionStoryPairs($executionID = 0, $productID = 0, $branch = 'all', $moduleIdList = '', $type = 'full', $status = 'all', $storyType = '', $hasParent = true)
  373. {
  374. if(commonModel::isTutorialMode()) return $this->loadModel('tutorial')->getExecutionStoryPairs();
  375. $stories = $this->dao->select('t2.id, t2.title, t2.module, t2.pri, t2.estimate, t3.name AS product')
  376. ->from(TABLE_PROJECTSTORY)->alias('t1')
  377. ->leftJoin(TABLE_STORY)->alias('t2')->on('t1.story = t2.id')
  378. ->leftJoin(TABLE_PRODUCT)->alias('t3')->on('t1.product = t3.id')
  379. ->where('t1.project')->eq($executionID)
  380. ->beginIF($storyType)->andWhere('t2.type')->in($storyType)->fi()
  381. ->beginIF(!$hasParent)->andWhere('t2.isParent')->eq('0')->fi()
  382. ->andWhere('t2.deleted')->eq('0')
  383. ->beginIF($productID)->andWhere('t2.product')->eq($productID)->fi()
  384. ->beginIF($branch !== 'all')->andWhere('t2.branch')->in("0,$branch")->fi()
  385. ->beginIF($moduleIdList)->andWhere('t2.module')->in($moduleIdList)->fi()
  386. ->beginIF($status == 'unclosed')->andWhere('t2.status')->ne('closed')->fi()
  387. ->beginIF($status == 'review')->andWhere('t2.status')->in('draft,changing')->fi()
  388. ->beginIF($status == 'active')->andWhere('t2.status')->eq('active')->fi()
  389. ->orderBy('t1.`order` desc, t1.`story` desc')
  390. ->fetchAll('id');
  391. return empty($stories) ? array() : $this->formatStories($stories, $type);
  392. }
  393. /**
  394. * Get stories list of a plan.
  395. *
  396. * @param int $planID
  397. * @param string $status
  398. * @param string $orderBy
  399. * @param object $pager
  400. * @access public
  401. * @return array
  402. */
  403. public function getPlanStories($planID, $status = 'all', $orderBy = 'id_desc', $pager = null)
  404. {
  405. if(common::isTutorialMode()) return array();
  406. if(strpos($orderBy, 'module') !== false)
  407. {
  408. $orderBy = (strpos($orderBy, 'module_asc') !== false) ? 't3.path asc' : 't3.path desc';
  409. $stories = $this->dao->select('distinct t1.story, t1.`plan`, t1.order, t2.*, t3.`path`')
  410. ->from(TABLE_PLANSTORY)->alias('t1')
  411. ->leftJoin(TABLE_STORY)->alias('t2')->on('t1.story = t2.id')
  412. ->leftJoin(TABLE_MODULE)->alias('t3')->on('t2.module = t3.id')
  413. ->where('t1.plan')->eq($planID)
  414. ->beginIF($status and $status != 'all')->andWhere('t2.status')->in($status)->fi()
  415. ->andWhere('t2.deleted')->eq(0)
  416. ->orderBy($orderBy)
  417. ->page($pager)
  418. ->fetchAll('id');
  419. }
  420. else
  421. {
  422. $stories = $this->dao->select("distinct t1.story, t1.`plan`, t1.order, t2.*, IF(t2.`pri` = 0, {$this->config->maxPriValue}, t2.`pri`) as priOrder")
  423. ->from(TABLE_PLANSTORY)->alias('t1')
  424. ->leftJoin(TABLE_STORY)->alias('t2')->on('t1.story = t2.id')
  425. ->where('t1.plan')->eq($planID)
  426. ->beginIF($status and $status != 'all')->andWhere('t2.status')->in($status)->fi()
  427. ->andWhere('t2.deleted')->eq(0)
  428. ->orderBy($orderBy)
  429. ->page($pager)
  430. ->fetchAll('id');
  431. }
  432. $this->loadModel('common')->saveQueryCondition($this->dao->get(), 'story', false);
  433. $this->common->saveQueryCondition($this->dao->get(), 'epic', false);
  434. $this->common->saveQueryCondition($this->dao->get(), 'requirement', false);
  435. /* Add parent list for display. */
  436. foreach($stories as $story)
  437. {
  438. $story->parent = array();
  439. foreach(explode(',', trim($story->path, ',')) as $parentID)
  440. {
  441. if(!$parentID) continue;
  442. if($parentID == $story->id) continue;
  443. $story->parent[] = (int)$parentID;
  444. }
  445. }
  446. return $stories;
  447. }
  448. /**
  449. * Get stories by plan id list.
  450. *
  451. * @param string|array $planIdList
  452. * @access public
  453. * @return array
  454. */
  455. public function getStoriesByPlanIdList($planIdList = '')
  456. {
  457. return $this->dao->select('t1.plan as planID, t2.*')->from(TABLE_PLANSTORY)->alias('t1')
  458. ->leftJoin(TABLE_STORY)->alias('t2')->on('t1.story=t2.id')
  459. ->where('t2.deleted')->eq(0)
  460. ->beginIF($planIdList)->andWhere('t1.plan')->in($planIdList)->fi()
  461. ->fetchGroup('planID', 'id');
  462. }
  463. /**
  464. * 批量获取关联传入项目ID的需求,并按照项目分组。
  465. * Fetch stories by project id list.
  466. *
  467. * @param array $projectIdList
  468. * @param string $storyType story|requirement|epic
  469. * @access public
  470. * @return array
  471. */
  472. public function fetchStoriesByProjectIdList($projectIdList = array(), $storyType = '')
  473. {
  474. return $this->dao->select("t2.*,t1.project")->from(TABLE_PROJECTSTORY)->alias('t1')
  475. ->leftJoin(TABLE_STORY)->alias('t2')->on('t1.story = t2.id')
  476. ->where('t1.project')->in($projectIdList)
  477. ->andWhere('t2.deleted')->eq(0)
  478. ->beginIF(strpos(',story,requirement,epic,', ",{$storyType},") !== false)->andWhere('t2.type')->in($storyType)->fi()
  479. ->fetchGroup('project', 'id');
  480. }
  481. /**
  482. * Create a story.
  483. *
  484. * @param object $story
  485. * @param int $executionID
  486. * @param int $bugID
  487. * @param string $extra
  488. * @param int $todoID
  489. *
  490. * @access public
  491. * @return int|false the id of the created story or false when error.
  492. */
  493. public function create($story, $executionID = 0, $bugID = 0, $extra = '', $todoID = 0)
  494. {
  495. if(commonModel::isTutorialMode()) return false;
  496. if(isset($story->estimate)) $story->estimate = round((float)$story->estimate, 2);
  497. $storyID = $this->storyTao->doCreateStory($story);
  498. if(!$storyID) return false;
  499. /* Upload files. */
  500. $this->loadModel('action');
  501. $this->loadModel('file')->updateObjectID($this->post->uid, $storyID, $story->type);
  502. $files = $this->file->saveUpload($story->type, $storyID, 1);
  503. if(defined('RUN_MODE') && RUN_MODE === 'api')
  504. {
  505. $uidFiles = $this->file->getUploadByUID($this->post->uid);
  506. $files = $files ? ($files + $uidFiles) : $uidFiles;
  507. }
  508. /* Add story spec verify. */
  509. $this->storyTao->doCreateSpec($storyID, $story, $files ?: '');
  510. $extraList = $this->storyTao->parseExtra($extra);
  511. $storyFrom = isset($extraList['fromType']) ? $extraList['fromType'] : '';
  512. $storyFromID = isset($extraList['fromID']) ? $extraList['fromID'] : '';
  513. $extra = $bugID == 0 ? $storyFromID : $bugID;
  514. if($executionID) $this->storyTao->linkToExecutionForCreate($executionID, $storyID, $story, (string)$extra);
  515. if($bugID) $this->storyTao->closeBugWhenToStory($bugID, $storyID);
  516. if(!empty($story->reviewer)) $this->storyTao->doCreateReviewer($storyID, $story->reviewer);
  517. if(!empty($story->parent))
  518. {
  519. $this->subdivide($story->parent, array($storyID));
  520. $this->updateParentStatus($storyID, $story->parent, false);
  521. }
  522. else
  523. {
  524. $this->dao->update(TABLE_STORY)->set('root')->eq($storyID)->set('path')->eq(",{$storyID},")->where('id')->eq($storyID)->exec();
  525. }
  526. if(!empty($story->plan))
  527. {
  528. $this->updateStoryOrderOfPlan($storyID, (string)$story->plan); // Set story order in this plan.
  529. foreach(explode(',', (string)$story->plan) as $planID)
  530. {
  531. if(!$planID) continue;
  532. $this->action->create('productplan', (int)$planID, 'linkstory', '', $storyID);
  533. }
  534. }
  535. $this->setStage($storyID);
  536. $this->loadModel('score')->create('story', 'create',$storyID);
  537. /* Create actions. Record submit review action. */
  538. $bugAction = empty($storyFrom) ? 'Opened' : 'From' . ucfirst($storyFrom);
  539. $action = $bugID == 0 ? $bugAction : 'Frombug';
  540. $this->action->create('story', $storyID, $action, '', $extra);
  541. if($story->status == 'reviewing') $this->action->create('story', $storyID, 'submitReview');
  542. if(!empty($story->assignedTo)) $this->action->create('story', $storyID, 'Assigned', '', $story->assignedTo);
  543. if($todoID > 0)
  544. {
  545. $this->dao->update(TABLE_TODO)->set('status')->eq('done')->where('id')->eq($todoID)->exec();
  546. $this->action->create('todo', $todoID, 'finished', '', "STORY:$storyID");
  547. if($this->config->edition != 'open')
  548. {
  549. $todo = $this->dao->select('type, objectID')->from(TABLE_TODO)->where('id')->eq($todoID)->fetch();
  550. if($todo->type == 'feedback' && $todo->objectID) $this->loadModel('feedback')->updateStatus('todo', $todo->objectID, 'done', '', $todoID);
  551. }
  552. }
  553. return $storyID;
  554. }
  555. /**
  556. * 创建孪生需求。
  557. * Create twins stories.
  558. *
  559. * @param object $storyData
  560. * @param int $objectID
  561. * @param int $bugID
  562. * @param string $extra
  563. * @param int $todoID
  564. *
  565. * @access public
  566. * @return int|false
  567. */
  568. public function createTwins($storyData, $objectID, $bugID, $extra = '', $todoID = 0)
  569. {
  570. if(empty($storyData->branches)) return $this->create($storyData, $objectID, $bugID, $extra, $todoID);
  571. $storyIdList = array();
  572. $mainStoryID = 0;
  573. foreach($storyData->branches as $key => $branchID)
  574. {
  575. $storyData->branch = (int)$branchID;
  576. $storyData->module = $storyData->modules[$key];
  577. $storyData->plan = isset($storyData->plans[$key]) ? $storyData->plans[$key] : 0;
  578. $storyID = $this->create($storyData, $objectID, $bugID, $extra, $todoID);
  579. $storyIdList[$storyID] = $storyID;
  580. if(empty($mainStoryID)) $mainStoryID = $storyID;
  581. }
  582. $this->storyTao->updateTwins($storyIdList, $mainStoryID);
  583. return $mainStoryID;
  584. }
  585. /**
  586. * 解除孪生需求。
  587. * Relieve twins stories.
  588. *
  589. * @param int $productID
  590. * @param int $storyID
  591. * @access public
  592. * @return bool
  593. */
  594. public function relieveTwins($productID, $storyID)
  595. {
  596. /* batchUnset twinID from twins.*/
  597. $this->dao->update(TABLE_STORY)->set("twins = REPLACE(twins, ',$storyID,', ',')")->where('product')->eq($productID)->exec();
  598. /* Update twins to empty by twinID and if twins eq ','.*/
  599. $this->dao->update(TABLE_STORY)->set('twins')->eq('')->where('id')->eq($storyID)->orWhere('twins')->eq(',')->exec();
  600. return !dao::isError();
  601. }
  602. /**
  603. * Create story from gitlab issue.
  604. *
  605. * @param object $story
  606. * @param int $executionID
  607. * @access public
  608. * @return int
  609. */
  610. public function createStoryFromGitlabIssue($story, $executionID)
  611. {
  612. $story->status = 'active';
  613. $story->stage = 'projected';
  614. $story->openedBy = $this->app->user->account;
  615. $story->version = 1;
  616. $story->pri = 3;
  617. $story->assignedDate = isset($story->assignedTo) ? helper::now() : null;
  618. if(isset($story->execution)) unset($story->execution);
  619. $requiredFields = $this->config->story->create->requiredFields;
  620. $this->dao->insert(TABLE_STORY)->data($story, 'spec,verify,gitlab,gitlabProject')->autoCheck()->batchCheck($requiredFields, 'notempty')->exec();
  621. if(dao::isError()) return false;
  622. $storyID = $this->dao->lastInsertID();
  623. $data = new stdclass();
  624. $data->story = $storyID;
  625. $data->version = 1;
  626. $data->title = $story->title;
  627. $data->spec = $story->spec;
  628. $data->verify = $story->spec;
  629. $this->dao->insert(TABLE_STORYSPEC)->data($data)->exec();
  630. /* Link story to execution. */
  631. $this->linkStory($executionID, $story->product, $storyID);
  632. return $storyID;
  633. }
  634. /**
  635. * Batch create stories.
  636. *
  637. * @param array $stories
  638. * @access public
  639. * @return array
  640. */
  641. public function batchCreate($stories)
  642. {
  643. $this->loadModel('action');
  644. $storyIdList = array();
  645. $link2Plans = array();
  646. foreach($stories as $i => $story)
  647. {
  648. if(isset($story->estimate)) $story->estimate = round((float)$story->estimate, 2);
  649. $storyID = $this->storyTao->doCreateStory($story);
  650. if(!$storyID) return array();
  651. $this->storyTao->doCreateSpec($storyID, $story);
  652. if(!empty($story->parent))
  653. {
  654. $this->subdivide($story->parent, array($storyID));
  655. $this->updateParentStatus($storyID, $story->parent, false);
  656. }
  657. else
  658. {
  659. $this->dao->update(TABLE_STORY)->set('root')->eq($storyID)->set('path')->eq(",{$storyID},")->where('id')->eq($storyID)->exec();
  660. }
  661. /* Update product plan stories order. */
  662. if(!empty($story->reviewer)) $this->storyTao->doCreateReviewer($storyID, $story->reviewer);
  663. if($story->plan)
  664. {
  665. $this->updateStoryOrderOfPlan($storyID, (string)$story->plan);
  666. foreach(explode(',', (string)$story->plan) as $planID)
  667. {
  668. if(!$planID) continue;
  669. $link2Plans[$planID] = empty($link2Plans[$planID]) ? $storyID : "{$link2Plans[$planID]},$storyID";
  670. }
  671. }
  672. /* 拆分时,如果父需求已经在已计划、研发立项阶段,则不需要通过拆分的子需求推算父需求的阶段。*/
  673. if(!empty($story->parent))
  674. {
  675. $parentStage = $this->dao->select('stage')->from(TABLE_STORY)->where('id')->eq($story->parent)->fetch('stage');
  676. if(!in_array($parentStage, array('planned', 'projected'))) $this->setStage($storyID);
  677. }
  678. $this->executeHooks($storyID);
  679. $this->action->create('story', $storyID, 'Opened', '');
  680. if(!empty($story->assignedTo)) $this->action->create('story', $storyID, 'Assigned', '', $story->assignedTo);
  681. $storyIdList[$i] = $storyID;
  682. }
  683. if(!dao::isError())
  684. {
  685. /* Remove upload image file and session. */
  686. if($this->session->storyImagesFile)
  687. {
  688. $file = current($_SESSION['storyImagesFile']);
  689. $realPath = dirname($file['realpath']);
  690. if(is_dir($realPath)) $this->app->loadClass('zfile')->removeDir($realPath);
  691. unset($_SESSION['storyImagesFile']);
  692. }
  693. $this->loadModel('score')->create('story', 'create',$storyID);
  694. $this->score->create('ajax', 'batchCreate');
  695. foreach($link2Plans as $planID => $stories) $this->action->create('productplan', $planID, 'linkstory', '', $stories);
  696. }
  697. return $storyIdList;
  698. }
  699. /**
  700. * Change a story.
  701. *
  702. * @param int $storyID
  703. * @param object $story
  704. * @access public
  705. * @return array the change of the story.
  706. */
  707. public function change($storyID, $story)
  708. {
  709. $oldStory = $this->getById($storyID);
  710. $this->dao->update(TABLE_STORY)->data($story, 'spec,verify,deleteFiles,renameFiles,files,relievedTwins,reviewer,reviewerHasChanged')
  711. ->autoCheck()
  712. ->batchCheck($this->config->{$oldStory->type}->change->requiredFields, 'notempty')
  713. ->checkFlow()
  714. ->where('id')->eq($storyID)->exec();
  715. if(dao::isError()) return false;
  716. $this->loadModel('file')->updateObjectID($this->post->uid, $storyID, $oldStory->type);
  717. $specChanged = $oldStory->version != $story->version;
  718. if($specChanged)
  719. {
  720. $this->loadModel('file')->processFileDiffsForObject($oldStory->type, $oldStory, $story, (string)$story->version);
  721. $this->storyTao->doCreateSpec($storyID, $story, $story->files);
  722. if(!empty($story->reviewer)) $this->storyTao->doCreateReviewer($storyID, $story->reviewer, $story->version);
  723. /* Sync twins. */
  724. if(!isset($story->relievedTwins) and !empty($oldStory->twins))
  725. {
  726. foreach(explode(',', trim($oldStory->twins, ',')) as $twinID)
  727. {
  728. $this->storyTao->doCreateSpec((int)$twinID, $story, $story->files);
  729. if(!empty($story->reviewer)) $this->storyTao->doCreateReviewer((int)$twinID, $story->reviewer, $story->version);
  730. }
  731. }
  732. if($story->reviewerHasChanged)
  733. {
  734. $oldStoryReviewers = $this->getReviewerPairs($storyID, $oldStory->version);
  735. $oldStory->reviewers = implode(',', array_keys($oldStoryReviewers));
  736. $story->reviewers = implode(',', $story->reviewer);
  737. }
  738. }
  739. $changes = common::createChanges($oldStory, $story);
  740. if(isset($story->relievedTwins))
  741. {
  742. $this->relieveTwins($oldStory->product, $storyID);
  743. if(!dao::isError()) $this->loadModel('action')->create('story', $storyID, 'relieved');
  744. }
  745. elseif(!empty($oldStory->twins))
  746. {
  747. $this->syncTwins($oldStory->id, $oldStory->twins, $changes, 'Changed');
  748. }
  749. return $changes;
  750. }
  751. /**
  752. * Update a story.
  753. *
  754. * @param int $storyID
  755. * @access public
  756. * @return bool|int
  757. * @param string|bool $comment
  758. * @param object $story
  759. */
  760. public function update($storyID, $story, $comment = '')
  761. {
  762. $oldStory = $this->getByID($storyID);
  763. /* Relieve twins when change product. */
  764. if(!empty($oldStory->twins) and $story->product != $oldStory->product)
  765. {
  766. $this->relieveTwins($oldStory->product, $storyID);
  767. $oldStory->twins = '';
  768. }
  769. if($oldStory->stage != 'verified' && $story->stage == 'verified') $story->verifiedDate = helper::now();
  770. if(isset($story->estimate)) $story->estimate = round((float)$story->estimate, 2);
  771. $moduleName = $this->app->rawModule;
  772. $this->dao->update(TABLE_STORY)->data($story, 'reviewer,spec,verify,deleteFiles,renameFiles,files,finalResult,oldDocs,docVersions')
  773. ->autoCheck()
  774. ->batchCheck($this->config->{$moduleName}->edit->requiredFields, 'notempty')
  775. ->checkIF(!empty($story->closedBy), 'closedReason', 'notempty')
  776. ->checkIF(isset($story->closedReason) and $story->closedReason == 'done', 'stage', 'notempty')
  777. ->checkIF(isset($story->closedReason) and $story->closedReason == 'duplicate', 'duplicateStory', 'notempty')
  778. ->checkIF($story->notifyEmail, 'notifyEmail', 'email')
  779. ->checkFlow()
  780. ->where('id')->eq((int)$storyID)->exec();
  781. if(dao::isError()) return false;
  782. $this->loadModel('action');
  783. $this->loadModel('file')->updateObjectID($this->post->uid, $storyID, $oldStory->type);
  784. $this->file->processFileDiffsForObject($oldStory->type, $oldStory, $story, (string)$oldStory->version);
  785. $this->storyTao->doUpdateSpec($storyID, $story, $oldStory);
  786. $this->storyTao->doUpdateLinkStories($storyID, $story, $oldStory);
  787. if($story->product != $oldStory->product || $story->branch != $oldStory->branch)
  788. {
  789. $this->loadModel('productplan');
  790. $oldPlans = explode(',', $oldStory->plan);
  791. foreach($oldPlans as $oldPlanID)
  792. {
  793. if(!$oldPlanID) continue;
  794. $this->productplan->unlinkStory($storyID, (int)$oldPlanID);
  795. }
  796. $this->dao->update(TABLE_PROJECTSTORY)->set('product')->eq($story->product)->where('story')->eq($storyID)->exec();
  797. $childStories = $this->getAllChildId($storyID, false);
  798. $childStoryAndPlan = $this->dao->select('id,plan')->from(TABLE_STORY)->where('id')->in($childStories)->fetchPairs();
  799. $story->id = $storyID;
  800. foreach($childStoryAndPlan as $childStoryID => $planIDList)
  801. {
  802. $this->updateStoryProduct($childStoryID, $story, $story->product);
  803. /* 切换产品后,移除子需求与原产品下计划的关联。*/
  804. $plans = explode(',', $planIDList);
  805. foreach($plans as $planID)
  806. {
  807. if(!$planID) continue;
  808. $this->productplan->unlinkStory($childStoryID, (int)$planID);
  809. }
  810. }
  811. }
  812. if($story->grade != $oldStory->grade) $this->syncGrade($oldStory, $story);
  813. $parentChanged = $story->parent != $oldStory->parent;
  814. if($parentChanged) $this->doChangeParent($storyID, $story, $oldStory);
  815. if($oldStory->parent > 0) $this->updateParentStatus($storyID, $oldStory->parent, !$parentChanged);
  816. if($story->parent > 0) $this->updateParentStatus($storyID, $story->parent, !$parentChanged);
  817. $this->storyTao->computeParentStage($story);
  818. /* Set new stage and update story sort of plan when story plan has changed. */
  819. if($oldStory->plan != $story->plan)
  820. {
  821. $this->updateStoryOrderOfPlan($storyID, (string)$story->plan, $oldStory->plan); // Insert a new story sort in this plan.
  822. if(empty($oldStory->plan) or empty($story->plan)) $this->setStage($storyID); // Set new stage for this story.
  823. if(!empty($oldStory->plan)) $this->action->create('productplan', (int)$oldStory->plan, 'unlinkstory', '', $storyID);
  824. if(!empty($story->plan)) $this->action->create('productplan', (int)$story->plan, 'linkstory', '', $storyID);
  825. }
  826. if(isset($story->stage) and $oldStory->stage != $story->stage)
  827. {
  828. list($linkedBranches, $linkedProjects) = $this->storyTao->getLinkedBranchesAndProjects($storyID);
  829. foreach($linkedProjects as $linkedProject)
  830. {
  831. $this->dao->update(TABLE_STORYSTAGE)->set('stage')->eq($story->stage)->where('story')->eq((int)$storyID)->andWhere('branch')->in($linkedProject->branches)->exec();
  832. }
  833. $executionIdList = $this->dao->select('t1.project')->from(TABLE_PROJECTSTORY)->alias('t1')
  834. ->leftJoin(TABLE_PROJECT)->alias('t2')->on('t1.project = t2.id')
  835. ->where('t1.story')->eq($storyID)
  836. ->andWhere('t2.deleted')->eq(0)
  837. ->andWhere('t2.type')->in('sprint,stage,kanban')
  838. ->fetchPairs('project', 'project');
  839. $this->loadModel('kanban');
  840. foreach($executionIdList as $executionID) $this->kanban->updateLane($executionID, $oldStory->type, $storyID);
  841. }
  842. unset($oldStory->parent, $story->parent);
  843. if($this->config->edition != 'open' && $oldStory->feedback) $this->loadModel('feedback')->updateStatus('story', $oldStory->feedback, $story->status, $oldStory->status, $storyID);
  844. if(!empty($story->reviewer))
  845. {
  846. if(strpos(',draft,changing,', ",{$oldStory->status},") !== false && $story->status == 'reviewing') $this->dao->delete()->from(TABLE_STORYREVIEW)->where('story')->eq($storyID)->andWhere('version')->in($oldStory->version)->exec();
  847. $oldReviewer = $this->getReviewerPairs($storyID, $oldStory->version);
  848. $oldStory->reviewers = implode(',', array_keys($oldReviewer));
  849. $story->reviewers = implode(',', $story->reviewer);
  850. if($story->reviewers != $oldStory->reviewers)
  851. {
  852. $oldStatus = $story->status;
  853. $this->doUpdateReviewer($storyID, $story);
  854. if($story->status != $oldStatus) $this->dao->update(TABLE_STORY)->set('status')->eq($story->status)->where('id')->eq($storyID)->exec();
  855. if($story->status == 'active') $story->finalResult = $story->status;
  856. }
  857. }
  858. $story = $this->loadModel('file')->replaceImgURL($story, 'spec,verify');
  859. $changes = common::createChanges($oldStory, $story);
  860. if(!empty($comment) or !empty($changes))
  861. {
  862. $action = !empty($changes) ? 'Edited' : 'Commented';
  863. $actionID = $this->action->create('story', $storyID, $action, $comment);
  864. $this->action->logHistory($actionID, $changes);
  865. if(isset($story->finalResult))
  866. {
  867. if($story->finalResult == 'clarify')
  868. {
  869. $action = 'reviewclarified';
  870. }
  871. elseif($story->finalResult == 'active')
  872. {
  873. $action = 'reviewpassed';
  874. }
  875. else
  876. {
  877. $action = 'review' . $story->finalResult . 'ed';
  878. }
  879. $this->action->create('story', $storyID, $action, '', "{$story->finalResult}|$oldStatus");
  880. }
  881. }
  882. if(isset($story->closedReason) and $story->closedReason == 'done') $this->loadModel('score')->create('story', 'close');
  883. if(!empty($oldStory->twins)) $this->syncTwins($oldStory->id, $oldStory->twins, $changes, 'Edited');
  884. return !empty($actionID) ? $actionID : false;
  885. }
  886. /**
  887. * Update story product.
  888. *
  889. * @param int $storyID
  890. * @param object $parentID
  891. * @param int $productID
  892. * @access public
  893. * @return void
  894. * @param object $parent
  895. */
  896. public function updateStoryProduct($storyID, $parent, $productID)
  897. {
  898. if($parent->id != $storyID)
  899. {
  900. $childPath = $this->dao->select('path')->from(TABLE_STORY)->where('id')->eq($storyID)->fetch('path');
  901. $childPath = strstr($childPath, ",{$parent->id},");
  902. $this->dao->update(TABLE_STORY)
  903. ->set('product')->eq($parent->product)
  904. ->set('branch')->eq($parent->branch)
  905. ->set('module')->eq(0)
  906. ->set('plan')->eq('')
  907. ->set('root')->eq($parent->id)
  908. ->set('path')->eq($childPath)
  909. ->where('id')->eq($storyID)
  910. ->exec();
  911. }
  912. else
  913. {
  914. $this->dao->update(TABLE_STORY)->set('path')->eq(",{$storyID},")->where('id')->eq($storyID)->exec();
  915. }
  916. $this->dao->update(TABLE_PROJECTSTORY)->set('product')->eq($productID)->where('story')->eq($storyID)->exec();
  917. $storyProjects = $this->dao->select('project')->from(TABLE_PROJECTSTORY)->where('story')->eq($storyID)->orderBy('project')->fetchPairs('project', 'project');
  918. $linkedProjects = $this->dao->select('project')->from(TABLE_PROJECTPRODUCT)->where('project')->in($storyProjects)->andWhere('product')->eq($productID)->orderBy('project')->fetchPairs('project','project');
  919. $unlinkedProjects = array_diff($storyProjects, $linkedProjects);
  920. foreach($unlinkedProjects as $projectID)
  921. {
  922. $data = new stdclass();
  923. $data->project = $projectID;
  924. $data->product = $productID;
  925. $this->dao->replace(TABLE_PROJECTPRODUCT)->data($data)->exec();
  926. }
  927. }
  928. /**
  929. * Update parent status.
  930. *
  931. * @param int $storyID
  932. * @param int $parentID
  933. * @param bool $createAction
  934. * @access public
  935. * @return object|bool
  936. */
  937. public function updateParentStatus($storyID, $parentID = 0, $createAction = true)
  938. {
  939. $childStory = $this->dao->select('*')->from(TABLE_STORY)->where('id')->eq($storyID)->fetch();
  940. if(empty($parentID)) $parentID = $childStory->parent;
  941. if($parentID <= 0) return true;
  942. $childrenStatus = $this->dao->select('id,status')->from(TABLE_STORY)->where('parent')->eq($parentID)->andWhere('deleted')->eq(0)->fetchPairs('status', 'status');
  943. if(empty($childrenStatus))
  944. {
  945. $this->dao->update(TABLE_STORY)->set('isParent')->eq('0')->where('id')->eq($parentID)->exec();
  946. return true;
  947. }
  948. $this->dao->update(TABLE_STORY)->set('isParent')->eq('1')->where('id')->eq($parentID)->exec();
  949. $oldParentStory = $this->dao->select('*')->from(TABLE_STORY)->where('id')->eq($parentID)->andWhere('deleted')->eq(0)->fetch();
  950. if(empty($oldParentStory))
  951. {
  952. $this->dao->update(TABLE_STORY)->set('parent')->eq('0')->where('id')->eq($storyID)->exec();
  953. return true;
  954. }
  955. $this->computeEstimate($parentID);
  956. $status = $oldParentStory->status;
  957. if(count($childrenStatus) == 1 and current($childrenStatus) == 'closed') $status = current($childrenStatus); // Close parent story.
  958. if($oldParentStory->status == 'closed' && $childStory->status == 'active') $status = $this->getActivateStatus($parentID); // Activate parent story.
  959. $action = '';
  960. $preStatus = '';
  961. if($status and $oldParentStory->status != $status)
  962. {
  963. $story = $this->storyTao->doUpdateParentStatus($parentID, $status);
  964. if(dao::isError()) return false;
  965. if(!$createAction) return $story;
  966. if(strpos('active,draft,changing', $status) !== false) $action = 'Activated';
  967. if($status == 'closed')
  968. {
  969. /* Record the status before closed. */
  970. $action = 'closedbysystem';
  971. $preStatus = $oldParentStory->status;
  972. $isChanged = $oldParentStory->changedBy ? true : false;
  973. if($preStatus == 'reviewing') $preStatus = $isChanged ? 'changing' : 'draft';
  974. }
  975. }
  976. else
  977. {
  978. $action = 'Edited';
  979. if(dao::isError()) return false;
  980. }
  981. $newParentStory = $this->dao->select('*')->from(TABLE_STORY)->where('id')->eq($parentID)->fetch();
  982. if($this->config->edition != 'open' && $oldParentStory->feedback) $this->loadModel('feedback')->updateStatus('story', $oldParentStory->feedback, $status, $oldParentStory->status, $oldParentStory->id);
  983. $changes = common::createChanges($oldParentStory, $newParentStory);
  984. if($action and $changes)
  985. {
  986. $actionID = $this->loadModel('action')->create('story', $parentID, $action, '', $preStatus, '', false);
  987. $this->action->logHistory($actionID, $changes);
  988. }
  989. if($newParentStory->parent) return $this->updateParentStatus($parentID, $newParentStory->parent, true);
  990. return true;
  991. }
  992. /**
  993. * Update the story order of plan.
  994. *
  995. * @param int $storyID
  996. * @param string $planIdList
  997. * @param string $oldPlanIdList
  998. * @access public
  999. * @return void
  1000. */
  1001. public function updateStoryOrderOfPlan($storyID, $planIdList = '', $oldPlanIdList = '')
  1002. {
  1003. $planIdList = $planIdList ? explode(',', $planIdList) : array();
  1004. $oldPlanIdList = $oldPlanIdList ? explode(',', $oldPlanIdList) : array();
  1005. /* Get the ids to be inserted and deleted by comparing plan ids. */
  1006. $plansTobeInsert = array_diff($planIdList, $oldPlanIdList);
  1007. $plansTobeDelete = array_diff($oldPlanIdList, $planIdList);
  1008. /* Delete old story sort of plan. */
  1009. if(!empty($plansTobeDelete)) $this->dao->delete()->from(TABLE_PLANSTORY)->where('story')->eq($storyID)->andWhere('plan')->in($plansTobeDelete)->exec();
  1010. if(!empty($plansTobeInsert))
  1011. {
  1012. /* Get last story order of plan list. */
  1013. $maxOrders = $this->dao->select('`plan`, max(`order`) as `order`')->from(TABLE_PLANSTORY)->where('plan')->in($plansTobeInsert)->groupBy('plan')->fetchPairs();
  1014. foreach($plansTobeInsert as $planID)
  1015. {
  1016. /* Set story order in new plan. */
  1017. $data = new stdClass();
  1018. $data->plan = $planID;
  1019. $data->story = $storyID;
  1020. $data->order = zget($maxOrders, $planID, 0) + 1;
  1021. $this->dao->replace(TABLE_PLANSTORY)->data($data)->exec();
  1022. }
  1023. }
  1024. }
  1025. /**
  1026. * Compute parent story estimate.
  1027. *
  1028. * @param int $storyID
  1029. * @access public
  1030. * @return bool
  1031. */
  1032. public function computeEstimate($storyID)
  1033. {
  1034. if(!$storyID) return true;
  1035. $estimates = $this->dao->select('`id`,`estimate`')->from(TABLE_STORY)->where('parent')->eq($storyID)->andWhere('deleted')->eq(0)->fetchPairs('id', 'estimate');
  1036. if(empty($estimates)) return true;
  1037. $estimate = round(array_sum($estimates), 2);
  1038. $this->dao->update(TABLE_STORY)->set('estimate')->eq($estimate)->where('id')->eq($storyID)->exec();
  1039. return !dao::isError();
  1040. }
  1041. /**
  1042. * Batch update stories.
  1043. *
  1044. * @param array $stories
  1045. * @access public
  1046. * @return array
  1047. */
  1048. public function batchUpdate($stories)
  1049. {
  1050. /* Init vars. */
  1051. $oldStories = $this->getByList(array_keys($stories));
  1052. $unlinkPlans = array();
  1053. $link2Plans = array();
  1054. foreach($stories as $storyID => $story)
  1055. {
  1056. $oldStory = $oldStories[$storyID];
  1057. if($story->plan != $oldStory->plan)
  1058. {
  1059. if(!empty($oldStory->plan))
  1060. {
  1061. foreach(explode(',', (string)$oldStory->plan) as $planID)
  1062. {
  1063. if(!$planID) continue;
  1064. $unlinkPlans[$planID] = empty($unlinkPlans[$planID]) ? $storyID : "{$unlinkPlans[$planID]},$storyID";
  1065. }
  1066. }
  1067. if(!empty($story->plan))
  1068. {
  1069. foreach(explode(',', (string)$story->plan) as $planID)
  1070. {
  1071. if(!$planID) continue;
  1072. $link2Plans[$planID] = empty($link2Plans[$planID]) ? $storyID : "{$link2Plans[$planID]},$storyID";
  1073. }
  1074. }
  1075. }
  1076. }
  1077. $this->loadModel('action');
  1078. foreach($stories as $storyID => $story)
  1079. {
  1080. $oldStory = $oldStories[$storyID];
  1081. if($oldStory->stage != 'verified' && $story->stage == 'verified') $story->verifiedDate = helper::now();
  1082. if(isset($story->estimate)) $story->estimate = round((float)$story->estimate, 2);
  1083. $this->dao->update(TABLE_STORY)->data($story)
  1084. ->autoCheck()
  1085. ->checkIF($story->closedBy, 'closedReason', 'notempty')
  1086. ->checkIF($story->closedReason == 'done', 'stage', 'notempty')
  1087. ->checkIF($story->closedReason == 'duplicate', 'duplicateStory', 'notempty')
  1088. ->where('id')->eq((int)$storyID)
  1089. ->exec();
  1090. if(dao::isError()) return false;
  1091. /* Update story sort of plan when story plan has changed. */
  1092. if($oldStory->plan != $story->plan) $this->updateStoryOrderOfPlan($storyID, (string)$story->plan, $oldStory->plan);
  1093. if($oldStory->parent > 0) $this->updateParentStatus($storyID, $oldStory->parent);
  1094. $this->executeHooks($storyID);
  1095. if($oldStory->type == 'story' && $story->stage != $oldStory->stage) $this->batchChangeStage(array($storyID), $story->stage);
  1096. if($story->closedReason == 'done') $this->loadModel('score')->create('story', 'close');
  1097. if($story->roadmap != $oldStory->roadmap) $this->storyTao->computeParentStage($oldStory);
  1098. $changes = common::createChanges($oldStory, $story);
  1099. if($changes)
  1100. {
  1101. $actionID = $this->action->create('story', $storyID, 'Edited');
  1102. $this->action->logHistory($actionID, $changes);
  1103. }
  1104. if($this->config->edition != 'open' && $oldStory->feedback && !isset($feedbacks[$oldStory->feedback]))
  1105. {
  1106. $feedbacks[$oldStory->feedback] = $oldStory->feedback;
  1107. $this->loadModel('feedback')->updateStatus('story', $oldStory->feedback, $story->status, $oldStory->status, $storyID);
  1108. }
  1109. }
  1110. $this->loadModel('score')->create('ajax', 'batchEdit');
  1111. foreach($unlinkPlans as $planID => $stories) $this->action->create('productplan', (int)$planID, 'unlinkstory', '', $stories);
  1112. foreach($link2Plans as $planID => $stories) $this->action->create('productplan', (int)$planID, 'linkstory', '', $stories);
  1113. return true;
  1114. }
  1115. /**
  1116. * Review a story.
  1117. *
  1118. * @param int $storyID
  1119. * @param object $story
  1120. * @param string $comment
  1121. * @access public
  1122. * @return bool
  1123. */
  1124. public function review($storyID, $story, $comment = '')
  1125. {
  1126. $oldStory = $this->dao->findById($storyID)->from(TABLE_STORY)->fetch();
  1127. $now = helper::now();
  1128. $account = $this->app->user->account;
  1129. if(strpos(",{$oldStory->reviewedBy},", ",{$account}") === false) $story->reviewedBy = $oldStory->reviewedBy . ',' . $account;
  1130. if(isset($story->estimate)) $story->estimate = round((float)$story->estimate, 2);
  1131. $this->dao->update(TABLE_STORYREVIEW)
  1132. ->set('result')->eq($story->result)
  1133. ->set('reviewDate')->eq($now)
  1134. ->where('story')->in($storyID . ($oldStory->twins ? ",{$oldStory->twins}" : ''))
  1135. ->andWhere('version')->eq($oldStory->version)
  1136. ->andWhere('reviewer')->eq($account)
  1137. ->exec();
  1138. $story = $this->updateStoryByReview($storyID, $oldStory, $story);
  1139. $skipFields = 'finalResult,result';
  1140. $isSuperReviewer = $this->storyTao->isSuperReviewer();
  1141. if($isSuperReviewer)
  1142. {
  1143. $reviewers = $this->getReviewerPairs($storyID, $oldStory->version);
  1144. if(count($reviewers) > 1) $skipFields .= ',closedReason';
  1145. }
  1146. $this->dao->update(TABLE_STORY)->data($story, $skipFields)->autoCheck()->checkFlow()->where('id')->eq($storyID)->exec();
  1147. if(dao::isError()) return false;
  1148. if($story->result != 'reject') $this->setStage($storyID);
  1149. if($story->result == 'reject' && $this->config->edition != 'open' && $oldStory->feedback) $this->loadModel('feedback')->updateStatus('story', $oldStory->feedback, $story->status, $oldStory->status, $storyID);
  1150. if($oldStory->parent) $this->computeEstimate($oldStory->parent);
  1151. $changes = common::createChanges($oldStory, $story);
  1152. if($changes)
  1153. {
  1154. $story->id = $storyID;
  1155. $actionID = $this->recordReviewAction($oldStory, $story, $comment);
  1156. if($actionID) $this->action->logHistory($actionID, $changes);
  1157. }
  1158. if(!empty($oldStory->twins)) $this->syncTwins($oldStory->id, $oldStory->twins, $changes, 'Reviewed');
  1159. return true;
  1160. }
  1161. /**
  1162. * Batch review stories.
  1163. *
  1164. * @param array $storyIdList
  1165. * @param string $result
  1166. * @param string $reason
  1167. * @access public
  1168. * @return string|null
  1169. */
  1170. public function batchReview($storyIdList, $result, $reason = '')
  1171. {
  1172. $now = helper::now();
  1173. $account = $this->app->user->account;
  1174. $reviewedTwins = array();
  1175. $this->loadModel('action');
  1176. $this->app->loadLang('product');
  1177. $oldStories = $this->getByList($storyIdList);
  1178. $hasResult = $this->dao->select('story,version,result')->from(TABLE_STORYREVIEW)->where('story')->in($storyIdList)->andWhere('reviewer')->eq($account)->andWhere('result')->ne('')->orderBy('version')->fetchAll('story');
  1179. $reviewerList = $this->dao->select('story,reviewer,result,version')->from(TABLE_STORYREVIEW)->where('story')->in($storyIdList)->orderBy('version')->fetchGroup('story', 'reviewer');
  1180. $isSuperReviewer = $this->storyTao->isSuperReviewer();
  1181. $cannotReviewStories = array();
  1182. $cannotRejectStories = array();
  1183. $cannotReviewTips = $this->lang->product->reviewStory;
  1184. foreach($storyIdList as $storyID)
  1185. {
  1186. $storyID = (int)$storyID;
  1187. $oldStory = zget($oldStories, $storyID, '');
  1188. if(empty($oldStory)) continue;
  1189. if($oldStory->status != 'reviewing') continue;
  1190. if($oldStory->version > 1 and $result == 'reject')
  1191. {
  1192. $cannotRejectStories[$storyID] = "#{$storyID}";
  1193. continue;
  1194. }
  1195. if(isset($hasResult[$storyID]) and $hasResult[$storyID]->version == $oldStory->version) continue;
  1196. /* 当评审人列表中没有当前用户或者当前用户不是当前版本需求的评审人时,将需求ID添加到不能评审的提示语中。*/
  1197. if((!isset($reviewerList[$storyID][$account]) || (isset($reviewerList[$storyID][$account]) && $reviewerList[$storyID][$account]->version != $oldStory->version)) && !$isSuperReviewer)
  1198. {
  1199. $cannotReviewStories[$storyID] = "#{$storyID}";
  1200. continue;
  1201. }
  1202. $reviewerPairs = array();
  1203. foreach($reviewerList[$storyID] as $reviewer => $reviewerInfo)
  1204. {
  1205. if($reviewerInfo->version != $oldStory->version) continue;
  1206. $reviewerPairs[$reviewer] = $reviewerInfo->result;
  1207. }
  1208. $reviewerPairs[$account] = $result;
  1209. $story = new stdClass();
  1210. $story->reviewedDate = $now;
  1211. $story->lastEditedBy = $account;
  1212. $story->lastEditedDate = $now;
  1213. $story->status = $oldStory->status;
  1214. $story->closedReason = $result == 'reject' ? $reason : '';
  1215. $story->reviewedBy = strpos(",{$oldStory->reviewedBy},", ",{$account},") !== false ? $oldStory->reviewedBy : ($oldStory->reviewedBy . ',' . $account);
  1216. $twinsIdList = $storyID . ($oldStory->twins ? ",{$oldStory->twins}" : '');
  1217. $this->dao->update(TABLE_STORYREVIEW)->set('result')->eq($result)->set('reviewDate')->eq($now)->where('story')->in($twinsIdList)->andWhere('version')->eq($oldStory->version)->andWhere('reviewer')->eq($account)->exec();
  1218. /* Update the story status by review rules. */
  1219. $reviewedBy = explode(',', trim($story->reviewedBy, ','));
  1220. if($isSuperReviewer) $story = $this->superReview($storyID, $oldStory, $story, $result, $reason);
  1221. if(!$isSuperReviewer and !array_diff(array_keys($reviewerPairs), $reviewedBy))
  1222. {
  1223. $reviewResult = $this->getReviewResult($reviewerPairs);
  1224. $story = $this->setStatusByReviewResult($story, $oldStory, $reviewResult, $reason);
  1225. }
  1226. $this->dao->update(TABLE_STORY)->data($story, 'finalResult')->autoCheck()->where('id')->eq($storyID)->exec();
  1227. $this->setStage((int)$storyID);
  1228. $story->id = $storyID;
  1229. $story->result = $result;
  1230. $this->recordReviewAction($oldStories[$storyID], $story);
  1231. /* Sync twins. */
  1232. $changes = common::createChanges($oldStory, $story);
  1233. if(!empty($oldStory->twins))
  1234. {
  1235. $twins = $oldStory->twins;
  1236. foreach(explode(',', $twins) as $twinID)
  1237. {
  1238. if(in_array($twinID, $storyIdList) or isset($reviewedTwins[$twinID])) $twins = str_replace(",$twinID,", ',', $twins);
  1239. }
  1240. $this->syncTwins($storyID, trim($twins, ','), $changes, 'Reviewed');
  1241. foreach(explode(',', trim($twins, ',')) as $reviewedID) $reviewedTwins[$reviewedID] = $reviewedID;
  1242. }
  1243. }
  1244. $message = '';
  1245. if($cannotReviewStories) $message .= sprintf($cannotReviewTips, implode(',', $cannotReviewStories));
  1246. if(!empty($cannotRejectStories)) $message .= sprintf($this->lang->story->cannotRejectTips, implode(',', $cannotRejectStories));
  1247. if(!empty($message)) return $message;
  1248. return null;
  1249. }
  1250. /**
  1251. * Recall the story review.
  1252. *
  1253. * @param int $storyID
  1254. * @access public
  1255. * @return void
  1256. */
  1257. public function recallReview($storyID)
  1258. {
  1259. $oldStory = $this->fetchById($storyID);
  1260. $isChanged = $oldStory->changedBy ? true : false;
  1261. $twinsIdList = $storyID . ($oldStory->twins ? ",{$oldStory->twins}" : '');
  1262. $story = new stdclass();
  1263. $story->status = $isChanged ? 'changing' : 'draft';
  1264. $this->dao->update(TABLE_STORY)->set('status')->eq($story->status)->where('id')->in($twinsIdList)->exec();
  1265. $this->dao->delete()->from(TABLE_STORYREVIEW)->where('story')->in($twinsIdList)->andWhere('version')->eq($oldStory->version)->exec();
  1266. $changes = common::createChanges($oldStory, $story);
  1267. if(!empty($oldStory->twins)) $this->syncTwins($storyID, $oldStory->twins, $changes, 'recalled');
  1268. }
  1269. /**
  1270. * Recall the story change.
  1271. *
  1272. * @param int $storyID
  1273. * @access public
  1274. * @return void
  1275. */
  1276. public function recallChange($storyID)
  1277. {
  1278. $oldStory = $this->fetchById($storyID);
  1279. if(empty($oldStory)) return;
  1280. /* Update story title and version and status. */
  1281. $twinsIdList = $storyID . ($oldStory->twins ? ",{$oldStory->twins}" : '');
  1282. $titleList = $this->dao->select('story,title')->from(TABLE_STORYSPEC)->where('story')->in($twinsIdList)->andWHere('version')->eq($oldStory->version - 1)->fetchAll('story');
  1283. foreach(explode(',', $twinsIdList) as $twinID)
  1284. {
  1285. $story = new stdclass();
  1286. $story->title = $titleList[$twinID]->title;
  1287. $story->version = $oldStory->version - 1;
  1288. $story->status = 'active';
  1289. $this->dao->update(TABLE_STORY)->set('title')->eq($story->title)->set('version')->eq($story->version)->set('status')->eq($story->status)->where('id')->eq($storyID)->exec();
  1290. }
  1291. /* Delete versions that is after this version. */
  1292. $this->dao->delete()->from(TABLE_STORYSPEC)->where('story')->in($twinsIdList)->andWHere('version')->eq($oldStory->version)->exec();
  1293. $this->dao->delete()->from(TABLE_STORYREVIEW)->where('story')->in($twinsIdList)->andWhere('version')->eq($oldStory->version)->exec();
  1294. $changes = common::createChanges($oldStory, $story);
  1295. if(!empty($oldStory->twins)) $this->syncTwins($storyID, $oldStory->twins, $changes, 'recalledChange');
  1296. }
  1297. /**
  1298. * Submit review.
  1299. *
  1300. * @param int $storyID
  1301. * @param object $story
  1302. * @access public
  1303. * @return array|bool
  1304. */
  1305. public function submitReview($storyID, $story)
  1306. {
  1307. $oldStory = $this->dao->findById($storyID)->from(TABLE_STORY)->fetch();
  1308. $reviewerList = $this->getReviewerPairs($oldStory->id, $oldStory->version);
  1309. $oldStory->reviewer = implode(',', array_keys($reviewerList));
  1310. $twinsIdList = $storyID . ($oldStory->twins ? ",{$oldStory->twins}" : '');
  1311. $this->dao->delete()->from(TABLE_STORYREVIEW)->where('story')->in($twinsIdList)->andWhere('version')->eq($oldStory->version)->exec();
  1312. foreach(explode(',', $twinsIdList) as $twinID)
  1313. {
  1314. if(empty($twinID)) continue;
  1315. $this->storyTao->doCreateReviewer((int)$twinID, $story->reviewer, $oldStory->version);
  1316. }
  1317. $story->reviewer = implode(',', $story->reviewer);
  1318. if($story->reviewer) $story->status = 'reviewing';
  1319. $this->dao->update(TABLE_STORY)->data($story, 'reviewer')->where('id')->in($twinsIdList)->exec();
  1320. $changes = common::createChanges($oldStory, $story);
  1321. if(!empty($oldStory->twins)) $this->syncTwins($storyID, $oldStory->twins, $changes, 'submitReview');
  1322. if(!dao::isError()) return $changes;
  1323. return false;
  1324. }
  1325. /**
  1326. * Subdivide story
  1327. *
  1328. * @param int $storyID
  1329. * @param array $stories
  1330. * @access public
  1331. * @return void
  1332. * @param mixed[] $SRList
  1333. */
  1334. public function subdivide($storyID, $SRList)
  1335. {
  1336. $now = helper::now();
  1337. $oldStory = $this->dao->findById($storyID)->from(TABLE_STORY)->fetch();
  1338. $storyType = $this->dao->select('id,type')->from(TABLE_STORY)->where('id')->in($SRList)->fetchPairs('id');
  1339. /* Set parent to child story. */
  1340. foreach($SRList as $childStoryID)
  1341. {
  1342. $path = rtrim($oldStory->path, ',') . ",$childStoryID,";
  1343. $this->dao->update(TABLE_STORY)
  1344. ->set('parent')->eq($storyID)
  1345. ->set('parentVersion')->eq($oldStory->version)
  1346. ->set('root')->eq($oldStory->root)
  1347. ->set('path')->eq($path)
  1348. ->where('id')->eq($childStoryID)
  1349. ->exec();
  1350. if($this->config->edition != 'open')
  1351. {
  1352. $relation = new stdClass();
  1353. $relation->product = 0;
  1354. $relation->AID = $storyID;
  1355. $relation->AType = $oldStory->type;
  1356. $relation->relation = 'subdivideinto';
  1357. $relation->BID = $childStoryID;
  1358. $relation->BType = $storyType[$childStoryID];
  1359. $this->dao->replace(TABLE_RELATION)->data($relation)->exec();
  1360. }
  1361. }
  1362. $this->computeEstimate($storyID);
  1363. /* Set childStories. */
  1364. $newStory = new stdClass();
  1365. $newStory->isParent = '1';
  1366. $newStory->lastEditedBy = $this->app->user->account;
  1367. $newStory->lastEditedDate = $now;
  1368. $this->dao->update(TABLE_STORY)->data($newStory)->autoCheck()->where('id')->eq($storyID)->exec();
  1369. $childStories = implode(',', $SRList);
  1370. $actionID = $this->loadModel('action')->create('story', $storyID, 'createChildrenStory', '', $childStories);
  1371. $this->action->logHistory($actionID, common::createChanges($oldStory, $newStory));
  1372. }
  1373. /**
  1374. * 关闭需求。
  1375. * Close the story.
  1376. *
  1377. * @param int $storyID
  1378. * @param object $postData
  1379. * @access public
  1380. * @return array|false
  1381. */
  1382. public function close($storyID, $postData)
  1383. {
  1384. $oldStory = $this->dao->findById($storyID)->from(TABLE_STORY)->fetch();
  1385. $story = $postData;
  1386. $this->loadModel($oldStory->type);
  1387. if(!empty($story->duplicateStory))
  1388. {
  1389. $duplicateStoryID = $this->dao->select('id')->from(TABLE_STORY)->where('id')->eq($story->duplicateStory)->fetch();
  1390. if(empty($duplicateStoryID))
  1391. {
  1392. dao::$errors['duplicateStory'] = sprintf($this->lang->story->errorDuplicateStory, $story->duplicateStory);
  1393. return false;
  1394. }
  1395. }
  1396. if($story->closedReason == 'duplicate' and empty($story->duplicateStory))
  1397. {
  1398. dao::$errors['duplicateStory'] = sprintf($this->lang->error->notempty, $this->lang->story->duplicateStory);
  1399. return false;
  1400. }
  1401. if(strpos($this->config->{$oldStory->type}->close->requiredFields, 'comment') !== false and !$this->post->comment) dao::$errors['comment'][] = sprintf($this->lang->error->notempty, $this->lang->comment);
  1402. $this->lang->story->comment = $this->lang->comment;
  1403. $this->dao->update(TABLE_STORY)->data($story, 'comment,closeSync')
  1404. ->autoCheck()
  1405. ->batchCheck($this->config->{$oldStory->type}->close->requiredFields, 'notempty')
  1406. ->checkIF($story->closedReason == 'duplicate', 'duplicateStory', 'notempty')
  1407. ->checkFlow()
  1408. ->where('id')->eq($storyID)
  1409. ->exec();
  1410. if(dao::isError()) return false;
  1411. $changes = common::createChanges($oldStory, $story);
  1412. if($changes && empty($postData->retractedReason))
  1413. {
  1414. $preStatus = $oldStory->status;
  1415. $isChanged = !empty($oldStory->changedBy) ? true : false;
  1416. if($preStatus == 'reviewing') $preStatus = $isChanged ? 'changing' : 'draft';
  1417. $actionID = $this->loadModel('action')->create('story', $storyID, 'Closed', $this->post->comment, ucfirst(zget($_POST, 'closedReason', '')) . (!empty($_POST['duplicateStory']) ? ':' . (int)$this->post->duplicateStory : '') . "|$preStatus");
  1418. $this->action->logHistory($actionID, $changes);
  1419. }
  1420. $this->dao->update(TABLE_STORY)->set('assignedTo')->eq('closed')->set('assignedDate')->eq(helper::now())->where('id')->eq((int)$storyID)->exec();
  1421. if($oldStory->isParent == '1') $this->closeAllChildren($storyID, $story->closedReason);
  1422. $this->setStage($storyID);
  1423. $this->loadModel('score')->create('story', 'close', $storyID);
  1424. if($this->config->edition != 'open' && $oldStory->feedback) $this->loadModel('feedback')->updateStatus('story', $oldStory->feedback, $story->status, $oldStory->status, $storyID);
  1425. if(!empty($postData->closeSync))
  1426. {
  1427. $this->relieveTwins($oldStory->product, $storyID);
  1428. if(!dao::isError()) $this->loadModel('action')->create('story', $storyID, 'relieved');
  1429. }
  1430. if(!empty($oldStory->twins) and empty($postData->closeSync)) $this->syncTwins($storyID, $oldStory->twins, $changes, 'Closed');
  1431. return $changes;
  1432. }
  1433. /**
  1434. * 批量关闭需求。
  1435. * Batch close the stories.
  1436. *
  1437. * @param array $stories
  1438. * @access public
  1439. * @return bool
  1440. */
  1441. public function batchClose($stories)
  1442. {
  1443. $this->loadModel('action');
  1444. $oldStories = $this->getByList(array_keys($stories));
  1445. foreach($stories as $storyID => $story)
  1446. {
  1447. if(empty($story->closedReason)) continue;
  1448. $oldStory = $oldStories[$storyID];
  1449. if(isset($stories[$oldStory->parent])) continue;
  1450. $this->dao->update(TABLE_STORY)->data($story, 'comment')->autoCheck()
  1451. ->checkIF($story->closedReason == 'duplicate', 'duplicateStory', 'notempty')
  1452. ->where('id')->eq($storyID)
  1453. ->exec();
  1454. if(dao::isError())
  1455. {
  1456. dao::$errors[] = 'story#' . $storyID . dao::getError(true);
  1457. return false;
  1458. }
  1459. if($oldStory->isParent == '1') $this->closeAllChildren($storyID, $story->closedReason);
  1460. $this->setStage($storyID);
  1461. $changes = common::createChanges($oldStory, $story);
  1462. if($changes)
  1463. {
  1464. $preStatus = $oldStory->status;
  1465. $isChanged = $oldStory->changedBy ? true : false;
  1466. if($preStatus == 'reviewing') $preStatus = $isChanged ? 'changing' : 'draft';
  1467. $actionID = $this->action->create('story', $storyID, 'Closed', $story->comment ?? '', ucfirst($story->closedReason) . (!empty($story->duplicateStory) ? ':' . (int)$story->duplicateStory : '') . "|{$preStatus}");
  1468. $this->action->logHistory($actionID, $changes);
  1469. if(!empty($oldStory->twins)) $this->syncTwins($storyID, $oldStory->twins, $changes, 'Closed');
  1470. }
  1471. if($this->config->edition != 'open' && $oldStory->feedback && !isset($feedbacks[$oldStory->feedback]))
  1472. {
  1473. $feedbacks[$oldStory->feedback] = $oldStory->feedback;
  1474. $this->loadModel('feedback')->updateStatus('story', $oldStory->feedback, $story->status, $oldStory->status, $storyID);
  1475. }
  1476. $this->loadModel('score')->create('story', 'close', $storyID);
  1477. }
  1478. return true;
  1479. }
  1480. /**
  1481. * Batch change the module of story.
  1482. *
  1483. * @param array $storyIdList
  1484. * @param int $moduleID
  1485. * @access public
  1486. * @return array
  1487. */
  1488. public function batchChangeModule($storyIdList, $moduleID)
  1489. {
  1490. $now = helper::now();
  1491. $allChanges = array();
  1492. $oldStories = $this->getByList($storyIdList);
  1493. foreach($storyIdList as $storyID)
  1494. {
  1495. $oldStory = $oldStories[$storyID];
  1496. if(empty($oldStory)) continue;
  1497. if($moduleID == $oldStory->module) continue;
  1498. $story = new stdclass();
  1499. $story->lastEditedBy = $this->app->user->account;
  1500. $story->lastEditedDate = $now;
  1501. $story->module = $moduleID;
  1502. $this->dao->update(TABLE_STORY)->data($story)->autoCheck()->where('id')->eq((int)$storyID)->exec();
  1503. if(!dao::isError()) $allChanges[$storyID] = common::createChanges($oldStory, $story);
  1504. }
  1505. return $allChanges;
  1506. }
  1507. /**
  1508. * Batch change the plan of story.
  1509. *
  1510. * @param array $storyIdList
  1511. * @param int $planID
  1512. * @param int $oldPlanID
  1513. * @access public
  1514. * @return array
  1515. */
  1516. public function batchChangePlan($storyIdList, $planID, $oldPlanID = 0)
  1517. {
  1518. /* Prepare data. */
  1519. $now = helper::now();
  1520. $oldStories = $this->getByList($storyIdList);
  1521. $oldStoryStages = $this->dao->select('*')->from(TABLE_STORYSTAGE)->where('story')->in($storyIdList)->fetchGroup('story', 'branch');
  1522. $plan = $this->loadModel('productplan')->getById($planID);
  1523. if(empty($plan))
  1524. {
  1525. $plan = new stdClass();
  1526. $plan->branch = BRANCH_MAIN;
  1527. }
  1528. $productIdList = array();
  1529. foreach($oldStories as $oldStory) $productIdList[$oldStory->product] = $oldStory->product;
  1530. $products = $this->loadModel('product')->getByIdList($productIdList);
  1531. /* Cycle every story and process it's plan and stage. */
  1532. $allChanges = array();
  1533. $unlinkPlans = array();
  1534. $link2Plans = array();
  1535. foreach($storyIdList as $storyID)
  1536. {
  1537. $oldStory = zget($oldStories, $storyID, null);
  1538. if(empty($oldStory)) continue;
  1539. if($oldStory->branch != BRANCH_MAIN and $plan->branch != BRANCH_MAIN and !in_array($oldStory->branch, explode(',', $plan->branch))) continue;
  1540. /* Ignore story linked to this plan already. */
  1541. if(strpos(",{$oldStory->plan},", ",$planID,") !== false) continue;
  1542. /* Init story and set last edited data. */
  1543. $story = new stdclass();
  1544. $story->lastEditedBy = $this->app->user->account;
  1545. $story->lastEditedDate = $now;
  1546. $story->demand = $oldStory->demand;
  1547. /* Remove old plan from the plan field. */
  1548. if($oldPlanID) $story->plan = trim(str_replace(",$oldPlanID,", ',', ",$oldStory->plan,"), ',');
  1549. /* Update the order of the story in the plan. */
  1550. $oldStoryPlan = $oldStory->type == 'story' ? $oldStory->plan : '';
  1551. $this->updateStoryOrderOfPlan((int)$storyID, (string)$planID, $oldStoryPlan);
  1552. /* 用需、业需追加计划,软需替换计划。 */
  1553. $productType = $products[$oldStory->product]->type;
  1554. if($oldStory->type != 'story')
  1555. {
  1556. $story->plan = trim("{$oldStory->plan},{$planID}", ',');
  1557. }
  1558. elseif($productType == 'normal' || empty($oldStory->plan) || $oldStory->branch)
  1559. {
  1560. $story->plan = $planID;
  1561. }
  1562. /* Change stage. */
  1563. if($planID)
  1564. {
  1565. if($oldStory->stage == 'wait') $story->stage = 'planned';
  1566. if($productType != 'normal' and $oldStory->branch == 0)
  1567. {
  1568. if(!empty($oldPlanID) && $oldStory->type == 'story') $story->plan = trim("{$story->plan},{$planID}", ',');
  1569. foreach(explode(',', $plan->branch) as $planBranch)
  1570. {
  1571. if(isset($oldStoryStages[$storyID][$planBranch])) continue;
  1572. $story->stage = 'planned';
  1573. $newStoryStage = new stdclass();
  1574. $newStoryStage->story = $storyID;
  1575. $newStoryStage->branch = $planBranch;
  1576. $newStoryStage->stage = $story->stage;
  1577. $this->dao->insert(TABLE_STORYSTAGE)->data($newStoryStage)->autoCheck()->exec();
  1578. }
  1579. }
  1580. }
  1581. /* Update story and recompute stage. */
  1582. $this->dao->update(TABLE_STORY)->data($story)->autoCheck()->where('id')->eq($storyID)->exec();
  1583. if(!$planID) $this->setStage((int)$storyID);
  1584. if(isset($story->stage) && $story->stage != $oldStory->stage)
  1585. {
  1586. $story->parent = $oldStory->parent;
  1587. $this->storyTao->computeParentStage($story);
  1588. }
  1589. if(!dao::isError())
  1590. {
  1591. $allChanges[$storyID] = common::createChanges($oldStory, $story);
  1592. if($story->plan != $oldStory->plan and !empty($oldStory->plan) and $oldStory->type == 'story')
  1593. {
  1594. foreach(explode(',', (string)$oldStory->plan) as $oldPlanID)
  1595. {
  1596. if(!$oldPlanID) continue;
  1597. $unlinkPlans[$oldPlanID] = empty($unlinkPlans[$oldPlanID]) ? $storyID : "{$unlinkPlans[$oldPlanID]},$storyID";
  1598. }
  1599. }
  1600. if($story->plan != $oldStory->plan and !empty($story->plan))
  1601. {
  1602. foreach(explode(',', (string)$story->plan) as $newPlanID)
  1603. {
  1604. if(!$newPlanID) continue;
  1605. $link2Plans[$newPlanID] = empty($link2Plans[$newPlanID]) ? $storyID : "{$link2Plans[$newPlanID]},$storyID";
  1606. }
  1607. }
  1608. }
  1609. }
  1610. if(!dao::isError())
  1611. {
  1612. $this->loadModel('action');
  1613. foreach($unlinkPlans as $planID => $stories) $this->action->create('productplan', (int)$planID, 'unlinkstory', '', $stories);
  1614. foreach($link2Plans as $planID => $stories) $this->action->create('productplan', (int)$planID, 'linkstory', '', $stories);
  1615. }
  1616. return $allChanges;
  1617. }
  1618. /**
  1619. * 批量修改需求的父需求。
  1620. * Batch change the parent of story.
  1621. *
  1622. * @param string $storyIdList
  1623. * @param int $parentID
  1624. * @param string $storyType
  1625. * @access public
  1626. * @return bool|string
  1627. */
  1628. public function batchChangeParent($storyIdList, $parentID, $storyType = 'story')
  1629. {
  1630. if(empty($storyIdList)) return;
  1631. $stories = $this->getByList($storyIdList);
  1632. $parent = $this->fetchById($parentID);
  1633. $gradePairs = $this->getGradePairs($storyType);
  1634. $gradePairs = array_keys($gradePairs);
  1635. $gradeErrorStories = array();
  1636. $parentErrorStories = array();
  1637. $this->loadModel('action');
  1638. foreach($stories as $storyID => $story)
  1639. {
  1640. if(!empty($story->twins)) continue;
  1641. if($story->parent == $parentID) continue;
  1642. if($story->id == $parentID)
  1643. {
  1644. $parentErrorStories[] = '#' . $storyID;
  1645. continue;
  1646. }
  1647. if($parent && $story->root == $parent->root && $story->type == $parent->type && $story->grade < $parent->grade)
  1648. {
  1649. $parentErrorStories[] = '#' . $storyID;
  1650. continue;
  1651. }
  1652. $oldStory = clone $story;
  1653. if($parent && $parent->type == $story->type)
  1654. {
  1655. $parentGradeIndex = array_search($parent->grade, $gradePairs);
  1656. $story->grade = $gradePairs[$parentGradeIndex + 1];
  1657. }
  1658. else
  1659. {
  1660. $story->grade = current($gradePairs);
  1661. }
  1662. if($story->grade > $oldStory->grade)
  1663. {
  1664. if(!$this->checkGrade($story, $oldStory, 'batch'))
  1665. {
  1666. $gradeErrorStories[] = '#' . $storyID;
  1667. continue;
  1668. }
  1669. }
  1670. if($story->grade != $oldStory->grade) $this->syncGrade($oldStory, $story);
  1671. $this->dao->update(TABLE_STORY)
  1672. ->set('grade')->eq($story->grade)
  1673. ->set('parent')->eq($parentID)
  1674. ->autoCheck()
  1675. ->where('id')->eq($story->id)
  1676. ->exec();
  1677. $this->action->create('story', $story->id, 'syncGrade', '', $story->grade);
  1678. $story->parent = $parentID;
  1679. $this->doChangeParent($story->id, $story, $oldStory);
  1680. if($oldStory->parent > 0) $this->updateParentStatus($storyID, $oldStory->parent, false);
  1681. if($story->parent > 0) $this->updateParentStatus($storyID, $story->parent, false);
  1682. $this->setStage($story->id);
  1683. }
  1684. $notice = '';
  1685. if($gradeErrorStories) $notice = sprintf($this->lang->story->batchGradeOverflow, implode(',', $gradeErrorStories));
  1686. if($parentErrorStories) $notice = sprintf($this->lang->story->batchParentError, implode(',', $parentErrorStories));
  1687. return $notice;
  1688. }
  1689. /**
  1690. * Batch change branch.
  1691. *
  1692. * @param array $storyIdList
  1693. * @param int $branchID
  1694. * @param string $confirm yes|null
  1695. * @param array $plans
  1696. * @access public
  1697. * @return array
  1698. */
  1699. public function batchChangeBranch($storyIdList, $branchID, $confirm = '', $plans = array())
  1700. {
  1701. $now = helper::now();
  1702. $allChanges = array();
  1703. $oldStories = $this->getByList($storyIdList);
  1704. $story = current($oldStories);
  1705. $productID = $story->product;
  1706. $mainModules = $this->dao->select('id')->from(TABLE_MODULE)->where('root')->eq($productID)->andWhere('branch')->eq(0)->andWhere('type')->eq('story')->fetchPairs('id', 'id');
  1707. foreach($storyIdList as $storyID)
  1708. {
  1709. $oldStory = $oldStories[$storyID];
  1710. $story = new stdclass();
  1711. $story->lastEditedBy = $this->app->user->account;
  1712. $story->lastEditedDate = $now;
  1713. $story->branch = $branchID;
  1714. $story->module = ($oldStory->branch != $branchID and !in_array($oldStory->module, $mainModules)) ? 0 : $oldStory->module;
  1715. $this->dao->update(TABLE_STORY)->data($story)->autoCheck()->where('id')->eq((int)$storyID)->exec();
  1716. if(dao::isError()) return array();
  1717. if($confirm == 'yes')
  1718. {
  1719. $planIdList = array();
  1720. $conflictPlanIdList = array();
  1721. /* Determine whether there is a conflict between the branch of the story and the linked plan. */
  1722. if($oldStory->branch != $branchID and $branchID != BRANCH_MAIN and isset($plans[$storyID]))
  1723. {
  1724. foreach($plans[$storyID] as $planID => $plan)
  1725. {
  1726. if($plan->branch != $branchID) $conflictPlanIdList[$planID] = $planID;
  1727. if($plan->branch == $branchID) $planIdList[$planID] = $planID;
  1728. }
  1729. /* If there is a conflict in the linked plan when the branch story to be modified, the linked with the conflicting plan will be removed. */
  1730. if($conflictPlanIdList) $this->dao->delete()->from(TABLE_PLANSTORY)->where('story')->eq($storyID)->andWhere('plan')->in(implode(',', $conflictPlanIdList))->exec();
  1731. if($planIdList)
  1732. {
  1733. $story->plan = implode(',', $planIdList);
  1734. $this->dao->update(TABLE_STORY)->set('plan')->eq($story->plan)->where('id')->eq($storyID)->exec();
  1735. }
  1736. }
  1737. }
  1738. $allChanges[$storyID] = common::createChanges($oldStory, $story);
  1739. }
  1740. return $allChanges;
  1741. }
  1742. /**
  1743. * Batch change the grade of story.
  1744. *
  1745. * @param array $storyIdList
  1746. * @param int $grade
  1747. * @param string $storyType
  1748. *
  1749. * @access public
  1750. * @return string|null
  1751. */
  1752. public function batchChangeGrade($storyIdList, $grade, $storyType = 'story')
  1753. {
  1754. $now = helper::now();
  1755. $account = $this->app->user->account;
  1756. $oldStories = $this->getByList($storyIdList);
  1757. $this->loadModel('action');
  1758. $rootGroup = array();
  1759. $parentIdList = array();
  1760. foreach($oldStories as $oldStory)
  1761. {
  1762. $rootGroup[$oldStory->root] = isset($rootGroup[$oldStory->root]) ? $rootGroup[$oldStory->root] + 1 : 1;
  1763. if($oldStory->parent > 0) $parentIdList[] = $oldStory->parent;
  1764. }
  1765. $parents = $this->dao->select('id, grade, type')->from(TABLE_STORY)->where('id')->in($parentIdList)->fetchAll('id');
  1766. $sameRootList = '';
  1767. $gradeGtParentList = '';
  1768. $gradeOverflowList = '';
  1769. foreach($storyIdList as $storyID)
  1770. {
  1771. $oldStory = $oldStories[$storyID];
  1772. if($grade == $oldStory->grade) continue;
  1773. if($oldStory->type != $storyType) continue;
  1774. if($rootGroup[$oldStory->root] > 1)
  1775. {
  1776. $sameRootList .= "#{$storyID} ";
  1777. continue;
  1778. }
  1779. if($oldStory->parent > 0 && $grade < $parents[$oldStory->parent]->grade && $oldStory->type == $parents[$oldStory->parent]->type)
  1780. {
  1781. $gradeGtParentList .= "#{$storyID} ";
  1782. continue;
  1783. }
  1784. $story = new stdclass();
  1785. $story->lastEditedBy = $account;
  1786. $story->lastEditedDate = $now;
  1787. $story->grade = $grade;
  1788. if($story->grade > $oldStory->grade)
  1789. {
  1790. if(!$this->checkGrade($story, $oldStory))
  1791. {
  1792. $gradeOverflowList .= "#{$storyID} ";
  1793. continue;
  1794. }
  1795. }
  1796. if($story->grade != $oldStory->grade) $this->syncGrade($oldStory, $story);
  1797. $this->dao->update(TABLE_STORY)->data($story)->autoCheck()->where('id')->eq((int)$storyID)->exec();
  1798. if(!dao::isError())
  1799. {
  1800. $changes = common::createChanges($oldStory, $story);
  1801. $actionID = $this->action->create('story', (int)$storyID, 'Edited');
  1802. $this->action->logHistory($actionID, $changes);
  1803. }
  1804. }
  1805. if($gradeOverflowList) return sprintf($this->lang->story->batchGradeOverflow, $gradeOverflowList);
  1806. if($sameRootList) return sprintf($this->lang->story->batchGradeSameRoot, $sameRootList);
  1807. if($gradeGtParentList) return sprintf($this->lang->story->batchGradeGtParent, $gradeGtParentList);
  1808. return null;
  1809. }
  1810. /**
  1811. * Batch change the stage of story.
  1812. *
  1813. * @param array $storyIdList
  1814. * @param string $stage
  1815. *
  1816. * @access public
  1817. * @return string|null
  1818. */
  1819. public function batchChangeStage($storyIdList, $stage)
  1820. {
  1821. $now = helper::now();
  1822. $account = $this->app->user->account;
  1823. $oldStories = $this->getByList($storyIdList);
  1824. $ignoreStories = '';
  1825. $this->loadModel('action');
  1826. foreach($storyIdList as $storyID)
  1827. {
  1828. $oldStory = $oldStories[$storyID];
  1829. if($oldStory->type != 'story' || $oldStory->isParent == '1') continue;
  1830. if($oldStory->status == 'draft' or $oldStory->status == 'closed')
  1831. {
  1832. $ignoreStories .= "#{$storyID} ";
  1833. continue;
  1834. }
  1835. $story = new stdclass();
  1836. $story->lastEditedBy = $account;
  1837. $story->lastEditedDate = $now;
  1838. $story->stage = $stage;
  1839. $story->stagedBy = $account;
  1840. /* Add for record released date. */
  1841. if($story->stage == 'released') $story->releasedDate = $now;
  1842. $this->dao->update(TABLE_STORY)->data($story)->autoCheck()->where('id')->eq((int)$storyID)->exec();
  1843. $this->dao->update(TABLE_STORYSTAGE)->set('stage')->eq($stage)->set('stagedBy')->eq($account)->where('story')->eq((int)$storyID)->exec();
  1844. if(!dao::isError())
  1845. {
  1846. $changes = common::createChanges($oldStory, $story);
  1847. $action = $stage == 'verified' ? 'Verified' : 'Edited';
  1848. $actionID = $this->action->create('story', (int)$storyID, $action);
  1849. $this->action->logHistory($actionID, $changes);
  1850. }
  1851. $oldStory->stage = $stage;
  1852. $this->storyTao->computeParentStage($oldStory);
  1853. }
  1854. if($ignoreStories) return sprintf($this->lang->story->ignoreChangeStage, $ignoreStories);
  1855. return null;
  1856. }
  1857. /**
  1858. * Batch to task.
  1859. *
  1860. * @param int $executionID
  1861. * @param array $tasks
  1862. * @access public
  1863. * @return array|false
  1864. */
  1865. public function batchToTask($executionID, $tasks)
  1866. {
  1867. /* load Module and get the data from the post and get the current time. */
  1868. $this->loadModel('action');
  1869. $this->loadModel('task');
  1870. $taskIdList = array();
  1871. foreach($tasks as $task)
  1872. {
  1873. if(isset($task->estimate)) $task->estimate = round((float)$task->estimate, 2);
  1874. if(isset($task->left)) $task->left = round((float)$task->left, 2);
  1875. $this->dao->insert(TABLE_TASK)->data($task)->autoCheck()
  1876. ->batchCheck($this->config->task->create->requiredFields, 'notempty')
  1877. ->exec();
  1878. if(dao::isError()) return false;
  1879. $taskID = $this->dao->lastInsertID();
  1880. $this->dao->update(TABLE_TASK)->set('path')->eq(",$taskID,")->where('id')->eq($taskID)->exec();
  1881. $taskIdList[] = $taskID;
  1882. $taskSpec = new stdClass();
  1883. $taskSpec->task = $taskID;
  1884. $taskSpec->version = $task->version;
  1885. $taskSpec->name = $task->name;
  1886. $taskSpec->estStarted = $task->estStarted;
  1887. $taskSpec->deadline = $task->deadline;
  1888. $this->dao->insert(TABLE_TASKSPEC)->data($taskSpec)->autoCheck()->exec();
  1889. if(dao::isError()) return false;
  1890. if($task->story)
  1891. {
  1892. $this->setStage($task->story);
  1893. if($this->config->edition != 'open')
  1894. {
  1895. $relation = new stdClass();
  1896. $relation->relation = 'generated';
  1897. $relation->AID = $task->story;
  1898. $relation->AType = 'story';
  1899. $relation->BID = $taskID;
  1900. $relation->BType = 'task';
  1901. $relation->product = 0;
  1902. $this->dao->replace(TABLE_RELATION)->data($relation)->exec();
  1903. }
  1904. }
  1905. $this->action->create('task', $taskID, 'Opened', '');
  1906. }
  1907. $this->loadModel('kanban')->updateLane($executionID, 'task');
  1908. return $taskIdList;
  1909. }
  1910. /**
  1911. * 指派需求。
  1912. * Assign story.
  1913. *
  1914. * @param int $storyID
  1915. * @param object $story
  1916. * @access public
  1917. * @return array|bool
  1918. */
  1919. public function assign($storyID, $story)
  1920. {
  1921. $oldStory = $this->dao->findById($storyID)->from(TABLE_STORY)->fetch();
  1922. if($story->assignedTo == $oldStory->assignedTo) return array();
  1923. $this->dao->update(TABLE_STORY)->data($story)->autoCheck()->checkFlow()->where('id')->eq((int)$storyID)->exec();
  1924. $changes = common::createChanges($oldStory, $story);
  1925. if($changes)
  1926. {
  1927. $actionID = $this->loadModel('action')->create('story', $storyID, 'Assigned', $this->post->comment, $this->post->assignedTo);
  1928. $this->action->logHistory($actionID, $changes);
  1929. }
  1930. if(!empty($oldStory->twins)) $this->syncTwins($storyID, $oldStory->twins, $changes, 'Assigned');
  1931. return !dao::isError();
  1932. }
  1933. /**
  1934. * Batch assign to.
  1935. *
  1936. * @param array $storyIdList
  1937. * @param string $assignedTo
  1938. * @access public
  1939. * @return array
  1940. */
  1941. public function batchAssignTo($storyIdList, $assignedTo = '')
  1942. {
  1943. $this->loadModel('action');
  1944. $now = helper::now();
  1945. $allChanges = array();
  1946. $oldStories = $this->getByList($storyIdList);
  1947. foreach($storyIdList as $storyID)
  1948. {
  1949. $oldStory = $oldStories[$storyID];
  1950. if($oldStory->status == 'closed') continue;
  1951. if($assignedTo == $oldStory->assignedTo) continue;
  1952. $story = new stdclass();
  1953. $story->lastEditedBy = $this->app->user->account;
  1954. $story->lastEditedDate = $now;
  1955. $story->assignedTo = $assignedTo;
  1956. $story->assignedDate = $now;
  1957. $this->dao->update(TABLE_STORY)->data($story)->autoCheck()->where('id')->eq((int)$storyID)->exec();
  1958. $allChanges[$storyID] = common::createChanges($oldStory, $story);
  1959. $actionID = $this->action->create('story', (int)$storyID, 'Assigned', '', $assignedTo);
  1960. $this->action->logHistory($actionID, $allChanges[$storyID]);
  1961. }
  1962. return $allChanges;
  1963. }
  1964. /**
  1965. * 激活需求。
  1966. * Activate a story.
  1967. *
  1968. * @param int $storyID
  1969. * @access public
  1970. * @return bool
  1971. * @param object $postData
  1972. */
  1973. public function activate($storyID, $postData)
  1974. {
  1975. $oldStory = $this->dao->findById($storyID)->from(TABLE_STORY)->fetch();
  1976. /* Get status after activation. */
  1977. $story = $postData;
  1978. $story->status = $this->getActivateStatus($storyID);
  1979. $this->dao->update(TABLE_STORY)->data($story, 'comment')->autoCheck()->checkFlow()->where('id')->eq($storyID)->exec();
  1980. /* Update parent story status. */
  1981. if(!empty($oldStory->parent) && $oldStory->parent > 0) $this->updateParentStatus($storyID, $oldStory->parent);
  1982. $this->setStage($storyID);
  1983. $changes = common::createChanges($oldStory, $story);
  1984. if($changes)
  1985. {
  1986. $actionID = $this->loadModel('action')->create('story', $storyID, 'Activated', $this->post->comment);
  1987. $this->action->logHistory($actionID, $changes);
  1988. }
  1989. if(!empty($oldStory->twins)) $this->syncTwins($storyID, $oldStory->twins, $changes, 'Activated');
  1990. if($this->config->edition != 'open' && $oldStory->feedback) $this->loadModel('feedback')->updateStatus('story', $oldStory->feedback, $story->status, $oldStory->status, $storyID);
  1991. if($this->config->edition == 'ipd' and $oldStory->demand)
  1992. {
  1993. $this->loadModel('demand')->changeDemandStatus($oldStory->demand, '0', true);
  1994. $this->loadModel('action')->create('demand', $oldStory->demand, 'restored', '', $storyID);
  1995. $relation = new stdClass();
  1996. $relation->AID = $oldStory->demand;
  1997. $relation->AType = 'demand';
  1998. $relation->relation = 'subdivideinto';
  1999. $relation->BID = $storyID;
  2000. $relation->BType = $oldStory->type;
  2001. $relation->product = 0;
  2002. $this->dao->replace(TABLE_RELATION)->data($relation)->exec();
  2003. }
  2004. return !dao::isError();
  2005. }
  2006. /**
  2007. * Set stage of a story.
  2008. *
  2009. * @param int $storyID
  2010. * @access public
  2011. * @return bool
  2012. */
  2013. public function setStage($storyID)
  2014. {
  2015. $story = $this->dao->findById($storyID)->from(TABLE_STORY)->fetch();
  2016. if(empty($story)) return false;
  2017. /* 获取已经存在的分支阶段. */
  2018. $oldStages = $this->dao->select('*')->from(TABLE_STORYSTAGE)->where('story')->eq($storyID)->fetchAll('branch');
  2019. $this->dao->delete()->from(TABLE_STORYSTAGE)->where('story')->eq($storyID)->exec();
  2020. /* 手动设置了阶段,就不需要自动计算阶段了。 */
  2021. $product = $this->dao->findById($story->product)->from(TABLE_PRODUCT)->fetch();
  2022. $hasBranch = ($product and $product->type != 'normal' and empty($story->branch));
  2023. if (!empty($story->stagedBy) and $story->status != 'closed') return true;
  2024. /* 获取需求关联的分支和项目。 */
  2025. list($linkedBranches, $linkedProjects) = $this->storyTao->getLinkedBranchesAndProjects($storyID);
  2026. /* 设置默认阶段。 */
  2027. $stages = array();
  2028. if($hasBranch) $stages = $this->storyTao->getDefaultStages($story->plan, $linkedProjects ? $linkedBranches : array());
  2029. /* When the status is closed, stage is also changed to closed. */
  2030. if($story->status == 'closed') return $this->storyTao->setStageToClosed($storyID, array_merge($linkedBranches, array_keys($stages)), $linkedProjects);
  2031. /* If no executions, in plan, stage is planned. No plan, wait. */
  2032. if(!$linkedProjects) $this->storyTao->setStageToPlanned($storyID, $stages, $oldStages);
  2033. /* 根据需求关联的任务状态统计,计算需求的阶段。 */
  2034. $taskStat = $this->storyTao->getLinkedTaskStat($storyID, $linkedProjects);
  2035. $stages = $this->storyTao->computeStagesByTasks($storyID, $taskStat, $stages, $linkedProjects);
  2036. $stages = $this->storyTao->computeStagesByRelease($storyID, $stages);
  2037. $this->storyTao->updateStage($storyID, $stages, $oldStages, $linkedProjects);
  2038. return true;
  2039. }
  2040. /**
  2041. * 获取可关联的需求列表。
  2042. * Get the stories to link.
  2043. *
  2044. * @param int $storyID
  2045. * @param string $browseType bySearch
  2046. * @param int $queryID
  2047. * @param object $pager
  2048. * @param string $orderBy
  2049. * @access public
  2050. * @return array
  2051. */
  2052. public function getStories2Link($storyID, $browseType = 'bySearch', $queryID = 0, $pager = null, $orderBy = 'id_desc')
  2053. {
  2054. $story = $this->getById($storyID);
  2055. $excludes = $this->storyTao->getRelation($storyID, $story->type);
  2056. /* 自身不能关联自身。 */
  2057. $excludes[$storyID] = $storyID;
  2058. $stories2Link = array();
  2059. if($browseType == 'bySearch')
  2060. {
  2061. $stories2Link = $this->getBySearch($story->product, $story->branch, $queryID, $orderBy, 0, 'all', $excludes, '', $pager);
  2062. }
  2063. return $stories2Link;
  2064. }
  2065. /**
  2066. * Get stories list of a product.
  2067. *
  2068. * @param string|int|array $productID
  2069. * @param array|string|int $branch
  2070. * @param array|string $moduleIdList
  2071. * @param array|string $status
  2072. * @param string $type requirement|story
  2073. * @param string $orderBy
  2074. * @param array|string $excludeStories
  2075. * @param object|null $pager
  2076. * @param bool $hasParent
  2077. *
  2078. * @access public
  2079. * @return array
  2080. */
  2081. public function getProductStories($productID = 0, $branch = 0, $moduleIdList = '', $status = 'all', $type = 'story', $orderBy = 'id_desc', $hasParent = true, $excludeStories = '', $pager = null)
  2082. {
  2083. if(commonModel::isTutorialMode()) return $this->loadModel('tutorial')->getStories();
  2084. $showGrades = isset($this->config->{$type}->showGrades) ? $this->config->{$type}->showGrades : null;
  2085. if($showGrades)
  2086. {
  2087. $pattern = "/{$type}(\d+)/";
  2088. preg_match_all($pattern, $showGrades, $matches);
  2089. $showGrades = isset($matches[1]) ? $matches[1] : null;
  2090. }
  2091. $productQuery = $this->storyTao->buildProductsCondition($productID, $branch);
  2092. $stories = $this->dao->select("*,`plan`,`path`,IF(`pri` = 0, {$this->config->maxPriValue}, `pri`) as priOrder")->from(TABLE_STORY)
  2093. ->where('deleted')->eq(0)
  2094. ->andWhere($productQuery)
  2095. ->beginIF(!$hasParent)->andWhere("isParent")->eq('0')->fi()
  2096. ->beginIF(!empty($moduleIdList))->andWhere('module')->in($moduleIdList)->fi()
  2097. ->beginIF(!empty($excludeStories))->andWhere('id')->notIN($excludeStories)->fi()
  2098. ->beginIF($status and $status != 'all')->andWhere('status')->in($status)->fi()
  2099. ->andWhere("FIND_IN_SET('{$this->config->vision}', vision)")
  2100. ->beginIF($type != 'all')->andWhere('type')->in($type)->fi()
  2101. ->beginIF($showGrades)->andWhere('grade')->in($showGrades)->fi()
  2102. ->beginIF(empty($this->config->enableER))->andWhere('type')->ne('epic')->fi()
  2103. ->beginIF(empty($this->config->URAndSR) && $this->config->edition != 'ipd')->andWhere('type')->ne('requirement')->fi()
  2104. ->orderBy($orderBy)
  2105. ->page($pager)
  2106. ->fetchAll('id', false);
  2107. return $this->storyTao->mergePlanTitleAndChildren($productID, $stories, $type);
  2108. }
  2109. /**
  2110. * 通过产品获取需求ID和需求信息的键值对。
  2111. * Get stories pairs of a product.
  2112. *
  2113. * @param int|array $productIdList
  2114. * @param string|int $branch
  2115. * @param array|string|int $moduleIdList
  2116. * @param string|array $status ''|all|changing|active|draft|closed|reviewing
  2117. * @param string $order
  2118. * @param int $limit
  2119. * @param string $type full|all
  2120. * @param string $storyType requirement|story|demand
  2121. * @param bool|string $hasParent
  2122. * @access public
  2123. * @return array
  2124. */
  2125. public function getProductStoryPairs($productIdList = 0, $branch = 'all', $moduleIdList = '', $status = 'all', $order = 'id_desc', $limit = 0, $type = 'full', $storyType = 'story', $hasParent = true)
  2126. {
  2127. if(common::isTutorialMode()) return $this->loadModel('tutorial')->getStoryPairs();
  2128. if($hasParent === 'false') $hasParent = false;
  2129. $stories = $this->dao->select('t1.id, t1.title, t1.module, t1.pri, t1.estimate, t2.name AS product')
  2130. ->from(TABLE_STORY)->alias('t1')->leftJoin(TABLE_PRODUCT)->alias('t2')->on('t1.product = t2.id')
  2131. ->where('1=1')
  2132. ->beginIF($productIdList)->andWhere('t1.product')->in($productIdList)->fi()
  2133. ->beginIF($moduleIdList)->andWhere('t1.module')->in($moduleIdList)->fi()
  2134. ->beginIF($branch !== 'all')->andWhere('t1.branch')->in("0,$branch")->fi()
  2135. ->beginIF(!$hasParent)->andWhere('t1.isParent')->eq('0')->fi()
  2136. ->beginIF($status and $status != 'all')->andWhere('t1.status')->in($status)->fi()
  2137. ->beginIF($type != 'full' && $type != 'all')->andWhere('t1.type')->eq($storyType)->fi()
  2138. ->andWhere("FIND_IN_SET('{$this->config->vision}', t1.vision)")
  2139. ->andWhere('t1.deleted')->eq('0')
  2140. ->orderBy($order)
  2141. ->fetchAll();
  2142. if(!$stories) return array();
  2143. return $this->formatStories($stories, $type, $limit);
  2144. }
  2145. /**
  2146. * 关闭父需求的所有子需求。
  2147. * Close all children of a story.
  2148. *
  2149. * @param int $storyID
  2150. * @param string closedReason
  2151. * @access public
  2152. * @return void
  2153. */
  2154. public function closeAllChildren($storyID, $closedReason)
  2155. {
  2156. $now = helper::now();
  2157. $childIdList = $this->getAllChildId($storyID, false, false, 'closed');
  2158. if(empty($childIdList)) return;
  2159. $childList = $this->getByList($childIdList);
  2160. $this->dao->update(TABLE_STORY)
  2161. ->set('status')->eq('closed')
  2162. ->set('stage')->eq('closed')
  2163. ->set('assignedTo')->eq('closed')
  2164. ->set('assignedDate')->eq($now)
  2165. ->set('closedReason')->eq($closedReason)
  2166. ->set('closedBy')->eq($this->app->user->account)
  2167. ->set('closedDate')->eq($now)
  2168. ->set('lastEditedBy')->eq($this->app->user->account)
  2169. ->set('lastEditedDate')->eq($now)
  2170. ->where('id')->in($childIdList)
  2171. ->exec();
  2172. $this->loadModel('action');
  2173. foreach($childList as $child)
  2174. {
  2175. $preStatus = $child->status;
  2176. $isChanged = !empty($child->changedBy) ? true : false;
  2177. if($preStatus == 'reviewing') $preStatus = $isChanged ? 'changing' : 'draft';
  2178. $this->loadModel('action')->create('story', $child->id, 'closedbyparent', '', ucfirst($closedReason) . "|$preStatus");
  2179. }
  2180. }
  2181. /**
  2182. * 获取需求的所有子需求ID。
  2183. * Get all child stories of a story.
  2184. *
  2185. * @param int $storyID
  2186. * @param bool $includeSelf
  2187. * @param bool $sameType true|false
  2188. * @param string $excludeStatus
  2189. * @access public
  2190. * @return array
  2191. */
  2192. public function getAllChildId($storyID, $includeSelf = true, $sameType = false, $excludeStatus = '')
  2193. {
  2194. if($storyID == 0) return array();
  2195. $story = $this->fetchByID($storyID);
  2196. if(empty($story)) return array();
  2197. $children = $this->dao->select('id')->from(TABLE_STORY)
  2198. ->where('path')->like($story->path . '%')
  2199. ->andWhere('deleted')->eq(0)
  2200. ->beginIF(!$includeSelf)->andWhere('id')->ne($storyID)->fi()
  2201. ->beginIF($sameType)->andWhere('type')->eq($story->type)->fi()
  2202. ->beginIF(!empty($excludeStatus))->andWhere('status')->ne($excludeStatus)->fi()
  2203. ->fetchPairs();
  2204. return array_keys($children);
  2205. }
  2206. /**
  2207. * Get stories by assignedTo.
  2208. *
  2209. * @param int $productID
  2210. * @param string $branch
  2211. * @param string $account
  2212. * @param string $type requirement|story
  2213. * @param string $orderBy
  2214. * @param object $pager
  2215. * @access public
  2216. * @return array
  2217. */
  2218. public function getByAssignedTo($productID, $branch, $modules, $account, $type = 'story', $orderBy = '', $pager = null)
  2219. {
  2220. return $this->getByField($productID, $branch, $modules, 'assignedTo', $account, $type, $orderBy, $pager);
  2221. }
  2222. /**
  2223. * Get stories by openedBy.
  2224. *
  2225. * @param int $productID
  2226. * @param int $branch
  2227. * @param string $modules
  2228. * @param string $account
  2229. * @param string $type requirement|story
  2230. * @param string $orderBy
  2231. * @param object $pager
  2232. * @access public
  2233. * @return array
  2234. */
  2235. public function getByOpenedBy($productID, $branch, $modules, $account, $type = 'story', $orderBy = '', $pager = null)
  2236. {
  2237. return $this->getByField($productID, $branch, $modules, 'openedBy', $account, $type, $orderBy, $pager);
  2238. }
  2239. /**
  2240. * Get stories by reviewedBy.
  2241. *
  2242. * @param int $productID
  2243. * @param int $branch
  2244. * @param string $modules
  2245. * @param string $account
  2246. * @param string $type requirement|story
  2247. * @param string $orderBy
  2248. * @param object $pager
  2249. * @access public
  2250. * @return array
  2251. */
  2252. public function getByReviewedBy($productID, $branch, $modules, $account, $type = 'story', $orderBy = '', $pager = null)
  2253. {
  2254. return $this->getByField($productID, $branch, $modules, 'reviewedBy', $account, $type, $orderBy, $pager, 'include');
  2255. }
  2256. /**
  2257. * Get stories which need to review.
  2258. *
  2259. * @param int $productID
  2260. * @param int $branch
  2261. * @param string $modules
  2262. * @param string $account
  2263. * @param string $type requirement|story
  2264. * @param string $orderBy
  2265. * @param object $pager
  2266. * @access public
  2267. * @return array
  2268. */
  2269. public function getByReviewBy($productID, $branch, $modules, $account, $type = 'story', $orderBy = '', $pager = null)
  2270. {
  2271. return $this->getByField($productID, $branch, $modules, 'reviewBy', $account, $type, $orderBy, $pager);
  2272. }
  2273. /**
  2274. * Get stories by closedBy.
  2275. *
  2276. * @param int $productID
  2277. * @param int $branch
  2278. * @param string $modules
  2279. * @param string $account
  2280. * @param string $type requirement|story
  2281. * @param string $orderBy
  2282. * @param object $pager
  2283. * @return array
  2284. */
  2285. public function getByClosedBy($productID, $branch, $modules, $account, $type = 'story', $orderBy = '', $pager = null)
  2286. {
  2287. return $this->getByField($productID, $branch, $modules, 'closedBy', $account, $type, $orderBy, $pager);
  2288. }
  2289. /**
  2290. * Get stories by status.
  2291. *
  2292. * @param int $productID
  2293. * @param int $branch
  2294. * @param string $modules
  2295. * @param string $status
  2296. * @param string $type requirement|story
  2297. * @param string $orderBy
  2298. * @param object $pager
  2299. * @access public
  2300. * @return array
  2301. */
  2302. public function getByStatus($productID, $branch, $modules, $status, $type = 'story', $orderBy = '', $pager = null)
  2303. {
  2304. return $this->getByField($productID, $branch, $modules, 'status', $status, $type, $orderBy, $pager);
  2305. }
  2306. /**
  2307. * Get stories by plan.
  2308. *
  2309. * @param int $productID
  2310. * @param int $branch
  2311. * @param array $modules
  2312. * @param int $plan
  2313. * @param string $type requirement|story
  2314. * @param string $orderBy
  2315. * @param object $pager
  2316. *
  2317. * @access public
  2318. * @return array
  2319. */
  2320. public function getByPlan($productID, $branch, $modules, $plan, $type = 'story', $orderBy = '', $pager = null)
  2321. {
  2322. return $this->getByField($productID, $branch, $modules, 'plan', $plan, $type, $orderBy, $pager);
  2323. }
  2324. /**
  2325. * Get stories by assignedBy.
  2326. *
  2327. * @param int $productID
  2328. * @param int $branch
  2329. * @param string $modules
  2330. * @param string $account
  2331. * @param string $type requirement|story
  2332. * @param string $orderBy
  2333. * @param object $pager
  2334. * @access public
  2335. * @return array
  2336. */
  2337. public function getByAssignedBy($productID, $branch, $modules, $account, $type = 'story', $orderBy = '', $pager = null)
  2338. {
  2339. return $this->getByField($productID, $branch, $modules, 'assignedBy', $account, $type, $orderBy, $pager);
  2340. }
  2341. /**
  2342. * Get stories by a field.
  2343. *
  2344. * @param int|array $productID
  2345. * @param int|string $branch
  2346. * @param string|array $modules
  2347. * @param string $fieldName
  2348. * @param string $fieldValue
  2349. * @param string $type requirement|story
  2350. * @param string $orderBy
  2351. * @param object $pager
  2352. * @param string $operator equal|include
  2353. * @access public
  2354. * @return array
  2355. */
  2356. public function getByField($productID, $branch, $modules, $fieldName, $fieldValue, $type = 'story', $orderBy = '', $pager = null, $operator = 'equal')
  2357. {
  2358. if(!$this->loadModel('common')->checkField(TABLE_STORY, $fieldName) and $fieldName != 'reviewBy' and $fieldName != 'assignedBy') return array();
  2359. $actionIDList = array();
  2360. if($fieldName == 'assignedBy') $actionIDList = $this->dao->select('objectID')->from(TABLE_ACTION)->where('objectType')->eq('story')->andWhere('action')->eq('assigned')->andWhere('actor')->eq($fieldValue)->fetchPairs('objectID', 'objectID');
  2361. $sql = $this->dao->select("t1.*, t1.`path`, t1.`plan`, IF(t1.`pri` = 0, {$this->config->maxPriValue}, t1.`pri`) as priOrder")->from(TABLE_STORY)->alias('t1');
  2362. if($fieldName == 'reviewBy') $sql = $sql->leftJoin(TABLE_STORYREVIEW)->alias('t2')->on('t1.id = t2.story and t1.version = t2.version');
  2363. $showGrades = isset($this->config->{$type}->showGrades) ? $this->config->{$type}->showGrades : null;
  2364. if($showGrades)
  2365. {
  2366. $pattern = "/{$type}(\d+)/";
  2367. preg_match_all($pattern, $showGrades, $matches);
  2368. $showGrades = isset($matches[1]) ? $matches[1] : null;
  2369. }
  2370. if($fieldValue == 'launched' && $fieldName == 'status')
  2371. {
  2372. $fieldValue = 'projected';
  2373. $fieldName = 'stage';
  2374. }
  2375. $stories = $sql->where('t1.product')->in($productID)
  2376. ->andWhere('t1.deleted')->eq(0)
  2377. ->andWhere("FIND_IN_SET('{$this->config->vision}', t1.vision)")
  2378. ->andWhere('t1.type')->eq($type)
  2379. ->beginIF($showGrades)->andWhere('t1.grade')->in($showGrades)->fi()
  2380. ->beginIF($branch != 'all')->andWhere("t1.branch")->eq($branch)->fi()
  2381. ->beginIF($modules)->andWhere("t1.module")->in($modules)->fi()
  2382. ->beginIF($operator == 'equal' and $fieldName != 'reviewBy' and $fieldName != 'assignedBy')->andWhere('t1.' . $fieldName)->eq($fieldValue)->fi()
  2383. ->beginIF($operator == 'include' and $fieldName != 'reviewBy' and $fieldName != 'assignedBy')->andWhere('t1.' . $fieldName)->like("%$fieldValue%")->fi()
  2384. ->beginIF($fieldName == 'reviewBy')
  2385. ->andWhere('t2.reviewer')->eq($this->app->user->account)
  2386. ->andWhere('t2.result')->eq('')
  2387. ->andWhere('t1.status')->eq('reviewing')
  2388. ->fi()
  2389. ->beginIF($fieldName == 'assignedBy')->andWhere('t1.id')->in($actionIDList)->andWhere('t1.status')->ne('closed')->fi()
  2390. ->orderBy($orderBy)
  2391. ->page($pager)
  2392. ->fetchAll('id');
  2393. return $this->storyTao->mergePlanTitleAndChildren($productID, $stories, $type);
  2394. }
  2395. /**
  2396. * Get to be closed stories.
  2397. *
  2398. * @param int|array $productID
  2399. * @param int $branch
  2400. * @param string $modules
  2401. * @param string $type requirement|story
  2402. * @param string $orderBy
  2403. * @param object $pager
  2404. * @access public
  2405. * @return array
  2406. */
  2407. public function get2BeClosed($productID, $branch, $modules, $type = 'story', $orderBy = '', $pager = null)
  2408. {
  2409. $showGrades = isset($this->config->{$type}->showGrades) ? $this->config->{$type}->showGrades : null;
  2410. if($showGrades)
  2411. {
  2412. $pattern = "/{$type}(\d+)/";
  2413. preg_match_all($pattern, $showGrades, $matches);
  2414. $showGrades = isset($matches[1]) ? $matches[1] : null;
  2415. }
  2416. $stories = $this->dao->select("*, `path`, `plan`, IF(`pri` = 0, {$this->config->maxPriValue}, `pri`) as priOrder")->from(TABLE_STORY)
  2417. ->where('product')->in($productID)
  2418. ->andWhere('type')->eq($type)
  2419. ->beginIF($showGrades)->andWhere('grade')->in($showGrades)->fi()
  2420. ->beginIF($branch and $branch != 'all')->andWhere("branch")->eq($branch)->fi()
  2421. ->beginIF($modules)->andWhere("module")->in($modules)->fi()
  2422. ->andWhere('deleted')->eq(0)
  2423. ->andWhere("FIND_IN_SET('{$this->config->vision}', vision)")
  2424. ->andWhere('stage')->in('developed,released,delivered')
  2425. ->andWhere('status')->ne('closed')
  2426. ->orderBy($orderBy)
  2427. ->page($pager)
  2428. ->fetchAll('id');
  2429. return $this->storyTao->mergePlanTitleAndChildren($productID, $stories, $type);
  2430. }
  2431. /**
  2432. * Get stories through search.
  2433. *
  2434. * @access public
  2435. * @param int|string $productID
  2436. * @param int|string $branch
  2437. * @param int $queryID
  2438. * @param string $orderBy
  2439. * @param int|array $executionID
  2440. * @param string $type requirement|story
  2441. * @param string $excludeStories
  2442. * @param string $excludeStatus
  2443. * @param object $pager
  2444. * @access public
  2445. * @return array
  2446. */
  2447. public function getBySearch($productID, $branch = '', $queryID = 0, $orderBy = '', $executionID = 0, $type = 'story', $excludeStories = '', $excludeStatus = '', $pager = null)
  2448. {
  2449. $this->loadModel('product');
  2450. $executionID = empty($executionID) ? 0 : $executionID;
  2451. if(is_array($executionID))
  2452. {
  2453. $products = array();
  2454. foreach($executionID as $id)
  2455. {
  2456. $productList = $this->product->getProducts($id);
  2457. $products += $productList;
  2458. }
  2459. }
  2460. elseif($this->app->rawModule == 'mr' && $this->session->repoID)
  2461. {
  2462. $repo = $this->loadModel('repo')->fetchByID((int)$this->session->repoID);
  2463. $products = $repo ? array_flip(explode(',', $repo->product)) : array();
  2464. }
  2465. else
  2466. {
  2467. $products = empty($executionID) ? $this->product->getList(0, 'all', 0, 0, 'all') : $this->product->getProducts($executionID);
  2468. }
  2469. $module = in_array($type, array('requirement', 'epic')) ? $type : 'story';
  2470. $this->loadModel('search')->setQuery($module, $queryID);
  2471. $allProduct = "`product` = 'all'";
  2472. $queryVar = "{$module}Query";
  2473. $storyQuery = $this->session->{$queryVar};
  2474. $queryProductID = $productID;
  2475. if(strpos($storyQuery, $allProduct) !== false)
  2476. {
  2477. $storyQuery = str_replace($allProduct, '1=1', $storyQuery);
  2478. $queryProductID = 'all';
  2479. }
  2480. $storyQuery = $storyQuery . ' AND `product` ' . helper::dbIN(array_keys($products));
  2481. if($excludeStories)
  2482. {
  2483. $dbIN = helper::dbIN($excludeStories);
  2484. $dbIN = strpos($dbIN, '=') === 0 ? "`id` !{$dbIN}" : "`id` NOT {$dbIN}";
  2485. $storyQuery = $storyQuery . ' AND ' . $dbIN;
  2486. }
  2487. if($excludeStatus) $storyQuery = $storyQuery . ' AND `status` NOT ' . helper::dbIN($excludeStatus);
  2488. if($this->app->moduleName == 'productplan') $storyQuery .= " AND `status` NOT IN ('closed') AND `parent` >= 0 ";
  2489. if(in_array($this->app->rawModule, array('build', 'release', 'projectrelease')) && $this->app->rawMethod == 'linkstory') $storyQuery .= " AND `parent` != '-1'";
  2490. $allBranch = "`branch` = 'all'";
  2491. if(!empty($executionID))
  2492. {
  2493. $normalProducts = array();
  2494. $branchProducts = array();
  2495. foreach($products as $product)
  2496. {
  2497. if($product->type != 'normal') $branchProducts[$product->id] = $product;
  2498. if($product->type == 'normal') $normalProducts[$product->id] = $product;
  2499. }
  2500. $storyQuery .= ' AND (';
  2501. if(!empty($normalProducts)) $storyQuery .= '`product` ' . helper::dbIN(array_keys($normalProducts));
  2502. if(!empty($branchProducts))
  2503. {
  2504. $branches = array(BRANCH_MAIN => BRANCH_MAIN);
  2505. if($branch === '')
  2506. {
  2507. foreach($branchProducts as $product)
  2508. {
  2509. foreach($product->branches as $branchID) $branches[$branchID] = $branchID;
  2510. }
  2511. }
  2512. else
  2513. {
  2514. $branches[$branch] = $branch;
  2515. }
  2516. $branches = implode(',', $branches);
  2517. if(!empty($normalProducts)) $storyQuery .= " OR ";
  2518. $storyQuery .= "(`product` " . helper::dbIN(array_keys($branchProducts)) . " AND `branch` " . helper::dbIN($branches) . ")";
  2519. }
  2520. if(empty($normalProducts) and empty($branchProducts)) $storyQuery .= '1 = 1';
  2521. $storyQuery .= ') ';
  2522. if($this->app->moduleName == 'release' or $this->app->moduleName == 'build')
  2523. {
  2524. $storyQuery .= " AND `status` != 'draft'"; // Fix bug #990.
  2525. }
  2526. else
  2527. {
  2528. $storyQuery .= " AND `status` NOT IN ('draft', 'reviewing', 'changing', 'closed')";
  2529. }
  2530. }
  2531. elseif(strpos($storyQuery, $allBranch) !== false)
  2532. {
  2533. $storyQuery = str_replace($allBranch, '1=1', $storyQuery);
  2534. }
  2535. elseif($branch !== 'all' and $branch !== '' and strpos($storyQuery, '`branch` =') === false and $queryProductID != 'all')
  2536. {
  2537. if($branch and strpos($storyQuery, '`branch` =') === false) $storyQuery .= " AND `branch` " . helper::dbIN((string)$branch);
  2538. }
  2539. $storyQuery = preg_replace("/`plan` +LIKE +'%([0-9]+)%'/i", "CONCAT(',', `plan`, ',') LIKE '%,$1,%'", $storyQuery);
  2540. $storyQuery = preg_replace_callback("/AND `grade` (=|!=) '(\w+)(\d+)'/", function($matches){return "AND `grade` {$matches[1]} '" . $matches[3] . "' AND `type` = '" . $matches[2] . "'";}, $storyQuery);
  2541. return $this->getBySQL($queryProductID, $storyQuery, $orderBy, $pager, $type);
  2542. }
  2543. /**
  2544. * Get stories by a sql.
  2545. *
  2546. * @param int|string $productID int|all
  2547. * @param string $sql
  2548. * @param string $orderBy
  2549. * @param object $pager
  2550. * @param string $type requirement|story
  2551. * @access public
  2552. * @return array
  2553. */
  2554. public function getBySQL($productID, $sql, $orderBy, $pager = null, $type = 'story')
  2555. {
  2556. /* Get plans. */
  2557. $plans = $this->dao->select('id,title')->from(TABLE_PRODUCTPLAN)->where('deleted')->eq('0')
  2558. ->beginIF($productID != 'all' and $productID != '')->andWhere('product')->eq((int)$productID)->fi()
  2559. ->fetchPairs();
  2560. $review = $this->storyTao->getRevertStoryIdList((int)$productID);
  2561. $sql = str_replace(array('`product`', '`version`', '`branch`', '`id`'), array('t1.`product`', 't1.`version`', 't1.`branch`', 't1.`id`'), $sql);
  2562. if(strpos($sql, 'result') !== false)
  2563. {
  2564. if(strpos($sql, 'revert') !== false)
  2565. {
  2566. $sql = str_replace("AND `result` = 'revert'", '', $sql);
  2567. $sql .= " AND t1.`id` " . helper::dbIN($review);
  2568. }
  2569. else
  2570. {
  2571. $sql = str_replace(array('`result`'), array('t3.`result`'), $sql);
  2572. }
  2573. }
  2574. $showGrades = isset($this->config->{$type}->showGrades) ? $this->config->{$type}->showGrades : null;
  2575. if($showGrades)
  2576. {
  2577. $pattern = "/{$type}(\d+)/";
  2578. preg_match_all($pattern, $showGrades, $matches);
  2579. $showGrades = isset($matches[1]) ? $matches[1] : null;
  2580. }
  2581. $tmpStories = $this->dao->select("DISTINCT t1.*, IF(t1.`pri` = 0, {$this->config->maxPriValue}, t1.`pri`) as priOrder")->from(TABLE_STORY)->alias('t1')
  2582. ->leftJoin(TABLE_PROJECTSTORY)->alias('t2')->on('t1.id=t2.story')
  2583. ->beginIF(strpos($sql, 'result') !== false)->leftJoin(TABLE_STORYREVIEW)->alias('t3')->on('t1.id = t3.story and t1.version = t3.version')->fi()
  2584. ->where($sql)
  2585. ->beginIF($productID != 'all' && $productID != '' && $productID != 0)->andWhere('t1.`product`')->eq((int)$productID)->fi()
  2586. ->andWhere('t1.deleted')->eq(0)
  2587. ->andWhere("FIND_IN_SET('{$this->config->vision}', t1.vision)")
  2588. ->beginIF($type != 'all')->andWhere('t1.type')->in($type)->fi()
  2589. ->beginIF($showGrades)->andWhere('t1.grade')->in($showGrades)->fi()
  2590. ->beginIF(empty($this->config->enableER))->andWhere('t1.type')->ne('epic')->fi()
  2591. ->beginIF(empty($this->config->URAndSR) && $this->config->edition != 'ipd')->andWhere('t1.type')->ne('requirement')->fi()
  2592. ->orderBy($orderBy)
  2593. ->page($pager, 't1.id')
  2594. ->fetchAll('id');
  2595. if(!$tmpStories) return array();
  2596. /* Process plans. */
  2597. $stories = array();
  2598. foreach($tmpStories as $story)
  2599. {
  2600. $story->planTitle = '';
  2601. $storyPlans = explode(',', trim($story->plan, ','));
  2602. foreach($storyPlans as $planID) $story->planTitle .= zget($plans, $planID, '') . ' ';
  2603. $story->parent = array();
  2604. foreach(explode(',', trim((string)$story->path, ',')) as $parentID)
  2605. {
  2606. if(!$parentID) continue;
  2607. if($parentID == $story->id) continue;
  2608. $story->parent[] = (int)$parentID;
  2609. }
  2610. $stories[$story->id] = $story;
  2611. }
  2612. return $stories;
  2613. }
  2614. /**
  2615. * 获取某产品下的父需求的键值对列表。
  2616. * Get parent story pairs.
  2617. *
  2618. * @param int $productID
  2619. * @param string|int $appendedStories
  2620. * @param string $storyType
  2621. * @param int $storyID
  2622. * @access public
  2623. * @return array
  2624. */
  2625. public function getParentStoryPairs($productID, $appendedStories = '', $storyType = 'story', $storyID = 0)
  2626. {
  2627. if(common::isTutorialMode()) return array();
  2628. if($storyType == 'story')
  2629. {
  2630. $maxGradeGroup = $this->getMaxGradeGroup();
  2631. $lastGrade = $this->dao->select('grade')->from(TABLE_STORYGRADE)->where('type')->eq($storyType)->andWhere('status')->eq('enable')->orderBy('grade_desc')->limit(1)->fetch('grade');
  2632. $SRGradePairs = $this->getGradePairs('story', 'all');
  2633. $URGradePairs = $this->getGradePairs('requirement', 'all');
  2634. $requirements = $this->dao->select('id, parent, grade, title, status')->from(TABLE_STORY)
  2635. ->where('deleted')->eq('0')
  2636. ->andWhere('product')->eq($productID)
  2637. ->andWhere('type')->eq('requirement')
  2638. ->andWhere("FIND_IN_SET('{$this->config->vision}', vision)")
  2639. ->beginIF($this->config->vision == 'rnd' && $this->config->requirement->gradeRule == 'stepwise')->andWhere('grade')->eq($maxGradeGroup['requirement'])->fi()
  2640. ->fetchAll('id');
  2641. $parents = array();
  2642. foreach($requirements as $requirement) $parents[$requirement->parent] = $requirement->parent;
  2643. foreach($requirements as $id => $requirement)
  2644. {
  2645. if(isset($parents[$requirement->id])) unset($requirements[$id]);
  2646. if(!isset($URGradePairs[$requirement->grade])) unset($requirements[$id]);
  2647. if(in_array($requirement->status, array('reviewing', 'closed'))) unset($requirements[$id]);
  2648. }
  2649. $childIdList = $this->getAllChildId($storyID);
  2650. $stories = $this->dao->select('id, grade, title')->from(TABLE_STORY)
  2651. ->where('deleted')->eq('0')
  2652. ->andWhere('product')->eq($productID)
  2653. ->andWhere('type')->eq('story')
  2654. ->andWhere('status')->notin('reviewing,closed')
  2655. ->andWhere('twins')->eq('')
  2656. ->andWhere('grade')->ne($lastGrade)
  2657. ->andWhere('grade')->in(array_keys($SRGradePairs))
  2658. ->beginIF($childIdList)->andWhere('id')->notIN($childIdList)->fi()
  2659. ->beginIF(!empty($appendedStories))->orWhere('id')->in($appendedStories)->fi()
  2660. ->fetchAll('id');
  2661. $storyIdList = array_keys($stories);
  2662. $casePairs = $this->dao->select('story')->from(TABLE_CASE)->where('story')->in($storyIdList)->andWhere('story')->notin($appendedStories)->andWhere('story')->ne('0')->andWhere('deleted')->eq('0')->fetchPairs();
  2663. $taskPairs = $this->dao->select('story')->from(TABLE_TASK)->where('story')->in($storyIdList)->andWhere('story')->notin($appendedStories)->andWhere('story')->ne('0')->andWhere('deleted')->eq('0')->fetchPairs();
  2664. foreach($stories as $story)
  2665. {
  2666. if(isset($casePairs[$story->id]) || isset($taskPairs[$story->id])) unset($stories[$story->id]);
  2667. }
  2668. $parents = $requirements + $stories;
  2669. return $this->addGradeLabel($parents);
  2670. }
  2671. elseif($storyType == 'requirement')
  2672. {
  2673. return $this->getRequirementParents($productID, $appendedStories, $storyType, $storyID);
  2674. }
  2675. elseif($storyType == 'epic')
  2676. {
  2677. return $this->getEpicParents($productID, $appendedStories, $storyType, $storyID);
  2678. }
  2679. }
  2680. /**
  2681. * 获取用户需求的父需求键值对。
  2682. * Get requirement parents.
  2683. *
  2684. * @param int $productID
  2685. * @param string|int $appendedStories
  2686. * @param string $storyType
  2687. * @param int $storyID
  2688. * @access public
  2689. * @return array
  2690. */
  2691. public function getRequirementParents($productID, $appendedStories = '', $storyType = 'requirement', $storyID = 0)
  2692. {
  2693. $maxGradeGroup = $this->getMaxGradeGroup();
  2694. $lastGrade = $this->dao->select('grade')->from(TABLE_STORYGRADE)->where('type')->eq($storyType)->andWhere('status')->eq('enable')->orderBy('grade_desc')->limit(1)->fetch('grade');
  2695. $ERGradePairs = $this->getGradePairs('epic', 'all');
  2696. $URGradePairs = $this->getGradePairs('requirement', 'all');
  2697. $epics = $this->dao->select('id, parent, grade, title, status')->from(TABLE_STORY)
  2698. ->where('deleted')->eq('0')
  2699. ->andWhere('product')->eq($productID)
  2700. ->andWhere('type')->eq('epic')
  2701. ->andWhere("CONCAT(',', vision, ',')")->like("%,{$this->config->vision},%")
  2702. ->beginIF($this->config->vision == 'rnd' && $this->config->epic->gradeRule == 'stepwise')->andWhere('grade')->eq($maxGradeGroup['epic'])->fi()
  2703. ->fetchAll('id');
  2704. $parents = array();
  2705. foreach($epics as $epic) $parents[$epic->parent] = $epic->parent;
  2706. foreach($epics as $id => $epic)
  2707. {
  2708. if(isset($parents[$epic->id])) unset($epics[$id]);
  2709. if(!isset($ERGradePairs[$epic->grade])) unset($epics[$id]);
  2710. if(in_array($epic->status, array('reviewing', 'closed'))) unset($epics[$id]);
  2711. }
  2712. $childIdList = $this->getAllChildId($storyID);
  2713. $allStoryParents = $this->dao->select('parent')->from(TABLE_STORY)
  2714. ->where('deleted')->eq('0')
  2715. ->andWhere('product')->eq($productID)
  2716. ->andWhere('type')->eq('story')
  2717. ->andWhere('parent')->gt('0')
  2718. ->fetchPairs();
  2719. $requirements = $this->dao->select('id, parent, grade, title')->from(TABLE_STORY)
  2720. ->where('deleted')->eq('0')
  2721. ->andWhere('product')->eq($productID)
  2722. ->andWhere('type')->eq('requirement')
  2723. ->andWhere("CONCAT(',', vision, ',')")->like("%,{$this->config->vision},%")
  2724. ->andWhere('status')->notin('reviewing,closed')
  2725. ->andWhere('grade')->in(array_keys($URGradePairs))
  2726. ->andWhere('grade')->ne($lastGrade)
  2727. ->andWhere('id')->notIN($allStoryParents)
  2728. ->beginIF($childIdList)->andWhere('id')->notIN($childIdList)->fi()
  2729. ->beginIF(!empty($appendedStories))->orWhere('id')->in($appendedStories)->fi()
  2730. ->fetchAll('id');
  2731. $parents = $epics + $requirements;
  2732. if(!$this->config->enableER) $parents = $requirements;
  2733. return $this->addGradeLabel($parents);
  2734. }
  2735. /**
  2736. * 获取业务需求的父需求键值对。
  2737. * Get epic parents.
  2738. *
  2739. * @param int $productID
  2740. * @param string|int $appendedStories
  2741. * @param string $storyType
  2742. * @param int $storyID
  2743. * @access public
  2744. * @return array
  2745. */
  2746. public function getEpicParents($productID, $appendedStories = '', $storyType = 'epic', $storyID = 0)
  2747. {
  2748. $lastGrade = $this->dao->select('grade')->from(TABLE_STORYGRADE)->where('type')->eq($storyType)->andWhere('status')->eq('enable')->orderBy('grade_desc')->limit(1)->fetch('grade');
  2749. $ERGradePairs = $this->getGradePairs('epic', 'all');
  2750. $childIdList = $this->getAllChildId($storyID);
  2751. $allRequirementParents = $this->dao->select('parent')->from(TABLE_STORY)
  2752. ->where('deleted')->eq('0')
  2753. ->andWhere('product')->eq($productID)
  2754. ->andWhere('type')->eq('requirement')
  2755. ->andWhere("CONCAT(',', vision, ',')")->like("%,{$this->config->vision},%")
  2756. ->andWhere('parent')->gt('0')
  2757. ->fetchPairs();
  2758. $epics = $this->dao->select('id, parent, grade, title')->from(TABLE_STORY)
  2759. ->where('deleted')->eq('0')
  2760. ->andWhere('product')->eq($productID)
  2761. ->andWhere('type')->eq('epic')
  2762. ->andWhere("CONCAT(',', vision, ',')")->like("%,{$this->config->vision},%")
  2763. ->andWhere('status')->notin('reviewing,closed')
  2764. ->andWhere('grade')->in(array_keys($ERGradePairs))
  2765. ->andWhere('grade')->ne($lastGrade)
  2766. ->andWhere('id')->notIN($allRequirementParents)
  2767. ->beginIF($childIdList)->andWhere('id')->notIN($childIdList)->fi()
  2768. ->beginIF(!empty($appendedStories))->orWhere('id')->in($appendedStories)->fi()
  2769. ->fetchAll('id');
  2770. return $this->addGradeLabel($epics);
  2771. }
  2772. /**
  2773. * Get stories of a user.
  2774. *
  2775. * @param string $account
  2776. * @param string $type the query type
  2777. * @param string $orderBy
  2778. * @param object $pager
  2779. * @param string $storyType requirement|story
  2780. * @param string|int $shadow all | 0 | 1
  2781. * @param int $productID
  2782. * @access public
  2783. * @return array
  2784. * @param bool $includeLibStories
  2785. */
  2786. public function getUserStories($account, $type = 'assignedTo', $orderBy = 'id_desc', $pager = null, $storyType = 'story', $includeLibStories = true, $shadow = 0, $productID = 0)
  2787. {
  2788. $sql = $this->dao->select("t1.*, t1.`path`, t1.`plan`, IF(t1.`pri` = 0, {$this->config->maxPriValue}, t1.`pri`) as priOrder, t2.name as productTitle, t2.shadow as shadow")->from(TABLE_STORY)->alias('t1')
  2789. ->leftJoin(TABLE_PRODUCT)->alias('t2')->on('t1.product = t2.id');
  2790. if($type == 'reviewBy') $sql = $sql->leftJoin(TABLE_STORYREVIEW)->alias('t3')->on('t1.id = t3.story and t1.version = t3.version');
  2791. $stories = $sql->where('t1.deleted')->eq(0)
  2792. ->andWhere('t2.deleted')->eq('0')
  2793. ->andWhere('t1.type')->eq($storyType)
  2794. ->andWhere("FIND_IN_SET('{$this->config->vision}', t1.vision)")
  2795. ->beginIF($type != 'closedBy' and $this->app->moduleName == 'block')->andWhere('t1.status')->ne('closed')->fi()
  2796. ->beginIF($type != 'all')
  2797. ->beginIF($type == 'assignedTo')->andWhere('t1.assignedTo')->eq($account)->fi()
  2798. ->beginIF($type == 'reviewBy')->andWhere('t3.reviewer')->eq($account)->andWhere('t3.result')->eq('')->andWhere('t1.status')->in('reviewing')->fi()
  2799. ->beginIF($type == 'openedBy')->andWhere('t1.openedBy')->eq($account)->fi()
  2800. ->beginIF($type == 'reviewedBy')->andWhere("CONCAT(',', t1.reviewedBy, ',')")->like("%,$account,%")->fi()
  2801. ->beginIF($type == 'closedBy')->andWhere('t1.closedBy')->eq($account)->fi()
  2802. ->fi()
  2803. ->beginIF(!$includeLibStories and $this->config->edition == 'max')->andWhere('t1.lib')->eq('0')->fi()
  2804. ->beginIF($shadow !== 'all')->andWhere('t2.shadow')->eq((int)$shadow)->fi()
  2805. ->beginIF($productID)->andWhere('t1.product')->eq((int)$productID)->fi()
  2806. ->orderBy($orderBy)
  2807. ->page($pager)
  2808. ->fetchAll('id', false);
  2809. $this->loadModel('common')->saveQueryCondition($this->dao->get(), 'story', false);
  2810. $productIdList = array();
  2811. foreach($stories as $story) $productIdList[$story->product] = $story->product;
  2812. return $this->storyTao->mergePlanTitleAndChildren($productIdList, $stories, $storyType);
  2813. }
  2814. /**
  2815. * 通过用户获取需求ID和需求信息的键值对。array(storyID => storyTitle, ...)
  2816. * Get story pairs by account name of the user.
  2817. *
  2818. * @param string $account
  2819. * @param int $limit
  2820. * @param string $type requirement|story
  2821. * @param array $skipProductIDList
  2822. * @param int|array $appendStoryID
  2823. * @access public
  2824. * @return array
  2825. */
  2826. public function getUserStoryPairs($account, $limit = 10, $type = 'story', $skipProductIDList = array(), $appendStoryID = 0)
  2827. {
  2828. return $this->dao->select('id, title')->from(TABLE_STORY)
  2829. ->where('deleted')->eq('0')
  2830. ->andWhere('type')->eq($type)
  2831. ->andWhere('assignedTo')->eq($account)
  2832. ->andWhere("FIND_IN_SET('{$this->config->vision}', vision)")
  2833. ->andWhere('status')->ne('closed')
  2834. ->andWhere('product')->ne(0)
  2835. ->beginIF(!empty($skipProductIDList))->andWhere('product')->notin($skipProductIDList)->fi()
  2836. ->beginIF(!empty($appendStoryID))->orWhere('id')->in($appendStoryID)->fi()
  2837. ->orderBy('id_desc')
  2838. ->limit($limit)
  2839. ->fetchPairs('id', 'title');
  2840. }
  2841. /**
  2842. * Get the story ID list of the linked to task.
  2843. *
  2844. * @param int $executionID
  2845. * @access public
  2846. * @return array
  2847. */
  2848. public function getIdListWithTask($executionID)
  2849. {
  2850. return $this->dao->select('story')->from(TABLE_TASK)
  2851. ->where('execution')->eq($executionID)
  2852. ->andWhere('story')->ne(0)
  2853. ->andWhere('deleted')->eq(0)
  2854. ->fetchPairs();
  2855. }
  2856. /**
  2857. * Get team members for a project or execution.
  2858. *
  2859. * @param int $storyID
  2860. * @param string $actionType
  2861. * @access public
  2862. * @return array
  2863. */
  2864. public function getTeamMembers($storyID, $actionType)
  2865. {
  2866. $teamMembers = array();
  2867. if($actionType == 'changed')
  2868. {
  2869. $executions = $this->dao->select('execution')->from(TABLE_TASK)
  2870. ->where('story')->eq($storyID)
  2871. ->andWhere('status')->ne('cancel')
  2872. ->andWhere('deleted')->eq(0)
  2873. ->fetchPairs('execution', 'execution');
  2874. if($executions) $teamMembers = $this->dao->select('account')->from(TABLE_TEAM)->where('root')->in($executions)->andWhere('type')->eq('execution')->fetchPairs('account');
  2875. }
  2876. else
  2877. {
  2878. $projects = $this->dao->select('t1.project')
  2879. ->from(TABLE_PROJECTSTORY)->alias('t1')
  2880. ->leftJoin(TABLE_PROJECT)->alias('t2')->on('t1.project = t2.id')
  2881. ->where('t1.story')->eq((int)$storyID)
  2882. ->andWhere('t2.status')->eq('doing')
  2883. ->andWhere('t2.deleted')->eq(0)
  2884. ->fetchPairs('project', 'project');
  2885. if($projects) $teamMembers = $this->dao->select('account')->from(TABLE_TEAM)->where('root')->in($projects)->andWhere('type')->eq('project')->fetchPairs('account');
  2886. }
  2887. return $teamMembers;
  2888. }
  2889. /**
  2890. * Get version of a story.
  2891. *
  2892. * @param int $storyID
  2893. * @access public
  2894. * @return int
  2895. */
  2896. public function getVersion($storyID)
  2897. {
  2898. return (int)$this->dao->select('version')->from(TABLE_STORY)->where('id')->eq($storyID)->fetch('version');
  2899. }
  2900. /**
  2901. * Get versions of some stories.
  2902. *
  2903. * @param array|string story id list
  2904. * @access public
  2905. * @return array
  2906. */
  2907. public function getVersions($storyIdList)
  2908. {
  2909. if(common::isTutorialMode())
  2910. {
  2911. $versions = array();
  2912. $stories = $this->loadModel('tutorial')->getStories();
  2913. foreach($stories as $story) $versions[$story->id] = $story->version;
  2914. return $versions;
  2915. }
  2916. return $this->dao->select('id, version')->from(TABLE_STORY)->where('id')->in($storyIdList)->fetchPairs('id', 'version');
  2917. }
  2918. /**
  2919. * 获取零用例需求。
  2920. * Get zero case.
  2921. *
  2922. * @param int $productID
  2923. * @param int $projectID
  2924. * @param int $executionID
  2925. * @param int $branchID
  2926. * @param string $orderBy
  2927. * @param object $pager
  2928. * @access public
  2929. * @return array
  2930. */
  2931. public function getZeroCase($productID, $projectID = 0, $executionID = 0, $branchID = 0, $orderBy = 'id_desc', $pager = null)
  2932. {
  2933. $casedStories = $this->dao->select('DISTINCT t1.story')
  2934. ->from(TABLE_CASE)->alias('t1')
  2935. ->leftJoin(TABLE_PROJECTCASE)->alias('t2')->on('t1.id = t2.case')
  2936. ->where('t1.product')->eq($productID)
  2937. ->andWhere('t1.story')->ne(0)
  2938. ->beginIF($projectID && !$executionID)->andWhere('t2.project IS NOT NULL')->andWhere('t2.project')->eq($projectID)->fi()
  2939. ->beginIF($executionID)->andWhere('t2.project IS NOT NULL')->andWhere('t2.project')->eq($executionID)->fi()
  2940. ->andWhere('t1.deleted')->eq(0)
  2941. ->fetchAll('story');
  2942. if($projectID) $allStories = $this->getExecutionStories($projectID, $productID, $orderBy, 'withoutparent', '', 'story', array_keys($casedStories), $pager);
  2943. if($executionID) $allStories = $this->getExecutionStories($executionID, $productID, $orderBy, 'withoutparent', '', 'story', array_keys($casedStories), $pager);
  2944. if(!$projectID && !$executionID) $allStories = $this->getProductStories($productID, $branchID, '', 'all', 'story', $orderBy, false, array_keys($casedStories), $pager);
  2945. if(!empty($allStories)) $allStories = $this->loadModel('story')->mergeReviewer($allStories);
  2946. return $allStories;
  2947. }
  2948. /**
  2949. * Batch get story stage.
  2950. *
  2951. * @param array $stories
  2952. * @access public
  2953. * @return array
  2954. * @param mixed[] $storyIdList
  2955. */
  2956. public function batchGetStoryStage($storyIdList)
  2957. {
  2958. return $this->dao->select('*')->from(TABLE_STORYSTAGE)->where('story')->in($storyIdList)->fetchGroup('story', 'branch');
  2959. }
  2960. /**
  2961. * Check need confirm.
  2962. *
  2963. * @param array|object $object
  2964. * @access public
  2965. * @return array|object
  2966. * @param mixed[]|object $data
  2967. */
  2968. public function checkNeedConfirm($data)
  2969. {
  2970. $objectList = is_object($data) ? array($data->id => $data) : $data;
  2971. $storyIdList = array();
  2972. $storyVersionList = array();
  2973. foreach($objectList as $key => $object)
  2974. {
  2975. $object->needconfirm = false;
  2976. if($object->story)
  2977. {
  2978. $storyIdList[$key] = $object->story;
  2979. $storyVersionList[$key] = $object->storyVersion;
  2980. }
  2981. }
  2982. $stories = $this->dao->select('id,version')->from(TABLE_STORY)->where('id')->in($storyIdList)->andWhere('status')->eq('active')->fetchPairs('id', 'version');
  2983. foreach($storyIdList as $key => $storyID)
  2984. {
  2985. if(isset($stories[$storyID]) and $stories[$storyID] > $storyVersionList[$key]) $objectList[$key]->needconfirm = true;
  2986. }
  2987. return is_object($data) ? reset($objectList) : $objectList;
  2988. }
  2989. /**
  2990. * 格式化需求的概要信息。
  2991. * Format stories pairs.
  2992. *
  2993. * @param array $stories
  2994. * @param string $type full|short|story|requirement
  2995. * @param int $limit 0|integer
  2996. * @access public
  2997. * @return array
  2998. */
  2999. public function formatStories($stories, $type = 'full', $limit = 0)
  3000. {
  3001. /* Format these stories. */
  3002. $storyPairs = array();
  3003. foreach($stories as $story)
  3004. {
  3005. $prefix = $story->id . ':';
  3006. if($type == 'ignoreID') $prefix = '';
  3007. $property = '';
  3008. if($type == 'short')
  3009. {
  3010. $property = '[p' . (!empty($this->lang->story->priList[$story->pri]) ? $this->lang->story->priList[$story->pri] : 0) . ', ' . $story->estimate . "{$this->config->hourUnit}]";
  3011. }
  3012. elseif($type == 'full')
  3013. {
  3014. $property = '(' . $this->lang->story->pri . ':' . (!empty($this->lang->story->priList[$story->pri]) ? $this->lang->story->priList[$story->pri] : 0) . ',' . $this->lang->story->estimate . ':' . $story->estimate . ')';
  3015. }
  3016. $storyPairs[$story->id] = $prefix . $story->title . ' ' . $property;
  3017. }
  3018. if($limit == 0) return $storyPairs;
  3019. return array_slice($storyPairs, 0, $limit, true);
  3020. }
  3021. /**
  3022. * Extract accounts from some stories.
  3023. *
  3024. * @param array $stories
  3025. * @access public
  3026. * @return array
  3027. */
  3028. public function extractAccountsFromList($stories)
  3029. {
  3030. $accounts = array();
  3031. foreach($stories as $story)
  3032. {
  3033. if(!empty($story->openedBy)) $accounts[] = $story->openedBy;
  3034. if(!empty($story->assignedTo)) $accounts[] = $story->assignedTo;
  3035. if(!empty($story->closedBy)) $accounts[] = $story->closedBy;
  3036. if(!empty($story->lastEditedBy)) $accounts[] = $story->lastEditedBy;
  3037. }
  3038. return array_unique($accounts);
  3039. }
  3040. /**
  3041. * Extract accounts from a story.
  3042. *
  3043. * @param object $story
  3044. * @access public
  3045. * @return array
  3046. */
  3047. public function extractAccountsFromSingle($story)
  3048. {
  3049. $accounts = array();
  3050. if(!empty($story->openedBy)) $accounts[] = $story->openedBy;
  3051. if(!empty($story->assignedTo)) $accounts[] = $story->assignedTo;
  3052. if(!empty($story->closedBy)) $accounts[] = $story->closedBy;
  3053. if(!empty($story->lastEditedBy)) $accounts[] = $story->lastEditedBy;
  3054. return array_unique($accounts);
  3055. }
  3056. /**
  3057. * 将默认的图表设置和当前图表的设置合并。
  3058. * Merge the default chart settings and the settings of current chart.
  3059. *
  3060. * @param string $chartType
  3061. * @access public
  3062. * @return void
  3063. */
  3064. public function mergeChartOption($chartType)
  3065. {
  3066. $chartOption = $this->lang->story->report->$chartType;
  3067. $commonOption = $this->lang->story->report->options;
  3068. $chartOption->graph->caption = $this->lang->story->report->charts[$chartType];
  3069. if(!isset($chartOption->type)) $chartOption->type = $commonOption->type;
  3070. if(!isset($chartOption->width)) $chartOption->width = $commonOption->width;
  3071. if(!isset($chartOption->height)) $chartOption->height = $commonOption->height;
  3072. foreach($commonOption->graph as $key => $value) if(!isset($chartOption->graph->$key)) $chartOption->graph->$key = $value;
  3073. }
  3074. /**
  3075. * 获取产品软件需求数量的数据。
  3076. * Get report data of stories per product.
  3077. *
  3078. * @param string $storyType
  3079. * @access public
  3080. * @return array
  3081. */
  3082. public function getDataOfStoriesPerProduct($storyType = 'story')
  3083. {
  3084. $datas = $this->dao->select('product as name, count(product) as value')->from(TABLE_STORY)
  3085. ->where($this->reportCondition($storyType))
  3086. ->groupBy('product')->orderBy('value DESC')
  3087. ->fetchAll('name');
  3088. if(!$datas) return array();
  3089. $products = $this->loadModel('product')->getPairs();
  3090. foreach($datas as $productID => $data) $data->name = isset($products[$productID]) ? $products[$productID] : $this->lang->report->undefined;
  3091. return $datas;
  3092. }
  3093. /**
  3094. * 获取按模块软件需求数量的统计数据。
  3095. * Get report data of stories per module.
  3096. *
  3097. * @param string $storyType
  3098. * @access public
  3099. * @return array
  3100. */
  3101. public function getDataOfStoriesPerModule($storyType = 'story')
  3102. {
  3103. $datas = $this->dao->select('module as name, count(module) as value, product, branch')
  3104. ->from(TABLE_STORY)
  3105. ->where($this->reportCondition($storyType))
  3106. ->groupBy('module,product,branch')
  3107. ->orderBy('value DESC')
  3108. ->fetchAll('name');
  3109. if(!$datas) return array();
  3110. $branchIDList = array();
  3111. foreach($datas as $project)
  3112. {
  3113. if(!$project->branch) continue;
  3114. $branchIDList[$project->branch] = $project->branch;
  3115. }
  3116. $branches = $this->dao->select('id, name')->from(TABLE_BRANCH)->where('id')->in($branchIDList)->andWhere('deleted')->eq('0')->fetchALL('id');
  3117. $modules = $this->loadModel('tree')->getModulesName(array_keys($datas));
  3118. foreach($datas as $moduleID => $data)
  3119. {
  3120. $branch = '';
  3121. if(isset($branches[$data->branch]->name))
  3122. {
  3123. $branch = '/' . $branches[$data->branch]->name;
  3124. }
  3125. $data->name = $branch . (isset($modules[$moduleID]) ? $modules[$moduleID] : '/');
  3126. }
  3127. return $datas;
  3128. }
  3129. /**
  3130. * 获取按需求来源统计的数据。
  3131. * Get report data of stories per source.
  3132. *
  3133. * @param string $storyType
  3134. * @access public
  3135. * @return array
  3136. */
  3137. public function getDataOfStoriesPerSource($storyType = 'story')
  3138. {
  3139. $datas = $this->dao->select('source as name, count(source) as value')->from(TABLE_STORY)
  3140. ->where($this->reportCondition($storyType))
  3141. ->groupBy('source')->orderBy('value DESC')
  3142. ->fetchAll('name');
  3143. if(!$datas) return array();
  3144. $this->lang->{$storyType}->sourceList[''] = $this->lang->report->undefined;
  3145. foreach($datas as $key => $data) $data->name = isset($this->lang->{$storyType}->sourceList[$key]) ? $this->lang->{$storyType}->sourceList[$key] : $this->lang->report->undefined;
  3146. return $datas;
  3147. }
  3148. /**
  3149. * 获取按计划进行统计的数据。
  3150. * Get report data of stories per plan.
  3151. *
  3152. * @param string $storyType
  3153. * @access public
  3154. * @return array
  3155. */
  3156. public function getDataOfStoriesPerPlan($storyType = 'story')
  3157. {
  3158. $datas = $this->dao->select('plan as name, count(plan) as value')->from(TABLE_STORY)
  3159. ->where($this->reportCondition($storyType))
  3160. ->groupBy('plan')->orderBy('value DESC')
  3161. ->fetchAll('name');
  3162. if(!$datas) return array();
  3163. /* Separate for multi-plan key. */
  3164. foreach($datas as $planID => $data)
  3165. {
  3166. if(strpos((string)$planID, ',') !== false)
  3167. {
  3168. $planIdList = explode(',', $planID);
  3169. foreach($planIdList as $multiPlanID)
  3170. {
  3171. if(empty($datas[$multiPlanID]))
  3172. {
  3173. $datas[$multiPlanID] = new stdclass();
  3174. $datas[$multiPlanID]->name = $multiPlanID;
  3175. $datas[$multiPlanID]->value = 0;
  3176. }
  3177. $datas[$multiPlanID]->value += $data->value;
  3178. }
  3179. unset($datas[$planID]);
  3180. }
  3181. }
  3182. /* Fix bug #2697. */
  3183. if(isset($datas['']))
  3184. {
  3185. if(empty($datas[0]))
  3186. {
  3187. $datas[0] = new stdclass();
  3188. $datas[0]->name = 0;
  3189. $datas[0]->value = 0;
  3190. }
  3191. $datas[0]->value += $datas['']->value;
  3192. unset($datas['']);
  3193. }
  3194. $plans = $this->dao->select('id, title')->from(TABLE_PRODUCTPLAN)->where('id')->in(array_keys($datas))->fetchPairs();
  3195. foreach($datas as $planID => $data) $data->name = isset($plans[$planID]) ? $plans[$planID] : $this->lang->report->undefined;
  3196. return $datas;
  3197. }
  3198. /**
  3199. * 获取按状态进行统计的数据。
  3200. * Get report data of stories per status.
  3201. *
  3202. * @param string $storyType
  3203. * @access public
  3204. * @return array
  3205. */
  3206. public function getDataOfStoriesPerStatus($storyType = 'story')
  3207. {
  3208. $datas = $this->dao->select('status as name, count(status) as value')->from(TABLE_STORY)
  3209. ->where($this->reportCondition($storyType))
  3210. ->groupBy('status')->orderBy('value DESC')
  3211. ->fetchAll('name');
  3212. if(!$datas) return array();
  3213. foreach($datas as $status => $data) if(isset($this->lang->{$storyType}->statusList[$status])) $data->name = $this->lang->{$storyType}->statusList[$status];
  3214. return $datas;
  3215. }
  3216. /**
  3217. * 获取按所处阶段进行统计的数据。
  3218. * Get report data of stories per stage.
  3219. *
  3220. * @param string $storyType
  3221. * @access public
  3222. * @return array
  3223. */
  3224. public function getDataOfStoriesPerStage($storyType = 'story')
  3225. {
  3226. $datas = $this->dao->select('stage as name, count(stage) as value')->from(TABLE_STORY)
  3227. ->where($this->reportCondition($storyType))
  3228. ->groupBy('stage')->orderBy('value DESC')
  3229. ->fetchAll('name');
  3230. if(!$datas) return array();
  3231. foreach($datas as $stage => $data) $data->name = $this->lang->{$storyType}->stageList[$stage] != '' ? $this->lang->{$storyType}->stageList[$stage] : $this->lang->report->undefined;
  3232. return $datas;
  3233. }
  3234. /**
  3235. * 获取按优先级进行统计的数据。
  3236. * Get report data of stories per pri.
  3237. *
  3238. * @param string $storyType
  3239. * @access public
  3240. * @return array
  3241. */
  3242. public function getDataOfStoriesPerPri($storyType = 'story')
  3243. {
  3244. $datas = $this->dao->select('pri as name, count(pri) as value')->from(TABLE_STORY)
  3245. ->where($this->reportCondition($storyType))
  3246. ->groupBy('pri')->orderBy('value DESC')
  3247. ->fetchAll('name');
  3248. if(!$datas) return array();
  3249. foreach($datas as $pri => $data)
  3250. {
  3251. if(isset($this->lang->{$storyType}->priList[$pri]) && $this->lang->{$storyType}->priList[$pri] != '')
  3252. $data->name = $this->lang->{$storyType}->priList[$pri];
  3253. else
  3254. $data->name = $this->lang->report->undefined;
  3255. }
  3256. return $datas;
  3257. }
  3258. /**
  3259. * 获取按照预计工时进行统计的数据。
  3260. * Get report data of stories per estimate.
  3261. *
  3262. * @param string $storyType
  3263. * @access public
  3264. * @return array
  3265. */
  3266. public function getDataOfStoriesPerEstimate($storyType = 'story')
  3267. {
  3268. return $this->dao->select('estimate as name, count(estimate) as value')->from(TABLE_STORY)
  3269. ->where($this->reportCondition($storyType))
  3270. ->groupBy('estimate')->orderBy('value')
  3271. ->fetchAll();
  3272. }
  3273. /**
  3274. * 获取按由谁创建来进行统计的数据。
  3275. * Get report data of stories per openedBy.
  3276. *
  3277. * @param string $storyType
  3278. * @access public
  3279. * @return array
  3280. */
  3281. public function getDataOfStoriesPerOpenedBy($storyType = 'story')
  3282. {
  3283. $datas = $this->dao->select('openedBy as name, count(openedBy) as value')->from(TABLE_STORY)
  3284. ->where($this->reportCondition($storyType))
  3285. ->groupBy('openedBy')->orderBy('value DESC')
  3286. ->fetchAll('name');
  3287. if(!$datas) return array();
  3288. if(!isset($this->users)) $this->users = $this->loadModel('user')->getPairs('noletter');
  3289. foreach($datas as $account => $data) $data->name = isset($this->users[$account]) ? $this->users[$account] : $this->lang->report->undefined;
  3290. return $datas;
  3291. }
  3292. /**
  3293. * 获取按当前指派给来进行统计的数据。
  3294. * Get report data of stories per assignedTo.
  3295. *
  3296. * @param string $storyType
  3297. * @access public
  3298. * @return array
  3299. */
  3300. public function getDataOfStoriesPerAssignedTo($storyType = 'story')
  3301. {
  3302. $datas = $this->dao->select('assignedTo as name, count(assignedTo) as value')->from(TABLE_STORY)
  3303. ->where($this->reportCondition($storyType))
  3304. ->groupBy('assignedTo')->orderBy('value DESC')
  3305. ->fetchAll('name');
  3306. if(!$datas) return array();
  3307. if(!isset($this->users)) $this->users = $this->loadModel('user')->getPairs('noletter');
  3308. foreach($datas as $account => $data) $data->name = (isset($this->users[$account]) and $this->users[$account] != '') ? $this->users[$account] : $this->lang->report->undefined;
  3309. return $datas;
  3310. }
  3311. /**
  3312. * 获取按关闭原因来进行统计的数据。
  3313. * Get report data of stories per closedReason.
  3314. *
  3315. * @param string $storyType
  3316. * @access public
  3317. * @return array
  3318. */
  3319. public function getDataOfStoriesPerClosedReason($storyType = 'story')
  3320. {
  3321. $datas = $this->dao->select('closedReason as name, count(closedReason) as value')->from(TABLE_STORY)
  3322. ->where($this->reportCondition($storyType))
  3323. ->groupBy('closedReason')->orderBy('value DESC')
  3324. ->fetchAll('name');
  3325. if(!$datas) return array();
  3326. foreach($datas as $reason => $data) $data->name = $this->lang->{$storyType}->reasonList[$reason] != '' ? $this->lang->{$storyType}->reasonList[$reason] : $this->lang->report->undefined;
  3327. return $datas;
  3328. }
  3329. /**
  3330. * 获取按变更次数来进行统计的数据。
  3331. * Get report data of stories per change.
  3332. *
  3333. * @param string $storyType
  3334. * @access public
  3335. * @return array
  3336. */
  3337. public function getDataOfStoriesPerChange($storyType = 'story')
  3338. {
  3339. return $this->dao->select('(version-1) as name, COUNT(1) AS value')->from(TABLE_STORY)
  3340. ->where($this->reportCondition($storyType))
  3341. ->groupBy('version')->orderBy('value')
  3342. ->fetchAll();
  3343. }
  3344. /**
  3345. * 获取按需求层级来进行统计的数据。
  3346. * Get report data of stories per grade.
  3347. *
  3348. * @param string $storyType
  3349. * @access public
  3350. * @return array
  3351. */
  3352. public function getDataOfStoriesPerGrade($storyType = 'story')
  3353. {
  3354. $datas = $this->dao->select('type, grade as name, COUNT(1) AS count')->from(TABLE_STORY)
  3355. ->where($this->reportCondition($storyType))
  3356. ->groupBy('type,name')
  3357. ->fetchGroup('type', 'name');
  3358. $gradeGroup = $this->getGradeGroup();
  3359. $summary = array();
  3360. foreach($datas as $type => $gradeCount)
  3361. {
  3362. $gradePairs = zget($gradeGroup, $type, array());
  3363. foreach($gradeCount as $gradeValue => $count)
  3364. {
  3365. $grade = zget($gradePairs, $gradeValue);
  3366. $data = new stdclass();
  3367. $data->name = isset($grade->name) ? $grade->name : $this->lang->report->undefined;
  3368. $data->value = $count->count;
  3369. $summary[] = $data;
  3370. }
  3371. }
  3372. return $summary;
  3373. }
  3374. /**
  3375. * Get kanban group data.
  3376. *
  3377. * @param array $stories
  3378. * @access public
  3379. * @return array
  3380. */
  3381. public function getKanbanGroupData($stories)
  3382. {
  3383. $this->app->loadConfig('execution');
  3384. $cols = $this->config->execution->storyKanbanCols;
  3385. $kanbanData = array();
  3386. $storyGroup = array();
  3387. foreach($stories as $story)
  3388. {
  3389. unset($story->deleted);
  3390. $index = array_search($story->stage, $cols) + 1;
  3391. $story->statusLabel = zget($this->lang->story->statusList, $story->status, '');
  3392. $storyGroup[$index][] = $story;
  3393. }
  3394. foreach($cols as $index => $stage)
  3395. {
  3396. $index ++;
  3397. $count = isset($storyGroup[$index]) ? count($storyGroup[$index]) : 0;
  3398. $col = new stdclass();
  3399. $col->name = $index;
  3400. $col->title = $this->lang->story->stageList[$stage] . ' (' . $count . ')';
  3401. $col->key = $stage;
  3402. $kanbanData['cols'][] = $col;
  3403. }
  3404. $lane = new stdclass();
  3405. $lane->name = 1;
  3406. $lane->title = '';
  3407. $kanbanData['lanes'][] = $lane;
  3408. $kanbanData['items'][1] = $storyGroup;
  3409. return $kanbanData;
  3410. }
  3411. /**
  3412. * Get toList and ccList.
  3413. *
  3414. * @param object $story
  3415. * @param string $actionType
  3416. * @access public
  3417. * @return bool|array
  3418. */
  3419. public function getToAndCcList($story, $actionType)
  3420. {
  3421. /* Set toList and ccList. */
  3422. $toList = $story->assignedTo;
  3423. $ccList = isset($story->mailto) ? str_replace(' ', '', trim($story->mailto, ',')) : '';
  3424. /* If the action is changed or reviewed, mail to the project or execution team. */
  3425. if((strtolower($actionType) == 'changed' or strtolower($actionType) == 'reviewed') && $story->type == 'story')
  3426. {
  3427. $teamMembers = $this->getTeamMembers($story->id, $actionType);
  3428. if($teamMembers)
  3429. {
  3430. $ccList .= ',' . implode(',', $teamMembers);
  3431. $ccList = ltrim($ccList, ',');
  3432. }
  3433. }
  3434. if(in_array(strtolower($actionType), array('changed', 'opened', 'submitreview')))
  3435. {
  3436. $reviewerList = $this->getReviewerPairs($story->id, $story->version);
  3437. unset($reviewerList[$story->assignedTo]);
  3438. $ccList .= ',' . implode(',', array_keys($reviewerList));
  3439. }
  3440. if(empty($toList))
  3441. {
  3442. if(empty($ccList)) return false;
  3443. if(strpos($ccList, ',') === false)
  3444. {
  3445. $toList = $ccList;
  3446. $ccList = '';
  3447. }
  3448. else
  3449. {
  3450. $commaPos = strpos($ccList, ',');
  3451. $toList = substr($ccList, 0, $commaPos);
  3452. $ccList = substr($ccList, $commaPos + 1);
  3453. }
  3454. }
  3455. elseif($story->status == 'closed')
  3456. {
  3457. $ccList .= ',' . $story->openedBy;
  3458. }
  3459. return array($toList, $ccList);
  3460. }
  3461. /**
  3462. * Adjust the action clickable.
  3463. *
  3464. * @param object $story
  3465. * @param string $action
  3466. * @param array $storyTasks
  3467. * @param array $storyCases
  3468. * @access public
  3469. * @return bool
  3470. */
  3471. public static function isClickable($story, $action, $storyTasks = [], $storyCases = [])
  3472. {
  3473. global $app, $config;
  3474. $action = strtolower($action);
  3475. if(in_array($action, array('edit', 'batchcreate', 'change', 'delete')) && !empty($story->frozen)) return false;
  3476. if($action == 'subdivide') $action = 'batchcreate';
  3477. if($action == 'recall') return $story->status == 'reviewing' || $story->status == 'changing';
  3478. if($action == 'close') return $story->status != 'closed';
  3479. if($action == 'activate') return $story->status == 'closed';
  3480. if($action == 'assignto') return $story->status != 'closed';
  3481. if($action == 'processstorychange') return !empty($story->parentChanged);
  3482. if($action == 'submitreview' && strpos('draft,changing', $story->status) === false) return false;
  3483. if($action == 'createtestcase' || $action == 'batchcreatetestcase') return $config->vision != 'lite' && $story->isParent == '0' && $story->type == 'story';
  3484. /* Check isClickable when feedback convert to story. */
  3485. if($action == 'create' && isset($story->solution)) return ($config->global->flow == 'full' && strpos('closed|clarify|noreview', $story->status) === false);
  3486. if($action == 'createtask')
  3487. {
  3488. if($app->tab == 'project' && !empty($_SESSION['project']))
  3489. {
  3490. global $dao;
  3491. $project = $dao->findByID($_SESSION['project'])->from(TABLE_PROJECT)->fetch();
  3492. }
  3493. return ($app->tab == 'execution' || (!empty($project) && $project->multiple == '0')) && $story->status == 'active' && $story->type == 'story' && $story->isParent == '0';
  3494. }
  3495. $disabledFeatures = ",{$config->disabledFeatures},";
  3496. if($action == 'importtolib')
  3497. {
  3498. if($config->vision == 'lite') return false;
  3499. if(!in_array($config->edition, array('max', 'ipd'))) return false;
  3500. if($app->tab != 'project') return false;
  3501. if($story->type == 'requirement') return false;
  3502. if(!common::hasPriv('story', 'importToLib')) return false;
  3503. if(strpos($disabledFeatures, ',assetlibStorylib,') !== false || strpos($disabledFeatures, ',assetlib,') !== false) return false;
  3504. return true;
  3505. }
  3506. static $shadowProducts = array();
  3507. static $taskGroups = array();
  3508. static $caseGroups = array();
  3509. if(empty($shadowProducts)) $shadowProducts = $app->dao->select('id')->from(TABLE_PRODUCT)->where('deleted')->eq('0')->andWhere('shadow')->eq(1)->fetchPairs();
  3510. if(empty($taskGroups)) $taskGroups = $storyTasks ?: $app->dao->select('story, id')->from(TABLE_TASK)->where('deleted')->eq('0')->andWhere('story')->ne(0)->fetchPairs();
  3511. if(empty($caseGroups)) $caseGroups = $storyCases ?: $app->dao->select('story, id')->from(TABLE_CASE)->where('deleted')->eq('0')->andWhere('story')->ne(0)->fetchPairs();
  3512. if(isset($story->parent) && $story->parent < 0 && strpos($config->story->list->actionsOperatedParentStory, ",$action,") === false) return false;
  3513. if($action == 'batchcreate')
  3514. {
  3515. if($config->vision == 'or')
  3516. {
  3517. $myself = new self();
  3518. static $getMaxGradeGroup;
  3519. if(empty($getMaxGradeGroup)) $getMaxGradeGroup = $myself->getMaxGradeGroup();
  3520. if(!empty($story->grade) && $story->type == 'requirement' && $story->grade >= $getMaxGradeGroup[$story->type]) return false;
  3521. }
  3522. if(!empty($story->twins)) return false;
  3523. if(strpos('reviewing,closed', $story->status) !== false) return false;
  3524. if($config->vision == 'lite' && ($story->status == 'active' && in_array($story->stage, array('wait', 'projected')))) return true;
  3525. if(!empty($caseGroups[$story->id])) return false;
  3526. if(!empty($taskGroups[$story->id])) return false;
  3527. if(!isset($shadowProducts[$story->product]) && !in_array($story->stage, array('wait', 'planned', 'projected')) && $story->type == 'story' && $story->isParent == '0') return false;
  3528. }
  3529. $story->reviewer = isset($story->reviewer) ? $story->reviewer : array();
  3530. $story->notReview = isset($story->notReview) ? $story->notReview : array();
  3531. $storyType = $app->methodName == 'audit' ? zget($story, 'storyType', $story->type) : $story->type;
  3532. $isSuperReviewer = isset($config->{$storyType}) ? strpos(',' . trim(zget($config->{$storyType}, 'superReviewers', ''), ',') . ',', ',' . $app->user->account . ',') : false;
  3533. if($action == 'change') return (($isSuperReviewer !== false or count($story->reviewer) == 0 or count($story->notReview) == 0) and $story->status == 'active');
  3534. if($action == 'review') return (($isSuperReviewer !== false or in_array($app->user->account, $story->notReview)) and $story->status == 'reviewing');
  3535. if($action == 'createbranch') return $story->type == 'story';
  3536. return true;
  3537. }
  3538. /**
  3539. * Merge story reviewers.
  3540. *
  3541. * @param array|object $stories
  3542. * @param bool $isObject
  3543. * @access public
  3544. * @return array|object
  3545. */
  3546. public function mergeReviewer($stories, $isObject = false)
  3547. {
  3548. if(common::isTutorialMode()) return $stories;
  3549. $rawQuery = $this->dao->get();
  3550. if($isObject)
  3551. {
  3552. $story = $stories;
  3553. $stories = (array)$stories;
  3554. $stories[$story->id] = $story;
  3555. }
  3556. /* Set child story id into array. */
  3557. $storyIdList = isset($stories['id']) ? array($stories['id'] => $stories['id']) : array_keys($stories);
  3558. if(isset($stories['id']) and isset($story->children)) $storyIdList = array_merge($storyIdList, array_keys($story->children));
  3559. if(!isset($stories['id']))
  3560. {
  3561. foreach($stories as $story)
  3562. {
  3563. if(isset($story->children)) $storyIdList = array_merge($storyIdList, array_keys($story->children));
  3564. }
  3565. }
  3566. $allReviewers = $this->dao->select('story,reviewer,result')->from(TABLE_STORY)->alias('t1')
  3567. ->leftJoin(TABLE_STORYREVIEW)->alias('t2')->on('t1.version=t2.version and t1.id=t2.story')
  3568. ->where('story')->in($storyIdList)
  3569. ->fetchGroup('story', 'reviewer');
  3570. foreach($allReviewers as $storyID => $reviewerList)
  3571. {
  3572. if(isset($stories[$storyID]))
  3573. {
  3574. $stories[$storyID]->reviewer = array_keys($reviewerList);
  3575. $stories[$storyID]->notReview = array();
  3576. foreach($reviewerList as $reviewer => $reviewInfo)
  3577. {
  3578. if($reviewInfo->result == '') $stories[$storyID]->notReview[] = $reviewer;
  3579. }
  3580. }
  3581. else
  3582. {
  3583. foreach($stories as $story)
  3584. {
  3585. if(!isset($story->children)) continue;
  3586. if(isset($story->children[$storyID]))
  3587. {
  3588. $story->children[$storyID]->reviewer = array_keys($reviewerList);
  3589. $story->children[$storyID]->notReview = array();
  3590. foreach($reviewerList as $reviewer => $reviewInfo)
  3591. {
  3592. if($reviewInfo->result == '') $story->children[$storyID]->notReview[] = $reviewer;
  3593. }
  3594. }
  3595. }
  3596. }
  3597. }
  3598. $this->dao->sqlobj->sql = $rawQuery;
  3599. if($isObject) return $stories[$story->id];
  3600. return $stories;
  3601. }
  3602. /**
  3603. * 向业务需求和用户需求追加子需求。
  3604. * Append children to story.
  3605. *
  3606. * @param int $productID
  3607. * @param array $stories
  3608. * @param string $storyType
  3609. * @access public
  3610. * @return array|object
  3611. */
  3612. public function appendChildren($productID, $stories, $storyType)
  3613. {
  3614. if(common::isTutorialMode()) return $stories;
  3615. /* 如果是OR界面的用户需求,则不需要追加子需求(研发需求)。 */
  3616. /* If it is the OR interface for user requirements, no need to add sub requirements.*/
  3617. if($storyType == 'requirement' && $this->config->vision == 'or') return $stories;
  3618. $storyGrades = array();
  3619. $requirementGrades = array();
  3620. $showGrades = isset($this->config->{$storyType}->showGrades);
  3621. if($showGrades)
  3622. {
  3623. foreach(explode(',', $this->config->{$storyType}->showGrades) as $grade)
  3624. {
  3625. if(!$grade) continue;
  3626. if(strpos($grade, 'requirement') !== false) $requirementGrades[] = str_replace('requirement', '', $grade);
  3627. if(strpos($grade, 'story') !== false && $this->config->vision != 'or') $storyGrades[] = str_replace('story', '', $grade);
  3628. }
  3629. }
  3630. $rootIdList = array();
  3631. foreach($stories as $story) $rootIdList[$story->root] = $story->root;
  3632. if(empty($rootIdList)) return $stories;
  3633. $children = $this->dao->select('*,path,plan')->from(TABLE_STORY)
  3634. ->where('root')->in($rootIdList)
  3635. ->andWhere('product')->eq($productID)
  3636. ->andWhere('deleted')->eq('0')
  3637. ->andWhere("FIND_IN_SET('{$this->config->vision}', vision)")
  3638. ->beginIF($storyType == 'requirement')
  3639. ->andWhere('type')->eq('story')
  3640. ->beginIF($showGrades && isset($storyGrades))->andWhere('grade')->in($storyGrades)->fi()
  3641. ->fi()
  3642. ->beginIF($storyType == 'epic')
  3643. ->andWhere('((type')->eq('requirement')
  3644. ->beginIF($showGrades && isset($requirementGrades))->andWhere('grade')->in($requirementGrades)->fi()
  3645. ->markRight(1)
  3646. ->orWhere('(type')->eq('story')
  3647. ->beginIF($showGrades && isset($storyGrades))->andWhere('grade')->in($storyGrades)->fi()
  3648. ->markRight(1)
  3649. ->markRight(1)
  3650. ->fi()
  3651. ->fetchAll('id');
  3652. /* 获取关联对象。*/
  3653. if($this->config->edition != 'open')
  3654. {
  3655. $requirements = array();
  3656. $childStories = array();
  3657. foreach($children as $child)
  3658. {
  3659. if($child->type == 'requirement') $requirements[$child->id] = $child->id;
  3660. if($child->type == 'story') $childStories[$child->id] = $child->id;
  3661. }
  3662. $this->loadModel('custom');
  3663. $requirementRelatedObjectList = $this->custom->getRelatedObjectList($requirements, 'requirement', 'byRelation', true);
  3664. $storyRelatedObjectList = $this->custom->getRelatedObjectList($childStories, 'story', 'byRelation', true);
  3665. foreach($children as $child)
  3666. {
  3667. if($child->type == 'requirement') $child->relatedObject = zget($requirementRelatedObjectList, $child->id, 0);
  3668. if($child->type == 'story') $child->relatedObject = zget($storyRelatedObjectList, $child->id, 0);
  3669. }
  3670. }
  3671. if($children)
  3672. {
  3673. $children = $this->storyTao->mergePlanTitleAndChildren($productID, $children, $storyType);
  3674. $children = $this->mergeReviewer($children);
  3675. return array_merge($stories, $children);
  3676. }
  3677. return $stories;
  3678. }
  3679. /**
  3680. * Set report condition.
  3681. *
  3682. * @param string $storyType
  3683. * @access public
  3684. * @return string
  3685. */
  3686. public function reportCondition($storyType)
  3687. {
  3688. $queryCondition = $storyType . 'QueryCondition';
  3689. $onlyCondition = $storyType . 'OnlyCondition';
  3690. if(isset($_SESSION[$queryCondition]))
  3691. {
  3692. if(!$this->session->{$onlyCondition})
  3693. {
  3694. preg_match_all('/[`"]' . trim(TABLE_STORY, '`') .'[`"] AS ([\w]+) /', $this->session->{$queryCondition}, $matches);
  3695. $tableAlias = isset($matches[1][0]) ? $matches[1][0] . '.' : '';
  3696. return 'id in (' . preg_replace('/SELECT .* FROM/', "SELECT $tableAlias" . "id FROM", $this->session->{$queryCondition}) . ')';
  3697. }
  3698. return $this->session->{$queryCondition};
  3699. }
  3700. return '1=1';
  3701. }
  3702. /**
  3703. * Check force review for user.
  3704. *
  3705. * @param string $storyType
  3706. * @access public
  3707. * @return bool
  3708. */
  3709. public function checkForceReview($storyType = 'story')
  3710. {
  3711. $forceField = $this->config->{$storyType}->needReview == 0 ? 'forceReview' : 'forceNotReview';
  3712. $forceReviewRoles = !empty($this->config->{$storyType}->{$forceField . 'Roles'}) ? $this->config->{$storyType}->{$forceField . 'Roles'} : '';
  3713. $forceReviewDepts = !empty($this->config->{$storyType}->{$forceField . 'Depts'}) ? $this->config->{$storyType}->{$forceField . 'Depts'} : '';
  3714. $forceUsers = '';
  3715. if(!empty($this->config->{$storyType}->{$forceField})) $forceUsers = $this->config->{$storyType}->{$forceField};
  3716. if(!empty($forceReviewRoles) or !empty($forceReviewDepts))
  3717. {
  3718. $users = $this->dao->select('account')->from(TABLE_USER)
  3719. ->where('deleted')->eq(0)
  3720. ->andWhere('1!=1', true)
  3721. ->beginIF(!empty($forceReviewRoles))
  3722. ->orWhere('(role', true)->in($forceReviewRoles)
  3723. ->andWhere('role')->ne('')
  3724. ->markRight(1)
  3725. ->fi()
  3726. ->beginIF(!empty($forceReviewDepts))->orWhere('dept')->in($forceReviewDepts)->fi()
  3727. ->markRight(1)
  3728. ->fetchAll('account');
  3729. $forceUsers .= "," . implode(',', array_keys($users));
  3730. }
  3731. return $this->config->{$storyType}->needReview == 0 ? strpos(",{$forceUsers},", ",{$this->app->user->account},") !== false : strpos(",{$forceUsers},", ",{$this->app->user->account},") === false;
  3732. }
  3733. /**
  3734. * 根据传入的需求获取跟踪矩阵,返回看板格式数据。
  3735. * Get track by stories, return kanban format.
  3736. *
  3737. * @param array $stories
  3738. * @param string $storyType epic|requirement|story
  3739. * @access public
  3740. * @return array
  3741. */
  3742. public function getTracksByStories($stories, $storyType)
  3743. {
  3744. if(empty($stories)) return array();
  3745. $rootIdList = array_unique(array_column($stories, 'root'));
  3746. $allStories = $this->dao->select('id,parent,color,isParent,root,path,grade,product,pri,type,status,stage,title,estimate')->from(TABLE_STORY)->where('root')->in($rootIdList)->andWhere('deleted')->eq(0)->orderBy('id')->fetchAll('id');
  3747. $stories = $this->storyTao->mergeChildrenForTrack($allStories, $stories, $storyType);
  3748. $leafNodes = $this->storyTao->getLeafNodes($stories, $storyType);
  3749. $lanes = $this->storyTao->buildTrackLanes($leafNodes, $storyType);
  3750. $cols = $this->storyTao->buildTrackCols($storyType);
  3751. $items = $this->storyTao->buildTrackItems($stories, $leafNodes, $storyType);
  3752. return array('lanes' => $lanes, 'cols' => $cols, 'items' => $items, 'leafNodes' => $leafNodes);
  3753. }
  3754. /**
  3755. * 获取矩阵需要合并的单元格。
  3756. * Get need merge cells for track.
  3757. *
  3758. * @param array $tracks
  3759. * @param array $showCols
  3760. * @access public
  3761. * @return array
  3762. */
  3763. public function getMergeTrackCells(&$tracks, $showCols)
  3764. {
  3765. if(empty($tracks)) return array();
  3766. $storyCols = array();
  3767. foreach($showCols as $colType)
  3768. {
  3769. if(!in_array($colType, array('demand', 'epic', 'requirement', 'story'))) continue;
  3770. foreach($tracks['cols'] as $col)
  3771. {
  3772. $colName = $col['name'];
  3773. if($col['parent'] == -1) continue;
  3774. if(strpos($colName, $colType) !== false) $storyCols[] = $col;
  3775. }
  3776. }
  3777. /* 比较前一行的单元格,是否需要合并。
  3778. 如果是相同的需求,则合并单元格。
  3779. 如果没有需求,则根据前面的单元格的合并情况判断是否合并。
  3780. 如果前面的单元格没有合并,则不合并。
  3781. 但是第一列仅根据有无需求和需求是否相同,判断是否合并。
  3782. */
  3783. $mergeCells = array();
  3784. $preRowIdList = array();
  3785. $preRowColID = null;
  3786. foreach($tracks['lanes'] as $lane)
  3787. {
  3788. $laneName = $lane['name'];
  3789. $preMerged = false;
  3790. foreach($storyCols as $i => $col)
  3791. {
  3792. $colName = $col['name'];
  3793. $story = isset($tracks['items'][$laneName][$colName]) ? reset($tracks['items'][$laneName][$colName]) : 0;
  3794. $storyID = $story ? $story->id : 0;
  3795. if(isset($preRowIdList[$i])) $preRowColID = $preRowIdList[$i];
  3796. $preRowIdList[$i] = $storyID;
  3797. if($preRowColID === null || $preRowColID != $storyID)
  3798. {
  3799. $preMerged = false;
  3800. continue;
  3801. }
  3802. if($i > 0 && !$preMerged && empty($storyID)) continue;
  3803. if(!empty($tracks['items'][$laneName][$colName])) $tracks['items'][$laneName][$colName] = array();
  3804. $mergeCells[$laneName][$colName] = true;
  3805. $preMerged = true;
  3806. }
  3807. }
  3808. return $mergeCells;
  3809. }
  3810. /**
  3811. * 获取需求关联的需求列表。
  3812. * Get story relation.
  3813. *
  3814. * @param int $storyID
  3815. * @param string $storyType story|requirement
  3816. * @access public
  3817. * @return array
  3818. */
  3819. public function getStoryRelation($storyID, $storyType = 'story')
  3820. {
  3821. $relations = $this->storyTao->getRelation($storyID, $storyType);
  3822. if(empty($relations)) return array();
  3823. return $this->dao->select('*')->from(TABLE_STORY)
  3824. ->where('id')->in($relations)
  3825. ->andWhere('deleted')->eq('0')
  3826. ->orderBy('id_desc')
  3827. ->fetchGroup('type', 'id');
  3828. }
  3829. /**
  3830. * Link a story.
  3831. *
  3832. * @param int $executionID
  3833. * @param int $productID
  3834. * @param int $storyID
  3835. * @access public
  3836. * @return void
  3837. */
  3838. public function linkStory($executionID, $productID, $storyID)
  3839. {
  3840. if(empty($executionID) || empty($productID) || empty($storyID)) return;
  3841. $lastOrder = (int)$this->dao->select('*')->from(TABLE_PROJECTSTORY)->where('project')->eq($executionID)->orderBy('order_desc')->limit(1)->fetch('order');
  3842. $projectStory = new stdclass();
  3843. $projectStory->project = $executionID;
  3844. $projectStory->product = $productID;
  3845. $projectStory->story = $storyID;
  3846. $projectStory->version = 1;
  3847. $projectStory->order = $lastOrder + 1;
  3848. $this->dao->insert(TABLE_PROJECTSTORY)->data($projectStory)->exec();
  3849. }
  3850. /**
  3851. * 需求关联需求。
  3852. * Story link story.
  3853. *
  3854. * @param int $storyID
  3855. * @param array|object storyList postedData
  3856. * @access public
  3857. * @return void
  3858. */
  3859. public function linkStories($storyID, $storyList = array())
  3860. {
  3861. $story = $this->getByID($storyID);
  3862. $linkStories = $this->getByList($storyList);
  3863. $this->loadModel('action');
  3864. foreach($linkStories as $id => $linkStory)
  3865. {
  3866. $data = new stdclass();
  3867. $data->AType = $story->type;
  3868. $data->BType = $linkStory->type;
  3869. $data->product = $story->product;
  3870. $data->relation = 'linkedto';
  3871. $data->AID = $storyID;
  3872. $data->BID = $id;
  3873. $data->AVersion = $story->version;
  3874. $data->BVersion = $linkStory->version;
  3875. $this->dao->insert(TABLE_RELATION)->data($data)->autoCheck()->exec();
  3876. $data->AType = $linkStory->type;
  3877. $data->BType = $story->type;
  3878. $data->product = $linkStory->product;
  3879. $data->relation = 'linkedfrom';
  3880. $data->AID = $id;
  3881. $data->BID = $storyID;
  3882. $data->AVersion = $linkStory->version;
  3883. $data->BVersion = $story->version;
  3884. $this->dao->insert(TABLE_RELATION)->data($data)->autoCheck()->exec();
  3885. $this->action->create('story', $id, 'linkrelatedstory', '', $storyID);
  3886. }
  3887. $this->action->create('story', $storyID, 'linkrelatedstory', '', implode(',', array_keys($linkStories)));
  3888. }
  3889. /**
  3890. * Unlink story.
  3891. *
  3892. * @param int $storyID
  3893. * @param int $linkedStoryID
  3894. * @access public
  3895. * @return bool
  3896. */
  3897. public function unlinkStory($storyID, $linkedStoryID)
  3898. {
  3899. $idList = "$storyID,$linkedStoryID";
  3900. $this->dao->delete()->from(TABLE_RELATION)
  3901. ->where('AType')->in('story,requirement,epic')
  3902. ->andWhere('BType')->in('story,requirement,epic')
  3903. ->andWhere('relation')->in('linkedto,linkedfrom')
  3904. ->andWhere('AID')->in($idList)
  3905. ->andWhere('BID')->in($idList)
  3906. ->exec();
  3907. $this->loadModel('action')->create('story', $storyID, 'unlinkrelatedstory', '', $linkedStoryID);
  3908. $this->loadModel('action')->create('story', $linkedStoryID, 'unlinkrelatedstory', '', $storyID);
  3909. return !dao::isError();
  3910. }
  3911. /**
  3912. * 根据需求ID和轮次获取需求估算。
  3913. * Get estimate info.
  3914. *
  3915. * @param int $storyID
  3916. * @param int $round
  3917. * @access public
  3918. * @return bool|object
  3919. */
  3920. public function getEstimateInfo($storyID, $round = 0)
  3921. {
  3922. $estimateInfo = $this->dao->select('*')->from(TABLE_STORYESTIMATE)
  3923. ->where('story')->eq($storyID)
  3924. ->beginIf($round)->andWhere('round')->eq($round)->fi()
  3925. ->orderBy('round_desc')
  3926. ->fetch();
  3927. if(!empty($estimateInfo)) $estimateInfo->estimate = json_decode($estimateInfo->estimate);
  3928. return $estimateInfo;
  3929. }
  3930. /**
  3931. * 获取需求估算的轮次列表。
  3932. * Get the rounds list of the estimate for the story.
  3933. *
  3934. * @param int $storyID
  3935. * @access public
  3936. * @return array
  3937. */
  3938. public function getEstimateRounds($storyID)
  3939. {
  3940. $lastRoundNo = $this->dao->select('round')->from(TABLE_STORYESTIMATE)
  3941. ->where('story')->eq($storyID)
  3942. ->orderBy('round_desc')
  3943. ->fetch('round');
  3944. if(!$lastRoundNo) return array();
  3945. $rounds = array();
  3946. for($roundNo = 1; $roundNo <= $lastRoundNo; $roundNo++)
  3947. {
  3948. $rounds[$roundNo] = sprintf($this->lang->story->storyRound, $roundNo);
  3949. }
  3950. return $rounds;
  3951. }
  3952. /**
  3953. * Save estimate information.
  3954. *
  3955. * @param int $storyID
  3956. * @access public
  3957. * @return void
  3958. */
  3959. public function saveEstimateInfo($storyID)
  3960. {
  3961. $data = fixer::input('post')->get();
  3962. $lastRound = $this->dao->select('round')->from(TABLE_STORYESTIMATE)
  3963. ->where('story')->eq($storyID)
  3964. ->orderBy('round_desc')
  3965. ->fetch('round');
  3966. $estimates = array();
  3967. foreach($data->account as $key => $account)
  3968. {
  3969. if(!empty($data->estimate[$key]) and !is_numeric($data->estimate[$key]))
  3970. {
  3971. dao::$errors['estimate'] = $this->lang->story->estimateMustBeNumber;
  3972. }
  3973. elseif(!empty($data->estimate[$key]) and $data->estimate[$key] < 0)
  3974. {
  3975. dao::$errors['estimate'] = $this->lang->story->estimateMustBePlus;
  3976. }
  3977. if(dao::isError()) return;
  3978. $estimates[$account]['account'] = $account;
  3979. $estimates[$account]['estimate'] = strpos($data->estimate[$key], '-') !== false ? (int)$data->estimate[$key] : (float)$data->estimate[$key];
  3980. }
  3981. $storyEstimate = new stdclass();
  3982. $storyEstimate->story = $storyID;
  3983. $storyEstimate->round = empty($lastRound) ? 1 : $lastRound + 1;
  3984. $storyEstimate->estimate = json_encode($estimates);
  3985. $storyEstimate->average = (float)$data->average;
  3986. $storyEstimate->openedBy = $this->app->user->account;
  3987. $storyEstimate->openedDate = helper::now();
  3988. $this->dao->insert(TABLE_STORYESTIMATE)->data($storyEstimate)->exec();
  3989. }
  3990. /**
  3991. * Update the story order according to the plan.
  3992. *
  3993. * @param int $planID
  3994. * @param array $sortIDList
  3995. * @param string $orderBy
  3996. * @param int $pageID
  3997. * @param int $recPerPage
  3998. * @access public
  3999. * @return void
  4000. */
  4001. public function sortStoriesOfPlan($planID, $sortIDList, $orderBy = 'id_desc', $pageID = 1, $recPerPage = 100)
  4002. {
  4003. /* Append id for second sort. */
  4004. $orderBy = common::appendOrder($orderBy);
  4005. /* Get all stories by plan. */
  4006. $stories = $this->getPlanStories($planID, 'all', $orderBy);
  4007. $storyIDList = array_keys($stories);
  4008. /* Calculate how many numbers there are before the sort list and after the sort list. */
  4009. $frontStoryCount = $recPerPage * ($pageID - 1);
  4010. $behindStoryCount = $recPerPage * $pageID;
  4011. $frontStoryIDList = array_slice($storyIDList, 0, $frontStoryCount);
  4012. $behindStoryIDList = array_slice($storyIDList, $behindStoryCount, count($storyIDList) - $behindStoryCount);
  4013. /* Merge to get a new sort list. */
  4014. $newSortIDList = array_merge($frontStoryIDList, $sortIDList, $behindStoryIDList);
  4015. if(strpos($orderBy, 'order_desc') !== false) $newSortIDList = array_reverse($newSortIDList);
  4016. /* Loop update the story order of plan. */
  4017. $order = 1;
  4018. foreach($newSortIDList as $storyID)
  4019. {
  4020. $this->dao->update(TABLE_PLANSTORY)->set('`order`')->eq($order)->where('story')->eq($storyID)->andWhere('plan')->eq($planID)->exec();
  4021. $order++;
  4022. }
  4023. }
  4024. /**
  4025. * 将软件需求语言项替换为用户需求语言项。
  4026. * Replace the lang of the story with the requirement.
  4027. *
  4028. * @param string $type
  4029. * @access public
  4030. * @return void
  4031. */
  4032. public function replaceURLang($type)
  4033. {
  4034. $storyLang = $this->lang->story;
  4035. $SRCommon = $this->lang->SRCommon;
  4036. if($this->app->tab != 'product' && $this->app->rawMethod == 'report')
  4037. {
  4038. $replacement = $this->lang->common->story;
  4039. $storyLang->report->charts['storiesPerProduct'] = str_replace($SRCommon, $replacement, $storyLang->report->charts['storiesPerProduct']);
  4040. $storyLang->report->charts['storiesPerModule'] = str_replace($SRCommon, $replacement, $storyLang->report->charts['storiesPerModule']);
  4041. $storyLang->report->charts['storiesPerSource'] = str_replace($SRCommon, $replacement, $storyLang->report->charts['storiesPerSource']);
  4042. }
  4043. else
  4044. {
  4045. if($type == 'story') return;
  4046. $replacement = $type == 'requirement' ? $this->lang->URCommon : $this->lang->epic->common;
  4047. $storyLang->create = str_replace($SRCommon, $replacement, $storyLang->create);
  4048. $storyLang->changeAction = str_replace($SRCommon, $replacement, $storyLang->changeAction);
  4049. $storyLang->changed = str_replace($SRCommon, $replacement, $storyLang->changed);
  4050. $storyLang->assignAction = str_replace($SRCommon, $replacement, $storyLang->assignAction);
  4051. $storyLang->reviewAction = str_replace($SRCommon, $replacement, $storyLang->reviewAction);
  4052. $storyLang->subdivideAction = str_replace($SRCommon, $replacement, $storyLang->subdivideAction);
  4053. $storyLang->closeAction = str_replace($SRCommon, $replacement, $storyLang->closeAction);
  4054. $storyLang->activateAction = str_replace($SRCommon, $replacement, $storyLang->activateAction);
  4055. $storyLang->deleteAction = str_replace($SRCommon, $replacement, $storyLang->deleteAction);
  4056. $storyLang->view = str_replace($SRCommon, $replacement, $storyLang->view);
  4057. $storyLang->linkStory = str_replace($SRCommon, $replacement, $storyLang->linkStory);
  4058. $storyLang->exportAction = str_replace($SRCommon, $replacement, $storyLang->exportAction);
  4059. $storyLang->zeroCase = str_replace($SRCommon, $replacement, $storyLang->zeroCase);
  4060. $storyLang->zeroTask = str_replace($SRCommon, $replacement, $storyLang->zeroTask);
  4061. $storyLang->copyTitle = str_replace($SRCommon, $replacement, $storyLang->copyTitle);
  4062. $storyLang->title = str_replace($SRCommon, $replacement, $storyLang->title);
  4063. $storyLang->spec = str_replace($SRCommon, $replacement, $storyLang->spec);
  4064. $storyLang->children = str_replace($SRCommon, $replacement, $storyLang->children);
  4065. $storyLang->linkStories = str_replace($SRCommon, $replacement, $storyLang->linkStories);
  4066. $storyLang->childStories = str_replace($SRCommon, $replacement, $storyLang->childStories);
  4067. $storyLang->duplicateStory = str_replace($SRCommon, $replacement, $storyLang->duplicateStory);
  4068. $storyLang->newStory = str_replace($SRCommon, $replacement, $storyLang->newStory);
  4069. $storyLang->copy = str_replace($SRCommon, $replacement, $storyLang->copy);
  4070. $storyLang->total = str_replace($SRCommon, $replacement, $storyLang->total);
  4071. $storyLang->released = str_replace($SRCommon, $replacement, $storyLang->released);
  4072. $storyLang->legendLifeTime = str_replace($SRCommon, $replacement, $storyLang->legendLifeTime);
  4073. $storyLang->legendLinkStories = str_replace($SRCommon, $replacement, $storyLang->legendLinkStories);
  4074. $storyLang->legendChildStories = str_replace($SRCommon, $replacement, $storyLang->legendChildStories);
  4075. $storyLang->legendSpec = str_replace($SRCommon, $replacement, $storyLang->legendSpec);
  4076. $storyLang->unlinkStory = str_replace($SRCommon, $replacement, $storyLang->unlinkStory);
  4077. $storyLang->legendLifeTime = str_replace($SRCommon, $replacement, $storyLang->legendLifeTime);
  4078. $storyLang->legendLinkStories = str_replace($SRCommon, $replacement, $storyLang->legendLinkStories);
  4079. $storyLang->legendChildStories = str_replace($SRCommon, $replacement, $storyLang->legendChildStories);
  4080. $storyLang->legendSpec = str_replace($SRCommon, $replacement, $storyLang->legendSpec);
  4081. $storyLang->notice->closed = str_replace($SRCommon, $replacement, $storyLang->notice->closed);
  4082. $storyLang->notice->reviewerNotEmpty = str_replace($SRCommon, $replacement, $storyLang->notice->reviewerNotEmpty);
  4083. $storyLang->closedStory = str_replace($SRCommon, $replacement, $storyLang->closedStory);
  4084. $storyLang->report->charts['storiesPerProduct'] = str_replace($SRCommon, $replacement, $storyLang->report->charts['storiesPerProduct']);
  4085. $storyLang->report->charts['storiesPerModule'] = str_replace($SRCommon, $replacement, $storyLang->report->charts['storiesPerModule']);
  4086. $storyLang->report->charts['storiesPerSource'] = str_replace($SRCommon, $replacement, $storyLang->report->charts['storiesPerSource']);
  4087. }
  4088. }
  4089. /**
  4090. * 通过需求编号和版本获取评审人和评审结果的键值对。
  4091. * Get story reviewer pairs. array(reviewer => result, ...)
  4092. *
  4093. * @param int $storyID
  4094. * @param int version
  4095. * @access public
  4096. * @return array
  4097. */
  4098. public function getReviewerPairs($storyID, $version)
  4099. {
  4100. return $this->dao->select('reviewer,result')->from(TABLE_STORYREVIEW)->where('story')->eq($storyID)->andWhere('version')->eq($version)->fetchPairs('reviewer', 'result');
  4101. }
  4102. /**
  4103. * Set story status by review rules.
  4104. *
  4105. * @param array $reviewerList
  4106. * @access public
  4107. * @return string
  4108. */
  4109. public function getReviewResult($reviewerList)
  4110. {
  4111. $results = array();
  4112. $passCount = 0;
  4113. $rejectCount = 0;
  4114. $revertCount = 0;
  4115. $clarifyCount = 0;
  4116. $moduleName = $this->app->rawModule;
  4117. $reviewRule = !empty($this->config->{$moduleName}->reviewRules) ? $this->config->{$moduleName}->reviewRules : 'allpass';
  4118. foreach($reviewerList as $result)
  4119. {
  4120. $passCount = $result == 'pass' ? $passCount + 1 : $passCount;
  4121. $rejectCount = $result == 'reject' ? $rejectCount + 1 : $rejectCount;
  4122. $revertCount = $result == 'revert' ? $revertCount + 1 : $revertCount;
  4123. $clarifyCount = $result == 'clarify' ? $clarifyCount + 1 : $clarifyCount;
  4124. $results[$result] = $result;
  4125. }
  4126. $finalResult = '';
  4127. if($reviewRule == 'allpass' and $passCount == count($reviewerList)) $finalResult = 'pass';
  4128. if($reviewRule == 'halfpass' and $passCount >= floor(count($reviewerList) / 2) + 1) $finalResult = 'pass';
  4129. if(empty($finalResult))
  4130. {
  4131. if($clarifyCount >= floor(count($reviewerList) / 2) + 1) return 'clarify';
  4132. if($revertCount >= floor(count($reviewerList) / 2) + 1) return 'revert';
  4133. if($rejectCount >= floor(count($reviewerList) / 2) + 1) return 'reject';
  4134. if(isset($results['clarify'])) return 'clarify';
  4135. if(isset($results['revert'])) return 'revert';
  4136. if(isset($results['reject'])) return 'reject';
  4137. }
  4138. return $finalResult;
  4139. }
  4140. /**
  4141. * Set story status by review result.
  4142. *
  4143. * @param object $story
  4144. * @param object $oldStory
  4145. * @param string $result
  4146. * @param string $reason
  4147. * @access public
  4148. * @return array
  4149. */
  4150. public function setStatusByReviewResult($story, $oldStory, $result, $reason = 'cancel')
  4151. {
  4152. if($result == 'pass') $story->status = 'active';
  4153. if($result == 'clarify')
  4154. {
  4155. /* When the review result of the changed story is clarify, the status should be changing. */
  4156. $isChanged = $oldStory->changedBy ? true : false;
  4157. $story->status = $isChanged ? 'changing' : 'draft';
  4158. $story->reviewedBy = '';
  4159. }
  4160. if($result == 'revert')
  4161. {
  4162. $story->status = 'active';
  4163. $story->version = $oldStory->version - 1;
  4164. $story->title = $this->dao->select('title')->from(TABLE_STORYSPEC)->where('story')->eq($oldStory->id)->andWHere('version')->eq($oldStory->version - 1)->fetch('title');
  4165. /* Delete versions that is after this version. */
  4166. $twinsIdList = $oldStory->id . ($oldStory->twins ? ",{$oldStory->twins}" : '');
  4167. $this->dao->delete()->from(TABLE_STORYSPEC)->where('story')->in($twinsIdList)->andWHere('version')->in($oldStory->version)->exec();
  4168. $this->dao->delete()->from(TABLE_STORYREVIEW)->where('story')->in($twinsIdList)->andWhere('version')->in($oldStory->version)->exec();
  4169. }
  4170. if($result == 'reject')
  4171. {
  4172. $now = helper::now();
  4173. $reason = (!empty($story->closedReason)) ? $story->closedReason : $reason;
  4174. $story->status = 'closed';
  4175. $story->closedBy = $this->app->user->account;
  4176. $story->closedDate = $now;
  4177. $story->assignedTo = 'closed';
  4178. $story->assignedDate = $now;
  4179. $story->stage = $reason == 'done' && $oldStory->type == 'story' ? 'released' : 'closed';
  4180. $story->closedReason = $reason;
  4181. }
  4182. $story->finalResult = $result;
  4183. return $story;
  4184. }
  4185. /**
  4186. * Record story review actions.
  4187. *
  4188. * @param object $oldStory
  4189. * @param object $story
  4190. * @param string $comment
  4191. * @access public
  4192. * @return int|string
  4193. */
  4194. public function recordReviewAction($oldStory, $story, $comment = '')
  4195. {
  4196. $isSuperReviewer = $this->storyTao->isSuperReviewer();
  4197. $result = zget($story, 'result', '');
  4198. $reason = zget($story, 'closedReason', '');
  4199. $story->id = (int)$story->id;
  4200. $this->loadModel('action');
  4201. if($isSuperReviewer and $this->app->rawMethod != 'edit') return $this->action->create('story', $story->id, 'Reviewed', $comment, ucfirst($result) . '|superReviewer');
  4202. $reasonParam = $result == 'reject' ? ',' . $reason : '';
  4203. $actionID = 0;
  4204. if(!empty($result)) $actionID = $this->action->create('story', $story->id, 'Reviewed', $comment, ucfirst($result) . $reasonParam);
  4205. if(isset($story->finalResult))
  4206. {
  4207. $preStatus = $oldStory->status;
  4208. $isChanged = !empty($story->changedBy) ? true : false;
  4209. if($preStatus == 'reviewing') $preStatus = $isChanged ? 'changing' : 'draft';
  4210. if($story->finalResult == 'reject') return $this->action->create('story', $story->id, 'ReviewRejected', '', "reject|$preStatus");
  4211. if($story->finalResult == 'pass') return $this->action->create('story', $story->id, 'ReviewPassed', '', "pass|$preStatus");
  4212. if($story->finalResult == 'clarify') return $this->action->create('story', $story->id, 'ReviewClarified', '', "clarify|$preStatus");
  4213. if($story->finalResult == 'revert') return $this->action->create('story', $story->id, 'ReviewReverted', '', "revert|$preStatus");
  4214. }
  4215. return $actionID;
  4216. }
  4217. /**
  4218. * Update the story fields value by review.
  4219. *
  4220. * @param int $storyID
  4221. * @param object $oldStory
  4222. * @param object $story
  4223. * @access public
  4224. * @return object
  4225. */
  4226. public function updateStoryByReview($storyID, $oldStory, $story)
  4227. {
  4228. $isSuperReviewer = $this->storyTao->isSuperReviewer();
  4229. if($isSuperReviewer) return $this->superReview($storyID, $oldStory, $story);
  4230. $reviewerList = $this->getReviewerPairs($storyID, (int)$oldStory->version);
  4231. $reviewedBy = explode(',', trim($story->reviewedBy, ','));
  4232. if(!array_diff(array_keys($reviewerList), $reviewedBy))
  4233. {
  4234. $reviewResult = $this->getReviewResult($reviewerList);
  4235. $story = $this->setStatusByReviewResult($story, $oldStory, $reviewResult);
  4236. }
  4237. return $story;
  4238. }
  4239. /**
  4240. * Do update reviewer.
  4241. *
  4242. * @param int $storyID
  4243. * @param object $story
  4244. * @access protected
  4245. * @return void
  4246. */
  4247. public function doUpdateReviewer($storyID, $story)
  4248. {
  4249. $oldStory = $this->fetchByID($storyID);
  4250. if(empty($oldStory)) return;
  4251. $oldReviewer = $this->getReviewerPairs($storyID, $oldStory->version);
  4252. $twins = explode(',', trim($oldStory->twins, ','));
  4253. $reviewerList = implode(',', array_filter($story->reviewer));
  4254. /* Update story reviewer. */
  4255. $this->dao->delete()->from(TABLE_STORYREVIEW)->where('story')->eq($storyID)
  4256. ->andWhere('version')->eq($oldStory->version)
  4257. ->beginIF($oldStory->status == 'reviewing')->andWhere('reviewer')->notIN($reviewerList)
  4258. ->exec();
  4259. /* Sync twins. */
  4260. if(!empty(array_filter($twins)))
  4261. {
  4262. foreach($twins as $twinID)
  4263. {
  4264. $this->dao->delete()->from(TABLE_STORYREVIEW)->where('story')->eq($twinID)
  4265. ->andWhere('version')->eq($oldStory->version)
  4266. ->beginIF($oldStory->status == 'reviewing')->andWhere('reviewer')->notin($reviewerList)
  4267. ->exec();
  4268. }
  4269. }
  4270. foreach($story->reviewer as $reviewer)
  4271. {
  4272. if(empty($reviewer)) continue;
  4273. if($oldStory->status == 'reviewing' and isset($oldReviewer[$reviewer])) continue;
  4274. $reviewData = new stdclass();
  4275. $reviewData->story = $storyID;
  4276. $reviewData->version = $oldStory->version;
  4277. $reviewData->reviewer = $reviewer;
  4278. $this->dao->insert(TABLE_STORYREVIEW)->data($reviewData)->exec();
  4279. /* Sync twins. */
  4280. if(!empty(array_filter($twins)))
  4281. {
  4282. foreach($twins as $twinID)
  4283. {
  4284. $reviewData->story = $twinID;
  4285. $this->dao->insert(TABLE_STORYREVIEW)->data($reviewData)->exec();
  4286. }
  4287. }
  4288. }
  4289. if($oldStory->status == 'reviewing') $story = $this->updateStoryByReview($storyID, $oldStory, $story);
  4290. if(strpos('draft,changing', $oldStory->status) !== false) $story->reviewedBy = '';
  4291. }
  4292. /**
  4293. * To review for super reviewer.
  4294. *
  4295. * @param int $storyID
  4296. * @param object $oldStory
  4297. * @param object $story
  4298. * @param string $result
  4299. * @param string $reason
  4300. * @access public
  4301. * @return object
  4302. */
  4303. public function superReview($storyID, $oldStory, $story, $result = '', $reason = '')
  4304. {
  4305. $result = isset($story->result) ? $story->result : $result;
  4306. if(empty($result)) return $story;
  4307. $reason = isset($story->closedReason) ? $story->closedReason : $reason;
  4308. $story = $this->setStatusByReviewResult($story, $oldStory, $result, $reason);
  4309. $this->dao->delete()->from(TABLE_STORYREVIEW)
  4310. ->where('story')->in($storyID . ($oldStory->twins ? ",{$oldStory->twins}" : ''))
  4311. ->andWhere('version')->eq($oldStory->version)
  4312. ->andWhere('result')->eq('')
  4313. ->exec();
  4314. return $story;
  4315. }
  4316. /**
  4317. * 获取要导出的需求数据。
  4318. * Get the stories to export.
  4319. *
  4320. * @param string $orderBy id_desc
  4321. * @param string $storyType
  4322. * @param object $postData
  4323. * @access public
  4324. * @return array
  4325. */
  4326. public function getExportStories($orderBy = 'id_desc', $storyType = 'story', $postData = null)
  4327. {
  4328. $orderBy = $orderBy !== 'id_desc' ? 'id_desc' : $orderBy; /* The order of the stories for exporting is disabled. */
  4329. $this->loadModel('file');
  4330. $this->loadModel('branch');
  4331. $this->replaceURLang($storyType);
  4332. $storyLang = $this->lang->story;
  4333. /* Create field lists. */
  4334. $fields = !empty($postData->exportFields) ? $postData->exportFields : explode(',', $this->config->story->exportFields);
  4335. if(empty($postData->exportFields)) $postData->exportFields = $this->config->story->exportFields;
  4336. foreach($fields as $key => $fieldName)
  4337. {
  4338. $fieldName = trim($fieldName);
  4339. $fields[$fieldName] = isset($storyLang->$fieldName) ? $storyLang->$fieldName : $fieldName;
  4340. unset($fields[$key]);
  4341. }
  4342. /* Get stories. */
  4343. $stories = array();
  4344. $selectedIDList = $this->cookie->checkedItem ? $this->cookie->checkedItem : '0';
  4345. $queryCondition = $storyType . 'QueryCondition';
  4346. $onlyCondition = $storyType . 'OnlyCondition';
  4347. if($this->session->$onlyCondition)
  4348. {
  4349. $queryCondition = $postData->exportType == 'selected' ? ' `id` ' . helper::dbIN($selectedIDList) : str_replace('`story`', '`id`', $this->session->$queryCondition);
  4350. $stories = $this->dao->select('id,title,type,grade,linkStories,parent,mailto,reviewedBy')->from(TABLE_STORY)->where($queryCondition)->orderBy($orderBy)->fetchAll('id');
  4351. }
  4352. else
  4353. {
  4354. $orderBy = " ORDER BY " . helper::wrapSqlAfterOrderBy($orderBy);
  4355. $querySQL = $this->session->$queryCondition . $orderBy;
  4356. if($postData->exportType == 'selected') $querySQL = "SELECT * FROM " . TABLE_STORY . "WHERE `id` IN({$selectedIDList})" . $orderBy;
  4357. $stmt = $this->app->dbQuery($querySQL);
  4358. while($row = $stmt->fetch()) $stories[$row->id] = $row;
  4359. }
  4360. if(empty($stories)) return $stories;
  4361. $storyIdList = array_keys($stories);
  4362. $children = array();
  4363. $parentIdList = array();
  4364. foreach($stories as $story)
  4365. {
  4366. if($story->parent > 0)
  4367. {
  4368. $parentIdList[] = $story->parent;
  4369. if(isset($stories[$story->parent]))
  4370. {
  4371. $children[$story->parent][$story->id] = $story;
  4372. }
  4373. }
  4374. }
  4375. if(!empty($children))
  4376. {
  4377. $reorderStories = array();
  4378. foreach($stories as $story)
  4379. {
  4380. $reorderStories[$story->id] = $story;
  4381. if(!isset($children[$story->id])) continue;
  4382. foreach($children[$story->id] as $childrenID => $childrenStory) $reorderStories[$childrenID] = $childrenStory;
  4383. }
  4384. $stories = $reorderStories;
  4385. }
  4386. /* Get users, products and relations. */
  4387. $users = $this->loadModel('user')->getPairs('noletter');
  4388. $storyTasks = $this->loadModel('task')->getStoryTaskCounts($storyIdList);
  4389. $storyBugs = $this->loadModel('bug')->getStoryBugCounts($storyIdList);
  4390. $storyCases = $this->loadModel('testcase')->getStoryCaseCounts($storyIdList);
  4391. $parents = $this->dao->select('id, title')->from(TABLE_STORY)->where('id')->in($parentIdList)->fetchPairs('id', 'title');
  4392. /* Get related objects title or names. */
  4393. $relatedSpecs = $this->dao->select('*')->from(TABLE_STORYSPEC)->where('`story`')->in($storyIdList)->orderBy('version desc')->fetchGroup('story');
  4394. $relatedStories = $this->dao->select('*')->from(TABLE_STORY)->where('`id`')->in($storyIdList)->andWhere('deleted')->eq('0')->fetchPairs('id', 'title');
  4395. $fileIdList = array();
  4396. foreach($relatedSpecs as $relatedSpec)
  4397. {
  4398. if(!empty($relatedSpec[0]->files)) $fileIdList[] = $relatedSpec[0]->files;
  4399. }
  4400. $fileIdList = array_unique($fileIdList);
  4401. $relatedFiles = $this->dao->select('id, objectID, pathname, title')->from(TABLE_FILE)->where('objectType')->eq($storyType)->andWhere('objectID')->in($storyIdList)->andWhere('extra')->ne('editor')->fetchGroup('objectID');
  4402. $filesInfo = $this->dao->select('id, objectID, pathname, title')->from(TABLE_FILE)->where('id')->in($fileIdList)->andWhere('extra')->ne('editor')->fetchAll('id');
  4403. $gradeGroup = $this->getGradeGroup();
  4404. foreach($stories as $story)
  4405. {
  4406. $story->spec = '';
  4407. $story->verify = '';
  4408. if(isset($relatedSpecs[$story->id]))
  4409. {
  4410. $storySpec = $relatedSpecs[$story->id][0];
  4411. $story->title = $storySpec->title;
  4412. $story->spec = $storySpec->spec;
  4413. $story->verify = $storySpec->verify;
  4414. if(!empty($storySpec->files) and empty($relatedFiles[$story->id]) and !empty($filesInfo[$storySpec->files])) $relatedFiles[$story->id][0] = $filesInfo[$storySpec->files];
  4415. }
  4416. if($postData->fileType == 'csv')
  4417. {
  4418. $story->spec = htmlspecialchars_decode($story->spec);
  4419. $story->spec = str_replace("<br />", "\n", $story->spec);
  4420. $story->spec = str_replace('"', '""', $story->spec);
  4421. $story->spec = str_replace('&nbsp;', ' ', $story->spec);
  4422. $story->verify = htmlspecialchars_decode($story->verify);
  4423. $story->verify = str_replace("<br />", "\n", $story->verify);
  4424. $story->verify = str_replace('"', '""', $story->verify);
  4425. $story->verify = str_replace('&nbsp;', ' ', $story->verify);
  4426. }
  4427. /* fill some field with useful value. */
  4428. if(isset($storyTasks[$story->id])) $story->taskCountAB = $storyTasks[$story->id];
  4429. if(isset($storyBugs[$story->id])) $story->bugCountAB = $storyBugs[$story->id];
  4430. if(isset($storyCases[$story->id])) $story->caseCountAB = $storyCases[$story->id];
  4431. if($story->linkStories)
  4432. {
  4433. $tmpLinkStories = array();
  4434. $linkStoriesIdList = explode(',', $story->linkStories);
  4435. foreach($linkStoriesIdList as $linkStoryID) $tmpLinkStories[] = zget($relatedStories, trim($linkStoryID), '');
  4436. $story->linkStories = implode("; \n", array_filter($tmpLinkStories));
  4437. }
  4438. /* Set related files. */
  4439. $story->files = '';
  4440. if(isset($relatedFiles[$story->id]))
  4441. {
  4442. foreach($relatedFiles[$story->id] as $file)
  4443. {
  4444. $fileURL = common::getSysURL() . helper::createLink('file', 'download', "fileID=$file->id");
  4445. $story->files .= html::a($fileURL, $file->title, '_blank') . '<br />';
  4446. }
  4447. }
  4448. $mailtoList = array_filter(explode(',', $story->mailto));
  4449. foreach($mailtoList as $i => $mailto) $mailtoList[$i] = zget($users, trim($mailto));
  4450. $story->mailto = implode(',', array_filter($mailtoList));
  4451. $reviewedByList = array_filter(explode(',', $story->reviewedBy));
  4452. foreach($reviewedByList as $i => $reviewedBy) $reviewedByList[$i] = zget($users, trim($reviewedBy));
  4453. $story->reviewedBy = implode(',', array_filter($reviewedByList));
  4454. $gradePairs = zget($gradeGroup, $story->type, array());
  4455. $grade = zget($gradePairs, $story->grade, '');
  4456. $story->grade = $grade->name;
  4457. $story->parentId = $story->parent;
  4458. $story->parent = zget($parents, $story->parent, '');
  4459. }
  4460. return $stories;
  4461. }
  4462. /**
  4463. * 获取需求激活后的状态。
  4464. * Get the story status after activation.
  4465. *
  4466. * @param int $storyID
  4467. * @param bool $hasTwins
  4468. * @access public
  4469. * @return string
  4470. */
  4471. public function getActivateStatus($storyID, $hasTwins = true)
  4472. {
  4473. $status = 'active';
  4474. $action = 'closed,reviewrejected,closedbysystem,closedbyparent';
  4475. $action = $hasTwins ? $action . ',synctwins' : $action;
  4476. $lastRecord = $this->dao->select('action,extra')->from(TABLE_ACTION)
  4477. ->where('objectType')->eq('story')
  4478. ->andWhere('objectID')->eq($storyID)
  4479. ->andWhere('action')->in($action)
  4480. ->orderBy('id_desc')
  4481. ->fetch();
  4482. if(empty($lastRecord->action)) return $status;
  4483. /* Set the status of the story to previous status in latest action log. */
  4484. $lastAction = $lastRecord->action;
  4485. if(strpos(',closed,reviewrejected,closedbyparent,', ",$lastAction,") !== false)
  4486. {
  4487. $status = strpos($lastRecord->extra, '|') !== false ? substr($lastRecord->extra, strpos($lastRecord->extra, '|') + 1) : 'active';
  4488. if($status == 'closed') $status = 'active'; /* Set the status to active if last status before close it is closed as well. */
  4489. }
  4490. /* Activate parent story. */
  4491. if($lastAction == 'closedbysystem')
  4492. {
  4493. $status = $lastRecord->extra ? $lastRecord->extra : 'active';
  4494. if($status == 'active')
  4495. {
  4496. /* If the parent story is not reviewed before closing, it will be activated to the status in changing. */
  4497. $hasNotReviewed = $this->dao->select('t1.*')->from(TABLE_STORYREVIEW)->alias('t1')
  4498. ->leftJoin(TABLE_STORY)->alias('t2')->on('t1.story = t2.id and t2.version = t1.version')
  4499. ->where('t1.story')->eq($storyID)
  4500. ->andWhere('t1.result')->eq('')
  4501. ->fetchAll();
  4502. if(!empty($hasNotReviewed)) $status = 'changing';
  4503. }
  4504. }
  4505. /* When activating twin story, you need to check the status of the twin story selected when closing. */
  4506. if($lastAction == 'synctwins')
  4507. {
  4508. $syncStoryID = strpos($lastRecord->extra, '|') !== false ? substr($lastRecord->extra, strpos($lastRecord->extra, '|') + 1) : 0;
  4509. $status = $this->getActivateStatus((int)$syncStoryID, false);
  4510. }
  4511. /* Change status of launched and projected to active. */
  4512. if(in_array($status, array('launched', 'projected'))) $status = 'active';
  4513. return $status;
  4514. }
  4515. /**
  4516. * Get reviewer pairs for story .
  4517. *
  4518. * @param int $productID
  4519. * @access public
  4520. * @return array
  4521. */
  4522. public function getStoriesReviewer($productID = 0)
  4523. {
  4524. $this->loadModel('user');
  4525. $product = $this->loadModel('product')->getByID($productID);
  4526. $reviewers = $product->reviewer;
  4527. if(!$reviewers and $product->acl != 'open') $reviewers = $this->user->getProductViewListUsers($product);
  4528. return $this->user->getPairs('noclosed|nodeleted', '', 0, $reviewers);
  4529. }
  4530. /**
  4531. * Get the last reviewer.
  4532. *
  4533. * @param int $storyID
  4534. * @access public
  4535. * @return string
  4536. */
  4537. public function getLastReviewer($storyID)
  4538. {
  4539. return $this->dao->select('t2.new')->from(TABLE_ACTION)->alias('t1')
  4540. ->leftJoin(TABLE_HISTORY)->alias('t2')->on('t1.id = t2.action')
  4541. ->where('t1.objectType')->eq('story')
  4542. ->andWhere('t1.objectID')->eq($storyID)
  4543. ->andWhere('t2.field')->in('reviewer,reviewers')
  4544. ->andWhere('t2.new')->ne('')
  4545. ->orderBy('t1.id_desc')
  4546. ->fetch('new');
  4547. }
  4548. /**
  4549. * Sync twins.
  4550. *
  4551. * @param int $storyID
  4552. * @param string $twins
  4553. * @param array $changes
  4554. * @param string $operate
  4555. * @access public
  4556. * @return void
  4557. */
  4558. public function syncTwins($storyID, $twins, $changes, $operate)
  4559. {
  4560. if(empty($twins) || empty($changes)) return;
  4561. /* Get the fields and values to be synchronized. */
  4562. $syncFieldList = array();
  4563. foreach($changes as $changeInfo)
  4564. {
  4565. $fieldName = $changeInfo['field'];
  4566. $fieldValue = $changeInfo['new'];
  4567. if(strpos(',product,branch,module,plan,stage,stagedBy,spec,verify,files,reviewers,reviewer,', ",{$fieldName},") !== false) continue;
  4568. $syncFieldList[$fieldName] = $fieldValue;
  4569. }
  4570. if(empty($syncFieldList)) return;
  4571. /* Synchronize and record dynamics. */
  4572. $this->loadModel('action');
  4573. foreach(explode(',', $twins) as $twinID)
  4574. {
  4575. $twinID = (int)$twinID;
  4576. if(empty($twinID)) continue;
  4577. $this->dao->update(TABLE_STORY)->data($syncFieldList)->where('id')->eq($twinID)->exec();
  4578. if(strtolower($operate) == 'closed') $this->dao->update(TABLE_STORY)->set('assignedTo')->eq('closed')->where('id')->eq($twinID)->exec();
  4579. if(dao::isError()) continue;
  4580. $this->setStage($twinID);
  4581. $actionID = $this->action->create('story', $twinID, 'synctwins', '', "$operate|$storyID");
  4582. $this->action->logHistory($actionID, $changes);
  4583. }
  4584. }
  4585. /**
  4586. * Build operate menu.
  4587. *
  4588. * @param object $story
  4589. * @param string $type
  4590. * @param object $execution
  4591. * @param string $storyType story|requirement
  4592. * @param array $maxGradeGroup
  4593. * @access public
  4594. * @return array
  4595. */
  4596. public function buildActionButtonList($story, $type = 'browse', $execution = null, $storyType = 'story', $maxGradeGroup = array())
  4597. {
  4598. $params = "storyID=$story->id";
  4599. if($type == 'browse') return $this->storyTao->buildBrowseActionBtnList($story, $params, $storyType, $execution, $maxGradeGroup);
  4600. return array();
  4601. }
  4602. /**
  4603. * 格式化列表中需求数据。
  4604. * Format story for list.
  4605. *
  4606. * @param object $story
  4607. * @param array $options
  4608. * @param string $storyType sting|requirement
  4609. * @param array $maxGradeGroup
  4610. * @access public
  4611. * @return object
  4612. */
  4613. public function formatStoryForList($story, $options = array(), $storyType = 'story', $maxGradeGroup = array())
  4614. {
  4615. $story->actions = $this->buildActionButtonList($story, 'browse', zget($options, 'execution', null), $storyType, $maxGradeGroup);
  4616. $story->estimate = helper::formatHours($story->estimate) . $this->config->hourUnit;
  4617. $story->taskCount = zget(zget($options, 'storyTasks', array()), $story->id, 0);
  4618. $story->bugCount = zget(zget($options, 'storyBugs', array()), $story->id, 0);
  4619. $story->caseCount = zget(zget($options, 'storyCases', array()), $story->id, 0);
  4620. $story->module = zget(zget($options, 'modules', array()), $story->module, '');
  4621. $story->branch = zget(zget($options, 'branches', array()), $story->branch, '');
  4622. $story->plan = isset($story->planTitle) ? $story->planTitle : zget(zget($options, 'plans', array()), $story->plan, '');
  4623. $story->roadmap = zget(zget($options, 'roadmaps', array()), $story->roadmap, 0);
  4624. $story->sourceNote = $story->source == 'researchreport' ? zget(zget($options, 'reports', array()), $story->sourceNote, '') : $story->sourceNote;
  4625. $story->source = zget($this->lang->{$story->type}->sourceList, $story->source);
  4626. $story->category = zget($this->lang->{$story->type}->categoryList, $story->category);
  4627. $story->closedReason = zget($this->lang->{$story->type}->reasonList, $story->closedReason);
  4628. $story->stage = zget($this->lang->{$story->type}->stageList, $story->stage);
  4629. if($story->parent < 0) $story->parent = 0;
  4630. if(empty($options['execution'])) $story->isParent = isset($story->childItem);
  4631. /* Format user list. */
  4632. foreach(array('mailto', 'reviewer') as $fieldName)
  4633. {
  4634. if(!isset($story->{$fieldName})) continue;
  4635. $fieldValue = is_string($story->{$fieldName}) ? array_filter(explode(',', $story->{$fieldName})) : array_filter($story->{$fieldName});
  4636. foreach($fieldValue as $i => $account) $fieldValue[$i] = zget(zget($options, 'users', array()), $account);
  4637. $story->{$fieldName} = implode(' ', $fieldValue);
  4638. }
  4639. /* Rewrite actions by action menus in options. */
  4640. if(isset($options['actionMenus']))
  4641. {
  4642. $actions = array();
  4643. foreach($options['actionMenus'] as $actionName)
  4644. {
  4645. foreach($story->actions as $action)
  4646. {
  4647. if($action['name'] == $actionName) $actions[] = $action;
  4648. }
  4649. }
  4650. $story->actions = $actions;
  4651. }
  4652. /* Parent story has changed. */
  4653. if(!empty($story->parentChanged))
  4654. {
  4655. $story->rawStatus = 'changing';
  4656. $story->status = $this->lang->story->parent . $this->lang->story->change;
  4657. }
  4658. else
  4659. {
  4660. $story->rawStatus = $story->status;
  4661. $story->status = zget($this->lang->{$story->type}->statusList, $story->status);
  4662. }
  4663. return $story;
  4664. }
  4665. /**
  4666. * 更新需求的发布日期
  4667. * Update the released date of story.
  4668. *
  4669. * @param string $stories
  4670. * @param string $releasedDate
  4671. * @access public
  4672. * @return bool
  4673. */
  4674. public function updateStoryReleasedDate($stories, $releasedDate)
  4675. {
  4676. $this->dao->update(TABLE_STORY)
  4677. ->set('releasedDate')->eq($releasedDate)
  4678. ->where('id')->in($stories)
  4679. ->exec();
  4680. return !dao::isError();
  4681. }
  4682. /**
  4683. * 根据项目ID获取需求列表。
  4684. * Get story list by project id.
  4685. *
  4686. * @param int $projectID
  4687. * @access public
  4688. * @return array
  4689. */
  4690. public function getListByProject($projectID)
  4691. {
  4692. return $this->dao->select('t2.*, t1.version as taskVersion')->from(TABLE_PROJECTSTORY)->alias('t1')
  4693. ->leftJoin(TABLE_STORY)->alias('t2')->on('t1.story = t2.id')
  4694. ->where('t1.project')->eq($projectID)
  4695. ->andWhere('t2.deleted')->eq(0)
  4696. ->orderBy('t1.`order`_desc')
  4697. ->fetchAll();
  4698. }
  4699. /**
  4700. * 获取产品所有状态对应的需求总数。
  4701. * Get stories count of each status by product ID.
  4702. *
  4703. * @param int $productID
  4704. * @param string $storyType
  4705. * @access public
  4706. * @return array
  4707. */
  4708. public function getStoriesCountByProductID($productID, $storyType = 'requirement')
  4709. {
  4710. return $this->dao->select('product, status, count(status) AS count')->from(TABLE_STORY)
  4711. ->where('deleted')->eq(0)
  4712. ->andWhere('type')->eq($storyType)
  4713. ->andWhere('product')->eq($productID)
  4714. ->groupBy('product, status')
  4715. ->fetchAll('status');
  4716. }
  4717. /**
  4718. * Get product stroies by status.
  4719. *
  4720. * @param string $status noclosed || active
  4721. * @access public
  4722. * @return array | string
  4723. */
  4724. public function getStatusList($status)
  4725. {
  4726. $storyStatus = '';
  4727. if($status == 'noclosed')
  4728. {
  4729. $storyStatus = $this->lang->story->statusList;
  4730. unset($storyStatus['closed']);
  4731. $storyStatus = array_keys($storyStatus);
  4732. }
  4733. if($status == 'active') $storyStatus = $status;
  4734. return $storyStatus;
  4735. }
  4736. /**
  4737. * 需求下拉列表增加层级标签。
  4738. * Add grade label to story options.
  4739. *
  4740. * @param array $stories
  4741. * @access public
  4742. * @return array
  4743. */
  4744. public function addGradeLabel($stories)
  4745. {
  4746. $gradeGroup = $this->getGradeGroup();
  4747. $storyList = $this->getByList(array_keys($stories));
  4748. $options = array();
  4749. foreach($storyList as $story)
  4750. {
  4751. $gradePairs = zget($gradeGroup, $story->type, array());
  4752. $storyTitle = is_string($stories[$story->id]) ? $stories[$story->id] : $story->title;
  4753. if(isset($gradePairs[$story->grade]))
  4754. {
  4755. $options[] = array('text' => array('html' => "<span class='label rounded-xl ring-0 inverse bg-opacity-10 text-inherit mr-1 size-sm'>{$gradePairs[$story->grade]->name}</span> {$storyTitle}"), 'hint' => $storyTitle, 'value' => $story->id, 'keys' => $storyTitle);
  4756. }
  4757. else
  4758. {
  4759. $options[] = array('text' => $stories[$story->id], 'value' => $story->id);
  4760. }
  4761. }
  4762. return $options;
  4763. }
  4764. /**
  4765. * Get story grade list.
  4766. *
  4767. * @param string $type story|requirement|epic
  4768. * @access public
  4769. * @return array
  4770. */
  4771. public function getGradeList($type = 'story')
  4772. {
  4773. if(common::isTutorialMode()) return $this->loadModel('tutorial')->getStoryGrade();
  4774. return $this->dao->select('*')->from(TABLE_STORYGRADE)
  4775. ->where('1=1')
  4776. ->beginIF($type)->andWhere('type')->eq($type)->fi()
  4777. ->orderBy('grade_asc')
  4778. ->fetchAll();
  4779. }
  4780. /**
  4781. * Get story grade group.
  4782. *
  4783. * @access public
  4784. * @return array
  4785. */
  4786. public function getGradeGroup()
  4787. {
  4788. return $this->dao->select('type, grade, name')->from(TABLE_STORYGRADE)->orderBy('type,grade')->fetchGroup('type', 'grade');
  4789. }
  4790. /**
  4791. * Get story grade pairs.
  4792. *
  4793. * @param string $type story|requirement|epic
  4794. * @param string $status enable|disable
  4795. * @param array $appendList
  4796. * @access public
  4797. * @return array
  4798. */
  4799. public function getGradePairs($type = 'story', $status = 'enable', $appendList = array())
  4800. {
  4801. if(common::isTutorialMode()) return $this->loadModel('tutorial')->getGradePairs($type);
  4802. return $this->dao->select("grade, name")->from(TABLE_STORYGRADE)
  4803. ->where('type')->eq($type)
  4804. ->beginIF($status == 'enable')->andWhere('status')->eq('enable')->fi()
  4805. ->beginIF($appendList)
  4806. ->orWhere('(grade')->in($appendList)
  4807. ->andWhere('type')->eq($type)
  4808. ->markRight(1)
  4809. ->fi()
  4810. ->orderBy('grade_asc')
  4811. ->fetchPairs();
  4812. }
  4813. /**
  4814. * 获取需求列表层级设置菜单。
  4815. * Get grade menu in story list.
  4816. *
  4817. * @param string $storyType
  4818. * @param object $project
  4819. * @access public
  4820. * @return array
  4821. */
  4822. public function getGradeMenu($storyType, $project = null)
  4823. {
  4824. $storyTypes = isset($project->storyType) ? explode(',', $project->storyType) : array();
  4825. $showEpic = $this->config->enableER && ($storyType == 'epic' || in_array('epic', $storyTypes) || $storyType == 'all');
  4826. $showRequirement = $showEpic || $storyType == 'requirement' || in_array('requirement', $storyTypes) || $storyType == 'all';
  4827. $space = $this->app->getClientLang() == 'en' ? ' ' : '';
  4828. $menu = array();
  4829. if($showEpic)
  4830. {
  4831. $items = array();
  4832. $gradePairs = $this->getGradePairs('epic', 'all');
  4833. foreach($gradePairs as $grade => $name) $items[] = array('text' => $name, 'value' => "epic{$grade}");
  4834. $menu[] = array('text' => $this->lang->preview . $space . $this->lang->ERCommon, 'value' => 'epic', 'items' => $items);
  4835. }
  4836. if($showRequirement)
  4837. {
  4838. $items = array();
  4839. $gradePairs = $this->getGradePairs('requirement', 'all');
  4840. foreach($gradePairs as $grade => $name) $items[] = array('text' => $name, 'value' => "requirement{$grade}");
  4841. $menu[] = array('text' => $this->lang->preview . $space . $this->lang->URCommon, 'value' => 'requirement', 'items' => $items);
  4842. }
  4843. if($this->config->vision != 'or')
  4844. {
  4845. $items = array();
  4846. $gradePairs = $this->getGradePairs('story', 'all');
  4847. foreach($gradePairs as $grade => $name) $items[] = array('text' => $name, 'value' => "story{$grade}");
  4848. $menu[] = array('text' => $this->lang->preview . $space . $this->lang->SRCommon, 'value' => 'story', 'items' => $items);
  4849. }
  4850. return $menu;
  4851. }
  4852. /**
  4853. * 获取需求层级下拉列表。
  4854. * Get grade options.
  4855. *
  4856. * @param object|bool $story
  4857. * @param string $storyType
  4858. * @param array $appendList
  4859. * @access public
  4860. * @return array
  4861. */
  4862. public function getGradeOptions($story, $storyType, $appendList = array())
  4863. {
  4864. if(!$story || common::isTutorialMode()) return $this->getGradePairs($storyType, 'enable', $appendList);
  4865. $gradeOptions = array();
  4866. if($storyType != $story->type)
  4867. {
  4868. $gradeOptions = $this->getGradePairs($storyType, 'enable', $appendList);
  4869. }
  4870. else
  4871. {
  4872. $gradeOptions = $this->dao->select('grade, name')->from(TABLE_STORYGRADE)
  4873. ->where('type')->eq($storyType)
  4874. ->andWhere('grade')->gt($story->grade)
  4875. ->andWhere('status')->eq('enable')
  4876. ->beginIF($appendList)
  4877. ->orWhere('(grade')->in($appendList)
  4878. ->andWhere('type')->eq($storyType)
  4879. ->markRight(1)
  4880. ->fi()
  4881. ->orderBy('grade_asc')
  4882. ->fetchPairs();
  4883. }
  4884. return $gradeOptions;
  4885. }
  4886. /**
  4887. * 按照类型分组,获取需求最大层级。
  4888. * Get max grade group by story type.
  4889. *
  4890. * @param string $status all|enable
  4891. * @access public
  4892. * @return array
  4893. */
  4894. public function getMaxGradeGroup($status = 'enable')
  4895. {
  4896. return $this->dao->select('type, max(grade) as maxGrade')->from(TABLE_STORYGRADE)
  4897. ->where('1 = 1')
  4898. ->beginIF($status != 'all')->andWhere('status')->eq($status)->fi()
  4899. ->groupBy('type')
  4900. ->fetchPairs();
  4901. }
  4902. /**
  4903. * 获取需求列表默认展示的层级。
  4904. * Get the default show grades.
  4905. *
  4906. * @param array $gradeMenu
  4907. * @access public
  4908. * @return string
  4909. */
  4910. public function getDefaultShowGrades($gradeMenu)
  4911. {
  4912. $showGrades = '';
  4913. foreach($gradeMenu as $menu)
  4914. {
  4915. foreach($menu['items'] as $item)
  4916. {
  4917. $showGrades .= $item['value'] . ',';
  4918. }
  4919. }
  4920. return $showGrades;
  4921. }
  4922. /**
  4923. * 检查需求层级是否合法。
  4924. * Check if the grade is valid.
  4925. *
  4926. * @param object $story
  4927. * @param object $oldStory
  4928. * @param string $mode single|batch
  4929. * @access public
  4930. * @return bool
  4931. */
  4932. public function checkGrade($story, $oldStory, $mode = 'single')
  4933. {
  4934. $children = $this->getAllChildId($oldStory->id);
  4935. $maxGrade = $this->dao->select('max(grade) as maxGrade')->from(TABLE_STORY)
  4936. ->where('id')->in($children)
  4937. ->andWhere('deleted')->eq('0')
  4938. ->fetch('maxGrade');
  4939. $systemMaxGrade = $this->dao->select('max(grade) as maxGrade')->from(TABLE_STORYGRADE)
  4940. ->where('type')->eq($oldStory->type)
  4941. ->andWhere('status')->eq('enable')
  4942. ->fetch('maxGrade');
  4943. $newMaxGrade = (int)$maxGrade + (int)$story->grade - (int)$oldStory->grade;
  4944. if($newMaxGrade > $systemMaxGrade)
  4945. {
  4946. if($mode == 'batch') return false;
  4947. dao::$errors['grade'] = sprintf($this->lang->story->gradeOverflow, $systemMaxGrade, $newMaxGrade);
  4948. }
  4949. return !dao::isError();
  4950. }
  4951. /**
  4952. * 父需求层级变更时,同步子需求层级。
  4953. * Sync child story grade when parent story grade changed.
  4954. *
  4955. * @param object $oldStory
  4956. * @param object $story
  4957. * @access public
  4958. * @return array
  4959. */
  4960. public function syncGrade($oldStory, $story)
  4961. {
  4962. if($oldStory->isParent != '1') return;
  4963. $childIdList = $this->getAllChildId($oldStory->id, false);
  4964. if($childIdList)
  4965. {
  4966. $this->loadModel('action');
  4967. $children = $this->getByList($childIdList);
  4968. foreach($children as $child)
  4969. {
  4970. if($child->type != $oldStory->type) continue;
  4971. $grade = (int)$child->grade + (int)$story->grade - (int)$oldStory->grade;
  4972. $this->dao->update(TABLE_STORY)->set('grade')->eq($grade)->where('id')->eq($child->id)->exec();
  4973. $this->action->create('story', $child->id, 'syncGrade', '', $grade);
  4974. }
  4975. }
  4976. }
  4977. /**
  4978. * 更新需求关联的代码提交记录。
  4979. * Update the commit logs linked with the stories.
  4980. *
  4981. * @param int $storyID
  4982. * @param int $repoID
  4983. * @param array $revisions
  4984. * @access protected
  4985. * @return bool
  4986. */
  4987. public function updateLinkedCommits($storyID, $repoID, $revisions)
  4988. {
  4989. if(!$storyID || !$repoID || empty($revisions)) return true;
  4990. $story = $this->dao->select('product')->from(TABLE_STORY)->where('id')->eq($storyID)->fetch();
  4991. if(!$story) return true;
  4992. foreach($revisions as $revision)
  4993. {
  4994. $data = new stdclass();
  4995. $data->product = $story->product;
  4996. $data->AType = 'story';
  4997. $data->AID = $storyID;
  4998. $data->BType = 'commit';
  4999. $data->BID = $revision;
  5000. $data->relation = 'completedin';
  5001. $data->extra = $repoID;
  5002. $this->dao->replace(TABLE_RELATION)->data($data)->autoCheck()->exec();
  5003. $data->AType = 'commit';
  5004. $data->AID = $revision;
  5005. $data->BType = 'story';
  5006. $data->BID = $storyID;
  5007. $data->relation = 'completedfrom';
  5008. $this->dao->replace(TABLE_RELATION)->data($data)->autoCheck()->exec();
  5009. }
  5010. return !dao::isError();
  5011. }
  5012. /**
  5013. * 获取需求关联的提交数据。
  5014. * Get the commit data for the associated stories
  5015. * @param int $storyID
  5016. * @param int $repoID
  5017. * @param array $revisions
  5018. * @access protected
  5019. * @return bool
  5020. */
  5021. public function getLinkedCommits($repoID, $revisions)
  5022. {
  5023. return $this->dao->select('t1.revision,t3.id AS id,t3.title AS title')
  5024. ->from(TABLE_REPOHISTORY)->alias('t1')
  5025. ->leftJoin(TABLE_RELATION)->alias('t2')->on("t2.relation='completedin' AND t2.BType='commit' AND t2.BID=t1.id")
  5026. ->leftJoin(TABLE_STORY)->alias('t3')->on("t2.AType='story' AND t2.AID=t3.id")
  5027. ->where('t1.revision')->in($revisions)
  5028. ->andWhere('t1.repo')->eq($repoID)
  5029. ->andWhere('t3.id')->notNULL()
  5030. ->fetchGroup('revision', 'id');
  5031. }
  5032. }