diff --git a/app/Http/Controllers/Api/V1/Design/BomTemplateController.php b/app/Http/Controllers/Api/V1/Design/BomTemplateController.php
new file mode 100644
index 0000000..cdc79e8
--- /dev/null
+++ b/app/Http/Controllers/Api/V1/Design/BomTemplateController.php
@@ -0,0 +1,63 @@
+service->paginate($versionId, page: 1, size: PHP_INT_MAX)->items();
+ }, __('message.fetched'));
+ }
+
+ public function upsertTemplate(Request $request, int $versionId)
+ {
+ return ApiResponse::handle(function () use ($request, $versionId) {
+ $payload = $request->validate([
+ 'name' => 'nullable|string|max:100',
+ 'is_primary' => 'boolean',
+ 'notes' => 'nullable|string',
+ ]);
+ return $this->service->upsertTemplate(
+ modelVersionId: $versionId,
+ name: $payload['name'] ?? 'Main',
+ isPrimary: (bool)($payload['is_primary'] ?? true),
+ notes: $payload['notes'] ?? null
+ );
+ }, __('message.created'));
+ }
+
+ public function show(int $templateId)
+ {
+ return ApiResponse::handle(fn() => $this->service->show($templateId, true), __('message.fetched'));
+ }
+
+ public function replaceItems(Request $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',
+ ]);
+ $this->service->replaceItems($templateId, $payload['items']);
+ return null;
+ }, __('message.bom.bulk_upsert'));
+ }
+}
diff --git a/app/Http/Controllers/Api/V1/Design/DesignModelController.php b/app/Http/Controllers/Api/V1/Design/DesignModelController.php
new file mode 100644
index 0000000..2c16a31
--- /dev/null
+++ b/app/Http/Controllers/Api/V1/Design/DesignModelController.php
@@ -0,0 +1,68 @@
+query('q', '');
+ $page = (int) $request->query('page', 1);
+ $size = (int) $request->query('size', 20);
+ return $this->service->list($q, $page, $size);
+ }, __('message.fetched'));
+ }
+
+ public function store(Request $request)
+ {
+ return ApiResponse::handle(function () use ($request) {
+ $payload = $request->validate([
+ 'code' => 'required|string|max:100',
+ 'name' => 'required|string|max:200',
+ 'category_id' => 'nullable|integer',
+ 'lifecycle' => 'nullable|string|max:30',
+ 'description' => 'nullable|string',
+ 'is_active' => 'boolean',
+ ]);
+ return $this->service->create($payload);
+ }, __('message.created'));
+ }
+
+ public function show(int $id)
+ {
+ return ApiResponse::handle(fn() => $this->service->find($id), __('message.fetched'));
+ }
+
+ public function update(Request $request, int $id)
+ {
+ return ApiResponse::handle(function () use ($request, $id) {
+ $payload = $request->validate([
+ 'code' => 'sometimes|string|max:100',
+ 'name' => 'sometimes|string|max:200',
+ 'category_id' => 'nullable|integer',
+ 'lifecycle' => 'nullable|string|max:30',
+ 'description' => 'nullable|string',
+ 'is_active' => 'boolean',
+ ]);
+ return $this->service->update($id, $payload);
+ }, __('message.updated'));
+ }
+
+ public function destroy(int $id)
+ {
+ return ApiResponse::handle(function () use ($id) {
+ $this->service->delete($id);
+ return null;
+ }, __('message.deleted'));
+ }
+}
diff --git a/app/Http/Controllers/Api/V1/Design/ModelVersionController.php b/app/Http/Controllers/Api/V1/Design/ModelVersionController.php
new file mode 100644
index 0000000..b5ef49f
--- /dev/null
+++ b/app/Http/Controllers/Api/V1/Design/ModelVersionController.php
@@ -0,0 +1,38 @@
+ $this->service->listByModel($modelId), __('message.fetched'));
+ }
+
+ public function createDraft(Request $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',
+ ]);
+ return $this->service->createDraft($modelId, $payload);
+ }, __('message.created'));
+ }
+
+ public function release(int $versionId)
+ {
+ return ApiResponse::handle(fn() => $this->service->release($versionId), __('message.updated'));
+ }
+}
diff --git a/app/Models/Design/BomTemplate.php b/app/Models/Design/BomTemplate.php
new file mode 100644
index 0000000..45a944f
--- /dev/null
+++ b/app/Models/Design/BomTemplate.php
@@ -0,0 +1,29 @@
+ 'boolean',
+ ];
+
+ public function modelVersion() {
+ return $this->belongsTo(ModelVersion::class, 'model_version_id');
+ }
+
+ public function items() {
+ return $this->hasMany(BomTemplateItem::class, 'bom_template_id');
+ }
+}
diff --git a/app/Models/Design/BomTemplateItem.php b/app/Models/Design/BomTemplateItem.php
new file mode 100644
index 0000000..fbfba7f
--- /dev/null
+++ b/app/Models/Design/BomTemplateItem.php
@@ -0,0 +1,23 @@
+ 'decimal:6',
+ 'waste_rate' => 'decimal:6',
+ ];
+
+ public function template() {
+ return $this->belongsTo(BomTemplate::class, 'bom_template_id');
+ }
+}
diff --git a/app/Models/Design/DesignModel.php b/app/Models/Design/DesignModel.php
new file mode 100644
index 0000000..0a1a325
--- /dev/null
+++ b/app/Models/Design/DesignModel.php
@@ -0,0 +1,26 @@
+ 'boolean',
+ ];
+
+ // 관계: 모델은 여러 버전을 가짐
+ public function versions() {
+ return $this->hasMany(ModelVersion::class, 'model_id');
+ }
+}
diff --git a/app/Models/Design/ModelVersion.php b/app/Models/Design/ModelVersion.php
new file mode 100644
index 0000000..b58ff7d
--- /dev/null
+++ b/app/Models/Design/ModelVersion.php
@@ -0,0 +1,35 @@
+ 'boolean',
+ 'effective_from' => 'datetime',
+ 'effective_to' => 'datetime',
+ ];
+
+ public function model() {
+ return $this->belongsTo(DesignModel::class, 'model_id');
+ }
+
+ public function bomTemplates() {
+ return $this->hasMany(BomTemplate::class, 'model_version_id');
+ }
+
+ public function scopeReleased($q) {
+ return $q->where('status', 'RELEASED');
+ }
+}
diff --git a/app/Services/Design/BomTemplateService.php b/app/Services/Design/BomTemplateService.php
new file mode 100644
index 0000000..b934340
--- /dev/null
+++ b/app/Services/Design/BomTemplateService.php
@@ -0,0 +1,221 @@
+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) {
+ 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 --git a/app/Services/Design/ModelService.php b/app/Services/Design/ModelService.php
new file mode 100644
index 0000000..27b4e8c
--- /dev/null
+++ b/app/Services/Design/ModelService.php
@@ -0,0 +1,112 @@
+tenantId();
+
+ $query = DesignModel::query()->where('tenant_id', $tenantId);
+
+ if ($q !== '') {
+ $query->where(function ($w) use ($q) {
+ $w->where('code', 'like', "%{$q}%")
+ ->orWhere('name', 'like', "%{$q}%")
+ ->orWhere('description', 'like', "%{$q}%");
+ });
+ }
+
+ return $query->orderByDesc('id')->paginate($size, ['*'], 'page', $page);
+ }
+
+ /** 생성 */
+ public function create(array $data): DesignModel
+ {
+ $tenantId = $this->tenantId();
+
+ $exists = DesignModel::query()
+ ->where('tenant_id', $tenantId)
+ ->where('code', $data['code'])
+ ->exists();
+
+ if ($exists) {
+ throw ValidationException::withMessages(['code' => __('error.duplicate')]);
+ }
+
+ return DB::transaction(function () use ($tenantId, $data) {
+ $payload = array_merge($data, ['tenant_id' => $tenantId]);
+ return DesignModel::create($payload);
+ });
+ }
+
+ /** 단건 */
+ public function find(int $id): DesignModel
+ {
+ $tenantId = $this->tenantId();
+ $model = DesignModel::query()
+ ->where('tenant_id', $tenantId)
+ ->with(['versions' => fn($q) => $q->orderBy('version_no')])
+ ->find($id);
+
+ if (!$model) {
+ throw new NotFoundHttpException(__('error.not_found'));
+ }
+
+ return $model;
+ }
+
+ /** 수정 */
+ public function update(int $id, array $data): DesignModel
+ {
+ $tenantId = $this->tenantId();
+
+ $model = DesignModel::query()
+ ->where('tenant_id', $tenantId)
+ ->find($id);
+
+ if (!$model) {
+ throw new NotFoundHttpException(__('error.not_found'));
+ }
+
+ if (isset($data['code']) && $data['code'] !== $model->code) {
+ $dup = DesignModel::query()
+ ->where('tenant_id', $tenantId)
+ ->where('code', $data['code'])
+ ->exists();
+ if ($dup) {
+ throw ValidationException::withMessages(['code' => __('error.duplicate')]);
+ }
+ }
+
+ $model->fill($data);
+ $model->save();
+
+ return $model;
+ }
+
+ /** 삭제(soft) */
+ public function delete(int $id): void
+ {
+ $tenantId = $this->tenantId();
+
+ $model = DesignModel::query()
+ ->where('tenant_id', $tenantId)
+ ->find($id);
+
+ if (!$model) {
+ throw new NotFoundHttpException(__('error.not_found'));
+ }
+
+ $model->delete();
+ }
+}
diff --git a/app/Services/Design/ModelVersionService.php b/app/Services/Design/ModelVersionService.php
new file mode 100644
index 0000000..116f719
--- /dev/null
+++ b/app/Services/Design/ModelVersionService.php
@@ -0,0 +1,102 @@
+tenantId();
+
+ $model = DesignModel::query()
+ ->where('tenant_id', $tenantId)
+ ->find($modelId);
+
+ if (!$model) {
+ throw new NotFoundHttpException(__('error.not_found'));
+ }
+
+ return ModelVersion::query()
+ ->where('tenant_id', $tenantId)
+ ->where('model_id', $modelId)
+ ->orderBy('version_no')
+ ->get();
+ }
+
+ /** DRAFT 생성 (version_no 자동/수동) */
+ public function createDraft(int $modelId, array $extra = []): ModelVersion
+ {
+ $tenantId = $this->tenantId();
+
+ $model = DesignModel::query()
+ ->where('tenant_id', $tenantId)
+ ->find($modelId);
+
+ if (!$model) {
+ throw new NotFoundHttpException(__('error.not_found'));
+ }
+
+ return DB::transaction(function () use ($tenantId, $model, $extra) {
+ $versionNo = $extra['version_no'] ?? null;
+
+ if ($versionNo === null) {
+ $max = ModelVersion::query()
+ ->where('model_id', $model->id)
+ ->max('version_no');
+ $versionNo = (int)($max ?? 0) + 1;
+ } else {
+ $exists = ModelVersion::query()
+ ->where('model_id', $model->id)
+ ->where('version_no', $versionNo)
+ ->exists();
+ if ($exists) {
+ throw ValidationException::withMessages(['version_no' => __('error.duplicate')]);
+ }
+ }
+
+ return ModelVersion::create([
+ 'tenant_id' => $tenantId,
+ 'model_id' => $model->id,
+ 'version_no' => $versionNo,
+ 'status' => 'DRAFT',
+ 'is_active' => true,
+ 'notes' => $extra['notes'] ?? null,
+ 'effective_from' => $extra['effective_from'] ?? null,
+ 'effective_to' => $extra['effective_to'] ?? null,
+ ]);
+ });
+ }
+
+ /** RELEASED 전환 */
+ public function release(int $versionId): ModelVersion
+ {
+ $tenantId = $this->tenantId();
+
+ $mv = ModelVersion::query()
+ ->where('tenant_id', $tenantId)
+ ->find($versionId);
+
+ if (!$mv) {
+ throw new NotFoundHttpException(__('error.not_found'));
+ }
+
+ if ($mv->status === 'RELEASED') {
+ return $mv; // 멱등
+ }
+
+ // TODO: 대표 템플릿 존재 등 사전 검증 훅 가능
+ $mv->status = 'RELEASED';
+ $mv->effective_from = $mv->effective_from ?? now();
+ $mv->save();
+
+ return $mv;
+ }
+}
diff --git a/app/Swagger/v1/DesignModelApi.php b/app/Swagger/v1/DesignModelApi.php
new file mode 100644
index 0000000..dec54cb
--- /dev/null
+++ b/app/Swagger/v1/DesignModelApi.php
@@ -0,0 +1,397 @@
+bigIncrements('id');
+ $table->unsignedBigInteger('tenant_id')->comment('테넌트ID');
+ $table->string('code', 100)->comment('모델코드(설계코드)');
+ $table->string('name', 200)->comment('모델명');
+ $table->unsignedBigInteger('category_id')->nullable()->comment('카테고리ID(참조용, FK 미설정)');
+ $table->string('lifecycle', 30)->nullable()->comment('PLANNING/ACTIVE/DEPRECATED 등');
+ $table->text('description')->nullable();
+ $table->boolean('is_active')->default(true);
+ $table->timestamps();
+ $table->softDeletes();
+
+ $table->unique(['tenant_id', 'code'], 'uq_models_tenant_code');
+ $table->index(['tenant_id', 'is_active'], 'idx_models_tenant_active');
+ $table->index(['tenant_id', 'category_id'], 'idx_models_tenant_category');
+ });
+ }
+
+ public function down(): void {
+ Schema::dropIfExists('models');
+ }
+};
diff --git a/database/migrations/2025_09_05_000002_create_model_versions_table.php b/database/migrations/2025_09_05_000002_create_model_versions_table.php
new file mode 100644
index 0000000..dc22ae6
--- /dev/null
+++ b/database/migrations/2025_09_05_000002_create_model_versions_table.php
@@ -0,0 +1,32 @@
+bigIncrements('id');
+ $table->unsignedBigInteger('tenant_id')->comment('테넌트ID');
+ $table->unsignedBigInteger('model_id')->comment('모델ID');
+ $table->integer('version_no')->comment('버전번호(1..N)');
+ $table->string('status', 30)->default('DRAFT')->comment('DRAFT/RELEASED');
+ $table->dateTime('effective_from')->nullable();
+ $table->dateTime('effective_to')->nullable();
+ $table->text('notes')->nullable();
+ $table->boolean('is_active')->default(true);
+ $table->timestamps();
+ $table->softDeletes();
+
+ $table->unique(['model_id', 'version_no'], 'uq_model_versions_model_ver');
+ $table->index(['tenant_id', 'status'], 'idx_mv_tenant_status');
+ $table->index(['tenant_id', 'model_id'], 'idx_mv_tenant_model');
+ });
+ }
+
+ public function down(): void {
+ Schema::dropIfExists('model_versions');
+ }
+};
diff --git a/database/migrations/2025_09_05_000003_create_bom_templates_table.php b/database/migrations/2025_09_05_000003_create_bom_templates_table.php
new file mode 100644
index 0000000..428f3d8
--- /dev/null
+++ b/database/migrations/2025_09_05_000003_create_bom_templates_table.php
@@ -0,0 +1,29 @@
+bigIncrements('id');
+ $table->unsignedBigInteger('tenant_id')->comment('테넌트ID');
+ $table->unsignedBigInteger('model_version_id')->comment('모델버전ID');
+ $table->string('name', 100)->default('Main')->comment('템플릿명');
+ $table->boolean('is_primary')->default(true)->comment('대표 템플릿 여부');
+ $table->text('notes')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+
+ $table->unique(['model_version_id', 'name'], 'uq_bomtpl_mv_name');
+ $table->index(['tenant_id', 'model_version_id'], 'idx_bomtpl_tenant_mv');
+ $table->index(['tenant_id', 'is_primary'], 'idx_bomtpl_tenant_primary');
+ });
+ }
+
+ public function down(): void {
+ Schema::dropIfExists('bom_templates');
+ }
+};
diff --git a/database/migrations/2025_09_05_000004_create_bom_template_items_table.php b/database/migrations/2025_09_05_000004_create_bom_template_items_table.php
new file mode 100644
index 0000000..d2ee7ee
--- /dev/null
+++ b/database/migrations/2025_09_05_000004_create_bom_template_items_table.php
@@ -0,0 +1,32 @@
+bigIncrements('id');
+ $table->unsignedBigInteger('tenant_id')->comment('테넌트ID');
+ $table->unsignedBigInteger('bom_template_id')->comment('BOM템플릿ID');
+ $table->string('ref_type', 20)->comment('참조타입: MATERIAL|PRODUCT');
+ $table->unsignedBigInteger('ref_id')->comment('참조ID(materials.id 또는 products.id)');
+ $table->decimal('qty', 18, 6)->default(1)->comment('수량');
+ $table->decimal('waste_rate', 9, 6)->default(0)->comment('로스율');
+ $table->unsignedBigInteger('uom_id')->nullable()->comment('단위ID(참조용)');
+ $table->string('notes', 255)->nullable();
+ $table->integer('sort_order')->default(0);
+ $table->timestamps();
+
+ $table->index(['tenant_id', 'bom_template_id'], 'idx_bomtpl_items_tenant_tpl');
+ $table->index(['tenant_id', 'ref_type', 'ref_id'], 'idx_bomtpl_items_tenant_ref');
+ $table->index(['bom_template_id', 'sort_order'], 'idx_bomtpl_items_sort');
+ });
+ }
+
+ public function down(): void {
+ Schema::dropIfExists('bom_template_items');
+ }
+};
diff --git a/public/tenant/product/bom.php b/public/tenant/product/bom.php
new file mode 100644
index 0000000..cdbc9a7
--- /dev/null
+++ b/public/tenant/product/bom.php
@@ -0,0 +1,224 @@
+
+
+
+
+
+
BOM 템플릿 편집기
+
모델 ID: — 모델 버전 선택 후 구성 편집
+
+
+
+
+
+
+
+
+
+
+
+
+
※ 그룹 = BOM 템플릿 내 상위 분류 (예: 본체/절곡물/모터/부자재)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | 구분 |
+ 참조코드 |
+ 명칭 |
+ 수량 |
+ 옵션/조건 |
+ 삭제 |
+
+
+
+
+
+
+
+
+ ※ 구분: PRODUCT(서브모델) / MATERIAL(자재) / PART(부품). 옵션/조건은 견적-선택값과 매핑(예: 가이드레일=벽면형).
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/tenant/product/model.php b/public/tenant/product/model.php
new file mode 100644
index 0000000..fceecf2
--- /dev/null
+++ b/public/tenant/product/model.php
@@ -0,0 +1,321 @@
+
+
+
+
+
+
+
+ 모델 등록 | SAM Prototype (Model-only)
+
+
+
+
+
+
+
+
+
+
+
모델 등록
+
모델(설계 뼈대) 메타데이터만 입력합니다. ※ BOM/속성은 별도 화면
+
+
MODEL-ONLY
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 최종(리프) 카테고리를 선택하세요. 이 선택은 모델의 *분류*만 의미합니다.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
모델 메타데이터가 임시로 저장되었습니다. (콘솔 로그 확인)
+
+
+
+
+
+
+
+
+
+
diff --git a/public/tenant/product/model_list.php b/public/tenant/product/model_list.php
index 89b845e..ce6f2d4 100644
--- a/public/tenant/product/model_list.php
+++ b/public/tenant/product/model_list.php
@@ -4,307 +4,179 @@
include '../inc/header.php';
?>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
총 0건
-
-
-
-
-
-
-
-
- | NO |
- 분류 |
- 모델명 |
- 로트 코드 |
- 등록일 |
- 수정/삭제 |
-
-
-
-
-
-
-
-
-
-
-
-
-
-