- StockTransaction: REASON_PRODUCTION_OUTPUT 상수 및 '생산입고' 라벨 추가 - StockLot: work_order_id FK 컬럼 마이그레이션 + 모델 fillable/casts/relation 추가 - StockService: increaseFromProduction() 메서드 구현 (increaseFromReceiving 기반) - WorkOrderService: 완료 시 sales_order_id 유무에 따라 출하/재고입고 분기 - stockInFromProduction(): 품목별 양품 재고 입고 처리 - shouldStockIn(): items.options 기반 입고 대상 판단 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
3057 lines
114 KiB
PHP
3057 lines
114 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\Documents\Document;
|
|
use App\Models\Documents\DocumentTemplate;
|
|
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\WorkOrderMaterialInput;
|
|
use App\Models\Production\WorkOrderStepProgress;
|
|
use App\Models\Tenants\Shipment;
|
|
use App\Models\Tenants\ShipmentItem;
|
|
use App\Models\Tenants\StockTransaction;
|
|
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,
|
|
private readonly StockService $stockService
|
|
) {}
|
|
|
|
/**
|
|
* 목록 조회 (검색/필터링/페이징)
|
|
*/
|
|
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', 'client_contact', 'site_name', 'quantity', 'received_at', 'delivery_date', 'options')->withCount('rootNodes'),
|
|
'salesOrder.client:id,name',
|
|
'process:id,process_name,process_code,department,options',
|
|
'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,floor_code,symbol_code',
|
|
'items.sourceOrderItem.node:id,name,code',
|
|
'items.materialInputs:id,work_order_id,work_order_item_id,stock_lot_id,item_id,qty,input_by,input_at',
|
|
'items.materialInputs.stockLot:id,lot_no',
|
|
'items.materialInputs.item:id,code,name,unit',
|
|
]);
|
|
|
|
// 검색어
|
|
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', 'options')->withCount('rootNodes'),
|
|
'salesOrder.client:id,name',
|
|
'salesOrder.writer:id,name',
|
|
'process:id,process_name,process_code,work_steps,department,options',
|
|
'process.steps' => fn ($q) => $q->where('is_active', true)->orderBy('sort_order'),
|
|
'items.sourceOrderItem:id,order_node_id,floor_code,symbol_code',
|
|
'items.sourceOrderItem.node:id,name,code',
|
|
'items.materialInputs:id,work_order_id,work_order_item_id,stock_lot_id,item_id,qty,input_by,input_at',
|
|
'items.materialInputs.stockLot:id,lot_no',
|
|
'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.node', 'rootNodes'])->find($salesOrderId);
|
|
if ($salesOrder && $salesOrder->items->isNotEmpty()) {
|
|
// order_node_id가 null인 품목용 fallback: 수주의 root node를 인덱스로 매핑
|
|
$rootNodes = $salesOrder->rootNodes;
|
|
$rootNodeCount = $rootNodes->count();
|
|
|
|
foreach ($salesOrder->items as $index => $orderItem) {
|
|
// 수주 품목 + 노드에서 options 조합
|
|
// 1순위: 품목에 직접 연결된 node, 2순위: root node fallback
|
|
$nodeOptions = $orderItem->node?->options ?? [];
|
|
if (empty($nodeOptions) && $rootNodeCount > 0) {
|
|
// root node가 1개면 모든 품목이 해당 node, 여러 개면 인덱스 기반 분배
|
|
$fallbackNode = $rootNodeCount === 1
|
|
? $rootNodes->first()
|
|
: $rootNodes->values()->get($index % $rootNodeCount);
|
|
$nodeOptions = $fallbackNode?->options ?? [];
|
|
}
|
|
$options = array_filter([
|
|
'floor' => $orderItem->floor_code,
|
|
'code' => $orderItem->symbol_code,
|
|
'width' => $nodeOptions['width'] ?? $nodeOptions['open_width'] ?? null,
|
|
'height' => $nodeOptions['height'] ?? $nodeOptions['open_height'] ?? null,
|
|
'cutting_info' => $nodeOptions['cutting_info'] ?? null,
|
|
'slat_info' => $nodeOptions['slat_info'] ?? null,
|
|
'bending_info' => $nodeOptions['bending_info'] ?? null,
|
|
'wip_info' => $nodeOptions['wip_info'] ?? null,
|
|
], fn ($v) => $v !== null);
|
|
|
|
$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,
|
|
'options' => ! empty($options) ? $options : null,
|
|
]);
|
|
}
|
|
}
|
|
} 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) {
|
|
if ($workOrder->sales_order_id) {
|
|
$this->createShipmentFromWorkOrder($workOrder, $tenantId, $userId);
|
|
} else {
|
|
$this->stockInFromProduction($workOrder);
|
|
}
|
|
}
|
|
|
|
return $workOrder->load(['assignee:id,name', 'assignees.user:id,name', 'team:id,name', 'process:id,process_name,process_code']);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 선생산 작업지시 완료 시 완성품을 재고로 입고
|
|
*
|
|
* 수주 없는 작업지시(sales_order_id = null)가 완료되면
|
|
* 각 품목의 양품 수량을 재고 시스템에 입고 처리합니다.
|
|
*/
|
|
private function stockInFromProduction(WorkOrder $workOrder): void
|
|
{
|
|
$workOrder->loadMissing('items.item');
|
|
|
|
foreach ($workOrder->items as $woItem) {
|
|
if ($this->shouldStockIn($woItem)) {
|
|
$resultData = $woItem->options['result'] ?? [];
|
|
$goodQty = (float) ($resultData['good_qty'] ?? $woItem->quantity);
|
|
$lotNo = $resultData['lot_no'] ?? '';
|
|
|
|
if ($goodQty > 0 && $lotNo) {
|
|
$this->stockService->increaseFromProduction(
|
|
$workOrder, $woItem, $goodQty, $lotNo
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 품목이 생산입고 대상인지 판단
|
|
*
|
|
* items.options의 production_source와 lot_managed 속성으로 판단.
|
|
*/
|
|
private function shouldStockIn(WorkOrderItem $woItem): bool
|
|
{
|
|
$item = $woItem->item;
|
|
if (! $item) {
|
|
return false;
|
|
}
|
|
|
|
$options = $item->options ?? [];
|
|
|
|
return ($options['production_source'] ?? null) === 'self_produced'
|
|
&& ($options['lot_managed'] ?? false) === true;
|
|
}
|
|
|
|
/**
|
|
* 작업지시 완료 시 자동 출하 생성
|
|
*
|
|
* 작업지시가 완료(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 순서로 반환합니다.
|
|
* 동일 자재가 여러 작업지시 품목에 걸쳐 있으면 필요수량을 합산하고 로트는 중복 없이 반환합니다.
|
|
*
|
|
* @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'));
|
|
}
|
|
|
|
// Phase 1: 작업지시 품목들에서 유니크 자재 목록 수집 (item_id 기준 합산)
|
|
$uniqueMaterials = [];
|
|
|
|
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),
|
|
];
|
|
}
|
|
}
|
|
}
|
|
|
|
// BOM이 없으면 품목 자체를 자재로 사용
|
|
if (empty($materialItems) && $woItem->item_id && $woItem->item) {
|
|
$materialItems[] = [
|
|
'item' => $woItem->item,
|
|
'bom_qty' => 1,
|
|
'required_qty' => $woItem->quantity ?? 1,
|
|
];
|
|
}
|
|
|
|
// 유니크 자재 수집 (같은 item_id면 required_qty 합산)
|
|
foreach ($materialItems as $matInfo) {
|
|
$itemId = $matInfo['item']->id;
|
|
if (isset($uniqueMaterials[$itemId])) {
|
|
$uniqueMaterials[$itemId]['required_qty'] += $matInfo['required_qty'];
|
|
} else {
|
|
$uniqueMaterials[$itemId] = $matInfo;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Phase 2: 유니크 자재별로 StockLot 조회
|
|
$materials = [];
|
|
$rank = 1;
|
|
|
|
foreach ($uniqueMaterials as $matInfo) {
|
|
$materialItem = $matInfo['item'];
|
|
|
|
$stock = \App\Models\Tenants\Stock::where('tenant_id', $tenantId)
|
|
->where('item_id', $materialItem->id)
|
|
->first();
|
|
|
|
$lotsFound = false;
|
|
|
|
if ($stock) {
|
|
$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) {
|
|
$lotsFound = true;
|
|
$materials[] = [
|
|
'stock_lot_id' => $lot->id,
|
|
'item_id' => $materialItem->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++,
|
|
];
|
|
}
|
|
}
|
|
|
|
// 가용 로트가 없는 경우 자재 정보만 반환 (재고 없음 표시)
|
|
if (! $lotsFound) {
|
|
$materials[] = [
|
|
'stock_lot_id' => null,
|
|
'item_id' => $materialItem->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'),
|
|
'items',
|
|
])
|
|
->find($workOrderId);
|
|
|
|
if (! $workOrder) {
|
|
throw new NotFoundHttpException(__('error.not_found'));
|
|
}
|
|
|
|
$processSteps = $workOrder->process?->steps ?? collect();
|
|
if ($processSteps->isEmpty()) {
|
|
return [];
|
|
}
|
|
|
|
$items = $workOrder->items;
|
|
$result = [];
|
|
|
|
if ($items->isNotEmpty()) {
|
|
// 개소(item)별 진행 레코드 생성/조회
|
|
$existingProgress = WorkOrderStepProgress::where('work_order_id', $workOrderId)
|
|
->whereNotNull('work_order_item_id')
|
|
->get()
|
|
->groupBy('work_order_item_id');
|
|
|
|
foreach ($items as $item) {
|
|
$itemProgress = ($existingProgress->get($item->id) ?? collect())->keyBy('process_step_id');
|
|
|
|
foreach ($processSteps as $step) {
|
|
if ($itemProgress->has($step->id)) {
|
|
$progress = $itemProgress->get($step->id);
|
|
} else {
|
|
$progress = WorkOrderStepProgress::create([
|
|
'tenant_id' => $tenantId,
|
|
'work_order_id' => $workOrderId,
|
|
'process_step_id' => $step->id,
|
|
'work_order_item_id' => $item->id,
|
|
'status' => WorkOrderStepProgress::STATUS_WAITING,
|
|
]);
|
|
}
|
|
|
|
$result[] = [
|
|
'id' => $progress->id,
|
|
'process_step_id' => $step->id,
|
|
'work_order_item_id' => $item->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,
|
|
];
|
|
}
|
|
}
|
|
} else {
|
|
// items 없으면 작업지시 전체 레벨 (기존 동작)
|
|
$existingProgress = WorkOrderStepProgress::where('work_order_id', $workOrderId)
|
|
->whereNull('work_order_item_id')
|
|
->get()
|
|
->keyBy('process_step_id');
|
|
|
|
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,
|
|
'work_order_item_id' => null,
|
|
'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['input_results'] ?? [],
|
|
'created_at' => $log->created_at,
|
|
'actor_id' => $log->actor_id,
|
|
];
|
|
})->toArray();
|
|
}
|
|
|
|
/**
|
|
* 작업지시에 투입된 자재 LOT 번호 조회 (stock_transactions 기반)
|
|
*
|
|
* stock_transactions에서 reference_type='work_order_input'인 거래를 조회하여
|
|
* 중복 없는 LOT 번호 목록을 반환합니다.
|
|
*/
|
|
public function getMaterialInputLots(int $workOrderId): array
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
|
|
$workOrder = WorkOrder::where('tenant_id', $tenantId)->find($workOrderId);
|
|
if (! $workOrder) {
|
|
throw new NotFoundHttpException(__('error.not_found'));
|
|
}
|
|
|
|
$transactions = DB::table('stock_transactions')
|
|
->where('tenant_id', $tenantId)
|
|
->where('reference_type', StockTransaction::REASON_WORK_ORDER_INPUT)
|
|
->where('reference_id', $workOrderId)
|
|
->orderBy('created_at')
|
|
->get(['id', 'lot_no', 'item_code', 'item_name', 'qty', 'stock_lot_id', 'created_at']);
|
|
|
|
// LOT 번호별 그룹핑 (동일 LOT에서 여러번 투입 가능)
|
|
$lotMap = [];
|
|
foreach ($transactions as $tx) {
|
|
$lotNo = $tx->lot_no;
|
|
if (! isset($lotMap[$lotNo])) {
|
|
$lotMap[$lotNo] = [
|
|
'lot_no' => $lotNo,
|
|
'item_code' => $tx->item_code,
|
|
'item_name' => $tx->item_name,
|
|
'total_qty' => 0,
|
|
'input_count' => 0,
|
|
'first_input_at' => $tx->created_at,
|
|
];
|
|
}
|
|
$lotMap[$lotNo]['total_qty'] += abs((float) $tx->qty);
|
|
$lotMap[$lotNo]['input_count']++;
|
|
}
|
|
|
|
return array_values($lotMap);
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────
|
|
// 중간검사 관련
|
|
// ──────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* 품목별 중간검사 데이터 저장
|
|
*/
|
|
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),
|
|
];
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────
|
|
// 검사 문서 템플릿 연동
|
|
// ──────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* 작업지시의 검사용 문서 템플릿 조회
|
|
*
|
|
* work_order → process → steps(needs_inspection=true) → documentTemplate 로드
|
|
* 모든 검사 단계의 템플릿을 반환 (다중 검사 단계 지원)
|
|
*/
|
|
public function getInspectionTemplate(int $workOrderId): array
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
|
|
$workOrder = WorkOrder::where('tenant_id', $tenantId)
|
|
->with([
|
|
'process.documentTemplate' => fn ($q) => $q->with([
|
|
'approvalLines',
|
|
'basicFields',
|
|
'sections.items',
|
|
'columns',
|
|
'sectionFields',
|
|
]),
|
|
'salesOrder:id,order_no,client_name,site_name',
|
|
'items:id,work_order_id,item_name,specification,quantity,unit,sort_order',
|
|
])
|
|
->find($workOrderId);
|
|
|
|
if (! $workOrder) {
|
|
throw new NotFoundHttpException(__('error.not_found'));
|
|
}
|
|
|
|
$process = $workOrder->process;
|
|
$docTemplate = $process?->documentTemplate;
|
|
|
|
if (! $docTemplate) {
|
|
return [
|
|
'work_order_id' => $workOrderId,
|
|
'has_template' => false,
|
|
'templates' => [],
|
|
'template' => null,
|
|
'work_order_info' => $this->buildWorkOrderInfo($workOrder),
|
|
];
|
|
}
|
|
|
|
$documentService = app(DocumentService::class);
|
|
$formattedTemplate = $documentService->formatTemplateForReact($docTemplate);
|
|
|
|
return [
|
|
'work_order_id' => $workOrderId,
|
|
'has_template' => true,
|
|
'templates' => [[
|
|
'template_id' => $docTemplate->id,
|
|
'template_name' => $docTemplate->name,
|
|
'template' => $formattedTemplate,
|
|
]],
|
|
'template' => $formattedTemplate,
|
|
'work_order_info' => $this->buildWorkOrderInfo($workOrder),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 작업지시 검사 문서 resolve (기존 문서 조회 또는 생성 정보 반환)
|
|
*
|
|
* step_id 기반으로 해당 검사 단계의 템플릿과 기존 문서를 조회.
|
|
* 기존 DRAFT/REJECTED 문서가 있으면 반환, 없으면 template만 반환.
|
|
*/
|
|
public function resolveInspectionDocument(int $workOrderId, array $params = []): array
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
|
|
$workOrder = WorkOrder::where('tenant_id', $tenantId)
|
|
->with([
|
|
'process.documentTemplate' => fn ($q) => $q->with([
|
|
'approvalLines',
|
|
'basicFields',
|
|
'sections.items',
|
|
'columns',
|
|
'sectionFields',
|
|
]),
|
|
'salesOrder:id,order_no,client_name,site_name',
|
|
'items:id,work_order_id,item_name,specification,quantity,unit,sort_order',
|
|
])
|
|
->find($workOrderId);
|
|
|
|
if (! $workOrder) {
|
|
throw new NotFoundHttpException(__('error.not_found'));
|
|
}
|
|
|
|
$process = $workOrder->process;
|
|
$templateId = $process?->document_template_id;
|
|
$docTemplate = $process?->documentTemplate;
|
|
|
|
if (! $templateId || ! $docTemplate) {
|
|
throw new BadRequestHttpException(__('error.work_order.no_inspection_template'));
|
|
}
|
|
|
|
$documentService = app(DocumentService::class);
|
|
$formattedTemplate = $documentService->formatTemplateForReact($docTemplate);
|
|
|
|
// 기존 문서 조회 (work_order + template, 수정 가능한 상태)
|
|
$existingDocument = Document::query()
|
|
->where('tenant_id', $tenantId)
|
|
->where('template_id', $templateId)
|
|
->where('linkable_type', 'work_order')
|
|
->where('linkable_id', $workOrderId)
|
|
->whereIn('status', [Document::STATUS_DRAFT, Document::STATUS_REJECTED])
|
|
->with(['data', 'attachments.file', 'approvals.user:id,name'])
|
|
->latest()
|
|
->first();
|
|
|
|
return [
|
|
'work_order_id' => $workOrderId,
|
|
'template_id' => $templateId,
|
|
'template' => $formattedTemplate,
|
|
'existing_document' => $existingDocument,
|
|
'work_order_info' => $this->buildWorkOrderInfo($workOrder),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 검사 완료 시 Document + DocumentData 생성
|
|
*
|
|
* 공정(Process) 레벨의 document_template_id를 사용.
|
|
* 기존 DRAFT/REJECTED 문서가 있으면 update, 없으면 create.
|
|
*/
|
|
public function createInspectionDocument(int $workOrderId, array $inspectionData): array
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
$userId = $this->apiUserId();
|
|
|
|
$workOrder = WorkOrder::where('tenant_id', $tenantId)
|
|
->with(['process', 'items' => fn ($q) => $q->orderBy('sort_order')])
|
|
->find($workOrderId);
|
|
|
|
if (! $workOrder) {
|
|
throw new NotFoundHttpException(__('error.not_found'));
|
|
}
|
|
|
|
// 공정 레벨의 중간검사 양식 사용
|
|
$process = $workOrder->process;
|
|
$templateId = $process?->document_template_id;
|
|
|
|
if (! $templateId) {
|
|
throw new BadRequestHttpException(__('error.work_order.no_inspection_template'));
|
|
}
|
|
|
|
$documentService = app(DocumentService::class);
|
|
|
|
// 기존 DRAFT/REJECTED 문서가 있으면 update
|
|
$existingDocument = Document::query()
|
|
->where('tenant_id', $tenantId)
|
|
->where('template_id', $templateId)
|
|
->where('linkable_type', 'work_order')
|
|
->where('linkable_id', $workOrderId)
|
|
->whereIn('status', [Document::STATUS_DRAFT, Document::STATUS_REJECTED])
|
|
->latest()
|
|
->first();
|
|
|
|
// ★ 원본 기반 동기화: work_order_items.options.inspection_data에서 전체 수집
|
|
$rawItems = [];
|
|
foreach ($workOrder->items as $item) {
|
|
$inspData = $item->getInspectionData();
|
|
if ($inspData) {
|
|
$rawItems[] = $inspData;
|
|
}
|
|
}
|
|
|
|
$documentDataRecords = $this->transformInspectionDataToDocumentRecords($rawItems, $templateId);
|
|
|
|
// 기존 문서의 기본필드(bf_*) 보존
|
|
if ($existingDocument) {
|
|
$existingBasicFields = $existingDocument->data()
|
|
->whereNull('section_id')
|
|
->where('field_key', 'LIKE', 'bf_%')
|
|
->get()
|
|
->map(fn ($d) => [
|
|
'section_id' => null,
|
|
'column_id' => null,
|
|
'row_index' => $d->row_index,
|
|
'field_key' => $d->field_key,
|
|
'field_value' => $d->field_value,
|
|
])
|
|
->toArray();
|
|
|
|
$document = $documentService->update($existingDocument->id, [
|
|
'title' => $inspectionData['title'] ?? $existingDocument->title,
|
|
'data' => array_merge($existingBasicFields, $documentDataRecords),
|
|
]);
|
|
|
|
$action = 'inspection_document_updated';
|
|
} else {
|
|
$documentData = [
|
|
'template_id' => $templateId,
|
|
'title' => $inspectionData['title'] ?? "중간검사성적서 - {$workOrder->work_order_no}",
|
|
'linkable_type' => 'work_order',
|
|
'linkable_id' => $workOrderId,
|
|
'data' => $documentDataRecords,
|
|
'approvers' => $inspectionData['approvers'] ?? [],
|
|
];
|
|
|
|
$document = $documentService->create($documentData);
|
|
$action = 'inspection_document_created';
|
|
}
|
|
|
|
// 감사 로그
|
|
$this->auditLogger->log(
|
|
$tenantId,
|
|
self::AUDIT_TARGET,
|
|
$workOrderId,
|
|
$action,
|
|
null,
|
|
['document_id' => $document->id, 'document_no' => $document->document_no]
|
|
);
|
|
|
|
return [
|
|
'document_id' => $document->id,
|
|
'document_no' => $document->document_no,
|
|
'status' => $document->status,
|
|
'is_new' => $action === 'inspection_document_created',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 프론트 InspectionData를 정규화된 document_data 레코드로 변환
|
|
*
|
|
* 정규화 형식 (NEW):
|
|
* [{ section_id, column_id, row_index, field_key, field_value }]
|
|
* field_key: 'value', 'n1', 'n2', 'n1_ok', 'n1_ng', 'overall_result', 'remark', 'row_judgment'
|
|
*
|
|
* 레거시 형식 (WorkerScreen):
|
|
* [{ templateValues: { section_X_item_Y: "ok"|number }, judgment, nonConformingContent }]
|
|
*/
|
|
private function transformInspectionDataToDocumentRecords(array $rawItems, int $templateId): array
|
|
{
|
|
if (empty($rawItems)) {
|
|
return [];
|
|
}
|
|
|
|
// 정규화 형식 감지: 첫 번째 요소에 field_key가 있으면 새 형식
|
|
if (isset($rawItems[0]['field_key'])) {
|
|
return array_map(fn (array $item) => [
|
|
'section_id' => $item['section_id'] ?? null,
|
|
'column_id' => $item['column_id'] ?? null,
|
|
'row_index' => $item['row_index'] ?? 0,
|
|
'field_key' => $item['field_key'],
|
|
'field_value' => $item['field_value'] ?? null,
|
|
], $rawItems);
|
|
}
|
|
|
|
// 레거시 형식: templateValues/values 기반 → 정규화 변환
|
|
return $this->normalizeOldFormatRecords($rawItems, $templateId);
|
|
}
|
|
|
|
/**
|
|
* 레거시 형식(section_X_item_Y 키)을 정규화 레코드로 변환
|
|
*/
|
|
private function normalizeOldFormatRecords(array $rawItems, int $templateId): array
|
|
{
|
|
$template = DocumentTemplate::with(['sections.items', 'columns'])->find($templateId);
|
|
if (! $template) {
|
|
return [];
|
|
}
|
|
|
|
// sectionItem.id → { section_id, column_id, measurement_type } 매핑
|
|
$itemMap = $this->buildItemColumnMap($template);
|
|
|
|
$records = [];
|
|
foreach ($rawItems as $rowIdx => $item) {
|
|
$values = $item['values'] ?? $item['templateValues'] ?? [];
|
|
|
|
foreach ($values as $key => $cellValue) {
|
|
// section_{sectionId}_item_{itemId} 또는 item_{itemId} 형식 파싱
|
|
if (! preg_match('/^(?:section_(\d+)_)?item_(\d+)$/', $key, $m)) {
|
|
continue;
|
|
}
|
|
|
|
$sectionId = $m[1] ? (int) $m[1] : null;
|
|
$itemId = (int) $m[2];
|
|
$info = $itemMap[$itemId] ?? null;
|
|
$columnId = $info['column_id'] ?? null;
|
|
$sectionId = $sectionId ?? ($info['section_id'] ?? null);
|
|
|
|
$expanded = $this->expandCellValue($cellValue, $info['measurement_type'] ?? '');
|
|
foreach ($expanded as $rec) {
|
|
$records[] = [
|
|
'section_id' => $sectionId,
|
|
'column_id' => $columnId,
|
|
'row_index' => $rowIdx,
|
|
'field_key' => $rec['field_key'],
|
|
'field_value' => $rec['field_value'],
|
|
];
|
|
}
|
|
}
|
|
|
|
// 행 판정
|
|
$judgment = $item['judgment'] ?? null;
|
|
if ($judgment !== null) {
|
|
$records[] = [
|
|
'section_id' => null,
|
|
'column_id' => null,
|
|
'row_index' => $rowIdx,
|
|
'field_key' => 'row_judgment',
|
|
'field_value' => (string) $judgment,
|
|
];
|
|
}
|
|
|
|
// 부적합 내용
|
|
if (! empty($item['nonConformingContent'])) {
|
|
$records[] = [
|
|
'section_id' => null,
|
|
'column_id' => null,
|
|
'row_index' => $rowIdx,
|
|
'field_key' => 'remark',
|
|
'field_value' => (string) $item['nonConformingContent'],
|
|
];
|
|
}
|
|
}
|
|
|
|
return $records;
|
|
}
|
|
|
|
/**
|
|
* 템플릿 구조에서 sectionItem → (section_id, column_id) 매핑 구축
|
|
*/
|
|
private function buildItemColumnMap(DocumentTemplate $template): array
|
|
{
|
|
$map = [];
|
|
foreach ($template->sections as $section) {
|
|
foreach ($section->items as $item) {
|
|
$itemLabel = $this->normalizeInspectionLabel($item->getFieldValue('item') ?? '');
|
|
$columnId = null;
|
|
|
|
foreach ($template->columns as $col) {
|
|
$colLabel = $this->normalizeInspectionLabel($col->label);
|
|
if ($itemLabel && $colLabel === $itemLabel) {
|
|
$columnId = $col->id;
|
|
|
|
break;
|
|
}
|
|
}
|
|
|
|
$map[$item->id] = [
|
|
'section_id' => $section->id,
|
|
'column_id' => $columnId,
|
|
'measurement_type' => $item->getFieldValue('measurement_type') ?? '',
|
|
];
|
|
}
|
|
}
|
|
|
|
return $map;
|
|
}
|
|
|
|
/**
|
|
* CellValue를 개별 field_key/field_value 레코드로 확장
|
|
*/
|
|
private function expandCellValue(mixed $cellValue, string $measurementType): array
|
|
{
|
|
if ($cellValue === null) {
|
|
return [];
|
|
}
|
|
|
|
// 단순 문자열/숫자 → value 레코드
|
|
if (is_string($cellValue) || is_numeric($cellValue)) {
|
|
return [['field_key' => 'value', 'field_value' => (string) $cellValue]];
|
|
}
|
|
|
|
if (! is_array($cellValue)) {
|
|
return [['field_key' => 'value', 'field_value' => (string) $cellValue]];
|
|
}
|
|
|
|
$records = [];
|
|
|
|
// measurements 배열: 복합 컬럼 데이터
|
|
if (isset($cellValue['measurements']) && is_array($cellValue['measurements'])) {
|
|
foreach ($cellValue['measurements'] as $n => $val) {
|
|
$nNum = $n + 1;
|
|
if ($measurementType === 'checkbox') {
|
|
$lower = strtolower($val ?? '');
|
|
$records[] = ['field_key' => "n{$nNum}_ok", 'field_value' => $lower === 'ok' ? 'OK' : ''];
|
|
$records[] = ['field_key' => "n{$nNum}_ng", 'field_value' => $lower === 'ng' ? 'NG' : ''];
|
|
} else {
|
|
$records[] = ['field_key' => "n{$nNum}", 'field_value' => (string) ($val ?? '')];
|
|
}
|
|
}
|
|
}
|
|
|
|
// value 필드: 단일 값
|
|
if (isset($cellValue['value'])) {
|
|
$records[] = ['field_key' => 'value', 'field_value' => (string) $cellValue['value']];
|
|
}
|
|
|
|
// text 필드: 텍스트 값
|
|
if (isset($cellValue['text'])) {
|
|
$records[] = ['field_key' => 'value', 'field_value' => (string) $cellValue['text']];
|
|
}
|
|
|
|
// 아무 필드도 매칭 안 되면 JSON으로 저장
|
|
return $records ?: [['field_key' => 'value', 'field_value' => json_encode($cellValue)]];
|
|
}
|
|
|
|
/**
|
|
* 라벨 정규화 (매칭용)
|
|
*/
|
|
private function normalizeInspectionLabel(string $label): string
|
|
{
|
|
$label = trim($label);
|
|
// ①②③ 등 번호 접두사 제거
|
|
$label = preg_replace('/^[①②③④⑤⑥⑦⑧⑨⑩]+/', '', $label);
|
|
|
|
return mb_strtolower(trim($label));
|
|
}
|
|
|
|
/**
|
|
* 작업지시 기본정보 빌드 (검사 문서 렌더링용)
|
|
*/
|
|
private function buildWorkOrderInfo(WorkOrder $workOrder): array
|
|
{
|
|
return [
|
|
'id' => $workOrder->id,
|
|
'work_order_no' => $workOrder->work_order_no,
|
|
'project_name' => $workOrder->project_name,
|
|
'status' => $workOrder->status,
|
|
'scheduled_date' => $workOrder->scheduled_date,
|
|
'sales_order' => $workOrder->salesOrder ? [
|
|
'order_no' => $workOrder->salesOrder->order_no,
|
|
'client_name' => $workOrder->salesOrder->client_name,
|
|
'site_name' => $workOrder->salesOrder->site_name,
|
|
] : null,
|
|
'items' => $workOrder->items?->map(fn ($item) => [
|
|
'id' => $item->id,
|
|
'item_name' => $item->item_name,
|
|
'specification' => $item->specification,
|
|
'quantity' => $item->quantity,
|
|
'unit' => $item->unit,
|
|
])->toArray() ?? [],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 작업지시 검사 성적서용 데이터 조회 (전체 품목 + 검사 데이터 + 주문 정보)
|
|
*/
|
|
public function getInspectionReport(int $workOrderId): array
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
|
|
$workOrder = WorkOrder::where('tenant_id', $tenantId)
|
|
->with(['salesOrder', 'items' => function ($q) {
|
|
$q->ordered()->with('sourceOrderItem');
|
|
}])
|
|
->find($workOrderId);
|
|
|
|
if (! $workOrder) {
|
|
throw new NotFoundHttpException(__('error.not_found'));
|
|
}
|
|
|
|
// 개소(order_node_id)별 그룹핑 — WorkerScreen과 동일한 구조
|
|
$grouped = $workOrder->items->groupBy(
|
|
fn ($item) => $item->sourceOrderItem?->order_node_id ?? 'unassigned'
|
|
);
|
|
|
|
$nodeIds = $grouped->keys()->filter(fn ($k) => $k !== 'unassigned')->values()->all();
|
|
$nodes = ! empty($nodeIds)
|
|
? \App\Models\Orders\OrderNode::whereIn('id', $nodeIds)->get()->keyBy('id')
|
|
: collect();
|
|
|
|
$nodeGroups = [];
|
|
foreach ($grouped as $nodeId => $groupItems) {
|
|
$node = $nodeId !== 'unassigned' ? $nodes->get($nodeId) : null;
|
|
$nodeOpts = $node?->options ?? [];
|
|
|
|
$firstItem = $groupItems->first();
|
|
$soi = $firstItem->sourceOrderItem;
|
|
$floorCode = $soi?->floor_code ?? '-';
|
|
$symbolCode = $soi?->symbol_code ?? '-';
|
|
$floorLabel = collect([$floorCode, $symbolCode])
|
|
->filter(fn ($v) => $v && $v !== '-')->join('/');
|
|
|
|
$nodeGroups[] = [
|
|
'node_id' => $nodeId !== 'unassigned' ? (int) $nodeId : null,
|
|
'node_name' => $floorLabel ?: ($node?->name ?? '미지정'),
|
|
'floor' => $nodeOpts['floor'] ?? $floorCode,
|
|
'code' => $nodeOpts['symbol'] ?? $symbolCode,
|
|
'width' => $nodeOpts['width'] ?? 0,
|
|
'height' => $nodeOpts['height'] ?? 0,
|
|
'total_quantity' => $groupItems->sum('quantity'),
|
|
'options' => $nodeOpts,
|
|
'items' => $groupItems->map(fn ($item) => [
|
|
'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(),
|
|
])->values()->all(),
|
|
];
|
|
}
|
|
|
|
// 플랫 아이템 목록 (summary 계산용)
|
|
$items = $workOrder->items->map(fn ($item) => [
|
|
'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->salesOrder ? [
|
|
'id' => $workOrder->salesOrder->id,
|
|
'order_no' => $workOrder->salesOrder->order_no,
|
|
'client_name' => $workOrder->salesOrder->client_name ?? null,
|
|
'site_name' => $workOrder->salesOrder->site_name ?? null,
|
|
'order_date' => $workOrder->salesOrder->order_date ?? null,
|
|
] : null,
|
|
'node_groups' => $nodeGroups,
|
|
'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(),
|
|
],
|
|
];
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────
|
|
// 작업일지 (Work Log)
|
|
// ──────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* 작업일지 양식 템플릿 조회
|
|
*
|
|
* 공정(Process)의 work_log_template_id 기반으로 작업일지 양식을 조회하고
|
|
* 기본필드에 작업지시 정보를 자동 매핑하여 반환
|
|
*/
|
|
public function getWorkLogTemplate(int $workOrderId): array
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
|
|
$workOrder = WorkOrder::where('tenant_id', $tenantId)
|
|
->with([
|
|
'process.workLogTemplateRelation' => fn ($q) => $q->with([
|
|
'approvalLines',
|
|
'basicFields',
|
|
'columns',
|
|
]),
|
|
'salesOrder:id,order_no,client_name,site_name,delivery_date',
|
|
'items:id,work_order_id,item_name,specification,quantity,unit,sort_order,status',
|
|
])
|
|
->find($workOrderId);
|
|
|
|
if (! $workOrder) {
|
|
throw new NotFoundHttpException(__('error.not_found'));
|
|
}
|
|
|
|
$process = $workOrder->process;
|
|
$docTemplate = $process?->workLogTemplateRelation;
|
|
|
|
if (! $docTemplate) {
|
|
return [
|
|
'work_order_id' => $workOrderId,
|
|
'has_template' => false,
|
|
'template' => null,
|
|
'work_order_info' => $this->buildWorkOrderInfo($workOrder),
|
|
];
|
|
}
|
|
|
|
$documentService = app(DocumentService::class);
|
|
$formattedTemplate = $documentService->formatTemplateForReact($docTemplate);
|
|
|
|
// 기본필드 자동 매핑 (발주처, 현장명, LOT NO 등)
|
|
$autoValues = $this->buildWorkLogAutoValues($workOrder);
|
|
|
|
return [
|
|
'work_order_id' => $workOrderId,
|
|
'has_template' => true,
|
|
'template' => $formattedTemplate,
|
|
'auto_values' => $autoValues,
|
|
'work_order_info' => $this->buildWorkOrderInfo($workOrder),
|
|
'work_stats' => $this->calculateWorkStats($workOrder),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 작업일지 조회 (기존 문서가 있으면 데이터 포함)
|
|
*/
|
|
public function getWorkLog(int $workOrderId): array
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
|
|
$workOrder = WorkOrder::where('tenant_id', $tenantId)
|
|
->with([
|
|
'process.workLogTemplateRelation' => fn ($q) => $q->with([
|
|
'approvalLines',
|
|
'basicFields',
|
|
'columns',
|
|
]),
|
|
'salesOrder:id,order_no,client_name,site_name,delivery_date',
|
|
'items:id,work_order_id,item_name,specification,quantity,unit,sort_order,status',
|
|
])
|
|
->find($workOrderId);
|
|
|
|
if (! $workOrder) {
|
|
throw new NotFoundHttpException(__('error.not_found'));
|
|
}
|
|
|
|
$process = $workOrder->process;
|
|
$templateId = $process?->work_log_template_id;
|
|
|
|
// 기존 작업일지 문서 조회
|
|
$document = null;
|
|
if ($templateId) {
|
|
$document = Document::query()
|
|
->where('tenant_id', $tenantId)
|
|
->where('template_id', $templateId)
|
|
->where('linkable_type', 'work_order')
|
|
->where('linkable_id', $workOrderId)
|
|
->with(['approvals.user:id,name', 'data'])
|
|
->latest()
|
|
->first();
|
|
}
|
|
|
|
$docTemplate = $process?->workLogTemplateRelation;
|
|
$formattedTemplate = null;
|
|
if ($docTemplate) {
|
|
$documentService = app(DocumentService::class);
|
|
$formattedTemplate = $documentService->formatTemplateForReact($docTemplate);
|
|
}
|
|
|
|
return [
|
|
'work_order_id' => $workOrderId,
|
|
'has_template' => $docTemplate !== null,
|
|
'template' => $formattedTemplate,
|
|
'document' => $document ? [
|
|
'id' => $document->id,
|
|
'document_no' => $document->document_no,
|
|
'status' => $document->status,
|
|
'submitted_at' => $document->submitted_at,
|
|
'completed_at' => $document->completed_at,
|
|
'approvals' => $document->approvals->map(fn ($a) => [
|
|
'id' => $a->id,
|
|
'step' => $a->step,
|
|
'role' => $a->role,
|
|
'status' => $a->status,
|
|
'user' => $a->user ? ['id' => $a->user->id, 'name' => $a->user->name] : null,
|
|
'comment' => $a->comment,
|
|
'acted_at' => $a->acted_at,
|
|
])->toArray(),
|
|
'data' => $document->data->map(fn ($d) => [
|
|
'field_key' => $d->field_key,
|
|
'field_value' => $d->field_value,
|
|
'section_id' => $d->section_id,
|
|
'column_id' => $d->column_id,
|
|
'row_index' => $d->row_index,
|
|
])->toArray(),
|
|
] : null,
|
|
'auto_values' => $this->buildWorkLogAutoValues($workOrder),
|
|
'work_order_info' => $this->buildWorkOrderInfo($workOrder),
|
|
'work_stats' => $this->calculateWorkStats($workOrder),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 작업일지 생성/수정 (Document 기반)
|
|
*
|
|
* 기존 DRAFT/REJECTED 문서가 있으면 update, 없으면 create
|
|
*/
|
|
public function createWorkLog(int $workOrderId, array $workLogData): array
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
|
|
$workOrder = WorkOrder::where('tenant_id', $tenantId)
|
|
->with(['process', 'salesOrder:id,order_no,client_name,site_name', 'items'])
|
|
->find($workOrderId);
|
|
|
|
if (! $workOrder) {
|
|
throw new NotFoundHttpException(__('error.not_found'));
|
|
}
|
|
|
|
$process = $workOrder->process;
|
|
$templateId = $process?->work_log_template_id;
|
|
|
|
if (! $templateId) {
|
|
throw new BadRequestHttpException(__('error.work_order.no_work_log_template'));
|
|
}
|
|
|
|
// 템플릿의 기본필드 로드 (bf_{id} 형식으로 저장하기 위해)
|
|
$template = DocumentTemplate::with('basicFields')->find($templateId);
|
|
|
|
$documentService = app(DocumentService::class);
|
|
|
|
// 기존 DRAFT/REJECTED 문서 확인
|
|
$existingDocument = Document::query()
|
|
->where('tenant_id', $tenantId)
|
|
->where('template_id', $templateId)
|
|
->where('linkable_type', 'work_order')
|
|
->where('linkable_id', $workOrderId)
|
|
->whereIn('status', [Document::STATUS_DRAFT, Document::STATUS_REJECTED])
|
|
->latest()
|
|
->first();
|
|
|
|
// 작업일지 데이터를 document_data 레코드로 변환
|
|
$documentDataRecords = $this->transformWorkLogDataToRecords($workLogData, $workOrder, $template);
|
|
|
|
if ($existingDocument) {
|
|
$document = $documentService->update($existingDocument->id, [
|
|
'title' => $workLogData['title'] ?? $existingDocument->title,
|
|
'data' => $documentDataRecords,
|
|
]);
|
|
$action = 'work_log_updated';
|
|
} else {
|
|
$document = $documentService->create([
|
|
'template_id' => $templateId,
|
|
'title' => $workLogData['title'] ?? "작업일지 - {$workOrder->work_order_no}",
|
|
'linkable_type' => 'work_order',
|
|
'linkable_id' => $workOrderId,
|
|
'data' => $documentDataRecords,
|
|
'approvers' => $workLogData['approvers'] ?? [],
|
|
]);
|
|
$action = 'work_log_created';
|
|
}
|
|
|
|
// 감사 로그
|
|
$this->auditLogger->log(
|
|
$tenantId,
|
|
self::AUDIT_TARGET,
|
|
$workOrderId,
|
|
$action,
|
|
null,
|
|
['document_id' => $document->id, 'document_no' => $document->document_no]
|
|
);
|
|
|
|
return [
|
|
'document_id' => $document->id,
|
|
'document_no' => $document->document_no,
|
|
'status' => $document->status,
|
|
'is_new' => $action === 'work_log_created',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 작업일지 기본필드 자동 매핑값 생성
|
|
*/
|
|
private function buildWorkLogAutoValues(WorkOrder $workOrder): array
|
|
{
|
|
$salesOrder = $workOrder->salesOrder;
|
|
|
|
// 수주일: received_at (date 또는 datetime)
|
|
$receivedAt = $salesOrder?->received_at;
|
|
$orderDate = $receivedAt ? substr((string) $receivedAt, 0, 10) : '';
|
|
|
|
// 납기일/출고예정일
|
|
$deliveryDate = $salesOrder?->delivery_date;
|
|
$deliveryStr = $deliveryDate ? substr((string) $deliveryDate, 0, 10) : '';
|
|
|
|
// 제품 LOT NO = 수주번호 (order_no)
|
|
$orderNo = $salesOrder?->order_no ?? '';
|
|
|
|
return [
|
|
'발주처' => $salesOrder?->client_name ?? '',
|
|
'현장명' => $salesOrder?->site_name ?? '',
|
|
'작업일자' => now()->format('Y-m-d'),
|
|
'LOT NO' => $orderNo,
|
|
'납기일' => $deliveryStr,
|
|
'작업지시번호' => $workOrder->work_order_no ?? '',
|
|
'수주일' => $orderDate,
|
|
'수주처' => $salesOrder?->client_name ?? '',
|
|
'담당자' => '',
|
|
'연락처' => '',
|
|
'제품 LOT NO' => $orderNo,
|
|
'생산담당자' => '',
|
|
'출고예정일' => $deliveryStr,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 작업 통계 계산
|
|
*/
|
|
private function calculateWorkStats(WorkOrder $workOrder): array
|
|
{
|
|
$items = $workOrder->items;
|
|
|
|
if (! $items || $items->isEmpty()) {
|
|
return [
|
|
'order_qty' => 0,
|
|
'completed_qty' => 0,
|
|
'in_progress_qty' => 0,
|
|
'waiting_qty' => 0,
|
|
'progress' => 0,
|
|
];
|
|
}
|
|
|
|
$total = $items->count();
|
|
$completed = $items->where('status', 'completed')->count();
|
|
$inProgress = $items->where('status', 'in_progress')->count();
|
|
$waiting = $total - $completed - $inProgress;
|
|
|
|
return [
|
|
'order_qty' => $total,
|
|
'completed_qty' => $completed,
|
|
'in_progress_qty' => $inProgress,
|
|
'waiting_qty' => $waiting,
|
|
'progress' => $total > 0 ? round(($completed / $total) * 100, 1) : 0,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 작업일지 데이터를 document_data 레코드로 변환
|
|
*
|
|
* mng show.blade.php 호환 형식:
|
|
* 기본필드: field_key = 'bf_{basicField->id}' (template basicFields 기반)
|
|
* 통계/비고: field_key = 'stats_*', 'remarks'
|
|
*
|
|
* auto_values로 기본필드 자동 채움, basic_data로 수동 override 가능
|
|
*/
|
|
private function transformWorkLogDataToRecords(array $workLogData, WorkOrder $workOrder, ?DocumentTemplate $template): array
|
|
{
|
|
$records = [];
|
|
|
|
// 1. 기본필드: bf_{id} 형식으로 저장 (mng show.blade.php 호환)
|
|
if ($template && $template->basicFields) {
|
|
$autoValues = $this->buildWorkLogAutoValues($workOrder);
|
|
$manualData = $workLogData['basic_data'] ?? [];
|
|
|
|
foreach ($template->basicFields as $field) {
|
|
// 수동 입력 우선, 없으면 auto_values에서 라벨로 매칭
|
|
$value = $manualData[$field->label]
|
|
?? $manualData[$field->field_key ?? '']
|
|
?? $autoValues[$field->label]
|
|
?? $field->default_value
|
|
?? '';
|
|
|
|
if ($value !== '') {
|
|
$records[] = [
|
|
'field_key' => "bf_{$field->id}",
|
|
'field_value' => (string) $value,
|
|
];
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. 작업 통계 (자동 계산)
|
|
$stats = $this->calculateWorkStats($workOrder);
|
|
foreach ($stats as $key => $value) {
|
|
$records[] = [
|
|
'field_key' => "stats_{$key}",
|
|
'field_value' => (string) $value,
|
|
];
|
|
}
|
|
|
|
// 3. 특이사항
|
|
if (isset($workLogData['remarks'])) {
|
|
$records[] = [
|
|
'field_key' => 'remarks',
|
|
'field_value' => $workLogData['remarks'],
|
|
];
|
|
}
|
|
|
|
return $records;
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────
|
|
// 개소별 자재 투입
|
|
// ──────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* 개소별 BOM 기반 필요 자재 + 재고 LOT 조회
|
|
*/
|
|
public function getMaterialsForItem(int $workOrderId, int $itemId): array
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
|
|
$workOrder = WorkOrder::where('tenant_id', $tenantId)->find($workOrderId);
|
|
if (! $workOrder) {
|
|
throw new NotFoundHttpException(__('error.not_found'));
|
|
}
|
|
|
|
$woItem = WorkOrderItem::where('tenant_id', $tenantId)
|
|
->where('work_order_id', $workOrderId)
|
|
->with('item')
|
|
->find($itemId);
|
|
|
|
if (! $woItem) {
|
|
throw new NotFoundHttpException(__('error.not_found'));
|
|
}
|
|
|
|
// 해당 개소의 BOM 기반 자재 추출
|
|
$materialItems = [];
|
|
|
|
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),
|
|
];
|
|
}
|
|
}
|
|
}
|
|
|
|
// BOM이 없으면 품목 자체를 자재로 사용
|
|
if (empty($materialItems) && $woItem->item_id && $woItem->item) {
|
|
$materialItems[] = [
|
|
'item' => $woItem->item,
|
|
'bom_qty' => 1,
|
|
'required_qty' => $woItem->quantity ?? 1,
|
|
];
|
|
}
|
|
|
|
// 이미 투입된 수량 조회 (item_id별 SUM)
|
|
$inputtedQties = WorkOrderMaterialInput::where('tenant_id', $tenantId)
|
|
->where('work_order_id', $workOrderId)
|
|
->where('work_order_item_id', $itemId)
|
|
->selectRaw('item_id, SUM(qty) as total_qty')
|
|
->groupBy('item_id')
|
|
->pluck('total_qty', 'item_id');
|
|
|
|
// 자재별 LOT 조회
|
|
$materials = [];
|
|
$rank = 1;
|
|
|
|
foreach ($materialItems as $matInfo) {
|
|
$materialItem = $matInfo['item'];
|
|
$alreadyInputted = (float) ($inputtedQties[$materialItem->id] ?? 0);
|
|
$remainingRequired = max(0, $matInfo['required_qty'] - $alreadyInputted);
|
|
|
|
$stock = \App\Models\Tenants\Stock::where('tenant_id', $tenantId)
|
|
->where('item_id', $materialItem->id)
|
|
->first();
|
|
|
|
$lotsFound = false;
|
|
|
|
if ($stock) {
|
|
$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) {
|
|
$lotsFound = true;
|
|
$materials[] = [
|
|
'stock_lot_id' => $lot->id,
|
|
'item_id' => $materialItem->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'],
|
|
'already_inputted' => $alreadyInputted,
|
|
'remaining_required_qty' => $remainingRequired,
|
|
'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++,
|
|
];
|
|
}
|
|
}
|
|
|
|
if (! $lotsFound) {
|
|
$materials[] = [
|
|
'stock_lot_id' => null,
|
|
'item_id' => $materialItem->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'],
|
|
'already_inputted' => $alreadyInputted,
|
|
'remaining_required_qty' => $remainingRequired,
|
|
'lot_qty' => 0,
|
|
'lot_available_qty' => 0,
|
|
'lot_reserved_qty' => 0,
|
|
'receipt_date' => null,
|
|
'supplier' => null,
|
|
'fifo_rank' => $rank++,
|
|
];
|
|
}
|
|
}
|
|
|
|
return $materials;
|
|
}
|
|
|
|
/**
|
|
* 개소별 자재 투입 등록
|
|
*/
|
|
public function registerMaterialInputForItem(int $workOrderId, int $itemId, 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'));
|
|
}
|
|
|
|
$woItem = WorkOrderItem::where('tenant_id', $tenantId)
|
|
->where('work_order_id', $workOrderId)
|
|
->find($itemId);
|
|
|
|
if (! $woItem) {
|
|
throw new NotFoundHttpException(__('error.not_found'));
|
|
}
|
|
|
|
return DB::transaction(function () use ($inputs, $tenantId, $userId, $workOrderId, $itemId) {
|
|
$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
|
|
);
|
|
|
|
// 로트의 품목 ID 조회
|
|
$lot = \App\Models\Tenants\StockLot::find($stockLotId);
|
|
$lotItemId = $lot ? ($lot->stock->item_id ?? null) : null;
|
|
|
|
// 개소별 매핑 레코드 생성
|
|
WorkOrderMaterialInput::create([
|
|
'tenant_id' => $tenantId,
|
|
'work_order_id' => $workOrderId,
|
|
'work_order_item_id' => $itemId,
|
|
'stock_lot_id' => $stockLotId,
|
|
'item_id' => $lotItemId ?? 0,
|
|
'qty' => $qty,
|
|
'input_by' => $userId,
|
|
'input_at' => now(),
|
|
]);
|
|
|
|
$inputResults[] = [
|
|
'stock_lot_id' => $stockLotId,
|
|
'qty' => $qty,
|
|
'status' => 'success',
|
|
'deducted_lot' => $result,
|
|
];
|
|
}
|
|
|
|
// 감사 로그
|
|
$this->auditLogger->log(
|
|
$tenantId,
|
|
self::AUDIT_TARGET,
|
|
$workOrderId,
|
|
'material_input_for_item',
|
|
null,
|
|
[
|
|
'work_order_item_id' => $itemId,
|
|
'inputs' => $inputs,
|
|
'input_results' => $inputResults,
|
|
'input_by' => $userId,
|
|
'input_at' => now()->toDateTimeString(),
|
|
]
|
|
);
|
|
|
|
return [
|
|
'work_order_id' => $workOrderId,
|
|
'work_order_item_id' => $itemId,
|
|
'material_count' => count($inputResults),
|
|
'input_results' => $inputResults,
|
|
'input_at' => now()->toDateTimeString(),
|
|
];
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 개소별 자재 투입 이력 조회
|
|
*/
|
|
public function getMaterialInputsForItem(int $workOrderId, int $itemId): array
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
|
|
$workOrder = WorkOrder::where('tenant_id', $tenantId)->find($workOrderId);
|
|
if (! $workOrder) {
|
|
throw new NotFoundHttpException(__('error.not_found'));
|
|
}
|
|
|
|
$woItem = WorkOrderItem::where('tenant_id', $tenantId)
|
|
->where('work_order_id', $workOrderId)
|
|
->find($itemId);
|
|
|
|
if (! $woItem) {
|
|
throw new NotFoundHttpException(__('error.not_found'));
|
|
}
|
|
|
|
$inputs = WorkOrderMaterialInput::where('tenant_id', $tenantId)
|
|
->where('work_order_id', $workOrderId)
|
|
->where('work_order_item_id', $itemId)
|
|
->with(['stockLot', 'item', 'inputBy'])
|
|
->orderBy('input_at', 'desc')
|
|
->get();
|
|
|
|
return $inputs->map(function ($input) {
|
|
return [
|
|
'id' => $input->id,
|
|
'stock_lot_id' => $input->stock_lot_id,
|
|
'lot_no' => $input->stockLot?->lot_no,
|
|
'item_id' => $input->item_id,
|
|
'material_code' => $input->item?->code,
|
|
'material_name' => $input->item?->name,
|
|
'qty' => (float) $input->qty,
|
|
'unit' => $input->item?->unit ?? 'EA',
|
|
'input_by' => $input->input_by,
|
|
'input_by_name' => $input->inputBy?->name,
|
|
'input_at' => $input->input_at?->toDateTimeString(),
|
|
];
|
|
})->toArray();
|
|
}
|
|
|
|
/**
|
|
* 개소별 자재 투입 삭제 (재고 복원)
|
|
*/
|
|
public function deleteMaterialInput(int $workOrderId, int $inputId): void
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
|
|
$input = WorkOrderMaterialInput::where('tenant_id', $tenantId)
|
|
->where('work_order_id', $workOrderId)
|
|
->find($inputId);
|
|
|
|
if (! $input) {
|
|
throw new NotFoundHttpException(__('error.not_found'));
|
|
}
|
|
|
|
DB::transaction(function () use ($input, $tenantId, $workOrderId) {
|
|
// 재고 복원
|
|
$stockService = app(StockService::class);
|
|
$stockService->increaseToLot(
|
|
stockLotId: $input->stock_lot_id,
|
|
qty: (float) $input->qty,
|
|
reason: 'work_order_input_cancel',
|
|
referenceId: $workOrderId
|
|
);
|
|
|
|
// 감사 로그
|
|
$this->auditLogger->log(
|
|
$tenantId,
|
|
self::AUDIT_TARGET,
|
|
$workOrderId,
|
|
'material_input_deleted',
|
|
[
|
|
'input_id' => $input->id,
|
|
'stock_lot_id' => $input->stock_lot_id,
|
|
'qty' => (float) $input->qty,
|
|
'work_order_item_id' => $input->work_order_item_id,
|
|
],
|
|
null
|
|
);
|
|
|
|
$input->delete();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 개소별 자재 투입 수량 수정 (재고 차이 반영)
|
|
*/
|
|
public function updateMaterialInput(int $workOrderId, int $inputId, float $newQty): array
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
|
|
$input = WorkOrderMaterialInput::where('tenant_id', $tenantId)
|
|
->where('work_order_id', $workOrderId)
|
|
->find($inputId);
|
|
|
|
if (! $input) {
|
|
throw new NotFoundHttpException(__('error.not_found'));
|
|
}
|
|
|
|
return DB::transaction(function () use ($input, $newQty, $tenantId, $workOrderId) {
|
|
$oldQty = (float) $input->qty;
|
|
$diff = $newQty - $oldQty;
|
|
|
|
if (abs($diff) < 0.001) {
|
|
return ['id' => $input->id, 'qty' => $oldQty, 'changed' => false];
|
|
}
|
|
|
|
$stockService = app(StockService::class);
|
|
|
|
if ($diff > 0) {
|
|
// 수량 증가 → 추가 차감
|
|
$stockService->decreaseFromLot(
|
|
stockLotId: $input->stock_lot_id,
|
|
qty: $diff,
|
|
reason: 'work_order_input',
|
|
referenceId: $workOrderId
|
|
);
|
|
} else {
|
|
// 수량 감소 → 차이만큼 복원
|
|
$stockService->increaseToLot(
|
|
stockLotId: $input->stock_lot_id,
|
|
qty: abs($diff),
|
|
reason: 'work_order_input_adjust',
|
|
referenceId: $workOrderId
|
|
);
|
|
}
|
|
|
|
// 감사 로그
|
|
$this->auditLogger->log(
|
|
$tenantId,
|
|
self::AUDIT_TARGET,
|
|
$workOrderId,
|
|
'material_input_updated',
|
|
['input_id' => $input->id, 'qty' => $oldQty],
|
|
['input_id' => $input->id, 'qty' => $newQty]
|
|
);
|
|
|
|
$input->qty = $newQty;
|
|
$input->save();
|
|
|
|
return ['id' => $input->id, 'qty' => $newQty, 'changed' => true];
|
|
});
|
|
}
|
|
}
|