feat: BOM批量导入型号/名称可编辑 + 生产订单查看按下单前库存快照 + 编辑冲销回下单状态
This commit is contained in:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user