feat: 生产订单支持一次多选 BOM 且各 BOM 独立设置生产数量

- 新增中间表 prod_order_bom(order_id, bom_id, qty),一个订单可挂多个 BOM,每个 BOM 单独设生产数量
- prod_order.qty 改为各 BOM 数量汇总,bom_id 仅作主 BOM 冗余摘要
- 物料需求按 Σ(BOM 单套用量 × 各自生产数量) 跨 BOM 合并聚合,覆盖详情/领料/调入/导出
- 订单表单改为动态多行(BOM 下拉 + 数量输入 + 删除行),列表与导出展示「BOM清单(型号×数量)」
- 导出 Excel 增至 3 个 sheet:订单信息 / BOM清单 / 领料明细
- 修复 _parse_bom_pairs 表单字段名 bom_id[]/qty[] 解析;order_delete 一并级联删除 prod_order_bom
- build_exe.py 增加构建后自动拷贝 dist_pkg→dist(EXE 同目录即数据库所在)

Co-Authored-By: WorkBuddy <workbuddy@tencent.com>
This commit is contained in:
2026-10-26 10:24:25 +08:00
co-authored by WorkBuddy
parent 099496dec9
commit 93f3ef458b
7 changed files with 279 additions and 121 deletions
+164 -82
View File
@@ -20,7 +20,7 @@ from flask import (Flask, render_template, request, redirect, url_for,
from sqlalchemy import update
from models import (db, User, Material, Inbound, Outbound,
Bom, BomItem, ProdOrder, ProdOrderAlloc, OperationLog, Factory, Transfer, Substitute, PrepPlan)
Bom, BomItem, ProdOrder, ProdOrderAlloc, ProdOrderBom, OperationLog, Factory, Transfer, Substitute, PrepPlan)
# ---------------- 路径(兼容 PyInstaller 冻结) ----------------
if getattr(sys, "frozen", False):
@@ -1462,9 +1462,10 @@ def orders():
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)
lines = order_bom_lines(o)
fac = db.session.get(Factory, o.factory_id) if o.factory_id else None
data.append({"o": o, "bom": bom, "factory": fac})
total = sum(q for _, q in lines)
data.append({"o": o, "lines": lines, "factory": fac, "total": total})
return render_template("orders.html", data=data)
@@ -1476,9 +1477,10 @@ def orders_export():
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)
lines = order_bom_lines(o)
fac = db.session.get(Factory, o.factory_id) if o.factory_id else None
data.append({"o": o, "bom": bom, "factory": fac})
total = sum(q for _, q in lines)
data.append({"o": o, "lines": lines, "factory": fac, "total": total})
from excel_io import export_orders
path = os.path.join(EXE_DIR, f"生产订单_{date.today().isoformat()}.xlsx")
export_orders(path, data)
@@ -1492,24 +1494,30 @@ def order_new():
boms_all = db.session.scalars(db.select(Bom).order_by(Bom.code)).all()
factories = db.session.scalars(db.select(Factory).order_by(Factory.code)).all()
if request.method == "POST":
bom_id = int(request.form["bom_id"])
qty = float(request.form["qty"])
pairs = _parse_bom_pairs(request.form)
fid_raw = (request.form.get("factory_id") or "").strip()
factory_id = int(fid_raw) if fid_raw.isdigit() else None
if qty <= 0:
flash("接单量必须大于 0", "error")
if not pairs:
flash("请至少选择一个 BOM 并填写大于 0 的生产数量", "error")
else:
order_no = request.form.get("order_no", "").strip() or _gen_order_no()
if db.session.scalar(db.select(ProdOrder).where(ProdOrder.order_no == order_no)):
order_no = _gen_order_no()
o = ProdOrder(order_no=order_no, bom_id=bom_id, factory_id=factory_id, qty=qty,
status="planned", operator=(g.user.display_name or g.user.username),
total = sum(q for _, q in pairs)
o = ProdOrder(order_no=order_no, bom_id=pairs[0][0], factory_id=factory_id,
qty=total, status="planned",
operator=(g.user.display_name or g.user.username),
remark=request.form.get("remark", "").strip())
db.session.add(o)
db.session.flush() # 需要 o.id 供关联行使用
for bid, qv in pairs:
db.session.add(ProdOrderBom(order_id=o.id, bom_id=bid, qty=qv))
db.session.commit()
log_action("新建生产订单", order_no, f"接单量 {qty}")
log_action("新建生产订单", order_no,
f"含 {len(pairs)} 个BOM, 总接单量 {total}")
return redirect(url_for("order_detail", oid=o.id))
return render_template("order_form.html", boms=boms_all, factories=factories, o=None)
return render_template("order_form.html", boms=boms_all, factories=factories,
o=None, initial_lines=[])
@app.route("/order/<int:oid>")
@@ -1519,8 +1527,8 @@ def order_detail(oid):
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()
lines = order_bom_lines(o)
total_qty = sum(q for _, q in lines)
# 查看订单时, 库存/缺口按「下单之前」的工厂库存快照展示(即按下单那一刻的库存),
# 按下单时间截断, 自动排除该订单自身后续的调入/领料及下单后发生的其他出入库,
# 用于还原下单时的可供料情况, 不受后续出入库影响。
@@ -1531,9 +1539,7 @@ def order_detail(oid):
db.select(ProdOrderAlloc).where(ProdOrderAlloc.order_id == oid)).all()
alloc_map = {a.material_id: a for a in allocs} # material_id -> alloc 行
reqs = []
for it in items:
m = db.session.get(Material, it.material_id)
need = it.qty_per_unit * o.qty
for m, need in order_aggregated_materials(o):
have = sm.get(m.id, 0)
a = alloc_map.get(m.id)
subs = [{"pn": s["sub"].part_number, "mf": s["sub"].manufacturer or "-",
@@ -1551,8 +1557,8 @@ def order_detail(oid):
posted_count = sum(1 for a in allocs if a.posted)
factories = db.session.scalars(
db.select(Factory).where(Factory.enabled == True).order_by(Factory.code)).all()
return render_template("order_detail.html", o=o, bom=bom, reqs=reqs, factory=fac,
factories=factories,
return render_template("order_detail.html", o=o, lines=lines, total_qty=total_qty,
reqs=reqs, factory=fac, factories=factories,
posted_total=posted_total, planned_total=planned_total,
posted_count=posted_count)
@@ -1565,17 +1571,15 @@ def order_export(oid):
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()
lines = order_bom_lines(o)
total_qty = sum(q for _, q in lines)
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
for m, need in order_aggregated_materials(o):
have = sm.get(m.id, 0)
a = alloc_map.get(m.id)
subs = [{"pn": s["sub"].part_number, "mf": s["sub"].manufacturer or "-",
@@ -1591,7 +1595,7 @@ def order_export(oid):
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)
export_order_detail(path, o, lines, reqs, fac, posted_total, planned_total, posted_count)
return send_file(path, as_attachment=True, download_name=os.path.basename(path))
@@ -1602,13 +1606,10 @@ def order_pick(oid):
o = db.session.get(ProdOrder, oid)
if not o or o.status == "picked":
return redirect(url_for("orders"))
items = db.session.scalars(db.select(BomItem).where(BomItem.bom_id == o.bom_id)).all()
sm = stock_map(o.factory_id)
picks = 0
shorts = []
for it in items:
m = db.session.get(Material, it.material_id)
need = it.qty_per_unit * o.qty
for m, need in order_aggregated_materials(o):
if need <= 0:
continue
db.session.add(Outbound(material_id=m.id, factory_id=o.factory_id, qty=need,
@@ -1638,51 +1639,57 @@ def order_edit(oid):
boms_all = db.session.scalars(db.select(Bom).order_by(Bom.code)).all()
factories = db.session.scalars(db.select(Factory).order_by(Factory.code)).all()
if request.method == "POST":
bom_id = int(request.form["bom_id"])
qty = float(request.form["qty"])
pairs = _parse_bom_pairs(request.form)
fid_raw = (request.form.get("factory_id") or "").strip()
factory_id = int(fid_raw) if fid_raw.isdigit() else None
if qty <= 0:
flash("接单量必须大于 0", "error")
if not pairs:
flash("请至少选择一个 BOM 并填写大于 0 的生产数量", "error")
return render_template("order_form.html", boms=boms_all, factories=factories,
o=o, initial_lines=[[b.id, q] for b, q in order_bom_lines(o)])
old_no = o.order_no
order_no = request.form.get("order_no", "").strip() or o.order_no
if order_no != o.order_no and db.session.scalar(
db.select(ProdOrder).where(ProdOrder.order_no == order_no)):
flash(f"订单号 {order_no} 已存在, 已保留原单号", "warn")
order_no = o.order_no
o.order_no = order_no
o.bom_id = pairs[0][0]
o.factory_id = factory_id
o.qty = sum(q for _, q in pairs)
o.remark = request.form.get("remark", "").strip()
# 同步 BOM 行(删除旧行, 按表单重建); 已录入的「调入数量」计划与已入账流水保持不变
db.session.execute(
db.delete(ProdOrderBom).where(ProdOrderBom.order_id == o.id))
for bid, qv in pairs:
db.session.add(ProdOrderBom(order_id=o.id, bom_id=bid, qty=qv))
# (需求: 改 BOM/改数量均不冲销调入计划与已入账流水, 它们保持编辑前的值)
if order_no != old_no:
# 备份后, 把关联流水(订单号作为关联键 order_ref)同步为新单号, 使其余数据保持一致
try:
_bak = os.path.join(EXE_DIR, "_backup",
f"edit_bak_{datetime.now():%Y%m%d_%H%M%S}.db")
os.makedirs(os.path.dirname(_bak), exist_ok=True)
shutil.copy(db_path, _bak)
except Exception:
_bak = None
db.session.execute(
update(Inbound).where(Inbound.order_ref == old_no)
.values(order_ref=order_no))
db.session.execute(
update(Outbound).where(Outbound.order_ref == old_no)
.values(order_ref=order_no))
log_action("编辑生产订单", order_no,
f"含 {len(pairs)} 个BOM, 总接单量 {o.qty}; 订单号 {old_no}→{order_no}, 已同步关联流水 order_ref"
+ (f"; 备份 {os.path.basename(_bak)}" if _bak else ""))
flash(f"订单已更新(订单号 {old_no}→{order_no}),调入数量与已入账流水保持不变", "ok")
else:
old_no = o.order_no
order_no = request.form.get("order_no", "").strip() or o.order_no
if order_no != o.order_no and db.session.scalar(
db.select(ProdOrder).where(ProdOrder.order_no == order_no)):
flash(f"订单号 {order_no} 已存在, 已保留原单号", "warn")
order_no = o.order_no
o.order_no = order_no
o.bom_id = bom_id
o.factory_id = factory_id
o.qty = qty
o.remark = request.form.get("remark", "").strip()
# 仅更新订单头; 已录入的「调入数量」计划与已入账流水保持不变
# (需求: 改订单号/改生产数量均不冲销, 调入数量保持编辑前的值)。
if order_no != old_no:
# 备份后, 把关联流水(订单号作为关联键 order_ref)同步为新单号, 使其余数据保持一致
try:
_bak = os.path.join(EXE_DIR, "_backup",
f"edit_bak_{datetime.now():%Y%m%d_%H%M%S}.db")
os.makedirs(os.path.dirname(_bak), exist_ok=True)
shutil.copy(db_path, _bak)
except Exception:
_bak = None
db.session.execute(
update(Inbound).where(Inbound.order_ref == old_no)
.values(order_ref=order_no))
db.session.execute(
update(Outbound).where(Outbound.order_ref == old_no)
.values(order_ref=order_no))
log_action("编辑生产订单", order_no,
f"接单量 {qty}; 订单号 {old_no}→{order_no}, 已同步关联流水 order_ref"
+ (f"; 备份 {os.path.basename(_bak)}" if _bak else ""))
flash(f"订单已更新(订单号 {old_no}→{order_no}),调入数量与已入账流水保持不变", "ok")
else:
log_action("编辑生产订单", order_no, f"接单量 {qty}; 订单头字段更新(其余数据不变)")
flash("订单已更新,调入数量与已入账流水保持不变", "ok")
db.session.commit()
return redirect(url_for("order_detail", oid=oid))
return render_template("order_form.html", boms=boms_all, factories=factories, o=o)
log_action("编辑生产订单", order_no,
f"含 {len(pairs)} 个BOM, 总接单量 {o.qty}; 订单头字段更新(其余数据不变)")
flash("订单已更新,调入数量与已入账流水保持不变", "ok")
db.session.commit()
return redirect(url_for("order_detail", oid=oid))
return render_template("order_form.html", boms=boms_all, factories=factories,
o=o, initial_lines=[[b.id, q] for b, q in order_bom_lines(o)])
@app.route("/order/<int:oid>/delete", methods=["POST"])
@@ -1696,6 +1703,9 @@ def order_delete(oid):
# 删除关联的「调入数量」计划行
db.session.execute(
db.delete(ProdOrderAlloc).where(ProdOrderAlloc.order_id == oid))
# 删除关联的「多 BOM 清单」行(订单头删除时一并清除, 避免孤儿行)
db.session.execute(
db.delete(ProdOrderBom).where(ProdOrderBom.order_id == oid))
# 级联删除该订单关联的领料出库流水(用途=生产领用, order_ref=订单号)
n_out = db.session.execute(
db.delete(Outbound).where(Outbound.order_ref == no)).rowcount
@@ -1709,6 +1719,66 @@ def order_delete(oid):
return redirect(url_for("orders"))
def order_bom_lines(o):
"""返回该订单的 BOM 行 [(bom, qty)], 按 BOM code 排序。
优先读 prod_order_bom 关联表(一订单多 BOM); 若该表为空(旧库未迁移或异常),
回退到 ProdOrder.bom_id / qty 单值。
"""
rows = db.session.scalars(
db.select(ProdOrderBom)
.where(ProdOrderBom.order_id == o.id)
.join(Bom, Bom.id == ProdOrderBom.bom_id)
.order_by(Bom.code)).all()
out = []
for r in rows:
b = db.session.get(Bom, r.bom_id)
if b:
out.append((b, r.qty))
if not out and o.bom_id:
b = db.session.get(Bom, o.bom_id)
if b:
out.append((b, o.qty or 0))
return out
def order_aggregated_materials(o):
"""聚合该订单所有 BOM 的物料需求。
返回 [(material, need_qty)]; need_qty = Σ(该BOM单套用量 × 该BOM本订单生产数量)。
同一物料出现在多个 BOM 中时, 需求数量累加合并(对应一个 ProdOrderAlloc 行)。
"""
agg = {}
for b, qty in order_bom_lines(o):
items = db.session.scalars(
db.select(BomItem).where(BomItem.bom_id == b.id)).all()
for it in items:
agg[it.material_id] = agg.get(it.material_id, 0) + (it.qty_per_unit or 0) * qty
return [(db.session.get(Material, mid), need) for mid, need in agg.items() if mid]
def _parse_bom_pairs(form):
"""从表单解析 (bom_id, qty) 列表: 仅保留已选 BOM 且数量 > 0 的行。
表单字段: bom_id[] / qty[] (数组, 逐行对应; 方括号为 HTML 原生多值命名)。
"""
bom_ids = form.getlist("bom_id[]")
qtys = form.getlist("qty[]")
pairs = []
for bid, q in zip(bom_ids, qtys):
bid = (bid or "").strip()
if not bid or not bid.isdigit():
continue
try:
qv = float((q or "").strip() or 0)
except ValueError:
qv = 0
if qv <= 0:
continue
pairs.append((int(bid), qv))
return pairs
def _find_posted_flows(order_no, material_id):
"""返回某生产订单某物料已入账调入流水(源厂出库 + 生产厂入库)。"""
outs = db.session.scalars(db.select(Outbound).where(
@@ -1824,10 +1894,10 @@ def order_alloc_save(oid):
if not o:
return redirect(url_for("orders"))
op = g.user.display_name or g.user.username
items = db.session.scalars(db.select(BomItem).where(BomItem.bom_id == o.bom_id)).all()
items = order_aggregated_materials(o)
edited = 0
for it in items:
raw = request.form.get(f"alloc_{it.material_id}", "").strip()
for m, _need in items:
raw = request.form.get(f"alloc_{m.id}", "").strip()
if raw == "":
continue
try:
@@ -1836,7 +1906,7 @@ def order_alloc_save(oid):
continue
if qty < 0:
qty = 0
st = _apply_alloc_change(o, it.material_id, qty, op, account_new=False)
st = _apply_alloc_change(o, m.id, qty, op, account_new=False)
if st in ("posted", "cancel"):
edited += 1
db.session.commit()
@@ -1873,12 +1943,12 @@ def order_alloc_post(oid):
flash("调入来源工厂不存在", "error")
return redirect(url_for("order_detail", oid=oid))
items = db.session.scalars(db.select(BomItem).where(BomItem.bom_id == o.bom_id)).all()
items = order_aggregated_materials(o)
op = g.user.display_name or g.user.username
n = 0
shorts = []
for it in items:
raw = request.form.get(f"alloc_{it.material_id}", "").strip()
for m, _need in items:
raw = request.form.get(f"alloc_{m.id}", "").strip()
qty = None
if raw != "":
try:
@@ -1887,7 +1957,7 @@ def order_alloc_post(oid):
qty = None
a = db.session.scalar(db.select(ProdOrderAlloc).where(
ProdOrderAlloc.order_id == oid,
ProdOrderAlloc.material_id == it.material_id))
ProdOrderAlloc.material_id == m.id))
if qty is None:
if a and a.posted:
qty = a.alloc_qty # 已入账且无输入 = 维持, helper 判定 unchanged 跳过
@@ -1895,12 +1965,11 @@ def order_alloc_post(oid):
continue # 未入账且无输入 = 不处理
if qty < 0:
qty = 0
st = _apply_alloc_change(o, it.material_id, qty, op,
st = _apply_alloc_change(o, m.id, qty, op,
from_id=from_id, account_new=True)
if st == "posted":
n += 1
elif st == "short":
m = db.session.get(Material, it.material_id)
if a and a.posted:
shorts.append(f"{m.part_number}(已入账调入修改, 源厂{ff.code}库存不足)")
else:
@@ -2529,6 +2598,19 @@ def seed_admin():
db.session.add(User(username="admin", password=sha256("admin123"),
role="admin", display_name="管理员"))
db.session.commit()
# ---- 一订单多 BOM: 为旧库(仅 bom_id 单值)的订单补齐 prod_order_bom 行(幂等) ----
try:
legacy = db.session.scalars(db.select(ProdOrder)).all()
for o in legacy:
exists = db.session.scalar(
db.select(db.func.count(ProdOrderBom.id))
.where(ProdOrderBom.order_id == o.id))
if exists == 0 and o.bom_id:
db.session.add(ProdOrderBom(
order_id=o.id, bom_id=o.bom_id, qty=o.qty or 0))
db.session.commit()
except Exception:
db.session.rollback()
if __name__ == "__main__":