From b086518075707f4c964a604c1fabc30c66cf9fec Mon Sep 17 00:00:00 2001 From: hskwon Date: Wed, 10 Dec 2025 21:37:20 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Material/Product=20=EB=8F=99=EC=A0=81?= =?UTF-8?q?=20=ED=95=84=EB=93=9C=20options=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EB=B0=8F=20=EC=9E=90=EC=9E=AC=20=EC=82=AD=EC=A0=9C=20=EB=B3=B4?= =?UTF-8?q?=ED=98=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - products 테이블에 options JSON 컬럼 추가 (마이그레이션) - Material/Product 모델에 options 필드 추가 (fillable, casts) - SystemFields::PRODUCTS에 options 상수 추가 - MaterialService/ProductService에 동적 필드 자동 추출 로직: - getKnownFields(): SystemFields + ItemField 기반 고정 필드 조회 - extractDynamicOptions(): 동적 필드 추출 - normalizeOptions(): [{label, value, unit}] 형태로 정규화 - material_code 중복 체크 시 soft delete 포함 (withTrashed) - 사용 중인 자재 삭제 방지 (checkMaterialUsage) - Material 모델에 category 관계 추가 --- app/Constants/SystemFields.php | 1 + .../Controllers/Api/V1/MaterialController.php | 6 +- app/Models/Materials/Material.php | 7 + app/Models/Products/Product.php | 4 +- app/Services/MaterialService.php | 194 +++++++++++++++++- app/Services/ProductService.php | 134 +++++++++++- ...9_add_options_column_to_products_table.php | 29 +++ 7 files changed, 362 insertions(+), 13 deletions(-) create mode 100644 database/migrations/2025_12_10_211539_add_options_column_to_products_table.php diff --git a/app/Constants/SystemFields.php b/app/Constants/SystemFields.php index 834f5f6..9b3e257 100644 --- a/app/Constants/SystemFields.php +++ b/app/Constants/SystemFields.php @@ -57,6 +57,7 @@ class SystemFields // JSON 필드 'attributes', 'attributes_archive', + 'options', ]; /** diff --git a/app/Http/Controllers/Api/V1/MaterialController.php b/app/Http/Controllers/Api/V1/MaterialController.php index cb35793..ae94404 100644 --- a/app/Http/Controllers/Api/V1/MaterialController.php +++ b/app/Http/Controllers/Api/V1/MaterialController.php @@ -23,7 +23,8 @@ public function index(Request $request) public function store(MaterialStoreRequest $request) { return ApiResponse::handle(function () use ($request) { - return $this->service->setMaterial($request->validated()); + // 동적 필드 지원을 위해 전체 입력값 전달 (Service에서 검증) + return $this->service->setMaterial($request->all()); }, __('message.material.created')); } @@ -37,7 +38,8 @@ public function show(int $id) public function update(MaterialUpdateRequest $request, int $id) { return ApiResponse::handle(function () use ($request, $id) { - return $this->service->updateMaterial($id, $request->validated()); + // 동적 필드 지원을 위해 전체 입력값 전달 (Service에서 검증) + return $this->service->updateMaterial($id, $request->all()); }, __('message.material.updated')); } diff --git a/app/Models/Materials/Material.php b/app/Models/Materials/Material.php index 0c58398..9eb7bbb 100644 --- a/app/Models/Materials/Material.php +++ b/app/Models/Materials/Material.php @@ -2,6 +2,7 @@ namespace App\Models\Materials; +use App\Models\Commons\Category; use App\Models\Commons\File; use App\Models\Commons\Tag; use App\Models\Qualitys\Lot; @@ -46,6 +47,12 @@ class Material extends Model 'deleted_at', ]; + // 카테고리 + public function category() + { + return $this->belongsTo(Category::class); + } + // 자재 입고 내역 public function receipts() { diff --git a/app/Models/Products/Product.php b/app/Models/Products/Product.php index 53825eb..6f18b7c 100644 --- a/app/Models/Products/Product.php +++ b/app/Models/Products/Product.php @@ -17,12 +17,11 @@ class Product extends Model protected $fillable = [ 'tenant_id', 'code', 'name', 'unit', 'category_id', 'product_type', // 라벨/분류용 - 'attributes', 'description', + 'attributes', 'attributes_archive', 'options', 'description', 'is_sellable', 'is_purchasable', 'is_producible', // 하이브리드 구조: 최소 고정 필드 'safety_stock', 'lead_time', 'is_variable_size', 'product_category', 'part_type', - 'attributes_archive', // 파일 필드 'bending_diagram', 'bending_details', 'specification_file', 'specification_file_name', @@ -34,6 +33,7 @@ class Product extends Model protected $casts = [ 'attributes' => 'array', 'attributes_archive' => 'array', + 'options' => 'array', 'bending_details' => 'array', 'certification_start_date' => 'date', 'certification_end_date' => 'date', diff --git a/app/Services/MaterialService.php b/app/Services/MaterialService.php index 1e98463..e02fd06 100644 --- a/app/Services/MaterialService.php +++ b/app/Services/MaterialService.php @@ -2,11 +2,82 @@ namespace App\Services; +use App\Constants\SystemFields; +use App\Models\ItemMaster\ItemField; use App\Models\Materials\Material; use Illuminate\Support\Facades\Validator; class MaterialService extends Service { + /** + * materials 테이블의 고정 컬럼 목록 조회 (SystemFields + ItemField 기반) + */ + private function getKnownFields(): array + { + $tenantId = $this->tenantId(); + + // 1. SystemFields에서 materials 테이블 고정 컬럼 + $systemFields = SystemFields::getReservedKeys(SystemFields::SOURCE_TABLE_MATERIALS); + + // 2. ItemField에서 storage_type='column'인 필드의 field_key 조회 + $columnFields = ItemField::where('tenant_id', $tenantId) + ->where('source_table', 'materials') + ->where('storage_type', 'column') + ->whereNotNull('field_key') + ->pluck('field_key') + ->toArray(); + + // 3. 추가적인 API 전용 필드 (DB 컬럼이 아니지만 API에서 사용하는 필드) + $apiFields = ['item_type', 'type_code', 'bom']; + + return array_unique(array_merge($systemFields, $columnFields, $apiFields)); + } + + /** + * 정의된 필드 외의 동적 필드를 options로 추출 + */ + private function extractDynamicOptions(array $params): array + { + $knownFields = $this->getKnownFields(); + + $dynamicOptions = []; + foreach ($params as $key => $value) { + if (! in_array($key, $knownFields) && $value !== null && $value !== '') { + $dynamicOptions[$key] = $value; + } + } + + return $dynamicOptions; + } + + /** + * 기존 options 배열과 동적 필드를 병합 + * - 기존 options가 [{label, value}] 배열이면 동적 필드를 배열 항목으로 추가 + * - 기존 options가 {key: value} 맵이면 동적 필드를 맵에 병합 + */ + private function mergeOptionsWithDynamic($existingOptions, array $dynamicOptions): array + { + if (! is_array($existingOptions) || empty($existingOptions)) { + // 기존 options가 없으면 동적 필드만 반환 + return $dynamicOptions; + } + + // 기존 options가 연관 배열(맵)인지 판별 + $isAssoc = array_keys($existingOptions) !== range(0, count($existingOptions) - 1); + + if ($isAssoc) { + // 맵 형태면 단순 병합 + return array_merge($existingOptions, $dynamicOptions); + } + + // 배열 형태 [{label, value}]면 동적 필드를 배열 항목으로 추가 + foreach ($dynamicOptions as $key => $value) { + $existingOptions[] = ['label' => $key, 'value' => $value]; + } + + return $existingOptions; + } + /** 공통 검증 헬퍼 */ protected function v(array $input, array $rules) { @@ -84,6 +155,13 @@ public function setMaterial(array $params) $tenantId = $this->tenantId(); $userId = $this->apiUserId(); + // 동적 필드를 options에 병합 + $dynamicOptions = $this->extractDynamicOptions($params); + if (! empty($dynamicOptions)) { + $existingOptions = $params['options'] ?? []; + $params['options'] = $this->mergeOptionsWithDynamic($existingOptions, $dynamicOptions); + } + $p = $this->v($params, [ 'category_id' => 'nullable|integer|min:1', 'name' => 'required|string|max:100', @@ -100,6 +178,26 @@ public function setMaterial(array $params) return $p; } + // material_code 중복 체크 (삭제된 레코드 포함 - DB unique 제약은 deleted_at 무시) + if (! empty($p['material_code'])) { + $duplicate = Material::withTrashed() + ->where('tenant_id', $tenantId) + ->where('material_code', $p['material_code']) + ->first(['id', 'name', 'deleted_at']); + + if ($duplicate) { + if ($duplicate->deleted_at) { + return [ + 'error' => "자재코드 '{$p['material_code']}'가 삭제된 자재에서 사용 중입니다. 해당 자재를 복구하거나 완전 삭제 후 다시 시도하세요.", + 'code' => 422, + 'deleted_material_id' => $duplicate->id, + ]; + } + + return ['error' => "자재코드 '{$p['material_code']}'가 이미 존재합니다.", 'code' => 422]; + } + } + // 기존 normalizeAttributes 사용(그대로), options는 새 normalizeOptions 사용 $attributes = $this->normalizeAttributes($p['attributes'] ?? null); $options = $this->normalizeOptions($p['options'] ?? null); @@ -139,6 +237,13 @@ public function updateMaterial(int $id, array $params = []) return ['error' => '자재를 찾을 수 없습니다.', 'code' => 404]; } + // 동적 필드를 options에 병합 + $dynamicOptions = $this->extractDynamicOptions($params); + if (! empty($dynamicOptions)) { + $existingOptions = $params['options'] ?? []; + $params['options'] = $this->mergeOptionsWithDynamic($existingOptions, $dynamicOptions); + } + $p = $this->v($params, [ 'category_id' => 'nullable|integer|min:1', 'name' => 'nullable|string|max:100', @@ -155,6 +260,28 @@ public function updateMaterial(int $id, array $params = []) return $p; } + // material_code 중복 체크 (삭제된 레코드 포함 - DB unique 제약은 deleted_at 무시) + $finalMaterialCode = $p['material_code'] ?? $exists->material_code; + if (! empty($finalMaterialCode)) { + $duplicate = Material::withTrashed() + ->where('tenant_id', $tenantId) + ->where('material_code', $finalMaterialCode) + ->where('id', '!=', $id) + ->first(['id', 'name', 'deleted_at']); + + if ($duplicate) { + if ($duplicate->deleted_at) { + return [ + 'error' => "자재코드 '{$finalMaterialCode}'가 삭제된 자재에서 사용 중입니다. 해당 자재를 복구하거나 완전 삭제 후 다시 시도하세요.", + 'code' => 422, + 'deleted_material_id' => $duplicate->id, + ]; + } + + return ['error' => "자재코드 '{$finalMaterialCode}'가 이미 존재합니다.", 'code' => 422]; + } + } + $currentAttrs = is_array($exists->attributes) ? $exists->attributes : ($exists->attributes ? json_decode($exists->attributes, true) : null); $currentOpts = is_array($exists->options) ? $exists->options @@ -209,11 +336,62 @@ public function destroyMaterial(int $id) return ['error' => '자재를 찾을 수 없습니다.', 'code' => 404]; } + // 사용 중인 자재 삭제 방지 + $usageCheck = $this->checkMaterialUsage($id, $tenantId); + if ($usageCheck['in_use']) { + return [ + 'error' => '사용 중인 자재는 삭제할 수 없습니다.', + 'code' => 422, + 'usage' => $usageCheck['details'], + ]; + } + $row->delete(); return ['id' => $id, 'deleted_at' => now()->toDateTimeString()]; } + /** + * 자재 사용 여부 체크 + * - material_receipts: 입고 내역 + * - lots: 로트 관리 + * - product_components: BOM 구성품 (ref_type='MATERIAL') + */ + private function checkMaterialUsage(int $materialId, int $tenantId): array + { + $details = []; + + // 1. 입고 내역 체크 + $receiptCount = \App\Models\Materials\MaterialReceipt::where('tenant_id', $tenantId) + ->where('material_id', $materialId) + ->count(); + if ($receiptCount > 0) { + $details['receipts'] = $receiptCount; + } + + // 2. 로트 체크 + $lotCount = \App\Models\Qualitys\Lot::where('tenant_id', $tenantId) + ->where('material_id', $materialId) + ->count(); + if ($lotCount > 0) { + $details['lots'] = $lotCount; + } + + // 3. BOM 구성품 체크 (ref_type='MATERIAL', ref_id=material_id) + $bomCount = \App\Models\Products\ProductComponent::where('tenant_id', $tenantId) + ->where('ref_type', 'MATERIAL') + ->where('ref_id', $materialId) + ->count(); + if ($bomCount > 0) { + $details['bom_components'] = $bomCount; + } + + return [ + 'in_use' => ! empty($details), + 'details' => $details, + ]; + } + /* ------------------------- 헬퍼: 규격/품목명 빌더 attributes 예시: @@ -292,9 +470,9 @@ private function buildSpecText(?array $attrs): ?string } /** - * options 입력을 [{label,value,unit}] 형태로 정규화. - * - 이미 리스트(triple)면 그대로 정규화 - * - 맵({"키":"값"})이면 [{label:키, value:값, unit:""}...]로 변환 + * options 입력을 [{label, value, unit}] 형태로 정규화. + * - 맵 형태 {"key": "value"}도 배열로 변환 + * - 항상 [{label, value, unit}] 형태로 저장 */ private function normalizeOptions(?array $in): ?array { @@ -306,14 +484,14 @@ private function normalizeOptions(?array $in): ?array $isAssoc = array_keys($in) !== range(0, count($in) - 1); if ($isAssoc) { + // 맵 형태를 [{label, value, unit}] 배열로 변환 $out = []; foreach ($in as $k => $v) { $label = trim((string) $k); $value = is_scalar($v) ? trim((string) $v) : json_encode($v, JSON_UNESCAPED_UNICODE); - if ($label === '' && $value === '') { - continue; + if ($label !== '' || $value !== '') { + $out[] = ['label' => $label, 'value' => $value, 'unit' => '']; } - $out[] = ['label' => $label, 'value' => $value, 'unit' => '']; } return $out ?: null; @@ -328,9 +506,11 @@ private function normalizeOptions(?array $in): ?array $label = trim((string) ($a['label'] ?? '')); $value = trim((string) ($a['value'] ?? '')); $unit = trim((string) ($a['unit'] ?? '')); - if ($label === '' && $value === '' && $unit === '') { + + if ($label === '' && $value === '') { continue; } + $out[] = ['label' => $label, 'value' => $value, 'unit' => $unit]; } diff --git a/app/Services/ProductService.php b/app/Services/ProductService.php index d1bb912..16fc9a9 100644 --- a/app/Services/ProductService.php +++ b/app/Services/ProductService.php @@ -2,13 +2,121 @@ namespace App\Services; +use App\Constants\SystemFields; use App\Models\Commons\Category; +use App\Models\ItemMaster\ItemField; use App\Models\Products\CommonCode; use App\Models\Products\Product; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; class ProductService extends Service { + /** + * products 테이블의 고정 컬럼 목록 조회 (SystemFields + ItemField 기반) + */ + private function getKnownFields(): array + { + $tenantId = $this->tenantId(); + + // 1. SystemFields에서 products 테이블 고정 컬럼 + $systemFields = SystemFields::getReservedKeys(SystemFields::SOURCE_TABLE_PRODUCTS); + + // 2. ItemField에서 storage_type='column'인 필드의 field_key 조회 + $columnFields = ItemField::where('tenant_id', $tenantId) + ->where('source_table', 'products') + ->where('storage_type', 'column') + ->whereNotNull('field_key') + ->pluck('field_key') + ->toArray(); + + // 3. 추가적인 API 전용 필드 (DB 컬럼이 아니지만 API에서 사용하는 필드) + $apiFields = ['item_type', 'type_code', 'bom']; + + return array_unique(array_merge($systemFields, $columnFields, $apiFields)); + } + + /** + * 정의된 필드 외의 동적 필드를 options로 추출 + */ + private function extractDynamicOptions(array $params): array + { + $knownFields = $this->getKnownFields(); + + $dynamicOptions = []; + foreach ($params as $key => $value) { + if (! in_array($key, $knownFields) && $value !== null && $value !== '') { + $dynamicOptions[$key] = $value; + } + } + + return $dynamicOptions; + } + + /** + * 기존 options 배열과 동적 필드를 병합 + */ + private function mergeOptionsWithDynamic($existingOptions, array $dynamicOptions): array + { + if (! is_array($existingOptions) || empty($existingOptions)) { + return $dynamicOptions; + } + + $isAssoc = array_keys($existingOptions) !== range(0, count($existingOptions) - 1); + + if ($isAssoc) { + return array_merge($existingOptions, $dynamicOptions); + } + + foreach ($dynamicOptions as $key => $value) { + $existingOptions[] = ['label' => $key, 'value' => $value]; + } + + return $existingOptions; + } + + /** + * options 입력을 [{label, value, unit}] 형태로 정규화 + */ + private function normalizeOptions(?array $in): ?array + { + if (! $in) { + return null; + } + + $isAssoc = array_keys($in) !== range(0, count($in) - 1); + + if ($isAssoc) { + $out = []; + foreach ($in as $k => $v) { + $label = trim((string) $k); + $value = is_scalar($v) ? trim((string) $v) : json_encode($v, JSON_UNESCAPED_UNICODE); + if ($label !== '' || $value !== '') { + $out[] = ['label' => $label, 'value' => $value, 'unit' => '']; + } + } + + return $out ?: null; + } + + $out = []; + foreach ($in as $a) { + if (! is_array($a)) { + continue; + } + $label = trim((string) ($a['label'] ?? '')); + $value = trim((string) ($a['value'] ?? '')); + $unit = trim((string) ($a['unit'] ?? '')); + + if ($label === '' && $value === '') { + continue; + } + + $out[] = ['label' => $label, 'value' => $value, 'unit' => $unit]; + } + + return $out ?: null; + } + /** * 카테고리 트리 전체 조회 (parent_id = null 기준) */ @@ -117,7 +225,18 @@ public function store(array $data) $tenantId = $this->tenantId(); $userId = $this->apiUserId(); - // FormRequest에서 이미 검증됨 + // 동적 필드를 options에 병합 + $dynamicOptions = $this->extractDynamicOptions($data); + if (! empty($dynamicOptions)) { + $existingOptions = $data['options'] ?? []; + $data['options'] = $this->mergeOptionsWithDynamic($existingOptions, $dynamicOptions); + } + + // options 정규화 + if (isset($data['options'])) { + $data['options'] = $this->normalizeOptions($data['options']); + } + $payload = $data; // tenant별 code 유니크 수동 체크 @@ -162,7 +281,18 @@ public function update(int $id, array $data) throw new BadRequestHttpException(__('error.not_found')); } - // FormRequest에서 이미 검증됨 + // 동적 필드를 options에 병합 + $dynamicOptions = $this->extractDynamicOptions($data); + if (! empty($dynamicOptions)) { + $existingOptions = $data['options'] ?? []; + $data['options'] = $this->mergeOptionsWithDynamic($existingOptions, $dynamicOptions); + } + + // options 정규화 + if (isset($data['options'])) { + $data['options'] = $this->normalizeOptions($data['options']); + } + $payload = $data; // code 변경 시 중복 체크 diff --git a/database/migrations/2025_12_10_211539_add_options_column_to_products_table.php b/database/migrations/2025_12_10_211539_add_options_column_to_products_table.php new file mode 100644 index 0000000..f34249f --- /dev/null +++ b/database/migrations/2025_12_10_211539_add_options_column_to_products_table.php @@ -0,0 +1,29 @@ +json('options')->nullable()->after('attributes_archive') + ->comment('동적 옵션 필드 [{label, value, unit}]'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('products', function (Blueprint $table) { + $table->dropColumn('options'); + }); + } +}; \ No newline at end of file