feat: 物料编号可编辑 + 工作台行内编辑/搜索 + 编号冲突合并/删除

- 物料编辑页物料编号放开可编辑并校验唯一性
- 工厂工作台看板行内可编辑型号/单位/安全库存/物料编号
- 工作台新增物料编号/型号搜索过滤
- 编号冲突时支持合并到已存在物料(保留流水)或删除整条物料(删前备份)
This commit is contained in:
2026-10-10 16:08:33 +08:00
parent 574d608e11
commit 882508aee9
4 changed files with 287 additions and 58 deletions
+140 -3
View File
@@ -17,6 +17,7 @@ from functools import wraps
from flask import (Flask, render_template, request, redirect, url_for,
session, flash, g, send_file, jsonify)
from sqlalchemy import update
from models import (db, User, Material, Inbound, Outbound,
Bom, BomItem, ProdOrder, ProdOrderAlloc, OperationLog, Factory, Transfer, Substitute)
@@ -456,7 +457,12 @@ def factory_workbench(fid):
f = db.session.get(Factory, fid)
if not f:
return redirect(url_for("factories"))
mats = db.session.scalars(db.select(Material).order_by(Material.part_number)).all()
q = (request.args.get("q") or "").strip()
stmt = db.select(Material)
if q:
like = f"%{q}%"
stmt = stmt.where((Material.part_number.like(like)) | (Material.manufacturer.like(like)))
mats = db.session.scalars(stmt.order_by(Material.part_number)).all()
sm = stock_map(fid)
rows = []
negative = low = 0
@@ -481,7 +487,7 @@ def factory_workbench(fid):
comps.append({"m": mm, "qty": it.qty_per_unit, "stock": st,
"status": _status_of(st, mm.safety_stock)})
bom_data.append({"b": b, "comps": comps})
return render_template("factory_workbench.html", f=f, rows=rows,
return render_template("factory_workbench.html", f=f, rows=rows, q=q,
negative=negative, low=low, bom_data=bom_data)
@@ -539,6 +545,125 @@ def factory_import_materials(fid):
return redirect(url_for("factory_workbench", fid=fid))
def _merge_material_into(src_id, dst_id):
"""把物料 src_id 合并到 dst_id: 转移所有引用(src->dst), 去重替代料, 删除 src。
系统中所有外键均指向整数主键 material.id (不指向 part_number),
所以"允许物料编号改成已存在的号" = 把当前物料并入那个已存在物料。
合并后 dst 保留其编号, src 的库存/出入库/BOM组成/替代料/调拨/生产订单调入计划并入 dst。
"""
# 1) 转移所有引用该物料的记录到目标物料
for tbl in (Inbound, Outbound, BomItem, ProdOrderAlloc, Transfer):
db.session.execute(update(tbl).where(tbl.material_id == src_id).values(material_id=dst_id))
db.session.execute(update(Substitute).where(Substitute.main_id == src_id).values(main_id=dst_id))
db.session.execute(update(Substitute).where(Substitute.sub_id == src_id).values(sub_id=dst_id))
db.session.flush()
# 2) 替代料去重 + 去除自引用(main==sub)
seen = set()
for s in db.session.scalars(
db.select(Substitute).where((Substitute.main_id == dst_id) | (Substitute.sub_id == dst_id))).all():
if s.main_id == s.sub_id:
db.session.delete(s)
continue
key = (s.main_id, s.sub_id)
if key in seen:
db.session.delete(s)
continue
seen.add(key)
db.session.flush()
# 3) 删除源物料
src = db.session.get(Material, src_id)
if src:
db.session.delete(src)
@app.route("/factory/<int:fid>/material/<int:mid>/quickedit", methods=["POST"])
@login_required
@role_required("admin", "manager")
def factory_material_quickedit(fid, mid):
"""工厂工作台内联快速编辑物料主数据(物料编号、型号/规格、单位、安全库存)。
不修改库存(实时聚合)。物料编号是唯一业务编号, 改前校验全局唯一(排除自身)。
保存后返回 JSON, 供前端原地刷新。
"""
f = db.session.get(Factory, fid)
m = db.session.get(Material, mid)
if not f or not m:
return jsonify({"ok": False, "msg": "工厂或物料不存在"}), 404
# 物料编号(可编辑, 允许改成"已存在的号" = 合并到那个物料)
new_pn = (request.form.get("part_number") or "").strip()
old_pn = (m.part_number or "").strip()
# 仅当真正改变(去除首尾空白后不同)才做处理, 避免只是重新输入同样编号(或多了空格)被误判
if new_pn and new_pn != old_pn:
dup = db.session.scalar(
db.select(Material).where(Material.part_number == new_pn, Material.id != mid))
if dup:
# 已存在的号: 默认先询问是否合并(前端二次确认后带 confirm_merge 重提)
if not request.form.get("confirm_merge"):
return jsonify({
"ok": False, "needs_confirm": True, "dup_id": dup.id,
"msg": (f"物料编号 {new_pn} 已存在 (已被物料 id={dup.id} "
f"{dup.manufacturer or ''} 占用, 该物料可能不在当前工厂看板显示)。\n"
f"点「确定」将把当前物料【合并】到该编号物料: 其库存/出入库/BOM/替代料/调拨"
f"记录并入目标, 当前物料记录被删除。\n"
f"若不想合并, 请点「取消」并另填一个不重复的编号。")
}), 409
# 确认合并: 把 m 并入 dup
_merge_material_into(m.id, dup.id)
db.session.commit()
log_action("工厂工作台合并物料", f"{old_pn}(id={m.id}) -> {new_pn}(id={dup.id})", f"厂 {f.code}")
return jsonify({"ok": True, "merged": True, "part_number": dup.part_number,
"msg": f"已合并到 {new_pn}; 原记录删除"})
m.part_number = new_pn
m.manufacturer = request.form.get("manufacturer", m.manufacturer) or ""
m.unit = (request.form.get("unit", m.unit) or "个") or "个"
try:
m.safety_stock = float(request.form.get("safety_stock") or 0)
except (ValueError, TypeError):
pass
m.note = request.form.get("note", m.note) or m.note
db.session.commit()
log_action("工厂工作台编辑物料", f"{old_pn} -> {m.part_number}", f"厂 {f.code}")
return jsonify({"ok": True, "part_number": m.part_number, "manufacturer": m.manufacturer,
"unit": m.unit, "safety_stock": m.safety_stock})
@app.route("/factory/<int:fid>/material/<int:mid>/delete", methods=["POST"])
@login_required
@role_required("admin", "manager")
def factory_material_delete(fid, mid):
"""删除整条物料记录(及其所有库存/出入库/BOM组成/替代料/调拨/生产订单调入计划)。
⚠️ 破坏性操作: 删除前自动备份数据库到 _backup/del_mat_<时间戳>.db。
"""
f = db.session.get(Factory, fid)
m = db.session.get(Material, mid)
if not f or not m:
return jsonify({"ok": False, "msg": "工厂或物料不存在"}), 404
pn = m.part_number
# 删除前备份
try:
_bak = os.path.join(EXE_DIR, "_backup", f"del_mat_{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
# 删除所有引用该物料的记录
for tbl in (Inbound, Outbound, BomItem, ProdOrderAlloc, Transfer):
db.session.query(tbl).filter(tbl.material_id == mid).delete(synchronize_session=False)
db.session.query(Substitute).filter(
(Substitute.main_id == mid) | (Substitute.sub_id == mid)).delete(synchronize_session=False)
db.session.delete(m)
db.session.commit()
log_action("工厂工作台删除物料", pn,
f"厂 {f.code}" + (f"; 备份 {os.path.basename(_bak)}" if _bak else "(备份失败)"))
return jsonify({"ok": True, "msg": f"已删除物料 {pn} 及其全部关联记录"
+ (f"(数据库已备份 {os.path.basename(_bak)})" if _bak else "")})
@app.route("/dashboard")
@login_required
def dashboard():
@@ -638,12 +763,24 @@ def material_edit(mid):
if not m:
return redirect(url_for("materials"))
if request.method == "POST":
new_pn = (request.form.get("part_number") or "").strip()
old_pn = m.part_number
if not new_pn:
flash("物料编号不能为空", "error")
return render_template("material_form.html", m=m)
# 唯一性校验(排除自身)
dup = db.session.scalar(
db.select(Material).where(Material.part_number == new_pn, Material.id != mid))
if dup:
flash(f"物料编号 {new_pn} 已存在, 请更换", "error")
return render_template("material_form.html", m=m)
m.part_number = new_pn
m.manufacturer = request.form.get("manufacturer", "")
m.unit = request.form.get("unit", "个") or "个"
m.safety_stock = float(request.form.get("safety_stock") or 0)
m.note = request.form.get("note", "")
db.session.commit()
log_action("编辑物料", m.part_number)
log_action("编辑物料", f"{old_pn} -> {new_pn}")
flash("已保存", "ok")
return redirect(url_for("materials"))
return render_template("material_form.html", m=m)