diff --git a/app/Http/Controllers/Api/Admin/NumberingRuleController.php b/app/Http/Controllers/Api/Admin/NumberingRuleController.php new file mode 100644 index 00000000..97061f34 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/NumberingRuleController.php @@ -0,0 +1,101 @@ +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()); + + return response()->json([ + 'success' => true, + 'message' => '채번 규칙이 생성되었습니다.', + 'redirect' => route('numbering-rules.index'), + '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); + } + + return response()->json([ + 'success' => true, + 'message' => '채번 규칙이 수정되었습니다.', + 'redirect' => route('numbering-rules.index'), + ]); + } + + public function destroy(Request $request, int $id): JsonResponse + { + $result = $this->numberingRuleService->deleteRule($id); + + if (! $result) { + return response()->json([ + 'success' => false, + 'message' => '채번 규칙 삭제에 실패했습니다.', + ], 400); + } + + return response()->json([ + 'success' => true, + 'message' => '채번 규칙이 삭제되었습니다.', + 'action' => 'remove', + ]); + } + + 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, + ]); + } +} diff --git a/app/Http/Controllers/NumberingRuleController.php b/app/Http/Controllers/NumberingRuleController.php new file mode 100644 index 00000000..87aac492 --- /dev/null +++ b/app/Http/Controllers/NumberingRuleController.php @@ -0,0 +1,51 @@ + 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, + ]); + } +} diff --git a/app/Http/Requests/StoreNumberingRuleRequest.php b/app/Http/Requests/StoreNumberingRuleRequest.php new file mode 100644 index 00000000..f2c31b21 --- /dev/null +++ b/app/Http/Requests/StoreNumberingRuleRequest.php @@ -0,0 +1,84 @@ + [ + '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', + + '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' => '유효하지 않은 세그먼트 타입입니다.', + 'pattern.*.value.required_if' => '이 세그먼트 타입에는 값이 필요합니다.', + 'pattern.*.format.required_if' => '날짜 타입에는 포맷이 필요합니다.', + 'pattern.*.key.required_if' => '이 세그먼트 타입에는 키가 필요합니다.', + 'pattern.*.map.required_if' => '매핑 타입에는 매핑 테이블이 필요합니다.', + 'sequence_padding.min' => '시퀀스 자릿수는 최소 1 이상이어야 합니다.', + 'sequence_padding.max' => '시퀀스 자릿수는 최대 10까지입니다.', + ]; + } +} diff --git a/app/Http/Requests/UpdateNumberingRuleRequest.php b/app/Http/Requests/UpdateNumberingRuleRequest.php new file mode 100644 index 00000000..3492a694 --- /dev/null +++ b/app/Http/Requests/UpdateNumberingRuleRequest.php @@ -0,0 +1,82 @@ +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', + + '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까지입니다.', + ]; + } +} diff --git a/app/Models/NumberingRule.php b/app/Models/NumberingRule.php new file mode 100644 index 00000000..016049e8 --- /dev/null +++ b/app/Models/NumberingRule.php @@ -0,0 +1,90 @@ + '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; + } +} diff --git a/app/Services/NumberingRuleService.php b/app/Services/NumberingRuleService.php new file mode 100644 index 00000000..7ffed286 --- /dev/null +++ b/app/Services/NumberingRuleService.php @@ -0,0 +1,118 @@ +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'], + '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(), + ]); + } + + public function deleteRule(int $id): bool + { + $rule = $this->getRule($id); + + if (! $rule) { + return false; + } + + return $rule->delete(); + } + + 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(); + } + + 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; + } +} diff --git a/resources/views/numbering/create.blade.php b/resources/views/numbering/create.blade.php new file mode 100644 index 00000000..2ae04e3f --- /dev/null +++ b/resources/views/numbering/create.blade.php @@ -0,0 +1,107 @@ +@extends('layouts.app') + +@section('title', '채번 규칙 생성') + +@section('content') +
+ +
+

채번 규칙 생성

+ + ← 목록으로 + +
+ + +
+

기본 정보

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +

2 → 01,02 / 3 → 001,002

+
+
+ + +
+
+
+ + +
+

패턴 세그먼트

+
+ +
+ + +
+

미리보기

+
+

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

+
+
+ + +
+ + 취소 + + +
+
+@endsection + +@push('scripts') +@include('numbering.partials.segment-editor-js') + +@endpush diff --git a/resources/views/numbering/edit.blade.php b/resources/views/numbering/edit.blade.php new file mode 100644 index 00000000..52e38d0b --- /dev/null +++ b/resources/views/numbering/edit.blade.php @@ -0,0 +1,107 @@ +@extends('layouts.app') + +@section('title', '채번 규칙 수정') + +@section('content') +
+ +
+

채번 규칙 수정

+ + ← 목록으로 + +
+ + +
+

기본 정보

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +

2 → 01,02 / 3 → 001,002

+
+
+ is_active ? 'checked' : '' }} + class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-2 focus:ring-blue-500"> + +
+
+
+ + +
+

패턴 세그먼트

+
+ +
+ + +
+

미리보기

+
+

로딩 중...

+
+
+ + +
+ + 취소 + + +
+
+@endsection + +@push('scripts') +@include('numbering.partials.segment-editor-js') + +@endpush diff --git a/resources/views/numbering/index.blade.php b/resources/views/numbering/index.blade.php new file mode 100644 index 00000000..50fd904e --- /dev/null +++ b/resources/views/numbering/index.blade.php @@ -0,0 +1,77 @@ +@extends('layouts.app') + +@section('title', '채번 규칙 관리') + +@section('content') + +
+

채번 규칙 관리

+ + + 새 규칙 + +
+ + + +
+
+ +
+
+ +
+
+ +
+ +
+
+ + +
+
+
+
+
+@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/numbering/partials/segment-editor-js.blade.php b/resources/views/numbering/partials/segment-editor-js.blade.php new file mode 100644 index 00000000..d55af455 --- /dev/null +++ b/resources/views/numbering/partials/segment-editor-js.blade.php @@ -0,0 +1,364 @@ + diff --git a/resources/views/numbering/partials/table.blade.php b/resources/views/numbering/partials/table.blade.php new file mode 100644 index 00000000..8f5a8831 --- /dev/null +++ b/resources/views/numbering/partials/table.blade.php @@ -0,0 +1,66 @@ +
+ + + + + + + + + + + + + + + @forelse($rules as $rule) + + + + + + + + + + @empty + + + + @endforelse + +
#규칙명문서유형패턴 미리보기리셋주기상태작업
{{ $rule->id }} + {{ $rule->rule_name ?? '-' }} + + {{ $rule->document_type_label }} + ({{ $rule->document_type }}) + + {{ $rule->preview }} + + {{ $rule->reset_period_label }} + + @if($rule->is_active) + + 활성 + + @else + + 비활성 + + @endif + + 수정 + +
+ 등록된 채번 규칙이 없습니다. +
+
+
+ +@if($rules->hasPages()) +
+ {{ $rules->links() }} +
+@endif diff --git a/routes/api.php b/routes/api.php index 3e722517..12f2a4f3 100644 --- a/routes/api.php +++ b/routes/api.php @@ -17,6 +17,7 @@ use App\Http\Controllers\Api\Admin\ProjectManagement\IssueController as PmIssueController; use App\Http\Controllers\Api\Admin\ProjectManagement\ProjectController as PmProjectController; use App\Http\Controllers\Api\Admin\ProjectManagement\TaskController as PmTaskController; +use App\Http\Controllers\Api\Admin\NumberingRuleController; use App\Http\Controllers\Api\Admin\Quote\QuoteFormulaCategoryController; use App\Http\Controllers\Api\Admin\Quote\QuoteFormulaController; use App\Http\Controllers\Api\Admin\RoleController; @@ -655,6 +656,15 @@ Route::get('/database-tables/{table}/columns', [ItemFieldController::class, 'tableColumns'])->name('tableColumns'); }); + // 채번 규칙 관리 API + Route::prefix('numbering-rules')->name('numbering-rules.')->group(function () { + Route::get('/', [NumberingRuleController::class, 'index'])->name('index'); + Route::post('/', [NumberingRuleController::class, 'store'])->name('store'); + Route::put('/{id}', [NumberingRuleController::class, 'update'])->name('update'); + Route::delete('/{id}', [NumberingRuleController::class, 'destroy'])->name('destroy'); + Route::post('/preview', [NumberingRuleController::class, 'preview'])->name('preview'); + }); + /* |-------------------------------------------------------------------------- | 견적수식 관리 API diff --git a/routes/web.php b/routes/web.php index d4b9e510..caeca3de 100644 --- a/routes/web.php +++ b/routes/web.php @@ -27,6 +27,7 @@ use App\Http\Controllers\PostController; use App\Http\Controllers\ProfileController; use App\Http\Controllers\ProjectManagementController; +use App\Http\Controllers\NumberingRuleController; use App\Http\Controllers\QuoteFormulaController; use App\Http\Controllers\RoleController; use App\Http\Controllers\RolePermissionController; @@ -132,6 +133,13 @@ Route::get('/{id}/edit', [DepartmentController::class, 'edit'])->name('edit'); }); + // 채번 규칙 관리 (Blade 화면만) + Route::prefix('numbering-rules')->name('numbering-rules.')->group(function () { + Route::get('/', [NumberingRuleController::class, 'index'])->name('index'); + Route::get('/create', [NumberingRuleController::class, 'create'])->name('create'); + Route::get('/{id}/edit', [NumberingRuleController::class, 'edit'])->name('edit'); + }); + // 사용자 관리 (Blade 화면만) Route::prefix('users')->name('users.')->group(function () { Route::get('/', [UserController::class, 'index'])->name('index');