# 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 '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 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 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 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 [ '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 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') {{-- 헤더 --}}

채번 규칙 관리

+ 새 규칙
{{-- 필터 --}}
{{-- HTMX 테이블 컨테이너 --}}
@endsection @push('scripts') @endpush ``` ### 7.2 테이블 파셜 (`numbering/partials/table.blade.php`) ```blade {{-- HTMX로 교체되는 파셜 --}}
@forelse($rules as $rule) @empty @endforelse
# 규칙명 문서유형 패턴 미리보기 리셋주기 상태 작업
{{ $rule->id }} {{ $rule->rule_name ?? '-' }} {{ $rule->document_type_label }} ({{ $rule->document_type }}) {{ $rule->preview }} {{ $rule->reset_period_label }} @if($rule->is_active) 활성 @else 비활성 @endif 수정
등록된 채번 규칙이 없습니다.
{{-- 페이지네이션 --}} @if($rules->hasPages())
{{ $rules->links() }}
@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 ``; case 'date': return ``; case 'param': return ` `; case 'mapping': const mapHtml = (seg._mapEntries || []).map((entry, ei) => `
`).join(''); return `
${mapHtml}
`; case 'sequence': return `자동 순번 (설정 없음)`; default: return ''; } } // ======================================== // 세그먼트 전체 렌더링 // ======================================== function renderSegments() { const container = document.getElementById('segmentsContainer'); if (segments.length === 0) { container.innerHTML = '

세그먼트를 추가하세요.

'; return; } container.innerHTML = segments.map((seg, index) => `
${index + 1}.
${getFieldsHtml(seg, index)}
`).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 = '

세그먼트를 추가하면 미리보기가 표시됩니다.

'; return; } previewEl.innerHTML = `
1번: ${generatePreviewStr(1)}
2번: ${generatePreviewStr(2)}
`; } // ======================================== // 폼 제출 (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') {{-- 헤더 --}}

채번 규칙 수정

← 목록으로
{{-- 기본 정보 폼 --}}

기본 정보

is_active ? 'checked' : '' }} class="rounded border-gray-300 text-blue-600">
{{-- 세그먼트 편집 영역 (Vanilla JS로 동적 렌더링) --}}

패턴 세그먼트

{{-- JS가 이 div 내부를 동적으로 렌더링 --}}
{{-- 미리보기 (JS가 동적 업데이트) --}}

미리보기

세그먼트를 추가하면 미리보기가 표시됩니다.

{{-- 버튼 --}}
취소
@endsection @push('scripts') @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 보완되었습니다.*