From 17fa82c35bcc4f0b4bde7ff73247aa108d1994cf Mon Sep 17 00:00:00 2001 From: hskwon Date: Thu, 11 Sep 2025 13:34:20 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Design=20BOM=20=ED=85=9C=ED=94=8C?= =?UTF-8?q?=EB=A6=BF=20diff/clone=20API=20=EB=B0=8F=20=EB=AA=A8=EB=8D=B8?= =?UTF-8?q?=EB=B2=84=EC=A0=84=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20=EC=9C=A0?= =?UTF-8?q?=ED=9A=A8=EC=84=B1=20=EA=B2=80=EC=82=AC=20=EB=8F=84=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Design BOM 템플릿 diff/clone 엔드포인트 추가 - 컨트롤러 검증 로직 FormRequest 분리(DiffRequest/CloneRequest/Upsert/ReplaceItems) - BomTemplateService에 diffTemplates/cloneTemplate/replaceItems/쇼우 로직 정리 - ModelVersionController createDraft FormRequest 적용 및 서비스 호출 정리 - 모델버전 release 전 유효성 검사(존재/활성/테넌트 일치, qty>0, 중복 금지) 추가 - DB enum 미사용 방침 준수(status 문자열 유지) - model_versions 인덱스 최적화(tenant_id, model_id, status / 기간 범위) - Swagger 문서(Design BOM) 및 i18n 메시지 키 추가 --- .../Api/V1/Design/BomTemplateController.php | 49 ++-- .../Api/V1/Design/DesignModelController.php | 31 ++- .../Api/V1/Design/ModelVersionController.php | 11 +- .../Design/BomTemplate/CloneRequest.php | 23 ++ .../Design/BomTemplate/DiffRequest.php | 29 ++ app/Services/Design/BomTemplateService.php | 257 ++++++++++++++++++ app/Services/Design/ModelVersionService.php | 4 +- app/Swagger/v1/DesignBomTemplateExtras.php | 103 +++++++ ...02_add_indexes_to_model_versions_table.php | 27 ++ lang/en/message.php | 5 + lang/ko/message.php | 5 + routes/api.php | 4 +- 12 files changed, 508 insertions(+), 40 deletions(-) create mode 100644 app/Http/Requests/Design/BomTemplate/CloneRequest.php create mode 100644 app/Http/Requests/Design/BomTemplate/DiffRequest.php create mode 100644 app/Swagger/v1/DesignBomTemplateExtras.php create mode 100644 database/migrations/2025_09_10_000002_add_indexes_to_model_versions_table.php diff --git a/app/Http/Controllers/Api/V1/Design/BomTemplateController.php b/app/Http/Controllers/Api/V1/Design/BomTemplateController.php index cdc79e8..a5070ae 100644 --- a/app/Http/Controllers/Api/V1/Design/BomTemplateController.php +++ b/app/Http/Controllers/Api/V1/Design/BomTemplateController.php @@ -4,8 +4,11 @@ use App\Helpers\ApiResponse; use App\Http\Controllers\Controller; +use App\Http\Requests\Design\BomTemplate\CloneRequest; +use App\Http\Requests\Design\BomTemplate\DiffRequest; +use App\Http\Requests\Design\BomTemplate\ReplaceItemsRequest; +use App\Http\Requests\Design\BomTemplate\UpsertRequest; use App\Services\Design\BomTemplateService; -use Illuminate\Http\Request; class BomTemplateController extends Controller { @@ -21,14 +24,10 @@ public function listByVersion(int $versionId) }, __('message.fetched')); } - public function upsertTemplate(Request $request, int $versionId) + public function upsertTemplate(UpsertRequest $request, int $versionId) { return ApiResponse::handle(function () use ($request, $versionId) { - $payload = $request->validate([ - 'name' => 'nullable|string|max:100', - 'is_primary' => 'boolean', - 'notes' => 'nullable|string', - ]); + $payload = $request->validated(); return $this->service->upsertTemplate( modelVersionId: $versionId, name: $payload['name'] ?? 'Main', @@ -43,21 +42,35 @@ public function show(int $templateId) return ApiResponse::handle(fn() => $this->service->show($templateId, true), __('message.fetched')); } - public function replaceItems(Request $request, int $templateId) + public function replaceItems(ReplaceItemsRequest $request, int $templateId) { return ApiResponse::handle(function () use ($request, $templateId) { - $payload = $request->validate([ - 'items' => 'required|array|min:1', - 'items.*.ref_type' => 'required|string|in:MATERIAL,PRODUCT', - 'items.*.ref_id' => 'required|integer|min:1', - 'items.*.qty' => 'required|numeric|gt:0', - 'items.*.waste_rate' => 'nullable|numeric|min:0', - 'items.*.uom_id' => 'nullable|integer|min:1', - 'items.*.notes' => 'nullable|string|max:255', - 'items.*.sort_order' => 'nullable|integer', - ]); + $payload = $request->validated(); $this->service->replaceItems($templateId, $payload['items']); return null; }, __('message.bom.bulk_upsert')); } + + public function diff(int $templateId, DiffRequest $request) + { + return ApiResponse::handle(function () use ($templateId, $request) { + $otherId = $request->validated()['other_template_id']; + return $this->service->diffTemplates($templateId, $otherId); + }, __('message.design.template_diff')); + } + + public function cloneTemplate(int $templateId, CloneRequest $request) + { + return ApiResponse::handle(function () use ($templateId, $request) { + $payload = $request->validated(); + $tpl = $this->service->cloneTemplate( + templateId: $templateId, + targetVersionId: $payload['target_version_id'] ?? null, + name: $payload['name'] ?? null, + isPrimary: (bool)($payload['is_primary'] ?? false), + notes: $payload['notes'] ?? null + ); + return $tpl; + }, __('message.design.template_cloned')); + } } diff --git a/app/Http/Controllers/Api/V1/Design/DesignModelController.php b/app/Http/Controllers/Api/V1/Design/DesignModelController.php index 470f786..30059bb 100644 --- a/app/Http/Controllers/Api/V1/Design/DesignModelController.php +++ b/app/Http/Controllers/Api/V1/Design/DesignModelController.php @@ -15,36 +15,43 @@ public function __construct( protected ModelService $service ) {} + // q, page, size만 서비스로 전달 (현재 시그니처 유지) public function index(PaginateRequest $request) { return ApiResponse::handle(function () use ($request) { - $p = $request->validatedOrDefaults(); - $q = $p['q'] ?? ''; - $page = (int) $p['page']; - $size = (int) $p['size']; + $v = $request->validatedOrDefaults(); // page/size 기본값 주입됨 + $q = $v['q'] ?? ''; + $page = (int)($v['page'] ?? 1); + $size = (int)($v['size'] ?? 20); + $sort = $v['sort'] ?? null; // id|code|name|created_at + $order = $v['order'] ?? 'desc'; // asc|desc - // 현재 서비스 시그니처가 list($q, $page, $size)이므로 정합 유지 return $this->service->list($q, $page, $size); }, __('message.fetched')); } public function store(StoreRequest $request) { - return ApiResponse::handle(function () use ($request) { - return $this->service->create($request->validated()); - }, __('message.created')); + return ApiResponse::handle( + fn() => $this->service->create($request->validated()), + __('message.created') + ); } public function show(int $id) { - return ApiResponse::handle(fn () => $this->service->find($id), __('message.fetched')); + return ApiResponse::handle( + fn() => $this->service->find($id), + __('message.fetched') + ); } public function update(UpdateRequest $request, int $id) { - return ApiResponse::handle(function () use ($request, $id) { - return $this->service->update($id, $request->validated()); - }, __('message.updated')); + return ApiResponse::handle( + fn() => $this->service->update($id, $request->validated()), + __('message.updated') + ); } public function destroy(int $id) diff --git a/app/Http/Controllers/Api/V1/Design/ModelVersionController.php b/app/Http/Controllers/Api/V1/Design/ModelVersionController.php index b5ef49f..cf6a18a 100644 --- a/app/Http/Controllers/Api/V1/Design/ModelVersionController.php +++ b/app/Http/Controllers/Api/V1/Design/ModelVersionController.php @@ -4,8 +4,8 @@ use App\Helpers\ApiResponse; use App\Http\Controllers\Controller; +use App\Http\Requests\Design\ModelVersion\CreateDraftRequest; use App\Services\Design\ModelVersionService; -use Illuminate\Http\Request; class ModelVersionController extends Controller { @@ -18,15 +18,10 @@ public function index(int $modelId) return ApiResponse::handle(fn() => $this->service->listByModel($modelId), __('message.fetched')); } - public function createDraft(Request $request, int $modelId) + public function createDraft(CreateDraftRequest $request, int $modelId) { return ApiResponse::handle(function () use ($request, $modelId) { - $payload = $request->validate([ - 'version_no' => 'nullable|integer|min:1', - 'notes' => 'nullable|string', - 'effective_from' => 'nullable|date', - 'effective_to' => 'nullable|date|after:effective_from', - ]); + $payload = $request->validated(); return $this->service->createDraft($modelId, $payload); }, __('message.created')); } diff --git a/app/Http/Requests/Design/BomTemplate/CloneRequest.php b/app/Http/Requests/Design/BomTemplate/CloneRequest.php new file mode 100644 index 0000000..acda037 --- /dev/null +++ b/app/Http/Requests/Design/BomTemplate/CloneRequest.php @@ -0,0 +1,23 @@ + 'nullable|integer|min:1', + 'name' => 'nullable|string|max:100', + 'is_primary' => 'nullable|boolean', + 'notes' => 'nullable|string', + ]; + } +} diff --git a/app/Http/Requests/Design/BomTemplate/DiffRequest.php b/app/Http/Requests/Design/BomTemplate/DiffRequest.php new file mode 100644 index 0000000..b8fbfeb --- /dev/null +++ b/app/Http/Requests/Design/BomTemplate/DiffRequest.php @@ -0,0 +1,29 @@ + 'required|integer|min:1', + ]; + } + + public function messages(): array + { + return [ + 'other_template_id.required' => __('validation.required', ['attribute' => 'other_template_id']), + 'other_template_id.integer' => __('validation.integer', ['attribute' => 'other_template_id']), + 'other_template_id.min' => __('validation.min.numeric', ['attribute' => 'other_template_id', 'min' => 1]), + ]; + } +} diff --git a/app/Services/Design/BomTemplateService.php b/app/Services/Design/BomTemplateService.php index b934340..575a769 100644 --- a/app/Services/Design/BomTemplateService.php +++ b/app/Services/Design/BomTemplateService.php @@ -5,6 +5,8 @@ use App\Models\Design\BomTemplate; use App\Models\Design\BomTemplateItem; use App\Models\Design\ModelVersion; +use App\Models\Materials\Material; +use App\Models\Products\Product; use App\Services\Service; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Support\Arr; @@ -170,6 +172,22 @@ public function replaceItems(int $templateId, array $items): void } DB::transaction(function () use ($tenantId, $tpl, $items) { + // 이전 항목 스냅샷 + $beforeItems = BomTemplateItem::query() + ->where('tenant_id', $tenantId) + ->where('bom_template_id', $tpl->id) + ->orderBy('sort_order') + ->get() + ->map(fn($i) => [ + 'ref_type' => strtoupper($i->ref_type), + 'ref_id' => (int)$i->ref_id, + 'qty' => (float)$i->qty, + 'waste_rate' => (float)$i->waste_rate, + 'uom_id' => $i->uom_id, + 'notes' => $i->notes, + 'sort_order' => (int)$i->sort_order, + ])->all(); + BomTemplateItem::query() ->where('tenant_id', $tenantId) ->where('bom_template_id', $tpl->id) @@ -218,4 +236,243 @@ public function listItems(int $templateId) ->orderBy('sort_order') ->get(); } + + /** 템플릿 간 diff */ + public function diffTemplates(int $leftTemplateId, int $rightTemplateId): array + { + $tenantId = $this->tenantId(); + + $left = BomTemplate::query()->where('tenant_id', $tenantId)->find($leftTemplateId); + $right = BomTemplate::query()->where('tenant_id', $tenantId)->find($rightTemplateId); + + if (!$left || !$right) { + throw new NotFoundHttpException(__('error.not_found')); + } + + $leftItems = BomTemplateItem::query() + ->where('tenant_id', $tenantId) + ->where('bom_template_id', $left->id) + ->get() + ->map(fn($i) => [ + 'key' => strtoupper($i->ref_type) . ':' . (int)$i->ref_id, + 'ref_type' => strtoupper($i->ref_type), + 'ref_id' => (int)$i->ref_id, + 'qty' => (float)$i->qty, + 'waste_rate' => (float)$i->waste_rate, + 'uom_id' => $i->uom_id ? (int)$i->uom_id : null, + 'notes' => $i->notes, + 'sort_order' => (int)$i->sort_order, + ])->keyBy('key'); + + $rightItems = BomTemplateItem::query() + ->where('tenant_id', $tenantId) + ->where('bom_template_id', $right->id) + ->get() + ->map(fn($i) => [ + 'key' => strtoupper($i->ref_type) . ':' . (int)$i->ref_id, + 'ref_type' => strtoupper($i->ref_type), + 'ref_id' => (int)$i->ref_id, + 'qty' => (float)$i->qty, + 'waste_rate' => (float)$i->waste_rate, + 'uom_id' => $i->uom_id ? (int)$i->uom_id : null, + 'notes' => $i->notes, + 'sort_order' => (int)$i->sort_order, + ])->keyBy('key'); + + $added = []; + $removed = []; + $changed = []; + + foreach ($rightItems as $key => $ri) { + if (!$leftItems->has($key)) { + $added[] = Arr::except($ri, ['key']); + } else { + $li = $leftItems[$key]; + $diffs = []; + foreach (['qty','waste_rate','uom_id','notes','sort_order'] as $fld) { + if (($li[$fld] ?? null) !== ($ri[$fld] ?? null)) { + $diffs[$fld] = ['before' => $li[$fld] ?? null, 'after' => $ri[$fld] ?? null]; + } + } + if (!empty($diffs)) { + $changed[] = [ + 'ref_type' => $ri['ref_type'], + 'ref_id' => $ri['ref_id'], + 'changes' => $diffs, + ]; + } + } + } + foreach ($leftItems as $key => $li) { + if (!$rightItems->has($key)) { + $removed[] = Arr::except($li, ['key']); + } + } + + return [ + 'left_template_id' => $left->id, + 'right_template_id' => $right->id, + 'summary' => [ + 'added' => count($added), + 'removed' => count($removed), + 'changed' => count($changed), + ], + 'added' => $added, + 'removed' => $removed, + 'changed' => $changed, + ]; + } + + /** 템플릿 복제(깊은 복사) */ + public function cloneTemplate(int $templateId, ?int $targetVersionId = null, ?string $name = null, bool $isPrimary = false, ?string $notes = null): BomTemplate + { + $tenantId = $this->tenantId(); + + $src = BomTemplate::query()->where('tenant_id', $tenantId)->find($templateId); + if (!$src) { + throw new NotFoundHttpException(__('error.not_found')); + } + + $targetVersionId = $targetVersionId ?: $src->model_version_id; + + $mv = ModelVersion::query() + ->where('tenant_id', $tenantId) + ->find($targetVersionId); + + if (!$mv) { + throw new NotFoundHttpException(__('error.not_found')); + } + + // 이름 결정(중복 회피) + $baseName = $name ?: ($src->name . ' Copy'); + $newName = $baseName; + $i = 2; + while (BomTemplate::query() + ->where('model_version_id', $mv->id) + ->where('name', $newName) + ->exists()) { + $newName = $baseName . ' ' . $i; + $i++; + } + + return DB::transaction(function () use ($tenantId, $src, $mv, $newName, $isPrimary, $notes) { + $dest = BomTemplate::create([ + 'tenant_id' => $tenantId, + 'model_version_id' => $mv->id, + 'name' => $newName, + 'is_primary' => $isPrimary, + 'notes' => $notes ?? $src->notes, + ]); + + $now = now(); + $items = BomTemplateItem::query() + ->where('tenant_id', $tenantId) + ->where('bom_template_id', $src->id) + ->get() + ->map(fn($i) => [ + 'tenant_id' => $tenantId, + 'bom_template_id' => $dest->id, + 'ref_type' => strtoupper($i->ref_type), + 'ref_id' => (int)$i->ref_id, + 'qty' => (string)$i->qty, + 'waste_rate' => (string)$i->waste_rate, + 'uom_id' => $i->uom_id, + 'notes' => $i->notes, + 'sort_order' => (int)$i->sort_order, + 'created_at' => $now, + 'updated_at' => $now, + ])->all(); + + if (!empty($items)) { + BomTemplateItem::insert($items); + } + + if ($isPrimary) { + BomTemplate::query() + ->where('model_version_id', $mv->id) + ->where('id', '<>', $dest->id) + ->update(['is_primary' => false]); + } + + return $dest; + }); + } + + /** 모델버전 릴리즈 전 유효성 검사 */ + public function validateForRelease(int $modelVersionId): void + { + $tenantId = $this->tenantId(); + + $mv = ModelVersion::query() + ->where('tenant_id', $tenantId) + ->find($modelVersionId); + + if (!$mv) { + throw new NotFoundHttpException(__('error.not_found')); + } + + // 대표 템플릿 존재 확인 + $primary = BomTemplate::query() + ->where('tenant_id', $tenantId) + ->where('model_version_id', $mv->id) + ->where('is_primary', true) + ->first(); + + if (!$primary) { + throw ValidationException::withMessages(['template' => __('error.validation_failed')]); + } + + $items = BomTemplateItem::query() + ->where('tenant_id', $tenantId) + ->where('bom_template_id', $primary->id) + ->orderBy('sort_order') + ->get(); + + if ($items->isEmpty()) { + throw ValidationException::withMessages(['items' => __('error.validation_failed')]); + } + + // 중복 키 및 값 검증 + $seen = []; + foreach ($items as $idx => $it) { + $key = strtoupper($it->ref_type) . ':' . (int)$it->ref_id; + if (isset($seen[$key])) { + throw ValidationException::withMessages(["items.$idx" => __('error.duplicate')]); + } + $seen[$key] = true; + + // 수량/로스율 + if ((float)$it->qty <= 0) { + throw ValidationException::withMessages(["items.$idx.qty" => __('error.validation_failed')]); + } + if ((float)$it->waste_rate < 0) { + throw ValidationException::withMessages(["items.$idx.waste_rate" => __('error.validation_failed')]); + } + + // 참조 존재/활성/테넌트 일치 + if (strtoupper($it->ref_type) === 'MATERIAL') { + $exists = Material::query() + ->where('tenant_id', $tenantId) + ->where('id', $it->ref_id) + ->whereNull('deleted_at') + ->exists(); + if (!$exists) { + throw ValidationException::withMessages(["items.$idx.ref_id" => __('error.not_found')]); + } + } elseif (strtoupper($it->ref_type) === 'PRODUCT') { + $exists = Product::query() + ->where('tenant_id', $tenantId) + ->where('id', $it->ref_id) + ->whereNull('deleted_at') + ->exists(); + if (!$exists) { + throw ValidationException::withMessages(["items.$idx.ref_id" => __('error.not_found')]); + } + } else { + throw ValidationException::withMessages(["items.$idx.ref_type" => __('error.validation_failed')]); + } + } + + // 주: 순환 참조 검사는 운영 BOM 실제 구성 시 그래프 탐색으로 보완 예정 + } } diff --git a/app/Services/Design/ModelVersionService.php b/app/Services/Design/ModelVersionService.php index 116f719..16d0459 100644 --- a/app/Services/Design/ModelVersionService.php +++ b/app/Services/Design/ModelVersionService.php @@ -92,7 +92,9 @@ public function release(int $versionId): ModelVersion return $mv; // 멱등 } - // TODO: 대표 템플릿 존재 등 사전 검증 훅 가능 + // 릴리즈 전 유효성 검사 + app(\App\Services\Design\BomTemplateService::class)->validateForRelease($mv->id); + $mv->status = 'RELEASED'; $mv->effective_from = $mv->effective_from ?? now(); $mv->save(); diff --git a/app/Swagger/v1/DesignBomTemplateExtras.php b/app/Swagger/v1/DesignBomTemplateExtras.php new file mode 100644 index 0000000..c54501c --- /dev/null +++ b/app/Swagger/v1/DesignBomTemplateExtras.php @@ -0,0 +1,103 @@ +index(['tenant_id', 'model_id', 'status'], 'ix_model_versions_tenant_model_status'); + // 효력기간 교집합/정렬 조회 최적화(테넌트+모델+기간) + $table->index(['tenant_id', 'model_id', 'effective_from', 'effective_to'], 'ix_model_versions_tenant_model_effective_range'); + // 주의: 유니크 제약은 기존 스키마(uq_model_versions_model_ver)와 충돌 우려로 추가하지 않음 + }); + } + + public function down(): void + { + Schema::table('model_versions', function (Blueprint $table) { + $table->dropIndex('ix_model_versions_tenant_model_status'); + $table->dropIndex('ix_model_versions_tenant_model_effective_range'); + }); + } +}; diff --git a/lang/en/message.php b/lang/en/message.php index 8bbcddc..b9fc735 100644 --- a/lang/en/message.php +++ b/lang/en/message.php @@ -45,4 +45,9 @@ 'template_saved' => 'Category template has been saved.', 'template_applied' => 'Category template has been applied.', ], + + 'design' => [ + 'template_cloned' => 'BOM template has been cloned.', + 'template_diff' => 'BOM template differences have been computed.', + ], ]; diff --git a/lang/ko/message.php b/lang/ko/message.php index fe56e7a..7971694 100644 --- a/lang/ko/message.php +++ b/lang/ko/message.php @@ -50,4 +50,9 @@ 'template_saved' => '카테고리 템플릿이 저장되었습니다.', 'template_applied' => '카테고리 템플릿이 적용되었습니다.', ], + + 'design' => [ + 'template_cloned' => 'BOM 템플릿이 복제되었습니다.', + 'template_diff' => 'BOM 템플릿 차이를 계산했습니다.', + ], ]; diff --git a/routes/api.php b/routes/api.php index 7a4c2d0..a17e480 100644 --- a/routes/api.php +++ b/routes/api.php @@ -334,7 +334,7 @@ Route::get ('/summary', [ProductBomItemController::class, 'summary'])->name('v1.products.bom.summary'); Route::get ('/validate', [ProductBomItemController::class, 'validateBom'])->name('v1.products.bom.validate'); - Route::get('/tree', [ProductBomItemController::class, 'tree']); + Route::get('/tree', [ProductBomItemController::class, 'tree'])->name('v1.products.bom.tree'); }); @@ -354,6 +354,8 @@ Route::post ('/versions/{versionId}/bom-templates', [DesignBomTemplateController::class, 'upsertTemplate'])->name('v1.design.bom.templates.store'); Route::get ('/bom-templates/{templateId}', [DesignBomTemplateController::class, 'show'])->name('v1.design.bom.templates.show'); Route::put ('/bom-templates/{templateId}/items', [DesignBomTemplateController::class, 'replaceItems'])->name('v1.design.bom.templates.items.replace'); + Route::get ('/bom-templates/{templateId}/diff', [DesignBomTemplateController::class, 'diff'])->name('v1.design.bom.templates.diff'); + Route::post ('/bom-templates/{templateId}/clone', [DesignBomTemplateController::class, 'cloneTemplate'])->name('v1.design.bom.templates.clone'); });