287 lines
10 KiB
PHP
287 lines
10 KiB
PHP
<?php
|
||
|
||
namespace Leantime\Domain\Tickets\Services;
|
||
|
||
use Leantime\Core\Domains\BaseService;
|
||
use Leantime\Domain\Bom\Permissions\BomPermissions;
|
||
use Leantime\Domain\Bom\Services\Bom as BomService;
|
||
use Leantime\Domain\Files\Permissions\FilesPermissions;
|
||
use Leantime\Domain\Files\Repositories\Files as FileRepository;
|
||
use Leantime\Domain\Files\Services\Files as FilesService;
|
||
use Leantime\Domain\Tickets\Permissions\TicketsPermissions;
|
||
use Leantime\Domain\Tickets\Repositories\TicketResource as TicketResourceRepository;
|
||
use Leantime\Domain\Tickets\Repositories\Tickets as TicketsRepository;
|
||
use Leantime\Domain\Wiki\Permissions\WikiPermissions;
|
||
use Leantime\Domain\Wiki\Services\Wiki as WikiService;
|
||
|
||
/**
|
||
* 待办事项(ticket/milestone) ↔ 资源关联服务。
|
||
*
|
||
* 5 类资源:BOM / 工艺文件 / 工具清单(共用 zp_bom,type 区分)、文件(zp_file)、
|
||
* wiki(zp_canvas/zp_canvas_items)。关联边落在 zp_entity_relationship(LinkedResource),
|
||
* entityBType 用资源类型标识:bom | process | tooling | file | wiki。
|
||
*/
|
||
class TicketResource extends BaseService
|
||
{
|
||
/** 资源类型 → 显示名 */
|
||
public const TYPE_NAMES = [
|
||
'bom' => 'BOM',
|
||
'process' => '工艺文件',
|
||
'tooling' => '工具清单',
|
||
'file' => '文件',
|
||
'wiki' => 'Wiki',
|
||
];
|
||
|
||
/** 主数据类型(对应 zp_bom.type) */
|
||
public const MASTER_TYPES = ['bom', 'process', 'tooling'];
|
||
|
||
public function __construct(
|
||
protected TicketResourceRepository $repo,
|
||
protected TicketsRepository $ticketRepo,
|
||
protected BomService $bomService,
|
||
protected FilesService $filesService,
|
||
protected WikiService $wikiService,
|
||
protected FileRepository $fileRepository,
|
||
) {}
|
||
|
||
/**
|
||
* 关联一个资源到 ticket(幂等)。
|
||
*
|
||
* @return array{success:bool,message?:string}
|
||
*/
|
||
public function link(int $ticketId, string $resourceType, int $resourceId): array
|
||
{
|
||
if ($ticketId <= 0 || $resourceId <= 0 || ! array_key_exists($resourceType, self::TYPE_NAMES)) {
|
||
return ['success' => false, 'message' => '无效的关联参数'];
|
||
}
|
||
|
||
$ticket = $this->ticketRepo->getTicket($ticketId);
|
||
if ($ticket === false) {
|
||
return ['success' => false, 'message' => '待办事项不存在'];
|
||
}
|
||
$projectId = (int) $ticket->projectId;
|
||
|
||
// 写关联需 tickets.edit;读资源详情需对应资源 view 权限
|
||
$this->authorize(TicketsPermissions::EDIT, $projectId);
|
||
|
||
if (! $this->resourceExists($resourceType, $resourceId, $projectId)) {
|
||
return ['success' => false, 'message' => '资源不存在或不属于当前项目'];
|
||
}
|
||
|
||
$userId = (int) (session('userdata.id') ?? 0);
|
||
$ok = $this->repo->addLink($ticketId, $resourceType, $resourceId, $userId);
|
||
|
||
return $ok
|
||
? ['success' => true]
|
||
: ['success' => false, 'message' => '关联失败'];
|
||
}
|
||
|
||
/**
|
||
* 解除一个资源关联。
|
||
*/
|
||
public function unlink(int $ticketId, string $resourceType, int $resourceId): array
|
||
{
|
||
if ($ticketId <= 0 || $resourceId <= 0) {
|
||
return ['success' => false, 'message' => '无效参数'];
|
||
}
|
||
$ticket = $this->ticketRepo->getTicket($ticketId);
|
||
if ($ticket === false) {
|
||
return ['success' => false, 'message' => '待办事项不存在'];
|
||
}
|
||
$this->authorize(TicketsPermissions::EDIT, (int) $ticket->projectId);
|
||
|
||
return $this->repo->removeLink($ticketId, $resourceType, $resourceId)
|
||
? ['success' => true]
|
||
: ['success' => false, 'message' => '解除失败'];
|
||
}
|
||
|
||
/**
|
||
* 某 ticket 已关联的资源详情列表(供详情页 chips 渲染)。
|
||
*
|
||
* @return array<int, array{type:string,typeName:string,id:int,title:string,subtitle?:string,url?:string}>
|
||
*/
|
||
public function getLinkedResources(int $ticketId): array
|
||
{
|
||
$links = $this->repo->getLinks($ticketId);
|
||
$result = [];
|
||
foreach ($links as $link) {
|
||
$detail = $this->resolveResourceDetail($link['type'], $link['id']);
|
||
if ($detail !== null) {
|
||
$result[] = $detail + ['type' => $link['type'], 'id' => $link['id']];
|
||
}
|
||
}
|
||
|
||
return $result;
|
||
}
|
||
|
||
/**
|
||
* 候选资源列表(供「添加关联」下拉)。
|
||
*
|
||
* @return array<int, array{type:string,typeName:string,items:array<int,array{id:int,title:string,subtitle?:string}>}>
|
||
*/
|
||
public function listCandidates(int $projectId): array
|
||
{
|
||
$groups = [];
|
||
|
||
// 主数据:BOM / 工艺文件 / 工具清单(全局,projectId=0)
|
||
foreach (self::MASTER_TYPES as $type) {
|
||
$masters = $this->bomService->getMasters($type, 0);
|
||
$items = [];
|
||
foreach ($masters as $m) {
|
||
$title = trim(($m['bomNo'] ?? '').' '.($m['productName'] ?? ''));
|
||
if ($title === '') {
|
||
$title = '#'.($m['id'] ?? '');
|
||
}
|
||
$items[] = [
|
||
'id' => (int) $m['id'],
|
||
'title' => $title,
|
||
'subtitle' => ($m['version'] ?? '') !== '' ? '版本 '.$m['version'] : null,
|
||
];
|
||
}
|
||
$groups[] = ['type' => $type, 'typeName' => self::TYPE_NAMES[$type], 'items' => $items];
|
||
}
|
||
|
||
// 文件(项目文件)
|
||
try {
|
||
$files = $this->filesService->getFilesByModule('project', $projectId);
|
||
$items = [];
|
||
if (is_array($files)) {
|
||
foreach ($files as $f) {
|
||
$items[] = [
|
||
'id' => (int) $f['id'],
|
||
'title' => (string) ($f['realName'] ?? ''),
|
||
'subtitle' => ($f['firstname'] ?? '').' '.($f['lastname'] ?? ''),
|
||
];
|
||
}
|
||
}
|
||
$groups[] = ['type' => 'file', 'typeName' => self::TYPE_NAMES['file'], 'items' => $items];
|
||
} catch (\Throwable $e) {
|
||
$groups[] = ['type' => 'file', 'typeName' => self::TYPE_NAMES['file'], 'items' => []];
|
||
}
|
||
|
||
// Wiki(项目 wiki 的标题作为可关联单元)
|
||
try {
|
||
$wikis = $this->wikiService->getAllProjectWikis($projectId);
|
||
$items = [];
|
||
if (is_array($wikis)) {
|
||
foreach ($wikis as $w) {
|
||
$items[] = [
|
||
'id' => (int) ($w['id'] ?? $w->id ?? 0),
|
||
'title' => (string) ($w['title'] ?? 'Wiki'),
|
||
];
|
||
}
|
||
}
|
||
$groups[] = ['type' => 'wiki', 'typeName' => self::TYPE_NAMES['wiki'], 'items' => $items];
|
||
} catch (\Throwable $e) {
|
||
$groups[] = ['type' => 'wiki', 'typeName' => self::TYPE_NAMES['wiki'], 'items' => []];
|
||
}
|
||
|
||
return $groups;
|
||
}
|
||
|
||
/**
|
||
* 校验资源是否存在(并做项目作用域兜底校验,主数据为全局)。
|
||
*/
|
||
private function resourceExists(string $resourceType, int $resourceId, int $projectId): bool
|
||
{
|
||
return match ($resourceType) {
|
||
'bom', 'process', 'tooling' => $this->bomService->getBom($resourceId) !== false,
|
||
'file' => $this->fileExists($resourceId),
|
||
'wiki' => $this->wikiExists($resourceId, $projectId),
|
||
default => false,
|
||
};
|
||
}
|
||
|
||
private function fileExists(int $fileId): bool
|
||
{
|
||
try {
|
||
$f = $this->filesService->getFileById($fileId);
|
||
|
||
return $f !== false;
|
||
} catch (\Throwable $e) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
private function wikiExists(int $wikiId, int $projectId): bool
|
||
{
|
||
try {
|
||
$wikis = $this->wikiService->getAllProjectWikis($projectId);
|
||
foreach ($wikis as $w) {
|
||
if ((int) ($w['id'] ?? $w->id ?? 0) === $wikiId) {
|
||
return true;
|
||
}
|
||
}
|
||
} catch (\Throwable $e) {
|
||
// ignore
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* 解析资源详情(供 chips / 弹窗)。
|
||
*
|
||
* @return array{type:string,typeName:string,id:int,title:string,subtitle?:string,url?:string}|null
|
||
*/
|
||
private function resolveResourceDetail(string $resourceType, int $resourceId): ?array
|
||
{
|
||
$typeName = self::TYPE_NAMES[$resourceType] ?? $resourceType;
|
||
|
||
if (in_array($resourceType, self::MASTER_TYPES, true)) {
|
||
$bom = $this->bomService->getBom($resourceId);
|
||
if ($bom === false) {
|
||
return null;
|
||
}
|
||
$title = trim(($bom['bomNo'] ?? '').' '.($bom['productName'] ?? ''));
|
||
if ($title === '') {
|
||
$title = '#'.$resourceId;
|
||
}
|
||
|
||
return [
|
||
'type' => $resourceType,
|
||
'typeName' => $typeName,
|
||
'id' => $resourceId,
|
||
'title' => $title,
|
||
'subtitle' => ($bom['version'] ?? '') !== '' ? '版本 '.$bom['version'] : null,
|
||
'url' => BASE_URL.'/bom/show/'.$resourceId,
|
||
];
|
||
}
|
||
|
||
if ($resourceType === 'file') {
|
||
$f = $this->fileRepository->getFile($resourceId);
|
||
if ($f === false) {
|
||
return null;
|
||
}
|
||
|
||
return [
|
||
'type' => 'file',
|
||
'typeName' => $typeName,
|
||
'id' => $resourceId,
|
||
'title' => (string) ($f['realName'] ?? ('文件 #'.$resourceId)),
|
||
'subtitle' => trim(($f['firstname'] ?? '').' '.($f['lastname'] ?? '')),
|
||
'url' => BASE_URL.'/files/get?fileId='.$resourceId,
|
||
];
|
||
}
|
||
|
||
if ($resourceType === 'wiki') {
|
||
try {
|
||
$article = $this->wikiService->getArticle($resourceId);
|
||
$title = (string) ($article->title ?? ('Wiki #'.$resourceId));
|
||
|
||
return [
|
||
'type' => 'wiki',
|
||
'typeName' => $typeName,
|
||
'id' => $resourceId,
|
||
'title' => $title,
|
||
'url' => BASE_URL.'/wiki/showArticle/'.$resourceId,
|
||
];
|
||
} catch (\Throwable $e) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
}
|