feat: 库存管理系统 MaterialWMS 初始版本
- Flask + SQLAlchemy 物料/入库/出库/BOM/生产订单全模块管理 - PyInstaller 打包为单文件 EXE (双击运行, 自动建库) - 物料维度: 入库总数量 / 消耗总数量 / 逐次入库流水(含时间) - BOM 详情展示组成物料的规格型号与单套用量 - 支持 Altium / 嘉立创 BOM 批量导入(文本与 Excel 双通道) - 内置端到端验证脚本(tools/_exe_util.py 等, 清理进程树防残留)
This commit is contained in:
@@ -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())
|
||||
Reference in New Issue
Block a user