diff --git a/app/Console/Commands/BackfillQuoteProductCodeCommand.php b/app/Console/Commands/BackfillQuoteProductCodeCommand.php new file mode 100644 index 0000000..4550ca9 --- /dev/null +++ b/app/Console/Commands/BackfillQuoteProductCodeCommand.php @@ -0,0 +1,54 @@ +option('dry-run'); + + $quotes = Quote::whereNull('product_code') + ->whereNotNull('calculation_inputs') + ->get(); + + $this->info("대상: {$quotes->count()}건".($dryRun ? ' (dry-run)' : '')); + + $updated = 0; + $skipped = 0; + + foreach ($quotes as $quote) { + $inputs = $quote->calculation_inputs; + if (! is_array($inputs)) { + $inputs = json_decode($inputs, true); + } + + $productCode = $inputs['items'][0]['productCode'] ?? null; + + if (! $productCode) { + $skipped++; + $this->line(" SKIP #{$quote->id} ({$quote->quote_number}) — productCode 없음"); + + continue; + } + + if (! $dryRun) { + $quote->update(['product_code' => $productCode]); + } + + $updated++; + $this->line(' '.($dryRun ? 'WOULD ' : '')."UPDATE #{$quote->id} ({$quote->quote_number}) → {$productCode}"); + } + + $this->info("완료: 보정 {$updated}건, 스킵 {$skipped}건"); + + return self::SUCCESS; + } +} diff --git a/app/Helpers/Legacy5130Calculator.php b/app/Helpers/Legacy5130Calculator.php index 1d8c59e..5c2f723 100644 --- a/app/Helpers/Legacy5130Calculator.php +++ b/app/Helpers/Legacy5130Calculator.php @@ -14,7 +14,7 @@ * - 두께 매핑 (normalizeThickness) * - 면적 계산 (calculateArea) * - * @see docs/plans/5130-sam-data-migration-plan.md 섹션 4.5 + * @see docs/dev_plans/5130-sam-data-migration-plan.md 섹션 4.5 */ class Legacy5130Calculator { diff --git a/app/Http/Controllers/Api/V1/InspectionController.php b/app/Http/Controllers/Api/V1/InspectionController.php index 2edc728..d543d12 100644 --- a/app/Http/Controllers/Api/V1/InspectionController.php +++ b/app/Http/Controllers/Api/V1/InspectionController.php @@ -34,6 +34,16 @@ public function stats(Request $request) }, __('message.inspection.fetched')); } + /** + * 캘린더 스케줄 조회 + */ + public function calendar(Request $request) + { + return ApiResponse::handle(function () use ($request) { + return $this->service->calendar($request->all()); + }, __('message.inspection.fetched')); + } + /** * 단건 조회 */ diff --git a/app/Http/Controllers/Api/V1/PerformanceReportController.php b/app/Http/Controllers/Api/V1/PerformanceReportController.php new file mode 100644 index 0000000..75d40ea --- /dev/null +++ b/app/Http/Controllers/Api/V1/PerformanceReportController.php @@ -0,0 +1,59 @@ +service->index($request->all()); + }, __('message.fetched')); + } + + public function stats(Request $request) + { + return ApiResponse::handle(function () use ($request) { + return $this->service->stats($request->all()); + }, __('message.fetched')); + } + + public function confirm(PerformanceReportConfirmRequest $request) + { + return ApiResponse::handle(function () use ($request) { + return $this->service->confirm($request->validated()['ids']); + }, __('message.updated')); + } + + public function unconfirm(PerformanceReportConfirmRequest $request) + { + return ApiResponse::handle(function () use ($request) { + return $this->service->unconfirm($request->validated()['ids']); + }, __('message.updated')); + } + + public function updateMemo(PerformanceReportMemoRequest $request) + { + return ApiResponse::handle(function () use ($request) { + $data = $request->validated(); + + return $this->service->updateMemo($data['ids'], $data['memo']); + }, __('message.updated')); + } + + public function missing(Request $request) + { + return ApiResponse::handle(function () use ($request) { + return $this->service->missing($request->all()); + }, __('message.fetched')); + } +} diff --git a/app/Http/Controllers/Api/V1/QualityDocumentController.php b/app/Http/Controllers/Api/V1/QualityDocumentController.php new file mode 100644 index 0000000..82cb42f --- /dev/null +++ b/app/Http/Controllers/Api/V1/QualityDocumentController.php @@ -0,0 +1,127 @@ +service->index($request->all()); + }, __('message.fetched')); + } + + public function stats(Request $request) + { + return ApiResponse::handle(function () use ($request) { + return $this->service->stats($request->all()); + }, __('message.fetched')); + } + + public function calendar(Request $request) + { + return ApiResponse::handle(function () use ($request) { + return $this->service->calendar($request->all()); + }, __('message.fetched')); + } + + public function availableOrders(Request $request) + { + return ApiResponse::handle(function () use ($request) { + return $this->service->availableOrders($request->all()); + }, __('message.fetched')); + } + + public function show(int $id) + { + return ApiResponse::handle(function () use ($id) { + return $this->service->show($id); + }, __('message.fetched')); + } + + public function store(QualityDocumentStoreRequest $request) + { + return ApiResponse::handle(function () use ($request) { + return $this->service->store($request->validated()); + }, __('message.created')); + } + + public function update(QualityDocumentUpdateRequest $request, int $id) + { + return ApiResponse::handle(function () use ($request, $id) { + return $this->service->update($id, $request->validated()); + }, __('message.updated')); + } + + public function destroy(int $id) + { + return ApiResponse::handle(function () use ($id) { + $this->service->destroy($id); + + return 'success'; + }, __('message.deleted')); + } + + public function complete(int $id) + { + return ApiResponse::handle(function () use ($id) { + return $this->service->complete($id); + }, __('message.updated')); + } + + public function attachOrders(Request $request, int $id) + { + $request->validate([ + 'order_ids' => ['required', 'array', 'min:1'], + 'order_ids.*' => ['required', 'integer'], + ]); + + return ApiResponse::handle(function () use ($request, $id) { + return $this->service->attachOrders($id, $request->input('order_ids')); + }, __('message.updated')); + } + + public function detachOrder(int $id, int $orderId) + { + return ApiResponse::handle(function () use ($id, $orderId) { + return $this->service->detachOrder($id, $orderId); + }, __('message.updated')); + } + + public function inspectLocation(Request $request, int $id, int $locId) + { + $request->validate([ + 'post_width' => ['nullable', 'integer'], + 'post_height' => ['nullable', 'integer'], + 'change_reason' => ['nullable', 'string', 'max:500'], + 'inspection_status' => ['nullable', 'string', 'in:pending,completed'], + ]); + + return ApiResponse::handle(function () use ($request, $id, $locId) { + return $this->service->inspectLocation($id, $locId, $request->all()); + }, __('message.updated')); + } + + public function requestDocument(int $id) + { + return ApiResponse::handle(function () use ($id) { + return $this->service->requestDocument($id); + }, __('message.fetched')); + } + + public function resultDocument(int $id) + { + return ApiResponse::handle(function () use ($id) { + return $this->service->resultDocument($id); + }, __('message.fetched')); + } +} diff --git a/app/Http/Requests/Inspection/InspectionStoreRequest.php b/app/Http/Requests/Inspection/InspectionStoreRequest.php index 56e38c1..9d92a4f 100644 --- a/app/Http/Requests/Inspection/InspectionStoreRequest.php +++ b/app/Http/Requests/Inspection/InspectionStoreRequest.php @@ -22,6 +22,7 @@ public function rules(): array Inspection::TYPE_FQC, ])], 'lot_no' => ['required', 'string', 'max:50'], + 'work_order_id' => ['nullable', 'integer', 'exists:work_orders,id'], 'item_name' => ['nullable', 'string', 'max:200'], 'process_name' => ['nullable', 'string', 'max:100'], 'quantity' => ['nullable', 'numeric', 'min:0'], diff --git a/app/Http/Requests/Quality/PerformanceReportConfirmRequest.php b/app/Http/Requests/Quality/PerformanceReportConfirmRequest.php new file mode 100644 index 0000000..de0b74b --- /dev/null +++ b/app/Http/Requests/Quality/PerformanceReportConfirmRequest.php @@ -0,0 +1,29 @@ + ['required', 'array', 'min:1'], + 'ids.*' => ['required', 'integer', 'exists:performance_reports,id'], + ]; + } + + public function messages(): array + { + return [ + 'ids.required' => __('validation.required', ['attribute' => '실적신고 ID']), + 'ids.min' => __('validation.min.array', ['attribute' => '실적신고 ID', 'min' => 1]), + ]; + } +} diff --git a/app/Http/Requests/Quality/PerformanceReportMemoRequest.php b/app/Http/Requests/Quality/PerformanceReportMemoRequest.php new file mode 100644 index 0000000..76fab6d --- /dev/null +++ b/app/Http/Requests/Quality/PerformanceReportMemoRequest.php @@ -0,0 +1,30 @@ + ['required', 'array', 'min:1'], + 'ids.*' => ['required', 'integer', 'exists:performance_reports,id'], + 'memo' => ['required', 'string', 'max:1000'], + ]; + } + + public function messages(): array + { + return [ + 'ids.required' => __('validation.required', ['attribute' => '실적신고 ID']), + 'memo.required' => __('validation.required', ['attribute' => '메모']), + ]; + } +} diff --git a/app/Http/Requests/Quality/QualityDocumentStoreRequest.php b/app/Http/Requests/Quality/QualityDocumentStoreRequest.php new file mode 100644 index 0000000..14e8cd8 --- /dev/null +++ b/app/Http/Requests/Quality/QualityDocumentStoreRequest.php @@ -0,0 +1,45 @@ + ['required', 'string', 'max:200'], + 'client_id' => ['nullable', 'integer', 'exists:clients,id'], + 'inspector_id' => ['nullable', 'integer', 'exists:users,id'], + 'received_date' => ['nullable', 'date'], + 'options' => ['nullable', 'array'], + 'options.manager' => ['nullable', 'array'], + 'options.manager.name' => ['nullable', 'string', 'max:50'], + 'options.manager.phone' => ['nullable', 'string', 'max:30'], + 'options.inspection' => ['nullable', 'array'], + 'options.inspection.request_date' => ['nullable', 'date'], + 'options.inspection.start_date' => ['nullable', 'date'], + 'options.inspection.end_date' => ['nullable', 'date'], + 'options.site_address' => ['nullable', 'array'], + 'options.construction_site' => ['nullable', 'array'], + 'options.material_distributor' => ['nullable', 'array'], + 'options.contractor' => ['nullable', 'array'], + 'options.supervisor' => ['nullable', 'array'], + 'order_ids' => ['nullable', 'array'], + 'order_ids.*' => ['integer', 'exists:orders,id'], + ]; + } + + public function messages(): array + { + return [ + 'site_name.required' => __('validation.required', ['attribute' => '현장명']), + ]; + } +} diff --git a/app/Http/Requests/Quality/QualityDocumentUpdateRequest.php b/app/Http/Requests/Quality/QualityDocumentUpdateRequest.php new file mode 100644 index 0000000..763d607 --- /dev/null +++ b/app/Http/Requests/Quality/QualityDocumentUpdateRequest.php @@ -0,0 +1,44 @@ + ['sometimes', 'string', 'max:200'], + 'client_id' => ['nullable', 'integer', 'exists:clients,id'], + 'inspector_id' => ['nullable', 'integer', 'exists:users,id'], + 'received_date' => ['nullable', 'date'], + 'options' => ['nullable', 'array'], + 'options.manager' => ['nullable', 'array'], + 'options.manager.name' => ['nullable', 'string', 'max:50'], + 'options.manager.phone' => ['nullable', 'string', 'max:30'], + 'options.inspection' => ['nullable', 'array'], + 'options.inspection.request_date' => ['nullable', 'date'], + 'options.inspection.start_date' => ['nullable', 'date'], + 'options.inspection.end_date' => ['nullable', 'date'], + 'options.site_address' => ['nullable', 'array'], + 'options.construction_site' => ['nullable', 'array'], + 'options.material_distributor' => ['nullable', 'array'], + 'options.contractor' => ['nullable', 'array'], + 'options.supervisor' => ['nullable', 'array'], + 'order_ids' => ['nullable', 'array'], + 'order_ids.*' => ['integer', 'exists:orders,id'], + 'locations' => ['nullable', 'array'], + 'locations.*.id' => ['required', 'integer'], + 'locations.*.post_width' => ['nullable', 'integer'], + 'locations.*.post_height' => ['nullable', 'integer'], + 'locations.*.change_reason' => ['nullable', 'string', 'max:500'], + 'locations.*.inspection_data' => ['nullable', 'array'], + ]; + } +} diff --git a/app/Http/Requests/WorkOrder/StoreItemInspectionRequest.php b/app/Http/Requests/WorkOrder/StoreItemInspectionRequest.php index b5dba3a..b542fe4 100644 --- a/app/Http/Requests/WorkOrder/StoreItemInspectionRequest.php +++ b/app/Http/Requests/WorkOrder/StoreItemInspectionRequest.php @@ -39,6 +39,16 @@ public function rules(): array 'inspection_data.nonConformingContent' => 'nullable|string|max:1000', 'inspection_data.templateValues' => 'nullable|array', 'inspection_data.templateValues.*' => 'nullable', + // 절곡 제품별 검사 데이터 + 'inspection_data.products' => 'nullable|array', + 'inspection_data.products.*.id' => 'required_with:inspection_data.products|string', + 'inspection_data.products.*.bendingStatus' => ['nullable', Rule::in(['양호', '불량'])], + 'inspection_data.products.*.lengthMeasured' => 'nullable|string|max:50', + 'inspection_data.products.*.widthMeasured' => 'nullable|string|max:50', + 'inspection_data.products.*.gapPoints' => 'nullable|array', + 'inspection_data.products.*.gapPoints.*.point' => 'nullable|string', + 'inspection_data.products.*.gapPoints.*.designValue' => 'nullable|string', + 'inspection_data.products.*.gapPoints.*.measured' => 'nullable|string|max:50', ]; } diff --git a/app/Models/Qualitys/Inspection.php b/app/Models/Qualitys/Inspection.php index f6453b3..fd4b6a2 100644 --- a/app/Models/Qualitys/Inspection.php +++ b/app/Models/Qualitys/Inspection.php @@ -4,6 +4,7 @@ use App\Models\Items\Item; use App\Models\Members\User; +use App\Models\Production\WorkOrder; use App\Traits\Auditable; use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -23,6 +24,7 @@ * @property string|null $inspection_date 검사일 * @property int|null $item_id 품목 ID * @property string $lot_no LOT번호 + * @property int|null $work_order_id 작업지시 ID (PQC/FQC용) * @property int|null $inspector_id 검사자 ID * @property array|null $meta 메타정보 (process_name, quantity, unit 등) * @property array|null $items 검사항목 배열 @@ -47,6 +49,7 @@ class Inspection extends Model 'inspection_date', 'item_id', 'lot_no', + 'work_order_id', 'inspector_id', 'meta', 'items', @@ -92,6 +95,14 @@ class Inspection extends Model // ===== Relationships ===== + /** + * 작업지시 (PQC/FQC용) + */ + public function workOrder() + { + return $this->belongsTo(WorkOrder::class); + } + /** * 품목 */ diff --git a/app/Models/Qualitys/PerformanceReport.php b/app/Models/Qualitys/PerformanceReport.php new file mode 100644 index 0000000..3c03cc9 --- /dev/null +++ b/app/Models/Qualitys/PerformanceReport.php @@ -0,0 +1,76 @@ + 'date', + 'year' => 'integer', + 'quarter' => 'integer', + ]; + + // ===== Relationships ===== + + public function qualityDocument() + { + return $this->belongsTo(QualityDocument::class); + } + + public function confirmer() + { + return $this->belongsTo(User::class, 'confirmed_by'); + } + + public function creator() + { + return $this->belongsTo(User::class, 'created_by'); + } + + // ===== Status Helpers ===== + + public function isUnconfirmed(): bool + { + return $this->confirmation_status === self::STATUS_UNCONFIRMED; + } + + public function isConfirmed(): bool + { + return $this->confirmation_status === self::STATUS_CONFIRMED; + } + + public function isReported(): bool + { + return $this->confirmation_status === self::STATUS_REPORTED; + } +} diff --git a/app/Models/Qualitys/QualityDocument.php b/app/Models/Qualitys/QualityDocument.php new file mode 100644 index 0000000..bcbc104 --- /dev/null +++ b/app/Models/Qualitys/QualityDocument.php @@ -0,0 +1,131 @@ + 'array', + 'received_date' => 'date', + ]; + + // ===== Relationships ===== + + public function client() + { + return $this->belongsTo(Client::class, 'client_id'); + } + + public function inspector() + { + return $this->belongsTo(User::class, 'inspector_id'); + } + + public function creator() + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function documentOrders() + { + return $this->hasMany(QualityDocumentOrder::class); + } + + public function locations() + { + return $this->hasMany(QualityDocumentLocation::class); + } + + public function performanceReport() + { + return $this->hasOne(PerformanceReport::class); + } + + // ===== 채번 ===== + + public static function generateDocNumber(int $tenantId): string + { + $prefix = 'KD-QD'; + $yearMonth = now()->format('Ym'); + + $lastNo = static::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('quality_doc_number', 'like', "{$prefix}-{$yearMonth}-%") + ->orderByDesc('quality_doc_number') + ->value('quality_doc_number'); + + if ($lastNo) { + $seq = (int) substr($lastNo, -4) + 1; + } else { + $seq = 1; + } + + return sprintf('%s-%s-%04d', $prefix, $yearMonth, $seq); + } + + // ===== Status Helpers ===== + + public function isReceived(): bool + { + return $this->status === self::STATUS_RECEIVED; + } + + public function isInProgress(): bool + { + return $this->status === self::STATUS_IN_PROGRESS; + } + + public function isCompleted(): bool + { + return $this->status === self::STATUS_COMPLETED; + } + + public static function mapStatusToFrontend(string $status): string + { + return match ($status) { + self::STATUS_RECEIVED => 'reception', + self::STATUS_IN_PROGRESS => 'in_progress', + self::STATUS_COMPLETED => 'completed', + default => $status, + }; + } + + public static function mapStatusFromFrontend(string $status): string + { + return match ($status) { + 'reception' => self::STATUS_RECEIVED, + default => $status, + }; + } +} diff --git a/app/Models/Qualitys/QualityDocumentLocation.php b/app/Models/Qualitys/QualityDocumentLocation.php new file mode 100644 index 0000000..311ed9d --- /dev/null +++ b/app/Models/Qualitys/QualityDocumentLocation.php @@ -0,0 +1,62 @@ + 'array', + ]; + + public function qualityDocument() + { + return $this->belongsTo(QualityDocument::class); + } + + public function qualityDocumentOrder() + { + return $this->belongsTo(QualityDocumentOrder::class); + } + + public function orderItem() + { + return $this->belongsTo(OrderItem::class); + } + + public function document() + { + return $this->belongsTo(Document::class); + } + + public function isPending(): bool + { + return $this->inspection_status === self::STATUS_PENDING; + } + + public function isCompleted(): bool + { + return $this->inspection_status === self::STATUS_COMPLETED; + } +} diff --git a/app/Models/Qualitys/QualityDocumentOrder.php b/app/Models/Qualitys/QualityDocumentOrder.php new file mode 100644 index 0000000..4d804f7 --- /dev/null +++ b/app/Models/Qualitys/QualityDocumentOrder.php @@ -0,0 +1,31 @@ +belongsTo(QualityDocument::class); + } + + public function order() + { + return $this->belongsTo(Order::class); + } + + public function locations() + { + return $this->hasMany(QualityDocumentLocation::class); + } +} diff --git a/app/Services/InspectionService.php b/app/Services/InspectionService.php index dcc5e38..3f142c7 100644 --- a/app/Services/InspectionService.php +++ b/app/Services/InspectionService.php @@ -33,7 +33,7 @@ public function index(array $params) $query = Inspection::query() ->where('tenant_id', $tenantId) - ->with(['inspector:id,name', 'item:id,item_name']); + ->with(['inspector:id,name', 'item:id,item_name', 'workOrder:id,work_order_no']); // 검색어 (검사번호, LOT번호) if ($q !== '') { @@ -126,7 +126,7 @@ public function show(int $id) $tenantId = $this->tenantId(); $inspection = Inspection::where('tenant_id', $tenantId) - ->with(['inspector:id,name', 'item:id,item_name']) + ->with(['inspector:id,name', 'item:id,item_name', 'workOrder:id,work_order_no']) ->find($id); if (! $inspection) { @@ -183,6 +183,7 @@ public function store(array $data) 'inspection_type' => $data['inspection_type'], 'request_date' => $data['request_date'] ?? now()->toDateString(), 'lot_no' => $data['lot_no'], + 'work_order_id' => $data['work_order_id'] ?? null, 'inspector_id' => $data['inspector_id'] ?? null, 'meta' => $meta, 'items' => $items, @@ -200,7 +201,7 @@ public function store(array $data) $inspection->toArray() ); - return $this->transformToFrontend($inspection->load(['inspector:id,name'])); + return $this->transformToFrontend($inspection->load(['inspector:id,name', 'workOrder:id,work_order_no'])); }); } @@ -277,7 +278,7 @@ public function update(int $id, array $data) $inspection->fresh()->toArray() ); - return $this->transformToFrontend($inspection->load(['inspector:id,name'])); + return $this->transformToFrontend($inspection->load(['inspector:id,name', 'workOrder:id,work_order_no'])); }); } @@ -360,10 +361,83 @@ public function complete(int $id, array $data) $inspection->fresh()->toArray() ); - return $this->transformToFrontend($inspection->load(['inspector:id,name'])); + return $this->transformToFrontend($inspection->load(['inspector:id,name', 'workOrder:id,work_order_no'])); }); } + /** + * 캘린더 스케줄 조회 + */ + public function calendar(array $params): array + { + $tenantId = $this->tenantId(); + + $year = (int) ($params['year'] ?? now()->year); + $month = (int) ($params['month'] ?? now()->month); + + // 해당 월의 시작일/종료일 + $startDate = sprintf('%04d-%02d-01', $year, $month); + $endDate = date('Y-m-t', strtotime($startDate)); + + $query = Inspection::query() + ->where('tenant_id', $tenantId) + ->where(function ($q) use ($startDate, $endDate) { + $q->whereBetween('request_date', [$startDate, $endDate]) + ->orWhereBetween('inspection_date', [$startDate, $endDate]); + }) + ->with(['inspector:id,name', 'item:id,item_name']); + + // 검사자 필터 + if (! empty($params['inspector'])) { + $query->whereHas('inspector', function ($q) use ($params) { + $q->where('name', $params['inspector']); + }); + } + + // 상태 필터 + if (! empty($params['status'])) { + $status = $params['status'] === 'reception' ? self::mapStatusFromFrontend('reception') : $params['status']; + $query->where('status', $status); + } + + return $query->orderBy('request_date') + ->get() + ->map(fn (Inspection $item) => [ + 'id' => $item->id, + 'start_date' => $item->request_date?->format('Y-m-d'), + 'end_date' => $item->inspection_date?->format('Y-m-d') ?? $item->request_date?->format('Y-m-d'), + 'inspector' => $item->inspector?->name ?? '', + 'site_name' => $item->item?->item_name ?? ($item->meta['process_name'] ?? $item->inspection_no), + 'status' => self::mapStatusToFrontend($item->status), + ]) + ->values() + ->toArray(); + } + + /** + * 상태를 프론트엔드 형식으로 매핑 + */ + private static function mapStatusToFrontend(string $status): string + { + return match ($status) { + Inspection::STATUS_WAITING => 'reception', + Inspection::STATUS_IN_PROGRESS => 'in_progress', + Inspection::STATUS_COMPLETED => 'completed', + default => $status, + }; + } + + /** + * 프론트엔드 상태를 DB 상태로 매핑 + */ + private static function mapStatusFromFrontend(string $status): string + { + return match ($status) { + 'reception' => Inspection::STATUS_WAITING, + default => $status, + }; + } + /** * DB 데이터를 프론트엔드 형식으로 변환 */ @@ -380,6 +454,8 @@ private function transformToFrontend(Inspection $inspection): array 'inspection_date' => $inspection->inspection_date?->format('Y-m-d'), 'item_name' => $inspection->item?->item_name ?? ($meta['item_name'] ?? null), 'lot_no' => $inspection->lot_no, + 'work_order_id' => $inspection->work_order_id, + 'work_order_no' => $inspection->workOrder?->work_order_no, 'process_name' => $meta['process_name'] ?? null, 'quantity' => $meta['quantity'] ?? null, 'unit' => $meta['unit'] ?? null, diff --git a/app/Services/OrderService.php b/app/Services/OrderService.php index 98b35d1..c3df77a 100644 --- a/app/Services/OrderService.php +++ b/app/Services/OrderService.php @@ -1325,9 +1325,13 @@ public function createProductionOrder(int $orderId, array $data) // 작업지시번호 생성 $workOrderNo = $this->generateWorkOrderNo($tenantId); - // 절곡 공정이면 bending_info 자동 생성 + // 공정 옵션 초기화 (보조 공정 플래그 포함) $workOrderOptions = null; if ($processId) { + $process = \App\Models\Process::find($processId); + if ($process && ! empty($process->options['is_auxiliary'])) { + $workOrderOptions = ['is_auxiliary' => true]; + } // 이 작업지시에 포함되는 노드 ID만 추출 $nodeIds = collect($items) ->pluck('order_node_id') @@ -1338,7 +1342,7 @@ public function createProductionOrder(int $orderId, array $data) $buildResult = app(BendingInfoBuilder::class)->build($order, $processId, $nodeIds ?: null); if ($buildResult) { - $workOrderOptions = ['bending_info' => $buildResult['bending_info']]; + $workOrderOptions = array_merge($workOrderOptions ?? [], ['bending_info' => $buildResult['bending_info']]); } } @@ -1410,6 +1414,8 @@ public function createProductionOrder(int $orderId, array $data) $woItemOptions = array_filter([ 'floor' => $orderItem->floor_code, 'code' => $orderItem->symbol_code, + 'product_code' => ! empty($nodeOptions['product_code']) ? $nodeOptions['product_code'] : null, + 'product_name' => ! empty($nodeOptions['product_name']) ? $nodeOptions['product_name'] : null, 'width' => $woWidth, 'height' => $woHeight, 'cutting_info' => $nodeOptions['cutting_info'] ?? null, diff --git a/app/Services/PerformanceReportService.php b/app/Services/PerformanceReportService.php new file mode 100644 index 0000000..80b343b --- /dev/null +++ b/app/Services/PerformanceReportService.php @@ -0,0 +1,258 @@ +tenantId(); + $perPage = (int) ($params['per_page'] ?? 20); + $q = trim((string) ($params['q'] ?? '')); + $year = $params['year'] ?? null; + $quarter = $params['quarter'] ?? null; + $confirmStatus = $params['confirm_status'] ?? null; + + $query = PerformanceReport::query() + ->where('performance_reports.tenant_id', $tenantId) + ->with(['qualityDocument.client', 'qualityDocument.locations', 'confirmer:id,name']); + + if ($q !== '') { + $query->whereHas('qualityDocument', function ($qq) use ($q) { + $qq->where('quality_doc_number', 'like', "%{$q}%") + ->orWhere('site_name', 'like', "%{$q}%"); + }); + } + + if ($year !== null) { + $query->where('year', $year); + } + if ($quarter !== null) { + $query->where('quarter', $quarter); + } + if ($confirmStatus !== null) { + $query->where('confirmation_status', $confirmStatus); + } + + $query->orderByDesc('performance_reports.id'); + $paginated = $query->paginate($perPage); + + $transformedData = $paginated->getCollection()->map(fn ($report) => $this->transformToFrontend($report)); + + return [ + 'items' => $transformedData, + 'current_page' => $paginated->currentPage(), + 'last_page' => $paginated->lastPage(), + 'per_page' => $paginated->perPage(), + 'total' => $paginated->total(), + ]; + } + + /** + * 통계 조회 + */ + public function stats(array $params = []): array + { + $tenantId = $this->tenantId(); + + $query = PerformanceReport::where('performance_reports.tenant_id', $tenantId); + + if (! empty($params['year'])) { + $query->where('performance_reports.year', $params['year']); + } + if (! empty($params['quarter'])) { + $query->where('performance_reports.quarter', $params['quarter']); + } + + $counts = (clone $query) + ->select('confirmation_status', DB::raw('count(*) as count')) + ->groupBy('confirmation_status') + ->pluck('count', 'confirmation_status') + ->toArray(); + + $totalLocations = (clone $query) + ->join('quality_documents', 'quality_documents.id', '=', 'performance_reports.quality_document_id') + ->join('quality_document_locations', 'quality_document_locations.quality_document_id', '=', 'quality_documents.id') + ->count('quality_document_locations.id'); + + return [ + 'total_count' => array_sum($counts), + 'confirmed_count' => $counts[PerformanceReport::STATUS_CONFIRMED] ?? 0, + 'unconfirmed_count' => $counts[PerformanceReport::STATUS_UNCONFIRMED] ?? 0, + 'total_locations' => $totalLocations, + ]; + } + + /** + * 일괄 확정 + */ + public function confirm(array $ids) + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($ids, $tenantId, $userId) { + $reports = PerformanceReport::where('tenant_id', $tenantId) + ->whereIn('id', $ids) + ->with(['qualityDocument']) + ->get(); + + $errors = []; + foreach ($reports as $report) { + if ($report->isConfirmed() || $report->isReported()) { + continue; + } + + // 필수정보 검증 + $requiredInfo = $this->qualityDocumentService->calculateRequiredInfo($report->qualityDocument); + if ($requiredInfo !== '완료') { + $errors[] = [ + 'id' => $report->id, + 'quality_doc_number' => $report->qualityDocument->quality_doc_number, + 'reason' => $requiredInfo, + ]; + + continue; + } + + $report->update([ + 'confirmation_status' => PerformanceReport::STATUS_CONFIRMED, + 'confirmed_date' => now()->toDateString(), + 'confirmed_by' => $userId, + 'updated_by' => $userId, + ]); + } + + if (! empty($errors)) { + throw new BadRequestHttpException(json_encode([ + 'message' => __('error.quality.confirm_failed'), + 'errors' => $errors, + ])); + } + + return ['confirmed_count' => count($ids) - count($errors)]; + }); + } + + /** + * 일괄 확정 해제 + */ + public function unconfirm(array $ids) + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($ids, $tenantId, $userId) { + PerformanceReport::where('tenant_id', $tenantId) + ->whereIn('id', $ids) + ->where('confirmation_status', PerformanceReport::STATUS_CONFIRMED) + ->update([ + 'confirmation_status' => PerformanceReport::STATUS_UNCONFIRMED, + 'confirmed_date' => null, + 'confirmed_by' => null, + 'updated_by' => $userId, + ]); + + return ['unconfirmed_count' => count($ids)]; + }); + } + + /** + * 일괄 메모 업데이트 + */ + public function updateMemo(array $ids, string $memo) + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + PerformanceReport::where('tenant_id', $tenantId) + ->whereIn('id', $ids) + ->update([ + 'memo' => $memo, + 'updated_by' => $userId, + ]); + + return ['updated_count' => count($ids)]; + } + + /** + * 누락체크 (출고완료 but 제품검사 미등록) + */ + public function missing(array $params): array + { + $tenantId = $this->tenantId(); + + // 품질관리서가 등록된 수주 ID + $registeredOrderIds = DB::table('quality_document_orders') + ->join('quality_documents', 'quality_documents.id', '=', 'quality_document_orders.quality_document_id') + ->where('quality_documents.tenant_id', $tenantId) + ->pluck('quality_document_orders.order_id'); + + // 출고완료 상태이지만 품질관리서 미등록 수주 + $query = DB::table('orders') + ->where('tenant_id', $tenantId) + ->whereNotIn('id', $registeredOrderIds) + ->where('status_code', 'SHIPPED'); // TODO: 출고완료 상태 추가 시 상수 확인 + + if (! empty($params['year'])) { + $query->whereYear('created_at', $params['year']); + } + if (! empty($params['quarter'])) { + $quarter = (int) $params['quarter']; + $startMonth = ($quarter - 1) * 3 + 1; + $endMonth = $quarter * 3; + $query->whereMonth('created_at', '>=', $startMonth) + ->whereMonth('created_at', '<=', $endMonth); + } + + return $query->orderByDesc('id') + ->limit(100) + ->get() + ->map(fn ($order) => [ + 'id' => $order->id, + 'order_number' => $order->order_no ?? '', + 'site_name' => $order->site_name ?? '', + 'client' => '', // 별도 조인 필요 + 'delivery_date' => $order->delivery_date ?? '', + ]) + ->toArray(); + } + + /** + * DB → 프론트엔드 변환 + */ + private function transformToFrontend(PerformanceReport $report): array + { + $doc = $report->qualityDocument; + + return [ + 'id' => $report->id, + 'quality_doc_number' => $doc?->quality_doc_number ?? '', + 'created_date' => $report->created_at?->format('Y-m-d') ?? '', + 'site_name' => $doc?->site_name ?? '', + 'client' => $doc?->client?->name ?? '', + 'location_count' => $doc?->locations?->count() ?? 0, + 'required_info' => $doc ? $this->qualityDocumentService->calculateRequiredInfo($doc) : '', + 'confirm_status' => $report->confirmation_status === PerformanceReport::STATUS_CONFIRMED ? 'confirmed' : 'unconfirmed', + 'confirm_date' => $report->confirmed_date?->format('Y-m-d'), + 'memo' => $report->memo ?? '', + 'year' => $report->year, + 'quarter' => $report->quarter, + ]; + } +} diff --git a/app/Services/QualityDocumentService.php b/app/Services/QualityDocumentService.php new file mode 100644 index 0000000..e3142cd --- /dev/null +++ b/app/Services/QualityDocumentService.php @@ -0,0 +1,1167 @@ +tenantId(); + $perPage = (int) ($params['per_page'] ?? 20); + $q = trim((string) ($params['q'] ?? '')); + $status = $params['status'] ?? null; + $dateFrom = $params['date_from'] ?? null; + $dateTo = $params['date_to'] ?? null; + + $query = QualityDocument::query() + ->where('tenant_id', $tenantId) + ->with(['client', 'inspector:id,name', 'creator:id,name', 'documentOrders.order', 'locations']); + + if ($q !== '') { + $query->where(function ($qq) use ($q) { + $qq->where('quality_doc_number', 'like', "%{$q}%") + ->orWhere('site_name', 'like', "%{$q}%"); + }); + } + + if ($status !== null) { + $dbStatus = QualityDocument::mapStatusFromFrontend($status); + $query->where('status', $dbStatus); + } + + if ($dateFrom !== null) { + $query->where('received_date', '>=', $dateFrom); + } + if ($dateTo !== null) { + $query->where('received_date', '<=', $dateTo); + } + + $query->orderByDesc('id'); + $paginated = $query->paginate($perPage); + + $transformedData = $paginated->getCollection()->map(fn ($doc) => $this->transformToFrontend($doc)); + + return [ + 'items' => $transformedData, + 'current_page' => $paginated->currentPage(), + 'last_page' => $paginated->lastPage(), + 'per_page' => $paginated->perPage(), + 'total' => $paginated->total(), + ]; + } + + /** + * 통계 조회 + */ + public function stats(array $params = []): array + { + $tenantId = $this->tenantId(); + + $query = QualityDocument::where('tenant_id', $tenantId); + + if (! empty($params['date_from'])) { + $query->where('received_date', '>=', $params['date_from']); + } + if (! empty($params['date_to'])) { + $query->where('received_date', '<=', $params['date_to']); + } + + $counts = (clone $query) + ->select('status', DB::raw('count(*) as count')) + ->groupBy('status') + ->pluck('count', 'status') + ->toArray(); + + return [ + 'reception_count' => $counts[QualityDocument::STATUS_RECEIVED] ?? 0, + 'in_progress_count' => $counts[QualityDocument::STATUS_IN_PROGRESS] ?? 0, + 'completed_count' => $counts[QualityDocument::STATUS_COMPLETED] ?? 0, + ]; + } + + /** + * 캘린더 스케줄 조회 + */ + public function calendar(array $params): array + { + $tenantId = $this->tenantId(); + $year = (int) ($params['year'] ?? now()->year); + $month = (int) ($params['month'] ?? now()->month); + + $startDate = sprintf('%04d-%02d-01', $year, $month); + $endDate = date('Y-m-t', strtotime($startDate)); + + $query = QualityDocument::query() + ->where('tenant_id', $tenantId) + ->with(['inspector:id,name']); + + // options JSON 내 inspection.start_date / inspection.end_date 기준 필터링 + // received_date 기준으로 범위 필터 (options JSON은 직접 SQL 필터 어려우므로) + $query->where(function ($q) use ($startDate, $endDate) { + $q->whereBetween('received_date', [$startDate, $endDate]); + }); + + if (! empty($params['status'])) { + $dbStatus = QualityDocument::mapStatusFromFrontend($params['status']); + $query->where('status', $dbStatus); + } + + return $query->orderBy('received_date') + ->get() + ->map(function (QualityDocument $doc) { + $options = $doc->options ?? []; + $inspection = $options['inspection'] ?? []; + + return [ + 'id' => $doc->id, + 'start_date' => $inspection['start_date'] ?? $doc->received_date?->format('Y-m-d'), + 'end_date' => $inspection['end_date'] ?? $doc->received_date?->format('Y-m-d'), + 'inspector' => $doc->inspector?->name ?? '', + 'site_name' => $doc->site_name, + 'status' => QualityDocument::mapStatusToFrontend($doc->status), + ]; + }) + ->values() + ->toArray(); + } + + /** + * 단건 조회 + */ + public function show(int $id) + { + $tenantId = $this->tenantId(); + + $doc = QualityDocument::where('tenant_id', $tenantId) + ->with([ + 'client', + 'inspector:id,name', + 'creator:id,name', + 'documentOrders.order', + 'locations.orderItem.node', + ]) + ->find($id); + + if (! $doc) { + throw new NotFoundHttpException(__('error.not_found')); + } + + return $this->transformToFrontend($doc, true); + } + + /** + * 생성 + */ + public function store(array $data) + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($data, $tenantId, $userId) { + // order_ids는 별도 처리 후 $data에서 제거 + $orderIds = $data['order_ids'] ?? null; + unset($data['order_ids']); + + $data['tenant_id'] = $tenantId; + $data['quality_doc_number'] = QualityDocument::generateDocNumber($tenantId); + $data['status'] = QualityDocument::STATUS_RECEIVED; + $data['created_by'] = $userId; + + $doc = QualityDocument::create($data); + + // 수주 연결 + if (! empty($orderIds)) { + $this->syncOrders($doc, $orderIds, $tenantId); + } + + $this->auditLogger->log( + $tenantId, + self::AUDIT_TARGET, + $doc->id, + 'created', + null, + $doc->toArray() + ); + + $doc->load(['client', 'inspector:id,name', 'creator:id,name', 'documentOrders.order', 'locations.orderItem.node']); + + // 요청서 Document(EAV) 자동생성 + $this->syncRequestDocument($doc); + + return $this->transformToFrontend($doc); + }); + } + + /** + * 수정 + */ + public function update(int $id, array $data) + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $doc = QualityDocument::where('tenant_id', $tenantId)->find($id); + if (! $doc) { + throw new NotFoundHttpException(__('error.not_found')); + } + + $beforeData = $doc->toArray(); + + return DB::transaction(function () use ($doc, $data, $userId, $beforeData, $tenantId) { + // order_ids, locations는 별도 처리 후 $data에서 제거 + $orderIds = $data['order_ids'] ?? null; + $locations = $data['locations'] ?? null; + unset($data['order_ids'], $data['locations']); + + $data['updated_by'] = $userId; + + // options는 기존 값과 병합 + if (isset($data['options'])) { + $existingOptions = $doc->options ?? []; + $data['options'] = array_replace_recursive($existingOptions, $data['options']); + } + + $doc->update($data); + + // 수주 동기화 (order_ids가 전달된 경우만) + if ($orderIds !== null) { + $this->syncOrders($doc, $orderIds, $tenantId); + } + + // 개소별 데이터 업데이트 (시공규격, 변경사유, 검사데이터) + if (! empty($locations)) { + $this->updateLocations($doc->id, $locations); + } + + $this->auditLogger->log( + $doc->tenant_id, + self::AUDIT_TARGET, + $doc->id, + 'updated', + $beforeData, + $doc->fresh()->toArray() + ); + + $doc->load(['client', 'inspector:id,name', 'creator:id,name', 'documentOrders.order', 'locations.orderItem.node']); + + // 요청서 Document(EAV) 동기화 + $this->syncRequestDocument($doc); + + return $this->transformToFrontend($doc); + }); + } + + /** + * 삭제 + */ + public function destroy(int $id) + { + $tenantId = $this->tenantId(); + + $doc = QualityDocument::where('tenant_id', $tenantId)->find($id); + if (! $doc) { + throw new NotFoundHttpException(__('error.not_found')); + } + + if ($doc->isCompleted()) { + throw new BadRequestHttpException(__('error.quality.cannot_delete_completed')); + } + + $beforeData = $doc->toArray(); + $doc->deleted_by = $this->apiUserId(); + $doc->save(); + $doc->delete(); + + $this->auditLogger->log( + $tenantId, + self::AUDIT_TARGET, + $doc->id, + 'deleted', + $beforeData, + null + ); + + return 'success'; + } + + /** + * 검사 완료 처리 + */ + public function complete(int $id) + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $doc = QualityDocument::where('tenant_id', $tenantId) + ->with(['locations']) + ->find($id); + + if (! $doc) { + throw new NotFoundHttpException(__('error.not_found')); + } + + if ($doc->isCompleted()) { + throw new BadRequestHttpException(__('error.quality.already_completed')); + } + + // 미완료 개소 확인 + $pendingCount = $doc->locations->where('inspection_status', QualityDocumentLocation::STATUS_PENDING)->count(); + if ($pendingCount > 0) { + throw new BadRequestHttpException(__('error.quality.pending_locations', ['count' => $pendingCount])); + } + + $beforeData = $doc->toArray(); + + return DB::transaction(function () use ($doc, $userId, $tenantId, $beforeData) { + $doc->update([ + 'status' => QualityDocument::STATUS_COMPLETED, + 'updated_by' => $userId, + ]); + + // 실적신고 자동 생성 + $now = now(); + PerformanceReport::firstOrCreate( + [ + 'tenant_id' => $tenantId, + 'quality_document_id' => $doc->id, + ], + [ + 'year' => $now->year, + 'quarter' => (int) ceil($now->month / 3), + 'confirmation_status' => PerformanceReport::STATUS_UNCONFIRMED, + 'created_by' => $userId, + ] + ); + + $this->auditLogger->log( + $tenantId, + self::AUDIT_TARGET, + $doc->id, + 'completed', + $beforeData, + $doc->fresh()->toArray() + ); + + return $this->transformToFrontend($doc->load(['client', 'inspector:id,name', 'creator:id,name'])); + }); + } + + /** + * 검사 미등록 수주 목록 + */ + public function availableOrders(array $params): array + { + $tenantId = $this->tenantId(); + $q = trim((string) ($params['q'] ?? '')); + $clientId = $params['client_id'] ?? null; + $itemId = $params['item_id'] ?? null; + + // 이미 연결된 수주 ID 목록 + $linkedOrderIds = QualityDocumentOrder::whereHas('qualityDocument', function ($query) use ($tenantId) { + $query->where('tenant_id', $tenantId); + })->pluck('order_id'); + + $query = Order::where('tenant_id', $tenantId) + ->whereNotIn('id', $linkedOrderIds) + ->with(['item:id,name', 'nodes' => function ($q) { + $q->whereNull('parent_id')->orderBy('sort_order') + ->with(['items' => function ($q2) { + $q2->orderBy('sort_order')->limit(1); + }]); + }]) + ->withCount(['nodes as location_count' => function ($q) { + $q->whereNull('parent_id'); + }]); + + if ($q !== '') { + $query->where(function ($qq) use ($q) { + $qq->where('order_no', 'like', "%{$q}%") + ->orWhere('site_name', 'like', "%{$q}%"); + }); + } + + // 같은 거래처(발주처) 필터 + if ($clientId) { + $query->where('client_id', $clientId); + } + + // 같은 모델(품목) 필터 + if ($itemId) { + $query->where('item_id', $itemId); + } + + return $query->orderByDesc('id') + ->limit(50) + ->get() + ->map(fn ($order) => [ + 'id' => $order->id, + 'order_number' => $order->order_no, + 'site_name' => $order->site_name ?? '', + 'client_id' => $order->client_id, + 'client_name' => $order->client_name ?? '', + 'item_id' => $order->item_id, + 'item_name' => $order->item?->name ?? '', + 'delivery_date' => $order->delivery_date?->format('Y-m-d') ?? '', + 'location_count' => $order->location_count, + 'locations' => $order->nodes->where('parent_id', null)->map(function ($node) { + $item = $node->items->first(); + $options = $node->options ?? []; + + return [ + 'node_id' => $node->id, + 'floor' => $item?->floor_code ?? $node->code ?? '', + 'symbol' => $item?->symbol_code ?? '', + 'order_width' => $options['width'] ?? 0, + 'order_height' => $options['height'] ?? 0, + ]; + })->values()->toArray(), + ]) + ->toArray(); + } + + /** + * 개소별 데이터 업데이트 + */ + private function updateLocations(int $docId, array $locations): void + { + foreach ($locations as $locData) { + $locId = $locData['id'] ?? null; + if (! $locId) { + continue; + } + + $location = QualityDocumentLocation::where('quality_document_id', $docId)->find($locId); + if (! $location) { + continue; + } + + $updateData = []; + if (array_key_exists('post_width', $locData)) { + $updateData['post_width'] = $locData['post_width']; + } + if (array_key_exists('post_height', $locData)) { + $updateData['post_height'] = $locData['post_height']; + } + if (array_key_exists('change_reason', $locData)) { + $updateData['change_reason'] = $locData['change_reason']; + } + if (array_key_exists('inspection_data', $locData)) { + $updateData['inspection_data'] = $locData['inspection_data']; + } + + if (! empty($updateData)) { + $location->update($updateData); + } + } + } + + /** + * 수주 동기화 (update 시 사용) + */ + private function syncOrders(QualityDocument $doc, array $orderIds, int $tenantId): void + { + $existingOrderIds = QualityDocumentOrder::where('quality_document_id', $doc->id) + ->pluck('order_id') + ->toArray(); + + $toAttach = array_diff($orderIds, $existingOrderIds); + $toDetach = array_diff($existingOrderIds, $orderIds); + + // 새로 연결 + foreach ($toAttach as $orderId) { + $order = Order::where('tenant_id', $tenantId)->find($orderId); + if (! $order) { + continue; + } + + $docOrder = QualityDocumentOrder::firstOrCreate([ + 'quality_document_id' => $doc->id, + 'order_id' => $orderId, + ]); + + // 개소(root OrderNode) 기준으로 location 생성 + $rootNodes = OrderNode::where('order_id', $orderId) + ->whereNull('parent_id') + ->orderBy('sort_order') + ->get(); + + foreach ($rootNodes as $node) { + // 각 개소의 대표 OrderItem (해당 노드 하위 첫 번째 품목) + $representativeItem = OrderItem::where('order_node_id', $node->id) + ->orderBy('sort_order') + ->first(); + + if ($representativeItem) { + QualityDocumentLocation::firstOrCreate([ + 'quality_document_id' => $doc->id, + 'quality_document_order_id' => $docOrder->id, + 'order_item_id' => $representativeItem->id, + ]); + } + } + } + + // 연결 해제 + foreach ($toDetach as $orderId) { + $docOrder = QualityDocumentOrder::where('quality_document_id', $doc->id) + ->where('order_id', $orderId) + ->first(); + + if ($docOrder) { + QualityDocumentLocation::where('quality_document_order_id', $docOrder->id)->delete(); + $docOrder->delete(); + } + } + } + + /** + * 수주 연결 + */ + public function attachOrders(int $docId, array $orderIds) + { + $tenantId = $this->tenantId(); + + $doc = QualityDocument::where('tenant_id', $tenantId)->find($docId); + if (! $doc) { + throw new NotFoundHttpException(__('error.not_found')); + } + + return DB::transaction(function () use ($doc, $orderIds, $tenantId) { + foreach ($orderIds as $orderId) { + $order = Order::where('tenant_id', $tenantId)->find($orderId); + if (! $order) { + continue; + } + + // 중복 체크 + $docOrder = QualityDocumentOrder::firstOrCreate([ + 'quality_document_id' => $doc->id, + 'order_id' => $orderId, + ]); + + // 수주 연결 시 개소(root OrderNode)를 locations에 자동 생성 + $rootNodes = OrderNode::where('order_id', $orderId) + ->whereNull('parent_id') + ->orderBy('sort_order') + ->get(); + + foreach ($rootNodes as $node) { + $representativeItem = OrderItem::where('order_node_id', $node->id) + ->orderBy('sort_order') + ->first(); + + if ($representativeItem) { + QualityDocumentLocation::firstOrCreate([ + 'quality_document_id' => $doc->id, + 'quality_document_order_id' => $docOrder->id, + 'order_item_id' => $representativeItem->id, + ]); + } + } + } + + // 상태를 진행중으로 변경 (접수 상태일 때) + if ($doc->isReceived()) { + $doc->update(['status' => QualityDocument::STATUS_IN_PROGRESS]); + } + + $doc->load(['client', 'inspector:id,name', 'creator:id,name', 'documentOrders.order', 'locations.orderItem.node']); + + // 요청서 Document(EAV) 동기화 (개소 추가됨) + $this->syncRequestDocument($doc); + + return $this->transformToFrontend($doc); + }); + } + + /** + * 수주 연결 해제 + */ + public function detachOrder(int $docId, int $orderId) + { + $tenantId = $this->tenantId(); + + $doc = QualityDocument::where('tenant_id', $tenantId)->find($docId); + if (! $doc) { + throw new NotFoundHttpException(__('error.not_found')); + } + + if ($doc->isCompleted()) { + throw new BadRequestHttpException(__('error.quality.cannot_modify_completed')); + } + + $docOrder = QualityDocumentOrder::where('quality_document_id', $docId) + ->where('order_id', $orderId) + ->first(); + + if ($docOrder) { + // 해당 수주의 locations 삭제 + QualityDocumentLocation::where('quality_document_order_id', $docOrder->id)->delete(); + $docOrder->delete(); + } + + return $this->transformToFrontend( + $doc->load(['client', 'inspector:id,name', 'creator:id,name', 'documentOrders.order', 'locations.orderItem.node']) + ); + } + + /** + * 필수정보 계산 + */ + public function calculateRequiredInfo(QualityDocument $doc): string + { + $options = $doc->options ?? []; + $missing = 0; + + $sections = [ + 'construction_site' => ['name', 'land_location', 'lot_number'], + 'material_distributor' => ['company', 'address', 'ceo', 'phone'], + 'contractor' => ['company', 'address', 'name', 'phone'], + 'supervisor' => ['office', 'address', 'name', 'phone'], + ]; + + foreach ($sections as $section => $fields) { + $data = $options[$section] ?? []; + foreach ($fields as $field) { + if (empty($data[$field])) { + $missing++; + break; // 섹션 단위 + } + } + } + + return $missing === 0 ? '완료' : "{$missing}건 누락"; + } + + /** + * DB → 프론트엔드 변환 + */ + private function transformToFrontend(QualityDocument $doc, bool $detail = false): array + { + $options = $doc->options ?? []; + + $result = [ + 'id' => $doc->id, + 'quality_doc_number' => $doc->quality_doc_number, + 'site_name' => $doc->site_name, + 'client' => $doc->client?->name ?? '', + 'location_count' => $doc->locations?->count() ?? 0, + 'required_info' => $this->calculateRequiredInfo($doc), + 'inspection_period' => $this->formatInspectionPeriod($options), + 'inspector' => $doc->inspector?->name ?? '', + 'status' => QualityDocument::mapStatusToFrontend($doc->status), + 'author' => $doc->creator?->name ?? '', + 'reception_date' => $doc->received_date?->format('Y-m-d'), + 'manager' => $options['manager']['name'] ?? '', + 'manager_contact' => $options['manager']['phone'] ?? '', + ]; + + // 요청서 Document ID (EAV) + $requestDoc = Document::where('tenant_id', $doc->tenant_id) + ->where('template_id', self::REQUEST_TEMPLATE_ID) + ->where('linkable_type', QualityDocument::class) + ->where('linkable_id', $doc->id) + ->first(); + $result['request_document_id'] = $requestDoc?->id; + + if ($detail) { + $result['construction_site'] = [ + 'site_name' => $options['construction_site']['name'] ?? '', + 'land_location' => $options['construction_site']['land_location'] ?? '', + 'lot_number' => $options['construction_site']['lot_number'] ?? '', + ]; + $result['material_distributor'] = [ + 'company_name' => $options['material_distributor']['company'] ?? '', + 'company_address' => $options['material_distributor']['address'] ?? '', + 'representative_name' => $options['material_distributor']['ceo'] ?? '', + 'phone' => $options['material_distributor']['phone'] ?? '', + ]; + $result['constructor_info'] = [ + 'company_name' => $options['contractor']['company'] ?? '', + 'company_address' => $options['contractor']['address'] ?? '', + 'name' => $options['contractor']['name'] ?? '', + 'phone' => $options['contractor']['phone'] ?? '', + ]; + $result['supervisor'] = [ + 'office_name' => $options['supervisor']['office'] ?? '', + 'office_address' => $options['supervisor']['address'] ?? '', + 'name' => $options['supervisor']['name'] ?? '', + 'phone' => $options['supervisor']['phone'] ?? '', + ]; + $result['schedule_info'] = [ + 'visit_request_date' => $options['inspection']['request_date'] ?? '', + 'start_date' => $options['inspection']['start_date'] ?? '', + 'end_date' => $options['inspection']['end_date'] ?? '', + 'inspector' => $doc->inspector?->name ?? '', + 'site_postal_code' => $options['site_address']['postal_code'] ?? '', + 'site_address' => $options['site_address']['address'] ?? '', + 'site_address_detail' => $options['site_address']['detail'] ?? '', + ]; + + // 개소 목록 (각 location은 1개 root OrderNode = 1개 개소) + $result['order_items'] = $doc->locations->map(function ($loc) { + $orderItem = $loc->orderItem; + $node = $orderItem?->node; + $nodeOptions = $node?->options ?? []; + $order = $loc->qualityDocumentOrder?->order; + + return [ + 'id' => (string) $loc->id, + 'order_id' => $order?->id, + 'order_number' => $order?->order_no ?? '', + 'site_name' => $order?->site_name ?? '', + 'client_id' => $order?->client_id, + 'client_name' => $order?->client_name ?? '', + 'item_id' => $order?->item_id, + 'item_name' => $order?->item?->name ?? '', + 'delivery_date' => $order?->delivery_date ? $order->delivery_date->format('Y-m-d') : '', + 'floor' => $orderItem?->floor_code ?? '', + 'symbol' => $orderItem?->symbol_code ?? '', + 'order_width' => $nodeOptions['width'] ?? 0, + 'order_height' => $nodeOptions['height'] ?? 0, + 'construction_width' => $loc->post_width ?? 0, + 'construction_height' => $loc->post_height ?? 0, + 'change_reason' => $loc->change_reason ?? '', + 'inspection_data' => $loc->inspection_data, + 'document_id' => $loc->document_id, + ]; + })->toArray(); + } + + return $result; + } + + /** + * 개소별 검사 저장 (시공후 규격 + 검사 성적서) + */ + public function inspectLocation(int $docId, int $locId, array $data) + { + $tenantId = $this->tenantId(); + + $doc = QualityDocument::where('tenant_id', $tenantId)->find($docId); + if (! $doc) { + throw new NotFoundHttpException(__('error.not_found')); + } + + $location = QualityDocumentLocation::where('quality_document_id', $docId)->find($locId); + if (! $location) { + throw new NotFoundHttpException(__('error.not_found')); + } + + return DB::transaction(function () use ($location, $data, $doc) { + $updateData = []; + + if (isset($data['post_width'])) { + $updateData['post_width'] = $data['post_width']; + } + if (isset($data['post_height'])) { + $updateData['post_height'] = $data['post_height']; + } + if (isset($data['change_reason'])) { + $updateData['change_reason'] = $data['change_reason']; + } + if (isset($data['inspection_status'])) { + $updateData['inspection_status'] = $data['inspection_status']; + } + if (array_key_exists('inspection_data', $data)) { + $updateData['inspection_data'] = $data['inspection_data']; + } + + if (! empty($updateData)) { + $location->update($updateData); + } + + // 상태를 진행중으로 변경 (접수 상태일 때) + if ($doc->isReceived()) { + $doc->update(['status' => QualityDocument::STATUS_IN_PROGRESS]); + } + + return $location->fresh()->toArray(); + }); + } + + /** + * 검사제품요청서 데이터 (PDF/프린트용) + */ + public function requestDocument(int $id): array + { + $tenantId = $this->tenantId(); + + $doc = QualityDocument::where('tenant_id', $tenantId) + ->with([ + 'client', + 'inspector:id,name', + 'documentOrders.order', + 'locations.orderItem.node', + ]) + ->find($id); + + if (! $doc) { + throw new NotFoundHttpException(__('error.not_found')); + } + + $options = $doc->options ?? []; + + return [ + 'quality_doc_number' => $doc->quality_doc_number, + 'site_name' => $doc->site_name, + 'client' => $doc->client?->name ?? '', + 'received_date' => $doc->received_date?->format('Y-m-d'), + 'inspector' => $doc->inspector?->name ?? '', + 'construction_site' => $options['construction_site'] ?? [], + 'material_distributor' => $options['material_distributor'] ?? [], + 'contractor' => $options['contractor'] ?? [], + 'supervisor' => $options['supervisor'] ?? [], + 'inspection' => $options['inspection'] ?? [], + 'site_address' => $options['site_address'] ?? [], + 'manager' => $options['manager'] ?? [], + 'items' => $doc->locations->map(function ($loc) { + $orderItem = $loc->orderItem; + $node = $orderItem?->node; + $nodeOptions = $node?->options ?? []; + $order = $loc->qualityDocumentOrder?->order; + + return [ + 'order_number' => $order?->order_no ?? '', + 'floor' => $orderItem?->floor_code ?? '', + 'symbol' => $orderItem?->symbol_code ?? '', + 'item_name' => $orderItem?->item_name ?? '', + 'specification' => $orderItem?->specification ?? '', + 'order_width' => $nodeOptions['width'] ?? 0, + 'order_height' => $nodeOptions['height'] ?? 0, + 'quantity' => $orderItem?->quantity ?? 1, + ]; + })->toArray(), + ]; + } + + /** + * 제품검사성적서 데이터 (documents EAV 연동) + */ + public function resultDocument(int $id): array + { + $tenantId = $this->tenantId(); + + $doc = QualityDocument::where('tenant_id', $tenantId) + ->with([ + 'client', + 'inspector:id,name', + 'locations.orderItem.node', + 'locations.document.data', + 'locations.document.template', + ]) + ->find($id); + + if (! $doc) { + throw new NotFoundHttpException(__('error.not_found')); + } + + $options = $doc->options ?? []; + + return [ + 'quality_doc_number' => $doc->quality_doc_number, + 'site_name' => $doc->site_name, + 'client' => $doc->client?->name ?? '', + 'inspector' => $doc->inspector?->name ?? '', + 'status' => QualityDocument::mapStatusToFrontend($doc->status), + 'locations' => $doc->locations->map(function ($loc) { + $orderItem = $loc->orderItem; + $node = $orderItem?->node; + $nodeOptions = $node?->options ?? []; + $document = $loc->document; + + $result = [ + 'id' => $loc->id, + 'floor' => $orderItem?->floor_code ?? '', + 'symbol' => $orderItem?->symbol_code ?? '', + 'order_width' => $nodeOptions['width'] ?? 0, + 'order_height' => $nodeOptions['height'] ?? 0, + 'post_width' => $loc->post_width, + 'post_height' => $loc->post_height, + 'change_reason' => $loc->change_reason, + 'inspection_status' => $loc->inspection_status, + 'document_id' => $loc->document_id, + ]; + + // EAV 문서 데이터가 있으면 포함 + if ($document) { + $result['document'] = [ + 'id' => $document->id, + 'document_no' => $document->document_no, + 'status' => $document->status, + 'template_id' => $document->template_id, + 'data' => $document->data?->map(fn ($d) => [ + 'field_key' => $d->field_key, + 'field_value' => $d->field_value, + 'section_id' => $d->section_id, + 'column_id' => $d->column_id, + ])->toArray() ?? [], + ]; + } + + return $result; + })->toArray(), + ]; + } + + // ========================================================================= + // 제품검사 요청서 Document 자동생성/동기화 + // ========================================================================= + + private const REQUEST_TEMPLATE_ID = 66; + + /** + * 요청서 Document(EAV) 동기화 + * + * quality_document 생성/수정 시 호출. + * - Document 없으면 생성 (template_id=66, linkable=QualityDocument) + * - 기본필드 + 섹션 데이터 + 사전고지 테이블을 EAV로 매핑 + */ + private function syncRequestDocument(QualityDocument $doc): void + { + $tenantId = $doc->tenant_id; + + // 템플릿 존재 확인 + $template = DocumentTemplate::where('tenant_id', $tenantId) + ->where('id', self::REQUEST_TEMPLATE_ID) + ->with(['basicFields', 'sections.items', 'columns']) + ->first(); + + if (! $template) { + return; // 템플릿 미등록 시 스킵 + } + + // 기존 Document 조회 또는 생성 + $document = Document::where('tenant_id', $tenantId) + ->where('template_id', self::REQUEST_TEMPLATE_ID) + ->where('linkable_type', QualityDocument::class) + ->where('linkable_id', $doc->id) + ->first(); + + if (! $document) { + $documentNo = $this->generateRequestDocumentNo($tenantId); + $document = Document::create([ + 'tenant_id' => $tenantId, + 'template_id' => self::REQUEST_TEMPLATE_ID, + 'document_no' => $documentNo, + 'title' => '제품검사 요청서 - '.($doc->site_name ?? $doc->quality_doc_number), + 'status' => Document::STATUS_DRAFT, + 'linkable_type' => QualityDocument::class, + 'linkable_id' => $doc->id, + 'created_by' => $doc->created_by, + 'updated_by' => $doc->updated_by ?? $doc->created_by, + ]); + } else { + // rendered_html 초기화 (데이터 변경 시 재캡처 필요) + $document->update([ + 'rendered_html' => null, + 'updated_by' => $doc->updated_by ?? $doc->created_by, + ]); + } + + // 기존 EAV 데이터 삭제 후 재생성 + DocumentData::where('document_id', $document->id)->delete(); + + $options = $doc->options ?? []; + $eavData = []; + + // 1. 기본필드 매핑 (quality_document → basicFields) + $fieldMapping = $this->buildBasicFieldMapping($doc, $options); + foreach ($template->basicFields as $bf) { + $value = $fieldMapping[$bf->field_key] ?? ''; + if ($value !== '') { + $eavData[] = [ + 'document_id' => $document->id, + 'section_id' => null, + 'column_id' => null, + 'row_index' => 0, + 'field_key' => $bf->field_key, + 'field_value' => (string) $value, + ]; + } + } + + // 2. 섹션 아이템 매핑 (options → section items) + $sectionMapping = $this->buildSectionMapping($options); + foreach ($template->sections as $section) { + if ($section->items->isEmpty()) { + continue; // 사전 고지 정보 섹션은 items가 없으므로 스킵 + } + $sectionData = $sectionMapping[$section->title] ?? []; + foreach ($section->items as $item) { + $value = $sectionData[$item->item] ?? ''; + if ($value !== '') { + $eavData[] = [ + 'document_id' => $document->id, + 'section_id' => $section->id, + 'column_id' => null, + 'row_index' => 0, + 'field_key' => $item->item, // item name as key + 'field_value' => (string) $value, + ]; + } + } + } + + // 3. 사전고지 테이블 매핑 (locations → columns) + $doc->loadMissing(['locations.orderItem.node', 'locations.qualityDocumentOrder.order']); + $columns = $template->columns->sortBy('sort_order'); + + foreach ($doc->locations as $rowIdx => $loc) { + $orderItem = $loc->orderItem; + $node = $orderItem?->node; + $nodeOptions = $node?->options ?? []; + $order = $loc->qualityDocumentOrder?->order; + + $rowData = [ + 'No.' => (string) ($rowIdx + 1), + '층수' => $orderItem?->floor_code ?? '', + '부호' => $orderItem?->symbol_code ?? '', + '발주 가로' => (string) ($nodeOptions['width'] ?? ''), + '발주 세로' => (string) ($nodeOptions['height'] ?? ''), + '시공 가로' => (string) ($loc->post_width ?? ''), + '시공 세로' => (string) ($loc->post_height ?? ''), + '변경사유' => $loc->change_reason ?? '', + ]; + + foreach ($columns as $col) { + $value = $rowData[$col->label] ?? ''; + $eavData[] = [ + 'document_id' => $document->id, + 'section_id' => null, + 'column_id' => $col->id, + 'row_index' => $rowIdx, + 'field_key' => $col->label, + 'field_value' => $value, + ]; + } + } + + // EAV 일괄 삽입 + if (! empty($eavData)) { + DocumentData::insert(array_map(function ($d) { + $d['created_at'] = now(); + $d['updated_at'] = now(); + + return $d; + }, $eavData)); + } + } + + /** + * 기본필드 매핑 (quality_document → template basicFields) + */ + private function buildBasicFieldMapping(QualityDocument $doc, array $options): array + { + $manager = $options['manager'] ?? []; + $inspection = $options['inspection'] ?? []; + $siteAddress = $options['site_address'] ?? []; + $order = $doc->documentOrders?->first()?->order; + + return [ + 'client' => $doc->client?->name ?? '', + 'company_name' => $manager['company'] ?? '', + 'manager' => $manager['name'] ?? '', + 'order_number' => $order?->order_no ?? '', + 'manager_contact' => $manager['phone'] ?? '', + 'site_name' => $doc->site_name ?? '', + 'delivery_date' => $order?->delivery_date?->format('Y-m-d') ?? '', + 'site_address' => trim(($siteAddress['address'] ?? '').' '.($siteAddress['detail'] ?? '')), + 'total_locations' => (string) ($doc->locations?->count() ?? 0), + 'receipt_date' => $doc->received_date?->format('Y-m-d') ?? '', + 'inspection_request_date' => $inspection['request_date'] ?? '', + ]; + } + + /** + * 섹션 데이터 매핑 (options → section items by section title) + */ + private function buildSectionMapping(array $options): array + { + $cs = $options['construction_site'] ?? []; + $md = $options['material_distributor'] ?? []; + $ct = $options['contractor'] ?? []; + $sv = $options['supervisor'] ?? []; + + return [ + '건축공사장 정보' => [ + '현장명' => $cs['name'] ?? '', + '대지위치' => $cs['land_location'] ?? '', + '지번' => $cs['lot_number'] ?? '', + ], + '자재유통업자 정보' => [ + '회사명' => $md['company'] ?? '', + '주소' => $md['address'] ?? '', + '대표자' => $md['ceo'] ?? '', + '전화번호' => $md['phone'] ?? '', + ], + '공사시공자 정보' => [ + '회사명' => $ct['company'] ?? '', + '주소' => $ct['address'] ?? '', + '성명' => $ct['name'] ?? '', + '전화번호' => $ct['phone'] ?? '', + ], + '공사감리자 정보' => [ + '사무소명' => $sv['office'] ?? '', + '주소' => $sv['address'] ?? '', + '성명' => $sv['name'] ?? '', + '전화번호' => $sv['phone'] ?? '', + ], + ]; + } + + /** + * 요청서 문서번호 생성 + */ + private function generateRequestDocumentNo(int $tenantId): string + { + $prefix = 'REQ'; + $date = now()->format('Ymd'); + + $lastNumber = Document::where('tenant_id', $tenantId) + ->where('document_no', 'like', "{$prefix}-{$date}-%") + ->orderByDesc('document_no') + ->value('document_no'); + + $sequence = $lastNumber ? (int) substr($lastNumber, -4) + 1 : 1; + + return sprintf('%s-%s-%04d', $prefix, $date, $sequence); + } + + private function formatInspectionPeriod(array $options): string + { + $inspection = $options['inspection'] ?? []; + $start = $inspection['start_date'] ?? ''; + $end = $inspection['end_date'] ?? ''; + + if ($start && $end) { + return "{$start}~{$end}"; + } + + return $start ?: $end ?: ''; + } +} diff --git a/app/Services/Quote/QuoteService.php b/app/Services/Quote/QuoteService.php index 34e05e0..b42cc01 100644 --- a/app/Services/Quote/QuoteService.php +++ b/app/Services/Quote/QuoteService.php @@ -321,7 +321,7 @@ public function store(array $data): Quote // 제품 정보 'product_category' => $data['product_category'] ?? Quote::CATEGORY_SCREEN, 'product_id' => $data['product_id'] ?? null, - 'product_code' => $data['product_code'] ?? null, + 'product_code' => $data['product_code'] ?? $this->extractProductCodeFromInputs($data), 'product_name' => $data['product_name'] ?? null, // 규격 정보 'open_size_width' => $data['open_size_width'] ?? null, @@ -418,7 +418,7 @@ public function update(int $id, array $data): Quote // 제품 정보 'product_category' => $data['product_category'] ?? $quote->product_category, 'product_id' => $data['product_id'] ?? $quote->product_id, - 'product_code' => $data['product_code'] ?? $quote->product_code, + 'product_code' => $data['product_code'] ?? $this->extractProductCodeFromInputs($data) ?? $quote->product_code, 'product_name' => $data['product_name'] ?? $quote->product_name, // 규격 정보 'open_size_width' => $data['open_size_width'] ?? $quote->open_size_width, @@ -799,6 +799,22 @@ private function resolveLocationIndex(QuoteItem $quoteItem, array $productItems) return 0; } + /** + * calculation_inputs에서 첫 번째 개소의 productCode 추출 + * 다중 개소 시 첫 번째를 대표값으로 사용 + */ + private function extractProductCodeFromInputs(array $data): ?string + { + $inputs = $data['calculation_inputs'] ?? null; + if (! $inputs || ! is_array($inputs)) { + return null; + } + + $items = $inputs['items'] ?? []; + + return $items[0]['productCode'] ?? null; + } + /** * 수주번호 생성 * 형식: ORD-YYMMDD-NNN (예: ORD-260105-001) diff --git a/database/migrations/2026_02_27_000001_add_work_order_id_to_inspections_table.php b/database/migrations/2026_02_27_000001_add_work_order_id_to_inspections_table.php new file mode 100644 index 0000000..f97f351 --- /dev/null +++ b/database/migrations/2026_02_27_000001_add_work_order_id_to_inspections_table.php @@ -0,0 +1,34 @@ +unsignedBigInteger('work_order_id') + ->nullable() + ->after('lot_no') + ->comment('작업지시 ID (PQC/FQC용)'); + + $table->foreign('work_order_id') + ->references('id') + ->on('work_orders') + ->nullOnDelete(); + + $table->index('work_order_id'); + }); + } + + public function down(): void + { + Schema::table('inspections', function (Blueprint $table) { + $table->dropForeign(['work_order_id']); + $table->dropIndex(['work_order_id']); + $table->dropColumn('work_order_id'); + }); + } +}; diff --git a/database/migrations/2026_03_05_180001_create_quality_documents_table.php b/database/migrations/2026_03_05_180001_create_quality_documents_table.php new file mode 100644 index 0000000..3f5fa13 --- /dev/null +++ b/database/migrations/2026_03_05_180001_create_quality_documents_table.php @@ -0,0 +1,39 @@ +id(); + $table->foreignId('tenant_id')->constrained(); + $table->string('quality_doc_number', 30)->comment('품질관리서 번호'); + $table->string('site_name')->comment('현장명'); + $table->string('status', 20)->default('received')->comment('received/in_progress/completed'); + $table->foreignId('client_id')->nullable()->constrained('clients')->comment('수주처'); + $table->foreignId('inspector_id')->nullable()->constrained('users')->comment('검사자'); + $table->date('received_date')->nullable()->comment('접수일'); + $table->json('options')->nullable()->comment('관련자정보, 검사정보, 현장주소 등'); + $table->unsignedBigInteger('created_by')->nullable(); + $table->unsignedBigInteger('updated_by')->nullable(); + $table->unsignedBigInteger('deleted_by')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->unique(['tenant_id', 'quality_doc_number']); + $table->index(['tenant_id', 'status']); + $table->index(['tenant_id', 'client_id']); + $table->index(['tenant_id', 'inspector_id']); + $table->index(['tenant_id', 'received_date']); + }); + } + + public function down(): void + { + Schema::dropIfExists('quality_documents'); + } +}; \ No newline at end of file diff --git a/database/migrations/2026_03_05_180002_create_quality_document_orders_table.php b/database/migrations/2026_03_05_180002_create_quality_document_orders_table.php new file mode 100644 index 0000000..2b9e6b2 --- /dev/null +++ b/database/migrations/2026_03_05_180002_create_quality_document_orders_table.php @@ -0,0 +1,25 @@ +id(); + $table->foreignId('quality_document_id')->constrained()->cascadeOnDelete(); + $table->foreignId('order_id')->constrained('orders'); + $table->timestamps(); + + $table->unique(['quality_document_id', 'order_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('quality_document_orders'); + } +}; \ No newline at end of file diff --git a/database/migrations/2026_03_05_180003_create_quality_document_locations_table.php b/database/migrations/2026_03_05_180003_create_quality_document_locations_table.php new file mode 100644 index 0000000..a3b8ba0 --- /dev/null +++ b/database/migrations/2026_03_05_180003_create_quality_document_locations_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('quality_document_id')->constrained()->cascadeOnDelete(); + $table->foreignId('quality_document_order_id')->constrained('quality_document_orders', 'id', 'qdl_qdo_id_fk')->cascadeOnDelete(); + $table->foreignId('order_item_id')->constrained('order_items'); + $table->integer('post_width')->nullable()->comment('시공후 가로'); + $table->integer('post_height')->nullable()->comment('시공후 세로'); + $table->string('change_reason')->nullable()->comment('규격 변경사유'); + $table->foreignId('document_id')->nullable()->comment('검사성적서 문서 ID'); + $table->string('inspection_status', 20)->default('pending')->comment('pending/completed'); + $table->timestamps(); + + $table->index(['quality_document_id', 'inspection_status'], 'qdl_doc_id_status_index'); + }); + } + + public function down(): void + { + Schema::dropIfExists('quality_document_locations'); + } +}; \ No newline at end of file diff --git a/database/migrations/2026_03_05_180004_create_performance_reports_table.php b/database/migrations/2026_03_05_180004_create_performance_reports_table.php new file mode 100644 index 0000000..860966d --- /dev/null +++ b/database/migrations/2026_03_05_180004_create_performance_reports_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('tenant_id')->constrained(); + $table->foreignId('quality_document_id')->constrained(); + $table->unsignedSmallInteger('year')->comment('연도'); + $table->unsignedTinyInteger('quarter')->comment('분기 1-4'); + $table->string('confirmation_status', 20)->default('unconfirmed')->comment('unconfirmed/confirmed/reported'); + $table->date('confirmed_date')->nullable(); + $table->foreignId('confirmed_by')->nullable()->constrained('users'); + $table->text('memo')->nullable(); + $table->unsignedBigInteger('created_by')->nullable(); + $table->unsignedBigInteger('updated_by')->nullable(); + $table->unsignedBigInteger('deleted_by')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->unique(['tenant_id', 'quality_document_id']); + $table->index(['tenant_id', 'year', 'quarter']); + $table->index(['tenant_id', 'confirmation_status']); + }); + } + + public function down(): void + { + Schema::dropIfExists('performance_reports'); + } +}; \ No newline at end of file diff --git a/database/migrations/2026_03_06_094425_add_inspection_data_to_quality_document_locations.php b/database/migrations/2026_03_06_094425_add_inspection_data_to_quality_document_locations.php new file mode 100644 index 0000000..d3e754a --- /dev/null +++ b/database/migrations/2026_03_06_094425_add_inspection_data_to_quality_document_locations.php @@ -0,0 +1,22 @@ +json('inspection_data')->nullable()->after('change_reason')->comment('검사 데이터 JSON'); + }); + } + + public function down(): void + { + Schema::table('quality_document_locations', function (Blueprint $table) { + $table->dropColumn('inspection_data'); + }); + } +}; diff --git a/routes/api/v1/quality.php b/routes/api/v1/quality.php new file mode 100644 index 0000000..edebe9d --- /dev/null +++ b/routes/api/v1/quality.php @@ -0,0 +1,40 @@ +group(function () { + Route::get('', [QualityDocumentController::class, 'index'])->name('v1.quality.documents.index'); + Route::get('/stats', [QualityDocumentController::class, 'stats'])->name('v1.quality.documents.stats'); + Route::get('/calendar', [QualityDocumentController::class, 'calendar'])->name('v1.quality.documents.calendar'); + Route::get('/available-orders', [QualityDocumentController::class, 'availableOrders'])->name('v1.quality.documents.available-orders'); + Route::post('', [QualityDocumentController::class, 'store'])->name('v1.quality.documents.store'); + Route::get('/{id}', [QualityDocumentController::class, 'show'])->whereNumber('id')->name('v1.quality.documents.show'); + Route::put('/{id}', [QualityDocumentController::class, 'update'])->whereNumber('id')->name('v1.quality.documents.update'); + Route::delete('/{id}', [QualityDocumentController::class, 'destroy'])->whereNumber('id')->name('v1.quality.documents.destroy'); + Route::patch('/{id}/complete', [QualityDocumentController::class, 'complete'])->whereNumber('id')->name('v1.quality.documents.complete'); + Route::post('/{id}/orders', [QualityDocumentController::class, 'attachOrders'])->whereNumber('id')->name('v1.quality.documents.attach-orders'); + Route::delete('/{id}/orders/{orderId}', [QualityDocumentController::class, 'detachOrder'])->whereNumber('id')->whereNumber('orderId')->name('v1.quality.documents.detach-order'); + Route::post('/{id}/locations/{locId}/inspect', [QualityDocumentController::class, 'inspectLocation'])->whereNumber('id')->whereNumber('locId')->name('v1.quality.documents.inspect-location'); + Route::get('/{id}/request-document', [QualityDocumentController::class, 'requestDocument'])->whereNumber('id')->name('v1.quality.documents.request-document'); + Route::get('/{id}/result-document', [QualityDocumentController::class, 'resultDocument'])->whereNumber('id')->name('v1.quality.documents.result-document'); +}); + +// 실적신고 +Route::prefix('quality/performance-reports')->group(function () { + Route::get('', [PerformanceReportController::class, 'index'])->name('v1.quality.performance-reports.index'); + Route::get('/stats', [PerformanceReportController::class, 'stats'])->name('v1.quality.performance-reports.stats'); + Route::get('/missing', [PerformanceReportController::class, 'missing'])->name('v1.quality.performance-reports.missing'); + Route::patch('/confirm', [PerformanceReportController::class, 'confirm'])->name('v1.quality.performance-reports.confirm'); + Route::patch('/unconfirm', [PerformanceReportController::class, 'unconfirm'])->name('v1.quality.performance-reports.unconfirm'); + Route::patch('/memo', [PerformanceReportController::class, 'updateMemo'])->name('v1.quality.performance-reports.memo'); +}); diff --git a/routes/api/v1/stats.php b/routes/api/v1/stats.php index eef2469..f9f61dc 100644 --- a/routes/api/v1/stats.php +++ b/routes/api/v1/stats.php @@ -17,3 +17,17 @@ Route::get('/monthly', [StatController::class, 'monthly'])->name('v1.stats.monthly'); Route::get('/alerts', [StatController::class, 'alerts'])->name('v1.stats.alerts'); }); + +// 미개발 Summary API (플레이스홀더 - 오류 방지용) +$placeholderSummary = function () { + return response()->json([ + 'success' => true, + 'message' => '해당 기능은 현재 준비 중입니다.', + 'data' => null, + ]); +}; + +Route::get('production/summary', $placeholderSummary)->name('v1.production.summary'); +Route::get('construction/summary', $placeholderSummary)->name('v1.construction.summary'); +Route::get('unshipped/summary', $placeholderSummary)->name('v1.unshipped.summary'); +Route::get('attendance/summary', $placeholderSummary)->name('v1.attendance.summary');