feat: 견적확정 밸리데이션, 작업지시 통계 공정별 카운트, 입고/재고 개선

- 견적확정 시 업체명/현장명/담당자/연락처 필수 검증 추가 (QuoteService)
- 작업지시 stats API에 by_process 공정별 카운트 반환 추가
- 작업지시 목록/상세 쿼리에 수주 개소(rootNodes) 연관 로딩
- 작업지시 품목에 sourceOrderItem.node 관계 추가
- 입고관리 완료건 수정 허용 및 재고 차이 조정
- work_order_step_progress 테이블 마이그레이션
- receivings 테이블 options 컬럼 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-07 03:27:07 +09:00
parent 6b3e5c3e87
commit 487e651845
22 changed files with 1422 additions and 72 deletions

View File

@@ -7,6 +7,7 @@
use App\Models\Production\WorkOrderAssignee;
use App\Models\Production\WorkOrderBendingDetail;
use App\Models\Production\WorkOrderItem;
use App\Models\Production\WorkOrderStepProgress;
use App\Models\Tenants\Shipment;
use App\Models\Tenants\ShipmentItem;
use App\Services\Audit\AuditLogger;
@@ -37,6 +38,7 @@ public function index(array $params)
$processCode = $params['process_code'] ?? null;
$assigneeId = $params['assignee_id'] ?? null;
$assignedToMe = isset($params['assigned_to_me']) && $params['assigned_to_me'];
$workerScreen = isset($params['worker_screen']) && $params['worker_screen'];
$teamId = $params['team_id'] ?? null;
$scheduledFrom = $params['scheduled_from'] ?? null;
$scheduledTo = $params['scheduled_to'] ?? null;
@@ -47,10 +49,12 @@ public function index(array $params)
'assignee:id,name',
'assignees.user:id,name',
'team:id,name',
'salesOrder:id,order_no,client_id,client_name',
'salesOrder' => fn ($q) => $q->select('id', 'order_no', 'client_id', 'client_name', 'site_name', 'quantity', 'received_at', 'delivery_date')->withCount('rootNodes'),
'salesOrder.client:id,name',
'process:id,process_name,process_code,department',
'items:id,work_order_id,item_name,quantity',
'items:id,work_order_id,item_id,item_name,specification,quantity,unit,status,options,sort_order,source_order_item_id',
'items.sourceOrderItem:id,order_node_id',
'items.sourceOrderItem.node:id,name,code',
]);
// 검색어
@@ -96,6 +100,35 @@ public function index(array $params)
});
}
// 작업자 화면용 캐스케이드 필터: 개인 배정 → 부서 → 전체
if ($workerScreen) {
$userId = $this->apiUserId();
// 1차: 개인 배정된 작업이 있는지 확인
$hasPersonal = (clone $query)->where(function ($q) use ($userId) {
$q->where('assignee_id', $userId)
->orWhereHas('assignees', fn ($aq) => $aq->where('user_id', $userId));
})->exists();
if ($hasPersonal) {
$query->where(function ($q) use ($userId) {
$q->where('assignee_id', $userId)
->orWhereHas('assignees', fn ($aq) => $aq->where('user_id', $userId));
});
} else {
// 2차: 사용자 소속 부서의 작업지시 필터
$departmentIds = DB::table('department_user')
->where('user_id', $userId)
->where('tenant_id', $tenantId)
->pluck('department_id');
if ($departmentIds->isNotEmpty()) {
$query->whereIn('team_id', $departmentIds);
}
// 3차: 부서도 없으면 필터 없이 전체 노출
}
}
// 팀 필터
if ($teamId !== null) {
$query->where('team_id', $teamId);
@@ -127,14 +160,34 @@ public function stats(): array
->pluck('count', 'status')
->toArray();
// 공정별 카운트 (탭 숫자 표시용)
$byProcess = WorkOrder::where('tenant_id', $tenantId)
->select('process_id', DB::raw('count(*) as count'))
->groupBy('process_id')
->pluck('count', 'process_id')
->toArray();
$total = array_sum($counts);
$noneCount = $byProcess[''] ?? $byProcess[0] ?? 0;
// null 키는 빈 문자열로 변환되므로 별도 처리
$processedByProcess = [];
foreach ($byProcess as $key => $count) {
if ($key === '' || $key === 0 || $key === null) {
$processedByProcess['none'] = $count;
} else {
$processedByProcess[(string) $key] = $count;
}
}
return [
'total' => array_sum($counts),
'total' => $total,
'unassigned' => $counts[WorkOrder::STATUS_UNASSIGNED] ?? 0,
'pending' => $counts[WorkOrder::STATUS_PENDING] ?? 0,
'waiting' => $counts[WorkOrder::STATUS_WAITING] ?? 0,
'in_progress' => $counts[WorkOrder::STATUS_IN_PROGRESS] ?? 0,
'completed' => $counts[WorkOrder::STATUS_COMPLETED] ?? 0,
'shipped' => $counts[WorkOrder::STATUS_SHIPPED] ?? 0,
'by_process' => $processedByProcess,
];
}
@@ -150,13 +203,16 @@ public function show(int $id)
'assignee:id,name',
'assignees.user:id,name',
'team:id,name',
'salesOrder:id,order_no,site_name,client_id,client_contact,received_at,writer_id,created_at,quantity',
'salesOrder' => fn ($q) => $q->select('id', 'order_no', 'site_name', 'client_id', 'client_contact', 'received_at', 'writer_id', 'created_at', 'quantity')->withCount('rootNodes'),
'salesOrder.client:id,name',
'salesOrder.writer:id,name',
'process:id,process_name,process_code,work_steps,department',
'items',
'process.steps' => fn ($q) => $q->where('is_active', true)->orderBy('sort_order'),
'items.sourceOrderItem:id,order_node_id',
'items.sourceOrderItem.node:id,name,code',
'bendingDetail',
'issues' => fn ($q) => $q->orderByDesc('created_at'),
'stepProgress.processStep:id,process_id,step_code,step_name,sort_order,needs_inspection,connection_type,completion_type',
])
->find($id);
@@ -1291,4 +1347,146 @@ private function syncWorkOrderStatusFromItems(WorkOrder $workOrder): bool
return false;
}
// ──────────────────────────────────────────────────────────────
// 공정 단계 진행 관리
// ──────────────────────────────────────────────────────────────
/**
* 작업지시의 공정 단계 진행 현황 조회
*
* process_steps 마스터 기준으로 진행 레코드를 자동 생성(없으면)하고 반환
*/
public function getStepProgress(int $workOrderId): array
{
$tenantId = $this->tenantId();
$workOrder = WorkOrder::where('tenant_id', $tenantId)
->with(['process.steps' => fn ($q) => $q->where('is_active', true)->orderBy('sort_order')])
->find($workOrderId);
if (! $workOrder) {
throw new NotFoundHttpException(__('error.not_found'));
}
$processSteps = $workOrder->process?->steps ?? collect();
if ($processSteps->isEmpty()) {
return [];
}
// 기존 진행 레코드 조회
$existingProgress = WorkOrderStepProgress::where('work_order_id', $workOrderId)
->whereNull('work_order_item_id')
->get()
->keyBy('process_step_id');
// 없는 단계는 자동 생성
$result = [];
foreach ($processSteps as $step) {
if ($existingProgress->has($step->id)) {
$progress = $existingProgress->get($step->id);
} else {
$progress = WorkOrderStepProgress::create([
'tenant_id' => $tenantId,
'work_order_id' => $workOrderId,
'process_step_id' => $step->id,
'work_order_item_id' => null,
'status' => WorkOrderStepProgress::STATUS_WAITING,
]);
}
$result[] = [
'id' => $progress->id,
'process_step_id' => $step->id,
'step_code' => $step->step_code,
'step_name' => $step->step_name,
'sort_order' => $step->sort_order,
'needs_inspection' => $step->needs_inspection,
'connection_type' => $step->connection_type,
'completion_type' => $step->completion_type,
'status' => $progress->status,
'is_completed' => $progress->isCompleted(),
'completed_at' => $progress->completed_at?->toDateTimeString(),
'completed_by' => $progress->completed_by,
];
}
return $result;
}
/**
* 공정 단계 완료 토글
*/
public function toggleStepProgress(int $workOrderId, int $progressId): array
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$workOrder = WorkOrder::where('tenant_id', $tenantId)->find($workOrderId);
if (! $workOrder) {
throw new NotFoundHttpException(__('error.not_found'));
}
$progress = WorkOrderStepProgress::where('id', $progressId)
->where('work_order_id', $workOrderId)
->first();
if (! $progress) {
throw new NotFoundHttpException(__('error.not_found'));
}
$before = ['status' => $progress->status];
$progress->toggle($userId);
$after = ['status' => $progress->status];
$this->auditLogger->log(
$tenantId,
self::AUDIT_TARGET,
$workOrderId,
'step_progress_toggled',
$before,
$after
);
return [
'id' => $progress->id,
'status' => $progress->status,
'is_completed' => $progress->isCompleted(),
'completed_at' => $progress->completed_at?->toDateTimeString(),
'completed_by' => $progress->completed_by,
];
}
/**
* 자재 투입 이력 조회
*/
public function getMaterialInputHistory(int $workOrderId): array
{
$tenantId = $this->tenantId();
$workOrder = WorkOrder::where('tenant_id', $tenantId)->find($workOrderId);
if (! $workOrder) {
throw new NotFoundHttpException(__('error.not_found'));
}
// audit_logs에서 material_input 액션 이력 조회
$logs = DB::table('audit_logs')
->where('tenant_id', $tenantId)
->where('target_type', self::AUDIT_TARGET)
->where('target_id', $workOrderId)
->where('action', 'material_input')
->orderByDesc('created_at')
->get();
return $logs->map(function ($log) {
$after = json_decode($log->after_data ?? '{}', true);
return [
'id' => $log->id,
'materials' => $after['materials'] ?? [],
'created_at' => $log->created_at,
'actor_id' => $log->actor_id,
];
})->toArray();
}
}