feat: 为MES和工作站服务添加启动后自动打开浏览器功能

优化WMS客户端URL处理逻辑,移除尾斜杠避免请求404错误
更新工作站配置默认工位号为1
新增数据库字段支持工位页签配置
删除WMS端未完成的权限树形化和范围验证测试文件
删除物料档案对齐方案文档

新增自动打开浏览器的跨平台实现,服务启动后等待1秒后打开管理后台,提升使用便捷性
This commit is contained in:
SunYF
2026-09-24 17:27:24 +08:00
parent f274da044c
commit d9d4155de6
9 changed files with 63 additions and 221 deletions
+25
View File
@@ -10,8 +10,11 @@ import (
"io/fs"
"net/http"
"os"
"os/exec"
"path"
"runtime"
"strings"
"time"
"bj_power_mes/common/logx"
"bj_power_mes/internal/config"
@@ -150,6 +153,22 @@ func newStaticHandler(fsys embed.FS) http.Handler {
})
}
// openBrowser 用系统默认浏览器打开 url(Windows/macOS/Linux)
func openBrowser(url string) {
var cmd *exec.Cmd
switch runtime.GOOS {
case "windows":
cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
case "darwin":
cmd = exec.Command("open", url)
default:
cmd = exec.Command("xdg-open", url)
}
if err := cmd.Start(); err != nil {
gologx.Errorf("自动打开浏览器失败(%s): %v", url, err)
}
}
func main() {
flag.Parse()
@@ -221,5 +240,11 @@ func main() {
fmt.Printf("Starting bj_power_mes server at %s:%d...\n", c.Host, c.Port)
gologx.Infof("Starting bj_power_mes server at %s:%d...", c.Host, c.Port)
// 启动后自动打开管理后台(等 1 秒让端口就绪;Host=0.0.0.0 时用 127.0.0.1 访问)
go func() {
time.Sleep(time.Second)
openBrowser(fmt.Sprintf("http://127.0.0.1:%d", c.Port))
}()
server.Start()
}
+5 -3
View File
@@ -110,8 +110,8 @@ var schemaPatchSQL = []string{
`ALTER TABLE inspection_record ADD COLUMN IF NOT EXISTS attachment_ids varchar(512) NOT NULL DEFAULT ''`,
`ALTER TABLE inspection_record ADD COLUMN IF NOT EXISTS disposal_type varchar(20) NOT NULL DEFAULT ''`,
`ALTER TABLE inspection_record ADD COLUMN IF NOT EXISTS disposal_remark varchar(500) NOT NULL DEFAULT ''`,
// work_order_bom 表名注解为 work_order_bom
`ALTER TABLE work_order_bom ADD COLUMN IF NOT EXISTS related_standard varchar(255) NOT NULL DEFAULT ''`,
// work_order_bom 表名注解为 work_order_bom(表已于 2026-09-23 退役删除,此补丁仅对历史库存留档库有意义)
`ALTER TABLE IF EXISTS work_order_bom ADD COLUMN IF NOT EXISTS related_standard varchar(255) NOT NULL DEFAULT ''`,
`ALTER TABLE process_step ADD COLUMN IF NOT EXISTS attachment jsonb NOT NULL DEFAULT '[]'::jsonb`,
`ALTER TABLE work_order ADD COLUMN IF NOT EXISTS created_by varchar(64) NOT NULL DEFAULT ''`,
`ALTER TABLE work_order ADD COLUMN IF NOT EXISTS due_date timestamp`,
@@ -194,7 +194,6 @@ var schemaPatchSQL = []string{
`ALTER TABLE workpiece_process DROP COLUMN IF EXISTS process_code`,
`ALTER TABLE workpiece_bind DROP COLUMN IF EXISTS process_code`,
`ALTER TABLE material_request DROP COLUMN IF EXISTS process_code`,
`ALTER TABLE work_order_bom DROP COLUMN IF EXISTS process_code`,
`ALTER TABLE workpiece DROP COLUMN IF EXISTS current_process`,
// 既有库补列(新装库由 ent AutoMigrate 直接建出,此处幂等兜底)
`ALTER TABLE work_order ADD COLUMN IF NOT EXISTS flow_combination jsonb`,
@@ -206,6 +205,9 @@ var schemaPatchSQL = []string{
// ---- 半成品流转新模型(2026-09-22 定稿):进度唯一权威源=workpiece.currentStationNo ----
// semi_flow 补进度快照列 completed_text("已完成至工位N");旧 done_process_codes 保留历史数据、不再写入
`ALTER TABLE semi_flow ADD COLUMN IF NOT EXISTS completed_text varchar(64) NOT NULL DEFAULT ''`,
// ---- 工位终端可见页签配置(2026-09-23):按工位勾选终端显示哪些页签(jsonb 数组),空=全部显示 ----
`ALTER TABLE station ADD COLUMN IF NOT EXISTS visible_tabs jsonb`,
}
// EnsureSchema ent 建表 + 幂等列补丁(只增,不删数据)
@@ -1,4 +1,4 @@
package production
package production
import (
"net/http"
+4 -5
View File
@@ -36,19 +36,18 @@ func New(baseURL, token string) *Client {
return c
}
// SetBaseURL 热切换 WMS 地址;空串回退默认地址
// SetBaseURL 热切换 WMS 地址;空串回退默认地址。
// 注意:所有请求路径都以 "/api/..." 开头,这里必须去掉尾斜杠,否则拼出 "//api" 404。
func (c *Client) SetBaseURL(u string) {
u = strings.TrimSpace(u)
if u == "" {
u = defaultBaseURL
}
if !strings.HasSuffix(u, "/") {
u += "/"
}
u = strings.TrimRight(u, "/")
c.baseURL.Store(u)
}
// BaseURL 当前生效的 WMS 地址(带尾斜杠)
// BaseURL 当前生效的 WMS 地址(不带尾斜杠,请求路径以 /api/... 开头直接拼接)
func (c *Client) BaseURL() string {
return c.baseURL.Load().(string)
}
-58
View File
@@ -1,58 +0,0 @@
# -*- coding: utf-8 -*-
"""验证:权限树形化(parent_code) - seed 回填 + /permissions 返回父子结构"""
import json, urllib.request, urllib.error
BASE = "http://127.0.0.1:8902"
TOKEN = None
def req(method, path, payload=None):
url = BASE + path
data = json.dumps(payload).encode() if payload 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=20) as resp:
return json.loads(resp.read().decode())
except urllib.error.HTTPError as e:
return {"_http": e.code, "_body": e.read().decode()[:300]}
def main():
global TOKEN
d = req("POST", "/api/auth/login", {"username": "admin", "password": "123456"})
TOKEN = (d.get("data") or {}).get("token")
assert TOKEN, f"登录失败: {d}"
print("✓ 登录成功")
# 执行 seed 回填 parent_code
sd = req("POST", "/api/rbac/seed")
print("✓ seed 完成:", sd.get("data") or sd)
# 拉取权限,校验 parent_code
pl = req("GET", "/api/permissions")
listp = (pl.get("data") or {}).get("list") or []
menus = [p for p in listp if p["type"] == "MENU"]
buttons = [p for p in listp if p["type"] == "BUTTON"]
print(f"权限总数 {len(listp)}:菜单 {len(menus)},按钮 {len(buttons)}")
# 校验每个按钮的 parent_code 是否为有效菜单 code 或为空(全局)
menu_codes = {m["code"] for m in menus}
bad = [p for p in buttons if p.get("parentCode") and p["parentCode"] not in menu_codes]
global_btns = [p for p in buttons if not p.get("parentCode")]
print(f" 按钮中全局(无父)数: {len(global_btns)} -> {[p['code'] for p in global_btns]}")
print(f" 按钮 parent_code 有误(指向不存在菜单): {[p['code'] for p in bad]}")
assert not bad, "存在 parent_code 指向无效菜单"
print("✓ 所有按钮 parent_code 均有效(或为全局空)")
# 展示各菜单下挂的按钮(模拟前端树)
for m in menus:
kids = [p["name"] for p in buttons if p.get("parentCode") == m["code"]]
print(f" [{m['name']}] 挂按钮: {kids if kids else '(无)'}")
passed = not bad and len(global_btns) >= 1
print("\n==== 验证 %s ====" % ("通过" if passed else "失败"))
return passed
if __name__ == "__main__":
raise SystemExit(0 if main() else 1)
-83
View File
@@ -1,83 +0,0 @@
# -*- coding: utf-8 -*-
"""端到端验证:问题四(备料台账无单选全部) + 问题六(盘点范围 + 物料分布)"""
import json, urllib.request, urllib.parse, urllib.error
BASE = "http://127.0.0.1:8902"
TOKEN = None
def req(method, path, payload=None, params=None):
url = BASE + path
if params:
qs = urllib.parse.urlencode({k: v for k, v in params.items() if v not in (None, "")})
if qs:
url += "?" + qs
data = json.dumps(payload).encode() if payload 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=20) as resp:
return json.loads(resp.read().decode())
except urllib.error.HTTPError as e:
return {"_http": e.code, "_body": e.read().decode()[:300]}
def login():
global TOKEN
d = req("POST", "/api/auth/login", {"username": "admin", "password": "123456"})
TOKEN = d.get("data", {}).get("token")
assert TOKEN, f"登录失败: {d}"
print("✓ 登录成功 admin")
passed = []
def check(name, cond, extra=""):
passed.append(bool(cond))
print(("✓ " if cond else "✗ ") + name + (f" {extra}" if extra and not cond else ""))
def main():
login()
# ---------- 问题四:备料台账无单号查全部 ----------
d = req("GET", "/api/ledger/query", params={"page": 1, "pageSize": 20})
t4 = d.get("data", {}) or d
check("ledger/query 无 orderNo 返回 total+list", "list" in t4 and "total" in t4,
f"got {list(t4.keys())}")
# ---------- 问题六:盘点范围按物料过滤 ----------
# 先看库存有哪些物料可盘点,选一个真实物料
st = req("GET", "/api/stock/query", params={"page": 1, "pageSize": 10})
stock_data = st.get("data", {}) or st
rows = stock_data.get("list") or []
if rows:
mat = rows[0]["materialCode"]
# 按该物料发起盘点
d = req("POST", "/api/stocktake/start", {"operator": "e2e", "materialCodes": [mat]})
data = d.get("data", {}) or d
check("startStocktake 按物料过滤成功", bool(data.get("stocktakeNo")),
f"got {data}")
check("按物料盘点 totalTargets>0", data.get("totalTargets", 0) > 0,
f"totalTargets={data.get('totalTargets')}")
# 按类型过滤发起
d2 = req("POST", "/api/stocktake/start", {"operator": "e2e", "manageMode": 1})
data2 = d2.get("data", {}) or d2
check("startStocktake 按类型(结构件)成功", bool(data2.get("stocktakeNo")),
f"got {data2}")
else:
check("stock/query 有数据(需先造数才能验盘点范围)", False, "库存为空")
# ---------- 问题六:物料分布数据源(聚合含 zoneCode) ----------
d = req("GET", "/api/stock/query", params={"page": 1, "pageSize": 50})
dist = d.get("data", {}) or d
dl = dist.get("list") or []
check("stock/query 返回 list(分布图数据源)", len(dl) > 0, f"len={len(dl)}")
if dl:
r0 = dl[0]
check("聚合行含 zoneCode/materialCode/totalQty",
all(k in r0 for k in ("zoneCode", "materialCode", "totalQty")),
f"keys={list(r0.keys())}")
print("\n==== 结果:%d/%d 通过 ====" % (sum(passed), len(passed)))
return all(passed)
if __name__ == "__main__":
ok = main()
raise SystemExit(0 if ok else 1)
@@ -24,7 +24,7 @@ Internal:
# 工位配置:StationNo 固定本终端工位号(仅允许运维改配置后重启生效,工人无法在前端修改)。
# 应拧颗数不再由工位固定配置,而是取自工件工艺流程中「拧紧工艺步骤」的数量。
Station:
StationNo: 0
StationNo: 1
# 工艺文件 PDF 本地预缓存目录(留空关闭缓存)
PdfCache:
+26
View File
@@ -6,6 +6,9 @@ import (
"flag"
"fmt"
"io/fs"
"os/exec"
"runtime"
"time"
"bj_power_workstation/internal/config"
"bj_power_workstation/internal/handler"
@@ -25,6 +28,22 @@ var webFS embed.FS
var configFile = flag.String("f", "etc/bj_power_workstation.yaml", "the config file")
// openBrowser 用系统默认浏览器打开 url(Windows/macOS/Linux)
func openBrowser(url string) {
var cmd *exec.Cmd
switch runtime.GOOS {
case "windows":
cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
case "darwin":
cmd = exec.Command("open", url)
default:
cmd = exec.Command("xdg-open", url)
}
if err := cmd.Start(); err != nil {
logx.Errorf("自动打开浏览器失败(%s): %v", url, err)
}
}
func main() {
flag.Parse()
@@ -85,5 +104,12 @@ func main() {
fmt.Printf("Starting bj_power_workstation server at %s:%d...\n", c.Host, c.Port)
logx.Infof("Starting bj_power_workstation server at %s:%d...", c.Host, c.Port)
// 启动后自动打开终端页面(等 1 秒让端口就绪;Host=0.0.0.0 时用 127.0.0.1 访问)
go func() {
time.Sleep(time.Second)
openBrowser(fmt.Sprintf("http://127.0.0.1:%d", c.Port))
}()
server.Start()
}
-69
View File
@@ -1,69 +0,0 @@
# 物料档案对齐 — 审核结果与方案(2026-09-21)
## 一、结论(先说)
「物料档案 类型下拉缺『其他』」**在 WMS 端没有丢、没有回退**——WMS 的品类/类型下拉一直都含「其他(辅料/工装/试验设备)」。
真正缺「其他」的是 **MES 物料档案**:MES 还停在「成品子集 /product-types」接口,根本没有全品类字段(品类/类型/安全库存/说明/附件全没有),所以 MES 端理所当然看不到「其他」。
MES 切到全品类 `/materials` 的链路**只做了一半就被中断**(WMS 侧只写了 delete handler、还没来得及注册路由)。这就是全部未完成工作量。
## 二、已落地且正确(无需再改,已核对源码)
1. WMS 物料档案品类/类型下拉含「其他」(item_type=4) —— BaseData.vue:
- 搜索区品类下拉 `L882`:`<el-option label="其他" :value="4" />`
- 新增表单品类下拉 `L966`:`<el-option :value="4" label="其他(辅料/工装/试验设备)" />`
- 结论:**未回退,仍存在**。
2. 出库记录「检测单号」列 + 搜索 —— Outbound.vue 已落地(表单 L501、列表列 L619/L726、搜索 L353/L662)。
3. 来料检验抽屉分页(每页20) + 批量送检上限100 —— Inspection.vue 已落地(drawer L832、pendingPage.size=20、上限100 L312)。
4. 单号格式 `IB/OB + YYYYMMDD + -NNN` + 手填/自动生成 + 唯一校验 —— inbound.go(nextDailyNo L28 / validateUniqueNo L95)、outbound.go、excel.go 均已接入。
## 三、未完成清单 + 最优方案(按执行顺序)
### A. WMS 侧(2 处)
1. **注册删除路由**:routes.go 的 `/api/internal/materials`(L244-246)目前只有 GET/POST/PUT,补一行:
`POST /api/internal/materials/delete` → `deleteMaterialHandler`(handler 已在 material.go L179 写好,只差注册)。
2. **加附件内部代理**:新增 `/api/internal/attachments/upload | list | delete`(X-API-TOKEN 版),供 MES 代理调用,保证 MES/WMS 附件单一来源一致。
### B. MES 后端(4 处)
3. wmsclient 补 `MaterialDelete` + 附件方法(upload/list/delete)。
4. logic 新建 materials 代理方法(page/get/post/put/delete,纯代理 WMS;WMS 宕机时读写均报错并提示,无本地缓存)。
5. handler 加 materials handler(对应 6 个接口)。
6. routes_production.go 注册 `/materials/*`;perm.go 加对应权限码。
### C. MES 前端(1 处)
7. ProductType.vue:接口 `/product-types` → `/materials`;字段对齐 WMS(**图号/名称/规格/单位/品类/类型/安全库存/说明/附件**);「编码」label 改「图号」;品类下拉含「其他」。
### D. 验证
8. WMS + MES 后端编译通过,前端 build 通过。
## 四、关键决策(已定,不再来回问)
- MES「物料档案」菜单 = WMS 物料**全品类**代理视图;品类/类型含「其他」,与 WMS 完全一致。
- MES 原 `/product-types`(成品子集)= **纯 WMS 代理**(方案 A,已落地代码,见第五节):不再保留本地 `product_type` 缓存/双写;WMS 不可达即报错,不再降级读本地。**继续供工单/BOM 联查下拉使用,零影响**。
- MES **不建**独立物料表(无本地缓存);附件走 WMS 单一来源,保证两边一致、不分裂。
- 物理删除 `product_type` 表属 ent 代码生成级改动,受「不编译」铁律约束,未在本轮执行,留给 `go generate ./ent` + `go build` 步骤(见第五节 D)。
## 五、/product-types 落地进展(2026-09-21 续 · 方案 A)
### 决策背景
用户确认:**没有「WMS 宕机时 MES 还必须看物料」的硬需求**,按默认建议选方案 A(纯代理,单一真源 = WMS)。
经代码核查,本地 `product_type` 表除「成品下拉降级兜底」外,还被**流程卡**、**排产支撑**两处生产代码读取(全代码零写入点,本就是空/陈旧残留)。
审计结论:双写/触发同步无法保证最终一致;纯代理可数学上保证一致。
### 已落地(代码,未编译验证)
1. `internal/logic/product.go`:删除 `localProductTypes` 降级函数 + 三处兜底分支;`ListProductTypes`/`ListProductTypesPage` 改为纯 WMS 代理(WMS 宕→报错,不再喂空/旧数据);新增 `ProductTypeByID`/`ProductTypeByCode`(读 WMS 全量按 id/code 定位);删除 `ent`、`ent/producttype` 两个不再使用的 import。
2. `internal/handler/processcard_model.go`(流程卡):`buildModelCard` 原读本地 `client.ProductType.Get/Query` → 改为 `logic.New(svcCtx).ProductTypeByID/ByCode`(WMS)。**顺带修复**:此前本地表为空,流程卡产品名实际取不到,现改读 WMS 可正常显示。
3. `internal/logic/producible.go`(排产支撑):产品名称映射、`dropInactiveProducts` 停用过滤,原读本地 `EntClient.ProductType` → 改为 `s.ctx.Wms.ProductTypes`(全量 / `isActive=false` 取停用)。**顺带修复**:此前本地表为空,停用成品不会被剔除,现改读 WMS 可正常生效。
4. 应用层(logic/handler)已无任何 `EntClient.ProductType` / `localProductTypes` 引用;WMS 端 `/api/internal/product-types?isActive=false` 已支持只返回停用成品,链路自洽。
### 物理删表(2026-09-21 已执行完成 · 用户授权「你干吧」)
- 执行链:删 `schema/product_type.go` → `go run ./tools/generate.go` 重生 ent(自动剔除 ProductType 生成代码)→ `go build ./...` 通过(BUILD_EXIT=0)→ 在 `db.go` 的 `schemaPatchSQL` 追加 `DROP TABLE IF EXISTS product_type` 并移除旧的 product_type ALTER 补丁 → `go run . migrate`(密码门禁通过)真实删除存量表。
- psql 核验:`information_schema` 中 `product_type` 表 count=0(已不存在)。
- 应用层三处纯代理改造(product.go / processcard_model.go / producible.go)经编译验证自洽。
- 前端 `ProductType.vue` 未改:`wmsOnline` 字段仍由后端返回(纯代理后恒为 `true`,预警横幅不会误触发),功能正常。
> 说明:本任务(方案 A 物理删表)已完成;但「MES 物料档案看到『其他』全品类」的原始目标仍依赖 临时.md 第三节的「全品类 /materials 代理视图」(8 处,WMS 2 + MES 后端 4 + MES 前端 1 + 验证 1),该部分一行未动,见下方「还有什么没干」。