feat: 库存管理系统 MaterialWMS 初始版本
- Flask + SQLAlchemy 物料/入库/出库/BOM/生产订单全模块管理 - PyInstaller 打包为单文件 EXE (双击运行, 自动建库) - 物料维度: 入库总数量 / 消耗总数量 / 逐次入库流水(含时间) - BOM 详情展示组成物料的规格型号与单套用量 - 支持 Altium / 嘉立创 BOM 批量导入(文本与 Excel 双通道) - 内置端到端验证脚本(tools/_exe_util.py 等, 清理进程树防残留)
This commit is contained in:
+25
@@ -0,0 +1,25 @@
|
||||
# ---- Python ----
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
# ---- PyInstaller 打包产物(体积大, 不入库; EXE 走 GitLab Release 分发) ----
|
||||
build/
|
||||
dist/
|
||||
*.spec
|
||||
|
||||
# ---- 运行时日志(EXE 冻结时排查用) ----
|
||||
*.log
|
||||
error.log
|
||||
startup.log
|
||||
request.log
|
||||
|
||||
# ---- 临时校验文件 ----
|
||||
_check_tmp/
|
||||
|
||||
# ---- 编辑器 ----
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
Binary file not shown.
@@ -0,0 +1,76 @@
|
||||
# 物料入库 / 出库系统 (M1 + M2 + M3 完整版)
|
||||
|
||||
本地 Web 系统,对标桌面 `物料明细表.xlsx` 的台账 + BOM 核算能力。Flask + SQLite,免外部数据库,即开即用。
|
||||
库存由 SQL 实时聚合(在库 = 入库 − 出库),彻底规避原 Excel 的 `[1]物料入库!D_` 外部链接断链风险。
|
||||
|
||||
## 运行方式
|
||||
|
||||
**方式一 · 单文件 EXE(推荐车间用)**
|
||||
`dist/MaterialWMS.exe` 双击即开,自动弹出浏览器到 http://127.0.0.1:8080。
|
||||
库文件 `app.db` 自动生成在 exe 同目录,换机器直接拷贝 exe 即可。
|
||||
|
||||
**方式二 · 源码(双击 bat)**
|
||||
双击 `启动.bat` 启动;或命令行 `python app.py`。
|
||||
|
||||
**方式三 · 指定库位置**
|
||||
`set WMS_DB=D:/path/app.db` 后再启动,可把数据放到任意位置。
|
||||
|
||||
## 默认账号
|
||||
- 管理员 `admin / admin123`(首次启动自动建;可在「用户」页加 operator / manager)
|
||||
|
||||
## 角色权限
|
||||
| 角色 | 权限 |
|
||||
|---|---|
|
||||
| admin | 全部,含用户管理 |
|
||||
| manager | 物料/BOM/订单/报表/日志/Excel 导入导出 |
|
||||
| operator | 库存看板 / 物料查看 / 入库 / 出库 |
|
||||
|
||||
## 功能清单
|
||||
| 模块 | 说明 |
|
||||
|---|---|
|
||||
| 库存看板 | 实时核算在库;低于安全库存标黄、库存为负标红,顶部统计负数/预警数 |
|
||||
| 物料 | 主数据 CRUD(编号/型号/单位/安全库存)+ 搜索 |
|
||||
| 入库 | 流水登记(批次/数量/经手人/来源),写入即更新库存 |
|
||||
| 出库 | 流水登记,**库存不足自动拦截** |
|
||||
| **批量导入窗口** | 入库/出库/物料/BOM 页「批量导入窗口」按钮弹出模态框:粘贴多行清单或上传 Excel/CSV,实时预览解析与校验,一键批量生成。各页格式见下 |
|
||||
| 入库/出库批量窗口 | 每行 `物料编号 数量 批次(可选) 日期(可选) 备注(可选)`,校验物料存在/数量>0/出库库存充足 |
|
||||
| 物料批量窗口 | 每行 `物料编号 型号/规格 单位 安全库存(可选) 期初库存(可选)`,校验重复编号;已存在编号显示「更新」,一键批量 upsert(含期初流水) |
|
||||
| BOM 批量窗口 | ① **上传真实 Altium / 嘉立创 BOM 模板**(`Item/Part Number/Manufacture Part Number/Quantity/Reference/Part/Package`):每个 worksheet 视为一个 BOM,sheet 名即 BOM 型号,Part Number 关联物料、Quantity 列即单套用量(若存在两个 Quantity 列,取首列,首列空时回退次列);也支持中文「组装」sheet(物料代码 + 数量)。② 粘贴文本:以 `#BOM <型号> <名称>` 分段、其下 `物料编号 单套用量`。物料不存在的项预览时标「新建」,**导入时自动新建物料**(并写入型号/规格),一键多 BOM 落库 |
|
||||
| **BOM(M2)** | BOM 型号 + 组成物料单套用量;支持 Excel 导入 |
|
||||
| **生产订单(M2)** | 选 BOM + 接单量 → 自动展开「需求 = 单套用量 × 接单量」,一键「确认领料」生成出库,缺料在看板预警 |
|
||||
| **Excel 双向(M2)** | 物料导入/导出、BOM 导入、库存报表导出、导入模板下载 |
|
||||
| **报表(M3)** | 库存报表页,支持打印(浏览器打印)与导出 Excel |
|
||||
| **操作日志(M3)** | 每次收发/BOM/订单/导入/用户操作全记录(谁/何时/做了什么) |
|
||||
| **用户管理(M3)** | admin 增删改角色、启停账号 |
|
||||
|
||||
## 数据来源
|
||||
`init_db.py` 已从桌面 `物料明细表.xlsx` 的「物料入库」表导入 **439 条真实物料**,
|
||||
把 Excel 库存数量作为"期初入库"写入,看板开箱即有数。
|
||||
|
||||
## 目录结构
|
||||
```
|
||||
material_wms/
|
||||
├─ app.py # 路由 + 鉴权 + 库存核算 + 冻结路径处理
|
||||
├─ models.py # SQLAlchemy 2.0 模型(user/material/inbound/outbound/bom/bom_item/prod_order/operation_log)
|
||||
├─ excel_io.py # Excel 双向导入导出 + 模板生成
|
||||
├─ init_db.py # 建表 + 管理员 + Excel 导入真实物料
|
||||
├─ build_exe.py # PyInstaller 单文件打包脚本
|
||||
├─ requirements.txt
|
||||
├─ 启动.bat
|
||||
├─ templates/ # 登录/看板/物料/收发/BOM/订单/报表/日志/用户/导入 共 15 个页面
|
||||
├─ static/style.css
|
||||
├─ instance/app.db # SQLite 库(源码模式自动生成)
|
||||
├─ dist/MaterialWMS.exe # 打包产物(单文件)
|
||||
└─ tools/ # e2e_test.py(全量冒烟) / check_server.py
|
||||
```
|
||||
|
||||
## 打包 EXE
|
||||
```bat
|
||||
python build_exe.py
|
||||
```
|
||||
产物在 `dist/MaterialWMS.exe`。冻结后模板/静态走 `sys._MEIPASS`,库文件落 exe 同目录。
|
||||
|
||||
## 测试
|
||||
```bat
|
||||
python tools/e2e_test.py # 覆盖 登录/收发核算/BOM/订单展开/领料/日志/报表/导入导出/入库·物料·BOM 批量导入窗口/角色拦截
|
||||
```
|
||||
@@ -0,0 +1,987 @@
|
||||
"""物料入库/出库系统 - Flask 应用 (M1+M2+M3)
|
||||
|
||||
M1 基础: 物料主数据 + 入库 + 出库 + 实时库存看板(预警标红)
|
||||
M2 核算: BOM 清单 + 生产订单(接单量)按 BOM 展开领料需求 + Excel 双向导入导出
|
||||
M3 治理: 多角色(admin/manager/operator) + 操作日志 + 报表打印导出
|
||||
|
||||
技术: Flask + SQLAlchemy + SQLite (单文件库, 免外部数据库)
|
||||
打包: PyInstaller --onefile, 冻结后模板/静态走 sys._MEIPASS, 库文件落 exe 同目录。
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import hashlib
|
||||
import webbrowser
|
||||
from datetime import date, datetime
|
||||
from functools import wraps
|
||||
|
||||
from flask import (Flask, render_template, request, redirect, url_for,
|
||||
session, flash, g, send_file, jsonify)
|
||||
|
||||
from models import (db, User, Material, Inbound, Outbound,
|
||||
Bom, BomItem, ProdOrder, OperationLog)
|
||||
|
||||
# ---------------- 路径(兼容 PyInstaller 冻结) ----------------
|
||||
if getattr(sys, "frozen", False):
|
||||
BASE = sys._MEIPASS # 模板/静态解压目录(只读)
|
||||
EXE_DIR = os.path.dirname(sys.executable) # exe 同目录(可写)
|
||||
else:
|
||||
BASE = os.path.dirname(os.path.abspath(__file__))
|
||||
EXE_DIR = BASE
|
||||
|
||||
template_folder = os.path.join(BASE, "templates")
|
||||
static_folder = os.path.join(BASE, "static")
|
||||
db_path = os.environ.get("WMS_DB") or os.path.join(EXE_DIR, "app.db")
|
||||
|
||||
app = Flask(__name__, template_folder=template_folder, static_folder=static_folder)
|
||||
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///" + db_path
|
||||
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
|
||||
app.config["SECRET_KEY"] = "material-wms-change-me"
|
||||
db.init_app(app)
|
||||
|
||||
|
||||
# ---------------- 鉴权 ----------------
|
||||
def sha256(s: str) -> str:
|
||||
return hashlib.sha256(s.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def login_required(f):
|
||||
@wraps(f)
|
||||
def wrapper(*a, **k):
|
||||
if "user_id" not in session:
|
||||
return redirect(url_for("login"))
|
||||
return f(*a, **k)
|
||||
return wrapper
|
||||
|
||||
|
||||
def role_required(*roles):
|
||||
def deco(f):
|
||||
@wraps(f)
|
||||
def wrapper(*a, **k):
|
||||
if "user_id" not in session:
|
||||
return redirect(url_for("login"))
|
||||
u = g.user
|
||||
if u is None or u.role not in roles:
|
||||
flash("权限不足", "error")
|
||||
return redirect(url_for("dashboard"))
|
||||
return f(*a, **k)
|
||||
return wrapper
|
||||
return deco
|
||||
|
||||
|
||||
@app.before_request
|
||||
def load_user():
|
||||
uid = session.get("user_id")
|
||||
g.user = db.session.get(User, uid) if uid else None
|
||||
|
||||
|
||||
def log_action(action, target="", detail=""):
|
||||
try:
|
||||
db.session.add(OperationLog(
|
||||
operator=(g.user.display_name or g.user.username) if g.user else "?",
|
||||
action=action, target=str(target), detail=str(detail)))
|
||||
db.session.commit()
|
||||
except Exception:
|
||||
db.session.rollback()
|
||||
|
||||
|
||||
# ---------------- 库存核算 ----------------
|
||||
def stock_map():
|
||||
"""返回 {material_id: 在库数量}"""
|
||||
rows = db.session.execute(
|
||||
db.text("SELECT material_id, COALESCE(SUM(qty),0) FROM inbound GROUP BY material_id")
|
||||
).fetchall()
|
||||
inb = {r[0]: float(r[1]) for r in rows}
|
||||
rows = db.session.execute(
|
||||
db.text("SELECT material_id, COALESCE(SUM(qty),0) FROM outbound GROUP BY material_id")
|
||||
).fetchall()
|
||||
out = {r[0]: float(r[1]) for r in rows}
|
||||
ids = set(inb) | set(out)
|
||||
return {i: inb.get(i, 0) - out.get(i, 0) for i in ids}
|
||||
|
||||
|
||||
def inbound_total_map():
|
||||
"""返回 {material_id: 入库总数量}"""
|
||||
rows = db.session.execute(
|
||||
db.text("SELECT material_id, COALESCE(SUM(qty),0) FROM inbound GROUP BY material_id")
|
||||
).fetchall()
|
||||
return {r[0]: float(r[1]) for r in rows}
|
||||
|
||||
|
||||
def outbound_total_map():
|
||||
"""返回 {material_id: 消耗(出库)总数量}"""
|
||||
rows = db.session.execute(
|
||||
db.text("SELECT material_id, COALESCE(SUM(qty),0) FROM outbound GROUP BY material_id")
|
||||
).fetchall()
|
||||
return {r[0]: float(r[1]) for r in rows}
|
||||
|
||||
|
||||
def _date(s):
|
||||
if s:
|
||||
try:
|
||||
return date.fromisoformat(s)
|
||||
except ValueError:
|
||||
return date.today()
|
||||
return date.today()
|
||||
|
||||
|
||||
def _validate_txn_rows(kind, rows):
|
||||
"""校验批量交易行, 返回 [{part_number, material_id, found, qty, batch_no, date, remark, ok, error}]"""
|
||||
sm = stock_map()
|
||||
out = []
|
||||
for r in rows:
|
||||
pn = str(r.get("part_number", "") or "").strip()
|
||||
qty = 0
|
||||
try:
|
||||
qty = float(r.get("qty") or 0)
|
||||
except (TypeError, ValueError):
|
||||
qty = 0
|
||||
err = ""
|
||||
mid = None
|
||||
if not pn:
|
||||
err = "缺少物料编号"
|
||||
else:
|
||||
m = db.session.scalar(db.select(Material).where(Material.part_number == pn))
|
||||
if not m:
|
||||
err = "物料不存在"
|
||||
else:
|
||||
mid = m.id
|
||||
if not err and qty <= 0:
|
||||
err = "数量必须大于 0"
|
||||
if not err and kind == "outbound":
|
||||
stock = sm.get(mid, 0)
|
||||
if qty > stock:
|
||||
err = f"库存不足(在库 {stock})"
|
||||
out.append({
|
||||
"part_number": pn, "material_id": mid, "found": bool(mid),
|
||||
"qty": qty, "batch_no": str(r.get("batch_no", "") or ""),
|
||||
"date": str(r.get("date", "") or ""), "remark": str(r.get("remark", "") or ""),
|
||||
"ok": not err, "error": err,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _validate_material_rows(rows):
|
||||
"""校验物料批量行 -> [{part_number, manufacturer, unit, safety_stock, opening_stock, exists, ok, error}]"""
|
||||
seen = set()
|
||||
out = []
|
||||
for r in rows:
|
||||
pn = str(r.get("part_number", "") or "").strip()
|
||||
mf = str(r.get("manufacturer", "") or "").strip()
|
||||
unit = str(r.get("unit", "") or "").strip() or "个"
|
||||
err = ""
|
||||
try:
|
||||
ss = float(r.get("safety_stock") or 0)
|
||||
except (TypeError, ValueError):
|
||||
ss = 0
|
||||
try:
|
||||
op = float(r.get("opening_stock") or 0)
|
||||
except (TypeError, ValueError):
|
||||
op = 0
|
||||
if not pn:
|
||||
err = "缺少物料编号"
|
||||
exists = False
|
||||
if not err:
|
||||
if pn in seen:
|
||||
err = "本批重复编号"
|
||||
else:
|
||||
m = db.session.scalar(db.select(Material).where(Material.part_number == pn))
|
||||
exists = bool(m)
|
||||
seen.add(pn)
|
||||
out.append({
|
||||
"part_number": pn, "manufacturer": mf, "unit": unit,
|
||||
"safety_stock": ss, "opening_stock": op,
|
||||
"exists": exists, "ok": not err, "error": err,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _validate_bom_rows(boms):
|
||||
"""校验 BOM 批量 -> [{code, name, items:[{part_number, qty_per_unit, manufacturer, ok, error, auto_create}], ok, error, skip, valid_items}]
|
||||
|
||||
物料不存在时记为 auto_create=True(提交时会自动新建物料), 视为可导入。
|
||||
"""
|
||||
out = []
|
||||
for b in boms:
|
||||
code = str(b.get("code", "") or "").strip()
|
||||
name = str(b.get("name", "") or "")
|
||||
items = b.get("items", []) or []
|
||||
err = ""
|
||||
if not code:
|
||||
err = "缺少BOM型号"
|
||||
item_rows = []
|
||||
skip = 0
|
||||
for it in items:
|
||||
pn = str(it.get("part_number", "") or "").strip()
|
||||
mf = str(it.get("manufacture", "") or "").strip()
|
||||
try:
|
||||
qty = float(it.get("qty_per_unit") or 0)
|
||||
except (TypeError, ValueError):
|
||||
qty = 0
|
||||
ierr = ""
|
||||
auto = False
|
||||
if not pn:
|
||||
ierr = "缺物料编号"
|
||||
else:
|
||||
m = db.session.scalar(db.select(Material).where(Material.part_number == pn))
|
||||
if not m:
|
||||
auto = True
|
||||
ierr = "物料不存在, 导入时自动新建"
|
||||
elif qty <= 0:
|
||||
ierr = "用量需>0"
|
||||
ok = auto or (ierr == "")
|
||||
if ierr and not auto:
|
||||
skip += 1
|
||||
item_rows.append({"part_number": pn, "qty_per_unit": qty, "manufacturer": mf,
|
||||
"ok": ok, "error": ierr, "auto_create": auto})
|
||||
bom_ok = (not err) and any(x["ok"] for x in item_rows)
|
||||
out.append({
|
||||
"code": code, "name": name, "items": item_rows,
|
||||
"ok": bom_ok, "error": err, "skip": skip,
|
||||
"valid_items": sum(1 for x in item_rows if x["ok"]),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
# ---------------- 页面 ----------------
|
||||
@app.route("/")
|
||||
@login_required
|
||||
def index():
|
||||
return redirect(url_for("dashboard"))
|
||||
|
||||
|
||||
@app.route("/login", methods=["GET", "POST"])
|
||||
def login():
|
||||
if request.method == "POST":
|
||||
u = db.session.scalar(db.select(User).where(User.username == request.form["username"]))
|
||||
if u and u.active and u.password == sha256(request.form["password"]):
|
||||
session["user_id"] = u.id
|
||||
return redirect(url_for("dashboard"))
|
||||
flash("用户名或密码错误", "error")
|
||||
return render_template("login.html")
|
||||
|
||||
|
||||
@app.route("/logout")
|
||||
def logout():
|
||||
session.clear()
|
||||
return redirect(url_for("login"))
|
||||
|
||||
|
||||
@app.route("/dashboard")
|
||||
@login_required
|
||||
def dashboard():
|
||||
mats = db.session.scalars(db.select(Material).order_by(Material.part_number)).all()
|
||||
sm = stock_map()
|
||||
rows = []
|
||||
negative = 0
|
||||
low = 0
|
||||
for m in mats:
|
||||
stock = sm.get(m.id, 0)
|
||||
if stock < 0:
|
||||
negative += 1
|
||||
status = "danger"
|
||||
elif stock < (m.safety_stock or 0):
|
||||
low += 1
|
||||
status = "warn"
|
||||
else:
|
||||
status = "ok"
|
||||
rows.append({"m": m, "stock": stock, "status": status})
|
||||
return render_template("dashboard.html", rows=rows, negative=negative, low=low)
|
||||
|
||||
|
||||
# ---------------- 物料 ----------------
|
||||
@app.route("/materials")
|
||||
@login_required
|
||||
def materials():
|
||||
q = request.args.get("q", "").strip()
|
||||
stmt = db.select(Material)
|
||||
if q:
|
||||
like = f"%{q}%"
|
||||
stmt = stmt.where((Material.part_number.like(like)) | (Material.manufacturer.like(like)))
|
||||
mats = db.session.scalars(stmt.order_by(Material.part_number)).all()
|
||||
sm = stock_map()
|
||||
inb_total = inbound_total_map()
|
||||
out_total = outbound_total_map()
|
||||
return render_template("materials.html", mats=mats, sm=sm,
|
||||
inb_total=inb_total, out_total=out_total, q=q)
|
||||
|
||||
|
||||
@app.route("/material/add", methods=["GET", "POST"])
|
||||
@login_required
|
||||
@role_required("admin", "manager")
|
||||
def material_add():
|
||||
if request.method == "POST":
|
||||
pn = request.form["part_number"].strip()
|
||||
if not pn:
|
||||
flash("物料编号必填", "error")
|
||||
elif db.session.scalar(db.select(Material).where(Material.part_number == pn)):
|
||||
flash("物料编号已存在", "error")
|
||||
else:
|
||||
m = Material(
|
||||
part_number=pn,
|
||||
manufacturer=request.form.get("manufacturer", ""),
|
||||
unit=request.form.get("unit", "个") or "个",
|
||||
safety_stock=float(request.form.get("safety_stock") or 0),
|
||||
note=request.form.get("note", ""),
|
||||
)
|
||||
db.session.add(m)
|
||||
db.session.commit()
|
||||
log_action("新增物料", pn, request.form.get("manufacturer", ""))
|
||||
flash("物料已添加", "ok")
|
||||
return redirect(url_for("materials"))
|
||||
return render_template("material_form.html", m=None)
|
||||
|
||||
|
||||
@app.route("/material/edit/<int:mid>", methods=["GET", "POST"])
|
||||
@login_required
|
||||
@role_required("admin", "manager")
|
||||
def material_edit(mid):
|
||||
m = db.session.get(Material, mid)
|
||||
if not m:
|
||||
return redirect(url_for("materials"))
|
||||
if request.method == "POST":
|
||||
m.manufacturer = request.form.get("manufacturer", "")
|
||||
m.unit = request.form.get("unit", "个") or "个"
|
||||
m.safety_stock = float(request.form.get("safety_stock") or 0)
|
||||
m.note = request.form.get("note", "")
|
||||
db.session.commit()
|
||||
log_action("编辑物料", m.part_number)
|
||||
flash("已保存", "ok")
|
||||
return redirect(url_for("materials"))
|
||||
return render_template("material_form.html", m=m)
|
||||
|
||||
|
||||
@app.route("/material/delete/<int:mid>", methods=["POST"])
|
||||
@login_required
|
||||
@role_required("admin", "manager")
|
||||
def material_delete(mid):
|
||||
m = db.session.get(Material, mid)
|
||||
if m:
|
||||
db.session.delete(m)
|
||||
db.session.commit()
|
||||
log_action("删除物料", m.part_number)
|
||||
flash("已删除", "ok")
|
||||
return redirect(url_for("materials"))
|
||||
|
||||
|
||||
@app.route("/material/<int:mid>")
|
||||
@login_required
|
||||
def material_detail(mid):
|
||||
m = db.session.get(Material, mid)
|
||||
if not m:
|
||||
return redirect(url_for("materials"))
|
||||
sm = stock_map()
|
||||
inb_total = inbound_total_map()
|
||||
out_total = outbound_total_map()
|
||||
inbounds = db.session.scalars(
|
||||
db.select(Inbound).where(Inbound.material_id == mid)
|
||||
.order_by(Inbound.occur_date.desc(), Inbound.id.desc())
|
||||
).all()
|
||||
outbounds = db.session.scalars(
|
||||
db.select(Outbound).where(Outbound.material_id == mid)
|
||||
.order_by(Outbound.occur_date.desc(), Outbound.id.desc())
|
||||
).all()
|
||||
return render_template("material_detail.html", m=m,
|
||||
stock=sm.get(m.id, 0),
|
||||
inb_total=inb_total.get(m.id, 0),
|
||||
out_total=out_total.get(m.id, 0),
|
||||
inbounds=inbounds, outbounds=outbounds)
|
||||
|
||||
|
||||
# ---------------- 入库 / 出库 ----------------
|
||||
@app.route("/inbound", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def inbound():
|
||||
if request.method == "POST":
|
||||
mid = int(request.form["material_id"])
|
||||
qty = float(request.form["qty"])
|
||||
if qty <= 0:
|
||||
flash("入库数量必须大于 0", "error")
|
||||
else:
|
||||
rec = Inbound(
|
||||
material_id=mid, batch_no=request.form.get("batch_no", ""),
|
||||
qty=qty, operator=request.form.get("operator", ""),
|
||||
source=request.form.get("source", ""),
|
||||
occur_date=_date(request.form.get("occur_date")),
|
||||
remark=request.form.get("remark", ""))
|
||||
db.session.add(rec)
|
||||
db.session.commit()
|
||||
log_action("入库", db.session.get(Material, mid).part_number, f"{qty}")
|
||||
flash("入库成功", "ok")
|
||||
return redirect(url_for("inbound"))
|
||||
mats = db.session.scalars(db.select(Material).order_by(Material.part_number)).all()
|
||||
recs = db.session.scalars(db.select(Inbound).order_by(Inbound.id.desc()).limit(50)).all()
|
||||
return render_template("txn.html", kind="inbound", mats=mats, recs=recs, today=date.today().isoformat())
|
||||
|
||||
|
||||
@app.route("/outbound", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def outbound():
|
||||
if request.method == "POST":
|
||||
mid = int(request.form["material_id"])
|
||||
qty = float(request.form["qty"])
|
||||
if qty <= 0:
|
||||
flash("出库数量必须大于 0", "error")
|
||||
else:
|
||||
stock = stock_map().get(mid, 0)
|
||||
if qty > stock:
|
||||
flash(f"库存不足! 当前在库 {stock}", "error")
|
||||
else:
|
||||
rec = Outbound(
|
||||
material_id=mid, batch_no=request.form.get("batch_no", ""),
|
||||
qty=qty, operator=request.form.get("operator", ""),
|
||||
purpose=request.form.get("purpose", ""),
|
||||
order_ref=request.form.get("order_ref", ""),
|
||||
occur_date=_date(request.form.get("occur_date")),
|
||||
remark=request.form.get("remark", ""))
|
||||
db.session.add(rec)
|
||||
db.session.commit()
|
||||
log_action("出库", db.session.get(Material, mid).part_number, f"{qty}")
|
||||
flash("出库成功", "ok")
|
||||
return redirect(url_for("outbound"))
|
||||
mats = db.session.scalars(db.select(Material).order_by(Material.part_number)).all()
|
||||
recs = db.session.scalars(db.select(Outbound).order_by(Outbound.id.desc()).limit(50)).all()
|
||||
return render_template("txn.html", kind="outbound", mats=mats, recs=recs, today=date.today().isoformat())
|
||||
|
||||
|
||||
# ---------------- M2: BOM ----------------
|
||||
@app.route("/boms")
|
||||
@login_required
|
||||
@role_required("admin", "manager")
|
||||
def boms():
|
||||
bs = db.session.scalars(db.select(Bom).order_by(Bom.code)).all()
|
||||
data = []
|
||||
for b in bs:
|
||||
items = db.session.scalars(db.select(BomItem).where(BomItem.bom_id == b.id)).all()
|
||||
data.append({"b": b, "count": len(items)})
|
||||
return render_template("boms.html", data=data)
|
||||
|
||||
|
||||
@app.route("/bom/add", methods=["GET", "POST"])
|
||||
@login_required
|
||||
@role_required("admin", "manager")
|
||||
def bom_add():
|
||||
mats = db.session.scalars(db.select(Material).order_by(Material.part_number)).all()
|
||||
if request.method == "POST":
|
||||
code = request.form.get("code", "").strip()
|
||||
if not code:
|
||||
flash("BOM 型号必填", "error")
|
||||
elif db.session.scalar(db.select(Bom).where(Bom.code == code)):
|
||||
flash("BOM 型号已存在", "error")
|
||||
else:
|
||||
b = Bom(code=code, name=request.form.get("name", ""), note=request.form.get("note", ""))
|
||||
db.session.add(b)
|
||||
db.session.flush()
|
||||
pns = request.form.getlist("pn")
|
||||
qts = request.form.getlist("qty")
|
||||
for pn, qt in zip(pns, qts):
|
||||
pn = pn.strip()
|
||||
if not pn:
|
||||
continue
|
||||
try:
|
||||
q = float(qt)
|
||||
except ValueError:
|
||||
continue
|
||||
if q <= 0:
|
||||
continue
|
||||
m = db.session.scalar(db.select(Material).where(Material.part_number == pn))
|
||||
if m:
|
||||
db.session.add(BomItem(bom_id=b.id, material_id=m.id, qty_per_unit=q))
|
||||
db.session.commit()
|
||||
log_action("新增BOM", code)
|
||||
flash("BOM 已保存", "ok")
|
||||
return redirect(url_for("boms"))
|
||||
return render_template("bom_form.html", b=None, mats=mats, items=[])
|
||||
|
||||
|
||||
@app.route("/bom/edit/<int:bid>", methods=["GET", "POST"])
|
||||
@login_required
|
||||
@role_required("admin", "manager")
|
||||
def bom_edit(bid):
|
||||
b = db.session.get(Bom, bid)
|
||||
if not b:
|
||||
return redirect(url_for("boms"))
|
||||
mats = db.session.scalars(db.select(Material).order_by(Material.part_number)).all()
|
||||
if request.method == "POST":
|
||||
b.name = request.form.get("name", "")
|
||||
b.note = request.form.get("note", "")
|
||||
db.session.execute(db.delete(BomItem).where(BomItem.bom_id == b.id))
|
||||
pns = request.form.getlist("pn")
|
||||
qts = request.form.getlist("qty")
|
||||
for pn, qt in zip(pns, qts):
|
||||
pn = pn.strip()
|
||||
if not pn:
|
||||
continue
|
||||
try:
|
||||
q = float(qt)
|
||||
except ValueError:
|
||||
continue
|
||||
if q <= 0:
|
||||
continue
|
||||
m = db.session.scalar(db.select(Material).where(Material.part_number == pn))
|
||||
if m:
|
||||
db.session.add(BomItem(bom_id=b.id, material_id=m.id, qty_per_unit=q))
|
||||
db.session.commit()
|
||||
log_action("编辑BOM", b.code)
|
||||
flash("已保存", "ok")
|
||||
return redirect(url_for("boms"))
|
||||
raw = db.session.scalars(db.select(BomItem).where(BomItem.bom_id == b.id)).all()
|
||||
items = [{"pn": db.session.get(Material, it.material_id).part_number,
|
||||
"qty": it.qty_per_unit} for it in raw]
|
||||
return render_template("bom_form.html", b=b, mats=mats, items=items)
|
||||
|
||||
|
||||
@app.route("/bom/delete/<int:bid>", methods=["POST"])
|
||||
@login_required
|
||||
@role_required("admin", "manager")
|
||||
def bom_delete(bid):
|
||||
b = db.session.get(Bom, bid)
|
||||
if b:
|
||||
db.session.execute(db.delete(BomItem).where(BomItem.bom_id == b.id))
|
||||
db.session.delete(b)
|
||||
db.session.commit()
|
||||
log_action("删除BOM", b.code)
|
||||
flash("已删除", "ok")
|
||||
return redirect(url_for("boms"))
|
||||
|
||||
|
||||
@app.route("/bom/<int:bid>")
|
||||
@login_required
|
||||
@role_required("admin", "manager")
|
||||
def bom_detail(bid):
|
||||
b = db.session.get(Bom, bid)
|
||||
if not b:
|
||||
return redirect(url_for("boms"))
|
||||
items = db.session.scalars(
|
||||
db.select(BomItem).where(BomItem.bom_id == b.id)
|
||||
.order_by(BomItem.id)
|
||||
).all()
|
||||
rows = []
|
||||
for it in items:
|
||||
m = db.session.get(Material, it.material_id)
|
||||
rows.append({"m": m, "qty": it.qty_per_unit})
|
||||
return render_template("bom_detail.html", b=b, rows=rows)
|
||||
|
||||
|
||||
# ---------------- M2: 生产订单 ----------------
|
||||
@app.route("/orders")
|
||||
@login_required
|
||||
@role_required("admin", "manager")
|
||||
def orders():
|
||||
os_ = db.session.scalars(db.select(ProdOrder).order_by(ProdOrder.id.desc())).all()
|
||||
data = []
|
||||
for o in os_:
|
||||
bom = db.session.get(Bom, o.bom_id)
|
||||
data.append({"o": o, "bom": bom})
|
||||
return render_template("orders.html", data=data)
|
||||
|
||||
|
||||
@app.route("/order/new", methods=["GET", "POST"])
|
||||
@login_required
|
||||
@role_required("admin", "manager")
|
||||
def order_new():
|
||||
boms_all = db.session.scalars(db.select(Bom).order_by(Bom.code)).all()
|
||||
if request.method == "POST":
|
||||
bom_id = int(request.form["bom_id"])
|
||||
qty = float(request.form["qty"])
|
||||
if qty <= 0:
|
||||
flash("接单量必须大于 0", "error")
|
||||
else:
|
||||
order_no = request.form.get("order_no", "").strip() or _gen_order_no()
|
||||
if db.session.scalar(db.select(ProdOrder).where(ProdOrder.order_no == order_no)):
|
||||
order_no = _gen_order_no()
|
||||
o = ProdOrder(order_no=order_no, bom_id=bom_id, qty=qty,
|
||||
status="planned", operator=(g.user.display_name or g.user.username))
|
||||
db.session.add(o)
|
||||
db.session.commit()
|
||||
log_action("新建生产订单", order_no, f"接单量 {qty}")
|
||||
return redirect(url_for("order_detail", oid=o.id))
|
||||
return render_template("order_form.html", boms=boms_all, o=None)
|
||||
|
||||
|
||||
@app.route("/order/<int:oid>")
|
||||
@login_required
|
||||
@role_required("admin", "manager")
|
||||
def order_detail(oid):
|
||||
o = db.session.get(ProdOrder, oid)
|
||||
if not o:
|
||||
return redirect(url_for("orders"))
|
||||
bom = db.session.get(Bom, o.bom_id)
|
||||
items = db.session.scalars(db.select(BomItem).where(BomItem.bom_id == o.bom_id)).all()
|
||||
sm = stock_map()
|
||||
reqs = []
|
||||
for it in items:
|
||||
m = db.session.get(Material, it.material_id)
|
||||
need = it.qty_per_unit * o.qty
|
||||
have = sm.get(m.id, 0)
|
||||
reqs.append({"m": m, "need": need, "have": have,
|
||||
"gap": need - have, "short": need > have})
|
||||
return render_template("order_detail.html", o=o, bom=bom, reqs=reqs)
|
||||
|
||||
|
||||
@app.route("/order/<int:oid>/pick", methods=["POST"])
|
||||
@login_required
|
||||
@role_required("admin", "manager")
|
||||
def order_pick(oid):
|
||||
o = db.session.get(ProdOrder, oid)
|
||||
if not o or o.status == "picked":
|
||||
return redirect(url_for("orders"))
|
||||
items = db.session.scalars(db.select(BomItem).where(BomItem.bom_id == o.bom_id)).all()
|
||||
sm = stock_map()
|
||||
picks = 0
|
||||
shorts = []
|
||||
for it in items:
|
||||
m = db.session.get(Material, it.material_id)
|
||||
need = it.qty_per_unit * o.qty
|
||||
if need <= 0:
|
||||
continue
|
||||
db.session.add(Outbound(material_id=m.id, qty=need,
|
||||
operator=(g.user.display_name or g.user.username),
|
||||
purpose="生产领用", order_ref=o.order_no,
|
||||
occur_date=date.today()))
|
||||
picks += 1
|
||||
if need > sm.get(m.id, 0):
|
||||
shorts.append(m.part_number)
|
||||
o.status = "picked"
|
||||
db.session.commit()
|
||||
log_action("生产领料", o.order_no, f"生成出库 {picks} 项" + (f", 缺料: {','.join(shorts)}" if shorts else ""))
|
||||
if shorts:
|
||||
flash(f"已领料, 但缺料: {', '.join(shorts)}", "warn")
|
||||
else:
|
||||
flash("领料出库完成", "ok")
|
||||
return redirect(url_for("order_detail", oid=oid))
|
||||
|
||||
|
||||
def _gen_order_no():
|
||||
d = date.today().strftime("%Y%m%d")
|
||||
n = db.session.scalar(db.select(db.func.count(ProdOrder.id))) + 1
|
||||
return f"WO{d}{n:03d}"
|
||||
|
||||
|
||||
# ---------------- M3: 报表 / 日志 / 用户 ----------------
|
||||
@app.route("/report")
|
||||
@login_required
|
||||
@role_required("admin", "manager")
|
||||
def report():
|
||||
mats = db.session.scalars(db.select(Material).order_by(Material.part_number)).all()
|
||||
sm = stock_map()
|
||||
rows = []
|
||||
for m in mats:
|
||||
stock = sm.get(m.id, 0)
|
||||
if stock < 0:
|
||||
status = "danger"
|
||||
elif stock < (m.safety_stock or 0):
|
||||
status = "warn"
|
||||
else:
|
||||
status = "ok"
|
||||
rows.append({"m": m, "stock": stock, "status": status})
|
||||
return render_template("report.html", rows=rows, today=date.today().isoformat())
|
||||
|
||||
|
||||
@app.route("/export/inventory")
|
||||
@login_required
|
||||
@role_required("admin", "manager")
|
||||
def export_inventory():
|
||||
from excel_io import export_inventory
|
||||
mats = db.session.scalars(db.select(Material).order_by(Material.part_number)).all()
|
||||
sm = stock_map()
|
||||
rows = []
|
||||
for m in mats:
|
||||
stock = sm.get(m.id, 0)
|
||||
status = "danger" if stock < 0 else ("warn" if stock < (m.safety_stock or 0) else "ok")
|
||||
rows.append({"m": m, "stock": stock, "status": status})
|
||||
path = os.path.join(EXE_DIR, f"库存导出_{date.today().isoformat()}.xlsx")
|
||||
export_inventory(path, rows)
|
||||
return send_file(path, as_attachment=True, download_name=os.path.basename(path))
|
||||
|
||||
|
||||
@app.route("/logs")
|
||||
@login_required
|
||||
@role_required("admin", "manager")
|
||||
def logs():
|
||||
recs = db.session.scalars(db.select(OperationLog).order_by(OperationLog.id.desc()).limit(200)).all()
|
||||
return render_template("logs.html", recs=recs)
|
||||
|
||||
|
||||
@app.route("/users")
|
||||
@login_required
|
||||
@role_required("admin")
|
||||
def users():
|
||||
us = db.session.scalars(db.select(User).order_by(User.username)).all()
|
||||
return render_template("users.html", us=us)
|
||||
|
||||
|
||||
@app.route("/user/add", methods=["POST"])
|
||||
@login_required
|
||||
@role_required("admin")
|
||||
def user_add():
|
||||
un = request.form.get("username", "").strip()
|
||||
pw = request.form.get("password", "")
|
||||
role = request.form.get("role", "operator")
|
||||
if not un or not pw:
|
||||
flash("用户名和密码必填", "error")
|
||||
elif db.session.scalar(db.select(User).where(User.username == un)):
|
||||
flash("用户名已存在", "error")
|
||||
else:
|
||||
db.session.add(User(username=un, password=sha256(pw), role=role,
|
||||
display_name=request.form.get("display_name", "")))
|
||||
db.session.commit()
|
||||
log_action("新增用户", un, role)
|
||||
flash("用户已添加", "ok")
|
||||
return redirect(url_for("users"))
|
||||
|
||||
|
||||
@app.route("/user/toggle/<int:uid>", methods=["POST"])
|
||||
@login_required
|
||||
@role_required("admin")
|
||||
def user_toggle(uid):
|
||||
u = db.session.get(User, uid)
|
||||
if u and u.id != g.user.id:
|
||||
u.active = not u.active
|
||||
db.session.commit()
|
||||
log_action("启用/停用用户", u.username)
|
||||
return redirect(url_for("users"))
|
||||
|
||||
|
||||
# ---------------- Excel 导入 ----------------
|
||||
@app.route("/import/materials", methods=["GET", "POST"])
|
||||
@login_required
|
||||
@role_required("admin", "manager")
|
||||
def import_materials():
|
||||
if request.method == "POST":
|
||||
f = request.files.get("file")
|
||||
if not f or not f.filename:
|
||||
flash("请选择文件", "error")
|
||||
else:
|
||||
tmp = os.path.join(EXE_DIR, "_tmp_materials.xlsx")
|
||||
f.save(tmp)
|
||||
from excel_io import read_materials, import_materials as imp
|
||||
rows = read_materials(tmp)
|
||||
added, updated = imp(rows, operator=(g.user.display_name or g.user.username))
|
||||
os.remove(tmp)
|
||||
log_action("导入物料", "Excel", f"新增 {added} / 更新 {updated}")
|
||||
flash(f"导入完成: 新增 {added} 条, 更新 {updated} 条", "ok")
|
||||
return redirect(url_for("materials"))
|
||||
return render_template("import_form.html", kind="materials")
|
||||
|
||||
|
||||
@app.route("/import/bom", methods=["GET", "POST"])
|
||||
@login_required
|
||||
@role_required("admin", "manager")
|
||||
def import_bom():
|
||||
if request.method == "POST":
|
||||
f = request.files.get("file")
|
||||
if not f or not f.filename:
|
||||
flash("请选择文件", "error")
|
||||
else:
|
||||
tmp = os.path.join(EXE_DIR, "_tmp_bom.xlsx")
|
||||
f.save(tmp)
|
||||
from excel_io import read_bom, import_bom as impb
|
||||
data = read_bom(tmp)
|
||||
os.remove(tmp)
|
||||
if not data:
|
||||
flash("Excel 格式不符(需含 BOM型号/物料编号/单套用量 列)", "error")
|
||||
else:
|
||||
bid, ok, skip = impb(data, operator=(g.user.display_name or g.user.username))
|
||||
log_action("导入BOM", data["code"], f"成功 {ok} / 跳过 {skip}")
|
||||
flash(f"BOM {data['code']} 导入: 成功 {ok} 项, 跳过(物料不存在) {skip} 项", "ok")
|
||||
return redirect(url_for("boms"))
|
||||
return render_template("import_form.html", kind="bom")
|
||||
|
||||
|
||||
@app.route("/template/<kind>")
|
||||
@login_required
|
||||
@role_required("admin", "manager")
|
||||
def download_template(kind):
|
||||
from excel_io import build_material_template, build_bom_template, build_transaction_template
|
||||
if kind == "materials":
|
||||
p = os.path.join(EXE_DIR, "物料导入模板.xlsx")
|
||||
build_material_template(p)
|
||||
return send_file(p, as_attachment=True, download_name="物料导入模板.xlsx")
|
||||
elif kind == "bom":
|
||||
p = os.path.join(EXE_DIR, "BOM导入模板.xlsx")
|
||||
build_bom_template(p)
|
||||
return send_file(p, as_attachment=True, download_name="BOM导入模板.xlsx")
|
||||
elif kind == "transactions":
|
||||
p = os.path.join(EXE_DIR, "批量导入模板.xlsx")
|
||||
build_transaction_template(p)
|
||||
return send_file(p, as_attachment=True, download_name="批量导入模板.xlsx")
|
||||
return redirect(url_for("dashboard"))
|
||||
|
||||
|
||||
# ---------------- 交易批量导入窗口(入库/出库) ----------------
|
||||
@app.route("/api/import/transactions/preview", methods=["POST"])
|
||||
@login_required
|
||||
def api_import_txn_preview():
|
||||
"""接收粘贴的 JSON 行 或 上传文件, 返回解析+校验结果(供前端预览)"""
|
||||
kind = (request.values.get("kind") or (request.get_json(silent=True) or {}).get("kind") or "inbound")
|
||||
if request.files.get("file"):
|
||||
tmp = os.path.join(EXE_DIR, "_tmp_txn.xlsx")
|
||||
request.files["file"].save(tmp)
|
||||
try:
|
||||
from excel_io import read_transactions
|
||||
rows = read_transactions(tmp)
|
||||
finally:
|
||||
if os.path.exists(tmp):
|
||||
os.remove(tmp)
|
||||
else:
|
||||
payload = request.get_json(silent=True) or {}
|
||||
rows = payload.get("rows", [])
|
||||
result = _validate_txn_rows(kind, rows)
|
||||
return jsonify({"kind": kind, "rows": result,
|
||||
"valid": sum(1 for r in result if r["ok"]),
|
||||
"invalid": sum(1 for r in result if not r["ok"])})
|
||||
|
||||
|
||||
@app.route("/api/import/transactions/commit", methods=["POST"])
|
||||
@login_required
|
||||
def api_import_txn_commit():
|
||||
"""提交校验通过的行, 批量写入入库/出库流水"""
|
||||
payload = request.get_json(silent=True) or {}
|
||||
kind = payload.get("kind", "inbound")
|
||||
rows = payload.get("rows", [])
|
||||
validated = _validate_txn_rows(kind, rows)
|
||||
ok_rows = [v for v in validated if v["ok"]]
|
||||
op_name = (g.user.display_name or g.user.username) if g.user else "?"
|
||||
added = 0
|
||||
for v in ok_rows:
|
||||
occur = _date(v["date"])
|
||||
if kind == "inbound":
|
||||
rec = Inbound(material_id=v["material_id"], batch_no=v["batch_no"], qty=v["qty"],
|
||||
operator=op_name, source="批量导入", occur_date=occur, remark=v["remark"])
|
||||
else:
|
||||
rec = Outbound(material_id=v["material_id"], batch_no=v["batch_no"], qty=v["qty"],
|
||||
operator=op_name, purpose="批量导入", occur_date=occur, remark=v["remark"])
|
||||
db.session.add(rec)
|
||||
added += 1
|
||||
db.session.commit()
|
||||
if added:
|
||||
log_action("批量导入" + ("入库" if kind == "inbound" else "出库"), "窗口",
|
||||
f"成功 {added} 条")
|
||||
return jsonify({"added": added,
|
||||
"skipped": len(validated) - added,
|
||||
"errors": [r["error"] for r in validated if not r["ok"]]})
|
||||
|
||||
|
||||
# ---------------- 物料批量导入窗口 ----------------
|
||||
@app.route("/api/import/materials/preview", methods=["POST"])
|
||||
@login_required
|
||||
@role_required("admin", "manager")
|
||||
def api_import_materials_preview():
|
||||
"""接收粘贴文本 JSON 或上传文件, 返回物料解析+校验结果"""
|
||||
if request.files.get("file"):
|
||||
tmp = os.path.join(EXE_DIR, "_tmp_materials.xlsx")
|
||||
request.files["file"].save(tmp)
|
||||
try:
|
||||
from excel_io import read_materials
|
||||
rows = read_materials(tmp)
|
||||
finally:
|
||||
if os.path.exists(tmp):
|
||||
os.remove(tmp)
|
||||
else:
|
||||
payload = request.get_json(silent=True) or {}
|
||||
from excel_io import parse_materials_text
|
||||
rows = parse_materials_text(payload.get("text", ""))
|
||||
result = _validate_material_rows(rows)
|
||||
return jsonify({"rows": result,
|
||||
"valid": sum(1 for r in result if r["ok"]),
|
||||
"invalid": sum(1 for r in result if not r["ok"])})
|
||||
|
||||
|
||||
@app.route("/api/import/materials/commit", methods=["POST"])
|
||||
@login_required
|
||||
@role_required("admin", "manager")
|
||||
def api_import_materials_commit():
|
||||
"""提交校验通过的物料行, 批量 upsert (含期初流水)"""
|
||||
payload = request.get_json(silent=True) or {}
|
||||
rows = payload.get("rows", [])
|
||||
rows = [r for r in rows if r.get("ok", True)] # 只提交校验通过的行
|
||||
from excel_io import import_materials as imp_materials
|
||||
op_name = (g.user.display_name or g.user.username) if g.user else "?"
|
||||
added, updated = imp_materials(rows, operator=op_name)
|
||||
if added or updated:
|
||||
log_action("批量导入物料", "窗口", f"新增 {added} / 更新 {updated}")
|
||||
return jsonify({"added": added, "updated": updated,
|
||||
"skipped": len(rows) - added - updated})
|
||||
|
||||
|
||||
# ---------------- BOM 批量导入窗口 ----------------
|
||||
@app.route("/api/import/boms/preview", methods=["POST"])
|
||||
@login_required
|
||||
@role_required("admin", "manager")
|
||||
def api_import_boms_preview():
|
||||
"""接收粘贴文本 JSON 或上传文件, 返回多 BOM 解析+校验结果"""
|
||||
if request.files.get("file"):
|
||||
tmp = os.path.join(EXE_DIR, "_tmp_bom.xlsx")
|
||||
request.files["file"].save(tmp)
|
||||
try:
|
||||
from excel_io import read_boms_multi
|
||||
boms = read_boms_multi(tmp)
|
||||
finally:
|
||||
if os.path.exists(tmp):
|
||||
os.remove(tmp)
|
||||
else:
|
||||
payload = request.get_json(silent=True) or {}
|
||||
from excel_io import parse_boms_text
|
||||
boms = parse_boms_text(payload.get("text", ""))
|
||||
result = _validate_bom_rows(boms)
|
||||
return jsonify({"boms": result,
|
||||
"valid": sum(1 for b in result if b["ok"]),
|
||||
"invalid": sum(1 for b in result if not b["ok"])})
|
||||
|
||||
|
||||
@app.route("/api/import/boms/commit", methods=["POST"])
|
||||
@login_required
|
||||
@role_required("admin", "manager")
|
||||
def api_import_boms_commit():
|
||||
"""提交校验通过的 BOM(多个), 批量写入 BOM + BomItem"""
|
||||
payload = request.get_json(silent=True) or {}
|
||||
boms = payload.get("boms", [])
|
||||
from excel_io import import_bom
|
||||
op_name = (g.user.display_name or g.user.username) if g.user else "?"
|
||||
created = 0
|
||||
total_ok = 0
|
||||
total_skip = 0
|
||||
total_created_mat = 0
|
||||
for b in boms:
|
||||
code = str(b.get("code", "") or "").strip()
|
||||
if not code:
|
||||
continue
|
||||
items = [{"part_number": it["part_number"], "qty_per_unit": it["qty_per_unit"],
|
||||
"manufacture": it.get("manufacture", "")}
|
||||
for it in b.get("items", []) if it.get("ok")]
|
||||
if not items:
|
||||
continue
|
||||
_, ok, skip, created_mat = import_bom(
|
||||
{"code": code, "name": b.get("name", ""), "items": items},
|
||||
operator=op_name, auto_create=True)
|
||||
created += 1
|
||||
total_ok += ok
|
||||
total_skip += skip
|
||||
total_created_mat += created_mat
|
||||
if created:
|
||||
msg = f"{created}个BOM, 成功项 {total_ok}, 跳过 {total_skip}"
|
||||
if total_created_mat:
|
||||
msg += f", 自动新建物料 {total_created_mat}"
|
||||
log_action("批量导入BOM", "窗口", msg)
|
||||
return jsonify({"boms": created, "added_items": total_ok,
|
||||
"skipped_items": total_skip, "created_materials": total_created_mat})
|
||||
|
||||
|
||||
# ---------------- 启动 ----------------
|
||||
def seed_admin():
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
if not db.session.scalar(db.select(User).where(User.username == "admin")):
|
||||
db.session.add(User(username="admin", password=sha256("admin123"),
|
||||
role="admin", display_name="管理员"))
|
||||
db.session.commit()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
port = 8080
|
||||
if len(sys.argv) > 1 and sys.argv[1].isdigit():
|
||||
port = int(sys.argv[1])
|
||||
seed_admin()
|
||||
if getattr(sys, "frozen", False):
|
||||
webbrowser.open(f"http://127.0.0.1:{port}/")
|
||||
app.run(host="127.0.0.1", port=port, debug=False)
|
||||
@@ -0,0 +1,34 @@
|
||||
"""打包单文件 EXE (PyInstaller --onefile --windowed)
|
||||
|
||||
冻结后:
|
||||
- 模板/静态走 sys._MEIPASS (用 --add-data 打入)
|
||||
- 库文件 app.db 落在 exe 同目录 (app.py 中 EXE_DIR 处理)
|
||||
- 启动时自动打开浏览器 (app.py __main__ 中 webbrowser.open)
|
||||
|
||||
用法: venv 的 python build_exe.py
|
||||
"""
|
||||
import os, sys, platform
|
||||
from PyInstaller.__main__ import run
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sep = ";" if platform.system() == "Windows" else ":"
|
||||
|
||||
args = [
|
||||
os.path.join(HERE, "app.py"),
|
||||
"--onefile",
|
||||
"--windowed",
|
||||
"--name", "MaterialWMS",
|
||||
"--distpath", os.path.join(HERE, "dist"),
|
||||
"--workpath", os.path.join(HERE, "build"),
|
||||
"--specpath", os.path.join(HERE, "build"),
|
||||
"--add-data", os.path.join(HERE, "templates") + sep + "templates",
|
||||
"--add-data", os.path.join(HERE, "static") + sep + "static",
|
||||
"--hidden-import", "flask",
|
||||
"--hidden-import", "flask_sqlalchemy",
|
||||
"--hidden-import", "sqlalchemy",
|
||||
"--hidden-import", "openpyxl",
|
||||
"--noconfirm",
|
||||
]
|
||||
print("PyInstaller args:", args)
|
||||
run(args)
|
||||
print("BUILD DONE ->", os.path.join(HERE, "dist", "MaterialWMS.exe"))
|
||||
+473
@@ -0,0 +1,473 @@
|
||||
"""Excel 双向导入/导出 (openpyxl)
|
||||
|
||||
- 物料导入 / 导出
|
||||
- BOM 导入(含组成行)
|
||||
- 交易(入库/出库)批量导入
|
||||
- 模板下载
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import csv
|
||||
from datetime import date
|
||||
from openpyxl import Workbook, load_workbook
|
||||
from openpyxl.styles import Font, PatternFill, Alignment
|
||||
|
||||
from models import db, Material, Inbound, Bom, BomItem, OperationLog
|
||||
|
||||
|
||||
# ---------------- 交易(入库/出库)批量导入 ----------------
|
||||
def _cell(r, i):
|
||||
"""安全取单元格, 越界返回 None"""
|
||||
try:
|
||||
return r[i] if i is not None else None
|
||||
except (IndexError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def read_transactions(path):
|
||||
"""Excel / CSV -> [{part_number, qty, batch_no, date, remark}]
|
||||
|
||||
表头识别: 物料编号 / 数量 / 批次 / 日期 / 备注 (名称大小写/中英均可)。
|
||||
"""
|
||||
if str(path).lower().endswith(".csv"):
|
||||
with open(path, newline="", encoding="utf-8-sig") as f:
|
||||
rows = [r for r in csv.reader(f)]
|
||||
else:
|
||||
wb = load_workbook(path, data_only=True)
|
||||
ws = wb.active
|
||||
rows = [list(r) for r in ws.iter_rows(values_only=True)]
|
||||
if not rows:
|
||||
return []
|
||||
header = [str(c).strip() if c is not None else "" for c in rows[0]]
|
||||
idx = {h: i for i, h in enumerate(header)}
|
||||
|
||||
def col(*names):
|
||||
for n in names:
|
||||
if n in idx:
|
||||
return idx[n]
|
||||
return None
|
||||
|
||||
c_pn = col("物料编号", "Part Number", "part_number", "编号")
|
||||
c_qty = col("数量", "qty", "qty_per_unit", "用量")
|
||||
c_batch = col("批次", "batch_no", "batch")
|
||||
c_date = col("日期", "date", "occur_date", "业务日期")
|
||||
c_remark = col("备注", "remark", "note")
|
||||
out = []
|
||||
for r in rows[1:]:
|
||||
if not any(c not in (None, "") for c in r):
|
||||
continue
|
||||
pn = str(_cell(r, c_pn) or "").strip()
|
||||
if not pn:
|
||||
continue
|
||||
q = _f(_cell(r, c_qty))
|
||||
batch = str(_cell(r, c_batch) or "").strip()
|
||||
d = str(_cell(r, c_date) or "").strip()
|
||||
remark = str(_cell(r, c_remark) or "").strip()
|
||||
out.append({"part_number": pn, "qty": q, "batch_no": batch,
|
||||
"date": d, "remark": remark})
|
||||
return out
|
||||
|
||||
|
||||
def build_transaction_template(path, kind="inbound"):
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.append(["物料编号", "数量", "批次", "日期", "备注"])
|
||||
for c in ws[1]:
|
||||
c.font = Font(bold=True)
|
||||
ws.append(["P0001", "100", "B20260911", "2026-09-11", "示例到货"])
|
||||
ws.append(["P0002", "50", "B20260911", "2026-09-11", "示例到货"])
|
||||
wb.save(path)
|
||||
return path
|
||||
|
||||
|
||||
# ---------------- 物料 ----------------
|
||||
def read_materials(path):
|
||||
"""返回 [{part_number, manufacturer, unit, safety_stock, opening_stock}]"""
|
||||
wb = load_workbook(path, data_only=True)
|
||||
ws = wb.active
|
||||
rows = list(ws.iter_rows(values_only=True))
|
||||
if not rows:
|
||||
return []
|
||||
header = [str(c).strip() if c is not None else "" for c in rows[0]]
|
||||
idx = {h: i for i, h in enumerate(header)}
|
||||
def col(*names):
|
||||
for n in names:
|
||||
if n in idx:
|
||||
return idx[n]
|
||||
return None
|
||||
c_pn = col("物料编号", "Part Number", "part_number", "编号")
|
||||
c_mf = col("型号", "Manufacture", "manufacturer", "规格")
|
||||
c_unit = col("单位", "unit")
|
||||
c_ss = col("安全库存", "safety_stock", "安全量")
|
||||
c_op = col("期初库存", "opening_stock", "期初", "库存")
|
||||
out = []
|
||||
for r in rows[1:]:
|
||||
if c_pn is None or r[c_pn] in (None, ""):
|
||||
continue
|
||||
pn = str(r[c_pn]).strip()
|
||||
if not pn:
|
||||
continue
|
||||
mf = str(r[c_mf]).strip() if (c_mf is not None and r[c_mf] is not None) else ""
|
||||
unit = str(r[c_unit]).strip() if (c_unit is not None and r[c_unit] is not None) else "个"
|
||||
ss = _f(r[c_ss]) if c_ss is not None else 0
|
||||
op = _f(r[c_op]) if c_op is not None else 0
|
||||
out.append({
|
||||
"part_number": pn, "manufacturer": mf,
|
||||
"unit": unit or "个", "safety_stock": ss, "opening_stock": op,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def import_materials(rows, operator="导入"):
|
||||
"""upsert 物料; opening_stock>0 写入一条"期初"入库流水。返回 (新增, 更新)"""
|
||||
added = 0
|
||||
updated = 0
|
||||
for d in rows:
|
||||
m = db.session.scalar(db.select(Material).where(Material.part_number == d["part_number"]))
|
||||
if m is None:
|
||||
m = Material(part_number=d["part_number"], manufacturer=d.get("manufacturer", ""),
|
||||
unit=d.get("unit", "个"), safety_stock=d.get("safety_stock", 0))
|
||||
db.session.add(m)
|
||||
db.session.flush()
|
||||
added += 1
|
||||
else:
|
||||
m.manufacturer = d.get("manufacturer", m.manufacturer)
|
||||
m.unit = d.get("unit", m.unit) or "个"
|
||||
m.safety_stock = d.get("safety_stock", m.safety_stock)
|
||||
updated += 1
|
||||
op = d.get("opening_stock", 0) or 0
|
||||
if op > 0:
|
||||
exists = db.session.scalar(
|
||||
db.select(Inbound).where(Inbound.material_id == m.id, Inbound.source == "期初"))
|
||||
if exists:
|
||||
exists.qty = op
|
||||
else:
|
||||
db.session.add(Inbound(material_id=m.id, qty=op, source="期初",
|
||||
occur_date=date.today(), operator=operator))
|
||||
db.session.commit()
|
||||
return added, updated
|
||||
|
||||
|
||||
# ---------------- BOM ----------------
|
||||
def read_bom(path):
|
||||
"""返回 {code, name, items:[{part_number, qty_per_unit}]} 或 None(格式不符)"""
|
||||
wb = load_workbook(path, data_only=True)
|
||||
ws = wb.active
|
||||
rows = list(ws.iter_rows(values_only=True))
|
||||
if not rows:
|
||||
return None
|
||||
header = [str(c).strip() if c is not None else "" for c in rows[0]]
|
||||
idx = {h: i for i, h in enumerate(header)}
|
||||
def col(*names):
|
||||
for n in names:
|
||||
if n in idx:
|
||||
return idx[n]
|
||||
return None
|
||||
c_code = col("BOM型号", "code", "BOM")
|
||||
c_name = col("名称", "name", "产品")
|
||||
c_pn = col("物料编号", "Part Number", "part_number", "编号")
|
||||
c_qty = col("单套用量", "qty_per_unit", "用量")
|
||||
if c_code is None or c_pn is None or c_qty is None:
|
||||
return None
|
||||
code = str(rows[1][c_code]).strip() if rows[1][c_code] else ""
|
||||
name = str(rows[1][c_name]).strip() if (c_name is not None and rows[1][c_name]) else ""
|
||||
items = []
|
||||
for r in rows[1:]:
|
||||
if r[c_pn] in (None, ""):
|
||||
continue
|
||||
pn = str(r[c_pn]).strip()
|
||||
q = _f(r[c_qty])
|
||||
if q <= 0:
|
||||
continue
|
||||
items.append({"part_number": pn, "qty_per_unit": q})
|
||||
return {"code": code, "name": name, "items": items}
|
||||
|
||||
|
||||
def import_bom(data, operator="导入", auto_create=False):
|
||||
"""写入 BOM + BomItem。
|
||||
|
||||
auto_create=True 时, 物料主数据不存在则按 Part Number + Manufacture 自动新建(便于真实 BOM 一步导入);
|
||||
否则物料不存在则该项被跳过。返回 (bom_id, 成功项, 跳过项, 自动新建物料数)。
|
||||
"""
|
||||
if not data or not data["code"]:
|
||||
return None, 0, 0, 0
|
||||
bom = db.session.scalar(db.select(Bom).where(Bom.code == data["code"]))
|
||||
if bom is None:
|
||||
bom = Bom(code=data["code"], name=data.get("name", ""))
|
||||
db.session.add(bom)
|
||||
db.session.flush()
|
||||
else:
|
||||
db.session.execute(db.delete(BomItem).where(BomItem.bom_id == bom.id))
|
||||
ok = 0
|
||||
skip = 0
|
||||
created_mat = 0
|
||||
for it in data["items"]:
|
||||
pn = str(it["part_number"]).strip()
|
||||
m = db.session.scalar(db.select(Material).where(Material.part_number == pn))
|
||||
if m is None:
|
||||
if not auto_create:
|
||||
skip += 1
|
||||
continue
|
||||
m = Material(part_number=pn,
|
||||
manufacturer=str(it.get("manufacture", "") or "").strip(),
|
||||
unit="个")
|
||||
db.session.add(m)
|
||||
db.session.flush()
|
||||
created_mat += 1
|
||||
db.session.add(BomItem(bom_id=bom.id, material_id=m.id, qty_per_unit=it["qty_per_unit"]))
|
||||
ok += 1
|
||||
db.session.commit()
|
||||
return bom.id, ok, skip, created_mat
|
||||
|
||||
|
||||
# ---------------- 导出 ----------------
|
||||
def export_inventory(path, rows):
|
||||
"""rows: [{m, stock, status}]"""
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "库存"
|
||||
head = ["物料编号", "型号/规格", "单位", "在库数量", "安全库存", "状态"]
|
||||
ws.append(head)
|
||||
for c in ws[1]:
|
||||
c.font = Font(bold=True)
|
||||
c.fill = PatternFill("solid", fgColor="DDEBF7")
|
||||
for r in rows:
|
||||
m = r["m"]
|
||||
status = {"ok": "正常", "warn": "低于安全库存", "danger": "库存为负"}[r["status"]]
|
||||
ws.append([m.part_number, m.manufacturer, m.unit, r["stock"],
|
||||
m.safety_stock or 0, status])
|
||||
if r["status"] == "danger":
|
||||
ws.cell(row=ws.max_row, column=4).fill = PatternFill("solid", fgColor="F8CBAD")
|
||||
elif r["status"] == "warn":
|
||||
ws.cell(row=ws.max_row, column=4).fill = PatternFill("solid", fgColor="FFE699")
|
||||
ws.freeze_panes = "A2"
|
||||
wb.save(path)
|
||||
|
||||
|
||||
def build_material_template(path):
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.append(["物料编号", "型号/规格", "单位", "安全库存", "期初库存"])
|
||||
for c in ws[1]:
|
||||
c.font = Font(bold=True)
|
||||
ws.append(["P0001", "示例型号A", "个", "100", "500"])
|
||||
ws.append(["P0002", "示例型号B", "卷", "50", "200"])
|
||||
wb.save(path)
|
||||
return path
|
||||
|
||||
|
||||
def build_bom_template(path):
|
||||
"""生成与真实 BOM 模板一致的格式(Altium/嘉立创导出):
|
||||
每个 worksheet 一个 BOM, sheet 名即 BOM 型号; 列头 Item/Part Number/
|
||||
Manufacture Part Number/Quantity/Reference/Part/Package。"""
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "示例型号A"
|
||||
ws.append(["Item", "Part Number", "Manufacture Part Number",
|
||||
"Quantity", "Reference", "Part", "Package"])
|
||||
for c in ws[1]:
|
||||
c.font = Font(bold=True)
|
||||
c.fill = PatternFill("solid", fgColor="DDEBF7")
|
||||
ws.append([1, "1001003", "CFW32C7UL_BUA", 1, "U1", "CFW32C7UL_BUA", "CFW32C7UL_IU_SOCKET"])
|
||||
ws.append([2, "1003002", "BY25Q32BSSIG", 1, "U2", "GD25Q127CSIG", "SOIC-8-S"])
|
||||
ws.append([3, "1007004", "SGM2036-2.5YUDH4G/TR", 1, "U11", "SGM2036-2.5YUDH4G/TR", ""])
|
||||
ws.freeze_panes = "A2"
|
||||
wb.save(path)
|
||||
return path
|
||||
|
||||
|
||||
def _norm(h):
|
||||
return str(h or "").strip().lower()
|
||||
|
||||
|
||||
def _find_col(header, names):
|
||||
"""返回第一个精确匹配或包含匹配的列索引; 精确优先, 避免误命中(如 'part' 命中 'Part Number')"""
|
||||
low = [_norm(h) for h in header]
|
||||
# 1) 精确匹配
|
||||
for n in names:
|
||||
nn = _norm(n)
|
||||
for i, h in enumerate(low):
|
||||
if h == nn:
|
||||
return i
|
||||
# 2) 包含匹配(子串)
|
||||
for n in names:
|
||||
nn = _norm(n)
|
||||
if not nn:
|
||||
continue
|
||||
for i, h in enumerate(low):
|
||||
if nn in h:
|
||||
return i
|
||||
return None
|
||||
|
||||
|
||||
def _find_cols(header, names):
|
||||
"""返回所有匹配的列索引(用于重复表头, 如两个 'Quantity')"""
|
||||
low = [_norm(h) for h in header]
|
||||
out = []
|
||||
for n in names:
|
||||
nn = _norm(n)
|
||||
if not nn:
|
||||
continue
|
||||
for i, h in enumerate(low):
|
||||
if h == nn or nn in h:
|
||||
if i not in out:
|
||||
out.append(i)
|
||||
return out
|
||||
|
||||
|
||||
def _f(v):
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
# ---------------- 批量导入窗口: 文本解析 ----------------
|
||||
_ITEM_HEADER_RE = re.compile(
|
||||
r"物料|编号|part\s?number|型号|manufacture|单位|unit|安全|safety|期初|库存|名称|bom|单套|用量|数量|qty",
|
||||
re.I,
|
||||
)
|
||||
|
||||
|
||||
def parse_materials_text(text):
|
||||
"""粘贴多行 -> [{part_number, manufacturer, unit, safety_stock, opening_stock}]
|
||||
|
||||
每行格式: 物料编号 型号/规格 单位 安全库存 期初库存
|
||||
列之间可用 空格/制表符/逗号/中文逗号 分隔; 首列命中表头关键词的行会被跳过。
|
||||
"""
|
||||
out = []
|
||||
for raw in (text or "").split("\n"):
|
||||
s = re.sub(r"[\t,,;;]+", " ", raw).strip()
|
||||
if not s:
|
||||
continue
|
||||
parts = s.split()
|
||||
if not parts:
|
||||
continue
|
||||
if _ITEM_HEADER_RE.search(parts[0]):
|
||||
continue # 跳过表头
|
||||
pn = parts[0]
|
||||
if not pn:
|
||||
continue
|
||||
mf = parts[1] if len(parts) > 1 else ""
|
||||
unit = parts[2] if len(parts) > 2 else "个"
|
||||
ss = _f(parts[3]) if len(parts) > 3 else 0
|
||||
op = _f(parts[4]) if len(parts) > 4 else 0
|
||||
out.append({
|
||||
"part_number": pn,
|
||||
"manufacturer": mf,
|
||||
"unit": unit or "个",
|
||||
"safety_stock": ss,
|
||||
"opening_stock": op,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def parse_boms_text(text):
|
||||
"""粘贴多行(以 #BOM 标记分段) -> [{code, name, items:[{part_number, qty_per_unit}]}]
|
||||
|
||||
格式:
|
||||
#BOM M4021 示例产品A
|
||||
P0001 2
|
||||
P0002 0.5
|
||||
|
||||
#BOM M4022 示例产品B
|
||||
P0003 4
|
||||
"""
|
||||
boms = []
|
||||
cur = None
|
||||
for raw in (text or "").split("\n"):
|
||||
s = re.sub(r"[\t,,;;]+", " ", raw).strip()
|
||||
if not s:
|
||||
continue
|
||||
parts = s.split()
|
||||
if not parts:
|
||||
continue
|
||||
is_head = s.startswith("#") or re.match(r"^(BOM|型号|MODEL)\b", parts[0], re.I) is not None
|
||||
if is_head:
|
||||
body = re.sub(r"^#+\s*", "", s, flags=re.I)
|
||||
body = re.sub(r"^(BOM|型号|MODEL)\s*", "", body, flags=re.I).strip()
|
||||
bp = body.split()
|
||||
if not bp:
|
||||
continue
|
||||
if len(bp) > 1 and re.search(r"名称|物料|编号|单套|用量|数量", bp[1]):
|
||||
continue # 表头行(如 "BOM型号 名称 物料编号 单套用量")
|
||||
cur = {"code": bp[0], "name": " ".join(bp[1:]) if len(bp) > 1 else "", "items": []}
|
||||
boms.append(cur)
|
||||
continue
|
||||
if _ITEM_HEADER_RE.search(parts[0]):
|
||||
continue # 表头行
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
if cur is None:
|
||||
continue
|
||||
cur["items"].append({"part_number": parts[0], "qty_per_unit": _f(parts[1])})
|
||||
return boms
|
||||
|
||||
|
||||
def read_boms_multi(path):
|
||||
"""Excel/CSV -> [{code, name, items:[{part_number, qty_per_unit, manufacture}]}]
|
||||
|
||||
适配真实 BOM 模板(Altium / 嘉立创 EDA 导出):
|
||||
- 每个 worksheet 视为一个 BOM, BOM 型号 = sheet 名
|
||||
- 电子料表: 表头含 'Part Number'(物料编号) + 'Quantity'(单套用量, 取第一个 Quantity 列)
|
||||
- 组装表: 表头含 '物料代码'(物料编号) + '数量'(单套用量)
|
||||
- 兼容旧模板: 若表头含 'BOM型号' 列, 则按该列分组(单 sheet 多 BOM)
|
||||
返回的每个 item 带 manufacture(型号/规格), 便于物料不存在时自动建物料。
|
||||
"""
|
||||
if str(path).lower().endswith(".csv"):
|
||||
with open(path, newline="", encoding="utf-8-sig") as f:
|
||||
rows = [r for r in csv.reader(f)]
|
||||
sheets = [("Sheet1", rows)]
|
||||
else:
|
||||
wb = load_workbook(path, data_only=True)
|
||||
sheets = [(ws.title, list(ws.iter_rows(values_only=True))) for ws in wb.worksheets]
|
||||
|
||||
result = []
|
||||
for sheet_name, rows in sheets:
|
||||
if not rows or len(rows) < 2:
|
||||
continue
|
||||
header = [str(c).strip() if c is not None else "" for c in rows[0]]
|
||||
if not any(header):
|
||||
continue
|
||||
pn_col = _find_col(header, ["part number", "partnumber", "物料编号", "物料代码"])
|
||||
qty_cols = _find_cols(header, ["quantity", "数量", "单套用量", "qty", "qty_per_unit"])
|
||||
mf_col = _find_col(header, ["manufacture part number", "manufacture", "型号", "part"])
|
||||
code_col = _find_col(header, ["bom型号", "code", "bom"])
|
||||
name_col = _find_col(header, ["名称", "name", "产品"])
|
||||
if pn_col is None or not qty_cols:
|
||||
continue # 该 sheet 不是物料清单, 跳过(如说明页)
|
||||
qty_col = qty_cols[0] # 取第一个 Quantity 列(嘉立创常出现两个 Quantity)
|
||||
|
||||
items = []
|
||||
for r in rows[1:]:
|
||||
if not any(c not in (None, "") for c in r):
|
||||
continue
|
||||
pn = str(r[pn_col]).strip() if pn_col < len(r) and r[pn_col] not in (None, "") else ""
|
||||
if not pn:
|
||||
continue
|
||||
qty = _f(r[qty_col]) if qty_col < len(r) else 0
|
||||
# 嘉立创常出现两个 Quantity 列, 若首列空/0 则回退到次列, 避免丢料
|
||||
if qty <= 0 and len(qty_cols) > 1 and qty_cols[1] < len(r):
|
||||
q2 = _f(r[qty_cols[1]])
|
||||
if q2 > 0:
|
||||
qty = q2
|
||||
if qty <= 0:
|
||||
continue
|
||||
mf = ""
|
||||
if mf_col is not None and mf_col < len(r) and r[mf_col] not in (None, ""):
|
||||
mf = str(r[mf_col]).strip()
|
||||
items.append({"part_number": pn, "qty_per_unit": qty, "manufacture": mf})
|
||||
if not items:
|
||||
continue
|
||||
|
||||
code = sheet_name.strip()
|
||||
name = ""
|
||||
if code_col is not None: # 兼容旧模板: 取首行 BOM型号/名称
|
||||
for r in rows[1:]:
|
||||
if code_col < len(r) and r[code_col] not in (None, ""):
|
||||
code = str(r[code_col]).strip()
|
||||
if name_col is not None and name_col < len(r) and r[name_col] not in (None, ""):
|
||||
name = str(r[name_col]).strip()
|
||||
break
|
||||
result.append({"code": code, "name": name, "items": items})
|
||||
return result
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
"""首次初始化: 建表 + 管理员 + 从桌面 Excel 导入真实物料(含期初库存)
|
||||
|
||||
用法: 用 venv 的 python 运行
|
||||
python init_db.py
|
||||
"""
|
||||
import hashlib
|
||||
from datetime import date
|
||||
from app import app, db
|
||||
from models import User, Material, Inbound
|
||||
|
||||
EXCEL = r"C:/Users/wps18/Desktop/物料明细表.xlsx"
|
||||
|
||||
|
||||
def main():
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
|
||||
# 管理员
|
||||
if not db.session.scalar(db.select(User).where(User.username == "admin")):
|
||||
db.session.add(User(
|
||||
username="admin",
|
||||
password=hashlib.sha256("admin123".encode("utf-8")).hexdigest(),
|
||||
role="admin",
|
||||
display_name="管理员",
|
||||
))
|
||||
print("已创建管理员 admin / admin123")
|
||||
|
||||
# 导入物料
|
||||
import openpyxl
|
||||
wb = openpyxl.load_workbook(EXCEL, data_only=True)
|
||||
ws = wb["物料入库"]
|
||||
imported = 0
|
||||
skipped = 0
|
||||
for row in ws.iter_rows(min_row=2, values_only=True):
|
||||
pn = row[1] # B 列 Part Number
|
||||
if pn is None:
|
||||
continue
|
||||
pn = str(pn).strip()
|
||||
if not pn:
|
||||
continue
|
||||
if db.session.scalar(db.select(Material).where(Material.part_number == pn)):
|
||||
skipped += 1
|
||||
continue
|
||||
man = str(row[2]) if row[2] else ""
|
||||
m = Material(part_number=pn, manufacturer=man, unit="个", safety_stock=0)
|
||||
db.session.add(m)
|
||||
db.session.flush()
|
||||
# 期初库存: 优先 E 列(库存数量), 其次 D 列(总数量)
|
||||
init_qty = row[4] if row[4] not in (None, "") else row[3]
|
||||
try:
|
||||
init_qty = float(init_qty)
|
||||
except (TypeError, ValueError):
|
||||
init_qty = 0
|
||||
if init_qty > 0:
|
||||
db.session.add(Inbound(
|
||||
material_id=m.id, qty=init_qty,
|
||||
source="期初", occur_date=date.today(), operator="系统",
|
||||
))
|
||||
imported += 1
|
||||
db.session.commit()
|
||||
print(f"导入物料 {imported} 条, 跳过已存在 {skipped} 条")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Binary file not shown.
@@ -0,0 +1,108 @@
|
||||
"""物料入库/出库系统 - 数据模型 (SQLAlchemy 2.0)
|
||||
|
||||
实体:
|
||||
User 账号(admin / manager / operator)
|
||||
Material 物料主数据
|
||||
Inbound 入库流水(含"期初"来源的首笔)
|
||||
Outbound 出库流水(含生产领用)
|
||||
Bom BOM 清单(产品型号)
|
||||
BomItem BOM 组成(某物料单套用量)
|
||||
ProdOrder 生产订单(接单量) -> 按 BOM 展开领料需求
|
||||
OperationLog 操作日志(谁/何时/做了什么)
|
||||
|
||||
库存口径: 在库 = SUM(入库) - SUM(出库), 由 app 实时聚合, 不冗余存储。
|
||||
"""
|
||||
import os
|
||||
from datetime import datetime, date
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
|
||||
db = SQLAlchemy()
|
||||
|
||||
|
||||
class User(db.Model):
|
||||
__tablename__ = "user"
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(64), unique=True, nullable=False, index=True)
|
||||
password = db.Column(db.String(128), nullable=False) # sha256 hex
|
||||
role = db.Column(db.String(32), default="operator") # admin / manager / operator
|
||||
display_name = db.Column(db.String(64), default="")
|
||||
active = db.Column(db.Boolean, default=True)
|
||||
|
||||
|
||||
class Material(db.Model):
|
||||
__tablename__ = "material"
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
part_number = db.Column(db.String(64), unique=True, nullable=False, index=True)
|
||||
manufacturer = db.Column(db.String(255), default="") # 型号 / 规格
|
||||
unit = db.Column(db.String(16), default="个")
|
||||
safety_stock = db.Column(db.Float, default=0) # 安全库存
|
||||
note = db.Column(db.String(255), default="")
|
||||
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||
|
||||
|
||||
class Inbound(db.Model):
|
||||
__tablename__ = "inbound"
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
material_id = db.Column(db.Integer, db.ForeignKey("material.id"), nullable=False, index=True)
|
||||
batch_no = db.Column(db.String(64), default="") # 批次号
|
||||
qty = db.Column(db.Float, nullable=False) # 入库数量(正)
|
||||
operator = db.Column(db.String(64), default="") # 经手人
|
||||
source = db.Column(db.String(255), default="") # 来源 / 供应商
|
||||
occur_date = db.Column(db.Date, default=date.today)
|
||||
remark = db.Column(db.String(255), default="")
|
||||
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||
|
||||
|
||||
class Outbound(db.Model):
|
||||
__tablename__ = "outbound"
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
material_id = db.Column(db.Integer, db.ForeignKey("material.id"), nullable=False, index=True)
|
||||
batch_no = db.Column(db.String(64), default="") # 批次号
|
||||
qty = db.Column(db.Float, nullable=False) # 出库数量(正)
|
||||
operator = db.Column(db.String(64), default="") # 经手人
|
||||
purpose = db.Column(db.String(255), default="") # 用途
|
||||
order_ref = db.Column(db.String(64), default="") # 关联生产单号
|
||||
occur_date = db.Column(db.Date, default=date.today)
|
||||
remark = db.Column(db.String(255), default="")
|
||||
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||
|
||||
|
||||
# ---------------- M2: BOM 与生产订单 ----------------
|
||||
class Bom(db.Model):
|
||||
__tablename__ = "bom"
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
code = db.Column(db.String(64), unique=True, nullable=False, index=True) # BOM 型号简码
|
||||
name = db.Column(db.String(255), default="")
|
||||
note = db.Column(db.String(255), default="")
|
||||
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||
|
||||
|
||||
class BomItem(db.Model):
|
||||
__tablename__ = "bom_item"
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
bom_id = db.Column(db.Integer, db.ForeignKey("bom.id"), nullable=False, index=True)
|
||||
material_id = db.Column(db.Integer, db.ForeignKey("material.id"), nullable=False, index=True)
|
||||
qty_per_unit = db.Column(db.Float, nullable=False, default=0) # 单套用量
|
||||
|
||||
|
||||
class ProdOrder(db.Model):
|
||||
__tablename__ = "prod_order"
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
order_no = db.Column(db.String(64), unique=True, nullable=False, index=True)
|
||||
bom_id = db.Column(db.Integer, db.ForeignKey("bom.id"), nullable=False, index=True)
|
||||
qty = db.Column(db.Float, nullable=False, default=0) # 接单量(台/套)
|
||||
status = db.Column(db.String(32), default="planned") # planned / picked / closed
|
||||
operator = db.Column(db.String(64), default="")
|
||||
remark = db.Column(db.String(255), default="")
|
||||
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||
|
||||
|
||||
# ---------------- M3: 操作日志 ----------------
|
||||
class OperationLog(db.Model):
|
||||
__tablename__ = "operation_log"
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
operator = db.Column(db.String(64), default="")
|
||||
action = db.Column(db.String(64), default="") # 动作分类
|
||||
target = db.Column(db.String(255), default="") # 对象
|
||||
detail = db.Column(db.String(512), default="") # 详情
|
||||
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||
@@ -0,0 +1,5 @@
|
||||
flask
|
||||
flask-sqlalchemy
|
||||
sqlalchemy
|
||||
openpyxl
|
||||
pyinstaller
|
||||
@@ -0,0 +1,90 @@
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0; font-family: "Microsoft YaHei", "PingFang SC", system-ui, sans-serif;
|
||||
background: #f4f6f8; color: #1f2733; font-size: 15px;
|
||||
}
|
||||
.topbar {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
background: #1f3a5f; color: #fff; padding: 0 20px; height: 52px;
|
||||
}
|
||||
.topbar .brand { font-weight: 700; font-size: 17px; }
|
||||
.topbar .ver { background: #2e8b57; border-radius: 4px; padding: 1px 6px; font-size: 12px; margin-left: 6px; }
|
||||
.topbar nav a { color: #dce6f2; text-decoration: none; margin-left: 18px; font-size: 14px; }
|
||||
.topbar nav a:hover { color: #fff; }
|
||||
.topbar .who { margin-left: 18px; color: #9fb3cc; font-size: 13px; }
|
||||
main { max-width: 1100px; margin: 18px auto; padding: 0 16px; }
|
||||
|
||||
.stats { display: flex; gap: 14px; margin-bottom: 16px; }
|
||||
.stat { flex: 1; background: #fff; border-radius: 8px; padding: 16px; text-align: center; box-shadow: 0 1px 3px rgba(0,0,0,.08); }
|
||||
.stat .num { font-size: 30px; font-weight: 700; }
|
||||
.stat .lbl { color: #6b7785; font-size: 13px; margin-top: 4px; }
|
||||
.stat.warn .num { color: #d48806; }
|
||||
.stat.danger .num { color: #d4380d; }
|
||||
|
||||
h3 { margin: 18px 0 10px; }
|
||||
.card { background: #fff; border-radius: 8px; padding: 18px; box-shadow: 0 1px 3px rgba(0,0,0,.08); }
|
||||
.login-wrap { display: flex; justify-content: center; margin-top: 60px; }
|
||||
.login-card { width: 320px; }
|
||||
.login-card h2 { margin-top: 0; }
|
||||
|
||||
label { display: block; margin: 10px 0 4px; font-size: 13px; color: #44525f; }
|
||||
input, select, textarea {
|
||||
width: 100%; padding: 8px 10px; border: 1px solid #cdd5df; border-radius: 6px; font-size: 14px;
|
||||
}
|
||||
button, .btn {
|
||||
display: inline-block; cursor: pointer; border: none; border-radius: 6px;
|
||||
padding: 9px 16px; font-size: 14px; text-decoration: none; background: #eef1f5; color: #1f2733;
|
||||
}
|
||||
button.primary, .btn.primary { background: #1f3a5f; color: #fff; }
|
||||
button:hover, .btn:hover { filter: brightness(.96); }
|
||||
.row-actions { margin-top: 14px; display: flex; gap: 10px; }
|
||||
|
||||
.toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
|
||||
.search input { width: 280px; }
|
||||
.search button { margin-left: 8px; }
|
||||
|
||||
.tbl { width: 100%; border-collapse: collapse; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,.08); }
|
||||
.tbl th, .tbl td { padding: 9px 12px; text-align: left; border-bottom: 1px solid #eef1f5; }
|
||||
.tbl th { background: #f0f3f7; font-size: 13px; color: #44525f; }
|
||||
.tbl td.num, .stat .num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.tbl a { color: #1f3a5f; text-decoration: none; }
|
||||
.row-danger { background: #fff1f0; }
|
||||
.row-warn { background: #fffbe6; }
|
||||
|
||||
.badge { padding: 2px 8px; border-radius: 10px; font-size: 12px; }
|
||||
.badge.ok { background: #e6f7ed; color: #1a7f47; }
|
||||
.badge.warn { background: #fff7e6; color: #ad6800; }
|
||||
.badge.danger { background: #fff1f0; color: #cf1322; }
|
||||
|
||||
.flash { padding: 10px 14px; border-radius: 6px; margin-bottom: 12px; font-size: 14px; }
|
||||
.flash.ok { background: #e6f7ed; color: #1a7f47; }
|
||||
.flash.error { background: #fff1f0; color: #cf1322; }
|
||||
.muted { color: #8a96a3; }
|
||||
.small { font-size: 12px; }
|
||||
.txn-form { max-width: 520px; margin-bottom: 22px; }
|
||||
|
||||
/* 批量导入窗口(模态) */
|
||||
.modal-mask {
|
||||
position: fixed; inset: 0; background: rgba(15,23,35,.5);
|
||||
display: flex; align-items: center; justify-content: center; z-index: 50;
|
||||
}
|
||||
.modal {
|
||||
background: #fff; border-radius: 10px; width: 720px; max-width: 94vw;
|
||||
max-height: 90vh; display: flex; flex-direction: column; overflow: hidden;
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,.25);
|
||||
}
|
||||
.modal-head {
|
||||
background: #1f3a5f; color: #fff; padding: 12px 16px;
|
||||
display: flex; justify-content: space-between; align-items: center; font-weight: 600;
|
||||
}
|
||||
.modal-head .x { background: transparent; color: #fff; font-size: 18px; padding: 0 6px; }
|
||||
.modal-body { padding: 16px; overflow: auto; }
|
||||
.modal-body textarea { font-family: Consolas, "Courier New", monospace; font-size: 13px; }
|
||||
.modal-body label.btn { margin: 0; }
|
||||
|
||||
/* 批量导入窗口: 多 BOM 卡片 */
|
||||
.bom-card { border: 1px solid #e3e8ef; border-radius: 8px; margin-bottom: 10px; overflow: hidden; }
|
||||
.bom-card.card-danger { border-color: #ffccc7; }
|
||||
.bom-card .bom-head { background: #f0f3f7; padding: 8px 12px; font-size: 14px; display: flex; align-items: center; gap: 8px; }
|
||||
.bom-card.card-danger .bom-head { background: #fff1f0; }
|
||||
.bom-card .tbl { box-shadow: none; border-radius: 0; margin: 0; }
|
||||
@@ -0,0 +1,39 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}物料入库系统{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brand">📦 物料入库系统 <span class="ver">M1+M2+M3</span></div>
|
||||
<nav>
|
||||
<a href="{{ url_for('dashboard') }}">库存看板</a>
|
||||
<a href="{{ url_for('materials') }}">物料</a>
|
||||
<a href="{{ url_for('inbound') }}">入库</a>
|
||||
<a href="{{ url_for('outbound') }}">出库</a>
|
||||
{% if g.user and g.user.role in ('admin','manager') %}
|
||||
<a href="{{ url_for('boms') }}">BOM</a>
|
||||
<a href="{{ url_for('orders') }}">生产订单</a>
|
||||
<a href="{{ url_for('report') }}">报表</a>
|
||||
<a href="{{ url_for('logs') }}">日志</a>
|
||||
{% endif %}
|
||||
{% if g.user and g.user.role == 'admin' %}
|
||||
<a href="{{ url_for('users') }}">用户</a>
|
||||
{% endif %}
|
||||
{% if g.user %}<span class="who">{{ g.user.display_name or g.user.username }} ({{ g.user.role }})</span>
|
||||
<a href="{{ url_for('logout') }}">退出</a>{% endif %}
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
{% with msgs = get_flashed_messages(with_categories=true) %}
|
||||
{% for cat, msg in msgs %}
|
||||
<div class="flash {{ cat }}">{{ msg }}</div>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,39 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}BOM 明细 {{ b.code }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="toolbar" data-page-node-id="kijLoAphCatqmeteC0fLS8">
|
||||
<h3 style="margin:0" data-page-node-id="ECJxkuTNaoeZCptb3hYWET">BOM 明细 · {{ b.code }}</h3>
|
||||
<div data-page-node-id="W3YF2xKkzcqbLyQSQHmDv9">
|
||||
<a class="btn" href="{{ url_for('boms') }}" data-page-node-id="mkt8bYpaiDpnsyNak2CwEx">← 返回 BOM 列表</a>
|
||||
<a class="btn" href="{{ url_for('bom_edit', bid=b.id) }}" data-page-node-id="b8G1I4yNJvCie28XRFo93k">编辑</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-bottom:16px" data-page-node-id="BMc2NONnJUN8plt66uKp3k">
|
||||
<table class="tbl" style="box-shadow:none" data-page-node-id="HuBqdlqwPqeqvRGoctqyFK">
|
||||
<tbody data-page-node-id="xq9k2F2CKZNS1J5oXVzrxN">
|
||||
<tr data-page-node-id="CSgYyr7zDavFkvrYRL7J2l"><th style="width:120px" data-page-node-id="YaqRj0xvW6swCf9HolEsdZ">BOM 型号</th><td data-page-node-id="W3h0NKaeFcWnSk6bqpmnfd">{{ b.code }}</td>
|
||||
<th style="width:120px" data-page-node-id="GrBiPbEdCAMAtba0DGidm8">名称</th><td data-page-node-id="t0dNbwOJnGYpyrvlMpJzHS">{{ b.name or '-' }}</td></tr>
|
||||
<tr data-page-node-id="xnlzfzKyPIY29LTkzF5Exj"><th data-page-node-id="loONdVka6TX9pWPbpWr85x">备注</th><td colspan="3" data-page-node-id="vOtWTckSv3MzklsdnlC0FE">{{ b.note or '-' }}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3 data-page-node-id="t3eVJx5yZD6C7dR2AhvvI1">组成物料(物料编号 / 规格型号 / 单套用量)</h3>
|
||||
<table class="tbl" data-page-node-id="df7zG7vdcHnmIIpkdv6sIX">
|
||||
<thead data-page-node-id="U5mUzLwlJBjGq8lEuFm0Yp"><tr data-page-node-id="B10e5ndG2Fo8DbA4uD8s5e"><th data-page-node-id="clptv2dGFiVnRCTmGNcxGx">#</th><th data-page-node-id="FrYt8QMtBKNICWDU3FNTwu">物料编号</th><th data-page-node-id="WHXRD4OE5syF9gB7S9xUtN">型号 / 规格</th><th data-page-node-id="SF4qN0XEJENLIO6zMz4Rqj">单位</th><th data-page-node-id="5IfMfV4HQupPKakWftwag4">单套用量</th></tr></thead>
|
||||
<tbody data-page-node-id="JBr6qsb25DZ6OzSa4g7USe">
|
||||
{% for r in rows %}
|
||||
<tr data-page-node-id="IM9jYB1JuNEMAWNi8ehaaZ">
|
||||
<td class="num" data-page-node-id="8ad0FCRnUybzeEWz8FbALx">{{ loop.index }}</td>
|
||||
<td data-page-node-id="gbLKKUuIavKSsnyYDv5tXm"><a href="{{ url_for('material_detail', mid=r.m.id) }}" data-page-node-id="m53G2ARWr2Udxdxlffdd1l">{{ r.m.part_number }}</a></td>
|
||||
<td data-page-node-id="vIrrECfeuKSVoBo8MG4qw8">{{ r.m.manufacturer or '-' }}</td>
|
||||
<td data-page-node-id="LdQUt8z687YhTj9wNbXNLp">{{ r.m.unit }}</td>
|
||||
<td class="num" data-page-node-id="m2vUEqKxHfCxdgKNKdrMZS">{{ '%.4g'|format(r.qty) }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr data-page-node-id="HRMX6kXmTYyKrAC01CI6EK"><td colspan="5" class="muted" data-page-node-id="G4RCcAkiwklmllhidB6cEW">该 BOM 还没有组成物料</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,44 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ '编辑 BOM' if b else '新增 BOM' }}{% endblock %}
|
||||
{% block content %}
|
||||
<form method="post" class="card form-card" style="max-width:640px">
|
||||
<h3>{{ '编辑 BOM' if b else '新增 BOM' }}</h3>
|
||||
<label>BOM 型号 *</label>
|
||||
<input name="code" value="{{ b.code if b else '' }}" required {% if b %}readonly{% endif %}>
|
||||
<label>名称</label>
|
||||
<input name="name" value="{{ b.name if b else '' }}">
|
||||
<label>备注</label>
|
||||
<input name="note" value="{{ b.note if b else '' }}">
|
||||
|
||||
<h3>组成物料 (单套用量)</h3>
|
||||
<div id="items">
|
||||
{% for it in items %}
|
||||
<div class="item-row" style="display:flex;gap:8px;margin-bottom:6px">
|
||||
<input name="pn" placeholder="物料编号" value="{{ it.pn }}" style="flex:2">
|
||||
<input name="qty" type="number" step="0.01" min="0" placeholder="单套用量" value="{{ it.qty }}" style="flex:1">
|
||||
<button type="button" class="del" onclick="this.parentNode.remove()">✕</button>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="item-row" style="display:flex;gap:8px;margin-bottom:6px">
|
||||
<input name="pn" placeholder="物料编号" style="flex:2">
|
||||
<input name="qty" type="number" step="0.01" min="0" placeholder="单套用量" style="flex:1">
|
||||
<button type="button" class="del" onclick="this.parentNode.remove()">✕</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<button type="button" class="btn" onclick="addRow()">+ 添加一行</button>
|
||||
<div class="row-actions"><button class="primary">保存</button></div>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
function addRow(){
|
||||
const d=document.createElement('div');
|
||||
d.className='item-row';
|
||||
d.style='display:flex;gap:8px;margin-bottom:6px';
|
||||
d.innerHTML='<input name="pn" placeholder="物料编号" style="flex:2">'+
|
||||
'<input name="qty" type="number" step="0.01" min="0" placeholder="单套用量" style="flex:1">'+
|
||||
'<button type="button" class="del" onclick="this.parentNode.remove()">✕</button>';
|
||||
document.getElementById('items').appendChild(d);
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,122 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}BOM 清单{% endblock %}
|
||||
{% block content %}
|
||||
<div class="toolbar">
|
||||
<h3 style="margin:0">BOM 清单 ({{ data|length }})</h3>
|
||||
<div>
|
||||
<a class="btn" href="{{ url_for('download_template', kind='bom') }}">⬇ 下载模板</a>
|
||||
<button class="primary" type="button" onclick="openBatch()">⊞ 批量导入窗口</button>
|
||||
</div>
|
||||
</div>
|
||||
<table class="tbl">
|
||||
<thead><tr><th>BOM 型号</th><th>名称</th><th>组成项数</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
{% for d in data %}
|
||||
<tr>
|
||||
<td><a href="{{ url_for('bom_detail', bid=d.b.id) }}">{{ d.b.code }}</a></td>
|
||||
<td>{{ d.b.name or '-' }}</td>
|
||||
<td class="num"><a href="{{ url_for('bom_detail', bid=d.b.id) }}">{{ d.count }}</a></td>
|
||||
<td>
|
||||
<a href="{{ url_for('bom_detail', bid=d.b.id) }}">查看</a>
|
||||
<a href="{{ url_for('bom_edit', bid=d.b.id) }}">编辑</a>
|
||||
<form method="post" action="{{ url_for('bom_delete', bid=d.b.id) }}" style="display:inline" onsubmit="return confirm('确认删除该 BOM?');">
|
||||
<button class="link">删除</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="4" class="muted">还没有 BOM, 点右上角新增</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- 批量导入窗口(模态) -->
|
||||
<div class="modal-mask" id="batchMask" style="display:none">
|
||||
<div class="modal">
|
||||
<div class="modal-head">
|
||||
<span>批量导入 BOM · 多 BOM 清单</span>
|
||||
<button class="x" onclick="closeBatch()">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="small muted">支持两种录入:① 直接上传 <b>Altium / 嘉立创 BOM</b>(每个 worksheet 即一个 BOM,<b>Part Number</b> 关联物料、<b>Quantity</b> 列即单套用量,下载模板即此格式);② 粘贴文本,每行一个 BOM,以 <b>#BOM <型号> <名称></b> 开头,其下为 <b>物料编号 单套用量</b>,空行分隔。物料不存在时导入会自动新建主数据。</p>
|
||||
<textarea id="batchText" rows="11" placeholder="#BOM M4021 示例产品A
|
||||
P0001 2
|
||||
P0002 0.5
|
||||
|
||||
#BOM M4022 示例产品B
|
||||
P0003 4"></textarea>
|
||||
<div class="row-actions">
|
||||
<button type="button" onclick="parsePaste()">解析预览</button>
|
||||
<label class="btn" style="cursor:pointer">📁 上传文件
|
||||
<input type="file" id="batchFile" accept=".xlsx,.csv" style="display:none" onchange="parseFile()">
|
||||
</label>
|
||||
<button type="button" class="primary" id="commitBtn" onclick="commitBatch()" disabled>确认导入</button>
|
||||
</div>
|
||||
<div id="batchResult" style="margin-top:12px"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let lastBoms = [];
|
||||
|
||||
function openBatch(){ document.getElementById('batchMask').style.display='flex'; }
|
||||
function closeBatch(){ document.getElementById('batchMask').style.display='none'; }
|
||||
document.getElementById('batchMask').addEventListener('click', e=>{ if(e.target.id==='batchMask') closeBatch(); });
|
||||
|
||||
function parsePaste(){
|
||||
const text = document.getElementById('batchText').value.trim();
|
||||
if(!text){ alert('请先粘贴清单'); return; }
|
||||
postPreview({text: text});
|
||||
}
|
||||
function parseFile(){
|
||||
const f = document.getElementById('batchFile').files[0];
|
||||
if(!f) return;
|
||||
const fd = new FormData();
|
||||
fd.append('file', f);
|
||||
fetch('/api/import/boms/preview', {method:'POST', body:fd})
|
||||
.then(r=>r.json()).then(renderPreview).catch(e=>alert('解析失败: '+e));
|
||||
}
|
||||
function postPreview(payload){
|
||||
fetch('/api/import/boms/preview', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify(payload)
|
||||
}).then(r=>r.json()).then(renderPreview).catch(e=>alert('解析失败: '+e));
|
||||
}
|
||||
function renderPreview(data){
|
||||
lastBoms = data.boms || [];
|
||||
const valid = data.valid, invalid = data.invalid;
|
||||
let html = `<div class="flash ${invalid? 'error':'ok'}">共 ${lastBoms.length} 个 BOM · 可导入 <b>${valid}</b> 个 · 异常 <b>${invalid}</b> 个</div>`;
|
||||
lastBoms.forEach(b=>{
|
||||
const btag = b.ok? '<span class="badge ok">OK</span>' : `<span class="badge danger">${b.error||'错误'}</span>`;
|
||||
html += `<div class="bom-card ${b.ok?'':'card-danger'}">`;
|
||||
html += `<div class="bom-head"><b>${b.code||''}</b> ${b.name||''} ${btag} <span class="muted small">成功项 ${b.valid_items}/${b.items.length}</span></div>`;
|
||||
html += '<table class="tbl"><thead><tr><th>物料编号</th><th>型号/规格</th><th>单套用量</th><th>校验</th></tr></thead><tbody>';
|
||||
b.items.forEach(it=>{
|
||||
const cls = it.ok? '' : 'row-danger';
|
||||
let tag;
|
||||
if (it.auto_create) tag = '<span class="badge warn">新建</span>';
|
||||
else if (it.ok) tag = '<span class="badge ok">OK</span>';
|
||||
else tag = `<span class="badge danger">${it.error||'错误'}</span>`;
|
||||
html += `<tr class="${cls}"><td>${it.part_number||''}</td><td class="muted small">${it.manufacturer||''}</td><td class="num">${it.qty_per_unit}</td><td>${tag}</td></tr>`;
|
||||
});
|
||||
html += '</tbody></table></div>';
|
||||
});
|
||||
document.getElementById('batchResult').innerHTML = html;
|
||||
document.getElementById('commitBtn').disabled = valid===0;
|
||||
}
|
||||
function commitBatch(){
|
||||
if(!lastBoms.length) return;
|
||||
fetch('/api/import/boms/commit', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({boms: lastBoms})
|
||||
}).then(r=>r.json()).then(d=>{
|
||||
let msg = `导入完成: ${d.boms} 个 BOM, 成功 ${d.added_items} 项`;
|
||||
if (d.created_materials) msg += `, 新建物料 ${d.created_materials}`;
|
||||
if (d.skipped_items) msg += `, 跳过 ${d.skipped_items} 项`;
|
||||
alert(msg);
|
||||
if(d.boms>0) location.reload();
|
||||
}).catch(e=>alert('导入失败: '+e));
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,34 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}库存看板{% endblock %}
|
||||
{% block content %}
|
||||
<div class="stats">
|
||||
<div class="stat"><div class="num">{{ rows|length }}</div><div class="lbl">物料种类</div></div>
|
||||
<div class="stat warn"><div class="num">{{ low }}</div><div class="lbl">低于安全库存</div></div>
|
||||
<div class="stat danger"><div class="num">{{ negative }}</div><div class="lbl">库存为负(缺料)</div></div>
|
||||
</div>
|
||||
|
||||
<h3>库存预警看板</h3>
|
||||
<table class="tbl">
|
||||
<thead>
|
||||
<tr><th>物料编号</th><th>型号 / 规格</th><th>单位</th><th>在库</th><th>安全库存</th><th>状态</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in rows %}
|
||||
<tr class="{{ 'row-'+r.status }}">
|
||||
<td>{{ r.m.part_number }}</td>
|
||||
<td>{{ r.m.manufacturer or '-' }}</td>
|
||||
<td>{{ r.m.unit }}</td>
|
||||
<td class="num">{{ '%.2f'|format(r.stock) }}</td>
|
||||
<td class="num">{{ '%.2f'|format(r.m.safety_stock or 0) }}</td>
|
||||
<td>
|
||||
{% if r.status=='danger' %}<span class="badge danger">缺料</span>
|
||||
{% elif r.status=='warn' %}<span class="badge warn">偏低</span>
|
||||
{% else %}<span class="badge ok">正常</span>{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="6" class="muted">暂无物料, 请先到"物料"页面添加</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,20 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Excel 导入{% endblock %}
|
||||
{% block content %}
|
||||
<div class="card form-card" style="max-width:520px">
|
||||
<h3>导入 {{ '物料' if kind=='materials' else 'BOM' }}</h3>
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
<label>选择 Excel 文件 (.xlsx) *</label>
|
||||
<input type="file" name="file" accept=".xlsx" required>
|
||||
<div class="row-actions"><button class="primary">上传并导入</button></div>
|
||||
</form>
|
||||
<p class="small muted">
|
||||
{% if kind=='materials' %}
|
||||
表头需含: 物料编号 / 型号规格 / 单位 / 安全库存 / 期初库存 (后三列可选)。
|
||||
{% else %}
|
||||
表头需含: BOM型号 / 名称 / 物料编号 / 单套用量。
|
||||
{% endif %}
|
||||
</p>
|
||||
<p><a class="btn" href="{{ url_for('download_template', kind=kind) }}">⬇ 下载导入模板</a></p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,16 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}登录{% endblock %}
|
||||
{% block content %}
|
||||
<div class="login-wrap">
|
||||
<form method="post" class="card login-card">
|
||||
<h2>物料入库系统</h2>
|
||||
<p class="muted">请输入账号登录</p>
|
||||
<label>用户名</label>
|
||||
<input name="username" autofocus required>
|
||||
<label>密码</label>
|
||||
<input name="password" type="password" required>
|
||||
<button class="primary">登录</button>
|
||||
<p class="muted small">默认管理员: admin / admin123</p>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,21 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}操作日志{% endblock %}
|
||||
{% block content %}
|
||||
<h3>操作日志 (最近 200 条)</h3>
|
||||
<table class="tbl">
|
||||
<thead><tr><th>时间</th><th>操作人</th><th>动作</th><th>对象</th><th>详情</th></tr></thead>
|
||||
<tbody>
|
||||
{% for r in recs %}
|
||||
<tr>
|
||||
<td class="small">{{ r.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
<td>{{ r.operator or '-' }}</td>
|
||||
<td>{{ r.action }}</td>
|
||||
<td>{{ r.target or '-' }}</td>
|
||||
<td class="small">{{ r.detail or '-' }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5" class="muted">暂无日志</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,69 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}物料明细 {{ m.part_number }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="toolbar">
|
||||
<h3 style="margin:0">物料明细 · {{ m.part_number }}</h3>
|
||||
<div>
|
||||
<a class="btn" href="{{ url_for('materials') }}">← 返回物料列表</a>
|
||||
<a class="btn" href="{{ url_for('material_edit', mid=m.id) }}">编辑</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-bottom:16px">
|
||||
<table class="tbl" style="box-shadow:none">
|
||||
<tbody>
|
||||
<tr><th style="width:120px">物料编号</th><td>{{ m.part_number }}</td>
|
||||
<th style="width:120px">型号 / 规格</th><td>{{ m.manufacturer or '-' }}</td></tr>
|
||||
<tr><th>单位</th><td>{{ m.unit }}</td>
|
||||
<th>安全库存</th><td class="num">{{ '%.2f'|format(m.safety_stock or 0) }}</td></tr>
|
||||
<tr><th>备注</th><td colspan="3">{{ m.note or '-' }}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat"><div class="num">{{ '%.2f'|format(stock) }}</div><div class="lbl">当前在库</div></div>
|
||||
<div class="stat"><div class="num">{{ '%.2f'|format(inb_total) }}</div><div class="lbl">入库总数量</div></div>
|
||||
<div class="stat"><div class="num">{{ '%.2f'|format(out_total) }}</div><div class="lbl">消耗总数量</div></div>
|
||||
</div>
|
||||
|
||||
<h3>入库流水(每次入库时间 / 数量)</h3>
|
||||
<table class="tbl">
|
||||
<thead><tr><th>入库日期</th><th>录入时间</th><th>数量</th><th>批次号</th><th>来源 / 供应商</th><th>经手人</th><th>备注</th></tr></thead>
|
||||
<tbody>
|
||||
{% for r in inbounds %}
|
||||
<tr>
|
||||
<td>{{ r.occur_date }}</td>
|
||||
<td class="small muted">{{ r.created_at.strftime('%Y-%m-%d %H:%M') if r.created_at else '-' }}</td>
|
||||
<td class="num">{{ '%.2f'|format(r.qty) }}</td>
|
||||
<td>{{ r.batch_no or '-' }}</td>
|
||||
<td>{{ r.source or '-' }}</td>
|
||||
<td>{{ r.operator or '-' }}</td>
|
||||
<td>{{ r.remark or '-' }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="7" class="muted">暂无入库记录</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>出库 / 消耗流水</h3>
|
||||
<table class="tbl">
|
||||
<thead><tr><th>出库日期</th><th>录入时间</th><th>数量</th><th>用途</th><th>关联单号</th><th>经手人</th><th>备注</th></tr></thead>
|
||||
<tbody>
|
||||
{% for r in outbounds %}
|
||||
<tr>
|
||||
<td>{{ r.occur_date }}</td>
|
||||
<td class="small muted">{{ r.created_at.strftime('%Y-%m-%d %H:%M') if r.created_at else '-' }}</td>
|
||||
<td class="num">{{ '%.2f'|format(r.qty) }}</td>
|
||||
<td>{{ r.purpose or '-' }}</td>
|
||||
<td>{{ r.order_ref or '-' }}</td>
|
||||
<td>{{ r.operator or '-' }}</td>
|
||||
<td>{{ r.remark or '-' }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="7" class="muted">暂无出库记录</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,21 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ '编辑' if m else '新增' }}物料{% endblock %}
|
||||
{% block content %}
|
||||
<form method="post" class="card form-card">
|
||||
<h3>{{ '编辑物料' if m else '新增物料' }}</h3>
|
||||
<label>物料编号 *</label>
|
||||
<input name="part_number" value="{{ m.part_number if m else '' }}" {{ 'readonly' if m }} required>
|
||||
<label>型号 / 规格</label>
|
||||
<input name="manufacturer" value="{{ m.manufacturer if m else '' }}">
|
||||
<label>单位</label>
|
||||
<input name="unit" value="{{ m.unit if m else '个' }}">
|
||||
<label>安全库存</label>
|
||||
<input name="safety_stock" type="number" step="0.01" value="{{ m.safety_stock if m else 0 }}">
|
||||
<label>备注</label>
|
||||
<input name="note" value="{{ m.note if m else '' }}">
|
||||
<div class="row-actions">
|
||||
<button class="primary">保存</button>
|
||||
<a class="btn" href="{{ url_for('materials') }}">返回</a>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,115 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}物料{% endblock %}
|
||||
{% block content %}
|
||||
<div class="toolbar">
|
||||
<form method="get" class="search">
|
||||
<input name="q" value="{{ q }}" placeholder="搜索物料编号 / 型号">
|
||||
<button>搜索</button>
|
||||
</form>
|
||||
<div>
|
||||
<a class="btn" href="{{ url_for('download_template', kind='materials') }}">⬇ 下载模板</a>
|
||||
<button class="primary" type="button" onclick="openBatch()">⊞ 批量导入窗口</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="tbl">
|
||||
<thead>
|
||||
<tr><th>物料编号</th><th>型号 / 规格</th><th>单位</th><th>安全库存</th><th>在库</th><th>入库总数量</th><th>消耗总数量</th><th>操作</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for m in mats %}
|
||||
<tr>
|
||||
<td>{{ m.part_number }}</td>
|
||||
<td>{{ m.manufacturer or '-' }}</td>
|
||||
<td>{{ m.unit }}</td>
|
||||
<td class="num">{{ '%.2f'|format(m.safety_stock or 0) }}</td>
|
||||
<td class="num">{{ '%.2f'|format(sm.get(m.id, 0)) }}</td>
|
||||
<td class="num">{{ '%.2f'|format(inb_total.get(m.id, 0)) }}</td>
|
||||
<td class="num">{{ '%.2f'|format(out_total.get(m.id, 0)) }}</td>
|
||||
<td>
|
||||
<a href="{{ url_for('material_detail', mid=m.id) }}">明细</a>
|
||||
<a href="{{ url_for('material_edit', mid=m.id) }}">编辑</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="8" class="muted">没有匹配的物料</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- 批量导入窗口(模态) -->
|
||||
<div class="modal-mask" id="batchMask" style="display:none">
|
||||
<div class="modal">
|
||||
<div class="modal-head">
|
||||
<span>批量导入物料 · 多行清单</span>
|
||||
<button class="x" onclick="closeBatch()">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="small muted">每行一条: <b>物料编号 型号/规格 单位 安全库存(可选) 期初库存(可选)</b>,列之间用空格或制表符分隔。也可直接上传 Excel/CSV。</p>
|
||||
<textarea id="batchText" rows="9" placeholder="P0001 示例型号A 个 100 500
|
||||
P0002 示例型号B 卷 50 200"></textarea>
|
||||
<div class="row-actions">
|
||||
<button type="button" onclick="parsePaste()">解析预览</button>
|
||||
<label class="btn" style="cursor:pointer">📁 上传文件
|
||||
<input type="file" id="batchFile" accept=".xlsx,.csv" style="display:none" onchange="parseFile()">
|
||||
</label>
|
||||
<button type="button" class="primary" id="commitBtn" onclick="commitBatch()" disabled>确认导入</button>
|
||||
</div>
|
||||
<div id="batchResult" style="margin-top:12px"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let lastRows = [];
|
||||
|
||||
function openBatch(){ document.getElementById('batchMask').style.display='flex'; }
|
||||
function closeBatch(){ document.getElementById('batchMask').style.display='none'; }
|
||||
document.getElementById('batchMask').addEventListener('click', e=>{ if(e.target.id==='batchMask') closeBatch(); });
|
||||
|
||||
function parsePaste(){
|
||||
const text = document.getElementById('batchText').value.trim();
|
||||
if(!text){ alert('请先粘贴清单'); return; }
|
||||
postPreview({text: text});
|
||||
}
|
||||
function parseFile(){
|
||||
const f = document.getElementById('batchFile').files[0];
|
||||
if(!f) return;
|
||||
const fd = new FormData();
|
||||
fd.append('file', f);
|
||||
fetch('/api/import/materials/preview', {method:'POST', body:fd})
|
||||
.then(r=>r.json()).then(renderPreview).catch(e=>alert('解析失败: '+e));
|
||||
}
|
||||
function postPreview(payload){
|
||||
fetch('/api/import/materials/preview', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify(payload)
|
||||
}).then(r=>r.json()).then(renderPreview).catch(e=>alert('解析失败: '+e));
|
||||
}
|
||||
function renderPreview(data){
|
||||
lastRows = data.rows || [];
|
||||
const valid = data.valid, invalid = data.invalid;
|
||||
let html = `<div class="flash ${invalid? 'error':'ok'}">共 ${lastRows.length} 行 · 可导入 <b>${valid}</b> 行 · 异常 <b>${invalid}</b> 行</div>`;
|
||||
html += '<table class="tbl"><thead><tr><th>物料编号</th><th>型号</th><th>单位</th><th>安全库存</th><th>期初库存</th><th>校验</th></tr></thead><tbody>';
|
||||
lastRows.forEach(r=>{
|
||||
const cls = r.ok? '' : 'row-danger';
|
||||
const tag = r.ok? (r.exists? '<span class="badge warn">更新</span>' : '<span class="badge ok">新增</span>')
|
||||
: `<span class="badge danger">${r.error||'错误'}</span>`;
|
||||
html += `<tr class="${cls}"><td>${r.part_number||''}</td><td>${r.manufacturer||'-'}</td><td>${r.unit||'个'}</td><td class="num">${r.safety_stock}</td><td class="num">${r.opening_stock}</td><td>${tag}</td></tr>`;
|
||||
});
|
||||
html += '</tbody></table>';
|
||||
document.getElementById('batchResult').innerHTML = html;
|
||||
document.getElementById('commitBtn').disabled = valid===0;
|
||||
}
|
||||
function commitBatch(){
|
||||
if(!lastRows.length) return;
|
||||
fetch('/api/import/materials/commit', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({rows: lastRows})
|
||||
}).then(r=>r.json()).then(d=>{
|
||||
alert(`导入完成: 新增 ${d.added} 条, 更新 ${d.updated} 条`);
|
||||
if(d.added+d.updated>0) location.reload();
|
||||
}).catch(e=>alert('导入失败: '+e));
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,38 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}订单 {{ o.order_no }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="toolbar">
|
||||
<h3 style="margin:0">生产订单 {{ o.order_no }}</h3>
|
||||
<span class="badge {{ 'ok' if o.status=='picked' else 'warn' }}">{{ '已领料' if o.status=='picked' else '待领料' }}</span>
|
||||
</div>
|
||||
<div class="card" style="margin-bottom:16px">
|
||||
<p>BOM: <b>{{ bom.code if bom else '-' }}</b> ({{ bom.name if bom else '' }}) | 接单量: <b>{{ '%.2f'|format(o.qty) }}</b> | 经手人: {{ o.operator or '-' }}</p>
|
||||
</div>
|
||||
|
||||
<h3>领料需求 (单套用量 × 接单量)</h3>
|
||||
<table class="tbl">
|
||||
<thead><tr><th>物料编号</th><th>型号</th><th>需求数量</th><th>当前库存</th><th>缺口</th><th>状态</th></tr></thead>
|
||||
<tbody>
|
||||
{% for r in reqs %}
|
||||
<tr class="{{ 'row-danger' if r.short else ('row-warn' if r.have < (r.m.safety_stock or 0)) }}">
|
||||
<td>{{ r.m.part_number }}</td>
|
||||
<td>{{ r.m.manufacturer or '-' }}</td>
|
||||
<td class="num">{{ '%.2f'|format(r.need) }}</td>
|
||||
<td class="num">{{ '%.2f'|format(r.have) }}</td>
|
||||
<td class="num">{{ '%.2f'|format(r.gap) }}</td>
|
||||
<td><span class="badge {{ 'danger' if r.short else 'ok' }}">{{ '缺料' if r.short else '充足' }}</span></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="6" class="muted">该 BOM 没有组成物料</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% if o.status != 'picked' %}
|
||||
<form method="post" action="{{ url_for('order_pick', oid=o.id) }}" onsubmit="return confirm('确认按 BOM 生成领料出库? 库存不足将记为负并在看板预警。');">
|
||||
<div class="row-actions"><button class="primary">确认领料出库</button></div>
|
||||
</form>
|
||||
{% else %}
|
||||
<p class="flash ok">该订单已领料出库。</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,20 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}新建生产订单{% endblock %}
|
||||
{% block content %}
|
||||
<form method="post" class="card form-card" style="max-width:520px">
|
||||
<h3>新建生产订单</h3>
|
||||
<label>订单号 (留空自动生成)</label>
|
||||
<input name="order_no" placeholder="如 WO20260911xxx">
|
||||
<label>BOM 型号 *</label>
|
||||
<select name="bom_id" required>
|
||||
<option value="">-- 选择 BOM --</option>
|
||||
{% for b in boms %}
|
||||
<option value="{{ b.id }}">{{ b.code }} ({{ b.name or '-' }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<label>接单量 (台/套) *</label>
|
||||
<input name="qty" type="number" step="0.01" min="0.01" required>
|
||||
<div class="row-actions"><button class="primary">生成订单</button></div>
|
||||
<p class="small muted">生成后可在订单详情页按 BOM 展开领料需求, 并一键"确认领料"出库。</p>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,25 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}生产订单{% endblock %}
|
||||
{% block content %}
|
||||
<div class="toolbar">
|
||||
<h3 style="margin:0">生产订单 ({{ data|length }})</h3>
|
||||
<a class="btn primary" href="{{ url_for('order_new') }}">+ 新建订单</a>
|
||||
</div>
|
||||
<table class="tbl">
|
||||
<thead><tr><th>订单号</th><th>BOM</th><th>接单量</th><th>状态</th><th>经手人</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
{% for d in data %}
|
||||
<tr class="{{ 'row-danger' if d.o.status=='picked' and false }}">
|
||||
<td>{{ d.o.order_no }}</td>
|
||||
<td>{{ d.bom.code if d.bom else '-' }} ({{ d.bom.name if d.bom else '' }})</td>
|
||||
<td class="num">{{ '%.2f'|format(d.o.qty) }}</td>
|
||||
<td><span class="badge {{ 'ok' if d.o.status=='picked' else 'warn' }}">{{ '已领料' if d.o.status=='picked' else '待领料' }}</span></td>
|
||||
<td>{{ d.o.operator or '-' }}</td>
|
||||
<td><a href="{{ url_for('order_detail', oid=d.o.id) }}">查看</a></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="6" class="muted">还没有生产订单</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,31 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}库存报表{% endblock %}
|
||||
{% block content %}
|
||||
<div class="toolbar no-print">
|
||||
<h3 style="margin:0">库存报表 ({{ today }})</h3>
|
||||
<div>
|
||||
<button class="btn" onclick="window.print()">🖨 打印</button>
|
||||
<a class="btn primary" href="{{ url_for('export_inventory') }}">⬇ 导出 Excel</a>
|
||||
</div>
|
||||
</div>
|
||||
<table class="tbl">
|
||||
<thead><tr><th>物料编号</th><th>型号/规格</th><th>单位</th><th>在库数量</th><th>安全库存</th><th>状态</th></tr></thead>
|
||||
<tbody>
|
||||
{% for r in rows %}
|
||||
<tr class="{{ 'row-danger' if r.status=='danger' else ('row-warn' if r.status=='warn') }}">
|
||||
<td>{{ r.m.part_number }}</td>
|
||||
<td>{{ r.m.manufacturer or '-' }}</td>
|
||||
<td>{{ r.m.unit }}</td>
|
||||
<td class="num">{{ '%.2f'|format(r.stock) }}</td>
|
||||
<td class="num">{{ '%.2f'|format(r.m.safety_stock or 0) }}</td>
|
||||
<td><span class="badge {{ r.status }}">{{ '库存为负' if r.status=='danger' else ('低于安全库存' if r.status=='warn' else '正常') }}</span></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<style media="print">
|
||||
.topbar, .no-print { display: none !important; }
|
||||
body { background:#fff; }
|
||||
.tbl { box-shadow:none; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,167 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ '入库登记' if kind=='inbound' else '出库登记' }}{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<div class="toolbar">
|
||||
<h3 style="margin:0">{{ '入库登记' if kind=='inbound' else '出库登记' }}</h3>
|
||||
<div>
|
||||
<a class="btn" href="{{ url_for('download_template', kind='transactions') }}">⬇ 下载批量模板</a>
|
||||
<button class="primary" type="button" onclick="openBatch()">⊞ 批量导入窗口</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" class="card form-card txn-form">
|
||||
<label>物料 *</label>
|
||||
<select name="material_id" required>
|
||||
<option value="">-- 选择物料 --</option>
|
||||
{% for m in mats %}
|
||||
<option value="{{ m.id }}">{{ m.part_number }} ({{ m.manufacturer or '-' }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<label>批次号</label>
|
||||
<input name="batch_no">
|
||||
<label>数量 *</label>
|
||||
<input name="qty" type="number" step="0.01" min="0.01" required>
|
||||
<label>经手人</label>
|
||||
<input name="operator">
|
||||
{% if kind=='inbound' %}
|
||||
<label>来源 / 供应商</label>
|
||||
<input name="source">
|
||||
{% else %}
|
||||
<label>用途</label>
|
||||
<input name="purpose">
|
||||
<label>关联单号</label>
|
||||
<input name="order_ref">
|
||||
{% endif %}
|
||||
<label>业务日期</label>
|
||||
<input name="occur_date" type="date" value="{{ today }}">
|
||||
<label>备注</label>
|
||||
<input name="remark">
|
||||
<div class="row-actions">
|
||||
<button class="primary">{{ '入库' if kind=='inbound' else '出库' }}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<h3>最近 {{ recs|length }} 条{{ '入库' if kind=='inbound' else '出库' }}记录</h3>
|
||||
<table class="tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>物料</th><th>批次</th><th>数量</th><th>经手人</th>
|
||||
{% if kind=='inbound' %}<th>来源</th>{% else %}<th>用途</th><th>单号</th>{% endif %}
|
||||
<th>日期</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in recs %}
|
||||
{% set m = mats|selectattr('id','equalto',r.material_id)|list|first %}
|
||||
<tr>
|
||||
<td>{{ m.part_number if m else r.material_id }}</td>
|
||||
<td>{{ r.batch_no or '-' }}</td>
|
||||
<td class="num">{{ '%.2f'|format(r.qty) }}</td>
|
||||
<td>{{ r.operator or '-' }}</td>
|
||||
{% if kind=='inbound' %}<td>{{ r.source or '-' }}</td>
|
||||
{% else %}<td>{{ r.purpose or '-' }}</td><td>{{ r.order_ref or '-' }}</td>{% endif %}
|
||||
<td>{{ r.occur_date }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="7" class="muted">暂无记录</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- 批量导入窗口(模态) -->
|
||||
<div class="modal-mask" id="batchMask" style="display:none">
|
||||
<div class="modal">
|
||||
<div class="modal-head">
|
||||
<span>批量导入{{ '入库' if kind=='inbound' else '出库' }} · 多行清单</span>
|
||||
<button class="x" onclick="closeBatch()">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="small muted">每行一条: <b>物料编号 数量 批次(可选) 日期(可选) 备注(可选)</b>,列之间用空格或制表符分隔。也可直接上传 Excel/CSV。</p>
|
||||
<textarea id="batchText" rows="8" placeholder="P0001 100 B20260911 2026-09-11 到货A
|
||||
P0002 50 2026-09-11 到货B"></textarea>
|
||||
<div class="row-actions">
|
||||
<button type="button" onclick="parsePaste()">解析预览</button>
|
||||
<label class="btn" style="cursor:pointer">📁 上传文件
|
||||
<input type="file" id="batchFile" accept=".xlsx,.csv" style="display:none" onchange="parseFile()">
|
||||
</label>
|
||||
<button type="button" class="primary" id="commitBtn" onclick="commitBatch()" disabled>确认导入</button>
|
||||
</div>
|
||||
<div id="batchResult" style="margin-top:12px"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const TXN_KIND = "{{ kind }}";
|
||||
let lastRows = [];
|
||||
|
||||
function openBatch(){ document.getElementById('batchMask').style.display='flex'; }
|
||||
function closeBatch(){ document.getElementById('batchMask').style.display='none'; }
|
||||
document.getElementById('batchMask').addEventListener('click', e=>{ if(e.target.id==='batchMask') closeBatch(); });
|
||||
|
||||
function splitLine(line){
|
||||
const s = line.replace(/[\t,,;;]+/g,' ').trim();
|
||||
if(!s) return [];
|
||||
return s.split(/\s+/);
|
||||
}
|
||||
function parsePaste(){
|
||||
const text = document.getElementById('batchText').value.trim();
|
||||
if(!text){ alert('请先粘贴清单'); return; }
|
||||
const rows = [];
|
||||
text.split(/\r?\n/).forEach(raw=>{
|
||||
const parts = splitLine(raw);
|
||||
if(!parts.length) return;
|
||||
const first = parts[0];
|
||||
if(/物料|编号|part\s?number|数量|qty/i.test(first)) return; // 跳过表头
|
||||
const pn = parts[0];
|
||||
const qty = parseFloat(parts[1]);
|
||||
const batch = parts[2] || '';
|
||||
const date = parts[3] || '';
|
||||
const remark = parts.slice(4).join(' ');
|
||||
rows.push({part_number:pn, qty:isNaN(qty)?0:qty, batch_no:batch, date:date, remark:remark});
|
||||
});
|
||||
if(!rows.length){ alert('未解析到有效行'); return; }
|
||||
postPreview({kind:TXN_KIND, rows:rows});
|
||||
}
|
||||
function parseFile(){
|
||||
const f = document.getElementById('batchFile').files[0];
|
||||
if(!f) return;
|
||||
const fd = new FormData();
|
||||
fd.append('file', f);
|
||||
fd.append('kind', TXN_KIND);
|
||||
fetch('/api/import/transactions/preview', {method:'POST', body:fd})
|
||||
.then(r=>r.json()).then(renderPreview).catch(e=>alert('解析失败: '+e));
|
||||
}
|
||||
function postPreview(payload){
|
||||
fetch('/api/import/transactions/preview', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify(payload)
|
||||
}).then(r=>r.json()).then(renderPreview).catch(e=>alert('解析失败: '+e));
|
||||
}
|
||||
function renderPreview(data){
|
||||
lastRows = data.rows || [];
|
||||
const valid = data.valid, invalid = data.invalid;
|
||||
let html = `<div class="flash ${invalid? 'error':'ok'}">共 ${lastRows.length} 行 · 可导入 <b>${valid}</b> 行 · 异常 <b>${invalid}</b> 行</div>`;
|
||||
html += '<table class="tbl"><thead><tr><th>物料编号</th><th>数量</th><th>批次</th><th>日期</th><th>备注</th><th>校验</th></tr></thead><tbody>';
|
||||
lastRows.forEach(r=>{
|
||||
const cls = r.ok? '' : 'row-danger';
|
||||
const tag = r.ok? '<span class="badge ok">OK</span>' : `<span class="badge danger">${r.error||'错误'}</span>`;
|
||||
html += `<tr class="${cls}"><td>${r.part_number}</td><td class="num">${r.qty}</td><td>${r.batch_no||'-'}</td><td>${r.date||'-'}</td><td>${r.remark||'-'}</td><td>${tag}</td></tr>`;
|
||||
});
|
||||
html += '</tbody></table>';
|
||||
document.getElementById('batchResult').innerHTML = html;
|
||||
document.getElementById('commitBtn').disabled = valid===0;
|
||||
}
|
||||
function commitBatch(){
|
||||
if(!lastRows.length) return;
|
||||
fetch('/api/import/transactions/commit', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({kind:TXN_KIND, rows:lastRows})
|
||||
}).then(r=>r.json()).then(d=>{
|
||||
alert(`导入完成: 成功 ${d.added} 条${d.skipped? ',跳过 '+d.skipped+' 条':''}`);
|
||||
if(d.added>0) location.reload();
|
||||
}).catch(e=>alert('导入失败: '+e));
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,42 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}用户管理{% endblock %}
|
||||
{% block content %}
|
||||
<h3>用户列表</h3>
|
||||
<table class="tbl">
|
||||
<thead><tr><th>用户名</th><th>姓名</th><th>角色</th><th>状态</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
{% for u in us %}
|
||||
<tr>
|
||||
<td>{{ u.username }}</td>
|
||||
<td>{{ u.display_name or '-' }}</td>
|
||||
<td><span class="badge ok">{{ u.role }}</span></td>
|
||||
<td>{{ '启用' if u.active else '停用' }}</td>
|
||||
<td>
|
||||
{% if u.id != g.user.id %}
|
||||
<form method="post" action="{{ url_for('user_toggle', uid=u.id) }}" style="display:inline">
|
||||
<button class="link">{{ '停用' if u.active else '启用' }}</button>
|
||||
</form>
|
||||
{% else %}<span class="muted">当前账号</span>{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>新增用户</h3>
|
||||
<form method="post" action="{{ url_for('user_add') }}" class="card form-card" style="max-width:480px">
|
||||
<label>用户名 *</label>
|
||||
<input name="username" required>
|
||||
<label>姓名</label>
|
||||
<input name="display_name">
|
||||
<label>密码 *</label>
|
||||
<input name="password" type="password" required>
|
||||
<label>角色</label>
|
||||
<select name="role">
|
||||
<option value="operator">operator (录入/出入库)</option>
|
||||
<option value="manager">manager (物料/BOM/订单/报表/日志)</option>
|
||||
<option value="admin">admin (含用户管理)</option>
|
||||
</select>
|
||||
<div class="row-actions"><button class="primary">添加</button></div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,59 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""EXE 启动 / 清理公共工具。
|
||||
|
||||
背景(踩坑记录):
|
||||
PyInstaller `--onefile --windowed` 的启动器(runw.exe)会**派生子进程**运行真正的
|
||||
Flask 服务器。若只 `p.terminate()` 父进程,子进程会残留并继续霸占 8080;
|
||||
Windows 允许同一端口被多个 socket 绑定,于是后续请求会被**随机路由到旧实例**,
|
||||
表现为: 随机 500、改了模板/代码"不生效"、时好时坏的返回内容。
|
||||
|
||||
正确做法:
|
||||
1) 启动前按可执行文件路径清掉 dist 下所有 EXE(兼容 *.old.exe / _prev_*.exe 等改名备份);
|
||||
2) 收尾用 `taskkill /F /T /PID` 杀掉**整棵进程树**,再兜底按路径清一次。
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
EXE = r"D:\2026-09-10-task-20\material_wms\dist\MaterialWMS.exe"
|
||||
|
||||
|
||||
def kill_dist_exes(exe_path=EXE):
|
||||
"""杀掉 exe_path 所在目录下的所有 EXE 进程(按路径匹配任意文件名)。"""
|
||||
dist = os.path.dirname(exe_path)
|
||||
ps = (
|
||||
"$d = '%s'; "
|
||||
"Get-CimInstance Win32_Process | "
|
||||
"Where-Object { $_.ExecutablePath -like ($d + '\\*.exe') } | "
|
||||
"ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }"
|
||||
) % dist
|
||||
try:
|
||||
subprocess.run(["powershell", "-NoProfile", "-Command", ps],
|
||||
capture_output=True, timeout=30)
|
||||
except Exception as e:
|
||||
print(" [warn] kill_dist_exes:", e)
|
||||
|
||||
|
||||
def start_exe(exe_path=EXE):
|
||||
"""清残留 -> 启动 EXE -> 返回 Popen(用完务必 stop_exe)。"""
|
||||
kill_dist_exes(exe_path)
|
||||
time.sleep(1)
|
||||
return subprocess.Popen([exe_path])
|
||||
|
||||
|
||||
def stop_exe(p):
|
||||
"""杀掉整棵进程树并兜底清理(幂等, 传入 None 安全)。"""
|
||||
if p is not None:
|
||||
try:
|
||||
subprocess.run(["taskkill", "/F", "/T", "/PID", str(p.pid)],
|
||||
capture_output=True, timeout=15)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
p.wait(timeout=5)
|
||||
except Exception:
|
||||
try:
|
||||
p.kill()
|
||||
except Exception:
|
||||
pass
|
||||
kill_dist_exes()
|
||||
@@ -0,0 +1,89 @@
|
||||
"""验证打包后的 EXE 包含批量导入窗口功能并端到端可用。"""
|
||||
import sqlite3, time, subprocess, sys, urllib.request, urllib.parse, json, os
|
||||
import atexit
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from _exe_util import start_exe, stop_exe
|
||||
|
||||
EXE = r"D:\2026-09-10-task-20\material_wms\dist\MaterialWMS.exe"
|
||||
BASE = "http://127.0.0.1:8080"
|
||||
|
||||
# 取一条真实物料编号
|
||||
conn = sqlite3.connect(r"D:\2026-09-10-task-20\material_wms\dist\app.db")
|
||||
pn = conn.execute("select part_number from material limit 1").fetchone()[0]
|
||||
conn.close()
|
||||
print("使用物料编号:", pn)
|
||||
|
||||
print("启动 EXE ...")
|
||||
p = start_exe(EXE)
|
||||
atexit.register(stop_exe, p)
|
||||
for _ in range(40):
|
||||
try:
|
||||
urllib.request.urlopen(BASE + "/login", timeout=2)
|
||||
break
|
||||
except Exception:
|
||||
time.sleep(0.5)
|
||||
else:
|
||||
print("ERROR: 服务未启动"); sys.exit(1)
|
||||
print("服务已就绪")
|
||||
|
||||
def post(path, data=None, raw=None, headers=None):
|
||||
if raw is not None:
|
||||
req = urllib.request.Request(BASE + path, data=raw, headers=headers or {}, method="POST")
|
||||
else:
|
||||
req = urllib.request.Request(BASE + path, data=urllib.parse.urlencode(data).encode(), method="POST")
|
||||
try:
|
||||
return urllib.request.urlopen(req, timeout=5)
|
||||
except urllib.error.HTTPError as e:
|
||||
return e
|
||||
|
||||
# 登录(保持 cookie)
|
||||
jar = {}
|
||||
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor())
|
||||
def get(path):
|
||||
return opener.open(BASE + path, timeout=5)
|
||||
def postj(path, obj):
|
||||
req = urllib.request.Request(BASE + path, data=json.dumps(obj).encode(),
|
||||
headers={"Content-Type": "application/json"}, method="POST")
|
||||
return opener.open(req, timeout=5)
|
||||
|
||||
# 登录
|
||||
login = urllib.parse.urlencode({"username": "admin", "password": "admin123"}).encode()
|
||||
opener.open(urllib.request.Request(BASE + "/login", data=login, method="POST"))
|
||||
|
||||
# 1. 入库页含批量导入窗口按钮
|
||||
html = get("/inbound").read().decode("utf-8", "ignore")
|
||||
assert "批量导入窗口" in html, "入库页缺少批量导入按钮"
|
||||
print("[OK] 入库页含「批量导入窗口」按钮")
|
||||
|
||||
# 2. 下载批量模板
|
||||
t = get("/template/transactions")
|
||||
assert t.status == 200, "模板下载失败"
|
||||
print("[OK] 批量导入模板下载 (HTTP %d)" % t.status)
|
||||
|
||||
# 3. 预览: 1 条有效(真实物料) + 1 条异常(物料不存在)
|
||||
prev = json.loads(postj("/api/import/transactions/preview", {
|
||||
"kind": "inbound",
|
||||
"rows": [
|
||||
{"part_number": pn, "qty": 25, "batch_no": "EXEBATCH", "date": "2026-09-11", "remark": "exe验证"},
|
||||
{"part_number": "NO_SUCH", "qty": 1},
|
||||
],
|
||||
}).read())
|
||||
assert prev["valid"] == 1 and prev["invalid"] == 1, prev
|
||||
print("[OK] 预览接口: 有效 %d / 异常 %d" % (prev["valid"], prev["invalid"]))
|
||||
|
||||
# 4. 提交: 应写入 1 条入库(另一条跳过)
|
||||
res = json.loads(postj("/api/import/transactions/commit", {
|
||||
"kind": "inbound", "rows": prev["rows"]}).read())
|
||||
assert res["added"] == 1 and res["skipped"] == 1, res
|
||||
print("[OK] 提交接口: 入库 %d 条 / 跳过 %d 条" % (res["added"], res["skipped"]))
|
||||
|
||||
# 5. 确认库里确有这条 EXEBATCH 入库
|
||||
conn = sqlite3.connect(r"D:\2026-09-10-task-20\material_wms\dist\app.db")
|
||||
cnt = conn.execute("select count(*) from inbound where batch_no='EXEBATCH'").fetchone()[0]
|
||||
conn.close()
|
||||
assert cnt == 1, "入库流水未写入"
|
||||
print("[OK] 数据库确认: EXEBATCH 入库流水已生成")
|
||||
|
||||
stop_exe(p)
|
||||
print("\nEXE 批量导入窗口 端到端验证通过 ✅")
|
||||
@@ -0,0 +1,158 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""EXE 端到端验证(聚焦真实 BOM 模板支持):
|
||||
1. 启动 MaterialWMS.exe
|
||||
2. 登录
|
||||
3. /boms 页面含 "批量导入窗口" + "Part Number" 真实格式说明
|
||||
4. /template/bom 下载模板, 表头含 Part Number / Quantity
|
||||
5. 生成多 sheet 真实格式 xlsx, POST /api/import/boms/preview (multipart) -> valid==2
|
||||
6. POST /api/import/boms/commit -> created_materials >= 2
|
||||
7. 校验 dist/app.db 中 Bom XM-A 存在
|
||||
"""
|
||||
import http.client, os, time, subprocess, sys, json
|
||||
from openpyxl import Workbook
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from _exe_util import kill_dist_exes, start_exe, stop_exe
|
||||
|
||||
HOST, PORT = "127.0.0.1", 8080
|
||||
EXE = r"D:\2026-09-10-task-20\material_wms\dist\MaterialWMS.exe"
|
||||
DB = r"D:\2026-09-10-task-20\material_wms\dist\app.db"
|
||||
|
||||
|
||||
def wait_port():
|
||||
for _ in range(60):
|
||||
try:
|
||||
c = http.client.HTTPConnection(HOST, PORT, timeout=2)
|
||||
c.request("GET", "/")
|
||||
r = c.getresponse(); r.read(); c.close()
|
||||
if r.status in (200, 302):
|
||||
return
|
||||
except Exception:
|
||||
time.sleep(1)
|
||||
raise RuntimeError("EXE 端口未起")
|
||||
|
||||
|
||||
def req(method, path, body=None, headers=None):
|
||||
c = http.client.HTTPConnection(HOST, PORT, timeout=15)
|
||||
c.request(method, path, body=body, headers=headers or {})
|
||||
r = c.getresponse()
|
||||
raw = r.read()
|
||||
setc = r.getheader("Set-Cookie")
|
||||
c.close()
|
||||
return r.status, raw, setc
|
||||
|
||||
|
||||
def post_file(path, file_path, cookie):
|
||||
with open(file_path, "rb") as f:
|
||||
data = f.read()
|
||||
boundary = "WMSBND"
|
||||
body = (
|
||||
f"--{boundary}\r\n"
|
||||
f'Content-Disposition: form-data; name="file"; filename="{os.path.basename(file_path)}"\r\n'
|
||||
f"Content-Type: application/octet-stream\r\n\r\n"
|
||||
).encode() + data + f"\r\n--{boundary}--\r\n".encode()
|
||||
hdrs = {"Content-Type": f"multipart/form-data; boundary={boundary}",
|
||||
"Content-Length": str(len(body))}
|
||||
if cookie:
|
||||
hdrs["Cookie"] = cookie
|
||||
return req("POST", path, body=body, headers=hdrs)
|
||||
|
||||
|
||||
def login(cookie):
|
||||
body = urllib_parse("username=admin&password=admin123")
|
||||
return req("POST", "/login", body=body,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Cookie": cookie or ""})
|
||||
|
||||
|
||||
def urllib_parse(s):
|
||||
import urllib.parse as up
|
||||
return up.urlencode(dict(up.parse_qsl(s))).encode()
|
||||
|
||||
|
||||
def main():
|
||||
# 1. 启动
|
||||
print("[1] 启动 EXE ...")
|
||||
p = start_exe(EXE)
|
||||
try:
|
||||
wait_port()
|
||||
print(" [OK] 端口就绪")
|
||||
|
||||
# 2. 登录
|
||||
_, _, setc = req("GET", "/login")
|
||||
cookie = setc.split(";")[0] if setc else ""
|
||||
_, _, setc2 = login(cookie)
|
||||
if setc2:
|
||||
cookie = setc2.split(";")[0]
|
||||
# 验证登录
|
||||
s, html, _ = req("GET", "/dashboard", headers={"Cookie": cookie})
|
||||
assert s == 200 and "库存看板" in html.decode("utf-8", "ignore"), "登录失败"
|
||||
print(" [OK] 登录")
|
||||
|
||||
# 3. BOM 页面含按钮 + 真实格式说明
|
||||
s, html, _ = req("GET", "/boms", headers={"Cookie": cookie})
|
||||
html = html.decode("utf-8", "ignore")
|
||||
if "批量导入窗口" not in html:
|
||||
print("[DEBUG] /boms status:", s)
|
||||
print("[DEBUG] HTML head:", html[:800])
|
||||
raise AssertionError("BOM 页缺按钮")
|
||||
assert "Part Number" in html, "BOM 页未说明真实格式"
|
||||
assert "嘉立创" in html or "Altium" in html, "BOM 页未提及 Altium/嘉立创"
|
||||
print(" [OK] BOM 页含按钮 + 真实格式说明")
|
||||
|
||||
# 4. 下载 BOM 模板
|
||||
s, raw, _ = req("GET", "/template/bom", headers={"Cookie": cookie})
|
||||
assert s == 200, f"模板下载 status={s}"
|
||||
tmpdir = os.path.join(os.path.dirname(DB), "_check_tmp")
|
||||
os.makedirs(tmpdir, exist_ok=True)
|
||||
tpath = os.path.join(tmpdir, "_tpl_bom_check.xlsx")
|
||||
with open(tpath, "wb") as f: f.write(raw)
|
||||
from openpyxl import load_workbook
|
||||
tws = load_workbook(tpath).active
|
||||
hdr = [c.value for c in tws[1]]
|
||||
assert "Part Number" in hdr and "Quantity" in hdr, hdr
|
||||
print(f" [OK] BOM 模板表头: {hdr}")
|
||||
|
||||
# 5. 生成多 sheet 真实格式 xlsx
|
||||
wb = Workbook()
|
||||
ws1 = wb.active; ws1.title = "XM-A"
|
||||
ws1.append(["Item", "Part Number", "Manufacture Part Number", "Quantity", "Reference", "Part", "Package"])
|
||||
ws1.append([1, "EXA100", "MFA", 2, "U1", "MFA", "PKGA"])
|
||||
ws1.append([2, "EXA101", "MFB", 1, "U2", "MFB", "PKGB"])
|
||||
ws2 = wb.create_sheet("组装")
|
||||
ws2.append(["序号", "层级", "物料代码", "描述", "数量", "材质"])
|
||||
ws2.append([1, "0.1", "EXA200", "外壳", 1, "ABS"])
|
||||
up = os.path.join(tmpdir, "_bom_check.xlsx")
|
||||
wb.save(up)
|
||||
|
||||
# 6. 上传预览
|
||||
s, raw, _ = post_file("/api/import/boms/preview", up, cookie)
|
||||
pv = json.loads(raw.decode("utf-8", "ignore"))
|
||||
codes = [b["code"] for b in pv["boms"]]
|
||||
assert "XM-A" in codes and "组装" in codes, pv
|
||||
assert pv["valid"] == 2, pv
|
||||
print(f" [OK] 上传预览: 解析 {len(codes)} 个 BOM, valid={pv['valid']}")
|
||||
|
||||
# 7. 提交
|
||||
body = json.dumps({"boms": pv["boms"]}).encode()
|
||||
s, raw, _ = req("POST", "/api/import/boms/commit", body=body,
|
||||
headers={"Content-Type": "application/json", "Cookie": cookie})
|
||||
rs = json.loads(raw.decode("utf-8", "ignore"))
|
||||
assert rs["boms"] == 2 and rs["created_materials"] >= 2, rs
|
||||
print(f" [OK] 提交: boms={rs['boms']} added={rs['added_items']} created_materials={rs['created_materials']}")
|
||||
|
||||
# 8. 校验数据库
|
||||
import sqlite3
|
||||
con = sqlite3.connect(DB)
|
||||
n = con.execute("select count(*) from bom where code in ('XM-A','组装')").fetchone()[0]
|
||||
con.close()
|
||||
assert n >= 2, f"DB 中 BOM 不足: {n}"
|
||||
print(f" [OK] 数据库确认: BOM 表新增 {n} 条")
|
||||
|
||||
print("\nEXE 真实 BOM 模板 端到端验证通过")
|
||||
finally:
|
||||
stop_exe(p)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,65 @@
|
||||
"""验证打包后的 EXE: 等端口 -> 登录 -> 访问关键页面 -> Excel 导出
|
||||
用法: 先手动启动 dist/MaterialWMS.exe, 再 python tools/check_exe.py
|
||||
"""
|
||||
import sys, time, urllib.request, urllib.parse
|
||||
|
||||
BASE = "http://127.0.0.1:8080"
|
||||
|
||||
def wait_port(t=40):
|
||||
for i in range(t):
|
||||
try:
|
||||
urllib.request.urlopen(BASE + "/login", timeout=2)
|
||||
return True
|
||||
except Exception:
|
||||
time.sleep(1)
|
||||
return False
|
||||
|
||||
def get(path, cookie=None):
|
||||
req = urllib.request.Request(BASE + path)
|
||||
if cookie:
|
||||
req.add_header("Cookie", cookie)
|
||||
return urllib.request.urlopen(req, timeout=5)
|
||||
|
||||
def post(path, data, cookie=None):
|
||||
req = urllib.request.Request(BASE + path, data=urllib.parse.urlencode(data).encode(), method="POST")
|
||||
if cookie:
|
||||
req.add_header("Cookie", cookie)
|
||||
return urllib.request.urlopen(req, timeout=5)
|
||||
|
||||
def main():
|
||||
if not wait_port():
|
||||
print("[FAIL] 端口 8080 未就绪 (EXE 可能未启动或解压慢)")
|
||||
sys.exit(1)
|
||||
print("[OK] EXE 服务已就绪")
|
||||
|
||||
# 登录页
|
||||
r = get("/login")
|
||||
assert "登录" in r.read().decode("utf-8", "ignore")
|
||||
print("[OK] 登录页(冻结模板加载正常)")
|
||||
|
||||
# 登录
|
||||
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor())
|
||||
req = urllib.request.Request(BASE + "/login",
|
||||
data=urllib.parse.urlencode({"username": "admin", "password": "admin123"}).encode(),
|
||||
method="POST")
|
||||
resp = opener.open(req)
|
||||
body = resp.read().decode("utf-8", "ignore")
|
||||
assert "库存看板" in body, "登录后未进入看板"
|
||||
print("[OK] 管理员登录成功, 看板渲染正常")
|
||||
|
||||
# 关键页面(冻结模板 + 路由)
|
||||
for p in ["/boms", "/orders", "/report", "/logs", "/users", "/import/materials", "/template/bom"]:
|
||||
rr = opener.open(BASE + p)
|
||||
assert rr.status == 200
|
||||
print("[OK] BOM/订单/报表/日志/用户/导入/模板 页面均可访问")
|
||||
|
||||
# 导出 Excel
|
||||
rr = opener.open(BASE + "/export/inventory")
|
||||
data = rr.read()
|
||||
assert rr.status == 200 and len(data) > 1000
|
||||
print(f"[OK] 库存 Excel 导出成功 ({len(data)} 字节)")
|
||||
|
||||
print("\nEXE 端到端验证通过 ✅")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,104 @@
|
||||
"""EXE 端到端验证: 物料 / BOM 批量导入窗口
|
||||
|
||||
用 urllib + cookiejar 维持会话, 对 http://127.0.0.1:8080 实测:
|
||||
1) /materials 含"批量导入窗口"按钮
|
||||
2) 物料预览(粘贴) -> 校验 -> 提交
|
||||
3) /boms 含"批量导入窗口"按钮
|
||||
4) BOM 多 BOM 预览 -> 校验 -> 提交
|
||||
5) 数据库确认新物料 / 新 BOM 已落库
|
||||
"""
|
||||
import sys, time, json, urllib.request, urllib.parse, http.cookiejar
|
||||
|
||||
BASE = "http://127.0.0.1:8080"
|
||||
|
||||
def wait_port(timeout=30):
|
||||
end = time.time() + timeout
|
||||
while time.time() < end:
|
||||
try:
|
||||
urllib.request.urlopen(BASE + "/login", timeout=2)
|
||||
return True
|
||||
except Exception:
|
||||
time.sleep(0.5)
|
||||
return False
|
||||
|
||||
def post(cj, url, data=None, is_json=False, headers=None):
|
||||
req = urllib.request.Request(url)
|
||||
if is_json:
|
||||
req.add_header("Content-Type", "application/json")
|
||||
body = json.dumps(data).encode("utf-8")
|
||||
elif data is not None:
|
||||
req.add_header("Content-Type", "application/x-www-form-urlencoded")
|
||||
body = urllib.parse.urlencode(data).encode("utf-8")
|
||||
else:
|
||||
body = None
|
||||
if headers:
|
||||
for k, v in headers.items():
|
||||
req.add_header(k, v)
|
||||
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj))
|
||||
return opener.open(req, data=body, timeout=10)
|
||||
|
||||
def main():
|
||||
cj = http.cookiejar.CookieJar()
|
||||
assert wait_port(), "服务端口未就绪"
|
||||
print("[OK] 服务已启动可访问")
|
||||
|
||||
# 登录
|
||||
r = post(cj, BASE + "/login", {"username": "admin", "password": "admin123"})
|
||||
assert "库存看板" in r.read().decode("utf-8", "ignore") or r.status == 200
|
||||
print("[OK] 登录成功")
|
||||
|
||||
# 物料页含批量按钮
|
||||
r = post(cj, BASE + "/materials")
|
||||
assert "批量导入窗口" in r.read().decode("utf-8", "ignore")
|
||||
print("[OK] 物料页含批量导入窗口按钮")
|
||||
|
||||
# 物料预览(粘贴 2 条新 + 1 条重复)
|
||||
text = "MTEST1 批量料A 个 10 200\nMTEST2 批量料B 卷 5 50\nMTEST1 重复料 个 1 1"
|
||||
r = post(cj, BASE + "/api/import/materials/preview",
|
||||
{"text": text}, is_json=True)
|
||||
mp = json.loads(r.read())
|
||||
assert mp["valid"] == 2 and mp["invalid"] == 1, mp
|
||||
print(f"[OK] 物料预览: 可导入 {mp['valid']} / 异常 {mp['invalid']}")
|
||||
|
||||
r = post(cj, BASE + "/api/import/materials/commit",
|
||||
{"rows": mp["rows"]}, is_json=True)
|
||||
mc = json.loads(r.read())
|
||||
assert mc["added"] == 2, mc
|
||||
print(f"[OK] 物料提交: 新增 {mc['added']} 条")
|
||||
|
||||
# BOM 页含批量按钮
|
||||
r = post(cj, BASE + "/boms")
|
||||
assert "批量导入窗口" in r.read().decode("utf-8", "ignore")
|
||||
print("[OK] BOM 页含批量导入窗口按钮")
|
||||
|
||||
# BOM 预览(2 个 BOM, 其中一个含不存在物料)
|
||||
btext = "#BOM PROMOTE1 推广品A\nMTEST1 2\nMTEST2 0.5\nGHOST 3\n#BOM PROMOTE2 推广品B\nMTEST1 4"
|
||||
r = post(cj, BASE + "/api/import/boms/preview", {"text": btext}, is_json=True)
|
||||
bp = json.loads(r.read())
|
||||
assert bp["valid"] == 2 and bp["invalid"] == 0, bp
|
||||
print(f"[OK] BOM 预览: 可导入 {bp['valid']} 个 BOM")
|
||||
|
||||
r = post(cj, BASE + "/api/import/boms/commit", {"boms": bp["boms"]}, is_json=True)
|
||||
bc = json.loads(r.read())
|
||||
assert bc["boms"] == 2 and bc["added_items"] == 3, bc
|
||||
print(f"[OK] BOM 提交: {bc['boms']} 个 BOM / 成功项 {bc['added_items']}")
|
||||
|
||||
# 数据库确认落库
|
||||
import sqlite3
|
||||
dbp = r"D:\2026-09-10-task-20\material_wms\dist\app.db"
|
||||
c = sqlite3.connect(dbp)
|
||||
mats = c.execute("select count(*) from material where part_number in ('MTEST1','MTEST2')").fetchone()[0]
|
||||
boms = c.execute("select count(*) from bom where code in ('PROMOTE1','PROMOTE2')").fetchone()[0]
|
||||
c.close()
|
||||
assert mats == 2, f"物料落库数={mats}"
|
||||
assert boms == 2, f"BOM落库数={boms}"
|
||||
print(f"[OK] 数据库确认: 新物料 {mats} 条 / 新 BOM {boms} 个 已落库")
|
||||
|
||||
print("\nEXE 物料/BOM 批量导入窗口 端到端验证通过 ✅")
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except Exception as e:
|
||||
print("验证失败:", e)
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,43 @@
|
||||
"""真实 HTTP 启动检查: 等待端口 -> 登录 -> 看板渲染"""
|
||||
import urllib.request, urllib.parse, http.cookiejar, time, sys
|
||||
|
||||
BASE = "http://127.0.0.1:8080"
|
||||
cj = http.cookiejar.CookieJar()
|
||||
op = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj))
|
||||
|
||||
|
||||
def get(path):
|
||||
return op.open(BASE + path, timeout=5).read().decode("utf-8", "ignore")
|
||||
|
||||
|
||||
# 等待服务起来
|
||||
for i in range(20):
|
||||
try:
|
||||
html = get("/login")
|
||||
if "物料入库系统" in html:
|
||||
print("[OK] 服务已启动, 登录页正常")
|
||||
break
|
||||
except Exception:
|
||||
time.sleep(0.5)
|
||||
else:
|
||||
print("[FAIL] 服务未在 10s 内就绪")
|
||||
sys.exit(1)
|
||||
|
||||
# 登录
|
||||
data = urllib.parse.urlencode({"username": "admin", "password": "admin123"}).encode()
|
||||
op.open(urllib.request.Request(BASE + "/login", data=data, method="POST"))
|
||||
html = get("/dashboard")
|
||||
assert "库存看板" in html, "看板未渲染"
|
||||
print("[OK] 登录后看板渲染正常")
|
||||
|
||||
# 物料页
|
||||
html = get("/materials")
|
||||
assert "物料" in html
|
||||
print("[OK] 物料页渲染正常")
|
||||
|
||||
# 入库页
|
||||
html = get("/inbound")
|
||||
assert "入库登记" in html
|
||||
print("[OK] 入库页渲染正常")
|
||||
|
||||
print("\n真实服务冒烟通过 ✅")
|
||||
@@ -0,0 +1,228 @@
|
||||
"""M1+M2+M3 冒烟测试: 登录 -> 看板 -> 入库出库 -> BOM -> 订单展开 -> 领料 -> 日志 -> 导出 -> 导入 -> 用户
|
||||
|
||||
用 Flask test_client; 通过 WMS_DB 指向临时库, 不污染真实数据。
|
||||
"""
|
||||
import sys, os, tempfile
|
||||
tmp = os.path.join(tempfile.gettempdir(), "wms_smoke.db")
|
||||
if os.path.exists(tmp):
|
||||
os.remove(tmp)
|
||||
os.environ["WMS_DB"] = tmp
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from app import app, db, stock_map, seed_admin
|
||||
from models import (Material, Inbound, Outbound, Bom, BomItem, ProdOrder, OperationLog, User)
|
||||
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
seed_admin()
|
||||
|
||||
c = app.test_client()
|
||||
|
||||
# 1. 未登录跳登录
|
||||
assert c.get("/dashboard").status_code == 302
|
||||
print("[OK] 未登录跳登录")
|
||||
|
||||
# 2. 登录
|
||||
r = c.post("/login", data={"username": "admin", "password": "admin123"}, follow_redirects=True)
|
||||
assert r.status_code == 200 and "库存看板" in r.text
|
||||
print("[OK] 登录成功")
|
||||
|
||||
# 3. 物料收发核算
|
||||
with app.app_context():
|
||||
m = db.session.scalars(db.select(Material).limit(1)).first()
|
||||
if not m: # 临时库无数据, 造一条
|
||||
m = Material(part_number="T001", manufacturer="测试料", unit="个")
|
||||
db.session.add(m); db.session.flush()
|
||||
mid = m.id
|
||||
before = stock_map().get(mid, 0)
|
||||
c.post("/inbound", data={"material_id": mid, "qty": 100}, follow_redirects=True)
|
||||
assert abs(stock_map().get(mid, 0) - (before + 100)) < 1e-6
|
||||
c.post("/outbound", data={"material_id": mid, "qty": 30}, follow_redirects=True)
|
||||
assert abs(stock_map().get(mid, 0) - (before + 70)) < 1e-6
|
||||
rr = c.post("/outbound", data={"material_id": mid, "qty": 999999}, follow_redirects=True)
|
||||
assert "库存不足" in rr.text
|
||||
print(f"[OK] 库存核算: 期初 {before} -> +100 -> -30 -> {stock_map().get(mid,0)}; 超额拦截")
|
||||
|
||||
# 4. BOM 新增
|
||||
r = c.post("/bom/add", data={"code": "BOMTEST", "name": "测试产品",
|
||||
"pn": ["T001"], "qty": ["2"]}, follow_redirects=True)
|
||||
assert r.status_code == 200
|
||||
with app.app_context():
|
||||
b = db.session.scalar(db.select(Bom).where(Bom.code == "BOMTEST"))
|
||||
assert b and db.session.scalar(db.select(BomItem).where(BomItem.bom_id == b.id))
|
||||
print("[OK] BOM 新增 + 组成项")
|
||||
|
||||
# 5. 生产订单 + 展开
|
||||
r = c.post("/order/new", data={"bom_id": b.id, "qty": 10}, follow_redirects=True)
|
||||
assert r.status_code == 200 and "领料需求" in r.text
|
||||
with app.app_context():
|
||||
o = db.session.scalar(db.select(ProdOrder).where(ProdOrder.bom_id == b.id))
|
||||
assert o
|
||||
print("[OK] 生产订单生成 + 需求展开 (单套2 × 10 = 20)")
|
||||
|
||||
# 6. 确认领料
|
||||
c.post(f"/order/{o.id}/pick", data={}, follow_redirects=True)
|
||||
with app.app_context():
|
||||
assert db.session.scalar(db.select(ProdOrder).where(ProdOrder.id == o.id)).status == "picked"
|
||||
pick = db.session.scalar(db.select(Outbound).where(Outbound.order_ref == o.order_no))
|
||||
assert pick and abs(pick.qty - 20) < 1e-6
|
||||
print("[OK] 领料出库生成 (20)")
|
||||
|
||||
# 7. 操作日志
|
||||
r = c.get("/logs")
|
||||
assert "操作日志" in r.text and "领料" in r.text
|
||||
print("[OK] 操作日志已记录")
|
||||
|
||||
# 8. 报表 + 导出 Excel
|
||||
assert "库存报表" in c.get("/report").text
|
||||
r = c.get("/export/inventory")
|
||||
assert r.status_code == 200 and r.mimetype in ("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "application/octet-stream")
|
||||
print("[OK] 报表页 + Excel 导出")
|
||||
|
||||
# 9. 模板下载
|
||||
assert c.get("/template/materials").status_code == 200
|
||||
assert c.get("/template/bom").status_code == 200
|
||||
print("[OK] 导入模板下载")
|
||||
|
||||
# 10. Excel 导入物料
|
||||
import openpyxl
|
||||
from excel_io import read_materials, import_materials
|
||||
wb = openpyxl.Workbook(); ws = wb.active
|
||||
ws.append(["物料编号", "型号/规格", "单位", "安全库存", "期初库存"])
|
||||
ws.append(["IMP001", "导入料A", "个", "10", "100"])
|
||||
tp = os.path.join(tempfile.gettempdir(), "imp_m.xlsx"); wb.save(tp)
|
||||
rows = read_materials(tp)
|
||||
assert rows and rows[0]["part_number"] == "IMP001"
|
||||
with app.app_context():
|
||||
a, u = import_materials(rows, operator="tester")
|
||||
assert a == 1
|
||||
print("[OK] Excel 物料导入 (新增 IMP001)")
|
||||
|
||||
# 10.5 批量导入窗口(入库) 预览 + 提交
|
||||
assert "批量导入窗口" in c.get("/inbound").text
|
||||
preview = c.post("/api/import/transactions/preview",
|
||||
json={"kind": "inbound", "rows": [
|
||||
{"part_number": "IMP001", "qty": 50, "batch_no": "BA", "date": "2026-09-11", "remark": "批导"},
|
||||
{"part_number": "NOPE", "qty": 5},
|
||||
]})
|
||||
pv = preview.get_json()
|
||||
assert pv["valid"] == 1 and pv["invalid"] == 1, pv
|
||||
commit = c.post("/api/import/transactions/commit", json={"kind": "inbound", "rows": pv["rows"]})
|
||||
cj = commit.get_json()
|
||||
assert cj["added"] == 1 and cj["skipped"] == 1, cj
|
||||
with app.app_context():
|
||||
rec = db.session.scalars(db.select(Inbound).where(Inbound.batch_no == "BA")).first()
|
||||
assert rec and abs(rec.qty - 50) < 1e-6
|
||||
# 出库预览: 物料不存在应标记异常
|
||||
opv = c.post("/api/import/transactions/preview",
|
||||
json={"kind": "outbound", "rows": [{"part_number": "GHOST", "qty": 1}]}).get_json()
|
||||
assert opv["rows"][0]["ok"] is False
|
||||
print("[OK] 批量导入窗口: 预览校验 + 提交入库 1 条 / 跳过 1 条(物料不存在)")
|
||||
|
||||
# 11. 用户管理 (admin)
|
||||
assert "用户列表" in c.get("/users").text
|
||||
c.post("/user/add", data={"username": "op1", "password": "pw", "role": "operator"}, follow_redirects=True)
|
||||
with app.app_context():
|
||||
assert db.session.scalar(db.select(User).where(User.username == "op1"))
|
||||
print("[OK] 用户新增 (operator)")
|
||||
|
||||
# 12. 角色拦截: operator 不能访问 /users
|
||||
c.post("/logout")
|
||||
c.post("/login", data={"username": "op1", "password": "pw"}, follow_redirects=True)
|
||||
assert c.get("/users").status_code == 302
|
||||
assert c.get("/boms").status_code == 302
|
||||
print("[OK] 角色权限拦截 (operator 无权访问用户/BOM)")
|
||||
|
||||
# 13. 物料批量导入窗口(粘贴预览 + 提交)
|
||||
c.post("/logout")
|
||||
c.post("/login", data={"username": "admin", "password": "admin123"}, follow_redirects=True)
|
||||
assert "批量导入窗口" in c.get("/materials").text
|
||||
mpv = c.post("/api/import/materials/preview", json={"text": "PK001 料一 个 10 200\nPK002 料二 卷 5 50\nPK001 重复料 个 1 1"})
|
||||
mp = mpv.get_json()
|
||||
assert mp["valid"] == 2 and mp["invalid"] == 1, mp # 第三行本批重复编号
|
||||
# 提交有效行
|
||||
mc = c.post("/api/import/materials/commit", json={"rows": mp["rows"]}).get_json()
|
||||
assert mc["added"] == 2 and mc["updated"] == 0, mc
|
||||
with app.app_context():
|
||||
assert db.session.scalar(db.select(Material).where(Material.part_number == "PK001"))
|
||||
# operator 越权应被拦截
|
||||
c.post("/logout")
|
||||
c.post("/login", data={"username": "op1", "password": "pw"}, follow_redirects=True)
|
||||
assert c.post("/api/import/materials/preview", json={"text": "X1 a 个 1 1"}).status_code == 302
|
||||
c.post("/logout")
|
||||
c.post("/login", data={"username": "admin", "password": "admin123"}, follow_redirects=True)
|
||||
print("[OK] 物料批量导入窗口: 预览校验 + 提交 2 条 / 重复拦截 + 角色拦截")
|
||||
|
||||
# 14. BOM 批量导入窗口(多 BOM 粘贴预览 + 提交 + 自动建物料)
|
||||
assert "批量导入窗口" in c.get("/boms").text
|
||||
bpv = c.post("/api/import/boms/preview", json={"text":
|
||||
"#BOM BM1 产品一\nPK001 2\nPK002 0.5\nNOPE 3\n#BOM BM2 产品二\nPK001 4"})
|
||||
bp = bpv.get_json()
|
||||
assert bp["valid"] == 2 and bp["invalid"] == 0, bp
|
||||
bm1 = next(b for b in bp["boms"] if b["code"] == "BM1")
|
||||
# NOPE 物料不存在 -> 预览标 auto_create, 仍算可导入项(导入时自动新建)
|
||||
assert bm1["valid_items"] == 3 and bm1["skip"] == 0, bm1
|
||||
nope_item = next(it for it in bm1["items"] if it["part_number"] == "NOPE")
|
||||
assert nope_item["auto_create"] is True, nope_item
|
||||
bc = c.post("/api/import/boms/commit", json={"boms": bp["boms"]}).get_json()
|
||||
# NOPE 被自动新建物料; BM2 的 PK001 已存在 -> 共新建 1 个物料, 成功项 4
|
||||
assert bc["boms"] == 2 and bc["added_items"] == 4 and bc["created_materials"] == 1, bc
|
||||
with app.app_context():
|
||||
assert db.session.scalar(db.select(Bom).where(Bom.code == "BM1"))
|
||||
assert db.session.scalar(db.select(Bom).where(Bom.code == "BM2"))
|
||||
assert db.session.scalar(db.select(Material).where(Material.part_number == "NOPE"))
|
||||
print("[OK] BOM 批量导入窗口: 多 BOM 预览 + 物料不存在自动新建 + 提交 2 BOM / 成功项 4 / 新建物料 1")
|
||||
|
||||
# 15. 真实 Altium/嘉立创 格式: 多 sheet, Part Number + Quantity 列
|
||||
wb = openpyxl.Workbook()
|
||||
ws1 = wb.active
|
||||
ws1.title = "XM-A"
|
||||
ws1.append(["Item", "Part Number", "Manufacture Part Number", "Quantity", "Reference", "Part", "Package"])
|
||||
ws1.append([1, "P100", "MF100", 2, "U1", "MF100", "PKG1"])
|
||||
ws1.append([2, "P101", "MF101", 1, "U2", "MF101", "PKG2"])
|
||||
ws2 = wb.create_sheet("组装")
|
||||
ws2.append(["序号", "层级", "物料代码", "描述", "数量", "材质"])
|
||||
ws2.append([1, "0.1", "P200", "外壳", 3, "ABS"])
|
||||
ws2.append([2, "0.1", "P201", "螺丝", 4, "铁"])
|
||||
tp2 = os.path.join(tempfile.gettempdir(), "bom_real.xlsx")
|
||||
wb.save(tp2)
|
||||
with open(tp2, "rb") as f:
|
||||
r = c.post("/api/import/boms/preview", data={"file": (f, "bom_real.xlsx")},
|
||||
content_type="multipart/form-data")
|
||||
rpv = r.get_json()
|
||||
codes = [b["code"] for b in rpv["boms"]]
|
||||
assert "XM-A" in codes and "组装" in codes, rpv
|
||||
assert rpv["valid"] == 2, rpv
|
||||
# 提交(自动建 4 个物料)
|
||||
rbc = c.post("/api/import/boms/commit", json={"boms": rpv["boms"]}).get_json()
|
||||
assert rbc["boms"] == 2 and rbc["created_materials"] == 4, rbc
|
||||
with app.app_context():
|
||||
from models import Material as _M
|
||||
xm = db.session.scalar(db.select(Bom).where(Bom.code == "XM-A"))
|
||||
assert xm
|
||||
its = db.session.scalars(db.select(BomItem).where(BomItem.bom_id == xm.id)).all()
|
||||
assert len(its) == 2
|
||||
p100 = db.session.scalar(db.select(_M).where(_M.part_number == "P100"))
|
||||
assert p100
|
||||
it = next(i for i in its if i.material_id == p100.id)
|
||||
assert abs(it.qty_per_unit - 2) < 1e-6
|
||||
print("[OK] 真实格式 BOM: 多 sheet 解析 + 自动建 4 物料 + 提交 2 个 BOM")
|
||||
|
||||
# 15b. 用户提供的真实 BOM 模板文件直接解析验证(双 Quantity 取首列)
|
||||
REAL = r"D:\办公\Q其他\20190316\2301045(码灵枪模组)\原理图\2301045T03\2301045T03\Output\2301045T03(生产)\2301045T03BOM(032A)-20260811.xlsx"
|
||||
if os.path.exists(REAL):
|
||||
from excel_io import read_boms_multi
|
||||
real = read_boms_multi(REAL)
|
||||
assert len(real) >= 2, real
|
||||
assert "2301045T03" in [b["code"] for b in real], [b["code"] for b in real]
|
||||
pcb = next(b for b in real if b["code"] == "2301045T03")
|
||||
it_1501001 = next((it for it in pcb["items"] if it["part_number"] == "1501001"), None)
|
||||
# 双 Quantity 列: 取第一个(D 列)=2, 而非 E 列=1
|
||||
assert it_1501001 and it_1501001["qty_per_unit"] == 2, it_1501001
|
||||
it_1001003 = next(it for it in pcb["items"] if it["part_number"] == "1001003")
|
||||
assert it_1001003["manufacture"] == "CFW32C7UL_BUA", it_1001003
|
||||
print(f"[OK] 真实模板文件解析: {len(real)} 个 BOM, 主 BOM {len(pcb['items'])} 项, 双Quantity取首列正确")
|
||||
else:
|
||||
print("[SKIP] 真实模板文件不存在, 跳过 15b")
|
||||
|
||||
print("\n全部冒烟测试通过 ✅")
|
||||
@@ -0,0 +1,122 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""验证新增功能: 物料明细(入库/消耗总量 + 入库流水时间) 与 BOM 明细(规格型号)。
|
||||
|
||||
正确收尾: 用 _exe_util.stop_exe 杀整棵进程树, 避免残留进程霸占 8080。
|
||||
"""
|
||||
import http.client
|
||||
import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
import sys
|
||||
import time
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from _exe_util import EXE, start_exe, stop_exe
|
||||
|
||||
HOST, PORT = "127.0.0.1", 8080
|
||||
DB = r"D:\2026-09-10-task-20\material_wms\dist\app.db"
|
||||
SEED = r"D:\2026-09-10-task-20\material_wms\instance\app.db"
|
||||
|
||||
|
||||
def seed_test_bom():
|
||||
"""往 dist 库插入一个测试 BOM(幂等), 引用已存在的物料 1/2/3。"""
|
||||
c = sqlite3.connect(DB)
|
||||
c.execute("DELETE FROM bom_item WHERE bom_id IN (SELECT id FROM bom WHERE code='TESTBOM01')")
|
||||
c.execute("DELETE FROM bom WHERE code='TESTBOM01'")
|
||||
c.execute("INSERT INTO bom(code,name,note) VALUES('TESTBOM01','测试产品','verify')")
|
||||
bid = c.execute("SELECT id FROM bom WHERE code='TESTBOM01'").fetchone()[0]
|
||||
for mid, q in [(1, 2.0), (2, 3.0), (3, 1.0)]:
|
||||
c.execute("INSERT INTO bom_item(bom_id,material_id,qty_per_unit) VALUES(?,?,?)", (bid, mid, q))
|
||||
c.commit()
|
||||
c.close()
|
||||
return bid
|
||||
|
||||
|
||||
def req(c, method, path, cookie="", body=None, headers=None):
|
||||
h = {"Cookie": cookie}
|
||||
if headers:
|
||||
h.update(headers)
|
||||
if body is not None and "Content-Type" not in h:
|
||||
h["Content-Type"] = "application/x-www-form-urlencoded"
|
||||
c.request(method, path, body=body, headers=h)
|
||||
r = c.getresponse()
|
||||
data = r.read().decode("utf-8", "ignore")
|
||||
return r.status, data, r.getheader("Set-Cookie") or ""
|
||||
|
||||
|
||||
def wait_port():
|
||||
for _ in range(60):
|
||||
try:
|
||||
http.client.HTTPConnection(HOST, PORT, timeout=2).connect()
|
||||
return True
|
||||
except Exception:
|
||||
time.sleep(1)
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
if os.path.exists(SEED):
|
||||
shutil.copyfile(SEED, DB)
|
||||
bid = seed_test_bom()
|
||||
|
||||
p = start_exe()
|
||||
try:
|
||||
assert wait_port(), "端口未就绪"
|
||||
c = http.client.HTTPConnection(HOST, PORT, timeout=15)
|
||||
_, _, ck = req(c, "GET", "/login")
|
||||
_, _, ck2 = req(c, "POST", "/login",
|
||||
body=b"username=admin&password=admin123",
|
||||
cookie=ck.split(";")[0])
|
||||
cookie = (ck2.split(";")[0]) if ck2 else ck.split(";")[0]
|
||||
|
||||
ok = True
|
||||
|
||||
# 1) 物料列表含 入库总数量 / 消耗总数量 列
|
||||
s, html, _ = req(c, "GET", "/materials", cookie=cookie)
|
||||
print(f"[materials] status={s}")
|
||||
assert s == 200, f"materials status={s}"
|
||||
for kw in ["入库总数量", "消耗总数量"]:
|
||||
has = kw in html
|
||||
print(f" [{'OK' if has else 'FAIL'}] 列表含列: {kw}")
|
||||
ok = ok and has
|
||||
|
||||
# 2) 物料明细 material/1: 入库=100, 消耗=30, 含入库流水日期
|
||||
s, html, _ = req(c, "GET", "/material/1", cookie=cookie)
|
||||
print(f"[material/1] status={s} len={len(html)}")
|
||||
assert s == 200, f"material_detail status={s}"
|
||||
for kw in ["入库总数量", "消耗总数量", "入库流水", "当前在库"]:
|
||||
has = kw in html
|
||||
print(f" [{'OK' if has else 'FAIL'}] 明细含: {kw}")
|
||||
ok = ok and has
|
||||
has_date = "2026-09-11" in html
|
||||
print(f" [{'OK' if has_date else 'FAIL'}] 入库流水含入库日期 2026-09-11")
|
||||
ok = ok and has_date
|
||||
has_in = "100.00" in html
|
||||
print(f" [{'OK' if has_in else 'FAIL'}] 入库总数量显示 100.00")
|
||||
ok = ok and has_in
|
||||
has_out = "30.00" in html
|
||||
print(f" [{'OK' if has_out else 'FAIL'}] 消耗总数量显示 30.00")
|
||||
ok = ok and has_out
|
||||
|
||||
# 3) BOM 明细含规格型号
|
||||
s, html, _ = req(c, "GET", f"/bom/{bid}", cookie=cookie)
|
||||
print(f"[bom/{bid}] status={s} len={len(html)}")
|
||||
assert s == 200, f"bom_detail status={s}"
|
||||
for kw in ["型号 / 规格", "单套用量", "组成物料"]:
|
||||
has = kw in html
|
||||
print(f" [{'OK' if has else 'FAIL'}] BOM明细含: {kw}")
|
||||
ok = ok and has
|
||||
for spec in ["X1500", "FH8626V100", "CFW32C7UL_BUA"]:
|
||||
has = spec in html
|
||||
print(f" [{'OK' if has else 'FAIL'}] BOM明细含物料规格型号 {spec}")
|
||||
ok = ok and has
|
||||
|
||||
c.close()
|
||||
print("\n" + ("✅ 新功能端到端验证通过" if ok else "❌ 存在未通过项"))
|
||||
return 0 if ok else 1
|
||||
finally:
|
||||
stop_exe(p)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,7 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
cd /d "%~dp0"
|
||||
set "PY=%USERPROFILE%\.workbuddy\binaries\python\envs\default\Scripts\python.exe"
|
||||
if not exist "%PY%" set "PY=python"
|
||||
start "" http://127.0.0.1:8080
|
||||
"%PY%" app.py 8080
|
||||
BIN
Binary file not shown.
Reference in New Issue
Block a user