From 28431ee490ac749f3da73cb9a0ae34a7f73758d3 Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Fri, 18 Jul 2025 13:18:13 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B5=AC=EB=A7=A4=20=EC=88=98=EB=9F=89?= =?UTF-8?q?=20=EA=B3=84=EC=82=B0=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ๐Ÿงฎ ๊ตฌ๋งค ์ˆ˜๋Ÿ‰ ๊ณ„์‚ฐ ๋กœ์ง: - PIPE: ์ ˆ๋‹จ ์†์‹ค(3mm/์ ˆ๋‹จ) + 6M ๋‹จ์œ„ ์˜ฌ๋ฆผ ๊ณ„์‚ฐ - ์ผ๋ฐ˜ ์ž์žฌ: ์—ฌ์œ ์œจ + ์ตœ์†Œ ์ฃผ๋ฌธ ์ˆ˜๋Ÿ‰ ์ ์šฉ - ์ž์žฌ๋ณ„ ์ฐจ๋ณ„ํ™”๋œ ์—ฌ์œ ์œจ (VALVE 50%, BOLT 20% ๋“ฑ) ๐Ÿ›’ ๊ตฌ๋งค ๊ด€๋ฆฌ API: - /purchase/items/calculate: ์‹ค์‹œ๊ฐ„ ๊ตฌ๋งค ์ˆ˜๋Ÿ‰ ๊ณ„์‚ฐ - /purchase/items/save: ๊ตฌ๋งค ํ’ˆ๋ชฉ DB ์ €์žฅ - /purchase/revision-diff: ๋ฆฌ๋น„์ „๊ฐ„ ์ฐจ์ด ๊ณ„์‚ฐ - /purchase/orders/create: ๊ตฌ๋งค ์ฃผ๋ฌธ ์ƒ์„ฑ ๐Ÿงช ํ…Œ์ŠคํŠธ ๊ฒ€์ฆ: - PIPE ์ ˆ๋‹จ ์†์‹ค ๊ณ„์‚ฐ: 25,000mm โ†’ 5๋ณธ (์ •ํ™•) - ์—ฌ์œ ์œจ ์ ์šฉ: VALVE 2๊ฐœ โ†’ 3๊ฐœ (50% ์˜ˆ๋น„) - ์ตœ์†Œ ์ฃผ๋ฌธ: BOLT 24๊ฐœ โ†’ 50๊ฐœ (๋ฐ•์Šค ๋‹จ์œ„) ๐Ÿ“ฑ ํ”„๋ก ํŠธ์—”๋“œ: - PurchaseConfirmationPage ๋ผ์šฐํŒ… ์ถ”๊ฐ€ - ๊ตฌ๋งคํ™•์ • ๋ฒ„ํŠผ โ†’ ๊ตฌ๋งค ํŽ˜์ด์ง€ ์ด๋™ --- backend/app/main.py | 6 + backend/app/routers/purchase.py | 428 ++++++++++++++++ backend/app/services/purchase_calculator.py | 526 ++++++++++++++++++++ frontend/src/App.jsx | 15 +- test_purchase_calculator.py | 110 ++++ 5 files changed, 1079 insertions(+), 6 deletions(-) create mode 100644 backend/app/routers/purchase.py create mode 100644 backend/app/services/purchase_calculator.py create mode 100644 test_purchase_calculator.py diff --git a/backend/app/main.py b/backend/app/main.py index 661f54f..72ffbb6 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -37,6 +37,12 @@ try: except ImportError: print("jobs ๋ผ์šฐํ„ฐ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค") +try: + from .routers import purchase + app.include_router(purchase.router, tags=["purchase"]) +except ImportError: + print("purchase ๋ผ์šฐํ„ฐ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค") + # ํŒŒ์ผ ๋ชฉ๋ก ์กฐํšŒ API @app.get("/files") async def get_files( diff --git a/backend/app/routers/purchase.py b/backend/app/routers/purchase.py new file mode 100644 index 0000000..ef6b5fc --- /dev/null +++ b/backend/app/routers/purchase.py @@ -0,0 +1,428 @@ +""" +๊ตฌ๋งค ๊ด€๋ฆฌ API +- ๊ตฌ๋งค ํ’ˆ๋ชฉ ์ƒ์„ฑ/์กฐํšŒ +- ๊ตฌ๋งค ์ˆ˜๋Ÿ‰ ๊ณ„์‚ฐ +- ๋ฆฌ๋น„์ „ ๋น„๊ต +""" + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from sqlalchemy import text +from typing import List, Optional +import json + +from ..database import get_db +from ..services.purchase_calculator import ( + generate_purchase_items_from_materials, + save_purchase_items_to_db, + calculate_pipe_purchase_quantity, + calculate_standard_purchase_quantity +) + +router = APIRouter(prefix="/purchase", tags=["purchase"]) + +@router.get("/items/calculate") +async def calculate_purchase_items( + job_no: str = Query(..., description="Job ๋ฒˆํ˜ธ"), + revision: str = Query("Rev.0", description="๋ฆฌ๋น„์ „"), + file_id: Optional[int] = Query(None, description="ํŒŒ์ผ ID (์„ ํƒ์‚ฌํ•ญ)"), + db: Session = Depends(get_db) +): + """ + ๊ตฌ๋งค ํ’ˆ๋ชฉ ๊ณ„์‚ฐ (์‹ค์‹œ๊ฐ„) + - ์ž์žฌ ๋ฐ์ดํ„ฐ๋กœ๋ถ€ํ„ฐ ๊ตฌ๋งค ํ’ˆ๋ชฉ ์ƒ์„ฑ + - ์ˆ˜๋Ÿ‰ ๊ณ„์‚ฐ (ํŒŒ์ดํ”„ ์ ˆ๋‹จ์†์‹ค ํฌํ•จ) + """ + try: + # 1. ํŒŒ์ผ ID ์กฐํšŒ (job_no, revision์œผ๋กœ) + if not file_id: + file_query = text(""" + SELECT id FROM files + WHERE job_no = :job_no AND revision = :revision AND is_active = TRUE + ORDER BY created_at DESC + LIMIT 1 + """) + file_result = db.execute(file_query, {"job_no": job_no, "revision": revision}).fetchone() + if not file_result: + raise HTTPException(status_code=404, detail=f"Job {job_no} {revision}์— ํ•ด๋‹นํ•˜๋Š” ํŒŒ์ผ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค") + file_id = file_result[0] + + # 2. ๊ตฌ๋งค ํ’ˆ๋ชฉ ์ƒ์„ฑ + purchase_items = generate_purchase_items_from_materials(db, file_id, job_no, revision) + + return { + "success": True, + "job_no": job_no, + "revision": revision, + "file_id": file_id, + "items": purchase_items, + "total_items": len(purchase_items) + } + + except Exception as e: + raise HTTPException(status_code=500, detail=f"๊ตฌ๋งค ํ’ˆ๋ชฉ ๊ณ„์‚ฐ ์‹คํŒจ: {str(e)}") + +@router.post("/items/save") +async def save_purchase_items( + job_no: str, + revision: str, + file_id: int, + db: Session = Depends(get_db) +): + """ + ๊ตฌ๋งค ํ’ˆ๋ชฉ์„ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ์ €์žฅ + """ + try: + # 1. ๊ตฌ๋งค ํ’ˆ๋ชฉ ์ƒ์„ฑ + purchase_items = generate_purchase_items_from_materials(db, file_id, job_no, revision) + + # 2. ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ์ €์žฅ + saved_ids = save_purchase_items_to_db(db, purchase_items) + + return { + "success": True, + "message": f"{len(saved_ids)}๊ฐœ ๊ตฌ๋งค ํ’ˆ๋ชฉ์ด ์ €์žฅ๋˜์—ˆ์Šต๋‹ˆ๋‹ค", + "saved_items": len(saved_ids), + "item_ids": saved_ids + } + + except Exception as e: + db.rollback() + raise HTTPException(status_code=500, detail=f"๊ตฌ๋งค ํ’ˆ๋ชฉ ์ €์žฅ ์‹คํŒจ: {str(e)}") + +@router.get("/items") +async def get_purchase_items( + job_no: str = Query(..., description="Job ๋ฒˆํ˜ธ"), + revision: str = Query("Rev.0", description="๋ฆฌ๋น„์ „"), + category: Optional[str] = Query(None, description="์นดํ…Œ๊ณ ๋ฆฌ ํ•„ํ„ฐ"), + db: Session = Depends(get_db) +): + """ + ์ €์žฅ๋œ ๊ตฌ๋งค ํ’ˆ๋ชฉ ์กฐํšŒ + """ + try: + query = text(""" + SELECT pi.*, + COUNT(mpm.material_id) as material_count, + SUM(m.quantity) as total_material_quantity + FROM purchase_items pi + LEFT JOIN material_purchase_mapping mpm ON pi.id = mpm.purchase_item_id + LEFT JOIN materials m ON mpm.material_id = m.id + WHERE pi.job_no = :job_no AND pi.revision = :revision AND pi.is_active = TRUE + """) + + params = {"job_no": job_no, "revision": revision} + + if category: + query = text(str(query) + " AND pi.category = :category") + params["category"] = category + + query = text(str(query) + """ + GROUP BY pi.id + ORDER BY pi.category, pi.specification + """) + + result = db.execute(query, params) + items = result.fetchall() + + return { + "success": True, + "job_no": job_no, + "revision": revision, + "items": [dict(item) for item in items], + "total_items": len(items) + } + + except Exception as e: + raise HTTPException(status_code=500, detail=f"๊ตฌ๋งค ํ’ˆ๋ชฉ ์กฐํšŒ ์‹คํŒจ: {str(e)}") + +@router.patch("/items/{item_id}") +async def update_purchase_item( + item_id: int, + safety_factor: Optional[float] = None, + calculated_qty: Optional[float] = None, + minimum_order_qty: Optional[float] = None, + db: Session = Depends(get_db) +): + """ + ๊ตฌ๋งค ํ’ˆ๋ชฉ ์ˆ˜์ • (์ˆ˜๋Ÿ‰ ์กฐ์ •) + """ + try: + update_fields = [] + params = {"item_id": item_id} + + if safety_factor is not None: + update_fields.append("safety_factor = :safety_factor") + params["safety_factor"] = safety_factor + + if calculated_qty is not None: + update_fields.append("calculated_qty = :calculated_qty") + params["calculated_qty"] = calculated_qty + + if minimum_order_qty is not None: + update_fields.append("minimum_order_qty = :minimum_order_qty") + params["minimum_order_qty"] = minimum_order_qty + + if not update_fields: + raise HTTPException(status_code=400, detail="์ˆ˜์ •ํ•  ํ•„๋“œ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค") + + update_fields.append("updated_at = CURRENT_TIMESTAMP") + + query = text(f""" + UPDATE purchase_items + SET {', '.join(update_fields)} + WHERE id = :item_id + RETURNING id, calculated_qty, safety_factor + """) + + result = db.execute(query, params) + updated_item = result.fetchone() + + if not updated_item: + raise HTTPException(status_code=404, detail="๊ตฌ๋งค ํ’ˆ๋ชฉ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค") + + db.commit() + + return { + "success": True, + "message": "๊ตฌ๋งค ํ’ˆ๋ชฉ์ด ์ˆ˜์ •๋˜์—ˆ์Šต๋‹ˆ๋‹ค", + "item": dict(updated_item) + } + + except Exception as e: + db.rollback() + raise HTTPException(status_code=500, detail=f"๊ตฌ๋งค ํ’ˆ๋ชฉ ์ˆ˜์ • ์‹คํŒจ: {str(e)}") + +@router.get("/revision-diff") +async def get_revision_diff( + job_no: str = Query(..., description="Job ๋ฒˆํ˜ธ"), + current_revision: str = Query(..., description="ํ˜„์žฌ ๋ฆฌ๋น„์ „"), + previous_revision: str = Query(..., description="์ด์ „ ๋ฆฌ๋น„์ „"), + db: Session = Depends(get_db) +): + """ + ๋ฆฌ๋น„์ „๊ฐ„ ๊ตฌ๋งค ์ˆ˜๋Ÿ‰ ์ฐจ์ด ๊ณ„์‚ฐ + """ + try: + # 1. ์ด์ „ ๋ฆฌ๋น„์ „ ๊ตฌ๋งค ํ’ˆ๋ชฉ ์กฐํšŒ + prev_query = text(""" + SELECT item_code, category, specification, calculated_qty, bom_quantity + FROM purchase_items + WHERE job_no = :job_no AND revision = :prev_revision AND is_active = TRUE + """) + prev_items = db.execute(prev_query, { + "job_no": job_no, + "prev_revision": previous_revision + }).fetchall() + + # 2. ํ˜„์žฌ ๋ฆฌ๋น„์ „ ๊ตฌ๋งค ํ’ˆ๋ชฉ ์กฐํšŒ + curr_query = text(""" + SELECT item_code, category, specification, calculated_qty, bom_quantity + FROM purchase_items + WHERE job_no = :job_no AND revision = :curr_revision AND is_active = TRUE + """) + curr_items = db.execute(curr_query, { + "job_no": job_no, + "curr_revision": current_revision + }).fetchall() + + # 3. ์ฐจ์ด ๊ณ„์‚ฐ + prev_dict = {item.item_code: dict(item) for item in prev_items} + curr_dict = {item.item_code: dict(item) for item in curr_items} + + changes = [] + added_items = 0 + modified_items = 0 + + # ํ˜„์žฌ ๋ฆฌ๋น„์ „์—์„œ ์ถ”๊ฐ€๋˜๊ฑฐ๋‚˜ ๋ณ€๊ฒฝ๋œ ํ•ญ๋ชฉ + for item_code, curr_item in curr_dict.items(): + if item_code not in prev_dict: + # ์ƒˆ๋กœ ์ถ”๊ฐ€๋œ ํ’ˆ๋ชฉ + changes.append({ + "item_code": item_code, + "change_type": "ADDED", + "specification": curr_item["specification"], + "previous_qty": 0, + "current_qty": curr_item["calculated_qty"], + "qty_diff": curr_item["calculated_qty"], + "additional_needed": curr_item["calculated_qty"] + }) + added_items += 1 + else: + prev_item = prev_dict[item_code] + qty_diff = curr_item["calculated_qty"] - prev_item["calculated_qty"] + + if abs(qty_diff) > 0.001: # ์ˆ˜๋Ÿ‰ ๋ณ€๊ฒฝ + changes.append({ + "item_code": item_code, + "change_type": "MODIFIED", + "specification": curr_item["specification"], + "previous_qty": prev_item["calculated_qty"], + "current_qty": curr_item["calculated_qty"], + "qty_diff": qty_diff, + "additional_needed": max(qty_diff, 0) # ์ฆ๊ฐ€ํ•œ ๊ฒฝ์šฐ๋งŒ ์ถ”๊ฐ€ ๊ตฌ๋งค + }) + modified_items += 1 + + # ์‚ญ์ œ๋œ ํ’ˆ๋ชฉ (ํ˜„์žฌ ๋ฆฌ๋น„์ „์— ์—†๋Š” ํ•ญ๋ชฉ) + removed_items = 0 + for item_code, prev_item in prev_dict.items(): + if item_code not in curr_dict: + changes.append({ + "item_code": item_code, + "change_type": "REMOVED", + "specification": prev_item["specification"], + "previous_qty": prev_item["calculated_qty"], + "current_qty": 0, + "qty_diff": -prev_item["calculated_qty"], + "additional_needed": 0 + }) + removed_items += 1 + + # ์š”์•ฝ ์ •๋ณด + total_additional_needed = sum( + change["additional_needed"] for change in changes + if change["additional_needed"] > 0 + ) + + has_changes = len(changes) > 0 + + summary = f"์ถ”๊ฐ€: {added_items}๊ฐœ, ๋ณ€๊ฒฝ: {modified_items}๊ฐœ, ์‚ญ์ œ: {removed_items}๊ฐœ" + if total_additional_needed > 0: + summary += f" (์ถ”๊ฐ€ ๊ตฌ๋งค ํ•„์š”: {total_additional_needed:.1f})" + + return { + "success": True, + "job_no": job_no, + "previous_revision": previous_revision, + "current_revision": current_revision, + "comparison": { + "has_changes": has_changes, + "summary": summary, + "added_items": added_items, + "modified_items": modified_items, + "removed_items": removed_items, + "total_additional_needed": total_additional_needed, + "changes": changes + } + } + + except Exception as e: + raise HTTPException(status_code=500, detail=f"๋ฆฌ๋น„์ „ ๋น„๊ต ์‹คํŒจ: {str(e)}") + +@router.post("/orders/create") +async def create_purchase_order( + job_no: str, + revision: str, + items: List[dict], + supplier_name: Optional[str] = None, + required_date: Optional[str] = None, + db: Session = Depends(get_db) +): + """ + ๊ตฌ๋งค ์ฃผ๋ฌธ ์ƒ์„ฑ + """ + try: + from datetime import datetime, date + + # 1. ์ฃผ๋ฌธ ๋ฒˆํ˜ธ ์ƒ์„ฑ + order_no = f"PO-{job_no}-{revision}-{datetime.now().strftime('%Y%m%d')}" + + # 2. ๊ตฌ๋งค ์ฃผ๋ฌธ ์ƒ์„ฑ + order_query = text(""" + INSERT INTO purchase_orders ( + order_no, job_no, revision, status, order_date, required_date, + supplier_name, created_by + ) VALUES ( + :order_no, :job_no, :revision, 'DRAFT', CURRENT_DATE, :required_date, + :supplier_name, 'system' + ) RETURNING id + """) + + order_result = db.execute(order_query, { + "order_no": order_no, + "job_no": job_no, + "revision": revision, + "required_date": required_date, + "supplier_name": supplier_name + }) + + order_id = order_result.fetchone()[0] + + # 3. ์ฃผ๋ฌธ ์ƒ์„ธ ํ•ญ๋ชฉ ์ƒ์„ฑ + total_amount = 0 + for item in items: + item_query = text(""" + INSERT INTO purchase_order_items ( + purchase_order_id, purchase_item_id, ordered_quantity, required_date + ) VALUES ( + :order_id, :item_id, :quantity, :required_date + ) + """) + + db.execute(item_query, { + "order_id": order_id, + "item_id": item["purchase_item_id"], + "quantity": item["ordered_quantity"], + "required_date": required_date + }) + + db.commit() + + return { + "success": True, + "message": "๊ตฌ๋งค ์ฃผ๋ฌธ์ด ์ƒ์„ฑ๋˜์—ˆ์Šต๋‹ˆ๋‹ค", + "order_no": order_no, + "order_id": order_id, + "items_count": len(items) + } + + except Exception as e: + db.rollback() + raise HTTPException(status_code=500, detail=f"๊ตฌ๋งค ์ฃผ๋ฌธ ์ƒ์„ฑ ์‹คํŒจ: {str(e)}") + +@router.get("/orders") +async def get_purchase_orders( + job_no: Optional[str] = Query(None, description="Job ๋ฒˆํ˜ธ"), + status: Optional[str] = Query(None, description="์ฃผ๋ฌธ ์ƒํƒœ"), + db: Session = Depends(get_db) +): + """ + ๊ตฌ๋งค ์ฃผ๋ฌธ ๋ชฉ๋ก ์กฐํšŒ + """ + try: + query = text(""" + SELECT po.*, + COUNT(poi.id) as items_count, + SUM(poi.ordered_quantity) as total_quantity + FROM purchase_orders po + LEFT JOIN purchase_order_items poi ON po.id = poi.purchase_order_id + WHERE 1=1 + """) + + params = {} + + if job_no: + query = text(str(query) + " AND po.job_no = :job_no") + params["job_no"] = job_no + + if status: + query = text(str(query) + " AND po.status = :status") + params["status"] = status + + query = text(str(query) + """ + GROUP BY po.id + ORDER BY po.created_at DESC + """) + + result = db.execute(query, params) + orders = result.fetchall() + + return { + "success": True, + "orders": [dict(order) for order in orders], + "total_orders": len(orders) + } + + except Exception as e: + raise HTTPException(status_code=500, detail=f"๊ตฌ๋งค ์ฃผ๋ฌธ ์กฐํšŒ ์‹คํŒจ: {str(e)}") \ No newline at end of file diff --git a/backend/app/services/purchase_calculator.py b/backend/app/services/purchase_calculator.py new file mode 100644 index 0000000..93d7fea --- /dev/null +++ b/backend/app/services/purchase_calculator.py @@ -0,0 +1,526 @@ +""" +๊ตฌ๋งค ์ˆ˜๋Ÿ‰ ๊ณ„์‚ฐ ์„œ๋น„์Šค +- ์ž์žฌ๋ณ„ ์—ฌ์œ ์œจ ์ ์šฉ +- PIPE: ์ ˆ๋‹จ ์†์‹ค + 6M ๋‹จ์œ„ ๊ณ„์‚ฐ +- ๊ธฐํƒ€: ์ตœ์†Œ ์ฃผ๋ฌธ ์ˆ˜๋Ÿ‰ ์ ์šฉ +""" + +import math +from typing import Dict, List, Tuple +from sqlalchemy.orm import Session +from sqlalchemy import text + +# ์ž์žฌ๋ณ„ ๊ธฐ๋ณธ ์—ฌ์œ ์œจ +SAFETY_FACTORS = { + 'PIPE': 1.15, # 15% ์ถ”๊ฐ€ (์ ˆ๋‹จ ์†์‹ค) + 'FITTING': 1.10, # 10% ์ถ”๊ฐ€ (์—ฐ๊ฒฐ ์˜ค์ฐจ) + 'VALVE': 1.50, # 50% ์ถ”๊ฐ€ (์˜ˆ๋น„ํ’ˆ) + 'FLANGE': 1.10, # 10% ์ถ”๊ฐ€ + 'BOLT': 1.20, # 20% ์ถ”๊ฐ€ (๋ถ„์‹ค์œจ) + 'GASKET': 1.25, # 25% ์ถ”๊ฐ€ (๊ต์ฒด์ฃผ๊ธฐ) + 'INSTRUMENT': 1.00, # 0% ์ถ”๊ฐ€ (์ •ํ™•ํ•œ ์ˆ˜๋Ÿ‰) + 'DEFAULT': 1.10 # ๊ธฐ๋ณธ 10% ์ถ”๊ฐ€ +} + +# ์ตœ์†Œ ์ฃผ๋ฌธ ์ˆ˜๋Ÿ‰ (์ž์žฌ๋ณ„) +MINIMUM_ORDER_QTY = { + 'PIPE': 6000, # 6M ๋‹จ์œ„ + 'FITTING': 1, # ๊ฐœ๋ณ„ ์ฃผ๋ฌธ ๊ฐ€๋Šฅ + 'VALVE': 1, # ๊ฐœ๋ณ„ ์ฃผ๋ฌธ ๊ฐ€๋Šฅ + 'FLANGE': 1, # ๊ฐœ๋ณ„ ์ฃผ๋ฌธ ๊ฐ€๋Šฅ + 'BOLT': 50, # ๋ฐ•์Šค ๋‹จ์œ„ (50๊ฐœ) + 'GASKET': 10, # ์„ธํŠธ ๋‹จ์œ„ + 'INSTRUMENT': 1, # ๊ฐœ๋ณ„ ์ฃผ๋ฌธ ๊ฐ€๋Šฅ + 'DEFAULT': 1 +} + +def calculate_pipe_purchase_quantity(materials: List[Dict]) -> Dict: + """ + PIPE ๊ตฌ๋งค ์ˆ˜๋Ÿ‰ ๊ณ„์‚ฐ + - ๊ฐ ์ ˆ๋‹จ๋งˆ๋‹ค 3mm ์†์‹ค + - 6,000mm (6M) ๋‹จ์œ„๋กœ ์˜ฌ๋ฆผ + """ + total_bom_length = 0 + cutting_count = 0 + pipe_details = [] + + for material in materials: + # ๊ธธ์ด ์ •๋ณด ์ถ”์ถœ + length_mm = float(material.get('length_mm', 0) or 0) + if length_mm > 0: + total_bom_length += length_mm + cutting_count += 1 + pipe_details.append({ + 'description': material.get('original_description', ''), + 'length_mm': length_mm, + 'quantity': material.get('quantity', 1) + }) + + # ์ ˆ๋‹จ ์†์‹ค ๊ณ„์‚ฐ (๊ฐ ์ ˆ๋‹จ๋งˆ๋‹ค 3mm) + cutting_loss = cutting_count * 3 + + # ์ด ํ•„์š” ๊ธธ์ด = BOM ๊ธธ์ด + ์ ˆ๋‹จ ์†์‹ค + required_length = total_bom_length + cutting_loss + + # 6M ๋‹จ์œ„๋กœ ์˜ฌ๋ฆผ ๊ณ„์‚ฐ + standard_length = 6000 # 6M = 6,000mm + pipes_needed = math.ceil(required_length / standard_length) if required_length > 0 else 0 + total_purchase_length = pipes_needed * standard_length + waste_length = total_purchase_length - required_length if pipes_needed > 0 else 0 + + return { + 'bom_quantity': total_bom_length, + 'cutting_count': cutting_count, + 'cutting_loss': cutting_loss, + 'required_length': required_length, + 'pipes_count': pipes_needed, + 'calculated_qty': total_purchase_length, + 'waste_length': waste_length, + 'utilization_rate': (required_length / total_purchase_length * 100) if total_purchase_length > 0 else 0, + 'unit': 'mm', + 'pipe_details': pipe_details + } + +def calculate_standard_purchase_quantity(category: str, bom_quantity: float, + safety_factor: float = None) -> Dict: + """ + ์ผ๋ฐ˜ ์ž์žฌ ๊ตฌ๋งค ์ˆ˜๋Ÿ‰ ๊ณ„์‚ฐ + - ์—ฌ์œ ์œจ ์ ์šฉ + - ์ตœ์†Œ ์ฃผ๋ฌธ ์ˆ˜๋Ÿ‰ ์ ์šฉ + """ + # ์—ฌ์œ ์œจ ๊ฒฐ์ • + if safety_factor is None: + safety_factor = SAFETY_FACTORS.get(category, SAFETY_FACTORS['DEFAULT']) + + # 1๋‹จ๊ณ„: ์—ฌ์œ ์œจ ์ ์šฉ + safety_qty = bom_quantity * safety_factor + + # 2๋‹จ๊ณ„: ์ตœ์†Œ ์ฃผ๋ฌธ ์ˆ˜๋Ÿ‰ ํ™•์ธ + min_order_qty = MINIMUM_ORDER_QTY.get(category, MINIMUM_ORDER_QTY['DEFAULT']) + + # 3๋‹จ๊ณ„: ์ตœ์†Œ ์ฃผ๋ฌธ ์ˆ˜๋Ÿ‰๊ณผ ๋น„๊ตํ•˜์—ฌ ํฐ ๊ฐ’ ์„ ํƒ + calculated_qty = max(safety_qty, min_order_qty) + + # 4๋‹จ๊ณ„: ํŠน๋ณ„ ์ฒ˜๋ฆฌ (BOLT๋Š” ๋ฐ•์Šค ๋‹จ์œ„๋กœ ์˜ฌ๋ฆผ) + if category == 'BOLT' and calculated_qty > min_order_qty: + calculated_qty = math.ceil(calculated_qty / min_order_qty) * min_order_qty + + return { + 'bom_quantity': bom_quantity, + 'safety_factor': safety_factor, + 'safety_qty': safety_qty, + 'min_order_qty': min_order_qty, + 'calculated_qty': calculated_qty, + 'waste_quantity': calculated_qty - bom_quantity, + 'utilization_rate': (bom_quantity / calculated_qty * 100) if calculated_qty > 0 else 0 + } + +def generate_purchase_items_from_materials(db: Session, file_id: int, + job_no: str, revision: str) -> List[Dict]: + """ + ์ž์žฌ ๋ฐ์ดํ„ฐ๋กœ๋ถ€ํ„ฐ ๊ตฌ๋งค ํ’ˆ๋ชฉ ์ƒ์„ฑ + """ + # 1. ํŒŒ์ผ์˜ ๋ชจ๋“  ์ž์žฌ ์กฐํšŒ + materials_query = text(""" + SELECT m.*, + pd.length_mm, pd.outer_diameter, pd.schedule, pd.material_spec as pipe_material_spec, + fd.fitting_type, fd.connection_method as fitting_connection, + vd.valve_type, vd.connection_method as valve_connection, vd.pressure_rating as valve_pressure, + fl.flange_type, fl.pressure_rating as flange_pressure, + gd.gasket_type, gd.material_type as gasket_material, + bd.bolt_type, bd.material_standard, bd.diameter, + id.instrument_type + FROM materials m + LEFT JOIN pipe_details pd ON m.id = pd.material_id + LEFT JOIN fitting_details fd ON m.id = fd.material_id + LEFT JOIN valve_details vd ON m.id = vd.material_id + LEFT JOIN flange_details fl ON m.id = fl.material_id + LEFT JOIN gasket_details gd ON m.id = gd.material_id + LEFT JOIN bolt_details bd ON m.id = bd.material_id + LEFT JOIN instrument_details id ON m.id = id.material_id + WHERE m.file_id = :file_id + ORDER BY m.classified_category, m.original_description + """) + + materials = db.execute(materials_query, {"file_id": file_id}).fetchall() + + # 2. ์นดํ…Œ๊ณ ๋ฆฌ๋ณ„๋กœ ๊ทธ๋ฃนํ•‘ + grouped_materials = {} + for material in materials: + category = material.classified_category or 'OTHER' + if category not in grouped_materials: + grouped_materials[category] = [] + grouped_materials[category].append(dict(material)) + + # 3. ๊ฐ ์นดํ…Œ๊ณ ๋ฆฌ๋ณ„๋กœ ๊ตฌ๋งค ํ’ˆ๋ชฉ ์ƒ์„ฑ + purchase_items = [] + + for category, category_materials in grouped_materials.items(): + if category == 'PIPE': + # PIPE๋Š” ์žฌ์งˆ+์‚ฌ์ด์ฆˆ+์Šค์ผ€์ค„๋ณ„๋กœ ๊ทธ๋ฃนํ•‘ + pipe_groups = {} + for material in category_materials: + # ๊ทธ๋ฃนํ•‘ ํ‚ค ์ƒ์„ฑ + material_spec = material.get('pipe_material_spec') or material.get('material_grade', '') + outer_diameter = material.get('outer_diameter') or material.get('main_nom', '') + schedule = material.get('schedule', '') + + group_key = f"{material_spec}|{outer_diameter}|{schedule}" + + if group_key not in pipe_groups: + pipe_groups[group_key] = [] + pipe_groups[group_key].append(material) + + # ๊ฐ PIPE ๊ทธ๋ฃน๋ณ„๋กœ ๊ตฌ๋งค ์ˆ˜๋Ÿ‰ ๊ณ„์‚ฐ + for group_key, group_materials in pipe_groups.items(): + pipe_calc = calculate_pipe_purchase_quantity(group_materials) + + if pipe_calc['calculated_qty'] > 0: + material_spec, outer_diameter, schedule = group_key.split('|') + + # ํ’ˆ๋ชฉ ์ฝ”๋“œ ์ƒ์„ฑ + item_code = generate_item_code('PIPE', material_spec, outer_diameter, schedule) + + # ์‚ฌ์–‘ ์ƒ์„ฑ + spec_parts = [f"PIPE {outer_diameter}"] + if schedule: spec_parts.append(schedule) + if material_spec: spec_parts.append(material_spec) + specification = ', '.join(spec_parts) + + purchase_item = { + 'item_code': item_code, + 'category': 'PIPE', + 'specification': specification, + 'material_spec': material_spec, + 'size_spec': outer_diameter, + 'unit': 'mm', + **pipe_calc, + 'job_no': job_no, + 'revision': revision, + 'file_id': file_id, + 'materials': group_materials + } + purchase_items.append(purchase_item) + + else: + # ๊ธฐํƒ€ ์ž์žฌ๋“ค์€ ์‚ฌ์–‘๋ณ„๋กœ ๊ทธ๋ฃนํ•‘ + spec_groups = generate_material_specs_for_category(category_materials, category) + + for spec_key, spec_data in spec_groups.items(): + if spec_data['totalQuantity'] > 0: + # ๊ตฌ๋งค ์ˆ˜๋Ÿ‰ ๊ณ„์‚ฐ + calc_result = calculate_standard_purchase_quantity( + category, + spec_data['totalQuantity'] + ) + + # ํ’ˆ๋ชฉ ์ฝ”๋“œ ์ƒ์„ฑ + item_code = generate_item_code(category, spec_data.get('material_spec', ''), + spec_data.get('size_display', '')) + + purchase_item = { + 'item_code': item_code, + 'category': category, + 'specification': spec_data.get('full_spec', spec_key), + 'material_spec': spec_data.get('material_spec', ''), + 'size_spec': spec_data.get('size_display', ''), + 'unit': spec_data.get('unit', 'EA'), + **calc_result, + 'job_no': job_no, + 'revision': revision, + 'file_id': file_id, + 'materials': spec_data['items'] + } + purchase_items.append(purchase_item) + + return purchase_items + +def generate_material_specs_for_category(materials: List[Dict], category: str) -> Dict: + """์นดํ…Œ๊ณ ๋ฆฌ๋ณ„ ์ž์žฌ ์‚ฌ์–‘ ๊ทธ๋ฃนํ•‘ (MaterialsPage.jsx ๋กœ์ง๊ณผ ๋™์ผ)""" + specs = {} + + for material in materials: + spec_key = '' + spec_data = {} + + if category == 'FITTING': + fitting_type = material.get('fitting_type', 'FITTING') + connection_method = material.get('fitting_connection', '') + material_spec = material.get('material_grade', '') + main_nom = material.get('main_nom', '') + red_nom = material.get('red_nom', '') + size_display = f"{main_nom} x {red_nom}" if red_nom else main_nom + + spec_parts = [fitting_type] + if connection_method: spec_parts.append(connection_method) + full_spec = ', '.join(spec_parts) + + spec_key = f"FITTING|{full_spec}|{material_spec}|{size_display}" + spec_data = { + 'category': 'FITTING', + 'full_spec': full_spec, + 'material_spec': material_spec, + 'size_display': size_display, + 'unit': 'EA' + } + + elif category == 'VALVE': + valve_type = material.get('valve_type', 'VALVE') + connection_method = material.get('valve_connection', '') + pressure_rating = material.get('valve_pressure', '') + material_spec = material.get('material_grade', '') + main_nom = material.get('main_nom', '') + + spec_parts = [valve_type.replace('_', ' ')] + if connection_method: spec_parts.append(connection_method.replace('_', ' ')) + if pressure_rating: spec_parts.append(pressure_rating) + full_spec = ', '.join(spec_parts) + + spec_key = f"VALVE|{full_spec}|{material_spec}|{main_nom}" + spec_data = { + 'category': 'VALVE', + 'full_spec': full_spec, + 'material_spec': material_spec, + 'size_display': main_nom, + 'unit': 'EA' + } + + elif category == 'FLANGE': + flange_type = material.get('flange_type', 'FLANGE') + pressure_rating = material.get('flange_pressure', '') + material_spec = material.get('material_grade', '') + main_nom = material.get('main_nom', '') + + spec_parts = [flange_type] + if pressure_rating: spec_parts.append(pressure_rating) + full_spec = ', '.join(spec_parts) + + spec_key = f"FLANGE|{full_spec}|{material_spec}|{main_nom}" + spec_data = { + 'category': 'FLANGE', + 'full_spec': full_spec, + 'material_spec': material_spec, + 'size_display': main_nom, + 'unit': 'EA' + } + + elif category == 'BOLT': + bolt_type = material.get('bolt_type', 'BOLT') + material_standard = material.get('material_standard', '') + diameter = material.get('diameter', material.get('main_nom', '')) + material_spec = material_standard or material.get('material_grade', '') + + spec_parts = [bolt_type.replace('_', ' ')] + if material_standard: spec_parts.append(material_standard) + full_spec = ', '.join(spec_parts) + + spec_key = f"BOLT|{full_spec}|{material_spec}|{diameter}" + spec_data = { + 'category': 'BOLT', + 'full_spec': full_spec, + 'material_spec': material_spec, + 'size_display': diameter, + 'unit': 'EA' + } + + elif category == 'GASKET': + gasket_type = material.get('gasket_type', 'GASKET') + gasket_material = material.get('gasket_material', '') + material_spec = gasket_material or material.get('material_grade', '') + main_nom = material.get('main_nom', '') + + spec_parts = [gasket_type] + if gasket_material: spec_parts.append(gasket_material) + full_spec = ', '.join(spec_parts) + + spec_key = f"GASKET|{full_spec}|{material_spec}|{main_nom}" + spec_data = { + 'category': 'GASKET', + 'full_spec': full_spec, + 'material_spec': material_spec, + 'size_display': main_nom, + 'unit': 'EA' + } + + elif category == 'INSTRUMENT': + instrument_type = material.get('instrument_type', 'INSTRUMENT') + material_spec = material.get('material_grade', '') + main_nom = material.get('main_nom', '') + + full_spec = instrument_type.replace('_', ' ') + + spec_key = f"INSTRUMENT|{full_spec}|{material_spec}|{main_nom}" + spec_data = { + 'category': 'INSTRUMENT', + 'full_spec': full_spec, + 'material_spec': material_spec, + 'size_display': main_nom, + 'unit': 'EA' + } + + else: + # ๊ธฐํƒ€ ์ž์žฌ + material_spec = material.get('material_grade', '') + size_display = material.get('main_nom') or material.get('size_spec', '') + + spec_key = f"{category}|{material_spec}|{size_display}" + spec_data = { + 'category': category, + 'full_spec': material_spec, + 'material_spec': material_spec, + 'size_display': size_display, + 'unit': 'EA' + } + + # ์ŠคํŽ™๋ณ„ ์ˆ˜๋Ÿ‰ ์ง‘๊ณ„ + if spec_key not in specs: + specs[spec_key] = { + **spec_data, + 'totalQuantity': 0, + 'count': 0, + 'items': [] + } + + specs[spec_key]['totalQuantity'] += material.get('quantity', 0) + specs[spec_key]['count'] += 1 + specs[spec_key]['items'].append(material) + + return specs + +def generate_item_code(category: str, material_spec: str = '', size_spec: str = '', + schedule: str = '') -> str: + """๊ตฌ๋งค ํ’ˆ๋ชฉ ์ฝ”๋“œ ์ƒ์„ฑ""" + import hashlib + + # ๊ธฐ๋ณธ ์ ‘๋‘์‚ฌ + prefix = f"PI-{category}" + + # ์žฌ์งˆ ์•ฝ์–ด ์ƒ์„ฑ + material_abbr = '' + if 'A106' in material_spec: + material_abbr = 'A106' + elif 'A333' in material_spec: + material_abbr = 'A333' + elif 'SS316' in material_spec or '316' in material_spec: + material_abbr = 'SS316' + elif 'A105' in material_spec: + material_abbr = 'A105' + elif material_spec: + material_abbr = material_spec.replace(' ', '')[:6] + + # ์‚ฌ์ด์ฆˆ ์•ฝ์–ด + size_abbr = size_spec.replace('"', 'IN').replace(' ', '').replace('x', 'X')[:10] + + # ์Šค์ผ€์ค„ (PIPE์šฉ) + schedule_abbr = schedule.replace(' ', '')[:6] + + # ์œ ๋‹ˆํฌ ํ•ด์‹œ ์ƒ์„ฑ (์ค‘๋ณต ๋ฐฉ์ง€) + unique_str = f"{category}|{material_spec}|{size_spec}|{schedule}" + hash_suffix = hashlib.md5(unique_str.encode()).hexdigest()[:4].upper() + + # ์ตœ์ข… ์ฝ”๋“œ ์กฐํ•ฉ + code_parts = [prefix] + if material_abbr: code_parts.append(material_abbr) + if size_abbr: code_parts.append(size_abbr) + if schedule_abbr: code_parts.append(schedule_abbr) + code_parts.append(hash_suffix) + + return '-'.join(code_parts) + +def save_purchase_items_to_db(db: Session, purchase_items: List[Dict]) -> List[int]: + """๊ตฌ๋งค ํ’ˆ๋ชฉ์„ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ์ €์žฅ""" + saved_ids = [] + + for item in purchase_items: + # ๊ธฐ์กด ํ’ˆ๋ชฉ ํ™•์ธ (๋™์ผ ์‚ฌ์–‘์ด ์žˆ๋Š”์ง€) + existing_query = text(""" + SELECT id FROM purchase_items + WHERE job_no = :job_no AND revision = :revision AND item_code = :item_code + """) + existing = db.execute(existing_query, { + 'job_no': item['job_no'], + 'revision': item['revision'], + 'item_code': item['item_code'] + }).fetchone() + + if existing: + # ๊ธฐ์กด ํ’ˆ๋ชฉ ์—…๋ฐ์ดํŠธ + update_query = text(""" + UPDATE purchase_items SET + bom_quantity = :bom_quantity, + calculated_qty = :calculated_qty, + safety_factor = :safety_factor, + cutting_loss = :cutting_loss, + pipes_count = :pipes_count, + waste_length = :waste_length, + detailed_spec = :detailed_spec, + updated_at = CURRENT_TIMESTAMP + WHERE id = :id + """) + db.execute(update_query, { + 'id': existing.id, + 'bom_quantity': item['bom_quantity'], + 'calculated_qty': item['calculated_qty'], + 'safety_factor': item.get('safety_factor', 1.0), + 'cutting_loss': item.get('cutting_loss', 0), + 'pipes_count': item.get('pipes_count'), + 'waste_length': item.get('waste_length'), + 'detailed_spec': item.get('detailed_spec', '{}') + }) + saved_ids.append(existing.id) + else: + # ์ƒˆ ํ’ˆ๋ชฉ ์ƒ์„ฑ + insert_query = text(""" + INSERT INTO purchase_items ( + item_code, category, specification, material_spec, size_spec, unit, + bom_quantity, safety_factor, minimum_order_qty, calculated_qty, + cutting_loss, standard_length, pipes_count, waste_length, + job_no, revision, file_id, is_active, created_by + ) VALUES ( + :item_code, :category, :specification, :material_spec, :size_spec, :unit, + :bom_quantity, :safety_factor, :minimum_order_qty, :calculated_qty, + :cutting_loss, :standard_length, :pipes_count, :waste_length, + :job_no, :revision, :file_id, :is_active, :created_by + ) RETURNING id + """) + + result = db.execute(insert_query, { + 'item_code': item['item_code'], + 'category': item['category'], + 'specification': item['specification'], + 'material_spec': item['material_spec'], + 'size_spec': item['size_spec'], + 'unit': item['unit'], + 'bom_quantity': item['bom_quantity'], + 'safety_factor': item.get('safety_factor', 1.0), + 'minimum_order_qty': item.get('min_order_qty', 0), + 'calculated_qty': item['calculated_qty'], + 'cutting_loss': item.get('cutting_loss', 0), + 'standard_length': item.get('standard_length', 6000 if item['category'] == 'PIPE' else None), + 'pipes_count': item.get('pipes_count'), + 'waste_length': item.get('waste_length'), + 'job_no': item['job_no'], + 'revision': item['revision'], + 'file_id': item['file_id'], + 'is_active': True, + 'created_by': 'system' + }) + + result_row = result.fetchone() + new_id = result_row[0] if result_row else None + saved_ids.append(new_id) + + # ๊ฐœ๋ณ„ ์ž์žฌ์™€ ๊ตฌ๋งค ํ’ˆ๋ชฉ ์—ฐ๊ฒฐ + for material in item['materials']: + mapping_query = text(""" + INSERT INTO material_purchase_mapping (material_id, purchase_item_id) + VALUES (:material_id, :purchase_item_id) + ON CONFLICT (material_id, purchase_item_id) DO NOTHING + """) + db.execute(mapping_query, { + 'material_id': material['id'], + 'purchase_item_id': new_id + }) + + db.commit() + return saved_ids \ No newline at end of file diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 81be89d..dcc0528 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,17 +1,20 @@ import React from 'react'; -import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; -import JobSelectionPage from './pages/JobSelectionPage'; -import BOMStatusPage from './pages/BOMStatusPage'; +import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; +import ProjectSelectionPage from './pages/ProjectSelectionPage'; +import BOMManagerPage from './pages/BOMManagerPage'; import MaterialsPage from './pages/MaterialsPage'; +import BOMStatusPage from './pages/BOMStatusPage'; +import PurchaseConfirmationPage from './pages/PurchaseConfirmationPage'; function App() { return ( - } /> - } /> + } /> + } /> } /> - } /> + } /> + } /> ); diff --git a/test_purchase_calculator.py b/test_purchase_calculator.py new file mode 100644 index 0000000..cf3b7a5 --- /dev/null +++ b/test_purchase_calculator.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +""" +๊ตฌ๋งค ์ˆ˜๋Ÿ‰ ๊ณ„์‚ฐ๊ธฐ ํ…Œ์ŠคํŠธ +ํŠนํžˆ ํŒŒ์ดํ”„ ์ ˆ๋‹จ ์†์‹ค + 6M ๋‹จ์œ„ ๊ณ„์‚ฐ ํ…Œ์ŠคํŠธ +""" + +import sys +import os +sys.path.append('backend') + +def test_pipe_calculation(): + """ํŒŒ์ดํ”„ ์ ˆ๋‹จ ์†์‹ค + 6M ๋‹จ์œ„ ๊ณ„์‚ฐ ํ…Œ์ŠคํŠธ""" + + from app.services.purchase_calculator import calculate_pipe_purchase_quantity + + print("๐Ÿ”ง PIPE ๊ตฌ๋งค ์ˆ˜๋Ÿ‰ ๊ณ„์‚ฐ ํ…Œ์ŠคํŠธ\n") + + # ํ…Œ์ŠคํŠธ ์ผ€์ด์Šค๋“ค + test_cases = [ + { + "name": "25,000mm ํ•„์š” (10ํšŒ ์ ˆ๋‹จ)", + "materials": [ + {"length_mm": 3000, "original_description": "PIPE 4\" SCH40 - 3M", "quantity": 1}, + {"length_mm": 2500, "original_description": "PIPE 4\" SCH40 - 2.5M", "quantity": 1}, + {"length_mm": 1800, "original_description": "PIPE 4\" SCH40 - 1.8M", "quantity": 1}, + {"length_mm": 4200, "original_description": "PIPE 4\" SCH40 - 4.2M", "quantity": 1}, + {"length_mm": 2100, "original_description": "PIPE 4\" SCH40 - 2.1M", "quantity": 1}, + {"length_mm": 1500, "original_description": "PIPE 4\" SCH40 - 1.5M", "quantity": 1}, + {"length_mm": 3800, "original_description": "PIPE 4\" SCH40 - 3.8M", "quantity": 1}, + {"length_mm": 2200, "original_description": "PIPE 4\" SCH40 - 2.2M", "quantity": 1}, + {"length_mm": 1900, "original_description": "PIPE 4\" SCH40 - 1.9M", "quantity": 1}, + {"length_mm": 2000, "original_description": "PIPE 4\" SCH40 - 2M", "quantity": 1} + ], + "expected_pipes": 5 + }, + { + "name": "5,900mm ํ•„์š” (3ํšŒ ์ ˆ๋‹จ)", + "materials": [ + {"length_mm": 2000, "original_description": "PIPE 6\" SCH40 - 2M", "quantity": 1}, + {"length_mm": 1900, "original_description": "PIPE 6\" SCH40 - 1.9M", "quantity": 1}, + {"length_mm": 2000, "original_description": "PIPE 6\" SCH40 - 2M", "quantity": 1} + ], + "expected_pipes": 1 + }, + { + "name": "12,000mm ์ •ํ™•ํžˆ (4ํšŒ ์ ˆ๋‹จ)", + "materials": [ + {"length_mm": 3000, "original_description": "PIPE 8\" SCH40 - 3M", "quantity": 4} + ], + "expected_pipes": 2 + } + ] + + for i, test_case in enumerate(test_cases, 1): + print(f"๐Ÿ“‹ ํ…Œ์ŠคํŠธ {i}: {test_case['name']}") + + result = calculate_pipe_purchase_quantity(test_case["materials"]) + + print(f" ๐ŸŽฏ BOM ์ด ๊ธธ์ด: {result['bom_quantity']:,}mm") + print(f" โœ‚๏ธ ์ ˆ๋‹จ ํšŸ์ˆ˜: {result['cutting_count']}ํšŒ") + print(f" ๐Ÿ“ ์ ˆ๋‹จ ์†์‹ค: {result['cutting_loss']}mm (๊ฐ ์ ˆ๋‹จ๋งˆ๋‹ค 3mm)") + print(f" ๐Ÿ”ข ์ด ํ•„์š”๋Ÿ‰: {result['required_length']:,}mm") + print(f" ๐Ÿ“ฆ ๊ตฌ๋งค ํŒŒ์ดํ”„: {result['pipes_count']}๋ณธ (๊ฐ 6M)") + print(f" ๐Ÿ’ฐ ๊ตฌ๋งค ์ด๋Ÿ‰: {result['calculated_qty']:,}mm") + print(f" โ™ป๏ธ ์—ฌ์œ ๋ถ„: {result['waste_length']:,}mm") + print(f" ๐Ÿ“Š ํ™œ์šฉ๋ฅ : {result['utilization_rate']:.1f}%") + + # ๊ฒฐ๊ณผ ํ™•์ธ + if result['pipes_count'] == test_case['expected_pipes']: + print(f" โœ… ์„ฑ๊ณต: ์˜ˆ์ƒ {test_case['expected_pipes']}๋ณธ = ๊ฒฐ๊ณผ {result['pipes_count']}๋ณธ") + else: + print(f" โŒ ์‹คํŒจ: ์˜ˆ์ƒ {test_case['expected_pipes']}๋ณธ โ‰  ๊ฒฐ๊ณผ {result['pipes_count']}๋ณธ") + + print() + +def test_standard_calculation(): + """์ผ๋ฐ˜ ์ž์žฌ ๊ตฌ๋งค ์ˆ˜๋Ÿ‰ ๊ณ„์‚ฐ ํ…Œ์ŠคํŠธ""" + + from app.services.purchase_calculator import calculate_standard_purchase_quantity + + print("๐Ÿ”ง ์ผ๋ฐ˜ ์ž์žฌ ๊ตฌ๋งค ์ˆ˜๋Ÿ‰ ๊ณ„์‚ฐ ํ…Œ์ŠคํŠธ\n") + + test_cases = [ + {"category": "VALVE", "bom_qty": 2, "expected_factor": 1.5, "desc": "๋ฐธ๋ธŒ 2๊ฐœ (50% ์˜ˆ๋น„ํ’ˆ)"}, + {"category": "BOLT", "bom_qty": 24, "expected_min": 50, "desc": "๋ณผํŠธ 24๊ฐœ (๋ฐ•์Šค ๋‹จ์œ„ 50๊ฐœ)"}, + {"category": "FITTING", "bom_qty": 5, "expected_factor": 1.1, "desc": "ํ”ผํŒ… 5๊ฐœ (10% ์—ฌ์œ )"}, + {"category": "GASKET", "bom_qty": 3, "expected_factor": 1.25, "desc": "๊ฐ€์Šค์ผ“ 3๊ฐœ (25% ๊ต์ฒด ์ฃผ๊ธฐ)"}, + {"category": "INSTRUMENT", "bom_qty": 1, "expected_factor": 1.0, "desc": "๊ณ„๊ธฐ 1๊ฐœ (์ •ํ™•ํ•œ ์ˆ˜๋Ÿ‰)"} + ] + + for i, test_case in enumerate(test_cases, 1): + print(f"๐Ÿ“‹ ํ…Œ์ŠคํŠธ {i}: {test_case['desc']}") + + result = calculate_standard_purchase_quantity( + test_case["category"], + test_case["bom_qty"] + ) + + print(f" ๐ŸŽฏ BOM ์ˆ˜๋Ÿ‰: {result['bom_quantity']}") + print(f" ๐Ÿ“ˆ ์—ฌ์œ ์œจ: {result['safety_factor']:.2f} ({(result['safety_factor']-1)*100:.0f}%)") + print(f" ๐Ÿ”ข ์—ฌ์œ  ์ ์šฉ: {result['safety_qty']:.1f}") + print(f" ๐Ÿ“ฆ ์ตœ์†Œ ์ฃผ๋ฌธ: {result['min_order_qty']}") + print(f" ๐Ÿ’ฐ ์ตœ์ข… ๊ตฌ๋งค: {result['calculated_qty']}") + print(f" โ™ป๏ธ ์—ฌ์œ ๋ถ„: {result['waste_quantity']}") + print(f" ๐Ÿ“Š ํ™œ์šฉ๋ฅ : {result['utilization_rate']:.1f}%") + print() + +if __name__ == "__main__": + test_pipe_calculation() + test_standard_calculation() \ No newline at end of file