feat: [QMS] 2일차 로트 추적 심사 API 구현 (Phase 1)

- QmsLotAuditService: 품질관리서 목록/상세, 8종 서류 조합, 서류 상세(2단계 로딩), 확인 토글
- QmsLotAuditController: 5개 엔드포인트 (index, show, routeDocuments, documentDetail, confirm)
- FormRequest 3개: Index, Confirm, DocumentDetail 파라미터 검증
- QualityDocumentLocation: options JSON 컬럼 추가 (마이그레이션 + 모델 casts)
- IQC 추적: WorkOrderMaterialInput → StockLot → lot_no → Inspection(IQC) 경로
- 비관적 업데이트: DB::transaction + lockForUpdate() 원자성 보장

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-10 16:38:21 +09:00
parent e372b9543b
commit 334e39d2de
8 changed files with 701 additions and 0 deletions

View File

@@ -0,0 +1,65 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\Qms\QmsLotAuditConfirmRequest;
use App\Http\Requests\Qms\QmsLotAuditDocumentDetailRequest;
use App\Http\Requests\Qms\QmsLotAuditIndexRequest;
use App\Services\QmsLotAuditService;
class QmsLotAuditController extends Controller
{
public function __construct(private QmsLotAuditService $service) {}
/**
* 품질관리서 목록 (로트 추적 심사용)
*/
public function index(QmsLotAuditIndexRequest $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->index($request->validated());
}, __('message.fetched'));
}
/**
* 품질관리서 상세 — 수주/개소 목록
*/
public function show(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->service->show($id);
}, __('message.fetched'));
}
/**
* 수주 루트별 8종 서류 목록
*/
public function routeDocuments(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->service->routeDocuments($id);
}, __('message.fetched'));
}
/**
* 서류 상세 조회 (2단계 로딩)
*/
public function documentDetail(QmsLotAuditDocumentDetailRequest $request, string $type, int $id)
{
return ApiResponse::handle(function () use ($type, $id) {
return $this->service->documentDetail($type, $id);
}, __('message.fetched'));
}
/**
* 개소별 로트 심사 확인 토글
*/
public function confirm(QmsLotAuditConfirmRequest $request, int $id)
{
return ApiResponse::handle(function () use ($request, $id) {
return $this->service->confirm($id, $request->validated());
}, __('message.updated'));
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Http\Requests\Qms;
use Illuminate\Foundation\Http\FormRequest;
class QmsLotAuditConfirmRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'confirmed' => 'required|boolean',
];
}
public function messages(): array
{
return [
'confirmed.required' => __('validation.required', ['attribute' => '확인 상태']),
'confirmed.boolean' => __('validation.boolean', ['attribute' => '확인 상태']),
];
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Http\Requests\Qms;
use Illuminate\Foundation\Http\FormRequest;
class QmsLotAuditDocumentDetailRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
protected function prepareForValidation(): void
{
$this->merge([
'type' => $this->route('type'),
]);
}
public function rules(): array
{
return [
'type' => 'required|string|in:import,order,log,report,confirmation,shipping,product,quality',
];
}
public function messages(): array
{
return [
'type.in' => __('validation.in', ['attribute' => '서류 타입']),
];
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Http\Requests\Qms;
use Illuminate\Foundation\Http\FormRequest;
class QmsLotAuditIndexRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'year' => 'nullable|integer|min:2020|max:2100',
'quarter' => 'nullable|integer|in:1,2,3,4',
'q' => 'nullable|string|max:100',
'per_page' => 'nullable|integer|min:1|max:100',
];
}
}

View File

@@ -26,10 +26,12 @@ class QualityDocumentLocation extends Model
'inspection_data',
'document_id',
'inspection_status',
'options',
];
protected $casts = [
'inspection_data' => 'array',
'options' => 'array',
];
public function qualityDocument()

View File

@@ -0,0 +1,517 @@
<?php
namespace App\Services;
use App\Models\Orders\Order;
use App\Models\Production\WorkOrder;
use App\Models\Production\WorkOrderMaterialInput;
use App\Models\Qualitys\Inspection;
use App\Models\Qualitys\QualityDocument;
use App\Models\Qualitys\QualityDocumentLocation;
use App\Models\Qualitys\QualityDocumentOrder;
use App\Models\Tenants\Shipment;
use App\Models\Tenants\StockLot;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class QmsLotAuditService extends Service
{
/**
* 품질관리서 목록 (로트 추적 심사용)
* completed 상태의 품질관리서를 PerformanceReport 기반으로 필터링
*/
public function index(array $params): array
{
$query = QualityDocument::with([
'documentOrders.order.nodes' => fn ($q) => $q->whereNull('parent_id'),
'documentOrders.order.nodes.items.item',
'locations',
'performanceReport',
])
->where('status', QualityDocument::STATUS_COMPLETED);
// 연도 필터
if (! empty($params['year'])) {
$year = (int) $params['year'];
$query->where(function ($q) use ($year) {
$q->whereHas('performanceReport', fn ($pr) => $pr->where('year', $year))
->orWhereDoesntHave('performanceReport');
});
}
// 분기 필터
if (! empty($params['quarter'])) {
$quarter = (int) $params['quarter'];
$query->whereHas('performanceReport', fn ($pr) => $pr->where('quarter', $quarter));
}
// 검색어 필터
if (! empty($params['q'])) {
$term = trim($params['q']);
$query->where(function ($q) use ($term) {
$q->where('quality_doc_number', 'like', "%{$term}%")
->orWhere('site_name', 'like', "%{$term}%");
});
}
$query->orderByDesc('id');
$perPage = (int) ($params['per_page'] ?? 20);
$paginated = $query->paginate($perPage);
$items = $paginated->getCollection()->map(fn ($doc) => $this->transformReportToFrontend($doc));
return [
'items' => $items,
'current_page' => $paginated->currentPage(),
'last_page' => $paginated->lastPage(),
'per_page' => $paginated->perPage(),
'total' => $paginated->total(),
];
}
/**
* 품질관리서 상세 — 수주/개소 목록 (RouteItem[])
*/
public function show(int $id): array
{
$doc = QualityDocument::with([
'documentOrders.order',
'documentOrders.locations.orderItem',
])->findOrFail($id);
return $doc->documentOrders->map(fn ($docOrder) => $this->transformRouteToFrontend($docOrder, $doc))->values()->all();
}
/**
* 수주 루트별 8종 서류 목록 (Document[])
*/
public function routeDocuments(int $qualityDocumentOrderId): array
{
$docOrder = QualityDocumentOrder::with([
'order.workOrders.process',
'locations',
'qualityDocument',
])->findOrFail($qualityDocumentOrderId);
$order = $docOrder->order;
$qualityDoc = $docOrder->qualityDocument;
$workOrders = $order->workOrders;
$documents = [];
// 1. 수입검사 성적서 (IQC): WorkOrderMaterialInput → StockLot → lot_no → Inspection(IQC)
$investedLotIds = WorkOrderMaterialInput::whereIn('work_order_id', $workOrders->pluck('id'))
->pluck('stock_lot_id')
->unique();
$investedLotNos = StockLot::whereIn('id', $investedLotIds)
->whereNotNull('lot_no')
->pluck('lot_no')
->unique();
$iqcInspections = Inspection::where('inspection_type', 'IQC')
->whereIn('lot_no', $investedLotNos)
->where('status', 'completed')
->get();
$documents[] = $this->formatDocument('import', '수입검사 성적서', $iqcInspections);
// 2. 수주서
$documents[] = $this->formatDocument('order', '수주서', collect([$order]));
// 3. 작업일지 (subType: process.process_name 기반)
$documents[] = $this->formatDocumentWithSubType('log', '작업일지', $workOrders);
// 4. 중간검사 성적서 (PQC)
$pqcInspections = Inspection::where('inspection_type', 'PQC')
->whereIn('work_order_id', $workOrders->pluck('id'))
->where('status', 'completed')
->with('workOrder.process')
->get();
$documents[] = $this->formatDocumentWithSubType('report', '중간검사 성적서', $pqcInspections, 'workOrder');
// 5. 납품확인서
$shipments = $order->shipments()->get();
$documents[] = $this->formatDocument('confirmation', '납품확인서', $shipments);
// 6. 출고증
$documents[] = $this->formatDocument('shipping', '출고증', $shipments);
// 7. 제품검사 성적서
$locationsWithDoc = $docOrder->locations->filter(fn ($loc) => $loc->document_id);
$documents[] = $this->formatDocument('product', '제품검사 성적서', $locationsWithDoc);
// 8. 품질관리서
$documents[] = $this->formatDocument('quality', '품질관리서', collect([$qualityDoc]));
return $documents;
}
/**
* 서류 상세 조회 (2단계 로딩 — 모달 렌더링용)
*/
public function documentDetail(string $type, int $id): array
{
return match ($type) {
'import' => $this->getInspectionDetail($id, 'IQC'),
'order' => $this->getOrderDetail($id),
'log' => $this->getWorkOrderLogDetail($id),
'report' => $this->getInspectionDetail($id, 'PQC'),
'confirmation', 'shipping' => $this->getShipmentDetail($id),
'product' => $this->getLocationDetail($id),
'quality' => $this->getQualityDocDetail($id),
default => throw new NotFoundHttpException(__('error.not_found')),
};
}
/**
* 개소별 로트 심사 확인 토글
*/
public function confirm(int $locationId, array $data): array
{
$location = QualityDocumentLocation::findOrFail($locationId);
$confirmed = (bool) $data['confirmed'];
$userId = $this->apiUserId();
DB::transaction(function () use ($location, $confirmed, $userId) {
$location->lockForUpdate();
$options = $location->options ?? [];
$options['lot_audit_confirmed'] = $confirmed;
$options['lot_audit_confirmed_at'] = $confirmed ? now()->toIso8601String() : null;
$options['lot_audit_confirmed_by'] = $confirmed ? $userId : null;
$location->options = $options;
$location->save();
});
$location->refresh();
return [
'id' => (string) $location->id,
'name' => $this->buildLocationName($location),
'location' => $this->buildLocationCode($location),
'is_completed' => (bool) data_get($location->options, 'lot_audit_confirmed', false),
];
}
// ===== Private: Transform Methods =====
private function transformReportToFrontend(QualityDocument $doc): array
{
$performanceReport = $doc->performanceReport;
$confirmedCount = $doc->locations->filter(function ($loc) {
return data_get($loc->options, 'lot_audit_confirmed', false);
})->count();
return [
'id' => (string) $doc->id,
'code' => $doc->quality_doc_number,
'site_name' => $doc->site_name,
'item' => $this->getFgProductName($doc),
'route_count' => $confirmedCount,
'total_routes' => $doc->locations->count(),
'quarter' => $performanceReport
? $performanceReport->year.'년 '.$performanceReport->quarter.'분기'
: '',
'year' => $performanceReport?->year ?? now()->year,
'quarter_num' => $performanceReport?->quarter ?? 0,
];
}
/**
* BOM 최상위(FG) 제품명 추출
* Order → root OrderNode(parent_id=null) → 대표 OrderItem → Item(FG).name
*/
private function getFgProductName(QualityDocument $doc): string
{
$firstDocOrder = $doc->documentOrders->first();
if (! $firstDocOrder) {
return '';
}
$order = $firstDocOrder->order;
if (! $order) {
return '';
}
// eager loaded with whereNull('parent_id') filter
$rootNode = $order->nodes->first();
if (! $rootNode) {
return '';
}
$representativeItem = $rootNode->items->first();
return $representativeItem?->item?->name ?? '';
}
private function transformRouteToFrontend(QualityDocumentOrder $docOrder, QualityDocument $qualityDoc): array
{
return [
'id' => (string) $docOrder->id,
'code' => $docOrder->order->order_no,
'date' => $docOrder->order->received_at?->toDateString(),
'site' => $docOrder->order->site_name ?? '',
'location_count' => $docOrder->locations->count(),
'sub_items' => $docOrder->locations->values()->map(fn ($loc, $idx) => [
'id' => (string) $loc->id,
'name' => $qualityDoc->quality_doc_number.'-'.str_pad($idx + 1, 2, '0', STR_PAD_LEFT),
'location' => $this->buildLocationCode($loc),
'is_completed' => (bool) data_get($loc->options, 'lot_audit_confirmed', false),
])->all(),
];
}
private function buildLocationName(QualityDocumentLocation $location): string
{
$qualityDoc = $location->qualityDocument;
if (! $qualityDoc) {
return '';
}
// location의 순번을 구하기 위해 같은 문서의 location 목록 조회
$locations = QualityDocumentLocation::where('quality_document_id', $qualityDoc->id)
->orderBy('id')
->pluck('id');
$index = $locations->search($location->id);
return $qualityDoc->quality_doc_number.'-'.str_pad(($index !== false ? $index + 1 : 1), 2, '0', STR_PAD_LEFT);
}
private function buildLocationCode(QualityDocumentLocation $location): string
{
$orderItem = $location->orderItem;
if (! $orderItem) {
return '';
}
return trim(($orderItem->floor_code ?? '').' '.($orderItem->symbol_code ?? ''));
}
// ===== Private: Document Format Helpers =====
private function formatDocument(string $type, string $title, $collection): array
{
return [
'id' => $type,
'type' => $type,
'title' => $title,
'count' => $collection->count(),
'items' => $collection->values()->map(fn ($item) => $this->formatDocumentItem($type, $item))->all(),
];
}
private function formatDocumentWithSubType(string $type, string $title, $collection, ?string $workOrderRelation = null): array
{
return [
'id' => $type,
'type' => $type,
'title' => $title,
'count' => $collection->count(),
'items' => $collection->values()->map(function ($item) use ($type, $workOrderRelation) {
$formatted = $this->formatDocumentItem($type, $item);
// subType: process.process_name 기반
$workOrder = $workOrderRelation ? $item->{$workOrderRelation} : $item;
if ($workOrder instanceof WorkOrder) {
$processName = $workOrder->process?->process_name;
$formatted['sub_type'] = $this->mapProcessToSubType($processName);
}
return $formatted;
})->all(),
];
}
private function formatDocumentItem(string $type, $item): array
{
return match ($type) {
'import', 'report' => [
'id' => (string) $item->id,
'title' => $item->inspection_no ?? '',
'date' => $item->inspection_date?->toDateString() ?? $item->request_date?->toDateString() ?? '',
'code' => $item->inspection_no ?? '',
],
'order' => [
'id' => (string) $item->id,
'title' => $item->order_no,
'date' => $item->received_at?->toDateString() ?? '',
'code' => $item->order_no,
],
'log' => [
'id' => (string) $item->id,
'title' => $item->project_name ?? '작업일지',
'date' => $item->created_at?->toDateString() ?? '',
'code' => $item->id,
],
'confirmation', 'shipping' => [
'id' => (string) $item->id,
'title' => $item->shipment_no ?? '',
'date' => $item->scheduled_date?->toDateString() ?? '',
'code' => $item->shipment_no ?? '',
],
'product' => [
'id' => (string) $item->id,
'title' => '제품검사 성적서',
'date' => $item->updated_at?->toDateString() ?? '',
'code' => '',
],
'quality' => [
'id' => (string) $item->id,
'title' => $item->quality_doc_number ?? '',
'date' => $item->received_date?->toDateString() ?? '',
'code' => $item->quality_doc_number ?? '',
],
default => [
'id' => (string) $item->id,
'title' => '',
'date' => '',
],
};
}
/**
* process_name → subType 매핑
*/
private function mapProcessToSubType(?string $processName): ?string
{
if (! $processName) {
return null;
}
$name = mb_strtolower($processName);
return match (true) {
str_contains($name, 'screen') || str_contains($name, '스크린') => 'screen',
str_contains($name, 'bending') || str_contains($name, '절곡') => 'bending',
str_contains($name, 'slat') || str_contains($name, '슬랫') => 'slat',
str_contains($name, 'jointbar') || str_contains($name, '조인트바') || str_contains($name, 'joint') => 'jointbar',
default => null,
};
}
// ===== Private: Document Detail Methods (2단계 로딩) =====
private function getInspectionDetail(int $id, string $type): array
{
$inspection = Inspection::where('inspection_type', $type)
->with(['item', 'workOrder.process'])
->findOrFail($id);
return [
'type' => $type === 'IQC' ? 'import' : 'report',
'data' => [
'id' => $inspection->id,
'inspection_no' => $inspection->inspection_no,
'inspection_type' => $inspection->inspection_type,
'status' => $inspection->status,
'result' => $inspection->result,
'request_date' => $inspection->request_date?->toDateString(),
'inspection_date' => $inspection->inspection_date?->toDateString(),
'lot_no' => $inspection->lot_no,
'item_name' => $inspection->item?->name,
'process_name' => $inspection->workOrder?->process?->process_name,
'meta' => $inspection->meta,
'items' => $inspection->items,
'attachments' => $inspection->attachments,
'extra' => $inspection->extra,
],
];
}
private function getOrderDetail(int $id): array
{
$order = Order::with(['nodes' => fn ($q) => $q->whereNull('parent_id')])->findOrFail($id);
return [
'type' => 'order',
'data' => [
'id' => $order->id,
'order_no' => $order->order_no,
'status' => $order->status,
'received_at' => $order->received_at?->toDateString(),
'site_name' => $order->site_name,
'nodes_count' => $order->nodes->count(),
],
];
}
private function getWorkOrderLogDetail(int $id): array
{
$workOrder = WorkOrder::with('process')->findOrFail($id);
return [
'type' => 'log',
'data' => [
'id' => $workOrder->id,
'project_name' => $workOrder->project_name,
'status' => $workOrder->status,
'process_name' => $workOrder->process?->process_name,
'options' => $workOrder->options,
'created_at' => $workOrder->created_at?->toDateString(),
],
];
}
private function getShipmentDetail(int $id): array
{
$shipment = Shipment::findOrFail($id);
return [
'type' => 'shipping',
'data' => [
'id' => $shipment->id,
'shipment_no' => $shipment->shipment_no,
'status' => $shipment->status,
'scheduled_date' => $shipment->scheduled_date?->toDateString(),
'customer_name' => $shipment->customer_name,
'site_name' => $shipment->site_name,
'delivery_address' => $shipment->delivery_address,
'delivery_method' => $shipment->delivery_method,
'vehicle_no' => $shipment->vehicle_no,
'driver_name' => $shipment->driver_name,
'remarks' => $shipment->remarks,
],
];
}
private function getLocationDetail(int $id): array
{
$location = QualityDocumentLocation::with(['orderItem', 'document'])->findOrFail($id);
return [
'type' => 'product',
'data' => [
'id' => $location->id,
'inspection_status' => $location->inspection_status,
'inspection_data' => $location->inspection_data,
'post_width' => $location->post_width,
'post_height' => $location->post_height,
'floor_code' => $location->orderItem?->floor_code,
'symbol_code' => $location->orderItem?->symbol_code,
'document_id' => $location->document_id,
],
];
}
private function getQualityDocDetail(int $id): array
{
$doc = QualityDocument::with(['client', 'inspector:id,name'])->findOrFail($id);
return [
'type' => 'quality',
'data' => [
'id' => $doc->id,
'quality_doc_number' => $doc->quality_doc_number,
'site_name' => $doc->site_name,
'status' => $doc->status,
'received_date' => $doc->received_date?->toDateString(),
'client_name' => $doc->client?->name,
'inspector_name' => $doc->inspector?->name,
'options' => $doc->options,
],
];
}
}

View File

@@ -0,0 +1,22 @@
<?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::table('quality_document_locations', function (Blueprint $table) {
$table->json('options')->nullable()->after('inspection_status')->comment('QMS 심사 확인 등 추가 데이터');
});
}
public function down(): void
{
Schema::table('quality_document_locations', function (Blueprint $table) {
$table->dropColumn('options');
});
}
};

View File

@@ -8,6 +8,7 @@
*/
use App\Http\Controllers\Api\V1\PerformanceReportController;
use App\Http\Controllers\Api\V1\QmsLotAuditController;
use App\Http\Controllers\Api\V1\QualityDocumentController;
use Illuminate\Support\Facades\Route;
@@ -38,3 +39,12 @@
Route::patch('/unconfirm', [PerformanceReportController::class, 'unconfirm'])->name('v1.quality.performance-reports.unconfirm');
Route::patch('/memo', [PerformanceReportController::class, 'updateMemo'])->name('v1.quality.performance-reports.memo');
});
// QMS 로트 추적 심사
Route::prefix('qms/lot-audit')->group(function () {
Route::get('/reports', [QmsLotAuditController::class, 'index'])->name('v1.qms.lot-audit.reports');
Route::get('/reports/{id}', [QmsLotAuditController::class, 'show'])->whereNumber('id')->name('v1.qms.lot-audit.reports.show');
Route::get('/routes/{id}/documents', [QmsLotAuditController::class, 'routeDocuments'])->whereNumber('id')->name('v1.qms.lot-audit.routes.documents');
Route::get('/documents/{type}/{id}', [QmsLotAuditController::class, 'documentDetail'])->whereNumber('id')->name('v1.qms.lot-audit.documents.detail');
Route::patch('/units/{id}/confirm', [QmsLotAuditController::class, 'confirm'])->whereNumber('id')->name('v1.qms.lot-audit.units.confirm');
});