From e3642395723ca725ebbe8ec35bd2934de24b6853 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Wed, 4 Feb 2026 23:07:08 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B2=AC=EC=A0=81=20=EC=B0=B8=EC=A1=B0?= =?UTF-8?q?=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20API,=20=EC=88=98=EC=A3=BC=20?= =?UTF-8?q?=EC=A0=84=ED=99=98=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0,?= =?UTF-8?q?=20=EA=B2=80=EC=82=AC=EA=B8=B0=EC=A4=80=EC=84=9C=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 견적 참조 데이터(현장명, 부호) 조회 API 추가 (GET /quotes/reference-data) - 수주 전환 시 floor_code/symbol_code를 quoteItem.note에서 파싱하도록 변경 - 수주 전환 시 note에 formula_category 저장 - 검사기준서 프리셋: standard + standard_criteria → text_with_criteria로 통합 - tolerance 컬럼 width 조정 (120px → 85px) - LOGICAL_RELATIONSHIPS.md 문서 갱신 Co-Authored-By: Claude Opus 4.5 --- LOGICAL_RELATIONSHIPS.md | 33 ++++++++++- .../Controllers/Api/V1/QuoteController.php | 10 ++++ app/Services/OrderService.php | 34 ++++------- app/Services/Quote/QuoteService.php | 58 +++++++++++++++++++ ..._02_03_120000_change_tolerance_to_json.php | 50 ++++++++++++++++ .../DocumentTemplateFieldPresetSeeder.php | 15 ++--- routes/api/v1/sales.php | 3 + 7 files changed, 169 insertions(+), 34 deletions(-) create mode 100644 database/migrations/2026_02_03_120000_change_tolerance_to_json.php diff --git a/LOGICAL_RELATIONSHIPS.md b/LOGICAL_RELATIONSHIPS.md index 2738d37..e288eb0 100644 --- a/LOGICAL_RELATIONSHIPS.md +++ b/LOGICAL_RELATIONSHIPS.md @@ -1,6 +1,6 @@ # 논리적 데이터베이스 관계 문서 -> **자동 생성**: 2026-02-03 17:09:34 +> **자동 생성**: 2026-02-03 17:39:58 > **소스**: Eloquent 모델 관계 분석 ## 📊 모델별 관계 현황 @@ -224,6 +224,12 @@ ### document_datas - **document()**: belongsTo → `documents` +### document_links +**모델**: `App\Models\Documents\DocumentLink` + +- **document()**: belongsTo → `documents` +- **linkDefinition()**: belongsTo → `document_template_links` + ### document_templates **모델**: `App\Models\Documents\DocumentTemplate` @@ -231,6 +237,8 @@ ### document_templates - **basicFields()**: hasMany → `document_template_basic_fields` - **sections()**: hasMany → `document_template_sections` - **columns()**: hasMany → `document_template_columns` +- **sectionFields()**: hasMany → `document_template_section_fields` +- **links()**: hasMany → `document_template_links` ### document_template_approval_lines **모델**: `App\Models\Documents\DocumentTemplateApprovalLine` @@ -247,12 +255,29 @@ ### document_template_columns - **template()**: belongsTo → `document_templates` +### document_template_links +**모델**: `App\Models\Documents\DocumentTemplateLink` + +- **template()**: belongsTo → `document_templates` +- **linkValues()**: hasMany → `document_template_link_values` + +### document_template_link_values +**모델**: `App\Models\Documents\DocumentTemplateLinkValue` + +- **template()**: belongsTo → `document_templates` +- **link()**: belongsTo → `document_template_links` + ### document_template_sections **모델**: `App\Models\Documents\DocumentTemplateSection` - **template()**: belongsTo → `document_templates` - **items()**: hasMany → `document_template_section_items` +### document_template_section_fields +**모델**: `App\Models\Documents\DocumentTemplateSectionField` + +- **template()**: belongsTo → `document_templates` + ### document_template_section_items **모델**: `App\Models\Documents\DocumentTemplateSectionItem` @@ -511,6 +536,7 @@ ### process - **classificationRules()**: hasMany → `process_classification_rules` - **processItems()**: hasMany → `process_items` +- **steps()**: hasMany → `process_steps` ### process_classification_rules **모델**: `App\Models\ProcessClassificationRule` @@ -523,6 +549,11 @@ ### process_items - **process()**: belongsTo → `processes` - **item()**: belongsTo → `items` +### process_steps +**모델**: `App\Models\ProcessStep` + +- **process()**: belongsTo → `processes` + ### work_orders **모델**: `App\Models\Production\WorkOrder` diff --git a/app/Http/Controllers/Api/V1/QuoteController.php b/app/Http/Controllers/Api/V1/QuoteController.php index 2221218..f2c6593 100644 --- a/app/Http/Controllers/Api/V1/QuoteController.php +++ b/app/Http/Controllers/Api/V1/QuoteController.php @@ -151,6 +151,16 @@ public function convertToBidding(int $id) }, __('message.bidding.converted')); } + /** + * 참조 데이터 조회 (현장명, 부호 목록) + */ + public function referenceData() + { + return ApiResponse::handle(function () { + return $this->quoteService->referenceData(); + }, __('message.fetched')); + } + /** * 견적번호 미리보기 */ diff --git a/app/Services/OrderService.php b/app/Services/OrderService.php index e42ef24..a9b54e0 100644 --- a/app/Services/OrderService.php +++ b/app/Services/OrderService.php @@ -470,39 +470,24 @@ public function createFromQuote(int $quoteId, array $data = []) $order->save(); - // calculation_inputs에서 제품-부품 매핑 정보 추출 - $calculationInputs = $quote->calculation_inputs ?? []; - $calcInputItems = $calculationInputs['items'] ?? []; - // 견적 품목을 수주 품목으로 변환 foreach ($quote->items as $index => $quoteItem) { - // calculation_inputs.items에서 해당 품목의 floor/code 정보 찾기 - // 1. item_index로 매칭 시도 - // 2. 없으면 배열 인덱스로 fallback + // floor_code/symbol_code 추출: + // 1순위: quoteItem->note에서 파싱 (형식: "4F DS-01" → floor=4F, symbol=DS-01) + // 2순위: NULL $floorCode = null; $symbolCode = null; - $itemIndex = $quoteItem->item_index ?? null; - if ($itemIndex !== null) { - // item_index로 매칭 - foreach ($calcInputItems as $calcItem) { - if (($calcItem['index'] ?? null) === $itemIndex) { - $floorCode = $calcItem['floor'] ?? null; - $symbolCode = $calcItem['code'] ?? null; - break; - } - } - } - - // item_index로 못 찾으면 배열 인덱스로 fallback - if ($floorCode === null && $symbolCode === null && isset($calcInputItems[$index])) { - $floorCode = $calcInputItems[$index]['floor'] ?? null; - $symbolCode = $calcInputItems[$index]['code'] ?? null; + $note = trim($quoteItem->note ?? ''); + if ($note !== '') { + $parts = preg_split('/\s+/', $note, 2); + $floorCode = $parts[0] ?? null; + $symbolCode = $parts[1] ?? null; } $order->items()->create([ 'tenant_id' => $tenantId, - 'serial_no' => $index + 1, // 1부터 시작하는 순번 + 'serial_no' => $index + 1, 'item_id' => $quoteItem->item_id, 'item_code' => $quoteItem->item_code, 'item_name' => $quoteItem->item_name, @@ -515,6 +500,7 @@ public function createFromQuote(int $quoteId, array $data = []) 'supply_amount' => $quoteItem->total_price, 'tax_amount' => round($quoteItem->total_price * 0.1, 2), 'total_amount' => round($quoteItem->total_price * 1.1, 2), + 'note' => $quoteItem->formula_category, 'sort_order' => $index, ]); } diff --git a/app/Services/Quote/QuoteService.php b/app/Services/Quote/QuoteService.php index be699c4..66d6cf1 100644 --- a/app/Services/Quote/QuoteService.php +++ b/app/Services/Quote/QuoteService.php @@ -101,6 +101,64 @@ public function index(array $params): LengthAwarePaginator return $query->paginate($size, ['*'], 'page', $page); } + /** + * 견적 참조 데이터 조회 (현장명, 부호 목록) + * + * 기존 견적/수주에서 사용된 현장명과 부호를 DISTINCT로 조회합니다. + */ + public function referenceData(): array + { + $tenantId = $this->tenantId(); + + // 현장명: 견적 테이블에서 DISTINCT + $siteNames = Quote::where('tenant_id', $tenantId) + ->whereNotNull('site_name') + ->where('site_name', '!=', '') + ->distinct() + ->orderBy('site_name') + ->pluck('site_name') + ->toArray(); + + // 부호(개소코드): calculation_inputs JSON 내 items[].code (예: FSS-01, SD-02) + $locationCodes = collect(); + + // calculation_inputs JSON에서 items[].code 추출 + $quotesWithInputs = Quote::where('tenant_id', $tenantId) + ->whereNotNull('calculation_inputs') + ->select('calculation_inputs') + ->get(); + + foreach ($quotesWithInputs as $quote) { + $inputs = is_string($quote->calculation_inputs) + ? json_decode($quote->calculation_inputs, true) + : $quote->calculation_inputs; + + if (! is_array($inputs)) { + continue; + } + + $items = $inputs['items'] ?? $inputs['locations'] ?? []; + foreach ($items as $item) { + $code = $item['code'] ?? null; + if ($code && trim($code) !== '') { + $locationCodes->push(trim($code)); + } + } + } + + // 중복 제거, 정렬 + $locationCodes = $locationCodes + ->unique() + ->sort() + ->values() + ->toArray(); + + return [ + 'site_names' => $siteNames, + 'location_codes' => $locationCodes, + ]; + } + /** * 견적 단건 조회 */ diff --git a/database/migrations/2026_02_03_120000_change_tolerance_to_json.php b/database/migrations/2026_02_03_120000_change_tolerance_to_json.php new file mode 100644 index 0000000..b9e7e77 --- /dev/null +++ b/database/migrations/2026_02_03_120000_change_tolerance_to_json.php @@ -0,0 +1,50 @@ +whereNotNull('tolerance') + ->where('tolerance', '!=', '') + ->orderBy('id') + ->each(function ($row) { + // 이미 JSON인 경우 스킵 + $decoded = json_decode($row->tolerance, true); + if (is_array($decoded) && isset($decoded['type'])) { + return; + } + + // 기존 문자열 → symmetric 타입으로 변환 + $value = trim($row->tolerance); + $numericValue = preg_replace('/[^\d.]/', '', $value); + + $json = json_encode([ + 'type' => 'symmetric', + 'value' => $numericValue !== '' ? (float) $numericValue : 0, + ], JSON_UNESCAPED_UNICODE); + + DB::table('document_template_section_items') + ->where('id', $row->id) + ->update(['tolerance' => $json]); + }); + + // 2. 컬럼 타입 변경: VARCHAR → JSON + Schema::table('document_template_section_items', function (Blueprint $table) { + $table->json('tolerance')->nullable()->comment('공차/허용범위 (JSON)')->change(); + }); + } + + public function down(): void + { + Schema::table('document_template_section_items', function (Blueprint $table) { + $table->string('tolerance', 100)->nullable()->comment('공차/허용범위')->change(); + }); + } +}; diff --git a/database/seeders/DocumentTemplateFieldPresetSeeder.php b/database/seeders/DocumentTemplateFieldPresetSeeder.php index 97ab726..76ba16a 100644 --- a/database/seeders/DocumentTemplateFieldPresetSeeder.php +++ b/database/seeders/DocumentTemplateFieldPresetSeeder.php @@ -20,9 +20,8 @@ public function run(): void 'fields' => json_encode([ ['field_key' => 'category', 'label' => '구분', 'field_type' => 'text', 'width' => '65px', 'is_required' => false], ['field_key' => 'item', 'label' => '검사항목', 'field_type' => 'text', 'width' => '130px', 'is_required' => true], - ['field_key' => 'standard', 'label' => '검사기준', 'field_type' => 'text', 'width' => '180px', 'is_required' => false], - ['field_key' => 'standard_criteria', 'label' => '기준범위', 'field_type' => 'json_criteria', 'width' => '100px', 'is_required' => false], - ['field_key' => 'tolerance', 'label' => '공차/범위', 'field_type' => 'json_tolerance', 'width' => '120px', 'is_required' => false], + ['field_key' => 'standard', 'label' => '검사 기준/범위', 'field_type' => 'text_with_criteria', 'width' => '220px', 'is_required' => false], + ['field_key' => 'tolerance', 'label' => '공차/범위', 'field_type' => 'json_tolerance', 'width' => '85px', 'is_required' => false], ['field_key' => 'method', 'label' => '검사방식', 'field_type' => 'select_api', 'width' => '110px', 'is_required' => false, 'options' => [ 'api_endpoint' => '/api/admin/common-codes/inspection_method', 'auto_map' => [ @@ -64,9 +63,8 @@ public function run(): void 'fields' => json_encode([ ['field_key' => 'category', 'label' => '구분', 'field_type' => 'text', 'width' => '65px', 'is_required' => false], ['field_key' => 'item', 'label' => '검사항목', 'field_type' => 'text', 'width' => '130px', 'is_required' => true], - ['field_key' => 'standard', 'label' => '검사기준', 'field_type' => 'text', 'width' => '180px', 'is_required' => false], - ['field_key' => 'standard_criteria', 'label' => '기준범위', 'field_type' => 'json_criteria', 'width' => '100px', 'is_required' => false], - ['field_key' => 'tolerance', 'label' => '공차/범위', 'field_type' => 'json_tolerance', 'width' => '120px', 'is_required' => false], + ['field_key' => 'standard', 'label' => '검사 기준/범위', 'field_type' => 'text_with_criteria', 'width' => '220px', 'is_required' => false], + ['field_key' => 'tolerance', 'label' => '공차/범위', 'field_type' => 'json_tolerance', 'width' => '85px', 'is_required' => false], ['field_key' => 'method', 'label' => '검사방식', 'field_type' => 'select_api', 'width' => '110px', 'is_required' => false, 'options' => [ 'api_endpoint' => '/api/admin/common-codes/inspection_method', 'auto_map' => [ @@ -108,9 +106,8 @@ public function run(): void 'fields' => json_encode([ ['field_key' => 'category', 'label' => '구분', 'field_type' => 'text', 'width' => '65px', 'is_required' => false], ['field_key' => 'item', 'label' => '검사항목', 'field_type' => 'text', 'width' => '130px', 'is_required' => true], - ['field_key' => 'standard', 'label' => '검사기준', 'field_type' => 'text', 'width' => '180px', 'is_required' => false], - ['field_key' => 'standard_criteria', 'label' => '기준범위', 'field_type' => 'json_criteria', 'width' => '100px', 'is_required' => false], - ['field_key' => 'tolerance', 'label' => '공차/범위', 'field_type' => 'json_tolerance', 'width' => '120px', 'is_required' => false], + ['field_key' => 'standard', 'label' => '검사 기준/범위', 'field_type' => 'text_with_criteria', 'width' => '220px', 'is_required' => false], + ['field_key' => 'tolerance', 'label' => '공차/범위', 'field_type' => 'json_tolerance', 'width' => '85px', 'is_required' => false], ['field_key' => 'method', 'label' => '검사방식', 'field_type' => 'select_api', 'width' => '110px', 'is_required' => false, 'options' => [ 'api_endpoint' => '/api/admin/common-codes/inspection_method', 'auto_map' => [ diff --git a/routes/api/v1/sales.php b/routes/api/v1/sales.php index 385ab5d..3330cfb 100644 --- a/routes/api/v1/sales.php +++ b/routes/api/v1/sales.php @@ -55,6 +55,9 @@ // 견적번호 미리보기 Route::get('/number/preview', [QuoteController::class, 'previewNumber'])->name('v1.quotes.number-preview'); + // 참조 데이터 (현장명, 부호 등) + Route::get('/reference-data', [QuoteController::class, 'referenceData'])->name('v1.quotes.reference-data'); + // 자동산출 Route::get('/calculation/schema', [QuoteController::class, 'calculationSchema'])->name('v1.quotes.calculation-schema'); Route::post('/calculate', [QuoteController::class, 'calculate'])->name('v1.quotes.calculate');