feat: BOM批量导入型号/名称可编辑 + 生产订单查看按下单前库存快照 + 编辑冲销回下单状态

This commit is contained in:
2026-10-10 12:26:21 +08:00
parent 5060d027e5
commit 574d608e11
6 changed files with 162 additions and 45 deletions
+76 -8
View File
@@ -9,6 +9,7 @@ M3 治理: 多角色(admin/manager/operator) + 操作日志 + 报表打印导出
"""
import os
import sys
import shutil
import hashlib
import webbrowser
from datetime import date, datetime
@@ -127,6 +128,35 @@ def stock_map(factory_id=None):
return {i: inb.get(i, 0) - out.get(i, 0) for i in ids}
def stock_map_as_of(factory_id=None, as_of=None):
"""返回截至 as_of 之前(不含 as_of 及之后创建)的净库存 {material_id: qty}。
- as_of 为 datetime(通常用订单的 created_at)。
- 用 created_at 作截断, 从而自动排除该订单自身后续的调入/领料流水,
以及下单之后才发生的其他出入库 —— 即「下单那一刻的库存快照」。
- as_of 为 None 时等价 stock_map(当前库存)。
- 兼容极少数字段 created_at 为空: 空值不参与(视为缺失), 不影响其余统计。
"""
if as_of is None:
return stock_map(factory_id)
fcond = " AND factory_id = :fid" if factory_id else ""
params = {"asof": as_of}
if factory_id:
params["fid"] = factory_id
rows = db.session.execute(db.text(
f"SELECT material_id, COALESCE(SUM(qty),0) FROM inbound "
f"WHERE created_at IS NOT NULL AND created_at < :asof{fcond} GROUP BY material_id"),
params).fetchall()
inb = {r[0]: float(r[1]) for r in rows}
rows = db.session.execute(db.text(
f"SELECT material_id, COALESCE(SUM(qty),0) FROM outbound "
f"WHERE created_at IS NOT NULL AND created_at < :asof{fcond} GROUP BY material_id"),
params).fetchall()
out = {r[0]: float(r[1]) for r in rows}
ids = set(inb) | set(out)
return {i: inb.get(i, 0) - out.get(i, 0) for i in ids}
def _status_of(stock, safety):
"""库存状态: danger=负库存, warn=低于安全库存, ok=正常"""
if stock < 0:
@@ -157,9 +187,13 @@ def outbound_total_map(factory_id=None):
# ---------------- 替代料关系 ----------------
def substitute_rows(main_id, factory_id=None):
"""返回主料 main_id 的可替代料列表: [{sub:Material, stock:float, available:bool}]。"""
sm = stock_map(factory_id)
def substitute_rows(main_id, factory_id=None, as_of=None):
"""返回主料 main_id 的可替代料列表: [{sub:Material, stock:float, available:bool}]。
as_of 传入时, 库存按「截至 as_of 之前」快照计算(用于生产订单查看时
呈现下单前的库存)。默认 None 取当前库存。
"""
sm = stock_map_as_of(factory_id, as_of)
subs = db.session.scalars(
db.select(Substitute).where(Substitute.main_id == main_id)).all()
out = []
@@ -1298,7 +1332,10 @@ def order_detail(oid):
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()
sm = stock_map(o.factory_id)
# 查看订单时, 库存按「下单那一刻之前」的快照展示, 而非当前实时库存,
# 这样能还原下单时的真实可供料情况(已自动排除本单后续的调入/领料)。
as_of = o.created_at
sm = stock_map_as_of(o.factory_id, as_of)
fac = db.session.get(Factory, o.factory_id) if o.factory_id else None
# 已录入的「调入数量」计划
allocs = db.session.scalars(
@@ -1312,7 +1349,7 @@ def order_detail(oid):
a = alloc_map.get(m.id)
subs = [{"pn": s["sub"].part_number, "mf": s["sub"].manufacturer or "-",
"stock": round(s["stock"], 3), "available": s["available"]}
for s in substitute_rows(m.id, o.factory_id)]
for s in substitute_rows(m.id, o.factory_id, as_of)]
reqs.append({"m": m, "need": need, "have": have,
"gap": need - have, "short": need > have, "subs": subs,
"alloc": a.alloc_qty if a else 0,
@@ -1323,7 +1360,7 @@ def order_detail(oid):
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,
factories=factories, as_of=as_of,
posted_total=posted_total, planned_total=planned_total,
posted_count=posted_count)
@@ -1378,6 +1415,7 @@ def order_edit(oid):
if qty <= 0:
flash("接单量必须大于 0", "error")
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)):
@@ -1388,9 +1426,20 @@ def order_edit(oid):
o.factory_id = factory_id
o.qty = qty
o.remark = request.form.get("remark", "").strip()
# 冲销回「下单时」状态: 先备份库, 再撤销该订单已产生的调入/调出/领料流水 + 清空调入计划
try:
_bak = os.path.join(EXE_DIR, "_backup",
f"reset_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
n_in, n_out, n_alloc = _reset_order_to_placed(o, old_no)
db.session.commit()
log_action("编辑生产订单", o.order_no, f"接单量 {qty}")
flash("订单已更新", "ok")
log_action("编辑生产订单", o.order_no,
f"接单量 {qty}; 冲销回下单状态: 调入入库{n_in}/调出领料{n_out}/计划{n_alloc}"
+ (f"; 备份 {os.path.basename(_bak)}" if _bak else ""))
flash(f"订单已更新并回到「下单状态」(冲销调入/领料流水 {n_in + n_out} 笔、清空调入计划 {n_alloc} 行)", "ok")
return redirect(url_for("order_detail", oid=oid))
return render_template("order_form.html", boms=boms_all, factories=factories, o=o)
@@ -1419,6 +1468,25 @@ def order_delete(oid):
return redirect(url_for("orders"))
def _reset_order_to_placed(o, order_no):
"""把订单冲销回「刚下单」状态: 撤销该订单关联的调入入库 / 调出 / 领料流水, 清空调入计划, 状态置回 planned。
- 调入入库(Inbound, source=生产订单调入) 与 调出/领料(Outbound, purpose in (生产领用, 生产订单调出)) 均按 order_ref 关联, 直接删除即冲销(总库存/厂间余额恢复)。
- 调入计划(prod_order_alloc) 按 order_id 清除。
- 返回 (冲销入库笔数, 冲销出库笔数, 清除计划行数)。
"""
n_in = db.session.execute(
db.delete(Inbound).where(Inbound.order_ref == order_no)).rowcount
n_out = db.session.execute(
db.delete(Outbound).where(
Outbound.order_ref == order_no,
Outbound.purpose.in_(["生产领用", "生产订单调出"]))).rowcount
n_alloc = db.session.execute(
db.delete(ProdOrderAlloc).where(ProdOrderAlloc.order_id == o.id)).rowcount
o.status = "planned"
return n_in, n_out, n_alloc
@app.route("/order/<int:oid>/alloc", methods=["POST"])
@login_required
@role_required("admin", "manager")