From 78851ec04acf81f9d7b4c716ecf3fb8b68a82316 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Sat, 7 Feb 2026 09:50:52 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=85=8C=EB=84=8C=ED=8A=B8=EB=B3=84=20?= =?UTF-8?q?=EC=B1=84=EB=B2=88=20=EA=B7=9C=EC=B9=99=20=EC=8B=9C=EC=8A=A4?= =?UTF-8?q?=ED=85=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - numbering_rules 테이블: JSON 패턴 기반 채번 규칙 저장 (tenant별) - numbering_sequences 테이블: MySQL UPSERT 기반 atomic 시퀀스 관리 - NumberingService: generate/preview/nextSequence 핵심 서비스 - QuoteNumberService: NumberingService 우선, 폴백 QT{YYYYMMDD}{NNNN} - OrderService: NumberingService 우선 (pair_code 지원), 폴백 ORD{YYYYMMDD}{NNNN} - StoreOrderRequest: pair_code 필드 추가 - NumberingRuleSeeder: tenant_id=287 견적(KD-PR)/수주(KD-{pairCode}) 규칙 Co-Authored-By: Claude Opus 4.6 --- app/Http/Requests/Order/StoreOrderRequest.php | 1 + app/Models/NumberingRule.php | 29 +++ app/Models/NumberingSequence.php | 20 ++ app/Services/NumberingService.php | 191 ++++++++++++++++++ app/Services/OrderService.php | 34 +++- app/Services/Quote/QuoteNumberService.php | 122 +++++++---- ...07_200000_create_numbering_rules_table.php | 33 +++ ...00001_create_numbering_sequences_table.php | 31 +++ database/seeders/NumberingRuleSeeder.php | 58 ++++++ 9 files changed, 475 insertions(+), 44 deletions(-) create mode 100644 app/Models/NumberingRule.php create mode 100644 app/Models/NumberingSequence.php create mode 100644 app/Services/NumberingService.php create mode 100644 database/migrations/2026_02_07_200000_create_numbering_rules_table.php create mode 100644 database/migrations/2026_02_07_200001_create_numbering_sequences_table.php create mode 100644 database/seeders/NumberingRuleSeeder.php diff --git a/app/Http/Requests/Order/StoreOrderRequest.php b/app/Http/Requests/Order/StoreOrderRequest.php index 32bb859..0265aed 100644 --- a/app/Http/Requests/Order/StoreOrderRequest.php +++ b/app/Http/Requests/Order/StoreOrderRequest.php @@ -24,6 +24,7 @@ public function rules(): array Order::STATUS_CONFIRMED, ])], 'category_code' => 'nullable|string|max:50', + 'pair_code' => 'nullable|string|max:20', // 거래처 정보 'client_id' => 'nullable|integer|exists:clients,id', diff --git a/app/Models/NumberingRule.php b/app/Models/NumberingRule.php new file mode 100644 index 0000000..ff72e8c --- /dev/null +++ b/app/Models/NumberingRule.php @@ -0,0 +1,29 @@ + 'array', + 'is_active' => 'boolean', + 'sequence_padding' => 'integer', + ]; +} \ No newline at end of file diff --git a/app/Models/NumberingSequence.php b/app/Models/NumberingSequence.php new file mode 100644 index 0000000..8146e6b --- /dev/null +++ b/app/Models/NumberingSequence.php @@ -0,0 +1,20 @@ + 'integer', + ]; +} \ No newline at end of file diff --git a/app/Services/NumberingService.php b/app/Services/NumberingService.php new file mode 100644 index 0000000..6b68960 --- /dev/null +++ b/app/Services/NumberingService.php @@ -0,0 +1,191 @@ +tenantId(); + + $rule = NumberingRule::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('document_type', $documentType) + ->where('is_active', true) + ->first(); + + if (! $rule) { + return null; + } + + $segments = $rule->pattern; + $result = ''; + $scopeKey = ''; + + foreach ($segments as $segment) { + switch ($segment['type']) { + case 'static': + $result .= $segment['value']; + break; + + case 'separator': + $result .= $segment['value']; + break; + + case 'date': + $result .= now()->format($segment['format']); + break; + + case 'param': + $value = $params[$segment['key']] ?? $segment['default'] ?? ''; + $result .= $value; + $scopeKey = $value; + break; + + case 'mapping': + $inputValue = $params[$segment['key']] ?? ''; + $value = $segment['map'][$inputValue] ?? $segment['default'] ?? ''; + $result .= $value; + $scopeKey = $value; + break; + + case 'sequence': + $periodKey = match ($rule->reset_period) { + 'daily' => now()->format('ymd'), + 'monthly' => now()->format('Ym'), + 'yearly' => now()->format('Y'), + 'never' => 'all', + default => now()->format('ymd'), + }; + + $nextSeq = $this->nextSequence( + $tenantId, + $documentType, + $scopeKey, + $periodKey + ); + + $result .= str_pad( + (string) $nextSeq, + $rule->sequence_padding, + '0', + STR_PAD_LEFT + ); + break; + } + } + + return $result; + } + + /** + * 미리보기 (시퀀스 증가 없이 다음 번호 예측) + */ + public function preview(string $documentType, array $params = []): ?array + { + $tenantId = $this->tenantId(); + + $rule = NumberingRule::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('document_type', $documentType) + ->where('is_active', true) + ->first(); + + if (! $rule) { + return null; + } + + $segments = $rule->pattern; + $result = ''; + $scopeKey = ''; + + foreach ($segments as $segment) { + switch ($segment['type']) { + case 'static': + case 'separator': + $result .= $segment['value']; + break; + case 'date': + $result .= now()->format($segment['format']); + break; + case 'param': + $value = $params[$segment['key']] ?? $segment['default'] ?? ''; + $result .= $value; + $scopeKey = $value; + break; + case 'mapping': + $inputValue = $params[$segment['key']] ?? ''; + $value = $segment['map'][$inputValue] ?? $segment['default'] ?? ''; + $result .= $value; + $scopeKey = $value; + break; + case 'sequence': + $periodKey = match ($rule->reset_period) { + 'daily' => now()->format('ymd'), + 'monthly' => now()->format('Ym'), + 'yearly' => now()->format('Y'), + 'never' => 'all', + default => now()->format('ymd'), + }; + + $currentSeq = (int) DB::table('numbering_sequences') + ->where('tenant_id', $tenantId) + ->where('document_type', $documentType) + ->where('scope_key', $scopeKey) + ->where('period_key', $periodKey) + ->value('last_sequence'); + + $result .= str_pad( + (string) ($currentSeq + 1), + $rule->sequence_padding, + '0', + STR_PAD_LEFT + ); + break; + } + } + + return [ + 'preview_number' => $result, + 'document_type' => $documentType, + 'rule_name' => $rule->rule_name, + ]; + } + + /** + * Atomic sequence increment (MySQL UPSERT) + */ + private function nextSequence( + int $tenantId, + string $documentType, + string $scopeKey, + string $periodKey + ): int { + DB::statement( + 'INSERT INTO numbering_sequences + (tenant_id, document_type, scope_key, period_key, last_sequence, created_at, updated_at) + VALUES (?, ?, ?, ?, 1, NOW(), NOW()) + ON DUPLICATE KEY UPDATE + last_sequence = last_sequence + 1, + updated_at = NOW()', + [$tenantId, $documentType, $scopeKey, $periodKey] + ); + + return (int) DB::table('numbering_sequences') + ->where('tenant_id', $tenantId) + ->where('document_type', $documentType) + ->where('scope_key', $scopeKey) + ->where('period_key', $periodKey) + ->value('last_sequence'); + } +} \ No newline at end of file diff --git a/app/Services/OrderService.php b/app/Services/OrderService.php index c17af88..565a1b5 100644 --- a/app/Services/OrderService.php +++ b/app/Services/OrderService.php @@ -14,6 +14,10 @@ class OrderService extends Service { + public function __construct( + private NumberingService $numberingService + ) {} + /** * 목록 조회 (검색/필터링/페이징) */ @@ -145,7 +149,9 @@ public function store(array $data) return DB::transaction(function () use ($data, $tenantId, $userId) { // 수주번호 자동 생성 - $data['order_no'] = $this->generateOrderNo($tenantId); + $pairCode = $data['pair_code'] ?? null; + unset($data['pair_code']); + $data['order_no'] = $this->generateOrderNo($tenantId, $pairCode); $data['tenant_id'] = $tenantId; $data['created_by'] = $userId; $data['updated_by'] = $userId; @@ -406,8 +412,29 @@ private function calculateItemAmounts(array &$item): void /** * 수주번호 자동 생성 + * + * 채번규칙이 있으면 NumberingService 사용 (KD-{pairCode}-{YYMMDD}-{NN}), + * 없으면 레거시 로직 (ORD{YYYYMMDD}{NNNN}) */ - private function generateOrderNo(int $tenantId): string + private function generateOrderNo(int $tenantId, ?string $pairCode = null): string + { + $this->numberingService->setContext($tenantId, $this->apiUserId()); + + $number = $this->numberingService->generate('order', [ + 'pair_code' => $pairCode ?? 'SS', + ]); + + if ($number !== null) { + return $number; + } + + return $this->generateOrderNoLegacy($tenantId); + } + + /** + * 레거시 수주번호 생성 (ORD{YYYYMMDD}{NNNN}) + */ + private function generateOrderNoLegacy(int $tenantId): string { $prefix = 'ORD'; $date = now()->format('Ymd'); @@ -456,7 +483,8 @@ public function createFromQuote(int $quoteId, array $data = []) return DB::transaction(function () use ($quote, $data, $tenantId, $userId) { // 수주번호 생성 - $orderNo = $this->generateOrderNo($tenantId); + $pairCode = $data['pair_code'] ?? null; + $orderNo = $this->generateOrderNo($tenantId, $pairCode); // Order 모델의 createFromQuote 사용 $order = Order::createFromQuote($quote, $orderNo); diff --git a/app/Services/Quote/QuoteNumberService.php b/app/Services/Quote/QuoteNumberService.php index dc18dcc..1e42836 100644 --- a/app/Services/Quote/QuoteNumberService.php +++ b/app/Services/Quote/QuoteNumberService.php @@ -3,54 +3,63 @@ namespace App\Services\Quote; use App\Models\Quote\Quote; +use App\Services\NumberingService; use App\Services\Service; class QuoteNumberService extends Service { + public function __construct( + private NumberingService $numberingService + ) {} + /** * 견적번호 생성 * - * 형식: KD-{PREFIX}-{YYMMDD}-{SEQ} - * 예시: KD-SC-251204-01 (스크린), KD-ST-251204-01 (철재) + * 채번규칙이 있으면 NumberingService 사용, 없으면 레거시 로직 */ public function generate(?string $productCategory = null): string { - $tenantId = $this->tenantId(); + $this->numberingService->setContext( + $this->tenantId(), + $this->apiUserId() + ); - // 제품 카테고리에 따른 접두어 - $prefix = match ($productCategory) { - Quote::CATEGORY_SCREEN => 'SC', - Quote::CATEGORY_STEEL => 'ST', - default => 'SC', - }; + $number = $this->numberingService->generate('quote', [ + 'product_category' => $productCategory, + ]); - // 날짜 부분 (YYMMDD) - $dateStr = now()->format('ymd'); - - // 오늘 날짜 기준으로 마지막 견적번호 조회 - $pattern = "KD-{$prefix}-{$dateStr}-%"; - - $lastQuote = Quote::withTrashed() - ->where('tenant_id', $tenantId) - ->where('quote_number', 'like', $pattern) - ->orderBy('quote_number', 'desc') - ->first(); - - // 순번 계산 - $sequence = 1; - if ($lastQuote) { - // KD-SC-251204-01 에서 마지막 숫자 추출 - $parts = explode('-', $lastQuote->quote_number); - if (count($parts) >= 4) { - $lastSeq = (int) end($parts); - $sequence = $lastSeq + 1; - } + if ($number !== null) { + return $number; } - // 2자리 순번 (01, 02, ...) - $seqStr = str_pad((string) $sequence, 2, '0', STR_PAD_LEFT); + return $this->generateLegacy($productCategory); + } - return "KD-{$prefix}-{$dateStr}-{$seqStr}"; + /** + * 기본 견적번호 생성 + * + * 형식: QT{YYYYMMDD}{NNNN} + * 예시: QT202602070001 + */ + private function generateLegacy(?string $productCategory = null): string + { + $tenantId = $this->tenantId(); + $prefix = 'QT'; + $date = now()->format('Ymd'); + + $lastNo = Quote::withTrashed() + ->where('tenant_id', $tenantId) + ->where('quote_number', 'like', "{$prefix}{$date}%") + ->orderByDesc('quote_number') + ->value('quote_number'); + + if ($lastNo) { + $seq = (int) substr($lastNo, -4) + 1; + } else { + $seq = 1; + } + + return sprintf('%s%s%04d', $prefix, $date, $seq); } /** @@ -58,7 +67,25 @@ public function generate(?string $productCategory = null): string */ public function preview(?string $productCategory = null): array { - $quoteNumber = $this->generate($productCategory); + $this->numberingService->setContext( + $this->tenantId(), + $this->apiUserId() + ); + + $rulePreview = $this->numberingService->preview('quote', [ + 'product_category' => $productCategory, + ]); + + if ($rulePreview !== null) { + return [ + 'quote_number' => $rulePreview['preview_number'], + 'product_category' => $productCategory ?? Quote::CATEGORY_SCREEN, + 'rule_name' => $rulePreview['rule_name'], + 'generated_at' => now()->toDateTimeString(), + ]; + } + + $quoteNumber = $this->generateLegacy($productCategory); return [ 'quote_number' => $quoteNumber, @@ -69,11 +96,14 @@ public function preview(?string $productCategory = null): array /** * 견적번호 형식 검증 + * + * 지원 형식: + * - 기본: QT{YYYYMMDD}{NNNN} (예: QT202602070001) + * - 채번규칙: KD-PR-{YYMMDD}-{NN} (예: KD-PR-260207-01) */ public function validate(string $quoteNumber): bool { - // 형식: KD-XX-YYMMDD-NN - return (bool) preg_match('/^KD-[A-Z]{2}-\d{6}-\d{2,}$/', $quoteNumber); + return (bool) preg_match('/^(QT\d{8}\d{4,}|KD-[A-Z]{2}-\d{6}-\d{2,})$/', $quoteNumber); } /** @@ -85,13 +115,23 @@ public function parse(string $quoteNumber): ?array return null; } - $parts = explode('-', $quoteNumber); + // 채번규칙 형식: KD-PR-260207-01 + if (str_starts_with($quoteNumber, 'KD-')) { + $parts = explode('-', $quoteNumber); + return [ + 'prefix' => $parts[0], + 'category_code' => $parts[1], + 'date' => $parts[2], + 'sequence' => (int) $parts[3], + ]; + } + + // 기본 형식: QT202602070001 return [ - 'prefix' => $parts[0], // KD - 'category_code' => $parts[1], // SC or ST - 'date' => $parts[2], // YYMMDD - 'sequence' => (int) $parts[3], // 순번 + 'prefix' => 'QT', + 'date' => substr($quoteNumber, 2, 8), + 'sequence' => (int) substr($quoteNumber, 10), ]; } diff --git a/database/migrations/2026_02_07_200000_create_numbering_rules_table.php b/database/migrations/2026_02_07_200000_create_numbering_rules_table.php new file mode 100644 index 0000000..9da41f3 --- /dev/null +++ b/database/migrations/2026_02_07_200000_create_numbering_rules_table.php @@ -0,0 +1,33 @@ +id(); + $table->unsignedBigInteger('tenant_id')->comment('테넌트 ID'); + $table->string('document_type', 50)->comment('문서유형: quote, order, sale, work_order, material_receipt'); + $table->string('rule_name', 100)->nullable()->comment('규칙명 (관리용)'); + $table->json('pattern')->comment('패턴 정의 (세그먼트 배열)'); + $table->string('reset_period', 20)->default('daily')->comment('시퀀스 리셋 주기: daily, monthly, yearly, never'); + $table->integer('sequence_padding')->default(2)->comment('시퀀스 자릿수'); + $table->boolean('is_active')->default(true); + $table->unsignedBigInteger('created_by')->nullable(); + $table->unsignedBigInteger('updated_by')->nullable(); + $table->timestamps(); + + $table->unique(['tenant_id', 'document_type'], 'uq_tenant_doctype'); + $table->index('tenant_id', 'idx_numbering_rules_tenant'); + }); + } + + public function down(): void + { + Schema::dropIfExists('numbering_rules'); + } +}; \ No newline at end of file diff --git a/database/migrations/2026_02_07_200001_create_numbering_sequences_table.php b/database/migrations/2026_02_07_200001_create_numbering_sequences_table.php new file mode 100644 index 0000000..eddfe19 --- /dev/null +++ b/database/migrations/2026_02_07_200001_create_numbering_sequences_table.php @@ -0,0 +1,31 @@ +id(); + $table->unsignedBigInteger('tenant_id')->comment('테넌트 ID'); + $table->string('document_type', 50)->comment('문서유형'); + $table->string('scope_key', 100)->default('')->comment('범위 키 (카테고리/모델별 구분)'); + $table->string('period_key', 20)->comment('기간 키: 260207(daily), 202602(monthly), 2026(yearly)'); + $table->unsignedInteger('last_sequence')->default(0)->comment('마지막 시퀀스 번호'); + $table->timestamps(); + + $table->unique( + ['tenant_id', 'document_type', 'scope_key', 'period_key'], + 'uq_numbering_sequence' + ); + }); + } + + public function down(): void + { + Schema::dropIfExists('numbering_sequences'); + } +}; \ No newline at end of file diff --git a/database/seeders/NumberingRuleSeeder.php b/database/seeders/NumberingRuleSeeder.php new file mode 100644 index 0000000..fa71f47 --- /dev/null +++ b/database/seeders/NumberingRuleSeeder.php @@ -0,0 +1,58 @@ +updateOrInsert( + ['tenant_id' => $tenantId, 'document_type' => 'quote'], + [ + 'rule_name' => '5130 견적번호', + 'pattern' => json_encode([ + ['type' => 'static', 'value' => 'KD'], + ['type' => 'separator', 'value' => '-'], + ['type' => 'static', 'value' => 'PR'], + ['type' => 'separator', 'value' => '-'], + ['type' => 'date', 'format' => 'ymd'], + ['type' => 'separator', 'value' => '-'], + ['type' => 'sequence'], + ]), + 'reset_period' => 'daily', + 'sequence_padding' => 2, + 'is_active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ] + ); + + // 수주 로트번호 규칙: KD-{pairCode}-{YYMMDD}-{NN} + DB::table('numbering_rules')->updateOrInsert( + ['tenant_id' => $tenantId, 'document_type' => 'order'], + [ + 'rule_name' => '5130 수주 로트번호', + 'pattern' => json_encode([ + ['type' => 'static', 'value' => 'KD'], + ['type' => 'separator', 'value' => '-'], + ['type' => 'param', 'key' => 'pair_code', 'default' => 'SS'], + ['type' => 'separator', 'value' => '-'], + ['type' => 'date', 'format' => 'ymd'], + ['type' => 'separator', 'value' => '-'], + ['type' => 'sequence'], + ]), + 'reset_period' => 'daily', + 'sequence_padding' => 2, + 'is_active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ] + ); + } +} \ No newline at end of file