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:
79
app/Http/Controllers/Api/V1/BendingController.php
Normal file
79
app/Http/Controllers/Api/V1/BendingController.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
33
app/Models/Production/BendingItemMapping.php
Normal file
33
app/Models/Production/BendingItemMapping.php
Normal 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);
|
||||
}
|
||||
}
|
||||
182
app/Services/BendingCodeService.php
Normal file
182
app/Services/BendingCodeService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user