Compare commits
4 Commits
2d178f8161
...
0d31d8b3fc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d31d8b3fc | ||
|
|
ffa6e58a7c | ||
|
|
905344681f | ||
|
|
5fa0ac4202 |
189
RULES.md
Normal file
189
RULES.md
Normal file
@@ -0,0 +1,189 @@
|
||||
# 🏗️ TK-MP-Project Rules & Context
|
||||
|
||||
## 📋 **프로젝트 개요**
|
||||
- **목적**: 배관 자재 BOM 관리 및 리비전 비교 시스템
|
||||
- **주요 기능**: 파일 업로드, 자재 분류, 리비전 비교, 구매 관리, 엑셀 내보내기
|
||||
|
||||
## 🛠️ **기술 스택**
|
||||
```
|
||||
Frontend: React.js + Material-UI + Vite + React Router DOM
|
||||
Backend: FastAPI + SQLAlchemy + Python
|
||||
Database: SQLite (개발) / PostgreSQL (운영)
|
||||
기타: Axios, XLSX (SheetJS), file-saver
|
||||
```
|
||||
|
||||
## 📁 **프로젝트 구조**
|
||||
```
|
||||
TK-MP-Project/
|
||||
├── frontend/src/
|
||||
│ ├── pages/ # 페이지 컴포넌트
|
||||
│ ├── components/ # 재사용 컴포넌트
|
||||
│ ├── utils/ # 유틸리티 (엑셀 등)
|
||||
│ └── api.js # API 통신
|
||||
├── backend/app/
|
||||
│ ├── routers/ # API 라우터
|
||||
│ ├── services/ # 비즈니스 로직 (분류기 등)
|
||||
│ ├── models.py # DB 모델
|
||||
│ └── main.py # FastAPI 앱
|
||||
└── database/ # DB 스키마/시드
|
||||
```
|
||||
|
||||
## 🗄️ **핵심 데이터베이스 스키마**
|
||||
```sql
|
||||
-- 핵심 테이블들
|
||||
jobs (job_no, job_name, client_name, ...)
|
||||
files (id, job_no, revision, original_filename, ...)
|
||||
materials (id, file_id, original_description, classified_category, quantity, ...)
|
||||
pipe_details (material_id, length_mm, ...)
|
||||
-- 기타: fitting_details, flange_details, bolt_details, gasket_details
|
||||
```
|
||||
|
||||
## 🔧 **중요한 코딩 컨벤션 & 패턴**
|
||||
|
||||
### **1. 자재 분류 시스템**
|
||||
```python
|
||||
# 항상 이 순서로 분류기 호출
|
||||
classification_result = classify_pipe("", description, main_nom, length_value)
|
||||
# 결과: {"category": "PIPE", "confidence": 0.95, ...}
|
||||
```
|
||||
|
||||
### **2. 파이프 길이 처리 규칙**
|
||||
```javascript
|
||||
// ❌ 절대 하지 말 것: 평균 길이 계산/표시
|
||||
// ✅ 항상 할 것: 총 길이 기준 계산
|
||||
const totalLength = quantity * unitLength; // 총 길이 = 수량 × 단위길이
|
||||
```
|
||||
|
||||
### **3. 자재 해싱 규칙**
|
||||
```python
|
||||
# 자재 고유성 판단: description + size + material_grade
|
||||
material_hash = hashlib.md5(f"{description}|{size_spec}|{material_grade}".encode()).hexdigest()
|
||||
```
|
||||
|
||||
### **4. 리비전 비교 로직**
|
||||
```python
|
||||
# 이전 리비전 자동 탐지: 숫자 기반 비교
|
||||
current_rev_num = int(current_revision.replace("Rev.", ""))
|
||||
# Rev.0 → Rev.1 → Rev.2 순서
|
||||
```
|
||||
|
||||
## 🐛 **자주 발생하는 이슈 & 해결법**
|
||||
|
||||
### **1. 파이프 길이 합산 문제**
|
||||
```python
|
||||
# ❌ 잘못된 SQL: GROUP BY에 pd.length_mm 포함
|
||||
# ✅ 올바른 방법: Python에서 같은 파이프들 합치기
|
||||
if material_hash in materials_dict:
|
||||
existing["quantity"] += float(new_quantity)
|
||||
existing["total_length"] += new_quantity * unit_length
|
||||
```
|
||||
|
||||
### **2. 프론트엔드 변수 초기화**
|
||||
```javascript
|
||||
// ❌ 사용 전에 선언하지 않음
|
||||
const summaryData = [..., consolidatedMaterials.length, ...];
|
||||
const consolidatedMaterials = consolidateMaterials(materials); // 뒤에 선언
|
||||
|
||||
// ✅ 사용 전에 먼저 선언
|
||||
const consolidatedMaterials = consolidateMaterials(materials);
|
||||
const summaryData = [..., consolidatedMaterials.length, ...];
|
||||
```
|
||||
|
||||
### **3. API 응답 처리**
|
||||
```javascript
|
||||
// ✅ 항상 Axios 응답 구조 확인
|
||||
setComparisonResult(result.data || result); // response.data 우선
|
||||
```
|
||||
|
||||
## 🎯 **UI/UX 가이드라인**
|
||||
|
||||
### **1. 자재 표시 규칙**
|
||||
- **파이프**: "총 길이: 4,561mm" (평균단위 표시 금지)
|
||||
- **기타 자재**: "수량: 24 EA"
|
||||
- **변경사항**: "이전: 2,781mm → 현재: 4,561mm / 변화: +1,780mm"
|
||||
|
||||
### **2. 버튼 네이밍**
|
||||
- "BOM 목록으로" (뒤로가기)
|
||||
- "엑셀 내보내기"
|
||||
- "상세 비교 보기"
|
||||
|
||||
### **3. 페이지 네비게이션**
|
||||
```javascript
|
||||
// BOM 관련 페이지들은 job_no 기준으로 이동
|
||||
navigate(`/bom-status?job_no=${jobNo}`);
|
||||
navigate(`/material-comparison?job_no=${jobNo}&revision=${revision}`);
|
||||
```
|
||||
|
||||
## 🔄 **개발 워크플로우**
|
||||
|
||||
### **1. 백엔드 변경 시**
|
||||
```bash
|
||||
# 항상 가상환경에서 실행 (사용자 선호사항)
|
||||
cd backend
|
||||
python -m uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
### **2. 데이터베이스 스키마 변경 시**
|
||||
```sql
|
||||
-- scripts/ 폴더에 마이그레이션 SQL 파일 생성
|
||||
-- 번호 순서: 01_, 02_, 03_...
|
||||
```
|
||||
|
||||
### **3. 커밋 메시지**
|
||||
```
|
||||
한국어로 작성 (사용자 선호사항)
|
||||
예: "파이프 길이 계산 및 엑셀 내보내기 버그 수정"
|
||||
```
|
||||
|
||||
## ⚠️ **절대 하지 말아야 할 것들**
|
||||
|
||||
1. **파이프 "평균단위" 표시** - 사용자가 혼란스러워함
|
||||
2. **하드코딩된 길이 값** - 실제 데이터베이스 값 사용
|
||||
3. **영어 커밋 메시지** - 사용자가 한국어 선호
|
||||
4. **SQL에서 과도한 GROUP BY** - 같은 자재 분리됨
|
||||
5. **비율 기반 길이 계산** - 실제 총길이 사용해야 함
|
||||
|
||||
## 💰 **구매 수량 계산 규칙**
|
||||
|
||||
### **1. 파이프 (PIPE)**
|
||||
```javascript
|
||||
// 6,000mm 단위 판매 + 절단여유분 2mm/조각
|
||||
const cutLength = originalLength + 2; // 절단 여유분
|
||||
const pipeCount = Math.ceil(cutLength / 6000); // 올림 처리
|
||||
```
|
||||
|
||||
### **2. 피팅/계기/밸브 (FITTING/INSTRUMENT/VALVE)**
|
||||
```javascript
|
||||
// BOM 수량 그대로
|
||||
const purchaseQuantity = bomQuantity;
|
||||
```
|
||||
|
||||
### **3. 볼트/너트 (BOLT)**
|
||||
```javascript
|
||||
// +5% 후 4의 배수로 올림
|
||||
const withMargin = bomQuantity * 1.05;
|
||||
const purchaseQuantity = Math.ceil(withMargin / 4) * 4;
|
||||
// 예: 150 → 157.5 → 160 SETS
|
||||
```
|
||||
|
||||
### **4. 가스켓 (GASKET)**
|
||||
```javascript
|
||||
// 5의 배수로 올림
|
||||
const purchaseQuantity = Math.ceil(bomQuantity / 5) * 5;
|
||||
// 예: 7 → 10 EA
|
||||
```
|
||||
|
||||
## 🎯 **현재 진행 상황**
|
||||
- ✅ 자재 업로드 및 분류 시스템
|
||||
- ✅ 리비전 비교 기능
|
||||
- ✅ 파이프 길이 합산 로직 수정
|
||||
- ✅ 엑셀 내보내기 기능
|
||||
- 🚧 구매 수량 계산 시스템 (진행 중)
|
||||
|
||||
## 📚 **추가 참고사항**
|
||||
- 사용자는 가상환경에서 Python 실행을 선호
|
||||
- 백엔드 서버는 자동 재시작되므로 수동 재시작 불필요
|
||||
- 작업 상태는 'in-progress'와 'complete'를 명확히 표시
|
||||
|
||||
---
|
||||
**마지막 업데이트**: 2024-12-20
|
||||
@@ -28,6 +28,7 @@ import ShoppingCart from '@mui/icons-material/ShoppingCart';
|
||||
import { Compare as CompareIcon, Download } from '@mui/icons-material';
|
||||
import { api, fetchFiles } from '../api';
|
||||
import { exportMaterialsToExcel } from '../utils/excelExport';
|
||||
import { calculatePurchaseQuantity } from '../utils/purchaseCalculator';
|
||||
|
||||
const MaterialsPage = () => {
|
||||
const [materials, setMaterials] = useState([]);
|
||||
@@ -871,6 +872,7 @@ const MaterialsPage = () => {
|
||||
</>
|
||||
)}
|
||||
<TableCell align="center"><strong>개수</strong></TableCell>
|
||||
<TableCell align="center"><strong>필요 수량</strong></TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
@@ -1064,6 +1066,31 @@ const MaterialsPage = () => {
|
||||
color="default"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{(() => {
|
||||
// 구매 수량 계산
|
||||
const purchaseInfo = calculatePurchaseQuantity({
|
||||
classified_category: category,
|
||||
quantity: spec.totalQuantity,
|
||||
pipe_details: spec.pipe_details || (category === 'PIPE' ? {
|
||||
length_mm: spec.averageLength || 0,
|
||||
total_length_mm: spec.totalLength || 0
|
||||
} : null),
|
||||
unit: spec.unit
|
||||
});
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="body2" fontWeight="bold" color="primary">
|
||||
{purchaseInfo.purchaseQuantity} {purchaseInfo.unit}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>
|
||||
{purchaseInfo.calculation}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
})()}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as XLSX from 'xlsx';
|
||||
import { saveAs } from 'file-saver';
|
||||
import { calculatePurchaseQuantity } from './purchaseCalculator';
|
||||
|
||||
/**
|
||||
* 자재 목록을 카테고리별로 그룹화
|
||||
@@ -98,45 +99,33 @@ const formatMaterialForExcel = (material, includeComparison = false) => {
|
||||
const category = material.classified_category || material.category || '-';
|
||||
const isPipe = category === 'PIPE';
|
||||
|
||||
// 구매 수량 계산
|
||||
const purchaseInfo = calculatePurchaseQuantity(material);
|
||||
|
||||
const base = {
|
||||
'카테고리': category,
|
||||
'자재 설명': material.original_description || material.description || '-',
|
||||
'사이즈': material.size_spec || '-',
|
||||
'라인 번호': material.line_number || '-'
|
||||
'사이즈': material.size_spec || '-'
|
||||
};
|
||||
|
||||
// 파이프인 경우 길이(m) 표시, 그 외는 수량
|
||||
if (isPipe) {
|
||||
// consolidateMaterials에서 이미 계산된 totalLength 사용
|
||||
const totalLength = material.totalLength || 0;
|
||||
const itemCount = material.itemCount || material.quantity || 0;
|
||||
|
||||
base['길이(m)'] = totalLength > 0 ? totalLength.toFixed(2) : 0;
|
||||
base['개수'] = itemCount;
|
||||
base['단위'] = 'M';
|
||||
} else {
|
||||
base['수량'] = material.quantity || 0;
|
||||
base['단위'] = material.unit || 'EA';
|
||||
}
|
||||
// 구매 수량 정보만 추가 (기존 수량/단위 정보 제거)
|
||||
base['필요 수량'] = purchaseInfo.purchaseQuantity || 0;
|
||||
base['구매 단위'] = purchaseInfo.unit || 'EA';
|
||||
|
||||
// 비교 모드인 경우 추가 정보
|
||||
// 비교 모드인 경우 구매 수량 변화 정보만 추가
|
||||
if (includeComparison) {
|
||||
if (material.previous_quantity !== undefined) {
|
||||
if (isPipe) {
|
||||
const prevTotalLength = material.previousTotalLength || 0;
|
||||
const currTotalLength = material.totalLength || 0;
|
||||
|
||||
base['이전 길이(m)'] = prevTotalLength > 0 ? prevTotalLength.toFixed(2) : 0;
|
||||
base['현재 길이(m)'] = currTotalLength > 0 ? currTotalLength.toFixed(2) : 0;
|
||||
base['길이 변경(m)'] = ((currTotalLength - prevTotalLength)).toFixed(2);
|
||||
base['이전 개수'] = material.previous_quantity;
|
||||
base['현재 개수'] = material.current_quantity;
|
||||
} else {
|
||||
base['이전 수량'] = material.previous_quantity;
|
||||
base['현재 수량'] = material.current_quantity;
|
||||
base['변경량'] = material.quantity_change;
|
||||
}
|
||||
// 이전 구매 수량 계산
|
||||
const prevPurchaseInfo = calculatePurchaseQuantity({
|
||||
...material,
|
||||
quantity: material.previous_quantity,
|
||||
totalLength: material.previousTotalLength || 0
|
||||
});
|
||||
|
||||
base['이전 필요 수량'] = prevPurchaseInfo.purchaseQuantity || 0;
|
||||
base['필요 수량 변경'] = (purchaseInfo.purchaseQuantity - prevPurchaseInfo.purchaseQuantity);
|
||||
}
|
||||
|
||||
base['변경 유형'] = material.change_type || (
|
||||
material.previous_quantity !== undefined ? '수량 변경' :
|
||||
material.quantity_change === undefined ? '신규' : '변경'
|
||||
|
||||
151
frontend/src/utils/purchaseCalculator.js
Normal file
151
frontend/src/utils/purchaseCalculator.js
Normal file
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* 자재 카테고리별 구매 수량 계산 유틸리티
|
||||
*/
|
||||
|
||||
/**
|
||||
* 파이프 구매 수량 계산
|
||||
* @param {number} lengthMm - 파이프 총 길이 (mm)
|
||||
* @param {number} quantity - BOM 수량 (개수)
|
||||
* @returns {object} 구매 계산 결과
|
||||
*/
|
||||
export const calculatePipePurchase = (lengthMm, quantity) => {
|
||||
if (!lengthMm || lengthMm <= 0 || !quantity || quantity <= 0) {
|
||||
return {
|
||||
purchaseQuantity: 0,
|
||||
standardLength: 6000,
|
||||
cutLength: 0,
|
||||
calculation: '길이 정보 없음'
|
||||
};
|
||||
}
|
||||
|
||||
// 절단 여유분: 조각마다 2mm 추가
|
||||
const cutLength = lengthMm + (quantity * 2);
|
||||
|
||||
// 6,000mm 단위로 올림 계산
|
||||
const pipeCount = Math.ceil(cutLength / 6000);
|
||||
|
||||
return {
|
||||
purchaseQuantity: pipeCount,
|
||||
standardLength: 6000,
|
||||
cutLength: cutLength,
|
||||
calculation: `${lengthMm}mm + ${quantity * 2}mm(여유분) = ${cutLength}mm → ${pipeCount}본`
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 볼트/너트 구매 수량 계산
|
||||
* @param {number} bomQuantity - BOM 수량
|
||||
* @returns {object} 구매 계산 결과
|
||||
*/
|
||||
export const calculateBoltPurchase = (bomQuantity) => {
|
||||
if (!bomQuantity || bomQuantity <= 0) {
|
||||
return {
|
||||
purchaseQuantity: 0,
|
||||
calculation: '수량 정보 없음'
|
||||
};
|
||||
}
|
||||
|
||||
// +5% 여유분 후 4의 배수로 올림
|
||||
const withMargin = bomQuantity * 1.05;
|
||||
const purchaseQuantity = Math.ceil(withMargin / 4) * 4;
|
||||
|
||||
return {
|
||||
purchaseQuantity: purchaseQuantity,
|
||||
marginQuantity: Math.round(withMargin * 10) / 10, // 소수점 1자리
|
||||
calculation: `${bomQuantity} × 1.05 = ${Math.round(withMargin * 10) / 10} → ${purchaseQuantity} SETS`
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 가스켓 구매 수량 계산
|
||||
* @param {number} bomQuantity - BOM 수량
|
||||
* @returns {object} 구매 계산 결과
|
||||
*/
|
||||
export const calculateGasketPurchase = (bomQuantity) => {
|
||||
if (!bomQuantity || bomQuantity <= 0) {
|
||||
return {
|
||||
purchaseQuantity: 0,
|
||||
calculation: '수량 정보 없음'
|
||||
};
|
||||
}
|
||||
|
||||
// 5의 배수로 올림
|
||||
const purchaseQuantity = Math.ceil(bomQuantity / 5) * 5;
|
||||
|
||||
return {
|
||||
purchaseQuantity: purchaseQuantity,
|
||||
calculation: `${bomQuantity} → ${purchaseQuantity} EA (5의 배수)`
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 피팅/계기/밸브 구매 수량 계산
|
||||
* @param {number} bomQuantity - BOM 수량
|
||||
* @returns {object} 구매 계산 결과
|
||||
*/
|
||||
export const calculateStandardPurchase = (bomQuantity) => {
|
||||
return {
|
||||
purchaseQuantity: bomQuantity || 0,
|
||||
calculation: `${bomQuantity || 0} EA (BOM 수량 그대로)`
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 자재 카테고리별 구매 수량 계산 (통합 함수)
|
||||
* @param {object} material - 자재 정보
|
||||
* @returns {object} 구매 계산 결과
|
||||
*/
|
||||
export const calculatePurchaseQuantity = (material) => {
|
||||
const category = material.classified_category || material.category || '';
|
||||
const bomQuantity = material.quantity || 0;
|
||||
|
||||
switch (category.toUpperCase()) {
|
||||
case 'PIPE':
|
||||
// 파이프의 경우 길이 정보 필요
|
||||
const lengthMm = material.pipe_details?.length_mm || 0;
|
||||
const totalLength = material.pipe_details?.total_length_mm || (lengthMm * bomQuantity);
|
||||
return {
|
||||
...calculatePipePurchase(totalLength, bomQuantity),
|
||||
category: 'PIPE',
|
||||
unit: '본'
|
||||
};
|
||||
|
||||
case 'BOLT':
|
||||
case 'NUT':
|
||||
return {
|
||||
...calculateBoltPurchase(bomQuantity),
|
||||
category: 'BOLT',
|
||||
unit: 'SETS'
|
||||
};
|
||||
|
||||
case 'GASKET':
|
||||
return {
|
||||
...calculateGasketPurchase(bomQuantity),
|
||||
category: 'GASKET',
|
||||
unit: 'EA'
|
||||
};
|
||||
|
||||
case 'FITTING':
|
||||
case 'INSTRUMENT':
|
||||
case 'VALVE':
|
||||
case 'FLANGE':
|
||||
default:
|
||||
return {
|
||||
...calculateStandardPurchase(bomQuantity),
|
||||
category: category || 'STANDARD',
|
||||
unit: material.unit || 'EA'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 자재 목록에 대한 구매 수량 계산 (일괄 처리)
|
||||
* @param {Array} materials - 자재 목록
|
||||
* @returns {Array} 구매 계산 결과가 포함된 자재 목록
|
||||
*/
|
||||
export const calculateBulkPurchase = (materials) => {
|
||||
return materials.map(material => ({
|
||||
...material,
|
||||
purchaseInfo: calculatePurchaseQuantity(material)
|
||||
}));
|
||||
};
|
||||
Reference in New Issue
Block a user