Files
sam-api/app/Services/WorkOrderService.php
권혁성 ee6794be1a feat: [생산관리] 중간검사 데이터 저장/조회 API 구현
- POST /work-orders/{id}/items/{itemId}/inspection: 품목별 검사 데이터 저장
- GET /work-orders/{id}/inspection-data: 전체 품목 검사 데이터 조회
- GET /work-orders/{id}/inspection-report: 검사 성적서용 데이터 조회
- WorkOrderItem 모델에 getInspectionData/setInspectionData 헬퍼 추가
- StoreItemInspectionRequest FormRequest 생성
- work_order_items.options['inspection_data']에 검사 결과 저장

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 09:00:57 +09:00

1670 lines
61 KiB
PHP

<?php
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\Production\WorkOrderStepProgress;
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;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class WorkOrderService extends Service
{
private const AUDIT_TARGET = 'work_order';
public function __construct(
private readonly AuditLogger $auditLogger
) {}
/**
* 목록 조회 (검색/필터링/페이징)
*/
public function index(array $params)
{
$tenantId = $this->tenantId();
$page = (int) ($params['page'] ?? 1);
$size = (int) ($params['size'] ?? 20);
$q = trim((string) ($params['q'] ?? ''));
$status = $params['status'] ?? null;
$processId = $params['process_id'] ?? null;
$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;
$query = WorkOrder::query()
->where('tenant_id', $tenantId)
->with([
'assignee:id,name',
'assignees.user:id,name',
'team:id,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_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',
]);
// 검색어
if ($q !== '') {
$query->where(function ($qq) use ($q) {
$qq->where('work_order_no', 'like', "%{$q}%")
->orWhere('project_name', 'like', "%{$q}%");
});
}
// 상태 필터
if ($status !== null) {
$query->where('status', $status);
}
// 공정 필터 (process_id)
// - 'none' 또는 '0': 공정 미지정 (process_id IS NULL)
// - 숫자: 해당 공정 ID로 필터
if ($processId !== null) {
if ($processId === 'none' || $processId === '0' || $processId === 0) {
$query->whereNull('process_id');
} else {
$query->where('process_id', $processId);
}
}
// 공정 코드 필터 (process_code) - 대시보드용
if ($processCode !== null) {
$query->whereHas('process', fn ($q) => $q->where('process_code', $processCode));
}
// 담당자 필터
if ($assigneeId !== null) {
$query->where('assignee_id', $assigneeId);
}
// 나에게 배정된 작업만 필터 (주 담당자 또는 공동 담당자)
if ($assignedToMe) {
$userId = $this->apiUserId();
$query->where(function ($q) use ($userId) {
$q->where('assignee_id', $userId)
->orWhereHas('assignees', fn ($aq) => $aq->where('user_id', $userId));
});
}
// 작업자 화면용 캐스케이드 필터: 개인 배정 → 부서 → 전체
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);
}
// 예정일 범위
if ($scheduledFrom !== null) {
$query->where('scheduled_date', '>=', $scheduledFrom);
}
if ($scheduledTo !== null) {
$query->where('scheduled_date', '<=', $scheduledTo);
}
$query->orderByDesc('created_at');
return $query->paginate($size, ['*'], 'page', $page);
}
/**
* 통계 조회
*/
public function stats(): array
{
$tenantId = $this->tenantId();
$counts = WorkOrder::where('tenant_id', $tenantId)
->select('status', DB::raw('count(*) as count'))
->groupBy('status')
->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' => $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,
];
}
/**
* 단건 조회
*/
public function show(int $id)
{
$tenantId = $this->tenantId();
$workOrder = WorkOrder::where('tenant_id', $tenantId)
->with([
'assignee:id,name',
'assignees.user:id,name',
'team:id,name',
'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',
'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);
if (! $workOrder) {
throw new NotFoundHttpException(__('error.not_found'));
}
return $workOrder;
}
/**
* 생성
*/
public function store(array $data)
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
return DB::transaction(function () use ($data, $tenantId, $userId) {
// 작업지시번호 자동 생성
$data['work_order_no'] = $this->generateWorkOrderNo($tenantId);
$data['tenant_id'] = $tenantId;
$data['created_by'] = $userId;
$data['updated_by'] = $userId;
// 담당자가 있으면 상태를 pending으로
if (! empty($data['assignee_id'])) {
$data['status'] = $data['status'] ?? WorkOrder::STATUS_PENDING;
}
$items = $data['items'] ?? [];
$bendingDetail = $data['bending_detail'] ?? null;
$salesOrderId = $data['sales_order_id'] ?? null;
unset($data['items'], $data['bending_detail']);
$workOrder = WorkOrder::create($data);
// process 관계 로드 (isBending 체크용)
$workOrder->load('process:id,process_name,process_code');
// 품목 저장: 직접 전달된 품목이 없고 수주 ID가 있으면 수주에서 복사
if (empty($items) && $salesOrderId) {
$salesOrder = \App\Models\Orders\Order::with('items')->find($salesOrderId);
if ($salesOrder && $salesOrder->items->isNotEmpty()) {
foreach ($salesOrder->items as $index => $orderItem) {
$workOrder->items()->create([
'tenant_id' => $tenantId,
'source_order_item_id' => $orderItem->id, // 원본 수주 품목 추적용
'item_id' => $orderItem->item_id,
'item_name' => $orderItem->item_name,
'specification' => $orderItem->specification,
'quantity' => $orderItem->quantity,
'unit' => $orderItem->unit,
'sort_order' => $index,
]);
}
}
} else {
// 직접 전달된 품목 저장
foreach ($items as $index => $item) {
$item['tenant_id'] = $tenantId;
$item['sort_order'] = $index;
$workOrder->items()->create($item);
}
}
// 벤딩 상세 저장 (벤딩 공정인 경우)
if ($workOrder->isBending() && $bendingDetail) {
$bendingDetail['tenant_id'] = $tenantId;
$workOrder->bendingDetail()->create($bendingDetail);
}
// 감사 로그
$this->auditLogger->log(
$tenantId,
self::AUDIT_TARGET,
$workOrder->id,
'created',
null,
$workOrder->toArray()
);
return $workOrder->load(['assignee:id,name', 'assignees.user:id,name', 'team:id,name', 'process:id,process_name,process_code', 'items', 'bendingDetail']);
});
}
/**
* 수정
*/
public function update(int $id, array $data)
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$workOrder = WorkOrder::where('tenant_id', $tenantId)
->with('process:id,process_name,process_code')
->find($id);
if (! $workOrder) {
throw new NotFoundHttpException(__('error.not_found'));
}
$beforeData = $workOrder->toArray();
return DB::transaction(function () use ($workOrder, $data, $userId, $beforeData) {
$data['updated_by'] = $userId;
$items = $data['items'] ?? null;
$bendingDetail = $data['bending_detail'] ?? null;
$assigneeIds = $data['assignee_ids'] ?? null;
unset($data['items'], $data['bending_detail'], $data['assignee_ids'], $data['work_order_no']); // 번호 변경 불가
// 품목 수정 시 기존 품목 기록
$oldItems = null;
if ($items !== null) {
$oldItems = $workOrder->items()->get()->toArray();
}
// 담당자 수정 시 기존 담당자 기록
$oldAssignees = null;
if ($assigneeIds !== null) {
$oldAssignees = $workOrder->assignees()->pluck('user_id')->toArray();
}
$workOrder->update($data);
// 담당자 처리 (assignee_ids 배열)
if ($assigneeIds !== null) {
$assigneeIds = array_unique(array_filter($assigneeIds));
// 기존 담당자 삭제 후 새로 추가
$workOrder->assignees()->delete();
foreach ($assigneeIds as $index => $assigneeId) {
WorkOrderAssignee::create([
'tenant_id' => $workOrder->tenant_id,
'work_order_id' => $workOrder->id,
'user_id' => $assigneeId,
'is_primary' => $index === 0, // 첫 번째가 주 담당자
]);
}
// 주 담당자는 work_orders 테이블에도 설정 (하위 호환)
$primaryAssigneeId = $assigneeIds[0] ?? null;
$workOrder->assignee_id = $primaryAssigneeId;
$workOrder->save();
// 담당자 수정 감사 로그
$this->auditLogger->log(
$workOrder->tenant_id,
self::AUDIT_TARGET,
$workOrder->id,
'assignees_updated',
['assignee_ids' => $oldAssignees],
['assignee_ids' => $assigneeIds]
);
}
// 품목 부분 수정 (ID 기반 upsert/delete)
if ($items !== null) {
$existingIds = $workOrder->items()->pluck('id')->toArray();
$incomingIds = [];
foreach ($items as $index => $item) {
$itemData = [
'tenant_id' => $workOrder->tenant_id,
'item_name' => $item['item_name'] ?? null,
'specification' => $item['specification'] ?? null,
'quantity' => $item['quantity'] ?? 1,
'unit' => $item['unit'] ?? null,
'sort_order' => $index,
];
if (isset($item['id']) && $item['id']) {
// ID가 있으면 업데이트
$existingItem = $workOrder->items()->find($item['id']);
if ($existingItem) {
$existingItem->update($itemData);
$incomingIds[] = (int) $item['id'];
}
} else {
// ID가 없으면 신규 생성
$newItem = $workOrder->items()->create($itemData);
$incomingIds[] = $newItem->id;
}
}
// 요청에 없는 기존 품목 삭제
$toDelete = array_diff($existingIds, $incomingIds);
if (! empty($toDelete)) {
$workOrder->items()->whereIn('id', $toDelete)->delete();
}
// 품목 수정 감사 로그
$this->auditLogger->log(
$workOrder->tenant_id,
self::AUDIT_TARGET,
$workOrder->id,
'items_updated',
['items' => $oldItems],
['items' => $workOrder->items()->get()->toArray()]
);
}
// 벤딩 상세 업데이트 (벤딩 공정인 경우에만)
if ($bendingDetail !== null && $workOrder->isBending()) {
$bendingDetail['tenant_id'] = $workOrder->tenant_id;
$workOrder->bendingDetail()->updateOrCreate(
['work_order_id' => $workOrder->id],
$bendingDetail
);
}
// 수정 감사 로그
$this->auditLogger->log(
$workOrder->tenant_id,
self::AUDIT_TARGET,
$workOrder->id,
'updated',
$beforeData,
$workOrder->fresh()->toArray()
);
return $workOrder->load(['assignee:id,name', 'assignees.user:id,name', 'team:id,name', 'process:id,process_name,process_code', 'items', 'bendingDetail']);
});
}
/**
* 삭제
*/
public function destroy(int $id)
{
$tenantId = $this->tenantId();
$workOrder = WorkOrder::where('tenant_id', $tenantId)->find($id);
if (! $workOrder) {
throw new NotFoundHttpException(__('error.not_found'));
}
// 진행 중이거나 완료된 작업은 삭제 불가
if (in_array($workOrder->status, [
WorkOrder::STATUS_IN_PROGRESS,
WorkOrder::STATUS_COMPLETED,
WorkOrder::STATUS_SHIPPED,
])) {
throw new BadRequestHttpException(__('error.work_order.cannot_delete_in_progress'));
}
$beforeData = $workOrder->toArray();
$workOrder->delete();
// 삭제 감사 로그
$this->auditLogger->log(
$tenantId,
self::AUDIT_TARGET,
$workOrder->id,
'deleted',
$beforeData,
null
);
return 'success';
}
/**
* 상태 변경
*
* @param int $id 작업지시 ID
* @param string $status 변경할 상태
* @param array|null $resultData 완료 시 결과 데이터 (선택)
*/
public function updateStatus(int $id, string $status, ?array $resultData = null)
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$workOrder = WorkOrder::where('tenant_id', $tenantId)->find($id);
if (! $workOrder) {
throw new NotFoundHttpException(__('error.not_found'));
}
// 상태 유효성 검증
if (! in_array($status, WorkOrder::STATUSES)) {
throw new BadRequestHttpException(__('error.invalid_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', [
'from' => $workOrder->status,
'to' => $status,
'allowed' => $allowed,
])
);
}
return DB::transaction(function () use ($workOrder, $status, $resultData, $tenantId, $userId) {
$oldStatus = $workOrder->status;
$workOrder->status = $status;
$workOrder->updated_by = $userId;
// 상태에 따른 타임스탬프 업데이트
switch ($status) {
case WorkOrder::STATUS_IN_PROGRESS:
$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);
break;
case WorkOrder::STATUS_SHIPPED:
$workOrder->shipped_at = now();
break;
}
$workOrder->save();
// 상태 변경 감사 로그
$this->auditLogger->log(
$tenantId,
self::AUDIT_TARGET,
$workOrder->id,
'status_changed',
['status' => $oldStatus],
['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]
);
}
/**
* 작업지시 품목에 결과 데이터 저장
*/
private function saveItemResults(WorkOrder $workOrder, ?array $resultData, int $userId): void
{
$items = $workOrder->items;
$lotNo = $this->generateLotNo($workOrder);
foreach ($items as $item) {
$itemResult = [
'completed_at' => now()->toDateTimeString(),
'good_qty' => $item->quantity, // 기본값: 지시수량 전체가 양품
'defect_qty' => 0,
'defect_rate' => 0,
'lot_no' => $lotNo,
'is_inspected' => false,
'is_packaged' => false,
'worker_id' => $userId,
'memo' => null,
];
// 개별 품목 결과 데이터가 있으면 병합
if ($resultData && isset($resultData['items'][$item->id])) {
$itemResult = array_merge($itemResult, $resultData['items'][$item->id]);
// 불량률 재계산
$totalQty = ($itemResult['good_qty'] ?? 0) + ($itemResult['defect_qty'] ?? 0);
$itemResult['defect_rate'] = $totalQty > 0
? round(($itemResult['defect_qty'] / $totalQty) * 100, 2)
: 0;
}
// 품목 상태도 완료로 변경
$item->status = WorkOrderItem::STATUS_COMPLETED;
$options = $item->options ?? [];
$options['result'] = $itemResult;
$item->options = $options;
$item->save();
}
}
/**
* LOT 번호 생성
*/
private function generateLotNo(WorkOrder $workOrder): string
{
$date = now()->format('ymd');
$prefix = 'KD-SA';
// 오늘 날짜의 마지막 LOT 번호 조회
$lastLotNo = WorkOrderItem::where('tenant_id', $workOrder->tenant_id)
->whereNotNull('options->result->lot_no')
->where('options->result->lot_no', 'like', "{$prefix}-{$date}-%")
->orderByDesc('id')
->value('options->result->lot_no');
if ($lastLotNo) {
// 마지막 번호에서 시퀀스 추출 후 증가
$parts = explode('-', $lastLotNo);
$seq = (int) end($parts) + 1;
} else {
$seq = 1;
}
return sprintf('%s-%s-%02d', $prefix, $date, $seq);
}
/**
* 담당자 배정 (다중 담당자 지원)
*
* @param int $id 작업지시 ID
* @param array $data 배정 데이터 (assignee_ids: int[], team_id?: int)
*/
public function assign(int $id, array $data)
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$workOrder = WorkOrder::where('tenant_id', $tenantId)->find($id);
if (! $workOrder) {
throw new NotFoundHttpException(__('error.not_found'));
}
return DB::transaction(function () use ($workOrder, $data, $tenantId, $userId) {
// 이전 상태 기록
$beforeAssignees = $workOrder->assignees()->pluck('user_id')->toArray();
$beforePrimaryAssignee = $workOrder->assignee_id;
$beforeTeam = $workOrder->team_id;
// 담당자 ID 배열 처리 (단일 값도 배열로 변환)
$assigneeIds = $data['assignee_ids'] ?? [];
if (isset($data['assignee_id']) && ! empty($data['assignee_id'])) {
// 하위 호환: 단일 assignee_id도 지원
$assigneeIds = is_array($data['assignee_id']) ? $data['assignee_id'] : [$data['assignee_id']];
}
$assigneeIds = array_unique(array_filter($assigneeIds));
// 기존 담당자 삭제 후 새로 추가
$workOrder->assignees()->delete();
foreach ($assigneeIds as $index => $assigneeId) {
WorkOrderAssignee::create([
'tenant_id' => $tenantId,
'work_order_id' => $workOrder->id,
'user_id' => $assigneeId,
'is_primary' => $index === 0, // 첫 번째가 주 담당자
]);
}
// 주 담당자는 work_orders 테이블에도 설정 (하위 호환)
$primaryAssigneeId = $assigneeIds[0] ?? null;
$workOrder->assignee_id = $primaryAssigneeId;
$workOrder->team_id = $data['team_id'] ?? $workOrder->team_id;
$workOrder->updated_by = $userId;
// 미배정이었으면 대기로 변경
if ($workOrder->status === WorkOrder::STATUS_UNASSIGNED && $primaryAssigneeId) {
$workOrder->status = WorkOrder::STATUS_PENDING;
}
$workOrder->save();
// 배정 감사 로그
$this->auditLogger->log(
$tenantId,
self::AUDIT_TARGET,
$workOrder->id,
'assigned',
[
'assignee_id' => $beforePrimaryAssignee,
'assignee_ids' => $beforeAssignees,
'team_id' => $beforeTeam,
],
[
'assignee_id' => $workOrder->assignee_id,
'assignee_ids' => $assigneeIds,
'team_id' => $workOrder->team_id,
]
);
return $workOrder->load(['assignee:id,name', 'assignees.user:id,name', 'team:id,name', 'process:id,process_name,process_code']);
});
}
/**
* 벤딩 항목 토글
*/
public function toggleBendingField(int $id, string $field)
{
$tenantId = $this->tenantId();
$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->isBending()) {
throw new BadRequestHttpException(__('error.work_order.not_bending_process'));
}
$detail = $workOrder->bendingDetail;
if (! $detail) {
$detail = $workOrder->bendingDetail()->create([
'tenant_id' => $workOrder->tenant_id,
]);
}
if (! in_array($field, WorkOrderBendingDetail::PROCESS_FIELDS)) {
throw new BadRequestHttpException(__('error.invalid_field'));
}
$beforeValue = $detail->{$field};
$detail->toggleField($field);
// 벤딩 토글 감사 로그
$this->auditLogger->log(
$workOrder->tenant_id,
self::AUDIT_TARGET,
$workOrder->id,
'bending_toggled',
[$field => $beforeValue],
[$field => $detail->{$field}]
);
return $detail;
}
/**
* 이슈 추가
*/
public function addIssue(int $workOrderId, array $data)
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$workOrder = WorkOrder::where('tenant_id', $tenantId)->find($workOrderId);
if (! $workOrder) {
throw new NotFoundHttpException(__('error.not_found'));
}
$data['tenant_id'] = $tenantId;
$data['reported_by'] = $userId;
$issue = $workOrder->issues()->create($data);
// 이슈 추가 감사 로그
$this->auditLogger->log(
$tenantId,
self::AUDIT_TARGET,
$workOrderId,
'issue_added',
null,
['issue_id' => $issue->id, 'title' => $issue->title, 'priority' => $issue->priority]
);
return $issue;
}
/**
* 이슈 해결
*/
public function resolveIssue(int $workOrderId, int $issueId)
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$workOrder = WorkOrder::where('tenant_id', $tenantId)->find($workOrderId);
if (! $workOrder) {
throw new NotFoundHttpException(__('error.not_found'));
}
$issue = $workOrder->issues()->find($issueId);
if (! $issue) {
throw new NotFoundHttpException(__('error.not_found'));
}
$issue->resolve($userId);
// 이슈 해결 감사 로그
$this->auditLogger->log(
$tenantId,
self::AUDIT_TARGET,
$workOrderId,
'issue_resolved',
['issue_id' => $issueId, 'status' => 'open'],
['issue_id' => $issueId, 'status' => 'resolved', 'resolved_by' => $userId]
);
return $issue;
}
/**
* 작업지시번호 자동 생성
*/
private function generateWorkOrderNo(int $tenantId): string
{
$prefix = 'WO';
$date = now()->format('Ymd');
// 오늘 날짜 기준 마지막 번호 조회
$lastNo = WorkOrder::withoutGlobalScopes()
->where('tenant_id', $tenantId)
->where('work_order_no', 'like', "{$prefix}{$date}%")
->orderByDesc('work_order_no')
->value('work_order_no');
if ($lastNo) {
$seq = (int) substr($lastNo, -4) + 1;
} else {
$seq = 1;
}
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,
];
}
/**
* 작업지시에 필요한 자재 목록 조회 (BOM 기반 + 로트별 재고)
*
* 작업지시 품목의 BOM 자재별로 StockLot(입고 로트)를 FIFO 순서로 반환합니다.
* 로트번호는 입고관리(Receiving)에서 생성된 실제 로트번호입니다.
*
* @param int $workOrderId 작업지시 ID
* @return array 자재 목록 (로트 단위)
*/
public function getMaterials(int $workOrderId): array
{
$tenantId = $this->tenantId();
$workOrder = WorkOrder::where('tenant_id', $tenantId)
->with(['items.item'])
->find($workOrderId);
if (! $workOrder) {
throw new NotFoundHttpException(__('error.not_found'));
}
$materials = [];
$rank = 1;
foreach ($workOrder->items as $woItem) {
$materialItems = [];
// BOM이 있으면 자식 품목들을 자재로 사용
if ($woItem->item_id) {
$item = \App\Models\Items\Item::where('tenant_id', $tenantId)
->find($woItem->item_id);
if ($item && ! empty($item->bom)) {
foreach ($item->bom as $bomItem) {
$childItemId = $bomItem['child_item_id'] ?? null;
$bomQty = (float) ($bomItem['qty'] ?? 1);
if (! $childItemId) {
continue;
}
$childItem = \App\Models\Items\Item::where('tenant_id', $tenantId)
->find($childItemId);
if (! $childItem) {
continue;
}
$materialItems[] = [
'item' => $childItem,
'bom_qty' => $bomQty,
'required_qty' => $bomQty * ($woItem->quantity ?? 1),
'work_order_item_id' => $woItem->id,
];
}
}
}
// BOM이 없으면 품목 자체를 자재로 사용
if (empty($materialItems) && $woItem->item_id && $woItem->item) {
$materialItems[] = [
'item' => $woItem->item,
'bom_qty' => 1,
'required_qty' => $woItem->quantity ?? 1,
'work_order_item_id' => $woItem->id,
];
}
// 각 자재별로 StockLot(입고 로트) 조회
foreach ($materialItems as $matInfo) {
$materialItem = $matInfo['item'];
// Stock 조회
$stock = \App\Models\Tenants\Stock::where('tenant_id', $tenantId)
->where('item_id', $materialItem->id)
->first();
if ($stock) {
// 가용 로트를 FIFO 순서로 조회
$lots = \App\Models\Tenants\StockLot::where('tenant_id', $tenantId)
->where('stock_id', $stock->id)
->where('status', 'available')
->where('available_qty', '>', 0)
->orderBy('fifo_order', 'asc')
->get();
foreach ($lots as $lot) {
$materials[] = [
'stock_lot_id' => $lot->id,
'item_id' => $materialItem->id,
'work_order_item_id' => $matInfo['work_order_item_id'],
'lot_no' => $lot->lot_no,
'material_code' => $materialItem->code,
'material_name' => $materialItem->name,
'specification' => $materialItem->specification,
'unit' => $lot->unit ?? $materialItem->unit ?? 'EA',
'bom_qty' => $matInfo['bom_qty'],
'required_qty' => $matInfo['required_qty'],
'lot_qty' => (float) $lot->qty,
'lot_available_qty' => (float) $lot->available_qty,
'lot_reserved_qty' => (float) $lot->reserved_qty,
'receipt_date' => $lot->receipt_date,
'supplier' => $lot->supplier,
'fifo_rank' => $rank++,
];
}
}
// 가용 로트가 없는 경우 자재 정보만 반환 (재고 없음 표시)
$hasLots = collect($materials)->where('item_id', $materialItem->id)->isNotEmpty();
if (! $hasLots) {
$materials[] = [
'stock_lot_id' => null,
'item_id' => $materialItem->id,
'work_order_item_id' => $matInfo['work_order_item_id'],
'lot_no' => null,
'material_code' => $materialItem->code,
'material_name' => $materialItem->name,
'specification' => $materialItem->specification,
'unit' => $materialItem->unit ?? 'EA',
'bom_qty' => $matInfo['bom_qty'],
'required_qty' => $matInfo['required_qty'],
'lot_qty' => 0,
'lot_available_qty' => 0,
'lot_reserved_qty' => 0,
'receipt_date' => null,
'supplier' => null,
'fifo_rank' => $rank++,
];
}
}
}
return $materials;
}
/**
* 자재 투입 등록 (로트 지정 차감)
*
* 사용자가 선택한 로트별로 지정 수량을 차감합니다.
*
* @param int $workOrderId 작업지시 ID
* @param array $inputs 투입 목록 [['stock_lot_id' => int, 'qty' => float], ...]
* @return array 투입 결과
*
* @throws \Exception 재고 부족 시
*/
public function registerMaterialInput(int $workOrderId, array $inputs): array
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$workOrder = WorkOrder::where('tenant_id', $tenantId)->find($workOrderId);
if (! $workOrder) {
throw new NotFoundHttpException(__('error.not_found'));
}
return DB::transaction(function () use ($inputs, $tenantId, $userId, $workOrderId) {
$stockService = app(StockService::class);
$inputResults = [];
foreach ($inputs as $input) {
$stockLotId = $input['stock_lot_id'] ?? null;
$qty = (float) ($input['qty'] ?? 0);
if (! $stockLotId || $qty <= 0) {
continue;
}
// 특정 로트에서 재고 차감
$result = $stockService->decreaseFromLot(
stockLotId: $stockLotId,
qty: $qty,
reason: 'work_order_input',
referenceId: $workOrderId
);
$inputResults[] = [
'stock_lot_id' => $stockLotId,
'qty' => $qty,
'status' => 'success',
'deducted_lot' => $result,
];
}
// 자재 투입 감사 로그
$this->auditLogger->log(
$tenantId,
self::AUDIT_TARGET,
$workOrderId,
'material_input',
null,
[
'inputs' => $inputs,
'input_results' => $inputResults,
'input_by' => $userId,
'input_at' => now()->toDateTimeString(),
]
);
return [
'work_order_id' => $workOrderId,
'material_count' => count($inputResults),
'input_results' => $inputResults,
'input_at' => now()->toDateTimeString(),
];
});
}
/**
* 품목 상태 기반으로 작업지시 상태 자동 동기화
*
* 규칙:
* - 품목 중 하나라도 in_progress → 작업지시 in_progress (pending에서도 자동 전환)
* - 모든 품목이 completed → 작업지시 completed
* - 모든 품목이 waiting → 작업지시 waiting (단, waiting 이상인 경우만)
* - 미배정(unassigned) 상태에서는 동기화하지 않음
*
* @return bool 상태 변경 여부
*/
private function syncWorkOrderStatusFromItems(WorkOrder $workOrder): bool
{
// 품목이 없으면 동기화하지 않음
$items = $workOrder->items()->get();
if ($items->isEmpty()) {
return false;
}
// 미배정(unassigned) 상태에서는 동기화하지 않음 (배정 없이 작업 시작 불가)
if ($workOrder->status === WorkOrder::STATUS_UNASSIGNED) {
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]
);
// 완료 시 수주 상태 동기화 및 자동 출하 생성
if ($newStatus === WorkOrder::STATUS_COMPLETED) {
$this->syncOrderStatus($workOrder, $workOrder->tenant_id);
$this->createShipmentFromWorkOrder($workOrder, $workOrder->tenant_id, $this->apiUserId());
}
return true;
}
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();
}
// ──────────────────────────────────────────────────────────────
// 중간검사 관련
// ──────────────────────────────────────────────────────────────
/**
* 품목별 중간검사 데이터 저장
*/
public function storeItemInspection(int $workOrderId, int $itemId, array $data): array
{
$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'));
}
$beforeData = $item->getInspectionData();
$inspectionData = $data['inspection_data'];
$inspectionData['process_type'] = $data['process_type'];
$inspectionData['inspected_at'] = now()->toDateTimeString();
$inspectionData['inspected_by'] = $userId;
$item->setInspectionData($inspectionData);
$item->save();
// 감사 로그
$this->auditLogger->log(
$tenantId,
self::AUDIT_TARGET,
$workOrderId,
'item_inspection_saved',
['item_id' => $itemId, 'inspection_data' => $beforeData],
['item_id' => $itemId, 'inspection_data' => $inspectionData]
);
return [
'item_id' => $item->id,
'inspection_data' => $item->getInspectionData(),
];
}
/**
* 작업지시의 전체 품목 검사 데이터 조회
*/
public function getInspectionData(int $workOrderId, array $params = []): array
{
$tenantId = $this->tenantId();
$workOrder = WorkOrder::where('tenant_id', $tenantId)->find($workOrderId);
if (! $workOrder) {
throw new NotFoundHttpException(__('error.not_found'));
}
$query = $workOrder->items()->ordered();
// 공정 유형 필터
if (! empty($params['process_type'])) {
$query->where('options->inspection_data->process_type', $params['process_type']);
}
$items = $query->get();
$inspectionMap = [];
foreach ($items as $item) {
$inspectionData = $item->getInspectionData();
if ($inspectionData) {
$inspectionMap[$item->id] = [
'item_id' => $item->id,
'item_name' => $item->item_name,
'specification' => $item->specification,
'quantity' => $item->quantity,
'sort_order' => $item->sort_order,
'options' => $item->options,
'inspection_data' => $inspectionData,
];
}
}
return [
'work_order_id' => $workOrderId,
'items' => array_values($inspectionMap),
'total' => count($inspectionMap),
];
}
/**
* 작업지시 검사 성적서용 데이터 조회 (전체 품목 + 검사 데이터 + 주문 정보)
*/
public function getInspectionReport(int $workOrderId): array
{
$tenantId = $this->tenantId();
$workOrder = WorkOrder::where('tenant_id', $tenantId)
->with(['order', 'items' => function ($q) {
$q->ordered();
}])
->find($workOrderId);
if (! $workOrder) {
throw new NotFoundHttpException(__('error.not_found'));
}
$items = $workOrder->items->map(function ($item) {
return [
'id' => $item->id,
'item_name' => $item->item_name,
'specification' => $item->specification,
'quantity' => $item->quantity,
'sort_order' => $item->sort_order,
'status' => $item->status,
'options' => $item->options,
'inspection_data' => $item->getInspectionData(),
];
});
return [
'work_order' => [
'id' => $workOrder->id,
'order_no' => $workOrder->order_no,
'status' => $workOrder->status,
'planned_date' => $workOrder->planned_date,
'due_date' => $workOrder->due_date,
],
'order' => $workOrder->order ? [
'id' => $workOrder->order->id,
'order_no' => $workOrder->order->order_no,
'client_name' => $workOrder->order->client_name ?? null,
'site_name' => $workOrder->order->site_name ?? null,
'order_date' => $workOrder->order->order_date ?? null,
] : null,
'items' => $items,
'summary' => [
'total_items' => $items->count(),
'inspected_items' => $items->filter(fn ($i) => $i['inspection_data'] !== null)->count(),
'passed_items' => $items->filter(fn ($i) => ($i['inspection_data']['judgment'] ?? null) === 'pass')->count(),
'failed_items' => $items->filter(fn ($i) => ($i['inspection_data']['judgment'] ?? null) === 'fail')->count(),
],
];
}
}