Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b95d4e879 | ||
|
|
bce4897390 | ||
|
|
c13dcd3ebc | ||
|
|
0b01b92c98 | ||
|
|
9dace6ac89 | ||
|
|
e99bbc2327 |
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Architecture-and-Code-Map
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.-
|
||||
wiki_revision: ad4a5c3c7e4c2e3631856b3021e11560be2e702e
|
||||
synchronized_at: 2026-09-27T03:42:29Z
|
||||
wiki_revision: 04cc54a554fc3bdc30273e6a5ace5fff5d95386f
|
||||
synchronized_at: 2026-09-28T08:28:30Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
@@ -504,7 +504,7 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
|
||||
|
||||
### 会话类失败自动重试(#330)
|
||||
|
||||
- purchase/order_writeback_worker.go:回填通过 `sybclient.AcquireSession` 统一复用/刷新 SYB 会话;`syb_session_auth_lease` 保证同步与回填同时只有一个 worker 触发 OCR 登录,其他任务等待新会话。回填终态同步 `purchase_task.writeback_status/writeback_at`;迁移为 `1789801400000_syb_session_auth_lease.go`。会话类失败仍使用有界退避,冲突不覆盖。
|
||||
- purchase/order_writeback_worker.go:restoreOrderWritebackClient 在 ImportCookiesJSON 后调用 sybclient.CheckSession,UserID<=0 显式判不可用;finishSessionUnavailable 复用 lease_expires_at 作为退避到期时间(maxSessionRetryAttempts=6,sessionRetryBackoff 5/10/15/30/30m),领取条件增加 failed+SYB_SESSION_UNAVAILABLE+到期+未达上限。无迁移。
|
||||
- purchase/order_writeback.go:会话类失败的 CanSubmit 不受退避租约限制;手工重新提交 attempt_count 置 0。
|
||||
- 验证:go test ./app/goauto/purchase(含 httptest 模拟 /am/user/get 与断言 syb_session 未删除)。
|
||||
|
||||
@@ -547,3 +547,12 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
|
||||
## #338 退货匹配(2026-09-27)
|
||||
|
||||
新增 `server/app/goauto/returnmatch` 模块及 `/api/admin/v1/return-matches` 路由,支持批量匹配、列表/详情、确认、取消和备注;相关表由版本化迁移创建,采购创建在服务端拦截有效退货匹配,售后角色可执行这些接口。
|
||||
|
||||
|
||||
## Yeeke 已重出同步与匹配边界(#345)
|
||||
|
||||
- server/app/goauto/yeekeclient/client.go:List 保留默认待出货兼容调用;ListStatus 仅接受状态 1/2,复用 relation/list 请求及原过滤条件。
|
||||
- server/app/goauto/yeeke/sync.go:每轮顺序遍历两种状态,分页指纹各自独立;共享包裹键处理跨状态重复,状态 2 优先。TotalPages 汇总实际取得的两组页数。两组完成且无写入错误才执行既有 markMissing;不完整分页明确返回失败。
|
||||
- server/app/goauto/returnmatch/service.go:候选池过滤待出货、已识别与同步可用状态;匹配事务按包裹再明细顺序沿用行锁,并在插入前再次校验。状态变化的候选记为跳过,不撤销既有匹配。
|
||||
- web/src/views/goauto/yeeke-returns/index.vue:复用原状态筛选和标签组件,增加已重出,待出货使用明确业务名称;接口、数据库结构及权限不变。
|
||||
- 回归覆盖双状态请求、跨页/跨状态重复与身份保持、第二状态网络失败不标 missing、已重出候选排除和最终校验、Web 状态标签及重置。未调用真实 Yeeke,也未执行线上数据库或发布。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Business-Rules-and-Glossary
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.-
|
||||
wiki_revision: 57af37bf6acf16f86785c989e5c37dce85a3c588
|
||||
synchronized_at: 2026-09-27T03:42:33Z
|
||||
wiki_revision: 57ae18796e441eeed7b0ffc2a833fe6b4b5055f1
|
||||
synchronized_at: 2026-09-28T08:28:33Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
@@ -648,7 +648,7 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
|
||||
采购管理增加独立状态列、批量回填和详情补偿;复用既有访问权限,不增支付确认或审批。批量受理与最终成功分开展示;重试采购和回填分别筛选勾选项。远端无原子CAS,对系统外人工并发修改/超长延迟请求不能承诺绝对互斥;有冲突应人工核对,禁止强制覆盖。
|
||||
### SYB 会话类失败的有界自动重试(#330)
|
||||
|
||||
实现 934a7be(#343,2026-09-27)。回填 worker 与 SYB 同步共用 `sybclient.AcquireSession`:有效缓存会话直接复用,明确失效时由 `syb_session_auth_lease` 保证单 worker OCR 登录,其他任务等待新会话后继续原回填。网络错误不清除有效会话;OCR 未配置或登录失败保留结构化失败并允许人工重试。回填创建时采购任务进入 `pending`,终态同步 `succeeded/failed` 与 `writeback_at`;冲突仍拒绝覆盖。会话类失败继续使用有界退避,成功须回读确认。
|
||||
实现 01510a8/08b7095(2026-09-21,已合并 main,未部署、未生产验证)。回填 worker 从缓存会话恢复客户端后调用 SYB 会话校验;会话缺失/过期、串号失效、校验网络错误等均记为 `SYB_SESSION_UNAVAILABLE`,error_message 只记录类别和“将自动重试;如持续失败请恢复登录后重试”,不含原始错误。该类失败发生在任何写入之前,最多自动重试 6 次,退避 5/10/15/30/30 分钟(约 90 分钟,大于一个整点同步周期),达上限保持 failed 等人工。会话仍只由每小时 SYB 同步刷新;回填不登录、不 OCR、不删除或写入会话。其他失败码仍不自动重试。退避期内可手工重新回填,手工提交重置尝试次数。历史失败记录不会被自动领取。
|
||||
|
||||
## Agent 回填订单入口兼容(#307)
|
||||
|
||||
@@ -707,3 +707,12 @@ SYB 回填与资料完整性独立:正式SYB任务已有单号且仍关联有
|
||||
## 退货匹配(#338)
|
||||
|
||||
退货匹配只由人工勾选 SYB 商品后触发。按虾皮商品 ID 与归一化规格匹配,状态为“退货待确认”或“已用退货”时拦截新采购,取消后恢复可采购;已有采购任务不受影响。售后与采购员可操作匹配、确认、取消和备注。
|
||||
|
||||
|
||||
## Yeeke 双状态退货同步(#345)
|
||||
|
||||
- 同一次同步顺序拉取待出货(status=1)和已重出(status=2);保持 claimFlag=1、relationFlag=1 和创建时间倒序。两种业务状态均是已识别状态,列表及详情分别展示并可按状态筛选。
|
||||
- 两组分页独立判重;仅两组均自然完整结束且无写入失败时,才沿用既有安全阀标记未见数据为 missing。重复页、页数上限、网络或写入错误均记录失败,已成功写入的页保留,不误报完整成功。
|
||||
- 沿用既有包裹和明细标识幂等更新;同一轮跨状态重复时已重出优先,不新增副本。
|
||||
- 新退货匹配仅使用状态为待出货、已识别且同步可用的包裹和明细;落库前再次校验。已重出不参与新匹配,不自动取消已有匹配。
|
||||
- 本次无新增字段、迁移、权限或自动采购动作;真实 Yeeke 数据及发布后页面效果仍需人工验收。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package returnmatch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
)
|
||||
|
||||
func TestReshippedReturnExcludedAndRecheckedBeforeInsert(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := NewService(db)
|
||||
deadline := time.Now().Add(24 * time.Hour)
|
||||
ret := seedReturn(t, db, "红色", &deadline)
|
||||
sy := seedSYB(t, db, "TEST", 1, "红色", "", time.Now())
|
||||
pool, err := s.availableReturnPool(context.Background())
|
||||
if err != nil || len(pool) != 1 {
|
||||
t.Fatalf("waiting pool=%v err=%v", pool, err)
|
||||
}
|
||||
if err := db.Model(&models.YeekeReturnPackage{}).Where("id = ?", ret.PackageID).Update("claim_status", "2").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pool, err = s.availableReturnPool(context.Background())
|
||||
if err != nil || len(pool) != 0 {
|
||||
t.Fatalf("reshipped pool=%v err=%v", pool, err)
|
||||
}
|
||||
_, err = s.matchOneWithLock(context.Background(), sy.ID, MatchOutcome{ReturnItemID: ret.ID, DestroyDeadline: deadline}, "test")
|
||||
if !errors.Is(err, errReturnNoLongerEligible) {
|
||||
t.Fatalf("final recheck=%v", err)
|
||||
}
|
||||
var count int64
|
||||
db.Model(&models.ReturnMatch{}).Count(&count)
|
||||
if count != 0 {
|
||||
t.Fatal("reshipped item was allocated")
|
||||
}
|
||||
}
|
||||
@@ -238,6 +238,11 @@ func (s *Service) batchMatch(ctx context.Context, req BatchMatchRequest) (BatchM
|
||||
}
|
||||
match, insertErr := s.matchOneWithLock(ctx, id, outcome, req.Operator)
|
||||
if insertErr != nil {
|
||||
if errors.Is(insertErr, errReturnNoLongerEligible) {
|
||||
resp.Items = append(resp.Items, BatchMatchItem{SYBProductID: id, ReasonCode: ReasonNoCandidate, Reason: "退货商品已重出或不再可用"})
|
||||
resp.SkippedCount++
|
||||
continue
|
||||
}
|
||||
if errors.Is(insertErr, errStageNoLongerEligible) {
|
||||
// #338 review fix: the stage was re-checked under the same
|
||||
// FOR UPDATE lock purchase.create takes, right before insert.
|
||||
@@ -292,6 +297,7 @@ func (s *Service) availableReturnPool(ctx context.Context) ([]ReturnCandidate, e
|
||||
Joins("JOIN yeeke_return_package AS p ON p.id = i.package_id").
|
||||
Joins("LEFT JOIN return_match AS m ON m.active_yeeke_return_item_id = i.id").
|
||||
Where("m.id IS NULL AND i.sync_status = ? AND p.sync_status = ?", "ok", "ok").
|
||||
Where("p.claim_status = ? AND p.status_unrecognized = ?", "1", false).
|
||||
Find(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -312,6 +318,7 @@ func (s *Service) availableReturnPool(ctx context.Context) ([]ReturnCandidate, e
|
||||
// it between BatchMatch's outer screening pass and this point (#338 review
|
||||
// fix: race between matching and purchase creation).
|
||||
var errStageNoLongerEligible = errors.New("syb product stage no longer participates in matching")
|
||||
var errReturnNoLongerEligible = errors.New("yeeke return is no longer waiting to ship")
|
||||
|
||||
// matchOneWithLock takes the SAME row lock purchase.Service.create takes on
|
||||
// syb_product (clause.Locking{Strength: "UPDATE"}) and re-computes the
|
||||
@@ -340,6 +347,17 @@ func (s *Service) matchOneWithLock(ctx context.Context, sybID uint64, outcome Ma
|
||||
if err := tx.First(&returnItem, outcome.ReturnItemID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var pkg models.YeekeReturnPackage
|
||||
if err := tx.Clauses(clauseLockUpdate()).First(&pkg, returnItem.PackageID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// Same package -> item lock order as sync upsert; avoid a lock cycle.
|
||||
if err := tx.Clauses(clauseLockUpdate()).First(&returnItem, outcome.ReturnItemID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if pkg.ClaimStatus != "1" || pkg.StatusUnrecognized || pkg.SyncStatus != "ok" || returnItem.SyncStatus != "ok" {
|
||||
return errReturnNoLongerEligible
|
||||
}
|
||||
sybIDCopy := syb.ID
|
||||
returnIDCopy := outcome.ReturnItemID
|
||||
deadline := outcome.DestroyDeadline
|
||||
|
||||
@@ -69,7 +69,8 @@ func seedSYB(t *testing.T, db *gorm.DB, orderCode string, detailID uint64, color
|
||||
func seedReturn(t *testing.T, db *gorm.DB, variationName string, deadline *time.Time) models.YeekeReturnItem {
|
||||
t.Helper()
|
||||
pkg := models.YeekeReturnPackage{
|
||||
ExternalID: "pkg-" + variationName + fmt.Sprint(time.Now().UnixNano()), OrderSN: "ORD1", TrackingNo: "TRK1",
|
||||
ClaimStatus: "1",
|
||||
ExternalID: "pkg-" + variationName + fmt.Sprint(time.Now().UnixNano()), OrderSN: "ORD1", TrackingNo: "TRK1",
|
||||
DestroyDeadLine: deadline, LastSyncedAt: time.Now(),
|
||||
}
|
||||
if err := db.Create(&pkg).Error; err != nil {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package yeeke
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/yeekeclient"
|
||||
)
|
||||
|
||||
func TestSyncBothStatusesPreservesIdentityAndAvailability(t *testing.T) {
|
||||
db := testDB(t)
|
||||
phase := 0
|
||||
var calls []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Status string `json:"status"`
|
||||
Page int `json:"pageNo"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
calls = append(calls, fmt.Sprintf("%s/%d", req.Status, req.Page))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if phase == 0 && req.Status == "2" {
|
||||
fmt.Fprint(w, page(nil, 0, 0))
|
||||
return
|
||||
}
|
||||
if req.Status == "1" {
|
||||
if req.Page == 1 {
|
||||
fmt.Fprint(w, page([]string{record("p1", "i1", "v1", 1)}, 2, 2))
|
||||
} else {
|
||||
fmt.Fprint(w, page([]string{record("p2", "i2", "v2", 1)}, 2, 2))
|
||||
}
|
||||
} else {
|
||||
fmt.Fprint(w, page([]string{record("p1", "i1", "v1", 2)}, 1, 1))
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
c, _ := yeekeclient.New(srv.URL)
|
||||
s := NewService(db, c, Config{PageSize: 1})
|
||||
if _, err := s.Sync(context.Background(), "manual"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var original models.YeekeReturnPackage
|
||||
db.Where("external_id = ?", "p1").First(&original)
|
||||
phase = 1
|
||||
calls = nil
|
||||
for run := 0; run < 2; run++ {
|
||||
rep, err := s.Sync(context.Background(), "manual")
|
||||
if err != nil || rep.Status != "succeeded" || rep.MissingMarked != 0 {
|
||||
t.Fatalf("rep=%+v err=%v", rep, err)
|
||||
}
|
||||
}
|
||||
if fmt.Sprint(calls) != "[1/1 1/2 2/1 1/1 1/2 2/1]" {
|
||||
t.Fatalf("independent pagination: %v", calls)
|
||||
}
|
||||
var current models.YeekeReturnPackage
|
||||
db.First(¤t, original.ID)
|
||||
if current.ClaimStatus != "2" || current.StatusUnrecognized || current.SyncStatus != "ok" {
|
||||
t.Fatalf("current=%+v", current)
|
||||
}
|
||||
var count int64
|
||||
db.Model(&models.YeekeReturnPackage{}).Count(&count)
|
||||
if count != 2 {
|
||||
t.Fatalf("packages=%d", count)
|
||||
}
|
||||
db.Model(&models.YeekeReturnItem{}).Count(&count)
|
||||
if count != 2 {
|
||||
t.Fatalf("items=%d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReshipPageFailureDoesNotMarkMissing(t *testing.T) {
|
||||
db := testDB(t)
|
||||
old := models.YeekeReturnPackage{ExternalID: "old", ClaimStatus: "2", SyncStatus: "ok"}
|
||||
if err := db.Create(&old).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
if req.Status == "2" {
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
fmt.Fprint(w, page([]string{record("new", "i", "v", 1)}, 1, 1))
|
||||
}))
|
||||
defer srv.Close()
|
||||
c, _ := yeekeclient.New(srv.URL)
|
||||
s := NewService(db, c, Config{PageSize: 10})
|
||||
rep, err := s.Sync(context.Background(), "manual")
|
||||
if err == nil || rep.Status != "failed" || rep.Created != 1 || rep.MissingMarked != 0 {
|
||||
t.Fatalf("rep=%+v err=%v", rep, err)
|
||||
}
|
||||
db.First(&old, old.ID)
|
||||
if old.SyncStatus != "ok" {
|
||||
t.Fatal("incomplete combined sync marked reshipped package missing")
|
||||
}
|
||||
var run models.YeekeSyncRun
|
||||
db.First(&run, rep.RunID)
|
||||
if run.ErrorMessage == "" {
|
||||
t.Fatal("missing state/page failure diagnostic")
|
||||
}
|
||||
}
|
||||
@@ -54,10 +54,8 @@ type Report struct {
|
||||
}
|
||||
|
||||
// knownClaimStatuses lists the status values the sync code currently
|
||||
// understands. The list surface (POST .../relation/list) is queried with
|
||||
// status=1, so "1" is the only value observed in practice; anything else is
|
||||
// flagged rather than silently accepted or rejected (#336).
|
||||
var knownClaimStatuses = map[string]bool{"1": true}
|
||||
// understands: waiting to ship (1) and reshipped (2), confirmed by HAR.
|
||||
var knownClaimStatuses = map[string]bool{"1": true, "2": true}
|
||||
|
||||
func external(v any) string { return fmt.Sprint(v) }
|
||||
func stamp(t *yeekeclient.Timestamp) *time.Time {
|
||||
@@ -194,86 +192,103 @@ func (s *Service) run(ctx context.Context, r *models.YeekeSyncRun) (Report, erro
|
||||
}
|
||||
s.db.Model(r).Updates(updates)
|
||||
}()
|
||||
seen := map[string]bool{}
|
||||
var firstWriteErr error
|
||||
seenPackages := map[string]string{}
|
||||
// complete tracks whether the page walk ended NATURALLY (empty page,
|
||||
// short page, or reaching p.Pages) as opposed to the duplicate-
|
||||
// fingerprint break or MaxPages exhaustion (#338): only a naturally
|
||||
// complete run is trusted to mark absent items/packages "missing" below,
|
||||
// since a duplicate/MaxPages stop means the walk never actually finished
|
||||
// seeing everything yeeke currently has.
|
||||
complete := false
|
||||
for page := 1; page <= s.cfg.MaxPages; page++ {
|
||||
var p yeekeclient.ReturnPage
|
||||
var e error
|
||||
for a := 0; ; a++ {
|
||||
p, e = s.client.List(ctx, page, s.cfg.PageSize)
|
||||
if e == nil || a >= s.cfg.Retry {
|
||||
complete := true
|
||||
for _, status := range []string{"1", "2"} {
|
||||
seen := map[string]bool{}
|
||||
statusComplete := false
|
||||
for page := 1; page <= s.cfg.MaxPages; page++ {
|
||||
var p yeekeclient.ReturnPage
|
||||
var e error
|
||||
for a := 0; ; a++ {
|
||||
p, e = s.client.ListStatus(ctx, page, s.cfg.PageSize, status)
|
||||
if e == nil || a >= s.cfg.Retry {
|
||||
break
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
runErr = ctx.Err()
|
||||
errMsg = truncateRunError(runErr.Error())
|
||||
return rep, runErr
|
||||
case <-time.After(time.Duration(a+1) * 100 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
if e != nil {
|
||||
// A failed page never overwrites what earlier pages already wrote
|
||||
// (#336): the run simply stops here and everything upserted so far
|
||||
// stays as-is, reported through Read/Created/Updated above.
|
||||
runErr = e
|
||||
errMsg = truncateRunError(fmt.Sprintf("状态 %s 第 %d 页拉取失败:%v", status, page, e))
|
||||
return rep, runErr
|
||||
}
|
||||
rep.TotalPages++
|
||||
if len(p.Records) == 0 {
|
||||
statusComplete = true
|
||||
break
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
runErr = ctx.Err()
|
||||
errMsg = truncateRunError(runErr.Error())
|
||||
return rep, runErr
|
||||
case <-time.After(time.Duration(a+1) * 100 * time.Millisecond):
|
||||
finger := pageFingerprint(p)
|
||||
if seen[finger] {
|
||||
rep.Skipped += len(p.Records)
|
||||
break
|
||||
}
|
||||
}
|
||||
if e != nil {
|
||||
// A failed page never overwrites what earlier pages already wrote
|
||||
// (#336): the run simply stops here and everything upserted so far
|
||||
// stays as-is, reported through Read/Created/Updated above.
|
||||
runErr = e
|
||||
errMsg = truncateRunError(e.Error())
|
||||
return rep, runErr
|
||||
}
|
||||
rep.TotalPages = page
|
||||
if len(p.Records) == 0 {
|
||||
complete = true
|
||||
break
|
||||
}
|
||||
finger := pageFingerprint(p)
|
||||
if seen[finger] {
|
||||
rep.Skipped += len(p.Records)
|
||||
break
|
||||
}
|
||||
seen[finger] = true
|
||||
for _, x := range p.Records {
|
||||
created, updated, recovered, err := s.upsert(ctx, x)
|
||||
if err != nil {
|
||||
rep.Failed++
|
||||
if firstWriteErr == nil {
|
||||
firstWriteErr = err
|
||||
seen[finger] = true
|
||||
for _, x := range p.Records {
|
||||
key, currentStatus := packageKey(x), external(x.Status)
|
||||
if previous, ok := seenPackages[key]; ok && (previous == "2" || previous == currentStatus) {
|
||||
rep.Skipped++
|
||||
continue
|
||||
}
|
||||
created, updated, recovered, err := s.upsert(ctx, x)
|
||||
seenPackages[key] = currentStatus
|
||||
if err != nil {
|
||||
rep.Failed++
|
||||
if firstWriteErr == nil {
|
||||
firstWriteErr = err
|
||||
}
|
||||
continue
|
||||
}
|
||||
rep.Read++
|
||||
rep.Recovered += recovered
|
||||
if created {
|
||||
rep.Created++
|
||||
} else if updated {
|
||||
rep.Updated++
|
||||
} else {
|
||||
rep.Skipped++
|
||||
}
|
||||
continue
|
||||
}
|
||||
rep.Read++
|
||||
rep.Recovered += recovered
|
||||
if created {
|
||||
rep.Created++
|
||||
} else if updated {
|
||||
rep.Updated++
|
||||
} else {
|
||||
rep.Skipped++
|
||||
if len(p.Records) < s.cfg.PageSize {
|
||||
statusComplete = true
|
||||
break
|
||||
}
|
||||
if p.Pages > 0 && page >= p.Pages {
|
||||
statusComplete = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(p.Records) < s.cfg.PageSize {
|
||||
complete = true
|
||||
break
|
||||
}
|
||||
if p.Pages > 0 && page >= p.Pages {
|
||||
complete = true
|
||||
if !statusComplete {
|
||||
complete = false
|
||||
errMsg = fmt.Sprintf("状态 %s 分页未完整结束(重复页或达到页数上限),未执行缺失标记", status)
|
||||
break
|
||||
}
|
||||
}
|
||||
if !complete {
|
||||
runErr = errors.New(errMsg)
|
||||
return rep, runErr
|
||||
}
|
||||
rep.Status = "succeeded"
|
||||
if rep.Failed > 0 && firstWriteErr != nil {
|
||||
// Surface why records failed instead of a bare counter.
|
||||
errMsg = truncateRunError(fmt.Sprintf("%d 条写入失败,首个原因:%v", rep.Failed, firstWriteErr))
|
||||
if rep.Read == 0 {
|
||||
rep.Status = "failed"
|
||||
runErr = errors.New(errMsg)
|
||||
}
|
||||
rep.Status = "failed"
|
||||
runErr = errors.New(errMsg)
|
||||
}
|
||||
// #338: only a naturally complete run with zero write failures is
|
||||
// trusted to mark items/packages the sync no longer sees as "missing".
|
||||
@@ -288,7 +303,7 @@ func (s *Service) run(ctx context.Context, r *models.YeekeSyncRun) (Report, erro
|
||||
rep.MissingMarked = marked
|
||||
}
|
||||
}
|
||||
return rep, nil
|
||||
return rep, runErr
|
||||
}
|
||||
|
||||
// markMissing implements #338's completion-triggered availability flip: any
|
||||
|
||||
@@ -204,11 +204,8 @@ func TestDuplicateFingerprintStopsMarking(t *testing.T) {
|
||||
defer srv2.Close()
|
||||
s.client, _ = yeekeclient.New(srv2.URL)
|
||||
rep, err := s.Sync(context.Background(), "manual")
|
||||
if err != nil {
|
||||
t.Fatalf("second sync: %v", err)
|
||||
}
|
||||
if rep.MissingMarked != 0 {
|
||||
t.Fatalf("MissingMarked=%d, want 0 (duplicate-fingerprint stop is not complete)", rep.MissingMarked)
|
||||
if err == nil || rep.Status != "failed" || rep.MissingMarked != 0 {
|
||||
t.Fatalf("rep=%+v err=%v (duplicate-fingerprint stop is not complete)", rep, err)
|
||||
}
|
||||
|
||||
var p2 models.YeekeReturnPackage
|
||||
@@ -250,11 +247,8 @@ func TestMaxPagesExhaustionStopsMarking(t *testing.T) {
|
||||
s.client, _ = yeekeclient.New(srv2.URL)
|
||||
s.cfg.MaxPages = 2
|
||||
rep, err := s.Sync(context.Background(), "manual")
|
||||
if err != nil {
|
||||
t.Fatalf("second sync: %v", err)
|
||||
}
|
||||
if rep.MissingMarked != 0 {
|
||||
t.Fatalf("MissingMarked=%d, want 0 (MaxPages exhaustion is not complete)", rep.MissingMarked)
|
||||
if err == nil || rep.Status != "failed" || rep.MissingMarked != 0 {
|
||||
t.Fatalf("rep=%+v err=%v (MaxPages exhaustion is not complete)", rep, err)
|
||||
}
|
||||
|
||||
var p2 models.YeekeReturnPackage
|
||||
|
||||
@@ -108,10 +108,7 @@ func TestPagingSkipsARepeatedDuplicatePage(t *testing.T) {
|
||||
c, _ := yeekeclient.New(srv.URL)
|
||||
s := NewService(db, c, Config{PageSize: 1})
|
||||
rep, err := s.Sync(context.Background(), "manual")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rep.Status != "succeeded" {
|
||||
if err == nil || rep.Status != "failed" {
|
||||
t.Fatalf("rep=%+v", rep)
|
||||
}
|
||||
var n int64
|
||||
@@ -138,7 +135,7 @@ func TestPagingStopsOnEmptyPage(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rep.Status != "succeeded" || rep.TotalPages != 1 || rep.Read != 0 {
|
||||
if rep.Status != "succeeded" || rep.TotalPages != 2 || rep.Read != 0 {
|
||||
t.Fatalf("rep=%+v", rep)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -371,8 +371,16 @@ func (f *FlexInt) UnmarshalJSON(b []byte) error {
|
||||
}
|
||||
|
||||
func (c *Client) List(ctx context.Context, pageNo, pageSize int) (ReturnPage, error) {
|
||||
return c.ListStatus(ctx, pageNo, pageSize, "1")
|
||||
}
|
||||
|
||||
// ListStatus reads only the two HAR-confirmed return statuses.
|
||||
func (c *Client) ListStatus(ctx context.Context, pageNo, pageSize int, status string) (ReturnPage, error) {
|
||||
if status != "1" && status != "2" {
|
||||
return ReturnPage{}, fmt.Errorf("unsupported yeeke return status")
|
||||
}
|
||||
// Same shape the web client posts (HAR): sort via column/order, filters as strings.
|
||||
body := map[string]any{"pageNo": pageNo, "pageSize": pageSize, "claimFlag": "1", "status": "1", "relationFlag": "1", "column": "createTime", "order": "desc"}
|
||||
body := map[string]any{"pageNo": pageNo, "pageSize": pageSize, "claimFlag": "1", "status": status, "relationFlag": "1", "column": "createTime", "order": "desc"}
|
||||
raw, e := c.do(ctx, http.MethodPost, "/agent-foreign/packageClaimRec/relation/list", body, nil)
|
||||
if e != nil {
|
||||
return ReturnPage{}, e
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package yeekeclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestListStatusUsesConfirmedHARFilters(t *testing.T) {
|
||||
var statuses []string
|
||||
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var body map[string]any
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
if r.Method != http.MethodPost || r.URL.Path != "/agent-foreign/packageClaimRec/relation/list" || body["claimFlag"] != "1" || body["relationFlag"] != "1" || body["column"] != "createTime" || body["order"] != "desc" {
|
||||
t.Error("HAR filters changed")
|
||||
}
|
||||
statuses = append(statuses, body["status"].(string))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"success":true,"result":{"records":[],"pages":0,"total":0}}`))
|
||||
}))
|
||||
defer s.Close()
|
||||
c, _ := New(s.URL)
|
||||
for _, status := range []string{"1", "2"} {
|
||||
if _, err := c.ListStatus(context.Background(), 1, 20, status); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := c.ListStatus(context.Background(), 1, 20, "3"); err == nil {
|
||||
t.Fatal("unsupported status allowed")
|
||||
}
|
||||
if len(statuses) != 2 || statuses[0] != "1" || statuses[1] != "2" {
|
||||
t.Fatal(statuses)
|
||||
}
|
||||
}
|
||||
@@ -172,24 +172,17 @@ export default {
|
||||
{ label: '退货待确认', value: 'matched' },
|
||||
{ label: '已用退货', value: 'confirmed' }
|
||||
],
|
||||
// yeeke's only confirmed claim-status value is the raw string "1"
|
||||
// (HAR evidence, #336 requirement doc). Any other value the sync sees
|
||||
// is preserved verbatim and flagged status_unrecognized — never
|
||||
// guessed into a label here. "unknown" is a server-side pseudo-filter
|
||||
// matching status_unrecognized=true, not a real yeeke value.
|
||||
// HAR-confirmed business statuses; unknown remains a server-side filter.
|
||||
claimStatusOptions: [
|
||||
{ label: '已认领', value: '1' },
|
||||
{ label: '待出货', value: '1' },
|
||||
{ label: '已重出', value: '2' },
|
||||
{ label: '未知(原样展示)', value: 'unknown' }
|
||||
]
|
||||
}
|
||||
},
|
||||
created() { this.load(); this.loadSummary(); this.loadShops() },
|
||||
methods: {
|
||||
// "1" is yeeke's only confirmed known status; anything else reaching
|
||||
// this function (status_unrecognized=false from the server) is an
|
||||
// unmapped value we have not seen yet — show it as-is rather than a
|
||||
// bare digit or a guessed Chinese label.
|
||||
claimStatusMeta(status) { return status === '1' ? { label: '已认领', type: 'success' } : { label: status ? `其他:${status}` : '—', type: 'info' } },
|
||||
claimStatusMeta(status) { return { '1': { label: '待出货', type: 'success' }, '2': { label: '已重出', type: 'info' }}[status] || { label: status ? `其他:${status}` : '—', type: 'info' } },
|
||||
matchStatusMeta(status) { return { unmatched: { label: '未匹配', type: 'info' }, matched: { label: '退货待确认', type: 'warning' }, confirmed: { label: '已用退货', type: 'success' }}[status] || { label: status || '—', type: 'info' } },
|
||||
openSybProduct(sybProductId) { this.$router.push({ path: '/syb-products/index', query: { sybProductId } }) },
|
||||
statusMeta(status) { return { running: { label: '执行中', type: 'primary' }, succeeded: { label: '成功', type: 'success' }, failed: { label: '失败', type: 'danger' }, interrupted: { label: '已中断', type: 'warning' }}[status] || { label: status || '-', type: 'info' } },
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const file = fs.readFileSync(path.join(__dirname, '../../src/views/goauto/yeeke-returns/index.vue'), 'utf8')
|
||||
const script = file.match(/<script>([\s\S]*?)<\/script>/)[1].replace(/^import[^\n]+\n/gm, '').replace('export default', 'return')
|
||||
const options = new Function(script)()
|
||||
|
||||
test('Yeeke status filters and table/detail labels agree for both HAR statuses', () => {
|
||||
const filters = options.data().claimStatusOptions
|
||||
for (const [value, label] of [['1', '待出货'], ['2', '已重出']]) {
|
||||
assert.equal(filters.find(item => item.value === value).label, label)
|
||||
assert.equal(options.methods.claimStatusMeta(value).label, label)
|
||||
}
|
||||
assert.equal(filters.find(item => item.value === 'unknown').label, '未知(原样展示)')
|
||||
assert.equal(options.methods.claimStatusMeta('9').label, '其他:9')
|
||||
})
|
||||
|
||||
test('clearing the status filter restores all records and reloads', () => {
|
||||
let reloads = 0
|
||||
const vm = { ...options.data(), load() { reloads++ } }
|
||||
vm.query.claimStatus = '2'
|
||||
options.methods.reset.call(vm)
|
||||
assert.equal(vm.query.claimStatus, '')
|
||||
assert.equal(vm.query.page, 1)
|
||||
assert.equal(reloads, 1)
|
||||
})
|
||||
Reference in New Issue
Block a user