feat: 테넌트별 채번 규칙 시스템 구현

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-02-07 09:50:52 +09:00
parent 6318474b6f
commit 78851ec04a
9 changed files with 475 additions and 44 deletions

View File

@@ -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),
];
}