diff --git a/app.py b/app.py index d6c35b9..dccf391 100644 --- a/app.py +++ b/app.py @@ -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//material//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//material//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) diff --git a/static/style.css b/static/style.css index 0ef212b..83ebecb 100644 --- a/static/style.css +++ b/static/style.css @@ -44,12 +44,17 @@ label { display: block; margin: 10px 0 4px; font-size: 13px; color: #44525f; } input, select, textarea { width: 100%; padding: 8px 10px; border: 1px solid #cdd5df; border-radius: 6px; font-size: 14px; } +.cell-inp { width: 100%; box-sizing: border-box; padding: 4px 6px; border: 1px solid #c9d2dc; border-radius: 4px; font-size: 13px; } +.cell-inp.num { text-align: right; } +.readonly-field { width: 100%; box-sizing: border-box; padding: 8px 10px; border: 1px solid #dfe3e8; border-radius: 6px; background: #f1f3f6; color: #6b7280; font-size: 14px; } +.btn.small, button.small { padding: 5px 10px; font-size: 12px; border-radius: 5px; } button, .btn { display: inline-block; cursor: pointer; border: none; border-radius: 6px; padding: 9px 16px; font-size: 14px; text-decoration: none; background: #eef1f5; color: #1f2733; } button.primary, .btn.primary { background: #1f3a5f; color: #fff; } button.secondary, .btn.secondary { background: #2f7d5b; color: #fff; } +button.danger, .btn.danger { background: #e6453c; color: #fff; border-color: #d23b32; } button:hover, .btn:hover { filter: brightness(.96); } .link-danger { background: none; border: none; color: #c0392b; cursor: pointer; padding: 0 4px; font-size: 14px; } .link-danger:hover { text-decoration: underline; } diff --git a/templates/factory_workbench.html b/templates/factory_workbench.html index c570fd8..4a74881 100644 --- a/templates/factory_workbench.html +++ b/templates/factory_workbench.html @@ -2,93 +2,107 @@ {% block title %}工厂工作台 · {{ f.code }}{% endblock %} {% block content %} -
-

工厂工作台:{{ f.code }} · {{ f.name or '' }} - {{ '启用' if f.enabled else '停用' }} +
+

工厂工作台:{{ f.code }} · {{ f.name or '' }} + {{ '启用' if f.enabled else '停用' }}

- ← 返回工厂列表 + ← 返回工厂列表
-
- - - +
+ + +
-
-
-
{{ rows|length }}
有库存物料
-
{{ low }}
低于安全库存
-
{{ negative }}
库存为负
+
+
+ + + {% if q %}清除{% endif %} +
+ {% if q %}

筛选「{{ q }}」命中 {{ rows|length }} 条(仅含在厂库存不为 0 的物料)

{% endif %} +
+
{{ rows|length }}
有库存物料
+
{{ low }}
低于安全库存
+
{{ negative }}
库存为负
- - - +
物料编号型号/规格单位安全库存在厂库存状态
+ + {% for r in rows %} - - - - - - - + + + + + + + + {% else %} - + {% endfor %}
物料编号型号/规格单位安全库存在厂库存状态操作
{{ r.m.part_number }}{{ r.m.manufacturer or '-' }}{{ r.m.unit or '-' }}{{ r.m.safety_stock or 0 }}{{ '%.2f'|format(r.stock) }}{{ '负库存' if r.status=='danger' else ('低于安全库存' if r.status=='warn' else '正常') }}
{{ r.m.part_number }}{{ r.m.manufacturer or '-' }}{{ r.m.unit or '-' }}{{ r.m.safety_stock or 0 }}{{ '%.2f'|format(r.stock) }}{{ '负库存' if r.status=='danger' else ('低于安全库存' if r.status=='warn' else '正常') }} + + +
该厂暂无库存数据
该厂暂无库存数据
-