Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c13dcd3ebc | ||
|
|
0b01b92c98 | ||
|
|
9dace6ac89 | ||
|
|
e99bbc2327 |
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Deployment-and-Operations
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-and-Operations.-
|
||||
wiki_revision: 9c10d971fe1dde057b13972566444a2234dd7f0e
|
||||
synchronized_at: 2026-09-27T03:42:42Z
|
||||
wiki_revision: 3a76e16d43c940425f68cc4748944213e980ad60
|
||||
synchronized_at: 2026-09-28T08:10:00Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
@@ -26,13 +26,47 @@ synchronized_at: 2026-09-22T02:56:36Z
|
||||
|
||||
## 当前线上拓扑
|
||||
|
||||
- 外部入口:`http://185.216.248.75:9527`,Nginx 同时承载 Admin 静态资源并反向代理 GoAuto API。
|
||||
- 外部入口:`http://122.228.200.167:9527`(2026-09-28 起;此前为 `185.216.248.75:9527`,旧机 `goauto.service` 已停止)。Nginx 在 9527 **直接提供 Admin 静态资源**,未命中静态文件的请求反向代理到 GoAuto API,配置见下节「Nginx 入口(9527)」。
|
||||
- GoAuto 服务监听:`127.0.0.1:8010`。
|
||||
- 常驻服务:systemd `goauto.service`。
|
||||
- 常驻服务:systemd `goauto.service`(新机依赖 `mysql84-cmhub.service`)。
|
||||
- 工作目录:`/home/goauto/current`,指向 `/home/goauto/releases/<发布标识>`。
|
||||
- 服务配置:`/home/goauto/current/config/settings.yml`;敏感环境变量由 `/etc/goauto/goauto.env` 提供,不写入 Git、Wiki、工单或日志。
|
||||
- Agent APK 私有目录:相对工作目录的 `var/goauto-agent-releases`;下载必须通过已认证接口。
|
||||
|
||||
## Nginx 入口(9527)
|
||||
|
||||
线上 vhost:`/www/server/panel/vhost/nginx/goauto-9527.conf`(宝塔面板目录)。标准配置:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 9527 default_server;
|
||||
server_name 122.228.200.167 _;
|
||||
root /home/goauto/current/dist;
|
||||
index index.html;
|
||||
client_max_body_size 100m;
|
||||
allow all;
|
||||
location = / {
|
||||
try_files /index.html =404;
|
||||
}
|
||||
location / {
|
||||
try_files $uri @goauto_backend;
|
||||
}
|
||||
location @goauto_backend {
|
||||
proxy_pass http://127.0.0.1:8010;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`[必须]` 不得把 9527 写成「全部 `proxy_pass` 到 8010」:GoAuto 服务端(go-admin)在 `GET /` 注册了「GO-ADMIN欢迎您」欢迎页,前端 SPA 只通过未匹配路由兜底提供,全部转发时首页就会显示欢迎页而不是 Admin 后台(2026-09-28 迁移时发生过,见 #346)。`location = /` 必须由 Nginx 返回 `dist/index.html`;其余路径先找静态文件,找不到再交给后端(后端对非 API 的 GET 返回 index.html 以支持前端路由,对 `/api/` 等返回真实结果)。修改后先 `nginx -t` 再 `nginx -s reload`,改前备份原文件。
|
||||
|
||||
## Agent HTTP 例外
|
||||
|
||||
服务端生产模式默认要求 Agent 使用 HTTPS。当前线上入口只有 HTTP,因此经 #181 用户明确确认,在 `/etc/goauto/goauto.env` 设置:
|
||||
@@ -49,7 +83,13 @@ GOAUTO_ALLOW_INSECURE_AGENT_HTTP=true
|
||||
|
||||
1. 在本地完成服务端测试/构建和 Android 单测/APK 构建,记录提交、versionCode、SHA-256 与大小。
|
||||
2. 创建新的 `/home/goauto/releases/<发布标识>`,复制服务端二进制、Web 静态资源和非敏感配置;保留旧发布目录用于回滚。
|
||||
3. 原子切换 `/home/goauto/current` 后重启 `goauto.service`,确认 `systemctl is-active goauto.service` 为 `active`,并从外部入口验证 API。
|
||||
3. 原子切换 `/home/goauto/current` 后重启 `goauto.service`,确认 `systemctl is-active goauto.service` 为 `active`,再从外部入口**按内容**验收(只看 HTTP 200 不算通过——欢迎页、错误页也可能是 200):
|
||||
- `GET /`:返回 HTML,包含 `id="app"`,且**不包含**「GO-ADMIN欢迎您」;
|
||||
- 前端路由(如 `GET /login`):同样返回 Admin 的 index.html;
|
||||
- `index.html` 引用的 `/js/…`、`/css/…` 资源:HTTP 200;
|
||||
- `GET /api/v1/captcha`:`application/json` 且 `code=200`;
|
||||
- 任一业务接口未登录访问(如 `GET /api/admin/v1/yeeke-returns`):JSON 业务码 401;
|
||||
- 结构日志无 panic/fatal/1146/1054。
|
||||
4. 上传 APK 到 Admin Agent 版本并按需设为当前;服务端解析 Manifest,校验 versionCode 唯一性并保存 SHA-256。
|
||||
5. 真机安装前确认设备没有运行中的任务。Android 系统安装确认仍由人工完成,Agent 不静默安装。
|
||||
|
||||
@@ -61,6 +101,19 @@ Admin 蝦皮规格 AI 匹配会同步等待外部 Provider:Provider 配置允
|
||||
|
||||
Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、上游 HTTP 状态码或网络错误分类;不得记录 API Key、Authorization、Provider URL、模型输入、候选规格、商品原始内容或响应正文。Provider 失败时接口返回 HTTP 503 与 `AI_MATCHING_UNAVAILABLE`,供 Admin 显示安全中文提示。
|
||||
|
||||
## 服务器迁移清单
|
||||
|
||||
更换线上服务器时逐项核对,每项都要在新机上回读确认:
|
||||
|
||||
1. **Nginx**:9527 vhost 按上节标准配置写入(root 指向 `/home/goauto/current/dist`,`location = /` 返回 index.html),`nginx -t` 通过后 reload;
|
||||
2. **发布目录**:`/home/goauto/releases/<发布标识>` 含二进制、`dist/index.html`、`config/settings.yml`(含 `extend.syb`、`extend.yeeke` 段);`current` 软链接指向它;静态目录与 `var`(APK 私有目录)随迁;属主 `goauto`;
|
||||
3. **环境变量**:`/etc/goauto/goauto.env` 与旧机字段一致(`GOAUTO_DB_*`、`GOAUTO_SERVER_PORT=8010`、`GOAUTO_WEB_DIST`、`GOAUTO_CONFIG`、SYB/yeeke 账号、Agent HTTP 例外等),权限 600 `root:goauto`;含中文等非 ASCII 值时按字节核对,不能只比长度;
|
||||
4. **systemd**:`goauto.service` 的 `WorkingDirectory`、`EnvironmentFile`、`ExecStart` 与依赖的数据库服务;
|
||||
5. **数据库**:数据完整迁移后,`sys_migration` 最新版本与旧机一致;
|
||||
6. **定时任务**:`sys_job` 启用状态与旧机一致,避免新旧两机同时执行同一定时任务(旧机须停服);
|
||||
7. **外部依赖**:SYB、yeeke、OCR 服务可达,先用手动同步验证登录;
|
||||
8. 按「发布与验证」第 3 步做按内容验收;更新本页「当前线上拓扑」。
|
||||
|
||||
## 回滚
|
||||
|
||||
服务异常时把 `/home/goauto/current` 切回上一已验证发布目录并重启 `goauto.service`,随后复核服务状态和 Agent 接口。不要删除当前或历史 APK/发布目录来代替回滚;数据库变化如需回退必须单独评估。
|
||||
@@ -180,3 +233,9 @@ Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、
|
||||
## #338 退货匹配发布
|
||||
|
||||
发布时执行退货匹配及售后权限迁移,切换 Server/Web release,重启 `goauto.service` 并 reload Nginx;发布后验证健康接口、Web 首页、售后登录及退货匹配只读接口,不用真实商品提交作为健康检查。
|
||||
|
||||
## 2026-09-28 迁移到 122.228.200.167 与首页修复(#346)
|
||||
|
||||
- 线上服务由 185.216.248.75 迁移到 122.228.200.167(发布目录 `20260928-344-9dace6a`)。
|
||||
- 迁移后 `http://122.228.200.167:9527/` 显示 go-admin 欢迎页:新机 9527 vhost 写成了全部 `proxy_pass`,缺少 `root /home/goauto/current/dist` 与 `location = /`。已按「Nginx 入口(9527)」标准配置修复(原文件备份为 `goauto-9527.conf.bak-20260928150822`),`nginx -t` 通过后 reload;按内容验收首页、前端路由、静态资源、验证码与未登录接口均通过。
|
||||
- 代码侧根治见 #346:存在 dist 时服务端 `GET /` 也返回 SPA index.html,即使 Nginx 误配为全部转发也不再出现欢迎页。
|
||||
|
||||
@@ -25,12 +25,8 @@ const SPADirEnv = "GOAUTO_WEB_DIST"
|
||||
// dist; a NoRoute handler installed anyway would turn every genuine 404 into
|
||||
// an HTML page, which is far more confusing than a plain 404.
|
||||
func InitSPARouter(engine *gin.Engine) {
|
||||
dist := strings.TrimSpace(os.Getenv(SPADirEnv))
|
||||
if dist == "" {
|
||||
dist = "dist"
|
||||
}
|
||||
index := filepath.Join(dist, "index.html")
|
||||
if _, err := os.Stat(index); err != nil {
|
||||
dist, index, ok := spaIndex()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -56,6 +52,38 @@ func InitSPARouter(engine *gin.Engine) {
|
||||
})
|
||||
}
|
||||
|
||||
// spaIndex resolves the built frontend directory and reports whether its
|
||||
// index.html exists.
|
||||
func spaIndex() (dist, index string, ok bool) {
|
||||
dist = strings.TrimSpace(os.Getenv(SPADirEnv))
|
||||
if dist == "" {
|
||||
dist = "dist"
|
||||
}
|
||||
index = filepath.Join(dist, "index.html")
|
||||
if _, err := os.Stat(index); err != nil {
|
||||
return dist, index, false
|
||||
}
|
||||
return dist, index, true
|
||||
}
|
||||
|
||||
// registerRootRoute decides what `GET /` returns (#346).
|
||||
//
|
||||
// `[必须]` When the built frontend exists, `/` must be the Admin SPA. go-admin's
|
||||
// welcome page used to own `/` in every non-prod mode, so any reverse proxy
|
||||
// that forwarded `/` to this server (instead of serving dist itself) showed
|
||||
// "GO-ADMIN欢迎您" instead of the Admin — which is exactly what happened after
|
||||
// the 2026-09-28 server migration. The welcome page is kept only for
|
||||
// development without a dist, where the frontend runs under vite.
|
||||
func registerRootRoute(r gin.IRoutes, mode string, welcome gin.HandlerFunc) {
|
||||
if _, index, ok := spaIndex(); ok {
|
||||
r.GET("/", func(c *gin.Context) { c.File(index) })
|
||||
return
|
||||
}
|
||||
if mode != "prod" {
|
||||
r.GET("/", welcome)
|
||||
}
|
||||
}
|
||||
|
||||
// isAPIPath reports whether a path belongs to the server rather than the SPA.
|
||||
func isAPIPath(path string) bool {
|
||||
for _, prefix := range []string{"/api/", "/swagger/", "/static/", "/form-generator/", "/ws/", "/wslogout/", "/info"} {
|
||||
|
||||
@@ -91,3 +91,46 @@ func TestWithoutDistNoFallbackIsInstalled(t *testing.T) {
|
||||
t.Fatalf("没有 dist 时接口仍应正常: %d", response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// #346: with a built frontend, `/` must be the Admin SPA — never go-admin's
|
||||
// welcome page, even in non-prod modes where the welcome page used to own `/`.
|
||||
func TestRootServesSPAWhenDistExists(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
dist := filepath.Join(t.TempDir(), "dist")
|
||||
if err := os.MkdirAll(dist, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dist, "index.html"), []byte("<!doctype html>SPA"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv(SPADirEnv, dist)
|
||||
for _, mode := range []string{"dev", "test", "prod"} {
|
||||
engine := gin.New()
|
||||
registerRootRoute(engine, mode, func(c *gin.Context) { c.String(http.StatusOK, "GO-ADMIN欢迎您") })
|
||||
InitSPARouter(engine)
|
||||
response := do(engine, http.MethodGet, "/")
|
||||
if response.Code != http.StatusOK || response.Body.String() != "<!doctype html>SPA" {
|
||||
t.Fatalf("mode=%s: / should serve index.html, got %d %q", mode, response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Without a dist (development under vite) the previous behaviour is kept:
|
||||
// welcome page outside prod, nothing registered in prod.
|
||||
func TestRootWithoutDistKeepsPreviousBehaviour(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Setenv(SPADirEnv, filepath.Join(t.TempDir(), "missing-dist"))
|
||||
welcome := func(c *gin.Context) { c.String(http.StatusOK, "GO-ADMIN欢迎您") }
|
||||
|
||||
dev := gin.New()
|
||||
registerRootRoute(dev, "dev", welcome)
|
||||
if response := do(dev, http.MethodGet, "/"); response.Code != http.StatusOK || response.Body.String() != "GO-ADMIN欢迎您" {
|
||||
t.Fatalf("dev without dist should keep the welcome page, got %d %q", response.Code, response.Body.String())
|
||||
}
|
||||
|
||||
prod := gin.New()
|
||||
registerRootRoute(prod, "prod", welcome)
|
||||
if response := do(prod, http.MethodGet, "/"); response.Code != http.StatusNotFound {
|
||||
t.Fatalf("prod without dist should not register /, got %d", response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,9 +40,7 @@ func sysBaseRouter(r *gin.RouterGroup) {
|
||||
go ws.WebsocketManager.SendService()
|
||||
go ws.WebsocketManager.SendAllService()
|
||||
|
||||
if config.ApplicationConfig.Mode != "prod" {
|
||||
r.GET("/", apis.GoAdmin)
|
||||
}
|
||||
registerRootRoute(r, config.ApplicationConfig.Mode, apis.GoAdmin)
|
||||
r.GET("/info", handler.Ping)
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/sybclient"
|
||||
@@ -193,13 +194,20 @@ func planRecord(ctx context.Context, reader MatchReader, record models.SYBInnerC
|
||||
if len(matches) == 1 && matches[0].ProductQty == count {
|
||||
chosen = []sybclient.DetailItem{matches[0]}
|
||||
} else if len(matches) == count && count > 1 {
|
||||
sort.Slice(matches, func(i, j int) bool { return matches[i].ID < matches[j].ID })
|
||||
for _, item := range matches {
|
||||
if item.ProductQty != 1 {
|
||||
return nil, models.SYBInnerCodeSkipped, "相同规格候选数量不明确,不能自动分配", nil
|
||||
}
|
||||
}
|
||||
chosen = matches
|
||||
codes := make([]string, count)
|
||||
for i, it := range record.Items {
|
||||
codes[i] = it.Code
|
||||
}
|
||||
assigned, reason := assignExistingBoundItems(record.Stall, codes, matches)
|
||||
if reason != "" {
|
||||
return nil, models.SYBInnerCodeSkipped, reason, nil
|
||||
}
|
||||
chosen = assigned
|
||||
} else if len(matches) > 1 {
|
||||
return nil, models.SYBInnerCodeSkipped, "同一订单存在多条相同规格候选商品,不能自动选择", nil
|
||||
} else {
|
||||
@@ -246,6 +254,83 @@ func planRecord(ctx context.Context, reader MatchReader, record models.SYBInnerC
|
||||
return plan, models.SYBInnerCodeReady, "唯一匹配,等待确认回写", nil
|
||||
}
|
||||
|
||||
// assignExistingBoundItems 把 N 个待写入入库码按顺序分配给 N 个数量为 1 的候选商品明细。
|
||||
// 修复 #289:候选明细的匹配顺序(按 ID 排序)未必与目标码顺序一致,若单纯按下标
|
||||
// 对应,会把已经正确绑定某个目标码的明细错误地重新分配给另一个码。这里先按“候选
|
||||
// 明细已有的入库码值”精确匹配对应的目标码,保留既有正确绑定不动;再把剩余尚未
|
||||
// 写入任何码的空白明细(按 ID 排序)依次填充给还没有候选的目标码位置。
|
||||
// 移植自 cmautobuy `planExistingMatchedInnerCodeItems` 的一致性护栏(代码评审补充):
|
||||
// 候选来自 NormalizeSpecKey 归一化匹配,原始 ProductSpec/sku/variationSku 可能在
|
||||
// 归一化后相同但原始值不同,必须逐一比对最低 ID 候选,避免跨真正不同商品自动分配;
|
||||
// 无 SKU 回退路径可能返回从未做过档口校验的候选,这里逐一重新校验;同时拒绝无效
|
||||
// 或重复的商品明细 ID。
|
||||
func assignExistingBoundItems(stall string, codes []string, matches []sybclient.DetailItem) ([]sybclient.DetailItem, string) {
|
||||
sorted := append([]sybclient.DetailItem(nil), matches...)
|
||||
sort.Slice(sorted, func(i, j int) bool { return sorted[i].ID < sorted[j].ID })
|
||||
seenIDs := make(map[int64]bool, len(sorted))
|
||||
for _, item := range sorted {
|
||||
if item.ID <= 0 || seenIDs[item.ID] {
|
||||
return nil, "重复候选包含无效或重复的商品明细 ID,不能自动逐件分配"
|
||||
}
|
||||
seenIDs[item.ID] = true
|
||||
}
|
||||
first := sorted[0]
|
||||
wantSpec := first.ProductSpec
|
||||
wantSKU := rawText(first.Raw["sku"])
|
||||
wantVariationSKU := rawText(first.Raw["variationSku"])
|
||||
for _, item := range sorted {
|
||||
if item.ProductSpec != wantSpec || rawText(item.Raw["sku"]) != wantSKU || rawText(item.Raw["variationSku"]) != wantVariationSKU {
|
||||
return nil, "重复候选的规格或 SKU 身份不一致,不能自动逐件分配"
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(stall) != "" {
|
||||
for _, item := range sorted {
|
||||
if !stallMatches(stall, item) {
|
||||
return nil, "重复候选的档口及货号不一致,不能自动逐件分配"
|
||||
}
|
||||
}
|
||||
}
|
||||
assigned := make([]sybclient.DetailItem, len(codes))
|
||||
taken := make([]bool, len(codes))
|
||||
codeIndex := make(map[string]int, len(codes))
|
||||
for i, code := range codes {
|
||||
codeIndex[code] = i
|
||||
}
|
||||
blanks := make([]int, 0, len(sorted))
|
||||
for si, item := range sorted {
|
||||
remote := rawText(item.Raw["innerExpCode"])
|
||||
if remote == "" {
|
||||
blanks = append(blanks, si)
|
||||
continue
|
||||
}
|
||||
idx, ok := codeIndex[remote]
|
||||
if !ok {
|
||||
return nil, "候选商品明细存在非目标入库码,不能自动逐件分配"
|
||||
}
|
||||
if taken[idx] {
|
||||
return nil, "同一入库码在候选商品中出现多次,不能自动逐件分配"
|
||||
}
|
||||
assigned[idx] = item
|
||||
taken[idx] = true
|
||||
}
|
||||
bi := 0
|
||||
for i := range assigned {
|
||||
if taken[i] {
|
||||
continue
|
||||
}
|
||||
if bi >= len(blanks) {
|
||||
return nil, "现成空白明细不足,不能完成逐件分配"
|
||||
}
|
||||
assigned[i] = sorted[blanks[bi]]
|
||||
taken[i] = true
|
||||
bi++
|
||||
}
|
||||
if bi != len(blanks) {
|
||||
return nil, "现成空白明细多于待写入入库码,不能自动逐件分配"
|
||||
}
|
||||
return assigned, ""
|
||||
}
|
||||
|
||||
func matchSpec(spec string, items []sybclient.DetailItem) []sybclient.DetailItem {
|
||||
result := []sybclient.DetailItem{}
|
||||
for _, item := range items {
|
||||
@@ -305,15 +390,131 @@ func strictStall(stall string, items []sybclient.DetailItem) []sybclient.DetailI
|
||||
return nil
|
||||
}
|
||||
result := []sybclient.DetailItem{}
|
||||
name, article, has := strings.Cut(stall, "#")
|
||||
for _, item := range items {
|
||||
blob := rawText(item.Raw["sku"]) + " " + rawText(item.Raw["variationSku"]) + " " + item.ProductSpec
|
||||
if strings.Contains(blob, stall) || (has && strings.Contains(blob, strings.TrimSpace(name)) && strings.Contains(blob, strings.TrimSpace(article))) {
|
||||
if stallMatches(stall, item) {
|
||||
result = append(result, item)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// stallMatches 移植自 cmautobuy `innerCodeStallMatches`(#259/#273 修复):
|
||||
// 档口名与货号以最后一个 `#` 切分;货号只与字母数字 token 比较;纯数字货号要求
|
||||
// 候选中同时包含档口名才允许前导零等价(如 "067"≡"67");非数字货号要求精确
|
||||
// token 匹配;ProductSpec 只在以货号开头时才算命中;货号为空时回退为档口名包含判断。
|
||||
func stallMatches(stall string, item sybclient.DetailItem) bool {
|
||||
sku := rawText(item.Raw["sku"])
|
||||
variation := rawText(item.Raw["variationSku"])
|
||||
blob := sku + " " + variation + " " + item.ProductSpec
|
||||
if strings.Contains(blob, stall) {
|
||||
return true
|
||||
}
|
||||
name, article, hasArticle := splitStall(stall)
|
||||
if !hasArticle {
|
||||
return false
|
||||
}
|
||||
if article == "" {
|
||||
return name != "" && (strings.Contains(sku, name) || strings.Contains(variation, name))
|
||||
}
|
||||
if isNumericArticle(article) {
|
||||
nameMatches := name != "" && (strings.Contains(sku, name) || strings.Contains(variation, name))
|
||||
if !nameMatches {
|
||||
return false
|
||||
}
|
||||
return textHasNumericArticle(sku, article) ||
|
||||
textHasNumericArticle(variation, article) ||
|
||||
productSpecStartsWithArticle(item.ProductSpec, article, true)
|
||||
}
|
||||
return textHasExactArticle(sku, article) ||
|
||||
textHasExactArticle(variation, article) ||
|
||||
productSpecStartsWithArticle(item.ProductSpec, article, false)
|
||||
}
|
||||
|
||||
// splitStall 从档口名称#货号取最后一个 #,避免档口名称本身含 # 时截错。
|
||||
func splitStall(stall string) (name, article string, ok bool) {
|
||||
stall = strings.TrimSpace(stall)
|
||||
separator := strings.LastIndex(stall, "#")
|
||||
if separator < 0 {
|
||||
return stall, "", false
|
||||
}
|
||||
return strings.TrimSpace(stall[:separator]), strings.TrimSpace(stall[separator+1:]), true
|
||||
}
|
||||
|
||||
func isNumericArticle(article string) bool {
|
||||
if article == "" {
|
||||
return false
|
||||
}
|
||||
for _, char := range article {
|
||||
if !unicode.IsDigit(char) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func textHasNumericArticle(text, article string) bool {
|
||||
target := normalizeNumericArticle(article)
|
||||
for _, token := range articleTokens(text) {
|
||||
if isNumericArticle(token) && normalizeNumericArticle(token) == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func normalizeNumericArticle(article string) string {
|
||||
normalized := strings.TrimLeft(article, "0")
|
||||
if normalized == "" {
|
||||
return "0"
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func textHasExactArticle(text, article string) bool {
|
||||
for _, token := range articleTokens(text) {
|
||||
if token == article {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func productSpecStartsWithArticle(productSpec, article string, numeric bool) bool {
|
||||
productSpec = strings.TrimSpace(productSpec)
|
||||
separator := strings.IndexAny(productSpec, " ,,")
|
||||
if separator <= 0 {
|
||||
return false
|
||||
}
|
||||
prefix := strings.TrimSpace(productSpec[:separator])
|
||||
if numeric {
|
||||
return isNumericArticle(prefix) && normalizeNumericArticle(prefix) == normalizeNumericArticle(article)
|
||||
}
|
||||
return prefix == article
|
||||
}
|
||||
|
||||
// articleTokens 只把连续字母或数字视为货号候选,标点、【】、#、横线、空格自然成为
|
||||
// 边界:能识别 "067【档口】",又不会把 "PDD256437" 中间的数字误认为独立货号。
|
||||
func articleTokens(text string) []string {
|
||||
tokens := make([]string, 0)
|
||||
start := -1
|
||||
runes := []rune(text)
|
||||
for index, char := range runes {
|
||||
if unicode.IsLetter(char) || unicode.IsDigit(char) {
|
||||
if start < 0 {
|
||||
start = index
|
||||
}
|
||||
continue
|
||||
}
|
||||
if start >= 0 {
|
||||
tokens = append(tokens, string(runes[start:index]))
|
||||
start = -1
|
||||
}
|
||||
}
|
||||
if start >= 0 {
|
||||
tokens = append(tokens, string(runes[start:]))
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
func rawText(value any) string {
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
|
||||
@@ -147,6 +147,209 @@ func TestRunMatchJobReservesDifferentDetailsForRecordsInOneBatch(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- #259/#273/#289 stall-matching regression tests (ported from cmautobuy) ---
|
||||
|
||||
func TestStallMatchesIgnoresWeightLikeNumberAsArticle(t *testing.T) {
|
||||
// #273: "50公斤" must not be treated as if the article were the bare number 50.
|
||||
item := detail(1, "50公斤,黑色", 1, "SKU-X", "", "")
|
||||
if stallMatches("档口甲#50", item) {
|
||||
t.Fatalf("weight-like text must not match numeric article 50")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStallMatchesRejectsSubstringInsideLongCode(t *testing.T) {
|
||||
// #273: a long code with internal digits (PDD256437) must not spuriously
|
||||
// match a short numeric article (256) via substring containment.
|
||||
item := detail(1, "黑色,L", 1, "PDD256437", "档口甲", "")
|
||||
if stallMatches("档口甲#256", item) {
|
||||
t.Fatalf("long code must not match numeric article 256 via substring")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStallMatchesLeadingZeroEquivalenceRequiresStallName(t *testing.T) {
|
||||
// #259/#273: "067" and "67" are equivalent articles only when the stall
|
||||
// name also matches; a different stall name must not match.
|
||||
sameStall := detail(1, "黑色,L", 1, "档口甲-067", "", "")
|
||||
if !stallMatches("档口甲#67", sameStall) {
|
||||
t.Fatalf("067 should be treated as equivalent to 67 when stall name matches")
|
||||
}
|
||||
differentStall := detail(2, "黑色,L", 1, "档口乙-067", "", "")
|
||||
if stallMatches("档口甲#67", differentStall) {
|
||||
t.Fatalf("067 must not match 67 when the stall name differs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStallMatchesNonNumericArticleRequiresExactToken(t *testing.T) {
|
||||
item := detail(1, "黑色,L", 1, "ABC12", "", "")
|
||||
if stallMatches("档口甲#AB", item) {
|
||||
t.Fatalf("non-numeric article must require an exact token match, not substring")
|
||||
}
|
||||
exact := detail(2, "黑色,L", 1, "AB", "", "")
|
||||
if !stallMatches("档口甲#AB", exact) {
|
||||
t.Fatalf("exact non-numeric token should match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStallMatchesHandlesHashInsideStallName(t *testing.T) {
|
||||
// Splits on the LAST '#' so a stall name that itself contains '#' still
|
||||
// yields the correct article.
|
||||
item := detail(1, "黑色,L", 1, "档口#甲-67", "", "")
|
||||
if !stallMatches("档口#甲#67", item) {
|
||||
t.Fatalf("stall name containing '#' should still resolve article via last '#'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStallMatchesProductSpecPrefixMatchesArticle(t *testing.T) {
|
||||
// Non-numeric article: ProductSpec prefix match does not additionally
|
||||
// require the stall name to appear in sku/variationSku.
|
||||
item := detail(1, "AB 黑色,L", 1, "", "", "")
|
||||
if !stallMatches("档口甲#AB", item) {
|
||||
t.Fatalf("ProductSpec starting with the article should match")
|
||||
}
|
||||
notPrefix := detail(2, "黑色,ABL", 1, "", "", "")
|
||||
if stallMatches("档口甲#AB", notPrefix) {
|
||||
t.Fatalf("article appearing mid-spec (not as prefix) must not match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStallMatchesEmptyArticleFallsBackToStallName(t *testing.T) {
|
||||
item := detail(1, "黑色,L", 1, "档口甲专柜", "", "")
|
||||
if !stallMatches("档口甲#", item) {
|
||||
t.Fatalf("empty article should fall back to stall-name containment")
|
||||
}
|
||||
}
|
||||
|
||||
// --- #289: existing-binding-preserving multi-piece assignment ---
|
||||
|
||||
func TestAssignExistingBoundItemsPreservesOutOfOrderBindings(t *testing.T) {
|
||||
// Two single-piece candidates already carry codes, but the previously
|
||||
// bound code (IC-2) sits on the LOWER-ID detail while the target order
|
||||
// expects it second. A naive ID-order/index assignment would strip the
|
||||
// existing correct binding from detail 20 and try to overwrite it.
|
||||
d20 := detail(20, "黑色,L", 1, "SKU-1", "A#1", "IC-2")
|
||||
d21 := detail(21, "黑色,L", 1, "SKU-1", "A#1", "")
|
||||
assigned, reason := assignExistingBoundItems("A#1", []string{"IC-1", "IC-2"}, []sybclient.DetailItem{d20, d21})
|
||||
if reason != "" {
|
||||
t.Fatalf("unexpected reason: %s", reason)
|
||||
}
|
||||
if assigned[1].ID != 20 {
|
||||
t.Fatalf("expected detail 20 (already bound to IC-2) preserved at index 1, got %+v", assigned[1])
|
||||
}
|
||||
if assigned[0].ID != 21 {
|
||||
t.Fatalf("expected the blank detail 21 filled in at index 0, got %+v", assigned[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssignExistingBoundItemsRejectsForeignCode(t *testing.T) {
|
||||
d20 := detail(20, "黑色,L", 1, "SKU-1", "A#1", "IC-OTHER")
|
||||
d21 := detail(21, "黑色,L", 1, "SKU-1", "A#1", "")
|
||||
_, reason := assignExistingBoundItems("A#1", []string{"IC-1", "IC-2"}, []sybclient.DetailItem{d20, d21})
|
||||
if reason == "" {
|
||||
t.Fatalf("expected rejection for detail already holding a non-target code")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssignExistingBoundItemsRejectsInconsistentIdentity(t *testing.T) {
|
||||
// Candidates can share a NormalizeSpecKey-normalized spec while their raw
|
||||
// ProductSpec/sku/variationSku differ; auto-assignment across genuinely
|
||||
// different items must be rejected.
|
||||
d20 := detail(20, "黑色, L", 1, "SKU-1", "A#1", "")
|
||||
d21 := detail(21, "黑色,L", 1, "SKU-2", "A#1", "")
|
||||
_, reason := assignExistingBoundItems("A#1", []string{"IC-1", "IC-2"}, []sybclient.DetailItem{d20, d21})
|
||||
if reason != "重复候选的规格或 SKU 身份不一致,不能自动逐件分配" {
|
||||
t.Fatalf("expected identity-mismatch rejection, got %q", reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssignExistingBoundItemsRejectsCandidateFailingStallCheck(t *testing.T) {
|
||||
// The no-SKU fallback path in matchEvidence can hand back candidates that
|
||||
// were never checked against the stall at all. Both candidates share an
|
||||
// identical identity (so the identity guard passes) but neither one's
|
||||
// sku/spec actually satisfies the record's stall/article requirement.
|
||||
d20 := detail(20, "黑色,L", 1, "SKU-1", "ZZZ", "")
|
||||
d21 := detail(21, "黑色,L", 1, "SKU-1", "ZZZ", "")
|
||||
_, reason := assignExistingBoundItems("甲档口#88", []string{"IC-1", "IC-2"}, []sybclient.DetailItem{d20, d21})
|
||||
if reason != "重复候选的档口及货号不一致,不能自动逐件分配" {
|
||||
t.Fatalf("expected stall-mismatch rejection, got %q", reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssignExistingBoundItemsRejectsInvalidOrDuplicateDetailID(t *testing.T) {
|
||||
invalidID := detail(0, "黑色,L", 1, "SKU-1", "A#1", "")
|
||||
valid := detail(21, "黑色,L", 1, "SKU-1", "A#1", "")
|
||||
if _, reason := assignExistingBoundItems("A#1", []string{"IC-1", "IC-2"}, []sybclient.DetailItem{invalidID, valid}); reason == "" {
|
||||
t.Fatalf("expected rejection for non-positive detail ID")
|
||||
}
|
||||
dup1 := detail(20, "黑色,L", 1, "SKU-1", "A#1", "")
|
||||
dup2 := detail(20, "黑色,L", 1, "SKU-1", "A#1", "")
|
||||
if _, reason := assignExistingBoundItems("A#1", []string{"IC-1", "IC-2"}, []sybclient.DetailItem{dup1, dup2}); reason == "" {
|
||||
t.Fatalf("expected rejection for duplicate detail IDs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssignExistingBoundItemsHappyPathStillAssignsWithGuards(t *testing.T) {
|
||||
// Both candidates share identical raw spec/sku/variationSku and both
|
||||
// individually satisfy the stall check; the guards must not block the
|
||||
// legitimate happy path.
|
||||
d20 := detail(20, "黑色,L", 1, "SKU-1", "A#1", "IC-2")
|
||||
d21 := detail(21, "黑色,L", 1, "SKU-1", "A#1", "")
|
||||
assigned, reason := assignExistingBoundItems("A#1", []string{"IC-1", "IC-2"}, []sybclient.DetailItem{d20, d21})
|
||||
if reason != "" {
|
||||
t.Fatalf("unexpected reason: %s", reason)
|
||||
}
|
||||
if assigned[0].ID != 21 || assigned[1].ID != 20 {
|
||||
t.Fatalf("assigned=%+v", assigned)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunMatchJobPreservesExistingBindingWhenCandidateOrderDiffers(t *testing.T) {
|
||||
// End-to-end regression for #289: N=2 single-piece candidates already
|
||||
// carrying one previously bound code, with the bound detail's ID not
|
||||
// matching sequential/ID order relative to the target codes. The plan
|
||||
// must leave the already-correct binding untouched and only place the
|
||||
// missing code onto the still-blank detail.
|
||||
db := testDB(t)
|
||||
// SourceSKURaw is intentionally left blank: with the same source SKU on
|
||||
// both candidate rows the source-SKU path would reject as "duplicate"
|
||||
// before ever reaching the stall-based multi-item assignment being
|
||||
// regression-tested here.
|
||||
record := models.SYBInnerCodeRecord{BusinessDate: "2026-08-28", OrderNumber: "ORDER-1", Stall: "A#1", SpecKey: "黑色,L", SpecRaw: "黑色,L", Status: models.SYBInnerCodePending, CreatedBy: 1, ImportRequestID: uuid.NewString(), Items: []models.SYBInnerCodeItem{{BusinessDate: "2026-08-28", Code: "IC-1", Ordinal: 1, SourceRow: 2}, {BusinessDate: "2026-08-28", Code: "IC-2", Ordinal: 2, SourceRow: 3}}}
|
||||
records := []models.SYBInnerCodeRecord{record}
|
||||
jobID := createMatchJob(t, db, records)
|
||||
recordID := records[0].ID
|
||||
// Detail 20 (lower ID) already carries IC-2 (bound out of sequence);
|
||||
// detail 21 (higher ID) is still blank and should receive IC-1.
|
||||
stock := sybclient.StockDetail{ID: 10, Details: []sybclient.DetailItem{
|
||||
detail(20, "黑色,L", 1, "SKU-1", "A#1", "IC-2"),
|
||||
detail(21, "黑色,L", 1, "SKU-1", "A#1", ""),
|
||||
}}
|
||||
reader := &fakeMatchReader{rows: map[string][]sybclient.StockRow{"ORDER-1": {{ID: 10, Code: "ORDER-1"}}}, stocks: map[int64]sybclient.StockDetail{10: stock}}
|
||||
if err := RunMatchJob(context.Background(), db, reader, jobID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var plan models.SYBInnerCodePlan
|
||||
if err := db.First(&plan, "record_id = ?", recordID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var items []plannedRemoteItem
|
||||
if err := json.Unmarshal([]byte(plan.RemoteItemsJSON), &items); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("items=%+v", items)
|
||||
}
|
||||
byCode := map[string]plannedRemoteItem{}
|
||||
for _, item := range items {
|
||||
byCode[item.Code] = item
|
||||
}
|
||||
if byCode["IC-2"].DetailID != 20 {
|
||||
t.Fatalf("existing binding for IC-2 must stay on detail 20, got %+v", byCode["IC-2"])
|
||||
}
|
||||
if byCode["IC-1"].DetailID != 21 {
|
||||
t.Fatalf("missing IC-1 should be assigned to the blank detail 21, got %+v", byCode["IC-1"])
|
||||
}
|
||||
}
|
||||
|
||||
type captureStarter struct {
|
||||
jobID string
|
||||
committed bool
|
||||
|
||||
Reference in New Issue
Block a user