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:
1294
plans/db-trigger-audit-system-plan.md
Normal file
1294
plans/db-trigger-audit-system-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
1001
plans/intermediate-inspection-report-plan.md
Normal file
1001
plans/intermediate-inspection-report-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
407
plans/mng-numbering-rule-management-plan.md
Normal file
407
plans/mng-numbering-rule-management-plan.md
Normal 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 스킬로 생성되었습니다.*
|
||||||
172
plans/quote-order-sync-improvement-plan.md
Normal file
172
plans/quote-order-sync-improvement-plan.md
Normal 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 스킬로 생성되었습니다.*
|
||||||
838
plans/tenant-numbering-system-plan.md
Normal file
838
plans/tenant-numbering-system-plan.md
Normal 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 스킬로 생성되었습니다.*
|
||||||
Reference in New Issue
Block a user