# 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') {{-- 헤더 --}}
| # | 규칙명 | 문서유형 | 패턴 미리보기 | 리셋주기 | 상태 | 작업 |
|---|---|---|---|---|---|---|
| {{ $rule->id }} | {{ $rule->rule_name ?? '-' }} | {{ $rule->document_type_label }} ({{ $rule->document_type }}) |
{{ $rule->preview }}
|
{{ $rule->reset_period_label }} | @if($rule->is_active) 활성 @else 비활성 @endif | 수정 |
| 등록된 채번 규칙이 없습니다. | ||||||
세그먼트를 추가하세요.
'; return; } container.innerHTML = segments.map((seg, index) => `세그먼트를 추가하면 미리보기가 표시됩니다.
'; return; } previewEl.innerHTML = `세그먼트를 추가하면 미리보기가 표시됩니다.