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 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, PrepPlan)
|
||||||
|
|
||||||
# ---------------- 路径(兼容 PyInstaller 冻结) ----------------
|
# ---------------- 路径(兼容 PyInstaller 冻结) ----------------
|
||||||
if getattr(sys, "frozen", False):
|
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))
|
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")
|
@app.route("/logs")
|
||||||
@login_required
|
@login_required
|
||||||
@role_required("admin", "manager")
|
@role_required("admin", "manager")
|
||||||
|
|||||||
+29
@@ -245,6 +245,35 @@ def export_inventory(path, rows):
|
|||||||
wb.save(path)
|
wb.save(path)
|
||||||
|
|
||||||
|
|
||||||
|
def export_prep(path, rows, scope=""):
|
||||||
|
"""导出备料清单(采购下单用, 不记账; 手工维护, 与生产订单无关)。
|
||||||
|
|
||||||
|
rows: [{id, m, plan(计划数量), stock(实际库存), diff(缺口=计划-库存), short}]
|
||||||
|
scope: 范围说明(工厂/全部)。
|
||||||
|
"""
|
||||||
|
wb = Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.title = "备料清单"
|
||||||
|
head = ["物料编号", "型号/规格", "单位", "计划数量", "实际库存",
|
||||||
|
"缺口(计划-库存)", "状态", "范围"]
|
||||||
|
ws.append(head)
|
||||||
|
for c in ws[1]:
|
||||||
|
c.font = Font(bold=True)
|
||||||
|
c.fill = PatternFill("solid", fgColor="DDEBF7")
|
||||||
|
for r in rows:
|
||||||
|
m = r["m"]
|
||||||
|
status = "需采购" if r["diff"] > 0 else ("刚好够" if r["diff"] == 0 else "已有盈余")
|
||||||
|
ws.append([m.part_number, m.manufacturer or "", m.unit or "个",
|
||||||
|
r["plan"], r["stock"], r["diff"], status, scope])
|
||||||
|
# 缺口着色: >0 需采购标红; <0 盈余标绿
|
||||||
|
if r["diff"] > 0:
|
||||||
|
ws.cell(row=ws.max_row, column=6).fill = PatternFill("solid", fgColor="FFC7CE")
|
||||||
|
elif r["diff"] < 0:
|
||||||
|
ws.cell(row=ws.max_row, column=6).fill = PatternFill("solid", fgColor="C6EFCE")
|
||||||
|
ws.freeze_panes = "A2"
|
||||||
|
wb.save(path)
|
||||||
|
|
||||||
|
|
||||||
def build_material_template(path):
|
def build_material_template(path):
|
||||||
wb = Workbook()
|
wb = Workbook()
|
||||||
ws = wb.active
|
ws = wb.active
|
||||||
|
|||||||
@@ -157,6 +157,25 @@ class Factory(db.Model):
|
|||||||
created_at = db.Column(db.DateTime, default=datetime.now)
|
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 备料计划数量(人工录入, 不记账) ----------------
|
||||||
|
class PrepPlan(db.Model):
|
||||||
|
"""备料「计划数量」人工录入(不记账): 每个物料在某工厂范围下的计划采购量。
|
||||||
|
|
||||||
|
- (material_id, factory_id) 组合唯一; factory_id 为 NULL 表示「全部工厂(汇总)」范围的计划。
|
||||||
|
- plan_qty 为 NULL 表示「尚未填写」; 0 表示明确计划不采购。
|
||||||
|
- 仅用于采购下单参考, 不产生任何入库/出库流水。
|
||||||
|
"""
|
||||||
|
__tablename__ = "prep_plan"
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
material_id = db.Column(db.Integer, db.ForeignKey("material.id"), nullable=False, index=True)
|
||||||
|
factory_id = db.Column(db.Integer, db.ForeignKey("factory.id"), nullable=True, index=True)
|
||||||
|
plan_qty = db.Column(db.Float, nullable=True, default=None) # 计划采购数量(空=未填写)
|
||||||
|
note = db.Column(db.String(255), default="")
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||||
|
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
|
||||||
|
__table_args__ = (UniqueConstraint("material_id", "factory_id", name="uq_prep_plan_mf"),)
|
||||||
|
|
||||||
|
|
||||||
# ---------------- M2-2: 厂间调拨 ----------------
|
# ---------------- M2-2: 厂间调拨 ----------------
|
||||||
class Transfer(db.Model):
|
class Transfer(db.Model):
|
||||||
"""厂间调拨: 从源工厂调出、调入目标工厂(同一颗物料)。
|
"""厂间调拨: 从源工厂调出、调入目标工厂(同一颗物料)。
|
||||||
|
|||||||
@@ -79,6 +79,13 @@ button:hover, .btn:hover { filter: brightness(.96); }
|
|||||||
.badge.warn { background: #fff7e6; color: #ad6800; }
|
.badge.warn { background: #fff7e6; color: #ad6800; }
|
||||||
.badge.danger { background: #fff1f0; color: #cf1322; }
|
.badge.danger { background: #fff1f0; color: #cf1322; }
|
||||||
|
|
||||||
|
/* 备料: 计划数量差异指示 */
|
||||||
|
.plan-input { padding: 3px 6px; border: 1px solid #d0d5dd; border-radius: 4px; font-size: 13px; }
|
||||||
|
.plan-input:focus { outline: none; border-color: #4096ff; box-shadow: 0 0 0 2px rgba(64,150,255,.15); }
|
||||||
|
.diff { font-variant-numeric: tabular-nums; }
|
||||||
|
.diff-pos { color: #1a7f47; font-weight: 600; } /* 计划 > 采购(多订) 绿 */
|
||||||
|
.diff-neg { color: #cf1322; font-weight: 600; } /* 计划 < 采购(少订) 红 */
|
||||||
|
|
||||||
.flash { padding: 10px 14px; border-radius: 6px; margin-bottom: 12px; font-size: 14px; }
|
.flash { padding: 10px 14px; border-radius: 6px; margin-bottom: 12px; font-size: 14px; }
|
||||||
.flash.ok { background: #e6f7ed; color: #1a7f47; }
|
.flash.ok { background: #e6f7ed; color: #1a7f47; }
|
||||||
.flash.error { background: #fff1f0; color: #cf1322; }
|
.flash.error { background: #fff1f0; color: #cf1322; }
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
{% if g.user and g.user.role in ('admin','manager') %}
|
{% if g.user and g.user.role in ('admin','manager') %}
|
||||||
<a href="{{ url_for('boms') }}">BOM</a>
|
<a href="{{ url_for('boms') }}">BOM</a>
|
||||||
<a href="{{ url_for('orders') }}">生产订单</a>
|
<a href="{{ url_for('orders') }}">生产订单</a>
|
||||||
|
<a href="{{ url_for('prepare') }}">备料</a>
|
||||||
<a href="{{ url_for('report') }}">报表</a>
|
<a href="{{ url_for('report') }}">报表</a>
|
||||||
<a href="{{ url_for('logs') }}">日志</a>
|
<a href="{{ url_for('logs') }}">日志</a>
|
||||||
<a href="{{ url_for('factories') }}">工厂</a>
|
<a href="{{ url_for('factories') }}">工厂</a>
|
||||||
@@ -47,6 +48,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
|
{% block scripts %}{% endblock %}
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}备料清单{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="toolbar">
|
||||||
|
<h3 style="margin:0">备料清单(采购下单用)</h3>
|
||||||
|
<span class="badge warn">不记账 · 按需用×套数预填</span>
|
||||||
|
<form method="post" action="{{ url_for('prepare_clear') }}" style="display:inline"
|
||||||
|
onsubmit="return confirm('确定清空当前范围内的全部备料计划?此操作不可撤销。');">
|
||||||
|
<button type="submit" class="btn danger">清空清单</button>
|
||||||
|
</form>
|
||||||
|
<a class="btn" href="{{ url_for('prepare_export') }}">导出 Excel</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="margin-bottom:16px">
|
||||||
|
<p class="small muted" style="margin-top:0">
|
||||||
|
本页为<b>独立的采购备料清单</b>:选一个 <b>BOM</b> 并输入<b>套数</b>,即按
|
||||||
|
<b>「计划数量 = BOM单套用量 × 套数」</b> 一键生成(可再手工改);
|
||||||
|
也可在下方手工添加任意物料并填数量。当前范围:<b>{% if current_factory %}{{ current_factory.code }} · {{ current_factory.name }}{% else %}全部工厂(汇总){% endif %}</b>;
|
||||||
|
「实际库存」「缺口」随顶栏工厂切换实时计算。
|
||||||
|
</p>
|
||||||
|
<p class="small muted" style="margin-bottom:0">
|
||||||
|
说明:本页与 Excel 导出均<b>只读</b>,不产生任何入库 / 出库流水,仅用于向供应商下单采购。
|
||||||
|
<b>缺口 = 计划数量 − 实际库存</b>(>0 表示还需采购,<0 表示库存已盈余)。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# 添加物料 #}
|
||||||
|
<form method="post" action="{{ url_for('prepare_add') }}" class="card" style="margin-bottom:16px;display:flex;gap:12px;align-items:flex-end;flex-wrap:wrap">
|
||||||
|
<div>
|
||||||
|
<label class="small muted">物料编号</label><br>
|
||||||
|
<input list="matlist" name="part_number" placeholder="输入或选择物料编号" style="width:240px" autocomplete="off" required>
|
||||||
|
<datalist id="matlist">
|
||||||
|
{% for m in materials %}<option value="{{ m.part_number }}">{{ m.manufacturer or '' }}</option>{% endfor %}
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="small muted">计划数量</label><br>
|
||||||
|
<input type="text" inputmode="decimal" name="qty" placeholder="可留空" style="width:120px;text-align:right">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn primary">添加物料</button>
|
||||||
|
<span class="muted small">手工录入单个物料(计划数量可留空,稍后填)</span>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{# 按 BOM 批量生成计划(计划数量 = 单套用量 × 套数) #}
|
||||||
|
<form method="post" action="{{ url_for('prepare_add_bom') }}" class="card" style="margin-bottom:16px;display:flex;gap:12px;align-items:flex-end;flex-wrap:wrap">
|
||||||
|
<div>
|
||||||
|
<label class="small muted">① 选择 BOM</label><br>
|
||||||
|
<select id="bomSel" name="bom_id" style="width:300px">
|
||||||
|
<option value="">-- 选择 BOM --</option>
|
||||||
|
{% for b in bom_opts %}<option value="{{ b.id }}">{{ b.label }}</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="small muted">② 套数</label><br>
|
||||||
|
<input type="text" inputmode="decimal" name="sets" value="1" style="width:120px;text-align:right">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn primary">生成计划(需用×套数)</button>
|
||||||
|
<span class="muted small">计划数量 = BOM单套用量 × 套数,生成后可在表中手工调整</span>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form method="post" action="{{ url_for('prepare_save') }}">
|
||||||
|
<div class="toolbar" style="justify-content:flex-end">
|
||||||
|
<span class="muted small">修改计划数量后点此保存</span>
|
||||||
|
<button type="submit" class="btn primary">保存计划数量</button>
|
||||||
|
</div>
|
||||||
|
<table class="tbl">
|
||||||
|
<thead><tr>
|
||||||
|
<th>物料编号</th><th>型号/规格</th><th>单位</th>
|
||||||
|
<th>计划数量</th><th>实际库存</th><th>缺口</th><th>状态</th><th>操作</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for r in rows %}
|
||||||
|
<tr class="{{ 'row-danger' if r.diff > 0 else '' }}">
|
||||||
|
<td><a href="{{ url_for('material_detail', mid=r.m.id) }}" title="进入工厂物料">{{ r.m.part_number }}</a></td>
|
||||||
|
<td>{{ r.m.manufacturer or '-' }}</td>
|
||||||
|
<td>{{ r.m.unit or '个' }}</td>
|
||||||
|
<td>
|
||||||
|
<input type="text" inputmode="decimal" name="plan_{{ r.id }}"
|
||||||
|
class="plan-input" style="width:90px;text-align:right"
|
||||||
|
value="{{ '%.2f'|format(r.plan) if r.plan else '' }}"
|
||||||
|
data-stock="{{ '%.2f'|format(r.stock) }}">
|
||||||
|
</td>
|
||||||
|
<td class="num">{{ '%.2f'|format(r.stock) }}</td>
|
||||||
|
<td class="num"><span class="diff" id="diff_{{ r.id }}">{{ '%.2f'|format(r.diff) }}</span></td>
|
||||||
|
<td><span class="badge {{ 'danger' if r.diff > 0 else 'ok' }}">{{ '需采购' if r.diff > 0 else ('盈余' if r.diff < 0 else '刚好') }}</span></td>
|
||||||
|
<td>
|
||||||
|
<form method="post" action="{{ url_for('prepare_delete', pid=r.id) }}" style="display:inline"
|
||||||
|
onsubmit="return confirm('确定删除该备料物料?');">
|
||||||
|
<button type="submit" class="btn danger small">删除</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="8" class="muted">还没有添加任何备料物料,请选择 BOM 加入或手工录入。</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
// 计划数量实时计算缺口 = 计划 - 实际库存
|
||||||
|
function recalcDiff(input) {
|
||||||
|
var row = input.closest('tr');
|
||||||
|
var diffEl = row.querySelector('.diff');
|
||||||
|
if (!diffEl) return;
|
||||||
|
var stock = parseFloat(input.getAttribute('data-stock')) || 0;
|
||||||
|
var val = input.value.trim();
|
||||||
|
if (val === '') { diffEl.textContent = ''; diffEl.className = 'diff'; return; }
|
||||||
|
var plan = parseFloat(val);
|
||||||
|
if (isNaN(plan)) { diffEl.textContent = '—'; diffEl.className = 'diff'; return; }
|
||||||
|
var d = Math.round((plan - stock) * 100) / 100;
|
||||||
|
diffEl.textContent = d.toFixed(2);
|
||||||
|
diffEl.className = 'diff ' + (d < 0 ? 'diff-neg' : (d > 0 ? 'diff-pos' : ''));
|
||||||
|
var badge = row.querySelector('.badge');
|
||||||
|
if (badge) {
|
||||||
|
badge.className = 'badge ' + (d > 0 ? 'danger' : 'ok');
|
||||||
|
badge.textContent = d > 0 ? '需采购' : (d < 0 ? '盈余' : '刚好');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.querySelectorAll('.plan-input').forEach(function (inp) {
|
||||||
|
inp.addEventListener('input', function () { recalcDiff(this); });
|
||||||
|
recalcDiff(inp); // 初始(回填值)
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user