diff --git a/app.py b/app.py index 1e65acf..1a5da7c 100644 --- a/app.py +++ b/app.py @@ -20,7 +20,7 @@ from flask import (Flask, render_template, request, redirect, url_for, from sqlalchemy import update 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 冻结) ---------------- 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)) +# ---------------- 备料(采购下单用, 不记账) ---------------- +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/", 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") @login_required @role_required("admin", "manager") diff --git a/excel_io.py b/excel_io.py index 1b7b691..32ce7ec 100644 --- a/excel_io.py +++ b/excel_io.py @@ -245,6 +245,35 @@ def export_inventory(path, rows): 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): wb = Workbook() ws = wb.active diff --git a/models.py b/models.py index 6ab1ffc..dd7ed57 100644 --- a/models.py +++ b/models.py @@ -157,6 +157,25 @@ class Factory(db.Model): 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: 厂间调拨 ---------------- class Transfer(db.Model): """厂间调拨: 从源工厂调出、调入目标工厂(同一颗物料)。 diff --git a/static/style.css b/static/style.css index 39c721c..870587a 100644 --- a/static/style.css +++ b/static/style.css @@ -79,6 +79,13 @@ button:hover, .btn:hover { filter: brightness(.96); } .badge.warn { background: #fff7e6; color: #ad6800; } .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.ok { background: #e6f7ed; color: #1a7f47; } .flash.error { background: #fff1f0; color: #cf1322; } diff --git a/templates/base.html b/templates/base.html index 86c611d..2750709 100644 --- a/templates/base.html +++ b/templates/base.html @@ -27,6 +27,7 @@ {% if g.user and g.user.role in ('admin','manager') %} BOM 生产订单 + 备料 报表 日志 工厂 @@ -47,6 +48,7 @@ {% endfor %} {% endwith %} {% block content %}{% endblock %} + {% block scripts %}{% endblock %} diff --git a/templates/prepare.html b/templates/prepare.html new file mode 100644 index 0000000..23f26a2 --- /dev/null +++ b/templates/prepare.html @@ -0,0 +1,127 @@ +{% extends "base.html" %} +{% block title %}备料清单{% endblock %} +{% block content %} +
+

备料清单(采购下单用)

+ 不记账 · 按需用×套数预填 +
+ +
+ 导出 Excel +
+ +
+

+ 本页为独立的采购备料清单:选一个 BOM 并输入套数,即按 + 「计划数量 = BOM单套用量 × 套数」 一键生成(可再手工改); + 也可在下方手工添加任意物料并填数量。当前范围:{% if current_factory %}{{ current_factory.code }} · {{ current_factory.name }}{% else %}全部工厂(汇总){% endif %}; + 「实际库存」「缺口」随顶栏工厂切换实时计算。 +

+

+ 说明:本页与 Excel 导出均只读,不产生任何入库 / 出库流水,仅用于向供应商下单采购。 + 缺口 = 计划数量 − 实际库存(>0 表示还需采购,<0 表示库存已盈余)。 +

+
+ +{# 添加物料 #} +
+
+
+ + + {% for m in materials %}{% endfor %} + +
+
+
+ +
+ + 手工录入单个物料(计划数量可留空,稍后填) +
+ +{# 按 BOM 批量生成计划(计划数量 = 单套用量 × 套数) #} +
+
+
+ +
+
+
+ +
+ + 计划数量 = BOM单套用量 × 套数,生成后可在表中手工调整 +
+ +
+
+ 修改计划数量后点此保存 + +
+ + + + + + + {% for r in rows %} + + + + + + + + + + + {% else %} + + {% endfor %} + +
物料编号型号/规格单位计划数量实际库存缺口状态操作
{{ r.m.part_number }}{{ r.m.manufacturer or '-' }}{{ r.m.unit or '个' }} + + {{ '%.2f'|format(r.stock) }}{{ '%.2f'|format(r.diff) }}{{ '需采购' if r.diff > 0 else ('盈余' if r.diff < 0 else '刚好') }} + + + +
还没有添加任何备料物料,请选择 BOM 加入或手工录入。
+ +{% endblock %} + +{% block scripts %} + +{% endblock %}