2025-12-26 13:57:42 +09:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
namespace App\Services;
|
|
|
|
|
|
|
|
|
|
use App\Models\Production\WorkOrder;
|
2026-01-09 08:32:44 +09:00
|
|
|
use App\Models\Production\WorkOrderAssignee;
|
2025-12-26 13:57:42 +09:00
|
|
|
use App\Models\Production\WorkOrderBendingDetail;
|
2026-01-13 16:00:47 +09:00
|
|
|
use App\Models\Production\WorkOrderItem;
|
2026-01-09 08:32:44 +09:00
|
|
|
use App\Services\Audit\AuditLogger;
|
2025-12-26 13:57:42 +09:00
|
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
|
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
|
|
|
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
|
|
|
|
|
|
|
|
class WorkOrderService extends Service
|
|
|
|
|
{
|
2026-01-09 08:32:44 +09:00
|
|
|
private const AUDIT_TARGET = 'work_order';
|
|
|
|
|
|
|
|
|
|
public function __construct(
|
|
|
|
|
private readonly AuditLogger $auditLogger
|
|
|
|
|
) {}
|
|
|
|
|
|
2025-12-26 13:57:42 +09:00
|
|
|
/**
|
|
|
|
|
* 목록 조회 (검색/필터링/페이징)
|
|
|
|
|
*/
|
|
|
|
|
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;
|
2026-01-13 16:00:47 +09:00
|
|
|
$processId = $params['process_id'] ?? null;
|
2025-12-26 13:57:42 +09:00
|
|
|
$assigneeId = $params['assignee_id'] ?? null;
|
2026-01-14 19:15:25 +09:00
|
|
|
$assignedToMe = isset($params['assigned_to_me']) && $params['assigned_to_me'];
|
2025-12-26 13:57:42 +09:00
|
|
|
$teamId = $params['team_id'] ?? null;
|
|
|
|
|
$scheduledFrom = $params['scheduled_from'] ?? null;
|
|
|
|
|
$scheduledTo = $params['scheduled_to'] ?? null;
|
|
|
|
|
|
|
|
|
|
$query = WorkOrder::query()
|
|
|
|
|
->where('tenant_id', $tenantId)
|
2026-01-13 16:00:47 +09:00
|
|
|
->with(['assignee:id,name', 'assignees.user:id,name', 'team:id,name', 'salesOrder:id,order_no', 'process:id,process_name,process_code']);
|
2025-12-26 13:57:42 +09:00
|
|
|
|
|
|
|
|
// 검색어
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-13 16:00:47 +09:00
|
|
|
// 공정 필터 (process_id)
|
|
|
|
|
if ($processId !== null) {
|
|
|
|
|
$query->where('process_id', $processId);
|
2025-12-26 13:57:42 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 담당자 필터
|
|
|
|
|
if ($assigneeId !== null) {
|
|
|
|
|
$query->where('assignee_id', $assigneeId);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-14 19:15:25 +09:00
|
|
|
// 나에게 배정된 작업만 필터 (주 담당자 또는 공동 담당자)
|
|
|
|
|
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));
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-26 13:57:42 +09:00
|
|
|
// 팀 필터
|
|
|
|
|
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',
|
2026-01-09 08:32:44 +09:00
|
|
|
'assignees.user:id,name',
|
2025-12-26 13:57:42 +09:00
|
|
|
'team:id,name',
|
2026-01-13 16:00:47 +09:00
|
|
|
'salesOrder:id,order_no,site_name',
|
|
|
|
|
'salesOrder.client:id,name',
|
|
|
|
|
'process:id,process_name,process_code,work_steps',
|
2025-12-26 13:57:42 +09:00
|
|
|
'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;
|
|
|
|
|
unset($data['items'], $data['bending_detail']);
|
|
|
|
|
|
|
|
|
|
$workOrder = WorkOrder::create($data);
|
|
|
|
|
|
2026-01-13 16:00:47 +09:00
|
|
|
// process 관계 로드 (isBending 체크용)
|
|
|
|
|
$workOrder->load('process:id,process_name,process_code');
|
|
|
|
|
|
2025-12-26 13:57:42 +09:00
|
|
|
// 품목 저장
|
|
|
|
|
foreach ($items as $index => $item) {
|
2026-01-09 08:32:44 +09:00
|
|
|
$item['tenant_id'] = $tenantId;
|
2025-12-26 13:57:42 +09:00
|
|
|
$item['sort_order'] = $index;
|
|
|
|
|
$workOrder->items()->create($item);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 벤딩 상세 저장 (벤딩 공정인 경우)
|
2026-01-13 16:00:47 +09:00
|
|
|
if ($workOrder->isBending() && $bendingDetail) {
|
2026-01-09 08:32:44 +09:00
|
|
|
$bendingDetail['tenant_id'] = $tenantId;
|
2025-12-26 13:57:42 +09:00
|
|
|
$workOrder->bendingDetail()->create($bendingDetail);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-09 08:32:44 +09:00
|
|
|
// 감사 로그
|
|
|
|
|
$this->auditLogger->log(
|
|
|
|
|
$tenantId,
|
|
|
|
|
self::AUDIT_TARGET,
|
|
|
|
|
$workOrder->id,
|
|
|
|
|
'created',
|
|
|
|
|
null,
|
|
|
|
|
$workOrder->toArray()
|
|
|
|
|
);
|
|
|
|
|
|
2026-01-13 16:00:47 +09:00
|
|
|
return $workOrder->load(['assignee:id,name', 'assignees.user:id,name', 'team:id,name', 'process:id,process_name,process_code', 'items', 'bendingDetail']);
|
2025-12-26 13:57:42 +09:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 수정
|
|
|
|
|
*/
|
|
|
|
|
public function update(int $id, array $data)
|
|
|
|
|
{
|
|
|
|
|
$tenantId = $this->tenantId();
|
|
|
|
|
$userId = $this->apiUserId();
|
|
|
|
|
|
2026-01-13 16:00:47 +09:00
|
|
|
$workOrder = WorkOrder::where('tenant_id', $tenantId)
|
|
|
|
|
->with('process:id,process_name,process_code')
|
|
|
|
|
->find($id);
|
2025-12-26 13:57:42 +09:00
|
|
|
if (! $workOrder) {
|
|
|
|
|
throw new NotFoundHttpException(__('error.not_found'));
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-09 08:32:44 +09:00
|
|
|
$beforeData = $workOrder->toArray();
|
|
|
|
|
|
|
|
|
|
return DB::transaction(function () use ($workOrder, $data, $userId, $beforeData) {
|
2025-12-26 13:57:42 +09:00
|
|
|
$data['updated_by'] = $userId;
|
|
|
|
|
|
|
|
|
|
$items = $data['items'] ?? null;
|
|
|
|
|
$bendingDetail = $data['bending_detail'] ?? null;
|
|
|
|
|
unset($data['items'], $data['bending_detail'], $data['work_order_no']); // 번호 변경 불가
|
|
|
|
|
|
2026-01-09 08:32:44 +09:00
|
|
|
// 품목 수정 시 기존 품목 기록
|
|
|
|
|
$oldItems = null;
|
|
|
|
|
if ($items !== null) {
|
|
|
|
|
$oldItems = $workOrder->items()->get()->toArray();
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-26 13:57:42 +09:00
|
|
|
$workOrder->update($data);
|
|
|
|
|
|
2026-01-09 08:32:44 +09:00
|
|
|
// 품목 부분 수정 (ID 기반 upsert/delete)
|
2025-12-26 13:57:42 +09:00
|
|
|
if ($items !== null) {
|
2026-01-09 08:32:44 +09:00
|
|
|
$existingIds = $workOrder->items()->pluck('id')->toArray();
|
|
|
|
|
$incomingIds = [];
|
|
|
|
|
|
2025-12-26 13:57:42 +09:00
|
|
|
foreach ($items as $index => $item) {
|
2026-01-09 08:32:44 +09:00
|
|
|
$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;
|
|
|
|
|
}
|
2025-12-26 13:57:42 +09:00
|
|
|
}
|
2026-01-09 08:32:44 +09:00
|
|
|
|
|
|
|
|
// 요청에 없는 기존 품목 삭제
|
|
|
|
|
$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()]
|
|
|
|
|
);
|
2025-12-26 13:57:42 +09:00
|
|
|
}
|
|
|
|
|
|
2026-01-13 16:00:47 +09:00
|
|
|
// 벤딩 상세 업데이트 (벤딩 공정인 경우에만)
|
|
|
|
|
if ($bendingDetail !== null && $workOrder->isBending()) {
|
2026-01-09 08:32:44 +09:00
|
|
|
$bendingDetail['tenant_id'] = $workOrder->tenant_id;
|
2025-12-26 13:57:42 +09:00
|
|
|
$workOrder->bendingDetail()->updateOrCreate(
|
|
|
|
|
['work_order_id' => $workOrder->id],
|
|
|
|
|
$bendingDetail
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-09 08:32:44 +09:00
|
|
|
// 수정 감사 로그
|
|
|
|
|
$this->auditLogger->log(
|
|
|
|
|
$workOrder->tenant_id,
|
|
|
|
|
self::AUDIT_TARGET,
|
|
|
|
|
$workOrder->id,
|
|
|
|
|
'updated',
|
|
|
|
|
$beforeData,
|
|
|
|
|
$workOrder->fresh()->toArray()
|
|
|
|
|
);
|
|
|
|
|
|
2026-01-13 16:00:47 +09:00
|
|
|
return $workOrder->load(['assignee:id,name', 'assignees.user:id,name', 'team:id,name', 'process:id,process_name,process_code', 'items', 'bendingDetail']);
|
2025-12-26 13:57:42 +09:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 삭제
|
|
|
|
|
*/
|
|
|
|
|
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'));
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-09 08:32:44 +09:00
|
|
|
$beforeData = $workOrder->toArray();
|
2025-12-26 13:57:42 +09:00
|
|
|
$workOrder->delete();
|
|
|
|
|
|
2026-01-09 08:32:44 +09:00
|
|
|
// 삭제 감사 로그
|
|
|
|
|
$this->auditLogger->log(
|
|
|
|
|
$tenantId,
|
|
|
|
|
self::AUDIT_TARGET,
|
|
|
|
|
$workOrder->id,
|
|
|
|
|
'deleted',
|
|
|
|
|
$beforeData,
|
|
|
|
|
null
|
|
|
|
|
);
|
|
|
|
|
|
2025-12-26 13:57:42 +09:00
|
|
|
return 'success';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 상태 변경
|
2026-01-14 19:15:25 +09:00
|
|
|
*
|
|
|
|
|
* @param int $id 작업지시 ID
|
|
|
|
|
* @param string $status 변경할 상태
|
|
|
|
|
* @param array|null $resultData 완료 시 결과 데이터 (선택)
|
2025-12-26 13:57:42 +09:00
|
|
|
*/
|
2026-01-14 19:15:25 +09:00
|
|
|
public function updateStatus(int $id, string $status, ?array $resultData = null)
|
2025-12-26 13:57:42 +09:00
|
|
|
{
|
|
|
|
|
$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'));
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-09 08:32:44 +09:00
|
|
|
// 상태 전이 규칙 검증
|
|
|
|
|
if (! $workOrder->canTransitionTo($status)) {
|
|
|
|
|
$allowed = implode(', ', $workOrder->getAllowedTransitions());
|
|
|
|
|
throw new BadRequestHttpException(
|
|
|
|
|
__('error.work_order.invalid_transition', [
|
|
|
|
|
'from' => $workOrder->status,
|
|
|
|
|
'to' => $status,
|
|
|
|
|
'allowed' => $allowed,
|
|
|
|
|
])
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-14 19:15:25 +09:00
|
|
|
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:
|
|
|
|
|
$workOrder->completed_at = now();
|
|
|
|
|
// 모든 품목에 결과 데이터 저장
|
|
|
|
|
$this->saveItemResults($workOrder, $resultData, $userId);
|
|
|
|
|
break;
|
|
|
|
|
case WorkOrder::STATUS_SHIPPED:
|
|
|
|
|
$workOrder->shipped_at = now();
|
|
|
|
|
break;
|
|
|
|
|
}
|
2025-12-26 13:57:42 +09:00
|
|
|
|
2026-01-14 19:15:25 +09:00
|
|
|
$workOrder->save();
|
2025-12-26 13:57:42 +09:00
|
|
|
|
2026-01-14 19:15:25 +09:00
|
|
|
// 상태 변경 감사 로그
|
|
|
|
|
$this->auditLogger->log(
|
|
|
|
|
$tenantId,
|
|
|
|
|
self::AUDIT_TARGET,
|
|
|
|
|
$workOrder->id,
|
|
|
|
|
'status_changed',
|
|
|
|
|
['status' => $oldStatus],
|
|
|
|
|
['status' => $status]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return $workOrder->load(['assignee:id,name', 'assignees.user:id,name', 'team:id,name', 'process:id,process_name,process_code']);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 작업지시 품목에 결과 데이터 저장
|
|
|
|
|
*/
|
|
|
|
|
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;
|
|
|
|
|
}
|
2026-01-09 08:32:44 +09:00
|
|
|
|
2026-01-14 19:15:25 +09:00
|
|
|
return sprintf('%s-%s-%02d', $prefix, $date, $seq);
|
2025-12-26 13:57:42 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-01-09 08:32:44 +09:00
|
|
|
* 담당자 배정 (다중 담당자 지원)
|
|
|
|
|
*
|
|
|
|
|
* @param int $id 작업지시 ID
|
|
|
|
|
* @param array $data 배정 데이터 (assignee_ids: int[], team_id?: int)
|
2025-12-26 13:57:42 +09:00
|
|
|
*/
|
|
|
|
|
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'));
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-09 08:32:44 +09:00
|
|
|
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, // 첫 번째가 주 담당자
|
|
|
|
|
]);
|
|
|
|
|
}
|
2025-12-26 13:57:42 +09:00
|
|
|
|
2026-01-09 08:32:44 +09:00
|
|
|
// 주 담당자는 work_orders 테이블에도 설정 (하위 호환)
|
|
|
|
|
$primaryAssigneeId = $assigneeIds[0] ?? null;
|
|
|
|
|
$workOrder->assignee_id = $primaryAssigneeId;
|
|
|
|
|
$workOrder->team_id = $data['team_id'] ?? $workOrder->team_id;
|
|
|
|
|
$workOrder->updated_by = $userId;
|
2025-12-26 13:57:42 +09:00
|
|
|
|
2026-01-09 08:32:44 +09:00
|
|
|
// 미배정이었으면 대기로 변경
|
|
|
|
|
if ($workOrder->status === WorkOrder::STATUS_UNASSIGNED && $primaryAssigneeId) {
|
|
|
|
|
$workOrder->status = WorkOrder::STATUS_PENDING;
|
|
|
|
|
}
|
2025-12-26 13:57:42 +09:00
|
|
|
|
2026-01-09 08:32:44 +09:00
|
|
|
$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,
|
|
|
|
|
]
|
|
|
|
|
);
|
|
|
|
|
|
2026-01-13 16:00:47 +09:00
|
|
|
return $workOrder->load(['assignee:id,name', 'assignees.user:id,name', 'team:id,name', 'process:id,process_name,process_code']);
|
2026-01-09 08:32:44 +09:00
|
|
|
});
|
2025-12-26 13:57:42 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 벤딩 항목 토글
|
|
|
|
|
*/
|
|
|
|
|
public function toggleBendingField(int $id, string $field)
|
|
|
|
|
{
|
|
|
|
|
$tenantId = $this->tenantId();
|
|
|
|
|
|
2026-01-13 16:00:47 +09:00
|
|
|
$workOrder = WorkOrder::where('tenant_id', $tenantId)
|
|
|
|
|
->with('process:id,process_name,process_code')
|
|
|
|
|
->find($id);
|
2025-12-26 13:57:42 +09:00
|
|
|
if (! $workOrder) {
|
|
|
|
|
throw new NotFoundHttpException(__('error.not_found'));
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-13 16:00:47 +09:00
|
|
|
if (! $workOrder->isBending()) {
|
2025-12-26 13:57:42 +09:00
|
|
|
throw new BadRequestHttpException(__('error.work_order.not_bending_process'));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$detail = $workOrder->bendingDetail;
|
|
|
|
|
if (! $detail) {
|
2026-01-09 08:32:44 +09:00
|
|
|
$detail = $workOrder->bendingDetail()->create([
|
|
|
|
|
'tenant_id' => $workOrder->tenant_id,
|
|
|
|
|
]);
|
2025-12-26 13:57:42 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (! in_array($field, WorkOrderBendingDetail::PROCESS_FIELDS)) {
|
|
|
|
|
throw new BadRequestHttpException(__('error.invalid_field'));
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-09 08:32:44 +09:00
|
|
|
$beforeValue = $detail->{$field};
|
2025-12-26 13:57:42 +09:00
|
|
|
$detail->toggleField($field);
|
|
|
|
|
|
2026-01-09 08:32:44 +09:00
|
|
|
// 벤딩 토글 감사 로그
|
|
|
|
|
$this->auditLogger->log(
|
|
|
|
|
$workOrder->tenant_id,
|
|
|
|
|
self::AUDIT_TARGET,
|
|
|
|
|
$workOrder->id,
|
|
|
|
|
'bending_toggled',
|
|
|
|
|
[$field => $beforeValue],
|
|
|
|
|
[$field => $detail->{$field}]
|
|
|
|
|
);
|
|
|
|
|
|
2025-12-26 13:57:42 +09:00
|
|
|
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'));
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-09 08:32:44 +09:00
|
|
|
$data['tenant_id'] = $tenantId;
|
2025-12-26 13:57:42 +09:00
|
|
|
$data['reported_by'] = $userId;
|
|
|
|
|
|
2026-01-09 08:32:44 +09:00
|
|
|
$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;
|
2025-12-26 13:57:42 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 이슈 해결
|
|
|
|
|
*/
|
|
|
|
|
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);
|
|
|
|
|
|
2026-01-09 08:32:44 +09:00
|
|
|
// 이슈 해결 감사 로그
|
|
|
|
|
$this->auditLogger->log(
|
|
|
|
|
$tenantId,
|
|
|
|
|
self::AUDIT_TARGET,
|
|
|
|
|
$workOrderId,
|
|
|
|
|
'issue_resolved',
|
|
|
|
|
['issue_id' => $issueId, 'status' => 'open'],
|
|
|
|
|
['issue_id' => $issueId, 'status' => 'resolved', 'resolved_by' => $userId]
|
|
|
|
|
);
|
|
|
|
|
|
2025-12-26 13:57:42 +09:00
|
|
|
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);
|
|
|
|
|
}
|
2026-01-13 16:00:47 +09:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 품목 상태 변경
|
|
|
|
|
*/
|
|
|
|
|
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,
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 품목 상태 기반으로 작업지시 상태 자동 동기화
|
|
|
|
|
*
|
|
|
|
|
* 규칙:
|
2026-01-14 15:37:28 +09:00
|
|
|
* - 품목 중 하나라도 in_progress → 작업지시 in_progress (pending에서도 자동 전환)
|
2026-01-13 16:00:47 +09:00
|
|
|
* - 모든 품목이 completed → 작업지시 completed
|
|
|
|
|
* - 모든 품목이 waiting → 작업지시 waiting (단, waiting 이상인 경우만)
|
2026-01-14 15:37:28 +09:00
|
|
|
* - 미배정(unassigned) 상태에서는 동기화하지 않음
|
2026-01-13 16:00:47 +09:00
|
|
|
*
|
|
|
|
|
* @return bool 상태 변경 여부
|
|
|
|
|
*/
|
|
|
|
|
private function syncWorkOrderStatusFromItems(WorkOrder $workOrder): bool
|
|
|
|
|
{
|
|
|
|
|
// 품목이 없으면 동기화하지 않음
|
|
|
|
|
$items = $workOrder->items()->get();
|
|
|
|
|
if ($items->isEmpty()) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-14 15:37:28 +09:00
|
|
|
// 미배정(unassigned) 상태에서는 동기화하지 않음 (배정 없이 작업 시작 불가)
|
|
|
|
|
if ($workOrder->status === WorkOrder::STATUS_UNASSIGNED) {
|
2026-01-13 16:00:47 +09:00
|
|
|
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]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return false;
|
|
|
|
|
}
|
2025-12-26 13:57:42 +09:00
|
|
|
}
|