diff --git a/app.py b/app.py index 16d7d24..d6c35b9 100644 --- a/app.py +++ b/app.py @@ -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//alloc", methods=["POST"]) @login_required @role_required("admin", "manager") diff --git a/push_to_gitea.bat b/push_to_gitea.bat new file mode 100644 index 0000000..1183941 --- /dev/null +++ b/push_to_gitea.bat @@ -0,0 +1,36 @@ +@echo off +REM ============================================================ +REM 一键推送本地提交到 Gitea +REM 使用前提:本机能访问公司内网 / 已连 VPN,能解析 git.btw-ocr.com +REM Gitea 凭据由系统凭据助手(helper-selector / GCM)提供,无需手填 +REM ============================================================ +cd /d "D:\2026-09-10-task-20\material_wms" + +echo [1/3] 拉取远程最新... +git fetch origin +if %errorlevel% neq 0 ( + echo fetch 失败:请确认已连内网/VPN,且能访问 git.btw-ocr.com + pause + exit /b 1 +) + +echo [2/3] 将本地提交 rebase 到远程 main 之上... +git rebase origin/main +if %errorlevel% neq 0 ( + echo rebase 冲突:请手动解决冲突后 git rebase --continue,或运行 git rebase --abort 撤销 + pause + exit /b 1 +) + +echo [3/3] 推送到 Gitea... +git push origin main +if %errorlevel% neq 0 ( + echo push 失败,请检查凭据或网络后重试 + pause + exit /b 1 +) + +echo. +echo ===== 推送完成 ===== +echo 可在 https://git.btw-ocr.com/star/Inventory-Management-System 核对最新提交 +pause diff --git a/static/style.css b/static/style.css index 3cd7ad3..0ef212b 100644 --- a/static/style.css +++ b/static/style.css @@ -143,6 +143,9 @@ tr.row-sim-danger td { color: #cf1322; } /* 批量导入窗口: 多 BOM 卡片 */ .bom-card { border: 1px solid #e3e8ef; border-radius: 8px; margin-bottom: 10px; overflow: hidden; } .bom-card.card-danger { border-color: #ffccc7; } -.bom-card .bom-head { background: #f0f3f7; padding: 8px 12px; font-size: 14px; display: flex; align-items: center; gap: 8px; } +.bom-card .bom-head { background: #f0f3f7; padding: 8px 12px; font-size: 14px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } .bom-card.card-danger .bom-head { background: #fff1f0; } +.bom-card .bom-head label.bh-lbl { margin: 0; white-space: nowrap; color: #44525f; font-size: 12px; } +.bom-card .bom-head input.bom-code-inp { width: 160px; flex: 0 0 auto; padding: 5px 8px; font-size: 13px; } +.bom-card .bom-head input.bom-name-inp { flex: 1 1 220px; min-width: 180px; padding: 5px 8px; font-size: 13px; } .bom-card .tbl { box-shadow: none; border-radius: 0; margin: 0; } diff --git a/templates/boms.html b/templates/boms.html index fb79748..92635af 100644 --- a/templates/boms.html +++ b/templates/boms.html @@ -1,58 +1,58 @@ {% extends "base.html" %} {% block title %}BOM 清单{% endblock %} {% block content %} -
-

BOM 清单 ({{ data|length }})

-
- ⬇ 下载模板 - +
+

BOM 清单 ({{ data|length }})

+
+ ⬇ 下载模板 +
- - - +
BOM 型号名称组成项数操作
+ + {% for d in data %} - - - - - + + + + {% else %} - + {% endfor %}
BOM 型号名称组成项数操作
{{ d.b.code }}{{ d.b.name or '-' }}{{ d.count }} - 查看 - 编辑 -
- +
{{ d.b.code }}{{ d.b.name or '-' }}{{ d.count }} + 查看 + 编辑 + +
还没有 BOM, 点右上角新增
还没有 BOM, 点右上角新增
-