From c11ac7867c9039ceb94887010eb54d332a85968c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Tue, 17 Mar 2026 13:06:29 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[bending]=20=EC=A0=88=EA=B3=A1=ED=92=88?= =?UTF-8?q?=20=EC=BD=94=EB=93=9C=EB=A7=B5/=ED=92=88=EB=AA=A9=EB=A7=A4?= =?UTF-8?q?=ED=95=91/LOT=20=EC=B1=84=EB=B2=88=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - bending_item_mappings 테이블 마이그레이션 - BendingCodeService: 코드 체계, 품목 매핑, LOT 일련번호 생성 - BendingController: code-map, resolve-item, generate-lot 엔드포인트 - StoreOrderRequest/UpdateOrderRequest: bending_lot validation 추가 --- .../Controllers/Api/V1/BendingController.php | 79 ++++++++ app/Http/Requests/Order/StoreOrderRequest.php | 10 + .../Requests/Order/UpdateOrderRequest.php | 10 + app/Models/Production/BendingItemMapping.php | 33 ++++ app/Services/BendingCodeService.php | 182 ++++++++++++++++++ ...000_create_bending_item_mappings_table.php | 30 +++ routes/api/v1/production.php | 8 + 7 files changed, 352 insertions(+) create mode 100644 app/Http/Controllers/Api/V1/BendingController.php create mode 100644 app/Models/Production/BendingItemMapping.php create mode 100644 app/Services/BendingCodeService.php create mode 100644 database/migrations/2026_03_17_120000_create_bending_item_mappings_table.php diff --git a/app/Http/Controllers/Api/V1/BendingController.php b/app/Http/Controllers/Api/V1/BendingController.php new file mode 100644 index 00000000..6fc75229 --- /dev/null +++ b/app/Http/Controllers/Api/V1/BendingController.php @@ -0,0 +1,79 @@ +service->getCodeMap(); + }, __('message.fetched')); + } + + /** + * 드롭다운 선택 → 품목 매핑 조회 + */ + public function resolveItem(Request $request): JsonResponse + { + return ApiResponse::handle(function () use ($request) { + $prodCode = $request->query('prod'); + $specCode = $request->query('spec'); + $lengthCode = $request->query('length'); + + if (! $prodCode || ! $specCode || ! $lengthCode) { + return ['error' => 'MISSING_PARAMS', 'code' => 400, 'message' => 'prod, spec, length 파라미터가 필요합니다.']; + } + + $item = $this->service->resolveItem($prodCode, $specCode, $lengthCode); + + if (! $item) { + return ['error' => 'NOT_MAPPED', 'code' => 404, 'message' => '해당 조합에 매핑된 품목이 없습니다.']; + } + + return $item; + }, __('message.fetched')); + } + + /** + * LOT 번호 생성 (프리뷰 + 일련번호 확정) + */ + public function generateLotNumber(Request $request): JsonResponse + { + return ApiResponse::handle(function () use ($request) { + $prodCode = $request->input('prod_code'); + $specCode = $request->input('spec_code'); + $lengthCode = $request->input('length_code'); + $regDate = $request->input('reg_date', now()->toDateString()); + + if (! $prodCode || ! $specCode || ! $lengthCode) { + return ['error' => 'MISSING_PARAMS', 'code' => 400, 'message' => 'prod_code, spec_code, length_code가 필요합니다.']; + } + + $dateCode = BendingCodeService::generateDateCode($regDate); + $lotBase = "{$prodCode}{$specCode}{$dateCode}-{$lengthCode}"; + $lotNumber = $this->service->generateLotNumber($lotBase); + $material = BendingCodeService::getMaterial($prodCode, $specCode); + + return [ + 'lot_base' => $lotBase, + 'lot_number' => $lotNumber, + 'date_code' => $dateCode, + 'material' => $material, + ]; + }, __('message.fetched')); + } +} diff --git a/app/Http/Requests/Order/StoreOrderRequest.php b/app/Http/Requests/Order/StoreOrderRequest.php index 272f924c..e4a4a5c4 100644 --- a/app/Http/Requests/Order/StoreOrderRequest.php +++ b/app/Http/Requests/Order/StoreOrderRequest.php @@ -58,6 +58,16 @@ public function rules(): array 'options.production_reason' => 'nullable|string|max:500', 'options.target_stock_qty' => 'nullable|numeric|min:0', + // 절곡품 LOT 정보 (STOCK 전용) + 'options.bending_lot' => 'nullable|array', + 'options.bending_lot.lot_number' => 'nullable|string|max:30', + 'options.bending_lot.prod_code' => 'nullable|string|max:2', + 'options.bending_lot.spec_code' => 'nullable|string|max:2', + 'options.bending_lot.length_code' => 'nullable|string|max:2', + 'options.bending_lot.raw_lot_no' => 'nullable|string|max:50', + 'options.bending_lot.fabric_lot_no' => 'nullable|string|max:50', + 'options.bending_lot.material' => 'nullable|string|max:50', + // 품목 배열 'items' => 'nullable|array', 'items.*.item_id' => 'nullable|integer|exists:items,id', diff --git a/app/Http/Requests/Order/UpdateOrderRequest.php b/app/Http/Requests/Order/UpdateOrderRequest.php index 53cd3168..ae9db234 100644 --- a/app/Http/Requests/Order/UpdateOrderRequest.php +++ b/app/Http/Requests/Order/UpdateOrderRequest.php @@ -52,6 +52,16 @@ public function rules(): array 'options.production_reason' => 'nullable|string|max:500', 'options.target_stock_qty' => 'nullable|numeric|min:0', + // 절곡품 LOT 정보 (STOCK 전용) + 'options.bending_lot' => 'nullable|array', + 'options.bending_lot.lot_number' => 'nullable|string|max:30', + 'options.bending_lot.prod_code' => 'nullable|string|max:2', + 'options.bending_lot.spec_code' => 'nullable|string|max:2', + 'options.bending_lot.length_code' => 'nullable|string|max:2', + 'options.bending_lot.raw_lot_no' => 'nullable|string|max:50', + 'options.bending_lot.fabric_lot_no' => 'nullable|string|max:50', + 'options.bending_lot.material' => 'nullable|string|max:50', + // 품목 배열 (전체 교체) 'items' => 'nullable|array', 'items.*.item_id' => 'nullable|integer|exists:items,id', diff --git a/app/Models/Production/BendingItemMapping.php b/app/Models/Production/BendingItemMapping.php new file mode 100644 index 00000000..cb264c38 --- /dev/null +++ b/app/Models/Production/BendingItemMapping.php @@ -0,0 +1,33 @@ + 'boolean', + ]; + + public function item(): BelongsTo + { + return $this->belongsTo(Item::class); + } +} diff --git a/app/Services/BendingCodeService.php b/app/Services/BendingCodeService.php new file mode 100644 index 00000000..818f4c04 --- /dev/null +++ b/app/Services/BendingCodeService.php @@ -0,0 +1,182 @@ + 'R', 'name' => '가이드레일(벽면형)'], + ['code' => 'S', 'name' => '가이드레일(측면형)'], + ['code' => 'G', 'name' => '연기차단재'], + ['code' => 'B', 'name' => '하단마감재(스크린)'], + ['code' => 'T', 'name' => '하단마감재(철재)'], + ['code' => 'L', 'name' => 'L-Bar'], + ['code' => 'C', 'name' => '케이스'], + ]; + + // ========================================================================= + // 종류 코드 + 사용 가능 제품 + // ========================================================================= + public const SPECS = [ + ['code' => 'M', 'name' => '본체', 'products' => ['R', 'S']], + ['code' => 'T', 'name' => '본체(철재)', 'products' => ['R', 'S']], + ['code' => 'C', 'name' => 'C형', 'products' => ['R', 'S']], + ['code' => 'D', 'name' => 'D형', 'products' => ['R', 'S']], + ['code' => 'S', 'name' => 'SUS(마감)', 'products' => ['R', 'S', 'B', 'T']], + ['code' => 'U', 'name' => 'SUS(마감)2', 'products' => ['S']], + ['code' => 'E', 'name' => 'EGI(마감)', 'products' => ['R', 'S', 'B', 'T']], + ['code' => 'I', 'name' => '화이바원단', 'products' => ['G']], + ['code' => 'A', 'name' => '스크린용', 'products' => ['L']], + ['code' => 'F', 'name' => '전면부', 'products' => ['C']], + ['code' => 'P', 'name' => '점검구', 'products' => ['C']], + ['code' => 'L', 'name' => '린텔부', 'products' => ['C']], + ['code' => 'B', 'name' => '후면코너부', 'products' => ['C']], + ]; + + // ========================================================================= + // 모양&길이 코드 + // ========================================================================= + public const LENGTHS_SMOKE_BARRIER = [ + ['code' => '53', 'name' => 'W50 × 3000'], + ['code' => '54', 'name' => 'W50 × 4000'], + ['code' => '83', 'name' => 'W80 × 3000'], + ['code' => '84', 'name' => 'W80 × 4000'], + ]; + + public const LENGTHS_GENERAL = [ + ['code' => '12', 'name' => '1219'], + ['code' => '24', 'name' => '2438'], + ['code' => '30', 'name' => '3000'], + ['code' => '35', 'name' => '3500'], + ['code' => '40', 'name' => '4000'], + ['code' => '41', 'name' => '4150'], + ['code' => '42', 'name' => '4200'], + ['code' => '43', 'name' => '4300'], + ]; + + // ========================================================================= + // 제품+종류 → 원자재(재질) 매핑 + // ========================================================================= + public const MATERIAL_MAP = [ + 'G:I' => '화이바원단', + 'B:S' => 'SUS 1.2T', + 'B:E' => 'EGI 1.55T', + 'T:S' => 'SUS 1.2T', + 'T:E' => 'EGI 1.55T', + 'L:A' => 'EGI 1.55T', + 'R:M' => 'EGI 1.55T', + 'R:T' => 'EGI 1.55T', + 'R:C' => 'EGI 1.55T', + 'R:D' => 'EGI 1.55T', + 'R:S' => 'SUS 1.2T', + 'R:E' => 'EGI 1.55T', + 'S:M' => 'EGI 1.55T', + 'S:T' => 'EGI 1.55T', + 'S:C' => 'EGI 1.55T', + 'S:D' => 'EGI 1.55T', + 'S:S' => 'SUS 1.2T', + 'S:U' => 'SUS 1.2T', + 'S:E' => 'EGI 1.55T', + 'C:F' => 'EGI 1.55T', + 'C:P' => 'EGI 1.55T', + 'C:L' => 'EGI 1.55T', + 'C:B' => 'EGI 1.55T', + ]; + + /** + * 코드맵 전체 반환 (프론트엔드 드롭다운 구성용) + */ + public function getCodeMap(): array + { + return [ + 'products' => self::PRODUCTS, + 'specs' => self::SPECS, + 'lengths' => [ + 'smoke_barrier' => self::LENGTHS_SMOKE_BARRIER, + 'general' => self::LENGTHS_GENERAL, + ], + 'material_map' => self::MATERIAL_MAP, + ]; + } + + /** + * 드롭다운 선택 조합 → items 테이블 품목 매핑 조회 + */ + public function resolveItem(string $prodCode, string $specCode, string $lengthCode): ?array + { + $mapping = BendingItemMapping::where('tenant_id', $this->tenantId()) + ->where('prod_code', $prodCode) + ->where('spec_code', $specCode) + ->where('length_code', $lengthCode) + ->where('is_active', true) + ->with('item:id,code,name,specification,unit') + ->first(); + + if (! $mapping || ! $mapping->item) { + return null; + } + + return [ + 'item_id' => $mapping->item->id, + 'item_code' => $mapping->item->code, + 'item_name' => $mapping->item->name, + 'specification' => $mapping->item->specification, + 'unit' => $mapping->item->unit ?? 'EA', + ]; + } + + /** + * LOT 번호 생성 (일련번호 suffix 포함) + * + * base: 'GI6317-53' → 결과: 'GI6317-53-001' + */ + public function generateLotNumber(string $lotBase): string + { + $tenantId = $this->tenantId(); + + // 같은 base로 시작하는 기존 LOT 수 조회 + $count = Order::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('order_type_code', Order::TYPE_STOCK) + ->where('options->bending_lot->lot_number', 'LIKE', $lotBase.'-%') + ->count(); + + $seq = str_pad($count + 1, 3, '0', STR_PAD_LEFT); + + return "{$lotBase}-{$seq}"; + } + + /** + * 날짜 → 4자리 날짜코드 + * + * 2026-03-17 → '6317' + * 2026-10-05 → '6A05' + */ + public static function generateDateCode(string $date): string + { + $dt = \Carbon\Carbon::parse($date); + $year = $dt->year % 10; + $month = $dt->month; + $day = $dt->day; + + $monthCode = $month >= 10 + ? chr(55 + $month) // 10=A, 11=B, 12=C + : (string) $month; + + return $year.$monthCode.str_pad($day, 2, '0', STR_PAD_LEFT); + } + + /** + * 제품+종류 → 원자재(재질) 반환 + */ + public static function getMaterial(string $prodCode, string $specCode): ?string + { + return self::MATERIAL_MAP["{$prodCode}:{$specCode}"] ?? null; + } +} diff --git a/database/migrations/2026_03_17_120000_create_bending_item_mappings_table.php b/database/migrations/2026_03_17_120000_create_bending_item_mappings_table.php new file mode 100644 index 00000000..04273a59 --- /dev/null +++ b/database/migrations/2026_03_17_120000_create_bending_item_mappings_table.php @@ -0,0 +1,30 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->string('prod_code', 2)->comment('제품코드: R,S,G,B,T,L,C'); + $table->string('spec_code', 2)->comment('종류코드: M,S,I,E,A,D,C,U,T,F,P,L,B'); + $table->string('length_code', 2)->comment('모양&길이코드: 53,42...'); + $table->unsignedBigInteger('item_id')->comment('매핑된 품목 ID'); + $table->boolean('is_active')->default(true); + $table->timestamps(); + + $table->unique(['tenant_id', 'prod_code', 'spec_code', 'length_code'], 'bim_tenant_prod_spec_length_unique'); + $table->index(['tenant_id', 'is_active']); + }); + } + + public function down(): void + { + Schema::dropIfExists('bending_item_mappings'); + } +}; diff --git a/routes/api/v1/production.php b/routes/api/v1/production.php index 17cb3f5b..0bddd2e3 100644 --- a/routes/api/v1/production.php +++ b/routes/api/v1/production.php @@ -9,6 +9,7 @@ * - 검사 관리 */ +use App\Http\Controllers\Api\V1\BendingController; use App\Http\Controllers\Api\V1\InspectionController; use App\Http\Controllers\Api\V1\ProductionOrderController; use App\Http\Controllers\Api\V1\WorkOrderController; @@ -123,6 +124,13 @@ Route::patch('/{id}/complete', [InspectionController::class, 'complete'])->whereNumber('id')->name('v1.inspections.complete'); // 완료 처리 }); +// Bending API (절곡품 코드맵/품목매핑/LOT) +Route::prefix('bending')->group(function () { + Route::get('/code-map', [BendingController::class, 'codeMap'])->name('v1.bending.code-map'); + Route::get('/resolve-item', [BendingController::class, 'resolveItem'])->name('v1.bending.resolve-item'); + Route::post('/generate-lot', [BendingController::class, 'generateLotNumber'])->name('v1.bending.generate-lot'); +}); + // Production Order API (생산지시 조회) Route::prefix('production-orders')->group(function () { Route::get('', [ProductionOrderController::class, 'index'])->name('v1.production-orders.index');