"""物料入库/出库系统 - 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/", 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/") @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//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//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/", 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/", 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/") @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/", 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/") @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/", 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/", 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/") @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/") @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//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//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//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//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//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/", 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/") @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)