feat: 物料编号可编辑 + 工作台行内编辑/搜索 + 编号冲突合并/删除
- 物料编辑页物料编号放开可编辑并校验唯一性 - 工厂工作台看板行内可编辑型号/单位/安全库存/物料编号 - 工作台新增物料编号/型号搜索过滤 - 编号冲突时支持合并到已存在物料(保留流水)或删除整条物料(删前备份)
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user