From 099496dec916f9c65ebcaef906fc729c708f19fc Mon Sep 17 00:00:00 2001 From: star <395723461@qq.com> Date: Thu, 22 Oct 2026 14:02:04 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=94=9F=E4=BA=A7=E8=AE=A2=E5=8D=95?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=AF=BC=E5=87=BA=20Excel=20=E4=B8=8E?= =?UTF-8?q?=E6=89=93=E5=8D=B0=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 订单列表页「导出 Excel」(/orders/export): 导出全部生产订单(订单号/BOM/接单量/工厂/状态/经手人/下单时间/备注) - 订单详情页「导出 Excel」(/order//export): 导出派工单, 含「订单信息」+「领料明细」双 sheet - 订单详情页「打印」按钮: window.print() + @media print 样式(隐藏导航/按钮, 输入框转纯文本, 隐藏提示), 输出干净派工单 --- app.py | 55 ++++++++++++++++++++++ excel_io.py | 93 +++++++++++++++++++++++++++++++++++++ templates/order_detail.html | 24 +++++++++- templates/orders.html | 1 + 4 files changed, 171 insertions(+), 2 deletions(-) diff --git a/app.py b/app.py index 1a5da7c..1df73be 100644 --- a/app.py +++ b/app.py @@ -1468,6 +1468,23 @@ def orders(): return render_template("orders.html", data=data) +@app.route("/orders/export") +@login_required +@role_required("admin", "manager") +def orders_export(): + """导出全部生产订单为 Excel。""" + os_ = db.session.scalars(db.select(ProdOrder).order_by(ProdOrder.order_no.desc())).all() + data = [] + for o in os_: + bom = db.session.get(Bom, o.bom_id) + fac = db.session.get(Factory, o.factory_id) if o.factory_id else None + data.append({"o": o, "bom": bom, "factory": fac}) + from excel_io import export_orders + path = os.path.join(EXE_DIR, f"生产订单_{date.today().isoformat()}.xlsx") + export_orders(path, data) + return send_file(path, as_attachment=True, download_name=os.path.basename(path)) + + @app.route("/order/new", methods=["GET", "POST"]) @login_required @role_required("admin", "manager") @@ -1540,6 +1557,44 @@ def order_detail(oid): posted_count=posted_count) +@app.route("/order//export") +@login_required +@role_required("admin", "manager") +def order_export(oid): + """导出单个生产订单详情(派工单)为 Excel。""" + o = db.session.get(ProdOrder, oid) + if not o: + return redirect(url_for("orders")) + bom = db.session.get(Bom, o.bom_id) + items = db.session.scalars(db.select(BomItem).where(BomItem.bom_id == o.bom_id)).all() + sm = stock_map_as_of(o.factory_id, o.created_at) + fac = db.session.get(Factory, o.factory_id) if o.factory_id else None + allocs = db.session.scalars( + db.select(ProdOrderAlloc).where(ProdOrderAlloc.order_id == oid)).all() + alloc_map = {a.material_id: a for a in allocs} + reqs = [] + for it in items: + m = db.session.get(Material, it.material_id) + need = it.qty_per_unit * o.qty + have = sm.get(m.id, 0) + a = alloc_map.get(m.id) + subs = [{"pn": s["sub"].part_number, "mf": s["sub"].manufacturer or "-", + "stock": round(s["stock"], 3), "available": s["available"]} + for s in substitute_rows(m.id, o.factory_id, None)] + reqs.append({"m": m, "need": need, "have": have, + "gap": have - need, "short": need > have, "subs": subs, + "alloc": a.alloc_qty if a else 0, + "posted": a.posted if a else False}) + reqs.sort(key=lambda x: x["gap"]) + posted_total = sum(a.alloc_qty for a in allocs if a.posted) + planned_total = sum(a.alloc_qty for a in allocs) + posted_count = sum(1 for a in allocs if a.posted) + from excel_io import export_order_detail + path = os.path.join(EXE_DIR, f"生产订单_{o.order_no}.xlsx") + export_order_detail(path, o, bom, reqs, fac, posted_total, planned_total, posted_count) + return send_file(path, as_attachment=True, download_name=os.path.basename(path)) + + @app.route("/order//pick", methods=["POST"]) @login_required @role_required("admin", "manager") diff --git a/excel_io.py b/excel_io.py index 32ce7ec..57b4e66 100644 --- a/excel_io.py +++ b/excel_io.py @@ -327,6 +327,99 @@ def export_ledger(target, rows): wb.save(target) +def export_orders(target, data): + """导出生产订单列表。 + + data: [{o, bom, factory}] (与 orders() 返回一致)。 + """ + wb = Workbook() + ws = wb.active + ws.title = "生产订单" + head = ["订单号", "BOM型号", "BOM名称", "接单量", "生产工厂", "状态", + "经手人", "下单时间", "备注"] + ws.append(head) + for c in ws[1]: + c.font = Font(bold=True) + c.fill = PatternFill("solid", fgColor="DDEBF7") + for d in data: + o = d["o"] + bom = d["bom"] + fac = d["factory"] + status = "已领料" if o.status == "picked" else "待领料" + ws.append([ + o.order_no, + bom.code if bom else "", + bom.name if bom else "", + o.qty, + (f"{fac.code}·{fac.name}" if fac else "未指定"), + status, + o.operator or "", + o.created_at.strftime("%Y-%m-%d %H:%M") if o.created_at else "", + o.remark or "", + ]) + if o.status == "picked": + ws.cell(row=ws.max_row, column=6).fill = PatternFill("solid", fgColor="C6EFCE") + else: + ws.cell(row=ws.max_row, column=6).fill = PatternFill("solid", fgColor="FFE699") + ws.freeze_panes = "A2" + wb.save(target) + + +def export_order_detail(target, o, bom, reqs, factory, + posted_total, planned_total, posted_count): + """导出单个生产订单详情(派工单):订单信息 sheet + 领料明细 sheet。""" + wb = Workbook() + ws = wb.active + ws.title = "订单信息" + ws.append(["生产订单派工单"]) + ws["A1"].font = Font(bold=True, size=14) + info = [ + ("订单号", o.order_no), + ("BOM型号", (bom.code if bom else "-") + (f" · {bom.name}" if bom and bom.name else "")), + ("接单量", o.qty), + ("生产工厂", (f"{factory.code}·{factory.name}" if factory else "未指定(按汇总库存)")), + ("经手人", o.operator or "-"), + ("下单时间", o.created_at.strftime("%Y-%m-%d %H:%M") if o.created_at else ""), + ("状态", "已领料" if o.status == "picked" else "待领料"), + ("备注", o.remark or "-"), + ("调入总览", f"已调入 {posted_count} 项 / 合计 {posted_total} 件;计划调入合计 {planned_total} 件"), + ] + r = 3 + for k, v in info: + ws.cell(row=r, column=1, value=k).font = Font(bold=True) + ws.cell(row=r, column=2, value=v) + r += 1 + + ws2 = wb.create_sheet("领料明细") + head = ["物料编号", "型号", "下单前库存", "需求数量", "缺口", + "调入数量", "状态", "工厂库存", "可替代料"] + ws2.append(head) + for c in ws2[1]: + c.font = Font(bold=True) + c.fill = PatternFill("solid", fgColor="DDEBF7") + for x in reqs: + subs = "、".join( + f"{s['pn']}·{s['stock']}{'✓' if s['available'] else '✗'}" for s in x["subs"] + ) if x["subs"] else "—" + ws2.append([ + x["m"].part_number, + x["m"].manufacturer or "-", + round(x["have"], 3), + round(x["need"], 3), + round(x["gap"], 3), + round(x["alloc"], 3), + "缺料" if x["short"] else "充足", + round(x["gap"] + x["alloc"], 3), + subs, + ]) + if x["short"]: + ws2.cell(row=ws2.max_row, column=7).fill = PatternFill("solid", fgColor="FFC7CE") + else: + ws2.cell(row=ws2.max_row, column=7).fill = PatternFill("solid", fgColor="C6EFCE") + ws2.freeze_panes = "A2" + wb.save(target) + + def build_bom_template(path): """生成与真实 BOM 模板一致的格式(Altium/嘉立创导出): 每个 worksheet 一个 BOM, sheet 名即 BOM 型号; 列头 Item/Part Number/ diff --git a/templates/order_detail.html b/templates/order_detail.html index bf039c1..c830d15 100644 --- a/templates/order_detail.html +++ b/templates/order_detail.html @@ -1,16 +1,36 @@ {% extends "base.html" %} {% block title %}订单 {{ o.order_no }}{% endblock %} {% block content %} +

生产订单 {{ o.order_no }}

{{ '已领料' if o.status=='picked' else '待领料' }} 编辑订单 + 导出 Excel +

下单信息(按下单时记录)

BOM: {{ bom.code if bom else '-' }}{% if bom and bom.name %} · {{ bom.name }}{% endif %} | 接单量: {{ '%.2f'|format(o.qty) }} | 生产工厂: {% if factory %}{{ factory.code }}{% if factory.name %} · {{ factory.name }}{% endif %}{% else %}未指定(按汇总库存){% endif %} | 经手人: {{ o.operator or '-' }}{% if o.created_at %} | 下单时间: {{ o.created_at.strftime('%Y-%m-%d %H:%M') }}{% endif %}

{% if o.remark %}

备注: {{ o.remark }}

{% endif %} -

提示:点击「编辑订单」保存后,该订单会冲销已产生的调入/领料流水并清空调入计划,自动回到「刚下单」状态(待领料、未调拨),可重新规划。

+

领料需求与调入 (单套用量 × 接单量){% if factory %} | 按下单前库存核算{% endif %}

@@ -73,7 +93,7 @@ formaction="{{ url_for('order_alloc_post', oid=o.id) }}" onclick="return confirm('确认将待调入数量从上方所选来源工厂调拨到生产工厂? 总库存不变, 仅厂间转移。');">确认调入(厂间调拨) -

「确认调入」是一次厂间调拨:从来源工厂出库减、生产工厂入库加,系统总库存不变。

+
diff --git a/templates/orders.html b/templates/orders.html index d06c666..f3ce88f 100644 --- a/templates/orders.html +++ b/templates/orders.html @@ -4,6 +4,7 @@

生产订单 ({{ data|length }})

+ 新建订单 + 导出 Excel
订单号BOM接单量生产工厂状态经手人操作