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