- 개발팀 전용 폴더 dev/ 생성 (standards, guides, quickstart, changes, deploys, data, history, dev_plans 이동) - 프론트엔드 전용 폴더 frontend/ 생성 (api/ → frontend/api-specs/) - 기획팀 폴더 requests/ 생성 - plans/ → dev/dev_plans/ 이름 변경 - README.md 신규 (사람용 안내), INDEX.md 재작성 (Claude Code용) - resources.md 신규 (노션 링크용, assets/brochure 이관 예정) - CURRENT_WORKS.md 삭제, TODO.md → dev/ 이동 - 전체 참조 경로 업데이트 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
28 KiB
테넌트별 채번 규칙 시스템 계획
작성일: 2026-02-07 목적: 테넌트별 문서번호(견적번호, 수주로트번호 등) 채번 규칙을 DB에 저장하고, 규칙 기반으로 자동 채번하는 시스템 구축 상태: 🔄 진행중 대상 테넌트: tenant_id = 287
📍 현재 진행 상태
| 항목 | 내용 |
|---|---|
| 마지막 완료 작업 | 분석 & 계획 문서 작성 |
| 다음 작업 | Phase 1: DB 마이그레이션 (1.1 numbering_rules 테이블 생성) |
| 진행률 | 0/8 (0%) |
| 마지막 업데이트 | 2026-02-07 |
1. 개요
1.1 배경
5130 레거시 시스템에서 SAM으로 전환 중이며, 기존 번호 체계를 SAM에 적용해야 함. 향후 채번관리 UI를 만들 예정이나, 현재는 DB에 규칙을 저장하고 서비스에서 읽어 사용하는 구조.
변경 전후 비교 (tenant_id=287):
| 문서 유형 | 현재 SAM 패턴 | 변경 후 패턴 (5130 방식) |
|---|---|---|
| 견적번호 | KD-SC-260207-01 |
KD-PR-260207-01 |
| 수주번호 | ORD202602070001 |
KD-SS-260207-01 (모델별 두문자) |
다른 테넌트: 기존 로직 그대로 유지 (하위호환)
1.2 5130 레거시 채번 패턴 (원본 분석)
견적번호 - 5130/estimate/get_initial_pjnum.php
// 핵심 로직 (전체 24줄)
$today = date('ymd'); // "260207"
$prefix = "KD-PR-$today"; // "KD-PR-260207"
// → DB에서 해당 prefix로 시작하는 기존 견적 개수 확인
// → 항상 "KD-PR-{YYMMDD}" 반환 (순번은 별도 관리)
// → 복사 시 마지막 "-NN" 제거: substr($pjnum, 0, -3)
결과: KD-PR-260207, 복사 시 KD-PR-260207-01 → KD-PR-260207 (순번 제거 후 재생성)
수주 로트번호 - 5130/output/lotnum_generator.php
// 핵심 로직 (전체 72줄)
$currentDate = date('ymd'); // "260207"
$filepath = $_SERVER['DOCUMENT_ROOT'] . '/output/lotnum.txt';
// lotnum.txt에서 이전 번호 읽기
$lastLotNumber = file_get_contents($filepath); // "260207-01"
list($date, $number) = explode('-', $lastLotNumber);
if ($date === $currentDate) {
$newNumber = str_pad((int)$number + 1, 2, '0', STR_PAD_LEFT);
$lot_number = $currentDate . '-' . $newNumber; // "260207-02"
} else {
$lot_number = $currentDate . '-01'; // 날짜 바뀌면 01 리셋
}
file_put_contents($filepath, $lot_number);
// lot_sales 테이블에 INSERT 후 JSON 반환
echo json_encode(['lotNum' => 'KD-' . $pairCode . '-' . $lot_number]);
// → "KD-SS-260207-01"
$pairCode: 프론트에서 POST로 전달 (모델 선택 시 해당 모델의 "두문자")
모델별 두문자 (pair_code) - 5130/models/models.json
[
{"model_name": "KSS01", "slatitem": "스크린", "pair": "SS"},
{"model_name": "KSS02", "slatitem": "스크린", "pair": "SA"},
{"model_name": "KSE01", "slatitem": "스크린", "pair": "SE"},
{"model_name": "KWE01", "slatitem": "스크린", "pair": "WE"},
{"model_name": "KQTS01", "slatitem": "철재", "pair": "TS"},
{"model_name": "KTE01", "slatitem": "철재", "pair": "TE"},
{"model_name": "스크린비인정", "slatitem": "스크린", "pair": "비인정"},
{"model_name": "철재비인정", "slatitem": "철재", "pair": "비인정"},
{"model_name": "KDSS01", "slatitem": "스크린", "pair": "DS"}
]
결정사항: 현재 9개 고정 목록으로 운영. 향후 채번관리 UI에서 추가/수정 가능하게 확장.
1.3 현재 SAM 채번 로직 (수정 대상 코드)
QuoteNumberService.php (전체 코드)
파일: api/app/Services/Quote/QuoteNumberService.php
<?php
namespace App\Services\Quote;
use App\Models\Quote\Quote;
use App\Services\Service;
class QuoteNumberService extends Service
{
// 형식: KD-{PREFIX}-{YYMMDD}-{SEQ}
// PREFIX: SCREEN→SC, STEEL→ST, default→SC
public function generate(?string $productCategory = null): string
{
$tenantId = $this->tenantId();
$prefix = match ($productCategory) {
Quote::CATEGORY_SCREEN => 'SC',
Quote::CATEGORY_STEEL => 'ST',
default => 'SC',
};
$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) {
$parts = explode('-', $lastQuote->quote_number);
if (count($parts) >= 4) {
$lastSeq = (int) end($parts);
$sequence = $lastSeq + 1;
}
}
$seqStr = str_pad((string) $sequence, 2, '0', STR_PAD_LEFT);
return "KD-{$prefix}-{$dateStr}-{$seqStr}";
}
public function preview(?string $productCategory = null): array { /* ... */ }
public function validate(string $quoteNumber): bool
{
return (bool) preg_match('/^KD-[A-Z]{2}-\d{6}-\d{2,}$/', $quoteNumber);
}
public function parse(string $quoteNumber): ?array { /* ... */ }
public function isUnique(string $quoteNumber, ?int $excludeId = null): bool { /* ... */ }
}
OrderService::generateOrderNo() (수정 대상 메서드)
파일: api/app/Services/OrderService.php (Line 410-429)
private function generateOrderNo(int $tenantId): string
{
$prefix = 'ORD';
$date = now()->format('Ymd');
$lastNo = Order::withoutGlobalScopes()
->where('tenant_id', $tenantId)
->where('order_no', 'like', "{$prefix}{$date}%")
->orderByDesc('order_no')
->value('order_no');
if ($lastNo) {
$seq = (int) substr($lastNo, -4) + 1;
} else {
$seq = 1;
}
return sprintf('%s%s%04d', $prefix, $date, $seq);
}
OrderService::store() 호출부 (Line 141-148)
public function store(array $data)
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
return DB::transaction(function () use ($data, $tenantId, $userId) {
$data['order_no'] = $this->generateOrderNo($tenantId);
// ...
});
}
OrderService::createFromQuote() 호출부 (Line 457-459)
return DB::transaction(function () use ($quote, $data, $tenantId, $userId) {
$orderNo = $this->generateOrderNo($tenantId);
$order = Order::createFromQuote($quote, $orderNo);
// ...
});
StoreOrderRequest.php (현재 전체 rules)
파일: api/app/Http/Requests/Order/StoreOrderRequest.php
public function rules(): array
{
return [
'quote_id' => 'nullable|integer|exists:quotes,id',
'order_type_code' => ['nullable', Rule::in([Order::TYPE_ORDER, Order::TYPE_PURCHASE])],
'status_code' => ['nullable', Rule::in([Order::STATUS_DRAFT, Order::STATUS_CONFIRMED])],
'category_code' => 'nullable|string|max:50',
'client_id' => 'nullable|integer|exists:clients,id',
'client_name' => 'nullable|string|max:200',
// ... (금액, 배송, 옵션, 품목 등)
// ❌ pair_code 없음 → 추가 필요
];
}
Service 베이스 클래스
파일: api/app/Services/Service.php
abstract class Service
{
protected function tenantId(): int { /* app('tenant_id') */ }
protected function apiUserId(): int { /* app('api_user') */ }
public function setContext(int $tenantId, int $userId): self
{
app()->instance('tenant_id', $tenantId);
app()->instance('api_user', $userId);
return $this;
}
}
1.4 핵심 원칙
- 규칙 없는 테넌트는 기존 하드코딩 로직 그대로 사용 (하위호환)
numbering_rules테이블에 규칙이 있으면 규칙 기반 채번- 시퀀스는 DB 기반으로 관리 (파일 기반 X, LIKE 검색 X)
- 동시성 안전 (MySQL UPSERT로 Atomic Update)
- 향후 채번관리 UI 확장 고려 (JSON 기반 유연한 패턴 정의)
1.5 변경 승인 정책
| 분류 | 예시 | 승인 |
|---|---|---|
| ✅ 즉시 가능 | 서비스 내부 리팩토링 | 불필요 |
| ⚠️ 컨펌 필요 | 새 테이블 생성, 기존 서비스 로직 변경, FormRequest 변경 | 필수 |
| 🔴 금지 | 기존 채번된 번호 변경, orders/quotes 테이블 스키마 변경 | 별도 협의 |
2. 대상 범위
2.1 Phase 1: DB 설계 & 마이그레이션
| # | 작업 항목 | 상태 | 비고 |
|---|---|---|---|
| 1.1 | numbering_rules 테이블 생성 마이그레이션 |
⏳ | 채번 규칙 저장 |
| 1.2 | numbering_sequences 테이블 생성 마이그레이션 |
⏳ | 시퀀스 추적 (atomic) |
| 1.3 | NumberingRuleSeeder - tenant_id=287 시드 데이터 |
⏳ | 견적/수주 규칙 2건 |
2.2 Phase 2: 핵심 서비스 구현
| # | 작업 항목 | 상태 | 비고 |
|---|---|---|---|
| 2.1 | NumberingRule 모델 |
⏳ | BelongsToTenant |
| 2.2 | NumberingSequence 모델 |
⏳ | |
| 2.3 | NumberingService 통합 서비스 |
⏳ | 규칙 해석 + 번호 생성 |
2.3 Phase 3: 견적번호 적용
| # | 작업 항목 | 상태 | 비고 |
|---|---|---|---|
| 3.1 | QuoteNumberService 수정 |
⏳ | NumberingService 우선, 없으면 기존 로직 |
2.4 Phase 4: 수주 로트번호 적용
| # | 작업 항목 | 상태 | 비고 |
|---|---|---|---|
| 4.1 | OrderService::generateOrderNo() 수정 |
⏳ | NumberingService 연동 |
| 4.2 | StoreOrderRequest에 pair_code 옵셔널 추가 |
⏳ |
3. 상세 설계
3.1 테이블 설계
numbering_rules (채번 규칙)
CREATE TABLE numbering_rules (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '테넌트 ID',
document_type VARCHAR(50) NOT NULL COMMENT '문서유형: quote, order, sale, work_order, material_receipt',
rule_name VARCHAR(100) NULL COMMENT '규칙명 (관리용)',
pattern JSON NOT NULL COMMENT '패턴 정의 (세그먼트 배열)',
reset_period VARCHAR(20) NOT NULL DEFAULT 'daily' COMMENT '시퀀스 리셋 주기: daily, monthly, yearly, never',
sequence_padding INT NOT NULL DEFAULT 2 COMMENT '시퀀스 자릿수',
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_by BIGINT UNSIGNED NULL,
updated_by BIGINT UNSIGNED NULL,
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL,
UNIQUE KEY uq_tenant_doctype (tenant_id, document_type),
INDEX idx_tenant (tenant_id)
) COMMENT '테넌트별 채번 규칙';
numbering_sequences (시퀀스 카운터)
CREATE TABLE numbering_sequences (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '테넌트 ID',
document_type VARCHAR(50) NOT NULL COMMENT '문서유형',
scope_key VARCHAR(100) NOT NULL DEFAULT '' COMMENT '범위 키 (카테고리/모델별 구분)',
period_key VARCHAR(20) NOT NULL COMMENT '기간 키: 260207(daily), 202602(monthly), 2026(yearly)',
last_sequence INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '마지막 시퀀스 번호',
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL,
UNIQUE KEY uq_sequence (tenant_id, document_type, scope_key, period_key)
) COMMENT '채번 시퀀스 카운터';
3.2 패턴 JSON 구조
세그먼트 타입 정의
| type | 설명 | 필수 속성 | 예시 |
|---|---|---|---|
static |
고정 문자열 | value |
{"type": "static", "value": "KD"} |
separator |
구분자 | value |
{"type": "separator", "value": "-"} |
date |
날짜 | format (PHP date format) |
{"type": "date", "format": "ymd"} |
sequence |
일련번호 | (padding은 rule 레벨 설정 사용) | {"type": "sequence"} |
param |
외부 파라미터 값 | key, default |
{"type": "param", "key": "pair_code", "default": "SS"} |
mapping |
파라미터→코드 매핑 | key, map, default |
아래 참조 |
mapping 세그먼트 상세
{
"type": "mapping",
"key": "product_category",
"map": { "SCREEN": "SC", "STEEL": "ST" },
"default": "SC"
}
→ params['product_category']가 "SCREEN"이면 "SC" 출력
3.3 tenant_id=287 시드 데이터
견적번호 규칙: KD-PR-{YYMMDD}-{NN}
{
"tenant_id": 287,
"document_type": "quote",
"rule_name": "5130 견적번호",
"pattern": [
{"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
}
scope_key: "" (빈 문자열, 견적은 카테고리 구분 없이 통합 순번)
생성 예시: KD-PR-260207-01, KD-PR-260207-02, ...
수주 로트번호 규칙: KD-{pairCode}-{YYMMDD}-{NN}
{
"tenant_id": 287,
"document_type": "order",
"rule_name": "5130 수주 로트번호",
"pattern": [
{"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
}
scope_key: param 세그먼트의 결과값 (예: "SS", "TS", "비인정")
생성 예시: pair_code="SS" → KD-SS-260207-01
pair_code 고정 목록 (9개, 향후 채번관리 UI에서 추가 가능)
| # | 모델명 | 종류 | pair_code | 로트번호 예시 |
|---|---|---|---|---|
| 1 | KSS01 | 스크린 | SS | KD-SS-260207-01 |
| 2 | KSS02 | 스크린 | SA | KD-SA-260207-01 |
| 3 | KSE01 | 스크린 | SE | KD-SE-260207-01 |
| 4 | KWE01 | 스크린 | WE | KD-WE-260207-01 |
| 5 | KQTS01 | 철재 | TS | KD-TS-260207-01 |
| 6 | KTE01 | 철재 | TE | KD-TE-260207-01 |
| 7 | 스크린비인정 | 스크린 | 비인정 | KD-비인정-260207-01 |
| 8 | 철재비인정 | 철재 | 비인정 | KD-비인정-260207-01 |
| 9 | KDSS01 | 스크린 | DS | KD-DS-260207-01 |
3.4 NumberingService 구현 명세
<?php
namespace App\Services;
use App\Models\NumberingRule;
use App\Models\NumberingSequence;
use Illuminate\Support\Facades\DB;
class NumberingService extends Service
{
/**
* 채번 규칙 기반 번호 생성
*
* @param string $documentType 문서유형 (quote, order, sale, ...)
* @param array $params 외부 파라미터 (pair_code, product_category 등)
* @return string|null 생성된 번호 (규칙 없으면 null → 호출자가 기존 로직 사용)
*/
public function generate(string $documentType, array $params = []): ?string
{
$tenantId = $this->tenantId();
// 1. 규칙 조회
$rule = NumberingRule::where('tenant_id', $tenantId)
->where('document_type', $documentType)
->where('is_active', true)
->first();
if (!$rule) {
return null; // 규칙 없음 → 호출자가 기존 로직 사용
}
$segments = $rule->pattern; // JSON → array (cast)
$result = '';
$scopeKey = '';
// 2. 세그먼트 순회하며 문자열 조립
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; // scope_key로 사용
break;
case 'mapping':
$inputValue = $params[$segment['key']] ?? '';
$value = $segment['map'][$inputValue] ?? $segment['default'] ?? '';
$result .= $value;
$scopeKey = $value;
break;
case 'sequence':
// 3. period_key 결정
$periodKey = match ($rule->reset_period) {
'daily' => now()->format('ymd'),
'monthly' => now()->format('Ym'),
'yearly' => now()->format('Y'),
'never' => 'all',
default => now()->format('ymd'),
};
// 4. atomic increment
$nextSeq = $this->nextSequence(
$tenantId, $documentType, $scopeKey, $periodKey
);
// 5. padding 적용
$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::where('tenant_id', $tenantId)
->where('document_type', $documentType)
->where('is_active', true)
->first();
if (!$rule) return null;
// preview는 시퀀스를 증가시키지 않고 현재값+1 예측
// (실제 generate와 동일 로직이나 DB UPDATE 없이 SELECT만)
// 구현 시 generate와 유사하되 nextSequence 대신 peekSequence 사용
return [
'preview_number' => '(preview)',
'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 {
// MySQL INSERT ... ON DUPLICATE KEY UPDATE (atomic)
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');
}
}
3.5 기존 서비스 수정 상세
QuoteNumberService.php 수정
// ===== 변경 전 =====
public function generate(?string $productCategory = null): string
{
$tenantId = $this->tenantId();
$prefix = match ($productCategory) { /* ... */ };
// ... LIKE 검색으로 마지막 번호 조회
}
// ===== 변경 후 =====
public function generate(?string $productCategory = null): string
{
$tenantId = $this->tenantId();
// 1. NumberingService로 규칙 기반 생성 시도
$number = app(NumberingService::class)
->setContext($tenantId, $this->apiUserId())
->generate('quote', [
'product_category' => $productCategory ?? Quote::CATEGORY_SCREEN,
]);
// 2. 규칙 없으면 기존 로직 (하위호환)
if ($number === null) {
return $this->generateLegacy($productCategory);
}
return $number;
}
// 기존 로직을 별도 메서드로 분리
private function generateLegacy(?string $productCategory = null): string
{
// 현재 generate() 코드 그대로 이동
$tenantId = $this->tenantId();
$prefix = match ($productCategory) {
Quote::CATEGORY_SCREEN => 'SC',
Quote::CATEGORY_STEEL => 'ST',
default => 'SC',
};
$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) {
$parts = explode('-', $lastQuote->quote_number);
if (count($parts) >= 4) {
$sequence = (int) end($parts) + 1;
}
}
$seqStr = str_pad((string) $sequence, 2, '0', STR_PAD_LEFT);
return "KD-{$prefix}-{$dateStr}-{$seqStr}";
}
// validate()도 규칙 기반이면 패턴 달라지므로 조정
public function validate(string $quoteNumber): bool
{
// 기존: /^KD-[A-Z]{2}-\d{6}-\d{2,}$/
// 규칙 기반이면 "KD-PR-260207-01" 도 유효 → 더 넓은 패턴
return (bool) preg_match('/^KD-.+-\d{6}-\d{2,}$/', $quoteNumber);
}
OrderService.php 수정
// ===== 변경 전 =====
private function generateOrderNo(int $tenantId): string
{
$prefix = 'ORD';
$date = now()->format('Ymd');
// ... LIKE 검색
return sprintf('%s%s%04d', $prefix, $date, $seq);
}
// ===== 변경 후 =====
private function generateOrderNo(int $tenantId, array $params = []): string
{
// 1. NumberingService로 규칙 기반 생성 시도
$number = app(NumberingService::class)
->setContext($tenantId, $this->apiUserId())
->generate('order', $params);
// 2. 규칙 없으면 기존 로직
if ($number === null) {
return $this->generateOrderNoLegacy($tenantId);
}
return $number;
}
private function generateOrderNoLegacy(int $tenantId): string
{
// 현재 generateOrderNo() 코드 그대로 이동
$prefix = 'ORD';
$date = now()->format('Ymd');
$lastNo = Order::withoutGlobalScopes()
->where('tenant_id', $tenantId)
->where('order_no', 'like', "{$prefix}{$date}%")
->orderByDesc('order_no')
->value('order_no');
$seq = $lastNo ? (int) substr($lastNo, -4) + 1 : 1;
return sprintf('%s%s%04d', $prefix, $date, $seq);
}
store() 호출부 수정 (Line 148)
// 변경 전
$data['order_no'] = $this->generateOrderNo($tenantId);
// 변경 후 (pair_code가 있으면 전달)
$data['order_no'] = $this->generateOrderNo($tenantId, [
'pair_code' => $data['pair_code'] ?? null,
]);
unset($data['pair_code']); // orders 테이블에 pair_code 컬럼 없으므로 제거
createFromQuote() 호출부 수정 (Line 459)
// 변경 전
$orderNo = $this->generateOrderNo($tenantId);
// 변경 후
$orderNo = $this->generateOrderNo($tenantId, [
'pair_code' => $data['pair_code'] ?? null,
]);
StoreOrderRequest.php 수정
public function rules(): array
{
return [
// ... 기존 규칙 그대로 ...
// 채번용 pair_code 추가 (수주 로트번호 생성에 사용)
'pair_code' => 'nullable|string|max:20',
];
}
4. 파일 생성/수정 목록
새로 생성 (6개)
| # | 파일 경로 | 용도 |
|---|---|---|
| 1 | api/database/migrations/2026_02_07_000001_create_numbering_rules_table.php |
채번 규칙 테이블 |
| 2 | api/database/migrations/2026_02_07_000002_create_numbering_sequences_table.php |
시퀀스 카운터 테이블 |
| 3 | api/app/Models/NumberingRule.php |
채번 규칙 모델 |
| 4 | api/app/Models/NumberingSequence.php |
시퀀스 카운터 모델 |
| 5 | api/app/Services/NumberingService.php |
통합 채번 서비스 |
| 6 | api/database/seeders/NumberingRuleSeeder.php |
tenant_id=287 시드 (견적/수주 규칙 2건) |
수정 (3개)
| # | 파일 경로 | 변경 내용 |
|---|---|---|
| 1 | api/app/Services/Quote/QuoteNumberService.php |
generate() → NumberingService 우선, 기존→generateLegacy() |
| 2 | api/app/Services/OrderService.php |
generateOrderNo() → NumberingService 우선, 기존→generateOrderNoLegacy(), store()/createFromQuote() 호출부에 pair_code 전달 |
| 3 | api/app/Http/Requests/Order/StoreOrderRequest.php |
pair_code 옵셔널 필드 추가 |
5. 작업 절차 (Step-by-Step)
Step 1: DB 마이그레이션 (Phase 1)
- 마이그레이션 파일 2개 생성 (numbering_rules, numbering_sequences)
php artisan migrate실행 (Docker 컨테이너 내)- NumberingRuleSeeder 생성 →
php artisan db:seed --class=NumberingRuleSeeder - DB에서 확인:
SELECT * FROM numbering_rules WHERE tenant_id = 287;→ 2건
Step 2: 모델 & 서비스 (Phase 2)
- NumberingRule 모델 생성 (BelongsToTenant,
$casts = ['pattern' => 'array']) - NumberingSequence 모델 생성
- NumberingService 생성 (섹션 3.4의 코드 기반)
- 단위 확인:
tinker에서 NumberingService 호출 테스트
Step 3: 견적번호 연동 (Phase 3)
- QuoteNumberService 수정 (섹션 3.5 코드 기반)
- 확인: tenant 287로 견적 생성 →
KD-PR-YYMMDD-01형식 확인 - 확인: 다른 테넌트로 견적 생성 →
KD-SC-YYMMDD-01(기존 로직)
Step 4: 수주 로트번호 연동 (Phase 4)
- StoreOrderRequest에
pair_code추가 - OrderService 수정 (섹션 3.5 코드 기반)
- 확인: tenant 287로 수주 생성 (pair_code=SS) →
KD-SS-YYMMDD-01 - 확인: pair_code 미전달 →
KD-SS-YYMMDD-01(default) - 확인: 다른 테넌트 →
ORD{YYYYMMDD}{NNNN}(기존 로직)
6. 검증 계획
6.1 테스트 케이스
| # | 시나리오 | 입력 | 예상 결과 | 상태 |
|---|---|---|---|---|
| 1 | 견적번호 (tenant 287, 첫 건) | quote, tenant=287 | KD-PR-{오늘}-01 |
⏳ |
| 2 | 견적번호 (tenant 287, 두 번째) | quote, tenant=287 | KD-PR-{오늘}-02 |
⏳ |
| 3 | 수주 로트 (pair_code=SS) | order, tenant=287, pair_code=SS | KD-SS-{오늘}-01 |
⏳ |
| 4 | 수주 로트 (pair_code=TS) | order, tenant=287, pair_code=TS | KD-TS-{오늘}-01 |
⏳ |
| 5 | 수주 로트 (pair_code 미전달) | order, tenant=287 | KD-SS-{오늘}-01 (default) |
⏳ |
| 6 | 규칙 없는 테넌트 견적 | quote, tenant=999 | KD-SC-{오늘}-01 (기존) |
⏳ |
| 7 | 규칙 없는 테넌트 수주 | order, tenant=999 | ORD{오늘날짜}0001 (기존) |
⏳ |
| 8 | 날짜 변경 후 리셋 | 다음 날 첫 요청 | 순번 01 리셋 | ⏳ |
6.2 성공 기준
| 기준 | 달성 | 비고 |
|---|---|---|
tenant 287 견적번호 KD-PR-YYMMDD-NN |
⏳ | |
tenant 287 수주 로트번호 KD-{pairCode}-YYMMDD-NN |
⏳ | |
| 규칙 없는 테넌트는 기존 번호 체계 유지 | ⏳ | 하위호환 |
| 번호 중복 불가 (atomic increment) | ⏳ |
6.3 확인 방법
# Docker 컨테이너 접속
docker exec -it sam-api bash
# 마이그레이션 실행
php artisan migrate
# 시더 실행
php artisan db:seed --class=NumberingRuleSeeder
# tinker로 테스트
php artisan tinker
# tenant 287 견적번호 테스트
>>> app()->instance('tenant_id', 287);
>>> app()->instance('api_user', 1);
>>> app(App\Services\NumberingService::class)->generate('quote');
// 예상: "KD-PR-260207-01"
# tenant 287 수주 로트번호 테스트
>>> app(App\Services\NumberingService::class)->generate('order', ['pair_code' => 'SS']);
// 예상: "KD-SS-260207-01"
# 규칙 없는 테넌트 (null 반환 확인)
>>> app()->instance('tenant_id', 999);
>>> app(App\Services\NumberingService::class)->generate('quote');
// 예상: null
7. 변경 이력
| 날짜 | 항목 | 변경 내용 | 파일 | 승인 |
|---|---|---|---|---|
| 2026-02-07 | - | 계획 문서 작성 | - | - |
8. 컨펌 대기 목록
| # | 항목 | 변경 내용 | 영향 범위 | 상태 |
|---|---|---|---|---|
| 1 | numbering_rules 테이블 생성 | 새 테이블 | DB | ⚠️ |
| 2 | numbering_sequences 테이블 생성 | 새 테이블 | DB | ⚠️ |
| 3 | QuoteNumberService 로직 변경 | NumberingService 우선 시도 | 견적 생성 API | ⚠️ |
| 4 | OrderService 로직 변경 | NumberingService 우선 시도 | 수주 생성 API | ⚠️ |
| 5 | StoreOrderRequest에 pair_code 추가 | 옵셔널 파라미터 | 수주 등록 API | ⚠️ |
이 문서는 /sc:plan 스킬로 생성되었습니다.