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

@@ -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'));
}
}

View File

@@ -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)

View File

@@ -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'));
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Http\Requests\Design\BomTemplate;
use Illuminate\Foundation\Http\FormRequest;
class CloneRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'target_version_id' => 'nullable|integer|min:1',
'name' => 'nullable|string|max:100',
'is_primary' => 'nullable|boolean',
'notes' => 'nullable|string',
];
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Http\Requests\Design\BomTemplate;
use Illuminate\Foundation\Http\FormRequest;
class DiffRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'other_template_id' => '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]),
];
}
}

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

View File

@@ -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();

View File

@@ -0,0 +1,103 @@
<?php
namespace App\Swagger\v1;
/**
* @OA\Tag(
* name="Design BOM",
* description="Design-time BOM template operations"
* )
*/
class DesignBomTemplateExtras
{
/**
* @OA\Get(
* path="/api/v1/design/bom-templates/{templateId}/diff",
* tags={"Design BOM"},
* summary="Diff two BOM templates",
* security={{"ApiKeyAuth": {}, "BearerAuth": {}}},
* @OA\Parameter(name="templateId", in="path", required=true, @OA\Schema(type="integer")),
* @OA\Parameter(name="other_template_id", in="query", required=true, @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="Success",
* @OA\JsonContent(
* type="object",
* @OA\Property(property="success", type="boolean"),
* @OA\Property(property="message", type="string"),
* @OA\Property(property="data", type="object",
* @OA\Property(property="left_template_id", type="integer"),
* @OA\Property(property="right_template_id", type="integer"),
* @OA\Property(property="summary", type="object",
* @OA\Property(property="added", type="integer"),
* @OA\Property(property="removed", type="integer"),
* @OA\Property(property="changed", type="integer"),
* ),
* @OA\Property(property="added", type="array", @OA\Items(type="object",
* @OA\Property(property="ref_type", type="string", example="MATERIAL"),
* @OA\Property(property="ref_id", type="integer"),
* @OA\Property(property="qty", type="number"),
* @OA\Property(property="waste_rate", type="number"),
* @OA\Property(property="uom_id", type="integer", nullable=true),
* @OA\Property(property="notes", type="string", nullable=true),
* @OA\Property(property="sort_order", type="integer"),
* )),
* @OA\Property(property="removed", type="array", @OA\Items(ref="#/components/schemas/DesignBomItemDiffRow")),
* @OA\Property(property="changed", type="array", @OA\Items(type="object",
* @OA\Property(property="ref_type", type="string"),
* @OA\Property(property="ref_id", type="integer"),
* @OA\Property(property="changes", type="object")
* ))
* )
* )
* )
* )
*
* @OA\Post(
* path="/api/v1/design/bom-templates/{templateId}/clone",
* tags={"Design BOM"},
* summary="Clone a BOM template (deep copy)",
* security={{"ApiKeyAuth": {}, "BearerAuth": {}}},
* @OA\Parameter(name="templateId", in="path", required=true, @OA\Schema(type="integer")),
* @OA\RequestBody(
* required=false,
* @OA\JsonContent(type="object",
* @OA\Property(property="target_version_id", type="integer", nullable=true),
* @OA\Property(property="name", type="string", nullable=true),
* @OA\Property(property="is_primary", type="boolean", nullable=true),
* @OA\Property(property="notes", type="string", nullable=true)
* )
* ),
* @OA\Response(
* response=200,
* description="Cloned",
* @OA\JsonContent(
* type="object",
* @OA\Property(property="success", type="boolean"),
* @OA\Property(property="message", type="string"),
* @OA\Property(property="data", type="object",
* @OA\Property(property="id", type="integer"),
* @OA\Property(property="tenant_id", type="integer"),
* @OA\Property(property="model_version_id", type="integer"),
* @OA\Property(property="name", type="string"),
* @OA\Property(property="is_primary", type="boolean"),
* @OA\Property(property="notes", type="string", nullable=true)
* )
* )
* )
* )
*
* @OA\Schema(
* schema="DesignBomItemDiffRow",
* type="object",
* @OA\Property(property="ref_type", type="string", example="MATERIAL"),
* @OA\Property(property="ref_id", type="integer"),
* @OA\Property(property="qty", type="number"),
* @OA\Property(property="waste_rate", type="number"),
* @OA\Property(property="uom_id", type="integer", nullable=true),
* @OA\Property(property="notes", type="string", nullable=true),
* @OA\Property(property="sort_order", type="integer")
* )
*/
public function docs() {}
}

View File

@@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('model_versions', function (Blueprint $table) {
// 상태 조회 최적화(테넌트+모델+상태)
$table->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');
});
}
};

View File

@@ -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.',
],
];

View File

@@ -50,4 +50,9 @@
'template_saved' => '카테고리 템플릿이 저장되었습니다.',
'template_applied' => '카테고리 템플릿이 적용되었습니다.',
],
'design' => [
'template_cloned' => 'BOM 템플릿이 복제되었습니다.',
'template_diff' => 'BOM 템플릿 차이를 계산했습니다.',
],
];

View File

@@ -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');
});