From aedec2a2ae37763be4bf86a3c700fd8ee3233f25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Fri, 6 Feb 2026 15:16:10 +0900 Subject: [PATCH] =?UTF-8?q?fix:=EB=8F=99=EC=9D=BC=20=EB=B6=84=EB=A5=98=20?= =?UTF-8?q?=EB=82=B4=20=EC=97=B0=EA=B2=B0=ED=92=88=EB=AA=A9=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=EB=B0=A9=EC=A7=80=20=EB=B0=8F=20=EB=B3=B5=EC=82=AC?= =?UTF-8?q?=20=EC=8B=9C=20=EC=97=B0=EA=B2=B0=ED=92=88=EB=AA=A9=20=EC=A0=9C?= =?UTF-8?q?=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - store/update 시 같은 category의 다른 템플릿에 이미 연결된 품목이 있으면 422 에러 반환 - duplicate 시 linked_item_ids, linked_process_id, linkValues 복사하지 않음 - 레거시(linked_item_ids)와 신규(template_link_values) 양쪽 경로 모두 검증 Co-Authored-By: Claude Opus 4.6 --- .../Admin/DocumentTemplateApiController.php | 156 ++++++++++++++++-- 1 file changed, 139 insertions(+), 17 deletions(-) diff --git a/app/Http/Controllers/Api/Admin/DocumentTemplateApiController.php b/app/Http/Controllers/Api/Admin/DocumentTemplateApiController.php index b9e2c94b..c686b4c3 100644 --- a/app/Http/Controllers/Api/Admin/DocumentTemplateApiController.php +++ b/app/Http/Controllers/Api/Admin/DocumentTemplateApiController.php @@ -39,12 +39,22 @@ public function index(Request $request): View $query->onlyTrashed(); } - // 검색 + // 검색 (양식명, 제목, 분류, 연결 품목) if ($search = $request->input('search')) { - $query->where(function ($q) use ($search) { + // 연결 품목 검색을 위해 품목명으로 ID 조회 + $matchingItemIds = \App\Models\Items\Item::where('name', 'like', "%{$search}%") + ->pluck('id') + ->toArray(); + + $query->where(function ($q) use ($search, $matchingItemIds) { $q->where('name', 'like', "%{$search}%") ->orWhere('title', 'like', "%{$search}%") ->orWhere('category', 'like', "%{$search}%"); + + // 연결 품목 검색 (JSON 배열에서 매칭) + foreach ($matchingItemIds as $itemId) { + $q->orWhereJsonContains('linked_item_ids', $itemId); + } }); } @@ -70,7 +80,21 @@ public function index(Request $request): View ->toArray(); } - return view('document-templates.partials.table', compact('templates', 'showTrashed', 'itemNames')); + // 분류 코드 → 이름 매핑 + $tenantId = session('selected_tenant_id'); + $categoryNames = DB::table('common_codes') + ->where('code_group', 'document_category') + ->where('is_active', true) + ->where(function ($q) use ($tenantId) { + $q->whereNull('tenant_id'); + if ($tenantId) { + $q->orWhere('tenant_id', $tenantId); + } + }) + ->pluck('name', 'code') + ->toArray(); + + return view('document-templates.partials.table', compact('templates', 'showTrashed', 'itemNames', 'categoryNames')); } /** @@ -121,6 +145,14 @@ public function store(Request $request): JsonResponse 'template_links' => 'nullable|array', ]); + // 동일 분류 내 연결품목 중복 검증 + $category = $validated['category'] ?? null; + $newItemIds = $this->extractLinkedItemIds($validated); + $duplicateError = $this->checkLinkedItemDuplicates($category, $newItemIds); + if ($duplicateError) { + return $duplicateError; + } + try { DB::beginTransaction(); @@ -190,6 +222,14 @@ public function update(Request $request, int $id): JsonResponse 'template_links' => 'nullable|array', ]); + // 동일 분류 내 연결품목 중복 검증 + $category = $validated['category'] ?? null; + $newItemIds = $this->extractLinkedItemIds($validated); + $duplicateError = $this->checkLinkedItemDuplicates($category, $newItemIds, $template->id); + if ($duplicateError) { + return $duplicateError; + } + try { DB::beginTransaction(); @@ -362,8 +402,8 @@ public function duplicate(Request $request, int $id): JsonResponse 'footer_judgement_label' => $source->footer_judgement_label, 'footer_judgement_options' => $source->footer_judgement_options, 'is_active' => false, - 'linked_item_ids' => $source->linked_item_ids, - 'linked_process_id' => $source->linked_process_id, + 'linked_item_ids' => null, // 연결품목은 복사하지 않음 (중복 방지) + 'linked_process_id' => null, ]); foreach ($source->approvalLines as $line) { @@ -440,9 +480,9 @@ public function duplicate(Request $request, int $id): JsonResponse ]); } - // 외부 키 매핑 복제 + // 외부 키 매핑 복제 (구조만 복사, 연결 값은 제외 - 중복 방지) foreach ($source->links as $link) { - $newLink = DocumentTemplateLink::create([ + DocumentTemplateLink::create([ 'template_id' => $newTemplate->id, 'link_key' => $link->link_key, 'label' => $link->label, @@ -453,16 +493,7 @@ public function duplicate(Request $request, int $id): JsonResponse 'is_required' => $link->is_required, 'sort_order' => $link->sort_order, ]); - - foreach ($link->linkValues as $value) { - DocumentTemplateLinkValue::create([ - 'template_id' => $newTemplate->id, - 'link_id' => $newLink->id, - 'linkable_id' => $value->linkable_id, - 'sort_order' => $value->sort_order, - 'created_at' => now(), - ]); - } + // linkValues는 복사하지 않음 (동일 분류 내 연결품목 중복 방지) } DB::commit(); @@ -662,4 +693,95 @@ private function saveRelations(DocumentTemplate $template, array $data, bool $de } } } + + /** + * 요청 데이터에서 연결 품목 ID 추출 (레거시 + 신규 방식) + */ + private function extractLinkedItemIds(array $data): array + { + $itemIds = collect($data['linked_item_ids'] ?? []); + + if (! empty($data['template_links'])) { + foreach ($data['template_links'] as $link) { + if (($link['source_table'] ?? '') === 'items' && ! empty($link['values'])) { + foreach ($link['values'] as $value) { + $id = $value['linkable_id'] ?? $value['id'] ?? (is_numeric($value) ? $value : null); + if ($id) { + $itemIds->push((int) $id); + } + } + } + } + } + + return $itemIds->filter()->unique()->values()->all(); + } + + /** + * 동일 분류 내 연결품목 중복 검증 → 중복 시 JsonResponse 반환 + */ + private function checkLinkedItemDuplicates(?string $category, array $newItemIds, ?int $excludeTemplateId = null): ?JsonResponse + { + if (empty($newItemIds) || empty($category)) { + return null; + } + + $tenantId = session('selected_tenant_id'); + + $query = DocumentTemplate::where('tenant_id', $tenantId) + ->where('category', $category); + + if ($excludeTemplateId) { + $query->where('id', '!=', $excludeTemplateId); + } + + $otherTemplates = $query->get(['id', 'name', 'linked_item_ids']); + + $duplicates = []; + + foreach ($otherTemplates as $template) { + // 레거시 linked_item_ids + $otherItemIds = collect($template->linked_item_ids ?? []); + + // 신규 link_values (source_table = 'items') + $linkValueItemIds = DocumentTemplateLinkValue::where('template_id', $template->id) + ->whereIn('linkable_id', $newItemIds) + ->whereHas('link', fn ($q) => $q->where('source_table', 'items')) + ->pluck('linkable_id'); + + $otherItemIds = $otherItemIds->merge($linkValueItemIds)->unique(); + $overlap = $otherItemIds->intersect($newItemIds); + + if ($overlap->isNotEmpty()) { + $duplicates[] = [ + 'template_name' => $template->name, + 'item_ids' => $overlap->values()->toArray(), + ]; + } + } + + if (empty($duplicates)) { + return null; + } + + // 품목명 조회 + $allDupItemIds = collect($duplicates)->pluck('item_ids')->flatten()->unique(); + $itemNames = \App\Models\Items\Item::whereIn('id', $allDupItemIds) + ->pluck('name', 'id') + ->toArray(); + + $messages = []; + foreach ($duplicates as $dup) { + $names = collect($dup['item_ids']) + ->map(fn ($id) => $itemNames[$id] ?? "ID:{$id}") + ->join(', '); + $messages[] = "'{$dup['template_name']}' 양식에 이미 연결된 품목: {$names}"; + } + + return response()->json([ + 'success' => false, + 'message' => '동일 분류 내 연결품목이 중복됩니다.', + 'errors' => ['linked_items' => $messages], + ], 422); + } }