feat: 生产订单库存口径/排序/编辑体验与入库默认工厂优化

- 入库登记「所属工厂」默认选中总部(SELF),出库不变
- 编辑生产订单改为仅更新字段:改单号同步关联出入库 order_ref、保留已录入调入数量与已入账流水(移除冲销逻辑)
- 生产订单列表/详情按最终订单号(WO+YYYYMMDD+序号)降序排序
- 生产订单详情库存/缺口改用「下单前库存快照」(按下单时间截断),缺口=库存-需求,升序使缺料排前
- 生产工厂列显示「编号 · 公司名称」
- 详情列表列序调整:下单前库存置需求左边、调入数量置缺口右边;状态右侧新增「工厂库存」列(=缺口+调入数量)
- 状态「缺料/充足」徽标强制单行显示(.badge white-space:nowrap)
- 已调入(已入账)数量可编辑:保存/确认后按新值自动撤销原流水并从原来源厂重新入账,源厂库存不足判定缺料保留原入账,系统总库存不变
This commit is contained in:
2026-10-15 13:30:15 +08:00
parent 882508aee9
commit 012e018b12
6 changed files with 198 additions and 96 deletions
+183 -84
View File
@@ -84,6 +84,16 @@ def current_fid():
return session.get("factory_id")
def headquarters_fid():
"""总部(自厂)工厂 id, 即 code='SELF'(name='自厂/总部')。
入库登记默认归属总部。"""
try:
f = db.session.scalar(db.select(Factory).where(Factory.code == "SELF"))
return f.id if f else None
except Exception:
return None
@app.context_processor
def inject_factory_ctx():
"""向所有模板注入工厂列表与当前选中工厂, 供顶栏切换器使用。"""
@@ -861,7 +871,9 @@ def inbound():
if fid:
stmt = stmt.where(Inbound.factory_id == fid)
recs = db.session.scalars(stmt).all()
return render_template("txn.html", kind="inbound", mats=mats, recs=recs, today=date.today().isoformat())
return render_template("txn.html", kind="inbound", mats=mats, recs=recs,
today=date.today().isoformat(),
default_factory_id=headquarters_fid())
@app.route("/outbound", methods=["GET", "POST"])
@@ -1424,7 +1436,7 @@ def bom_detail(bid):
@login_required
@role_required("admin", "manager")
def orders():
os_ = db.session.scalars(db.select(ProdOrder).order_by(ProdOrder.id.desc())).all()
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)
@@ -1469,10 +1481,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()
# 查看订单时, 库存按「下单那一刻之前」的快照展示, 而非当前实时库存,
# 这样能还原下单时的真实可供料情况(已自动排除本单后续的调入/领料)。
as_of = o.created_at
sm = stock_map_as_of(o.factory_id, as_of)
# 查看订单时, 库存/缺口按「下单之前」的工厂库存快照展示(即按下单那一刻的库存),
# 按下单时间截断, 自动排除该订单自身后续的调入/领料及下单后发生的其他出入库,
# 用于还原下单时的可供料情况, 不受后续出入库影响。
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(
@@ -1486,18 +1498,21 @@ 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, as_of)]
for s in substitute_rows(m.id, o.factory_id, None)]
reqs.append({"m": m, "need": need, "have": have,
"gap": need - have, "short": need > have, "subs": subs,
"gap": have - need, "short": need > have, "subs": subs,
"alloc": a.alloc_qty if a else 0,
"posted": a.posted if a else False})
# 缺口 = 工厂库存 - 需求(少为负=缺料, 多为正=富余); 按缺口升序(从小到大),
# 使缺料最严重(缺口最负)的物料排在最前, 便于优先调度
reqs.sort(key=lambda x: x["gap"])
posted_total = sum(a.alloc_qty for a in allocs if a.posted)
planned_total = sum(a.alloc_qty for a in allocs)
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, as_of=as_of,
factories=factories,
posted_total=posted_total, planned_total=planned_total,
posted_count=posted_count)
@@ -1563,20 +1578,31 @@ 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)
# 仅更新订单头; 已录入的「调入数量」计划与已入账流水保持不变
# (需求: 改订单号/改生产数量均不冲销, 调入数量保持编辑前的值)。
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()
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)
@@ -1605,34 +1631,123 @@ def order_delete(oid):
return redirect(url_for("orders"))
def _reset_order_to_placed(o, order_no):
"""把订单冲销回「刚下单」状态: 撤销该订单关联的调入入库 / 调出 / 领料流水, 清空调入计划, 状态置回 planned。
def _find_posted_flows(order_no, material_id):
"""返回某生产订单某物料已入账调入流水(源厂出库 + 生产厂入库)。"""
outs = db.session.scalars(db.select(Outbound).where(
Outbound.order_ref == order_no, Outbound.material_id == material_id,
Outbound.purpose == "生产订单调出")).all()
ins = db.session.scalars(db.select(Inbound).where(
Inbound.order_ref == order_no, Inbound.material_id == material_id,
Inbound.source == "生产订单调入")).all()
return outs, ins
- 调入入库(Inbound, source=生产订单调入) 与 调出/领料(Outbound, purpose in (生产领用, 生产订单调出)) 均按 order_ref 关联, 直接删除即冲销(总库存/厂间余额恢复)。
- 调入计划(prod_order_alloc) 按 order_id 清除。
- 返回 (冲销入库笔数, 冲销出库笔数, 清除计划行数)。
def _cancel_posted(o, material_id, op):
"""撤销某物料已入账的调入流水(删除源厂出库 + 生产厂入库), 不改变 alloc 行本身。"""
outs, ins = _find_posted_flows(o.order_no, material_id)
for x in outs + ins:
db.session.delete(x)
if outs or ins:
log_action("撤销调入", o.order_no,
f"物料ID {material_id} 撤销原调入流水 出{len(outs)}/入{len(ins)} 笔")
return len(outs), len(ins)
def _repost(o, material_id, qty, from_id, op, ff):
"""按指定源厂重新生成调入流水(源厂出库减 + 生产厂入库加, 总库存不变)。"""
db.session.add(Outbound(
material_id=material_id, factory_id=from_id, qty=qty,
operator=op, purpose="生产订单调出", order_ref=o.order_no,
occur_date=date.today(),
remark=f"生产订单 {o.order_no} 调入(自 {ff.code})"))
db.session.add(Inbound(
material_id=material_id, factory_id=o.factory_id, qty=qty,
operator=op, source="生产订单调入", order_ref=o.order_no,
occur_date=date.today(),
remark=f"生产订单 {o.order_no} 调入(自 {ff.code})"))
def _apply_alloc_change(o, material_id, new_qty, op, from_id=None, account_new=False):
"""把某物料的「调入数量」调整到 new_qty, 并保证实际库存流水与之一致。
返回状态: 'unchanged' / 'planned' / 'posted' / 'cancel' / 'short' / 'nosource'
- 未入账行: account_new=True 且给定 from_id(≠生产厂) 时直接入账; 否则仅更新计划(不生成流水)。
- 已入账行: 若 new_qty 与已入账数量不同, 先撤销原流水, 再从原始来源厂重新入账(或归零撤销)。
源厂库存校验在撤销前做(避免撤销后因源厂不足而无法回补、丢失调入)。
"""
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
new_qty = max(float(new_qty), 0.0)
a = db.session.scalar(db.select(ProdOrderAlloc).where(
ProdOrderAlloc.order_id == o.id,
ProdOrderAlloc.material_id == material_id))
posted = bool(a and a.posted)
changed = (a is None) or (abs((a.alloc_qty or 0) - new_qty) > 1e-9)
if new_qty <= 0:
if posted:
_cancel_posted(o, material_id, op)
a.alloc_qty = 0
a.posted = False
return "cancel"
if a is not None:
a.alloc_qty = 0 # 未入账计划归零(保留行)
return "planned"
if posted:
if not changed:
return "unchanged"
outs, _ = _find_posted_flows(o.order_no, material_id)
if not outs:
return "nosource"
src_fid = outs[0].factory_id
old_qty = outs[0].qty
# 撤销后源厂可用 = 当前源厂库存(仍含原调出扣减) + 原调出量
available = stock_map(src_fid).get(material_id, 0) + old_qty
if new_qty > available:
return "short" # 源厂不足, 保留原入账不动
_cancel_posted(o, material_id, op)
ff = db.session.get(Factory, src_fid)
_repost(o, material_id, new_qty, src_fid, op, ff)
a.alloc_qty = new_qty
a.posted = True
return "posted"
# 未入账
if account_new and from_id is not None and from_id != (o.factory_id or -1):
src_stock = stock_map(from_id).get(material_id, 0)
if new_qty > src_stock:
return "short"
ff = db.session.get(Factory, from_id)
_repost(o, material_id, new_qty, from_id, op, ff)
if a is None:
a = ProdOrderAlloc(order_id=o.id, material_id=material_id)
db.session.add(a)
a.alloc_qty = new_qty
a.posted = True
return "posted"
# 仅更新计划, 不生成流水
if a is None:
a = ProdOrderAlloc(order_id=o.id, material_id=material_id, alloc_qty=new_qty)
db.session.add(a)
else:
a.alloc_qty = new_qty
return "planned"
@app.route("/order/<int:oid>/alloc", methods=["POST"])
@login_required
@role_required("admin", "manager")
def order_alloc_save(oid):
"""保存生产订单各物料的「调入数量」(手动录入, 不生成流水)。"""
"""保存生产订单各物料的「调入数量」。
- 未入账行: 更新计划(不生成流水)。
- 已入账(已调入)行: 若数量被改动, 自动撤销原流水并按原始来源厂重新入账(最终调入数量=确认后的数量); 改为 0 则撤销调入。
"""
o = db.session.get(ProdOrder, 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()
edited = 0
for it in items:
raw = request.form.get(f"alloc_{it.material_id}", "").strip()
if raw == "":
@@ -1643,17 +1758,15 @@ def order_alloc_save(oid):
continue
if qty < 0:
qty = 0
a = db.session.scalar(db.select(ProdOrderAlloc).where(
ProdOrderAlloc.order_id == oid,
ProdOrderAlloc.material_id == it.material_id))
if a is None:
a = ProdOrderAlloc(order_id=oid, material_id=it.material_id, alloc_qty=qty)
db.session.add(a)
else:
a.alloc_qty = qty
st = _apply_alloc_change(o, it.material_id, qty, op, account_new=False)
if st in ("posted", "cancel"):
edited += 1
db.session.commit()
log_action("保存调入数量", o.order_no, "更新调入计划")
flash("已保存调入数量", "ok")
if edited:
log_action("保存调入数量", o.order_no, f"已调整 {edited} 项已入账调入(撤销并重入)")
flash(f"已保存调入数量(其中 {edited} 项已入账调入已按新值调整)", "ok")
else:
flash("已保存调入数量", "ok")
return redirect(url_for("order_detail", oid=oid))
@@ -1661,23 +1774,22 @@ def order_alloc_save(oid):
@login_required
@role_required("admin", "manager")
def order_alloc_post(oid):
"""确认调入: 这是一次「调拨」——从选定的源工厂出库减, 同时计入生产工厂入库加, 总库存不变。
"""确认调入: 厂间调拨(源厂出库减 + 生产厂入库加, 总库存不变)。
与「保存调入数量」共用同一表单(formaction 提交), 一次点击即可先记录再入账。
源工厂由表单 from_factory_id 指定(默认自厂 SELF); 自动校验源厂库存, 不足则跳过并提示。
与「保存调入数量」共用同一表单(formaction 提交)。
- 未入账行: 按所选来源厂入账。
- 已入账(已调入)行: 若数量被改动, 先撤销原流水、再从原始来源厂重新入账; 改为 0 则撤销调入。
源厂不足时跳过并提示, 原入账保持不变。
"""
o = db.session.get(ProdOrder, oid)
if not o:
return redirect(url_for("orders"))
# 解析源工厂
# 解析源工厂(用于未入账行的首次入账)
from_raw = (request.form.get("from_factory_id") or "").strip()
if not from_raw.isdigit():
flash("请先选择「调入来源工厂」", "error")
return redirect(url_for("order_detail", oid=oid))
from_id = int(from_raw)
if from_id == (o.factory_id or -1):
flash("调入来源工厂不能与生产工厂相同", "error")
return redirect(url_for("order_detail", oid=oid))
ff = db.session.get(Factory, from_id)
if not ff:
flash("调入来源工厂不存在", "error")
@@ -1698,36 +1810,23 @@ def order_alloc_post(oid):
a = db.session.scalar(db.select(ProdOrderAlloc).where(
ProdOrderAlloc.order_id == oid,
ProdOrderAlloc.material_id == it.material_id))
if qty is None and a and not a.posted and a.alloc_qty > 0:
qty = a.alloc_qty # 回退到已保存计划
if qty is None or qty <= 0:
continue
if a and a.posted:
continue # 已入账, 跳过避免重复
# 校验源厂库存
src_stock = stock_map(from_id).get(it.material_id, 0)
if qty > src_stock:
if qty is None:
if a and a.posted:
qty = a.alloc_qty # 已入账且无输入 = 维持, helper 判定 unchanged 跳过
else:
continue # 未入账且无输入 = 不处理
if qty < 0:
qty = 0
st = _apply_alloc_change(o, it.material_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)
shorts.append(f"{m.part_number}(源厂{ff.code}库存{src_stock:.2f}, 需{qty:.2f})")
continue
if a is None:
a = ProdOrderAlloc(order_id=oid, material_id=it.material_id)
db.session.add(a)
a.alloc_qty = qty
# 源厂出库减
db.session.add(Outbound(
material_id=it.material_id, factory_id=from_id, qty=qty,
operator=op, purpose="生产订单调出", order_ref=o.order_no,
occur_date=date.today(),
remark=f"生产订单 {o.order_no} 调入(自 {ff.code})"))
# 生产厂入库加
db.session.add(Inbound(
material_id=it.material_id, factory_id=o.factory_id, qty=qty,
operator=op, source="生产订单调入", order_ref=o.order_no,
occur_date=date.today(),
remark=f"生产订单 {o.order_no} 调入(自 {ff.code})"))
a.posted = True
n += 1
if a and a.posted:
shorts.append(f"{m.part_number}(已入账调入修改, 源厂{ff.code}库存不足)")
else:
shorts.append(f"{m.part_number}(源厂{ff.code}库存不足)")
db.session.commit()
if n == 0 and not shorts:
flash("没有新的调入数量待确认(数量需>0, 且不能重复入账)", "warn")