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:
2025-12-26 13:57:42 +09:00
parent 5ab5353d4d
commit 05a53cdc8e
17 changed files with 2000 additions and 0 deletions

View 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'));
}
}

View 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' => '담당자']),
];
}
}

View 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' => '이슈 제목']),
];
}
}

View 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' => '상태']),
];
}
}

View 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' => '품목명']),
];
}
}

View 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',
];
}
}

View 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();
}
}

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

View 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();
}
}

View 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');
}
}

View 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);
}
}

View 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() {}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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');