<?php
/**
 * The model file of pi module of ZenTaoPMS.
 * @copyright   Copyright 2009-2023 禅道软件（青岛）有限公司(ZenTao Software (Qingdao) Co., Ltd. www.zentao.net)
 * @license     ZPL(https://zpl.pub/page/zplv12.html) or AGPL(https://www.gnu.org/licenses/agpl-3.0.en.html)
 * @author      Yuting Wang <wangyuting@easycorp.ltd>
 * @package     pi
 * @link        https://www.zentao.net
 */
class piModel extends model
{
    /**
     * 获取单条PI信息。
     * Get PI by id.
     *
     * @param  int    $id
     * @access public
     * @return object|false
     */
    public function getByID($id)
    {
        return $this->fetchByID($id);
    }

    /**
     * 获取PI名称列表。
     * Get PI pairs.
     *
     * @param  string      $type
     * @param  string      $status
     * @access public
     * @return array|false
     */
    public function getPairs($status = '')
    {
        return $this->dao->select('id,name')->from(TABLE_PI)
            ->where('deleted')->eq('0')
            ->beginIF($status)->andWhere('status')->eq($status)->fi()
            ->fetchPairs();
    }

    /**
     * 获取PI迭代规划。
     * Get PI plan by id.
     *
     * @param  int         $id
     * @access public
     * @return array|false
     */
    public function getPIPlan($id)
    {
        return $this->dao->select('*')->from(TABLE_PIEXECUTION)
            ->where('pi')->eq($id)
            ->orderBy('`order`')
            ->fetchAll();
    }

    /**
     * 获取团队下还未创建迭代的看板列。
     * Get execution columns by team.
     *
     * @param  object $PI
     * @access public
     * @return array
     */
    public function getTeamExecutions($PI): array
    {
        return $this->dao->select('t3.team, t4.id, t4.name, t4.begin, t4.end')->from(TABLE_KANBANCELL)->alias('t1')
            ->leftJoin(TABLE_KANBANCOLUMN)->alias('t2')->on('t1.column = t2.id')
            ->leftJoin(TABLE_KANBANLANE)->alias('t3')->on('t1.lane = t3.id')
            ->leftJoin(TABLE_PIEXECUTION)->alias('t4')->on('t4.id = t2.piexecution')
            ->where('t1.kanban')->eq($PI->teamkanban)
            ->andWhere('t3.team')->in($PI->team)
            ->andWhere('t2.execution')->eq(0)
            ->andWhere('t2.piexecution')->ne(0)
            ->fetchGroup('team', 'id');
    }

    /**
     * 获取PI关联的所有ART。
     * Get ART pairs.
     *
     * @param  string $status
     * @access public
     * @return array
     */
    public function getArtPairs($status = ''): array
    {
        return $this->dao->select('art')->from(TABLE_PI)
            ->where('deleted')->eq('0')
            ->beginIF($status)->andWhere('status')->eq($status)->fi()
            ->fetchPairs();
    }

    /**
     * 获取ART关联的所有PI。
     * Get PI list by ART.
     *
     * @param  int    $artID
     * @param  string $status
     * @access public
     * @return array
     */
    public function getByART($artID, $status = 'noclosed'): array
    {
        $piList = $this->dao->select('*')->from(TABLE_PI)
            ->where('deleted')->eq('0')
            ->andWhere('art')->eq($artID)
            ->beginIF($status == 'noclosed')->andWhere('status')->ne('closed')->fi()
            ->fetchAll('id');

        $this->loadModel('team');
        foreach($piList as $PI)
        {
            $PI->teams = $this->team->getByIdList($PI->team);
            foreach($PI->teams as $teamID => $team)
            {
                $columns = $this->dao->select('t1.column')->from(TABLE_KANBANCELL)->alias('t1')
                    ->leftJoin(TABLE_KANBANLANE)->alias('t2')->on('t1.lane = t2.id')
                    ->where('t1.kanban')->eq($PI->teamkanban)
                    ->andWhere('t2.team')->eq($teamID)
                    ->fetchPairs();

                $PI->executions[$teamID] = $this->dao->select('t1.name, t1.begin, t1.end, t3.id, t3.name as executionName, t3.status, t3.estimate, t3.consumed, t3.left, t3.progress, t3.deleted')
                    ->from(TABLE_PIEXECUTION)->alias('t1')
                    ->leftJoin(TABLE_KANBANCOLUMN)->alias('t2')->on('t1.id = t2.piexecution')
                    ->leftJoin(TABLE_EXECUTION)->alias('t3')->on('t2.execution = t3.id')
                    ->where('t1.pi')->eq($PI->id)
                    ->andWhere('t2.id')->in($columns)
                    ->fetchAll();
            }
        }

        return $piList;
    }

    /**
     * 按照ART分组展示PI列表。
     * Group PI by ART.
     *
     * @access public
     * @return array|false
     */
    public function groupByART()
    {
        $conditions = '1=1';
        if(!$this->app->user->admin) $conditions = $this->getPermissionsCondition();

        return $this->dao->select('PI.*, ART.PO,ART.RTE,ART.architect')->from(TABLE_PI)->alias('PI')
            ->leftjoin(TABLE_ART)->alias('ART')->on('PI.ART = ART.id')
            ->where('PI.deleted')->eq('0')
            ->beginIF(!$this->app->user->admin)->andWhere($conditions)->fi()
            ->fetchGroup('ART');
    }

    /**
     * 获取PI列表。
     * Get PI list.
     *
     * @param  string     $browseType
     * @param  string     $orderBy
     * @param  object     $pager
     * @access public
     * @return array|false
     */
    public function getList($browseType = 'all', $orderBy = 'id_desc', $pager = null)
    {
        $conditions = '1=1';
        if(!$this->app->user->admin) $conditions = $this->getPermissionsCondition();

        return $this->dao->select('PI.*, ART.PO, ART.RTE, ART.architect')->from(TABLE_PI)->alias('PI')
            ->leftjoin(TABLE_ART)->alias('ART')->on('PI.ART = ART.id')
            ->where('PI.deleted')->eq('0')
            ->beginIF($browseType != 'all')->andWhere('PI.status')->eq($browseType)->fi()
            ->beginIF(!$this->app->user->admin)->andWhere($conditions)->fi()
            ->orderBy($orderBy)
            ->page($pager)
            ->fetchAll('id');
    }

    /**
     * 获取可关联的用户需求列表。
     * Get requirements.
     *
     * @param  string $productIdList
     * @param  array  $linkedIdList
     * @param  string $browseType
     * @param  int    $queryID
     * @param  string $orderBy
     * @param  object $pager
     * @access public
     * @return array
     */
    public function getRequirements($productIdList, $linkedIdList = array(), $browseType = '', $queryID = 0, $orderBy = 'id_desc', $pager = null): array
    {
        $pistoryQuery = '';
        if($browseType == 'bysearch')
        {
            $query = $queryID ? $this->loadModel('search')->getQuery($queryID) : '';
            if($query)
            {
                $this->session->set('pistoryQuery', $query->sql);
                $this->session->set('pistoryForm', $query->form);
            }

            if($this->session->pistoryQuery == false) $this->session->set('pistoryQuery', ' 1 = 1');
            $pistoryQuery = $this->session->pistoryQuery;
        }

        return $this->dao->select('*')->from(TABLE_STORY)
            ->where('deleted')->eq('0')
            ->andWhere('product')->in($productIdList)
            ->andWhere('type')->eq('requirement')
            ->andWhere('status')->ne('closed')
            ->beginIF($browseType == 'bysearch')->andWhere($pistoryQuery)->fi()
            ->beginIF($linkedIdList)->andWhere('id')->notin(array_keys($linkedIdList))->fi()
            ->orderBy($orderBy)
            ->page($pager)
            ->fetchAll('id');
    }

    /**
     * 获取规划板可关联的用户需求列表。
     * Get requirements for plan.
     *
     * @param  int    $piID
     * @param  int    $productID
     * @param  int    $columnID
     * @param  int    $toLane
     * @param  string $orderBy
     * @param  object $pager
     * @access public
     * @return array
     */
    public function getRequirementsForPlan($piID, $productID, $columnID, $toLane, $orderBy, $pager): array
    {
        $pi = $this->fetchByID($piID);
        $linkedRequirements = $this->getPlanLinkedStories($pi->plankanban, $columnID, $toLane);

        return $this->dao->select('t1.*')->from(TABLE_STORY)->alias('t1')
            ->leftJoin(TABLE_PISTORY)->alias('t2')->on('t1.id = t2.story')
            ->where('t1.deleted')->eq('0')
            ->andWhere('t1.product')->eq($productID)
            ->andWhere('t1.type')->eq('requirement')
            ->andWhere('t1.status')->ne('closed')
            ->andWhere('t2.pi')->eq($piID)
            ->andWhere('t1.id')->notin($linkedRequirements)
            ->orderBy($orderBy)
            ->page($pager)
            ->fetchAll('id');
    }

    /**
     * 获取已经关联的用户需求列表。
     * Get linked requirements.
     *
     * @param  int    $id
     * @param  string $browseType
     * @param  string $orderBy
     * @param  object $pager
     * @access public
     * @return array
     */
    public function getLinkedRequirements($id, $browseType = 'all', $orderBy = 'order_asc', $pager = null): array
    {
        return $this->dao->select('t1.*, t2.pi')->from(TABLE_STORY)->alias('t1')
            ->leftJoin(TABLE_PISTORY)->alias('t2')->on('t1.id=t2.story')
            ->where('t1.deleted')->eq('0')
            ->andWhere('t2.pi')->eq($id)
            ->beginIF($browseType != 'all')->andWhere('t1.status')->eq($browseType)->fi()
            ->orderBy($orderBy)
            ->page($pager)
            ->fetchAll('id');
    }

    /**
     * 获取已经关联的用户需求ID列表。
     * Get linked requirement id list.
     *
     * @param  int    $id
     * @access public
     * @return array
     */
    public function getLinkedRequirementPairs($id): array
    {
        return $this->dao->select('t1.story, t2.title')->from(TABLE_PISTORY)->alias('t1')
            ->leftJoin(TABLE_STORY)->alias('t2')->on('t1.story = t2.id')
            ->where('t1.pi')->eq($id)
            ->andWhere('t2.deleted')->eq('0')
            ->fetchPairs();
    }

    /**
     * 获取可关联的研发需求列表。
     * Get requirements.
     *
     * @param  string $productIdList
     * @param  array  $linkedRequirements
     * @param  array  $linkedStories
     * @param  string $browseType
     * @param  int    $queryID
     * @param  string $orderBy
     * @param  object $pager
     * @access public
     * @return array
     */
    public function getStories($productIdList, $linkedRequirements = array(), $linkedStories = array(), $browseType = '', $queryID = 0, $orderBy = 'id_desc', $pager = null): array
    {
        $pistoryQuery = '';
        if($browseType == 'bysearch')
        {
            $query = $queryID ? $this->loadModel('search')->getQuery($queryID) : '';
            if($query)
            {
                $this->session->set('pistoryQuery', $query->sql);
                $this->session->set('pistoryForm', $query->form);
            }

            if($this->session->pistoryQuery == false) $this->session->set('pistoryQuery', ' 1 = 1');
            $pistoryQuery = $this->session->pistoryQuery;

            if(strpos($pistoryQuery, '`product` = ') !== false) $pistoryQuery = str_replace('`product`', 't1.`product`', $pistoryQuery);
        }

        return $this->dao->select('t1.*')->from(TABLE_STORY)->alias('t1')
            ->leftJoin(TABLE_RELATION)->alias('t2')->on('t1.id=t2.BID')
            ->where('t1.deleted')->eq('0')
            ->andWhere('t1.product')->in($productIdList)
            ->andWhere('t1.type')->eq('story')
            ->andWhere('t1.status')->ne('closed')
            ->beginIF($browseType == 'bysearch')->andWhere($pistoryQuery)->fi()
            ->beginIF($browseType != 'bysearch')
            ->andWhere('t2.Btype')->eq('story')
            ->andWhere('t2.Atype')->eq('requirement')
            ->andWhere('t2.AID')->in(array_keys($linkedRequirements))
            ->fi()
            ->beginIF($linkedStories)->andWhere('t1.id')->notin($linkedStories)->fi()
            ->orderBy($orderBy)
            ->page($pager)
            ->fetchAll('id');
    }

    /**
     * 根据软件需求获取用户需求.
     * Get requirements by story.
     *
     * @param  array        $storyIdList
     * @access public
     * @return array|false
     */
    public function getLinkedRequirementByStory($storyIdList)
    {
        return $this->dao->select('t1.story,t1.title,t1.spec, t2.BID')->from(TABLE_STORYSPEC)->alias('t1')
            ->leftJoin(TABLE_RELATION)->alias('t2')->on('t1.story=t2.AID')
            ->where('t2.Atype')->eq('requirement')
            ->andWhere('t2.Btype')->eq('story')
            ->andWhere('t2.BID')->in($storyIdList)
            ->orderBy('t1.version asc')
            ->fetchAll('BID');
    }

    /**
     * 获取已经关联的研发需求ID列表。
     * Get linked requirement id list.
     *
     * @param  int    $columnID
     * @access public
     * @return array
     */
    public function getLinkedStoryIDList($columnID): array
    {
        $cardList = $this->dao->select('cards')->from(TABLE_KANBANCELL)->where('column')->eq($columnID)->fetchAll();

        $cards = array();
        foreach($cardList as $card)
        {
            if(!$card->cards) continue;
            $cards = array_merge($cards, explode(',', trim($card->cards, ',')));
        }

        return $this->dao->select('fromID')->from(TABLE_KANBANCARD)->where('id')->in($cards)->andWhere('fromType')->eq('story')->fetchPairs();
    }

    /**
     * 获取看板的所有泳道。
     * Get lanes by ID.
     *
     * @param  int    $kanbanID
     * @param  string $type
     * @access public
     * @return array
     */
    public function getLanesByKanban($kanbanID = 0, $type = 'team'): array
    {
        return $this->dao->select('t1.id, t1.name')->from(TABLE_KANBANLANE)->alias('t1')
            ->leftJoin(TABLE_KANBANGROUP)->alias('t2')->on('t1.group = t2.id')
            ->where('t2.kanban')->eq($kanbanID)
            ->andWhere('t1.type')->in($type)
            ->andWhere('t1.deleted')->eq('0')
            ->orderBy('t1.id asc')
            ->fetchPairs('id');
    }

    /**
     * 获取规划看板对应泳道、列已关联的需求列表。
     * Get linked story id list for plan kanban.
     *
     * @param  int    $kanbanID
     * @param  int    $columnID
     * @param  int    $laneID
     * @access public
     * @return array
     */
    public function getPlanLinkedStories($kanbanID = 0, $columnID = 0, $laneID = 0): array
    {
        $cardList = $this->dao->select('cards')->from(TABLE_KANBANCELL)
             ->where('column')->eq($columnID)
             ->andWhere('lane')->eq($laneID)
             ->andWhere('kanban')->eq($kanbanID)
             ->fetchAll();

        $cards = array();
        foreach($cardList as $card)
        {
            if(!$card->cards) continue;
            $cards = array_merge($cards, explode(',', trim($card->cards, ',')));
        }

        return $this->dao->select('fromID')->from(TABLE_KANBANCARD)->where('id')->in($cards)->andWhere('fromType')->eq('story')->fetchPairs();
    }

    /**
     * 获取规划看板可关联的研发需求列表。
     * Get linkable stories for plan kanban.
     *
     * @param  int    $teamKanbanID
     * @param  int    $fromLane
     * @param  array  $linkedStories
     * @param  string $browseType
     * @param  int    $queryID
     * @param  string $orderBy
     * @param  object $pager
     * @access public
     * @return array
     */
    public function getStoriesForPlan($teamKanbanID, $fromLane = 0, $linkedStories = array(), $browseType = '', $queryID = 0, $orderBy = 'id_desc', $pager = null): array
    {
        $pistoryQuery = '';
        if($browseType == 'bysearch')
        {
            $query = $queryID ? $this->loadModel('search')->getQuery($queryID) : '';
            if($query)
            {
                $this->session->set('pistoryQuery', $query->sql);
                $this->session->set('pistoryForm', $query->form);
            }

            if($this->session->pistoryQuery == false) $this->session->set('pistoryQuery', ' 1 = 1');
            $pistoryQuery = $this->session->pistoryQuery;
        }

        /* 获取来源需求ID列表。*/
        $cardList = $this->dao->select('cards')->from(TABLE_KANBANCELL)
           ->where('kanban')->eq($teamKanbanID)
           ->andWhere('type')->eq('story')
           ->beginIF($fromLane)->andWhere('lane')->eq($fromLane)->fi()
           ->fetchAll();

        $sourceList = array();
        foreach($cardList as $card)
        {
            if(!$card->cards) continue;
            $sourceList = array_merge($sourceList, explode(',', trim($card->cards, ',')));
        }

        $storyIdList = $this->dao->select('fromID')->from(TABLE_KANBANCARD)->where('id')->in($sourceList)->andWhere('fromType')->eq('story')->fetchPairs();

        $stories = $this->dao->select('*')->from(TABLE_STORY)
            ->where('deleted')->eq('0')
            ->andWhere('type')->eq('story')
            ->andWhere('status')->ne('closed')
            ->andWhere('id')->in($storyIdList)
            ->beginIF($browseType == 'bysearch')->andWhere($pistoryQuery)->fi()
            ->beginIF($linkedStories)->andWhere('id')->notin($linkedStories)->fi()
            ->orderBy($orderBy)
            ->page($pager)
            ->fetchAll('id');

        return $stories;
    }

    /**
     * 获取PI下的迭代列表。
     * Get PI executions.
     *
     * @param  int    $id
     * @access public
     * @return array|false
     */
    public function getPIExecutions($id)
    {
        $PI = $this->fetchByID($id);
        if(!$PI) return false;

        return $this->dao->select('t2.execution')->from(TABLE_KANBANGROUP)->alias('t1')
            ->leftjoin(TABLE_KANBANCOLUMN)->alias('t2')->on('t1.id=t2.group')
            ->where('t1.kanban')->eq($PI->teamkanban)
            ->andWhere('t2.execution')->ne('0')
            ->fetchPairs();
    }

    /**
     * 获取PI看板当前泳道的所属团队。
     * Get kanban teamID.
     *
     * @param  int    $laneID
     * @param  int    $columnID
     * @access public
     * @return int
     */
    public function getTeamByKanban($laneID = 0, $columnID = 0): int
    {
        return $this->dao->select('team')->from(TABLE_KANBANLANE)->alias('t1')
            ->leftJoin(TABLE_KANBANCELL)->alias('t2')->on('t1.id=t2.lane')
            ->where('1=1')
            ->beginIF($laneID)->andWhere('t1.id')->eq($laneID)->fi()
            ->beginIF($columnID)->andWhere('t2.column')->eq($columnID)->fi()
            ->fetch('team');
    }

    /**
     * 获取检查用户查看范围的SQL语句。
     * Get permissions condition.
     *
     * @access public
     * @return string
     */
    public function getPermissionsCondition()
    {
        $myTeams = $this->loadModel('team')->getMyTeams();

        $privateConditions = "(PI.createdBy = '{$this->app->user->account}' OR ";
        if($myTeams)
        {
            foreach($myTeams as $team => $name) $privateConditions .= "FIND_IN_SET($team, PI.`team`) OR ";
        }
        $privateConditions  = trim($privateConditions, 'OR ');
        $privateConditions .= " OR FIND_IN_SET('{$this->app->user->account}', ART.manager)";
        $privateConditions .= " OR FIND_IN_SET('{$this->app->user->account}', ART.PO)";
        $privateConditions .= " OR FIND_IN_SET('{$this->app->user->account}', ART.architect)";
        $privateConditions .= " OR FIND_IN_SET('{$this->app->user->account}', ART.whitelist)";
        $privateConditions .= " OR FIND_IN_SET('{$this->app->user->account}', PI.whitelist)";
        $privateConditions .= " OR ART.RTE = '{$this->app->user->account}'";
        $privateConditions .= ')';

        $extendConditions  = "(ART.createdBy = '{$this->app->user->account}' OR ";
        if($myTeams)
        {
            foreach($myTeams as $team => $name) $extendConditions .= "FIND_IN_SET($team, ART.`team`) OR ";
        }
        $extendConditions  = trim($extendConditions, 'OR ');
        $extendConditions .= " OR FIND_IN_SET('{$this->app->user->account}', ART.manager)";
        $extendConditions .= " OR FIND_IN_SET('{$this->app->user->account}', ART.PO)";
        $extendConditions .= " OR FIND_IN_SET('{$this->app->user->account}', ART.architect)";
        $extendConditions .= " OR FIND_IN_SET('{$this->app->user->account}', ART.whitelist)";
        $extendConditions .= " OR ART.RTE = '{$this->app->user->account}'";
        $extendConditions .= ')';
        $extendConditions = "IF(ART.acl='open', 1=1, $extendConditions)";

        return "IF(PI.acl='extends', $extendConditions, $privateConditions)";
    }

    /**
     * 创建一个PI。
     * Create PI.
     *
     * @param  object $PI
     * @access public
     * @return int|false
     */
    public function create($PI)
    {
        $this->dao->insert(TABLE_PI)->data($PI, 'begin,end,plan,execution,executions')
            ->autoCheck()
            ->batchCheck($this->config->pi->create->requiredFields, 'notempty')
            ->check('name', 'unique')
            ->exec();

        if(dao::isError()) return false;

        /* 保存历史记录。 */
        $PI->id = $this->dao->lastInsertID();
        $this->loadModel('action')->create('pi', $PI->id, 'Opened');

        foreach($PI->executions as $execution)
        {
            $execution->pi = $PI->id;
            $this->dao->insert(TABLE_PIEXECUTION)->data($execution, 'id')->exec();
            $execution->id = $this->dao->lastInsertID();
        }

        $teamKanbanID = $this->initTeamKanban($PI);
        $planKanbanID = $this->initPlanKanban($PI);

        $this->dao->update(TABLE_PI)->set('teamkanban')->eq($teamKanbanID)->set('plankanban')->eq($planKanbanID)->where('id')->eq($PI->id)->exec();

        return $PI->id;
    }

    /**
     * 创建卡片。
     * Create card.
     *
     * @param  int    $piID
     * @param  int    $columnID
     * @param  object $card
     * @access public
     * @return bool
     */
    public function createCard($piID, $columnID, $card): bool
    {
        if(!$card->lane)
        {
            dao::$errors['lane'] = $this->lang->pi->notice->laneNotEmpty;
            return false;
        }

        if($card->progress < 0 or $card->progress > 100)
        {
            dao::$errors['progress'] = $this->lang->pi->notice->progressError;
            return false;
        }

        if($card->end < $card->begin)
        {
            dao::$errors['end'] = $this->lang->pi->notice->endNotLessThanBegin;
            return false;
        }

        $card->status = $card->progress > 0 ? 'doing' : 'wait';

        $PI    = $this->fetchByID($piID);
        $group = $this->dao->select('*')->from(TABLE_KANBANGROUP)->where('kanban')->eq($PI->plankanban)->fetch();

        $card->kanban = $PI->plankanban;
        $card->region = $group->region;
        $card->group  = $group->id;

        $this->dao->insert(TABLE_KANBANCARD)
             ->data($card, 'lane')
             ->batchCheck('name', 'notempty')
             ->exec();

        if(dao::isError()) return false;
        $cardID = $this->dao->lastInsertID();

        $this->loadModel('action')->create('picard', $cardID, 'created');
        $this->updateCards($PI->plankanban, $card->lane, $columnID, '', array($cardID));

        return true;
    }

    /**
     * 编辑卡片。
     * Update card.
     *
     * @param  int    $cardID
     * @param  object $card
     * @access public
     * @return bool
     */
    public function updateCard($cardID, $card): bool
    {
        if($card->end < $card->begin)
        {
            dao::$errors['end'] = $this->lang->pi->notice->endNotLessThanBegin;
            return false;
        }

        if($card->progress < 0 or $card->progress > 100)
        {
            dao::$errors['progress'] = $this->lang->pi->notice->progressError;
            return false;
        }

        $oldCard = $this->loadModel('kanban')->getCardByID($cardID);
        if($oldCard->status == 'wait' && $card->progress > 0) $card->status = 'doing';

        $this->dao->update(TABLE_KANBANCARD)
             ->data($card, 'lane')
             ->batchCheck('name', 'notempty')
             ->where('id')->eq($cardID)
             ->exec();

        if(dao::isError()) return false;

        $changes = common::createChanges($oldCard, $card);
        if($changes)
        {
            $actionID = $this->loadModel('action')->create('picard', $cardID, 'edited');
            $this->action->logHistory($actionID, $changes);
        }

        return true;
    }

    /**
     * 创建里程碑卡片。
     * Create milestone card.
     *
     * @param  int    $piID
     * @param  int    $columnID
     * @param  object $card
     * @access public
     * @return bool
     */
    public function createMilestone($piID, $columnID, $card): bool
    {
        $PI    = $this->fetchByID($piID);
        $group = $this->dao->select('*')->from(TABLE_KANBANGROUP)->where('kanban')->eq($PI->plankanban)->fetch();

        $card->kanban = $PI->plankanban;
        $card->region = $group->region;
        $card->group  = $group->id;

        /* For dao::error lang. */
        $this->app->loadLang('kanban');
        $this->lang->kanbancard->name = $this->lang->pi->milestone->name;
        $this->lang->kanbancard->end  = $this->lang->pi->milestone->end;

        $this->dao->insert(TABLE_KANBANCARD)
             ->data($card)
             ->batchCheck('name,end', 'notempty')
             ->exec();

        if(dao::isError()) return false;
        $cardID = $this->dao->lastInsertID();
        $this->loadModel('action')->create('picard', $cardID, 'created');

        $this->updateCards($PI->plankanban, 0, $columnID, 'milestone', array($cardID));

        return true;
    }

    /**
     * 编辑里程碑卡片。
     * Edit milestone card.
     *
     * @param  int    $cardID
     * @param  object $card
     * @access public
     * @return bool
     */
    public function editMilestone($cardID, $card): bool
    {
        /* For dao::error lang. */
        $oldCard = $this->loadModel('kanban')->getCardByID($cardID);
        $this->app->loadLang('kanban');
        $this->lang->kanbancard->name = $this->lang->pi->milestone->name;
        $this->lang->kanbancard->end  = $this->lang->pi->milestone->end;

        $this->dao->update(TABLE_KANBANCARD)
             ->data($card)
             ->where('id')->eq($cardID)
             ->autoCheck()
             ->batchCheck('name,end', 'notempty')
             ->exec();

        $changes = common::createChanges($oldCard, $card);
        if($changes)
        {
            $actionID = $this->loadModel('action')->create('picard', $cardID, 'edited');
            $this->action->logHistory($actionID, $changes);
        }

        return !dao::isError();
    }

    /**
     * 规划板创建泳道。
     * Create lane for plan kanban.
     *
     * @param  int       $kanbanID
     * @param  object    $lane
     * @access public
     * @return int|false
     */
    public function createLane($kanbanID, $lane)
    {
        $this->app->loadLang('kanban');

        $group    = $this->dao->select('*')->from(TABLE_KANBANGROUP)->where('kanban')->eq($kanbanID)->fetch();
        $maxOrder = $this->dao->select('max(`order`) as `order`')->from(TABLE_KANBANLANE)->where('`group`')->eq($group->id)->fetch('order');

        $lane->type   = 'common';
        $lane->region = $group->region;
        $lane->group  = $group->id;
        $lane->order  = $maxOrder + 1;

        $this->dao->insert(TABLE_KANBANLANE)
             ->data($lane)
             ->autoCheck()
             ->batchCheck('name', 'notempty')
             ->exec();

        if(dao::isError()) return false;

        $laneID  = $this->dao->lastInsertID();
        $columns = $this->dao->select('id')->from(TABLE_KANBANCOLUMN)->where('`group`')->eq($group->id)->fetchPairs();
        foreach($columns as $columnID)
        {
            $cell = new stdclass();
            $cell->kanban = $kanbanID;
            $cell->lane   = $laneID;
            $cell->column = $columnID;
            $cell->type   = 'common';
            $cell->cards  = '';

            $this->dao->insert(TABLE_KANBANCELL)->data($cell)->exec();
        }
        if(dao::isError()) return false;

        $this->loadModel('action')->create('kanbanlane', $laneID, 'created');
        return $laneID;
    }

    /**
     * 规划板编辑泳道。
     * Update lane for plan kanban.
     *
     * @param  int       $laneID
     * @param  object    $lane
     * @access public
     * @return int|false
     */
    public function updateLane($laneID, $lane)
    {
        $oldLane = $this->fetchByID($laneID, 'kanbanlane');

        $this->app->loadLang('kanban');
        $this->dao->update(TABLE_KANBANLANE)
            ->data($lane)
            ->autoCheck()
            ->batchCheck('name', 'notempty')
            ->where('id')->eq($laneID)
            ->exec();

        $changes = common::createChanges($oldLane, $lane);
        if($changes)
        {
            $actionID = $this->loadModel('action')->create('kanbanlane', $laneID, 'edited');
            $this->action->logHistory($actionID, $changes);
        }

        return !dao::isError();
    }

    /**
     * 设置PI的看板。
     * Set PI kanban.
     *
     * @param  object    $kanbanID
     * @access public
     * @return int|false
     */
    public function setKanban($formData)
    {
        $oldKanban = $this->fetchByID($formData->id, 'kanban');
        if(!$formData->heightType) $formData->displayCards = 0;

        $this->dao->update(TABLE_KANBAN)->data($formData, 'heightType')->where('id')->eq($formData->id)->exec();

        $changes = common::createChanges($oldKanban, $formData);
        if($changes)
        {
            $actionID = $this->loadModel('action')->create('kanban', $formData->id, 'edited');
            $this->action->logHistory($actionID, $changes);
        }
        return !dao::isError();
    }

    /**
     * 构造需求搜索表单。
     * Build requirement search form.
     *
     * @param  int    $queryID
     * @param  string $actionURL
     * @access public
     * @return void
     */
    public function buildRequirementSearchForm($queryID, $actionURL)
    {
        $this->app->loadLang('story');
        $this->app->loadLang('product');
        $this->app->loadConfig('product');

        $this->config->product->search['module']    = 'pistory';
        $this->config->product->search['queryID']   = $queryID;
        $this->config->product->search['actionURL'] = $actionURL;

        unset($this->config->product->search['fields']['product']);
        unset($this->config->product->search['fields']['plan']);
        unset($this->config->product->search['fields']['module']);
        unset($this->config->product->search['fields']['branch']);
        unset($this->config->product->search['fields']['stage']);

        unset($this->config->product->search['params']['product']);
        unset($this->config->product->search['params']['plan']);
        unset($this->config->product->search['params']['module']);
        unset($this->config->product->search['params']['branch']);
        unset($this->config->product->search['params']['stage']);

        $this->config->product->search['fields']['title'] = str_replace($this->lang->SRCommon, $this->lang->URCommon, $this->lang->story->title);

        $this->loadModel('search')->setSearchParams($this->config->product->search);
    }

    /**
     * PI关联用户需求。
     * Link requirements.
     *
     * @param  int    $id
     * @access public
     * @return bool
    */
    public function linkRequirement($id)
    {
        $maxOrder = $this->dao->select('max(`order`) as `order`')->from(TABLE_PISTORY)->where('pi')->eq($id)->fetch('order');
        $maxOrder = $maxOrder ? $maxOrder : 0;
        foreach($this->post->storyIdList as $storyID)
        {
            $data = new stdClass();
            $data->pi    = $id;
            $data->story = $storyID;
            $data->order = $maxOrder + 1;

            $this->dao->replace(TABLE_PISTORY)->data($data)->exec();

            $maxOrder ++;
        }

        $this->loadModel('action')->create('pi', $id, 'linkrequirement', '', implode(',', $this->post->storyIdList));
    }

    /**
     * PI取消关联卡片。
     * Unlink card.
     *
     * @param  int    $piID
     * @param  int    $laneID
     * @param  int    $columnID
     * @param  string $objectType
     * @param  int    $objectID
     * @param  string $from
     * @access public
     * @return bool
     */
    public function unlinkKanbanObjects($piID, $laneID, $columnID, $objectType, $objectID, $from = ''): bool
    {
        $cards = $this->dao->select('cards')->from(TABLE_KANBANCELL)
             ->where('column')->eq($columnID)
             ->beginIF($laneID)->andWhere('lane')->eq($laneID)->fi()
             ->fetch('cards');

        $cards = str_replace(",$objectID,", ',', ",$cards,");
        $cards = trim($cards, ',');

        $this->dao->update(TABLE_KANBANCELL)
             ->set('cards')->eq($cards)
             ->where('column')->eq($columnID)
             ->beginIF($laneID)->andWhere('lane')->eq($laneID)->fi()
             ->exec();

        $this->loadModel('action')->create('pi', $piID, "unlink{$objectType}", '', $objectID);

        $column = $this->fetchByID($columnID, 'kanbancolumn');
        if($column->execution and $from != 'execution')
        {
            $storyID = $this->dao->select('fromID')->from(TABLE_KANBANCARD)->where('id')->eq($objectID)->andWhere('fromType')->eq('story')->fetch('fromID');
            if($storyID) $this->loadModel('execution')->unlinkStory($column->execution , $storyID, 0, 0, 'pi');
        }

        return !dao::isError();
    }

    /**
     * PI看板关联卡片。
     * Link cards.
     *
     * @param  int    $piID
     * @param  int    $laneID
     * @param  int    $columnID
     * @param  string $objectType
     * @param  array  $objectIdList
     * @param  string $from
     * @access public
     * @return bool
    */
    public function linkKanbanObjects($piID, $laneID, $columnID, $objectType, $objectIdList, $from = ''): bool
    {
        /* 需求类型的卡片需要创建一个card和需求对应.*/
        if($objectType == 'story' || $objectType == 'requirement')
        {
            $column = $this->fetchByID($columnID, 'kanbancolumn');
            if($from != 'execution' && $column->execution) $this->loadModel('execution')->linkStory($column->execution, $objectIdList, array(), '', array(), 'story', 'pi');

            $objectIdList = $this->createStoryCard($laneID, $columnID, $objectIdList);
        }

        $cards = $this->dao->select('cards')->from(TABLE_KANBANCELL)
             ->where('`column`')->eq($columnID)
             ->beginIF($laneID)->andWhere('lane')->eq($laneID)->fi()
             ->fetch('cards');

        $cards = trim($cards, ',');
        foreach($objectIdList as $objectID) $cards .= ',' . $objectID;

        $this->dao->update(TABLE_KANBANCELL)->set('cards')->eq($cards)
             ->where('`column`')->eq($columnID)
             ->beginIF($laneID)->andWhere('lane')->eq($laneID)->fi()
             ->exec();

        $this->loadModel('action')->create('pi', $piID, "link$objectType", '', implode(',', $objectIdList));

        return !dao::isError();
    }

    /**
     * 当关联一个需求时，创建一个卡片。
     * Create story card.
     *
     * @param  int    $laneID
     * @param  int    $columnID
     * @param  array  $objectIdList
     * @access public
     * @return array
    */
    public function createStoryCard($laneID, $columnID, $objectIdList): array
    {
        if(!$laneID) $laneID = $this->dao->select('lane')->from(TABLE_KANBANCELL)->where('column')->eq($columnID)->fetch('lane');

        $lane   = $this->dao->select('*')->from(TABLE_KANBANLANE)->where('id')->eq($laneID)->fetch();
        $kanban = $this->dao->select('kanban')->from(TABLE_KANBANCELL)
            ->where('column')->eq($columnID)
            ->andWhere('lane')->eq($laneID)
            ->fetch('kanban');

        $cardIdList = array();
        foreach($objectIdList as $objectID)
        {
            $card = new stdclass();
            $card->kanban   = $kanban;
            $card->region   = $lane->region;
            $card->group    = $lane->group;
            $card->fromID   = $objectID;
            $card->fromType = 'story';

            $this->dao->insert(TABLE_KANBANCARD)->data($card)->exec();

            $cardIdList[] = $this->dao->lastInsertID();
        }

        return $cardIdList;
    }

    /**
     * 看板卡片连线。
     * Link cards.
     * @param  int    $piID
     * @param  string $from
     * @param  string $to
     * @access public
     * @return bool
     * @param object $PI
     */
    public function linkCard($PI, $from, $to): bool
    {
        $link = new stdclass();
        $link->kanban = $PI->plankanban;
        $link->from   = $from;
        $link->to     = $to;
        $this->dao->insert(TABLE_KANBANLINKS)->data($link)->exec();

        return !dao::isError();
    }

    /**
     * 看板卡片取消连线。
     * Unlink cards.
     * @param  int    $piID
     * @param  string $from
     * @param  string $to
     * @access public
     * @return bool
     * @param object $PI
     */
    public function unlinkCard($PI, $from, $to): bool
    {
        $this->dao->delete()->from(TABLE_KANBANLINKS)
            ->where('kanban')->eq($PI->plankanban)
            ->andWhere('`from`')->eq($from)
            ->andWhere('`to`')->eq($to)
            ->exec();

        return !dao::isError();
    }

    /**
     * 获取PI关联的风险。
     * Get PI risks.
     *
     * @param  int    $piID
     * @access public
     * @return string
     */
    public function getRisks($piID): string
    {
        $pi    = $this->fetchByID($piID);
        $cells = $this->dao->select('*')->from(TABLE_KANBANCELL)->where('kanban')->eq($pi->teamkanban)->andWhere('type')->eq('risk')->fetchAll();

        $risks = '';
        foreach($cells as $cell) $risks = trim($risks, ',') . ',' . $cell->cards;

        return $risks;
    }

    /**
     * 更新列和泳道的卡片。
     * Update cards.
     *
     * @param  int    $kanbanID
     * @param  int    $laneID
     * @param  int    $columnID
     * @param  string $cardType
     * @param  array  $cardIdList
     * @access public
     * @return bool
    */
    public function updateCards($kanbanID, $laneID, $columnID, $cardType = '', $cardIdList = array()): bool
    {
        $cards = $this->dao->select('cards')->from(TABLE_KANBANCELL)
             ->where('column')->eq($columnID)
             ->beginIF($laneID)->andWhere('lane')->eq($laneID)->fi()
             ->beginIF($cardType)->andWhere('type')->eq($cardType)->fi()
             ->andWhere('kanban')->eq($kanbanID)
             ->fetch('cards');

        $cards = trim($cards, ',');
        foreach($cardIdList as $cardID) $cards .= ',' . $cardID;

        $this->dao->update(TABLE_KANBANCELL)->set('cards')->eq($cards)
             ->where('column')->eq($columnID)
             ->beginIF($laneID)->andWhere('lane')->eq($laneID)->fi()
             ->beginIF($cardType)->andWhere('type')->eq($cardType)->fi()
             ->andWhere('kanban')->eq($kanbanID)
             ->exec();

        return !dao::isError();
    }

    /**
     * 在团队看板中新增团队。
     * Add team on the team kanban.
     *
     * @param  int    $kanbanID
     * @param  array  $executions
     * @param  string $teams
     * @access public
     * @return bool
    */
    public function addTeamOnTeamKanban($kanbanID, $executions, $teams)
    {
        $order      = $this->dao->select('`order`')->from(TABLE_KANBANREGION)->where('kanban')->eq($kanbanID)->fetch('`order`');
        $colorIndex = 0;
        $teams      = !empty($teams) ? $this->loadModel('team')->getPairs('', '', $teams) : array();
        foreach($teams as $teamID => $teamName)
        {
            $order = (int)$order + 5;
            $kanbanRegion = new stdclass();
            $kanbanRegion->kanban      = $kanbanID;
            $kanbanRegion->name        = $teamName;
            $kanbanRegion->order       = $order;
            $kanbanRegion->createdBy   = $this->app->user->account;
            $kanbanRegion->createdDate = $now;
            $this->dao->insert(TABLE_KANBANREGION)->data($kanbanRegion)->autoCheck()->exec();
            $regionID = $this->dao->lastInsertID();

            $kanbanGroup = new stdclass();
            $kanbanGroup->kanban = $kanbanID;
            $kanbanGroup->region = $regionID;
            $kanbanGroup->order  = $order;
            $this->dao->insert(TABLE_KANBANGROUP)->data($kanbanGroup)->autoCheck()->exec();
            $groupID = $this->dao->lastInsertID();

            $kanbanLane = new stdclass();
            $kanbanLane->type   = 'team';
            $kanbanLane->region = $regionID;
            $kanbanLane->group  = $groupID;
            $kanbanLane->team   = $teamID;
            $kanbanLane->name   = $teamName;
            $kanbanLane->color  = $this->config->kanban->laneColorList[$colorIndex];
            $kanbanLane->order  = 0;
            $this->dao->insert(TABLE_KANBANLANE)->data($kanbanLane)->autoCheck()->exec();
            $laneID = $this->dao->lastInsertID();

            $colorIndex ++;
            if($colorIndex == count($this->config->kanban->laneColorList)) $colorIndex = 0;

            $order = 1;
            foreach($executions as $execution)
            {
                $kanbanColumn = new stdclass();
                $kanbanColumn->piexecution = $execution->id;
                $kanbanColumn->type        = 'story';
                $kanbanColumn->region      = $regionID;
                $kanbanColumn->group       = $groupID;
                $kanbanColumn->name        = $execution->name;
                $kanbanColumn->color       = '#333';
                $kanbanColumn->limit       = '-1';
                $kanbanColumn->order       = $order;
                $this->dao->insert(TABLE_KANBANCOLUMN)->data($kanbanColumn)->autoCheck()->exec();
                $columnID = $this->dao->lastInsertID();
                $order ++;

                $kanbanCell = new stdclass();
                $kanbanCell->kanban = $kanbanID;
                $kanbanCell->lane   = $laneID;
                $kanbanCell->column = $columnID;
                $kanbanCell->type   = 'story';
                $this->dao->insert(TABLE_KANBANCELL)->data($kanbanCell)->autoCheck()->exec();
            }

            /* 追加风险和目标的列。 */
            foreach($this->lang->pi->appendColumnList as $type => $columnName)
            {
                $kanbanColumn = new stdclass();
                $kanbanColumn->type   = $type;
                $kanbanColumn->region = $regionID;
                $kanbanColumn->group  = $groupID;
                $kanbanColumn->name   = $columnName;
                $kanbanColumn->color  = '#333';
                $kanbanColumn->limit  = '-1';
                $kanbanColumn->order  = $order;
                $this->dao->insert(TABLE_KANBANCOLUMN)->data($kanbanColumn)->autoCheck()->exec();
                $columnID = $this->dao->lastInsertID();
                $order ++;

                $kanbanCell = new stdclass();
                $kanbanCell->kanban = $kanbanID;
                $kanbanCell->lane   = $laneID;
                $kanbanCell->column = $columnID;
                $kanbanCell->type   = $type;
                $this->dao->insert(TABLE_KANBANCELL)->data($kanbanCell)->autoCheck()->exec();

                if($type == 'objective')
                {
                    foreach($this->lang->pi->childColumnList['promise'] as $currentType => $columnName)
                    {
                        $kanbanColumn = new stdclass();
                        $kanbanColumn->parent = $columnID;
                        $kanbanColumn->type   = $currentType;
                        $kanbanColumn->region = $regionID;
                        $kanbanColumn->group  = $groupID;
                        $kanbanColumn->name   = $columnName;
                        $kanbanColumn->color  = '#333';
                        $kanbanColumn->limit  = '-1';
                        $kanbanColumn->order  = $order;
                        $this->dao->insert(TABLE_KANBANCOLUMN)->data($kanbanColumn)->autoCheck()->exec();
                        $currentColumnID = $this->dao->lastInsertID();
                        $order ++;

                        $kanbanCell = new stdclass();
                        $kanbanCell->kanban = $kanbanID;
                        $kanbanCell->lane   = $laneID;
                        $kanbanCell->column = $currentColumnID;
                        $kanbanCell->type   = $type;
                        $this->dao->insert(TABLE_KANBANCELL)->data($kanbanCell)->autoCheck()->exec();
                    }
                }
            }
        }
    }

    /**
     * 在规划看板中新增团队。
     * Add team on the plan kanban.
     *
     * @param  int    $kanbanID
     * @param  string $teams
     * @access public
     * @return bool
    */
    public function addTeamOnPlanKanban($kanbanID, $teams)
    {
        $regionID   = $this->dao->select('id')->from(TABLE_KANBANREGION)->where('kanban')->eq($kanbanID)->fetch('id');
        $groupID    = $this->dao->select('id')->from(TABLE_KANBANGROUP)->where('kanban')->eq($kanbanID)->fetch('id');
        $order      = $this->dao->select('MAX(`order`) as `order`')->from(TABLE_KANBANLANE)->where('region')->eq($regionID)->fetch('`order`');
        $columns    = $this->dao->select('id')->from(TABLE_KANBANCOLUMN)->where('region')->eq($regionID)->fetchPairs();
        $colorIndex = 0;
        $teams = !empty($teams) ? $this->loadModel('team')->getPairs('', '', $teams) : array();
        foreach($teams as $teamID => $teamName)
        {
            $order ++;
            $colorIndex ++;

            $kanbanLane = new stdclass();
            $kanbanLane->type   = 'team';
            $kanbanLane->region = $regionID;
            $kanbanLane->group  = $groupID;
            $kanbanLane->team   = $teamID;
            $kanbanLane->name   = $teamName;
            $kanbanLane->color  = $this->config->kanban->laneColorList[$colorIndex];
            $kanbanLane->order  = $order;
            $this->dao->insert(TABLE_KANBANLANE)->data($kanbanLane)->autoCheck()->exec();
            $laneID = $this->dao->lastInsertID();

            if($colorIndex == count($this->config->kanban->laneColorList)) $colorIndex = 0;

            foreach($columns as $columnID)
            {
                $kanbanCell = new stdclass();
                $kanbanCell->kanban = $kanbanID;
                $kanbanCell->lane   = $laneID;
                $kanbanCell->column = $columnID;
                $kanbanCell->type   = 'story';
                $this->dao->insert(TABLE_KANBANCELL)->data($kanbanCell)->autoCheck()->exec();
            }
        }
    }

    /**
     * 初始化PI团队规划看板。
     * Init PI team kanban.
     *
     * @param  object $PI
     * @access public
     * @return int|bool
     */
    public function initTeamKanban($PI)
    {
        $this->app->loadConfig('kanban');

        $now    = helper::now();
        $kanban = new stdclass();
        $kanban->name        = $PI->name . $this->lang->pi->teamKanban;
        $kanban->acl         = 'open';
        $kanban->status      = 'active';
        $kanban->createdBy   = $this->app->user->account;
        $kanban->createdDate = $now;
        $kanban->fluidBoard  = '1';
        $kanban->maxColWidth = '240';
        $kanban->minColWidth = '240';
        $this->dao->insert(TABLE_KANBAN)->data($kanban)->autoCheck()->exec();
        if(dao::isError()) return false;

        $kanbanID = $this->dao->lastInsertID();
        $this->loadModel('action')->create('kanban', $kanbanID, 'created');

        $this->addTeamOnTeamKanban($kanbanID, $PI->executions, $PI->team);

        if(dao::isError()) return false;

        return $kanbanID;
    }

    /**
     * 初始化PI规划版板。
     * Init PI plan kanban.
     *
     * @param  object $PI
     * @access public
     * @return int|bool
     */
    public function initPlanKanban($PI)
    {
        $this->app->loadConfig('kanban');

        $now    = helper::now();
        $kanban = new stdclass();
        $kanban->name        = $PI->name . $this->lang->pi->planKanban;
        $kanban->acl         = 'open';
        $kanban->status      = 'active';
        $kanban->createdBy   = $this->app->user->account;
        $kanban->createdDate = $now;
        $kanban->fluidBoard  = '1';
        $kanban->maxColWidth = '240';
        $kanban->minColWidth = '240';
        $this->dao->insert(TABLE_KANBAN)->data($kanban)->autoCheck()->exec();
        if(dao::isError()) return false;

        $kanbanID = $this->dao->lastInsertID();
        $this->loadModel('action')->create('kanban', $kanbanID, 'created');

        $kanbanRegion = new stdclass();
        $kanbanRegion->kanban      = $kanbanID;
        $kanbanRegion->name        = $PI->name . $this->lang->pi->planKanban;
        $kanbanRegion->order       = 0;
        $kanbanRegion->createdBy   = $this->app->user->account;
        $kanbanRegion->createdDate = $now;
        $this->dao->insert(TABLE_KANBANREGION)->data($kanbanRegion)->autoCheck()->exec();
        $regionID = $this->dao->lastInsertID();

        $kanbanGroup = new stdclass();
        $kanbanGroup->kanban = $kanbanID;
        $kanbanGroup->region = $regionID;
        $kanbanGroup->order  = 0;
        $this->dao->insert(TABLE_KANBANGROUP)->data($kanbanGroup)->autoCheck()->exec();
        $groupID = $this->dao->lastInsertID();

        $kanbanLane = new stdclass();
        $kanbanLane->type   = 'milestone';
        $kanbanLane->region = $regionID;
        $kanbanLane->group  = $groupID;
        $kanbanLane->name   = $this->lang->pi->milestone->common;
        $kanbanLane->color  = $this->config->kanban->laneColorList[0];
        $kanbanLane->order  = 1;
        $this->dao->insert(TABLE_KANBANLANE)->data($kanbanLane)->autoCheck()->exec();
        $laneID = $this->dao->lastInsertID();

        $order = 1;
        foreach($PI->executions as $execution)
        {
            $kanbanColumn = new stdclass();
            $kanbanColumn->piexecution = $execution->id;
            $kanbanColumn->type        = 'story';
            $kanbanColumn->region      = $regionID;
            $kanbanColumn->group       = $groupID;
            $kanbanColumn->name        = $execution->name;
            $kanbanColumn->color       = '#333';
            $kanbanColumn->limit       = '-1';
            $kanbanColumn->order       = $order;
            $this->dao->insert(TABLE_KANBANCOLUMN)->data($kanbanColumn)->autoCheck()->exec();
            $columnID = $this->dao->lastInsertID();
            $order ++;

            $kanbanCell = new stdclass();
            $kanbanCell->kanban = $kanbanID;
            $kanbanCell->lane   = $laneID;
            $kanbanCell->column = $columnID;
            $kanbanCell->type   = 'milestone';
            $this->dao->insert(TABLE_KANBANCELL)->data($kanbanCell)->autoCheck()->exec();
        }

        $this->addTeamOnPlanKanban($kanbanID, $PI->team);

        if(dao::isError()) return false;

        return $kanbanID;
    }

    /**
     * 启动PI，创建迭代。
     * Start PI, create executions.
     *
     * @param  object   $PI
     * @param  object   $executions
     * @access public
     * @return string|bool
     * @param object $execution
     */
    public function initExecution($PI, $execution)
    {
        if($execution->newProject && !$execution->projectName)
        {
            dao::$errors['projectName'] = $this->lang->pi->error->projectNameEmpty;
            return false;
        }

        if($execution->newProject)
        {
            $duplicateProject = $this->dao->select('id')->from(TABLE_PROJECT)->where('name')->eq($execution->projectName)->andWhere('deleted')->eq(0)->fetch('id');
            if($duplicateProject)
            {
                dao::$errors['projectName'] = $this->lang->pi->error->projectNameDuplicate;
                return false;
            }
        }

        foreach($execution->execution as $index => $name)
        {
            if(!$name)
            {
                dao::$errors["execution[{$index}]"] = $this->lang->pi->error->executionNameEmpty;
                return false;
            }
        }

        $sqlErrors = array();
        $this->lang->project->name = $this->lang->pi->execution;
        foreach($execution->execution as $index => $name)
        {
            $data = new stdclass();
            $data->name = $name;

            $this->dao->insert(TABLE_EXECUTION)
                ->data($data)
                ->autoCheck()
                ->checkIF(!empty($execution->project), 'name', 'unique', "`type` in ('sprint','stage', 'kanban') and `project` = {$execution->project} and `deleted` = '0'");

            if(dao::isError())
            {
                $errors = dao::getError();
                if(!empty($errors['name'])) $sqlErrors["execution[{$index}]"] = $errors['name'];
            }
        }

        if($sqlErrors)
        {
            dao::$errors = $sqlErrors;
            return false;
        }

        $this->loadModel('user');
        $this->loadModel('action');

        $message     = '';
        $projectID   = $execution->project;
        $teamMembers = $this->loadModel('team')->getTeamMembers($execution->team);
        $columns     = $this->dao->select('t2.id')->from(TABLE_KANBANCELL)->alias('t1')
            ->leftJoin(TABLE_KANBANCOLUMN)->alias('t2')->on('t1.column = t2.id')
            ->leftJoin(TABLE_KANBANLANE)->alias('t3')->on('t1.lane = t3.id')
            ->where('t1.kanban')->eq($PI->teamkanban)
            ->andWhere('t3.team')->eq($execution->team)
            ->fetchPairs();

        /* 新增项目的逻辑。 */
        if($execution->newProject)
        {
            $project = new stdclass();
            $project->name       = $execution->projectName;
            $project->code       = $execution->projectName;
            $project->team       = $execution->projectName;
            $project->begin      = min($execution->begin);
            $project->end        = max($execution->end);
            $project->days       = helper::diffDate($project->end, $project->begin);
            $project->type       = 'project';
            $project->model      = 'agileplus';
            $project->acl        = 'private';
            $project->grade      = 1;
            $project->status     = 'wait';
            $project->auth       = 'extend';
            $project->openedBy   = $this->app->user->account;
            $project->openedDate = helper::today();

            $this->dao->insert(TABLE_PROJECT)->data($project)->autoCheck()->exec();
            $projectID = $this->dao->lastInsertID();
            $this->dao->update(TABLE_PROJECT)->set('path')->eq(",$projectID,")->where('id')->eq($projectID)->exec();

            $this->addProjectMember($teamMembers, $projectID, 'project');
            $this->user->updateUserView($projectID, 'project');
            $this->action->create('project', $projectID, 'opened');
        }
        else
        {
            /* 如果已有项目的起止时间随着PI的规划时间变化，则给出提示。 */
            $project = $this->loadModel('project')->getById($projectID);
            if($project->begin > min($execution->begin) || $project->end < max($execution->end))
            {
                $project->begin = $project->begin > min($execution->begin) ? min($execution->begin) : $project->begin;
                $project->end   = $project->end < max($execution->end) ? max($execution->end) : $project->end;

                $message .= sprintf($this->lang->pi->notice->updateProjectDate, $project->begin, $project->end);
                $this->dao->update(TABLE_PROJECT)->data($project)->where('id')->eq($projectID)->exec();
            }

            /* 如果已有项目的关联产品随着PI的产品变化，则给出提示。 */
            $oldProducts  = $this->dao->select('product')->from(TABLE_PROJECTPRODUCT)->where('project')->eq($projectID)->fetchPairs();
            $products     = explode(',', trim($PI->product, ','));
            $newProducts  = array_diff($products, $oldProducts);
            $newProducts  = $this->dao->select('id,name')->from(TABLE_PRODUCT)->where('id')->in($newProducts)->fetchPairs();
            $productNames = '';
            foreach($newProducts as $productName) $productNames .= $productName . ' ';

            if($productNames) $message .= sprintf($this->lang->pi->notice->updateProjectProducts, $productNames);
        }

        /* 将PI的产品关联到项目上。 */
        $this->linkProducts($projectID, $PI->product);

        /* 创建执行。 */
        foreach($execution->execution as $index => $name)
        {
            $data = new stdclass();
            $data->name     = $name;
            $data->code     = $name;
            $data->team     = $name;
            $data->grade    = 1;
            $data->PI       = $index;
            $data->project  = $projectID;
            $data->parent   = $projectID;
            $data->begin    = $execution->begin[$index];
            $data->end      = $execution->end[$index];
            $data->acl      = 'private';
            $data->type     = 'sprint';
            $data->status   = 'wait';
            $data->openedBy = $this->app->user->account;

            $this->dao->insert(TABLE_EXECUTION)->data($data)->autoCheck()->exec();
            $executionID = $this->dao->lastInsertID();
            $this->dao->update(TABLE_EXECUTION)->set('path')->eq(",$projectID,$executionID,")->where('id')->eq($executionID)->exec();

            $this->addProjectMember($teamMembers, $executionID, 'execution');
            $this->user->updateUserView($executionID, 'sprint');
            $this->linkProducts($executionID, $PI->product);
            $this->linkStories($execution, $PI, $index, $projectID, $executionID);
            $this->action->create('execution', $executionID, 'opened');

            $this->dao->update(TABLE_KANBANCOLUMN)->set('execution')->eq($executionID)
                 ->where('piexecution')->eq($index)
                 ->andWhere('id')->in($columns)
                 ->exec();
        }

        return $message;
    }

    /**
     * 添加项目成员。
     * Add project member.
     *
     * @param  array  $teamMembers
     * @param  int    $projectID
     * @param  string $type
     * @access public
     * @return void
    */
    public function addProjectMember($teamMembers, $projectID, $type = 'project')
    {
        foreach($teamMembers as $teamMember)
        {
            $member = clone $teamMember;
            unset($member->id);

            $member->teamgroup = $member->root;
            $member->root      = $projectID;
            $member->type      = $type;
            $member->join      = helper::today();

            $this->dao->insert(TABLE_TEAM)->data($member)->exec();
        }
    }

    /**
     * 项目关联产品。
     * Link products.
     *
     * @param  int    $projectID
     * @param  string $product
     * @access public
     * @return bool
     */
    public function linkProducts($projectID, $product)
    {
        $products   = explode(',', trim($product, ','));
        $needUpdate = array();
        foreach($products as $productID)
        {
            if(!$productID) continue;

            $projectProduct = new stdclass();
            $projectProduct->project = $projectID;
            $projectProduct->product = $productID;
            $this->dao->replace(TABLE_PROJECTPRODUCT)->data($projectProduct)->exec();

            $needUpdate[] = $productID;
        }

        $this->loadModel('user')->updateUserView($needUpdate, 'product');
    }

    /**
     * 将看板列上的需求卡片关联到迭代中。
     * Link stories to execution.
     *
     * @param  object $execution
     * @param  object $PI
     * @param  int    $piExecution
     * @param  int    $projectID
     * @param  int    $executionID
     * @access public
     * @return void
    */
    public function linkStories($execution, $PI, $piExecution, $projectID, $executionID)
    {
        $cards = $this->dao->select('t1.cards')->from(TABLE_KANBANCELL)->alias('t1')
            ->leftJoin(TABLE_KANBANLANE)->alias('t2')->on('t1.lane = t2.id')
            ->leftJoin(TABLE_KANBANCOLUMN)->alias('t3')->on('t1.column = t3.id')
            ->where('t1.kanban')->eq($PI->teamkanban)
            ->andWhere('t2.team')->eq($execution->team)
            ->andWhere('t3.piexecution')->eq($piExecution)
            ->fetch('cards');

        $storyIdList = $this->dao->select('fromID')->from(TABLE_KANBANCARD)
             ->where('id')->in($cards)
             ->andWhere('fromType')->eq('story')
             ->fetchPairs();

        if($storyIdList)
        {
            $this->loadModel('execution')->linkStory($executionID, $storyIdList);
            $this->loadModel('execution')->linkStory($projectID, $storyIdList);
        }
    }

    /**
     * 更新一个PI。
     * Update a PI.
     *
     * @param  object $PI
     * @access public
     * @return bool
     */
    public function update($PI): bool
    {
        if(empty($PI->id)) return false;

        $oldPI = $this->fetchByID($PI->id);

        $this->dao->update(TABLE_PI)->data($PI, 'begin,end,executions,execution,plan')
            ->autoCheck()
            ->batchCheck($this->config->pi->edit->requiredFields, 'notempty')
            ->check('name', 'unique', "id != $PI->id")
            ->where('id')->eq($PI->id)
            ->exec();

        if(dao::isError()) return false;

        /* 保存历史记录。 */
        $actionID = $this->loadModel('action')->create('pi', $PI->id, 'Edited');
        $changes  = common::createChanges($oldPI, $PI);
        if($changes) $this->action->logHistory($actionID, $changes);

        $oldPIExecutions  = $this->dao->select('id')->from(TABLE_PIEXECUTION)->where('pi')->eq($PI->id)->fetchPairs('id');
        $linkKanbanGroup = $this->dao->select('*')->from(TABLE_KANBANGROUP)->where('kanban')->in("$oldPI->teamkanban,$oldPI->plankanban")->fetchAll();

        foreach($PI->executions as $execution)
        {
            if(empty($execution->id))
            {
                $execution->pi = $PI->id;
                $this->dao->insert(TABLE_PIEXECUTION)->data($execution)->exec();
                $execution->id = $this->dao->lastInsertID();

                foreach($linkKanbanGroup as $group)
                {
                    $kanbanColumn = new stdclass();
                    $kanbanColumn->piexecution = $execution->id;
                    $kanbanColumn->type        = 'story';
                    $kanbanColumn->region      = $group->region;
                    $kanbanColumn->group       = $group->id;
                    $kanbanColumn->name        = $execution->name;
                    $kanbanColumn->color       = '#333';
                    $kanbanColumn->limit       = '-1';
                    $kanbanColumn->order       = $execution->order;
                    $this->dao->insert(TABLE_KANBANCOLUMN)->data($kanbanColumn)->autoCheck()->exec();
                    $columnID = $this->dao->lastInsertID();

                    $kanbanLanes = $this->dao->select('id')->from(TABLE_KANBANLANE)->where('group')->eq($group->id)->andWhere('region')->eq($group->region)->fetchPairs('id');
                    foreach($kanbanLanes as $laneID)
                    {
                        $kanbanCell = new stdclass();
                        $kanbanCell->kanban = $group->kanban;
                        $kanbanCell->lane   = $laneID;
                        $kanbanCell->column = $columnID;
                        $kanbanCell->type   = 'story';
                        $this->dao->insert(TABLE_KANBANCELL)->data($kanbanCell)->autoCheck()->exec();
                    }
                }
            }
            else
            {
                $this->dao->update(TABLE_PIEXECUTION)->data($execution, 'id')->where('id')->eq($execution->id)->exec();
                $this->dao->update(TABLE_KANBANCOLUMN)->set('`order`')->eq($execution->order)->where('piexecution')->eq($execution->id)->exec();
                unset($oldPIExecutions[$execution->id]);
            }
        }

        /* 编辑PI时，删除了迭代，则要把已经创建的迭代同步删除。*/
        if($oldPIExecutions) $this->changeExecution($oldPIExecutions);

        /* 编辑PI时，增删了产品，则要把产品和迭代的关联关系同步。*/
        if($PI->product != $oldPI->product) $this->changeProduct($oldPI, $PI);

        /* 编辑PI时，删除了团队，则要把已经创建的迭代同步删除。*/
        if($PI->team != $oldPI->team) $this->changeTeam($oldPI, $PI);

        return true;
    }

    /**
     * 编辑PI时，删除了迭代，则要把已经创建的迭代同步删除。
     * IF delete execution when edit PI, then delete execution.
     *
     * @param  array $oldPIExecutions
     * @access public
     * @return void
    */
    public function changeExecution($oldPIExecutions)
    {
        $executionIdList = $this->dao->select('execution')->from(TABLE_KANBANCOLUMN)
            ->where('piexecution')->in($oldPIExecutions)
            ->andWhere('execution')->ne('0')
            ->fetchPairs();

        $this->dao->delete()->from(TABLE_PIEXECUTION)->where('id')->in($oldPIExecutions)->exec();
        $this->dao->delete()->from(TABLE_KANBANCOLUMN)->where('piexecution')->in($oldPIExecutions)->exec();

        $this->dao->update(TABLE_EXECUTION)->set('deleted')->eq('1')->where('id')->in($executionIdList)->exec();
    }

    /**
     * 编辑PI时，增删了产品，则要把产品和迭代的关联关系同步。
     * IF delete product when edit PI, then delete product and execution link.
     *
     * @param  object $oldPI
     * @param  object $PI
     * @access public
     * @return void
    */
    public function changeProduct($oldPI, $PI)
    {
        $newPIExecutions = $this->dao->select('id')->from(TABLE_PIEXECUTION)->where('pi')->eq($PI->id)->fetchPairs('id');
        $oldProducts = explode(',', trim($oldPI->product, ','));
        $newProducts = explode(',', trim($PI->product, ','));

        $addedProducts   = array_diff($newProducts, $oldProducts);
        $removedProducts = array_diff($oldProducts, $newProducts);
        $executionIdList = $this->dao->select('execution')->from(TABLE_KANBANCOLUMN)
            ->where('piexecution')->in($newPIExecutions)
            ->andWhere('execution')->ne('0')
            ->fetchPairs();

        foreach($addedProducts as $productID)
        {
            foreach($executionIdList as $executionID)
            {
                if($productID and $executionID) $this->linkProducts($executionID, $productID);
            }
        }

        $this->dao->delete()->from(TABLE_PROJECTPRODUCT)->where('project')->in($executionIdList)->andWhere('product')->in($removedProducts)->exec();
    }

    /**
     * 编辑PI时，如果删除了团队，则要把已经创建的迭代同步删除。
     * IF delete team when edit PI, then delete executions.
     *
     * @param  object $oldPI
     * @param  object $PI
     * @access public
     * @return void
    */
    public function changeTeam($oldPI, $PI)
    {
        $oldTeams     = explode(',', trim($oldPI->team, ','));
        $newTeams     = explode(',', trim($PI->team, ','));

        $removedTeams = array_diff($oldTeams, $newTeams);
        if($removedTeams)
        {
            $executionIdList = $this->dao->select('t3.execution')->from(TABLE_KANBANCELL)->alias('t1')
                ->leftJoin(TABLE_KANBANLANE)->alias('t2')->on('t1.lane = t2.id')
                ->leftJoin(TABLE_KANBANCOLUMN)->alias('t3')->on('t1.column = t3.id')
                ->where('t1.kanban')->eq($oldPI->teamkanban)
                ->andWhere('t2.team')->in($removedTeams)
                ->andWhere('t3.execution')->ne('0')
                ->fetchPairs();

            $this->dao->update(TABLE_EXECUTION)->set('deleted')->eq('1')->where('id')->in($executionIdList)->exec();
            $this->dao->delete()->from(TABLE_KANBANLANE)->where('team')->in($removedTeams)->andWhere('team')->ne('')->exec();
        }

        $addedTeams = array_diff($newTeams, $oldTeams);
        if($addedTeams)
        {
            $executions = $this->dao->select('*')->from(TABLE_PIEXECUTION)->where('pi')->eq($PI->id)->fetchAll();
            $this->addTeamOnTeamKanban($oldPI->teamkanban, $executions, implode(',', $addedTeams));
            $this->addTeamOnPlanKanban($oldPI->plankanban, implode(',', $addedTeams));
        }
    }

    /**
     * 关闭一个PI。
     * close a PI.
     *
     * @param  object $PI
     * @access public
     * @return bool
     */
    public function close($PI)
    {
        $oldPI = $this->fetchByID($PI->id);

        $this->dao->update(TABLE_PI)->data($PI, 'comment')->where('id')->eq($PI->id)->exec();

        $changes  = common::createChanges($oldPI, $PI);
        $actionID = $this->loadModel('action')->create('pi', $PI->id, 'Closed', $PI->comment);
        if($changes) $this->action->logHistory($actionID, $changes);

        return !dao::isError();
    }

    /**
     * 激活一个PI。
     * Activate a PI.
     *
     * @param  object $PI
     * @access public
     * @return bool
     */
    public function activate($PI)
    {
        $this->dao->update(TABLE_PI)->data($PI, 'comment')->where('id')->eq($PI->id)->exec();
        $this->loadModel('action')->create('pi', $PI->id, 'Activated', $PI->comment);
        return !dao::isError();
    }

    /**
     * 检查操作按钮是否允许被点击。
     * Check actions clickable.
     *
     * @param  object $object
     * @param  string $action
     * @param  string $module
     * @static
     * @access public
     * @return bool
     */
    public static function isClickable($object, $action): bool
    {
        global $app;

        $module = $app->getModuleName();
        $action = strtolower($action);

        if($object->deleted) return false;
        if($action == 'activate' && $object->status != 'closed') return false;
        if($action == 'close'    && $object->status != 'normal') return false;
        if($action == 'activatecard') return $object->status != 'wait';
        if($action == 'finishcard')   return $object->status == 'wait';
        if($module == 'art' && $action == 'create') return $object->status != 'closed';

        return true;
    }

    /**
     * 根据卡片类型,获取看板卡片的分组。
     * Get card group.
     *
     * @param  int    $kanbanID
     * @static
     * @access public
     * @return array
     */
    public function getCardGroup($kanbanID): array
    {
        $cellList = $this->dao->select('type, cards, `column`')->from(TABLE_KANBANCELL)->where('kanban')->eq($kanbanID)->fetchAll();

        $storyIdList     = array();
        $riskIdList      = array();
        $cardIdList      = array();
        $objectiveIdList = array();
        $columns         = array();
        foreach($cellList as $cell)
        {
            $cards = explode(',', trim($cell->cards, ','));
            foreach($cards as $card)
            {
                if(!$card) continue;
                if($cell->type == 'story')      $storyIdList[$card]     = $card;
                if($cell->type == 'risk')       $riskIdList[$card]      = $card;
                if($cell->type == 'objective')  $objectiveIdList[$card] = $card;
                if(in_array($cell->type, array('common', 'milestone', 'story'))) $cardIdList[$card] = $card;
            }
            $columns[$cell->column] = $cell->column;
        }

        $stories = $this->dao->select('t1.*, t2.id as cardID')->from(TABLE_STORY)->alias('t1')
            ->leftJoin(TABLE_KANBANCARD)->alias('t2')->on('t1.id = t2.fromID')
            ->where('t2.id')->in($storyIdList)
            ->andWhere('t1.deleted')->eq('0')
            ->andWhere('t2.fromType')->eq('story')
            ->fetchAll('cardID');

        $executions = $this->dao->select('execution')->from(TABLE_KANBANCOLUMN)->where('id')->in($columns)->fetchPairs();

        $storyIdList = array();
        foreach($stories as $story) $storyIdList[$story->id] = $story->id;
        $requirements = $this->getLinkedRequirementByStory($storyIdList);
        $tasks        = $this->dao->select('story')->from(TABLE_TASK)->where('deleted')->eq('0')->andWhere('story')->in($storyIdList)->andWhere('execution')->in($executions)->fetchPairs();

        foreach($stories as $story)
        {
            $story->requirement = !empty($requirements[$story->id]) ? $requirements[$story->id] : array();
            $story->linkedTask  = !empty($tasks[$story->id]) ? true : false;
        }

        $risks = $this->dao->select('*')->from(TABLE_RISK)
            ->where('id')->in($riskIdList)
            ->andWhere('deleted')->eq('0')
            ->fetchAll('id');

        $objectives = $this->dao->select('*')->from(TABLE_OBJECTIVE)
            ->where('id')->in($objectiveIdList)
            ->andWhere('deleted')->eq('0')
            ->fetchAll('id');

        $cards = $this->dao->select('*')->from(TABLE_KANBANCARD)
            ->where('id')->in($cardIdList)
            ->andWhere('deleted')->eq('0')
            ->andWhere('fromID')->eq('0')
            ->fetchAll('id');

        return array('story' => $stories, 'risk' => $risks, 'card' => $cards, 'objective' => $objectives);
    }

    /**
     * 获取看板卡片的连线关系。
     * Get kanban links.
     *
     * @param  int    $kanbanID
     * @access public
     * @return array
     */
    public function getKanbanLinks($kanbanID)
    {
        return $this->dao->select('`from`, `to`')->from(TABLE_KANBANLINKS)->where('kanban')->eq($kanbanID)->fetchAll();
    }

    /**
     * 处理看板数据。
     * Process kanban data.
     *
     * @param  int    $kanbanID
     * @param  int    $groupID
     * @param  int    $teamID
     * @access public
     * @return array
     */
    public function processKanban($kanbanID, $groupID = 0, $teamID = 0): array
    {
        $this->app->loadLang('story');
        $kanbanInfo  = $this->fetchByID($kanbanID, 'kanban');
        $kanbanGroup = $this->dao->select('*')->from(TABLE_KANBANGROUP)->where('kanban')->eq($kanbanID)->beginIF($groupID)->andWhere('id')->eq($groupID)->fi()->orderBy('`order`')->fetchAll();
        $kanban      = array();
        $links       = $this->getKanbanLinks($kanbanID);
        foreach($kanbanGroup as $group)
        {
            $kanbanData  = array();
            $kanbanLanes = $this->dao->select('*')->from(TABLE_KANBANLANE)->where('group')->eq($group->id)->andWhere('deleted')->eq('0')->beginIF($teamID)->andWhere('team')->eq($teamID)->fi()->orderBy('`order`')->fetchAll();
            if(!$kanbanLanes) continue;
            foreach($kanbanLanes as $lane)
            {
                $lanes = array();
                $lanes['id']        = $lane->id;
                $lanes['name']      = $lane->id;
                $lanes['title']     = $lane->name;
                $lanes['color']     = $lane->color;
                $lanes['type']      = $lane->type;
                $kanbanData['lanes'][] = $lanes;
            }

            $kanbanColumns = $this->dao->select("t1.*, IF(t2.name != '', t2.name, t1.name) as name, t2.begin, t2.end")->from(TABLE_KANBANCOLUMN)->alias('t1')
                ->leftJoin(TABLE_PIEXECUTION)->alias('t2')->on('t1.piexecution = t2.id')
                ->where('t1.group')->eq($group->id)
                ->orderBy('t1.order')
                ->fetchAll('id');

            foreach($kanbanColumns as $column)
            {
                $cols = array();
                $cols['id']        = $column->id;
                $cols['name']      = $column->id;
                $cols['title']     = $column->name;
                $cols['color']     = $column->color;
                $cols['execution'] = $column->execution;
                $cols['capacity']  = $column->capacity;
                $cols['begin']     = $column->begin;
                $cols['end']       = $column->end;
                $cols['type']      = $column->type;
                if($column->parent) $cols['parentName'] = $column->parent;
                $kanbanData['cols'][] = $cols;
            }

            $kanbanData = $this->processCard($kanbanData, $kanbanID, $lane->id);

            $kanbanData['colWidth']      = 'auto';
            $kanbanData['laneHeight']    = 'auto';
            $kanbanData['minLaneHeight'] = 200;
            $kanbanData['links']         = $links;
            $kanbanData['editLinks']     = true;
            if($kanbanInfo->displayCards) $kanbanData['laneHeight'] = $kanbanInfo->displayCards;
            if($kanbanInfo->fluidBoard)
            {
                if($kanbanInfo->minColWidth) $kanbanData['minColWidth'] = $kanbanInfo->minColWidth;
                if($kanbanInfo->maxColWidth) $kanbanData['maxColWidth'] = $kanbanInfo->maxColWidth;
            }
            else
            {
                $kanbanData['colWidth'] = $kanbanInfo->colWidth;
            }
            $kanban[$group->id] = $kanbanData;
        }

        if($groupID) return zget($kanban, $groupID, array());
        return $kanban;
    }

    /**
     * 处理看板卡片及工作容量。
     * Process kanban card and workload.
     *
     * @param  array  $kanbanData
     * @param  int    $kanbanID
     * @param  int    $laneID
     * @access public
     * @return array
     */
    public function processCard($kanbanData, $kanbanID, $laneID): array
    {
        $kanbanCells         = $this->dao->select('*')->from(TABLE_KANBANCELL)->where('kanban')->eq($kanbanID)->fetchAll();
        $cardGroup           = $this->getCardGroup($kanbanID);
        $userAvatars         = $this->loadModel('user')->getAvatarPairs();
        $users               = $this->user->getPairs('noletter|nodelete');
        $columnCards         = array();
        $kanbanData['items'] = array();

        foreach($kanbanCells as $cell)
        {
            if(!isset($columnCards[$cell->column])) $columnCards[$cell->column] = '';

            if($cell->type == 'story')
            {
                foreach(explode(',', $cell->cards) as $cardID)
                {
                    if(isset($cardGroup['story'][$cardID]))
                    {
                        $kanbanData = $this->getStoryCard($cardID, $cell, $cardGroup, $userAvatars, $kanbanData);
                    }
                    elseif(isset($cardGroup['card'][$cardID]))
                    {
                        $kanbanData = $this->getKanbanCard($cardID, $cell, $cardGroup, $users, $userAvatars, $kanbanData);
                    }
                }
                $columnCards[$cell->column] .= $cell->cards;
            }
            else if($cell->type == 'risk')
            {
                foreach(explode(',', $cell->cards) as $riskID)
                {
                    if(!isset($cardGroup['risk'][$riskID])) continue;

                    $risk = $cardGroup['risk'][$riskID];
                    $kanbanData['items'][$cell->lane][$cell->column][] = array('id' => "risk{$risk->id}", 'name' => $risk->id, 'lane' => $cell->lane, 'col' => $cell->column, 'title' => $risk->name, 'pri' => $risk->pri, 'type' => 'risk', 'rate' => $risk->rate, 'status' => $risk->status, 'strategy' => $risk->strategy);
                }
            }

            else if($cell->type == 'objective')
            {
                $childColumn = $this->dao->select('type,id')->from(TABLE_KANBANCOLUMN)->where('parent')->eq($cell->column)->fetchPairs();
                if(!$childColumn) continue;

                foreach(explode(',', $cell->cards) as $objectiveID)
                {
                    if(!isset($cardGroup['objective'][$objectiveID])) continue;

                    $objective = $cardGroup['objective'][$objectiveID];
                    $kanbanData['items'][$cell->lane][$childColumn[$objective->promise]][] = array('id' => "objective{$objective->id}", 'name' => $objective->id, 'lane' => $cell->lane, 'col' => $childColumn[$objective->promise], 'title' => $objective->name, 'type' => 'objective', 'BV' => $objective->BV, 'AV' => $objective->AV, 'promise' => $objective->promise);
                }
            }
            else if(in_array($cell->type, array('common', 'milestone')))
            {
                foreach(explode(',', $cell->cards) as $cardID)
                {
                    if(!isset($cardGroup['card'][$cardID])) continue;
                    $kanbanData = $this->getKanbanCard($cardID, $cell, $cardGroup, $users, $userAvatars, $kanbanData, $columnCards, $laneID);
                }
            }
        }

        return $kanbanData;
    }

    /**
     * 获取看板上的需求卡片。
     * Get story card.
     *
     * @param  string  $cardID
     * @param  object  $cell
     * @param  array   $cardGroup
     * @param  array   $userAvatars
     * @param  array   $kanbanData
     * @access public
     * @return array
     */
    public function getStoryCard($cardID, $cell, $cardGroup, $userAvatars, $kanbanData): array
    {
        $story      = $cardGroup['story'][$cardID];
        $assignedTo = $story->assignedTo;
        if($assignedTo)
        {
            $userAvatar = zget($userAvatars, $assignedTo, '');
            $assignedTo = $userAvatar ? "<img src='$userAvatar'/>" : strtoupper(mb_substr($assignedTo, 0, 1, 'utf-8'));
        }

        $kanbanData['items'][$cell->lane][$cell->column][] = array('id' => "story{$cardID}", 'name' => $story->id, 'cardID' => $cardID, 'lane' => $cell->lane, 'col' => $cell->column, 'estimate' => $story->estimate, 'status' => $story->status, 'statusLabel' => zget($this->lang->story->statusList, $story->status), 'title' => $story->title, 'pri' => $story->pri, 'uavatar' => $assignedTo, 'type' => $story->type, 'linkedTask' => $story->linkedTask, 'requirement' => $story->requirement);

        return $kanbanData;
    }

    /**
     * 获取看板上的自定义卡片。
     * Get kanban card.
     *
     * @param  string  $cardID
     * @param  object  $cell
     * @param  array   $cardGroup
     * @param  array   $users
     * @param  array   $userAvatars
     * @param  array   $kanbanData
     * @access public
     * @return string
     */
    public function getKanbanCard($cardID, $cell, $cardGroup, $users, $userAvatars, $kanbanData): array
    {
        $card = $cardGroup['card'][$cardID];

        $userAvatar = zget($userAvatars, $card->assignedTo, '');
        $userAvatar = $userAvatar ? "<img src='$userAvatar'/>" : strtoupper(mb_substr($card->assignedTo, 0, 1, 'utf-8'));

        $card->assignedTo = zget($users, $card->assignedTo, '');
        $statusLabel = $cell->type == 'milestone' ? zget($this->lang->pi->milestone->statusList, $card->status) : zget($this->lang->pi->cardStatusList, $card->status);

        $type = $cell->type == 'milestone' ? 'milestone' : 'common';

        $kanbanData['items'][$cell->lane][$cell->column][] = array('id' => "card{$card->id}", 'name' => $card->id, 'title' => $card->name, 'lane' => $cell->lane, 'col' => $cell->column, 'end' => $card->end, 'type' => $type, 'status' => $card->status, 'statusLabel' => $statusLabel, 'uavatar' => $userAvatar, 'assignedTo' => $card->assignedTo, 'pri' => $card->pri, 'estimate' => $card->estimate, 'progress' => $card->progress);

        return $kanbanData;
    }

    /**
     * 获取关联过需求的产品。
     * Get product with linked story.
     *
     * @param  int    $piID
     * @access public
     * @return string
     */
    public function getLinkedProducts($piID): string
    {
        $PI = $this->fetchByID($piID);
        if(!$PI) return '';

        $products = $this->dao->select('t1.product')->from(TABLE_STORY)->alias('t1')
            ->leftJoin(TABLE_PISTORY)->alias('t2')->on('t1.id=t2.story')
            ->leftJoin(TABLE_KANBANCARD)->alias('t3')->on('t1.id=t3.fromID')
            ->where('1=1')
            ->andWhere('t2.pi', true)->eq($PI->id)
            ->andWhere('((t3.kanban')->eq($PI->teamkanban)
            ->orwhere('t3.kanban')->eq($PI->plankanban)
            ->markRight(1)
            ->andWhere('t3.fromType')->eq('story')
            ->markRight(2)
            ->fetchPairs();

        return $products ? implode(',', $products) : '';
    }

    /**
     * 获取关联过PI看板卡片的团队。
     * Get team with linked pi card.
     *
     * @param  int    $piID
     * @access public
     * @return string
     */
    public function getLinkedTeams($piID): string
    {
        $PI = $this->fetchByID($piID);
        if(!$PI) return '';

        $teams = $this->dao->select('t1.team')->from(TABLE_KANBANLANE)->alias('t1')
            ->leftJoin(TABLE_KANBANCELL)->alias('t2')->on('t1.id=t2.lane')
            ->where('1=1')
            ->andWhere('t2.kanban', true)->eq($PI->teamkanban)
            ->orwhere('t2.kanban')->eq($PI->plankanban)
            ->markRight(1)
            ->andWhere('t2.cards')->ne('')
            ->fetchPairs();

        return $teams ? implode(',', $teams) : '';
    }

    /**
     * 获取关联过PI看板卡片的迭代规划。
     * Get execution with linked pi card.
     *
     * @param  int    $piID
     * @access public
     * @return string
     */
    public function getLinkedExecutions($piID): string
    {
        $PI = $this->fetchByID($piID);
        if(!$PI) return '';

        $executions = $this->dao->select('t1.piexecution')->from(TABLE_KANBANCOLUMN)->alias('t1')
            ->leftJoin(TABLE_KANBANCELL)->alias('t2')->on('t1.id=t2.column')
            ->where('1=1')
            ->andWhere('t2.kanban', true)->eq($PI->teamkanban)
            ->orwhere('t2.kanban')->eq($PI->plankanban)
            ->markRight(1)
            ->andWhere('t2.cards')->ne('')
            ->fetchPairs();

        return $executions ? implode(',', $executions) : '';
    }

    /**
     * 获取当前PI关联过迭代的团队。
     * Get teams with linked execution.
     *
     * @param  int    $piID
     * @access public
     * @return string
     */
    public function getLinkedExecutionTeams($piID)
    {
        $PI = $this->fetchByID($piID);
        if(!$PI) return '';

        $teams = $this->dao->select('t2.team')->from(TABLE_KANBANGROUP)->alias('t1')
            ->leftjoin(TABLE_KANBANLANE)->alias('t2')->on('t1.id=t2.group')
            ->leftjoin(TABLE_KANBANCOLUMN)->alias('t3')->on('t2.region=t3.region')
            ->where('t1.kanban')->eq($PI->teamkanban)
            ->andWhere('t3.execution')->ne('0')
            ->fetchPairs();

        return $teams ? implode(',', $teams) : '';
    }

    /**
     * 获取当前PI关联过迭代的迭代规划。
     * Get plans with linked execution.
     *
     * @param  int    $piID
     * @access public
     * @return string
     */
    public function getLinkedExecutionPlans($piID)
    {
        $plans = $this->dao->select('t1.id')->from(TABLE_PIEXECUTION)->alias('t1')
            ->leftjoin(TABLE_KANBANCOLUMN)->alias('t2')->on('t1.id=t2.piexecution')
            ->where('t1.pi')->eq($piID)
            ->andWhere('t2.execution')->ne('0')
            ->fetchPairs();

        return $plans ? implode(',', $plans) : '';
    }

    /**
     * 移动看板卡片。
     * Move kanban card.
     *
     * @param  int    $objectID
     * @param  string $objectType
     * @param  int    $sourceLane
     * @param  int    $sourceColumn
     * @param  int    $targetLane
     * @param  int    $targetColumn
     * @param  string $sortList
     * @access public
     * @return bool
     */
    public function moveCard($objectID, $objectType, $sourceLane, $sourceColumn, $targetLane, $targetColumn, $sortList = ''): bool
    {
        if($objectType == 'requirement') $objectType = 'story';
        if($objectType == 'objective')
        {
            $column = $this->dao->select('type,parent')->from(TABLE_KANBANCOLUMN)->where('id')->eq($targetColumn)->fetch();
            $this->dao->update(TABLE_OBJECTIVE)->set('promise')->eq($column->type)->where('id')->eq($objectID)->exec();

            if($sortList)
            {
                $oldCards = $this->dao->select('cards')->from(TABLE_KANBANCELL)->where('type')->eq($objectType)->andWhere('lane')->eq($targetLane)->andWhere('column')->eq($column->parent)->fetch('cards');
                $oldCards = explode(',', $oldCards);
                $sortList = explode(',', $sortList);
                $diffCard = array_diff($oldCards, $sortList);
                $newCards = array_merge($diffCard, $sortList);
                $newCards = array_unique($newCards);
                $newCards = implode(',', $newCards);

                $this->dao->update(TABLE_KANBANCELL)->set('cards')->eq($newCards)->where('type')->eq($objectType)->andWhere('lane')->eq($targetLane)->andWhere('column')->eq($column->parent)->exec();
            }

            $this->loadModel('action')->create('objective', $objectID, 'Moved');
        }
        else
        {
            if($sourceLane != $targetLane || $sourceColumn != $targetColumn)
            {
                $oldCards = $this->dao->select('cards')->from(TABLE_KANBANCELL)->where('lane')->eq($sourceLane)->andWhere('column')->eq($sourceColumn)->fetch('cards');
                $oldCards = str_replace(",$objectID,", ',', ",$oldCards,");
                $oldCards = trim($oldCards, ',');
                $this->dao->update(TABLE_KANBANCELL)->set('cards')->eq($oldCards)->where('lane')->eq($sourceLane)->andWhere('column')->eq($sourceColumn)->exec();

                $newCards = $this->dao->select('cards')->from(TABLE_KANBANCELL)->where('lane')->eq($targetLane)->andWhere('column')->eq($targetColumn)->fetch('cards');
                $newCards = "$newCards,$objectID";
                $newCards = trim($newCards, ',');
                $this->dao->update(TABLE_KANBANCELL)->set('cards')->eq($newCards)->where('lane')->eq($targetLane)->andWhere('column')->eq($targetColumn)->exec();
            }

            if($sortList) $this->dao->update(TABLE_KANBANCELL)->set('cards')->eq(trim($sortList, ','))->where('lane')->eq($targetLane)->andWhere('column')->eq($targetColumn)->exec();

            /* 如果列上有执行，则取消原来的执行和需求的关联关系，新增新的执行和需求的关联关系。 */
            if($sourceColumn != $targetColumn)
            {
                $storyID = $this->dao->select('fromID')->from(TABLE_KANBANCARD)->where('id')->eq($objectID)->andWhere('fromType')->eq('story')->fetch('fromID');

                /* 执行取消关联原来的需求。*/
                $sourceColumn = $this->fetchByID($sourceColumn, 'kanbancolumn');
                if($sourceColumn->execution and $storyID) $this->loadModel('execution')->unlinkStory($sourceColumn->execution , $storyID, 0, 0, 'pi');

                /* 执行关联新的需求。*/
                $targetColumn = $this->fetchByID($targetColumn, 'kanbancolumn');
                if($targetColumn->execution and $objectType == 'story' and $storyID) $this->loadModel('execution')->linkStory($targetColumn->execution, array($storyID), array(), '', array(), 'story', 'pi');
            }

            $card = $this->dao->select('*')->from(TABLE_KANBANCARD)->where('id')->eq($objectID)->fetch();
            $this->loadModel('action')->create($card->fromType ? $card->fromType : 'picard', $card->fromType ? $card->fromID : $card->id, 'Moved');
        }

        return !dao::isError();
    }

    /**
     * 获取更新看板回调函数。
     * Get kanban callback.
     *
     * @param  int    $groupID
     * @access public
     * @return array
     */
    public function getKanbanCallBack($groupID): array
    {
        $kanbanID   = $this->dao->select('kanban')->from(TABLE_KANBANGROUP)->where('id')->eq($groupID)->fetch('kanban');
        $kanbanData = $this->processKanban($kanbanID, $groupID, 0);
        return array('name' => 'updateKanban', 'params' => array((string)$groupID, $kanbanData));
    }
}
