feat: G-1 작업지시 관리 API 구현
- 작업지시 테이블 마이그레이션 (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>
This commit is contained in:
130
app/Http/Controllers/Api/V1/WorkOrderController.php
Normal file
130
app/Http/Controllers/Api/V1/WorkOrderController.php
Normal file
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\WorkOrder\WorkOrderAssignRequest;
|
||||
use App\Http\Requests\WorkOrder\WorkOrderIssueRequest;
|
||||
use App\Http\Requests\WorkOrder\WorkOrderStatusRequest;
|
||||
use App\Http\Requests\WorkOrder\WorkOrderStoreRequest;
|
||||
use App\Http\Requests\WorkOrder\WorkOrderUpdateRequest;
|
||||
use App\Services\WorkOrderService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class WorkOrderController extends Controller
|
||||
{
|
||||
public function __construct(private WorkOrderService $service) {}
|
||||
|
||||
/**
|
||||
* 목록 조회
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->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'));
|
||||
}
|
||||
}
|
||||
29
app/Http/Requests/WorkOrder/WorkOrderAssignRequest.php
Normal file
29
app/Http/Requests/WorkOrder/WorkOrderAssignRequest.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\WorkOrder;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class WorkOrderAssignRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'assignee_id' => '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' => '담당자']),
|
||||
];
|
||||
}
|
||||
}
|
||||
31
app/Http/Requests/WorkOrder/WorkOrderIssueRequest.php
Normal file
31
app/Http/Requests/WorkOrder/WorkOrderIssueRequest.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\WorkOrder;
|
||||
|
||||
use App\Models\Production\WorkOrderIssue;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class WorkOrderIssueRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'title' => 'required|string|max:200',
|
||||
'description' => 'nullable|string',
|
||||
'priority' => ['nullable', Rule::in(WorkOrderIssue::PRIORITIES)],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'title.required' => __('validation.required', ['attribute' => '이슈 제목']),
|
||||
];
|
||||
}
|
||||
}
|
||||
30
app/Http/Requests/WorkOrder/WorkOrderStatusRequest.php
Normal file
30
app/Http/Requests/WorkOrder/WorkOrderStatusRequest.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\WorkOrder;
|
||||
|
||||
use App\Models\Production\WorkOrder;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class WorkOrderStatusRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'status' => ['required', Rule::in(WorkOrder::STATUSES)],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'status.required' => __('validation.required', ['attribute' => '상태']),
|
||||
'status.in' => __('validation.in', ['attribute' => '상태']),
|
||||
];
|
||||
}
|
||||
}
|
||||
60
app/Http/Requests/WorkOrder/WorkOrderStoreRequest.php
Normal file
60
app/Http/Requests/WorkOrder/WorkOrderStoreRequest.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\WorkOrder;
|
||||
|
||||
use App\Models\Production\WorkOrder;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class WorkOrderStoreRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
// 기본 정보
|
||||
'sales_order_id' => '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' => '품목명']),
|
||||
];
|
||||
}
|
||||
}
|
||||
51
app/Http/Requests/WorkOrder/WorkOrderUpdateRequest.php
Normal file
51
app/Http/Requests/WorkOrder/WorkOrderUpdateRequest.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\WorkOrder;
|
||||
|
||||
use App\Models\Production\WorkOrder;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class WorkOrderUpdateRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
// 기본 정보
|
||||
'sales_order_id' => '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',
|
||||
];
|
||||
}
|
||||
}
|
||||
294
app/Models/Production/WorkOrder.php
Normal file
294
app/Models/Production/WorkOrder.php
Normal file
@@ -0,0 +1,294 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Production;
|
||||
|
||||
use App\Models\Members\User;
|
||||
use App\Models\Orders\Order;
|
||||
use App\Models\Tenants\Department;
|
||||
use App\Traits\BelongsToTenant;
|
||||
use App\Traits\ModelTrait;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
/**
|
||||
* 작업지시 모델
|
||||
*
|
||||
* 생산 관리의 핵심 엔티티로 수주(SalesOrder)를 기반으로 생산 작업을 지시하고 추적
|
||||
*/
|
||||
class WorkOrder extends Model
|
||||
{
|
||||
use BelongsToTenant, ModelTrait, SoftDeletes;
|
||||
|
||||
protected $table = 'work_orders';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'work_order_no',
|
||||
'sales_order_id',
|
||||
'project_name',
|
||||
'process_type',
|
||||
'status',
|
||||
'assignee_id',
|
||||
'team_id',
|
||||
'scheduled_date',
|
||||
'started_at',
|
||||
'completed_at',
|
||||
'shipped_at',
|
||||
'memo',
|
||||
'is_active',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'scheduled_date' => '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();
|
||||
}
|
||||
}
|
||||
131
app/Models/Production/WorkOrderBendingDetail.php
Normal file
131
app/Models/Production/WorkOrderBendingDetail.php
Normal file
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Production;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* 작업지시 벤딩 상세 모델
|
||||
*
|
||||
* 벤딩 공정의 세부 작업 항목 완료 상태를 추적
|
||||
*/
|
||||
class WorkOrderBendingDetail extends Model
|
||||
{
|
||||
protected $table = 'work_order_bending_details';
|
||||
|
||||
protected $fillable = [
|
||||
'work_order_id',
|
||||
'shaft_cutting',
|
||||
'bearing',
|
||||
'shaft_welding',
|
||||
'assembly',
|
||||
'winder_welding',
|
||||
'frame_assembly',
|
||||
'bundle_assembly',
|
||||
'motor_assembly',
|
||||
'bracket_assembly',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'shaft_cutting' => '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;
|
||||
}
|
||||
}
|
||||
161
app/Models/Production/WorkOrderIssue.php
Normal file
161
app/Models/Production/WorkOrderIssue.php
Normal file
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Production;
|
||||
|
||||
use App\Models\Members\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* 작업지시 이슈 모델
|
||||
*
|
||||
* 작업지시 진행 중 발생한 이슈를 기록하고 추적
|
||||
*/
|
||||
class WorkOrderIssue extends Model
|
||||
{
|
||||
protected $table = 'work_order_issues';
|
||||
|
||||
protected $fillable = [
|
||||
'work_order_id',
|
||||
'title',
|
||||
'description',
|
||||
'priority',
|
||||
'status',
|
||||
'reported_by',
|
||||
'resolved_by',
|
||||
'resolved_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'resolved_at' => '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();
|
||||
}
|
||||
}
|
||||
62
app/Models/Production/WorkOrderItem.php
Normal file
62
app/Models/Production/WorkOrderItem.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Production;
|
||||
|
||||
use App\Models\Items\Item;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* 작업지시 품목 모델
|
||||
*/
|
||||
class WorkOrderItem extends Model
|
||||
{
|
||||
protected $table = 'work_order_items';
|
||||
|
||||
protected $fillable = [
|
||||
'work_order_id',
|
||||
'item_id',
|
||||
'item_name',
|
||||
'specification',
|
||||
'quantity',
|
||||
'unit',
|
||||
'sort_order',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'quantity' => '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');
|
||||
}
|
||||
}
|
||||
394
app/Services/WorkOrderService.php
Normal file
394
app/Services/WorkOrderService.php
Normal file
@@ -0,0 +1,394 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
416
app/Swagger/v1/WorkOrderApi.php
Normal file
416
app/Swagger/v1/WorkOrderApi.php
Normal file
@@ -0,0 +1,416 @@
|
||||
<?php
|
||||
|
||||
namespace App\Swagger\v1;
|
||||
|
||||
/**
|
||||
* @OA\Tag(name="WorkOrder", description="작업지시 관리")
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="WorkOrder",
|
||||
* type="object",
|
||||
* required={"id","work_order_no","process_type","status"},
|
||||
*
|
||||
* @OA\Property(property="id", type="integer", example=1),
|
||||
* @OA\Property(property="tenant_id", type="integer", example=1),
|
||||
* @OA\Property(property="work_order_no", type="string", example="WO202512260001"),
|
||||
* @OA\Property(property="sales_order_id", type="integer", nullable=true, example=1),
|
||||
* @OA\Property(property="project_name", type="string", nullable=true, example="강남빌딩 방충망"),
|
||||
* @OA\Property(property="process_type", type="string", enum={"screen","slat","bending"}, example="screen"),
|
||||
* @OA\Property(property="status", type="string", enum={"unassigned","pending","waiting","in_progress","completed","shipped"}, example="pending"),
|
||||
* @OA\Property(property="assignee_id", type="integer", nullable=true, example=10),
|
||||
* @OA\Property(property="team_id", type="integer", nullable=true, example=5),
|
||||
* @OA\Property(property="scheduled_date", type="string", format="date", nullable=true, example="2025-12-28"),
|
||||
* @OA\Property(property="started_at", type="string", format="date-time", nullable=true),
|
||||
* @OA\Property(property="completed_at", type="string", format="date-time", nullable=true),
|
||||
* @OA\Property(property="shipped_at", type="string", format="date-time", nullable=true),
|
||||
* @OA\Property(property="memo", type="string", nullable=true),
|
||||
* @OA\Property(property="is_active", type="boolean", example=true),
|
||||
* @OA\Property(property="created_at", type="string", example="2025-12-26"),
|
||||
* @OA\Property(property="updated_at", type="string", example="2025-12-26"),
|
||||
* @OA\Property(property="assignee", type="object", nullable=true, @OA\Property(property="id", type="integer"), @OA\Property(property="name", type="string")),
|
||||
* @OA\Property(property="team", type="object", nullable=true, @OA\Property(property="id", type="integer"), @OA\Property(property="name", type="string"))
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="WorkOrderItem",
|
||||
* type="object",
|
||||
*
|
||||
* @OA\Property(property="id", type="integer", example=1),
|
||||
* @OA\Property(property="work_order_id", type="integer", example=1),
|
||||
* @OA\Property(property="item_id", type="integer", nullable=true, example=100),
|
||||
* @OA\Property(property="item_name", type="string", example="방충망 프레임"),
|
||||
* @OA\Property(property="specification", type="string", nullable=true, example="W1200 x H2400"),
|
||||
* @OA\Property(property="quantity", type="number", format="float", example=10),
|
||||
* @OA\Property(property="unit", type="string", nullable=true, example="EA"),
|
||||
* @OA\Property(property="sort_order", type="integer", example=0)
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="WorkOrderBendingDetail",
|
||||
* type="object",
|
||||
*
|
||||
* @OA\Property(property="id", type="integer", example=1),
|
||||
* @OA\Property(property="work_order_id", type="integer", example=1),
|
||||
* @OA\Property(property="shaft_cutting", type="boolean", example=true),
|
||||
* @OA\Property(property="bearing", type="boolean", example=false),
|
||||
* @OA\Property(property="shaft_welding", type="boolean", example=false),
|
||||
* @OA\Property(property="assembly", type="boolean", example=false),
|
||||
* @OA\Property(property="winder_welding", type="boolean", example=false),
|
||||
* @OA\Property(property="frame_assembly", type="boolean", example=false),
|
||||
* @OA\Property(property="bundle_assembly", type="boolean", example=false),
|
||||
* @OA\Property(property="motor_assembly", type="boolean", example=false),
|
||||
* @OA\Property(property="bracket_assembly", type="boolean", example=false)
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="WorkOrderIssue",
|
||||
* type="object",
|
||||
*
|
||||
* @OA\Property(property="id", type="integer", example=1),
|
||||
* @OA\Property(property="work_order_id", type="integer", example=1),
|
||||
* @OA\Property(property="title", type="string", example="자재 부족"),
|
||||
* @OA\Property(property="description", type="string", nullable=true, example="방충망 프레임 재고 부족"),
|
||||
* @OA\Property(property="priority", type="string", enum={"high","medium","low"}, example="high"),
|
||||
* @OA\Property(property="status", type="string", enum={"open","in_progress","resolved"}, example="open"),
|
||||
* @OA\Property(property="reported_by", type="integer", nullable=true, example=10),
|
||||
* @OA\Property(property="resolved_by", type="integer", nullable=true),
|
||||
* @OA\Property(property="resolved_at", type="string", format="date-time", nullable=true)
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="WorkOrderStats",
|
||||
* type="object",
|
||||
*
|
||||
* @OA\Property(property="total", type="integer", example=100),
|
||||
* @OA\Property(property="unassigned", type="integer", example=10),
|
||||
* @OA\Property(property="pending", type="integer", example=15),
|
||||
* @OA\Property(property="waiting", type="integer", example=5),
|
||||
* @OA\Property(property="in_progress", type="integer", example=20),
|
||||
* @OA\Property(property="completed", type="integer", example=40),
|
||||
* @OA\Property(property="shipped", type="integer", example=10)
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="WorkOrderPagination",
|
||||
* type="object",
|
||||
*
|
||||
* @OA\Property(property="current_page", type="integer", example=1),
|
||||
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/WorkOrder")),
|
||||
* @OA\Property(property="first_page_url", type="string"),
|
||||
* @OA\Property(property="from", type="integer"),
|
||||
* @OA\Property(property="last_page", type="integer"),
|
||||
* @OA\Property(property="per_page", type="integer"),
|
||||
* @OA\Property(property="total", type="integer")
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="WorkOrderCreateRequest",
|
||||
* type="object",
|
||||
* required={"process_type"},
|
||||
*
|
||||
* @OA\Property(property="sales_order_id", type="integer", nullable=true),
|
||||
* @OA\Property(property="project_name", type="string", nullable=true, maxLength=200),
|
||||
* @OA\Property(property="process_type", type="string", enum={"screen","slat","bending"}),
|
||||
* @OA\Property(property="assignee_id", type="integer", nullable=true),
|
||||
* @OA\Property(property="team_id", type="integer", nullable=true),
|
||||
* @OA\Property(property="scheduled_date", type="string", format="date", nullable=true),
|
||||
* @OA\Property(property="memo", type="string", nullable=true),
|
||||
* @OA\Property(property="items", type="array", @OA\Items(
|
||||
* type="object",
|
||||
* @OA\Property(property="item_id", type="integer", nullable=true),
|
||||
* @OA\Property(property="item_name", type="string"),
|
||||
* @OA\Property(property="specification", type="string", nullable=true),
|
||||
* @OA\Property(property="quantity", type="number", nullable=true),
|
||||
* @OA\Property(property="unit", type="string", nullable=true)
|
||||
* )),
|
||||
* @OA\Property(property="bending_detail", type="object", ref="#/components/schemas/WorkOrderBendingDetail")
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="WorkOrderUpdateRequest",
|
||||
* type="object",
|
||||
*
|
||||
* @OA\Property(property="sales_order_id", type="integer", nullable=true),
|
||||
* @OA\Property(property="project_name", type="string", nullable=true),
|
||||
* @OA\Property(property="process_type", type="string", enum={"screen","slat","bending"}, nullable=true),
|
||||
* @OA\Property(property="status", type="string", enum={"unassigned","pending","waiting","in_progress","completed","shipped"}, nullable=true),
|
||||
* @OA\Property(property="assignee_id", type="integer", nullable=true),
|
||||
* @OA\Property(property="team_id", type="integer", nullable=true),
|
||||
* @OA\Property(property="scheduled_date", type="string", format="date", nullable=true),
|
||||
* @OA\Property(property="memo", type="string", nullable=true),
|
||||
* @OA\Property(property="items", type="array", nullable=true, @OA\Items(type="object")),
|
||||
* @OA\Property(property="bending_detail", type="object", nullable=true)
|
||||
* )
|
||||
*/
|
||||
class WorkOrderApi
|
||||
{
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/v1/work-orders",
|
||||
* tags={"WorkOrder"},
|
||||
* summary="작업지시 목록",
|
||||
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
|
||||
*
|
||||
* @OA\Parameter(name="page", in="query", @OA\Schema(type="integer", example=1)),
|
||||
* @OA\Parameter(name="size", in="query", @OA\Schema(type="integer", example=20)),
|
||||
* @OA\Parameter(name="q", in="query", description="작업지시번호/프로젝트명 검색", @OA\Schema(type="string")),
|
||||
* @OA\Parameter(name="status", in="query", description="상태 필터", @OA\Schema(type="string", enum={"unassigned","pending","waiting","in_progress","completed","shipped"})),
|
||||
* @OA\Parameter(name="process_type", in="query", description="공정유형 필터", @OA\Schema(type="string", enum={"screen","slat","bending"})),
|
||||
* @OA\Parameter(name="assignee_id", in="query", description="담당자 필터", @OA\Schema(type="integer")),
|
||||
* @OA\Parameter(name="team_id", in="query", description="팀 필터", @OA\Schema(type="integer")),
|
||||
* @OA\Parameter(name="scheduled_from", in="query", description="예정일 시작", @OA\Schema(type="string", format="date")),
|
||||
* @OA\Parameter(name="scheduled_to", in="query", description="예정일 종료", @OA\Schema(type="string", format="date")),
|
||||
*
|
||||
* @OA\Response(response=200, description="조회 성공",
|
||||
*
|
||||
* @OA\JsonContent(allOf={
|
||||
*
|
||||
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/WorkOrderPagination"))
|
||||
* })
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function index() {}
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/v1/work-orders/stats",
|
||||
* tags={"WorkOrder"},
|
||||
* summary="작업지시 통계",
|
||||
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
|
||||
*
|
||||
* @OA\Response(response=200, description="조회 성공",
|
||||
*
|
||||
* @OA\JsonContent(allOf={
|
||||
*
|
||||
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/WorkOrderStats"))
|
||||
* })
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
public function stats() {}
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/v1/work-orders/{id}",
|
||||
* tags={"WorkOrder"},
|
||||
* summary="작업지시 단건 조회",
|
||||
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
|
||||
*
|
||||
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
|
||||
*
|
||||
* @OA\Response(response=200, description="조회 성공",
|
||||
*
|
||||
* @OA\JsonContent(allOf={
|
||||
*
|
||||
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/WorkOrder"))
|
||||
* })
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=404, description="데이터 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function show() {}
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/v1/work-orders",
|
||||
* tags={"WorkOrder"},
|
||||
* summary="작업지시 생성",
|
||||
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
|
||||
*
|
||||
* @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/WorkOrderCreateRequest")),
|
||||
*
|
||||
* @OA\Response(response=200, description="생성 성공",
|
||||
*
|
||||
* @OA\JsonContent(allOf={
|
||||
*
|
||||
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/WorkOrder"))
|
||||
* })
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=400, description="검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function store() {}
|
||||
|
||||
/**
|
||||
* @OA\Put(
|
||||
* path="/api/v1/work-orders/{id}",
|
||||
* tags={"WorkOrder"},
|
||||
* summary="작업지시 수정",
|
||||
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
|
||||
*
|
||||
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
|
||||
*
|
||||
* @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/WorkOrderUpdateRequest")),
|
||||
*
|
||||
* @OA\Response(response=200, description="수정 성공",
|
||||
*
|
||||
* @OA\JsonContent(allOf={
|
||||
*
|
||||
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/WorkOrder"))
|
||||
* })
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=404, description="데이터 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function update() {}
|
||||
|
||||
/**
|
||||
* @OA\Delete(
|
||||
* path="/api/v1/work-orders/{id}",
|
||||
* tags={"WorkOrder"},
|
||||
* summary="작업지시 삭제",
|
||||
* description="진행 중이거나 완료된 작업지시는 삭제할 수 없습니다.",
|
||||
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
|
||||
*
|
||||
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
|
||||
*
|
||||
* @OA\Response(response=200, description="삭제 성공", @OA\JsonContent(ref="#/components/schemas/ApiResponse")),
|
||||
* @OA\Response(response=400, description="삭제 불가", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||
* @OA\Response(response=404, description="데이터 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function destroy() {}
|
||||
|
||||
/**
|
||||
* @OA\Patch(
|
||||
* path="/api/v1/work-orders/{id}/status",
|
||||
* tags={"WorkOrder"},
|
||||
* summary="상태 변경",
|
||||
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
|
||||
*
|
||||
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
|
||||
*
|
||||
* @OA\RequestBody(required=true, @OA\JsonContent(
|
||||
*
|
||||
* @OA\Property(property="status", type="string", enum={"unassigned","pending","waiting","in_progress","completed","shipped"})
|
||||
* )),
|
||||
*
|
||||
* @OA\Response(response=200, description="변경 성공",
|
||||
*
|
||||
* @OA\JsonContent(allOf={
|
||||
*
|
||||
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/WorkOrder"))
|
||||
* })
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
public function updateStatus() {}
|
||||
|
||||
/**
|
||||
* @OA\Patch(
|
||||
* path="/api/v1/work-orders/{id}/assign",
|
||||
* tags={"WorkOrder"},
|
||||
* summary="담당자 배정",
|
||||
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
|
||||
*
|
||||
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
|
||||
*
|
||||
* @OA\RequestBody(required=true, @OA\JsonContent(
|
||||
*
|
||||
* @OA\Property(property="assignee_id", type="integer", description="담당자 ID"),
|
||||
* @OA\Property(property="team_id", type="integer", nullable=true, description="팀 ID")
|
||||
* )),
|
||||
*
|
||||
* @OA\Response(response=200, description="배정 성공",
|
||||
*
|
||||
* @OA\JsonContent(allOf={
|
||||
*
|
||||
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/WorkOrder"))
|
||||
* })
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
public function assign() {}
|
||||
|
||||
/**
|
||||
* @OA\Patch(
|
||||
* path="/api/v1/work-orders/{id}/bending/toggle",
|
||||
* tags={"WorkOrder"},
|
||||
* summary="벤딩 항목 토글",
|
||||
* description="벤딩 공정의 세부 항목 완료 여부를 토글합니다.",
|
||||
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
|
||||
*
|
||||
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
|
||||
*
|
||||
* @OA\RequestBody(required=true, @OA\JsonContent(
|
||||
*
|
||||
* @OA\Property(property="field", type="string", enum={"shaft_cutting","bearing","shaft_welding","assembly","winder_welding","frame_assembly","bundle_assembly","motor_assembly","bracket_assembly"})
|
||||
* )),
|
||||
*
|
||||
* @OA\Response(response=200, description="토글 성공",
|
||||
*
|
||||
* @OA\JsonContent(allOf={
|
||||
*
|
||||
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/WorkOrderBendingDetail"))
|
||||
* })
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=400, description="벤딩 공정이 아님", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function toggleBendingField() {}
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/v1/work-orders/{id}/issues",
|
||||
* tags={"WorkOrder"},
|
||||
* summary="이슈 추가",
|
||||
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
|
||||
*
|
||||
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
|
||||
*
|
||||
* @OA\RequestBody(required=true, @OA\JsonContent(
|
||||
*
|
||||
* @OA\Property(property="title", type="string", maxLength=200),
|
||||
* @OA\Property(property="description", type="string", nullable=true),
|
||||
* @OA\Property(property="priority", type="string", enum={"high","medium","low"}, nullable=true)
|
||||
* )),
|
||||
*
|
||||
* @OA\Response(response=200, description="추가 성공",
|
||||
*
|
||||
* @OA\JsonContent(allOf={
|
||||
*
|
||||
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/WorkOrderIssue"))
|
||||
* })
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
public function addIssue() {}
|
||||
|
||||
/**
|
||||
* @OA\Patch(
|
||||
* path="/api/v1/work-orders/{workOrderId}/issues/{issueId}/resolve",
|
||||
* tags={"WorkOrder"},
|
||||
* summary="이슈 해결",
|
||||
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
|
||||
*
|
||||
* @OA\Parameter(name="workOrderId", in="path", required=true, @OA\Schema(type="integer")),
|
||||
* @OA\Parameter(name="issueId", in="path", required=true, @OA\Schema(type="integer")),
|
||||
*
|
||||
* @OA\Response(response=200, description="해결 성공",
|
||||
*
|
||||
* @OA\JsonContent(allOf={
|
||||
*
|
||||
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/WorkOrderIssue"))
|
||||
* })
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
public function resolveIssue() {}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 작업지시 테이블 (Production Work Orders)
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('work_orders', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 작업지시 품목 테이블 (Work Order Items)
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('work_order_items', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 작업지시 벤딩 상세 테이블 (Bending Process Details)
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('work_order_bending_details', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 작업지시 이슈 테이블 (Work Order Issues)
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('work_order_issues', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user