tenantId(); $q = BomTemplate::query()->where('tenant_id', $tenantId); if ($modelVersionId) { $q->where('model_version_id', $modelVersionId); } return $q->orderByDesc('is_primary') ->orderBy('name') ->paginate($size, ['*'], 'page', $page); } /** 템플릿 upsert(name 기준) */ public function upsertTemplate(int $modelVersionId, string $name = 'Main', bool $isPrimary = true, ?string $notes = null): BomTemplate { $tenantId = $this->tenantId(); $mv = ModelVersion::query() ->where('tenant_id', $tenantId) ->find($modelVersionId); if (!$mv) { throw new NotFoundHttpException(__('error.not_found')); } return DB::transaction(function () use ($tenantId, $mv, $name, $isPrimary, $notes) { $tpl = BomTemplate::query() ->where('model_version_id', $mv->id) ->where('name', $name) ->first(); if (!$tpl) { $tpl = BomTemplate::create([ 'tenant_id' => $tenantId, 'model_version_id' => $mv->id, 'name' => $name, 'is_primary' => $isPrimary, 'notes' => $notes, ]); } else { $tpl->fill(['is_primary' => $isPrimary, 'notes' => $notes])->save(); } if ($isPrimary) { BomTemplate::query() ->where('model_version_id', $mv->id) ->where('id', '<>', $tpl->id) ->update(['is_primary' => false]); } return $tpl; }); } /** 템플릿 메타 수정 */ public function updateTemplate(int $templateId, array $data): BomTemplate { $tenantId = $this->tenantId(); $tpl = BomTemplate::query() ->where('tenant_id', $tenantId) ->find($templateId); if (!$tpl) { throw new NotFoundHttpException(__('error.not_found')); } $name = $data['name'] ?? $tpl->name; if ($name !== $tpl->name) { $dup = BomTemplate::query() ->where('model_version_id', $tpl->model_version_id) ->where('name', $name) ->where('id', '<>', $tpl->id) ->exists(); if ($dup) { throw ValidationException::withMessages(['name' => __('error.duplicate')]); } } $tpl->fill($data)->save(); if (array_key_exists('is_primary', $data) && $data['is_primary']) { // 다른 템플릿 대표 해제 BomTemplate::query() ->where('model_version_id', $tpl->model_version_id) ->where('id', '<>', $tpl->id) ->update(['is_primary' => false]); } return $tpl; } /** 템플릿 삭제(soft) */ public function deleteTemplate(int $templateId): void { $tenantId = $this->tenantId(); $tpl = BomTemplate::query() ->where('tenant_id', $tenantId) ->find($templateId); if (!$tpl) { throw new NotFoundHttpException(__('error.not_found')); } $tpl->delete(); } /** 템플릿 상세 (옵션: 항목 포함) */ public function show(int $templateId, bool $withItems = false): BomTemplate { $tenantId = $this->tenantId(); $q = BomTemplate::query()->where('tenant_id', $tenantId); if ($withItems) { $q->with(['items' => fn($w) => $w->orderBy('sort_order')]); } $tpl = $q->find($templateId); if (!$tpl) { throw new NotFoundHttpException(__('error.not_found')); } return $tpl; } /** 항목 일괄 치환 */ public function replaceItems(int $templateId, array $items): void { $tenantId = $this->tenantId(); $tpl = BomTemplate::query() ->where('tenant_id', $tenantId) ->find($templateId); if (!$tpl) { throw new NotFoundHttpException(__('error.not_found')); } // 1차 검증 foreach ($items as $i => $row) { $refType = strtoupper((string) Arr::get($row, 'ref_type')); $qty = (float) Arr::get($row, 'qty', 0); if (!in_array($refType, ['MATERIAL','PRODUCT'], true)) { throw ValidationException::withMessages(["items.$i.ref_type" => __('error.validation_failed')]); } if ($qty <= 0) { throw ValidationException::withMessages(["items.$i.qty" => __('error.validation_failed')]); } } 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) ->delete(); $now = now(); $payloads = []; foreach ($items as $row) { $payloads[] = [ 'tenant_id' => $tenantId, 'bom_template_id' => $tpl->id, 'ref_type' => strtoupper($row['ref_type']), 'ref_id' => (int) $row['ref_id'], 'qty' => (string) ($row['qty'] ?? 1), 'waste_rate' => (string) ($row['waste_rate'] ?? 0), 'uom_id' => $row['uom_id'] ?? null, 'notes' => $row['notes'] ?? null, 'sort_order' => (int) ($row['sort_order'] ?? 0), 'created_at' => $now, 'updated_at' => $now, ]; } if (!empty($payloads)) { BomTemplateItem::insert($payloads); } }); } /** 항목 조회 */ public function listItems(int $templateId) { $tenantId = $this->tenantId(); $tpl = BomTemplate::query() ->where('tenant_id', $tenantId) ->find($templateId); if (!$tpl) { throw new NotFoundHttpException(__('error.not_found')); } return BomTemplateItem::query() ->where('tenant_id', $tenantId) ->where('bom_template_id', $tpl->id) ->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 실제 구성 시 그래프 탐색으로 보완 예정 } }