Files
star 7d34a481b3 feat: 库存管理系统 MaterialWMS 初始版本
- Flask + SQLAlchemy 物料/入库/出库/BOM/生产订单全模块管理
- PyInstaller 打包为单文件 EXE (双击运行, 自动建库)
- 物料维度: 入库总数量 / 消耗总数量 / 逐次入库流水(含时间)
- BOM 详情展示组成物料的规格型号与单套用量
- 支持 Altium / 嘉立创 BOM 批量导入(文本与 Excel 双通道)
- 内置端到端验证脚本(tools/_exe_util.py 等, 清理进程树防残留)
2026-09-11 15:41:56 +08:00

229 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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全部冒烟测试通过 ✅")