diff --git a/app.py b/app.py index 1df73be..675a00e 100644 --- a/app.py +++ b/app.py @@ -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/") @@ -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//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__": diff --git a/build_exe.py b/build_exe.py index de09b07..63d6440 100644 --- a/build_exe.py +++ b/build_exe.py @@ -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) diff --git a/excel_io.py b/excel_io.py index 57b4e66..588e570 100644 --- a/excel_io.py +++ b/excel_io.py @@ -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) diff --git a/models.py b/models.py index dd7ed57..77d7341 100644 --- a/models.py +++ b/models.py @@ -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)替代使用。 diff --git a/templates/order_detail.html b/templates/order_detail.html index c830d15..83d72cf 100644 --- a/templates/order_detail.html +++ b/templates/order_detail.html @@ -28,12 +28,13 @@

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

-

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

+

BOM 清单:{% for b, q in lines %}{{ b.code }}{% if b.name %} · {{ b.name }}{% endif %} × {{ '%.2f'|format(q) }}{% else %}-{% endfor %}

+

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

{% if o.remark %}

备注: {{ o.remark }}

{% endif %}
-

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

+

领料需求与调入 (按各 BOM 单套用量 × 各自生产数量 汇总){% if factory %} | 按下单前库存核算{% endif %}

库存口径:下方「下单前库存」为 订单下单那一刻、所属工厂的库存快照(按下单时间截断,已自动排除该订单自身后续的调入/领料及下单后发生的其他出入库),用于还原下单时的可供料情况。{% if not factory %}本单未指定生产工厂,按下单前全部工厂汇总库存展示。{% endif %}

diff --git a/templates/order_form.html b/templates/order_form.html index a6ba4e3..d5988a7 100644 --- a/templates/order_form.html +++ b/templates/order_form.html @@ -1,21 +1,18 @@ {% extends "base.html" %} {% block title %}{{ '编辑生产订单' if o else '新建生产订单' }}{% endblock %} {% block content %} - + {% if o %}onsubmit="return confirm('确认保存修改?已录入的调入数量与已入账流水将保持不变(被移除 BOM 中已入账的物料仍保留其流水)。');"{% endif %}>

{{ '编辑生产订单' if o else '新建生产订单' }}

- - - - + + +

可在同一订单中加入多个 BOM,每个 BOM 独立填写生产数量;点击「+ 添加 BOM」增加一行,不需要的行可删除。

+
+ +