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 sqlalchemy import update
from models import (db, User, Material, Inbound, Outbound, 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 冻结) ---------------- # ---------------- 路径(兼容 PyInstaller 冻结) ----------------
if getattr(sys, "frozen", False): 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() os_ = db.session.scalars(db.select(ProdOrder).order_by(ProdOrder.order_no.desc())).all()
data = [] data = []
for o in os_: 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 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) 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() os_ = db.session.scalars(db.select(ProdOrder).order_by(ProdOrder.order_no.desc())).all()
data = [] data = []
for o in os_: 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 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 from excel_io import export_orders
path = os.path.join(EXE_DIR, f"生产订单_{date.today().isoformat()}.xlsx") path = os.path.join(EXE_DIR, f"生产订单_{date.today().isoformat()}.xlsx")
export_orders(path, data) export_orders(path, data)
@@ -1492,24 +1494,30 @@ def order_new():
boms_all = db.session.scalars(db.select(Bom).order_by(Bom.code)).all() 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() factories = db.session.scalars(db.select(Factory).order_by(Factory.code)).all()
if request.method == "POST": if request.method == "POST":
bom_id = int(request.form["bom_id"]) pairs = _parse_bom_pairs(request.form)
qty = float(request.form["qty"])
fid_raw = (request.form.get("factory_id") or "").strip() fid_raw = (request.form.get("factory_id") or "").strip()
factory_id = int(fid_raw) if fid_raw.isdigit() else None factory_id = int(fid_raw) if fid_raw.isdigit() else None
if qty <= 0: if not pairs:
flash("接单量必须大于 0", "error") flash("请至少选择一个 BOM 并填写大于 0 的生产数量", "error")
else: else:
order_no = request.form.get("order_no", "").strip() or _gen_order_no() 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)): if db.session.scalar(db.select(ProdOrder).where(ProdOrder.order_no == order_no)):
order_no = _gen_order_no() order_no = _gen_order_no()
o = ProdOrder(order_no=order_no, bom_id=bom_id, factory_id=factory_id, qty=qty, total = sum(q for _, q in pairs)
status="planned", operator=(g.user.display_name or g.user.username), 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()) remark=request.form.get("remark", "").strip())
db.session.add(o) 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() 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 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>") @app.route("/order/<int:oid>")
@@ -1519,8 +1527,8 @@ def order_detail(oid):
o = db.session.get(ProdOrder, oid) o = db.session.get(ProdOrder, oid)
if not o: if not o:
return redirect(url_for("orders")) return redirect(url_for("orders"))
bom = db.session.get(Bom, o.bom_id) lines = order_bom_lines(o)
items = db.session.scalars(db.select(BomItem).where(BomItem.bom_id == o.bom_id)).all() 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() db.select(ProdOrderAlloc).where(ProdOrderAlloc.order_id == oid)).all()
alloc_map = {a.material_id: a for a in allocs} # material_id -> alloc 行 alloc_map = {a.material_id: a for a in allocs} # material_id -> alloc 行
reqs = [] reqs = []
for it in items: for m, need in order_aggregated_materials(o):
m = db.session.get(Material, it.material_id)
need = it.qty_per_unit * o.qty
have = sm.get(m.id, 0) have = sm.get(m.id, 0)
a = alloc_map.get(m.id) a = alloc_map.get(m.id)
subs = [{"pn": s["sub"].part_number, "mf": s["sub"].manufacturer or "-", 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) posted_count = sum(1 for a in allocs if a.posted)
factories = db.session.scalars( factories = db.session.scalars(
db.select(Factory).where(Factory.enabled == True).order_by(Factory.code)).all() 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, return render_template("order_detail.html", o=o, lines=lines, total_qty=total_qty,
factories=factories, reqs=reqs, factory=fac, factories=factories,
posted_total=posted_total, planned_total=planned_total, posted_total=posted_total, planned_total=planned_total,
posted_count=posted_count) posted_count=posted_count)
@@ -1565,17 +1571,15 @@ def order_export(oid):
o = db.session.get(ProdOrder, oid) o = db.session.get(ProdOrder, oid)
if not o: if not o:
return redirect(url_for("orders")) return redirect(url_for("orders"))
bom = db.session.get(Bom, o.bom_id) lines = order_bom_lines(o)
items = db.session.scalars(db.select(BomItem).where(BomItem.bom_id == o.bom_id)).all() total_qty = sum(q for _, q in lines)
sm = stock_map_as_of(o.factory_id, o.created_at) 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 fac = db.session.get(Factory, o.factory_id) if o.factory_id else None
allocs = db.session.scalars( allocs = db.session.scalars(
db.select(ProdOrderAlloc).where(ProdOrderAlloc.order_id == oid)).all() db.select(ProdOrderAlloc).where(ProdOrderAlloc.order_id == oid)).all()
alloc_map = {a.material_id: a for a in allocs} alloc_map = {a.material_id: a for a in allocs}
reqs = [] reqs = []
for it in items: for m, need in order_aggregated_materials(o):
m = db.session.get(Material, it.material_id)
need = it.qty_per_unit * o.qty
have = sm.get(m.id, 0) have = sm.get(m.id, 0)
a = alloc_map.get(m.id) a = alloc_map.get(m.id)
subs = [{"pn": s["sub"].part_number, "mf": s["sub"].manufacturer or "-", 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) posted_count = sum(1 for a in allocs if a.posted)
from excel_io import export_order_detail from excel_io import export_order_detail
path = os.path.join(EXE_DIR, f"生产订单_{o.order_no}.xlsx") 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)) 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) o = db.session.get(ProdOrder, oid)
if not o or o.status == "picked": if not o or o.status == "picked":
return redirect(url_for("orders")) 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) sm = stock_map(o.factory_id)
picks = 0 picks = 0
shorts = [] shorts = []
for it in items: for m, need in order_aggregated_materials(o):
m = db.session.get(Material, it.material_id)
need = it.qty_per_unit * o.qty
if need <= 0: if need <= 0:
continue continue
db.session.add(Outbound(material_id=m.id, factory_id=o.factory_id, qty=need, 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() 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() factories = db.session.scalars(db.select(Factory).order_by(Factory.code)).all()
if request.method == "POST": if request.method == "POST":
bom_id = int(request.form["bom_id"]) pairs = _parse_bom_pairs(request.form)
qty = float(request.form["qty"])
fid_raw = (request.form.get("factory_id") or "").strip() fid_raw = (request.form.get("factory_id") or "").strip()
factory_id = int(fid_raw) if fid_raw.isdigit() else None factory_id = int(fid_raw) if fid_raw.isdigit() else None
if qty <= 0: if not pairs:
flash("接单量必须大于 0", "error") 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: else:
old_no = o.order_no log_action("编辑生产订单", order_no,
order_no = request.form.get("order_no", "").strip() or o.order_no f"含 {len(pairs)} 个BOM, 总接单量 {o.qty}; 订单头字段更新(其余数据不变)")
if order_no != o.order_no and db.session.scalar( flash("订单已更新,调入数量与已入账流水保持不变", "ok")
db.select(ProdOrder).where(ProdOrder.order_no == order_no)): db.session.commit()
flash(f"订单号 {order_no} 已存在, 已保留原单号", "warn") return redirect(url_for("order_detail", oid=oid))
order_no = o.order_no return render_template("order_form.html", boms=boms_all, factories=factories,
o.order_no = order_no o=o, initial_lines=[[b.id, q] for b, q in order_bom_lines(o)])
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)
@app.route("/order/<int:oid>/delete", methods=["POST"]) @app.route("/order/<int:oid>/delete", methods=["POST"])
@@ -1696,6 +1703,9 @@ def order_delete(oid):
# 删除关联的「调入数量」计划行 # 删除关联的「调入数量」计划行
db.session.execute( db.session.execute(
db.delete(ProdOrderAlloc).where(ProdOrderAlloc.order_id == oid)) db.delete(ProdOrderAlloc).where(ProdOrderAlloc.order_id == oid))
# 删除关联的「多 BOM 清单」行(订单头删除时一并清除, 避免孤儿行)
db.session.execute(
db.delete(ProdOrderBom).where(ProdOrderBom.order_id == oid))
# 级联删除该订单关联的领料出库流水(用途=生产领用, order_ref=订单号) # 级联删除该订单关联的领料出库流水(用途=生产领用, order_ref=订单号)
n_out = db.session.execute( n_out = db.session.execute(
db.delete(Outbound).where(Outbound.order_ref == no)).rowcount db.delete(Outbound).where(Outbound.order_ref == no)).rowcount
@@ -1709,6 +1719,66 @@ def order_delete(oid):
return redirect(url_for("orders")) 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): def _find_posted_flows(order_no, material_id):
"""返回某生产订单某物料已入账调入流水(源厂出库 + 生产厂入库)。""" """返回某生产订单某物料已入账调入流水(源厂出库 + 生产厂入库)。"""
outs = db.session.scalars(db.select(Outbound).where( outs = db.session.scalars(db.select(Outbound).where(
@@ -1824,10 +1894,10 @@ def order_alloc_save(oid):
if not o: if not o:
return redirect(url_for("orders")) return redirect(url_for("orders"))
op = g.user.display_name or g.user.username 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 edited = 0
for it in items: for m, _need in items:
raw = request.form.get(f"alloc_{it.material_id}", "").strip() raw = request.form.get(f"alloc_{m.id}", "").strip()
if raw == "": if raw == "":
continue continue
try: try:
@@ -1836,7 +1906,7 @@ def order_alloc_save(oid):
continue continue
if qty < 0: if qty < 0:
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"): if st in ("posted", "cancel"):
edited += 1 edited += 1
db.session.commit() db.session.commit()
@@ -1873,12 +1943,12 @@ def order_alloc_post(oid):
flash("调入来源工厂不存在", "error") flash("调入来源工厂不存在", "error")
return redirect(url_for("order_detail", oid=oid)) 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 op = g.user.display_name or g.user.username
n = 0 n = 0
shorts = [] shorts = []
for it in items: for m, _need in items:
raw = request.form.get(f"alloc_{it.material_id}", "").strip() raw = request.form.get(f"alloc_{m.id}", "").strip()
qty = None qty = None
if raw != "": if raw != "":
try: try:
@@ -1887,7 +1957,7 @@ def order_alloc_post(oid):
qty = None qty = None
a = db.session.scalar(db.select(ProdOrderAlloc).where( a = db.session.scalar(db.select(ProdOrderAlloc).where(
ProdOrderAlloc.order_id == oid, ProdOrderAlloc.order_id == oid,
ProdOrderAlloc.material_id == it.material_id)) ProdOrderAlloc.material_id == m.id))
if qty is None: if qty is None:
if a and a.posted: if a and a.posted:
qty = a.alloc_qty # 已入账且无输入 = 维持, helper 判定 unchanged 跳过 qty = a.alloc_qty # 已入账且无输入 = 维持, helper 判定 unchanged 跳过
@@ -1895,12 +1965,11 @@ def order_alloc_post(oid):
continue # 未入账且无输入 = 不处理 continue # 未入账且无输入 = 不处理
if qty < 0: if qty < 0:
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) from_id=from_id, account_new=True)
if st == "posted": if st == "posted":
n += 1 n += 1
elif st == "short": elif st == "short":
m = db.session.get(Material, it.material_id)
if a and a.posted: if a and a.posted:
shorts.append(f"{m.part_number}(已入账调入修改, 源厂{ff.code}库存不足)") shorts.append(f"{m.part_number}(已入账调入修改, 源厂{ff.code}库存不足)")
else: else:
@@ -2529,6 +2598,19 @@ def seed_admin():
db.session.add(User(username="admin", password=sha256("admin123"), db.session.add(User(username="admin", password=sha256("admin123"),
role="admin", display_name="管理员")) role="admin", display_name="管理员"))
db.session.commit() 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__": if __name__ == "__main__":
+11 -1
View File
@@ -32,4 +32,14 @@ args = [
] ]
print("PyInstaller args:", args) print("PyInstaller args:", args)
run(args) run(args)
print("BUILD DONE ->", os.path.join(HERE, "dist", "MaterialWMS.exe")) # 构建产物在 dist_pkg/; 统一拷贝到 dist/ (EXE 同目录即数据库所在, 才是实际发布入口)
built = os.path.join(HERE, "dist_pkg", "MaterialWMS.exe")
target = os.path.join(HERE, "dist", "MaterialWMS.exe")
if os.path.exists(built):
import shutil
os.makedirs(os.path.dirname(target), exist_ok=True)
shutil.copyfile(built, target)
print("COPIED ->", target)
else:
print("WARN: built exe not found at", built)
print("BUILD DONE ->", target)
+41 -21
View File
@@ -330,53 +330,62 @@ def export_ledger(target, rows):
def export_orders(target, data): def export_orders(target, data):
"""导出生产订单列表。 """导出生产订单列表。
data: [{o, bom, factory}] (与 orders() 返回一致)。 data: [{o, lines, factory, total}] (与 orders() 返回一致)。
lines: [(Bom, qty)] 该订单的 BOM 行(可多个)。
""" """
wb = Workbook() wb = Workbook()
ws = wb.active ws = wb.active
ws.title = "生产订单" ws.title = "生产订单"
head = ["订单号", "BOM型号", "BOM名称", "接单量", "生产工厂", "状态", head = ["订单号", "BOM清单(型号×数量)", "生产工厂", "状态",
"经手人", "下单时间", "备注"] "经手人", "下单时间", "备注", "总接单量"]
ws.append(head) ws.append(head)
for c in ws[1]: for c in ws[1]:
c.font = Font(bold=True) c.font = Font(bold=True)
c.fill = PatternFill("solid", fgColor="DDEBF7") c.fill = PatternFill("solid", fgColor="DDEBF7")
for d in data: for d in data:
o = d["o"] o = d["o"]
bom = d["bom"] lines = d.get("lines") or []
fac = d["factory"] fac = d["factory"]
total = d.get("total") or 0
bom_info = "\n".join(
f"{b.code}{('·' + b.name) if b.name else ''}×{q}" for b, q in lines) or "—"
status = "已领料" if o.status == "picked" else "待领料" status = "已领料" if o.status == "picked" else "待领料"
ws.append([ ws.append([
o.order_no, o.order_no,
bom.code if bom else "", bom_info,
bom.name if bom else "",
o.qty,
(f"{fac.code}·{fac.name}" if fac else "未指定"), (f"{fac.code}·{fac.name}" if fac else "未指定"),
status, status,
o.operator or "", o.operator or "",
o.created_at.strftime("%Y-%m-%d %H:%M") if o.created_at else "", o.created_at.strftime("%Y-%m-%d %H:%M") if o.created_at else "",
o.remark or "", o.remark or "",
total,
]) ])
if o.status == "picked": if o.status == "picked":
ws.cell(row=ws.max_row, column=6).fill = PatternFill("solid", fgColor="C6EFCE") ws.cell(row=ws.max_row, column=4).fill = PatternFill("solid", fgColor="C6EFCE")
else: else:
ws.cell(row=ws.max_row, column=6).fill = PatternFill("solid", fgColor="FFE699") ws.cell(row=ws.max_row, column=4).fill = PatternFill("solid", fgColor="FFE699")
ws.cell(row=ws.max_row, column=2).alignment = Alignment(wrap_text=True, vertical="top")
ws.freeze_panes = "A2" ws.freeze_panes = "A2"
wb.save(target) wb.save(target)
def export_order_detail(target, o, bom, reqs, factory, def export_order_detail(target, o, lines, reqs, factory,
posted_total, planned_total, posted_count): posted_total, planned_total, posted_count):
"""导出单个生产订单详情(派工单):订单信息 sheet + 领料明细 sheet。""" """导出单个生产订单详情(派工单):订单信息 sheet + BOM清单 sheet + 领料明细 sheet。
lines: [(Bom, qty)] 该订单的 BOM 行(可多个); 领料明细已按全部 BOM 汇总。
"""
wb = Workbook() wb = Workbook()
ws = wb.active ws = wb.active
ws.title = "订单信息" ws.title = "订单信息"
ws.append(["生产订单派工单"]) ws.append(["生产订单派工单"])
ws["A1"].font = Font(bold=True, size=14) ws["A1"].font = Font(bold=True, size=14)
total_qty = sum(q for _, q in (lines or []))
info = [ info = [
("订单号", o.order_no), ("订单号", o.order_no),
("BOM型号", (bom.code if bom else "-") + (f" · {bom.name}" if bom and bom.name else "")), ("BOM清单", "\n".join(
("接单量", o.qty), f"{b.code}{('·' + b.name) if b.name else ''}×{q}" for b, q in (lines or [])) or "-"),
("总接单量", total_qty),
("生产工厂", (f"{factory.code}·{factory.name}" if factory else "未指定(按汇总库存)")), ("生产工厂", (f"{factory.code}·{factory.name}" if factory else "未指定(按汇总库存)")),
("经手人", o.operator or "-"), ("经手人", o.operator or "-"),
("下单时间", o.created_at.strftime("%Y-%m-%d %H:%M") if o.created_at else ""), ("下单时间", o.created_at.strftime("%Y-%m-%d %H:%M") if o.created_at else ""),
@@ -387,21 +396,32 @@ def export_order_detail(target, o, bom, reqs, factory,
r = 3 r = 3
for k, v in info: for k, v in info:
ws.cell(row=r, column=1, value=k).font = Font(bold=True) ws.cell(row=r, column=1, value=k).font = Font(bold=True)
ws.cell(row=r, column=2, value=v) cell = ws.cell(row=r, column=2, value=v)
if k == "BOM清单":
cell.alignment = Alignment(wrap_text=True, vertical="top")
r += 1 r += 1
ws2 = wb.create_sheet("领料明细") ws2 = wb.create_sheet("BOM清单")
ws2.append(["BOM型号", "BOM名称", "生产数量"])
for c in ws2[1]:
c.font = Font(bold=True)
c.fill = PatternFill("solid", fgColor="DDEBF7")
for b, q in (lines or []):
ws2.append([b.code, b.name or "", q])
ws2.freeze_panes = "A2"
ws3 = wb.create_sheet("领料明细")
head = ["物料编号", "型号", "下单前库存", "需求数量", "缺口", head = ["物料编号", "型号", "下单前库存", "需求数量", "缺口",
"调入数量", "状态", "工厂库存", "可替代料"] "调入数量", "状态", "工厂库存", "可替代料"]
ws2.append(head) ws3.append(head)
for c in ws2[1]: for c in ws3[1]:
c.font = Font(bold=True) c.font = Font(bold=True)
c.fill = PatternFill("solid", fgColor="DDEBF7") c.fill = PatternFill("solid", fgColor="DDEBF7")
for x in reqs: for x in reqs:
subs = "、".join( subs = "、".join(
f"{s['pn']}·{s['stock']}{'✓' if s['available'] else '✗'}" for s in x["subs"] f"{s['pn']}·{s['stock']}{'✓' if s['available'] else '✗'}" for s in x["subs"]
) if x["subs"] else "—" ) if x["subs"] else "—"
ws2.append([ ws3.append([
x["m"].part_number, x["m"].part_number,
x["m"].manufacturer or "-", x["m"].manufacturer or "-",
round(x["have"], 3), round(x["have"], 3),
@@ -413,10 +433,10 @@ def export_order_detail(target, o, bom, reqs, factory,
subs, subs,
]) ])
if x["short"]: if x["short"]:
ws2.cell(row=ws2.max_row, column=7).fill = PatternFill("solid", fgColor="FFC7CE") ws3.cell(row=ws3.max_row, column=7).fill = PatternFill("solid", fgColor="FFC7CE")
else: else:
ws2.cell(row=ws2.max_row, column=7).fill = PatternFill("solid", fgColor="C6EFCE") ws3.cell(row=ws3.max_row, column=7).fill = PatternFill("solid", fgColor="C6EFCE")
ws2.freeze_panes = "A2" ws3.freeze_panes = "A2"
wb.save(target) wb.save(target)
+15
View File
@@ -118,6 +118,21 @@ class ProdOrderAlloc(db.Model):
created_at = db.Column(db.DateTime, default=datetime.now) created_at = db.Column(db.DateTime, default=datetime.now)
class ProdOrderBom(db.Model):
"""生产订单包含的 BOM 行: 一个订单可含多个 BOM, 每个 BOM 独立的生产数量。
- 这是「一订单多 BOM」的唯一数据源, 取代原 ProdOrder.bom_id 的单值绑定。
- (order_id, bom_id) 唯一: 同一 BOM 在同一订单中只出现一行(数量可累加合并)。
- ProdOrder.bom_id / qty 仅作为兼容旧数据与列表快速展示的冗余(主BOM / 总接单量)。
"""
__tablename__ = "prod_order_bom"
id = db.Column(db.Integer, primary_key=True)
order_id = db.Column(db.Integer, db.ForeignKey("prod_order.id"), nullable=False, index=True)
bom_id = db.Column(db.Integer, db.ForeignKey("bom.id"), nullable=False, index=True)
qty = db.Column(db.Float, nullable=False, default=0) # 该 BOM 在本订单的生产数量
__table_args__ = (UniqueConstraint("order_id", "bom_id", name="uq_pob_order_bom"),)
class Substitute(db.Model): class Substitute(db.Model):
"""替代料关系: 某主料(main)可由某替代料(sub)替代使用。 """替代料关系: 某主料(main)可由某替代料(sub)替代使用。
+3 -2
View File
@@ -28,12 +28,13 @@
</div> </div>
<div class="card" style="margin-bottom:16px"> <div class="card" style="margin-bottom:16px">
<p class="small muted" style="margin-top:0"><b>下单信息(按下单时记录)</b></p> <p class="small muted" style="margin-top:0"><b>下单信息(按下单时记录)</b></p>
<p>BOM: <b>{{ bom.code if bom else '-' }}</b>{% if bom and bom.name %} · {{ bom.name }}{% endif %} | 接单量: <b>{{ '%.2f'|format(o.qty) }}</b> | 生产工厂: <b>{% if factory %}{{ factory.code }}{% if factory.name %} · {{ factory.name }}{% endif %}{% else %}未指定(按汇总库存){% endif %}</b> | 经手人: {{ o.operator or '-' }}{% if o.created_at %} | 下单时间: {{ o.created_at.strftime('%Y-%m-%d %H:%M') }}{% endif %}</p> <p><b>BOM 清单:</b>{% for b, q in lines %}<span style="display:inline-block;margin-right:14px">{{ b.code }}{% if b.name %} · {{ b.name }}{% endif %} × <b>{{ '%.2f'|format(q) }}</b></span>{% else %}-{% endfor %}</p>
<p>总接单量(合计): <b>{{ '%.2f'|format(total_qty) }}</b> | 生产工厂: <b>{% if factory %}{{ factory.code }}{% if factory.name %} · {{ factory.name }}{% endif %}{% else %}未指定(按汇总库存){% endif %}</b> | 经手人: {{ o.operator or '-' }}{% if o.created_at %} | 下单时间: {{ o.created_at.strftime('%Y-%m-%d %H:%M') }}{% endif %}</p>
{% if o.remark %}<p class="small muted">备注: {{ o.remark }}</p>{% endif %} {% if o.remark %}<p class="small muted">备注: {{ o.remark }}</p>{% endif %}
<p class="small muted print-hide" style="margin-bottom:0">提示:点击「编辑订单」保存后,该订单会冲销已产生的调入/领料流水并清空调入计划,自动回到「刚下单」状态(待领料、未调拨),可重新规划。</p> <p class="small muted print-hide" style="margin-bottom:0">提示:点击「编辑订单」保存后,该订单会冲销已产生的调入/领料流水并清空调入计划,自动回到「刚下单」状态(待领料、未调拨),可重新规划。</p>
</div> </div>
<h3>领料需求与调入 (单套用量 × 接单量){% if factory %} | 按下单前库存核算{% endif %}</h3> <h3>领料需求与调入 (按各 BOM 单套用量 × 各自生产数量 汇总){% if factory %} | 按下单前库存核算{% endif %}</h3>
<p class="small muted" style="margin-top:-6px">库存口径:下方「下单前库存」为 <b>订单下单那一刻、所属工厂的库存快照</b>(按下单时间截断,已自动排除该订单自身后续的调入/领料及下单后发生的其他出入库),用于还原下单时的可供料情况。{% if not factory %}本单未指定生产工厂,按下单前全部工厂汇总库存展示。{% endif %}</p> <p class="small muted" style="margin-top:-6px">库存口径:下方「下单前库存」为 <b>订单下单那一刻、所属工厂的库存快照</b>(按下单时间截断,已自动排除该订单自身后续的调入/领料及下单后发生的其他出入库),用于还原下单时的可供料情况。{% if not factory %}本单未指定生产工厂,按下单前全部工厂汇总库存展示。{% endif %}</p>
<form method="post" action="{{ url_for('order_alloc_save', oid=o.id) }}"> <form method="post" action="{{ url_for('order_alloc_save', oid=o.id) }}">
+42 -12
View File
@@ -1,21 +1,18 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{{ '编辑生产订单' if o else '新建生产订单' }}{% endblock %} {% block title %}{{ '编辑生产订单' if o else '新建生产订单' }}{% endblock %}
{% block content %} {% block content %}
<form method="post" class="card form-card" style="max-width:520px" <form method="post" class="card form-card" style="max-width:720px"
action="{{ url_for('order_edit', oid=o.id) if o else url_for('order_new') }}" action="{{ url_for('order_edit', oid=o.id) if o else url_for('order_new') }}"
{% if o %}onsubmit="return confirm('确认保存修改?已录入的调入数量与已入账流水将保持不变。');"{% endif %}> {% if o %}onsubmit="return confirm('确认保存修改?已录入的调入数量与已入账流水将保持不变(被移除 BOM 中已入账的物料仍保留其流水)。');"{% endif %}>
<h3>{{ '编辑生产订单' if o else '新建生产订单' }}</h3> <h3>{{ '编辑生产订单' if o else '新建生产订单' }}</h3>
<label>订单号 {% if o %}(单号不可重复){% else %}(留空自动生成){% endif %}</label> <label>订单号 {% if o %}(单号不可重复){% else %}(留空自动生成){% endif %}</label>
<input name="order_no" value="{{ o.order_no if o else '' }}" placeholder="如 WO20260911xxx"> <input name="order_no" value="{{ o.order_no if o else '' }}" placeholder="如 WO20260911xxx">
<label>BOM 型号 *</label>
<select name="bom_id" required> <label>BOM 与生产数量 *</label>
<option value="">-- 选择 BOM --</option> <p class="small muted" style="margin:0 0 6px">可在同一订单中加入多个 BOM,每个 BOM 独立填写生产数量;点击「+ 添加 BOM」增加一行,不需要的行可删除。</p>
{% for b in boms %} <div id="bomRows" class="bom-rows"></div>
<option value="{{ b.id }}" {% if o and o.bom_id == b.id %}selected{% endif %}>{{ b.code }}{% if b.name %} · {{ b.name }}{% endif %}</option> <button type="button" class="btn" style="margin:2px 0 12px" onclick="addBomRow()">+ 添加 BOM</button>
{% endfor %}
</select>
<label>接单量 (台/套) *</label>
<input name="qty" type="number" step="0.01" min="0.01" required value="{{ '%.2f'|format(o.qty) if o else '' }}">
<label>生产工厂</label> <label>生产工厂</label>
<select name="factory_id"> <select name="factory_id">
<option value="">-- 不指定(汇总库存校验)--</option> <option value="">-- 不指定(汇总库存校验)--</option>
@@ -29,6 +26,39 @@
<button class="primary">{{ '保存修改' if o else '生成订单' }}</button> <button class="primary">{{ '保存修改' if o else '生成订单' }}</button>
<a class="btn" href="{{ url_for('orders') }}">取消</a> <a class="btn" href="{{ url_for('orders') }}">取消</a>
</div> </div>
<p class="small muted">生成后可在订单详情页按 BOM 展开领料需求, 并手动录入每个物料的「调入数量」。</p> <p class="small muted">生成后可在订单详情页按各 BOM 展开领料需求, 并手动录入每个物料的「调入数量」。</p>
</form> </form>
{# 隐藏模板: 供 JS 克隆(含全部 BOM 选项) #}
<template id="bomTpl">
<div class="bom-row" style="display:flex; gap:10px; align-items:center; margin-bottom:6px">
<select name="bom_id[]" required>
<option value="">-- 选择 BOM --</option>
{% for b in boms %}<option value="{{ b.id }}">{{ b.code }}{% if b.name %} · {{ b.name }}{% endif %}</option>{% endfor %}
</select>
<input name="qty[]" type="number" step="0.01" min="0.01" required placeholder="生产数量" style="width:130px">
<button type="button" class="link-danger" onclick="this.closest('.bom-row').remove();checkRows()">删除</button>
</div>
</template>
<script>
function addBomRow(sel, qty){
var tpl = document.getElementById('bomTpl');
var row = tpl.content.firstElementChild.cloneNode(true);
if (sel) row.querySelector('select').value = String(sel);
if (qty != null && qty !== undefined) row.querySelector('input').value = qty;
document.getElementById('bomRows').appendChild(row);
checkRows();
}
function checkRows(){
var rows = document.querySelectorAll('#bomRows .bom-row');
rows.forEach(function(r){
var btn = r.querySelector('button');
if (btn) btn.style.display = rows.length > 1 ? '' : 'none';
});
}
var init = {{ initial_lines|tojson }};
if (init && init.length){ init.forEach(function(x){ addBomRow(x[0], x[1]); }); }
else { addBomRow(); }
</script>
{% endblock %} {% endblock %}
+3 -3
View File
@@ -7,13 +7,13 @@
<a class="btn" href="{{ url_for('orders_export') }}">导出 Excel</a> <a class="btn" href="{{ url_for('orders_export') }}">导出 Excel</a>
</div> </div>
<table class="tbl"> <table class="tbl">
<thead><tr><th>订单号</th><th>BOM</th><th>接单量</th><th>生产工厂</th><th>状态</th><th>经手人</th><th>操作</th></tr></thead> <thead><tr><th>订单号</th><th>BOM 清单(型号 × 数量)</th><th>总接单量</th><th>生产工厂</th><th>状态</th><th>经手人</th><th>操作</th></tr></thead>
<tbody> <tbody>
{% for d in data %} {% for d in data %}
<tr> <tr>
<td>{{ d.o.order_no }}</td> <td>{{ d.o.order_no }}</td>
<td>{{ d.bom.code if d.bom else '-' }}{% if d.bom and d.bom.name %} · {{ d.bom.name }}{% endif %}</td> <td>{% for b, q in d.lines %}<div>{{ b.code }}{% if b.name %} · {{ b.name }}{% endif %} × {{ '%.2f'|format(q) }}</div>{% else %}-{% endfor %}</td>
<td class="num">{{ '%.2f'|format(d.o.qty) }}</td> <td class="num">{{ '%.2f'|format(d.total) }}</td>
<td>{% if d.factory %}{{ d.factory.code }}{% if d.factory.name %} · {{ d.factory.name }}{% endif %}{% else %}未指定{% endif %}</td> <td>{% if d.factory %}{{ d.factory.code }}{% if d.factory.name %} · {{ d.factory.name }}{% endif %}{% else %}未指定{% endif %}</td>
<td><span class="badge {{ 'ok' if d.o.status=='picked' else 'warn' }}">{{ '已领料' if d.o.status=='picked' else '待领料' }}</span></td> <td><span class="badge {{ 'ok' if d.o.status=='picked' else 'warn' }}">{{ '已领料' if d.o.status=='picked' else '待领料' }}</span></td>
<td>{{ d.o.operator or '-' }}</td> <td>{{ d.o.operator or '-' }}</td>