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