diff --git a/app/Http/Controllers/Api/V1/NonconformingReportController.php b/app/Http/Controllers/Api/V1/NonconformingReportController.php new file mode 100644 index 00000000..3daf85bd --- /dev/null +++ b/app/Http/Controllers/Api/V1/NonconformingReportController.php @@ -0,0 +1,69 @@ +service->index($request->all()); + }, __('message.fetched')); + } + + public function stats(Request $request): JsonResponse + { + return ApiResponse::handle(function () use ($request) { + return $this->service->stats($request->all()); + }, __('message.fetched')); + } + + public function show(int $id): JsonResponse + { + return ApiResponse::handle(function () use ($id) { + return $this->service->show($id); + }, __('message.fetched')); + } + + public function store(StoreNonconformingReportRequest $request): JsonResponse + { + return ApiResponse::handle(function () use ($request) { + return $this->service->store($request->validated()); + }, __('message.created')); + } + + public function update(UpdateNonconformingReportRequest $request, int $id): JsonResponse + { + return ApiResponse::handle(function () use ($request, $id) { + return $this->service->update($id, $request->validated()); + }, __('message.updated')); + } + + public function destroy(int $id): JsonResponse + { + return ApiResponse::handle(function () use ($id) { + $this->service->destroy($id); + + return 'success'; + }, __('message.deleted')); + } + + public function changeStatus(Request $request, int $id): JsonResponse + { + $request->validate(['status' => 'required|string|in:RECEIVED,ANALYZING,RESOLVED,CLOSED']); + + return ApiResponse::handle(function () use ($request, $id) { + return $this->service->changeStatus($id, $request->input('status')); + }, __('message.updated')); + } +} diff --git a/app/Http/Requests/Material/StoreNonconformingReportRequest.php b/app/Http/Requests/Material/StoreNonconformingReportRequest.php new file mode 100644 index 00000000..34aaadc1 --- /dev/null +++ b/app/Http/Requests/Material/StoreNonconformingReportRequest.php @@ -0,0 +1,58 @@ + 'required|string|in:material,process,construction,other', + 'occurred_at' => 'required|date', + 'confirmed_at' => 'nullable|date', + 'site_name' => 'nullable|string|max:100', + 'department_id' => 'nullable|integer|exists:departments,id', + 'order_id' => 'nullable|integer|exists:orders,id', + 'item_id' => 'nullable|integer|exists:items,id', + 'defect_quantity' => 'nullable|numeric|min:0', + 'unit' => 'nullable|string|max:20', + 'defect_description' => 'nullable|string', + 'cause_analysis' => 'nullable|string', + 'corrective_action' => 'nullable|string', + 'action_completed_at' => 'nullable|date', + 'action_manager_id' => 'nullable|integer', + 'related_employee_id' => 'nullable|integer', + 'material_cost' => 'nullable|integer|min:0', + 'shipping_cost' => 'nullable|integer|min:0', + 'construction_cost' => 'nullable|integer|min:0', + 'other_cost' => 'nullable|integer|min:0', + 'remarks' => 'nullable|string', + 'drawing_location' => 'nullable|string|max:255', + + // 자재 상세 내역 + 'items' => 'nullable|array', + 'items.*.item_id' => 'nullable|integer', + 'items.*.item_name' => 'required_with:items|string|max:100', + 'items.*.specification' => 'nullable|string|max:100', + 'items.*.quantity' => 'nullable|numeric|min:0', + 'items.*.unit_price' => 'nullable|integer|min:0', + 'items.*.remarks' => 'nullable|string|max:255', + ]; + } + + public function messages(): array + { + return [ + 'nc_type.required' => __('error.nonconforming.nc_type_required'), + 'nc_type.in' => __('error.nonconforming.nc_type_invalid'), + 'occurred_at.required' => __('error.nonconforming.occurred_at_required'), + ]; + } +} diff --git a/app/Http/Requests/Material/UpdateNonconformingReportRequest.php b/app/Http/Requests/Material/UpdateNonconformingReportRequest.php new file mode 100644 index 00000000..6922d7c2 --- /dev/null +++ b/app/Http/Requests/Material/UpdateNonconformingReportRequest.php @@ -0,0 +1,51 @@ + 'sometimes|string|in:material,process,construction,other', + 'occurred_at' => 'sometimes|date', + 'confirmed_at' => 'nullable|date', + 'site_name' => 'nullable|string|max:100', + 'department_id' => 'nullable|integer|exists:departments,id', + 'order_id' => 'nullable|integer|exists:orders,id', + 'item_id' => 'nullable|integer|exists:items,id', + 'defect_quantity' => 'nullable|numeric|min:0', + 'unit' => 'nullable|string|max:20', + 'defect_description' => 'nullable|string', + 'cause_analysis' => 'nullable|string', + 'corrective_action' => 'nullable|string', + 'action_completed_at' => 'nullable|date', + 'action_manager_id' => 'nullable|integer', + 'related_employee_id' => 'nullable|integer', + 'material_cost' => 'nullable|integer|min:0', + 'shipping_cost' => 'nullable|integer|min:0', + 'construction_cost' => 'nullable|integer|min:0', + 'other_cost' => 'nullable|integer|min:0', + 'remarks' => 'nullable|string', + 'drawing_location' => 'nullable|string|max:255', + 'status' => 'sometimes|string|in:RECEIVED,ANALYZING,RESOLVED,CLOSED', + + // 자재 상세 내역 + 'items' => 'nullable|array', + 'items.*.id' => 'nullable|integer', + 'items.*.item_id' => 'nullable|integer', + 'items.*.item_name' => 'required_with:items|string|max:100', + 'items.*.specification' => 'nullable|string|max:100', + 'items.*.quantity' => 'nullable|numeric|min:0', + 'items.*.unit_price' => 'nullable|integer|min:0', + 'items.*.remarks' => 'nullable|string|max:255', + ]; + } +} diff --git a/app/Models/Materials/NonconformingReport.php b/app/Models/Materials/NonconformingReport.php new file mode 100644 index 00000000..287e0c2f --- /dev/null +++ b/app/Models/Materials/NonconformingReport.php @@ -0,0 +1,172 @@ + 'array', + 'occurred_at' => 'date', + 'confirmed_at' => 'date', + 'action_completed_at' => 'date', + 'material_cost' => 'integer', + 'shipping_cost' => 'integer', + 'construction_cost' => 'integer', + 'other_cost' => 'integer', + 'total_cost' => 'integer', + 'defect_quantity' => 'decimal:2', + ]; + + // 상태 상수 + public const STATUS_RECEIVED = 'RECEIVED'; + + public const STATUS_ANALYZING = 'ANALYZING'; + + public const STATUS_RESOLVED = 'RESOLVED'; + + public const STATUS_CLOSED = 'CLOSED'; + + // 부적합 유형 상수 + public const TYPE_MATERIAL = 'material'; + + public const TYPE_PROCESS = 'process'; + + public const TYPE_CONSTRUCTION = 'construction'; + + public const TYPE_OTHER = 'other'; + + public const NC_TYPES = [ + self::TYPE_MATERIAL => '자재불량', + self::TYPE_PROCESS => '공정불량', + self::TYPE_CONSTRUCTION => '시공불량', + self::TYPE_OTHER => '기타', + ]; + + public const STATUSES = [ + self::STATUS_RECEIVED => '접수', + self::STATUS_ANALYZING => '분석중', + self::STATUS_RESOLVED => '조치완료', + self::STATUS_CLOSED => '종결', + ]; + + // ── 관계 ── + + public function items(): HasMany + { + return $this->hasMany(NonconformingReportItem::class)->orderBy('sort_order'); + } + + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + public function item(): BelongsTo + { + return $this->belongsTo(Item::class); + } + + public function department(): BelongsTo + { + return $this->belongsTo(Department::class); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function actionManager(): BelongsTo + { + return $this->belongsTo(User::class, 'action_manager_id'); + } + + public function relatedEmployee(): BelongsTo + { + return $this->belongsTo(User::class, 'related_employee_id'); + } + + public function files(): MorphMany + { + return $this->morphMany(File::class, 'fileable'); + } + + // ── 스코프 ── + + public function scopeStatus($query, string $status) + { + return $query->where('status', $status); + } + + public function scopeNcType($query, string $type) + { + return $query->where('nc_type', $type); + } + + // ── 헬퍼 ── + + public function recalculateTotalCost(): void + { + $this->total_cost = $this->material_cost + $this->shipping_cost + + $this->construction_cost + $this->other_cost; + } + + public function recalculateMaterialCost(): void + { + $this->material_cost = (int) $this->items()->sum('amount'); + $this->recalculateTotalCost(); + } + + public function isClosed(): bool + { + return $this->status === self::STATUS_CLOSED; + } +} diff --git a/app/Models/Materials/NonconformingReportItem.php b/app/Models/Materials/NonconformingReportItem.php new file mode 100644 index 00000000..8747f33b --- /dev/null +++ b/app/Models/Materials/NonconformingReportItem.php @@ -0,0 +1,45 @@ + 'array', + 'quantity' => 'decimal:2', + 'unit_price' => 'integer', + 'amount' => 'integer', + ]; + + public function report(): BelongsTo + { + return $this->belongsTo(NonconformingReport::class, 'nonconforming_report_id'); + } + + public function item(): BelongsTo + { + return $this->belongsTo(Item::class); + } +} diff --git a/app/Services/NonconformingReportService.php b/app/Services/NonconformingReportService.php new file mode 100644 index 00000000..06b715fd --- /dev/null +++ b/app/Services/NonconformingReportService.php @@ -0,0 +1,329 @@ +tenantId(); + $perPage = $params['per_page'] ?? 20; + $page = $params['page'] ?? 1; + + $query = NonconformingReport::query() + ->where('tenant_id', $tenantId) + ->with(['creator:id,name', 'item:id,name', 'order:id,order_number']); + + // 필터 + if (! empty($params['status'])) { + $query->where('status', $params['status']); + } + if (! empty($params['nc_type'])) { + $query->where('nc_type', $params['nc_type']); + } + if (! empty($params['from_date'])) { + $query->where('occurred_at', '>=', $params['from_date']); + } + if (! empty($params['to_date'])) { + $query->where('occurred_at', '<=', $params['to_date']); + } + + // 검색 + if (! empty($params['search'])) { + $search = $params['search']; + $query->where(function ($q) use ($search) { + $q->where('nc_number', 'like', "%{$search}%") + ->orWhere('site_name', 'like', "%{$search}%") + ->orWhereHas('item', function ($q2) use ($search) { + $q2->where('name', 'like', "%{$search}%"); + }); + }); + } + + $query->orderByDesc('occurred_at')->orderByDesc('id'); + + return $query->paginate($perPage, ['*'], 'page', $page); + } + + /** + * 단건 조회 + */ + public function show(int $id): NonconformingReport + { + return NonconformingReport::query() + ->where('tenant_id', $this->tenantId()) + ->with([ + 'items', + 'order:id,order_number,site_name', + 'item:id,name', + 'department:id,name', + 'creator:id,name', + 'actionManager:id,name', + 'relatedEmployee:id,name', + 'files', + ]) + ->findOrFail($id); + } + + /** + * 등록 (items 일괄 저장) + */ + public function store(array $data): NonconformingReport + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($data, $tenantId, $userId) { + // 채번 + $ncNumber = $this->generateNcNumber($tenantId); + + // 비용 계산 + $items = $data['items'] ?? []; + $materialCost = $data['material_cost'] ?? $this->sumItemAmounts($items); + $shippingCost = $data['shipping_cost'] ?? 0; + $constructionCost = $data['construction_cost'] ?? 0; + $otherCost = $data['other_cost'] ?? 0; + $totalCost = $materialCost + $shippingCost + $constructionCost + $otherCost; + + $report = NonconformingReport::create([ + 'tenant_id' => $tenantId, + 'nc_number' => $ncNumber, + 'status' => NonconformingReport::STATUS_RECEIVED, + 'nc_type' => $data['nc_type'], + 'occurred_at' => $data['occurred_at'], + 'confirmed_at' => $data['confirmed_at'] ?? null, + 'site_name' => $data['site_name'] ?? null, + 'department_id' => $data['department_id'] ?? null, + 'order_id' => $data['order_id'] ?? null, + 'item_id' => $data['item_id'] ?? null, + 'defect_quantity' => $data['defect_quantity'] ?? null, + 'unit' => $data['unit'] ?? null, + 'defect_description' => $data['defect_description'] ?? null, + 'cause_analysis' => $data['cause_analysis'] ?? null, + 'corrective_action' => $data['corrective_action'] ?? null, + 'action_completed_at' => $data['action_completed_at'] ?? null, + 'action_manager_id' => $data['action_manager_id'] ?? null, + 'related_employee_id' => $data['related_employee_id'] ?? null, + 'material_cost' => $materialCost, + 'shipping_cost' => $shippingCost, + 'construction_cost' => $constructionCost, + 'other_cost' => $otherCost, + 'total_cost' => $totalCost, + 'remarks' => $data['remarks'] ?? null, + 'drawing_location' => $data['drawing_location'] ?? null, + 'created_by' => $userId, + ]); + + // 자재 상세 내역 저장 + $this->syncItems($report, $items, $tenantId); + + return $this->show($report->id); + }); + } + + /** + * 수정 (items sync) + */ + public function update(int $id, array $data): NonconformingReport + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($id, $data, $tenantId, $userId) { + $report = NonconformingReport::query() + ->where('tenant_id', $tenantId) + ->findOrFail($id); + + if ($report->isClosed()) { + abort(403, __('error.nonconforming.closed_cannot_edit')); + } + + // items가 전달되면 자재비 재계산 + $hasItems = array_key_exists('items', $data); + if ($hasItems) { + $this->syncItems($report, $data['items'] ?? [], $tenantId); + $data['material_cost'] = (int) $report->items()->sum('amount'); + } + + // 비용 합계 재계산 + $materialCost = $data['material_cost'] ?? $report->material_cost; + $shippingCost = $data['shipping_cost'] ?? $report->shipping_cost; + $constructionCost = $data['construction_cost'] ?? $report->construction_cost; + $otherCost = $data['other_cost'] ?? $report->other_cost; + $data['total_cost'] = $materialCost + $shippingCost + $constructionCost + $otherCost; + $data['updated_by'] = $userId; + + // items 키는 모델 필드가 아니므로 제거 + unset($data['items']); + + $report->update($data); + + return $this->show($report->id); + }); + } + + /** + * 삭제 (소프트) + */ + public function destroy(int $id): void + { + $report = NonconformingReport::query() + ->where('tenant_id', $this->tenantId()) + ->findOrFail($id); + + if ($report->isClosed()) { + abort(403, __('error.nonconforming.closed_cannot_delete')); + } + + $report->deleted_by = $this->apiUserId(); + $report->save(); + $report->delete(); + } + + /** + * 상태 변경 + */ + public function changeStatus(int $id, string $newStatus): NonconformingReport + { + $report = NonconformingReport::query() + ->where('tenant_id', $this->tenantId()) + ->findOrFail($id); + + $this->validateStatusTransition($report, $newStatus); + + $report->status = $newStatus; + $report->updated_by = $this->apiUserId(); + $report->save(); + + return $this->show($report->id); + } + + /** + * 상태별 통계 + */ + public function stats(array $params): array + { + $tenantId = $this->tenantId(); + + $statusCounts = NonconformingReport::query() + ->where('tenant_id', $tenantId) + ->selectRaw('status, COUNT(*) as count') + ->groupBy('status') + ->pluck('count', 'status') + ->toArray(); + + $totalCost = NonconformingReport::query() + ->where('tenant_id', $tenantId) + ->sum('total_cost'); + + return [ + 'by_status' => [ + 'RECEIVED' => $statusCounts['RECEIVED'] ?? 0, + 'ANALYZING' => $statusCounts['ANALYZING'] ?? 0, + 'RESOLVED' => $statusCounts['RESOLVED'] ?? 0, + 'CLOSED' => $statusCounts['CLOSED'] ?? 0, + ], + 'total_count' => array_sum($statusCounts), + 'total_cost' => (int) $totalCost, + ]; + } + + // ── private ── + + /** + * 채번: NC-YYYYMMDD-NNN + */ + private function generateNcNumber(int $tenantId): string + { + $prefix = 'NC'; + $date = now()->format('Ymd'); + $pattern = "{$prefix}-{$date}-"; + + $lastNumber = NonconformingReport::withTrashed() + ->where('tenant_id', $tenantId) + ->where('nc_number', 'like', "{$pattern}%") + ->orderByDesc('nc_number') + ->value('nc_number'); + + $seq = $lastNumber ? ((int) substr($lastNumber, -3) + 1) : 1; + + return sprintf('%s-%s-%03d', $prefix, $date, $seq); + } + + /** + * 자재 상세 내역 동기화 (삭제 후 재생성) + */ + private function syncItems(NonconformingReport $report, array $items, int $tenantId): void + { + $report->items()->delete(); + + foreach ($items as $index => $itemData) { + $quantity = $itemData['quantity'] ?? 0; + $unitPrice = $itemData['unit_price'] ?? 0; + + NonconformingReportItem::create([ + 'tenant_id' => $tenantId, + 'nonconforming_report_id' => $report->id, + 'item_id' => $itemData['item_id'] ?? null, + 'item_name' => $itemData['item_name'], + 'specification' => $itemData['specification'] ?? null, + 'quantity' => $quantity, + 'unit_price' => $unitPrice, + 'amount' => (int) ($quantity * $unitPrice), + 'sort_order' => $index, + 'remarks' => $itemData['remarks'] ?? null, + ]); + } + } + + /** + * items 배열에서 금액 합계 계산 + */ + private function sumItemAmounts(array $items): int + { + $total = 0; + foreach ($items as $item) { + $qty = $item['quantity'] ?? 0; + $price = $item['unit_price'] ?? 0; + $total += (int) ($qty * $price); + } + + return $total; + } + + /** + * 상태 전이 검증 + */ + private function validateStatusTransition(NonconformingReport $report, string $newStatus): void + { + $allowed = [ + NonconformingReport::STATUS_RECEIVED => [NonconformingReport::STATUS_ANALYZING], + NonconformingReport::STATUS_ANALYZING => [NonconformingReport::STATUS_RESOLVED], + NonconformingReport::STATUS_RESOLVED => [NonconformingReport::STATUS_CLOSED], + NonconformingReport::STATUS_CLOSED => [], + ]; + + $current = $report->status; + + if (! in_array($newStatus, $allowed[$current] ?? [])) { + abort(422, __('error.nonconforming.invalid_status_transition', [ + 'from' => NonconformingReport::STATUSES[$current] ?? $current, + 'to' => NonconformingReport::STATUSES[$newStatus] ?? $newStatus, + ])); + } + + // ANALYZING → RESOLVED: 원인분석 + 시정조치 필수 + if ($newStatus === NonconformingReport::STATUS_RESOLVED) { + if (empty($report->cause_analysis) || empty($report->corrective_action)) { + abort(422, __('error.nonconforming.analysis_required')); + } + } + } +} diff --git a/database/migrations/2026_03_19_100000_create_nonconforming_reports_table.php b/database/migrations/2026_03_19_100000_create_nonconforming_reports_table.php new file mode 100644 index 00000000..aa95462f --- /dev/null +++ b/database/migrations/2026_03_19_100000_create_nonconforming_reports_table.php @@ -0,0 +1,79 @@ +id(); + $table->unsignedBigInteger('tenant_id')->comment('테넌트 ID'); + $table->string('nc_number', 30)->comment('부적합번호 NC-YYYYMMDD-NNN'); + $table->string('status', 20)->default('RECEIVED')->comment('상태: RECEIVED/ANALYZING/RESOLVED/CLOSED'); + $table->string('nc_type', 20)->comment('부적합유형: material/process/construction/other'); + $table->date('occurred_at')->comment('발생일'); + $table->date('confirmed_at')->nullable()->comment('불량확인일'); + $table->string('site_name', 100)->nullable()->comment('현장명'); + $table->unsignedBigInteger('department_id')->nullable()->comment('부서'); + $table->unsignedBigInteger('order_id')->nullable()->comment('관련 수주'); + $table->unsignedBigInteger('item_id')->nullable()->comment('관련 품목'); + $table->decimal('defect_quantity', 10, 2)->nullable()->comment('불량 수량'); + $table->string('unit', 20)->nullable()->comment('단위'); + $table->text('defect_description')->nullable()->comment('불량 상세 설명'); + $table->text('cause_analysis')->nullable()->comment('원인 분석'); + $table->text('corrective_action')->nullable()->comment('처리 방안 및 개선 사항'); + $table->date('action_completed_at')->nullable()->comment('조치 완료일'); + $table->unsignedBigInteger('action_manager_id')->nullable()->comment('조치 담당자'); + $table->unsignedBigInteger('related_employee_id')->nullable()->comment('관련 직원'); + $table->decimal('material_cost', 12, 0)->default(0)->comment('자재 비용'); + $table->decimal('shipping_cost', 12, 0)->default(0)->comment('운송 비용'); + $table->decimal('construction_cost', 12, 0)->default(0)->comment('시공 비용'); + $table->decimal('other_cost', 12, 0)->default(0)->comment('기타 비용'); + $table->decimal('total_cost', 12, 0)->default(0)->comment('비용 합계'); + $table->text('remarks')->nullable()->comment('비고'); + $table->string('drawing_location', 255)->nullable()->comment('도면 저장 위치'); + $table->json('options')->nullable()->comment('확장 속성'); + $table->unsignedBigInteger('created_by')->default(0)->comment('등록자'); + $table->unsignedBigInteger('updated_by')->nullable()->comment('수정자'); + $table->unsignedBigInteger('deleted_by')->nullable()->comment('삭제자'); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['tenant_id', 'status']); + $table->index(['tenant_id', 'nc_type']); + $table->index(['tenant_id', 'occurred_at']); + $table->unique(['tenant_id', 'nc_number']); + }); + + Schema::create('nonconforming_report_items', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('tenant_id')->comment('테넌트 ID'); + $table->unsignedBigInteger('nonconforming_report_id')->comment('부적합 보고서 ID'); + $table->unsignedBigInteger('item_id')->nullable()->comment('품목 마스터 연결'); + $table->string('item_name', 100)->comment('품목명'); + $table->string('specification', 100)->nullable()->comment('규격/사양'); + $table->decimal('quantity', 10, 2)->default(0)->comment('수량'); + $table->decimal('unit_price', 12, 0)->default(0)->comment('단가'); + $table->decimal('amount', 12, 0)->default(0)->comment('금액'); + $table->integer('sort_order')->default(0)->comment('정렬 순서'); + $table->string('remarks', 255)->nullable()->comment('비고'); + $table->json('options')->nullable()->comment('확장 속성'); + $table->timestamps(); + + $table->index('nonconforming_report_id'); + $table->foreign('nonconforming_report_id') + ->references('id') + ->on('nonconforming_reports') + ->onDelete('cascade'); + }); + } + + public function down(): void + { + Schema::dropIfExists('nonconforming_report_items'); + Schema::dropIfExists('nonconforming_reports'); + } +}; diff --git a/lang/ko/error.php b/lang/ko/error.php index 68027f33..95c33deb 100644 --- a/lang/ko/error.php +++ b/lang/ko/error.php @@ -529,6 +529,17 @@ ], 'tenant_access_denied' => '해당 테넌트에 대한 접근 권한이 없습니다.', + // 부적합관리 관련 + 'nonconforming' => [ + 'nc_type_required' => '부적합 유형은 필수입니다.', + 'nc_type_invalid' => '유효하지 않은 부적합 유형입니다.', + 'occurred_at_required' => '발생일은 필수입니다.', + 'closed_cannot_edit' => '종결된 부적합 보고서는 수정할 수 없습니다.', + 'closed_cannot_delete' => '종결된 부적합 보고서는 삭제할 수 없습니다.', + 'invalid_status_transition' => "상태를 ':from'에서 ':to'(으)로 변경할 수 없습니다.", + 'analysis_required' => '조치완료로 변경하려면 원인 분석과 처리 방안을 먼저 입력해야 합니다.', + ], + // 데모 테넌트 관련 'demo_tenant' => [ 'not_found' => '데모 테넌트를 찾을 수 없습니다.', diff --git a/routes/api/v1/inventory.php b/routes/api/v1/inventory.php index ff0bdc60..83c45e97 100644 --- a/routes/api/v1/inventory.php +++ b/routes/api/v1/inventory.php @@ -15,6 +15,7 @@ use App\Http\Controllers\Api\V1\ItemsController; use App\Http\Controllers\Api\V1\ItemsFileController; use App\Http\Controllers\Api\V1\LaborController; +use App\Http\Controllers\Api\V1\NonconformingReportController; use App\Http\Controllers\Api\V1\PurchaseController; use App\Http\Controllers\Api\V1\ReceivingController; use App\Http\Controllers\Api\V1\ShipmentController; @@ -128,6 +129,17 @@ Route::delete('/{id}', [ShipmentController::class, 'destroy'])->whereNumber('id')->name('v1.shipments.destroy'); }); +// Nonconforming Report API (부적합관리) +Route::prefix('material/nonconforming-reports')->group(function () { + Route::get('', [NonconformingReportController::class, 'index'])->name('v1.nonconforming-reports.index'); + Route::get('/stats', [NonconformingReportController::class, 'stats'])->name('v1.nonconforming-reports.stats'); + Route::post('', [NonconformingReportController::class, 'store'])->name('v1.nonconforming-reports.store'); + Route::get('/{id}', [NonconformingReportController::class, 'show'])->whereNumber('id')->name('v1.nonconforming-reports.show'); + Route::put('/{id}', [NonconformingReportController::class, 'update'])->whereNumber('id')->name('v1.nonconforming-reports.update'); + Route::delete('/{id}', [NonconformingReportController::class, 'destroy'])->whereNumber('id')->name('v1.nonconforming-reports.destroy'); + Route::patch('/{id}/status', [NonconformingReportController::class, 'changeStatus'])->whereNumber('id')->name('v1.nonconforming-reports.change-status'); +}); + // Vehicle Dispatch API (배차차량 관리) Route::prefix('vehicle-dispatches')->group(function () { Route::get('', [VehicleDispatchController::class, 'index'])->name('v1.vehicle-dispatches.index');