feat: 新增备料(采购下单)功能模块,含清空清单

- 独立采购备料清单:选 BOM + 输入套数一键生成计划(计划数量 = 单套用量 × 套数),可手工增删改、导出 Excel

- 备料与生产订单完全解耦,仅记录计划数量、不记账(不产生任何出入库流水)

- 新增「清空清单」功能(/prepare/clear,JS 二次确认 + 操作日志),按当前工厂范围清空

- 配套:PrepPlan 模型、缺口(计划-库存)实时计算、库存随工厂切换过滤
This commit is contained in:
2026-10-19 15:09:20 +08:00
parent e9b605fca6
commit 5a6f4ea4df
6 changed files with 424 additions and 1 deletions
+240 -1
View File
@@ -20,7 +20,7 @@ from flask import (Flask, render_template, request, redirect, url_for,
from sqlalchemy import update
from models import (db, User, Material, Inbound, Outbound,
Bom, BomItem, ProdOrder, ProdOrderAlloc, OperationLog, Factory, Transfer, Substitute)
Bom, BomItem, ProdOrder, ProdOrderAlloc, OperationLog, Factory, Transfer, Substitute, PrepPlan)
# ---------------- 路径(兼容 PyInstaller 冻结) ----------------
if getattr(sys, "frozen", False):
@@ -1913,6 +1913,245 @@ def export_inventory():
return send_file(path, as_attachment=True, download_name=os.path.basename(path))
# ---------------- 备料(采购下单用, 不记账) ----------------
def _compute_prep(fid):
"""备料清单 = 手工维护的「计划数量」列表(与生产订单无关, 不记账)。
- 数据来源: prep_plan 表(用户手动添加物料 + 计划数量), 不再联动生产订单。
- 实际库存 = stock_map(fid) (随顶栏工厂过滤; fid=None 为全部工厂汇总)。
- 缺口 = 计划数量 − 实际库存 (>0 需采购 / <0 盈余)。
返回 rows(按物料编号排序)。
"""
q = db.select(PrepPlan)
if fid:
# 选定某厂: 显示该厂专属计划 + 共享(全部范围)计划
q = q.where((PrepPlan.factory_id == fid) | (PrepPlan.factory_id.is_(None)))
# fid 为 None(全部): 显示全部计划
plans = db.session.scalars(q).all()
sm = stock_map(fid)
mats = {m.id: m for m in db.session.scalars(db.select(Material)).all()}
rows = []
for p in plans:
m = mats.get(p.material_id)
if not m:
continue
plan = round(p.plan_qty or 0, 2)
stock = round(sm.get(m.id, 0), 2)
diff = round(plan - stock, 2)
rows.append({"id": p.id, "m": m, "plan": plan, "stock": stock,
"diff": diff, "short": diff > 0.001})
rows.sort(key=lambda r: r["m"].part_number)
return rows
@app.route("/prepare")
@login_required
@role_required("admin", "manager")
def prepare():
fid = current_fid()
rows = _compute_prep(fid)
mats = db.session.scalars(
db.select(Material).order_by(Material.part_number)).all()
boms = db.session.scalars(db.select(Bom).order_by(Bom.code)).all()
bom_opts = [{"id": b.id, "label": f"{b.code} · {b.name or ''}"} for b in boms]
return render_template("prepare.html", rows=rows, materials=mats,
bom_opts=bom_opts,
today=date.today().isoformat())
@app.route("/prepare/add", methods=["POST"])
@login_required
@role_required("admin", "manager")
def prepare_add():
"""手工添加(或更新)一条备料计划: 指定物料编号 + 计划数量(不记账)。"""
fid = current_fid()
pn = (request.form.get("part_number") or "").strip()
qty_raw = (request.form.get("qty") or "").strip()
if not pn:
flash("请填写物料编号", "warn")
return redirect(url_for("prepare"))
m = db.session.scalar(
db.select(Material).where(Material.part_number == pn))
if not m:
flash(f"未找到物料: {pn}", "warn")
return redirect(url_for("prepare"))
qty = None
if qty_raw != "":
try:
qty = round(float(qty_raw), 2)
except ValueError:
flash("计划数量不是合法数字", "warn")
return redirect(url_for("prepare"))
existing = db.session.scalar(
db.select(PrepPlan).where(PrepPlan.material_id == m.id,
PrepPlan.factory_id == fid))
if existing:
existing.plan_qty = qty
action = "更新"
else:
db.session.add(PrepPlan(material_id=m.id, factory_id=fid, plan_qty=qty))
action = "添加"
db.session.commit()
db.session.add(OperationLog(
operator=(g.user.display_name or g.user.username) if g.user else "?",
action="备料计划", target=pn,
detail=f"{action}备料(数量={qty}, 范围: {fid if fid else '全部'})"))
db.session.commit()
flash(f"已{action}备料: {pn}", "ok")
return redirect(url_for("prepare"))
@app.route("/prepare/add_bom", methods=["POST"])
@login_required
@role_required("admin", "manager")
def prepare_add_bom():
"""按 BOM 批量生成备料计划: 计划数量 = BOM单套用量 × 套数。
物料来源 = 所选 BOM 的 BomItem; 套数由用户直接输入(默认 1 套)。
对该 BOM 中每个物料: 已存在的备料行刷新计划数量, 不存在则新建。
缺口 = 计划数量 − 实际库存(在 save/页面计算)。不记账。
"""
fid = current_fid()
bid_raw = (request.form.get("bom_id") or "").strip()
sets_raw = (request.form.get("sets") or "1").strip()
if not bid_raw:
flash("请选择BOM", "warn")
return redirect(url_for("prepare"))
try:
bid = int(bid_raw)
sets = float(sets_raw) if sets_raw else 1.0
if sets <= 0:
sets = 1.0
except ValueError:
flash("BOM编号或套数不是合法数字", "warn")
return redirect(url_for("prepare"))
b = db.session.get(Bom, bid)
if not b:
flash("未找到该BOM", "warn")
return redirect(url_for("prepare"))
items = db.session.scalars(
db.select(BomItem).where(BomItem.bom_id == b.id)).all()
existing = {p.material_id: p for p in db.session.scalars(
db.select(PrepPlan).where(PrepPlan.factory_id == fid)).all()}
added, updated = 0, 0
for it in items:
plan = round((it.qty_per_unit or 0) * sets, 2)
if it.material_id in existing:
existing[it.material_id].plan_qty = plan
updated += 1
else:
db.session.add(PrepPlan(material_id=it.material_id, factory_id=fid, plan_qty=plan))
added += 1
db.session.commit()
db.session.add(OperationLog(
operator=(g.user.display_name or g.user.username) if g.user else "?",
action="备料计划", target=b.code,
detail=f"按BOM生成计划: 套数{sets}, 新增{added}/刷新{updated}个物料(范围: {fid if fid else '全部'})"))
db.session.commit()
flash(f"已按 BOM 生成计划:新增 {added} 个、刷新 {updated} 个物料"
f"(计划数量 = 单套用量 × {sets} 套)", "ok")
return redirect(url_for("prepare"))
@app.route("/prepare/save", methods=["POST"])
@login_required
@role_required("admin", "manager")
def prepare_save():
"""批量保存已有备料行的「计划数量」(人工录入, 不记账)。"""
fid = current_fid()
saved = 0
for k, v in request.form.items():
if not k.startswith("plan_"):
continue
try:
pid = int(k.split("_", 1)[1])
except (ValueError, IndexError):
continue
val = (v or "").strip()
q = None
if val != "":
try:
q = round(float(val), 2)
except ValueError:
continue # 非法输入跳过
p = db.session.get(PrepPlan, pid)
if not p:
continue
p.plan_qty = q
saved += 1
db.session.commit()
db.session.add(OperationLog(
operator=(g.user.display_name or g.user.username) if g.user else "?",
action="备料计划", target="备料清单",
detail=f"保存 {saved} 条计划数量(范围: {fid if fid else '全部'})"))
db.session.commit()
flash(f"已保存 {saved} 条备料计划数量", "ok")
return redirect(url_for("prepare"))
@app.route("/prepare/delete/<int:pid>", methods=["POST"])
@login_required
@role_required("admin", "manager")
def prepare_delete(pid):
"""删除一条备料计划(不记账)。"""
p = db.session.get(PrepPlan, pid)
if p:
m = db.session.get(Material, p.material_id)
pn = m.part_number if m else f"#{pid}"
db.session.delete(p)
db.session.commit()
db.session.add(OperationLog(
operator=(g.user.display_name or g.user.username) if g.user else "?",
action="备料计划", target=pn, detail="删除备料"))
db.session.commit()
flash(f"已删除备料: {pn}", "ok")
return redirect(url_for("prepare"))
@app.route("/prepare/clear", methods=["POST"])
@login_required
@role_required("admin", "manager")
def prepare_clear():
"""清空当前范围内全部备料计划(不记账, 破坏性操作)。
范围与页面显示一致,避免清空后页面还有残留行:
- 全部工厂视图(fid=None): 删除所有 prep_plan
- 某工厂视图: 删除该厂专属(factory_id==fid) + 共享(NULL) 的计划
"""
fid = current_fid()
if fid:
rows = db.session.scalars(
db.select(PrepPlan).where(
(PrepPlan.factory_id == fid) | (PrepPlan.factory_id.is_(None)))).all()
else:
rows = db.session.scalars(db.select(PrepPlan)).all()
cnt = len(rows)
for p in rows:
db.session.delete(p)
db.session.commit()
db.session.add(OperationLog(
operator=(g.user.display_name or g.user.username) if g.user else "?",
action="备料计划", target="备料清单",
detail=f"清空 {cnt} 条备料计划(范围: {fid if fid else '全部'})"))
db.session.commit()
flash(f"已清空 {cnt} 条备料计划", "ok")
return redirect(url_for("prepare"))
@app.route("/prepare/export")
@login_required
@role_required("admin", "manager")
def prepare_export():
fid = current_fid()
rows = _compute_prep(fid)
cur = db.session.get(Factory, fid) if fid else None
scope = (f"{cur.code}·{cur.name}" if cur else "全部工厂(汇总)")
from excel_io import export_prep
path = os.path.join(EXE_DIR, f"备料清单_{date.today().isoformat()}.xlsx")
export_prep(path, rows, scope)
return send_file(path, as_attachment=True, download_name=os.path.basename(path))
@app.route("/logs")
@login_required
@role_required("admin", "manager")