refactor(work-orders): 코드 리뷰 기반 전면 개선
## Critical 수정 - Multi-tenancy: WorkOrderItem, BendingDetail, Issue에 BelongsToTenant 적용 - 감사 로그: 상태변경, 품목수정, 이슈 등록/해결 시 로깅 추가 - 상태 전이 규칙: STATUS_TRANSITIONS + canTransitionTo() 구현 ## High 수정 - 다중 담당자: work_order_assignees 피벗 테이블 및 관계 추가 - 부분 수정: 품목 ID 기반 upsert/delete 로직 구현 ## 변경 파일 - Models: WorkOrder, WorkOrderAssignee(신규), 하위 모델들 - Services: WorkOrderService (assign, update 메서드 개선) - Migrations: tenant_id 추가, assignees 테이블 생성 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -3,13 +3,21 @@
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Production\WorkOrder;
|
||||
use App\Models\Production\WorkOrderAssignee;
|
||||
use App\Models\Production\WorkOrderBendingDetail;
|
||||
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
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 목록 조회 (검색/필터링/페이징)
|
||||
*/
|
||||
@@ -29,7 +37,7 @@ public function index(array $params)
|
||||
|
||||
$query = WorkOrder::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->with(['assignee:id,name', 'team:id,name', 'salesOrder:id,order_no']);
|
||||
->with(['assignee:id,name', 'assignees.user:id,name', 'team:id,name', 'salesOrder:id,order_no']);
|
||||
|
||||
// 검색어
|
||||
if ($q !== '') {
|
||||
@@ -106,6 +114,7 @@ public function show(int $id)
|
||||
$workOrder = WorkOrder::where('tenant_id', $tenantId)
|
||||
->with([
|
||||
'assignee:id,name',
|
||||
'assignees.user:id,name',
|
||||
'team:id,name',
|
||||
'salesOrder:id,order_no,project_name',
|
||||
'items',
|
||||
@@ -149,16 +158,28 @@ public function store(array $data)
|
||||
|
||||
// 품목 저장
|
||||
foreach ($items as $index => $item) {
|
||||
$item['tenant_id'] = $tenantId;
|
||||
$item['sort_order'] = $index;
|
||||
$workOrder->items()->create($item);
|
||||
}
|
||||
|
||||
// 벤딩 상세 저장 (벤딩 공정인 경우)
|
||||
if ($data['process_type'] === WorkOrder::PROCESS_BENDING && $bendingDetail) {
|
||||
$bendingDetail['tenant_id'] = $tenantId;
|
||||
$workOrder->bendingDetail()->create($bendingDetail);
|
||||
}
|
||||
|
||||
return $workOrder->load(['assignee:id,name', 'team:id,name', 'items', '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', 'items', 'bendingDetail']);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -175,33 +196,89 @@ public function update(int $id, array $data)
|
||||
throw new NotFoundHttpException(__('error.not_found'));
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($workOrder, $data, $userId) {
|
||||
$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;
|
||||
unset($data['items'], $data['bending_detail'], $data['work_order_no']); // 번호 변경 불가
|
||||
|
||||
// 품목 수정 시 기존 품목 기록
|
||||
$oldItems = null;
|
||||
if ($items !== null) {
|
||||
$oldItems = $workOrder->items()->get()->toArray();
|
||||
}
|
||||
|
||||
$workOrder->update($data);
|
||||
|
||||
// 품목 교체 (있는 경우)
|
||||
// 품목 부분 수정 (ID 기반 upsert/delete)
|
||||
if ($items !== null) {
|
||||
$workOrder->items()->delete();
|
||||
$existingIds = $workOrder->items()->pluck('id')->toArray();
|
||||
$incomingIds = [];
|
||||
|
||||
foreach ($items as $index => $item) {
|
||||
$item['sort_order'] = $index;
|
||||
$workOrder->items()->create($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->process_type === WorkOrder::PROCESS_BENDING) {
|
||||
$bendingDetail['tenant_id'] = $workOrder->tenant_id;
|
||||
$workOrder->bendingDetail()->updateOrCreate(
|
||||
['work_order_id' => $workOrder->id],
|
||||
$bendingDetail
|
||||
);
|
||||
}
|
||||
|
||||
return $workOrder->load(['assignee:id,name', 'team:id,name', 'items', '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', 'items', 'bendingDetail']);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -226,8 +303,19 @@ public function destroy(int $id)
|
||||
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';
|
||||
}
|
||||
|
||||
@@ -249,6 +337,19 @@ public function updateStatus(int $id, string $status)
|
||||
throw new BadRequestHttpException(__('error.invalid_status'));
|
||||
}
|
||||
|
||||
// 상태 전이 규칙 검증
|
||||
if (! $workOrder->canTransitionTo($status)) {
|
||||
$allowed = implode(', ', $workOrder->getAllowedTransitions());
|
||||
throw new BadRequestHttpException(
|
||||
__('error.work_order.invalid_transition', [
|
||||
'from' => $workOrder->status,
|
||||
'to' => $status,
|
||||
'allowed' => $allowed,
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
$oldStatus = $workOrder->status;
|
||||
$workOrder->status = $status;
|
||||
$workOrder->updated_by = $userId;
|
||||
|
||||
@@ -267,11 +368,24 @@ public function updateStatus(int $id, string $status)
|
||||
|
||||
$workOrder->save();
|
||||
|
||||
return $workOrder->load(['assignee:id,name', 'team:id,name']);
|
||||
// 상태 변경 감사 로그
|
||||
$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']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 담당자 배정
|
||||
* 담당자 배정 (다중 담당자 지원)
|
||||
*
|
||||
* @param int $id 작업지시 ID
|
||||
* @param array $data 배정 데이터 (assignee_ids: int[], team_id?: int)
|
||||
*/
|
||||
public function assign(int $id, array $data)
|
||||
{
|
||||
@@ -283,18 +397,65 @@ public function assign(int $id, array $data)
|
||||
throw new NotFoundHttpException(__('error.not_found'));
|
||||
}
|
||||
|
||||
$workOrder->assignee_id = $data['assignee_id'];
|
||||
$workOrder->team_id = $data['team_id'] ?? $workOrder->team_id;
|
||||
$workOrder->updated_by = $userId;
|
||||
return DB::transaction(function () use ($workOrder, $data, $tenantId, $userId) {
|
||||
// 이전 상태 기록
|
||||
$beforeAssignees = $workOrder->assignees()->pluck('user_id')->toArray();
|
||||
$beforePrimaryAssignee = $workOrder->assignee_id;
|
||||
$beforeTeam = $workOrder->team_id;
|
||||
|
||||
// 미배정이었으면 대기로 변경
|
||||
if ($workOrder->status === WorkOrder::STATUS_UNASSIGNED) {
|
||||
$workOrder->status = WorkOrder::STATUS_PENDING;
|
||||
}
|
||||
// 담당자 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->save();
|
||||
// 기존 담당자 삭제 후 새로 추가
|
||||
$workOrder->assignees()->delete();
|
||||
|
||||
return $workOrder->load(['assignee:id,name', 'team:id,name']);
|
||||
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']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -315,15 +476,28 @@ public function toggleBendingField(int $id, string $field)
|
||||
|
||||
$detail = $workOrder->bendingDetail;
|
||||
if (! $detail) {
|
||||
$detail = $workOrder->bendingDetail()->create([]);
|
||||
$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;
|
||||
}
|
||||
|
||||
@@ -340,9 +514,22 @@ public function addIssue(int $workOrderId, array $data)
|
||||
throw new NotFoundHttpException(__('error.not_found'));
|
||||
}
|
||||
|
||||
$data['tenant_id'] = $tenantId;
|
||||
$data['reported_by'] = $userId;
|
||||
|
||||
return $workOrder->issues()->create($data);
|
||||
$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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -365,6 +552,16 @@ public function resolveIssue(int $workOrderId, int $issueId)
|
||||
|
||||
$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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user