Files
sam-docs/dev/dev_plans/mng-numbering-rule-management-plan.md
권혁성 db63fcff85 refactor: [docs] 팀별 폴더 구조 재편 (공유/개발/프론트/기획)
- 개발팀 전용 폴더 dev/ 생성 (standards, guides, quickstart, changes, deploys, data, history, dev_plans 이동)
- 프론트엔드 전용 폴더 frontend/ 생성 (api/ → frontend/api-specs/)
- 기획팀 폴더 requests/ 생성
- plans/ → dev/dev_plans/ 이름 변경
- README.md 신규 (사람용 안내), INDEX.md 재작성 (Claude Code용)
- resources.md 신규 (노션 링크용, assets/brochure 이관 예정)
- CURRENT_WORKS.md 삭제, TODO.md → dev/ 이동
- 전체 참조 경로 업데이트

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 16:46:03 +09:00

1836 lines
71 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# MNG 채번 규칙 관리 UI 계획
> **작성일**: 2026-02-07
> **보완일**: 2026-02-10 (Alpine.js → Vanilla JS 전환, API 라우트 경로 수정)
> **목적**: MNG 관리자 패널에서 테넌트별 채번 규칙(견적번호, 수주로트번호 등)을 CRUD 관리하는 UI 구현
> **기준 문서**: `docs/dev_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 + Vanilla JS로 SPA 유사 UX 제공 (Alpine.js 사용 금지 - MNG 기술 표준)
- 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 | Vanilla JS (Alpine.js 사용 금지 - MNG 기술 표준) |
| 인증 | Session 기반 (middleware: auth, hq.member, password.changed) |
| Multi-tenant | `session('selected_tenant_id')` 기반 |
### 2.2 MNG 아키텍처 패턴
#### Controller 이중 구조
```
Blade Controller (뷰 렌더링만) Api/Admin Controller (데이터 처리)
├─ index() → view 반환 ├─ index() → HTMX HTML 또는 JSON
├─ create() → view 반환 ├─ store() → JSON (생성)
├─ edit($id) → view 반환 ├─ update($id) → JSON (수정)
├─ destroy($id) → JSON (삭제)
└─ preview() → JSON (미리보기)
```
#### HTMX 요청/응답 플로우
```
[브라우저]
↓ HTMX 요청 (HX-Request 헤더 포함)
[Api/Admin Controller]
↓ FormRequest 검증 → Service 호출
[Service]
↓ session('selected_tenant_id')로 테넌트 격리
↓ 비즈니스 로직 수행
[Controller 응답]
├─ HX-Request? → view('partials/table', $data) (HTML 파셜)
└─ 일반 요청? → response()->json([...])
[브라우저]
└─ HTMX가 #target 영역에 HTML 교체 (페이지 새로고침 없음)
```
### 2.3 참고 패턴 (부서관리 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 생성 (Store + Update) | ⏳ | 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 (생성) | ⏳ | Vanilla 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 | 사이드바 메뉴 추가 | ⏳ | ⚠️ 컨펌 필요 (DB `menus` 테이블에 INSERT) |
| 3.2 | 기능 테스트 | ⏳ | CRUD + 미리보기 |
| 3.3 | 기존 시더 데이터 확인 | ⏳ | tenant_id=287 규칙 편집 가능 확인 |
---
## 4. DB 스키마 (API에서 생성 완료, 참조용)
### 4.1 numbering_rules 테이블
```sql
-- 마이그레이션: api/database/migrations/2026_02_07_200000_create_numbering_rules_table.php
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) DEFAULT 'daily' COMMENT '시퀀스 리셋 주기: daily, monthly, yearly, never',
sequence_padding INT DEFAULT 2 COMMENT '시퀀스 자릿수 (2→01,02 / 3→001,002)',
is_active TINYINT(1) 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_numbering_rules_tenant (tenant_id)
);
-- ⚠️ SoftDeletes 없음 → Hard Delete
-- ⚠️ UNIQUE(tenant_id, document_type) → 테넌트당 문서유형 1개 규칙만 가능
```
### 4.2 numbering_sequences 테이블 (MNG에서 조회 전용)
```sql
-- 마이그레이션: api/database/migrations/2026_02_07_200001_create_numbering_sequences_table.php
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) DEFAULT '' COMMENT '범위 키 (pair_code 등 카테고리 구분)',
period_key VARCHAR(20) NOT NULL COMMENT '기간 키: 260207(daily), 202602(monthly), 2026(yearly)',
last_sequence INT UNSIGNED DEFAULT 0 COMMENT '마지막 시퀀스 번호',
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL,
UNIQUE KEY uq_numbering_sequence (tenant_id, document_type, scope_key, period_key)
);
-- ⚠️ MNG에서는 읽기 전용 (시퀀스 증가는 API의 NumberingService만 수행)
-- ⚠️ MySQL UPSERT(INSERT...ON DUPLICATE KEY UPDATE)로 원자적 증가
```
### 4.3 기존 시더 데이터 (tenant_id=287)
```php
// api/database/seeders/NumberingRuleSeeder.php
// 규칙 1: 견적번호 - KD-PR-{YYMMDD}-{NN}
[
'tenant_id' => 287,
'document_type' => 'quote',
'rule_name' => '5130 견적번호',
'pattern' => [
['type' => 'static', 'value' => 'KD'],
['type' => 'separator', 'value' => '-'],
['type' => 'static', 'value' => 'PR'],
['type' => 'separator', 'value' => '-'],
['type' => 'date', 'format' => 'ymd'],
['type' => 'separator', 'value' => '-'],
['type' => 'sequence'],
],
'reset_period' => 'daily',
'sequence_padding' => 2,
// 결과: KD-PR-260207-01, KD-PR-260207-02, ...
]
// 규칙 2: 수주 로트번호 - KD-{pairCode}-{YYMMDD}-{NN}
[
'tenant_id' => 287,
'document_type' => 'order',
'rule_name' => '5130 수주 로트번호',
'pattern' => [
['type' => 'static', 'value' => 'KD'],
['type' => 'separator', 'value' => '-'],
['type' => 'param', 'key' => 'pair_code', 'default' => 'SS'],
['type' => 'separator', 'value' => '-'],
['type' => 'date', 'format' => 'ymd'],
['type' => 'separator', 'value' => '-'],
['type' => 'sequence'],
],
'reset_period' => 'daily',
'sequence_padding' => 2,
// 결과: KD-SS-260207-01, KD-TS-260207-01, ...
// scope_key = pair_code 값 (SS, TS 등) → pair_code별 독립 시퀀스
]
```
---
## 5. JSON 패턴 세그먼트 타입 상세
### 5.1 세그먼트 타입 정의
| 타입 | 필수 필드 | 선택 필드 | 설명 |
|------|-----------|-----------|------|
| `static` | `value` | - | 고정 문자열 (예: "KD", "PR") |
| `separator` | `value` | - | 구분자 (예: "-", "/", ".") |
| `date` | `format` | - | PHP date format (아래 표 참고) |
| `param` | `key` | `default` | 외부 파라미터 값 사용 |
| `mapping` | `key`, `map` | `default` | 파라미터 값을 코드로 변환 |
| `sequence` | - | - | 자동 순번 (reset_period에 따라 리셋) |
### 5.2 date format 옵션
| format | 출력 | 예시 (2026-02-07) |
|--------|------|-------------------|
| `ymd` | YYMMDD | 260207 |
| `Ymd` | YYYYMMDD | 20260207 |
| `Ym` | YYYYMM | 202602 |
| `ym` | YYMM | 2602 |
| `Y` | YYYY | 2026 |
| `y` | YY | 26 |
### 5.3 JSON 예시
```json
// 견적: KD-PR-260207-01
[
{"type": "static", "value": "KD"},
{"type": "separator", "value": "-"},
{"type": "static", "value": "PR"},
{"type": "separator", "value": "-"},
{"type": "date", "format": "ymd"},
{"type": "separator", "value": "-"},
{"type": "sequence"}
]
// 수주: KD-SS-260207-01 (pair_code에 따라 SS, TS 등 변동)
[
{"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"}
]
// 매핑 예시: product_category → SC/ST 코드 변환
[
{"type": "static", "value": "SAM"},
{"type": "separator", "value": "-"},
{"type": "mapping", "key": "product_category", "map": {"screen": "SC", "steel": "ST"}, "default": "XX"},
{"type": "separator", "value": "-"},
{"type": "date", "format": "Ym"},
{"type": "separator", "value": "-"},
{"type": "sequence"}
]
```
### 5.4 API NumberingService의 세그먼트 처리 로직 (참조)
```php
// api/app/Services/NumberingService.php - generate() 메서드 핵심 로직
// MNG의 미리보기(preview) 구현 시 이 로직과 동일하게 처리해야 함
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':
$periodKey = match ($rule->reset_period) {
'daily' => now()->format('ymd'),
'monthly' => now()->format('Ym'),
'yearly' => now()->format('Y'),
'never' => 'all',
};
$nextSeq = $this->nextSequence($tenantId, $documentType, $scopeKey, $periodKey);
$result .= str_pad((string) $nextSeq, $rule->sequence_padding, '0', STR_PAD_LEFT);
break;
}
}
```
---
## 6. 상세 설계
### 6.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
└── routes/
├── web.php ← MODIFY (Blade 라우트 추가)
└── api.php ← MODIFY (API/HTMX 라우트 추가)
```
### 6.2 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',
];
// ⚠️ SoftDeletes 없음 (DB에 deleted_at 컬럼 없음) → Hard Delete
// 문서유형 상수
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
{
if (empty($this->pattern) || !is_array($this->pattern)) {
return '';
}
$result = '';
foreach ($this->pattern as $segment) {
$result .= match ($segment['type'] ?? '') {
'static' => $segment['value'] ?? '',
'separator' => $segment['value'] ?? '',
'date' => now()->format($segment['format'] ?? 'ymd'),
'param' => $segment['default'] ?? '{' . ($segment['key'] ?? '?') . '}',
'mapping' => $segment['default'] ?? '{' . ($segment['key'] ?? '?') . '}',
'sequence' => str_pad('1', $this->sequence_padding, '0', STR_PAD_LEFT),
default => '',
};
}
return $result;
}
/**
* 문서유형 한글명
*/
public function getDocumentTypeLabelAttribute(): string
{
return self::documentTypes()[$this->document_type] ?? $this->document_type;
}
/**
* 리셋주기 한글명
*/
public function getResetPeriodLabelAttribute(): string
{
return self::resetPeriods()[$this->reset_period] ?? $this->reset_period;
}
}
```
### 6.3 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
{
$tenantId = session('selected_tenant_id');
$query = NumberingRule::query();
if ($tenantId) {
$query->where('tenant_id', $tenantId);
}
return $query->find($id);
}
/**
* 규칙 생성
*/
public function createRule(array $data): NumberingRule
{
$tenantId = session('selected_tenant_id');
return NumberingRule::create([
'tenant_id' => $tenantId,
'document_type' => $data['document_type'],
'rule_name' => $data['rule_name'] ?? null,
'pattern' => $data['pattern'], // JSON array (FormRequest에서 검증 완료)
'reset_period' => $data['reset_period'] ?? 'daily',
'sequence_padding' => $data['sequence_padding'] ?? 2,
'is_active' => $data['is_active'] ?? true,
'created_by' => auth()->id(),
]);
}
/**
* 규칙 수정
*/
public function updateRule(int $id, array $data): bool
{
$rule = $this->getRule($id);
if (! $rule) {
return false;
}
return $rule->update([
'document_type' => $data['document_type'] ?? $rule->document_type,
'rule_name' => $data['rule_name'] ?? $rule->rule_name,
'pattern' => $data['pattern'] ?? $rule->pattern,
'reset_period' => $data['reset_period'] ?? $rule->reset_period,
'sequence_padding' => $data['sequence_padding'] ?? $rule->sequence_padding,
'is_active' => $data['is_active'] ?? $rule->is_active,
'updated_by' => auth()->id(),
]);
}
/**
* 규칙 삭제 (Hard Delete - SoftDeletes 없음)
* ⚠️ 삭제 시 해당 테넌트의 채번이 레거시 로직으로 폴백됨
*/
public function deleteRule(int $id): bool
{
$rule = $this->getRule($id);
if (! $rule) {
return false;
}
return $rule->delete();
}
/**
* 특정 테넌트의 이미 사용 중인 document_type 목록
* (생성 시 중복 방지 안내용)
*/
public function getUsedDocumentTypes(?int $excludeId = null): array
{
$tenantId = session('selected_tenant_id');
$query = NumberingRule::where('tenant_id', $tenantId);
if ($excludeId) {
$query->where('id', '!=', $excludeId);
}
return $query->pluck('document_type')->toArray();
}
/**
* 미리보기 생성 (세그먼트 배열 → 예시 번호 문자열)
* 클라이언트 JS 미리보기의 서버사이드 보완용
*/
public function generatePreview(array $pattern, int $sequencePadding = 2): string
{
$result = '';
foreach ($pattern as $segment) {
$result .= match ($segment['type'] ?? '') {
'static' => $segment['value'] ?? '',
'separator' => $segment['value'] ?? '',
'date' => now()->format($segment['format'] ?? 'ymd'),
'param' => $segment['default'] ?? '{' . ($segment['key'] ?? '?') . '}',
'mapping' => $segment['default'] ?? '{' . ($segment['key'] ?? '?') . '}',
'sequence' => str_pad('1', $sequencePadding, '0', STR_PAD_LEFT),
default => '',
};
}
return $result;
}
}
```
### 6.4 Blade Controller (`mng/app/Http/Controllers/NumberingRuleController.php`)
```php
<?php
namespace App\Http\Controllers;
use App\Models\NumberingRule;
use App\Services\NumberingRuleService;
use Illuminate\Http\Request;
use Illuminate\View\View;
class NumberingRuleController extends Controller
{
public function __construct(
private readonly NumberingRuleService $numberingRuleService
) {}
/**
* 목록 페이지 (데이터는 HTMX로 로드)
*/
public function index(Request $request): View
{
return view('numbering.index', [
'documentTypes' => NumberingRule::documentTypes(),
]);
}
/**
* 생성 폼
*/
public function create(): View
{
$usedTypes = $this->numberingRuleService->getUsedDocumentTypes();
return view('numbering.create', [
'documentTypes' => NumberingRule::documentTypes(),
'resetPeriods' => NumberingRule::resetPeriods(),
'usedDocumentTypes' => $usedTypes,
]);
}
/**
* 수정 폼
*/
public function edit(int $id): View
{
$rule = $this->numberingRuleService->getRule($id);
if (! $rule) {
abort(404, '채번 규칙을 찾을 수 없습니다.');
}
$usedTypes = $this->numberingRuleService->getUsedDocumentTypes($id);
return view('numbering.edit', [
'rule' => $rule,
'documentTypes' => NumberingRule::documentTypes(),
'resetPeriods' => NumberingRule::resetPeriods(),
'usedDocumentTypes' => $usedTypes,
]);
}
}
```
### 6.5 API Controller (`mng/app/Http/Controllers/Api/Admin/NumberingRuleController.php`)
```php
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreNumberingRuleRequest;
use App\Http\Requests\UpdateNumberingRuleRequest;
use App\Services\NumberingRuleService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class NumberingRuleController extends Controller
{
public function __construct(
private readonly NumberingRuleService $numberingRuleService
) {}
/**
* 목록 조회 (HTMX → HTML 파셜, 일반 → JSON)
*/
public function index(Request $request): JsonResponse|\Illuminate\View\View
{
$rules = $this->numberingRuleService->getRules(
$request->all(),
$request->integer('per_page', 20)
);
if ($request->header('HX-Request')) {
return view('numbering.partials.table', compact('rules'));
}
return response()->json([
'success' => true,
'data' => $rules->items(),
'meta' => [
'current_page' => $rules->currentPage(),
'last_page' => $rules->lastPage(),
'per_page' => $rules->perPage(),
'total' => $rules->total(),
],
]);
}
/**
* 생성
*/
public function store(StoreNumberingRuleRequest $request): JsonResponse
{
$rule = $this->numberingRuleService->createRule($request->validated());
if ($request->header('HX-Request')) {
return response()->json([
'success' => true,
'message' => '채번 규칙이 생성되었습니다.',
'redirect' => route('numbering-rules.index'),
]);
}
return response()->json([
'success' => true,
'message' => '채번 규칙이 생성되었습니다.',
'data' => $rule,
], 201);
}
/**
* 수정
*/
public function update(UpdateNumberingRuleRequest $request, int $id): JsonResponse
{
$result = $this->numberingRuleService->updateRule($id, $request->validated());
if (! $result) {
return response()->json([
'success' => false,
'message' => '채번 규칙 수정에 실패했습니다.',
], 400);
}
if ($request->header('HX-Request')) {
return response()->json([
'success' => true,
'message' => '채번 규칙이 수정되었습니다.',
'redirect' => route('numbering-rules.index'),
]);
}
return response()->json([
'success' => true,
'message' => '채번 규칙이 수정되었습니다.',
]);
}
/**
* 삭제 (Hard Delete)
*/
public function destroy(Request $request, int $id): JsonResponse
{
$result = $this->numberingRuleService->deleteRule($id);
if (! $result) {
return response()->json([
'success' => false,
'message' => '채번 규칙 삭제에 실패했습니다.',
], 400);
}
if ($request->header('HX-Request')) {
return response()->json([
'success' => true,
'message' => '채번 규칙이 삭제되었습니다.',
'action' => 'remove',
]);
}
return response()->json([
'success' => true,
'message' => '채번 규칙이 삭제되었습니다.',
]);
}
/**
* 미리보기 (패턴 JSON → 예시 번호)
* 클라이언트 JS 실시간 미리보기 외에, 서버사이드 검증용
*/
public function preview(Request $request): JsonResponse
{
$pattern = $request->input('pattern', []);
$sequencePadding = $request->integer('sequence_padding', 2);
$preview = $this->numberingRuleService->generatePreview($pattern, $sequencePadding);
return response()->json([
'success' => true,
'preview' => $preview,
]);
}
}
```
### 6.6 FormRequest - Store (`mng/app/Http/Requests/StoreNumberingRuleRequest.php`)
```php
<?php
namespace App\Http\Requests;
use App\Models\NumberingRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreNumberingRuleRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
$tenantId = session('selected_tenant_id');
$validTypes = array_keys(NumberingRule::documentTypes());
$validResets = array_keys(NumberingRule::resetPeriods());
return [
'document_type' => [
'required',
'string',
Rule::in($validTypes),
Rule::unique('numbering_rules', 'document_type')
->where('tenant_id', $tenantId),
],
'rule_name' => 'nullable|string|max:100',
'reset_period' => ['required', 'string', Rule::in($validResets)],
'sequence_padding' => 'required|integer|min:1|max:10',
'is_active' => 'nullable|boolean',
// JSON 패턴 검증
'pattern' => 'required|array|min:1',
'pattern.*.type' => ['required', 'string', Rule::in([
'static', 'separator', 'date', 'param', 'mapping', 'sequence',
])],
// static, separator: value 필수
'pattern.*.value' => 'required_if:pattern.*.type,static|required_if:pattern.*.type,separator|nullable|string|max:50',
// date: format 필수
'pattern.*.format' => 'required_if:pattern.*.type,date|nullable|string|max:20',
// param: key 필수
'pattern.*.key' => 'required_if:pattern.*.type,param|required_if:pattern.*.type,mapping|nullable|string|max:50',
// param, mapping: default 선택
'pattern.*.default' => 'nullable|string|max:50',
// mapping: map 필수 (연관 배열)
'pattern.*.map' => 'required_if:pattern.*.type,mapping|nullable|array',
'pattern.*.map.*' => 'nullable|string|max:50',
];
}
public function attributes(): array
{
return [
'document_type' => '문서유형',
'rule_name' => '규칙명',
'reset_period' => '리셋 주기',
'sequence_padding' => '시퀀스 자릿수',
'is_active' => '활성 상태',
'pattern' => '패턴',
'pattern.*.type' => '세그먼트 타입',
'pattern.*.value' => '세그먼트 값',
'pattern.*.format' => '날짜 포맷',
'pattern.*.key' => '파라미터 키',
'pattern.*.default' => '기본값',
'pattern.*.map' => '매핑 테이블',
];
}
public function messages(): array
{
return [
'document_type.required' => '문서유형은 필수입니다.',
'document_type.unique' => '이 문서유형에 대한 규칙이 이미 존재합니다.',
'document_type.in' => '유효하지 않은 문서유형입니다.',
'pattern.required' => '최소 1개 이상의 세그먼트가 필요합니다.',
'pattern.min' => '최소 1개 이상의 세그먼트가 필요합니다.',
'pattern.*.type.required' => '세그먼트 타입은 필수입니다.',
'pattern.*.type.in' => '유효하지 않은 세그먼트 타입입니다.',
'pattern.*.value.required_if' => '이 세그먼트 타입에는 값이 필요합니다.',
'pattern.*.format.required_if' => '날짜 타입에는 포맷이 필요합니다.',
'pattern.*.key.required_if' => '이 세그먼트 타입에는 키가 필요합니다.',
'pattern.*.map.required_if' => '매핑 타입에는 매핑 테이블이 필요합니다.',
'sequence_padding.min' => '시퀀스 자릿수는 최소 1 이상이어야 합니다.',
'sequence_padding.max' => '시퀀스 자릿수는 최대 10까지입니다.',
];
}
}
```
### 6.7 FormRequest - Update (`mng/app/Http/Requests/UpdateNumberingRuleRequest.php`)
```php
<?php
namespace App\Http\Requests;
use App\Models\NumberingRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateNumberingRuleRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
$tenantId = session('selected_tenant_id');
$ruleId = $this->route('id');
$validTypes = array_keys(NumberingRule::documentTypes());
$validResets = array_keys(NumberingRule::resetPeriods());
return [
'document_type' => [
'required',
'string',
Rule::in($validTypes),
Rule::unique('numbering_rules', 'document_type')
->where('tenant_id', $tenantId)
->ignore($ruleId), // 자기 자신 제외
],
'rule_name' => 'nullable|string|max:100',
'reset_period' => ['required', 'string', Rule::in($validResets)],
'sequence_padding' => 'required|integer|min:1|max:10',
'is_active' => 'nullable|boolean',
// JSON 패턴 검증 (Store와 동일)
'pattern' => 'required|array|min:1',
'pattern.*.type' => ['required', 'string', Rule::in([
'static', 'separator', 'date', 'param', 'mapping', 'sequence',
])],
'pattern.*.value' => 'required_if:pattern.*.type,static|required_if:pattern.*.type,separator|nullable|string|max:50',
'pattern.*.format' => 'required_if:pattern.*.type,date|nullable|string|max:20',
'pattern.*.key' => 'required_if:pattern.*.type,param|required_if:pattern.*.type,mapping|nullable|string|max:50',
'pattern.*.default' => 'nullable|string|max:50',
'pattern.*.map' => 'required_if:pattern.*.type,mapping|nullable|array',
'pattern.*.map.*' => 'nullable|string|max:50',
];
}
public function attributes(): array
{
return [
'document_type' => '문서유형',
'rule_name' => '규칙명',
'reset_period' => '리셋 주기',
'sequence_padding' => '시퀀스 자릿수',
'is_active' => '활성 상태',
'pattern' => '패턴',
'pattern.*.type' => '세그먼트 타입',
'pattern.*.value' => '세그먼트 값',
'pattern.*.format' => '날짜 포맷',
'pattern.*.key' => '파라미터 키',
'pattern.*.default' => '기본값',
'pattern.*.map' => '매핑 테이블',
];
}
public function messages(): array
{
return [
'document_type.required' => '문서유형은 필수입니다.',
'document_type.unique' => '이 문서유형에 대한 규칙이 이미 존재합니다.',
'document_type.in' => '유효하지 않은 문서유형입니다.',
'pattern.required' => '최소 1개 이상의 세그먼트가 필요합니다.',
'pattern.min' => '최소 1개 이상의 세그먼트가 필요합니다.',
'pattern.*.type.required' => '세그먼트 타입은 필수입니다.',
'pattern.*.type.in' => '유효하지 않은 세그먼트 타입입니다.',
'sequence_padding.min' => '시퀀스 자릿수는 최소 1 이상이어야 합니다.',
'sequence_padding.max' => '시퀀스 자릿수는 최대 10까지입니다.',
];
}
}
```
### 6.8 라우트
#### Blade 라우트 (`mng/routes/web.php`에 추가)
```php
// ⚠️ 컨펌 필요: routes/web.php 수정
// 기존 middleware(['auth', 'hq.member', 'password.changed']) 그룹 내부에 추가
Route::prefix('numbering-rules')->name('numbering-rules.')->group(function () {
Route::get('/', [\App\Http\Controllers\NumberingRuleController::class, 'index'])->name('index');
Route::get('/create', [\App\Http\Controllers\NumberingRuleController::class, 'create'])->name('create');
Route::get('/{id}/edit', [\App\Http\Controllers\NumberingRuleController::class, 'edit'])->name('edit');
});
```
#### API 라우트 (`mng/routes/api.php`에 추가)
```php
// ⚠️ 컨펌 필요: routes/api.php 수정
// 기존 middleware(['web', 'auth', 'hq.member'])->prefix('admin')->name('api.admin.') 그룹 내부에 추가
Route::prefix('numbering-rules')->name('numbering-rules.')->group(function () {
Route::get('/', [\App\Http\Controllers\Api\Admin\NumberingRuleController::class, 'index'])->name('index');
Route::post('/', [\App\Http\Controllers\Api\Admin\NumberingRuleController::class, 'store'])->name('store');
Route::put('/{id}', [\App\Http\Controllers\Api\Admin\NumberingRuleController::class, 'update'])->name('update');
Route::delete('/{id}', [\App\Http\Controllers\Api\Admin\NumberingRuleController::class, 'destroy'])->name('destroy');
Route::post('/preview', [\App\Http\Controllers\Api\Admin\NumberingRuleController::class, 'preview'])->name('preview');
});
// → URL: /admin/numbering-rules/*, 이름: api.admin.numbering-rules.*
```
---
## 7. UI 설계 & Blade 뷰
### 7.1 목록 페이지 (`numbering/index.blade.php`)
```
┌──────────────────────────────────────────────────────────┐
│ 채번 규칙 관리 [+ 새 규칙] │
├──────────────────────────────────────────────────────────┤
│ [문서유형 ▼] [상태 ▼] [검색...] [검색 버튼] │
├──────────────────────────────────────────────────────────┤
│ # │ 규칙명 │ 문서유형 │ 패턴 미리보기 │ 상태 │ 작업 │
│ 1 │ 5130 견적번호 │ 견적 │ KD-PR-260207-01 │ 활성 │ 수정/삭제│
│ 2 │ 5130 수주 로트 │ 수주 │ KD-SS-260207-01 │ 활성 │ 수정/삭제│
└──────────────────────────────────────────────────────────┘
```
**핵심 Blade 구조:**
```blade
{{-- numbering/index.blade.php --}}
@extends('layouts.app')
@section('title', '채번 규칙 관리')
@section('content')
{{-- 헤더 --}}
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-6">
<h1 class="text-2xl font-bold text-gray-800">채번 규칙 관리</h1>
<a href="{{ route('numbering-rules.create') }}"
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition text-center">
+ 새 규칙
</a>
</div>
{{-- 필터 --}}
<x-filter-collapsible id="filterSection">
<form id="filterForm" class="flex flex-wrap gap-2 sm:gap-4">
<div class="w-full sm:w-40">
<select name="document_type" class="w-full px-4 py-2 border border-gray-300 rounded-lg">
<option value="">전체 문서유형</option>
@foreach($documentTypes as $value => $label)
<option value="{{ $value }}">{{ $label }}</option>
@endforeach
</select>
</div>
<div class="w-full sm:w-40">
<select name="is_active" class="w-full px-4 py-2 border border-gray-300 rounded-lg">
<option value="">전체 상태</option>
<option value="1">활성</option>
<option value="0">비활성</option>
</select>
</div>
<div class="flex-1 min-w-0">
<input type="text" name="search" placeholder="규칙명 검색..."
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500">
</div>
<button type="submit" class="bg-gray-600 hover:bg-gray-700 text-white px-6 py-2 rounded-lg">
검색
</button>
</form>
</x-filter-collapsible>
{{-- HTMX 테이블 컨테이너 --}}
<div id="rules-table"
hx-get="/admin/numbering-rules"
hx-trigger="load, filterSubmit from:body"
hx-include="#filterForm"
hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'
class="bg-white rounded-lg shadow-sm overflow-hidden">
<div class="flex justify-center items-center p-12">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
</div>
@endsection
@push('scripts')
<script>
document.getElementById('filterForm').addEventListener('submit', function(e) {
e.preventDefault();
htmx.trigger('#rules-table', 'filterSubmit');
});
window.confirmDelete = function(id, name) {
showDeleteConfirm(name, () => {
htmx.ajax('DELETE', `/admin/numbering-rules/${id}`, {
target: '#rules-table',
swap: 'none',
headers: { 'X-CSRF-TOKEN': '{{ csrf_token() }}' }
}).then(() => {
htmx.trigger('#rules-table', 'filterSubmit');
});
});
};
</script>
@endpush
```
### 7.2 테이블 파셜 (`numbering/partials/table.blade.php`)
```blade
{{-- HTMX로 교체되는 파셜 --}}
<div class="bg-white rounded-lg shadow-sm overflow-hidden">
<x-table-swipe>
<table class="min-w-full">
<thead class="bg-gray-50 border-b">
<tr>
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-700 uppercase">#</th>
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-700 uppercase">규칙명</th>
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-700 uppercase">문서유형</th>
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-700 uppercase">패턴 미리보기</th>
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-700 uppercase">리셋주기</th>
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-700 uppercase">상태</th>
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-700 uppercase">작업</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
@forelse($rules as $rule)
<tr class="hover:bg-gray-50">
<td class="px-6 py-4 text-sm text-gray-500">{{ $rule->id }}</td>
<td class="px-6 py-4 text-sm font-medium text-gray-900">
{{ $rule->rule_name ?? '-' }}
</td>
<td class="px-6 py-4 text-sm text-gray-700">
{{ $rule->document_type_label }}
<span class="text-xs text-gray-400">({{ $rule->document_type }})</span>
</td>
<td class="px-6 py-4">
<code class="text-sm bg-gray-100 px-2 py-1 rounded">{{ $rule->preview }}</code>
</td>
<td class="px-6 py-4 text-sm text-gray-500">
{{ $rule->reset_period_label }}
</td>
<td class="px-6 py-4">
@if($rule->is_active)
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">
활성
</span>
@else
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-gray-100 text-gray-800">
비활성
</span>
@endif
</td>
<td class="px-6 py-4 text-sm font-medium">
<a href="{{ route('numbering-rules.edit', $rule->id) }}"
class="text-blue-600 hover:text-blue-900 mr-3">수정</a>
<button onclick="confirmDelete({{ $rule->id }}, '{{ $rule->rule_name }}')"
class="text-red-600 hover:text-red-900">삭제</button>
</td>
</tr>
@empty
<tr>
<td colspan="7" class="px-6 py-12 text-center text-gray-500">
등록된 채번 규칙이 없습니다.
</td>
</tr>
@endforelse
</tbody>
</table>
</x-table-swipe>
</div>
{{-- 페이지네이션 --}}
@if($rules->hasPages())
<div class="mt-4">
{{ $rules->links() }}
</div>
@endif
```
### 7.3 생성/수정 폼 (`numbering/create.blade.php`)
```
┌──────────────────────────────────────────────────────────┐
│ 채번 규칙 생성 ← 목록으로 │
├──────────────────────────────────────────────────────────┤
│ ┌─ 기본 정보 ──────────────────────────────────────────┐ │
│ │ 규칙명: [________] 문서유형: [quote ▼] │ │
│ │ 리셋주기: [daily ▼] 시퀀스 자릿수: [2] │ │
│ │ 활성: [✓] │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ ┌─ 패턴 세그먼트 (Vanilla JS 동적 폼) ──────────────┐ │
│ │ ① [static ▼] value: [KD] [✕] [↕] │ │
│ │ ② [separator ▼] value: [-] [✕] [↕] │ │
│ │ ③ [date ▼] format: [ymd ▼] [✕] [↕] │ │
│ │ ④ [param ▼] key: [pair_code] default: [SS] [✕] [↕] │ │
│ │ ⑤ [mapping ▼] key: [cat] map: {...} [✕] [↕] │ │
│ │ ⑥ [sequence ▼] (추가 설정 없음) [✕] [↕] │ │
│ │ [+ 세그먼트 추가] │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ ┌─ 미리보기 (실시간) ────────────────────────────────┐ │
│ │ 생성 예시: KD-PR-260207-01 │ │
│ │ KD-PR-260207-02 │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ [취소] [저장] │
└──────────────────────────────────────────────────────────┘
```
**핵심: 세그먼트 타입별 동적 필드 렌더링**
| 타입 선택 시 | 표시되는 필드 |
|-------------|-------------|
| `static` | `value` 텍스트 입력 |
| `separator` | `value` 텍스트 입력 (기본값 "-") |
| `date` | `format` 셀렉트 (ymd, Ymd, Ym, Y 등) |
| `param` | `key` 텍스트 + `default` 텍스트 |
| `mapping` | `key` 텍스트 + `default` 텍스트 + 동적 key-value 맵 에디터 |
| `sequence` | 추가 필드 없음 |
### 7.4 Vanilla JS 동적 세그먼트 폼 (완전한 구현)
> **MNG 기술 표준**: Alpine.js 사용 금지 → Vanilla JS + HTMX 조합
> **참고 패턴**: `mng/resources/views/quote-formulas/create.blade.php` (fetch + JSON + classList 패턴)
```javascript
// create.blade.php / edit.blade.php의 @push('scripts') 내부
// ========================================
// 전역 상태 (segments 배열)
// ========================================
let segments = []; // edit 시 서버에서 초기값 전달
let sequencePadding = 2;
const SEGMENT_TYPES = [
{ value: 'static', label: '고정 문자열' },
{ value: 'separator', label: '구분자' },
{ value: 'date', label: '날짜' },
{ value: 'param', label: '외부 파라미터' },
{ value: 'mapping', label: '값 매핑' },
{ value: 'sequence', label: '자동 순번' },
];
const DATE_FORMATS = [
{ value: 'ymd', label: 'YYMMDD (260207)' },
{ value: 'Ymd', label: 'YYYYMMDD (20260207)' },
{ value: 'Ym', label: 'YYYYMM (202602)' },
{ value: 'ym', label: 'YYMM (2602)' },
{ value: 'Y', label: 'YYYY (2026)' },
{ value: 'y', label: 'YY (26)' },
];
// ========================================
// 초기화
// ========================================
function initPatternEditor(initialSegments = [], initialPadding = 2) {
sequencePadding = initialPadding;
// mapping 타입의 map 객체 → _mapEntries 배열로 변환
segments = (initialSegments || []).map(seg => {
if (seg.type === 'mapping' && seg.map && typeof seg.map === 'object') {
seg._mapEntries = Object.entries(seg.map).map(([k, v]) => ({ key: k, value: v }));
} else {
seg._mapEntries = seg._mapEntries || [];
}
return seg;
});
renderSegments();
updatePreview();
// 시퀀스 자릿수 변경 시 미리보기 업데이트
document.querySelector('[name="sequence_padding"]').addEventListener('input', function() {
sequencePadding = parseInt(this.value) || 2;
updatePreview();
});
}
// ========================================
// 세그먼트 CRUD
// ========================================
function addSegment() {
segments.push({
type: 'static', value: '', format: 'ymd',
key: '', default: '', map: {}, _mapEntries: [],
});
renderSegments();
updatePreview();
}
function removeSegment(index) {
segments.splice(index, 1);
renderSegments();
updatePreview();
}
function moveSegment(from, direction) {
const to = from + direction;
if (to < 0 || to >= segments.length) return;
const temp = segments.splice(from, 1)[0];
segments.splice(to, 0, temp);
renderSegments();
updatePreview();
}
// ========================================
// 타입별 동적 필드 HTML 생성
// ========================================
function getFieldsHtml(seg, index) {
switch (seg.type) {
case 'static':
case 'separator':
return `<input type="text" value="${seg.value || ''}" placeholder="${seg.type === 'separator' ? '구분자 (예: -)' : '값'}"
onchange="onSegFieldChange(${index}, 'value', this.value)"
class="flex-1 min-w-[100px] px-3 py-2 border border-gray-300 rounded text-sm">`;
case 'date':
return `<select onchange="onSegFieldChange(${index}, 'format', this.value)"
class="flex-1 min-w-[180px] px-3 py-2 border border-gray-300 rounded text-sm">
${DATE_FORMATS.map(f =>
`<option value="${f.value}" ${seg.format === f.value ? 'selected' : ''}>${f.label}</option>`
).join('')}
</select>`;
case 'param':
return `<input type="text" value="${seg.key || ''}" placeholder="파라미터 키 (예: pair_code)"
onchange="onSegFieldChange(${index}, 'key', this.value)"
class="flex-1 px-3 py-2 border border-gray-300 rounded text-sm">
<input type="text" value="${seg.default || ''}" placeholder="기본값"
onchange="onSegFieldChange(${index}, 'default', this.value)"
class="w-24 px-3 py-2 border border-gray-300 rounded text-sm">`;
case 'mapping':
const mapHtml = (seg._mapEntries || []).map((entry, ei) => `
<div class="flex gap-1 items-center">
<input type="text" value="${entry.key || ''}" placeholder="입력값"
onchange="onMapEntryChange(${index}, ${ei}, 'key', this.value)"
class="w-28 px-2 py-1 border border-gray-300 rounded text-xs">
<span class="text-gray-400 text-xs">→</span>
<input type="text" value="${entry.value || ''}" placeholder="변환값"
onchange="onMapEntryChange(${index}, ${ei}, 'value', this.value)"
class="w-20 px-2 py-1 border border-gray-300 rounded text-xs">
<button type="button" onclick="removeMapEntry(${index}, ${ei})"
class="text-red-400 hover:text-red-600 text-xs">✕</button>
</div>
`).join('');
return `<div class="flex-1">
<div class="flex gap-2 mb-2">
<input type="text" value="${seg.key || ''}" placeholder="파라미터 키"
onchange="onSegFieldChange(${index}, 'key', this.value)"
class="flex-1 px-3 py-2 border border-gray-300 rounded text-sm">
<input type="text" value="${seg.default || ''}" placeholder="기본값"
onchange="onSegFieldChange(${index}, 'default', this.value)"
class="w-24 px-3 py-2 border border-gray-300 rounded text-sm">
</div>
<div class="ml-4 space-y-1">
${mapHtml}
<button type="button" onclick="addMapEntry(${index})"
class="text-xs text-blue-600 hover:text-blue-800">+ 매핑 추가</button>
</div>
</div>`;
case 'sequence':
return `<span class="text-sm text-gray-400 pt-2">자동 순번 (설정 없음)</span>`;
default:
return '';
}
}
// ========================================
// 세그먼트 전체 렌더링
// ========================================
function renderSegments() {
const container = document.getElementById('segmentsContainer');
if (segments.length === 0) {
container.innerHTML = '<p class="text-gray-400 text-sm py-4 text-center">세그먼트를 추가하세요.</p>';
return;
}
container.innerHTML = segments.map((seg, index) => `
<div class="flex items-start gap-2 mb-3 p-3 bg-gray-50 rounded-lg" data-index="${index}">
<span class="text-sm text-gray-400 pt-2">${index + 1}.</span>
<select onchange="onTypeChange(${index}, this.value)"
class="w-36 px-3 py-2 border border-gray-300 rounded text-sm">
${SEGMENT_TYPES.map(t =>
`<option value="${t.value}" ${seg.type === t.value ? 'selected' : ''}>${t.label}</option>`
).join('')}
</select>
<div class="flex-1 flex flex-wrap gap-2">
${getFieldsHtml(seg, index)}
</div>
<div class="flex gap-1">
<button type="button" onclick="moveSegment(${index}, -1)" ${index === 0 ? 'disabled' : ''}
class="px-2 py-1 text-gray-400 hover:text-gray-600 disabled:opacity-30">↑</button>
<button type="button" onclick="moveSegment(${index}, 1)" ${index === segments.length - 1 ? 'disabled' : ''}
class="px-2 py-1 text-gray-400 hover:text-gray-600 disabled:opacity-30">↓</button>
<button type="button" onclick="removeSegment(${index})"
class="px-2 py-1 text-red-400 hover:text-red-600">✕</button>
</div>
</div>
`).join('');
}
// ========================================
// 필드값 변경 핸들러
// ========================================
function onTypeChange(index, newType) {
segments[index].type = newType;
segments[index].value = newType === 'separator' ? '-' : '';
segments[index].format = 'ymd';
segments[index].key = '';
segments[index].default = '';
segments[index].map = {};
segments[index]._mapEntries = [];
renderSegments();
updatePreview();
}
function onSegFieldChange(index, field, value) {
segments[index][field] = value;
updatePreview();
}
// ========================================
// 매핑 엔트리 관리
// ========================================
function addMapEntry(segIndex) {
if (!segments[segIndex]._mapEntries) segments[segIndex]._mapEntries = [];
segments[segIndex]._mapEntries.push({ key: '', value: '' });
renderSegments();
}
function removeMapEntry(segIndex, entryIndex) {
segments[segIndex]._mapEntries.splice(entryIndex, 1);
renderSegments();
updatePreview();
}
function onMapEntryChange(segIndex, entryIndex, field, value) {
segments[segIndex]._mapEntries[entryIndex][field] = value;
updatePreview();
}
// ========================================
// 실시간 미리보기
// ========================================
function generatePreviewStr(seqNum) {
const now = new Date();
const pad2 = (n) => String(n).padStart(2, '0');
const yy = String(now.getFullYear()).slice(-2);
const yyyy = String(now.getFullYear());
const mm = pad2(now.getMonth() + 1);
const dd = pad2(now.getDate());
const formatDate = (fmt) => {
switch (fmt) {
case 'ymd': return yy + mm + dd;
case 'Ymd': return yyyy + mm + dd;
case 'Ym': return yyyy + mm;
case 'ym': return yy + mm;
case 'Y': return yyyy;
case 'y': return yy;
default: return yy + mm + dd;
}
};
return 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 String(seqNum).padStart(sequencePadding, '0');
default: return '';
}
}).join('');
}
function updatePreview() {
const previewEl = document.getElementById('previewArea');
if (segments.length === 0) {
previewEl.innerHTML = '<p class="text-gray-400">세그먼트를 추가하면 미리보기가 표시됩니다.</p>';
return;
}
previewEl.innerHTML = `
<div class="text-lg font-mono">
<span class="text-gray-500">1번:</span>
<span class="font-bold text-blue-700">${generatePreviewStr(1)}</span>
</div>
<div class="text-lg font-mono mt-1">
<span class="text-gray-500">2번:</span>
<span class="font-bold text-blue-700">${generatePreviewStr(2)}</span>
</div>`;
}
// ========================================
// 폼 제출 (fetch + JSON)
// ========================================
function prepareSubmitData() {
return segments.map(seg => {
const clean = { type: seg.type };
switch (seg.type) {
case 'static':
case 'separator':
clean.value = seg.value;
break;
case 'date':
clean.format = seg.format;
break;
case 'param':
clean.key = seg.key;
if (seg.default) clean.default = seg.default;
break;
case 'mapping':
clean.key = seg.key;
if (seg.default) clean.default = seg.default;
clean.map = {};
(seg._mapEntries || []).forEach(entry => {
if (entry.key) clean.map[entry.key] = entry.value;
});
break;
case 'sequence':
break;
}
return clean;
});
}
async function submitForm(url, method = 'POST') {
const formData = {
document_type: document.querySelector('[name="document_type"]').value,
rule_name: document.querySelector('[name="rule_name"]').value,
reset_period: document.querySelector('[name="reset_period"]').value,
sequence_padding: parseInt(document.querySelector('[name="sequence_padding"]').value),
is_active: document.querySelector('[name="is_active"]').checked ? 1 : 0,
pattern: prepareSubmitData(),
};
try {
const response = await fetch(url, {
method: method,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
},
body: JSON.stringify(formData),
});
const result = await response.json();
if (response.ok && result.success) {
showToast(result.message, 'success');
if (result.redirect) window.location.href = result.redirect;
} else if (response.status === 422) {
// 유효성 검증 실패
const errors = result.errors || {};
let errorMsg = '입력 오류: ';
for (let field in errors) {
errorMsg += errors[field].join(', ') + ' ';
}
showToast(errorMsg, 'error');
} else {
showToast(result.message || '저장에 실패했습니다.', 'error');
}
} catch (error) {
showToast('요청 처리 중 오류가 발생했습니다.', 'error');
}
}
```
### 7.5 Blade 템플릿 구조 (edit.blade.php 예시)
```blade
{{-- edit.blade.php --}}
@extends('layouts.app')
@section('title', '채번 규칙 수정')
@section('content')
{{-- 헤더 --}}
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-800">채번 규칙 수정</h1>
<a href="{{ route('numbering-rules.index') }}"
class="text-gray-600 hover:text-gray-800">← 목록으로</a>
</div>
{{-- 기본 정보 폼 --}}
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
<h2 class="text-lg font-semibold mb-4">기본 정보</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">규칙명</label>
<input type="text" name="rule_name" value="{{ $rule->rule_name }}"
class="w-full px-4 py-2 border border-gray-300 rounded-lg">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">문서유형</label>
<select name="document_type" class="w-full px-4 py-2 border border-gray-300 rounded-lg">
@foreach($documentTypes as $value => $label)
<option value="{{ $value }}"
{{ $rule->document_type === $value ? 'selected' : '' }}
{{ in_array($value, $usedDocumentTypes) ? 'disabled' : '' }}>
{{ $label }} ({{ $value }})
{{ in_array($value, $usedDocumentTypes) ? ' - 사용 중' : '' }}
</option>
@endforeach
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">리셋 주기</label>
<select name="reset_period" class="w-full px-4 py-2 border border-gray-300 rounded-lg">
@foreach($resetPeriods as $value => $label)
<option value="{{ $value }}" {{ $rule->reset_period === $value ? 'selected' : '' }}>
{{ $label }}
</option>
@endforeach
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">시퀀스 자릿수</label>
<input type="number" name="sequence_padding" min="1" max="10"
value="{{ $rule->sequence_padding }}"
class="w-full px-4 py-2 border border-gray-300 rounded-lg">
</div>
<div class="flex items-center gap-2">
<input type="checkbox" name="is_active" id="is_active"
{{ $rule->is_active ? 'checked' : '' }}
class="rounded border-gray-300 text-blue-600">
<label for="is_active" class="text-sm text-gray-700">활성</label>
</div>
</div>
</div>
{{-- 세그먼트 편집 영역 (Vanilla JS로 동적 렌더링) --}}
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
<h2 class="text-lg font-semibold mb-4">패턴 세그먼트</h2>
{{-- JS가 이 div 내부를 동적으로 렌더링 --}}
<div id="segmentsContainer"></div>
<button type="button" onclick="addSegment()"
class="w-full py-2 border-2 border-dashed border-gray-300 rounded-lg text-gray-500 hover:border-blue-400 hover:text-blue-600 transition">
+ 세그먼트 추가
</button>
</div>
{{-- 미리보기 (JS가 동적 업데이트) --}}
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
<h2 class="text-lg font-semibold mb-4">미리보기</h2>
<div id="previewArea" class="bg-gray-50 rounded-lg p-4">
<p class="text-gray-400">세그먼트를 추가하면 미리보기가 표시됩니다.</p>
</div>
</div>
{{-- 버튼 --}}
<div class="flex justify-end gap-3">
<a href="{{ route('numbering-rules.index') }}"
class="px-6 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50">취소</a>
<button type="button" onclick="submitForm('/admin/numbering-rules/{{ $rule->id }}', 'PUT')"
class="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">저장</button>
</div>
@endsection
@push('scripts')
<script>
// 7.4의 Vanilla JS 코드 전체 포함
// edit 모드: 서버 데이터로 초기화
document.addEventListener('DOMContentLoaded', function() {
initPatternEditor(@json($rule->pattern), {{ $rule->sequence_padding }});
});
</script>
@endpush
```
> **create.blade.php**는 동일한 구조이되:
> - `initPatternEditor([], 2)` 빈 배열로 초기화
> - `submitForm('/admin/numbering-rules', 'POST')` 호출
> - `$rule->xxx` 대신 빈 기본값 사용
> - 문서유형에서 이미 사용 중인 타입 `disabled` 처리
---
## 8. 구현 순서 & 예상 작업량
| Phase | 작업 | 파일 수 | 예상 난이도 |
|-------|------|--------|------------|
| 1 | 백엔드 (Model, Service, Controller×2, FormRequest×2, Route) | 6개 생성 + 1개 수정 | 중 |
| 2 | 프론트엔드 (Blade Views + Vanilla JS 동적 폼) | 4~5개 생성 | **대** (Vanilla JS 동적 폼이 핵심) |
| 3 | 통합 & 검증 (메뉴, 테스트) | 1개 수정 | 소 |
**핵심 난이도**: Phase 2의 세그먼트 동적 폼
- Vanilla JS로 JSON 배열 CRUD (추가/삭제/순서변경 + innerHTML 재렌더링)
- 타입별 동적 필드 전환 (static↔date↔param↔mapping)
- mapping 타입의 key-value 맵 에디터
- 실시간 미리보기 (클라이언트 사이드)
- 폼 제출 시 JSON 직렬화 → fetch API 전송
---
## 9. 검증 시나리오
### 9.1 테스트 케이스
| # | 시나리오 | 예상 결과 | 상태 |
|---|---------|----------|:----:|
| 1 | 목록 진입 | tenant_id=287 규칙 2건 표시 (견적, 수주) | ⏳ |
| 2 | 문서유형 필터 → "견적" | 1건만 표시 | ⏳ |
| 3 | 견적 규칙 수정 → 저장 | pattern JSON 업데이트, 미리보기 변경 | ⏳ |
| 4 | 새 규칙 생성 (material_receipt) | 규칙 3건으로 증가 | ⏳ |
| 5 | 이미 존재하는 document_type으로 생성 | "이 문서유형에 대한 규칙이 이미 존재합니다" 에러 | ⏳ |
| 6 | 세그먼트 추가/삭제/순서변경 | Vanilla JS 동적 폼 정상 동작 | ⏳ |
| 7 | mapping 세그먼트: 매핑 추가/삭제 | key-value 에디터 정상 동작 | ⏳ |
| 8 | 미리보기 | 패턴 변경 시 실시간 업데이트 | ⏳ |
| 9 | 규칙 삭제 (Hard Delete) | DB에서 완전 삭제, 목록에서 제거 | ⏳ |
| 10 | 삭제 후 API 채번 | 레거시 로직으로 폴백 (null → fallback) | ⏳ |
| 11 | 세그먼트 없이 저장 시도 | "최소 1개 이상의 세그먼트가 필요합니다" 에러 | ⏳ |
### 9.2 성공 기준
| 기준 | 비고 |
|------|------|
| 규칙 CRUD 정상 동작 | 생성/조회/수정/삭제 |
| 세그먼트 동적 편집 | 추가/삭제/순서변경/타입전환 |
| mapping 에디터 | key-value 추가/삭제 |
| 실시간 미리보기 | 패턴 변경 시 즉시 반영 |
| 기존 API 채번 로직과 호환 | MNG에서 수정한 규칙이 API에서 정상 작동 |
| Unique 제약 처리 | 중복 document_type 에러 표시 |
| MNG 기존 패턴 준수 | HTMX + Vanilla JS + Tailwind |
---
## 10. 주의사항 & 제약
### 10.1 금지 사항
-`mng/database/migrations/` 파일 생성 금지
- ❌ API `numbering_rules`, `numbering_sequences` 테이블 구조 변경 금지
- ❌ MNG에서 시퀀스(`numbering_sequences`) 직접 수정 금지 (조회만 가능)
### 10.2 호환성 주의
- MNG에서 pattern JSON을 수정하면 **즉시** API의 채번 로직에 영향
- API의 `NumberingService.generate()``NumberingRule.pattern`을 그대로 사용
- pattern JSON 구조가 잘못되면 API 채번이 실패할 수 있음 → **FormRequest 검증 필수**
### 10.3 삭제 정책
- `numbering_rules` 테이블에 `deleted_at` 없음 → **Hard Delete**
- 삭제 시 해당 테넌트/문서유형의 채번이 레거시 로직으로 폴백됨
- 삭제 전 확인 다이얼로그 필수 ("이 규칙을 삭제하면 레거시 채번 방식으로 전환됩니다.")
### 10.4 사이드바 메뉴 추가
- MNG의 메뉴는 `menus` DB 테이블 기반 (코드가 아닌 데이터)
- `SidebarMenuService`가 메뉴를 렌더링
- 새 메뉴 추가: `menus` 테이블에 INSERT 필요 (시더 또는 수동)
- **⚠️ 컨펌 필요**: 어떤 상위 메뉴 아래에 배치할지 결정
---
## 11. API 채번 시스템 핵심 참조
### 11.1 API NumberingService 동작 원리
```
API 견적 생성 요청
QuoteNumberService.generate()
NumberingService.generate('quote', params)
numbering_rules에서 (tenant_id, document_type='quote', is_active=true) 조회
├─ 규칙 있음 → pattern 세그먼트 순서대로 처리 → 번호 생성
└─ 규칙 없음 (null 반환) → QuoteNumberService가 레거시 로직 실행
```
### 11.2 시퀀스 동작 (sequence 타입)
```
sequence 세그먼트 처리 시:
1. reset_period에 따라 period_key 생성
- daily → now()->format('ymd') → "260207"
- monthly → now()->format('Ym') → "202602"
- yearly → now()->format('Y') → "2026"
- never → "all"
2. scope_key = param/mapping 세그먼트의 결과값 (없으면 빈 문자열)
3. MySQL UPSERT로 원자적 시퀀스 증가:
INSERT INTO numbering_sequences (tenant_id, document_type, scope_key, period_key, last_sequence)
VALUES (?, ?, ?, ?, 1)
ON DUPLICATE KEY UPDATE last_sequence = last_sequence + 1
4. 결과를 sequence_padding만큼 0 패딩 → "01", "02", ...
```
### 11.3 scope_key의 역할
- param/mapping 세그먼트의 결과값이 scope_key가 됨
- 예: 수주 규칙에서 `pair_code=SS` → scope_key="SS"
- SS와 TS는 **독립적인 시퀀스**를 가짐 (같은 날에 SS-01, TS-01 각각)
- scope_key가 없으면 빈 문자열 → 전체 공유 시퀀스
---
## 12. 참고 문서
- **채번 시스템 설계**: `docs/dev_plans/tenant-numbering-system-plan.md`
- **MNG CRUD 패턴**: `mng/app/Http/Controllers/DepartmentController.php` + `Api/Admin/DepartmentController.php`
- **MNG Service 패턴**: `mng/app/Services/DepartmentService.php`
- **MNG FormRequest 패턴**: `mng/app/Http/Requests/StoreDepartmentRequest.php`
- **Vanilla JS 동적 폼 참고**: `mng/resources/views/quote-formulas/create.blade.php` (fetch + JSON 패턴)
- **HTMX 테이블 참고**: `mng/resources/views/departments/partials/table.blade.php`
- **API NumberingService**: `api/app/Services/NumberingService.php`
- **API NumberingRule Model**: `api/app/Models/NumberingRule.php`
- **API 마이그레이션**: `api/database/migrations/2026_02_07_200000_create_numbering_rules_table.php`
- **API Seeder**: `api/database/seeders/NumberingRuleSeeder.php`
---
*이 문서는 /sc:plan 스킬로 생성되었으며, 2026-02-10 보완되었습니다.*