feat(work-order): 품목 상태 변경 및 작업지시 상태 자동 연동

- WorkOrderItem 모델에 status 컬럼 및 상수 추가 (waiting/in_progress/completed)
- 품목 상태 변경 API 엔드포인트 추가 (PATCH /work-orders/{id}/items/{itemId}/status)
- syncWorkOrderStatusFromItems() 메서드로 품목→작업지시 상태 자동 동기화
  - 품목 중 하나라도 in_progress → 작업지시 in_progress
  - 모든 품목 completed → 작업지시 completed
  - 모든 품목 waiting → 작업지시 waiting
- 감사 로그: item_status_changed, status_synced_from_items 액션 추가

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-01-13 16:00:47 +09:00
parent 84ad9e1fc4
commit 38d56aa564
5 changed files with 224 additions and 18 deletions

View File

@@ -127,4 +127,14 @@ public function resolveIssue(int $workOrderId, int $issueId)
return $this->service->resolveIssue($workOrderId, $issueId); return $this->service->resolveIssue($workOrderId, $issueId);
}, __('message.work_order.issue_resolved')); }, __('message.work_order.issue_resolved'));
} }
/**
* 품목 상태 변경
*/
public function updateItemStatus(Request $request, int $workOrderId, int $itemId)
{
return ApiResponse::handle(function () use ($request, $workOrderId, $itemId) {
return $this->service->updateItemStatus($workOrderId, $itemId, $request->input('status'));
}, __('message.work_order.item_status_updated'));
}
} }

View File

@@ -25,6 +25,20 @@ class WorkOrderItem extends Model
'quantity', 'quantity',
'unit', 'unit',
'sort_order', 'sort_order',
'status',
];
/**
* 품목 상태 상수
*/
public const STATUS_WAITING = 'waiting';
public const STATUS_IN_PROGRESS = 'in_progress';
public const STATUS_COMPLETED = 'completed';
public const STATUSES = [
self::STATUS_WAITING,
self::STATUS_IN_PROGRESS,
self::STATUS_COMPLETED,
]; ];
protected $casts = [ protected $casts = [

View File

@@ -5,6 +5,7 @@
use App\Models\Production\WorkOrder; use App\Models\Production\WorkOrder;
use App\Models\Production\WorkOrderAssignee; use App\Models\Production\WorkOrderAssignee;
use App\Models\Production\WorkOrderBendingDetail; use App\Models\Production\WorkOrderBendingDetail;
use App\Models\Production\WorkOrderItem;
use App\Services\Audit\AuditLogger; use App\Services\Audit\AuditLogger;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
@@ -29,7 +30,7 @@ public function index(array $params)
$size = (int) ($params['size'] ?? 20); $size = (int) ($params['size'] ?? 20);
$q = trim((string) ($params['q'] ?? '')); $q = trim((string) ($params['q'] ?? ''));
$status = $params['status'] ?? null; $status = $params['status'] ?? null;
$processType = $params['process_type'] ?? null; $processId = $params['process_id'] ?? null;
$assigneeId = $params['assignee_id'] ?? null; $assigneeId = $params['assignee_id'] ?? null;
$teamId = $params['team_id'] ?? null; $teamId = $params['team_id'] ?? null;
$scheduledFrom = $params['scheduled_from'] ?? null; $scheduledFrom = $params['scheduled_from'] ?? null;
@@ -37,7 +38,7 @@ public function index(array $params)
$query = WorkOrder::query() $query = WorkOrder::query()
->where('tenant_id', $tenantId) ->where('tenant_id', $tenantId)
->with(['assignee:id,name', 'assignees.user:id,name', 'team:id,name', 'salesOrder:id,order_no']); ->with(['assignee:id,name', 'assignees.user:id,name', 'team:id,name', 'salesOrder:id,order_no', 'process:id,process_name,process_code']);
// 검색어 // 검색어
if ($q !== '') { if ($q !== '') {
@@ -52,9 +53,9 @@ public function index(array $params)
$query->where('status', $status); $query->where('status', $status);
} }
// 공정유형 필터 // 공정 필터 (process_id)
if ($processType !== null) { if ($processId !== null) {
$query->where('process_type', $processType); $query->where('process_id', $processId);
} }
// 담당자 필터 // 담당자 필터
@@ -116,7 +117,9 @@ public function show(int $id)
'assignee:id,name', 'assignee:id,name',
'assignees.user:id,name', 'assignees.user:id,name',
'team:id,name', 'team:id,name',
'salesOrder:id,order_no,project_name', 'salesOrder:id,order_no,site_name',
'salesOrder.client:id,name',
'process:id,process_name,process_code,work_steps',
'items', 'items',
'bendingDetail', 'bendingDetail',
'issues' => fn ($q) => $q->orderByDesc('created_at'), 'issues' => fn ($q) => $q->orderByDesc('created_at'),
@@ -156,6 +159,9 @@ public function store(array $data)
$workOrder = WorkOrder::create($data); $workOrder = WorkOrder::create($data);
// process 관계 로드 (isBending 체크용)
$workOrder->load('process:id,process_name,process_code');
// 품목 저장 // 품목 저장
foreach ($items as $index => $item) { foreach ($items as $index => $item) {
$item['tenant_id'] = $tenantId; $item['tenant_id'] = $tenantId;
@@ -164,7 +170,7 @@ public function store(array $data)
} }
// 벤딩 상세 저장 (벤딩 공정인 경우) // 벤딩 상세 저장 (벤딩 공정인 경우)
if ($data['process_type'] === WorkOrder::PROCESS_BENDING && $bendingDetail) { if ($workOrder->isBending() && $bendingDetail) {
$bendingDetail['tenant_id'] = $tenantId; $bendingDetail['tenant_id'] = $tenantId;
$workOrder->bendingDetail()->create($bendingDetail); $workOrder->bendingDetail()->create($bendingDetail);
} }
@@ -179,7 +185,7 @@ public function store(array $data)
$workOrder->toArray() $workOrder->toArray()
); );
return $workOrder->load(['assignee:id,name', 'assignees.user:id,name', 'team:id,name', 'items', 'bendingDetail']); return $workOrder->load(['assignee:id,name', 'assignees.user:id,name', 'team:id,name', 'process:id,process_name,process_code', 'items', 'bendingDetail']);
}); });
} }
@@ -191,7 +197,9 @@ public function update(int $id, array $data)
$tenantId = $this->tenantId(); $tenantId = $this->tenantId();
$userId = $this->apiUserId(); $userId = $this->apiUserId();
$workOrder = WorkOrder::where('tenant_id', $tenantId)->find($id); $workOrder = WorkOrder::where('tenant_id', $tenantId)
->with('process:id,process_name,process_code')
->find($id);
if (! $workOrder) { if (! $workOrder) {
throw new NotFoundHttpException(__('error.not_found')); throw new NotFoundHttpException(__('error.not_found'));
} }
@@ -259,8 +267,8 @@ public function update(int $id, array $data)
); );
} }
// 벤딩 상세 업데이트 // 벤딩 상세 업데이트 (벤딩 공정인 경우에만)
if ($bendingDetail !== null && $workOrder->process_type === WorkOrder::PROCESS_BENDING) { if ($bendingDetail !== null && $workOrder->isBending()) {
$bendingDetail['tenant_id'] = $workOrder->tenant_id; $bendingDetail['tenant_id'] = $workOrder->tenant_id;
$workOrder->bendingDetail()->updateOrCreate( $workOrder->bendingDetail()->updateOrCreate(
['work_order_id' => $workOrder->id], ['work_order_id' => $workOrder->id],
@@ -278,7 +286,7 @@ public function update(int $id, array $data)
$workOrder->fresh()->toArray() $workOrder->fresh()->toArray()
); );
return $workOrder->load(['assignee:id,name', 'assignees.user:id,name', 'team:id,name', 'items', 'bendingDetail']); return $workOrder->load(['assignee:id,name', 'assignees.user:id,name', 'team:id,name', 'process:id,process_name,process_code', 'items', 'bendingDetail']);
}); });
} }
@@ -378,7 +386,7 @@ public function updateStatus(int $id, string $status)
['status' => $status] ['status' => $status]
); );
return $workOrder->load(['assignee:id,name', 'assignees.user:id,name', 'team:id,name']); return $workOrder->load(['assignee:id,name', 'assignees.user:id,name', 'team:id,name', 'process:id,process_name,process_code']);
} }
/** /**
@@ -454,7 +462,7 @@ public function assign(int $id, array $data)
] ]
); );
return $workOrder->load(['assignee:id,name', 'assignees.user:id,name', 'team:id,name']); return $workOrder->load(['assignee:id,name', 'assignees.user:id,name', 'team:id,name', 'process:id,process_name,process_code']);
}); });
} }
@@ -465,12 +473,14 @@ public function toggleBendingField(int $id, string $field)
{ {
$tenantId = $this->tenantId(); $tenantId = $this->tenantId();
$workOrder = WorkOrder::where('tenant_id', $tenantId)->find($id); $workOrder = WorkOrder::where('tenant_id', $tenantId)
->with('process:id,process_name,process_code')
->find($id);
if (! $workOrder) { if (! $workOrder) {
throw new NotFoundHttpException(__('error.not_found')); throw new NotFoundHttpException(__('error.not_found'));
} }
if ($workOrder->process_type !== WorkOrder::PROCESS_BENDING) { if (! $workOrder->isBending()) {
throw new BadRequestHttpException(__('error.work_order.not_bending_process')); throw new BadRequestHttpException(__('error.work_order.not_bending_process'));
} }
@@ -588,4 +598,131 @@ private function generateWorkOrderNo(int $tenantId): string
return sprintf('%s%s%04d', $prefix, $date, $seq); 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,
];
}
/**
* 품목 상태 기반으로 작업지시 상태 자동 동기화
*
* 규칙:
* - 품목 중 하나라도 in_progress → 작업지시 in_progress
* - 모든 품목이 completed → 작업지시 completed
* - 모든 품목이 waiting → 작업지시 waiting (단, waiting 이상인 경우만)
*
* @return bool 상태 변경 여부
*/
private function syncWorkOrderStatusFromItems(WorkOrder $workOrder): bool
{
// 품목이 없으면 동기화하지 않음
$items = $workOrder->items()->get();
if ($items->isEmpty()) {
return false;
}
// 작업 가능 상태가 아니면 동기화하지 않음 (unassigned, pending은 제외)
$syncableStatuses = [
WorkOrder::STATUS_WAITING,
WorkOrder::STATUS_IN_PROGRESS,
WorkOrder::STATUS_COMPLETED,
];
if (! in_array($workOrder->status, $syncableStatuses)) {
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;
}
} }

View File

@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('work_order_items', function (Blueprint $table) {
$table->string('status', 20)->default('waiting')->after('sort_order')
->comment('품목 상태: waiting=대기, in_progress=작업중, completed=완료');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('work_order_items', function (Blueprint $table) {
$table->dropColumn('status');
});
}
};

View File

@@ -58,6 +58,7 @@
// use App\Http\Controllers\Api\V1\MaterialController; // REMOVED: materials 테이블 삭제됨 // use App\Http\Controllers\Api\V1\MaterialController; // REMOVED: materials 테이블 삭제됨
use App\Http\Controllers\Api\V1\ItemsController; use App\Http\Controllers\Api\V1\ItemsController;
use App\Http\Controllers\Api\V1\ItemsFileController; use App\Http\Controllers\Api\V1\ItemsFileController;
use App\Http\Controllers\Api\V1\LaborController;
use App\Http\Controllers\Api\V1\LeaveController; use App\Http\Controllers\Api\V1\LeaveController;
use App\Http\Controllers\Api\V1\LeavePolicyController; use App\Http\Controllers\Api\V1\LeavePolicyController;
use App\Http\Controllers\Api\V1\LoanController; use App\Http\Controllers\Api\V1\LoanController;
@@ -69,10 +70,9 @@
use App\Http\Controllers\Api\V1\PayrollController; use App\Http\Controllers\Api\V1\PayrollController;
use App\Http\Controllers\Api\V1\PermissionController; use App\Http\Controllers\Api\V1\PermissionController;
use App\Http\Controllers\Api\V1\PlanController; use App\Http\Controllers\Api\V1\PlanController;
use App\Http\Controllers\Api\V1\PopupController;
// use App\Http\Controllers\Api\V1\ProductBomItemController; // REMOVED: products 테이블 삭제됨 // use App\Http\Controllers\Api\V1\ProductBomItemController; // REMOVED: products 테이블 삭제됨
// use App\Http\Controllers\Api\V1\ProductController; // REMOVED: products 테이블 삭제됨 // use App\Http\Controllers\Api\V1\ProductController; // REMOVED: products 테이블 삭제됨
use App\Http\Controllers\Api\V1\LaborController; use App\Http\Controllers\Api\V1\PopupController;
use App\Http\Controllers\Api\V1\PositionController; use App\Http\Controllers\Api\V1\PositionController;
use App\Http\Controllers\Api\V1\PostController; use App\Http\Controllers\Api\V1\PostController;
use App\Http\Controllers\Api\V1\PricingController; use App\Http\Controllers\Api\V1\PricingController;
@@ -89,6 +89,7 @@
use App\Http\Controllers\Api\V1\SalaryController; use App\Http\Controllers\Api\V1\SalaryController;
use App\Http\Controllers\Api\V1\SaleController; use App\Http\Controllers\Api\V1\SaleController;
use App\Http\Controllers\Api\V1\ShipmentController; use App\Http\Controllers\Api\V1\ShipmentController;
use App\Http\Controllers\Api\V1\SiteBriefingController;
use App\Http\Controllers\Api\V1\SiteController; use App\Http\Controllers\Api\V1\SiteController;
use App\Http\Controllers\Api\V1\StockController; use App\Http\Controllers\Api\V1\StockController;
use App\Http\Controllers\Api\V1\SubscriptionController; use App\Http\Controllers\Api\V1\SubscriptionController;
@@ -425,6 +426,17 @@
Route::delete('/{id}', [SiteController::class, 'destroy'])->whereNumber('id')->name('v1.sites.destroy'); Route::delete('/{id}', [SiteController::class, 'destroy'])->whereNumber('id')->name('v1.sites.destroy');
}); });
// Site Briefing API (현장설명회 관리)
Route::prefix('site-briefings')->group(function () {
Route::get('', [SiteBriefingController::class, 'index'])->name('v1.site-briefings.index');
Route::post('', [SiteBriefingController::class, 'store'])->name('v1.site-briefings.store');
Route::get('/stats', [SiteBriefingController::class, 'stats'])->name('v1.site-briefings.stats');
Route::delete('/bulk', [SiteBriefingController::class, 'bulkDestroy'])->name('v1.site-briefings.bulk-destroy');
Route::get('/{id}', [SiteBriefingController::class, 'show'])->whereNumber('id')->name('v1.site-briefings.show');
Route::put('/{id}', [SiteBriefingController::class, 'update'])->whereNumber('id')->name('v1.site-briefings.update');
Route::delete('/{id}', [SiteBriefingController::class, 'destroy'])->whereNumber('id')->name('v1.site-briefings.destroy');
});
// Construction API (시공관리) // Construction API (시공관리)
Route::prefix('construction')->group(function () { Route::prefix('construction')->group(function () {
// Contract API (계약관리) // Contract API (계약관리)
@@ -1105,6 +1117,7 @@
// 견적 관리 API // 견적 관리 API
Route::prefix('estimates')->group(function () { Route::prefix('estimates')->group(function () {
Route::get('/', [EstimateController::class, 'index'])->name('v1.estimates.index'); // 견적 목록 Route::get('/', [EstimateController::class, 'index'])->name('v1.estimates.index'); // 견적 목록
Route::get('/stats', [EstimateController::class, 'stats'])->name('v1.estimates.stats'); // 견적 통계
Route::post('/', [EstimateController::class, 'store'])->name('v1.estimates.store'); // 견적 생성 Route::post('/', [EstimateController::class, 'store'])->name('v1.estimates.store'); // 견적 생성
Route::get('/{id}', [EstimateController::class, 'show'])->name('v1.estimates.show'); // 견적 상세 Route::get('/{id}', [EstimateController::class, 'show'])->name('v1.estimates.show'); // 견적 상세
Route::put('/{id}', [EstimateController::class, 'update'])->name('v1.estimates.update'); // 견적 수정 Route::put('/{id}', [EstimateController::class, 'update'])->name('v1.estimates.update'); // 견적 수정
@@ -1170,6 +1183,9 @@
// 이슈 관리 // 이슈 관리
Route::post('/{id}/issues', [WorkOrderController::class, 'addIssue'])->whereNumber('id')->name('v1.work-orders.issues.store'); // 이슈 등록 Route::post('/{id}/issues', [WorkOrderController::class, 'addIssue'])->whereNumber('id')->name('v1.work-orders.issues.store'); // 이슈 등록
Route::patch('/{id}/issues/{issueId}/resolve', [WorkOrderController::class, 'resolveIssue'])->whereNumber('id')->name('v1.work-orders.issues.resolve'); // 이슈 해결 Route::patch('/{id}/issues/{issueId}/resolve', [WorkOrderController::class, 'resolveIssue'])->whereNumber('id')->name('v1.work-orders.issues.resolve'); // 이슈 해결
// 품목 상태 변경
Route::patch('/{id}/items/{itemId}/status', [WorkOrderController::class, 'updateItemStatus'])->whereNumber('id')->whereNumber('itemId')->name('v1.work-orders.items.status');
}); });
// 작업실적 관리 API (Production) // 작업실적 관리 API (Production)