feat: [material] 부적합관리 API Phase 1-A 구현

- Migration: nonconforming_reports, nonconforming_report_items 테이블
- Model: NonconformingReport, NonconformingReportItem (관계, cast, scope)
- FormRequest: Store/Update 검증 (items 배열 포함)
- Service: CRUD + 채번(NC-YYYYMMDD-NNN) + 비용 자동 계산 + 상태 전이
- Controller: REST 7개 엔드포인트 (목록/통계/상세/등록/수정/삭제/상태변경)
- Route: /api/v1/material/nonconforming-reports
- i18n: 부적합관리 에러 메시지 (ko)
This commit is contained in:
김보곤
2026-03-19 08:36:45 +09:00
parent d8a57f71c6
commit 847c60b03d
9 changed files with 826 additions and 0 deletions

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\Material\StoreNonconformingReportRequest;
use App\Http\Requests\Material\UpdateNonconformingReportRequest;
use App\Services\NonconformingReportService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class NonconformingReportController extends Controller
{
public function __construct(private NonconformingReportService $service) {}
public function index(Request $request): JsonResponse
{
return ApiResponse::handle(function () use ($request) {
return $this->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'));
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Http\Requests\Material;
use Illuminate\Foundation\Http\FormRequest;
class StoreNonconformingReportRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'nc_type' => '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'),
];
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Http\Requests\Material;
use Illuminate\Foundation\Http\FormRequest;
class UpdateNonconformingReportRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'nc_type' => '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',
];
}
}

View File

@@ -0,0 +1,172 @@
<?php
namespace App\Models\Materials;
use App\Models\Commons\File;
use App\Models\Departments\Department;
use App\Models\Items\Item;
use App\Models\Orders\Order;
use App\Models\Users\User;
use App\Traits\Auditable;
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\MorphMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class NonconformingReport extends Model
{
use Auditable, BelongsToTenant, ModelTrait, SoftDeletes;
protected $fillable = [
'tenant_id',
'nc_number',
'status',
'nc_type',
'occurred_at',
'confirmed_at',
'site_name',
'department_id',
'order_id',
'item_id',
'defect_quantity',
'unit',
'defect_description',
'cause_analysis',
'corrective_action',
'action_completed_at',
'action_manager_id',
'related_employee_id',
'material_cost',
'shipping_cost',
'construction_cost',
'other_cost',
'total_cost',
'remarks',
'drawing_location',
'options',
'created_by',
'updated_by',
'deleted_by',
];
protected $casts = [
'options' => '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;
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Models\Materials;
use App\Models\Items\Item;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class NonconformingReportItem extends Model
{
use BelongsToTenant, ModelTrait;
protected $fillable = [
'tenant_id',
'nonconforming_report_id',
'item_id',
'item_name',
'specification',
'quantity',
'unit_price',
'amount',
'sort_order',
'remarks',
'options',
];
protected $casts = [
'options' => '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);
}
}

View File

@@ -0,0 +1,329 @@
<?php
namespace App\Services;
use App\Models\Materials\NonconformingReport;
use App\Models\Materials\NonconformingReportItem;
use Illuminate\Support\Facades\DB;
class NonconformingReportService extends Service
{
/**
* 목록 조회 (필터/검색/페이지네이션)
*/
public function index(array $params): mixed
{
$tenantId = $this->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'));
}
}
}
}

View File

@@ -0,0 +1,79 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('nonconforming_reports', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -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' => '데모 테넌트를 찾을 수 없습니다.',

View File

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