Files
Inventory-Management-System/app.py
T
star 639d7f1545 feat: BOM型号可编辑 + 生产订单BOM去括号 + 物料明细各工厂实时库存
- BOM编辑页「型号」输入框取消 readonly,可修改;bom_edit 增加非空与唯一校验(重复提示「BOM 型号已存在」、空值提示「BOM 型号必填」),改名不影响关联生产订单与流水
- 生产订单列表/详情/表单中 BOM 显示由 CODE (NAME) 改为 CODE · NAME,去掉括号
- 物料明细页新增「各工厂实时库存」表(工厂编号/名称/实时库存),显示每个工厂的实时聚合库存(入−出),不受顶栏工厂过滤影响
2026-10-15 14:03:16 +08:00

2248 lines
93 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""物料入库/出库系统 - Flask 应用 (M1+M2+M3)
M1 基础: 物料主数据 + 入库 + 出库 + 实时库存看板(预警标红)
M2 核算: BOM 清单 + 生产订单(接单量)按 BOM 展开领料需求 + Excel 双向导入导出
M3 治理: 多角色(admin/manager/operator) + 操作日志 + 报表打印导出
技术: Flask + SQLAlchemy + SQLite (单文件库, 免外部数据库)
打包: PyInstaller --onefile, 冻结后模板/静态走 sys._MEIPASS, 库文件落 exe 同目录。
"""
import os
import sys
import shutil
import hashlib
import webbrowser
from datetime import date, datetime
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)
# ---------------- 路径(兼容 PyInstaller 冻结) ----------------
if getattr(sys, "frozen", False):
BASE = sys._MEIPASS # 模板/静态解压目录(只读)
EXE_DIR = os.path.dirname(sys.executable) # exe 同目录(可写)
else:
BASE = os.path.dirname(os.path.abspath(__file__))
# 开发/测试模式也统一指向 dist/app.db,避免与打包版数据分家(两套库各存各的)
_dist_dir = os.path.join(BASE, "dist")
EXE_DIR = _dist_dir if os.path.isdir(_dist_dir) else BASE
template_folder = os.path.join(BASE, "templates")
static_folder = os.path.join(BASE, "static")
db_path = os.environ.get("WMS_DB") or os.path.join(EXE_DIR, "app.db")
app = Flask(__name__, template_folder=template_folder, static_folder=static_folder)
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///" + db_path
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
app.config["SECRET_KEY"] = "material-wms-change-me"
db.init_app(app)
# ---------------- 鉴权 ----------------
def sha256(s: str) -> str:
return hashlib.sha256(s.encode("utf-8")).hexdigest()
def login_required(f):
@wraps(f)
def wrapper(*a, **k):
if "user_id" not in session:
return redirect(url_for("login"))
return f(*a, **k)
return wrapper
def role_required(*roles):
def deco(f):
@wraps(f)
def wrapper(*a, **k):
if "user_id" not in session:
return redirect(url_for("login"))
u = g.user
if u is None or u.role not in roles:
flash("权限不足", "error")
return redirect(url_for("dashboard"))
return f(*a, **k)
return wrapper
return deco
@app.before_request
def load_user():
uid = session.get("user_id")
g.user = db.session.get(User, uid) if uid else None
# ---------------- M2-1: 工厂维度(全局筛选) ----------------
def current_fid():
"""当前选中的工厂 id; None 表示"全部工厂(汇总)"。"""
return session.get("factory_id")
def headquarters_fid():
"""总部(自厂)工厂 id, 即 code='SELF'(name='自厂/总部')。
入库登记默认归属总部。"""
try:
f = db.session.scalar(db.select(Factory).where(Factory.code == "SELF"))
return f.id if f else None
except Exception:
return None
@app.context_processor
def inject_factory_ctx():
"""向所有模板注入工厂列表与当前选中工厂, 供顶栏切换器使用。"""
fids = []
cur = None
try:
fids = db.session.scalars(
db.select(Factory).where(Factory.enabled == True).order_by(Factory.code)
).all()
fid = session.get("factory_id")
if fid:
cur = db.session.get(Factory, fid)
except Exception:
pass
return {"factories": fids, "current_factory": cur,
"current_factory_id": session.get("factory_id")}
def log_action(action, target="", detail=""):
try:
db.session.add(OperationLog(
operator=(g.user.display_name or g.user.username) if g.user else "?",
action=action, target=str(target), detail=str(detail)))
db.session.commit()
except Exception:
db.session.rollback()
# ---------------- 库存核算 ----------------
def stock_map(factory_id=None):
"""返回 {material_id: 在库数量}; factory_id 指定时只统计该工厂。"""
where = " WHERE factory_id = :fid" if factory_id else ""
params = {"fid": factory_id} if factory_id else {}
rows = db.session.execute(
db.text(f"SELECT material_id, COALESCE(SUM(qty),0) FROM inbound{where} GROUP BY material_id"),
params).fetchall()
inb = {r[0]: float(r[1]) for r in rows}
rows = db.session.execute(
db.text(f"SELECT material_id, COALESCE(SUM(qty),0) FROM outbound{where} GROUP BY material_id"),
params).fetchall()
out = {r[0]: float(r[1]) for r in rows}
ids = set(inb) | set(out)
return {i: inb.get(i, 0) - out.get(i, 0) for i in ids}
def stock_map_as_of(factory_id=None, as_of=None):
"""返回截至 as_of 之前(不含 as_of 及之后创建)的净库存 {material_id: qty}。
- as_of 为 datetime(通常用订单的 created_at)。
- 用 created_at 作截断, 从而自动排除该订单自身后续的调入/领料流水,
以及下单之后才发生的其他出入库 —— 即「下单那一刻的库存快照」。
- as_of 为 None 时等价 stock_map(当前库存)。
- 兼容极少数字段 created_at 为空: 空值不参与(视为缺失), 不影响其余统计。
"""
if as_of is None:
return stock_map(factory_id)
fcond = " AND factory_id = :fid" if factory_id else ""
params = {"asof": as_of}
if factory_id:
params["fid"] = factory_id
rows = db.session.execute(db.text(
f"SELECT material_id, COALESCE(SUM(qty),0) FROM inbound "
f"WHERE created_at IS NOT NULL AND created_at < :asof{fcond} GROUP BY material_id"),
params).fetchall()
inb = {r[0]: float(r[1]) for r in rows}
rows = db.session.execute(db.text(
f"SELECT material_id, COALESCE(SUM(qty),0) FROM outbound "
f"WHERE created_at IS NOT NULL AND created_at < :asof{fcond} GROUP BY material_id"),
params).fetchall()
out = {r[0]: float(r[1]) for r in rows}
ids = set(inb) | set(out)
return {i: inb.get(i, 0) - out.get(i, 0) for i in ids}
def _status_of(stock, safety):
"""库存状态: danger=负库存, warn=低于安全库存, ok=正常"""
if stock < 0:
return "danger"
if stock < (safety or 0):
return "warn"
return "ok"
def inbound_total_map(factory_id=None):
"""返回 {material_id: 入库总数量}; factory_id 指定时只统计该工厂。"""
where = " WHERE factory_id = :fid" if factory_id else ""
params = {"fid": factory_id} if factory_id else {}
rows = db.session.execute(
db.text(f"SELECT material_id, COALESCE(SUM(qty),0) FROM inbound{where} GROUP BY material_id"),
params).fetchall()
return {r[0]: float(r[1]) for r in rows}
def outbound_total_map(factory_id=None):
"""返回 {material_id: 消耗(出库)总数量}; factory_id 指定时只统计该工厂。"""
where = " WHERE factory_id = :fid" if factory_id else ""
params = {"fid": factory_id} if factory_id else {}
rows = db.session.execute(
db.text(f"SELECT material_id, COALESCE(SUM(qty),0) FROM outbound{where} GROUP BY material_id"),
params).fetchall()
return {r[0]: float(r[1]) for r in rows}
# ---------------- 替代料关系 ----------------
def substitute_rows(main_id, factory_id=None, as_of=None):
"""返回主料 main_id 的可替代料列表: [{sub:Material, stock:float, available:bool}]。
as_of 传入时, 库存按「截至 as_of 之前」快照计算(用于生产订单查看时
呈现下单前的库存)。默认 None 取当前库存。
"""
sm = stock_map_as_of(factory_id, as_of)
subs = db.session.scalars(
db.select(Substitute).where(Substitute.main_id == main_id)).all()
out = []
for s in subs:
sub_m = db.session.get(Material, s.sub_id)
if not sub_m:
continue
st = sm.get(sub_m.id, 0)
out.append({"sub": sub_m, "stock": st, "available": st > 0})
return out
def import_substitutes_from_excel(path):
"""从替代料 Excel(含'替代料' sheet)解析主料↔替代料关系并写入 substitute 表。
返回 (added, skipped, missing): 新增条数 / 跳过(已存在)条数 / 物料未找到条数。
"""
from openpyxl import load_workbook
wb = load_workbook(path, read_only=True, data_only=True)
ws = wb["替代料"] if "替代料" in wb.sheetnames else wb.worksheets[0]
rows = list(ws.iter_rows(values_only=True))
groups = {}
cur = None
for r in rows[2:]:
seq, main, main_spec, sub, sub_spec, _ = (list(r) + [None] * 6)[:6]
if main is not None and str(main).strip() not in ("", "None"):
cur = str(main).strip()
groups.setdefault(cur, set())
if sub is not None and str(sub).strip() not in ("", "None") and cur:
groups[cur].add(str(sub).strip())
added = skipped = missing = 0
for main_pn, subs in groups.items():
m = db.session.scalar(db.select(Material).where(Material.part_number == main_pn))
if not m:
missing += len(subs)
continue
for sp in subs:
sm_ = db.session.scalar(db.select(Material).where(Material.part_number == sp))
if not sm_:
missing += 1
continue
if db.session.scalar(db.select(Substitute).where(
Substitute.main_id == m.id, Substitute.sub_id == sm_.id)):
skipped += 1
continue
db.session.add(Substitute(main_id=m.id, sub_id=sm_.id))
added += 1
db.session.commit()
return added, skipped, missing
def _date(s):
if s:
try:
return date.fromisoformat(s)
except ValueError:
return date.today()
return date.today()
def _validate_txn_rows(kind, rows):
"""校验批量交易行, 返回 [{part_number, material_id, found, qty, batch_no, date, remark, ok, error}]"""
sm = stock_map()
out = []
for r in rows:
pn = str(r.get("part_number", "") or "").strip()
qty = 0
try:
qty = float(r.get("qty") or 0)
except (TypeError, ValueError):
qty = 0
err = ""
mid = None
if not pn:
err = "缺少物料编号"
else:
m = db.session.scalar(db.select(Material).where(Material.part_number == pn))
if not m:
err = "物料不存在"
else:
mid = m.id
if not err and qty <= 0:
err = "数量必须大于 0"
if not err and kind == "outbound":
stock = sm.get(mid, 0)
if qty > stock:
err = f"库存不足(在库 {stock})"
out.append({
"part_number": pn, "material_id": mid, "found": bool(mid),
"qty": qty, "batch_no": str(r.get("batch_no", "") or ""),
"date": str(r.get("date", "") or ""), "remark": str(r.get("remark", "") or ""),
"ok": not err, "error": err,
})
return out
def _validate_material_rows(rows):
"""校验物料批量行 -> [{part_number, manufacturer, unit, safety_stock, opening_stock, exists, ok, error}]"""
seen = set()
out = []
for r in rows:
pn = str(r.get("part_number", "") or "").strip()
mf = str(r.get("manufacturer", "") or "").strip()
unit = str(r.get("unit", "") or "").strip() or "个"
err = ""
try:
ss = float(r.get("safety_stock") or 0)
except (TypeError, ValueError):
ss = 0
try:
op = float(r.get("opening_stock") or 0)
except (TypeError, ValueError):
op = 0
if not pn:
err = "缺少物料编号"
exists = False
if not err:
if pn in seen:
err = "本批重复编号"
else:
m = db.session.scalar(db.select(Material).where(Material.part_number == pn))
exists = bool(m)
seen.add(pn)
out.append({
"part_number": pn, "manufacturer": mf, "unit": unit,
"safety_stock": ss, "opening_stock": op,
"exists": exists, "ok": not err, "error": err,
})
return out
def _validate_bom_rows(boms):
"""校验 BOM 批量 -> [{code, name, items:[{part_number, qty_per_unit, manufacturer, ok, error, auto_create}], ok, error, skip, valid_items}]
物料不存在时记为 auto_create=True(提交时会自动新建物料), 视为可导入。
"""
out = []
for b in boms:
code = str(b.get("code", "") or "").strip()
name = str(b.get("name", "") or "")
items = b.get("items", []) or []
err = ""
if not code:
err = "缺少BOM型号"
item_rows = []
skip = 0
for it in items:
pn = str(it.get("part_number", "") or "").strip()
mf = str(it.get("manufacture", "") or "").strip()
try:
qty = float(it.get("qty_per_unit") or 0)
except (TypeError, ValueError):
qty = 0
ierr = ""
auto = False
if not pn:
ierr = "缺物料编号"
else:
m = db.session.scalar(db.select(Material).where(Material.part_number == pn))
if not m:
auto = True
ierr = "物料不存在, 导入时自动新建"
elif qty <= 0:
ierr = "用量需>0"
ok = auto or (ierr == "")
if ierr and not auto:
skip += 1
item_rows.append({"part_number": pn, "qty_per_unit": qty, "manufacturer": mf,
"ok": ok, "error": ierr, "auto_create": auto})
bom_ok = (not err) and any(x["ok"] for x in item_rows)
out.append({
"code": code, "name": name, "items": item_rows,
"ok": bom_ok, "error": err, "skip": skip,
"valid_items": sum(1 for x in item_rows if x["ok"]),
})
return out
# ---------------- 页面 ----------------
@app.route("/")
@login_required
def index():
return redirect(url_for("dashboard"))
@app.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
u = db.session.scalar(db.select(User).where(User.username == request.form["username"]))
if u and u.active and u.password == sha256(request.form["password"]):
session["user_id"] = u.id
return redirect(url_for("dashboard"))
flash("用户名或密码错误", "error")
return render_template("login.html")
@app.route("/logout")
def logout():
session.clear()
return redirect(url_for("login"))
@app.route("/set_factory", methods=["POST"])
@login_required
def set_factory():
"""顶栏工厂切换: 选择某厂则按该厂过滤, 选"全部"则汇总。"""
val = (request.form.get("factory_id") or "").strip()
if val == "" or val == "all":
session.pop("factory_id", None)
else:
try:
session["factory_id"] = int(val)
except ValueError:
session.pop("factory_id", None)
nxt = request.form.get("next") or request.referrer or url_for("dashboard")
return redirect(nxt)
# ---------------- M2-1: 工厂管理 ----------------
@app.route("/factories", methods=["GET", "POST"])
@login_required
@role_required("admin", "manager")
def factories():
if request.method == "POST":
code = (request.form.get("code") or "").strip().upper()
name = (request.form.get("name") or "").strip()
ftype = request.form.get("type") or "self"
if not code:
flash("工厂编码必填", "error")
elif db.session.scalar(db.select(Factory).where(Factory.code == code)):
flash("工厂编码已存在", "error")
else:
db.session.add(Factory(code=code, name=name, type=ftype, enabled=True))
db.session.commit()
log_action("新增工厂", code, name)
flash("工厂已添加", "ok")
return redirect(url_for("factories"))
fs = db.session.scalars(db.select(Factory).order_by(Factory.code)).all()
return render_template("factories.html", fs=fs)
@app.route("/factory/toggle/<int:fid>", methods=["POST"])
@login_required
@role_required("admin", "manager")
def factory_toggle(fid):
f = db.session.get(Factory, fid)
if f:
f.enabled = not f.enabled
db.session.commit()
log_action("切换工厂状态", f.code, "启用" if f.enabled else "停用")
flash(f"工厂 {f.code} 已{'启用' if f.enabled else '停用'}", "ok")
return redirect(url_for("factories"))
# ---------------- M2-1+: 工厂工作台(物料看板 / BOM / 批量导入) ----------------
@app.route("/factory/<int:fid>")
@login_required
@role_required("admin", "manager")
def factory_workbench(fid):
f = db.session.get(Factory, fid)
if not f:
return redirect(url_for("factories"))
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
for m in mats:
st = sm.get(m.id, 0)
if st == 0:
continue
status = _status_of(st, m.safety_stock)
if status == "danger":
negative += 1
elif status == "warn":
low += 1
rows.append({"m": m, "stock": st, "status": status})
bs = db.session.scalars(db.select(Bom).order_by(Bom.code)).all()
bom_data = []
for b in bs:
items = db.session.scalars(db.select(BomItem).where(BomItem.bom_id == b.id)).all()
comps = []
for it in items:
mm = db.session.get(Material, it.material_id)
st = sm.get(mm.id, 0)
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, q=q,
negative=negative, low=low, bom_data=bom_data)
@app.route("/factory/<int:fid>/import_inbound", methods=["POST"])
@login_required
@role_required("admin", "manager")
def factory_import_inbound(fid):
f = db.session.get(Factory, fid)
if not f:
return redirect(url_for("factories"))
fl = request.files.get("file")
if not fl or not fl.filename:
flash("请选择文件", "error")
else:
tmp = os.path.join(EXE_DIR, f"_tmp_txn_{fid}.xlsx")
fl.save(tmp)
from excel_io import read_transactions
rows = read_transactions(tmp)
os.remove(tmp)
added = 0
for r in rows:
m = db.session.scalar(db.select(Material).where(Material.part_number == r["part_number"]))
if not m or (r.get("qty") or 0) <= 0:
continue
db.session.add(Inbound(material_id=m.id, factory_id=fid, qty=r["qty"],
batch_no=r.get("batch_no", ""), source="批量导入",
occur_date=_date(r.get("date", "")), remark=r.get("remark", ""),
operator=(g.user.display_name or g.user.username)))
added += 1
db.session.commit()
log_action("工厂批量导入入库", f.code, f"新增 {added} 条")
flash(f"已导入 {added} 条入库流水到 {f.code}", "ok")
return redirect(url_for("factory_workbench", fid=fid))
@app.route("/factory/<int:fid>/import_materials", methods=["POST"])
@login_required
@role_required("admin", "manager")
def factory_import_materials(fid):
f = db.session.get(Factory, fid)
if not f:
return redirect(url_for("factories"))
fl = request.files.get("file")
if not fl or not fl.filename:
flash("请选择文件", "error")
else:
tmp = os.path.join(EXE_DIR, f"_tmp_mat_{fid}.xlsx")
fl.save(tmp)
from excel_io import read_materials, import_materials as imp
rows = read_materials(tmp)
os.remove(tmp)
added, updated = imp(rows, operator=(g.user.display_name or g.user.username), factory_id=fid)
log_action("工厂批量导入物料", f.code, f"新增 {added} / 更新 {updated}")
flash(f"物料导入完成: 新增 {added} 条, 更新 {updated} 条(期初库存归属 {f.code})", "ok")
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")
@login_required
def dashboard():
fid = current_fid()
mats = db.session.scalars(db.select(Material).order_by(Material.part_number)).all()
sm = stock_map(fid)
rows = []
negative = 0
low = 0
for m in mats:
stock = sm.get(m.id, 0)
status = _status_of(stock, m.safety_stock)
if status == "danger":
negative += 1
elif status == "warn":
low += 1
rows.append({"m": m, "stock": stock, "status": status})
# BOM 物料齐套看板(仅管理员/主管可见)
can_bom = bool(g.user and g.user.role in ("admin", "manager"))
bom_board = []
if can_bom:
boms = db.session.scalars(db.select(Bom).order_by(Bom.code)).all()
for b in boms:
items = db.session.scalars(
db.select(BomItem).where(BomItem.bom_id == b.id).order_by(BomItem.id)
).all()
item_rows = []
for it in items:
m = db.session.get(Material, it.material_id)
if not m:
continue
stock = sm.get(m.id, 0)
item_rows.append({"m": m, "qty": it.qty_per_unit,
"stock": stock, "status": _status_of(stock, m.safety_stock)})
if any(x["status"] == "danger" for x in item_rows):
bstatus = "danger"
elif any(x["status"] == "warn" for x in item_rows):
bstatus = "warn"
else:
bstatus = "ok"
bom_board.append({"b": b, "parts": item_rows, "status": bstatus})
return render_template("dashboard.html", rows=rows, negative=negative, low=low,
bom_board=bom_board, can_bom=can_bom)
# ---------------- 物料 ----------------
@app.route("/materials")
@login_required
def materials():
fid = current_fid()
q = request.args.get("q", "").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)
inb_total = inbound_total_map(fid)
out_total = outbound_total_map(fid)
return render_template("materials.html", mats=mats, sm=sm,
inb_total=inb_total, out_total=out_total, q=q)
@app.route("/material/add", methods=["GET", "POST"])
@login_required
@role_required("admin", "manager")
def material_add():
if request.method == "POST":
pn = request.form["part_number"].strip()
if not pn:
flash("物料编号必填", "error")
elif db.session.scalar(db.select(Material).where(Material.part_number == pn)):
flash("物料编号已存在", "error")
else:
m = Material(
part_number=pn,
manufacturer=request.form.get("manufacturer", ""),
unit=request.form.get("unit", "个") or "个",
safety_stock=float(request.form.get("safety_stock") or 0),
note=request.form.get("note", ""),
)
db.session.add(m)
db.session.commit()
log_action("新增物料", pn, request.form.get("manufacturer", ""))
flash("物料已添加", "ok")
return redirect(url_for("materials"))
return render_template("material_form.html", m=None)
@app.route("/material/edit/<int:mid>", methods=["GET", "POST"])
@login_required
@role_required("admin", "manager")
def material_edit(mid):
m = db.session.get(Material, 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("编辑物料", f"{old_pn} -> {new_pn}")
flash("已保存", "ok")
return redirect(url_for("materials"))
return render_template("material_form.html", m=m)
@app.route("/material/delete/<int:mid>", methods=["POST"])
@login_required
@role_required("admin", "manager")
def material_delete(mid):
m = db.session.get(Material, mid)
if m:
db.session.delete(m)
db.session.commit()
log_action("删除物料", m.part_number)
flash("已删除", "ok")
return redirect(url_for("materials"))
@app.route("/material/<int:mid>")
@login_required
def material_detail(mid):
m = db.session.get(Material, mid)
if not m:
return redirect(url_for("materials"))
fid = current_fid()
sm = stock_map(fid)
inb_total = inbound_total_map(fid)
out_total = outbound_total_map(fid)
stmt_in = db.select(Inbound).where(Inbound.material_id == mid)
stmt_out = db.select(Outbound).where(Outbound.material_id == mid)
if fid:
stmt_in = stmt_in.where(Inbound.factory_id == fid)
stmt_out = stmt_out.where(Outbound.factory_id == fid)
inbounds = db.session.scalars(
stmt_in.order_by(Inbound.occur_date.desc(), Inbound.id.desc())
).all()
outbounds = db.session.scalars(
stmt_out.order_by(Outbound.occur_date.desc(), Outbound.id.desc())
).all()
fmap = {f.id: f.name for f in db.session.scalars(db.select(Factory)).all()}
# 各工厂实时库存(按工厂拆分, 实时聚合 inbound-outbound)
inb_by_f = {r[0]: float(r[1]) for r in db.session.execute(
db.text("SELECT factory_id, COALESCE(SUM(qty),0) FROM inbound WHERE material_id=:mid GROUP BY factory_id"),
{"mid": mid}).fetchall()}
out_by_f = {r[0]: float(r[1]) for r in db.session.execute(
db.text("SELECT factory_id, COALESCE(SUM(qty),0) FROM outbound WHERE material_id=:mid GROUP BY factory_id"),
{"mid": mid}).fetchall()}
per_factory = [{"f": f, "stock": inb_by_f.get(f.id, 0) - out_by_f.get(f.id, 0)}
for f in db.session.scalars(db.select(Factory).order_by(Factory.code)).all()]
return render_template("material_detail.html", m=m,
stock=sm.get(m.id, 0),
inb_total=inb_total.get(m.id, 0),
out_total=out_total.get(m.id, 0),
inbounds=inbounds, outbounds=outbounds, fmap=fmap,
per_factory=per_factory)
# ---------------- 入库 / 出库 ----------------
@app.route("/inbound", methods=["GET", "POST"])
@login_required
def inbound():
if request.method == "POST":
mid = int(request.form["material_id"])
qty = float(request.form["qty"])
if qty <= 0:
flash("入库数量必须大于 0", "error")
else:
dlv = request.form.get("delivery_date", "").strip()
delivery_date = _date(dlv) if dlv else None
fid_raw = request.form.get("factory_id", "").strip()
factory_id = int(fid_raw) if fid_raw.isdigit() else None
rec = Inbound(
material_id=mid, factory_id=factory_id,
batch_no=request.form.get("batch_no", ""),
qty=qty, operator=request.form.get("operator", ""),
source=request.form.get("source", ""),
occur_date=_date(request.form.get("occur_date")),
delivery_date=delivery_date,
remark=request.form.get("remark", ""))
db.session.add(rec)
db.session.commit()
log_action("入库", db.session.get(Material, mid).part_number, f"{qty}")
flash("入库成功", "ok")
return redirect(url_for("inbound"))
fid = current_fid()
mats = db.session.scalars(db.select(Material).order_by(Material.part_number)).all()
stmt = db.select(Inbound).order_by(Inbound.id.desc()).limit(50)
if fid:
stmt = stmt.where(Inbound.factory_id == fid)
recs = db.session.scalars(stmt).all()
return render_template("txn.html", kind="inbound", mats=mats, recs=recs,
today=date.today().isoformat(),
default_factory_id=headquarters_fid())
@app.route("/outbound", methods=["GET", "POST"])
@login_required
def outbound():
if request.method == "POST":
mid = int(request.form["material_id"])
qty = float(request.form["qty"])
if qty <= 0:
flash("出库数量必须大于 0", "error")
else:
stock = stock_map(current_fid()).get(mid, 0)
if qty > stock:
flash(f"库存不足! 当前在库 {stock}", "error")
else:
fid_raw = request.form.get("factory_id", "").strip()
factory_id = int(fid_raw) if fid_raw.isdigit() else None
rec = Outbound(
material_id=mid, factory_id=factory_id,
batch_no=request.form.get("batch_no", ""),
qty=qty, operator=request.form.get("operator", ""),
purpose=request.form.get("purpose", ""),
order_ref=request.form.get("order_ref", ""),
occur_date=_date(request.form.get("occur_date")),
remark=request.form.get("remark", ""))
db.session.add(rec)
db.session.commit()
log_action("出库", db.session.get(Material, mid).part_number, f"{qty}")
flash("出库成功", "ok")
return redirect(url_for("outbound"))
fid = current_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()
stmt = db.select(Outbound).order_by(Outbound.id.desc()).limit(50)
if fid:
stmt = stmt.where(Outbound.factory_id == fid)
recs = db.session.scalars(stmt).all()
return render_template("txn.html", kind="outbound", mats=mats, boms=boms, recs=recs, today=date.today().isoformat())
# ---------------- M2-1+: 出库 / 调拨 的 BOM 选项 ----------------
@app.route("/bom_issue", methods=["POST"])
@login_required
def bom_issue():
"""按 BOM 批量出库/入库:
- mode=pick : 按BOM齐套领料出库(从本厂库存扣减每种组件)
- mode=transfer : 按BOM调拨入库(把成套组件调入目标工厂)
"""
mode = request.form.get("mode", "pick")
try:
bom_id = int(request.form.get("bom_id"))
qty = float(request.form.get("qty") or 0)
except (TypeError, ValueError):
flash("参数错误", "error")
return redirect(url_for("outbound"))
fid_raw = (request.form.get("factory_id") or "").strip()
factory_id = int(fid_raw) if fid_raw.isdigit() else None
operator = request.form.get("operator", "")
occur_date = _date(request.form.get("occur_date"))
remark = request.form.get("remark", "")
if qty <= 0:
flash("数量必须大于 0", "error")
return redirect(url_for("outbound"))
b = db.session.get(Bom, bom_id)
if not b:
flash("BOM 不存在", "error")
return redirect(url_for("outbound"))
if not factory_id:
flash("请选择工厂", "error")
return redirect(url_for("outbound"))
items = db.session.scalars(db.select(BomItem).where(BomItem.bom_id == bom_id)).all()
if not items:
flash("该 BOM 没有组成物料", "error")
return redirect(url_for("outbound"))
plan = []
shorts = []
for it in items:
m = db.session.get(Material, it.material_id)
need = it.qty_per_unit * qty
if need <= 0:
continue
stock = stock_map(factory_id).get(m.id, 0)
plan.append((m, need, stock))
if mode == "pick" and need > stock:
shorts.append(m.part_number)
if mode == "pick" and shorts:
flash(f"库存不足, 无法领料! 缺料: {', '.join(shorts)}", "error")
return redirect(url_for("outbound"))
tag = b.code
for m, need, stock in plan:
if mode == "pick":
db.session.add(Outbound(material_id=m.id, factory_id=factory_id,
qty=need, operator=operator,
purpose=f"BOM领料:{tag}", occur_date=occur_date,
remark=remark))
else:
db.session.add(Inbound(material_id=m.id, factory_id=factory_id,
qty=need, operator=operator,
source=f"BOM调拨:{tag}", occur_date=occur_date,
remark=remark))
db.session.commit()
log_action("BOM领料出库" if mode == "pick" else "BOM调拨入库", tag, f"数量 {qty}, 组件 {len(plan)} 项")
flash(f"{'领料出库' if mode=='pick' else '调拨入库'}成功: BOM {tag} × {qty}, 共 {len(plan)} 种组件", "ok")
return redirect(url_for("outbound"))
@app.route("/api/bom/preview")
@login_required
def bom_preview():
"""BOM 组件需求预览(含按厂库存与缺料判定), 供出库页 BOM 卡片实时预览。"""
try:
bom_id = int(request.args.get("bom_id", 0))
qty = float(request.args.get("qty") or 0)
except (TypeError, ValueError):
return {"items": [], "any_short": False}
fid_raw = (request.args.get("factory_id") or "").strip()
factory_id = int(fid_raw) if fid_raw.isdigit() else None
mode = request.args.get("mode", "pick")
items = []
if bom_id and qty > 0:
sm = stock_map(factory_id)
for it in db.session.scalars(db.select(BomItem).where(BomItem.bom_id == bom_id)).all():
m = db.session.get(Material, it.material_id)
need = it.qty_per_unit * qty
stock = sm.get(m.id, 0)
short = (mode == "pick" and need > stock)
subs = [{"pn": s["sub"].part_number, "mf": s["sub"].manufacturer or "-",
"stock": round(s["stock"], 3), "available": s["available"]}
for s in substitute_rows(m.id, factory_id)]
items.append({"pn": m.part_number, "mf": m.manufacturer or "-",
"need": round(need, 3), "stock": round(stock, 3),
"short": short, "subs": subs})
return {"items": items, "any_short": any(i["short"] for i in items)}
# ---------------- M2-2: 厂间调拨 ----------------
@app.route("/transfer/new", methods=["GET", "POST"])
@login_required
@role_required("admin", "manager")
def transfer_new():
"""新建厂间调拨: 源厂出库 + 目标厂入库(双向记账, 实时校验源厂库存)。"""
factories = db.session.scalars(
db.select(Factory).where(Factory.enabled == True).order_by(Factory.code)).all()
materials = db.session.scalars(
db.select(Material).order_by(Material.part_number)).all()
if request.method == "POST":
try:
from_id = int(request.form.get("from_factory_id"))
to_id = int(request.form.get("to_factory_id"))
material_id = int(request.form.get("material_id"))
qty = float(request.form.get("qty") or 0)
except (TypeError, ValueError):
flash("参数错误", "error")
return redirect(url_for("transfer_new"))
operator = (request.form.get("operator", "") or "").strip()
occur_date = _date(request.form.get("occur_date"))
remark = (request.form.get("remark", "") or "").strip()
if qty <= 0:
flash("调拨数量必须大于 0", "error")
return redirect(url_for("transfer_new"))
if from_id == to_id:
flash("调出工厂与调入工厂不能相同", "error")
return redirect(url_for("transfer_new"))
ff = db.session.get(Factory, from_id)
tf = db.session.get(Factory, to_id)
m = db.session.get(Material, material_id)
if not (ff and tf and m):
flash("工厂或物料不存在", "error")
return redirect(url_for("transfer_new"))
src_stock = stock_map(from_id).get(material_id, 0)
if qty > src_stock:
flash(f"源厂 {ff.code} 库存不足: 现有 {src_stock}, 需调拨 {qty}", "error")
return redirect(url_for("transfer_new"))
op = g.user.display_name or g.user.username
no = _gen_transfer_no()
db.session.add(Transfer(no=no, from_factory_id=from_id, to_factory_id=to_id,
material_id=material_id, qty=qty, operator=op,
occur_date=occur_date, remark=remark))
db.session.add(Outbound(material_id=material_id, factory_id=from_id, qty=qty,
operator=op, purpose="厂间调拨出库", order_ref=no,
occur_date=occur_date, remark=remark or f"调往 {tf.code}"))
db.session.add(Inbound(material_id=material_id, factory_id=to_id, qty=qty,
operator=op, source="厂间调拨入库",
occur_date=occur_date, remark=remark or f"自 {ff.code} 调入"))
db.session.commit()
log_action("厂间调拨", no, f"{ff.code} → {tf.code}: {m.part_number} × {qty}")
flash(f"调拨成功: {ff.code} → {tf.code}, {m.part_number} × {qty}", "ok")
return redirect(url_for("transfers"))
cur = current_fid()
return render_template("transfer_form.html", factories=factories, materials=materials,
current_fid=cur, today=date.today().isoformat())
@app.route("/transfers")
@login_required
def transfers():
"""厂间调拨列表(当前选中某厂时, 仅显示调出/调入涉及该厂的记录)。"""
fid = current_fid()
recs = db.session.scalars(
db.select(Transfer).order_by(Transfer.created_at.desc())).all()
if fid:
recs = [t for t in recs if t.from_factory_id == fid or t.to_factory_id == fid]
rows = []
for t in recs:
ff = db.session.get(Factory, t.from_factory_id)
tf = db.session.get(Factory, t.to_factory_id)
m = db.session.get(Material, t.material_id)
rows.append({"t": t, "ff": ff, "tf": tf, "m": m})
return render_template("transfers.html", rows=rows, factory=fid)
# ---------------- M3+: 替代料关系管理 ----------------
@app.route("/substitutes", methods=["GET", "POST"])
@login_required
@role_required("admin", "manager")
def substitutes():
fid = current_fid()
mats = db.session.scalars(db.select(Material).order_by(Material.part_number)).all()
if request.method == "POST" and request.form.get("action") == "add":
try:
main_id = int(request.form.get("main_id"))
sub_id = int(request.form.get("sub_id"))
except (TypeError, ValueError):
flash("参数错误", "error")
return redirect(url_for("substitutes"))
note = (request.form.get("note") or "").strip()
if main_id == sub_id:
flash("主料与替代料不能相同", "error")
elif db.session.scalar(db.select(Substitute).where(
Substitute.main_id == main_id, Substitute.sub_id == sub_id)):
flash("该替代关系已存在", "error")
else:
mm = db.session.get(Material, main_id)
sm_ = db.session.get(Material, sub_id)
db.session.add(Substitute(main_id=main_id, sub_id=sub_id, note=note))
db.session.commit()
log_action("替代料", mm.part_number if mm else main_id,
f"新增替代 {sm_.part_number if sm_ else sub_id}")
flash("已添加替代关系", "ok")
return redirect(url_for("substitutes"))
sm = stock_map(fid)
rows = []
for s in db.session.scalars(db.select(Substitute).order_by(Substitute.id)).all():
m = db.session.get(Material, s.main_id)
sub_m = db.session.get(Material, s.sub_id)
if not (m and sub_m):
continue
rows.append({"id": s.id, "main": m, "sub": sub_m, "note": s.note or "",
"stock": sm.get(sub_m.id, 0)})
return render_template("substitutes.html", rows=rows, mats=mats, factory=fid)
@app.route("/substitute/delete/<int:sid>", methods=["POST"])
@login_required
@role_required("admin", "manager")
def substitute_delete(sid):
s = db.session.get(Substitute, sid)
if s:
db.session.delete(s)
db.session.commit()
flash("已删除替代关系", "ok")
return redirect(url_for("substitutes"))
@app.route("/substitute/import", methods=["POST"])
@login_required
@role_required("admin", "manager")
def substitute_import():
f = request.files.get("file")
if not f:
flash("未选择文件", "error")
return redirect(url_for("substitutes"))
import tempfile, os
tmp = os.path.join(tempfile.gettempdir(), "sub_import.xlsx")
f.save(tmp)
try:
added, skipped, missing = import_substitutes_from_excel(tmp)
flash(f"导入完成: 新增 {added} 条, 跳过(已存在/无效) {skipped} 条, 物料未找到 {missing} 条", "ok")
except Exception as e:
flash(f"导入失败: {e}", "error")
finally:
try:
os.remove(tmp)
except OSError:
pass
return redirect(url_for("substitutes"))
@app.route("/api/substitutes/<int:mid>")
@login_required
def api_substitutes(mid):
"""返回主料 mid 的可替代料(含当前厂库存), 供出库/BOM 领料实时提示。"""
fid = current_fid()
sm = stock_map(fid)
main_stock = sm.get(mid, 0)
subs = substitute_rows(mid, fid)
return {
"main_stock": round(main_stock, 3),
"subs": [{"id": s["sub"].id, "pn": s["sub"].part_number,
"mf": s["sub"].manufacturer or "-",
"stock": round(s["stock"], 3),
"available": s["available"]} for s in subs],
}
@app.route("/api/transfer/preview")
@login_required
def transfer_preview():
"""调拨源厂库存预览: 返回源厂在库与是否充足。"""
try:
material_id = int(request.args.get("material_id", 0))
from_id = int(request.args.get("from_factory_id", 0))
qty = float(request.args.get("qty") or 0)
except (TypeError, ValueError):
return {"stock": 0, "short": False}
stock = stock_map(from_id).get(material_id, 0) if from_id else 0
return {"stock": round(stock, 3), "short": qty > stock}
def _build_ledger_rows(fid, frm, to, type_filter=None, q=None):
"""返回流水台账行(已筛选+排序), 供页面与导出复用。"""
inb = db.session.scalars(db.select(Inbound)).all()
out = db.session.scalars(db.select(Outbound)).all()
if fid:
inb = [x for x in inb if (x.factory_id or 0) == fid]
out = [x for x in out if (x.factory_id or 0) == fid]
mats = {m.id: m for m in db.session.scalars(db.select(Material)).all()}
facs = {f.id: f for f in db.session.scalars(db.select(Factory)).all()}
def classify(who):
if who and "调拨" in who:
return "调拨"
if who and "生产" in who:
return "生产"
return "其他"
rows = []
for x in inb:
d = x.occur_date
if (frm and d < frm) or (to and d > to):
continue
who = x.source
rows.append({"date": d, "type": "入", "m": mats.get(x.material_id),
"qty": x.qty, "fac": facs.get(x.factory_id),
"who": who, "ref": x.order_ref, "op": x.operator,
"cat": classify(who)})
for x in out:
d = x.occur_date
if (frm and d < frm) or (to and d > to):
continue
who = x.purpose
rows.append({"date": d, "type": "出", "m": mats.get(x.material_id),
"qty": x.qty, "fac": facs.get(x.factory_id),
"who": who, "ref": x.order_ref, "op": x.operator,
"cat": classify(who)})
# 业务类型筛选
if type_filter:
if type_filter == "in":
rows = [r for r in rows if r["type"] == "入"]
elif type_filter == "out":
rows = [r for r in rows if r["type"] == "出"]
elif type_filter == "transfer":
rows = [r for r in rows if r["cat"] == "调拨"]
elif type_filter == "prod":
rows = [r for r in rows if r["cat"] == "生产"]
elif type_filter == "other":
rows = [r for r in rows if r["cat"] == "其他"]
# 调拨/关联单号 关键字搜索(如 TR... / WO...)
if q:
ql = q.strip().lower()
if ql:
rows = [r for r in rows if (r["ref"] or "").lower().find(ql) >= 0]
rows.sort(key=lambda r: (r["date"], r["type"]), reverse=True)
return rows
@app.route("/ledger")
@login_required
def ledger():
"""流水台账: 把入库、出库(含厂间调拨/生产领用/生产调入产生的双向流水)按日期合并成一张表。"""
fid = current_fid()
frm = _date(request.args.get("from")) if request.args.get("from") else None
to = _date(request.args.get("to")) if request.args.get("to") else None
type_filter = request.args.get("type", "")
q = request.args.get("q", "")
rows = _build_ledger_rows(fid, frm, to, type_filter or None, q)
# 按日期分组(同一天的出入合并展示)
by_date = {}
for r in rows:
by_date.setdefault(r["date"], []).append(r)
date_groups = sorted(by_date.items(), key=lambda kv: kv[0], reverse=True)
total_in = sum(x["qty"] for x in rows if x["type"] == "入")
total_out = sum(x["qty"] for x in rows if x["type"] == "出")
cur = db.session.get(Factory, fid) if fid else None
return render_template("ledger.html", date_groups=date_groups, total_in=total_in,
total_out=total_out, factory=cur,
frm=request.args.get("from", ""), to=request.args.get("to", ""),
type=type_filter, q=q)
@app.route("/ledger/export")
@login_required
def ledger_export():
"""导出流水台账为 Excel(沿用当前日期/类型/单号筛选条件)。"""
fid = current_fid()
frm = _date(request.args.get("from")) if request.args.get("from") else None
to = _date(request.args.get("to")) if request.args.get("to") else None
type_filter = request.args.get("type", "")
q = request.args.get("q", "")
rows = _build_ledger_rows(fid, frm, to, type_filter or None, q)
import io
from excel_io import export_ledger
bio = io.BytesIO()
export_ledger(bio, rows)
bio.seek(0)
# 文件名按所选日期区间命名, 体现"按日期生成, 出库+入库同一文件"
if frm and to and frm == to:
seg = frm.isoformat()
elif frm and to:
seg = f"{frm.isoformat()}_至_{to.isoformat()}"
elif frm:
seg = f"{frm.isoformat()}_起"
elif to:
seg = f"至_{to.isoformat()}"
else:
seg = date.today().isoformat()
fname = f"流水台账_{seg}.xlsx"
return send_file(bio, as_attachment=True, download_name=fname,
mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
# ---------------- M2: BOM ----------------
@app.route("/boms")
@login_required
@role_required("admin", "manager")
def boms():
bs = db.session.scalars(db.select(Bom).order_by(Bom.code)).all()
data = []
for b in bs:
items = db.session.scalars(db.select(BomItem).where(BomItem.bom_id == b.id)).all()
data.append({"b": b, "count": len(items)})
return render_template("boms.html", data=data)
@app.route("/bom/add", methods=["GET", "POST"])
@login_required
@role_required("admin", "manager")
def bom_add():
mats = db.session.scalars(db.select(Material).order_by(Material.part_number)).all()
if request.method == "POST":
code = request.form.get("code", "").strip()
if not code:
flash("BOM 型号必填", "error")
elif db.session.scalar(db.select(Bom).where(Bom.code == code)):
flash("BOM 型号已存在", "error")
else:
b = Bom(code=code, name=request.form.get("name", ""), note=request.form.get("note", ""))
db.session.add(b)
db.session.flush()
pns = request.form.getlist("pn")
qts = request.form.getlist("qty")
for pn, qt in zip(pns, qts):
pn = pn.strip()
if not pn:
continue
try:
q = float(qt)
except ValueError:
continue
if q <= 0:
continue
m = db.session.scalar(db.select(Material).where(Material.part_number == pn))
if m:
db.session.add(BomItem(bom_id=b.id, material_id=m.id, qty_per_unit=q))
db.session.commit()
log_action("新增BOM", code)
flash("BOM 已保存", "ok")
return redirect(url_for("boms"))
return render_template("bom_form.html", b=None, mats=mats, items=[])
@app.route("/bom/edit/<int:bid>", methods=["GET", "POST"])
@login_required
@role_required("admin", "manager")
def bom_edit(bid):
b = db.session.get(Bom, bid)
if not b:
return redirect(url_for("boms"))
mats = db.session.scalars(db.select(Material).order_by(Material.part_number)).all()
if request.method == "POST":
# BOM 型号(简码)可编辑: 校验非空 + 不与其它 BOM 重复
new_code = request.form.get("code", "").strip()
b.name = request.form.get("name", "")
b.note = request.form.get("note", "")
raw = db.session.scalars(db.select(BomItem).where(BomItem.bom_id == b.id)).all()
items = [{"pn": db.session.get(Material, it.material_id).part_number,
"qty": it.qty_per_unit} for it in raw]
if not new_code:
flash("BOM 型号必填", "error")
return render_template("bom_form.html", b=b, mats=mats, items=items, form_code=new_code)
dup = db.session.scalar(db.select(Bom).where(Bom.code == new_code, Bom.id != b.id))
if dup:
flash("BOM 型号已存在", "error")
return render_template("bom_form.html", b=b, mats=mats, items=items, form_code=new_code)
b.code = new_code
db.session.execute(db.delete(BomItem).where(BomItem.bom_id == b.id))
pns = request.form.getlist("pn")
qts = request.form.getlist("qty")
for pn, qt in zip(pns, qts):
pn = pn.strip()
if not pn:
continue
try:
q = float(qt)
except ValueError:
continue
if q <= 0:
continue
m = db.session.scalar(db.select(Material).where(Material.part_number == pn))
if m:
db.session.add(BomItem(bom_id=b.id, material_id=m.id, qty_per_unit=q))
db.session.commit()
log_action("编辑BOM", b.code)
flash("已保存", "ok")
return redirect(url_for("boms"))
raw = db.session.scalars(db.select(BomItem).where(BomItem.bom_id == b.id)).all()
items = [{"pn": db.session.get(Material, it.material_id).part_number,
"qty": it.qty_per_unit} for it in raw]
return render_template("bom_form.html", b=b, mats=mats, items=items)
@app.route("/bom/delete/<int:bid>", methods=["POST"])
@login_required
@role_required("admin", "manager")
def bom_delete(bid):
b = db.session.get(Bom, bid)
if b:
db.session.execute(db.delete(BomItem).where(BomItem.bom_id == b.id))
db.session.delete(b)
db.session.commit()
log_action("删除BOM", b.code)
flash("已删除", "ok")
return redirect(url_for("boms"))
@app.route("/bom/<int:bid>")
@login_required
@role_required("admin", "manager")
def bom_detail(bid):
b = db.session.get(Bom, bid)
if not b:
return redirect(url_for("boms"))
items = db.session.scalars(
db.select(BomItem).where(BomItem.bom_id == b.id)
.order_by(BomItem.id)
).all()
sm = stock_map()
rows = []
for it in items:
m = db.session.get(Material, it.material_id)
if not m:
continue
stock = sm.get(m.id, 0)
rows.append({"m": m, "qty": it.qty_per_unit,
"stock": stock, "status": _status_of(stock, m.safety_stock)})
return render_template("bom_detail.html", b=b, rows=rows)
# ---------------- M2: 生产订单 ----------------
@app.route("/orders")
@login_required
@role_required("admin", "manager")
def orders():
os_ = db.session.scalars(db.select(ProdOrder).order_by(ProdOrder.order_no.desc())).all()
data = []
for o in os_:
bom = db.session.get(Bom, o.bom_id)
fac = db.session.get(Factory, o.factory_id) if o.factory_id else None
data.append({"o": o, "bom": bom, "factory": fac})
return render_template("orders.html", data=data)
@app.route("/order/new", methods=["GET", "POST"])
@login_required
@role_required("admin", "manager")
def order_new():
boms_all = db.session.scalars(db.select(Bom).order_by(Bom.code)).all()
factories = db.session.scalars(db.select(Factory).order_by(Factory.code)).all()
if request.method == "POST":
bom_id = int(request.form["bom_id"])
qty = float(request.form["qty"])
fid_raw = (request.form.get("factory_id") or "").strip()
factory_id = int(fid_raw) if fid_raw.isdigit() else None
if qty <= 0:
flash("接单量必须大于 0", "error")
else:
order_no = request.form.get("order_no", "").strip() or _gen_order_no()
if db.session.scalar(db.select(ProdOrder).where(ProdOrder.order_no == order_no)):
order_no = _gen_order_no()
o = ProdOrder(order_no=order_no, bom_id=bom_id, factory_id=factory_id, qty=qty,
status="planned", operator=(g.user.display_name or g.user.username),
remark=request.form.get("remark", "").strip())
db.session.add(o)
db.session.commit()
log_action("新建生产订单", order_no, f"接单量 {qty}")
return redirect(url_for("order_detail", oid=o.id))
return render_template("order_form.html", boms=boms_all, factories=factories, o=None)
@app.route("/order/<int:oid>")
@login_required
@role_required("admin", "manager")
def order_detail(oid):
o = db.session.get(ProdOrder, oid)
if not o:
return redirect(url_for("orders"))
bom = db.session.get(Bom, o.bom_id)
items = db.session.scalars(db.select(BomItem).where(BomItem.bom_id == o.bom_id)).all()
# 查看订单时, 库存/缺口按「下单之前」的工厂库存快照展示(即按下单那一刻的库存),
# 按下单时间截断, 自动排除该订单自身后续的调入/领料及下单后发生的其他出入库,
# 用于还原下单时的可供料情况, 不受后续出入库影响。
sm = stock_map_as_of(o.factory_id, o.created_at)
fac = db.session.get(Factory, o.factory_id) if o.factory_id else None
# 已录入的「调入数量」计划
allocs = db.session.scalars(
db.select(ProdOrderAlloc).where(ProdOrderAlloc.order_id == oid)).all()
alloc_map = {a.material_id: a for a in allocs} # material_id -> alloc 行
reqs = []
for it in items:
m = db.session.get(Material, it.material_id)
need = it.qty_per_unit * o.qty
have = sm.get(m.id, 0)
a = alloc_map.get(m.id)
subs = [{"pn": s["sub"].part_number, "mf": s["sub"].manufacturer or "-",
"stock": round(s["stock"], 3), "available": s["available"]}
for s in substitute_rows(m.id, o.factory_id, None)]
reqs.append({"m": m, "need": need, "have": have,
"gap": have - need, "short": need > have, "subs": subs,
"alloc": a.alloc_qty if a else 0,
"posted": a.posted if a else False})
# 缺口 = 工厂库存 - 需求(少为负=缺料, 多为正=富余); 按缺口升序(从小到大),
# 使缺料最严重(缺口最负)的物料排在最前, 便于优先调度
reqs.sort(key=lambda x: x["gap"])
posted_total = sum(a.alloc_qty for a in allocs if a.posted)
planned_total = sum(a.alloc_qty for a in allocs)
posted_count = sum(1 for a in allocs if a.posted)
factories = db.session.scalars(
db.select(Factory).where(Factory.enabled == True).order_by(Factory.code)).all()
return render_template("order_detail.html", o=o, bom=bom, reqs=reqs, factory=fac,
factories=factories,
posted_total=posted_total, planned_total=planned_total,
posted_count=posted_count)
@app.route("/order/<int:oid>/pick", methods=["POST"])
@login_required
@role_required("admin", "manager")
def order_pick(oid):
o = db.session.get(ProdOrder, oid)
if not o or o.status == "picked":
return redirect(url_for("orders"))
items = db.session.scalars(db.select(BomItem).where(BomItem.bom_id == o.bom_id)).all()
sm = stock_map(o.factory_id)
picks = 0
shorts = []
for it in items:
m = db.session.get(Material, it.material_id)
need = it.qty_per_unit * o.qty
if need <= 0:
continue
db.session.add(Outbound(material_id=m.id, factory_id=o.factory_id, qty=need,
operator=(g.user.display_name or g.user.username),
purpose="生产领用", order_ref=o.order_no,
occur_date=date.today()))
picks += 1
if need > sm.get(m.id, 0):
shorts.append(m.part_number)
o.status = "picked"
db.session.commit()
log_action("生产领料", o.order_no, f"生成出库 {picks} 项" + (f", 缺料: {','.join(shorts)}" if shorts else ""))
if shorts:
flash(f"已领料, 但缺料: {', '.join(shorts)}", "warn")
else:
flash("领料出库完成", "ok")
return redirect(url_for("order_detail", oid=oid))
@app.route("/order/<int:oid>/edit", methods=["GET", "POST"])
@login_required
@role_required("admin", "manager")
def order_edit(oid):
o = db.session.get(ProdOrder, oid)
if not o:
return redirect(url_for("orders"))
boms_all = db.session.scalars(db.select(Bom).order_by(Bom.code)).all()
factories = db.session.scalars(db.select(Factory).order_by(Factory.code)).all()
if request.method == "POST":
bom_id = int(request.form["bom_id"])
qty = float(request.form["qty"])
fid_raw = (request.form.get("factory_id") or "").strip()
factory_id = int(fid_raw) if fid_raw.isdigit() else None
if qty <= 0:
flash("接单量必须大于 0", "error")
else:
old_no = o.order_no
order_no = request.form.get("order_no", "").strip() or o.order_no
if order_no != o.order_no and db.session.scalar(
db.select(ProdOrder).where(ProdOrder.order_no == order_no)):
flash(f"订单号 {order_no} 已存在, 已保留原单号", "warn")
order_no = o.order_no
o.order_no = order_no
o.bom_id = bom_id
o.factory_id = factory_id
o.qty = qty
o.remark = request.form.get("remark", "").strip()
# 仅更新订单头; 已录入的「调入数量」计划与已入账流水保持不变
# (需求: 改订单号/改生产数量均不冲销, 调入数量保持编辑前的值)。
if order_no != old_no:
# 备份后, 把关联流水(订单号作为关联键 order_ref)同步为新单号, 使其余数据保持一致
try:
_bak = os.path.join(EXE_DIR, "_backup",
f"edit_bak_{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
db.session.execute(
update(Inbound).where(Inbound.order_ref == old_no)
.values(order_ref=order_no))
db.session.execute(
update(Outbound).where(Outbound.order_ref == old_no)
.values(order_ref=order_no))
log_action("编辑生产订单", order_no,
f"接单量 {qty}; 订单号 {old_no}→{order_no}, 已同步关联流水 order_ref"
+ (f"; 备份 {os.path.basename(_bak)}" if _bak else ""))
flash(f"订单已更新(订单号 {old_no}→{order_no}),调入数量与已入账流水保持不变", "ok")
else:
log_action("编辑生产订单", order_no, f"接单量 {qty}; 订单头字段更新(其余数据不变)")
flash("订单已更新,调入数量与已入账流水保持不变", "ok")
db.session.commit()
return redirect(url_for("order_detail", oid=oid))
return render_template("order_form.html", boms=boms_all, factories=factories, o=o)
@app.route("/order/<int:oid>/delete", methods=["POST"])
@login_required
@role_required("admin", "manager")
def order_delete(oid):
o = db.session.get(ProdOrder, oid)
if not o:
return redirect(url_for("orders"))
no = o.order_no
# 删除关联的「调入数量」计划行
db.session.execute(
db.delete(ProdOrderAlloc).where(ProdOrderAlloc.order_id == oid))
# 级联删除该订单关联的领料出库流水(用途=生产领用, order_ref=订单号)
n_out = db.session.execute(
db.delete(Outbound).where(Outbound.order_ref == no)).rowcount
# 级联删除该订单关联的「调入」流水(生产订单调拨产生的源厂出库 + 生产厂入库, 均带 order_ref)
n_in = db.session.execute(
db.delete(Inbound).where(Inbound.order_ref == no)).rowcount
db.session.delete(o)
db.session.commit()
log_action("删除生产订单", no, f"已删除订单头、调入计划及关联流水 出库{n_out}/入库{n_in} 笔")
flash(f"已删除生产订单 {no}(含关联出库 {n_out} 笔、入库 {n_in} 笔)", "ok")
return redirect(url_for("orders"))
def _find_posted_flows(order_no, material_id):
"""返回某生产订单某物料已入账调入流水(源厂出库 + 生产厂入库)。"""
outs = db.session.scalars(db.select(Outbound).where(
Outbound.order_ref == order_no, Outbound.material_id == material_id,
Outbound.purpose == "生产订单调出")).all()
ins = db.session.scalars(db.select(Inbound).where(
Inbound.order_ref == order_no, Inbound.material_id == material_id,
Inbound.source == "生产订单调入")).all()
return outs, ins
def _cancel_posted(o, material_id, op):
"""撤销某物料已入账的调入流水(删除源厂出库 + 生产厂入库), 不改变 alloc 行本身。"""
outs, ins = _find_posted_flows(o.order_no, material_id)
for x in outs + ins:
db.session.delete(x)
if outs or ins:
log_action("撤销调入", o.order_no,
f"物料ID {material_id} 撤销原调入流水 出{len(outs)}/入{len(ins)} 笔")
return len(outs), len(ins)
def _repost(o, material_id, qty, from_id, op, ff):
"""按指定源厂重新生成调入流水(源厂出库减 + 生产厂入库加, 总库存不变)。"""
db.session.add(Outbound(
material_id=material_id, factory_id=from_id, qty=qty,
operator=op, purpose="生产订单调出", order_ref=o.order_no,
occur_date=date.today(),
remark=f"生产订单 {o.order_no} 调入(自 {ff.code})"))
db.session.add(Inbound(
material_id=material_id, factory_id=o.factory_id, qty=qty,
operator=op, source="生产订单调入", order_ref=o.order_no,
occur_date=date.today(),
remark=f"生产订单 {o.order_no} 调入(自 {ff.code})"))
def _apply_alloc_change(o, material_id, new_qty, op, from_id=None, account_new=False):
"""把某物料的「调入数量」调整到 new_qty, 并保证实际库存流水与之一致。
返回状态: 'unchanged' / 'planned' / 'posted' / 'cancel' / 'short' / 'nosource'
- 未入账行: account_new=True 且给定 from_id(≠生产厂) 时直接入账; 否则仅更新计划(不生成流水)。
- 已入账行: 若 new_qty 与已入账数量不同, 先撤销原流水, 再从原始来源厂重新入账(或归零撤销)。
源厂库存校验在撤销前做(避免撤销后因源厂不足而无法回补、丢失调入)。
"""
new_qty = max(float(new_qty), 0.0)
a = db.session.scalar(db.select(ProdOrderAlloc).where(
ProdOrderAlloc.order_id == o.id,
ProdOrderAlloc.material_id == material_id))
posted = bool(a and a.posted)
changed = (a is None) or (abs((a.alloc_qty or 0) - new_qty) > 1e-9)
if new_qty <= 0:
if posted:
_cancel_posted(o, material_id, op)
a.alloc_qty = 0
a.posted = False
return "cancel"
if a is not None:
a.alloc_qty = 0 # 未入账计划归零(保留行)
return "planned"
if posted:
if not changed:
return "unchanged"
outs, _ = _find_posted_flows(o.order_no, material_id)
if not outs:
return "nosource"
src_fid = outs[0].factory_id
old_qty = outs[0].qty
# 撤销后源厂可用 = 当前源厂库存(仍含原调出扣减) + 原调出量
available = stock_map(src_fid).get(material_id, 0) + old_qty
if new_qty > available:
return "short" # 源厂不足, 保留原入账不动
_cancel_posted(o, material_id, op)
ff = db.session.get(Factory, src_fid)
_repost(o, material_id, new_qty, src_fid, op, ff)
a.alloc_qty = new_qty
a.posted = True
return "posted"
# 未入账
if account_new and from_id is not None and from_id != (o.factory_id or -1):
src_stock = stock_map(from_id).get(material_id, 0)
if new_qty > src_stock:
return "short"
ff = db.session.get(Factory, from_id)
_repost(o, material_id, new_qty, from_id, op, ff)
if a is None:
a = ProdOrderAlloc(order_id=o.id, material_id=material_id)
db.session.add(a)
a.alloc_qty = new_qty
a.posted = True
return "posted"
# 仅更新计划, 不生成流水
if a is None:
a = ProdOrderAlloc(order_id=o.id, material_id=material_id, alloc_qty=new_qty)
db.session.add(a)
else:
a.alloc_qty = new_qty
return "planned"
@app.route("/order/<int:oid>/alloc", methods=["POST"])
@login_required
@role_required("admin", "manager")
def order_alloc_save(oid):
"""保存生产订单各物料的「调入数量」。
- 未入账行: 更新计划(不生成流水)。
- 已入账(已调入)行: 若数量被改动, 自动撤销原流水并按原始来源厂重新入账(最终调入数量=确认后的数量); 改为 0 则撤销调入。
"""
o = db.session.get(ProdOrder, oid)
if not o:
return redirect(url_for("orders"))
op = g.user.display_name or g.user.username
items = db.session.scalars(db.select(BomItem).where(BomItem.bom_id == o.bom_id)).all()
edited = 0
for it in items:
raw = request.form.get(f"alloc_{it.material_id}", "").strip()
if raw == "":
continue
try:
qty = float(raw)
except ValueError:
continue
if qty < 0:
qty = 0
st = _apply_alloc_change(o, it.material_id, qty, op, account_new=False)
if st in ("posted", "cancel"):
edited += 1
db.session.commit()
if edited:
log_action("保存调入数量", o.order_no, f"已调整 {edited} 项已入账调入(撤销并重入)")
flash(f"已保存调入数量(其中 {edited} 项已入账调入已按新值调整)", "ok")
else:
flash("已保存调入数量", "ok")
return redirect(url_for("order_detail", oid=oid))
@app.route("/order/<int:oid>/alloc/post", methods=["POST"])
@login_required
@role_required("admin", "manager")
def order_alloc_post(oid):
"""确认调入: 厂间调拨(源厂出库减 + 生产厂入库加, 总库存不变)。
与「保存调入数量」共用同一表单(formaction 提交)。
- 未入账行: 按所选来源厂入账。
- 已入账(已调入)行: 若数量被改动, 先撤销原流水、再从原始来源厂重新入账; 改为 0 则撤销调入。
源厂不足时跳过并提示, 原入账保持不变。
"""
o = db.session.get(ProdOrder, oid)
if not o:
return redirect(url_for("orders"))
# 解析源工厂(用于未入账行的首次入账)
from_raw = (request.form.get("from_factory_id") or "").strip()
if not from_raw.isdigit():
flash("请先选择「调入来源工厂」", "error")
return redirect(url_for("order_detail", oid=oid))
from_id = int(from_raw)
ff = db.session.get(Factory, from_id)
if not ff:
flash("调入来源工厂不存在", "error")
return redirect(url_for("order_detail", oid=oid))
items = db.session.scalars(db.select(BomItem).where(BomItem.bom_id == o.bom_id)).all()
op = g.user.display_name or g.user.username
n = 0
shorts = []
for it in items:
raw = request.form.get(f"alloc_{it.material_id}", "").strip()
qty = None
if raw != "":
try:
qty = float(raw)
except ValueError:
qty = None
a = db.session.scalar(db.select(ProdOrderAlloc).where(
ProdOrderAlloc.order_id == oid,
ProdOrderAlloc.material_id == it.material_id))
if qty is None:
if a and a.posted:
qty = a.alloc_qty # 已入账且无输入 = 维持, helper 判定 unchanged 跳过
else:
continue # 未入账且无输入 = 不处理
if qty < 0:
qty = 0
st = _apply_alloc_change(o, it.material_id, qty, op,
from_id=from_id, account_new=True)
if st == "posted":
n += 1
elif st == "short":
m = db.session.get(Material, it.material_id)
if a and a.posted:
shorts.append(f"{m.part_number}(已入账调入修改, 源厂{ff.code}库存不足)")
else:
shorts.append(f"{m.part_number}(源厂{ff.code}库存不足)")
db.session.commit()
if n == 0 and not shorts:
flash("没有新的调入数量待确认(数量需>0, 且不能重复入账)", "warn")
elif n > 0 and not shorts:
log_action("确认调入", o.order_no, f"从 {ff.code} 调拨 {n} 项到生产工厂(总库存不变)")
flash(f"已从 {ff.code} 调入 {n} 项到生产工厂(总库存不变)", "ok")
elif n > 0 and shorts:
log_action("确认调入", o.order_no, f"从 {ff.code} 调拨 {n} 项(部分缺料跳过)")
flash(f"已调入 {n} 项; 以下源厂库存不足已跳过: {', '.join(shorts)}", "warn")
else:
flash(f"源厂库存不足, 全部跳过: {', '.join(shorts)}", "error")
return redirect(url_for("order_detail", oid=oid))
def _gen_order_no():
d = date.today().strftime("%Y%m%d")
n = db.session.scalar(db.select(db.func.count(ProdOrder.id))) + 1
return f"WO{d}{n:03d}"
def _gen_transfer_no():
d = date.today().strftime("%Y%m%d")
n = db.session.scalar(db.select(db.func.count(Transfer.id))) + 1
return f"TR{d}{n:03d}"
# ---------------- M3: 报表 / 日志 / 用户 ----------------
@app.route("/report")
@login_required
@role_required("admin", "manager")
def report():
mats = db.session.scalars(db.select(Material).order_by(Material.part_number)).all()
sm = stock_map()
rows = []
for m in mats:
stock = sm.get(m.id, 0)
if stock < 0:
status = "danger"
elif stock < (m.safety_stock or 0):
status = "warn"
else:
status = "ok"
rows.append({"m": m, "stock": stock, "status": status})
return render_template("report.html", rows=rows, today=date.today().isoformat())
@app.route("/export/inventory")
@login_required
@role_required("admin", "manager")
def export_inventory():
from excel_io import export_inventory
mats = db.session.scalars(db.select(Material).order_by(Material.part_number)).all()
sm = stock_map()
rows = []
for m in mats:
stock = sm.get(m.id, 0)
status = "danger" if stock < 0 else ("warn" if stock < (m.safety_stock or 0) else "ok")
rows.append({"m": m, "stock": stock, "status": status})
path = os.path.join(EXE_DIR, f"库存导出_{date.today().isoformat()}.xlsx")
export_inventory(path, rows)
return send_file(path, as_attachment=True, download_name=os.path.basename(path))
@app.route("/logs")
@login_required
@role_required("admin", "manager")
def logs():
recs = db.session.scalars(db.select(OperationLog).order_by(OperationLog.id.desc()).limit(200)).all()
return render_template("logs.html", recs=recs)
@app.route("/users")
@login_required
@role_required("admin")
def users():
us = db.session.scalars(db.select(User).order_by(User.username)).all()
return render_template("users.html", us=us)
@app.route("/user/add", methods=["POST"])
@login_required
@role_required("admin")
def user_add():
un = request.form.get("username", "").strip()
pw = request.form.get("password", "")
role = request.form.get("role", "operator")
if not un or not pw:
flash("用户名和密码必填", "error")
elif db.session.scalar(db.select(User).where(User.username == un)):
flash("用户名已存在", "error")
else:
db.session.add(User(username=un, password=sha256(pw), role=role,
display_name=request.form.get("display_name", "")))
db.session.commit()
log_action("新增用户", un, role)
flash("用户已添加", "ok")
return redirect(url_for("users"))
@app.route("/user/toggle/<int:uid>", methods=["POST"])
@login_required
@role_required("admin")
def user_toggle(uid):
u = db.session.get(User, uid)
if u and u.id != g.user.id:
u.active = not u.active
db.session.commit()
log_action("启用/停用用户", u.username)
return redirect(url_for("users"))
# ---------------- Excel 导入 ----------------
@app.route("/import/materials", methods=["GET", "POST"])
@login_required
@role_required("admin", "manager")
def import_materials():
if request.method == "POST":
f = request.files.get("file")
if not f or not f.filename:
flash("请选择文件", "error")
else:
tmp = os.path.join(EXE_DIR, "_tmp_materials.xlsx")
f.save(tmp)
from excel_io import read_materials, import_materials as imp
rows = read_materials(tmp)
added, updated = imp(rows, operator=(g.user.display_name or g.user.username))
os.remove(tmp)
log_action("导入物料", "Excel", f"新增 {added} / 更新 {updated}")
flash(f"导入完成: 新增 {added} 条, 更新 {updated} 条", "ok")
return redirect(url_for("materials"))
return render_template("import_form.html", kind="materials")
@app.route("/import/bom", methods=["GET", "POST"])
@login_required
@role_required("admin", "manager")
def import_bom():
if request.method == "POST":
f = request.files.get("file")
if not f or not f.filename:
flash("请选择文件", "error")
else:
tmp = os.path.join(EXE_DIR, "_tmp_bom.xlsx")
f.save(tmp)
from excel_io import read_bom, import_bom as impb
data = read_bom(tmp)
os.remove(tmp)
if not data:
flash("Excel 格式不符(需含 BOM型号/物料编号/单套用量 列)", "error")
else:
bid, ok, skip = impb(data, operator=(g.user.display_name or g.user.username))
log_action("导入BOM", data["code"], f"成功 {ok} / 跳过 {skip}")
flash(f"BOM {data['code']} 导入: 成功 {ok} 项, 跳过(物料不存在) {skip} 项", "ok")
return redirect(url_for("boms"))
return render_template("import_form.html", kind="bom")
@app.route("/template/<kind>")
@login_required
@role_required("admin", "manager")
def download_template(kind):
from excel_io import build_material_template, build_bom_template, build_transaction_template
if kind == "materials":
p = os.path.join(EXE_DIR, "物料导入模板.xlsx")
build_material_template(p)
return send_file(p, as_attachment=True, download_name="物料导入模板.xlsx")
elif kind == "bom":
p = os.path.join(EXE_DIR, "BOM导入模板.xlsx")
build_bom_template(p)
return send_file(p, as_attachment=True, download_name="BOM导入模板.xlsx")
elif kind == "transactions":
p = os.path.join(EXE_DIR, "批量导入模板.xlsx")
build_transaction_template(p)
return send_file(p, as_attachment=True, download_name="批量导入模板.xlsx")
return redirect(url_for("dashboard"))
# ---------------- 交易批量导入窗口(入库/出库) ----------------
@app.route("/api/import/transactions/preview", methods=["POST"])
@login_required
def api_import_txn_preview():
"""接收粘贴的 JSON 行 或 上传文件, 返回解析+校验结果(供前端预览)"""
kind = (request.values.get("kind") or (request.get_json(silent=True) or {}).get("kind") or "inbound")
if request.files.get("file"):
tmp = os.path.join(EXE_DIR, "_tmp_txn.xlsx")
request.files["file"].save(tmp)
try:
from excel_io import read_transactions
rows = read_transactions(tmp)
finally:
if os.path.exists(tmp):
os.remove(tmp)
else:
payload = request.get_json(silent=True) or {}
rows = payload.get("rows", [])
result = _validate_txn_rows(kind, rows)
return jsonify({"kind": kind, "rows": result,
"valid": sum(1 for r in result if r["ok"]),
"invalid": sum(1 for r in result if not r["ok"])})
@app.route("/api/import/transactions/commit", methods=["POST"])
@login_required
def api_import_txn_commit():
"""提交校验通过的行, 批量写入入库/出库流水"""
payload = request.get_json(silent=True) or {}
kind = payload.get("kind", "inbound")
rows = payload.get("rows", [])
validated = _validate_txn_rows(kind, rows)
ok_rows = [v for v in validated if v["ok"]]
op_name = (g.user.display_name or g.user.username) if g.user else "?"
added = 0
for v in ok_rows:
occur = _date(v["date"])
if kind == "inbound":
rec = Inbound(material_id=v["material_id"], batch_no=v["batch_no"], qty=v["qty"],
operator=op_name, source="批量导入", occur_date=occur, remark=v["remark"])
else:
rec = Outbound(material_id=v["material_id"], batch_no=v["batch_no"], qty=v["qty"],
operator=op_name, purpose="批量导入", occur_date=occur, remark=v["remark"])
db.session.add(rec)
added += 1
db.session.commit()
if added:
log_action("批量导入" + ("入库" if kind == "inbound" else "出库"), "窗口",
f"成功 {added} 条")
return jsonify({"added": added,
"skipped": len(validated) - added,
"errors": [r["error"] for r in validated if not r["ok"]]})
# ---------------- 物料批量导入窗口 ----------------
@app.route("/api/import/materials/preview", methods=["POST"])
@login_required
@role_required("admin", "manager")
def api_import_materials_preview():
"""接收粘贴文本 JSON 或上传文件, 返回物料解析+校验结果"""
if request.files.get("file"):
tmp = os.path.join(EXE_DIR, "_tmp_materials.xlsx")
request.files["file"].save(tmp)
try:
from excel_io import read_materials
rows = read_materials(tmp)
finally:
if os.path.exists(tmp):
os.remove(tmp)
else:
payload = request.get_json(silent=True) or {}
from excel_io import parse_materials_text
rows = parse_materials_text(payload.get("text", ""))
result = _validate_material_rows(rows)
return jsonify({"rows": result,
"valid": sum(1 for r in result if r["ok"]),
"invalid": sum(1 for r in result if not r["ok"])})
@app.route("/api/import/materials/commit", methods=["POST"])
@login_required
@role_required("admin", "manager")
def api_import_materials_commit():
"""提交校验通过的物料行, 批量 upsert (含期初流水)"""
payload = request.get_json(silent=True) or {}
rows = payload.get("rows", [])
rows = [r for r in rows if r.get("ok", True)] # 只提交校验通过的行
from excel_io import import_materials as imp_materials
op_name = (g.user.display_name or g.user.username) if g.user else "?"
added, updated = imp_materials(rows, operator=op_name)
if added or updated:
log_action("批量导入物料", "窗口", f"新增 {added} / 更新 {updated}")
return jsonify({"added": added, "updated": updated,
"skipped": len(rows) - added - updated})
# ---------------- BOM 批量导入窗口 ----------------
@app.route("/api/import/boms/preview", methods=["POST"])
@login_required
@role_required("admin", "manager")
def api_import_boms_preview():
"""接收粘贴文本 JSON 或上传文件, 返回多 BOM 解析+校验结果"""
if request.files.get("file"):
tmp = os.path.join(EXE_DIR, "_tmp_bom.xlsx")
request.files["file"].save(tmp)
try:
from excel_io import read_boms_multi
boms = read_boms_multi(tmp)
finally:
if os.path.exists(tmp):
os.remove(tmp)
else:
payload = request.get_json(silent=True) or {}
from excel_io import parse_boms_text
boms = parse_boms_text(payload.get("text", ""))
result = _validate_bom_rows(boms)
return jsonify({"boms": result,
"valid": sum(1 for b in result if b["ok"]),
"invalid": sum(1 for b in result if not b["ok"])})
@app.route("/api/import/boms/commit", methods=["POST"])
@login_required
@role_required("admin", "manager")
def api_import_boms_commit():
"""提交校验通过的 BOM(多个), 批量写入 BOM + BomItem"""
payload = request.get_json(silent=True) or {}
boms = payload.get("boms", [])
from excel_io import import_bom
op_name = (g.user.display_name or g.user.username) if g.user else "?"
created = 0
total_ok = 0
total_skip = 0
total_created_mat = 0
for b in boms:
code = str(b.get("code", "") or "").strip()
if not code:
continue
items = [{"part_number": it["part_number"], "qty_per_unit": it["qty_per_unit"],
"manufacture": it.get("manufacture", "")}
for it in b.get("items", []) if it.get("ok")]
if not items:
continue
_, ok, skip, created_mat = import_bom(
{"code": code, "name": b.get("name", ""), "items": items},
operator=op_name, auto_create=True)
created += 1
total_ok += ok
total_skip += skip
total_created_mat += created_mat
if created:
msg = f"{created}个BOM, 成功项 {total_ok}, 跳过 {total_skip}"
if total_created_mat:
msg += f", 自动新建物料 {total_created_mat}"
log_action("批量导入BOM", "窗口", msg)
return jsonify({"boms": created, "added_items": total_ok,
"skipped_items": total_skip, "created_materials": total_created_mat})
# ---------------- 启动 ----------------
def seed_admin():
with app.app_context():
db.create_all()
# 兼容旧库: 给 inbound 表补充 delivery_date / order_ref 列(幂等)
try:
cols = [r[1] for r in db.session.execute(
db.text("PRAGMA table_info(inbound)")).fetchall()]
if "delivery_date" not in cols:
db.session.execute(db.text("ALTER TABLE inbound ADD COLUMN delivery_date DATE"))
db.session.commit()
if "order_ref" not in cols:
db.session.execute(db.text("ALTER TABLE inbound ADD COLUMN order_ref VARCHAR(64) DEFAULT ''"))
db.session.commit()
except Exception:
db.session.rollback()
# ---- M2-1: 工厂维度(幂等迁移) ----
try:
for tbl in ("inbound", "outbound", "prod_order"):
cols = [r[1] for r in db.session.execute(
db.text(f"PRAGMA table_info({tbl})")).fetchall()]
if "factory_id" not in cols:
db.session.execute(db.text(f"ALTER TABLE {tbl} ADD COLUMN factory_id INTEGER"))
db.session.commit()
except Exception:
db.session.rollback()
# 默认工厂(自厂/总部), 旧流水若无归属则归到它
default_f = db.session.scalar(db.select(Factory).where(Factory.code == "SELF"))
if not default_f:
default_f = Factory(code="SELF", name="自厂/总部", type="self", enabled=True)
db.session.add(default_f)
db.session.commit()
try:
db.session.execute(
db.text("UPDATE inbound SET factory_id = :fid WHERE factory_id IS NULL"),
{"fid": default_f.id})
db.session.execute(
db.text("UPDATE outbound SET factory_id = :fid WHERE factory_id IS NULL"),
{"fid": default_f.id})
db.session.execute(
db.text("UPDATE prod_order SET factory_id = :fid WHERE factory_id IS NULL"),
{"fid": default_f.id})
db.session.commit()
except Exception:
db.session.rollback()
if not db.session.scalar(db.select(User).where(User.username == "admin")):
db.session.add(User(username="admin", password=sha256("admin123"),
role="admin", display_name="管理员"))
db.session.commit()
if __name__ == "__main__":
port = 8080
if len(sys.argv) > 1 and sys.argv[1].isdigit():
port = int(sys.argv[1])
seed_admin()
if getattr(sys, "frozen", False):
webbrowser.open(f"http://127.0.0.1:{port}/")
app.run(host="127.0.0.1", port=port, debug=False)