From d5e3cdbeded365b6f1bb6fe59d4b6187baea5bf3 Mon Sep 17 00:00:00 2001 From: HHY Date: Fri, 25 Sep 2026 03:09:21 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20P0-2=20=E8=AE=A2=E5=8D=95BOM=E8=AE=A1?= =?UTF-8?q?=E7=AE=97=E5=8D=95+Excel=E5=AF=BC=E5=87=BA(openpyxl)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .tmp_token | 1 + CHANGELOG | 2 + doc/API.md | 7 ++ frontend/src/api/index.ts | 10 +++ frontend/src/views/OrdersView.vue | 49 ++++++++++- pyproject.toml | 1 + src/huamenchuang/api/orders.py | 44 ++++++++++ src/huamenchuang/services/excel_service.py | 98 ++++++++++++++++++++++ src/huamenchuang/services/quote_service.py | 13 +++ uv.lock | 23 +++++ 10 files changed, 247 insertions(+), 1 deletion(-) create mode 100644 .tmp_token create mode 100644 src/huamenchuang/services/excel_service.py diff --git a/.tmp_token b/.tmp_token new file mode 100644 index 0000000..28c4952 --- /dev/null +++ b/.tmp_token @@ -0,0 +1 @@ +eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodWFtZW5jaHVhbmcvYXBpL2xvZ2luIiwiaWF0IjoxNzkwMzMwODcwLCJuYmYiOjE3OTAzMzA4NzAsImV4cCI6MTc5MDQxNzI3MCwianRpIjoiNjFjZDJlYzg1YmQxNDYyOSIsInN1YiI6IjEifQ.tq972PS0j-k47fyx6i1Jkuvippom5ovjXAGkTxxYK04 diff --git a/CHANGELOG b/CHANGELOG index a30bb6a..79b2031 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,7 @@ # 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 00:45] DOCS: 源站功能差异审计——重新提取源站主包(app.cd1d79e5.js)完整路由表 118 条+内嵌中文词典 2058 键, 与 280 接口清单/本地实现三方交叉核对, 产出未复刻功能清单 50 项(P0×8/P1×18/P2×24)及补齐顺序建议 (Files: doc/未复刻功能清单.md) diff --git a/doc/API.md b/doc/API.md index 9d030b5..1162a41 100644 --- a/doc/API.md +++ b/doc/API.md @@ -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` 标记 | 种子示例脚本 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` | diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 8477d6b..3bf4fc7 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -41,3 +41,13 @@ export async function api(method: string, url: string, body?: object): const res = await http.request({ method, url, data: body }) return res.data.data as T } + +/** 下载二进制文件(自动带 token),filename 为保存文件名 */ +export async function apiDownload(url: string, filename: string): Promise { + 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) +} diff --git a/frontend/src/views/OrdersView.vue b/frontend/src/views/OrdersView.vue index 5cdb7f8..d38049e 100644 --- a/frontend/src/views/OrdersView.vue +++ b/frontend/src/views/OrdersView.vue @@ -82,6 +82,7 @@
算料报价 + BOM 单 打印订单
@@ -130,6 +131,35 @@ + + + +
+ + {{ bom.width_mm }} × {{ bom.height_mm }} mm + {{ bom.glass_area_m2 }} ㎡ + {{ bom.sash_count }} 套 + + + + + + + + + + + +
+ 型材合计:{{ ((bom.bars ?? []).reduce((s: number, b: any) => s + b.length * b.qty, 0) / 1000).toFixed(2) }} m +
+
+ +
+ @@ -138,7 +168,7 @@ import { onMounted, reactive, ref } from 'vue' import { useRoute } from 'vue-router' import { ElMessage, ElMessageBox } from 'element-plus' -import { api } from '../api' +import { api, apiDownload } from '../api' import WindoorCanvas from '../components/WindoorCanvas.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 } +// ---- BOM 计算单 ---- +const bomVisible = ref(false) +const bomLoading = ref(false) +const bom = ref(null) + +async function openBom() { + bomVisible.value = true + bomLoading.value = true + try { bom.value = await api('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) @@ -325,4 +371,5 @@ onMounted(load) .empty-tip { height: 100%; display: flex; align-items: center; justify-content: center; color: #909399; font-size: 14px; } .tip { color: #909399; font-size: 12px; } .live-price { color: #e6483d; font-size: 15px; font-weight: 700; } +.bom-total { margin-top: 8px; text-align: right; font-weight: 600; color: #303133; } diff --git a/pyproject.toml b/pyproject.toml index 376d1a9..4fb0ce3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ dependencies = [ "pydantic>=2.7", "pydantic-settings>=2.3", "pyjwt>=2.8", + "openpyxl>=3.1", ] [project.scripts] diff --git a/src/huamenchuang/api/orders.py b/src/huamenchuang/api/orders.py index 4bae10f..de4f2c9 100644 --- a/src/huamenchuang/api/orders.py +++ b/src/huamenchuang/api/orders.py @@ -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 时执行状态迁移。""" diff --git a/src/huamenchuang/services/excel_service.py b/src/huamenchuang/services/excel_service.py new file mode 100644 index 0000000..25df88d --- /dev/null +++ b/src/huamenchuang/services/excel_service.py @@ -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) diff --git a/src/huamenchuang/services/quote_service.py b/src/huamenchuang/services/quote_service.py index 22054b5..f2c5c2d 100644 --- a/src/huamenchuang/services/quote_service.py +++ b/src/huamenchuang/services/quote_service.py @@ -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: """完整报价:算料 + 套价(价格脚本可覆盖单项单价或总价)。 diff --git a/uv.lock b/uv.lock index 357730f..05e86ea 100644 --- a/uv.lock +++ b/uv.lock @@ -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" }, ] +[[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]] name = "fastapi" version = "0.141.1" @@ -144,6 +153,7 @@ source = { editable = "." } dependencies = [ { name = "aiosqlite" }, { name = "fastapi" }, + { name = "openpyxl" }, { name = "pydantic" }, { name = "pydantic-settings" }, { name = "pyjwt" }, @@ -155,6 +165,7 @@ dependencies = [ requires-dist = [ { name = "aiosqlite", specifier = ">=0.20" }, { name = "fastapi", specifier = ">=0.115" }, + { name = "openpyxl", specifier = ">=3.1" }, { name = "pydantic", specifier = ">=2.7" }, { name = "pydantic-settings", specifier = ">=2.3" }, { 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" }, ] +[[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]] name = "pydantic" version = "2.13.5"