fix(WEB): 수주 페이지 필드 매핑 및 제품-부품 트리 구조 개선

- ApiClient 인터페이스: representative → manager_name, contact_person 변경
- transformApiToFrontend: client.representative → client.manager_name 수정
- ApiOrderItem에 floor_code, symbol_code 필드 추가 (제품-부품 매핑)
- ApiOrder에 options 타입 정의 추가
- ApiQuote에 calculation_inputs 타입 정의 추가
- 수주 상세 페이지 제품-부품 트리 구조 UI 개선
This commit is contained in:
2026-01-16 21:59:06 +09:00
committed by hskwon
parent 0b94da0741
commit 7246ac003f
52 changed files with 1105 additions and 115 deletions

View File

@@ -2,10 +2,13 @@
namespace App\Services;
use App\Models\Orders\Order;
use App\Models\Production\WorkOrder;
use App\Models\Production\WorkOrderAssignee;
use App\Models\Production\WorkOrderBendingDetail;
use App\Models\Production\WorkOrderItem;
use App\Models\Tenants\Shipment;
use App\Models\Tenants\ShipmentItem;
use App\Services\Audit\AuditLogger;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
@@ -431,8 +434,13 @@ public function updateStatus(int $id, string $status, ?array $resultData = null)
throw new BadRequestHttpException(__('error.invalid_status'));
}
// 상태 전이 규칙 검증
if (! $workOrder->canTransitionTo($status)) {
// Fast-track 완료 체크: pending/waiting에서 completed로 직접 전환 허용
// 작업자 화면의 "전량완료" 버튼 지원
$isFastTrackCompletion = $status === WorkOrder::STATUS_COMPLETED &&
in_array($workOrder->status, [WorkOrder::STATUS_PENDING, WorkOrder::STATUS_WAITING]);
// 일반 상태 전이 규칙 검증 (fast-track이 아닌 경우)
if (! $isFastTrackCompletion && ! $workOrder->canTransitionTo($status)) {
$allowed = implode(', ', $workOrder->getAllowedTransitions());
throw new BadRequestHttpException(
__('error.work_order.invalid_transition', [
@@ -454,6 +462,8 @@ public function updateStatus(int $id, string $status, ?array $resultData = null)
$workOrder->started_at = $workOrder->started_at ?? now();
break;
case WorkOrder::STATUS_COMPLETED:
// Fast-track 완료의 경우 started_at도 설정 (중간 상태 생략)
$workOrder->started_at = $workOrder->started_at ?? now();
$workOrder->completed_at = now();
// 모든 품목에 결과 데이터 저장
$this->saveItemResults($workOrder, $resultData, $userId);
@@ -475,10 +485,221 @@ public function updateStatus(int $id, string $status, ?array $resultData = null)
['status' => $status]
);
// 연결된 수주(Order) 상태 동기화
$this->syncOrderStatus($workOrder, $tenantId);
// 작업완료 시 자동 출하 생성
if ($status === WorkOrder::STATUS_COMPLETED) {
$this->createShipmentFromWorkOrder($workOrder, $tenantId, $userId);
}
return $workOrder->load(['assignee:id,name', 'assignees.user:id,name', 'team:id,name', 'process:id,process_name,process_code']);
});
}
/**
* 작업지시 완료 시 자동 출하 생성
*
* 작업지시가 완료(completed) 상태가 되면 출하(Shipment)를 자동 생성하여 출하관리로 넘깁니다.
* 발주처/배송 정보는 출하에 복사하지 않고, 수주(Order)를 참조합니다.
* (Shipment 모델의 accessor 메서드로 수주 정보 참조)
*/
private function createShipmentFromWorkOrder(WorkOrder $workOrder, int $tenantId, int $userId): ?Shipment
{
// 이미 이 작업지시에 연결된 출하가 있으면 스킵
$existingShipment = Shipment::where('tenant_id', $tenantId)
->where('work_order_id', $workOrder->id)
->first();
if ($existingShipment) {
return $existingShipment;
}
// 출하번호 자동 생성
$shipmentNo = Shipment::generateShipmentNo($tenantId);
// 출하 생성 데이터
// 발주처/배송 정보는 수주(Order)를 참조하므로 여기서 복사하지 않음
$shipmentData = [
'tenant_id' => $tenantId,
'shipment_no' => $shipmentNo,
'work_order_id' => $workOrder->id,
'order_id' => $workOrder->sales_order_id,
'scheduled_date' => now()->toDateString(), // 오늘 날짜로 출하 예정
'status' => 'scheduled', // 예정 상태로 생성
'priority' => 'normal',
'delivery_method' => 'pickup', // 기본값
'can_ship' => true, // 생산 완료 후 생성되므로 출하가능
'created_by' => $userId,
'updated_by' => $userId,
];
$shipment = Shipment::create($shipmentData);
// 작업지시 품목을 출하 품목으로 복사
$this->copyWorkOrderItemsToShipment($workOrder, $shipment, $tenantId);
// 자동 출하 생성 감사 로그
$this->auditLogger->log(
$tenantId,
'shipment',
$shipment->id,
'auto_created_from_work_order',
null,
[
'work_order_id' => $workOrder->id,
'shipment_no' => $shipmentNo,
'items_count' => $shipment->items()->count(),
]
);
return $shipment;
}
/**
* 작업지시 품목을 출하 품목으로 복사
*
* 작업지시 품목(work_order_items)의 정보를 출하 품목(shipment_items)으로 복사합니다.
* 작업지시 품목이 없으면 수주 품목(order_items)을 대체 사용합니다.
* LOT 번호는 작업지시 품목의 결과 데이터에서 가져옵니다.
* 층/부호(floor_unit)는 원본 수주품목(order_items)에서 가져옵니다.
*/
private function copyWorkOrderItemsToShipment(WorkOrder $workOrder, Shipment $shipment, int $tenantId): void
{
$workOrderItems = $workOrder->items()->get();
// 작업지시 품목이 있으면 사용
if ($workOrderItems->isNotEmpty()) {
foreach ($workOrderItems as $index => $woItem) {
// 작업지시 품목의 결과 데이터에서 LOT 번호 추출
$result = $woItem->options['result'] ?? [];
$lotNo = $result['lot_no'] ?? null;
// 원본 수주품목에서 층/부호 정보 조회
$floorUnit = $this->getFloorUnitFromOrderItem($woItem->source_order_item_id, $tenantId);
// 출하 품목 생성
ShipmentItem::create([
'tenant_id' => $tenantId,
'shipment_id' => $shipment->id,
'seq' => $index + 1,
'item_code' => $woItem->item_id ? "ITEM-{$woItem->item_id}" : null,
'item_name' => $woItem->item_name,
'floor_unit' => $floorUnit,
'specification' => $woItem->specification,
'quantity' => $result['good_qty'] ?? $woItem->quantity, // 양품 수량 우선
'unit' => $woItem->unit,
'lot_no' => $lotNo,
'remarks' => null,
]);
}
return;
}
// 작업지시 품목이 없으면 수주 품목에서 복사 (Fallback)
if ($workOrder->salesOrder) {
$orderItems = $workOrder->salesOrder->items()->get();
foreach ($orderItems as $index => $orderItem) {
// 수주품목에서 층/부호 정보 조회
$floorUnit = $this->getFloorUnitFromOrderItem($orderItem->id, $tenantId);
// 출하 품목 생성
ShipmentItem::create([
'tenant_id' => $tenantId,
'shipment_id' => $shipment->id,
'seq' => $index + 1,
'item_code' => $orderItem->item_id ? "ITEM-{$orderItem->item_id}" : null,
'item_name' => $orderItem->item_name,
'floor_unit' => $floorUnit,
'specification' => $orderItem->specification,
'quantity' => $orderItem->quantity,
'unit' => $orderItem->unit,
'lot_no' => null, // 수주 품목에는 LOT 번호 없음
'remarks' => null,
]);
}
}
}
/**
* 수주품목에서 층/부호 정보 조회
*
* floor_code와 symbol_code를 조합하여 floor_unit 형식으로 반환합니다.
* 예: floor_code='3층', symbol_code='A호' → '3층/A호'
*/
private function getFloorUnitFromOrderItem(?int $orderItemId, int $tenantId): ?string
{
if (! $orderItemId) {
return null;
}
$orderItem = \App\Models\Orders\OrderItem::where('tenant_id', $tenantId)
->find($orderItemId);
if (! $orderItem) {
return null;
}
$parts = array_filter([
$orderItem->floor_code,
$orderItem->symbol_code,
]);
return ! empty($parts) ? implode('/', $parts) : null;
}
/**
* 작업지시 상태 변경 시 연결된 수주(Order) 상태 동기화
*
* 매핑 규칙:
* - WorkOrder::STATUS_IN_PROGRESS → Order::STATUS_IN_PRODUCTION (생산중)
* - WorkOrder::STATUS_COMPLETED → Order::STATUS_PRODUCED (생산완료)
* - WorkOrder::STATUS_SHIPPED → Order::STATUS_SHIPPED (출하완료)
*/
private function syncOrderStatus(WorkOrder $workOrder, int $tenantId): void
{
// 수주 연결이 없으면 스킵
if (! $workOrder->sales_order_id) {
return;
}
$order = Order::where('tenant_id', $tenantId)->find($workOrder->sales_order_id);
if (! $order) {
return;
}
// 작업지시 상태 → 수주 상태 매핑
$statusMap = [
WorkOrder::STATUS_IN_PROGRESS => Order::STATUS_IN_PRODUCTION,
WorkOrder::STATUS_COMPLETED => Order::STATUS_PRODUCED,
WorkOrder::STATUS_SHIPPED => Order::STATUS_SHIPPED,
];
$newOrderStatus = $statusMap[$workOrder->status] ?? null;
// 매핑되는 상태가 없거나 이미 동일한 상태면 스킵
if (! $newOrderStatus || $order->status_code === $newOrderStatus) {
return;
}
$oldOrderStatus = $order->status_code;
$order->status_code = $newOrderStatus;
$order->updated_by = $this->apiUserId();
$order->save();
// 수주 상태 동기화 감사 로그
$this->auditLogger->log(
$tenantId,
'order',
$order->id,
'status_synced_from_work_order',
['status_code' => $oldOrderStatus, 'work_order_id' => $workOrder->id],
['status_code' => $newOrderStatus, 'work_order_id' => $workOrder->id]
);
}
/**
* 작업지시 품목에 결과 데이터 저장
*/
@@ -803,6 +1024,102 @@ public function updateItemStatus(int $workOrderId, int $itemId, string $status)
];
}
/**
* 작업지시에 필요한 자재 목록 조회 (BOM 기반)
*
* 작업지시의 품목에 연결된 BOM 자재 목록을 반환합니다.
* 현재는 품목 정보 기반으로 Mock 데이터를 반환하며,
* 향후 자재 관리 기능 구현 시 실제 데이터로 연동됩니다.
*
* @param int $workOrderId 작업지시 ID
* @return array 자재 목록 (id, material_code, material_name, unit, current_stock, fifo_rank)
*/
public function getMaterials(int $workOrderId): array
{
$tenantId = $this->tenantId();
$workOrder = WorkOrder::where('tenant_id', $tenantId)
->with(['items.item', 'salesOrder.items'])
->find($workOrderId);
if (! $workOrder) {
throw new NotFoundHttpException(__('error.not_found'));
}
$materials = [];
$rank = 1;
// 1. WorkOrder 자체 items가 있으면 사용
if ($workOrder->items && $workOrder->items->count() > 0) {
foreach ($workOrder->items as $item) {
$materials[] = [
'id' => $item->id,
'material_code' => $item->item_id ? "MAT-{$item->item_id}" : "MAT-{$item->id}",
'material_name' => $item->item_name ?? '자재 '.$item->id,
'unit' => $item->unit ?? 'EA',
'current_stock' => 100, // Mock: 실제 재고 데이터 연동 필요
'fifo_rank' => $rank++,
];
}
}
// 2. WorkOrder items가 없으면 SalesOrder items 사용
elseif ($workOrder->salesOrder && $workOrder->salesOrder->items && $workOrder->salesOrder->items->count() > 0) {
foreach ($workOrder->salesOrder->items as $item) {
$materials[] = [
'id' => $item->id,
'material_code' => $item->item_id ? "MAT-{$item->item_id}" : "SO-{$item->id}",
'material_name' => $item->item_name ?? '자재 '.$item->id,
'unit' => $item->unit ?? 'EA',
'current_stock' => 100, // Mock: 실제 재고 데이터 연동 필요
'fifo_rank' => $rank++,
];
}
}
return $materials;
}
/**
* 자재 투입 등록
*
* 작업지시에 자재 투입을 등록합니다.
* 현재는 감사 로그만 기록하며, 향후 재고 차감 로직 추가 필요.
*
* @param int $workOrderId 작업지시 ID
* @param array $materialIds 투입할 자재 ID 목록
* @return array 투입 결과
*/
public function registerMaterialInput(int $workOrderId, array $materialIds): array
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$workOrder = WorkOrder::where('tenant_id', $tenantId)->find($workOrderId);
if (! $workOrder) {
throw new NotFoundHttpException(__('error.not_found'));
}
// 자재 투입 감사 로그
$this->auditLogger->log(
$tenantId,
self::AUDIT_TARGET,
$workOrderId,
'material_input',
null,
[
'material_ids' => $materialIds,
'input_by' => $userId,
'input_at' => now()->toDateTimeString(),
]
);
return [
'work_order_id' => $workOrderId,
'material_count' => count($materialIds),
'input_at' => now()->toDateTimeString(),
];
}
/**
* 품목 상태 기반으로 작업지시 상태 자동 동기화
*
@@ -872,6 +1189,12 @@ private function syncWorkOrderStatusFromItems(WorkOrder $workOrder): bool
['status' => $newStatus]
);
// 완료 시 수주 상태 동기화 및 자동 출하 생성
if ($newStatus === WorkOrder::STATUS_COMPLETED) {
$this->syncOrderStatus($workOrder, $workOrder->tenant_id);
$this->createShipmentFromWorkOrder($workOrder, $workOrder->tenant_id, $this->apiUserId());
}
return true;
}