Files
sam-api/app/Services/WorkOrderService.php
권혁성 6bc766411b feat: 생산지시 생성 시 공정 자동 분류 및 아이템 연결
- OrderService: 생산지시 생성 로직 개선
  - order_items.item_id → process_items 테이블에서 공정 자동 조회
  - 공정별로 아이템 그룹화 (미지정 아이템은 별도 그룹)
  - 각 공정별 작업지시 생성
  - work_order_items에 해당 공정의 아이템들 자동 추가

- WorkOrderService: 목록 조회 시 관계 추가
  - items 관계 추가 (틀수 계산용)
  - process.department 필드 추가 (부서 표시용)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-06 10:28:30 +09:00

1295 lines
47 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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\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'];
$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:id,order_no,client_id,client_name',
'salesOrder.client:id,name',
'process:id,process_name,process_code,department',
'items:id,work_order_id,item_name,quantity',
]);
// 검색어
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 ($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();
return [
'total' => array_sum($counts),
'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,
];
}
/**
* 단건 조회
*/
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:id,order_no,site_name,client_id,client_contact,received_at,writer_id,created_at,quantity',
'salesOrder.client:id,name',
'salesOrder.writer:id,name',
'process:id,process_name,process_code,work_steps,department',
'items',
'bendingDetail',
'issues' => fn ($q) => $q->orderByDesc('created_at'),
])
->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 자재 목록과 실제 재고 정보를 반환합니다.
* 품목의 BOM 정보를 기반으로 필요 자재를 추출하고, 각 자재의 실제 재고를 조회합니다.
*
* @param int $workOrderId 작업지시 ID
* @return array 자재 목록 (item_id, material_code, material_name, unit, required_qty, current_stock, available_qty, fifo_rank)
*/
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;
$stockService = app(StockService::class);
// 작업지시 품목들의 BOM에서 자재 추출
foreach ($workOrder->items as $woItem) {
// item_id가 있으면 해당 Item의 BOM 조회
if ($woItem->item_id) {
$item = \App\Models\Items\Item::where('tenant_id', $tenantId)
->find($woItem->item_id);
if ($item && ! empty($item->bom)) {
// 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;
}
// 필요 수량 계산 (BOM 수량 × 작업지시 수량)
$requiredQty = $bomQty * ($woItem->quantity ?? 1);
// 실제 재고 조회
$stockInfo = $stockService->getAvailableStock($childItemId);
$materials[] = [
'item_id' => $childItemId,
'work_order_item_id' => $woItem->id,
'material_code' => $childItem->code,
'material_name' => $childItem->name,
'specification' => $childItem->specification,
'unit' => $childItem->unit ?? 'EA',
'bom_qty' => $bomQty,
'required_qty' => $requiredQty,
'current_stock' => $stockInfo['stock_qty'] ?? 0,
'available_qty' => $stockInfo['available_qty'] ?? 0,
'reserved_qty' => $stockInfo['reserved_qty'] ?? 0,
'is_sufficient' => ($stockInfo['available_qty'] ?? 0) >= $requiredQty,
'fifo_rank' => $rank++,
];
}
}
}
// BOM이 없는 경우, 품목 자체를 자재로 취급 (Fallback)
if (empty($materials) && $woItem->item_id) {
$stockInfo = $stockService->getAvailableStock($woItem->item_id);
$materials[] = [
'item_id' => $woItem->item_id,
'work_order_item_id' => $woItem->id,
'material_code' => $woItem->item_id ? "ITEM-{$woItem->item_id}" : null,
'material_name' => $woItem->item_name,
'specification' => $woItem->specification,
'unit' => $woItem->unit ?? 'EA',
'bom_qty' => 1,
'required_qty' => $woItem->quantity ?? 1,
'current_stock' => $stockInfo['stock_qty'] ?? 0,
'available_qty' => $stockInfo['available_qty'] ?? 0,
'reserved_qty' => $stockInfo['reserved_qty'] ?? 0,
'is_sufficient' => ($stockInfo['available_qty'] ?? 0) >= ($woItem->quantity ?? 1),
'fifo_rank' => $rank++,
];
}
}
return $materials;
}
/**
* 자재 투입 등록 (재고 차감 포함)
*
* 작업지시에 자재 투입을 등록하고 재고를 차감합니다.
* FIFO 기반으로 가장 오래된 LOT부터 차감합니다.
*
* @param int $workOrderId 작업지시 ID
* @param array $materials 투입할 자재 목록 [['item_id' => int, 'qty' => float], ...]
* @return array 투입 결과
*
* @throws \Exception 재고 부족 시
*/
public function registerMaterialInput(int $workOrderId, array $materials): 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 ($materials, $tenantId, $userId, $workOrderId) {
$stockService = app(StockService::class);
$inputResults = [];
foreach ($materials as $material) {
$itemId = $material['item_id'] ?? null;
$qty = (float) ($material['qty'] ?? 0);
if (! $itemId || $qty <= 0) {
continue;
}
// FIFO 기반 재고 차감
try {
$deductedLots = $stockService->decreaseFIFO(
itemId: $itemId,
qty: $qty,
reason: 'work_order_input',
referenceId: $workOrderId
);
$inputResults[] = [
'item_id' => $itemId,
'qty' => $qty,
'status' => 'success',
'deducted_lots' => $deductedLots,
];
} catch (\Exception $e) {
// 재고 부족 등의 오류는 전체 트랜잭션 롤백
throw $e;
}
}
// 자재 투입 감사 로그
$this->auditLogger->log(
$tenantId,
self::AUDIT_TARGET,
$workOrderId,
'material_input',
null,
[
'materials' => $materials,
'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;
}
}