feat: P0-2 订单BOM计算单+Excel导出(openpyxl)

This commit is contained in:
HHY
2026-09-25 03:09:21 -07:00
parent 8c2bb5e12d
commit d5e3cdbede
10 changed files with 247 additions and 1 deletions
+44
View File
@@ -75,6 +75,50 @@ async def order_detail(order_id: int, user: CurrentUser, db: DbSession):
return ok(data=data)
@router.get("/{order_id}/bom")
async def order_bom(order_id: int, user: CurrentUser, db: DbSession):
"""订单 BOM 计算单(对齐 calcbomOrder):型材下料清单 + 玻璃/五金汇总。"""
try:
order = await OrderService(db).get_detail(user, order_id)
except KeyError as e:
return fail(str(e))
if not order.drawing_data:
return fail("该订单没有图纸数据")
from huamenchuang.core.drawing import parse_drawing
from huamenchuang.services.quote_service import bom as build_bom
try:
data = build_bom(parse_drawing(order.drawing_data))
except Exception:
return fail("图纸数据解析失败")
return ok(data={**data, "order_no": order.order_no}, message="BOM 生成成功")
@router.get("/{order_id}/export")
async def export_order(order_id: int, user: CurrentUser, db: DbSession):
"""订单导出 Excel(对齐 orders/export:信息 + BOM 下料清单)。"""
from fastapi.responses import Response
try:
order = await OrderService(db).get_detail(user, order_id)
except KeyError as e:
return fail(str(e))
if not order.drawing_data:
return fail("该订单没有图纸数据")
from huamenchuang.core.drawing import parse_drawing
from huamenchuang.services.excel_service import order_workbook
from huamenchuang.services.quote_service import bom as build_bom
try:
sheet = build_bom(parse_drawing(order.drawing_data))
except Exception:
return fail("图纸数据解析失败")
content = order_workbook(_to_out(order), sheet)
filename = f"order_{order.order_no}.xlsx"
return Response(
content,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f"attachment; filename={filename}"},
)
@router.put("/{order_id}")
async def update_order(order_id: int, body: OrderUpdate, user: CurrentUser, db: DbSession):
"""更新订单基础字段;action=change_status 时执行状态迁移。"""
@@ -0,0 +1,98 @@
"""Excel 生成服务(openpyxl):BOM 单、订单导出、业务数据导入导出共用工具。"""
import io
from typing import Any
from openpyxl import Workbook
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
from openpyxl.utils import get_column_letter
HEADER_FILL = PatternFill("solid", fgColor="2F5B8F")
HEADER_FONT = Font(color="FFFFFF", bold=True, size=11)
TITLE_FONT = Font(bold=True, size=14)
THIN = Side(style="thin", color="C0C0C0")
BORDER = Border(left=THIN, right=THIN, top=THIN, bottom=THIN)
def new_workbook() -> Workbook:
return Workbook()
def save_bytes(wb: Workbook) -> bytes:
"""工作簿 → xlsx 字节(供响应返回)。"""
buf = io.BytesIO()
wb.save(buf)
return buf.getvalue()
def write_title(ws: Any, row: int, text: str, span: int) -> int:
"""写合并标题行,返回下一行行号。"""
ws.cell(row=row, column=1, value=text).font = TITLE_FONT
ws.merge_cells(start_row=row, start_column=1, end_row=row, end_column=span)
ws.cell(row=row, column=1).alignment = Alignment(horizontal="center", vertical="center")
ws.row_dimensions[row].height = 24
return row + 1
def write_table(ws: Any, start_row: int, headers: list[str], rows: list[list[Any]],
widths: list[int] | None = None) -> int:
"""写带样式表格(表头填充+全边框),返回下一行行号。"""
for c, head in enumerate(headers, 1):
cell = ws.cell(row=start_row, column=c, value=head)
cell.fill = HEADER_FILL
cell.font = HEADER_FONT
cell.border = BORDER
cell.alignment = Alignment(horizontal="center", vertical="center")
for r, row_data in enumerate(rows, start_row + 1):
for c, v in enumerate(row_data, 1):
cell = ws.cell(row=r, column=c, value=v)
cell.border = BORDER
if isinstance(v, (int, float)):
cell.alignment = Alignment(horizontal="right")
if widths:
for c, w in enumerate(widths, 1):
ws.column_dimensions[get_column_letter(c)].width = w
return start_row + 1 + len(rows)
def order_workbook(order: dict, bom: dict) -> bytes:
"""订单 Excel:基本信息 + BOM 型材表 + 玻璃/五金汇总。"""
wb = new_workbook()
ws = wb.active
ws.title = "订单BOM"
row = write_title(ws, 1, f"生产订单 — {order['order_no']}", 5)
info_rows = [
["客户", order.get("customer_name") or "-", "电话", order.get("phone") or "-", ""],
["地址", order.get("address") or "-", "状态", order.get("status_label") or "-", ""],
["面积", f"{order.get('area') or 0} ㎡", "樘数", order.get("count") or 0, ""],
["窗型尺寸", f"{bom.get('width_mm', 0)} × {bom.get('height_mm', 0)} mm", "玻璃面积",
f"{bom.get('glass_area_m2', 0)} ㎡", ""],
["下单时间", str(order.get("created_at"))[:19], "金额", f"¥{order.get('money') or 0}", ""],
]
for r in info_rows:
for c, v in enumerate(r, 1):
cell = ws.cell(row=row, column=c, value=v)
if c in (1, 3):
cell.font = Font(bold=True)
row += 1
row += 1
row = write_title(ws, row, "型材下料清单", 5)
bar_rows = [[b["note"] or b["kind"], b["length"], b["qty"], b["length"] * b["qty"] / 1000, ""]
for b in bom.get("bars", [])]
row = write_table(ws, row, ["用途", "单件长度(mm)", "数量(件)", "总长(m)", "备注"], bar_rows,
widths=[18, 16, 12, 12, 20])
total_m = sum(b["length"] * b["qty"] for b in bom.get("bars", [])) / 1000
cell = ws.cell(row=row, column=1, value="型材合计")
cell.font = Font(bold=True)
ws.cell(row=row, column=4, value=round(total_m, 2)).font = Font(bold=True)
row += 2
row = write_title(ws, row, "玻璃/五金", 5)
glass_hw = [["玻璃", f"{bom.get('glass_area_m2', 0)} ㎡", "", "", ""],
["五金", f"{bom.get('sash_count', 0)} 套", "", "", ""]]
row = write_table(ws, row, ["项目", "数量", "", "", ""], glass_hw)
row += 1
ws.cell(row=row, column=1, value="制单:____________ 审核:____________ 日期:____________")
return save_bytes(wb)
@@ -129,6 +129,19 @@ def merge_bars(bars: list[BarItem]) -> list[dict]:
return sorted(merged.values(), key=lambda x: -x["length"])
def bom(drawing: str | dict) -> dict:
"""订单 BOM(对齐目标站 calcbomOrder):型材下料清单 + 玻璃/五金汇总(不含价格)。"""
dd = parse_drawing(drawing)
t = takeoff(dd)
return {
"bars": merge_bars(t.bars),
"glass_area_m2": round(t.glass_area_m2, 3),
"sash_count": t.sash_count,
"width_mm": t.width_mm,
"height_mm": t.height_mm,
}
def quote(drawing: str | dict, prices: dict | None = None, scripts: dict | None = None) -> dict:
"""完整报价:算料 + 套价(价格脚本可覆盖单项单价或总价)。