feat: 新增备料(采购下单)功能模块,含清空清单

- 独立采购备料清单:选 BOM + 输入套数一键生成计划(计划数量 = 单套用量 × 套数),可手工增删改、导出 Excel

- 备料与生产订单完全解耦,仅记录计划数量、不记账(不产生任何出入库流水)

- 新增「清空清单」功能(/prepare/clear,JS 二次确认 + 操作日志),按当前工厂范围清空

- 配套:PrepPlan 模型、缺口(计划-库存)实时计算、库存随工厂切换过滤
This commit is contained in:
2026-10-19 15:09:20 +08:00
parent e9b605fca6
commit 5a6f4ea4df
6 changed files with 424 additions and 1 deletions
+240 -1
View File
@@ -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
View File
@@ -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
+19
View File
@@ -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):
"""厂间调拨: 从源工厂调出、调入目标工厂(同一颗物料)。 """厂间调拨: 从源工厂调出、调入目标工厂(同一颗物料)。
+7
View File
@@ -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; }
+2
View File
@@ -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>
+127
View File
@@ -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>(&gt;0 表示还需采购,&lt;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 %}