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
+1
View File
@@ -0,0 +1 @@
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodWFtZW5jaHVhbmcvYXBpL2xvZ2luIiwiaWF0IjoxNzkwMzMwODcwLCJuYmYiOjE3OTAzMzA4NzAsImV4cCI6MTc5MDQxNzI3MCwianRpIjoiNjFjZDJlYzg1YmQxNDYyOSIsInN1YiI6IjEifQ.tq972PS0j-k47fyx6i1Jkuvippom5ovjXAGkTxxYK04
+2
View File
@@ -1,5 +1,7 @@
# CHANGELOG # CHANGELOG
- [2026-09-25 11:40] FEAT: v2.21 P0-2 订单 BOM 计算单+Excel 导出。①quote_service.bom() 从 drawing_data 重放算料输出型材下料清单(calcbomOrder 语义); ②services/excel_service.py 通用 xlsx 工具(标题/带样式表格/列宽)+order_workbook 订单导出(信息+型材清单+玻璃五金汇总+签审行); ③GET /orders/{id}/bom + GET /orders/{id}/export(attachment 下载); ④前端订单详情抽屉加"BOM 单"弹窗(汇总+下料表+合计米数)+导出 Excel(apiDownload blob 带 token); openpyxl 入依赖; vue-tsc 0 错+构建通过 (Files: pyproject.toml, src/huamenchuang/services/excel_service.py, src/huamenchuang/services/quote_service.py, src/huamenchuang/api/orders.py, frontend/src/api/index.ts, frontend/src/views/OrdersView.vue, doc/API.md)
- [2026-09-25 11:00] FEAT: v2.20 P0-1 价格脚本引擎。①services/formula.py 安全公式引擎(递归下降解析, 无 eval; 四则/括号/min/max/round/abs/ceil/floor/sqrt; ALLOWED_VARS 变量白名单+validate_formula 语法校验); ②PriceScript 模型(系列名/target/frame~total 六作用项/formula/enabled)+种子 5 条示例; ③/api/scripts CRUD+preview 试算(product-test 语义); ④offer-price 集成 use_scripts/scripts 参数, 行级 scripted 标记+script_total 直取; ⑤产品管理页拆双 Tab(价格本/脚本 CRUD+试算), 报价弹窗"脚本计价"标记; 前端 vue-tsc 0 错+构建通过 (Files: src/huamenchuang/services/formula.py, src/huamenchuang/models/business.py, src/huamenchuang/api/script.py, src/huamenchuang/api/quote.py, src/huamenchuang/services/quote_service.py, src/huamenchuang/scripts/seed.py, frontend/src/views/ProductsView.vue, frontend/src/components/QuoteDialog.vue, doc/API.md) - [2026-09-25 11:00] FEAT: v2.20 P0-1 价格脚本引擎。①services/formula.py 安全公式引擎(递归下降解析, 无 eval; 四则/括号/min/max/round/abs/ceil/floor/sqrt; ALLOWED_VARS 变量白名单+validate_formula 语法校验); ②PriceScript 模型(系列名/target/frame~total 六作用项/formula/enabled)+种子 5 条示例; ③/api/scripts CRUD+preview 试算(product-test 语义); ④offer-price 集成 use_scripts/scripts 参数, 行级 scripted 标记+script_total 直取; ⑤产品管理页拆双 Tab(价格本/脚本 CRUD+试算), 报价弹窗"脚本计价"标记; 前端 vue-tsc 0 错+构建通过 (Files: src/huamenchuang/services/formula.py, src/huamenchuang/models/business.py, src/huamenchuang/api/script.py, src/huamenchuang/api/quote.py, src/huamenchuang/services/quote_service.py, src/huamenchuang/scripts/seed.py, frontend/src/views/ProductsView.vue, frontend/src/components/QuoteDialog.vue, doc/API.md)
- [2026-09-25 00:45] DOCS: 源站功能差异审计——重新提取源站主包(app.cd1d79e5.js)完整路由表 118 条+内嵌中文词典 2058 键, 与 280 接口清单/本地实现三方交叉核对, 产出未复刻功能清单 50 项(P0×8/P1×18/P2×24)及补齐顺序建议 (Files: doc/未复刻功能清单.md) - [2026-09-25 00:45] DOCS: 源站功能差异审计——重新提取源站主包(app.cd1d79e5.js)完整路由表 118 条+内嵌中文词典 2058 键, 与 280 接口清单/本地实现三方交叉核对, 产出未复刻功能清单 50 项(P0×8/P1×18/P2×24)及补齐顺序建议 (Files: doc/未复刻功能清单.md)
+7
View File
@@ -383,3 +383,10 @@ uv run uvicorn huamenchuang.main:app --reload # 启动,http://localhost:8000
| 报价启用脚本 | POST | `/api/offer-price` | 增加 `use_scripts: true` 或 `scripts: {target: formula}` | 响应加 `scripted[]`(被脚本覆盖的项)、`script_total`(总价是否被脚本直取);行级 `scripted` 标记 | | 报价启用脚本 | POST | `/api/offer-price` | 增加 `use_scripts: true` 或 `scripts: {target: formula}` | 响应加 `scripted[]`(被脚本覆盖的项)、`script_total`(总价是否被脚本直取);行级 `scripted` 标记 |
种子示例脚本 5 条:断桥铝-55系列(frame/mullion/sash)、双层中空玻璃(glass)、标准五金(hardware)。 种子示例脚本 5 条:断桥铝-55系列(frame/mullion/sash)、双层中空玻璃(glass)、标准五金(hardware)。
### 12.16 订单 BOM 与导出(v2.20 新增,P0-生产闭环)
| 接口名称 | 方式 | URL 路径 | 请求参数 | 说明 |
|---|---|---|---|---|
| **订单 BOM 单** | GET | `/api/orders/{id}/bom` | 路径 | 对齐目标站 calcbomOrder;返回 `{bars:[{kind,note,length,qty}], glass_area_m2, sash_count, width_mm, height_mm}`(从 drawing_data 重放算料,不含价格) |
| **订单导出 Excel** | GET | `/api/orders/{id}/export` | 路径 | 对齐 orders/export;xlsx(订单信息+BOM 下料清单+玻璃/五金汇总),`Content-Disposition: attachment` |
+10
View File
@@ -41,3 +41,13 @@ export async function api<T = any>(method: string, url: string, body?: object):
const res = await http.request({ method, url, data: body }) const res = await http.request({ method, url, data: body })
return res.data.data as T return res.data.data as T
} }
/** 下载二进制文件(自动带 token),filename 为保存文件名 */
export async function apiDownload(url: string, filename: string): Promise<void> {
const res = await http.get(url, { responseType: 'blob' })
const link = document.createElement('a')
link.href = URL.createObjectURL(res.data)
link.download = filename
link.click()
URL.revokeObjectURL(link.href)
}
+48 -1
View File
@@ -82,6 +82,7 @@
</el-descriptions> </el-descriptions>
<div class="canvas-actions"> <div class="canvas-actions">
<el-button size="small" type="success" plain @click="quoteRef.open({ order_id: detail.id })">算料报价</el-button> <el-button size="small" type="success" plain @click="quoteRef.open({ order_id: detail.id })">算料报价</el-button>
<el-button size="small" type="primary" plain @click="openBom">BOM 单</el-button>
<el-button size="small" @click="printOrder">打印订单</el-button> <el-button size="small" @click="printOrder">打印订单</el-button>
</div> </div>
<el-tabs model-value="canvas"> <el-tabs model-value="canvas">
@@ -130,6 +131,35 @@
</el-tabs> </el-tabs>
</div> </div>
</el-drawer> </el-drawer>
<!-- BOM 计算单 -->
<el-dialog v-model="bomVisible" :title="`BOM 计算单 — ${bom?.order_no ?? ''}`" width="640px">
<div v-if="bom" v-loading="bomLoading">
<el-descriptions :column="3" border size="small">
<el-descriptions-item label="窗型尺寸">{{ bom.width_mm }} × {{ bom.height_mm }} mm</el-descriptions-item>
<el-descriptions-item label="玻璃面积">{{ bom.glass_area_m2 }} ㎡</el-descriptions-item>
<el-descriptions-item label="五金">{{ bom.sash_count }} 套</el-descriptions-item>
</el-descriptions>
<el-table :data="bom.bars" size="small" border style="margin-top:10px">
<el-table-column prop="note" label="用途" min-width="120">
<template #default="{ row }">{{ row.note || row.kind }}</template>
</el-table-column>
<el-table-column prop="length" label="单件长度(mm)" width="120" />
<el-table-column prop="qty" label="数量(件)" width="100" />
<el-table-column label="总长(m)" width="100">
<template #default="{ row }">{{ (row.length * row.qty / 1000).toFixed(2) }}</template>
</el-table-column>
</el-table>
<div class="bom-total">
型材合计:{{ ((bom.bars ?? []).reduce((s: number, b: any) => s + b.length * b.qty, 0) / 1000).toFixed(2) }} m
</div>
</div>
<template #footer>
<el-button @click="bomVisible = false">关闭</el-button>
<el-button type="primary" @click="exportOrder">导出 Excel</el-button>
</template>
</el-dialog>
<QuoteDialog ref="quoteRef" /> <QuoteDialog ref="quoteRef" />
</div> </div>
</template> </template>
@@ -138,7 +168,7 @@
import { onMounted, reactive, ref } from 'vue' import { onMounted, reactive, ref } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { api } from '../api' import { api, apiDownload } from '../api'
import WindoorCanvas from '../components/WindoorCanvas.vue' import WindoorCanvas from '../components/WindoorCanvas.vue'
import QuoteDialog from '../components/QuoteDialog.vue' import QuoteDialog from '../components/QuoteDialog.vue'
@@ -315,6 +345,22 @@ async function updateProcess() {
processActive.value = detail.value.processes.filter((p: any) => p.status === 2).length processActive.value = detail.value.processes.filter((p: any) => p.status === 2).length
} }
// ---- BOM 计算单 ----
const bomVisible = ref(false)
const bomLoading = ref(false)
const bom = ref<any>(null)
async function openBom() {
bomVisible.value = true
bomLoading.value = true
try { bom.value = await api<any>('get', `/api/orders/${detail.value.id}/bom`) } finally { bomLoading.value = false }
}
async function exportOrder() {
await apiDownload(`/api/orders/${detail.value.id}/export`, `order_${detail.value.order_no}.xlsx`)
ElMessage.success('已导出')
}
onMounted(load) onMounted(load)
</script> </script>
@@ -325,4 +371,5 @@ onMounted(load)
.empty-tip { height: 100%; display: flex; align-items: center; justify-content: center; color: #909399; font-size: 14px; } .empty-tip { height: 100%; display: flex; align-items: center; justify-content: center; color: #909399; font-size: 14px; }
.tip { color: #909399; font-size: 12px; } .tip { color: #909399; font-size: 12px; }
.live-price { color: #e6483d; font-size: 15px; font-weight: 700; } .live-price { color: #e6483d; font-size: 15px; font-weight: 700; }
.bom-total { margin-top: 8px; text-align: right; font-weight: 600; color: #303133; }
</style> </style>
+1
View File
@@ -12,6 +12,7 @@ dependencies = [
"pydantic>=2.7", "pydantic>=2.7",
"pydantic-settings>=2.3", "pydantic-settings>=2.3",
"pyjwt>=2.8", "pyjwt>=2.8",
"openpyxl>=3.1",
] ]
[project.scripts] [project.scripts]
+44
View File
@@ -75,6 +75,50 @@ async def order_detail(order_id: int, user: CurrentUser, db: DbSession):
return ok(data=data) 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}") @router.put("/{order_id}")
async def update_order(order_id: int, body: OrderUpdate, user: CurrentUser, db: DbSession): async def update_order(order_id: int, body: OrderUpdate, user: CurrentUser, db: DbSession):
"""更新订单基础字段;action=change_status 时执行状态迁移。""" """更新订单基础字段;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"]) 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: def quote(drawing: str | dict, prices: dict | None = None, scripts: dict | None = None) -> dict:
"""完整报价:算料 + 套价(价格脚本可覆盖单项单价或总价)。 """完整报价:算料 + 套价(价格脚本可覆盖单项单价或总价)。
Generated
+23
View File
@@ -51,6 +51,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" },
] ]
[[package]]
name = "et-xmlfile"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" },
]
[[package]] [[package]]
name = "fastapi" name = "fastapi"
version = "0.141.1" version = "0.141.1"
@@ -144,6 +153,7 @@ source = { editable = "." }
dependencies = [ dependencies = [
{ name = "aiosqlite" }, { name = "aiosqlite" },
{ name = "fastapi" }, { name = "fastapi" },
{ name = "openpyxl" },
{ name = "pydantic" }, { name = "pydantic" },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
{ name = "pyjwt" }, { name = "pyjwt" },
@@ -155,6 +165,7 @@ dependencies = [
requires-dist = [ requires-dist = [
{ name = "aiosqlite", specifier = ">=0.20" }, { name = "aiosqlite", specifier = ">=0.20" },
{ name = "fastapi", specifier = ">=0.115" }, { name = "fastapi", specifier = ">=0.115" },
{ name = "openpyxl", specifier = ">=3.1" },
{ name = "pydantic", specifier = ">=2.7" }, { name = "pydantic", specifier = ">=2.7" },
{ name = "pydantic-settings", specifier = ">=2.3" }, { name = "pydantic-settings", specifier = ">=2.3" },
{ name = "pyjwt", specifier = ">=2.8" }, { name = "pyjwt", specifier = ">=2.8" },
@@ -171,6 +182,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" },
] ]
[[package]]
name = "openpyxl"
version = "3.1.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "et-xmlfile" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" },
]
[[package]] [[package]]
name = "pydantic" name = "pydantic"
version = "2.13.5" version = "2.13.5"