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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user