feat: [작업지시/작업자화면] items.item 관계 로드, 부서 필터 개선, BD 재질 자동 매칭, 전개도 폭 매칭

This commit is contained in:
김보곤
2026-03-21 07:59:53 +09:00
parent 41177f8f6c
commit da1ea6d3b4
3 changed files with 189 additions and 31 deletions

View File

@@ -1,6 +1,6 @@
# 논리적 데이터베이스 관계 문서
> **자동 생성**: 2026-03-20 11:23:51
> **자동 생성**: 2026-03-20 16:30:28
> **소스**: Eloquent 모델 관계 분석
## 📊 모델별 관계 현황

View File

@@ -2,8 +2,7 @@
namespace App\Services;
use App\Models\BendingItem;
use App\Models\Orders\Order;
use App\Models\Items\Item;
class BendingCodeService extends Service
{
@@ -128,28 +127,19 @@ public function getCodeMap(): array
}
/**
* 드롭다운 선택 조합 → bending_items 품목 매핑 조회
* 드롭다운 선택 조합 → 품목(items) 매핑 조회
*
* legacy_code 패턴: BD-{prod}{spec}-{length} (예: BD-CP-30)
* 품목코드 패턴: BD-{prod}{spec}-{length} (예: BD-RC-24)
*/
public function resolveItem(string $prodCode, string $specCode, string $lengthCode): ?array
{
// 1차: code + length_code로 조회 (신규 LOT 체계)
$item = BendingItem::where('tenant_id', $this->tenantId())
->where('code', 'like', "{$prodCode}{$specCode}%")
->where('length_code', $lengthCode)
$itemCode = "BD-{$prodCode}{$specCode}-{$lengthCode}";
$item = Item::where('tenant_id', $this->tenantId())
->where('code', $itemCode)
->where('is_active', true)
->first();
// 2차: legacy_code 폴백
if (! $item) {
$legacyCode = "BD-{$prodCode}{$specCode}-{$lengthCode}";
$item = BendingItem::where('tenant_id', $this->tenantId())
->where('legacy_code', $legacyCode)
->where('is_active', true)
->first();
}
if (! $item) {
return null;
}
@@ -157,9 +147,9 @@ public function resolveItem(string $prodCode, string $specCode, string $lengthCo
return [
'item_id' => $item->id,
'item_code' => $item->code,
'item_name' => $item->item_name,
'specification' => $item->item_spec,
'unit' => 'EA',
'item_name' => $item->name,
'specification' => $item->getOption('item_spec'),
'unit' => $item->unit ?? 'EA',
];
}
@@ -202,4 +192,87 @@ public static function getMaterial(string $prodCode, string $specCode): ?string
{
return self::MATERIAL_MAP["{$prodCode}:{$specCode}"] ?? null;
}
/**
* 품목 코드(BD-XX-YY) → 매칭되는 bending_item의 전개 폭(width_sum) 반환
*
* 매칭 로직:
* BD-{prod}{spec}-{length} 파싱
* → PRODUCTS/SPECS에서 item_bending, item_sep, 키워드 추출
* → bending_items 검색 → bending_data 마지막 sum = 전개 폭
*/
public function getBendingWidthByItemCode(string $itemCode): ?float
{
if (! preg_match('/^BD-([A-Z])([A-Z])-(\d+)$/', $itemCode, $m)) {
return null;
}
$prodCode = $m[1];
$specCode = $m[2];
// 제품명 → item_bending 추출 (가이드레일(벽면형) → 가이드레일)
$productName = null;
foreach (self::PRODUCTS as $p) {
if ($p['code'] === $prodCode) {
$productName = $p['name'];
break;
}
}
if (! $productName) {
return null;
}
// 종류명 추출
$specName = null;
foreach (self::SPECS as $s) {
if ($s['code'] === $specCode && in_array($prodCode, $s['products'])) {
$specName = $s['name'];
break;
}
}
if (! $specName) {
return null;
}
// item_bending: 괄호 제거 (가이드레일(벽면형) → 가이드레일)
$itemBending = preg_replace('/\(.*\)/', '', $productName);
// item_sep 판단: 종류명 또는 제품명에 '철재' → 철재, 아니면 스크린
$itemSep = (str_contains($specName, '철재') || str_contains($productName, '철재'))
? '철재' : '스크린';
// bending_items 검색
$query = \App\Models\BendingItem::query()
->where('tenant_id', $this->tenantId())
->where('item_bending', $itemBending)
->where('item_sep', $itemSep)
->whereNotNull('bending_data');
// 가이드레일: 벽면형/측면형 구분 (item_name 키워드 매칭)
if (str_contains($productName, '벽면형')) {
$query->where('item_name', 'LIKE', '%벽면형%');
} elseif (str_contains($productName, '측면형')) {
$query->where('item_name', 'LIKE', '%측면형%');
}
// 종류 키워드 매칭 (본체, C형, D형, 전면, 점검구, 린텔 등)
$specKeyword = preg_replace('/\(.*\)/', '', $specName); // 본체(철재) → 본체
$query->where('item_name', 'LIKE', "%{$specKeyword}%");
// 최신 코드 우선
$bendingItem = $query->orderByDesc('code')->first();
if (! $bendingItem) {
return null;
}
// bending_data 마지막 항목의 sum = 전개 폭
$data = $bendingItem->bending_data;
if (empty($data)) {
return null;
}
$last = end($data);
return isset($last['sum']) ? (float) $last['sum'] : null;
}
}

View File

@@ -3,7 +3,6 @@
namespace App\Services;
use App\Models\Documents\Document;
use Illuminate\Support\Facades\Storage;
use App\Models\Documents\DocumentTemplate;
use App\Models\Orders\Order;
use App\Models\Process;
@@ -18,6 +17,7 @@
use App\Models\Tenants\StockTransaction;
use App\Services\Audit\AuditLogger;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@@ -62,6 +62,7 @@ public function index(array $params)
'salesOrder.client:id,name',
'process:id,process_name,process_code,department,options',
'items:id,work_order_id,item_id,item_name,specification,quantity,unit,status,options,sort_order,source_order_item_id',
'items.item:id,code',
'items.sourceOrderItem:id,order_node_id,floor_code,symbol_code',
'items.sourceOrderItem.node:id,name,code',
'items.materialInputs:id,work_order_id,work_order_item_id,stock_lot_id,item_id,qty,input_by,input_at',
@@ -123,14 +124,21 @@ public function index(array $params)
->orWhereHas('assignees', fn ($aq) => $aq->where('user_id', $userId));
});
} else {
// 2차: 사용자 소속 부서의 작업지시 필터
// 2차: 사용자 소속 부서 + 상위 부서의 작업지시 필터
$departmentIds = DB::table('department_user')
->where('user_id', $userId)
->where('tenant_id', $tenantId)
->pluck('department_id');
if ($departmentIds->isNotEmpty()) {
$query->whereIn('team_id', $departmentIds);
// 소속 부서의 상위 부서도 포함 (부서 계층 지원)
$parentIds = DB::table('departments')
->whereIn('id', $departmentIds)
->whereNotNull('parent_id')
->pluck('parent_id');
$allDeptIds = $departmentIds->merge($parentIds)->unique();
$query->whereIn('team_id', $allDeptIds);
}
// 3차: 부서도 없으면 필터 없이 전체 노출
}
@@ -151,7 +159,37 @@ public function index(array $params)
$query->orderByDesc('created_at');
return $query->paginate($size, ['*'], 'page', $page);
$result = $query->paginate($size, ['*'], 'page', $page);
// 작업자 화면: BENDING 카테고리 품목에 전개도 폭(bending_width) 추가
if ($workerScreen) {
$this->appendBendingWidths($result);
}
return $result;
}
/**
* BENDING 카테고리 품목에 전개도 폭 추가
*/
private function appendBendingWidths($paginator): void
{
$bendingService = app(BendingCodeService::class);
foreach ($paginator->items() as $workOrder) {
foreach ($workOrder->items as $item) {
$itemCode = $item->item?->code;
if (! $itemCode || ! str_starts_with($itemCode, 'BD-')) {
continue;
}
$width = $bendingService->getBendingWidthByItemCode($itemCode);
if ($width !== null) {
$options = $item->options ?? [];
$options['bending_width'] = $width;
$item->setAttribute('options', $options);
}
}
}
}
/**
@@ -215,6 +253,7 @@ public function show(int $id)
'salesOrder.writer:id,name',
'process:id,process_name,process_code,work_steps,department,options',
'process.steps' => fn ($q) => $q->where('is_active', true)->orderBy('sort_order'),
'items.item:id,code',
'items.sourceOrderItem:id,order_node_id,floor_code,symbol_code',
'items.sourceOrderItem.node:id,name,code',
'items.materialInputs:id,work_order_id,work_order_item_id,stock_lot_id,item_id,qty,input_by,input_at',
@@ -3806,13 +3845,59 @@ public function getMaterialsForItem(int $workOrderId, int $itemId): array
}
}
// BOM이 없으면 품목 자체를 자재로 사용
// BOM이 없으면 BD 품목의 재질 정보로 원자재 자동 매칭
if (empty($materialItems) && $woItem->item_id && $woItem->item) {
$materialItems[] = [
'item' => $woItem->item,
'bom_qty' => 1,
'required_qty' => $woItem->quantity ?? 1,
];
$itemOptions = $woItem->item->options ?? [];
$material = $itemOptions['material'] ?? null;
$matchedRawItems = [];
if ($material && preg_match('/^(\w+)\s*(\d+\.?\d*)/i', $material, $matMatch)) {
$matType = $matMatch[1];
$matThickness = (float) $matMatch[2];
// 품목명에서 제품 길이 추출 (예: 1750mm)
$productLength = 0;
if (preg_match('/(\d{3,5})mm/', $woItem->item->name, $lenMatch)) {
$productLength = (int) $lenMatch[1];
}
// 원자재 검색: material_type + thickness 매칭, length >= 제품길이
$rawItems = \App\Models\Items\Item::where('tenant_id', $tenantId)
->where('item_type', 'RM')
->whereRaw("JSON_UNQUOTE(JSON_EXTRACT(options, '$.attributes.material_type')) = ?", [$matType])
->whereRaw('CAST(JSON_EXTRACT(options, \'$.attributes.thickness\') AS DECIMAL(10,2)) = ?', [$matThickness])
->get();
foreach ($rawItems as $rawItem) {
$rawAttrs = $rawItem->options['attributes'] ?? [];
$rawLength = $rawAttrs['length'] ?? null;
// 길이 조건: 원자재 길이 >= 제품 길이 (길이 미정이면 통과)
if ($rawLength !== null && $productLength > 0 && $rawLength < $productLength) {
continue;
}
$matchedRawItems[] = $rawItem;
}
}
if (! empty($matchedRawItems)) {
// 매칭된 원자재를 자재 목록으로 추가
foreach ($matchedRawItems as $rawItem) {
$materialItems[] = [
'item' => $rawItem,
'bom_qty' => 1,
'required_qty' => $woItem->quantity ?? 1,
];
}
} else {
// 매칭 실패 시 기존 동작 유지 (품목 자체를 자재로 표시)
$materialItems[] = [
'item' => $woItem->item,
'bom_qty' => 1,
'required_qty' => $woItem->quantity ?? 1,
];
}
}
// 이미 투입된 수량 조회 (item_id별 SUM)