feat: [생산지시] 전용 API + 자재투입/공정 개선

- ProductionOrder 전용 엔드포인트 (목록/통계/상세)
- 재고생산 보조공정 일반 워크플로우에서 분리
- 자재투입 replace 모드 + bom_group_key 개별 저장
- 공정단계 options 컬럼 추가 (검사 설정/범위)
- 셔터박스 prefix isStandard 파라미터 제거

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-07 02:57:59 +09:00
parent f9cd219f67
commit 4dd38ab14d
17 changed files with 1335 additions and 101 deletions

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\ProductionOrder\ProductionOrderIndexRequest;
use App\Services\ProductionOrderService;
use Illuminate\Http\JsonResponse;
class ProductionOrderController extends Controller
{
public function __construct(
private readonly ProductionOrderService $service
) {}
/**
* 생산지시 목록 조회
*/
public function index(ProductionOrderIndexRequest $request): JsonResponse
{
$result = $this->service->index($request->validated());
return ApiResponse::success($result, __('message.fetched'));
}
/**
* 생산지시 상태별 통계
*/
public function stats(): JsonResponse
{
$stats = $this->service->stats();
return ApiResponse::success($stats, __('message.fetched'));
}
/**
* 생산지시 상세 조회
*/
public function show(int $orderId): JsonResponse
{
try {
$detail = $this->service->show($orderId);
return ApiResponse::success($detail, __('message.fetched'));
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
return ApiResponse::error(__('error.order.not_found'), 404);
}
}
}

View File

@@ -230,6 +230,16 @@ public function inspectionReport(int $id)
}, __('message.work_order.fetched')); }, __('message.work_order.fetched'));
} }
/**
* 작업지시 검사 설정 조회 (공정 자동 판별 + 구성품 목록)
*/
public function inspectionConfig(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->service->getInspectionConfig($id);
}, __('message.work_order.fetched'));
}
/** /**
* 작업지시의 검사용 문서 템플릿 조회 * 작업지시의 검사용 문서 템플릿 조회
*/ */
@@ -310,7 +320,14 @@ public function materialsForItem(int $id, int $itemId)
public function registerMaterialInputForItem(MaterialInputForItemRequest $request, int $id, int $itemId) public function registerMaterialInputForItem(MaterialInputForItemRequest $request, int $id, int $itemId)
{ {
return ApiResponse::handle(function () use ($request, $id, $itemId) { return ApiResponse::handle(function () use ($request, $id, $itemId) {
return $this->service->registerMaterialInputForItem($id, $itemId, $request->validated()['inputs']); $validated = $request->validated();
return $this->service->registerMaterialInputForItem(
$id,
$itemId,
$validated['inputs'],
(bool) ($validated['replace'] ?? false)
);
}, __('message.work_order.material_input_registered')); }, __('message.work_order.material_input_registered'));
} }

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Http\Requests\ProductionOrder;
use Illuminate\Foundation\Http\FormRequest;
class ProductionOrderIndexRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'search' => 'nullable|string|max:100',
'production_status' => 'nullable|in:waiting,in_production,completed',
'sort_by' => 'nullable|in:created_at,delivery_date,order_no',
'sort_dir' => 'nullable|in:asc,desc',
'page' => 'nullable|integer|min:1',
'per_page' => 'nullable|integer|min:1|max:100',
];
}
}

View File

@@ -22,6 +22,12 @@ public function rules(): array
'connection_type' => ['nullable', 'string', 'max:20'], 'connection_type' => ['nullable', 'string', 'max:20'],
'connection_target' => ['nullable', 'string', 'max:255'], 'connection_target' => ['nullable', 'string', 'max:255'],
'completion_type' => ['nullable', 'string', 'in:click_complete,selection_complete,inspection_complete'], 'completion_type' => ['nullable', 'string', 'in:click_complete,selection_complete,inspection_complete'],
'options' => ['nullable', 'array'],
'options.inspection_setting' => ['nullable', 'array'],
'options.inspection_scope' => ['nullable', 'array'],
'options.inspection_scope.type' => ['nullable', 'string', 'in:all,sampling,group'],
'options.inspection_scope.sample_size' => ['nullable', 'integer', 'min:1'],
'options.inspection_scope.sample_base' => ['nullable', 'string', 'in:order,lot'],
]; ];
} }
@@ -36,6 +42,12 @@ public function attributes(): array
'connection_type' => '연결유형', 'connection_type' => '연결유형',
'connection_target' => '연결대상', 'connection_target' => '연결대상',
'completion_type' => '완료유형', 'completion_type' => '완료유형',
'options' => '옵션',
'options.inspection_setting' => '검사설정',
'options.inspection_scope' => '검사범위',
'options.inspection_scope.type' => '검사범위 유형',
'options.inspection_scope.sample_size' => '샘플 크기',
'options.inspection_scope.sample_base' => '샘플 기준',
]; ];
} }
} }

View File

@@ -22,6 +22,12 @@ public function rules(): array
'connection_type' => ['nullable', 'string', 'max:20'], 'connection_type' => ['nullable', 'string', 'max:20'],
'connection_target' => ['nullable', 'string', 'max:255'], 'connection_target' => ['nullable', 'string', 'max:255'],
'completion_type' => ['nullable', 'string', 'in:click_complete,selection_complete,inspection_complete'], 'completion_type' => ['nullable', 'string', 'in:click_complete,selection_complete,inspection_complete'],
'options' => ['nullable', 'array'],
'options.inspection_setting' => ['nullable', 'array'],
'options.inspection_scope' => ['nullable', 'array'],
'options.inspection_scope.type' => ['nullable', 'string', 'in:all,sampling,group'],
'options.inspection_scope.sample_size' => ['nullable', 'integer', 'min:1'],
'options.inspection_scope.sample_base' => ['nullable', 'string', 'in:order,lot'],
]; ];
} }
@@ -36,6 +42,12 @@ public function attributes(): array
'connection_type' => '연결유형', 'connection_type' => '연결유형',
'connection_target' => '연결대상', 'connection_target' => '연결대상',
'completion_type' => '완료유형', 'completion_type' => '완료유형',
'options' => '옵션',
'options.inspection_setting' => '검사설정',
'options.inspection_scope' => '검사범위',
'options.inspection_scope.type' => '검사범위 유형',
'options.inspection_scope.sample_size' => '샘플 크기',
'options.inspection_scope.sample_base' => '샘플 기준',
]; ];
} }
} }

View File

@@ -17,6 +17,8 @@ public function rules(): array
'inputs' => 'required|array|min:1', 'inputs' => 'required|array|min:1',
'inputs.*.stock_lot_id' => 'required|integer', 'inputs.*.stock_lot_id' => 'required|integer',
'inputs.*.qty' => 'required|numeric|gt:0', 'inputs.*.qty' => 'required|numeric|gt:0',
'inputs.*.bom_group_key' => 'sometimes|nullable|string|max:100',
'replace' => 'sometimes|boolean',
]; ];
} }

View File

@@ -22,6 +22,7 @@ class ProcessStep extends Model
'connection_type', 'connection_type',
'connection_target', 'connection_target',
'completion_type', 'completion_type',
'options',
]; ];
protected $casts = [ protected $casts = [
@@ -30,6 +31,7 @@ class ProcessStep extends Model
'needs_inspection' => 'boolean', 'needs_inspection' => 'boolean',
'is_active' => 'boolean', 'is_active' => 'boolean',
'sort_order' => 'integer', 'sort_order' => 'integer',
'options' => 'array',
]; ];
/** /**

View File

@@ -6,6 +6,7 @@
use App\Models\Members\User; use App\Models\Members\User;
use App\Models\Orders\Order; use App\Models\Orders\Order;
use App\Models\Process; use App\Models\Process;
use App\Models\Qualitys\Inspection;
use App\Models\Tenants\Department; use App\Models\Tenants\Department;
use App\Models\Tenants\Shipment; use App\Models\Tenants\Shipment;
use App\Traits\Auditable; use App\Traits\Auditable;
@@ -234,6 +235,14 @@ public function shipments(): HasMany
return $this->hasMany(Shipment::class); return $this->hasMany(Shipment::class);
} }
/**
* 품질검사 (IQC/PQC/FQC)
*/
public function inspections(): HasMany
{
return $this->hasMany(Inspection::class);
}
/** /**
* 생성자 * 생성자
*/ */

View File

@@ -27,6 +27,7 @@ class WorkOrderMaterialInput extends Model
'work_order_item_id', 'work_order_item_id',
'stock_lot_id', 'stock_lot_id',
'item_id', 'item_id',
'bom_group_key',
'qty', 'qty',
'input_by', 'input_by',
'input_at', 'input_at',

View File

@@ -218,17 +218,19 @@ public function buildDynamicBomForItem(array $context, int $width, int $height,
// ─── 3. 셔터박스 세부품목 ─── // ─── 3. 셔터박스 세부품목 ───
if ($boxSize) { if ($boxSize) {
$isStandard = $boxSize === '500*380';
$dist = $this->shutterBoxDistribution($width); $dist = $this->shutterBoxDistribution($width);
$shutterPartTypes = ['front', 'lintel', 'inspection', 'rear_corner', 'top_cover', 'fin_cover']; // 상부덮개(top_cover), 마구리(fin_cover)는 1219mm 기준으로 별도 생성 (아래 256행~)
$shutterPartTypes = ['front', 'lintel', 'inspection', 'rear_corner'];
foreach ($dist as $length => $count) { // 작업일지와 동일한 순서: 파트 → 길이
$totalCount = $count * $qty; foreach ($shutterPartTypes as $partType) {
if ($totalCount <= 0) { foreach ($dist as $length => $count) {
continue; $totalCount = $count * $qty;
} if ($totalCount <= 0) {
foreach ($shutterPartTypes as $partType) { continue;
$prefix = $resolver->resolveShutterBoxPrefix($partType, $isStandard); }
$prefix = $resolver->resolveShutterBoxPrefix($partType);
$itemCode = $resolver->buildItemCode($prefix, $length); $itemCode = $resolver->buildItemCode($prefix, $length);
if (! $itemCode) { if (! $itemCode) {
continue; continue;
@@ -256,7 +258,7 @@ public function buildDynamicBomForItem(array $context, int $width, int $height,
// 상부덮개 수량: ceil(width / 1219) × qty (1219mm 단위) // 상부덮개 수량: ceil(width / 1219) × qty (1219mm 단위)
$coverQty = (int) ceil($width / 1219) * $qty; $coverQty = (int) ceil($width / 1219) * $qty;
if ($coverQty > 0) { if ($coverQty > 0) {
$coverPrefix = $resolver->resolveShutterBoxPrefix('top_cover', $isStandard); $coverPrefix = $resolver->resolveShutterBoxPrefix('top_cover');
$coverCode = $resolver->buildItemCode($coverPrefix, 1219); $coverCode = $resolver->buildItemCode($coverPrefix, 1219);
if ($coverCode) { if ($coverCode) {
$coverId = $resolver->resolveItemId($coverCode, $tenantId); $coverId = $resolver->resolveItemId($coverCode, $tenantId);
@@ -278,7 +280,7 @@ public function buildDynamicBomForItem(array $context, int $width, int $height,
// 마구리 수량: qty × 2 // 마구리 수량: qty × 2
$finQty = $qty * 2; $finQty = $qty * 2;
if ($finQty > 0) { if ($finQty > 0) {
$finPrefix = $resolver->resolveShutterBoxPrefix('fin_cover', $isStandard); $finPrefix = $resolver->resolveShutterBoxPrefix('fin_cover');
// 마구리는 박스 높이 기준이므로 셔터박스 최소 단위 사용 // 마구리는 박스 높이 기준이므로 셔터박스 최소 단위 사용
$finCode = $resolver->buildItemCode($finPrefix, 1219); $finCode = $resolver->buildItemCode($finPrefix, 1219);
if ($finCode) { if ($finCode) {

View File

@@ -189,16 +189,14 @@ public function resolveBottomBarPrefix(string $partType, string $productCode, st
/** /**
* 셔터박스 세부품목의 prefix 결정 * 셔터박스 세부품목의 prefix 결정
* *
* CF/CL/CP/CB 품목은 모든 길이에 등록되어 있으므로 boxSize 무관하게 적용.
* top_cover, fin_cover는 전용 품목 없이 XX(하부BASE/상부/마구리) 공용.
*
* @param string $partType 'front', 'lintel', 'inspection', 'rear_corner', 'top_cover', 'fin_cover' * @param string $partType 'front', 'lintel', 'inspection', 'rear_corner', 'top_cover', 'fin_cover'
* @param bool $isStandardSize 500*380인지
* @return string prefix * @return string prefix
*/ */
public function resolveShutterBoxPrefix(string $partType, bool $isStandardSize): string public function resolveShutterBoxPrefix(string $partType): string
{ {
if (! $isStandardSize) {
return 'XX';
}
return self::SHUTTER_STANDARD[$partType] ?? 'XX'; return self::SHUTTER_STANDARD[$partType] ?? 'XX';
} }

View File

@@ -0,0 +1,285 @@
<?php
namespace App\Services;
use App\Models\Orders\Order;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
class ProductionOrderService extends Service
{
/**
* 생산지시 대상 상태 코드
*/
private const PRODUCTION_STATUSES = [
Order::STATUS_IN_PROGRESS,
Order::STATUS_IN_PRODUCTION,
Order::STATUS_PRODUCED,
Order::STATUS_SHIPPING,
Order::STATUS_SHIPPED,
];
/**
* 생산지시 목록 조회
*/
public function index(array $params): LengthAwarePaginator
{
$tenantId = $this->tenantId();
$query = Order::query()
->where('tenant_id', $tenantId)
->whereIn('status_code', self::PRODUCTION_STATUSES)
->with(['client', 'workOrders.process', 'workOrders.assignees.user'])
->withCount([
'workOrders' => fn ($q) => $q->whereNotNull('process_id')
->where(fn ($q2) => $q2->whereNull('options->is_auxiliary')
->orWhere('options->is_auxiliary', false)),
'nodes',
]);
// 검색어 필터
if (! empty($params['search'])) {
$search = $params['search'];
$query->where(function ($q) use ($search) {
$q->where('order_no', 'like', "%{$search}%")
->orWhere('client_name', 'like', "%{$search}%")
->orWhere('site_name', 'like', "%{$search}%");
});
}
// 생산 상태 필터
if (! empty($params['production_status'])) {
switch ($params['production_status']) {
case 'waiting':
$query->where('status_code', Order::STATUS_IN_PROGRESS);
break;
case 'in_production':
$query->where('status_code', Order::STATUS_IN_PRODUCTION);
break;
case 'completed':
$query->whereIn('status_code', [
Order::STATUS_PRODUCED,
Order::STATUS_SHIPPING,
Order::STATUS_SHIPPED,
]);
break;
}
}
// 정렬
$sortBy = $params['sort_by'] ?? 'created_at';
$sortDir = $params['sort_dir'] ?? 'desc';
$query->orderBy($sortBy, $sortDir);
// 페이지네이션
$perPage = $params['per_page'] ?? 20;
$result = $query->paginate($perPage);
// 가공 필드 추가
$result->getCollection()->transform(function (Order $order) {
$minCreatedAt = $order->workOrders->min('created_at');
$order->production_ordered_at = $minCreatedAt
? $minCreatedAt->format('Y-m-d')
: null;
// 개소수 (order_nodes 수)
$order->node_count = $order->nodes_count ?? 0;
// 주요 생산 공정 WO만 (구매품 + 보조 공정 제외)
$productionWOs = $this->filterMainProductionWOs($order->workOrders);
$order->work_order_progress = [
'total' => $productionWOs->count(),
'completed' => $productionWOs->where('status', 'completed')->count()
+ $productionWOs->where('status', 'shipped')->count(),
'in_progress' => $productionWOs->where('status', 'in_progress')->count(),
];
// 프론트 탭용 production_status 매핑
$order->production_status = $this->mapProductionStatus($order->status_code);
return $order;
});
return $result;
}
/**
* 상태별 통계
*/
public function stats(): array
{
$tenantId = $this->tenantId();
$waiting = Order::where('tenant_id', $tenantId)
->where('status_code', Order::STATUS_IN_PROGRESS)
->count();
$inProduction = Order::where('tenant_id', $tenantId)
->where('status_code', Order::STATUS_IN_PRODUCTION)
->count();
$completed = Order::where('tenant_id', $tenantId)
->whereIn('status_code', [
Order::STATUS_PRODUCED,
Order::STATUS_SHIPPING,
Order::STATUS_SHIPPED,
])
->count();
return [
'total' => $waiting + $inProduction + $completed,
'waiting' => $waiting,
'in_production' => $inProduction,
'completed' => $completed,
];
}
/**
* 생산지시 상세 조회
*/
public function show(int $orderId): array
{
$tenantId = $this->tenantId();
$order = Order::query()
->where('tenant_id', $tenantId)
->whereIn('status_code', self::PRODUCTION_STATUSES)
->with([
'client',
'workOrders.process',
'workOrders.items',
'workOrders.assignees.user',
'nodes',
])
->withCount('nodes')
->findOrFail($orderId);
// 생산지시일 (날짜만)
$minCreatedAt = $order->workOrders->min('created_at');
$order->production_ordered_at = $minCreatedAt
? $minCreatedAt->format('Y-m-d')
: null;
$order->production_status = $this->mapProductionStatus($order->status_code);
// 주요 생산 공정 WO만 필터 (구매품 + 보조 공정 제외)
$productionWorkOrders = $this->filterMainProductionWOs($order->workOrders);
// WorkOrder 진행 현황 (생산 공정 기준)
$workOrderProgress = [
'total' => $productionWorkOrders->count(),
'completed' => $productionWorkOrders->where('status', 'completed')->count()
+ $productionWorkOrders->where('status', 'shipped')->count(),
'in_progress' => $productionWorkOrders->where('status', 'in_progress')->count(),
];
// WorkOrder 목록 가공 (생산 공정만)
$workOrders = $productionWorkOrders->values()->map(function ($wo) {
return [
'id' => $wo->id,
'work_order_no' => $wo->work_order_no,
'process_name' => $wo->process?->process_name ?? '',
'quantity' => $wo->items->count(),
'status' => $wo->status,
'assignees' => $wo->assignees->map(fn ($a) => $a->user?->name ?? '')->filter()->values()->toArray(),
];
});
// BOM 데이터 (order_nodes에서 추출)
$bomProcessGroups = $this->extractBomProcessGroups($order->nodes);
return [
'order' => $order->makeHidden(['workOrders', 'nodes']),
'production_ordered_at' => $order->production_ordered_at,
'production_status' => $order->production_status,
'node_count' => $order->nodes_count ?? 0,
'work_order_progress' => $workOrderProgress,
'work_orders' => $workOrders,
'bom_process_groups' => $bomProcessGroups,
];
}
/**
* Order status_code → 프론트 production_status 매핑
*/
private function mapProductionStatus(string $statusCode): string
{
return match ($statusCode) {
Order::STATUS_IN_PROGRESS => 'waiting',
Order::STATUS_IN_PRODUCTION => 'in_production',
default => 'completed',
};
}
/**
* order_nodes에서 BOM 공정 분류 추출
*
* bom_result 구조: { items: [...], success, subtotals, ... }
* 각 item: { item_id, item_code, item_name, process_group, specification, quantity, unit, ... }
*/
private function extractBomProcessGroups($nodes): array
{
$groups = [];
foreach ($nodes as $node) {
$bomResult = $node->options['bom_result'] ?? null;
if (! $bomResult || ! is_array($bomResult)) {
continue;
}
// bom_result.items 배열에서 추출
$items = $bomResult['items'] ?? [];
if (! is_array($items)) {
continue;
}
$nodeName = $node->name ?? '';
foreach ($items as $item) {
if (! is_array($item)) {
continue;
}
$processGroup = $item['process_group'] ?? $item['category_group'] ?? '기타';
if (! isset($groups[$processGroup])) {
$groups[$processGroup] = [
'process_name' => $processGroup,
'items' => [],
];
}
$groups[$processGroup]['items'][] = [
'id' => $item['item_id'] ?? null,
'item_code' => $item['item_code'] ?? '',
'item_name' => $item['item_name'] ?? '',
'spec' => $item['specification'] ?? '',
'unit' => $item['unit'] ?? '',
'quantity' => $item['quantity'] ?? 0,
'unit_price' => $item['unit_price'] ?? 0,
'total_price' => $item['total_price'] ?? 0,
'node_name' => $nodeName,
];
}
}
return array_values($groups);
}
/**
* 주요 생산 공정 WO만 필터 (구매품/서비스 + 보조 공정 제외)
*
* 제외 대상:
* - process_id가 null인 WO (구매품/서비스)
* - options.is_auxiliary가 true인 WO (재고생산 등 보조 공정)
*/
private function filterMainProductionWOs($workOrders): \Illuminate\Support\Collection
{
return $workOrders->filter(function ($wo) {
if (empty($wo->process_id)) {
return false;
}
$options = is_array($wo->options) ? $wo->options : (json_decode($wo->options, true) ?? []);
return empty($options['is_auxiliary']);
});
}
}

View File

@@ -5,6 +5,7 @@
use App\Models\Documents\Document; use App\Models\Documents\Document;
use App\Models\Documents\DocumentTemplate; use App\Models\Documents\DocumentTemplate;
use App\Models\Orders\Order; use App\Models\Orders\Order;
use App\Models\Process;
use App\Models\Production\WorkOrder; use App\Models\Production\WorkOrder;
use App\Models\Production\WorkOrderAssignee; use App\Models\Production\WorkOrderAssignee;
use App\Models\Production\WorkOrderBendingDetail; use App\Models\Production\WorkOrderBendingDetail;
@@ -258,6 +259,17 @@ public function store(array $data)
$salesOrderId = $data['sales_order_id'] ?? null; $salesOrderId = $data['sales_order_id'] ?? null;
unset($data['items'], $data['bending_detail']); unset($data['items'], $data['bending_detail']);
// 공정의 is_auxiliary 플래그를 WO options에 복사
if (! empty($data['process_id'])) {
$process = \App\Models\Process::find($data['process_id']);
if ($process && ! empty($process->options['is_auxiliary'])) {
$opts = $data['options'] ?? [];
$opts = is_array($opts) ? $opts : (json_decode($opts, true) ?? []);
$opts['is_auxiliary'] = true;
$data['options'] = $opts;
}
}
$workOrder = WorkOrder::create($data); $workOrder = WorkOrder::create($data);
// process 관계 로드 (isBending 체크용) // process 관계 로드 (isBending 체크용)
@@ -285,6 +297,8 @@ public function store(array $data)
$options = array_filter([ $options = array_filter([
'floor' => $orderItem->floor_code, 'floor' => $orderItem->floor_code,
'code' => $orderItem->symbol_code, 'code' => $orderItem->symbol_code,
'product_code' => ! empty($nodeOptions['product_code']) ? $nodeOptions['product_code'] : null,
'product_name' => ! empty($nodeOptions['product_name']) ? $nodeOptions['product_name'] : null,
'width' => $nodeOptions['width'] ?? $nodeOptions['open_width'] ?? null, 'width' => $nodeOptions['width'] ?? $nodeOptions['open_width'] ?? null,
'height' => $nodeOptions['height'] ?? $nodeOptions['open_height'] ?? null, 'height' => $nodeOptions['height'] ?? $nodeOptions['open_height'] ?? null,
'cutting_info' => $nodeOptions['cutting_info'] ?? null, 'cutting_info' => $nodeOptions['cutting_info'] ?? null,
@@ -812,6 +826,11 @@ private function syncOrderStatus(WorkOrder $workOrder, int $tenantId): void
return; return;
} }
// 보조 공정(재고생산 등)은 수주 상태에 영향 주지 않음
if ($this->isAuxiliaryWorkOrder($workOrder)) {
return;
}
$order = Order::where('tenant_id', $tenantId)->find($workOrder->sales_order_id); $order = Order::where('tenant_id', $tenantId)->find($workOrder->sales_order_id);
if (! $order) { if (! $order) {
return; return;
@@ -847,6 +866,47 @@ private function syncOrderStatus(WorkOrder $workOrder, int $tenantId): void
); );
} }
/**
* 자재 투입 시 작업지시가 대기 상태이면 자동으로 진행중으로 전환
*
* pending/waiting 상태에서 첫 자재 투입이 발생하면
* 작업지시 → in_progress, 수주 → IN_PRODUCTION 으로 자동 전환
*/
private function autoStartWorkOrderOnMaterialInput(WorkOrder $workOrder, int $tenantId): void
{
// 보조 공정(재고생산 등)은 WO 자체는 진행중으로 전환하되, 수주 상태는 변경하지 않음
$isAuxiliary = $this->isAuxiliaryWorkOrder($workOrder);
// 아직 진행 전인 상태에서만 자동 전환 (자재투입 = 실질적 작업 시작)
if (! in_array($workOrder->status, [
WorkOrder::STATUS_UNASSIGNED,
WorkOrder::STATUS_PENDING,
WorkOrder::STATUS_WAITING,
])) {
return;
}
$oldStatus = $workOrder->status;
$workOrder->status = WorkOrder::STATUS_IN_PROGRESS;
$workOrder->updated_by = $this->apiUserId();
$workOrder->save();
// 감사 로그
$this->auditLogger->log(
$tenantId,
self::AUDIT_TARGET,
$workOrder->id,
'status_auto_changed_on_material_input',
['status' => $oldStatus],
['status' => WorkOrder::STATUS_IN_PROGRESS]
);
// 보조 공정이 아닌 경우만 수주 상태 동기화
if (! $isAuxiliary) {
$this->syncOrderStatus($workOrder, $tenantId);
}
}
/** /**
* 작업지시 품목에 결과 데이터 저장 * 작업지시 품목에 결과 데이터 저장
*/ */
@@ -887,6 +947,16 @@ private function saveItemResults(WorkOrder $workOrder, ?array $resultData, int $
} }
} }
/**
* 보조 공정(재고생산 등) 여부 판단
*/
private function isAuxiliaryWorkOrder(WorkOrder $workOrder): bool
{
$options = is_array($workOrder->options) ? $workOrder->options : (json_decode($workOrder->options, true) ?? []);
return ! empty($options['is_auxiliary']);
}
/** /**
* LOT 번호 생성 * LOT 번호 생성
*/ */
@@ -1455,6 +1525,9 @@ public function registerMaterialInput(int $workOrderId, array $inputs): array
$totalCount = array_sum(array_column($delegatedResults, 'material_count')); $totalCount = array_sum(array_column($delegatedResults, 'material_count'));
$allResults = array_merge(...array_map(fn ($r) => $r['input_results'], $delegatedResults)); $allResults = array_merge(...array_map(fn ($r) => $r['input_results'], $delegatedResults));
// 자재 투입 시 작업지시가 대기 상태면 자동으로 진행중으로 전환
$this->autoStartWorkOrderOnMaterialInput($workOrder, $tenantId);
return [ return [
'work_order_id' => $workOrderId, 'work_order_id' => $workOrderId,
'material_count' => $totalCount, 'material_count' => $totalCount,
@@ -1533,6 +1606,9 @@ public function registerMaterialInput(int $workOrderId, array $inputs): array
$allResults = array_merge($allResults, $dr['input_results']); $allResults = array_merge($allResults, $dr['input_results']);
} }
// 자재 투입 시 작업지시가 대기 상태면 자동으로 진행중으로 전환
$this->autoStartWorkOrderOnMaterialInput($workOrder, $tenantId);
return [ return [
'work_order_id' => $workOrderId, 'work_order_id' => $workOrderId,
'material_count' => count($allResults), 'material_count' => count($allResults),
@@ -1834,25 +1910,25 @@ public function getMaterialInputLots(int $workOrderId): array
->orderBy('created_at') ->orderBy('created_at')
->get(['id', 'lot_no', 'item_code', 'item_name', 'qty', 'stock_lot_id', 'created_at']); ->get(['id', 'lot_no', 'item_code', 'item_name', 'qty', 'stock_lot_id', 'created_at']);
// LOT 번호별 그룹핑 (동일 LOT에서 여러번 투입 가능) // 품목코드별 그룹핑 (작업일지에서 item_code → lot_no 매핑에 사용)
$lotMap = []; $itemMap = [];
foreach ($transactions as $tx) { foreach ($transactions as $tx) {
$lotNo = $tx->lot_no; $itemCode = $tx->item_code;
if (! isset($lotMap[$lotNo])) { if (! isset($itemMap[$itemCode])) {
$lotMap[$lotNo] = [ $itemMap[$itemCode] = [
'lot_no' => $lotNo, 'item_code' => $itemCode,
'item_code' => $tx->item_code, 'lot_no' => $tx->lot_no,
'item_name' => $tx->item_name, 'item_name' => $tx->item_name,
'total_qty' => 0, 'total_qty' => 0,
'input_count' => 0, 'input_count' => 0,
'first_input_at' => $tx->created_at, 'first_input_at' => $tx->created_at,
]; ];
} }
$lotMap[$lotNo]['total_qty'] += abs((float) $tx->qty); $itemMap[$itemCode]['total_qty'] += abs((float) $tx->qty);
$lotMap[$lotNo]['input_count']++; $itemMap[$itemCode]['input_count']++;
} }
return array_values($lotMap); return array_values($itemMap);
} }
// ────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────
@@ -1887,6 +1963,16 @@ public function storeItemInspection(int $workOrderId, int $itemId, array $data):
$item->setInspectionData($inspectionData); $item->setInspectionData($inspectionData);
$item->save(); $item->save();
// 절곡 공정: 수주 단위 검사 → 동일 작업지시의 모든 item에 검사 데이터 복제
$processType = $data['process_type'] ?? '';
if (in_array($processType, ['bending', 'bending_wip'])) {
$otherItems = $workOrder->items()->where('id', '!=', $itemId)->get();
foreach ($otherItems as $otherItem) {
$otherItem->setInspectionData($inspectionData);
$otherItem->save();
}
}
// 감사 로그 // 감사 로그
$this->auditLogger->log( $this->auditLogger->log(
$tenantId, $tenantId,
@@ -1947,6 +2033,293 @@ public function getInspectionData(int $workOrderId, array $params = []): array
]; ];
} }
// ──────────────────────────────────────────────────────────────
// 검사 설정 (inspection-config)
// ──────────────────────────────────────────────────────────────
/**
* 절곡 검사 기준 간격 프로파일 (5130 레거시 기준 S1/S2/S3 마감유형별)
*
* S1: KSS01 계열 (KQTS01 포함)
* S2: KSS02 계열 (EGI 마감 포함)
* S3: KWE01/KSE01 + SUS 별도마감
*
* 향후 DB 테이블 또는 테넌트 설정으로 이관 가능
*/
private const BENDING_GAP_PROFILES = [
'S1' => [
'guide_rail_wall' => [
'name' => '가이드레일(벽면형)',
'gap_points' => [
['point' => '(1)', 'design_value' => '30'],
['point' => '(2)', 'design_value' => '80'],
['point' => '(3)', 'design_value' => '45'],
['point' => '(4)', 'design_value' => '40'],
],
],
'guide_rail_side' => [
'name' => '가이드레일(측면형)',
'gap_points' => [
['point' => '(1)', 'design_value' => '30'],
['point' => '(2)', 'design_value' => '70'],
['point' => '(3)', 'design_value' => '45'],
['point' => '(4)', 'design_value' => '35'],
['point' => '(5)', 'design_value' => '95'],
['point' => '(6)', 'design_value' => '90'],
],
],
'bottom_bar' => [
'name' => '하단마감재',
'gap_points' => [
['point' => '(1)', 'design_value' => '60'],
],
],
],
'S2' => [
'guide_rail_wall' => [
'name' => '가이드레일(벽면형)',
'gap_points' => [
['point' => '(1)', 'design_value' => '30'],
['point' => '(2)', 'design_value' => '80'],
['point' => '(3)', 'design_value' => '45'],
],
],
'guide_rail_side' => [
'name' => '가이드레일(측면형)',
'gap_points' => [
['point' => '(1)', 'design_value' => '30'],
['point' => '(2)', 'design_value' => '70'],
['point' => '(3)', 'design_value' => '45'],
['point' => '(4)', 'design_value' => '35'],
['point' => '(5)', 'design_value' => '95'],
],
],
'bottom_bar' => [
'name' => '하단마감재',
'gap_points' => [
['point' => '(1)', 'design_value' => '60'],
],
],
],
'S3' => [
'guide_rail_wall' => [
'name' => '가이드레일(벽면형·별도마감)',
'gap_points' => [
['point' => '(1)', 'design_value' => '30'],
['point' => '(2)', 'design_value' => '80'],
['point' => '(3)', 'design_value' => '45'],
['point' => '(4)', 'design_value' => '40'],
['point' => '(5)', 'design_value' => '34'],
],
],
'guide_rail_side' => [
'name' => '가이드레일(측면형·별도마감)',
'gap_points' => [
['point' => '(1)', 'design_value' => '30'],
['point' => '(2)', 'design_value' => '70'],
['point' => '(3)', 'design_value' => '80'],
['point' => '(4)', 'design_value' => '45'],
['point' => '(5)', 'design_value' => '40'],
['point' => '(6)', 'design_value' => '34'],
['point' => '(7)', 'design_value' => '74'],
],
],
'bottom_bar' => [
'name' => '하단마감재(별도마감)',
'gap_points' => [
['point' => '(1)', 'design_value' => '60'],
['point' => '(2)', 'design_value' => '64'],
],
],
],
'common' => [
'case_box' => [
'name' => '케이스',
'gap_points' => [
['point' => '(1)', 'design_value' => '550'],
['point' => '(2)', 'design_value' => '50'],
['point' => '(3)', 'design_value' => '385'],
['point' => '(4)', 'design_value' => '50'],
['point' => '(5)', 'design_value' => '410'],
],
],
'smoke_w50' => [
'name' => '연기차단재 W50',
'gap_points' => [
['point' => '(1)', 'design_value' => '50'],
['point' => '(2)', 'design_value' => '12'],
],
],
'smoke_w80' => [
'name' => '연기차단재 W80',
'gap_points' => [
['point' => '(1)', 'design_value' => '80'],
['point' => '(2)', 'design_value' => '12'],
],
],
],
];
/**
* 작업지시의 검사 설정 조회 (공정 자동 판별 + 구성품 목록)
*
* 절곡 공정: bending_info 기반으로 검사 대상 구성품 + 간격 기준치 반환
* 기타 공정: items 빈 배열 (스크린/슬랫은 별도 구성품 없음)
*/
public function getInspectionConfig(int $workOrderId): array
{
$workOrder = WorkOrder::where('tenant_id', $this->tenantId())
->with(['process', 'items' => fn ($q) => $q->orderBy('sort_order')])
->findOrFail($workOrderId);
$process = $workOrder->process;
$processType = $this->resolveInspectionProcessType($process);
$firstItem = $workOrder->items->first();
$productCode = $firstItem?->options['product_code'] ?? null;
$templateId = $process?->document_template_id;
$items = [];
$finishingType = null;
if ($processType === 'bending') {
$finishingType = $this->resolveFinishingType($productCode);
$items = $this->buildBendingInspectionItems($firstItem);
}
return [
'work_order_id' => $workOrder->id,
'process_type' => $processType,
'product_code' => $productCode,
'finishing_type' => $finishingType,
'template_id' => $templateId,
'items' => $items,
];
}
/**
* 공정명 → 검사 공정 타입 변환
*/
private function resolveInspectionProcessType(?Process $process): string
{
if (! $process) {
return 'unknown';
}
return match ($process->process_name) {
'스크린' => 'screen',
'슬랫' => 'slat',
'절곡' => 'bending',
default => strtolower($process->process_code ?? 'unknown'),
};
}
/**
* 제품코드에서 마감유형(S1/S2/S3) 결정 (5130 레거시 기준)
*
* KSS01, KQTS01 → S1
* KSS02 (및 EGI 마감) → S2
* KWE01/KSE01 + SUS → S3
*/
private function resolveFinishingType(?string $productCode): string
{
if (! $productCode) {
return 'S1';
}
// FG-{model}-{type}-{material} 형식에서 모델코드와 재질 추출
$parts = explode('-', $productCode);
$modelCode = $parts[1] ?? '';
$material = $parts[3] ?? '';
// SUS 재질 + KWE/KSE 모델 → S3 (별도마감)
if (stripos($material, 'SUS') !== false && (str_starts_with($modelCode, 'KWE') || str_starts_with($modelCode, 'KSE'))) {
return 'S3';
}
return match (true) {
str_starts_with($modelCode, 'KSS01'), str_starts_with($modelCode, 'KQTS') => 'S1',
str_starts_with($modelCode, 'KSS02') => 'S2',
str_starts_with($modelCode, 'KWE'), str_starts_with($modelCode, 'KSE') => 'S2', // EGI마감 = S2
default => 'S2', // 기본값: S2 (5130 기준 EGI와 동일)
};
}
/**
* 절곡 bending_info에서 검사 대상 구성품 + 간격 기준치 빌드
* 마감유형(S1/S2/S3)에 따라 gap_points가 달라짐
*/
private function buildBendingInspectionItems(?WorkOrderItem $firstItem): array
{
if (! $firstItem) {
return [];
}
$productCode = $firstItem->options['product_code'] ?? null;
$finishingType = $this->resolveFinishingType($productCode);
$typeProfiles = self::BENDING_GAP_PROFILES[$finishingType] ?? self::BENDING_GAP_PROFILES['S1'];
$commonProfiles = self::BENDING_GAP_PROFILES['common'];
$bendingInfo = $firstItem->options['bending_info'] ?? null;
$items = [];
// 가이드레일 벽면 (벽면형 또는 혼합형)
$guideRail = $bendingInfo['guideRail'] ?? null;
$hasWall = ! $bendingInfo || ($guideRail && ($guideRail['wall'] ?? false));
$hasSide = $guideRail && ($guideRail['side'] ?? false);
if ($hasWall) {
$profile = $typeProfiles['guide_rail_wall'];
$items[] = [
'id' => 'guide_rail_wall',
'name' => $profile['name'],
'gap_points' => $profile['gap_points'],
];
}
if ($hasSide) {
$profile = $typeProfiles['guide_rail_side'];
$items[] = [
'id' => 'guide_rail_side',
'name' => $profile['name'],
'gap_points' => $profile['gap_points'],
];
}
// 하단마감재 (항상 포함, 마감유형별 gap_points 다름)
$profile = $typeProfiles['bottom_bar'];
$items[] = [
'id' => 'bottom_bar',
'name' => $profile['name'],
'gap_points' => $profile['gap_points'],
];
// 케이스 (항상 포함, 공통)
$profile = $commonProfiles['case_box'];
$items[] = [
'id' => 'case_box',
'name' => $profile['name'],
'gap_points' => $profile['gap_points'],
];
// 연기차단재 W50 (항상 포함, 공통)
$profile = $commonProfiles['smoke_w50'];
$items[] = [
'id' => 'smoke_w50',
'name' => $profile['name'],
'gap_points' => $profile['gap_points'],
];
// 연기차단재 W80 (항상 포함, 공통)
$profile = $commonProfiles['smoke_w80'];
$items[] = [
'id' => 'smoke_w80',
'name' => $profile['name'],
'gap_points' => $profile['gap_points'],
];
return $items;
}
// ────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────
// 검사 문서 템플릿 연동 // 검사 문서 템플릿 연동
// ────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────
@@ -2058,11 +2431,26 @@ public function resolveInspectionDocument(int $workOrderId, array $params = []):
->latest() ->latest()
->first(); ->first();
// Lazy Snapshot 대상: rendered_html이 없는 문서 (상태 무관)
$snapshotDocumentId = null;
$snapshotCandidate = Document::query()
->where('tenant_id', $tenantId)
->where('template_id', $templateId)
->where('linkable_type', 'work_order')
->where('linkable_id', $workOrderId)
->whereNull('rendered_html')
->latest()
->value('id');
if ($snapshotCandidate) {
$snapshotDocumentId = $snapshotCandidate;
}
return [ return [
'work_order_id' => $workOrderId, 'work_order_id' => $workOrderId,
'template_id' => $templateId, 'template_id' => $templateId,
'template' => $formattedTemplate, 'template' => $formattedTemplate,
'existing_document' => $existingDocument, 'existing_document' => $existingDocument,
'snapshot_document_id' => $snapshotDocumentId,
'work_order_info' => $this->buildWorkOrderInfo($workOrder), 'work_order_info' => $this->buildWorkOrderInfo($workOrder),
]; ];
} }
@@ -2094,80 +2482,92 @@ public function createInspectionDocument(int $workOrderId, array $inspectionData
throw new BadRequestHttpException(__('error.work_order.no_inspection_template')); throw new BadRequestHttpException(__('error.work_order.no_inspection_template'));
} }
$documentService = app(DocumentService::class); return DB::transaction(function () use ($workOrder, $workOrderId, $tenantId, $templateId, $inspectionData) {
// 동시 생성 방지: 동일 작업지시에 대한 락
$workOrder->lockForUpdate();
// 기존 DRAFT/REJECTED 문서가 있으면 update $documentService = app(DocumentService::class);
$existingDocument = Document::query()
->where('tenant_id', $tenantId)
->where('template_id', $templateId)
->where('linkable_type', 'work_order')
->where('linkable_id', $workOrderId)
->whereIn('status', [Document::STATUS_DRAFT, Document::STATUS_REJECTED])
->latest()
->first();
// ★ 원본 기반 동기화: work_order_items.options.inspection_data에서 전체 수집 // 기존 DRAFT/REJECTED 문서가 있으면 update
$rawItems = []; $existingDocument = Document::query()
foreach ($workOrder->items as $item) { ->where('tenant_id', $tenantId)
$inspData = $item->getInspectionData(); ->where('template_id', $templateId)
if ($inspData) { ->where('linkable_type', 'work_order')
$rawItems[] = $inspData; ->where('linkable_id', $workOrderId)
->whereIn('status', [Document::STATUS_DRAFT, Document::STATUS_REJECTED])
->latest()
->first();
// ★ 원본 기반 동기화: work_order_items.options.inspection_data에서 전체 수집
$rawItems = [];
foreach ($workOrder->items as $item) {
$inspData = $item->getInspectionData();
if ($inspData) {
$rawItems[] = $inspData;
}
} }
}
$documentDataRecords = $this->transformInspectionDataToDocumentRecords($rawItems, $templateId); $documentDataRecords = $this->transformInspectionDataToDocumentRecords($rawItems, $templateId);
// 기존 문서의 기본필드(bf_*) 보존 // 기존 문서의 기본필드(bf_*) 보존
if ($existingDocument) { if ($existingDocument) {
$existingBasicFields = $existingDocument->data() $existingBasicFields = $existingDocument->data()
->whereNull('section_id') ->whereNull('section_id')
->where('field_key', 'LIKE', 'bf_%') ->where('field_key', 'LIKE', 'bf_%')
->get() ->get()
->map(fn ($d) => [ ->map(fn ($d) => [
'section_id' => null, 'section_id' => null,
'column_id' => null, 'column_id' => null,
'row_index' => $d->row_index, 'row_index' => $d->row_index,
'field_key' => $d->field_key, 'field_key' => $d->field_key,
'field_value' => $d->field_value, 'field_value' => $d->field_value,
]) ])
->toArray(); ->toArray();
$document = $documentService->update($existingDocument->id, [ $updateData = [
'title' => $inspectionData['title'] ?? $existingDocument->title, 'title' => $inspectionData['title'] ?? $existingDocument->title,
'data' => array_merge($existingBasicFields, $documentDataRecords), 'data' => array_merge($existingBasicFields, $documentDataRecords),
]); ];
if (isset($inspectionData['rendered_html'])) {
$updateData['rendered_html'] = $inspectionData['rendered_html'];
}
$document = $documentService->update($existingDocument->id, $updateData);
$action = 'inspection_document_updated'; $action = 'inspection_document_updated';
} else { } else {
$documentData = [ $documentData = [
'template_id' => $templateId, 'template_id' => $templateId,
'title' => $inspectionData['title'] ?? "중간검사성적서 - {$workOrder->work_order_no}", 'title' => $inspectionData['title'] ?? "중간검사성적서 - {$workOrder->work_order_no}",
'linkable_type' => 'work_order', 'linkable_type' => 'work_order',
'linkable_id' => $workOrderId, 'linkable_id' => $workOrderId,
'data' => $documentDataRecords, 'data' => $documentDataRecords,
'approvers' => $inspectionData['approvers'] ?? [], 'approvers' => $inspectionData['approvers'] ?? [],
];
if (isset($inspectionData['rendered_html'])) {
$documentData['rendered_html'] = $inspectionData['rendered_html'];
}
$document = $documentService->create($documentData);
$action = 'inspection_document_created';
}
// 감사 로그
$this->auditLogger->log(
$tenantId,
self::AUDIT_TARGET,
$workOrderId,
$action,
null,
['document_id' => $document->id, 'document_no' => $document->document_no]
);
return [
'document_id' => $document->id,
'document_no' => $document->document_no,
'status' => $document->status,
'is_new' => $action === 'inspection_document_created',
]; ];
});
$document = $documentService->create($documentData);
$action = 'inspection_document_created';
}
// 감사 로그
$this->auditLogger->log(
$tenantId,
self::AUDIT_TARGET,
$workOrderId,
$action,
null,
['document_id' => $document->id, 'document_no' => $document->document_no]
);
return [
'document_id' => $document->id,
'document_no' => $document->document_no,
'status' => $document->status,
'is_new' => $action === 'inspection_document_created',
];
} }
/** /**
@@ -2197,10 +2597,107 @@ private function transformInspectionDataToDocumentRecords(array $rawItems, int $
], $rawItems); ], $rawItems);
} }
// 절곡 products 배열 감지 → bending 전용 EAV 레코드 생성
$productsItem = collect($rawItems)->first(fn ($item) => isset($item['products']) && is_array($item['products']));
if ($productsItem) {
return $this->transformBendingProductsToRecords($productsItem, $templateId);
}
// 레거시 형식: templateValues/values 기반 → 정규화 변환 // 레거시 형식: templateValues/values 기반 → 정규화 변환
return $this->normalizeOldFormatRecords($rawItems, $templateId); return $this->normalizeOldFormatRecords($rawItems, $templateId);
} }
/**
* 절곡 products 배열 → bending 전용 EAV 레코드 변환
*
* InspectionInputModal이 저장하는 products 형식:
* [{ id, bendingStatus: '양호'|'불량', lengthMeasured, widthMeasured, gapPoints: [{point, designValue, measured}] }]
*
* 프론트엔드 TemplateInspectionContent가 기대하는 EAV field_key 형식:
* b{productIdx}_ok / b{productIdx}_ng, b{productIdx}_n1, b{productIdx}_p{pointIdx}_n1
*/
private function transformBendingProductsToRecords(array $item, int $templateId): array
{
$template = DocumentTemplate::with(['columns'])->find($templateId);
if (! $template) {
return [];
}
// 컬럼 식별 (column_type + sort_order 기반)
$checkCol = $template->columns->firstWhere('column_type', 'check');
$complexCols = $template->columns->where('column_type', 'complex')->sortBy('sort_order')->values();
// complex 컬럼 순서: 길이(0), 너비(1), 간격(2)
$lengthCol = $complexCols->get(0);
$widthCol = $complexCols->get(1);
$gapCol = $complexCols->get(2);
$records = [];
$products = $item['products'];
foreach ($products as $productIdx => $product) {
// 절곡상태 → check column
if ($checkCol) {
if (($product['bendingStatus'] ?? null) === '양호') {
$records[] = [
'section_id' => null, 'column_id' => $checkCol->id,
'row_index' => $productIdx, 'field_key' => "b{$productIdx}_ok", 'field_value' => 'OK',
];
} elseif (($product['bendingStatus'] ?? null) === '불량') {
$records[] = [
'section_id' => null, 'column_id' => $checkCol->id,
'row_index' => $productIdx, 'field_key' => "b{$productIdx}_ng", 'field_value' => 'NG',
];
}
}
// 길이 → first complex column
if ($lengthCol && ! empty($product['lengthMeasured'])) {
$records[] = [
'section_id' => null, 'column_id' => $lengthCol->id,
'row_index' => $productIdx, 'field_key' => "b{$productIdx}_n1", 'field_value' => (string) $product['lengthMeasured'],
];
}
// 너비 → second complex column
if ($widthCol && ! empty($product['widthMeasured'])) {
$records[] = [
'section_id' => null, 'column_id' => $widthCol->id,
'row_index' => $productIdx, 'field_key' => "b{$productIdx}_n1", 'field_value' => (string) $product['widthMeasured'],
];
}
// 간격 포인트 → third complex column (gap)
if ($gapCol && ! empty($product['gapPoints'])) {
foreach ($product['gapPoints'] as $pointIdx => $gp) {
if (! empty($gp['measured'])) {
$records[] = [
'section_id' => null, 'column_id' => $gapCol->id,
'row_index' => $productIdx, 'field_key' => "b{$productIdx}_p{$pointIdx}_n1", 'field_value' => (string) $gp['measured'],
];
}
}
}
}
// 전체 판정
if (isset($item['judgment'])) {
$records[] = [
'section_id' => null, 'column_id' => null,
'row_index' => 0, 'field_key' => 'overall_result', 'field_value' => (string) $item['judgment'],
];
}
// 부적합 내용
if (! empty($item['nonConformingContent'])) {
$records[] = [
'section_id' => null, 'column_id' => null,
'row_index' => 0, 'field_key' => 'remark', 'field_value' => (string) $item['nonConformingContent'],
];
}
return $records;
}
/** /**
* 레거시 형식(section_X_item_Y 키)을 정규화 레코드로 변환 * 레거시 형식(section_X_item_Y 키)을 정규화 레코드로 변환
*/ */
@@ -2665,20 +3162,28 @@ public function createWorkLog(int $workOrderId, array $workLogData): array
$documentDataRecords = $this->transformWorkLogDataToRecords($workLogData, $workOrder, $template); $documentDataRecords = $this->transformWorkLogDataToRecords($workLogData, $workOrder, $template);
if ($existingDocument) { if ($existingDocument) {
$document = $documentService->update($existingDocument->id, [ $updateData = [
'title' => $workLogData['title'] ?? $existingDocument->title, 'title' => $workLogData['title'] ?? $existingDocument->title,
'data' => $documentDataRecords, 'data' => $documentDataRecords,
]); ];
if (isset($workLogData['rendered_html'])) {
$updateData['rendered_html'] = $workLogData['rendered_html'];
}
$document = $documentService->update($existingDocument->id, $updateData);
$action = 'work_log_updated'; $action = 'work_log_updated';
} else { } else {
$document = $documentService->create([ $createData = [
'template_id' => $templateId, 'template_id' => $templateId,
'title' => $workLogData['title'] ?? "작업일지 - {$workOrder->work_order_no}", 'title' => $workLogData['title'] ?? "작업일지 - {$workOrder->work_order_no}",
'linkable_type' => 'work_order', 'linkable_type' => 'work_order',
'linkable_id' => $workOrderId, 'linkable_id' => $workOrderId,
'data' => $documentDataRecords, 'data' => $documentDataRecords,
'approvers' => $workLogData['approvers'] ?? [], 'approvers' => $workLogData['approvers'] ?? [],
]); ];
if (isset($workLogData['rendered_html'])) {
$createData['rendered_html'] = $workLogData['rendered_html'];
}
$document = $documentService->create($createData);
$action = 'work_log_created'; $action = 'work_log_created';
} }
@@ -2870,6 +3375,12 @@ public function getMaterialsForItem(int $workOrderId, int $itemId): array
continue; continue;
} }
// LOT 관리 대상이 아닌 품목은 자재투입 목록에서 제외
$childOptions = $childItems[$childItemId]->options ?? [];
if (isset($childOptions['lot_managed']) && $childOptions['lot_managed'] === false) {
continue;
}
// dynamic_bom.qty는 아이템 수량이 곱해져 있으므로 나눠서 개소당 수량 산출 // dynamic_bom.qty는 아이템 수량이 곱해져 있으므로 나눠서 개소당 수량 산출
// (작업일지 bendingInfo와 동일한 수량) // (작업일지 bendingInfo와 동일한 수량)
$bomQty = (float) ($bomEntry['qty'] ?? 1); $bomQty = (float) ($bomEntry['qty'] ?? 1);
@@ -2907,6 +3418,12 @@ public function getMaterialsForItem(int $workOrderId, int $itemId): array
continue; continue;
} }
// LOT 관리 대상이 아닌 품목은 자재투입 목록에서 제외
$childOptions = $childItem->options ?? [];
if (isset($childOptions['lot_managed']) && $childOptions['lot_managed'] === false) {
continue;
}
$materialItems[] = [ $materialItems[] = [
'item' => $childItem, 'item' => $childItem,
'bom_qty' => $bomQty, 'bom_qty' => $bomQty,
@@ -2933,15 +3450,44 @@ public function getMaterialsForItem(int $workOrderId, int $itemId): array
->groupBy('item_id') ->groupBy('item_id')
->pluck('total_qty', 'item_id'); ->pluck('total_qty', 'item_id');
// LOT별 기투입 수량 조회 (stock_lot_id + bom_group_key별 SUM)
$lotInputtedRaw = WorkOrderMaterialInput::where('tenant_id', $tenantId)
->where('work_order_id', $workOrderId)
->where('work_order_item_id', $itemId)
->whereNotNull('stock_lot_id')
->selectRaw('stock_lot_id, bom_group_key, SUM(qty) as total_qty')
->groupBy('stock_lot_id', 'bom_group_key')
->get();
// bom_group_key 포함 복합키 매핑 + stock_lot_id 단순 매핑 (하위호환)
$lotInputtedByGroup = [];
$lotInputtedByLot = [];
foreach ($lotInputtedRaw as $row) {
$lotId = $row->stock_lot_id;
$groupKey = $row->bom_group_key;
$qty = (float) $row->total_qty;
if ($groupKey) {
$compositeKey = $lotId.'_'.$groupKey;
$lotInputtedByGroup[$compositeKey] = ($lotInputtedByGroup[$compositeKey] ?? 0) + $qty;
}
$lotInputtedByLot[$lotId] = ($lotInputtedByLot[$lotId] ?? 0) + $qty;
}
// 자재별 LOT 조회 // 자재별 LOT 조회
$materials = []; $materials = [];
$rank = 1; $rank = 1;
foreach ($materialItems as $matInfo) { foreach ($materialItems as $bomIdx => $matInfo) {
$materialItem = $matInfo['item']; $materialItem = $matInfo['item'];
$alreadyInputted = (float) ($inputtedQties[$materialItem->id] ?? 0); $alreadyInputted = (float) ($inputtedQties[$materialItem->id] ?? 0);
$remainingRequired = max(0, $matInfo['required_qty'] - $alreadyInputted); $remainingRequired = max(0, $matInfo['required_qty'] - $alreadyInputted);
// BOM 엔트리별 고유 그룹키 (같은 item_id라도 category+partType이 다르면 별도 그룹)
$bomGroupKey = $materialItem->id
.'_'.($matInfo['category'] ?? '')
.'_'.($matInfo['part_type'] ?? '');
$stock = \App\Models\Tenants\Stock::where('tenant_id', $tenantId) $stock = \App\Models\Tenants\Stock::where('tenant_id', $tenantId)
->where('item_id', $materialItem->id) ->where('item_id', $materialItem->id)
->first(); ->first();
@@ -2961,6 +3507,7 @@ public function getMaterialsForItem(int $workOrderId, int $itemId): array
$materials[] = [ $materials[] = [
'stock_lot_id' => $lot->id, 'stock_lot_id' => $lot->id,
'item_id' => $materialItem->id, 'item_id' => $materialItem->id,
'bom_group_key' => $bomGroupKey,
'lot_no' => $lot->lot_no, 'lot_no' => $lot->lot_no,
'material_code' => $materialItem->code, 'material_code' => $materialItem->code,
'material_name' => $materialItem->name, 'material_name' => $materialItem->name,
@@ -2970,6 +3517,7 @@ public function getMaterialsForItem(int $workOrderId, int $itemId): array
'required_qty' => $matInfo['required_qty'], 'required_qty' => $matInfo['required_qty'],
'already_inputted' => $alreadyInputted, 'already_inputted' => $alreadyInputted,
'remaining_required_qty' => $remainingRequired, 'remaining_required_qty' => $remainingRequired,
'lot_inputted_qty' => (float) ($lotInputtedByGroup[$lot->id.'_'.$bomGroupKey] ?? $lotInputtedByLot[$lot->id] ?? 0),
'lot_qty' => (float) $lot->qty, 'lot_qty' => (float) $lot->qty,
'lot_available_qty' => (float) $lot->available_qty, 'lot_available_qty' => (float) $lot->available_qty,
'lot_reserved_qty' => (float) $lot->reserved_qty, 'lot_reserved_qty' => (float) $lot->reserved_qty,
@@ -2987,6 +3535,7 @@ public function getMaterialsForItem(int $workOrderId, int $itemId): array
$materials[] = [ $materials[] = [
'stock_lot_id' => null, 'stock_lot_id' => null,
'item_id' => $materialItem->id, 'item_id' => $materialItem->id,
'bom_group_key' => $bomGroupKey,
'lot_no' => null, 'lot_no' => null,
'material_code' => $materialItem->code, 'material_code' => $materialItem->code,
'material_name' => $materialItem->name, 'material_name' => $materialItem->name,
@@ -2996,6 +3545,7 @@ public function getMaterialsForItem(int $workOrderId, int $itemId): array
'required_qty' => $matInfo['required_qty'], 'required_qty' => $matInfo['required_qty'],
'already_inputted' => $alreadyInputted, 'already_inputted' => $alreadyInputted,
'remaining_required_qty' => $remainingRequired, 'remaining_required_qty' => $remainingRequired,
'lot_inputted_qty' => 0,
'lot_qty' => 0, 'lot_qty' => 0,
'lot_available_qty' => 0, 'lot_available_qty' => 0,
'lot_reserved_qty' => 0, 'lot_reserved_qty' => 0,
@@ -3014,8 +3564,10 @@ public function getMaterialsForItem(int $workOrderId, int $itemId): array
/** /**
* 개소별 자재 투입 등록 * 개소별 자재 투입 등록
*
* @param bool $replace true면 기존 투입 이력을 삭제(재고 복원) 후 새로 등록
*/ */
public function registerMaterialInputForItem(int $workOrderId, int $itemId, array $inputs): array public function registerMaterialInputForItem(int $workOrderId, int $itemId, array $inputs, bool $replace = false): array
{ {
$tenantId = $this->tenantId(); $tenantId = $this->tenantId();
$userId = $this->apiUserId(); $userId = $this->apiUserId();
@@ -3033,13 +3585,32 @@ public function registerMaterialInputForItem(int $workOrderId, int $itemId, arra
throw new NotFoundHttpException(__('error.not_found')); throw new NotFoundHttpException(__('error.not_found'));
} }
return DB::transaction(function () use ($inputs, $tenantId, $userId, $workOrderId, $itemId) { return DB::transaction(function () use ($inputs, $tenantId, $userId, $workOrderId, $itemId, $replace) {
$stockService = app(StockService::class); $stockService = app(StockService::class);
$inputResults = []; $inputResults = [];
// replace 모드: 기존 투입 이력 삭제 + 재고 복원
if ($replace) {
$existingInputs = WorkOrderMaterialInput::where('tenant_id', $tenantId)
->where('work_order_id', $workOrderId)
->where('work_order_item_id', $itemId)
->get();
foreach ($existingInputs as $existing) {
$stockService->increaseToLot(
stockLotId: $existing->stock_lot_id,
qty: (float) $existing->qty,
reason: 'work_order_input_replace',
referenceId: $workOrderId
);
$existing->delete();
}
}
foreach ($inputs as $input) { foreach ($inputs as $input) {
$stockLotId = $input['stock_lot_id'] ?? null; $stockLotId = $input['stock_lot_id'] ?? null;
$qty = (float) ($input['qty'] ?? 0); $qty = (float) ($input['qty'] ?? 0);
$bomGroupKey = $input['bom_group_key'] ?? null;
if (! $stockLotId || $qty <= 0) { if (! $stockLotId || $qty <= 0) {
continue; continue;
@@ -3064,6 +3635,7 @@ public function registerMaterialInputForItem(int $workOrderId, int $itemId, arra
'work_order_item_id' => $itemId, 'work_order_item_id' => $itemId,
'stock_lot_id' => $stockLotId, 'stock_lot_id' => $stockLotId,
'item_id' => $lotItemId ?? 0, 'item_id' => $lotItemId ?? 0,
'bom_group_key' => $bomGroupKey,
'qty' => $qty, 'qty' => $qty,
'input_by' => $userId, 'input_by' => $userId,
'input_at' => now(), 'input_at' => now(),

View File

@@ -0,0 +1,188 @@
<?php
namespace App\Swagger\v1;
/**
* @OA\Tag(name="ProductionOrders", description="생산지시 관리")
*
* @OA\Schema(
* schema="ProductionOrderListItem",
* type="object",
* description="생산지시 목록 아이템",
*
* @OA\Property(property="id", type="integer", example=1, description="수주 ID"),
* @OA\Property(property="order_no", type="string", example="ORD-20260301-0001", description="수주번호 (= 생산지시번호)"),
* @OA\Property(property="site_name", type="string", example="서울현장", nullable=true, description="현장명"),
* @OA\Property(property="client_name", type="string", example="(주)고객사", nullable=true, description="거래처명"),
* @OA\Property(property="quantity", type="number", example=232, description="부품수량 합계"),
* @OA\Property(property="node_count", type="integer", example=4, description="개소수 (order_nodes 수)"),
* @OA\Property(property="delivery_date", type="string", format="date", example="2026-03-15", nullable=true, description="납기일"),
* @OA\Property(property="production_ordered_at", type="string", format="date", example="2026-02-21", nullable=true, description="생산지시일 (첫 WorkOrder 생성일, Y-m-d)"),
* @OA\Property(property="production_status", type="string", enum={"waiting","in_production","completed"}, example="waiting", description="생산 상태"),
* @OA\Property(property="work_orders_count", type="integer", example=2, description="작업지시 수 (공정별 1건)"),
* @OA\Property(property="work_order_progress", type="object",
* @OA\Property(property="total", type="integer", example=3),
* @OA\Property(property="completed", type="integer", example=1),
* @OA\Property(property="in_progress", type="integer", example=1)
* ),
* @OA\Property(property="client", type="object", nullable=true,
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="name", type="string", example="(주)고객사")
* )
* )
*
* @OA\Schema(
* schema="ProductionOrderStats",
* type="object",
* description="생산지시 통계",
*
* @OA\Property(property="total", type="integer", example=25, description="전체"),
* @OA\Property(property="waiting", type="integer", example=10, description="생산대기"),
* @OA\Property(property="in_production", type="integer", example=8, description="생산중"),
* @OA\Property(property="completed", type="integer", example=7, description="생산완료")
* )
*
* @OA\Schema(
* schema="ProductionOrderDetail",
* type="object",
* description="생산지시 상세",
*
* @OA\Property(property="order", ref="#/components/schemas/ProductionOrderListItem"),
* @OA\Property(property="production_ordered_at", type="string", format="date", example="2026-02-21", nullable=true),
* @OA\Property(property="production_status", type="string", enum={"waiting","in_production","completed"}),
* @OA\Property(property="node_count", type="integer", example=4, description="개소수"),
* @OA\Property(property="work_order_progress", type="object",
* @OA\Property(property="total", type="integer"),
* @OA\Property(property="completed", type="integer"),
* @OA\Property(property="in_progress", type="integer")
* ),
* @OA\Property(property="work_orders", type="array",
*
* @OA\Items(type="object",
*
* @OA\Property(property="id", type="integer"),
* @OA\Property(property="work_order_no", type="string"),
* @OA\Property(property="process_name", type="string"),
* @OA\Property(property="quantity", type="integer"),
* @OA\Property(property="status", type="string"),
* @OA\Property(property="assignees", type="array", @OA\Items(type="string"))
* )
* ),
* @OA\Property(property="bom_process_groups", type="array",
*
* @OA\Items(type="object",
*
* @OA\Property(property="process_name", type="string"),
* @OA\Property(property="size_spec", type="string", nullable=true),
* @OA\Property(property="items", type="array",
*
* @OA\Items(type="object",
*
* @OA\Property(property="id", type="integer", nullable=true),
* @OA\Property(property="item_code", type="string"),
* @OA\Property(property="item_name", type="string"),
* @OA\Property(property="spec", type="string"),
* @OA\Property(property="lot_no", type="string"),
* @OA\Property(property="required_qty", type="number"),
* @OA\Property(property="qty", type="number")
* )
* )
* )
* )
* )
*/
class ProductionOrderApi
{
/**
* @OA\Get(
* path="/api/v1/production-orders",
* tags={"ProductionOrders"},
* summary="생산지시 목록 조회",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
*
* @OA\Parameter(name="search", in="query", required=false,
*
* @OA\Schema(type="string"), description="검색어 (수주번호, 거래처명, 현장명)"
* ),
*
* @OA\Parameter(name="production_status", in="query", required=false,
*
* @OA\Schema(type="string", enum={"waiting","in_production","completed"}), description="생산 상태 필터"
* ),
*
* @OA\Parameter(name="sort_by", in="query", required=false,
*
* @OA\Schema(type="string", enum={"created_at","delivery_date","order_no"}), description="정렬 기준"
* ),
*
* @OA\Parameter(name="sort_dir", in="query", required=false,
*
* @OA\Schema(type="string", enum={"asc","desc"}), description="정렬 방향"
* ),
*
* @OA\Parameter(name="page", in="query", required=false, @OA\Schema(type="integer")),
* @OA\Parameter(name="per_page", in="query", required=false, @OA\Schema(type="integer")),
*
* @OA\Response(response=200, description="성공",
*
* @OA\JsonContent(
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string"),
* @OA\Property(property="data", type="object",
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/ProductionOrderListItem")),
* @OA\Property(property="current_page", type="integer"),
* @OA\Property(property="last_page", type="integer"),
* @OA\Property(property="per_page", type="integer"),
* @OA\Property(property="total", type="integer")
* )
* )
* )
* )
*/
public function index() {}
/**
* @OA\Get(
* path="/api/v1/production-orders/stats",
* tags={"ProductionOrders"},
* summary="생산지시 상태별 통계",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
*
* @OA\Response(response=200, description="성공",
*
* @OA\JsonContent(
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string"),
* @OA\Property(property="data", ref="#/components/schemas/ProductionOrderStats")
* )
* )
* )
*/
public function stats() {}
/**
* @OA\Get(
* path="/api/v1/production-orders/{orderId}",
* tags={"ProductionOrders"},
* summary="생산지시 상세 조회",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
*
* @OA\Parameter(name="orderId", in="path", required=true, @OA\Schema(type="integer"), description="수주 ID"),
*
* @OA\Response(response=200, description="성공",
*
* @OA\JsonContent(
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string"),
* @OA\Property(property="data", ref="#/components/schemas/ProductionOrderDetail")
* )
* ),
*
* @OA\Response(response=404, description="생산지시를 찾을 수 없음")
* )
*/
public function show() {}
}

View File

@@ -0,0 +1,26 @@
<?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::table('work_order_material_inputs', function (Blueprint $table) {
$table->string('bom_group_key')->nullable()->after('item_id')
->comment('BOM 그룹키 (같은 item_id의 다른 용도 구분, ex: itemId_category_partType)');
$table->index(['work_order_item_id', 'bom_group_key'], 'idx_womi_item_bomgroup');
});
}
public function down(): void
{
Schema::table('work_order_material_inputs', function (Blueprint $table) {
$table->dropIndex('idx_womi_item_bomgroup');
$table->dropColumn('bom_group_key');
});
}
};

View File

@@ -0,0 +1,23 @@
<?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::table('process_steps', function (Blueprint $table) {
$table->json('options')->nullable()->after('completion_type')
->comment('검사설정, 검사범위 등 추가 옵션 JSON');
});
}
public function down(): void
{
Schema::table('process_steps', function (Blueprint $table) {
$table->dropColumn('options');
});
}
};

View File

@@ -10,6 +10,7 @@
*/ */
use App\Http\Controllers\Api\V1\InspectionController; use App\Http\Controllers\Api\V1\InspectionController;
use App\Http\Controllers\Api\V1\ProductionOrderController;
use App\Http\Controllers\Api\V1\WorkOrderController; use App\Http\Controllers\Api\V1\WorkOrderController;
use App\Http\Controllers\Api\V1\WorkResultController; use App\Http\Controllers\Api\V1\WorkResultController;
use App\Http\Controllers\V1\ProcessController; use App\Http\Controllers\V1\ProcessController;
@@ -82,6 +83,7 @@
// 중간검사 관리 // 중간검사 관리
Route::post('/{id}/items/{itemId}/inspection', [WorkOrderController::class, 'storeItemInspection'])->whereNumber('id')->whereNumber('itemId')->name('v1.work-orders.items.inspection'); // 품목 검사 저장 Route::post('/{id}/items/{itemId}/inspection', [WorkOrderController::class, 'storeItemInspection'])->whereNumber('id')->whereNumber('itemId')->name('v1.work-orders.items.inspection'); // 품목 검사 저장
Route::get('/{id}/inspection-config', [WorkOrderController::class, 'inspectionConfig'])->whereNumber('id')->name('v1.work-orders.inspection-config'); // 검사 설정 (공정 판별 + 구성품)
Route::get('/{id}/inspection-data', [WorkOrderController::class, 'inspectionData'])->whereNumber('id')->name('v1.work-orders.inspection-data'); // 검사 데이터 조회 Route::get('/{id}/inspection-data', [WorkOrderController::class, 'inspectionData'])->whereNumber('id')->name('v1.work-orders.inspection-data'); // 검사 데이터 조회
Route::get('/{id}/inspection-report', [WorkOrderController::class, 'inspectionReport'])->whereNumber('id')->name('v1.work-orders.inspection-report'); // 검사 성적서 조회 Route::get('/{id}/inspection-report', [WorkOrderController::class, 'inspectionReport'])->whereNumber('id')->name('v1.work-orders.inspection-report'); // 검사 성적서 조회
Route::get('/{id}/inspection-template', [WorkOrderController::class, 'inspectionTemplate'])->whereNumber('id')->name('v1.work-orders.inspection-template'); // 검사 문서 템플릿 조회 Route::get('/{id}/inspection-template', [WorkOrderController::class, 'inspectionTemplate'])->whereNumber('id')->name('v1.work-orders.inspection-template'); // 검사 문서 템플릿 조회
@@ -113,9 +115,17 @@
Route::prefix('inspections')->group(function () { Route::prefix('inspections')->group(function () {
Route::get('', [InspectionController::class, 'index'])->name('v1.inspections.index'); // 목록 Route::get('', [InspectionController::class, 'index'])->name('v1.inspections.index'); // 목록
Route::get('/stats', [InspectionController::class, 'stats'])->name('v1.inspections.stats'); // 통계 Route::get('/stats', [InspectionController::class, 'stats'])->name('v1.inspections.stats'); // 통계
Route::get('/calendar', [InspectionController::class, 'calendar'])->name('v1.inspections.calendar'); // 캘린더
Route::post('', [InspectionController::class, 'store'])->name('v1.inspections.store'); // 생성 Route::post('', [InspectionController::class, 'store'])->name('v1.inspections.store'); // 생성
Route::get('/{id}', [InspectionController::class, 'show'])->whereNumber('id')->name('v1.inspections.show'); // 상세 Route::get('/{id}', [InspectionController::class, 'show'])->whereNumber('id')->name('v1.inspections.show'); // 상세
Route::put('/{id}', [InspectionController::class, 'update'])->whereNumber('id')->name('v1.inspections.update'); // 수정 Route::put('/{id}', [InspectionController::class, 'update'])->whereNumber('id')->name('v1.inspections.update'); // 수정
Route::delete('/{id}', [InspectionController::class, 'destroy'])->whereNumber('id')->name('v1.inspections.destroy'); // 삭제 Route::delete('/{id}', [InspectionController::class, 'destroy'])->whereNumber('id')->name('v1.inspections.destroy'); // 삭제
Route::patch('/{id}/complete', [InspectionController::class, 'complete'])->whereNumber('id')->name('v1.inspections.complete'); // 완료 처리 Route::patch('/{id}/complete', [InspectionController::class, 'complete'])->whereNumber('id')->name('v1.inspections.complete'); // 완료 처리
}); });
// Production Order API (생산지시 조회)
Route::prefix('production-orders')->group(function () {
Route::get('', [ProductionOrderController::class, 'index'])->name('v1.production-orders.index');
Route::get('/stats', [ProductionOrderController::class, 'stats'])->name('v1.production-orders.stats');
Route::get('/{orderId}', [ProductionOrderController::class, 'show'])->whereNumber('orderId')->name('v1.production-orders.show');
});