feat: [bending] 절곡품 코드맵/품목매핑/LOT 채번 API 추가

- bending_item_mappings 테이블 마이그레이션
- BendingCodeService: 코드 체계, 품목 매핑, LOT 일련번호 생성
- BendingController: code-map, resolve-item, generate-lot 엔드포인트
- StoreOrderRequest/UpdateOrderRequest: bending_lot validation 추가
This commit is contained in:
김보곤
2026-03-17 13:06:29 +09:00
parent 269a17b49c
commit c11ac7867c
7 changed files with 352 additions and 0 deletions

View File

@@ -0,0 +1,79 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Services\BendingCodeService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class BendingController extends Controller
{
public function __construct(
private readonly BendingCodeService $service
) {}
/**
* 절곡품 코드맵 조회 (캐스케이딩 드롭다운용)
*/
public function codeMap(): JsonResponse
{
return ApiResponse::handle(function () {
return $this->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'));
}
}

View File

@@ -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',

View File

@@ -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',

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Models\Production;
use App\Models\Items\Item;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class BendingItemMapping extends Model
{
use BelongsToTenant;
protected $table = 'bending_item_mappings';
protected $fillable = [
'tenant_id',
'prod_code',
'spec_code',
'length_code',
'item_id',
'is_active',
];
protected $casts = [
'is_active' => 'boolean',
];
public function item(): BelongsTo
{
return $this->belongsTo(Item::class);
}
}

View File

@@ -0,0 +1,182 @@
<?php
namespace App\Services;
use App\Models\Orders\Order;
use App\Models\Production\BendingItemMapping;
class BendingCodeService extends Service
{
// =========================================================================
// 제품 코드 (7종)
// =========================================================================
public const PRODUCTS = [
['code' => '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;
}
}

View File

@@ -0,0 +1,30 @@
<?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::create('bending_item_mappings', function (Blueprint $table) {
$table->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');
}
};

View File

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