-
-
录入到列表
-
点【录入到列表】加入下方清单,回车只是换行
+
+
+
+
+
+
+ 点击右侧「明细」按钮加载(共 {{ row.detailCount || 0 }} 条)
-
-
-
-
-
-
-
- 已录入 {{ snList.length }} 条 SN
-
-
-
-
-
-
- 删除
+
+
+
+ {{ d.batchNo || '-' }}
+
+
+ {{ d.snCode || '-' }}
+
+
+
+
+ changeDetailPage(row, p)" />
+
-
-
-
- 提交入库
- 清空列表
-
-
-
-
-
-
- 将 .xlsx 文件拖到此处,或点击选择文件
-
-
- 开始导入
-
-
-
-
- 导入结果:共 {{ excelResult.total }} 行,成功 {{ excelResult.success }} 行,失败 {{ excelResult.total - excelResult.success }} 行
-
-
-
-
-
-
-
- 成功
- {{ row.error }}
-
-
-
+
-
-
+
+
+
+
+
+ {{ typeLabel(row.manageMode) }}
+
+
+
+
+
+ {{ row.materialName || '-' }}
+
+
+
+ {{ row.batchNo || '-' }}
+
+
+ {{ row.zoneCode || '-' }}
+
+
+ {{ row.operator || '-' }}
+
+
+ {{ fmtTime(row.createdAt) }}
+
+
+
+
+ 明细{{ row.detailCount ? '(' + row.detailCount + ')' : '' }}
+
+
+
+
+
+
\ No newline at end of file
+.mb12 { margin-bottom: 12px; }
+.pager { display: flex; justify-content: flex-end; margin-top: 12px; }
+
diff --git a/bj_power_wms_client/frontend/src/pages/InboundCreate.vue b/bj_power_wms_client/frontend/src/pages/InboundCreate.vue
new file mode 100644
index 0000000..53b1890
--- /dev/null
+++ b/bj_power_wms_client/frontend/src/pages/InboundCreate.vue
@@ -0,0 +1,309 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 提交入库
+
+
+
+
+
+
+
+ 批量导入(Excel):列头顺序(首行忽略)
+ 物料编码 | 批次号(可空自动生成) | 数量 | 生产日期 | 供应商 | 区域 | 备注 。
+ 整表一次性全导入,任一行错误则整体回退(不写入任何数据)。
+
+
+ 将结构件 .xlsx 拖到此处,或点击选择
+
+
开始批量导入
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 录入到列表
+ 点【录入到列表】加入下方清单,回车只是换行
+
+
+
+
+
+
+
+
+ 已录入 {{ snList.length }} 条 SN
+
+
+
+
+
+
+ 删除
+
+
+
+
+ 提交入库
+ 清空列表
+
+
+
+
+
+ 批量导入(Excel):列头顺序(首行忽略)
+ 物料编码 | SN | 区域 | 生产日期 | 供应商 。
+ 整表一次性全导入,任一行错误则整体回退(不写入任何数据)。
+
+
+ 将精密件 .xlsx 拖到此处,或点击选择
+
+
开始批量导入
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/bj_power_wms_client/frontend/src/pages/Inspection.vue b/bj_power_wms_client/frontend/src/pages/Inspection.vue
index 14ed2ad..cc99270 100644
--- a/bj_power_wms_client/frontend/src/pages/Inspection.vue
+++ b/bj_power_wms_client/frontend/src/pages/Inspection.vue
@@ -1,5 +1,5 @@
-
-
+
+
+
-
- 批次 BATCH
- 序列号 SN
+
+ 批次 (结构件)
+ 序列号 SN (精密件)
@@ -96,26 +126,61 @@ async function submitBatch() {
- 单个录入(扫码)
-
-
- 当前目标:{{ targetType === 'BATCH' ? '批次号' : 'SN 序列号' }}
+ 检验量化(可空)
+
+
-
- 提交检验
+
+
+
+
+
+
+
+
- 批量翻转
-
-
+ 待检编号(可扫/选多条,统一提交)
+
+
+
+
+
+
+
+
+
+
+
+ 录入到列表
+ 支持扫码枪/粘贴,多编号用逗号、分号、空格或回车分隔
+
+
+
+
+
+
+ {{ v }}
+ 尚未选择任何待检编号
+
+
+
- 批量提交
- 清空
+ 提交检验
+ 清空
@@ -124,4 +189,4 @@ async function submitBatch() {
\ No newline at end of file
+
diff --git a/bj_power_wms_client/frontend/src/pages/InspectionQuery.vue b/bj_power_wms_client/frontend/src/pages/InspectionQuery.vue
new file mode 100644
index 0000000..0378f2d
--- /dev/null
+++ b/bj_power_wms_client/frontend/src/pages/InspectionQuery.vue
@@ -0,0 +1,192 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 至
+
+
+
+ 查询
+ 导出
+
+
+
+
+
+
+
+
+ {{ targetTypeLabel(row.targetType) }}
+
+
+
+
+ {{ typeLabel(row.inspectionType) }}
+
+
+
+ {{ row.status || '未检' }}
+
+
+
+ {{ row.inspectQty || 0 }}
+
+
+ {{ row.passQty || 0 }}
+
+
+
+
+ {{ row.checkDesc }}
+
+ -
+
+
+
+
+
+ {{ row.failReason }}
+
+ -
+
+
+
+ {{ row.inspector || '-' }}
+
+
+ {{ fmtTime(row.createdAt) }}
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/bj_power_wms_client/frontend/src/pages/Inventory.vue b/bj_power_wms_client/frontend/src/pages/Inventory.vue
index 78dec41..64d4b33 100644
--- a/bj_power_wms_client/frontend/src/pages/Inventory.vue
+++ b/bj_power_wms_client/frontend/src/pages/Inventory.vue
@@ -1,20 +1,44 @@
-
-
-
-
-
-
-
-
-
-
-
-
- 查询备料台账
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 查询备料台账
+
+
+
-
-
- 台账明细(共 {{ rows.length }} 行,待补 {{ pendingRows.length }} 行)
-
-
-
-
-
- {{ row.materialName || '-' }}
-
-
-
-
-
- {{ gapOf(row) }}
+
+
+ 台账明细(共 {{ rows.length }} 行,待补 {{ pendingRows.length }} 行)
-
-
-
- {{ row.status }}
-
-
-
-
- 一键分配出库
- 已完结
-
-
-
-
+
+
+
+
+ {{ row.materialName || '-' }}
+
+
+
+
+ {{ gapOf(row) }}
+
+
+ {{ row.status }}
+
+
+
+ 一键分配出库
+ 已完结
+
+
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+ 结构件(批次)
+ 精密件(SN)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 录入到列表
+ 已录入 {{ snList.length }} 条
+
+
+
+
+
+ 移除
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 确认通用出库
+
+
+
+
+
+
+
+
+
+ {{ row.batchNo || '-' }}
+
+
+ {{ row.snCodes || '-' }}
+
+
+
+ {{ row.boxNo || '-' }}
+
+
+
+ {{ fmtTime(row.createdAt) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 查询
+ 重置
+ 导出
+
+
+
+
+
+
+
+
+
+
+ {{ row.outboundType === 'general' ? '通用' : '备料' }}
+
+
+
+
+ {{ row.orderNo || '-' }}
+
+
+
+ {{ row.batchNo || '-' }}
+
+
+ {{ row.snCodes || '-' }}
+
+
+
+ {{ row.boxNo || '-' }}
+
+
+
+ {{ fmtTime(row.createdAt) }}
+
+
+
+
+
+
@@ -165,4 +436,5 @@ async function query() {
\ No newline at end of file
+.pager { display: flex; justify-content: flex-end; margin-top: 10px; }
+
diff --git a/bj_power_wms_client/frontend/src/pages/Package.vue b/bj_power_wms_client/frontend/src/pages/Package.vue
deleted file mode 100644
index 91fac24..0000000
--- a/bj_power_wms_client/frontend/src/pages/Package.vue
+++ /dev/null
@@ -1,288 +0,0 @@
-
-
-
-
-
-
-
- 新增装箱
- 装箱:将多个成品/精密件 SN 装入一个箱号,用于发货、入库、整箱追溯
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 查询
- 重置
-
-
-
-
-
- {{ parseSn(row.snList) }}
-
-
-
- {{ row.materialCode || '-' }}
-
-
- {{ row.contractNo || '-' }}
-
-
- {{ row.operator || '-' }}
-
-
- {{ fmtTime(row.createdAt) }}
-
-
-
- 编辑
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 录入到列表
- 点【录入到列表】加入下方清单,回车只是换行
-
-
-
-
-
-
共 {{ snList.length }} 条
-
-
-
-
-
- 移除
-
-
-
-
-
-
-
-
-
-
- 取消
- {{ editingId ? '保存修改' : '确认装箱' }}
-
-
-
-
-
-
-
diff --git a/bj_power_wms_client/frontend/src/pages/RoleManage.vue b/bj_power_wms_client/frontend/src/pages/RoleManage.vue
new file mode 100644
index 0000000..b5acd76
--- /dev/null
+++ b/bj_power_wms_client/frontend/src/pages/RoleManage.vue
@@ -0,0 +1,210 @@
+
+
+
+
+
+
+
+ 角色管理
+
+ 角色决定用户的菜单与按钮权限;内置角色(管理员/库房保管员/质检员)不可删除
+
+
+
新增角色
+
+
+
+
+
+
+
+
+
+ {{ row.remark || '-' }}
+
+
+ {{ (row.permissionCodes || []).length }} 项
+
+
+
+
+ {{ permSummary(row.permissionCodes) }}
+
+
+
+
+ {{ fmtTime(row.createdAt) }}
+
+
+
+ 编辑权限
+ 删除
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
菜单权限
+
+ v ? dialog.checked.push(p.code) : (dialog.checked = dialog.checked.filter(c => c !== p.code))">
+ {{ p.name }}
+
+
+
+
+
按钮权限
+
+ v ? dialog.checked.push(p.code) : (dialog.checked = dialog.checked.filter(c => c !== p.code))">
+ {{ p.name }}
+
+
+
+
+
+
+
+
+
+ 取消
+ 保存
+
+
+
+
+
+
\ No newline at end of file
diff --git a/bj_power_wms_client/frontend/src/pages/Semi.vue b/bj_power_wms_client/frontend/src/pages/Semi.vue
index d9c15d4..0a14727 100644
--- a/bj_power_wms_client/frontend/src/pages/Semi.vue
+++ b/bj_power_wms_client/frontend/src/pages/Semi.vue
@@ -1,4 +1,5 @@
-
+
+
diff --git a/e2e_inbound_details.py b/e2e_inbound_details.py
new file mode 100644
index 0000000..5f1d4fe
--- /dev/null
+++ b/e2e_inbound_details.py
@@ -0,0 +1,79 @@
+import json
+import urllib.request
+import urllib.error
+
+BASE = "http://localhost:8910"
+MAT = "SYF_SN_DETAIL_1"
+INBOUND_NO = None
+
+def req(method, path, token=None, body=None):
+ url = BASE + path
+ data = json.dumps(body).encode() if body is not None else None
+ r = urllib.request.Request(url, data=data, method=method)
+ r.add_header("Content-Type", "application/json")
+ if token:
+ r.add_header("Authorization", "Bearer " + token)
+ try:
+ with urllib.request.urlopen(r, timeout=10) as resp:
+ return resp.status, json.loads(resp.read().decode())
+ except urllib.error.HTTPError as e:
+ return e.code, json.loads(e.read().decode())
+
+print("=== 登录 ===")
+_, login = req("POST", "/api/auth/login", body={"username": "admin", "password": "123456"})
+TOKEN = login.get("data", {}).get("token", "")
+assert TOKEN, "登录失败"
+print("token 长度:", len(TOKEN))
+
+print("\n=== 1) 建精密件物料(manageMode=2) ===")
+_, cmat = req("POST", "/api/material/create", TOKEN, {
+ "code": MAT, "name": "明细分页测试料", "manageMode": 2, "unit": "pcs"
+})
+print("create material:", cmat.get("code"), cmat.get("message"))
+
+print("\n=== 2) 造一笔 120 条 SN 的精密件入库 ===")
+sn_list = ["SN%05d" % i for i in range(1, 121)]
+_, cin = req("POST", "/api/inbound/create", TOKEN, {
+ "inboundType": "purchase", "materialCode": MAT,
+ "snList": sn_list, "zoneCode": "AREA_TEST", "operator": "e2e"
+})
+INBOUND_NO = cin.get("data", {}).get("inboundNo")
+print("inboundNo:", INBOUND_NO, "qty:", cin.get("data", {}).get("quantity"))
+
+print("\n=== 3) 列表查询:应只带 detailCount,不带 details 全量 ===")
+_, q = req("GET", "/api/inbound/query?materialCode=%s&pageSize=10" % MAT, TOKEN)
+row = next((r for r in q["data"]["list"] if r.get("inboundNo") == INBOUND_NO), None)
+assert row, "列表未找到该单"
+print("detailCount =", row.get("detailCount"), "| 'details' 字段是否存在:", "details" in row)
+assert row.get("detailCount") == 120, "detailCount 应为 120"
+assert "details" not in row, "列表不应内嵌全量明细(数据量炸弹!)"
+print("✅ 列表未加载全量明细,仅带总数 120")
+
+print("\n=== 4) 明细分页接口 /inbound/details ===")
+for pg in (1, 2, 3):
+ _, d = req("GET", "/api/inbound/details?inboundNo=%s&page=%d&pageSize=50" % (INBOUND_NO, pg), TOKEN)
+ dd = d.get("data", {})
+ print(" page %d -> total=%s 返回行数=%d" % (pg, dd.get("total"), len(dd.get("list", []))))
+ assert dd.get("total") == 120
+ if pg == 1:
+ assert len(dd.get("list", [])) == 50
+ elif pg == 2:
+ assert len(dd.get("list", [])) == 50
+ elif pg == 3:
+ assert len(dd.get("list", [])) == 20
+print("✅ 明细分页正确: 50 / 50 / 20,服务端未一次性加载 120 条")
+
+print("\n=== 5) 清理测试数据 ===")
+import subprocess
+sql = (
+ "DELETE FROM inbound_details WHERE inbound_no='%s';"
+ "DELETE FROM inbound_orders WHERE inbound_no='%s';"
+ "DELETE FROM inventory WHERE inbound_no='%s';"
+ "DELETE FROM materials WHERE code='%s';"
+) % (INBOUND_NO, INBOUND_NO, INBOUND_NO, MAT)
+subprocess.run(["psql", "-h", "127.0.0.1", "-U", "postgres", "-d", "bj_power_wms",
+ "-c", sql], env={"PGPASSWORD": "postgres", **__import__("os").environ},
+ check=False, capture_output=True)
+print("已清理测试物料/入库单/库存")
+
+print("\nALL PASS ✅")
diff --git a/e2e_inspection.py b/e2e_inspection.py
new file mode 100644
index 0000000..538c054
--- /dev/null
+++ b/e2e_inspection.py
@@ -0,0 +1,96 @@
+import json
+import urllib.request
+import urllib.error
+import urllib.parse
+import time
+
+BASE = "http://localhost:8902"
+
+def req(method, path, token=None, body=None):
+ url = BASE + path
+ data = json.dumps(body).encode() if body is not None else None
+ r = urllib.request.Request(url, data=data, method=method)
+ r.add_header("Content-Type", "application/json")
+ if token:
+ r.add_header("Authorization", "Bearer " + token)
+ try:
+ with urllib.request.urlopen(r, timeout=15) as resp:
+ return resp.status, json.loads(resp.read().decode())
+ except urllib.error.HTTPError as e:
+ try:
+ return e.code, json.loads(e.read().decode())
+ except Exception:
+ return e.code, {}
+
+print("=== 1) 登录 admin ===")
+_, login = req("POST", "/api/auth/login", body={"username": "admin", "password": "123456"})
+TOKEN = login.get("data", {}).get("token", "")
+assert TOKEN, "登录失败"
+
+print("\n=== 2) 从库存取一个可检验的批次号和 SN ===")
+_, stq = req("GET", "/api/stock/query?manageMode=1&page=1&pageSize=5", TOKEN)
+st_rows = stq.get("data", {}).get("list", [])
+_, sq = req("GET", "/api/stock/query?manageMode=2&page=1&pageSize=5", TOKEN)
+sn_rows = sq.get("data", {}).get("list", [])
+print(" 结构件聚合数:", len(st_rows), "| 精密件聚合数:", len(sn_rows))
+# 从明细取真实批次号 / SN
+batch_id = None
+if st_rows:
+ mc = st_rows[0]["materialCode"]
+ _, dd = req("GET", f"/api/stock/details?materialCode={mc}&manageMode=1&page=1&pageSize=5", TOKEN)
+ dd_list = dd.get("data", {}).get("list", [])
+ if dd_list:
+ batch_id = dd_list[0].get("batchNo")
+print(" 测试批次号:", batch_id)
+
+sn_id = None
+if sn_rows:
+ mc2 = sn_rows[0]["materialCode"]
+ _, dd2 = req("GET", f"/api/stock/details?materialCode={mc2}&manageMode=2&page=1&pageSize=5", TOKEN)
+ dd2_list = dd2.get("data", {}).get("list", [])
+ if dd2_list:
+ sn_id = dd2_list[0].get("snCode")
+print(" 测试 SN:", sn_id)
+
+print("\n=== 3) 批量检验(含新字段)===")
+if batch_id:
+ s, r = req("POST", "/api/inspection/batch-flip", TOKEN, {
+ "targetType": "BATCH", "targetIds": [batch_id], "status": "合格",
+ "inspectQty": 5, "passQty": 5, "checkDesc": "外观+尺寸抽检", "inspector": "核验员"
+ })
+ print(" 批次检验 -> HTTP", s, r.get("data"))
+if sn_id:
+ s2, r2 = req("POST", "/api/inspection/batch-flip", TOKEN, {
+ "targetType": "SN", "targetIds": [sn_id], "status": "不合格",
+ "inspectQty": 1, "passQty": 0, "checkDesc": "通断测试", "failReason": "导通电阻超标", "inspector": "核验员"
+ })
+ print(" SN检验 -> HTTP", s2, r2.get("data"))
+
+print("\n=== 4) 检验记录查询 /inspection/query ===")
+s, q = req("GET", "/api/inspection/query?page=1&pageSize=20", TOKEN)
+print("HTTP", s, "| total:", q.get("data", {}).get("total"))
+rows = q.get("data", {}).get("list", [])
+for r in rows[:3]:
+ print(f" id={r['id']} 对象={r['targetType']} 编号={r['targetId']} 结论={r['status']} "
+ f"检验数={r.get('inspectQty')} 合格数={r.get('passQty')} 说明={r.get('checkDesc')} 不合格原因={r.get('failReason')}")
+
+# 校验新字段落库:找一个我们刚写的记录
+found = any(r.get("checkDesc") for r in rows)
+assert found, "检验记录未保存新字段(checkDesc)!"
+print(" ✓ 新字段(检验数/合格数/说明/不合格原因)已落库")
+
+print("\n=== 5) 筛选联动(结论=不合格)===")
+s, fq = req("GET", "/api/inspection/query?status=" + urllib.parse.quote("不合格") + "&page=1&pageSize=20", TOKEN)
+fq_list = fq.get("data", {}).get("list", [])
+print(" 结论=不合格 total:", fq.get("data", {}).get("total"), "| 全部不合格:", all(r["status"]=="不合格" for r in fq_list))
+
+print("\n=== 6) 全量导出 /inspection/export ===")
+s, ex = req("GET", "/api/inspection/export?page=1&pageSize=99999", TOKEN)
+ex_list = ex.get("data", {}).get("list", [])
+print("HTTP", s, "| 导出条数:", len(ex_list))
+first = ex_list[0] if ex_list else {}
+print(" 导出首行 keys:", sorted(first.keys()))
+assert "inspectQty" in first or "checkDesc" in first, "导出缺少新字段"
+print(" ✓ 导出接口正常返回全部记录(含新字段)")
+
+print("\n=== ALL CHECKS PASSED ===")
\ No newline at end of file
diff --git a/e2e_issues.py b/e2e_issues.py
new file mode 100644
index 0000000..6424cc0
--- /dev/null
+++ b/e2e_issues.py
@@ -0,0 +1,202 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""验证 #2/#3/#4 三个 issue 的修复:
+#2 库存查询支持按入库单号过滤且返回 inboundNo
+#3 Excel 批量导入:事务化全成功/全失败,支持 batch/sn
+#4 备料出库/MES:orders 接口 MES 不可达时优雅降级;台账缺失提示友好
+"""
+import json
+import os
+import urllib.request
+import urllib.error
+import zipfile
+
+BASE = "http://127.0.0.1:8890"
+TMP = "e2e_tmp"
+os.makedirs(TMP, exist_ok=True)
+
+def req(method, path, body=None, token=None, raw=False):
+ url = BASE + path
+ data = None
+ headers = {"Content-Type": "application/json"}
+ if token:
+ headers["Authorization"] = "Bearer " + token
+ if body is not None:
+ data = json.dumps(body).encode("utf-8")
+ r = urllib.request.Request(url, data=data, headers=headers, method=method)
+ try:
+ with urllib.request.urlopen(r, timeout=10) as resp:
+ return resp.status, resp.read().decode("utf-8")
+ except urllib.error.HTTPError as e:
+ return e.code, e.read().decode("utf-8", "replace")
+
+def jd(txt):
+ d = json.loads(txt)
+ return d.get("data") if isinstance(d, dict) else None
+
+def post_multipart(path, token, fields, file_path, file_field="file"):
+ boundary = "----wbtestboundary"
+ parts = []
+ for k, v in fields.items():
+ parts.append(("--" + boundary).encode())
+ parts.append(('Content-Disposition: form-data; name="%s"' % k).encode())
+ parts.append(b"")
+ parts.append(v.encode("utf-8"))
+ parts.append(("--" + boundary).encode())
+ parts.append(('Content-Disposition: form-data; name="%s"; filename="t.xlsx"' % file_field).encode())
+ parts.append(b"Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
+ parts.append(b"")
+ with open(file_path, "rb") as f:
+ parts.append(f.read())
+ parts.append(("--" + boundary + "--").encode())
+ parts.append(b"")
+ data = b"\r\n".join(parts)
+ r = urllib.request.Request(BASE + path, data=data,
+ headers={"Authorization": "Bearer " + token,
+ "Content-Type": "multipart/form-data; boundary=" + boundary},
+ method="POST")
+ try:
+ with urllib.request.urlopen(r, timeout=15) as resp:
+ return resp.status, resp.read().decode("utf-8")
+ except urllib.error.HTTPError as e:
+ return e.code, e.read().decode("utf-8", "replace")
+
+# ---------- 构造最小合法 xlsx(inlineStr,免依赖) ----------
+def sheet_xml(rows):
+ out = ['']
+ out.append('')
+ for r in rows:
+ out.append("")
+ for c in r:
+ s = str(c).replace("&", "&").replace("<", "<").replace(">", ">")
+ out.append('%s ' % s)
+ out.append("
")
+ out.append(" ")
+ return "".join(out)
+
+def make_xlsx(path, rows):
+ ct = ' '
+ rels = ' '
+ wb = ' '
+ wbrels = ' '
+ with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as z:
+ z.writestr("[Content_Types].xml", ct)
+ z.writestr("_rels/.rels", rels)
+ z.writestr("xl/workbook.xml", wb)
+ z.writestr("xl/_rels/workbook.xml.rels", wbrels)
+ z.writestr("xl/worksheets/sheet1.xml", sheet_xml(rows))
+
+print("=== 0) login ===")
+st, txt = req("POST", "/api/auth/login", {"username": "admin", "password": "123456"})
+TOKEN = jd(txt).get("token")
+print(" token:", "OK" if TOKEN else "FAIL")
+
+MAT_B = "TEST_BATCH_E2E"
+MAT_S = "TEST_SN_E2E"
+
+def ensure_material(code, mode):
+ st, txt = req("GET", "/api/material/query?keyword=" + code, token=TOKEN)
+ lst = jd(txt)
+ if isinstance(lst, dict):
+ for m in lst.get("list", []):
+ if m.get("code") == code:
+ return
+ req("POST", "/api/material/create", {"code": code, "name": code, "manageMode": mode, "unit": "PCS"}, token=TOKEN)
+
+ensure_material(MAT_B, 1)
+ensure_material(MAT_S, 2)
+
+print("\n=== #2 库存查询按入库单号过滤 + 返回 inboundNo ===")
+import datetime
+uq = datetime.datetime.now().strftime("%H%M%S%f")
+batch2 = "B-E2E-" + uq
+st, txt = req("POST", "/api/inbound/create",
+ {"inboundType": "purchase", "materialCode": MAT_B, "batchNo": batch2,
+ "quantity": 4, "zoneCode": "Z01", "operator": "tester"}, token=TOKEN)
+ib_no = jd(txt).get("inboundNo")
+print(" 入库单号:", ib_no, "| 批次:", batch2)
+st, txt = req("GET", "/api/stock/query?inboundNo=" + ib_no, token=TOKEN)
+d = jd(txt)
+rows = d.get("list", []) if isinstance(d, dict) else []
+hit = [r for r in rows if r.get("inboundNo") == ib_no]
+print(" 按该单号查到库存行:", len(hit), "| 首行 inboundNo=", hit[0].get("inboundNo") if hit else None, "| 首行批次=", hit[0].get("batchNo") if hit else None)
+st, txt = req("GET", "/api/stock/query?inboundNo=NOPE_NO_SUCH", token=TOKEN)
+d = jd(txt)
+empty = (d.get("list") == []) if isinstance(d, dict) else False
+print(" 按不存在单号查询为空:", empty)
+
+print("\n=== #3 Excel 批量导入(事务 全成功/全失败) ===")
+# 合法 batch
+make_xlsx(TMP + "/batch_ok.xlsx", [
+ ["物料编码", "批次号", "数量", "生产日期", "供应商", "区域", "备注"],
+ [MAT_B, "B-E2E-OK1", "5", "2026-09-02", "SUP", "Z01", ""],
+ [MAT_B, "B-E2E-OK2", "3", "2026-09-02", "SUP", "Z01", ""],
+])
+st, txt = post_multipart("/api/inbound/batch-excel", TOKEN, {"mode": "batch"}, TMP + "/batch_ok.xlsx")
+d = jd(txt)
+print(" 合法 batch -> success=%s failed=%s" % (d.get("success"), d.get("failed")))
+# 非法 batch(一行物料不存在)
+make_xlsx(TMP + "/batch_bad.xlsx", [
+ ["物料编码", "批次号", "数量", "生产日期", "供应商", "区域", "备注"],
+ [MAT_B, "B-E2E-FAIL", "5", "2026-09-02", "SUP", "Z01", ""],
+ ["UNKNOWN_MAT_X", "X", "1", "2026-09-02", "SUP", "Z01", ""],
+])
+st, txt = post_multipart("/api/inbound/batch-excel", TOKEN, {"mode": "batch"}, TMP + "/batch_bad.xlsx")
+d = jd(txt)
+print(" 非法 batch -> success=%s failed=%s err0=%s" % (d.get("success"), d.get("failed"), (d.get("errors") or [{}])[0].get("message")))
+st, txt = req("GET", "/api/stock/query?keyword=B-E2E-FAIL", token=TOKEN)
+d = jd(txt)
+not_written = (d.get("list") == []) if isinstance(d, dict) else False
+print(" 非法行对应批次未被写入(整体回退):", not_written)
+# 合法 sn
+make_xlsx(TMP + "/sn_ok.xlsx", [
+ ["物料编码", "SN", "区域", "生产日期", "供应商"],
+ [MAT_S, "SNE2E001", "Z01", "", ""],
+ [MAT_S, "SNE2E002", "Z01", "", ""],
+])
+st, txt = post_multipart("/api/inbound/batch-excel", TOKEN, {"mode": "sn"}, TMP + "/sn_ok.xlsx")
+d = jd(txt)
+print(" 合法 sn -> success=%s failed=%s" % (d.get("success"), d.get("failed")))
+# 非法 sn(文件内重复 SN)
+make_xlsx(TMP + "/sn_bad.xlsx", [
+ ["物料编码", "SN", "区域", "生产日期", "供应商"],
+ [MAT_S, "SNE2E_DUP", "Z01", "", ""],
+ [MAT_S, "SNE2E_DUP", "Z01", "", ""],
+])
+st, txt = post_multipart("/api/inbound/batch-excel", TOKEN, {"mode": "sn"}, TMP + "/sn_bad.xlsx")
+d = jd(txt)
+print(" 非法 sn(重复) -> success=%s failed=%s" % (d.get("success"), d.get("failed")))
+st, txt = req("GET", "/api/stock/query?keyword=SNE2E_DUP", token=TOKEN)
+d = jd(txt)
+sn_not_written = (d.get("list") == []) if isinstance(d, dict) else False
+print(" 重复 SN 未被写入(整体回退):", sn_not_written)
+
+print("\n=== #4 备料出库/MES 关联 ===")
+st, txt = req("GET", "/api/orders", token=TOKEN)
+od = jd(txt)
+print(" /api/orders (MES 不可达时应优雅返回空列表, 非 502): HTTP", st, "data是列表:", isinstance(od, list))
+st, txt = req("POST", "/api/outbound/create",
+ {"orderNo": "NOPE_ORDER_X", "materialCode": MAT_B, "batchNo": "B-E2E-01",
+ "qty": 1, "operator": "tester"}, token=TOKEN)
+d = json.loads(txt)
+print(" 台账缺失提示含'通用出库':", "通用出库" in (d.get("message") or ""), "| msg=", d.get("message"))
+
+print("\n=== 清理 E2E 测试数据 ===")
+import subprocess
+cleanup_sql = (
+ "DELETE FROM inbound_details WHERE material_code IN ('TEST_BATCH_E2E','TEST_SN_E2E');"
+ "DELETE FROM inbound_orders WHERE material_code IN ('TEST_BATCH_E2E','TEST_SN_E2E');"
+ "DELETE FROM inventories WHERE batch_no LIKE 'B-E2E-%' OR sn_code LIKE 'SNE2E%';"
+ "DELETE FROM materials WHERE code IN ('TEST_BATCH_E2E','TEST_SN_E2E');"
+)
+sql = "BEGIN;" + cleanup_sql + "COMMIT;"
+try:
+ subprocess.run(["psql", "-h", "127.0.0.1", "-U", "postgres", "-d", "bj_power_wms",
+ "-c", sql], check=True,
+ env={**os.environ, "PGPASSWORD": "postgres"},
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+ print(" 已清理测试物料/库存/入库单")
+except Exception as e:
+ print(" 清理失败(可手动):", e)
+
+print("\n=== DONE ===")
diff --git a/e2e_rbac.py b/e2e_rbac.py
new file mode 100644
index 0000000..3a9f47f
--- /dev/null
+++ b/e2e_rbac.py
@@ -0,0 +1,80 @@
+import json
+import urllib.request
+import urllib.error
+
+BASE = "http://localhost:8902"
+
+def req(method, path, token=None, body=None):
+ url = BASE + path
+ data = json.dumps(body).encode() if body is not None else None
+ r = urllib.request.Request(url, data=data, method=method)
+ r.add_header("Content-Type", "application/json")
+ if token:
+ r.add_header("Authorization", "Bearer " + token)
+ try:
+ with urllib.request.urlopen(r, timeout=10) as resp:
+ return resp.status, json.loads(resp.read().decode())
+ except urllib.error.HTTPError as e:
+ return e.code, json.loads(e.read().decode())
+
+print("=== 1) 登录 admin ===")
+_, login = req("POST", "/api/auth/login", body={"username": "admin", "password": "123456"})
+TOKEN = login.get("data", {}).get("token", "")
+assert TOKEN, "登录失败"
+print("token:", TOKEN[:12] + "...")
+
+print("\n=== 2) seed 初始化权限与角色 ===")
+s, seed = req("POST", "/api/rbac/seed", TOKEN, {})
+print("HTTP", s, "->", seed.get("data"))
+
+print("\n=== 3) /api/permissions 权限列表(应含 MENU+BUTTON, 无废弃package:view) ===")
+_, perms = req("GET", "/api/permissions", TOKEN)
+plist = perms.get("data", {}).get("list", [])
+codes = [p["code"] for p in plist]
+print("权限总数:", len(plist), "| MENU:", sum(1 for c in codes if c.endswith(':view') or c in ('dashboard:view','role:manage','user:manage')))
+print("menu去重内 sample:", [c for c in codes if c in ('inbound:view','inventory:view','role:manage')])
+print("BUTTON sample:", [c for c in codes if c in ('inbound:create','material:edit','*:export')])
+assert "inbound:view" in codes, "缺 inbound:view"
+assert "inbound:create" in codes, "缺按钮权限"
+assert "package:view" not in codes, "废弃 package:view 未清除!"
+
+print("\n=== 4) /api/roles 角色列表 ===")
+_, roles = req("GET", "/api/roles", TOKEN)
+rlist = roles.get("data", {}).get("list", [])
+print("角色数:", len(rlist), "|", [(r["code"], len(r.get("permissionCodes", []))) for r in rlist])
+admin_role = next(r for r in rlist if r["code"] == "admin")
+print("admin 权限示例:", admin_role.get("permissionCodes", [])[:4], "...")
+assert any(r["code"] == "admin" for r in rlist)
+
+print("\n=== 5) 新建自定义角色 test-role ===")
+_, cr = req("POST", "/api/roles", TOKEN, {
+ "name": "E2E测试角色", "code": "e2e_test_role",
+ "remark": "验证用", "permissionCodes": ["inventory:view", "*:export"]
+})
+print("创建:", cr.get("code"), cr.get("message"))
+rid = cr.get("data", {}).get("id")
+assert rid, "创建角色失败"
+
+print("\n=== 6a) 编辑角色(变更权限) ===")
+_, ur = req("POST", "/api/roles/update", TOKEN, {
+ "id": rid, "name": "E2E测试角色改", "permissionCodes": ["inventory:view", "inbound:view", "*:export"]
+})
+print("编辑:", ur.get("code"), ur.get("message"))
+
+print("\n=== 6b) 删除内置角色应被拒 ===")
+admin_role_id = next(r for r in rlist if r["code"] == "admin")["id"]
+_, dr = req("POST", "/api/roles/delete", TOKEN, {"id": admin_role_id})
+print("删内置角色 -> code:", dr.get("code"), "msg:", dr.get("message"))
+assert dr.get("code") != 0, "内置角色不应被删除!"
+
+print("\n=== 7) 删除测试角色 ===")
+_, dr2 = req("POST", "/api/roles/delete", TOKEN, {"id": rid})
+print("删测试角色 -> code:", dr2.get("code"))
+
+print("\n=== 8) /user/info 返回权限码(admin 应含全部) ===")
+_, info = req("GET", "/api/user/info", TOKEN)
+pc = info.get("data", {}).get("permissionCodes", [])
+print("admin 权限码数:", len(pc), "| 含 role:manage:", "role:manage" in pc, "| 含 inbound:create:", "inbound:create" in pc)
+assert "role:manage" in pc, "admin 应可见角色管理"
+
+print("\nALL PASS ✅")
\ No newline at end of file
diff --git a/e2e_stock_agg.py b/e2e_stock_agg.py
new file mode 100644
index 0000000..d15450f
--- /dev/null
+++ b/e2e_stock_agg.py
@@ -0,0 +1,121 @@
+import json
+import urllib.request
+import urllib.error
+import urllib.parse
+import time
+
+BASE = "http://localhost:8902"
+
+def req(method, path, token=None, body=None):
+ url = BASE + path
+ data = json.dumps(body).encode() if body is not None else None
+ r = urllib.request.Request(url, data=data, method=method)
+ r.add_header("Content-Type", "application/json")
+ if token:
+ r.add_header("Authorization", "Bearer " + token)
+ try:
+ with urllib.request.urlopen(r, timeout=15) as resp:
+ return resp.status, json.loads(resp.read().decode())
+ except urllib.error.HTTPError as e:
+ try:
+ return e.code, json.loads(e.read().decode())
+ except Exception:
+ return e.code, {}
+
+print("=== 1) 登录 admin ===")
+_, login = req("POST", "/api/auth/login", body={"username": "admin", "password": "123456"})
+TOKEN = login.get("data", {}).get("token", "")
+assert TOKEN, "登录失败"
+print("token:", TOKEN[:12] + "...")
+
+def cleanup():
+ # 停用测试期间生成的库存行,避免污染/重复
+ pass
+
+print("\n=== 2) 确保测试物料存在 ===")
+def ensure_material(code, name, manage_mode, spec):
+ _, lst = req("GET", f"/api/material/query?materialCode={code}", TOKEN)
+ for m in lst.get("data", {}).get("list", []):
+ if m.get("code") == code:
+ return
+ s, r = req("POST", "/api/material/create", TOKEN, {
+ "code": code, "name": name, "spec": spec,
+ "manageMode": manage_mode, "unit": "件"
+ })
+ print(f" 创建物料 {code} -> HTTP {s} {r.get('message','')}")
+
+ensure_material("ST-A100", "标准结构件A100", 1, "Φ20×300")
+ensure_material("PR-S100", "精密传感器S100", 2, "M8")
+
+print("\n=== 3) 造库存数据:入库若干批次/SN ===")
+# 结构件批次入库(每条一个批次,区域 Z01)
+inb1 = req("POST", "/api/inbound/create", TOKEN, {
+ "inboundType": "purchase", "materialCode": "ST-A100",
+ "quantity": 10, "zoneCode": "Z01", "operator": "verify"
+})
+print(" 结构件批次1入库 -> HTTP", inb1[0], inb1[1].get("message", ""))
+inb1b = req("POST", "/api/inbound/create", TOKEN, {
+ "inboundType": "purchase", "materialCode": "ST-A100",
+ "quantity": 5, "zoneCode": "Z01", "operator": "verify"
+})
+print(" 结构件批次2入库 -> HTTP", inb1b[0], inb1b[1].get("message", ""))
+# 精密件 SN 入库
+sn_items = {"inboundType": "purchase", "materialCode": "PR-S100",
+ "zoneCode": "Z02", "operator": "verify"}
+sn_items["snList"] = [f"SN100{i:03d}" for i in range(1, 6)]
+inb2 = req("POST", "/api/inbound/create", TOKEN, sn_items)
+print(" 精密件SN入库 -> HTTP", inb2[0], inb2[1].get("message", ""))
+
+print("\n=== 4) 聚合主列表 /stock/query ===")
+s, q = req("GET", "/api/stock/query?page=1&pageSize=50", TOKEN)
+data = q.get("data", {})
+rows = data.get("list", [])
+print("HTTP", s, "| total:", data.get("total"), "| rows:", len(rows))
+for r in rows:
+ print(f" 物料={r['materialCode']} 名称={r['materialName']} 类型={r['manageMode']} "
+ f"数量={r['totalQty']} 锁定={r['lockedQty']} 可用={r['availQty']} "
+ f"批次数={r['batchCount']} SN数={r['snCount']} 区={r['zoneCode']}")
+
+agg = {r["materialCode"]: r for r in rows}
+assert "ST-A100" in agg, "聚合列表缺结构件物料 ST-A100"
+assert "PR-S100" in agg, "聚合列表缺精密件物料 PR-S100"
+st = agg["ST-A100"]; pr = agg["PR-S100"]
+assert st["batchCount"] >= 2, "结构件应有 >=2 个批次,聚合后批次数不对"
+assert pr["snCount"] >= 5, "精密件应有 >=5 个 SN,聚合后 SN 数不对"
+# 关键:主列表不应泄露 SN;结构中不应出现明细的 snCode/batchNo 平铺字段
+for r in rows:
+ assert "snCode" not in r or r.get("snCode") is None, f"主列表不应泄露 SN: {r}"
+ assert "inventoryId" not in r, "主列表不应带明细 id"
+print(" ✓ 主列表按物料聚合且不泄露 SN/批次明细")
+
+print("\n=== 5) 明细下钻 /stock/details ===")
+# 点 ST-A100 结构件(管理粒度=1)
+s, d1 = req("GET", f"/api/stock/details?materialCode=ST-A100&manageMode=1&page=1&pageSize=50", TOKEN)
+dd = d1.get("data", {})
+print("HTTP", s, "| 结构件明细 total:", dd.get("total"), "| rows:", len(dd.get("list", [])))
+for r in dd.get("list", [])[:3]:
+ print(f" 批次={r['batchNo']} 数量={r['quantity']} 状态={r['status']}")
+assert dd.get("total", 0) >= 2, "结构件明细应有 >=2 批次"
+for r in dd.get("list", []):
+ assert r.get("batchNo"), "明细应含批次号"
+
+# 点 PR-S100 精密件(管理粒度=2)
+s, d2 = req("GET", f"/api/stock/details?materialCode=PR-S100&manageMode=2&page=1&pageSize=50", TOKEN)
+dd2 = d2.get("data", {})
+print("HTTP", s, "| 精密件明细 total:", dd2.get("total"), "| rows:", len(dd2.get("list", [])))
+for r in dd2.get("list", [])[:3]:
+ print(f" SN={r.get('snCode')} 数量={r.get('quantity')} 锁定={r.get('lockedQty')} 状态={r.get('status')}")
+ok_sn = [r for r in dd2.get("list", []) if r.get("snCode")]
+assert len(ok_sn) >= 5, "精密件明细应有 >=5 SN"
+print(" ✓ 明细分页下钻正常,SN/批次仅在明细层可见")
+
+print("\n=== 6) 筛选联动验证:区域 + 质量状态 ===")
+s, f1 = req("GET", "/api/stock/query?zoneCode=Z01&page=1&pageSize=50", TOKEN)
+print(" zoneCode=Z01 -> total:", f1.get("data", {}).get("total"))
+s, f2 = req("GET", "/api/stock/query?qualityStatus=" + urllib.parse.quote("未检") + "&page=1&pageSize=50", TOKEN)
+print(" qualityStatus=未检 -> total:", f2.get("data", {}).get("total"))
+s, f3 = req("GET", "/api/stock/query?manageMode=2&page=1&pageSize=50", TOKEN)
+md2 = f3.get("data", {}).get("list", [])
+print(" manageMode=2 -> total:", f3.get("data", {}).get("total"), "| 全部为精密件:", all(r["manageMode"]==2 for r in md2))
+
+print("\n=== ALL CHECKS PASSED ===")
\ No newline at end of file
diff --git a/e2e_tmp/batch_bad.xlsx b/e2e_tmp/batch_bad.xlsx
new file mode 100644
index 0000000..5732e7b
Binary files /dev/null and b/e2e_tmp/batch_bad.xlsx differ
diff --git a/e2e_tmp/batch_ok.xlsx b/e2e_tmp/batch_ok.xlsx
new file mode 100644
index 0000000..5f6796f
Binary files /dev/null and b/e2e_tmp/batch_ok.xlsx differ
diff --git a/e2e_tmp/sn_bad.xlsx b/e2e_tmp/sn_bad.xlsx
new file mode 100644
index 0000000..325c0f3
Binary files /dev/null and b/e2e_tmp/sn_bad.xlsx differ
diff --git a/e2e_tmp/sn_ok.xlsx b/e2e_tmp/sn_ok.xlsx
new file mode 100644
index 0000000..088199f
Binary files /dev/null and b/e2e_tmp/sn_ok.xlsx differ
diff --git a/e2e_verify.py b/e2e_verify.py
new file mode 100644
index 0000000..01ef8b9
--- /dev/null
+++ b/e2e_verify.py
@@ -0,0 +1,129 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""统一出库主表 + 装箱属性落库 端到端验证脚本"""
+import json
+import urllib.request
+import urllib.error
+
+BASE = "http://127.0.0.1:8890"
+
+def req(method, path, body=None, token=None, raw=False):
+ url = BASE + path
+ data = None
+ headers = {"Content-Type": "application/json"}
+ if token:
+ headers["Authorization"] = "Bearer " + token
+ if body is not None:
+ data = json.dumps(body).encode("utf-8")
+ r = urllib.request.Request(url, data=data, headers=headers, method=method)
+ try:
+ with urllib.request.urlopen(r, timeout=10) as resp:
+ raw_text = resp.read().decode("utf-8")
+ return resp.status, raw_text
+ except urllib.error.HTTPError as e:
+ return e.code, e.read().decode("utf-8", "replace")
+
+def j(resp_text):
+ try:
+ return json.loads(resp_text)
+ except Exception:
+ return {"_raw": resp_text}
+
+def data_of(resp_text):
+ d = j(resp_text)
+ return d.get("data") if isinstance(d, dict) else None
+
+print("=== 0) login ===")
+st, txt = req("POST", "/api/auth/login", {"username": "admin", "password": "123456"})
+print(st, txt)
+tok_data = data_of(txt)
+TOKEN = tok_data.get("token") if isinstance(tok_data, dict) else None
+print("token:", (TOKEN[:20] + "...") if TOKEN else None)
+
+def ensure_material(code, name, mode):
+ # 查是否已存在
+ st, txt = req("GET", "/api/material/query?keyword=" + code, token=TOKEN)
+ lst = data_of(txt)
+ if isinstance(lst, dict):
+ for m in lst.get("list", []):
+ if m.get("code") == code:
+ print(" 物料已存在:", m.get("id"), code, "mode=", m.get("manageMode"))
+ return m
+ # 创建
+ st, txt = req("POST", "/api/material/create",
+ {"code": code, "name": name, "manageMode": mode, "unit": "PCS"},
+ token=TOKEN)
+ print(" 创建物料", st, txt)
+ return data_of(txt)
+
+print("=== 1) 物料档案 ===")
+MAT_B = ensure_material("TEST_BATCH_001", "测试结构件", 1)
+MAT_S = ensure_material("TEST_SN_001", "测试精密件", 2)
+
+print("=== 2) 批次入库 TEST_BATCH_001 B-BOX001 qty5 ===")
+st, txt = req("POST", "/api/inbound/create",
+ {"inboundType": "purchase", "materialCode": "TEST_BATCH_001",
+ "batchNo": "B-BOX001", "quantity": 5, "zoneCode": "Z01", "operator": "tester"},
+ token=TOKEN)
+print(st, txt)
+
+print("=== 3) SN入库 TEST_SN_001 SNBOX001/SNBOX002 ===")
+st, txt = req("POST", "/api/inbound/create",
+ {"inboundType": "purchase", "materialCode": "TEST_SN_001",
+ "snList": ["SNBOX001", "SNBOX002"], "zoneCode": "Z01", "operator": "tester"},
+ token=TOKEN)
+print(st, txt)
+
+print("=== 4) 通用出库(批次+箱号 BOXTEST001) ===")
+st, txt = req("POST", "/api/outbound/general",
+ {"materialCode": "TEST_BATCH_001", "batchNo": "B-BOX001", "qty": 3,
+ "operator": "tester", "zoneCode": "Z01", "boxNo": "BOXTEST001",
+ "contractNo": "HT2026-001", "remark": "批次装箱测试"},
+ token=TOKEN)
+print(st, txt)
+
+print("=== 5) 通用出库(SN+箱号 BOXTEST002) ===")
+st, txt = req("POST", "/api/outbound/general",
+ {"materialCode": "TEST_SN_001", "snList": ["SNBOX001"],
+ "operator": "tester", "zoneCode": "Z01", "boxNo": "BOXTEST002",
+ "contractNo": "HT2026-002"},
+ token=TOKEN)
+print(st, txt)
+
+print("=== 6) 查询 general 出库单(校验装箱字段落库) ===")
+st, txt = req("GET", "/api/outbound/query?outboundType=general", token=TOKEN)
+d = data_of(txt)
+lst = d.get("list", []) if isinstance(d, dict) else []
+print(" total=", d.get("total") if isinstance(d, dict) else None)
+for row in lst:
+ print(" ---")
+ print(" outboundNo=", row.get("outboundNo"), "type=", row.get("outboundType"),
+ "material=", row.get("materialCode"), "qty=", row.get("quantity"))
+ print(" boxNo=", repr(row.get("boxNo")), "contractNo=", repr(row.get("contractNo")),
+ "boxSnList=", repr(row.get("boxSnList")))
+ for det in row.get("details", []):
+ print(" detail:", det.get("snCode") or det.get("batchNo"), "qty=", det.get("quantity"))
+
+print("=== 7) 导出全部出库(整表导出) ===")
+st, txt = req("GET", "/api/outbound/export", token=TOKEN)
+d = data_of(txt)
+lst = d.get("list", []) if isinstance(d, dict) else []
+print(" total=", d.get("total") if isinstance(d, dict) else None)
+boxnos = sorted({r.get("boxNo") for r in lst if r.get("boxNo")})
+print(" boxNos=", boxnos)
+# 抽样打印含装箱字段的行
+for row in lst:
+ if row.get("boxNo"):
+ print(" 导出行:", row.get("outboundNo"), "boxNo=", row.get("boxNo"),
+ "contractNo=", row.get("contractNo"), "snCodes=", row.get("snCodes"))
+
+print("=== 8) 旧装箱接口应 404 ===")
+st, txt = req("GET", "/api/package/query", token=TOKEN)
+print(" HTTP", st)
+
+print("=== 9) 大屏 packageCount(应随箱号记录增长) ===")
+st, txt = req("GET", "/api/display/overview")
+d = data_of(txt)
+print(" packageCount=", d.get("packageCount") if isinstance(d, dict) else txt)
+
+print("=== DONE ===")
diff --git a/问题修复记录.md b/问题修复记录.md
index af10377..637fb46 100644
--- a/问题修复记录.md
+++ b/问题修复记录.md
@@ -621,3 +621,51 @@ wms
1、代码,要写清楚中文注释
2、schema数据库注释,必须写的非常详细,并且准确。
+
+
+
+20260903
+
+1、
+报错:该物料为精密件(按 SN 管理),请切换到「精密件 SN 入库」页扫码录入后再提交
+修复建议:物料选择 时,直接根据当前页面过滤,不显示不属于自己的。
+
+2、
+wms前端提示:最近入库单号:IB0de1c3bd-7ad
+但是,库存查询 也 没有显示 入库单号 的地方。没闭环,很费解、很别扭。和批次号,什么区别,设计思路是什么。
+请给我方案让我确认,你再改
+
+
+3、
+Excel 批量导入
+用于大批量结构件入库。列头顺序(首行忽略):物料编码 | 批次号(空自动生成) | 数量 | 生产日期 | 供应商 | 区域。
+
+“Excel 批量导入”名称有歧义,并且,sn件也应该能导入。所有导入,直接在各自的 tab页即可,不用单独一个“Excel 批量导入”
+你看看,导入,在什么地方显示,比较合适。并且,批量导入必须一次性全导入or全失败,禁止部分成功。
+
+4、
+当我没有起到 mes时,wms的“备料出库”提示,无法连接到mes系统。 说明:这个出库是专门给mes用的。缺少一个 单独的出库操作? 并且,如果专门给mes用,这个“备料出库”名称,没有体现出来这一点。帮助说明,也没体现这一点。
+请给出,业内 合理方案
+
+5、
+wms的质量检验 ,需要手动输入 批次号,这很不友好,垃圾设计,你再想想,帮我闭环
+
+6、
+当前目标:批次号 ? 当前目标:SN 序列号 ,当前根据“目标类型” 的选择,改变文字提醒。建议,你能改变这个体现,不能改变输入框体现吗?
+另外,多个输入框,已经包含了单个框,就没有必要保留“单个录入(扫码)”,也不用提示 “批量翻转” ,一个批量即可,写清楚引导,和帮助文档
+并且提示:检验已记录:BATCH cccccccc333 → 合格 。但是库存查询菜单里面 的 “质量状态” 仍然是 未检 ,可能因为我输入的是sn,但是,提示成功了。
+操作不了还有其他错误提示:创建检验记录失败: ent: missing required field "InspectionRecord.material_code"
+业务没闭环,请全盘分析,能关联上,能闭环。
+
+
+7、
+我刚录入的 aaaaa3 aaaaa2 aaaa1 ,他们是一批的,为什么 “统一库存(结构件批次 + 精密件 SN)” 显示,3个? 你能不能闭环? 我原来给你说的,主表子表,你设计不明吗?请说明原因,正确的方案是什么,你可以坚持当前代码方案是正确,如果你真觉得是对的。
+
+
+8、
+wms、mes等等 5个系统,,所有列表,都必须展示 主键ID,方便,后续查库,并且Id是唯一的。
+
+
+
+
+
diff --git a/问题记录.md b/问题记录.md
index b28b04f..ebd446a 100644
--- a/问题记录.md
+++ b/问题记录.md
@@ -1,3 +1,38 @@
+wms系统 + wms客户端系统:
+
+一、wms 和 wms客户端
+1、缺少 角色维护
+2、缺少 角色菜单按钮权限
+二、物料档案(菜单页)
+1、搜索条件缺少,去除关键字搜索 ,增加 物料编码、名称、规格、类型 。该模糊就模糊,该下拉选择就下拉选择,用合适的方式
+2、所有列表,增加创建时间 字段,展示格式 yyyy‑MM‑dd HH:mm:ss ,搜索条件增加 创建时间筛选,默认3个月内筛选
+3、所有列表,增加主键ID
+
+三、区域维护(菜单页)
+1、搜索条件缺少,去除关键字搜索 ,增加 区域编码、区域名称、状态 。该模糊就模糊,该下拉选择就下拉选择,用合适的方式
+2、所有列表,增加创建时间 字段,展示格式 yyyy‑MM‑dd HH:mm:ss ,搜索条件增加 创建时间筛选,默认3个月内筛选
+3、所有列表,增加主键ID
+
+四、备料台账
+1、填充一些假数据,方便我看展示效果
+
+五、半成品/成品
+1、这种展示方式叫什么名字? 内嵌tab页? tab1、tab2、tab3
+2、和 菜单 出库管理 ,是什么区别关系
+
+
+六、库存盘点
+1、发起盘点,锁所有库存吗?能不能选择,盘点全库、盘点 某个物料
+2、既然要盘点,首先有 物料分布图,怎么看现有分布,哪个物料,在哪个货架,每个多少,一共多少。
+
+七、质量检验
+1、你设计东西,都是胡胡闹闹。质量检测无法闭环,没有记录,没有检测说明,没有不合格说明,不能导出
+
+八、库存查询
+1、设计的乱七八糟。
+2、有主表子表,你为什么把sn漏出来,不应该同 物料类型,只漏 数量吗?
+3、同批次 和 库存查询,你是如何定义职责的。我理解库存是查 物料数量的地方,出入库管理,可以查自己的批次。
+
diff --git a/项目现状说明.md b/项目现状说明.md
new file mode 100644
index 0000000..fa9332c
--- /dev/null
+++ b/项目现状说明.md
@@ -0,0 +1,261 @@
+# 北京电力 WMS 项目 · 现状说明(供重新规划)
+
+> 生成时间:2026-09-03
+> 用途:作为「重新规划」的输入材料,供其他 AI 直接阅读并制定方案
+> 核实方式:全部内容来自实际代码读取(非记忆/推测),关键结论已标注代码位置
+
+---
+
+## 一、系统全景
+
+根仓库 `D:\hardman\bj_power` 下共有 **5 个子系统**:
+
+| 子系统 | 端口 | 技术 | 职责 | 备注 |
+|---|---|---|---|---|
+| `bj_power_wms` | **8890** | Go + go-zero + ent | **WMS 后端**(全部业务逻辑 + PostgreSQL) | 核心系统 |
+| `bj_power_wms_client` | **8891** | Go(仅 config/proxy) | **库房客户端**:静态托管 + `/api` 反向代理 | **无业务逻辑**,转发到 8890 |
+| `bj_power_mes` | — | Go + ent + frontend | MES 系统(工单、BOM、台账来源) | 独立服务 |
+| `bj_power_workstation` | — | Go + frontend + SQLite | 工位终端 | 独立 DB |
+| `bj_power_dashboard` | — | Vue3 + TS + Vite | 大屏展示(独立构建产物 dist) | 免登录 |
+
+**关键架构事实(已核实)**:
+- `bj_power_wms_client`(8891)**不是**纯前端壳,它是一个 Go 服务,但 `internal/` 下**只有 `config` 和 `proxy` 两个包**,无 handler、无 ent。
+- 其配置 `etc/bj_power_wms_client.yaml` 明确:`WmsAddr: http://127.0.0.1:8890`,请求转发时注入 `X-API-TOKEN: Hardman_2026`。
+- 因此:**前端页面(8891)看到的 `/api/*` 实际全部由 8890 的 WMS 后端处理**。
+- 前端源码在 `bj_power_wms_client/frontend/`,构建产物输出到 `bj_power_wms_client/web/static`。
+- `bj_power_wms`(8890)**不含 `go:embed`**,不内嵌前端,前端由 8891 单独提供。
+
+> 结论:用户口中的「wms 和 wms 客户端」= 8890(业务后端)+ 8891(客户端壳)。**所有功能/权限改造实际上都落在 8890。**
+
+---
+
+## 二、技术栈
+
+**后端(bj_power_wms)**
+- Go + `go-zero`(rest 路由)+ `ent` ORM
+- PostgreSQL(`bj_power_wms`,账号 `postgres/postgres`)
+- JWT 鉴权,Header:`Authorization: Bearer {token}`
+- 统一响应包裹 `{code, message, data}`,**业务数据一律在 `data` 层**
+- ent 代码生成:`go run -tags entgenerate ./tools`(build tag `entgenerate`,入口 `tools/generate.go`)
+
+**前端(bj_power_wms_client/frontend)**
+- Vue3(`