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
+세그먼트를 추가하면 미리보기가 표시됩니다.
+2 → 01,02 / 3 → 001,002
+로딩 중...
+| # | +규칙명 | +문서유형 | +패턴 미리보기 | +리셋주기 | +상태 | +작업 | +
|---|---|---|---|---|---|---|
| {{ $rule->id }} | ++ {{ $rule->rule_name ?? '-' }} + | ++ {{ $rule->document_type_label }} + ({{ $rule->document_type }}) + | +
+ {{ $rule->preview }}
+ |
+ + {{ $rule->reset_period_label }} + | ++ @if($rule->is_active) + + 활성 + + @else + + 비활성 + + @endif + | ++ 수정 + + | +
| + 등록된 채번 규칙이 없습니다. + | +||||||