From 05a53cdc8e0f9e22555c1adba2ad5981bca9e642 Mon Sep 17 00:00:00 2001 From: kent Date: Fri, 26 Dec 2025 13:57:42 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20G-1=20=EC=9E=91=EC=97=85=EC=A7=80?= =?UTF-8?q?=EC=8B=9C=20=EA=B4=80=EB=A6=AC=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 작업지시 테이블 마이그레이션 (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 --- .../Api/V1/WorkOrderController.php | 130 ++++++ .../WorkOrder/WorkOrderAssignRequest.php | 29 ++ .../WorkOrder/WorkOrderIssueRequest.php | 31 ++ .../WorkOrder/WorkOrderStatusRequest.php | 30 ++ .../WorkOrder/WorkOrderStoreRequest.php | 60 +++ .../WorkOrder/WorkOrderUpdateRequest.php | 51 +++ app/Models/Production/WorkOrder.php | 294 +++++++++++++ .../Production/WorkOrderBendingDetail.php | 131 ++++++ app/Models/Production/WorkOrderIssue.php | 161 +++++++ app/Models/Production/WorkOrderItem.php | 62 +++ app/Services/WorkOrderService.php | 394 +++++++++++++++++ app/Swagger/v1/WorkOrderApi.php | 416 ++++++++++++++++++ ..._12_26_100000_create_work_orders_table.php | 49 +++ ...6_100100_create_work_order_items_table.php | 40 ++ ...reate_work_order_bending_details_table.php | 46 ++ ..._100300_create_work_order_issues_table.php | 42 ++ routes/api.php | 34 ++ 17 files changed, 2000 insertions(+) create mode 100644 app/Http/Controllers/Api/V1/WorkOrderController.php create mode 100644 app/Http/Requests/WorkOrder/WorkOrderAssignRequest.php create mode 100644 app/Http/Requests/WorkOrder/WorkOrderIssueRequest.php create mode 100644 app/Http/Requests/WorkOrder/WorkOrderStatusRequest.php create mode 100644 app/Http/Requests/WorkOrder/WorkOrderStoreRequest.php create mode 100644 app/Http/Requests/WorkOrder/WorkOrderUpdateRequest.php create mode 100644 app/Models/Production/WorkOrder.php create mode 100644 app/Models/Production/WorkOrderBendingDetail.php create mode 100644 app/Models/Production/WorkOrderIssue.php create mode 100644 app/Models/Production/WorkOrderItem.php create mode 100644 app/Services/WorkOrderService.php create mode 100644 app/Swagger/v1/WorkOrderApi.php create mode 100644 database/migrations/2025_12_26_100000_create_work_orders_table.php create mode 100644 database/migrations/2025_12_26_100100_create_work_order_items_table.php create mode 100644 database/migrations/2025_12_26_100200_create_work_order_bending_details_table.php create mode 100644 database/migrations/2025_12_26_100300_create_work_order_issues_table.php diff --git a/app/Http/Controllers/Api/V1/WorkOrderController.php b/app/Http/Controllers/Api/V1/WorkOrderController.php new file mode 100644 index 0000000..9138ff4 --- /dev/null +++ b/app/Http/Controllers/Api/V1/WorkOrderController.php @@ -0,0 +1,130 @@ +service->index($request->all()); + }, __('message.work_order.fetched')); + } + + /** + * 통계 조회 + */ + public function stats() + { + return ApiResponse::handle(function () { + return $this->service->stats(); + }, __('message.work_order.fetched')); + } + + /** + * 단건 조회 + */ + public function show(int $id) + { + return ApiResponse::handle(function () use ($id) { + return $this->service->show($id); + }, __('message.work_order.fetched')); + } + + /** + * 생성 + */ + public function store(WorkOrderStoreRequest $request) + { + return ApiResponse::handle(function () use ($request) { + return $this->service->store($request->validated()); + }, __('message.work_order.created')); + } + + /** + * 수정 + */ + public function update(WorkOrderUpdateRequest $request, int $id) + { + return ApiResponse::handle(function () use ($request, $id) { + return $this->service->update($id, $request->validated()); + }, __('message.work_order.updated')); + } + + /** + * 삭제 + */ + public function destroy(int $id) + { + return ApiResponse::handle(function () use ($id) { + $this->service->destroy($id); + + return 'success'; + }, __('message.work_order.deleted')); + } + + /** + * 상태 변경 + */ + public function updateStatus(WorkOrderStatusRequest $request, int $id) + { + return ApiResponse::handle(function () use ($request, $id) { + return $this->service->updateStatus($id, $request->validated()['status']); + }, __('message.work_order.status_updated')); + } + + /** + * 담당자 배정 + */ + public function assign(WorkOrderAssignRequest $request, int $id) + { + return ApiResponse::handle(function () use ($request, $id) { + return $this->service->assign($id, $request->validated()); + }, __('message.work_order.assigned')); + } + + /** + * 벤딩 항목 토글 + */ + public function toggleBendingField(Request $request, int $id) + { + return ApiResponse::handle(function () use ($request, $id) { + return $this->service->toggleBendingField($id, $request->input('field')); + }, __('message.work_order.bending_toggled')); + } + + /** + * 이슈 추가 + */ + public function addIssue(WorkOrderIssueRequest $request, int $id) + { + return ApiResponse::handle(function () use ($request, $id) { + return $this->service->addIssue($id, $request->validated()); + }, __('message.work_order.issue_added')); + } + + /** + * 이슈 해결 + */ + public function resolveIssue(int $workOrderId, int $issueId) + { + return ApiResponse::handle(function () use ($workOrderId, $issueId) { + return $this->service->resolveIssue($workOrderId, $issueId); + }, __('message.work_order.issue_resolved')); + } +} diff --git a/app/Http/Requests/WorkOrder/WorkOrderAssignRequest.php b/app/Http/Requests/WorkOrder/WorkOrderAssignRequest.php new file mode 100644 index 0000000..80fc520 --- /dev/null +++ b/app/Http/Requests/WorkOrder/WorkOrderAssignRequest.php @@ -0,0 +1,29 @@ + 'required|integer|exists:users,id', + 'team_id' => 'nullable|integer|exists:departments,id', + ]; + } + + public function messages(): array + { + return [ + 'assignee_id.required' => __('validation.required', ['attribute' => '담당자']), + 'assignee_id.exists' => __('validation.exists', ['attribute' => '담당자']), + ]; + } +} diff --git a/app/Http/Requests/WorkOrder/WorkOrderIssueRequest.php b/app/Http/Requests/WorkOrder/WorkOrderIssueRequest.php new file mode 100644 index 0000000..a7c4df7 --- /dev/null +++ b/app/Http/Requests/WorkOrder/WorkOrderIssueRequest.php @@ -0,0 +1,31 @@ + 'required|string|max:200', + 'description' => 'nullable|string', + 'priority' => ['nullable', Rule::in(WorkOrderIssue::PRIORITIES)], + ]; + } + + public function messages(): array + { + return [ + 'title.required' => __('validation.required', ['attribute' => '이슈 제목']), + ]; + } +} diff --git a/app/Http/Requests/WorkOrder/WorkOrderStatusRequest.php b/app/Http/Requests/WorkOrder/WorkOrderStatusRequest.php new file mode 100644 index 0000000..70545f9 --- /dev/null +++ b/app/Http/Requests/WorkOrder/WorkOrderStatusRequest.php @@ -0,0 +1,30 @@ + ['required', Rule::in(WorkOrder::STATUSES)], + ]; + } + + public function messages(): array + { + return [ + 'status.required' => __('validation.required', ['attribute' => '상태']), + 'status.in' => __('validation.in', ['attribute' => '상태']), + ]; + } +} diff --git a/app/Http/Requests/WorkOrder/WorkOrderStoreRequest.php b/app/Http/Requests/WorkOrder/WorkOrderStoreRequest.php new file mode 100644 index 0000000..0fe7813 --- /dev/null +++ b/app/Http/Requests/WorkOrder/WorkOrderStoreRequest.php @@ -0,0 +1,60 @@ + 'nullable|integer|exists:orders,id', + 'project_name' => 'nullable|string|max:200', + 'process_type' => ['required', Rule::in(WorkOrder::PROCESS_TYPES)], + 'status' => ['nullable', Rule::in(WorkOrder::STATUSES)], + 'assignee_id' => 'nullable|integer|exists:users,id', + 'team_id' => 'nullable|integer|exists:departments,id', + 'scheduled_date' => 'nullable|date', + 'memo' => 'nullable|string', + 'is_active' => 'nullable|boolean', + + // 품목 배열 + 'items' => 'nullable|array', + 'items.*.item_id' => 'nullable|integer|exists:items,id', + 'items.*.item_name' => 'required|string|max:200', + 'items.*.specification' => 'nullable|string|max:500', + 'items.*.quantity' => 'nullable|numeric|min:0', + 'items.*.unit' => 'nullable|string|max:20', + + // 벤딩 상세 (process_type이 bending인 경우) + 'bending_detail' => 'nullable|array', + 'bending_detail.shaft_cutting' => 'nullable|boolean', + 'bending_detail.bearing' => 'nullable|boolean', + 'bending_detail.shaft_welding' => 'nullable|boolean', + 'bending_detail.assembly' => 'nullable|boolean', + 'bending_detail.winder_welding' => 'nullable|boolean', + 'bending_detail.frame_assembly' => 'nullable|boolean', + 'bending_detail.bundle_assembly' => 'nullable|boolean', + 'bending_detail.motor_assembly' => 'nullable|boolean', + 'bending_detail.bracket_assembly' => 'nullable|boolean', + ]; + } + + public function messages(): array + { + return [ + 'process_type.required' => __('validation.required', ['attribute' => '공정유형']), + 'process_type.in' => __('validation.in', ['attribute' => '공정유형']), + 'items.*.item_name.required' => __('validation.required', ['attribute' => '품목명']), + ]; + } +} diff --git a/app/Http/Requests/WorkOrder/WorkOrderUpdateRequest.php b/app/Http/Requests/WorkOrder/WorkOrderUpdateRequest.php new file mode 100644 index 0000000..1c5e5e4 --- /dev/null +++ b/app/Http/Requests/WorkOrder/WorkOrderUpdateRequest.php @@ -0,0 +1,51 @@ + 'nullable|integer|exists:orders,id', + 'project_name' => 'nullable|string|max:200', + 'process_type' => ['nullable', Rule::in(WorkOrder::PROCESS_TYPES)], + 'status' => ['nullable', Rule::in(WorkOrder::STATUSES)], + 'assignee_id' => 'nullable|integer|exists:users,id', + 'team_id' => 'nullable|integer|exists:departments,id', + 'scheduled_date' => 'nullable|date', + 'memo' => 'nullable|string', + 'is_active' => 'nullable|boolean', + + // 품목 배열 (있으면 전체 교체) + 'items' => 'nullable|array', + 'items.*.item_id' => 'nullable|integer|exists:items,id', + 'items.*.item_name' => 'required|string|max:200', + 'items.*.specification' => 'nullable|string|max:500', + 'items.*.quantity' => 'nullable|numeric|min:0', + 'items.*.unit' => 'nullable|string|max:20', + + // 벤딩 상세 + 'bending_detail' => 'nullable|array', + 'bending_detail.shaft_cutting' => 'nullable|boolean', + 'bending_detail.bearing' => 'nullable|boolean', + 'bending_detail.shaft_welding' => 'nullable|boolean', + 'bending_detail.assembly' => 'nullable|boolean', + 'bending_detail.winder_welding' => 'nullable|boolean', + 'bending_detail.frame_assembly' => 'nullable|boolean', + 'bending_detail.bundle_assembly' => 'nullable|boolean', + 'bending_detail.motor_assembly' => 'nullable|boolean', + 'bending_detail.bracket_assembly' => 'nullable|boolean', + ]; + } +} diff --git a/app/Models/Production/WorkOrder.php b/app/Models/Production/WorkOrder.php new file mode 100644 index 0000000..bf3d72b --- /dev/null +++ b/app/Models/Production/WorkOrder.php @@ -0,0 +1,294 @@ + 'date', + 'started_at' => 'datetime', + 'completed_at' => 'datetime', + 'shipped_at' => 'datetime', + 'is_active' => 'boolean', + ]; + + protected $hidden = [ + 'deleted_at', + ]; + + // ────────────────────────────────────────────────────────────── + // 상수 + // ────────────────────────────────────────────────────────────── + + /** + * 공정 유형 + */ + public const PROCESS_SCREEN = 'screen'; + + public const PROCESS_SLAT = 'slat'; + + public const PROCESS_BENDING = 'bending'; + + public const PROCESS_TYPES = [ + self::PROCESS_SCREEN, + self::PROCESS_SLAT, + self::PROCESS_BENDING, + ]; + + /** + * 상태 + */ + public const STATUS_UNASSIGNED = 'unassigned'; // 미배정 + + public const STATUS_PENDING = 'pending'; // 대기 + + public const STATUS_WAITING = 'waiting'; // 준비중 + + public const STATUS_IN_PROGRESS = 'in_progress'; // 진행중 + + public const STATUS_COMPLETED = 'completed'; // 완료 + + public const STATUS_SHIPPED = 'shipped'; // 출하 + + public const STATUSES = [ + self::STATUS_UNASSIGNED, + self::STATUS_PENDING, + self::STATUS_WAITING, + self::STATUS_IN_PROGRESS, + self::STATUS_COMPLETED, + self::STATUS_SHIPPED, + ]; + + // ────────────────────────────────────────────────────────────── + // 관계 + // ────────────────────────────────────────────────────────────── + + /** + * 수주 + */ + public function salesOrder(): BelongsTo + { + return $this->belongsTo(Order::class, 'sales_order_id'); + } + + /** + * 담당자 + */ + public function assignee(): BelongsTo + { + return $this->belongsTo(User::class, 'assignee_id'); + } + + /** + * 팀 + */ + public function team(): BelongsTo + { + return $this->belongsTo(Department::class, 'team_id'); + } + + /** + * 작업지시 품목들 + */ + public function items(): HasMany + { + return $this->hasMany(WorkOrderItem::class)->orderBy('sort_order'); + } + + /** + * 벤딩 상세 (1:1) + */ + public function bendingDetail(): HasOne + { + return $this->hasOne(WorkOrderBendingDetail::class); + } + + /** + * 이슈들 + */ + public function issues(): HasMany + { + return $this->hasMany(WorkOrderIssue::class); + } + + /** + * 생성자 + */ + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + /** + * 수정자 + */ + public function updater(): BelongsTo + { + return $this->belongsTo(User::class, 'updated_by'); + } + + // ────────────────────────────────────────────────────────────── + // 스코프 + // ────────────────────────────────────────────────────────────── + + /** + * 상태별 필터 + */ + public function scopeStatus($query, string $status) + { + return $query->where('status', $status); + } + + /** + * 공정유형별 필터 + */ + public function scopeProcessType($query, string $type) + { + return $query->where('process_type', $type); + } + + /** + * 담당자별 필터 + */ + public function scopeAssignee($query, int $assigneeId) + { + return $query->where('assignee_id', $assigneeId); + } + + /** + * 팀별 필터 + */ + public function scopeTeam($query, int $teamId) + { + return $query->where('team_id', $teamId); + } + + /** + * 미배정 + */ + public function scopeUnassigned($query) + { + return $query->where('status', self::STATUS_UNASSIGNED); + } + + /** + * 진행중 (pending, waiting, in_progress) + */ + public function scopeInProgress($query) + { + return $query->whereIn('status', [ + self::STATUS_PENDING, + self::STATUS_WAITING, + self::STATUS_IN_PROGRESS, + ]); + } + + /** + * 완료됨 (completed, shipped) + */ + public function scopeCompleted($query) + { + return $query->whereIn('status', [ + self::STATUS_COMPLETED, + self::STATUS_SHIPPED, + ]); + } + + /** + * 예정일 범위 + */ + public function scopeScheduledBetween($query, $from, $to) + { + return $query->whereBetween('scheduled_date', [$from, $to]); + } + + // ────────────────────────────────────────────────────────────── + // 헬퍼 메서드 + // ────────────────────────────────────────────────────────────── + + /** + * 벤딩 공정인지 확인 + */ + public function isBending(): bool + { + return $this->process_type === self::PROCESS_BENDING; + } + + /** + * 미배정 상태인지 확인 + */ + public function isUnassigned(): bool + { + return $this->status === self::STATUS_UNASSIGNED; + } + + /** + * 진행중인지 확인 + */ + public function isInProgress(): bool + { + return in_array($this->status, [ + self::STATUS_PENDING, + self::STATUS_WAITING, + self::STATUS_IN_PROGRESS, + ]); + } + + /** + * 완료되었는지 확인 + */ + public function isCompleted(): bool + { + return in_array($this->status, [ + self::STATUS_COMPLETED, + self::STATUS_SHIPPED, + ]); + } + + /** + * 미해결 이슈 수 + */ + public function getOpenIssuesCountAttribute(): int + { + return $this->issues()->where('status', '!=', 'resolved')->count(); + } +} diff --git a/app/Models/Production/WorkOrderBendingDetail.php b/app/Models/Production/WorkOrderBendingDetail.php new file mode 100644 index 0000000..501acf1 --- /dev/null +++ b/app/Models/Production/WorkOrderBendingDetail.php @@ -0,0 +1,131 @@ + 'boolean', + 'bearing' => 'boolean', + 'shaft_welding' => 'boolean', + 'assembly' => 'boolean', + 'winder_welding' => 'boolean', + 'frame_assembly' => 'boolean', + 'bundle_assembly' => 'boolean', + 'motor_assembly' => 'boolean', + 'bracket_assembly' => 'boolean', + ]; + + /** + * 벤딩 공정 항목 목록 + */ + public const PROCESS_FIELDS = [ + 'shaft_cutting', + 'bearing', + 'shaft_welding', + 'assembly', + 'winder_welding', + 'frame_assembly', + 'bundle_assembly', + 'motor_assembly', + 'bracket_assembly', + ]; + + // ────────────────────────────────────────────────────────────── + // 관계 + // ────────────────────────────────────────────────────────────── + + /** + * 작업지시 + */ + public function workOrder(): BelongsTo + { + return $this->belongsTo(WorkOrder::class); + } + + // ────────────────────────────────────────────────────────────── + // 헬퍼 메서드 + // ────────────────────────────────────────────────────────────── + + /** + * 완료된 항목 수 + */ + public function getCompletedCountAttribute(): int + { + $count = 0; + foreach (self::PROCESS_FIELDS as $field) { + if ($this->{$field}) { + $count++; + } + } + + return $count; + } + + /** + * 전체 항목 수 + */ + public function getTotalCountAttribute(): int + { + return count(self::PROCESS_FIELDS); + } + + /** + * 진행률 (%) + */ + public function getProgressPercentAttribute(): int + { + $total = $this->total_count; + if ($total === 0) { + return 0; + } + + return (int) round(($this->completed_count / $total) * 100); + } + + /** + * 모든 항목이 완료되었는지 확인 + */ + public function isAllCompleted(): bool + { + return $this->completed_count === $this->total_count; + } + + /** + * 특정 항목 토글 + */ + public function toggleField(string $field): bool + { + if (! in_array($field, self::PROCESS_FIELDS)) { + return false; + } + + $this->{$field} = ! $this->{$field}; + $this->save(); + + return true; + } +} diff --git a/app/Models/Production/WorkOrderIssue.php b/app/Models/Production/WorkOrderIssue.php new file mode 100644 index 0000000..7036a69 --- /dev/null +++ b/app/Models/Production/WorkOrderIssue.php @@ -0,0 +1,161 @@ + 'datetime', + ]; + + // ────────────────────────────────────────────────────────────── + // 상수 + // ────────────────────────────────────────────────────────────── + + /** + * 우선순위 + */ + public const PRIORITY_HIGH = 'high'; + + public const PRIORITY_MEDIUM = 'medium'; + + public const PRIORITY_LOW = 'low'; + + public const PRIORITIES = [ + self::PRIORITY_HIGH, + self::PRIORITY_MEDIUM, + self::PRIORITY_LOW, + ]; + + /** + * 상태 + */ + public const STATUS_OPEN = 'open'; + + public const STATUS_IN_PROGRESS = 'in_progress'; + + public const STATUS_RESOLVED = 'resolved'; + + public const STATUSES = [ + self::STATUS_OPEN, + self::STATUS_IN_PROGRESS, + self::STATUS_RESOLVED, + ]; + + // ────────────────────────────────────────────────────────────── + // 관계 + // ────────────────────────────────────────────────────────────── + + /** + * 작업지시 + */ + public function workOrder(): BelongsTo + { + return $this->belongsTo(WorkOrder::class); + } + + /** + * 보고자 + */ + public function reporter(): BelongsTo + { + return $this->belongsTo(User::class, 'reported_by'); + } + + /** + * 해결자 + */ + public function resolver(): BelongsTo + { + return $this->belongsTo(User::class, 'resolved_by'); + } + + // ────────────────────────────────────────────────────────────── + // 스코프 + // ────────────────────────────────────────────────────────────── + + /** + * 열린 이슈 + */ + public function scopeOpen($query) + { + return $query->where('status', self::STATUS_OPEN); + } + + /** + * 미해결 이슈 (open + in_progress) + */ + public function scopeUnresolved($query) + { + return $query->whereIn('status', [self::STATUS_OPEN, self::STATUS_IN_PROGRESS]); + } + + /** + * 해결된 이슈 + */ + public function scopeResolved($query) + { + return $query->where('status', self::STATUS_RESOLVED); + } + + /** + * 우선순위별 + */ + public function scopePriority($query, string $priority) + { + return $query->where('priority', $priority); + } + + /** + * 높은 우선순위 + */ + public function scopeHighPriority($query) + { + return $query->where('priority', self::PRIORITY_HIGH); + } + + // ────────────────────────────────────────────────────────────── + // 헬퍼 메서드 + // ────────────────────────────────────────────────────────────── + + /** + * 해결되었는지 확인 + */ + public function isResolved(): bool + { + return $this->status === self::STATUS_RESOLVED; + } + + /** + * 이슈 해결 처리 + */ + public function resolve(int $userId): void + { + $this->status = self::STATUS_RESOLVED; + $this->resolved_by = $userId; + $this->resolved_at = now(); + $this->save(); + } +} diff --git a/app/Models/Production/WorkOrderItem.php b/app/Models/Production/WorkOrderItem.php new file mode 100644 index 0000000..849b71a --- /dev/null +++ b/app/Models/Production/WorkOrderItem.php @@ -0,0 +1,62 @@ + 'decimal:2', + 'sort_order' => 'integer', + ]; + + // ────────────────────────────────────────────────────────────── + // 관계 + // ────────────────────────────────────────────────────────────── + + /** + * 작업지시 + */ + public function workOrder(): BelongsTo + { + return $this->belongsTo(WorkOrder::class); + } + + /** + * 품목 + */ + public function item(): BelongsTo + { + return $this->belongsTo(Item::class); + } + + // ────────────────────────────────────────────────────────────── + // 스코프 + // ────────────────────────────────────────────────────────────── + + /** + * 정렬 순서 + */ + public function scopeOrdered($query) + { + return $query->orderBy('sort_order'); + } +} diff --git a/app/Services/WorkOrderService.php b/app/Services/WorkOrderService.php new file mode 100644 index 0000000..988337e --- /dev/null +++ b/app/Services/WorkOrderService.php @@ -0,0 +1,394 @@ +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); + } +} diff --git a/app/Swagger/v1/WorkOrderApi.php b/app/Swagger/v1/WorkOrderApi.php new file mode 100644 index 0000000..483a860 --- /dev/null +++ b/app/Swagger/v1/WorkOrderApi.php @@ -0,0 +1,416 @@ +bigIncrements('id'); + $table->unsignedBigInteger('tenant_id')->comment('테넌트ID'); + $table->string('work_order_no', 50)->comment('작업지시번호'); + $table->unsignedBigInteger('sales_order_id')->nullable()->comment('수주ID'); + $table->string('project_name', 200)->nullable()->comment('프로젝트명'); + $table->string('process_type', 30)->comment('공정유형: screen/slat/bending'); + $table->string('status', 30)->default('unassigned')->comment('상태: unassigned/pending/waiting/in_progress/completed/shipped'); + $table->unsignedBigInteger('assignee_id')->nullable()->comment('담당자ID'); + $table->unsignedBigInteger('team_id')->nullable()->comment('팀ID'); + $table->date('scheduled_date')->nullable()->comment('예정일'); + $table->timestamp('started_at')->nullable()->comment('작업시작일시'); + $table->timestamp('completed_at')->nullable()->comment('작업완료일시'); + $table->timestamp('shipped_at')->nullable()->comment('출하일시'); + $table->text('memo')->nullable()->comment('메모'); + $table->boolean('is_active')->default(true)->comment('활성여부'); + $table->unsignedBigInteger('created_by')->nullable()->comment('생성자'); + $table->unsignedBigInteger('updated_by')->nullable()->comment('수정자'); + $table->timestamps(); + $table->softDeletes(); + + // Indexes + $table->unique(['tenant_id', 'work_order_no'], 'uq_work_orders_tenant_no'); + $table->index(['tenant_id', 'status'], 'idx_work_orders_tenant_status'); + $table->index(['tenant_id', 'process_type'], 'idx_work_orders_tenant_process'); + $table->index(['tenant_id', 'assignee_id'], 'idx_work_orders_tenant_assignee'); + $table->index(['tenant_id', 'scheduled_date'], 'idx_work_orders_tenant_scheduled'); + $table->index(['tenant_id', 'is_active'], 'idx_work_orders_tenant_active'); + }); + } + + public function down(): void + { + Schema::dropIfExists('work_orders'); + } +}; \ No newline at end of file diff --git a/database/migrations/2025_12_26_100100_create_work_order_items_table.php b/database/migrations/2025_12_26_100100_create_work_order_items_table.php new file mode 100644 index 0000000..80252e0 --- /dev/null +++ b/database/migrations/2025_12_26_100100_create_work_order_items_table.php @@ -0,0 +1,40 @@ +bigIncrements('id'); + $table->unsignedBigInteger('work_order_id')->comment('작업지시ID'); + $table->unsignedBigInteger('item_id')->nullable()->comment('품목ID'); + $table->string('item_name', 200)->comment('품목명'); + $table->string('specification', 500)->nullable()->comment('규격'); + $table->decimal('quantity', 12, 2)->default(1)->comment('수량'); + $table->string('unit', 20)->nullable()->comment('단위'); + $table->integer('sort_order')->default(0)->comment('정렬순서'); + $table->timestamps(); + + // Foreign Keys + $table->foreign('work_order_id') + ->references('id') + ->on('work_orders') + ->onDelete('cascade'); + + // Indexes + $table->index(['work_order_id', 'sort_order'], 'idx_work_order_items_order_sort'); + }); + } + + public function down(): void + { + Schema::dropIfExists('work_order_items'); + } +}; diff --git a/database/migrations/2025_12_26_100200_create_work_order_bending_details_table.php b/database/migrations/2025_12_26_100200_create_work_order_bending_details_table.php new file mode 100644 index 0000000..4fcd97a --- /dev/null +++ b/database/migrations/2025_12_26_100200_create_work_order_bending_details_table.php @@ -0,0 +1,46 @@ +bigIncrements('id'); + $table->unsignedBigInteger('work_order_id')->comment('작업지시ID'); + + // 벤딩 공정 세부 항목 + $table->boolean('shaft_cutting')->default(false)->comment('샤프트 절단'); + $table->boolean('bearing')->default(false)->comment('베어링'); + $table->boolean('shaft_welding')->default(false)->comment('샤프트 용접'); + $table->boolean('assembly')->default(false)->comment('조립'); + $table->boolean('winder_welding')->default(false)->comment('와인더 용접'); + $table->boolean('frame_assembly')->default(false)->comment('프레임 조립'); + $table->boolean('bundle_assembly')->default(false)->comment('번들 조립'); + $table->boolean('motor_assembly')->default(false)->comment('모터 조립'); + $table->boolean('bracket_assembly')->default(false)->comment('브라켓 조립'); + + $table->timestamps(); + + // Foreign Keys + $table->foreign('work_order_id') + ->references('id') + ->on('work_orders') + ->onDelete('cascade'); + + // Unique (1:1 관계) + $table->unique('work_order_id', 'uq_bending_details_work_order'); + }); + } + + public function down(): void + { + Schema::dropIfExists('work_order_bending_details'); + } +}; \ No newline at end of file diff --git a/database/migrations/2025_12_26_100300_create_work_order_issues_table.php b/database/migrations/2025_12_26_100300_create_work_order_issues_table.php new file mode 100644 index 0000000..579f8f5 --- /dev/null +++ b/database/migrations/2025_12_26_100300_create_work_order_issues_table.php @@ -0,0 +1,42 @@ +bigIncrements('id'); + $table->unsignedBigInteger('work_order_id')->comment('작업지시ID'); + $table->string('title', 200)->comment('이슈 제목'); + $table->text('description')->nullable()->comment('이슈 설명'); + $table->string('priority', 20)->default('medium')->comment('우선순위: high/medium/low'); + $table->string('status', 30)->default('open')->comment('상태: open/in_progress/resolved'); + $table->unsignedBigInteger('reported_by')->nullable()->comment('보고자ID'); + $table->unsignedBigInteger('resolved_by')->nullable()->comment('해결자ID'); + $table->timestamp('resolved_at')->nullable()->comment('해결일시'); + $table->timestamps(); + + // Foreign Keys + $table->foreign('work_order_id') + ->references('id') + ->on('work_orders') + ->onDelete('cascade'); + + // Indexes + $table->index(['work_order_id', 'status'], 'idx_work_order_issues_order_status'); + $table->index(['work_order_id', 'priority'], 'idx_work_order_issues_order_priority'); + }); + } + + public function down(): void + { + Schema::dropIfExists('work_order_issues'); + } +}; \ No newline at end of file diff --git a/routes/api.php b/routes/api.php index 553ac69..742fe04 100644 --- a/routes/api.php +++ b/routes/api.php @@ -35,6 +35,9 @@ use App\Http\Controllers\Api\V1\Design\ModelVersionController as DesignModelVersionController; use App\Http\Controllers\Api\V1\EmployeeController; use App\Http\Controllers\Api\V1\ExpectedExpenseController; +use App\Http\Controllers\Api\V1\VendorLedgerController; +use App\Http\Controllers\Api\V1\BankTransactionController; +use App\Http\Controllers\Api\V1\CardTransactionController; use App\Http\Controllers\Api\V1\EstimateController; use App\Http\Controllers\Api\V1\FileStorageController; use App\Http\Controllers\Api\V1\FolderController; @@ -68,6 +71,7 @@ use App\Http\Controllers\Api\V1\PricingController; use App\Http\Controllers\Api\V1\PurchaseController; use App\Http\Controllers\Api\V1\ReceivingController; +use App\Http\Controllers\Api\V1\StockController; use App\Http\Controllers\Api\V1\PushNotificationController; use App\Http\Controllers\Api\V1\QuoteController; use App\Http\Controllers\Api\V1\RefreshController; @@ -473,6 +477,28 @@ Route::post('/{id}/settle', [LoanController::class, 'settle'])->whereNumber('id')->name('v1.loans.settle'); }); + // Vendor Ledger API (거래처원장) + Route::prefix('vendor-ledger')->group(function () { + Route::get('', [VendorLedgerController::class, 'index'])->name('v1.vendor-ledger.index'); + Route::get('/summary', [VendorLedgerController::class, 'summary'])->name('v1.vendor-ledger.summary'); + Route::get('/{clientId}', [VendorLedgerController::class, 'show'])->whereNumber('clientId')->name('v1.vendor-ledger.show'); + }); + + // Card Transaction API (카드 거래 조회) + Route::prefix('card-transactions')->group(function () { + Route::get('', [CardTransactionController::class, 'index'])->name('v1.card-transactions.index'); + Route::get('/summary', [CardTransactionController::class, 'summary'])->name('v1.card-transactions.summary'); + Route::put('/bulk-update-account', [CardTransactionController::class, 'bulkUpdateAccountCode'])->name('v1.card-transactions.bulk-update-account'); + Route::get('/{id}', [CardTransactionController::class, 'show'])->whereNumber('id')->name('v1.card-transactions.show'); + }); + + // Bank Transaction API (은행 거래 조회) + Route::prefix('bank-transactions')->group(function () { + Route::get('', [BankTransactionController::class, 'index'])->name('v1.bank-transactions.index'); + Route::get('/summary', [BankTransactionController::class, 'summary'])->name('v1.bank-transactions.summary'); + Route::get('/accounts', [BankTransactionController::class, 'accounts'])->name('v1.bank-transactions.accounts'); + }); + // Plan API (요금제 관리) Route::prefix('plans')->group(function () { Route::get('', [PlanController::class, 'index'])->name('v1.plans.index'); @@ -559,6 +585,14 @@ Route::post('/{id}/process', [ReceivingController::class, 'process'])->whereNumber('id')->name('v1.receivings.process'); }); + // Stock API (재고 현황) + Route::prefix('stocks')->group(function () { + Route::get('', [StockController::class, 'index'])->name('v1.stocks.index'); + Route::get('/stats', [StockController::class, 'stats'])->name('v1.stocks.stats'); + Route::get('/stats-by-type', [StockController::class, 'statsByItemType'])->name('v1.stocks.stats-by-type'); + Route::get('/{id}', [StockController::class, 'show'])->whereNumber('id')->name('v1.stocks.show'); + }); + // Barobill Setting API (바로빌 설정) Route::prefix('barobill-settings')->group(function () { Route::get('', [BarobillSettingController::class, 'show'])->name('v1.barobill-settings.show');