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>
This commit is contained in:
2026-02-09 21:31:30 +09:00
parent 6b8b70a74f
commit 0e9559fcd8
5 changed files with 3712 additions and 0 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,407 @@
# MNG 채번 규칙 관리 UI 계획
> **작성일**: 2026-02-07
> **목적**: MNG 관리자 패널에서 테넌트별 채번 규칙(견적번호, 수주로트번호 등)을 CRUD 관리하는 UI 구현
> **기준 문서**: `docs/plans/tenant-numbering-system-plan.md` (API 채번 시스템)
> **상태**: 대기
---
## 1. 개요
### 1.1 배경
- API에 채번 규칙 시스템(`numbering_rules`, `numbering_sequences` 테이블)이 이미 구현됨
- 현재는 Seeder로만 규칙 등록 가능 → MNG에서 관리 UI가 필요
- 테넌트별로 견적, 수주, 원자재수입검사 등 문서유형별 채번 패턴을 설정/수정/삭제할 수 있어야 함
### 1.2 기준 원칙
```
- MNG 독립 모델 사용 (API 테이블 참조, 마이그레이션 생성 금지)
- MNG 기존 패턴 준수: Controller(Blade) + Api Controller(HTMX/JSON) + Service + FormRequest
- HTMX + Alpine.js로 SPA 유사 UX 제공
- JSON 패턴 편집을 위한 동적 폼 (세그먼트 추가/삭제/정렬)
```
### 1.3 변경 승인 정책
| 분류 | 예시 | 승인 |
|------|------|------|
| 즉시 가능 | MNG 모델/서비스/컨트롤러/뷰 생성 | 불필요 |
| 컨펌 필요 | routes/web.php 수정, 사이드바 메뉴 추가 | **필수** |
| 금지 | mng/database/migrations/ 파일 생성, API 테이블 구조 변경 | 별도 협의 |
---
## 2. 기술 스택 & 패턴
### 2.1 MNG 프로젝트 스택
| 항목 | 기술 |
|------|------|
| Backend | Laravel 12, PHP 8.4+ |
| Template | Blade (Plain Laravel, React/Vue 없음) |
| CSS | Tailwind CSS |
| 비동기 | HTMX 1.9 (페이지 새로고침 없이 테이블/폼 업데이트) |
| JS 프레임워크 | Alpine.js (동적 폼, 탭, 모달) |
| 인증 | Session 기반 (middleware: auth, tenant) |
| Multi-tenant | `session('selected_tenant_id')` 기반 |
### 2.2 참고 패턴 (부서관리 CRUD)
```
mng/app/Http/Controllers/DepartmentController.php ← Blade 렌더링만
mng/app/Http/Controllers/Api/Admin/DepartmentController.php ← CRUD 로직 (HTMX/JSON)
mng/app/Services/DepartmentService.php ← 비즈니스 로직
mng/app/Http/Requests/StoreDepartmentRequest.php ← 검증
mng/resources/views/departments/index.blade.php ← 목록 (HTMX 테이블)
mng/resources/views/departments/create.blade.php ← 생성 폼
mng/resources/views/departments/edit.blade.php ← 수정 폼
mng/resources/views/departments/partials/table.blade.php ← HTMX 파셜
```
---
## 3. 대상 범위
### 3.1 Phase 1: 백엔드 (Model + Service + Controller + FormRequest + Route)
| # | 작업 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 1.1 | NumberingRule 모델 생성 | ⏳ | API 테이블 참조, BelongsToTenant |
| 1.2 | NumberingRuleService 생성 | ⏳ | CRUD + 미리보기 |
| 1.3 | NumberingRuleController (페이지) 생성 | ⏳ | Blade 렌더링 |
| 1.4 | Api/Admin/NumberingRuleController 생성 | ⏳ | HTMX/JSON CRUD |
| 1.5 | FormRequest 생성 | ⏳ | JSON 패턴 검증 |
| 1.6 | routes/web.php 라우트 추가 | ⏳ | ⚠️ 컨펌 필요 |
### 3.2 Phase 2: 프론트엔드 (Blade Views)
| # | 작업 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 2.1 | index.blade.php (목록) | ⏳ | HTMX 테이블, 필터 |
| 2.2 | partials/table.blade.php | ⏳ | HTMX 파셜 |
| 2.3 | create.blade.php (생성) | ⏳ | Alpine.js 동적 세그먼트 폼 |
| 2.4 | edit.blade.php (수정) | ⏳ | 기존 패턴 로드 + 편집 |
| 2.5 | partials/segment-form.blade.php | ⏳ | 세그먼트 편집 컴포넌트 |
| 2.6 | partials/preview.blade.php | ⏳ | 실시간 미리보기 |
### 3.3 Phase 3: 통합 & 검증
| # | 작업 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 3.1 | 사이드바 메뉴 추가 | ⏳ | ⚠️ 컨펌 필요 |
| 3.2 | 기능 테스트 | ⏳ | CRUD + 미리보기 |
| 3.3 | 기존 시더 데이터 확인 | ⏳ | tenant_id=287 규칙 편집 가능 확인 |
---
## 4. 상세 설계
### 4.1 파일 구조 (생성할 파일 목록)
```
mng/
├── app/
│ ├── Models/
│ │ └── NumberingRule.php ← NEW
│ ├── Services/
│ │ └── NumberingRuleService.php ← NEW
│ ├── Http/
│ │ ├── Controllers/
│ │ │ ├── NumberingRuleController.php ← NEW (Blade)
│ │ │ └── Api/Admin/
│ │ │ └── NumberingRuleController.php ← NEW (HTMX/JSON)
│ │ └── Requests/
│ │ ├── StoreNumberingRuleRequest.php ← NEW
│ │ └── UpdateNumberingRuleRequest.php ← NEW
├── resources/views/
│ └── numbering/
│ ├── index.blade.php ← NEW
│ ├── create.blade.php ← NEW
│ ├── edit.blade.php ← NEW
│ └── partials/
│ ├── table.blade.php ← NEW
│ ├── segment-form.blade.php ← NEW
│ └── preview.blade.php ← NEW
└── routes/
└── web.php ← MODIFY (라우트 추가)
```
### 4.2 DB 스키마 (이미 존재, 참조용)
```sql
-- numbering_rules (API에서 생성 완료)
id, tenant_id, document_type(50), rule_name(100),
pattern(JSON), reset_period(20), sequence_padding(INT),
is_active(BOOL), created_by, updated_by, timestamps
UNIQUE(tenant_id, document_type)
-- numbering_sequences (API에서 생성 완료, 조회 전용)
id, tenant_id, document_type(50), scope_key(100),
period_key(20), last_sequence(INT), timestamps
UNIQUE(tenant_id, document_type, scope_key, period_key)
```
### 4.3 JSON 패턴 세그먼트 타입
| 타입 | 필드 | 예시 | 설명 |
|------|------|------|------|
| `static` | `value` | `{"type":"static","value":"KD"}` | 고정 문자열 |
| `separator` | `value` | `{"type":"separator","value":"-"}` | 구분자 |
| `date` | `format` | `{"type":"date","format":"ymd"}` | PHP date format |
| `param` | `key`, `default` | `{"type":"param","key":"pair_code","default":"SS"}` | 외부 파라미터 |
| `mapping` | `key`, `map`, `default` | `{"type":"mapping","key":"product_category","map":{"screen":"SC","steel":"ST"},"default":"SC"}` | 값 매핑 |
| `sequence` | (없음) | `{"type":"sequence"}` | 자동 순번 |
### 4.4 UI 설계
#### 목록 페이지 (`index.blade.php`)
```
┌──────────────────────────────────────────────────────────┐
│ 채번 규칙 관리 [+ 새 규칙] │
├──────────────────────────────────────────────────────────┤
│ [문서유형 ▼] [상태 ▼] [검색...] [검색 버튼] │
├──────────────────────────────────────────────────────────┤
│ # │ 규칙명 │ 문서유형 │ 패턴 미리보기 │ 상태 │ 작업 │
│ 1 │ 5130 견적번호 │ quote │ KD-PR-260207-01 │ 활성 │ 수정/삭제│
│ 2 │ 5130 수주 로트 │ order │ KD-SS-260207-01 │ 활성 │ 수정/삭제│
└──────────────────────────────────────────────────────────┘
```
#### 생성/수정 페이지 (`create.blade.php` / `edit.blade.php`)
```
┌──────────────────────────────────────────────────────────┐
│ 채번 규칙 생성 ← 목록으로 │
├──────────────────────────────────────────────────────────┤
│ ┌─ 기본 정보 ──────────────────────────────────────────┐ │
│ │ 규칙명: [________] 문서유형: [quote ▼] │ │
│ │ 리셋주기: [daily ▼] 시퀀스 자릿수: [2] │ │
│ │ 활성: [✓] │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ ┌─ 패턴 세그먼트 ─────────────────────────────────────┐ │
│ │ ① [static ▼] value: [KD] [✕] [↕] │ │
│ │ ② [separator ▼] value: [-] [✕] [↕] │ │
│ │ ③ [static ▼] value: [PR] [✕] [↕] │ │
│ │ ④ [separator ▼] value: [-] [✕] [↕] │ │
│ │ ⑤ [date ▼] format: [ymd] [✕] [↕] │ │
│ │ ⑥ [separator ▼] value: [-] [✕] [↕] │ │
│ │ ⑦ [sequence ▼] [✕] [↕] │ │
│ │ [+ 세그먼트 추가] │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ ┌─ 미리보기 ──────────────────────────────────────────┐ │
│ │ 생성 예시: KD-PR-260207-01 │ │
│ │ KD-PR-260207-02 │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ [취소] [저장] │
└──────────────────────────────────────────────────────────┘
```
### 4.5 핵심 구현 코드 (Blueprint)
#### Model (`mng/app/Models/NumberingRule.php`)
```php
<?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',
];
// 문서유형 상수
const DOC_QUOTE = 'quote';
const DOC_ORDER = 'order';
const DOC_SALE = 'sale';
const DOC_WORK_ORDER = 'work_order';
const DOC_MATERIAL_RECEIPT = 'material_receipt';
public static function documentTypes(): array
{
return [
self::DOC_QUOTE => '견적',
self::DOC_ORDER => '수주',
self::DOC_SALE => '매출',
self::DOC_WORK_ORDER => '작업지시',
self::DOC_MATERIAL_RECEIPT => '원자재수입검사',
];
}
public static function resetPeriods(): array
{
return [
'daily' => '일별',
'monthly' => '월별',
'yearly' => '연별',
'never' => '리셋안함',
];
}
/**
* 패턴 미리보기 문자열 생성 (실제 시퀀스 없이)
*/
public function getPreviewAttribute(): string
{
$result = '';
foreach ($this->pattern as $segment) {
$result .= match ($segment['type']) {
'static' => $segment['value'],
'separator' => $segment['value'],
'date' => now()->format($segment['format']),
'param' => $segment['default'] ?? '{' . $segment['key'] . '}',
'mapping' => $segment['default'] ?? '{' . $segment['key'] . '}',
'sequence' => str_pad('1', $this->sequence_padding, '0', STR_PAD_LEFT),
default => '',
};
}
return $result;
}
}
```
#### Service (`mng/app/Services/NumberingRuleService.php`)
```php
<?php
namespace App\Services;
use App\Models\NumberingRule;
use Illuminate\Pagination\LengthAwarePaginator;
class NumberingRuleService
{
public function getRules(array $filters = [], int $perPage = 20): LengthAwarePaginator
{
$tenantId = session('selected_tenant_id');
$query = NumberingRule::query();
if ($tenantId) {
$query->where('tenant_id', $tenantId);
}
if (!empty($filters['document_type'])) {
$query->where('document_type', $filters['document_type']);
}
if (isset($filters['is_active']) && $filters['is_active'] !== '') {
$query->where('is_active', (bool) $filters['is_active']);
}
if (!empty($filters['search'])) {
$query->where('rule_name', 'like', "%{$filters['search']}%");
}
return $query->orderBy('document_type')->paginate($perPage);
}
public function getRule(int $id): ?NumberingRule { ... }
public function createRule(array $data): NumberingRule { ... }
public function updateRule(int $id, array $data): bool { ... }
public function deleteRule(int $id): bool { ... }
}
```
#### 세그먼트 동적 폼 (Alpine.js)
```javascript
// create.blade.php 내 Alpine.js 컴포넌트
Alpine.data('patternEditor', () => ({
segments: [],
segmentTypes: [
{ value: 'static', label: '고정 문자열' },
{ value: 'separator', label: '구분자' },
{ value: 'date', label: '날짜' },
{ value: 'param', label: '외부 파라미터' },
{ value: 'mapping', label: '값 매핑' },
{ value: 'sequence', label: '자동 순번' },
],
dateFormats: [
{ value: 'ymd', label: 'YYMMDD (260207)' },
{ value: 'Ymd', label: 'YYYYMMDD (20260207)' },
{ value: 'Ym', label: 'YYYYMM (202602)' },
{ value: 'Y', label: 'YYYY (2026)' },
],
addSegment() {
this.segments.push({ type: 'static', value: '' });
},
removeSegment(index) {
this.segments.splice(index, 1);
},
moveSegment(from, to) { ... },
get preview() {
return this.segments.map(seg => {
switch(seg.type) {
case 'static': return seg.value || '?';
case 'separator': return seg.value || '-';
case 'date': return formatDate(seg.format || 'ymd');
case 'param': return seg.default || `{${seg.key || '?'}}`;
case 'mapping': return seg.default || `{${seg.key || '?'}}`;
case 'sequence': return '01';
default: return '';
}
}).join('');
}
}));
```
---
## 5. 구현 순서 & 예상 작업량
| Phase | 작업 | 파일 수 | 예상 |
|-------|------|--------|------|
| 1 | 백엔드 (Model, Service, Controller, FormRequest, Route) | 6개 생성 + 1개 수정 | 중 |
| 2 | 프론트엔드 (Blade Views 6개) | 6개 생성 | 대 (Alpine.js 동적 폼) |
| 3 | 통합 & 검증 (메뉴, 테스트) | 1개 수정 | 소 |
**핵심 난이도**: Phase 2의 세그먼트 동적 폼 (Alpine.js로 JSON 배열 편집 + 실시간 미리보기)
---
## 6. 검증 결과
### 6.1 테스트 시나리오
| 입력 | 예상 결과 | 상태 |
|------|----------|:----:|
| 목록 진입 | tenant_id=287 규칙 2건 표시 | ⏳ |
| 견적 규칙 수정 → 저장 | pattern JSON 업데이트, 미리보기 변경 | ⏳ |
| 새 규칙 생성 (material_receipt) | 규칙 3건으로 증가 | ⏳ |
| 세그먼트 추가/삭제/순서변경 | Alpine.js 동적 폼 동작 | ⏳ |
| 미리보기 버튼 | 실시간 번호 예시 표시 | ⏳ |
| 규칙 삭제 | soft delete 또는 hard delete | ⏳ |
| 중복 document_type 생성 시도 | 유니크 제약 에러 표시 | ⏳ |
### 6.2 성공 기준
| 기준 | 달성 | 비고 |
|------|------|------|
| 규칙 CRUD 정상 동작 | ⏳ | 생성/조회/수정/삭제 |
| 세그먼트 동적 편집 | ⏳ | 추가/삭제/순서변경 |
| 실시간 미리보기 | ⏳ | 패턴 변경 시 즉시 반영 |
| 기존 API 채번 로직과 호환 | ⏳ | MNG에서 수정한 규칙이 API에서 정상 작동 |
| MNG 기존 패턴 준수 | ⏳ | HTMX + Alpine.js + Tailwind |
---
## 7. 참고 문서
- **채번 시스템 설계**: `docs/plans/tenant-numbering-system-plan.md`
- **MNG CRUD 패턴**: `mng/app/Http/Controllers/DepartmentController.php` + `Api/Admin/DepartmentController.php`
- **Alpine.js 동적 폼 참고**: `mng/resources/views/quote-formulas/edit.blade.php` (탭 + 동적 아이템)
- **HTMX 테이블 참고**: `mng/resources/views/departments/partials/table.blade.php`
---
*이 문서는 /sc:plan 스킬로 생성되었습니다.*

View File

@@ -0,0 +1,172 @@
# 견적 수정 → 기존 수주 업데이트 연동 개선 계획
> **작성일**: 2026-02-07
> **목적**: 견적 수정 시 연결된 기존 수주를 자동 업데이트하고, "수주등록" 대신 "수주 보기" 버튼 표시
> **상태**: 🔄 진행중
---
## 📍 현재 진행 상태
| 항목 | 내용 |
|------|------|
| **마지막 완료 작업** | 분석 완료 |
| **다음 작업** | Phase 1.1 - API 수정 |
| **진행률** | 0/4 (0%) |
| **마지막 업데이트** | 2026-02-07 |
---
## 1. 개요
### 1.1 배경
사용자가 수주(33) 상세 → 견적(41) 수정 → 저장 후 "수주등록" 버튼이 표시되어 클릭 시 새 수주가 생성됨.
**기대 동작**: 견적 수정 → 기존 수주(33)에 자동 반영 + "수주 보기" 버튼으로 기존 수주 이동
### 1.2 근본 원인
- `Quote.order_id`가 null인 상태에서 `Order.quote_id`만 설정된 경우 발생
- `QuoteService::update()``quote->order_id` 기준으로만 동기화 트리거
- `QuoteService::show()`는 역방향 참조(`Order.quote_id`)를 고려하지 않음
- 결과: 프론트에서 `orderId = null` → "수주등록" 버튼 표시
### 1.3 기존 구현 현황 (이미 구현된 부분)
| 기능 | 상태 | 위치 |
|------|------|------|
| `OrderService::syncFromQuote()` | ✅ 완전 구현 | `api/app/Services/OrderService.php:561-746` |
| `QuoteService::update()` → syncFromQuote 호출 | ✅ 구현 (order_id 기준) | `api/app/Services/Quote/QuoteService.php:448` |
| QuoteFooterBar "수주 보기" / "수주등록" 분기 | ✅ 구현 (orderId 기준) | `react/src/components/quotes/QuoteFooterBar.tsx:209` |
| `transformApiToV2`: order_id → orderId 매핑 | ✅ 구현 | `react/src/components/quotes/types.ts:275,1008` |
### 1.4 변경 승인 정책
| 분류 | 예시 | 승인 |
|------|------|------|
| ✅ 즉시 가능 | 기존 메서드에 조건 추가 | 불필요 |
| ⚠️ 컨펌 필요 | API 로직 변경, 데이터 보정 | **필수** |
---
## 2. 대상 범위
### 2.1 Phase 1: API 수정 (백엔드)
| # | 작업 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 1.1 | QuoteService::show() - 역방향 order 참조 보정 | ⏳ | order_id null이면 orders() 관계로 탐색 |
| 1.2 | QuoteService::update() - 역방향 sync 트리거 | ⏳ | order_id null이어도 orders() 있으면 동기화 |
### 2.2 Phase 2: 프론트엔드 확인
| # | 작업 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 2.1 | 견적 수정 후 view 모드 전환 시 데이터 갱신 확인 | ⏳ | orderId가 정상 반영되는지 |
| 2.2 | "수주 보기" 버튼 동작 확인 | ⏳ | 기존 수주로 정상 이동하는지 |
---
## 3. 작업 절차
### 3.1 상세 변경 사항
#### 1.1 QuoteService::show() 수정
**파일**: `api/app/Services/Quote/QuoteService.php` (line 168-187)
**현재 동작**: Quote 모델을 그대로 반환 (order_id가 null이면 null 그대로)
**변경**: quote.order_id가 null인데 Order.quote_id로 연결된 수주가 있으면 order_id를 보정
```php
// show() 메서드 내, return $quote; 직전에 추가
if (!$quote->order_id) {
$linkedOrder = \App\Models\Orders\Order::where('quote_id', $quote->id)
->where('tenant_id', $tenantId)
->first();
if ($linkedOrder) {
// DB에도 반영 (데이터 정합성 복구)
$quote->update(['order_id' => $linkedOrder->id]);
$quote->refresh();
}
}
```
#### 1.2 QuoteService::update() 동기화 트리거 확장
**파일**: `api/app/Services/Quote/QuoteService.php` (line 447-460)
**현재 동작**: `if ($quote->order_id)` 일 때만 syncFromQuote 호출
**변경**: order_id가 null이어도 orders() 관계로 연결된 수주가 있으면 동기화 실행
```php
// 기존 코드 (line 448)
if ($quote->order_id) {
// 변경 후
$orderId = $quote->order_id;
if (!$orderId) {
// 역방향 참조로 연결된 수주 찾기
$linkedOrder = \App\Models\Orders\Order::where('quote_id', $quote->id)
->where('tenant_id', $tenantId)
->first();
if ($linkedOrder) {
$quote->update(['order_id' => $linkedOrder->id]);
$orderId = $linkedOrder->id;
}
}
if ($orderId) {
```
### 3.2 영향 범위
| 영향 받는 부분 | 변경 여부 | 설명 |
|---------------|----------|------|
| QuoteService::show() | ✅ 수정 | 역방향 참조 보정 |
| QuoteService::update() | ✅ 수정 | sync 트리거 확장 |
| OrderService::syncFromQuote() | ❌ 변경 없음 | 이미 완전 구현 |
| QuoteFooterBar.tsx | ❌ 변경 없음 | orderId 기준 분기 이미 구현 |
| QuoteRegistrationV2.tsx | ❌ 변경 없음 | orderId 전달 이미 구현 |
| types.ts (transformApiToV2) | ❌ 변경 없음 | order_id → orderId 매핑 이미 구현 |
---
## 4. 검증 방법
### 4.1 테스트 시나리오
| # | 시나리오 | 예상 결과 |
|---|---------|----------|
| 1 | 수주(33) 상세 → 견적(41) 수정 → 저장 | 기존 수주(33) 품목 자동 업데이트 |
| 2 | 견적(41) 상세 view 모드 진입 | "수주 보기" 버튼 표시 (수주등록 아님) |
| 3 | "수주 보기" 버튼 클릭 | 수주(33) 상세 페이지로 이동 |
| 4 | 견적 수정 후 금액 변경 | 수주 총금액도 동기화 |
### 4.2 성공 기준
- 견적 수정 시 연결된 수주가 자동 업데이트됨
- "수주 보기" 버튼이 정상 표시됨
- 기존 수주로 정상 네비게이션됨
- 기존 convertToOrder() 플로우에 영향 없음
---
## 5. 참고 파일
| 파일 | 역할 |
|------|------|
| `api/app/Services/Quote/QuoteService.php` | 견적 서비스 (show, update, convertToOrder) |
| `api/app/Services/OrderService.php` | 수주 서비스 (syncFromQuote) |
| `api/app/Models/Quote/Quote.php` | 견적 모델 (orders() 관계) |
| `api/app/Models/Orders/Order.php` | 수주 모델 (quote() 관계) |
| `react/src/components/quotes/QuoteFooterBar.tsx` | 견적 푸터 바 (버튼 분기) |
| `react/src/components/quotes/QuoteRegistrationV2.tsx` | 견적 등록/수정 V2 |
| `react/src/components/quotes/types.ts` | 타입 및 API→V2 변환 |
| `react/src/app/[locale]/(protected)/sales/quote-management/[id]/page.tsx` | 견적 상세 페이지 |
---
*이 문서는 /sc:plan 스킬로 생성되었습니다.*

View File

@@ -0,0 +1,838 @@
# 테넌트별 채번 규칙 시스템 계획
> **작성일**: 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 스킬로 생성되었습니다.*