diff --git a/plans/archive/document-management-system-changelog.md b/plans/archive/document-management-system-changelog.md index 5d833a9..ee81f29 100644 --- a/plans/archive/document-management-system-changelog.md +++ b/plans/archive/document-management-system-changelog.md @@ -1,7 +1,7 @@ # 문서관리 시스템 - 변경 이력 > **본 문서**: `docs/plans/document-management-system-plan.md`의 변경 이력 -> **최종 업데이트**: 2026-01-31 +> **최종 업데이트**: 2026-02-12 --- @@ -25,4 +25,7 @@ | 2026-01-31 | Phase 3.4 완료 | 검사 기준 이미지 이관. 5130/img/inspection/ → mng/public/img/inspection/ (27개 파일). 가이드레일(벽면/측면×6변형), 하단마감재(4), 케이스(4), 절곡기준서(2), 스크린/슬랫/조인트바(각1), L-BAR(1), 연기차단재(1) | 섹션 5.4 | - | | 2026-01-31 | Phase 4.1 완료 | API 엔드포인트 설계. ①DocumentTemplate 모델 6개(Template+ApprovalLine+BasicField+Section+SectionItem+Column) ②DocumentTemplateService(list+show) ③DocumentTemplateController(index+show) ④IndexRequest FormRequest ⑤라우트 2개(GET /v1/document-templates, GET /v1/document-templates/{id}) ⑥DocumentTemplateApi.php Swagger(7개 스키마) ⑦Document 결재 워크플로우 활성화(submit/approve/reject/cancel 4개 엔드포인트) ⑧ApproveRequest+RejectRequest FormRequest ⑨DocumentApi.php Swagger에 결재 4개 추가 ⑩Document.template() 참조 경로 수정 | 섹션 3.4, 4.1, 7 | - | | 2026-01-31 | Phase 4.2 완료 | mng JSON 기반 문서 화면. ①show.blade.php 섹션 테이블 읽기전용 렌더링(complex/select/check/measurement/text 5가지 컬럼 타입) ②select 판정값 배지(적합=초록, 부적합=빨강) ③check 체크마크 SVG ④measurement mono 폰트 ⑤정적 컬럼 매핑(NO/검사항목/기준/방식/주기/규격/분류) ⑥종합판정+비고 Footer(마지막 섹션에 표시) ⑦검사 기준 이미지 표시 ⑧버그 3건 수정: field_key→Str::slug, field_type→field_type, section.name→title | 섹션 3.4 | - | -| 2026-01-31 | Phase 4.3 완료 | 문서 데이터 입력/저장 연동 검증. Phase 2.2~2.3에서 이미 완전 구현 확인: ①edit.blade.php JS 폼 수집(기본필드+섹션데이터+체크박스) ②fetch POST/PATCH→DocumentApiController ③saveDocumentData() EAV 저장(section_id/column_id/row_index) ④판정(적합/부적합) select+종합판정 Footer 저장 정상 ⑤6.2 결정사항 #2(프론트 입력, 결과만 저장) 적용됨. 추가 코드 작업 없음 | 섹션 3.4 | - | \ No newline at end of file +| 2026-01-31 | Phase 4.3 완료 | 문서 데이터 입력/저장 연동 검증. Phase 2.2~2.3에서 이미 완전 구현 확인: ①edit.blade.php JS 폼 수집(기본필드+섹션데이터+체크박스) ②fetch POST/PATCH→DocumentApiController ③saveDocumentData() EAV 저장(section_id/column_id/row_index) ④판정(적합/부적합) select+종합판정 Footer 저장 정상 ⑤6.2 결정사항 #2(프론트 입력, 결과만 저장) 적용됨. 추가 코드 작업 없음 | 섹션 3.4 | - | +| 2026-02-10 | Phase 5 계획 수립 | Phase 5 확장 계획 수립. ①마스터 진행 관리 문서 신규 생성(document-system-master.md) ②중간검사(PQC) 상세 계획(document-system-mid-inspection.md) ③제품검사(FQC) 상세 계획(document-system-product-inspection.md) ④작업일지 상세 계획(document-system-work-log.md) ⑤핵심 결정사항 5건: 조인트바=슬랫하위유지, 제품검사=개소별1문서, 작업일지=하이브리드, 제품검사=품질검사 동일, 기타문서=추후정의 ⑥기존 plan 문서 Phase 5 섹션 업데이트 | 섹션 3.5, 마스터 문서 | - | +| 2026-02-10 | 방안1 채택 | 검사기준서↔테이블컬럼 연동 분석 및 방안1 결정. ①edit.blade.php 분석(검사기준서 탭=section_fields+items, 테이블컬럼 탭=columns, 완전 독립) ②이슈 수정: 스키마 불일치→section_fields 누락이 실제 원인(컬럼은 모두 존재) ③방안1 채택: items.measurement_type→columns 자동 파생, 테이블컬럼 탭은 확인/미세조정용 ④Phase 5.0 신설(3개 작업: 자동파생 JS, 시더 section_fields 추가, 탭 모드 전환) ⑤결정사항 #9/#10 추가 ⑥4개 문서 업데이트(master, mid-inspection, product-inspection, changelog) | 마스터 섹션 7.5, 결정사항 | - | +| 2026-02-12 | Phase 5.2 전체 완료 | 제품검사(FQC) 폼 구현 5/5 완료. ①5.2.1 ProductInspectionTemplateSeeder(template_id:65, 결재3+기본필드7+섹션2+항목11) ②5.2.2 mng 양식 편집/미리보기 검증 ③5.2.3 API bulk-create-fqc+fqc-status 엔드포인트(DocumentService.bulkCreateFqc/fqcStatus) ④5.2.4 React fqcActions.ts+FqcDocumentContent.tsx 신규, InspectionReportModal/ProductInspectionInputModal 듀얼모드(FQC양식/legacy하드코딩) 전환 ⑤5.2.5 InspectionDetail FQC 진행현황 통계바+개소별 상태뱃지(합격/불합격/진행중/미생성)+조회버튼. OrderSettingItem.orderId 기반 자동 활성화, 없으면 legacy fallback | Phase 5.2, 마스터 문서 | - | \ No newline at end of file diff --git a/plans/document-management-system-plan.md b/plans/document-management-system-plan.md index 7db3f40..7894962 100644 --- a/plans/document-management-system-plan.md +++ b/plans/document-management-system-plan.md @@ -1,9 +1,13 @@ -# 문서관리 시스템 개발 계획 +# 문서관리 시스템 개발 계획 (Phase 1~4) > **작성일**: 2026-01-31 > **목적**: mng에서 문서양식(템플릿)을 관리하고 문서를 생성하여, SAM(react)에서 JSON으로 소비하는 문서관리 시스템을 구축한다 > **기준 문서**: `docs/specs/database-schema.md`, `mng/CLAUDE.md` -> **상태**: 📋 계획 확정 → Phase 1 시작 대기 +> **상태**: Phase 1~3 ✅ 완료, Phase 4 🔄 (4.4 미완료) +> +> **📌 이 문서는 Phase 1~4 아카이브입니다.** +> **새 작업은 마스터 문서에서 시작하세요**: [`document-system-master.md`](./document-system-master.md) +> Phase 5 상세는 유형별 개별 문서로 분리되었습니다. --- @@ -299,13 +303,16 @@ documents # 문서 인스턴스 | 4.3 | mng에서 문서 데이터 입력/저장 연동 | ✅ | Phase 2.2~2.3에서 이미 완전 구현 확인. edit.blade.php JS→DocumentApiController.saveDocumentData()→document_data EAV 저장. 판정(적합/부적합) select+종합판정 Footer 저장 정상. 6.2 결정사항 #2(프론트 입력, 결과만 저장) 적용됨 | 추가 코드 작업 없음 | | 4.4 | 프론트엔드 담당자 협의 후 react 전환 결정 | ⏳ | mng 완성 후 프론트 담당자와 미팅. react 기존 컴포넌트는 미수정 (6.2 결정사항 #4) | 협의 결과 문서화 | -### 3.5 Phase 5 (추후): 기타 문서 확장 +### 3.5 Phase 5: 문서 유형 확장 -| # | 작업 항목 | 상태 | 비고 | -|---|----------|:----:|------| -| 5.1 | 제품검사 양식 | ⏳ | Phase 3 이후 | -| 5.2 | 작업일지 양식 | ⏳ | WorkLogContent 기반 | -| 5.3 | 견적서/거래명세서/발주서 양식 | ⏳ | 기존 문서 모달 분석 필요 | +> **상세 계획은 개별 문서로 분리됨** → [`document-system-master.md`](./document-system-master.md) + +| # | 작업 항목 | 상태 | 상세 문서 | +|---|----------|:----:|----------| +| 5.1 | 중간검사(PQC) 폼 구현 | ⏳ | [`document-system-mid-inspection.md`](./document-system-mid-inspection.md) | +| 5.2 | 제품검사(FQC) 폼 구현 | ⏳ | [`document-system-product-inspection.md`](./document-system-product-inspection.md) | +| 5.3 | 작업일지 폼 구현 | ⏳ | [`document-system-work-log.md`](./document-system-work-log.md) | +| 5.4 | 기타문서 (견적서/거래명세서/발주서 등) | ⏭️ | 추후 정의 | --- diff --git a/plans/document-system-master.md b/plans/document-system-master.md index 1a2417f..054fda8 100644 --- a/plans/document-system-master.md +++ b/plans/document-system-master.md @@ -2,7 +2,7 @@ > **작성일**: 2026-02-10 > **목적**: mng에서 문서양식(템플릿)을 관리하고, SAM(react)에서 JSON으로 소비하는 문서관리 시스템. 수입검사/중간검사/제품검사/작업일지 폼을 지원한다. -> **상태**: Phase 1~3 ✅, Phase 4 🔄, Phase 5.0 ✅, Phase 5.1 🔄, Phase 5.3 🔄 (5.3.1~3 ✅, 5.3.4 ⏳, mng 상세보기 완료) +> **상태**: Phase 1~3 ✅, Phase 4 🔄, Phase 5.0 ✅, Phase 5.1 🔄, Phase 5.2 ✅, Phase 5.3 🔄 (5.3.1~3 ✅, 5.3.4 ⏳, mng 상세보기 완료) --- @@ -41,10 +41,10 @@ | 항목 | 내용 | |------|------| -| **마지막 완료** | Phase 5.3.3 API ✅ + mng 작업일지 상세보기 완성 (템플릿 컬럼 렌더링, 재단 알고리즘, 개소별 LOT, 취소 상쇄) (2026-02-12) | +| **마지막 완료** | Phase 5.2 제품검사(FQC) 폼 구현 ✅ (5.2.1~5.2.5 전체 완료) (2026-02-12) | | **미완료** | Phase 4.4 - 프론트엔드 담당자 협의 후 react 전환 결정 | | **현재 작업** | Phase 5.1.6 (결재 워크플로우 보류), Phase 5.3.4 (React 전환 대기) | -| **진행률** | Phase 1~3 ✅, Phase 4 (3/4), Phase 5.0 ✅, Phase 5.1 (5/6), Phase 5.3 (3/4+α, mng ✅) | +| **진행률** | Phase 1~3 ✅, Phase 4 (3/4), Phase 5.0 ✅, Phase 5.1 (5/6), **Phase 5.2 ✅**, Phase 5.3 (3/4+α, mng ✅) | | **마지막 업데이트** | 2026-02-12 | --- @@ -59,7 +59,7 @@ | 4 | API 연동 및 mng JSON | 3/4 | 🔄 | [Phase 1~4 아카이브](#9-phase-14-아카이브-요약) | | **5.0** | **공통: 검사기준서↔컬럼 연동 (방안1)** | 3/3 | ✅ | [섹션 7.5](#75-방안1-columns-자동-파생-설계) | | **5.1** | **중간검사(PQC) 폼 구현** | 5/6 | 🔄 | [**document-system-mid-inspection.md**](./document-system-mid-inspection.md) | -| **5.2** | **제품검사(FQC) 폼 구현** | 0/5 | ⏳ | [**document-system-product-inspection.md**](./document-system-product-inspection.md) | +| **5.2** | **제품검사(FQC) 폼 구현** | 5/5 | ✅ | [**document-system-product-inspection.md**](./document-system-product-inspection.md) | | **5.3** | **작업일지 폼 구현** | 3/4+α (mng ✅) | 🔄 | [**document-system-work-log.md**](./document-system-work-log.md) | | 5.4 | 기타문서 확장 | - | ⏭️ | 추후 정의 | diff --git a/plans/document-system-mid-inspection.md b/plans/document-system-mid-inspection.md new file mode 100644 index 0000000..bd20f6c --- /dev/null +++ b/plans/document-system-mid-inspection.md @@ -0,0 +1,239 @@ +# Phase 5.1: 중간검사(PQC) 폼 구현 계획 + +> **작성일**: 2026-02-10 +> **마스터 문서**: [`document-system-master.md`](./document-system-master.md) +> **기존 설계**: [`document-management-system-plan.md`](./document-management-system-plan.md) 섹션 5.2~5.3 +> **상태**: 🔄 진행 중 (5/6) +> **선행 조건**: Phase 5.0 ✅ 완료됨 + +--- + +## 1. 개요 + +### 1.1 목적 +mng에서 중간검사 양식 템플릿을 완성하고, React 작업자 화면(`/production/worker-screen`)의 중간검사 모달에서 해당 양식 기반으로 검사 데이터를 입력/저장/조회할 수 있도록 한다. + +### 1.2 현재 상태 + +| 항목 | 상태 | 비고 | +|------|:----:|------| +| mng 시더 (4종) | ✅ | MidInspectionTemplateSeeder: 조인트바(10), 슬랫(11), 스크린(12), 절곡품(13) | +| mng edit.blade.php 탭 | ✅ | 4개 탭 (기본정보/기본필드/검사기준서/컬럼) | +| 검사 기준 이미지 | ✅ | 27개 파일 → `mng/public/img/inspection/` | +| 스키마 정합성 | ✅ | 컬럼 모두 존재 확인 (2026-02-10 분석) | +| section_fields | ✅ | Phase 5.0.2에서 해결: MidInspection 7필드, IncomingInspection 6필드 | +| ProcessStep.document_template_id | ✅ | 2026-02-10 마이그레이션 추가됨 | +| React 중간검사 모달 | ✅ | InspectionReportModal + TemplateInspectionContent (양식 기반 동적 렌더링) | +| React 검사 입력 모달 | ✅ | InspectionInputModal + DynamicInspectionForm (양식 기반) | +| API 검사 문서 생성 | ✅ | createInspectionDocument() 완전 구현. 정규화+레거시 자동 변환 | +| API 검사 데이터 조회 | ✅ | getInspectionTemplate(), resolveInspectionDocument(), getInspectionData() | +| 결재 워크플로우 | ⏳ | API 결재 엔드포인트 준비됨, 프론트 연동 필요 | + +### 1.3 성공 기준 +1. mng에서 4종 중간검사 양식 편집/미리보기 정상 동작 +2. React 작업자 화면에서 양식 기반 중간검사 입력 가능 +3. 개소별(WorkOrderItem별) 검사 데이터 EAV 저장/조회 가능 +4. 결재 워크플로우(작성→검토→승인) 정상 동작 + +--- + +## 2. 데이터 흐름 + +``` +WorkOrder (작업지시) +├─ process_id → Process (공정: 스크린/슬랫/절곡) +├─ sales_order_id → Order (수주) +└─ items: WorkOrderItem[] + ├─ [0] itemName="와이어 스크린", source_order_item_id → OrderItem + ├─ [1] itemName="메쉬 스크린" + └─ [N] ... + ↓ +ProcessStep (공정단계) +├─ needs_inspection = true +├─ document_template_id → DocumentTemplate (중간검사 양식) +└─ step_name = "중간검사" + ↓ +Document (중간검사 문서) +├─ template_id → DocumentTemplate +├─ linkable_type = 'WorkOrder' +├─ linkable_id = work_order.id +├─ status: DRAFT → PENDING → APPROVED +└─ document_data (EAV) + ├─ 기본필드: 품명, 규격, LOT NO, 발주처, 현장명, 검사일자, 검사자 + ├─ 검사데이터: 행(row) = 개소별, 열(column) = 검사항목 + │ ├─ s{섹션}_r{행}_c{컬럼}_sub{인덱스} + │ └─ 예: s1_r0_c4_sub0 = "7400" (1번 개소의 길이 도면치수) + └─ Footer: 부적합내용, 종합판정 +``` + +### 2.1 조인트바 처리 (슬랫 하위) + +``` +Process: 슬랫 +└─ ProcessStep: "중간검사" + └─ document_template_id: 슬랫 양식(11) 또는 조인트바 양식(10) + +React 판별 로직: +if (isJointBar || items?.some(i => i.productName?.includes('조인트바'))) + → SlatJointBarInspectionContent (조인트바 양식) +else + → SlatInspectionContent (슬랫 양식) +``` + +**조인트바 양식 선택 방법** (2가지 옵션): +- **Option A**: WorkOrderItem의 productName으로 프론트에서 분기 (현재 방식) +- **Option B**: ProcessStep에 별도 document_template_id 매핑 (권장) + +--- + +## 3. 작업 항목 + +| # | 작업 | 상태 | 완료 기준 | 비고 | +|---|------|:----:|----------|------| +| 5.1.1 | ~~mng 스키마 정합성 수정~~ → section_fields 생성 | ✅ | Phase 5.0.2에서 해결. MidInspection 7필드, IncomingInspection 6필드 | createSectionFields() 구현 | +| 5.1.2 | mng 중간검사 양식 편집/미리보기 검증 | ✅ | 4종 양식 모두 edit → 미리보기 → 저장 정상 동작 | edit.blade.php 4탭 CRUD | +| 5.1.3 | API 중간검사 문서 생성 연동 | ✅ | createInspectionDocument() 완전 구현. 기존 DRAFT/REJECTED 문서 update, 없으면 create | WorkOrderService line 1810+ | +| 5.1.4 | React 중간검사 모달 → 양식 기반 전환 | ✅ | TemplateInspectionContent 구현. 템플릿/레거시 모드 병행 | InspectionReportModal 두 가지 모드 | +| 5.1.5 | 개소별 검사 데이터 저장/조회 | ✅ | getInspectionData, saveInspectionDocument 구현. 정규화 레코드 형식 | section_id/column_id/row_index/field_key | +| 5.1.6 | 결재 워크플로우 연동 | ⏳ | 작성→검토→승인 3단계 결재. API 엔드포인트 준비됨 | 프론트 결재 UI 연동 필요 | + +--- + +## 4. 공정별 검사 구조 (React 현재) + +### 4.1 스크린 (ScreenInspectionContent) + +| # | 검사항목 | 타입 | 기준 | +|---|---------|------|------| +| 1 | 가공상태 | check | 양호/불량 | +| 2 | 재봉상태 | check | 양호/불량 | +| 3 | 조립상태 | check | 양호/불량 | +| 4 | 길이 | complex | 도면치수 ±4mm | +| 5 | 나비(높이) | complex | 도면치수 ±40mm | +| 6 | 간격 | complex | 400 이하 → OK/NG | + +- **행 수**: WorkOrderItem 수 (개소별 1행) +- **mng 양식 ID**: 12 + +### 4.2 슬랫 (SlatInspectionContent) + +| # | 검사항목 | 타입 | 기준 | +|---|---------|------|------| +| 1 | 가공상태 | check | 양호/불량 | +| 2 | 조립상태 | check | 양호/불량 | +| 3 | 높이(1) | complex | 16.5 ± 1mm | +| 4 | 높이(2) | complex | 14.5 ± 1mm | +| 5 | 길이(엔드락제외) | complex | 도면치수 ±4mm | + +- **행 수**: WorkOrderItem 수 (개소별 1행) +- **mng 양식 ID**: 11 + +### 4.3 조인트바 (SlatJointBarInspectionContent) - 슬랫 하위 + +| # | 검사항목 | 타입 | 기준 | +|---|---------|------|------| +| 1 | 가공상태 | check | 양호/불량 | +| 2 | 조립상태 | check | 양호/불량 | +| 3 | 높이(1) | complex | 16.5 ± 1mm | +| 4 | 높이(2) | complex | 14.5 ± 1mm | +| 5 | 길이 | complex | 300 ± 4mm | +| 6 | 간격 | complex | 150 ± 4mm | + +- **행 수**: 단일 행 (제품 1건 단위) +- **mng 양식 ID**: 10 + +### 4.4 절곡 (BendingInspectionContent) + +| # | 검사항목 | 타입 | 기준 | +|---|---------|------|------| +| 1 | 절곡상태 | check | 양호/불량 | +| 2 | 길이 | complex | 도면치수 ±4mm | +| 3 | 너비 | complex | 도면치수 | +| 4 | 간격 | complex | 5개 포인트 (좌우 각) ±2mm | + +- **행 수**: 구성품별 동적 (제품 코드에 따라 다름) +- **mng 양식 ID**: 13 +- **특이사항**: 제품코드(KSS01/KSS02/KWE01)와 마감유형(S1/S2/S3)에 따라 검사항목 동적 변경 + +### 4.5 절곡 재공품 (BendingWipInspectionContent) + +| # | 검사항목 | 타입 | 기준 | +|---|---------|------|------| +| 1 | 절곡상태 | check | 양호/불량 | +| 2 | 길이 | complex | 고정값 | +| 3 | 너비 | complex | 고정값 | +| 4 | 간격 | complex | 고정값 | + +- **mng 양식**: 신규 생성 필요 (또는 절곡 양식에 통합) + +--- + +## 5. 핵심 파일 경로 + +### mng +| 파일 | 용도 | +|------|------| +| `mng/resources/views/document-templates/edit.blade.php` | 양식 편집 UI | +| `mng/app/Http/Controllers/DocumentTemplateController.php` | 양식 CRUD | +| `mng/database/seeders/MidInspectionTemplateSeeder.php` | 중간검사 시더 | +| `mng/app/Models/DocumentTemplate*.php` | 양식 모델 | + +### api +| 파일 | 용도 | +|------|------| +| `api/app/Http/Controllers/V1/DocumentTemplateController.php` | 양식 조회 API | +| `api/app/Http/Controllers/V1/DocumentController.php` | 문서 CRUD API | +| `api/app/Models/Documents/Document.php` | 문서 모델 | +| `api/database/migrations/2026_02_10_*` | ProcessStep.document_template_id | + +### react +| 파일 | 용도 | +|------|------| +| `react/src/components/production/WorkOrders/documents/InspectionReportModal.tsx` | 중간검사 성적서 모달 (템플릿/레거시 모드 병행) | +| `react/src/components/production/WorkOrders/documents/TemplateInspectionContent.tsx` | ✅ 양식 기반 동적 검사 렌더링 (NEW) | +| `react/src/components/production/WorkOrders/documents/inspection-shared.tsx` | 공유 컴포넌트/유틸 | +| `react/src/components/production/WorkOrders/documents/Screen|Slat|Bending*.tsx` | 공정별 레거시 검사 Content | +| `react/src/components/production/WorkerScreen/InspectionInputModal.tsx` | 검사 입력 모달 (DynamicInspectionForm 포함) | +| `react/src/components/production/WorkerScreen/actions.ts` | 작업자 화면 API 호출 | + +--- + +## 6. 알려진 이슈 + +### 6.1 ~~스키마 불일치~~ → ✅ section_fields 해결됨 (Phase 5.0.2) +- **기존 오해**: Controller가 DB에 없는 컬럼에 접근한다고 판단 +- **실제 상황**: `tolerance`, `standard_criteria`, `measurement_type`, `frequency_n`, `frequency_c`, `field_values` 컬럼 **모두 존재** (마이그레이션 순차 추가됨) +- **실제 문제**: 중간검사 템플릿에 `document_template_section_fields` 레코드가 없었음 +- **해결 완료**: Phase 5.0.2에서 MidInspectionTemplateSeeder에 section_fields 7필드 생성 (category, item, standard, tolerance, method, measurement_type, frequency) + +### 6.1.1 columns 자동 파생 (방안1) +- **결정**: items.measurement_type → columns 자동 파생 (마스터 문서 결정사항 #9) +- columns 정의는 시더에서 생략 가능 → 저장 시 자동 생성 +- 상세: [마스터 문서 섹션 7.5](./document-system-master.md#75-방안1-columns-자동-파생-설계) + +### 6.2 절곡품 동적 구성 +- 제품 코드별로 검사항목이 완전히 달라짐 (구성품 수, 포인트 수 등) +- 기존 계획의 Option C 권장: 기본 양식에 구성품 목록만 정의, 문서 생성 시 제품 코드에 따라 동적 행 구성 + +### 6.3 절곡 재공품 양식 미존재 +- BendingWipInspectionContent에 대응하는 mng 양식 없음 +- 신규 시더 추가 또는 절곡 양식에 통합 필요 + +--- + +## 7. 변경 이력 + +| 날짜 | 내용 | +|------|------| +| 2026-02-10 | Phase 5.1 계획 문서 신규 생성 | +| 2026-02-10 | 이슈 6.1 수정: 스키마 불일치→section_fields 누락. 방안1 채택(columns 자동 파생). 선행조건 Phase 5.0 추가 | +| 2026-02-11 | 5.1.1 완료: Phase 5.0.2에서 section_fields 해결 (MidInspection 7필드) | +| 2026-02-11 | 5.1.2 완료: mng 양식 편집/미리보기 정상 동작 확인 | +| 2026-02-11 | 5.1.3 완료: createInspectionDocument() 완전 구현. 정규화+레거시 형식 지원 | +| 2026-02-11 | 5.1.4 완료: TemplateInspectionContent 양식 기반 동적 렌더링. 템플릿/레거시 모드 병행 | +| 2026-02-11 | 5.1.5 완료: getInspectionData, saveInspectionDocument, resolveInspectionDocument 구현 | +| 2026-02-11 | 상태 분석: Phase 5.1 → 5/6 완료. 결재 워크플로우(5.1.6)만 남음 | + +--- + +*이 문서는 /plan 스킬로 생성되었습니다.* \ No newline at end of file diff --git a/plans/mng-numbering-rule-management-plan.md b/plans/mng-numbering-rule-management-plan.md index fe2950e..7fcce8b 100644 --- a/plans/mng-numbering-rule-management-plan.md +++ b/plans/mng-numbering-rule-management-plan.md @@ -1,6 +1,7 @@ # MNG 채번 규칙 관리 UI 계획 > **작성일**: 2026-02-07 +> **보완일**: 2026-02-10 (Alpine.js → Vanilla JS 전환, API 라우트 경로 수정) > **목적**: MNG 관리자 패널에서 테넌트별 채번 규칙(견적번호, 수주로트번호 등)을 CRUD 관리하는 UI 구현 > **기준 문서**: `docs/plans/tenant-numbering-system-plan.md` (API 채번 시스템) > **상태**: 대기 @@ -18,7 +19,7 @@ ``` - MNG 독립 모델 사용 (API 테이블 참조, 마이그레이션 생성 금지) - MNG 기존 패턴 준수: Controller(Blade) + Api Controller(HTMX/JSON) + Service + FormRequest -- HTMX + Alpine.js로 SPA 유사 UX 제공 +?- HTMX + Vanilla JS로 SPA 유사 UX 제공 (Alpine.js 사용 금지 - MNG 기술 표준) - JSON 패턴 편집을 위한 동적 폼 (세그먼트 추가/삭제/정렬) ``` @@ -41,11 +42,39 @@ | Template | Blade (Plain Laravel, React/Vue 없음) | | CSS | Tailwind CSS | | 비동기 | HTMX 1.9 (페이지 새로고침 없이 테이블/폼 업데이트) | -| JS 프레임워크 | Alpine.js (동적 폼, 탭, 모달) | -| 인증 | Session 기반 (middleware: auth, tenant) | +| JS | Vanilla JS (Alpine.js 사용 금지 - MNG 기술 표준) | +| 인증 | Session 기반 (middleware: auth, hq.member, password.changed) | | Multi-tenant | `session('selected_tenant_id')` 기반 | -### 2.2 참고 패턴 (부서관리 CRUD) +### 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) @@ -69,7 +98,7 @@ mng/resources/views/departments/partials/table.blade.php ← HTMX 파셜 | 1.2 | NumberingRuleService 생성 | ⏳ | CRUD + 미리보기 | | 1.3 | NumberingRuleController (페이지) 생성 | ⏳ | Blade 렌더링 | | 1.4 | Api/Admin/NumberingRuleController 생성 | ⏳ | HTMX/JSON CRUD | -| 1.5 | FormRequest 생성 | ⏳ | JSON 패턴 검증 | +| 1.5 | FormRequest 생성 (Store + Update) | ⏳ | JSON 패턴 검증 | | 1.6 | routes/web.php 라우트 추가 | ⏳ | ⚠️ 컨펌 필요 | ### 3.2 Phase 2: 프론트엔드 (Blade Views) @@ -78,7 +107,7 @@ mng/resources/views/departments/partials/table.blade.php ← HTMX 파셜 |---|----------|:----:|------| | 2.1 | index.blade.php (목록) | ⏳ | HTMX 테이블, 필터 | | 2.2 | partials/table.blade.php | ⏳ | HTMX 파셜 | -| 2.3 | create.blade.php (생성) | ⏳ | Alpine.js 동적 세그먼트 폼 | +| 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 | ⏳ | 실시간 미리보기 | @@ -87,15 +116,214 @@ mng/resources/views/departments/partials/table.blade.php ← HTMX 파셜 | # | 작업 항목 | 상태 | 비고 | |---|----------|:----:|------| -| 3.1 | 사이드바 메뉴 추가 | ⏳ | ⚠️ 컨펌 필요 | +| 3.1 | 사이드바 메뉴 추가 | ⏳ | ⚠️ 컨펌 필요 (DB `menus` 테이블에 INSERT) | | 3.2 | 기능 테스트 | ⏳ | CRUD + 미리보기 | | 3.3 | 기존 시더 데이터 확인 | ⏳ | tenant_id=287 규칙 편집 가능 확인 | --- -## 4. 상세 설계 +## 4. DB 스키마 (API에서 생성 완료, 참조용) -### 4.1 파일 구조 (생성할 파일 목록) +### 4.1 numbering_rules 테이블 + +```sql +-- 마이그레이션: api/database/migrations/2026_02_07_200000_create_numbering_rules_table.php +CREATE TABLE numbering_rules ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '테넌트 ID', + document_type VARCHAR(50) NOT NULL COMMENT '문서유형: quote, order, sale, work_order, material_receipt', + rule_name VARCHAR(100) NULL COMMENT '규칙명 (관리용)', + pattern JSON NOT NULL COMMENT '패턴 정의 (세그먼트 배열)', + reset_period VARCHAR(20) DEFAULT 'daily' COMMENT '시퀀스 리셋 주기: daily, monthly, yearly, never', + sequence_padding INT DEFAULT 2 COMMENT '시퀀스 자릿수 (2→01,02 / 3→001,002)', + is_active TINYINT(1) DEFAULT 1, + created_by BIGINT UNSIGNED NULL, + updated_by BIGINT UNSIGNED NULL, + created_at TIMESTAMP NULL, + updated_at TIMESTAMP NULL, + + UNIQUE KEY uq_tenant_doctype (tenant_id, document_type), + INDEX idx_numbering_rules_tenant (tenant_id) +); +-- ⚠️ SoftDeletes 없음 → Hard Delete +-- ⚠️ UNIQUE(tenant_id, document_type) → 테넌트당 문서유형 1개 규칙만 가능 +``` + +### 4.2 numbering_sequences 테이블 (MNG에서 조회 전용) + +```sql +-- 마이그레이션: api/database/migrations/2026_02_07_200001_create_numbering_sequences_table.php +CREATE TABLE numbering_sequences ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '테넌트 ID', + document_type VARCHAR(50) NOT NULL COMMENT '문서유형', + scope_key VARCHAR(100) DEFAULT '' COMMENT '범위 키 (pair_code 등 카테고리 구분)', + period_key VARCHAR(20) NOT NULL COMMENT '기간 키: 260207(daily), 202602(monthly), 2026(yearly)', + last_sequence INT UNSIGNED DEFAULT 0 COMMENT '마지막 시퀀스 번호', + created_at TIMESTAMP NULL, + updated_at TIMESTAMP NULL, + + UNIQUE KEY uq_numbering_sequence (tenant_id, document_type, scope_key, period_key) +); +-- ⚠️ MNG에서는 읽기 전용 (시퀀스 증가는 API의 NumberingService만 수행) +-- ⚠️ MySQL UPSERT(INSERT...ON DUPLICATE KEY UPDATE)로 원자적 증가 +``` + +### 4.3 기존 시더 데이터 (tenant_id=287) + +```php +// api/database/seeders/NumberingRuleSeeder.php + +// 규칙 1: 견적번호 - KD-PR-{YYMMDD}-{NN} +[ + 'tenant_id' => 287, + 'document_type' => 'quote', + 'rule_name' => '5130 견적번호', + 'pattern' => [ + ['type' => 'static', 'value' => 'KD'], + ['type' => 'separator', 'value' => '-'], + ['type' => 'static', 'value' => 'PR'], + ['type' => 'separator', 'value' => '-'], + ['type' => 'date', 'format' => 'ymd'], + ['type' => 'separator', 'value' => '-'], + ['type' => 'sequence'], + ], + 'reset_period' => 'daily', + 'sequence_padding' => 2, + // 결과: KD-PR-260207-01, KD-PR-260207-02, ... +] + +// 규칙 2: 수주 로트번호 - KD-{pairCode}-{YYMMDD}-{NN} +[ + 'tenant_id' => 287, + 'document_type' => 'order', + 'rule_name' => '5130 수주 로트번호', + 'pattern' => [ + ['type' => 'static', 'value' => 'KD'], + ['type' => 'separator', 'value' => '-'], + ['type' => 'param', 'key' => 'pair_code', 'default' => 'SS'], + ['type' => 'separator', 'value' => '-'], + ['type' => 'date', 'format' => 'ymd'], + ['type' => 'separator', 'value' => '-'], + ['type' => 'sequence'], + ], + 'reset_period' => 'daily', + 'sequence_padding' => 2, + // 결과: KD-SS-260207-01, KD-TS-260207-01, ... + // scope_key = pair_code 값 (SS, TS 등) → pair_code별 독립 시퀀스 +] +``` + +--- + +## 5. JSON 패턴 세그먼트 타입 상세 + +### 5.1 세그먼트 타입 정의 + +| 타입 | 필수 필드 | 선택 필드 | 설명 | +|------|-----------|-----------|------| +| `static` | `value` | - | 고정 문자열 (예: "KD", "PR") | +| `separator` | `value` | - | 구분자 (예: "-", "/", ".") | +| `date` | `format` | - | PHP date format (아래 표 참고) | +| `param` | `key` | `default` | 외부 파라미터 값 사용 | +| `mapping` | `key`, `map` | `default` | 파라미터 값을 코드로 변환 | +| `sequence` | - | - | 자동 순번 (reset_period에 따라 리셋) | + +### 5.2 date format 옵션 + +| format | 출력 | 예시 (2026-02-07) | +|--------|------|-------------------| +| `ymd` | YYMMDD | 260207 | +| `Ymd` | YYYYMMDD | 20260207 | +| `Ym` | YYYYMM | 202602 | +| `ym` | YYMM | 2602 | +| `Y` | YYYY | 2026 | +| `y` | YY | 26 | + +### 5.3 JSON 예시 + +```json +// 견적: KD-PR-260207-01 +[ + {"type": "static", "value": "KD"}, + {"type": "separator", "value": "-"}, + {"type": "static", "value": "PR"}, + {"type": "separator", "value": "-"}, + {"type": "date", "format": "ymd"}, + {"type": "separator", "value": "-"}, + {"type": "sequence"} +] + +// 수주: KD-SS-260207-01 (pair_code에 따라 SS, TS 등 변동) +[ + {"type": "static", "value": "KD"}, + {"type": "separator", "value": "-"}, + {"type": "param", "key": "pair_code", "default": "SS"}, + {"type": "separator", "value": "-"}, + {"type": "date", "format": "ymd"}, + {"type": "separator", "value": "-"}, + {"type": "sequence"} +] + +// 매핑 예시: product_category → SC/ST 코드 변환 +[ + {"type": "static", "value": "SAM"}, + {"type": "separator", "value": "-"}, + {"type": "mapping", "key": "product_category", "map": {"screen": "SC", "steel": "ST"}, "default": "XX"}, + {"type": "separator", "value": "-"}, + {"type": "date", "format": "Ym"}, + {"type": "separator", "value": "-"}, + {"type": "sequence"} +] +``` + +### 5.4 API NumberingService의 세그먼트 처리 로직 (참조) + +```php +// api/app/Services/NumberingService.php - generate() 메서드 핵심 로직 +// MNG의 미리보기(preview) 구현 시 이 로직과 동일하게 처리해야 함 + +foreach ($segments as $segment) { + switch ($segment['type']) { + case 'static': + $result .= $segment['value']; + break; + case 'separator': + $result .= $segment['value']; + break; + case 'date': + $result .= now()->format($segment['format']); + break; + case 'param': + $value = $params[$segment['key']] ?? $segment['default'] ?? ''; + $result .= $value; + $scopeKey = $value; // scope_key로 사용 (시퀀스 분리용) + break; + case 'mapping': + $inputValue = $params[$segment['key']] ?? ''; + $value = $segment['map'][$inputValue] ?? $segment['default'] ?? ''; + $result .= $value; + $scopeKey = $value; + break; + case 'sequence': + $periodKey = match ($rule->reset_period) { + 'daily' => now()->format('ymd'), + 'monthly' => now()->format('Ym'), + 'yearly' => now()->format('Y'), + 'never' => 'all', + }; + $nextSeq = $this->nextSequence($tenantId, $documentType, $scopeKey, $periodKey); + $result .= str_pad((string) $nextSeq, $rule->sequence_padding, '0', STR_PAD_LEFT); + break; + } +} +``` + +--- + +## 6. 상세 설계 + +### 6.1 파일 구조 (생성할 파일 목록) ``` mng/ @@ -118,90 +346,17 @@ mng/ │ ├── create.blade.php ← NEW │ ├── edit.blade.php ← NEW │ └── partials/ -│ ├── table.blade.php ← NEW -│ ├── segment-form.blade.php ← NEW -│ └── preview.blade.php ← NEW +│ └── table.blade.php ← NEW └── routes/ - └── web.php ← MODIFY (라우트 추가) + ├── web.php ← MODIFY (Blade 라우트 추가) + └── api.php ← MODIFY (API/HTMX 라우트 추가) ``` -### 4.2 DB 스키마 (이미 존재, 참조용) +### 6.2 Model (`mng/app/Models/NumberingRule.php`) -```sql --- numbering_rules (API에서 생성 완료) -id, tenant_id, document_type(50), rule_name(100), -pattern(JSON), reset_period(20), sequence_padding(INT), -is_active(BOOL), created_by, updated_by, timestamps -UNIQUE(tenant_id, document_type) - --- numbering_sequences (API에서 생성 완료, 조회 전용) -id, tenant_id, document_type(50), scope_key(100), -period_key(20), last_sequence(INT), timestamps -UNIQUE(tenant_id, document_type, scope_key, period_key) -``` - -### 4.3 JSON 패턴 세그먼트 타입 - -| 타입 | 필드 | 예시 | 설명 | -|------|------|------|------| -| `static` | `value` | `{"type":"static","value":"KD"}` | 고정 문자열 | -| `separator` | `value` | `{"type":"separator","value":"-"}` | 구분자 | -| `date` | `format` | `{"type":"date","format":"ymd"}` | PHP date format | -| `param` | `key`, `default` | `{"type":"param","key":"pair_code","default":"SS"}` | 외부 파라미터 | -| `mapping` | `key`, `map`, `default` | `{"type":"mapping","key":"product_category","map":{"screen":"SC","steel":"ST"},"default":"SC"}` | 값 매핑 | -| `sequence` | (없음) | `{"type":"sequence"}` | 자동 순번 | - -### 4.4 UI 설계 - -#### 목록 페이지 (`index.blade.php`) -``` -┌──────────────────────────────────────────────────────────┐ -│ 채번 규칙 관리 [+ 새 규칙] │ -├──────────────────────────────────────────────────────────┤ -│ [문서유형 ▼] [상태 ▼] [검색...] [검색 버튼] │ -├──────────────────────────────────────────────────────────┤ -│ # │ 규칙명 │ 문서유형 │ 패턴 미리보기 │ 상태 │ 작업 │ -│ 1 │ 5130 견적번호 │ quote │ KD-PR-260207-01 │ 활성 │ 수정/삭제│ -│ 2 │ 5130 수주 로트 │ order │ KD-SS-260207-01 │ 활성 │ 수정/삭제│ -└──────────────────────────────────────────────────────────┘ -``` - -#### 생성/수정 페이지 (`create.blade.php` / `edit.blade.php`) -``` -┌──────────────────────────────────────────────────────────┐ -│ 채번 규칙 생성 ← 목록으로 │ -├──────────────────────────────────────────────────────────┤ -│ ┌─ 기본 정보 ──────────────────────────────────────────┐ │ -│ │ 규칙명: [________] 문서유형: [quote ▼] │ │ -│ │ 리셋주기: [daily ▼] 시퀀스 자릿수: [2] │ │ -│ │ 활성: [✓] │ │ -│ └──────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─ 패턴 세그먼트 ─────────────────────────────────────┐ │ -│ │ ① [static ▼] value: [KD] [✕] [↕] │ │ -│ │ ② [separator ▼] value: [-] [✕] [↕] │ │ -│ │ ③ [static ▼] value: [PR] [✕] [↕] │ │ -│ │ ④ [separator ▼] value: [-] [✕] [↕] │ │ -│ │ ⑤ [date ▼] format: [ymd] [✕] [↕] │ │ -│ │ ⑥ [separator ▼] value: [-] [✕] [↕] │ │ -│ │ ⑦ [sequence ▼] [✕] [↕] │ │ -│ │ [+ 세그먼트 추가] │ │ -│ └──────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─ 미리보기 ──────────────────────────────────────────┐ │ -│ │ 생성 예시: KD-PR-260207-01 │ │ -│ │ KD-PR-260207-02 │ │ -│ └──────────────────────────────────────────────────────┘ │ -│ │ -│ [취소] [저장] │ -└──────────────────────────────────────────────────────────┘ -``` - -### 4.5 핵심 구현 코드 (Blueprint) - -#### Model (`mng/app/Models/NumberingRule.php`) ```php 'integer', ]; + // ⚠️ SoftDeletes 없음 (DB에 deleted_at 컬럼 없음) → Hard Delete + // 문서유형 상수 const DOC_QUOTE = 'quote'; const DOC_ORDER = 'order'; @@ -253,29 +416,52 @@ class NumberingRule extends Model /** * 패턴 미리보기 문자열 생성 (실제 시퀀스 없이) + * 목록 테이블에서 간략 미리보기로 사용 */ 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']), - 'param' => $segment['default'] ?? '{' . $segment['key'] . '}', - 'mapping' => $segment['default'] ?? '{' . $segment['key'] . '}', + $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; + } } ``` -#### Service (`mng/app/Services/NumberingRuleService.php`) +### 6.3 Service (`mng/app/Services/NumberingRuleService.php`) + ```php where('tenant_id', $tenantId); } - if (!empty($filters['document_type'])) { + 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'])) { + if (! empty($filters['search'])) { $query->where('rule_name', 'like', "%{$filters['search']}%"); } return $query->orderBy('document_type')->paginate($perPage); } - public function getRule(int $id): ?NumberingRule { ... } - public function createRule(array $data): NumberingRule { ... } - public function updateRule(int $id, array $data): bool { ... } - public function deleteRule(int $id): bool { ... } + /** + * 단건 조회 + */ + 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; + } } ``` -#### 세그먼트 동적 폼 (Alpine.js) -```javascript -// create.blade.php 내 Alpine.js 컴포넌트 -Alpine.data('patternEditor', () => ({ - segments: [], - segmentTypes: [ - { value: 'static', label: '고정 문자열' }, - { value: 'separator', label: '구분자' }, - { value: 'date', label: '날짜' }, - { value: 'param', label: '외부 파라미터' }, - { value: 'mapping', label: '값 매핑' }, - { value: 'sequence', label: '자동 순번' }, - ], - dateFormats: [ - { value: 'ymd', label: 'YYMMDD (260207)' }, - { value: 'Ymd', label: 'YYYYMMDD (20260207)' }, - { value: 'Ym', label: 'YYYYMM (202602)' }, - { value: 'Y', label: 'YYYY (2026)' }, - ], +### 6.4 Blade Controller (`mng/app/Http/Controllers/NumberingRuleController.php`) - addSegment() { - this.segments.push({ type: 'static', value: '' }); - }, - removeSegment(index) { - this.segments.splice(index, 1); - }, - moveSegment(from, to) { ... }, +```php + { - 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 '01'; - default: return ''; - } - }).join(''); +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 +numberingRuleService->getRules( + $request->all(), + $request->integer('per_page', 20) + ); + + if ($request->header('HX-Request')) { + return view('numbering.partials.table', compact('rules')); + } + + return response()->json([ + 'success' => true, + 'data' => $rules->items(), + 'meta' => [ + 'current_page' => $rules->currentPage(), + 'last_page' => $rules->lastPage(), + 'per_page' => $rules->perPage(), + 'total' => $rules->total(), + ], + ]); + } + + /** + * 생성 + */ + public function store(StoreNumberingRuleRequest $request): JsonResponse + { + $rule = $this->numberingRuleService->createRule($request->validated()); + + if ($request->header('HX-Request')) { + return response()->json([ + 'success' => true, + 'message' => '채번 규칙이 생성되었습니다.', + 'redirect' => route('numbering-rules.index'), + ]); + } + + return response()->json([ + 'success' => true, + 'message' => '채번 규칙이 생성되었습니다.', + 'data' => $rule, + ], 201); + } + + /** + * 수정 + */ + public function update(UpdateNumberingRuleRequest $request, int $id): JsonResponse + { + $result = $this->numberingRuleService->updateRule($id, $request->validated()); + + if (! $result) { + return response()->json([ + 'success' => false, + 'message' => '채번 규칙 수정에 실패했습니다.', + ], 400); + } + + if ($request->header('HX-Request')) { + return response()->json([ + 'success' => true, + 'message' => '채번 규칙이 수정되었습니다.', + 'redirect' => route('numbering-rules.index'), + ]); + } + + return response()->json([ + 'success' => true, + 'message' => '채번 규칙이 수정되었습니다.', + ]); + } + + /** + * 삭제 (Hard Delete) + */ + public function destroy(Request $request, int $id): JsonResponse + { + $result = $this->numberingRuleService->deleteRule($id); + + if (! $result) { + return response()->json([ + 'success' => false, + 'message' => '채번 규칙 삭제에 실패했습니다.', + ], 400); + } + + if ($request->header('HX-Request')) { + return response()->json([ + 'success' => true, + 'message' => '채번 규칙이 삭제되었습니다.', + 'action' => 'remove', + ]); + } + + return response()->json([ + 'success' => true, + 'message' => '채번 규칙이 삭제되었습니다.', + ]); + } + + /** + * 미리보기 (패턴 JSON → 예시 번호) + * 클라이언트 JS 실시간 미리보기 외에, 서버사이드 검증용 + */ + public function preview(Request $request): JsonResponse + { + $pattern = $request->input('pattern', []); + $sequencePadding = $request->integer('sequence_padding', 2); + + $preview = $this->numberingRuleService->generatePreview($pattern, $sequencePadding); + + return response()->json([ + 'success' => true, + 'preview' => $preview, + ]); + } +} +``` + +### 6.6 FormRequest - Store (`mng/app/Http/Requests/StoreNumberingRuleRequest.php`) + +```php + [ + 'required', + 'string', + Rule::in($validTypes), + Rule::unique('numbering_rules', 'document_type') + ->where('tenant_id', $tenantId), + ], + 'rule_name' => 'nullable|string|max:100', + 'reset_period' => ['required', 'string', Rule::in($validResets)], + 'sequence_padding' => 'required|integer|min:1|max:10', + 'is_active' => 'nullable|boolean', + + // JSON 패턴 검증 + 'pattern' => 'required|array|min:1', + 'pattern.*.type' => ['required', 'string', Rule::in([ + 'static', 'separator', 'date', 'param', 'mapping', 'sequence', + ])], + // static, separator: value 필수 + 'pattern.*.value' => 'required_if:pattern.*.type,static|required_if:pattern.*.type,separator|nullable|string|max:50', + // date: format 필수 + 'pattern.*.format' => 'required_if:pattern.*.type,date|nullable|string|max:20', + // param: key 필수 + 'pattern.*.key' => 'required_if:pattern.*.type,param|required_if:pattern.*.type,mapping|nullable|string|max:50', + // param, mapping: default 선택 + 'pattern.*.default' => 'nullable|string|max:50', + // mapping: map 필수 (연관 배열) + 'pattern.*.map' => 'required_if:pattern.*.type,mapping|nullable|array', + 'pattern.*.map.*' => 'nullable|string|max:50', + ]; + } + + public function attributes(): array + { + return [ + 'document_type' => '문서유형', + 'rule_name' => '규칙명', + 'reset_period' => '리셋 주기', + 'sequence_padding' => '시퀀스 자릿수', + 'is_active' => '활성 상태', + 'pattern' => '패턴', + 'pattern.*.type' => '세그먼트 타입', + 'pattern.*.value' => '세그먼트 값', + 'pattern.*.format' => '날짜 포맷', + 'pattern.*.key' => '파라미터 키', + 'pattern.*.default' => '기본값', + 'pattern.*.map' => '매핑 테이블', + ]; + } + + public function messages(): array + { + return [ + 'document_type.required' => '문서유형은 필수입니다.', + 'document_type.unique' => '이 문서유형에 대한 규칙이 이미 존재합니다.', + 'document_type.in' => '유효하지 않은 문서유형입니다.', + 'pattern.required' => '최소 1개 이상의 세그먼트가 필요합니다.', + 'pattern.min' => '최소 1개 이상의 세그먼트가 필요합니다.', + 'pattern.*.type.required' => '세그먼트 타입은 필수입니다.', + 'pattern.*.type.in' => '유효하지 않은 세그먼트 타입입니다.', + 'pattern.*.value.required_if' => '이 세그먼트 타입에는 값이 필요합니다.', + 'pattern.*.format.required_if' => '날짜 타입에는 포맷이 필요합니다.', + 'pattern.*.key.required_if' => '이 세그먼트 타입에는 키가 필요합니다.', + 'pattern.*.map.required_if' => '매핑 타입에는 매핑 테이블이 필요합니다.', + 'sequence_padding.min' => '시퀀스 자릿수는 최소 1 이상이어야 합니다.', + 'sequence_padding.max' => '시퀀스 자릿수는 최대 10까지입니다.', + ]; + } +} +``` + +### 6.7 FormRequest - Update (`mng/app/Http/Requests/UpdateNumberingRuleRequest.php`) + +```php +route('id'); + $validTypes = array_keys(NumberingRule::documentTypes()); + $validResets = array_keys(NumberingRule::resetPeriods()); + + return [ + 'document_type' => [ + 'required', + 'string', + Rule::in($validTypes), + Rule::unique('numbering_rules', 'document_type') + ->where('tenant_id', $tenantId) + ->ignore($ruleId), // 자기 자신 제외 + ], + 'rule_name' => 'nullable|string|max:100', + 'reset_period' => ['required', 'string', Rule::in($validResets)], + 'sequence_padding' => 'required|integer|min:1|max:10', + 'is_active' => 'nullable|boolean', + + // JSON 패턴 검증 (Store와 동일) + 'pattern' => 'required|array|min:1', + 'pattern.*.type' => ['required', 'string', Rule::in([ + 'static', 'separator', 'date', 'param', 'mapping', 'sequence', + ])], + 'pattern.*.value' => 'required_if:pattern.*.type,static|required_if:pattern.*.type,separator|nullable|string|max:50', + 'pattern.*.format' => 'required_if:pattern.*.type,date|nullable|string|max:20', + 'pattern.*.key' => 'required_if:pattern.*.type,param|required_if:pattern.*.type,mapping|nullable|string|max:50', + 'pattern.*.default' => 'nullable|string|max:50', + 'pattern.*.map' => 'required_if:pattern.*.type,mapping|nullable|array', + 'pattern.*.map.*' => 'nullable|string|max:50', + ]; + } + + public function attributes(): array + { + return [ + 'document_type' => '문서유형', + 'rule_name' => '규칙명', + 'reset_period' => '리셋 주기', + 'sequence_padding' => '시퀀스 자릿수', + 'is_active' => '활성 상태', + 'pattern' => '패턴', + 'pattern.*.type' => '세그먼트 타입', + 'pattern.*.value' => '세그먼트 값', + 'pattern.*.format' => '날짜 포맷', + 'pattern.*.key' => '파라미터 키', + 'pattern.*.default' => '기본값', + 'pattern.*.map' => '매핑 테이블', + ]; + } + + public function messages(): array + { + return [ + 'document_type.required' => '문서유형은 필수입니다.', + 'document_type.unique' => '이 문서유형에 대한 규칙이 이미 존재합니다.', + 'document_type.in' => '유효하지 않은 문서유형입니다.', + 'pattern.required' => '최소 1개 이상의 세그먼트가 필요합니다.', + 'pattern.min' => '최소 1개 이상의 세그먼트가 필요합니다.', + 'pattern.*.type.required' => '세그먼트 타입은 필수입니다.', + 'pattern.*.type.in' => '유효하지 않은 세그먼트 타입입니다.', + 'sequence_padding.min' => '시퀀스 자릿수는 최소 1 이상이어야 합니다.', + 'sequence_padding.max' => '시퀀스 자릿수는 최대 10까지입니다.', + ]; + } +} +``` + +### 6.8 라우트 + +#### Blade 라우트 (`mng/routes/web.php`에 추가) + +```php +// ⚠️ 컨펌 필요: routes/web.php 수정 +// 기존 middleware(['auth', 'hq.member', 'password.changed']) 그룹 내부에 추가 + +Route::prefix('numbering-rules')->name('numbering-rules.')->group(function () { + Route::get('/', [\App\Http\Controllers\NumberingRuleController::class, 'index'])->name('index'); + Route::get('/create', [\App\Http\Controllers\NumberingRuleController::class, 'create'])->name('create'); + Route::get('/{id}/edit', [\App\Http\Controllers\NumberingRuleController::class, 'edit'])->name('edit'); +}); +``` + +#### API 라우트 (`mng/routes/api.php`에 추가) + +```php +// ⚠️ 컨펌 필요: routes/api.php 수정 +// 기존 middleware(['web', 'auth', 'hq.member'])->prefix('admin')->name('api.admin.') 그룹 내부에 추가 + +Route::prefix('numbering-rules')->name('numbering-rules.')->group(function () { + Route::get('/', [\App\Http\Controllers\Api\Admin\NumberingRuleController::class, 'index'])->name('index'); + Route::post('/', [\App\Http\Controllers\Api\Admin\NumberingRuleController::class, 'store'])->name('store'); + Route::put('/{id}', [\App\Http\Controllers\Api\Admin\NumberingRuleController::class, 'update'])->name('update'); + Route::delete('/{id}', [\App\Http\Controllers\Api\Admin\NumberingRuleController::class, 'destroy'])->name('destroy'); + Route::post('/preview', [\App\Http\Controllers\Api\Admin\NumberingRuleController::class, 'preview'])->name('preview'); +}); +// → URL: /admin/numbering-rules/*, 이름: api.admin.numbering-rules.* ``` --- -## 5. 구현 순서 & 예상 작업량 +## 7. UI 설계 & Blade 뷰 -| Phase | 작업 | 파일 수 | 예상 | -|-------|------|--------|------| -| 1 | 백엔드 (Model, Service, Controller, FormRequest, Route) | 6개 생성 + 1개 수정 | 중 | -| 2 | 프론트엔드 (Blade Views 6개) | 6개 생성 | 대 (Alpine.js 동적 폼) | +### 7.1 목록 페이지 (`numbering/index.blade.php`) + +``` +┌──────────────────────────────────────────────────────────┐ +│ 채번 규칙 관리 [+ 새 규칙] │ +├──────────────────────────────────────────────────────────┤ +│ [문서유형 ▼] [상태 ▼] [검색...] [검색 버튼] │ +├──────────────────────────────────────────────────────────┤ +│ # │ 규칙명 │ 문서유형 │ 패턴 미리보기 │ 상태 │ 작업 │ +│ 1 │ 5130 견적번호 │ 견적 │ KD-PR-260207-01 │ 활성 │ 수정/삭제│ +│ 2 │ 5130 수주 로트 │ 수주 │ KD-SS-260207-01 │ 활성 │ 수정/삭제│ +└──────────────────────────────────────────────────────────┘ +``` + +**핵심 Blade 구조:** + +```blade +{{-- numbering/index.blade.php --}} +@extends('layouts.app') +@section('title', '채번 규칙 관리') + +@section('content') + {{-- 헤더 --}} +
+

채번 규칙 관리

+ + + 새 규칙 + +
+ + {{-- 필터 --}} + +
+
+ +
+
+ +
+
+ +
+ +
+
+ + {{-- HTMX 테이블 컨테이너 --}} +
+
+
+
+
+@endsection + +@push('scripts') + +@endpush +``` + +### 7.2 테이블 파셜 (`numbering/partials/table.blade.php`) + +```blade +{{-- HTMX로 교체되는 파셜 --}} +
+ + + + + + + + + + + + + + + @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 +``` + +### 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 패턴) + +```javascript +// 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 ``; + case 'date': + return ``; + case 'param': + return ` + `; + case 'mapping': + const mapHtml = (seg._mapEntries || []).map((entry, ei) => ` +
+ + + + +
+ `).join(''); + + return `
+
+ + +
+
+ ${mapHtml} + +
+
`; + case 'sequence': + return `자동 순번 (설정 없음)`; + default: + return ''; + } +} + +// ======================================== +// 세그먼트 전체 렌더링 +// ======================================== +function renderSegments() { + const container = document.getElementById('segmentsContainer'); + + if (segments.length === 0) { + container.innerHTML = '

세그먼트를 추가하세요.

'; + return; + } + + container.innerHTML = segments.map((seg, index) => ` +
+ ${index + 1}. + + + +
+ ${getFieldsHtml(seg, index)} +
+ +
+ + + +
+
+ `).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 = '

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

'; + return; + } + previewEl.innerHTML = ` +
+ 1번: + ${generatePreviewStr(1)} +
+
+ 2번: + ${generatePreviewStr(2)} +
`; +} + +// ======================================== +// 폼 제출 (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 예시) + +```blade +{{-- edit.blade.php --}} +@extends('layouts.app') +@section('title', '채번 규칙 수정') + +@section('content') + {{-- 헤더 --}} +
+

채번 규칙 수정

+ ← 목록으로 +
+ + {{-- 기본 정보 폼 --}} +
+

기본 정보

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ is_active ? 'checked' : '' }} + class="rounded border-gray-300 text-blue-600"> + +
+
+
+ + {{-- 세그먼트 편집 영역 (Vanilla JS로 동적 렌더링) --}} +
+

패턴 세그먼트

+ + {{-- JS가 이 div 내부를 동적으로 렌더링 --}} +
+ + +
+ + {{-- 미리보기 (JS가 동적 업데이트) --}} +
+

미리보기

+
+

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

+
+
+ + {{-- 버튼 --}} +
+ 취소 + +
+@endsection + +@push('scripts') + +@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의 세그먼트 동적 폼 (Alpine.js로 JSON 배열 편집 + 실시간 미리보기) +**핵심 난이도**: Phase 2의 세그먼트 동적 폼 +- Vanilla JS로 JSON 배열 CRUD (추가/삭제/순서변경 + innerHTML 재렌더링) +- 타입별 동적 필드 전환 (static↔date↔param↔mapping) +- mapping 타입의 key-value 맵 에디터 +- 실시간 미리보기 (클라이언트 사이드) +- 폼 제출 시 JSON 직렬화 → fetch API 전송 --- -## 6. 검증 결과 +## 9. 검증 시나리오 -### 6.1 테스트 시나리오 +### 9.1 테스트 케이스 -| 입력 | 예상 결과 | 상태 | -|------|----------|:----:| -| 목록 진입 | tenant_id=287 규칙 2건 표시 | ⏳ | -| 견적 규칙 수정 → 저장 | pattern JSON 업데이트, 미리보기 변경 | ⏳ | -| 새 규칙 생성 (material_receipt) | 규칙 3건으로 증가 | ⏳ | -| 세그먼트 추가/삭제/순서변경 | Alpine.js 동적 폼 동작 | ⏳ | -| 미리보기 버튼 | 실시간 번호 예시 표시 | ⏳ | -| 규칙 삭제 | soft delete 또는 hard delete | ⏳ | -| 중복 document_type 생성 시도 | 유니크 제약 에러 표시 | ⏳ | +| # | 시나리오 | 예상 결과 | 상태 | +|---|---------|----------|:----:| +| 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개 이상의 세그먼트가 필요합니다" 에러 | ⏳ | -### 6.2 성공 기준 +### 9.2 성공 기준 -| 기준 | 달성 | 비고 | -|------|------|------| -| 규칙 CRUD 정상 동작 | ⏳ | 생성/조회/수정/삭제 | -| 세그먼트 동적 편집 | ⏳ | 추가/삭제/순서변경 | -| 실시간 미리보기 | ⏳ | 패턴 변경 시 즉시 반영 | -| 기존 API 채번 로직과 호환 | ⏳ | MNG에서 수정한 규칙이 API에서 정상 작동 | -| MNG 기존 패턴 준수 | ⏳ | HTMX + Alpine.js + Tailwind | +| 기준 | 비고 | +|------|------| +| 규칙 CRUD 정상 동작 | 생성/조회/수정/삭제 | +| 세그먼트 동적 편집 | 추가/삭제/순서변경/타입전환 | +| mapping 에디터 | key-value 추가/삭제 | +| 실시간 미리보기 | 패턴 변경 시 즉시 반영 | +| 기존 API 채번 로직과 호환 | MNG에서 수정한 규칙이 API에서 정상 작동 | +| Unique 제약 처리 | 중복 document_type 에러 표시 | +| MNG 기존 패턴 준수 | HTMX + Vanilla JS + Tailwind | --- -## 7. 참고 문서 +## 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` -- **Alpine.js 동적 폼 참고**: `mng/resources/views/quote-formulas/edit.blade.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 스킬로 생성되었습니다.* \ No newline at end of file +*이 문서는 /sc:plan 스킬로 생성되었으며, 2026-02-10 보완되었습니다.* \ No newline at end of file