feat: 库存管理系统 MaterialWMS 初始版本
- Flask + SQLAlchemy 物料/入库/出库/BOM/生产订单全模块管理 - PyInstaller 打包为单文件 EXE (双击运行, 自动建库) - 物料维度: 入库总数量 / 消耗总数量 / 逐次入库流水(含时间) - BOM 详情展示组成物料的规格型号与单套用量 - 支持 Altium / 嘉立创 BOM 批量导入(文本与 Excel 双通道) - 内置端到端验证脚本(tools/_exe_util.py 等, 清理进程树防残留)
This commit is contained in:
@@ -0,0 +1,987 @@
|
||||
"""物料入库/出库系统 - 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 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 models import (db, User, Material, Inbound, Outbound,
|
||||
Bom, BomItem, ProdOrder, OperationLog)
|
||||
|
||||
# ---------------- 路径(兼容 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__))
|
||||
EXE_DIR = 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
|
||||
|
||||
|
||||
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():
|
||||
"""返回 {material_id: 在库数量}"""
|
||||
rows = db.session.execute(
|
||||
db.text("SELECT material_id, COALESCE(SUM(qty),0) FROM inbound GROUP BY material_id")
|
||||
).fetchall()
|
||||
inb = {r[0]: float(r[1]) for r in rows}
|
||||
rows = db.session.execute(
|
||||
db.text("SELECT material_id, COALESCE(SUM(qty),0) FROM outbound GROUP BY material_id")
|
||||
).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 inbound_total_map():
|
||||
"""返回 {material_id: 入库总数量}"""
|
||||
rows = db.session.execute(
|
||||
db.text("SELECT material_id, COALESCE(SUM(qty),0) FROM inbound GROUP BY material_id")
|
||||
).fetchall()
|
||||
return {r[0]: float(r[1]) for r in rows}
|
||||
|
||||
|
||||
def outbound_total_map():
|
||||
"""返回 {material_id: 消耗(出库)总数量}"""
|
||||
rows = db.session.execute(
|
||||
db.text("SELECT material_id, COALESCE(SUM(qty),0) FROM outbound GROUP BY material_id")
|
||||
).fetchall()
|
||||
return {r[0]: float(r[1]) for r in rows}
|
||||
|
||||
|
||||
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("/dashboard")
|
||||
@login_required
|
||||
def dashboard():
|
||||
mats = db.session.scalars(db.select(Material).order_by(Material.part_number)).all()
|
||||
sm = stock_map()
|
||||
rows = []
|
||||
negative = 0
|
||||
low = 0
|
||||
for m in mats:
|
||||
stock = sm.get(m.id, 0)
|
||||
if stock < 0:
|
||||
negative += 1
|
||||
status = "danger"
|
||||
elif stock < (m.safety_stock or 0):
|
||||
low += 1
|
||||
status = "warn"
|
||||
else:
|
||||
status = "ok"
|
||||
rows.append({"m": m, "stock": stock, "status": status})
|
||||
return render_template("dashboard.html", rows=rows, negative=negative, low=low)
|
||||
|
||||
|
||||
# ---------------- 物料 ----------------
|
||||
@app.route("/materials")
|
||||
@login_required
|
||||
def materials():
|
||||
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()
|
||||
inb_total = inbound_total_map()
|
||||
out_total = outbound_total_map()
|
||||
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":
|
||||
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("编辑物料", m.part_number)
|
||||
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"))
|
||||
sm = stock_map()
|
||||
inb_total = inbound_total_map()
|
||||
out_total = outbound_total_map()
|
||||
inbounds = db.session.scalars(
|
||||
db.select(Inbound).where(Inbound.material_id == mid)
|
||||
.order_by(Inbound.occur_date.desc(), Inbound.id.desc())
|
||||
).all()
|
||||
outbounds = db.session.scalars(
|
||||
db.select(Outbound).where(Outbound.material_id == mid)
|
||||
.order_by(Outbound.occur_date.desc(), Outbound.id.desc())
|
||||
).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)
|
||||
|
||||
|
||||
# ---------------- 入库 / 出库 ----------------
|
||||
@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:
|
||||
rec = Inbound(
|
||||
material_id=mid, 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")),
|
||||
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"))
|
||||
mats = db.session.scalars(db.select(Material).order_by(Material.part_number)).all()
|
||||
recs = db.session.scalars(db.select(Inbound).order_by(Inbound.id.desc()).limit(50)).all()
|
||||
return render_template("txn.html", kind="inbound", mats=mats, recs=recs, today=date.today().isoformat())
|
||||
|
||||
|
||||
@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().get(mid, 0)
|
||||
if qty > stock:
|
||||
flash(f"库存不足! 当前在库 {stock}", "error")
|
||||
else:
|
||||
rec = Outbound(
|
||||
material_id=mid, 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"))
|
||||
mats = db.session.scalars(db.select(Material).order_by(Material.part_number)).all()
|
||||
recs = db.session.scalars(db.select(Outbound).order_by(Outbound.id.desc()).limit(50)).all()
|
||||
return render_template("txn.html", kind="outbound", mats=mats, recs=recs, today=date.today().isoformat())
|
||||
|
||||
|
||||
# ---------------- 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":
|
||||
b.name = request.form.get("name", "")
|
||||
b.note = request.form.get("note", "")
|
||||
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()
|
||||
rows = []
|
||||
for it in items:
|
||||
m = db.session.get(Material, it.material_id)
|
||||
rows.append({"m": m, "qty": it.qty_per_unit})
|
||||
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.id.desc())).all()
|
||||
data = []
|
||||
for o in os_:
|
||||
bom = db.session.get(Bom, o.bom_id)
|
||||
data.append({"o": o, "bom": bom})
|
||||
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()
|
||||
if request.method == "POST":
|
||||
bom_id = int(request.form["bom_id"])
|
||||
qty = float(request.form["qty"])
|
||||
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, qty=qty,
|
||||
status="planned", operator=(g.user.display_name or g.user.username))
|
||||
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, 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()
|
||||
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)
|
||||
reqs.append({"m": m, "need": need, "have": have,
|
||||
"gap": need - have, "short": need > have})
|
||||
return render_template("order_detail.html", o=o, bom=bom, reqs=reqs)
|
||||
|
||||
|
||||
@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()
|
||||
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, 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))
|
||||
|
||||
|
||||
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}"
|
||||
|
||||
|
||||
# ---------------- 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()
|
||||
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)
|
||||
Reference in New Issue
Block a user