diff --git a/app.py b/app.py index dccf391..e0d3ea5 100644 --- a/app.py +++ b/app.py @@ -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//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") diff --git a/static/style.css b/static/style.css index 83ebecb..39c721c 100644 --- a/static/style.css +++ b/static/style.css @@ -74,7 +74,7 @@ button:hover, .btn:hover { filter: brightness(.96); } .row-danger { background: #fff1f0; } .row-warn { background: #fffbe6; } -.badge { padding: 2px 8px; border-radius: 10px; font-size: 12px; } +.badge { display: inline-block; white-space: nowrap; padding: 2px 8px; border-radius: 10px; font-size: 12px; } .badge.ok { background: #e6f7ed; color: #1a7f47; } .badge.warn { background: #fff7e6; color: #ad6800; } .badge.danger { background: #fff1f0; color: #cf1322; } diff --git a/templates/order_detail.html b/templates/order_detail.html index 0472130..944d70c 100644 --- a/templates/order_detail.html +++ b/templates/order_detail.html @@ -8,38 +8,41 @@

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

-

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

+

BOM: {{ bom.code if bom else '-' }} ({{ bom.name if bom else '' }}) | 接单量: {{ '%.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 %}

{% if o.remark %}

备注: {{ o.remark }}

{% endif %}

提示:点击「编辑订单」保存后,该订单会冲销已产生的调入/领料流水并清空调入计划,自动回到「刚下单」状态(待领料、未调拨),可重新规划。

-

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

-

库存口径:下方「下单前库存」为 该订单下单那一刻的库存快照(已自动剔除本单后续调入/领料及下单之后的出入库),用于还原下单时的可供料情况,与实时库存可能不同。{% if as_of %}快照时点:{{ as_of.strftime('%Y-%m-%d %H:%M') }}。{% endif %}

+

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

+

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

- - + + {% for r in reqs %} + + - - + - +
物料编号型号需求数量调入数量下单前库存缺口状态可替代料物料编号型号下单前库存需求数量缺口调入数量状态工厂库存可替代料
{{ r.m.part_number }} {{ r.m.manufacturer or '-' }}{{ '%.2f'|format(r.have) }} {{ '%.2f'|format(r.need) }}{{ '%.2f'|format(r.gap) }} {% if r.posted %} - 已调入 {{ '%.2f'|format(r.alloc) }} +
已调入
+ {% else %} {% endif %}
{{ '%.2f'|format(r.have) }}{{ '%.2f'|format(r.gap) }} {{ '缺料' if r.short else '充足' }}{{ '%.2f'|format(r.gap + r.alloc) }} {% if r.subs %} {% for s in r.subs %} diff --git a/templates/order_form.html b/templates/order_form.html index ace2032..4ee1463 100644 --- a/templates/order_form.html +++ b/templates/order_form.html @@ -3,7 +3,7 @@ {% block content %} + {% if o %}onsubmit="return confirm('确认保存修改?已录入的调入数量与已入账流水将保持不变。');"{% endif %}>

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

diff --git a/templates/orders.html b/templates/orders.html index 880f919..9b95263 100644 --- a/templates/orders.html +++ b/templates/orders.html @@ -13,7 +13,7 @@
{{ d.o.order_no }} {{ d.bom.code if d.bom else '-' }} ({{ d.bom.name if d.bom else '' }}) {{ '%.2f'|format(d.o.qty) }}{{ d.factory.code if d.factory else '未指定' }}{% if d.factory %}{{ d.factory.code }}{% if d.factory.name %} · {{ d.factory.name }}{% endif %}{% else %}未指定{% endif %} {{ '已领料' if d.o.status=='picked' else '待领料' }} {{ d.o.operator or '-' }} diff --git a/templates/txn.html b/templates/txn.html index 0033ae0..528bdef 100644 --- a/templates/txn.html +++ b/templates/txn.html @@ -27,7 +27,7 @@