# 테넌트별 채번 규칙 시스템 계획

> **작성일**: 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`
```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`
```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`
```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
<?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)
```php
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)
```php
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)
```php
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`
```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`
```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 핵심 원칙

1. 규칙 없는 테넌트는 기존 하드코딩 로직 그대로 사용 (하위호환)
2. `numbering_rules` 테이블에 규칙이 있으면 규칙 기반 채번
3. 시퀀스는 DB 기반으로 관리 (파일 기반 X, LIKE 검색 X)
4. 동시성 안전 (MySQL UPSERT로 Atomic Update)
5. 향후 채번관리 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 (채번 규칙)

```sql
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 (시퀀스 카운터)

```sql
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 세그먼트 상세

```json
{
    "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}`

```json
{
    "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}`

```json
{
    "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
<?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 수정

```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 수정

```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)
```php
// 변경 전
$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)
```php
// 변경 전
$orderNo = $this->generateOrderNo($tenantId);

// 변경 후
$orderNo = $this->generateOrderNo($tenantId, [
    'pair_code' => $data['pair_code'] ?? null,
]);
```

#### StoreOrderRequest.php 수정
```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)

1. 마이그레이션 파일 2개 생성 (numbering_rules, numbering_sequences)
2. `php artisan migrate` 실행 (Docker 컨테이너 내)
3. NumberingRuleSeeder 생성 → `php artisan db:seed --class=NumberingRuleSeeder`
4. DB에서 확인: `SELECT * FROM numbering_rules WHERE tenant_id = 287;` → 2건

### Step 2: 모델 & 서비스 (Phase 2)

1. NumberingRule 모델 생성 (BelongsToTenant, `$casts = ['pattern' => 'array']`)
2. NumberingSequence 모델 생성
3. NumberingService 생성 (섹션 3.4의 코드 기반)
4. 단위 확인: `tinker`에서 NumberingService 호출 테스트

### Step 3: 견적번호 연동 (Phase 3)

1. QuoteNumberService 수정 (섹션 3.5 코드 기반)
2. 확인: tenant 287로 견적 생성 → `KD-PR-YYMMDD-01` 형식 확인
3. 확인: 다른 테넌트로 견적 생성 → `KD-SC-YYMMDD-01` (기존 로직)

### Step 4: 수주 로트번호 연동 (Phase 4)

1. StoreOrderRequest에 `pair_code` 추가
2. OrderService 수정 (섹션 3.5 코드 기반)
3. 확인: tenant 287로 수주 생성 (pair_code=SS) → `KD-SS-YYMMDD-01`
4. 확인: pair_code 미전달 → `KD-SS-YYMMDD-01` (default)
5. 확인: 다른 테넌트 → `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 확인 방법

```bash
# 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 스킬로 생성되었습니다.*