"""物料入库/出库系统 - Flask 应用 (M1+M2+M3) M1 基础: 物料主数据 + 入库 + 出库 + 实时库存看板(预警标红) M2 核算: BOM 清单 + 生产订单(接单量)按 BOM 展开领料需求 + Excel 双向导入导出 M3 治理: 多角色(admin/manager/operator) + 操作日志 + 报表打印导出 技术: Flask + SQLAlchemy + SQLite (单文件库, 免外部数据库) 打包: PyInstaller --onefile, 冻结后模板/静态走 sys._MEIPASS, 库文件落 exe 同目录。 """ import os import sys import hashlib import webbrowser from datetime import date, datetime from functools import wraps from flask import (Flask, render_template, request, redirect, url_for, session, flash, g, send_file, jsonify) from models import (db, User, Material, Inbound, Outbound, Bom, BomItem, ProdOrder, OperationLog) # ---------------- 路径(兼容 PyInstaller 冻结) ---------------- if getattr(sys, "frozen", False): BASE = sys._MEIPASS # 模板/静态解压目录(只读) EXE_DIR = os.path.dirname(sys.executable) # exe 同目录(可写) else: BASE = os.path.dirname(os.path.abspath(__file__)) EXE_DIR = BASE template_folder = os.path.join(BASE, "templates") static_folder = os.path.join(BASE, "static") db_path = os.environ.get("WMS_DB") or os.path.join(EXE_DIR, "app.db") app = Flask(__name__, template_folder=template_folder, static_folder=static_folder) app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///" + db_path app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.config["SECRET_KEY"] = "material-wms-change-me" db.init_app(app) # ---------------- 鉴权 ---------------- def sha256(s: str) -> str: return hashlib.sha256(s.encode("utf-8")).hexdigest() def login_required(f): @wraps(f) def wrapper(*a, **k): if "user_id" not in session: return redirect(url_for("login")) return f(*a, **k) return wrapper def role_required(*roles): def deco(f): @wraps(f) def wrapper(*a, **k): if "user_id" not in session: return redirect(url_for("login")) u = g.user if u is None or u.role not in roles: flash("权限不足", "error") return redirect(url_for("dashboard")) return f(*a, **k) return wrapper return deco @app.before_request def load_user(): uid = session.get("user_id") g.user = db.session.get(User, uid) if uid else None def log_action(action, target="", detail=""): try: db.session.add(OperationLog( operator=(g.user.display_name or g.user.username) if g.user else "?", action=action, target=str(target), detail=str(detail))) db.session.commit() except Exception: db.session.rollback() # ---------------- 库存核算 ---------------- def stock_map(): """返回 {material_id: 在库数量}""" rows = db.session.execute( db.text("SELECT material_id, COALESCE(SUM(qty),0) FROM inbound GROUP BY material_id") ).fetchall() inb = {r[0]: float(r[1]) for r in rows} rows = db.session.execute( db.text("SELECT material_id, COALESCE(SUM(qty),0) FROM outbound GROUP BY material_id") ).fetchall() out = {r[0]: float(r[1]) for r in rows} ids = set(inb) | set(out) return {i: inb.get(i, 0) - out.get(i, 0) for i in ids} def _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(): """返回 {material_id: 入库总数量}""" rows = db.session.execute( db.text("SELECT material_id, COALESCE(SUM(qty),0) FROM inbound GROUP BY material_id") ).fetchall() return {r[0]: float(r[1]) for r in rows} def outbound_total_map(): """返回 {material_id: 消耗(出库)总数量}""" rows = db.session.execute( db.text("SELECT material_id, COALESCE(SUM(qty),0) FROM outbound GROUP BY material_id") ).fetchall() return {r[0]: float(r[1]) for r in rows} def _date(s): if s: try: return date.fromisoformat(s) except ValueError: return date.today() return date.today() def _validate_txn_rows(kind, rows): """校验批量交易行, 返回 [{part_number, material_id, found, qty, batch_no, date, remark, ok, error}]""" sm = stock_map() out = [] for r in rows: pn = str(r.get("part_number", "") or "").strip() qty = 0 try: qty = float(r.get("qty") or 0) except (TypeError, ValueError): qty = 0 err = "" mid = None if not pn: err = "缺少物料编号" else: m = db.session.scalar(db.select(Material).where(Material.part_number == pn)) if not m: err = "物料不存在" else: mid = m.id if not err and qty <= 0: err = "数量必须大于 0" if not err and kind == "outbound": stock = sm.get(mid, 0) if qty > stock: err = f"库存不足(在库 {stock})" out.append({ "part_number": pn, "material_id": mid, "found": bool(mid), "qty": qty, "batch_no": str(r.get("batch_no", "") or ""), "date": str(r.get("date", "") or ""), "remark": str(r.get("remark", "") or ""), "ok": not err, "error": err, }) return out def _validate_material_rows(rows): """校验物料批量行 -> [{part_number, manufacturer, unit, safety_stock, opening_stock, exists, ok, error}]""" seen = set() out = [] for r in rows: pn = str(r.get("part_number", "") or "").strip() mf = str(r.get("manufacturer", "") or "").strip() unit = str(r.get("unit", "") or "").strip() or "个" err = "" try: ss = float(r.get("safety_stock") or 0) except (TypeError, ValueError): ss = 0 try: op = float(r.get("opening_stock") or 0) except (TypeError, ValueError): op = 0 if not pn: err = "缺少物料编号" exists = False if not err: if pn in seen: err = "本批重复编号" else: m = db.session.scalar(db.select(Material).where(Material.part_number == pn)) exists = bool(m) seen.add(pn) out.append({ "part_number": pn, "manufacturer": mf, "unit": unit, "safety_stock": ss, "opening_stock": op, "exists": exists, "ok": not err, "error": err, }) return out def _validate_bom_rows(boms): """校验 BOM 批量 -> [{code, name, items:[{part_number, qty_per_unit, manufacturer, ok, error, auto_create}], ok, error, skip, valid_items}] 物料不存在时记为 auto_create=True(提交时会自动新建物料), 视为可导入。 """ out = [] for b in boms: code = str(b.get("code", "") or "").strip() name = str(b.get("name", "") or "") items = b.get("items", []) or [] err = "" if not code: err = "缺少BOM型号" item_rows = [] skip = 0 for it in items: pn = str(it.get("part_number", "") or "").strip() mf = str(it.get("manufacture", "") or "").strip() try: qty = float(it.get("qty_per_unit") or 0) except (TypeError, ValueError): qty = 0 ierr = "" auto = False if not pn: ierr = "缺物料编号" else: m = db.session.scalar(db.select(Material).where(Material.part_number == pn)) if not m: auto = True ierr = "物料不存在, 导入时自动新建" elif qty <= 0: ierr = "用量需>0" ok = auto or (ierr == "") if ierr and not auto: skip += 1 item_rows.append({"part_number": pn, "qty_per_unit": qty, "manufacturer": mf, "ok": ok, "error": ierr, "auto_create": auto}) bom_ok = (not err) and any(x["ok"] for x in item_rows) out.append({ "code": code, "name": name, "items": item_rows, "ok": bom_ok, "error": err, "skip": skip, "valid_items": sum(1 for x in item_rows if x["ok"]), }) return out # ---------------- 页面 ---------------- @app.route("/") @login_required def index(): return redirect(url_for("dashboard")) @app.route("/login", methods=["GET", "POST"]) def login(): if request.method == "POST": u = db.session.scalar(db.select(User).where(User.username == request.form["username"])) if u and u.active and u.password == sha256(request.form["password"]): session["user_id"] = u.id return redirect(url_for("dashboard")) flash("用户名或密码错误", "error") return render_template("login.html") @app.route("/logout") def logout(): session.clear() return redirect(url_for("login")) @app.route("/dashboard") @login_required def dashboard(): mats = db.session.scalars(db.select(Material).order_by(Material.part_number)).all() sm = stock_map() rows = [] negative = 0 low = 0 for m in mats: stock = sm.get(m.id, 0) 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(): q = request.args.get("q", "").strip() stmt = db.select(Material) if q: like = f"%{q}%" stmt = stmt.where((Material.part_number.like(like)) | (Material.manufacturer.like(like))) mats = db.session.scalars(stmt.order_by(Material.part_number)).all() sm = stock_map() inb_total = inbound_total_map() out_total = outbound_total_map() return render_template("materials.html", mats=mats, sm=sm, inb_total=inb_total, out_total=out_total, q=q) @app.route("/material/add", methods=["GET", "POST"]) @login_required @role_required("admin", "manager") def material_add(): if request.method == "POST": pn = request.form["part_number"].strip() if not pn: flash("物料编号必填", "error") elif db.session.scalar(db.select(Material).where(Material.part_number == pn)): flash("物料编号已存在", "error") else: m = Material( part_number=pn, manufacturer=request.form.get("manufacturer", ""), unit=request.form.get("unit", "个") or "个", safety_stock=float(request.form.get("safety_stock") or 0), note=request.form.get("note", ""), ) db.session.add(m) db.session.commit() log_action("新增物料", pn, request.form.get("manufacturer", "")) flash("物料已添加", "ok") return redirect(url_for("materials")) return render_template("material_form.html", m=None) @app.route("/material/edit/", 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")) sm = stock_map() inb_total = inbound_total_map() out_total = outbound_total_map() inbounds = db.session.scalars( db.select(Inbound).where(Inbound.material_id == mid) .order_by(Inbound.occur_date.desc(), Inbound.id.desc()) ).all() outbounds = db.session.scalars( db.select(Outbound).where(Outbound.material_id == mid) .order_by(Outbound.occur_date.desc(), Outbound.id.desc()) ).all() return render_template("material_detail.html", m=m, stock=sm.get(m.id, 0), inb_total=inb_total.get(m.id, 0), out_total=out_total.get(m.id, 0), inbounds=inbounds, outbounds=outbounds) # ---------------- 入库 / 出库 ---------------- @app.route("/inbound", methods=["GET", "POST"]) @login_required def inbound(): if request.method == "POST": mid = int(request.form["material_id"]) qty = float(request.form["qty"]) if qty <= 0: flash("入库数量必须大于 0", "error") else: dlv = request.form.get("delivery_date", "").strip() delivery_date = _date(dlv) if dlv else None rec = Inbound( material_id=mid, batch_no=request.form.get("batch_no", ""), qty=qty, operator=request.form.get("operator", ""), source=request.form.get("source", ""), occur_date=_date(request.form.get("occur_date")), 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")) mats = db.session.scalars(db.select(Material).order_by(Material.part_number)).all() recs = db.session.scalars(db.select(Inbound).order_by(Inbound.id.desc()).limit(50)).all() return render_template("txn.html", kind="inbound", mats=mats, recs=recs, today=date.today().isoformat()) @app.route("/outbound", methods=["GET", "POST"]) @login_required def outbound(): if request.method == "POST": mid = int(request.form["material_id"]) qty = float(request.form["qty"]) if qty <= 0: flash("出库数量必须大于 0", "error") else: stock = stock_map().get(mid, 0) if qty > stock: flash(f"库存不足! 当前在库 {stock}", "error") else: rec = Outbound( material_id=mid, batch_no=request.form.get("batch_no", ""), qty=qty, operator=request.form.get("operator", ""), purpose=request.form.get("purpose", ""), order_ref=request.form.get("order_ref", ""), occur_date=_date(request.form.get("occur_date")), remark=request.form.get("remark", "")) db.session.add(rec) db.session.commit() log_action("出库", db.session.get(Material, mid).part_number, f"{qty}") flash("出库成功", "ok") return redirect(url_for("outbound")) mats = db.session.scalars(db.select(Material).order_by(Material.part_number)).all() recs = db.session.scalars(db.select(Outbound).order_by(Outbound.id.desc()).limit(50)).all() return render_template("txn.html", kind="outbound", mats=mats, recs=recs, today=date.today().isoformat()) # ---------------- M2: BOM ---------------- @app.route("/boms") @login_required @role_required("admin", "manager") def boms(): bs = db.session.scalars(db.select(Bom).order_by(Bom.code)).all() data = [] for b in bs: items = db.session.scalars(db.select(BomItem).where(BomItem.bom_id == b.id)).all() data.append({"b": b, "count": len(items)}) return render_template("boms.html", data=data) @app.route("/bom/add", methods=["GET", "POST"]) @login_required @role_required("admin", "manager") def bom_add(): mats = db.session.scalars(db.select(Material).order_by(Material.part_number)).all() if request.method == "POST": code = request.form.get("code", "").strip() if not code: flash("BOM 型号必填", "error") elif db.session.scalar(db.select(Bom).where(Bom.code == code)): flash("BOM 型号已存在", "error") else: b = Bom(code=code, name=request.form.get("name", ""), note=request.form.get("note", "")) db.session.add(b) db.session.flush() pns = request.form.getlist("pn") qts = request.form.getlist("qty") for pn, qt in zip(pns, qts): pn = pn.strip() if not pn: continue try: q = float(qt) except ValueError: continue if q <= 0: continue m = db.session.scalar(db.select(Material).where(Material.part_number == pn)) if m: db.session.add(BomItem(bom_id=b.id, material_id=m.id, qty_per_unit=q)) db.session.commit() log_action("新增BOM", code) flash("BOM 已保存", "ok") return redirect(url_for("boms")) return render_template("bom_form.html", b=None, mats=mats, items=[]) @app.route("/bom/edit/", 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) data.append({"o": o, "bom": bom}) return render_template("orders.html", data=data) @app.route("/order/new", methods=["GET", "POST"]) @login_required @role_required("admin", "manager") def order_new(): boms_all = db.session.scalars(db.select(Bom).order_by(Bom.code)).all() if request.method == "POST": bom_id = int(request.form["bom_id"]) qty = float(request.form["qty"]) if qty <= 0: flash("接单量必须大于 0", "error") else: order_no = request.form.get("order_no", "").strip() or _gen_order_no() if db.session.scalar(db.select(ProdOrder).where(ProdOrder.order_no == order_no)): order_no = _gen_order_no() o = ProdOrder(order_no=order_no, bom_id=bom_id, qty=qty, status="planned", operator=(g.user.display_name or g.user.username)) db.session.add(o) db.session.commit() log_action("新建生产订单", order_no, f"接单量 {qty}") return redirect(url_for("order_detail", oid=o.id)) return render_template("order_form.html", boms=boms_all, o=None) @app.route("/order/") @login_required @role_required("admin", "manager") def order_detail(oid): o = db.session.get(ProdOrder, oid) if not o: return redirect(url_for("orders")) bom = db.session.get(Bom, o.bom_id) items = db.session.scalars(db.select(BomItem).where(BomItem.bom_id == o.bom_id)).all() sm = stock_map() reqs = [] for it in items: m = db.session.get(Material, it.material_id) need = it.qty_per_unit * o.qty have = sm.get(m.id, 0) reqs.append({"m": m, "need": need, "have": have, "gap": need - have, "short": need > have}) return render_template("order_detail.html", o=o, bom=bom, reqs=reqs) @app.route("/order//pick", methods=["POST"]) @login_required @role_required("admin", "manager") def order_pick(oid): o = db.session.get(ProdOrder, oid) if not o or o.status == "picked": return redirect(url_for("orders")) items = db.session.scalars(db.select(BomItem).where(BomItem.bom_id == o.bom_id)).all() sm = stock_map() picks = 0 shorts = [] for it in items: m = db.session.get(Material, it.material_id) need = it.qty_per_unit * o.qty if need <= 0: continue db.session.add(Outbound(material_id=m.id, qty=need, operator=(g.user.display_name or g.user.username), purpose="生产领用", order_ref=o.order_no, occur_date=date.today())) picks += 1 if need > sm.get(m.id, 0): shorts.append(m.part_number) o.status = "picked" db.session.commit() log_action("生产领料", o.order_no, f"生成出库 {picks} 项" + (f", 缺料: {','.join(shorts)}" if shorts else "")) if shorts: flash(f"已领料, 但缺料: {', '.join(shorts)}", "warn") else: flash("领料出库完成", "ok") return redirect(url_for("order_detail", oid=oid)) def _gen_order_no(): d = date.today().strftime("%Y%m%d") n = db.session.scalar(db.select(db.func.count(ProdOrder.id))) + 1 return f"WO{d}{n:03d}" # ---------------- M3: 报表 / 日志 / 用户 ---------------- @app.route("/report") @login_required @role_required("admin", "manager") def report(): mats = db.session.scalars(db.select(Material).order_by(Material.part_number)).all() sm = stock_map() rows = [] for m in mats: stock = sm.get(m.id, 0) if stock < 0: status = "danger" elif stock < (m.safety_stock or 0): status = "warn" else: status = "ok" rows.append({"m": m, "stock": stock, "status": status}) return render_template("report.html", rows=rows, today=date.today().isoformat()) @app.route("/export/inventory") @login_required @role_required("admin", "manager") def export_inventory(): from excel_io import export_inventory mats = db.session.scalars(db.select(Material).order_by(Material.part_number)).all() sm = stock_map() rows = [] for m in mats: stock = sm.get(m.id, 0) status = "danger" if stock < 0 else ("warn" if stock < (m.safety_stock or 0) else "ok") rows.append({"m": m, "stock": stock, "status": status}) path = os.path.join(EXE_DIR, f"库存导出_{date.today().isoformat()}.xlsx") export_inventory(path, rows) return send_file(path, as_attachment=True, download_name=os.path.basename(path)) @app.route("/logs") @login_required @role_required("admin", "manager") def logs(): recs = db.session.scalars(db.select(OperationLog).order_by(OperationLog.id.desc()).limit(200)).all() return render_template("logs.html", recs=recs) @app.route("/users") @login_required @role_required("admin") def users(): us = db.session.scalars(db.select(User).order_by(User.username)).all() return render_template("users.html", us=us) @app.route("/user/add", methods=["POST"]) @login_required @role_required("admin") def user_add(): un = request.form.get("username", "").strip() pw = request.form.get("password", "") role = request.form.get("role", "operator") if not un or not pw: flash("用户名和密码必填", "error") elif db.session.scalar(db.select(User).where(User.username == un)): flash("用户名已存在", "error") else: db.session.add(User(username=un, password=sha256(pw), role=role, display_name=request.form.get("display_name", ""))) db.session.commit() log_action("新增用户", un, role) flash("用户已添加", "ok") return redirect(url_for("users")) @app.route("/user/toggle/", 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 列(幂等) 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() 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)