feat: 新增备料(采购下单)功能模块,含清空清单
- 独立采购备料清单:选 BOM + 输入套数一键生成计划(计划数量 = 单套用量 × 套数),可手工增删改、导出 Excel - 备料与生产订单完全解耦,仅记录计划数量、不记账(不产生任何出入库流水) - 新增「清空清单」功能(/prepare/clear,JS 二次确认 + 操作日志),按当前工厂范围清空 - 配套:PrepPlan 模型、缺口(计划-库存)实时计算、库存随工厂切换过滤
This commit is contained in:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user