Files
Leantime/app/Domain/Tickets/Services/TicketResource.php

287 lines
10 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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_bomtype 区分、文件zp_file
* wikizp_canvas/zp_canvas_items。关联边落在 zp_entity_relationshipLinkedResource
* 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;
}
}