- 문서관리 시스템 계획/변경이력/마스터 진행상황 반영 - 중간검사 서식 계획 신규 추가 - 채번규칙 관리 계획 상세화 (1767줄 확장) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
71 KiB
71 KiB
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의 메뉴는
menusDB 테이블 기반 (코드가 아닌 데이터) 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 보완되었습니다.