diff --git a/RULES.md b/RULES.md
index 68015c0..3630c07 100644
--- a/RULES.md
+++ b/RULES.md
@@ -143,12 +143,42 @@ python -m uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
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 실행을 선호
diff --git a/frontend/src/pages/MaterialsPage.jsx b/frontend/src/pages/MaterialsPage.jsx
index 7155cd6..685c813 100644
--- a/frontend/src/pages/MaterialsPage.jsx
+++ b/frontend/src/pages/MaterialsPage.jsx
@@ -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 = () => {
>
)}
개수
+ 필요 수량
@@ -1064,6 +1066,31 @@ const MaterialsPage = () => {
color="default"
/>
+
+ {(() => {
+ // 구매 수량 계산
+ 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 (
+
+
+ {purchaseInfo.purchaseQuantity} {purchaseInfo.unit}
+
+
+ {purchaseInfo.calculation}
+
+
+ );
+ })()}
+
))}
diff --git a/frontend/src/utils/excelExport.js b/frontend/src/utils/excelExport.js
index bf5b224..583b0d9 100644
--- a/frontend/src/utils/excelExport.js
+++ b/frontend/src/utils/excelExport.js
@@ -1,5 +1,6 @@
import * as XLSX from 'xlsx';
import { saveAs } from 'file-saver';
+import { calculatePurchaseQuantity } from './purchaseCalculator';
/**
* 자재 목록을 카테고리별로 그룹화
@@ -98,6 +99,9 @@ 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 || '-',
@@ -119,6 +123,11 @@ const formatMaterialForExcel = (material, includeComparison = false) => {
base['단위'] = material.unit || 'EA';
}
+ // 구매 수량 정보 추가
+ base['필요 수량'] = purchaseInfo.purchaseQuantity || 0;
+ base['구매 단위'] = purchaseInfo.unit || 'EA';
+ base['계산 과정'] = purchaseInfo.calculation || '-';
+
// 비교 모드인 경우 추가 정보
if (includeComparison) {
if (material.previous_quantity !== undefined) {
@@ -131,10 +140,27 @@ const formatMaterialForExcel = (material, includeComparison = false) => {
base['길이 변경(m)'] = ((currTotalLength - prevTotalLength)).toFixed(2);
base['이전 개수'] = material.previous_quantity;
base['현재 개수'] = material.current_quantity;
+
+ // 이전/현재 구매 수량 계산
+ const prevPurchaseInfo = calculatePurchaseQuantity({
+ ...material,
+ quantity: material.previous_quantity,
+ totalLength: prevTotalLength
+ });
+ base['이전 필요 수량'] = prevPurchaseInfo.purchaseQuantity || 0;
+ base['필요 수량 변경'] = (purchaseInfo.purchaseQuantity - prevPurchaseInfo.purchaseQuantity);
} else {
base['이전 수량'] = material.previous_quantity;
base['현재 수량'] = material.current_quantity;
base['변경량'] = material.quantity_change;
+
+ // 이전/현재 구매 수량 계산
+ const prevPurchaseInfo = calculatePurchaseQuantity({
+ ...material,
+ quantity: material.previous_quantity
+ });
+ base['이전 필요 수량'] = prevPurchaseInfo.purchaseQuantity || 0;
+ base['필요 수량 변경'] = (purchaseInfo.purchaseQuantity - prevPurchaseInfo.purchaseQuantity);
}
}
base['변경 유형'] = material.change_type || (
diff --git a/frontend/src/utils/purchaseCalculator.js b/frontend/src/utils/purchaseCalculator.js
new file mode 100644
index 0000000..616879d
--- /dev/null
+++ b/frontend/src/utils/purchaseCalculator.js
@@ -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)
+ }));
+};
\ No newline at end of file