feat: P0-2 订单BOM计算单+Excel导出(openpyxl)
This commit is contained in:
@@ -0,0 +1 @@
|
||||
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodWFtZW5jaHVhbmcvYXBpL2xvZ2luIiwiaWF0IjoxNzkwMzMwODcwLCJuYmYiOjE3OTAzMzA4NzAsImV4cCI6MTc5MDQxNzI3MCwianRpIjoiNjFjZDJlYzg1YmQxNDYyOSIsInN1YiI6IjEifQ.tq972PS0j-k47fyx6i1Jkuvippom5ovjXAGkTxxYK04
|
||||
@@ -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)
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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 })
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -82,6 +82,7 @@
|
||||
</el-descriptions>
|
||||
<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="primary" plain @click="openBom">BOM 单</el-button>
|
||||
<el-button size="small" @click="printOrder">打印订单</el-button>
|
||||
</div>
|
||||
<el-tabs model-value="canvas">
|
||||
@@ -130,6 +131,35 @@
|
||||
</el-tabs>
|
||||
</div>
|
||||
</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" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -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<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)
|
||||
</script>
|
||||
|
||||
@@ -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; }
|
||||
</style>
|
||||
|
||||
@@ -12,6 +12,7 @@ dependencies = [
|
||||
"pydantic>=2.7",
|
||||
"pydantic-settings>=2.3",
|
||||
"pyjwt>=2.8",
|
||||
"openpyxl>=3.1",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
||||
@@ -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:
|
||||
"""完整报价:算料 + 套价(价格脚本可覆盖单项单价或总价)。
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user