docs: archive 37개 + COMPLETED 3개 복원 - 향후 docs/ 정식 문서화 시 참조용

- 완료 문서의 상세 내용은 추후 docs/ 구조화 시 정식 문서에 반영 예정
- HISTORY.md는 요약 인덱스로 유지, 개별 파일은 상세 참조용 보관

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-26 17:34:06 +09:00
parent 730266f069
commit 28b69e5449
78 changed files with 26277 additions and 0 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

View File

@@ -0,0 +1,446 @@
# 5130 → SAM BOM 데이터 마이그레이션 계획
> **작성일**: 2025-01-20
> **목적**: 5130 레거시 시스템의 BOM 데이터를 SAM items 테이블의 bom 컬럼에 마이그레이션
> **기준 문서**: `api/app/Services/Quote/FormulaEvaluatorService.php`
> **상태**: ✅ 완료 (Serena ID: 5130-bom-migration-state)
---
## 📍 현재 진행 상태
| 항목 | 내용 |
|------|------|
| **마지막 완료 작업** | BOM 마이그레이션 실행 완료 (61건) |
| **다음 작업** | 견적 페이지에서 실제 테스트 (사용자 수동 확인) |
| **진행률** | 4/4 (100%) |
| **마지막 업데이트** | 2025-01-20 |
---
## 1. 개요
### 1.1 배경
5130 레거시 시스템에서 SAM으로 품목(items) 마이그레이션이 완료되었으나, 완제품(FG)의 BOM 데이터가 마이그레이션되지 않아 다음 문제가 발생:
```
문제 현상:
- 견적 페이지에서 "국민방화스크린 (일체형) (S0001)" 선택 후 자동 견적 산출 → 합계 0원
- 원인: S0001의 bom 컬럼이 NULL
- items 테이블에서 확인: SELECT bom FROM items WHERE code = 'S0001' → NULL
```
**기존 마이그레이션 상태:**
- Items: 608건 (KDunitprice → items)
- Orders: 24,424건
- Order Items: 43,900건
- ❌ BOM 데이터: 마이그레이션 안됨
### 1.2 기준 원칙
```
┌─────────────────────────────────────────────────────────────────┐
│ 🎯 핵심 원칙 │
├─────────────────────────────────────────────────────────────────┤
│ 1. FormulaEvaluatorService 호환 BOM JSON 형식 생성 │
│ 2. 동적 수량 계산을 위한 quantityFormula 필드 지원 │
│ 3. childItemCode 기반 참조 (child_item_id 아님) │
│ 4. 기존 SAM BOM 패턴과 일관성 유지 │
└─────────────────────────────────────────────────────────────────┘
```
### 1.3 변경 승인 정책
| 분류 | 예시 | 승인 |
|------|------|------|
| ✅ 즉시 가능 | BOM JSON 데이터 추가, 매핑 테이블 생성 | 불필요 |
| ⚠️ 컨펌 필요 | 기존 items 데이터 수정, 새 마이그레이션 스크립트 | **필수** |
| 🔴 금지 | items 테이블 구조 변경, 기존 BOM 삭제 | 별도 협의 |
### 1.4 준수 규칙
- `docs/quickstart/quick-start.md` - 빠른 시작 가이드
- `docs/standards/quality-checklist.md` - 품질 체크리스트
- `api/app/Services/Quote/FormulaEvaluatorService.php` - BOM 계산 로직
---
## 2. 데이터 구조 분석
### 2.1 5130 BOM 구조
```
5130 DB (chandj)
├── KDunitprice (품목 마스터)
│ ├── prodcode: 품목 코드
│ ├── item_name: 품목명
│ └── item_div: [제품], [상품], [부재료], [원재료], [반제품]
├── models (모델 마스터)
│ ├── model_id: PK
│ ├── model_name: KSS01, KSE01, KWE01... (모델 코드)
│ ├── major_category: 스크린 | 철재
│ ├── finishing_type: SUS마감 | EGI마감
│ └── guiderail_type: 벽면형 | 측면형
├── parts (1단계 BOM - 모델별 부품)
│ ├── part_id: PK
│ ├── model_id: FK → models
│ ├── part_name: 가이드레일, 하단마감재 등
│ ├── spec: 120*70, 60*40 등
│ ├── quantity: 수량
│ ├── unit: SET, EA 등
│ └── unitprice: 단가 (문자열, 콤마 포함)
└── parts_sub (2단계 BOM - 부품별 원자재)
├── subpart_id: PK
├── part_id: FK → parts
├── subpart_name: 1번(마감제), 2번(본체) 등
├── material: SUS 1.2T, EGI 1.55T 등
├── quantity: 수량
├── bendSum, plateSum, finalSum: 가공 관련
└── unitPrice, computedPrice, lineTotal: 금액
```
**5130 model_id별 데이터 현황:**
| model_id | model_name | category | finishing | guiderail | parts 수 |
|----------|------------|----------|-----------|-----------|----------|
| 12 | KSS01 | 스크린 | SUS마감 | 벽면형 | 2 |
| 13 | KSS01 | 스크린 | SUS마감 | 측면형 | 2 |
| 14 | KSE01 | 스크린 | SUS마감 | 벽면형 | 2 |
| ... | ... | ... | ... | ... | ... |
**5130 KDunitprice item_div 분포:**
| item_div | 건수 | SAM item_type 매핑 |
|----------|------|-------------------|
| [제품] | 194건 | FG (완제품) |
| [상품] | 260건 | SM (부자재) |
| [부재료] | 48건 | SM (부자재) |
| [원재료] | 24건 | RM (원자재) |
| [반제품] | 73건 | SF (반제품) |
| [무형상품] | 4건 | CS (서비스) |
### 2.2 SAM BOM 구조
```sql
-- SAM items 테이블 BOM 컬럼
items.bom: JSON
```
**SAM BOM JSON 형식 (FormulaEvaluatorService 호환):**
```json
[
{
"childItemCode": "SF-SCR-F01", // 필수: 하위 품목 코드
"quantity": 1, // 필수: 기본 수량
"quantityFormula": "W*H/1000000", // 선택: 동적 수량 계산식
"unit": "M2", // 선택: 단위
"note": "스크린 원단" // 선택: 비고
},
{
"childItemCode": "SF-SCR-M01",
"quantity": 1,
"quantityFormula": "",
"unit": "EA",
"note": "소형용 모터"
}
]
```
**기존 SAM BOM 예시 (FG-SCR-001):**
```json
[
{"unit":"M2","quantity":1,"childItemCode":"SF-SCR-F01","quantityFormula":"W*H/1000000"},
{"unit":"M","quantity":1,"childItemCode":"SF-SCR-F02","quantityFormula":"H/1000"},
{"unit":"EA","quantity":1,"childItemCode":"SF-SCR-M01","quantityFormula":"","note":"소형용"},
{"unit":"EA","quantity":20,"childItemCode":"SM-B002","quantityFormula":"","note":"조립용"}
]
```
### 2.3 핵심 차이점
| 항목 | 5130 | SAM |
|------|------|-----|
| **BOM 저장 위치** | parts/parts_sub 테이블 | items.bom JSON 컬럼 |
| **연결 기준** | model_id (모델 기준) | childItemCode (품목 코드 기준) |
| **수량 계산** | 고정값 + estimate.detailJson | quantityFormula 동적 계산 |
| **단가 계산** | parts.unitprice 고정 | FormulaEvaluatorService 동적 |
| **계층 구조** | 2단계 (parts → parts_sub) | 1단계 (flat JSON array) |
---
## 3. 마이그레이션 전략
### 3.1 접근 방식: 수동 매핑 + 템플릿 기반
5130의 BOM 구조와 SAM의 BOM 구조가 근본적으로 다르기 때문에, 자동 변환이 아닌 **수동 매핑 + 템플릿 기반** 접근 필요:
```
┌─────────────────────────────────────────────────────────────────┐
│ 전략: 완제품(FG) 유형별 BOM 템플릿 정의 │
├─────────────────────────────────────────────────────────────────┤
│ 1. SCREEN 완제품 → screen_bom_template │
│ 2. STEEL 완제품 → steel_bom_template │
│ 3. BENDING 완제품 → bending_bom_template │
│ │
│ 각 템플릿은 FormulaEvaluatorService 호환 JSON 형식으로 정의 │
└─────────────────────────────────────────────────────────────────┘
```
### 3.2 완제품-모델 매핑
**매핑 대상 (SAM items WHERE item_type='FG' AND source='5130'):**
```sql
-- SAM에서 5130에서 마이그레이션된 완제품 목록
SELECT id, code, name, item_category
FROM items
WHERE item_type = 'FG'
AND (legacy_code IS NOT NULL OR code LIKE 'S%');
```
**주요 완제품 매핑 예시:**
| SAM code | SAM name | item_category | 5130 model |
|----------|----------|---------------|------------|
| S0001 | 국민방화스크린(일체형) | SCREEN | KSS01 (스크린/SUS/벽면형) |
| S0002 | 국민방화스크린(분리형) | SCREEN | KSE01 (스크린/SUS/벽면형) |
| ... | ... | ... | ... |
### 3.3 BOM 템플릿 정의
**SCREEN 완제품 BOM 템플릿:**
```json
[
{"childItemCode": "RM-SCR-FABRIC", "quantity": 1, "quantityFormula": "W*H/1000000", "unit": "M2", "note": "스크린 원단"},
{"childItemCode": "PT-SCR-GUIDE", "quantity": 1, "quantityFormula": "H/1000", "unit": "M", "note": "가이드레일"},
{"childItemCode": "PT-SCR-BOTTOM", "quantity": 1, "quantityFormula": "W/1000", "unit": "M", "note": "하단바"},
{"childItemCode": "PT-SCR-CASE", "quantity": 1, "quantityFormula": "W/1000", "unit": "M", "note": "케이스"},
{"childItemCode": "PT-SCR-MOTOR", "quantity": 1, "quantityFormula": "", "unit": "EA", "note": "모터"}
]
```
---
## 4. 작업 절차
### 4.1 Phase 1: 하위 품목 확인 및 생성
| # | 작업 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 1.1 | BOM에 필요한 하위 품목(SF, PT, RM) 목록 정의 | ✅ | 52개 품목 정의됨 |
| 1.2 | SAM items 테이블에 하위 품목 존재 여부 확인 | ✅ | 52개 모두 존재 확인 |
| 1.3 | 누락된 하위 품목 생성 (필요시) | ✅ | 누락 품목 없음 (생성 불필요) |
### 4.2 Phase 2: BOM 템플릿 정의
| # | 작업 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 2.1 | SCREEN 완제품용 BOM 템플릿 정의 | ✅ | FG-SCR-001 (14개 항목) |
| 2.2 | STEEL 완제품용 BOM 템플릿 정의 | ✅ | FG-STL-001 (12개 항목) |
| 2.3 | BENDING 완제품용 BOM 템플릿 정의 | ✅ | FG-BND-001 (6개 항목) |
### 4.3 Phase 3: 마이그레이션 스크립트 작성
| # | 작업 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 3.1 | Migrate5130Bom 커맨드 생성 | ✅ | `api/app/Console/Commands/Migrate5130Bom.php` |
| 3.2 | 완제품-템플릿 매핑 로직 구현 | ✅ | item_category 기반 매핑 |
| 3.3 | items.bom 컬럼 업데이트 로직 구현 | ✅ | DB::table 직접 업데이트 |
| 3.4 | 검증 로직 구현 | ✅ | dry-run, verbose 옵션 지원 |
### 4.4 Phase 4: 검증 및 테스트
| # | 작업 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 4.1 | Migrate5130Bom 커맨드 실행 | ✅ | 61건 처리 완료 |
| 4.2 | 견적 페이지에서 실제 테스트 | ⏳ | 사용자 수동 확인 필요 |
| 4.3 | 결과 문서화 | ✅ | 본 문서 업데이트 |
---
## 5. 기술 상세
### 5.1 FormulaEvaluatorService BOM 처리 로직
```php
// api/app/Services/Quote/FormulaEvaluatorService.php
// BOM JSON 필드 사용 위치:
// 1. getBomItems() - bom JSON 파싱
// 2. calculateBomQuantity() - quantityFormula 평가
// 3. childItemCode로 하위 품목 조회
// 주요 변수:
// - W0, H0: 개구부 치수 (입력값)
// - W1, H1: 제작 치수 (계산값)
// - W, H: W1, H1과 동일
// - M: 면적 (m²)
// - K: 중량 (kg)
```
### 5.2 마이그레이션 스크립트 구조
```php
// api/app/Console/Commands/Migrate5130Bom.php
class Migrate5130Bom extends Command
{
protected $signature = 'migration:migrate-5130-bom
{--dry-run : 실제 변경 없이 시뮬레이션}
{--code= : 특정 품목 코드만 처리}';
// 1. item_category별 BOM 템플릿 정의
private array $bomTemplates = [
'SCREEN' => [...],
'STEEL' => [...],
'BENDING' => [...]
];
// 2. 완제품 조회 (5130 마이그레이션된 FG)
// 3. 템플릿 기반 BOM JSON 생성
// 4. items.bom 컬럼 업데이트
}
```
### 5.3 검증 쿼리
```sql
-- 마이그레이션 전: BOM이 NULL인 완제품
SELECT code, name, item_category
FROM items
WHERE item_type = 'FG'
AND item_category IN ('SCREEN', 'STEEL', 'BENDING')
AND (bom IS NULL OR bom = '[]');
-- 마이그레이션 후: BOM이 있는 완제품
SELECT code, name, item_category, JSON_LENGTH(bom) as bom_count
FROM items
WHERE item_type = 'FG'
AND item_category IN ('SCREEN', 'STEEL', 'BENDING')
AND bom IS NOT NULL
AND JSON_LENGTH(bom) > 0;
```
---
## 6. 컨펌 대기 목록
> 모든 승인 항목 완료
| # | 항목 | 변경 내용 | 영향 범위 | 상태 |
|---|------|----------|----------|------|
| 1 | BOM 템플릿 확정 | SCREEN/STEEL/BENDING별 템플릿 | 견적 계산 | ✅ 완료 |
| 2 | 하위 품목 코드 확정 | childItemCode 명명 규칙 | items 테이블 | ✅ 완료 |
| 3 | 마이그레이션 실행 | items.bom 업데이트 | 완제품 61건 | ✅ 완료 |
---
## 7. 변경 이력
| 날짜 | 항목 | 변경 내용 | 파일 | 승인 |
|------|------|----------|------|------|
| 2025-01-20 | 초안 | 계획 문서 작성 | - | - |
| 2025-01-20 | 분석 | 5130/SAM BOM 구조 분석 완료 | - | - |
| 2025-01-20 | 스크립트 | Migrate5130Bom 커맨드 생성 | `api/app/Console/Commands/Migrate5130Bom.php` | ✅ |
| 2025-01-20 | 실행 | BOM 마이그레이션 실행 (61건) | items.bom 컬럼 | ✅ |
| 2025-01-20 | 문서화 | 결과 문서화 완료 | 본 문서 | ✅ |
---
## 8. 참고 문서
- **FormulaEvaluatorService**: `api/app/Services/Quote/FormulaEvaluatorService.php`
- **기존 마이그레이션**: `api/app/Console/Commands/Migrate5130PriceItems.php`
- **검증 커맨드**: `api/app/Console/Commands/Verify5130Calculation.php`
- **품질 체크리스트**: `docs/standards/quality-checklist.md`
---
## 9. 세션 및 메모리 관리 정책 (Serena Optimized)
### 9.1 세션 시작 시 (Load Strategy)
```javascript
// 순차적 로드
read_memory("5130-bom-migration-state") // 1. 상태 파악
read_memory("5130-bom-migration-rules") // 2. 규칙 확인
read_memory("5130-bom-migration-mappings") // 3. 매핑 확인
```
### 9.2 Serena 메모리 구조
- `5130-bom-migration-state`: { phase, progress, next_step, last_decision }
- `5130-bom-migration-rules`: BOM 템플릿 정의, 변환 규칙
- `5130-bom-migration-mappings`: 완제품-모델 매핑 테이블
---
## 10. 검증 결과
> 2025-01-20 마이그레이션 실행 완료
### 10.1 마이그레이션 실행 결과
```
📊 카테고리별 BOM 적용 현황 (tenant_id=287):
SCREEN: 35건
STEEL: 11건
BENDING: 15건
✅ BOM 적용 완료: 61건
⏳ BOM 미적용: 0건
```
### 10.2 테스트 케이스
| 입력값 | 예상 결과 | 실제 결과 | 상태 |
|--------|----------|----------|------|
| S0001 BOM JSON 확인 | childItemCode 5개 이상 | 14개 항목 적용됨 | ✅ |
| S0001 + W0=2500, H0=2000 | 견적 금액 > 0 | 사용자 확인 필요 | ⏳ |
### 10.3 성공 기준 달성 현황
| 기준 | 달성 | 비고 |
|------|------|------|
| 완제품 BOM NULL → JSON 변환 | ✅ | 61건 변환 완료 |
| BOM JSON 형식 호환 | ✅ | FormulaEvaluatorService 호환 형식 |
| 견적 계산 정상 동작 | ⏳ | 사용자 수동 확인 필요 |
### 10.4 BOM 템플릿 상세
| 카테고리 | 소스 템플릿 | BOM 항목 수 | 적용 완제품 수 |
|----------|------------|------------|--------------|
| SCREEN | FG-SCR-001 | 14개 | 35건 |
| STEEL | FG-STL-001 | 12개 | 11건 |
| BENDING | FG-BND-001 | 6개 | 15건 |
---
## 11. 자기완결성 점검 결과
> Phase 5.5에서 수행된 자기완결성 점검 결과
### 11.1 체크리스트 검증
| # | 검증 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 1 | 작업 목적이 명확한가? | ✅ | S0001 등 BOM NULL → 견적 0원 문제 해결 |
| 2 | 성공 기준이 정의되어 있는가? | ✅ | 섹션 10.2 참조 |
| 3 | 작업 범위가 구체적인가? | ✅ | SCREEN/STEEL/BENDING 완제품 대상 |
| 4 | 의존성이 명시되어 있는가? | ✅ | FormulaEvaluatorService, 하위 품목 |
| 5 | 참고 파일 경로가 정확한가? | ✅ | 섹션 8 참조 |
| 6 | 단계별 절차가 실행 가능한가? | ✅ | 섹션 4 참조 |
| 7 | 검증 방법이 명시되어 있는가? | ✅ | 섹션 10.1 참조 |
| 8 | 모호한 표현이 없는가? | ✅ | 구체적 수치/조건 명시 |
### 11.2 새 세션 시뮬레이션 테스트
| 질문 | 답변 가능 | 참조 섹션 |
|------|:--------:|----------|
| Q1. 이 작업의 목적은 무엇인가? | ✅ | 1.1 배경 |
| Q2. 어디서부터 시작해야 하는가? | ✅ | 4. 작업 절차 |
| Q3. 어떤 파일을 수정해야 하는가? | ✅ | 5.2 마이그레이션 스크립트 |
| Q4. 작업 완료 확인 방법은? | ✅ | 10. 검증 결과 |
| Q5. 막혔을 때 참고 문서는? | ✅ | 8. 참고 문서 |
**결과**: 5/5 통과 → ✅ 자기완결성 확보
---
*이 문서는 /plan 스킬로 생성되었습니다.*

View File

@@ -0,0 +1,828 @@
# 5130 → SAM 자재/수주 데이터 마이그레이션 계획
> **작성일**: 2025-01-19
> **목적**: 5130 레거시 시스템의 품목(KDunitprice, price_*) 및 수주(output, output_extra) 데이터를 SAM 구조(items, orders, order_items)로 마이그레이션
> **기준 문서**: 5130/output/_row.php, 5130/KDunitprice/_row.php, api/database/migrations/*
> **상태**: ✅ 마이그레이션 완료 (Phase 1-4 완료)
---
## 📍 현재 진행 상태
| 항목 | 내용 |
|------|------|
| **마지막 완료 작업** | Phase 4 - 전체 데이터 마이그레이션 실행 완료 |
| **다음 작업** | 완료 (운영 검증 후 문서 아카이브) |
| **진행률** | 14/14 (100%) |
| **마지막 업데이트** | 2026-01-20 |
---
## 1. 개요
### 1.1 배경
5130 레거시 시스템에서 운영 중인 자재/수주 데이터를 SAM 신규 시스템으로 마이그레이션해야 합니다.
- 5130: 플랫 테이블 구조 + JSON 컬럼으로 데이터 저장
- SAM: 정규화된 관계형 테이블 구조 + JSON attributes 필드
### 1.2 기준 원칙
```
┌─────────────────────────────────────────────────────────────────┐
│ 🎯 핵심 원칙 │
├─────────────────────────────────────────────────────────────────┤
│ 📊 데이터 (값): 5130 우선 - 실제 운영 중인 사이트 │
│ 🏗️ 구조: SAM 우선 - 신규 정규화 설계 │
│ 🧮 견적 수식: 동일성 유지 - 5130과 SAM 결과값 일치 필수 │
└─────────────────────────────────────────────────────────────────┘
```
### 1.3 변경 승인 정책
| 분류 | 예시 | 승인 |
|------|------|------:|
| ✅ 즉시 가능 | 필드 추가/변경, 마이그레이션 스크립트 작성, 문서 수정 | 불필요 |
| ⚠️ 컨펌 필요 | 테이블 구조 변경, 새 컬럼 추가, 데이터 타입 변경 | **필수** |
| 🔴 금지 | 기존 데이터 삭제, 운영 DB 직접 수정, 스키마 파괴적 변경 | 별도 협의 |
### 1.4 준수 규칙
- `docs/quickstart/quick-start.md` - 빠른 시작 가이드
- `docs/standards/quality-checklist.md` - 품질 체크리스트
- `docs/specs/database-schema.md` - 데이터베이스 스키마
- `api/CLAUDE.md` - API 개발 규칙
---
## 2. 테이블 매핑 개요
### 2.1 5130 소스 테이블
| 테이블 | 용도 | 주요 필드 |
|--------|------|----------|
| `KDunitprice` | 단가표 (Ecount 연동) | prodcode, item_name, item_div, spec, unit, unitprice |
| `price_raw_materials` | 원자재 단가 | JSON itemList |
| `price_bend` | 절곡 단가 | JSON itemList |
| `output` | 수주 마스터 | ~80개 필드, JSON (screenlist, slatlist, motorList 등) |
| `output_extra` | 수주 부가정보 | ~30개 필드 (parent_num으로 연결) |
### 2.2 SAM 대상 테이블
| 테이블 | 용도 | item_type |
|--------|------|-----------|
| `items` | 통합 품목 마스터 | FG, PT, SM, RM, CS |
| `orders` | 수주 마스터 | - |
| `order_items` | 수주 상세 | - |
| `order_item_components` | 자재 투입 | - |
### 2.3 매핑 관계
```
┌─────────────────────────────────────────────────────────────────┐
│ 5130 → SAM │
├─────────────────────────────────────────────────────────────────┤
│ KDunitprice → items (SM, RM, CS) │
│ price_raw_materials.itemList → items (RM) │
│ price_bend.itemList → items (PT) + price tables │
│ output → orders │
│ output.screenlist/slatlist → order_items │
│ output_extra → order_items.attributes │
└─────────────────────────────────────────────────────────────────┘
```
---
## 3. 대상 범위
### 3.1 Phase 1: 품목 마스터 마이그레이션
| # | 작업 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 1.1 | KDunitprice → items 매핑 분석 | ✅ | 10개 필드 매핑 완료 |
| 1.2 | price_raw_materials → items 매핑 | ✅ | RM 타입, itemList JSON 15개 필드 매핑 |
| 1.3 | price_bend → items 매핑 | ✅ | PT 타입, itemList JSON 18개 필드 매핑 |
| 1.4 | 품목 마이그레이션 스크립트 작성 | ✅ | `Migrate5130PriceItems.php` |
| 1.5 | 품목 데이터 검증 | ✅ | dry-run 621건 성공, item_type 분류 검증 완료 |
### 3.2 Phase 2: 수주 마스터 마이그레이션
| # | 작업 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 2.1 | output → orders 필드 매핑 | ✅ | 69개 필드 분석, 상세 매핑 완료 |
| 2.2 | output JSON → order_items 변환 | ✅ | screenlist, slatlist 구조 분석 완료 |
| 2.3 | output_extra → order_items.attributes | ✅ | 33개 필드, motorList/bendList 등 |
| 2.4 | 수주 마이그레이션 스크립트 작성 | ✅ | `Migrate5130Orders.php` + `order_id_mappings` 테이블 |
| 2.5 | 수주 데이터 검증 | ✅ | dry-run 100건 성공, 필드 매핑 검증 완료 |
### 3.3 Phase 3: 견적 로직 검증
| # | 작업 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 3.1 | 5130 견적 수식 분석 | ✅ | write_form_script.php + fetch_unitprice.php 분석 완료 |
| 3.2 | SAM 견적 수식 구현/검증 | ✅ | Legacy5130Calculator.php + Verify5130Calculation.php |
| 3.3 | 검증 테스트 실행 | ✅ | 5/5 테스트 케이스 통과, 100% 일치 |
---
## 4. 상세 필드 매핑
### 4.1 KDunitprice → items
| 5130 필드 | SAM 필드 | 타입 | 비고 |
|-----------|----------|------|------|
| prodcode | code | string | 품목코드 |
| item_name | name | string | 품목명 |
| item_div | item_type 판별 기준 | - | SM/RM/CS 분류 |
| spec | attributes.spec | JSON | 규격 |
| unit | unit | string | 단위 |
| unitprice | attributes.unit_price | JSON | 단가 |
### 4.2 output → orders (상세 매핑)
#### 4.2.1 기본 정보 매핑
| 5130 필드 | SAM 필드 | 타입 변환 | 비고 |
|-----------|----------|----------|------|
| num | options.legacy_num | int→JSON | 5130 원본 PK 보존 |
| - | id | auto | SAM 신규 PK |
| - | tenant_id | 287 | 경동기업 고정 |
| outdate | received_at | date→datetime | 수주일 |
| orderdate | options.order_date | date | 발주일 |
| outworkplace | site_name | varchar(50) | 현장명 |
| orderman | options.orderman | varchar(20) | 수주담당자 |
| con_num | client_id | int→FK | 거래처 (조회 필요) |
| outputplace | options.output_place | varchar(50) | 출고장소 |
| receiver | options.receiver | varchar(20) | 수령인 |
| phone | client_contact | varchar(15) | 연락처 |
| comment | memo | varchar(250) | 메모 |
| delivery | delivery_method_code | varchar(15) | 배송방법 |
#### 4.2.2 상태 필드 매핑
| 5130 필드 | SAM 필드 | 변환 규칙 | 비고 |
|-----------|----------|----------|------|
| regist_state | status_code | '등록'→'REGISTERED' | 주 상태 |
| screen_state | options.screen_state | 그대로 | 방충망 상태 |
| slat_state | options.slat_state | 그대로 | 슬랫 상태 |
| bend_state | options.bend_state | 그대로 | 절곡 상태 |
| motor_state | options.motor_state | 그대로 | 모터 상태 |
#### 4.2.3 수량/금액 필드
| 5130 필드 | SAM 필드 | 비고 |
|-----------|----------|------|
| screen_su | quantity (합산) | 방충망 수량 |
| slat_su | quantity (합산) | 슬랫 수량 |
| screen_m2 | options.screen_m2 | 방충망 면적 |
| slat_m2 | options.slat_m2 | 슬랫 면적 |
| output_extra.EstimateFinalSum | total_amount | 최종금액 |
| output_extra.EstimateDiscount | discount_amount | 할인금액 |
| output_extra.EstimateDiscountRate | discount_rate | 할인율 |
#### 4.2.4 JSON → order_items 변환 대상
| 5130 JSON 필드 | order_items 유형 | 비고 |
|----------------|-----------------|------|
| screenlist | item_type='SCREEN' | 방충망 품목 |
| slatlist | item_type='SLAT' | 슬랫 품목 |
| output_extra.motorList | item_type='MOTOR' | 모터 품목 |
| output_extra.bendList | item_type='BEND' | 절곡 품목 |
| output_extra.etcList | item_type='ETC' | 기타 품목 |
| output_extra.controllerList | item_type='CTRL' | 컨트롤러 |
| deliveryfeeList | item_type='DELIVERY' | 배송비 |
#### 4.2.5 options JSON에 보존할 필드
```json
{
"legacy_num": "5130 num",
"legacy_extra_num": "output_extra num",
"orderman": "수주담당자",
"output_place": "출고장소",
"receiver": "수령인",
"secondord": "2차 주문처",
"secondordman": "2차 주문 담당자",
"secondordmantel": "2차 주문 연락처",
"screen_state": "방충망 상태",
"slat_state": "슬랫 상태",
"bend_state": "절곡 상태",
"motor_state": "모터 상태",
"screen_m2": "방충망 면적",
"slat_m2": "슬랫 면적",
"warranty": "보증서 여부",
"warrantyNum": "보증서 번호",
"lotNum": "로트번호",
"prodCode": "제품코드",
"ACI": {
"regDate": "인정검사 등록일",
"askDate": "인정검사 요청일",
"doneDate": "인정검사 완료일",
"memo": "인정검사 메모",
"check": "인정검사 체크",
"groupCode": "인정검사 그룹코드",
"groupName": "인정검사 그룹명"
},
"pjnum": "프로젝트 번호",
"major_category": "대분류",
"position": "위치",
"makeWidth": "제작폭",
"makeHeight": "제작높이",
"maguriWing": "마구리날개"
}
```
### 4.3 screenlist/slatlist → order_items
#### 4.3.1 screenlist JSON 구조
```json
{
"floors": "층수",
"text1": "표시텍스트1",
"text2": "표시텍스트2 (요약)",
"memo": "메모 (재질)",
"cutwidth": "절단폭",
"cutheight": "절단높이",
"number": "수량",
"exititem": "출고여부",
"printside": "인쇄면",
"direction": "방향",
"intervalnum": "간격수",
"intervalnumsecond": "2차간격수",
"exitinterval": "출고간격",
"cover": "커버",
"drawbottom1": "하부도면1",
"drawbottom2": "하부도면2",
"drawbottom3": "하부도면3",
"draw": "도면파일",
"done_check": "완료체크",
"remain_check": "잔여체크",
"mid_check": "중간체크",
"left_check": "좌측체크",
"right_check": "우측체크"
}
```
#### 4.3.2 screenlist → order_items 매핑
| screenlist 필드 | order_items 필드 | 비고 |
|-----------------|-----------------|------|
| - | serial_no | 순번 (1부터) |
| cutwidth + 'x' + cutheight | specification | 규격 (예: 3260x4000) |
| floors | floor_code | 층수 |
| text1 | symbol_code | 기호 |
| number | quantity | 수량 |
| memo | remarks | 메모 (재질 등) |
| text2 | note | 요약 텍스트 |
| (전체) | attributes | 원본 JSON 보존 |
#### 4.3.3 slatlist JSON 구조
```json
{
"floors": "층수",
"text1": "기호 (FST-1 등)",
"text2": "요약텍스트",
"memo": "메모 (재질 EGI 1.6T 등)",
"cutwidth": "절단폭",
"cutheight": "절단높이 (총H)",
"number": "수량",
"exititem": "출고여부",
"intervalnum": "간격수 (매수)",
"hinge": "힌지",
"hingenum": "힌지수량",
"hinge_direction": "힌지방향",
"done_check": "완료체크"
}
```
### 4.4 output_extra 상세 매핑
#### 4.4.1 금액 관련 필드
| 5130 필드 | SAM 필드 | 비고 |
|-----------|----------|------|
| estimateTotal | orders.supply_amount | 공급가액 |
| EstimateFirstSum | options.estimate_first | 최초견적 |
| EstimateUpdatetSum | options.estimate_update | 변경견적 |
| EstimateDiffer | options.estimate_diff | 차액 |
| EstimateDiscountRate | orders.discount_rate | 할인율 |
| EstimateDiscount | orders.discount_amount | 할인금액 |
| EstimateFinalSum | orders.total_amount | 최종금액 |
| estimateSurang | options.estimate_quantity | 견적수량 |
| inspectionFee | options.inspection_fee | 검사비용 |
#### 4.4.2 JSON 리스트 필드 (→ order_items)
| 5130 필드 | 건수 | 구조 | SAM 변환 |
|-----------|------|------|----------|
| motorList | 7건 | col1~col8 | order_items (MOTOR) |
| bendList | 10건 | col1~col8 | order_items (BEND) |
| etcList | - | col1~col5 | order_items (ETC) |
| controllerList | - | col1~col4 | order_items (CTRL) |
#### 4.4.3 motorList col 매핑
| col | 내용 | order_items 필드 |
|-----|------|-----------------|
| col1 | 품명 (전동개폐기_단상 220V) | item_name |
| col2 | 용량 (300kg) | specification |
| col3 | 규격 (380*180) | attributes.dimension |
| col4 | 인치 (5인치) | attributes.inch |
| col5 | 수량 | quantity |
| col6 | 형태 (신형) | attributes.type |
| col7 | 옵션 | attributes.option |
| col8 | 전원 (단상) | attributes.power |
#### 4.4.4 bendList col 매핑
| col | 내용 | order_items 필드 |
|-----|------|-----------------|
| col1 | 품명 (가이드레일) | item_name |
| col2 | 재질 (EGI 1.6T) | specification |
| col3 | 길이 (3000) | attributes.length |
| col5 | 폭 (332) | attributes.width |
| col6 | 도면이미지 | attributes.drawing |
| col7 | 수량 | quantity |
| col8 | 비고 | remarks |
### 4.5 견적 수식 분석 (Phase 3.1)
> **분석 대상**: `5130/output/write_form_script.php` (JS), `5130/estimate/fetch_unitprice.php` (PHP)
#### 4.5.1 절곡품 단가 계산
**함수**: `getBendPlatePrice(material, thickness, length, width, qty)`
```javascript
// 5130/output/write_form_script.php (lines 5780-5822)
// item_bend 배열: { col1: 재질, col5: 두께, col17: 면적당단가(원/m²) }
// 1. 재질/두께 정규화
EGI: 1.15 1.2, 1.55 1.6
SUS: 1.15 1.2, 1.55 1.5
// 2. 면적 계산 (mm² → m²)
areaM² = (length × width) / 1,000,000
// 3. 총액 계산 (절삭)
total = Math.floor(unitPricePerM² × areaM² × qty)
```
**데이터 소스**: `price_bend.itemList``window.item_bend` (JS 전역)
#### 4.5.2 비인정 스크린 단가 계산
**함수**: 익명 함수 (tables 배열 내)
```javascript
// 5130/output/write_form_script.php (lines 6794-6822)
// materialBasePrice에서 재질(material)로 단가 조회
// 1. 단가 조회
unitprice = materialBasePrice[material] || 0
// 2. 수량 계산 (타입별 분기)
if (원단류) {
// 세로 기준 1000mm 단위
surang = height / 1000
} else {
// 일반 면적 기준
surang = (width × height) / 1,000,000 × qty
}
// 3. 총액
total = unitprice × surang
```
**데이터 소스**: `price_raw_materials.itemList``window.materialBasePrice` (JS 전역)
#### 4.5.3 철재 스라트 비인정 단가
**함수**: 익명 함수 (tables 배열 내)
```javascript
// 5130/output/write_form_script.php (lines 6824-6881)
// 1. 유형별 단가 조회
type = 방화셔터/방범셔터/단열셔터/이중파이프/조인트바
unitprice = materialBasePrice[type] || 0
// 2. 수량 계산 (유형별 분기)
if (면적 기준: 방화/방범/단열/이중파이프) {
surang = (width × height) / 1,000,000 × qty
} else if (수량 기준: 조인트바) {
surang = qty
}
// 3. 총액
total = unitprice × surang
```
#### 4.5.4 전동 개폐기/제어기 조회
**함수**: `lookupMotorPrice(row)`, `lookupControllerPrice(row)`
```javascript
// 5130/output/write_form_script.php (lines 6886-6920)
// KDunitprice 테이블에서 조회
// unitInfo: { prodcode → unitprice } 매핑
// 전동 개폐기
unitprice = lookupMotorPrice(row)
// → row 데이터(용량, 전원, 형태 등)로 KDunitprice 조회
// 제어기
unitprice = lookupControllerPrice(row)
// → row 데이터(유형, 규격)로 KDunitprice 조회
```
**데이터 소스**: `KDunitprice``window.unitInfo` (JS 전역)
#### 4.5.5 모터 용량 계산 (핵심 로직)
**함수**: `calculateMotorSpec($item, $weight, $BracketInch)` (PHP)
```php
// 5130/estimate/fetch_unitprice.php (lines 200-350)
// 1. 품목 유형 판별
$ItemSel = (substr($item['col4'], 0, 2) === 'KS' ||
substr($item['col4'], 0, 2) === 'KW')
? '스크린' : '철재';
// 2. 용량 결정 테이블
// 스크린: 150K ~ 600K
// 철재: 300K ~ 1000K
// Weight + BracketInch 조합으로 용량 결정
// 3. 브라켓 사이즈 매핑
300-400K 530×320
500-600K 600×350
800-1000K 690×390
```
#### 4.5.6 기타 계산 함수
| 함수 | 용도 | 계산식 |
|------|------|--------|
| `calculateGuidrail()` | 가이드레일 수량 | `col17 / 3490` (기본 길이) |
| `calculateShaft()` | 샤프트 단가 | `col19 × 수량`, 길이별 조회 |
| `calculatePipe()` | 파이프 단가 | `col4(길이)`, `col2(규격)`으로 `col8(단가)` 조회 |
| `slatPrice()` | 인정 슬랫 단가 | `price_raw_materials.col13` |
| `unapprovedSlatPrice()` | 비인정 슬랫 단가 | `price_raw_materials.col15` |
#### 4.5.7 전역 데이터 구조 (JS)
```javascript
// 5130/output/write_form.php에서 PHP→JS 전달
// 비인정 자재 단가 (재질 → 단가)
window.materialBasePrice = {
"실리카": 12000,
"폴리에스터": 8500,
// ...
};
// 비인정 자재 코드 (재질 → 코드)
window.materialBaseCode = {
"실리카": "RM001",
// ...
};
// 절곡품 단가표
var item_bend = [
{ col1: "EGI", col5: 1.2, col17: 45000 },
{ col1: "SUS", col5: 1.5, col17: 85000 },
// ...
];
// KDunitprice 단가 (prodcode → unitprice)
window.unitInfo = {
"MOT300": 250000,
"MOT500": 380000,
// ...
};
```
#### 4.5.8 SAM 구현 시 고려사항
| 구분 | 5130 방식 | SAM 구현 방향 |
|------|----------|--------------|
| 단가 조회 | JS 전역 변수 | Service 클래스 + DB 쿼리 |
| 면적 계산 | JS (mm² → m²) | PHP Helper 함수 |
| 두께 매핑 | JS 하드코딩 | 설정 테이블 or Enum |
| 모터 용량 | PHP 조건문 | 룰 엔진 or 매핑 테이블 |
| 반올림/절삭 | `Math.floor()` | `floor()` 동일 적용 |
---
## 5. 작업 절차
### 5.1 단계별 절차
```
Step 1: 품목 마스터 분석 (Phase 1.1-1.3)
├── KDunitprice 테이블 구조 상세 분석
├── price_raw_materials JSON 구조 분석
├── price_bend JSON 구조 분석
└── SAM items 테이블과 매핑 확정
Step 2: 품목 마이그레이션 (Phase 1.4-1.5)
├── 마이그레이션 스크립트 작성 (Artisan Command)
├── 테스트 데이터로 검증
└── 전체 데이터 마이그레이션
Step 3: 수주 마스터 분석 (Phase 2.1-2.3)
├── output 테이블 80개 필드 분석
├── JSON 필드 (screenlist 등) 구조 분석
├── output_extra 연결 관계 분석
└── SAM orders/order_items 매핑 확정
Step 4: 수주 마이그레이션 (Phase 2.4-2.5)
├── 마이그레이션 스크립트 작성
├── JSON → 관계형 변환 로직 구현
├── 테스트 데이터로 검증
└── 전체 데이터 마이그레이션
Step 5: 견적 로직 검증 (Phase 3)
├── 5130 견적 계산 JS 분석
├── SAM에서 동일 로직 구현/검증
└── 샘플 데이터로 결과 비교
```
### 5.2 분석 템플릿
```markdown
### [테이블명] 분석
**현재 상태 (5130):**
- 테이블: [테이블명]
- 필드 수: [N]개
- 레코드 수: [N]건
**목표 상태 (SAM):**
- 테이블: [테이블명]
- 매핑 필드: [N]개
**필드 매핑:**
| 5130 | SAM | 변환 로직 |
|------|-----|----------|
| | | |
**특이사항:**
- [ ] JSON 변환 필요 여부
- [ ] 타입 변환 필요 여부
- [ ] 기본값 처리 방법
```
---
## 6. 컨펌 대기 목록
> 테이블 구조 변경 등 승인 필요 항목
| # | 항목 | 변경 내용 | 영향 범위 | 상태 |
|---|------|----------|----------|------|
| - | - | - | - | - |
---
## 7. 변경 이력
| 날짜 | 항목 | 변경 내용 | 파일 | 승인 |
|------|------|----------|------|------|
| 2025-01-19 | 초안 | 문서 초안 작성 | - | - |
| 2025-01-19 | Phase 1.1 | KDunitprice → items 매핑 분석 완료 | - | - |
| 2025-01-19 | Phase 1.2 | price_raw_materials → items 매핑 분석 완료 (itemList JSON 15필드) | - | - |
| 2025-01-19 | Phase 1.3 | price_bend → items 매핑 분석 완료 (itemList JSON 18필드) | - | - |
| 2025-01-19 | Phase 1.4 | 품목 마이그레이션 스크립트 작성 완료 | `api/app/Console/Commands/Migrate5130PriceItems.php` | - |
| 2026-01-19 | Phase 2.4 | 수주 마이그레이션 스크립트 작성 완료 | `api/app/Console/Commands/Migrate5130Orders.php`, `api/database/migrations/2026_01_19_202830_create_order_id_mappings_table.php` | - |
| 2026-01-19 | Phase 3.1 | 5130 견적 수식 분석 완료 | `5130/output/write_form_script.php`, `5130/estimate/fetch_unitprice.php` | - |
| 2026-01-19 | Phase 3.2 | SAM 견적 수식 구현 완료 | `api/app/Helpers/Legacy5130Calculator.php`, `api/app/Console/Commands/Verify5130Calculation.php` | - |
| 2026-01-19 | Phase 3.3 | 견적 수식 검증 테스트 실행 | 5/5 테스트 케이스 100% 일치 | - |
| 2026-01-20 | 준비 완료 | Phase 1-3 모든 준비 작업 완료, 실행 대기 | 13/13 작업 완료 | - |
| 2026-01-20 | Phase 4 | 전체 마이그레이션 실행 완료 | items 608건, orders 24,424건, order_items 43,900건 | ✅ |
---
## 8. 참고 문서
### 8.1 5130 소스 코드
- **수주 폼**: `5130/output/write_form.php` (1176줄)
- **견적 계산 JS**: `5130/output/write_form_script.php` (302KB, ~7000줄)
- **단가 조회 PHP**: `5130/estimate/fetch_unitprice.php` (875줄)
- **output 필드**: `5130/output/_row.php` (~80개 필드)
- **output_extra 필드**: `5130/output/_row_extra.php` (~30개 필드)
- **단가표 필드**: `5130/KDunitprice/_row.php`
### 8.2 SAM 스키마
- **items 테이블**: `api/database/migrations/2025_12_13_152507_create_items_table.php`
- **orders 테이블**: `api/database/migrations/2024_11_19_000001_create_orders_table.php`
- **order_items 테이블**: `api/database/migrations/2024_11_19_000002_create_order_items_table.php`
### 8.3 SAM 모델
- **Order 모델**: `api/app/Models/Orders/Order.php`
- **OrderItem 모델**: `api/app/Models/Orders/OrderItem.php`
- **Item 모델**: `api/app/Models/Items/Item.php`
---
## 9. 세션 및 메모리 관리 정책
### 9.1 세션 시작 시 (Load Strategy)
```javascript
// 순차적 로드
read_memory("5130-migration-state") // 1. 상태 파악
read_memory("5130-migration-mappings") // 2. 매핑 정보 로드
read_memory("5130-migration-rules") // 3. 규칙 확인
```
### 9.2 작업 중 관리 (Context Defense)
| 컨텍스트 잔량 | Action | 내용 |
|--------------|--------|------|
| **30% 이하** | 🛠 **Snapshot** | `write_memory("5130-migration-snapshot", "진행상황")` |
| **20% 이하** | 🧹 **Context Purge** | `write_memory("5130-migration-active", "현재 작업")` |
| **10% 이하** | 🛑 **Stop & Save** | 최종 상태 저장 후 세션 교체 권고 |
### 9.3 Serena 메모리 구조
- `5130-migration-state`: { phase, progress, next_step } (JSON 구조)
- `5130-migration-mappings`: 테이블/필드 매핑 정보 (Text)
- `5130-migration-rules`: 변환 규칙, 타입 매핑 (Text)
---
## 10. 검증 결과
### 10.1 Phase 1 품목 마이그레이션 검증 (2025-01-19)
#### 소스 데이터 카운트
| 테이블 | 총 건수 | 활성 건수 | 최신 버전 |
|--------|---------|----------|----------|
| KDunitprice | 603 | 601 (NULL/0) | - |
| price_raw_materials | 14 | 6 | 2025-06-18 |
| price_bend | 3 | 3 | 2025-03-09 |
#### dry-run 검증 결과
| 테이블 | Total | Migrated | Skipped | 결과 |
|--------|-------|----------|---------|:----:|
| KDunitprice | 601 | 601 | 0 | ✅ |
| price_raw_materials | 13 | 13 | 0 | ✅ |
| price_bend | 7 | 7 | 0 | ✅ |
| **합계** | **621** | **621** | **0** | ✅ |
#### item_type 분류 검증
| item_div | 예상 | 실제 | 결과 |
|----------|------|------|:----:|
| [상품] | FG | FG | ✅ |
| [제품] | FG | FG | ✅ |
| [반제품] | PT | PT | ✅ |
| [부재료] | SM | SM | ✅ |
| [원재료] | RM | RM | ✅ |
| [무형상품] | CS | CS | ✅ |
#### item_div 분포 (KDunitprice 601건)
| item_div | 건수 | item_type |
|----------|------|-----------|
| [상품] | 259 | FG |
| [제품] | 193 | FG |
| [반제품] | 73 | PT |
| [부재료] | 48 | SM |
| [원재료] | 24 | RM |
| [무형상품] | 4 | CS |
### 10.2 Phase 2 수주 마이그레이션 검증 (2026-01-19)
#### 소스 데이터 현황
| 테이블/필드 | 총 건수 | 비고 |
|-------------|---------|------|
| output | 24,584 | 전체 수주 |
| output (screenlist 있음) | 9,392 | 방충망 포함 |
| output (slatlist 있음) | 1,955 | 슬랫 포함 |
| output_extra (motorList 있음) | 7 | 모터 포함 |
| output_extra (bendList 있음) | 10 | 절곡 포함 |
#### dry-run 검증 결과
| 항목 | 건수 | 결과 | 비고 |
|------|------|:----:|------|
| orders | 100 | ✅ | 100건 테스트 성공 |
| order_items (screen) | - | ⏳ | 실제 실행 후 확인 |
| order_items (slat) | - | ⏳ | 실제 실행 후 확인 |
| order_items (motor) | 0 | ✅ | motorList 없는 범위 |
| order_items (bend) | 0 | ✅ | bendList 없는 범위 |
#### 샘플 데이터 매핑 검증
**샘플 num=25810**
| 5130 필드 | 값 | SAM 필드 | 변환 결과 | 검증 |
|-----------|-----|----------|----------|:----:|
| outdate | 2025-12-15 | received_at | 2025-12-15 00:00:00 | ✅ |
| outworkplace | IFC | site_name | IFC | ✅ |
| regist_state | 등록 | status_code | REGISTERED | ✅ |
| phone | 010-5231-3134 | client_contact | 010-5231-3134 | ✅ |
| comment | 실리카1틀/... | memo | 실리카1틀/... | ✅ |
| delivery | 직접배차 | delivery_method_code | 직접배차 | ✅ |
| screenlist[0].cutwidth×cutheight | 3260×4000 | specification | 3260x4000 | ✅ |
| screenlist[0].number | 1 | quantity | 1 | ✅ |
| screenlist[0].memo | 실리카 | remarks | 실리카 | ✅ |
**motorList/bendList 구조 검증**
| col | motorList 매핑 | bendList 매핑 | 검증 |
|-----|---------------|--------------|:----:|
| col1 | item_name (전동개폐기_단상 220V) | item_name (가이드레일) | ✅ |
| col2 | specification (300kg) | specification (EGI 1.6T) | ✅ |
| col3 | attributes.dimension (380*180) | attributes.length (3000) | ✅ |
| col5 | quantity (2) | attributes.width (332) | ✅ |
| col6 | attributes.type (신형) | attributes.drawing (이미지경로) | ✅ |
| col7 | attributes.option | quantity (1) | ✅ |
| col8 | attributes.power (단상) | remarks | ✅ |
### 10.3 데이터 정합성 요약
| 테이블 | 5130 건수 | SAM 건수 | 일치 | 비고 |
|--------|----------|----------|:----:|------|
| KDunitprice → items | 601 | (dry-run) | ✅ | Phase 1 검증 완료 |
| price_raw_materials → items | 13 | (dry-run) | ✅ | 최신 버전만 |
| price_bend → items | 7 | (dry-run) | ✅ | 최신 버전만 |
| output → orders | 24,584 | (dry-run) | ✅ | 100건 테스트 성공 |
| screenlist → order_items | 9,392+ | (대기) | ⏳ | 실제 마이그레이션 후 확인 |
| slatlist → order_items | 1,955+ | (대기) | ⏳ | 실제 마이그레이션 후 확인 |
### 10.4 견적 수식 검증 (2026-01-19)
#### 검증 도구
- **Legacy5130Calculator.php**: 5130 호환 계산 헬퍼 클래스
- **Verify5130Calculation.php**: 검증 Artisan 커맨드
- **실행**: `php artisan migration:verify-5130-calculation --W0=3000 --H0=2500 --type=screen`
#### 테스트 결과
| 케이스 | W0×H0 | 유형 | W1 (5130/SAM) | H1 (5130/SAM) | M (m²) | K (kg) | 결과 |
|--------|-------|------|---------------|---------------|--------|--------|:----:|
| 스크린 소형 | 1500×1200 | screen | 1640/1640 | 1550/1550 | 2.542 | 26.34 | ✅ |
| 스크린 중형 | 3000×2500 | screen | 3140/3140 | 2850/2850 | 8.949 | 60.41 | ✅ |
| 스크린 대형 | 5000×4000 | screen | 5140/5140 | 4350/4350 | 22.359 | 115.57 | ✅ |
| 철재 중형 | 2000×1800 | steel | 2110/2110 | 2150/2150 | 4.5365 | 113.41 | ✅ |
| 철재 대형 | 4000×3500 | steel | 4110/4110 | 3850/3850 | 15.8235 | 395.59 | ✅ |
#### 검증 수식
```
스크린 (screen):
├── W1 = W0 + 140 (마진)
├── H1 = H0 + 350 (마진)
├── M = (W1 × H1) / 1,000,000 (m²)
└── K = (M × 2) + (W0 / 1000 × 14.17) (kg)
철재 (steel):
├── W1 = W0 + 110 (마진)
├── H1 = H0 + 350 (마진)
├── M = (W1 × H1) / 1,000,000 (m²)
└── K = M × 25 (kg)
```
#### 모터 용량/브라켓 사이즈 검증
| 케이스 | 중량(K) | 브라켓인치 | 모터용량 | 브라켓사이즈 |
|--------|---------|-----------|---------|-------------|
| 스크린 중형 | 60.41 | 124" | 600K | 600×350 |
| 철재 중형 | 113.41 | 84" | 1000K | 690×390 |
**결과**: 5/5 테스트 케이스 통과 → ✅ **견적 수식 100% 일치 확인**
---
## 11. 자기완결성 점검 결과
### 11.1 체크리스트 검증
| # | 검증 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 1 | 작업 목적이 명확한가? | ✅ | 5130→SAM 데이터 마이그레이션 |
| 2 | 성공 기준이 정의되어 있는가? | ✅ | 데이터 정합성 + 견적 동일성 |
| 3 | 작업 범위가 구체적인가? | ✅ | Phase 1-3 정의됨 |
| 4 | 의존성이 명시되어 있는가? | ✅ | 5130 소스 + SAM 스키마 |
| 5 | 참고 파일 경로가 정확한가? | ✅ | 섹션 8 참조 |
| 6 | 단계별 절차가 실행 가능한가? | ✅ | 섹션 5 참조 |
| 7 | 검증 방법이 명시되어 있는가? | ✅ | 섹션 10 참조 |
| 8 | 모호한 표현이 없는가? | ✅ | 구체적 테이블/필드 명시 |
### 11.2 새 세션 시뮬레이션 테스트
| 질문 | 답변 가능 | 참조 섹션 |
|------|:--------:|----------|
| Q1. 이 작업의 목적은 무엇인가? | ✅ | 1.1 배경 |
| Q2. 어디서부터 시작해야 하는가? | ✅ | 5.1 단계별 절차 |
| Q3. 어떤 테이블을 매핑해야 하는가? | ✅ | 2. 테이블 매핑 개요 |
| Q4. 작업 완료 확인 방법은? | ✅ | 10. 검증 결과 |
| Q5. 막혔을 때 참고 문서는? | ✅ | 8. 참고 문서 |
**결과**: 5/5 통과 → ✅ 자기완결성 확보
---
*이 문서는 /sc:plan 스킬로 생성되었습니다.*

View File

@@ -0,0 +1,406 @@
# SAM ERP 대시보드
## AI 리포트 핵심 키워드 색상 체계 가이드
### (임계값 명확화 버전 v1.4)
> 버전: D1.4 | 작성일: 2026년 1월
---
## 1. AI 리포트 색상 체계 개요
AI 리포트는 각 섹션별 핵심 키워드에 색상을 적용하여 사용자가 즉시 상태를 파악할 수 있도록 합니다. 모든 기준은 명확한 수치로 정의되어 일관된 적용이 가능합니다.
### 1.1 색상 정의
| 색상 | 의미 | 적용 원칙 | 우선순위 |
|:---:|:---:|:---|:---:|
| 🔴 빨간색 | 경고 | 즉각 조치 필요, 한도/기준 초과, 손실 발생 | 1순위 (최우선) |
| 🟠 주황색 | 주의 | 기준의 80~100% 도달, 기한 임박, 검토 필요 | 2순위 |
| 🟢 녹색 | 긍정 | 목표 달성, 정상 완료, 개선, 입금/회수 | 3순위 |
| 🔵 파란색 | 양호 | 안정적 유지, 정상 진행 중, 충분히 확보 | 4순위 |
### 1.2 공통 임계값 원칙
| 구분 | 🔴 경고 | 🟠 주의 | 🟢 긍정 | 🔵 양호 |
|:---|:---|:---|:---|:---|
| 한도 사용률 | 100% 초과 | 85~100% | - | 85% 미만 |
| 전월/전기 대비 증감 | ±20% 이상 | ±10~20% | 개선 방향 변동 | ±10% 이내 |
| 예산 대비 | 100% 초과 | 90~100% | - | 90% 미만 |
| 연체 기간 | 90일 초과 | 30~90일 | 정상 회수 | 만기 전 |
| 운영자금 확보 | 3개월 미만 | 3~6개월 | - | 6개월 이상 |
---
## 2. 일일 일보 섹션
일일 일보는 당일 자금 현황을 요약하여 보여주며, 현금 흐름에 대한 AI 분석 리포트가 함께 제공됩니다.
### 2.1 현금 자산 - 출금 분석
| 키워드 | 색상 | 임계값 기준 | 계산 방식 |
|:---|:---:|:---|:---|
| **출금** | 🔴 빨간색 | 7일 평균 대비 200% 이상 | 당일출금 ÷ 7일평균출금 ≥ 2.0 |
| **점검이 필요** | 🔴 빨간색 | 7일 평균 대비 200% 이상 | 출금 키워드와 함께 사용 |
| **출금 증가** | 🟠 주황색 | 7일 평균 대비 150~200% | 당일출금 ÷ 7일평균출금 1.5~2.0 |
| **정상 출금** | 🔵 파란색 | 7일 평균 대비 150% 미만 | 당일출금 ÷ 7일평균출금 < 1.5 |
#### 적용 예시
- 어제 🔴**3.5억원 출금**했습니다. 최근 7일 평균(1.7억원) 대비 206% 🔴**점검이 필요**합니다.
### 2.2 현금 자산 - 입금 분석
| 키워드 | 색상 | 임계값 기준 | 계산 방식 |
|:---|:---:|:---|:---|
| **입금** | 🟢 녹색 | 입금 발생 (금액 무관) | 당일 입금 > 0 |
| **대규모 입금** | 🟢 녹색 | 월평균 입금의 200% 이상 | 당일입금 ÷ 월평균입금 ≥ 2.0 |
| **주요 원인** | 🟢 녹색 | 입금 원인 설명 시 | 입금 키워드와 함께 사용 |
#### 적용 예시
- 어제 🟢**10.2억원이 입금**되었습니다. 대한건설 선수금 🟢**입금**이 🟢**주요 원인**입니다.
### 2.3 현금 자산 - 운영자금 안정성
| 키워드 | 색상 | 임계값 기준 | 계산 방식 |
|:---|:---:|:---|:---|
| **자금 부족 우려** | 🔴 빨간색 | 월 운영비용 대비 3개월 미만 | 현금자산 ÷ 월운영비 < 3 |
| **자금 관리 필요** | 🟠 주황색 | 운영비용 대비 3~6개월 | 현금자산 ÷ 월운영비 3~6 |
| **확보되어 안정적** | 🔵 파란색 | 운영비용 대비 6개월 이상 | 현금자산 ÷ 월운영비 6 |
#### 적용 예시
- 현금성 자산이 300.2억원입니다. 운영비용(16.7억원) 대비 🔵**18개월 분이 확보되어 안정적**입니다.
### 2.4 외화 현황 - 환율 변동
| 키워드 | 색상 | 임계값 기준 (일일) | 임계값 기준 (주간) |
|:---|:---:|:---|:---|
| **환율 급등** | 🔴 빨간색 | 전일 대비 ±1.5% 이상 또는 ±20원 이상 | 전주 대비 ±3% 이상 |
| **환율 급락** | 🔴 빨간색 | 전일 대비 ±1.5% 이상 또는 ±20원 이상 | 전주 대비 ±3% 이상 |
| **환율 변동 주의** | 🟠 주황색 | 전일 대비 ±1.0~1.5% 또는 ±10~20원 | 전주 대비 ±2~3% |
| **환율 안정** | 🔵 파란색 | 전일 대비 ±1.0% 미만 또는 ±10원 미만 | 전주 대비 ±2% 미만 |
### 2.5 외화 현황 - 환차손익
| 키워드 | 색상 | 임계값 기준 (금액) | 임계값 기준 (비율) |
|:---|:---:|:---|:---|
| **환차손 발생** | 🔴 빨간색 | 평가손실 1,000만원 이상 | 외화보유액 대비 2% 이상 손실 |
| **환리스크 주의** | 🟠 주황색 | 평가손실 500~1,000만원 | 외화보유액 대비 1~2% 손실 |
| **환차익 발생** | 🟢 녹색 | 평가이익 500만원 이상 | 외화보유액 대비 1% 이상 이익 |
| **환율 영향 미미** | 🔵 파란색 | 평가손익 ±500만원 미만 | 외화보유액 대비 ±1% 미만 |
#### 적용 예시
- 전일 대비 환율이 🔴**1.8% 상승(+24원)**했습니다. 외화자산 평가손실 🔴** 1,500만원 환차손 발생** 예상됩니다.
- 전일 대비 환율 변동 0.3%(+4원)으로 🔵**환율 안정**적인 상태입니다. 🔵**환율 영향 미미**합니다.
---
## 3. 당월 예상 지출 내역 섹션
당월 예상되는 지출 항목(매입, 카드, 발행어음 ) 분석하여 전월 대비 예산 대비 현황을 제공합니다.
### 3.1 전월 대비 분석
| 키워드 | 색상 | 임계값 기준 | 계산 방식 |
|:---|:---:|:---|:---|
| **전월 대비 N% 증가** | 🔴 빨간색 | 전월 대비 15% 이상 증가 | (당월-전월) ÷ 전월 0.15 |
| **지출 증가 추이** | 🟠 주황색 | 전월 대비 10~15% 증가 | (당월-전월) ÷ 전월 0.10~0.15 |
| **전월 대비 N% 감소** | 🟢 녹색 | 전월 대비 5% 이상 감소 | (당월-전월) ÷ 전월 -0.05 |
| **전월과 유사** | 🔵 파란색 | 전월 대비 ±10% 이내 | |(당월-전월) ÷ 전월| < 0.10 |
#### 적용 예시
- 이번 예상 지출이 🔴**전월 대비 15% 증가**했습니다. 매입 비용 증가가 주요 원인입니다.
- 이번 예상 지출이 🟢**전월 대비 8% 감소**했습니다. 외주비용 절감이 주요 원인입니다.
### 3.2 예산 대비 분석
| 키워드 | 색상 | 임계값 기준 | 계산 방식 |
|:---|:---:|:---|:---|
| **예산을 N% 초과** | 🔴 빨간색 | 예산 대비 100% 초과 | 예상지출 ÷ 예산 > 1.0 |
| **예산 임박** | 🟠 주황색 | 예산 대비 90~100% | 예상지출 ÷ 예산 0.9~1.0 |
| **예산 내 운영** | 🟢 녹색 | 예산 대비 90% 미만 | 예상지출 ÷ 예산 < 0.9 |
#### 적용 예시
- 이번 예상 지출이 🔴**예산을 12% 초과**했습니다. 비용 항목별 점검이 필요합니다.
- 이번 예상 지출이 🟢**예산 운영** 중입니다. (예산 대비 82%)
### 3.3 항목별 지출 분석 기준
| 지출 항목 | 🔴 경고 | 🟠 주의 | 🟢 긍정 | 🔵 양호 |
|:---|:---|:---|:---|:---|
| 매입 | 전월 대비 20% 이상 증가 | 전월 대비 10~20% 증가 | 전월 대비 감소 | ±10% 이내 |
| 카드 | 한도 100% 초과 | 한도 80~100% 사용 | - | 한도 80% 미만 |
| 발행어음 | 만기 초과 또는 부도 위험 | 만기 D-7일 이내 | 정상 결제 완료 | 만기 D-8일 이상 |
| 인건비 | 예산 대비 100% 초과 | 예산 대비 90~100% | - | 예산 대비 90% 미만 |
---
## 4. 카드/가지급금 관리 섹션
법인카드 사용 현황과 가지급금 발생 현황을 분석하여 세무 리스크를 사전에 안내합니다.
### 4.1 가지급금 전환
| 키워드 | 색상 | 임계값 기준 | 세무 영향 |
|:---|:---:|:---|:---|
| **가지급금으로 전환** | 🔴 빨간색 | 미정리 법인카드 사용 100만원 이상 | 인정이자 4.6% 발생 |
| **인정이자가 발생** | 🔴 빨간색 | 가지급금 잔액 × 4.6% | 법인세 증가 |
| **연간 N만원의 인정이자** | 🔴 빨간색 | 연간 인정이자 100만원 이상 | 가지급금 × 4.6% |
| **가지급금 정리 필요** | 🟠 주황색 | 미정리 법인카드 사용 50~100만원 | 정리 권고 |
#### 적용 예시
- 법인카드 사용 850만원이 🔴**가지급금으로 전환**되었습니다. 🔴** 4.6% 인정이자가 발생**합니다.
- 현재 가지급금 3.5억 × 4.6% = 🔴**연간 1,610만원의 인정이자가 발생** 중입니다.
### 4.2 업무관련성 소명 필요
| 키워드 | 색상 | 임계값 기준 | 발생 사유 |
|:---|:---:|:---|:---|
| **불인정 가맹점 결제** | 🔴 빨간색 | 유흥업소, 귀금속, 상품권 결제 | 가지급금 전환 대상 |
| **본인 청구 결제 감지** | 🟠 주황색 | 상품권, 귀금속, 면세점 1건 이상 | 소명 자료 필요 |
| **주말 사용 감지** | 🟠 주황색 | /일요일 결제 50만원 이상 | 업무관련성 검토 |
| **심야 사용 감지** | 🟠 주황색 | 22시~06시 결제 30만원 이상 | 업무관련성 검토 |
| **해외 사용 감지** | 🟠 주황색 | 해외 결제 발생 | 출장 증빙 필요 |
#### 적용 예시
- 상품권 구매 🟠**본인 청구 결제 감지**. 가지급금 처리 예정입니다.
- 🟠**주말 사용 감지** - 토요일 120만원 결제. 업무관련성 소명이 어려울 있으니 기록을 남겨주세요.
### 4.3 법인세/종합소득세 예상 가중
| 키워드 | 색상 | 임계값 기준 | 계산 방식 |
|:---|:---:|:---|:---|
| **법인세 예상 가중** | 🔴 빨간색 | 추가 법인세 100만원 이상 예상 | 가지급금 인정이자 × 법인세율 |
| **대표자 종합소득세 예상 가중** | 🔴 빨간색 | 추가 종합소득세 50만원 이상 예상 | 인정상여 × 소득세율 |
| **세무 리스크 주의** | 🟠 주황색 | 추가 세금 50만원 미만 예상 | 정리 권고 |
#### 적용 예시
- 가지급금으로 인한 🔴**법인세 예상 가중 320만원** 발생합니다.
- 🔴**대표자 종합소득세 예상 가중 180만원** 예상됩니다. (추가 사용 +10.5%)
---
## 5. 접대비 현황 섹션
접대비 사용 현황과 한도 대비 사용률을 분석하여 세법상 한도 초과 여부를 사전에 안내합니다.
### 5.1 한도 사용률 기준
| 키워드 | 색상 | 임계값 기준 | 계산 방식 |
|:---|:---:|:---|:---|
| **한도 초과 N만원 발생** | 🔴 빨간색 | 한도 사용률 100% 초과 | 사용액 > 한도액 |
| **손금 불산입** | 🔴 빨간색 | 한도 초과액 발생 시 | 초과액 = 사용액 - 한도액 |
| **법인세 부담이 증가** | 🔴 빨간색 | 한도 초과로 인한 법인세 증가 | 초과액 × 법인세율 |
| **잔여 한도 N원** | 🟠 주황색 | 한도 사용률 85~100% | 잔여 = 한도액 - 사용액 |
| **사용 계획을 점검** | 🟠 주황색 | 한도 사용률 85% 이상 | 사용액 ÷ 한도액 ≥ 0.85 |
| **여유 있게 운영** | 🟢 녹색 | 한도 사용률 75% 미만 | 사용액 ÷ 한도액 < 0.75 |
| **정상 운영** | 🔵 파란색 | 한도 사용률 75~85% | 사용액 ÷ 한도액 0.75~0.85 |
#### 세법상 접대비 한도 계산
- 기본한도: 중소기업 3,600만원, 일반기업 2,400만원 (연간)
- 추가한도: 수입금액 × 적용률 (100억 이하 0.3%, 100~500억 0.2%, 500억 초과 0.03%)
#### 적용 예시
- (1분기) 접대비 사용 1,000만원 / 한도 4,012만원 (25%). 🟢**여유 있게 운영** 중입니다.
- 접대비 한도 85% 도달. 🟠**잔여 한도 600만원**입니다. 🟠**사용 계획을 점검** 주세요.
- 🔴**접대비 한도 초과 320만원 발생**. 초과분은 🔴**손금 불산입**되어 🔴**법인세 부담이 증가**합니다.
### 5.2 증빙 관리
| 키워드 | 색상 | 임계값 기준 | 필수 정보 |
|:---|:---:|:---|:---|
| **거래처 정보가 누락** | 🔴 빨간색 | 거래처명 또는 참석자 미입력 1건 이상 | 거래처명, 참석자, 목적 |
| **증빙 누락** | 🔴 빨간색 | 영수증 또는 카드전표 미첨부 1건 이상 | 적격증빙 필수 |
| **기록 보완 필요** | 🟠 주황색 | 상세 내용 미기재 (목적, 장소 ) | 상세 기록 권고 |
| **증빙 완비** | 🟢 녹색 | 모든 필수 정보 입력 완료 | - |
#### 적용 예시
- 접대비 사용 3건(45만원) 🔴**거래처 정보가 누락**되었습니다. 🟠**기록 보완 필요**합니다.
---
## 6. 복리후생비 현황 섹션
복리후생비 사용 현황을 분석하여 비과세 한도 초과 여부와 업계 평균 대비 적정성을 안내합니다.
### 6.1 1인당 복리후생비
| 키워드 | 색상 | 임계값 기준 | 업계 평균 |
|:---|:---:|:---|:---|
| **과다 지출** | 🔴 빨간색 | 1인당 30만원 초과 | 업계 평균의 150% 초과 |
| **지출 증가 추이** | 🟠 주황색 | 1인당 25~30만원 | 업계 평균의 120~150% |
| **업계 평균 내 정상 운영** | 🟢 녹색 | 1인당 15~25만원 | 업계 평균 범위 |
| **적정 운영** | 🔵 파란색 | 1인당 15만원 미만 | 업계 평균 미만 |
#### 적용 예시
- 1인당 복리후생비 20만원. 🟢**업계 평균(15~25만원) 정상 운영** 중입니다.
### 6.2 항목별 비과세 한도
| 항목 | 비과세 한도 | 🔴 경고 기준 | 🟢 정상 기준 |
|:---|:---|:---|:---|
| 식대 | 20만원 | 20만원 초과 초과분 과세 | 20만원 이하 |
| 자가운전보조금 | 20만원 | 20만원 초과 초과분 과세 | 20만원 이하 |
| 출산/보육수당 | 20만원 | 20만원 초과 초과분 과세 | 20만원 이하 |
| 연구보조비 | 20만원 | 20만원 초과 초과분 과세 | 20만원 이하 |
| 야근식대/숙직비 | 실비 정산 | 과다 지급 과세 위험 | 실비 범위 |
### 6.3 비과세 초과 시
| 키워드 | 색상 | 임계값 기준 | 세무 처리 |
|:---|:---:|:---|:---|
| **비과세 한도를 초과** | 🔴 빨간색 | 항목별 비과세 한도 초과 | 초과분 근로소득 과세 |
| **근로소득 과세됩니다** | 🔴 빨간색 | 비과세 초과분 발생 | 원천세 추가 징수 |
| **초과분 N만원 과세 처리** | 🔴 빨간색 | 과세 금액 명시 | 급여에 합산 |
| **한도 임박** | 🟠 주황색 | 비과세 한도의 90% 이상 사용 | 사용 주의 |
#### 적용 예시
- 식대가 25만원으로 🔴**비과세 한도(20만원) 초과**했습니다. 🔴**초과분 5만원 근로소득 과세됩니다**.
---
## 7. 미수금 현황 섹션
미수금 현황을 분석하여 연체 상태, 회수 필요성, 리스크 집중도를 안내합니다.
### 7.1 연체 기간별 분류
| 키워드 | 색상 | 연체 기간 | 조치 수준 |
|:---|:---:|:---|:---|
| **장기 미수금 발생** | 🔴 빨간색 | 90일 초과 | 법적 조치 검토 |
| **회수 조치가 필요** | 🔴 빨간색 | 60~90일 | 적극적 독촉/추심 |
| **연체 발생** | 🟠 주황색 | 30~60일 | 독촉장 발송 |
| **연체 임박** | 🟠 주황색 | 만기 D-7일 ~ 만기 30일 | 사전 연락 |
| **정상 거래** | 🟢 녹색 | 만기 | 정상 관리 |
| **회수 완료** | 🟢 녹색 | 전액 회수 | 완료 처리 |
#### 적용 예시
- 90일 이상 🔴**장기 미수금 3건(2,500만원) 발생**. 🔴**회수 조치가 필요**합니다.
### 7.2 리스크 집중도
| 키워드 | 색상 | 임계값 기준 | 계산 방식 |
|:---|:---:|:---|:---|
| **전체의 N%를 차지** | 🔴 빨간색 | 상위 1개사 미수금 비중 30% 이상 | 거래처 미수금 ÷ 미수금 |
| **리스크 분산이 필요** | 🔴 빨간색 | 상위 1개사 비중 30% 이상 | 집중 리스크 경고 |
| **리스크 관리 필요** | 🟠 주황색 | 상위 3개사 비중 50% 이상 | 분산 권고 |
| **리스크 분산 양호** | 🟢 녹색 | 상위 1개사 비중 20% 미만 | 정상 분산 |
| **리스크 관리 양호** | 🔵 파란색 | 상위 3개사 비중 40% 미만 | 양호한 분산 |
#### 적용 예시
- ()대한전자 미수금 1,500만원으로 🔴**전체의 35% 차지**합니다. 🔴**리스크 분산이 필요**합니다.
- 상위 3개사 미수금 비중 38%. 🔵**리스크 관리 양호**합니다.
### 7.3 미수금 금액 기준
| 키워드 | 색상 | 임계값 기준 | 비고 |
|:---|:---:|:---|:---|
| **대형 미수금** | 🔴 빨간색 | 단일 3,000만원 이상 | 집중 관리 대상 |
| **주요 미수금** | 🟠 주황색 | 단일 1,000~3,000만원 | 관리 주의 |
| **일반 미수금** | 🔵 파란색 | 단일 1,000만원 미만 | 정상 관리 |
---
## 8. 채권추심 현황 섹션
채권추심 진행 현황을 분석하여 법적 조치 상태와 회수 가능성을 안내합니다.
### 8.1 추심 진행 상태
| 키워드 | 색상 | 임계값 기준 | 다음 단계 |
|:---|:---:|:---|:---|
| **회수 불가 판정** | 🔴 빨간색 | 채무자 무자력 확인 또는 소멸시효 완성 | 대손 처리 |
| **파산/회생 신청** | 🔴 빨간색 | 채무자 파산/회생 신청 확인 | 채권 신고 |
| **대손 처리 검토가 필요** | 🟠 주황색 | 회수 가능성 30% 미만 판단 | 세무 검토 |
| **법적 조치 진행 중** | 🔵 파란색 | 소송/강제집행 진행 | 결과 대기 |
| **지급명령 신청 완료** | 🟢 녹색 | 지급명령 신청 접수 완료 | 법원 결정 대기 |
| **회수 완료** | 🟢 녹색 | 채권 전액 또는 일부 회수 | 종결 처리 |
#### 적용 예시
- ()대한전자 🟢**지급명령 신청 완료**. 법원 결정까지 2주 소요 예정입니다.
- ()삼성테크 🔴**회수 불가 판정**. 🟠**대손 처리 검토가 필요**합니다.
### 8.2 예상 소요 기간
| 키워드 | 색상 | 임계값 기준 | 비고 |
|:---|:---:|:---|:---|
| **장기 소송 예상** | 🔴 빨간색 | 예상 소요 기간 6개월 이상 | 비용/효익 검토 필요 |
| **소송 진행 중** | 🟠 주황색 | 예상 소요 기간 3~6개월 | 진행 상황 모니터링 |
| **법원 결정까지 약 N주 소요 예정** | 🔵 파란색 | 예상 소요 기간 3개월 미만 | 정상 진행 |
| **조기 회수 예상** | 🟢 녹색 | 예상 소요 기간 1개월 미만 | 신속 처리 |
### 8.3 회수율 기준
| 키워드 | 색상 | 임계값 기준 | 판단 기준 |
|:---|:---:|:---|:---|
| **회수 불가** | 🔴 빨간색 | 예상 회수율 10% 미만 | 대손 처리 대상 |
| **회수 곤란** | 🔴 빨간색 | 예상 회수율 10~30% | 적극 추심 필요 |
| **부분 회수 예상** | 🟠 주황색 | 예상 회수율 30~70% | 협상 검토 |
| **회수 가능성 높음** | 🟢 녹색 | 예상 회수율 70% 이상 | 정상 추심 |
| **전액 회수 예상** | 🟢 녹색 | 예상 회수율 90% 이상 | 양호 |
---
## 9. 부가세 현황 섹션
부가세 예정/확정 신고 현황을 분석하여 납부/환급 예상액과 세금계산서 발행 현황을 안내합니다.
### 9.1 납부/환급 세액
| 키워드 | 색상 | 임계값 기준 | 판단 근거 |
|:---|:---:|:---|:---|
| **납부세액 급증** | 🔴 빨간색 | 전기 대비 30% 이상 증가 (매출 증가율 대비 초과) | 비정상 증가 |
| **매입세액 누락 의심** | 🔴 빨간색 | 매입세액 ÷ 매출세액 < 업종 평균의 70% | 누락 가능성 |
| **납부세액 증가** | 🟠 주황색 | 전기 대비 15~30% 증가 | 검토 필요 |
| **예상 환급세액** | 🟢 녹색 | 매입세액 > 매출세액 | 환급 발생 |
| **매입세액 증가가 주요 원인** | 🟢 녹색 | 설비투자 등 정당한 매입 증가 시 | 정상 사유 |
| **정상적인 증가로 판단** | 🟢 녹색 | 매출 증가율과 납부세액 증가율 유사 | 정상 범위 |
| **전기 대비 N% 증가** | 🔵 파란색 | 전기 대비 15% 미만 증가 | 정상 변동 |
#### 적용 예시
- 2026년 1기 예정신고 기준, 🟢**예상 환급세액**은 5,200,000원입니다. 설비투자에 따른 🟢**매입세액 증가가 주요 원인**입니다.
- 예상 납부세액 110,100,000원. 🔵**전기 대비 12.9% 증가**했으며, 매출 증가(11.5%)에 따른 🟢**정상적인 증가로 판단**됩니다.
### 9.2 세금계산서 발행 관리
| 키워드 | 색상 | 임계값 기준 | 가산세 |
|:---|:---:|:---|:---|
| **세금계산서 미발행** | 🔴 빨간색 | 발행 기한 경과 후 미발행 1건 이상 | 공급가액의 2% |
| **발행 기한 초과** | 🔴 빨간색 | 공급일 다음 달 10일 경과 | 지연 발급 1% |
| **가산세 발생 위험** | 🔴 빨간색 | 미발행 또는 지연 발행 시 | 최대 2% |
| **발행 기한 임박** | 🟠 주황색 | 발행 기한 D-3일 이내 | 발행 권고 |
| **정상 발행** | 🟢 녹색 | 공급일 다음 달 10일 이내 발행 | 정상 |
#### 적용 예시
- 🔴**세금계산서 미발행** 3건 발생. 🔴**가산세 발생 위험**이 있습니다. 공급가액 1,500만원 × 2% = 30만원
- 12월 매출분 세금계산서 🟠**발행 기한 임박** (D-2일). 1월 10일까지 발행 필요합니다.
---
## 10. 종합 색상 적용 기준 매트릭스
모든 AI 리포트 섹션에 대한 색상 적용 기준을 요약한 종합 매트릭스입니다.
| 섹션 | 🔴 경고 | 🟠 주의 | 🟢 긍정 | 🔵 양호 |
|:---|:---|:---|:---|:---|
| 일일 일보 (출금) | 7일 평균 대비 200% 이상 | 7일 평균 대비 150~200% | - | 7일 평균 대비 150% 미만 |
| 일일 일보 (입금) | - | - | 입금 발생 시 | - |
| 일일 일보 (운영자금) | 3개월 미만 확보 | 3~6개월 확보 | - | 6개월 이상 확보 |
| 일일 일보 (환율) | 일 ±1.5% 이상 | 일 ±1.0~1.5% | 환차익 발생 | 일 ±1.0% 미만 |
| 일일 일보 (환차손익) | 손실 1,000만원 이상 | 손실 500~1,000만원 | 이익 500만원 이상 | ±500만원 미만 |
| 당월 지출 (전월 대비) | 15% 이상 증가 | 10~15% 증가 | 5% 이상 감소 | ±10% 이내 |
| 당월 지출 (예산 대비) | 100% 초과 | 90~100% | - | 90% 미만 |
| 카드/가지급금 (전환) | 100만원 이상 전환 | 50~100만원 전환 | - | - |
| 카드/가지급금 (소명) | 불인정 가맹점 | 주말/심야 50만원 이상 | - | - |
| 접대비 (한도) | 100% 초과 | 85~100% | 75% 미만 | 75~85% |
| 접대비 (증빙) | 거래처 정보 누락 | 상세 내용 미기재 | 증빙 완비 | - |
| 복리후생비 | 1인당 월 30만원 초과 | 1인당 월 25~30만원 | 1인당 월 15~25만원 | 1인당 월 15만원 미만 |
| 복리후생비 (비과세) | 한도 초과 시 과세 | 한도 90% 이상 | - | 한도 이하 |
| 미수금 (연체) | 90일 초과 | 30~90일 | 회수 완료 | 만기 전 |
| 미수금 (집중도) | 1개사 30% 이상 | 3개사 50% 이상 | 1개사 20% 미만 | 3개사 40% 미만 |
| 채권추심 (상태) | 회수 불가, 파산 | 대손 검토 필요 | 지급명령 완료, 회수 | 법적 조치 진행 중 |
| 채권추심 (회수율) | 10% 미만 | 10~30% | 70% 이상 | 30~70% |
| 부가세 (납부세액) | 전기 대비 30% 이상 증가 | 전기 대비 15~30% 증가 | 환급 발생, 정상 증가 | 전기 대비 15% 미만 |
| 부가세 (세금계산서) | 미발행/기한 초과 | 기한 D-3일 이내 | 정상 발행 | - |
---
*— 문서 끝 —*

View File

@@ -0,0 +1,128 @@
# SAM API 시더 목록
> 생성일: 2025-01-05
> 대상 테넌트: ID 287
## 개별 실행 방법
```bash
# Docker 컨테이너 접속 후
php artisan db:seed --class=시더클래스명
# Dummy 폴더 시더는 네임스페이스 포함
php artisan db:seed --class=Dummy\\DummyClientSeeder
```
---
## 1. 메인 시더
| # | 시더 | 설명 | 실행 명령어 |
|---|------|------|-------------|
| 1 | `DatabaseSeeder` | 기본 시더 (테스트 유저 + 메뉴) | `php artisan db:seed` |
| 2 | `DummyDataSeeder` | 전체 더미 데이터 (모든 Dummy 호출) | `php artisan db:seed --class=DummyDataSeeder` |
---
## 2. 기본 데이터 시더 (Dummy)
| # | 시더 | 테이블 | 수량 | 실행 명령어 |
|---|------|--------|------|-------------|
| 3 | `DummyUserSeeder` | users | 15 | `php artisan db:seed --class=Dummy\\DummyUserSeeder` |
| 4 | `DummyDepartmentSeeder` | departments | 11 | `php artisan db:seed --class=Dummy\\DummyDepartmentSeeder` |
| 5 | `DummyClientGroupSeeder` | client_groups | 5 | `php artisan db:seed --class=Dummy\\DummyClientGroupSeeder` |
| 6 | `DummyBankAccountSeeder` | bank_accounts | 5 | `php artisan db:seed --class=Dummy\\DummyBankAccountSeeder` |
| 7 | `DummyClientSeeder` | clients | 20 | `php artisan db:seed --class=Dummy\\DummyClientSeeder` |
---
## 3. 회계 데이터 시더 (Dummy)
| # | 시더 | 테이블 | 수량 | 실행 명령어 |
|---|------|--------|------|-------------|
| 8 | `DummyDepositSeeder` | deposits | 60 | `php artisan db:seed --class=Dummy\\DummyDepositSeeder` |
| 9 | `DummyWithdrawalSeeder` | withdrawals | 60 | `php artisan db:seed --class=Dummy\\DummyWithdrawalSeeder` |
| 10 | `DummySaleSeeder` | sales | 80 | `php artisan db:seed --class=Dummy\\DummySaleSeeder` |
| 11 | `DummyPurchaseSeeder` | purchases | 70 | `php artisan db:seed --class=Dummy\\DummyPurchaseSeeder` |
| 12 | `DummyBadDebtSeeder` | bad_debts | 18 | `php artisan db:seed --class=Dummy\\DummyBadDebtSeeder` |
| 13 | `DummyBillSeeder` | bills | 30 | `php artisan db:seed --class=Dummy\\DummyBillSeeder` |
---
## 4. HR 데이터 시더 (Dummy)
| # | 시더 | 테이블 | 수량 | 실행 명령어 |
|---|------|--------|------|-------------|
| 14 | `DummyWorkSettingSeeder` | work_settings | 1 | `php artisan db:seed --class=Dummy\\DummyWorkSettingSeeder` |
| 15 | `DummyAttendanceSettingSeeder` | attendance_settings | 1 | `php artisan db:seed --class=Dummy\\DummyAttendanceSettingSeeder` |
| 16 | `DummyAttendanceSeeder` | attendances | ~300 | `php artisan db:seed --class=Dummy\\DummyAttendanceSeeder` |
| 17 | `DummyLeaveGrantSeeder` | leave_grants | ~200 | `php artisan db:seed --class=Dummy\\DummyLeaveGrantSeeder` |
| 18 | `DummyLeaveSeeder` | leaves | ~50 | `php artisan db:seed --class=Dummy\\DummyLeaveSeeder` |
| 19 | `DummyCardSeeder` | cards | 5 | `php artisan db:seed --class=Dummy\\DummyCardSeeder` |
| 20 | `DummySalarySeeder` | salaries | 15 | `php artisan db:seed --class=Dummy\\DummySalarySeeder` |
---
## 5. 기타 더미 시더 (Dummy)
| # | 시더 | 테이블 | 수량 | 실행 명령어 |
|---|------|--------|------|-------------|
| 21 | `DummyItemSeeder` | items | 10,000 | `php artisan db:seed --class=Dummy\\DummyItemSeeder` |
| 22 | `DummyPopupSeeder` | popups | 8 | `php artisan db:seed --class=Dummy\\DummyPopupSeeder` |
| 23 | `DummyPaymentSeeder` | payments | 13 | `php artisan db:seed --class=Dummy\\DummyPaymentSeeder` |
| 24 | `ApprovalTestDataSeeder` | approvals | ~60 | `php artisan db:seed --class=ApprovalTestDataSeeder` |
---
## 6. 시스템/설정 시더
| # | 시더 | 설명 | 실행 명령어 |
|---|------|------|-------------|
| 25 | `GlobalMenuTemplateSeeder` | 글로벌 메뉴 템플릿 | `php artisan db:seed --class=GlobalMenuTemplateSeeder` |
| 26 | `ReactMenuSeeder` | React 메뉴 | `php artisan db:seed --class=ReactMenuSeeder` |
| 27 | `CategorySeeder` | 카테고리 | `php artisan db:seed --class=CategorySeeder` |
| 28 | `ItemTypeSeeder` | 품목 유형 | `php artisan db:seed --class=ItemTypeSeeder` |
| 29 | `ItemMasterSeeder` | 품목 마스터 | `php artisan db:seed --class=ItemMasterSeeder` |
| 30 | `PositionSeeder` | 직급 | `php artisan db:seed --class=PositionSeeder` |
| 31 | `FolderSeeder` | 폴더 | `php artisan db:seed --class=FolderSeeder` |
| 32 | `CapabilityProfileSeeder` | 역량 프로필 | `php artisan db:seed --class=CapabilityProfileSeeder` |
| 33 | `StockReceivingSeeder` | 입고 | `php artisan db:seed --class=StockReceivingSeeder` |
| 34 | `ComprehensiveAnalysisSeeder` | 종합분석 | `php artisan db:seed --class=ComprehensiveAnalysisSeeder` |
| 35 | `SystemFieldDefinitionSeeder` | 시스템 필드 정의 | `php artisan db:seed --class=SystemFieldDefinitionSeeder` |
| 36 | `DemoSystemSeeder` | 데모 시스템 | `php artisan db:seed --class=DemoSystemSeeder` |
| 37 | `BpMesCategoryFieldsSeeder` | MES 카테고리 필드 | `php artisan db:seed --class=BpMesCategoryFieldsSeeder` |
| 38 | `BpMesTenantStatFieldsSeeder` | MES 테넌트 통계 필드 | `php artisan db:seed --class=BpMesTenantStatFieldsSeeder` |
---
## 7. 견적 관련 시더
| # | 시더 | 설명 | 실행 명령어 |
|---|------|------|-------------|
| 39 | `QuoteFormulaSeeder` | 견적 계산식 | `php artisan db:seed --class=QuoteFormulaSeeder` |
| 40 | `QuoteFormulaCategorySeeder` | 견적 계산 카테고리 | `php artisan db:seed --class=QuoteFormulaCategorySeeder` |
| 41 | `QuoteFormulaItemSeeder` | 견적 계산 품목 | `php artisan db:seed --class=QuoteFormulaItemSeeder` |
| 42 | `QuoteFormulaMappingSeeder` | 견적 계산 매핑 | `php artisan db:seed --class=QuoteFormulaMappingSeeder` |
---
## 요약
| 카테고리 | 개수 |
|----------|------|
| 메인 시더 | 2 |
| 기본 데이터 (Dummy) | 5 |
| 회계 데이터 (Dummy) | 6 |
| HR 데이터 (Dummy) | 7 |
| 기타 더미 (Dummy) | 4 |
| 시스템/설정 | 14 |
| 견적 관련 | 4 |
| **총계** | **42** |
---
## 주의사항
1. **Dummy 시더**는 `TENANT_ID = 287` 하드코딩
2. **의존성 순서**: 기본 데이터 → 회계 → HR → 기타 순서로 실행 권장
3. **중복 주의**: 이미 데이터가 있는 경우 중복 생성됨 (특히 `DummyItemSeeder` 10,000개)

View File

@@ -0,0 +1,434 @@
# SAM API 전체 분석 보고서
> **작성일**: 2026-01-29
> **목적**: api/, mng/, react/ 프로젝트 간 API 중복/통합/미사용 분석 및 관계 정리
> **기준 문서**: api/routes/api/v1/*.php, mng/routes/api.php, mng/routes/web.php, react/src/lib/api/*
> **상태**: ✅ 분석 완료
---
## 📍 분석 결과 요약
| 항목 | 수치 |
|------|------|
| **api/ 엔드포인트** | ~710+ |
| **mng/ 엔드포인트** | ~300+ |
| **React 실제 사용** | ~80+ (api/ 전체의 ~15%) |
| **중복 도메인** | 10개 |
| **즉시 정리 가능** | 3건 |
| **통합 가능 그룹** | 6개 |
---
## 1. 개요
### 1.1 배경
SAM 프로젝트는 api/(REST API), mng/(관리자 패널), react/(프론트엔드) 3개 프로젝트로 구성되어 있으며, 각 프로젝트가 독립적으로 발전하면서 동일 도메인에 대한 API가 중복 생성되었다. 본 분석은 전체 API를 파악하고 정리 방안을 제시한다.
### 1.2 분석 범위
| 프로젝트 | 역할 | 분석 대상 |
|---------|------|----------|
| **api/** | REST API 서버 | routes/api/v1/*.php (14개 라우트 파일), 컨트롤러 138개 |
| **mng/** | 관리자 패널 | routes/api.php, routes/web.php, 컨트롤러 90+개 |
| **react/** | 프론트엔드 | src/lib/api/*, src/hooks/*, src/app/api/* |
---
## 2. 프로젝트별 API 구조
### 2.1 API 프로젝트 (api/)
**라우트 파일**: `api/routes/api/v1/`
| 도메인 | 라우트 파일 | 엔드포인트 수 | 소비자 |
|--------|-----------|:----------:|--------|
| 인증 | `auth.php` | 8 | React, MNG |
| 사용자 | `users.php` | 25 | React |
| 테넌트 | `tenants.php` | 18 | React |
| 관리자 | `admin.php` | 22 | React, MNG |
| 공통 | `common.php` | 95+ | React, MNG |
| HR | `hr.php` | 85+ | React |
| 재무 | `finance.php` | 130+ | React |
| 영업 | `sales.php` | 85+ | React |
| 재고 | `inventory.php` | 65+ | React |
| 생산 | `production.php` | 35+ | React |
| 설계 | `design.php` | 55+ | React |
| 파일 | `files.php` | 15 | React |
| 게시판 | `boards.php` | 70+ | React |
| 문서 | `documents.php` | 5+ | React |
### 2.2 MNG 프로젝트 (mng/)
**API 소비 방식**: 자체 모델로 DB 직접 접근 (api/ REST API를 거치지 않음)
| 도메인 | 엔드포인트 수 | 비고 |
|--------|:----------:|------|
| 사용자/역할/권한 | 30+ | api/와 중복 |
| 메뉴/글로벌메뉴 | 25+ | api/와 중복 |
| 게시판/필드 | 20+ | api/와 중복 |
| 카테고리/글로벌 | 15+ | api/와 중복 |
| 바로빌 (전체) | 60+ | MNG 전용 (외부 서비스) |
| 프로젝트 관리 | 25+ | MNG 전용 |
| 견적 공식 | 30+ | MNG 전용 |
| 품목 필드 | 25+ | MNG 전용 |
| 문서/템플릿 | 12+ | api/와 중복 |
| 계좌/자금일정 | 18+ | api/와 중복 |
| 기타 (회의록, 신용, 영업, Lab) | 40+ | MNG 전용 |
### 2.3 React 프론트엔드 (react/)
**API 호출 방식**: Next.js Proxy (`/api/proxy/*`) → Backend (`/api/v1/*`)
| 카테고리 | 주요 엔드포인트 | 사용 빈도 |
|---------|---------------|:--------:|
| 인증 | login, logout, refresh, signup | 높음 |
| 품목 CRUD | items, items/{id}, items/bom | 높음 |
| 품목기준관리 | item-master/* (pages, sections, fields) | 높음 |
| 견적 계산 | quotes/calculate, quotes/calculate/bom | 높음 |
| 공통코드 | settings/common/{group} | 높음 |
| 대시보드 | card-transactions/dashboard, loans/dashboard | 중간 |
| 알림 | today-issues/unread, unread/count | 중간 |
| 거래처 | clients, client-groups | 중간 |
| 재고 | stocks, work-results | 중간 |
| 일괄작업 | bulk-update-account-code | 낮음 |
| 내보내기 | attendances/export, salaries/export | 낮음 |
---
## 3. 중복 API 분석
### 3.1 중복 컨트롤러 목록 (api/ vs mng/)
| # | 도메인 | api/ 컨트롤러 | mng/ 컨트롤러 | 중복 수준 |
|---|--------|-------------|-------------|:--------:|
| 1 | 사용자 관리 | `Api\V1\Admin\AdminController` | `Api\Admin\UserController` | 🔴 높음 |
| 2 | 역할 관리 | `Api\V1\RoleController` | `Api\Admin\RoleController` | 🔴 높음 |
| 3 | 메뉴 관리 | `Api\V1\MenuController` | `Api\Admin\MenuController` | 🔴 높음 |
| 4 | 카테고리 | `Api\V1\CategoryController` | `Api\Admin\CategoryApiController` | 🔴 높음 |
| 5 | 계좌 관리 | `Api\V1\BankAccountController` | `Api\Admin\BankAccountController` | 🔴 높음 |
| 6 | 권한 관리 | `Api\V1\PermissionController` | `Api\Admin\PermissionController` | 🟡 중간 |
| 7 | 부서 관리 | `Api\V1\DepartmentController` | `Api\Admin\DepartmentController` | 🟡 중간 |
| 8 | 게시판 | `Api\V1\BoardController` | `Api\Admin\BoardController` | 🟡 중간 |
| 9 | 문서 | `Api\V1\Documents\DocumentController` | `Api\Admin\DocumentApiController` | 🟡 중간 |
| 10 | 테넌트 | `Api\V1\TenantController` | `Api\Admin\TenantController` | 🟡 중간 |
### 3.2 상세 비교
#### (1) 사용자 관리 🔴
| 기능 | api/ | mng/ | 차이 |
|------|:----:|:----:|------|
| 기본 CRUD | ✅ | ✅ | 동일 |
| 복구 (restore) | ✅ | ✅ | 동일 |
| 비밀번호 초기화 | ✅ | ✅ | 동일 |
| 활성화/비활성화 | ✅ (PATCH /status) | ❌ | api/만 |
| 역할 부여/해제 | ✅ (POST/DELETE /roles) | ❌ | api/만 |
| 영구삭제 | ❌ | ✅ (DELETE /force) | mng/만 (슈퍼관리자) |
| 개발용 로그인토큰 | ❌ | ✅ (POST /login-token) | mng/만 |
| 모달 데이터 | ❌ | ✅ (GET /modal) | mng/만 |
#### (2) 역할 관리 🔴
| 기능 | api/ | mng/ | 차이 |
|------|:----:|:----:|------|
| 기본 CRUD | ✅ | ✅ | 동일 |
| 통계 (stats) | ✅ | ❌ | api/만 |
| 활성 목록 (active) | ✅ | ❌ | api/만 |
#### (3) 메뉴 관리 🔴
| 기능 | api/ | mng/ | 차이 |
|------|:----:|:----:|------|
| 기본 CRUD | ✅ | ✅ | 동일 |
| 순서변경 (reorder) | ✅ | ✅ | 동일 |
| 복구 (restore) | ✅ | ✅ | 동일 |
| 활성화 토글 | ✅ (toggle) | ✅ (toggle-active) | 동일 기능 |
| 동기화 | ✅ (sync, sync-new, sync-updates) | ❌ | api/만 |
| 트리 구조 | ❌ | ✅ (tree) | mng/만 |
| 글로벌 복사 | ❌ | ✅ (copy-from-global) | mng/만 |
| 일괄 작업 | ❌ | ✅ (bulk-delete/restore/force) | mng/만 |
| 숨김 토글 | ❌ | ✅ (toggle-hidden) | mng/만 |
| 영구삭제 | ❌ | ✅ (force) | mng/만 (슈퍼관리자) |
#### (4) 카테고리 🔴
| 기능 | api/ | mng/ | 차이 |
|------|:----:|:----:|------|
| 기본 CRUD | ✅ | ✅ | 동일 |
| 트리/순서변경/이동 | ✅ | ✅ | 동일 |
| 활성화 토글 | ✅ | ✅ | 동일 |
| 필드 관리 | ✅ (fields CRUD, bulk-upsert) | ❌ | api/만 |
| 템플릿 관리 | ✅ (templates, apply, preview, diff) | ❌ | api/만 |
| 로그 조회 | ✅ (logs) | ❌ | api/만 |
| 글로벌 관리 | ❌ | ✅ (global-categories) | mng/만 |
#### (5) 계좌 관리 🔴
| 기능 | api/ | mng/ | 차이 |
|------|:----:|:----:|------|
| 기본 CRUD | ✅ | ✅ | 동일 |
| 활성화 토글 | ✅ | ✅ | 동일 |
| 활성 목록 (active) | ✅ | ❌ | api/만 |
| 대표계좌 설정 | ✅ (set-primary) | ❌ | api/만 |
| 전체 조회 (all) | ❌ | ✅ | mng/만 |
| 요약 (summary) | ❌ | ✅ | mng/만 |
| 거래내역 | ❌ | ✅ (transactions) | mng/만 |
| 일괄 작업 | ❌ | ✅ (bulk-*) | mng/만 |
| 영구삭제/복구 | ❌ | ✅ (force/restore) | mng/만 |
#### (6) 권한 관리 🟡
| 기능 | api/ | mng/ | 차이 |
|------|:----:|:----:|------|
| 권한 매트릭스 조회 | ✅ (dept/role/user menu-matrix) | ❌ | api/만 (특화) |
| 기본 CRUD | ❌ | ✅ | mng/만 |
> **분석**: api/는 매트릭스 조회 전용, mng/는 CRUD 전용으로 기능 분리된 상태. 완전 중복은 아님.
---
## 4. 통합 가능 API
### 4.1 통합 대상 그룹
| # | 대상 | 현재 상태 | 통합 방안 | 우선순위 |
|---|------|----------|----------|:--------:|
| 1 | **인증 API** | signup + register 중복 | register 제거 (signup 유지) | 🔴 |
| 2 | **사용자 관리** | api/ + mng/ 각각 CRUD | mng/ → api/ 호출로 전환 | 🔴 |
| 3 | **역할 관리** | api/ + mng/ 각각 CRUD | api/에 통합, mng/는 호출만 | 🟡 |
| 4 | **메뉴 관리** | api/ 동기화 + mng/ 관리 분리 | 관리: mng/, 조회+동기화: api/ | 🟡 |
| 5 | **대시보드 데이터** | 개별 엔드포인트 분산 | 통합 대시보드 API 제공 | 🟢 |
| 6 | **일괄 업데이트** | withdrawals/deposits/sales 각각 | 공통 bulk-update 패턴 | 🟢 |
### 4.2 인증 API 중복 상세
```
현재:
POST /v1/login → 로그인
POST /v1/logout → 로그아웃
POST /v1/signup → 회원가입 (1)
POST /v1/register → 회원가입 (2) ← 중복!
POST /v1/token-login → 토큰 로그인 (MNG→DEV)
POST /v1/refresh → 토큰 갱신
POST /v1/internal/exchange-token → 내부 서버 토큰 교환
GET /v1/debug-apikey → 디버그용 ← 프로덕션 제거 필요
권장:
- register 제거 (signup 유지)
- debug-apikey 프로덕션 비활성화
```
---
## 5. 미사용 API
### 5.1 React에서 호출하지 않는 api/ 도메인
| 도메인 | 엔드포인트 수 | 미사용 이유 |
|--------|:----------:|-----------|
| HR 전체 (employees, attendance, leave, approval) | ~80+ | MNG에서 직접 관리 또는 React 미구현 |
| 생산 대부분 (processes, work-orders, inspections) | ~35+ | work-results만 사용 |
| 설계 전체 (models, versions, bom-templates) | ~55+ | 견적 계산 시 간접 사용만 |
| 재무 대부분 (cards, payroll, bad-debts 등) | ~100+ | CEO 대시보드 일부만 사용 |
| 사용자 초대 (invitations) | ~5 | React 미구현 |
| 알림 설정 (notification-settings) | ~5 | React 미구현 |
| 프로필 관리 (profiles) | ~5 | React 미구현 |
| 팝업 관리 (popups) | ~5 | React 미구현 |
| AI 리포트 (reports/ai) | ~4 | React 미구현 |
| 구독/결제 (subscriptions, payments) | ~20+ | React 미구현 |
| 현장/시공 (sites, construction) | ~30+ | React 미구현 |
| 검사 관리 (inspections) | ~8 | React 미구현 |
> **참고**: "미사용"은 React 프론트엔드 기준. MNG에서 Blade UI로 직접 사용하거나 향후 구현 예정인 경우 포함.
### 5.2 완전 미사용 가능성 높은 API
| 엔드포인트 | 이유 | 조치 권장 |
|-----------|------|----------|
| `GET /v1/debug-apikey` | 디버그 전용 | 프로덕션 비활성화 |
| `POST /v1/register` | signup과 중복 | 제거 |
| `GET /v1/welfare/*` | React/MNG 모두 미호출 확인 필요 | 사용 여부 확인 |
| `GET /v1/entertainment/*` | React/MNG 모두 미호출 확인 필요 | 사용 여부 확인 |
| `GET /v1/calendar/schedules` | React 미호출 | 사용 여부 확인 |
| `GET /v1/comprehensive-analysis` | React 미호출 | 사용 여부 확인 |
### 5.3 MNG 전용 기능 (정상)
| 기능 | 설명 | 상태 |
|------|------|:----:|
| 바로빌 (Barobill) | 전자세금계산서, 카드, 홈택스 연동 | ✅ 정상 |
| 프로젝트 관리 | 프로젝트, 태스크, 이슈 | ✅ 정상 |
| 데일리 로그 | 일일 스크럼 | ✅ 정상 |
| 견적 공식 | 견적 계산 공식 관리 | ✅ 정상 |
| 회의록 | 녹음, AI 요약 (Google Cloud) | ✅ 정상 |
| 신용 평가 | Coocon API 연동 | ✅ 정상 |
| 영업 관리 | 매니저, 전망, 기록 | ✅ 정상 |
| DevTools | API 탐색기, 흐름 테스터 | ✅ 정상 |
| Lab/R&D | AI, 전략 실험 | ✅ 정상 |
---
## 6. 프로젝트 간 API 관계도
### 6.1 시스템 구조
```
┌─────────────────────────────────────────────────────────────┐
│ 사용자 (브라우저) │
│ │
│ ┌──────────────┐ ┌──────────────────┐ │
│ │ React App │ │ MNG Admin │ │
│ │ (dev.sam.kr) │ │ (mng.sam.kr) │ │
│ └──────┬───────┘ └──────┬───────────┘ │
│ │ │ │
│ Next.js Proxy 자체 모델 직접 사용 │
│ (/api/proxy/*) + 일부 api/ 호출 │
│ │ │ │
│ ▼ │ │
│ ┌──────────────┐ │ │
│ │ API 서버 │◄─────────────────┘ │
│ │ (api.sam.kr) │ token-login, │
│ │ │ DevTools API 탐색 │
│ └──────┬───────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ Database │◄──── MNG도 동일 DB 직접 접근 │
│ │ (MySQL) │ │
│ └──────────────┘ │
└─────────────────────────────────────────────────────────────┘
외부 API:
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ Google │ │ Coocon │ │ FCM │ │ NTS │
│ Cloud │ │ (신용) │ │ (푸시) │ │ (홈택스) │
└────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘
└────────────┴────────────┴─────────────┘
MNG에서 호출
```
### 6.2 데이터 흐름
| 흐름 | 방식 | 설명 |
|------|------|------|
| React → API | HTTP (Proxy) | 모든 비즈니스 로직 API 호출 |
| MNG → DB | 직접 모델 | 관리 기능은 DB 직접 접근 |
| MNG → API | HTTP | token-login, DevTools, 일부 동기화 |
| MNG → 외부 | HTTP | Barobill, Google Cloud, Coocon, NTS |
| API → DB | 직접 모델 | 모든 비즈니스 로직 |
### 6.3 중복 발생 원인
```
문제: MNG가 api/를 호출하지 않고 DB 직접 접근
→ 동일 도메인에 대해 api/, mng/ 각각 독립 컨트롤러 보유
→ 비즈니스 로직 분산, 유지보수 부담 증가
현재:
React → api/ (REST API) → DB
MNG → DB 직접 ← 여기가 문제
이상적:
React → api/ (REST API) → DB
MNG → api/ (REST API) → DB (관리자 전용 엔드포인트 추가)
```
---
## 7. 개선 권장사항
### 7.1 즉시 정리 (Quick Wins) 🔴
| # | 작업 | 영향 | 노력 |
|---|------|------|:----:|
| 1 | `POST /v1/register` 제거 (signup 유지) | 코드 정리 | 소 |
| 2 | `GET /v1/debug-apikey` 프로덕션 비활성화 | 보안 강화 | 소 |
| 3 | 미사용 Swagger 문서 정리 | 문서 정확성 | 소 |
### 7.2 중복 해소 (Medium Term) 🟡
| # | 작업 | 현재 | 목표 |
|---|------|------|------|
| 1 | 사용자 관리 통합 | api/ + mng/ 각각 | api/ 마스터, mng/ 관리자 기능만 추가 |
| 2 | 역할 관리 통합 | api/ + mng/ 각각 | api/ 단일 소스 |
| 3 | 카테고리 통합 | api/ + mng/ 각각 | api/ 마스터, mng/ 글로벌 관리만 유지 |
| 4 | 계좌 관리 통합 | api/ + mng/ 각각 | 하나로 통합 |
| 5 | 메뉴 관리 정리 | api/ 동기화 + mng/ 관리 | 역할 분리 명확화 |
### 7.3 아키텍처 개선 (Long Term) 🟢
| # | 작업 | 설명 |
|---|------|------|
| 1 | MNG → API 호출 전환 | MNG가 DB 직접 접근 대신 api/ REST API 호출 |
| 2 | API Gateway 도입 | 인증/권한/레이트리밋 중앙 관리 |
| 3 | 미사용 API 비활성화 | deprecation 헤더 추가 후 단계적 제거 |
| 4 | API v2 전환 | 중복 정리 포함한 v2 설계 |
---
## 8. 전체 엔드포인트 도메인별 수
### API 프로젝트
| 도메인 | 파일 | 수 |
|--------|------|:--:|
| 인증 | auth.php | 8 |
| 사용자 | users.php | 25 |
| 테넌트 | tenants.php | 18 |
| 관리자 | admin.php | 22 |
| 공통 | common.php | 95+ |
| HR | hr.php | 85+ |
| 재무 | finance.php | 130+ |
| 영업 | sales.php | 85+ |
| 재고 | inventory.php | 65+ |
| 생산 | production.php | 35+ |
| 설계 | design.php | 55+ |
| 파일 | files.php | 15 |
| 게시판 | boards.php | 70+ |
| 문서 | documents.php | 5+ |
| **합계** | | **~710+** |
### MNG 프로젝트
| 그룹 | 수 |
|------|:--:|
| 사용자/역할/권한 | 30+ |
| 메뉴/글로벌메뉴 | 25+ |
| 게시판/필드 | 20+ |
| 카테고리/글로벌 | 15+ |
| 바로빌 | 60+ |
| 프로젝트 관리 | 25+ |
| 견적 공식 | 30+ |
| 품목 필드 | 25+ |
| 문서/템플릿 | 12+ |
| 계좌/자금일정 | 18+ |
| 기타 | 40+ |
| **합계** | **~300+** |
---
## 9. 참고 문서
- `docs/standards/api-rules.md` - API 규칙
- `docs/architecture/system-overview.md` - 시스템 아키텍처
- `docs/specs/database-schema.md` - DB 스키마
- `api/routes/api/v1/*.php` - API 라우트 파일
- `mng/routes/api.php` - MNG API 라우트
- `react/src/lib/api/` - React API 클라이언트
---
## 10. 결론
1. **api/와 mng/의 10개 도메인에서 컨트롤러 중복** 발생 - 동일 DB를 각각 직접 접근하는 구조적 문제
2. **React는 api/ 전체의 약 15%만 사용** - 나머지는 MNG 전용이거나 미구현 기능
3. **인증 API에 signup/register 중복** 존재 - 즉시 정리 가능
4. **장기적으로 MNG → API 호출 전환**이 이상적이나, 현재 아키텍처도 기능적으로 동작
5. **Quick Wins(register 제거, debug-apikey 비활성화)부터 시작** 권장
---
*이 문서는 /sc:plan 스킬로 생성되었습니다.*

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,860 @@
# 절곡 작업일지 완전 재구현 계획
> **작성일**: 2026-02-19
> **목적**: PHP viewBendingWork_slat.php와 동일한 구조로 React BendingWorkLogContent.tsx 완전 재구현
> **기준 문서**: `5130/output/viewBendingWork_slat.php` (~1400줄)
> **상태**: ✅ 구현 완료 (커밋: 59b9b1b)
---
## 📍 현재 진행 상태
| 항목 | 내용 |
|------|------|
| **마지막 완료 작업** | Phase 1~5 전체 구현 완료 + 슬랫 입고 LOT NO 개소별 표시 버그 수정 |
| **다음 작업** | 실 데이터 테스트 (bending_info가 채워진 작업지시로 화면 확인) |
| **진행률** | 15/15 (100%) |
| **마지막 업데이트** | 2026-02-19 |
| **Git 커밋** | `59b9b1b` feat(WEB): 절곡 작업일지 완전 재구현 + 슬랫 입고 LOT NO 개소별 표시 수정 |
---
## 1. 개요
### 1.1 배경
현재 React `BendingWorkLogContent.tsx`는 **빈 껍데기 상태**로, 단순 테이블에 `item.productName`, `item.specification`, `item.quantity`만 평면 나열함. PHP 원본(`viewBendingWork_slat.php`)의 4개 카테고리 구조를 전혀 지원하지 않음.
**현재 React 컴포넌트 상태:**
- 헤더 + 결재란 (ConstructionApprovalTable 사용) ✅
- 신청업체 / 신청내용 테이블 ✅
- 제품 정보 테이블 (빈 칸) ❌ 데이터 바인딩 없음
- 작업내역 (유형명/세부품명/재질/LOT/길이/수량) ❌ 단순 flat 리스트
- 생산량 합계 [kg] SUS/EGI ❌ 빈 칸
- **4개 카테고리 섹션 완전 부재** ❌
**PHP 원본 구조 (구현 목표):**
- 가이드레일: 벽면형/측면형 분류, 이미지 + 세부품명별 길이/수량/LOT NO/무게 계산
- 하단마감재: 3000/4000mm 길이별 수량, 별도마감재
- 셔터박스: 동적 이미지 + 구성요소(전면부/린텔부/점검구/후면코너부/상부덮개/측면부)
- 연기차단재: W50 레일용, W80 케이스용
- 생산량 합계: SUS(7.93g/cm3) / EGI(7.85g/cm3) 무게 자동 계산
### 1.2 데이터 흐름 (전체 파이프라인)
```
[수주 시스템]
order_nodes.options.bending_info (JSON)
▼ WorkOrderService.php (Line 276)
│ $nodeOptions['bending_info'] ?? null
work_order_items.options (JSON)
│ { floor, code, width, height, bending_info, slat_info, cutting_info, wip_info }
▼ API GET /work-orders/{id} → items[].options.bending_info
▼ Frontend getWorkOrderById() → WorkOrder.items
▼ WorkLogModal.tsx (Line 207-213)
│ <BendingWorkLogContent data={order} />
│ ※ materialLots 미전달 (bending은 slat과 다르게 LOT를 별도로 안 받음)
▼ BendingWorkLogContent.tsx (재작성 대상)
```
**핵심**: `bending_info``work_order_items.options` JSON 안에 저장되며, 현재 프론트엔드 `WorkOrderItem` 타입에는 `bendingInfo` 필드가 **없음** (slatInfo처럼 추가 필요).
### 1.3 현재 bending_info 구조 (SAM에 정의된 것)
```typescript
// react/src/components/production/WorkerScreen/types.ts (Lines 91-107)
export interface BendingInfo {
drawingUrl?: string;
common: BendingCommonInfo;
detailParts: BendingDetailPart[];
}
export interface BendingCommonInfo {
kind: string; // "혼합형 120X70"
type: string; // "혼합형" | "벽면형" | "측면형"
lengthQuantities: { length: number; quantity: number }[];
}
export interface BendingDetailPart {
partName: string; // "엘바", "하장바"
material: string; // "EGI 1.6T"
barcyInfo: string; // "16 I 75"
}
```
### 1.4 현재 WorkOrderItem 타입 (types.ts Lines 106-120)
```typescript
// react/src/components/production/WorkOrders/types.ts
export interface WorkOrderItem {
id: string;
no: number;
status: ItemStatus;
productName: string;
floorCode: string;
specification: string;
width?: number;
height?: number;
quantity: number;
unit: string;
orderNodeId: number | null;
orderNodeName: string;
slatInfo?: { length: number; slatCount: number; jointBar: number; glassQty: number };
// ❌ bendingInfo 없음 → 추가 필요
}
```
**transform 함수** (types.ts Lines 457-474): `slatInfo``item.options.slat_info`에서 파싱하지만, `bending_info`는 아직 매핑하지 않음.
### 1.5 PHP col → SAM 매핑 (완전 테이블)
PHP에서 데이터는 `estimateSlatList` JSON의 각 아이템에 `col{N}` 키로 저장됨.
| PHP 컬럼 | 의미 | SAM bending_info 필드 | 상태 |
|---------|------|----------------------|------|
| `col4` | 제품코드 (KQTS01, KTE01 등) | `productCode` | ⚠️ item_code로 별도 존재, bending_info에도 추가 |
| `col6` | 가이드레일 유형 | `common.type` | ✅ 존재 |
| `col7` | 마감유형 (SUS마감/EGI마감) | `finishMaterial` | ❌ 추가 필요 |
| `col24` | 유효 길이 (mm) | `common.lengthQuantities` | ✅ 존재 |
| `col32` | 연기차단재 W50 수량 - 2438mm | `smokeBarrier.w50[].quantity` | ❌ 추가 필요 |
| `col33` | 연기차단재 W50 수량 - 3000mm | 상동 | ❌ |
| `col34` | 연기차단재 W50 수량 - 3500mm | 상동 | ❌ |
| `col35` | 연기차단재 W50 수량 - 4000mm | 상동 | ❌ |
| `col36` | 연기차단재 W50 수량 - 4300mm | 상동 | ❌ |
| `col37` | 셔터박스 크기 (500*380 등) | `shutterBox[].size` | ❌ 추가 필요 |
| `col37_custom` | 셔터박스 커스텀 크기 | `shutterBox[].size` (custom일 때) | ❌ |
| `col37_railwidth` | 셔터박스 레일 폭 | `shutterBox[].railWidth` | ❌ |
| `col37_frontbottom` | 셔터박스 전면 하단 치수 | `shutterBox[].frontBottom` | ❌ |
| `col37_boxdirection` | 셔터박스 방향 (양면/밑면/후면) | `shutterBox[].direction` | ❌ |
| `col39` | 셔터박스 수량 - 1219mm | `shutterBox[].lengthData` | ❌ |
| `col40` | 셔터박스 수량 - 2438mm | 상동 | ❌ |
| `col41` | 셔터박스 수량 - 3000mm | 상동 | ❌ |
| `col42` | 셔터박스 수량 - 3500mm | 상동 | ❌ |
| `col43` | 셔터박스 수량 - 4000mm | 상동 | ❌ |
| `col44` | 셔터박스 수량 - 4150mm | 상동 | ❌ |
| `col45` | 상부덮개 수량 | `shutterBox[].coverQty` | ❌ |
| `col47` | 마구리 수량 | `shutterBox[].finCoverQty` | ❌ |
| `col48` | 연기차단재 W80 수량 | `smokeBarrier.w80Qty` | ❌ |
| `col50` | 하단마감재 3000mm 수량 | `bottomBar.length3000Qty` | ❌ |
| `col51` | 하단마감재 4000mm 수량 | `bottomBar.length4000Qty` | ❌ |
### 1.6 기준 원칙
```
┌─────────────────────────────────────────────────────────────────┐
│ 🎯 핵심 원칙 │
├─────────────────────────────────────────────────────────────────┤
│ - options JSON 확장 (컬럼 추가 금지 - 멀티테넌시 원칙) │
│ - PHP 원본과 동일한 계산 로직 (calWeight, 길이 버킷팅) │
│ - 이미지는 정적 파일로 서빙 (셔터박스만 SVG/Canvas 대체) │
│ - 카테고리별 독립 컴포넌트 (가이드레일/하단마감/셔터박스/연기차단재)│
│ - 현재 WorkOrderItem에 bendingInfo 필드 추가 (slatInfo 패턴) │
└─────────────────────────────────────────────────────────────────┘
```
### 1.7 변경 승인 정책
| 분류 | 예시 | 승인 |
|------|------|------|
| ✅ 즉시 가능 | React 컴포넌트 추가/수정, 타입 정의 추가, 이미지 복사 | 불필요 |
| ⚠️ 컨펌 필요 | bending_info JSON 스키마 변경, API 응답 구조 변경, 계산 로직 변경 | **필수** |
| 🔴 금지 | work_order_items 테이블 컬럼 추가, 기존 API 삭제 | 별도 협의 |
---
## 2. 대상 범위
### 2.1 Phase 1: 데이터 스키마 확장 (백엔드)
| # | 작업 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 1.1 | bending_info JSON 스키마 확장 설계 | ✅ | BendingInfoExtended 타입 정의 완료 |
| 1.2 | WorkOrderService.php - options 매핑 확인/수정 | ✅ | Line 277에서 bending_info 정상 전달 확인 |
| 1.3 | API 응답에 확장된 bending_info 포함 확인 | ✅ | transform 함수에 bendingInfo 매핑 추가 완료 |
### 2.2 Phase 2: 이미지 서빙
| # | 작업 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 2.1 | 5130/img/ → api/public/images/bending/ 복사 | ✅ | guiderail(12) + bottombar(6) + part(1) + box source(3) = 22개 |
| 2.2 | 이미지 URL 빌더 유틸 (프론트) | ✅ | bending/utils.ts getBendingImageUrl() |
### 2.3 Phase 3: 프론트엔드 타입 & 유틸리티
| # | 작업 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 3.1 | BendingWorkLog 타입 정의 확장 | ✅ | bending/types.ts + WorkOrderItem.bendingInfo 추가 |
| 3.2 | 무게 계산 유틸리티 (`calcWeight`) | ✅ | bending/utils.ts (calcWeight, getMaterialMapping 등 11개 함수) |
| 3.3 | WorkOrderItem transform에 bendingInfo 매핑 추가 | ✅ | item.options.bending_info → bendingInfo |
### 2.4 Phase 4: 프론트엔드 컴포넌트 구현
| # | 작업 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 4.1 | GuideRailSection 컴포넌트 | ✅ | 벽면형/측면형 분류, 이미지+파트테이블 |
| 4.2 | BottomBarSection 컴포넌트 | ✅ | 하단마감재 + 별도마감재 |
| 4.3 | ShutterBoxSection 컴포넌트 | ✅ | 방향별(양면/밑면/후면) 구성요소, source 이미지 |
| 4.4 | SmokeBarrierSection 컴포넌트 | ✅ | W50 레일용 + W80 케이스용 |
| 4.5 | ProductionSummarySection 컴포넌트 | ✅ | SUS/EGI/합계 표시 |
| 4.6 | BendingWorkLogContent 통합 | ✅ | 헤더 + 신청업체/내용 + 제품정보 + 4섹션 + 합계 + 비고 |
### 2.5 Phase 5: 검증 & 정리
| # | 작업 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 5.1 | PHP 원본과 출력 비교 검증 | ✅ | TypeScript 타입 체크 통과, 실 데이터 테스트 대기 |
---
## 3. 작업 절차
### 3.1 단계별 절차
```
Phase 1: 데이터 스키마 확장 (백엔드)
├── 1.1 bending_info 확장 스키마 설계
│ ├── guideRail: { wall, side } (길이 버킷팅 + 수량 + baseSize)
│ ├── bottomBar: { material, extraFinish, length3000Qty, length4000Qty }
│ ├── shutterBox: [{ size, direction, railWidth, frontBottom, coverQty, finCoverQty, lengthData }]
│ └── smokeBarrier: { w50: [...], w80Qty }
├── 1.2 WorkOrderService.php 매핑 확인 (Line 276)
└── 1.3 API 응답 검증 (curl로 직접 확인)
Phase 2: 이미지 서빙
├── 2.1 정적 이미지 복사 (guiderail 12jpg + bottombar 6jpg + part 1jpg = 19개)
└── 2.2 이미지 URL 헬퍼 유틸
Phase 3: 프론트엔드 타입 & 유틸
├── 3.1 타입 정의 (bending/types.ts 신규 + WorkOrderItem.bendingInfo 추가)
├── 3.2 calcWeight + getMaterialMapping 유틸 (bending/utils.ts)
└── 3.3 transform 함수에 bendingInfo 매핑 추가 (slatInfo 패턴 동일)
Phase 4: 컴포넌트 구현
├── 4.1 GuideRailSection (가장 복잡 - 벽면/측면 분리, 파트 구성, 무게 계산)
├── 4.2 BottomBarSection (3000/4000 수량, 별도마감)
├── 4.3 ShutterBoxSection (방향별 구성요소, SVG 다이어그램)
├── 4.4 SmokeBarrierSection (W50 길이별 + W80 고정)
├── 4.5 ProductionSummarySection (SUS/EGI 누적 합계)
└── 4.6 BendingWorkLogContent 통합 (헤더+신청+4섹션+합계 조립)
Phase 5: 검증
└── 5.1 PHP 원본과 비교 (num=24822)
```
---
## 4. 상세 작업 내용 (PHP 로직 완전 인라인)
### 4.1 Phase 1: bending_info 확장 스키마
#### 1.1 확장된 bending_info JSON 구조
```typescript
interface BendingInfoExtended {
// === 기존 필드 (유지) ===
drawingUrl?: string;
common: BendingCommonInfo; // { kind, type, lengthQuantities }
detailParts: BendingDetailPart[]; // [{ partName, material, barcyInfo }]
// === 신규 필드 ===
productCode: string; // "KTE01", "KQTS01", "KSE01", "KSS01", "KWE01"
finishMaterial: string; // "EGI마감", "SUS마감"
guideRail: {
wall: {
lengthData: { length: number; quantity: number }[];
baseSize: string; // "135*80" 또는 "135*130"
} | null;
side: {
lengthData: { length: number; quantity: number }[];
baseSize: string; // "135*130"
} | null;
};
bottomBar: {
material: string; // "EGI 1.55T" 또는 "SUS 1.5T"
extraFinish: string; // "SUS 1.2T" 또는 "없음"
length3000Qty: number;
length4000Qty: number;
};
shutterBox: {
size: string; // "500*380" 등
direction: string; // "양면" | "밑면" | "후면"
railWidth: number;
frontBottom: number;
coverQty: number; // 상부덮개 수량
finCoverQty: number; // 마구리 수량
lengthData: { length: number; quantity: number }[];
}[]; // 배열 (여러 사이즈 가능)
smokeBarrier: {
w50: { length: number; quantity: number }[]; // 레일용 W50
w80Qty: number; // 케이스용 W80 수량
};
}
```
#### 1.2 calWeight 함수 (PHP 원본 Lines 27-55 → TypeScript 구현)
```typescript
// PHP 원본:
// $volume_cm3 = ($thickness * $calWidth * $calHeight) / 1000;
// $weight_kg = ($volume_cm3 * $density) / 1000;
// SUS → $SUS_total += $weight_kg, EGI → $EGI_total += $weight_kg
function calcWeight(
material: string, // "SUS 1.2T", "EGI 1.55T", "EGI 0.8T" 등
width: number, // mm
height: number // mm (= 길이)
): { weight: number; type: 'SUS' | 'EGI' } {
const thickness = parseFloat(material.match(/\d+(\.\d+)?/)?.[0] || '0');
const isSUS = material.includes('SUS');
const density = isSUS ? 7.93 : 7.85; // g/cm3
const volume_cm3 = (thickness * width * height) / 1000;
const weight_kg = (volume_cm3 * density) / 1000;
return {
weight: Math.round(weight_kg * 100) / 100,
type: isSUS ? 'SUS' : 'EGI',
};
}
```
#### 1.3 제품코드별 재질 매핑 (PHP Lines 330-366)
```typescript
function getMaterialMapping(productCode: string, finishMaterial: string) {
// Group 1: KQTS01
if (productCode === 'KQTS01') {
return {
guideRailFinish: 'SUS 1.2T', // ①②마감재
bodyMaterial: 'EGI 1.55T', // ③본체, ④C형, ⑤D형
guideRailExtraFinish: '', // 별도마감 없음
bottomBarFinish: 'SUS 1.5T', // 하단마감재
bottomBarExtraFinish: '없음', // 별도마감 없음
};
}
// Group 2: KTE01
if (productCode === 'KTE01') {
const isSUS = finishMaterial === 'SUS마감';
return {
guideRailFinish: 'EGI 1.55T',
bodyMaterial: 'EGI 1.55T',
guideRailExtraFinish: isSUS ? 'SUS 1.2T' : '',
bottomBarFinish: 'EGI 1.55T',
bottomBarExtraFinish: isSUS ? 'SUS 1.2T' : '없음',
};
}
// 기타 제품코드 (KSE01, KSS01, KWE01 등) - KTE01 + EGI마감과 동일 패턴
return {
guideRailFinish: 'EGI 1.55T',
bodyMaterial: 'EGI 1.55T',
guideRailExtraFinish: '',
bottomBarFinish: 'EGI 1.55T',
bottomBarExtraFinish: '없음',
};
}
```
#### 1.4 가이드레일 길이 버킷팅 알고리즘 (PHP Lines 384-413)
```typescript
// 고정 버킷: [2438, 3000, 3500, 4000, 4300]
// 각 아이템의 col24(유효길이)를 "첫 번째로 수용 가능한 버킷"에 넣음 (first-fit)
const LENGTH_BUCKETS = [2438, 3000, 3500, 4000, 4300];
function bucketGuideRails(items: Array<{ validLength: number; railType: string }>) {
const buckets = LENGTH_BUCKETS.map(len => ({
length: len, wallSum: 0, sideSum: 0,
wallBaseSize: null as string | null, sideBaseSize: null as string | null,
}));
for (const item of items) {
for (const bucket of buckets) {
if (item.validLength <= bucket.length) {
if (item.railType === '혼합형(130*75)(130*125)') {
bucket.wallSum += 1;
bucket.sideSum += 1;
bucket.wallBaseSize = '135*80';
bucket.sideBaseSize = '135*130';
} else if (item.railType === '벽면형(130*75)') {
bucket.wallSum += 2;
bucket.wallBaseSize = '135*130';
} else if (item.railType === '측면형(130*125)') {
bucket.sideSum += 2;
bucket.sideBaseSize = '135*130';
}
break; // first-fit: 한 버킷에 넣으면 다음 아이템으로
}
}
}
return buckets.filter(b => b.wallSum > 0 || b.sideSum > 0);
}
```
#### 1.5 가이드레일 세부품명 + LOT 접두사 + 무게 계산 폭
**벽면형 [130*75] 파트 구성:**
| 세부품명 | LOT 접두사 | 재질 | 무게 계산 폭 (mm) |
|---------|-----------|------|-----------------|
| ①②마감재 | XX | `guideRailFinish` | 412 |
| ③본체 | RT | `bodyMaterial` | 412 |
| ④C형 | RC | `bodyMaterial` | 412 |
| ⑤D형 | RD | `bodyMaterial` | 412 |
| ⑥별도마감 (SUS마감 시만) | RS | `guideRailExtraFinish` | 412 |
| 하부BASE | XX | EGI 1.55T (고정) | 135 (높이=80) |
무게: `calcWeight(재질, 412, 길이)` / 하부BASE: `calcWeight('EGI 1.55T', 135, 80)`
baseSize는 `135*80` (혼합형) 또는 `135*130` (벽면형 단독)
**측면형 [130*125] 파트 구성:**
| 세부품명 | LOT 접두사 | 재질 | 무게 계산 폭 (mm) |
|---------|-----------|------|-----------------|
| ①②마감재 | SS | `guideRailFinish` | 462 |
| ③본체 | ST | `bodyMaterial` | 462 |
| ④C형 | SC | `bodyMaterial` | 462 |
| ⑤D형 | SD | `bodyMaterial` | 462 |
| 하부BASE | XX | EGI 1.55T (고정) | 135 (높이=130) |
무게: `calcWeight(재질, 462, 길이)` / 하부BASE: `calcWeight('EGI 1.55T', 135, 130)`
#### 1.6 하단마감재 세부품명
| 세부품명 | LOT 접두사 | 재질 | 무게 계산 폭 (mm) | 길이 옵션 |
|---------|-----------|------|-----------------|---------|
| ①하단마감재 | TE(EGI)/TS(SUS) | `bottomBarFinish` | 184 | 3000, 4000 |
| ④별도마감재 | TE/TS | `bottomBarExtraFinish` | 238 | 3000, 4000 |
별도마감재는 `bottomBarExtraFinish !== '없음'`일 때만 표시.
#### 1.7 셔터박스 구성요소 (방향별 - PHP Lines 819-1190)
**셔터박스 재질**: 항상 `EGI 1.55T` (= `$BoxFinish`)
**표준 사이즈 (500*380) 구성:**
| 구성요소 | LOT 접두사 | 치수 공식 |
|---------|-----------|----------|
| ①전면부 | CF | `boxHeight + 122` |
| ②린텔부 | CL | `boxWidth - 330` |
| ③⑤점검구 | CP | `boxWidth - 200` |
| ④후면코너부 | CB | `170` (고정) |
**비표준 사이즈 - 양면 구성:**
| 구성요소 | LOT 접두사 | 치수 공식 |
|---------|-----------|----------|
| ①전면부 | XX | `boxHeight + 122` |
| ②린텔부 | CL | `boxWidth - 330` |
| ③점검구 | XX | `boxWidth - 200` |
| ④후면코너부 | CB | `170` (고정) |
| ⑤점검구 | XX | `boxHeight - 100` |
| ⑥상부덮개 | XX | `1219 * (boxWidth - 111)` |
| ⑦측면부(마구리) | XX | `(boxWidthFin+5) * (boxHeightFin+5)` 표시 |
**비표준 사이즈 - 밑면 구성:**
| 구성요소 | LOT 접두사 | 치수 공식 |
|---------|-----------|----------|
| ①전면부 | XX | `boxHeight + 122` |
| ②린텔부 | CL | `boxWidth - 330` |
| ③점검구 | XX | `boxWidth - 200` |
| ④후면부 | CB | `boxHeight + 85*2` |
| ⑤상부덮개 | XX | `1219 * (boxWidth - 111)` |
| ⑥측면부(마구리) | XX | `(boxWidthFin+5) * (boxHeightFin+5)` 표시 |
**비표준 사이즈 - 후면 구성:**
| 구성요소 | LOT 접두사 | 치수 공식 |
|---------|-----------|----------|
| ①전면부 | XX | `boxHeight + 122` |
| ②린텔부 | CL | `boxWidth + 85*2` |
| ③점검구 | XX | `boxHeight - 200` |
| ④후면코너부 | CB | `boxHeight + 85*2` |
| ⑤상부덮개 | XX | `1219 * (boxWidth - 111)` |
| ⑥측면부(마구리) | XX | `(boxWidthFin+5) * (boxHeightFin+5)` 표시 |
**공통 사항:**
- 상부덮개 무게: `calcWeight('EGI 1.55T', boxWidth - 111, 1219)` × coverQty
- 마구리 무게: `calcWeight('EGI 1.55T', boxWidthFin, boxHeightFin)` × finCoverQty
- 셔터박스 길이 버킷: [1219, 2438, 3000, 3500, 4000, 4150]
#### 1.8 연기차단재 (PHP Lines 1195-1321)
| 파트 | 재질 | 무게 계산 폭 (mm) | 길이 버킷 |
|-----|------|-----------------|---------|
| 레일용 [W50] | EGI 0.8T | 26 | 2438, 3000, 3500, 4000, 4300 |
| 케이스용 [W80] | EGI 0.8T | 26 | 3000 (고정) |
LOT 접두사: 모두 `GI`
LOT 코드 생성: `GI-{getSLengthCode(length, category)}`
#### 1.9 getSLengthCode 함수 (PHP Lines 56-100)
```typescript
function getSLengthCode(length: number, category: string): string | null {
if (category === '연기차단재50') {
return length === 3000 ? '53' : length === 4000 ? '54' : null;
}
if (category === '연기차단재80') {
return length === 3000 ? '83' : length === 4000 ? '84' : null;
}
// category === '기타' (일반)
const map: Record<number, string> = {
1219: '12', 2438: '24', 3000: '30', 3500: '35',
4000: '40', 4150: '41', 4200: '42', 4300: '43',
};
return map[length] || null;
}
```
---
### 4.2 Phase 2: 이미지 서빙
#### 복사 대상 (총 19개 JPG 파일)
**가이드레일 (12개):**
```
5130/img/guiderail/ → api/public/images/bending/guiderail/
├── guiderail_KQTS01_wall_130x75.jpg
├── guiderail_KQTS01_side_130x125.jpg
├── guiderail_KTE01_wall_130x75.jpg
├── guiderail_KTE01_side_130x125.jpg
├── guiderail_KSE01_wall_120x70.jpg
├── guiderail_KSE01_side_120x120.jpg
├── guiderail_KSS01_wall_120x70.jpg
├── guiderail_KSS01_side_120x120.jpg
├── guiderail_KSS02_wall_120x70.jpg
├── guiderail_KSS02_side_120x120.jpg
├── guiderail_KWE01_wall_120x70.jpg
└── guiderail_KWE01_side_120x120.jpg
```
**하단마감재 (6개):**
```
5130/img/bottombar/ → api/public/images/bending/bottombar/
├── bottombar_KQTS01.jpg
├── bottombar_KTE01.jpg
├── bottombar_KSE01.jpg
├── bottombar_KSS01.jpg
├── bottombar_KSS02.jpg
└── bottombar_KWE01.jpg
```
**연기차단재 (1개):**
```
5130/img/part/ → api/public/images/bending/part/
└── smokeban.jpg
```
**셔터박스 이미지**: PHP에서 GD 라이브러리로 동적 생성 → React에서는 SVG/Canvas로 대체
- 소스 이미지: `5130/img/box/source/box_{both|bottom|rear}.jpg`
- 치수 텍스트를 오버레이하는 구조 → SVG 컴포넌트로 재구현
#### 이미지 URL 패턴
```typescript
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'https://api.sam.kr';
function getBendingImageUrl(category: string, productCode: string, type?: string): string {
switch (category) {
case 'guiderail': {
// PHP: guiderail_{prodCode}_{wall|side}_{size}.jpg
// KQTS01, KTE01 → 130x75 (wall) / 130x125 (side)
// KSE01, KSS01, KSS02, KWE01 → 120x70 (wall) / 120x120 (side)
const size = ['KQTS01', 'KTE01'].includes(productCode)
? (type === 'wall' ? '130x75' : '130x125')
: (type === 'wall' ? '120x70' : '120x120');
return `${API_BASE}/images/bending/guiderail/guiderail_${productCode}_${type}_${size}.jpg`;
}
case 'bottombar':
return `${API_BASE}/images/bending/bottombar/bottombar_${productCode}.jpg`;
case 'smokebarrier':
return `${API_BASE}/images/bending/part/smokeban.jpg`;
default:
return '';
}
}
```
---
### 4.3 Phase 3: 프론트엔드 타입 & 유틸리티
#### 파일 구조
```
react/src/components/production/WorkOrders/documents/
├── BendingWorkLogContent.tsx ← 기존 파일 (재작성)
├── bending/
│ ├── types.ts ← 절곡 작업일지 전용 타입
│ ├── utils.ts ← calcWeight, getMaterialMapping, getBendingImageUrl, getSLengthCode
│ ├── GuideRailSection.tsx ← 가이드레일 섹션
│ ├── BottomBarSection.tsx ← 하단마감재 섹션
│ ├── ShutterBoxSection.tsx ← 셔터박스 섹션
│ ├── SmokeBarrierSection.tsx ← 연기차단재 섹션
│ └── ProductionSummarySection.tsx ← 생산량 합계
```
#### WorkOrderItem.bendingInfo 추가 (slatInfo 패턴 참고)
```typescript
// types.ts에 추가
export interface WorkOrderItem {
// ... 기존 필드 ...
slatInfo?: { length: number; slatCount: number; jointBar: number; glassQty: number };
bendingInfo?: BendingInfoExtended; // ← 신규 추가
}
// transform 함수에 추가 (slatInfo 패턴 동일)
bendingInfo: item.options?.bending_info
? (item.options.bending_info as BendingInfoExtended)
: undefined,
```
---
### 4.4 Phase 4: 컴포넌트 구현 상세
#### 4.1 GuideRailSection 레이아웃
```
┌──────────────────────────────────────────────────────────────────────────────┐
│ 1.1 벽면형 [130*75] │
│ ┌─────────────────────┐ ┌──────────────────────────────────────────────────┐│
│ │ [guiderail 이미지] │ │ 세부품명 │ 재질 │ 길이 │ 수량 │ LOT NO │ 무게 ││
│ │ │ │──────────┼──────────┼──────┼──────┼────────┼──────││
│ │ │ │ ①②마감재 │ SUS 1.2T │ 4000 │ 6 │ ____ │ XX.X ││
│ │ 입고&생산 LOT NO: │ │ ③본체 │ EGI 1.55T│ 4000 │ 6 │ ____ │ XX.X ││
│ │ ___________ │ │ ④C형 │ EGI 1.55T│ 4000 │ 6 │ ____ │ XX.X ││
│ └─────────────────────┘ │ ⑤D형 │ EGI 1.55T│ 4000 │ 6 │ ____ │ XX.X ││
│ │ ⑥별도마감 │ SUS 1.2T │ 4000 │ 6 │ ____ │ XX.X ││
│ │ 하부BASE │ EGI 1.55T│135*80│ N │ ____ │ XX.X ││
│ └──────────────────────────────────────────────────┘│
├──────────────────────────────────────────────────────────────────────────────┤
│ 1.2 측면형 [130*125] (동일 구조, 폭=462mm, baseSize=135*130) │
└──────────────────────────────────────────────────────────────────────────────┘
```
각 길이 버킷(2438/3000/3500/4000/4300)별로 수량이 있는 행만 표시.
각 파트의 무게는 `calcWeight(재질, 폭, 길이)` × 수량으로 계산.
#### 4.2 BottomBarSection 레이아웃
```
┌──────────────────────────────────────────────────────────────────────────────┐
│ 2. 하단마감재 │
│ ┌─────────────────────┐ ┌──────────────────────────────────────────────────┐│
│ │ [bottombar 이미지] │ │ 세부품명 │ 재질 │ 길이 │ 수량 │ LOT │ 무게 ││
│ │ │ │─────────────┼──────────┼──────┼──────┼──────┼──────││
│ │ │ │ ①하단마감재 │ EGI 1.55T│ 3000 │ N │ ____ │ XX.X ││
│ └─────────────────────┘ │ ①하단마감재 │ EGI 1.55T│ 4000 │ N │ ____ │ XX.X ││
│ │ ④별도마감재 │ SUS 1.2T │ 3000 │ N │ ____ │ XX.X ││
│ │ ④별도마감재 │ SUS 1.2T │ 4000 │ N │ ____ │ XX.X ││
│ └──────────────────────────────────────────────────┘│
└──────────────────────────────────────────────────────────────────────────────┘
```
#### 4.3 ShutterBoxSection 레이아웃
```
┌──────────────────────────────────────────────────────────────────────────────┐
│ 3. 셔터박스 [500*380] 양면 │
│ ┌─────────────────────┐ ┌──────────────────────────────────────────────────┐│
│ │ [SVG 다이어그램] │ │ 구성요소 │ 재질 │ 길이 │ 수량 │ LOT │ 무게 ││
│ │ (치수 텍스트 포함) │ │────────────┼──────────┼──────┼──────┼──────┼──────││
│ │ boxHeight+122 │ │ ①전면부 │ EGI 1.55T│ 3000 │ N │ ____ │ XX.X ││
│ │ boxWidth-330 │ │ ②린텔부 │ EGI 1.55T│ 3000 │ N │ ____ │ XX.X ││
│ │ boxWidth-200 │ │ ③점검구 │ EGI 1.55T│ 3000 │ N │ ____ │ XX.X ││
│ └─────────────────────┘ │ ④후면코너부 │ EGI 1.55T│ 3000 │ N │ ____ │ XX.X ││
│ │ ⑤점검구 │ EGI 1.55T│ 3000 │ N │ ____ │ XX.X ││
│ │ ⑥상부덮개 │ EGI 1.55T│ 1219 │ N │ ____ │ XX.X ││
│ │ ⑦마구리 │ EGI 1.55T│ - │ N │ ____ │ XX.X ││
│ └──────────────────────────────────────────────────┘│
└──────────────────────────────────────────────────────────────────────────────┘
```
#### 4.4 SmokeBarrierSection 레이아웃
```
┌──────────────────────────────────────────────────────────────────────────────┐
│ 4. 연기차단재 │
│ ┌─────────────────────┐ ┌──────────────────────────────────────────────────┐│
│ │ [smokeban.jpg] │ │ 파트 │ 재질 │ 길이 │ 수량 │ LOT │ 무게 ││
│ │ │ │───────────────┼─────────┼──────┼──────┼──────┼──────││
│ └─────────────────────┘ │ 레일용 [W50] │EGI 0.8T │ 3000 │ N │ ____ │ XX.X ││
│ │ 레일용 [W50] │EGI 0.8T │ 4000 │ N │ ____ │ XX.X ││
│ │ 케이스용 [W80]│EGI 0.8T │ 3000 │ N │ ____ │ XX.X ││
│ └──────────────────────────────────────────────────┘│
└──────────────────────────────────────────────────────────────────────────────┘
```
#### 4.5 ProductionSummarySection 레이아웃
```
┌──────────────────────────────────────────────────────┐
│ 생산량 합계(KG) │ SUS │ EGI │ 합계 │
│ │ XX.XX kg │ XX.XX kg │ XX.XX kg │
└──────────────────────────────────────────────────────┘
```
SUS_total과 EGI_total은 4개 섹션의 모든 calcWeight 호출에서 누적.
---
## 5. 모든 하드코딩 상수 (PHP 원본 기준)
| 상수 | 값 | 용도 |
|------|-----|------|
| SUS 밀도 | 7.93 g/cm3 | calWeight |
| EGI 밀도 | 7.85 g/cm3 | calWeight |
| 벽면형 파트 폭 | 412 mm | 가이드레일 무게 계산 |
| 측면형 파트 폭 | 462 mm | 가이드레일 무게 계산 |
| 벽면형 하부BASE | 135 × 80 mm | 가이드레일 |
| 측면형 하부BASE | 135 × 130 mm | 가이드레일 |
| 하단마감재 폭 | 184 mm | 하단마감재 무게 |
| 별도마감재 폭 | 238 mm | 별도마감재 무게 |
| 연기차단재 폭 (W50/W80) | 26 mm | 연기차단재 무게 |
| 상부덮개 길이 | 1219 mm (고정) | 셔터박스 |
| 상부덮개 폭 | boxWidth - 111 | 셔터박스 |
| 전면부 치수 | boxHeight + 122 | 셔터박스 |
| 린텔부 치수 | boxWidth - 330 | 셔터박스 |
| 점검구 치수 | boxWidth - 200 | 셔터박스 |
| 후면코너부 치수 (표준/양면) | 170 | 셔터박스 |
| 가이드레일 길이 버킷 | [2438, 3000, 3500, 4000, 4300] | 길이 분류 |
| 셔터박스 길이 버킷 | [1219, 2438, 3000, 3500, 4000, 4150] | 길이 분류 |
| 하단마감재 길이 | [3000, 4000] | 길이 분류 |
| 연기차단재 W50 길이 버킷 | [2438, 3000, 3500, 4000, 4300] | 길이 분류 |
| 케이스용 W80 길이 | 3000 (고정) | 연기차단재 |
| 마구리 표시 크기 보정 | +5 mm (양쪽) | 셔터박스 |
---
## 6. 컨펌 대기 목록
| # | 항목 | 변경 내용 | 영향 범위 | 상태 |
|---|------|----------|----------|------|
| 1 | bending_info 스키마 확장 | guideRail, bottomBar, shutterBox, smokeBarrier 필드 추가 | api options JSON | ⚠️ 컨펌 필요 |
| 2 | 이미지 파일 복사 | 5130/img/ → api/public/images/bending/ (19개 JPG) | api 서버 | ⚠️ 컨펌 필요 |
| 3 | 셔터박스 이미지 처리 | SVG 컴포넌트로 클라이언트 렌더링 (PHP GD 대체) | react | ⚠️ 컨펌 필요 |
---
## 7. 변경 이력
| 날짜 | 항목 | 변경 내용 | 파일 | 승인 |
|------|------|----------|------|------|
| 2026-02-19 | - | 문서 초안 작성 | - | - |
| 2026-02-19 | - | 자기완결성 보완 (PHP 로직 완전 인라인, 이미지 목록, 상수 테이블, 데이터 흐름) | - | - |
---
## 8. 참고 문서 & 핵심 파일 경로
### 수정 대상 파일
| 파일 | 역할 | 작업 |
|------|------|------|
| `react/src/components/production/WorkOrders/documents/BendingWorkLogContent.tsx` | 메인 컴포넌트 | **재작성** |
| `react/src/components/production/WorkOrders/types.ts` | WorkOrderItem 타입 | `bendingInfo` 필드 추가 + transform 함수 수정 |
| `react/src/components/production/WorkOrders/documents/bending/` | 신규 디렉토리 | **6개 파일 생성** (types, utils, 4개 섹션 + 합계) |
### 참조 파일 (읽기 전용)
| 파일 | 역할 |
|------|------|
| `5130/output/viewBendingWork_slat.php` | PHP 원본 (~1400줄) |
| `react/src/components/production/WorkerScreen/types.ts` | BendingInfo 인터페이스 (Lines 91-107) |
| `react/src/components/production/WorkerScreen/WorkLogModal.tsx` | 작업일지 모달 - BendingWorkLogContent 호출 (Lines 207-213) |
| `api/app/Services/WorkOrderService.php` | options에 bending_info 저장 (Line 276) |
| `react/src/components/production/WorkOrders/documents/SlatWorkLogContent.tsx` | 슬랫 작업일지 참고 (유사 패턴) |
| `react/src/components/production/WorkOrders/documents/index.ts` | export 파일 (BendingWorkLogContent 등록됨) |
### 이미지 원본 경로
| 소스 | 대상 | 파일 수 |
|------|------|---------|
| `5130/img/guiderail/*.jpg` | `api/public/images/bending/guiderail/` | 12개 |
| `5130/img/bottombar/*.jpg` | `api/public/images/bending/bottombar/` | 6개 |
| `5130/img/part/smokeban.jpg` | `api/public/images/bending/part/` | 1개 |
**참고**: `api/public/images/bending/` 디렉토리는 아직 존재하지 않음 → 생성 필요.
---
## 9. 세션 관리
### Serena 메모리 ID
- `bending-worklog-state`: 진행 상태
- `bending-worklog-snapshot`: 스냅샷
- `bending-worklog-active-symbols`: 수정 중 파일
---
## 10. 검증 결과
### 10.1 성공 기준
| 기준 | 달성 | 비고 |
|------|------|------|
| 4개 카테고리 섹션이 PHP와 동일한 레이아웃으로 렌더링 | ⏳ | |
| SUS/EGI 무게 계산이 PHP calWeight와 동일한 결과 | ⏳ | calcWeight(SUS 1.2T, 412, 4000) 등으로 검증 |
| 생산량 합계(KG)가 SUS/EGI 별도 + 합산으로 표시 | ⏳ | |
| 가이드레일/하단마감재/연기차단재 이미지가 정상 표시 | ⏳ | |
| 셔터박스 SVG 다이어그램에 치수 텍스트 표시 | ⏳ | |
| 제품코드/마감유형에 따라 세부품명 동적 변경 | ⏳ | KQTS01 vs KTE01+SUS vs KTE01+EGI |
| 가이드레일 길이 버킷팅이 PHP first-fit과 동일 | ⏳ | |
| 빌드 에러 없음 | ⏳ | |
### 10.2 검증 방법
- PHP 원본: `5130/output/viewBendingWork_slat.php?num=24822` 출력과 비교
- 무게 계산 단위 테스트: `calcWeight('SUS 1.2T', 412, 4000)` → 예상값과 비교
- `thickness=1.2, width=412, height=4000, density=7.93`
- `volume_cm3 = (1.2 * 412 * 4000) / 1000 = 1977.6`
- `weight_kg = (1977.6 * 7.93) / 1000 = 15.68`
---
## 11. 자기완결성 점검 결과
| # | 검증 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 1 | 작업 목적이 명확한가? | ✅ | PHP 동일 구조 재구현 |
| 2 | 성공 기준이 정의되어 있는가? | ✅ | 섹션 10.1 (8개 기준) |
| 3 | 작업 범위가 구체적인가? | ✅ | 5 Phase, 15개 작업 항목 |
| 4 | 의존성이 명시되어 있는가? | ✅ | Phase 순서 = 의존성, 데이터 흐름 섹션 1.2 |
| 5 | 참고 파일 경로가 정확한가? | ✅ | 섹션 8 (수정 대상 + 참조 파일 분리) |
| 6 | 단계별 절차가 실행 가능한가? | ✅ | PHP 로직 완전 인라인 (섹션 4) |
| 7 | 검증 방법이 명시되어 있는가? | ✅ | PHP num=24822 비교 + 단위 테스트 예시 |
| 8 | 모호한 표현이 없는가? | ✅ | 모든 상수/공식/조건 구체적으로 명시 |
### 새 세션 시뮬레이션 테스트
| 질문 | 답변 가능 | 참조 섹션 |
|------|:--------:|----------:|
| Q1. 이 작업의 목적은 무엇인가? | ✅ | 1.1 배경 |
| Q2. 데이터가 어디서 어떻게 오는가? | ✅ | 1.2 데이터 흐름 |
| Q3. 어디서부터 시작해야 하는가? | ✅ | 3.1 단계별 절차 |
| Q4. 어떤 파일을 수정/생성해야 하는가? | ✅ | 8 핵심 파일 경로 |
| Q5. PHP 원본의 계산 로직은? | ✅ | 4.1 (calWeight, 버킷팅, 재질매핑 전부 인라인) |
| Q6. 이미지 파일은 어디에 있는가? | ✅ | 4.2 (19개 파일 목록 + URL 패턴) |
| Q7. 모든 하드코딩 상수 값은? | ✅ | 섹션 5 (완전 테이블) |
| Q8. 작업 완료 확인 방법은? | ✅ | 10.1 성공 기준 + 10.2 검증 방법 |
| Q9. 막혔을 때 참고 문서는? | ✅ | 8 참고 문서 |
**결과**: 9/9 통과 → ✅ 자기완결성 확보
---
*이 문서는 /sc:plan 스킬로 생성되었습니다.*

View File

@@ -0,0 +1,817 @@
# 입찰관리(Bidding) API 구현 계획
> **작성일**: 2026-01-19
> **목적**: 견적 → 입찰 전환 기능 구현 및 테스트용 더미데이터 생성
> **기준 문서**: React 목업 타입 (`react/src/components/business/construction/bidding/types.ts`)
> **상태**: ✅ 완료 (Serena ID: bidding-api-state)
---
## 📍 현재 진행 상태
| 항목 | 내용 |
|------|------|
| **마지막 완료 작업** | Phase 4.3 - Pint 코드 포맷팅 및 Swagger 재생성 |
| **다음 작업** | 사용자 수동 실행 (마이그레이션, 시더) |
| **진행률** | 12/12 (100%) |
| **마지막 업데이트** | 2026-01-19 |
---
## 1. 개요
### 1.1 배경
**업무 흐름:**
```
현장설명회 → 견적관리 → [견적완료] → 입찰관리 → 계약관리 → 기성/정산
전환 기능 필요
```
현재 React 프론트엔드의 입찰관리(`/construction/project/bidding`)는 **목업 데이터**를 사용 중입니다.
견적(Quote) API는 이미 구현되어 있으므로, 입찰(Bidding) API를 새로 구현하고 견적 → 입찰 전환 기능을 추가해야 합니다.
**현재 상태:**
| 구분 | 견적(Estimate/Quote) | 입찰(Bidding) |
|------|---------------------|---------------|
| API Model | ✅ `Estimate.php` | ❌ 없음 |
| API Migration | ✅ `estimates` 테이블 | ❌ 없음 |
| API Endpoint | ✅ `/api/v1/quotes` | ❌ 없음 |
| React | ✅ API 연동 완료 | ❌ 목업 상태 |
### 1.2 기준 원칙
```
┌─────────────────────────────────────────────────────────────────┐
│ 🎯 핵심 원칙 │
├─────────────────────────────────────────────────────────────────┤
│ 1. SAM API Rules 엄격 준수 (Service-First, FormRequest) │
│ 2. Multi-tenancy 필수 (BelongsToTenant) │
│ 3. React 목업 타입과 100% 호환 │
│ 4. 견적 데이터 참조 (복사가 아닌 FK 연결) │
└─────────────────────────────────────────────────────────────────┘
```
### 1.3 변경 승인 정책
| 분류 | 예시 | 승인 |
|------|------|------|
| ✅ 즉시 가능 | 새 테이블 생성, 새 API 추가, Seeder 작성 | 불필요 |
| ⚠️ 컨펌 필요 | 기존 quotes 테이블 수정, 비즈니스 로직 변경 | **필수** |
| 🔴 금지 | 기존 API 삭제, 파괴적 변경 | 별도 협의 |
### 1.4 준수 규칙
- `api/CLAUDE.md` - SAM API Development Rules
- `docs/standards/quality-checklist.md` - 품질 체크리스트
- `docs/guides/swagger-guide.md` - Swagger 문서화
---
## 2. 대상 범위
### 2.1 Phase 1: Database & Model (Day 1)
| # | 작업 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 1.1 | `biddings` 테이블 마이그레이션 생성 | ✅ | `2026_01_19_100000_create_biddings_table.php` |
| 1.2 | `Bidding` Model 생성 | ✅ | BelongsToTenant, SoftDeletes |
| 1.3 | 더미데이터 Seeder 생성 | ✅ | 10건 테스트 데이터 |
### 2.2 Phase 2: API Implementation (Day 2)
| # | 작업 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 2.1 | BiddingService 생성 | ✅ | CRUD + 통계 |
| 2.2 | BiddingController 생성 | ✅ | |
| 2.3 | FormRequest 생성 | ✅ | Filter, Update, Status, BulkDelete |
| 2.4 | Routes 등록 | ✅ | `/api/v1/biddings` |
### 2.3 Phase 3: 견적 → 입찰 전환 (Day 2-3)
| # | 작업 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 3.1 | QuoteService에 `convertToBidding()` 추가 | ✅ | 기존 코드에 메서드 추가 |
| 3.2 | 전환 API 엔드포인트 추가 | ✅ | `POST /quotes/{id}/convert-to-bidding` |
### 2.4 Phase 4: Swagger & 검증 (Day 3)
| # | 작업 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 4.1 | Swagger 문서 작성 | ✅ | `BiddingApi.php` |
| 4.2 | i18n 메시지 추가 | ✅ | message.php, error.php |
| 4.3 | Pint 코드 포맷팅 | ✅ | 9 style issues fixed |
---
## 3. 작업 절차
### 3.1 단계별 절차
```
Step 1: Database Schema
├── biddings 테이블 마이그레이션 작성
├── 마이그레이션 실행
└── Seeder로 더미데이터 생성
Step 2: Model & Service
├── Bidding Model 생성 (BelongsToTenant, SoftDeletes)
├── BiddingService 생성 (CRUD, stats, filter)
└── BiddingController 생성
Step 3: API Routes
├── routes/api.php에 biddings 라우트 추가
├── FormRequest 클래스 생성
└── API 테스트
Step 4: 견적 → 입찰 전환
├── QuoteService에 convertToBidding() 추가
├── 전환 API 엔드포인트 추가
└── 전환 테스트
Step 5: Documentation
├── Swagger 문서 작성
├── API 문서 검증
└── Pint 실행
```
### 3.2 데이터베이스 스키마
```sql
-- biddings 테이블
CREATE TABLE biddings (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '테넌트 ID',
-- 기본 정보
bidding_code VARCHAR(50) NOT NULL COMMENT '입찰번호',
quote_id BIGINT UNSIGNED NULL COMMENT '연결된 견적 ID (quotes.id)',
-- 거래처/현장
client_id BIGINT UNSIGNED NULL COMMENT '거래처 ID',
client_name VARCHAR(100) NULL COMMENT '거래처명 (스냅샷)',
project_name VARCHAR(200) NULL COMMENT '현장명',
-- 입찰 정보
bidding_date DATE NULL COMMENT '입찰일',
bid_date DATE NULL COMMENT '입찰일 (레거시 호환)',
submission_date DATE NULL COMMENT '투찰일',
confirm_date DATE NULL COMMENT '확정일',
total_count INT DEFAULT 0 COMMENT '총 개소',
bidding_amount DECIMAL(15,2) DEFAULT 0 COMMENT '입찰금액',
-- 상태
status VARCHAR(20) DEFAULT 'waiting' COMMENT '상태 (waiting/submitted/failed/invalid/awarded/hold)',
-- 입찰자
bidder_id BIGINT UNSIGNED NULL COMMENT '입찰자 ID',
bidder_name VARCHAR(50) NULL COMMENT '입찰자명 (스냅샷)',
-- 공사기간
construction_start_date DATE NULL COMMENT '공사 시작일',
construction_end_date DATE NULL COMMENT '공사 종료일',
vat_type VARCHAR(20) DEFAULT 'excluded' COMMENT '부가세 (included/excluded)',
-- 비고
remarks TEXT NULL COMMENT '비고',
-- 견적 데이터 스냅샷 (JSON)
expense_items JSON NULL COMMENT '공과 항목 스냅샷',
estimate_detail_items JSON NULL COMMENT '견적 상세 항목 스냅샷',
-- 감사
created_by BIGINT UNSIGNED NULL COMMENT '생성자',
updated_by BIGINT UNSIGNED NULL COMMENT '수정자',
deleted_by BIGINT UNSIGNED NULL COMMENT '삭제자',
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL,
deleted_at TIMESTAMP NULL,
-- 인덱스
INDEX idx_tenant_id (tenant_id),
INDEX idx_status (status),
INDEX idx_bidding_date (bidding_date),
INDEX idx_quote_id (quote_id),
UNIQUE INDEX idx_bidding_code (tenant_id, bidding_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```
### 3.3 API 엔드포인트 설계
| Method | Path | 설명 |
|--------|------|------|
| GET | `/api/v1/biddings` | 목록 조회 (필터, 페이지네이션) |
| GET | `/api/v1/biddings/stats` | 통계 조회 |
| GET | `/api/v1/biddings/{id}` | 단건 조회 |
| PUT | `/api/v1/biddings/{id}` | 수정 |
| DELETE | `/api/v1/biddings/{id}` | 삭제 |
| DELETE | `/api/v1/biddings/bulk` | 일괄 삭제 |
| POST | `/api/v1/quotes/{id}/convert-to-bidding` | 견적 → 입찰 전환 |
**참고**: 입찰은 별도 등록 없음 (견적완료 시 자동 전환)
### 3.4 타입 매핑 (React → API)
| React (camelCase) | API (snake_case) | DB Column |
|-------------------|------------------|-----------|
| `id` | `id` | `id` |
| `biddingCode` | `bidding_code` | `bidding_code` |
| `partnerId` | `client_id` | `client_id` |
| `partnerName` | `client_name` | `client_name` |
| `projectName` | `project_name` | `project_name` |
| `biddingDate` | `bidding_date` | `bidding_date` |
| `totalCount` | `total_count` | `total_count` |
| `biddingAmount` | `bidding_amount` | `bidding_amount` |
| `bidDate` | `bid_date` | `bid_date` |
| `submissionDate` | `submission_date` | `submission_date` |
| `confirmDate` | `confirm_date` | `confirm_date` |
| `status` | `status` | `status` |
| `bidderId` | `bidder_id` | `bidder_id` |
| `bidderName` | `bidder_name` | `bidder_name` |
| `remarks` | `remarks` | `remarks` |
| `estimateId` | `quote_id` | `quote_id` |
| `estimateCode` | `quote_number` | (join) |
### 3.5 상태값 매핑
| 값 | 한글 | 설명 |
|----|------|------|
| `waiting` | 입찰대기 | 견적 전환 후 초기 상태 |
| `submitted` | 투찰 | 투찰서 제출 완료 |
| `failed` | 탈락 | 입찰 실패 |
| `invalid` | 유찰 | 입찰 무효 |
| `awarded` | 낙찰 | 입찰 성공 |
| `hold` | 보류 | 검토 대기 |
### 3.6 기존 quotes 테이블 스키마 (연결용)
> `biddings.quote_id` → `quotes.id` FK 연결
```sql
-- quotes 테이블 핵심 컬럼 (api/database/migrations/2025_12_04_164542_create_quotes_table.php)
quotes (
id BIGINT PRIMARY KEY,
tenant_id BIGINT NOT NULL,
quote_type ENUM('manufacturing', 'construction'), -- 'construction' 필터
quote_number VARCHAR(50), -- 견적번호 (예: KD-SC-251204-01)
registration_date DATE,
client_id BIGINT, -- 거래처 ID
client_name VARCHAR(100), -- 거래처명
site_name VARCHAR(200), -- 현장명
total_amount DECIMAL(15,2), -- 최종 금액
status ENUM('pending','draft','sent','approved','rejected','finalized','converted'),
site_briefing_id BIGINT, -- 현장설명회 연결
options JSON, -- { summary_items, expense_items, detail_items, price_adjustment_data }
...
)
```
**Quote 상태 상수** (api/app/Models/Quote/Quote.php):
- `pending` → 견적대기 (현장설명회에서 자동생성)
- `finalized` → 확정 (입찰 전환 가능)
- `converted` → 전환완료
### 3.7 API 응답 형식 (JSON)
#### 목록 조회 응답 (GET /biddings)
```json
{
"success": true,
"message": "message.fetched",
"data": {
"data": [
{
"id": 1,
"bidding_code": "BID-2025-001",
"client_id": 1,
"client_name": "이사대표",
"project_name": "광장 아파트",
"bidding_date": "2025-01-25",
"total_count": 15,
"bidding_amount": 71000000,
"bid_date": "2025-01-20",
"submission_date": "2025-01-22",
"confirm_date": "2025-01-25",
"status": "awarded",
"bidder_id": 1,
"bidder_name": "홍길동",
"remarks": "",
"quote_id": 1,
"quote_number": "EST-2025-001",
"created_at": "2025-01-01T00:00:00.000000Z"
}
],
"current_page": 1,
"per_page": 20,
"total": 10,
"last_page": 1
}
}
```
#### 통계 응답 (GET /biddings/stats)
```json
{
"success": true,
"message": "message.fetched",
"data": {
"total": 10,
"waiting": 3,
"awarded": 3
}
}
```
#### 단건 조회 응답 (GET /biddings/{id})
```json
{
"success": true,
"message": "message.fetched",
"data": {
"id": 1,
"bidding_code": "BID-2025-001",
"client_id": 1,
"client_name": "이사대표",
"project_name": "광장 아파트",
"bidding_date": "2025-01-25",
"total_count": 15,
"bidding_amount": 71000000,
"status": "awarded",
"construction_start_date": "2025-02-01",
"construction_end_date": "2025-04-30",
"vat_type": "excluded",
"expense_items": [
{ "id": "1", "name": "설계비", "amount": 5000000 },
{ "id": "2", "name": "운반비", "amount": 3000000 }
],
"estimate_detail_items": [
{ "id": "1", "no": 1, "name": "방화문", "material": "SUS304", "width": 1000, "height": 2100, "quantity": 10, ... }
],
"quote": {
"id": 1,
"quote_number": "EST-2025-001"
}
}
}
```
### 3.8 convertToBidding() 상세 로직
```php
/**
* 견적 → 입찰 전환
*
* @param int $quoteId 견적 ID
* @return Bidding 생성된 입찰
*/
public function convertToBidding(int $quoteId): Bidding
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
// 1. 견적 조회 (quote_type=construction, status=finalized)
$quote = Quote::where('tenant_id', $tenantId)
->where('id', $quoteId)
->where('quote_type', 'construction')
->where('status', 'finalized')
->firstOrFail();
// 2. 이미 입찰이 존재하는지 확인
$existingBidding = Bidding::where('quote_id', $quoteId)->first();
if ($existingBidding) {
throw new BadRequestHttpException(__('error.bidding_already_exists'));
}
// 3. 입찰 데이터 생성
$bidding = Bidding::create([
'tenant_id' => $tenantId,
'bidding_code' => $this->generateBiddingCode($tenantId),
'quote_id' => $quote->id,
// 거래처/현장 정보 복사
'client_id' => $quote->client_id,
'client_name' => $quote->client_name,
'project_name' => $quote->site_name,
// 금액 정보
'bidding_amount' => $quote->total_amount,
'total_count' => $quote->items->count(),
// 날짜
'bidding_date' => now()->toDateString(),
// 상태
'status' => 'waiting',
// 현장설명회에서 공사기간 가져오기
'construction_start_date' => $quote->siteBriefing?->construction_start_date,
'construction_end_date' => $quote->siteBriefing?->construction_end_date,
'vat_type' => $quote->siteBriefing?->vat_type ?? 'excluded',
// 견적 옵션 데이터 스냅샷
'expense_items' => $quote->options['expense_items'] ?? [],
'estimate_detail_items' => $quote->options['detail_items'] ?? [],
'created_by' => $userId,
]);
// 4. 견적 상태 업데이트 (선택적)
// $quote->update(['status' => 'converted']);
return $bidding;
}
/**
* 입찰번호 자동 생성 (BID-YYYY-NNN)
*/
private function generateBiddingCode(int $tenantId): string
{
$year = now()->format('Y');
$prefix = "BID-{$year}-";
$lastBidding = Bidding::where('tenant_id', $tenantId)
->where('bidding_code', 'like', "{$prefix}%")
->orderBy('id', 'desc')
->first();
$sequence = 1;
if ($lastBidding) {
$lastNum = (int) substr($lastBidding->bidding_code, -3);
$sequence = $lastNum + 1;
}
return $prefix . str_pad($sequence, 3, '0', STR_PAD_LEFT);
}
```
### 3.9 Service/Controller 패턴 (SAM 표준)
**Controller 패턴** (api/app/Http/Controllers):
```php
<?php
namespace App\Http\Controllers\Api\v1;
use App\Helpers\ApiResponse;
use App\Http\Requests\Bidding\BiddingFilterRequest;
use App\Http\Requests\Bidding\BiddingUpdateRequest;
use App\Services\Bidding\BiddingService;
class BiddingController extends Controller
{
public function __construct(private BiddingService $service) {}
public function index(BiddingFilterRequest $request)
{
return ApiResponse::handle(fn () => $this->service->index($request->validated()));
}
public function show(int $id)
{
return ApiResponse::handle(fn () => $this->service->show($id));
}
public function update(BiddingUpdateRequest $request, int $id)
{
return ApiResponse::handle(fn () => $this->service->update($id, $request->validated()));
}
public function destroy(int $id)
{
return ApiResponse::handle(fn () => $this->service->destroy($id));
}
public function stats()
{
return ApiResponse::handle(fn () => $this->service->stats());
}
}
```
**Service 패턴** (api/app/Services):
```php
<?php
namespace App\Services\Bidding;
use App\Models\Bidding\Bidding;
use App\Services\Service;
use Illuminate\Pagination\LengthAwarePaginator;
class BiddingService extends Service
{
public function index(array $params): LengthAwarePaginator
{
$tenantId = $this->tenantId(); // 필수
$query = Bidding::where('tenant_id', $tenantId);
// ... 필터, 정렬, 페이지네이션
return $query->paginate($params['size'] ?? 20);
}
public function show(int $id): Bidding
{
$tenantId = $this->tenantId();
return Bidding::where('tenant_id', $tenantId)
->with(['quote'])
->findOrFail($id);
}
public function stats(): array
{
$tenantId = $this->tenantId();
return [
'total' => Bidding::where('tenant_id', $tenantId)->count(),
'waiting' => Bidding::where('tenant_id', $tenantId)->where('status', 'waiting')->count(),
'awarded' => Bidding::where('tenant_id', $tenantId)->where('status', 'awarded')->count(),
];
}
}
```
### 3.10 더미데이터 (Seeder용 10건)
> React 목업 기준 (`react/src/components/business/construction/bidding/actions.ts`)
```php
// api/database/seeders/BiddingSeeder.php
$biddings = [
[
'bidding_code' => 'BID-2025-001',
'client_name' => '이사대표',
'project_name' => '광장 아파트',
'bidding_date' => '2025-01-25',
'total_count' => 15,
'bidding_amount' => 71000000,
'bid_date' => '2025-01-20',
'submission_date' => '2025-01-22',
'confirm_date' => '2025-01-25',
'status' => 'awarded',
'bidder_name' => '홍길동',
'remarks' => '',
],
[
'bidding_code' => 'BID-2025-002',
'client_name' => '야사건설',
'project_name' => '대림아파트',
'bidding_date' => '2025-01-20',
'total_count' => 22,
'bidding_amount' => 100000000,
'bid_date' => '2025-01-18',
'submission_date' => null,
'confirm_date' => null,
'status' => 'waiting',
'bidder_name' => '김철수',
'remarks' => '',
],
[
'bidding_code' => 'BID-2025-003',
'client_name' => '여의건설',
'project_name' => '현장아파트',
'bidding_date' => '2025-01-18',
'total_count' => 18,
'bidding_amount' => 85000000,
'bid_date' => '2025-01-15',
'submission_date' => '2025-01-16',
'confirm_date' => '2025-01-18',
'status' => 'awarded',
'bidder_name' => '홍길동',
'remarks' => '',
],
[
'bidding_code' => 'BID-2025-004',
'client_name' => '이사대표',
'project_name' => '송파타워',
'bidding_date' => '2025-01-15',
'total_count' => 30,
'bidding_amount' => 120000000,
'bid_date' => '2025-01-12',
'submission_date' => '2025-01-13',
'confirm_date' => '2025-01-15',
'status' => 'failed',
'bidder_name' => '이영희',
'remarks' => '가격 경쟁력 부족',
],
[
'bidding_code' => 'BID-2025-005',
'client_name' => '야사건설',
'project_name' => '강남센터',
'bidding_date' => '2025-01-12',
'total_count' => 25,
'bidding_amount' => 95000000,
'bid_date' => '2025-01-10',
'submission_date' => '2025-01-11',
'confirm_date' => null,
'status' => 'submitted',
'bidder_name' => '홍길동',
'remarks' => '',
],
[
'bidding_code' => 'BID-2025-006',
'client_name' => '여의건설',
'project_name' => '목동센터',
'bidding_date' => '2025-01-10',
'total_count' => 12,
'bidding_amount' => 78000000,
'bid_date' => '2025-01-08',
'submission_date' => '2025-01-09',
'confirm_date' => '2025-01-10',
'status' => 'invalid',
'bidder_name' => '김철수',
'remarks' => '입찰 조건 미충족',
],
[
'bidding_code' => 'BID-2025-007',
'client_name' => '이사대표',
'project_name' => '서초타워',
'bidding_date' => '2025-01-08',
'total_count' => 35,
'bidding_amount' => 150000000,
'bid_date' => '2025-01-05',
'submission_date' => null,
'confirm_date' => null,
'status' => 'waiting',
'bidder_name' => '이영희',
'remarks' => '',
],
[
'bidding_code' => 'BID-2025-008',
'client_name' => '야사건설',
'project_name' => '청담프로젝트',
'bidding_date' => '2025-01-05',
'total_count' => 40,
'bidding_amount' => 200000000,
'bid_date' => '2025-01-03',
'submission_date' => '2025-01-04',
'confirm_date' => '2025-01-05',
'status' => 'awarded',
'bidder_name' => '홍길동',
'remarks' => '',
],
[
'bidding_code' => 'BID-2025-009',
'client_name' => '여의건설',
'project_name' => '잠실센터',
'bidding_date' => '2025-01-03',
'total_count' => 20,
'bidding_amount' => 88000000,
'bid_date' => '2025-01-01',
'submission_date' => null,
'confirm_date' => null,
'status' => 'hold',
'bidder_name' => '김철수',
'remarks' => '검토 대기 중',
],
[
'bidding_code' => 'BID-2025-010',
'client_name' => '이사대표',
'project_name' => '역삼빌딩',
'bidding_date' => '2025-01-01',
'total_count' => 10,
'bidding_amount' => 65000000,
'bid_date' => '2024-12-28',
'submission_date' => null,
'confirm_date' => null,
'status' => 'waiting',
'bidder_name' => '이영희',
'remarks' => '',
],
];
// 통계 요약:
// - total: 10건
// - waiting: 3건 (BID-002, 007, 010)
// - awarded: 3건 (BID-001, 003, 008)
// - submitted: 1건 (BID-005)
// - failed: 1건 (BID-004)
// - invalid: 1건 (BID-006)
// - hold: 1건 (BID-009)
```
---
## 4. 상세 작업 내용
> 각 Phase 진행 후 이 섹션에 상세 내용 추가
### 4.1 Phase 1: Database & Model
#### 1.1 마이그레이션 파일 생성
- **상태**: ⏳ 대기
- **파일**: `api/database/migrations/2026_01_19_XXXXXX_create_biddings_table.php`
#### 1.2 Model 생성
- **상태**: ⏳ 대기
- **파일**: `api/app/Models/Bidding/Bidding.php`
#### 1.3 Seeder 생성
- **상태**: ⏳ 대기
- **파일**: `api/database/seeders/BiddingSeeder.php`
- **데이터**: React 목업 기준 10건
---
## 5. 컨펌 대기 목록
> API 내부 로직 변경 등 승인 필요 항목
| # | 항목 | 변경 내용 | 영향 범위 | 상태 |
|---|------|----------|----------|------|
| 1 | QuoteService 수정 | `convertToBidding()` 메서드 추가 | api/Quote | ⏳ 대기 |
---
## 6. 변경 이력
| 날짜 | 항목 | 변경 내용 | 파일 | 승인 |
|------|------|----------|------|------|
| 2026-01-19 | - | 문서 초안 작성 | - | - |
---
## 7. 참고 문서
- **SAM API Rules**: `api/CLAUDE.md`
- **품질 체크리스트**: `docs/standards/quality-checklist.md`
- **Swagger 가이드**: `docs/guides/swagger-guide.md`
- **React 목업 타입**: `react/src/components/business/construction/bidding/types.ts`
- **React 목업 데이터**: `react/src/components/business/construction/bidding/actions.ts`
- **기존 견적 API**: `react/src/components/business/construction/estimates/actions.ts`
---
## 8. 세션 및 메모리 관리 정책 (Serena Optimized)
### 8.1 세션 시작 시 (Load Strategy)
```javascript
read_memory("bidding-api-state") // 1. 상태 파악
read_memory("bidding-api-snapshot") // 2. 사고 흐름 복구
```
### 8.2 작업 중 관리 (Context Defense)
| 컨텍스트 잔량 | Action | 내용 |
|--------------|--------|------|
| **30% 이하** | 🛠 Snapshot | 현재까지 코드 변경점 저장 |
| **20% 이하** | 🧹 Context Purge | 활성 심볼 저장 |
| **10% 이하** | 🛑 Stop & Save | 최종 상태 저장 |
### 8.3 Serena 메모리 구조
- `bidding-api-state`: { phase, progress, next_step }
- `bidding-api-snapshot`: 현재까지의 코드 변경점 요약
---
## 9. 검증 결과
> 작업 완료 후 이 섹션에 검증 결과 추가
### 9.1 API 테스트 케이스
| 엔드포인트 | 입력 | 예상 결과 | 실제 결과 | 상태 |
|-----------|------|----------|----------|------|
| GET /biddings | - | 목록 반환 | | ⏳ |
| GET /biddings/stats | - | 통계 반환 | | ⏳ |
| GET /biddings/{id} | id=1 | 단건 반환 | | ⏳ |
| PUT /biddings/{id} | 수정 데이터 | 수정 성공 | | ⏳ |
| POST /quotes/{id}/convert-to-bidding | quote_id | 입찰 생성 | | ⏳ |
### 9.2 성공 기준 달성 현황
| 기준 | 달성 | 비고 |
|------|------|------|
| Bidding API CRUD 동작 | ⏳ | |
| 견적 → 입찰 전환 동작 | ⏳ | |
| 더미데이터 10건 생성 | ⏳ | |
| Swagger 문서 완성 | ⏳ | |
| Pint 통과 | ⏳ | |
---
## 10. 자기완결성 점검 결과
### 10.1 체크리스트 검증
| # | 검증 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 1 | 작업 목적이 명확한가? | ✅ | 견적→입찰 전환 + 더미데이터 |
| 2 | 성공 기준이 정의되어 있는가? | ✅ | 9.2 참조 |
| 3 | 작업 범위가 구체적인가? | ✅ | Phase 1-4 정의 |
| 4 | 의존성이 명시되어 있는가? | ✅ | quotes API 의존 |
| 5 | 참고 파일 경로가 정확한가? | ✅ | 7. 참고 문서 |
| 6 | 단계별 절차가 실행 가능한가? | ✅ | 3.1 절차 |
| 7 | 검증 방법이 명시되어 있는가? | ✅ | 9.1 테스트 케이스 |
| 8 | 모호한 표현이 없는가? | ✅ | 구체적 파일/API 명시 |
### 10.2 새 세션 시뮬레이션 테스트
| 질문 | 답변 가능 | 참조 섹션 |
|------|:--------:|----------|
| Q1. 이 작업의 목적은 무엇인가? | ✅ | 1.1 배경 |
| Q2. 어디서부터 시작해야 하는가? | ✅ | 현재 진행 상태 + 3.1 |
| Q3. 어떤 파일을 수정해야 하는가? | ✅ | 2. 대상 범위 |
| Q4. 작업 완료 확인 방법은? | ✅ | 9. 검증 결과 |
| Q5. 막혔을 때 참고 문서는? | ✅ | 7. 참고 문서 |
**결과**: 5/5 통과 → ✅ 자기완결성 확보
---
*이 문서는 /sc:plan 스킬로 생성되었습니다.*

View File

@@ -0,0 +1,480 @@
# 시공사 페이지 API 연동 계획
> **작성일**: 2026-01-08
> **목적**: 시공사 8개 페이지 Mock → API 연동
> **기준 문서**: `docs/standards/api-rules.md`, `docs/guides/swagger-guide.md`
> **상태**: ✅ 완료
---
## 📍 현재 진행 상태
| 항목 | 내용 |
|------|------|
| **마지막 완료 작업** | Phase 3.4: 노임관리 API 연동 완료 ✅ |
| **다음 작업** | 🎉 **전체 완료** |
| **진행률** | 8/8 (100%) |
| **마지막 업데이트** | 2026-01-12 |
---
## 0. 전제 조건 (Prerequisites)
### 0.1 환경 확인
```bash
# Docker 컨테이너 상태 확인
docker ps | grep sam
# API 서버 접속 확인
curl -I http://api.sam.kr/api/health
# React 개발 서버 확인
curl -I http://react.sam.kr
```
**체크리스트:**
- [ ] Docker 컨테이너 실행 중 (api, react, mysql)
- [ ] api.sam.kr 접속 가능 (200 응답)
- [ ] react.sam.kr 접속 가능 (200 응답)
- [ ] 데이터베이스 연결 정상
### 0.2 권한 및 인증
- [ ] API 개발 권한 (`api/` 디렉토리 수정 가능)
- [ ] React 개발 권한 (`react/` 디렉토리 수정 가능)
- [ ] Sanctum 토큰 발급 방법 숙지 (테스트용)
### 0.3 필수 도구
- PHP 8.4+, Composer
- Node.js 20+, pnpm
- Git
---
## 1. 개요
### 1.1 배경
시공사 메뉴의 8개 페이지가 현재 모두 Mock 데이터를 사용하고 있으며, 실제 API 연동이 필요함.
(물량검토관리는 Frontend/기획 미존재로 제외)
### 1.2 기준 원칙
```
┌─────────────────────────────────────────────────────────────────┐
│ 🎯 핵심 원칙 │
├─────────────────────────────────────────────────────────────────┤
│ - Service-First: 비즈니스 로직 → Service Layer │
│ - Multi-tenancy: BelongsToTenant 필수 │
│ - FormRequest: Controller 검증 금지 │
│ - Server Actions: React에서 'use server' 패턴 사용 │
└─────────────────────────────────────────────────────────────────┘
```
### 1.3 변경 승인 정책
| 분류 | 예시 | 승인 |
|------|------|------|
| ✅ 즉시 가능 | actions.ts Mock→API 변경, 타입 수정 | 불필요 |
| ⚠️ 컨펌 필요 | 새 API 엔드포인트 생성, DB 스키마 변경 | **필수** |
| 🔴 금지 | 기존 API 삭제, 테이블 구조 변경 | 별도 협의 |
### 1.4 준수 규칙
- `docs/standards/api-rules.md` - API 개발 규칙 ✅ 존재
- `docs/guides/swagger-guide.md` - Swagger 작성 가이드 ✅ 존재
- `docs/standards/quality-checklist.md` - 품질 체크리스트 ✅ 존재
---
## 2. 대상 범위
### 2.1 Phase 1: 계약관리 (Contract)
| # | 작업 항목 | 상태 | 서브 문서 |
|---|----------|:----:|----------|
| 1.1 | 계약관리 (contract) | ✅ | [contract-plan.md](./sub/contract-plan.md) |
| 1.2 | 인수인계보고서관리 (handover-report) | ✅ | [handover-report-plan.md](./sub/handover-report-plan.md) |
### 2.2 Phase 2: 발주관리 (Order)
| # | 작업 항목 | 상태 | 서브 문서 |
|---|----------|:----:|----------|
| 2.1 | 현장관리 (site-management) | ✅ | [site-management-plan.md](./sub/site-management-plan.md) |
| 2.2 | 구조검토관리 (structure-review) | ✅ | [structure-review-plan.md](./sub/structure-review-plan.md) |
| 2.3 | 물량검토관리 (quantity-review) | ❌ 제외 | Frontend/기획 미존재 |
### 2.3 Phase 3: 기준정보 (Base Info)
| # | 작업 항목 | 상태 | 서브 문서 |
|---|----------|:----:|----------|
| 3.1 | 카테고리관리 (categories) | ✅ | [categories-plan.md](./sub/categories-plan.md) |
| 3.2 | 품목관리 (items) | ✅ | [items-plan.md](./sub/items-plan.md) |
| 3.3 | 단가관리 (pricing) | ✅ | [pricing-plan.md](./sub/pricing-plan.md) |
| 3.4 | 노임관리 (labor) | ✅ | [labor-plan.md](./sub/labor-plan.md) |
---
## 3. API 현황 분석
### 3.1 기존 API (연동 가능)
| API | 경로 | 상태 | 대상 컴포넌트 |
|-----|------|:----:|--------------|
| categories | `/api/construction/categories` | ✅ 존재 | 카테고리관리 |
| pricing | `/api/construction/pricing` | ✅ 존재 | 단가관리 |
### 3.2 신규 개발 필요 API
| API | 예상 경로 | 우선순위 | 대상 컴포넌트 |
|-----|----------|:--------:|--------------|
| contracts | `/api/construction/contracts` | ✅ 완료 | 계약관리 |
| handover-reports | `/api/construction/handover-reports` | ✅ 완료 | 인수인계보고서 |
| sites | `/api/construction/sites` | ✅ 완료 | 현장관리 |
| structure-reviews | `/api/construction/structure-reviews` | ✅ 완료 | 구조검토관리 |
| quantity-reviews | `/api/construction/quantity-reviews` | ❌ 제외 | 물량검토관리 (Frontend/기획 미존재) |
| items | `/api/construction/items` | 🟢 낮음 | 품목관리 |
| labor | `/api/construction/labor` | 🟢 낮음 | 노임관리 |
---
## 4. 작업 절차
### 4.1 단계별 절차 (상세)
```
Step 1: 서브 문서 확인
├── docs/plans/sub/{module}-plan.md 읽기
├── 현재 Mock 데이터 구조 확인
└── 필요한 API 엔드포인트 파악
Step 2: API 엔드포인트 확인/생성
├── api/routes/api.php에서 기존 API 확인
├── 없으면:
│ ├── Controller 생성: php artisan make:controller Api/Construction/{Name}Controller
│ ├── Service 생성: app/Services/Construction/{Name}Service.php
│ ├── FormRequest 생성: php artisan make:request Api/Construction/{Name}Request
│ └── Model 확인/생성
└── Swagger 문서 작성
Step 3: React actions.ts 수정
├── react/src/components/business/construction/{module}/actions.ts 열기
├── Mock 데이터 상수 제거 (MOCK_XXX)
├── API 호출 로직 구현:
│ └── const response = await fetch('/api/construction/{endpoint}', {...})
└── 에러 핸들링 추가
Step 4: 타입 정합성 확인
├── API 응답과 프론트엔드 타입 매칭
├── types.ts 수정 (snake_case → camelCase 변환 등)
└── 컴포넌트 수정 (필요시)
Step 5: 테스트 및 검증
├── API 직접 호출 테스트 (curl/Postman)
├── UI 동작 확인 (브라우저)
└── 에러 케이스 테스트
```
### 4.2 첫 번째 작업 시작점
**Phase 1.1 계약관리 시작:**
```bash
# 1. 서브 문서 읽기
cat docs/plans/sub/contract-plan.md
# 2. 현재 Mock 확인
cat react/src/components/business/construction/contract/actions.ts
# 3. API 존재 여부 확인
grep -n "contracts" api/routes/api.php
# 4. 없으면 Controller 생성
cd api && php artisan make:controller Api/Construction/ContractController --resource
```
---
## 5. 환경 정보
### 5.1 프로젝트 구조
```
SAM/
├── api/ # Laravel 12 REST API
│ ├── app/Http/Controllers/Api/Construction/
│ ├── app/Services/Construction/
│ └── routes/api.php
├── react/ # Next.js 15 Frontend
│ └── src/
│ ├── app/[locale]/(protected)/construction/
│ │ ├── project/contract/ # 계약관리
│ │ ├── project/contract/handover-report/ # 인수인계
│ │ ├── order/site-management/ # 현장관리
│ │ ├── order/structure-review/ # 구조검토
│ │ ├── order/order-management/ # 발주관리
│ │ └── order/base-info/ # 기준정보
│ │ ├── categories/
│ │ ├── items/
│ │ ├── pricing/
│ │ └── labor/
│ └── components/business/construction/
└── docs/plans/ # 계획 문서
├── construction-api-integration-plan.md # 메인 (현재 문서)
└── sub/ # 서브 문서 (9개)
```
### 5.2 개발 환경
| 항목 | 값 |
|------|-----|
| 도메인 | sam.kr (로컬) |
| API | api.sam.kr |
| React | react.sam.kr |
| PHP | 8.4+ |
| Laravel | 12 |
| Next.js | 15 |
---
## 6. 컴포넌트 분석 요약
### 6.1 계약관리 (Contract)
| 컴포넌트 | Mock 상태 | 주요 기능 |
|----------|:--------:|----------|
| ContractListClient | ✅ Mock | 목록, 검색, 삭제, 필터 |
| 인수인계보고서 | ✅ Mock | 목록, 상세, 삭제 |
### 6.2 발주관리 (Order)
| 컴포넌트 | Mock 상태 | 주요 기능 |
|----------|:--------:|----------|
| SiteManagementListClient | ✅ Mock | 현장 목록, 통계, 삭제 |
| StructureReviewListClient | ✅ Mock | 구조검토 목록, 상태 관리 |
| OrderManagementClient | ✅ Mock | 발주 목록, 필터, 삭제 |
### 6.3 기준정보 (Base Info)
| 컴포넌트 | Mock 상태 | API 존재 | 주요 기능 |
|----------|:--------:|:-------:|----------|
| CategoryManagementClient | ✅ Mock | ✅ | 카테고리 CRUD, 순서 변경 |
| ItemManagementClient | ✅ Mock | ❌ | 품목 CRUD, 카테고리 연결 |
| PricingListClient | ✅ Mock | ✅ | 단가 CRUD, 버전 관리 |
| LaborManagementClient | ✅ Mock | ❌ | 노임 CRUD, 단가 관리 |
---
## 7. 성공 기준
### 7.1 각 페이지 완료 조건
| # | 조건 | 확인 방법 |
|---|------|----------|
| 1 | Mock 데이터 완전 제거 | `grep -r "MOCK_" actions.ts` 결과 없음 |
| 2 | API 호출 성공 | 네트워크 탭에서 200 응답 확인 |
| 3 | UI에서 데이터 정상 표시 | 목록에 실제 데이터 표시 |
| 4 | CRUD 동작 정상 | 생성/조회/수정/삭제 모두 동작 |
| 5 | 에러 핸들링 동작 | 네트워크 끊김 시 에러 메시지 표시 |
### 7.2 전체 완료 조건
- [ ] 8개 페이지 모두 API 연동 완료 (4/8)
- [ ] Swagger 문서 작성 완료
- [ ] 기본 동작 테스트 통과
- [ ] 코드 리뷰 완료
### 7.3 품질 기준
- API 응답 시간: < 500ms
- 에러 발생 사용자 친화적 메시지 표시
- TypeScript 타입 에러 0개
- ESLint 경고 0개
---
## 8. 검증 방법
### 8.1 API 테스트 (curl)
```bash
# 1. 인증 토큰 획득 (테스트용)
TOKEN=$(curl -s -X POST "http://api.sam.kr/api/auth/login" \
-H "Content-Type: application/json" \
-d '{"email":"test@test.com","password":"password"}' | jq -r '.token')
# 2. 계약 목록 조회
curl -X GET "http://api.sam.kr/api/construction/contracts" \
-H "Authorization: Bearer $TOKEN" \
-H "Accept: application/json"
# 3. 계약 상세 조회
curl -X GET "http://api.sam.kr/api/construction/contracts/1" \
-H "Authorization: Bearer $TOKEN"
# 4. 계약 생성
curl -X POST "http://api.sam.kr/api/construction/contracts" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"title":"테스트 계약","partner_id":1}'
```
### 8.2 UI 테스트 체크리스트
```
□ 페이지 접속 시 로딩 스피너 표시
□ 데이터 로딩 완료 후 목록 표시
□ 검색 기능 동작
□ 필터 기능 동작
□ 페이지네이션 동작
□ 상세 보기 동작
□ 생성 폼 동작
□ 수정 폼 동작
□ 삭제 확인 및 동작
□ 에러 발생 시 메시지 표시
```
### 8.3 에러 케이스 테스트
| 케이스 | 예상 동작 | 확인 방법 |
|--------|----------|----------|
| 네트워크 끊김 | 에러 메시지 표시 | 네트워크 탭에서 Offline 모드 |
| 401 인증 오류 | 로그인 페이지 리다이렉트 | 토큰 만료 상태에서 접속 |
| 404 데이터 없음 | "데이터 없음" 표시 | 존재하지 않는 ID 접근 |
| 500 서버 오류 | 에러 메시지 표시 | API 강제 에러 발생 |
---
## 9. 세션 관리
### 9.1 새 세션 시작 시
```bash
# 1. 메인 문서 읽기 (현재 진행 상태 확인)
cat docs/plans/construction-api-integration-plan.md | head -30
# 2. "다음 작업" 확인
grep "다음 작업" docs/plans/construction-api-integration-plan.md
# 3. 해당 서브 문서 읽기
cat docs/plans/sub/{다음작업}-plan.md
# 4. 작업 시작
```
### 9.2 작업 중 체크포인트
| 시점 | 행동 |
|------|------|
| 작업 완료 | 메인 문서 "현재 진행 상태" 업데이트 |
| 서브 작업 완료 | 서브 문서 상태 (⏳→✅) 업데이트 |
| 컨펌 필요 | "컨펌 대기 목록" 추가 |
| 세션 종료 | 변경 이력에 기록 |
### 9.3 세션 종료 시
```bash
# 1. 진행 상태 업데이트
# - 📍 현재 진행 상태 섹션의 "마지막 완료 작업", "다음 작업" 수정
# - 대상 범위의 상태 아이콘 수정 (⏳ → ✅ 또는 🔄)
# 2. 변경 이력 추가
# | 2026-01-08 | 1.1 | 계약관리 API 연동 완료 | contract/actions.ts | - |
# 3. 커밋 (승인 후)
git add . && git commit -m "feat: [시공사] 1.1 계약관리 - API 연동"
```
### 9.4 컨텍스트 관리 (Serena 메모리)
```javascript
// 세션 시작 시 로드
read_memory("construction-api-state")
// 작업 중 저장 (30분마다 또는 주요 완료 시)
write_memory("construction-api-state", {
phase: "1.1",
status: "진행중",
lastCompleted: "Controller 생성",
nextStep: "Service 로직 구현"
})
// 컨텍스트 30% 이하 시
write_memory("construction-api-snapshot", "현재까지 진행 상황 요약...")
```
---
## 10. 변경 이력
| 날짜 | 항목 | 변경 내용 | 파일 | 승인 |
|------|------|----------|------|------|
| 2026-01-08 | 초안 | 문서 초안 작성, 9개 컴포넌트 분석 | - | - |
| 2026-01-08 | 보완 | 전제조건, 성공기준, 검증방법, 세션관리 추가 | - | - |
| 2026-01-09 | 1.1 | 계약관리 API 연동 완료 (Backend + Frontend) | api/, react/ | |
| 2026-01-09 | 1.2 | 인수인계보고서 Frontend API 연동 완료 | react/ | |
| 2026-01-09 | 2.1 | 현장관리 API 연동 완료 (Backend + Frontend) | api/, react/ | |
| 2026-01-09 | 2.2 | 구조검토관리 API 연동 완료 (Backend + Frontend) | api/, react/ | |
| 2026-01-09 | 2.3 | 물량검토관리 제외 (Frontend/기획 미존재) | docs/ | |
| 2026-01-09 | 3.1 | 카테고리관리 API 연동 완료 (HTTP 메서드 수정) | react/ | |
| 2026-01-09 | 3.2 | 품목관리 API 연동 완료 (apiClient.delete body 지원 추가) | react/ | |
| 2026-01-09 | 3.3 | 단가관리 Backend API 보완 (stats, bulkDestroy 추가) | api/ | |
---
## 11. 참고 문서
| 문서 | 경로 | 용도 |
|------|------|------|
| API 규칙 | `docs/standards/api-rules.md` | API 개발 표준 |
| Swagger 가이드 | `docs/guides/swagger-guide.md` | API 문서화 |
| 품질 체크리스트 | `docs/standards/quality-checklist.md` | 완료 점검 |
| 빠른 시작 | `docs/quickstart/quick-start.md` | 환경 설정 |
| 개발 명령어 | `docs/quickstart/dev-commands.md` | 자주 쓰는 명령어 |
---
## 12. 서브 문서 링크
| Phase | 문서 | 경로 | API 상태 |
|-------|------|------|:--------:|
| 1.1 | 계약관리 | [./sub/contract-plan.md](./sub/contract-plan.md) | 완료 |
| 1.2 | 인수인계보고서 | [./sub/handover-report-plan.md](./sub/handover-report-plan.md) | 신규 |
| 2.1 | 현장관리 | [./sub/site-management-plan.md](./sub/site-management-plan.md) | 확인필요 |
| 2.2 | 구조검토관리 | [./sub/structure-review-plan.md](./sub/structure-review-plan.md) | 신규 |
| 2.3 | 발주관리 | [./sub/order-management-plan.md](./sub/order-management-plan.md) | 신규 |
| 3.1 | 카테고리관리 | [./sub/categories-plan.md](./sub/categories-plan.md) | 존재 |
| 3.2 | 품목관리 | [./sub/items-plan.md](./sub/items-plan.md) | 신규 |
| 3.3 | 단가관리 | [./sub/pricing-plan.md](./sub/pricing-plan.md) | 존재 |
| 3.4 | 노임관리 | [./sub/labor-plan.md](./sub/labor-plan.md) | 신규 |
---
## 13. 자기완결성 점검 결과
### 13.1 체크리스트 검증
| # | 검증 항목 | 상태 | 참조 섹션 |
|---|----------|:----:|----------|
| 1 | 작업 목적이 명확한가? | | 1.1 배경 |
| 2 | 성공 기준이 정의되어 있는가? | | 7. 성공 기준 |
| 3 | 작업 범위가 구체적인가? | | 2. 대상 범위 |
| 4 | 의존성이 명시되어 있는가? | | 0. 전제 조건 |
| 5 | 참고 파일 경로가 정확한가? | | 11. 참고 문서 (검증됨) |
| 6 | 단계별 절차가 실행 가능한가? | | 4. 작업 절차 |
| 7 | 검증 방법이 명시되어 있는가? | | 8. 검증 방법 |
| 8 | 모호한 표현이 없는가? | | 구체적 명령어 포함 |
### 13.2 새 세션 시뮬레이션 테스트
| 질문 | 답변 가능 | 참조 섹션 |
|------|:--------:|----------|
| Q1. 작업의 목적은 무엇인가? | | 1.1 배경 |
| Q2. 어디서부터 시작해야 하는가? | | 4.2 번째 작업 시작점 |
| Q3. 어떤 파일을 수정해야 하는가? | | 5.1 프로젝트 구조 + 서브 문서 |
| Q4. 작업 완료 확인 방법은? | | 7. 성공 기준, 8. 검증 방법 |
| Q5. 막혔을 참고 문서는? | | 11. 참고 문서 |
**결과: 5/5 통과 → ✅ 자기완결성 확보**
---
*이 문서는 /sc:plan 스킬로 생성되었습니다.*
*보완일: 2026-01-08*

View File

@@ -0,0 +1,309 @@
# docs/architecture 문서 업데이트 계획
> **작성일**: 2025-12-26
> **목적**: 현재 시스템 상태와 문서 동기화
> **기준 문서**: docs/INDEX.md
> **상태**: 🔄 진행중
---
## 📍 현재 진행 상태
| 항목 | 내용 |
|------|------|
| **마지막 완료 작업** | Phase 4 전체 완료 |
| **다음 작업** | 없음 (완료) |
| **진행률** | 13/13 (100%) ✅ |
| **마지막 업데이트** | 2025-12-26 |
---
## 1. 개요
### 1.1 배경
- 2025-12-13 admin 프로젝트 → mng 프로젝트 전환 완료
- 문서에 아직 admin 참조가 남아있어 동기화 필요
- 기술 스택 버전 업데이트 반영 필요
### 1.2 기준 원칙
```
┌─────────────────────────────────────────────────────────────────┐
│ 🎯 문서 업데이트 원칙 │
├─────────────────────────────────────────────────────────────────┤
│ - 현재 시스템 상태와 100% 동기화 │
│ - admin → mng 전환 완전 반영 │
│ - 버전 정보 최신화 (React 19.2.1, Next.js 15.5.7) │
│ - 상호 참조 링크 일관성 유지 │
└─────────────────────────────────────────────────────────────────┘
```
### 1.3 변경 승인 정책
| 분류 | 예시 | 승인 |
|------|------|------|
| ✅ 즉시 가능 | 날짜 갱신, 오타 수정, 버전 업데이트 | 불필요 |
| ⚠️ 컨펌 필요 | 구조 변경, 새 섹션 추가, 문서 삭제 | **필수** |
| 🔴 금지 | 비즈니스 로직 변경, 정책 변경 | 별도 협의 |
### 1.4 준수 규칙
- `docs/INDEX.md` - 문서 인덱스
- `docs/standards/quality-checklist.md` - 품질 체크리스트
---
## 2. 대상 범위
### 2.1 Phase 1: 핵심 문서 업데이트
| # | 작업 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 1.1 | system-overview.md - admin→mng 전환 | ✅ | 완료 |
| 1.2 | dev-commands.md - admin→mng 변경 | ✅ | 완료 |
| 1.3 | quick-start.md - claudedocs→docs 경로 수정 | ✅ | 완료 |
### 2.2 Phase 2: 보조 문서 업데이트
| # | 작업 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 2.1 | INDEX.md - 프로젝트 구조 미세 조정 | ✅ | Admin 참조 제거 |
| 2.2 | quality-checklist.md - 날짜 갱신 | ✅ | 2025-12-26 |
| 2.3 | swagger-guide.md - 날짜 갱신 | ✅ | 2025-12-26 |
### 2.3 Phase 3: 검증 및 정리
| # | 작업 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 3.1 | security-policy.md - 날짜 갱신 | ✅ | 2025-12-26 |
| 3.2 | database-schema.md - 테이블 수 업데이트 | ✅ | 92개→171개 |
### 2.4 Phase 4: 오래된 파일 정리/아카이브
| # | 작업 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 4.1 | history/2025-09/ 문서 검토 | ✅ | 참조용 유지 |
| 4.2 | history/2025-11/ 문서 검토 | ✅ | 아카이브로 적절 |
| 4.3 | admin 참조 파일 식별 및 정리 | ✅ | 4개 파일 수정 완료 |
| 4.4 | 완료된 plans/ 문서 정리 | ✅ | D0.8→history, index 업데이트 |
| 4.5 | 중복/불필요 문서 정리 | ✅ | 빈 디렉토리 6개 삭제 |
---
## 3. 작업 절차
### 3.1 단계별 절차
```
Step 1: Phase 1 - 핵심 문서 업데이트
├── 1.1 system-overview.md 전면 업데이트
│ ├── admin/ 설명 → mng/ 설명
│ ├── Filament v4 → Pure Blade + Tailwind
│ ├── Docker 서비스 구성 업데이트
│ └── 저장소 구조 업데이트
├── 1.2 dev-commands.md 수정
│ ├── Admin Application → MNG Application
│ └── admin/ 경로 → mng/ 경로
└── 1.3 quick-start.md 수정
├── claudedocs/ → docs/ 경로
└── 프로젝트 구조 업데이트
Step 2: Phase 2 - 보조 문서 업데이트
├── 2.1 INDEX.md 미세 조정
├── 2.2 quality-checklist.md 날짜 갱신
└── 2.3 swagger-guide.md 날짜 갱신
Step 3: Phase 3 - 검증 및 정리
├── 3.1 security-policy.md 날짜 갱신
├── 3.2 database-schema.md 테이블 수 확인
└── 3.3 모든 문서 일관성 검증
Step 4: Phase 4 - 오래된 파일 정리/아카이브
├── 4.1 history/2025-09/ 문서 검토
│ └── 구버전 스키마, 체크포인트 확인
├── 4.2 history/2025-11/ 문서 검토
│ └── item-master 관련 아카이브 정리
├── 4.3 admin 참조 파일 정리
│ └── mng로 미전환된 파일 식별/수정
├── 4.4 완료된 plans/ 문서 정리
│ └── 완료된 계획 문서 삭제/아카이브
└── 4.5 중복/불필요 문서 정리
└── 통합 가능 문서 식별 및 처리
```
### 3.2 문서 업데이트 템플릿
```markdown
### [항목 ID] 항목명
**현재 상태:**
- [현재 상태 설명]
**목표 상태:**
- [목표 상태 설명]
**변경 사항:**
- [ ] ✅ [즉시 가능 항목]
- [ ] ⚠️ [컨펌 필요 항목]
```
---
## 4. 상세 작업 내용
### 4.1 Phase 1: 핵심 문서 업데이트
#### 1.1 system-overview.md
- **상태**: ⏳ 대기
- **주요 변경**:
- [ ] admin/ 섹션 → mng/ 섹션으로 전환
- [ ] 기술 스택: Filament v4 → Pure Blade + Tailwind CSS 3.x
- [ ] Docker 서비스: design, php73 추가
- [ ] React 버전: 19.2.0 → 19.2.1
- [ ] Next.js 버전: 15 → 15.5.7
- [ ] 도메인 매핑: admin.sam.kr → mng 서비스 설명
- [ ] 저장소 구조: admin → mng
#### 1.2 dev-commands.md
- **상태**: ⏳ 대기
- **주요 변경**:
- [ ] "Admin Application (admin/)" → "MNG Application (mng/)"
- [ ] admin/ 경로 → mng/ 경로
- [ ] 업데이트 날짜 갱신
#### 1.3 quick-start.md
- **상태**: ⏳ 대기
- **주요 변경**:
- [ ] claudedocs/SAM/ 경로 → docs/ 경로
- [ ] 프로젝트 구조에 mng, design, planning 추가
- [ ] admin/ 참조 → mng/ 참조
- [ ] 업데이트 날짜 갱신
### 4.2 Phase 4: 오래된 파일 정리/아카이브
#### 4.1 history/2025-09/ 문서 검토
- **상태**: ⏳ 대기
- **대상 파일**:
- `history/2025-09/checkpoint.md` - 구버전 체크포인트
- `history/2025-09/database-schema.md` - 구버전 스키마 (참조용 유지 검토)
- **조치**: 아카이브 적합성 검토, 불필요시 삭제
#### 4.2 history/2025-11/ 문서 검토
- **상태**: ⏳ 대기
- **대상 파일**:
- `history/2025-11/item-master-gap-analysis.md`
- `history/2025-11/item-master-spec.md`
- `history/2025-11/front-requests/` 디렉토리
- `history/2025-11/item-master-archived/` 디렉토리
- **조치**: 현재 유효성 검토, 아카이브 정리
#### 4.3 admin 참조 파일 식별 및 정리
- **상태**: ⏳ 대기
- **검색 대상**: docs/ 전체에서 "admin" 키워드 포함 파일
- **조치**: mng로 전환 또는 deprecated 표시
#### 4.4 완료된 plans/ 문서 정리
- **상태**: ⏳ 대기
- **대상 파일**:
- 완료된 계획 문서 식별
- 현재 진행중인 문서 유지
- **조치**: 완료된 계획은 삭제 또는 history/로 이동
#### 4.5 중복/불필요 문서 정리
- **상태**: ⏳ 대기
- **검토 대상**:
- 내용이 중복된 문서
- 더 이상 유효하지 않은 문서
- 통합 가능한 문서
- **조치**: 통합, 삭제, 또는 아카이브
---
## 5. 컨펌 대기 목록
> 구조 변경 등 승인 필요 항목
| # | 항목 | 변경 내용 | 영향 범위 | 상태 |
|---|------|----------|----------|------|
| - | - | - | - | - |
---
## 6. 변경 이력
| 날짜 | 항목 | 변경 내용 | 파일 | 승인 |
|------|------|----------|------|------|
| 2025-12-26 | - | 계획 문서 초안 작성 | - | - |
| 2025-12-26 | Phase 4 | 오래된 파일 정리/아카이브 작업 추가 | docs-update-plan.md | - |
| 2025-12-26 | Phase 1 | 핵심 문서 3개 업데이트 완료 | system-overview.md, dev-commands.md, quick-start.md | ✅ |
| 2025-12-26 | Phase 2 | 보조 문서 3개 업데이트 완료 | INDEX.md, quality-checklist.md, swagger-guide.md | ✅ |
| 2025-12-26 | Phase 3 | 검증 및 정리 완료 | security-policy.md, database-schema.md | ✅ |
| 2025-12-26 | Phase 4.1-4.2 | history/ 문서 검토 완료 | - | ✅ |
| 2025-12-26 | Phase 4.4 | plans/ 정리 완료 | D0.8→history, index_plans.md 업데이트 | ✅ |
| 2025-12-26 | Phase 4.3 | admin 참조 파일 정리 | docker-setup, git-conventions, project-launch-roadmap, remote-work-setup | ✅ |
---
## 7. 참고 문서
- **문서 인덱스**: `docs/INDEX.md`
- **품질 체크리스트**: `docs/standards/quality-checklist.md`
- **Serena 메모리**: `docs-update-analysis.md`
---
## 8. 세션 관리 정책
### 8.1 세션 시작 시
```
list_memories() → 기존 상태 확인
read_memory("docs-update-analysis") → 분석 결과 로드
이 계획 문서 읽기 → 컨텍스트 로드
```
### 8.2 작업 중
- 변경 이력 실시간 업데이트
- Phase/항목별 상태 업데이트
- 컨펌 필요 시 대기 목록 추가
### 8.3 세션 종료 시
```
변경 이력에 최종 업데이트 기록
write_memory("docs-update-progress") → Serena에 저장
```
### 8.4 Serena 메모리 구조
```
docs-update-analysis.md # 분석 결과 (완료)
docs-update-progress.md # 진행 상황 (작업 중 업데이트)
```
---
## 9. 검증 결과
> 작업 완료 후 이 섹션에 검증 결과 추가
### 9.1 문서 일관성 체크
| 문서 | admin 참조 | mng 반영 | 날짜 최신화 | 링크 유효 |
|------|:----------:|:--------:|:-----------:|:---------:|
| system-overview.md | | | | |
| dev-commands.md | | | | |
| quick-start.md | | | | |
| INDEX.md | | | | |
| quality-checklist.md | | | | |
| swagger-guide.md | | | | |
| security-policy.md | | | | |
| database-schema.md | | | | |
### 9.2 성공 기준 달성 현황
| 기준 | 달성 | 비고 |
|------|------|------|
| admin 참조 완전 제거 | | |
| mng 반영 완료 | | |
| 버전 정보 최신화 | | |
| 상호 참조 링크 유효 | | |
---
*이 문서는 /plan 스킬로 생성되었습니다.*

View File

@@ -0,0 +1,31 @@
# 문서관리 시스템 - 변경 이력
> **본 문서**: `docs/plans/document-management-system-plan.md`의 변경 이력
> **최종 업데이트**: 2026-02-12
---
## 변경 이력
| 날짜 | 항목 | 변경 내용 | 관련 섹션 | 승인 |
|------|------|----------|----------|------|
| 2026-01-31 | 초안 | 기존 시스템 분석 기반 계획 문서 전면 재작성 | 본 문서 | - |
| 2026-01-31 | Phase 1.1 완료 | 양식 편집 UI 5개 탭 전체 CRUD 확인 (사실상 완료) | 섹션 3.1, 11.1 | - |
| 2026-01-31 | Phase 1.2 완료 | viewJS.php 라우팅 분석 + EGI/SUS 대표 2종 상세 분석 + 공통패턴 추출 | 섹션 3.1, 11.2 | - |
| 2026-01-31 | Phase 1.3 완료 | IncomingInspectionTemplateSeeder 생성. EGI(ID:7), SUS(ID:8) 2종 시드 완료. 결재2+기본필드10+섹션+항목+컬럼 전체 | 섹션 3.1 | - |
| 2026-01-31 | Phase 1.4 완료 | 미리보기 기능 기존 구현 확인. 모달로 결재란+기본정보+검사이미지+검사테이블(complex)+Footer 모두 렌더링 | 섹션 3.1 | - |
| 2026-01-31 | Phase 1.5 완료 | 양식 복제 기능. duplicate() 메서드 + 라우트 + 테이블 버튼 + JS 함수 추가 | 섹션 3.1 | - |
| 2026-01-31 | Phase 2.1 완료 | 문서 생성 기능 보완. ①문서번호 카테고리별 prefix(IQC/PRD/SLS/PUR, YYMMDD-순번) ②결재라인 초기화(template.approvalLines→document_approvals) ③기본필드 뷰 속성 불일치 수정(field_type/label/default_value 매핑, Str::slug로 field_key 생성) ④섹션 title 참조 수정 | 섹션 3.2 | - |
| 2026-01-31 | Phase 2.2 완료 | 문서 데이터 입력 UI. ①섹션별 동적 검사 테이블 렌더링(complex/select/check/measurement/text 컬럼 타입 지원) ②서브 라벨 행(complex 컬럼의 n1/n2/n3) ③정적 컬럼 자동 매핑(NO/검사항목/검사기준/검사방식/검사주기→item속성) ④종합판정+비고 Footer ⑤JS 폼 데이터 수집(기본필드+섹션데이터+체크박스) ⑥백엔드 saveDocumentData() 공통 메서드(section_id/column_id/row_index EAV 저장) | 섹션 3.2 | - |
| 2026-01-31 | Phase 2.3 완료 | 결재 워크플로우. ①API: submit(DRAFT→PENDING), approve(단계별 승인, 전체 완료 시 APPROVED), reject(반려 사유 필수, REJECTED) ②edit.blade: 결재 제출 버튼 + JS ③show.blade: 승인/반려 버튼, 반려 모달, 결재 현황 속성 수정(step/role/acted_at), 상태 배지 CSS ④재제출 시 결재라인 상태 초기화 ⑤라우트: submit/approve/reject 3개 추가 | 섹션 3.2 | - |
| 2026-01-31 | Phase 2.4 완료 | 문서 목록/검색/필터. ①날짜 범위 필터(date_from/date_to) API + UI 추가 ②DRAFT 문서 삭제 버튼 + deleteDocument() JS (showDeleteConfirm + fetch DELETE) ③기존 구현 확인: 상태/템플릿/검색/페이징 정상 동작 | 섹션 3.2 | - |
| 2026-01-31 | Phase 3.1 완료 | 중간검사 양식 구조 설계. ①5130 레거시 4종(절곡/스크린/슬랫/조인트바) viewMidInspect*.php 전체 분석 ②검사항목·기준·판정방식·공차·이미지 문서화 ③컬럼 구조(check/complex/select) 매핑 설계 ④4종 비교표 + 양식 시스템 매핑 전략(Option A/B/C) ⑤공통 구조(결재3단계, 기본필드7개, Footer) 정의 | 섹션 5.2 | - |
| 2026-01-31 | Phase 3.2 완료 | 5130 중간검사 데이터 이관 설계. ①JSON 공통 배열 구조 분석([0]결재/[1]입력값/[2]num/[3]table/[4]log/[5]checkbox) ②JSON→EAV 매핑 테이블(결재→document_approvals, 기본필드/측정값/체크박스→document_data) ③데이터 변환 규칙(날짜mm/dd→datetime, boolean→string, 이름→user_id) ④6단계 이관 프로세스 설계 ⑤절곡품 inputValue named object vs 나머지 flat array 차이 문서화 ⑥주의사항 5건 | 섹션 5.3 | - |
| 2026-01-31 | Phase 3.3 완료 | 중간검사 양식 시드 데이터. MidInspectionTemplateSeeder 생성. ①조인트바(ID:10, 1섹션6항목8컬럼, 고정기준값4개) ②슬랫(ID:11, 1섹션5항목7컬럼, 고정2+도면1) ③스크린(ID:12, 1섹션6항목8컬럼, 겉모양3+치수3) ④절곡품(ID:13, 4섹션11항목7컬럼, 구성품별 분리) ⑤공통: 결재3단계(판매→생산→품질), 기본필드7개, Footer(부적합+종합판정) | 섹션 3.3 | - |
| 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 | - |
| 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, 마스터 문서 | - |

View File

@@ -0,0 +1,375 @@
# Phase 5.2: 제품검사(FQC) 폼 구현 계획
> **작성일**: 2026-02-10
> **마스터 문서**: [`document-system-master.md`](./document-system-master.md)
> **상태**: 🔄 진행 중
> **선행 조건**: Phase 5.0 (공통: 검사기준서↔컬럼 연동) 완료 필요, Phase 5.1과 병렬 진행 가능
> **최종 분석일**: 2026-02-12
---
## 1. 개요
### 1.1 목적
mng에서 제품검사(FQC) 양식 템플릿을 관리하고, React 품질관리 화면(`/quality/inspections`)에서 수주건의 **개소별** 제품검사 문서를 생성/입력/결재할 수 있도록 한다.
### 1.2 제품검사 = 품질검사
- 동일 개념. "제품검사(FQC: Final Quality Control)"로 통일
- 수주건(Order) + 개소(OrderItem) 단위로 관리
- **전수검사**: 수주 50개소 → 제품검사 문서 50건 생성
### 1.3 현재 상태 (2026-02-12 분석)
| 항목 | 상태 | 비고 |
|------|:----:|------|
| React InspectionManagement | ✅ | `components/quality/InspectionManagement/` - 요청관리 CRUD (목록/등록/상세/캘린더) |
| React ProductInspectionDocument | ✅ | `quality/qms/components/documents/` - 하드코딩 11개 항목 |
| React 제품검사 모달 | ✅ | InspectionReportModal, ProductInspectionInputModal |
| React 문서시스템 뷰어 | ✅ | `components/document-system/` - DocumentViewer, TemplateInspectionContent |
| API Inspection 모델 | ✅ | `/api/v1/inspections` - JSON 기반, 단순 status (waiting→completed) |
| API Document 모델 | ✅ | EAV 정규화, 결재 워크플로우 (DRAFT→APPROVED) |
| mng 양식 템플릿 | ❌ | 미존재 (신규 생성 필요) |
| 개소별 문서 자동생성 | ❌ | 미구현 |
### 1.4 핵심 발견 사항
**두 개의 독립적 검사 시스템 존재:**
| 시스템 | 데이터 모델 | 특징 |
|--------|------------|------|
| InspectionManagement | `inspections` 테이블 (JSON) | 요청관리, 단순 상태, 결재 없음 |
| Document System | `documents` 테이블 (EAV) | 양식 기반, 결재 워크플로우, 이력 관리 |
**세 가지 검사항목 세트 발견:**
| 출처 | 항목 | 용도 |
|------|------|------|
| types.ts ProductInspectionData | 겉모양(가공/재봉/조립/연기차단재/하단마감재), 모터, 재질/치수, 시험 | 공장출하검사 |
| 계획문서 (이 문서) | 외관, 작동, 개폐속도, 방연/차연/내화, 안전, 비상개방, 전기배선, 설치, 부속 | **설치 후 최종검사 ← 채택** |
| QMS ProductInspectionDocument | 가공상태, 외관검사, 절단면, 도포상태, 조립, 슬릿, 규격치수, 마감처리, 내벽/마감/배색시트 | 제조품질검사 |
### 1.5 통합 전략 (확정)
> **InspectionManagement의 요청관리 흐름(목록/등록/상세/캘린더)은 유지하고,
> 검사 성적서 생성/입력/결재만 documents 시스템으로 전환한다.**
- `inspections` 테이블: 검사 요청/일정/상태 관리 (meta 정보) → **유지**
- `documents` 테이블: 검사 성적서 (양식 기반 상세 데이터, 결재) → **신규 연동**
- 연결: `documents.linkable_type = 'order_item'`, `document_links`로 Order/Inspection 연결
- 기존 InspectionReportModal/ProductInspectionInputModal → TemplateInspectionContent 기반 전환
### 1.6 성공 기준
1. mng에서 제품검사 양식 편집/미리보기 정상 동작
2. 수주 1건 선택 시 개소(OrderItem) 수만큼 Document 자동생성
3. 각 Document에 해당 개소의 정보(층-부호, 규격, 수량) 자동매핑
4. 개소별 검사 데이터 입력/저장/조회 가능
5. 결재 워크플로우 정상 동작
6. 기존 InspectionManagement 요청관리 기능 정상 유지
---
## 2. 데이터 흐름
```
Order (수주)
├─ order_no: "KD-TS-260210-01"
├─ client_name: "발주처명"
├─ site_name: "현장명"
├─ quantity: 50 (총 개소 수)
└─ items: OrderItem[] (50건)
├─ [0] floor_code="1F", symbol_code="A", specification="W7400×H2950"
├─ [1] floor_code="1F", symbol_code="B", specification="W5200×H3100"
└─ [49] ...
제품검사 요청 시:
Document (50건 자동생성)
├─ Document[0]
│ ├─ template_id → 제품검사 양식
│ ├─ linkable_type = 'App\Models\OrderItem'
│ ├─ linkable_id = OrderItem[0].id
│ ├─ document_no = "FQC-260210-01"
│ ├─ title = "제품검사 - 1F-A (W7400×H2950)"
│ └─ document_data (EAV)
│ ├─ 기본필드: 납품명, 제품명, 발주처, LOT NO, 로트크기, 검사일자, 검사자
│ ├─ 검사데이터: 11개 항목별 적합/부적합
│ └─ Footer: 종합판정(합격/불합격)
├─ Document[1] → OrderItem[1]
└─ Document[49] → OrderItem[49]
+ document_links 연결:
├─ link_key="order" → Order.id
└─ link_key="inspection" → Inspection.id (있는 경우)
```
### 2.1 linkable 다형성 연결
| 필드 | 값 | 설명 |
|------|-----|------|
| `linkable_type` | `App\Models\OrderItem` | OrderItem 모델 |
| `linkable_id` | OrderItem.id | 개소 PK |
추가로 `document_links` 테이블을 통해:
- Order(수주) 연결: link_key="order"
- Inspection(검사요청) 연결: link_key="inspection" (InspectionManagement에서 연결 시)
- Process(공정) 연결: link_key="process" (해당되는 경우)
---
## 3. 작업 항목
| # | 작업 | 상태 | 완료 기준 | 비고 |
|---|------|:----:|----------|------|
| 5.2.1 | mng 제품검사 양식 시더 생성 | ✅ | ProductInspectionTemplateSeeder 작성 (template_id: 65). 결재3+기본필드7+섹션2+항목11+section_fields | 2026-02-12 |
| 5.2.2 | mng 양식 편집/미리보기 검증 | ✅ | 양식 edit → 미리보기 → 저장 정상 동작 확인 | 2026-02-12 |
| 5.2.3 | API 개소별 문서 일괄생성 | ✅ | `POST /api/v1/documents/bulk-create-fqc` + `GET /api/v1/documents/fqc-status`. DocumentService에 bulkCreateFqc/fqcStatus 추가 | 2026-02-12 |
| 5.2.4 | React 제품검사 모달 → 양식 기반 전환 | ✅ | fqcActions.ts + FqcDocumentContent.tsx 신규. InspectionReportModal/ProductInspectionInputModal 듀얼모드(FQC/legacy) | 2026-02-12 |
| 5.2.5 | 개소 목록/진행현황 UI | ✅ | InspectionDetail에 FQC 진행현황 통계 바 + 개소별 상태 뱃지(합격/불합격/진행중/미생성) + 조회 버튼 | 2026-02-12 |
---
## 4. 제품검사 항목 (설치 후 최종검사 11항목 - 확정)
| # | 카테고리 | 검사항목 | 검사기준 | 검사방식 | 측정유형 |
|---|---------|---------|---------|---------|---------|
| 1 | 외관 | 외관검사 | 사용상 결함이 없을 것 | visual | checkbox |
| 2 | 기능 | 작동상태 | 정상 작동 | visual | checkbox |
| 3 | 기능 | 개폐속도 | 규정 속도 범위 이내 | visual | checkbox |
| 4 | 성능 | 방연성능 | 기준 적합 | visual | checkbox |
| 5 | 성능 | 차연성능 | 기준 적합 | visual | checkbox |
| 6 | 성능 | 내화성능 | 기준 적합 | visual | checkbox |
| 7 | 안전 | 안전장치 | 정상 작동 | visual | checkbox |
| 8 | 안전 | 비상개방 | 정상 작동 | visual | checkbox |
| 9 | 설치 | 전기배선 | 규정 적합 | visual | checkbox |
| 10 | 설치 | 설치상태 | 규정 적합 | visual | checkbox |
| 11 | 부속 | 부속품 | 누락 없음 | visual | checkbox |
**특성:**
- 모든 항목이 visual/checkbox (적합/부적합)
- numeric 측정값 없음 → columns 구조가 중간검사보다 훨씬 단순
- **columns 자동 파생(방안1)**: checkbox → 판정(select) 컬럼
**결재라인**: 작성(품질) → 검토(품질QC) → 승인(경영)
**Footer**: 부적합 내용 + 종합판정(합격/불합격)
**자동판정**: 모든 항목 적합 → 합격, 1개라도 부적합 → 불합격
### 4.1 양식 시더 구조 (MidInspectionTemplateSeeder 패턴)
```php
// ProductInspectionTemplateSeeder
[
'name' => '제품검사 성적서',
'category' => '품질/제품검사',
'title' => '제 품 검 사 성 적 서',
'company_name' => '케이디산업',
'footer_remark_label' => '부적합 내용',
'footer_judgement_label' => '종합판정',
'footer_judgement_options' => ['합격', '불합격'],
'approval_lines' => [
['name' => '작성', 'dept' => '품질', 'role' => '담당자', 'sort_order' => 1],
['name' => '검토', 'dept' => '품질', 'role' => 'QC', 'sort_order' => 2],
['name' => '승인', 'dept' => '경영', 'role' => '대표', 'sort_order' => 3],
],
'basic_fields' => [
['label' => '납품명', 'field_type' => 'text'],
['label' => '제품명', 'field_type' => 'text'],
['label' => '발주처', 'field_type' => 'text'],
['label' => 'LOT NO', 'field_type' => 'text'],
['label' => '로트크기', 'field_type' => 'text'],
['label' => '검사일자', 'field_type' => 'date'],
['label' => '검사자', 'field_type' => 'text'],
],
'sections' => [
[
'title' => '제품검사 기준서',
'items' => [], // 기준서 섹션 (빈 섹션, 향후 확장)
],
[
'title' => '제품검사 DATA',
'items' => [
['category' => '외관', 'item' => '외관검사', ...],
// ... 11개 항목 (모두 visual/checkbox)
],
],
],
// columns는 자동 파생 (Phase 5.0 방안1)
// checkbox → [NO, 검사항목, 검사기준, 판정(select)]
]
```
---
## 5. 개소별 문서 일괄생성 로직
### 5.1 API 엔드포인트 (계획)
```
POST /api/v1/orders/{orderId}/create-fqc
Request: { template_id: number }
Response: { documents: Document[], created_count: number }
```
### 5.2 생성 로직
```php
// 1. Order + OrderItems 조회
$order = Order::with('items')->findOrFail($orderId);
// 2. 개소별 Document 생성
foreach ($order->items as $index => $orderItem) {
$document = Document::create([
'template_id' => $templateId,
'document_no' => "FQC-" . date('ymd') . "-" . str_pad($index + 1, 2, '0', STR_PAD_LEFT),
'title' => "제품검사 - {$orderItem->floor_code}-{$orderItem->symbol_code} ({$orderItem->specification})",
'status' => DocumentStatus::DRAFT,
'linkable_type' => OrderItem::class,
'linkable_id' => $orderItem->id,
]);
// 3. 기본필드 자동매핑
$autoFillData = [
'납품명' => $order->title,
'제품명' => $orderItem->item_name,
'발주처' => $order->client_name,
'LOT NO' => $order->order_no,
'로트크기' => "1 EA",
];
// 4. document_data에 기본필드 저장
foreach ($autoFillData as $key => $value) {
DocumentData::create([
'document_id' => $document->id,
'field_key' => Str::slug($key),
'field_value' => $value,
]);
}
// 5. document_links 연결
DocumentLink::create([
'document_id' => $document->id,
'link_key' => 'order',
'linkable_type' => Order::class,
'linkable_id' => $order->id,
]);
// 6. 결재라인 초기화
// ... (기존 패턴 재사용)
}
```
### 5.3 개소 진행현황 조회
```
GET /api/v1/orders/{orderId}/fqc-status
Response: {
total: 50,
inspected: 30,
passed: 28,
failed: 2,
pending: 20,
items: [
{ order_item_id: 1, floor_code: "1F", symbol_code: "A", document_id: 101, status: "APPROVED", result: "합격" },
{ order_item_id: 2, floor_code: "1F", symbol_code: "B", document_id: 102, status: "DRAFT", result: null },
...
]
}
```
---
## 6. 핵심 파일 경로
### mng
| 파일 | 용도 | 상태 |
|------|------|:----:|
| `mng/database/seeders/ProductInspectionTemplateSeeder.php` | 제품검사 양식 시더 | 🔄 작성 중 |
| `mng/database/seeders/MidInspectionTemplateSeeder.php` | 참조 패턴 (중간검사) | ✅ |
### api
| 파일 | 용도 | 상태 |
|------|------|:----:|
| `api/app/Models/Order.php` | 수주 모델 | ✅ |
| `api/app/Models/OrderItem.php` | 수주 상세(개소) 모델 | ✅ |
| `api/app/Models/Documents/Document.php` | 문서 모델 | ✅ |
| `api/app/Models/Qualitys/Inspection.php` | 기존 검사 모델 (IQC/PQC/FQC) | ✅ |
| `api/app/Http/Controllers/Api/V1/OrderController.php` | 수주 컨트롤러 (createFqc 추가 필요) | ⏳ |
| `api/app/Services/DocumentService.php` | 문서 생성 서비스 | ✅ |
### react
| 파일 | 용도 | 상태 |
|------|------|:----:|
| `react/src/components/quality/InspectionManagement/` | 품질검사 요청관리 (15+ 파일) | ✅ 유지 |
| `react/src/components/quality/InspectionManagement/InspectionList.tsx` | 검사 목록 | ✅ 유지 |
| `react/src/components/quality/InspectionManagement/InspectionDetail.tsx` | 검사 상세 | 🔄 수정 필요 |
| `react/src/components/quality/InspectionManagement/modals/InspectionReportModal.tsx` | 성적서 모달 | 🔄 전환 필요 |
| `react/src/components/quality/InspectionManagement/modals/ProductInspectionInputModal.tsx` | 입력 모달 | 🔄 전환 필요 |
| `react/src/components/document-system/viewer/DocumentViewer.tsx` | 문서 뷰어 | ✅ |
| `react/src/components/document-system/content/TemplateInspectionContent.tsx` | 양식 기반 렌더링 | ✅ |
| `react/src/app/[locale]/(protected)/quality/qms/components/documents/ProductInspectionDocument.tsx` | 하드코딩 문서 | ❌ 대체 예정 |
---
## 7. 기존 Inspection 모델과의 관계 (통합 전략)
### 7.1 현재 구조
```
inspections 테이블 (JSON 기반)
├─ inspection_type: IQC/PQC/FQC
├─ status: waiting → in_progress → completed
├─ meta: { ... } (JSON)
├─ items: { ... } (JSON - 검사 결과)
└─ extra: { ... } (JSON)
documents 테이블 (EAV 정규화)
├─ template_id → document_templates
├─ status: DRAFT → PENDING → APPROVED/REJECTED
├─ linkable_type + linkable_id (다형성)
├─ document_data (EAV - 섹션/컬럼/행 기반)
└─ document_approvals (결재 이력)
```
### 7.2 통합 후 구조
```
InspectionManagement (요청관리 레이어) - 유지
├─ 검사 목록/등록/상세/캘린더
├─ inspections 테이블 (요청/일정/상태)
└─ API: /api/v1/inspections (CRUD)
Document System (성적서 레이어) - 신규 연동
├─ 양식 기반 검사 데이터 입력
├─ documents 테이블 (EAV + 결재)
├─ linkable → OrderItem (개소별)
└─ document_links → Order, Inspection
연결 포인트:
├─ InspectionDetail에서 "성적서 작성/조회" 시 → Document System 호출
├─ InspectionReportModal → TemplateInspectionContent 기반 전환
└─ ProductInspectionInputModal → 양식 기반 입력으로 전환
```
---
## 8. 변경 이력
| 날짜 | 내용 |
|------|------|
| 2026-02-10 | Phase 5.2 계획 문서 신규 생성 |
| 2026-02-10 | 방안1 반영: 시더에 section_fields 필수, columns 자동 파생. 선행조건 Phase 5.0 추가 |
| 2026-02-12 | 코드베이스 분석 반영: InspectionManagement 발견, 3개 검사항목 세트 정리, 통합 전략 확정 |
| 2026-02-12 | 설치 후 최종검사 11항목 확정, documents 기반 통합 방향 확정 |
| 2026-02-12 | 5.2.1 ProductInspectionTemplateSeeder 작성 완료 (template_id: 65) |
| 2026-02-12 | 5.2.2 mng 양식 편집/미리보기 검증 완료 |
| 2026-02-12 | 5.2.3 API bulk-create-fqc + fqc-status 엔드포인트 구현 완료 |
| 2026-02-12 | 5.2.4 React fqcActions.ts + FqcDocumentContent + 모달 듀얼모드 전환 완료 |
| 2026-02-12 | 5.2.5 InspectionDetail FQC 진행현황 통계 바 + 개소별 상태/조회 UI 완료 |
| 2026-02-12 | **Phase 5.2 전체 완료 (5/5)** |
---
*이 문서는 /plan 스킬로 생성되었습니다.*

View File

@@ -0,0 +1,559 @@
# SAM ERP API 개발 작업 계획 - D1.0 변경사항
> **작성일**: 2025-12-19
> **기준 문서**: SAM_ERP_Storyboard_D1.0_251218 (38페이지)
> **이전 버전**: SAM_ERP_Storyboard_D0.8_251216 (85페이지)
> **상태**: ✅ Phase 5 완료 | ✅ Phase 6 완료 | ✅ Phase 7 완료 | ✅ Phase 8 완료
---
## 📚 참고 문서
### 핵심 참고 문서
| 문서 | 경로 | 용도 |
|------|------|------|
| **기존 개발 계획** | [`erp-api-development-plan.md`](./erp-api-development-plan.md) | D0.8 기준 Phase 1-4 |
| **개발 공통 정책** | [`../guides/PROJECT_DEVELOPMENT_POLICY.md`](../guides/PROJECT_DEVELOPMENT_POLICY.md) | 개발 표준 및 정책 |
| **D0.8 스토리보드** | [`SAM_ERP_Storyboard_D0.8_251216/`](./SAM_ERP_Storyboard_D0.8_251216/) | 이전 버전 UI 참조 |
| **D1.0 스토리보드** | [`SAM_ERP_Storyboard_D1.0_251218/`](./SAM_ERP_Storyboard_D1.0_251218/) | 최신 UI/UX 참조 |
### 기존 코드 참조
| 항목 | 경로 | 상태 |
|------|------|------|
| `Board` 모델 | `api/app/Models/Boards/Board.php` | ✅ 존재 |
| `BoardSetting` 모델 | `api/app/Models/Boards/BoardSetting.php` | ✅ 존재 |
| `BoardComment` 모델 | `api/app/Models/Boards/BoardComment.php` | ✅ 존재 |
| `Plan` 모델 | `api/app/Models/Tenants/Plan.php` | ✅ 존재 |
| `Subscription` 모델 | `api/app/Models/Tenants/Subscription.php` | ✅ 존재 |
| `PushNotificationSetting` | `api/app/Models/PushNotificationSetting.php` | ✅ 존재 |
---
## 📊 D1.0 개발 범위 요약
| Phase | 구분 | 항목수 | 신규 테이블 | API 수 | 상태 |
|-------|------|--------|------------|--------|------|
| Phase 5 | 기본 확장 | 4개 | 1개 | ~14개 | ✅ 완료 |
| Phase 6 | 핵심 신규 | 2개 | 4개 | ~17개 | ✅ 완료 |
| Phase 7 | 게시판 연동 | 2개 | 0개 | ~15개 | ✅ 완료 |
| Phase 8 | SaaS 확장 | 3개 | 1개 | ~10개 | ✅ 완료 |
| **합계** | | **12개** | **~5개** | **~71개** | |
---
## 🚀 Phase 5: D1.0 기본 확장 ✅ 완료
> 기존 테이블/모델 활용, API 추가 중심
> **완료일: 2025-12-22** (기존 구현 확인)
### 5.1 사용자 초대 기능 ✅
> 슬라이드: 2 | 경로: 인사관리 > 사원관리 > 사용자 초대
> **완료일: 2025-12-19**
- [x] **테이블 생성**
- [x] `user_invitations` 마이그레이션 (2025_12_19_100001)
- [x] 마이그레이션 실행 및 검증
- [x] **모델 생성**
- [x] `UserInvitation` 모델 (BelongsToTenant)
- [x] 관계 정의 (inviter, role, tenant)
- [x] 토큰 생성 헬퍼 (`generateToken()`)
- [x] 상태 상수 (pending, accepted, expired, cancelled)
- [x] **서비스 구현**
- [x] `UserInvitationService` 생성
- [x] 이메일 초대 발송 로직 (`invite()`)
- [x] 초대 수락 로직 (`accept()`)
- [x] 토큰 만료 처리 (`expirePendingInvitations()`)
- [x] 초대 재발송 로직 (`resend()`)
- [x] **API 엔드포인트** (5개)
- [x] `POST /v1/users/invite` - 사용자 초대 (이메일 발송)
- [x] `GET /v1/users/invitations` - 초대 목록
- [x] `POST /v1/users/invitations/{token}/accept` - 초대 수락
- [x] `DELETE /v1/users/invitations/{id}` - 초대 취소
- [x] `POST /v1/users/invitations/{id}/resend` - 초대 재발송
- [x] **Swagger 문서**
- [x] `UserInvitationApi.php` 작성
- [x] 스키마 정의 (UserInvitation, InviteRequest, AcceptRequest)
- [ ] **테스트**
- [ ] Feature 테스트 작성
- [ ] 수동 API 테스트
---
### 5.2 알림설정 확장 ✅
> 슬라이드: 19-22 | 경로: 기준정보 > 알림설정
> **완료일: 2025-12-19**
- [x] **테이블 확장**
- [x] `notification_settings` 테이블 확인/생성
- [x] **모델 생성/수정**
- [x] `NotificationSetting` 모델 (BelongsToTenant)
- [x] 카테고리별 그룹화 메서드
- [x] **서비스 구현**
- [x] `NotificationSettingService` 생성
- [x] 카테고리별 조회/수정 로직
- [x] 사용자별 기본값 생성 로직
- [x] **API 엔드포인트** (3개)
- [x] `GET /v1/users/me/notification-settings` - 알림 설정 조회
- [x] `PUT /v1/users/me/notification-settings` - 알림 설정 수정
- [x] `PUT /v1/users/me/notification-settings/bulk` - 알림 일괄 설정
- [x] **Swagger 문서**
- [x] `NotificationSettingApi.php` 작성
- [ ] **테스트**
- [ ] Feature 테스트 작성
- [ ] 수동 API 테스트
---
### 5.3 계정정보 수정 (탈퇴/사용중지) ✅
> 슬라이드: 24 | 경로: 계정정보
> **완료일: 2025-12-19**
- [x] **서비스 구현**
- [x] `AccountService` 생성/확장
- [x] 회원 탈퇴 로직 (`withdraw()`)
- [x] 사용 중지 로직 (`suspend()`)
- [x] 약관 동의 정보 관리 (`getAgreements()`, `updateAgreements()`)
- [x] **API 엔드포인트** (4개)
- [x] `POST /v1/account/withdraw` - 회원 탈퇴
- [x] `POST /v1/account/suspend` - 사용 중지 (특정 테넌트)
- [x] `GET /v1/account/agreements` - 약관 동의 정보 조회
- [x] `PUT /v1/account/agreements` - 약관 동의 정보 수정
- [x] **Swagger 문서**
- [x] `AccountApi.php` 확장
- [ ] **테스트**
- [ ] Feature 테스트 작성
- [ ] 수동 API 테스트
---
### 5.4 매출 상세 확장 (거래명세서) ✅
> 슬라이드: 9 | 경로: 회계관리 > 매출관리 > 매출 상세
> **완료일: 2025-12-19**
**기존 구성요소:**
- `Sale` 모델, `SaleService` 존재
- `TaxInvoice` 모델 존재 (세금계산서)
- [x] **서비스 확장**
- [x] `SaleService` 확장
- [x] 거래명세서 조회 로직 (`getStatement()`)
- [x] 거래명세서 발행 로직 (`issueStatement()`)
- [x] 거래명세서 이메일 발송 로직 (`sendStatement()`)
- [x] **API 엔드포인트** (3개)
- [x] `GET /v1/sales/{id}/statement` - 거래명세서 조회
- [x] `POST /v1/sales/{id}/statement/issue` - 거래명세서 발행
- [x] `POST /v1/sales/{id}/statement/send` - 거래명세서 이메일 발송
- [x] **Swagger 문서**
- [x] `SaleApi.php` 확장 (거래명세서 관련 추가)
- [ ] **테스트**
- [ ] Feature 테스트 작성
- [ ] 수동 API 테스트
---
## 🔨 Phase 6: D1.0 핵심 신규 개발 (예상 2-3주)
> 신규 테이블 + API 전체 신규 구현
### 6.1 악성채권 추심관리 ✅
> 슬라이드: 10-13 | 경로: 회계관리 > 악성채권 추심관리
> **완료일: 2025-12-19** (commit: c0af888)
- [x] **테이블 생성** (3개)
- [x] `bad_debts` 마이그레이션 (2025_12_19_160001)
- [x] `bad_debt_documents` 마이그레이션 (2025_12_19_160002)
- [x] `bad_debt_memos` 마이그레이션 (2025_12_19_160003)
- [x] 마이그레이션 실행 및 검증
- [x] **모델 생성** (3개)
- [x] `BadDebt` 모델 (BelongsToTenant, SoftDeletes)
- 상태 상수: collecting, legal_action, recovered, bad_debt
- 관계: client, assignedUser, creator, documents, memos
- [x] `BadDebtDocument` 모델
- 문서 유형: business_license, tax_invoice, additional
- [x] `BadDebtMemo` 모델
- [x] **서비스 구현**
- [x] `BadDebtService` 생성 (307줄)
- [x] 악성채권 등록/수정/삭제 로직
- [x] 상태 전이 로직 (추심중→법적조치→회수완료/대손처리)
- [x] 요약 통계 (총 채권, 상태별 금액)
- [x] 서류 첨부/삭제 로직
- [x] 메모 추가/삭제 로직
- [x] **API 엔드포인트** (11개)
- [x] `GET /v1/bad-debts` - 악성채권 목록
- [x] `POST /v1/bad-debts` - 악성채권 등록
- [x] `GET /v1/bad-debts/summary` - 상단 요약 (총 채권, 상태별 금액)
- [x] `GET /v1/bad-debts/{id}` - 악성채권 상세
- [x] `PUT /v1/bad-debts/{id}` - 악성채권 수정
- [x] `DELETE /v1/bad-debts/{id}` - 악성채권 삭제
- [x] `PATCH /v1/bad-debts/{id}/toggle` - 설정 ON/OFF
- [x] `POST /v1/bad-debts/{id}/documents` - 서류 첨부
- [x] `DELETE /v1/bad-debts/{id}/documents/{docId}` - 서류 삭제
- [x] `POST /v1/bad-debts/{id}/memos` - 메모 추가
- [x] `DELETE /v1/bad-debts/{id}/memos/{memoId}` - 메모 삭제
- [x] **Swagger 문서**
- [x] `BadDebtApi.php` 작성 (433줄)
- [x] 스키마 정의 (BadDebt, BadDebtDocument, BadDebtMemo, Summary)
- [ ] **테스트**
- [ ] Feature 테스트 작성
- [ ] 수동 API 테스트
---
### 6.2 팝업관리 ✅
> 슬라이드: 15-16 | 경로: 기준정보 > 팝업관리
> **완료일: 2025-12-19**
- [x] **테이블 생성** (1개)
- [x] `popups` 마이그레이션
```sql
-- popups (팝업)
id, tenant_id, target_type, target_id,
title, content, status,
started_at, ended_at, options,
created_by, updated_by, deleted_by,
created_at, updated_at, deleted_at
```
- [x] 마이그레이션 실행 및 검증
- [x] **모델 생성**
- [x] `Popup` 모델 (BelongsToTenant, SoftDeletes)
- target_type: all, department
- status: active, inactive
- 활성 팝업 스코프 (기간 + 상태 체크)
- [x] **서비스 구현**
- [x] `PopupService` 생성
- [x] 팝업 CRUD 로직
- [x] 활성 팝업 조회 로직 (로그인 후 노출용)
- [x] 기간 유효성 검사 로직
- [x] **API 엔드포인트** (6개)
- [x] `GET /v1/popups` - 팝업 목록 (관리자용)
- [x] `POST /v1/popups` - 팝업 등록
- [x] `GET /v1/popups/active` - 활성 팝업 목록 (사용자용)
- [x] `GET /v1/popups/{id}` - 팝업 상세
- [x] `PUT /v1/popups/{id}` - 팝업 수정
- [x] `DELETE /v1/popups/{id}` - 팝업 삭제
- [x] **Swagger 문서**
- [x] `PopupApi.php` 작성
- [ ] **테스트**
- [ ] Feature 테스트 작성
- [ ] 수동 API 테스트
---
## 📋 Phase 7: D1.0 게시판 연동 ✅ 완료
> **완료일: 2025-12-19**
> 기존 Board 모델 활용, API 엔드포인트 추가
**기존 구성요소 (api 프로젝트):**
- `Board` 모델: is_system, board_type, board_code, name, extra_settings
- `BoardSetting` 모델: 커스텀 필드 정의
- `BoardComment` 모델: 댓글
- `Post` 모델: 게시글
### 7.1 게시판관리 ✅
> 슬라이드: 17-18 | 경로: 기준정보 > 게시판관리
> **완료일: 2025-12-19** (기존 구현 활용)
- [x] **기존 모델 확인/확장**
- [x] `Board` 모델 확인
- [x] `BoardSetting` 모델 확인
- [x] 필요 필드 이미 존재
- [x] **서비스 구현**
- [x] `BoardService` 존재 (테넌트별 게시판 CRUD 로직)
- [x] **API 엔드포인트** (5개)
- [x] `GET /v1/boards` - 게시판 목록
- [x] `POST /v1/boards` - 게시판 생성
- [x] `GET /v1/boards/{id}` - 게시판 상세
- [x] `PUT /v1/boards/{id}` - 게시판 수정
- [x] `DELETE /v1/boards/{id}` - 게시판 삭제
- [x] **Swagger 문서**
- [x] `BoardApi.php` 작성 완료
---
### 7.2 게시판 (사용자용) ✅
> 슬라이드: 3-7 | 경로: 게시판
> **완료일: 2025-12-19**
- [x] **기존 모델 확인/확장**
- [x] `Post` 모델 확인
- [x] 상단 노출 필드 (is_notice)
- [x] 조회수 필드 (views)
- [x] **서비스 구현**
- [x] `PostService` 존재
- [x] 게시글 CRUD 로직
- [x] 상단 노출 로직
- [x] 조회수 증가 로직
- [x] 나의 게시글 조회 로직 ✅ 추가됨
- [x] **API 엔드포인트** (10개)
- [x] `GET /v1/boards` - 게시판 목록 (탭용)
- [x] `GET /v1/boards/{code}/posts` - 게시글 목록
- [x] `POST /v1/boards/{code}/posts` - 게시글 등록
- [x] `GET /v1/boards/{code}/posts/{id}` - 게시글 상세
- [x] `PUT /v1/boards/{code}/posts/{id}` - 게시글 수정
- [x] `DELETE /v1/boards/{code}/posts/{id}` - 게시글 삭제
- [x] `GET /v1/posts/my` - 나의 게시글 ✅ 신규 추가
- [x] `GET /v1/boards/{code}/posts/{id}/comments` - 댓글 목록
- [x] `POST /v1/boards/{code}/posts/{id}/comments` - 댓글 등록
- [x] `PUT /v1/boards/{code}/posts/{id}/comments/{commentId}` - 댓글 수정
- [x] `DELETE /v1/boards/{code}/posts/{id}/comments/{commentId}` - 댓글 삭제
- [x] **Swagger 문서**
- [x] `BoardApi.php` 작성 완료
- [x] `PostApi.php` 작성 완료
---
### 7.3 고객센터 → 게시판관리로 대체 ⏭️
> 슬라이드: 30-38 | 경로: 고객센터
**결정사항:** 고객센터 기능은 기존 게시판관리 시스템으로 구현
- 공지사항, 이벤트, FAQ, 1:1 문의 → 게시판 유형(board_code)으로 관리
- 별도 SupportAPI 불필요, 기존 Board/Post API 활용
---
## 💼 Phase 8: D1.0 SaaS 확장 (예상 1-2주)
> 기존 Plan/Subscription/Payment 모델 활용
### 8.1 구독관리 ✅
> 슬라이드: 28 | 경로: 구독관리
> **완료일: 2025-12-22** (기존 구현 확인)
**기존 구성요소:**
- `Plan` 모델: name, code, price, features(json)
- `Subscription` 모델: tenant_id, plan_id, started_at, ended_at, status
- `DataExport` 모델: 데이터 내보내기
- [x] **서비스 확장**
- [x] `SubscriptionService` 확장 (432줄)
- [x] 현재 구독 정보 조회 로직 (`current()`)
- [x] 사용량 조회 로직 (`usage()`)
- [x] 자료 내보내기 로직 (`createExport()`, `getExport()`)
- [x] 서비스 해지 로직 (`cancel()`)
- [x] **API 엔드포인트** (5개 + 추가 6개)
- [x] `GET /v1/subscriptions/current` - 현재 구독 정보
- [x] `GET /v1/subscriptions/usage` - 사용량 조회
- [x] `POST /v1/subscriptions/export` - 자료 내보내기 요청
- [x] `GET /v1/subscriptions/export/{id}` - 내보내기 상태 조회
- [x] `POST /v1/subscriptions/{id}/cancel` - 서비스 해지
- [x] `GET /v1/subscriptions` - 구독 목록 (추가)
- [x] `POST /v1/subscriptions` - 구독 등록 (추가)
- [x] `GET /v1/subscriptions/{id}` - 구독 상세 (추가)
- [x] `POST /v1/subscriptions/{id}/renew` - 구독 갱신 (추가)
- [x] `POST /v1/subscriptions/{id}/suspend` - 일시정지 (추가)
- [x] `POST /v1/subscriptions/{id}/resume` - 재개 (추가)
- [x] **Swagger 문서**
- [x] `SubscriptionApi.php` 작성 (526줄)
- [ ] **테스트**
- [ ] Feature 테스트 작성
- [ ] 수동 API 테스트
---
### 8.2 결제내역 ✅
> 슬라이드: 29 | 경로: 결제내역
> **완료일: 2025-12-22** (기존 구현 확인)
**기존 구성요소:**
- `Payment` 모델: subscription_id, amount, payment_method, paid_at, status
- [x] **서비스 확장**
- [x] `PaymentService` 확장 (357줄)
- [x] 결제 내역 목록 조회 로직 (`index()`)
- [x] 거래명세서 생성 로직 (`statement()`)
- [x] 결제 요약 통계 (`summary()`)
- [x] **API 엔드포인트** (2개 + 추가 6개)
- [x] `GET /v1/payments` - 결제 내역 목록
- [x] `GET /v1/payments/{id}/statement` - 거래명세서 조회
- [x] `GET /v1/payments/summary` - 결제 요약 통계 (추가)
- [x] `GET /v1/payments/{id}` - 결제 상세 (추가)
- [x] `POST /v1/payments` - 결제 등록 (추가)
- [x] `POST /v1/payments/{id}/complete` - 완료 처리 (추가)
- [x] `POST /v1/payments/{id}/cancel` - 취소 (추가)
- [x] `POST /v1/payments/{id}/refund` - 환불 (추가)
- [x] **Swagger 문서**
- [x] `PaymentApi.php` 작성 (455줄)
- [ ] **테스트**
- [ ] Feature 테스트 작성
- [ ] 수동 API 테스트
---
### 8.3 회사 추가 ✅
> 슬라이드: 25-27 | 경로: 회사정보
> **완료일: 2025-12-22**
- [x] **테이블 생성** (1개)
- [x] `company_requests` 마이그레이션
```sql
-- company_requests (회사 추가 신청)
id, user_id, business_number, company_name, ceo_name,
address, phone, email, status, message, reject_reason,
barobill_response(json), approved_by, created_tenant_id,
processed_at, created_at, updated_at
```
- [x] 마이그레이션 실행 및 검증
- [x] **모델 생성**
- [x] `CompanyRequest` 모델
- 상태 상수: pending, approved, rejected
- 관계: user, approver, createdTenant
- 스코프: pending(), approved(), rejected()
- [x] **서비스 구현**
- [x] `CompanyService` 생성
- [x] 사업자등록번호 유효성 검사 (바로빌 연동 + 체크섬 검증)
- [x] 회사 추가 신청 로직
- [x] 신청 승인 로직 (테넌트 자동 생성 + 사용자 연결)
- [x] 신청 반려 로직
- [x] 신청 목록 조회 (관리자용/사용자용)
- [x] **API 엔드포인트** (7개)
- [x] `POST /v1/companies/check` - 사업자등록번호 유효성 검사
- [x] `POST /v1/companies/request` - 회사 추가 신청
- [x] `GET /v1/companies/requests` - 신청 목록 (관리자용)
- [x] `GET /v1/companies/requests/{id}` - 신청 상세
- [x] `POST /v1/companies/requests/{id}/approve` - 승인
- [x] `POST /v1/companies/requests/{id}/reject` - 반려
- [x] `GET /v1/companies/my-requests` - 내 신청 목록
- [x] **Swagger 문서**
- [x] `CompanyApi.php` 작성
- [ ] **테스트**
- [ ] Feature 테스트 작성
- [ ] 수동 API 테스트
---
## 📋 기획 확인 필요 항목
> ⚠️ API 구현 전 비즈니스 로직 확정 필요
### D1.0 신규 확인 필요
- [ ] 사용자 초대 시 권한 범위 (테넌트 단위 vs 전사)
- [ ] 악성채권 자동 판정 조건 (연체일수 기준, 기본 90일?)
- [ ] 팝업 노출 우선순위 (복수 팝업 시)
- [ ] 서비스 해지 시 데이터 보관 기간
- [ ] 자료 내보내기 포맷 (Excel, CSV, JSON)
- [ ] 상단 노출 게시글 최대 개수 (기본 5개)
- [ ] 1:1 문의 상담분류 목록 (문의하기, 신고하기, 건의사항, 서비스 오류)
### 기존 확인 사항 (D0.8)
- [ ] 테넌트: 신청→승인→만료→해지 전이 조건
- [ ] 전자결재→회계: 지출결의서 승인 시 출금 자동 생성?
- [ ] 바로빌 API 비용 확인
---
## 📝 작업 일지
### 2025-12-19
- [x] D1.0 스토리보드 분석 완료 (38페이지)
- [x] D0.8 대비 변경사항 식별 (신규 8개, 수정 4개)
- [x] D1.0 개발 계획 문서 작성 (Phase 5-8)
- [x] 기존 코드베이스 분석 (Board, Plan, Subscription 모델 확인)
- [x] Phase 6.1 악성채권 추심관리 API 개발 완료 (commit: c0af888)
- [x] Phase 6.2 팝업관리 API 개발 완료
- [x] Phase 7.1 게시판관리 - 기존 구현 확인 완료
- [x] Phase 7.2 게시판(사용자용) - 기존 구현 확인 + `/posts/my` API 추가 (commit: c15a245)
- [x] Phase 7.3 고객센터 → 게시판관리로 대체 결정
- [x] Phase 8 SaaS 확장 분석 시작
### 2025-12-22
- [x] Phase 8.1 구독관리 - 기존 구현 확인 완료
- [x] Phase 8.2 결제내역 - 기존 구현 확인 완료
- [x] Phase 8.3 회사 추가 API 개발 완료 (commit: 7781253)
- company_requests 테이블 생성
- CompanyRequest 모델 생성
- CompanyService 생성 (바로빌 연동 + 테넌트 생성)
- 7개 API 엔드포인트 구현
- Swagger 문서 작성
- [x] Phase 5 전체 기존 구현 확인 완료
- 5.1 사용자 초대: 5개 API (invite, invitations, accept, cancel, resend)
- 5.2 알림설정: 3개 API (notification-settings, update, bulk)
- 5.3 계정정보: 4개 API (withdraw, suspend, agreements)
- 5.4 매출 거래명세서: 3개 API (statement, issue, send)
- [x] D1.0 Phase 5-8 전체 API 개발 완료!
---
## ✅ 완료 기준
### Phase 5 완료 조건 (기본 확장) ✅
- [x] 사용자 초대 API 구현 완료 ✅ 2025-12-19
- [x] 알림설정 API 확장 완료 ✅ 2025-12-19
- [x] 계정정보 API 확장 완료 ✅ 2025-12-19
- [x] 매출 거래명세서 API 구현 완료 ✅ 2025-12-19
- [x] Swagger 문서 완성 ✅ 2025-12-19
- [x] Pint 코드 포맷팅 완료 ✅
### Phase 6 완료 조건 (핵심 신규)
- [x] 악성채권 추심관리 전체 구현 ✅ 2025-12-18
- [x] 팝업관리 전체 구현 ✅ 2025-12-19
- [x] 마이그레이션 검증 완료
- [x] Swagger 문서 완성
### Phase 7 완료 조건 (게시판 연동) ✅
- [x] 게시판관리 API 구현 완료 ✅ 2025-12-19
- [x] 게시판 (사용자용) API 구현 완료 ✅ 2025-12-19
- [x] 고객센터 → 게시판관리로 대체 결정 ✅ 2025-12-19
### Phase 8 완료 조건 (SaaS 확장) ✅
- [x] 구독관리 API 구현 완료 ✅ 2025-12-22
- [x] 결제내역 API 구현 완료 ✅ 2025-12-22
- [x] 회사 추가 API 구현 완료 ✅ 2025-12-22
- [x] 자료 내보내기 기능 구현 ✅ (SubscriptionService에 포함)
### 전체 완료 조건
- [ ] 모든 D1.0 API 구현 완료 (~71개)
- [ ] Swagger 문서 100%
- [ ] 통합 테스트 통과
- [ ] 프론트엔드 연동 준비 완료
---
## 🔗 관련 링크
- **기존 개발 계획**: [`erp-api-development-plan.md`](./erp-api-development-plan.md)
- **API Swagger UI**: http://sam.kr/api-docs/index.html
- **개발 공통 정책**: [`../guides/PROJECT_DEVELOPMENT_POLICY.md`](../guides/PROJECT_DEVELOPMENT_POLICY.md)
- **D1.0 스토리보드**: [`SAM_ERP_Storyboard_D1.0_251218/`](./SAM_ERP_Storyboard_D1.0_251218/)

View File

@@ -0,0 +1,369 @@
# FCM 사용자별 알림 발송 계획
> **작성일**: 2026-01-28
> **목적**: FCM 푸시 알림을 테넌트 전체 브로드캐스트에서 사용자별 타겟 발송으로 변경
> **상태**: ✅ 구현 완료
---
## 📍 현재 진행 상태
| 항목 | 내용 |
|------|------|
| **마지막 완료 작업** | Phase 4 - FCM 발송 로직 수정 완료 |
| **다음 작업** | 테스트 검증 |
| **진행률** | 8/8 (100%) |
| **마지막 업데이트** | 2026-01-28 |
---
## 1. 개요
### 1.1 배경
현재 TodayIssue 생성 시 FCM 푸시 알림이 **테넌트 전체 사용자** 중 알림 설정이 켜진 모든 사용자에게 발송됨.
**문제점**:
- 결재요청 알림이 결재자가 아닌 사람에게도 발송됨
- 기안 승인/반려/완료 알림이 기안자가 아닌 사람에게도 발송됨
- 불필요한 알림으로 사용자 경험 저하
### 1.2 목표
```
┌─────────────────────────────────────────────────────────────────┐
│ 🎯 핵심 목표 │
├─────────────────────────────────────────────────────────────────┤
│ 1. 이슈 타입에 따라 특정 대상자에게만 FCM 발송 │
│ 2. 사용자별 알림 설정(ON/OFF)이 정상 동작하도록 보장 │
│ 3. 근태 알림은 제외 (정책 미확정) │
└─────────────────────────────────────────────────────────────────┘
```
### 1.3 발송 대상 정책
| 이슈 타입 | 현재 | 변경 후 대상 |
|-----------|------|-------------|
| **결재요청** | 테넌트 전체 | **결재자(나)** - ApprovalStep.user_id |
| **기안 승인** | 테넌트 전체 | **기안자** - Approval.drafter_id |
| **기안 반려** | 테넌트 전체 | **기안자** - Approval.drafter_id |
| **기안 완료** | 테넌트 전체 | **기안자** - Approval.drafter_id |
| 수주등록 | 테넌트 전체 | 테넌트 전체 (변경 없음) |
| 추심이슈 | 테넌트 전체 | 테넌트 전체 (변경 없음) |
| 안전재고 | 테넌트 전체 | 테넌트 전체 (변경 없음) |
| 지출승인 | 테넌트 전체 | 테넌트 전체 (변경 없음) |
| 세금신고 | 테넌트 전체 | 테넌트 전체 (변경 없음) |
| 신규업체 | 테넌트 전체 | 테넌트 전체 (변경 없음) |
| 입금 | 테넌트 전체 | 테넌트 전체 (변경 없음) |
| 출금 | 테넌트 전체 | 테넌트 전체 (변경 없음) |
| **근태 알림** | - | **제외** (정책 미확정) |
### 1.4 변경 승인 정책
| 분류 | 예시 | 승인 |
|------|------|------|
| ✅ 즉시 가능 | 필드 추가, 로직 수정, 문서 수정 | 불필요 |
| ⚠️ 컨펌 필요 | 마이그레이션, 새 테이블/컬럼 | **필수** |
| 🔴 금지 | 기존 테이블 구조 변경, 파괴적 변경 | 별도 협의 |
---
## 2. 대상 범위
### 2.1 Phase 1: 데이터베이스 변경
| # | 작업 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 1.1 | TodayIssue 테이블에 `target_user_id` 컬럼 추가 | ✅ | nullable, FK |
| 1.2 | 마이그레이션 파일 생성 | ✅ | 2026_01_28_132426 |
### 2.2 Phase 2: 모델 수정
| # | 작업 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 2.1 | TodayIssue 모델에 target_user_id 추가 | ✅ | fillable, relation, scopes |
| 2.2 | TodayIssue::createIssue() 메서드에 targetUserId 파라미터 추가 | ✅ | |
### 2.3 Phase 3: Observer 수정
| # | 작업 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 3.1 | handleApprovalStepChange() - 결재요청 시 결재자 지정 | ✅ | step->user_id 전달 |
| 3.2 | 기안 승인/반려/완료 알림 추가 (기안자 지정) | ✅ | ApprovalIssueObserver 신규 |
### 2.4 Phase 4: FCM 발송 로직 수정
| # | 작업 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 4.1 | sendFcmNotification() - target_user_id 있으면 해당 사용자만 | ✅ | |
| 4.2 | getEnabledUserTokens() - 특정 사용자 필터링 로직 추가 | ✅ | |
---
## 3. 작업 절차
### 3.1 단계별 절차
```
Step 1: 데이터베이스 변경
├── today_issues 테이블에 target_user_id 컬럼 추가
├── 마이그레이션 실행
└── 검증: 테이블 구조 확인
Step 2: TodayIssue 모델 수정
├── target_user_id fillable 추가
├── targetUser() relation 추가
└── createIssue() 파라미터 추가
Step 3: TodayIssueObserverService 수정
├── createIssueWithFcm() 파라미터 추가
├── handleApprovalStepChange() 수정 - 결재자 지정
├── 기안 상태 변경 알림 추가 (신규)
└── 근태 알림 비활성화
Step 4: FCM 발송 로직 수정
├── sendFcmNotification() 수정
├── getEnabledUserTokens() 수정 - targetUserId 파라미터 추가
└── 검증: 대상자만 수신 확인
```
---
## 4. 상세 작업 내용
### 4.1 Phase 1: 데이터베이스 변경
**마이그레이션 파일**:
```php
// database/migrations/xxxx_add_target_user_id_to_today_issues_table.php
Schema::table('today_issues', function (Blueprint $table) {
$table->unsignedBigInteger('target_user_id')
->nullable()
->after('source_id')
->comment('특정 대상 사용자 ID (null이면 테넌트 전체)');
$table->foreign('target_user_id')
->references('id')
->on('users')
->onDelete('cascade');
$table->index(['tenant_id', 'target_user_id']);
});
```
### 4.2 Phase 2: TodayIssue 모델 수정
```php
// app/Models/Tenants/TodayIssue.php
protected $fillable = [
// ... 기존 필드
'target_user_id', // 추가
];
public function targetUser(): BelongsTo
{
return $this->belongsTo(User::class, 'target_user_id');
}
public static function createIssue(
int $tenantId,
string $sourceType,
?int $sourceId,
string $badge,
string $content,
?string $path = null,
bool $needsApproval = false,
?\DateTime $expiresAt = null,
?int $targetUserId = null // 추가
): self {
// ... 기존 로직 + target_user_id 저장
}
```
### 4.3 Phase 3: Observer 수정
**결재요청 - 결재자에게만**:
```php
// handleApprovalStepChange() 수정
$this->createIssueWithFcm(
tenantId: $approval->tenant_id,
sourceType: TodayIssue::SOURCE_APPROVAL,
sourceId: $step->id,
badge: TodayIssue::BADGE_APPROVAL_REQUEST,
content: __('message.today_issue.approval_pending', [...]),
path: '/approval/inbox',
needsApproval: true,
expiresAt: null,
targetUserId: $step->user_id // 결재자
);
```
**기안 승인/반려/완료 - 기안자에게만** (신규):
```php
// handleApprovalStatusChange() 신규 메서드
public function handleApprovalStatusChange(Approval $approval): void
{
$badge = match($approval->status) {
'approved' => TodayIssue::BADGE_DRAFT_APPROVED,
'rejected' => TodayIssue::BADGE_DRAFT_REJECTED,
'completed' => TodayIssue::BADGE_DRAFT_COMPLETED,
default => null,
};
if (!$badge) return;
$this->createIssueWithFcm(
tenantId: $approval->tenant_id,
sourceType: TodayIssue::SOURCE_APPROVAL,
sourceId: $approval->id,
badge: $badge,
content: __('message.today_issue.'.$approval->status, [...]),
path: '/approval/draft',
needsApproval: false,
expiresAt: Carbon::now()->addDays(7),
targetUserId: $approval->drafter_id // 기안자
);
}
```
### 4.4 Phase 4: FCM 발송 로직 수정
```php
// sendFcmNotification() 수정
public function sendFcmNotification(TodayIssue $issue): void
{
// target_user_id가 있으면 해당 사용자만, 없으면 테넌트 전체
$tokens = $this->getEnabledUserTokens(
$issue->tenant_id,
$issue->notification_type,
$issue->target_user_id // 추가
);
// ... 기존 발송 로직
}
// getEnabledUserTokens() 수정
private function getEnabledUserTokens(
int $tenantId,
string $notificationType,
?int $targetUserId = null // 추가
): array {
$query = PushDeviceToken::withoutGlobalScopes()
->where('tenant_id', $tenantId)
->where('is_active', true)
->whereNull('deleted_at');
// 특정 대상자가 지정된 경우
if ($targetUserId !== null) {
$query->where('user_id', $targetUserId);
}
$tokens = $query->get();
// 알림 설정 확인 후 필터링
$enabledTokens = [];
foreach ($tokens as $token) {
if ($this->isNotificationEnabledForUser($tenantId, $token->user_id, $notificationType)) {
$enabledTokens[] = $token->token;
}
}
return $enabledTokens;
}
```
---
## 5. 제외 항목
### 5.1 근태 알림 (정책 미확정)
다음 알림 타입은 이번 작업에서 **제외**:
- 연차 알림
- 출근 알림
- 지각 알림
- 결근 알림
**사유**: 정책이 모호하여 추후 별도 작업
### 5.2 알림 소리 커스터마이징
현재는 **하드코딩된 채널별 알림음** 사용:
- `push_urgent`: 긴급 (신규업체)
- `push_payment`: 결재
- `push_sales_order`: 수주
- `push_default`: 기타
**추후 작업**: 사용자별 알림 설정의 `soundType` 값 기준으로 발송
---
## 6. 영향받는 파일
### API (api/)
| 파일 | 변경 내용 |
|------|----------|
| `database/migrations/2026_01_28_132426_add_target_user_id_to_today_issues_table.php` | 신규 - 마이그레이션 |
| `app/Models/Tenants/TodayIssue.php` | target_user_id 추가, 신규 뱃지 상수, targetUser 관계, forUser/targetedTo 스코프 |
| `app/Services/TodayIssueObserverService.php` | createIssueWithFcm, sendFcmNotification, getEnabledUserTokens 수정, handleApprovalStatusChange 추가 |
| `app/Observers/TodayIssue/ApprovalIssueObserver.php` | 신규 - 기안 상태 변경 Observer |
| `app/Providers/AppServiceProvider.php` | ApprovalIssueObserver 등록 |
| `lang/ko/message.php` | 신규 메시지 키 추가 (draft_approved/rejected/completed) |
### React (react/) - 변경 없음
프론트엔드 알림 설정 UI는 이미 사용자별로 구현되어 있음.
---
## 7. 검증 방법
### 7.1 테스트 시나리오
| # | 시나리오 | 예상 결과 |
|---|----------|----------|
| 1 | A가 B에게 결재 요청 | B에게만 FCM 발송 |
| 2 | B가 A의 기안 승인 | A에게만 FCM 발송 |
| 3 | B가 A의 기안 반려 | A에게만 FCM 발송 |
| 4 | 수주 등록 | 테넌트 전체 (알림 ON인 사용자만) |
| 5 | A가 알림 OFF → 수주 등록 | A에게는 발송 안됨 |
### 7.2 성공 기준
- [ ] 결재요청 알림이 결재자에게만 발송됨
- [ ] 기안 상태 변경 알림이 기안자에게만 발송됨
- [ ] 사용자별 알림 설정(ON/OFF)이 정상 동작함
- [ ] 기존 브로드캐스트 이슈(수주, 입금 등)는 정상 동작함
---
## 8. 참고 문서
- `api/app/Services/TodayIssueObserverService.php` - 현재 발송 로직
- `api/app/Models/NotificationSetting.php` - 알림 설정 모델
- `react/src/components/settings/NotificationSettings/types.ts` - 프론트엔드 알림 설정 타입
---
## 9. 변경 이력
| 날짜 | 항목 | 변경 내용 | 파일 | 승인 |
|------|------|----------|------|------|
| 2026-01-28 | - | 계획 문서 초안 작성 | - | - |
| 2026-01-28 | Phase 1 | target_user_id 컬럼 추가 마이그레이션 | migrations/2026_01_28_132426_* | ✅ |
| 2026-01-28 | Phase 2 | TodayIssue 모델 수정 (fillable, relation, scopes) | TodayIssue.php | ✅ |
| 2026-01-28 | Phase 3 | Observer 수정 (결재자/기안자 타겟팅) | TodayIssueObserverService.php, ApprovalIssueObserver.php | ✅ |
| 2026-01-28 | Phase 4 | FCM 발송 로직 수정 | TodayIssueObserverService.php | ✅ |
| 2026-01-28 | 신규 | ApprovalIssueObserver 생성 | ApprovalIssueObserver.php | ✅ |
| 2026-01-28 | i18n | 기안 상태 알림 메시지 추가 | lang/ko/message.php | ✅ |
---
*이 문서는 /sc:plan 스킬로 생성되었습니다.*

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,589 @@
# Items 테이블 통합 마이그레이션 계획
## 참조 문서
### 필수 확인
| 문서 | 경로 | 내용 |
|------|------|------|
| **ItemMaster 연동 설계서** | [specs/item-master-integration.md](../specs/item-master-integration.md) | source_table, EntityRelationship 구조 |
| **DB 스키마** | [specs/database-schema.md](../specs/database-schema.md) | 테이블 구조, Multi-tenant 아키텍처 |
### 참고 문서
| 문서 | 경로 | 내용 |
|------|------|------|
| **품목관리 마이그레이션 가이드** | [projects/mes/ITEM_MANAGEMENT_MIGRATION_GUIDE.md](../projects/mes/ITEM_MANAGEMENT_MIGRATION_GUIDE.md) | 프론트엔드 마이그레이션 |
| **API 품목 분석 요약** | [projects/mes/00_baseline/docs_breakdown/api_item_analysis_summary.md](../projects/mes/00_baseline/docs_breakdown/api_item_analysis_summary.md) | 기존 API 분석, price_histories |
| **Swagger 가이드** | [guides/swagger-guide.md](../guides/swagger-guide.md) | API 문서화 규칙 |
### 관련 코드
| 파일 | 경로 | 역할 |
|------|------|------|
| ItemPage 모델 | `api/app/Models/ItemMaster/ItemPage.php` | source_table 매핑 |
| EntityRelationship 모델 | `api/app/Models/ItemMaster/EntityRelationship.php` | 엔티티 관계 관리 |
| ItemMasterService | `api/app/Services/ItemMaster/ItemMasterService.php` | init API, 메타데이터 조회 |
| ProductService | `api/app/Services/ProductService.php` | 기존 Products API (제거 예정) |
| MaterialService | `api/app/Services/MaterialService.php` | 기존 Materials API (제거 예정) |
---
## 개요
### 목적
`products`/`materials` 테이블을 `items` 테이블로 통합하여:
- BOM 관리 시 `child_item_type` 불필요 (ID만으로 유일 식별)
- 단일 쿼리로 모든 품목 조회 가능
- Item-Master 시스템과 일관된 구조
### 현재 상황
- **개발 단계**: 미오픈 (레거시 호환 불필요)
- **Item-Master**: 메타데이터 시스템 운영 중 (pages, sections, fields)
- **이전 시도**: 12/11 items 생성 → 12/12 롤백 (정책 정리 필요)
### 현재 시스템 구조
```
┌─────────────────────────────────────────────────────────────┐
│ Item-Master (메타데이터) │
├─────────────────────────────────────────────────────────────┤
│ item_pages (source_table: 'products'|'materials') │
│ ↓ EntityRelationship │
│ item_sections → item_fields, item_bom_items │
└─────────────────────────────────────────────────────────────┘
↓ 참조
┌─────────────────────────────────────────────────────────────┐
│ 실제 데이터 테이블 │
├─────────────────────────────────────────────────────────────┤
│ products (808건) ← ProductController, ProductService │
│ materials (417건) ← MaterialController, MaterialService │
└─────────────────────────────────────────────────────────────┘
```
### 목표 구조
```
┌─────────────────────────────────────────────────────────────┐
│ Item-Master (메타데이터) │
├─────────────────────────────────────────────────────────────┤
│ item_pages (source_table: 'items') │
│ ↓ EntityRelationship │
│ item_sections → item_fields, item_bom_items │
└─────────────────────────────────────────────────────────────┘
↓ 참조
┌─────────────────────────────────────────────────────────────┐
│ 통합 데이터 테이블 │
├─────────────────────────────────────────────────────────────┤
│ items ← ItemController, ItemService │
│ item_type: FG, PT, SM, RM, CS │
└─────────────────────────────────────────────────────────────┘
```
---
## Phase 0: 데이터 정규화
### 0.1 item_type 표준화
개발 중이므로 비표준 데이터는 삭제 처리. 품목관리 완료 후 경동기업 데이터 전체 재세팅 예정.
**표준 item_type 체계**:
| 코드 | 설명 | 출처 |
|------|------|------|
| FG | 완제품 (Finished Goods) | products |
| PT | 부품 (Parts) | products |
| SM | 부자재 (Sub-materials) | materials |
| RM | 원자재 (Raw Materials) | materials |
| CS | 소모품 (Consumables) | materials만 |
**비표준 데이터 삭제**:
```sql
-- products에서 비표준 타입 삭제 (PRODUCT, SUBASSEMBLY, PART, CS)
DELETE FROM products WHERE product_type NOT IN ('FG', 'PT');
-- materials는 이미 표준 타입만 사용 (SM, RM, CS)
```
### 0.2 BOM 데이터 정리
통합 시 문제되는 BOM 데이터 삭제:
```sql
-- 삭제될 products/materials를 참조하는 BOM 항목 제거
-- (Phase 1 이관 전에 실행)
```
### 0.3 체크리스트
- [x] products 비표준 타입 삭제
- [x] 관련 BOM 데이터 정리
- [x] 삭제 건수 확인
---
## Phase 1: items 테이블 생성 + 데이터 이관
### 1.1 items 테이블
```sql
CREATE TABLE items (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
-- 기본 정보
item_type VARCHAR(15) NOT NULL COMMENT 'FG, PT, SM, RM, CS',
code VARCHAR(100) NOT NULL,
name VARCHAR(255) NOT NULL,
unit VARCHAR(20) NULL,
category_id BIGINT UNSIGNED NULL,
-- BOM (JSON)
bom JSON NULL COMMENT '[{child_item_id, quantity}, ...]',
-- 상태
is_active TINYINT(1) DEFAULT 1,
-- 감사 필드
created_by BIGINT UNSIGNED NULL,
updated_by BIGINT UNSIGNED NULL,
deleted_by BIGINT UNSIGNED NULL,
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL,
deleted_at TIMESTAMP NULL,
-- 인덱스
INDEX idx_items_tenant_type (tenant_id, item_type),
INDEX idx_items_tenant_code (tenant_id, code),
INDEX idx_items_tenant_category (tenant_id, category_id),
UNIQUE KEY uq_items_tenant_code (tenant_id, code, deleted_at),
FOREIGN KEY (tenant_id) REFERENCES tenants(id),
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```
### 1.2 item_details 테이블 (확장 필드)
```sql
CREATE TABLE item_details (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
item_id BIGINT UNSIGNED NOT NULL,
-- Products 전용 필드
is_sellable TINYINT(1) DEFAULT 1,
is_purchasable TINYINT(1) DEFAULT 0,
is_producible TINYINT(1) DEFAULT 0,
safety_stock INT NULL,
lead_time INT NULL,
is_variable_size TINYINT(1) DEFAULT 0,
product_category VARCHAR(50) NULL,
part_type VARCHAR(50) NULL,
-- Materials 전용 필드
is_inspection VARCHAR(1) DEFAULT 'N',
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL,
UNIQUE KEY uq_item_details_item_id (item_id),
FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```
### 1.3 item_attributes 테이블 (동적 속성)
```sql
CREATE TABLE item_attributes (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
item_id BIGINT UNSIGNED NOT NULL,
attributes JSON NULL,
options JSON NULL,
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL,
UNIQUE KEY uq_item_attributes_item_id (item_id),
FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```
### 1.4 데이터 이관 스크립트
```php
// Products → Items
DB::statement("
INSERT INTO items (tenant_id, item_type, code, name, unit, category_id, bom,
is_active, created_by, updated_by, deleted_by, created_at, updated_at, deleted_at)
SELECT tenant_id, product_type, code, name, unit, category_id, bom,
is_active, created_by, updated_by, deleted_by, created_at, updated_at, deleted_at
FROM products
");
// Materials → Items
DB::statement("
INSERT INTO items (tenant_id, item_type, code, name, unit, category_id,
is_active, created_by, updated_by, deleted_by, created_at, updated_at, deleted_at)
SELECT tenant_id, material_type, material_code, name, unit, category_id,
is_active, created_by, updated_by, deleted_by, created_at, updated_at, deleted_at
FROM materials
");
```
### 1.5 체크리스트
- [x] items 마이그레이션 생성
- [x] item_details 마이그레이션 생성
- [x] item_attributes 마이그레이션 생성
- [x] 데이터 이관 스크립트 실행
- [x] 건수 검증 (1,225건)
---
## Phase 2: Item 모델 + Service 생성
### 2.1 Item 모델
```php
// app/Models/Item.php
class Item extends Model
{
use BelongsToTenant, ModelTrait, SoftDeletes;
protected $fillable = [
'tenant_id', 'item_type', 'code', 'name', 'unit',
'category_id', 'bom', 'is_active',
];
protected $casts = [
'bom' => 'array',
'is_active' => 'boolean',
];
// 1:1 관계
public function details() { return $this->hasOne(ItemDetail::class); }
public function attributes() { return $this->hasOne(ItemAttribute::class); }
// 타입별 스코프
public function scopeProducts($q) {
return $q->whereIn('item_type', ['FG', 'PT']);
}
public function scopeMaterials($q) {
return $q->whereIn('item_type', ['SM', 'RM', 'CS']);
}
}
```
### 2.2 ItemService
```php
// app/Services/ItemService.php
class ItemService extends Service
{
public function index(array $params): LengthAwarePaginator
{
$query = Item::where('tenant_id', $this->tenantId());
// item_type 필터
if ($itemType = $params['item_type'] ?? null) {
$query->where('item_type', strtoupper($itemType));
}
// 검색
if ($search = $params['search'] ?? null) {
$query->where(fn($q) => $q
->where('code', 'like', "%{$search}%")
->orWhere('name', 'like', "%{$search}%")
);
}
return $query->with(['details', 'attributes'])->paginate($params['per_page'] ?? 15);
}
}
```
### 2.3 체크리스트
- [x] Item 모델 생성
- [x] ItemDetail 모델 생성
- [x] ItemAttribute 모델 생성
- [x] ItemService 생성
- [x] ItemRequest 생성
---
## Phase 3: Item-Master 연동 수정
### 3.1 ItemPage.source_table 변경
```php
// app/Models/ItemMaster/ItemPage.php
// 기존
$mapping = [
'products' => \App\Models\Product::class,
'materials' => \App\Models\Material::class,
];
// 변경
$mapping = [
'items' => \App\Models\Item::class,
];
```
### 3.2 item_pages 데이터 업데이트
```sql
-- source_table 통합
UPDATE item_pages SET source_table = 'items' WHERE source_table IN ('products', 'materials');
```
### 3.3 체크리스트
- [x] ItemPage 모델 수정 (getTargetModelClass)
- [x] item_pages.source_table 마이그레이션
- [x] ItemMasterService 연동 테스트
---
## Phase 4: API 통합
### 4.1 API 구조 변경
```
기존 (분리):
/api/v1/products → ProductController
/api/v1/products/materials → MaterialController
통합 후:
/api/v1/items → ItemController
/api/v1/items?item_type=FG → Products 조회
/api/v1/items?item_type=SM → Materials 조회
```
### 4.2 ItemController
```php
// app/Http/Controllers/Api/V1/ItemController.php
class ItemController extends Controller
{
public function __construct(private ItemService $service) {}
public function index(ItemIndexRequest $request)
{
return ApiResponse::handle(fn() => [
'data' => $this->service->index($request->validated()),
], __('message.fetched'));
}
public function store(ItemStoreRequest $request)
{
return ApiResponse::handle(fn() => [
'data' => $this->service->store($request->validated()),
], __('message.created'));
}
}
```
### 4.3 라우트
```php
// routes/api_v1.php
Route::prefix('items')->group(function () {
Route::get('/', [ItemController::class, 'index']);
Route::post('/', [ItemController::class, 'store']);
Route::get('/{id}', [ItemController::class, 'show']);
Route::patch('/{id}', [ItemController::class, 'update']);
Route::delete('/{id}', [ItemController::class, 'destroy']);
});
```
### 4.4 체크리스트
- [x] ItemController 생성
- [x] ItemIndexRequest, ItemStoreRequest 등 생성
- [x] 라우트 등록
- [x] Swagger 문서 작성
- [x] 기존 ProductController, MaterialController 제거
---
## Phase 5: 참조 테이블 마이그레이션
### 5.1 변경 대상
| 테이블 | 기존 | 변경 |
|--------|------|------|
| product_components | ref_type + ref_id | child_item_id |
| bom_template_items | ref_type + ref_id | item_id |
| orders | product_id | item_id |
| order_items | product_id | item_id |
| material_receipts | material_id | item_id |
| lots | material_id | item_id |
| price_histories | item_type + item_id | item_id |
| item_fields | source_table 'products'\|'materials' | source_table 'items' |
### 5.2 체크리스트
- [x] 각 참조 테이블 마이그레이션 작성
- [x] 관련 모델 관계 업데이트
- [x] 데이터 검증
---
## Phase 6: 정리
### 6.1 체크리스트
- [x] CRUD 테스트 (전체 item_type)
- [x] BOM 계산 테스트
- [x] Item-Master 연동 테스트
- [x] 참조 무결성 테스트
- [x] products 테이블 삭제
- [x] materials 테이블 삭제
- [x] 기존 Product, Material 모델 삭제
- [x] 기존 ProductService, MaterialService 삭제
---
## 테이블 구조 요약
```
┌─────────────────────────────────────────────────────┐
│ items (핵심) │
├─────────────────────────────────────────────────────┤
│ id, tenant_id, item_type, code, name, unit │
│ category_id, bom (JSON), is_active │
│ timestamps + soft deletes │
└─────────────────────┬───────────────────────────────┘
│ 1:1
┌───────────────┴───────────────┐
▼ ▼
┌─────────────┐ ┌─────────────┐
│item_details │ │item_attrs │
├─────────────┤ ├─────────────┤
│ is_sellable │ │ attributes │
│ is_purch... │ │ options │
│ safety_stk │ └─────────────┘
│ lead_time │
│ is_inspect │
└─────────────┘
```
---
## BOM 계산 로직
### 통합 전
```php
foreach ($bom as $item) {
if ($item['child_item_type'] === 'product') {
$child = Product::find($item['child_item_id']);
} else {
$child = Material::find($item['child_item_id']);
}
}
```
### 통합 후
```php
$childIds = collect($bom)->pluck('child_item_id');
$children = Item::whereIn('id', $childIds)->get()->keyBy('id');
```
---
## 프론트엔드 전달 사항
### API 엔드포인트 변경
| 기존 | 통합 |
|------|------|
| `GET /api/v1/products` | `GET /api/v1/items?item_type=FG` |
| `GET /api/v1/products?product_type=PART` | `GET /api/v1/items?item_type=PART` |
| `GET /api/v1/products/materials` | `GET /api/v1/items?item_type=SM` |
### 응답 필드 변경
| 기존 | 통합 |
|------|------|
| `product_type` | `item_type` |
| `material_type` | `item_type` |
| `material_code` | `code` |
### BOM 요청/응답 변경
**요청 (Request)**:
```json
// 기존: BOM 저장 시 ref_type 지정 필요
{
"bom": [
{ "ref_type": "PRODUCT", "ref_id": 5, "quantity": 2 },
{ "ref_type": "MATERIAL", "ref_id": 10, "quantity": 1 }
]
}
// 통합: item_id만 사용
{
"bom": [
{ "child_item_id": 5, "quantity": 2 },
{ "child_item_id": 10, "quantity": 1 }
]
}
```
**응답 (Response)**:
```json
// 기존
{ "child_item_type": "product", "child_item_id": 5, "quantity": 2 }
// 통합
{ "child_item_id": 5, "quantity": 2 }
```
**프론트엔드 수정 포인트**:
- BOM 구성품 추가 시 `ref_type` 선택 UI 제거
- 품목 검색 시 `/api/v1/items` 단일 엔드포인트 사용
- BOM 저장 payload에서 `ref_type`, `ref_id``child_item_id`로 변경
---
## 일정
| Phase | 작업 | 상태 |
|-------|------|------|
| 0 | 데이터 정규화 (비표준 item_type/BOM 삭제) | ✅ 완료 |
| 1 | items 테이블 생성 + 데이터 이관 | ✅ 완료 |
| 2 | Item 모델 + Service 생성 | ✅ 완료 |
| 3 | Item-Master 연동 수정 | ✅ 완료 |
| 4 | API 통합 | ✅ 완료 |
| 5 | 참조 테이블 마이그레이션 | ✅ 완료 |
| 6 | 정리 | ✅ 완료 |
> **완료일**: 2025-12-15
> **관련 커밋**: `039fd62` (products/materials 테이블 삭제), `a93dfe7` (Phase 6 완료)
---
## 리스크
| 리스크 | 대응 |
|--------|------|
| 데이터 이관 누락 | 이관 전후 건수 검증 |
| Item-Master 연동 오류 | source_table 변경 전 테스트 |
| BOM 순환 참조 | 저장 시 검증 로직 추가 |
| Code 중복 (products↔materials) | 개발 중이므로 품목관리 완료 후 경동기업 데이터 전체 삭제 후 재세팅 예정. 중복 데이터는 삭제 처리 |
---
## 롤백 계획
각 Phase는 독립적 마이그레이션으로 구성:
```bash
# Phase 1 롤백
php artisan migrate:rollback --step=3
# 데이터 복구 (products/materials 테이블 유지 상태에서)
# 신규 테이블만 삭제하면 됨
```

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,378 @@
# L-2 권한관리 Mock → API 연동 계획
> **작성일**: 2025-12-30
> **목적**: React 권한관리 페이지의 Mock 데이터를 API 연동으로 전환
> **기준 문서**: mng.sam.kr/role-permissions
> **상태**: ✅ 완료 - Phase 1~4 전체 완료
---
## 📍 현재 진행 상태
| 항목 | 내용 |
|------|------|
| **마지막 완료 작업** | Phase 4 React 연동 완료 |
| **다음 작업** | 완료 (테스트 후 운영 배포) |
| **진행률** | 12/12 (100%) |
| **마지막 업데이트** | 2025-12-30
---
## 1. 개요
### 1.1 배경
현재 React의 권한관리 페이지(`/settings/permissions`)는 `localStorage``defaultPermissions` Mock 데이터를 사용하고 있습니다. mng 프로젝트에는 이미 완전한 역할-권한 관리 시스템이 구현되어 있으므로, api 프로젝트에 동일한 API를 개발하고 React에서 연동해야 합니다.
**문제점:**
- React는 `localStorage`에 권한 데이터 저장 (새로고침/브라우저 변경 시 데이터 손실)
- 실제 DB 연동 없음
- 역할 숨김(is_hidden) 기능이 DB 스키마에 없음
### 1.2 기준 원칙
```
┌─────────────────────────────────────────────────────────────────┐
│ 🎯 핵심 원칙 │
├─────────────────────────────────────────────────────────────────┤
│ 1. React → api.sam.kr만 호출 (mng 직접 호출 금지) │
│ 2. mng의 RoleService/RolePermissionService 로직 참조하여 api에 재구현 │
│ 3. Spatie Permission 패키지 활용 (기존 테이블 구조 유지) │
│ 4. Multi-tenant 지원 필수 (BelongsToTenant) │
└─────────────────────────────────────────────────────────────────┘
```
### 1.3 변경 승인 정책
| 분류 | 예시 | 승인 |
|------|------|------|
| ✅ 즉시 가능 | API 엔드포인트 추가, 타입 정의, 문서 수정 | 불필요 |
| ⚠️ 컨펌 필요 | DB 마이그레이션 (is_hidden 컬럼), 기존 API 수정 | **필수** |
| 🔴 금지 | roles 테이블 구조 대폭 변경, 기존 권한 삭제 | 별도 협의 |
### 1.4 준수 규칙
- `docs/quickstart/quick-start.md` - 빠른 시작 가이드
- `docs/standards/api-rules.md` - API 개발 규칙
- `docs/standards/quality-checklist.md` - 품질 체크리스트
- `docs/specs/database-schema.md` - DB 스키마
---
## 2. 현재 상태 분석
### 2.1 mng 프로젝트 (기준)
| 파일 | 역할 | 주요 기능 |
|------|------|----------|
| `RoleController.php` | 역할 CRUD 화면 | index, create, edit |
| `RoleService.php` | 역할 비즈니스 로직 | getRoles, createRole, updateRole, deleteRole |
| `RolePermissionController.php` | 권한 매트릭스 화면 | index (테넌트별 역할 목록) |
| `RolePermissionService.php` | 권한 매트릭스 로직 | togglePermission, allowAll, denyAll, getMenuTree |
| `Role.php` (Model) | 역할 모델 | tenant, permissions, users 관계 |
**mng의 역할 필드:**
```php
$fillable = ['tenant_id', 'name', 'description', 'guard_name'];
```
**⚠️ 숨김 기능 없음**: mng에도 `is_hidden` 필드가 없음
### 2.2 React 프로젝트 (현재)
| 파일 | 현재 상태 | 문제점 |
|------|----------|--------|
| `index.tsx` | `localStorage` + `defaultPermissions` | 실제 DB 연동 없음 |
| `types.ts` | `Permission` 타입 정의 | `status: 'active' | 'hidden'` 있음 |
| `PermissionDetail.tsx` | 메뉴별 권한 설정 | Mock 데이터 사용 |
**React의 Permission 타입:**
```typescript
interface Permission {
id: number;
name: string;
status: 'active' | 'hidden'; // ← DB에 없음!
menuPermissions: MenuPermission[];
createdAt: string;
}
```
### 2.3 api 프로젝트 (현재)
- **Role 관련 API 없음** (개발 필요)
- `shared/Models/Role.php` 존재 여부 확인 필요
### 2.4 DB 스키마 (roles 테이블)
```sql
roles (11 컬럼):
- id (PK)
- tenant_id (FK tenants.id)
- name
- guard_name (default: 'web')
- description
- created_by, updated_by, deleted_by
- created_at, updated_at, deleted_at
-- ⚠️ is_hidden 컬럼 없음! 추가 필요
```
---
## 3. 대상 범위
### 3.1 Phase 1: DB 스키마 수정
| # | 작업 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 1.1 | roles 테이블에 `is_hidden` 컬럼 추가 | ✅ | `2025_12_30_160802_add_is_hidden_to_roles_table.php` 생성완료, 실행대기 |
| 1.2 | 기존 역할 데이터 기본값 설정 (is_hidden = false) | ✅ | 마이그레이션에 포함 |
### 3.2 Phase 2: api 프로젝트 - Role CRUD API
| # | 작업 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 2.1 | Role 모델 생성/수정 | ✅ | shared/Models/Role.php |
| 2.2 | RoleService 생성 | ✅ | `api/app/Services/RoleService.php` |
| 2.3 | RoleController 생성 | ✅ | `api/app/Http/Controllers/Api/V1/RoleController.php` |
| 2.4 | RoleFormRequest 생성 | ⏳ | StoreRoleRequest, UpdateRoleRequest 미생성 |
| 2.5 | routes/api.php 라우트 추가 | ✅ | 5개 CRUD 라우트 등록완료 |
| 2.6 | Swagger 문서 작성 | ✅ | `api/app/Swagger/v1/RoleApi.php` |
### 3.3 Phase 3: api 프로젝트 - 권한 매트릭스 API
| # | 작업 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 3.1 | RolePermissionController 생성 | ✅ | `api/app/Http/Controllers/Api/V1/RolePermissionController.php` |
| 3.2 | 권한 목록 조회 API | ✅ | GET /roles/{id}/permissions |
| 3.3 | 권한 부여 API | ✅ | POST /roles/{id}/permissions |
| 3.4 | 권한 회수/동기화 API | ✅ | DELETE, PUT /roles/{id}/permissions/sync |
| 3.5 | Swagger 문서 작성 | ✅ | `api/app/Swagger/v1/RolePermissionApi.php` |
### 3.4 Phase 4: React 연동 ✅
| # | 작업 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 4.1 | actions.ts 생성 | ✅ | 12개 Server Actions (fetchRoles, createRole, updateRole, deleteRole 등) |
| 4.2 | types.ts 수정 | ✅ | ApiResponse, Role, RoleStats, MenuTreeItem, PermissionMatrix 타입 추가 |
| 4.3 | index.tsx 수정 (목록) | ✅ | localStorage → API 연동, 로딩/에러 상태, toast 알림 |
| 4.4 | PermissionDetailClient.tsx 수정 (상세/권한매트릭스) | ✅ | 역할 CRUD, 권한 토글, 전체 허용/거부/초기화 |
| 4.5 | Mock 데이터 제거 | ✅ | defaultPermissions 삭제, API 기반으로 전환 |
---
## 4. API 설계
### 4.1 Role CRUD API
| Method | Endpoint | 설명 | Request | Response |
|--------|----------|------|---------|----------|
| GET | `/api/v1/roles` | 역할 목록 | `?search=&is_hidden=` | `{ data: Role[], meta: Pagination }` |
| GET | `/api/v1/roles/{id}` | 역할 상세 | - | `{ data: Role }` |
| POST | `/api/v1/roles` | 역할 생성 | `{ name, description, is_hidden }` | `{ data: Role }` |
| PUT | `/api/v1/roles/{id}` | 역할 수정 | `{ name, description, is_hidden }` | `{ data: Role }` |
| DELETE | `/api/v1/roles/{id}` | 역할 삭제 | - | `{ message }` |
### 4.2 권한 매트릭스 API
| Method | Endpoint | 설명 | Request | Response |
|--------|----------|------|---------|----------|
| GET | `/api/v1/roles/{id}/menus` | 메뉴 트리 + 권한 상태 | - | `{ data: MenuWithPermissions[] }` |
| POST | `/api/v1/roles/{id}/permissions/toggle` | 권한 토글 | `{ menu_id, permission_type }` | `{ data: { value: boolean } }` |
| POST | `/api/v1/roles/{id}/permissions/allow-all` | 전체 허용 | - | `{ message }` |
| POST | `/api/v1/roles/{id}/permissions/deny-all` | 전체 거부 | - | `{ message }` |
| POST | `/api/v1/roles/{id}/permissions/reset` | 기본값 초기화 | - | `{ message }` |
### 4.3 Role 응답 타입
```typescript
interface Role {
id: number;
tenant_id: number;
name: string;
description: string | null;
guard_name: string;
is_hidden: boolean; // ← 신규 필드
permissions_count: number; // ← 권한 개수
users_count: number; // ← 사용자 수
created_at: string;
updated_at: string;
}
interface MenuWithPermissions {
id: number;
name: string;
parent_id: number | null;
depth: number;
has_children: boolean;
permissions: {
view: boolean;
create: boolean;
update: boolean;
delete: boolean;
approve: boolean;
export: boolean;
manage: boolean;
};
}
```
---
## 5. 상세 작업 내용
### 5.1 Phase 1: DB 스키마 수정 ✅
#### 1.1 roles 테이블에 is_hidden 컬럼 추가
- **상태**: ✅ 파일 생성완료 (실행 대기)
- **마이그레이션 파일**: `2025_12_30_160802_add_is_hidden_to_roles_table.php`
- **컬럼 정의**: `boolean is_hidden default false after description`
- **영향**: api, mng 모두 적용
### 5.2 Phase 2: Role CRUD API ✅
#### 생성된 파일
| 파일 | 경로 |
|------|------|
| RoleController | `api/app/Http/Controllers/Api/V1/RoleController.php` |
| RoleService | `api/app/Services/RoleService.php` |
| RoleApi Swagger | `api/app/Swagger/v1/RoleApi.php` |
#### 등록된 라우트 (5개)
```
GET /api/v1/roles → index
POST /api/v1/roles → store
GET /api/v1/roles/{id} → show
PATCH /api/v1/roles/{id} → update
DELETE /api/v1/roles/{id} → destroy
```
### 5.3 Phase 3: 권한 매트릭스 API ✅
#### 생성된 파일
| 파일 | 경로 |
|------|------|
| RolePermissionController | `api/app/Http/Controllers/Api/V1/RolePermissionController.php` |
| RolePermissionApi Swagger | `api/app/Swagger/v1/RolePermissionApi.php` |
#### 등록된 라우트 (4개)
```
GET /api/v1/roles/{id}/permissions → index
POST /api/v1/roles/{id}/permissions → grant
DELETE /api/v1/roles/{id}/permissions → revoke
PUT /api/v1/roles/{id}/permissions/sync → sync
```
---
## 6. 컨펌 대기 목록
> API 내부 로직 변경 등 승인 필요 항목
| # | 항목 | 변경 내용 | 영향 범위 | 상태 |
|---|------|----------|----------|------|
| 1 | is_hidden 컬럼 추가 | roles 테이블 마이그레이션 | api, mng | ⏳ 대기 |
---
## 7. 파일 구조 (예상)
### 7.1 api 프로젝트
```
api/app/
├── Http/
│ ├── Controllers/
│ │ └── RoleController.php ← 🆕 생성
│ └── Requests/
│ ├── StoreRoleRequest.php ← 🆕 생성
│ └── UpdateRoleRequest.php ← 🆕 생성
├── Models/
│ └── Role.php ← 🔄 수정 (is_hidden 추가)
└── Services/
├── RoleService.php ← 🆕 생성
└── RolePermissionService.php ← 🆕 생성
api/database/migrations/
└── xxxx_add_is_hidden_to_roles_table.php ← 🆕 생성
api/routes/
└── api.php ← 🔄 수정 (라우트 추가)
```
### 7.2 React 프로젝트
```
react/src/components/settings/PermissionManagement/
├── index.tsx ← 🔄 수정 (API 연동)
├── types.ts ← 🔄 수정 (타입 매핑)
├── actions.ts ← 🆕 생성
├── PermissionDetail.tsx ← 🔄 수정 (API 연동)
├── PermissionDetailClient.tsx ← 🔄 수정
└── PermissionDialog.tsx ← 🔄 수정
```
---
## 8. 변경 이력
| 날짜 | 항목 | 변경 내용 | 파일 | 승인 |
|------|------|----------|------|------|
| 2025-12-30 | Phase 1~3 | API 개발 완료 (마이그레이션, Controller, Service, Swagger, 라우트) | 다수 | ✅ |
| 2025-12-30 | Phase 4 | React 연동 완료 (actions.ts, types.ts, index.tsx, PermissionDetailClient.tsx) | react 4개 파일 | ✅ |
| 2025-12-30 | 문서 | 계획 문서 초안 작성 | - | - |
| 2025-12-30 | 문서 | Phase 4 완료 반영 업데이트 | - | - |
---
## 9. 참고 문서
- **빠른 시작**: `docs/quickstart/quick-start.md`
- **API 규칙**: `docs/standards/api-rules.md`
- **품질 체크리스트**: `docs/standards/quality-checklist.md`
- **DB 스키마**: `docs/specs/database-schema.md`
- **mng 권한관리**: `mng/app/Services/RoleService.php`, `RolePermissionService.php`
---
## 10. 세션 및 메모리 관리 정책 (Serena Optimized)
### 10.1 세션 시작 시 (Load Strategy)
```javascript
read_memory("l2-permission-state") // 1. 상태 파악
read_memory("l2-permission-snapshot") // 2. 사고 흐름 복구
```
### 10.2 Serena 메모리 구조
- `l2-permission-state`: { phase, progress, next_step, last_decision }
- `l2-permission-snapshot`: 현재까지의 논의 및 코드 변경점 요약
---
## 11. 검증 결과
> 작업 완료 후 이 섹션에 검증 결과 추가
### 11.1 테스트 케이스
| 입력값 | 예상 결과 | 실제 결과 | 상태 |
|--------|----------|----------|------|
| GET /api/v1/roles | 역할 목록 반환 | | ⏳ |
| POST /api/v1/roles | 역할 생성 | | ⏳ |
| PUT /api/v1/roles/{id} | 역할 수정 | | ⏳ |
| DELETE /api/v1/roles/{id} | 역할 삭제 | | ⏳ |
| GET /api/v1/roles/{id}/menus | 메뉴+권한 매트릭스 | | ⏳ |
| POST /api/v1/roles/{id}/permissions/toggle | 권한 토글 | | ⏳ |
### 11.2 성공 기준 달성 현황
| 기준 | 달성 | 비고 |
|------|------|------|
| localStorage 제거 | ⏳ | |
| 역할 CRUD API 동작 | ⏳ | |
| 권한 매트릭스 API 동작 | ⏳ | |
| 숨김 기능 동작 | ⏳ | |
---
*이 문서는 /sc:plan 스킬로 생성되었습니다.*

View File

@@ -0,0 +1,482 @@
# 개소별 자재 투입 매핑 계획
> **작성일**: 2026-02-12
> **목적**: Worker Screen 자재 투입 시 개소(work_order_item)별 매핑 추적 기능 구현
> **기준 문서**: `docs/specs/database-schema.md`, `docs/standards/api-rules.md`
> **상태**: 🔄 진행중
---
## 📍 현재 진행 상태
| 항목 | 내용 |
|------|------|
| **마지막 완료 작업** | Phase 1~3 전체 구현 완료 |
| **다음 작업** | 테스트 및 검증 |
| **진행률** | 8/8 (100%) |
| **마지막 업데이트** | 2026-02-12 |
---
## 1. 개요
### 1.1 배경
현재 자재 투입은 **작업지시(WorkOrder) 단위**로만 처리됨:
- `POST /api/v1/work-orders/{id}/material-inputs``{inputs: [{stock_lot_id, qty}]}`
- `stock_transactions.reference_id` = `work_order_id` (개소 정보 없음)
- 어떤 개소(work_order_item)에 어떤 자재가 투입되었는지 추적 불가
**필요**: 개소별로 자재 투입을 추적하여:
- 개소별 투입 완료 여부 확인
- 개소별 필요 자재 vs 실투입 비교
- 검사서에 개소별 투입 자재 LOT 번호 기록
### 1.2 기준 원칙
```
┌─────────────────────────────────────────────────────────────────┐
│ 🎯 핵심 원칙 │
├─────────────────────────────────────────────────────────────────┤
│ 1. 신규 테이블(work_order_material_inputs)로 개소별 매핑 추적 │
│ 2. 기존 stock_transactions 구조 변경 없음 (재고 이력은 그대로) │
│ 3. 기존 작업지시 단위 API는 유지, 개소별 API를 추가 │
│ 4. BOM 기반 필요 자재 계산은 기존 로직 재활용 │
└─────────────────────────────────────────────────────────────────┘
```
### 1.3 변경 승인 정책
| 분류 | 예시 | 승인 |
|------|------|------|
| ✅ 즉시 가능 | 타입 정의 추가, 프론트 UI 변경 | 불필요 |
| ⚠️ 컨펌 필요 | 새 마이그레이션, 새 API 엔드포인트, 서비스 로직 변경 | **필수** |
| 🔴 금지 | 기존 stock_transactions 구조 변경, 기존 API 삭제 | 별도 협의 |
### 1.4 준수 규칙
- `docs/standards/api-rules.md` - Service-First, FormRequest, ApiResponse::handle()
- `docs/standards/quality-checklist.md` - 품질 체크리스트
- `docs/specs/database-schema.md` - DB 스키마 규칙
- MEMORY.md: 멀티테넌시 원칙 (FK/조인키만 컬럼, 나머지 options JSON)
---
## 2. 대상 범위
### 2.1 Phase 1: Database & Model (백엔드 기반)
| # | 작업 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 1.1 | `work_order_material_inputs` 마이그레이션 생성 | ✅ | api/ 프로젝트에서 |
| 1.2 | `WorkOrderMaterialInput` 모델 생성 | ✅ | BelongsToTenant 필수 |
| 1.3 | 관계 설정 (WorkOrderItem, WorkOrder) | ✅ | |
### 2.2 Phase 2: Backend API (서비스 + 컨트롤러)
| # | 작업 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 2.1 | `getMaterialsForItem()` 서비스 메서드 | ✅ | 개소별 BOM 자재 조회 |
| 2.2 | `registerMaterialInputForItem()` 서비스 메서드 | ✅ | 개소별 투입 + 매핑 저장 |
| 2.3 | `getMaterialInputsForItem()` 서비스 메서드 | ✅ | 개소별 투입 이력 조회 |
| 2.4 | 컨트롤러 엔드포인트 추가 | ✅ | 3개 엔드포인트 |
| 2.5 | FormRequest 생성 | ✅ | 투입 요청 검증 |
| 2.6 | 라우트 등록 | ✅ | production.php |
### 2.3 Phase 3: Frontend (React)
| # | 작업 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 3.1 | Server Actions 추가 | ✅ | 개소별 API 호출 함수 |
| 3.2 | MaterialInputModal props 확장 | ✅ | workOrderItemId 추가 |
| 3.3 | 자재투입 버튼 → 개소별 호출 연결 | ✅ | WorkerScreen에서 |
| 3.4 | 투입 이력/상태 표시 | ✅ | 개소 카드에 투입 완료 표시 |
---
## 3. 상세 설계
### 3.1 신규 테이블: `work_order_material_inputs`
```sql
CREATE TABLE work_order_material_inputs (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
tenant_id BIGINT UNSIGNED NOT NULL,
work_order_id BIGINT UNSIGNED NOT NULL COMMENT '작업지시 ID',
work_order_item_id BIGINT UNSIGNED NOT NULL COMMENT '개소(작업지시품목) ID',
stock_lot_id BIGINT UNSIGNED NOT NULL COMMENT '투입 로트 ID',
item_id BIGINT UNSIGNED NOT NULL COMMENT '자재 품목 ID',
qty DECIMAL(12,3) NOT NULL COMMENT '투입 수량',
input_by BIGINT UNSIGNED NULL COMMENT '투입자 ID',
input_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '투입 시각',
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL,
-- FK
FOREIGN KEY (work_order_id) REFERENCES work_orders(id) ON DELETE CASCADE,
FOREIGN KEY (work_order_item_id) REFERENCES work_order_items(id) ON DELETE CASCADE,
-- Index
INDEX idx_womi_tenant (tenant_id),
INDEX idx_womi_wo_item (work_order_id, work_order_item_id),
INDEX idx_womi_lot (stock_lot_id)
) COMMENT='개소별 자재 투입 이력';
```
**설계 근거**:
- `work_order_id`: 작업지시 단위 조회용 (기존 호환)
- `work_order_item_id`: 개소별 매핑 핵심
- `stock_lot_id`: 어떤 LOT에서 투입했는지
- `item_id`: 어떤 자재(품목)인지
- `qty`: 투입 수량
- `input_by`, `input_at`: 투입자/시간 추적
### 3.2 API 엔드포인트
#### GET `/api/v1/work-orders/{workOrderId}/items/{itemId}/materials`
- **용도**: 특정 개소의 BOM 기반 필요 자재 + 재고 LOT 조회
- **응답**: 기존 `MaterialForInput[]`과 동일 구조
- **로직**: 기존 `getMaterials()` 중 해당 item_id의 BOM만 추출
#### POST `/api/v1/work-orders/{workOrderId}/items/{itemId}/material-inputs`
- **용도**: 특정 개소에 자재 투입 등록
- **요청**:
```json
{
"inputs": [
{ "stock_lot_id": 456, "qty": 100 }
]
}
```
- **처리 순서**:
1. `StockService::decreaseFromLot()` 호출 (기존 재고 차감 로직 재사용)
2. `work_order_material_inputs` 레코드 생성 (개소 매핑)
3. 감사 로그 기록
- **응답**:
```json
{
"work_order_id": 123,
"work_order_item_id": 789,
"material_count": 2,
"input_results": [...],
"input_at": "2026-02-12T14:30:00"
}
```
#### GET `/api/v1/work-orders/{workOrderId}/items/{itemId}/material-inputs`
- **용도**: 특정 개소의 투입 이력 조회
- **응답**:
```json
{
"data": [
{
"id": 1,
"stock_lot_id": 456,
"lot_no": "LOT-2026-001",
"item_id": 100,
"material_code": "MAT-001",
"material_name": "내화실",
"qty": 100,
"unit": "EA",
"input_by": 5,
"input_by_name": "홍길동",
"input_at": "2026-02-12T14:30:00"
}
]
}
```
### 3.3 서비스 메서드 설계
#### WorkOrderService::getMaterialsForItem(int $workOrderId, int $itemId): array
```
1. WorkOrderItem 조회 (workOrderId + itemId 검증)
2. 해당 item의 BOM 추출
3. BOM child_item별 required_qty = bom_qty × item.quantity
4. 각 자재의 StockLot 조회 (FIFO)
5. 이미 투입된 수량 차감 계산 (work_order_material_inputs에서 SUM)
6. 반환: MaterialForInput[] (remaining_required_qty 포함)
```
#### WorkOrderService::registerMaterialInputForItem(int $workOrderId, int $itemId, array $inputs): array
```
DB::transaction {
1. WorkOrderItem 조회 + 검증
2. foreach (inputs as input):
a. StockService::decreaseFromLot() (기존 로직 재사용)
b. WorkOrderMaterialInput::create({
tenant_id, work_order_id, work_order_item_id,
stock_lot_id, item_id (로트의 품목),
qty, input_by, input_at
})
3. 감사 로그 기록
4. 결과 반환
}
```
### 3.4 프론트엔드 변경
#### MaterialInputModal Props 확장
```typescript
interface MaterialInputModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
order: WorkOrder | null;
workOrderItemId?: number; // ← 추가: 개소 ID
workOrderItemName?: string; // ← 추가: 개소명 (모달 헤더용)
isCompletionFlow?: boolean;
onComplete?: () => void;
onSaveMaterials?: (...) => void;
savedMaterials?: MaterialInput[];
}
```
#### Server Actions 추가
```typescript
// 개소별 자재 조회
getMaterialsForItem(workOrderId: string, itemId: number): Promise<{
success: boolean;
data: MaterialForInput[];
}>
// 개소별 자재 투입
registerMaterialInputForItem(workOrderId: string, itemId: number, inputs: ...): Promise<{
success: boolean;
}>
// 개소별 투입 이력
getMaterialInputsForItem(workOrderId: string, itemId: number): Promise<{
success: boolean;
data: MaterialInputHistory[];
}>
```
#### MaterialInputModal 로직 변경
```
useEffect에서:
if (workOrderItemId) {
getMaterialsForItem(order.id, workOrderItemId) // 개소별 조회
} else {
getMaterialsForWorkOrder(order.id) // 기존 전체 조회 (하위호환)
}
handleSubmit에서:
if (workOrderItemId) {
registerMaterialInputForItem(order.id, workOrderItemId, inputs)
} else {
registerMaterialInput(order.id, inputs)
}
```
### 3.5 기존 API와의 관계
```
기존 API (유지, 하위 호환):
GET /work-orders/{id}/materials → 전체 자재 조회
POST /work-orders/{id}/material-inputs → 전체 단위 투입
신규 API (추가):
GET /work-orders/{id}/items/{itemId}/materials → 개소별 자재 조회
POST /work-orders/{id}/items/{itemId}/material-inputs → 개소별 투입
GET /work-orders/{id}/items/{itemId}/material-inputs → 개소별 투입 이력
```
---
## 4. 작업 절차
### Step 1: 마이그레이션 + 모델 (Phase 1)
```
1.1 api/ 프로젝트에서 마이그레이션 파일 생성
- 파일: api/database/migrations/2026_02_12_XXXXXX_create_work_order_material_inputs_table.php
- 테이블: work_order_material_inputs (섹션 3.1 참조)
1.2 WorkOrderMaterialInput 모델 생성
- 파일: api/app/Models/Production/WorkOrderMaterialInput.php
- traits: BelongsToTenant, SoftDeletes (선택)
- $fillable: tenant_id, work_order_id, work_order_item_id, stock_lot_id, item_id, qty, input_by, input_at
- 관계: belongsTo(WorkOrder), belongsTo(WorkOrderItem), belongsTo(StockLot)
1.3 기존 모델에 역관계 추가
- WorkOrderItem: hasMany(WorkOrderMaterialInput)
- WorkOrder: hasMany(WorkOrderMaterialInput)
검증: docker exec sam-api-1 php artisan migrate → 테이블 생성 확인
```
### Step 2: Backend Service (Phase 2.1-2.3)
```
2.1 WorkOrderService에 getMaterialsForItem() 추가
- 기존 getMaterials() 로직 재활용
- 해당 item의 BOM만 필터링
- 이미 투입된 수량 차감 표시
2.2 WorkOrderService에 registerMaterialInputForItem() 추가
- 기존 registerMaterialInput() 로직 기반
- work_order_material_inputs 레코드 추가 생성
- 트랜잭션 내에서 처리
2.3 WorkOrderService에 getMaterialInputsForItem() 추가
- work_order_material_inputs 조회
- lot_no, material_name 등 조인
검증: API 테스트 (curl 또는 Swagger)
```
### Step 3: Controller + Route (Phase 2.4-2.6)
```
2.4 WorkOrderController에 3개 메서드 추가
- materialsForItem(int $workOrderId, int $itemId)
- registerMaterialInputForItem(Request, int $workOrderId, int $itemId)
- materialInputsForItem(int $workOrderId, int $itemId)
2.5 MaterialInputForItemRequest FormRequest 생성 (투입 검증)
- inputs: required|array|min:1
- inputs.*.stock_lot_id: required|integer
- inputs.*.qty: required|numeric|gt:0
2.6 라우트 등록: api/routes/api/v1/production.php
- Route::get('work-orders/{id}/items/{itemId}/materials', ...)
- Route::post('work-orders/{id}/items/{itemId}/material-inputs', ...)
- Route::get('work-orders/{id}/items/{itemId}/material-inputs', ...)
검증: php artisan route:list | grep material
```
### Step 4: Frontend (Phase 3)
```
3.1 actions.ts에 3개 Server Action 추가
- getMaterialsForItem()
- registerMaterialInputForItem()
- getMaterialInputsForItem()
3.2 MaterialInputModal 수정
- workOrderItemId prop 추가
- useEffect에서 조건부 API 호출
- handleSubmit에서 조건부 API 호출
- 모달 헤더에 개소명 표시
3.3 WorkerScreen에서 개소별 자재투입 연결
- 자재투입 버튼 클릭 시 workOrderItemId 전달
3.4 개소 카드에 투입 상태 표시
- 투입 완료/미완료 뱃지
검증: dev.sam.kr에서 실제 플로우 테스트
```
---
## 5. 핵심 파일 참조
### Backend (api/)
| 파일 | 역할 |
|------|------|
| `app/Services/WorkOrderService.php` | getMaterials() (line 1117), registerMaterialInput() (line 1264) |
| `app/Services/StockService.php` | decreaseFromLot() (line 618) - 재고 차감 |
| `app/Http/Controllers/Api/V1/WorkOrderController.php` | materials(), registerMaterialInput() |
| `routes/api/v1/production.php` (line 67-70) | 자재 관련 라우트 |
| `app/Models/Production/WorkOrderItem.php` | 작업지시 품목 모델 |
### Frontend (react/)
| 파일 | 역할 |
|------|------|
| `src/components/production/WorkerScreen/MaterialInputModal.tsx` | 자재 투입 모달 UI |
| `src/components/production/WorkerScreen/actions.ts` | getMaterialsForWorkOrder(), registerMaterialInput() |
| `src/components/production/WorkerScreen/types.ts` | MaterialForInput, MaterialInput 타입 |
### Database
| 테이블 | 역할 |
|--------|------|
| `work_order_items` | 작업지시 품목(개소). options JSON에 공정별 상세 |
| `stock_lots` | 재고 LOT. available_qty, fifo_order |
| `stock_transactions` | 재고 거래 이력. reference_type='work_order_input' |
| `work_order_material_inputs` | **신규** - 개소별 투입 매핑 |
---
## 6. 컨펌 대기 목록
| # | 항목 | 변경 내용 | 영향 범위 | 상태 |
|---|------|----------|----------|------|
| 1 | 마이그레이션 | work_order_material_inputs 테이블 생성 | DB | ⚠️ 컨펌 필요 |
| 2 | API 엔드포인트 3개 추가 | 개소별 자재 조회/투입/이력 | api | ⚠️ 컨펌 필요 |
| 3 | 기존 API 유지 여부 | 작업지시 단위 API 유지 (하위호환) | api | ✅ 유지 |
---
## 7. 변경 이력
| 날짜 | 항목 | 변경 내용 | 파일 | 승인 |
|------|------|----------|------|------|
| 2026-02-12 | - | 문서 초안 작성 | - | - |
---
## 8. 참고 문서
- **API 규칙**: `docs/standards/api-rules.md`
- **DB 스키마**: `docs/specs/database-schema.md`
- **품질 체크리스트**: `docs/standards/quality-checklist.md`
- **기존 분석**: Explore Agent 분석 결과 (세션 내)
- **품목 정책**: `docs/rules/item-policy.md` (BOM, lot_managed 등)
- **MEMORY.md**: 멀티테넌시 원칙, 품목 options 체계
---
## 9. 검증 결과
### 9.1 테스트 케이스
| # | 시나리오 | 예상 결과 | 실제 결과 | 상태 |
|---|---------|----------|----------|------|
| 1 | 개소별 자재 조회 (BOM 있는 품목) | 해당 개소 BOM의 자재 + LOT 목록 반환 | | ✅ |
| 2 | 개소별 자재 조회 (BOM 없는 품목) | 품목 자체를 자재로 반환 | | ✅ |
| 3 | 개소별 자재 투입 | stock_lot 차감 + material_inputs 레코드 생성 | | ✅ |
| 4 | 이미 투입된 자재 재조회 | remaining_required_qty 감소 확인 | | ✅ |
| 5 | 가용수량 초과 투입 시도 | 에러 반환 (재고 부족) | | ✅ |
| 6 | 투입 이력 조회 | lot_no, 자재명, 수량, 투입자 확인 | | ✅ |
| 7 | 프론트 자재투입 모달에서 개소별 투입 | 해당 개소 자재만 표시, 투입 성공 | | ✅ |
### 9.2 성공 기준
| 기준 | 달성 | 비고 |
|------|------|------|
| 개소별 자재 조회 API 동작 | ✅ | BOM 기반 필터링 |
| 개소별 자재 투입 API 동작 | ✅ | 재고 차감 + 매핑 저장 |
| 프론트에서 개소별 투입 플로우 | ✅ | MaterialInputModal 연동 |
| 기존 작업지시 단위 API 호환 유지 | ✅ | 기존 기능 미파손 |
---
## 10. 자기완결성 점검 결과
### 10.1 체크리스트 검증
| # | 검증 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 1 | 작업 목적이 명확한가? | ✅ | 개소별 자재 투입 매핑 |
| 2 | 성공 기준이 정의되어 있는가? | ✅ | 섹션 9.2 |
| 3 | 작업 범위가 구체적인가? | ✅ | Phase 1-3, 8개 작업 항목 |
| 4 | 의존성이 명시되어 있는가? | ✅ | 기존 API, StockService 재사용 |
| 5 | 참고 파일 경로가 정확한가? | ✅ | 섹션 5 핵심 파일 참조 |
| 6 | 단계별 절차가 실행 가능한가? | ✅ | 섹션 4 Step 1-4 |
| 7 | 검증 방법이 명시되어 있는가? | ✅ | 섹션 9.1 테스트 케이스 |
| 8 | 모호한 표현이 없는가? | ✅ | SQL, API 스키마 구체적 명시 |
### 10.2 새 세션 시뮬레이션 테스트
| 질문 | 답변 가능 | 참조 섹션 |
|------|:--------:|----------|
| Q1. 이 작업의 목적은 무엇인가? | ✅ | 1.1 배경 |
| Q2. 어디서부터 시작해야 하는가? | ✅ | 4. 작업 절차 Step 1 |
| Q3. 어떤 파일을 수정해야 하는가? | ✅ | 5. 핵심 파일 참조 |
| Q4. 작업 완료 확인 방법은? | ✅ | 9. 검증 결과 |
| Q5. 막혔을 때 참고 문서는? | ✅ | 8. 참고 문서 |
**결과**: 5/5 통과 → ✅ 자기완결성 확보
---
*이 문서는 /plan 스킬로 생성되었습니다.*

View File

@@ -0,0 +1,525 @@
# MES 모듈 통합 흐름 분석 계획
> **작성일**: 2025-01-09
> **목적**: 견적 → 수주 → 작업지시 + 공정관리 모듈 간 연동 상태 점검 및 문제점 분석
> **기준 문서**: `docs/plans/process-management-plan.md`, `docs/plans/order-management-plan.md`, `docs/plans/work-order-plan.md`
> **상태**: ✅ 분석 완료 + 개선 방향 **재결정됨** (2025-01-09 추가 분석)
---
## 📍 현재 진행 상태
| 항목 | 내용 |
|------|------|
| **마지막 완료 작업** | 공정 관리 페이지 확인 + 개념 명확화 |
| **다음 작업** | WorkOrder `process_type``process_id` FK 변경 구현 |
| **진행률** | 7/7 (100%) |
| **마지막 업데이트** | 2025-01-09 |
### ✅ 결정된 개선 방향 (재결정)
| 결정 사항 | 내용 |
|----------|------|
| **WorkOrder.process_type** | `process_type` (varchar) → `process_id` (FK) **변경** |
| **Process.process_type** | 공정 구분 → `common_codes`에서 관리 |
| **개념 정리** | 공정명(WorkOrder) ≠ 공정구분(Process) 명확히 구분 |
---
## 1. 개요
### 1.1 배경
MES 시스템의 핵심 모듈인 공정관리, 수주관리, 작업지시가 개별적으로 개발 완료되었으나,
모듈 간 통합 흐름이 제대로 설계되었는지 검증이 필요합니다.
### 1.2 분석 목표
```
┌─────────────────────────────────────────────────────────────────┐
│ 🎯 분석 목표 │
├─────────────────────────────────────────────────────────────────┤
│ 1. 모듈 간 데이터 흐름 검증 │
│ 2. API 연동 상태 점검 │
│ 3. 프론트엔드 연동 상태 점검 │
│ 4. 설계 문제점 및 개선 방안 도출 │
└─────────────────────────────────────────────────────────────────┘
```
---
## 2. 분석 대상
### 2.1 모듈 구성
| 모듈 | 역할 | API 상태 | Frontend 상태 |
|------|------|:--------:|:------------:|
| **견적관리 (Quote)** | 견적서 작성 및 수주 변환 | ✅ 완료 | ✅ 완료 |
| **수주관리 (Order)** | 견적→수주 변환, 생산지시 생성 | ✅ 완료 | ✅ 완료 |
| **작업지시 (WorkOrder)** | 실제 생산 작업 관리 | ✅ 완료 | ✅ 완료 |
| **공정관리 (Process)** | 공정 템플릿 및 품목 분류 규칙 관리 | ✅ 완료 | ✅ 완료 |
### 2.2 기대 데이터 흐름
```
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ 견적관리 │ │ 수주관리 │ │ 작업지시 │ │ 공정관리 │
│ (Quote) │ ──→ │ (Order) │ ──→ │ (WorkOrder) │ ? │ (Process) │
└──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘
│ │ │ │
▼ ▼ ▼ ▼
- 견적서 작성 - 수주 확정 - 작업 상태 관리 - 공정 템플릿
- 품목/단가 구성 - 생산지시 생성 - 담당자 배정 - 품목 분류 규칙
- 고객 승인 - 납기 관리 - 공정별 진행 - 작업 단계 정의
```
---
## 3. 분석 결과
### 3.0 ✅ 견적관리 → 수주관리 연동 (정상 작동)
**API 연동 구현**:
```
POST /api/v1/orders/from-quote/{quoteId}
→ Order 생성 + Quote 상태 변경 (finalized → converted)
```
**연결 관계**:
| 항목 | 내용 |
|------|------|
| FK 연결 | `orders.quote_id``quotes.id` |
| 상태 연동 | Quote `finalized` 시에만 수주 변환 가능 |
| 중복 방지 | 동일 Quote에 대해 중복 변환 불가 |
**Quote 상태 흐름**:
```
draft → sent → approved → finalized → converted
(임시저장) (발송) (승인) (확정) (수주변환)
```
**API 핵심 로직** (`api/app/Services/OrderService.php`):
```php
public function createFromQuote(int $quoteId): Order
{
$quote = Quote::findOrFail($quoteId);
// 변환 가능 상태 검증 (finalized만 가능)
if ($quote->status !== Quote::STATUS_FINALIZED) {
throw new BadRequestHttpException(__('error.quote.must_be_finalized'));
}
// 중복 변환 방지
$existingOrder = Order::where('quote_id', $quoteId)->first();
if ($existingOrder) {
throw new BadRequestHttpException(__('error.order.already_exists_from_quote'));
}
// Order 생성 + Quote 품목 자동 복사
$order = Order::create([
'quote_id' => $quote->id,
'client_id' => $quote->client_id,
'status_code' => Order::STATUS_DRAFT,
// ... 견적 정보 복사
]);
// Quote 상태 변경
$quote->status = Quote::STATUS_CONVERTED;
$quote->save();
return $order;
}
```
**프론트엔드 구현**:
```typescript
// react/src/components/orders/actions.ts
export async function createOrderFromQuote(
quoteId: string | number
): Promise<OrderApiResponse>
// react/src/components/quotes/QuotationSelectDialog.tsx
// 견적 선택 → 수주 변환 UI 컴포넌트
```
**데이터 변환**:
| Quote 필드 | Order 필드 | 변환 방식 |
|-----------|-----------|----------|
| `id` | `quote_id` (FK) | 참조 |
| `client_id` | `client_id` | 복사 |
| `project_name` | `project_name` | 복사 |
| `quote_items` | `order_items` | 품목 복사 |
| `product_category` | - | 참조용 |
**평가**: ✅ **정상 구현됨** - FK 관계, 상태 연동, 중복 방지 모두 정상
---
### 3.1 ✅ 수주관리 → 작업지시 연동 (정상 작동)
**API 연동 구현**:
```
POST /api/v1/orders/{id}/production-order
→ WorkOrder 생성 + Order 상태 변경 (CONFIRMED → IN_PROGRESS)
```
**연결 관계**:
| 항목 | 내용 |
|------|------|
| FK 연결 | `work_orders.sales_order_id``orders.id` |
| 상태 연동 | Order CONFIRMED 시에만 생산지시 가능 |
| 중복 방지 | 동일 Order에 대해 중복 생성 불가 |
**프론트엔드 구현**:
```typescript
// react/src/components/orders/actions.ts
export async function createProductionOrder(
orderId: string,
data?: CreateProductionOrderData
): Promise<ProductionOrderResult>
// CreateProductionOrderData 타입
interface CreateProductionOrderData {
processType?: 'screen' | 'slat' | 'bending';
priority?: 'urgent' | 'high' | 'normal' | 'low';
assigneeId?: number;
teamId?: number;
scheduledDate?: string;
memo?: string;
}
```
**평가**: ✅ **정상 구현됨**
---
### 3.2 🔴 공정관리 → 작업지시 연동 (설계 문제 발견 → 해결 방향 결정)
#### 3.2.0 ✅ 개념 명확화 (2025-01-09 추가 분석)
**공정 관리 페이지 확인** (`/master-data/process-management`):
| 공정코드 | 공정명 | 구분 | 담당부서 | 상태 |
|---------|-------|------|---------|------|
| P-001 | 슬랫 | 생산 | 경영본부 | 사용중 |
| P-002 | 스크린 | 생산 | 개발팀 | 사용중 |
**핵심 발견**:
```
┌─────────────────────────────────────────────────────────────────┐
│ 💡 개념 정리 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ WorkOrder.process_type = "공정명" (스크린, 슬랫, 절곡) │
│ → 공정 관리 테이블(processes)에서 등록된 공정 │
│ → 하드코딩 ❌ → 공정 테이블 FK로 연결해야 함 ✅ │
│ │
│ Process.process_type = "공정 구분" (생산, 검사, 포장, 조립) │
│ → 공정의 분류/카테고리 │
│ → common_codes에서 관리해야 함 ✅ │
│ │
└─────────────────────────────────────────────────────────────────┘
```
**최종 정리**:
| 구분 | 필드명 | 실제 의미 | 현재 상태 | 올바른 상태 |
|------|--------|----------|----------|------------|
| **WorkOrder** | `process_type` | 공정명 | 하드코딩 (screen/slat/bending) | **공정 테이블 FK** |
| **Process** | `process_type` | 공정 구분 | 하드코딩 (생산/검사/포장/조립) | common_codes |
---
#### 3.2.1 process_type 불일치 문제 (기존 분석)
| 구분 | 공정관리 (Process) | 작업지시 (WorkOrder) |
|------|:------------------:|:-------------------:|
| **필드명** | `process_type` | `process_type` |
| **값 (Frontend)** | '생산', '검사', '포장', '조립' | 'screen', 'slat', 'bending' |
| **값 개수** | 4개 (한글) | 3개 (영문) |
| **실제 의미** | 공정 **구분** (카테고리) | 공정 **명** (공정 테이블 데이터) |
**문제점**:
- 동일한 필드명(`process_type`)을 사용하지만 **완전히 다른 의미**
- WorkOrder는 **공정 테이블을 참조해야 하는데** 하드코딩되어 있음
- **FK 관계가 없음** - Process 테이블과 WorkOrder 테이블 연결 없음
#### 3.2.2 코드 증거
**공정관리 타입** (`react/src/types/process.ts`):
```typescript
export type ProcessType = '생산' | '검사' | '포장' | '조립';
```
**작업지시 타입** (`react/src/components/production/WorkOrders/types.ts`):
```typescript
export type ProcessType = 'screen' | 'slat' | 'bending';
export const PROCESS_TYPE_LABELS: Record<ProcessType, string> = {
screen: '스크린',
slat: '슬랫',
bending: '절곡',
};
```
**API 모델** (`api/app/Models/Production/WorkOrder.php`):
```php
const PROCESS_SCREEN = 'screen';
const PROCESS_SLAT = 'slat';
const PROCESS_BENDING = 'bending';
```
#### 3.2.3 영향도 분석
| 기능 | 현재 상태 | 문제점 |
|------|----------|--------|
| 공정 선택 | WorkOrder 생성 시 하드코딩된 3개 옵션만 사용 | Process 테이블 활용 안됨 |
| 분류 규칙 | Process에만 존재 | WorkOrder에서 품목 자동 분류 불가 |
| 작업 단계 | Process와 WorkOrder 각각 별도 정의 | 데이터 중복 |
| 메타데이터 | Process에 풍부한 정보 (인원, 설비, 템플릿) | WorkOrder에서 미활용 |
---
### 3.3 🟡 공정관리 → 수주관리 연동 (연결 없음)
**현재 상태**:
- Process와 Order 간 직접적인 연결 관계 없음
- 이는 **의도된 설계**로 보임 (공정은 생산 단계에서 적용)
---
## 4. 문제점 요약
### 4.1 핵심 문제: process_type 이중 정의
```
┌─────────────────────────────────────────────────────────────────┐
│ 🔴 핵심 문제 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 공정관리(Process)와 작업지시(WorkOrder)가 │
│ 동일한 필드명(process_type)을 사용하지만 │
│ 완전히 다른 값 체계와 목적을 가지고 있음 │
│ │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ Process │ ❌ │ WorkOrder │ │
│ │ (생산/검사) │ ─────── │ (screen/slat) │ │
│ └─────────────┘ 연결없음 └─────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
```
### 4.2 문제 유형 분류
| # | 문제 | 심각도 | 영향 |
|---|------|:------:|------|
| 1 | process_type 값 체계 불일치 | 🔴 높음 | 데이터 일관성, 확장성 |
| 2 | Process ↔ WorkOrder FK 부재 | 🔴 높음 | 메타데이터 활용 불가 |
| 3 | 공정 정보 중복 정의 | 🟡 중간 | 유지보수 복잡성 |
| 4 | 새 공정 추가 시 코드 수정 필요 | 🟡 중간 | 확장성 제한 |
---
## 5. 해결 방안 (검토 필요)
### 5.1 Option A: 현행 유지 (의도된 분리)
**전제**: 공정관리와 작업지시가 **서로 다른 도메인**임을 인정
```
공정관리 (Process) 작업지시 (WorkOrder)
───────────────── ─────────────────
목적: 품목 분류 자동화 목적: 실제 생산 작업 관리
대상: 모든 품목 유형 대상: 특화 제조품 (스크린/슬랫/절곡)
사용자: 품질/물류팀 사용자: 생산팀
```
**장점**:
- 현재 코드 변경 불필요
- 각 도메인의 독립성 유지
**단점**:
- `process_type` 필드명 혼란 지속
- 공정 메타데이터 재활용 불가
**권장 조치**:
- WorkOrder의 `process_type``manufacturing_type` 또는 `product_line`으로 **리네이밍**
- 문서에 두 개념의 차이 명확히 기술
---
### 5.2 Option B: 통합 연결 (FK 추가)
**전제**: 공정관리가 작업지시의 **상위 템플릿** 역할을 해야 함
```
Process (공정 템플릿)
│ process_id (FK)
WorkOrder (작업지시)
```
**필요 변경**:
1. `work_orders` 테이블에 `process_id` FK 추가
2. Process 모델에 제조 공정 유형 추가 (screen, slat, bending)
3. WorkOrder 생성 시 Process 선택 UI 추가
4. 공정별 메타데이터 (작업단계, 인원, 설비) 자동 적용
**장점**:
- 데이터 일관성 확보
- 공정 메타데이터 재활용
- 새 공정 추가 시 코드 수정 불필요
**단점**:
- DB 마이그레이션 필요
- 기존 데이터 마이그레이션 필요
- API 및 프론트엔드 수정 필요
---
### 5.3 Option C: 하이브리드 (권장)
**전제**: 점진적 통합으로 위험 최소화
**Phase 1**: 명명 정리 (즉시)
- WorkOrder의 `process_type``manufacturing_type` 리네이밍
- 문서 정리 및 팀 공유
**Phase 2**: 연결 준비 (중기)
- Process 모델에 `is_manufacturing` 플래그 추가
- 제조 전용 공정 구분 (screen, slat, bending)
**Phase 3**: 통합 (장기)
- WorkOrder에 `process_id` FK 추가 (optional)
- 메타데이터 연동 구현
---
## 6. 컨펌 결과 (✅ 결정 완료 → 재결정)
| # | 항목 | ~~이전 결정~~ | **최종 결정** | 결정일 |
|---|------|-------------|--------------|--------|
| 1 | **설계 방향** | ~~Option C (하이브리드)~~ | **Option B** (FK 추가) | 2025-01-09 |
| 2 | **필드 변경** | ~~리네이밍만~~ | **FK로 변경** | 2025-01-09 |
| 3 | **FK 추가 여부** | ~~❌ 불필요~~ | **✅ 필요** - 공정 테이블 FK | 2025-01-09 |
| 4 | **도메인 연결** | ~~독립 도메인~~ | **Process → WorkOrder 연결** | 2025-01-09 |
### 6.0 재결정 사유
```
┌─────────────────────────────────────────────────────────────────┐
│ 💡 핵심 발견 (공정 관리 페이지 확인) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ WorkOrder.process_type 값 (screen, slat, bending)이 │
│ 실제로는 공정 관리 페이지에서 등록된 "공정명"임을 확인 │
│ │
│ /master-data/process-management 등록 현황: │
│ - P-001: 슬랫 (slat) │
│ - P-002: 스크린 (screen) │
│ │
│ ∴ 하드코딩된 값이 아닌 공정 테이블 FK로 연결해야 함 │
│ │
└─────────────────────────────────────────────────────────────────┘
```
### 6.1 다음 작업 (FK 추가 구현)
```
WorkOrder `process_type` (varchar) → `process_id` (FK) 변경 작업 범위:
1. DB 마이그레이션
- work_orders.process_type (varchar) 제거
- work_orders.process_id (FK) 추가 → processes.id 참조
- 기존 데이터 마이그레이션 (screen→P-002, slat→P-001, bending→신규등록)
2. API 수정
- api/app/Models/Production/WorkOrder.php
- PROCESS_* 상수 제거
- process_type 필드 → process_id FK 필드
- process() BelongsTo 관계 추가
- api/app/Services/OrderService.php (생산지시 생성 로직)
- api/app/Services/WorkOrderService.php (비즈니스 로직)
- 관련 FormRequest, Resource 클래스
3. Frontend 수정
- react/src/components/production/WorkOrders/types.ts
- ProcessType enum 제거
- process_id: number 필드 추가
- process 관계 데이터 타입 추가
- 관련 컴포넌트 (actions.ts, components)
- 공정 선택 드롭다운 → API에서 공정 목록 조회
```
---
## 7. 참고 문서
- **공정관리 계획**: `docs/plans/process-management-plan.md`
- **수주관리 계획**: `docs/plans/order-management-plan.md`
- **작업지시 계획**: `docs/plans/work-order-plan.md`
- **시스템 아키텍처**: `docs/architecture/system-overview.md`
- **품질 체크리스트**: `docs/standards/quality-checklist.md`
---
## 8. 분석 파일 참조
### 8.1 API 레이어
| 파일 | 역할 |
|------|------|
| `api/app/Http/Controllers/Api/V1/QuoteController.php` | 견적 CRUD |
| `api/app/Http/Controllers/Api/V1/OrderController.php` | 수주 CRUD + 생산지시 생성 |
| `api/app/Http/Controllers/V1/ProcessController.php` | 공정 CRUD |
| `api/app/Http/Controllers/Api/V1/WorkOrderController.php` | 작업지시 CRUD |
| `api/app/Services/QuoteService.php` | 견적 비즈니스 로직 |
| `api/app/Services/OrderService.php` | 견적→수주 변환, 수주→작업지시 연동 |
| `api/app/Services/WorkOrderService.php` | 작업지시 비즈니스 로직 |
### 8.2 모델 레이어
| 파일 | 핵심 필드 |
|------|----------|
| `api/app/Models/Quote/Quote.php` | `status` (draft/sent/approved/finalized/converted), `product_category` |
| `api/app/Models/Order.php` | `status_code`, `quote_id` (FK) |
| `api/app/Models/Process.php` | `process_type` (생산/검사/포장/조립) |
| `api/app/Models/Production/WorkOrder.php` | `process_type` (screen/slat/bending), `sales_order_id` (FK) |
### 8.3 프론트엔드 레이어
| 파일 | 역할 |
|------|------|
| `react/src/components/quotes/types.ts` | Quote 타입 정의 |
| `react/src/components/quotes/QuotationSelectDialog.tsx` | 견적 선택 UI |
| `react/src/types/process.ts` | Process 타입 정의 |
| `react/src/components/production/WorkOrders/types.ts` | WorkOrder 타입 정의 |
| `react/src/components/orders/actions.ts` | Order API 호출 + 생산지시 생성 + 견적변환 |
| `react/src/components/process-management/actions.ts` | Process API 호출 |
| `react/src/components/production/WorkOrders/actions.ts` | WorkOrder API 호출 |
---
## 9. 변경 이력
| 날짜 | 항목 | 변경 내용 | 파일 | 승인 |
|------|------|----------|------|------|
| 2025-01-09 | 문서 생성 | MES 통합 흐름 분석 완료 | - | - |
| 2025-01-09 | 견적 분석 추가 | Quote → Order 연동 분석 (섹션 3.0) | - | - |
| 2025-01-09 | 결정 반영 | Option C 선택, 리네이밍 진행, FK 미추가 결정 | - | ✅ |
| 2025-01-09 | **재결정** | 공정 관리 페이지 확인 후 **Option B (FK 추가)로 변경** | - | ✅ |
### 9.1 재결정 상세
**재결정 배경**:
- 공정 관리 페이지(`/master-data/process-management`) 실제 확인
- `screen`, `slat`, `bending` 값이 공정명(Process Name)임을 확인
- P-001: 슬랫, P-002: 스크린 등록 확인
**이전 결정 → 최종 결정**:
| 항목 | 이전 | 최종 |
|------|------|------|
| 설계 방향 | Option C (하이브리드) | **Option B (FK 추가)** |
| 필드 처리 | 리네이밍만 | **FK로 변경** |
| FK 추가 | 불필요 | **필요** |
| 도메인 관계 | 독립 | **연결** |
---
*이 문서는 /plan 스킬로 생성되었습니다.*

View File

@@ -0,0 +1,837 @@
# MNG 품목관리 - 견적수식 엔진(FormulaEvaluatorService) 연동 계획
> **작성일**: 2026-02-19
> **목적**: 가변사이즈 완제품(FG) 선택 시 오픈사이즈(W,H) 입력 → FormulaEvaluatorService로 동적 자재 산출 → 중앙 패널에 트리 표시
> **기준 문서**: docs/plans/mng-item-management-plan.md, api/app/Services/Quote/FormulaEvaluatorService.php
> **선행 작업**: 3-Panel 품목관리 페이지 구현 완료 (Phase 1~2 of mng-item-management-plan.md)
> **상태**: 🔄 진행중
---
## 📍 현재 진행 상태
| 항목 | 내용 |
|------|------|
| **마지막 완료 작업** | Phase 2.5: 로딩/에러 상태 처리 (전체 구현 완료) |
| **다음 작업** | 검증 (브라우저 테스트) |
| **진행률** | 8/8 (100%) |
| **마지막 업데이트** | 2026-02-19 |
---
## 1. 개요
### 1.1 배경
MNG 품목관리 페이지(`mng.sam.kr/item-management`)에서 완제품(FG) `FG-KQTS01-벽면형-SUS`를 선택하면 `items.bom` JSON에 등록된 정적 BOM(PT 2개: 가이드레일, 하단마감재)만 표시된다.
그러나 견적관리(`dev.sam.kr/sales/quote-management/46`)에서는 `FormulaEvaluatorService`가 W=3000, H=3000 입력으로 17종 51개의 자재를 동적 산출한다.
**핵심 문제**: items.bom(정적)과 FormulaEvaluatorService(동적) 두 시스템이 분리되어 있어, 품목관리 페이지에서 실제 필요 자재를 볼 수 없다.
**해결**: 가변사이즈 품목(`item_details.is_variable_size = true`) 선택 시 오픈사이즈(W, H) 입력 UI를 제공하고, API의 기존 엔드포인트(`POST /api/v1/quotes/calculate/bom`)를 MNG에서 HTTP로 호출하여 산출 결과를 중앙 패널에 표시한다.
### 1.2 기준 원칙
```
┌─────────────────────────────────────────────────────────────────┐
│ 🎯 핵심 원칙 │
├─────────────────────────────────────────────────────────────────┤
│ - MNG에서 마이그레이션 파일 생성 금지 (API에서만) │
│ - MNG ↔ API 통신은 HTTP API 호출 (코드 공유/직접 서비스 호출 X) │
│ - 기존 API 엔드포인트 재사용 (POST /api/v1/quotes/calculate/bom)│
│ - Docker nginx 내부 라우팅 + SSL 우회 패턴 사용 │
│ - Blade + HTMX + Tailwind + Vanilla JS (Alpine.js 미사용) │
│ - 정적 BOM과 수식 산출 결과를 탭으로 전환 가능하게 │
│ - Controller에서 직접 DB 쿼리 금지 (Service-First) │
│ - Controller에서 직접 validate() 금지 (FormRequest 필수) │
└─────────────────────────────────────────────────────────────────┘
```
### 1.3 변경 승인 정책
| 분류 | 예시 | 승인 |
|------|------|------|
| ✅ 즉시 가능 | 서비스 생성, 컨트롤러 메서드 추가, Blade 수정, JS 추가 | 불필요 |
| ⚠️ 컨펌 필요 | API 라우트 추가, 기존 Blade 구조 변경 | **필수** |
| 🔴 금지 | mng에서 마이그레이션 생성, API 소스 수정 | 별도 협의 |
### 1.4 MNG 절대 금지 규칙
```
❌ mng/database/migrations/ 에 파일 생성 금지
❌ docker exec sam-mng-1 php artisan migrate 실행 금지
❌ php artisan db:seed --class=*MenuSeeder 실행 금지
❌ Controller에서 직접 DB 쿼리 금지 (Service-First)
❌ Controller에서 직접 validate() 금지 (FormRequest 필수)
❌ api/ 프로젝트 소스 코드 수정 금지
```
---
## 2. 대상 범위
### 2.1 Phase 1: MNG 백엔드 (HTTP API 호출 서비스)
| # | 작업 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 1.1 | FormulaApiService 생성 (MNG→API HTTP 호출 래퍼) | ✅ | 신규 파일 |
| 1.2 | ItemManagementApiController에 calculateFormula 메서드 추가 | ✅ | 기존 파일 수정 |
| 1.3 | API 라우트 추가 (POST /api/admin/items/{id}/calculate-formula) | ✅ | 기존 파일 수정 |
### 2.2 Phase 2: MNG 프론트엔드 (UI 연동)
| # | 작업 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 2.1 | 중앙 패널 헤더에 탭 UI 추가 (정적 BOM / 수식 산출) | ✅ | index.blade.php 수정 |
| 2.2 | 오픈사이즈 입력 폼 (W, H, 수량) + 산출 버튼 | ✅ | index.blade.php 수정 |
| 2.3 | 수식 산출 결과 트리 렌더링 (카테고리 그룹별) | ✅ | JS 추가 |
| 2.4 | 가변사이즈 품목 감지 → 자동 탭 전환 | ✅ | item-detail.blade.php + JS 수정 |
| 2.5 | 로딩/에러 상태 처리 | ✅ | JS 추가 |
---
## 3. 이미 구현된 코드 (선행 작업 - 수정 대상)
> 새 세션에서 현재 코드 상태를 파악할 수 있도록 이미 존재하는 파일 전체 목록과 핵심 구조를 기록.
### 3.1 파일 구조 (이미 존재)
```
mng/
├── app/
│ ├── Http/Controllers/
│ │ ├── ItemManagementController.php # Web (HX-Redirect 패턴)
│ │ └── Api/Admin/
│ │ └── ItemManagementApiController.php # API (index, bomTree, detail)
│ ├── Models/
│ │ ├── Items/
│ │ │ ├── Item.php # BelongsToTenant, 관계, 스코프, 상수
│ │ │ └── ItemDetail.php # 1:1 확장 (is_variable_size 필드 포함)
│ │ └── Commons/
│ │ └── File.php # 파일 모델
│ ├── Services/
│ │ └── ItemManagementService.php # getItemList, getBomTree, getItemDetail
│ └── Traits/
│ └── BelongsToTenant.php # 테넌트 격리 Trait
├── resources/views/item-management/
│ ├── index.blade.php # 3-Panel 메인 (★ 수정 대상)
│ └── partials/
│ ├── item-list.blade.php # 좌측 패널 (변경 없음)
│ ├── bom-tree.blade.php # 중앙 패널 초기 상태 (변경 없음)
│ └── item-detail.blade.php # 우측 패널 (★ 수정 대상)
├── routes/
│ ├── web.php # Route: GET /item-management (변경 없음)
│ └── api.php # Route: items group (★ 수정 대상 - 라우트 추가)
└── config/
└── api-explorer.php # FLOW_TESTER_API_KEY 설정 참조
```
### 3.2 현재 ItemManagementApiController 전체 (수정 대상)
```php
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Controller;
use App\Services\ItemManagementService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class ItemManagementApiController extends Controller
{
public function __construct(
private readonly ItemManagementService $service
) {}
public function index(Request $request): View
{
$items = $this->service->getItemList([
'search' => $request->input('search'),
'item_type' => $request->input('item_type'),
'per_page' => $request->input('per_page', 50),
]);
return view('item-management.partials.item-list', compact('items'));
}
public function bomTree(int $id, Request $request): JsonResponse
{
$maxDepth = $request->input('max_depth', 10);
$tree = $this->service->getBomTree($id, $maxDepth);
return response()->json($tree);
}
public function detail(int $id): View
{
$data = $this->service->getItemDetail($id);
return view('item-management.partials.item-detail', [
'item' => $data['item'],
'bomChildren' => $data['bom_children'],
]);
}
}
```
### 3.3 현재 API 라우트 (items 그룹, mng/routes/api.php:866~)
```php
Route::middleware(['web', 'auth', 'hq.member'])->prefix('admin/items')->name('api.admin.items.')->group(function () {
Route::get('/search', [ItemApiController::class, 'search'])->name('search');
// 품목관리 페이지 API
Route::get('/', [ItemManagementApiController::class, 'index'])->name('index');
Route::get('/{id}/bom-tree', [ItemManagementApiController::class, 'bomTree'])->name('bom-tree');
Route::get('/{id}/detail', [ItemManagementApiController::class, 'detail'])->name('detail');
// ★ 여기에 calculate-formula 라우트 추가 예정
});
```
### 3.4 현재 index.blade.php 중앙 패널 (수정 대상 부분)
```html
<!-- 현재 중앙 패널 -->
<div class="flex-1 bg-white rounded-lg shadow-sm flex flex-col overflow-hidden">
<div class="px-4 py-3 border-b border-gray-200">
<h2 class="text-sm font-semibold text-gray-700">BOM 구성 (재귀 트리)</h2>
</div>
<div id="bom-tree-container" class="flex-1 overflow-y-auto p-4">
<p class="text-gray-400 text-center py-10">좌측에서 품목을 선택하세요.</p>
</div>
</div>
```
### 3.5 현재 JS 구조 (index.blade.php @push('scripts'))
핵심 함수:
- `loadItemList()` - 좌측 품목 리스트 HTMX 로드
- `selectItem(itemId, updateTree)` - 품목 선택 (좌측 하이라이트 + 중앙 트리 fetch + 우측 상세 HTMX)
- `selectTreeNode(itemId)` - 중앙 트리 노드 클릭 (우측만 갱신, 트리 유지)
- `renderBomTree(node, container)` - BOM 트리 재귀 렌더링
- `getTypeBadgeClass(type)` - 유형별 뱃지 CSS 클래스
### 3.6 테넌트 필터링 패턴 (중요)
MNG의 HQ 관리자는 헤더에서 테넌트를 선택하며, `session('selected_tenant_id')`에 저장된다.
그러나 `BelongsToTenant``TenantScope``request->attributes`, `X-TENANT-ID 헤더`, `auth user`에서 tenant_id를 읽으므로 **세션 값과 불일치**할 수 있다.
**따라서 Service에서는 `Item::withoutGlobalScopes()->where('tenant_id', session('selected_tenant_id'))` 패턴을 사용한다.**
```php
// ✅ 올바른 패턴 (현재 ItemManagementService에서 사용 중)
Item::withoutGlobalScopes()
->where('tenant_id', session('selected_tenant_id'))
->findOrFail($id);
// ❌ 잘못된 패턴 (HQ 관리자 세션과 불일치)
Item::findOrFail($id); // TenantScope가 auth user의 tenant_id 사용
```
---
## 4. 상세 작업 내용
### 4.1 Phase 1: MNG 백엔드
#### 1.1 FormulaApiService 생성
**파일 경로**: `mng/app/Services/FormulaApiService.php` (신규 생성)
**역할**: MNG에서 API 프로젝트의 `POST /api/v1/quotes/calculate/bom` 엔드포인트를 HTTP로 호출하는 래퍼
**호출 대상 API 엔드포인트 상세**:
```
POST /api/v1/quotes/calculate/bom
라우트 정의: api/routes/api/v1/sales.php:64
미들웨어: 글로벌(ApiKeyMiddleware, CorsMiddleware, ApiRateLimiter)
FormRequest: QuoteBomCalculateRequest (authorize = true, 제한 없음)
```
**API 인증 요구사항** (확인 완료):
| 헤더 | 필수 | 설명 |
|------|:----:|------|
| `X-API-KEY` | ✅ 필수 | `api_keys` 테이블에 `is_active=true`로 등록된 키 |
| `Authorization: Bearer {token}` | ❌ 선택 | Sanctum 토큰, 있으면 tenant_id 자동 설정 |
| `X-TENANT-ID` | ❌ 선택 | 테넌트 식별 (Bearer 없을 때 대안) |
**API Key 취득 방법**: `env('FLOW_TESTER_API_KEY')` (mng/.env에 설정됨, `config/api-explorer.php:26`에서 참조)
**요청 페이로드**:
```json
{
"finished_goods_code": "FG-KQTS01",
"variables": {
"W0": 3000,
"H0": 3000,
"QTY": 1
},
"tenant_id": 287
}
```
**응답 구조** (FormulaEvaluatorService::calculateBomWithDebug 반환값):
```json
{
"success": true,
"finished_goods": { "code": "FG-KQTS01", "name": "벽면형-SUS", "id": 123 },
"variables": { "W0": 3000, "H0": 3000, "QTY": 1 },
"items": [
{
"item_code": "PT-강재-C형강",
"item_name": "C형강 65×32×10t",
"specification": "65×32×10t",
"unit": "mm",
"quantity": 6038,
"unit_price": 1.0,
"total_price": 6038,
"category_group": "steel"
}
],
"grouped_items": {
"steel": [ ... ],
"part": [ ... ],
"motor": [ ... ]
},
"subtotals": { "steel": 123456, "part": 78900, "motor": 50000 },
"grand_total": 252356,
"debug_steps": [ ... ]
}
```
**구현 코드**:
```php
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class FormulaApiService
{
/**
* API 서버의 FormulaEvaluatorService를 HTTP로 호출하여 BOM 산출
*
* Docker 내부 통신 패턴:
* - URL: https://nginx/api/v1/quotes/calculate/bom (Docker nginx 컨테이너)
* - Host 헤더: api.sam.kr (nginx가 올바른 서버 블록으로 라우팅)
* - SSL 우회: withoutVerifying() (내부 자체 서명 인증서)
* - 인증: X-API-KEY 헤더 (FLOW_TESTER_API_KEY 환경변수)
*/
public function calculateBom(string $finishedGoodsCode, array $variables, int $tenantId): array
{
try {
$apiKey = config('api-explorer.default_environments.0.api_key')
?: env('FLOW_TESTER_API_KEY', '');
$response = Http::timeout(30)
->withoutVerifying()
->withHeaders([
'Host' => 'api.sam.kr',
'Accept' => 'application/json',
'Content-Type' => 'application/json',
'X-API-KEY' => $apiKey,
'X-TENANT-ID' => (string) $tenantId,
])
->post('https://nginx/api/v1/quotes/calculate/bom', [
'finished_goods_code' => $finishedGoodsCode,
'variables' => $variables,
'tenant_id' => $tenantId,
]);
if ($response->successful()) {
$json = $response->json();
// ApiResponse::handle()는 {success, message, data} 구조로 래핑
return $json['data'] ?? $json;
}
Log::warning('FormulaApiService: API 호출 실패', [
'status' => $response->status(),
'body' => $response->body(),
'code' => $finishedGoodsCode,
]);
return [
'success' => false,
'error' => 'API 응답 오류: HTTP ' . $response->status(),
];
} catch (\Exception $e) {
Log::error('FormulaApiService: 예외 발생', [
'message' => $e->getMessage(),
'code' => $finishedGoodsCode,
]);
return [
'success' => false,
'error' => '수식 계산 서버 연결 실패: ' . $e->getMessage(),
];
}
}
}
```
**트러블슈팅 가이드**:
- `401 Unauthorized` → API Key 확인: `docker exec sam-mng-1 php artisan tinker --execute="echo env('FLOW_TESTER_API_KEY');"`
- `Connection refused` → nginx 컨테이너 확인: `docker ps | grep nginx`
- `SSL certificate problem``withoutVerifying()` 누락 확인
- `422 Validation` → finished_goods_code가 items 테이블에 존재하는지 확인
#### 1.2 ItemManagementApiController::calculateFormula 추가
**파일**: `mng/app/Http/Controllers/Api/Admin/ItemManagementApiController.php`
**변경**: 기존 컨트롤러에 메서드 1개 추가 + use 문 추가
```php
// 파일 상단 use 추가
use App\Services\FormulaApiService;
// 기존 메서드 아래에 추가
/**
* 수식 기반 BOM 산출 (API 서버의 FormulaEvaluatorService HTTP 호출)
*/
public function calculateFormula(Request $request, int $id): JsonResponse
{
$item = \App\Models\Items\Item::withoutGlobalScopes()
->where('tenant_id', session('selected_tenant_id'))
->findOrFail($id);
$width = (int) $request->input('width', 1000);
$height = (int) $request->input('height', 1000);
$qty = (int) $request->input('qty', 1);
$variables = [
'W0' => $width,
'H0' => $height,
'QTY' => $qty,
];
$formulaService = new FormulaApiService();
$result = $formulaService->calculateBom(
$item->code,
$variables,
(int) session('selected_tenant_id')
);
return response()->json($result);
}
```
#### 1.3 API 라우트 추가
**파일**: `mng/routes/api.php` (라인 866~ 기존 items 그룹 내)
**추가 위치**: 기존 detail 라우트 아래
```php
// 기존 라우트 아래에 추가
Route::post('/{id}/calculate-formula', [ItemManagementApiController::class, 'calculateFormula'])->name('calculate-formula');
```
---
### 4.2 Phase 2: MNG 프론트엔드
#### 2.1 중앙 패널 탭 UI
**수정 파일**: `mng/resources/views/item-management/index.blade.php`
**변경 대상 (현재 HTML)**:
```html
<div class="px-4 py-3 border-b border-gray-200">
<h2 class="text-sm font-semibold text-gray-700">BOM 구성 (재귀 트리)</h2>
</div>
<div id="bom-tree-container" class="flex-1 overflow-y-auto p-4">
```
**변경 후**:
```html
<div class="px-4 py-3 border-b border-gray-200">
<div class="flex items-center gap-1">
<button type="button" id="tab-static-bom"
class="bom-tab px-3 py-1.5 text-xs font-medium rounded-md bg-blue-100 text-blue-800"
onclick="switchBomTab('static')">
정적 BOM
</button>
<button type="button" id="tab-formula-bom"
class="bom-tab px-3 py-1.5 text-xs font-medium rounded-md bg-gray-100 text-gray-600 hover:bg-gray-200"
onclick="switchBomTab('formula')"
style="display:none;">
수식 산출
</button>
</div>
</div>
<!-- 수식 산출 입력 폼 (가변사이즈 품목 선택 시에만 표시) -->
<div id="formula-input-panel" style="display:none;" class="p-4 bg-gray-50 border-b border-gray-200">
<div class="flex items-end gap-3">
<div>
<label class="block text-xs text-gray-500 mb-1">폭 W (mm)</label>
<input type="number" id="input-width" value="1000" min="100" max="10000" step="1"
class="w-24 px-2 py-1.5 text-sm border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none">
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">높이 H (mm)</label>
<input type="number" id="input-height" value="1000" min="100" max="10000" step="1"
class="w-24 px-2 py-1.5 text-sm border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none">
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">수량</label>
<input type="number" id="input-qty" value="1" min="1" max="100" step="1"
class="w-16 px-2 py-1.5 text-sm border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none">
</div>
<button type="button" id="btn-calculate" onclick="calculateFormula()"
class="px-4 py-1.5 text-sm font-medium text-white bg-blue-600 rounded hover:bg-blue-700 transition-colors">
산출
</button>
</div>
</div>
<!-- 정적 BOM 영역 -->
<div id="bom-tree-container" class="flex-1 overflow-y-auto p-4">
<p class="text-gray-400 text-center py-10">좌측에서 품목을 선택하세요.</p>
</div>
<!-- 수식 산출 결과 영역 (초기 숨김) -->
<div id="formula-result-container" class="flex-1 overflow-y-auto p-4" style="display:none;">
<p class="text-gray-400 text-center py-10">오픈사이즈를 입력하고 산출 버튼을 클릭하세요.</p>
</div>
```
#### 2.2 item-detail.blade.php에 메타 데이터 추가
**수정 파일**: `mng/resources/views/item-management/partials/item-detail.blade.php`
**파일 맨 위에 추가** (기존 `<div class="space-y-4">` 앞):
```html
<!-- 품목 메타 데이터 (JS에서 가변사이즈 감지용) -->
<div id="item-meta-data"
data-item-id="{{ $item->id }}"
data-item-code="{{ $item->code }}"
data-is-variable-size="{{ $item->details?->is_variable_size ? 'true' : 'false' }}"
style="display:none;"></div>
```
#### 2.3 JS 추가 (index.blade.php @push('scripts'))
**기존 IIFE 내부에 추가할 변수와 함수**:
```javascript
// ── 추가 변수 ──
let currentBomTab = 'static'; // 'static' | 'formula'
let currentItemId = null;
let currentItemCode = null;
// ── 탭 전환 ──
window.switchBomTab = function(tab) {
currentBomTab = tab;
// 탭 버튼 스타일
document.querySelectorAll('.bom-tab').forEach(btn => {
btn.classList.remove('bg-blue-100', 'text-blue-800');
btn.classList.add('bg-gray-100', 'text-gray-600');
});
const activeBtn = document.getElementById(tab === 'static' ? 'tab-static-bom' : 'tab-formula-bom');
if (activeBtn) {
activeBtn.classList.remove('bg-gray-100', 'text-gray-600');
activeBtn.classList.add('bg-blue-100', 'text-blue-800');
}
// 콘텐츠 영역 전환
document.getElementById('bom-tree-container').style.display = (tab === 'static') ? '' : 'none';
document.getElementById('formula-input-panel').style.display = (tab === 'formula') ? '' : 'none';
document.getElementById('formula-result-container').style.display = (tab === 'formula') ? '' : 'none';
};
// ── 가변사이즈 탭 표시/숨김 ──
function showFormulaTab() {
document.getElementById('tab-formula-bom').style.display = '';
switchBomTab('formula'); // 자동으로 수식 산출 탭으로 전환
}
function hideFormulaTab() {
document.getElementById('tab-formula-bom').style.display = 'none';
document.getElementById('formula-input-panel').style.display = 'none';
document.getElementById('formula-result-container').style.display = 'none';
switchBomTab('static');
}
// ── 상세 로드 완료 후 가변사이즈 감지 ──
document.body.addEventListener('htmx:afterSwap', function(event) {
if (event.detail.target.id === 'item-detail') {
const meta = document.getElementById('item-meta-data');
if (meta) {
currentItemId = meta.dataset.itemId;
currentItemCode = meta.dataset.itemCode;
if (meta.dataset.isVariableSize === 'true') {
showFormulaTab();
} else {
hideFormulaTab();
}
}
}
});
// ── 수식 산출 API 호출 ──
window.calculateFormula = function() {
if (!currentItemId) return;
const width = parseInt(document.getElementById('input-width').value) || 1000;
const height = parseInt(document.getElementById('input-height').value) || 1000;
const qty = parseInt(document.getElementById('input-qty').value) || 1;
// 입력값 범위 검증
if (width < 100 || width > 10000 || height < 100 || height > 10000) {
alert('폭과 높이는 100~10000 범위로 입력하세요.');
return;
}
const container = document.getElementById('formula-result-container');
container.innerHTML = '<div class="flex justify-center py-10"><div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div></div>';
fetch(`/api/admin/items/${currentItemId}/calculate-formula`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken,
},
body: JSON.stringify({ width, height, qty }),
})
.then(res => res.json())
.then(data => {
if (data.success === false) {
container.innerHTML = `
<div class="text-center py-10">
<p class="text-red-500 text-sm mb-2">${data.error || '산출 실패'}</p>
<button onclick="calculateFormula()" class="text-blue-600 text-sm hover:underline">다시 시도</button>
</div>`;
return;
}
renderFormulaTree(data, container);
})
.catch(err => {
container.innerHTML = `
<div class="text-center py-10">
<p class="text-red-500 text-sm mb-2">서버 연결 실패</p>
<button onclick="calculateFormula()" class="text-blue-600 text-sm hover:underline">다시 시도</button>
</div>`;
});
};
// ── 수식 산출 결과 트리 렌더링 ──
function renderFormulaTree(data, container) {
container.innerHTML = '';
// 카테고리 그룹 한글 매핑
const groupLabels = { steel: '강재', part: '부품', motor: '모터/컨트롤러' };
const groupIcons = { steel: '🏗️', part: '🔧', motor: '⚡' };
const groupedItems = data.grouped_items || {};
// 합계 영역
if (data.grand_total) {
const totalDiv = document.createElement('div');
totalDiv.className = 'mb-4 p-3 bg-blue-50 rounded-lg flex justify-between items-center';
totalDiv.innerHTML = `
<span class="text-sm font-medium text-blue-800">
${data.finished_goods?.name || ''} (${data.finished_goods?.code || ''})
<span class="text-xs text-blue-600 ml-2">W:${data.variables?.W0} H:${data.variables?.H0}</span>
</span>
<span class="text-sm font-bold text-blue-900">합계: ${Number(data.grand_total).toLocaleString()}원</span>
`;
container.appendChild(totalDiv);
}
// 카테고리 그룹별 렌더링
Object.entries(groupedItems).forEach(([group, items]) => {
if (!items || items.length === 0) return;
const groupDiv = document.createElement('div');
groupDiv.className = 'mb-3';
const subtotal = data.subtotals?.[group] || 0;
// 그룹 헤더
const header = document.createElement('div');
header.className = 'flex items-center gap-2 py-2 px-3 bg-gray-50 rounded-t-lg cursor-pointer';
header.innerHTML = `
<span class="text-xs text-gray-400">▼</span>
<span>${groupIcons[group] || '📦'}</span>
<span class="text-sm font-semibold text-gray-700">${groupLabels[group] || group}</span>
<span class="text-xs text-gray-500">(${items.length}건)</span>
<span class="ml-auto text-xs font-medium text-gray-600">소계: ${Number(subtotal).toLocaleString()}원</span>
`;
const listDiv = document.createElement('div');
listDiv.className = 'border border-gray-100 rounded-b-lg divide-y divide-gray-50';
// 그룹 접기/펼치기
header.onclick = function() {
const toggle = header.querySelector('.text-gray-400');
if (listDiv.style.display === 'none') {
listDiv.style.display = '';
toggle.textContent = '▼';
} else {
listDiv.style.display = 'none';
toggle.textContent = '▶';
}
};
// 아이템 목록
items.forEach(item => {
const row = document.createElement('div');
row.className = 'flex items-center gap-2 py-1.5 px-3 hover:bg-gray-50 cursor-pointer text-sm';
row.innerHTML = `
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">PT</span>
<span class="font-mono text-xs text-gray-500 w-32 truncate">${item.item_code || ''}</span>
<span class="text-gray-700 flex-1 truncate">${item.item_name || ''}</span>
<span class="text-xs text-gray-500 w-16 text-right">${item.quantity || 0} ${item.unit || ''}</span>
<span class="text-xs text-blue-600 font-medium w-20 text-right">${Number(item.total_price || 0).toLocaleString()}원</span>
`;
// 아이템 클릭 시 items 테이블에서 해당 코드로 검색하여 상세 표시
row.onclick = function() {
// item_code로 좌측 검색 → 해당 품목 상세 로드
const searchInput = document.getElementById('item-search');
searchInput.value = item.item_code;
loadItemList();
};
listDiv.appendChild(row);
});
groupDiv.appendChild(header);
groupDiv.appendChild(listDiv);
container.appendChild(groupDiv);
});
if (Object.keys(groupedItems).length === 0) {
container.innerHTML = '<p class="text-gray-400 text-center py-10">산출된 자재가 없습니다.</p>';
}
}
```
---
## 5. 컨펌 대기 목록
| # | 항목 | 변경 내용 | 영향 범위 | 상태 |
|---|------|----------|----------|------|
| 1 | ~~API 인증 방식~~ | X-API-KEY 필수 (FLOW_TESTER_API_KEY) | MNG→API 통신 | ✅ 확인 완료 |
| 2 | API 라우트 추가 | POST /api/admin/items/{id}/calculate-formula | mng/routes/api.php | ✅ 즉시 가능 |
---
## 6. 변경 이력
| 날짜 | 항목 | 변경 내용 | 파일 | 승인 |
|------|------|----------|------|------|
| 2026-02-19 | - | 계획 문서 초안 작성 | - | - |
| 2026-02-19 | - | 자기완결성 보강 (기존 코드 현황, API 인증 분석, 트러블슈팅) | - | - |
| 2026-02-19 | 1.1~1.3 | Phase 1 백엔드 구현 완료 (FormulaApiService, Controller, Route) | FormulaApiService.php, ItemManagementApiController.php, api.php | ✅ |
| 2026-02-19 | 2.1~2.5 | Phase 2 프론트엔드 구현 완료 (탭 UI, 입력 폼, 트리 렌더링, 감지, 에러) | index.blade.php, item-detail.blade.php | ✅ |
---
## 7. 참고 문서
- **기존 품목관리 계획**: `docs/plans/mng-item-management-plan.md`
- **FormulaEvaluatorService**: `api/app/Services/Quote/FormulaEvaluatorService.php`
- 메서드: `calculateBomWithDebug(string $finishedGoodsCode, array $inputVariables, ?int $tenantId): array`
- tenant_id=287 자동 감지 → KyungdongFormulaHandler 라우팅
- **KyungdongFormulaHandler**: `api/app/Services/Quote/Handlers/KyungdongFormulaHandler.php`
- `calculateDynamicItems(array $inputs)` → steel(10종), part(3종), motor/controller 산출
- **API 라우트**: `api/routes/api/v1/sales.php:64``QuoteController::calculateBom`
- **QuoteBomCalculateRequest**: `api/app/Http/Requests/V1/QuoteBomCalculateRequest.php`
- `finished_goods_code` (required|string)
- `variables` (required|array), `variables.W0` (required|numeric), `variables.H0` (required|numeric)
- `tenant_id` (nullable|integer)
- **MNG-API HTTP 패턴**: `mng/app/Services/FlowTester/HttpClient.php`
- **API Key 설정**: `mng/config/api-explorer.php:26``env('FLOW_TESTER_API_KEY')`
- **현재 품목관리 뷰**: `mng/resources/views/item-management/index.blade.php`
- **MNG 프로젝트 규칙**: `mng/CLAUDE.md`
---
## 8. 세션 및 메모리 관리 정책
### 8.1 세션 시작 시 (Load Strategy)
```
1. 이 문서 읽기 (docs/plans/mng-item-formula-integration-plan.md)
2. 📍 현재 진행 상태 확인 → 다음 작업 파악
3. 섹션 3 "이미 구현된 코드" 확인 → 수정 대상 파일 파악
4. 필요시 Serena 메모리 로드:
read_memory("item-formula-state")
read_memory("item-formula-snapshot")
read_memory("item-formula-active-symbols")
```
### 8.2 작업 중 관리 (Context Defense)
| 컨텍스트 잔량 | Action | 내용 |
|--------------|--------|------|
| **30% 이하** | Snapshot | `write_memory("item-formula-snapshot", "코드변경+논의요약")` |
| **20% 이하** | Context Purge | `write_memory("item-formula-active-symbols", "주요 수정 파일/함수")` |
| **10% 이하** | Stop & Save | 최종 상태 저장 후 세션 교체 권고 |
---
## 9. 검증 결과
### 9.1 테스트 케이스
| # | 테스트 | 입력 | 예상 결과 | 실제 결과 | 상태 |
|---|--------|------|----------|----------|------|
| 1 | FG 가변사이즈 품목 선택 | FG-KQTS01 클릭 | 수식 산출 탭 자동 표시, 입력 폼 노출 | | ⏳ |
| 2 | 오픈사이즈 입력 후 산출 | W:3000, H:3000, QTY:1 | 17종 자재 트리 (steel/part/motor 그룹별), 소계/합계 표시 | | ⏳ |
| 3 | 비가변사이즈 품목 선택 | PT 품목 클릭 | 수식 산출 탭 숨김, 정적 BOM만 표시 | | ⏳ |
| 4 | 정적 BOM ↔ 수식 산출 탭 전환 | 탭 클릭 | 각 탭 콘텐츠 전환, 입력 폼 표시/숨김 | | ⏳ |
| 5 | 산출 결과에서 품목 클릭 | 트리 노드 클릭 | 좌측 검색에 품목코드 입력 → 품목 리스트 필터링 | | ⏳ |
| 6 | API Key 미설정 | FLOW_TESTER_API_KEY 없음 | 에러 메시지 "API 응답 오류: HTTP 401" + 재시도 버튼 | | ⏳ |
| 7 | 입력값 범위 초과 | W:0, H:-1 | alert 표시, API 호출 안 함 | | ⏳ |
| 8 | 서버 연결 실패 | nginx 중지 상태 | 에러 메시지 "서버 연결 실패" + 재시도 버튼 | | ⏳ |
### 9.2 성공 기준 달성 현황
| 기준 | 달성 | 비고 |
|------|------|------|
| FG 가변사이즈 품목에서 수식 산출 가능 | ⏳ | |
| 산출 결과가 견적관리와 동일한 17종 자재 표시 | ⏳ | |
| 정적 BOM과 수식 산출 탭 전환 작동 | ⏳ | |
| 비가변사이즈 품목은 기존 정적 BOM만 표시 | ⏳ | |
| 에러 처리 및 로딩 상태 표시 | ⏳ | |
---
## 10. 자기완결성 점검 결과
### 10.1 체크리스트 검증
| # | 검증 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 1 | 작업 목적이 명확한가? | ✅ | 가변사이즈 FG 품목의 동적 자재 산출 표시 |
| 2 | 성공 기준이 정의되어 있는가? | ✅ | 9.2 성공 기준 5개 항목 |
| 3 | 작업 범위가 구체적인가? | ✅ | Phase 1 백엔드 3건, Phase 2 프론트 5건 |
| 4 | 의존성이 명시되어 있는가? | ✅ | API 엔드포인트 인증 분석 완료, Docker 라우팅 패턴 |
| 5 | 참고 파일 경로가 정확한가? | ✅ | 모든 파일 경로 검증 완료 |
| 6 | 단계별 절차가 실행 가능한가? | ✅ | 전체 구현 코드 포함 |
| 7 | 검증 방법이 명시되어 있는가? | ✅ | 9.1 테스트 케이스 8개 |
| 8 | 모호한 표현이 없는가? | ✅ | 구체적 수치/코드로 기술 |
| 9 | 기존 코드 현황이 명시되어 있는가? | ✅ | 섹션 3에 전체 파일 구조 + 핵심 코드 인라인 |
| 10 | API 인증 방식이 확정되었는가? | ✅ | X-API-KEY 필수 (FLOW_TESTER_API_KEY) |
| 11 | 트러블슈팅 가이드가 있는가? | ✅ | 4.1 FormulaApiService 트러블슈팅 섹션 |
### 10.2 새 세션 시뮬레이션 테스트
| 질문 | 답변 가능 | 참조 섹션 |
|------|:--------:|----------|
| Q1. 이 작업의 목적은 무엇인가? | ✅ | 1.1 배경 |
| Q2. 어디서부터 시작해야 하는가? | ✅ | 📍 현재 진행 상태 + 4.1 Phase 1 |
| Q3. 어떤 파일을 수정/생성해야 하는가? | ✅ | 3.1 파일 구조 + 2. 대상 범위 |
| Q4. 현재 코드 상태는 어떤가? | ✅ | 3.2~3.6 기존 코드 현황 |
| Q5. API 인증은 어떻게 하는가? | ✅ | 4.1 FormulaApiService (인증 테이블) |
| Q6. 테넌트 필터링은 어떻게 동작하는가? | ✅ | 3.6 테넌트 필터링 패턴 |
| Q7. 작업 완료 확인 방법은? | ✅ | 9. 검증 결과 |
| Q8. 막혔을 때 참고 문서는? | ✅ | 7. 참고 문서 + 4.1 트러블슈팅 |
**결과**: 8/8 통과 → ✅ 자기완결성 확보
---
*이 문서는 /sc:plan 스킬로 생성되었습니다. 자기완결성 보강: 2026-02-19*

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,553 @@
# MNG 견적수식 관리 개발 계획
> **작성일**: 2025-12-22
> **상태**: ✅ 완료
> **대상**: mng.sam.kr/quote-formulas
---
## 1. 현황 분석
### 1.1 MNG 프로젝트 현재 상태
#### 구현된 기능 (mng)
| 기능 | 상태 | 설명 |
|-----|------|-----|
| 수식 목록 | ✅ 완료 | 페이지네이션, 필터링, HTMX 테이블 |
| 수식 생성 | ✅ 완료 | 카테고리, 유형, 변수명, 수식 입력 |
| 수식 수정 | ✅ 완료 | 편집 폼, API 연동 |
| 수식 삭제 | ✅ 완료 | Soft Delete, 복원, 영구삭제 |
| 수식 복제 | ✅ 완료 | 수식 복사 기능 |
| 활성/비활성 | ✅ 완료 | 토글 기능 |
| 카테고리 관리 | ✅ 완료 | CRUD 구현 |
| 시뮬레이터 | ✅ 완료 | 입력값 → 계산 결과 미리보기 |
| 변수 참조 | ✅ 완료 | 사용 가능한 변수 목록 표시 |
| 수식 검증 | ✅ 완료 | 문법 검증 API |
| 범위(Range) 관리 UI | ✅ 완료 | 범위별 결과 설정 화면 (Phase 1) |
| 매핑(Mapping) 관리 UI | ✅ 완료 | 매핑 규칙 설정 화면 (Phase 2) |
| 품목(Item) 관리 UI | ✅ 완료 | 출력 품목 설정 화면 (Phase 3) |
### 1.2 API 프로젝트 현재 상태
#### 모델 구조 (api)
```
QuoteFormulaCategory (카테고리)
└── QuoteFormula (수식)
├── QuoteFormulaRange (범위 조건)
├── QuoteFormulaMapping (매핑 규칙)
└── QuoteFormulaItem (출력 품목)
```
#### 시더 데이터 (api)
| 시더 | 데이터 수 | 설명 |
|-----|---------|-----|
| QuoteFormulaCategorySeeder | 11개 | 카테고리 (오픈사이즈~단가수식) |
| QuoteFormulaSeeder | 30개 수식, 18개 범위 | 스크린 계산 수식 |
| QuoteFormulaItemSeeder | 25개 | 품목 마스터 |
#### 서비스 (api)
| 서비스 | 역할 |
|-------|-----|
| QuoteCalculationService | 자동산출 실행 엔진 |
| FormulaEvaluatorService | 수식 평가, 범위/매핑 처리 |
| QuoteService | 견적 CRUD, 상태 관리 |
| QuoteNumberService | 견적번호 생성 |
| QuoteDocumentService | PDF/이메일/카카오 발송 (TODO) |
---
## 2. MNG vs API 비교 분석
### 2.1 데이터 구조 비교
| 항목 | MNG | API | 일치 |
|-----|-----|-----|-----|
| quote_formula_categories | ✅ | ✅ | ✅ |
| quote_formulas | ✅ | ✅ | ✅ |
| quote_formula_ranges | ✅ | ✅ | ✅ |
| quote_formula_mappings | ✅ | ✅ | ✅ |
| quote_formula_items | ✅ | ✅ | ✅ |
**결론**: 모델 구조는 동일함 (같은 DB 사용)
### 2.2 기능 비교
| 기능 | MNG | API | 비고 |
|-----|-----|-----|-----|
| 수식 CRUD | ✅ | ✅ | 동일 |
| 카테고리 CRUD | ✅ | ✅ | 동일 |
| 범위 관리 UI | ✅ | ✅ (시더) | Phase 1 완료 |
| 매핑 관리 UI | ✅ | ✅ (시더) | Phase 2 완료 |
| 품목 관리 UI | ✅ | ✅ (시더) | Phase 3 완료 |
| 시뮬레이터 | ✅ | ✅ | 동일 |
| 자동산출 API | - | ✅ | API 전용 |
---
## 3. 개발 계획 (완료)
### 3.1 목표
MNG에서 **범위(Range), 매핑(Mapping), 품목(Item)** 관리 UI를 추가하여:
1. 시더 없이도 관리자가 직접 수식 규칙 설정 가능
2. SAM 자체 품목 마스터로 가격 설정
3. 실시간 시뮬레이션으로 설정 검증 가능
### 3.2 개발 범위 (완료)
#### Phase 1: 범위(Range) 관리 UI ✅
**우선순위**: 높음
**이유**: 모터, 가이드레일, 케이스 자동 선택에 필수
**기능 목록**:
1. 수식 상세 페이지에 범위 관리 탭 추가
2. 범위 목록 표시 (min ~ max → 결과)
3. 범위 추가/수정/삭제
4. 드래그앤드롭 순서 변경
5. item_code 연결 (품목 선택)
**화면 설계**:
```
[수식 수정] 페이지
├── [기본 정보] 탭 (기존)
├── [범위 설정] 탭 ← 추가
│ ├── 조건 변수: [K (중량)] ▼
│ ├── 범위 목록
│ │ ┌─────────────────────────────────────────────────┐
│ │ │ # │ 최소값 │ 최대값 │ 결과값 │ 품목코드 │
│ │ ├─────────────────────────────────────────────────┤
│ │ │ 1 │ 0 │ 150 │ 150K │ PT-MOTOR-150│
│ │ │ 2 │ 150 │ 300 │ 300K │ PT-MOTOR-300│
│ │ │ 3 │ 300 │ 400 │ 400K │ PT-MOTOR-400│
│ │ └─────────────────────────────────────────────────┘
│ └── [+ 범위 추가]
├── [매핑 설정] 탭
└── [품목 설정] 탭
```
**API 엔드포인트 (MNG 내부)**:
```
GET /api/admin/quote-formulas/formulas/{id}/ranges
POST /api/admin/quote-formulas/formulas/{id}/ranges
PUT /api/admin/quote-formulas/formulas/{id}/ranges/{rangeId}
DELETE /api/admin/quote-formulas/formulas/{id}/ranges/{rangeId}
POST /api/admin/quote-formulas/formulas/{id}/ranges/reorder
```
#### Phase 2: 매핑(Mapping) 관리 UI ✅
**우선순위**: 중간
**이유**: 제어기 유형 등 코드 매핑에 사용
**기능 목록**:
1. 수식 상세 페이지에 매핑 관리 탭 추가
2. 매핑 목록 표시 (소스값 → 결과값)
3. 매핑 추가/수정/삭제
**화면 설계**:
```
[매핑 설정] 탭
├── 소스 변수: [CONTROL_TYPE] ▼
├── 매핑 목록
│ ┌──────────────────────────────────────────────────┐
│ │ # │ 소스값 │ 결과값 │ 품목코드 │
│ ├──────────────────────────────────────────────────┤
│ │ 1 │ EMB │ 매립형 │ PT-CTRL-EMB │
│ │ 2 │ EXP │ 노출형 │ PT-CTRL-EXP │
│ │ 3 │ BOX_1P │ 콘트롤박스 │ PT-CTRL-BOX-1P │
│ └──────────────────────────────────────────────────┘
└── [+ 매핑 추가]
```
#### Phase 3: 품목(Item) 관리 UI ✅
**우선순위**: 중간
**이유**: 수식 결과로 생성되는 품목 정의
**기능 목록**:
1. 수식 상세 페이지에 품목 관리 탭 추가
2. 품목 목록 표시
3. 품목 추가/수정/삭제
4. 수량/단가 수식 입력
5. SAM 품목 마스터에서 가격 참조
**화면 설계**:
```
[품목 설정] 탭
├── 품목 목록
│ ┌───────────────────────────────────────────────────────────┐
│ │ 품목코드 │ 품목명 │ 규격 │ 수량식 │ 단가식│
│ ├───────────────────────────────────────────────────────────┤
│ │ PT-MOTOR-150 │ 개폐전동기 150kg│ 150K(S) │ 1 │ 285000│
│ │ PT-GR-3000 │ 가이드레일 3000 │ 3000mm │ 2 │ 42000 │
│ └───────────────────────────────────────────────────────────┘
└── [+ 품목 추가]
```
### 3.3 파일 구조 (구현 완료)
#### Controllers
```
app/Http/Controllers/
├── QuoteFormulaController.php (수정: 탭 추가)
└── Api/Admin/Quote/
├── QuoteFormulaController.php
├── QuoteFormulaRangeController.php ✅
├── QuoteFormulaMappingController.php ✅
├── QuoteFormulaItemController.php ✅
└── QuoteFormulaCategoryController.php
```
#### Services
```
app/Services/Quote/
├── QuoteFormulaService.php
├── QuoteFormulaRangeService.php ✅
├── QuoteFormulaMappingService.php ✅
├── QuoteFormulaItemService.php ✅
└── QuoteFormulaCategoryService.php
```
#### Views
```
resources/views/quote-formulas/
├── index.blade.php
├── create.blade.php
├── edit.blade.php (수정: 탭 구조)
├── simulator.blade.php
└── partials/
├── basic-info-tab.blade.php ✅
├── ranges-tab.blade.php ✅
├── mappings-tab.blade.php ✅
└── items-tab.blade.php ✅
```
---
## 4. 기술 스택
### 4.1 Frontend (MNG)
- **Framework**: Laravel Blade + Alpine.js
- **Styling**: Tailwind CSS + DaisyUI
- **AJAX**: HTMX (hx-get, hx-post, hx-delete)
- **Modal**: DaisyUI modal 컴포넌트
### 4.2 Backend (MNG)
- **Framework**: Laravel 12
- **ORM**: Eloquent
- **DB**: MySQL (samdb)
- **Auth**: Session 기반
### 4.3 API 연동
- MNG 내부 API (`/api/admin/quote-formulas/*`)
---
## 5. 검증 계획
### 5.1 시뮬레이터 테스트
```
입력: W0=3000, H0=2500
예상 결과:
- CASE: PT-CASE-3600 (S=3270)
- GR: PT-GR-3000 (H1=2770)
- MOTOR: PT-MOTOR-150 (K=41.21kg)
```
### 5.2 CRUD 테스트
- 범위 추가/수정/삭제 후 시뮬레이터 결과 확인
- 품목 가격 변경 후 합계 확인
---
## 6. 참고 자료
### 6.1 파일 위치 (MNG)
```
mng/
├── app/Http/Controllers/
│ ├── QuoteFormulaController.php
│ └── Api/Admin/Quote/
│ ├── QuoteFormulaController.php
│ ├── QuoteFormulaRangeController.php
│ ├── QuoteFormulaMappingController.php
│ ├── QuoteFormulaItemController.php
│ └── QuoteFormulaCategoryController.php
├── app/Services/Quote/
│ ├── QuoteFormulaService.php
│ ├── QuoteFormulaRangeService.php
│ ├── QuoteFormulaMappingService.php
│ ├── QuoteFormulaItemService.php
│ └── QuoteFormulaCategoryService.php
├── app/Models/Quote/
│ ├── QuoteFormula.php
│ ├── QuoteFormulaCategory.php
│ ├── QuoteFormulaRange.php
│ ├── QuoteFormulaMapping.php
│ └── QuoteFormulaItem.php
└── resources/views/quote-formulas/
├── index.blade.php
├── create.blade.php
├── edit.blade.php
├── simulator.blade.php
└── partials/
├── basic-info-tab.blade.php
├── ranges-tab.blade.php
├── mappings-tab.blade.php
└── items-tab.blade.php
```
### 6.2 API 시더 위치
```
api/database/seeders/
├── QuoteFormulaCategorySeeder.php
├── QuoteFormulaSeeder.php
└── QuoteFormulaItemSeeder.php
```
---
## 7. 코딩 컨벤션 및 예시 코드
### 7.1 API Controller 패턴 (MNG)
```php
<?php
// 파일: app/Http/Controllers/Api/Admin/Quote/QuoteFormulaRangeController.php
namespace App\Http\Controllers\Api\Admin\Quote;
use App\Http\Controllers\Controller;
use App\Services\Quote\QuoteFormulaRangeService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class QuoteFormulaRangeController extends Controller
{
public function __construct(
private readonly QuoteFormulaRangeService $rangeService
) {}
/**
* 범위 목록 조회
*/
public function index(int $formulaId): JsonResponse
{
$ranges = $this->rangeService->getRangesByFormula($formulaId);
return response()->json([
'success' => true,
'data' => $ranges,
]);
}
/**
* 범위 생성
*/
public function store(Request $request, int $formulaId): JsonResponse
{
$validated = $request->validate([
'min_value' => 'nullable|numeric',
'max_value' => 'nullable|numeric',
'condition_variable' => 'required|string|max:50',
'result_value' => 'required|string',
'result_type' => 'in:fixed,formula',
'sort_order' => 'nullable|integer',
]);
$range = $this->rangeService->createRange($formulaId, $validated);
return response()->json([
'success' => true,
'message' => '범위가 추가되었습니다.',
'data' => $range,
]);
}
/**
* 범위 수정
*/
public function update(Request $request, int $formulaId, int $rangeId): JsonResponse
{
$validated = $request->validate([
'min_value' => 'nullable|numeric',
'max_value' => 'nullable|numeric',
'result_value' => 'required|string',
'result_type' => 'in:fixed,formula',
]);
$this->rangeService->updateRange($rangeId, $validated);
return response()->json([
'success' => true,
'message' => '범위가 수정되었습니다.',
]);
}
/**
* 범위 삭제
*/
public function destroy(int $formulaId, int $rangeId): JsonResponse
{
$this->rangeService->deleteRange($rangeId);
return response()->json([
'success' => true,
'message' => '범위가 삭제되었습니다.',
]);
}
/**
* 순서 변경
*/
public function reorder(Request $request, int $formulaId): JsonResponse
{
$validated = $request->validate([
'range_ids' => 'required|array',
'range_ids.*' => 'integer',
]);
$this->rangeService->reorder($validated['range_ids']);
return response()->json([
'success' => true,
'message' => '순서가 변경되었습니다.',
]);
}
}
```
### 7.2 Service 패턴 (MNG)
```php
<?php
// 파일: app/Services/Quote/QuoteFormulaRangeService.php
namespace App\Services\Quote;
use App\Models\Quote\QuoteFormulaRange;
use Illuminate\Support\Collection;
class QuoteFormulaRangeService
{
/**
* 수식별 범위 조회
*/
public function getRangesByFormula(int $formulaId): Collection
{
return QuoteFormulaRange::where('formula_id', $formulaId)
->orderBy('sort_order')
->get();
}
/**
* 범위 생성
*/
public function createRange(int $formulaId, array $data): QuoteFormulaRange
{
$data['formula_id'] = $formulaId;
// 순서 자동 설정
if (!isset($data['sort_order'])) {
$maxOrder = QuoteFormulaRange::where('formula_id', $formulaId)->max('sort_order') ?? 0;
$data['sort_order'] = $maxOrder + 1;
}
return QuoteFormulaRange::create($data);
}
/**
* 범위 수정
*/
public function updateRange(int $rangeId, array $data): QuoteFormulaRange
{
$range = QuoteFormulaRange::findOrFail($rangeId);
$range->update($data);
return $range->fresh();
}
/**
* 범위 삭제
*/
public function deleteRange(int $rangeId): void
{
QuoteFormulaRange::destroy($rangeId);
}
/**
* 순서 변경
*/
public function reorder(array $rangeIds): void
{
foreach ($rangeIds as $order => $id) {
QuoteFormulaRange::where('id', $id)->update(['sort_order' => $order + 1]);
}
}
}
```
### 7.3 API 응답 형식
```json
// 성공 응답
{
"success": true,
"message": "범위가 추가되었습니다.",
"data": { ... }
}
// 실패 응답
{
"success": false,
"message": "이미 사용 중인 변수명입니다."
}
// 목록 응답
{
"success": true,
"data": [
{
"id": 1,
"formula_id": 5,
"min_value": "0.0000",
"max_value": "150.0000",
"condition_variable": "K",
"result_value": "{\"value\":\"150K\",\"item_code\":\"PT-MOTOR-150\"}",
"result_type": "fixed",
"sort_order": 1
}
]
}
```
---
## 8. 체크리스트 (완료)
### 개발 완료 확인
- [x] mng 프로젝트 디렉토리: `/Users/hskwon/Works/@KD_SAM/SAM/mng`
- [x] `QuoteFormulaRangeController.php` 생성
- [x] `QuoteFormulaRangeService.php` 생성
- [x] `QuoteFormulaMappingController.php` 생성
- [x] `QuoteFormulaMappingService.php` 생성
- [x] `QuoteFormulaItemController.php` 생성
- [x] `QuoteFormulaItemService.php` 생성
- [x] `routes/api.php`에 라우트 추가
- [x] `edit.blade.php` 탭 구조로 수정
- [x] `partials/ranges-tab.blade.php` 생성
- [x] `partials/mappings-tab.blade.php` 생성
- [x] `partials/items-tab.blade.php` 생성
---
*문서 버전*: 2.0
*작성자*: Claude Code
*검토자*: -
*최종 업데이트*: 2025-12-22 (Phase 1-3 완료, 5130 연동 제거)

View File

@@ -0,0 +1,424 @@
# 알림음 시스템 구현 계획
> **작성일**: 2025-01-07
> **목적**: FCM 푸시 알림 타입별 커스텀 알림음 구현
> **영향 범위**: app (Capacitor), api (Laravel), mng (Laravel)
> **상태**: ✅ 핵심 기능 완료 (4.3 알림 설정 테이블은 후순위)
---
## 📍 현재 진행 상태
| 항목 | 내용 |
|------|------|
| **마지막 완료 작업** | Phase 5 - 테스트 및 검증 완료 ✅ |
| **다음 작업** | 완료 (4.3 알림 설정 테이블은 후순위) |
| **진행률** | 10/11 (91%) - 핵심 기능 완료 |
| **마지막 업데이트** | 2025-01-07 |
---
## 1. 개요
### 1.1 배경
현재 SAM 앱은 FCM 푸시 알림 시 2개 채널(`push_default`, `push_urgent`)만 지원합니다.
비즈니스 요구사항에 따라 알림 타입별로 다른 알림음이 필요합니다:
- 결제 알림 → 결제 전용 알림음
- 수주 알림 → 수주 전용 알림음
- 발주 알림 → 발주 전용 알림음
- 계약 알림 → 계약 전용 알림음
- 일반 알림 → 기본 알림음
- 신규업체 등록 → 긴급 알림음
### 1.2 목표 구조
| 타입 | 채널 ID | 알림음 파일 | 설명 |
|------|---------|------------|------|
| 결제 | `push_payment` | `push_payment.wav` | 결제 관련 알림 |
| 수주 | `push_sales_order` | `push_sales_order.wav` | 수주 관련 알림 |
| 발주 | `push_purchase_order` | `push_purchase_order.wav` | 발주 관련 알림 |
| 계약 | `push_contract` | `push_contract.wav` | 계약 관련 알림 |
| 일반 | `push_default` | `push_default.wav` | 일반 알림 (기존) |
| 신규업체 등록 | `push_urgent` | `push_urgent.wav` | 신규업체 등록 (기존) |
### 1.3 현재 상태 분석
#### App (Capacitor Android)
- **파일**: `app/android/app/src/main/java/com/codebridgex/webapp/MainActivity.java`
- **현재**: 2개 채널 (`push_default`, `push_urgent`)
- **알림음**: `res/raw/push_default.wav`, `res/raw/push_urgent.wav`
#### API (Laravel)
- **파일**: `api/app/Services/Fcm/FcmSender.php`
- **현재**: `channel_id` 파라미터 지원, 사운드는 `'default'` 하드코딩
- **문제**: 커스텀 사운드 미지원
#### MNG (Laravel)
- **파일**: `mng/app/Http/Controllers/FcmController.php`
- **현재**: `sound_key` 파라미터 존재하나 실제 활용 안됨
### 1.4 시스템 흐름
```
┌─────────────────────────────────────────────────────────────────────────┐
│ FCM 알림음 시스템 흐름 │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ MNG (발송 UI) │
│ ┌─────────────────┐ │
│ │ 타입 선택 │ ← 결제/수주/발주/계약/일반/신규업체 │
│ │ channel_id 설정 │ │
│ └────────┬────────┘ │
│ │ │
│ ▼ │
│ API (FCM 발송) │
│ ┌─────────────────┐ │
│ │ FcmSender │ │
│ │ channel_id → │ │
│ │ android.channel │ │
│ └────────┬────────┘ │
│ │ │
│ ▼ │
│ Firebase Cloud Messaging │
│ ┌─────────────────┐ │
│ │ FCM Server │ │
│ └────────┬────────┘ │
│ │ │
│ ▼ │
│ App (Capacitor) │
│ ┌─────────────────┐ │
│ │ NotificationChannel │ ← channel_id로 매칭 │
│ │ 채널별 사운드 재생 │ ← push_payment.wav 등 │
│ └─────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
```
---
## 2. 대상 범위
### 2.1 Phase 1: App - 채널 및 알림음 추가
| # | 작업 항목 | 상태 | 파일 |
|---|----------|:----:|------|
| 1.1 | 알림음 파일 준비 (4개) | ✅ | `res/raw/*.wav` |
| 1.2 | MainActivity.java 채널 추가 (4개) | ✅ | `MainActivity.java` |
### 2.2 Phase 2: API - FcmSender 수정
| # | 작업 항목 | 상태 | 파일 |
|---|----------|:----:|------|
| 2.1 | buildMessage() 사운드 동적 처리 | ✅ | `FcmSender.php` |
| 2.2 | 채널-사운드 매핑 (FcmSender 내부 통합) | ✅ | `FcmSender.php` |
### 2.3 Phase 3: MNG - 발송 UI 수정
| # | 작업 항목 | 상태 | 파일 |
|---|----------|:----:|------|
| 3.1 | 타입 선택 드롭다운 추가 | ✅ | `fcm/send.blade.php` |
| 3.2 | 타입-채널 매핑 로직 | ✅ | `FcmController.php` |
### 2.4 Phase 4: 이벤트 기반 자동 푸시
| # | 작업 항목 | 상태 | 파일 |
|---|----------|:----:|------|
| 4.1 | PushNotificationService 생성 | ✅ | `api/app/Services/PushNotificationService.php` |
| 4.2 | 신규 거래처 등록 시 푸시 | ✅ | `api/app/Services/ClientService.php` |
| 4.3 | 알림 설정 테이블 (추후) | ⏭️ | 후순위 |
### 2.5 Phase 5: 테스트 및 검증
| # | 작업 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 5.1 | 각 타입별 푸시 발송 테스트 | ✅ | 6개 타입 |
| 5.2 | 알림음 재생 확인 | ✅ | Android 실기기 |
---
## 3. 상세 작업 내용
### 3.1 Phase 1: App - 채널 및 알림음 추가
#### 1.1 알림음 파일 준비
**위치**: `app/android/app/src/main/res/raw/`
| 파일명 | 상태 | 비고 |
|--------|------|------|
| `push_default.wav` | ✅ | 일반 알림 |
| `push_urgent.wav` | ✅ | 신규업체 등록 |
| `push_payment.wav` | ✅ | 결제 알림 |
| `push_sales_order.wav` | ✅ | 수주 알림 |
| `push_purchase_order.wav` | ✅ | 발주 알림 |
| `push_contract.wav` | ✅ | 계약 알림 |
> **완료**: 6개 알림음 파일 모두 준비됨 (2025-01-07)
#### 1.2 MainActivity.java 수정
**현재 코드** (2개 채널):
```java
public static final String CHANNEL_DEFAULT = "push_default";
public static final String CHANNEL_URGENT = "push_urgent";
```
**목표 코드** (6개 채널):
```java
public static final String CHANNEL_DEFAULT = "push_default";
public static final String CHANNEL_URGENT = "push_urgent";
public static final String CHANNEL_PAYMENT = "push_payment";
public static final String CHANNEL_SALES_ORDER = "push_sales_order";
public static final String CHANNEL_PURCHASE_ORDER = "push_purchase_order";
public static final String CHANNEL_CONTRACT = "push_contract";
```
### 3.2 Phase 2: API - FcmSender 수정
#### 2.1 buildMessage() 수정
**현재** (`FcmSender.php:112`):
```php
'android' => [
'notification' => [
'channel_id' => $channelId,
'sound' => 'default', // 하드코딩
],
],
```
**목표**:
```php
'android' => [
'notification' => [
'channel_id' => $channelId,
'sound' => $this->getSoundForChannel($channelId),
],
],
```
#### 2.2 채널-사운드 매핑
```php
// config/fcm.php 또는 FcmSender 내부
private function getSoundForChannel(string $channelId): string
{
return match($channelId) {
'push_payment' => 'push_payment',
'push_sales_order' => 'push_sales_order',
'push_purchase_order' => 'push_purchase_order',
'push_contract' => 'push_contract',
'push_urgent' => 'push_urgent',
default => 'push_default',
};
}
```
### 3.3 Phase 3: MNG - 발송 UI 수정
#### 3.1 타입 선택 UI
```html
<select name="notification_type" id="notification_type">
<option value="general">일반</option>
<option value="payment">결제</option>
<option value="sales_order">수주</option>
<option value="purchase_order">발주</option>
<option value="contract">계약</option>
<option value="new_company">신규업체 등록</option>
</select>
```
#### 3.2 타입 → 채널 매핑
```php
$channelMap = [
'general' => 'push_default',
'payment' => 'push_payment',
'sales_order' => 'push_sales_order',
'purchase_order' => 'push_purchase_order',
'contract' => 'push_contract',
'new_company' => 'push_urgent',
];
```
### 3.4 Phase 4: 이벤트 기반 자동 푸시
#### 4.1 PushNotificationService 생성
**파일**: `api/app/Services/PushNotificationService.php`
```php
<?php
namespace App\Services;
use App\Models\PushDeviceToken;
use App\Services\Fcm\FcmSender;
class PushNotificationService extends Service
{
public function __construct(
private readonly FcmSender $fcmSender
) {}
/**
* 비즈니스 이벤트별 푸시 발송
*/
public function sendByEvent(
string $event,
int $tenantId,
string $title,
string $body,
array $data = []
): void {
$channelId = $this->getChannelForEvent($event);
// 해당 테넌트의 활성 토큰 조회
$tokens = PushDeviceToken::where('tenant_id', $tenantId)
->where('is_active', true)
->pluck('token')
->toArray();
if (empty($tokens)) {
return;
}
$this->fcmSender->sendToMany(
$tokens,
$title,
$body,
$channelId,
$data
);
}
/**
* 이벤트 → 채널 매핑
*/
private function getChannelForEvent(string $event): string
{
return match($event) {
'payment' => 'push_payment',
'sales_order' => 'push_sales_order',
'purchase_order' => 'push_purchase_order',
'contract' => 'push_contract',
'new_client' => 'push_urgent',
default => 'push_default',
};
}
}
```
#### 4.2 ClientService에서 푸시 호출
**파일**: `api/app/Services/ClientService.php` (store 메서드)
```php
/** 생성 */
public function store(array $data)
{
$tenantId = $this->tenantId();
$data['client_code'] = $this->generateClientCode($tenantId);
$data['tenant_id'] = $tenantId;
$data['is_active'] = $data['is_active'] ?? true;
$client = Client::create($data);
// 신규 거래처 등록 푸시 발송
app(PushNotificationService::class)
->setTenantId($tenantId)
->sendByEvent(
'new_client',
$tenantId,
'신규 거래처 등록',
"새로운 거래처 '{$client->name}'이(가) 등록되었습니다.",
['client_id' => $client->id]
);
return $client;
}
```
#### 4.3 이벤트 타입 정의
| 이벤트 | 채널 | 발생 시점 |
|--------|------|----------|
| `new_client` | `push_urgent` | 거래처 신규 등록 |
| `payment` | `push_payment` | 결제 완료/요청 |
| `sales_order` | `push_sales_order` | 수주 등록/변경 |
| `purchase_order` | `push_purchase_order` | 발주 등록/변경 |
| `contract` | `push_contract` | 계약 등록/만료 |
---
## 4. 변경 승인 정책
| 분류 | 예시 | 승인 |
|------|------|------|
| ✅ 즉시 가능 | 알림음 파일 추가, 채널 추가 | 불필요 |
| ⚠️ 컨펌 필요 | FcmSender 로직 변경, UI 수정 | **필수** |
| 🔴 금지 | FCM 구조 변경, 기존 채널 삭제 | 별도 협의 |
---
## 5. 컨펌 대기 목록
| # | 항목 | 변경 내용 | 영향 범위 | 상태 |
|---|------|----------|----------|------|
| 1 | 알림음 파일 | 6개 wav 파일 준비 | app | ✅ 완료 |
---
## 6. 변경 이력
| 날짜 | 항목 | 변경 내용 | 파일 | 승인 |
|------|------|----------|------|------|
| 2025-01-07 | - | 계획 문서 초안 작성 | - | - |
| 2025-01-07 | 1.2 | MainActivity.java 6개 채널 추가 | `MainActivity.java` | ✅ |
| 2025-01-07 | 2.1/2.2 | FcmSender 사운드 동적 처리 + getSoundForChannel 추가 | `FcmSender.php` | ✅ |
| 2025-01-07 | 3.1 | MNG 알림 타입 드롭다운 추가 (6개 타입) | `fcm/send.blade.php` | ✅ |
| 2025-01-07 | 3.2 | FcmController channel_id 검증 + sound_key 제거 | `FcmController.php` | ✅ |
| 2025-01-07 | 4.1 | PushNotificationService 생성 (이벤트 기반 푸시) | `PushNotificationService.php` | ✅ |
| 2025-01-07 | 4.2 | ClientService.store()에 푸시 알림 연동 | `ClientService.php` | ✅ |
| 2025-01-07 | 5.1/5.2 | 테스트 및 검증 완료 | 서버 배포 후 실기기 테스트 | ✅ |
---
## 7. 참고 문서
- **FCM 푸시 계획**: `docs/plans/react-fcm-push-notification-plan.md`
- **API 규칙**: `docs/standards/api-rules.md`
---
## 8. 알림음 파일 준비 가이드
### 요구사항
- **포맷**: WAV (권장) 또는 MP3
- **길이**: 1-3초 권장
- **샘플레이트**: 44.1kHz
- **비트레이트**: 16bit
### 임시 방안
알림음 파일이 준비되지 않은 경우, 기존 파일을 복사하여 사용:
```bash
cd app/android/app/src/main/res/raw/
cp push_default.wav push_payment.wav
cp push_default.wav push_sales_order.wav
cp push_default.wav push_purchase_order.wav
cp push_default.wav push_contract.wav
```
### 무료 알림음 리소스
- [Pixabay Sound Effects](https://pixabay.com/sound-effects/)
- [Freesound](https://freesound.org/)
- [Zapsplat](https://www.zapsplat.com/)
---
*이 문서는 /sc:plan 스킬로 생성되었습니다.*

View File

@@ -0,0 +1,831 @@
# 수주 하위 구조 관리 시스템 구축 계획
> **작성일**: 2026-02-06
> **목적**: 수주(Order) 하위에 범용 N-depth 트리 구조를 구축하여 개소/구역/공정 등 다양한 하위 단위를 자유롭게 관리
> **기준 문서**: `docs/features/quotes/README.md`, `docs/specs/database-schema.md`, `docs/standards/api-rules.md`
> **상태**: 🔄 진행중
> **설계 결정**: 하이브리드 (고정 코어 컬럼 + options JSON) — 통계 쿼리 성능과 유연성 균형
---
## 📍 현재 진행 상태
| 항목 | 내용 |
|------|------|
| **마지막 완료 작업** | Phase 4.2 - 프론트엔드 노드별 그룹 UI |
| **다음 작업** | 완료 (테스트 검증 필요) |
| **진행률** | 13/13 (100%) |
| **마지막 업데이트** | 2026-02-06 |
---
## 1. 개요
### 1.1 배경
**즉시 문제**: 견적→수주 전환(`QuoteService::convertToOrder`)에서 개소 정보가 매핑되지 않아 `order_items.floor_code`, `symbol_code`가 null로 저장됨. 반면 `OrderService::syncFromQuote`에는 이미 파싱 로직이 있어 정상 동작.
**구조적 문제**: 현재 수주 하위 구조는 `order_items` 플랫 테이블뿐이며, 개소/구역/공정 등 다양한 그루핑 단위를 관리할 수 없음. 5130(경동)은 개소별 관리가 필요하지만, 향후 다른 테넌트에서는 구역별, 층별, 공정별 등 다양한 트리 구조가 필요.
**현재 데이터 흐름 문제**:
```
견적 저장:
quotes.calculation_inputs.items[] → 개소별 데이터 ✅
quote_items.note → "4F FSS-01" ✅
수주 전환 (convertToOrder):
order_items.floor_code → null ❌ ← $productMapping이 빈 배열
order_items.symbol_code → null ❌
수주 동기화 (syncFromQuote):
order_items.floor_code → "4F" ✅ ← note 파싱 로직 있음
order_items.symbol_code → "FSS-01" ✅
```
### 1.2 목표
1. 견적→수주 전환 시 개소 정보가 정확히 매핑되도록 즉시 수정 (Quick Fix)
2. `order_nodes` 테이블을 신규 생성하여 **범용 N-depth 트리 구조** 제공
3. 노드별 독립 상태 추적 (대기/진행중/완료/취소)
4. 프론트엔드에서 노드별 그룹 UI 제공 (경동은 개소별 표시)
### 1.3 아키텍처 결정
```
┌─────────────────────────────────────────────────────────────────┐
│ 🎯 설계 결정: 하이브리드 (고정 코어 + options JSON) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ❌ 순수 EAV → 통계 쿼리 시 JOIN 폭발, 성능 문제 │
│ ❌ 고정 컬럼 전용 → 경동 개소에만 맞고 범용성 없음 │
│ ✅ 하이브리드 → 통계용 고정 컬럼 + 유형별 상세는 options JSON │
│ │
│ 근거: │
│ - SAM 프로젝트에서 이미 options JSON 패턴 사용 중 │
│ (work_order_items.options, quotes.calculation_inputs) │
│ - MySQL 8 JSON path 쿼리 지원 (options->>'$.floor' 등) │
│ - 통계 집계는 고정 컬럼(code, name, status, quantity, price)으로 │
│ - sam_stat 일간/월간 집계에도 고정 컬럼 기반으로 수월 │
│ │
└─────────────────────────────────────────────────────────────────┘
```
### 1.4 핵심 원칙
```
┌─────────────────────────────────────────────────────────────────┐
│ 1. 범용 트리: N-depth 자기참조(parent_id)로 어떤 구조든 표현 가능 │
│ 2. 통계 친화: code, name, status, quantity, price는 고정 컬럼 │
│ 3. 유형 자유: node_type으로 구분, 유형별 상세는 options JSON │
│ 4. 역호환성: 기존 수주(order_nodes 없는)도 정상 동작 │
│ 5. SAM 패턴 준수: BelongsToTenant, Auditable, SoftDeletes │
└─────────────────────────────────────────────────────────────────┘
```
### 1.5 적용 예시
**경동 (1-depth: 개소)**:
```
Order: ORD-260206-001
├── Node (type:location, code:"1F-FSS-01", name:"1F FSS-01")
│ ├── options: { floor:"1F", symbol:"FSS-01", product_code:"KSS01",
│ │ open_width:5000, open_height:3000, guide_rail:"wall" }
│ └── OrderItems (자재 N개)
└── Node (type:location, code:"2F-SD-02", name:"2F SD-02")
├── options: { floor:"2F", symbol:"SD-02", product_code:"KWE01",
│ open_width:2800, open_height:2400 }
└── OrderItems (자재 N개)
```
**다른 테넌트 (3-depth: 동→층→실)**:
```
Order: ORD-260206-005
├── Node (type:zone, code:"A", name:"A동")
│ ├── Node (type:floor, code:"1F", name:"1층")
│ │ ├── Node (type:room, code:"101", name:"회의실")
│ │ │ └── OrderItems
│ │ └── Node (type:room, code:"102", name:"사무실")
│ │ └── OrderItems
│ └── Node (type:floor, code:"2F", name:"2층")
│ └── ...
└── Node (type:zone, code:"B", name:"B동")
└── ...
```
### 1.6 변경 승인 정책
| 분류 | 예시 | 승인 |
|------|------|------|
| ✅ 즉시 가능 | convertToOrder 로직 수정, 모델 관계 추가 | 불필요 |
| ⚠️ 컨펌 필요 | 마이그레이션 생성, 신규 테이블, API 엔드포인트 추가 | **필수** |
| 🔴 금지 | 기존 order_items.floor_code/symbol_code 삭제, 기존 API 스키마 변경 | 별도 협의 |
### 1.7 준수 규칙
- `docs/standards/api-rules.md` - Service-First, FormRequest, i18n
- `docs/specs/database-schema.md` - 공통 컬럼 패턴 (tenant_id, audit, softDeletes)
- `docs/standards/quality-checklist.md` - 품질 체크리스트
- `react/CLAUDE.md` - 'use client' 필수, Server Actions
---
## 2. 대상 범위
### 2.1 Phase 1: convertToOrder 개소 파싱 (Quick Fix)
| # | 작업 항목 | 파일 | 상태 | 비고 |
|---|----------|------|:----:|------|
| 1.1 | convertToOrder에 개소 파싱 로직 추가 | `api/app/Services/Quote/QuoteService.php` | ✅ | syncFromQuote 로직 재사용 |
| 1.2 | 개소 파싱 공통 메소드 추출 | `api/app/Services/Quote/QuoteService.php` | ✅ | 중복 코드 제거 |
### 2.2 Phase 2: order_nodes 테이블 (DB 스키마)
| # | 작업 항목 | 파일 | 상태 | 비고 |
|---|----------|------|:----:|------|
| 2.1 | order_nodes 마이그레이션 생성 | `api/database/migrations/XXXX_create_order_nodes_table.php` | ✅ | 신규 테이블 |
| 2.2 | order_items에 order_node_id 추가 | `api/database/migrations/XXXX_add_order_node_id_to_order_items.php` | ✅ | nullable FK |
| 2.3 | OrderNode 모델 생성 | `api/app/Models/Orders/OrderNode.php` | ✅ | BelongsToTenant, SoftDeletes, 자기참조 |
| 2.4 | Order 모델에 nodes() 관계 추가 | `api/app/Models/Orders/Order.php` | ✅ | HasMany |
| 2.5 | OrderItem 모델에 node() 관계 추가 | `api/app/Models/Orders/OrderItem.php` | ✅ | BelongsTo, fillable 추가 |
### 2.3 Phase 3: 전환 로직 연동 (Service)
| # | 작업 항목 | 파일 | 상태 | 비고 |
|---|----------|------|:----:|------|
| 3.1 | convertToOrder에 OrderNode 생성 로직 추가 | `api/app/Services/Quote/QuoteService.php` | ✅ | Phase 1.1 위에 구축 |
| 3.2 | syncFromQuote에 OrderNode 동기화 추가 | `api/app/Services/OrderService.php` | ✅ | 기존 items 삭제→재생성 패턴 동일 |
| 3.3 | 수주 상세 조회에 nodes eager loading | `api/app/Services/OrderService.php` | ✅ | show() 메소드 수정 |
### 2.4 Phase 4: 프론트엔드 노드별 UI
| # | 작업 항목 | 파일 | 상태 | 비고 |
|---|----------|------|:----:|------|
| 4.1 | OrderNode 타입 + 서버 액션 추가 | `react/src/components/orders/actions.ts` | ✅ | 타입 정의, API 호출 |
| 4.2 | 수주 상세 뷰 노드별 그룹 UI | `react/src/components/orders/OrderSalesDetailView.tsx` | ✅ | 트리/아코디언 형식 |
---
## 3. 작업 절차
### 3.1 단계별 절차
```
Phase 1: Quick Fix (convertToOrder 개소 파싱)
├── 1.1 syncFromQuote의 개소 파싱 로직을 공통 메소드로 추출
├── 1.2 convertToOrder에서 공통 메소드 호출하여 $productMapping 전달
└── 검증: 견적→수주 전환 후 order_items.floor_code/symbol_code 값 확인
Phase 2: DB 스키마 (order_nodes 테이블)
├── 2.1 order_nodes 마이그레이션 작성
│ ├── 트리 구조: parent_id 자기참조 (nullable = 루트)
│ ├── 고정 코어: node_type, code, name, status_code, quantity, unit_price, total_price
│ └── 유연 확장: options JSON
├── 2.2 order_items에 order_node_id 컬럼 마이그레이션 작성
├── 2.3 OrderNode 모델 생성 (BelongsToTenant, Auditable, SoftDeletes)
│ ├── 자기참조 관계: parent(), children()
│ └── items() HasMany
├── 2.4 Order 모델에 nodes() HasMany 관계 추가
├── 2.5 OrderItem 모델에 node() BelongsTo 관계 추가
└── 검증: php artisan migrate 성공, 트리 관계 정상 동작
Phase 3: 전환 로직 연동
├── 3.1 convertToOrder에 OrderNode 생성 로직 삽입
│ ├── calculation_inputs.items[] 순회하여 OrderNode (type:location) 생성
│ ├── bomResults[]에서 금액 정보 매핑
│ └── OrderItem 생성 시 order_node_id 연결
├── 3.2 syncFromQuote에 OrderNode 동기화 추가
│ ├── 기존 nodes 소프트삭제 → 신규 생성
│ └── OrderItem 재생성 시 node 연결
├── 3.3 수주 상세 조회에 nodes eager loading 추가
└── 검증: API 호출로 노드 데이터 정상 반환 확인
Phase 4: 프론트엔드 UI
├── 4.1 타입 + 서버 액션
│ ├── OrderNode 인터페이스 정의
│ └── 수주 상세 조회 응답에 nodes 포함
├── 4.2 수주 상세 뷰 노드별 그룹 UI
│ ├── 노드별 카드/아코디언 레이아웃
│ ├── 노드 헤더 (유형/코드/이름/상태/금액)
│ ├── 노드 내 자재 테이블
│ ├── 하위 노드 중첩 표시 (재귀 컴포넌트)
│ └── 역호환: nodes 없는 수주는 기존 플랫 테이블 유지
└── 검증: 수주 상세 화면에서 노드별 그룹 표시 확인
```
---
## 4. 상세 작업 내용
### 4.1 Phase 1: Quick Fix (변경 없음)
#### 1.1 convertToOrder 개소 파싱 로직 추가
**현재 코드** (`QuoteService.php` Line 600-607):
```php
$serialIndex = 1;
foreach ($quote->items as $quoteItem) {
$orderItem = OrderItem::createFromQuoteItem($quoteItem, $order->id, $serialIndex);
$orderItem->created_by = $userId;
$orderItem->save();
$serialIndex++;
}
```
**수정 코드**:
```php
$calculationInputs = $quote->calculation_inputs ?? [];
$productItems = $calculationInputs['items'] ?? [];
$serialIndex = 1;
foreach ($quote->items as $quoteItem) {
$productMapping = $this->resolveLocationMapping($quoteItem, $productItems);
$orderItem = OrderItem::createFromQuoteItem($quoteItem, $order->id, $serialIndex, $productMapping);
$orderItem->created_by = $userId;
$orderItem->save();
$serialIndex++;
}
```
#### 1.2 공통 메소드 추출
```php
/**
* 견적 품목에서 개소(층/부호) 정보 추출
*/
private function resolveLocationMapping(QuoteItem $quoteItem, array $productItems): array
{
$floorCode = null;
$symbolCode = null;
// 1순위: note에서 파싱 ("4F FSS-01")
$note = trim($quoteItem->note ?? '');
if ($note !== '') {
$parts = preg_split('/\s+/', $note, 2);
$floorCode = $parts[0] ?? null;
$symbolCode = $parts[1] ?? null;
}
// 2순위: formula_source → calculation_inputs
if (empty($floorCode) && empty($symbolCode)) {
$productIndex = 0;
$formulaSource = $quoteItem->formula_source ?? '';
if (preg_match('/product_(\d+)/', $formulaSource, $matches)) {
$productIndex = (int) $matches[1];
}
if (isset($productItems[$productIndex])) {
$floorCode = $productItems[$productIndex]['floor'] ?? null;
$symbolCode = $productItems[$productIndex]['code'] ?? null;
} elseif (count($productItems) === 1) {
$floorCode = $productItems[0]['floor'] ?? null;
$symbolCode = $productItems[0]['code'] ?? null;
}
}
return ['floor_code' => $floorCode, 'symbol_code' => $symbolCode];
}
```
---
### 4.2 Phase 2: DB 스키마
#### 2.1 order_nodes 마이그레이션
```php
Schema::create('order_nodes', function (Blueprint $table) {
$table->id()->comment('ID');
$table->foreignId('tenant_id')->comment('테넌트 ID');
$table->foreignId('order_id')->comment('수주 ID');
// ---- 트리 구조 ----
$table->foreignId('parent_id')->nullable()->comment('상위 노드 ID (NULL=루트)');
// ---- 고정 코어 (통계/집계용) ----
$table->string('node_type', 50)->comment('노드 유형 (location, zone, floor, room, process...)');
$table->string('code', 100)->comment('식별 코드');
$table->string('name', 200)->comment('표시명');
$table->string('status_code', 30)->default('PENDING')
->comment('상태 (PENDING/CONFIRMED/IN_PRODUCTION/PRODUCED/SHIPPED/COMPLETED/CANCELLED)');
$table->integer('quantity')->default(1)->comment('수량');
$table->decimal('unit_price', 15, 2)->default(0)->comment('단가');
$table->decimal('total_price', 15, 2)->default(0)->comment('합계');
// ---- 유연 확장 (유형별 상세) ----
$table->json('options')->nullable()->comment('유형별 동적 속성 JSON');
// ---- 정렬 ----
$table->integer('depth')->default(0)->comment('트리 깊이 (0=루트)');
$table->integer('sort_order')->default(0)->comment('정렬 순서');
// ---- 감사 ----
$table->foreignId('created_by')->nullable()->comment('생성자 ID');
$table->foreignId('updated_by')->nullable()->comment('수정자 ID');
$table->foreignId('deleted_by')->nullable()->comment('삭제자 ID');
$table->timestamps();
$table->softDeletes();
// ---- 인덱스 ----
$table->index('tenant_id');
$table->index('parent_id');
$table->index(['order_id', 'depth', 'sort_order']);
$table->index(['order_id', 'node_type']);
$table->index(['tenant_id', 'node_type', 'status_code']); // 통계용
});
```
**통계 쿼리 예시**:
```sql
-- 1. 노드 유형별 수주 현황 (고정 컬럼만으로 가능)
SELECT node_type, status_code, COUNT(*), SUM(total_price)
FROM order_nodes WHERE tenant_id = 287
GROUP BY node_type, status_code;
-- 2. 경동 개소별 상세 (필요 시 JSON path)
SELECT code, name, total_price,
options->>'$.floor' AS floor,
options->>'$.symbol' AS symbol
FROM order_nodes
WHERE order_id = 123 AND node_type = 'location';
```
#### 2.2 order_items에 order_node_id 추가
```php
Schema::table('order_items', function (Blueprint $table) {
$table->foreignId('order_node_id')
->nullable()
->after('order_id')
->comment('수주 노드 ID (order_nodes)');
$table->index('order_node_id');
});
```
#### 2.3 OrderNode 모델
```php
namespace App\Models\Orders;
class OrderNode extends Model
{
use Auditable, BelongsToTenant, SoftDeletes;
protected $table = 'order_nodes';
// 상태 코드 (Order와 동일 체계)
public const STATUS_PENDING = 'PENDING';
public const STATUS_CONFIRMED = 'CONFIRMED';
public const STATUS_IN_PRODUCTION = 'IN_PRODUCTION';
public const STATUS_PRODUCED = 'PRODUCED';
public const STATUS_SHIPPED = 'SHIPPED';
public const STATUS_COMPLETED = 'COMPLETED';
public const STATUS_CANCELLED = 'CANCELLED';
protected $fillable = [
'tenant_id', 'order_id', 'parent_id',
'node_type', 'code', 'name',
'status_code', 'quantity', 'unit_price', 'total_price',
'options', 'depth', 'sort_order',
'created_by', 'updated_by', 'deleted_by',
];
protected $casts = [
'quantity' => 'integer',
'unit_price' => 'decimal:2',
'total_price' => 'decimal:2',
'options' => 'array',
'depth' => 'integer',
];
// ---- 트리 관계 ----
public function parent(): BelongsTo
{
return $this->belongsTo(self::class, 'parent_id');
}
public function children(): HasMany
{
return $this->hasMany(self::class, 'parent_id')->orderBy('sort_order');
}
// ---- 비즈니스 관계 ----
public function order(): BelongsTo
{
return $this->belongsTo(Order::class);
}
public function items(): HasMany
{
return $this->hasMany(OrderItem::class, 'order_node_id');
}
// ---- 트리 헬퍼 ----
public function isRoot(): bool
{
return $this->parent_id === null;
}
public function isLeaf(): bool
{
return $this->children()->count() === 0;
}
/**
* 하위 노드 포함 전체 트리 재귀 로드
*/
public function scopeWithRecursiveChildren($query)
{
return $query->with(['children' => function ($q) {
$q->orderBy('sort_order')->with('children', 'items');
}, 'items']);
}
}
```
#### 2.4-2.5 기존 모델 수정
**Order 모델**:
```php
public function nodes(): HasMany
{
return $this->hasMany(OrderNode::class)->orderBy('depth')->orderBy('sort_order');
}
public function rootNodes(): HasMany
{
return $this->hasMany(OrderNode::class)->whereNull('parent_id')->orderBy('sort_order');
}
```
**OrderItem 모델** - fillable + 관계:
```php
// fillable에 추가
'order_node_id',
// 관계
public function node(): BelongsTo
{
return $this->belongsTo(OrderNode::class, 'order_node_id');
}
```
---
### 4.3 Phase 3: 전환 로직 연동
#### 3.1 convertToOrder OrderNode 생성
**수정 위치**: `QuoteService::convertToOrder()` (Line 590~623)
```php
return DB::transaction(function () use ($quote, $userId, $tenantId) {
$orderNo = $this->generateOrderNumber($tenantId);
$order = Order::createFromQuote($quote, $orderNo);
$order->created_by = $userId;
$order->save();
// ---- OrderNode 생성 (개소별) ----
$calculationInputs = $quote->calculation_inputs ?? [];
$productItems = $calculationInputs['items'] ?? [];
$bomResults = $calculationInputs['bomResults'] ?? [];
$nodeMap = []; // productIndex → OrderNode
foreach ($productItems as $idx => $locItem) {
$bomResult = $bomResults[$idx] ?? null;
$grandTotal = $bomResult['grand_total'] ?? 0;
$qty = (int) ($locItem['quantity'] ?? 1);
$floor = $locItem['floor'] ?? '';
$symbol = $locItem['code'] ?? '';
$node = OrderNode::create([
'tenant_id' => $tenantId,
'order_id' => $order->id,
'parent_id' => null, // 루트 노드 (경동은 1-depth)
'node_type' => 'location',
'code' => trim("{$floor}-{$symbol}", '-') ?: "LOC-{$idx}",
'name' => trim("{$floor} {$symbol}") ?: "개소 ".($idx + 1),
'status_code' => OrderNode::STATUS_PENDING,
'quantity' => $qty,
'unit_price' => $grandTotal,
'total_price' => $grandTotal * $qty,
'options' => [
'floor' => $floor,
'symbol' => $symbol,
'product_code' => $locItem['productCode'] ?? null,
'product_name' => $locItem['productName'] ?? null,
'open_width' => $locItem['openWidth'] ?? null,
'open_height' => $locItem['openHeight'] ?? null,
'guide_rail_type' => $locItem['guideRailType'] ?? null,
'motor_power' => $locItem['motorPower'] ?? null,
'controller' => $locItem['controller'] ?? null,
'wing_size' => $locItem['wingSize'] ?? null,
'inspection_fee' => $locItem['inspectionFee'] ?? null,
'bom_result' => $bomResult,
],
'depth' => 0,
'sort_order' => $idx,
'created_by' => $userId,
]);
$nodeMap[$idx] = $node;
}
// ---- OrderItem 생성 (노드 연결) ----
$serialIndex = 1;
foreach ($quote->items as $quoteItem) {
$mapping = $this->resolveLocationMapping($quoteItem, $productItems);
$locIdx = $this->resolveLocationIndex($quoteItem, $productItems);
$productMapping = array_merge($mapping, [
'order_node_id' => isset($nodeMap[$locIdx]) ? $nodeMap[$locIdx]->id : null,
]);
$orderItem = OrderItem::createFromQuoteItem($quoteItem, $order->id, $serialIndex, $productMapping);
$orderItem->created_by = $userId;
$orderItem->save();
$serialIndex++;
}
// 합계 재계산 + 견적 상태 변경 (기존 로직 유지)
$order->load('items');
$order->recalculateTotals();
$order->save();
$quote->update([
'status' => Quote::STATUS_CONVERTED,
'order_id' => $order->id,
'updated_by' => $userId,
]);
return $quote->refresh()->load(['items', 'client', 'order']);
});
```
**resolveLocationIndex 헬퍼**:
```php
private function resolveLocationIndex(QuoteItem $quoteItem, array $productItems): int
{
$formulaSource = $quoteItem->formula_source ?? '';
if (preg_match('/product_(\d+)/', $formulaSource, $matches)) {
return (int) $matches[1];
}
$note = trim($quoteItem->note ?? '');
if ($note !== '') {
$parts = preg_split('/\s+/', $note, 2);
$floor = $parts[0] ?? '';
$code = $parts[1] ?? '';
foreach ($productItems as $idx => $item) {
if (($item['floor'] ?? '') === $floor && ($item['code'] ?? '') === $code) {
return $idx;
}
}
}
return 0;
}
```
#### 3.2 syncFromQuote OrderNode 동기화
**수정 위치**: `OrderService::syncFromQuote()` (Line 559~659)
기존 `$order->items()->delete()` 다음에:
```php
// 기존 노드 삭제 후 재생성
$order->nodes()->delete();
// OrderNode 생성 (convertToOrder와 동일 로직)
$nodeMap = [];
foreach ($productItems as $idx => $locItem) {
// ... (convertToOrder와 동일)
$nodeMap[$idx] = $node;
}
// OrderItem 생성 시 order_node_id 연결
foreach ($quote->items as $index => $quoteItem) {
$locIdx = $this->resolveLocationIndex($quoteItem, $productItems);
$order->items()->create([
// ... 기존 필드 ...
'order_node_id' => $nodeMap[$locIdx]->id ?? null,
]);
}
```
#### 3.3 수주 상세 조회 nodes eager loading
```php
$order = Order::where('tenant_id', $tenantId)
->with([
'items',
'rootNodes' => function ($q) {
$q->withRecursiveChildren(); // 재귀 트리 로드
},
'client',
'quote',
])
->find($id);
```
---
### 4.4 Phase 4: 프론트엔드 노드별 UI
#### 4.1 타입 + 서버 액션
**OrderNode 타입** (`react/src/components/orders/actions.ts`):
```typescript
export interface OrderNode {
id: number;
parentId: number | null;
nodeType: string; // 'location', 'zone', 'floor', 'room', 'process'...
code: string;
name: string;
statusCode: string;
quantity: number;
unitPrice: number;
totalPrice: number;
options: Record<string, unknown> | null; // 유형별 동적 속성
depth: number;
sortOrder: number;
children: OrderNode[]; // 하위 노드 (재귀)
items: OrderItem[]; // 해당 노드의 자재
}
export interface OrderDetail extends Order {
nodes: OrderNode[]; // 루트 노드 배열 (children 재귀 포함)
}
```
#### 4.2 수주 상세 뷰 노드별 그룹 UI
**레이아웃 (경동 1-depth 예시)**:
```
┌─ 수주 기본 정보 ────────────────────────────────────────┐
│ 수주번호: ORD-260206-001 | 현장명: 삼성 빌딩 신축 │
│ 거래처: 삼성물산 | 총금액: 15,000,000원 │
└─────────────────────────────────────────────────────────┘
┌─ 구조 (3개 노드) ──────────────────────────────────────┐
│ │
│ ┌─ [location] 1F FSS-01 ──────────────────────────┐ │
│ │ KSS01(고정스크린) | 5000×3000 | 수량:1 │ │
│ │ 상태: [PENDING ▾] | 금액: 1,250,000원 │ │
│ ├──────────────────────────────────────────────────┤ │
│ │ # | 품목코드 | 품명 | 수량 | 단가 | 금액 │ │
│ │ 1 | MT-SUS-01 | 슬랫 | 15.5 | 12,000 | 186K │ │
│ │ 소계: 1,250,000원 │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ ┌─ [location] 2F SD-02 ──────────────────────────┐ │
│ │ ... │ │
│ └─────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
```
**재귀 컴포넌트 (N-depth)**:
```typescript
function OrderNodeCard({ node, depth = 0 }: { node: OrderNode; depth?: number }) {
return (
<div style={{ marginLeft: depth * 24 }}>
{/* 노드 헤더 */}
<NodeHeader node={node} />
{/* 해당 노드의 자재 테이블 */}
{node.items.length > 0 && <ItemsTable items={node.items} />}
{/* 하위 노드 재귀 렌더링 */}
{node.children.map(child => (
<OrderNodeCard key={child.id} node={child} depth={depth + 1} />
))}
</div>
);
}
```
**역호환**:
```typescript
{order.nodes && order.nodes.length > 0 ? (
order.nodes.map(node => <OrderNodeCard key={node.id} node={node} />)
) : (
<LegacyFlatTableView items={order.items} />
)}
```
---
## 5. 컨펌 대기 목록
| # | 항목 | 변경 내용 | 영향 범위 | 상태 |
|---|------|----------|----------|------|
| 1 | order_nodes 테이블 생성 | N-depth 자기참조 트리 + 하이브리드 JSON | DB 스키마 | ⚠️ 컨펌 필요 |
| 2 | order_items에 order_node_id 추가 | nullable FK 컬럼 | DB 스키마 | ⚠️ 컨펌 필요 |
| 3 | 노드 상태 코드 체계 | Order와 동일 체계 사용 | 비즈니스 로직 | ⚠️ 컨펌 필요 |
| 4 | 경동 node_type 값 | "location" 사용 | 비즈니스 로직 | ⚠️ 컨펌 필요 |
---
## 6. 변경 이력
| 날짜 | 항목 | 변경 내용 | 파일 | 승인 |
|------|------|----------|------|------|
| 2026-02-06 | - | 문서 초안 작성 (order_locations 전용 설계) | - | - |
| 2026-02-06 | 아키텍처 변경 | order_locations → order_nodes (N-depth 트리 + 하이브리드) | - | ✅ 사용자 승인 |
---
## 7. 참고 문서
- **견적 시스템 분석**: `docs/features/quotes/README.md`
- **DB 스키마 규칙**: `docs/specs/database-schema.md`
- **API 개발 규칙**: `docs/standards/api-rules.md`
- **품질 체크리스트**: `docs/standards/quality-checklist.md`
### 핵심 소스 파일
| 파일 | 역할 | 핵심 라인 |
|------|------|----------|
| `api/app/Services/Quote/QuoteService.php` | 견적→수주 전환 | L574-623 (`convertToOrder`) |
| `api/app/Services/OrderService.php` | 수주 동기화 | L559-659 (`syncFromQuote`) |
| `api/app/Models/Orders/Order.php` | 수주 모델 | L23-59 (상태 코드) |
| `api/app/Models/Orders/OrderItem.php` | 수주 품목 모델 | L162-190 (`createFromQuoteItem`) |
| `react/src/components/orders/actions.ts` | 수주 프론트 타입 | L281-300 (OrderItem) |
| `react/src/components/orders/OrderSalesDetailView.tsx` | 수주 상세 뷰 | L386-424 (테이블) |
| `react/src/components/quotes/types.ts` | 견적 타입 | L661-684 (LocationItem) |
---
## 8. 세션 및 메모리 관리 정책
### 8.1 세션 시작 시
```
1. read_memory("order-nodes-state") → 진행 상태 파악
2. 이 문서의 "📍 현재 진행 상태" 섹션 확인
3. 마지막 완료 작업 확인 후 다음 작업 착수
```
### 8.2 Serena 메모리 구조
- `order-nodes-state`: `{ phase, progress, next_step, last_decision }`
- `order-nodes-snapshot`: 현재까지의 코드 변경점 요약
- `order-nodes-active-symbols`: 수정 중인 파일/함수 목록
---
## 9. 검증 결과
### 9.1 테스트 케이스
| # | 시나리오 | 예상 결과 | 실제 결과 | 상태 |
|---|---------|----------|----------|------|
| 1 | 견적 3개소 → 수주 전환 | order_nodes 3행(type:location) 생성, 각 order_items에 node_id 연결 | - | ⏳ |
| 2 | 수주 상세 조회 | rootNodes + children 재귀 + items eager loading 정상 | - | ⏳ |
| 3 | 견적 수정 → 수주 동기화 | 기존 nodes 삭제 후 재생성, items 재연결 | - | ⏳ |
| 4 | 기존 수주 (nodes 없음) 조회 | 기존 플랫 테이블 정상 표시, 에러 없음 | - | ⏳ |
| 5 | 프론트 노드별 그룹 표시 | 노드 카드 내 자재 테이블, 역호환 플랫 뷰 | - | ⏳ |
| 6 | 통계 쿼리 성능 | 고정 컬럼(node_type, status, total_price) 기반 GROUP BY 정상 | - | ⏳ |
### 9.2 성공 기준
| 기준 | 달성 | 비고 |
|------|------|------|
| 전환 시 order_nodes 생성됨 | ⏳ | Phase 2+3 |
| N-depth 트리 구조 지원 | ⏳ | Phase 2 (parent_id 자기참조) |
| order_items에 order_node_id 연결됨 | ⏳ | Phase 3 |
| 프론트 노드별 그룹 표시 | ⏳ | Phase 4 |
| 기존 수주 역호환 정상 | ⏳ | Phase 4 |
| 통계 쿼리가 고정 컬럼으로 가능 | ⏳ | Phase 2 (인덱스) |
---
## 10. 자기완결성 점검 결과
### 10.1 체크리스트 검증
| # | 검증 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 1 | 작업 목적이 명확한가? | ✅ | 범용 N-depth 트리 + 통계 친화 하이브리드 |
| 2 | 성공 기준이 정의되어 있는가? | ✅ | 섹션 9.2 (6개 기준) |
| 3 | 작업 범위가 구체적인가? | ✅ | 섹션 2 (13개 작업 항목) |
| 4 | 의존성이 명시되어 있는가? | ✅ | Phase 1→2→3→4 순서 |
| 5 | 참고 파일 경로가 정확한가? | ✅ | 섹션 7 (라인번호 포함) |
| 6 | 단계별 절차가 실행 가능한가? | ✅ | 섹션 3+4 (코드 포함) |
| 7 | 검증 방법이 명시되어 있는가? | ✅ | 섹션 9.1 (6개 테스트 케이스) |
| 8 | 모호한 표현이 없는가? | ✅ | 구체적 코드/파일/라인 명시 |
### 10.2 새 세션 시뮬레이션 테스트
| 질문 | 답변 가능 | 참조 섹션 |
|------|:--------:|----------|
| Q1. 이 작업의 목적은 무엇인가? | ✅ | 1.1 배경, 1.2 목표 |
| Q2. 왜 하이브리드 구조를 선택했는가? | ✅ | 1.3 아키텍처 결정 |
| Q3. 어디서부터 시작해야 하는가? | ✅ | 📍 현재 진행 상태 |
| Q4. 어떤 파일을 수정해야 하는가? | ✅ | 2. 대상 범위 |
| Q5. 작업 완료 확인 방법은? | ✅ | 9. 검증 결과 |
**결과**: 5/5 통과 → ✅ 자기완결성 확보
---
*이 문서는 /plan 스킬 + Sequential Thinking MCP로 생성되었습니다.*
*아키텍처: N-depth 트리(order_nodes) + 하이브리드(고정 코어 + options JSON)*

View File

@@ -0,0 +1,335 @@
# 수주관리 (Order Management) API 연동 계획
> **작성일**: 2025-01-08
> **목적**: 수주관리 페이지 Mock 데이터 → API 연동
> **상태**: ✅ Phase 3 완료 (100% 완료)
---
## 📍 현재 진행 상태
| 항목 | 내용 |
|------|------|
| **마지막 완료 작업** | 버그 수정 - 목록 페이지 서버 에러 해결 (3건) |
| **다음 작업** | 완료 |
| **진행률** | 3/3 Phase (100%) + 버그 수정 완료 |
| **마지막 업데이트** | 2025-01-09 |
| **커밋** | 버그 수정 커밋 완료 |
---
## 1. 개요
### 1.1 배경
수주관리 페이지는 프론트엔드 UI가 구현되어 있으나, **하드코딩된 Mock 데이터(SAMPLE_ORDERS)**를 사용 중입니다.
실제 비즈니스 운영을 위해 API 연동이 필요합니다.
### 1.2 현재 구현 상태 분석
#### API (Laravel) - ✅ Phase 1 완료
| 구성요소 | 파일 경로 | 상태 |
|---------|----------|------|
| Model | `api/app/Models/Orders/Order.php` | ✅ 존재 |
| Model | `api/app/Models/Orders/OrderItem.php` | ✅ 존재 |
| Model | `api/app/Models/Orders/OrderHistory.php` | ✅ 존재 |
| Model | `api/app/Models/Orders/OrderVersion.php` | ✅ 존재 |
| Model | `api/app/Models/Orders/OrderItemComponent.php` | ✅ 존재 |
| Controller | `api/app/Http/Controllers/Api/V1/OrderController.php` | ✅ **완료** |
| Service | `api/app/Services/OrderService.php` | ✅ **완료** |
| FormRequest | `api/app/Http/Requests/Order/*.php` | ✅ **완료** (3개) |
| Route | `/api/v1/orders` | ✅ **완료** (7개 엔드포인트) |
| Swagger | `api/app/Swagger/v1/OrderApi.php` | ✅ **완료** |
#### Frontend (React/Next.js) - ✅ Phase 2 완료
| 구성요소 | 파일 경로 | 상태 |
|---------|----------|------|
| 목록 페이지 | `react/src/app/[locale]/(protected)/sales/order-management-sales/page.tsx` | ✅ API 연동 |
| 등록 페이지 | `react/src/app/[locale]/(protected)/sales/order-management-sales/new/page.tsx` | ✅ API 연동 |
| 상세 페이지 | `react/src/app/[locale]/(protected)/sales/order-management-sales/[id]/page.tsx` | ✅ API 연동 |
| 수정 페이지 | `react/src/app/[locale]/(protected)/sales/order-management-sales/[id]/edit/page.tsx` | ✅ API 연동 |
| 생산지시 페이지 | `react/src/app/[locale]/(protected)/sales/order-management-sales/[id]/production-order/page.tsx` | ✅ 완료 |
| 등록 컴포넌트 | `react/src/components/orders/OrderRegistration.tsx` | ✅ 완료 |
| 견적선택 다이얼로그 | `react/src/components/orders/QuotationSelectDialog.tsx` | ✅ 완료 |
| 품목추가 다이얼로그 | `react/src/components/orders/ItemAddDialog.tsx` | ✅ 완료 |
| **actions.ts** | `react/src/components/orders/actions.ts` | ✅ **완료** |
### 1.3 연관관계
```
┌─────────────────┐ ┌─────────────────┐
│ Quote │────── quote_id ────▶│ Order │
│ (견적서) │ │ (수주) │
└─────────────────┘ └─────────────────┘
│ sales_order_id
┌─────────────────┐
│ WorkOrder │
│ (작업지시) │
└─────────────────┘
```
### 1.4 변경 승인 정책
| 분류 | 예시 | 승인 |
|------|------|------|
| ✅ 즉시 가능 | 필드 추가/변경, API 엔드포인트 추가 | 불필요 |
| ⚠️ 컨펌 필요 | 테이블 구조 변경, 기존 API 수정 | **필수** |
| 🔴 금지 | 기존 Order 모델 구조 변경 | 별도 협의 |
---
## 2. 대상 범위
### Phase 1: API 개발 (✅ 완료)
| # | 작업 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 1.1 | OrderController 생성 | ✅ | CRUD + 상태관리 (7개 메서드) |
| 1.2 | OrderService 생성 | ✅ | 비즈니스 로직 (index, stats, show, store, update, destroy, updateStatus) |
| 1.3 | FormRequest 생성 | ✅ | Store, Update, UpdateStatus (3개) |
| 1.4 | API 라우트 등록 | ✅ | routes/api.php (7개 엔드포인트) |
| 1.5 | Swagger 문서 작성 | ✅ | OrderApi.php (스키마 8개) |
### Phase 2: Frontend 연동 (✅ 완료)
| # | 작업 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 2.1 | actions.ts 생성 | ✅ | API 호출 함수 + 타입 정의 + 변환 함수 |
| 2.2 | 목록 페이지 연동 | ✅ | getOrders(), getOrderStats() 연동 |
| 2.3 | 상세 페이지 연동 | ✅ | getOrderById() 연동 + 타입 오류 수정 |
| 2.4 | 등록 페이지 연동 | ✅ | createOrder() 연동 |
| 2.5 | 수정 페이지 연동 | ✅ | updateOrder() 연동 + 타입 오류 수정 |
### Phase 3: 고급 기능 (✅ 완료)
| # | 작업 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 3.1 | 견적서 → 수주 변환 | ✅ | QuotationSelectDialog + createOrderFromQuote() |
| 3.2 | 생산지시 생성 연동 | ✅ | createProductionOrder() + production-order 페이지 |
| 3.3 | 상태 흐름 관리 | ✅ | 수주확정 다이얼로그 + updateOrderStatus() |
---
## 3. API 엔드포인트 설계
### 3.1 REST API
| Method | Endpoint | 설명 | 우선순위 |
|--------|----------|------|:--------:|
| GET | `/api/v1/orders` | 수주 목록 조회 (페이징/필터) | 🔴 |
| GET | `/api/v1/orders/stats` | 수주 통계 | 🔴 |
| GET | `/api/v1/orders/{id}` | 수주 상세 조회 | 🔴 |
| POST | `/api/v1/orders` | 수주 생성 | 🔴 |
| PUT | `/api/v1/orders/{id}` | 수주 수정 | 🟡 |
| DELETE | `/api/v1/orders/{id}` | 수주 삭제 | 🟡 |
| PATCH | `/api/v1/orders/{id}/status` | 상태 변경 | 🟡 |
| POST | `/api/v1/orders/{id}/production-order` | 생산지시 생성 | 🟢 |
| POST | `/api/v1/orders/from-quote/{quoteId}` | 견적→수주 변환 | 🟢 |
### 3.2 데이터 스키마
#### Order (수주) - 기존 모델 기반
```typescript
interface Order {
id: number;
tenantId: number;
quoteId?: number; // 원본 견적
orderNo: string; // 수주번호 (KD-TS-YYMMDD-NN)
orderTypeCode: 'ORDER' | 'PURCHASE';
statusCode: 'DRAFT' | 'CONFIRMED' | 'IN_PROGRESS' | 'COMPLETED' | 'CANCELLED';
clientId?: number;
clientName?: string;
siteName?: string; // 현장명
quantity: number;
supplyAmount: number;
taxAmount: number;
totalAmount: number;
deliveryDate?: Date;
deliveryMethodCode?: string;
memo?: string;
createdBy?: number;
updatedBy?: number;
createdAt: Date;
updatedAt: Date;
// Relations
items?: OrderItem[];
client?: Client;
}
```
#### OrderItem (수주 품목)
```typescript
interface OrderItem {
id: number;
orderId: number;
itemId?: number;
itemName: string;
specification?: string;
quantity: number;
unit?: string;
unitPrice: number;
supplyAmount: number;
taxAmount: number;
totalAmount: number;
sortOrder: number;
}
```
---
## 4. 작업 절차
### Step 1: API 개발 (Backend)
```
1. OrderService 생성
├── index(): 목록 조회 (페이징, 필터링)
├── show(): 상세 조회
├── store(): 생성
├── update(): 수정
├── destroy(): 삭제
├── updateStatus(): 상태 변경
├── stats(): 통계 조회
└── createFromQuote(): 견적→수주 변환
2. OrderController 생성
├── FormRequest DI
└── ApiResponse::handle() 사용
3. FormRequest 생성
├── StoreOrderRequest
└── UpdateOrderRequest
4. 라우트 등록
└── Route::prefix('orders')->group(...)
5. Swagger 문서 작성
└── app/Swagger/v1/OrderApi.php
```
### Step 2: Frontend 연동
```
1. actions.ts 생성
├── getOrders(): 목록 조회
├── getOrderById(): 상세 조회
├── createOrder(): 생성
├── updateOrder(): 수정
├── deleteOrder(): 삭제
├── updateOrderStatus(): 상태 변경
└── getOrderStats(): 통계 조회
2. 페이지별 연동
├── page.tsx: SAMPLE_ORDERS → getOrders()
├── [id]/page.tsx: Mock → getOrderById()
├── new/page.tsx: Mock → createOrder()
└── [id]/edit/page.tsx: Mock → updateOrder()
```
---
## 5. 의존성
### 5.1 필수 선행 작업
- **없음** - Order 모델 이미 존재, 바로 작업 가능
### 5.2 연관 기능 (선택적)
- **견적관리 (Quote)**: 견적→수주 변환 시 필요
- **거래처관리 (Client)**: 거래처 연동
- **품목관리 (Item)**: 품목 마스터 연동
### 5.3 후속 연동
- **작업지시 (WorkOrder)**: 생산지시 생성 시 `sales_order_id` 연결
- **출하관리**: 수주 완료 후 출하 처리
---
## 6. 참고 문서
- **빠른 시작**: `docs/quickstart/quick-start.md`
- **API 규칙**: `docs/standards/api-rules.md`
- **품질 체크리스트**: `docs/standards/quality-checklist.md`
- **DB 스키마**: `docs/specs/database-schema.md`
- **Swagger 가이드**: `docs/guides/swagger-guide.md`
### 참고 코드
- **작업지시 API (참고용)**: `api/app/Http/Controllers/Api/V1/WorkOrderController.php`
- **공정관리 actions.ts (참고용)**: `react/src/components/process-management/actions.ts`
---
## 7. 검증 방법
### 7.1 API 테스트
```bash
# 목록 조회
curl -X GET "http://api.sam.kr/api/v1/orders" -H "X-Api-Key: ..."
# 상세 조회
curl -X GET "http://api.sam.kr/api/v1/orders/1" -H "X-Api-Key: ..."
# 통계 조회
curl -X GET "http://api.sam.kr/api/v1/orders/stats" -H "X-Api-Key: ..."
```
### 7.2 성공 기준
| 기준 | 측정 방법 |
|------|----------|
| API CRUD 동작 | Swagger UI 테스트 통과 |
| 목록 페이지 | 실제 데이터 표시 |
| 상세 페이지 | 수주 정보 정상 표시 |
| 등록/수정 | 데이터 저장 및 조회 |
| 상태 변경 | DRAFT → CONFIRMED 전환 |
---
## 8. 자기완결성 점검
| # | 검증 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 1 | 작업 목적이 명확한가? | ✅ | Mock → API 연동 |
| 2 | 성공 기준이 정의되어 있는가? | ✅ | 섹션 7 참조 |
| 3 | 작업 범위가 구체적인가? | ✅ | Phase 1-3 단계별 정의 |
| 4 | 의존성이 명시되어 있는가? | ✅ | 선행 작업 없음 |
| 5 | 참고 파일 경로가 정확한가? | ✅ | 모든 경로 검증됨 |
| 6 | 단계별 절차가 실행 가능한가? | ✅ | Step 1-2 상세 정의 |
| 7 | 검증 방법이 명시되어 있는가? | ✅ | curl 테스트 + 기준 |
| 8 | 모호한 표현이 없는가? | ✅ | 구체적 파일/엔드포인트 명시 |
---
## 9. 버그 수정 이력
### 2025-01-09: 목록 페이지 서버 에러 수정
| # | 파일 | 문제 | 수정 내용 |
|---|------|------|----------|
| 1 | `react/.../page.tsx:120` | API 응답 데이터 구조 불일치 | `ordersResult.data``ordersResult.data.items` |
| 2 | `api/.../OrderService.php:113` | Quote 필드명 오류 | `quote:id,quote_no,site_name``quote:id,quote_number,site_name` |
| 3 | `react/.../actions.ts:384` | Quote 필드명 오류 | `apiData.quote?.quote_no``apiData.quote?.quote_number` |
**원인 분석:**
- `getOrders()` 함수는 `{ items: Order[], total, page, totalPages }` 구조를 반환하나, 페이지에서 `ordersResult.data`를 직접 사용하여 타입 불일치 발생
- Quote 모델의 필드명이 `quote_number`인데 `quote_no`로 잘못 참조
**영향 범위:**
- 수주 목록 페이지 접근 시 서버 에러 발생
- 견적 연동 수주의 견적번호 표시 오류
### 2025-01-09: 수주 등록 페이지 거래처 API 연동
| # | 파일 | 변경 내용 |
|---|------|----------|
| 1 | `react/.../OrderRegistration.tsx` | `SAMPLE_CLIENTS` 하드코딩 제거 |
| 2 | `react/.../OrderRegistration.tsx` | `useClientList` 훅으로 실제 API 연동 |
| 3 | `react/.../OrderRegistration.tsx` | 로딩 상태 처리 ("불러오는 중...") |
| 4 | `react/.../OrderRegistration.tsx` | 견적 선택 시 발주처 필드 비활성화 |
**개선 내용:**
- 발주처(거래처) 드롭다운이 `/api/proxy/clients` API에서 실제 데이터 조회
- 견적 선택 시 발주처가 자동 설정되고 필드 비활성화
- 로딩 중 "불러오는 중..." 플레이스홀더 표시
---
*이 문서는 독립 세션에서 바로 작업 시작 가능하도록 설계되었습니다.*

View File

@@ -0,0 +1,659 @@
# 수주-작업지시-출하 하이브리드 연동 구조 구현 계획
> **작성일**: 2025-01-19
> **목적**: Order → WorkOrder → Shipment 간 FK 연결 강화 및 상태 동기화 로직 구현
> **기준 문서**: `api/app/Models/Orders/Order.php`, `api/app/Models/Production/WorkOrder.php`, `api/app/Models/Tenants/Shipment.php`
> **상태**: 📋 계획 수립 완료 (Serena ID: order-integration-state)
---
## 📍 현재 진행 상태
| 항목 | 내용 |
|------|------|
| **마지막 완료 작업** | Phase 4: 작업완료 시 자동 출하 생성 기능 구현 |
| **다음 작업** | ✅ 모든 Phase 완료 |
| **진행률** | 4/4 Phase (100%) |
| **마지막 업데이트** | 2025-01-19 |
---
## 1. 개요
### 1.1 배경
현재 SAM 시스템은 수주(Order), 작업지시(WorkOrder), 출하(Shipment)가 독립적으로 운영되고 있습니다.
**현재 문제점:**
- `shipments` 테이블에 `work_order_id` FK가 없음
- 작업 완료 시 출하로 자동 연결되지 않음
- Order의 전체 진행 상태를 추적할 수 없음
- 데이터 정합성 보장이 어려움
**목표:**
- 하이브리드 마스터-디테일 구조로 전환
- `orders.status_code`로 전체 진행 상태 추적
- 각 단계별 상태 변경 시 연관 테이블 자동 동기화
### 1.2 목표 구조
```
┌─────────────────────────────────────────────────────────────────┐
│ 목표 구조 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ orders (마스터) │
│ ├─ status_code: 전체 진행상태 추적 │
│ │ DRAFT → CONFIRMED → IN_PRODUCTION → PRODUCED │
│ │ → SHIPPING → SHIPPED → COMPLETED │
│ │ │
│ ├──(1:N)──▶ work_orders (생산 상세) │
│ │ ├─ sales_order_id FK ✅ (기존) │
│ │ └─ status: 생산 프로세스 상태 │
│ │ │
│ └──(1:N)──▶ shipments (출하 상세) │
│ ├─ order_id FK ✅ (기존) │
│ ├─ work_order_id FK 🆕 (신규 추가) │
│ └─ status: 출하 프로세스 상태 │
│ │
└─────────────────────────────────────────────────────────────────┘
```
### 1.3 기준 원칙
```
┌─────────────────────────────────────────────────────────────────┐
│ 🎯 핵심 원칙 │
├─────────────────────────────────────────────────────────────────┤
│ 1. orders.status_code = 전체 프로세스의 Single Source of Truth │
│ 2. 하위 테이블(work_orders, shipments)은 상세 정보만 관리 │
│ 3. 상태 변경 시 상위 테이블 자동 동기화 │
│ 4. 기존 데이터 호환성 유지 (work_order_id는 nullable) │
└─────────────────────────────────────────────────────────────────┘
```
### 1.4 변경 승인 정책
| 분류 | 예시 | 승인 |
|------|------|------|
| ✅ 즉시 가능 | 모델 관계 추가, 상수 추가, 문서 수정 | 불필요 |
| ⚠️ 컨펌 필요 | 마이그레이션, 서비스 로직 변경, 상태 동기화 | **필수** |
| 🔴 금지 | 기존 테이블 구조 파괴적 변경, 기존 API 삭제 | 별도 협의 |
### 1.5 준수 규칙
- `docs/quickstart/quick-start.md` - 빠른 시작 가이드
- `docs/standards/quality-checklist.md` - 품질 체크리스트
- `CLAUDE.md` - SAM API Development Rules
---
## 2. 대상 범위
### 2.1 Phase 1: DB 스키마 수정
| # | 작업 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 1.1 | `shipments` 테이블에 `work_order_id` FK 추가 마이그레이션 | ⏳ | nullable, index 포함 |
### 2.2 Phase 2: 모델 관계 추가
| # | 작업 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 2.1 | Order 모델에 `shipments()` HasMany 관계 추가 | ⏳ | |
| 2.2 | WorkOrder 모델에 `shipments()` HasMany 관계 추가 | ⏳ | |
| 2.3 | Shipment 모델에 `workOrder()` BelongsTo 관계 추가 | ⏳ | |
| 2.4 | Shipment 모델에 `work_order_id` fillable 추가 | ⏳ | |
### 2.3 Phase 3: Order 상태 확장 및 동기화 로직
| # | 작업 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 3.1 | Order 모델에 생산/출하 관련 상태 상수 추가 | ⏳ | IN_PRODUCTION, PRODUCED, SHIPPING, SHIPPED |
| 3.2 | WorkOrderService에 Order 상태 동기화 로직 추가 | ⏳ | 상태 변경 시 Order.status_code 업데이트 |
| 3.3 | ShipmentService에 Order 상태 동기화 로직 추가 | ⏳ | 상태 변경 시 Order.status_code 업데이트 |
### 2.4 Phase 4: 연동 기능 (선택)
| # | 작업 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 4.1 | ShipmentService.store()에 work_order_id 연결 로직 추가 | ⏳ | 출하 생성 시 WorkOrder 선택 가능 |
| 4.2 | WorkOrder 완료 시 Shipment 자동 생성 옵션 | ⏳ | 선택적 기능 |
---
## 3. 작업 절차
### 3.1 단계별 절차
```
Phase 1: DB 스키마 수정
└── 1.1 마이그레이션 생성 및 실행
├── add_work_order_id_to_shipments_table.php
├── work_order_id FK (nullable)
└── index 추가
Phase 2: 모델 관계 추가
├── 2.1 Order.php - shipments() HasMany
├── 2.2 WorkOrder.php - shipments() HasMany
├── 2.3 Shipment.php - workOrder() BelongsTo
└── 2.4 Shipment.php - fillable에 work_order_id 추가
Phase 3: 상태 동기화
├── 3.1 Order.php - 상태 상수 확장
│ ├── STATUS_IN_PRODUCTION = 'IN_PRODUCTION'
│ ├── STATUS_PRODUCED = 'PRODUCED'
│ ├── STATUS_SHIPPING = 'SHIPPING'
│ └── STATUS_SHIPPED = 'SHIPPED'
├── 3.2 WorkOrderService.php - syncOrderStatus() 메서드 추가
│ ├── in_progress → Order: IN_PRODUCTION
│ ├── completed → Order: PRODUCED
│ └── shipped → Order: (Shipment 생성 시)
└── 3.3 ShipmentService.php - syncOrderStatus() 메서드 추가
├── scheduled/ready → Order: SHIPPING (첫 출하 생성 시)
└── completed → Order: SHIPPED (모든 출하 완료 시)
Phase 4: 연동 기능 (선택)
├── 4.1 ShipmentService.store() - work_order_id 파라미터 추가
└── 4.2 WorkOrderService.updateStatus() - 자동 Shipment 생성 옵션
```
### 3.2 상태 흐름도
```
┌─────────────────────────────────────────────────────────────────┐
│ 전체 상태 흐름 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ [Order] │
│ DRAFT ──▶ CONFIRMED ──▶ IN_PRODUCTION ──▶ PRODUCED │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ WorkOrder WorkOrder WorkOrder │
│ 생성 in_progress completed │
│ │ │
│ ▼ │
│ ──────────────────────▶ SHIPPING ──▶ SHIPPED ──▶ COMPLETED │
│ │ │ │
│ ▼ ▼ │
│ Shipment Shipment │
│ 생성 completed │
│ │
└─────────────────────────────────────────────────────────────────┘
```
---
## 4. 상세 작업 내용
### 4.1 Phase 1: DB 스키마 수정
#### 1.1 마이그레이션: shipments 테이블에 work_order_id 추가
**파일**: `api/database/migrations/2025_01_19_XXXXXX_add_work_order_id_to_shipments_table.php`
```php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('shipments', function (Blueprint $table) {
$table->foreignId('work_order_id')
->nullable()
->after('order_id')
->comment('작업지시 ID');
$table->index(['tenant_id', 'work_order_id']);
});
}
public function down(): void
{
Schema::table('shipments', function (Blueprint $table) {
$table->dropIndex(['tenant_id', 'work_order_id']);
$table->dropColumn('work_order_id');
});
}
};
```
---
### 4.2 Phase 2: 모델 관계 추가
#### 2.1 Order 모델 - shipments() 관계
**파일**: `api/app/Models/Orders/Order.php`
```php
use App\Models\Tenants\Shipment;
/**
* 출하 목록
*/
public function shipments(): HasMany
{
return $this->hasMany(Shipment::class, 'order_id');
}
```
#### 2.2 WorkOrder 모델 - shipments() 관계
**파일**: `api/app/Models/Production/WorkOrder.php`
```php
use App\Models\Tenants\Shipment;
/**
* 출하 목록
*/
public function shipments(): HasMany
{
return $this->hasMany(Shipment::class);
}
```
#### 2.3-2.4 Shipment 모델 수정
**파일**: `api/app/Models/Tenants/Shipment.php`
```php
use App\Models\Production\WorkOrder;
// fillable에 추가
protected $fillable = [
// ... 기존 필드들
'work_order_id', // 추가
];
// casts에 추가
protected $casts = [
// ... 기존 캐스트들
'work_order_id' => 'integer', // 추가
];
/**
* 작업지시 관계
*/
public function workOrder(): BelongsTo
{
return $this->belongsTo(WorkOrder::class);
}
```
---
### 4.3 Phase 3: Order 상태 확장 및 동기화 로직
#### 3.1 Order 모델 - 상태 상수 확장
**파일**: `api/app/Models/Orders/Order.php`
```php
// 기존 상태
public const STATUS_DRAFT = 'DRAFT';
public const STATUS_CONFIRMED = 'CONFIRMED';
public const STATUS_IN_PROGRESS = 'IN_PROGRESS';
public const STATUS_COMPLETED = 'COMPLETED';
public const STATUS_CANCELLED = 'CANCELLED';
// 신규 상태 추가
public const STATUS_IN_PRODUCTION = 'IN_PRODUCTION'; // 생산중
public const STATUS_PRODUCED = 'PRODUCED'; // 생산완료
public const STATUS_SHIPPING = 'SHIPPING'; // 출하중
public const STATUS_SHIPPED = 'SHIPPED'; // 출하완료
/**
* 전체 상태 목록
*/
public const STATUSES = [
self::STATUS_DRAFT,
self::STATUS_CONFIRMED,
self::STATUS_IN_PRODUCTION,
self::STATUS_PRODUCED,
self::STATUS_SHIPPING,
self::STATUS_SHIPPED,
self::STATUS_COMPLETED,
self::STATUS_CANCELLED,
];
/**
* 상태 라벨
*/
public const STATUS_LABELS = [
self::STATUS_DRAFT => '임시저장',
self::STATUS_CONFIRMED => '확정',
self::STATUS_IN_PRODUCTION => '생산중',
self::STATUS_PRODUCED => '생산완료',
self::STATUS_SHIPPING => '출하중',
self::STATUS_SHIPPED => '출하완료',
self::STATUS_COMPLETED => '완료',
self::STATUS_CANCELLED => '취소',
];
```
#### 3.2 WorkOrderService - Order 상태 동기화
**파일**: `api/app/Services/WorkOrderService.php`
```php
use App\Models\Orders\Order;
/**
* Order 상태 동기화
* WorkOrder 상태 변경 시 Order.status_code 업데이트
*/
private function syncOrderStatus(WorkOrder $workOrder): void
{
if (!$workOrder->sales_order_id) {
return;
}
$order = Order::find($workOrder->sales_order_id);
if (!$order) {
return;
}
$newStatus = null;
switch ($workOrder->status) {
case WorkOrder::STATUS_IN_PROGRESS:
case WorkOrder::STATUS_WAITING:
case WorkOrder::STATUS_PENDING:
// 하나라도 진행중이면 생산중
$newStatus = Order::STATUS_IN_PRODUCTION;
break;
case WorkOrder::STATUS_COMPLETED:
// 모든 작업지시가 완료되었는지 확인
$allCompleted = WorkOrder::where('sales_order_id', $order->id)
->whereNotIn('status', [WorkOrder::STATUS_COMPLETED, WorkOrder::STATUS_SHIPPED])
->doesntExist();
if ($allCompleted) {
$newStatus = Order::STATUS_PRODUCED;
}
break;
}
if ($newStatus && $order->status_code !== $newStatus) {
$order->update(['status_code' => $newStatus]);
$this->auditLogger->log(
$order->tenant_id,
'order',
$order->id,
'status_synced_from_work_order',
['status_code' => $order->getOriginal('status_code')],
['status_code' => $newStatus, 'work_order_id' => $workOrder->id]
);
}
}
```
**updateStatus() 메서드에 호출 추가:**
```php
public function updateStatus(int $id, string $status, ?array $resultData = null)
{
// ... 기존 로직 ...
return DB::transaction(function () use ($workOrder, $status, $resultData, $tenantId, $userId) {
// ... 기존 상태 변경 로직 ...
$workOrder->save();
// Order 상태 동기화 추가
$this->syncOrderStatus($workOrder);
// ... 나머지 로직 ...
});
}
```
#### 3.3 ShipmentService - Order 상태 동기화
**파일**: `api/app/Services/ShipmentService.php`
```php
use App\Models\Orders\Order;
/**
* Order 상태 동기화
* Shipment 상태 변경 시 Order.status_code 업데이트
*/
private function syncOrderStatus(Shipment $shipment): void
{
if (!$shipment->order_id) {
return;
}
$order = Order::find($shipment->order_id);
if (!$order) {
return;
}
$newStatus = null;
switch ($shipment->status) {
case 'scheduled':
case 'ready':
case 'shipping':
// 출하 프로세스 시작
if (!in_array($order->status_code, [Order::STATUS_SHIPPING, Order::STATUS_SHIPPED, Order::STATUS_COMPLETED])) {
$newStatus = Order::STATUS_SHIPPING;
}
break;
case 'completed':
// 모든 출하가 완료되었는지 확인
$allCompleted = Shipment::where('order_id', $order->id)
->where('status', '!=', 'completed')
->doesntExist();
if ($allCompleted) {
$newStatus = Order::STATUS_SHIPPED;
}
break;
}
if ($newStatus && $order->status_code !== $newStatus) {
$order->update(['status_code' => $newStatus]);
}
}
```
**store() 및 updateStatus() 메서드에 호출 추가:**
```php
public function store(array $data): Shipment
{
// ... 기존 로직 ...
return DB::transaction(function () use ($data, $tenantId, $userId) {
// ... 기존 생성 로직 ...
// Order 상태 동기화 추가
$this->syncOrderStatus($shipment);
return $shipment->load('items');
});
}
public function updateStatus(int $id, string $status, ?array $additionalData = null): Shipment
{
// ... 기존 로직 ...
$shipment->update($updateData);
// Order 상태 동기화 추가
$this->syncOrderStatus($shipment);
return $shipment->load('items');
}
```
---
### 4.4 Phase 4: 연동 기능 (선택)
#### 4.1 ShipmentService.store() - work_order_id 연결
**파일**: `api/app/Services/ShipmentService.php`
```php
public function store(array $data): Shipment
{
return DB::transaction(function () use ($data, $tenantId, $userId) {
$shipment = Shipment::create([
// ... 기존 필드들 ...
'work_order_id' => $data['work_order_id'] ?? null, // 추가
]);
// WorkOrder가 있으면 상태를 shipped로 변경
if ($shipment->work_order_id) {
$workOrder = WorkOrder::find($shipment->work_order_id);
if ($workOrder && $workOrder->status === WorkOrder::STATUS_COMPLETED) {
$workOrder->update([
'status' => WorkOrder::STATUS_SHIPPED,
'shipped_at' => now(),
]);
}
}
// ... 나머지 로직 ...
});
}
```
#### 4.2 ShipmentStoreRequest - work_order_id 검증
**파일**: `api/app/Http/Requests/Shipment/ShipmentStoreRequest.php`
```php
public function rules(): array
{
return [
// ... 기존 규칙들 ...
'work_order_id' => ['nullable', 'integer', 'exists:work_orders,id'],
];
}
```
---
## 5. 컨펌 대기 목록
| # | 항목 | 변경 내용 | 영향 범위 | 상태 |
|---|------|----------|----------|------|
| 1 | 마이그레이션 | shipments에 work_order_id FK 추가 | DB | ⏳ 컨펌 필요 |
| 2 | Order 상태 확장 | 4개 상태 추가 (IN_PRODUCTION, PRODUCED, SHIPPING, SHIPPED) | Order 모델, API | ⏳ 컨펌 필요 |
| 3 | 상태 동기화 로직 | WorkOrder/Shipment 상태 변경 시 Order 자동 업데이트 | 서비스 로직 | ⏳ 컨펌 필요 |
---
## 6. 변경 이력
| 날짜 | 항목 | 변경 내용 | 파일 | 승인 |
|------|------|----------|------|------|
| 2025-01-19 | - | 계획 문서 초안 작성 | - | - |
---
## 7. 참고 문서
- **빠른 시작**: `docs/quickstart/quick-start.md`
- **품질 체크리스트**: `docs/standards/quality-checklist.md`
- **SAM API 규칙**: `CLAUDE.md`
- **DB 스키마**: `docs/specs/database-schema.md`
### 분석된 기존 파일
| 파일 | 역할 |
|------|------|
| `api/app/Models/Orders/Order.php` | 수주 마스터 모델 |
| `api/app/Models/Production/WorkOrder.php` | 작업지시 모델 |
| `api/app/Models/Tenants/Shipment.php` | 출하 모델 |
| `api/app/Services/WorkOrderService.php` | 작업지시 비즈니스 로직 |
| `api/app/Services/ShipmentService.php` | 출하 비즈니스 로직 |
| `api/database/migrations/2025_12_26_100000_create_work_orders_table.php` | 작업지시 테이블 |
| `api/database/migrations/2025_12_26_150604_create_shipments_table.php` | 출하 테이블 |
---
## 8. 세션 및 메모리 관리 정책
### 8.1 세션 시작 시
```javascript
read_memory("order-integration-state") // 상태 파악
read_memory("order-integration-snapshot") // 사고 흐름 복구
```
### 8.2 Serena 메모리 구조
- `order-integration-state`: { phase, progress, next_step, last_decision }
- `order-integration-snapshot`: 현재까지의 논의 및 코드 변경점 요약
- `order-integration-rules`: 해당 작업에서 결정된 규칙들
---
## 9. 검증 결과
> 작업 완료 후 이 섹션에 검증 결과 추가
### 9.1 테스트 케이스
| 시나리오 | 예상 결과 | 실제 결과 | 상태 |
|----------|----------|----------|------|
| WorkOrder 생성 (in_progress) | Order.status = IN_PRODUCTION | - | ⏳ |
| WorkOrder 완료 (completed) | Order.status = PRODUCED | - | ⏳ |
| Shipment 생성 | Order.status = SHIPPING | - | ⏳ |
| Shipment 완료 | Order.status = SHIPPED | - | ⏳ |
| 모든 프로세스 완료 | Order.status = COMPLETED | - | ⏳ |
### 9.2 성공 기준
| 기준 | 달성 | 비고 |
|------|------|------|
| shipments.work_order_id FK 추가 완료 | ⏳ | |
| 모델 관계 정상 동작 | ⏳ | |
| Order 상태 자동 동기화 | ⏳ | |
| 기존 데이터 호환성 유지 | ⏳ | |
---
## 10. 자기완결성 점검 결과
### 10.1 체크리스트 검증
| # | 검증 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 1 | 작업 목적이 명확한가? | ✅ | 섹션 1.1 배경 |
| 2 | 성공 기준이 정의되어 있는가? | ✅ | 섹션 9.2 |
| 3 | 작업 범위가 구체적인가? | ✅ | 섹션 2 대상 범위 |
| 4 | 의존성이 명시되어 있는가? | ✅ | Phase별 순서 정의 |
| 5 | 참고 파일 경로가 정확한가? | ✅ | 섹션 7 참고 문서 |
| 6 | 단계별 절차가 실행 가능한가? | ✅ | 섹션 3, 4 상세 코드 포함 |
| 7 | 검증 방법이 명시되어 있는가? | ✅ | 섹션 9.1 테스트 케이스 |
| 8 | 모호한 표현이 없는가? | ✅ | 구체적 코드 예시 포함 |
### 10.2 새 세션 시뮬레이션 테스트
| 질문 | 답변 가능 | 참조 섹션 |
|------|:--------:|----------|
| Q1. 이 작업의 목적은 무엇인가? | ✅ | 1.1 배경 |
| Q2. 어디서부터 시작해야 하는가? | ✅ | 현재 진행 상태, 3.1 절차 |
| Q3. 어떤 파일을 수정해야 하는가? | ✅ | 섹션 4 상세 작업 내용 |
| Q4. 작업 완료 확인 방법은? | ✅ | 9. 검증 결과 |
| Q5. 막혔을 때 참고 문서는? | ✅ | 7. 참고 문서 |
**결과**: 5/5 통과 → ✅ 자기완결성 확보
---
*이 문서는 /sc:plan 스킬로 생성되었습니다.*

View File

@@ -0,0 +1,397 @@
# 공정관리 (Process Management) API 연동 계획
> **작성일**: 2025-01-08
> **목적**: 공정관리 기능 검증 및 테스트
> **상태**: ✅ 검증 완료
---
## 📍 현재 진행 상태
| 항목 | 내용 |
|------|------|
| **마지막 완료 작업** | Phase 3: 개별 품목 연결 기능 (process_items) |
| **다음 작업** | 완료 (Phase 2는 선택사항) |
| **진행률** | 5/5 (100%) - Phase 1 + Phase 3 완료 |
| **마지막 업데이트** | 2026-01-08 |
---
## 1. 개요
### 1.1 기능 설명
공정관리는 MES 시스템의 기초 데이터로, 생산 공정을 정의하고 관리하는 기능입니다.
작업지시 생성 시 공정 유형(process_type)으로 연결되며, 자동 분류 규칙을 통해 품목별 공정 배정을 자동화합니다.
### 1.2 현재 구현 상태 분석
#### API (Laravel) - ✅ 완료
| 구성요소 | 파일 경로 | 상태 |
|---------|----------|:----:|
| Model | `api/app/Models/Process.php` | ✅ |
| Model | `api/app/Models/ProcessClassificationRule.php` | ✅ |
| Model | `api/app/Models/ProcessItem.php` | ✅ (Phase 3) |
| Migration | `api/database/migrations/2026_01_08_180607_create_process_items_table.php` | ✅ |
| Service | `api/app/Services/ProcessService.php` | ✅ |
| Controller | `api/app/Http/Controllers/V1/ProcessController.php` | ✅ |
| FormRequest | `api/app/Http/Requests/V1/Process/StoreProcessRequest.php` | ✅ |
| FormRequest | `api/app/Http/Requests/V1/Process/UpdateProcessRequest.php` | ✅ |
| Swagger | `api/app/Swagger/v1/ProcessApi.php` | ✅ |
| Route | `/api/v1/processes` | ✅ |
#### Frontend (React/Next.js) - ✅ API 연동 완료
| 구성요소 | 파일 경로 | 상태 |
|---------|----------|:----:|
| 목록 페이지 | `react/src/app/[locale]/(protected)/master-data/process-management/page.tsx` | ✅ |
| 등록 페이지 | `react/src/app/[locale]/(protected)/master-data/process-management/new/page.tsx` | ✅ |
| 상세 페이지 | `react/src/app/[locale]/(protected)/master-data/process-management/[id]/page.tsx` | ✅ |
| 수정 페이지 | `react/src/app/[locale]/(protected)/master-data/process-management/[id]/edit/page.tsx` | ✅ |
| 목록 컴포넌트 | `react/src/components/process-management/ProcessListClient.tsx` | ✅ |
| 폼 컴포넌트 | `react/src/components/process-management/ProcessForm.tsx` | ✅ |
| 상세 컴포넌트 | `react/src/components/process-management/ProcessDetail.tsx` | ✅ |
| 규칙 모달 | `react/src/components/process-management/RuleModal.tsx` | ✅ |
| **actions.ts** | `react/src/components/process-management/actions.ts` | ✅ |
### 1.3 관련 URL
| 화면 | URL | 설명 |
|------|-----|------|
| 공정목록 | `/master-data/process-management` | 토글 기능 포함 |
| 공정등록 | `/master-data/process-management/new` | 모달 - 규칙추가 |
| 공정상세 | `/master-data/process-management/{id}` | 상세 정보 |
| 공정수정 | `/master-data/process-management/{id}/edit` | 수정 폼 |
### 1.4 연관관계
```
┌─────────────────┐ process_type ┌─────────────────┐
│ Process │ ───────────────────────│ WorkOrder │
│ (공정관리) │ screen/slat/bending │ (작업지시) │
└─────────────────┘ └─────────────────┘
├── classificationRules (패턴 규칙)
│ ▼
│ ┌─────────────────────────┐
│ │ ProcessClassificationRule│
│ │ (자동 분류 규칙) │
│ └─────────────────────────┘
└── processItems (개별 품목) ← Phase 3
┌─────────────────────────┐ ┌─────────────────┐
│ ProcessItem │────────│ Item │
│ (공정-품목 연결) │ │ (품목) │
└─────────────────────────┘ └─────────────────┘
```
---
## 2. API 엔드포인트
### 2.1 REST API (구현 완료)
| Method | Endpoint | 설명 | 상태 |
|--------|----------|------|:----:|
| GET | `/api/v1/processes` | 공정 목록 조회 (검색/페이징) | ✅ |
| GET | `/api/v1/processes/{id}` | 공정 상세 조회 | ✅ |
| POST | `/api/v1/processes` | 공정 생성 | ✅ |
| PUT | `/api/v1/processes/{id}` | 공정 수정 | ✅ |
| DELETE | `/api/v1/processes/{id}` | 공정 삭제 | ✅ |
| DELETE | `/api/v1/processes` | 공정 일괄 삭제 | ✅ |
| PATCH | `/api/v1/processes/{id}/toggle` | 공정 상태 토글 | ✅ |
| GET | `/api/v1/processes/options` | 드롭다운용 옵션 목록 | ✅ |
| GET | `/api/v1/processes/stats` | 공정 통계 | ✅ |
### 2.2 actions.ts 구현 함수 (완료)
```typescript
// 목록/조회
getProcessList(params) // 목록 조회
getProcessById(id) // 상세 조회
getProcessOptions() // 드롭다운 옵션
getProcessStats() // 통계 조회
// CRUD
createProcess(data) // 생성
updateProcess(id, data) // 수정
deleteProcess(id) // 삭제
deleteProcesses(ids) // 일괄 삭제
toggleProcessActive(id) // 상태 토글
// 보조
getDepartmentOptions() // 부서 옵션 (분류 규칙용)
getItemList(params) // 품목 목록 (분류 규칙용)
```
---
## 3. 데이터 스키마
### 3.1 Process (공정)
```typescript
interface Process {
id: string;
processCode: string; // P-001, P-002
processName: string; // 공정명
description?: string; // 공정 설명
processType: '생산' | '검사' | '포장' | '조립';
department: string; // 담당 부서
workLogTemplate?: string; // 작업일지 양식
classificationRules: ClassificationRule[];
requiredWorkers: number; // 필요 작업자 수
equipmentInfo?: string; // 설비 정보
workSteps: string[]; // 작업 단계
note?: string;
status: '사용중' | '미사용';
createdAt: string;
updatedAt: string;
}
```
### 3.2 ClassificationRule (자동 분류 규칙)
```typescript
interface ClassificationRule {
id: string;
registrationType: 'pattern' | 'individual'; // 패턴 규칙 vs 개별 품목
ruleType: '품목코드' | '품목명' | '품목구분';
matchingType: 'startsWith' | 'endsWith' | 'contains' | 'equals';
conditionValue: string;
priority: number;
description?: string;
isActive: boolean;
createdAt: string;
}
```
### 3.3 ProcessItem (공정-품목 연결) - Phase 3 추가
```typescript
// API 응답 스키마
interface ApiProcessItem {
id: number;
process_id: number;
item_id: number;
priority: number;
is_active: boolean;
item?: {
id: number;
code: string;
name: string;
};
}
// DB 테이블: process_items
// - id (PK)
// - process_id (FK → processes)
// - item_id (FK → items)
// - priority (정렬 순서)
// - is_active (사용 여부)
// - created_at, updated_at
```
### 3.4 API 요청/응답 변환
#### 요청 (Frontend → API)
```typescript
// 패턴 규칙과 개별 품목 분리
{
classification_rules: [ // 패턴 규칙만
{ rule_type, matching_type, condition_value, ... }
],
item_ids: [123, 456, 789] // 개별 품목 ID 배열
}
```
#### 응답 (API → Frontend)
```typescript
// process_items를 individual 규칙으로 변환
{
classification_rules: [...], // 패턴 규칙
process_items: [ // 개별 품목 연결
{ id, process_id, item_id, priority, is_active, item: {...} }
]
}
```
---
## 4. 작업 범위
### Phase 1: 검증 및 테스트 (완료 - 2026-01-08)
| # | 작업 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 1.1 | 목록 조회 테스트 | ✅ | 검색, 탭 필터 정상 |
| 1.2 | 등록 기능 테스트 | ✅ | 정상 (담당부서는 DB 데이터 의존) |
| 1.3 | 수정 기능 테스트 | ✅ | 필요인원 변경/저장 정상 |
| 1.4 | 삭제 기능 테스트 | ⏭️ | 데이터 보존으로 생략 |
| 1.5 | 토글 기능 테스트 | ✅ | 사용중↔미사용 전환 정상 |
### 📋 참고사항
- **담당부서 드롭다운**: departments 테이블 데이터에 의존. 데이터 없으면 빈 드롭다운 (정상 동작)
### Phase 2: 개선 사항 (선택)
| # | 작업 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 2.1 | 공정 순서 드래그앤드롭 | ⏭️ | 후순위 |
| 2.2 | 작업 지침서 PDF 업로드 | ⏭️ | 후순위 |
| 2.3 | 공정 흐름도 시각화 | ⏭️ | 후순위 |
### Phase 3: 개별 품목 연결 기능 (완료 - 2026-01-08)
#### 배경
- 기존 분류 규칙에서 400개 이상의 품목 코드를 `,` 구분자로 저장 시도
- `condition_value` VARCHAR(255) 필드 초과 → API 422 에러 발생
- 해결: 개별 품목은 별도 테이블(`process_items`)로 관계형 저장
#### 완료 작업
| # | 작업 항목 | 상태 | 파일/위치 |
|---|----------|:----:|----------|
| 3.1 | ProcessItem 모델 생성 | ✅ | `api/app/Models/ProcessItem.php` |
| 3.2 | process_items 마이그레이션 | ✅ | `api/database/migrations/2026_01_08_180607_*` |
| 3.3 | Process 모델 관계 추가 | ✅ | `processItems()` HasMany |
| 3.4 | ProcessService 수정 | ✅ | `syncProcessItems()` 메서드 추가 |
| 3.5 | Validation 업데이트 | ✅ | `item_ids` 배열 검증 추가 |
| 3.6 | Swagger 문서 업데이트 | ✅ | `ProcessItem` 스키마 추가 |
| 3.7 | Frontend actions.ts 수정 | ✅ | 요청/응답 변환 로직 |
#### 핵심 변경 사항
**API 측 (Laravel)**
```php
// ProcessService.php
private function syncProcessItems(Process $process, array $itemIds): void
{
$process->processItems()->delete();
foreach ($itemIds as $index => $itemId) {
ProcessItem::create([
'process_id' => $process->id,
'item_id' => $itemId,
'priority' => $index,
'is_active' => true,
]);
}
}
```
**Frontend 측 (Next.js)**
```typescript
// actions.ts
// 패턴 규칙과 개별 품목 분리
const patternRules = data.classificationRules.filter(
(rule) => rule.registrationType === 'pattern'
);
const individualRules = data.classificationRules.filter(
(rule) => rule.registrationType === 'individual'
);
// item_ids 추출
const itemIds = individualRules.flatMap((rule) =>
rule.conditionValue.split(',').map((id) => parseInt(id.trim(), 10))
);
```
---
## 5. 주요 기능 상세
### 5.1 토글 기능
- 목록에서 각 공정의 사용/미사용 상태를 토글
- `PATCH /api/v1/processes/{id}/toggle` 호출
- 미사용 공정은 작업지시 생성 시 선택 불가
### 5.2 규칙 추가 (모달)
- 자동 분류 규칙을 통해 품목별 공정 자동 배정
- 우선순위(priority)에 따라 규칙 적용 순서 결정
- include/exclude로 포함/제외 규칙 설정
### 5.3 양식 보기 (모달)
- 작업일지 템플릿 미리보기
- HTML/마크다운 형식 지원
---
## 6. 의존성
### 6.1 필수 선행 작업
- **없음** (기초 데이터)
### 6.2 후속 연동
- **작업지시 (WorkOrder)**: 공정 유형 선택 (process_type: screen/slat/bending)
- **품목관리 (Item)**: 자동 분류 규칙 적용
---
## 7. 검증 방법
### 7.1 테스트 체크리스트
| 기능 | 테스트 항목 | 예상 결과 |
|------|-----------|----------|
| 목록 조회 | 페이지 로드 | 공정 목록 표시 |
| 검색 | "생산" 검색 | 필터링된 결과 |
| 탭 필터 | "사용중" 탭 클릭 | 사용중 공정만 표시 |
| 등록 | 새 공정 등록 | 목록에 추가됨 |
| 수정 | 공정명 변경 | 변경 반영됨 |
| 삭제 | 공정 삭제 | 목록에서 제거됨 |
| 토글 | 상태 토글 | 사용중↔미사용 전환 |
| 규칙 추가 | 분류 규칙 추가 | 규칙 저장됨 |
### 7.2 API 테스트
```bash
# 목록 조회
curl -X GET "http://api.sam.kr/api/v1/processes" -H "X-Api-Key: ..."
# 상세 조회
curl -X GET "http://api.sam.kr/api/v1/processes/1" -H "X-Api-Key: ..."
# 통계 조회
curl -X GET "http://api.sam.kr/api/v1/processes/stats" -H "X-Api-Key: ..."
# 토글
curl -X PATCH "http://api.sam.kr/api/v1/processes/1/toggle" -H "X-Api-Key: ..."
```
---
## 8. 참고 사항
### 8.1 공정 유형 (process_type)
현재 작업지시에서 사용하는 공정 유형:
- `screen`: 스크린 공정
- `slat`: 슬랫 공정
- `bending`: 절곡 공정
### 8.2 Process vs WorkOrder.process_type
- `Process` 모델: 공정의 메타데이터 (이름, 설명, 규칙 등)
- `WorkOrder.process_type`: 실제 작업지시에 적용된 공정 유형
- 향후 FK 연결로 확장성 확보 가능
---
## 9. 참고 문서
- **빠른 시작**: `docs/quickstart/quick-start.md`
- **API 규칙**: `docs/standards/api-rules.md`
- **품질 체크리스트**: `docs/standards/quality-checklist.md`
### 참고 코드
- **Controller**: `api/app/Http/Controllers/V1/ProcessController.php`
- **Service**: `api/app/Services/ProcessService.php`
- **actions.ts**: `react/src/components/process-management/actions.ts`
---
## 10. 자기완결성 점검
| # | 검증 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 1 | 작업 목적이 명확한가? | ✅ | 검증 및 테스트 |
| 2 | 성공 기준이 정의되어 있는가? | ✅ | 섹션 7 참조 |
| 3 | 작업 범위가 구체적인가? | ✅ | Phase 1 테스트 항목 |
| 4 | 의존성이 명시되어 있는가? | ✅ | 선행 작업 없음 |
| 5 | 참고 파일 경로가 정확한가? | ✅ | 모든 경로 검증됨 |
| 6 | 단계별 절차가 실행 가능한가? | ✅ | 테스트 체크리스트 제공 |
| 7 | 검증 방법이 명시되어 있는가? | ✅ | curl + 체크리스트 |
| 8 | 모호한 표현이 없는가? | ✅ | 구체적 경로 명시 |
---
*이 문서는 독립 세션에서 바로 작업 시작 가능하도록 설계되었습니다.*

View File

@@ -0,0 +1,743 @@
# 견적 자동산출 개발 계획
> **작성일**: 2025-12-22
> **상태**: ✅ 구현 완료
> **목표**: MNG 견적수식 데이터 셋팅 + React 견적관리 자동산출 기능 구현
> **완료일**: 2025-12-22
> **실제 소요 시간**: 약 2시간
---
## 0. 빠른 시작 가이드
### 폴더 구조 이해 (중요!)
| 폴더 | 포트 | 역할 | 비고 |
|------|------|------|------|
| `design/` | localhost:3002 | 디자인 프로토타입 | UI 참고용 |
| `react/` | localhost:3000 | **실제 프론트엔드** | 구현 대상 ✅ |
| `mng/` | mng.sam.kr | 관리자 패널 | 수식 데이터 관리 |
| `api/` | api.sam.kr | REST API | 견적 산출 엔진 |
### 이 문서만으로 작업을 시작하려면:
```bash
# 1. Docker 서비스 시작
cd /Users/hskwon/Works/@KD_SAM/SAM
docker-compose up -d
# 2. MNG 시더 실행 (Phase 1 완료 후)
cd mng
php artisan quote:seed-formulas --tenant=1
# 3. React 개발 서버 (실제 구현 대상)
cd react
npm run dev
# http://localhost:3000 접속
```
### 핵심 파일 위치
| 구분 | 파일 경로 | 역할 |
|------|----------|------|
| **MNG 시더** | `mng/app/Console/Commands/SeedQuoteFormulasCommand.php` | 🆕 생성 필요 |
| **React 자동산출** | `react/src/components/quotes/QuoteRegistration.tsx` | ⚡ 수정 필요 (line 332) |
| **API 클라이언트** | `react/src/lib/api/client.ts` | 참조 |
| **API 엔드포인트** | `api/app/Http/Controllers/Api/V1/QuoteController.php` | ✅ 구현됨 |
| **수식 엔진** | `api/app/Services/Quote/QuoteCalculationService.php` | ✅ 구현됨 |
---
## 1. 현황 분석
### 1.1 시스템 구조
```
┌───────────────────────────────────────────────────────────────────────────────┐
│ SAM 시스템 │
├───────────────────────────────────────────────────────────────────────────────┤
│ MNG (mng.sam.kr) │ React (react/ 폴더) │ Design │
│ ├── 기준정보관리 │ ├── 판매관리 │ (참고용) │
│ │ └── 견적수식관리 ✅ │ │ └── 견적관리 │ │
│ │ - 카테고리 CRUD │ │ └── 자동견적산출 │ design/ │
│ │ - 수식 CRUD │ │ UI 있음 ✅ │ :3002 │
│ │ - 범위/매핑/품목 탭 │ │ API 연동 ❌ │ │
│ │ │ │ │ │
│ └── DB: quote_formulas 테이블 │ └── API 호출: │ │
│ (데이터 없음! ❌) │ POST /v1/quotes/calculate │ │
└───────────────────────────────────────────────────────────────────────────────┘
※ design/ 폴더 (localhost:3002)는 UI 프로토타입용이며, 실제 구현은 react/ 폴더에서 진행
```
### 1.2 React 견적등록 컴포넌트 현황
**파일**: `react/src/components/quotes/QuoteRegistration.tsx`
```typescript
// 현재 상태 (line 332-335)
const handleAutoCalculate = () => {
toast.info(`자동 견적 산출 (${formData.items.length}개 항목) - API 연동 필요`);
};
// 입력 필드 (이미 구현됨):
interface QuoteItem {
openWidth: string; // W0 (오픈사이즈 가로)
openHeight: string; // H0 (오픈사이즈 세로)
productCategory: string; // screen | steel
quantity: number;
// ... 기타 필드
}
```
### 1.3 API 엔드포인트 현황
**파일**: `api/app/Http/Controllers/Api/V1/QuoteController.php`
```php
// 이미 구현됨 (line 135-145)
public function calculate(QuoteCalculateRequest $request)
{
return ApiResponse::handle(function () use ($request) {
$validated = $request->validated();
return $this->calculationService->calculate(
$validated['inputs'] ?? $validated,
$validated['product_category'] ?? null
);
}, __('message.quote.calculated'));
}
```
### 1.4 수식 시더 데이터 (API)
**파일**: `api/database/seeders/QuoteFormulaSeeder.php`
| 카테고리 | 수식 수 | 설명 |
|---------|--------|------|
| OPEN_SIZE | 2 | W0, H0 입력값 |
| MAKE_SIZE | 4 | 제작사이즈 계산 |
| AREA | 1 | 면적 = W1 * H1 / 1000000 |
| WEIGHT | 2 | 중량 계산 (스크린/철재) |
| GUIDE_RAIL | 5 | 가이드레일 자동 선택 |
| CASE | 3 | 케이스 자동 선택 |
| MOTOR | 1 | 모터 자동 선택 (범위 9개) |
| CONTROLLER | 2 | 제어기 매핑 |
| EDGE_WING | 1 | 마구리 수량 |
| INSPECTION | 1 | 검사비 |
| PRICE_FORMULA | 8 | 단가 수식 |
| **합계** | **30개** | + 범위 18개 |
---
## 2. 개발 상세 계획
### Phase 1: MNG 시더 데이터 생성 (1일)
#### 2.1 Artisan 명령어 생성
**생성할 파일**: `mng/app/Console/Commands/SeedQuoteFormulasCommand.php`
```php
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class SeedQuoteFormulasCommand extends Command
{
protected $signature = 'quote:seed-formulas
{--tenant=1 : 테넌트 ID}
{--only= : categories|formulas|ranges|mappings|items}
{--fresh : 기존 데이터 삭제 후 재생성}';
protected $description = '견적수식 시드 데이터를 생성합니다';
public function handle(): int
{
$tenantId = (int) $this->option('tenant');
$only = $this->option('only');
$fresh = $this->option('fresh');
if ($fresh) {
$this->warn('기존 데이터를 삭제합니다...');
$this->truncateTables($tenantId);
}
if (!$only || $only === 'categories') {
$this->seedCategories($tenantId);
}
if (!$only || $only === 'formulas') {
$this->seedFormulas($tenantId);
}
if (!$only || $only === 'ranges') {
$this->seedRanges($tenantId);
}
$this->info('✅ 견적수식 시드 완료!');
return Command::SUCCESS;
}
private function seedCategories(int $tenantId): void
{
$categories = [
['code' => 'OPEN_SIZE', 'name' => '오픈사이즈', 'sort_order' => 1],
['code' => 'MAKE_SIZE', 'name' => '제작사이즈', 'sort_order' => 2],
['code' => 'AREA', 'name' => '면적', 'sort_order' => 3],
['code' => 'WEIGHT', 'name' => '중량', 'sort_order' => 4],
['code' => 'GUIDE_RAIL', 'name' => '가이드레일', 'sort_order' => 5],
['code' => 'CASE', 'name' => '케이스', 'sort_order' => 6],
['code' => 'MOTOR', 'name' => '모터', 'sort_order' => 7],
['code' => 'CONTROLLER', 'name' => '제어기', 'sort_order' => 8],
['code' => 'EDGE_WING', 'name' => '마구리', 'sort_order' => 9],
['code' => 'INSPECTION', 'name' => '검사', 'sort_order' => 10],
['code' => 'PRICE_FORMULA', 'name' => '단가수식', 'sort_order' => 11],
];
foreach ($categories as $cat) {
DB::table('quote_formula_categories')->updateOrInsert(
['tenant_id' => $tenantId, 'code' => $cat['code']],
array_merge($cat, [
'tenant_id' => $tenantId,
'is_active' => true,
'created_at' => now(),
'updated_at' => now(),
])
);
}
$this->info("카테고리 " . count($categories) . "개 생성됨");
}
private function seedFormulas(int $tenantId): void
{
// API 시더와 동일한 데이터 (api/database/seeders/QuoteFormulaSeeder.php 참조)
$formulas = $this->getFormulaData();
$categoryMap = DB::table('quote_formula_categories')
->where('tenant_id', $tenantId)
->pluck('id', 'code')
->toArray();
$count = 0;
foreach ($formulas as $formula) {
$categoryId = $categoryMap[$formula['category_code']] ?? null;
if (!$categoryId) continue;
DB::table('quote_formulas')->updateOrInsert(
['tenant_id' => $tenantId, 'variable' => $formula['variable']],
[
'tenant_id' => $tenantId,
'category_id' => $categoryId,
'variable' => $formula['variable'],
'name' => $formula['name'],
'type' => $formula['type'],
'formula' => $formula['formula'] ?? null,
'output_type' => 'variable',
'description' => $formula['description'] ?? null,
'sort_order' => $formula['sort_order'] ?? 0,
'is_active' => $formula['is_active'] ?? true,
'created_at' => now(),
'updated_at' => now(),
]
);
$count++;
}
$this->info("수식 {$count}개 생성됨");
}
private function getFormulaData(): array
{
return [
// 오픈사이즈
['category_code' => 'OPEN_SIZE', 'variable' => 'W0', 'name' => '오픈사이즈 W0 (가로)', 'type' => 'input', 'formula' => null, 'sort_order' => 1],
['category_code' => 'OPEN_SIZE', 'variable' => 'H0', 'name' => '오픈사이즈 H0 (세로)', 'type' => 'input', 'formula' => null, 'sort_order' => 2],
// 제작사이즈
['category_code' => 'MAKE_SIZE', 'variable' => 'W1_SCREEN', 'name' => '제작사이즈 W1 (스크린)', 'type' => 'calculation', 'formula' => 'W0 + 140', 'sort_order' => 1],
['category_code' => 'MAKE_SIZE', 'variable' => 'H1_SCREEN', 'name' => '제작사이즈 H1 (스크린)', 'type' => 'calculation', 'formula' => 'H0 + 350', 'sort_order' => 2],
['category_code' => 'MAKE_SIZE', 'variable' => 'W1_STEEL', 'name' => '제작사이즈 W1 (철재)', 'type' => 'calculation', 'formula' => 'W0 + 110', 'sort_order' => 3],
['category_code' => 'MAKE_SIZE', 'variable' => 'H1_STEEL', 'name' => '제작사이즈 H1 (철재)', 'type' => 'calculation', 'formula' => 'H0 + 350', 'sort_order' => 4],
// 면적
['category_code' => 'AREA', 'variable' => 'M', 'name' => '면적 계산', 'type' => 'calculation', 'formula' => 'W1 * H1 / 1000000', 'sort_order' => 1],
// 중량
['category_code' => 'WEIGHT', 'variable' => 'K_SCREEN', 'name' => '중량 계산 (스크린)', 'type' => 'calculation', 'formula' => 'M * 2 + W0 / 1000 * 14.17', 'sort_order' => 1],
['category_code' => 'WEIGHT', 'variable' => 'K_STEEL', 'name' => '중량 계산 (철재)', 'type' => 'calculation', 'formula' => 'M * 25', 'sort_order' => 2],
// 가이드레일
['category_code' => 'GUIDE_RAIL', 'variable' => 'G', 'name' => '가이드레일 제작길이', 'type' => 'calculation', 'formula' => 'H0 + 250', 'sort_order' => 1],
['category_code' => 'GUIDE_RAIL', 'variable' => 'GR_AUTO_SELECT', 'name' => '가이드레일 자재 자동 선택', 'type' => 'range', 'formula' => null, 'sort_order' => 2],
// 케이스
['category_code' => 'CASE', 'variable' => 'S_SCREEN', 'name' => '케이스 사이즈 (스크린)', 'type' => 'calculation', 'formula' => 'W0 + 220', 'sort_order' => 1],
['category_code' => 'CASE', 'variable' => 'S_STEEL', 'name' => '케이스 사이즈 (철재)', 'type' => 'calculation', 'formula' => 'W0 + 240', 'sort_order' => 2],
['category_code' => 'CASE', 'variable' => 'CASE_AUTO_SELECT', 'name' => '케이스 자재 자동 선택', 'type' => 'range', 'formula' => null, 'sort_order' => 3],
// 모터
['category_code' => 'MOTOR', 'variable' => 'MOTOR_AUTO_SELECT', 'name' => '모터 자동 선택', 'type' => 'range', 'formula' => null, 'sort_order' => 1],
// 제어기
['category_code' => 'CONTROLLER', 'variable' => 'CONTROLLER_TYPE', 'name' => '제어기 유형', 'type' => 'input', 'formula' => null, 'sort_order' => 0],
['category_code' => 'CONTROLLER', 'variable' => 'CTRL_AUTO_SELECT', 'name' => '제어기 자동 선택', 'type' => 'mapping', 'formula' => null, 'sort_order' => 1],
// 검사
['category_code' => 'INSPECTION', 'variable' => 'INSP_FEE', 'name' => '검사비', 'type' => 'calculation', 'formula' => '1', 'sort_order' => 1],
];
}
// ... 나머지 메서드 (seedRanges, truncateTables 등)
}
```
#### 2.2 작업 순서
```bash
# 1. 명령어 파일 생성
# mng/app/Console/Commands/SeedQuoteFormulasCommand.php
# 2. 실행
cd mng
php artisan quote:seed-formulas --tenant=1
# 3. 확인
php artisan tinker
>>> \App\Models\Quote\QuoteFormula::count()
# 예상: 30
# 4. 시뮬레이터 테스트
# mng.sam.kr/quote-formulas/simulator
# 입력: W0=3000, H0=2500
```
---
### Phase 2: React 자동산출 기능 구현 (2-3일)
#### 2.1 API 클라이언트 추가
**수정할 파일**: `react/src/lib/api/quote.ts` (신규)
```typescript
// react/src/lib/api/quote.ts
import { ApiClient } from './client';
import { AUTH_CONFIG } from './auth/auth-config';
// API 응답 타입
interface CalculationResult {
inputs: Record<string, number | string>;
outputs: Record<string, {
name: string;
value: number;
category: string;
type: string;
}>;
items: Array<{
item_code: string;
item_name: string;
specification?: string;
unit?: string;
quantity: number;
unit_price: number;
total_price: number;
formula_variable: string;
}>;
costs: {
material_cost: number;
labor_cost: number;
install_cost: number;
subtotal: number;
};
errors: string[];
}
interface CalculateRequest {
inputs: {
W0: number;
H0: number;
QTY?: number;
INSTALL_TYPE?: string;
CONTROL_TYPE?: string;
};
product_category: 'screen' | 'steel';
}
// Quote API 클라이언트
class QuoteApiClient extends ApiClient {
constructor() {
super({
mode: 'bearer',
apiKey: AUTH_CONFIG.apiKey,
getToken: () => {
if (typeof window !== 'undefined') {
return localStorage.getItem('auth_token');
}
return null;
},
});
}
/**
* 자동 견적 산출
*/
async calculate(request: CalculateRequest): Promise<{ success: boolean; data: CalculationResult; message: string }> {
return this.post('/api/v1/quotes/calculate', request);
}
/**
* 입력 스키마 조회
*/
async getCalculationSchema(productCategory?: string): Promise<{ success: boolean; data: Record<string, unknown> }> {
const query = productCategory ? `?product_category=${productCategory}` : '';
return this.get(`/api/v1/quotes/calculation-schema${query}`);
}
}
export const quoteApi = new QuoteApiClient();
```
#### 2.2 QuoteRegistration.tsx 수정
**수정할 파일**: `react/src/components/quotes/QuoteRegistration.tsx`
```typescript
// 추가할 import
import { quoteApi } from '@/lib/api/quote';
import { useState } from 'react';
// 상태 추가 (컴포넌트 내부)
const [calculationResult, setCalculationResult] = useState<CalculationResult | null>(null);
const [isCalculating, setIsCalculating] = useState(false);
// handleAutoCalculate 수정 (line 332-335)
const handleAutoCalculate = async () => {
const item = formData.items[activeItemIndex];
if (!item.openWidth || !item.openHeight) {
toast.error('오픈사이즈(W0, H0)를 입력해주세요.');
return;
}
setIsCalculating(true);
try {
const response = await quoteApi.calculate({
inputs: {
W0: parseFloat(item.openWidth),
H0: parseFloat(item.openHeight),
QTY: item.quantity,
INSTALL_TYPE: item.guideRailType,
CONTROL_TYPE: item.controller,
},
product_category: item.productCategory as 'screen' | 'steel' || 'screen',
});
if (response.success) {
setCalculationResult(response.data);
toast.success('자동 산출이 완료되었습니다.');
} else {
toast.error(response.message || '산출 중 오류가 발생했습니다.');
}
} catch (error) {
console.error('자동 산출 오류:', error);
toast.error('서버 연결에 실패했습니다.');
} finally {
setIsCalculating(false);
}
};
// 산출 결과 반영 함수 추가
const handleApplyCalculation = () => {
if (!calculationResult) return;
// 산출된 품목을 견적 항목에 반영
const newItems = calculationResult.items.map((item, index) => ({
id: `calc-${Date.now()}-${index}`,
floor: formData.items[activeItemIndex].floor,
code: item.item_code,
productCategory: formData.items[activeItemIndex].productCategory,
productName: item.item_name,
openWidth: formData.items[activeItemIndex].openWidth,
openHeight: formData.items[activeItemIndex].openHeight,
guideRailType: formData.items[activeItemIndex].guideRailType,
motorPower: formData.items[activeItemIndex].motorPower,
controller: formData.items[activeItemIndex].controller,
quantity: item.quantity,
wingSize: formData.items[activeItemIndex].wingSize,
inspectionFee: item.unit_price,
unitPrice: item.unit_price,
totalAmount: item.total_price,
}));
setFormData({
...formData,
items: [...formData.items.slice(0, activeItemIndex), ...newItems, ...formData.items.slice(activeItemIndex + 1)],
});
setCalculationResult(null);
toast.success(`${newItems.length}개 품목이 반영되었습니다.`);
};
```
#### 2.3 산출 결과 표시 UI 추가
```tsx
{/* 자동 견적 산출 버튼 아래에 추가 */}
{calculationResult && (
<Card className="border-green-200 bg-green-50/50">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Calculator className="h-4 w-4" />
산출 결과
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* 계산 변수 */}
<div className="grid grid-cols-4 gap-2 text-sm">
{Object.entries(calculationResult.outputs).map(([key, val]) => (
<div key={key} className="bg-white p-2 rounded border">
<div className="text-gray-500 text-xs">{val.name}</div>
<div className="font-medium">{typeof val.value === 'number' ? val.value.toFixed(2) : val.value}</div>
</div>
))}
</div>
{/* 산출 품목 */}
<table className="w-full text-sm border-collapse">
<thead>
<tr className="bg-gray-100">
<th className="p-2 text-left">품목코드</th>
<th className="p-2 text-left">품목명</th>
<th className="p-2 text-right">수량</th>
<th className="p-2 text-right">단가</th>
<th className="p-2 text-right">금액</th>
</tr>
</thead>
<tbody>
{calculationResult.items.map((item, i) => (
<tr key={i} className="border-b">
<td className="p-2 font-mono text-xs">{item.item_code}</td>
<td className="p-2">{item.item_name}</td>
<td className="p-2 text-right">{item.quantity}</td>
<td className="p-2 text-right">{item.unit_price.toLocaleString()}</td>
<td className="p-2 text-right font-medium">{item.total_price.toLocaleString()}</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="bg-gray-50 font-medium">
<td colSpan={4} className="p-2 text-right">합계</td>
<td className="p-2 text-right">{calculationResult.costs.subtotal.toLocaleString()}</td>
</tr>
</tfoot>
</table>
{/* 반영 버튼 */}
<Button onClick={handleApplyCalculation} className="w-full bg-green-600 hover:bg-green-700">
<Check className="h-4 w-4 mr-2" />
품목에 반영하기
</Button>
</CardContent>
</Card>
)}
```
---
### Phase 3: 통합 테스트 (1일)
#### 3.1 테스트 시나리오
| 번호 | 테스트 케이스 | 입력값 | 예상 결과 |
|-----|-------------|-------|----------|
| 1 | 기본 스크린 산출 | W0=3000, H0=2500 | 가이드레일 PT-GR-3000, 모터 PT-MOTOR-150 |
| 2 | 대형 스크린 산출 | W0=5000, H0=4000 | 모터 규격 상향 (300K 이상) |
| 3 | 철재 산출 | W0=2000, H0=2000, steel | 중량 M*25 적용 |
| 4 | 품목 반영 | 산출 후 반영 클릭 | 견적 항목에 추가됨 |
| 5 | 에러 처리 | W0/H0 미입력 | "오픈사이즈를 입력해주세요" |
#### 3.2 검증 체크리스트
```
□ MNG 시뮬레이터에서 수식 계산 정확도 확인
□ React 자동산출 버튼 클릭 → API 호출 확인
□ 산출 결과 테이블 정상 표시
□ "품목에 반영하기" 클릭 → 견적 항목 추가 확인
□ 견적 저장 시 calculation_inputs 필드 저장 확인
□ 에러 시 적절한 메시지 표시
```
---
## 3. SAM 개발 규칙 요약
### 3.1 API 개발 규칙 (CLAUDE.md 참조)
```php
// Controller: FormRequest + ApiResponse 패턴
public function calculate(QuoteCalculateRequest $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->calculationService->calculate($request->validated());
}, __('message.quote.calculated'));
}
// Service: 비즈니스 로직 분리
class QuoteCalculationService extends Service
{
public function calculate(array $inputs, ?string $productCategory = null): array
{
$tenantId = $this->tenantId(); // 필수
// ...
}
}
// 응답 형식
{
"success": true,
"message": "견적이 산출되었습니다.",
"data": { ... }
}
```
### 3.2 React 개발 패턴
```typescript
// API 클라이언트 패턴 (react/src/lib/api/client.ts)
class ApiClient {
async post<T>(endpoint: string, data?: unknown): Promise<T>
async get<T>(endpoint: string): Promise<T>
}
// 컴포넌트 패턴
// - shadcn/ui 컴포넌트 사용
// - toast (sonner) 알림
// - FormField, Card, Button 등
```
### 3.3 MNG 개발 패턴
```php
// Artisan 명령어 패턴
protected $signature = 'quote:seed-formulas {--tenant=1}';
// 모델 사용
use App\Models\Quote\QuoteFormula;
use App\Models\Quote\QuoteFormulaCategory;
// 서비스 패턴
class QuoteFormulaService {
public function __construct(
private FormulaEvaluatorService $evaluator
) {}
}
```
---
## 4. 파일 구조
```
SAM/
├── mng/
│ ├── app/Console/Commands/
│ │ └── SeedQuoteFormulasCommand.php # 🆕 Phase 1
│ ├── app/Models/Quote/
│ │ ├── QuoteFormula.php # ✅ 있음
│ │ ├── QuoteFormulaCategory.php # ✅ 있음
│ │ └── QuoteFormulaRange.php # ✅ 있음
│ └── app/Services/Quote/
│ └── FormulaEvaluatorService.php # ✅ 있음
├── api/
│ ├── app/Http/Controllers/Api/V1/
│ │ └── QuoteController.php # ✅ calculate() 있음
│ ├── app/Services/Quote/
│ │ ├── QuoteCalculationService.php # ✅ 있음
│ │ └── FormulaEvaluatorService.php # ✅ 있음
│ └── database/seeders/
│ └── QuoteFormulaSeeder.php # 참조용 데이터
├── react/
│ ├── src/lib/api/
│ │ ├── client.ts # ✅ ApiClient 클래스
│ │ └── quote.ts # 🆕 Phase 2
│ └── src/components/quotes/
│ └── QuoteRegistration.tsx # ⚡ Phase 2 수정
└── docs/plans/
└── quote-auto-calculation-development-plan.md # 이 문서
```
---
## 5. 수식 계산 예시
```
입력: W0=3000mm, H0=2500mm, product_category=screen
계산 순서:
1. W1 = W0 + 140 = 3140mm (스크린 제작 가로)
2. H1 = H0 + 350 = 2850mm (스크린 제작 세로)
3. M = W1 * H1 / 1000000 = 8.949㎡ (면적)
4. K = M * 2 + W0 / 1000 * 14.17 = 60.41kg (중량)
5. G = H0 + 250 = 2750mm (가이드레일 길이)
6. S = W0 + 220 = 3220mm (케이스 사이즈)
범위 자동 선택:
- 가이드레일: G=2750 → 2438 < G ≤ 3000 → PT-GR-3000 × 2개
- 케이스: S=3220 → 3000 < S ≤ 3600 → PT-CASE-3600 × 1개
- 모터: K=60.41 → 0 < K ≤ 150 → PT-MOTOR-150 × 1개
```
---
## 6. 일정 요약
| Phase | 작업 | 예상 기간 | 상태 |
|-------|------|----------|------|
| 1 | MNG 시더 명령어 생성 | 1일 | ✅ 완료 |
| 2 | React Quote API 클라이언트 생성 | 0.5일 | ✅ 완료 |
| 3 | React handleAutoCalculate API 연동 | 0.5일 | ✅ 완료 |
| 4 | 산출 결과 UI 추가 | 0.5일 | ✅ 완료 |
| 5 | 문서 업데이트 | 0.5시간 | ✅ 완료 |
| **합계** | | **약 2시간** | ✅ |
---
## 7. 완료된 구현 내역
### 생성된 파일
| 파일 경로 | 역할 |
|----------|------|
| `mng/app/Console/Commands/SeedQuoteFormulasCommand.php` | MNG 견적수식 시더 명령어 |
| `react/src/lib/api/quote.ts` | React Quote API 클라이언트 |
### 수정된 파일
| 파일 경로 | 변경 내용 |
|----------|----------|
| `react/src/components/quotes/QuoteRegistration.tsx` | handleAutoCalculate API 연동, 산출 결과 UI 추가 |
### MNG 시더 실행 결과
```
✅ 견적수식 시드 완료!
카테고리: 11개
수식: 18개
범위: 18개
```
### React 기능 구현
- `handleAutoCalculate`: API 호출 및 로딩 상태 관리
- `handleApplyCalculation`: 산출 결과를 견적 항목에 반영
- 산출 결과 UI: 변수, 품목 테이블, 비용 합계 표시
- 에러 처리: 입력값 검증, API 에러 토스트
---
*문서 버전*: 3.0 (구현 완료)
*작성자*: Claude Code
*최종 업데이트*: 2025-12-22

View File

@@ -0,0 +1,262 @@
# 견적 V2 자동 견적 산출 오류 수정 계획
> **작성일**: 2026-01-26
> **목적**: 자동 견적 산출 기능의 4가지 오류 분석 및 수정
> **기준 문서**: `QuoteRegistrationV2.tsx`, `LocationDetailPanel.tsx`, `QuoteSummaryPanel.tsx`, `actions.ts`
> **상태**: ✅ 완료
---
## 📍 현재 진행 상태
| 항목 | 내용 |
|------|------|
| **마지막 완료 작업** | 테스트 및 검증 완료 |
| **다음 작업** | - |
| **진행률** | 4/4 (100%) ✅ |
| **마지막 업데이트** | 2026-01-26 |
---
## 1. 개요
### 1.1 배경
견적 V2 페이지(`/sales/quote-management/test-new`)에서 자동 견적 산출 버튼 클릭 후 다음 4가지 문제 발생:
1. 오른쪽 패널에 제품 리스트가 표시되지 않음
2. 개소별 합계(상세소계)가 표시되지 않음
3. 상세별 합계(그룹)가 표시되지 않음
4. 예상 견적금액이 0원으로 표시됨
### 1.2 기준 원칙
```
┌─────────────────────────────────────────────────────────────────┐
│ 🎯 핵심 원칙 │
├─────────────────────────────────────────────────────────────────┤
│ - API 응답 구조와 프론트엔드 기대 구조의 일치 확보 │
│ - Mock 데이터 fallback 로직은 디버깅/테스트용으로만 유지 │
│ - 실제 BOM 계산 결과가 UI에 정확히 반영되도록 수정 │
└─────────────────────────────────────────────────────────────────┘
```
### 1.3 변경 승인 정책
| 분류 | 예시 | 승인 |
|------|------|------|
| ✅ 즉시 가능 | 타입 캐스팅 수정, 데이터 매핑 로직 수정 | 불필요 |
| ⚠️ 컨펌 필요 | API 응답 구조 변경, 새 인터페이스 정의 | **필수** |
| 🔴 금지 | API 엔드포인트 변경, DB 스키마 변경 | 별도 협의 |
---
## 2. 근본 원인 분석
### 2.1 API 응답 구조 불일치 (핵심 원인)
**API 실제 응답** (`actions.ts:962-965`):
```typescript
return {
success: true,
data: result.data || [], // 배열을 직접 반환
};
```
**API 서버 응답** (`QuoteCalculationService.php:168-178`):
```php
return [
'success' => $failCount === 0,
'summary' => [
'total_count' => count($inputItems),
'success_count' => $successCount,
'fail_count' => $failCount,
'grand_total' => round($grandTotal, 2),
],
'items' => $results, // items 배열 안에 결과가 있음
];
```
**컴포넌트 기대 구조** (`QuoteRegistrationV2.tsx:459-462`):
```typescript
const apiData = result.data as {
summary?: { grand_total: number };
items?: Array<{ index: number; result: BomCalculationResult }>;
};
const bomItems = apiData.items || []; // ❌ result.data가 배열이면 items가 없음!
```
### 2.2 문제 발생 흐름
```
사용자 → "자동 견적 산출" 클릭
calculateBomBulk(bomItems) 호출
API 서버: { success, summary, items: [...] } 반환
actions.ts: result.data = 전체 응답 객체 (또는 배열로 잘못 파싱)
QuoteRegistrationV2.tsx: result.data.items 접근 시도
❌ items가 undefined → bomItems = []
locations에 bomResult 저장 안됨
LocationDetailPanel: bomResult?.items 없음 → Mock 데이터 표시
QuoteSummaryPanel: bomResult?.subtotals 없음 → Mock 데이터 표시
💥 모든 UI 영역에 데이터 없음
```
### 2.3 영향 받는 컴포넌트
| 컴포넌트 | 파일 | 영향 |
|----------|------|------|
| QuoteRegistrationV2 | `QuoteRegistrationV2.tsx:457-481` | bomResult 저장 안됨 |
| LocationDetailPanel | `LocationDetailPanel.tsx:152-184` | Mock 데이터로 fallback |
| QuoteSummaryPanel | `QuoteSummaryPanel.tsx:136-164` | Mock 데이터로 fallback |
---
## 3. 대상 범위
### 3.1 Phase 1: API 응답 처리 수정
| # | 작업 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 1.1 | `actions.ts` 응답 구조 확인 | ✅ | API 서버 응답과 비교 |
| 1.2 | `actions.ts` BomBulkResponse 타입 추가 | ✅ | 정확한 API 응답 구조 정의 |
| 1.3 | `QuoteRegistrationV2.tsx` handleCalculate 수정 | ✅ | 응답 매핑 로직 및 디버그 로그 추가 |
### 3.2 Phase 2: 데이터 바인딩 수정
| # | 작업 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 2.1 | `FormulaEvaluatorService.php` items에 process_group 추가 | ✅ | addProcessGroupToItems 메서드 추가 |
| 2.2 | `LocationDetailPanel.tsx` bomItemsByTab 수정 | ✅ | process_group_key 기반 매핑 |
| 2.3 | `QuoteSummaryPanel.tsx` detailTotals 수정 | ✅ | grouped_items에서 items 가져오기 |
| 2.4 | `actions.ts` BomCalculationResult 타입 확장 | ✅ | process_group, grouped_items 필드 추가 |
---
## 4. 상세 작업 내용
### 4.1 Phase 1.2: handleCalculate 함수 수정
**현재 코드** (`QuoteRegistrationV2.tsx:457-479`):
```typescript
if (result.success && result.data) {
// ❌ 잘못된 타입 캐스팅 - result.data 자체가 API 응답 객체임
const apiData = result.data as {
summary?: { grand_total: number };
items?: Array<{ index: number; result: BomCalculationResult }>;
};
const bomItems = apiData.items || []; // ❌ undefined
// ...
}
```
**수정 방안**:
`actions.ts`의 응답 처리를 확인하여 두 가지 접근법 중 선택:
#### 방안 A: actions.ts 수정 (권장)
```typescript
// actions.ts에서 API 응답 구조 유지
return {
success: true,
data: {
summary: result.data.summary,
items: result.data.items,
},
};
```
#### 방안 B: QuoteRegistrationV2.tsx 수정
```typescript
if (result.success && result.data) {
// result.data가 { summary, items } 구조인지 확인
const apiData = result.data as unknown as {
summary?: { grand_total: number };
items?: Array<{ index: number; result: BomCalculationResult }>;
};
// ...
}
```
---
## 5. 컨펌 대기 목록
| # | 항목 | 변경 내용 | 영향 범위 | 상태 |
|---|------|----------|----------|------|
| 1 | API 응답 구조 | actions.ts의 result.data 반환 방식 검토 | react/actions.ts | 대기 |
---
## 6. 변경 이력
| 날짜 | 항목 | 변경 내용 | 파일 | 승인 |
|------|------|----------|------|------|
| 2026-01-26 | 분석 | 문서 초안 작성 및 근본 원인 분석 완료 | - | - |
| 2026-01-26 | 수정 | actions.ts BomBulkResponse 타입 추가 | react/src/components/quotes/actions.ts | ✅ |
| 2026-01-26 | 수정 | QuoteRegistrationV2.tsx handleCalculate 수정 | react/src/components/quotes/QuoteRegistrationV2.tsx | ✅ |
| 2026-01-26 | 수정 | FormulaEvaluatorService.php process_group 추가 | api/app/Services/Quote/FormulaEvaluatorService.php | ✅ |
| 2026-01-26 | 수정 | LocationDetailPanel.tsx bomItemsByTab 수정 | react/src/components/quotes/LocationDetailPanel.tsx | ✅ |
| 2026-01-26 | 수정 | QuoteSummaryPanel.tsx detailTotals 수정 | react/src/components/quotes/QuoteSummaryPanel.tsx | ✅ |
| 2026-01-26 | 수정 | ItemService.php has_bom 필드 추가 | api/app/Services/ItemService.php | ✅ |
| 2026-01-26 | 수정 | actions.ts FinishedGoods에 has_bom, bom 필드 추가 | react/src/components/quotes/actions.ts | ✅ |
| 2026-01-26 | 수정 | QuoteRegistrationV2.tsx DevFill BOM 필터링 | react/src/components/quotes/QuoteRegistrationV2.tsx | ✅ |
| 2026-01-26 | 검증 | 브라우저 테스트 완료 - 4가지 문제 모두 해결 확인 | - | ✅ |
---
## 7. 참고 문서
- **빠른 시작**: `docs/quickstart/quick-start.md`
- **품질 체크리스트**: `docs/standards/quality-checklist.md`
- **API 규칙**: `docs/standards/api-rules.md`
---
## 8. 검증 결과
> 브라우저 자동화 테스트 완료 (2026-01-26)
### 8.1 테스트 케이스
| 입력값 | 예상 결과 | 실제 결과 | 상태 |
|--------|----------|----------|------|
| DevFill 후 자동 견적 산출 | 제품 리스트 표시 | 볼트 M10×40, 너트 M10, 볼트 M8×30 등 6개 품목 표시 | ✅ |
| 개소 선택 | 개소별 합계 표시 | 1F / SS-01 상세소계: 3,119,555.94원 | ✅ |
| 그룹별 합계 | 상세별 합계 표시 | 절곡 공정: 735,891.24원, 철재 공정: 2,383,364.7원 | ✅ |
| 전체 금액 | 예상 견적금액 > 0 | 예상 견적금액: 3,119,555.94원 | ✅ |
### 8.2 테스트 환경
- **URL**: `http://dev.sam.kr/sales/quote-management/test-new`
- **테스트 방법**: Claude-in-Chrome 브라우저 자동화
- **데이터**: DevFill로 생성된 테스트 데이터
### 8.3 추가 발견 및 해결 사항
테스트 중 DevFill이 BOM 없는 제품을 선택하여 계산 결과가 0으로 나오는 문제 발견:
| 문제 | 원인 | 해결 |
|------|------|------|
| DevFill 후 bomItemsCount: 0 | BOM 없는 제품 선택 | DevFill에서 BOM 있는 제품만 필터링 |
| has_bom 필드 없음 | API 응답에 미포함 | ItemService.php에서 계산 필드 추가 |
| getFinishedGoods에서 필드 누락 | 매핑 시 has_bom, bom 미포함 | FinishedGoods 인터페이스 및 매핑 수정 |
### 8.4 최종 검증 결과
```
[DevFill] BOM 있는 제품: 15개 / 전체: 2017개
[BOM 계산 결과]
- bomItemsCount: 6
- bomGrandTotal: 3,119,555.94
- 공정별 그룹: 절곡, 철재
```
**모든 4가지 UI 문제 해결 확인 완료**
---
*이 문서는 /sc:plan 스킬로 생성되었습니다.*

View File

@@ -0,0 +1,543 @@
# React FCM 푸시 알림 연동 계획
> **작성일**: 2025-12-30
> **목적**: Capacitor 앱 웹뷰가 dev.sam.kr (Next.js)을 로드할 때 FCM 푸시 알림 지원
> **기준 문서**: mng/public/js/fcm.js (포팅 대상), api/app/Swagger/v1/PushApi.php
> **상태**: ✅ 구현 완료 (Serena ID: react-fcm-state)
---
## 📍 현재 진행 상태
| 항목 | 내용 |
|------|------|
| **마지막 완료 작업** | Phase 4: 통합 완료 |
| **다음 작업** | 테스트 (Capacitor 앱에서 확인) |
| **진행률** | 4/4 (100%) ✅ |
| **마지막 업데이트** | 2025-12-30 |
---
## 1. 개요
### 1.1 현재 구조
```
Capacitor 앱 (웹뷰)
mng (현재)
├── fcm.js 로드
│ ├── Capacitor PushNotifications 사용
│ ├── 토큰 발급
│ └── api에 토큰 등록
api
└── /push/register-token
```
### 1.2 목표 구조
```
Capacitor 앱 (웹뷰)
dev.sam.kr (react) ← 변경
├── FCM 훅/유틸리티 (포팅)
│ ├── Capacitor PushNotifications 사용 (동일)
│ ├── 토큰 발급 (동일)
│ └── api에 토큰 등록 (동일)
api (변경 없음)
└── /push/register-token
```
### 1.3 핵심 포인트
```
┌─────────────────────────────────────────────────────────────────┐
│ 🎯 핵심: mng/public/js/fcm.js를 react에 포팅 │
├─────────────────────────────────────────────────────────────────┤
│ 1. Capacitor PushNotifications 플러그인 사용 (동일) │
│ 2. 토큰 발급 → api 등록 로직 (동일) │
│ 3. 포그라운드 알림 → sonner 토스트로 변경 │
│ 4. 백엔드 API 변경 없음 │
└─────────────────────────────────────────────────────────────────┘
```
### 1.4 변경 승인 정책
| 분류 | 예시 | 승인 |
|------|------|------|
| ✅ 즉시 가능 | Capacitor 플러그인 설치, 훅 생성, 유틸리티 추가 | 불필요 |
| ⚠️ 컨펌 필요 | 포그라운드 알림 UX (토스트 디자인) | **필수** |
| 🔴 금지 | API 엔드포인트 변경, DB 스키마 변경 | 별도 협의 |
---
## 2. 대상 범위
### 2.1 Phase 1: Capacitor 플러그인 설치 ✅
| # | 작업 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 1.1 | @capacitor/push-notifications 설치 | ✅ | ^8.0.0 |
| 1.2 | @capacitor/app 설치 | ✅ | ^8.0.0 |
| 1.3 | @capacitor/core 설치 | ✅ | ^8.0.0 |
### 2.2 Phase 2: FCM 유틸리티 포팅 ✅
| # | 작업 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 2.1 | lib/capacitor/fcm.ts 생성 | ✅ | 9.1KB |
| 2.2 | useFCM 훅 생성 | ✅ | 3.3KB |
| 2.3 | FCM Provider 생성 | ✅ | contexts/FCMProvider.tsx |
### 2.3 Phase 3: 포그라운드 알림 UI ✅
| # | 작업 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 3.1 | sonner 토스트 연동 | ✅ | useFCM에서 처리 |
| 3.2 | 알림 사운드 재생 | ✅ | public/sounds/ |
| 3.3 | 클릭 시 URL 이동 | ✅ | window.location.href |
### 2.4 Phase 4: 통합 ✅
| # | 작업 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 4.1 | layout.tsx에 FCMProvider 추가 | ✅ | (protected)/layout.tsx |
| 4.2 | 로그아웃 시 토큰 해제 | ✅ | logout.ts 수정 |
| 4.3 | 토큰 등록 테스트 | ⏳ | Capacitor 앱에서 확인 필요 |
| 4.4 | 포그라운드/백그라운드 알림 테스트 | ⏳ | Capacitor 앱에서 확인 필요 |
---
## 3. 기술 상세
### 3.1 기존 mng/public/js/fcm.js 분석
```javascript
// 핵심 기능 요약
1. Capacitor 네이티브 환경 체크 (ios/android)
2. PushNotifications.requestPermissions() - 권한 요청
3. PushNotifications.register() - 토큰 발급
4. registration 이벤트 api에 토큰 등록
5. pushNotificationReceived 포그라운드 알림 (토스트 + 사운드)
6. pushNotificationActionPerformed 알림 클릭 URL 이동
```
### 3.2 FCM 유틸리티 (포팅)
```typescript
// src/lib/capacitor/fcm.ts
import { Capacitor } from '@capacitor/core';
import { PushNotifications } from '@capacitor/push-notifications';
import { App } from '@capacitor/app';
const CONFIG = {
apiBaseUrl: process.env.NEXT_PUBLIC_API_URL || 'https://api.codebridge-x.com',
fcmTokenKey: 'fcm_token',
soundBasePath: '/sounds/',
defaultSound: 'default',
};
let isAppForeground = true;
/**
* FCM 초기화 (Capacitor 네이티브 환경에서만 동작)
*/
export async function initializeFCM(
accessToken: string,
onForegroundNotification?: (notification: PushNotification) => void
): Promise<boolean> {
// 네이티브 환경 체크
const platform = Capacitor.getPlatform();
if (platform !== 'ios' && platform !== 'android') {
console.log('[FCM] Not running in native app');
return false;
}
if (!Capacitor.isPluginAvailable('PushNotifications')) {
console.log('[FCM] PushNotifications plugin not available');
return false;
}
try {
// 앱 상태 리스너
App.addListener('appStateChange', ({ isActive }) => {
isAppForeground = isActive;
console.log('[FCM] App state:', isActive ? 'foreground' : 'background');
});
// 기존 리스너 제거
await PushNotifications.removeAllListeners();
// 리스너 등록
PushNotifications.addListener('registration', async (token) => {
console.log('[FCM] Token received:', token.value?.substring(0, 20) + '...');
await handleTokenRegistration(token.value, accessToken);
});
PushNotifications.addListener('registrationError', (err) => {
console.error('[FCM] Registration error:', err);
});
PushNotifications.addListener('pushNotificationReceived', (notification) => {
console.log('[FCM] Push received (foreground):', notification);
if (onForegroundNotification) {
onForegroundNotification(notification);
}
handleForegroundSound(notification);
});
PushNotifications.addListener('pushNotificationActionPerformed', (action) => {
console.log('[FCM] Push action performed:', action);
const url = action.notification?.data?.url;
if (url) {
window.location.href = url;
}
});
// 권한 요청
const perm = await PushNotifications.requestPermissions();
console.log('[FCM] Push permission:', perm.receive);
if (perm.receive !== 'granted') {
console.log('[FCM] Push permission not granted');
return false;
}
// 토큰 발급 요청
await PushNotifications.register();
return true;
} catch (error) {
console.error('[FCM] Initialization error:', error);
return false;
}
}
/**
* 토큰 등록 처리
*/
async function handleTokenRegistration(newToken: string, accessToken: string): Promise<void> {
const oldToken = sessionStorage.getItem(CONFIG.fcmTokenKey);
if (oldToken === newToken) {
console.log('[FCM] Token unchanged, skip');
return;
}
const success = await registerTokenToServer(newToken, accessToken);
if (success) {
sessionStorage.setItem(CONFIG.fcmTokenKey, newToken);
console.log('[FCM] Token saved to sessionStorage');
}
}
/**
* 서버에 토큰 등록
*/
async function registerTokenToServer(token: string, accessToken: string): Promise<boolean> {
try {
const response = await fetch(`${CONFIG.apiBaseUrl}/api/v1/push/register-token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': `Bearer ${accessToken}`,
},
body: JSON.stringify({
token,
platform: Capacitor.getPlatform(),
device_name: navigator.userAgent?.substring(0, 100) || null,
app_version: process.env.NEXT_PUBLIC_APP_VERSION || null,
}),
});
if (response.ok) {
console.log('[FCM] Token registered successfully');
return true;
}
console.error('[FCM] Token registration failed:', response.status);
return false;
} catch (error) {
console.error('[FCM] Failed to send token:', error);
return false;
}
}
/**
* 토큰 해제 (로그아웃 시)
*/
export async function unregisterFCMToken(accessToken?: string): Promise<boolean> {
const token = sessionStorage.getItem(CONFIG.fcmTokenKey);
if (!token) return true;
try {
if (accessToken) {
await fetch(`${CONFIG.apiBaseUrl}/api/v1/push/unregister-token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`,
},
body: JSON.stringify({ token }),
});
}
} catch (e) {
console.warn('[FCM] Unregister failed');
}
sessionStorage.removeItem(CONFIG.fcmTokenKey);
return true;
}
/**
* 포그라운드 사운드 재생
*/
function handleForegroundSound(notification: any): void {
if (!isAppForeground) return;
const soundKey = notification.data?.sound_key;
if (!soundKey) return;
try {
const audio = new Audio(`${CONFIG.soundBasePath}${soundKey}.wav`);
audio.volume = 0.5;
audio.play().catch(() => {
// 기본 사운드 시도
const defaultAudio = new Audio(`${CONFIG.soundBasePath}${CONFIG.defaultSound}.wav`);
defaultAudio.volume = 0.5;
defaultAudio.play().catch(() => {});
});
} catch (err) {
console.warn('[FCM] Sound error:', err);
}
}
/**
* Capacitor 네이티브 환경인지 확인
*/
export function isCapacitorNative(): boolean {
const platform = Capacitor.getPlatform();
return platform === 'ios' || platform === 'android';
}
// 타입 정의
export interface PushNotification {
title?: string;
body?: string;
data?: {
type?: string;
url?: string;
sound_key?: string;
};
}
```
### 3.3 useFCM 훅
```typescript
// src/hooks/useFCM.ts
'use client';
import { useEffect, useRef } from 'react';
import { useSession } from 'next-auth/react';
import { toast } from 'sonner';
import {
initializeFCM,
unregisterFCMToken,
isCapacitorNative,
PushNotification,
} from '@/lib/capacitor/fcm';
export function useFCM() {
const { data: session } = useSession();
const initialized = useRef(false);
useEffect(() => {
// 네이티브 환경이 아니면 무시
if (!isCapacitorNative()) return;
// 로그인 안 됐으면 무시
if (!session?.accessToken) return;
// 이미 초기화됐으면 무시
if (initialized.current) return;
initialized.current = true;
// FCM 초기화
initializeFCM(session.accessToken, handleForegroundNotification);
// 클린업 (로그아웃 시)
return () => {
// 로그아웃 시 토큰 해제는 별도 처리
};
}, [session?.accessToken]);
// 포그라운드 알림 핸들러
function handleForegroundNotification(notification: PushNotification) {
const { title, body, data } = notification;
const type = data?.type || 'default';
const url = data?.url;
// 타입별 토스트 스타일
const toastFn = getToastFunction(type);
toastFn(title || '알림', {
description: body,
action: url ? {
label: '보기',
onClick: () => {
window.location.href = url;
},
} : undefined,
duration: 5000,
});
}
// 타입별 토스트 함수
function getToastFunction(type: string) {
const errorTypes = ['invoice_failed', 'payment_failed', 'order_cancelled'];
const warningTypes = ['approval_required', 'stock_low'];
const successTypes = ['order_completed', 'payment_completed', 'approval_approved'];
if (errorTypes.includes(type)) return toast.error;
if (warningTypes.includes(type)) return toast.warning;
if (successTypes.includes(type)) return toast.success;
return toast.info;
}
// 로그아웃 시 호출
async function cleanup(accessToken?: string) {
await unregisterFCMToken(accessToken);
initialized.current = false;
}
return { cleanup };
}
```
### 3.4 FCM Provider
```typescript
// src/providers/FCMProvider.tsx
'use client';
import { useFCM } from '@/hooks/useFCM';
export function FCMProvider({ children }: { children: React.ReactNode }) {
// FCM 훅 실행 (초기화)
useFCM();
return <>{children}</>;
}
```
### 3.5 레이아웃에 Provider 추가
```typescript
// src/app/layout.tsx (또는 적절한 위치)
import { FCMProvider } from '@/providers/FCMProvider';
export default function RootLayout({ children }) {
return (
<html>
<body>
<SessionProvider>
<FCMProvider>
{children}
</FCMProvider>
</SessionProvider>
</body>
</html>
);
}
```
---
## 4. 파일 구조
```
react/
├── public/
│ └── sounds/ ← 알림 사운드 (mng에서 복사)
│ ├── default.wav
│ └── *.wav
├── src/
│ ├── lib/
│ │ └── capacitor/
│ │ └── fcm.ts ← 🆕 FCM 핵심 로직 (포팅)
│ ├── hooks/
│ │ └── useFCM.ts ← 🆕 FCM 훅
│ └── providers/
│ └── FCMProvider.tsx ← 🆕 FCM Provider
├── capacitor.config.ts ← 확인/수정 필요
└── package.json ← Capacitor 플러그인 추가
```
---
## 5. 의존성
| 패키지 | 버전 | 용도 |
|--------|------|------|
| @capacitor/core | (기존) | Capacitor 코어 |
| @capacitor/push-notifications | ^6.0.0 | 푸시 알림 플러그인 |
| @capacitor/app | ^6.0.0 | 앱 상태 감지 |
| sonner | (기존) | 포그라운드 토스트 |
---
## 6. mng vs react 비교
| 항목 | mng (기존) | react (포팅) |
|------|-----------|--------------|
| **FCM 플러그인** | Capacitor PushNotifications | 동일 |
| **토큰 저장** | sessionStorage | 동일 |
| **API 호출** | fetch | 동일 |
| **포그라운드 알림** | showToast (커스텀) | sonner 토스트 |
| **사운드 재생** | Audio API | 동일 |
| **URL 이동** | window.location.href | 동일 (또는 router.push) |
---
## 7. 참고 문서
| 문서 | 용도 |
|------|------|
| `mng/public/js/fcm.js` | 포팅 원본 |
| `api/app/Swagger/v1/PushApi.php` | 백엔드 API 스펙 |
| [Capacitor Push Notifications](https://capacitorjs.com/docs/apis/push-notifications) | 공식 문서 |
---
## 8. 컨펌 대기 목록
| # | 항목 | 변경 내용 | 영향 범위 | 상태 |
|---|------|----------|----------|------|
| 1 | 포그라운드 알림 UX | sonner 토스트 디자인/위치 | UX | ⏳ |
---
## 9. 변경 이력
| 날짜 | 항목 | 변경 내용 | 파일 | 승인 |
|------|------|----------|------|------|
| 2025-12-30 | 계획 수립 | 계획 문서 작성 | - | - |
---
*이 문서는 /sc:plan 스킬로 생성되었습니다.*

View File

@@ -0,0 +1,147 @@
# React 서버 컴포넌트 점검 계획
> **작성일**: 2025-01-09
> **목적**: push하지 않은 작업분 중 서버 컴포넌트를 클라이언트 컴포넌트로 변경
> **상태**: ✅ 점검 완료 - 수정 불필요
---
## 📍 점검 결과 요약
| 항목 | 내용 |
|------|------|
| **점검 대상** | push하지 않은 커밋 (origin/master..HEAD) |
| **커밋 수** | 20개 |
| **점검 파일 수** | 31개 (tsx/ts 파일) |
| **서버 컴포넌트 발견** | 0개 |
| **수정 필요** | ❌ 없음 |
---
## 1. 점검 배경
### 1.1 정책
- 프론트엔드 정책: **서버 컴포넌트 사용 금지**
- 모든 컴포넌트는 **클라이언트 컴포넌트**로 작성해야 함
- `'use client'` 지시어 필수
### 1.2 점검 범위
- **대상**: react 폴더의 push하지 않은 작업분
- **제외**: 이미 push된 커밋 (프론트엔드에서 수정 중)
---
## 2. 점검 대상 파일
### 2.1 변경된 TSX 파일 (16개)
| # | 파일 | 'use client' | 상태 |
|---|------|:------------:|:----:|
| 1 | `src/app/[locale]/(protected)/sales/order-management-sales/[id]/edit/page.tsx` | ✅ | 정상 |
| 2 | `src/app/[locale]/(protected)/sales/order-management-sales/[id]/page.tsx` | ✅ | 정상 |
| 3 | `src/app/[locale]/(protected)/sales/order-management-sales/[id]/production-order/page.tsx` | ✅ | 정상 |
| 4 | `src/app/[locale]/(protected)/sales/order-management-sales/new/page.tsx` | ✅ | 정상 |
| 5 | `src/app/[locale]/(protected)/sales/order-management-sales/page.tsx` | ✅ | 정상 |
| 6 | `src/components/approval/DocumentCreate/ApprovalLineSection.tsx` | ✅ | 정상 |
| 7 | `src/components/approval/DocumentCreate/ReferenceSection.tsx` | ✅ | 정상 |
| 8 | `src/components/hr/EmployeeManagement/EmployeeForm.tsx` | ✅ | 정상 |
| 9 | `src/components/orders/OrderRegistration.tsx` | ✅ | 정상 |
| 10 | `src/components/orders/QuotationSelectDialog.tsx` | ✅ | 정상 |
| 11 | `src/components/process-management/ProcessDetail.tsx` | ✅ | 정상 |
| 12 | `src/components/process-management/RuleModal.tsx` | ✅ | 정상 |
| 13 | `src/components/production/WorkOrders/SalesOrderSelectModal.tsx` | ✅ | 정상 |
| 14 | `src/components/production/WorkOrders/WorkOrderCreate.tsx` | ✅ | 정상 |
| 15 | `src/components/production/WorkOrders/WorkOrderDetail.tsx` | ✅ | 정상 |
| 16 | `src/components/production/WorkOrders/WorkOrderList.tsx` | ✅ | 정상 |
### 2.2 변경된 TS 파일 (15개) - 검토 불필요
TS 파일은 컴포넌트가 아닌 유틸리티/타입/액션 파일로 서버 컴포넌트 대상 아님:
- `src/components/business/construction/*/actions.ts` (6개)
- `src/components/orders/actions.ts`
- `src/components/orders/index.ts`
- `src/components/process-management/actions.ts`
- `src/components/production/WorkOrders/actions.ts`
- `src/components/production/WorkOrders/types.ts`
- `src/lib/api/common-codes.ts`
- `src/lib/api/index.ts`
- `src/types/process.ts`
- `src/components/business/construction/site-management/types.ts`
---
## 3. Push하지 않은 커밋 목록
```
311ddd9 docs: Phase D~K 마이그레이션 완료 상태 반영 (95%)
6615f39 feat(order-management): Mock → API 연동 및 common-codes 유틸리티 추가
d472b77 fix(approval): 결재선/참조 Select 값 변경 불가 버그 수정
5fa20c8 feat(item-management): Mock → API 연동 완료
749f0ce feat: 거래처관리 API 연동 (Phase 2.2)
273d570 feat(시공사): 2.1 현장관리 - Frontend API 연동
78e193c refactor(work-orders): process_type을 process_id FK로 변환
9d30555 feat(시공사): 1.2 인수인계보고서 - Frontend API 연동
d15a203 feat(work-orders): 다중 담당자 UI 구현
8172226 Merge remote-tracking branch 'origin/master'
668cde3 Merge remote-tracking branch 'origin/master'
c651e7b feat(WEB): 수주관리 Phase 3 완료 - 고급 기능 구현
2d7809b feat: [시공관리] 계약관리 Frontend API 연동
12b4259 refactor(work-orders): 코드 리뷰 기반 프론트엔드 개선
fde8726 feat(WEB): 수주관리 Phase 2 타입 정의 확장 및 공정관리 개별 품목 표시 수정
ba36c0e feat: 공정 관리 Frontend actions 업데이트
d797868 fix(WEB): 공정관리 개별 품목 저장 안되는 버그 수정
3d2dea6 feat: 수주 관리 Phase 3 - Frontend API 연동
6632943 Merge remote-tracking branch 'origin/master'
288871c feat(WEB): 직원 관리 폼 직급/부서/직책 Select 드롭다운 연동
572ffe8 feat(orders): Phase 2 - Frontend API 연동 완료
```
---
## 4. 점검 결론
### 4.1 결과
**✅ 모든 TSX 파일에 'use client' 지시어가 있음**
push하지 않은 작업분에서 서버 컴포넌트가 발견되지 않았습니다.
모든 컴포넌트가 클라이언트 컴포넌트 정책을 준수하고 있습니다.
### 4.2 수정 필요 항목
**없음**
---
## 5. 향후 권장사항
### 5.1 새 파일 생성 시 체크리스트
```
□ TSX 파일 첫 줄에 'use client' 지시어 추가
□ page.tsx 파일도 예외 없이 'use client' 필수
□ layout.tsx 파일도 필요시 'use client' 추가
```
### 5.2 코드 리뷰 시 확인
- PR 리뷰 시 새 TSX 파일의 'use client' 지시어 확인
- async 컴포넌트 패턴 지양 (useEffect, React Query 등 사용)
### 5.3 린트 규칙 고려
향후 ESLint 커스텀 룰 추가 검토:
```javascript
// .eslintrc.js 예시
rules: {
'react/enforce-use-client': 'error' // 커스텀 룰
}
```
---
## 6. 변경 이력
| 날짜 | 항목 | 변경 내용 |
|------|------|----------|
| 2025-01-09 | 문서 생성 | 서버 컴포넌트 점검 완료, 수정 불필요 확인 |
---
*이 문서는 /sc:plan 스킬로 생성되었습니다.*

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,421 @@
# 재고 통합 시스템 개발 계획
> **작성일**: 2025-01-26
> **목적**: 입고/생산/견적 시스템과 재고(Stock)의 실시간 연동 구현
> **기준 문서**: `docs/specs/database-schema.md`, `docs/standards/api-rules.md`
> **상태**: 🔄 계획 수립 중
---
## 📍 현재 진행 상태
| 항목 | 내용 |
|------|------|
| **마지막 완료 작업** | Phase 3 - 견적/출하 → 재고 연동 완료 |
| **다음 작업** | ✅ 모든 Phase 완료 |
| **진행률** | 12/12 (100%) |
| **마지막 업데이트** | 2025-01-26 |
---
## 1. 개요
### 1.1 배경
현재 SAM 시스템의 재고 관리는 **조회 전용**으로만 작동합니다:
- 입고(Receiving)가 완료되어도 Stock이 증가하지 않음
- 생산(WorkOrder)에서 자재를 투입해도 Stock이 감소하지 않음
- 견적(Order)이 확정되어도 재고 예약이 되지 않음
- 출하(Shipment)가 완료되어도 Stock이 감소하지 않음
**결과**: 재고현황 페이지가 실제 재고를 반영하지 못함
### 1.2 목표
```
┌─────────────────────────────────────────────────────────────────┐
│ 🎯 핵심 목표 │
├─────────────────────────────────────────────────────────────────┤
│ 1. 입고 완료 → Stock 자동 증가 + StockLot 생성 │
│ 2. 자재 투입 → Stock 자동 차감 (FIFO 기반) │
│ 3. 견적 확정 → reserved_qty 증가 │
│ 4. 출하 완료 → stock_qty 차감 │
│ 5. 모든 변경에 대한 감사 로그 기록 │
└─────────────────────────────────────────────────────────────────┘
```
### 1.3 성공 기준
| 기준 | 측정 방법 |
|------|----------|
| 입고 → 재고 연동 | 입고 완료 시 Stock.stock_qty 자동 증가 확인 |
| 생산 → 재고 연동 | 자재 투입 시 Stock.stock_qty 자동 감소 확인 |
| 견적 → 재고 연동 | 견적 확정 시 Stock.reserved_qty 증가 확인 |
| 출하 → 재고 연동 | 출하 완료 시 Stock.stock_qty 감소 확인 |
| 감사 로그 | 모든 재고 변경이 audit_logs에 기록됨 |
| FIFO 적용 | StockLot이 fifo_order 순서대로 차감됨 |
### 1.4 변경 승인 정책
| 분류 | 예시 | 승인 |
|------|------|------|
| ✅ 즉시 가능 | 메서드 추가, 파라미터 추가, 문서 수정 | 불필요 |
| ⚠️ 컨펌 필요 | Service 로직 변경, 새 이벤트 추가, 마이그레이션 | **필수** |
| 🔴 금지 | 기존 API 응답 구조 변경, Stock 테이블 컬럼 삭제 | 별도 협의 |
### 1.5 준수 규칙
- `docs/standards/api-rules.md` - Service-First 패턴
- `docs/standards/quality-checklist.md` - 품질 체크리스트
- `docs/specs/database-schema.md` - DB 스키마 규칙
---
## 2. 현재 시스템 분석
### 2.1 데이터 모델 관계
```
┌─────────────────────────────────────────────────────────────────┐
│ 현재 상태 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Item (품목) │
│ ↓ 1:1 │
│ Stock (재고현황) ←── 자동 업데이트 없음 ──┐ │
│ ↓ 1:N │ │
│ StockLot (LOT별 상세) ←── 자동 생성 없음 ─┤ │
│ │ │
│ Receiving (입고) ─── 연결 끊김 ────────────┤ │
│ WorkOrder (생산) ─── 연결 없음 ────────────┤ │
│ Order (견적/수주) ─── 연결 없음 ───────────┤ │
│ Shipment (출하) ─── 연결 없음 ─────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
```
### 2.2 목표 데이터 흐름
```
┌─────────────────────────────────────────────────────────────────┐
│ 목표 상태 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ [입고 완료] ──→ StockLot 생성 ──→ Stock.refreshFromLots() │
│ │
│ [자재 투입] ──→ StockLot 차감(FIFO) ──→ Stock.refreshFromLots()│
│ │
│ [견적 확정] ──→ Stock.reserved_qty 증가 │
│ │
│ [출하 완료] ──→ StockLot 차감 ──→ Stock.refreshFromLots() │
│ ──→ Stock.reserved_qty 감소 │
│ │
│ [모든 변경] ──→ AuditLog 기록 │
│ │
└─────────────────────────────────────────────────────────────────┘
```
### 2.3 핵심 파일 위치
| 구분 | 경로 |
|------|------|
| **Stock 모델** | `api/app/Models/Tenants/Stock.php` |
| **StockLot 모델** | `api/app/Models/Tenants/StockLot.php` |
| **StockService** | `api/app/Services/StockService.php` |
| **ReceivingService** | `api/app/Services/ReceivingService.php` |
| **WorkOrderService** | `api/app/Services/WorkOrderService.php` |
| **OrderService** | `api/app/Services/OrderService.php` |
---
## 3. 대상 범위
### Phase 1: 입고 → 재고 연동 (우선순위 1) ✅ 완료
| # | 작업 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 1.1 | StockService에 이벤트 기반 구조 설계 | ✅ | increaseFromReceiving(), getOrCreateStock() |
| 1.2 | ReceivingService.process() 수정 - Stock 연동 | ✅ | StockService 호출 추가 |
| 1.3 | StockLot 자동 생성 로직 구현 | ✅ | FIFO 순서 자동 계산 |
| 1.4 | 감사 로그 통합 | ✅ | logStockChange() 구현 |
| 1.5 | 단위 테스트 작성 | ⏭️ | 수동 테스트로 대체 |
### Phase 2: 생산 → 재고 연동 (우선순위 2) ✅ 완료
| # | 작업 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 2.1 | WorkOrderService에 BOM 기반 자재 조회 구현 | ✅ | getMaterials() 실제 재고 연동 |
| 2.2 | 자재 투입 시 Stock 차감 로직 (FIFO) | ✅ | StockService.decreaseFIFO() |
| 2.3 | 작업 완료 시 제품 Stock 증가 로직 | ⏭️ | 추후 구현 (생산품 LOT 생성 시) |
| 2.4 | 단위 테스트 작성 | ⏭️ | 수동 테스트로 대체 |
### Phase 3: 견적/출하 → 재고 연동 (우선순위 3) ✅ 완료
| # | 작업 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 3.1 | Order 확정 시 reserved_qty 증가 로직 | ✅ | StockService.reserve(), reserveForOrder() |
| 3.2 | Shipment 출하 시 stock_qty 차감 로직 | ✅ | StockService.decreaseForShipment() |
| 3.3 | 예약 취소/변경 처리 로직 | ✅ | StockService.releaseReservation() |
---
## 4. 상세 설계
### 4.1 StockService 이벤트 구조
```php
// api/app/Services/StockService.php
class StockService
{
/**
* 입고 완료 시 재고 증가
* @param Receiving $receiving
* @return StockLot
*/
public function increaseFromReceiving(Receiving $receiving): StockLot
{
// 1. StockLot 생성
// 2. Stock.refreshFromLots() 호출
// 3. 감사 로그 기록
}
/**
* 자재 투입 시 재고 차감 (FIFO)
* @param int $itemId
* @param float $qty
* @param string $reason (work_order, shipment 등)
* @param int $referenceId
* @return array 차감된 LOT 정보
*/
public function decreaseFIFO(int $itemId, float $qty, string $reason, int $referenceId): array
{
// 1. StockLot을 fifo_order 순서로 조회
// 2. 필요 수량만큼 차감 (여러 LOT에 걸칠 수 있음)
// 3. Stock.refreshFromLots() 호출
// 4. 감사 로그 기록
}
/**
* 재고 예약
* @param int $itemId
* @param float $qty
* @param int $orderId
*/
public function reserve(int $itemId, float $qty, int $orderId): void
{
// 1. Stock.reserved_qty 증가
// 2. Stock.available_qty 재계산
// 3. 감사 로그 기록
}
/**
* 예약 해제
*/
public function releaseReservation(int $itemId, float $qty, int $orderId): void
{
// reserved_qty 감소
}
}
```
### 4.2 ReceivingService 수정 사항
```php
// api/app/Services/ReceivingService.php - process() 메서드 수정
public function process(Receiving $receiving, array $data): Receiving
{
return DB::transaction(function () use ($receiving, $data) {
// 기존 로직 유지
$receiving->update([
'receiving_qty' => $data['receiving_qty'],
'receiving_date' => $data['receiving_date'],
'lot_no' => $data['lot_no'],
'status' => 'completed',
]);
// 🆕 재고 연동 추가
app(StockService::class)->increaseFromReceiving($receiving);
return $receiving->fresh();
});
}
```
### 4.3 WorkOrderService 수정 사항
```php
// api/app/Services/WorkOrderService.php - registerMaterialInput() 수정
public function registerMaterialInput(WorkOrder $workOrder, array $data): void
{
DB::transaction(function () use ($workOrder, $data) {
// 기존 감사 로그 유지
// 🆕 재고 차감 추가
$stockService = app(StockService::class);
foreach ($data['materials'] as $material) {
$stockService->decreaseFIFO(
itemId: $material['item_id'],
qty: $material['qty'],
reason: 'work_order_input',
referenceId: $workOrder->id
);
}
});
}
```
### 4.4 감사 로그 구조
| 필드 | 값 |
|------|------|
| `auditable_type` | `Stock` |
| `auditable_id` | Stock ID |
| `event` | `stock_increase`, `stock_decrease`, `stock_reserve` |
| `old_values` | 변경 전 수량 |
| `new_values` | 변경 후 수량 + 사유 + 참조 ID |
---
## 5. 작업 절차
### Step 1: Phase 1 - 입고 → 재고 연동
```
1.1 StockService 이벤트 메서드 추가
├── increaseFromReceiving() 구현
├── 감사 로그 통합
└── 단위 테스트
1.2 ReceivingService.process() 수정
├── 기존 로직 분석
├── StockService 호출 추가
└── 트랜잭션 보장
1.3 StockLot 자동 생성
├── Receiving 정보로 StockLot 생성
├── fifo_order 자동 계산
└── Stock.refreshFromLots() 호출
1.4 테스트 및 검증
├── 입고 생성 → 입고처리 → Stock 확인
└── 감사 로그 확인
```
### Step 2: Phase 2 - 생산 → 재고 연동
```
2.1 BOM 기반 자재 조회 구현
├── 품목의 BOM 정보 조회
├── Mock 데이터 제거
└── 실제 자재 목록 반환
2.2 자재 투입 시 Stock 차감
├── decreaseFIFO() 구현
├── 여러 LOT 걸쳐 차감 처리
└── 재고 부족 시 예외 처리
2.3 작업 완료 시 제품 Stock 증가
├── 생산된 제품의 StockLot 생성
├── Stock.refreshFromLots() 호출
└── 감사 로그 기록
```
### Step 3: Phase 3 - 견적/출하 → 재고 연동
```
3.1 Order 확정 시 예약
├── reserve() 호출
├── available_qty 감소
└── 오버부킹 방지 검증
3.2 Shipment 출하 시 차감
├── decreaseFIFO() 호출
├── reserved_qty 동시 감소
└── 감사 로그 기록
```
---
## 6. 컨펌 대기 목록
> API 내부 로직 변경 등 승인 필요 항목
| # | 항목 | 변경 내용 | 영향 범위 | 상태 |
|---|------|----------|----------|------|
| 1 | ReceivingService.process() | Stock 연동 로직 추가 | 입고 프로세스 | ⏳ 대기 |
| 2 | WorkOrderService.registerMaterialInput() | Stock 차감 로직 추가 | 생산 프로세스 | ⏳ 대기 |
| 3 | ShipmentService (신규) | Stock 차감 로직 추가 | 출하 프로세스 | ⏳ 대기 |
---
## 7. 리스크 및 대응
### 7.1 데이터 정합성 리스크
| 리스크 | 확률 | 영향 | 대응 |
|--------|------|------|------|
| 트랜잭션 실패 시 Stock만 변경됨 | 중 | 높음 | DB 트랜잭션으로 원자성 보장 |
| 동시 요청 시 재고 충돌 | 중 | 높음 | 비관적 락(FOR UPDATE) 적용 |
| 재고 부족 상태에서 차감 시도 | 높음 | 중 | 사전 검증 + 예외 처리 |
### 7.2 성능 리스크
| 리스크 | 확률 | 영향 | 대응 |
|--------|------|------|------|
| LOT가 많을 경우 FIFO 조회 느림 | 낮음 | 중 | fifo_order 인덱스 확인 |
| refreshFromLots() 빈번 호출 | 중 | 낮음 | 필요 시에만 호출 (이미 구현됨) |
---
## 8. 변경 이력
| 날짜 | 항목 | 변경 내용 | 파일 | 승인 |
|------|------|----------|------|------|
| 2025-01-26 | Phase 3 | 견적/출하→재고 연동 구현 완료 | StockService, OrderService, ShipmentService | ✅ |
| 2025-01-26 | Phase 2 | 생산→재고 연동 구현 완료 | StockService, WorkOrderService | ✅ |
| 2025-01-26 | Phase 1 | 입고→재고 연동 구현 완료 | StockService, ReceivingService | ✅ |
| 2025-01-26 | - | 문서 초안 작성 | - | - |
---
## 9. 참고 문서
- **API 규칙**: `docs/standards/api-rules.md`
- **품질 체크리스트**: `docs/standards/quality-checklist.md`
- **DB 스키마**: `docs/specs/database-schema.md`
---
## 10. 자기완결성 점검 결과
### 10.1 체크리스트 검증
| # | 검증 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 1 | 작업 목적이 명확한가? | ✅ | 1.1 배경, 1.2 목표 |
| 2 | 성공 기준이 정의되어 있는가? | ✅ | 1.3 성공 기준 |
| 3 | 작업 범위가 구체적인가? | ✅ | 섹션 3 대상 범위 |
| 4 | 의존성이 명시되어 있는가? | ✅ | 2.3 핵심 파일 위치 |
| 5 | 참고 파일 경로가 정확한가? | ✅ | 섹션 9 참고 문서 |
| 6 | 단계별 절차가 실행 가능한가? | ✅ | 섹션 5 작업 절차 |
| 7 | 검증 방법이 명시되어 있는가? | ✅ | 1.3 성공 기준 |
| 8 | 모호한 표현이 없는가? | ✅ | |
### 10.2 새 세션 시뮬레이션 테스트
| 질문 | 답변 가능 | 참조 섹션 |
|------|:--------:|----------|
| Q1. 이 작업의 목적은 무엇인가? | ✅ | 1.1 배경, 1.2 목표 |
| Q2. 어디서부터 시작해야 하는가? | ✅ | 3. 대상 범위 Phase 1 |
| Q3. 어떤 파일을 수정해야 하는가? | ✅ | 2.3 핵심 파일 위치 |
| Q4. 작업 완료 확인 방법은? | ✅ | 1.3 성공 기준 |
| Q5. 막혔을 때 참고 문서는? | ✅ | 9. 참고 문서 |
**결과**: 5/5 통과 → ✅ 자기완결성 확보
---
*이 문서는 /sc:plan 스킬로 생성되었습니다.*

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,409 @@
# 작업지시 (Work Orders) API 연동 계획
> **작성일**: 2025-01-08
> **목적**: 작업지시 기능 검증 및 테스트
> **상태**: ✅ 전체 테스트 완료 (2025-01-11)
---
## 📍 현재 진행 상태
| 항목 | 내용 |
|------|------|
| **마지막 완료 작업** | 전체 기능 테스트 완료 (2025-01-11) |
| **다음 작업** | 운영 준비 |
| **진행률** | 5/5 (100%) |
| **마지막 업데이트** | 2025-01-11 |
---
## 1. 개요
### 1.1 기능 설명
작업지시는 MES 시스템의 핵심 기능으로, 수주를 기반으로 실제 생산 작업을 지시하고 추적합니다.
공정 유형별(스크린/슬랫/절곡)로 작업 단계를 관리하며, 담당자 배정 및 작업 상태를 추적합니다.
### 1.2 현재 구현 상태 분석
#### API (Laravel) - ✅ 완료
| 구성요소 | 파일 경로 | 상태 |
|---------|----------|:----:|
| Model | `api/app/Models/Production/WorkOrder.php` | ✅ |
| Model | `api/app/Models/Production/WorkOrderItem.php` | ✅ |
| Model | `api/app/Models/Production/WorkOrderBendingDetail.php` | ✅ |
| Model | `api/app/Models/Production/WorkOrderIssue.php` | ✅ |
| Service | `api/app/Services/WorkOrderService.php` | ✅ |
| Controller | `api/app/Http/Controllers/Api/V1/WorkOrderController.php` | ✅ |
| FormRequest | `api/app/Http/Requests/WorkOrder/*.php` | ✅ |
| Route | `/api/v1/work-orders` | ✅ |
#### Frontend (React/Next.js) - ✅ API 연동 완료
| 구성요소 | 파일 경로 | 상태 |
|---------|----------|:----:|
| 목록 페이지 | `react/src/app/[locale]/(protected)/production/work-orders/page.tsx` | ✅ |
| 등록 페이지 | `react/src/app/[locale]/(protected)/production/work-orders/create/page.tsx` | ✅ |
| 상세 페이지 | `react/src/app/[locale]/(protected)/production/work-orders/[id]/page.tsx` | ✅ |
| 목록 컴포넌트 | `react/src/components/production/WorkOrders/WorkOrderList.tsx` | ✅ |
| 등록 컴포넌트 | `react/src/components/production/WorkOrders/WorkOrderCreate.tsx` | ✅ |
| 상세 컴포넌트 | `react/src/components/production/WorkOrders/WorkOrderDetail.tsx` | ✅ |
| 수주선택 모달 | `react/src/components/production/WorkOrders/SalesOrderSelectModal.tsx` | ✅ |
| 담당자선택 모달 | `react/src/components/production/WorkOrders/AssigneeSelectModal.tsx` | ✅ |
| 타입 정의 | `react/src/components/production/WorkOrders/types.ts` | ✅ |
| **actions.ts** | `react/src/components/production/WorkOrders/actions.ts` | ✅ |
### 1.3 관련 URL
| 화면 | URL | 설명 |
|------|-----|------|
| 작업지시목록 | `/production/work-orders` | 상태별 필터링, 검색 |
| 작업지시등록 | `/production/work-orders/create` | 모달 - 수주선택 |
| 작업지시상세 | `/production/work-orders/{id}` | 상세 정보 |
### 1.4 연관관계
```
┌─────────────────┐ ┌─────────────────┐
│ Order │────sales_order_id──▶│ WorkOrder │
│ (수주) │ │ (작업지시) │
└─────────────────┘ └─────────────────┘
┌───────────────────────────────────────┼───────────────────────────────────────┐
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ WorkOrderItem │ │WorkOrderBending │ │ WorkOrderIssue │
│ (작업품목) │ │ Detail │ │ (이슈) │
└─────────────────┘ │ (절곡상세) │ └─────────────────┘
└─────────────────┘
│ work_order_id
┌─────────────────┐
│ WorkResult │
│ (작업실적) │
└─────────────────┘
```
---
## 2. API 엔드포인트
### 2.1 REST API (구현 완료)
| Method | Endpoint | 설명 | 상태 |
|--------|----------|------|:----:|
| GET | `/api/v1/work-orders` | 목록 조회 (필터/페이징) | ✅ |
| GET | `/api/v1/work-orders/stats` | 통계 조회 | ✅ |
| GET | `/api/v1/work-orders/{id}` | 상세 조회 | ✅ |
| POST | `/api/v1/work-orders` | 작업지시 생성 | ✅ |
| PUT | `/api/v1/work-orders/{id}` | 작업지시 수정 | ✅ |
| DELETE | `/api/v1/work-orders/{id}` | 작업지시 삭제 | ✅ |
| PATCH | `/api/v1/work-orders/{id}/status` | 상태 변경 | ✅ |
| PATCH | `/api/v1/work-orders/{id}/assign` | 담당자 배정 | ✅ |
| PATCH | `/api/v1/work-orders/{id}/bending/toggle` | 절곡 상세 토글 | ✅ |
| POST | `/api/v1/work-orders/{id}/issues` | 이슈 등록 | ✅ |
| PATCH | `/api/v1/work-orders/{id}/issues/{issueId}/resolve` | 이슈 해결 | ✅ |
### 2.2 actions.ts 구현 함수 (완료)
```typescript
// 목록/조회
getWorkOrders(params) // 목록 조회
getWorkOrderStats() // 통계 조회
getWorkOrderById(id) // 상세 조회
// CRUD
createWorkOrder(data) // 생성
updateWorkOrder(id, data) // 수정
deleteWorkOrder(id) // 삭제
// 상태/배정
updateWorkOrderStatus(id, status) // 상태 변경
assignWorkOrder(id, data) // 담당자 배정
// 절곡 공정
toggleBendingField(id, field, value) // 절곡 상세 토글
// 이슈 관리
addWorkOrderIssue(id, data) // 이슈 등록
resolveWorkOrderIssue(id, issueId) // 이슈 해결
// 연동
getSalesOrdersForWorkOrder() // 수주 목록 (작업지시용)
getDepartmentsWithUsers() // 부서/사용자 목록 (담당자 배정용)
```
---
## 3. 데이터 스키마
### 3.1 WorkOrder (작업지시)
```typescript
interface WorkOrder {
id: string;
workOrderNo: string; // WO202512260001
lotNo: string; // 수주번호 참조
processType: 'screen' | 'slat' | 'bending';
status: WorkOrderStatus;
// 기본 정보
client: string; // 발주처
projectName: string; // 현장명
dueDate: string; // 납기일
assignee: string; // 작업자
// 날짜
orderDate: string; // 지시일
shipmentDate: string; // 출고예정일
// 플래그
isAssigned: boolean;
isStarted: boolean;
priority: number; // 1~9
// 품목
items: WorkOrderItem[];
// 공정 진행
currentStep: number;
// 절곡 전용
bendingDetails?: BendingDetail[];
// 이슈
issues?: WorkOrderIssue[];
note?: string;
}
```
### 3.2 WorkOrderStatus (상태)
```typescript
type WorkOrderStatus =
| 'unassigned' // 미배정
| 'pending' // 승인대기
| 'waiting' // 작업대기
| 'in_progress' // 작업중
| 'completed' // 작업완료
| 'shipped'; // 출하완료
```
### 3.3 ProcessType (공정 유형)
```typescript
type ProcessType = 'screen' | 'slat' | 'bending';
// 공정별 작업 단계
const SCREEN_STEPS = ['원단절단', '미싱', '앤드락작업', '중간검사', '포장'];
const SLAT_STEPS = ['코일절단', '성형', '미미작업', '검사', '포장'];
const BENDING_STEPS = ['가이드레일 제작', '케이스 제작', '하단마감재 제작', '검사'];
```
---
## 4. 작업 범위
### Phase 1: 검증 및 테스트 ✅ 완료 (2025-01-11)
| # | 작업 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 1.1 | 목록 조회 테스트 | ✅ | 필터링/검색/페이징 정상 |
| 1.2 | 등록 기능 테스트 | ✅ | 수주 선택 모달 동작 확인 |
| 1.3 | 상세 조회 테스트 | ✅ | 버그 수정 완료 (site_name 컬럼 수정) |
| 1.4 | 상태 변경 테스트 | ✅ | 전체 상태 전이 검증 완료 |
| 1.5 | 담당자 배정 테스트 | ✅ | 배정 시 상태 자동 전이 확인 |
**Phase 1 테스트 상세:**
- **버그 수정**: WorkOrderService.php:119 - `project_name``site_name` (Order 모델에 맞춤)
- **상태 전이**: pending ⇄ waiting ⇄ in_progress ⇄ completed ⇄ shipped 모두 정상
- **담당자 배정**: 배정 시 unassigned → pending 자동 전이 확인
### Phase 2: 공정별 기능 테스트 ✅ 완료 (2025-01-11)
| # | 작업 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 2.1 | 스크린 공정 작업지시 | ✅ | process_id=2 생성 확인 |
| 2.2 | 슬랫 공정 작업지시 | ✅ | process_id=1 생성 확인 |
| 2.3 | 공정별 필터링 | ✅ | forProcess(), forProcessName() 정상 |
| 2.4 | 작업지시 품목 관리 | ✅ | WorkOrderItem CRUD 확인 |
**Phase 2 테스트 상세:**
- **공정 목록**: 슬랫(P-001), 스크린(P-002) 활성화 확인
- **공정별 필터**: `forProcess(1)`, `forProcessName('슬랫')` 정상 동작
- **품목 관리**: 작업지시별 품목 추가/조회 정상
### Phase 3: 이슈 및 연동 ✅ 완료 (2025-01-11)
| # | 작업 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 3.1 | 이슈 등록 기능 | ✅ | 이슈 생성 정상 |
| 3.2 | 이슈 해결 기능 | ✅ | 해결 상태/시간 저장 확인 |
| 3.3 | 수주 연동 확인 | ✅ | salesOrder 관계 정상 |
| 3.4 | 작업실적 연동 | ⏭️ | 후순위 (별도 기능) |
**Phase 3 테스트 상세:**
- **이슈 관리**: 등록(open) → 해결(resolved) 전체 흐름 정상
- **open_issues_count**: 미해결 이슈 카운트 속성 정상
- **수주 연동**: WorkOrder.salesOrder 관계를 통한 수주 정보 조회 정상
---
## 5. 주요 기능 상세
### 5.1 수주 선택 (모달)
```
작업지시 등록
▼ "수주 선택" 버튼
┌─────────────────────────────────┐
│ SalesOrderSelectModal │
│ - 수주 목록 (for_work_order=1) │
│ - 검색 기능 │
│ - 선택 시 정보 자동 채움 │
└─────────────────────────────────┘
```
### 5.2 상태 흐름
```
unassigned (미배정)
▼ 담당자 배정
pending (승인대기)
▼ 승인
waiting (작업대기)
▼ 작업 시작
in_progress (작업중)
▼ 작업 완료
completed (작업완료)
▼ 출하
shipped (출하완료)
```
### 5.3 공정별 작업 단계
#### 스크린 공정 (screen)
1. 원단절단 (cutting)
2. 미싱 (sewing)
3. 앤드락작업 (endlock)
4. 중간검사 (inspection)
5. 포장 (packing)
#### 슬랫 공정 (slat)
1. 코일절단 (coil_cutting)
2. 성형 (forming)
3. 미미작업 (finishing)
4. 검사 (inspection)
5. 포장 (packing)
#### 절곡 공정 (bending)
1. 가이드레일 제작 (guide_rail)
2. 케이스 제작 (case)
3. 하단마감재 제작 (bottom_finish)
4. 검사 (inspection)
### 5.4 절곡 상세 토글
- 절곡 공정의 세부 항목 완료 여부 토글
- `PATCH /api/v1/work-orders/{id}/bending/toggle`
- 필드: shaft_cutting, bearing, shaft_welding, assembly 등
### 5.5 이슈 관리
- 작업 중 발생한 이슈 등록
- 우선순위: low, medium, high
- 상태: pending → resolved
---
## 6. 의존성
### 6.1 필수 선행 작업
- **공정관리 (Process)**: 공정 유형 정의 - ✅ 완료
- **사원관리**: 담당자 배정 (assignee_id)
- **부서관리**: 팀 배정 (team_id)
### 6.2 관련 의존성
- **수주관리 (Order)**: 수주 데이터 필요 (sales_order_id)
- ✅ Order API 연동 완료 (2025-01-09)
- 수주 → 생산지시 생성 기능 연동됨
### 6.3 후속 연동
- **작업실적 (WorkResult)**: 작업 완료 후 실적 등록
- **품질검사**: 검사 공정 연동
- **출하관리**: 출하 처리
---
## 7. 검증 방법
### 7.1 테스트 체크리스트
| 기능 | 테스트 항목 | 예상 결과 |
|------|-----------|----------|
| 목록 조회 | 페이지 로드 | 작업지시 목록 표시 |
| 상태 필터 | "작업중" 탭 클릭 | 해당 상태만 표시 |
| 검색 | 작업지시번호 검색 | 필터링된 결과 |
| 등록 | 새 작업지시 등록 | 목록에 추가됨 |
| 상세 조회 | 행 클릭 | 상세 정보 표시 |
| 상태 변경 | 상태 버튼 클릭 | 상태 전환됨 |
| 담당자 배정 | 배정 버튼 클릭 | 담당자 변경됨 |
| 이슈 등록 | 이슈 추가 | 이슈 목록에 표시 |
### 7.2 API 테스트
```bash
# 목록 조회
curl -X GET "http://api.sam.kr/api/v1/work-orders" -H "X-Api-Key: ..."
# 상세 조회
curl -X GET "http://api.sam.kr/api/v1/work-orders/1" -H "X-Api-Key: ..."
# 통계 조회
curl -X GET "http://api.sam.kr/api/v1/work-orders/stats" -H "X-Api-Key: ..."
# 상태 변경
curl -X PATCH "http://api.sam.kr/api/v1/work-orders/1/status" \
-H "X-Api-Key: ..." \
-H "Content-Type: application/json" \
-d '{"status": "in_progress"}'
```
---
## 8. 참고 사항
### 8.1 작업지시번호 형식
- 형식: `WO{YYYYMMDD}{NNNN}`
- 예: `WO202512260001`
- 자동 생성: `WorkOrderService::generateWorkOrderNo()`
### 8.2 Worker Screen (작업자 화면)
- 별도 화면: `/production/worker-screen`
- 작업자가 직접 작업 진행/완료 처리
- 이슈 보고 기능
- `react/src/components/production/WorkerScreen/` 참고
### 8.3 Production Dashboard
- 생산 현황 대시보드: `/production/dashboard`
- 공정별 작업 현황 시각화
- `react/src/components/production/ProductionDashboard/` 참고
---
## 9. 참고 문서
- **빠른 시작**: `docs/quickstart/quick-start.md`
- **API 규칙**: `docs/standards/api-rules.md`
- **품질 체크리스트**: `docs/standards/quality-checklist.md`
### 참고 코드
- **Controller**: `api/app/Http/Controllers/Api/V1/WorkOrderController.php`
- **Service**: `api/app/Services/WorkOrderService.php`
- **actions.ts**: `react/src/components/production/WorkOrders/actions.ts`
---
## 10. 자기완결성 점검
| # | 검증 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 1 | 작업 목적이 명확한가? | ✅ | 검증 및 테스트 |
| 2 | 성공 기준이 정의되어 있는가? | ✅ | 섹션 7 참조 |
| 3 | 작업 범위가 구체적인가? | ✅ | Phase 1-3 테스트 항목 |
| 4 | 의존성이 명시되어 있는가? | ✅ | Order API 연동 완료 |
| 5 | 참고 파일 경로가 정확한가? | ✅ | 모든 경로 검증됨 |
| 6 | 단계별 절차가 실행 가능한가? | ✅ | 테스트 체크리스트 제공 |
| 7 | 검증 방법이 명시되어 있는가? | ✅ | curl + 체크리스트 |
| 8 | 모호한 표현이 없는가? | ✅ | 구체적 경로 명시 |
---
*이 문서는 독립 세션에서 바로 작업 시작 가능하도록 설계되었습니다.*

View File

@@ -0,0 +1,838 @@
# 절곡품 선생산 → 재고 적재 흐름 통합 개발 계획
> **작성일**: 2026-02-21
> **목적**: 레거시 5130 절곡품(가이드레일/셔터박스/바텀바) 관리를 SAM 기존 재고 시스템에 통합하고, 선생산→재고적재 흐름 구현
> **기준 문서**: `api/app/Services/StockService.php`, `api/app/Services/WorkOrderService.php`, `docs/plans/bending-info-auto-generation-plan.md`
> **상태**: 🔄 Phase 3 완료 (3.5 마이그레이션 제외)
---
## 📍 현재 진행 상태
| 항목 | 내용 |
|------|------|
| **마지막 완료 작업** | 3.5 레거시 데이터 마이그레이션 커맨드 작성 완료 |
| **다음 작업** | 마이그레이션 실행 및 검증 |
| **진행률** | 14/14 (100%) |
| **마지막 업데이트** | 2026-02-21 |
---
## 0. 용어 및 비즈니스 배경
### 0.1 절곡품이란?
- **절곡(Bending)**: 금속판(철판, SUS, EGI)을 절곡기로 구부려 만드는 부품
- **주요 절곡품 3종**:
- **가이드레일**: 방화셔터가 상하로 이동하는 레일 (벽면형/측면형, SUS/EGI 마감)
- **셔터박스(케이스)**: 방화셔터가 말려 들어가는 상부 박스 (양면/밑면/후면 점검구)
- **바텀바(하단마감재)**: 방화셔터 하부를 마감하는 부품 (스크린/철재)
- **연기차단재**: 가이드레일/케이스에 부착하는 연기 차단용 부자재 (W50 레일용, W80 케이스용)
### 0.2 선생산 운영 방식
- 절곡품은 **수주와 무관하게 미리 대량 생산**하여 재고로 비축
- 수주 발생 시 비축된 재고에서 **투입(차감)**하여 사용
- 이유: 절곡 공정은 셋업 시간이 길어 건별 생산보다 일괄 생산이 효율적
### 0.3 SAM 프로젝트 구조
```
SAM/
├── api/ # Laravel 12 REST API (백엔드)
├── react/ # Next.js 15 프론트엔드
├── mng/ # 관리자 패널 (Plain Laravel)
├── 5130/ # 레거시 시스템 소스코드 (참조용)
└── docs/ # 기술 문서
```
### 0.4 SAM 핵심 아키텍처 규칙
- **Service-First**: 비즈니스 로직은 반드시 Service 레이어
- **Multi-tenancy**: 모든 모델에 `BelongsToTenant` trait, tenant_id 필수
- **컬럼 추가 정책**: FK/조인키만 컬럼 추가, 나머지 속성은 `options` JSON 활용
- **FormRequest**: Controller에서 검증 금지, FormRequest 사용
---
## 1. 개요
### 1.1 배경
레거시 5130에서 절곡품(가이드레일, 셔터박스, 바텀바)은 **수주와 무관하게 미리 생산하여 재고로 관리**하는 형태.
수주 발생 시 재고에서 투입(차감)하는 방식으로 운영됨.
SAM에는 이미 재고 관리 시스템(`stocks` + `stock_lots` + `stock_transactions`)이 구축되어 있으나,
**생산 완료 → 재고 입고** 경로가 없어 절곡품 선생산 흐름을 지원하지 못함.
### 1.2 레거시 5130 절곡품 관리 구조
```
[5130 시스템]
┌─────────────────────────────────────────────────────────────┐
│ 절곡품 마스터 (3종) │
│ ├── guiderail 테이블 (가이드레일) │
│ │ ├── 대분류: 스크린/철재 │
│ │ ├── 인정/비인정, 제품코드(KSS01 등) │
│ │ ├── 치수: rail_width × rail_length │
│ │ ├── material_summary (소요자재량 JSON) │
│ │ └── bending_components (절곡 구성품) │
│ ├── shutterbox 테이블 (셔터박스) │
│ │ ├── 점검구 형태: 양면/밑면/후면 │
│ │ └── 치수: box_width × box_height │
│ └── bottombar 테이블 (바텀바/하단마감재) │
│ ├── 대분류: 스크린/철재 │
│ └── 치수: bar_width × bar_height │
│ │
│ 재고 관리 │
│ ├── lot 테이블 (생산 LOT) │
│ │ ├── 3코드 식별: prod + spec + slength │
│ │ ├── lot_number, surang(수량), rawLot(원자재LOT) │
│ │ └── 재고 = SUM(lot.surang) - SUM(bending_work_log.qty) │
│ └── bending_work_log 테이블 (사용 이력) │
│ └── quantity, reg_date, lot_no │
└─────────────────────────────────────────────────────────────┘
```
### 1.3 SAM 현재 상태 (AS-IS)
```
[수주 기반 흐름만 존재]
Order(수주) ──→ WorkOrder(생산지시) ──→ 자재투입 ──→ 완료 ──→ Shipment(출하)
│ │ │
│ sales_order_id 필수 │ 재고차감 │ ⚠️ 재고입고 없이
│ (비즈니스 로직상) │ (기존 OK) │ 바로 출하
[구매입고 흐름 (별도)]
Receiving(입고) ──→ StockService::increaseFromReceiving() (라인 241)
│ Stock + StockLot 생성
│ StockTransaction(IN, receiving)
└─ FIFO 순서 부여
```
### 1.4 목표 흐름 (TO-BE)
```
[선생산 흐름 (신규)]
선생산 작업지시 ──→ 자재투입 ──→ 생산완료
│ sales_order_id = NULL │
│ mode = 'manual' (프론트) │
⭐ 재고 입고 (신규)
StockService::increaseFromProduction()
Stock + StockLot 생성
StockTransaction(IN, production_output)
[완성품 재고 적재]
LOT 추적, FIFO 관리
[수주 발생 시]
재고 확인 → reserve() → 부족분만 생산지시
[기존 수주 기반 흐름 (변경 없음)]
Order ──→ WorkOrder ──→ 완료 ──→ Shipment (기존 유지)
```
### 1.5 핵심 설계 결정
```
┌─────────────────────────────────────────────────────────────────┐
│ 🎯 설계 원칙 │
├─────────────────────────────────────────────────────────────────┤
│ 1. 기존 재고 시스템(stocks/stock_lots/stock_transactions) 재활용 │
│ 2. Receiving은 구매입고 전용 유지 → 생산입고는 직접 StockService │
│ 3. 멀티테넌시 정책: FK만 컬럼, 나머지는 options JSON │
│ 4. items.options 체계 활용 (production_source, lot_managed 등) │
│ 5. 절곡품 전용 페이지 불필요 → 기존 재고현황에 필터 추가 │
└─────────────────────────────────────────────────────────────────┘
```
### 1.6 변경 승인 정책
| 분류 | 예시 | 승인 |
|------|------|------|
| ✅ 즉시 가능 | 상수 추가, 필터 파라미터 추가, options JSON 활용 | 불필요 |
| ⚠️ 컨펌 필요 | 신규 메서드 추가, 비즈니스 로직 분기, 프론트 UI 변경 | **필수** |
| 🔴 금지 | 기존 입출고 로직 변경, stocks 테이블 구조 변경, 기존 API 스펙 변경 | 별도 협의 |
### 1.7 준수 규칙
- `CLAUDE.md` - Service-First, FormRequest, BelongsToTenant
- `SAM_QUICK_REFERENCE.md` - API 규칙
- `docs/plans/bending-info-auto-generation-plan.md` - BendingInfoBuilder 참조
- `docs/plans/bending-worklog-reimplementation-plan.md` - 프론트 절곡 컴포넌트 참조
---
## 2. 대상 범위
### 2.1 Phase 1: 재고 입고 기반 구축 (백엔드)
| # | 작업 항목 | 상태 | 영향 파일 |
|---|----------|:----:|----------|
| 1.1 | StockTransaction REASON 상수 추가 | ✅ | `api/app/Models/Tenants/StockTransaction.php` (라인 41-57) |
| 1.2 | StockLot에 work_order_id 컬럼 추가 | ✅ | `api/database/migrations/` (신규), `api/app/Models/Tenants/StockLot.php` |
| 1.3 | StockService::increaseFromProduction() 구현 | ✅ | `api/app/Services/StockService.php` (라인 241 참조) |
| 1.4 | WorkOrderService 완료 처리 분기 로직 | ✅ | `api/app/Services/WorkOrderService.php` (라인 563-593) |
### 2.2 Phase 2: 선생산 작업지시 흐름 (백엔드 + 프론트)
| # | 작업 항목 | 상태 | 영향 파일 |
|---|----------|:----:|----------|
| 2.1 | 수주 없는 작업지시 API 보완 | ✅ | 이미 지원됨 (sales_order_id nullable, items 직접 전달 가능) |
| 2.2 | items.options 기반 비즈니스 로직 분기 | ✅ | Phase 1에서 shouldStockIn()으로 구현 완료 |
| 2.3 | 작업지시 생성 프론트 UI 보완 (manual 모드) | ✅ | `react/.../WorkOrderCreate.tsx` + `actions.ts` (품목 검색/추가 UI, items 파라미터) |
| 2.4 | 재고현황 item_category 필터 추가 (API) | ✅ | `api/app/Services/StockService.php`, `StockController.php` |
| 2.5 | 재고현황 절곡품 필터 추가 (프론트) | ✅ | `react/.../StockStatusList.tsx` + `actions.ts` (카테고리 필터 드롭다운) |
### 2.3 Phase 3: 수주 연동 고도화
| # | 작업 항목 | 상태 | 영향 파일 |
|---|----------|:----:|----------|
| 3.1 | 수주의 절곡 BOM 품목별 재고 확인 API | ✅ | `api/app/Services/OrderService.php`, `OrderController.php`, `routes/api/v1/sales.php` |
| 3.2 | 가용 재고 자동 예약(reserve) 로직 | ✅ | 기존 `reserveForOrder()` (라인 639-642)에서 이미 처리됨 |
| 3.3 | 부족분 수동 처리 (사용자 결정) | ✅ | 프론트에서 부족 현황 표시 → 사용자가 수동으로 선생산 작업지시 생성 |
| 3.4 | 수주화면 절곡 재고 현황 표시 (프론트) | ✅ | `react/src/components/orders/actions.ts`, `orders/index.ts`, `order-management-sales/[id]/page.tsx` |
| 3.5 | 5130 레거시 데이터 마이그레이션 | ⏳ | `api/database/seeders/` 또는 마이그레이션 스크립트 (별도 진행) |
---
## 3. 작업 절차
### 3.1 Phase 1 상세 절차
```
Step 1.1: StockTransaction REASON 상수 추가
├── 파일: api/app/Models/Tenants/StockTransaction.php
├── 위치: 라인 49 (REASON_ORDER_CANCEL 다음)
├── 추가: const REASON_PRODUCTION_OUTPUT = 'production_output';
├── REASONS 배열에도 추가 (라인 51-57)
└── 검증: 모델 상수 선언 확인
Step 1.2: StockLot에 work_order_id 컬럼 추가
├── 마이그레이션 파일 생성
│ └── stock_lots 테이블에 work_order_id (nullable, FK → work_orders.id) 추가
│ └── 위치: receiving_id (라인 47) 다음
├── StockLot 모델 수정 (api/app/Models/Tenants/StockLot.php)
│ ├── fillable에 'work_order_id' 추가 (라인 15-34)
│ └── workOrder() 관계 추가: belongsTo(WorkOrder::class)
├── 멀티테넌시 정책: work_order_id는 FK이므로 컬럼 추가 정당
└── 검증: migrate:status, 모델 관계 확인
Step 1.3: StockService::increaseFromProduction() 구현
├── 파일: api/app/Services/StockService.php
├── 기존 increaseFromReceiving() (라인 241-314) 참고하여 구현
│ ├── getOrCreateStock() 재사용 (라인 423-466)
│ ├── getNextFifoOrder() 재사용 (라인 474)
│ ├── StockLot 생성 (work_order_id 참조, receiving_id는 null)
│ ├── Stock.refreshFromLots() 호출 (Stock.php 라인 149-164)
│ ├── recordTransaction() 호출 (라인 1232)
│ └── logStockChange() 호출 (라인 1274)
├── 차이점: receiving_id 대신 work_order_id 사용, supplier 관련 필드 null
├── LOT 번호: WorkOrderService::generateLotNo() (라인 845-866) 에서 생성한 것 수신
└── 검증: 단위 테스트 (입고 후 재고량 증가 확인)
Step 1.4: WorkOrderService 완료 처리 분기 로직
├── 파일: api/app/Services/WorkOrderService.php
├── 수정 위치: updateStatus() 라인 591-593
│ 현재 코드:
│ if ($status === WorkOrder::STATUS_COMPLETED) {
│ $this->createShipmentFromWorkOrder($workOrder, $tenantId, $userId);
│ }
│ 변경:
│ if ($status === WorkOrder::STATUS_COMPLETED) {
│ if ($workOrder->sales_order_id) {
│ $this->createShipmentFromWorkOrder($workOrder, $tenantId, $userId);
│ } else {
│ $this->stockInFromProduction($workOrder);
│ }
│ }
├── saveItemResults() (라인 805-840)는 양쪽 모두 실행됨 (라인 563-568, 분기 전에 호출)
├── generateLotNo() (라인 845-866) 에서 LOT 번호 자동 생성 (KD-SA-YYMMDD-NN 형식)
└── 검증: 선생산 WO 완료 시 재고 증가 확인, 기존 수주 WO는 변경 없음
```
### 3.2 Phase 2 상세 절차
```
Step 2.1: 수주 없는 작업지시 API 보완
├── WorkOrderService::store() 메서드 확인
│ └── sales_order_id 없이도 items 직접 전달 가능 (기존 경로 활용)
├── work_orders.sales_order_id는 DB에서 이미 nullable
├── 프론트: WorkOrderCreate.tsx의 RegistrationMode (라인 52)
│ └── 현재: type RegistrationMode = 'linked' | 'manual'
│ └── 'manual' 선택 시 수주 연동 없이 생성 가능
│ └── ⚠️ 주의: 'source_type' 필드는 현재 존재하지 않음 → 필요시 신규 추가
└── 검증: Postman으로 수주 없는 작업지시 생성 테스트
Step 2.2: items.options 기반 비즈니스 로직 분기
├── Item.options 참조 위치 정리
│ ├── production_source: 'purchased' | 'self_produced' | 'both'
│ ├── lot_managed: boolean
│ └── consumption_method: 'auto' | 'manual' | 'none'
├── 생산완료 시: production_source === 'self_produced' && lot_managed → 재고 입고
├── 자재투입 시: consumption_method에 따른 차감 방식 분기
└── 검증: 절곡 품목의 options 값 시더 데이터 확인
Step 2.3: 작업지시 생성 프론트 UI 보완
├── 파일: react/src/components/production/WorkOrders/WorkOrderCreate.tsx
├── 현재 manual 모드 UI (라인 278-305):
│ └── RadioGroup에 'linked' | 'manual' 선택지, Label: "수동 등록 (재고생산)"
├── 보완 필요:
│ ├── 품목 검색/선택 UI (items 마스터에서 BENDING 카테고리 필터)
│ ├── 수량 입력
│ └── 공정 선택 (절곡 공정 기본 선택)
├── 생산완료 버튼 UI 변경 (선생산 WO: "재고 입고" / 수주 WO: "출하")
└── 검증: 프론트에서 선생산 작업지시 생성 → 완료 → 재고 확인
Step 2.4: 재고현황 item_category 필터 추가 (API)
├── 파일: api/app/Services/StockService.php
├── index() 메서드 (라인 45) 파라미터에 item_category 추가
│ └── whereHas('item', fn($q) => $q->where('item_category', $category))
├── StockController 파라미터 바인딩
└── 검증: API 호출로 BENDING 카테고리 필터링 확인
Step 2.5: 재고현황 절곡품 필터 추가 (프론트)
├── 파일: react/src/components/material/StockStatus/StockStatusList.tsx
├── 관련 파일:
│ ├── StockStatusDetail.tsx (상세)
│ ├── stockStatusConfig.ts (설정)
│ ├── actions.ts (API 호출)
│ └── types.ts (타입 정의)
├── 카테고리 탭 또는 드롭다운 추가
│ └── 전체 | 원자재 | 절곡품(BENDING) | 부자재 | 소모품
├── API 호출 시 item_category 파라미터 전달
└── 검증: 절곡품 필터 적용하여 재고 목록 확인
```
### 3.3 Phase 3 상세 절차
```
Step 3.1: 수주 확정 시 재고 자동 확인
├── OrderService::confirmOrder() 또는 createProductionOrder() 수정
│ ├── BOM에서 절곡 품목 추출 (item_category === 'BENDING')
│ ├── 각 품목의 가용 재고 조회: StockService::getAvailableStock() (라인 796)
│ └── 재고 현황 반환 (충족/부족 품목별)
├── 프론트에 재고 확인 결과 표시
└── 검증: 수주 확정 시 재고 현황 표시 확인
Step 3.2: 가용 재고 자동 예약
├── 기존 메서드 활용:
│ ├── StockService::reserve() (라인 832)
│ └── StockService::releaseReservation() (라인 948)
├── 예약 시점: 수주 확정 시 자동 예약 (사용자 확인 후)
├── 예약 해제: 수주 취소 시 releaseReservation()
└── 검증: 예약 후 available_qty 감소 확인
Step 3.3: 부족분 자동 생산지시
├── 수주 확정 시 재고 부족 품목에 대해 자동 생산지시 생성
│ └── createProductionOrder()에 부족 수량만 반영
├── 또는 수동: 부족 품목 목록을 사용자에게 표시 → 선생산 지시 유도
└── 검증: 재고 10개, 필요 15개 → 5개만 생산지시 확인
Step 3.4: 수주화면 재고 현황 표시
├── 수주 상세/편집 화면에 절곡 품목별 재고 현황 표시
│ └── 품목명 | 필요수량 | 가용재고 | 부족수량
└── 검증: UI 렌더링 확인
Step 3.5: 5130 레거시 데이터 마이그레이션
├── lot 테이블 → stocks + stock_lots 매핑
│ ├── prod+spec+slength → items.code (BD-* 패턴) 매핑
│ ├── surang → stock_lots.qty
│ └── rawLot → stock_lots.options (원자재 LOT 추적)
├── bending_work_log → stock_transactions 매핑
│ └── quantity → stock_transactions (TYPE_OUT)
├── guiderail/shutterbox/bottombar → items 테이블 매핑
│ └── item_category = 'BENDING', item_type = 'PT'
└── 검증: 마이그레이션 전후 재고량 일치 확인
```
---
## 4. 상세 작업 내용
### 4.1 현재 DB 스키마 (수정 대상)
#### stocks 테이블 (`2025_12_26_132806_create_stocks_table.php`)
```
id, tenant_id, item_id, item_code, item_name, item_type,
specification, unit, stock_qty, safety_stock,
reserved_qty, available_qty, lot_count, oldest_lot_date,
location, status, last_receipt_date, last_issue_date,
created_by, updated_by, timestamps, softDeletes, deleted_by
```
#### stock_lots 테이블 (`2025_12_26_132842_create_stock_lots_table.php`)
```
id, tenant_id, stock_id(FK→stocks), lot_no, fifo_order(default:1),
receipt_date, qty(decimal 15,3), reserved_qty, available_qty,
unit(default:'EA'), supplier, supplier_lot, po_number,
location, status(default:'available'), receiving_id(nullable),
created_by, updated_by, timestamps, softDeletes, deleted_by
인덱스: tenant_id, stock_id, lot_no, status, (stock_id+fifo_order) 복합
유니크: (tenant_id, stock_id, lot_no)
```
#### stock_transactions 테이블 (`2026_01_29_000001_create_stock_transactions_table.php`)
```
id, tenant_id, stock_id, stock_lot_id, type(IN/OUT/RESERVE/RELEASE),
qty, balance_qty, reference_type, reference_id, lot_no,
reason, remark, item_code, item_name, created_by, timestamps
```
### 4.2 현재 코드 레퍼런스 (라인번호 포함)
#### StockTransaction 상수 (`api/app/Models/Tenants/StockTransaction.php`)
```php
// 라인 25-31: TYPE 상수
const TYPE_IN = 'IN'; // 라인 25
const TYPE_OUT = 'OUT'; // 라인 27
const TYPE_RESERVE = 'RESERVE'; // 라인 29
const TYPE_RELEASE = 'RELEASE'; // 라인 31
// 라인 41-57: REASON 상수
const REASON_RECEIVING = 'receiving'; // 라인 41
const REASON_WORK_ORDER_INPUT = 'work_order_input'; // 라인 43
const REASON_SHIPMENT = 'shipment'; // 라인 45
const REASON_ORDER_CONFIRM = 'order_confirm'; // 라인 47
const REASON_ORDER_CANCEL = 'order_cancel'; // 라인 49
const REASONS = [ ... ]; // 라인 51-57
```
#### StockService 주요 메서드 (`api/app/Services/StockService.php`)
```
라인 45: index(array $params): LengthAwarePaginator
라인 109: stats(): array
라인 159: show(int $id): Item
라인 176: findByItemCode(string $itemCode): ?Item
라인 192: statsByItemType(): array
라인 241: increaseFromReceiving(Receiving $receiving): StockLot ← 참조 대상
라인 325: adjustFromReceiving(Receiving $receiving, float $newQty): void
라인 423: getOrCreateStock(int $itemId, ?Receiving $receiving = null): Stock ← 재사용
라인 474: getNextFifoOrder(int $stockId): int ← 재사용
라인 493: decreaseFIFO(int $itemId, float $qty, string $reason, int $referenceId): array
라인 618: decreaseFromLot(int $stockLotId, float $qty, string $reason, int $referenceId): array
라인 710: increaseToLot(int $stockLotId, float $qty, string $reason, int $referenceId): array
라인 796: getAvailableStock(int $itemId): ?array
라인 832: reserve(int $itemId, float $qty, int $orderId): void
라인 948: releaseReservation(int $itemId, float $qty, int $orderId): void
라인 1050: reserveForOrder($orderItems, int $orderId): void
라인 1071: releaseReservationForOrder($orderItems, int $orderId): void
라인 1099: decreaseForShipment(int $itemId, float $qty, int $shipmentId, ?int $stockLotId = null): array
라인 1232: [private] recordTransaction(...)
라인 1274: [private] logStockChange(...)
```
#### WorkOrderService 완료 처리 (`api/app/Services/WorkOrderService.php`)
```php
// 라인 563-568: completed 케이스 (saveItemResults 호출)
case WorkOrder::STATUS_COMPLETED:
$workOrder->started_at = $workOrder->started_at ?? now();
$workOrder->completed_at = now();
$this->saveItemResults($workOrder, $resultData, $userId);
break;
// 라인 591-593: 완료 후 출하 자동 생성 (← 여기에 분기 삽입)
if ($status === WorkOrder::STATUS_COMPLETED) {
$this->createShipmentFromWorkOrder($workOrder, $tenantId, $userId);
}
// 라인 606: 출하 생성 메서드
private function createShipmentFromWorkOrder(WorkOrder $workOrder, int $tenantId, int $userId): ?Shipment
// 라인 805: 결과 데이터 저장 (LOT 번호 생성 포함)
private function saveItemResults(WorkOrder $workOrder, ?array $resultData, int $userId): void
// 라인 845-866: LOT 번호 생성
private function generateLotNo(WorkOrder $workOrder): string
// 패턴: KD-SA-YYMMDD-NN (예: KD-SA-260221-01)
```
#### Stock 모델 refreshFromLots (`api/app/Models/Tenants/Stock.php`)
```php
// 라인 149-164
public function refreshFromLots(): void
{
$lots = $this->lots()->where('status', '!=', 'used')->get();
$this->lot_count = $lots->count();
$this->stock_qty = $lots->sum('qty');
$this->reserved_qty = $lots->sum('reserved_qty');
$this->available_qty = $lots->sum('available_qty');
$oldestLot = $lots->sortBy('receipt_date')->first();
$this->oldest_lot_date = $oldestLot?->receipt_date;
$this->last_receipt_date = $lots->max('receipt_date');
$this->status = $this->calculateStatus();
$this->save();
}
```
### 4.3 increaseFromReceiving() 실제 코드 (참조용)
신규 `increaseFromProduction()` 구현 시 아래 코드를 기반으로 작성:
```php
// api/app/Services/StockService.php 라인 241-314
public function increaseFromReceiving(Receiving $receiving): StockLot
{
if (! $receiving->item_id) {
throw new \Exception(__('error.stock.item_id_required'));
}
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
return DB::transaction(function () use ($receiving, $tenantId, $userId) {
$stock = $this->getOrCreateStock($receiving->item_id, $receiving);
$fifoOrder = $this->getNextFifoOrder($stock->id);
$stockLot = new StockLot;
$stockLot->tenant_id = $tenantId;
$stockLot->stock_id = $stock->id;
$stockLot->lot_no = $receiving->lot_no;
$stockLot->fifo_order = $fifoOrder;
$stockLot->receipt_date = $receiving->receiving_date;
$stockLot->qty = $receiving->receiving_qty;
$stockLot->reserved_qty = 0;
$stockLot->available_qty = $receiving->receiving_qty;
$stockLot->unit = $receiving->order_unit ?? 'EA';
$stockLot->supplier = $receiving->supplier; // ← 생산입고: null
$stockLot->supplier_lot = $receiving->supplier_lot; // ← 생산입고: null
$stockLot->po_number = $receiving->order_no; // ← 생산입고: null
$stockLot->location = $receiving->receiving_location;
$stockLot->status = 'available';
$stockLot->receiving_id = $receiving->id; // ← 생산입고: null, work_order_id 대신 사용
$stockLot->created_by = $userId;
$stockLot->updated_by = $userId;
$stockLot->save();
$stock->refreshFromLots();
$this->recordTransaction(
stock: $stock,
type: StockTransaction::TYPE_IN,
qty: $receiving->receiving_qty,
reason: StockTransaction::REASON_RECEIVING, // ← 생산입고: REASON_PRODUCTION_OUTPUT
referenceType: 'receiving', // ← 생산입고: 'work_order'
referenceId: $receiving->id, // ← 생산입고: $workOrder->id
lotNo: $receiving->lot_no,
stockLotId: $stockLot->id
);
$this->logStockChange(...);
return $stockLot;
});
}
```
### 4.4 increaseFromProduction() 구현 설계
```php
/**
* 생산 완료 시 완성품 재고 입고
* increaseFromReceiving()을 기반으로 구현
*
* @param WorkOrder $workOrder 선생산 작업지시
* @param WorkOrderItem $woItem 작업지시 품목
* @param float $goodQty 양품 수량 (saveItemResults에서 기록)
* @param string $lotNo LOT 번호 (generateLotNo에서 생성)
*/
public function increaseFromProduction(
WorkOrder $workOrder,
WorkOrderItem $woItem,
float $goodQty,
string $lotNo
): StockLot {
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
return DB::transaction(function () use ($workOrder, $woItem, $goodQty, $lotNo, $tenantId, $userId) {
// 1. Stock 조회 또는 생성
// getOrCreateStock()의 두 번째 파라미터(Receiving)는 null
// → specification, unit은 Item에서 가져옴
$stock = $this->getOrCreateStock($woItem->item_id);
// 2. FIFO 순서
$fifoOrder = $this->getNextFifoOrder($stock->id);
// 3. StockLot 생성
$stockLot = new StockLot;
$stockLot->tenant_id = $tenantId;
$stockLot->stock_id = $stock->id;
$stockLot->lot_no = $lotNo;
$stockLot->fifo_order = $fifoOrder;
$stockLot->receipt_date = now()->toDateString();
$stockLot->qty = $goodQty;
$stockLot->reserved_qty = 0;
$stockLot->available_qty = $goodQty;
$stockLot->unit = $woItem->unit ?? 'EA';
$stockLot->supplier = null; // 구매입고 전용 필드
$stockLot->supplier_lot = null;
$stockLot->po_number = null;
$stockLot->location = null;
$stockLot->status = 'available';
$stockLot->receiving_id = null; // 구매입고가 아님
$stockLot->work_order_id = $workOrder->id; // ★ 생산입고 참조
$stockLot->created_by = $userId;
$stockLot->updated_by = $userId;
$stockLot->save();
// 4. Stock 합계 갱신
$stock->refreshFromLots();
// 5. 거래 이력 기록
$this->recordTransaction(
stock: $stock,
type: StockTransaction::TYPE_IN,
qty: $goodQty,
reason: StockTransaction::REASON_PRODUCTION_OUTPUT,
referenceType: 'work_order',
referenceId: $workOrder->id,
lotNo: $lotNo,
stockLotId: $stockLot->id
);
// 6. 감사 로그
$this->logStockChange(
stock: $stock,
action: 'production_in',
details: [
'work_order_id' => $workOrder->id,
'work_order_item_id' => $woItem->id,
'qty' => $goodQty,
'lot_no' => $lotNo,
]
);
return $stockLot;
});
}
```
### 4.5 WorkOrderService 완료 분기 구현 설계
```php
// 라인 591-593 변경: updateStatus() 내부
if ($status === WorkOrder::STATUS_COMPLETED) {
if ($workOrder->sales_order_id) {
// 기존 로직: 수주 연동 → 출하 자동 생성
$this->createShipmentFromWorkOrder($workOrder, $tenantId, $userId);
} else {
// 신규 로직: 선생산 → 재고 입고
$this->stockInFromProduction($workOrder);
}
}
// 신규 private 메서드
private function stockInFromProduction(WorkOrder $workOrder): void
{
foreach ($workOrder->items as $woItem) {
if ($this->shouldStockIn($woItem)) {
$resultData = $woItem->options['result'] ?? [];
$goodQty = $resultData['good_qty'] ?? $woItem->quantity;
$lotNo = $resultData['lot_no'] ?? '';
if ($goodQty > 0 && $lotNo) {
$this->stockService->increaseFromProduction(
$workOrder, $woItem, $goodQty, $lotNo
);
}
}
}
}
private function shouldStockIn(WorkOrderItem $woItem): bool
{
$item = $woItem->item;
$options = $item->options ?? [];
return ($options['production_source'] ?? null) === 'self_produced'
&& ($options['lot_managed'] ?? false) === true;
}
```
### 4.6 데이터 매핑 (5130 → SAM)
#### 절곡품 마스터 매핑
| 5130 | SAM | 비고 |
|------|-----|------|
| guiderail.model_name | items.code (BD-가이드레일-*) | item_category=BENDING |
| guiderail.rail_width × rail_length | items.options.dimensions | JSON |
| guiderail.material_summary | items.options.material_summary | JSON |
| guiderail.finishing_type | items.options.finishing_type | JSON |
| shutterbox.box_width × box_height | items.code (BD-케이스-*) | 치수 코드화 |
| bottombar.bar_width × bar_height | items.code (BD-하단마감재-*) | 치수 코드화 |
#### 재고 매핑
| 5130 | SAM | 비고 |
|------|-----|------|
| lot.lot_number | stock_lots.lot_no | 1:1 |
| lot.surang | stock_lots.qty | 생산 수량 |
| lot.prod+spec+slength | items.code → stocks.item_id | 3코드→품목코드 변환 |
| lot.rawLot | stock_lots.options.raw_lot | JSON |
| lot.fabric_lot | stock_lots.options.fabric_lot | JSON |
| bending_work_log.quantity | stock_transactions.qty (TYPE_OUT) | 사용 이력 |
#### 3코드 → 품목코드 변환 규칙
| prod | spec | slength | SAM item_code |
|------|------|---------|---------------|
| R(벽면형) | S(SUS) | 53(W50x3000) | BD-가이드레일-벽면형-SUS-W50x3000 |
| R(벽면형) | E(EGI) | 84(W80x4000) | BD-가이드레일-벽면형-EGI-W80x4000 |
| C(케이스) | M(본체) | 30(3000) | BD-케이스-본체-3000 |
| B(하단마감재스크린) | A(스크린용) | 30(3000) | BD-하단마감재-스크린-3000 |
---
## 5. 컨펌 대기 목록
| # | 항목 | 변경 내용 | 영향 범위 | 상태 |
|---|------|----------|----------|------|
| C1 | StockLot에 work_order_id 컬럼 추가 | DB 마이그레이션 | stock_lots 테이블 | ⚠️ 컨펌 필요 |
| C2 | WorkOrderService 완료 로직 분기 | 비즈니스 로직 변경 | 생산 완료 프로세스 | ⚠️ 컨펌 필요 |
| C3 | Phase 3 수주→재고 자동 매칭 설계 | 신규 비즈니스 프로세스 | OrderService | ⚠️ Phase 3 착수 전 별도 협의 |
---
## 6. 변경 이력
| 날짜 | 항목 | 변경 내용 | 파일 | 승인 |
|------|------|----------|------|------|
| 2026-02-21 | - | 문서 초안 작성 | - | - |
| 2026-02-21 | 보완 | 용어설명, 파일경로 수정, 코드 레퍼런스 추가, DB 스키마 추가 | - | - |
| 2026-02-21 | Phase 1 구현 | 1.1~1.4 전체 완료 | StockTransaction, StockLot, StockService, WorkOrderService | ✅ |
---
## 7. 참고 문서
### 직접 관련 문서
- `docs/plans/bending-info-auto-generation-plan.md` - BendingInfoBuilder 자동 생성 계획
- `docs/plans/bending-worklog-reimplementation-plan.md` - 절곡 작업일지 프론트 재구현 (완료)
- `docs/projects/legacy-5130/04_PRODUCTION.md` - 레거시 생산 시스템 분석
### 핵심 코드 파일 (⚠️ 경로 주의: Models는 Tenants 네임스페이스)
**백엔드 서비스**:
- `api/app/Services/StockService.php` - 재고 서비스 (increaseFromReceiving 라인 241)
- `api/app/Services/WorkOrderService.php` - 작업지시 서비스 (updateStatus 라인 521, saveItemResults 라인 805)
- `api/app/Services/OrderService.php` - 수주 서비스 (createProductionOrder)
- `api/app/Services/Production/BendingInfoBuilder.php` - 절곡 정보 자동 생성
**백엔드 모델** (⚠️ `Models/Tenants/` 경로):
- `api/app/Models/Tenants/Stock.php` - 재고 모델 (refreshFromLots 라인 149)
- `api/app/Models/Tenants/StockLot.php` - 재고 LOT 모델 (fillable 라인 15-34)
- `api/app/Models/Tenants/StockTransaction.php` - 재고 거래 이력 모델 (상수 라인 25-57)
**DB 마이그레이션**:
- `api/database/migrations/2025_12_26_132806_create_stocks_table.php`
- `api/database/migrations/2025_12_26_132842_create_stock_lots_table.php`
- `api/database/migrations/2026_01_29_000001_create_stock_transactions_table.php`
### 프론트 코드 파일
- `react/src/components/production/WorkOrders/WorkOrderCreate.tsx` - 작업지시 생성 (RegistrationMode 라인 52, manual UI 라인 278-305)
- `react/src/components/material/StockStatus/StockStatusList.tsx` - 재고 현황 목록
- `react/src/components/material/StockStatus/` - 재고 현황 전체 디렉토리 (Detail, Audit, actions, types, config, mockData)
- `react/src/components/production/WorkOrders/documents/bending/` - 절곡 작업일지 컴포넌트
---
## 8. 세션 및 메모리 관리 정책 (Serena Optimized)
### 8.1 세션 시작 시 (Load Strategy)
```javascript
read_memory("bending-preproduction-state") // 1. 상태 파악
read_memory("bending-preproduction-snapshot") // 2. 사고 흐름 복구
read_memory("bending-preproduction-active-symbols") // 3. 작업 대상 파악
```
### 8.2 작업 중 관리 (Context Defense)
| 컨텍스트 잔량 | Action | 내용 |
|--------------|--------|------|
| **30% 이하** | Snapshot | `write_memory("bending-preproduction-snapshot", "코드변경+논의요약")` |
| **20% 이하** | Context Purge | `write_memory("bending-preproduction-active-symbols", "수정 파일/함수")` |
| **10% 이하** | Stop & Save | 최종 상태 저장 후 세션 교체 권고 |
### 8.3 Serena 메모리 구조
- `bending-preproduction-state`: { phase, progress, next_step, last_decision }
- `bending-preproduction-snapshot`: 현재까지의 논의 및 코드 변경점 요약
- `bending-preproduction-rules`: 불변 규칙 (Receiving 우회, options JSON 정책 등)
- `bending-preproduction-active-symbols`: 현재 수정 중인 파일/심볼 리스트
---
## 9. 검증 결과
> 작업 완료 후 이 섹션에 검증 결과 추가
### 9.1 Phase 1 테스트 케이스
| # | 시나리오 | 입력 | 예상 결과 | 실제 결과 | 상태 |
|---|---------|------|----------|----------|------|
| T1.1 | 선생산 WO 완료 시 재고 입고 | WO(sales_order_id=null) 완료 | Stock/StockLot 생성, qty 증가 | | ⏳ |
| T1.2 | 기존 수주 WO 완료 시 변경 없음 | WO(sales_order_id=43) 완료 | 기존대로 Shipment 생성 | | ⏳ |
| T1.3 | LOT 번호 자동 생성 | 선생산 WO 완료 | KD-SA-YYMMDD-NN 형식 LOT | | ⏳ |
| T1.4 | StockTransaction 기록 | 생산 입고 | TYPE_IN, reason=production_output | | ⏳ |
### 9.2 Phase 2 테스트 케이스
| # | 시나리오 | 입력 | 예상 결과 | 실제 결과 | 상태 |
|---|---------|------|----------|----------|------|
| T2.1 | 수주 없이 작업지시 생성 | manual 모드 + 절곡 품목 | WO 생성, sales_order_id=null | | ⏳ |
| T2.2 | 재고현황 절곡품 필터 | item_category=BENDING | 절곡품만 표시 | | ⏳ |
| T2.3 | FIFO 출고 | 재고 투입 | 가장 오래된 LOT부터 차감 | | ⏳ |
### 9.3 Phase 3 테스트 케이스
| # | 시나리오 | 입력 | 예상 결과 | 실제 결과 | 상태 |
|---|---------|------|----------|----------|------|
| T3.1 | 수주 확정 시 재고 확인 | 재고 10, 필요 15 | 부족 5 표시 | | ⏳ |
| T3.2 | 가용 재고 자동 예약 | 재고 10, 필요 5 | reserved_qty=5, available_qty=5 | | ⏳ |
| T3.3 | 부족분 생산지시 | 재고 10, 필요 15 | 5개 생산지시 자동 생성 | | ⏳ |
### 9.4 성공 기준
| 기준 | 달성 | 비고 |
|------|------|------|
| 선생산 WO → 재고 입고 정상 동작 | ⏳ | Phase 1 핵심 |
| 기존 수주 WO 흐름 변경 없음 | ⏳ | 회귀 테스트 |
| 절곡품 재고현황 필터링 가능 | ⏳ | Phase 2 |
| 수주 시 재고 자동 매칭 | ⏳ | Phase 3 |
| 5130 데이터 마이그레이션 완료 | ⏳ | Phase 3 |
---
## 10. 자기완결성 점검 결과
### 10.1 체크리스트 검증
| # | 검증 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 1 | 작업 목적이 명확한가? | ✅ | 0.2 선생산 운영 방식 + 1.1 배경 |
| 2 | 성공 기준이 정의되어 있는가? | ✅ | 9.4 성공 기준 참조 |
| 3 | 작업 범위가 구체적인가? | ✅ | Phase 1~3, 14개 작업 항목 |
| 4 | 의존성이 명시되어 있는가? | ✅ | 기존 bending 계획 문서 참조 |
| 5 | 참고 파일 경로가 정확한가? | ✅ | 검증 완료 (Models/Tenants/, material/StockStatus/) |
| 6 | 단계별 절차가 실행 가능한가? | ✅ | 라인번호 + 실제 코드 바디 포함 |
| 7 | 검증 방법이 명시되어 있는가? | ✅ | 섹션 9 테스트 케이스 참조 |
| 8 | 모호한 표현이 없는가? | ✅ | 코드 수준 상세 기술 + 용어 설명 포함 |
### 10.2 새 세션 시뮬레이션 테스트
| 질문 | 답변 가능 | 참조 섹션 |
|------|:--------:|----------|
| Q1. 절곡품이 뭔가? 왜 선생산하는가? | ✅ | 0.1, 0.2 용어 및 비즈니스 배경 |
| Q2. 이 작업의 목적은 무엇인가? | ✅ | 1.1 배경 |
| Q3. 어디서부터 시작해야 하는가? | ✅ | 2.1 Phase 1 + 3.1 절차 |
| Q4. 어떤 파일을 수정해야 하는가? | ✅ | 2.1~2.3 영향 파일 (정확한 경로) |
| Q5. 기존 코드 구조가 어떻게 되어 있는가? | ✅ | 4.1~4.3 DB 스키마 + 코드 레퍼런스 |
| Q6. 신규 메서드를 어떻게 구현해야 하는가? | ✅ | 4.4~4.5 구현 설계 (전체 코드) |
| Q7. 작업 완료 확인 방법은? | ✅ | 9. 검증 결과 |
| Q8. 막혔을 때 참고 문서는? | ✅ | 7. 참고 문서 |
---
*이 문서는 /plan 스킬로 생성되었습니다.*

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff