- 작업지시 테이블 마이그레이션 (work_orders, work_order_items, work_order_bending_details, work_order_issues)
- 작업지시 모델 4개 (WorkOrder, WorkOrderItem, WorkOrderBendingDetail, WorkOrderIssue)
- WorkOrderService 비즈니스 로직 구현
- WorkOrderController REST API 엔드포인트 11개
- FormRequest 검증 클래스 5개
- Swagger API 문서화 완료
API Endpoints:
- GET /work-orders (목록)
- GET /work-orders/stats (통계)
- POST /work-orders (등록)
- GET /work-orders/{id} (상세)
- PUT /work-orders/{id} (수정)
- DELETE /work-orders/{id} (삭제)
- PATCH /work-orders/{id}/status (상태변경)
- PATCH /work-orders/{id}/assign (담당자배정)
- PATCH /work-orders/{id}/bending/toggle (벤딩토글)
- POST /work-orders/{id}/issues (이슈등록)
- PATCH /work-orders/{id}/issues/{issueId}/resolve (이슈해결)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
395 lines
12 KiB
PHP
395 lines
12 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\Production\WorkOrder;
|
|
use App\Models\Production\WorkOrderBendingDetail;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
|
|
class WorkOrderService extends Service
|
|
{
|
|
/**
|
|
* 목록 조회 (검색/필터링/페이징)
|
|
*/
|
|
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;
|
|
$processType = $params['process_type'] ?? null;
|
|
$assigneeId = $params['assignee_id'] ?? null;
|
|
$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', 'team:id,name', 'salesOrder:id,order_no']);
|
|
|
|
// 검색어
|
|
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);
|
|
}
|
|
|
|
// 공정유형 필터
|
|
if ($processType !== null) {
|
|
$query->where('process_type', $processType);
|
|
}
|
|
|
|
// 담당자 필터
|
|
if ($assigneeId !== null) {
|
|
$query->where('assignee_id', $assigneeId);
|
|
}
|
|
|
|
// 팀 필터
|
|
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',
|
|
'team:id,name',
|
|
'salesOrder:id,order_no,project_name',
|
|
'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);
|
|
|
|
// 품목 저장
|
|
foreach ($items as $index => $item) {
|
|
$item['sort_order'] = $index;
|
|
$workOrder->items()->create($item);
|
|
}
|
|
|
|
// 벤딩 상세 저장 (벤딩 공정인 경우)
|
|
if ($data['process_type'] === WorkOrder::PROCESS_BENDING && $bendingDetail) {
|
|
$workOrder->bendingDetail()->create($bendingDetail);
|
|
}
|
|
|
|
return $workOrder->load(['assignee:id,name', 'team:id,name', 'items', 'bendingDetail']);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 수정
|
|
*/
|
|
public function update(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, $userId) {
|
|
$data['updated_by'] = $userId;
|
|
|
|
$items = $data['items'] ?? null;
|
|
$bendingDetail = $data['bending_detail'] ?? null;
|
|
unset($data['items'], $data['bending_detail'], $data['work_order_no']); // 번호 변경 불가
|
|
|
|
$workOrder->update($data);
|
|
|
|
// 품목 교체 (있는 경우)
|
|
if ($items !== null) {
|
|
$workOrder->items()->delete();
|
|
foreach ($items as $index => $item) {
|
|
$item['sort_order'] = $index;
|
|
$workOrder->items()->create($item);
|
|
}
|
|
}
|
|
|
|
// 벤딩 상세 업데이트
|
|
if ($bendingDetail !== null && $workOrder->process_type === WorkOrder::PROCESS_BENDING) {
|
|
$workOrder->bendingDetail()->updateOrCreate(
|
|
['work_order_id' => $workOrder->id],
|
|
$bendingDetail
|
|
);
|
|
}
|
|
|
|
return $workOrder->load(['assignee:id,name', 'team:id,name', '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'));
|
|
}
|
|
|
|
$workOrder->delete();
|
|
|
|
return 'success';
|
|
}
|
|
|
|
/**
|
|
* 상태 변경
|
|
*/
|
|
public function updateStatus(int $id, string $status)
|
|
{
|
|
$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'));
|
|
}
|
|
|
|
$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();
|
|
break;
|
|
case WorkOrder::STATUS_SHIPPED:
|
|
$workOrder->shipped_at = now();
|
|
break;
|
|
}
|
|
|
|
$workOrder->save();
|
|
|
|
return $workOrder->load(['assignee:id,name', 'team:id,name']);
|
|
}
|
|
|
|
/**
|
|
* 담당자 배정
|
|
*/
|
|
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'));
|
|
}
|
|
|
|
$workOrder->assignee_id = $data['assignee_id'];
|
|
$workOrder->team_id = $data['team_id'] ?? $workOrder->team_id;
|
|
$workOrder->updated_by = $userId;
|
|
|
|
// 미배정이었으면 대기로 변경
|
|
if ($workOrder->status === WorkOrder::STATUS_UNASSIGNED) {
|
|
$workOrder->status = WorkOrder::STATUS_PENDING;
|
|
}
|
|
|
|
$workOrder->save();
|
|
|
|
return $workOrder->load(['assignee:id,name', 'team:id,name']);
|
|
}
|
|
|
|
/**
|
|
* 벤딩 항목 토글
|
|
*/
|
|
public function toggleBendingField(int $id, string $field)
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
|
|
$workOrder = WorkOrder::where('tenant_id', $tenantId)->find($id);
|
|
if (! $workOrder) {
|
|
throw new NotFoundHttpException(__('error.not_found'));
|
|
}
|
|
|
|
if ($workOrder->process_type !== WorkOrder::PROCESS_BENDING) {
|
|
throw new BadRequestHttpException(__('error.work_order.not_bending_process'));
|
|
}
|
|
|
|
$detail = $workOrder->bendingDetail;
|
|
if (! $detail) {
|
|
$detail = $workOrder->bendingDetail()->create([]);
|
|
}
|
|
|
|
if (! in_array($field, WorkOrderBendingDetail::PROCESS_FIELDS)) {
|
|
throw new BadRequestHttpException(__('error.invalid_field'));
|
|
}
|
|
|
|
$detail->toggleField($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['reported_by'] = $userId;
|
|
|
|
return $workOrder->issues()->create($data);
|
|
}
|
|
|
|
/**
|
|
* 이슈 해결
|
|
*/
|
|
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);
|
|
|
|
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);
|
|
}
|
|
}
|