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