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 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,13 +1639,13 @@ 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")
|
||||||
else:
|
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
|
old_no = o.order_no
|
||||||
order_no = request.form.get("order_no", "").strip() or 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(
|
if order_no != o.order_no and db.session.scalar(
|
||||||
@@ -1652,12 +1653,16 @@ def order_edit(oid):
|
|||||||
flash(f"订单号 {order_no} 已存在, 已保留原单号", "warn")
|
flash(f"订单号 {order_no} 已存在, 已保留原单号", "warn")
|
||||||
order_no = o.order_no
|
order_no = o.order_no
|
||||||
o.order_no = order_no
|
o.order_no = order_no
|
||||||
o.bom_id = bom_id
|
o.bom_id = pairs[0][0]
|
||||||
o.factory_id = factory_id
|
o.factory_id = factory_id
|
||||||
o.qty = qty
|
o.qty = sum(q for _, q in pairs)
|
||||||
o.remark = request.form.get("remark", "").strip()
|
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:
|
if order_no != old_no:
|
||||||
# 备份后, 把关联流水(订单号作为关联键 order_ref)同步为新单号, 使其余数据保持一致
|
# 备份后, 把关联流水(订单号作为关联键 order_ref)同步为新单号, 使其余数据保持一致
|
||||||
try:
|
try:
|
||||||
@@ -1674,15 +1679,17 @@ def order_edit(oid):
|
|||||||
update(Outbound).where(Outbound.order_ref == old_no)
|
update(Outbound).where(Outbound.order_ref == old_no)
|
||||||
.values(order_ref=order_no))
|
.values(order_ref=order_no))
|
||||||
log_action("编辑生产订单", order_no,
|
log_action("编辑生产订单", order_no,
|
||||||
f"接单量 {qty}; 订单号 {old_no}→{order_no}, 已同步关联流水 order_ref"
|
f"含 {len(pairs)} 个BOM, 总接单量 {o.qty}; 订单号 {old_no}→{order_no}, 已同步关联流水 order_ref"
|
||||||
+ (f"; 备份 {os.path.basename(_bak)}" if _bak else ""))
|
+ (f"; 备份 {os.path.basename(_bak)}" if _bak else ""))
|
||||||
flash(f"订单已更新(订单号 {old_no}→{order_no}),调入数量与已入账流水保持不变", "ok")
|
flash(f"订单已更新(订单号 {old_no}→{order_no}),调入数量与已入账流水保持不变", "ok")
|
||||||
else:
|
else:
|
||||||
log_action("编辑生产订单", order_no, f"接单量 {qty}; 订单头字段更新(其余数据不变)")
|
log_action("编辑生产订单", order_no,
|
||||||
|
f"含 {len(pairs)} 个BOM, 总接单量 {o.qty}; 订单头字段更新(其余数据不变)")
|
||||||
flash("订单已更新,调入数量与已入账流水保持不变", "ok")
|
flash("订单已更新,调入数量与已入账流水保持不变", "ok")
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return redirect(url_for("order_detail", oid=oid))
|
return redirect(url_for("order_detail", oid=oid))
|
||||||
return render_template("order_form.html", boms=boms_all, factories=factories, o=o)
|
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"])
|
@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
@@ -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
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)替代使用。
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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 %}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user