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