Files
sam-docs/plans/tenant-numbering-system-plan.md
권혁성 0e9559fcd8 docs: 개발 계획 문서 5건 추가
- db-trigger-audit-system-plan.md
- intermediate-inspection-report-plan.md
- mng-numbering-rule-management-plan.md
- quote-order-sync-improvement-plan.md
- tenant-numbering-system-plan.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 10:02:47 +09:00

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-01KD-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 핵심 원칙

  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 StoreOrderRequestpair_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)

  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 확인 방법

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