From 905344681f2b474517bc33b55d37fae69e566e16 Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Wed, 23 Jul 2025 10:41:50 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9E=90=EC=9E=AC=EB=B3=84=20=EA=B5=AC?= =?UTF-8?q?=EB=A7=A4=20=EC=88=98=EB=9F=89=20=EA=B3=84=EC=82=B0=20=EC=8B=9C?= =?UTF-8?q?=EC=8A=A4=ED=85=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 파이프: 6,000mm 단위 + 절단여유분 2mm/조각 계산 - 볼트/너트: +5% 후 4의 배수로 올림 - 가스켓: 5의 배수로 올림 - 피팅/계기/밸브: BOM 수량 그대로 - MaterialsPage에 '필요 수량' 칼럼 추가 - 엑셀 내보내기에 구매 수량 정보 포함 - 리비전 비교시 구매 수량 변화량도 계산 --- RULES.md | 32 ++++- frontend/src/pages/MaterialsPage.jsx | 27 ++++ frontend/src/utils/excelExport.js | 26 ++++ frontend/src/utils/purchaseCalculator.js | 151 +++++++++++++++++++++++ 4 files changed, 235 insertions(+), 1 deletion(-) create mode 100644 frontend/src/utils/purchaseCalculator.js 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