feat: Design BOM 템플릿 diff/clone API 및 모델버전 릴리즈 유효성 검사 도입

- 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 메시지 키 추가
This commit is contained in:
2025-09-11 13:34:20 +09:00
parent 4bf02b7424
commit 17fa82c35b
12 changed files with 508 additions and 40 deletions

View File

@@ -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 실제 구성 시 그래프 탐색으로 보완 예정
}
}