feat: 库存管理系统 MaterialWMS 初始版本

- Flask + SQLAlchemy 物料/入库/出库/BOM/生产订单全模块管理
- PyInstaller 打包为单文件 EXE (双击运行, 自动建库)
- 物料维度: 入库总数量 / 消耗总数量 / 逐次入库流水(含时间)
- BOM 详情展示组成物料的规格型号与单套用量
- 支持 Altium / 嘉立创 BOM 批量导入(文本与 Excel 双通道)
- 内置端到端验证脚本(tools/_exe_util.py 等, 清理进程树防残留)
This commit is contained in:
2026-09-11 15:41:56 +08:00
commit 7d34a481b3
38 changed files with 3601 additions and 0 deletions
+59
View File
@@ -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()
+89
View File
@@ -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 批量导入窗口 端到端验证通过 ✅")
+158
View File
@@ -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()
+65
View File
@@ -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()
+104
View File
@@ -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)
+43
View File
@@ -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真实服务冒烟通过 ✅")
+228
View File
@@ -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全部冒烟测试通过 ✅")
+122
View File
@@ -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())