Files
Inventory-Management-System/app.py
T

1989 lines
80 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 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")
@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"))
mats = db.session.scalars(db.select(Material).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,
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))
@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":
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"))
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()}
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)
# ---------------- 入库 / 出库 ----------------
@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())
@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":
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()
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.id.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()
# 查看订单时, 库存按「下单那一刻之前」的快照展示, 而非当前实时库存,
# 这样能还原下单时的真实可供料情况(已自动排除本单后续的调入/领料)。
as_of = o.created_at
sm = stock_map_as_of(o.factory_id, as_of)
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, as_of)]
reqs.append({"m": m, "need": need, "have": have,
"gap": need - have, "short": need > have, "subs": subs,
"alloc": a.alloc_qty if a else 0,
"posted": a.posted if a else False})
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, as_of=as_of,
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()
# 冲销回「下单时」状态: 先备份库, 再撤销该订单已产生的调入/调出/领料流水 + 清空调入计划
try:
_bak = os.path.join(EXE_DIR, "_backup",
f"reset_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
n_in, n_out, n_alloc = _reset_order_to_placed(o, old_no)
db.session.commit()
log_action("编辑生产订单", o.order_no,
f"接单量 {qty}; 冲销回下单状态: 调入入库{n_in}/调出领料{n_out}/计划{n_alloc}"
+ (f"; 备份 {os.path.basename(_bak)}" if _bak else ""))
flash(f"订单已更新并回到「下单状态」(冲销调入/领料流水 {n_in + n_out} 笔、清空调入计划 {n_alloc} 行)", "ok")
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 _reset_order_to_placed(o, order_no):
"""把订单冲销回「刚下单」状态: 撤销该订单关联的调入入库 / 调出 / 领料流水, 清空调入计划, 状态置回 planned。
- 调入入库(Inbound, source=生产订单调入) 与 调出/领料(Outbound, purpose in (生产领用, 生产订单调出)) 均按 order_ref 关联, 直接删除即冲销(总库存/厂间余额恢复)。
- 调入计划(prod_order_alloc) 按 order_id 清除。
- 返回 (冲销入库笔数, 冲销出库笔数, 清除计划行数)。
"""
n_in = db.session.execute(
db.delete(Inbound).where(Inbound.order_ref == order_no)).rowcount
n_out = db.session.execute(
db.delete(Outbound).where(
Outbound.order_ref == order_no,
Outbound.purpose.in_(["生产领用", "生产订单调出"]))).rowcount
n_alloc = db.session.execute(
db.delete(ProdOrderAlloc).where(ProdOrderAlloc.order_id == o.id)).rowcount
o.status = "planned"
return n_in, n_out, n_alloc
@app.route("/order/<int:oid>/alloc", methods=["POST"])
@login_required
@role_required("admin", "manager")
def order_alloc_save(oid):
"""保存生产订单各物料的「调入数量」(手动录入, 不生成流水)。"""
o = db.session.get(ProdOrder, oid)
if not o:
return redirect(url_for("orders"))
items = db.session.scalars(db.select(BomItem).where(BomItem.bom_id == o.bom_id)).all()
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
a = db.session.scalar(db.select(ProdOrderAlloc).where(
ProdOrderAlloc.order_id == oid,
ProdOrderAlloc.material_id == it.material_id))
if a is None:
a = ProdOrderAlloc(order_id=oid, material_id=it.material_id, alloc_qty=qty)
db.session.add(a)
else:
a.alloc_qty = qty
db.session.commit()
log_action("保存调入数量", o.order_no, "更新调入计划")
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 提交), 一次点击即可先记录再入账。
源工厂由表单 from_factory_id 指定(默认自厂 SELF); 自动校验源厂库存, 不足则跳过并提示。
"""
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)
if from_id == (o.factory_id or -1):
flash("调入来源工厂不能与生产工厂相同", "error")
return redirect(url_for("order_detail", oid=oid))
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 and a and not a.posted and a.alloc_qty > 0:
qty = a.alloc_qty # 回退到已保存计划
if qty is None or qty <= 0:
continue
if a and a.posted:
continue # 已入账, 跳过避免重复
# 校验源厂库存
src_stock = stock_map(from_id).get(it.material_id, 0)
if qty > src_stock:
m = db.session.get(Material, it.material_id)
shorts.append(f"{m.part_number}(源厂{ff.code}库存{src_stock:.2f}, 需{qty:.2f})")
continue
if a is None:
a = ProdOrderAlloc(order_id=oid, material_id=it.material_id)
db.session.add(a)
a.alloc_qty = qty
# 源厂出库减
db.session.add(Outbound(
material_id=it.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=it.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})"))
a.posted = True
n += 1
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)