feat(work-order): 품목 상태 변경 및 작업지시 상태 자동 연동

- WorkOrderItem 모델에 status 컬럼 및 상수 추가 (waiting/in_progress/completed)
- 품목 상태 변경 API 엔드포인트 추가 (PATCH /work-orders/{id}/items/{itemId}/status)
- syncWorkOrderStatusFromItems() 메서드로 품목→작업지시 상태 자동 동기화
  - 품목 중 하나라도 in_progress → 작업지시 in_progress
  - 모든 품목 completed → 작업지시 completed
  - 모든 품목 waiting → 작업지시 waiting
- 감사 로그: item_status_changed, status_synced_from_items 액션 추가

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-01-13 16:00:47 +09:00
parent 84ad9e1fc4
commit 38d56aa564
5 changed files with 224 additions and 18 deletions

View File

@@ -127,4 +127,14 @@ public function resolveIssue(int $workOrderId, int $issueId)
return $this->service->resolveIssue($workOrderId, $issueId);
}, __('message.work_order.issue_resolved'));
}
/**
* 품목 상태 변경
*/
public function updateItemStatus(Request $request, int $workOrderId, int $itemId)
{
return ApiResponse::handle(function () use ($request, $workOrderId, $itemId) {
return $this->service->updateItemStatus($workOrderId, $itemId, $request->input('status'));
}, __('message.work_order.item_status_updated'));
}
}

View File

@@ -25,6 +25,20 @@ class WorkOrderItem extends Model
'quantity',
'unit',
'sort_order',
'status',
];
/**
* 품목 상태 상수
*/
public const STATUS_WAITING = 'waiting';
public const STATUS_IN_PROGRESS = 'in_progress';
public const STATUS_COMPLETED = 'completed';
public const STATUSES = [
self::STATUS_WAITING,
self::STATUS_IN_PROGRESS,
self::STATUS_COMPLETED,
];
protected $casts = [

View File

@@ -5,6 +5,7 @@
use App\Models\Production\WorkOrder;
use App\Models\Production\WorkOrderAssignee;
use App\Models\Production\WorkOrderBendingDetail;
use App\Models\Production\WorkOrderItem;
use App\Services\Audit\AuditLogger;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
@@ -29,7 +30,7 @@ public function index(array $params)
$size = (int) ($params['size'] ?? 20);
$q = trim((string) ($params['q'] ?? ''));
$status = $params['status'] ?? null;
$processType = $params['process_type'] ?? null;
$processId = $params['process_id'] ?? null;
$assigneeId = $params['assignee_id'] ?? null;
$teamId = $params['team_id'] ?? null;
$scheduledFrom = $params['scheduled_from'] ?? null;
@@ -37,7 +38,7 @@ public function index(array $params)
$query = WorkOrder::query()
->where('tenant_id', $tenantId)
->with(['assignee:id,name', 'assignees.user:id,name', 'team:id,name', 'salesOrder:id,order_no']);
->with(['assignee:id,name', 'assignees.user:id,name', 'team:id,name', 'salesOrder:id,order_no', 'process:id,process_name,process_code']);
// 검색어
if ($q !== '') {
@@ -52,9 +53,9 @@ public function index(array $params)
$query->where('status', $status);
}
// 공정유형 필터
if ($processType !== null) {
$query->where('process_type', $processType);
// 공정 필터 (process_id)
if ($processId !== null) {
$query->where('process_id', $processId);
}
// 담당자 필터
@@ -116,7 +117,9 @@ public function show(int $id)
'assignee:id,name',
'assignees.user:id,name',
'team:id,name',
'salesOrder:id,order_no,project_name',
'salesOrder:id,order_no,site_name',
'salesOrder.client:id,name',
'process:id,process_name,process_code,work_steps',
'items',
'bendingDetail',
'issues' => fn ($q) => $q->orderByDesc('created_at'),
@@ -156,6 +159,9 @@ public function store(array $data)
$workOrder = WorkOrder::create($data);
// process 관계 로드 (isBending 체크용)
$workOrder->load('process:id,process_name,process_code');
// 품목 저장
foreach ($items as $index => $item) {
$item['tenant_id'] = $tenantId;
@@ -164,7 +170,7 @@ public function store(array $data)
}
// 벤딩 상세 저장 (벤딩 공정인 경우)
if ($data['process_type'] === WorkOrder::PROCESS_BENDING && $bendingDetail) {
if ($workOrder->isBending() && $bendingDetail) {
$bendingDetail['tenant_id'] = $tenantId;
$workOrder->bendingDetail()->create($bendingDetail);
}
@@ -179,7 +185,7 @@ public function store(array $data)
$workOrder->toArray()
);
return $workOrder->load(['assignee:id,name', 'assignees.user:id,name', 'team:id,name', 'items', 'bendingDetail']);
return $workOrder->load(['assignee:id,name', 'assignees.user:id,name', 'team:id,name', 'process:id,process_name,process_code', 'items', 'bendingDetail']);
});
}
@@ -191,7 +197,9 @@ public function update(int $id, array $data)
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$workOrder = WorkOrder::where('tenant_id', $tenantId)->find($id);
$workOrder = WorkOrder::where('tenant_id', $tenantId)
->with('process:id,process_name,process_code')
->find($id);
if (! $workOrder) {
throw new NotFoundHttpException(__('error.not_found'));
}
@@ -259,8 +267,8 @@ public function update(int $id, array $data)
);
}
// 벤딩 상세 업데이트
if ($bendingDetail !== null && $workOrder->process_type === WorkOrder::PROCESS_BENDING) {
// 벤딩 상세 업데이트 (벤딩 공정인 경우에만)
if ($bendingDetail !== null && $workOrder->isBending()) {
$bendingDetail['tenant_id'] = $workOrder->tenant_id;
$workOrder->bendingDetail()->updateOrCreate(
['work_order_id' => $workOrder->id],
@@ -278,7 +286,7 @@ public function update(int $id, array $data)
$workOrder->fresh()->toArray()
);
return $workOrder->load(['assignee:id,name', 'assignees.user:id,name', 'team:id,name', 'items', 'bendingDetail']);
return $workOrder->load(['assignee:id,name', 'assignees.user:id,name', 'team:id,name', 'process:id,process_name,process_code', 'items', 'bendingDetail']);
});
}
@@ -378,7 +386,7 @@ public function updateStatus(int $id, string $status)
['status' => $status]
);
return $workOrder->load(['assignee:id,name', 'assignees.user:id,name', 'team:id,name']);
return $workOrder->load(['assignee:id,name', 'assignees.user:id,name', 'team:id,name', 'process:id,process_name,process_code']);
}
/**
@@ -454,7 +462,7 @@ public function assign(int $id, array $data)
]
);
return $workOrder->load(['assignee:id,name', 'assignees.user:id,name', 'team:id,name']);
return $workOrder->load(['assignee:id,name', 'assignees.user:id,name', 'team:id,name', 'process:id,process_name,process_code']);
});
}
@@ -465,12 +473,14 @@ public function toggleBendingField(int $id, string $field)
{
$tenantId = $this->tenantId();
$workOrder = WorkOrder::where('tenant_id', $tenantId)->find($id);
$workOrder = WorkOrder::where('tenant_id', $tenantId)
->with('process:id,process_name,process_code')
->find($id);
if (! $workOrder) {
throw new NotFoundHttpException(__('error.not_found'));
}
if ($workOrder->process_type !== WorkOrder::PROCESS_BENDING) {
if (! $workOrder->isBending()) {
throw new BadRequestHttpException(__('error.work_order.not_bending_process'));
}
@@ -588,4 +598,131 @@ private function generateWorkOrderNo(int $tenantId): string
return sprintf('%s%s%04d', $prefix, $date, $seq);
}
/**
* 품목 상태 변경
*/
public function updateItemStatus(int $workOrderId, int $itemId, string $status)
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$workOrder = WorkOrder::where('tenant_id', $tenantId)->find($workOrderId);
if (! $workOrder) {
throw new NotFoundHttpException(__('error.not_found'));
}
$item = $workOrder->items()->find($itemId);
if (! $item) {
throw new NotFoundHttpException(__('error.not_found'));
}
// 상태 유효성 검증
if (! in_array($status, WorkOrderItem::STATUSES)) {
throw new BadRequestHttpException(__('error.invalid_status'));
}
$beforeStatus = $item->status;
$item->status = $status;
$item->save();
// 감사 로그
$this->auditLogger->log(
$tenantId,
self::AUDIT_TARGET,
$workOrderId,
'item_status_changed',
['item_id' => $itemId, 'status' => $beforeStatus],
['item_id' => $itemId, 'status' => $status]
);
// 작업지시 상태 자동 연동
$workOrderStatusChanged = $this->syncWorkOrderStatusFromItems($workOrder);
// 품목과 함께 작업지시 상태도 반환
return [
'item' => $item,
'work_order_status' => $workOrder->fresh()->status,
'work_order_status_changed' => $workOrderStatusChanged,
];
}
/**
* 품목 상태 기반으로 작업지시 상태 자동 동기화
*
* 규칙:
* - 품목 중 하나라도 in_progress → 작업지시 in_progress
* - 모든 품목이 completed → 작업지시 completed
* - 모든 품목이 waiting → 작업지시 waiting (단, waiting 이상인 경우만)
*
* @return bool 상태 변경 여부
*/
private function syncWorkOrderStatusFromItems(WorkOrder $workOrder): bool
{
// 품목이 없으면 동기화하지 않음
$items = $workOrder->items()->get();
if ($items->isEmpty()) {
return false;
}
// 작업 가능 상태가 아니면 동기화하지 않음 (unassigned, pending은 제외)
$syncableStatuses = [
WorkOrder::STATUS_WAITING,
WorkOrder::STATUS_IN_PROGRESS,
WorkOrder::STATUS_COMPLETED,
];
if (! in_array($workOrder->status, $syncableStatuses)) {
return false;
}
// 품목 상태 집계
$statusCounts = $items->groupBy('status')->map->count();
$totalItems = $items->count();
$waitingCount = $statusCounts->get(WorkOrderItem::STATUS_WAITING, 0);
$inProgressCount = $statusCounts->get(WorkOrderItem::STATUS_IN_PROGRESS, 0);
$completedCount = $statusCounts->get(WorkOrderItem::STATUS_COMPLETED, 0);
// 새 상태 결정
$newStatus = null;
if ($inProgressCount > 0) {
// 하나라도 진행중이면 작업지시도 진행중
$newStatus = WorkOrder::STATUS_IN_PROGRESS;
} elseif ($completedCount === $totalItems) {
// 모두 완료면 작업지시도 완료
$newStatus = WorkOrder::STATUS_COMPLETED;
} elseif ($waitingCount === $totalItems) {
// 모두 대기면 작업지시도 대기
$newStatus = WorkOrder::STATUS_WAITING;
}
// 상태가 변경되어야 하고, 현재와 다른 경우에만 업데이트
if ($newStatus && $newStatus !== $workOrder->status) {
$oldStatus = $workOrder->status;
$workOrder->status = $newStatus;
// 상태에 따른 타임스탬프 업데이트
if ($newStatus === WorkOrder::STATUS_IN_PROGRESS && ! $workOrder->started_at) {
$workOrder->started_at = now();
} elseif ($newStatus === WorkOrder::STATUS_COMPLETED) {
$workOrder->completed_at = now();
}
$workOrder->save();
// 상태 변경 감사 로그
$this->auditLogger->log(
$workOrder->tenant_id,
self::AUDIT_TARGET,
$workOrder->id,
'status_synced_from_items',
['status' => $oldStatus],
['status' => $newStatus]
);
return true;
}
return false;
}
}