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, from flask import (Flask, render_template, request, redirect, url_for,
session, flash, g, send_file, jsonify) session, flash, g, send_file, jsonify)
from sqlalchemy import update
from models import (db, User, Material, Inbound, Outbound, from models import (db, User, Material, Inbound, Outbound,
Bom, BomItem, ProdOrder, ProdOrderAlloc, OperationLog, Factory, Transfer, Substitute) Bom, BomItem, ProdOrder, ProdOrderAlloc, OperationLog, Factory, Transfer, Substitute)
@@ -456,7 +457,12 @@ def factory_workbench(fid):
f = db.session.get(Factory, fid) f = db.session.get(Factory, fid)
if not f: if not f:
return redirect(url_for("factories")) 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) sm = stock_map(fid)
rows = [] rows = []
negative = low = 0 negative = low = 0
@@ -481,7 +487,7 @@ def factory_workbench(fid):
comps.append({"m": mm, "qty": it.qty_per_unit, "stock": st, comps.append({"m": mm, "qty": it.qty_per_unit, "stock": st,
"status": _status_of(st, mm.safety_stock)}) "status": _status_of(st, mm.safety_stock)})
bom_data.append({"b": b, "comps": comps}) 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) 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)) 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") @app.route("/dashboard")
@login_required @login_required
def dashboard(): def dashboard():
@@ -638,12 +763,24 @@ def material_edit(mid):
if not m: if not m:
return redirect(url_for("materials")) return redirect(url_for("materials"))
if request.method == "POST": 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.manufacturer = request.form.get("manufacturer", "")
m.unit = request.form.get("unit", "个") or "个" m.unit = request.form.get("unit", "个") or "个"
m.safety_stock = float(request.form.get("safety_stock") or 0) m.safety_stock = float(request.form.get("safety_stock") or 0)
m.note = request.form.get("note", "") m.note = request.form.get("note", "")
db.session.commit() db.session.commit()
log_action("编辑物料", m.part_number) log_action("编辑物料", f"{old_pn} -> {new_pn}")
flash("已保存", "ok") flash("已保存", "ok")
return redirect(url_for("materials")) return redirect(url_for("materials"))
return render_template("material_form.html", m=m) return render_template("material_form.html", m=m)
+5
View File
@@ -44,12 +44,17 @@ label { display: block; margin: 10px 0 4px; font-size: 13px; color: #44525f; }
input, select, textarea { input, select, textarea {
width: 100%; padding: 8px 10px; border: 1px solid #cdd5df; border-radius: 6px; font-size: 14px; 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 { button, .btn {
display: inline-block; cursor: pointer; border: none; border-radius: 6px; display: inline-block; cursor: pointer; border: none; border-radius: 6px;
padding: 9px 16px; font-size: 14px; text-decoration: none; background: #eef1f5; color: #1f2733; padding: 9px 16px; font-size: 14px; text-decoration: none; background: #eef1f5; color: #1f2733;
} }
button.primary, .btn.primary { background: #1f3a5f; color: #fff; } button.primary, .btn.primary { background: #1f3a5f; color: #fff; }
button.secondary, .btn.secondary { background: #2f7d5b; 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); } 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 { background: none; border: none; color: #c0392b; cursor: pointer; padding: 0 4px; font-size: 14px; }
.link-danger:hover { text-decoration: underline; } .link-danger:hover { text-decoration: underline; }
+140 -54
View File
@@ -2,93 +2,107 @@
{% block title %}工厂工作台 · {{ f.code }}{% endblock %} {% block title %}工厂工作台 · {{ f.code }}{% endblock %}
{% block content %} {% block content %}
<div class="toolbar"> <div class="toolbar" data-page-node-id="h8tr6moQVxGSdE9dKEg6bd">
<h3 style="margin:0">工厂工作台:{{ f.code }} · {{ f.name or '' }} <h3 style="margin:0" data-page-node-id="mN5n1l6U4DnPTMkfHhjkpo">工厂工作台:{{ f.code }} · {{ f.name or '' }}
<span class="badge {{ 'ok' if f.enabled else 'warn' }}">{{ '启用' if f.enabled else '停用' }}</span> <span class="badge {{ 'ok' if f.enabled else 'warn' }}" data-page-node-id="9lYmmds1mElbCd1ib8GNYD">{{ '启用' if f.enabled else '停用' }}</span>
</h3> </h3>
<a class="btn" href="{{ url_for('factories') }}">← 返回工厂列表</a> <a class="btn" href="{{ url_for('factories') }}" data-page-node-id="gLM7aoKY7uRuTSOFXVSBaL">← 返回工厂列表</a>
</div> </div>
<div class="tabs"> <div class="tabs" data-page-node-id="ante6pBhAiQZDcPHSkk7pm">
<button class="tab active" onclick="switchTab('kanban')">📊 物料看板</button> <button class="tab active" onclick="switchTab('kanban')" data-page-node-id="t8Y9L0Rv6nJWGYeAc4Butp">📊 物料看板</button>
<button class="tab" onclick="switchTab('bom')">🧩 BOM 齐套</button> <button class="tab" onclick="switchTab('bom')" data-page-node-id="1vqssKcNPuRFxIfonFhl2W">🧩 BOM 齐套</button>
<button class="tab" onclick="switchTab('import')">⊞ 批量导入</button> <button class="tab" onclick="switchTab('import')" data-page-node-id="VulCglmTCTpl2I8BI40TEX">⊞ 批量导入</button>
</div> </div>
<!-- 物料看板 --> <!-- 物料看板 -->
<div id="tab-kanban" class="tab-pane"> <div id="tab-kanban" class="tab-pane" data-page-node-id="O5tr8yIq5PuggrETsUYwhX">
<div class="stat-row" style="margin:12px 0"> <form method="get" action="{{ url_for('factory_workbench', fid=f.id) }}" style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin:12px 0" data-page-node-id="DP9ksglRvmmkKQtWX8GzZC">
<div class="stat"><div class="num">{{ rows|length }}</div><div class="lbl">有库存物料</div></div> <input type="text" name="q" value="{{ q }}" placeholder="搜索物料编号 / 型号规格"
<div class="stat warn"><div class="num">{{ low }}</div><div class="lbl">低于安全库存</div></div> style="width:260px;padding:8px 10px;border:1px solid #cdd5df;border-radius:6px;font-size:14px" data-page-node-id="DtGH4sWm3Q8IaidV8Vcsvl">
<div class="stat danger"><div class="num">{{ negative }}</div><div class="lbl">库存为负</div></div> <button class="primary" type="submit" data-page-node-id="9zgPeVdBfILSpKfD4TsDB2">搜索</button>
{% if q %}<a class="btn" href="{{ url_for('factory_workbench', fid=f.id) }}" data-page-node-id="y6UrlI6Z8PXiCHpif7cspa">清除</a>{% endif %}
</form>
{% if q %}<p class="small muted" style="margin:0 0 10px" data-page-node-id="bSjz9L1Hcc4kra4Z4DRTIT">筛选「{{ q }}」命中 <b data-page-node-id="gYjIGP7vAGwZROP3und6Mt">{{ rows|length }}</b> 条(仅含在厂库存不为 0 的物料)</p>{% endif %}
<div class="stat-row" style="margin:12px 0" data-page-node-id="XdjnYnwPez6NSo8RabtdEw">
<div class="stat" data-page-node-id="iWmneOIg5GaNg13oi8Tpml"><div class="num" data-page-node-id="sDfnYz7FDudHWa1F7hWxUj">{{ rows|length }}</div><div class="lbl" data-page-node-id="yUuytA7Ee2uZjYvyOVwdGW">有库存物料</div></div>
<div class="stat warn" data-page-node-id="9WprNxfiSAhkHKJoAfKVUs"><div class="num" data-page-node-id="PFYgLN2kA2eh3ehng7sL4E">{{ low }}</div><div class="lbl" data-page-node-id="cFVCxICUvwmYDEFJZ5LDBc">低于安全库存</div></div>
<div class="stat danger" data-page-node-id="OqyIPhwXMA2FKrB5ElPOnP"><div class="num" data-page-node-id="ymG0ZfkOIrB5d4yj8v3EMR">{{ negative }}</div><div class="lbl" data-page-node-id="EgDYjJQoOn0L8dkCLwkgE1">库存为负</div></div>
</div> </div>
<table class="tbl"> <table class="tbl" data-page-node-id="v2dpNrmLrYl4iBusCHbYId">
<thead><tr><th>物料编号</th><th>型号/规格</th><th>单位</th><th>安全库存</th><th>在厂库存</th><th>状态</th></tr></thead> <thead data-page-node-id="pfxxaWDexxHnrtTIFGOSvu"><tr data-page-node-id="7tQDDZE1giJATqe3rbI0V0"><th data-page-node-id="7PQzAHQJqUuBvUEQahPffK">物料编号</th><th data-page-node-id="u4s8ycQodHcEKS8Aou9MLv">型号/规格</th><th data-page-node-id="BJqNtTZALVaxrbIcqkZsb1">单位</th><th data-page-node-id="GIF5kOqgw826EHX7PEfF27">安全库存</th><th data-page-node-id="SK7R13nwY2nvGYlhCeRu37">在厂库存</th><th data-page-node-id="wImv6pW0xYFq35YHkUHEqk">状态</th><th data-page-node-id="MwGW0LuvnhgNhyhix4ge2L">操作</th></tr></thead>
<tbody> <tbody data-page-node-id="taNmAZ8i4abDFIQijzVWOs">
{% for r in rows %} {% for r in rows %}
<tr class="{{ 'row-danger' if r.status=='danger' else ('row-warn' if r.status=='warn' else '') }}"> <tr class="{{ 'row-danger' if r.status=='danger' else ('row-warn' if r.status=='warn' else '') }}"
<td>{{ r.m.part_number }}</td> data-mid="{{ r.m.id }}" data-fid="{{ f.id }}"
<td>{{ r.m.manufacturer or '-' }}</td> data-pn="{{ r.m.part_number }}" data-mf="{{ r.m.manufacturer or '' }}"
<td>{{ r.m.unit or '-' }}</td> data-unit="{{ r.m.unit or '' }}" data-ss="{{ r.m.safety_stock or 0 }}" data-page-node-id="2cidlTmV6rQwsq9PrbpeeR">
<td class="num">{{ r.m.safety_stock or 0 }}</td> <td data-page-node-id="wJDGDx053XiyTJva6zouSf">{{ r.m.part_number }}</td>
<td class="num">{{ '%.2f'|format(r.stock) }}</td> <td data-page-node-id="GLAB4ys6K8tlRL09h0SVSX">{{ r.m.manufacturer or '-' }}</td>
<td><span class="badge {{ 'danger' if r.status=='danger' else ('warn' if r.status=='warn' else 'ok') }}">{{ '负库存' if r.status=='danger' else ('低于安全库存' if r.status=='warn' else '正常') }}</span></td> <td data-page-node-id="VDNrmbPZHQlBIJ05AoQly5">{{ r.m.unit or '-' }}</td>
<td class="num" data-page-node-id="EjDg4DmtWkB5OjEr7Jaw0J">{{ r.m.safety_stock or 0 }}</td>
<td class="num" data-page-node-id="V5Fp579iiSV2MBYW3ZF2i6">{{ '%.2f'|format(r.stock) }}</td>
<td data-page-node-id="z4HE32qyI5SFj3Kkb0rhS2"><span class="badge {{ 'danger' if r.status=='danger' else ('warn' if r.status=='warn' else 'ok') }}" data-page-node-id="n6KadEnTE72NuD06GKXEIx">{{ '负库存' if r.status=='danger' else ('低于安全库存' if r.status=='warn' else '正常') }}</span></td>
<td data-page-node-id="7DvKrplCD4RKbwZvM67dCa">
<span id="acts_{{ r.m.id }}" data-page-node-id="LvRHno48lkdWEGjdKxTHXX"><button class="btn small" type="button" onclick="editRow(this)" data-page-node-id="NSU10L6H6zY5TGeTPGQFEz">编辑</button></span>
<button class="btn small danger" type="button" onclick="delRow(this)" data-page-node-id="0sV7BYBp0CUIRvBU73nREM">删除</button>
</td>
</tr> </tr>
{% else %} {% else %}
<tr><td colspan="6" class="muted">该厂暂无库存数据</td></tr> <tr data-page-node-id="osy9WfjSd5DiIkWUlHquBR"><td colspan="7" class="muted" data-page-node-id="TR0Ye9v5IRzsMkB8O5DadZ">该厂暂无库存数据</td></tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </div>
<!-- BOM 齐套 --> <!-- BOM 齐套 -->
<div id="tab-bom" class="tab-pane" style="display:none"> <div id="tab-bom" class="tab-pane" style="display:none" data-page-node-id="0dcZdYpNL1yp8lUhBww5DZ">
{% for bd in bom_data %} {% for bd in bom_data %}
<div class="card" style="margin-bottom:16px"> <div class="card" style="margin-bottom:16px" data-page-node-id="KQOJWc5nHbG4VjhSaQPuMk">
<h3 style="margin-top:0">{{ bd.b.code }} ({{ bd.b.name or '-' }}) <h3 style="margin-top:0" data-page-node-id="ycs6WD46CdMJ8UcSRCy5f6">{{ bd.b.code }} ({{ bd.b.name or '-' }})
<span class="small muted">· 单套用量 × 该厂库存</span></h3> <span class="small muted" data-page-node-id="U6NLfEkrS2iQqgZimxy4zl">· 单套用量 × 该厂库存</span></h3>
{% if bd.comps %} {% if bd.comps %}
<table class="tbl"> <table class="tbl" data-page-node-id="UdJnn9kfq0ajEZWGKyzVOL">
<thead><tr><th>物料编号</th><th>型号/规格</th><th>单套用量</th><th>该厂库存</th><th>状态</th></tr></thead> <thead data-page-node-id="ARRG4xD4kWgudXephSkWRB"><tr data-page-node-id="nlsJQ9mpAJYIhFm75kH05N"><th data-page-node-id="V2dk9RMlqoxhgKdJtcLOMh">物料编号</th><th data-page-node-id="Kjj7qJMrh899UoknQcDLNA">型号/规格</th><th data-page-node-id="pkEIgqReAFzWQmTG58bBhE">单套用量</th><th data-page-node-id="amLBx4jB5xYtw9KX74SBbU">该厂库存</th><th data-page-node-id="4cEYL4lgAaUBmObLLkX0Km">状态</th></tr></thead>
<tbody> <tbody data-page-node-id="mhRWoazwMuPyFzrrAIVuq7">
{% for c in bd.comps %} {% for c in bd.comps %}
<tr class="{{ 'row-danger' if c.status=='danger' else ('row-warn' if c.status=='warn' else '') }}"> <tr class="{{ 'row-danger' if c.status=='danger' else ('row-warn' if c.status=='warn' else '') }}" data-page-node-id="9d7M31Zqmc5feItheDMB71">
<td>{{ c.m.part_number }}</td> <td data-page-node-id="jv8YvfrtYpEsLkFsFyrwiO">{{ c.m.part_number }}</td>
<td>{{ c.m.manufacturer or '-' }}</td> <td data-page-node-id="pRtaop8GPgnCJGFS4Cugle">{{ c.m.manufacturer or '-' }}</td>
<td class="num">{{ '%.3f'|format(c.qty) }}</td> <td class="num" data-page-node-id="3uop7BWAV6eYdlVz5eqakf">{{ '%.3f'|format(c.qty) }}</td>
<td class="num">{{ '%.2f'|format(c.stock) }}</td> <td class="num" data-page-node-id="jfNj0QeLPUrrBSfsPjzsHT">{{ '%.2f'|format(c.stock) }}</td>
<td><span class="badge {{ 'danger' if c.status=='danger' else ('warn' if c.status=='warn' else 'ok') }}">{{ '负库存' if c.status=='danger' else ('低于安全库存' if c.status=='warn' else '正常') }}</span></td> <td data-page-node-id="MToeQ16NLjIvuPdfGFLojv"><span class="badge {{ 'danger' if c.status=='danger' else ('warn' if c.status=='warn' else 'ok') }}" data-page-node-id="3a5chV8jORWtOZKE3kVKYM">{{ '负库存' if c.status=='danger' else ('低于安全库存' if c.status=='warn' else '正常') }}</span></td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
{% else %} {% else %}
<p class="small muted">该 BOM 没有组成物料</p> <p class="small muted" data-page-node-id="htEBvmB0x6pS6bfdx1SFaA">该 BOM 没有组成物料</p>
{% endif %} {% endif %}
</div> </div>
{% else %} {% else %}
<p class="muted">暂无 BOM</p> <p class="muted" data-page-node-id="f188trWNRvLEGIEFhxwrC1">暂无 BOM</p>
{% endfor %} {% endfor %}
</div> </div>
<!-- 批量导入 --> <!-- 批量导入 -->
<div id="tab-import" class="tab-pane" style="display:none"> <div id="tab-import" class="tab-pane" style="display:none" data-page-node-id="NRRM2DunropDEi85SDdAeY">
<div class="card" style="margin-bottom:16px"> <div class="card" style="margin-bottom:16px" data-page-node-id="pmIAFmv6QG2cAhVHKguzCC">
<h3 style="margin-top:0">导入入库流水到 {{ f.code }}</h3> <h3 style="margin-top:0" data-page-node-id="gAGSs6J0QvEnz8OmMFZdaW">导入入库流水到 {{ f.code }}</h3>
<p class="small muted">上传 Excel/CSV(列:物料编号 / 数量 / 批次 / 日期 / 备注),将生成归属 {{ f.code }} 的入库流水。</p> <p class="small muted" data-page-node-id="dRwIvucQurC2wYwZd7grcI">上传 Excel/CSV(列:物料编号 / 数量 / 批次 / 日期 / 备注),将生成归属 {{ f.code }} 的入库流水。</p>
<form method="post" action="{{ url_for('factory_import_inbound', fid=f.id) }}" enctype="multipart/form-data" style="display:flex;gap:10px;align-items:center;flex-wrap:wrap"> <form method="post" action="{{ url_for('factory_import_inbound', fid=f.id) }}" enctype="multipart/form-data" style="display:flex;gap:10px;align-items:center;flex-wrap:wrap" data-page-node-id="Oli4teU6igJvERarUlj8i8">
<input type="file" name="file" accept=".xlsx,.csv" required> <input type="file" name="file" accept=".xlsx,.csv" required data-page-node-id="QvuR34OQvaVh8GOCZ4g69g">
<button class="primary" type="submit">上传并导入入库</button> <button class="primary" type="submit" data-page-node-id="62RYuou3Nbtk1RAVVli1eE">上传并导入入库</button>
<a class="btn" href="{{ url_for('download_template', kind='transactions') }}">⬇ 下载模板</a> <a class="btn" href="{{ url_for('download_template', kind='transactions') }}" data-page-node-id="XukAuHeUyL7T9wJs6aDfhT">⬇ 下载模板</a>
</form> </form>
</div> </div>
<div class="card"> <div class="card" data-page-node-id="G6s6IIwsdxtO2QoglkQu5O">
<h3 style="margin-top:0">导入物料主数据(期初库存归属 {{ f.code }})</h3> <h3 style="margin-top:0" data-page-node-id="sBnAiFr1fC7vAnge2CYt9L">导入物料主数据(期初库存归属 {{ f.code }})</h3>
<p class="small muted">上传物料 Excel(列:物料编号 / 型号 / 单位 / 安全库存 / 期初库存),期初库存将写入 {{ f.code }}。</p> <p class="small muted" data-page-node-id="NHW4ZB8CHbwjA6xqxkOAJK">上传物料 Excel(列:物料编号 / 型号 / 单位 / 安全库存 / 期初库存),期初库存将写入 {{ f.code }}。</p>
<form method="post" action="{{ url_for('factory_import_materials', fid=f.id) }}" enctype="multipart/form-data" style="display:flex;gap:10px;align-items:center;flex-wrap:wrap"> <form method="post" action="{{ url_for('factory_import_materials', fid=f.id) }}" enctype="multipart/form-data" style="display:flex;gap:10px;align-items:center;flex-wrap:wrap" data-page-node-id="9Y7Ec13v0Yo6okhmgErNLS">
<input type="file" name="file" accept=".xlsx,.csv" required> <input type="file" name="file" accept=".xlsx,.csv" required data-page-node-id="3PqBIl65X6AWBok5cKoO4S">
<button class="primary" type="submit">上传并导入物料</button> <button class="primary" type="submit" data-page-node-id="iU26xXbC5XkB2oLWJtUVws">上传并导入物料</button>
<a class="btn" href="{{ url_for('download_template', kind='materials') }}">⬇ 下载模板</a> <a class="btn" href="{{ url_for('download_template', kind='materials') }}" data-page-node-id="o5mKNqqJtZJffB2hM595GA">⬇ 下载模板</a>
</form> </form>
</div> </div>
</div> </div>
@@ -100,5 +114,77 @@ function switchTab(name){
event.target.classList.add('active'); event.target.classList.add('active');
document.getElementById('tab-'+name).style.display='block'; document.getElementById('tab-'+name).style.display='block';
} }
function esc(s){
return (s==null?'':String(s)).replace(/[&<>"']/g, c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
// 进入某行编辑态: 物料编号、型号/规格、单位、安全库存 变为输入框
function editRow(btn){
var tr = btn.closest('tr');
tr.dataset.view = tr.innerHTML; // 保存视图态, 供取消还原
var mid = tr.dataset.mid, pn = tr.dataset.pn, mf = tr.dataset.mf, unit = tr.dataset.unit, ss = tr.dataset.ss;
var c = tr.children;
c[0].innerHTML = '<input class="cell-inp" id="pn_'+mid+'" value="'+esc(pn)+'">';
c[1].innerHTML = '<input class="cell-inp" id="mf_'+mid+'" value="'+esc(mf)+'">';
c[2].innerHTML = '<input class="cell-inp" id="unit_'+mid+'" value="'+esc(unit)+'">';
c[3].innerHTML = '<input class="cell-inp num" id="ss_'+mid+'" type="number" step="0.01" min="0" value="'+esc(ss)+'">';
document.getElementById('acts_'+mid).innerHTML =
'<button class="btn small primary" type="button" onclick="saveRow(this)">保存</button> '
+ '<button class="btn small" type="button" onclick="cancelRow(this)">取消</button>';
}
function cancelRow(btn){
var tr = btn.closest('tr');
tr.innerHTML = tr.dataset.view; // 还原视图态
}
// 提交保存(可递归: 二次确认合并后重提)
function postSave(fid, mid, fd){
fetch('/factory/'+fid+'/material/'+mid+'/quickedit', {method:'POST', body: fd})
.then(function(r){ return r.json(); })
.then(function(d){
if(d.ok){
location.reload(); // 安全库存/合并可能影响状态与列表, 整页刷新保证一致
} else if(d.needs_confirm){
if(confirm(d.msg)){
fd.append('confirm_merge','1'); // 用户确认把当前物料合并到已存在的号
postSave(fid, mid, fd);
}
} else {
alert('保存失败: ' + (d.msg || '未知错误'));
}
})
.catch(function(e){ alert('请求失败: ' + e); });
}
function saveRow(btn){
var tr = btn.closest('tr');
var mid = tr.dataset.mid, fid = tr.dataset.fid;
var pn = document.getElementById('pn_'+mid).value.trim();
var mf = document.getElementById('mf_'+mid).value;
var unit = document.getElementById('unit_'+mid).value;
var ss = document.getElementById('ss_'+mid).value;
if(!pn){ alert('物料编号不能为空'); return; }
var fd = new FormData();
fd.append('part_number', pn);
fd.append('manufacturer', mf);
fd.append('unit', unit);
fd.append('safety_stock', ss);
postSave(fid, mid, fd);
}
// 删除整条物料记录(含其全部关联记录); 后端会自动备份数据库
function delRow(btn){
var tr = btn.closest('tr');
var mid = tr.dataset.mid, fid = tr.dataset.fid;
var pn = tr.dataset.pn;
if(!confirm('确定删除物料「'+pn+'」?\n将同时删除其所有入库/出库/BOM组成/替代料/调拨/生产订单调入计划记录,'
+ '且不可撤销(系统会自动备份数据库)。')) return;
fetch('/factory/'+fid+'/material/'+mid+'/delete', {method:'POST'})
.then(function(r){ return r.json(); })
.then(function(d){ if(d.ok){ location.reload(); } else { alert('删除失败: ' + (d.msg||'未知错误')); } })
.catch(function(e){ alert('请求失败: ' + e); });
}
</script> </script>
{% endblock %} {% endblock %}
+2 -1
View File
@@ -4,7 +4,8 @@
<form method="post" class="card form-card"> <form method="post" class="card form-card">
<h3>{{ '编辑物料' if m else '新增物料' }}</h3> <h3>{{ '编辑物料' if m else '新增物料' }}</h3>
<label>物料编号 *</label> <label>物料编号 *</label>
<input name="part_number" value="{{ m.part_number if m else '' }}" {{ 'readonly' if m }} required> <input name="part_number" value="{{ m.part_number if m else '' }}" {{ 'required' if m else 'required' }}>
<p class="small muted" style="margin-top:2px">物料编码为唯一业务编号(不可与其他物料重复)。</p>
<label>型号 / 规格</label> <label>型号 / 规格</label>
<input name="manufacturer" value="{{ m.manufacturer if m else '' }}"> <input name="manufacturer" value="{{ m.manufacturer if m else '' }}">
<label>单位</label> <label>单位</label>