<?php
/**
 * The model file of kanban module of ZenTaoPMS.
 *
 * @copyright   Copyright 2009-2023 禅道软件（青岛）有限公司(ZenTao Software (Qingdao) Co., Ltd. www.cnezsoft.com)
 * @license     ZPL(http://zpl.pub/page/zplv12.html) or AGPL(https://www.gnu.org/licenses/agpl-3.0.en.html)
 * @author      Shujie Tian <tianshujie@easycorp.ltd>
 * @package     kanban
 * @version     $Id: model.php 5118 2021-10-22 10:18:41Z $
 * @link        https://www.zentao.net
 */
class kanbanModel extends model
{
    /**
     * 创建看板分组。
     * Create a kanban group.
     *
     * @param  int    $kanbanID
     * @param  int    $regionID
     * @access public
     * @return int|false
     */
    public function createGroup($kanbanID, $regionID)
    {
        $maxOrder = $this->dao->select('MAX(`order`) AS maxOrder')->from(TABLE_KANBANGROUP)
            ->where('region')->eq($regionID)
            ->fetch('maxOrder');

        $order = $maxOrder ? $maxOrder + 1 : 1;

        $group = new stdclass();
        $group->kanban = $kanbanID;
        $group->region = $regionID;
        $group->order  = $order;

        $this->dao->insert(TABLE_KANBANGROUP)->data($group)->autoCheck()->exec();
        if(dao::isError()) return false;

        return $this->dao->lastInsertID();
    }

    /**
     * 创建默认看板区域。
     * Create a default kanban region.
     *
     * @param  object $kanban
     * @access public
     * @return int|bool
     */
    public function createDefaultRegion($kanban)
    {
        $region = new stdclass();
        $region->name        = $this->lang->kanbanregion->default;
        $region->kanban      = $kanban->id;
        $region->space       = $kanban->space;
        $region->createdBy   = $this->app->user->account;
        $region->createdDate = helper::now();

        return $this->createRegion($kanban, $region);
    }

    /**
     * 复制看板区域。
     * Copy kanban regions.
     *
     * @param  object $kanban
     * @param  int    $copyKanbanID
     * @param  string $from kanban|execution
     * @param  string $param
     * @access public
     * @return void
     */
    public function copyRegions($kanban, $copyKanbanID, $from = 'kanban', $param = 'withArchived')
    {
        if(empty($kanban) || empty($copyKanbanID)) return;

        $regions = $this->getRegionPairs($copyKanbanID, 0, $from);
        $order   = 1;
        foreach($regions as $copyID => $copyName)
        {
            $region = new stdclass();
            $region->name        = $copyName;
            $region->kanban      = $kanban->id;
            $region->space       = $kanban->space;
            $region->createdBy   = $this->app->user->account;
            $region->createdDate = helper::now();
            $region->order       = $order;

            $this->createRegion($kanban, $region, $copyID, $from, $param);
            $order ++;
        }
    }

    /**
     * 创建区域。
     * Create a new region.
     *
     * @param  object $kanban
     * @param  object $fromRegion
     * @param  int    $copyRegionID
     * @param  string $from         kanban|execution
     * @param  string $param
     * @access public
     * @return int
     */
    public function createRegion($kanban, $fromRegion = null, $copyRegionID = 0, $from = 'kanban', $param = '')
    {
        $account = $this->app->user->account;
        $order   = 1;
        $region  = $fromRegion;

        if(empty($region))
        {
            $maxOrder = $this->dao->select('MAX(`order`) AS maxOrder')->from(TABLE_KANBANREGION)
                ->where('kanban')->eq($kanban->id)
                ->fetch('maxOrder');

            $order  = $maxOrder ? $maxOrder + 1 : 1;
            $region = new stdclass();
            $region->kanban      = $kanban->id;
            $region->space       = $from == 'kanban' ? $kanban->space : 0;
            $region->name        = $this->post->name;
            $region->createdBy   = $account;
            $region->createdDate = helper::now();
        }

        $region->order = isset($region->order) ? $region->order : $order;
        $this->dao->insert(TABLE_KANBANREGION)->data($region)
            ->batchCheck($this->config->kanban->require->createregion, 'notempty')
            ->check('name', 'unique', "kanban = {$kanban->id} AND deleted = '0' AND space='$region->space'")
            ->autoCheck()
            ->exec();

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

        $this->loadModel('action')->create('kanbanRegion', $regionID, 'Created');

        if($copyRegionID)
        {
            $this->copyRegion($kanban, $regionID, $copyRegionID, $from, $param);
        }
        elseif($from == 'kanban')
        {
            $groupID = $this->createGroup((int)$kanban->id, $regionID);
            if(dao::isError()) return false;

            $this->createDefaultLane($regionID, $groupID);
            if(dao::isError()) return false;

            $this->createDefaultColumns($regionID, $groupID);
            if(dao::isError()) return false;
        }

        return $regionID;
    }

    /**
     * 复制区域。
     * Copy a region.
     *
     * @param  object $kanban
     * @param  int    $regionID
     * @param  int    $copyRegionID
     * @param  string $from
     * @param  string $param
     * @access public
     * @return void
     */
    public function copyRegion($kanban, $regionID, $copyRegionID, $from = 'kanban', $param = '')
    {
        /* Gets the groups, lanes and columns of the replication region. */
        $copyGroups      = $this->getGroupGroupByRegions((array)$copyRegionID);
        $copyLaneGroup   = $this->getLaneGroupByRegions((array)$copyRegionID);
        $copyColumnGroup = $this->getColumnGroupByRegions((array)$copyRegionID, 'id_asc', $param);

        /* Create groups, lanes, and columns. */
        if(empty($copyGroups)) return $regionID;

        foreach($copyGroups[$copyRegionID] as $copyGroupID => $copyGroup)
        {
            $newGroupID = $this->createGroup((int)$kanban->id, $regionID);
            if(dao::isError()) return false;

            $copyLanes     = isset($copyLaneGroup[$copyGroupID]) ? $copyLaneGroup[$copyGroupID] : array();
            $copyColumns   = isset($copyColumnGroup[$copyGroupID]) ? $copyColumnGroup[$copyGroupID] : array();

            $lanePairs = $this->copyLanes($kanban, $copyLanes, $regionID, $newGroupID);
            $this->copyColumns($copyColumns, $regionID, $newGroupID, $from);
            if(dao::isError()) return false;

            if($param == 'updateTaskCell')
            {
                foreach($lanePairs as $oldLaneID => $newLaneID)
                {
                    $cards = $this->dao->select('id,cards')->from(TABLE_KANBANCELL)->where('lane')->eq($oldLaneID)->andWhere('type')->eq('task')->fetchPairs();
                    $cards = implode(',', $cards);
                    $cards = preg_replace('/[,]+/', ',',$cards);
                    $cards = trim($cards, ',');

                    $group      = $this->dao->select('`group`')->from(TABLE_KANBANLANE)->where('id')->eq($newLaneID)->fetch();
                    $waitColumn = $this->dao->select('id')->from(TABLE_KANBANCOLUMN)->where('type')->eq('wait')->andWhere('`group`')->eq($group->group)->fetch();

                    if(!empty($waitColumn)) $this->addKanbanCell($kanban->id, $newLaneID, $waitColumn->id, 'task', $cards);
                }
            }
        }
    }

    /**
     * 复制区域中的泳道。
     * Copy lanes.
     *
     * @param  object       $kanban
     * @param  array        $copyLanes
     * @param  int          $regionID
     * @param  int          $newGroupID
     * @access public
     * @return array|false
     */
    public function copyLanes($kanban, $copyLanes, $regionID, $newGroupID)
    {
        $lanePairs = array();
        foreach($copyLanes as $copyLane)
        {
            if(is_array($copyLane)) $copyLane = (object)$copyLane;

            $laneID = $copyLane->id;
            $copyLane->name = $copyLane->title;

            unset($copyLane->id);
            unset($copyLane->actionList);
            unset($copyLane->title);

            $copyLane->region         = $regionID;
            $copyLane->group          = $newGroupID;
            $copyLane->lastEditedTime = helper::now();
            $lanePairs[$laneID] = $this->createLane((int)$kanban->id, $regionID, $copyLane, 'copy');
            if(dao::isError()) return false;
        }

        return $lanePairs;
    }

    /**
     * 复制区域中的列。
     * Copy columns.
     *
     * @param  array  $copyColumns
     * @param  int    $regionID
     * @param  int    $newGroupID
     * @param  string $from
     * @access public
     * @return void
     */
    public function copyColumns($copyColumns, $regionID, $newGroupID, $from = 'kanban')
    {
        $parentColumns = array();
        foreach($copyColumns as $copyColumn)
        {
            if(is_array($copyColumn)) $copyColumn = (object)$copyColumn;

            $copyColumnID = $copyColumn->id;
            $copyColumn->name = $copyColumn->title;

            unset($copyColumn->id);
            unset($copyColumn->title);
            unset($copyColumn->actionList);
            unset($copyColumn->asParent);
            unset($copyColumn->parentName);

            $copyColumn->region = $regionID;
            $copyColumn->group  = $newGroupID;

            if($copyColumn->parent > 0 && isset($parentColumns[$copyColumn->parent]))
            {
                $copyColumn->parent = $parentColumns[$copyColumn->parent];
            }

            $parentColumnID = $this->createColumn($regionID, $copyColumn, $from, 'copy');

            if($copyColumn->parent < 0) $parentColumns[$copyColumnID] = $parentColumnID;
            if(dao::isError()) return false;
        }
    }


    /**
     * 创建默认泳道。
     * Create default lane.
     *
     * @param  int    $regionID
     * @param  int    $groupID
     * @access public
     * @return int
     */
    public function createDefaultLane($regionID, $groupID)
    {
        $lane = new stdclass();
        $lane->name           = $this->lang->kanbanlane->default;
        $lane->group          = $groupID;
        $lane->region         = $regionID;
        $lane->type           = 'common';
        $lane->lastEditedTime = helper::now();
        $lane->color          = '#7ec5ff';
        $lane->order          = 1;
        $lane->groupby        = '';
        $lane->extra          = '';

        $this->dao->insert(TABLE_KANBANLANE)->data($lane)->exec();
        $laneID = $this->dao->lastInsertId();

        return $laneID;
    }

    /**
     * 创建默认看板列。
     * Create default kanban columns.
     *
     * @param  int    $regionID
     * @param  int    $groupID
     * @access public
     * @return void
     */
    public function createDefaultColumns($regionID, $groupID)
    {
        $order = 1;
        foreach($this->lang->kanban->defaultColumn as $columnName)
        {
            $column = new stdclass();
            $column->region = $regionID;
            $column->group  = $groupID;
            $column->name   = $columnName;
            $column->order  = $order;
            $column->limit  = -1;
            $column->color  = '#333';
            $column->type   = '';

            $this->createColumn($regionID, $column, 'kanban', 'copy');
            $order ++;
        }

        return !dao::isError();
    }

    /**
     * 创建看板列。
     * Create a column.
     *
     * @param  int       $regionID
     * @param  object    $column
     * @param  string    $from kanban|execution
     * @param  string    $mode new|copy
     * @access public
     * @return int|false
     */
    public function createColumn($regionID, $column = null, $from = 'kanban', $mode = 'new')
    {
        if($mode == 'new')
        {
            if(!$column->limit && empty($column->noLimit)) dao::$errors['limit'][] = sprintf($this->lang->error->notempty, $this->lang->kanban->WIP);
            if(!preg_match("/^-?\d+$/", (string)$column->limit) || (!isset($column->noLimit) && $column->limit <= 0)) dao::$errors['limit'] = $this->lang->kanban->error->mustBeInt;
            if(dao::isError()) return false;
        }

        $limit = $column->limit;
        if(isset($column->parent) && $column->parent > 0)
        {
            /* Create a child column. */
            $parentColumn = $this->getColumnByID((int)$column->parent);
            if($parentColumn->limit != -1)
            {
                /* The WIP of the child column is infinite or greater than the WIP of the parent column. */
                $sumChildLimit = $this->dao->select('SUM(`limit`) AS sumChildLimit')->from(TABLE_KANBANCOLUMN)->where('parent')->eq($column->parent)->andWhere('deleted')->eq(0)->fetch('sumChildLimit');
                if($limit == -1 || (((int)$limit + (int)$sumChildLimit) > $parentColumn->limit)) dao::$errors['limit'][] = $this->lang->kanban->error->parentLimitNote;
                if(dao::isError()) return false;
            }
        }

        if($mode == 'new' && $column->order)
        {
            $this->dao->update(TABLE_KANBANCOLUMN)
                ->set('`order` = `order` + 1')
                ->where('`group`')->eq($column->group)
                ->andWhere('`order`')->ge($column->order)
                ->exec();
        }

        $this->dao->insert(TABLE_KANBANCOLUMN)->data($column, 'noLimit,position,copyItems')
            ->batchCheck($this->config->kanban->require->createcolumn, 'notempty')
            ->autoCheck()
            ->exec();
        if(dao::isError()) return false;

        $columnID = $this->dao->lastInsertID();
        $this->loadModel('action')->create('kanbanColumn', $columnID, 'Created');
        if($from == 'kanban') $this->dao->update(TABLE_KANBANCOLUMN)->set('type')->eq("column{$columnID}")->where('id')->eq($columnID)->exec();

        /* Add kanban cell. */
        $lanes    = $this->dao->select('id,type')->from(TABLE_KANBANLANE)->where('`group`')->eq($column->group)->fetchPairs();
        $kanbanID = $this->dao->select('kanban')->from(TABLE_KANBANREGION)->where('id')->eq($regionID)->fetch('kanban');
        foreach($lanes as $laneID => $laneType) $this->addKanbanCell((int)$kanbanID, $laneID, $columnID, $laneType);

        return $columnID;
    }

    /**
     * 拆分看板列。
     * Split column.
     *
     * @param  int    $columnID
     * @param  array  $columns
     * @access public
     * @return void
     */
    public function splitColumn($columnID, $columns)
    {
        $this->loadModel('action');
        $column        = $this->getColumnByID($columnID);
        $maxOrder      = $this->dao->select('MAX(`order`) AS maxOrder')->from(TABLE_KANBANCOLUMN)->where('`group`')->eq($column->group)->fetch('maxOrder');
        $order         = $maxOrder ? $maxOrder + 1 : 1;
        $sumChildLimit = 0;

        $childrenColumn = array();
        foreach($columns as $childColumn)
        {
            $childColumn->parent = $column->id;
            $childColumn->region = $column->region;
            $childColumn->group  = $column->group;
            $childColumn->limit  = $childColumn->noLimit == -1 ? -1 : $childColumn->limit;
            $childColumn->order  = $order;

            $sumChildLimit += $childColumn->limit == -1 ? 0 : $childColumn->limit;
            if(!$this->checkChildColumn($column, $childColumn, $sumChildLimit)) return false;

            $order ++;
            $childrenColumn[] = $childColumn;
        }

        foreach($childrenColumn as $i => $childColumn)
        {
            $this->dao->insert(TABLE_KANBANCOLUMN)->data($childColumn, 'noLimit')
                ->autoCheck()
                ->batchCheck($this->config->kanban->splitcolumn->requiredFields, 'notempty')
                ->exec();

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

            $childColumnID = $this->dao->lastInsertID();
            $this->dao->update(TABLE_KANBANCOLUMN)->set('type')->eq("column{$childColumnID}")->where('id')->eq($childColumnID)->exec();
            $this->action->create('kanbanColumn', $childColumnID, 'created');

            $this->kanbanTao->addChildColumnCell($columnID, $childColumnID, $i);
        }

        $this->dao->update(TABLE_KANBANCOLUMN)->set('parent')->eq(-1)->where('id')->eq($columnID)->exec();
    }

    /**
     * 检查看板列信息是否合法。
     * Check the column information is legal.
     *
     * @param  object $column
     * @param  object $childColumn
     * @param  int    $sumChildLimit
     * @access public
     * @return bool
     */
    public function checkChildColumn($column, $childColumn, $sumChildLimit)
    {
        if(empty($childColumn->name))
        {
            dao::$errors['name'] = sprintf($this->lang->error->notempty, $this->lang->kanbancolumn->name);
            return false;
        }

        if(!preg_match("/^-?\d+$/", (string)$childColumn->limit) || (!$childColumn->noLimit && $childColumn->limit <= 0))
        {
            dao::$errors['limit'] = $this->lang->kanban->error->mustBeInt;
            return false;
        }

        if($column->limit != -1 && ($childColumn->limit == -1 || ($column->limit < $sumChildLimit)))
        {
            dao::$errors['limit'] = $this->lang->kanban->error->parentLimitNote;
            return false;
        }

        return true;
    }

    /**
     * 创建看板卡片。
     * Create a kanban card.
     *
     * @param  int      $columnID
     * @param  object   $card
     * @access public
     * @return bool|int
     */
    public function createCard($columnID, $card)
    {
        if($card->estimate < 0)
        {
            dao::$errors['estimate'] = $this->lang->kanbancard->error->recordMinus;
            return false;
        }

        if($card->end && $card->begin > $card->end)
        {
            dao::$errors['end'] = $this->lang->kanbancard->error->endSmall;
            return false;
        }

        $card = $this->loadModel('file')->processImgURL($card, $this->config->kanban->editor->createcard['id'], $this->post->uid);

        $this->dao->insert(TABLE_KANBANCARD)->data($card)->autoCheck()
            ->checkIF($card->estimate != '', 'estimate', 'float')
            ->batchCheck($this->config->kanban->createcard->requiredFields, 'notempty')
            ->exec();

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

        $cardID = $this->dao->lastInsertID();
        $this->file->saveUpload('kanbancard', $cardID);
        $this->file->updateObjectID($this->post->uid, $cardID, 'kanbancard');
        $this->addKanbanCell((int)$card->kanban, (int)$this->post->lane, $columnID, 'common', (string)$cardID);

        return $cardID;
    }

    /**
     * 转入其它看板的卡片。
     * Import card.
     *
     * @param  int $kanbanID
     * @param  int $regionID
     * @param  int $groupID
     * @param  int $columnID
     * @access public
     * @return array|false
     */
    public function importCard($kanbanID, $regionID, $groupID, $columnID)
    {
        $importIDList = $this->post->cards;
        $targetLaneID = $this->post->targetLane;

        if(!$importIDList || !$targetLaneID) return false;

        $updateData = new stdclass();
        $updateData->kanban = $kanbanID;
        $updateData->region = $regionID;
        $updateData->group  = $groupID;
        $this->dao->update(TABLE_KANBANCARD)->data($updateData)->where('id')->in($importIDList)->exec();

        $kanban         = $this->getByID($kanbanID);
        $oldCardsKanban = array();
        $kanbanUsers    = trim($kanban->owner) . ',' . trim($kanban->team);
        $users          = $this->loadModel('user')->getPairs('noclosed|nodeleted', '', 0, $kanbanUsers);

        $cardList = $this->dao->select('*')->from(TABLE_KANBANCARD)->where('id')->in($importIDList)->fetchAll('id');
        foreach($cardList as $cardID => $card)
        {
            $oldCardsKanban[$cardID] = $card->kanban;
            if(empty($card->assignedTo)) continue;
            $this->kanbanTao->updateCardAssignedTo($cardID, $card->assignedTo, $users);
        }

        if(!dao::isError())
        {
            $this->removeKanbanCell('common', $importIDList, $oldCardsKanban);

            $cards = implode(',', $importIDList);
            $this->addKanbanCell($kanbanID, (int)$targetLaneID, $columnID, 'common', $cards);

            return $importIDList;
        }

        return false;
    }

    /**
     * 转入其它对象作为看板卡片
     * Import object.
     *
     * @param  int    $kanbanID
     * @param  int    $regionID
     * @param  int    $groupID
     * @param  int    $columnID
     * @param  string $objectType
     * @access public
     * @return array|false
     */
    public function importObject($kanbanID, $regionID, $groupID, $columnID, $objectType)
    {
        $objectIDList = $this->post->{$objectType . 's'};
        $targetLaneID = $this->post->targetLane;

        $objectCards = array();
        $now         = helper::now();
        foreach($objectIDList as $objectID)
        {
            $cardData = new stdclass();
            $cardData->kanban      = $kanbanID;
            $cardData->region      = $regionID;
            $cardData->group       = $groupID;
            $cardData->fromID      = $objectID;
            $cardData->fromType    = $objectType;
            $cardData->createdBy   = $this->app->user->account;
            $cardData->createdDate = $now;
            $this->dao->insert(TABLE_KANBANCARD)->data($cardData)->exec();

            $cardID = $this->dao->lastInsertID();
            $objectCards[$cardID] = $objectID;
        }

        if(!dao::isError())
        {
            $cards = implode(',', array_keys($objectCards));
            $this->addKanbanCell($kanbanID, (int)$targetLaneID, $columnID, 'common', $cards);

            return $objectCards;
        }

        return false;
    }

    /**
     * 批量创建卡片。
     * Batch create kanban cards.
     *
     * @param  int    $kanbanID
     * @param  int    $regionID
     * @param  int    $groupID
     * @param  int    $columnID
     * @param  array  $cards
     * @access public
     * @return void
     */
    public function batchCreateCard($kanbanID, $regionID, $groupID, $columnID, $cards)
    {
        $now = helper::now();
        foreach($cards as $i => $card)
        {
            if($card->estimate < 0)
            {
                dao::$errors["estimate[$i]"] = $this->lang->kanbancard->error->recordMinus;
                return false;
            }
            if($card->end && $card->begin > $card->end)
            {
                dao::$errors["end[$i]"] = $this->lang->kanbancard->error->endSmall;
                return false;
            }
        }

        foreach($cards as $card)
        {
            $card->kanban       = $kanbanID;
            $card->region       = $regionID;
            $card->group        = $groupID;
            $card->createdBy    = $this->app->user->account;
            $card->createdDate  = $now;
            $card->assignedDate = $now;
            $card->color        = '#fff';

            $this->dao->insert(TABLE_KANBANCARD)->data($card, 'lane')->autoCheck()
                ->checkIF($card->estimate != '', 'estimate', 'float')
                ->batchCheck($this->config->kanban->createcard->requiredFields, 'notempty')
                ->exec();

            if(!dao::isError())
            {
                $cardID = $this->dao->lastInsertID();
                $this->addKanbanCell($kanbanID, $card->lane, $columnID, 'common', (string)$cardID);
                $this->loadModel('action')->create('kanbancard', $cardID, 'created');
            }
        }
    }

    /**
     * 获取看板数据。
     * Get kanban by id.
     *
     * @param  int    $kanbanID
     * @access public
     * @return object
     */
    public function getByID($kanbanID)
    {
        $kanban = $this->dao->findByID($kanbanID)->from(TABLE_KANBAN)->fetch();
        $kanban = $this->loadModel('file')->replaceImgURL($kanban, 'desc');

        return $kanban;
    }

    /**
     * 获取看板键值对。
     * Get kanban pairs.
     *
     * @access public
     * @return array
     */
    public function getPairs()
    {
        $idList = $this->getCanViewObjects();
        return $this->dao->select('id,name')->from(TABLE_KANBAN)
            ->where('id')->in($idList)
            ->andWhere('deleted')->eq('0')
            ->fetchPairs();
    }

    /**
     * 获取看板所有数据。
     * Get kanban data.
     *
     * @param  int    $kanbanID
     * @param  mixed  $regionIDList
     * @access public
     * @return array
     */
    public function getKanbanData($kanbanID, $regionIDList = '')
    {
        $regions = $this->getRegionPairs($kanbanID);

        if(empty($regionIDList))
        {
            $regionIDList = array_keys($regions);
        }
        elseif(!is_array($regionIDList))
        {
            $regionIDList = array($regionIDList);
        }

        $groupGroup  = $this->getGroupGroupByRegions($regionIDList);
        $laneGroup   = $this->getLaneGroupByRegions($regionIDList);
        $columnGroup = $this->getColumnGroupByRegions($regionIDList);
        $cardGroup   = $this->getCardGroupByKanban($kanbanID);

        $kanbanList = array();
        foreach($regionIDList as $regionID)
        {
            $regionData = array();

            $heading = new stdclass();
            $heading->title       = new stdClass();
            $heading->title->html = zget($regions, $regionID, '') . '<span class="icon icon-angle-top"></span>';
            $heading->actions     = $this->getRegionActions($kanbanID, $regionID, count($regions));

            $regionData['key']               = "region{$regionID}";
            $regionData['id']                = $regionID;
            $regionData['heading']           = $heading;
            $regionData['toggleFromHeading'] = true;

            $groups = zget($groupGroup, $regionID, array());
            $kanbanList[] = $this->kanbanTao->buildRegionData($regionData, $groups, $laneGroup, $columnGroup, $cardGroup);
        }

        return $kanbanList;
    }

    /**
     * 获取看板区域上的操作按钮。
     * Get region actions.
     *
     * @param  int        $kanbanID
     * @param  int|string $regionID
     * @param  int        $regionCount
     * @access public
     * @return array
     */
    public function getRegionActions($kanbanID, $regionID, $regionCount)
    {
        $action  = array();
        $actions = array();

        $action['type']  = 'dropdown';
        $action['icon']  = 'ellipsis-v';
        $action['caret'] = false;
        $action['items'] = array();

        if(common::hasPriv('kanban', 'createRegion')) $action['items'][] = array('text' => $this->lang->kanban->createRegion, 'url' => helper::createLink('kanban', 'createRegion', "kanbanID=$kanbanID"), 'data-toggle' => 'modal', 'icon' => 'plus');
        if(common::hasPriv('kanban', 'editRegion'))   $action['items'][] = array('text' => $this->lang->kanban->editRegion,   'url' => helper::createLink('kanban', 'editRegion', "regionID=$regionID"), 'data-toggle' => 'modal', 'icon' => 'edit');
        if(common::hasPriv('kanban', 'sortRegion'))   $action['items'][] = array('text' => $this->lang->kanban->sortRegion,   'url' => 'javascript:;', 'icon' => 'move', 'data-on' => 'click', 'data-call' => 'sortItems', 'data-params' => 'event', 'data-type' => 'region', 'data-id' => $regionID);
        if(common::hasPriv('kanban', 'createLane'))   $action['items'][] = array('text' => $this->lang->kanban->createLane,   'url' => helper::createLink('kanban', 'createLane', "kanbanID=$kanbanID&regionID=$regionID"), 'data-toggle' => 'modal', 'icon' => 'plus');
        if(common::hasPriv('kanban', 'deleteRegion') and ($regionCount > 1)) $action['items'][] = array('text' => $this->lang->kanban->deleteRegion, 'url' => helper::createLink('kanban', 'deleteRegion', "regionID=$regionID"), 'data-confirm' => $this->lang->kanbanregion->confirmDelete, 'icon' => 'trash', 'innerClass' => 'ajax-submit');

        $action['items'][] = array('type' => 'divider');

        if(commonModel::hasPriv('kanban', 'viewArchivedCard'))   $action['items'][] = array('text' => $this->lang->kanban->viewArchivedCard,   'url' => "javascript:loadMore(\"Card\", $regionID)", 'icon' => 'card-archive');
        if(commonModel::hasPriv('kanban', 'viewArchivedColumn')) $action['items'][] = array('text' => $this->lang->kanban->viewArchivedColumn, 'url' => "javascript:loadMore(\"Column\", $regionID)", 'icon' => 'col-archive');

        $actions[] = $action;

        return $actions;
    }

    /**
     * 获取专业研发看板区域上的操作按钮。
     * Get RD region actions.
     *
     * @param  int    $kanbanID
     * @param  int    $regionID
     * @param  int    $regionCount
     * @access public
     * @return array
     */
    public function getRDRegionActions($kanbanID, $regionID, $regionCount = 1)
    {
        $action  = array();
        $actions = array();

        $action['type']  = 'dropdown';
        $action['icon']  = 'ellipsis-v';
        $action['caret'] = false;
        $action['items'] = array();

        if(common::hasPriv('kanban', 'createRegion'))                       $action['items'][] = array('text' => $this->lang->kanban->createRegion, 'url' => helper::createLink('kanban', 'createRegion', "kanbanID=$kanbanID&from=execution"), 'data-toggle' => 'modal', 'icon' => 'plus', 'class' => 'kanban-createRegion-btn');
        if(common::hasPriv('kanban', 'editRegion'))                         $action['items'][] = array('text' => $this->lang->kanban->editRegion,   'url' => helper::createLink('kanban', 'editRegion', "regionID=$regionID"), 'data-toggle' => 'modal', 'icon' => 'edit');
        if(common::hasPriv('kanban', 'createLane'))                         $action['items'][] = array('text' => $this->lang->kanban->createLane,   'url' => helper::createLink('kanban', 'createLane', "kanbanID=$kanbanID&regionID=$regionID&from=execution"), 'data-toggle' => 'modal', 'icon' => 'plus');
        if(common::hasPriv('kanban', 'deleteRegion') && ($regionCount > 1)) $action['items'][] = array('text' => $this->lang->kanban->deleteRegion, 'url' => helper::createLink('kanban', 'deleteRegion', "regionID=$regionID"), 'data-confirm' => $this->lang->kanbanregion->confirmDelete, 'icon' => 'trash', 'innerClass' => 'ajax-submit');

        $actions[] = $action;
        return $actions;
    }

    /**
     * 获取计划看板。
     * Get plan kanban.
     *
     * @param  object $product
     * @param  string $branchID
     * @param  array  $planGroup
     * @access public
     * @return array
     */
    public function getPlanKanban($product, $branchID = '0', $planGroup = array())
    {
        $this->loadModel('productplan');

        $lanes = $columns = $kanbanData = $planList = $lanes = $columns = $columnCards = array();
        $colorIndex = 0;
        $laneOrder  = 1;

        $cardActions = array('view', 'createExecution', 'linkStory', 'linkBug', 'edit', 'start', 'finish', 'close', 'activate', 'delete');
        $branches    = $this->kanbanTao->getBranchesForPlanKanban($product, $branchID);
        foreach($branches as $id => $name)
        {
            if($product->type != 'normal') $plans = isset($planGroup[$product->id][$id]) ? array_filter($planGroup[$product->id][$id]) : array();
            if($product->type == 'normal') $plans = $planGroup;

            foreach($plans as $plan)
            {
                if(empty($plan) || $plan->parent == -1) continue;
                $plan->isParent = false;

                $planBegin = $plan->begin == $this->config->productplan->future ? $this->lang->productplan->future : date('m-d', strtotime($plan->begin));
                $planEnd   = $plan->end == $this->config->productplan->future ? $this->lang->productplan->future : date('m-d', strtotime($plan->end));

                $item = array('id' => $plan->id, 'name' => $plan->id, 'title' => htmlspecialchars_decode($plan->title), 'status' => $plan->status);
                $item['statusLabel'] = zget($this->lang->productplan->statusList, $plan->status);
                $item['delay']       = helper::today() > $plan->end ? true : false;
                $item['desc']        = strip_tags(htmlspecialchars_decode($plan->desc));
                $item['dateLine']    = in_array($this->lang->productplan->future, array($planBegin, $planEnd)) ? $this->lang->productplan->future : $planBegin . ' ' . $this->lang->productplan->to . ' ' . $planEnd;
                $item['actionList']  = array();
                foreach($cardActions as $action)
                {
                    if($this->productplan->isClickable($plan, $action)) $item['actionList'][] = $action;
                }
                $planList['lane' . $id][$plan->status][] = $item;

                if(!isset($columnCards[$plan->status])) $columnCards[$plan->status] = 0;
                $columnCards[$plan->status] ++;
            }

            $lanes[] = array('id' => $id, 'name' => 'lane' . $id, 'title' => $name, 'color' => $this->config->productplan->laneColorList[$colorIndex], 'order' => $laneOrder);
            $laneOrder ++;
            $colorIndex ++;
            if($colorIndex == count($this->config->productplan->laneColorList)) $colorIndex = 0;
        }

        foreach($this->lang->kanban->defaultColumn as $columnType => $columnName) $columns[] = array('id' => $columnType, 'name' => $columnType, 'title' => $columnName);
        foreach($columns as $key => $column) $columns[$key]['cards'] = !empty($columnCards[$column['name']]) ? $columnCards[$column['name']] : 0;

        $groupData['key']  = 'planKanban';
        $groupData['data'] = array('lanes' => $lanes, 'cols' => $columns, 'items' => $planList);
        $kanbanData[] = array('items' => array($groupData), 'key' => 'planKanban');
        return $kanbanData;
    }

    /**
     * 获取专业研发看板。
     * Get a RD kanban data.
     *
     * @param  int    $executionID
     * @param  string $browseType all|story|task|bug
     * @param  string $orderBy
     * @param  int    $regionID
     * @param  string $groupBy
     * @param  string $searchValue
     * @access public
     * @return array
     */
    public function getRDKanban($executionID, $browseType = 'all', $orderBy = 'id_desc', $regionID = 0, $groupBy = 'default', $searchValue = '')
    {
        $kanbanList = array();
        $execution  = $this->loadModel('execution')->getByID($executionID);

        if($groupBy != 'default' && $groupBy != '') return $this->getRDKanbanByGroup($execution, $browseType, $orderBy, $regionID, $groupBy, $searchValue);

        $regions      = $this->getRegionPairs($executionID, $regionID, 'execution');
        $regionIDList = $regionID == 0 ? array_keys($regions) : array(0 => $regionID);
        $groupGroup   = $this->getGroupGroupByRegions($regionIDList);
        $laneGroup    = $this->getLaneGroupByRegions($regionIDList, $browseType);

        foreach($laneGroup as $lanes)
        {
            foreach($lanes as $lane)
            {
                $lane['execution'] = $executionID;
                if(in_array($execution->attribute, array('request', 'design', 'review')) && $lane['type'] == 'bug') continue 2;
                if(in_array($execution->attribute, array('request', 'review')) && $lane['type'] == 'story') continue 2;
                $this->refreshCards((array)$lane);
                $lane['defaultCardType'] = $lane['type'];
            }
        }

        $columnGroup = $this->getRDColumnGroupByRegions($regionIDList, array_keys($laneGroup));
        $cardGroup   = $this->getCardGroupByExecution($executionID, $browseType, $orderBy, $searchValue);

        foreach($regions as $regionID => $regionName)
        {
            $regionData = array();

            $heading              = new stdclass();
            $heading->title       = new stdClass();
            $heading->title->html = $regionName . '<span class="icon icon-angle-top"></span>';
            $heading->actions     = $this->getRDRegionActions($executionID, $regionID, count($regions));

            $regionData['key']               = "region{$regionID}";
            $regionData['id']                = $regionID;
            $regionData['heading']           = $heading;
            $regionData['toggleFromHeading'] = true;
            $regionData['items']             = array();

            $groups = zget($groupGroup, $regionID, array());

            $regionData = $this->kanbanTao->buildRDRegionData($regionData, $groups, $laneGroup, $columnGroup, $cardGroup, $searchValue);

            $kanbanList[] = $regionData;
        }

        return $kanbanList;
    }

    /**
     * 获取专业研发看板分组视图下的看板数据。
     * Get kanban data for group view of RD kanban.
     *
     * @param  object  $execution
     * @param  string  $browseType
     * @param  string  $groupBy
     * @param  string  $searchValue
     * @param  string  $orderBy
     * @access private
     * @return array
     * @param int $regionID
     */
    private function getRDKanbanByGroup($execution, $browseType, $orderBy, $regionID, $groupBy, $searchValue)
    {
        $regionData = array();
        $heading          = new stdclass();
        $heading->title   = $execution->name;
        $heading->actions = $this->getRDRegionActions($execution->id, $regionID, 1);

        $regionData['key']               = "region{$execution->id}";
        $regionData['id']                = $execution->id;
        $regionData['heading']           = $heading;
        $regionData['toggleFromHeading'] = true;
        $regionData['items']             = $this->getKanban4Group($execution->id, $browseType, $groupBy, $searchValue, $orderBy);

        $kanbanList[] = $regionData;
        return $kanbanList;
    }

    /**
     * 获取看板区域。
     * Get region by id.
     *
     * @param  int    $regionID
     * @access public
     * @return object
     */
    public function getRegionByID($regionID)
    {
        return $this->dao->findByID($regionID)->from(TABLE_KANBANREGION)->fetch();
    }

    /**
     * 获取看板区域的键值对。
     * Get ordered region pairs.
     *
     * @param  int    $kanbanID
     * @param  int    $regionID
     * @param  string $from kanban|execution
     * @access public
     * @return array
     */
    public function getRegionPairs($kanbanID, $regionID = 0, $from = 'kanban')
    {
        if(common::isTutorialMode()) return $this->loadModel('tutorial')->getRegionPairs();

        return $this->dao->select('id,name')->from(TABLE_KANBANREGION)
            ->where('kanban')->eq($kanbanID)
            ->andWhere('deleted')->eq('0')
            ->beginIF($regionID)->andWhere('id')->eq($regionID)->fi()
            ->beginIF($from == 'execution')->andWhere('space')->eq(0)->fi()
            ->beginIF($from == 'kanban')->andWhere('space')->ne(0)->fi()
            ->orderBy('order_asc')
            ->fetchPairs();
    }

    /**
     * 根据区域获取看板ID。
     * Get kanban id by region id.
     *
     * @param  int    $regionID
     * @access public
     * @return int
     */
    public function getKanbanIDByRegion($regionID)
    {
        return (int)$this->dao->select('kanban')->from(TABLE_KANBANREGION)->where('id')->eq($regionID)->fetch('kanban');
    }

    /**
     * 分组获取看板组。
     * Get kanban group by regions.
     *
     * @param  array $regions
     * @access public
     * @return array
     */
    public function getGroupGroupByRegions($regions)
    {
        if(common::isTutorialMode()) return $this->loadModel('tutorial')->getGroups();

        return $this->dao->select('*')->from(TABLE_KANBANGROUP)
            ->where('region')->in($regions)
            ->orderBy('order')
            ->fetchGroup('region', 'id');
    }

    /**
     * 获取区域中的泳道分组。
     * Get lane group by regions.
     *
     * @param  array  $regions
     * @param  string $browseType
     * @access public
     * @return array
     */
    public function getLaneGroupByRegions($regions, $browseType = 'all')
    {
        if(common::isTutorialMode()) return $this->loadModel('tutorial')->getLaneGroup();

        $lanes = $this->dao->select('*')->from(TABLE_KANBANLANE)
            ->where('deleted')->eq('0')
            ->andWhere('region')->in($regions)
            ->beginIf($browseType != 'all')->andWhere('type')->eq($browseType)->fi()
            ->orderBy('order_asc')
            ->fetchAll();

        $actions = array('sortLane', 'deleteLane', 'editLaneName', 'editLaneColor');
        $laneGroup = array();
        foreach($lanes as $lane)
        {
            $item = array();
            $item['id']        = $lane->id;
            $item['execution'] = $lane->execution;
            $item['type']      = $lane->type;
            $item['name']      = $lane->id;
            $item['region']    = $lane->region;
            $item['title']     = htmlspecialchars_decode($lane->name);
            $item['color']     = $lane->color;
            $item['order']     = $lane->order;

            foreach($actions as $action)
            {
                if($this->isClickable($lane, $action)) $item['actionList'][] = $action;
            }

            $laneGroup[$lane->group][] = $item;
        }

        return $laneGroup;
    }

    /**
     * 根据分组ID获取泳道键值对。
     * Get kanban lane pairs by group id.
     *
     * @param  int    $groupID
     * @param  string $orderBy
     * @access public
     * @return array
     */
    public function getLanePairsByGroup($groupID, $orderBy = '`order`_asc')
    {
        return $this->dao->select('id,name')->from(TABLE_KANBANLANE)
            ->where('deleted')->eq(0)
            ->andWhere('`group`')->eq($groupID)
            ->orderBy($orderBy)
            ->fetchPairs();
    }

    /**
     * 根据分组ID获取看板列键值对。
     * Get kanban column pairs by group id.
     *
     * @param  int    $groupID
     * @param  int    $parentID
     * @param  string $orderBy
     * @access public
     * @return array
     */
    public function getColumnPairsByGroup($groupID, $parentID = 0, $orderBy = '`order`_asc')
    {
        return $this->dao->select('id,name')->from(TABLE_KANBANCOLUMN)
            ->where('deleted')->eq(0)
            ->andWhere('`group`')->eq($groupID)
            ->beginIf($parentID > 0)->andWhere('parent')->eq($parentID)->fi()
            ->beginIf($parentID <= 0)->andWhere('parent')->le(0)->fi()
            ->orderBy($orderBy)
            ->fetchPairs();
    }

    /**
     * Get column group by regions.
     *
     * @param  array  $regions
     * @param  string $order order|id_asc
     * @param  string $param
     * @access public
     * @return array
     */
    public function getColumnGroupByRegions($regions, $order = '`order`', $param = '')
    {
        $columns = $this->dao->select("*")->from(TABLE_KANBANCOLUMN)
            ->where('deleted')->eq('0')
            ->andWhere('region')->in($regions)
            ->beginIF(strpos(",$param,", ',withArchived,') === false)->andWhere('archived')->eq('0')->fi()
            ->orderBy($order)
            ->fetchAll();

        $actions = array('createColumn', 'setColumn', 'setWIP', 'sortColumn', 'archiveColumn', 'restoreColumn', 'deleteColumn', 'createCard', 'batchCreateCard', 'splitColumn', 'sortColumn');

        /* Group by parent. */
        $columnGroup = array();
        foreach($columns as $column)
        {
            $item = array();
            $item['title']  = htmlspecialchars_decode($column->name);
            $item['name']   = $column->id;
            $item['id']     = $column->id;
            $item['type']   = $column->type;
            $item['limit']  = $column->limit;
            $item['region'] = $column->region;
            $item['group']  = $column->group;
            $item['parent'] = $column->parent;
            $item['color']  = $column->color;
            $item['order']  = (int)$column->order;
            if($column->parent > 0) $item['parentName'] = $column->parent;

            /* Judge column action priv. */
            foreach($actions as $action)
            {
                if($this->isClickable($column, $action)) $item['actionList'][] = $action;
            }

            $columnGroup[$column->group][] = $item;
        }

        return $columnGroup;
    }

    /**
     * 获取看板下的卡片。
     * Get card group by kanban id.
     *
     * @param  int    $kanbanID
     * @access public
     * @return array
     */
    public function getCardGroupByKanban($kanbanID)
    {
        /* Get card data.*/
        $cards = $this->dao->select('*')->from(TABLE_KANBANCARD)
            ->where('deleted')->eq(0)
            ->andWhere('kanban')->eq($kanbanID)
            ->andWhere('archived')->eq(0)
            ->andWhere('fromID')->eq(0)
            ->fetchAll('id');

        foreach($this->config->kanban->fromType as $fromType) $cards = $this->getImportedCards($kanbanID, $cards, $fromType);

        $cellList = $this->dao->select('*')->from(TABLE_KANBANCELL)
            ->where('kanban')->eq($kanbanID)
            ->andWhere('type')->eq('common')
            ->fetchAll();

        $actions     = array('editCard', 'archiveCard', 'deleteCard', 'moveCard', 'setCardColor', 'viewCard', 'sortCard', 'viewExecution', 'viewPlan', 'viewRelease', 'viewBuild', 'viewTicket', 'activateCard', 'finishCard');
        $cardGroup   = array();
        $avatarPairs = $this->loadModel('user')->getAvatarPairs();
        $users       = $this->loadModel('user')->getPairs('noletter');
        foreach($cellList as $cell)
        {
            $cardIdList = array_filter(explode(',', $cell->cards));
            if(empty($cardIdList)) continue;

            $order = 0;
            foreach($cardIdList as $cardID)
            {
                if(!isset($cards[$cardID])) continue;

                $card = zget($cards, $cardID);
                $item = $this->kanbanTao->initCardItem($card, $cell, $order, $avatarPairs, $users);

                $order ++;

                foreach($actions as $action)
                {
                    if(in_array($action, array('viewExecution', 'viewPlan', 'viewRelease', 'viewBuild', 'viewTicket')))
                    {
                        if($card->fromType == 'execution')
                        {
                            if($card->execType == 'kanban' && common::hasPriv('execution', 'kanban')) $item['actionList'][] = $action;
                            if($card->execType != 'kanban' && common::hasPriv('execution', 'view'))   $item['actionList'][] = $action;
                        }
                        else
                        {
                            if(common::hasPriv($fromType, 'view')) $item['actionList'][] = $action;
                        }
                        continue;
                    }
                    if(common::hasPriv('kanban', $action)) $item['actionList'][] = $action;
                }

                $cardGroup[$card->group][$cell->lane][$cell->column][] = $item;
            }
        }

        return $cardGroup;
    }

    /**
     * 获取已经导入的卡片。
     * Get imported cards.
     *
     * @param  int    $kanbanID
     * @param  array  $cards
     * @param string $fromType
     * @param  int    $archived
     * @param  int    $regionID
     * @access public
     * @return array
     */
    public function getImportedCards($kanbanID, $cards, $fromType, $archived = 0, $regionID = 0)
    {
        /* Get imported cards based on imported object type. */
        $objectCards = $this->dao->select('*')->from(TABLE_KANBANCARD)
            ->where('deleted')->eq(0)
            ->andWhere('kanban')->eq($kanbanID)
            ->andWhere('archived')->eq($archived)
            ->andWhere('fromType')->eq($fromType)
            ->beginIF($regionID)->andWhere('region')->eq($regionID)->fi()
            ->fetchGroup('fromID', 'id');

        if(!empty($objectCards))
        {
            /* Get imported objects. */
            $table   = $this->config->objectTables[$fromType];
            $objects = $this->dao->select('*')->from($table)
                ->where('id')->in(array_keys($objectCards))
                ->fetchAll('id');

            $creators = array();
            if($fromType == 'productplan' || $fromType == 'release')
            {
                $creators = $this->dao->select('objectID, actor')->from(TABLE_ACTION)
                    ->where('objectID')->in(array_keys($objectCards))
                    ->andWhere('objectType')->eq($fromType)
                    ->andWhere('action')->eq('opened')
                    ->fetchPairs();
            }

            /* Data for constructing the card. */
            foreach($objectCards as $objectID => $cardsInfo)
            {
                foreach($cardsInfo as $cardID => $objectCard)
                {
                    $object    = $objects[$objectID];
                    $fieldType = $fromType . 'Field';

                    if($fromType == 'execution') $objectCard->progress = $object->progress;

                    foreach($this->config->kanban->$fieldType as $field) $objectCard->$field = $object->$field;

                    $objectCard = $this->kanbanTao->buildObjectCard($objectCard, $object, $fromType, $creators);
                    $cards[$cardID] = $objectCard;
                }
            }
        }
        return $cards;
    }

    /**
     * 获取专业研发看板的列。
     * Get RD column group by regions.
     *
     * @param  array  $regions
     * @param  array  $groupIDList
     * @access public
     * @return array
     */
    public function getRDColumnGroupByRegions($regions, $groupIDList = array())
    {
        if(common::isTutorialMode()) return $this->loadModel('tutorial')->getColumns();

        $columnGroup = $this->dao->select("*")->from(TABLE_KANBANCOLUMN)
            ->where('deleted')->eq('0')
            ->andWhere('region')->in($regions)
            ->beginIF(!empty($groupIDList))->andWhere('`group`')->in($groupIDList)->fi()
            ->orderBy('id_asc')
            ->fetchGroup('group');

        $actions = array('setColumn', 'setWIP', 'deleteColumn');

        /* Group by parent. */
        $columnData = array();
        foreach($columnGroup as $group => $columns)
        {
            foreach($columns as $column)
            {
                $item = array();
                $item['title']  = htmlspecialchars_decode($column->name);
                $item['name']   = $column->id;
                $item['id']     = $column->id;
                $item['type']   = $column->type;
                $item['limit']  = $column->limit;
                $item['region'] = $column->region;
                $item['group']  = $column->group;
                $item['parent'] = $column->parent;
                $item['color']  = $column->color;
                if($column->parent > 0) $item['parentName'] = $column->parent;

                /* Judge column action priv. */
                foreach($actions as $action)
                {
                    if($this->isClickable($column, $action)) $item['actionList'][] = $action;
                }

                $columnData[$group][] = $item;
            }
        }

        return $columnData;
    }

    /**
     * 获取执行中的看板卡片分组。
     * Get card group by execution id.
     *
     * @param  int    $kanbanID
     * @param  string $browseType all|task|bug|story
     * @param  string $orderBy
     * @param  string $searchValue
     *
     * @access public
     * @return array
     * @param int $executionID
     */
    public function getCardGroupByExecution($executionID, $browseType = 'all', $orderBy = 'id_asc', $searchValue = '')
    {
        if(common::isTutorialMode()) return $this->loadModel('tutorial')->getCardGroup();

        $cards = $this->dao->select('t1.*, t2.type as columnType, t2.group')
            ->from(TABLE_KANBANCELL)->alias('t1')
            ->leftJoin(TABLE_KANBANCOLUMN)->alias('t2')->on('t1.column=t2.id')
            ->where('t1.kanban')->eq($executionID)
            ->beginIF($browseType != 'all')->andWhere('t1.type')->eq($browseType)->fi()
            ->orderby($orderBy)
            ->fetchgroup('lane', 'column');

        /* Get group objects. */
        if($browseType == 'all' || $browseType == 'parentStory') $objectGroup['parentStory'] = $this->loadModel('story')->getExecutionStories($executionID, 0, 't1.`order`_desc', 'allStory');
        if($browseType == 'all' || $browseType == 'story')       $objectGroup['story']       = $this->loadModel('story')->getExecutionStories($executionID, 0, 't1.`order`_desc', 'allStory');
        if($browseType == 'all' || $browseType == 'bug')         $objectGroup['bug']         = $this->loadModel('bug')->getExecutionBugs($executionID);
        if($browseType == 'all' || $browseType == 'task')        $objectGroup['task']        = $this->loadModel('execution')->getKanbanTasks($executionID, "id");

        $cardGroup = array();

        $avatarPairs = $this->loadModel('user')->getAvatarPairs();
        $users       = $this->loadModel('user')->getPairs('noletter');
        foreach($cards as $laneID => $cells)
        {
            foreach($cells as $cell)
            {
                $order = 0;
                $cardIdList = array_filter(explode(',', $cell->cards));
                foreach($cardIdList as $cardID)
                {
                    $cardData = array();
                    $objects  = zget($objectGroup, $cell->type, array());
                    $object   = zget($objects, $cardID, array());

                    if(empty($object)) continue;
                    if($cell->type == 'parentStory' && $object->isParent != '1') continue;
                    if($cell->type == 'story'       && $object->isParent == '1') continue;

                    $cardData = $this->kanbanTao->initCardItem($object, $cell, $order, $avatarPairs, $users);
                    $cardData['acl'] = 'open';
                    $order ++;

                    if($searchValue != '' && strpos($cardData['title'], $searchValue) === false) continue;
                    $cardGroup[$cell->group][$laneID][$cell->column][] = $cardData;
                }
            }
        }

        return $cardGroup;
    }

    /**
     * 获取执行看板的数据。
     * Get Kanban by execution id.
     *
     * @param  int    $executionID
     * @param  string $browseType  all|story|bug|task
     * @param  string $groupBy     default|pri|category|module|source|assignedTo|type|story|severity
     * @param  string $searchValue
     * @param  string $orderBy
     * @access public
     * @return array
     */
    public function getExecutionKanban($executionID, $browseType = 'all', $groupBy = 'default', $searchValue = '', $orderBy = 'id_asc')
    {
        if($groupBy != 'default') return array($this->getKanban4Group($executionID, $browseType, $groupBy, $searchValue, $orderBy), array());

        $execution = $this->loadModel('execution')->fetchByID($executionID);
        $lanes     = $this->dao->select('*')->from(TABLE_KANBANLANE)
            ->where('execution')->eq($executionID)
            ->andWhere('deleted')->eq(0)
            ->beginIF($browseType != 'all')->andWhere('type')->eq($browseType)->fi()
            ->beginIF(!empty($execution->attribute) && !in_array($execution->attribute, array('mix', 'request', 'design')))->andWhere('type')->notIn('epic,requirement')->fi()
            ->orderBy('order_asc')
            ->fetchAll('id');

        if(empty($lanes)) return array();

        foreach($lanes as $lane) $this->refreshCards((array)$lane);

        $columns = $this->dao->select('t1.cards, t1.lane, t2.id, t2.type, t2.name, t2.color, t2.limit, t2.parent')->from(TABLE_KANBANCELL)->alias('t1')
            ->leftJoin(TABLE_KANBANCOLUMN)->alias('t2')->on('t1.column = t2.id')
            ->where('t2.deleted')->eq(0)
            ->andWhere('t1.lane')->in(array_keys($lanes))
            ->orderBy('id_asc')
            ->fetchGroup('lane', 'id');

        $geMax = in_array($this->config->edition, array('max', 'ipd'));
        /* Get group objects. */
        if($browseType == 'all' || $browseType == 'epic')             $objectGroup['epic']        = $this->loadModel('story')->getExecutionStories($executionID, 0, 't1.`order`_desc', 'allStory', '0', 'epic');
        if($browseType == 'all' || $browseType == 'requirement')      $objectGroup['requirement'] = $this->loadModel('story')->getExecutionStories($executionID, 0, 't1.`order`_desc', 'allStory', '0', 'requirement');
        if($browseType == 'all' || $browseType == 'parentStory')      $objectGroup['parentStory'] = $this->loadModel('story')->getExecutionStories($executionID, 0, 't1.`order`_desc', 'allStory');
        if($browseType == 'all' || $browseType == 'story')            $objectGroup['story']       = $this->loadModel('story')->getExecutionStories($executionID, 0, 't1.`order`_desc', 'allStory');
        if($browseType == 'all' || $browseType == 'bug')              $objectGroup['bug']         = $this->loadModel('bug')->getExecutionBugs($executionID);
        if($browseType == 'all' || $browseType == 'task')             $objectGroup['task']        = $this->loadModel('execution')->getKanbanTasks($executionID, "id");
        if($geMax && ($browseType == 'all' || $browseType == 'risk')) $objectGroup['risk']        = $this->loadModel('risk')->getKanbanRisks($executionID);

        /* Get objects cards menus. */
        $menus = array();
        $menus['epic']        = $browseType == 'all' || $browseType == 'epic'        ? $this->getKanbanCardMenu($executionID, $objectGroup['epic'], 'epic') : array();
        $menus['requirement'] = $browseType == 'all' || $browseType == 'requirement' ? $this->getKanbanCardMenu($executionID, $objectGroup['requirement'], 'requirement') : array();
        $menus['parentStory'] = $browseType == 'all' || $browseType == 'parentStory' ? $this->getKanbanCardMenu($executionID, $objectGroup['parentStory'], 'parentStory') : array();
        $menus['story']       = $browseType == 'all' || $browseType == 'story'       ? $this->getKanbanCardMenu($executionID, $objectGroup['story'], 'story') : array();
        $menus['bug']         = $browseType == 'all' || $browseType == 'bug'         ? $this->getKanbanCardMenu($executionID, $objectGroup['bug'], 'bug')     : array();
        $menus['task']        = $browseType == 'all' || $browseType == 'task'        ? $this->getKanbanCardMenu($executionID, $objectGroup['task'], 'task')   : array();
        $menus['risk']        = $geMax && ($browseType == 'all' || $browseType == 'risk') ? $this->getKanbanCardMenu($executionID, $objectGroup['risk'], 'risk')   : array();

        /* 获取看板连线的fromKanbanID. */
        $fromKanbanID = '';
        foreach($lanes as $lane)
        {
            if($lane->type == 'parentStory') $fromKanbanID = 'group' . $lane->id;
        }

        /* Build kanban group data. */
        $kanbanGroup  = array();
        $links        = array();
        $execution    = $this->loadModel('execution')->fetchById($executionID);
        $project      = $this->loadModel('project')->fetchById($execution->project);
        foreach($lanes as $lane)
        {
            $laneType = $lane->type;
            if(in_array($laneType, array('epic', 'requirement')) && strpos($project->storyType, $laneType) === false) continue;
            list($laneData, $columnData, $cardsData) = $this->buildExecutionGroup($lane, $columns, $objectGroup, $searchValue, $menus);

            if($lane->type == 'risk') $cardsData = $this->appendRiskField($cardsData, $objectGroup['risk']);
            $kanbanID = 'group' . $lane->id;

            $kanbanGroup[$laneType]['id']   = $lane->id;
            $kanbanGroup[$laneType]['key']  = $kanbanID;
            $kanbanGroup[$laneType]['data'] = array();
            $kanbanGroup[$laneType]['data']['lanes'] = array($laneData);
            $kanbanGroup[$laneType]['data']['cols']  = $columnData;
            $kanbanGroup[$laneType]['data']['items'] = $cardsData;

            foreach($cardsData as $columnCard)
            {
                foreach($columnCard as $cards)
                {
                    foreach($cards as $card)
                    {
                        if($card['parent'] == 0) continue;
                        /* 获取看板卡片的连线关系，业需、用需和父需求共用同一组看板列，所以fromKanban和toKanban是一样的。 */
                        $link = array('from' => $card['parent'], 'to' => $card['id'], 'fromKanban' => $fromKanbanID, 'toKanban' => $kanbanID);
                        if(in_array($laneType, array('epic', 'requirement'))) $link['toKanban'] = $fromKanbanID;

                        $links[] = $link;
                    }
                }
            }
        }

        /* 展示所有泳道的时候，业务、用需与父需求泳道共用看板列。 */
        if($browseType == 'all')
        {
            foreach(array('requirement', 'epic') as $laneType)
            {
                if(!isset($kanbanGroup[$laneType])) continue;

                array_unshift($kanbanGroup['parentStory']['data']['lanes'], $kanbanGroup[$laneType]['data']['lanes'][0]);
                $kanbanGroup['parentStory']['data']['items'] += $kanbanGroup[$laneType]['data']['items'];
                unset($kanbanGroup[$laneType]);
            }
        }

        return array(array_values($kanbanGroup), $links);
    }

    /**
     * 构建迭代看板的泳道组数据。
     * Build the laneGroup data for the execution Kanban.
     *
     * @param  object $lane
     * @param  array  $columns
     * @param  array  $objectGroup
     * @param  string $searchValue
     * @param  array  $menus        array(story => array, bug => array, task => array)
     * @access public
     * @return array
     */
    public function buildExecutionGroup($lane, $columns, $objectGroup, $searchValue = '', $menus = array())
    {
        $laneData    = array();
        $columnsData = array();
        $cardsData   = array();

        $laneData['id']     = $lane->id;
        $laneData['type']   = $lane->type;
        $laneData['name']   = $lane->id;
        $laneData['region'] = $lane->region;
        $laneData['title']  = $lane->name;
        $laneData['color']  = $lane->color;

        foreach($columns[$lane->id] as $columnID => $column)
        {
            $cardIdList = array_unique(array_filter(explode(',', $column->cards)));

            $columnsData[$column->id]['id']         = $columnID;
            $columnsData[$column->id]['type']       = $column->type;
            $columnsData[$column->id]['name']       = $columnID;
            $columnsData[$column->id]['title']      = $column->name;
            $columnsData[$column->id]['color']      = $column->color;
            $columnsData[$column->id]['limit']      = $column->limit;
            $columnsData[$column->id]['region']     = $lane->region;
            $columnsData[$column->id]['laneName']   = $column->lane;
            $columnsData[$column->id]['group']      = $lane->type;
            $columnsData[$column->id]['cards']      = 0;
            $columnsData[$column->id]['actionList'] = array('setColumn', 'setWIP');

            if($column->parent > 0) $columnsData[$column->id]['parentName'] = $column->parent;
            if($cardIdList)
            {
                $cardsData = $this->buildExecutionCards($cardsData, $column, $lane->type, $cardIdList, $objectGroup, $searchValue, $menus);
                $columnsData[$column->id]['cards'] = empty($cardsData[$column->lane][$column->id]) ? 0 : count($cardsData[$column->lane][$column->id]);
            }
        }

        foreach($columnsData as $columnData)
        {
            if(isset($columnData['parentName'])) $columnsData[$columnData['parentName']]['cards'] += $columnData['cards'];
        }

        return array($laneData, array_values($columnsData), $cardsData);
    }

    /**
     * 构建迭代看板的卡片数据。
     * Build the card data for the execution Kanban.
     *
     * @param  object $lane
     * @param  array  $laneData
     * @param  string $columnType
     * @param  array  $cardIdList
     * @param  array  $objectGroup
     * @param  string $searchValue
     * @param  array  $menus        array(story => array, bug => array, task => array)
     * @access public
     * @return array
     * @param object $column
     * @param mixed[] $cardsData
     * @param string $laneType
     */
    public function buildExecutionCards($cardsData, $column, $laneType, $cardIdList, $objectGroup, $searchValue = '', $menus = array())
    {
        foreach($cardIdList as $cardID)
        {
            $cardData = array();
            $objects  = zget($objectGroup, $laneType, array());
            $object   = zget($objects, $cardID, array());

            if(empty($object)) continue;
            if($laneType == 'story' && $object->isParent == '1') continue;
            $cardData = $this->buildExecutionCard($object, $column, $laneType, $searchValue, $menus);
            if(empty($cardData)) continue;

            $cardsData[$column->lane][$column->id][] = $cardData;
        }

        return $cardsData;
    }

    /**
     * 构造执行看板卡片数据。
     * Build execution card.
     *
     * @param  object $object
     * @param  object $column
     * @param  string $laneType
     * @param  string $searchValue
     * @param  array  $menus        array(story => array, bug => array, task => array)
     * @access public
     * @return array
     */
    public function buildExecutionCard($object, $column, $laneType, $searchValue = '', $menus = array())
    {
        if(empty($object)) return array();

        $cardData = array();
        $cardData['id']         = $object->id;
        $cardData['lane']       = $column->lane;
        $cardData['column']     = $column->id;
        $cardData['pri']        = zget($object, 'pri', 0);
        $cardData['group']      = $laneType;
        $cardData['parent']     = zget($object, 'originParent', 0);
        $cardData['status']     = zget($object, 'status', '');
        $cardData['estimate']   = helper::formatHours(zget($object, 'estimate', 0));
        $cardData['assignedTo'] = $object->assignedTo;
        $cardData['deadline']   = zget($object, 'deadline', '');
        $cardData['severity']   = zget($object, 'severity', 0);
        $cardData['actionList'] = zget(zget($menus, $laneType, array()), $object->id, array());

        $cardData['title'] = zget($object, 'title', '');
        if($laneType == 'task')
        {
            $cardData['title']      = $object->name;
            $cardData['status']     = $object->status;
            $cardData['left']       = helper::formatHours($object->left);
            $cardData['estStarted'] = $object->estStarted;
            $cardData['mode']       = $object->mode;
            if($object->mode == 'multi') $cardData['teamMembers'] = $object->teamMembers;
        }

        if($searchValue != '' && strpos($cardData['title'], $searchValue) === false) return array();

        return $cardData;
    }

    /**
     * 获取执行看板分组视图的数据。
     * Get kanban for group view.
     *
     * @param  int    $executionID
     * @param  string $browseType
     * @param  string $groupBy
     * @param  string $searchValue
     * @param  string $orderBy
     *
     * @access public
     * @return array
     */
    public function getKanban4Group($executionID, $browseType, $groupBy, $searchValue = '', $orderBy = 'id_asc')
    {
        /* Get card  data. */
        $cardList = array();
        if(in_array($browseType, array('story', 'parentStory'))) $cardList = $this->loadModel('story')->getExecutionStories($executionID, 0, 't1.`order`_desc', 'allStory');

        if($browseType == 'epic')        $cardList = $this->loadModel('story')->getExecutionStories($executionID, 0, 't1.`order`_desc', 'allStory', '0', 'epic');
        if($browseType == 'requirement') $cardList = $this->loadModel('story')->getExecutionStories($executionID, 0, 't1.`order`_desc', 'allStory', '0', 'requirement');
        if($browseType == 'bug')         $cardList = $this->loadModel('bug')->getExecutionBugs($executionID);
        if($browseType == 'task')        $cardList = $this->loadModel('execution')->getKanbanTasks($executionID);

        if($browseType == 'task' && $groupBy == 'assignedTo') $cardList = $this->appendTeamMember($cardList);

        /* Get objects cards menus. */
        $menus = array();
        if($browseType == 'story') $menus['story'] = $this->getKanbanCardMenu($executionID, $cardList, 'story');
        if($browseType == 'bug')   $menus['bug']   = $this->getKanbanCardMenu($executionID, $cardList, 'bug');
        if($browseType == 'task')  $menus['task']  = $this->getKanbanCardMenu($executionID, $cardList, 'task');

        if($groupBy == 'story' && $browseType == 'task' && !isset($this->lang->kanban->orderList[$orderBy])) $orderBy = 'id_asc';
        $lanes = $this->getLanes4Group($executionID, $browseType, $groupBy, $cardList, $orderBy);
        if(empty($lanes)) return array();

        $columns = $this->getCols4Group($executionID, $browseType);

        $cardGroup = array();
        foreach($columns as $column)
        {
            if(empty($column->cards)) continue;
            foreach($cardList as $card)
            {
                if(strpos($column->cards, ",$card->id,") !== false) $cardGroup[$column->columnType][$card->id] = $card;
            }
        }

        /* Build kanban group data. */
        $kanbanGroup = array();
        list($laneData, $columnData, $cardData) = $this->buildGroupKanban($lanes, $columns, $cardGroup, $searchValue, $groupBy, $browseType, $menus);

        $kanbanGroup['id']   = $executionID;
        $kanbanGroup['key']  = 'group' . $executionID;
        $kanbanGroup['data'] = array();
        $kanbanGroup['data']['lanes'] = $laneData;
        $kanbanGroup['data']['cols']  = array_values($columnData);
        $kanbanGroup['data']['items'] = $cardData;

        return array($kanbanGroup);
    }

    /**
     * 构建分组视图的看板泳道。
     * Build lanes for group kanban.
     *
     * @access public
     * @param  int    $executionID
     * @param  string $browseType
     * @param  string $groupBy
     * @param  array  $cardList
     * @param  string $orderBy
     *
     * @return array
     */
    public function getLanes4Group($executionID, $browseType, $groupBy, $cardList, $orderBy = 'id_asc')
    {
        $lanes       = array();
        $groupByList = array();
        foreach($cardList as $item)
        {
            if(!isset($groupByList[$item->$groupBy])) $groupByList[$item->$groupBy] = $item->$groupBy;

            if($groupBy == 'assignedTo' && !empty($item->teamMember))
            {
                foreach($item->teamMember as $account => $name)
                {
                    if(!isset($groupByList[$account])) $groupByList[$account] = $account;
                }
            }
        }

        $objectPairs = $this->getObjectPairs($groupBy, $groupByList, $browseType, $orderBy);
        if($groupBy == 'story') $objects = $this->dao->select('*')->from(TABLE_STORY)->where('deleted')->eq(0)->andWhere('id')->in($groupByList)->orderBy($orderBy)->fetchAll('id');

        $laneColor = 0;
        $order     = 1;
        foreach($objectPairs as $objectType => $objectName)
        {
            if(!isset($groupByList[$objectType]) && $objectType && !in_array($objectType, array('feature', 'design'))) continue;

            $lane = new stdclass();
            $lane->id         = $groupBy . $objectType;
            $lane->type       = $browseType;
            $lane->execution  = $executionID;
            $lane->name       = $objectName;
            $lane->order      = (int)$order;
            $lane->color      = $this->config->kanban->laneColorList[$laneColor];
            $lane->pri        = (isset($objects) && isset($objects[$objectType]->pri)) ? $objects[$objectType]->pri : '';
            $lane->assignedTo = (isset($objects) && isset($objects[$objectType]->assignedTo)) ? $objects[$objectType]->assignedTo : '';

            $order     += 1;
            $laneColor += 1;
            if($laneColor == count($this->config->kanban->laneColorList)) $laneColor = 0;
            $lanes[$objectType] = $lane;
        }

        return $lanes;
    }

    /**
     * 获取分组视图的看板列数据。
     * Get columns for group kanban.
     *
     * @param  int    $executionID
     * @param  string $browseType
     * @access public
     * @return array
     */
    public function getCols4Group($executionID, $browseType)
    {
        $execution = $this->loadModel('execution')->getByID($executionID);
        $cards     = $this->dao->select('t1.*, t2.`type` as columnType, t2.parent, t2.limit, t2.name as columnName, t2.color')->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_KANBANREGION)->alias('t4')->on('t1.kanban = t4.kanban')
            ->where('t1.kanban')->eq($executionID)
            ->andWhere('t1.`type`')->eq($browseType)
            ->beginIF(isset($execution->type) && $execution->type == 'kanban')
            ->andWhere('t3.deleted')->eq(0)
            ->andWhere('t4.deleted')->eq(0)
            ->fi()
            ->orderBy('column_asc')
            ->fetchAll();

        $columns = array();
        foreach($cards as $card)
        {
            if(!isset($columns[$card->columnType])) $columns[$card->columnType] = $card;

            $columns[$card->columnType]->cards .= ",$card->id,";
            $columns[$card->columnType]->cards  = ',' . trim($columns[$card->columnType]->cards, ',') . ',';
        }
        return $columns;
    }

    /**
     * 获取看板空间列表。
     * Get space list.
     *
     * @param  string $browseType private|cooperation|public|involved
     * @param  object $pager
     * @access public
     * @return array
     */
    public function getSpaceList($browseType, $pager = null)
    {
        $account     = $this->app->user->account;
        $spaceIdList = $this->getCanViewObjects('kanbanspace', $browseType);
        $spaceList   = $this->dao->select('*')->from(TABLE_KANBANSPACE)
            ->where('deleted')->eq(0)
            ->beginIF(in_array($browseType, array('private', 'cooperation', 'public')))->andWhere('type')->eq($browseType)->fi()
            ->beginIF($browseType == 'private' && !$this->app->user->admin)->andWhere('owner')->eq($account)->fi()
            ->beginIF($this->cookie->showClosed == 0)->andWhere('status')->ne('closed')->fi()
            ->andWhere('id')->in($spaceIdList)
            ->orderBy('id_desc')
            ->page($pager)
            ->fetchAll('id');

        $kanbanIdList = $this->getCanViewObjects('kanban', $browseType);
        $kanbanGroup  = $this->getGroupBySpaceList(array_keys($spaceList), $kanbanIdList);
        foreach($spaceList as $spaceID => $space)
        {
            if(isset($kanbanGroup[$spaceID])) $space->kanbans = $kanbanGroup[$spaceID];
        }

        return $spaceList;
    }

    /**
     * 获取看板空间键值对。
     * Get space pairs.
     *
     * @param  string $browseType private|cooperation|public|involved
     * @access public
     * @return array
     */
    public function getSpacePairs($browseType = 'private')
    {
        $spaceIdList = $this->getCanViewObjects('kanbanspace', $browseType);

        return $this->dao->select('id,name')->from(TABLE_KANBANSPACE)
            ->where('deleted')->eq(0)
            ->andWhere('id')->in($spaceIdList)
            ->beginIF(in_array($browseType, array('private', 'cooperation', 'public')))->andWhere('type')->eq($browseType)->fi()
            ->beginIF($this->cookie->showClosed == 0 && $browseType != 'showClosed')->andWhere('status')->ne('closed')->fi()
            ->orderBy('id_desc')
            ->fetchPairs('id');
    }

    /**
     * 获取看板键值对。
     * Get Kanban pairs.
     *
     * @access public
     * @return array
     */
    public function getKanbanPairs()
    {
        $kanbanIdList = $this->getCanViewObjects('kanban');

        return $this->dao->select('id,name')->from(TABLE_KANBAN)
            ->where('deleted')->eq(0)
            ->beginIF(!$this->app->user->admin)->andWhere('id')->in($kanbanIdList)->fi()
            ->orderBy('id_desc')
            ->fetchPairs('id');
    }

    /**
     * 获取有权查看的对象。
     * Get can view objects.
     *
     * @param  string $objectType kanbanspace|kanban
     * @param  string $param      all|noclosed|private|cooperation|public|involved
     * @access public
     * @return array
     */
    public function getCanViewObjects($objectType = 'kanban', $param = 'all')
    {
        $table   = $this->config->objectTables[$objectType];
        $objects = $this->dao->select('*')->from($table)
            ->where('deleted')->eq(0)
            ->beginIF(strpos($param, 'noclosed') !== false)->andWhere('status')->ne('closed')->fi()
            ->fetchAll('id');

        $spaceList = $objectType == 'kanban' ? $this->dao->select('id,owner,type')->from(TABLE_KANBANSPACE)->fetchAll('id') : array();

        if($param && $this->app->user->admin && strpos('private,involved', $param) === false) return array_keys($objects);

        $account = $this->app->user->account;
        foreach($objects as $objectID => $object)
        {
            if($objectType == 'kanbanspace' && $object->type == 'public' && $param != 'involved') continue;

            $remove = true;

            if($object->owner == $account) $remove = false;
            if(strpos(",{$object->team},", ",$account,") !== false) $remove = false;
            if(strpos(",{$object->whitelist},", ",$account,") !== false) $remove = false;

            if($objectType == 'kanban')
            {
                $spaceOwner = isset($spaceList[$object->space]->owner) ? $spaceList[$object->space]->owner : '';
                $spaceType  = isset($spaceList[$object->space]->type) ? $spaceList[$object->space]->type : '';
                if(strpos(",$spaceOwner,", ",$account,") !== false) $remove = false;
                if($spaceType == 'public' && $param != 'involved') $remove = false;
            }

            if($remove) unset($objects[$objectID]);
        }

        return array_keys($objects);
    }

    /**
     * 创建看板空间。
     * Create a space.
     *
     * @param  object $space
     * @access public
     * @return int|false
     */
    public function createSpace($space)
    {
        $account = $this->app->user->account;

        if($space->type == 'private') $space->owner = $account;
        if($space->type != 'private') $this->config->kanban->createspace->requiredFields .= ',owner';
        if(strpos(",{$space->team},", ",$account,") === false)      $space->team .= ",$account";
        if(strpos(",{$space->team},", ",$space->owner,") === false) $space->team .= ",$space->owner";

        $this->dao->insert(TABLE_KANBANSPACE)->data($space)
            ->autoCheck()
            ->batchCheck($this->config->kanban->createspace->requiredFields, 'notempty')
            ->exec();

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

        $spaceID = $this->dao->lastInsertID();
        $this->dao->update(TABLE_KANBANSPACE)->set('`order`')->eq($spaceID)->where('id')->eq($spaceID)->exec();
        $this->loadModel('file')->saveUpload('kanbanspace', $spaceID);
        $this->file->updateObjectID($this->post->uid, $spaceID, 'kanbanspace');
        $this->loadModel('action')->create('kanbanSpace', $spaceID, 'created');

        return $spaceID;
    }

    /**
     * 编辑看板空间。
     * Update a space.
     *
     * @param  object $space
     * @param  int    $spaceID
     * @access public
     * @return bool
     */
    public function updateSpace($space, $spaceID)
    {
        $oldSpace = $this->getSpaceById($spaceID);

        if($space->type == 'cooperation' || $space->type == 'public') $space->whitelist = '';
        if($space->type != 'private') $this->config->kanban->editspace->requiredFields .= ',owner';

        $this->dao->update(TABLE_KANBANSPACE)->data($space)
            ->autoCheck()
            ->batchCheck($this->config->kanban->editspace->requiredFields, 'notempty')
            ->where('id')->eq($spaceID)
            ->exec();

        if($oldSpace->type == 'private' && (in_array($space->type, array('cooperation', 'public'))))
        {
            $this->dao->update(TABLE_KANBAN)->set('team = whitelist')->set('whitelist')->eq('')->where('space')->eq($spaceID)->andWhere('deleted')->eq('0')->exec();
        }

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

        $this->loadModel('file')->saveUpload('kanbanspace', $spaceID);
        $this->file->updateObjectID($this->post->uid, $spaceID, 'kanbanspace');
        $changes = common::createChanges($oldSpace, $space);

        if($changes)
        {
            $actionID = $this->loadModel('action')->create('kanbanSpace', $spaceID, 'edited');
            $this->action->logHistory($actionID, $changes);
        }

        return true;
    }

    /**
     * 关闭看板空间。
     * Close a space.
     *
     * @param  int    $spaceID
     * @param  object $space
     * @access public
     * @return bool
     */
    function closeSpace($spaceID, $space)
    {
        $oldSpace = $this->getSpaceById($spaceID);

        $this->dao->update(TABLE_KANBANSPACE)->data($space)
            ->autoCheck()
            ->where('id')->eq($spaceID)
            ->exec();

        if(dao::isError()) return false;
        $changes = common::createChanges($oldSpace, $space);
        if($changes)
        {
            $actionID = $this->loadModel('action')->create('kanbanSpace', $spaceID, 'closed', $this->post->comment);
            $this->action->logHistory($actionID, $changes);
        }

        return true;
    }

    /**
     * 激活看板空间。
     * Activate a space.
     *
     * @param  int    $spaceID
     * @param  object $space
     * @access public
     * @return bool
     */
    function activateSpace($spaceID, $space)
    {
        $oldSpace = $this->getSpaceById($spaceID);

        $this->dao->update(TABLE_KANBANSPACE)->data($space)
            ->autoCheck()
            ->where('id')->eq($spaceID)
            ->exec();

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

        $changes = common::createChanges($oldSpace, $space);
        if($changes)
        {
            $actionID = $this->loadModel('action')->create('kanbanSpace', $spaceID, 'activated', $this->post->comment);
            $this->action->logHistory($actionID, $changes);
        }

        return true;
    }

    /**
     * 获取看板泳道键值对。
     * Get lane pairs by region id.
     *
     * @param int $regionID
     * @param  string    $type all|story|task|bug|common
     * @access public
     * @return array
     */
    public function getLanePairsByRegion($regionID, $type = 'all')
    {
        return $this->dao->select('id, name')->from(TABLE_KANBANLANE)
            ->where('deleted')->eq('0')
            ->andWhere('region')->in($regionID)
            ->beginIF($type != 'all')->andWhere('type')->eq($type)->fi()
            ->fetchPairs();
    }

    /**
     * 获取看板泳道分组。
     * Get lane by region id.
     *
     * @param  array|int $regionID
     * @param  string    $type     all|story|task|bug|common
     * @access public
     * @return array
     */
    public function getLaneGroupByRegion($regionID, $type = 'all')
    {
        return $this->dao->select('*')->from(TABLE_KANBANLANE)
            ->where('deleted')->eq('0')
            ->andWhere('region')->in($regionID)
            ->beginIF($type != 'all')->andWhere('type')->eq($type)->fi()
            ->fetchGroup('region');
    }

    /**
     * 创建看板泳道。
     * Create a lane.
     *
     * @param  int    $kanbanID
     * @param  int    $regionID
     * @param  object $lane
     * @param  string $mode    new|copy
     * @access public
     * @return int|bool
     */
    public function createLane($kanbanID, $regionID, $lane = null, $mode = 'new')
    {
        $laneType = isset($_POST['laneType']) ? $_POST['laneType'] : 'common';
        if($laneType == 'common')
        {
            $sameNameLane = $this->dao->select('id')->from(TABLE_KANBANLANE)->where('region')->eq($regionID)->andWhere('name')->eq($lane->name)->andWhere('deleted')->eq('0')->limit(1)->fetch();
            if($sameNameLane)
            {
                dao::$errors['name'][] = $this->lang->kanbanlane->error->hasExist;
                return false;
            }
        }
        if($mode == 'new')
        {
            $maxOrder = $this->dao->select('MAX(`order`) AS maxOrder')->from(TABLE_KANBANLANE)
                ->where('region')->eq($regionID)
                ->fetch('maxOrder');

            $lane->order     = $maxOrder ? $maxOrder + 1 : 1;
            $lane->type      = $laneType;
            $lane->execution = isset($_POST['laneType']) ? $kanbanID : 0;

            if($lane->mode == 'sameAsOther')
            {
                if($lane->otherLane) $lane->group = $this->dao->select('`group`')->from(TABLE_KANBANLANE)->where('id')->eq($lane->otherLane)->fetch('group');
            }
            elseif($lane->mode == 'independent')
            {
                $lane->group = $this->createGroup($kanbanID, $regionID);
                if($lane->type == 'common') $this->createDefaultColumns($regionID, $lane->group);
            }
        }

        $this->dao->insert(TABLE_KANBANLANE)->data($lane, $skip = 'mode,otherLane')
            ->batchCheck($this->config->kanban->require->createlane, 'notempty')
            ->autoCheck()
            ->exec();
        if(dao::isError()) return false;

        $laneID = $this->dao->lastInsertID();
        if($lane->type != 'common' && $lane->mode == 'independent') $this->createRDColumn($regionID, $lane->group, $laneID, $lane->type, $kanbanID);

        if($mode == 'sameAsOther' || ($lane->type == 'common' && $mode == 'independent'))
        {
            $columnIDList = $this->dao->select('id')->from(TABLE_KANBANCOLUMN)->where('deleted')->eq(0)->andWhere('archived')->eq(0)->andWhere('`group`')->eq($lane->group)->fetchPairs();
            foreach($columnIDList as $columnID)
            {
                $this->addKanbanCell($kanbanID, $laneID, $columnID, $lane->type);

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

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

    /*
     * 创建看板。
     * Create a kanban.
     *
     * @param  object $kanban
     * @access public
     * @return int|false
     */
    /**
     * @return int|false
     * @param object $kanban
     */
    public function create($kanban)
    {
        $account = $this->app->user->account;

        if($this->post->import == 'on') $kanban->object = implode(',', $this->post->importObjectList);

        if(strpos(",{$kanban->team},", ",$account,") === false)       $kanban->team .= ",$account";
        if(strpos(",{$kanban->team},", ",$kanban->owner,") === false) $kanban->team .= ",$kanban->owner";

        if(!empty($kanban->space))
        {
            $maxOrder = $this->dao->select('MAX(`order`) AS maxOrder')->from(TABLE_KANBAN)
                ->where('space')->eq($kanban->space)
                ->fetch('maxOrder');
            $kanban->order = $maxOrder ? $maxOrder + 1 : 1;

            $space = $this->getSpaceById($kanban->space);
            if(!empty($space) && $space->type == 'private') $kanban->owner = $account;
        }

        $this->kanbanTao->createKanban($kanban);

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

        $kanbanID   = $this->dao->lastInsertID();
        $kanbanInfo = $this->getByID($kanbanID);

        $this->loadModel('action')->create('kanban', $kanbanID, 'created');
        $this->loadModel('file')->saveUpload('kanban', $kanbanID);
        $this->file->updateObjectID($this->post->uid, $kanbanID, 'kanban');

        !empty($kanban->copyRegion) ? $this->copyRegions($kanbanInfo, $kanban->copyKanbanID): $this->createDefaultRegion($kanbanInfo);

        if(!empty($kanbanInfo->team) || !empty($kanbanInfo->whitelist))
        {
            $type = !empty($kanbanInfo->team) ? 'team' : 'whitelist';
            $kanbanMembers = empty($kanbanInfo->{$type}) ? array() : explode(',', $kanbanInfo->{$type});
            $this->addSpaceMembers($kanbanInfo->space, $type, $kanbanMembers);
        }

        return $kanbanID;
    }

    /**
     * 编辑看板。
     * Update a kanban.
     *
     * @param  int    $kanbanID
     * @param  object $kanban
     * @access public
     * @return bool
     */
    public function update($kanbanID, $kanban)
    {
        $oldKanban = $this->getByID($kanbanID);

        if(strpos(",{$kanban->team},", ",$kanban->owner,") === false) $kanban->team .= ",$kanban->owner";

        $this->dao->update(TABLE_KANBAN)->data($kanban)
            ->autoCheck()
            ->batchCheck($this->config->kanban->edit->requiredFields, 'notempty')
            ->where('id')->eq($kanbanID)
            ->exec();

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

        $this->loadModel('file')->saveUpload('kanban', $kanbanID);
        $this->file->updateObjectID($this->post->uid, $kanbanID, 'kanban');

        if(!empty($kanban->team) || !empty($kanban->whitelist))
        {
            $type = !empty($kanban->team) ? 'team' : 'whitelist';
            $kanbanMembers = empty($kanban->{$type}) ? array() : explode(',', $kanban->{$type});
            $this->addSpaceMembers($kanban->space, $type, $kanbanMembers);
        }

        $changes  = common::createChanges($oldKanban, $kanban);
        $actionID = $this->loadModel('action')->create('kanban', $kanbanID, 'edited');
        $this->action->logHistory($actionID, $changes);

        return true;
    }

    /**
     * 设置看板。
     * Setting kanban.
     *
     * @param  int    $kanbanID
     * @param  object $kanban
     * @access public
     * @return bool
     */
    public function setting($kanbanID, $kanban)
    {
        $oldKanban = $this->getByID($kanbanID);

        if($this->post->import == 'off')      $kanban->object = '';
        if($this->post->heightType == 'auto') $kanban->displayCards = 0;
        if(empty($kanban->displayCards))      $kanban->displayCards = 0;

        if($this->post->import == 'on') $kanban->object = $this->post->importObjectList ? implode(',', $this->post->importObjectList) : '';
        if(isset($_POST['heightType']) && $this->post->heightType == 'custom' && !$this->checkDisplayCards($kanban->displayCards)) return false;

        $this->dao->update(TABLE_KANBAN)->data($kanban)
            ->autoCheck()
            ->batchCheck($this->config->kanban->edit->requiredFields, 'notempty')
            ->checkIF(!$kanban->fluidBoard, 'colWidth', 'ge', $this->config->minColWidth)
            ->batchCheckIF($kanban->fluidBoard, 'minColWidth', 'ge', $this->config->minColWidth)
            ->checkIF($kanban->minColWidth >= $this->config->minColWidth && $kanban->fluidBoard, 'maxColWidth', 'gt', $kanban->minColWidth)
            ->where('id')->eq($kanbanID)
            ->exec();

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

        $changes = common::createChanges($oldKanban, $kanban);

        if($changes)
        {
            $actionID = $this->loadModel('action')->create('kanban', $kanbanID, 'edited');
            $this->action->logHistory($actionID, $changes);
        }

        return true;
    }

    /**
     * 激活看板。
     * Activate a kanban.
     *
     * @param  int    $kanbanID
     * @param  object $kanban
     * @access public
     * @return bool
     */
    function activate($kanbanID, $kanban)
    {
        $oldKanban = $this->getByID($kanbanID);

        $this->dao->update(TABLE_KANBAN)->data($kanban)
            ->autoCheck()
            ->where('id')->eq($kanbanID)
            ->exec();

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

        $changes  = common::createChanges($oldKanban, $kanban);
        $actionID = $this->loadModel('action')->create('kanban', $kanbanID, 'activated', $this->post->comment);
        $this->action->logHistory($actionID, $changes);

        return true;
    }

    /**
     * 关闭看板。
     * Close a kanban.
     *
     * @param  int    $kanbanID
     * @param  object $kanban
     * @access public
     * @return bool
     */
    function close($kanbanID, $kanban)
    {
        $oldKanban = $this->getByID($kanbanID);

        $this->dao->update(TABLE_KANBAN)->data($kanban)
            ->autoCheck()
            ->where('id')->eq($kanbanID)
            ->exec();

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

        $changes = common::createChanges($oldKanban, $kanban);
        if($changes)
        {
            $actionID = $this->loadModel('action')->create('kanban', $kanbanID, 'closed', $this->post->comment);
            $this->action->logHistory($actionID, $changes);
        }

        return true;
    }

    /* 执行看板新增列和泳道。
     * Check whether kanban lane exists.
     *
     * @param  int    $executionID
     * @access public
     * @return void
     */
    /**
     * @param int $executionID
     */
    public function createLaneIfNotExist($executionID)
    {
        $kanbanColumns = $this->dao->select('*')->from(TABLE_KANBANLANE)
            ->where('execution')->eq($executionID)
            ->andWhere('deleted')->eq(0)
            ->fetchAll('type');
        $types = array_keys($kanbanColumns);

        /* 如果没有任意一种类型的 kanban， 生成一份新的 */
        if(empty($types))
        {
            $this->createExecutionLane($executionID, 'all');
        }
        else
        {
            /* 如果缺少某种类型的 kanban，单独生成缺少的 */
            $kanbanTypes = $this->lang->kanban->type;
            unset($kanbanTypes['all']);
            unset($kanbanTypes['epic']);
            unset($kanbanTypes['requirement']);
            foreach($kanbanTypes as $type => $typeName)
            {
                if(!in_array($type, $types)) $this->createExecutionLane($executionID, $type);
            }
        }
    }

    /**
     * 执行看板新增列和泳道。
     * Add execution Kanban lanes and columns.
     *
     * @param  int    $executionID
     * @param  string $type all|parentStory|story|bug|task|risk
     * @access public
     * @return void
     */
    public function createExecutionLane($executionID, $type = 'all')
    {
        /* e.g  $defaults = array('risk' => (object)array('name' => 'risk', 'color' => '#FF0000', 'order' => 20)); */
        $defaults = ($type != 'all') ? array($type => $this->config->kanban->default->$type) : (array)$this->config->kanban->default;

        $ERURLanes         = array();
        $parentStoryLaneID = 0;
        $execution         = $this->loadModel('execution')->fetchById($executionID);
        foreach($defaults as $type => $lane)
        {
            /* 只有综合、需求、设计阶段，才可关联业需、用需。 */
            if($execution->type != 'stage' && !in_array($execution->attribute, array('mix', 'request', 'design')) && in_array($type, array('epic', 'requirement'))) continue;

            $lane->type      = $type;
            $lane->execution = $executionID;
            $lane->region    = 0;
            $lane->group     = 0;
            $lane->groupby   = '';
            $lane->extra     = '';
            $this->dao->insert(TABLE_KANBANLANE)->data($lane)->exec();

            $laneID = $this->dao->lastInsertId();
            if($type == 'parentStory') $parentStoryLaneID = $laneID;

            if(in_array($type, array('epic', 'requirement')))
            {
                $ERURLanes[$type] = $laneID;
            }
            else
            {
                if($type != 'risk') $this->createExecutionColumns($laneID, $type, $executionID);
                else $this->createExecutionRiskColumns($laneID, $type, $executionID);
            }
        }

        if($ERURLanes)
        {
            $columnIDList = $this->dao->select('`column`')->from(TABLE_KANBANCELL)->where('lane')->eq($parentStoryLaneID)->fetchPairs();
            foreach($ERURLanes as $type => $laneID)
            {
                foreach($columnIDList as $columnID)
                {
                    $this->addKanbanCell($executionID, $laneID, $columnID, $type);
                    if(dao::isError()) return false;
                }
            }
        }

        return true;
    }

    /**
     * 创建执行看板列。
     * Create execution columns.
     *
     * @param  int|array $laneID
     * @param  string    $type story|bug|task
     * @param  int       $executionID
     * @access public
     * @return void
     */
    public function createExecutionColumns($laneID, $type, $executionID)
    {
        $designColumnID = $devColumnID = $testColumnID = $resolvingColumnID = 0;

        $columns = array();
        if(in_array($type, array('epic', 'requirement', 'parentStory'))) $columns = $this->lang->kanban->ERURColumn;
        if($type == 'story') $columns = $this->lang->kanban->storyColumn;
        if($type == 'bug')   $columns = $this->lang->kanban->bugColumn;
        if($type == 'task')  $columns = $this->lang->kanban->taskColumn;
        if(empty($columns)) return;

        foreach($columns as $colType => $name)
        {
            $data = new stdclass();
            $data->name   = $name;
            $data->color  = '#333';
            $data->type   = $colType;
            $data->region = 0;

            if(strpos(',designing,designed,', ",{$colType},") !== false) $data->parent = $designColumnID;
            if(strpos(',developing,developed,', ",{$colType},") !== false) $data->parent = $devColumnID;
            if(strpos(',testing,tested,', ",{$colType},") !== false) $data->parent = $testColumnID;
            if(strpos(',fixing,fixed,', ",{$colType},") !== false) $data->parent = $resolvingColumnID;
            if(strpos(',resolving,develop,test,', ",{$colType},") !== false) $data->parent = -1;

            $this->dao->insert(TABLE_KANBANCOLUMN)->data($data)->exec();

            $colID = $this->dao->lastInsertId();
            if($colType == 'design')    $designColumnID    = $colID;
            if($colType == 'develop')   $devColumnID       = $colID;
            if($colType == 'test')      $testColumnID      = $colID;
            if($colType == 'resolving') $resolvingColumnID = $colID;

            if(is_array($laneID))
            {
                foreach($laneID as $id) $this->addKanbanCell($executionID, $id, $colID, $type);
            }
            else
            {
                $this->addKanbanCell($executionID, $laneID, $colID, $type);
            }
        }
    }

    /**
     * 新增看板Cell。
     * Add kanban cell for new lane.
     *
     * @param  int    $kanbanID
     * @param  int    $laneID
     * @param  int    $colID
     * @param  string $type story|task|bug|card
     * @param  string $cardID
     * @access public
     * @return void
     */
    public function addKanbanCell($kanbanID, $laneID, $colID, $type, $cardID = '')
    {
        $cell = $this->dao->select('id, cards')->from(TABLE_KANBANCELL)
            ->where('kanban')->eq($kanbanID)
            ->andWhere('lane')->eq($laneID)
            ->andWhere('`column`')->eq($colID)
            ->andWhere('type')->eq($type)
            ->fetch();

        if(empty($cell))
        {
            $cell = new stdclass();
            $cell->kanban = $kanbanID;
            $cell->lane   = $laneID;
            $cell->column = $colID;
            $cell->type   = $type;
            $cell->cards  = $cardID ? ",$cardID," : '';

            $this->dao->insert(TABLE_KANBANCELL)->data($cell)->exec();
        }
        else
        {
            $cell->cards = $cell->cards ? ",$cardID" . $cell->cards : ",$cardID,";
            $this->dao->update(TABLE_KANBANCELL)->set('cards')->eq($cell->cards)->where('id')->eq($cell->id)->exec();
        }
    }

    /**
     * 向看板空间添加成员。
     * Add space members.
     *
     * @param  int    $spaceID
     * @param  string $type team|whitelist
     * @param  array  $kanbanMembers
     * @access public
     * @return void
     */
    public function addSpaceMembers($spaceID, $type, $kanbanMembers = array())
    {
        $space = $this->getSpaceById($spaceID);
        if(empty($space)) return;

        $spaceMembers = empty($space->{$type}) ? array() : explode(',', $space->{$type});
        $members      = $space->{$type};
        $addMembers   = array_diff($kanbanMembers, $spaceMembers);

        if(!empty($addMembers))
        {
            $addMembers = implode(',', $addMembers);
            $members   .= ',' . trim($addMembers, ',');
            $this->dao->update(TABLE_KANBANSPACE)->set($type)->eq($members)->where('id')->eq($spaceID)->exec();
        }
    }

    /**
     * 从看板格子中移除卡片。
     * Remove kanban cell.
     *
     * @param  string    $type
     * @param  int|array $removeCardID
     * @param  array     $kanbanList
     * @access public
     * @return void
     */
    public function removeKanbanCell($type, $removeCardID, $kanbanList)
    {
        $removeIDList = is_array($removeCardID) ? $removeCardID : array($removeCardID);
        foreach($removeIDList as $cardID)
        {
            if(empty($cardID)) continue;

            $this->dao->update(TABLE_KANBANCELL)->set("cards = REPLACE(cards, ',$cardID,', ',')")->where('type')->eq($type)->andWhere('kanban')->eq($kanbanList[$cardID])->exec();
        }

        $this->dao->update(TABLE_KANBANCELL)
            ->set('cards')->eq('')
            ->where('cards')->eq(',')
            ->andWhere('type')->eq($type)
            ->andWhere('kanban')->in($kanbanList)
            ->exec();
    }

    /**
     * 创建默认的专业研发看板。
     * Create a default RD kanban.
     *
     * @param  object $execution
     * @access public
     * @return bool
     */
    public function createRDKanban($execution)
    {
        $regionID =  $this->createRDRegion($execution);
        if(dao::isError()) return false;

        $this->createGroup((int)$execution->id, $regionID);
        if(dao::isError()) return false;

        $this->createRDLane((int)$execution->id, $regionID);
        if(dao::isError()) return false;

        return true;
    }

    /**
     * 创建默认的专业研发看板区域。
     * Create a default RD region.
     *
     * @param  object $execution
     *
     * @access public
     * @return int|bool
     */
    public function createRDRegion($execution)
    {
        $region = new stdclass();
        $region->name        = $this->lang->kanbanregion->default;
        $region->kanban      = $execution->id;
        $region->createdBy   = $this->app->user->account;
        $region->createdDate = helper::today();
        $region->order       = 1;

        $this->dao->insert(TABLE_KANBANREGION)->data($region)
            ->check('name', 'unique', "kanban={$execution->id} AND deleted='0' AND space = '0'")
            ->autoCheck()
            ->exec();

        if(dao::isError()) return false;
        return $this->dao->lastInsertId();
    }

    /**
     * 创建默认的专业研发看板泳道。
     * Create default RD lanes.
     *
     * @param  int    $executionID
     * @param  int    $regionID
     *
     * @access public
     * @return bool
     */
    public function createRDLane($executionID, $regionID)
    {
        $execution = $this->loadModel('execution')->fetchByID($executionID);
        $project   = $this->loadModel('project')->fetchByID($execution->project);
        $index     = 0;
        foreach($this->lang->kanban->laneTypeList as $type => $name)
        {
            $groupID = $this->createGroup($executionID, $regionID);
            if(dao::isError()) return false;

            $lane = new stdclass();
            $lane->execution = $executionID;
            $lane->type      = $type;
            $lane->region    = $regionID;
            $lane->group     = $groupID;
            $lane->name      = $name;
            $lane->color     = $this->config->kanban->laneColorList[$index];
            $lane->order     = $this->config->kanban->RDLaneOrder[$type];

            $this->dao->insert(TABLE_KANBANLANE)->data($lane)->autoCheck()->exec();
            if(dao::isError()) return false;
            $laneID = $this->dao->lastInsertId();

            $this->createRDColumn($regionID, $groupID, $laneID, $type, $executionID);

            $index ++;
        }

        return true;
    }

    /**
     * 创建默认值的专业研发看板列。
     * Create default RD columns.
     *
     * @param  int    $regionID
     * @param  int    $groupID
     * @param  int    $laneID
     * @param  string $laneType
     * @param  int    $executionID
     *
     * @access public
     * @return bool
     */
    public function createRDColumn($regionID, $groupID, $laneID, $laneType, $executionID)
    {
        $designColumnID = $devColumnID = $testColumnID = $resolvingColumnID = 0;
        if($laneType == 'parentStory')  $columnList = $this->lang->kanban->ERURColumn;
        if($laneType == 'story')        $columnList = $this->lang->kanban->storyColumn;
        if($laneType == 'bug')          $columnList = $this->lang->kanban->bugColumn;
        if($laneType == 'task')         $columnList = $this->lang->kanban->taskColumn;

        foreach($columnList as $type => $name)
        {
            $data = new stdclass();
            $data->name   = $name;
            $data->color  = '#333';
            $data->type   = $type;
            $data->group  = $groupID;
            $data->region = $regionID;

            if(strpos(',designing,designed,', $type) !== false)   $data->parent = $designColumnID;
            if(strpos(',developing,developed,', $type) !== false) $data->parent = $devColumnID;
            if(strpos(',testing,tested,', $type) !== false)       $data->parent = $testColumnID;
            if(strpos(',fixing,fixed,', $type) !== false)         $data->parent = $resolvingColumnID;
            if(strpos(',design,develop,test,resolving,', $type) !== false) $data->parent = -1;

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

            $columnID = $this->dao->lastInsertId();
            if($type == 'design')    $designColumnID    = $columnID;
            if($type == 'develop')   $devColumnID       = $columnID;
            if($type == 'test')      $testColumnID      = $columnID;
            if($type == 'resolving') $resolvingColumnID = $columnID;

            $this->addKanbanCell($executionID, $laneID, $columnID, $laneType);
        }

        return true;
    }

    /**
     * 编辑区域。
     * Update a region.
     *
     * @param  int    $regionID
     * @access public
     * @return bool
     */
    public function updateRegion($regionID)
    {
        $region = new stdclass();
        $region->lastEditedBy   = $this->app->user->account;
        $region->lastEditedDate = helper::now();
        $region->name           = trim($this->post->name);

        $oldRegion = $this->getRegionById($regionID);

        $this->dao->update(TABLE_KANBANREGION)->data($region)
            ->autoCheck()
            ->batchcheck($this->config->kanban->editregion->requiredFields, 'notempty')
            ->where('id')->eq($regionID)
            ->exec();

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

        $changes = common::createChanges($oldRegion, $region);
        if($changes)
        {
            $actionID = $this->loadModel('action')->create('kanbanregion', $regionID, 'edited');
            $this->action->logHistory($actionID, $changes);
        }

        return true;
    }

    /**
     * 编辑看板泳道。
     * Update kanban lane.
     *
     * @param  int    $executionID
     * @param  string $laneType
     * @param  int    $cardID
     * @access public
     * @return void
     */
    public function updateLane($executionID, $laneType = '', $cardID = 0)
    {
        $execution = $this->loadModel('execution')->getByID($executionID);
        if($execution->type == 'kanban')
        {
            $lanes = $this->dao->select('t2.*')->from(TABLE_KANBANREGION)->alias('t1')
                ->leftJoin(TABLE_KANBANLANE)->alias('t2')->on('t1.id=t2.region')
                ->leftJoin(TABLE_KANBANCELL)->alias('t3')->on('t2.id=t3.lane')
                ->where('t1.deleted')->eq(0)
                ->andWhere('t2.deleted')->eq(0)
                ->andWhere('t1.kanban')->eq($executionID)
                ->andWhere('t2.execution')->eq($executionID)
                ->beginIF($laneType)->andWhere('t2.type')->eq($laneType)->fi()
                ->beginIF(!empty($cardID))->andWhere('t3.cards')->like("%,$cardID,%")->fi()
                ->orderBy('t1.`order` asc, t2.`order` asc')
                ->fetchAll('id');

            if(count($lanes) > 1) $lanes = array_slice($lanes, 0, 2);
        }
        else
        {
            $lanes = $this->dao->select('*')->from(TABLE_KANBANLANE)
                ->where('execution')->eq($executionID)
                ->beginIF($laneType)->andWhere('type')->eq($laneType)->fi()
                ->fetchAll('id');
        }

        foreach($lanes as $lane) $this->refreshCards((array)$lane);
    }

    /**
     * 更新看板泳道上的卡片。
     * Refresh column cards.
     *
     * @param  array  $lane
     * @access public
     * @return void
     */
    public function refreshCards($lane)
    {
        if(common::isTutorialMode()) return;

        $laneID        = zget($lane, 'id');
        $laneType      = zget($lane, 'type');
        $executionID   = zget($lane, 'execution');
        $otherCardList = '';
        $otherLanes    = $this->dao->select('t2.id, t2.cards')->from(TABLE_KANBANLANE)->alias('t1')
            ->leftJoin(TABLE_KANBANCELL)->alias('t2')->on('t1.id=t2.lane')
            ->where('t1.id')->ne($laneID)
            ->andWhere('t1.execution')->eq($executionID)
            ->andWhere('t2.`type`')->eq($laneType)
            ->fetchPairs();

        foreach($otherLanes as $cardIDList)
        {
            $cardIDList = trim($cardIDList, ',');
            if(!empty($cardIDList)) $otherCardList .= ',' . $cardIDList;
        }

        $cardPairs = $this->dao->select('t2.type, t1.cards')->from(TABLE_KANBANCELL)->alias('t1')
            ->leftJoin(TABLE_KANBANCOLUMN)->alias('t2')->on('t1.`column` = t2.id')
            ->where('t1.kanban')->eq($executionID)
            ->andWhere('t1.lane')->eq($laneID)
            ->fetchPairs();

        if(empty($cardPairs)) return;
        $sourceCards = $cardPairs;

        if(in_array($laneType, array('epic', 'requirement', 'parentStory'))) $cardPairs = $this->kanbanTao->refreshERURCards($cardPairs, $executionID, $otherCardList, $laneType);
        if($laneType == 'story') $cardPairs = $this->kanbanTao->refreshStoryCards($cardPairs, $executionID, $otherCardList);
        if($laneType == 'bug')   $cardPairs = $this->kanbanTao->refreshBugCards($cardPairs, $executionID, $otherCardList);
        if($laneType == 'task')  $cardPairs = $this->kanbanTao->refreshTaskCards($cardPairs, $executionID, $otherCardList);
        if($laneType == 'risk')  $cardPairs = $this->refreshRiskCards($cardPairs, $executionID, $otherCardList);

        $colPairs = $this->dao->select('t2.type, t2.id')->from(TABLE_KANBANCELL)->alias('t1')
            ->leftJoin(TABLE_KANBANCOLUMN)->alias('t2')->on('t1.`column` = t2.id')
            ->where('t1.kanban')->eq($executionID)
            ->andWhere('t1.lane')->eq($laneID)
            ->fetchPairs();

        $updated = false;
        foreach($cardPairs as $colType => $cards)
        {
            if(!isset($colPairs[$colType])) continue;
            if($sourceCards[$colType] == $cards) continue;

            $this->dao->update(TABLE_KANBANCELL)->set('cards')->eq($cards)->where('lane')->eq($laneID)->andWhere('`column`')->eq($colPairs[$colType])->exec();
            if(!$updated) $updated = true;
        }

        if($updated) $this->dao->update(TABLE_KANBANLANE)->set('lastEditedTime')->eq(helper::now())->where('id')->eq($laneID)->exec();
    }

    /**
     * 编辑看板列。
     * Update column.
     *
     * @param  int    $columnID
     * @param  object $column
     * @access public
     * @return array|false
     */
    public function updateColumn($columnID, $column)
    {
        $oldColumn = $this->getColumnById($columnID);
        $this->dao->update(TABLE_KANBANCOLUMN)->data($column)
            ->autoCheck()
            ->batchcheck($this->config->kanban->setColumn->requiredFields, 'notempty')
            ->where('id')->eq($columnID)
            ->exec();

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

        $changes = common::createChanges($oldColumn, $column);
        return $changes;
    }

    /**
     * 激活卡片。
     * Activate a card.
     *
     * @param  int    $cardID
     * @access public
     * @return bool
     */
    public function activateCard($cardID)
    {
        if($this->post->progress >= 100 || $this->post->progress < 0)
        {
            dao::$errors['progress'][] = $this->lang->kanbancard->error->progressIllegal;
            return false;
        }

        $oldCard = $this->getCardByID($cardID);
        $this->dao->update(TABLE_KANBANCARD)->set('progress')->eq($this->post->progress ? $this->post->progress : 0)->set('status')->eq('doing')->where('id')->eq($cardID)->exec();
        $card = $this->getCardByID($cardID);

        $changes = common::createChanges($oldCard, $card);
        $actionID = $this->loadModel('action')->create('kanbanCard', $cardID, 'activated');
        $this->action->logHistory($actionID, $changes);

        return true;
    }

    /**
     * 编辑看板卡片。
     * Update a card.
     *
     * @param  int    $cardID
     * @param  object $card
     * @access public
     * @return bool
     */
    public function updateCard($cardID, $card)
    {
        if($card->estimate < 0)
        {
            dao::$errors['estimate'] = $this->lang->kanbancard->error->recordMinus;
            return false;
        }

        if($card->end && ($card->begin > $card->end))
        {
            dao::$errors['end'] = $this->lang->kanbancard->error->endSmall;
            return false;
        }

        if($card->progress > 100 || $card->progress < 0)
        {
            dao::$errors['progress'] = $this->lang->kanbancard->error->progressIllegal;
            return false;
        }

        $oldCard = $this->getCardByID($cardID);

        if(!empty($this->post->assignedTo) && $oldCard->assignedTo != $this->post->assignedTo) $card->assignedDate = helper::now();
        $card->status = $card->progress == 100 ? 'done' : 'doing';

        $card = $this->loadModel('file')->processImgURL($card, $this->config->kanban->editor->editcard['id'], $this->post->uid);

        $this->dao->update(TABLE_KANBANCARD)->data($card)
            ->autoCheck()
            ->checkIF($card->estimate != '', 'estimate', 'float')
            ->batchcheck($this->config->kanban->editcard->requiredFields, 'notempty')
            ->where('id')->eq($cardID)
            ->exec();

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

        $this->file->saveUpload('kanbancard', $cardID);
        $this->file->updateObjectID($this->post->uid, $cardID, 'kanbancard');

        $changes = common::createChanges($oldCard, $card);

        if($changes)
        {
            $actionID = $this->loadModel('action')->create('kanbanCard', $cardID, 'edited');
            $this->action->logHistory($actionID, $changes);
        }

        return true;
    }

    /**
     * 设置看板在制品限制。
     * Set WIP limit.
     *
     * @param  int    $columnID
     * @param  object $WIP
     * @access public
     * @return bool
     */
    public function setWIP($columnID, $WIP)
    {
        $oldColumn = $this->getColumnById($columnID);
        if(!preg_match("/^-?\d+$/", (string)$WIP->limit) || (!$WIP->noLimit && $WIP->limit <= 0))
        {
            dao::$errors['limit'] = $this->lang->kanban->error->mustBeInt;
            return false;
        }

        /* Check column limit. */
        $sumChildLimit = 0;
        if($oldColumn->parent == -1 && $WIP->limit != -1)
        {
            $childColumns = $this->dao->select('id,`limit`')->from(TABLE_KANBANCOLUMN)->where('parent')->eq($columnID)->andWhere('deleted')->eq(0)->fetchAll();
            foreach($childColumns as $childColumn) $sumChildLimit += $childColumn->limit;

            if($sumChildLimit > $WIP->limit)
            {
                dao::$errors['limit'] = $this->lang->kanban->error->parentLimitNote;
            }
        }
        elseif($oldColumn->parent > 0)
        {
            $parentColumn = $this->getColumnByID($oldColumn->parent);
            if($parentColumn->limit != -1)
            {
                $siblingLimit = $this->dao->select('`limit`')->from(TABLE_KANBANCOLUMN)
                    ->where('`parent`')->eq($oldColumn->parent)
                    ->andWhere('id')->ne($columnID)
                    ->fetch('limit');

                $sumChildLimit = (int)$siblingLimit + (int)$WIP->limit;

                if($WIP->limit == -1 || $siblingLimit == -1 || $sumChildLimit > $parentColumn->limit)
                {
                    dao::$errors['limit'] = $this->lang->kanban->error->childLimitNote;
                }
            }
        }

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

        $this->dao->update(TABLE_KANBANCOLUMN)->data($WIP, 'noLimit')
            ->autoCheck()
            ->checkIF($WIP->limit != -1, 'limit', 'gt', 0)
            ->batchcheck($this->config->kanban->setwip->requiredFields, 'notempty')
            ->where('id')->eq($columnID)
            ->exec();

        return !dao::isError();
    }

    /**
     * 设置看板列。
     * Set lane info.
     *
     * @param  int    $laneID
     * @param  object $lane
     * @access public
     * @return bool
     */
    public function setLane($laneID, $lane)
    {
        $this->dao->update(TABLE_KANBANLANE)->data($lane)
            ->autoCheck()
            ->batchcheck($this->config->kanban->setlane->requiredFields, 'notempty')
            ->where('id')->eq($laneID)
            ->exec();

        return !dao::isError();
    }

    /**
     * 获取看板右上角的工具栏。
     * Get kanban pageToolBar.
     *
     * @param  object $kanban
     * @access public
     * @return string
     */
    public function getPageToolBar($kanban)
    {
        $actions  = '';
        $actions .= "<a href='javascript:$(\"#kanbanList\").fullscreen();' id='fullScreenBtn' class='toolbar-item ghost btn btn-default'><i class='icon icon-fullscreen'></i> {$this->lang->kanban->fullScreen}</a>";

        $printKanbanBtn = (common::hasPriv('kanban', 'edit') || ($kanban->status == 'active' && common::hasPriv('kanban', 'close')) || common::hasPriv('kanban', 'delete') || ($kanban->status == 'closed' && common::hasPriv('kanban', 'activate')));

        if($printKanbanBtn)
        {
            $actions .= "<a class='toolbar-item ghost btn btn-default setting' type='button' data-toggle='dropdown' data-target='#kanbanActionMenu'>" . '<i class="icon icon-edit"></i> ' . $this->lang->edit . '</a>';
            $actions .= "<menu id='kanbanActionMenu' class='dropdown-menu text-left'>";

            $columnActions = '';
            $actions .= $columnActions;

            $commonActions = '';

            if($columnActions && $commonActions)
            {
                $actions .= "<li class='divider'></li>";
            }
            $actions .= $commonActions;

            $kanbanActions = '';
            if(common::hasPriv('kanban', 'edit')) $kanbanActions .= '<li class="menu-item item">' . html::a(helper::createLink('kanban', 'edit', "kanbanID=$kanban->id", '', true), '<i class="icon icon-edit"></i>' . $this->lang->kanban->edit, '', "class='listitem item-inner menu-item-inner state' data-toggle='modal'") . '</li>';
            if(common::hasPriv('kanban', 'close') && $kanban->status == 'active') $kanbanActions .= '<li class="menu-item item">' . html::a(helper::createLink('kanban', 'close', "kanbanID=$kanban->id", '', true), '<i class="icon icon-off"></i>' . $this->lang->kanban->close, '', "class='listitem item-inner menu-item-inner state' data-toggle='modal'") . '</li>';
            if(common::hasPriv('kanban', 'activate') && $kanban->status == 'closed') $kanbanActions .= '<li class="menu-item item">' . html::a(helper::createLink('kanban', 'activate', "kanbanID=$kanban->id", '', true), '<i class="icon icon-magic"></i>' . $this->lang->kanban->activate, '', "class='listitem item-inner menu-item-inner state' data-toggle='modal'") . '</li>';
            if(common::hasPriv('kanban', 'delete')) $kanbanActions .= '<li class="menu-item item">' . html::a(helper::createLink('kanban', 'delete', "kanbanID=$kanban->id"), '<i class="icon icon-trash"></i>' . $this->lang->kanban->delete, 'hiddenwin', "class='listitem item-inner menu-item-inner state ajax-submit' data-confirm={$this->lang->kanban->confirmDeleteKanban}") . '</li>';

            if($commonActions && $kanbanActions)
            {
                $actions .= "<li class='divider'></li>";
            }
            $actions .= $kanbanActions;

            $actions .= "</menu>";
        }

        if(common::hasPriv('kanban', 'setting'))
        {
            $actions .= html::a(helper::createLink('kanban', 'setting', "kanbanID=$kanban->id", '', true), '<i class="icon icon-cog-outline"></i> ' . $this->lang->kanban->setting, '', "class='toolbar-item ghost btn btn-default' data-toggle='modal'");
        }

        return $actions;
    }

    /**
     * 排序看板分组。
     * Sort kanban group.
     *
     * @param  int    $region
     * @param  array  $groups
     * @access public
     * @return bool
     */
    public function sortGroup($region, $groups)
    {
        $groupList = $this->getGroupList($region);

        $order = 1;
        foreach($groups as $groupID)
        {
            if(!$groupID) continue;
            if(!isset($groupList[$groupID])) continue;

            $this->dao->update(TABLE_KANBANGROUP)->set('`order`')->eq($order)->where('id')->eq($groupID)->exec();

            $order++;
        }

        return !dao::isError();
    }

    /**
     * 移动看板卡片。
     * Move a card.
     *
     * @param  int    $cardID
     * @param  int    $fromColID
     * @param  int    $toColID
     * @param  int    $fromLaneID
     * @param  int    $toLaneID
     * @param  int    $kanbanID
     * @access public
     * @return void
     */
    public function moveCard($cardID, $fromColID, $toColID, $fromLaneID, $toLaneID, $kanbanID = 0)
    {
        $groupBy = ($this->session->executionGroupBy && ($this->app->tab == 'execution' || $this->config->vision == 'lite')) ? $this->session->executionGroupBy : '';

        $fromCell = $this->dao->select('id,cards,lane')->from(TABLE_KANBANCELL)
            ->where('`column`')->eq($fromColID)
            ->beginIF(!$groupBy || $groupBy == 'default')->andWhere('lane')->eq($fromLaneID)->fi()
            ->beginIF($groupBy && $groupBy != 'default')
            ->andWhere('type')->eq($this->session->executionLaneType)
            ->andWhere('cards')->like("%,$cardID,%")
            ->fi()
            ->fetch();

        if($groupBy && $groupBy != 'default') $fromLaneID = $toLaneID = $fromCell->lane;
        $fromCells[$fromCell->id] = $fromCell;

        /* Remove all cells with cardID in fromCell. */
        $fromLane   = $this->getLaneById($fromLaneID);
        $fromCells += $this->dao->select('t1.id as id,t1.cards,t1.lane')->from(TABLE_KANBANCELL)->alias('t1')
            ->leftJoin(TABLE_KANBANLANE)->alias('t2')->on('t1.lane=t2.id')
            ->where('t1.type')->eq($fromLane->type)
            ->andWhere('t1.id')->ne($fromCell->id)
            ->andWhere('t2.region')->eq($fromLane->region)
            ->andWhere('t1.cards')->like("%,$cardID,%")
            ->fetchAll('id');

        foreach($fromCells as $fromCell)
        {
            $fromCellCards = explode(',', $fromCell->cards);
            $fromCellCards = array_unique($fromCellCards);
            $fromCellCards = array_filter($fromCellCards);
            $fromCellCards = implode(',', $fromCellCards);
            $fromCardList  = str_replace(",$cardID,", ',', ",$fromCellCards,");
            if($fromCardList == ',') $fromCardList = '';
            $this->dao->update(TABLE_KANBANCELL)->set('cards')->eq($fromCardList)->where('id')->eq($fromCell->id)->exec();
        }

        /* Add cardID to toCell. */
        $toCell      = $this->dao->select('*')->from(TABLE_KANBANCELL)->where('lane')->eq($toLaneID)->andWhere('`column`')->eq($toColID)->fetch();
        $toCellCards = $this->dao->select('cards')->from(TABLE_KANBANCELL)->where('lane')->eq($toLaneID)->andWhere('`column`')->eq($toColID)->fetch('cards');
        $kanbanID    = $kanbanID == 0 ? $toCell->kanban : $kanbanID;
        if(!$toCell) $this->addKanbanCell($kanbanID, $toLaneID, $toColID, 'common');

        $toCardList = rtrim($toCellCards, ',') . ",$cardID,";
        $this->dao->update(TABLE_KANBANCELL)->set('cards')->eq($toCardList)->where('`column`')->eq($toColID)->andWhere('lane')->eq($toLaneID)->exec();

        $toLane = $this->getLaneById($toLaneID);
        $this->dao->update(TABLE_KANBANCARD)->set('group')->eq($toLane->group)->where('id')->eq($cardID)->exec();
    }

    /**
     * 更新卡片的颜色。
     * Update a card's color.
     *
     * @param  int    $cardID
     * @param  string $color
     * @access public
     * @return void
     */
    public function updateCardColor($cardID, $color)
    {
        $this->dao->update(TABLE_KANBANCARD)->set('`color`')->eq('#' . $color)->where('id')->eq($cardID)->exec();
    }

    /**
     * 归档看板列。
     * Archive a column.
     *
     * @param  int    $columnID
     * @access public
     * @return void
     */
    public function archiveColumn($columnID)
    {
        $this->dao->update(TABLE_KANBANCOLUMN)
            ->set('archived')->eq(1)
            ->where('id')->eq($columnID)
            ->exec();

        if(!dao::isError()) $this->loadModel('action')->create('kanbancolumn', $columnID, 'archived');
    }

    /**
     * 还原看板列。
     * Restore a column.
     *
     * @param  int    $columnID
     * @access public
     * @return void
     */
    public function restoreColumn($columnID)
    {
        $column = $this->getColumnByID($columnID);

        if($column->parent)
        {
            $parent = $this->getColumnByID($column->parent);

            /* If the parent column is normal now, put its card into child column. */
            if($parent && $parent->parent != -1)
            {
                $parentCells = $this->dao->select('*')->from(TABLE_KANBANCELL)
                    ->where('`column`')->eq($column->parent)
                    ->andWhere('type')->eq('common')
                    ->fetchAll('id');

                foreach($parentCells as $cell)
                {
                    $cards = $this->dao->select('cards')->from(TABLE_KANBANCELL)
                        ->where('lane')->eq($cell->lane)
                        ->andWhere('`column`')->eq($columnID)
                        ->andWhere('type')->eq('common')
                        ->fetch('cards');

                    $cards = $cards ? $cards . ltrim($cell->cards, ',') : $cell->cards;

                    $this->dao->update(TABLE_KANBANCELL)->set('cards')->eq($cards)
                        ->where('lane')->eq($cell->lane)
                        ->andWhere('`column`')->eq($columnID)
                        ->andWhere('type')->eq('common')
                        ->exec();

                    $this->dao->update(TABLE_KANBANCELL)->set('cards')->eq('')
                        ->where('lane')->eq($cell->lane)
                        ->andWhere('`column`')->eq($cell->column)
                        ->andWhere('type')->eq('common')
                        ->exec();
                }

                $this->dao->update(TABLE_KANBANCOLUMN)->set('parent')->eq(-1)->where('id')->eq($column->parent)->exec();
            }
        }

        $this->dao->update(TABLE_KANBANCOLUMN)
            ->set('archived')->eq(0)
            ->where('id')->eq($columnID)
            ->exec();

        if(!dao::isError()) $this->loadModel('action')->create('kanbancolumn', $columnID, 'restore');
    }

    /**
     * 归档看板卡片。
     * Archive a card.
     *
     * @param  int    $cardID
     * @access public
     * @return bool
     */
    public function archiveCard($cardID)
    {
        $oldCard = $this->getCardByID($cardID);

        $this->dao->update(TABLE_KANBANCARD)
            ->set('archived')->eq(1)
            ->set('archivedBy')->eq($this->app->user->account)
            ->set('archivedDate')->eq(helper::now())
            ->where('id')->eq($cardID)
            ->exec();

        $card = $this->getCardByID($cardID);

        if(dao::isError()) return false;
        $changes = common::createChanges($oldCard, $card);

        if($changes)
        {
            $actionID = $this->loadModel('action')->create('kanbancard', $cardID, 'archived');
            $this->action->logHistory($actionID, $changes);
        }

        return true;
    }

    /**
     * 还原看板卡片。
     * Restore a card.
     *
     * @param  int    $cardID
     * @access public
     * @return bool
     */
    public function restoreCard($cardID)
    {
        $oldCard = $this->getCardByID($cardID);

        $this->dao->update(TABLE_KANBANCARD)
            ->set('archived')->eq(0)
            ->set('archivedBy')->eq('')
            ->set('archivedDate')->eq(null)
            ->where('id')->eq($cardID)
            ->exec();

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

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

        return true;
    }

    /**
     * 删除子列时，将子列的卡片移动到第一个子列，或者移动到父列。
     * Process cards when delete a column.
     *
     * @param  object $column
     * @access public
     * @return void
     */
    public function processCards($column)
    {
        $firstColumnID = $this->dao->select('id')->from(TABLE_KANBANCOLUMN)
            ->where('parent')->eq($column->parent)
            ->andWhere('id')->ne($column->id)
            ->andWhere('deleted')->eq('0')
            ->andWhere('archived')->eq('0')
            ->orderBy('`order` asc')
            ->fetch('id');

        $extendID = $firstColumnID;
        if(!$firstColumnID)
        {
            $extendID = $column->parent;
            $this->dao->update(TABLE_KANBANCOLUMN)->set('parent')->eq(0)->where('id')->eq($column->parent)->exec();
        }

        $cells = $this->dao->select('*')->from(TABLE_KANBANCELL)
            ->where('`column`')->eq($column->id)
            ->andWhere('type')->eq('common')
            ->fetchAll('id');

        foreach($cells as $cell)
        {
            $extendCards = $this->dao->select('cards')->from(TABLE_KANBANCELL)
                ->where('lane')->eq($cell->lane)
                ->andWhere('`column`')->eq($extendID)
                ->andWhere('type')->eq('common')
                ->fetch('cards');

            $extendCards = $extendCards ? $extendCards . ltrim($cell->cards, ',') : $cell->cards;

            $this->dao->update(TABLE_KANBANCELL)->set('cards')->eq($extendCards)
                ->where('lane')->eq($cell->lane)
                ->andWhere('`column`')->eq($extendID)
                ->andWhere('type')->eq('common')
                ->exec();
        }
    }

    /**
     * 获取看板空间。
     * Get space by id.
     *
     * @param  int    $spaceID
     * @access public
     * @return object
     */
    public function getSpaceById($spaceID)
    {
        $space = $this->dao->findById($spaceID)->from(TABLE_KANBANSPACE)->fetch();
        if($space) $space = $this->loadModel('file')->replaceImgURL($space, 'desc');
        return $space;
    }

    /**
     * Get kanban group by space id list.
     *
     * @param  array $spaceIdList
     * @param  array $kanbanIdList
     * @access public
     * @return array
     */
    public function getGroupBySpaceList($spaceIdList, $kanbanIdList = array())
    {
        $spaceList = $this->dao->select('*')->from(TABLE_KANBAN)
            ->where('deleted')->eq(0)
            ->andWhere('space')->in($spaceIdList)
            ->beginIF($kanbanIdList)->andWhere('id')->in($kanbanIdList)->fi()
            ->fetchGroup('space', 'id');

        $kanbanIDList = array();
        foreach($spaceList as $kanbanList) $kanbanIDList = array_merge_recursive($kanbanIDList, array_keys($kanbanList));
        $cardsCount = $this->dao->select('kanban, COUNT(1) AS count')->from(TABLE_KANBANCARD)
            ->where('deleted')->eq(0)
            ->andWhere('kanban')->in($kanbanIDList)
            ->groupBy('kanban')
            ->fetchPairs('kanban');

        foreach($spaceList as $kanbanList)
        {
            foreach($kanbanList as $kanban)
            {
                $kanban->cardsCount = zget($cardsCount, $kanban->id, 0);
            }
        }

        return $spaceList;
    }

    /**
     * 获取看板组。
     * Get group list by region.
     *
     * @param  int    $regionID
     * @access public
     * @return array
     */
    public function getGroupList($regionID)
    {
        return $this->dao->select('*')->from(TABLE_KANBANGROUP)
            ->where('region')->eq($regionID)
            ->orderBy('order')
            ->fetchAll('id');
    }

    /**
     * 获取看板列。
     * Get column by id.
     *
     * @param  int    $columnID
     * @access public
     * @return object
     */
    public function getColumnByID($columnID)
    {
        if(common::isTutorialMode()) return $this->loadModel('tutorial')->getColumn();

        $column = $this->dao->select('t1.*, t2.type as laneType')->from(TABLE_KANBANCOLUMN)->alias('t1')
            ->leftJoin(TABLE_KANBANCELL)->alias('t2')->on('t1.id=t2.column')
            ->where('t1.id')->eq($columnID)
            ->fetch();

        if(empty($column)) return false;
        if($column->parent > 0) $column->parentName = $this->dao->findById($column->parent)->from(TABLE_KANBANCOLUMN)->fetch('name');

        return $column;
    }

    /**
     * 根据字段获取看板列。
     * Get columns by field.
     *
     * @param  string $field    parent|region|group
     * @param  int    $fieldID
     * @param  string $archived
     * @param  string $deleted
     * @access public
     * @return array
     */
    public function getColumnsByField($field = '', $fieldID = 0, $archived = '0', $deleted = '0')
    {
        return $this->dao->select('*')->from(TABLE_KANBANCOLUMN)
            ->where('1 = 1')
            ->beginIF($field)->andWhere($field)->eq($fieldID)->fi()
            ->beginIF($archived != '')->andWhere('archived')->eq($archived)->fi()
            ->beginIF($deleted != '')->andWhere('deleted')->eq($deleted)->fi()
            ->orderBy('order')
            ->fetchAll('id');
    }

    /**
     * 获取泳道的看板列ID。
     * Get column ID by lane ID.
     *
     * @param  int    $laneID
     * @param  string $columnType
     * @access public
     * @return string|int
     */
    public function getColumnIDByLaneID($laneID, $columnType)
    {
        return $this->dao->select('t1.column')->from(TABLE_KANBANCELL)->alias('t1')
            ->leftJoin(TABLE_KANBANCOLUMN)->alias('t2')->on('t1.column = t2.id')
            ->where('t1.lane')->eq($laneID)
            ->andWhere('t2.type')->eq($columnType)
            ->fetch('column');
    }

    /**
     * 根据ID获取看板泳道。
     * Get lane by id.
     *
     * @param  int    $laneID
     * @access public
     * @return object
     */
    public function getLaneById($laneID)
    {
        return $this->dao->findById($laneID)->from(TABLE_KANBANLANE)->fetch();
    }

    /**
     * 获取看板卡片。
     * Get card by id.
     *
     * @param  int    $cardID
     * @access public
     * @return object|false
     */
    public function getCardByID($cardID)
    {
        $card = $this->dao->select('*')->from(TABLE_KANBANCARD)->where('id')->eq($cardID)->fetch();
        if(!$card) return false;

        $card = $this->loadModel('file')->replaceImgURL($card, 'desc');

        return $card;
    }

    /**
     * 获取某个对象的看板卡片。
     * Get cards by object id.
     *
     * @param  string     $objectType kanban|region|group|lane|column
     * @param  int|string $objectID
     * @param  string     $archived
     * @param  string     $deleted
     * @access public
     * @return array
     */
    public function getCardsByObject($objectType = '', $objectID = 0, $archived = '0', $deleted = '0')
    {
        return $this->dao->select('*')->from(TABLE_KANBANCARD)
            ->where('1 = 1')
            ->beginIF($objectType)->andWhere($objectType)->eq($objectID)->fi()
            ->beginIF($archived != '')->andWhere('archived')->eq($archived)->fi()
            ->beginIF($deleted != '')->andWhere('deleted')->eq($deleted)->fi()
            ->orderBy('order')
            ->fetchAll('id');
    }

    /**
     * 获取可转入的看板卡片。
     * Get cards to import.
     *
     * @param  int    $kanbanID
     * @param  int    $excludedID
     * @param  object $pager
     * @access public
     * @return array
     */
    public function getCards2Import($kanbanID = 0, $excludedID = 0, $pager = null)
    {
        $kanbanIdList = $this->getCanViewObjects();

        return $this->dao->select('*')->from(TABLE_KANBANCARD)
            ->where('deleted')->eq(0)
            ->andWhere('archived')->eq(0)
            ->andWhere('fromID')->eq(0)
            ->andWhere('kanban')->in($kanbanIdList)
            ->beginIF($kanbanID)->andWhere('kanban')->eq($kanbanID)->fi()
            ->beginIF($excludedID)->andWhere('kanban')->ne($excludedID)->fi()
            ->orderBy('order')
            ->page($pager)
            ->fetchAll('id');
    }

    /**
     * 获取看板卡片的操作按钮。
     * Get Kanban cards menus by execution id.
     *
     * @param  int    $executionID
     * @param  array  $objects
     * @param  string $objecType   story|bug|task
     * @access public
     * @return array
     */
    public function getKanbanCardMenu($executionID, $objects, $objecType)
    {
        $this->app->loadLang('execution');
        $execution = $this->loadModel('execution')->getByID($executionID);
        if(!common::canModify('execution', $execution)) return array();

        $menus = array();
        switch ($objecType)
        {
            case 'epic':
                $menus = $this->kanbanTao->getERURCardMenu($executionID, $objects);
                break;
            case 'requirement':
                $menus = $this->kanbanTao->getERURCardMenu($executionID, $objects);
                break;
            case 'parentStory':
                $menus = $this->kanbanTao->getERURCardMenu($executionID, $objects);
                break;
            case 'story':
                if($execution) $menus = $this->kanbanTao->getStoryCardMenu($execution, $objects);
                break;
            case 'bug':
                $menus = $this->kanbanTao->getBugCardMenu($objects);
                break;
            case 'task':
                $menus = $this->kanbanTao->getTaskCardMenu($objects);
                break;
            case 'risk':
                $menus = $this->kanbanTao->getRiskCardMenu($objects);
                break;
        }
        return $menus;
    }

    /**
     * 获取卡片的发送人和抄送人。
     * Get toList and ccList.
     *
     * @param  object    $card
     * @access public
     * @return bool|array
     */
    public function getToAndCcList($card)
    {
        /* Set toList and ccList. */
        $toList = $card->createdBy;
        $ccList = trim($card->assignedTo, ',');

        if(empty($toList))
        {
            if(empty($ccList)) return false;
            if(strpos($ccList, ',') === false)
            {
                $toList = $ccList;
                $ccList = '';
            }
            else
            {
                $commaPos = strpos($ccList, ',');
                $toList   = substr($ccList, 0, $commaPos);
                $ccList   = substr($ccList, $commaPos + 1);
            }
        }

        return array($toList, $ccList);
    }

    /**
     * 检查操作按钮是否可点。
     * Check if user can execute an action.
     *
     * @param  object $object
     * @param  string $action
     * @access public
     * @return bool
     */
    public function isClickable($object, $action)
    {
        $action    = strtolower($action);
        $clickable = commonModel::hasPriv('kanban', $action);
        if(!$clickable) return false;

        switch($action)
        {
            case 'sortlane' :
            case 'deletelane' :
                if($object->deleted != '0') return false;

                $count = $this->dao->select('COUNT(id) AS count')->from(TABLE_KANBANLANE)
                    ->where('deleted')->eq('0')
                    ->andWhere('region')->eq($object->region)
                    ->beginIF($action == 'sortlane')->andWhere('`group`')->eq($object->group)->fi()
                    ->fetch('count');
                return $count > 1;
            case 'splitcolumn' :
                return $object->parent == 0;
            case 'restorecolumn' :
                if($object->parent > 0)
                {
                    $parent = $this->getColumnByID($object->parent);
                    if(!empty($parent) && ($parent->deleted == '1' || $parent->archived == '1')) return false;
                }
                return $object->archived == '1';
            case 'archivecolumn' :
                return $object->archived == '0';    // The column has been archived.
            case 'deletecolumn' :
                return $object->deleted == '0';
            case 'sortcolumn':
                return $object->deleted == '0';
            case 'editcard':
                return $object->deleted == '0' && $object->archived == '0';
            case 'finishcard':
                return $object->status != 'done' && $object->deleted == '0' && $object->archived == '0';
            case 'activatecard':
                return $object->status == 'done' && $object->deleted == '0' && $object->archived == '0';
            case 'deletecard':
                return $object->deleted == '0';
            case 'archivecard':
                return $object->archived == '0';
            case 'restorecard':
                return $object->archived == '1';
        }

        return true;
    }

    /**
     * 获取泳道的数量。
     * Get kanban lane count.
     *
     * @param  int    $kanbanID
     * @param  string $type
     * @access public
     * @return int
     */
    public function getLaneCount($kanbanID, $type = 'common')
    {
        if($type == 'common' || $type == 'kanban')
        {
            return $this->dao->select('COUNT(t2.id) as count')->from(TABLE_KANBANREGION)->alias('t1')
                ->leftJoin(TABLE_KANBANLANE)->alias('t2')->on('t1.id=t2.region')
                ->where('t1.kanban')->eq($kanbanID)
                ->andWhere('t1.deleted')->eq(0)
                ->andWhere('t2.deleted')->eq(0)
                ->beginIF($type == 'common')->andWhere('t2.type')->eq('common')->fi()
                ->beginIF($type != 'common')->andWhere('t2.type')->ne('common')->fi()
                ->fetch('count');
        }
        else
        {
            return $this->dao->select('COUNT(id) as count')->from(TABLE_KANBANLANE)
                ->where('execution')->eq($kanbanID)
                ->andWhere('deleted')->eq(0)
                ->fetch('count');
        }
    }

    /**
     * 检查卡片数量是否合法。
     * Check display card count.
     *
     * @param  int    $count
     * @access public
     * @return bool
     */
    public function checkDisplayCards($count)
    {
        if(!preg_match("/^-?\d+$/", (string)$count) || $count <= DEFAULT_CARDCOUNT || $count > MAX_CARDCOUNT) dao::$errors['displayCards'] = $this->lang->kanbanlane->error->mustBeInt;
        return !dao::isError();
    }

    /**
     * 获取看板动态刷新的回调函数数组。
     * Get kanban callback data.
     *
     * @param  int    $kanbanID
     * @param  int    $regionID
     * @access public
     * @return void
     */
    public function getKanbanCallback($kanbanID, $regionID)
    {
        $kanbanData = $this->getKanbanData($kanbanID, $regionID);
        $kanbanData = reset($kanbanData);
        return array('name' => 'updateKanbanRegion', 'params' => array('region' . $regionID, $kanbanData));
    }

    /**
     * 获取卡片所在的单元格。
     * Get card cell.
     *
     * @param  int    $cardID
     * @param  int    $kanbanID
     * @access public
     * @return object
     */
    public function getCellByCard($cardID, $kanbanID)
    {
        return $this->dao->select('id,cards,lane,`column`')->from(TABLE_KANBANCELL)
            ->where('kanban')->eq($kanbanID)
            ->andWhere('type')->eq('common')
            ->andWhere('cards')->like("%,$cardID,%")
            ->fetch();
    }
}
