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:
2026-01-09 08:32:44 +09:00
parent 18aa745518
commit 349917f019
8 changed files with 490 additions and 22 deletions

View File

@@ -98,6 +98,19 @@ class WorkOrder extends Model
self::STATUS_SHIPPED,
];
/**
* 상태 전이 규칙
* [현재 상태 => [허용되는 다음 상태들]]
*/
public const STATUS_TRANSITIONS = [
self::STATUS_UNASSIGNED => [self::STATUS_PENDING],
self::STATUS_PENDING => [self::STATUS_UNASSIGNED, self::STATUS_WAITING],
self::STATUS_WAITING => [self::STATUS_PENDING, self::STATUS_IN_PROGRESS],
self::STATUS_IN_PROGRESS => [self::STATUS_WAITING, self::STATUS_COMPLETED],
self::STATUS_COMPLETED => [self::STATUS_IN_PROGRESS, self::STATUS_SHIPPED],
self::STATUS_SHIPPED => [self::STATUS_COMPLETED],
];
// ──────────────────────────────────────────────────────────────
// 관계
// ──────────────────────────────────────────────────────────────
@@ -111,13 +124,29 @@ public function salesOrder(): BelongsTo
}
/**
* 담당자
* 담당자 (주 담당자 - 하위 호환)
*/
public function assignee(): BelongsTo
{
return $this->belongsTo(User::class, 'assignee_id');
}
/**
* 담당자들 (다중 담당자)
*/
public function assignees(): HasMany
{
return $this->hasMany(WorkOrderAssignee::class);
}
/**
* 주 담당자 (다중 담당자 중)
*/
public function primaryAssignee(): HasMany
{
return $this->hasMany(WorkOrderAssignee::class)->where('is_primary', true);
}
/**
* 팀
*/
@@ -291,4 +320,41 @@ public function getOpenIssuesCountAttribute(): int
{
return $this->issues()->where('status', '!=', 'resolved')->count();
}
/**
* 특정 상태로 전이 가능한지 확인
*/
public function canTransitionTo(string $newStatus): bool
{
$allowedTransitions = self::STATUS_TRANSITIONS[$this->status] ?? [];
return in_array($newStatus, $allowedTransitions);
}
/**
* 현재 상태에서 허용되는 다음 상태 목록 조회
*/
public function getAllowedTransitions(): array
{
return self::STATUS_TRANSITIONS[$this->status] ?? [];
}
/**
* 상태 전이 실행 (유효성 검증 포함)
*
* @throws \InvalidArgumentException 허용되지 않은 전이인 경우
*/
public function transitionTo(string $newStatus): bool
{
if (! $this->canTransitionTo($newStatus)) {
$allowed = implode(', ', $this->getAllowedTransitions());
throw new \InvalidArgumentException(
"상태를 '{$this->status}'에서 '{$newStatus}'(으)로 변경할 수 없습니다. 허용된 상태: {$allowed}"
);
}
$this->status = $newStatus;
return true;
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace App\Models\Production;
use App\Models\Members\User;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 작업지시 담당자 피벗 모델
*
* 다중 담당자 지원을 위한 피벗 테이블 모델
*/
class WorkOrderAssignee extends Model
{
use BelongsToTenant, ModelTrait;
protected $table = 'work_order_assignees';
protected $fillable = [
'tenant_id',
'work_order_id',
'user_id',
'is_primary',
];
protected $casts = [
'is_primary' => 'boolean',
];
// ──────────────────────────────────────────────────────────────
// 관계
// ──────────────────────────────────────────────────────────────
/**
* 작업지시
*/
public function workOrder(): BelongsTo
{
return $this->belongsTo(WorkOrder::class);
}
/**
* 담당자
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
// ──────────────────────────────────────────────────────────────
// 스코프
// ──────────────────────────────────────────────────────────────
/**
* 주 담당자만
*/
public function scopePrimary($query)
{
return $query->where('is_primary', true);
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Models\Production;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -12,9 +13,12 @@
*/
class WorkOrderBendingDetail extends Model
{
use BelongsToTenant;
protected $table = 'work_order_bending_details';
protected $fillable = [
'tenant_id',
'work_order_id',
'shaft_cutting',
'bearing',

View File

@@ -3,6 +3,7 @@
namespace App\Models\Production;
use App\Models\Members\User;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -13,9 +14,12 @@
*/
class WorkOrderIssue extends Model
{
use BelongsToTenant;
protected $table = 'work_order_issues';
protected $fillable = [
'tenant_id',
'work_order_id',
'title',
'description',

View File

@@ -3,6 +3,7 @@
namespace App\Models\Production;
use App\Models\Items\Item;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -11,9 +12,12 @@
*/
class WorkOrderItem extends Model
{
use BelongsToTenant;
protected $table = 'work_order_items';
protected $fillable = [
'tenant_id',
'work_order_id',
'item_id',
'item_name',

View File

@@ -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;
}

View File

@@ -0,0 +1,88 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 작업지시 하위 테이블에 tenant_id 컬럼 추가
* - work_order_items
* - work_order_bending_details
* - work_order_issues
*
* 기존 데이터는 work_orders 테이블의 tenant_id를 참조하여 업데이트
*/
public function up(): void
{
// 1. work_order_items
Schema::table('work_order_items', function (Blueprint $table) {
$table->unsignedBigInteger('tenant_id')->nullable()->after('id')->comment('테넌트ID');
$table->index('tenant_id', 'idx_work_order_items_tenant');
});
// 기존 데이터 업데이트
DB::statement('
UPDATE work_order_items wi
JOIN work_orders wo ON wi.work_order_id = wo.id
SET wi.tenant_id = wo.tenant_id
');
// nullable 제거
Schema::table('work_order_items', function (Blueprint $table) {
$table->unsignedBigInteger('tenant_id')->nullable(false)->change();
});
// 2. work_order_bending_details
Schema::table('work_order_bending_details', function (Blueprint $table) {
$table->unsignedBigInteger('tenant_id')->nullable()->after('id')->comment('테넌트ID');
$table->index('tenant_id', 'idx_work_order_bending_details_tenant');
});
DB::statement('
UPDATE work_order_bending_details wbd
JOIN work_orders wo ON wbd.work_order_id = wo.id
SET wbd.tenant_id = wo.tenant_id
');
Schema::table('work_order_bending_details', function (Blueprint $table) {
$table->unsignedBigInteger('tenant_id')->nullable(false)->change();
});
// 3. work_order_issues
Schema::table('work_order_issues', function (Blueprint $table) {
$table->unsignedBigInteger('tenant_id')->nullable()->after('id')->comment('테넌트ID');
$table->index('tenant_id', 'idx_work_order_issues_tenant');
});
DB::statement('
UPDATE work_order_issues woi
JOIN work_orders wo ON woi.work_order_id = wo.id
SET woi.tenant_id = wo.tenant_id
');
Schema::table('work_order_issues', function (Blueprint $table) {
$table->unsignedBigInteger('tenant_id')->nullable(false)->change();
});
}
public function down(): void
{
Schema::table('work_order_items', function (Blueprint $table) {
$table->dropIndex('idx_work_order_items_tenant');
$table->dropColumn('tenant_id');
});
Schema::table('work_order_bending_details', function (Blueprint $table) {
$table->dropIndex('idx_work_order_bending_details_tenant');
$table->dropColumn('tenant_id');
});
Schema::table('work_order_issues', function (Blueprint $table) {
$table->dropIndex('idx_work_order_issues_tenant');
$table->dropColumn('tenant_id');
});
}
};

View File

@@ -0,0 +1,41 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 작업지시 담당자 피벗 테이블 (Work Order Assignees)
* - 다중 담당자 지원
* - 주 담당자 구분 (is_primary)
*/
public function up(): void
{
Schema::create('work_order_assignees', function (Blueprint $table) {
$table->bigIncrements('id');
$table->unsignedBigInteger('tenant_id')->comment('테넌트ID');
$table->unsignedBigInteger('work_order_id')->comment('작업지시ID');
$table->unsignedBigInteger('user_id')->comment('담당자ID');
$table->boolean('is_primary')->default(false)->comment('주담당자 여부');
$table->timestamps();
// Indexes
$table->unique(['work_order_id', 'user_id'], 'uq_work_order_assignees');
$table->index(['tenant_id', 'work_order_id'], 'idx_wo_assignees_tenant_wo');
$table->index(['tenant_id', 'user_id'], 'idx_wo_assignees_tenant_user');
// Foreign keys
$table->foreign('work_order_id')
->references('id')
->on('work_orders')
->onDelete('cascade');
});
}
public function down(): void
{
Schema::dropIfExists('work_order_assignees');
}
};