'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 */ 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}> */ 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; } }