Files
sam-docs/plans/mng-numbering-rule-management-plan.md
권혁성 da2839c4d0 docs: 문서관리 시스템 및 채번규칙 계획 문서 업데이트
- 문서관리 시스템 계획/변경이력/마스터 진행상황 반영
- 중간검사 서식 계획 신규 추가
- 채번규칙 관리 계획 상세화 (1767줄 확장)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 10:02:47 +09:00

71 KiB
Raw Blame History

MNG 채번 규칙 관리 UI 계획

작성일: 2026-02-07 보완일: 2026-02-10 (Alpine.js → Vanilla JS 전환, API 라우트 경로 수정) 목적: MNG 관리자 패널에서 테넌트별 채번 규칙(견적번호, 수주로트번호 등)을 CRUD 관리하는 UI 구현 기준 문서: docs/plans/tenant-numbering-system-plan.md (API 채번 시스템) 상태: 대기


1. 개요

1.1 배경

  • API에 채번 규칙 시스템(numbering_rules, numbering_sequences 테이블)이 이미 구현됨
  • 현재는 Seeder로만 규칙 등록 가능 → MNG에서 관리 UI가 필요
  • 테넌트별로 견적, 수주, 원자재수입검사 등 문서유형별 채번 패턴을 설정/수정/삭제할 수 있어야 함

1.2 기준 원칙

- MNG 독립 모델 사용 (API 테이블 참조, 마이그레이션 생성 금지)
- MNG 기존 패턴 준수: Controller(Blade) + Api Controller(HTMX/JSON) + Service + FormRequest
?- HTMX + 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 테이블

-- 마이그레이션: 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에서 조회 전용)

-- 마이그레이션: 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)

// 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 예시

// 견적: 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의 세그먼트 처리 로직 (참조)

// 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

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

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

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

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

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

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에 추가)

// ⚠️ 컨펌 필요: 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에 추가)

// ⚠️ 컨펌 필요: 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 구조:

{{-- 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)

{{-- 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 패턴)

// 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 예시)

{{-- 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/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 보완되었습니다.