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:
@@ -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',
|
||||
|
||||
29
app/Models/NumberingRule.php
Normal file
29
app/Models/NumberingRule.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class NumberingRule extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'document_type',
|
||||
'rule_name',
|
||||
'pattern',
|
||||
'reset_period',
|
||||
'sequence_padding',
|
||||
'is_active',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'pattern' => 'array',
|
||||
'is_active' => 'boolean',
|
||||
'sequence_padding' => 'integer',
|
||||
];
|
||||
}
|
||||
20
app/Models/NumberingSequence.php
Normal file
20
app/Models/NumberingSequence.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class NumberingSequence extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'document_type',
|
||||
'scope_key',
|
||||
'period_key',
|
||||
'last_sequence',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'last_sequence' => 'integer',
|
||||
];
|
||||
}
|
||||
191
app/Services/NumberingService.php
Normal file
191
app/Services/NumberingService.php
Normal file
@@ -0,0 +1,191 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\NumberingRule;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class NumberingService extends Service
|
||||
{
|
||||
/**
|
||||
* 채번 규칙 기반 번호 생성
|
||||
*
|
||||
* @param string $documentType 문서유형 (quote, order, sale, work_order, material_receipt)
|
||||
* @param array $params 외부 파라미터 (pair_code, product_category 등)
|
||||
* @return string|null 생성된 번호 (규칙 없으면 null → 호출자가 기존 로직 사용)
|
||||
*/
|
||||
public function generate(string $documentType, array $params = []): ?string
|
||||
{
|
||||
$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':
|
||||
$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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user