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:
@@ -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__":
|
||||
|
||||
+11
-1
@@ -32,4 +32,14 @@ args = [
|
||||
]
|
||||
print("PyInstaller args:", 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
@@ -330,53 +330,62 @@ def export_ledger(target, rows):
|
||||
def export_orders(target, data):
|
||||
"""导出生产订单列表。
|
||||
|
||||
data: [{o, bom, factory}] (与 orders() 返回一致)。
|
||||
data: [{o, lines, factory, total}] (与 orders() 返回一致)。
|
||||
lines: [(Bom, qty)] 该订单的 BOM 行(可多个)。
|
||||
"""
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "生产订单"
|
||||
head = ["订单号", "BOM型号", "BOM名称", "接单量", "生产工厂", "状态",
|
||||
"经手人", "下单时间", "备注"]
|
||||
head = ["订单号", "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"]
|
||||
lines = d.get("lines") or []
|
||||
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 "待领料"
|
||||
ws.append([
|
||||
o.order_no,
|
||||
bom.code if bom else "",
|
||||
bom.name if bom else "",
|
||||
o.qty,
|
||||
bom_info,
|
||||
(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 "",
|
||||
total,
|
||||
])
|
||||
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:
|
||||
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"
|
||||
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):
|
||||
"""导出单个生产订单详情(派工单):订单信息 sheet + 领料明细 sheet。"""
|
||||
"""导出单个生产订单详情(派工单):订单信息 sheet + BOM清单 sheet + 领料明细 sheet。
|
||||
|
||||
lines: [(Bom, qty)] 该订单的 BOM 行(可多个); 领料明细已按全部 BOM 汇总。
|
||||
"""
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "订单信息"
|
||||
ws.append(["生产订单派工单"])
|
||||
ws["A1"].font = Font(bold=True, size=14)
|
||||
total_qty = sum(q for _, q in (lines or []))
|
||||
info = [
|
||||
("订单号", o.order_no),
|
||||
("BOM型号", (bom.code if bom else "-") + (f" · {bom.name}" if bom and bom.name else "")),
|
||||
("接单量", o.qty),
|
||||
("BOM清单", "\n".join(
|
||||
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 "未指定(按汇总库存)")),
|
||||
("经手人", o.operator or "-"),
|
||||
("下单时间", 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
|
||||
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)
|
||||
cell = ws.cell(row=r, column=2, value=v)
|
||||
if k == "BOM清单":
|
||||
cell.alignment = Alignment(wrap_text=True, vertical="top")
|
||||
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 = ["物料编号", "型号", "下单前库存", "需求数量", "缺口",
|
||||
"调入数量", "状态", "工厂库存", "可替代料"]
|
||||
ws2.append(head)
|
||||
for c in ws2[1]:
|
||||
ws3.append(head)
|
||||
for c in ws3[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([
|
||||
ws3.append([
|
||||
x["m"].part_number,
|
||||
x["m"].manufacturer or "-",
|
||||
round(x["have"], 3),
|
||||
@@ -413,10 +433,10 @@ def export_order_detail(target, o, bom, reqs, factory,
|
||||
subs,
|
||||
])
|
||||
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:
|
||||
ws2.cell(row=ws2.max_row, column=7).fill = PatternFill("solid", fgColor="C6EFCE")
|
||||
ws2.freeze_panes = "A2"
|
||||
ws3.cell(row=ws3.max_row, column=7).fill = PatternFill("solid", fgColor="C6EFCE")
|
||||
ws3.freeze_panes = "A2"
|
||||
wb.save(target)
|
||||
|
||||
|
||||
|
||||
@@ -118,6 +118,21 @@ class ProdOrderAlloc(db.Model):
|
||||
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):
|
||||
"""替代料关系: 某主料(main)可由某替代料(sub)替代使用。
|
||||
|
||||
|
||||
@@ -28,12 +28,13 @@
|
||||
</div>
|
||||
<div class="card" style="margin-bottom:16px">
|
||||
<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 %}
|
||||
<p class="small muted print-hide" style="margin-bottom:0">提示:点击「编辑订单」保存后,该订单会冲销已产生的调入/领料流水并清空调入计划,自动回到「刚下单」状态(待领料、未调拨),可重新规划。</p>
|
||||
</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>
|
||||
|
||||
<form method="post" action="{{ url_for('order_alloc_save', oid=o.id) }}">
|
||||
|
||||
+42
-12
@@ -1,21 +1,18 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ '编辑生产订单' if o else '新建生产订单' }}{% endblock %}
|
||||
{% 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') }}"
|
||||
{% if o %}onsubmit="return confirm('确认保存修改?已录入的调入数量与已入账流水将保持不变。');"{% endif %}>
|
||||
{% if o %}onsubmit="return confirm('确认保存修改?已录入的调入数量与已入账流水将保持不变(被移除 BOM 中已入账的物料仍保留其流水)。');"{% endif %}>
|
||||
<h3>{{ '编辑生产订单' if o else '新建生产订单' }}</h3>
|
||||
<label>订单号 {% if o %}(单号不可重复){% else %}(留空自动生成){% endif %}</label>
|
||||
<input name="order_no" value="{{ o.order_no if o else '' }}" placeholder="如 WO20260911xxx">
|
||||
<label>BOM 型号 *</label>
|
||||
<select name="bom_id" required>
|
||||
<option value="">-- 选择 BOM --</option>
|
||||
{% for b in boms %}
|
||||
<option value="{{ b.id }}" {% if o and o.bom_id == b.id %}selected{% endif %}>{{ b.code }}{% if b.name %} · {{ b.name }}{% endif %}</option>
|
||||
{% 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>BOM 与生产数量 *</label>
|
||||
<p class="small muted" style="margin:0 0 6px">可在同一订单中加入多个 BOM,每个 BOM 独立填写生产数量;点击「+ 添加 BOM」增加一行,不需要的行可删除。</p>
|
||||
<div id="bomRows" class="bom-rows"></div>
|
||||
<button type="button" class="btn" style="margin:2px 0 12px" onclick="addBomRow()">+ 添加 BOM</button>
|
||||
|
||||
<label>生产工厂</label>
|
||||
<select name="factory_id">
|
||||
<option value="">-- 不指定(汇总库存校验)--</option>
|
||||
@@ -29,6 +26,39 @@
|
||||
<button class="primary">{{ '保存修改' if o else '生成订单' }}</button>
|
||||
<a class="btn" href="{{ url_for('orders') }}">取消</a>
|
||||
</div>
|
||||
<p class="small muted">生成后可在订单详情页按 BOM 展开领料需求, 并手动录入每个物料的「调入数量」。</p>
|
||||
<p class="small muted">生成后可在订单详情页按各 BOM 展开领料需求, 并手动录入每个物料的「调入数量」。</p>
|
||||
</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 %}
|
||||
|
||||
@@ -7,13 +7,13 @@
|
||||
<a class="btn" href="{{ url_for('orders_export') }}">导出 Excel</a>
|
||||
</div>
|
||||
<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>
|
||||
{% for d in data %}
|
||||
<tr>
|
||||
<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 class="num">{{ '%.2f'|format(d.o.qty) }}</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.total) }}</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>{{ d.o.operator or '-' }}</td>
|
||||
|
||||
Reference in New Issue
Block a user