-
-
录入到列表
-
点【录入到列表】加入下方清单,回车只是换行
+
+
+
+
+
+
+ 点击右侧「明细」按钮加载(共 {{ 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 0000000000000000000000000000000000000000..5732e7b3ca61f4bda3ea6b080fc4a092f87a96da
GIT binary patch
literal 1741
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
literal 0
HcmV?d00001
diff --git a/e2e_tmp/sn_bad.xlsx b/e2e_tmp/sn_bad.xlsx
new file mode 100644
index 0000000000000000000000000000000000000000..325c0f3103099436c88fe7352a2079baf726de64
GIT binary patch
literal 1669
zcmWIWW@Zs#U|`^2$mvju%}d-u0|xmFSj_wWs6D`>*n*PdS*q;b8LVevg{nnPta2j(A-QKHWa&@!Ba%
z*h(CfT)w!zbSj!~+VHDUlhi`@%FyfAgIMQZe6Drg`p2@J$N9dQZEjx~Grvk@%aNOR
zPg-n_;xmj|tRNmMx96YR%5yA%le*j4gfl~VV&YWJoI2l7{BEa>kCD_(?j?U%*6nzb
z^0eCHaWu!`AGIf2r!9Ql<0O0K-qel-GXzX{J8nj9^m%yT$JP6Pj`|cyzOL8%wDtey
z_LzOupBV#Cf@{XD*WK%Y!KBW}z`zZphHLBi{%>6QnyocGs9!SoPvFO?9-F?fEpAACr2pyh+>A3q
zTzj;BK0Ek|7u8#HbDa0?1bRmr=q-LAU6G?-o?n!ml%JmsN&&t8-s=t-@a*w=V!I@@
zao(zJIRZl24;U5}X1XMYm;KmtTez+|%=lWyG2^eVbngWU(QiqvgV7<#icc;PQFrk
zv4^iiv#%^r#9KO6p+Y_6lQ-M<+7|_9@2V;-E$?{nB1mt&^9+$&zrH%!XPh`1Q}z8x
zs@$g|YtLIB^bj?gxK{t*wyl3}y(y3U&4B95sJ~6w8-c#l0Qyo2;!8;UA^Q~?mnXRn
zIe_9)vS@pzZHLG5IF}cpub6o*mn@vydUD5?x3V@Jto-wwK26qNVq)5UXXikwk=2Bscl(h(^Pbh6s$SLeO7CksGQq*CCO>3JchrRm0maYJUUak
zN%Lilfz~p!FE6If3-RA^_OonTKJ&F@E223U=5Y(0ee_cPcIn*}FQ0rlJE7O!@4?~L
zPWDAJ0z|92gzS`$m1}NV!W5>u&TdT{szZb1nvPovwpIy#)SmKi
zdF1W&iEkXg?8tNPudLi(FK2&3dGTtUQn3Xl(+@fSo!2cRu+eA7jLHnDd2(|V+@;lo
zrg<^S?PBY7o1(LH*Og+1#}RQE{AYVlwoAOO;FG*;JF|I%xp+|OnH!5|CC=4O@Z?U_
zl-u#n@SKEr$E0m$X>Eavo=lo_!%*(o-g;#W~E^r)<&ZEj^cH-P(I;fmlOE@%O;>
z^JksiRk>I3MnKd4*@b(ey$oKS3;UiLDsuN{T4>Jc@4r5-t-Gn5`uk_h)YY#Ze#>Xc
zYkikv`lZ=`OKqb4hwl@!K4#e1zLqO|_igv#cBTKaa;FskeZBngJlDeb0B=Sn5eD34
zB`{>cpaB@vSW8ZH1JFwkh;{~s21b3L5lCeTx<>T;iO|RfEVt2eE4pU%WRKAN2BH~9
jCO|g_JyjvhxylT81V$PQ@MdKLsbmF0SD-JoSV24hQmUsZ
literal 0
HcmV?d00001
diff --git a/e2e_tmp/sn_ok.xlsx b/e2e_tmp/sn_ok.xlsx
new file mode 100644
index 0000000000000000000000000000000000000000..088199f4951c5806cf0237419e823cc9207b6eb6
GIT binary patch
literal 1668
zcmWIWW@Zs#U|`^2$mvju%}d-u0|xmFSj_wWs6D`>*n*PdS*q;b8LVevg{nnPta2j(A-QKHWa&@!Ba%
z*h(CfT)w!zbSj!~+VHDUlhi`@%FyfAgIMQZe6Drg`p2@J$N9dQZEjx~Grvk@%aNOR
zPg-n_;xmj|tRNmMx96YR%5yA%le*j4gfl~VV&YWJoI2l7{BEa>kCD_(?j?U%*6nzb
z^0eCHaWu!`AGIf2r!9Ql<0O0K-qel-GXzX{J8nj9^m%yT$JP6Pj`|cyzOL8%wDtey
z_LzOupBV#Cf@{XD*WK%Y!KBW}z`zZphHLBi{%>6QnyocGs9!SoPvFO?9-F?fEpAACr2pyh+>A3q
zTzj;BK0Ek|7u8#HbDa0?1bRmr=q-LAU6G?-o?n!ml%JmsN&&t8-s=t-@a*w=V!I@@
zao(zJIRZl24;U5}X1XMYm;KmtTez+|%=lWyG2^eVbngWU(QiqvgV7<#icc;PQFrk
zv4^iiv#%^r#9KO6p+Y_6lQ-M<+7|_9@2V;-E$?{nB1mt&^9+$&zrH%!XPh`1Q}z8x
zs@$g|YtLIB^bj?gxK{t*wyl3}y(y3U&4B95sJ~6w8-c#l0Qyo2;!8;UA^Q~?mnXRn
zIe_9)vS@pzZHLG5IF}cpub6o*mn@vydUD5?x3V@Jto-wwK26qNVq)5UXXikwk=2Bscl(h(^Pbh6s$SLeO7CksGQq*CCO>3JchrRm0maYJUUak
zN%Lilfz~p!FE6If3-RA^_OonTKJ&F@E223U=5Y(0ee_cPcIn*}FQ0rlJE7O!@4?~L
zPWDAJ0z|92gzS`$m1}NV!W5>u&TdT{szydLJd{`>FeAG24cRLx62Cp6_*(2oq8qKV8((>TSsuJUJ
z_vPMN?%l+z`eU=?@!d;XHvh}14ZfOsbAJByr`xJB-_4tSoKf@k_x4>;*SCk;D;KDj
zq^}MXaQj?f#=j?_BcyxxyB+n1P73_ke!_mih2P>_AGg={?HA+t3rswWOd<@pi%MX?
zfKqHWf5_FB|*%P6W4OncWWma^}=&2r|`3*!fjy!;F
g4tk 生成时间: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(`