Compare commits
43
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
abdf90775c | ||
|
|
934a7beabc | ||
|
|
24100f04d4 | ||
|
|
266418ac47 | ||
|
|
5e5fdd797e | ||
|
|
58c8c077ad | ||
|
|
9f679f6403 | ||
|
|
977ac235e2 | ||
|
|
573f5b8cf3 | ||
|
|
1491a9677a | ||
|
|
d4d32c6e89 | ||
|
|
b7038633f0 | ||
|
|
844f611d77 | ||
|
|
d415971268 | ||
|
|
6fe588d5e3 | ||
|
|
17cbfaec85 | ||
|
|
b72fa105a8 | ||
|
|
5ac8e4c8fd | ||
|
|
ff6e87649c | ||
|
|
1e944b9d2e | ||
|
|
20a8144be0 | ||
|
|
f2c8307c17 | ||
|
|
92c42ba1fe | ||
|
|
fc99be0392 | ||
|
|
dec99cb81d | ||
|
|
fbde80cc20 | ||
|
|
9bebf3930d | ||
|
|
3516fb7edc | ||
|
|
d0b86092a6 | ||
|
|
5faf870d0f | ||
|
|
6773c88a64 | ||
|
|
248eae01ee | ||
|
|
ac5d8336ec | ||
|
|
8ba3470728 | ||
|
|
b1629b900a | ||
|
|
c33e83823a | ||
|
|
faac8bb155 | ||
|
|
e845651933 | ||
|
|
3bc4784402 | ||
|
|
ab292d6a17 | ||
|
|
02bddbf304 | ||
|
|
3e82ad6570 | ||
|
|
b015719947 |
@@ -1,3 +1,19 @@
|
|||||||
|
<!-- gitea-wiki-mirror:start -->
|
||||||
|
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
|
||||||
|
<!-- gitea-wiki-mirror:end -->
|
||||||
|
|
||||||
|
<!-- gitea-wiki-mirror:start -->
|
||||||
|
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: c021b5b8251fecaf1cfa4b96d731964f6b04ca52
|
||||||
|
synchronized_at: 2026-09-27T02:47:22Z
|
||||||
|
<!-- gitea-wiki-mirror:end -->
|
||||||
|
|
||||||
<!-- gitea-wiki-mirror:start -->
|
<!-- gitea-wiki-mirror:start -->
|
||||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||||
wiki_page: Architecture-and-Code-Map
|
wiki_page: Architecture-and-Code-Map
|
||||||
@@ -488,7 +504,7 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
|
|||||||
|
|
||||||
### 会话类失败自动重试(#330)
|
### 会话类失败自动重试(#330)
|
||||||
|
|
||||||
- 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_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.go:会话类失败的 CanSubmit 不受退避租约限制;手工重新提交 attempt_count 置 0。
|
- purchase/order_writeback.go:会话类失败的 CanSubmit 不受退避租约限制;手工重新提交 attempt_count 置 0。
|
||||||
- 验证:go test ./app/goauto/purchase(含 httptest 模拟 /am/user/get 与断言 syb_session 未删除)。
|
- 验证:go test ./app/goauto/purchase(含 httptest 模拟 /am/user/get 与断言 syb_session 未删除)。
|
||||||
|
|
||||||
@@ -520,3 +536,14 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
|
|||||||
- `automation/PurchaseRehearsalExecutor.kt`:`waitForSpecPanel` 对回退类型要求连续两次结构一致;`selectionFailureEvidence` 选中失败诊断。
|
- `automation/PurchaseRehearsalExecutor.kt`:`waitForSpecPanel` 对回退类型要求连续两次结构一致;`selectionFailureEvidence` 选中失败诊断。
|
||||||
- `automation/PurchaseLiveAutomation.kt`:`waitForSettledPanelAfterAddressSave` 保存地址后稳定等待(不按返回键);`finalSubmitTargets` 拒绝零尺寸文字/容器;新增构造参数 `panelDiagnostic`(仅布尔/计数)。
|
- `automation/PurchaseLiveAutomation.kt`:`waitForSettledPanelAfterAddressSave` 保存地址后稳定等待(不按返回键);`finalSubmitTargets` 拒绝零尺寸文字/容器;新增构造参数 `panelDiagnostic`(仅布尔/计数)。
|
||||||
- 测试:`SpecPanelRecognitionTest`、`SpecOptionDedupTest`、`SpecPanelFixtures`(脱敏构造样本)。
|
- 测试:`SpecPanelRecognitionTest`、`SpecOptionDedupTest`、`SpecPanelFixtures`(脱敏构造样本)。
|
||||||
|
|
||||||
|
## GoAuto 售后角色与退货模块权限(#341)
|
||||||
|
|
||||||
|
- 追加迁移 `1789801100000_after_sales_role.go` 创建固定角色 `after_sales`(显示名“售后”)。
|
||||||
|
- 售后角色幂等复制当前采购员角色的菜单绑定与 Casbin API 策略,因此包含采购业务权限以及“yeeke 退货包裹”“yeeke 同步记录”两个退货模块;初始密码仅通过迁移时环境变量注入,不进入代码、日志或文档。
|
||||||
|
- 迁移创建四个内部账号并绑定售后角色;已存在账号不会重置密码或静默改绑角色。
|
||||||
|
|
||||||
|
|
||||||
|
## #338 退货匹配(2026-09-27)
|
||||||
|
|
||||||
|
新增 `server/app/goauto/returnmatch` 模块及 `/api/admin/v1/return-matches` 路由,支持批量匹配、列表/详情、确认、取消和备注;相关表由版本化迁移创建,采购创建在服务端拦截有效退货匹配,售后角色可执行这些接口。
|
||||||
|
|||||||
@@ -1,3 +1,19 @@
|
|||||||
|
<!-- gitea-wiki-mirror:start -->
|
||||||
|
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
|
||||||
|
<!-- gitea-wiki-mirror:end -->
|
||||||
|
|
||||||
|
<!-- gitea-wiki-mirror:start -->
|
||||||
|
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: 25e61fdbdfe1b3e70fec6d353620170ce14eb31f
|
||||||
|
synchronized_at: 2026-09-27T02:47:26Z
|
||||||
|
<!-- gitea-wiki-mirror:end -->
|
||||||
|
|
||||||
<!-- gitea-wiki-mirror:start -->
|
<!-- gitea-wiki-mirror:start -->
|
||||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||||
wiki_page: Business-Rules-and-Glossary
|
wiki_page: Business-Rules-and-Glossary
|
||||||
@@ -632,7 +648,7 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
|
|||||||
采购管理增加独立状态列、批量回填和详情补偿;复用既有访问权限,不增支付确认或审批。批量受理与最终成功分开展示;重试采购和回填分别筛选勾选项。远端无原子CAS,对系统外人工并发修改/超长延迟请求不能承诺绝对互斥;有冲突应人工核对,禁止强制覆盖。
|
采购管理增加独立状态列、批量回填和详情补偿;复用既有访问权限,不增支付确认或审批。批量受理与最终成功分开展示;重试采购和回填分别筛选勾选项。远端无原子CAS,对系统外人工并发修改/超长延迟请求不能承诺绝对互斥;有冲突应人工核对,禁止强制覆盖。
|
||||||
### SYB 会话类失败的有界自动重试(#330)
|
### SYB 会话类失败的有界自动重试(#330)
|
||||||
|
|
||||||
实现 01510a8/08b7095(2026-09-21,已合并 main,未部署、未生产验证)。回填 worker 从缓存会话恢复客户端后调用 SYB 会话校验;会话缺失/过期、串号失效、校验网络错误等均记为 `SYB_SESSION_UNAVAILABLE`,error_message 只记录类别和“将自动重试;如持续失败请恢复登录后重试”,不含原始错误。该类失败发生在任何写入之前,最多自动重试 6 次,退避 5/10/15/30/30 分钟(约 90 分钟,大于一个整点同步周期),达上限保持 failed 等人工。会话仍只由每小时 SYB 同步刷新;回填不登录、不 OCR、不删除或写入会话。其他失败码仍不自动重试。退避期内可手工重新回填,手工提交重置尝试次数。历史失败记录不会被自动领取。
|
实现 934a7be(#343,2026-09-27)。回填 worker 与 SYB 同步共用 `sybclient.AcquireSession`:有效缓存会话直接复用,明确失效时由 `syb_session_auth_lease` 保证单 worker OCR 登录,其他任务等待新会话后继续原回填。网络错误不清除有效会话;OCR 未配置或登录失败保留结构化失败并允许人工重试。回填创建时采购任务进入 `pending`,终态同步 `succeeded/failed` 与 `writeback_at`;冲突仍拒绝覆盖。会话类失败继续使用有界退避,成功须回读确认。
|
||||||
|
|
||||||
## Agent 回填订单入口兼容(#307)
|
## Agent 回填订单入口兼容(#307)
|
||||||
|
|
||||||
@@ -680,3 +696,14 @@ SYB 回填与资料完整性独立:正式SYB任务已有单号且仍关联有
|
|||||||
|
|
||||||
### 采购手机环境前提
|
### 采购手机环境前提
|
||||||
执行采购的 Android 手机必须:PDD App 已登录、微信已登录、PDD 已设置默认收货地址、PDD 默认支付方式为微信支付。规格面板的地址与支付证据依赖该前提;不为支付宝等其他支付方式补充识别别名(有意决定)。条件不满足时 Agent 明确失败,不自动登录、不切换支付方式、不填写地址。
|
执行采购的 Android 手机必须:PDD App 已登录、微信已登录、PDD 已设置默认收货地址、PDD 默认支付方式为微信支付。规格面板的地址与支付证据依赖该前提;不为支付宝等其他支付方式补充识别别名(有意决定)。条件不满足时 Agent 明确失败,不自动登录、不切换支付方式、不填写地址。
|
||||||
|
|
||||||
|
## 管理端售后角色(#341)
|
||||||
|
|
||||||
|
- 管理端固定增加 `after_sales`(显示名“售后”)业务角色。售后账号拥有采购员当前权限,并额外可访问“yeeke 退货包裹”和“yeeke 同步记录”两个模块及其接口。
|
||||||
|
- 售后角色与账号由版本化迁移幂等维护;创建初始账号时由部署环境临时提供密码,迁移不输出或持久化明文密码。已存在账号若绑定其他角色则迁移失败并要求人工处理。
|
||||||
|
- 本角色不获得管理员专属的用户、角色、设备凭据、规则配置或 AI Provider 管理权限,也不新增支付能力。
|
||||||
|
|
||||||
|
|
||||||
|
## 退货匹配(#338)
|
||||||
|
|
||||||
|
退货匹配只由人工勾选 SYB 商品后触发。按虾皮商品 ID 与归一化规格匹配,状态为“退货待确认”或“已用退货”时拦截新采购,取消后恢复可采购;已有采购任务不受影响。售后与采购员可操作匹配、确认、取消和备注。
|
||||||
|
|||||||
@@ -525,6 +525,13 @@ POST /am/stock/detail/updateDetailCode?t=0&id={stockID}&detailId={detailID}&code
|
|||||||
它是反复启动的一次性脚本,进程间要传会话;GoAuto 服务端是常驻进程,没有这个需求,
|
它是反复启动的一次性脚本,进程间要传会话;GoAuto 服务端是常驻进程,没有这个需求,
|
||||||
持久化只为重启后免登录,复用现有数据库即可。表见 `syb_session`。
|
持久化只为重启后免登录,复用现有数据库即可。表见 `syb_session`。
|
||||||
|
|
||||||
|
`[必须]` 同步和采购订单回填共用统一会话获取器:先校验 `syb_session`,只有明确
|
||||||
|
会话失效或不存在时才进入登录。登录由 `syb_session_auth_lease` 的单账号租约串行化,
|
||||||
|
租约内其他任务等待新会话,不重复请求验证码;登录成功后原回填任务重新执行并回读确认。
|
||||||
|
网络超时、5xx 或 OCR 不可用不得清除有效会话,必须保留结构化失败状态供人工重试。
|
||||||
|
回填终态同时同步到 `purchase_task.writeback_status/writeback_at`,页面不得继续显示旧的
|
||||||
|
`not_selected`。
|
||||||
|
|
||||||
`[决定已变更]` ~~不引入 OCR 服务。~~ 这条判断在上游工单 #47 里被推翻了,
|
`[决定已变更]` ~~不引入 OCR 服务。~~ 这条判断在上游工单 #47 里被推翻了,
|
||||||
原文和推翻理由都留在这里,方便后来人知道这个决定变过、为什么变:
|
原文和推翻理由都留在这里,方便后来人知道这个决定变过、为什么变:
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,19 @@
|
|||||||
|
<!-- gitea-wiki-mirror:start -->
|
||||||
|
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
|
||||||
|
<!-- gitea-wiki-mirror:end -->
|
||||||
|
|
||||||
|
<!-- gitea-wiki-mirror:start -->
|
||||||
|
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||||
|
wiki_page: Deployment-and-Operations
|
||||||
|
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-and-Operations.-
|
||||||
|
wiki_revision: 1195a0fab799c064c7e279443dd57977da63c94b
|
||||||
|
synchronized_at: 2026-09-27T02:47:34Z
|
||||||
|
<!-- gitea-wiki-mirror:end -->
|
||||||
|
|
||||||
<!-- gitea-wiki-mirror:start -->
|
<!-- gitea-wiki-mirror:start -->
|
||||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||||
wiki_page: Deployment-and-Operations
|
wiki_page: Deployment-and-Operations
|
||||||
@@ -153,3 +169,14 @@ Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、
|
|||||||
5. GoAuto Agent 已启动,无障碍服务已开启,Admin 设备列表显示在线并已归属对应采购员账号。
|
5. GoAuto Agent 已启动,无障碍服务已开启,Admin 设备列表显示在线并已归属对应采购员账号。
|
||||||
|
|
||||||
任一项不满足时采购会明确失败并提示缺失项(如“未找到支付入口:请确认 PDD 默认支付方式为微信支付”);Agent 不代为登录、切换支付方式或填写地址。安装新版 Agent 后需手动启动一次 App,心跳恢复后设备才显示在线。
|
任一项不满足时采购会明确失败并提示缺失项(如“未找到支付入口:请确认 PDD 默认支付方式为微信支付”);Agent 不代为登录、切换支付方式或填写地址。安装新版 Agent 后需手动启动一次 App,心跳恢复后设备才显示在线。
|
||||||
|
|
||||||
|
## #341 售后角色与账号迁移
|
||||||
|
|
||||||
|
- 发布前需单独授权执行 `server/cmd/migrate/migration/version-local/1789801100000_after_sales_role.go`。迁移会创建/维护 `after_sales` 角色、复制采购员当前菜单/API 授权,并创建四个售后账号;不会重置已存在账号密码。
|
||||||
|
- 首次创建账号时仅在当前迁移进程设置 `GOAUTO_AFTER_SALES_INITIAL_PASSWORD`,执行完成后立即清除;不得把密码写入仓库、日志、工单或 Wiki。
|
||||||
|
- 本地和线上均执行迁移后回读角色、四个账号的角色绑定、退货接口策略及幂等结果,再按常规流程重启 GoAuto API。
|
||||||
|
|
||||||
|
|
||||||
|
## #338 退货匹配发布
|
||||||
|
|
||||||
|
发布时执行退货匹配及售后权限迁移,切换 Server/Web release,重启 `goauto.service` 并 reload Nginx;发布后验证健康接口、Web 首页、售后登录及退货匹配只读接口,不用真实商品提交作为健康检查。
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
goautopurchase "go-admin/app/goauto/purchase"
|
goautopurchase "go-admin/app/goauto/purchase"
|
||||||
goautopurchaserule "go-admin/app/goauto/purchaserule"
|
goautopurchaserule "go-admin/app/goauto/purchaserule"
|
||||||
goautoreplacement "go-admin/app/goauto/replacement"
|
goautoreplacement "go-admin/app/goauto/replacement"
|
||||||
|
goautoreturnmatch "go-admin/app/goauto/returnmatch"
|
||||||
goautorule "go-admin/app/goauto/rule"
|
goautorule "go-admin/app/goauto/rule"
|
||||||
goautoshopeeproduct "go-admin/app/goauto/shopeeproduct"
|
goautoshopeeproduct "go-admin/app/goauto/shopeeproduct"
|
||||||
goautosybimport "go-admin/app/goauto/sybimport"
|
goautosybimport "go-admin/app/goauto/sybimport"
|
||||||
@@ -21,6 +22,7 @@ import (
|
|||||||
goautosybproductfilter "go-admin/app/goauto/sybproductfilter"
|
goautosybproductfilter "go-admin/app/goauto/sybproductfilter"
|
||||||
goautosybshop "go-admin/app/goauto/sybshop"
|
goautosybshop "go-admin/app/goauto/sybshop"
|
||||||
goautotask "go-admin/app/goauto/task"
|
goautotask "go-admin/app/goauto/task"
|
||||||
|
goautoyeeke "go-admin/app/goauto/yeeke"
|
||||||
common "go-admin/common/middleware"
|
common "go-admin/common/middleware"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -69,4 +71,6 @@ func InitRouter() {
|
|||||||
goautosybinnercode.InitRouter(r, authMiddleware)
|
goautosybinnercode.InitRouter(r, authMiddleware)
|
||||||
goautosybshop.InitRouter(r, authMiddleware)
|
goautosybshop.InitRouter(r, authMiddleware)
|
||||||
goautosybproductfilter.InitRouter(r, authMiddleware)
|
goautosybproductfilter.InitRouter(r, authMiddleware)
|
||||||
|
goautoyeeke.InitRouter(r, authMiddleware)
|
||||||
|
goautoreturnmatch.InitRouter(r, authMiddleware)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ const (
|
|||||||
ModuleCollectionTasks = "collection_tasks"
|
ModuleCollectionTasks = "collection_tasks"
|
||||||
ModulePurchaseTasks = "purchase_tasks"
|
ModulePurchaseTasks = "purchase_tasks"
|
||||||
ModuleAIMatching = "ai_matching"
|
ModuleAIMatching = "ai_matching"
|
||||||
|
ModuleYeekeReturns = "yeeke_returns"
|
||||||
|
ModuleYeekeSyncRuns = "yeeke_sync_runs"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ModuleDefinition is the single source of truth shared by menu migration,
|
// ModuleDefinition is the single source of truth shared by menu migration,
|
||||||
@@ -67,6 +69,8 @@ var goAutoMenuGroupMetadata = []MenuGroupDefinition{
|
|||||||
ModulePDDProducts,
|
ModulePDDProducts,
|
||||||
ModuleCollectionTasks,
|
ModuleCollectionTasks,
|
||||||
ModulePurchaseTasks,
|
ModulePurchaseTasks,
|
||||||
|
ModuleYeekeReturns,
|
||||||
|
ModuleYeekeSyncRuns,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -101,6 +105,8 @@ var goAutoModuleMetadata = []ModuleDefinition{
|
|||||||
{Key: ModuleCollectionTasks, Title: "采集任务", Path: "/collection-tasks", RouteName: "GoAutoCollectionTasks", Component: "/goauto/collection-tasks/index", Icon: "list", Sort: 60, PurchaserDefault: true},
|
{Key: ModuleCollectionTasks, Title: "采集任务", Path: "/collection-tasks", RouteName: "GoAutoCollectionTasks", Component: "/goauto/collection-tasks/index", Icon: "list", Sort: 60, PurchaserDefault: true},
|
||||||
{Key: ModulePurchaseTasks, Title: "采购管理", Path: "/purchase-tasks", RouteName: "GoAutoPurchaseTasks", Component: "/goauto/purchase-tasks/index", Icon: "shopping", Sort: 61, PurchaserDefault: true},
|
{Key: ModulePurchaseTasks, Title: "采购管理", Path: "/purchase-tasks", RouteName: "GoAutoPurchaseTasks", Component: "/goauto/purchase-tasks/index", Icon: "shopping", Sort: 61, PurchaserDefault: true},
|
||||||
{Key: ModuleAIMatching, Title: "AI 规格匹配", Path: "/ai-matching-settings", RouteName: "GoAutoAiMatchingSettings", Component: "/goauto/ai-matching-settings/index", Icon: "setting", Sort: 62, PurchaserHardHidden: true},
|
{Key: ModuleAIMatching, Title: "AI 规格匹配", Path: "/ai-matching-settings", RouteName: "GoAutoAiMatchingSettings", Component: "/goauto/ai-matching-settings/index", Icon: "setting", Sort: 62, PurchaserHardHidden: true},
|
||||||
|
{Key: ModuleYeekeReturns, Title: "yeeke 退货包裹", Path: "/yeeke-returns", RouteName: "GoAutoYeekeReturns", Component: "/goauto/yeeke-returns/index", Icon: "goods", Sort: 63, PurchaserDefault: true},
|
||||||
|
{Key: ModuleYeekeSyncRuns, Title: "yeeke 同步记录", Path: "/yeeke-sync-runs", RouteName: "GoAutoYeekeSyncRuns", Component: "/goauto/yeeke-sync-runs/index", Icon: "time", Sort: 64, PurchaserDefault: true},
|
||||||
}
|
}
|
||||||
|
|
||||||
// GoAutoModules returns independent copies so callers cannot mutate the
|
// GoAutoModules returns independent copies so callers cannot mutate the
|
||||||
@@ -161,6 +167,14 @@ func moduleKeyForAPI(path string) string {
|
|||||||
return ModulePurchaseTasks
|
return ModulePurchaseTasks
|
||||||
case strings.HasPrefix(path, "/api/admin/v1/ai-matching-settings"):
|
case strings.HasPrefix(path, "/api/admin/v1/ai-matching-settings"):
|
||||||
return ModuleAIMatching
|
return ModuleAIMatching
|
||||||
|
case strings.HasPrefix(path, "/api/admin/v1/yeeke-returns/sync-runs"):
|
||||||
|
return ModuleYeekeSyncRuns
|
||||||
|
case strings.HasPrefix(path, "/api/admin/v1/yeeke-returns/sync"):
|
||||||
|
return ModuleYeekeSyncRuns
|
||||||
|
case strings.HasPrefix(path, "/api/admin/v1/yeeke-returns"):
|
||||||
|
return ModuleYeekeReturns
|
||||||
|
case strings.HasPrefix(path, "/api/admin/v1/return-matches"):
|
||||||
|
return ModuleSYBProducts
|
||||||
default:
|
default:
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import (
|
|||||||
|
|
||||||
func TestGoAutoModulesOwnEveryAdminAPIExactlyOnce(t *testing.T) {
|
func TestGoAutoModulesOwnEveryAdminAPIExactlyOnce(t *testing.T) {
|
||||||
modules := GoAutoModules()
|
modules := GoAutoModules()
|
||||||
if len(modules) != 13 {
|
if len(modules) != 15 {
|
||||||
t.Fatalf("got %d modules, want 13", len(modules))
|
t.Fatalf("got %d modules, want 15", len(modules))
|
||||||
}
|
}
|
||||||
|
|
||||||
owners := make(map[string]int)
|
owners := make(map[string]int)
|
||||||
@@ -31,6 +31,30 @@ func TestGoAutoModulesOwnEveryAdminAPIExactlyOnce(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestYeekeSyncRunsModuleIsRegisteredAndPurchaserVisible(t *testing.T) {
|
||||||
|
module, ok := ModuleByRouteName("GoAutoYeekeSyncRuns")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("yeeke sync-runs module not found")
|
||||||
|
}
|
||||||
|
if module.Key != ModuleYeekeSyncRuns {
|
||||||
|
t.Fatalf("unexpected key: %s", module.Key)
|
||||||
|
}
|
||||||
|
if module.Path != "/yeeke-sync-runs" || module.Component != "/goauto/yeeke-sync-runs/index" {
|
||||||
|
t.Fatalf("unexpected route wiring: %#v", module)
|
||||||
|
}
|
||||||
|
if !module.PurchaserDefault {
|
||||||
|
t.Fatal("yeeke sync-runs module must be purchaser-visible per #337")
|
||||||
|
}
|
||||||
|
if len(module.APIs) == 0 {
|
||||||
|
t.Fatal("yeeke sync-runs module must own its APIs")
|
||||||
|
}
|
||||||
|
for _, permission := range module.APIs {
|
||||||
|
if permission.Path == "/api/admin/v1/yeeke-returns" && permission.Method == "GET" {
|
||||||
|
t.Fatal("the return-package list API belongs to yeeke_returns, not yeeke_sync_runs")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAIMatchingIsHardHiddenFromPurchaser(t *testing.T) {
|
func TestAIMatchingIsHardHiddenFromPurchaser(t *testing.T) {
|
||||||
module, ok := ModuleByRouteName("GoAutoAiMatchingSettings")
|
module, ok := ModuleByRouteName("GoAutoAiMatchingSettings")
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -56,8 +80,8 @@ func TestGoAutoMenuSortsFitMySQLSignedTinyInt(t *testing.T) {
|
|||||||
}
|
}
|
||||||
previous = module.Sort
|
previous = module.Sort
|
||||||
}
|
}
|
||||||
if modules[0].Sort != 50 || modules[len(modules)-1].Sort != 62 {
|
if modules[0].Sort != 50 || modules[len(modules)-1].Sort != 64 {
|
||||||
t.Fatalf("GoAuto menu sort range = %d..%d, want 50..62", modules[0].Sort, modules[len(modules)-1].Sort)
|
t.Fatalf("GoAuto menu sort range = %d..%d, want 50..64", modules[0].Sort, modules[len(modules)-1].Sort)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,7 +95,7 @@ func TestGoAutoMenuGroupsCoverModulesExactlyOnce(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
wantOrder := [][]string{
|
wantOrder := [][]string{
|
||||||
{ModuleSYBProducts, ModuleSYBSyncRuns, ModuleSYBInnerCodes, ModuleShopeeProducts, ModulePDDProducts, ModuleCollectionTasks, ModulePurchaseTasks},
|
{ModuleSYBProducts, ModuleSYBSyncRuns, ModuleSYBInnerCodes, ModuleShopeeProducts, ModulePDDProducts, ModuleCollectionTasks, ModulePurchaseTasks, ModuleYeekeReturns, ModuleYeekeSyncRuns},
|
||||||
{ModuleSYBShops, ModuleSYBProductFilters, ModuleCollectionRules, ModulePurchaseRules, ModuleDevices, ModuleAIMatching},
|
{ModuleSYBShops, ModuleSYBProductFilters, ModuleCollectionRules, ModulePurchaseRules, ModuleDevices, ModuleAIMatching},
|
||||||
}
|
}
|
||||||
seen := make(map[string]int)
|
seen := make(map[string]int)
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ package access
|
|||||||
// RolePurchaser is the fixed role key used by the GoAuto purchaser account.
|
// RolePurchaser is the fixed role key used by the GoAuto purchaser account.
|
||||||
const RolePurchaser = "purchaser"
|
const RolePurchaser = "purchaser"
|
||||||
|
|
||||||
|
// RoleAfterSales is the fixed role key for the internal after-sales users.
|
||||||
|
const RoleAfterSales = "after_sales"
|
||||||
|
|
||||||
// APIPermission describes one admin API known to GoAuto. Purchaser marks the
|
// APIPermission describes one admin API known to GoAuto. Purchaser marks the
|
||||||
// APIs that the purchaser role may call; every other API remains admin-only.
|
// APIs that the purchaser role may call; every other API remains admin-only.
|
||||||
type APIPermission struct {
|
type APIPermission struct {
|
||||||
@@ -124,6 +127,23 @@ var AdminAPIs = []APIPermission{
|
|||||||
{"取消采购任务", "/api/admin/v1/purchase-tasks/:taskId/cancel", "POST", true},
|
{"取消采购任务", "/api/admin/v1/purchase-tasks/:taskId/cancel", "POST", true},
|
||||||
{"处理结果不明确任务", "/api/admin/v1/purchase-tasks/:taskId/resolve-unknown", "POST", true},
|
{"处理结果不明确任务", "/api/admin/v1/purchase-tasks/:taskId/resolve-unknown", "POST", true},
|
||||||
|
|
||||||
|
{"查看 yeeke 退货包裹列表", "/api/admin/v1/yeeke-returns", "GET", true},
|
||||||
|
{"查看 yeeke 退货店铺列表", "/api/admin/v1/yeeke-returns/shops", "GET", true},
|
||||||
|
{"查看 yeeke 退货商品详情", "/api/admin/v1/yeeke-returns/items/:itemId", "GET", true},
|
||||||
|
{"查看 yeeke 退货包裹详情", "/api/admin/v1/yeeke-returns/:packageId", "GET", true},
|
||||||
|
{"查看 yeeke 同步记录", "/api/admin/v1/yeeke-returns/sync-runs", "GET", true},
|
||||||
|
{"查看 yeeke 同步详情", "/api/admin/v1/yeeke-returns/sync-runs/:runId", "GET", true},
|
||||||
|
{"手动触发 yeeke 同步", "/api/admin/v1/yeeke-returns/sync", "POST", true},
|
||||||
|
|
||||||
|
{"查看退货匹配", "/api/admin/v1/return-matches", "GET", true},
|
||||||
|
{"查看退货匹配批次", "/api/admin/v1/return-matches/batches", "GET", true},
|
||||||
|
{"查看退货匹配批次详情", "/api/admin/v1/return-matches/batches/:batchId", "GET", true},
|
||||||
|
{"查看退货匹配详情", "/api/admin/v1/return-matches/:id", "GET", true},
|
||||||
|
{"批量匹配退货", "/api/admin/v1/return-matches/batch-match", "POST", true},
|
||||||
|
{"确认退货匹配", "/api/admin/v1/return-matches/:id/confirm", "POST", true},
|
||||||
|
{"取消退货匹配", "/api/admin/v1/return-matches/:id/cancel", "POST", true},
|
||||||
|
{"备注退货匹配", "/api/admin/v1/return-matches/:id/remark", "POST", true},
|
||||||
|
|
||||||
{"查看 AI 匹配状态", "/api/admin/v1/ai-matching-settings", "GET", true},
|
{"查看 AI 匹配状态", "/api/admin/v1/ai-matching-settings", "GET", true},
|
||||||
{"保存 AI 匹配设置", "/api/admin/v1/ai-matching-settings", "PUT", false},
|
{"保存 AI 匹配设置", "/api/admin/v1/ai-matching-settings", "PUT", false},
|
||||||
{"测试 AI 服务连接", "/api/admin/v1/ai-matching-settings/test", "POST", false},
|
{"测试 AI 服务连接", "/api/admin/v1/ai-matching-settings/test", "POST", false},
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ func fixture(t *testing.T) (*gorm.DB, clientkey.Service) {
|
|||||||
func TestEveryRouteIsExplicitlyScoped(t *testing.T) {
|
func TestEveryRouteIsExplicitlyScoped(t *testing.T) {
|
||||||
db, s := fixture(t)
|
db, s := fixture(t)
|
||||||
routes := Inventory()
|
routes := Inventory()
|
||||||
if len(s.Modules) != 13 {
|
if len(s.Modules) != 15 {
|
||||||
t.Fatal("menu groups lost")
|
t.Fatal("menu groups lost")
|
||||||
}
|
}
|
||||||
router := gin.New()
|
router := gin.New()
|
||||||
|
|||||||
@@ -41,9 +41,14 @@ func MigratedModels() []any {
|
|||||||
&models.SYBSpecAIParseRun{},
|
&models.SYBSpecAIParseRun{},
|
||||||
&models.SYBSpecAIParseWorkItem{},
|
&models.SYBSpecAIParseWorkItem{},
|
||||||
&models.SYBSession{},
|
&models.SYBSession{},
|
||||||
|
&models.SYBSessionAuthLease{},
|
||||||
&models.SYBShop{},
|
&models.SYBShop{},
|
||||||
&models.SYBProductFilter{},
|
&models.SYBProductFilter{},
|
||||||
&models.SYBSyncRun{},
|
&models.SYBSyncRun{},
|
||||||
|
&models.YeekeSession{},
|
||||||
|
&models.YeekeReturnPackage{},
|
||||||
|
&models.YeekeReturnItem{},
|
||||||
|
&models.YeekeSyncRun{},
|
||||||
&models.SYBInnerCodeRecord{},
|
&models.SYBInnerCodeRecord{},
|
||||||
&models.SYBInnerCodeItem{},
|
&models.SYBInnerCodeItem{},
|
||||||
&models.SYBInnerCodeApplyBatch{},
|
&models.SYBInnerCodeApplyBatch{},
|
||||||
@@ -75,6 +80,9 @@ func MigratedModels() []any {
|
|||||||
&models.PDDProductReplacement{},
|
&models.PDDProductReplacement{},
|
||||||
&models.PDDProductReplacementItem{},
|
&models.PDDProductReplacementItem{},
|
||||||
&models.PDDProductReplacementWorkerLease{},
|
&models.PDDProductReplacementWorkerLease{},
|
||||||
|
&models.ReturnMatch{},
|
||||||
|
&models.ReturnMatchLog{},
|
||||||
|
&models.ReturnMatchBatch{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// #338: ReturnMatch links one syb_product row to one yeeke_return_item row so
|
||||||
|
// the return can be reused instead of buying the item again on PDD. A match
|
||||||
|
// is "active" (still blocking purchase creation and occupying the return)
|
||||||
|
// while Status is matched or confirmed; cancelling clears both Active*
|
||||||
|
// columns so the unique index frees up both sides for a future rematch
|
||||||
|
// (issue #338, business rule: 取消匹配后再次点击「匹配退货」若配回同一对,允许).
|
||||||
|
//
|
||||||
|
// ActiveSYBProductID/ActiveYeekeReturnItemID follow the same nullable-unique-
|
||||||
|
// column pattern already used by YeekeSyncRun.ActiveSlot in this codebase:
|
||||||
|
// MySQL unique indexes allow any number of NULL rows, so only the currently
|
||||||
|
// active match for a given side occupies the slot, and a concurrent second
|
||||||
|
// insert for the same side fails the unique constraint instead of double-
|
||||||
|
// booking it.
|
||||||
|
const (
|
||||||
|
ReturnMatchStatusMatched = "matched"
|
||||||
|
ReturnMatchStatusConfirmed = "confirmed"
|
||||||
|
ReturnMatchStatusCancelled = "cancelled"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ReturnMatch struct {
|
||||||
|
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||||
|
|
||||||
|
SYBProductID uint64 `json:"sybProductId" gorm:"not null;index"`
|
||||||
|
YeekeReturnItemID uint64 `json:"yeekeReturnItemId" gorm:"not null;index"`
|
||||||
|
|
||||||
|
// ActiveSYBProductID/ActiveYeekeReturnItemID mirror the ID columns above
|
||||||
|
// while Status is matched/confirmed, and are cleared to NULL on cancel.
|
||||||
|
ActiveSYBProductID *uint64 `json:"-" gorm:"uniqueIndex:ux_return_match_active_syb"`
|
||||||
|
ActiveYeekeReturnItemID *uint64 `json:"-" gorm:"uniqueIndex:ux_return_match_active_return"`
|
||||||
|
|
||||||
|
Status string `json:"status" gorm:"size:16;not null;index;check:ck_return_match_status,status IN ('matched','confirmed','cancelled')"`
|
||||||
|
|
||||||
|
// Snapshots of the normalized spec text on both sides at match time, kept
|
||||||
|
// for audit and for the compare screen even if the source rows change
|
||||||
|
// later.
|
||||||
|
SYBSpecText string `json:"sybSpecText" gorm:"size:255;not null;default:''"`
|
||||||
|
YeekeSpecText string `json:"yeekeSpecText" gorm:"size:255;not null;default:''"`
|
||||||
|
NormalizedKey string `json:"normalizedKey" gorm:"size:255;not null;default:'';index"`
|
||||||
|
|
||||||
|
// PreviousProcessStage records what the SYB product's computed process
|
||||||
|
// stage was immediately before this match, so cancel can report/restore
|
||||||
|
// context; the actual restore is a pure recomputation (no stage is
|
||||||
|
// stored on syb_product), this is for audit/display only.
|
||||||
|
PreviousProcessStage string `json:"previousProcessStage" gorm:"size:32;not null;default:''"`
|
||||||
|
|
||||||
|
DestroyDeadlineSnapshot *time.Time `json:"destroyDeadlineSnapshot,omitempty"`
|
||||||
|
|
||||||
|
Remark string `json:"remark" gorm:"size:500;not null;default:''"`
|
||||||
|
|
||||||
|
MatchedBy string `json:"matchedBy" gorm:"size:64;not null;default:''"`
|
||||||
|
MatchedAt time.Time `json:"matchedAt"`
|
||||||
|
ConfirmedBy string `json:"confirmedBy,omitempty" gorm:"size:64;not null;default:''"`
|
||||||
|
ConfirmedAt *time.Time `json:"confirmedAt,omitempty"`
|
||||||
|
CancelledBy string `json:"cancelledBy,omitempty" gorm:"size:64;not null;default:''"`
|
||||||
|
CancelledAt *time.Time `json:"cancelledAt,omitempty"`
|
||||||
|
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ReturnMatch) TableName() string { return "return_match" }
|
||||||
|
|
||||||
|
// IsActive reports whether the match currently blocks purchase creation and
|
||||||
|
// occupies its return item.
|
||||||
|
func (m ReturnMatch) IsActive() bool {
|
||||||
|
return m.Status == ReturnMatchStatusMatched || m.Status == ReturnMatchStatusConfirmed
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// ReturnMatchBatch records one click of 「匹配退货」 (#338): who submitted it,
|
||||||
|
// which SYB products were submitted and the per-product outcome, so a later
|
||||||
|
// question like "why did only N of my selection match" can be answered from
|
||||||
|
// data instead of reconstructing the page selection. ItemsJSON holds the
|
||||||
|
// []BatchMatchItem result array (sybProductId/matched/reasonCode/reason/
|
||||||
|
// matchId) in submission order.
|
||||||
|
type ReturnMatchBatch struct {
|
||||||
|
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||||
|
Operator string `json:"operator" gorm:"size:64;not null;default:'';index"`
|
||||||
|
SubmittedCount int `json:"submittedCount" gorm:"not null;default:0"`
|
||||||
|
MatchedCount int `json:"matchedCount" gorm:"not null;default:0"`
|
||||||
|
SkippedCount int `json:"skippedCount" gorm:"not null;default:0"`
|
||||||
|
ItemsJSON string `json:"-" gorm:"type:longtext;not null"`
|
||||||
|
// Error is non-empty when the batch aborted part-way; ItemsJSON then
|
||||||
|
// holds only the rows processed before the failure.
|
||||||
|
Error string `json:"error" gorm:"size:500;not null;default:''"`
|
||||||
|
|
||||||
|
CreatedAt time.Time `json:"createdAt" gorm:"index"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ReturnMatchBatch) TableName() string { return "return_match_batch" }
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// ReturnMatchLog is #338's dedicated operation log for the return-matching
|
||||||
|
// feature. The framework's generic sys_opera_log records raw HTTP
|
||||||
|
// request/response per call and is not queryable per match id nor does it
|
||||||
|
// carry a stable action/operator/detail shape, so this table is written
|
||||||
|
// explicitly, in the same transaction as the action it records (match /
|
||||||
|
// confirm / cancel / remark).
|
||||||
|
const (
|
||||||
|
ReturnMatchLogActionMatched = "matched"
|
||||||
|
ReturnMatchLogActionConfirmed = "confirmed"
|
||||||
|
ReturnMatchLogActionCancelled = "cancelled"
|
||||||
|
ReturnMatchLogActionRemark = "remark"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ReturnMatchLog struct {
|
||||||
|
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||||
|
MatchID uint64 `json:"matchId" gorm:"not null;index"`
|
||||||
|
Action string `json:"action" gorm:"size:16;not null;index;check:ck_return_match_log_action,action IN ('matched','confirmed','cancelled','remark')"`
|
||||||
|
Operator string `json:"operator" gorm:"size:64;not null;default:''"`
|
||||||
|
Detail string `json:"detail" gorm:"size:1000;not null;default:''"`
|
||||||
|
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ReturnMatchLog) TableName() string { return "return_match_log" }
|
||||||
@@ -504,6 +504,17 @@ type SYBSession struct {
|
|||||||
UpdatedAt time.Time `json:"updatedAt"`
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SYBSessionAuthLease serializes re-authentication across sync and writeback
|
||||||
|
// workers. Cookies and passwords never live in this table.
|
||||||
|
type SYBSessionAuthLease struct {
|
||||||
|
ID uint64 `gorm:"primaryKey"`
|
||||||
|
Username string `gorm:"size:128;not null;uniqueIndex"`
|
||||||
|
Owner string `gorm:"size:36;not null"`
|
||||||
|
ExpiresAt time.Time `gorm:"not null"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (SYBSessionAuthLease) TableName() string { return "syb_session_auth_lease" }
|
||||||
|
|
||||||
// SYBProduct is one SYB (顺云宝 ERP) shipment detail line: one order can carry
|
// SYBProduct is one SYB (顺云宝 ERP) shipment detail line: one order can carry
|
||||||
// several Shopee product lines, and the same Shopee product can appear more
|
// several Shopee product lines, and the same Shopee product can appear more
|
||||||
// than once within one order at different colors/sizes/quantities — each such
|
// than once within one order at different colors/sizes/quantities — each such
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// YeekeSession stores only the opaque session material; credentials are kept
|
||||||
|
// outside the application database and supplied by the administrator at run time.
|
||||||
|
type YeekeSession struct {
|
||||||
|
ID uint64 `gorm:"primaryKey;autoIncrement"`
|
||||||
|
Username string `gorm:"size:128;not null;uniqueIndex:ux_yeeke_session_username"`
|
||||||
|
Token string `json:"-" gorm:"type:text;not null"`
|
||||||
|
CookiesJSON string `json:"-" gorm:"type:text;not null"`
|
||||||
|
UserID string `gorm:"size:128;not null;default:''"`
|
||||||
|
ExpiresAt time.Time `gorm:"not null;index"`
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (YeekeSession) TableName() string { return "yeeke_session" }
|
||||||
|
|
||||||
|
type YeekeReturnPackage struct {
|
||||||
|
ID uint64 `gorm:"primaryKey;autoIncrement"`
|
||||||
|
ExternalID string `gorm:"size:128;not null;uniqueIndex:ux_yeeke_return_package_external"`
|
||||||
|
OrderSN string `gorm:"size:128;not null;index"`
|
||||||
|
TrackingNo string `gorm:"size:128;not null;index"`
|
||||||
|
ShopID string `gorm:"size:128;not null;default:''"`
|
||||||
|
ShopName string `gorm:"size:255;not null;default:''"`
|
||||||
|
WareCode string `gorm:"size:128;not null;default:''"`
|
||||||
|
WareHouse string `gorm:"size:255;not null;default:''"`
|
||||||
|
WareName string `gorm:"size:255;not null;default:''"`
|
||||||
|
ClaimStatus string `gorm:"size:64;not null;default:''"`
|
||||||
|
// StatusUnrecognized is set when ClaimStatus is not one of the values the
|
||||||
|
// sync code currently understands. It is never bucketed into a known
|
||||||
|
// status silently (#336): the raw value is still kept in ClaimStatus, and
|
||||||
|
// this flag lets an operator find and review these rows.
|
||||||
|
StatusUnrecognized bool `gorm:"not null;default:false;index"`
|
||||||
|
ClaimTime *time.Time
|
||||||
|
CreateTime *time.Time
|
||||||
|
UpdateTime *time.Time
|
||||||
|
DestroyDeadLine *time.Time
|
||||||
|
LastSyncedAt time.Time `gorm:"not null;index"`
|
||||||
|
// SyncStatus is "ok" while the package still appears in a COMPLETE yeeke
|
||||||
|
// sync; #338 sets it to "missing" (with MissingSince stamped) once a
|
||||||
|
// completed sync no longer sees it, so return matching stops using it.
|
||||||
|
// It is never deleted or marked "已销毁" — only flagged unavailable.
|
||||||
|
SyncStatus string `gorm:"size:32;not null;default:'ok';index"`
|
||||||
|
MissingSince *time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (YeekeReturnPackage) TableName() string { return "yeeke_return_package" }
|
||||||
|
|
||||||
|
type YeekeReturnItem struct {
|
||||||
|
ID uint64 `gorm:"primaryKey;autoIncrement"`
|
||||||
|
PackageID uint64 `gorm:"not null;uniqueIndex:ux_yeeke_return_item_key,priority:1;index"`
|
||||||
|
ExternalKey string `gorm:"size:512;not null;uniqueIndex:ux_yeeke_return_item_key,priority:2"`
|
||||||
|
ItemID string `gorm:"size:128;not null;index"`
|
||||||
|
VariationID string `gorm:"size:128;not null;default:''"`
|
||||||
|
ItemName string `gorm:"size:500;not null;default:''"`
|
||||||
|
VariationName string `gorm:"size:500;not null;default:''"`
|
||||||
|
Image string `gorm:"type:text;not null"`
|
||||||
|
Quantity int64 `gorm:"not null;default:0"`
|
||||||
|
LastSyncedAt time.Time `gorm:"not null;index"`
|
||||||
|
// SyncStatus/MissingSince mirror YeekeReturnPackage's fields (#338): once
|
||||||
|
// a COMPLETE yeeke sync no longer sees this item it is flagged "missing"
|
||||||
|
// so returnmatch.availableReturnPool stops offering it, without ever
|
||||||
|
// deleting the row.
|
||||||
|
SyncStatus string `gorm:"size:32;not null;default:'ok';index"`
|
||||||
|
MissingSince *time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (YeekeReturnItem) TableName() string { return "yeeke_return_item" }
|
||||||
|
|
||||||
|
type YeekeSyncRun struct {
|
||||||
|
ID uint64 `gorm:"primaryKey;autoIncrement"`
|
||||||
|
Status string `gorm:"size:32;not null;index"`
|
||||||
|
Trigger string `gorm:"size:32;not null;index"`
|
||||||
|
TotalPages int `gorm:"not null;default:0"`
|
||||||
|
ReadCount int `gorm:"not null;default:0"`
|
||||||
|
CreatedCount int `gorm:"not null;default:0"`
|
||||||
|
UpdatedCount int `gorm:"not null;default:0"`
|
||||||
|
SkippedCount int `gorm:"not null;default:0"`
|
||||||
|
FailedCount int `gorm:"not null;default:0"`
|
||||||
|
// #338: items flipped to "missing" / back to "ok" by this run.
|
||||||
|
MissingMarkedCount int `gorm:"not null;default:0"`
|
||||||
|
RecoveredCount int `gorm:"not null;default:0"`
|
||||||
|
ErrorMessage string `gorm:"size:1000;not null;default:''"`
|
||||||
|
StartedAt time.Time `gorm:"not null"`
|
||||||
|
FinishedAt *time.Time
|
||||||
|
LastSuccessAt *time.Time
|
||||||
|
ActiveSlot *uint8 `gorm:"uniqueIndex:ux_yeeke_sync_run_active_slot"`
|
||||||
|
LeaseOwner string `gorm:"size:128;not null;default:''"`
|
||||||
|
LeaseExpiresAt *time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (YeekeSyncRun) TableName() string { return "yeeke_sync_run" }
|
||||||
@@ -142,6 +142,11 @@ type batchPreviewDataset struct {
|
|||||||
// 见 #289 与 sybimport.RawSpecHalves。
|
// 见 #289 与 sybimport.RawSpecHalves。
|
||||||
collapsedColorByShopee map[uint64]map[string]bool
|
collapsedColorByShopee map[uint64]map[string]bool
|
||||||
collapsedSizeByShopee map[uint64]map[string]bool
|
collapsedSizeByShopee map[uint64]map[string]bool
|
||||||
|
// activeReturnMatchBySYB is #338's return-match stage input: a non-zero
|
||||||
|
// entry means the SYB product currently has an active (matched or
|
||||||
|
// confirmed) return_match row, which forces the 退货待确认/已用退货
|
||||||
|
// stages regardless of what the rest of the pipeline would compute.
|
||||||
|
activeReturnMatchBySYB map[uint64]models.ReturnMatch
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadBatchPreviewDataset keeps the read-only preview on bounded bulk queries.
|
// loadBatchPreviewDataset keeps the read-only preview on bounded bulk queries.
|
||||||
@@ -159,6 +164,7 @@ func (s *Service) loadBatchPreviewDataset(ctx context.Context, ids []uint64) (ba
|
|||||||
skuCombinationsByPDD: make(map[uint64][]pddSKUCombination),
|
skuCombinationsByPDD: make(map[uint64][]pddSKUCombination),
|
||||||
collapsedColorByShopee: make(map[uint64]map[string]bool),
|
collapsedColorByShopee: make(map[uint64]map[string]bool),
|
||||||
collapsedSizeByShopee: make(map[uint64]map[string]bool),
|
collapsedSizeByShopee: make(map[uint64]map[string]bool),
|
||||||
|
activeReturnMatchBySYB: make(map[uint64]models.ReturnMatch),
|
||||||
}
|
}
|
||||||
var sybProducts []models.SYBProduct
|
var sybProducts []models.SYBProduct
|
||||||
if err := s.DB.WithContext(ctx).Where("id IN ?", ids).Find(&sybProducts).Error; err != nil {
|
if err := s.DB.WithContext(ctx).Where("id IN ?", ids).Find(&sybProducts).Error; err != nil {
|
||||||
@@ -248,6 +254,18 @@ func (s *Service) loadBatchPreviewDataset(ctx context.Context, ids []uint64) (ba
|
|||||||
dataset.latestTaskBySYB[*task.SYBProductID] = task
|
dataset.latestTaskBySYB[*task.SYBProductID] = task
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// #338: load active return matches for these SYB products. This query
|
||||||
|
// only ever ADDS a stage override on top of the pre-#338 computation; an
|
||||||
|
// empty result set (the common case for products never matched) leaves
|
||||||
|
// every other branch of processStageFromDataset byte-for-byte unchanged
|
||||||
|
// (issue #338 acceptance item 11 regression).
|
||||||
|
var activeMatches []models.ReturnMatch
|
||||||
|
if err := s.DB.WithContext(ctx).Where("syb_product_id IN ? AND active_syb_product_id IS NOT NULL", ids).Find(&activeMatches).Error; err != nil {
|
||||||
|
return dataset, err
|
||||||
|
}
|
||||||
|
for _, m := range activeMatches {
|
||||||
|
dataset.activeReturnMatchBySYB[m.SYBProductID] = m
|
||||||
|
}
|
||||||
return dataset, nil
|
return dataset, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -311,10 +311,11 @@ func TestBatchPreviewBulkLoadsAndNeverCallsAIMatcher(t *testing.T) {
|
|||||||
t.Fatalf("read-only preview called AI matcher %d times", matcher.calls)
|
t.Fatalf("read-only preview called AI matcher %d times", matcher.calls)
|
||||||
}
|
}
|
||||||
// #289 新增一次有界批量查询(按蕃皮商品拉全部明细用于塔缩检测),
|
// #289 新增一次有界批量查询(按蕃皮商品拉全部明细用于塔缩检测),
|
||||||
// 因此从 7 变为 8。这条断言守的是“不得出现 N+1”,不是具体数字;
|
// 因此从 7 变为 8;#338 再新增一次有界批量查询(按 SYB 商品ID拉活跃
|
||||||
// 只有新增的查询确实有界时才允许上调。
|
// 退货匹配),因此从 8 变为 9。这条断言守的是“不得出现 N+1”,不是具体
|
||||||
if queries != 8 {
|
// 数字;只有新增的查询确实有界时才允许上调。
|
||||||
t.Fatalf("batch preview used %d queries, want 8 bounded queries including collection eligibility, current purchase rule and collapsed spec keys", queries)
|
if queries != 9 {
|
||||||
|
t.Fatalf("batch preview used %d queries, want 9 bounded queries including collection eligibility, current purchase rule, collapsed spec keys and #338 active return matches", queries)
|
||||||
}
|
}
|
||||||
if len(response.Items) != 2 || !response.Items[0].Eligible || !response.Items[1].Eligible || response.EligibleCount != 2 {
|
if len(response.Items) != 2 || !response.Items[0].Eligible || !response.Items[1].Eligible || response.EligibleCount != 2 {
|
||||||
t.Fatalf("unresolved rows remain eligible for live probing but are not purchase-ready: %+v", response)
|
t.Fatalf("unresolved rows remain eligible for live probing but are not purchase-ready: %+v", response)
|
||||||
|
|||||||
@@ -475,10 +475,10 @@ func allowedOperator(c *gin.Context) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
role, _ := jwt.ExtractClaims(c)["rolekey"].(string)
|
role, _ := jwt.ExtractClaims(c)["rolekey"].(string)
|
||||||
if role == "admin" || role == "purchaser" {
|
if role == "admin" || role == "purchaser" || role == "after_sales" {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusForbidden, gin.H{"code": "FORBIDDEN", "message": "只有管理员或采购员可以操作采购任务"})
|
c.JSON(http.StatusForbidden, gin.H{"code": "FORBIDDEN", "message": "只有管理员、采购员或售后可以操作采购任务"})
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package purchase
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAllowedOperatorIncludesAfterSales(t *testing.T) {
|
||||||
|
for _, role := range []string{"admin", "purchaser", "after_sales", "other", ""} {
|
||||||
|
t.Run(role, func(t *testing.T) {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(w)
|
||||||
|
c.Set("JWT_PAYLOAD", jwt.MapClaims{"rolekey": role})
|
||||||
|
want := role == "admin" || role == "purchaser" || role == "after_sales"
|
||||||
|
if got := allowedOperator(c); got != want {
|
||||||
|
t.Fatalf("role %q allowed=%v want %v", role, got, want)
|
||||||
|
}
|
||||||
|
if !want && w.Code != 403 {
|
||||||
|
t.Fatalf("denied role status=%d", w.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -96,7 +96,7 @@ func TestOrderBackfillMixedBatchAndReplay(t *testing.T) {
|
|||||||
if saved.StatusVersion != a.StatusVersion+1 || saved.ErrorCode != nil || saved.ErrorMessage != nil || saved.DeviceRunSlot != nil || saved.AccountRunSlot != nil || saved.ActiveSlot == nil || saved.Status != models.PurchaseTaskStatusOrderCreated {
|
if saved.StatusVersion != a.StatusVersion+1 || saved.ErrorCode != nil || saved.ErrorMessage != nil || saved.DeviceRunSlot != nil || saved.AccountRunSlot != nil || saved.ActiveSlot == nil || saved.Status != models.PurchaseTaskStatusOrderCreated {
|
||||||
t.Fatalf("state metadata: %+v", saved)
|
t.Fatalf("state metadata: %+v", saved)
|
||||||
}
|
}
|
||||||
if saved.PaymentReviewStatus != a.PaymentReviewStatus || saved.LogisticsStatus != a.LogisticsStatus || saved.WritebackStatus != a.WritebackStatus || saved.RuleSnapshot != a.RuleSnapshot {
|
if saved.PaymentReviewStatus != a.PaymentReviewStatus || saved.LogisticsStatus != a.LogisticsStatus || saved.WritebackStatus != models.PurchaseWritebackStatusPending || saved.RuleSnapshot != a.RuleSnapshot {
|
||||||
t.Fatal("unrelated business facts changed")
|
t.Fatal("unrelated business facts changed")
|
||||||
}
|
}
|
||||||
for _, replayID := range []string{rid, uuid.NewString()} {
|
for _, replayID := range []string{rid, uuid.NewString()} {
|
||||||
|
|||||||
@@ -60,7 +60,10 @@ func ensureOrderWriteback(tx *gorm.DB, t models.PurchaseTask) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
row := models.PurchaseOrderWriteback{PurchaseTaskID: t.ID, StockID: int64(syb.StockID), DetailID: int64(syb.DetailID), OrderNo: *t.PDDOrderNo, Status: "pending"}
|
row := models.PurchaseOrderWriteback{PurchaseTaskID: t.ID, StockID: int64(syb.StockID), DetailID: int64(syb.DetailID), OrderNo: *t.PDDOrderNo, Status: "pending"}
|
||||||
return tx.Session(&gorm.Session{Logger: logger.Default.LogMode(logger.Silent)}).Clauses(clause.OnConflict{DoNothing: true}).Create(&row).Error
|
if err := tx.Session(&gorm.Session{Logger: logger.Default.LogMode(logger.Silent)}).Clauses(clause.OnConflict{DoNothing: true}).Create(&row).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Session(&gorm.Session{SkipHooks: true}).Model(&models.PurchaseTask{}).Where("id = ? AND writeback_status = ?", t.ID, models.PurchaseWritebackStatusNotSelected).Update("writeback_status", models.PurchaseWritebackStatusPending).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) OrderWritebackViews(ctx context.Context, tasks []models.PurchaseTask) (map[uint64]OrderWritebackView, error) {
|
func (s *Service) OrderWritebackViews(ctx context.Context, tasks []models.PurchaseTask) (map[uint64]OrderWritebackView, error) {
|
||||||
|
|||||||
@@ -116,7 +116,11 @@ func TestOrderWritebackRemoteOutcomes(t *testing.T) {
|
|||||||
t.Fatal("automatically repeated write")
|
t.Fatal("automatically repeated write")
|
||||||
}
|
}
|
||||||
after := loadBackfillTask(t, s.DB, task.ID)
|
after := loadBackfillTask(t, s.DB, task.ID)
|
||||||
if after.PaymentReviewStatus != task.PaymentReviewStatus || after.WritebackStatus != task.WritebackStatus || after.StatusVersion != task.StatusVersion {
|
wantTaskWriteback := models.PurchaseWritebackStatusFailed
|
||||||
|
if tc.want == "succeeded" {
|
||||||
|
wantTaskWriteback = models.PurchaseWritebackStatusSucceeded
|
||||||
|
}
|
||||||
|
if after.PaymentReviewStatus != task.PaymentReviewStatus || after.WritebackStatus != wantTaskWriteback || after.StatusVersion != task.StatusVersion {
|
||||||
t.Fatal("changed purchase/payment/logistics facts")
|
t.Fatal("changed purchase/payment/logistics facts")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -84,38 +84,16 @@ func sessionUnavailableMessage(err error) string {
|
|||||||
return "SYB会话不可用(" + category + "),将自动重试;如持续失败请恢复登录后重试"
|
return "SYB会话不可用(" + category + "),将自动重试;如持续失败请恢复登录后重试"
|
||||||
}
|
}
|
||||||
|
|
||||||
// restoreOrderWritebackClient rebuilds a SYB client from the cached session
|
// restoreOrderWritebackClient uses the same session acquisition path as SYB
|
||||||
// only. It never logs in, never triggers OCR and never deletes the cached
|
// sync. A valid cached session is reused; an explicitly invalid session is
|
||||||
// session (that stays the exclusive responsibility of sybimport.Connect's
|
// refreshed under the shared database auth lease so concurrent workers do not
|
||||||
// login/refresh path) — it only reports whether the cached cookies still
|
// request multiple captcha codes.
|
||||||
// work, via CheckSession, so the caller can classify the failure (#330).
|
|
||||||
func restoreOrderWritebackClient(ctx context.Context, db *gorm.DB) (OrderNumberClient, error) {
|
func restoreOrderWritebackClient(ctx context.Context, db *gorm.DB) (OrderNumberClient, error) {
|
||||||
cfg := config.ExtConfig.SYB.Resolved()
|
cfg := config.ExtConfig.SYB.Resolved()
|
||||||
session, err := sybclient.NewSessionStore(db).Load(ctx, cfg.Username, time.Now())
|
return sybclient.AcquireSession(ctx, db, sybclient.LoginConfig{
|
||||||
if err != nil {
|
BaseURL: cfg.BaseURL, Username: cfg.Username, Password: cfg.Password,
|
||||||
return nil, err
|
OcrURL: cfg.OcrURL, OcrMaxAttempts: cfg.OcrMaxAttempts,
|
||||||
}
|
})
|
||||||
if session.UserID <= 0 {
|
|
||||||
return nil, errSessionUserIDMissing
|
|
||||||
}
|
|
||||||
c, err := sybclient.New(cfg.BaseURL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err = c.ImportCookiesJSON(session.CookiesJSON); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
// Active probe (#330 修订1): without this, a remotely-expired cookie jar
|
|
||||||
// imports cleanly and only fails later inside read(), which would record
|
|
||||||
// it as SYB_READ_FAILED instead of the retryable session-class outcome.
|
|
||||||
// Any error here — ErrSessionInvalid or network/format — is treated as
|
|
||||||
// session-class; only ErrSessionInvalid is a confirmed logout, but a
|
|
||||||
// network/format error is not confirmed-valid either, so it is still
|
|
||||||
// retried rather than attempted as a write.
|
|
||||||
if err = c.CheckSession(ctx, session.UserID, cfg.Username); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return c, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// One short-lived claim at a time across processes. No business writes occur
|
// One short-lived claim at a time across processes. No business writes occur
|
||||||
@@ -184,7 +162,20 @@ func (w *OrderWritebackWorker) RunOnce(ctx context.Context) (bool, error) {
|
|||||||
if status == "succeeded" {
|
if status == "succeeded" {
|
||||||
updates["completed_at"] = w.Now()
|
updates["completed_at"] = w.Now()
|
||||||
}
|
}
|
||||||
return db.Model(&models.PurchaseOrderWriteback{}).Where("id = ? AND status = 'running' AND lease_owner = ?", item.ID, owner).Updates(updates).Error
|
if err := db.Model(&models.PurchaseOrderWriteback{}).Where("id = ? AND status = 'running' AND lease_owner = ?", item.ID, owner).Updates(updates).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Keep the admin task state aligned with the authoritative writeback
|
||||||
|
// record. Conflicts and unknown outcomes remain failed until a human
|
||||||
|
// resolves them; they must never appear as successful.
|
||||||
|
taskStatus := models.PurchaseWritebackStatusFailed
|
||||||
|
if status == "succeeded" {
|
||||||
|
taskStatus = models.PurchaseWritebackStatusSucceeded
|
||||||
|
}
|
||||||
|
return db.Session(&gorm.Session{SkipHooks: true}).Model(&models.PurchaseTask{}).Where("id = ?", item.PurchaseTaskID).Updates(map[string]any{
|
||||||
|
"writeback_status": taskStatus,
|
||||||
|
"writeback_at": gorm.Expr("CASE WHEN ? = 'succeeded' THEN ? ELSE writeback_at END", status, w.Now()),
|
||||||
|
}).Error
|
||||||
}
|
}
|
||||||
// finishSessionUnavailable is the bounded-retry counterpart of finish for
|
// finishSessionUnavailable is the bounded-retry counterpart of finish for
|
||||||
// SYB_SESSION_UNAVAILABLE: instead of clearing the lease, it schedules the
|
// SYB_SESSION_UNAVAILABLE: instead of clearing the lease, it schedules the
|
||||||
@@ -201,7 +192,10 @@ func (w *OrderWritebackWorker) RunOnce(ctx context.Context) (bool, error) {
|
|||||||
} else {
|
} else {
|
||||||
updates["lease_expires_at"] = nil
|
updates["lease_expires_at"] = nil
|
||||||
}
|
}
|
||||||
return db.Model(&models.PurchaseOrderWriteback{}).Where("id = ? AND status = 'running' AND lease_owner = ?", item.ID, owner).Updates(updates).Error
|
if err := db.Model(&models.PurchaseOrderWriteback{}).Where("id = ? AND status = 'running' AND lease_owner = ?", item.ID, owner).Updates(updates).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return db.Session(&gorm.Session{SkipHooks: true}).Model(&models.PurchaseTask{}).Where("id = ?", item.PurchaseTaskID).Update("writeback_status", models.PurchaseWritebackStatusFailed).Error
|
||||||
}
|
}
|
||||||
var task models.PurchaseTask
|
var task models.PurchaseTask
|
||||||
if err = db.First(&task, item.PurchaseTaskID).Error; err != nil {
|
if err = db.First(&task, item.PurchaseTaskID).Error; err != nil {
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ const (
|
|||||||
ProcessStageTaskCreated = "task_created"
|
ProcessStageTaskCreated = "task_created"
|
||||||
ProcessStagePurchaseSucceeded = "purchase_succeeded"
|
ProcessStagePurchaseSucceeded = "purchase_succeeded"
|
||||||
ProcessStageOrderReview = "order_review"
|
ProcessStageOrderReview = "order_review"
|
||||||
|
// ProcessStageReturnPending/ProcessStageReturnUsed are the two new stages
|
||||||
|
// added by issue #338: a SYB product with an active return_match blocks
|
||||||
|
// purchase creation regardless of what stage it would otherwise compute
|
||||||
|
// to (see rejectIfActiveReturnMatch and processStageFromDataset below).
|
||||||
|
ProcessStageReturnPending = "return_pending"
|
||||||
|
ProcessStageReturnUsed = "return_used"
|
||||||
processActionOpenPDDLink = "open_pdd_link"
|
processActionOpenPDDLink = "open_pdd_link"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -35,6 +41,8 @@ var processStageLabels = map[string]string{
|
|||||||
ProcessStageTaskCreated: "已创建任务",
|
ProcessStageTaskCreated: "已创建任务",
|
||||||
ProcessStagePurchaseSucceeded: "采购成功",
|
ProcessStagePurchaseSucceeded: "采购成功",
|
||||||
ProcessStageOrderReview: "待人工核对",
|
ProcessStageOrderReview: "待人工核对",
|
||||||
|
ProcessStageReturnPending: "退货待确认",
|
||||||
|
ProcessStageReturnUsed: "已用退货",
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProcessStageResult struct {
|
type ProcessStageResult struct {
|
||||||
@@ -132,6 +140,17 @@ func processStageFromDataset(id uint64, dataset batchPreviewDataset, preview Bat
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #338: an active return match overrides the normal computation below —
|
||||||
|
// it only ever fires for a SYB product that has one, so with zero
|
||||||
|
// matches (the pre-#338 default) this branch is a no-op and every stage
|
||||||
|
// below is unchanged (acceptance item 11 regression).
|
||||||
|
if match, ok := dataset.activeReturnMatchBySYB[id]; ok {
|
||||||
|
if match.Status == models.ReturnMatchStatusConfirmed {
|
||||||
|
return stage(ProcessStageReturnUsed, "已用退货冲抵,无需采购", "open_return_match")
|
||||||
|
}
|
||||||
|
return stage(ProcessStageReturnPending, "已匹配退货待人工确认,暂不能创建采购任务", "open_return_match")
|
||||||
|
}
|
||||||
|
|
||||||
syb, ok := dataset.sybByID[id]
|
syb, ok := dataset.sybByID[id]
|
||||||
if !ok {
|
if !ok {
|
||||||
return stage(ProcessStageManualAction, "SYB 商品不存在或已删除", "refresh")
|
return stage(ProcessStageManualAction, "SYB 商品不存在或已删除", "refresh")
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package purchase
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go-admin/app/goauto/models"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// rejectIfActiveReturnMatch implements issue #338's purchase-creation block:
|
||||||
|
// a SYB product with an active (matched or confirmed) return_match row must
|
||||||
|
// not get a NEW purchase task created for it (single or batch — BatchCreate
|
||||||
|
// calls s.Create per row, so this single check point covers both paths and
|
||||||
|
// batch creation simply skips the row via the normal createErr handling,
|
||||||
|
// issue #338 acceptance item 5). Resuming/reparsing an existing task is a
|
||||||
|
// different code path and is not affected.
|
||||||
|
//
|
||||||
|
// On any query error this returns the error (never nil) so a DB failure can
|
||||||
|
// never silently allow a purchase for an already-matched product (issue
|
||||||
|
// #338 acceptance item 11).
|
||||||
|
func rejectIfActiveReturnMatch(tx *gorm.DB, sybProductID uint64) error {
|
||||||
|
var match models.ReturnMatch
|
||||||
|
err := tx.Where("active_syb_product_id = ?", sybProductID).First(&match).Error
|
||||||
|
if err == nil {
|
||||||
|
return fail(CodeReturnMatched, "该商品已匹配退货,需先在退货匹配中取消才能创建采购任务")
|
||||||
|
}
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return internal(err)
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package purchase
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go-admin/app/goauto/models"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// #338 acceptance item 5/6/11: a SYB product with an active return match
|
||||||
|
// must be rejected for NEW single-task creation, batch creation must skip
|
||||||
|
// only that row (not fail the whole batch), and with zero matches (the
|
||||||
|
// default) behavior must be byte-for-byte unchanged.
|
||||||
|
|
||||||
|
func TestCreate_RejectsWhenActiveReturnMatch(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
s := testService(db)
|
||||||
|
f := seed(t, db, liveCaps(), true)
|
||||||
|
|
||||||
|
match := models.ReturnMatch{
|
||||||
|
SYBProductID: f.syb.ID, YeekeReturnItemID: 1,
|
||||||
|
ActiveSYBProductID: &f.syb.ID, Status: models.ReturnMatchStatusMatched,
|
||||||
|
MatchedAt: time.Now(),
|
||||||
|
}
|
||||||
|
if err := db.Create(&match).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := createLive(t, s, f)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected rejection, got success")
|
||||||
|
}
|
||||||
|
if se, ok := asServiceError(err); !ok || se.Code != CodeReturnMatched {
|
||||||
|
t.Fatalf("expected CodeReturnMatched, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreate_ConfirmedReturnMatchAlsoRejects(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
s := testService(db)
|
||||||
|
f := seed(t, db, liveCaps(), true)
|
||||||
|
|
||||||
|
match := models.ReturnMatch{
|
||||||
|
SYBProductID: f.syb.ID, YeekeReturnItemID: 1,
|
||||||
|
ActiveSYBProductID: &f.syb.ID, Status: models.ReturnMatchStatusConfirmed,
|
||||||
|
MatchedAt: time.Now(),
|
||||||
|
}
|
||||||
|
if err := db.Create(&match).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err := createLive(t, s, f)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected rejection for confirmed match too")
|
||||||
|
}
|
||||||
|
if se, ok := asServiceError(err); !ok || se.Code != CodeReturnMatched {
|
||||||
|
t.Fatalf("expected CodeReturnMatched, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreate_CancelledReturnMatchDoesNotBlock(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
s := testService(db)
|
||||||
|
f := seed(t, db, liveCaps(), true)
|
||||||
|
|
||||||
|
match := models.ReturnMatch{
|
||||||
|
SYBProductID: f.syb.ID, YeekeReturnItemID: 1,
|
||||||
|
ActiveSYBProductID: nil, Status: models.ReturnMatchStatusCancelled,
|
||||||
|
MatchedAt: time.Now(),
|
||||||
|
}
|
||||||
|
if err := db.Create(&match).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := createLive(t, s, f); err != nil {
|
||||||
|
t.Fatalf("cancelled match must not block creation: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBatchCreate_SkipsOnlyBlockedRowNotWholeBatch is the acceptance-item-5
|
||||||
|
// "batch creation must skip just those rows" regression: one blocked SYB
|
||||||
|
// product and one clean one in the same batch call.
|
||||||
|
func TestBatchCreate_SkipsOnlyBlockedRowNotWholeBatch(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
s := testService(db)
|
||||||
|
f1 := seed(t, db, liveCaps(), true)
|
||||||
|
setCollectedPDDPrice(t, db, f1.pdd.ID)
|
||||||
|
|
||||||
|
// second, independent eligible SYB product sharing the same shopee/pdd
|
||||||
|
// chain shape but its own row.
|
||||||
|
syb2 := models.SYBProduct{OrderCode: "SYB-2", DetailID: 2, StockID: 3, ShopeeItemID: f1.shopee.ShopeeItemID, ShopeeProductID: &f1.shopee.ID, ProductTitle: f1.shopee.Title, TargetColor: "黑色", TargetSize: "XL", Quantity: 1, UnitPriceCent: 2000, ImageURL: "", ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{}`}
|
||||||
|
if err := db.Create(&syb2).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
match := models.ReturnMatch{
|
||||||
|
SYBProductID: f1.syb.ID, YeekeReturnItemID: 1,
|
||||||
|
ActiveSYBProductID: &f1.syb.ID, Status: models.ReturnMatchStatusMatched,
|
||||||
|
MatchedAt: time.Now(),
|
||||||
|
}
|
||||||
|
if err := db.Create(&match).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := s.BatchCreate(context.Background(), BatchCreateRequest{
|
||||||
|
RequestID: uuid.NewString(), SYBProductIDs: []uint64{f1.syb.ID, syb2.ID}, DeviceID: &f1.device.ID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("batch call itself must not fail: %v", err)
|
||||||
|
}
|
||||||
|
if resp.CreatedCount != 1 || resp.FailedCount != 1 {
|
||||||
|
t.Fatalf("expected 1 created + 1 failed, got created=%d failed=%d items=%+v", resp.CreatedCount, resp.FailedCount, resp.Items)
|
||||||
|
}
|
||||||
|
for _, item := range resp.Items {
|
||||||
|
if item.SYBProductID == f1.syb.ID {
|
||||||
|
if item.Created {
|
||||||
|
t.Fatalf("matched row must not be created: %+v", item)
|
||||||
|
}
|
||||||
|
if item.ReasonCode != CodeReturnMatched {
|
||||||
|
t.Fatalf("expected CodeReturnMatched reason, got %+v", item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if item.SYBProductID == syb2.ID && !item.Created {
|
||||||
|
t.Fatalf("clean row must still be created: %+v", item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCreate_NoReturnMatchTableRowsUnaffected is the acceptance-item-11
|
||||||
|
// regression: with zero return_match rows anywhere, creation behaves exactly
|
||||||
|
// as it did before #338.
|
||||||
|
func TestCreate_NoReturnMatchTableRowsUnaffected(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
s := testService(db)
|
||||||
|
f := seed(t, db, liveCaps(), true)
|
||||||
|
if _, err := createLive(t, s, f); err != nil {
|
||||||
|
t.Fatalf("zero-match baseline must succeed unchanged: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func asServiceError(err error) (*ServiceError, bool) {
|
||||||
|
se, ok := err.(*ServiceError)
|
||||||
|
return se, ok
|
||||||
|
}
|
||||||
@@ -178,6 +178,9 @@ func (s *Service) create(ctx context.Context, req CreateRequest) (models.Purchas
|
|||||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&syb, *req.SYBProductID).Error; err != nil {
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&syb, *req.SYBProductID).Error; err != nil {
|
||||||
return notFound(err, "顺云宝商品不存在")
|
return notFound(err, "顺云宝商品不存在")
|
||||||
}
|
}
|
||||||
|
if err := rejectIfActiveReturnMatch(tx, syb.ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if syb.ShopeeProductID == nil {
|
if syb.ShopeeProductID == nil {
|
||||||
return fail(CodeInvalidRequest, "该商品尚未关联蝦皮商品")
|
return fail(CodeInvalidRequest, "该商品尚未关联蝦皮商品")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,11 @@ const (
|
|||||||
CodeOrderTimeMissing = "PURCHASE_ORDER_TIME_MISSING"
|
CodeOrderTimeMissing = "PURCHASE_ORDER_TIME_MISSING"
|
||||||
CodeOrderTimeInvalid = "PURCHASE_ORDER_TIME_INVALID"
|
CodeOrderTimeInvalid = "PURCHASE_ORDER_TIME_INVALID"
|
||||||
CodeOrderUnpaidMissing = "PURCHASE_ORDER_UNPAID_EVIDENCE_MISSING"
|
CodeOrderUnpaidMissing = "PURCHASE_ORDER_UNPAID_EVIDENCE_MISSING"
|
||||||
|
// CodeReturnMatched is returned when a SYB product has an active (matched
|
||||||
|
// or confirmed) return match: issue #338 blocks creating NEW purchase
|
||||||
|
// tasks for such a product until the match is cancelled. Resuming or
|
||||||
|
// reparsing an existing task is not affected.
|
||||||
|
CodeReturnMatched = "PURCHASE_RETURN_MATCHED"
|
||||||
CodeInternal = "INTERNAL_ERROR"
|
CodeInternal = "INTERNAL_ERROR"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package returnmatch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go-admin/app/goauto/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// #338: every 「匹配退货」 click is recorded with the submitted SYB products in
|
||||||
|
// order and each one's outcome, so "why did only some of my selection match"
|
||||||
|
// can be answered from data.
|
||||||
|
func TestBatchMatch_RecordsSubmittedProductsAndOutcomes(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
s := NewService(db)
|
||||||
|
s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
|
||||||
|
|
||||||
|
deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
hit := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 2, 0, 0, 0, 0, time.UTC))
|
||||||
|
miss := seedSYB(t, db, "SYB-2", 2, "黑色", "XL", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
|
||||||
|
seedReturn(t, db, "白色,L", &deadline)
|
||||||
|
missingID := uint64(999999)
|
||||||
|
|
||||||
|
submitted := []uint64{miss.ID, hit.ID, missingID}
|
||||||
|
resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: submitted, Operator: "caigou1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
batches, err := s.ListBatches(context.Background(), 10, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(batches) != 1 {
|
||||||
|
t.Fatalf("want exactly one batch record, got %d", len(batches))
|
||||||
|
}
|
||||||
|
b := batches[0]
|
||||||
|
if b.Operator != "caigou1" || b.SubmittedCount != 3 || b.MatchedCount != 1 || b.SkippedCount != 2 || b.Error != "" {
|
||||||
|
t.Fatalf("unexpected batch header: %+v", b.ReturnMatchBatch)
|
||||||
|
}
|
||||||
|
if len(b.Items) != len(submitted) {
|
||||||
|
t.Fatalf("want %d items, got %+v", len(submitted), b.Items)
|
||||||
|
}
|
||||||
|
for i, id := range submitted {
|
||||||
|
if b.Items[i].SYBProductID != id {
|
||||||
|
t.Fatalf("item %d: want submitted order id %d, got %d", i, id, b.Items[i].SYBProductID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if b.Items[0].ReasonCode != ReasonNoCandidate || !b.Items[1].Matched || b.Items[1].MatchID != resp.Items[1].MatchID || b.Items[2].ReasonCode != ReasonStageIneligible {
|
||||||
|
t.Fatalf("unexpected recorded outcomes: %+v", b.Items)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := s.GetBatch(context.Background(), b.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(got.Items) != 3 {
|
||||||
|
t.Fatalf("GetBatch items: %+v", got.Items)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The list without items stays small.
|
||||||
|
light, err := s.ListBatches(context.Background(), 10, false)
|
||||||
|
if err != nil || len(light) != 1 || light[0].Items != nil {
|
||||||
|
t.Fatalf("list without items: %+v err=%v", light, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An empty submission is rejected before matching and leaves no record.
|
||||||
|
func TestBatchMatch_EmptySubmissionNotRecorded(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
if _, err := NewService(db).BatchMatch(context.Background(), BatchMatchRequest{Operator: "x"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var n int64
|
||||||
|
db.Model(&models.ReturnMatchBatch{}).Count(&n)
|
||||||
|
if n != 0 {
|
||||||
|
t.Fatalf("empty submission recorded %d batches", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package returnmatch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SYBCandidate is the pure, DB-free view of one selected SYB order product
|
||||||
|
// eligible to participate in this batch's matching (stage filter and "no
|
||||||
|
// active match" filter are applied by the caller before building this list;
|
||||||
|
// see issue #338 rule 1).
|
||||||
|
type SYBCandidate struct {
|
||||||
|
SYBProductID uint64
|
||||||
|
ShopeeItemID string
|
||||||
|
TargetColor string
|
||||||
|
TargetSize string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReturnCandidate is the pure, DB-free view of one available yeeke return
|
||||||
|
// item (no active match, non-nil destroy deadline; the "deadline later than
|
||||||
|
// now" filter is applied by the caller — see BuildReturnPool below, or by
|
||||||
|
// the caller directly when it already filters in SQL).
|
||||||
|
type ReturnCandidate struct {
|
||||||
|
ReturnItemID uint64
|
||||||
|
ItemID string
|
||||||
|
VariationName string
|
||||||
|
DestroyDeadline time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// SkipReason enumerates why a selected SYB candidate was not matched in this
|
||||||
|
// batch, for the batch-result dialog's per-row reasons (issue #338 prototype
|
||||||
|
// screen 2).
|
||||||
|
type SkipReason string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SkipReasonNoCandidate SkipReason = "no_candidate"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MatchOutcome is one row of the pure matching result: either matched to a
|
||||||
|
// return item, or skipped with a reason.
|
||||||
|
type MatchOutcome struct {
|
||||||
|
SYBProductID uint64
|
||||||
|
Matched bool
|
||||||
|
ReturnItemID uint64
|
||||||
|
SkipReason SkipReason
|
||||||
|
NormalizedKey string
|
||||||
|
DestroyDeadline time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// SelectMatches implements issue #338 rules 2-6 purely in memory:
|
||||||
|
// - products are processed in the order given by the caller, which MUST be
|
||||||
|
// SYB created_at DESC (rule 4); this function does not itself sort by
|
||||||
|
// CreatedAt so a caller can supply a stable pre-sorted/tie-broken order.
|
||||||
|
// - a return is eligible only while its deadline is strictly after `now`
|
||||||
|
// (rule 2), matched on shopeeItemId + normalized spec text (rule 3);
|
||||||
|
// - once a return is used within this run it cannot be reused by a later
|
||||||
|
// product in the same run, on top of whatever was already occupied
|
||||||
|
// before the run started (rule 4, "已被占用的退货商品不再参与本轮后续
|
||||||
|
// 及以后的匹配");
|
||||||
|
// - when several candidates match the same product, the one with the
|
||||||
|
// earliest destroy deadline is chosen (rule 5); quantity is ignored
|
||||||
|
// entirely (rule 6).
|
||||||
|
func SelectMatches(products []SYBCandidate, returns []ReturnCandidate, now time.Time) []MatchOutcome {
|
||||||
|
// Group available (deadline > now) returns by matchKey = itemId + "|" +
|
||||||
|
// normalized spec text, sorted by deadline ascending so the first unused
|
||||||
|
// entry in each bucket is always the earliest deadline.
|
||||||
|
buckets := make(map[string][]ReturnCandidate)
|
||||||
|
for _, r := range returns {
|
||||||
|
if !r.DestroyDeadline.After(now) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := matchKey(r.ItemID, trimCommas(Normalize(r.VariationName)))
|
||||||
|
buckets[key] = append(buckets[key], r)
|
||||||
|
}
|
||||||
|
for key := range buckets {
|
||||||
|
bucket := buckets[key]
|
||||||
|
sort.Slice(bucket, func(i, j int) bool {
|
||||||
|
return bucket[i].DestroyDeadline.Before(bucket[j].DestroyDeadline)
|
||||||
|
})
|
||||||
|
buckets[key] = bucket
|
||||||
|
}
|
||||||
|
|
||||||
|
used := make(map[uint64]bool)
|
||||||
|
outcomes := make([]MatchOutcome, 0, len(products))
|
||||||
|
for _, p := range products {
|
||||||
|
key := matchKey(p.ShopeeItemID, trimCommas(Normalize(SYBSpecText(p.TargetColor, p.TargetSize))))
|
||||||
|
var picked *ReturnCandidate
|
||||||
|
for i := range buckets[key] {
|
||||||
|
cand := buckets[key][i]
|
||||||
|
if used[cand.ReturnItemID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
picked = &buckets[key][i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if picked == nil {
|
||||||
|
outcomes = append(outcomes, MatchOutcome{SYBProductID: p.SYBProductID, Matched: false, SkipReason: SkipReasonNoCandidate, NormalizedKey: key})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
used[picked.ReturnItemID] = true
|
||||||
|
outcomes = append(outcomes, MatchOutcome{
|
||||||
|
SYBProductID: p.SYBProductID, Matched: true, ReturnItemID: picked.ReturnItemID,
|
||||||
|
NormalizedKey: key, DestroyDeadline: picked.DestroyDeadline,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return outcomes
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchKey(itemID, normalizedSpec string) string {
|
||||||
|
return itemID + "|" + normalizedSpec
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package returnmatch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func t1(offsetDays int) time.Time {
|
||||||
|
base := time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC)
|
||||||
|
return base.AddDate(0, 0, offsetDays)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectMatches_BasicMatch(t *testing.T) {
|
||||||
|
now := t1(0)
|
||||||
|
products := []SYBCandidate{
|
||||||
|
{SYBProductID: 1, ShopeeItemID: "100", TargetColor: "白色", TargetSize: "2XL", CreatedAt: t1(-1)},
|
||||||
|
}
|
||||||
|
returns := []ReturnCandidate{
|
||||||
|
{ReturnItemID: 900, ItemID: "100", VariationName: "白色,2XL【建議65-75公斤】", DestroyDeadline: t1(10)},
|
||||||
|
}
|
||||||
|
out := SelectMatches(products, returns, now)
|
||||||
|
if len(out) != 1 || !out[0].Matched || out[0].ReturnItemID != 900 {
|
||||||
|
t.Fatalf("unexpected outcome: %+v", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSelectMatches_MultiColourSameOrderCrossPairing reproduces issue #338's
|
||||||
|
// documented real-world case: the same order buys 2+ colour variants of the
|
||||||
|
// same Shopee item, and each SYB row must pair with the return item of its
|
||||||
|
// OWN colour, never grab whichever candidate is available first.
|
||||||
|
func TestSelectMatches_MultiColourSameOrderCrossPairing(t *testing.T) {
|
||||||
|
now := t1(0)
|
||||||
|
products := []SYBCandidate{
|
||||||
|
{SYBProductID: 1, ShopeeItemID: "100", TargetColor: "白色", TargetSize: "2XL", CreatedAt: t1(-1)},
|
||||||
|
{SYBProductID: 2, ShopeeItemID: "100", TargetColor: "紫色", TargetSize: "M", CreatedAt: t1(-2)},
|
||||||
|
}
|
||||||
|
returns := []ReturnCandidate{
|
||||||
|
{ReturnItemID: 901, ItemID: "100", VariationName: "紫色,M【建議43-53公斤】", DestroyDeadline: t1(5)},
|
||||||
|
{ReturnItemID: 900, ItemID: "100", VariationName: "白色,2XL【建議65-75公斤】", DestroyDeadline: t1(10)},
|
||||||
|
}
|
||||||
|
out := SelectMatches(products, returns, now)
|
||||||
|
got := map[uint64]uint64{}
|
||||||
|
for _, o := range out {
|
||||||
|
if !o.Matched {
|
||||||
|
t.Fatalf("expected all matched, got skip: %+v", o)
|
||||||
|
}
|
||||||
|
got[o.SYBProductID] = o.ReturnItemID
|
||||||
|
}
|
||||||
|
if got[1] != 900 {
|
||||||
|
t.Fatalf("white/2XL SYB product should match return 900 (white/2XL), got %d", got[1])
|
||||||
|
}
|
||||||
|
if got[2] != 901 {
|
||||||
|
t.Fatalf("purple/M SYB product should match return 901 (purple/M), got %d", got[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectMatches_ExpiredDeadlineExcluded(t *testing.T) {
|
||||||
|
now := t1(0)
|
||||||
|
products := []SYBCandidate{{SYBProductID: 1, ShopeeItemID: "100", TargetColor: "白色", TargetSize: "L", CreatedAt: t1(-1)}}
|
||||||
|
returns := []ReturnCandidate{
|
||||||
|
{ReturnItemID: 900, ItemID: "100", VariationName: "白色,L", DestroyDeadline: t1(0)}, // not after now -> excluded
|
||||||
|
{ReturnItemID: 901, ItemID: "100", VariationName: "白色,L", DestroyDeadline: t1(-1)}, // already past -> excluded
|
||||||
|
}
|
||||||
|
out := SelectMatches(products, returns, now)
|
||||||
|
if out[0].Matched {
|
||||||
|
t.Fatalf("expected no match for expired-only candidates, got %+v", out[0])
|
||||||
|
}
|
||||||
|
if out[0].SkipReason != SkipReasonNoCandidate {
|
||||||
|
t.Fatalf("unexpected skip reason: %+v", out[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectMatches_EarliestDeadlineFirst(t *testing.T) {
|
||||||
|
now := t1(0)
|
||||||
|
products := []SYBCandidate{{SYBProductID: 1, ShopeeItemID: "100", TargetColor: "白色", TargetSize: "L", CreatedAt: t1(-1)}}
|
||||||
|
returns := []ReturnCandidate{
|
||||||
|
{ReturnItemID: 900, ItemID: "100", VariationName: "白色,L", DestroyDeadline: t1(30)},
|
||||||
|
{ReturnItemID: 901, ItemID: "100", VariationName: "白色,L", DestroyDeadline: t1(3)}, // soonest to expire, must win
|
||||||
|
{ReturnItemID: 902, ItemID: "100", VariationName: "白色,L", DestroyDeadline: t1(10)},
|
||||||
|
}
|
||||||
|
out := SelectMatches(products, returns, now)
|
||||||
|
if !out[0].Matched || out[0].ReturnItemID != 901 {
|
||||||
|
t.Fatalf("expected earliest-deadline candidate 901, got %+v", out[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectMatches_OccupiedWithinRunNotReused(t *testing.T) {
|
||||||
|
now := t1(0)
|
||||||
|
// Two products with the identical spec, only one return item available:
|
||||||
|
// processed in the given (already created_at DESC) order, the second
|
||||||
|
// product must NOT be able to reuse the same return item.
|
||||||
|
products := []SYBCandidate{
|
||||||
|
{SYBProductID: 1, ShopeeItemID: "100", TargetColor: "白色", TargetSize: "L", CreatedAt: t1(-1)}, // newer, processed first (DESC)
|
||||||
|
{SYBProductID: 2, ShopeeItemID: "100", TargetColor: "白色", TargetSize: "L", CreatedAt: t1(-5)},
|
||||||
|
}
|
||||||
|
returns := []ReturnCandidate{
|
||||||
|
{ReturnItemID: 900, ItemID: "100", VariationName: "白色,L", DestroyDeadline: t1(10)},
|
||||||
|
}
|
||||||
|
out := SelectMatches(products, returns, now)
|
||||||
|
if !out[0].Matched || out[0].ReturnItemID != 900 {
|
||||||
|
t.Fatalf("first (newer) product should win the only candidate: %+v", out[0])
|
||||||
|
}
|
||||||
|
if out[1].Matched {
|
||||||
|
t.Fatalf("second product must not reuse the already-occupied return: %+v", out[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectMatches_QuantityIgnored(t *testing.T) {
|
||||||
|
// SYBCandidate/ReturnCandidate deliberately carry no quantity field at
|
||||||
|
// all: this test documents that omission is intentional (issue #338
|
||||||
|
// rule 6, quantity never participates in matching).
|
||||||
|
now := t1(0)
|
||||||
|
products := []SYBCandidate{{SYBProductID: 1, ShopeeItemID: "100", TargetColor: "白色", TargetSize: "L", CreatedAt: t1(-1)}}
|
||||||
|
returns := []ReturnCandidate{{ReturnItemID: 900, ItemID: "100", VariationName: "白色,L", DestroyDeadline: t1(10)}}
|
||||||
|
out := SelectMatches(products, returns, now)
|
||||||
|
if !out[0].Matched {
|
||||||
|
t.Fatalf("expected match regardless of any quantity mismatch: %+v", out[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectMatches_ColorOnlySYBSpecMatchesPlainYeekeText(t *testing.T) {
|
||||||
|
now := t1(0)
|
||||||
|
products := []SYBCandidate{{SYBProductID: 1, ShopeeItemID: "100", TargetColor: "黑色", TargetSize: "", CreatedAt: t1(-1)}}
|
||||||
|
returns := []ReturnCandidate{{ReturnItemID: 900, ItemID: "100", VariationName: "黑色", DestroyDeadline: t1(10)}}
|
||||||
|
out := SelectMatches(products, returns, now)
|
||||||
|
if !out[0].Matched || out[0].ReturnItemID != 900 {
|
||||||
|
t.Fatalf("color-only spec should still match: %+v", out[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectMatches_SizeOnlySYBSpecMatchesPlainYeekeText(t *testing.T) {
|
||||||
|
now := t1(0)
|
||||||
|
products := []SYBCandidate{{SYBProductID: 1, ShopeeItemID: "100", TargetColor: "", TargetSize: "L", CreatedAt: t1(-1)}}
|
||||||
|
returns := []ReturnCandidate{{ReturnItemID: 900, ItemID: "100", VariationName: "L", DestroyDeadline: t1(10)}}
|
||||||
|
out := SelectMatches(products, returns, now)
|
||||||
|
if !out[0].Matched || out[0].ReturnItemID != 900 {
|
||||||
|
t.Fatalf("size-only spec should still match: %+v", out[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectMatches_DifferentItemIDNeverMatches(t *testing.T) {
|
||||||
|
now := t1(0)
|
||||||
|
products := []SYBCandidate{{SYBProductID: 1, ShopeeItemID: "100", TargetColor: "白色", TargetSize: "L", CreatedAt: t1(-1)}}
|
||||||
|
returns := []ReturnCandidate{{ReturnItemID: 900, ItemID: "200", VariationName: "白色,L", DestroyDeadline: t1(10)}}
|
||||||
|
out := SelectMatches(products, returns, now)
|
||||||
|
if out[0].Matched {
|
||||||
|
t.Fatalf("different shopee item id must never match: %+v", out[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
package returnmatch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"go-admin/app/goauto/models"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/go-admin-team/go-admin-core/sdk/pkg"
|
||||||
|
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handler exposes the #338 return-matching admin surface: manual batch
|
||||||
|
// match, list/filter (shared by the SYB product page and the yeeke returns
|
||||||
|
// page), detail, confirm, cancel and remark. Everything here only reads or
|
||||||
|
// writes GoAuto's own database — no yeeke or SYB write call is made.
|
||||||
|
type Handler struct {
|
||||||
|
DB *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h Handler) db(c *gin.Context) (*gorm.DB, bool) {
|
||||||
|
db := h.DB
|
||||||
|
var err error
|
||||||
|
if db == nil {
|
||||||
|
db, err = pkg.GetOrm(c)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return db, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func operatorFromContext(c *gin.Context) (string, string) {
|
||||||
|
claims := jwt.ExtractClaims(c)
|
||||||
|
role, _ := claims["rolekey"].(string)
|
||||||
|
username, _ := claims["username"].(string)
|
||||||
|
if username == "" {
|
||||||
|
username, _ = claims["userName"].(string)
|
||||||
|
}
|
||||||
|
return role, username
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireCanPurchase mirrors the admin/purchaser/after-sales write gate this codebase
|
||||||
|
// already uses for other manual-trigger actions (yeeke.Handler.TriggerSync,
|
||||||
|
// sybimport.Handler.Import): trigger match, confirm, cancel and remark are
|
||||||
|
// writes and require it; the two list/detail read endpoints do not.
|
||||||
|
func requireCanPurchase(c *gin.Context) bool {
|
||||||
|
role, _ := operatorFromContext(c)
|
||||||
|
if role != "admin" && role != "purchaser" && role != "after_sales" {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"code": "FORBIDDEN", "message": "只有管理员、采购员或售后可以操作退货匹配"})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
type batchMatchBody struct {
|
||||||
|
SYBProductIDs []uint64 `json:"sybProductIds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BatchMatch is the「匹配退货」button: manual-only trigger for ticked rows.
|
||||||
|
func (h Handler) BatchMatch(c *gin.Context) {
|
||||||
|
if !requireCanPurchase(c) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db, ok := h.db(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body batchMatchBody
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil || len(body.SYBProductIDs) == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "sybProductIds 不能为空"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, operator := operatorFromContext(c)
|
||||||
|
resp, err := NewService(db).BatchMatch(c.Request.Context(), BatchMatchRequest{SYBProductIDs: body.SYBProductIDs, Operator: operator})
|
||||||
|
if errors.Is(err, ErrTooManyItems) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": ErrTooManyItems.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 200, "data": resp})
|
||||||
|
}
|
||||||
|
|
||||||
|
// List backs both the SYB product page's匹配列/筛选 and the yeeke returns
|
||||||
|
// page's占用状态筛选/列 (issue #338 pages section).
|
||||||
|
func (h Handler) List(c *gin.Context) {
|
||||||
|
db, ok := h.db(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
filter := ListFilter{Status: c.Query("status")}
|
||||||
|
if raw := c.QueryArray("sybProductId"); len(raw) > 0 {
|
||||||
|
filter.SYBProductIDs = parseUint64List(raw)
|
||||||
|
}
|
||||||
|
if raw := c.QueryArray("yeekeReturnItemId"); len(raw) > 0 {
|
||||||
|
filter.YeekeReturnItemIDs = parseUint64List(raw)
|
||||||
|
}
|
||||||
|
rows, err := NewService(db).List(c.Request.Context(), filter)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{"items": rows}})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h Handler) Detail(c *gin.Context) {
|
||||||
|
db, ok := h.db(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "id 无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
detail, err := NewService(db).Detail(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"code": "NOT_FOUND", "message": "匹配记录不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 200, "data": detail})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h Handler) Confirm(c *gin.Context) {
|
||||||
|
h.transition(c, func(s *Service, ctx *gin.Context, id uint64, operator string) (models.ReturnMatch, error) {
|
||||||
|
return s.Confirm(ctx.Request.Context(), id, operator)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h Handler) Cancel(c *gin.Context) {
|
||||||
|
h.transition(c, func(s *Service, ctx *gin.Context, id uint64, operator string) (models.ReturnMatch, error) {
|
||||||
|
return s.Cancel(ctx.Request.Context(), id, operator)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h Handler) transition(c *gin.Context, fn func(*Service, *gin.Context, uint64, string) (models.ReturnMatch, error)) {
|
||||||
|
if !requireCanPurchase(c) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db, ok := h.db(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "id 无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, operator := operatorFromContext(c)
|
||||||
|
match, err := fn(NewService(db), c, id, operator)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"code": "NOT_FOUND", "message": "匹配记录不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, errStateConflict) {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"code": "STATE_CONFLICT", "message": "匹配状态已变化,请刷新后重试"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{"item": match}})
|
||||||
|
}
|
||||||
|
|
||||||
|
type remarkBody struct {
|
||||||
|
Remark string `json:"remark"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h Handler) Remark(c *gin.Context) {
|
||||||
|
if !requireCanPurchase(c) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db, ok := h.db(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "id 无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body remarkBody
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "请求体无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, operator := operatorFromContext(c)
|
||||||
|
match, err := NewService(db).Remark(c.Request.Context(), id, operator, body.Remark)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"code": "NOT_FOUND", "message": "匹配记录不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, errRemarkTooLong) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": "REMARK_TOO_LONG", "message": "备注不能超过 500 字"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{"item": match}})
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseUint64List(raw []string) []uint64 {
|
||||||
|
out := make([]uint64, 0, len(raw))
|
||||||
|
for _, v := range raw {
|
||||||
|
if id, err := strconv.ParseUint(v, 10, 64); err == nil {
|
||||||
|
out = append(out, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListBatches returns recent 「匹配退货」 batch records (who, when, submitted
|
||||||
|
// and matched counts); pass withItems=1 to include each row's outcome.
|
||||||
|
func (h Handler) ListBatches(c *gin.Context) {
|
||||||
|
db, ok := h.db(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limit, _ := strconv.Atoi(c.Query("limit"))
|
||||||
|
rows, err := NewService(db).ListBatches(c.Request.Context(), limit, c.Query("withItems") == "1")
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{"items": rows}})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBatch returns one batch record with every submitted SYB product's outcome.
|
||||||
|
func (h Handler) GetBatch(c *gin.Context) {
|
||||||
|
db, ok := h.db(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := strconv.ParseUint(c.Param("batchId"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "batchId 无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rec, err := NewService(db).GetBatch(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"code": "NOT_FOUND", "message": "批量匹配记录不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 200, "data": rec})
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package returnmatch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestReturnMatchWriteRoles(t *testing.T) {
|
||||||
|
for _, role := range []string{"admin", "purchaser", "after_sales", "other", ""} {
|
||||||
|
t.Run(role, func(t *testing.T) {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(w)
|
||||||
|
c.Set("JWT_PAYLOAD", jwt.MapClaims{"rolekey": role})
|
||||||
|
want := role == "admin" || role == "purchaser" || role == "after_sales"
|
||||||
|
if got := requireCanPurchase(c); got != want {
|
||||||
|
t.Fatalf("role %q allowed=%v want %v", role, got, want)
|
||||||
|
}
|
||||||
|
if !want && w.Code != 403 {
|
||||||
|
t.Fatalf("denied role status=%d", w.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
package returnmatch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go-admin/app/goauto/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestBatchMatch_MissingItemNotMatched: a return item whose sync_status is
|
||||||
|
// "missing" (#338: dropped from a COMPLETE yeeke sync) must never be offered
|
||||||
|
// to matching, even though it otherwise satisfies every other rule (an
|
||||||
|
// eligible SYB candidate, a future destroy deadline, no active match).
|
||||||
|
func TestBatchMatch_MissingItemNotMatched(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
s := NewService(db)
|
||||||
|
s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
|
||||||
|
|
||||||
|
deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
syb := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
|
||||||
|
ret := seedReturn(t, db, "白色,L【建議65-75公斤】", &deadline)
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
if err := db.Model(&models.YeekeReturnItem{}).Where("id = ?", ret.ID).
|
||||||
|
Updates(map[string]any{"sync_status": "missing", "missing_since": now}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}, Operator: "tester"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if resp.MatchedCount != 0 || resp.SkippedCount != 1 {
|
||||||
|
t.Fatalf("expected the missing item to be skipped, not matched: %+v", resp)
|
||||||
|
}
|
||||||
|
if resp.Items[0].ReasonCode != ReasonNoCandidate {
|
||||||
|
t.Fatalf("expected no_candidate (the only candidate is missing): %+v", resp.Items[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBatchMatch_MissingPackageNotMatched: same as above but the ITEM itself
|
||||||
|
// is still "ok" while its PACKAGE is "missing" — availableReturnPool must
|
||||||
|
// exclude it too, since #338's rule is "an item, or an item whose package,
|
||||||
|
// is no longer ok".
|
||||||
|
func TestBatchMatch_MissingPackageNotMatched(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
s := NewService(db)
|
||||||
|
s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
|
||||||
|
|
||||||
|
deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
syb := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
|
||||||
|
ret := seedReturn(t, db, "白色,L【建議65-75公斤】", &deadline)
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
if err := db.Model(&models.YeekeReturnPackage{}).Where("id = ?", ret.PackageID).
|
||||||
|
Updates(map[string]any{"sync_status": "missing", "missing_since": now}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}, Operator: "tester"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if resp.MatchedCount != 0 || resp.SkippedCount != 1 {
|
||||||
|
t.Fatalf("expected the item to be skipped because its package is missing: %+v", resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestActiveMatchSurvivesItemGoingMissing: issue #338's rule 6 — an existing
|
||||||
|
// active match is NOT auto-cancelled when its return item later becomes
|
||||||
|
// missing. Detail() must still report it (SYB side unaffected) and surface
|
||||||
|
// the yeeke side's syncStatus="missing" so the UI can show the warning.
|
||||||
|
func TestActiveMatchSurvivesItemGoingMissing(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
s := NewService(db)
|
||||||
|
s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
|
||||||
|
|
||||||
|
deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
syb := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
|
||||||
|
ret := seedReturn(t, db, "白色,L【建議65-75公斤】", &deadline)
|
||||||
|
|
||||||
|
resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}, Operator: "tester"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if resp.MatchedCount != 1 {
|
||||||
|
t.Fatalf("expected a match before the item goes missing: %+v", resp)
|
||||||
|
}
|
||||||
|
matchID := resp.Items[0].MatchID
|
||||||
|
|
||||||
|
// The return item now drops out of a COMPLETE yeeke sync.
|
||||||
|
missingSince := time.Now().UTC()
|
||||||
|
if err := db.Model(&models.YeekeReturnItem{}).Where("id = ?", ret.ID).
|
||||||
|
Updates(map[string]any{"sync_status": "missing", "missing_since": missingSince}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var match models.ReturnMatch
|
||||||
|
if err := db.First(&match, matchID).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if match.Status != models.ReturnMatchStatusMatched || match.ActiveYeekeReturnItemID == nil {
|
||||||
|
t.Fatalf("existing match must stay active when its return item goes missing, got %+v", match)
|
||||||
|
}
|
||||||
|
|
||||||
|
detail, err := s.Detail(context.Background(), matchID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if detail.Yeeke == nil {
|
||||||
|
t.Fatal("Detail must still resolve the yeeke side (the row was never deleted)")
|
||||||
|
}
|
||||||
|
if detail.Yeeke.SyncStatus != "missing" || detail.Yeeke.MissingSince == nil {
|
||||||
|
t.Fatalf("Detail must report the return item's syncStatus=missing, got %+v", detail.Yeeke)
|
||||||
|
}
|
||||||
|
if detail.SYB == nil || detail.SYB.SYBProductID != syb.ID {
|
||||||
|
t.Fatalf("SYB side must be unaffected by the return item going missing, got %+v", detail.SYB)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestList_ReportsMissingReturnSyncStatus: the SYB products match column and
|
||||||
|
// the compare dialog read List()'s syncStatus/missingSince fields (#338); a
|
||||||
|
// match whose return item is missing must carry them through.
|
||||||
|
func TestList_ReportsMissingReturnSyncStatus(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
s := NewService(db)
|
||||||
|
s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
|
||||||
|
|
||||||
|
deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
syb := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
|
||||||
|
ret := seedReturn(t, db, "白色,L【建議65-75公斤】", &deadline)
|
||||||
|
|
||||||
|
resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}, Operator: "tester"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if resp.MatchedCount != 1 {
|
||||||
|
t.Fatalf("expected a match: %+v", resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
missingSince := time.Now().UTC()
|
||||||
|
if err := db.Model(&models.YeekeReturnItem{}).Where("id = ?", ret.ID).
|
||||||
|
Updates(map[string]any{"sync_status": "missing", "missing_since": missingSince}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
items, err := s.List(context.Background(), ListFilter{SYBProductIDs: []uint64{syb.ID}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(items) != 1 {
|
||||||
|
t.Fatalf("expected one list row, got %d", len(items))
|
||||||
|
}
|
||||||
|
if items[0].SyncStatus != "missing" || items[0].MissingSince == nil {
|
||||||
|
t.Fatalf("List() must surface the missing return item's status, got %+v", items[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
// Package returnmatch implements the #338 return-matching algorithm: pairing
|
||||||
|
// a SYB order product with an available yeeke return item that is the same
|
||||||
|
// Shopee item with the same spec, so the return can be reused instead of
|
||||||
|
// buying the item again. This file holds only pure, DB-free functions so the
|
||||||
|
// matching rules can be unit tested directly against the 840 real pairs used
|
||||||
|
// to design the normalization rule (see issue #338 "当前事实").
|
||||||
|
package returnmatch
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// bracketPairs lists every bracket style issue #338 requires stripped, along
|
||||||
|
// with its contained content: 【】()() and [].
|
||||||
|
var bracketPairs = []struct{ open, close rune }{
|
||||||
|
{'【', '】'},
|
||||||
|
{'(', ')'},
|
||||||
|
{'(', ')'},
|
||||||
|
{'[', ']'},
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripBracketedContent removes every bracket-delimited span (any style in
|
||||||
|
// bracketPairs) and its contents. Unmatched opening brackets discard the
|
||||||
|
// remainder of the string from that point, which is safe here because SYB
|
||||||
|
// and yeeke free-text specs never rely on a trailing unmatched bracket to
|
||||||
|
// carry meaningful spec content.
|
||||||
|
func stripBracketedContent(s string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
depth := 0
|
||||||
|
for _, r := range s {
|
||||||
|
isOpen, isClose := false, false
|
||||||
|
for _, pair := range bracketPairs {
|
||||||
|
if r == pair.open {
|
||||||
|
isOpen = true
|
||||||
|
}
|
||||||
|
if r == pair.close {
|
||||||
|
isClose = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case isOpen:
|
||||||
|
depth++
|
||||||
|
case isClose:
|
||||||
|
if depth > 0 {
|
||||||
|
depth--
|
||||||
|
}
|
||||||
|
case depth == 0:
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// fullwidthToHalfwidth converts fullwidth ASCII forms (U+FF01-U+FF5E) and the
|
||||||
|
// fullwidth space (U+3000) to their halfwidth equivalents, leaving CJK
|
||||||
|
// characters (colors like 紫色) untouched.
|
||||||
|
func fullwidthToHalfwidth(s string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range s {
|
||||||
|
switch {
|
||||||
|
case r == ' ':
|
||||||
|
b.WriteRune(' ')
|
||||||
|
case r >= '!' && r <= '~':
|
||||||
|
b.WriteRune(r - 0xFEE0)
|
||||||
|
default:
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize implements issue #338's spec-text normalization rule: strip
|
||||||
|
// bracketed remarks (【】()()[] and their content), strip whitespace,
|
||||||
|
// convert fullwidth characters to halfwidth, and lowercase. It is used on
|
||||||
|
// both the SYB side (target_color + "," + target_size) and the yeeke side
|
||||||
|
// (variation_name) so the two can be compared for equality.
|
||||||
|
func Normalize(raw string) string {
|
||||||
|
s := stripBracketedContent(raw)
|
||||||
|
s = fullwidthToHalfwidth(s)
|
||||||
|
s = strings.ToLower(s)
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range s {
|
||||||
|
if !isSpace(r) {
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSpace(r rune) bool {
|
||||||
|
switch r {
|
||||||
|
case ' ', '\t', '\n', '\r', '\f', '\v', ' ':
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// SYBSpecText builds the SYB-side comparable spec text from the parser's
|
||||||
|
// target_color/target_size fields, per issue #338's data design. Only
|
||||||
|
// non-empty parts are joined so a single-dimension spec (color-only or
|
||||||
|
// size-only) doesn't pick up a stray leading/trailing comma that would
|
||||||
|
// prevent it from normalizing equal to the yeeke side's single-value
|
||||||
|
// variation_name (e.g. SYBSpecText("黑色", "") must be "黑色", not "黑色,").
|
||||||
|
func SYBSpecText(targetColor, targetSize string) string {
|
||||||
|
parts := make([]string, 0, 2)
|
||||||
|
if targetColor != "" {
|
||||||
|
parts = append(parts, targetColor)
|
||||||
|
}
|
||||||
|
if targetSize != "" {
|
||||||
|
parts = append(parts, targetSize)
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
// trimCommas removes leading/trailing commas left over after normalization
|
||||||
|
// (e.g. a source value that itself started or ended with a comma). Used only
|
||||||
|
// when building the matching key, so exact-text comparisons/tests elsewhere
|
||||||
|
// are unaffected.
|
||||||
|
func trimCommas(s string) string {
|
||||||
|
return strings.Trim(s, ",")
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package returnmatch
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestNormalize(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"square brackets with content", "白色,2XL【建議65-75公斤】", "白色,2xl"},
|
||||||
|
{"chinese parens", "紫色,M(建議43-53公斤)", "紫色,m"},
|
||||||
|
{"ascii parens", "紫色,M (建議43-53公斤)", "紫色,m"},
|
||||||
|
{"latin square brackets", "紫色,M [建議43-53公斤]", "紫色,m"},
|
||||||
|
{"internal whitespace stripped", "紫色, M 【建議43-53公斤】", "紫色,m"},
|
||||||
|
{"fullwidth digits and letters", "白色,2XL", "白色,2xl"},
|
||||||
|
{"no bracket, plain", "黑色,L", "黑色,l"},
|
||||||
|
{"empty", "", ""},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := Normalize(tc.in)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Fatalf("Normalize(%q) = %q, want %q", tc.in, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeMatchesRealPairSample(t *testing.T) {
|
||||||
|
// Representative real pairs from issue #338's local 840-pair backtest
|
||||||
|
// note: SYB target_color+","+target_size vs yeeke variation_name should
|
||||||
|
// normalize equal once bracketed remarks are stripped.
|
||||||
|
pairs := []struct{ syb, yeeke string }{
|
||||||
|
{SYBSpecText("白色", "2XL"), "白色,2XL【建議65-75公斤】"},
|
||||||
|
{SYBSpecText("紫色", "M"), "紫色,M【建議43-53公斤】"},
|
||||||
|
{SYBSpecText("黑色", "L"), "黑色,L"},
|
||||||
|
}
|
||||||
|
for _, p := range pairs {
|
||||||
|
if Normalize(p.syb) != Normalize(p.yeeke) {
|
||||||
|
t.Fatalf("expected normalized equality: syb=%q (%q) yeeke=%q (%q)",
|
||||||
|
p.syb, Normalize(p.syb), p.yeeke, Normalize(p.yeeke))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSYBSpecText(t *testing.T) {
|
||||||
|
if got := SYBSpecText("白色", "2XL"); got != "白色,2XL" {
|
||||||
|
t.Fatalf("got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSYBSpecText_SingleDimension(t *testing.T) {
|
||||||
|
if got := SYBSpecText("黑色", ""); got != "黑色" {
|
||||||
|
t.Fatalf("color-only: got %q, want %q (no trailing comma)", got, "黑色")
|
||||||
|
}
|
||||||
|
if got := SYBSpecText("", "L"); got != "L" {
|
||||||
|
t.Fatalf("size-only: got %q, want %q (no leading comma)", got, "L")
|
||||||
|
}
|
||||||
|
if got := SYBSpecText("", ""); got != "" {
|
||||||
|
t.Fatalf("both empty: got %q, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTrimCommas(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"黑色,": "黑色",
|
||||||
|
",黑色": "黑色",
|
||||||
|
",黑色,": "黑色",
|
||||||
|
"黑色": "黑色",
|
||||||
|
"": "",
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
if got := trimCommas(in); got != want {
|
||||||
|
t.Fatalf("trimCommas(%q) = %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package returnmatch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// InitRouter mounts the #338 return-matching admin surface. List/detail are
|
||||||
|
// readable by any authenticated admin user; the write actions (batch match,
|
||||||
|
// confirm, cancel, remark) additionally require admin/purchaser/after-sales via
|
||||||
|
// requireCanPurchase, same gate as yeeke.Handler.TriggerSync.
|
||||||
|
func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) {
|
||||||
|
handler := Handler{}
|
||||||
|
group := engine.Group("/api/admin/v1/return-matches").Use(auth.MiddlewareFunc())
|
||||||
|
group.GET("", handler.List)
|
||||||
|
// Static batch routes are registered before /:id so they never parse as a match id.
|
||||||
|
group.GET("/batches", handler.ListBatches)
|
||||||
|
group.GET("/batches/:batchId", handler.GetBatch)
|
||||||
|
group.GET("/:id", handler.Detail)
|
||||||
|
group.POST("/batch-match", handler.BatchMatch)
|
||||||
|
group.POST("/:id/confirm", handler.Confirm)
|
||||||
|
group.POST("/:id/cancel", handler.Cancel)
|
||||||
|
group.POST("/:id/remark", handler.Remark)
|
||||||
|
}
|
||||||
@@ -0,0 +1,651 @@
|
|||||||
|
package returnmatch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"go-admin/app/goauto/models"
|
||||||
|
"go-admin/app/goauto/purchase"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
func clauseLockUpdate() clause.Locking { return clause.Locking{Strength: "UPDATE"} }
|
||||||
|
|
||||||
|
// participatingStages is issue #338 rule 1's "待采购范围": 待人工处理 is
|
||||||
|
// explicitly excluded (already matched, per the confirmed rule), as are the
|
||||||
|
// stages that mean a task is already in flight or a match already exists.
|
||||||
|
var participatingStages = map[string]bool{
|
||||||
|
purchase.ProcessStagePDDUnlinked: true,
|
||||||
|
purchase.ProcessStagePDDPending: true,
|
||||||
|
purchase.ProcessStagePDDCollecting: true,
|
||||||
|
purchase.ProcessStagePDDCollectionFail: true,
|
||||||
|
purchase.ProcessStageColorMapping: true,
|
||||||
|
purchase.ProcessStagePurchaseReady: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
DB *gorm.DB
|
||||||
|
Now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(db *gorm.DB) *Service {
|
||||||
|
return &Service{DB: db, Now: time.Now}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BatchMatchRequest/Result mirror the prototype's batch result dialog
|
||||||
|
// (screen 2): per-SYB-product outcome plus a reason bucket count.
|
||||||
|
type BatchMatchRequest struct {
|
||||||
|
SYBProductIDs []uint64
|
||||||
|
Operator string
|
||||||
|
}
|
||||||
|
|
||||||
|
type BatchMatchItem struct {
|
||||||
|
SYBProductID uint64 `json:"sybProductId"`
|
||||||
|
Matched bool `json:"matched"`
|
||||||
|
ReasonCode string `json:"reasonCode,omitempty"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
MatchID uint64 `json:"matchId,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BatchMatchResponse struct {
|
||||||
|
Items []BatchMatchItem `json:"items"`
|
||||||
|
ProcessedCount int `json:"processedCount"`
|
||||||
|
MatchedCount int `json:"matchedCount"`
|
||||||
|
SkippedCount int `json:"skippedCount"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
ReasonStageIneligible = "stage_ineligible"
|
||||||
|
ReasonNoCandidate = "no_candidate"
|
||||||
|
ReasonConflict = "conflict"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxBatchMatchItems caps a single「匹配退货」submission (#339: SYB list page
|
||||||
|
// size can now go up to 500/page, so the button's selection can exceed the
|
||||||
|
// previously-unbounded batch-match request size).
|
||||||
|
const maxBatchMatchItems = 500
|
||||||
|
|
||||||
|
// ErrTooManyItems is returned by BatchMatch when the caller submits more than
|
||||||
|
// maxBatchMatchItems SYB product ids; the handler turns this into an
|
||||||
|
// INVALID_REQUEST response instead of a 500.
|
||||||
|
var ErrTooManyItems = errors.New("sybProductIds 一次最多 500 条")
|
||||||
|
|
||||||
|
// BatchMatch implements issue #338's manual "匹配退货" trigger. It is only
|
||||||
|
// ever called from the batch-match button (ticked rows) — no scheduler, no
|
||||||
|
// yeeke-sync/SYB-import hook calls this (rule: 手动触发, 无定时任务).
|
||||||
|
func (s *Service) BatchMatch(ctx context.Context, req BatchMatchRequest) (BatchMatchResponse, error) {
|
||||||
|
if len(req.SYBProductIDs) > maxBatchMatchItems {
|
||||||
|
return BatchMatchResponse{}, ErrTooManyItems
|
||||||
|
}
|
||||||
|
resp, err := s.batchMatch(ctx, req)
|
||||||
|
if len(req.SYBProductIDs) > 0 {
|
||||||
|
// The batch record is written after the per-row transactions have
|
||||||
|
// committed, so a failure here must not turn an already-applied
|
||||||
|
// match into an error response; it is logged instead.
|
||||||
|
if logErr := s.recordBatch(ctx, req, resp, err); logErr != nil {
|
||||||
|
log.Printf("return match batch record failed: operator=%s submitted=%d: %v", req.Operator, len(req.SYBProductIDs), logErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordBatch stores one ReturnMatchBatch row describing this click of
|
||||||
|
// 「匹配退货」: submitted SYB products in order and each one's outcome.
|
||||||
|
func (s *Service) recordBatch(ctx context.Context, req BatchMatchRequest, resp BatchMatchResponse, batchErr error) error {
|
||||||
|
items := resp.Items
|
||||||
|
if items == nil {
|
||||||
|
items = []BatchMatchItem{}
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(items)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
row := models.ReturnMatchBatch{
|
||||||
|
Operator: req.Operator,
|
||||||
|
SubmittedCount: len(req.SYBProductIDs),
|
||||||
|
MatchedCount: resp.MatchedCount,
|
||||||
|
SkippedCount: resp.SkippedCount,
|
||||||
|
ItemsJSON: string(raw),
|
||||||
|
}
|
||||||
|
if batchErr != nil {
|
||||||
|
row.Error = truncateRunes(batchErr.Error(), 500)
|
||||||
|
}
|
||||||
|
return s.DB.WithContext(ctx).Create(&row).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncateRunes(v string, max int) string {
|
||||||
|
r := []rune(v)
|
||||||
|
if len(r) <= max {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return string(r[:max])
|
||||||
|
}
|
||||||
|
|
||||||
|
// BatchRecord is the API view of one ReturnMatchBatch row.
|
||||||
|
type BatchRecord struct {
|
||||||
|
models.ReturnMatchBatch
|
||||||
|
Items []BatchMatchItem `json:"items,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListBatches returns the most recent batch records, newest first; items are
|
||||||
|
// only expanded when withItems is true to keep the list response small.
|
||||||
|
func (s *Service) ListBatches(ctx context.Context, limit int, withItems bool) ([]BatchRecord, error) {
|
||||||
|
if limit <= 0 || limit > 100 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
var rows []models.ReturnMatchBatch
|
||||||
|
if err := s.DB.WithContext(ctx).Order("id DESC").Limit(limit).Find(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]BatchRecord, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
rec := BatchRecord{ReturnMatchBatch: row}
|
||||||
|
if withItems {
|
||||||
|
if err := json.Unmarshal([]byte(row.ItemsJSON), &rec.Items); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, rec)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBatch returns one batch record with its items expanded.
|
||||||
|
func (s *Service) GetBatch(ctx context.Context, id uint64) (BatchRecord, error) {
|
||||||
|
var row models.ReturnMatchBatch
|
||||||
|
if err := s.DB.WithContext(ctx).First(&row, id).Error; err != nil {
|
||||||
|
return BatchRecord{}, err
|
||||||
|
}
|
||||||
|
rec := BatchRecord{ReturnMatchBatch: row}
|
||||||
|
if err := json.Unmarshal([]byte(row.ItemsJSON), &rec.Items); err != nil {
|
||||||
|
return BatchRecord{}, err
|
||||||
|
}
|
||||||
|
return rec, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) batchMatch(ctx context.Context, req BatchMatchRequest) (BatchMatchResponse, error) {
|
||||||
|
resp := BatchMatchResponse{Items: make([]BatchMatchItem, 0, len(req.SYBProductIDs))}
|
||||||
|
if len(req.SYBProductIDs) == 0 {
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var sybProducts []models.SYBProduct
|
||||||
|
if err := s.DB.WithContext(ctx).Where("id IN ?", req.SYBProductIDs).Find(&sybProducts).Error; err != nil {
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
sybByID := make(map[uint64]models.SYBProduct, len(sybProducts))
|
||||||
|
for _, p := range sybProducts {
|
||||||
|
sybByID[p.ID] = p
|
||||||
|
}
|
||||||
|
|
||||||
|
stages, err := purchase.NewService(s.DB).ProcessStages(ctx, req.SYBProductIDs)
|
||||||
|
if err != nil {
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
now := s.Now()
|
||||||
|
eligible := make([]SYBCandidate, 0, len(req.SYBProductIDs))
|
||||||
|
skipped := make(map[uint64]string)
|
||||||
|
for _, id := range req.SYBProductIDs {
|
||||||
|
syb, ok := sybByID[id]
|
||||||
|
if !ok {
|
||||||
|
skipped[id] = "SYB 商品不存在或已删除"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stage := stages[id]
|
||||||
|
if !participatingStages[stage.Stage] {
|
||||||
|
skipped[id] = "该处理阶段不参与匹配:" + stage.Label
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
eligible = append(eligible, SYBCandidate{
|
||||||
|
SYBProductID: syb.ID, ShopeeItemID: syb.ShopeeItemID,
|
||||||
|
TargetColor: syb.TargetColor, TargetSize: syb.TargetSize, CreatedAt: syb.CreatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// Rule 4: process in SYB created_at DESC order.
|
||||||
|
sortSYBCandidatesDesc(eligible)
|
||||||
|
|
||||||
|
returns, err := s.availableReturnPool(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
outcomes := SelectMatches(eligible, returns, now)
|
||||||
|
outcomeBySYB := make(map[uint64]MatchOutcome, len(outcomes))
|
||||||
|
for _, o := range outcomes {
|
||||||
|
outcomeBySYB[o.SYBProductID] = o
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, id := range req.SYBProductIDs {
|
||||||
|
if reason, isSkip := skipped[id]; isSkip {
|
||||||
|
resp.Items = append(resp.Items, BatchMatchItem{SYBProductID: id, Matched: false, ReasonCode: ReasonStageIneligible, Reason: reason})
|
||||||
|
resp.SkippedCount++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
outcome, ok := outcomeBySYB[id]
|
||||||
|
if !ok || !outcome.Matched {
|
||||||
|
resp.Items = append(resp.Items, BatchMatchItem{SYBProductID: id, Matched: false, ReasonCode: ReasonNoCandidate, Reason: "没有满足条件的退货商品"})
|
||||||
|
resp.SkippedCount++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
match, insertErr := s.matchOneWithLock(ctx, id, outcome, req.Operator)
|
||||||
|
if insertErr != nil {
|
||||||
|
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.
|
||||||
|
// A purchase task could have been created for this row
|
||||||
|
// between the outer screening pass above and this point;
|
||||||
|
// when that happens the product is no longer in a
|
||||||
|
// participating stage and this row is skipped, not matched.
|
||||||
|
resp.Items = append(resp.Items, BatchMatchItem{SYBProductID: id, Matched: false, ReasonCode: ReasonStageIneligible, Reason: "处理阶段已变化,不再参与匹配"})
|
||||||
|
resp.SkippedCount++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if isUniqueConstraintErr(insertErr) {
|
||||||
|
// Rule 7 / acceptance item 9: a concurrent insert lost the
|
||||||
|
// race for either side — skip this row and report it, never
|
||||||
|
// fail the whole batch.
|
||||||
|
resp.Items = append(resp.Items, BatchMatchItem{SYBProductID: id, Matched: false, ReasonCode: ReasonConflict, Reason: "并发匹配冲突,该商品或退货商品已被占用"})
|
||||||
|
resp.SkippedCount++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return resp, insertErr
|
||||||
|
}
|
||||||
|
resp.Items = append(resp.Items, BatchMatchItem{SYBProductID: id, Matched: true, MatchID: match.ID})
|
||||||
|
resp.MatchedCount++
|
||||||
|
}
|
||||||
|
resp.ProcessedCount = len(resp.Items)
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortSYBCandidatesDesc(items []SYBCandidate) {
|
||||||
|
for i := 1; i < len(items); i++ {
|
||||||
|
for j := i; j > 0 && items[j].CreatedAt.After(items[j-1].CreatedAt); j-- {
|
||||||
|
items[j], items[j-1] = items[j-1], items[j]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// availableReturnPool loads every yeeke return item with no active match and
|
||||||
|
// a destroy deadline (rule 2: empty deadline is unavailable, handled by the
|
||||||
|
// NULL exclusion below; "later than now" is enforced by SelectMatches).
|
||||||
|
// #338 scope addition: an item, or an item whose package, is no longer
|
||||||
|
// sync_status="ok" (i.e. a COMPLETE yeeke sync stopped seeing it) is
|
||||||
|
// excluded here so matching never offers a return yeeke has dropped.
|
||||||
|
func (s *Service) availableReturnPool(ctx context.Context) ([]ReturnCandidate, error) {
|
||||||
|
var rows []struct {
|
||||||
|
ID uint64
|
||||||
|
ItemID string
|
||||||
|
VariationName string
|
||||||
|
DestroyDeadLine *time.Time
|
||||||
|
}
|
||||||
|
err := s.DB.WithContext(ctx).Table("yeeke_return_item AS i").
|
||||||
|
Select("i.id AS id, i.item_id AS item_id, i.variation_name AS variation_name, p.destroy_dead_line AS destroy_dead_line").
|
||||||
|
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").
|
||||||
|
Find(&rows).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]ReturnCandidate, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
if r.DestroyDeadLine == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, ReturnCandidate{ReturnItemID: r.ID, ItemID: r.ItemID, VariationName: r.VariationName, DestroyDeadline: *r.DestroyDeadLine})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// errStageNoLongerEligible is returned by matchOneWithLock when the SYB
|
||||||
|
// product's process stage, re-checked under lock immediately before insert,
|
||||||
|
// is no longer in participatingStages — e.g. a purchase task was created for
|
||||||
|
// 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")
|
||||||
|
|
||||||
|
// matchOneWithLock takes the SAME row lock purchase.Service.create takes on
|
||||||
|
// syb_product (clause.Locking{Strength: "UPDATE"}) and re-computes the
|
||||||
|
// process stage inside that transaction via purchase.NewService(tx) before
|
||||||
|
// inserting the match, so a purchase task creation racing with this batch
|
||||||
|
// match can never both succeed: whichever gets the row lock first commits,
|
||||||
|
// and the other sees the now-current state (an active return_match row, or
|
||||||
|
// a task_created/order_review/purchase_succeeded stage) and is rejected/
|
||||||
|
// skipped instead of double-committing an inconsistent state.
|
||||||
|
func (s *Service) matchOneWithLock(ctx context.Context, sybID uint64, outcome MatchOutcome, operator string) (models.ReturnMatch, error) {
|
||||||
|
var match models.ReturnMatch
|
||||||
|
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
var syb models.SYBProduct
|
||||||
|
if err := tx.Clauses(clauseLockUpdate()).First(&syb, sybID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
stages, err := purchase.NewService(tx).ProcessStages(ctx, []uint64{sybID})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
stage := stages[sybID]
|
||||||
|
if !participatingStages[stage.Stage] {
|
||||||
|
return errStageNoLongerEligible
|
||||||
|
}
|
||||||
|
var returnItem models.YeekeReturnItem
|
||||||
|
if err := tx.First(&returnItem, outcome.ReturnItemID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sybIDCopy := syb.ID
|
||||||
|
returnIDCopy := outcome.ReturnItemID
|
||||||
|
deadline := outcome.DestroyDeadline
|
||||||
|
now := s.Now()
|
||||||
|
match = models.ReturnMatch{
|
||||||
|
SYBProductID: sybIDCopy, YeekeReturnItemID: returnIDCopy,
|
||||||
|
ActiveSYBProductID: &sybIDCopy, ActiveYeekeReturnItemID: &returnIDCopy,
|
||||||
|
Status: models.ReturnMatchStatusMatched,
|
||||||
|
SYBSpecText: SYBSpecText(syb.TargetColor, syb.TargetSize),
|
||||||
|
YeekeSpecText: returnItem.VariationName,
|
||||||
|
NormalizedKey: outcome.NormalizedKey,
|
||||||
|
PreviousProcessStage: stage.Stage,
|
||||||
|
MatchedBy: operator,
|
||||||
|
MatchedAt: now,
|
||||||
|
DestroyDeadlineSnapshot: &deadline,
|
||||||
|
}
|
||||||
|
if err := tx.Create(&match).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
logRow := models.ReturnMatchLog{
|
||||||
|
MatchID: match.ID, Action: models.ReturnMatchLogActionMatched, Operator: operator,
|
||||||
|
Detail: fmt.Sprintf("匹配退货商品 #%d(%s),此前处理阶段:%s", returnIDCopy, returnItem.VariationName, stage.Label),
|
||||||
|
}
|
||||||
|
return tx.Create(&logRow).Error
|
||||||
|
})
|
||||||
|
return match, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func isUniqueConstraintErr(err error) bool {
|
||||||
|
msg := strings.ToLower(err.Error())
|
||||||
|
return strings.Contains(msg, "unique") || strings.Contains(msg, "duplicate")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm/Cancel/Remark implement the human-review half of issue #338.
|
||||||
|
|
||||||
|
func (s *Service) Confirm(ctx context.Context, matchID uint64, operator string) (models.ReturnMatch, error) {
|
||||||
|
var match models.ReturnMatch
|
||||||
|
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Clauses(clauseLockUpdate()).First(&match, matchID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if match.Status != models.ReturnMatchStatusMatched {
|
||||||
|
return errStateConflict
|
||||||
|
}
|
||||||
|
now := s.Now()
|
||||||
|
match.Status = models.ReturnMatchStatusConfirmed
|
||||||
|
match.ConfirmedBy = operator
|
||||||
|
match.ConfirmedAt = &now
|
||||||
|
if err := tx.Save(&match).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Create(&models.ReturnMatchLog{MatchID: match.ID, Action: models.ReturnMatchLogActionConfirmed, Operator: operator, Detail: "确认匹配,商品状态变为已用退货"}).Error
|
||||||
|
})
|
||||||
|
return match, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel restores the SYB product to purchasable (by clearing
|
||||||
|
// ActiveSYBProductID) and returns the return item to the eligible pool (by
|
||||||
|
// clearing ActiveYeekeReturnItemID), from either matched or confirmed state,
|
||||||
|
// per issue #338 rule: 取消匹配后再次点击「匹配退货」若配回同一对,允许.
|
||||||
|
func (s *Service) Cancel(ctx context.Context, matchID uint64, operator string) (models.ReturnMatch, error) {
|
||||||
|
var match models.ReturnMatch
|
||||||
|
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Clauses(clauseLockUpdate()).First(&match, matchID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if match.Status == models.ReturnMatchStatusCancelled {
|
||||||
|
return errStateConflict
|
||||||
|
}
|
||||||
|
now := s.Now()
|
||||||
|
match.Status = models.ReturnMatchStatusCancelled
|
||||||
|
match.ActiveSYBProductID = nil
|
||||||
|
match.ActiveYeekeReturnItemID = nil
|
||||||
|
match.CancelledBy = operator
|
||||||
|
match.CancelledAt = &now
|
||||||
|
if err := tx.Save(&match).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Create(&models.ReturnMatchLog{MatchID: match.ID, Action: models.ReturnMatchLogActionCancelled, Operator: operator, Detail: "取消匹配,SYB 商品恢复可采购,退货商品回到可用池"}).Error
|
||||||
|
})
|
||||||
|
return match, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// errRemarkTooLong is returned instead of silently truncating; remark is a
|
||||||
|
// varchar(500) column and MySQL VARCHAR length is a character count, so this
|
||||||
|
// checks runes, not bytes.
|
||||||
|
var errRemarkTooLong = errors.New("remark exceeds 500 characters")
|
||||||
|
|
||||||
|
const maxRemarkLength = 500
|
||||||
|
|
||||||
|
func (s *Service) Remark(ctx context.Context, matchID uint64, operator, remark string) (models.ReturnMatch, error) {
|
||||||
|
if utf8.RuneCountInString(remark) > maxRemarkLength {
|
||||||
|
return models.ReturnMatch{}, errRemarkTooLong
|
||||||
|
}
|
||||||
|
var match models.ReturnMatch
|
||||||
|
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.First(&match, matchID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
match.Remark = remark
|
||||||
|
if err := tx.Save(&match).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Create(&models.ReturnMatchLog{MatchID: match.ID, Action: models.ReturnMatchLogActionRemark, Operator: operator, Detail: remark}).Error
|
||||||
|
})
|
||||||
|
return match, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// List supports both admin pages' filter/column needs: by SYB product ids,
|
||||||
|
// by yeeke return item ids, or by status.
|
||||||
|
type ListFilter struct {
|
||||||
|
SYBProductIDs []uint64
|
||||||
|
YeekeReturnItemIDs []uint64
|
||||||
|
Status string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListItem is one row of List's result: the match itself plus a few display
|
||||||
|
// fields the SYB product page's match column needs (thumbnail, return order
|
||||||
|
// sn, spec) so it never has to issue a second request per row.
|
||||||
|
type ListItem struct {
|
||||||
|
models.ReturnMatch
|
||||||
|
OrderSN string `json:"orderSn,omitempty"`
|
||||||
|
Image string `json:"image,omitempty"`
|
||||||
|
VariationName string `json:"variationName,omitempty"`
|
||||||
|
DestroyDeadline *time.Time `json:"destroyDeadline,omitempty"`
|
||||||
|
// SyncStatus/MissingSince (#338) surface the yeeke return item's own
|
||||||
|
// current availability ("ok"/"missing") so the SYB products match column
|
||||||
|
// and the compare dialog can warn even on an existing, already-matched
|
||||||
|
// pair whose return later dropped out of a COMPLETE yeeke sync.
|
||||||
|
SyncStatus string `json:"syncStatus,omitempty"`
|
||||||
|
MissingSince *time.Time `json:"missingSince,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) List(ctx context.Context, filter ListFilter) ([]ListItem, error) {
|
||||||
|
q := s.DB.WithContext(ctx).Model(&models.ReturnMatch{})
|
||||||
|
if len(filter.SYBProductIDs) > 0 {
|
||||||
|
q = q.Where("syb_product_id IN ?", filter.SYBProductIDs)
|
||||||
|
}
|
||||||
|
if len(filter.YeekeReturnItemIDs) > 0 {
|
||||||
|
q = q.Where("yeeke_return_item_id IN ?", filter.YeekeReturnItemIDs)
|
||||||
|
}
|
||||||
|
if filter.Status != "" {
|
||||||
|
q = q.Where("status = ?", filter.Status)
|
||||||
|
}
|
||||||
|
var rows []models.ReturnMatch
|
||||||
|
if err := q.Order("id DESC").Find(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items := make([]ListItem, len(rows))
|
||||||
|
returnIDs := make([]uint64, 0, len(rows))
|
||||||
|
for i, r := range rows {
|
||||||
|
items[i] = ListItem{ReturnMatch: r}
|
||||||
|
returnIDs = append(returnIDs, r.YeekeReturnItemID)
|
||||||
|
}
|
||||||
|
if len(returnIDs) == 0 {
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
// One bounded join query for every return item referenced on this page —
|
||||||
|
// never a per-row lookup.
|
||||||
|
var joined []struct {
|
||||||
|
ID uint64
|
||||||
|
OrderSN string
|
||||||
|
Image string
|
||||||
|
VariationName string
|
||||||
|
DestroyDeadLine *time.Time
|
||||||
|
SyncStatus string
|
||||||
|
MissingSince *time.Time
|
||||||
|
PackageSyncStat string `gorm:"column:package_sync_status"`
|
||||||
|
PackageMissingAt *time.Time `gorm:"column:package_missing_since"`
|
||||||
|
}
|
||||||
|
if err := s.DB.WithContext(ctx).Table("yeeke_return_item AS i").
|
||||||
|
Select("i.id AS id, p.order_sn AS order_sn, i.image AS image, i.variation_name AS variation_name, p.destroy_dead_line AS destroy_dead_line, "+
|
||||||
|
"i.sync_status AS sync_status, i.missing_since AS missing_since, p.sync_status AS package_sync_status, p.missing_since AS package_missing_since").
|
||||||
|
Joins("JOIN yeeke_return_package AS p ON p.id = i.package_id").
|
||||||
|
Where("i.id IN ?", returnIDs).
|
||||||
|
Find(&joined).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
type extraFields struct {
|
||||||
|
OrderSN string
|
||||||
|
Image string
|
||||||
|
VariationName string
|
||||||
|
DestroyDeadLine *time.Time
|
||||||
|
SyncStatus string
|
||||||
|
MissingSince *time.Time
|
||||||
|
}
|
||||||
|
byID := make(map[uint64]extraFields, len(joined))
|
||||||
|
for _, j := range joined {
|
||||||
|
// A missing package makes its items unavailable too (#338), even
|
||||||
|
// if the item row itself is still "ok" — surface the package's
|
||||||
|
// missing_since in that case since it is the more accurate reason.
|
||||||
|
syncStatus, missingSince := j.SyncStatus, j.MissingSince
|
||||||
|
if j.PackageSyncStat != "ok" {
|
||||||
|
syncStatus, missingSince = j.PackageSyncStat, j.PackageMissingAt
|
||||||
|
}
|
||||||
|
byID[j.ID] = extraFields{j.OrderSN, j.Image, j.VariationName, j.DestroyDeadLine, syncStatus, missingSince}
|
||||||
|
}
|
||||||
|
for i := range items {
|
||||||
|
if extra, ok := byID[items[i].YeekeReturnItemID]; ok {
|
||||||
|
items[i].OrderSN = extra.OrderSN
|
||||||
|
items[i].Image = extra.Image
|
||||||
|
items[i].VariationName = extra.VariationName
|
||||||
|
items[i].DestroyDeadline = extra.DestroyDeadLine
|
||||||
|
items[i].SyncStatus = extra.SyncStatus
|
||||||
|
items[i].MissingSince = extra.MissingSince
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SYBDetailView/YeekeDetailView/MatchDetail back the prototype's compare
|
||||||
|
// screen (screen 3): both sides' fields and images side by side, normalized
|
||||||
|
// spec text for both, and the operation log.
|
||||||
|
type SYBDetailView struct {
|
||||||
|
SYBProductID uint64 `json:"sybProductId"`
|
||||||
|
OrderCode string `json:"orderCode"`
|
||||||
|
ShopeeItemID string `json:"shopeeItemId"`
|
||||||
|
ShopName string `json:"shopName"`
|
||||||
|
ProductTitle string `json:"productTitle"`
|
||||||
|
TargetColor string `json:"targetColor"`
|
||||||
|
TargetSize string `json:"targetSize"`
|
||||||
|
Quantity int64 `json:"quantity"`
|
||||||
|
ImageURL string `json:"imageUrl,omitempty"`
|
||||||
|
CurrentStage string `json:"currentStage"`
|
||||||
|
CurrentStageLabel string `json:"currentStageLabel"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type YeekeDetailView struct {
|
||||||
|
YeekeReturnItemID uint64 `json:"yeekeReturnItemId"`
|
||||||
|
OrderSN string `json:"orderSn"`
|
||||||
|
ItemID string `json:"itemId"`
|
||||||
|
VariationID string `json:"variationId"`
|
||||||
|
ShopName string `json:"shopName"`
|
||||||
|
ItemName string `json:"itemName"`
|
||||||
|
VariationName string `json:"variationName"`
|
||||||
|
Quantity int64 `json:"quantity"`
|
||||||
|
Image string `json:"image,omitempty"`
|
||||||
|
DestroyDeadline *time.Time `json:"destroyDeadline,omitempty"`
|
||||||
|
// SyncStatus/MissingSince (#338): "missing" when either the item or its
|
||||||
|
// package fell out of a COMPLETE yeeke sync, so the compare dialog can
|
||||||
|
// show the warning even on a match made before the item went missing.
|
||||||
|
SyncStatus string `json:"syncStatus"`
|
||||||
|
MissingSince *time.Time `json:"missingSince,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MatchDetail struct {
|
||||||
|
Match models.ReturnMatch `json:"match"`
|
||||||
|
SYB *SYBDetailView `json:"syb,omitempty"`
|
||||||
|
Yeeke *YeekeDetailView `json:"yeeke,omitempty"`
|
||||||
|
NormalizedSYBSpec string `json:"normalizedSybSpec"`
|
||||||
|
NormalizedYeekeSpec string `json:"normalizedYeekeSpec"`
|
||||||
|
Logs []models.ReturnMatchLog `json:"logs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Detail(ctx context.Context, matchID uint64) (MatchDetail, error) {
|
||||||
|
var match models.ReturnMatch
|
||||||
|
if err := s.DB.WithContext(ctx).First(&match, matchID).Error; err != nil {
|
||||||
|
return MatchDetail{}, err
|
||||||
|
}
|
||||||
|
detail := MatchDetail{Match: match}
|
||||||
|
|
||||||
|
var syb models.SYBProduct
|
||||||
|
if err := s.DB.WithContext(ctx).First(&syb, match.SYBProductID).Error; err == nil {
|
||||||
|
stages, stageErr := purchase.NewService(s.DB).ProcessStages(ctx, []uint64{syb.ID})
|
||||||
|
var stage purchase.ProcessStageResult
|
||||||
|
if stageErr == nil {
|
||||||
|
stage = stages[syb.ID]
|
||||||
|
}
|
||||||
|
detail.SYB = &SYBDetailView{
|
||||||
|
SYBProductID: syb.ID, OrderCode: syb.OrderCode, ShopeeItemID: syb.ShopeeItemID,
|
||||||
|
ShopName: syb.ShopName, ProductTitle: syb.ProductTitle,
|
||||||
|
TargetColor: syb.TargetColor, TargetSize: syb.TargetSize, Quantity: syb.Quantity,
|
||||||
|
ImageURL: syb.ImageURL, CurrentStage: stage.Stage, CurrentStageLabel: stage.Label,
|
||||||
|
}
|
||||||
|
detail.NormalizedSYBSpec = trimCommas(Normalize(SYBSpecText(syb.TargetColor, syb.TargetSize)))
|
||||||
|
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return detail, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var item models.YeekeReturnItem
|
||||||
|
if err := s.DB.WithContext(ctx).First(&item, match.YeekeReturnItemID).Error; err == nil {
|
||||||
|
var pkg models.YeekeReturnPackage
|
||||||
|
if pkgErr := s.DB.WithContext(ctx).First(&pkg, item.PackageID).Error; pkgErr != nil && !errors.Is(pkgErr, gorm.ErrRecordNotFound) {
|
||||||
|
return detail, pkgErr
|
||||||
|
}
|
||||||
|
syncStatus, missingSince := item.SyncStatus, item.MissingSince
|
||||||
|
if pkg.SyncStatus != "" && pkg.SyncStatus != "ok" {
|
||||||
|
syncStatus, missingSince = pkg.SyncStatus, pkg.MissingSince
|
||||||
|
}
|
||||||
|
detail.Yeeke = &YeekeDetailView{
|
||||||
|
YeekeReturnItemID: item.ID, OrderSN: pkg.OrderSN, ItemID: item.ItemID, VariationID: item.VariationID,
|
||||||
|
ShopName: pkg.ShopName, ItemName: item.ItemName, VariationName: item.VariationName,
|
||||||
|
Quantity: item.Quantity, Image: item.Image, DestroyDeadline: pkg.DestroyDeadLine,
|
||||||
|
SyncStatus: syncStatus, MissingSince: missingSince,
|
||||||
|
}
|
||||||
|
detail.NormalizedYeekeSpec = trimCommas(Normalize(item.VariationName))
|
||||||
|
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return detail, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var logs []models.ReturnMatchLog
|
||||||
|
if err := s.DB.WithContext(ctx).Where("match_id = ?", matchID).Order("id DESC").Find(&logs).Error; err != nil {
|
||||||
|
return detail, err
|
||||||
|
}
|
||||||
|
detail.Logs = logs
|
||||||
|
return detail, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var errStateConflict = errors.New("return match state conflict")
|
||||||
@@ -0,0 +1,485 @@
|
|||||||
|
package returnmatch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go-admin/app/goauto/migrations"
|
||||||
|
"go-admin/app/goauto/models"
|
||||||
|
|
||||||
|
"gorm.io/driver/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testDB(t *testing.T) *gorm.DB {
|
||||||
|
t.Helper()
|
||||||
|
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared&_foreign_keys=on", strings.ReplaceAll(t.Name(), "/", "_"))
|
||||||
|
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = migrations.Migrate(db); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
// shopeeLinkForItem memoizes one shopee_product row per (db, item id) test
|
||||||
|
// case so multiple SYB rows for the same order/item share the link, same as
|
||||||
|
// real data, while still landing on the PDD-unlinked stage (which
|
||||||
|
// participates in matching per issue #338 rule 1) without needing the full
|
||||||
|
// PDD/price fixture the purchase package's own tests use.
|
||||||
|
func shopeeLinkForItem(t *testing.T, db *gorm.DB, itemID string) uint64 {
|
||||||
|
t.Helper()
|
||||||
|
var existing models.ShopeeProduct
|
||||||
|
if err := db.Where("shopee_item_id = ?", itemID).First(&existing).Error; err == nil {
|
||||||
|
return existing.ID
|
||||||
|
}
|
||||||
|
sp := models.ShopeeProduct{ShopeeItemID: itemID, Title: "蝦皮商品", ShopName: "测试店", SpecsJSON: `[]`, Currency: "CNY"}
|
||||||
|
if err := db.Create(&sp).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return sp.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedSYB(t *testing.T, db *gorm.DB, orderCode string, detailID uint64, color, size string, createdAt time.Time) models.SYBProduct {
|
||||||
|
t.Helper()
|
||||||
|
shopeeID := shopeeLinkForItem(t, db, "100")
|
||||||
|
syb := models.SYBProduct{
|
||||||
|
OrderCode: orderCode, DetailID: detailID, StockID: detailID, ShopeeItemID: "100", ShopeeProductID: &shopeeID,
|
||||||
|
TargetColor: color, TargetSize: size, Quantity: 1, UnitPriceCent: 1000,
|
||||||
|
ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{}`,
|
||||||
|
}
|
||||||
|
if err := db.Create(&syb).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.Model(&models.SYBProduct{}).Where("id = ?", syb.ID).Update("created_at", createdAt).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
syb.CreatedAt = createdAt
|
||||||
|
return syb
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
DestroyDeadLine: deadline, LastSyncedAt: time.Now(),
|
||||||
|
}
|
||||||
|
if err := db.Create(&pkg).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
item := models.YeekeReturnItem{
|
||||||
|
PackageID: pkg.ID, ExternalKey: "key-" + variationName + fmt.Sprint(time.Now().UnixNano()),
|
||||||
|
ItemID: "100", VariationName: variationName, LastSyncedAt: time.Now(),
|
||||||
|
}
|
||||||
|
if err := db.Create(&item).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBatchMatch_EndToEnd(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
s := NewService(db)
|
||||||
|
s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
|
||||||
|
|
||||||
|
deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
syb := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
|
||||||
|
seedReturn(t, db, "白色,L【建議65-75公斤】", &deadline)
|
||||||
|
|
||||||
|
resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}, Operator: "tester"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if resp.MatchedCount != 1 || len(resp.Items) != 1 || !resp.Items[0].Matched {
|
||||||
|
t.Fatalf("expected one match: %+v", resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
var match models.ReturnMatch
|
||||||
|
if err := db.First(&match, resp.Items[0].MatchID).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if match.Status != models.ReturnMatchStatusMatched || match.ActiveSYBProductID == nil || *match.ActiveSYBProductID != syb.ID {
|
||||||
|
t.Fatalf("unexpected match row: %+v", match)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBatchMatch_RejectsMoreThan500Items(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
s := NewService(db)
|
||||||
|
|
||||||
|
ids := make([]uint64, maxBatchMatchItems+1)
|
||||||
|
for i := range ids {
|
||||||
|
ids[i] = uint64(i + 1)
|
||||||
|
}
|
||||||
|
resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: ids, Operator: "tester"})
|
||||||
|
if !errors.Is(err, ErrTooManyItems) {
|
||||||
|
t.Fatalf("expected ErrTooManyItems, got %v", err)
|
||||||
|
}
|
||||||
|
if len(resp.Items) != 0 {
|
||||||
|
t.Fatalf("expected empty response on rejection, got %+v", resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
var batchCount int64
|
||||||
|
if err := db.Model(&models.ReturnMatchBatch{}).Count(&batchCount).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if batchCount != 0 {
|
||||||
|
t.Fatalf("rejected oversized batch must not be recorded, got %d rows", batchCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBatchMatch_ExpiredDeadlineNotMatched(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
s := NewService(db)
|
||||||
|
s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
|
||||||
|
|
||||||
|
past := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
syb := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
|
||||||
|
seedReturn(t, db, "白色,L", &past)
|
||||||
|
|
||||||
|
resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if resp.MatchedCount != 0 || resp.SkippedCount != 1 {
|
||||||
|
t.Fatalf("expected skip due to expired deadline: %+v", resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBatchMatch_MultiColourSameOrderCrossPairing(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
s := NewService(db)
|
||||||
|
s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
|
||||||
|
deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
white := seedSYB(t, db, "SYB-1", 1, "白色", "2XL", time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC))
|
||||||
|
purple := seedSYB(t, db, "SYB-1", 2, "紫色", "M", time.Date(2026, 9, 1, 11, 0, 0, 0, time.UTC))
|
||||||
|
whiteReturn := seedReturn(t, db, "白色,2XL【建議65-75公斤】", &deadline)
|
||||||
|
purpleReturn := seedReturn(t, db, "紫色,M【建議43-53公斤】", &deadline)
|
||||||
|
|
||||||
|
resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{white.ID, purple.ID}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if resp.MatchedCount != 2 {
|
||||||
|
t.Fatalf("expected both to match: %+v", resp)
|
||||||
|
}
|
||||||
|
var matches []models.ReturnMatch
|
||||||
|
if err := db.Find(&matches).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := map[uint64]uint64{}
|
||||||
|
for _, m := range matches {
|
||||||
|
got[m.SYBProductID] = m.YeekeReturnItemID
|
||||||
|
}
|
||||||
|
if got[white.ID] != whiteReturn.ID {
|
||||||
|
t.Fatalf("white SYB product should pair with white return, got %+v", got)
|
||||||
|
}
|
||||||
|
if got[purple.ID] != purpleReturn.ID {
|
||||||
|
t.Fatalf("purple SYB product should pair with purple return, got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBatchMatch_ManualActionStageDoesNotParticipate(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
s := NewService(db)
|
||||||
|
s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
|
||||||
|
deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
// A parse-failed SYB product computes to 待人工处理, which the confirmed
|
||||||
|
// rule says must NOT participate even though a candidate return exists.
|
||||||
|
syb := models.SYBProduct{OrderCode: "SYB-1", DetailID: 1, StockID: 1, ShopeeItemID: "100", TargetColor: "白色", TargetSize: "L", Quantity: 1, UnitPriceCent: 1000, ParseStatus: models.SYBParseStatusFailed, RawJSON: `{}`}
|
||||||
|
if err := db.Create(&syb).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
seedReturn(t, db, "白色,L", &deadline)
|
||||||
|
|
||||||
|
resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if resp.MatchedCount != 0 || resp.Items[0].ReasonCode != ReasonStageIneligible {
|
||||||
|
t.Fatalf("expected 待人工处理 to be excluded from matching: %+v", resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfirmThenCancel_RestoresAvailability(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
s := NewService(db)
|
||||||
|
s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
|
||||||
|
deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
syb := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
|
||||||
|
seedReturn(t, db, "白色,L", &deadline)
|
||||||
|
|
||||||
|
resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}})
|
||||||
|
if err != nil || resp.MatchedCount != 1 {
|
||||||
|
t.Fatalf("setup match failed: %v %+v", err, resp)
|
||||||
|
}
|
||||||
|
matchID := resp.Items[0].MatchID
|
||||||
|
|
||||||
|
if _, err := s.Confirm(context.Background(), matchID, "op1"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var confirmed models.ReturnMatch
|
||||||
|
db.First(&confirmed, matchID)
|
||||||
|
if confirmed.Status != models.ReturnMatchStatusConfirmed {
|
||||||
|
t.Fatalf("expected confirmed: %+v", confirmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.Cancel(context.Background(), matchID, "op2"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var cancelled models.ReturnMatch
|
||||||
|
db.First(&cancelled, matchID)
|
||||||
|
if cancelled.Status != models.ReturnMatchStatusCancelled || cancelled.ActiveSYBProductID != nil || cancelled.ActiveYeekeReturnItemID != nil {
|
||||||
|
t.Fatalf("expected cancel to clear active columns: %+v", cancelled)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rematch must be able to pick the same pair again (issue #338 rule 7).
|
||||||
|
resp2, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if resp2.MatchedCount != 1 {
|
||||||
|
t.Fatalf("expected rematch after cancel to succeed: %+v", resp2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBatchMatch_SkipsWhenStageRacesToTaskCreatedUnderLock is the #338
|
||||||
|
// review fix-2 regression: a purchase task gets created for the SYB product
|
||||||
|
// (moving its stage to task_created, which does not participate) AFTER
|
||||||
|
// BatchMatch's outer screening pass already judged it eligible but BEFORE
|
||||||
|
// matchOneWithLock's own re-check runs. The row must be skipped with
|
||||||
|
// stage_ineligible, not matched.
|
||||||
|
func TestMatchOneWithLock_SkipsWhenStageNoLongerParticipatesUnderLock(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
s := NewService(db)
|
||||||
|
s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
|
||||||
|
deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
syb := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
|
||||||
|
ret := seedReturn(t, db, "白色,L", &deadline)
|
||||||
|
|
||||||
|
// Simulate the exact race the review flagged: BatchMatch's outer
|
||||||
|
// screening pass already computed `outcome` while the product was still
|
||||||
|
// participating (pdd_unlinked). Before matchOneWithLock's own re-check
|
||||||
|
// runs (which takes the same row lock purchase.create takes and
|
||||||
|
// recomputes the stage inside that transaction), something else moves
|
||||||
|
// the product out of a participating stage — here simulated directly by
|
||||||
|
// flipping it to a parse-failed/manual_action state, same effect as a
|
||||||
|
// purchase task having been created for it in the meantime.
|
||||||
|
if err := db.Model(&models.SYBProduct{}).Where("id = ?", syb.ID).Update("parse_status", models.SYBParseStatusFailed).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
outcome := MatchOutcome{SYBProductID: syb.ID, Matched: true, ReturnItemID: ret.ID, NormalizedKey: "100|白色,l"}
|
||||||
|
|
||||||
|
_, err := s.matchOneWithLock(context.Background(), syb.ID, outcome, "tester")
|
||||||
|
if !errors.Is(err, errStageNoLongerEligible) {
|
||||||
|
t.Fatalf("expected errStageNoLongerEligible, got %v", err)
|
||||||
|
}
|
||||||
|
var count int64
|
||||||
|
db.Model(&models.ReturnMatch{}).Where("syb_product_id = ?", syb.ID).Count(&count)
|
||||||
|
if count != 0 {
|
||||||
|
t.Fatalf("no return_match row should have been created when the stage race is caught: count=%d", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBatchMatch_WritesLogRowInSameTransaction(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
s := NewService(db)
|
||||||
|
s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
|
||||||
|
deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
syb := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
|
||||||
|
seedReturn(t, db, "白色,L【建議65-75公斤】", &deadline)
|
||||||
|
|
||||||
|
resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}, Operator: "tester"})
|
||||||
|
if err != nil || resp.MatchedCount != 1 {
|
||||||
|
t.Fatalf("setup match failed: %v %+v", err, resp)
|
||||||
|
}
|
||||||
|
var logs []models.ReturnMatchLog
|
||||||
|
if err := db.Where("match_id = ?", resp.Items[0].MatchID).Find(&logs).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(logs) != 1 || logs[0].Action != models.ReturnMatchLogActionMatched || logs[0].Operator != "tester" {
|
||||||
|
t.Fatalf("expected one matched log row: %+v", logs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfirmCancelRemark_EachWritesOwnLogRow(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
s := NewService(db)
|
||||||
|
s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
|
||||||
|
deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
syb := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
|
||||||
|
seedReturn(t, db, "白色,L", &deadline)
|
||||||
|
resp, _ := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}, Operator: "matcher"})
|
||||||
|
matchID := resp.Items[0].MatchID
|
||||||
|
|
||||||
|
if _, err := s.Remark(context.Background(), matchID, "reviewer", "看起来没问题"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := s.Confirm(context.Background(), matchID, "reviewer"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := s.Cancel(context.Background(), matchID, "reviewer2"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var logs []models.ReturnMatchLog
|
||||||
|
if err := db.Where("match_id = ?", matchID).Order("id ASC").Find(&logs).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
wantActions := []string{models.ReturnMatchLogActionMatched, models.ReturnMatchLogActionRemark, models.ReturnMatchLogActionConfirmed, models.ReturnMatchLogActionCancelled}
|
||||||
|
if len(logs) != len(wantActions) {
|
||||||
|
t.Fatalf("expected %d log rows, got %d: %+v", len(wantActions), len(logs), logs)
|
||||||
|
}
|
||||||
|
for i, action := range wantActions {
|
||||||
|
if logs[i].Action != action {
|
||||||
|
t.Fatalf("log[%d].Action = %q, want %q (%+v)", i, logs[i].Action, action, logs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if logs[1].Detail != "看起来没问题" {
|
||||||
|
t.Fatalf("remark log must carry the remark text: %+v", logs[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemark_RejectsOverLongInputInsteadOfTruncating(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
s := NewService(db)
|
||||||
|
s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
|
||||||
|
deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
syb := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
|
||||||
|
seedReturn(t, db, "白色,L", &deadline)
|
||||||
|
resp, _ := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}})
|
||||||
|
matchID := resp.Items[0].MatchID
|
||||||
|
|
||||||
|
tooLong := strings.Repeat("字", 501)
|
||||||
|
if _, err := s.Remark(context.Background(), matchID, "op", tooLong); !errors.Is(err, errRemarkTooLong) {
|
||||||
|
t.Fatalf("expected errRemarkTooLong, got %v", err)
|
||||||
|
}
|
||||||
|
var match models.ReturnMatch
|
||||||
|
db.First(&match, matchID)
|
||||||
|
if match.Remark != "" {
|
||||||
|
t.Fatalf("rejected remark must not be saved (partially or fully): %q", match.Remark)
|
||||||
|
}
|
||||||
|
|
||||||
|
exactly500 := strings.Repeat("字", 500)
|
||||||
|
if _, err := s.Remark(context.Background(), matchID, "op", exactly500); err != nil {
|
||||||
|
t.Fatalf("exactly 500 runes must be accepted: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetail_ReturnsBothSidesNormalizedSpecsAndLogs(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
s := NewService(db)
|
||||||
|
s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
|
||||||
|
deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
syb := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
|
||||||
|
ret := seedReturn(t, db, "白色,L【建議65-75公斤】", &deadline)
|
||||||
|
|
||||||
|
resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}, Operator: "matcher"})
|
||||||
|
if err != nil || resp.MatchedCount != 1 {
|
||||||
|
t.Fatalf("setup failed: %v %+v", err, resp)
|
||||||
|
}
|
||||||
|
matchID := resp.Items[0].MatchID
|
||||||
|
if _, err := s.Remark(context.Background(), matchID, "op", "备注文字"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
detail, err := s.Detail(context.Background(), matchID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if detail.SYB == nil || detail.SYB.SYBProductID != syb.ID || detail.SYB.OrderCode != "SYB-1" {
|
||||||
|
t.Fatalf("missing/wrong SYB detail: %+v", detail.SYB)
|
||||||
|
}
|
||||||
|
if detail.Yeeke == nil || detail.Yeeke.YeekeReturnItemID != ret.ID || detail.Yeeke.OrderSN != "ORD1" {
|
||||||
|
t.Fatalf("missing/wrong yeeke detail: %+v", detail.Yeeke)
|
||||||
|
}
|
||||||
|
if detail.Yeeke.DestroyDeadline == nil || !detail.Yeeke.DestroyDeadline.Equal(deadline) {
|
||||||
|
t.Fatalf("expected destroy deadline to be carried through: %+v", detail.Yeeke)
|
||||||
|
}
|
||||||
|
if detail.NormalizedSYBSpec == "" || detail.NormalizedSYBSpec != detail.NormalizedYeekeSpec {
|
||||||
|
t.Fatalf("expected both sides to normalize equal: syb=%q yeeke=%q", detail.NormalizedSYBSpec, detail.NormalizedYeekeSpec)
|
||||||
|
}
|
||||||
|
if len(detail.Logs) != 2 {
|
||||||
|
t.Fatalf("expected 2 log rows (matched + remark), got %+v", detail.Logs)
|
||||||
|
}
|
||||||
|
if detail.Logs[0].Action != models.ReturnMatchLogActionRemark {
|
||||||
|
t.Fatalf("expected newest-first ordering (remark first): %+v", detail.Logs)
|
||||||
|
}
|
||||||
|
if detail.Match.YeekeSpecText == "" || detail.Match.PreviousProcessStage == "" {
|
||||||
|
t.Fatalf("insertMatch must fill YeekeSpecText and PreviousProcessStage: %+v", detail.Match)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBatchMatch_ConcurrentInsertSkipsConflictRow exercises the DB unique
|
||||||
|
// constraint directly: two goroutines racing to insert an active match for
|
||||||
|
// the exact same SYB product must have exactly one winner, and the loser
|
||||||
|
// must not error out the whole call (acceptance item 9).
|
||||||
|
func TestBatchMatch_ConcurrentInsertSkipsConflictRow(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
sybID := uint64(1)
|
||||||
|
returnAID := uint64(101)
|
||||||
|
returnBID := uint64(102)
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
results := make([]error, 2)
|
||||||
|
for i, retID := range []uint64{returnAID, returnBID} {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(i int, retID uint64) {
|
||||||
|
defer wg.Done()
|
||||||
|
match := models.ReturnMatch{
|
||||||
|
SYBProductID: sybID, YeekeReturnItemID: retID,
|
||||||
|
ActiveSYBProductID: &sybID, ActiveYeekeReturnItemID: &retID,
|
||||||
|
Status: models.ReturnMatchStatusMatched, MatchedAt: time.Now(),
|
||||||
|
}
|
||||||
|
results[i] = db.Create(&match).Error
|
||||||
|
}(i, retID)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
successCount := 0
|
||||||
|
conflictCount := 0
|
||||||
|
for _, err := range results {
|
||||||
|
if err == nil {
|
||||||
|
successCount++
|
||||||
|
} else if isUniqueConstraintErr(err) {
|
||||||
|
conflictCount++
|
||||||
|
} else {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if successCount != 1 || conflictCount != 1 {
|
||||||
|
t.Fatalf("expected exactly one winner and one unique-constraint conflict, got success=%d conflict=%d results=%v", successCount, conflictCount, results)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestList_EnrichesWithYeekeReturnDisplayFields(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
s := NewService(db)
|
||||||
|
s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
|
||||||
|
deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
syb := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
|
||||||
|
seedReturn(t, db, "白色,L【建議65-75公斤】", &deadline)
|
||||||
|
|
||||||
|
resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}})
|
||||||
|
if err != nil || resp.MatchedCount != 1 {
|
||||||
|
t.Fatalf("setup failed: %v %+v", err, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
items, err := s.List(context.Background(), ListFilter{SYBProductIDs: []uint64{syb.ID}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(items) != 1 || items[0].OrderSN != "ORD1" || items[0].DestroyDeadline == nil {
|
||||||
|
t.Fatalf("expected enriched list item: %+v", items)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,10 @@ type SessionStore struct{ db *gorm.DB }
|
|||||||
|
|
||||||
func NewSessionStore(db *gorm.DB) *SessionStore { return &SessionStore{db: db} }
|
func NewSessionStore(db *gorm.DB) *SessionStore { return &SessionStore{db: db} }
|
||||||
|
|
||||||
|
// DB exposes the store connection to the shared session manager; callers do
|
||||||
|
// not receive any session data through this accessor.
|
||||||
|
func (s *SessionStore) DB() *gorm.DB { return s.db }
|
||||||
|
|
||||||
// Session is one cached SYB login.
|
// Session is one cached SYB login.
|
||||||
type Session struct {
|
type Session struct {
|
||||||
Username string
|
Username string
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package sybclient
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"go-admin/app/goauto/models"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LoginConfig contains the non-secret connection settings and credentials
|
||||||
|
// needed to acquire a SYB session. Password is used only during LoginWithOCR.
|
||||||
|
type LoginConfig struct {
|
||||||
|
BaseURL string
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
OcrURL string
|
||||||
|
OcrMaxAttempts int
|
||||||
|
}
|
||||||
|
|
||||||
|
// AcquireSession reuses a valid cached session and performs at most one
|
||||||
|
// re-login per account at a time. Callers waiting for another process to
|
||||||
|
// refresh the session never request another captcha.
|
||||||
|
func AcquireSession(ctx context.Context, db *gorm.DB, cfg LoginConfig) (*Client, error) {
|
||||||
|
if cfg.Username == "" {
|
||||||
|
return nil, fmt.Errorf("顺云宝账号未配置")
|
||||||
|
}
|
||||||
|
client, err := New(cfg.BaseURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
store := NewSessionStore(db)
|
||||||
|
if cached, loadErr := store.Load(ctx, cfg.Username, time.Now()); loadErr == nil {
|
||||||
|
if importErr := client.ImportCookiesJSON(cached.CookiesJSON); importErr != nil {
|
||||||
|
return nil, importErr
|
||||||
|
}
|
||||||
|
checkErr := client.CheckSession(ctx, cached.UserID, cfg.Username)
|
||||||
|
if checkErr == nil {
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
if !errors.Is(checkErr, ErrSessionInvalid) {
|
||||||
|
return nil, checkErr
|
||||||
|
}
|
||||||
|
if cfg.Password == "" {
|
||||||
|
return nil, checkErr
|
||||||
|
}
|
||||||
|
} else if !errors.Is(loadErr, ErrNoSession) {
|
||||||
|
return nil, loadErr
|
||||||
|
} else if cfg.Password == "" {
|
||||||
|
return nil, loadErr
|
||||||
|
}
|
||||||
|
|
||||||
|
owner := uuid.NewString()
|
||||||
|
if claimed, err := claimAuthLease(ctx, db, cfg.Username, owner, 2*time.Minute); err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if claimed {
|
||||||
|
defer releaseAuthLease(context.Background(), db, cfg.Username, owner)
|
||||||
|
// Another worker may have completed login between our first probe and
|
||||||
|
// acquiring the lease; always re-check before requesting a captcha.
|
||||||
|
if c, ok := validCachedSession(ctx, client, store, cfg.Username); ok {
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
if err := refreshSession(ctx, db, client, store, cfg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return validSessionOrError(ctx, client, store, cfg.Username)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A different worker owns the login lease. Wait for its session, bounded by
|
||||||
|
// the caller's context; do not trigger a second login.
|
||||||
|
for {
|
||||||
|
if c, ok := validCachedSession(ctx, client, store, cfg.Username); ok {
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("等待顺云宝会话刷新超时: %w", err)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-time.After(500 * time.Millisecond):
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validSessionOrError(ctx context.Context, client *Client, store *SessionStore, username string) (*Client, error) {
|
||||||
|
s, err := store.Load(ctx, username, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := client.ImportCookiesJSON(s.CookiesJSON); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := client.CheckSession(ctx, s.UserID, username); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validCachedSession(ctx context.Context, client *Client, store *SessionStore, username string) (*Client, bool) {
|
||||||
|
s, err := store.Load(ctx, username, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if err := client.ImportCookiesJSON(s.CookiesJSON); err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if err := client.CheckSession(ctx, s.UserID, username); err != nil {
|
||||||
|
// Network failures are not treated as logout, but they also do not
|
||||||
|
// authorize a write; the caller will retry through the normal worker.
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return client, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshSession(ctx context.Context, db *gorm.DB, client *Client, store *SessionStore, cfg LoginConfig) error {
|
||||||
|
ocr, err := NewOcrClient(cfg.OcrURL, 0)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("顺云宝验证码识别服务不可用: %w", err)
|
||||||
|
}
|
||||||
|
result, reason := client.LoginWithOCR(ctx, ocr, cfg.Username, cfg.Password, cfg.OcrMaxAttempts)
|
||||||
|
if result == nil {
|
||||||
|
return fmt.Errorf("顺云宝自动登录失败,需要手工输入验证码: %s", reason)
|
||||||
|
}
|
||||||
|
cookies, err := client.ExportCookiesJSON()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return store.Save(ctx, Session{Username: cfg.Username, UserID: result.User.ID, CookiesJSON: cookies, ExpiresAt: result.ExpiresAt})
|
||||||
|
}
|
||||||
|
|
||||||
|
func claimAuthLease(ctx context.Context, db *gorm.DB, username, owner string, ttl time.Duration) (bool, error) {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
if err := db.WithContext(ctx).Clauses(clause.OnConflict{DoNothing: true}).Create(&models.SYBSessionAuthLease{ID: 1, Username: username, Owner: "", ExpiresAt: now.Add(-time.Second)}).Error; err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
r := db.WithContext(ctx).Model(&models.SYBSessionAuthLease{}).Where("id = 1 AND username = ? AND (expires_at <= ? OR owner = '')", username, now).Updates(map[string]any{"owner": owner, "expires_at": now.Add(ttl)})
|
||||||
|
return r.RowsAffected == 1, r.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func releaseAuthLease(ctx context.Context, db *gorm.DB, username, owner string) {
|
||||||
|
_ = db.WithContext(ctx).Model(&models.SYBSessionAuthLease{}).Where("id = 1 AND username = ? AND owner = ?", username, owner).Updates(map[string]any{"owner": "", "expires_at": time.Now().UTC().Add(-time.Second)})
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package sybclient
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go-admin/app/goauto/models"
|
||||||
|
"gorm.io/driver/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAuthLeaseAllowsOnlyOneRefreshOwner(t *testing.T) {
|
||||||
|
db, err := gorm.Open(sqlite.Open("file:syb-auth-lease?mode=memory&cache=shared"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&models.SYBSessionAuthLease{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
first, err := claimAuthLease(ctx, db, "operator", "first", time.Minute)
|
||||||
|
if err != nil || !first {
|
||||||
|
t.Fatalf("first owner should claim: %v %v", first, err)
|
||||||
|
}
|
||||||
|
second, err := claimAuthLease(ctx, db, "operator", "second", time.Minute)
|
||||||
|
if err != nil || second {
|
||||||
|
t.Fatalf("second owner must wait: %v %v", second, err)
|
||||||
|
}
|
||||||
|
releaseAuthLease(ctx, db, "operator", "first")
|
||||||
|
second, err = claimAuthLease(ctx, db, "operator", "second", time.Minute)
|
||||||
|
if err != nil || !second {
|
||||||
|
t.Fatalf("lease should be reusable: %v %v", second, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,7 +47,7 @@ func (handler Handler) List(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
response, err := service.List(c.Request.Context(), ListRequest{
|
response, err := service.List(c.Request.Context(), ListRequest{
|
||||||
Page: page, PageSize: pageSize, ShopName: c.Query("shopName"), OrderCodes: []string{c.Query("orderCodes")}, ParseStatus: strings.TrimSpace(c.Query("parseStatus")), ProcessStage: strings.TrimSpace(c.Query("processStage")),
|
Page: page, PageSize: pageSize, ShopName: c.Query("shopName"), OrderCodes: []string{c.Query("orderCodes")}, ParseStatus: strings.TrimSpace(c.Query("parseStatus")), ProcessStage: strings.TrimSpace(c.Query("processStage")), CreatedFrom: strings.TrimSpace(c.Query("createdFrom")), CreatedTo: strings.TrimSpace(c.Query("createdTo")),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(c, err)
|
writeError(c, err)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"go-admin/app/goauto/models"
|
"go-admin/app/goauto/models"
|
||||||
"go-admin/app/goauto/purchase"
|
"go-admin/app/goauto/purchase"
|
||||||
@@ -44,6 +45,8 @@ type ListRequest struct {
|
|||||||
OrderCodes []string
|
OrderCodes []string
|
||||||
ParseStatus string
|
ParseStatus string
|
||||||
ProcessStage string
|
ProcessStage string
|
||||||
|
CreatedFrom string
|
||||||
|
CreatedTo string
|
||||||
}
|
}
|
||||||
|
|
||||||
type ListResponse struct {
|
type ListResponse struct {
|
||||||
@@ -65,10 +68,20 @@ func (service *Service) List(ctx context.Context, request ListRequest) (ListResp
|
|||||||
if request.PageSize < 1 {
|
if request.PageSize < 1 {
|
||||||
request.PageSize = 20
|
request.PageSize = 20
|
||||||
}
|
}
|
||||||
if request.PageSize > 100 {
|
if request.PageSize > 500 {
|
||||||
request.PageSize = 100
|
request.PageSize = 500
|
||||||
}
|
}
|
||||||
query := service.DB.WithContext(ctx).Model(&models.SYBProduct{})
|
query := service.DB.WithContext(ctx).Model(&models.SYBProduct{})
|
||||||
|
createdFrom, createdTo, err := createdAtRange(request.CreatedFrom, request.CreatedTo)
|
||||||
|
if err != nil {
|
||||||
|
return ListResponse{}, err
|
||||||
|
}
|
||||||
|
if createdFrom != nil {
|
||||||
|
query = query.Where("created_at >= ?", *createdFrom)
|
||||||
|
}
|
||||||
|
if createdTo != nil {
|
||||||
|
query = query.Where("created_at < ?", *createdTo)
|
||||||
|
}
|
||||||
if request.ShopName = strings.TrimSpace(request.ShopName); request.ShopName != "" {
|
if request.ShopName = strings.TrimSpace(request.ShopName); request.ShopName != "" {
|
||||||
if len([]rune(request.ShopName)) > 255 {
|
if len([]rune(request.ShopName)) > 255 {
|
||||||
return ListResponse{}, invalidRequest("店铺名称不能超过 255 个字符")
|
return ListResponse{}, invalidRequest("店铺名称不能超过 255 个字符")
|
||||||
@@ -142,6 +155,43 @@ func (service *Service) List(ctx context.Context, request ListRequest) (ListResp
|
|||||||
return ListResponse{Items: items, Total: total, Page: request.Page, PageSize: request.PageSize}, nil
|
return ListResponse{Items: items, Total: total, Page: request.Page, PageSize: request.PageSize}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// createdAtRange turns inclusive YYYY-MM-DD bounds into a half-open time
|
||||||
|
// range. The bounds are interpreted in the server's local timezone, matching
|
||||||
|
// the timestamps written by GORM for this service.
|
||||||
|
func createdAtRange(from, to string) (*time.Time, *time.Time, error) {
|
||||||
|
from = strings.TrimSpace(from)
|
||||||
|
to = strings.TrimSpace(to)
|
||||||
|
if from == "" && to == "" {
|
||||||
|
return nil, nil, nil
|
||||||
|
}
|
||||||
|
parse := func(value, label string) (*time.Time, error) {
|
||||||
|
if value == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
parsed, err := time.ParseInLocation("2006-01-02", value, time.Local)
|
||||||
|
if err != nil {
|
||||||
|
return nil, invalidRequest(label + " 必须是 YYYY-MM-DD")
|
||||||
|
}
|
||||||
|
return &parsed, nil
|
||||||
|
}
|
||||||
|
start, err := parse(from, "createdFrom")
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
endDay, err := parse(to, "createdTo")
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if start != nil && endDay != nil && start.After(*endDay) {
|
||||||
|
return nil, nil, invalidRequest("createdFrom 不能晚于 createdTo")
|
||||||
|
}
|
||||||
|
if endDay != nil {
|
||||||
|
end := endDay.AddDate(0, 0, 1)
|
||||||
|
endDay = &end
|
||||||
|
}
|
||||||
|
return start, endDay, nil
|
||||||
|
}
|
||||||
|
|
||||||
func normalizeOrderCodes(raw []string) ([]string, error) {
|
func normalizeOrderCodes(raw []string) ([]string, error) {
|
||||||
seen := make(map[string]bool, len(raw))
|
seen := make(map[string]bool, len(raw))
|
||||||
result := make([]string, 0, len(raw))
|
result := make([]string, 0, len(raw))
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"go-admin/app/goauto/models"
|
"go-admin/app/goauto/models"
|
||||||
"go-admin/app/goauto/sybimport"
|
"go-admin/app/goauto/sybimport"
|
||||||
@@ -93,6 +94,76 @@ func TestServiceListRejectsTooManyOrTooLongOrderCodes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestServiceListCapsPageSizeAt500(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
order := realOrder()
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
detail := realDetailA()
|
||||||
|
detail.ID += uint64(i)
|
||||||
|
order.Code = fmt.Sprintf("260728TB95MJTQ-%d", i)
|
||||||
|
if _, err := sybimport.ApplyDetail(context.Background(), db, order, detail); err != nil {
|
||||||
|
t.Fatalf("apply detail %d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
service := sybimport.NewService(db)
|
||||||
|
|
||||||
|
overLimit, err := service.List(context.Background(), sybimport.ListRequest{Page: 1, PageSize: 600})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list with oversized page size: %v", err)
|
||||||
|
}
|
||||||
|
if overLimit.PageSize != 500 {
|
||||||
|
t.Fatalf("expected page size capped at 500, got %d", overLimit.PageSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
within, err := service.List(context.Background(), sybimport.ListRequest{Page: 1, PageSize: 500})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list at exactly 500: %v", err)
|
||||||
|
}
|
||||||
|
if within.PageSize != 500 {
|
||||||
|
t.Fatalf("expected page size of exactly 500 to pass through unchanged, got %d", within.PageSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceListFiltersByCreatedDateInclusive(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
order := realOrder()
|
||||||
|
for i, day := range []string{"2026-09-01", "2026-09-02", "2026-09-03"} {
|
||||||
|
rowOrder := order
|
||||||
|
rowOrder.Code = fmt.Sprintf("CREATED-%d", i)
|
||||||
|
rowOrder.StockID += uint64(i)
|
||||||
|
detail := realDetailA()
|
||||||
|
detail.ID += uint64(i)
|
||||||
|
result, err := sybimport.ApplyDetail(context.Background(), db, rowOrder, detail)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
created, err := time.ParseInLocation("2006-01-02", day, time.Local)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.Model(&models.SYBProduct{}).Where("id = ?", result.SYBProduct.ID).Update("created_at", created).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
service := sybimport.NewService(db)
|
||||||
|
|
||||||
|
between, err := service.List(context.Background(), sybimport.ListRequest{CreatedFrom: "2026-09-01", CreatedTo: "2026-09-02"})
|
||||||
|
if err != nil || between.Total != 2 {
|
||||||
|
t.Fatalf("inclusive created date range should return two rows, total=%d err=%v", between.Total, err)
|
||||||
|
}
|
||||||
|
fromOnly, err := service.List(context.Background(), sybimport.ListRequest{CreatedFrom: "2026-09-03"})
|
||||||
|
if err != nil || fromOnly.Total != 1 {
|
||||||
|
t.Fatalf("created-from filter should return one row, total=%d err=%v", fromOnly.Total, err)
|
||||||
|
}
|
||||||
|
toOnly, err := service.List(context.Background(), sybimport.ListRequest{CreatedTo: "2026-09-01"})
|
||||||
|
if err != nil || toOnly.Total != 1 {
|
||||||
|
t.Fatalf("created-to filter should return one row, total=%d err=%v", toOnly.Total, err)
|
||||||
|
}
|
||||||
|
if _, err := service.List(context.Background(), sybimport.ListRequest{CreatedFrom: "2026-09-04", CreatedTo: "2026-09-01"}); serviceErrCode(t, err) != sybimport.CodeInvalidRequest {
|
||||||
|
t.Fatalf("reversed created date range should be rejected: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestServiceListRejectsInvalidParseStatus(t *testing.T) {
|
func TestServiceListRejectsInvalidParseStatus(t *testing.T) {
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
service := sybimport.NewService(db)
|
service := sybimport.NewService(db)
|
||||||
|
|||||||
@@ -480,54 +480,10 @@ func splitDateRange(dateFrom, dateTo string) ([]string, error) {
|
|||||||
// blip as a logout would trigger needless logins and could throw away a
|
// blip as a logout would trigger needless logins and could throw away a
|
||||||
// perfectly good session.
|
// perfectly good session.
|
||||||
func Connect(ctx context.Context, store *sybclient.SessionStore, cfg ConnectConfig) (*sybclient.Client, error) {
|
func Connect(ctx context.Context, store *sybclient.SessionStore, cfg ConnectConfig) (*sybclient.Client, error) {
|
||||||
if cfg.Username == "" || cfg.Password == "" {
|
return sybclient.AcquireSession(ctx, store.DB(), sybclient.LoginConfig{
|
||||||
return nil, errors.New("顺云宝账号或密码未配置,请设置 GOAUTO_SYB_USERNAME 和 GOAUTO_SYB_PASSWORD")
|
BaseURL: cfg.BaseURL, Username: cfg.Username, Password: cfg.Password,
|
||||||
}
|
OcrURL: cfg.OcrURL, OcrMaxAttempts: cfg.OcrMaxAttempts,
|
||||||
client, err := sybclient.New(cfg.BaseURL)
|
})
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
cached, err := store.Load(ctx, cfg.Username, time.Now())
|
|
||||||
switch {
|
|
||||||
case err == nil:
|
|
||||||
if importErr := client.ImportCookiesJSON(cached.CookiesJSON); importErr == nil {
|
|
||||||
if checkErr := client.CheckSession(ctx, cached.UserID, cfg.Username); checkErr == nil {
|
|
||||||
return client, nil
|
|
||||||
} else if errors.Is(checkErr, sybclient.ErrSessionInvalid) {
|
|
||||||
if delErr := store.Delete(ctx, cfg.Username); delErr != nil {
|
|
||||||
return nil, delErr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case errors.Is(err, sybclient.ErrNoSession):
|
|
||||||
// Nothing cached; fall through to a fresh login.
|
|
||||||
default:
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if cfg.OcrURL == "" {
|
|
||||||
return nil, errors.New("顺云宝会话已失效,且未配置验证码识别服务;请配置 extend.syb.ocrurl 或改用手工登录")
|
|
||||||
}
|
|
||||||
ocr, err := sybclient.NewOcrClient(cfg.OcrURL, 0)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
result, reason := client.LoginWithOCR(ctx, ocr, cfg.Username, cfg.Password, cfg.OcrMaxAttempts)
|
|
||||||
if result == nil {
|
|
||||||
return nil, fmt.Errorf("顺云宝自动登录失败,需要手工输入验证码: %s", reason)
|
|
||||||
}
|
|
||||||
jar, err := client.ExportCookiesJSON()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := store.Save(ctx, sybclient.Session{
|
|
||||||
Username: cfg.Username, UserID: result.User.ID,
|
|
||||||
CookiesJSON: jar, ExpiresAt: result.ExpiresAt,
|
|
||||||
}); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return client, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConnectConfig carries the login settings. The password is passed through and
|
// ConnectConfig carries the login settings. The password is passed through and
|
||||||
|
|||||||
@@ -0,0 +1,212 @@
|
|||||||
|
package yeeke
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go-admin/app/goauto/models"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/go-admin-team/go-admin-core/sdk/api"
|
||||||
|
"github.com/go-admin-team/go-admin-core/sdk/pkg"
|
||||||
|
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handler exposes the yeeke read-only admin surface: manual sync trigger and
|
||||||
|
// sync run history/summary. It never returns credentials, tokens, captcha
|
||||||
|
// text or a full raw yeeke response — only the counters already stored on
|
||||||
|
// models.YeekeSyncRun (#336 requirement #7).
|
||||||
|
type Handler struct {
|
||||||
|
// DB lets tests inject a database directly; production requests resolve
|
||||||
|
// it from the gin context via pkg.GetOrm, same as sybimport.Handler.
|
||||||
|
DB *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h Handler) db(c *gin.Context) (*gorm.DB, bool) {
|
||||||
|
db := h.DB
|
||||||
|
var err error
|
||||||
|
if db == nil {
|
||||||
|
db, err = pkg.GetOrm(c)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return db, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncRunDTO is the read-only shape returned to admin/purchaser. It embeds
|
||||||
|
// only the summary fields already computed by the sync run itself; it never
|
||||||
|
// carries yeeke_session (token/cookies) or a raw page response.
|
||||||
|
type SyncRunDTO struct {
|
||||||
|
ID uint64 `json:"id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Trigger string `json:"trigger"`
|
||||||
|
TotalPages int `json:"totalPages"`
|
||||||
|
ReadCount int `json:"readCount"`
|
||||||
|
CreatedCount int `json:"createdCount"`
|
||||||
|
UpdatedCount int `json:"updatedCount"`
|
||||||
|
SkippedCount int `json:"skippedCount"`
|
||||||
|
FailedCount int `json:"failedCount"`
|
||||||
|
MissingMarkedCount int `json:"missingMarkedCount"`
|
||||||
|
RecoveredCount int `json:"recoveredCount"`
|
||||||
|
ErrorMessage string `json:"errorMessage"`
|
||||||
|
StartedAt string `json:"startedAt"`
|
||||||
|
FinishedAt *string `json:"finishedAt"`
|
||||||
|
LastSuccessAt *string `json:"lastSuccessAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func toDTO(r models.YeekeSyncRun) SyncRunDTO {
|
||||||
|
dto := SyncRunDTO{
|
||||||
|
ID: r.ID, Status: r.Status, Trigger: r.Trigger, TotalPages: r.TotalPages,
|
||||||
|
ReadCount: r.ReadCount, CreatedCount: r.CreatedCount, UpdatedCount: r.UpdatedCount,
|
||||||
|
SkippedCount: r.SkippedCount, FailedCount: r.FailedCount,
|
||||||
|
MissingMarkedCount: r.MissingMarkedCount, RecoveredCount: r.RecoveredCount, ErrorMessage: r.ErrorMessage,
|
||||||
|
StartedAt: r.StartedAt.UTC().Format("2006-01-02T15:04:05Z"),
|
||||||
|
}
|
||||||
|
if r.FinishedAt != nil {
|
||||||
|
s := r.FinishedAt.UTC().Format("2006-01-02T15:04:05Z")
|
||||||
|
dto.FinishedAt = &s
|
||||||
|
}
|
||||||
|
if r.LastSuccessAt != nil {
|
||||||
|
s := r.LastSuccessAt.UTC().Format("2006-01-02T15:04:05Z")
|
||||||
|
dto.LastSuccessAt = &s
|
||||||
|
}
|
||||||
|
return dto
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListSyncRuns returns the most recent sync runs, newest first, optionally
|
||||||
|
// filtered by trigger, status and started-at range (#337 backward-compatible
|
||||||
|
// extension: all filters are optional query params, existing callers with no
|
||||||
|
// filters keep the #336 behavior). Visible to admin and purchaser alike
|
||||||
|
// (#336 requirement #7); it is mounted without Casbin role gating, mirroring
|
||||||
|
// sybimport's /sync-runs.
|
||||||
|
func (h Handler) ListSyncRuns(c *gin.Context) {
|
||||||
|
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
if err != nil || page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
pageSize, err := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
|
||||||
|
if err != nil || pageSize < 1 || pageSize > 100 {
|
||||||
|
pageSize = 20
|
||||||
|
}
|
||||||
|
db, ok := h.db(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
query := db.Model(&models.YeekeSyncRun{})
|
||||||
|
if v := strings.TrimSpace(c.Query("trigger")); v != "" {
|
||||||
|
query = query.Where("trigger = ?", v)
|
||||||
|
}
|
||||||
|
if v := strings.TrimSpace(c.Query("status")); v != "" {
|
||||||
|
query = query.Where("status = ?", v)
|
||||||
|
}
|
||||||
|
if v := strings.TrimSpace(c.Query("startedFrom")); v != "" {
|
||||||
|
t, perr := parseFilterTime(v)
|
||||||
|
if perr != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "startedFrom 格式无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
query = query.Where("started_at >= ?", t)
|
||||||
|
}
|
||||||
|
if v := strings.TrimSpace(c.Query("startedTo")); v != "" {
|
||||||
|
t, perr := parseFilterTime(v)
|
||||||
|
if perr != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "startedTo 格式无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
query = query.Where("started_at <= ?", t)
|
||||||
|
}
|
||||||
|
|
||||||
|
var total int64
|
||||||
|
if err := query.Count(&total).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var rows []models.YeekeSyncRun
|
||||||
|
if err := query.Order("id desc").Offset((page - 1) * pageSize).Limit(pageSize).Find(&rows).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items := make([]SyncRunDTO, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
items = append(items, toDTO(row))
|
||||||
|
}
|
||||||
|
var last models.YeekeSyncRun
|
||||||
|
lastSuccessAt := ""
|
||||||
|
if err := db.Where("status = ?", "succeeded").Order("id desc").First(&last).Error; err == nil && last.LastSuccessAt != nil {
|
||||||
|
lastSuccessAt = last.LastSuccessAt.UTC().Format("2006-01-02T15:04:05Z")
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{
|
||||||
|
"items": items, "total": total, "page": page, "pageSize": pageSize,
|
||||||
|
"lastSuccessAt": lastSuccessAt,
|
||||||
|
}})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncRunDetail returns one run's summary.
|
||||||
|
func (h Handler) SyncRunDetail(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("runId"), 10, 64)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "runId 无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db, ok := h.db(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var row models.YeekeSyncRun
|
||||||
|
if err := db.First(&row, id).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"code": "NOT_FOUND", "message": "同步记录不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{"item": toDTO(row)}})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TriggerSync starts a manual sync run. Only admin and purchaser may call it,
|
||||||
|
// same as SYB's manual Import (sybimport.Handler.Import) — this mirrors that
|
||||||
|
// role check exactly.
|
||||||
|
func (h Handler) TriggerSync(c *gin.Context) {
|
||||||
|
role, _ := jwt.ExtractClaims(c)["rolekey"].(string)
|
||||||
|
if role != "admin" && role != "purchaser" {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"code": "FORBIDDEN", "message": "只有管理员或采购员可以开始同步"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db, ok := h.db(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := StartSync(c.Request.Context(), db, nil, "manual", false)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrAlreadyRunning) {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"code": "ALREADY_RUNNING", "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// `[必须]` err here is only ever a config/connect-stage message built in
|
||||||
|
// start.go/yeekeclient — never a raw yeeke response, never a token or
|
||||||
|
// credential. api.GetRequestLogger keeps the same text out of the HTTP
|
||||||
|
// body while still recording it server-side for operators.
|
||||||
|
api.GetRequestLogger(c).Errorf("yeeke manual sync failed to start: %v", err)
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"code": "SYNC_START_FAILED", "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusAccepted, gin.H{"code": 200, "data": gin.H{"runId": result.RunID, "skipped": result.Skipped}})
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseFilterTime accepts either a full RFC3339-ish "2006-01-02T15:04:05Z" or
|
||||||
|
// a plain "2006-01-02" date, matching the query-string shapes used by both
|
||||||
|
// the return-package and sync-run filters.
|
||||||
|
func parseFilterTime(v string) (time.Time, error) {
|
||||||
|
if t, err := time.Parse("2006-01-02T15:04:05Z", v); err == nil {
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
return time.Parse("2006-01-02", v)
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package yeeke
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ReturnSyncInvokeTarget is the go-admin job invoke_target key for the
|
||||||
|
// scheduled yeeke return sync (#336). The job row itself is seeded disabled
|
||||||
|
// (Status: 2) by migrations/version-local; an admin turns it on explicitly.
|
||||||
|
const ReturnSyncInvokeTarget = "GoAutoYeekeReturnSync"
|
||||||
|
|
||||||
|
// ReturnSyncJob is registered in go-admin's ExecJob map (app/jobs/examples.go).
|
||||||
|
// ExecWithDB is the production path; Exec exists only to satisfy the legacy
|
||||||
|
// jobs.JobExec interface and fails closed if an older caller forgets to
|
||||||
|
// provide the current database.
|
||||||
|
type ReturnSyncJob struct{}
|
||||||
|
|
||||||
|
func (ReturnSyncJob) Exec(_ interface{}) error {
|
||||||
|
return errors.New("yeeke 定时同步缺少数据库连接")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecWithDB starts a sync sharing the same StartSync entry point, and hence
|
||||||
|
// the same syncGate/active_slot lease, as the manual admin trigger — a
|
||||||
|
// scheduled tick that lands while a manual run (or a previous tick) is still
|
||||||
|
// in progress is skipped rather than queued or run concurrently.
|
||||||
|
func (ReturnSyncJob) ExecWithDB(db *gorm.DB, _ interface{}) error {
|
||||||
|
_, err := StartSync(context.Background(), db, nil, "scheduled", true)
|
||||||
|
if errors.Is(err, ErrAlreadyRunning) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,487 @@
|
|||||||
|
package yeeke
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go-admin/app/goauto/models"
|
||||||
|
"go-admin/app/goauto/purchase"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ReturnItemDTO is the read-only shape of one item inside a return package.
|
||||||
|
// It never carries anything beyond what #336's sync already stores, and the
|
||||||
|
// image field is always the external yeeke URL (never downloaded/proxied,
|
||||||
|
// per #337 non-goal).
|
||||||
|
type ReturnItemDTO struct {
|
||||||
|
ID uint64 `json:"id"`
|
||||||
|
ItemID string `json:"itemId"`
|
||||||
|
VariationID string `json:"variationId"`
|
||||||
|
ItemName string `json:"itemName"`
|
||||||
|
VariationName string `json:"variationName"`
|
||||||
|
Image string `json:"image"`
|
||||||
|
Quantity int64 `json:"quantity"`
|
||||||
|
SyncStatus string `json:"syncStatus"`
|
||||||
|
MissingSince *string `json:"missingSince,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReturnPackageDTO is the read-only shape of one return package returned to
|
||||||
|
// admin/purchaser. StatusUnrecognized is surfaced verbatim (#337 #4 / #336):
|
||||||
|
// an unknown ClaimStatus is never bucketed into a known value.
|
||||||
|
type ReturnPackageDTO struct {
|
||||||
|
ID uint64 `json:"id"`
|
||||||
|
OrderSN string `json:"orderSn"`
|
||||||
|
TrackingNo string `json:"trackingNo"`
|
||||||
|
ShopID string `json:"shopId"`
|
||||||
|
ShopName string `json:"shopName"`
|
||||||
|
WareCode string `json:"wareCode"`
|
||||||
|
WareHouse string `json:"wareHouse"`
|
||||||
|
WareName string `json:"wareName"`
|
||||||
|
ClaimStatus string `json:"claimStatus"`
|
||||||
|
StatusUnrecognized bool `json:"statusUnrecognized"`
|
||||||
|
ItemCount int `json:"itemCount"`
|
||||||
|
ClaimTime *string `json:"claimTime"`
|
||||||
|
CreateTime *string `json:"createTime"`
|
||||||
|
UpdateTime *string `json:"updateTime"`
|
||||||
|
DestroyDeadLine *string `json:"destroyDeadLine"`
|
||||||
|
LastSyncedAt string `json:"lastSyncedAt"`
|
||||||
|
SyncStatus string `json:"syncStatus"`
|
||||||
|
MissingSince *string `json:"missingSince,omitempty"`
|
||||||
|
Items []ReturnItemDTO `json:"items,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReturnItemRowDTO is one row of the #337 item-level admin list: one row per
|
||||||
|
// yeeke_return_item, joined with its parent package. A package with no items
|
||||||
|
// still produces exactly one placeholder row (HasItem=false, item fields
|
||||||
|
// empty/zero) so it is never silently dropped from the list.
|
||||||
|
type ReturnItemRowDTO struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
PackageID uint64 `json:"packageId"`
|
||||||
|
ItemID uint64 `json:"itemId"`
|
||||||
|
OrderSN string `json:"orderSn"`
|
||||||
|
Image string `json:"image"`
|
||||||
|
ItemName string `json:"itemName"`
|
||||||
|
VariationName string `json:"variationName"`
|
||||||
|
ShopeeItemID string `json:"shopeeItemId"`
|
||||||
|
VariationID string `json:"variationId"`
|
||||||
|
Quantity int64 `json:"quantity"`
|
||||||
|
ShopName string `json:"shopName"`
|
||||||
|
WareHouse string `json:"wareHouse"`
|
||||||
|
WareCode string `json:"wareCode"`
|
||||||
|
ClaimStatus string `json:"claimStatus"`
|
||||||
|
StatusUnrecognized bool `json:"statusUnrecognized"`
|
||||||
|
ClaimTime *string `json:"claimTime"`
|
||||||
|
DestroyDeadLine *string `json:"destroyDeadLine"`
|
||||||
|
LastSyncedAt string `json:"lastSyncedAt"`
|
||||||
|
HasItem bool `json:"hasItem"`
|
||||||
|
|
||||||
|
// SyncStatus/MissingSince (#338 scope addition): "missing" when this
|
||||||
|
// item, or its package, has fallen out of a COMPLETE yeeke sync — the
|
||||||
|
// row is never dropped from this list, only flagged unavailable.
|
||||||
|
SyncStatus string `json:"syncStatus,omitempty"`
|
||||||
|
MissingSince *string `json:"missingSince,omitempty"`
|
||||||
|
|
||||||
|
// #338: match status/占用信息, joined from return_match (active match
|
||||||
|
// only). MatchStatus is "unmatched" when there is no active match.
|
||||||
|
MatchID uint64 `json:"matchId,omitempty"`
|
||||||
|
MatchStatus string `json:"matchStatus"`
|
||||||
|
OccupyingSYBProductID uint64 `json:"occupyingSybProductId,omitempty"`
|
||||||
|
OccupyingSYBOrderCode string `json:"occupyingSybOrderCode,omitempty"`
|
||||||
|
OccupyingSYBStage string `json:"occupyingSybStage,omitempty"`
|
||||||
|
OccupyingSYBStageLabel string `json:"occupyingSybStageLabel,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
ReturnMatchFilterUnmatched = "unmatched"
|
||||||
|
ReturnMatchFilterMatched = "matched"
|
||||||
|
ReturnMatchFilterConfirmed = "confirmed"
|
||||||
|
)
|
||||||
|
|
||||||
|
func formatTimePtr(t *time.Time) *string {
|
||||||
|
if t == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s := t.UTC().Format("2006-01-02T15:04:05Z")
|
||||||
|
return &s
|
||||||
|
}
|
||||||
|
|
||||||
|
func toPackageDTO(p models.YeekeReturnPackage, itemCount int) ReturnPackageDTO {
|
||||||
|
return ReturnPackageDTO{
|
||||||
|
ID: p.ID, OrderSN: p.OrderSN, TrackingNo: p.TrackingNo, ShopID: p.ShopID, ShopName: p.ShopName,
|
||||||
|
WareCode: p.WareCode, WareHouse: p.WareHouse, WareName: p.WareName, ClaimStatus: p.ClaimStatus,
|
||||||
|
StatusUnrecognized: p.StatusUnrecognized, ItemCount: itemCount,
|
||||||
|
ClaimTime: formatTimePtr(p.ClaimTime), CreateTime: formatTimePtr(p.CreateTime),
|
||||||
|
UpdateTime: formatTimePtr(p.UpdateTime), DestroyDeadLine: formatTimePtr(p.DestroyDeadLine),
|
||||||
|
LastSyncedAt: p.LastSyncedAt.UTC().Format("2006-01-02T15:04:05Z"), SyncStatus: p.SyncStatus,
|
||||||
|
MissingSince: formatTimePtr(p.MissingSince),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toItemDTO(i models.YeekeReturnItem) ReturnItemDTO {
|
||||||
|
return ReturnItemDTO{
|
||||||
|
ID: i.ID, ItemID: i.ItemID, VariationID: i.VariationID, ItemName: i.ItemName,
|
||||||
|
VariationName: i.VariationName, Image: i.Image, Quantity: i.Quantity,
|
||||||
|
SyncStatus: i.SyncStatus, MissingSince: formatTimePtr(i.MissingSince),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// itemRow mirrors one row of the LEFT JOIN query in ListReturnItemRows: every
|
||||||
|
// package column plus every (nullable) item column.
|
||||||
|
type itemRow struct {
|
||||||
|
PackageID uint64
|
||||||
|
OrderSN string
|
||||||
|
ShopName string
|
||||||
|
WareHouse string
|
||||||
|
WareCode string
|
||||||
|
ClaimStatus string
|
||||||
|
StatusUnrecognized bool
|
||||||
|
ClaimTime *time.Time
|
||||||
|
DestroyDeadLine *time.Time
|
||||||
|
PackageLastSynced time.Time
|
||||||
|
|
||||||
|
ItemID *uint64
|
||||||
|
ItemExternalItem *string
|
||||||
|
ItemVariationID *string
|
||||||
|
ItemName *string
|
||||||
|
ItemVariationName *string
|
||||||
|
ItemImage *string
|
||||||
|
ItemQuantity *int64
|
||||||
|
ItemLastSynced *time.Time
|
||||||
|
|
||||||
|
// #338 scope addition: item's own and its package's sync_status/
|
||||||
|
// missing_since, so the row can be flagged unavailable regardless of
|
||||||
|
// which side dropped out of a COMPLETE yeeke sync.
|
||||||
|
ItemSyncStatus *string `gorm:"column:item_sync_status"`
|
||||||
|
ItemMissingSince *time.Time `gorm:"column:item_missing_since"`
|
||||||
|
PackageSyncStatus string `gorm:"column:package_sync_status"`
|
||||||
|
PackageMissingSince *time.Time `gorm:"column:package_missing_since"`
|
||||||
|
|
||||||
|
// #338 match columns, from the LEFT JOIN onto return_match/syb_product.
|
||||||
|
MatchID *uint64 `gorm:"column:match_id"`
|
||||||
|
MatchStatus *string `gorm:"column:match_status"`
|
||||||
|
OccupyingSYBProductID *uint64 `gorm:"column:occupying_syb_product_id"`
|
||||||
|
OccupyingSYBOrderCode *string `gorm:"column:occupying_syb_order_code"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func toItemRowDTO(r itemRow) ReturnItemRowDTO {
|
||||||
|
dto := ReturnItemRowDTO{
|
||||||
|
PackageID: r.PackageID, OrderSN: r.OrderSN, ShopName: r.ShopName,
|
||||||
|
WareHouse: r.WareHouse, WareCode: r.WareCode, ClaimStatus: r.ClaimStatus,
|
||||||
|
StatusUnrecognized: r.StatusUnrecognized,
|
||||||
|
ClaimTime: formatTimePtr(r.ClaimTime),
|
||||||
|
DestroyDeadLine: formatTimePtr(r.DestroyDeadLine),
|
||||||
|
LastSyncedAt: r.PackageLastSynced.UTC().Format("2006-01-02T15:04:05Z"),
|
||||||
|
}
|
||||||
|
if r.ItemID == nil || *r.ItemID == 0 {
|
||||||
|
dto.Key = "p" + strconv.FormatUint(r.PackageID, 10)
|
||||||
|
dto.HasItem = false
|
||||||
|
return dto
|
||||||
|
}
|
||||||
|
dto.Key = strconv.FormatUint(*r.ItemID, 10)
|
||||||
|
dto.ItemID = *r.ItemID
|
||||||
|
dto.HasItem = true
|
||||||
|
if r.ItemExternalItem != nil {
|
||||||
|
dto.ShopeeItemID = *r.ItemExternalItem
|
||||||
|
}
|
||||||
|
if r.ItemVariationID != nil {
|
||||||
|
dto.VariationID = *r.ItemVariationID
|
||||||
|
}
|
||||||
|
if r.ItemName != nil {
|
||||||
|
dto.ItemName = *r.ItemName
|
||||||
|
}
|
||||||
|
if r.ItemVariationName != nil {
|
||||||
|
dto.VariationName = *r.ItemVariationName
|
||||||
|
}
|
||||||
|
if r.ItemImage != nil {
|
||||||
|
dto.Image = *r.ItemImage
|
||||||
|
}
|
||||||
|
if r.ItemQuantity != nil {
|
||||||
|
dto.Quantity = *r.ItemQuantity
|
||||||
|
}
|
||||||
|
if r.ItemLastSynced != nil {
|
||||||
|
dto.LastSyncedAt = r.ItemLastSynced.UTC().Format("2006-01-02T15:04:05Z")
|
||||||
|
}
|
||||||
|
// #338: the package being "missing" makes the item unavailable too,
|
||||||
|
// even if the item row itself is still "ok" — its reason/timestamp wins.
|
||||||
|
syncStatus, missingSince := "ok", r.ItemMissingSince
|
||||||
|
if r.ItemSyncStatus != nil {
|
||||||
|
syncStatus = *r.ItemSyncStatus
|
||||||
|
}
|
||||||
|
if r.PackageSyncStatus != "" && r.PackageSyncStatus != "ok" {
|
||||||
|
syncStatus, missingSince = r.PackageSyncStatus, r.PackageMissingSince
|
||||||
|
}
|
||||||
|
dto.SyncStatus = syncStatus
|
||||||
|
dto.MissingSince = formatTimePtr(missingSince)
|
||||||
|
dto.MatchStatus = ReturnMatchFilterUnmatched
|
||||||
|
if r.MatchID != nil {
|
||||||
|
dto.MatchID = *r.MatchID
|
||||||
|
if r.MatchStatus != nil {
|
||||||
|
switch *r.MatchStatus {
|
||||||
|
case models.ReturnMatchStatusConfirmed:
|
||||||
|
dto.MatchStatus = ReturnMatchFilterConfirmed
|
||||||
|
default:
|
||||||
|
dto.MatchStatus = ReturnMatchFilterMatched
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if r.OccupyingSYBProductID != nil {
|
||||||
|
dto.OccupyingSYBProductID = *r.OccupyingSYBProductID
|
||||||
|
}
|
||||||
|
if r.OccupyingSYBOrderCode != nil {
|
||||||
|
dto.OccupyingSYBOrderCode = *r.OccupyingSYBOrderCode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dto
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListReturnPackages returns a paginated, filtered list of return packages.
|
||||||
|
// Filters: order sn, tracking no, shop (id or name, partial match), itemId /
|
||||||
|
// variationId (matched against yeeke_return_item, requires a join), claim
|
||||||
|
// status (including the literal "unknown" pseudo-value for
|
||||||
|
// StatusUnrecognized rows), and createTime range. Visible to admin and
|
||||||
|
// purchaser alike, same gating as ListSyncRuns.
|
||||||
|
func (h Handler) ListReturnPackages(c *gin.Context) {
|
||||||
|
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
if err != nil || page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
pageSize, err := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
|
||||||
|
if err != nil || pageSize < 1 || pageSize > 100 {
|
||||||
|
pageSize = 20
|
||||||
|
}
|
||||||
|
db, ok := h.db(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
base := db.Table("yeeke_return_package p").
|
||||||
|
Joins("LEFT JOIN yeeke_return_item i ON i.package_id = p.id").
|
||||||
|
// #338: single bounded LEFT JOIN onto the active match (if any) and
|
||||||
|
// the SYB product it occupies, so 匹配状态 filtering/column never
|
||||||
|
// needs a per-row lookup.
|
||||||
|
Joins("LEFT JOIN return_match m ON m.active_yeeke_return_item_id = i.id").
|
||||||
|
Joins("LEFT JOIN syb_product sp ON sp.id = m.syb_product_id")
|
||||||
|
if v := strings.TrimSpace(c.Query("orderSn")); v != "" {
|
||||||
|
base = base.Where("p.order_sn LIKE ?", "%"+v+"%")
|
||||||
|
}
|
||||||
|
if v := strings.TrimSpace(c.Query("trackingNo")); v != "" {
|
||||||
|
base = base.Where("p.tracking_no LIKE ?", "%"+v+"%")
|
||||||
|
}
|
||||||
|
// #337 #1: 店铺 filter switched from free-text partial match to an exact
|
||||||
|
// match against p.shop_name, matching the dropdown surfaced by /shops.
|
||||||
|
if v := strings.TrimSpace(c.Query("shop")); v != "" {
|
||||||
|
base = base.Where("p.shop_name = ?", v)
|
||||||
|
}
|
||||||
|
claimStatus := strings.TrimSpace(c.Query("claimStatus"))
|
||||||
|
switch claimStatus {
|
||||||
|
case "":
|
||||||
|
// no filter
|
||||||
|
case "unknown":
|
||||||
|
base = base.Where("p.status_unrecognized = ?", true)
|
||||||
|
default:
|
||||||
|
base = base.Where("p.claim_status = ? AND p.status_unrecognized = ?", claimStatus, false)
|
||||||
|
}
|
||||||
|
if v := strings.TrimSpace(c.Query("createTimeFrom")); v != "" {
|
||||||
|
t, perr := parseFilterTime(v)
|
||||||
|
if perr != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "createTimeFrom 格式无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
base = base.Where("p.create_time >= ?", t)
|
||||||
|
}
|
||||||
|
if v := strings.TrimSpace(c.Query("createTimeTo")); v != "" {
|
||||||
|
t, perr := parseFilterTime(v)
|
||||||
|
if perr != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "createTimeTo 格式无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
base = base.Where("p.create_time <= ?", t)
|
||||||
|
}
|
||||||
|
// itemId/variationId kept for backward compatibility (#337): now filter
|
||||||
|
// the item-level join directly instead of a package sub-select.
|
||||||
|
if v := strings.TrimSpace(c.Query("itemId")); v != "" {
|
||||||
|
base = base.Where("i.item_id = ?", v)
|
||||||
|
}
|
||||||
|
if v := strings.TrimSpace(c.Query("variationId")); v != "" {
|
||||||
|
base = base.Where("i.variation_id = ?", v)
|
||||||
|
}
|
||||||
|
switch matchStatus := strings.TrimSpace(c.Query("matchStatus")); matchStatus {
|
||||||
|
case "":
|
||||||
|
// no filter
|
||||||
|
case ReturnMatchFilterUnmatched:
|
||||||
|
base = base.Where("m.id IS NULL")
|
||||||
|
case ReturnMatchFilterMatched:
|
||||||
|
base = base.Where("m.status = ?", models.ReturnMatchStatusMatched)
|
||||||
|
case ReturnMatchFilterConfirmed:
|
||||||
|
base = base.Where("m.status = ?", models.ReturnMatchStatusConfirmed)
|
||||||
|
default:
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "matchStatus 无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var total int64
|
||||||
|
if err := base.Session(&gorm.Session{}).Count(&total).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows []itemRow
|
||||||
|
q := base.Session(&gorm.Session{}).Select(
|
||||||
|
"p.id as package_id, p.order_sn, p.shop_name, p.ware_house, p.ware_code, p.claim_status, " +
|
||||||
|
"p.status_unrecognized, p.claim_time, p.destroy_dead_line as destroy_dead_line, p.last_synced_at as package_last_synced, " +
|
||||||
|
"i.id as item_id, i.item_id as item_external_item, i.variation_id as item_variation_id, i.item_name as item_name, " +
|
||||||
|
"i.variation_name as item_variation_name, i.image as item_image, i.quantity as item_quantity, i.last_synced_at as item_last_synced, " +
|
||||||
|
"i.sync_status as item_sync_status, i.missing_since as item_missing_since, " +
|
||||||
|
"p.sync_status as package_sync_status, p.missing_since as package_missing_since, " +
|
||||||
|
"m.id as match_id, m.status as match_status, sp.id as occupying_syb_product_id, sp.order_code as occupying_syb_order_code",
|
||||||
|
).Order("p.create_time desc, i.id asc").
|
||||||
|
Offset((page - 1) * pageSize).Limit(pageSize)
|
||||||
|
if err := q.Find(&rows).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// #338: one bounded ProcessStages call for the distinct occupying SYB
|
||||||
|
// product ids on this page (never per-row), so the 「被占用 SYB 订单
|
||||||
|
// 商品」 column can also show its current stage.
|
||||||
|
sybIDs := make([]uint64, 0, len(rows))
|
||||||
|
seenSYB := make(map[uint64]bool, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
if r.OccupyingSYBProductID != nil && !seenSYB[*r.OccupyingSYBProductID] {
|
||||||
|
seenSYB[*r.OccupyingSYBProductID] = true
|
||||||
|
sybIDs = append(sybIDs, *r.OccupyingSYBProductID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var stages map[uint64]purchase.ProcessStageResult
|
||||||
|
if len(sybIDs) > 0 {
|
||||||
|
var stageErr error
|
||||||
|
stages, stageErr = purchase.NewService(db).ProcessStages(c.Request.Context(), sybIDs)
|
||||||
|
if stageErr != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]ReturnItemRowDTO, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
dto := toItemRowDTO(r)
|
||||||
|
if r.OccupyingSYBProductID != nil {
|
||||||
|
if stage, ok := stages[*r.OccupyingSYBProductID]; ok {
|
||||||
|
dto.OccupyingSYBStage = stage.Stage
|
||||||
|
dto.OccupyingSYBStageLabel = stage.Label
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items = append(items, dto)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{
|
||||||
|
"items": items, "total": total, "page": page, "pageSize": pageSize,
|
||||||
|
}})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListReturnShops returns the distinct, non-empty shop names known from
|
||||||
|
// yeeke_return_package, sorted ascending, for the #337 shop-name dropdown
|
||||||
|
// filter. It must be registered before the /:packageId wildcard route so it
|
||||||
|
// is never swallowed by it.
|
||||||
|
func (h Handler) ListReturnShops(c *gin.Context) {
|
||||||
|
db, ok := h.db(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var shops []string
|
||||||
|
if err := db.Model(&models.YeekeReturnPackage{}).
|
||||||
|
Where("shop_name <> ''").
|
||||||
|
Distinct("shop_name").
|
||||||
|
Order("shop_name asc").
|
||||||
|
Pluck("shop_name", &shops).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sort.Strings(shops)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{"shops": shops}})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReturnItemDetail returns one item, its parent package, and the sibling
|
||||||
|
// items of the same package (excluding itself), for the #337 "退货商品详情"
|
||||||
|
// drawer. It must be registered before the /:packageId wildcard route so
|
||||||
|
// /yeeke-returns/items/:itemId is never swallowed by it.
|
||||||
|
func (h Handler) ReturnItemDetail(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("itemId"), 10, 64)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "itemId 无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db, ok := h.db(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var item models.YeekeReturnItem
|
||||||
|
if err := db.First(&item, id).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"code": "NOT_FOUND", "message": "退货商品不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var pkg models.YeekeReturnPackage
|
||||||
|
if err := db.First(&pkg, item.PackageID).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"code": "NOT_FOUND", "message": "所属退货包裹不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var siblingRows []models.YeekeReturnItem
|
||||||
|
if err := db.Where("package_id = ? AND id <> ?", item.PackageID, item.ID).Order("id asc").Find(&siblingRows).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
siblings := make([]ReturnItemDTO, 0, len(siblingRows))
|
||||||
|
for _, s := range siblingRows {
|
||||||
|
siblings = append(siblings, toItemDTO(s))
|
||||||
|
}
|
||||||
|
pkgDTO := toPackageDTO(pkg, len(siblingRows)+1)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{
|
||||||
|
"item": toItemDTO(item), "package": pkgDTO, "siblings": siblings,
|
||||||
|
}})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReturnPackageDetail returns one package with its items.
|
||||||
|
func (h Handler) ReturnPackageDetail(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("packageId"), 10, 64)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "packageId 无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db, ok := h.db(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var row models.YeekeReturnPackage
|
||||||
|
if err := db.First(&row, id).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"code": "NOT_FOUND", "message": "退货包裹不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var itemRows []models.YeekeReturnItem
|
||||||
|
if err := db.Where("package_id = ?", row.ID).Order("id asc").Find(&itemRows).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dto := toPackageDTO(row, len(itemRows))
|
||||||
|
dto.Items = make([]ReturnItemDTO, 0, len(itemRows))
|
||||||
|
for _, i := range itemRows {
|
||||||
|
dto.Items = append(dto.Items, toItemDTO(i))
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{"item": dto}})
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package yeeke
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go-admin/app/goauto/models"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestListReturnItems_MatchStatusFilterAndOccupyingColumn is the #338 web-UI
|
||||||
|
// support test: the yeeke returns list must be able to filter by match
|
||||||
|
// status and show which SYB order product occupies a matched return, using
|
||||||
|
// the single bounded LEFT JOIN added to ListReturnPackages (no N+1).
|
||||||
|
func TestListReturnItems_MatchStatusFilterAndOccupyingColumn(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
db := testDB(t)
|
||||||
|
now := time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC)
|
||||||
|
deadline := now.Add(30 * 24 * time.Hour)
|
||||||
|
|
||||||
|
pkg := models.YeekeReturnPackage{ExternalID: "pkg-m1", OrderSN: "ORD-M1", TrackingNo: "T1", DestroyDeadLine: &deadline, LastSyncedAt: now, CreateTime: &now}
|
||||||
|
if err := db.Create(&pkg).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
matchedItem := models.YeekeReturnItem{PackageID: pkg.ID, ExternalKey: "k-matched", ItemID: "100", VariationName: "白色,L", LastSyncedAt: now}
|
||||||
|
unmatchedItem := models.YeekeReturnItem{PackageID: pkg.ID, ExternalKey: "k-unmatched", ItemID: "100", VariationName: "黑色,M", LastSyncedAt: now}
|
||||||
|
if err := db.Create(&matchedItem).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&unmatchedItem).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
syb := models.SYBProduct{OrderCode: "SYB-OCC-1", DetailID: 1, StockID: 1, ShopeeItemID: "100", TargetColor: "白色", TargetSize: "L", Quantity: 1, UnitPriceCent: 100, ParseStatus: models.SYBParseStatusSuccess, RawJSON: "{}"}
|
||||||
|
if err := db.Create(&syb).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
match := models.ReturnMatch{
|
||||||
|
SYBProductID: syb.ID, YeekeReturnItemID: matchedItem.ID,
|
||||||
|
ActiveSYBProductID: &syb.ID, ActiveYeekeReturnItemID: &matchedItem.ID,
|
||||||
|
Status: models.ReturnMatchStatusMatched, MatchedAt: now,
|
||||||
|
}
|
||||||
|
if err := db.Create(&match).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := Handler{DB: db}
|
||||||
|
engine := gin.New()
|
||||||
|
engine.GET("/api/admin/v1/yeeke-returns", handler.ListReturnPackages)
|
||||||
|
do := func(req *http.Request) *httptest.ResponseRecorder {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
engine.ServeHTTP(w, req)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
// No filter: both rows present, matched row carries occupying info.
|
||||||
|
w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns", nil))
|
||||||
|
var resp itemRowListResp
|
||||||
|
decodeJSON(t, w.Body.Bytes(), &resp)
|
||||||
|
if resp.Data.Total != 2 {
|
||||||
|
t.Fatalf("expected 2 rows, got %+v", resp.Data)
|
||||||
|
}
|
||||||
|
byKey := map[string]ReturnItemRowDTO{}
|
||||||
|
for _, item := range resp.Data.Items {
|
||||||
|
byKey[item.Key] = item
|
||||||
|
}
|
||||||
|
matchedDTO := byKey[strconv.FormatUint(matchedItem.ID, 10)]
|
||||||
|
if matchedDTO.MatchStatus != ReturnMatchFilterMatched {
|
||||||
|
t.Fatalf("expected matched status: %+v", matchedDTO)
|
||||||
|
}
|
||||||
|
if matchedDTO.OccupyingSYBOrderCode != "SYB-OCC-1" || matchedDTO.OccupyingSYBProductID != syb.ID {
|
||||||
|
t.Fatalf("expected occupying SYB info: %+v", matchedDTO)
|
||||||
|
}
|
||||||
|
if matchedDTO.OccupyingSYBStageLabel == "" {
|
||||||
|
t.Fatalf("expected occupying SYB stage label to be filled: %+v", matchedDTO)
|
||||||
|
}
|
||||||
|
unmatchedDTO := byKey[strconv.FormatUint(unmatchedItem.ID, 10)]
|
||||||
|
if unmatchedDTO.MatchStatus != ReturnMatchFilterUnmatched || unmatchedDTO.OccupyingSYBProductID != 0 {
|
||||||
|
t.Fatalf("expected unmatched status with no occupying info: %+v", unmatchedDTO)
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchStatus=unmatched filters down to just the unmatched row.
|
||||||
|
w = do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns?matchStatus=unmatched", nil))
|
||||||
|
decodeJSON(t, w.Body.Bytes(), &resp)
|
||||||
|
if resp.Data.Total != 1 || len(resp.Data.Items) != 1 || resp.Data.Items[0].Key != strconv.FormatUint(unmatchedItem.ID, 10) {
|
||||||
|
t.Fatalf("expected only the unmatched row: %+v", resp.Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchStatus=matched filters down to just the matched row.
|
||||||
|
w = do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns?matchStatus=matched", nil))
|
||||||
|
decodeJSON(t, w.Body.Bytes(), &resp)
|
||||||
|
if resp.Data.Total != 1 || len(resp.Data.Items) != 1 || resp.Data.Items[0].Key != strconv.FormatUint(matchedItem.ID, 10) {
|
||||||
|
t.Fatalf("expected only the matched row: %+v", resp.Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalid value is rejected, not silently ignored.
|
||||||
|
w = do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns?matchStatus=bogus", nil))
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected 400 for invalid matchStatus, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeJSON(t *testing.T, body []byte, v any) {
|
||||||
|
t.Helper()
|
||||||
|
if err := json.Unmarshal(body, v); err != nil {
|
||||||
|
t.Fatalf("decode response: %v (%s)", err, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,439 @@
|
|||||||
|
package yeeke
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go-admin/app/goauto/models"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupListTestDB(t *testing.T) (*gin.Engine, func(req *http.Request) *httptest.ResponseRecorder) {
|
||||||
|
t.Helper()
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
db := testDB(t)
|
||||||
|
|
||||||
|
now := time.Date(2026, 9, 20, 10, 0, 0, 0, time.UTC)
|
||||||
|
older := now.Add(-48 * time.Hour)
|
||||||
|
if err := db.Create(&models.YeekeReturnPackage{
|
||||||
|
ExternalID: "p1", OrderSN: "ORDER-1", TrackingNo: "TRACK-1", ShopID: "s1", ShopName: "店铺一",
|
||||||
|
ClaimStatus: "claimed", StatusUnrecognized: false, CreateTime: &now, LastSyncedAt: now, SyncStatus: "ok",
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&models.YeekeReturnPackage{
|
||||||
|
ExternalID: "p2", OrderSN: "ORDER-2", TrackingNo: "TRACK-2", ShopID: "s2", ShopName: "店铺二",
|
||||||
|
ClaimStatus: "weird_status", StatusUnrecognized: true, CreateTime: &older, LastSyncedAt: older, SyncStatus: "ok",
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// p3 has no items at all: must still produce exactly one placeholder row.
|
||||||
|
if err := db.Create(&models.YeekeReturnPackage{
|
||||||
|
ExternalID: "p3", OrderSN: "ORDER-3", TrackingNo: "TRACK-3", ShopID: "s3", ShopName: "店铺三",
|
||||||
|
ClaimStatus: "pending", StatusUnrecognized: false, CreateTime: &older, LastSyncedAt: older, SyncStatus: "ok",
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var pkg1, pkg2 models.YeekeReturnPackage
|
||||||
|
db.Where("external_id = ?", "p1").First(&pkg1)
|
||||||
|
db.Where("external_id = ?", "p2").First(&pkg2)
|
||||||
|
|
||||||
|
if err := db.Create(&models.YeekeReturnItem{
|
||||||
|
PackageID: pkg1.ID, ExternalKey: "k1", ItemID: "item1", VariationID: "v1", ItemName: "商品1", VariationName: "红色",
|
||||||
|
Image: "https://cdn.example.com/a.jpg", Quantity: 2, LastSyncedAt: now, SyncStatus: "ok",
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&models.YeekeReturnItem{
|
||||||
|
PackageID: pkg1.ID, ExternalKey: "k2", ItemID: "item1", VariationID: "v2", ItemName: "商品1", VariationName: "蓝色",
|
||||||
|
Image: "https://cdn.example.com/b.jpg", Quantity: 1, LastSyncedAt: now, SyncStatus: "ok",
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&models.YeekeReturnItem{
|
||||||
|
PackageID: pkg2.ID, ExternalKey: "k3", ItemID: "item2", VariationID: "v3", ItemName: "商品2", VariationName: "默认",
|
||||||
|
Image: "https://cdn.example.com/c.jpg", Quantity: 3, LastSyncedAt: older, SyncStatus: "ok",
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Create(&models.YeekeSyncRun{
|
||||||
|
Status: "succeeded", Trigger: "manual", StartedAt: now,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&models.YeekeSyncRun{
|
||||||
|
Status: "failed", Trigger: "scheduled", StartedAt: older,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := Handler{DB: db}
|
||||||
|
engine := gin.New()
|
||||||
|
engine.GET("/api/admin/v1/yeeke-returns", handler.ListReturnPackages)
|
||||||
|
engine.GET("/api/admin/v1/yeeke-returns/shops", handler.ListReturnShops)
|
||||||
|
engine.GET("/api/admin/v1/yeeke-returns/items/:itemId", handler.ReturnItemDetail)
|
||||||
|
engine.GET("/api/admin/v1/yeeke-returns/:packageId", handler.ReturnPackageDetail)
|
||||||
|
engine.GET("/api/admin/v1/yeeke-returns/sync-runs", handler.ListSyncRuns)
|
||||||
|
|
||||||
|
do := func(req *http.Request) *httptest.ResponseRecorder {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
engine.ServeHTTP(w, req)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
return engine, do
|
||||||
|
}
|
||||||
|
|
||||||
|
type itemRowListResp struct {
|
||||||
|
Data struct {
|
||||||
|
Items []ReturnItemRowDTO `json:"items"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListReturnItemsFiltersByOrderSn(t *testing.T) {
|
||||||
|
_, do := setupListTestDB(t)
|
||||||
|
w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns?orderSn=ORDER-1", nil))
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var resp itemRowListResp
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if resp.Data.Total != 2 || len(resp.Data.Items) != 2 {
|
||||||
|
t.Fatalf("expected 2 item rows for a 2-item package, got: %+v", resp.Data)
|
||||||
|
}
|
||||||
|
for _, row := range resp.Data.Items {
|
||||||
|
if row.OrderSN != "ORDER-1" || !row.HasItem {
|
||||||
|
t.Fatalf("unexpected row: %+v", row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListReturnItemsPlaceholderRowForItemlessPackage(t *testing.T) {
|
||||||
|
_, do := setupListTestDB(t)
|
||||||
|
w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns?orderSn=ORDER-3", nil))
|
||||||
|
var resp itemRowListResp
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if resp.Data.Total != 1 || len(resp.Data.Items) != 1 {
|
||||||
|
t.Fatalf("expected exactly one placeholder row, got: %+v", resp.Data)
|
||||||
|
}
|
||||||
|
row := resp.Data.Items[0]
|
||||||
|
if row.HasItem {
|
||||||
|
t.Fatalf("expected hasItem=false for item-less package, got: %+v", row)
|
||||||
|
}
|
||||||
|
if row.ItemID != 0 || row.ItemName != "" {
|
||||||
|
t.Fatalf("expected empty/zero item fields on placeholder row, got: %+v", row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListReturnItemsPaginationAndCountOverItemRows(t *testing.T) {
|
||||||
|
_, do := setupListTestDB(t)
|
||||||
|
// total rows across all packages: 2 (p1) + 1 (p2) + 1 placeholder (p3) = 4
|
||||||
|
w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns?pageSize=2&page=1", nil))
|
||||||
|
var resp itemRowListResp
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if resp.Data.Total != 4 {
|
||||||
|
t.Fatalf("expected total=4 item-level rows, got %d", resp.Data.Total)
|
||||||
|
}
|
||||||
|
if len(resp.Data.Items) != 2 {
|
||||||
|
t.Fatalf("expected page size 2 applied to item-level rows, got %d", len(resp.Data.Items))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListReturnItemsShopFilterIsExactMatch(t *testing.T) {
|
||||||
|
_, do := setupListTestDB(t)
|
||||||
|
w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns?shop=%E5%BA%97", nil)) // partial "店" must not match
|
||||||
|
var resp itemRowListResp
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if resp.Data.Total != 0 {
|
||||||
|
t.Fatalf("expected no matches for a partial shop name (exact match required), got %d", resp.Data.Total)
|
||||||
|
}
|
||||||
|
|
||||||
|
w2 := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns?shop=%E5%BA%97%E9%93%BA%E4%B8%80", nil)) // "店铺一"
|
||||||
|
var resp2 itemRowListResp
|
||||||
|
if err := json.Unmarshal(w2.Body.Bytes(), &resp2); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if resp2.Data.Total != 2 {
|
||||||
|
t.Fatalf("expected exact shop match to find package p1's 2 item rows, got %d", resp2.Data.Total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListReturnItemsUnknownClaimStatusFilter(t *testing.T) {
|
||||||
|
_, do := setupListTestDB(t)
|
||||||
|
w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns?claimStatus=unknown", nil))
|
||||||
|
var resp itemRowListResp
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if resp.Data.Total != 1 || resp.Data.Items[0].OrderSN != "ORDER-2" {
|
||||||
|
t.Fatalf("unexpected result: %+v", resp.Data)
|
||||||
|
}
|
||||||
|
if !resp.Data.Items[0].StatusUnrecognized {
|
||||||
|
t.Fatal("expected StatusUnrecognized=true, unknown status must be shown verbatim, not bucketed")
|
||||||
|
}
|
||||||
|
if resp.Data.Items[0].ClaimStatus != "weird_status" {
|
||||||
|
t.Fatalf("unknown claim status must be shown verbatim, got %q", resp.Data.Items[0].ClaimStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListReturnItemsFiltersByItemAndVariationBackwardCompat(t *testing.T) {
|
||||||
|
_, do := setupListTestDB(t)
|
||||||
|
w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns?itemId=item1&variationId=v2", nil))
|
||||||
|
var resp itemRowListResp
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if resp.Data.Total != 1 || resp.Data.Items[0].OrderSN != "ORDER-1" || resp.Data.Items[0].VariationID != "v2" {
|
||||||
|
t.Fatalf("unexpected result: %+v", resp.Data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListReturnItemsFiltersByCreateTimeRange(t *testing.T) {
|
||||||
|
_, do := setupListTestDB(t)
|
||||||
|
w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns?createTimeFrom=2026-09-19&createTimeTo=2026-09-21", nil))
|
||||||
|
var resp itemRowListResp
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if resp.Data.Total != 2 || resp.Data.Items[0].OrderSN != "ORDER-1" {
|
||||||
|
t.Fatalf("unexpected result: %+v", resp.Data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListReturnShopsReturnsDistinctSortedNames(t *testing.T) {
|
||||||
|
_, do := setupListTestDB(t)
|
||||||
|
w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns/shops", nil))
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var resp struct {
|
||||||
|
Data struct {
|
||||||
|
Shops []string `json:"shops"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := []string{"店铺一", "店铺三", "店铺二"}
|
||||||
|
if len(resp.Data.Shops) != len(want) {
|
||||||
|
t.Fatalf("expected %d distinct shops, got %+v", len(want), resp.Data.Shops)
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if resp.Data.Shops[i] != want[i] {
|
||||||
|
t.Fatalf("expected sorted shops %+v, got %+v", want, resp.Data.Shops)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReturnItemDetailReturnsSiblingsExcludingSelf(t *testing.T) {
|
||||||
|
_, do := setupListTestDB(t)
|
||||||
|
listW := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns?orderSn=ORDER-1", nil))
|
||||||
|
var listResp itemRowListResp
|
||||||
|
if err := json.Unmarshal(listW.Body.Bytes(), &listResp); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(listResp.Data.Items) != 2 {
|
||||||
|
t.Fatalf("expected 2 item rows to pick from, got %+v", listResp.Data.Items)
|
||||||
|
}
|
||||||
|
target := listResp.Data.Items[0]
|
||||||
|
|
||||||
|
w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns/items/"+itoa(target.ItemID), nil))
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var resp struct {
|
||||||
|
Data struct {
|
||||||
|
Item ReturnItemDTO `json:"item"`
|
||||||
|
Package ReturnPackageDTO `json:"package"`
|
||||||
|
Siblings []ReturnItemDTO `json:"siblings"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if resp.Data.Item.ID != target.ItemID {
|
||||||
|
t.Fatalf("expected item id %d, got %d", target.ItemID, resp.Data.Item.ID)
|
||||||
|
}
|
||||||
|
if resp.Data.Package.OrderSN != "ORDER-1" {
|
||||||
|
t.Fatalf("expected parent package ORDER-1, got %+v", resp.Data.Package)
|
||||||
|
}
|
||||||
|
if len(resp.Data.Siblings) != 1 {
|
||||||
|
t.Fatalf("expected exactly 1 sibling (the other item in the same package), got %+v", resp.Data.Siblings)
|
||||||
|
}
|
||||||
|
if resp.Data.Siblings[0].ID == resp.Data.Item.ID {
|
||||||
|
t.Fatal("siblings must exclude the current item")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReturnItemDetailNotFound(t *testing.T) {
|
||||||
|
_, do := setupListTestDB(t)
|
||||||
|
w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns/items/999999", nil))
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestYeekeReturnsRoutesDoNotCollide(t *testing.T) {
|
||||||
|
// Routing test proving /yeeke-returns/shops and /yeeke-returns/items/:id
|
||||||
|
// are not swallowed by the /yeeke-returns/:packageId wildcard route.
|
||||||
|
_, do := setupListTestDB(t)
|
||||||
|
|
||||||
|
w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns/shops", nil))
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("/shops should hit ListReturnShops, got status=%d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var shopsResp struct {
|
||||||
|
Data struct {
|
||||||
|
Shops []string `json:"shops"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &shopsResp); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(shopsResp.Data.Shops) == 0 {
|
||||||
|
t.Fatal("/shops must hit ListReturnShops, not ReturnPackageDetail (which would 400/404 on a non-numeric id)")
|
||||||
|
}
|
||||||
|
|
||||||
|
listW := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns?orderSn=ORDER-1", nil))
|
||||||
|
var listResp itemRowListResp
|
||||||
|
if err := json.Unmarshal(listW.Body.Bytes(), &listResp); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
itemID := listResp.Data.Items[0].ItemID
|
||||||
|
|
||||||
|
w2 := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns/items/"+itoa(itemID), nil))
|
||||||
|
if w2.Code != http.StatusOK {
|
||||||
|
t.Fatalf("/items/:itemId should hit ReturnItemDetail, got status=%d body=%s", w2.Code, w2.Body.String())
|
||||||
|
}
|
||||||
|
var itemResp struct {
|
||||||
|
Data struct {
|
||||||
|
Item ReturnItemDTO `json:"item"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w2.Body.Bytes(), &itemResp); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if itemResp.Data.Item.ID != itemID {
|
||||||
|
t.Fatalf("/items/:itemId must hit ReturnItemDetail, got item=%+v", itemResp.Data.Item)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The plain numeric package-id route must still work unchanged.
|
||||||
|
w3 := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns/999999", nil))
|
||||||
|
if w3.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("/:packageId route must still work for a numeric id, got status=%d", w3.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReturnPackageDetailIncludesSeparateItemRows(t *testing.T) {
|
||||||
|
_, do := setupListTestDB(t)
|
||||||
|
listW := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns?orderSn=ORDER-1", nil))
|
||||||
|
var listResp itemRowListResp
|
||||||
|
if err := json.Unmarshal(listW.Body.Bytes(), &listResp); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
id := listResp.Data.Items[0].PackageID
|
||||||
|
|
||||||
|
w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns/"+itoa(id), nil))
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var resp struct {
|
||||||
|
Data struct {
|
||||||
|
Item ReturnPackageDTO `json:"item"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(resp.Data.Item.Items) != 2 {
|
||||||
|
t.Fatalf("expected 2 separate item rows for multiple variations, got %d", len(resp.Data.Item.Items))
|
||||||
|
}
|
||||||
|
variations := map[string]bool{}
|
||||||
|
for _, item := range resp.Data.Item.Items {
|
||||||
|
variations[item.VariationID] = true
|
||||||
|
}
|
||||||
|
if !variations["v1"] || !variations["v2"] {
|
||||||
|
t.Fatalf("expected both variations present as separate rows: %+v", resp.Data.Item.Items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReturnPackageDetailNotFound(t *testing.T) {
|
||||||
|
_, do := setupListTestDB(t)
|
||||||
|
w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns/999999", nil))
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListSyncRunsFiltersByTriggerStatusAndStartedRange(t *testing.T) {
|
||||||
|
_, do := setupListTestDB(t)
|
||||||
|
w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns/sync-runs?trigger=manual&status=succeeded", nil))
|
||||||
|
var resp struct {
|
||||||
|
Data struct {
|
||||||
|
Items []SyncRunDTO `json:"items"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if resp.Data.Total != 1 || resp.Data.Items[0].Trigger != "manual" || resp.Data.Items[0].Status != "succeeded" {
|
||||||
|
t.Fatalf("unexpected result: %+v", resp.Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
w2 := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns/sync-runs?startedFrom=2026-09-19&startedTo=2026-09-21", nil))
|
||||||
|
var resp2 struct {
|
||||||
|
Data struct {
|
||||||
|
Items []SyncRunDTO `json:"items"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w2.Body.Bytes(), &resp2); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if resp2.Data.Total != 1 || resp2.Data.Items[0].Trigger != "manual" {
|
||||||
|
t.Fatalf("unexpected started-range result: %+v", resp2.Data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListSyncRunsWithNoFiltersStaysBackwardCompatible(t *testing.T) {
|
||||||
|
_, do := setupListTestDB(t)
|
||||||
|
w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns/sync-runs", nil))
|
||||||
|
var resp struct {
|
||||||
|
Data struct {
|
||||||
|
Items []SyncRunDTO `json:"items"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if resp.Data.Total != 2 {
|
||||||
|
t.Fatalf("expected both runs with no filters, got total=%d", resp.Data.Total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func itoa(v uint64) string {
|
||||||
|
if v == 0 {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
digits := []byte{}
|
||||||
|
for v > 0 {
|
||||||
|
digits = append([]byte{byte('0' + v%10)}, digits...)
|
||||||
|
v /= 10
|
||||||
|
}
|
||||||
|
return string(digits)
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package yeeke
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// InitRouter mounts the yeeke read-only admin surface (#336): manual sync
|
||||||
|
// trigger plus sync run history/summary. Every route here only reads from or
|
||||||
|
// writes to GoAuto's own database and, for the trigger, starts a read-only
|
||||||
|
// yeeke sync — no yeeke write endpoint is ever called.
|
||||||
|
//
|
||||||
|
// `[必须]` These routes are authenticated but intentionally not Casbin-gated
|
||||||
|
// (middleware.AuthCheckRole), same as sybimport's /sync-runs: #336 requires
|
||||||
|
// both admin and purchaser to see the summary, and TriggerSync does its own
|
||||||
|
// admin/purchaser role check inline (mirroring sybimport.Handler.Import).
|
||||||
|
func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) {
|
||||||
|
handler := Handler{}
|
||||||
|
group := engine.Group("/api/admin/v1/yeeke-returns").Use(auth.MiddlewareFunc())
|
||||||
|
group.GET("", handler.ListReturnPackages)
|
||||||
|
// #337: static-prefixed routes must be registered before the
|
||||||
|
// /:packageId wildcard route below so they are never swallowed by it.
|
||||||
|
group.GET("/shops", handler.ListReturnShops)
|
||||||
|
group.GET("/items/:itemId", handler.ReturnItemDetail)
|
||||||
|
group.GET("/:packageId", handler.ReturnPackageDetail)
|
||||||
|
group.GET("/sync-runs", handler.ListSyncRuns)
|
||||||
|
group.GET("/sync-runs/:runId", handler.SyncRunDetail)
|
||||||
|
group.POST("/sync", handler.TriggerSync)
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package yeeke
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go-admin/app/goauto/sybclient"
|
||||||
|
"go-admin/app/goauto/yeekeclient"
|
||||||
|
"go-admin/config"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// syncTimeout bounds one sync run. It exists so a stalled yeeke response
|
||||||
|
// cannot pin a background goroutine forever.
|
||||||
|
const syncTimeout = 30 * time.Minute
|
||||||
|
|
||||||
|
// syncGate makes sync runs mutually exclusive within this process, same
|
||||||
|
// reasoning as sybimport.importGate: manual trigger and the scheduled job
|
||||||
|
// must never run concurrently (#336 requirement #5). The database-level
|
||||||
|
// unique active_slot column on yeeke_sync_run is the durable backstop (it
|
||||||
|
// also covers the unlikely case of two processes sharing one database); this
|
||||||
|
// in-memory gate exists to fail fast with a clear message before even
|
||||||
|
// touching the network or the OCR service.
|
||||||
|
var syncGate = struct {
|
||||||
|
sync.Mutex
|
||||||
|
running bool
|
||||||
|
}{}
|
||||||
|
|
||||||
|
// ErrAlreadyRunning is returned by StartSync when a sync is already in
|
||||||
|
// progress, whether it was started manually or by the scheduled job.
|
||||||
|
var ErrAlreadyRunning = errors.New("已有 yeeke 退货包裹同步正在执行,请等它结束后再试")
|
||||||
|
|
||||||
|
// OCR is satisfied by sybclient.OcrClient; yeeke reuses the same self-hosted
|
||||||
|
// captcha OCR service already approved for SYB (#336).
|
||||||
|
type OCR = yeekeclient.OCR
|
||||||
|
|
||||||
|
// StartResult reports what StartSync did without exposing any run internals
|
||||||
|
// that should not cross the HTTP boundary.
|
||||||
|
type StartResult struct {
|
||||||
|
RunID uint64
|
||||||
|
Skipped bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartSync is the single entry point shared by the authenticated admin
|
||||||
|
// handler and the scheduled job, so a scheduled run can never bypass the same
|
||||||
|
// concurrency guard as a manual run.
|
||||||
|
//
|
||||||
|
// `[必须]` This only ever reads from yeeke. Credentials are resolved by the
|
||||||
|
// caller (config.ExtConfig.Yeeke) and never logged here.
|
||||||
|
func StartSync(ctx context.Context, db *gorm.DB, ocr OCR, trigger string, skipIfRunning bool) (StartResult, error) {
|
||||||
|
settings := config.ExtConfig.Yeeke.Resolved()
|
||||||
|
if !settings.HasCredentials() {
|
||||||
|
return StartResult{}, fmt.Errorf("yeeke 账号未配置:请设置 GOAUTO_YEEKE_USERNAME / GOAUTO_YEEKE_PASSWORD,或在 config.yaml 的 yeeke 段填写 username/password,然后重启服务端")
|
||||||
|
}
|
||||||
|
if ocr == nil {
|
||||||
|
if strings.TrimSpace(settings.OcrURL) == "" {
|
||||||
|
return StartResult{}, fmt.Errorf("yeeke 验证码识别服务未配置:请设置 extend.yeeke.ocrurl")
|
||||||
|
}
|
||||||
|
// Reuse SYB's OcrClient implementation as-is (#336): independent
|
||||||
|
// http.Client, no shared cookie jar, captcha bytes stay in memory. See
|
||||||
|
// sybclient/ocr.go's package comment for why that isolation matters.
|
||||||
|
client, err := sybclient.NewOcrClient(settings.OcrURL, 0)
|
||||||
|
if err != nil {
|
||||||
|
return StartResult{}, err
|
||||||
|
}
|
||||||
|
ocr = client
|
||||||
|
}
|
||||||
|
|
||||||
|
syncGate.Lock()
|
||||||
|
if syncGate.running {
|
||||||
|
syncGate.Unlock()
|
||||||
|
if skipIfRunning {
|
||||||
|
return StartResult{Skipped: true}, nil
|
||||||
|
}
|
||||||
|
return StartResult{}, ErrAlreadyRunning
|
||||||
|
}
|
||||||
|
syncGate.running = true
|
||||||
|
syncGate.Unlock()
|
||||||
|
release := func() {
|
||||||
|
syncGate.Lock()
|
||||||
|
syncGate.running = false
|
||||||
|
syncGate.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := yeekeclient.Connect(ctx, yeekeclient.NewSessionStore(db), yeekeclient.Credentials{
|
||||||
|
Username: settings.Username, Password: settings.Password,
|
||||||
|
}, settings.BaseURL, ocr, settings.OcrMaxAttempts)
|
||||||
|
if err != nil {
|
||||||
|
release()
|
||||||
|
return StartResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := NewService(db, client, Config{PageSize: settings.PageSize, MaxPages: settings.MaxPages, Retry: settings.Retry})
|
||||||
|
// SyncAsync acquires the DB lease synchronously (so the caller gets a run
|
||||||
|
// id right away and the active_slot lease is held before this function
|
||||||
|
// returns) then walks pages in the background, bound to its own timeout
|
||||||
|
// independent of the HTTP request context. syncGate is released once that
|
||||||
|
// background walk finishes, not when this function returns.
|
||||||
|
runID, err := svc.SyncAsync(ctx, trigger, release)
|
||||||
|
if err != nil {
|
||||||
|
release()
|
||||||
|
return StartResult{}, err
|
||||||
|
}
|
||||||
|
return StartResult{RunID: runID}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,437 @@
|
|||||||
|
package yeeke
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"go-admin/app/goauto/models"
|
||||||
|
"go-admin/app/goauto/yeekeclient"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
PageSize, MaxPages, Retry int
|
||||||
|
Lease time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Config) norm() Config {
|
||||||
|
if c.PageSize <= 0 || c.PageSize > 500 {
|
||||||
|
c.PageSize = 100
|
||||||
|
}
|
||||||
|
if c.MaxPages <= 0 || c.MaxPages > 10000 {
|
||||||
|
c.MaxPages = 10000
|
||||||
|
}
|
||||||
|
if c.Retry < 0 || c.Retry > 5 {
|
||||||
|
c.Retry = 2
|
||||||
|
}
|
||||||
|
if c.Lease <= 0 {
|
||||||
|
c.Lease = 30 * time.Minute
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
type Report struct {
|
||||||
|
RunID uint64
|
||||||
|
TotalPages, Read, Created, Updated, Skipped, Failed int
|
||||||
|
Status string
|
||||||
|
// MissingMarked is the number of yeeke_return_item rows flipped from
|
||||||
|
// "ok" to "missing" by this run's completion (#338). It is only ever
|
||||||
|
// non-zero on a run whose page walk finished naturally, wrote no
|
||||||
|
// failures, and stayed under the 20% safety-valve threshold. Persisted
|
||||||
|
// as yeeke_sync_run.missing_marked_count; when the safety valve skips
|
||||||
|
// marking it stays 0 and the reason goes to error_message.
|
||||||
|
MissingMarked int
|
||||||
|
// Recovered is the number of yeeke_return_item rows that were "missing"
|
||||||
|
// before this run and reappeared in it, flipping back to "ok" (#338).
|
||||||
|
// Persisted as yeeke_sync_run.recovered_count.
|
||||||
|
Recovered int
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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}
|
||||||
|
|
||||||
|
func external(v any) string { return fmt.Sprint(v) }
|
||||||
|
func stamp(t *yeekeclient.Timestamp) *time.Time {
|
||||||
|
if t == nil || t.IsZero() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
x := t.Time
|
||||||
|
return &x
|
||||||
|
}
|
||||||
|
func packageKey(p yeekeclient.ReturnPackage) string {
|
||||||
|
if x := external(p.ID); x != "<nil>" && x != "" {
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
return p.Ordersn + "/" + p.TrackingNo + "/" + external(p.ShopID) + "/" + p.CreateTime.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// itemKey builds the stable per-item identity used to upsert
|
||||||
|
// models.YeekeReturnItem without duplicating rows across syncs. It prefers
|
||||||
|
// the yeeke-issued identifiers (i.ID, then i.ItemID/i.VariationID) because
|
||||||
|
// those stay the same regardless of the order the API returns items in
|
||||||
|
// within a package; the positional index n is only used as a last resort
|
||||||
|
// when none of those identifiers are present, since in that case the index
|
||||||
|
// is the sole thing distinguishing items of the same package (#336).
|
||||||
|
func itemKey(p yeekeclient.ReturnPackage, i yeekeclient.ReturnItem, n int) string {
|
||||||
|
id, itemID, variationID := external(i.ID), external(i.ItemID), external(i.VariationID)
|
||||||
|
if isEmptyExternal(id) && isEmptyExternal(itemID) && isEmptyExternal(variationID) {
|
||||||
|
return packageKey(p) + "/" + id + "/" + itemID + "/" + variationID + "/" + strconv.Itoa(n)
|
||||||
|
}
|
||||||
|
return packageKey(p) + "/" + id + "/" + itemID + "/" + variationID
|
||||||
|
}
|
||||||
|
|
||||||
|
// isEmptyExternal reports whether external() produced a value that carries
|
||||||
|
// no real identity: either the field was unset (formatted as "<nil>" by
|
||||||
|
// fmt.Sprint on a nil/zero value) or it was an explicit empty string.
|
||||||
|
func isEmptyExternal(v string) bool { return v == "" || v == "<nil>" }
|
||||||
|
|
||||||
|
// takeoverStaleLease reclaims a run whose lease has expired, e.g. because the
|
||||||
|
// process crashed or was restarted mid-sync. It matches the
|
||||||
|
// lease-with-expiry-takeover idiom used by
|
||||||
|
// app/goauto/purchase/order_writeback_worker.go: a single conditional UPDATE
|
||||||
|
// guarded by "status = running AND lease_expires_at <= now" flips the stale
|
||||||
|
// row to a terminal status and frees active_slot in one statement, so it is
|
||||||
|
// atomic without a separate row lock. The stale row is never deleted — it is
|
||||||
|
// left in place with status "failed" and an error_message explaining why, so
|
||||||
|
// history stays auditable. If two callers race this same UPDATE, only the
|
||||||
|
// first to reach the database actually changes any row; the second's WHERE
|
||||||
|
// clause no longer matches (status is no longer "running") and it affects
|
||||||
|
// zero rows, which is a harmless no-op. Whichever caller then wins the
|
||||||
|
// subsequent Create (see acquire) is arbitrated by the ux_yeeke_sync_run_active_slot
|
||||||
|
// unique index, exactly as it already is for two brand-new concurrent runs.
|
||||||
|
func (s *Service) takeoverStaleLease(ctx context.Context) error {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
return s.db.WithContext(ctx).Model(&models.YeekeSyncRun{}).
|
||||||
|
Where("status = ? AND active_slot = ? AND lease_expires_at IS NOT NULL AND lease_expires_at <= ?", "running", 1, now).
|
||||||
|
Updates(map[string]any{
|
||||||
|
"status": "failed",
|
||||||
|
"active_slot": nil,
|
||||||
|
"lease_owner": "",
|
||||||
|
"error_message": "lease expired: run interrupted, likely a process restart mid-sync (stale lease takeover)",
|
||||||
|
"lease_expires_at": nil,
|
||||||
|
"finished_at": now,
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) acquire(ctx context.Context, trigger string) (*models.YeekeSyncRun, error) {
|
||||||
|
if e := s.takeoverStaleLease(ctx); e != nil {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
owner := fmt.Sprintf("%d", now.UnixNano())
|
||||||
|
slot := uint8(1)
|
||||||
|
exp := now.Add(s.cfg.Lease)
|
||||||
|
r := &models.YeekeSyncRun{Status: "running", Trigger: trigger, StartedAt: now, ActiveSlot: &slot, LeaseOwner: owner, LeaseExpiresAt: &exp}
|
||||||
|
if e := s.db.WithContext(ctx).Create(r).Error; e != nil {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
db *gorm.DB
|
||||||
|
client *yeekeclient.Client
|
||||||
|
cfg Config
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(db *gorm.DB, c *yeekeclient.Client, cfg Config) *Service {
|
||||||
|
return &Service{db: db, client: c, cfg: cfg.norm()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync acquires the shared lease, runs the page walk synchronously and
|
||||||
|
// returns the final report. Tests use this directly; StartSync (start.go)
|
||||||
|
// uses SyncAsync instead so an HTTP request does not block for the whole
|
||||||
|
// run.
|
||||||
|
func (s *Service) Sync(ctx context.Context, trigger string) (Report, error) {
|
||||||
|
r, e := s.acquire(ctx, trigger)
|
||||||
|
if e != nil {
|
||||||
|
return Report{}, e
|
||||||
|
}
|
||||||
|
return s.run(ctx, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncAsync acquires the lease synchronously (so the caller gets a run id
|
||||||
|
// immediately, and the unique active_slot lease is held before returning)
|
||||||
|
// and continues the page walk in a background goroutine bound to its own
|
||||||
|
// timeout, independent of the caller's request context.
|
||||||
|
// onDone, when non-nil, runs after the background page walk finishes
|
||||||
|
// (success or failure) — StartSync uses it to release the in-memory
|
||||||
|
// concurrency gate at the right time instead of when this function returns.
|
||||||
|
func (s *Service) SyncAsync(ctx context.Context, trigger string, onDone func()) (uint64, error) {
|
||||||
|
r, e := s.acquire(ctx, trigger)
|
||||||
|
if e != nil {
|
||||||
|
return 0, e
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
bg, cancel := context.WithTimeout(context.Background(), syncTimeout)
|
||||||
|
defer cancel()
|
||||||
|
_, _ = s.run(bg, r)
|
||||||
|
if onDone != nil {
|
||||||
|
onDone()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return r.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) run(ctx context.Context, r *models.YeekeSyncRun) (Report, error) {
|
||||||
|
rep := Report{RunID: r.ID, Status: "failed"}
|
||||||
|
var errMsg string
|
||||||
|
var runErr error
|
||||||
|
defer func() {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
updates := map[string]any{"status": rep.Status, "total_pages": rep.TotalPages, "read_count": rep.Read, "created_count": rep.Created, "updated_count": rep.Updated, "skipped_count": rep.Skipped, "failed_count": rep.Failed, "missing_marked_count": rep.MissingMarked, "recovered_count": rep.Recovered, "error_message": errMsg, "active_slot": nil, "lease_owner": "", "lease_expires_at": nil, "finished_at": now}
|
||||||
|
if rep.Status == "succeeded" {
|
||||||
|
updates["last_success_at"] = now
|
||||||
|
}
|
||||||
|
s.db.Model(r).Updates(updates)
|
||||||
|
}()
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var firstWriteErr error
|
||||||
|
// 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 {
|
||||||
|
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(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
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
complete = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if p.Pages > 0 && page >= p.Pages {
|
||||||
|
complete = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// #338: only a naturally complete run with zero write failures is
|
||||||
|
// trusted to mark items/packages the sync no longer sees as "missing".
|
||||||
|
if complete && rep.Failed == 0 && rep.Status == "succeeded" {
|
||||||
|
marked, valveReason, mErr := s.markMissing(ctx, r)
|
||||||
|
switch {
|
||||||
|
case mErr != nil:
|
||||||
|
errMsg = truncateRunError(fmt.Sprintf("标记 yeeke 退货不可用失败:%v", mErr))
|
||||||
|
case valveReason != "":
|
||||||
|
errMsg = truncateRunError(valveReason)
|
||||||
|
default:
|
||||||
|
rep.MissingMarked = marked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rep, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// markMissing implements #338's completion-triggered availability flip: any
|
||||||
|
// yeeke_return_item/yeeke_return_package row still marked sync_status="ok"
|
||||||
|
// but whose last_synced_at predates this run's start was not touched by
|
||||||
|
// this (complete, failure-free) run's upsert calls, meaning yeeke no longer
|
||||||
|
// reports it. Both tables are flipped to "missing" with missing_since
|
||||||
|
// stamped, in one transaction, so a reader never observes the item flipped
|
||||||
|
// without its package (or vice versa). Rows are never deleted.
|
||||||
|
//
|
||||||
|
// Safety valve: if the number of items that would be marked exceeds 20% of
|
||||||
|
// the items currently "ok", nothing is marked and valveReason explains why
|
||||||
|
// (the run itself still finishes as "succeeded" — this is a caution, not a
|
||||||
|
// sync failure).
|
||||||
|
func (s *Service) markMissing(ctx context.Context, r *models.YeekeSyncRun) (marked int, valveReason string, err error) {
|
||||||
|
var totalOkItems int64
|
||||||
|
if e := s.db.WithContext(ctx).Model(&models.YeekeReturnItem{}).Where("sync_status = ?", "ok").Count(&totalOkItems).Error; e != nil {
|
||||||
|
return 0, "", e
|
||||||
|
}
|
||||||
|
var candidateItems int64
|
||||||
|
if e := s.db.WithContext(ctx).Model(&models.YeekeReturnItem{}).
|
||||||
|
Where("sync_status = ? AND last_synced_at < ?", "ok", r.StartedAt).
|
||||||
|
Count(&candidateItems).Error; e != nil {
|
||||||
|
return 0, "", e
|
||||||
|
}
|
||||||
|
if candidateItems == 0 {
|
||||||
|
return 0, "", nil
|
||||||
|
}
|
||||||
|
// candidateItems/totalOkItems > 20% <=> candidateItems*5 > totalOkItems
|
||||||
|
// (integer-only, avoids float rounding).
|
||||||
|
if totalOkItems > 0 && candidateItems*5 > totalOkItems {
|
||||||
|
return 0, fmt.Sprintf(
|
||||||
|
"未标记 yeeke 退货不可用:待标记 %d 条超过当前可用退货商品 %d 条的 20%% 安全阈值,需人工核查后再处理",
|
||||||
|
candidateItems, totalOkItems,
|
||||||
|
), nil
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
txErr := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
if e := tx.Model(&models.YeekeReturnItem{}).
|
||||||
|
Where("sync_status = ? AND last_synced_at < ?", "ok", r.StartedAt).
|
||||||
|
Updates(map[string]any{"sync_status": "missing", "missing_since": now}).Error; e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
return tx.Model(&models.YeekeReturnPackage{}).
|
||||||
|
Where("sync_status = ? AND last_synced_at < ?", "ok", r.StartedAt).
|
||||||
|
Updates(map[string]any{"sync_status": "missing", "missing_since": now}).Error
|
||||||
|
})
|
||||||
|
if txErr != nil {
|
||||||
|
return 0, "", txErr
|
||||||
|
}
|
||||||
|
return int(candidateItems), "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// truncateRunError keeps error_message inside the column's size limit. It
|
||||||
|
// never includes request bodies or headers, so it cannot leak a captcha,
|
||||||
|
// token or credential: every error path above passes only Go error text from
|
||||||
|
// HTTP status/timeout/JSON-decoding failures.
|
||||||
|
func truncateRunError(value string) string {
|
||||||
|
const limit = 1000
|
||||||
|
runes := []rune(strings.TrimSpace(value))
|
||||||
|
if len(runes) <= limit {
|
||||||
|
return string(runes)
|
||||||
|
}
|
||||||
|
return string(runes[:limit])
|
||||||
|
}
|
||||||
|
func pageFingerprint(p yeekeclient.ReturnPage) string {
|
||||||
|
b, _ := json.Marshal(p.Records)
|
||||||
|
h := sha256.Sum256(b)
|
||||||
|
return hex.EncodeToString(h[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// upsert writes one package and its items. recovered counts items that were
|
||||||
|
// marked "missing" before and reappeared in this run (#338).
|
||||||
|
func (s *Service) upsert(ctx context.Context, p yeekeclient.ReturnPackage) (created bool, updated bool, recovered int, err error) {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
key := packageKey(p)
|
||||||
|
status := external(p.Status)
|
||||||
|
var isNew bool
|
||||||
|
// One transaction per package: a failing item never leaves a package row
|
||||||
|
// behind without its items. Rows are always inserted fully populated —
|
||||||
|
// MySQL strict mode (NO_ZERO_DATE) rejects the zero last_synced_at an
|
||||||
|
// empty placeholder insert would carry, which failed every record.
|
||||||
|
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
var row models.YeekeReturnPackage
|
||||||
|
e := tx.Where("external_id = ?", key).First(&row).Error
|
||||||
|
isNew = errors.Is(e, gorm.ErrRecordNotFound)
|
||||||
|
if e != nil && !isNew {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
fields := models.YeekeReturnPackage{
|
||||||
|
ExternalID: key, OrderSN: p.Ordersn, TrackingNo: p.TrackingNo,
|
||||||
|
ShopID: external(p.ShopID), ShopName: p.ShopName,
|
||||||
|
WareCode: p.WareCode, WareHouse: p.WareHouse, WareName: p.WareName,
|
||||||
|
ClaimStatus: status, StatusUnrecognized: !knownClaimStatuses[status],
|
||||||
|
ClaimTime: stamp(p.ClaimTime), CreateTime: stamp(p.CreateTime),
|
||||||
|
UpdateTime: stamp(p.UpdateTime), DestroyDeadLine: stamp(p.DestroyDeadLine),
|
||||||
|
LastSyncedAt: now, SyncStatus: "ok",
|
||||||
|
}
|
||||||
|
if isNew {
|
||||||
|
row = fields
|
||||||
|
if e = tx.Create(&row).Error; e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// #338: missing_since is reset to NULL whenever a package
|
||||||
|
// reappears in a sync so it recovers cleanly, whatever its prior
|
||||||
|
// sync_status was.
|
||||||
|
vals := map[string]any{"order_sn": fields.OrderSN, "tracking_no": fields.TrackingNo, "shop_id": fields.ShopID, "shop_name": fields.ShopName, "ware_code": fields.WareCode, "ware_house": fields.WareHouse, "ware_name": fields.WareName, "claim_status": fields.ClaimStatus, "status_unrecognized": fields.StatusUnrecognized, "claim_time": fields.ClaimTime, "create_time": fields.CreateTime, "update_time": fields.UpdateTime, "destroy_dead_line": fields.DestroyDeadLine, "last_synced_at": now, "sync_status": "ok", "missing_since": (*time.Time)(nil)}
|
||||||
|
if e = tx.Model(&row).Updates(vals).Error; e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for n, i := range p.Items {
|
||||||
|
ik := itemKey(p, i, n)
|
||||||
|
item := models.YeekeReturnItem{
|
||||||
|
PackageID: row.ID, ExternalKey: ik,
|
||||||
|
ItemID: external(i.ItemID), VariationID: external(i.VariationID),
|
||||||
|
ItemName: i.ItemName, VariationName: i.VariationName, Image: i.Image,
|
||||||
|
Quantity: int64(i.Quantity), LastSyncedAt: now, SyncStatus: "ok",
|
||||||
|
}
|
||||||
|
var existing models.YeekeReturnItem
|
||||||
|
ie := tx.Where("package_id = ? AND external_key = ?", row.ID, ik).First(&existing).Error
|
||||||
|
switch {
|
||||||
|
case errors.Is(ie, gorm.ErrRecordNotFound):
|
||||||
|
if e = tx.Create(&item).Error; e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
case ie != nil:
|
||||||
|
return ie
|
||||||
|
default:
|
||||||
|
if existing.SyncStatus == "missing" {
|
||||||
|
recovered++
|
||||||
|
}
|
||||||
|
iv := map[string]any{"item_id": item.ItemID, "variation_id": item.VariationID, "item_name": item.ItemName, "variation_name": item.VariationName, "image": item.Image, "quantity": item.Quantity, "last_synced_at": now, "sync_status": "ok", "missing_since": (*time.Time)(nil)}
|
||||||
|
if e = tx.Model(&existing).Updates(iv).Error; e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return false, false, 0, err
|
||||||
|
}
|
||||||
|
return isNew, !isNew, recovered, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,406 @@
|
|||||||
|
package yeeke
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"go-admin/app/goauto/models"
|
||||||
|
"go-admin/app/goauto/yeekeclient"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fivePackagesFirstReport builds a first-sync page of 5 distinct packages
|
||||||
|
// (p1..p5), so a follow-up run dropping exactly one of them (1/5 = 20%,
|
||||||
|
// the safety-valve boundary, which is not ">20%") still marks it missing.
|
||||||
|
func fivePackagesFirstReport() []string {
|
||||||
|
recs := make([]string, 0, 5)
|
||||||
|
for i := 1; i <= 5; i++ {
|
||||||
|
id := fmt.Sprintf("p%d", i)
|
||||||
|
recs = append(recs, record(id, "i", "v"+id, 1))
|
||||||
|
}
|
||||||
|
return recs
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCompleteRunMarksAbsentItemsAndPackagesMissing: p1 exists from an
|
||||||
|
// earlier sync of 5 packages; a later, naturally complete run only reports
|
||||||
|
// the other 4 (p2..p5). p1's item and package must both flip to
|
||||||
|
// sync_status="missing" with missing_since set; p2..p5 must stay "ok".
|
||||||
|
func TestCompleteRunMarksAbsentItemsAndPackagesMissing(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
c, _ := yeekeclient.New("http://unused.invalid")
|
||||||
|
s := NewService(db, c, Config{PageSize: 10})
|
||||||
|
|
||||||
|
srv1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, page(fivePackagesFirstReport(), 5, 1))
|
||||||
|
}))
|
||||||
|
s.client, _ = yeekeclient.New(srv1.URL)
|
||||||
|
if _, err := s.Sync(context.Background(), "manual"); err != nil {
|
||||||
|
t.Fatalf("first sync: %v", err)
|
||||||
|
}
|
||||||
|
srv1.Close()
|
||||||
|
|
||||||
|
time.Sleep(1100 * time.Millisecond) // ensure StartedAt of run 2 is strictly later
|
||||||
|
srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, page([]string{record("p2", "i", "vp2", 1), record("p3", "i", "vp3", 1), record("p4", "i", "vp4", 1), record("p5", "i", "vp5", 1)}, 4, 1))
|
||||||
|
}))
|
||||||
|
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.Status != "succeeded" {
|
||||||
|
t.Fatalf("rep=%+v", rep)
|
||||||
|
}
|
||||||
|
if rep.MissingMarked != 1 {
|
||||||
|
t.Fatalf("MissingMarked=%d, want 1", rep.MissingMarked)
|
||||||
|
}
|
||||||
|
|
||||||
|
var p1 models.YeekeReturnPackage
|
||||||
|
if e := db.Where("external_id = ?", "p1").First(&p1).Error; e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
if p1.SyncStatus != "missing" || p1.MissingSince == nil {
|
||||||
|
t.Fatalf("p1 package=%+v, want sync_status=missing with missing_since set", p1)
|
||||||
|
}
|
||||||
|
var i1 models.YeekeReturnItem
|
||||||
|
if e := db.Where("package_id = ?", p1.ID).First(&i1).Error; e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
if i1.SyncStatus != "missing" || i1.MissingSince == nil {
|
||||||
|
t.Fatalf("p1 item=%+v, want sync_status=missing with missing_since set", i1)
|
||||||
|
}
|
||||||
|
|
||||||
|
var p2 models.YeekeReturnPackage
|
||||||
|
if e := db.Where("external_id = ?", "p2").First(&p2).Error; e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
if p2.SyncStatus != "ok" || p2.MissingSince != nil {
|
||||||
|
t.Fatalf("p2 package=%+v, want sync_status=ok with no missing_since", p2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFailedPageStopsMarking: page 2 always fails, so the run ends "failed".
|
||||||
|
// Nothing must be marked missing even though p1 (from an earlier run) is
|
||||||
|
// absent from this run's (incomplete) output.
|
||||||
|
func TestFailedPageStopsMarking(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
c, _ := yeekeclient.New("http://unused.invalid")
|
||||||
|
s := NewService(db, c, Config{PageSize: 1, Retry: 0})
|
||||||
|
|
||||||
|
srv1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, page([]string{record("p1", "i", "v1", 1)}, 1, 1))
|
||||||
|
}))
|
||||||
|
s.client, _ = yeekeclient.New(srv1.URL)
|
||||||
|
if _, err := s.Sync(context.Background(), "manual"); err != nil {
|
||||||
|
t.Fatalf("first sync: %v", err)
|
||||||
|
}
|
||||||
|
srv1.Close()
|
||||||
|
|
||||||
|
var calls int32
|
||||||
|
srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
n := atomic.AddInt32(&calls, 1)
|
||||||
|
if n == 1 {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, page([]string{record("p2", "i", "v2", 1)}, 2, 2))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, "boom", http.StatusInternalServerError)
|
||||||
|
}))
|
||||||
|
defer srv2.Close()
|
||||||
|
s.client, _ = yeekeclient.New(srv2.URL)
|
||||||
|
if _, err := s.Sync(context.Background(), "manual"); err == nil {
|
||||||
|
t.Fatal("expected the second run (failed page 2) to error")
|
||||||
|
}
|
||||||
|
|
||||||
|
var p1 models.YeekeReturnPackage
|
||||||
|
if e := db.Where("external_id = ?", "p1").First(&p1).Error; e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
if p1.SyncStatus != "ok" || p1.MissingSince != nil {
|
||||||
|
t.Fatalf("p1 must stay ok after a failed page, got %+v", p1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCtxCancelStopsMarking: the context is cancelled mid-walk. The run ends
|
||||||
|
// with an error and must not mark anything missing.
|
||||||
|
func TestCtxCancelStopsMarking(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
c, _ := yeekeclient.New("http://unused.invalid")
|
||||||
|
s := NewService(db, c, Config{PageSize: 1, Retry: 0})
|
||||||
|
|
||||||
|
srv1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, page([]string{record("p1", "i", "v1", 1)}, 1, 1))
|
||||||
|
}))
|
||||||
|
s.client, _ = yeekeclient.New(srv1.URL)
|
||||||
|
if _, err := s.Sync(context.Background(), "manual"); err != nil {
|
||||||
|
t.Fatalf("first sync: %v", err)
|
||||||
|
}
|
||||||
|
srv1.Close()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
var calls int32
|
||||||
|
srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
n := atomic.AddInt32(&calls, 1)
|
||||||
|
if n == 1 {
|
||||||
|
cancel()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, page([]string{record("p2", "i", "v2", 1)}, 2, 2))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, "should not be reached", http.StatusInternalServerError)
|
||||||
|
}))
|
||||||
|
defer srv2.Close()
|
||||||
|
s.client, _ = yeekeclient.New(srv2.URL)
|
||||||
|
if _, err := s.Sync(ctx, "manual"); err == nil {
|
||||||
|
t.Fatal("expected a context-cancellation error")
|
||||||
|
}
|
||||||
|
|
||||||
|
var p1 models.YeekeReturnPackage
|
||||||
|
if e := db.Where("external_id = ?", "p1").First(&p1).Error; e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
if p1.SyncStatus != "ok" || p1.MissingSince != nil {
|
||||||
|
t.Fatalf("p1 must stay ok after a context-cancelled run, got %+v", p1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDuplicateFingerprintStopsMarking: the walk ends via the
|
||||||
|
// duplicate-page break, not a natural stop, so nothing must be marked even
|
||||||
|
// though only p1 (not p2 from an earlier sync) is ever reported.
|
||||||
|
func TestDuplicateFingerprintStopsMarking(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
c, _ := yeekeclient.New("http://unused.invalid")
|
||||||
|
s := NewService(db, c, Config{PageSize: 1})
|
||||||
|
|
||||||
|
srv1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, page([]string{record("p2", "i", "v2", 1)}, 1, 1))
|
||||||
|
}))
|
||||||
|
s.client, _ = yeekeclient.New(srv1.URL)
|
||||||
|
if _, err := s.Sync(context.Background(), "manual"); err != nil {
|
||||||
|
t.Fatalf("first sync (seeds p2): %v", err)
|
||||||
|
}
|
||||||
|
srv1.Close()
|
||||||
|
|
||||||
|
// Second run: page 1 returns p1, but the server (mis)reports pages=5 and
|
||||||
|
// then serves the exact same page again, triggering the duplicate break
|
||||||
|
// before ever reaching a natural stop.
|
||||||
|
body := page([]string{record("p1", "i", "v1", 1)}, 10, 5)
|
||||||
|
var calls int32
|
||||||
|
srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
atomic.AddInt32(&calls, 1)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, body)
|
||||||
|
}))
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
var p2 models.YeekeReturnPackage
|
||||||
|
if e := db.Where("external_id = ?", "p2").First(&p2).Error; e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
if p2.SyncStatus != "ok" || p2.MissingSince != nil {
|
||||||
|
t.Fatalf("p2 must stay ok after a duplicate-fingerprint stop, got %+v", p2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMaxPagesExhaustionStopsMarking: MaxPages is reached without any
|
||||||
|
// natural stop condition being hit, so nothing must be marked.
|
||||||
|
func TestMaxPagesExhaustionStopsMarking(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
c, _ := yeekeclient.New("http://unused.invalid")
|
||||||
|
s := NewService(db, c, Config{PageSize: 1})
|
||||||
|
|
||||||
|
srv1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, page([]string{record("p2", "i", "v2", 1)}, 1, 1))
|
||||||
|
}))
|
||||||
|
s.client, _ = yeekeclient.New(srv1.URL)
|
||||||
|
if _, err := s.Sync(context.Background(), "manual"); err != nil {
|
||||||
|
t.Fatalf("first sync (seeds p2): %v", err)
|
||||||
|
}
|
||||||
|
srv1.Close()
|
||||||
|
|
||||||
|
// Second run: every page returns a distinct full page (never short,
|
||||||
|
// never empty, pages always reported far beyond MaxPages), so the walk
|
||||||
|
// only stops because MaxPages is exhausted.
|
||||||
|
var n int32
|
||||||
|
srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
k := atomic.AddInt32(&n, 1)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, page([]string{record(fmt.Sprintf("p1-%d", k), "i", "v1", 1)}, 1000, 1000))
|
||||||
|
}))
|
||||||
|
defer srv2.Close()
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
var p2 models.YeekeReturnPackage
|
||||||
|
if e := db.Where("external_id = ?", "p2").First(&p2).Error; e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
if p2.SyncStatus != "ok" || p2.MissingSince != nil {
|
||||||
|
t.Fatalf("p2 must stay ok after MaxPages exhaustion, got %+v", p2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSafetyValveSkipsMarkingWhenOverThreshold: 3 packages are "ok"; a
|
||||||
|
// complete follow-up run only reports 1 of them (2 of 3 = 67% would be
|
||||||
|
// marked, well over the 20% threshold). Nothing must be marked, and the
|
||||||
|
// run's error_message must explain why.
|
||||||
|
func TestSafetyValveSkipsMarkingWhenOverThreshold(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
c, _ := yeekeclient.New("http://unused.invalid")
|
||||||
|
s := NewService(db, c, Config{PageSize: 10})
|
||||||
|
|
||||||
|
srv1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, page([]string{record("p1", "i", "v1", 1), record("p2", "i", "v2", 1), record("p3", "i", "v3", 1)}, 3, 1))
|
||||||
|
}))
|
||||||
|
s.client, _ = yeekeclient.New(srv1.URL)
|
||||||
|
if _, err := s.Sync(context.Background(), "manual"); err != nil {
|
||||||
|
t.Fatalf("first sync: %v", err)
|
||||||
|
}
|
||||||
|
srv1.Close()
|
||||||
|
|
||||||
|
time.Sleep(1100 * time.Millisecond)
|
||||||
|
srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, page([]string{record("p1", "i", "v1", 1)}, 1, 1))
|
||||||
|
}))
|
||||||
|
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.Status != "succeeded" {
|
||||||
|
t.Fatalf("safety valve must not fail the run, got status=%q", rep.Status)
|
||||||
|
}
|
||||||
|
if rep.MissingMarked != 0 {
|
||||||
|
t.Fatalf("MissingMarked=%d, want 0 (over the 20%% safety valve)", rep.MissingMarked)
|
||||||
|
}
|
||||||
|
var run models.YeekeSyncRun
|
||||||
|
if e := db.First(&run, rep.RunID).Error; e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
if !strings.Contains(run.ErrorMessage, "20%") {
|
||||||
|
t.Fatalf("run.ErrorMessage=%q, want an explanation mentioning the 20%% safety valve", run.ErrorMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ext := range []string{"p2", "p3"} {
|
||||||
|
var p models.YeekeReturnPackage
|
||||||
|
if e := db.Where("external_id = ?", ext).First(&p).Error; e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
if p.SyncStatus != "ok" || p.MissingSince != nil {
|
||||||
|
t.Fatalf("%s must stay ok when the safety valve trips, got %+v", ext, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestReappearingRecordRecoversFromMissing: a package/item marked missing by
|
||||||
|
// an earlier complete run reappears in a later sync and must recover to
|
||||||
|
// sync_status="ok" with missing_since cleared.
|
||||||
|
func TestReappearingRecordRecoversFromMissing(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
c, _ := yeekeclient.New("http://unused.invalid")
|
||||||
|
s := NewService(db, c, Config{PageSize: 10})
|
||||||
|
|
||||||
|
srv1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, page(fivePackagesFirstReport(), 5, 1))
|
||||||
|
}))
|
||||||
|
s.client, _ = yeekeclient.New(srv1.URL)
|
||||||
|
if _, err := s.Sync(context.Background(), "manual"); err != nil {
|
||||||
|
t.Fatalf("run 1: %v", err)
|
||||||
|
}
|
||||||
|
srv1.Close()
|
||||||
|
|
||||||
|
time.Sleep(1100 * time.Millisecond)
|
||||||
|
srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, page([]string{record("p2", "i", "vp2", 1), record("p3", "i", "vp3", 1), record("p4", "i", "vp4", 1), record("p5", "i", "vp5", 1)}, 4, 1))
|
||||||
|
}))
|
||||||
|
s.client, _ = yeekeclient.New(srv2.URL)
|
||||||
|
rep2, err := s.Sync(context.Background(), "manual")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("run 2: %v", err)
|
||||||
|
}
|
||||||
|
if rep2.MissingMarked != 1 {
|
||||||
|
t.Fatalf("run2 MissingMarked=%d, want 1", rep2.MissingMarked)
|
||||||
|
}
|
||||||
|
srv2.Close()
|
||||||
|
|
||||||
|
var p1 models.YeekeReturnPackage
|
||||||
|
db.Where("external_id = ?", "p1").First(&p1)
|
||||||
|
if p1.SyncStatus != "missing" || p1.MissingSince == nil {
|
||||||
|
t.Fatalf("p1 must be missing before recovery, got %+v", p1)
|
||||||
|
}
|
||||||
|
|
||||||
|
srv3 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, page(fivePackagesFirstReport(), 5, 1))
|
||||||
|
}))
|
||||||
|
defer srv3.Close()
|
||||||
|
s.client, _ = yeekeclient.New(srv3.URL)
|
||||||
|
rep3, err := s.Sync(context.Background(), "manual")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("run 3 (recovery): %v", err)
|
||||||
|
}
|
||||||
|
if rep3.Recovered != 1 || rep3.MissingMarked != 0 {
|
||||||
|
t.Fatalf("run3 Recovered=%d MissingMarked=%d, want 1 and 0", rep3.Recovered, rep3.MissingMarked)
|
||||||
|
}
|
||||||
|
// Both counters are persisted on the run rows shown by the sync-runs page.
|
||||||
|
var run2, run3 models.YeekeSyncRun
|
||||||
|
if e := db.First(&run2, rep2.RunID).Error; e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
if e := db.First(&run3, rep3.RunID).Error; e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
if run2.MissingMarkedCount != 1 || run2.RecoveredCount != 0 || run3.MissingMarkedCount != 0 || run3.RecoveredCount != 1 {
|
||||||
|
t.Fatalf("persisted counts run2=(%d,%d) run3=(%d,%d), want (1,0) and (0,1)", run2.MissingMarkedCount, run2.RecoveredCount, run3.MissingMarkedCount, run3.RecoveredCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A fresh variable is used here (not the p1 declared above): GORM's Scan
|
||||||
|
// does not reset an already non-nil pointer field to nil when the new
|
||||||
|
// row's column is NULL, so reusing the earlier struct would misreport a
|
||||||
|
// stale MissingSince even though the row itself recovered correctly.
|
||||||
|
var recovered models.YeekeReturnPackage
|
||||||
|
if e := db.Where("external_id = ?", "p1").First(&recovered).Error; e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
if recovered.SyncStatus != "ok" || recovered.MissingSince != nil {
|
||||||
|
t.Fatalf("p1 must recover to ok with missing_since cleared, got %+v", recovered)
|
||||||
|
}
|
||||||
|
var i1 models.YeekeReturnItem
|
||||||
|
if e := db.Where("package_id = ?", recovered.ID).First(&i1).Error; e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
if i1.SyncStatus != "ok" || i1.MissingSince != nil {
|
||||||
|
t.Fatalf("p1's item must recover to ok with missing_since cleared, got %+v", i1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,779 @@
|
|||||||
|
package yeeke
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"go-admin/app/goauto/migrations"
|
||||||
|
"go-admin/app/goauto/models"
|
||||||
|
"go-admin/app/goauto/yeekeclient"
|
||||||
|
"go-admin/config"
|
||||||
|
"gorm.io/driver/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type stubOCR struct{ codes []string }
|
||||||
|
|
||||||
|
func (s *stubOCR) Recognize(context.Context, []byte) (string, error) {
|
||||||
|
if len(s.codes) == 0 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
c := s.codes[0]
|
||||||
|
s.codes = s.codes[1:]
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDB(t *testing.T) *gorm.DB {
|
||||||
|
t.Helper()
|
||||||
|
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := migrations.Migrate(db); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
func record(id, itemID, variationID string, status any) string {
|
||||||
|
b, _ := json.Marshal(map[string]any{
|
||||||
|
"id": id, "ordersn": "o-" + id, "trackingNo": "t-" + id, "status": status,
|
||||||
|
"items": []map[string]any{{
|
||||||
|
"id": id + "-i1", "itemId": itemID, "variationId": variationID,
|
||||||
|
"itemName": "n", "variationName": "v", "variationQuantityPurchased": 1,
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func page(records []string, total, pages int) string {
|
||||||
|
return fmt.Sprintf(`{"success":true,"result":{"records":[%s],"total":%d,"pages":%d}}`, strings.Join(records, ","), total, pages)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPagingSurvivesTotalChangingMidRun: page 1 reports one total/pages, page
|
||||||
|
// 2 reports a different total/pages (the underlying data changed between the
|
||||||
|
// two requests). The walk must still finish using what each page returned.
|
||||||
|
func TestPagingSurvivesTotalChangingMidRun(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
var calls int32
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
n := atomic.AddInt32(&calls, 1)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch n {
|
||||||
|
case 1:
|
||||||
|
fmt.Fprint(w, page([]string{record("p1", "i", "v1", 1)}, 100, 2))
|
||||||
|
case 2:
|
||||||
|
fmt.Fprint(w, page([]string{record("p2", "i", "v2", 1)}, 50, 1))
|
||||||
|
default:
|
||||||
|
fmt.Fprint(w, page(nil, 0, 0))
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
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" || rep.Read != 2 {
|
||||||
|
t.Fatalf("rep=%+v", rep)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPagingSkipsARepeatedDuplicatePage: the server returns the exact same
|
||||||
|
// page twice in a row (e.g. a retried request landed after all). The second
|
||||||
|
// occurrence must be recognized as a duplicate and stop the walk instead of
|
||||||
|
// looping or double counting.
|
||||||
|
func TestPagingSkipsARepeatedDuplicatePage(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
var calls int32
|
||||||
|
body := page([]string{record("p1", "i", "v1", 1)}, 10, 5)
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
n := atomic.AddInt32(&calls, 1)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if n <= 2 {
|
||||||
|
fmt.Fprint(w, body) // identical page served twice
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprint(w, page(nil, 10, 5))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
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" {
|
||||||
|
t.Fatalf("rep=%+v", rep)
|
||||||
|
}
|
||||||
|
var n int64
|
||||||
|
db.Model(&models.YeekeReturnPackage{}).Count(&n)
|
||||||
|
if n != 1 {
|
||||||
|
t.Fatalf("packages=%d, want 1 (duplicate page must not double-insert)", n)
|
||||||
|
}
|
||||||
|
if calls != 2 {
|
||||||
|
t.Fatalf("calls=%d, want exactly 2 (stop right after recognizing the duplicate)", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPagingStopsOnEmptyPage confirms an empty page ends the walk cleanly.
|
||||||
|
func TestPagingStopsOnEmptyPage(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, page(nil, 0, 0))
|
||||||
|
}))
|
||||||
|
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 {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if rep.Status != "succeeded" || rep.TotalPages != 1 || rep.Read != 0 {
|
||||||
|
t.Fatalf("rep=%+v", rep)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPagingTimeoutFailsRunButKeepsEarlierPages: page 1 succeeds and is
|
||||||
|
// persisted; page 2 always times out. The run must end as "failed" (after
|
||||||
|
// retrying up to cfg.Retry times) but page 1's row must remain intact — a
|
||||||
|
// failed page must never roll back or overwrite valid prior data.
|
||||||
|
func TestPagingTimeoutFailsRunButKeepsEarlierPages(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
var calls int32
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
n := atomic.AddInt32(&calls, 1)
|
||||||
|
if n == 1 {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, page([]string{record("p1", "i", "v1", 1)}, 10, 5))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Simulate a slow/timed-out request: this response is deliberately
|
||||||
|
// slower than the client's own context deadline below, so the client
|
||||||
|
// side must give up on its own rather than waiting for us.
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
c, _ := yeekeclient.New(srv.URL)
|
||||||
|
s := NewService(db, c, Config{PageSize: 1, Retry: 1})
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
rep, err := s.Sync(ctx, "manual")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected the timed-out page to surface an error")
|
||||||
|
}
|
||||||
|
if rep.Status != "failed" || rep.RunID == 0 {
|
||||||
|
t.Fatalf("rep=%+v", rep)
|
||||||
|
}
|
||||||
|
var row models.YeekeReturnPackage
|
||||||
|
if e := db.Where("external_id = ?", "p1").First(&row).Error; e != nil {
|
||||||
|
t.Fatalf("page 1's row must survive a later page's failure: %v", e)
|
||||||
|
}
|
||||||
|
if row.ClaimStatus != "1" {
|
||||||
|
t.Fatalf("page 1's row must be unmodified: %+v", row)
|
||||||
|
}
|
||||||
|
var run models.YeekeSyncRun
|
||||||
|
if e := db.First(&run, rep.RunID).Error; e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
if run.Status != "failed" || run.ErrorMessage == "" {
|
||||||
|
t.Fatalf("run=%+v", run)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSyncResumeAfterSimulatedRestart: a first run fails partway through
|
||||||
|
// (simulating the process being interrupted after committing page 1). A
|
||||||
|
// second, full run afterwards must succeed and must not duplicate the row
|
||||||
|
// page 1 already wrote — it converges to exactly one package row.
|
||||||
|
func TestSyncResumeAfterSimulatedRestart(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
var calls int32
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
n := atomic.AddInt32(&calls, 1)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if n == 1 {
|
||||||
|
fmt.Fprint(w, page([]string{record("p1", "i", "v1", 1)}, 10, 2))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n == 2 {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Full second run: both pages succeed this time.
|
||||||
|
if n == 3 {
|
||||||
|
fmt.Fprint(w, page([]string{record("p1", "i", "v1", 1)}, 10, 2))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprint(w, page(nil, 10, 2))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
c, _ := yeekeclient.New(srv.URL)
|
||||||
|
s := NewService(db, c, Config{PageSize: 1, Retry: 0})
|
||||||
|
|
||||||
|
if _, err := s.Sync(context.Background(), "manual"); err == nil {
|
||||||
|
t.Fatal("expected the first ('interrupted') run to fail")
|
||||||
|
}
|
||||||
|
rep2, err := s.Sync(context.Background(), "manual")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if rep2.Status != "succeeded" {
|
||||||
|
t.Fatalf("rep2=%+v", rep2)
|
||||||
|
}
|
||||||
|
var n int64
|
||||||
|
db.Model(&models.YeekeReturnPackage{}).Count(&n)
|
||||||
|
if n != 1 {
|
||||||
|
t.Fatalf("packages=%d, want 1 (resume must not duplicate p1)", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIdempotentStatusUpdateInPlace: syncing the same package twice with a
|
||||||
|
// different status the second time updates the existing row rather than
|
||||||
|
// creating a second one.
|
||||||
|
func TestIdempotentStatusUpdateInPlace(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
var status int32 = 1
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, page([]string{record("p1", "i", "v1", atomic.LoadInt32(&status))}, 1, 1))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
c, _ := yeekeclient.New(srv.URL)
|
||||||
|
s := NewService(db, c, Config{PageSize: 10})
|
||||||
|
if _, err := s.Sync(context.Background(), "manual"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
atomic.StoreInt32(&status, 9) // an unrecognized status the second time
|
||||||
|
if _, err := s.Sync(context.Background(), "manual"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var n int64
|
||||||
|
db.Model(&models.YeekeReturnPackage{}).Count(&n)
|
||||||
|
if n != 1 {
|
||||||
|
t.Fatalf("packages=%d, want 1 (status change must update in place)", n)
|
||||||
|
}
|
||||||
|
var row models.YeekeReturnPackage
|
||||||
|
db.Where("external_id = ?", "p1").First(&row)
|
||||||
|
if row.ClaimStatus != "9" || !row.StatusUnrecognized {
|
||||||
|
t.Fatalf("row=%+v, want claim_status=9 flagged unrecognized", row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUnknownStatusIsPreservedVerbatimAndFlagged: an unrecognized status value
|
||||||
|
// is kept as-is (never remapped into a known bucket) and the row is flagged.
|
||||||
|
func TestUnknownStatusIsPreservedVerbatimAndFlagged(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, page([]string{record("p1", "i", "v1", 7)}, 1, 1))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
c, _ := yeekeclient.New(srv.URL)
|
||||||
|
s := NewService(db, c, Config{PageSize: 10})
|
||||||
|
if _, err := s.Sync(context.Background(), "manual"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var row models.YeekeReturnPackage
|
||||||
|
if e := db.Where("external_id = ?", "p1").First(&row).Error; e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
if row.ClaimStatus != "7" {
|
||||||
|
t.Fatalf("claim_status=%q, want the raw value 7 preserved verbatim", row.ClaimStatus)
|
||||||
|
}
|
||||||
|
if !row.StatusUnrecognized {
|
||||||
|
t.Fatal("an unknown status must be flagged, not silently accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestActiveSlotLeaseRejectsConcurrentRuns exercises the same DB-level
|
||||||
|
// uniqueness the manual trigger and the scheduled job both rely on
|
||||||
|
// (models.YeekeSyncRun.ActiveSlot): two Sync calls racing against the same
|
||||||
|
// database must not both hold the lease at once.
|
||||||
|
func TestActiveSlotLeaseRejectsConcurrentRuns(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
release := make(chan struct{})
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
<-release
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, page(nil, 0, 0))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
c, _ := yeekeclient.New(srv.URL)
|
||||||
|
s := NewService(db, c, Config{PageSize: 10})
|
||||||
|
|
||||||
|
started := make(chan struct{})
|
||||||
|
var firstErr, secondErr error
|
||||||
|
go func() {
|
||||||
|
close(started)
|
||||||
|
_, firstErr = s.Sync(context.Background(), "manual")
|
||||||
|
}()
|
||||||
|
<-started
|
||||||
|
time.Sleep(50 * time.Millisecond) // let the first Sync acquire its lease row
|
||||||
|
_, secondErr = s.Sync(context.Background(), "scheduled")
|
||||||
|
close(release)
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
|
||||||
|
if secondErr == nil {
|
||||||
|
t.Fatal("a second concurrent Sync must be rejected by the active_slot lease")
|
||||||
|
}
|
||||||
|
_ = firstErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStartSyncGateRejectsConcurrentTriggers exercises StartSync itself (the
|
||||||
|
// entry point manual and scheduled triggers actually share): a second call
|
||||||
|
// while one is in flight is rejected, and with skipIfRunning it is reported
|
||||||
|
// as skipped instead of erroring — this is what the scheduled job uses so a
|
||||||
|
// tick landing during a manual run does not surface as a failure.
|
||||||
|
func TestStartSyncGateRejectsConcurrentTriggers(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
restore := setTestYeekeConfig(t, "op", "secret-pw")
|
||||||
|
defer restore()
|
||||||
|
|
||||||
|
release := make(chan struct{})
|
||||||
|
var loginCalls, listCalls int32
|
||||||
|
srv := fakeYeekeServer(t, &loginCalls, &listCalls, release)
|
||||||
|
defer srv.Close()
|
||||||
|
restoreURL := setTestYeekeBaseURL(t, srv.URL)
|
||||||
|
defer restoreURL()
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(1)
|
||||||
|
var firstErr error
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
_, firstErr = StartSync(context.Background(), db, &stubOCR{codes: []string{"abcd"}}, "manual", false)
|
||||||
|
}()
|
||||||
|
// Give the first call time to acquire syncGate and the DB lease before
|
||||||
|
// the second one is attempted.
|
||||||
|
time.Sleep(150 * time.Millisecond)
|
||||||
|
|
||||||
|
_, err := StartSync(context.Background(), db, &stubOCR{}, "scheduled", true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("skipIfRunning=true must not error, got %v", err)
|
||||||
|
}
|
||||||
|
_, err2 := StartSync(context.Background(), db, &stubOCR{}, "manual", false)
|
||||||
|
if err2 == nil || err2 != ErrAlreadyRunning {
|
||||||
|
t.Fatalf("skipIfRunning=false must report ErrAlreadyRunning, got %v", err2)
|
||||||
|
}
|
||||||
|
|
||||||
|
close(release)
|
||||||
|
wg.Wait()
|
||||||
|
if firstErr != nil {
|
||||||
|
t.Fatalf("first StartSync should have completed cleanly: %v", firstErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoginFailureMessageNeverLeaksCredentialsOrCaptcha: whatever StartSync
|
||||||
|
// or the underlying client return as an error, the credential, password and
|
||||||
|
// recognized captcha text must never appear in it, since that text ends up
|
||||||
|
// in server logs and (truncated) in yeeke_sync_run.error_message.
|
||||||
|
func TestLoginFailureMessageNeverLeaksCredentialsOrCaptcha(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
const secretPassword = "S3cr3t-Do-Not-Leak"
|
||||||
|
const secretCaptcha = "zZqQ9x"
|
||||||
|
restore := setTestYeekeConfig(t, "leak-user", secretPassword)
|
||||||
|
defer restore()
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/agent-foreign/sys/randomImage":
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{"success": true, "result": map[string]string{"image": "data:image/jpg;base64,SGk=", "checkKey": "k"}})
|
||||||
|
case "/agent-foreign/sys/login":
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{"success": false, "code": 1, "message": "验证码错误"})
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
restoreURL := setTestYeekeBaseURL(t, srv.URL)
|
||||||
|
defer restoreURL()
|
||||||
|
|
||||||
|
_, err := StartSync(context.Background(), db, &stubOCR{codes: []string{secretCaptcha}}, "manual", false)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected a login failure")
|
||||||
|
}
|
||||||
|
msg := err.Error()
|
||||||
|
if strings.Contains(msg, secretPassword) || strings.Contains(msg, secretCaptcha) || strings.Contains(msg, "leak-user") {
|
||||||
|
t.Fatalf("error message leaked a credential or captcha text: %q", msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
var run models.YeekeSyncRun
|
||||||
|
// StartSync failed before acquiring a run row here (Connect failed first),
|
||||||
|
// so there should be no run row at all to check — that is itself part of
|
||||||
|
// the guarantee: a failed login never gets far enough to write a summary
|
||||||
|
// row that could carry sensitive text.
|
||||||
|
if e := db.Order("id desc").First(&run).Error; e == nil {
|
||||||
|
if strings.Contains(run.ErrorMessage, secretPassword) || strings.Contains(run.ErrorMessage, secretCaptcha) {
|
||||||
|
t.Fatalf("run.ErrorMessage leaked a credential or captcha text: %q", run.ErrorMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeYeekeServer serves a minimal login+list surface. It blocks the *list*
|
||||||
|
// call on release, so a test can hold StartSync's background goroutine open
|
||||||
|
// long enough to exercise the concurrency gate.
|
||||||
|
func fakeYeekeServer(t *testing.T, loginCalls, listCalls *int32, release chan struct{}) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/agent-foreign/sys/randomImage":
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{"success": true, "result": map[string]string{"image": "data:image/jpg;base64,SGk=", "checkKey": "k"}})
|
||||||
|
case "/agent-foreign/sys/login":
|
||||||
|
atomic.AddInt32(loginCalls, 1)
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{"success": true, "result": map[string]any{"token": "tok", "userInfo": map[string]any{"id": "u1", "username": "op"}}})
|
||||||
|
case "/agent-foreign/packageClaimRec/relation/list":
|
||||||
|
atomic.AddInt32(listCalls, 1)
|
||||||
|
<-release
|
||||||
|
fmt.Fprint(w, page(nil, 0, 0))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The next two helpers isolate StartSync's config.ExtConfig.Yeeke dependency
|
||||||
|
// for tests, restoring it afterwards so other tests are unaffected.
|
||||||
|
func setTestYeekeConfig(t *testing.T, username, password string) func() {
|
||||||
|
t.Helper()
|
||||||
|
before := config.ExtConfig.Yeeke
|
||||||
|
config.ExtConfig.Yeeke.Username = username
|
||||||
|
config.ExtConfig.Yeeke.Password = password
|
||||||
|
config.ExtConfig.Yeeke.OcrURL = "http://unused.invalid/ocr"
|
||||||
|
return func() { config.ExtConfig.Yeeke = before }
|
||||||
|
}
|
||||||
|
|
||||||
|
func setTestYeekeBaseURL(t *testing.T, url string) func() {
|
||||||
|
t.Helper()
|
||||||
|
before := config.ExtConfig.Yeeke
|
||||||
|
config.ExtConfig.Yeeke.BaseURL = url
|
||||||
|
return func() { config.ExtConfig.Yeeke = before }
|
||||||
|
}
|
||||||
|
|
||||||
|
// packageWithItems builds a raw list-page record for one package carrying an
|
||||||
|
// arbitrary, caller-ordered set of items, so tests can reorder items between
|
||||||
|
// two syncs of the same package.
|
||||||
|
func packageWithItems(pkgID string, items ...map[string]any) string {
|
||||||
|
b, _ := json.Marshal(map[string]any{
|
||||||
|
"id": pkgID, "ordersn": "o-" + pkgID, "trackingNo": "t-" + pkgID, "status": 1,
|
||||||
|
"items": items,
|
||||||
|
})
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func item(id, itemID, variationID string) map[string]any {
|
||||||
|
return map[string]any{"id": id, "itemId": itemID, "variationId": variationID, "itemName": "n", "variationName": "v", "variationQuantityPurchased": 1}
|
||||||
|
}
|
||||||
|
|
||||||
|
// itemNoIDs builds an item carrying no yeeke-issued identifiers at all
|
||||||
|
// (id/itemId/variationId all empty), the case itemKey's positional-index
|
||||||
|
// fallback exists for.
|
||||||
|
func itemNoIDs() map[string]any {
|
||||||
|
return map[string]any{"id": "", "itemId": "", "variationId": "", "itemName": "n", "variationName": "v", "variationQuantityPurchased": 1}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Defect 1 (#336): stale lease takeover -------------------------------
|
||||||
|
|
||||||
|
// TestStaleLeaseIsTakenOverOnNextAcquire simulates a crash: a "running" row
|
||||||
|
// is left behind with an active_slot and a lease that has already expired
|
||||||
|
// (as if the process died mid-sync, long before the lease's normal
|
||||||
|
// duration). The very next Sync call — scheduled or manual — must reclaim
|
||||||
|
// the slot rather than being permanently blocked, and the abandoned row must
|
||||||
|
// end up in a clear terminal state (not silently deleted) recording why.
|
||||||
|
func TestStaleLeaseIsTakenOverOnNextAcquire(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
slot := uint8(1)
|
||||||
|
past := time.Now().UTC().Add(-time.Hour)
|
||||||
|
stale := models.YeekeSyncRun{
|
||||||
|
Status: "running", Trigger: "scheduled", StartedAt: past.Add(-time.Minute),
|
||||||
|
ActiveSlot: &slot, LeaseOwner: "dead-process", LeaseExpiresAt: &past,
|
||||||
|
}
|
||||||
|
if e := db.Create(&stale).Error; e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, page(nil, 0, 0))
|
||||||
|
}))
|
||||||
|
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 {
|
||||||
|
t.Fatalf("resume after a stale lease must succeed, got err=%v", err)
|
||||||
|
}
|
||||||
|
if rep.Status != "succeeded" {
|
||||||
|
t.Fatalf("rep=%+v", rep)
|
||||||
|
}
|
||||||
|
|
||||||
|
var reclaimed models.YeekeSyncRun
|
||||||
|
if e := db.First(&reclaimed, stale.ID).Error; e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
if reclaimed.Status != "failed" {
|
||||||
|
t.Fatalf("stale run status=%q, want a terminal status (not silently left running or deleted)", reclaimed.Status)
|
||||||
|
}
|
||||||
|
if reclaimed.ErrorMessage == "" {
|
||||||
|
t.Fatal("stale run must record why it was taken over")
|
||||||
|
}
|
||||||
|
if reclaimed.ActiveSlot != nil {
|
||||||
|
t.Fatal("stale run must release active_slot on takeover")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The new run's own row clears active_slot on completion just like any
|
||||||
|
// other successful run (see run()'s defer), so what proves the takeover
|
||||||
|
// happened is that a second, distinct run row now exists alongside the
|
||||||
|
// reclaimed stale one.
|
||||||
|
var totalRuns int64
|
||||||
|
db.Model(&models.YeekeSyncRun{}).Count(&totalRuns)
|
||||||
|
if totalRuns != 2 {
|
||||||
|
t.Fatalf("expected the stale row plus exactly one new run after takeover, got %d run rows", totalRuns)
|
||||||
|
}
|
||||||
|
if rep.RunID == stale.ID {
|
||||||
|
t.Fatal("the new run must not reuse the stale run's row")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestActiveSlotLeaseRejectsConcurrentRuns above must still pass unmodified:
|
||||||
|
// a lease that has NOT expired must keep blocking a second run. This test
|
||||||
|
// pins that same guarantee at the acquire() level directly.
|
||||||
|
func TestValidLeaseIsNotTakenOver(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
slot := uint8(1)
|
||||||
|
future := time.Now().UTC().Add(time.Hour)
|
||||||
|
holding := models.YeekeSyncRun{
|
||||||
|
Status: "running", Trigger: "manual", StartedAt: time.Now().UTC(),
|
||||||
|
ActiveSlot: &slot, LeaseOwner: "still-alive", LeaseExpiresAt: &future,
|
||||||
|
}
|
||||||
|
if e := db.Create(&holding).Error; e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
c, _ := yeekeclient.New("http://unused.invalid")
|
||||||
|
s := NewService(db, c, Config{PageSize: 10})
|
||||||
|
if _, e := s.acquire(context.Background(), "scheduled"); e == nil {
|
||||||
|
t.Fatal("a still-valid lease must not be taken over or bypassed")
|
||||||
|
}
|
||||||
|
var row models.YeekeSyncRun
|
||||||
|
if e := db.First(&row, holding.ID).Error; e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
if row.Status != "running" || row.ActiveSlot == nil {
|
||||||
|
t.Fatalf("holder must be untouched: %+v", row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConcurrentTakeoverExactlyOneWins races two acquire() calls against the
|
||||||
|
// same stale, expired-lease row. Both attempt the takeover UPDATE and then a
|
||||||
|
// Create; the takeover UPDATE is idempotent (the loser affects zero rows
|
||||||
|
// since the row's status is no longer "running" by the time it runs), and
|
||||||
|
// the ux_yeeke_sync_run_active_slot unique index arbitrates the Create race
|
||||||
|
// the same way it already does for two brand-new concurrent runs. Exactly
|
||||||
|
// one goroutine must come away holding the slot.
|
||||||
|
func TestConcurrentTakeoverExactlyOneWins(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
slot := uint8(1)
|
||||||
|
past := time.Now().UTC().Add(-time.Hour)
|
||||||
|
stale := models.YeekeSyncRun{
|
||||||
|
Status: "running", Trigger: "scheduled", StartedAt: past.Add(-time.Minute),
|
||||||
|
ActiveSlot: &slot, LeaseOwner: "dead-process", LeaseExpiresAt: &past,
|
||||||
|
}
|
||||||
|
if e := db.Create(&stale).Error; e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
// SQLite only allows one writer at a time; serialize connections through
|
||||||
|
// the Go pool (same pattern as app/goauto/purchase/order_backfill_test.go
|
||||||
|
// and friends) so the race is decided by acquire()'s own logic rather
|
||||||
|
// than by spurious "database is locked" errors.
|
||||||
|
if sqlDB, e := db.DB(); e == nil {
|
||||||
|
sqlDB.SetMaxOpenConns(1)
|
||||||
|
}
|
||||||
|
c, _ := yeekeclient.New("http://unused.invalid")
|
||||||
|
s := NewService(db, c, Config{PageSize: 10})
|
||||||
|
|
||||||
|
const n = 8
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
oks := make([]bool, n)
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(i int) {
|
||||||
|
defer wg.Done()
|
||||||
|
_, e := s.acquire(context.Background(), "manual")
|
||||||
|
oks[i] = e == nil
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
winners := 0
|
||||||
|
for _, ok := range oks {
|
||||||
|
if ok {
|
||||||
|
winners++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if winners != 1 {
|
||||||
|
t.Fatalf("winners=%d, want exactly 1 (active_slot must arbitrate concurrent takeover attempts)", winners)
|
||||||
|
}
|
||||||
|
var holders int64
|
||||||
|
db.Model(&models.YeekeSyncRun{}).Where("active_slot = ?", 1).Count(&holders)
|
||||||
|
if holders != 1 {
|
||||||
|
t.Fatalf("holders=%d, want exactly 1 row holding active_slot after the race", holders)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Defect 2 (#336): itemKey must not depend on item order --------------
|
||||||
|
|
||||||
|
// TestItemKeyStableAcrossReorder syncs the same package twice with its two
|
||||||
|
// items in reversed order the second time. Reordering must not create new
|
||||||
|
// rows: each item's identity must key off its own IDs, not its position.
|
||||||
|
func TestItemKeyStableAcrossReorder(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
var call int32
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
n := atomic.AddInt32(&call, 1)
|
||||||
|
if n == 1 {
|
||||||
|
fmt.Fprint(w, page([]string{packageWithItems("p1", item("i1", "item-a", "var-a"), item("i2", "item-b", "var-b"))}, 1, 1))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n == 2 {
|
||||||
|
// Same package, items reordered.
|
||||||
|
fmt.Fprint(w, page([]string{packageWithItems("p1", item("i2", "item-b", "var-b"), item("i1", "item-a", "var-a"))}, 1, 1))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprint(w, page(nil, 0, 0))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
c, _ := yeekeclient.New(srv.URL)
|
||||||
|
s := NewService(db, c, Config{PageSize: 10})
|
||||||
|
|
||||||
|
if _, err := s.Sync(context.Background(), "manual"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := s.Sync(context.Background(), "manual"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var pkg models.YeekeReturnPackage
|
||||||
|
if e := db.Where("external_id = ?", "p1").First(&pkg).Error; e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
var n int64
|
||||||
|
db.Model(&models.YeekeReturnItem{}).Where("package_id = ?", pkg.ID).Count(&n)
|
||||||
|
if n != 2 {
|
||||||
|
t.Fatalf("items=%d, want 2 (reordering the same items must not duplicate rows)", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestItemKeyIndexFallbackForItemsLackingAllIDs covers a package whose items
|
||||||
|
// carry no yeeke-issued identifiers at all: the positional index is the only
|
||||||
|
// thing that can distinguish them, so the fallback must still apply and keep
|
||||||
|
// them as separate rows.
|
||||||
|
func TestItemKeyIndexFallbackForItemsLackingAllIDs(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, page([]string{packageWithItems("p1", itemNoIDs(), itemNoIDs())}, 1, 1))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
c, _ := yeekeclient.New(srv.URL)
|
||||||
|
s := NewService(db, c, Config{PageSize: 10})
|
||||||
|
if _, err := s.Sync(context.Background(), "manual"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var pkg models.YeekeReturnPackage
|
||||||
|
if e := db.Where("external_id = ?", "p1").First(&pkg).Error; e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
var n int64
|
||||||
|
db.Model(&models.YeekeReturnItem{}).Where("package_id = ?", pkg.ID).Count(&n)
|
||||||
|
if n != 2 {
|
||||||
|
t.Fatalf("items=%d, want 2 (items lacking all IDs must still be distinguished by position)", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestItemKeyDistinctVariationsOfSameItemID pins existing behavior: two
|
||||||
|
// items sharing the same itemID but different variationIDs are, and must
|
||||||
|
// remain, two distinct rows.
|
||||||
|
func TestItemKeyDistinctVariationsOfSameItemID(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, page([]string{packageWithItems("p1", item("i1", "item-a", "var-1"), item("i2", "item-a", "var-2"))}, 1, 1))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
c, _ := yeekeclient.New(srv.URL)
|
||||||
|
s := NewService(db, c, Config{PageSize: 10})
|
||||||
|
if _, err := s.Sync(context.Background(), "manual"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var pkg models.YeekeReturnPackage
|
||||||
|
if e := db.Where("external_id = ?", "p1").First(&pkg).Error; e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
var n int64
|
||||||
|
db.Model(&models.YeekeReturnItem{}).Where("package_id = ?", pkg.ID).Count(&n)
|
||||||
|
if n != 2 {
|
||||||
|
t.Fatalf("items=%d, want 2 (same itemID with different variationID must stay distinct)", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestResyncUpdatesClaimTimeAndDestroyDeadLine: yeeke may correct a package's
|
||||||
|
// claim time or destroy deadline between two syncs of the same package. The
|
||||||
|
// second sync must overwrite the stored values, not just refresh
|
||||||
|
// last_synced_at.
|
||||||
|
func TestResyncUpdatesClaimTimeAndDestroyDeadLine(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
recordWithTimes := func(claimTime, destroyDeadLine string) string {
|
||||||
|
b, _ := json.Marshal(map[string]any{
|
||||||
|
"id": "p1", "ordersn": "o-p1", "trackingNo": "t-p1", "status": "1",
|
||||||
|
"claimTime": claimTime, "destroyDeadLine": destroyDeadLine,
|
||||||
|
"items": []map[string]any{{
|
||||||
|
"id": "p1-i1", "itemId": "i", "variationId": "v1",
|
||||||
|
"itemName": "n", "variationName": "v", "variationQuantityPurchased": "1",
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
var body string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, body)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
c, _ := yeekeclient.New(srv.URL)
|
||||||
|
s := NewService(db, c, Config{PageSize: 20})
|
||||||
|
|
||||||
|
body = page([]string{recordWithTimes("2026-09-01 10:00:00", "2026-10-01 10:00:00")}, 1, 1)
|
||||||
|
if _, err := s.Sync(context.Background(), "manual"); err != nil {
|
||||||
|
t.Fatalf("first sync: %v", err)
|
||||||
|
}
|
||||||
|
var row models.YeekeReturnPackage
|
||||||
|
db.Where("external_id = ?", "p1").First(&row)
|
||||||
|
if got := row.ClaimTime.Format("2006-01-02"); got != "2026-09-01" {
|
||||||
|
t.Fatalf("claim_time after first sync = %s", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
body = page([]string{recordWithTimes("2026-09-15 08:00:00", "2026-10-15 08:00:00")}, 1, 1)
|
||||||
|
if _, err := s.Sync(context.Background(), "manual"); err != nil {
|
||||||
|
t.Fatalf("second sync: %v", err)
|
||||||
|
}
|
||||||
|
db.Where("external_id = ?", "p1").First(&row)
|
||||||
|
if got := row.ClaimTime.Format("2006-01-02"); got != "2026-09-15" {
|
||||||
|
t.Fatalf("claim_time after resync = %s, want updated value", got)
|
||||||
|
}
|
||||||
|
if got := row.DestroyDeadLine.Format("2006-01-02"); got != "2026-10-15" {
|
||||||
|
t.Fatalf("destroy_dead_line after resync = %s, want updated value", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package yeeke
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go-admin/app/goauto/models"
|
||||||
|
"go-admin/app/goauto/yeekeclient"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// rejectZeroLastSyncedAt emulates MySQL strict mode (NO_ZERO_DATE) in SQLite:
|
||||||
|
// an INSERT carrying a zero last_synced_at is refused, exactly as production
|
||||||
|
// MySQL refused the old placeholder insert and failed every record.
|
||||||
|
func rejectZeroLastSyncedAt(t *testing.T, db *gorm.DB) {
|
||||||
|
t.Helper()
|
||||||
|
err := db.Callback().Create().Before("gorm:create").Register("test:strict_no_zero_date", func(tx *gorm.DB) {
|
||||||
|
switch v := tx.Statement.Dest.(type) {
|
||||||
|
case *models.YeekeReturnPackage:
|
||||||
|
if v.LastSyncedAt.IsZero() {
|
||||||
|
_ = tx.AddError(errors.New("Error 1292: Incorrect datetime value: '0000-00-00' for column 'last_synced_at'"))
|
||||||
|
}
|
||||||
|
case *models.YeekeReturnItem:
|
||||||
|
if v.LastSyncedAt.IsZero() {
|
||||||
|
_ = tx.AddError(errors.New("Error 1292: Incorrect datetime value: '0000-00-00' for column 'last_synced_at'"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSyncWritesUnderStrictDatetimeMode(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
rejectZeroLastSyncedAt(t, db)
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, page([]string{record("p1", "i", "v1", "1"), record("p2", "i", "v2", "1")}, 2, 1))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
c, _ := yeekeclient.New(srv.URL)
|
||||||
|
rep, err := NewService(db, c, Config{PageSize: 20}).Sync(context.Background(), "manual")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("sync: %v (rep=%+v)", err, rep)
|
||||||
|
}
|
||||||
|
if rep.Failed != 0 || rep.Created != 2 {
|
||||||
|
t.Fatalf("rep=%+v, want 2 created and 0 failed", rep)
|
||||||
|
}
|
||||||
|
var pkgs, items int64
|
||||||
|
db.Model(&models.YeekeReturnPackage{}).Count(&pkgs)
|
||||||
|
db.Model(&models.YeekeReturnItem{}).Count(&items)
|
||||||
|
if pkgs != 2 || items != 2 {
|
||||||
|
t.Fatalf("stored packages=%d items=%d", pkgs, items)
|
||||||
|
}
|
||||||
|
var p models.YeekeReturnPackage
|
||||||
|
db.First(&p)
|
||||||
|
if p.LastSyncedAt.IsZero() || time.Since(p.LastSyncedAt) > time.Minute {
|
||||||
|
t.Fatalf("last_synced_at not set: %v", p.LastSyncedAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// When records fail to write, the run must say why and must not claim success
|
||||||
|
// when nothing was stored.
|
||||||
|
func TestSyncReportsWriteFailureReason(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
if err := db.Callback().Create().Before("gorm:create").Register("test:fail_items", func(tx *gorm.DB) {
|
||||||
|
if _, ok := tx.Statement.Dest.(*models.YeekeReturnItem); ok {
|
||||||
|
_ = tx.AddError(errors.New("simulated item write failure"))
|
||||||
|
}
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, page([]string{record("p1", "i", "v1", "1")}, 1, 1))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
c, _ := yeekeclient.New(srv.URL)
|
||||||
|
rep, _ := NewService(db, c, Config{PageSize: 20}).Sync(context.Background(), "manual")
|
||||||
|
if rep.Status != "failed" || rep.Failed != 1 {
|
||||||
|
t.Fatalf("rep=%+v, want failed with 1 failure", rep)
|
||||||
|
}
|
||||||
|
var run models.YeekeSyncRun
|
||||||
|
db.Order("id DESC").First(&run)
|
||||||
|
if !strings.Contains(run.ErrorMessage, "1 条写入失败") || !strings.Contains(run.ErrorMessage, "simulated item write failure") {
|
||||||
|
t.Fatalf("error_message = %q", run.ErrorMessage)
|
||||||
|
}
|
||||||
|
// The package insert shares the transaction, so no half-written package.
|
||||||
|
var pkgs int64
|
||||||
|
db.Model(&models.YeekeReturnPackage{}).Count(&pkgs)
|
||||||
|
if pkgs != 0 {
|
||||||
|
t.Fatalf("package rows = %d, want 0 after rolled-back item failure", pkgs)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package yeeke
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"go-admin/app/goauto/migrations"
|
||||||
|
"go-admin/app/goauto/models"
|
||||||
|
"go-admin/app/goauto/yeekeclient"
|
||||||
|
"gorm.io/driver/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSyncIsIdempotentAndKeepsVariationsSeparate(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{"success":true,"result":{"records":[{"id":"p1","ordersn":"o","trackingNo":"t","status":1,"items":[{"id":"a","itemId":"i","variationId":"v1","itemName":"n","variationName":"red","variationQuantityPurchased":1},{"id":"b","itemId":"i","variationId":"v2","itemName":"n","variationName":"blue","variationQuantityPurchased":1}]}],"total":1,"pages":1}}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
db, _ := gorm.Open(sqlite.Open("file:yeeke-sync?mode=memory&cache=shared"), &gorm.Config{})
|
||||||
|
if e := migrations.Migrate(db); e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
c, _ := yeekeclient.New(server.URL)
|
||||||
|
s := NewService(db, c, Config{PageSize: 10})
|
||||||
|
if _, e := s.Sync(context.Background(), "manual"); e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
if _, e := s.Sync(context.Background(), "manual"); e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
var n int64
|
||||||
|
db.Model(&models.YeekeReturnPackage{}).Count(&n)
|
||||||
|
if n != 1 {
|
||||||
|
t.Fatalf("packages=%d", n)
|
||||||
|
}
|
||||||
|
db.Model(&models.YeekeReturnItem{}).Count(&n)
|
||||||
|
if n != 2 {
|
||||||
|
t.Fatalf("items=%d", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
package yeekeclient
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/cookiejar"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrSessionInvalid = errors.New("yeeke session invalid")
|
||||||
|
var ErrNoSession = errors.New("yeeke session unavailable")
|
||||||
|
|
||||||
|
type Session struct {
|
||||||
|
Username, Token, CookiesJSON, UserID string
|
||||||
|
ExpiresAt time.Time
|
||||||
|
}
|
||||||
|
type Captcha struct {
|
||||||
|
Image []byte
|
||||||
|
CheckKey string
|
||||||
|
ContentType string
|
||||||
|
}
|
||||||
|
type Client struct {
|
||||||
|
baseURL string
|
||||||
|
http *http.Client
|
||||||
|
jar *cookiejar.Jar
|
||||||
|
token string
|
||||||
|
retry int
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(baseURL string) (*Client, error) {
|
||||||
|
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||||
|
if baseURL == "" {
|
||||||
|
return nil, fmt.Errorf("yeeke base_url required")
|
||||||
|
}
|
||||||
|
j, e := cookiejar.New(nil)
|
||||||
|
if e != nil {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
return &Client{baseURL: baseURL, jar: j, http: &http.Client{Jar: j, Timeout: 60 * time.Second}}, nil
|
||||||
|
}
|
||||||
|
func (c *Client) SetToken(t string) { c.token = t }
|
||||||
|
func (c *Client) Token() string { return c.token }
|
||||||
|
|
||||||
|
type cookieDTO struct{ Name, Value, Path string }
|
||||||
|
|
||||||
|
func (c *Client) ExportCookiesJSON() (string, error) {
|
||||||
|
u, e := url.Parse(c.baseURL)
|
||||||
|
if e != nil {
|
||||||
|
return "", e
|
||||||
|
}
|
||||||
|
a := []cookieDTO{}
|
||||||
|
for _, x := range c.jar.Cookies(u) {
|
||||||
|
a = append(a, cookieDTO{x.Name, x.Value, x.Path})
|
||||||
|
}
|
||||||
|
b, e := json.Marshal(a)
|
||||||
|
return string(b), e
|
||||||
|
}
|
||||||
|
func (c *Client) ImportCookiesJSON(s string) error {
|
||||||
|
var a []cookieDTO
|
||||||
|
if e := json.Unmarshal([]byte(s), &a); e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
u, e := url.Parse(c.baseURL)
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
cs := []*http.Cookie{}
|
||||||
|
for _, x := range a {
|
||||||
|
if x.Name != "" {
|
||||||
|
p := x.Path
|
||||||
|
if p == "" {
|
||||||
|
p = "/"
|
||||||
|
}
|
||||||
|
cs = append(cs, &http.Cookie{Name: x.Name, Value: x.Value, Path: p})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.jar.SetCookies(u, cs)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (c *Client) do(ctx context.Context, method, path string, body any, query url.Values) (json.RawMessage, error) {
|
||||||
|
b := io.Reader(nil)
|
||||||
|
if body != nil {
|
||||||
|
x, e := json.Marshal(body)
|
||||||
|
if e != nil {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
b = bytes.NewReader(x)
|
||||||
|
}
|
||||||
|
u := c.baseURL + path
|
||||||
|
if len(query) > 0 {
|
||||||
|
u += "?" + query.Encode()
|
||||||
|
}
|
||||||
|
req, e := http.NewRequestWithContext(ctx, method, u, b)
|
||||||
|
if e != nil {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
// yeeke is driven through its web front end; a Go default user agent is
|
||||||
|
// treated differently from the browser (see demo/yeeke_demo.py and the HAR).
|
||||||
|
req.Header.Set("User-Agent", browserUserAgent)
|
||||||
|
if body != nil {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
// The yeeke web client authenticates with the X-Access-Token header (HAR);
|
||||||
|
// keeping the token out of the URL also keeps it out of any access log.
|
||||||
|
if c.token != "" {
|
||||||
|
req.Header.Set("X-Access-Token", c.token)
|
||||||
|
}
|
||||||
|
resp, e := c.http.Do(req)
|
||||||
|
if e != nil {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
raw, e := io.ReadAll(resp.Body)
|
||||||
|
if e != nil {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
if resp.StatusCode == 401 || resp.StatusCode == 403 {
|
||||||
|
return nil, ErrSessionInvalid
|
||||||
|
}
|
||||||
|
if resp.StatusCode >= 500 {
|
||||||
|
return nil, fmt.Errorf("yeeke http %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var env struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Code int `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Result json.RawMessage `json:"result"`
|
||||||
|
}
|
||||||
|
if e = json.Unmarshal(raw, &env); e != nil {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
if !env.Success {
|
||||||
|
return nil, &APIError{Code: env.Code, Message: env.Message}
|
||||||
|
}
|
||||||
|
return env.Result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// browserUserAgent mirrors the desktop Chrome the yeeke web client runs in.
|
||||||
|
const browserUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||||
|
|
||||||
|
// APIError is a yeeke business failure (success=false). Message is yeeke's own
|
||||||
|
// short text (e.g. "验证码错误"); it never contains our credentials or token.
|
||||||
|
type APIError struct {
|
||||||
|
Code int
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *APIError) Error() string {
|
||||||
|
return fmt.Sprintf("yeeke request failed code=%d: %s", e.Code, safeMessage(e.Message))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Is keeps the historical contract: an expired or missing login is reported as
|
||||||
|
// ErrSessionInvalid so callers can re-login.
|
||||||
|
func (e *APIError) Is(target error) bool {
|
||||||
|
if target != ErrSessionInvalid {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return e.Code == 401 || strings.Contains(e.Message, "token") || strings.Contains(e.Message, "登录已过期") || strings.Contains(e.Message, "未登录") || strings.Contains(e.Message, "重新登录") || (strings.Contains(e.Message, "登录") && strings.Contains(e.Message, "失效"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// isCaptchaRejected reports a wrong captcha, the only login failure worth
|
||||||
|
// retrying with a fresh image.
|
||||||
|
func (e *APIError) isCaptchaRejected() bool { return strings.Contains(e.Message, "验证码") }
|
||||||
|
|
||||||
|
func safeMessage(m string) string {
|
||||||
|
r := []rune(strings.TrimSpace(m))
|
||||||
|
if len(r) > 60 {
|
||||||
|
r = r[:60]
|
||||||
|
}
|
||||||
|
return string(r)
|
||||||
|
}
|
||||||
|
func (c *Client) FetchCaptcha(ctx context.Context) (*Captcha, error) {
|
||||||
|
q := url.Values{"_t": {strconv.FormatInt(time.Now().UnixMilli(), 10)}}
|
||||||
|
raw, e := c.do(ctx, http.MethodGet, "/agent-foreign/sys/randomImage", nil, q)
|
||||||
|
if e != nil {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
// The live randomImage response carries the captcha key as "key" (seen in
|
||||||
|
// the HAR evidence for #336); "checkKey" is only the name the login body
|
||||||
|
// uses. Accept both so a future rename on either side still works.
|
||||||
|
var p struct {
|
||||||
|
Image string `json:"image"`
|
||||||
|
Key string `json:"key"`
|
||||||
|
CheckKey string `json:"checkKey"`
|
||||||
|
}
|
||||||
|
if e = json.Unmarshal(raw, &p); e != nil {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
key := p.Key
|
||||||
|
if key == "" {
|
||||||
|
key = p.CheckKey
|
||||||
|
}
|
||||||
|
if key == "" {
|
||||||
|
return nil, fmt.Errorf("yeeke captcha response missing key")
|
||||||
|
}
|
||||||
|
s := p.Image
|
||||||
|
if i := strings.Index(s, ","); i >= 0 {
|
||||||
|
s = s[i+1:]
|
||||||
|
}
|
||||||
|
img, e := base64.StdEncoding.DecodeString(s)
|
||||||
|
if e != nil {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
return &Captcha{Image: img, CheckKey: key, ContentType: "image/jpeg"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoginResult struct {
|
||||||
|
Token, UserID, Username string
|
||||||
|
ExpiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type OCR interface {
|
||||||
|
Recognize(context.Context, []byte) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginWithOCR keeps captcha bytes in memory and never includes credentials or
|
||||||
|
// recognized text in returned errors. A fresh image is fetched for every try.
|
||||||
|
func (c *Client) LoginWithOCR(ctx context.Context, ocr OCR, username, password string, maxAttempts int) (*LoginResult, error) {
|
||||||
|
if ocr == nil {
|
||||||
|
return nil, fmt.Errorf("yeeke OCR unavailable")
|
||||||
|
}
|
||||||
|
if maxAttempts <= 0 || maxAttempts > 5 {
|
||||||
|
maxAttempts = 3
|
||||||
|
}
|
||||||
|
emptyOCR, captchaRejected := 0, 0
|
||||||
|
for i := 0; i < maxAttempts; i++ {
|
||||||
|
cap, e := c.FetchCaptcha(ctx)
|
||||||
|
if e != nil {
|
||||||
|
return nil, fmt.Errorf("yeeke 获取验证码失败: %w", e)
|
||||||
|
}
|
||||||
|
code, e := ocr.Recognize(ctx, cap.Image)
|
||||||
|
if e != nil {
|
||||||
|
return nil, fmt.Errorf("yeeke OCR unavailable")
|
||||||
|
}
|
||||||
|
code = strings.TrimSpace(code)
|
||||||
|
if code == "" {
|
||||||
|
emptyOCR++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out, e := c.Login(ctx, username, password, code, cap.CheckKey)
|
||||||
|
if e == nil {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
var apiErr *APIError
|
||||||
|
if errors.As(e, &apiErr) && apiErr.isCaptchaRejected() {
|
||||||
|
captchaRejected++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Anything else (wrong account/password, locked account, network) will
|
||||||
|
// not be fixed by another captcha; stop instead of burning attempts.
|
||||||
|
if errors.As(e, &apiErr) {
|
||||||
|
return nil, fmt.Errorf("yeeke 登录被拒绝: %s", safeMessage(apiErr.Message))
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("yeeke 登录请求失败: %w", e)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("yeeke login failed after limited captcha attempts (attempts=%d, captcha_rejected=%d, ocr_empty=%d)", maxAttempts, captchaRejected, emptyOCR)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Login(ctx context.Context, username, password, captcha, checkKey string) (*LoginResult, error) {
|
||||||
|
if username == "" || password == "" || captcha == "" || checkKey == "" {
|
||||||
|
return nil, fmt.Errorf("login fields required")
|
||||||
|
}
|
||||||
|
raw, e := c.do(ctx, http.MethodPost, "/agent-foreign/sys/login", map[string]any{"username": username, "password": password, "captcha": captcha, "checkKey": checkKey, "agentCode": "mmt", "remember_me": true}, nil)
|
||||||
|
if e != nil {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
var p struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
UserInfo struct {
|
||||||
|
ID any `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
} `json:"userInfo"`
|
||||||
|
}
|
||||||
|
if e = json.Unmarshal(raw, &p); e != nil {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
if p.Token == "" {
|
||||||
|
return nil, fmt.Errorf("yeeke login response missing token")
|
||||||
|
}
|
||||||
|
c.token = p.Token
|
||||||
|
return &LoginResult{Token: p.Token, UserID: fmt.Sprint(p.UserInfo.ID), Username: p.UserInfo.Username, ExpiresAt: time.Now().UTC().Add(24 * time.Hour)}, nil
|
||||||
|
}
|
||||||
|
func (c *Client) CheckSession(ctx context.Context) error {
|
||||||
|
// /shopee/user/info is what the web client itself calls after login (HAR);
|
||||||
|
// the earlier /sys/userInfo path does not exist and answered HTTP 500.
|
||||||
|
q := url.Values{"_t": {strconv.FormatInt(time.Now().UnixMilli(), 10)}}
|
||||||
|
_, e := c.do(ctx, http.MethodGet, "/agent-foreign/shopee/user/info", nil, q)
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReturnPage struct {
|
||||||
|
Records []ReturnPackage `json:"records"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
Pages int `json:"pages"`
|
||||||
|
}
|
||||||
|
type Timestamp struct{ time.Time }
|
||||||
|
|
||||||
|
func (t *Timestamp) UnmarshalJSON(b []byte) error {
|
||||||
|
var s string
|
||||||
|
if json.Unmarshal(b, &s) != nil || s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, f := range []string{time.RFC3339, "2006-01-02 15:04:05", "2006-01-02"} {
|
||||||
|
// yeeke returns naive Beijing wall-clock times ("2026-09-21 14:13:29");
|
||||||
|
// reading them as UTC would shift every time by eight hours.
|
||||||
|
if x, e := time.ParseInLocation(f, s, beijing); e == nil {
|
||||||
|
t.Time = x
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReturnPackage struct {
|
||||||
|
ID any `json:"id"`
|
||||||
|
Ordersn string `json:"ordersn"`
|
||||||
|
TrackingNo string `json:"trackingNo"`
|
||||||
|
ShopID any `json:"shopId"`
|
||||||
|
ShopName string `json:"shopName"`
|
||||||
|
WareCode string `json:"wareCode"`
|
||||||
|
WareHouse string `json:"wareHouse"`
|
||||||
|
WareName string `json:"wareName"`
|
||||||
|
Status any `json:"status"`
|
||||||
|
ClaimTime *Timestamp `json:"claimTime"`
|
||||||
|
CreateTime *Timestamp `json:"createTime"`
|
||||||
|
UpdateTime *Timestamp `json:"updateTime"`
|
||||||
|
DestroyDeadLine *Timestamp `json:"destroyDeadLine"`
|
||||||
|
Items []ReturnItem `json:"items"`
|
||||||
|
}
|
||||||
|
type ReturnItem struct {
|
||||||
|
ID any `json:"id"`
|
||||||
|
ItemID any `json:"itemId"`
|
||||||
|
VariationID any `json:"variationId"`
|
||||||
|
ItemName string `json:"itemName"`
|
||||||
|
VariationName string `json:"variationName"`
|
||||||
|
Image string `json:"image"`
|
||||||
|
Quantity FlexInt `json:"variationQuantityPurchased"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// beijing is yeeke's server wall clock (fixed UTC+8, no DST).
|
||||||
|
var beijing = time.FixedZone("CST", 8*3600)
|
||||||
|
|
||||||
|
// FlexInt accepts a JSON number or a numeric string; yeeke sends
|
||||||
|
// variationQuantityPurchased as a string (e.g. "2", see HAR). Empty, null or
|
||||||
|
// non-numeric values decode as 0 instead of failing the whole page.
|
||||||
|
type FlexInt int64
|
||||||
|
|
||||||
|
func (f *FlexInt) UnmarshalJSON(b []byte) error {
|
||||||
|
s := strings.Trim(strings.TrimSpace(string(b)), `"`)
|
||||||
|
if s == "" || s == "null" {
|
||||||
|
*f = 0
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
n, err := strconv.ParseInt(s, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
*f = 0
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
*f = FlexInt(n)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) List(ctx context.Context, pageNo, pageSize int) (ReturnPage, error) {
|
||||||
|
// 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"}
|
||||||
|
raw, e := c.do(ctx, http.MethodPost, "/agent-foreign/packageClaimRec/relation/list", body, nil)
|
||||||
|
if e != nil {
|
||||||
|
return ReturnPage{}, e
|
||||||
|
}
|
||||||
|
var p ReturnPage
|
||||||
|
if e = json.Unmarshal(raw, &p); e != nil {
|
||||||
|
return p, e
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
package yeekeclient
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCaptchaLoginAndReadOnlyList(t *testing.T) {
|
||||||
|
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/agent-foreign/sys/randomImage":
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{"success": true, "result": map[string]string{"image": "data:image/jpg;base64,SGk=", "key": "k"}})
|
||||||
|
case "/agent-foreign/sys/login":
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{"success": true, "result": map[string]any{"token": "opaque", "userInfo": map[string]any{"id": "u"}}})
|
||||||
|
case "/agent-foreign/packageClaimRec/relation/list":
|
||||||
|
if r.Header.Get("X-Access-Token") != "opaque" {
|
||||||
|
t.Errorf("X-Access-Token header missing")
|
||||||
|
}
|
||||||
|
if r.URL.Query().Get("token") != "" {
|
||||||
|
t.Errorf("token must not be sent in the URL")
|
||||||
|
}
|
||||||
|
var body map[string]any
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
if body["column"] != "createTime" || body["claimFlag"] != "1" || body["status"] != "1" || body["relationFlag"] != "1" || body["order"] != "desc" {
|
||||||
|
t.Errorf("list body shape differs from the web client: %v", body)
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{"success": true, "result": map[string]any{"records": []any{}, "total": 0}})
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer s.Close()
|
||||||
|
c, _ := New(s.URL)
|
||||||
|
cap, e := c.FetchCaptcha(context.Background())
|
||||||
|
if e != nil || string(cap.Image) != "Hi" || cap.CheckKey != "k" {
|
||||||
|
t.Fatalf("captcha=%+v err=%v", cap, e)
|
||||||
|
}
|
||||||
|
if _, e = c.Login(context.Background(), "u", "p", "1234", "k"); e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
if _, e = c.List(context.Background(), 1, 10); e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The live randomImage response names the captcha key "key" (HAR evidence for
|
||||||
|
// #336). A client that only read "checkKey" sent an empty checkKey, never
|
||||||
|
// reached the login endpoint, and every OCR attempt "failed". This pins the
|
||||||
|
// real response shape end to end: the key from randomImage must arrive as the
|
||||||
|
// login body's checkKey, together with remember_me.
|
||||||
|
func TestLoginWithOCRSendsCaptchaKeyFromRandomImage(t *testing.T) {
|
||||||
|
var loginBody map[string]any
|
||||||
|
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/agent-foreign/sys/randomImage":
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{"success": true, "code": 0, "result": map[string]string{"image": "data:image/jpg;base64,SGk=", "key": "0123456789abcdef0123456789abcdef"}})
|
||||||
|
case "/agent-foreign/sys/login":
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&loginBody)
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{"success": true, "code": 200, "result": map[string]any{"token": "opaque", "userInfo": map[string]any{"id": "u"}}})
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer s.Close()
|
||||||
|
c, _ := New(s.URL)
|
||||||
|
if _, e := c.LoginWithOCR(context.Background(), fixedOCR("abcd"), "u", "p", 3); e != nil {
|
||||||
|
t.Fatalf("login: %v", e)
|
||||||
|
}
|
||||||
|
if loginBody == nil {
|
||||||
|
t.Fatal("login endpoint was never called")
|
||||||
|
}
|
||||||
|
if loginBody["checkKey"] != "0123456789abcdef0123456789abcdef" {
|
||||||
|
t.Fatalf("checkKey = %v, want the randomImage key", loginBody["checkKey"])
|
||||||
|
}
|
||||||
|
if loginBody["remember_me"] != true || loginBody["captcha"] != "abcd" || loginBody["agentCode"] != "mmt" {
|
||||||
|
t.Fatalf("unexpected login body shape: %v", keysOnly(loginBody))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchCaptchaRejectsResponseWithoutKey(t *testing.T) {
|
||||||
|
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{"success": true, "result": map[string]string{"image": "data:image/jpg;base64,SGk="}})
|
||||||
|
}))
|
||||||
|
defer s.Close()
|
||||||
|
c, _ := New(s.URL)
|
||||||
|
if _, e := c.FetchCaptcha(context.Background()); e == nil {
|
||||||
|
t.Fatal("expected an explicit error when the captcha key is missing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type fixedOCR string
|
||||||
|
|
||||||
|
func (f fixedOCR) Recognize(context.Context, []byte) (string, error) { return string(f), nil }
|
||||||
|
|
||||||
|
func keysOnly(m map[string]any) []string {
|
||||||
|
out := make([]string, 0, len(m))
|
||||||
|
for k := range m {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirrors demo/yeeke_demo.py and the HAR: browser user agent on every call and
|
||||||
|
// a _t cache-buster on randomImage.
|
||||||
|
func TestRequestsCarryBrowserUserAgentAndCaptchaTimestamp(t *testing.T) {
|
||||||
|
var captchaQuery, captchaUA, loginUA string
|
||||||
|
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/agent-foreign/sys/randomImage":
|
||||||
|
captchaQuery, captchaUA = r.URL.RawQuery, r.UserAgent()
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{"success": true, "code": 0, "result": map[string]string{"image": "data:image/jpg;base64,SGk=", "key": "k"}})
|
||||||
|
case "/agent-foreign/sys/login":
|
||||||
|
loginUA = r.UserAgent()
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{"success": true, "code": 200, "result": map[string]any{"token": "opaque"}})
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer s.Close()
|
||||||
|
c, _ := New(s.URL)
|
||||||
|
if _, e := c.LoginWithOCR(context.Background(), fixedOCR("abcd"), "u", "p", 3); e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(captchaQuery, "_t=") {
|
||||||
|
t.Fatalf("randomImage query = %q, want _t timestamp", captchaQuery)
|
||||||
|
}
|
||||||
|
for _, ua := range []string{captchaUA, loginUA} {
|
||||||
|
if !strings.Contains(ua, "Chrome/") {
|
||||||
|
t.Fatalf("user agent = %q, want browser UA", ua)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A wrong captcha is retried with a fresh image; a wrong password is not.
|
||||||
|
func TestLoginWithOCRRetriesOnlyCaptchaRejections(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
responses []map[string]any
|
||||||
|
wantOK bool
|
||||||
|
wantLogins int
|
||||||
|
wantErrSubstr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "captcha wrong then ok",
|
||||||
|
responses: []map[string]any{
|
||||||
|
{"success": false, "code": 500, "message": "验证码错误"},
|
||||||
|
{"success": true, "code": 200, "result": map[string]any{"token": "opaque"}},
|
||||||
|
},
|
||||||
|
wantOK: true, wantLogins: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wrong password stops immediately",
|
||||||
|
responses: []map[string]any{
|
||||||
|
{"success": false, "code": 500, "message": "用户名或密码错误"},
|
||||||
|
{"success": true, "code": 200, "result": map[string]any{"token": "opaque"}},
|
||||||
|
},
|
||||||
|
wantLogins: 1, wantErrSubstr: "用户名或密码错误",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "captcha always wrong",
|
||||||
|
responses: []map[string]any{
|
||||||
|
{"success": false, "code": 500, "message": "验证码错误"},
|
||||||
|
{"success": false, "code": 500, "message": "验证码错误"},
|
||||||
|
{"success": false, "code": 500, "message": "验证码错误"},
|
||||||
|
},
|
||||||
|
wantLogins: 3, wantErrSubstr: "captcha_rejected=3",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
logins := 0
|
||||||
|
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/agent-foreign/sys/randomImage":
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{"success": true, "code": 0, "result": map[string]string{"image": "data:image/jpg;base64,SGk=", "key": "k"}})
|
||||||
|
case "/agent-foreign/sys/login":
|
||||||
|
resp := tc.responses[logins]
|
||||||
|
logins++
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer s.Close()
|
||||||
|
c, _ := New(s.URL)
|
||||||
|
_, e := c.LoginWithOCR(context.Background(), fixedOCR("abcd"), "u", "S3cr3t-Do-Not-Leak", 3)
|
||||||
|
if (e == nil) != tc.wantOK {
|
||||||
|
t.Fatalf("err = %v, wantOK = %v", e, tc.wantOK)
|
||||||
|
}
|
||||||
|
if logins != tc.wantLogins {
|
||||||
|
t.Fatalf("login calls = %d, want %d", logins, tc.wantLogins)
|
||||||
|
}
|
||||||
|
if e != nil {
|
||||||
|
if !strings.Contains(e.Error(), tc.wantErrSubstr) {
|
||||||
|
t.Fatalf("err = %q, want it to contain %q", e, tc.wantErrSubstr)
|
||||||
|
}
|
||||||
|
if strings.Contains(e.Error(), "S3cr3t-Do-Not-Leak") || strings.Contains(e.Error(), "abcd") {
|
||||||
|
t.Fatalf("error leaks credentials or captcha text: %q", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIErrorMapsExpiredLoginToSessionInvalid(t *testing.T) {
|
||||||
|
for msg, want := range map[string]bool{"token失效,请重新登录": true, "未登录": true, "登录已失效": true, "验证码错误": false, "用户名或密码错误": false} {
|
||||||
|
if got := errors.Is(&APIError{Code: 500, Message: msg}, ErrSessionInvalid); got != want {
|
||||||
|
t.Fatalf("%q: errors.Is(ErrSessionInvalid) = %v, want %v", msg, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !errors.Is(&APIError{Code: 401}, ErrSessionInvalid) {
|
||||||
|
t.Fatal("code 401 must map to ErrSessionInvalid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Field types taken from the HAR list response (values are fake): quantity is
|
||||||
|
// a numeric string, ids are strings, many fields are null, and times are
|
||||||
|
// naive Beijing wall-clock strings.
|
||||||
|
func TestListDecodesWebClientResponseShape(t *testing.T) {
|
||||||
|
const page = `{"success":true,"code":200,"message":"操作成功!","result":{
|
||||||
|
"records":[{"id":"1000000000000000001","ordersn":"FAKE-ORDER-1","trackingNo":"FAKE-TRACK-1",
|
||||||
|
"shopId":"123","shopName":"示例店铺","wareCode":"W1","wareHouse":"A-01","wareName":"示例仓",
|
||||||
|
"status":"1","claimTime":"2026-09-21 14:13:29","createTime":"2026-09-21 14:13:29",
|
||||||
|
"updateTime":"2026-09-21 14:13:29","destroyDeadLine":"2026-10-21 14:13:29",
|
||||||
|
"airwayBill":null,"secondSaleOrdersn":null,"photoList":null,
|
||||||
|
"items":[{"id":null,"itemId":"48800000000","variationId":"435000000000","itemName":"示例商品",
|
||||||
|
"variationName":"黑色,L","image":"https://example.invalid/a.jpg","variationQuantityPurchased":"2",
|
||||||
|
"aneroidMarkup":false,"num":null},
|
||||||
|
{"id":null,"itemId":"48800000000","variationId":"435000000001","itemName":"示例商品",
|
||||||
|
"variationName":"黑色,XL","image":"","variationQuantityPurchased":null}]}],
|
||||||
|
"total":1,"size":20,"current":1,"pages":1,"searchCount":true}}`
|
||||||
|
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(page))
|
||||||
|
}))
|
||||||
|
defer s.Close()
|
||||||
|
c, _ := New(s.URL)
|
||||||
|
c.SetToken("opaque")
|
||||||
|
p, e := c.List(context.Background(), 1, 20)
|
||||||
|
if e != nil {
|
||||||
|
t.Fatalf("list: %v", e)
|
||||||
|
}
|
||||||
|
if len(p.Records) != 1 || len(p.Records[0].Items) != 2 || p.Total != 1 || p.Pages != 1 {
|
||||||
|
t.Fatalf("unexpected page: %+v", p)
|
||||||
|
}
|
||||||
|
if p.Records[0].Items[0].Quantity != 2 || p.Records[0].Items[1].Quantity != 0 {
|
||||||
|
t.Fatalf("quantities = %d, %d", p.Records[0].Items[0].Quantity, p.Records[0].Items[1].Quantity)
|
||||||
|
}
|
||||||
|
got := p.Records[0].CreateTime.Time.UTC().Format(time.RFC3339)
|
||||||
|
if got != "2026-09-21T06:13:29Z" {
|
||||||
|
t.Fatalf("createTime = %s, want Beijing 14:13:29 = 06:13:29Z", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package yeekeclient
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Credentials struct{ Username, Password string }
|
||||||
|
|
||||||
|
// Connect restores and validates a cached session. Only an explicit invalid
|
||||||
|
// response deletes it; timeouts and 5xx preserve the usable cache.
|
||||||
|
func Connect(ctx context.Context, store *SessionStore, creds Credentials, baseURL string, ocr OCR, maxLogin int) (*Client, error) {
|
||||||
|
c, e := New(baseURL)
|
||||||
|
if e != nil {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
s, e := store.Load(ctx, creds.Username, time.Now().UTC())
|
||||||
|
if e == nil {
|
||||||
|
if e = c.ImportCookiesJSON(s.CookiesJSON); e == nil {
|
||||||
|
c.SetToken(s.Token)
|
||||||
|
if e = c.CheckSession(ctx); e == nil {
|
||||||
|
return c, nil
|
||||||
|
} else if errors.Is(e, ErrSessionInvalid) {
|
||||||
|
_ = store.Delete(ctx, creds.Username)
|
||||||
|
} else {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if !errors.Is(e, ErrNoSession) {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
r, e := c.LoginWithOCR(ctx, ocr, creds.Username, creds.Password, maxLogin)
|
||||||
|
if e != nil {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
cookies, _ := c.ExportCookiesJSON()
|
||||||
|
if e = store.Save(ctx, Session{Username: creds.Username, Token: r.Token, UserID: r.UserID, CookiesJSON: cookies, ExpiresAt: r.ExpiresAt}); e != nil {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
package yeekeclient
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"go-admin/app/goauto/models"
|
||||||
|
"gorm.io/driver/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newDB(t *testing.T) *gorm.DB {
|
||||||
|
t.Helper()
|
||||||
|
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&models.YeekeSession{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
type stubOCR struct {
|
||||||
|
codes []string
|
||||||
|
calls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubOCR) Recognize(context.Context, []byte) (string, error) {
|
||||||
|
if s.calls >= len(s.codes) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
c := s.codes[s.calls]
|
||||||
|
s.calls++
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// server builds a fake yeeke backend. loginOK controls whether /login accepts
|
||||||
|
// the submitted captcha; sessionValid controls whether /userInfo (used by
|
||||||
|
// CheckSession) reports the cached token as still good.
|
||||||
|
func fakeServer(t *testing.T, loginOK func(captcha string) bool, sessionValid func(token string) bool) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/agent-foreign/sys/randomImage":
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{"success": true, "result": map[string]string{"image": "data:image/jpg;base64,SGk=", "key": "k"}})
|
||||||
|
case "/agent-foreign/sys/login":
|
||||||
|
var body map[string]any
|
||||||
|
json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
captcha, _ := body["captcha"].(string)
|
||||||
|
if loginOK(captcha) {
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{"success": true, "result": map[string]any{"token": "tok-" + captcha, "userInfo": map[string]any{"id": "u1", "username": "u"}}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{"success": false, "code": 1, "message": "验证码错误"})
|
||||||
|
case "/agent-foreign/shopee/user/info":
|
||||||
|
token := r.Header.Get("X-Access-Token")
|
||||||
|
if sessionValid(token) {
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{"success": true, "result": map[string]any{}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{"success": false, "code": 401, "message": "登录已失效"})
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConnectLoginsOnceThenReusesSession: a fresh Connect performs exactly one
|
||||||
|
// login, and a second Connect call with the cached session valid performs no
|
||||||
|
// login at all (token reuse, no re-login when session valid).
|
||||||
|
func TestConnectLoginsOnceThenReusesSession(t *testing.T) {
|
||||||
|
db := newDB(t)
|
||||||
|
loginCalls := 0
|
||||||
|
srv := fakeServer(t,
|
||||||
|
func(captcha string) bool { loginCalls++; return captcha == "abcd" },
|
||||||
|
func(token string) bool { return token == "tok-abcd" },
|
||||||
|
)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
store := NewSessionStore(db)
|
||||||
|
ocr := &stubOCR{codes: []string{"abcd"}}
|
||||||
|
client, err := Connect(context.Background(), store, Credentials{Username: "u", Password: "p"}, srv.URL, ocr, 3)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if client.Token() != "tok-abcd" {
|
||||||
|
t.Fatalf("token=%q", client.Token())
|
||||||
|
}
|
||||||
|
if loginCalls != 1 {
|
||||||
|
t.Fatalf("loginCalls=%d, want 1", loginCalls)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second connect: session is cached and still valid, so this must not
|
||||||
|
// touch OCR or /login again.
|
||||||
|
ocr2 := &stubOCR{codes: []string{"should-not-be-used"}}
|
||||||
|
client2, err := Connect(context.Background(), store, Credentials{Username: "u", Password: "p"}, srv.URL, ocr2, 3)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if client2.Token() != "tok-abcd" {
|
||||||
|
t.Fatalf("reused token=%q", client2.Token())
|
||||||
|
}
|
||||||
|
if loginCalls != 1 {
|
||||||
|
t.Fatalf("loginCalls after reuse=%d, want still 1", loginCalls)
|
||||||
|
}
|
||||||
|
if ocr2.calls != 0 {
|
||||||
|
t.Fatalf("OCR must not be called when the cached session is valid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConnectReLoginsAfterSessionExpiredAndIsBounded: when the cached session
|
||||||
|
// is explicitly rejected (ErrSessionInvalid), Connect re-logs in — but only
|
||||||
|
// up to maxLogin captcha attempts, never looping forever.
|
||||||
|
func TestConnectReLoginsAfterSessionExpiredAndIsBounded(t *testing.T) {
|
||||||
|
db := newDB(t)
|
||||||
|
store := NewSessionStore(db)
|
||||||
|
// Seed an already-cached, not-yet-expired session so Connect's Load finds
|
||||||
|
// it and only CheckSession decides it is dead.
|
||||||
|
if err := store.Save(context.Background(), Session{
|
||||||
|
Username: "u", Token: "stale", CookiesJSON: `[]`, UserID: "u1",
|
||||||
|
ExpiresAt: time.Now().UTC().Add(time.Hour),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
loginAttempts := 0
|
||||||
|
srv := fakeServer(t,
|
||||||
|
func(captcha string) bool { loginAttempts++; return false }, // every captcha rejected
|
||||||
|
func(token string) bool { return false }, // cached session always invalid
|
||||||
|
)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
ocr := &stubOCR{codes: []string{"1", "2", "3", "4", "5", "6"}} // more codes than maxLogin allows
|
||||||
|
_, err := Connect(context.Background(), store, Credentials{Username: "u", Password: "p"}, srv.URL, ocr, 3)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected login failure")
|
||||||
|
}
|
||||||
|
if loginAttempts != 3 {
|
||||||
|
t.Fatalf("loginAttempts=%d, want exactly maxLogin=3 (bounded, not endless)", loginAttempts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The rejected cached session must have been deleted, not left in place.
|
||||||
|
if _, loadErr := store.Load(context.Background(), "u", time.Now().UTC()); loadErr != ErrNoSession {
|
||||||
|
t.Fatalf("expired/invalid session should have been deleted: %v", loadErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConnectKeepsCachedSessionOnTimeoutOrServerError: a network-level error
|
||||||
|
// checking the session (not an explicit "invalid") must not discard a
|
||||||
|
// possibly-still-good cached session.
|
||||||
|
func TestConnectKeepsCachedSessionOnTimeoutOrServerError(t *testing.T) {
|
||||||
|
db := newDB(t)
|
||||||
|
store := NewSessionStore(db)
|
||||||
|
if err := store.Save(context.Background(), Session{
|
||||||
|
Username: "u", Token: "tok", CookiesJSON: `[]`, UserID: "u1",
|
||||||
|
ExpiresAt: time.Now().UTC().Add(time.Hour),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
_, err := Connect(context.Background(), store, Credentials{Username: "u", Password: "p"}, srv.URL, &stubOCR{}, 3)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected a propagated 5xx error")
|
||||||
|
}
|
||||||
|
if _, loadErr := store.Load(context.Background(), "u", time.Now().UTC()); loadErr != nil {
|
||||||
|
t.Fatalf("a 5xx must not discard the cached session: %v", loadErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package yeekeclient
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"go-admin/app/goauto/models"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SessionStore struct{ db *gorm.DB }
|
||||||
|
|
||||||
|
func NewSessionStore(db *gorm.DB) *SessionStore { return &SessionStore{db: db} }
|
||||||
|
func (s *SessionStore) Save(ctx context.Context, x Session) error {
|
||||||
|
if strings.TrimSpace(x.Username) == "" || x.Token == "" || x.ExpiresAt.IsZero() {
|
||||||
|
return errors.New("invalid yeeke session")
|
||||||
|
}
|
||||||
|
return s.db.WithContext(ctx).Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "username"}}, DoUpdates: clause.AssignmentColumns([]string{"token", "cookies_json", "user_id", "expires_at", "updated_at"})}).Create(&models.YeekeSession{Username: strings.TrimSpace(x.Username), Token: x.Token, CookiesJSON: x.CookiesJSON, UserID: x.UserID, ExpiresAt: x.ExpiresAt.UTC()}).Error
|
||||||
|
}
|
||||||
|
func (s *SessionStore) Load(ctx context.Context, user string, now time.Time) (Session, error) {
|
||||||
|
var r models.YeekeSession
|
||||||
|
if e := s.db.WithContext(ctx).Where("username = ?", strings.TrimSpace(user)).First(&r).Error; e != nil {
|
||||||
|
if errors.Is(e, gorm.ErrRecordNotFound) {
|
||||||
|
return Session{}, ErrNoSession
|
||||||
|
}
|
||||||
|
return Session{}, e
|
||||||
|
}
|
||||||
|
if !now.UTC().Before(r.ExpiresAt) {
|
||||||
|
return Session{}, ErrNoSession
|
||||||
|
}
|
||||||
|
return Session{Username: r.Username, Token: r.Token, CookiesJSON: r.CookiesJSON, UserID: r.UserID, ExpiresAt: r.ExpiresAt}, nil
|
||||||
|
}
|
||||||
|
func (s *SessionStore) Delete(ctx context.Context, user string) error {
|
||||||
|
return s.db.WithContext(ctx).Where("username = ?", strings.TrimSpace(user)).Delete(&models.YeekeSession{}).Error
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
|
|
||||||
"go-admin/app/goauto/shopeeproduct"
|
"go-admin/app/goauto/shopeeproduct"
|
||||||
"go-admin/app/goauto/sybimport"
|
"go-admin/app/goauto/sybimport"
|
||||||
|
"go-admin/app/goauto/yeeke"
|
||||||
)
|
)
|
||||||
|
|
||||||
// InitJob
|
// InitJob
|
||||||
@@ -17,6 +18,7 @@ func InitJob() {
|
|||||||
sybimport.HourlySyncInvokeTarget: sybimport.HourlySyncJob{},
|
sybimport.HourlySyncInvokeTarget: sybimport.HourlySyncJob{},
|
||||||
sybimport.SpecAIParseInvokeTarget: sybimport.ScheduledSpecAIParseJob{},
|
sybimport.SpecAIParseInvokeTarget: sybimport.ScheduledSpecAIParseJob{},
|
||||||
shopeeproduct.SpecAutoMatchInvokeTarget: shopeeproduct.ScheduledAutoMatchJob{},
|
shopeeproduct.SpecAutoMatchInvokeTarget: shopeeproduct.ScheduledAutoMatchJob{},
|
||||||
|
yeeke.ReturnSyncInvokeTarget: yeeke.ReturnSyncJob{},
|
||||||
// ...
|
// ...
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package version_local
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go-admin/app/goauto/migrations"
|
||||||
|
"go-admin/cmd/migrate/migration"
|
||||||
|
common "go-admin/common/models"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
_, f, _, _ := runtime.Caller(0)
|
||||||
|
migration.Migrate.SetVersion(migration.GetFilename(f), migrateYeekeReturnSync)
|
||||||
|
}
|
||||||
|
func migrateYeekeReturnSync(db *gorm.DB, version string) error {
|
||||||
|
return db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := migrations.Migrate(tx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Create(&common.Migration{Version: version}).Error
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package version_local
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"runtime"
|
||||||
|
|
||||||
|
"go-admin/app/goauto/yeeke"
|
||||||
|
jobsmodels "go-admin/app/jobs/models"
|
||||||
|
"go-admin/cmd/migrate/migration"
|
||||||
|
common "go-admin/common/models"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
_, fileName, _, _ := runtime.Caller(0)
|
||||||
|
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateYeekeReturnSyncJob)
|
||||||
|
}
|
||||||
|
|
||||||
|
// migrateYeekeReturnSyncJob seeds the scheduled yeeke return sync job row,
|
||||||
|
// disabled by default (#336 requirement: 默认关闭定时任务,管理员手动开启).
|
||||||
|
// It follows 1786701600000_syb_hourly_sync_job.go exactly: Status 2 keeps the
|
||||||
|
// row out of the running cron set (see app/jobs/service/sys_job.go, which
|
||||||
|
// only adds Status == 1 jobs to the cron at startup).
|
||||||
|
func migrateYeekeReturnSyncJob(db *gorm.DB, version string) error {
|
||||||
|
return db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := ensureYeekeReturnSyncJob(tx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Create(&common.Migration{Version: version}).Error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureYeekeReturnSyncJob(db *gorm.DB) error {
|
||||||
|
var existing jobsmodels.SysJob
|
||||||
|
err := db.Where("invoke_target = ?", yeeke.ReturnSyncInvokeTarget).First(&existing).Error
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return db.Create(&jobsmodels.SysJob{
|
||||||
|
JobName: "yeeke 退货包裹同步", JobGroup: "GoAuto", JobType: 2,
|
||||||
|
CronExpression: "0 20 * * * *", InvokeTarget: yeeke.ReturnSyncInvokeTarget,
|
||||||
|
Args: "",
|
||||||
|
MisfirePolicy: 1, Concurrent: 1, Status: 2,
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package version_local
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"runtime"
|
||||||
|
|
||||||
|
"go-admin/app/goauto/access"
|
||||||
|
"go-admin/cmd/migrate/migration"
|
||||||
|
migrationmodels "go-admin/cmd/migrate/migration/models"
|
||||||
|
common "go-admin/common/models"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
_, file, _, _ := runtime.Caller(0)
|
||||||
|
migration.Migrate.SetVersion(migration.GetFilename(file), migrateYeekeAdminMenu)
|
||||||
|
}
|
||||||
|
|
||||||
|
// migrateYeekeAdminMenu adds the 「yeeke 退货包裹」 and 「yeeke 同步记录」 menu
|
||||||
|
// entries for #337. #336 registered access.ModuleYeekeReturns and shipped the
|
||||||
|
// sync backend, but never wrote its sys_menu row or web page, so Admin had no
|
||||||
|
// entry point; #337 also splits the sync-run history into its own module
|
||||||
|
// (access.ModuleYeekeSyncRuns), matching the existing SYB 商品 / SYB 同步记录
|
||||||
|
// split.
|
||||||
|
//
|
||||||
|
// `[必须]` Adding a module to access.GoAutoModules() is not enough to make its
|
||||||
|
// page reachable. Menus are written by the versioned migration that first
|
||||||
|
// created them, and that version is already recorded in sys_migration on every
|
||||||
|
// existing database — it never runs again. Startup only reconciles the casbin
|
||||||
|
// API matrix (access.ReconcilePurchaserPermissions), not sys_menu. Without this
|
||||||
|
// migration the pages exist, the APIs answer and the routes resolve, yet
|
||||||
|
// nobody can navigate to them on an already-migrated database. This mirrors
|
||||||
|
// 1789113000000_syb_product_filter_menu.go exactly.
|
||||||
|
func migrateYeekeAdminMenu(db *gorm.DB, version string) error {
|
||||||
|
return db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
// Both pages belong beside the SYB modules under 采集采购, where the
|
||||||
|
// admin already manages inbound collection/purchase data sources.
|
||||||
|
var parent migrationmodels.SysMenu
|
||||||
|
if err := tx.Where("menu_name = ?", "GoAutoCollectionPurchase").First(&parent).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, key := range []string{access.ModuleYeekeReturns, access.ModuleYeekeSyncRuns} {
|
||||||
|
module, ok := goAutoModuleByKey(key)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("module %q is not registered in access.GoAutoModules()", key)
|
||||||
|
}
|
||||||
|
|
||||||
|
child, _, err := upsertGoAutoMenu(tx, migrationmodels.SysMenu{
|
||||||
|
MenuName: module.RouteName,
|
||||||
|
Title: module.Title,
|
||||||
|
Icon: module.Icon,
|
||||||
|
Path: module.Path + "/index",
|
||||||
|
MenuType: "C",
|
||||||
|
Action: "无",
|
||||||
|
ParentId: parent.MenuId,
|
||||||
|
Component: module.Component,
|
||||||
|
Sort: module.Sort,
|
||||||
|
Visible: "0",
|
||||||
|
IsFrame: "1",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err = tx.Model(&child).Update("paths", fmt.Sprintf("/0/%d/%d", parent.MenuId, child.MenuId)).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bind to both roles the module declares. 采购员 needs it because the
|
||||||
|
// module is PurchaserDefault; 管理员 needs it so it can always see and
|
||||||
|
// manually trigger the read-only sync surface.
|
||||||
|
roleKeys := []string{"admin"}
|
||||||
|
if module.PurchaserDefault {
|
||||||
|
roleKeys = append(roleKeys, access.RolePurchaser)
|
||||||
|
}
|
||||||
|
for _, roleKey := range roleKeys {
|
||||||
|
var role migrationmodels.SysRole
|
||||||
|
if err = tx.Where("role_key = ?", roleKey).First(&role).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err = tx.Model(&role).Association("SysMenu").Append(&child); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Create(&common.Migration{Version: version}).Error
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package version_local
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go-admin/app/goauto/migrations"
|
||||||
|
"go-admin/cmd/migrate/migration"
|
||||||
|
common "go-admin/common/models"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
// #338: creates return_match and return_match_log (registered in
|
||||||
|
// migrations.MigratedModels) on databases whose earlier versions are already
|
||||||
|
// recorded in sys_migration.
|
||||||
|
func init() {
|
||||||
|
_, f, _, _ := runtime.Caller(0)
|
||||||
|
migration.Migrate.SetVersion(migration.GetFilename(f), migrateReturnMatch)
|
||||||
|
}
|
||||||
|
func migrateReturnMatch(db *gorm.DB, version string) error {
|
||||||
|
return db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := migrations.Migrate(tx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Create(&common.Migration{Version: version}).Error
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package version_local
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go-admin/app/goauto/migrations"
|
||||||
|
"go-admin/cmd/migrate/migration"
|
||||||
|
common "go-admin/common/models"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
// #338: creates return_match_batch (registered in
|
||||||
|
// migrations.MigratedModels) on databases whose earlier versions are already
|
||||||
|
// recorded in sys_migration.
|
||||||
|
func init() {
|
||||||
|
_, f, _, _ := runtime.Caller(0)
|
||||||
|
migration.Migrate.SetVersion(migration.GetFilename(f), migrateReturnMatchBatch)
|
||||||
|
}
|
||||||
|
func migrateReturnMatchBatch(db *gorm.DB, version string) error {
|
||||||
|
return db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := migrations.Migrate(tx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Create(&common.Migration{Version: version}).Error
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package version_local
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go-admin/app/goauto/migrations"
|
||||||
|
"go-admin/cmd/migrate/migration"
|
||||||
|
common "go-admin/common/models"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
// #338 scope addition: adds the nullable missing_since column to
|
||||||
|
// yeeke_return_package and yeeke_return_item (models.YeekeReturnPackage /
|
||||||
|
// models.YeekeReturnItem, registered in migrations.MigratedModels) on
|
||||||
|
// databases whose earlier versions are already recorded in sys_migration.
|
||||||
|
// AutoMigrate alone never reaches an existing database without a version
|
||||||
|
// file like this one being run by the migrate command.
|
||||||
|
func init() {
|
||||||
|
_, f, _, _ := runtime.Caller(0)
|
||||||
|
migration.Migrate.SetVersion(migration.GetFilename(f), migrateReturnMissing)
|
||||||
|
}
|
||||||
|
func migrateReturnMissing(db *gorm.DB, version string) error {
|
||||||
|
return db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := migrations.Migrate(tx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Create(&common.Migration{Version: version}).Error
|
||||||
|
})
|
||||||
|
}
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
package version_local
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go-admin/app/goauto/migrations"
|
||||||
|
"go-admin/cmd/migrate/migration"
|
||||||
|
common "go-admin/common/models"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
// #338: adds yeeke_sync_run.missing_marked_count and recovered_count (registered in
|
||||||
|
// migrations.MigratedModels) on databases whose earlier versions are already
|
||||||
|
// recorded in sys_migration.
|
||||||
|
func init() {
|
||||||
|
_, f, _, _ := runtime.Caller(0)
|
||||||
|
migration.Migrate.SetVersion(migration.GetFilename(f), migrateYeekeSyncRunMissingCounts)
|
||||||
|
}
|
||||||
|
func migrateYeekeSyncRunMissingCounts(db *gorm.DB, version string) error {
|
||||||
|
return db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := migrations.Migrate(tx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Create(&common.Migration{Version: version}).Error
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package version_local
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
adminmodels "go-admin/app/admin/models"
|
||||||
|
"go-admin/app/goauto/access"
|
||||||
|
"go-admin/cmd/migrate/migration"
|
||||||
|
migrationmodels "go-admin/cmd/migrate/migration/models"
|
||||||
|
common "go-admin/common/models"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const afterSalesInitialPasswordEnv = "GOAUTO_AFTER_SALES_INITIAL_PASSWORD"
|
||||||
|
|
||||||
|
var afterSalesUsers = []string{"zengyt", "huangyj", "zhuyt", "wangxy"}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
_, fileName, _, _ := runtime.Caller(0)
|
||||||
|
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateAfterSalesRole)
|
||||||
|
}
|
||||||
|
|
||||||
|
// migrateAfterSalesRole creates the internal 售后 role and accounts. It
|
||||||
|
// copies the current purchaser menu/API grants, so the two roles stay aligned
|
||||||
|
// for the current product surface (including yeeke 退货同步 and 退货商品).
|
||||||
|
// The initial password is deliberately supplied only at migration time via an
|
||||||
|
// environment variable; it is never stored in source, logs, or issue text.
|
||||||
|
func migrateAfterSalesRole(db *gorm.DB, version string) error {
|
||||||
|
return db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
role, err := ensureAfterSalesRole(tx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := clonePurchaserPermissions(tx, role.RoleId); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := ensureAfterSalesUsers(tx, role.RoleId); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Create(&common.Migration{Version: version}).Error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureAfterSalesRole(db *gorm.DB) (migrationmodels.SysRole, error) {
|
||||||
|
var purchaser migrationmodels.SysRole
|
||||||
|
if err := db.Where("role_key = ?", access.RolePurchaser).First(&purchaser).Error; err != nil {
|
||||||
|
return migrationmodels.SysRole{}, fmt.Errorf("find purchaser role: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
role := migrationmodels.SysRole{}
|
||||||
|
if err := db.Where("role_key = ?", access.RoleAfterSales).
|
||||||
|
Assign(migrationmodels.SysRole{
|
||||||
|
RoleName: "售后", Status: "2", RoleSort: purchaser.RoleSort + 1,
|
||||||
|
Admin: false, DataScope: purchaser.DataScope,
|
||||||
|
Remark: "GoAuto 售后业务角色(系统维护)",
|
||||||
|
}).FirstOrCreate(&role, migrationmodels.SysRole{RoleKey: access.RoleAfterSales}).Error; err != nil {
|
||||||
|
return migrationmodels.SysRole{}, err
|
||||||
|
}
|
||||||
|
return role, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func clonePurchaserPermissions(db *gorm.DB, roleID int) error {
|
||||||
|
var purchaser migrationmodels.SysRole
|
||||||
|
if err := db.Where("role_key = ?", access.RolePurchaser).First(&purchaser).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := db.Exec(`
|
||||||
|
INSERT INTO sys_role_menu (role_id, menu_id)
|
||||||
|
SELECT ?, source.menu_id
|
||||||
|
FROM sys_role_menu AS source
|
||||||
|
WHERE source.role_id = ?
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM sys_role_menu AS target
|
||||||
|
WHERE target.role_id = ? AND target.menu_id = source.menu_id
|
||||||
|
)`, roleID, purchaser.RoleId, roleID).Error; err != nil {
|
||||||
|
return fmt.Errorf("clone purchaser menus: %w", err)
|
||||||
|
}
|
||||||
|
if err := db.Exec(`
|
||||||
|
INSERT INTO casbin_rule (ptype, v0, v1, v2, v3, v4, v5)
|
||||||
|
SELECT source.ptype, ?, source.v1, source.v2, source.v3, source.v4, source.v5
|
||||||
|
FROM casbin_rule AS source
|
||||||
|
WHERE source.ptype = 'p' AND source.v0 = ?
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM casbin_rule AS target
|
||||||
|
WHERE target.ptype = source.ptype AND target.v0 = ?
|
||||||
|
AND target.v1 = source.v1 AND target.v2 = source.v2
|
||||||
|
AND COALESCE(target.v3, '') = COALESCE(source.v3, '')
|
||||||
|
AND COALESCE(target.v4, '') = COALESCE(source.v4, '')
|
||||||
|
AND COALESCE(target.v5, '') = COALESCE(source.v5, '')
|
||||||
|
)`, access.RoleAfterSales, access.RolePurchaser, access.RoleAfterSales).Error; err != nil {
|
||||||
|
return fmt.Errorf("clone purchaser API policies: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureAfterSalesUsers(db *gorm.DB, roleID int) error {
|
||||||
|
missing := make([]string, 0, len(afterSalesUsers))
|
||||||
|
for _, username := range afterSalesUsers {
|
||||||
|
var existing adminmodels.SysUser
|
||||||
|
err := db.Unscoped().Where("username = ?", username).First(&existing).Error
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||||
|
missing = append(missing, username)
|
||||||
|
case err != nil:
|
||||||
|
return fmt.Errorf("find after-sales user %q: %w", username, err)
|
||||||
|
case existing.RoleId != roleID:
|
||||||
|
return fmt.Errorf("after-sales user %q already exists with another role", username)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(missing) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
password := strings.TrimSpace(os.Getenv(afterSalesInitialPasswordEnv))
|
||||||
|
if password == "" {
|
||||||
|
return fmt.Errorf("%s must be set when creating after-sales users", afterSalesInitialPasswordEnv)
|
||||||
|
}
|
||||||
|
// Validate before creating any account; the value itself is never logged or
|
||||||
|
// written to a repository artifact.
|
||||||
|
if len(password) < 1 {
|
||||||
|
return errors.New("after-sales initial password is empty")
|
||||||
|
}
|
||||||
|
for _, username := range missing {
|
||||||
|
user := adminmodels.SysUser{
|
||||||
|
Username: username, Password: password, NickName: username,
|
||||||
|
RoleId: roleID, Status: "2", Remark: "GoAuto 售后账号",
|
||||||
|
}
|
||||||
|
if err := db.Create(&user).Error; err != nil {
|
||||||
|
return fmt.Errorf("create after-sales user %q: %w", username, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package version_local
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
adminmodels "go-admin/app/admin/models"
|
||||||
|
"go-admin/app/goauto/access"
|
||||||
|
migrationmodels "go-admin/cmd/migrate/migration/models"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"gorm.io/driver/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEnsureAfterSalesRoleAndUsersIsIdempotent(t *testing.T) {
|
||||||
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = db.AutoMigrate(&migrationmodels.SysRole{}, &migrationmodels.SysMenu{}, &adminmodels.SysUser{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = db.Exec(`CREATE TABLE IF NOT EXISTS sys_role_menu (role_id integer NOT NULL, menu_id integer NOT NULL, PRIMARY KEY (role_id, menu_id))`).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = db.Exec(`CREATE TABLE IF NOT EXISTS casbin_rule (id integer PRIMARY KEY AUTOINCREMENT, ptype varchar(100), v0 varchar(100), v1 varchar(100), v2 varchar(100), v3 varchar(100), v4 varchar(100), v5 varchar(100))`).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
purchaser := migrationmodels.SysRole{RoleName: "采购员", RoleKey: access.RolePurchaser, Status: "2", RoleSort: 20, DataScope: "1"}
|
||||||
|
if err = db.Create(&purchaser).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = db.Exec(`INSERT INTO sys_menu (menu_name, title, menu_type, parent_id) VALUES ('GoAutoYeekeReturns', 'yeeke 退货包裹', 'C', 0)`).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = db.Exec(`INSERT INTO sys_role_menu (role_id, menu_id) SELECT ?, menu_id FROM sys_menu`, purchaser.RoleId).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = db.Exec(`INSERT INTO casbin_rule (ptype, v0, v1, v2) VALUES ('p', ?, '/api/admin/v1/yeeke-returns', 'GET')`, access.RolePurchaser).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
oldPassword := os.Getenv(afterSalesInitialPasswordEnv)
|
||||||
|
defer os.Setenv(afterSalesInitialPasswordEnv, oldPassword)
|
||||||
|
if err = os.Setenv(afterSalesInitialPasswordEnv, "test-only-password"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
role, err := ensureAfterSalesRole(db)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = clonePurchaserPermissions(db, role.RoleId); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = ensureAfterSalesUsers(db, role.RoleId); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = clonePurchaserPermissions(db, role.RoleId); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = ensureAfterSalesUsers(db, role.RoleId); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var count int64
|
||||||
|
if err = db.Model(&adminmodels.SysUser{}).Where("role_id = ?", role.RoleId).Count(&count).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if count != int64(len(afterSalesUsers)) {
|
||||||
|
t.Fatalf("got %d after-sales users, want %d", count, len(afterSalesUsers))
|
||||||
|
}
|
||||||
|
var user adminmodels.SysUser
|
||||||
|
if err = db.Where("username = ?", afterSalesUsers[0]).First(&user).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if bcrypt.CompareHashAndPassword([]byte(user.Password), []byte("test-only-password")) != nil {
|
||||||
|
t.Fatal("initial password was not stored as a bcrypt hash")
|
||||||
|
}
|
||||||
|
if err = db.Table("casbin_rule").Where("ptype = 'p' AND v0 = ?", access.RoleAfterSales).Count(&count).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if count != 1 {
|
||||||
|
t.Fatalf("got %d after-sales policies, want 1", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package version_local
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"runtime"
|
||||||
|
|
||||||
|
"go-admin/app/goauto/access"
|
||||||
|
"go-admin/cmd/migrate/migration"
|
||||||
|
common "go-admin/common/models"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// #341 follow-up: the after-sales role was created before #338 added the
|
||||||
|
// return-match API surface. Add only the reviewed return-match grants so the
|
||||||
|
// existing role can use the new SYB action without recreating or changing its
|
||||||
|
// users.
|
||||||
|
func init() {
|
||||||
|
_, fileName, _, _ := runtime.Caller(0)
|
||||||
|
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateAfterSalesReturnMatch)
|
||||||
|
}
|
||||||
|
|
||||||
|
type afterSalesReturnMatchAPI struct {
|
||||||
|
ID int `gorm:"column:id;primaryKey;autoIncrement"`
|
||||||
|
Title string `gorm:"column:title;size:128"`
|
||||||
|
Path string `gorm:"column:path;size:128"`
|
||||||
|
Type string `gorm:"column:type;size:16"`
|
||||||
|
Action string `gorm:"column:action;size:16"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (afterSalesReturnMatchAPI) TableName() string { return "sys_api" }
|
||||||
|
|
||||||
|
type afterSalesReturnMatchPolicy struct {
|
||||||
|
ID uint `gorm:"column:id;primaryKey;autoIncrement"`
|
||||||
|
Ptype string `gorm:"column:ptype;size:100"`
|
||||||
|
V0 string `gorm:"column:v0;size:100"`
|
||||||
|
V1 string `gorm:"column:v1;size:100"`
|
||||||
|
V2 string `gorm:"column:v2;size:100"`
|
||||||
|
V3 string `gorm:"column:v3;size:100"`
|
||||||
|
V4 string `gorm:"column:v4;size:100"`
|
||||||
|
V5 string `gorm:"column:v5;size:100"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (afterSalesReturnMatchPolicy) TableName() string { return "casbin_rule" }
|
||||||
|
|
||||||
|
func migrateAfterSalesReturnMatch(db *gorm.DB, version string) error {
|
||||||
|
return db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
var role struct {
|
||||||
|
RoleID int `gorm:"column:role_id"`
|
||||||
|
}
|
||||||
|
if err := tx.Table("sys_role").Select("role_id").Where("role_key = ?", access.RoleAfterSales).First(&role).Error; err != nil {
|
||||||
|
return fmt.Errorf("find after-sales role: %w", err)
|
||||||
|
}
|
||||||
|
for _, permission := range access.AdminAPIs {
|
||||||
|
if len(permission.Path) < len("/api/admin/v1/return-matches") || permission.Path[:len("/api/admin/v1/return-matches")] != "/api/admin/v1/return-matches" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
api := afterSalesReturnMatchAPI{Path: permission.Path, Action: permission.Method}
|
||||||
|
if err := tx.Where("path = ? AND action = ?", permission.Path, permission.Method).
|
||||||
|
Assign(afterSalesReturnMatchAPI{Title: permission.Title, Path: permission.Path, Action: permission.Method, Type: "BUS"}).
|
||||||
|
FirstOrCreate(&api).Error; err != nil {
|
||||||
|
return fmt.Errorf("ensure return-match API: %w", err)
|
||||||
|
}
|
||||||
|
policy := afterSalesReturnMatchPolicy{Ptype: "p", V0: access.RoleAfterSales, V1: permission.Path, V2: permission.Method}
|
||||||
|
var count int64
|
||||||
|
if err := tx.Model(&afterSalesReturnMatchPolicy{}).Where("ptype = ? AND v0 = ? AND v1 = ? AND v2 = ?", policy.Ptype, policy.V0, policy.V1, policy.V2).Count(&count).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count == 0 {
|
||||||
|
if err := tx.Create(&policy).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Create(&common.Migration{Version: version}).Error
|
||||||
|
})
|
||||||
|
}
|
||||||
+63
@@ -0,0 +1,63 @@
|
|||||||
|
package version_local
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go-admin/app/goauto/access"
|
||||||
|
migrationmodels "go-admin/cmd/migrate/migration/models"
|
||||||
|
common "go-admin/common/models"
|
||||||
|
"gorm.io/driver/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAfterSalesReturnMatchCatalogAndGrants(t *testing.T) {
|
||||||
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = db.AutoMigrate(&migrationmodels.SysRole{}, &afterSalesReturnMatchAPI{}, &afterSalesReturnMatchPolicy{}, &common.Migration{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = db.Create(&migrationmodels.SysRole{RoleKey: access.RoleAfterSales}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, version := range []string{"test-first", "test-repeat"} {
|
||||||
|
if err = migrateAfterSalesReturnMatch(db, version); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var apis []afterSalesReturnMatchAPI
|
||||||
|
if err = db.Find(&apis).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(apis) != 8 {
|
||||||
|
t.Fatalf("API count=%d", len(apis))
|
||||||
|
}
|
||||||
|
for _, api := range apis {
|
||||||
|
if api.Path == "" || api.Action == "" {
|
||||||
|
t.Fatalf("empty API path/action: id=%d", api.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var count int64
|
||||||
|
if err = db.Model(&afterSalesReturnMatchPolicy{}).Where("v0 = ?", access.RoleAfterSales).Count(&count).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if count != 8 {
|
||||||
|
t.Fatalf("policy count=%d", count)
|
||||||
|
}
|
||||||
|
// Simulate the original deployed catalogue bug, then repair and repeat.
|
||||||
|
if err = db.Model(&afterSalesReturnMatchAPI{}).Where("id > 0").Updates(map[string]any{"path": "", "action": ""}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, version := range []string{"repair-first", "repair-repeat"} {
|
||||||
|
if err = migrateFixReturnMatchAPICatalog(db, version); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err = db.Model(&afterSalesReturnMatchAPI{}).Where("path = '' OR action = ''").Count(&count).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if count != 0 {
|
||||||
|
t.Fatalf("unrepaired API rows=%d", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
package version_local
|
||||||
|
|
||||||
|
import (
|
||||||
|
"runtime"
|
||||||
|
|
||||||
|
"go-admin/app/goauto/access"
|
||||||
|
"go-admin/cmd/migrate/migration"
|
||||||
|
common "go-admin/common/models"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Repair the API catalogue rows created by 1789801200000 before the path and
|
||||||
|
// action fields were populated. This is idempotent and only touches the
|
||||||
|
// return-match endpoints.
|
||||||
|
func init() {
|
||||||
|
_, fileName, _, _ := runtime.Caller(0)
|
||||||
|
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateFixReturnMatchAPICatalog)
|
||||||
|
}
|
||||||
|
|
||||||
|
func migrateFixReturnMatchAPICatalog(db *gorm.DB, version string) error {
|
||||||
|
return db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
for _, permission := range access.AdminAPIs {
|
||||||
|
if len(permission.Path) < len("/api/admin/v1/return-matches") || permission.Path[:len("/api/admin/v1/return-matches")] != "/api/admin/v1/return-matches" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := tx.Table("sys_api").Where("title = ?", permission.Title).Updates(map[string]any{
|
||||||
|
"title": permission.Title, "path": permission.Path, "action": permission.Method, "type": "BUS",
|
||||||
|
}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Create(&common.Migration{Version: version}).Error
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package version_local
|
||||||
|
|
||||||
|
import (
|
||||||
|
"runtime"
|
||||||
|
|
||||||
|
"go-admin/app/goauto/models"
|
||||||
|
"go-admin/cmd/migrate/migration"
|
||||||
|
common "go-admin/common/models"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
_, file, _, _ := runtime.Caller(0)
|
||||||
|
migration.Migrate.SetVersion(migration.GetFilename(file), migrateSYBSessionAuthLease)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The lease is additive and contains no credentials. It serializes automatic
|
||||||
|
// SYB re-authentication across sync and purchase writeback workers.
|
||||||
|
func migrateSYBSessionAuthLease(db *gorm.DB, version string) error {
|
||||||
|
return db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.AutoMigrate(&models.SYBSessionAuthLease{}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Create(&common.Migration{Version: version}).Error
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ var ExtConfig Extend
|
|||||||
type Extend struct {
|
type Extend struct {
|
||||||
AMap AMap // 这里配置对应配置文件的结构即可
|
AMap AMap // 这里配置对应配置文件的结构即可
|
||||||
SYB SYB
|
SYB SYB
|
||||||
|
Yeeke Yeeke
|
||||||
}
|
}
|
||||||
|
|
||||||
type AMap struct {
|
type AMap struct {
|
||||||
@@ -89,6 +90,65 @@ func (s SYB) HasCredentials() bool {
|
|||||||
return strings.TrimSpace(s.Username) != "" && s.Password != ""
|
return strings.TrimSpace(s.Username) != "" && s.Password != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Yeeke holds the mmt.yeeke.com 退货包裹只读对接 connection settings (#336).
|
||||||
|
//
|
||||||
|
// `[必须]` Username and Password are NOT read from settings.yml — same rule as
|
||||||
|
// SYB above — they come from GOAUTO_YEEKE_USERNAME / GOAUTO_YEEKE_PASSWORD (or
|
||||||
|
// config.yaml's yeeke: section) so no credential ever lands in a tracked file.
|
||||||
|
type Yeeke struct {
|
||||||
|
BaseURL string
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
PageSize int
|
||||||
|
MaxPages int
|
||||||
|
Retry int
|
||||||
|
// OcrURL is the captcha recognition service shared with SYB (#336, approved
|
||||||
|
// 2026-09-23). Empty disables OCR; there is no manual-entry fallback here
|
||||||
|
// because this is a server-side scheduled/triggered flow, not an interactive
|
||||||
|
// login form, so a disabled OCR simply makes sync fail with a clear error.
|
||||||
|
OcrURL string
|
||||||
|
OcrMaxAttempts int
|
||||||
|
}
|
||||||
|
|
||||||
|
// YeekeDefaults are the values used when settings.yml leaves a field blank.
|
||||||
|
const (
|
||||||
|
DefaultYeekeBaseURL = "https://mmt.yeeke.com"
|
||||||
|
DefaultYeekePageSize = 100
|
||||||
|
DefaultYeekeMaxPages = 10000
|
||||||
|
DefaultYeekeRetry = 2
|
||||||
|
DefaultYeekeOcrMaxAttempts = 5
|
||||||
|
)
|
||||||
|
|
||||||
|
// Resolved returns the Yeeke settings with blanks replaced by defaults. It
|
||||||
|
// never defaults Username or Password: missing credentials must surface as an
|
||||||
|
// error at the call site, not as an attempt to log in as nobody.
|
||||||
|
func (y Yeeke) Resolved() Yeeke {
|
||||||
|
if strings.TrimSpace(y.BaseURL) == "" {
|
||||||
|
y.BaseURL = DefaultYeekeBaseURL
|
||||||
|
}
|
||||||
|
if y.PageSize <= 0 {
|
||||||
|
y.PageSize = DefaultYeekePageSize
|
||||||
|
}
|
||||||
|
if y.MaxPages <= 0 {
|
||||||
|
y.MaxPages = DefaultYeekeMaxPages
|
||||||
|
}
|
||||||
|
if y.Retry < 0 {
|
||||||
|
y.Retry = DefaultYeekeRetry
|
||||||
|
}
|
||||||
|
if y.OcrMaxAttempts <= 0 {
|
||||||
|
y.OcrMaxAttempts = DefaultYeekeOcrMaxAttempts
|
||||||
|
}
|
||||||
|
y.Username = strings.TrimSpace(y.Username)
|
||||||
|
y.BaseURL = strings.TrimRight(strings.TrimSpace(y.BaseURL), "/")
|
||||||
|
y.OcrURL = strings.TrimSpace(y.OcrURL)
|
||||||
|
return y
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasCredentials reports whether both account fields were supplied.
|
||||||
|
func (y Yeeke) HasCredentials() bool {
|
||||||
|
return strings.TrimSpace(y.Username) != "" && y.Password != ""
|
||||||
|
}
|
||||||
|
|
||||||
// ApplyEnvironment replaces tracked defaults with process-local runtime values.
|
// ApplyEnvironment replaces tracked defaults with process-local runtime values.
|
||||||
// Credentials stay outside tracked configuration files. GOAUTO_DB_DRIVER
|
// Credentials stay outside tracked configuration files. GOAUTO_DB_DRIVER
|
||||||
// defaults to mysql when GOAUTO_DB_DSN is present.
|
// defaults to mysql when GOAUTO_DB_DSN is present.
|
||||||
@@ -104,6 +164,13 @@ func ApplyEnvironment() {
|
|||||||
if password := os.Getenv("GOAUTO_SYB_PASSWORD"); password != "" {
|
if password := os.Getenv("GOAUTO_SYB_PASSWORD"); password != "" {
|
||||||
ExtConfig.SYB.Password = password
|
ExtConfig.SYB.Password = password
|
||||||
}
|
}
|
||||||
|
if username := strings.TrimSpace(os.Getenv("GOAUTO_YEEKE_USERNAME")); username != "" {
|
||||||
|
ExtConfig.Yeeke.Username = username
|
||||||
|
}
|
||||||
|
// `[必须]` Taken verbatim, same reasoning as GOAUTO_SYB_PASSWORD above.
|
||||||
|
if password := os.Getenv("GOAUTO_YEEKE_PASSWORD"); password != "" {
|
||||||
|
ExtConfig.Yeeke.Password = password
|
||||||
|
}
|
||||||
|
|
||||||
dsn := strings.TrimSpace(os.Getenv("GOAUTO_DB_DSN"))
|
dsn := strings.TrimSpace(os.Getenv("GOAUTO_DB_DSN"))
|
||||||
if dsn == "" {
|
if dsn == "" {
|
||||||
|
|||||||
@@ -62,5 +62,45 @@ func TestSettingsFilesCarryNoSYBCredentials(t *testing.T) {
|
|||||||
if file.Settings.Extend.SYB.Username != "" || file.Settings.Extend.SYB.Password != "" {
|
if file.Settings.Extend.SYB.Username != "" || file.Settings.Extend.SYB.Password != "" {
|
||||||
t.Fatalf("%s 里出现了顺云宝凭据,凭据必须走环境变量", name)
|
t.Fatalf("%s 里出现了顺云宝凭据,凭据必须走环境变量", name)
|
||||||
}
|
}
|
||||||
|
if file.Settings.Extend.Yeeke.Username != "" || file.Settings.Extend.Yeeke.Password != "" {
|
||||||
|
t.Fatalf("%s 里出现了 yeeke 凭据,凭据必须走环境变量 (#336)", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// settings.yml 里的 extend.yeeke 必须真的能绑进 ExtConfig.Yeeke,同样的道理
|
||||||
|
// 见上面 TestSYBSettingsInRepoBindToExtendStruct 的注释 (#336)。
|
||||||
|
func TestYeekeSettingsInRepoBindToExtendStruct(t *testing.T) {
|
||||||
|
raw, err := os.ReadFile("settings.yml")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("读取 settings.yml 失败: %v", err)
|
||||||
|
}
|
||||||
|
var file struct {
|
||||||
|
Settings struct {
|
||||||
|
Extend Extend `yaml:"extend"`
|
||||||
|
} `yaml:"settings"`
|
||||||
|
}
|
||||||
|
if err := yaml.Unmarshal(raw, &file); err != nil {
|
||||||
|
t.Fatalf("解析 settings.yml 失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
yeeke := file.Settings.Extend.Yeeke
|
||||||
|
if yeeke.BaseURL != "https://mmt.yeeke.com" {
|
||||||
|
t.Fatalf("baseurl 没有绑定成功: %q", yeeke.BaseURL)
|
||||||
|
}
|
||||||
|
if yeeke.PageSize != 100 {
|
||||||
|
t.Fatalf("pagesize 没有绑定成功: %d", yeeke.PageSize)
|
||||||
|
}
|
||||||
|
if yeeke.MaxPages != 10000 {
|
||||||
|
t.Fatalf("maxpages 没有绑定成功: %d", yeeke.MaxPages)
|
||||||
|
}
|
||||||
|
if yeeke.Retry != 2 {
|
||||||
|
t.Fatalf("retry 没有绑定成功: %d", yeeke.Retry)
|
||||||
|
}
|
||||||
|
if yeeke.OcrURL == "" {
|
||||||
|
t.Fatal("ocrurl 没有绑定成功")
|
||||||
|
}
|
||||||
|
if yeeke.OcrMaxAttempts != 5 {
|
||||||
|
t.Fatalf("ocrmaxattempts 没有绑定成功: %d", yeeke.OcrMaxAttempts)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,61 @@ func TestApplyEnvironmentLoadsSYBCredentials(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 同上,yeeke 凭据也只能来自环境变量 (#336)。
|
||||||
|
func TestApplyEnvironmentLoadsYeekeCredentials(t *testing.T) {
|
||||||
|
original := ExtConfig.Yeeke
|
||||||
|
t.Cleanup(func() { ExtConfig.Yeeke = original })
|
||||||
|
|
||||||
|
t.Setenv("GOAUTO_YEEKE_USERNAME", " operator ")
|
||||||
|
t.Setenv("GOAUTO_YEEKE_PASSWORD", " se cret ")
|
||||||
|
ApplyEnvironment()
|
||||||
|
|
||||||
|
if ExtConfig.Yeeke.Username != "operator" {
|
||||||
|
t.Fatalf("账号应去掉首尾空白: %q", ExtConfig.Yeeke.Username)
|
||||||
|
}
|
||||||
|
if ExtConfig.Yeeke.Password != " se cret " {
|
||||||
|
t.Fatalf("密码不应被修改: %q", ExtConfig.Yeeke.Password)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestYeekeResolvedFillsBlanksButNeverInventsCredentials(t *testing.T) {
|
||||||
|
resolved := Yeeke{}.Resolved()
|
||||||
|
|
||||||
|
if resolved.BaseURL != DefaultYeekeBaseURL {
|
||||||
|
t.Fatalf("BaseURL 默认值不对: %q", resolved.BaseURL)
|
||||||
|
}
|
||||||
|
if resolved.PageSize != DefaultYeekePageSize || resolved.MaxPages != DefaultYeekeMaxPages {
|
||||||
|
t.Fatalf("分页默认值不对: %+v", resolved)
|
||||||
|
}
|
||||||
|
if resolved.OcrMaxAttempts != DefaultYeekeOcrMaxAttempts {
|
||||||
|
t.Fatalf("OCR 重试次数默认值不对: %d", resolved.OcrMaxAttempts)
|
||||||
|
}
|
||||||
|
if resolved.Username != "" || resolved.Password != "" {
|
||||||
|
t.Fatal("Resolved 不得给账号密码编造默认值")
|
||||||
|
}
|
||||||
|
if resolved.OcrURL != "" {
|
||||||
|
t.Fatalf("空 OcrURL 不应被填充: %q", resolved.OcrURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestYeekeHasCredentialsRequiresBothFields(t *testing.T) {
|
||||||
|
for _, c := range []struct {
|
||||||
|
name string
|
||||||
|
yeeke Yeeke
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"都有", Yeeke{Username: "a", Password: "b"}, true},
|
||||||
|
{"缺密码", Yeeke{Username: "a"}, false},
|
||||||
|
{"缺账号", Yeeke{Password: "b"}, false},
|
||||||
|
{"账号只有空白", Yeeke{Username: " ", Password: "b"}, false},
|
||||||
|
{"都没有", Yeeke{}, false},
|
||||||
|
} {
|
||||||
|
if got := c.yeeke.HasCredentials(); got != c.want {
|
||||||
|
t.Fatalf("%s: 期望 %v,实际 %v", c.name, c.want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSYBResolvedFillsBlanksButNeverInventsCredentials(t *testing.T) {
|
func TestSYBResolvedFillsBlanksButNeverInventsCredentials(t *testing.T) {
|
||||||
resolved := SYB{}.Resolved()
|
resolved := SYB{}.Resolved()
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ type localFile struct {
|
|||||||
Database map[string]any `yaml:"database"`
|
Database map[string]any `yaml:"database"`
|
||||||
Ports map[string]any `yaml:"ports"`
|
Ports map[string]any `yaml:"ports"`
|
||||||
SYB map[string]any `yaml:"syb"`
|
SYB map[string]any `yaml:"syb"`
|
||||||
|
Yeeke map[string]any `yaml:"yeeke"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ApplyLocalConfig loads config.yaml, if one is present, over the values
|
// ApplyLocalConfig loads config.yaml, if one is present, over the values
|
||||||
@@ -96,6 +97,7 @@ func ApplyLocalConfig() {
|
|||||||
applyLocalDatabase(file.Database)
|
applyLocalDatabase(file.Database)
|
||||||
applyLocalPorts(file.Ports)
|
applyLocalPorts(file.Ports)
|
||||||
ApplyLocalSYB(file.SYB)
|
ApplyLocalSYB(file.SYB)
|
||||||
|
ApplyLocalYeeke(file.Yeeke)
|
||||||
logInfo("本地配置:已加载 %s", path)
|
logInfo("本地配置:已加载 %s", path)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,6 +117,17 @@ func LogEffectiveConfig() {
|
|||||||
}
|
}
|
||||||
logInfo("顺云宝配置:凭据=%v(来源:%s)base_url=%s 验证码识别=%v",
|
logInfo("顺云宝配置:凭据=%v(来源:%s)base_url=%s 验证码识别=%v",
|
||||||
syb.HasCredentials(), source, syb.BaseURL, syb.OcrURL != "")
|
syb.HasCredentials(), source, syb.BaseURL, syb.OcrURL != "")
|
||||||
|
|
||||||
|
yeeke := ExtConfig.Yeeke.Resolved()
|
||||||
|
yeekeSource := "未配置"
|
||||||
|
switch {
|
||||||
|
case strings.TrimSpace(os.Getenv("GOAUTO_YEEKE_USERNAME")) != "":
|
||||||
|
yeekeSource = "环境变量"
|
||||||
|
case yeeke.HasCredentials():
|
||||||
|
yeekeSource = LocalConfigName
|
||||||
|
}
|
||||||
|
logInfo("yeeke 配置:凭据=%v(来源:%s)base_url=%s 验证码识别=%v",
|
||||||
|
yeeke.HasCredentials(), yeekeSource, yeeke.BaseURL, yeeke.OcrURL != "")
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyLocalDatabase(database map[string]any) {
|
func applyLocalDatabase(database map[string]any) {
|
||||||
@@ -177,6 +190,35 @@ func ApplyLocalSYB(syb map[string]any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ApplyLocalYeeke folds a config.yaml `yeeke:` section into ExtConfig, mirroring
|
||||||
|
// ApplyLocalSYB above (#336).
|
||||||
|
func ApplyLocalYeeke(yeeke map[string]any) {
|
||||||
|
if username := strings.TrimSpace(scalar(yeeke, "username")); username != "" {
|
||||||
|
ExtConfig.Yeeke.Username = username
|
||||||
|
}
|
||||||
|
if password := scalar(yeeke, "password"); password != "" {
|
||||||
|
ExtConfig.Yeeke.Password = password
|
||||||
|
}
|
||||||
|
if baseURL := strings.TrimSpace(scalar(yeeke, "base_url")); baseURL != "" {
|
||||||
|
ExtConfig.Yeeke.BaseURL = baseURL
|
||||||
|
}
|
||||||
|
if ocrURL := strings.TrimSpace(scalar(yeeke, "ocr_url")); ocrURL != "" {
|
||||||
|
ExtConfig.Yeeke.OcrURL = ocrURL
|
||||||
|
}
|
||||||
|
if pageSize, err := strconv.Atoi(scalar(yeeke, "page_size")); err == nil && pageSize > 0 {
|
||||||
|
ExtConfig.Yeeke.PageSize = pageSize
|
||||||
|
}
|
||||||
|
if maxPages, err := strconv.Atoi(scalar(yeeke, "max_pages")); err == nil && maxPages > 0 {
|
||||||
|
ExtConfig.Yeeke.MaxPages = maxPages
|
||||||
|
}
|
||||||
|
if retry, err := strconv.Atoi(scalar(yeeke, "retry")); err == nil && retry >= 0 {
|
||||||
|
ExtConfig.Yeeke.Retry = retry
|
||||||
|
}
|
||||||
|
if attempts, err := strconv.Atoi(scalar(yeeke, "ocr_max_attempts")); err == nil && attempts > 0 {
|
||||||
|
ExtConfig.Yeeke.OcrMaxAttempts = attempts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// logInfo writes an informational startup line to stdout.
|
// logInfo writes an informational startup line to stdout.
|
||||||
//
|
//
|
||||||
// `[必须]` Not stderr. The development launcher pipes the server through
|
// `[必须]` Not stderr. The development launcher pipes the server through
|
||||||
|
|||||||
@@ -61,6 +61,17 @@ settings:
|
|||||||
# `[必须]` 验证码图片会被发送到这个地址;换成别人运营的服务前要重新评估。
|
# `[必须]` 验证码图片会被发送到这个地址;换成别人运营的服务前要重新评估。
|
||||||
ocrurl: https://ocr.ilapage.cn/ocr
|
ocrurl: https://ocr.ilapage.cn/ocr
|
||||||
ocrmaxattempts: 5
|
ocrmaxattempts: 5
|
||||||
|
# yeeke(mmt.yeeke.com)退货包裹只读对接,见 #336。
|
||||||
|
# `[必须]` 账号密码不在这里,走环境变量 GOAUTO_YEEKE_USERNAME / GOAUTO_YEEKE_PASSWORD,
|
||||||
|
# 以免凭据进 Git。
|
||||||
|
yeeke:
|
||||||
|
baseurl: https://mmt.yeeke.com
|
||||||
|
pagesize: 100
|
||||||
|
maxpages: 10000
|
||||||
|
retry: 2
|
||||||
|
# 验证码识别服务,与 SYB 共用(#336 已批准)。
|
||||||
|
ocrurl: https://ocr.ilapage.cn/ocr
|
||||||
|
ocrmaxattempts: 5
|
||||||
cache:
|
cache:
|
||||||
# redis:
|
# redis:
|
||||||
# addr: 127.0.0.1:6379
|
# addr: 127.0.0.1:6379
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import request from '@/utils/request'
|
||||||
|
|
||||||
|
export function listReturnMatches(params, options = {}) {
|
||||||
|
return request({ url: '/api/admin/v1/return-matches', method: 'get', params, ...options })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getReturnMatch(matchId, options = {}) {
|
||||||
|
return request({ url: `/api/admin/v1/return-matches/${matchId}`, method: 'get', ...options })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function batchMatchReturns(data) {
|
||||||
|
return request({ url: '/api/admin/v1/return-matches/batch-match', method: 'post', data })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function confirmReturnMatch(matchId) {
|
||||||
|
return request({ url: `/api/admin/v1/return-matches/${matchId}/confirm`, method: 'post' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cancelReturnMatch(matchId) {
|
||||||
|
return request({ url: `/api/admin/v1/return-matches/${matchId}/cancel`, method: 'post' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function remarkReturnMatch(matchId, data) {
|
||||||
|
return request({ url: `/api/admin/v1/return-matches/${matchId}/remark`, method: 'post', data })
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import request from '@/utils/request'
|
||||||
|
|
||||||
|
export function listYeekeReturnPackages(params, options = {}) {
|
||||||
|
return request({ url: '/api/admin/v1/yeeke-returns', method: 'get', params, ...options })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getYeekeReturnPackage(packageId) {
|
||||||
|
return request({ url: `/api/admin/v1/yeeke-returns/${packageId}`, method: 'get' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listYeekeReturnShops(options = {}) {
|
||||||
|
return request({ url: '/api/admin/v1/yeeke-returns/shops', method: 'get', ...options })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getYeekeReturnItem(itemId) {
|
||||||
|
return request({ url: `/api/admin/v1/yeeke-returns/items/${itemId}`, method: 'get' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listYeekeSyncRuns(params, options = {}) {
|
||||||
|
return request({ url: '/api/admin/v1/yeeke-returns/sync-runs', method: 'get', params, ...options })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getYeekeSyncRun(runId) {
|
||||||
|
return request({ url: `/api/admin/v1/yeeke-returns/sync-runs/${runId}`, method: 'get' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function triggerYeekeSync() {
|
||||||
|
return request({ url: '/api/admin/v1/yeeke-returns/sync', method: 'post' })
|
||||||
|
}
|
||||||
@@ -91,7 +91,7 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
canPurchase() { const roles = this.$store.getters.roles || []; return roles.includes('admin') || roles.includes('purchaser') },
|
canPurchase() { const roles = this.$store.getters.roles || []; return roles.includes('admin') || roles.includes('purchaser') || roles.includes('after_sales') },
|
||||||
relatedPurchaseCandidates() { return this.related.selected.filter(row => this.isRelatedPurchaseCandidate(row)) },
|
relatedPurchaseCandidates() { return this.related.selected.filter(row => this.isRelatedPurchaseCandidate(row)) },
|
||||||
stockColorOptions() { return this.stockSpecValues('color').filter(item => item.priceCent !== null && item.priceCent !== undefined && item.priceCent >= 0) },
|
stockColorOptions() { return this.stockSpecValues('color').filter(item => item.priceCent !== null && item.priceCent !== undefined && item.priceCent >= 0) },
|
||||||
stockSizeOptions() { return this.stockSpecValues('size') },
|
stockSizeOptions() { return this.stockSpecValues('size') },
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
<template #wrapper>
|
<template #wrapper>
|
||||||
<el-card class="page-card" shadow="never">
|
<el-card class="page-card" shadow="never">
|
||||||
<el-form :model="query" :inline="true" class="search-form" @submit.prevent="search">
|
<el-form :model="query" :inline="true" class="search-form" @submit.prevent="search">
|
||||||
|
<div class="search-row search-row-primary">
|
||||||
<el-form-item v-if="canPurchase"><span class="selection-count" aria-live="polite">已选择 <strong>{{ selectedProducts.length }}</strong> 条</span></el-form-item>
|
<el-form-item v-if="canPurchase"><span class="selection-count" aria-live="polite">已选择 <strong>{{ selectedProducts.length }}</strong> 条</span></el-form-item>
|
||||||
<el-form-item label="店铺">
|
<el-form-item label="店铺">
|
||||||
<el-autocomplete v-model="query.shopName" class="shop-input" placeholder="选择或输入店铺" clearable value-key="displayName" :fetch-suggestions="suggestSybShops">
|
<el-autocomplete v-model="query.shopName" class="shop-input" placeholder="选择或输入店铺" clearable value-key="displayName" :fetch-suggestions="suggestSybShops">
|
||||||
@@ -12,7 +13,11 @@
|
|||||||
<el-form-item label="订单号"><el-input v-model="query.orderCodesText" type="textarea" :autosize="{ minRows: 1, maxRows: 4 }" resize="none" class="order-codes-input" placeholder="每行一个订单号,最多 100 个" @keydown.ctrl.enter.prevent="search" /></el-form-item>
|
<el-form-item label="订单号"><el-input v-model="query.orderCodesText" type="textarea" :autosize="{ minRows: 1, maxRows: 4 }" resize="none" class="order-codes-input" placeholder="每行一个订单号,最多 100 个" @keydown.ctrl.enter.prevent="search" /></el-form-item>
|
||||||
<el-form-item label="解析状态"><el-select v-model="query.parseStatus" clearable placeholder="全部" style="width: 110px"><el-option label="成功" value="success" /><el-option label="失败" value="failed" /></el-select></el-form-item>
|
<el-form-item label="解析状态"><el-select v-model="query.parseStatus" clearable placeholder="全部" style="width: 110px"><el-option label="成功" value="success" /><el-option label="失败" value="failed" /></el-select></el-form-item>
|
||||||
<el-form-item label="处理阶段"><el-select v-model="query.processStage" clearable placeholder="全部" style="width: 132px"><el-option v-for="item in processStageOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item>
|
<el-form-item label="处理阶段"><el-select v-model="query.processStage" clearable placeholder="全部" style="width: 132px"><el-option v-for="item in processStageOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item>
|
||||||
<el-form-item class="toolbar-actions"><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button><el-tooltip v-if="canPurchase" :content="aiMatchButtonReason" :disabled="!aiMatchButtonReason" placement="top"><span class="action-button-wrap"><el-button :loading="specMatchLoading" :disabled="purchaseReadinessLoading || specMatchLoading || aiMatchCandidates.length === 0" @click="runBatchSpecMatch">AI 匹配<span class="action-count">{{ aiMatchCandidates.length }}</span></el-button></span></el-tooltip><el-button v-if="canPurchase" type="success" plain :disabled="imageSearchRows.length === 0" @click="openImageSearchBatch">图搜采集<span class="action-count">{{ imageSearchRows.length }}</span></el-button><el-button v-if="canPurchase" type="primary" :loading="purchaseReadinessLoading" :disabled="purchaseReadinessLoading || specMatchLoading || collectionCandidates.length === 0" @click="openCollectionBatch">创建采集<span class="action-count action-count-primary">{{ collectionCandidates.length }}</span></el-button><el-button v-if="canPurchase" type="primary" :loading="purchaseReadinessLoading" :disabled="purchaseReadinessLoading || specMatchLoading || purchaseCandidates.length === 0" @click="openPurchaseBatch">创建采购<span class="action-count action-count-primary">{{ purchaseCandidates.length }}</span></el-button></el-form-item>
|
<el-form-item class="toolbar-actions"><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button><el-tooltip v-if="canPurchase" :content="aiMatchButtonReason" :disabled="!aiMatchButtonReason" placement="top"><span class="action-button-wrap"><el-button :loading="specMatchLoading" :disabled="purchaseReadinessLoading || specMatchLoading || aiMatchCandidates.length === 0 || aiMatchButtonOverLimit" @click="runBatchSpecMatch">AI 匹配<span class="action-count">{{ aiMatchCandidates.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="imageSearchButtonReason" :disabled="!imageSearchButtonReason" placement="top"><span class="action-button-wrap"><el-button type="success" plain :disabled="imageSearchRows.length === 0 || imageSearchButtonOverLimit" @click="openImageSearchBatch">图搜采集<span class="action-count">{{ imageSearchRows.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="collectionButtonReason" :disabled="!collectionButtonReason" placement="top"><span class="action-button-wrap"><el-button type="primary" :loading="purchaseReadinessLoading" :disabled="purchaseReadinessLoading || specMatchLoading || collectionCandidates.length === 0 || collectionButtonOverLimit" @click="openCollectionBatch">创建采集<span class="action-count action-count-primary">{{ collectionCandidates.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="purchaseButtonReason" :disabled="!purchaseButtonReason" placement="top"><span class="action-button-wrap"><el-button type="primary" :loading="purchaseReadinessLoading" :disabled="purchaseReadinessLoading || specMatchLoading || purchaseCandidates.length === 0 || purchaseButtonOverLimit" @click="openPurchaseBatch">创建采购<span class="action-count action-count-primary">{{ purchaseCandidates.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="returnMatchButtonReason" :disabled="!returnMatchButtonReason" placement="top"><span class="action-button-wrap"><el-button :loading="returnMatchBatchLoading" :disabled="purchaseReadinessLoading || returnMatchBatchLoading || returnMatchCandidateIds.length === 0 || returnMatchButtonOverLimit" @click="runBatchMatchReturns">匹配退货<span class="action-count">{{ returnMatchCandidateIds.length }}</span></el-button></span></el-tooltip></el-form-item>
|
||||||
|
</div>
|
||||||
|
<div class="search-row search-row-secondary">
|
||||||
|
<el-form-item label="创建时间"><el-date-picker v-model="query.createdAtRange" type="daterange" value-format="YYYY-MM-DD" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" clearable /></el-form-item>
|
||||||
|
</div>
|
||||||
</el-form>
|
</el-form>
|
||||||
<el-alert v-if="canPurchase" title="先完成并保存规格匹配,商品才可以创建采购;AI 匹配只处理已解析规格、已关联 PDD 且具备完整可售 SKU 组合的明细。表头全选仅作用于当前页。" type="info" :closable="false" show-icon class="notice compact-notice" />
|
<el-alert v-if="canPurchase" title="先完成并保存规格匹配,商品才可以创建采购;AI 匹配只处理已解析规格、已关联 PDD 且具备完整可售 SKU 组合的明细。表头全选仅作用于当前页。" type="info" :closable="false" show-icon class="notice compact-notice" />
|
||||||
<el-table ref="productTable" v-loading="loading" :data="products" row-key="id" border stripe empty-text="暂无 SYB 商品明细" @selection-change="handleSelectionChange">
|
<el-table ref="productTable" v-loading="loading" :data="products" row-key="id" border stripe empty-text="暂无 SYB 商品明细" @selection-change="handleSelectionChange">
|
||||||
@@ -51,9 +56,31 @@
|
|||||||
<div v-if="quickBusyId === row.id || quickMessages[row.id]" class="muted" aria-live="polite">{{ quickBusyId === row.id ? quickPhase : quickMessages[row.id] }}</div>
|
<div v-if="quickBusyId === row.id || quickMessages[row.id]" class="muted" aria-live="polite">{{ quickBusyId === row.id ? quickPhase : quickMessages[row.id] }}</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column label="匹配到的退货商品" min-width="200">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<template v-if="returnMatchByProductId[row.id]">
|
||||||
|
<div class="return-match-cell">
|
||||||
|
<el-image v-if="returnMatchByProductId[row.id].image" :src="returnMatchByProductId[row.id].image" fit="cover" class="thumb" preview-teleported />
|
||||||
|
<div>
|
||||||
|
<div>{{ returnMatchByProductId[row.id].orderSn || '—' }}</div>
|
||||||
|
<div class="muted">{{ returnMatchByProductId[row.id].variationName || '—' }}</div>
|
||||||
|
<div class="muted">销毁截止:{{ formatMatchDeadline(returnMatchByProductId[row.id].destroyDeadline) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-alert v-if="returnMatchByProductId[row.id].syncStatus === 'missing'" title="退货已不在 yeeke 列表" type="error" :closable="false" show-icon class="return-missing-alert" />
|
||||||
|
<el-alert v-if="isDeadlinePassed(returnMatchByProductId[row.id].destroyDeadline)" title="退货已过销毁截止" type="error" :closable="false" show-icon class="return-missing-alert" />
|
||||||
|
<div class="quick-link-actions">
|
||||||
|
<el-button type="primary" link @click="openMatchDetail(returnMatchByProductId[row.id].id)">查看对比</el-button>
|
||||||
|
<el-button v-if="canPurchase" type="primary" link @click="openMatchDetail(returnMatchByProductId[row.id].id, true)">备注</el-button>
|
||||||
|
<el-button v-if="canPurchase" type="danger" link @click="quickCancelMatch(returnMatchByProductId[row.id].id)">取消匹配</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<span v-else class="muted">未匹配</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="132" fixed="right"><template #default="{ row }"><el-button type="primary" link @click="openDetail(row.id)">详情</el-button><el-button v-if="canPurchase && isPurchaseCandidate(row)" type="primary" link @click="openSinglePurchase(row)">采购</el-button></template></el-table-column>
|
<el-table-column label="操作" width="132" fixed="right"><template #default="{ row }"><el-button type="primary" link @click="openDetail(row.id)">详情</el-button><el-button v-if="canPurchase && isPurchaseCandidate(row)" type="primary" link @click="openSinglePurchase(row)">采购</el-button></template></el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
<pagination v-show="total > 0" v-model:current-page="query.page" v-model:page-size="query.pageSize" :total="total" @pagination="load" />
|
<pagination v-show="total > 0" v-model:current-page="query.page" v-model:page-size="query.pageSize" :page-sizes="[20, 50, 100, 200, 500]" :total="total" @pagination="load" />
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<el-dialog v-model="quickPicker.open" :title="quickPicker.row ? '选择采集手机' : '切换采集手机'" width="460px" :close-on-click-modal="false" @close="quickCancelPicker">
|
<el-dialog v-model="quickPicker.open" :title="quickPicker.row ? '选择采集手机' : '切换采集手机'" width="460px" :close-on-click-modal="false" @close="quickCancelPicker">
|
||||||
@@ -162,6 +189,82 @@
|
|||||||
<template #footer><el-button type="primary" @click="specMatchResult.open = false">关闭</el-button></template>
|
<template #footer><el-button type="primary" @click="specMatchResult.open = false">关闭</el-button></template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 匹配退货批量结果 -->
|
||||||
|
<el-dialog v-model="returnMatchBatchResult.open" title="批量匹配退货结果" width="820px">
|
||||||
|
<div class="purchase-summary" aria-live="polite">
|
||||||
|
<span class="success-text">成功匹配 <strong>{{ returnMatchBatchResult.matchedCount }}</strong> 条</span>
|
||||||
|
<span>无候选 <strong>{{ returnMatchBatchResult.noCandidateCount }}</strong> 条</span>
|
||||||
|
<span>阶段不参与跳过 <strong>{{ returnMatchBatchResult.stageIneligibleCount }}</strong> 条</span>
|
||||||
|
<span v-if="returnMatchBatchResult.conflictCount" class="danger-text">并发冲突跳过 <strong>{{ returnMatchBatchResult.conflictCount }}</strong> 条</span>
|
||||||
|
</div>
|
||||||
|
<el-table :data="returnMatchBatchResult.items" border size="small" max-height="420" empty-text="没有匹配结果">
|
||||||
|
<el-table-column label="SYB 商品 ID" prop="sybProductId" width="120" />
|
||||||
|
<el-table-column label="结果" width="100"><template #default="{ row }"><el-tag :type="row.matched ? 'success' : 'warning'">{{ row.matched ? '已匹配' : '跳过' }}</el-tag></template></el-table-column>
|
||||||
|
<el-table-column label="原因" min-width="260"><template #default="{ row }">{{ row.matched ? '—' : (row.reason || matchReasonMeta(row.reasonCode)) }}</template></el-table-column>
|
||||||
|
<el-table-column label="操作" width="100"><template #default="{ row }"><el-button v-if="row.matched && row.matchId" type="primary" link @click="openMatchDetail(row.matchId)">查看对比</el-button></template></el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<template #footer><el-button type="primary" @click="returnMatchBatchResult.open = false">关闭</el-button></template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 匹配详情对比 -->
|
||||||
|
<el-dialog v-model="matchDetail.open" title="退货匹配详情" width="900px" :close-on-click-modal="false">
|
||||||
|
<div v-loading="matchDetail.loading">
|
||||||
|
<template v-if="matchDetail.data">
|
||||||
|
<el-alert :title="'当前状态:' + matchStatusLabel(matchDetail.data.match.status)" :type="matchDetail.data.match.status === 'confirmed' ? 'success' : matchDetail.data.match.status === 'cancelled' ? 'info' : 'warning'" :closable="false" show-icon class="notice" />
|
||||||
|
<div class="split">
|
||||||
|
<div class="split-col">
|
||||||
|
<h3 class="section-title">SYB 商品</h3>
|
||||||
|
<el-descriptions :column="1" border size="small" v-if="matchDetail.data.syb">
|
||||||
|
<el-descriptions-item label="订单号">{{ matchDetail.data.syb.orderCode }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="虾皮商品ID">{{ matchDetail.data.syb.shopeeItemId }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="店铺">{{ matchDetail.data.syb.shopName }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="标题">{{ matchDetail.data.syb.productTitle }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="目标颜色/尺码">{{ matchDetail.data.syb.targetColor || '—' }} / {{ matchDetail.data.syb.targetSize || '—' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="数量">{{ matchDetail.data.syb.quantity }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="当前阶段"><el-tag :type="processMeta(matchDetail.data.syb.currentStage).type">{{ matchDetail.data.syb.currentStageLabel }}</el-tag></el-descriptions-item>
|
||||||
|
<el-descriptions-item label="归一化规格文字">{{ matchDetail.data.normalizedSybSpec }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
<p v-else class="muted">SYB 商品不存在或已删除</p>
|
||||||
|
</div>
|
||||||
|
<div class="split-col">
|
||||||
|
<h3 class="section-title">yeeke 退货商品</h3>
|
||||||
|
<el-alert v-if="matchDetail.data.yeeke && matchDetail.data.yeeke.syncStatus === 'missing'" title="退货已不在 yeeke 列表" type="error" :closable="false" show-icon class="notice" />
|
||||||
|
<el-alert v-if="matchDetail.data.yeeke && isDeadlinePassed(matchDetail.data.yeeke.destroyDeadline)" title="退货已过销毁截止,请核实退货是否仍在库" type="error" :closable="false" show-icon class="notice" />
|
||||||
|
<el-descriptions :column="1" border size="small" v-if="matchDetail.data.yeeke">
|
||||||
|
<el-descriptions-item label="退货订单号">{{ matchDetail.data.yeeke.orderSn }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="商品ID">{{ matchDetail.data.yeeke.itemId }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="规格ID">{{ matchDetail.data.yeeke.variationId }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="店铺">{{ matchDetail.data.yeeke.shopName }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="商品名">{{ matchDetail.data.yeeke.itemName }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="规格文字">{{ matchDetail.data.yeeke.variationName }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="数量">{{ matchDetail.data.yeeke.quantity }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="销毁截止">{{ formatMatchDeadline(matchDetail.data.yeeke.destroyDeadline) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="归一化规格文字">{{ matchDetail.data.normalizedYeekeSpec }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
<p v-else class="muted">退货商品不存在或已删除</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h3 class="section-title">备注</h3>
|
||||||
|
<el-input ref="matchRemarkInput" v-model="matchDetail.remarkText" type="textarea" :rows="2" maxlength="500" show-word-limit :disabled="!canPurchase" />
|
||||||
|
<div class="drawer-actions">
|
||||||
|
<el-button v-if="canPurchase" :loading="matchDetail.saving" @click="saveMatchRemark">保存备注</el-button>
|
||||||
|
</div>
|
||||||
|
<h3 class="section-title">操作日志</h3>
|
||||||
|
<el-table :data="matchDetail.data.logs" border size="small" max-height="220" empty-text="暂无日志">
|
||||||
|
<el-table-column label="时间" width="170"><template #default="{ row }">{{ formatMatchDeadline(row.createdAt) }}</template></el-table-column>
|
||||||
|
<el-table-column label="动作" width="90" prop="action" />
|
||||||
|
<el-table-column label="操作人" width="100" prop="operator" />
|
||||||
|
<el-table-column label="详情" min-width="200" prop="detail" />
|
||||||
|
</el-table>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="matchDetail.open = false">关闭</el-button>
|
||||||
|
<el-button v-if="canPurchase && matchDetail.data && matchDetail.data.match.status === 'matched'" type="success" @click="confirmMatchDetail">确认</el-button>
|
||||||
|
<el-button v-if="canPurchase && matchDetail.data && matchDetail.data.match.status !== 'cancelled'" type="danger" @click="cancelMatchDetail">取消匹配</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
<!-- 详情 -->
|
<!-- 详情 -->
|
||||||
<el-drawer v-model="detail.open" title="SYB 商品明细详情" size="960px">
|
<el-drawer v-model="detail.open" title="SYB 商品明细详情" size="960px">
|
||||||
<div v-loading="detail.loading" class="drawer-body">
|
<div v-loading="detail.loading" class="drawer-body">
|
||||||
@@ -227,6 +330,7 @@ import { listDevices } from '@/api/goauto/devices'
|
|||||||
import { createPurchaseTasksBatch, matchPurchaseSpecsBatch, previewPurchaseTasks } from '@/api/goauto/purchase-tasks'
|
import { createPurchaseTasksBatch, matchPurchaseSpecsBatch, previewPurchaseTasks } from '@/api/goauto/purchase-tasks'
|
||||||
import { batchCreateCollectionTasks, batchCreateImageSearchCollectionTasks } from '@/api/goauto/collection-tasks'
|
import { batchCreateCollectionTasks, batchCreateImageSearchCollectionTasks } from '@/api/goauto/collection-tasks'
|
||||||
import { listCollectionRules } from '@/api/goauto/collection-rules'
|
import { listCollectionRules } from '@/api/goauto/collection-rules'
|
||||||
|
import { listReturnMatches, batchMatchReturns, getReturnMatch, confirmReturnMatch, cancelReturnMatch, remarkReturnMatch } from '@/api/goauto/return-matches'
|
||||||
import { createRequestId } from '@/utils/request-id'
|
import { createRequestId } from '@/utils/request-id'
|
||||||
import { readPurchaseDevice, rememberPurchaseDevice } from '@/utils/purchase-device-preference'
|
import { readPurchaseDevice, rememberPurchaseDevice } from '@/utils/purchase-device-preference'
|
||||||
import ShopeeProductDetailDrawer from '../shopee-products/ShopeeProductDetailDrawer.vue'
|
import ShopeeProductDetailDrawer from '../shopee-products/ShopeeProductDetailDrawer.vue'
|
||||||
@@ -234,6 +338,12 @@ import PddProductDetailDrawer from '../pdd-products/PddProductDetailDrawer.vue'
|
|||||||
|
|
||||||
// 与服务端 image_search.go 的 imageSearchMaxBatchTasks 保持一致(单批去重后任务数上限,#280)。
|
// 与服务端 image_search.go 的 imageSearchMaxBatchTasks 保持一致(单批去重后任务数上限,#280)。
|
||||||
const IMAGE_SEARCH_MAX_BATCH_TASKS = 50
|
const IMAGE_SEARCH_MAX_BATCH_TASKS = 50
|
||||||
|
// #339: 各批量按钮的服务端上限,用于勾选数超限时禁用按钮并提示原因。
|
||||||
|
// 创建采购 / AI 匹配:purchase/batch.go maxBatchPurchaseItems;创建采集(去重后 PDD 商品数):
|
||||||
|
// task/admin_service.go BatchCreate 的 100 上限;匹配退货:returnmatch/service.go maxBatchMatchItems。
|
||||||
|
const PURCHASE_BATCH_MAX = 100
|
||||||
|
const COLLECTION_BATCH_MAX = 100
|
||||||
|
const RETURN_MATCH_BATCH_MAX = 500
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'GoAutoSybProducts',
|
name: 'GoAutoSybProducts',
|
||||||
@@ -254,7 +364,12 @@ export default {
|
|||||||
imageSearchBatch: this.emptyImageSearchBatch(),
|
imageSearchBatch: this.emptyImageSearchBatch(),
|
||||||
collectionBatchData: { ruleId: null, deviceId: null },
|
collectionBatchData: { ruleId: null, deviceId: null },
|
||||||
collectionBatchRules: { ruleId: [{ required: true, message: '请选择采集规则', trigger: 'change' }] },
|
collectionBatchRules: { ruleId: [{ required: true, message: '请选择采集规则', trigger: 'change' }] },
|
||||||
query: { page: 1, pageSize: 20, shopName: '', orderCodesText: '', parseStatus: '', processStage: '' },
|
query: { page: 1, pageSize: 200, shopName: '', orderCodesText: '', parseStatus: '', processStage: '', createdAtRange: [] },
|
||||||
|
returnMatchByProductId: {},
|
||||||
|
returnMatchLoading: false,
|
||||||
|
returnMatchBatchLoading: false,
|
||||||
|
returnMatchBatchResult: { open: false, items: [], matchedCount: 0, noCandidateCount: 0, stageIneligibleCount: 0, conflictCount: 0 },
|
||||||
|
matchDetail: { open: false, loading: false, saving: false, id: null, data: null, remarkText: '' },
|
||||||
detail: { open: false, loading: false, item: null },
|
detail: { open: false, loading: false, item: null },
|
||||||
correct: { open: false, saving: false, productId: null, targetColor: '', targetSize: '' },
|
correct: { open: false, saving: false, productId: null, targetColor: '', targetSize: '' },
|
||||||
shopeeDetail: { open: false, productId: null, targetColor: '', action: '' },
|
shopeeDetail: { open: false, productId: null, targetColor: '', action: '' },
|
||||||
@@ -264,23 +379,38 @@ export default {
|
|||||||
computed: {
|
computed: {
|
||||||
purchaseDeviceUnavailable() { return !!this.purchaseDialog.deviceId && !this.purchaseDevices.some(device => device.id === this.purchaseDialog.deviceId) },
|
purchaseDeviceUnavailable() { return !!this.purchaseDialog.deviceId && !this.purchaseDevices.some(device => device.id === this.purchaseDialog.deviceId) },
|
||||||
purchaseImageSearchLinkedCount() { return this.purchaseDialog.items.filter(item => item.imageSearchLinked).length },
|
purchaseImageSearchLinkedCount() { return this.purchaseDialog.items.filter(item => item.imageSearchLinked).length },
|
||||||
canPurchase() { const roles = this.$store.getters.roles || []; return roles.includes('admin') || roles.includes('purchaser') },
|
canPurchase() { const roles = this.$store.getters.roles || []; return roles.includes('admin') || roles.includes('purchaser') || roles.includes('after_sales') },
|
||||||
processStageOptions() { return [{ value: 'manual_action', label: '待人工处理' }, { value: 'pdd_unlinked', label: '未关联 PDD' }, { value: 'pdd_pending', label: 'PDD 待采集' }, { value: 'pdd_collecting', label: 'PDD 采集中' }, { value: 'pdd_collection_failed', label: 'PDD 采集失败' }, { value: 'color_mapping', label: '规格待匹配' }, { value: 'purchase_ready', label: '可创建采购' }, { value: 'task_created', label: '已创建任务' }, { value: 'purchase_succeeded', label: '采购成功' }, { value: 'order_review', label: '待人工核对' }] },
|
processStageOptions() { return [{ value: 'manual_action', label: '待人工处理' }, { value: 'pdd_unlinked', label: '未关联 PDD' }, { value: 'pdd_pending', label: 'PDD 待采集' }, { value: 'pdd_collecting', label: 'PDD 采集中' }, { value: 'pdd_collection_failed', label: 'PDD 采集失败' }, { value: 'color_mapping', label: '规格待匹配' }, { value: 'purchase_ready', label: '可创建采购' }, { value: 'task_created', label: '已创建任务' }, { value: 'purchase_succeeded', label: '采购成功' }, { value: 'order_review', label: '待人工核对' }, { value: 'return_pending', label: '退货待确认' }, { value: 'return_used', label: '已用退货' }] },
|
||||||
|
returnMatchCandidateIds() { return this.selectedProducts.filter(row => this.isReturnMatchCandidate(row)).map(row => row.id) },
|
||||||
aiMatchCandidates() { return this.selectedProducts.filter(row => this.purchaseReady(row).aiMatchEligible === true) },
|
aiMatchCandidates() { return this.selectedProducts.filter(row => this.purchaseReady(row).aiMatchEligible === true) },
|
||||||
|
aiMatchButtonOverLimit() { return this.aiMatchCandidates.length > PURCHASE_BATCH_MAX },
|
||||||
aiMatchButtonReason() {
|
aiMatchButtonReason() {
|
||||||
if (this.purchaseReadinessLoading) return '正在检查 AI 匹配资格'
|
if (this.purchaseReadinessLoading) return '正在检查 AI 匹配资格'
|
||||||
if (!this.selectedProducts.length) return '请先勾选当前页中可处理的明细'
|
if (!this.selectedProducts.length) return '请先勾选当前页中可处理的明细'
|
||||||
|
if (this.aiMatchButtonOverLimit) return `一次最多 ${PURCHASE_BATCH_MAX} 条`
|
||||||
if (this.aiMatchCandidates.length) return ''
|
if (this.aiMatchCandidates.length) return ''
|
||||||
const reasons = [...new Set(this.selectedProducts.map(row => this.purchaseReady(row).aiMatchDisabledReason).filter(Boolean))]
|
const reasons = [...new Set(this.selectedProducts.map(row => this.purchaseReady(row).aiMatchDisabledReason).filter(Boolean))]
|
||||||
return reasons[0] || '所选明细不满足 AI 匹配前提'
|
return reasons[0] || '所选明细不满足 AI 匹配前提'
|
||||||
},
|
},
|
||||||
purchaseCandidates() { return this.selectedProducts.filter(row => this.isPurchaseCandidate(row)) },
|
purchaseCandidates() { return this.selectedProducts.filter(row => this.isPurchaseCandidate(row)) },
|
||||||
|
// #339: 创建采购上限保持 100(服务端 maxBatchPurchaseItems 不放宽),超出勾选数时禁用并提示。
|
||||||
|
purchaseButtonOverLimit() { return this.purchaseCandidates.length > PURCHASE_BATCH_MAX },
|
||||||
|
purchaseButtonReason() { return this.purchaseButtonOverLimit ? `一次最多 ${PURCHASE_BATCH_MAX} 条` : '' },
|
||||||
collectionCandidateRows() { return this.selectedProducts.filter(row => this.isCollectionCandidate(row)) },
|
collectionCandidateRows() { return this.selectedProducts.filter(row => this.isCollectionCandidate(row)) },
|
||||||
collectionCandidates() { return [...new Set(this.collectionCandidateRows.map(row => this.purchaseReady(row).pddProductId))] },
|
collectionCandidates() { return [...new Set(this.collectionCandidateRows.map(row => this.purchaseReady(row).pddProductId))] },
|
||||||
|
// #339: 创建采集按去重后的 PDD 商品数计数,与服务端 task.BatchCreate 的 100 上限口径一致。
|
||||||
|
collectionButtonOverLimit() { return this.collectionCandidates.length > COLLECTION_BATCH_MAX },
|
||||||
|
collectionButtonReason() { return this.collectionButtonOverLimit ? `一次最多 ${COLLECTION_BATCH_MAX} 个 PDD 商品` : '' },
|
||||||
imageSearchRows() { return this.selectedProducts.filter(row => row.shopeeProductId && row.imageUrl) },
|
imageSearchRows() { return this.selectedProducts.filter(row => row.shopeeProductId && row.imageUrl) },
|
||||||
// 服务端按去重后的蝦皮商品数创建任务(见 image_search.go 的 imageSearchMaxBatchTasks),
|
// 服务端按去重后的蝦皮商品数创建任务(见 image_search.go 的 imageSearchMaxBatchTasks),
|
||||||
// 这里用相同口径预估,避免提交后才发现超限。
|
// 这里用相同口径预估,避免提交后才发现超限。
|
||||||
imageSearchMaxBatchTasks() { return IMAGE_SEARCH_MAX_BATCH_TASKS },
|
imageSearchMaxBatchTasks() { return IMAGE_SEARCH_MAX_BATCH_TASKS },
|
||||||
|
imageSearchButtonTaskCount() { return new Set(this.imageSearchRows.map(row => row.shopeeProductId)).size },
|
||||||
|
imageSearchButtonOverLimit() { return this.imageSearchButtonTaskCount > IMAGE_SEARCH_MAX_BATCH_TASKS },
|
||||||
|
imageSearchButtonReason() { return this.imageSearchButtonOverLimit ? `一次最多 ${IMAGE_SEARCH_MAX_BATCH_TASKS} 个蝦皮商品` : '' },
|
||||||
|
// #339: 匹配退货服务端上限(returnmatch.maxBatchMatchItems),超出直接勾选数即禁用。
|
||||||
|
returnMatchButtonOverLimit() { return this.returnMatchCandidateIds.length > RETURN_MATCH_BATCH_MAX },
|
||||||
|
returnMatchButtonReason() { return this.returnMatchButtonOverLimit ? `一次最多 ${RETURN_MATCH_BATCH_MAX} 条` : '' },
|
||||||
imageSearchBatchTaskCount() { return new Set(this.imageSearchBatch.rows.map(row => row.shopeeProductId)).size },
|
imageSearchBatchTaskCount() { return new Set(this.imageSearchBatch.rows.map(row => row.shopeeProductId)).size },
|
||||||
imageSearchBatchOverLimit() { return this.imageSearchBatchTaskCount > IMAGE_SEARCH_MAX_BATCH_TASKS },
|
imageSearchBatchOverLimit() { return this.imageSearchBatchTaskCount > IMAGE_SEARCH_MAX_BATCH_TASKS },
|
||||||
imageSearchBatchDurationText() {
|
imageSearchBatchDurationText() {
|
||||||
@@ -307,12 +437,14 @@ export default {
|
|||||||
this.$refs.productTable?.clearSelection()
|
this.$refs.productTable?.clearSelection()
|
||||||
const requestOptions = allowNetworkRetry ? { suppressNetworkError: true } : {}
|
const requestOptions = allowNetworkRetry ? { suppressNetworkError: true } : {}
|
||||||
try {
|
try {
|
||||||
const r = await listSybProducts({ page: this.query.page, pageSize: this.query.pageSize, shopName: this.query.shopName.trim(), orderCodes: orderCodes.join(','), parseStatus: this.query.parseStatus, processStage: this.query.processStage }, requestOptions)
|
const [createdFrom = '', createdTo = ''] = this.query.createdAtRange || []
|
||||||
|
const r = await listSybProducts({ page: this.query.page, pageSize: this.query.pageSize, shopName: this.query.shopName.trim(), orderCodes: orderCodes.join(','), parseStatus: this.query.parseStatus, processStage: this.query.processStage, createdFrom, createdTo }, requestOptions)
|
||||||
if (generation !== this.loadGeneration) return
|
if (generation !== this.loadGeneration) return
|
||||||
this.products = r.data.items
|
this.products = r.data.items
|
||||||
this.total = r.data.total
|
this.total = r.data.total
|
||||||
this.loading = false
|
this.loading = false
|
||||||
void this.loadPurchaseReadiness(this.products.map(item => item.id), requestOptions, generation)
|
void this.loadPurchaseReadiness(this.products.map(item => item.id), requestOptions, generation)
|
||||||
|
void this.loadReturnMatches(this.products.map(item => item.id), generation)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (generation !== this.loadGeneration) return
|
if (generation !== this.loadGeneration) return
|
||||||
if (allowNetworkRetry && error?.message === 'Network Error' && !error?.response) {
|
if (allowNetworkRetry && error?.message === 'Network Error' && !error?.response) {
|
||||||
@@ -326,7 +458,7 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
search() { this.query.page = 1; this.load() },
|
search() { this.query.page = 1; this.load() },
|
||||||
reset() { this.query = { page: 1, pageSize: 20, shopName: '', orderCodesText: '', parseStatus: '', processStage: '' }; this.load() },
|
reset() { this.query = { page: 1, pageSize: 200, shopName: '', orderCodesText: '', parseStatus: '', processStage: '', createdAtRange: [] }; this.load() },
|
||||||
normalizeShopName(value) { return String(value || '').normalize('NFKC').trim().toLocaleLowerCase() },
|
normalizeShopName(value) { return String(value || '').normalize('NFKC').trim().toLocaleLowerCase() },
|
||||||
async ensureShopOptions() {
|
async ensureShopOptions() {
|
||||||
if (this.shopOptionsLoaded) return
|
if (this.shopOptionsLoaded) return
|
||||||
@@ -357,24 +489,43 @@ export default {
|
|||||||
// 不能要求已关联——另外三个判据都要求,于是图搜入口对它最该服务的那类商品
|
// 不能要求已关联——另外三个判据都要求,于是图搜入口对它最该服务的那类商品
|
||||||
// 一直不可达(#291)。这里与 imageSearchRows 的过滤条件保持一致。
|
// 一直不可达(#291)。这里与 imageSearchRows 的过滤条件保持一致。
|
||||||
isImageSearchCandidate(row) { return Boolean(row.shopeeProductId && row.imageUrl) },
|
isImageSearchCandidate(row) { return Boolean(row.shopeeProductId && row.imageUrl) },
|
||||||
|
// #338: 参与匹配退货的处理阶段范围(服务端 returnmatch.participatingStages 同一口径)。
|
||||||
|
isReturnMatchCandidate(row) {
|
||||||
|
const participating = new Set(['pdd_unlinked', 'pdd_pending', 'pdd_collecting', 'pdd_collection_failed', 'color_mapping', 'purchase_ready'])
|
||||||
|
return participating.has(this.purchaseReady(row).processStage)
|
||||||
|
},
|
||||||
|
isActivelyReturnMatched(row) { const stage = this.purchaseReady(row).processStage; return stage === 'return_pending' || stage === 'return_used' },
|
||||||
// `[必须]` 可勾选不等于可采购。放宽的只有勾选:purchaseCandidates / collectionCandidates
|
// `[必须]` 可勾选不等于可采购。放宽的只有勾选:purchaseCandidates / collectionCandidates
|
||||||
// / aiMatchCandidates 仍各自用自己的判据过滤,采购门禁不受影响。
|
// / aiMatchCandidates / returnMatch 仍各自用自己的判据过滤,采购门禁不受影响。
|
||||||
isSelectableCandidate(row) { return this.isPurchaseCandidate(row) || this.isCollectionCandidate(row) || this.isAIMatchCandidate(row) || this.isImageSearchCandidate(row) },
|
isSelectableCandidate(row) { return this.isPurchaseCandidate(row) || this.isCollectionCandidate(row) || this.isAIMatchCandidate(row) || this.isImageSearchCandidate(row) || this.isReturnMatchCandidate(row) },
|
||||||
rowSelectable(row) { return this.canPurchase && !this.purchaseReadinessLoading && this.isSelectableCandidate(row) },
|
rowSelectable(row) { return this.canPurchase && !this.purchaseReadinessLoading && this.isSelectableCandidate(row) },
|
||||||
purchaseReady(row) { return this.purchaseReadiness[row.id] || { sybProductId: row.id, eligible: false, reason: this.purchaseReadinessLoading ? '正在检查' : '请刷新后重试' } },
|
purchaseReady(row) { return this.purchaseReadiness[row.id] || { sybProductId: row.id, eligible: false, reason: this.purchaseReadinessLoading ? '正在检查' : '请刷新后重试' } },
|
||||||
purchasePriceText(item) { if (item.minUnitPriceCent === undefined || item.maxUnitPriceCent === undefined) return ''; return `允许单价 ¥${(item.minUnitPriceCent / 100).toFixed(2)}~¥${(item.maxUnitPriceCent / 100).toFixed(2)}` },
|
purchasePriceText(item) { if (item.minUnitPriceCent === undefined || item.maxUnitPriceCent === undefined) return ''; return `允许单价 ¥${(item.minUnitPriceCent / 100).toFixed(2)}~¥${(item.maxUnitPriceCent / 100).toFixed(2)}` },
|
||||||
processMeta(stage) { return { manual_action: { label: '待人工处理', type: 'warning' }, pdd_unlinked: { label: '未关联 PDD', type: 'info' }, pdd_pending: { label: 'PDD 待采集', type: 'info' }, pdd_collecting: { label: 'PDD 采集中', type: 'primary' }, pdd_collection_failed: { label: 'PDD 采集失败', type: 'danger' }, color_mapping: { label: '规格待匹配', type: 'warning' }, purchase_ready: { label: '可创建采购', type: 'success' }, task_created: { label: '已创建任务', type: 'primary' }, purchase_succeeded: { label: '采购成功', type: 'success' }, order_review: { label: '待人工核对', type: 'danger' }}[stage] || { label: '待人工处理', type: 'warning' } },
|
processMeta(stage) { return { manual_action: { label: '待人工处理', type: 'warning' }, pdd_unlinked: { label: '未关联 PDD', type: 'info' }, pdd_pending: { label: 'PDD 待采集', type: 'info' }, pdd_collecting: { label: 'PDD 采集中', type: 'primary' }, pdd_collection_failed: { label: 'PDD 采集失败', type: 'danger' }, color_mapping: { label: '规格待匹配', type: 'warning' }, purchase_ready: { label: '可创建采购', type: 'success' }, task_created: { label: '已创建任务', type: 'primary' }, purchase_succeeded: { label: '采购成功', type: 'success' }, order_review: { label: '待人工核对', type: 'danger' }, return_pending: { label: '退货待确认', type: 'warning' }, return_used: { label: '已用退货', type: 'info' }}[stage] || { label: '待人工处理', type: 'warning' } },
|
||||||
purchaseActionLabel(item) { return { open_pdd_link: '去关联', open_mapping: '去匹配', open_shopee: '查看蝦皮商品', open_pdd: '查看 PDD 商品', open_task: '查看任务', reparse: '查看并处理', select_device: '重新选择设备', refresh: '刷新' }[item.nextAction] || '' },
|
purchaseActionLabel(item) { return { open_pdd_link: '去关联', open_mapping: '去匹配', open_shopee: '查看蝦皮商品', open_pdd: '查看 PDD 商品', open_task: '查看任务', reparse: '查看并处理', select_device: '重新选择设备', refresh: '刷新' }[item.nextAction] || '' },
|
||||||
|
// #339: the page can now show up to 500 rows, but the preview endpoint
|
||||||
|
// (previewPurchaseTasks -> purchase/batch.go maxBatchPurchaseItems) still
|
||||||
|
// caps a single request at 100 ids, so this splits the current page into
|
||||||
|
// sequential chunks of <=100 and merges their results. Any chunk failing
|
||||||
|
// falls back to the same page-wide failure state the single-request path
|
||||||
|
// used before, so rows never end up in a partially-updated mix of real
|
||||||
|
// and failed readiness.
|
||||||
async loadPurchaseReadiness(ids, requestOptions = {}, generation = this.loadGeneration, selectedIDsOverride = null) {
|
async loadPurchaseReadiness(ids, requestOptions = {}, generation = this.loadGeneration, selectedIDsOverride = null) {
|
||||||
if (generation !== this.loadGeneration) return
|
if (generation !== this.loadGeneration) return
|
||||||
const selectedIDs = selectedIDsOverride || new Set(this.selectedProducts.map(row => row.id))
|
const selectedIDs = selectedIDsOverride || new Set(this.selectedProducts.map(row => row.id))
|
||||||
this.purchaseReadiness = {}
|
this.purchaseReadiness = {}
|
||||||
if (!this.canPurchase || !ids.length) { this.purchaseReadinessLoading = false; return }
|
if (!this.canPurchase || !ids.length) { this.purchaseReadinessLoading = false; return }
|
||||||
this.purchaseReadinessLoading = true
|
this.purchaseReadinessLoading = true
|
||||||
|
const chunkSize = 100
|
||||||
|
const merged = {}
|
||||||
try {
|
try {
|
||||||
const r = await previewPurchaseTasks({ sybProductIds: ids }, { ...requestOptions, suppressErrorMessage: true })
|
for (let start = 0; start < ids.length; start += chunkSize) {
|
||||||
|
const chunk = ids.slice(start, start + chunkSize)
|
||||||
|
const r = await previewPurchaseTasks({ sybProductIds: chunk }, { ...requestOptions, suppressErrorMessage: true })
|
||||||
if (generation !== this.loadGeneration) return
|
if (generation !== this.loadGeneration) return
|
||||||
this.purchaseReadiness = Object.fromEntries(r.data.items.map(item => [item.sybProductId, item]))
|
for (const item of r.data.items) merged[item.sybProductId] = item
|
||||||
|
}
|
||||||
|
this.purchaseReadiness = merged
|
||||||
await this.$nextTick()
|
await this.$nextTick()
|
||||||
const valid = this.products.filter(row => selectedIDs.has(row.id) && this.isSelectableCandidate(row))
|
const valid = this.products.filter(row => selectedIDs.has(row.id) && this.isSelectableCandidate(row))
|
||||||
this.$refs.productTable?.clearSelection()
|
this.$refs.productTable?.clearSelection()
|
||||||
@@ -428,6 +579,109 @@ export default {
|
|||||||
},
|
},
|
||||||
formatConfidence(value) { return Number.isFinite(value) ? `${Math.round(value * 100)}%` : '—' },
|
formatConfidence(value) { return Number.isFinite(value) ? `${Math.round(value * 100)}%` : '—' },
|
||||||
|
|
||||||
|
// ---------------- 退货匹配 (#338) ----------------
|
||||||
|
formatMatchDeadline(value) { return value ? new Date(value).toLocaleString() : '—' },
|
||||||
|
// #338: an already-matched return whose destroy deadline has passed is only flagged, never auto-cancelled.
|
||||||
|
isDeadlinePassed(value) { return Boolean(value) && new Date(value).getTime() <= Date.now() },
|
||||||
|
matchStatusLabel(status) { return { matched: '待确认', confirmed: '已确认', cancelled: '已取消' }[status] || status },
|
||||||
|
async loadReturnMatches(ids, generation = this.loadGeneration) {
|
||||||
|
this.returnMatchByProductId = {}
|
||||||
|
if (!ids.length) return
|
||||||
|
this.returnMatchLoading = true
|
||||||
|
try {
|
||||||
|
const r = await listReturnMatches({ sybProductId: ids }, { suppressErrorMessage: true })
|
||||||
|
if (generation !== this.loadGeneration) return
|
||||||
|
const map = {}
|
||||||
|
for (const item of (r.data?.items || [])) {
|
||||||
|
if (item.status === 'cancelled') continue
|
||||||
|
if (!map[item.sybProductId]) map[item.sybProductId] = item
|
||||||
|
}
|
||||||
|
this.returnMatchByProductId = map
|
||||||
|
} catch {
|
||||||
|
// 展示失败不影响主列表;用户可重新查询重试。
|
||||||
|
} finally {
|
||||||
|
if (generation === this.loadGeneration) this.returnMatchLoading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
matchReasonMeta(code) {
|
||||||
|
return {
|
||||||
|
stage_ineligible: '阶段不参与',
|
||||||
|
no_candidate: '无候选',
|
||||||
|
conflict: '并发冲突'
|
||||||
|
}[code] || code
|
||||||
|
},
|
||||||
|
async runBatchMatchReturns() {
|
||||||
|
const ids = this.returnMatchCandidateIds
|
||||||
|
if (!ids.length || this.returnMatchBatchLoading) return
|
||||||
|
this.returnMatchBatchLoading = true
|
||||||
|
try {
|
||||||
|
const r = await batchMatchReturns({ sybProductIds: ids })
|
||||||
|
const items = r.data.items || []
|
||||||
|
this.returnMatchBatchResult = {
|
||||||
|
open: true, items,
|
||||||
|
matchedCount: r.data.matchedCount || 0,
|
||||||
|
noCandidateCount: items.filter(i => i.reasonCode === 'no_candidate').length,
|
||||||
|
stageIneligibleCount: items.filter(i => i.reasonCode === 'stage_ineligible').length,
|
||||||
|
conflictCount: items.filter(i => i.reasonCode === 'conflict').length
|
||||||
|
}
|
||||||
|
await this.load()
|
||||||
|
} finally {
|
||||||
|
this.returnMatchBatchLoading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async openMatchDetail(matchId, focusRemark = false) {
|
||||||
|
this.matchDetail = { open: true, loading: true, saving: false, id: matchId, data: null, remarkText: '' }
|
||||||
|
try {
|
||||||
|
const r = await getReturnMatch(matchId)
|
||||||
|
this.matchDetail.data = r.data
|
||||||
|
this.matchDetail.remarkText = r.data.match.remark || ''
|
||||||
|
} finally {
|
||||||
|
this.matchDetail.loading = false
|
||||||
|
}
|
||||||
|
if (focusRemark) this.$nextTick(() => this.$refs.matchRemarkInput?.focus())
|
||||||
|
},
|
||||||
|
async refreshMatchDetail() {
|
||||||
|
const r = await getReturnMatch(this.matchDetail.id)
|
||||||
|
this.matchDetail.data = r.data
|
||||||
|
this.matchDetail.remarkText = r.data.match.remark || ''
|
||||||
|
await this.load()
|
||||||
|
},
|
||||||
|
async saveMatchRemark() {
|
||||||
|
this.matchDetail.saving = true
|
||||||
|
try {
|
||||||
|
await remarkReturnMatch(this.matchDetail.id, { remark: this.matchDetail.remarkText })
|
||||||
|
ElMessage.success('已保存备注')
|
||||||
|
await this.refreshMatchDetail()
|
||||||
|
} finally {
|
||||||
|
this.matchDetail.saving = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async confirmMatchDetail() {
|
||||||
|
try {
|
||||||
|
await this.$confirm('确认后该 SYB 商品状态变为「已用退货」,仍不能创建采购任务。确定继续?', '确认匹配', { type: 'warning' })
|
||||||
|
} catch { return }
|
||||||
|
await confirmReturnMatch(this.matchDetail.id)
|
||||||
|
ElMessage.success('已确认匹配')
|
||||||
|
await this.refreshMatchDetail()
|
||||||
|
},
|
||||||
|
async cancelMatchDetail() {
|
||||||
|
try {
|
||||||
|
await this.$confirm('取消后该 SYB 商品恢复可创建采购,对应退货商品回到可用池。确定取消匹配?', '取消匹配', { type: 'warning', confirmButtonClass: 'el-button--danger' })
|
||||||
|
} catch { return }
|
||||||
|
await cancelReturnMatch(this.matchDetail.id)
|
||||||
|
ElMessage.success('已取消匹配')
|
||||||
|
this.matchDetail.open = false
|
||||||
|
await this.load()
|
||||||
|
},
|
||||||
|
async quickCancelMatch(matchId) {
|
||||||
|
try {
|
||||||
|
await this.$confirm('取消后该 SYB 商品恢复可创建采购,对应退货商品回到可用池。确定取消匹配?', '取消匹配', { type: 'warning', confirmButtonClass: 'el-button--danger' })
|
||||||
|
} catch { return }
|
||||||
|
await cancelReturnMatch(matchId)
|
||||||
|
ElMessage.success('已取消匹配')
|
||||||
|
await this.load()
|
||||||
|
},
|
||||||
|
|
||||||
// ---------------- 批量创建采购任务 ----------------
|
// ---------------- 批量创建采购任务 ----------------
|
||||||
async openSinglePurchase(row) {
|
async openSinglePurchase(row) {
|
||||||
this.$refs.productTable?.clearSelection()
|
this.$refs.productTable?.clearSelection()
|
||||||
@@ -608,7 +862,9 @@ export default {
|
|||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.page-card { min-height: calc(100vh - 124px); }
|
.page-card { min-height: calc(100vh - 124px); }
|
||||||
.page-heading { display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:16px;h1{margin:0 0 6px;font-size:24px;color:#1f2937}p{margin:0;color:#606266;line-height:1.5} }
|
.page-heading { display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:16px;h1{margin:0 0 6px;font-size:24px;color:#1f2937}p{margin:0;color:#606266;line-height:1.5} }
|
||||||
.search-form{display:flex;flex-wrap:nowrap;align-items:center;column-gap:8px;padding:12px 16px 0;margin-bottom:16px;border:1px solid #e5e7eb;border-radius:8px;background:#f8fafc}
|
.search-form{display:flex;flex-direction:column;align-items:stretch;gap:0;padding:12px 16px 0;margin-bottom:16px;border:1px solid #e5e7eb;border-radius:8px;background:#f8fafc}
|
||||||
|
.search-row{display:flex;flex-wrap:wrap;align-items:center;column-gap:8px}
|
||||||
|
.search-row-secondary{justify-content:flex-start}
|
||||||
.search-form :deep(.el-form-item){margin-right:0;margin-bottom:12px}
|
.search-form :deep(.el-form-item){margin-right:0;margin-bottom:12px}
|
||||||
.search-form :deep(.el-form-item__content){flex-wrap:nowrap}
|
.search-form :deep(.el-form-item__content){flex-wrap:nowrap}
|
||||||
.shop-input{width:130px}.order-codes-input{width:210px}.unlinked-tag{margin-left:8px}
|
.shop-input{width:130px}.order-codes-input{width:210px}.unlinked-tag{margin-left:8px}
|
||||||
@@ -625,9 +881,11 @@ export default {
|
|||||||
.missing{color:#b91c1c}
|
.missing{color:#b91c1c}
|
||||||
.quick-link-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:4px 0}
|
.quick-link-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:4px 0}
|
||||||
.thumb{width:44px;height:44px;border-radius:4px;object-fit:cover}.thumb.placeholder{display:flex;align-items:center;justify-content:center;background:#f1f5f9;color:#909399;font-size:11px}
|
.thumb{width:44px;height:44px;border-radius:4px;object-fit:cover}.thumb.placeholder{display:flex;align-items:center;justify-content:center;background:#f1f5f9;color:#909399;font-size:11px}
|
||||||
|
.return-match-cell{display:flex;gap:8px;align-items:flex-start}
|
||||||
.link{color:#1677ff;cursor:pointer}
|
.link{color:#1677ff;cursor:pointer}
|
||||||
.shopee-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
.shopee-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
.notice{margin-bottom:16px}
|
.notice{margin-bottom:16px}
|
||||||
|
.return-missing-alert{margin:6px 0}
|
||||||
.compact-notice{margin-bottom:12px}
|
.compact-notice{margin-bottom:12px}
|
||||||
.purchase-summary{display:flex;flex-wrap:wrap;gap:12px 24px;margin-bottom:16px;padding:12px 16px;border:1px solid #dbeafe;border-radius:8px;background:#f8fafc}.purchase-summary strong{font-variant-numeric:tabular-nums;color:#1e40af}.success-text{color:#166534}.warning-text{color:#b45309}.danger-text,.purchase-reason{color:#b91c1c}.purchase-reason{margin-top:4px;font-size:12px;line-height:1.45}.purchase-settings{margin-bottom:12px}.field-help{margin-left:12px;color:#909399;font-size:12px}.ellipsis{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.image-search-tag{margin-left:6px;vertical-align:middle}
|
.purchase-summary{display:flex;flex-wrap:wrap;gap:12px 24px;margin-bottom:16px;padding:12px 16px;border:1px solid #dbeafe;border-radius:8px;background:#f8fafc}.purchase-summary strong{font-variant-numeric:tabular-nums;color:#1e40af}.success-text{color:#166534}.warning-text{color:#b45309}.danger-text,.purchase-reason{color:#b91c1c}.purchase-reason{margin-top:4px;font-size:12px;line-height:1.45}.purchase-settings{margin-bottom:12px}.field-help{margin-left:12px;color:#909399;font-size:12px}.ellipsis{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.image-search-tag{margin-left:6px;vertical-align:middle}
|
||||||
.spec-match-summary{gap:8px 24px}
|
.spec-match-summary{gap:8px 24px}
|
||||||
@@ -638,7 +896,6 @@ export default {
|
|||||||
.section-title{margin:0 0 12px}
|
.section-title{margin:0 0 12px}
|
||||||
.drawer-actions{display:flex;gap:12px;margin-top:16px}
|
.drawer-actions{display:flex;gap:12px;margin-top:16px}
|
||||||
.raw-json{max-height:520px;overflow:auto;padding:12px;background:#0f172a;color:#e2e8f0;border-radius:6px;font-size:12px;line-height:1.5;white-space:pre-wrap;word-break:break-all}
|
.raw-json{max-height:520px;overflow:auto;padding:12px;background:#0f172a;color:#e2e8f0;border-radius:6px;font-size:12px;line-height:1.5;white-space:pre-wrap;word-break:break-all}
|
||||||
@media(max-width:1280px){.search-form{flex-wrap:wrap}}
|
@media(max-width:1280px){.search-row-primary{align-items:flex-start}.search-row-primary .toolbar-actions{margin-left:auto}}
|
||||||
@media(max-width:900px){.split{grid-template-columns:1fr}}
|
@media(max-width:900px){.split{grid-template-columns:1fr}}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
canStartSync() { return (this.$store.getters.roles || []).some(role => role === 'admin' || role === 'purchaser') },
|
canStartSync() { return (this.$store.getters.roles || []).some(role => role === 'admin' || role === 'purchaser' || role === 'after_sales') },
|
||||||
hasRunningSync() { return this.items.some(item => item.status === 'running') }
|
hasRunningSync() { return this.items.some(item => item.status === 'running') }
|
||||||
},
|
},
|
||||||
created() { this.load().then(() => { const id = Number(this.$route.query.runId); if (id > 0) this.openDetail(id) }) },
|
created() { this.load().then(() => { const id = Number(this.$route.query.runId); if (id > 0) this.openDetail(id) }) },
|
||||||
|
|||||||
@@ -0,0 +1,284 @@
|
|||||||
|
<template>
|
||||||
|
<BasicLayout>
|
||||||
|
<template #wrapper>
|
||||||
|
<el-card class="page-card" shadow="never">
|
||||||
|
<el-alert v-if="summaryError" :title="summaryError" type="warning" show-icon :closable="false" class="notice" />
|
||||||
|
<div v-else class="summary-bar" aria-live="polite">
|
||||||
|
<span>最后成功同步:<strong>{{ formatTime(summary.lastSuccessAt) }}</strong></span>
|
||||||
|
<span v-if="summary.lastRun">读取 {{ summary.lastRun.readCount }} / 新增 {{ summary.lastRun.createdCount }} / 更新 {{ summary.lastRun.updatedCount }} / 跳过 {{ summary.lastRun.skippedCount }} / 失败 {{ summary.lastRun.failedCount }}</span>
|
||||||
|
<span v-if="summary.lastRun"><el-tag :type="statusMeta(summary.lastRun.status).type" size="small">{{ statusMeta(summary.lastRun.status).label }}</el-tag></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-form :model="query" :inline="true" class="search-form" @submit.prevent="search">
|
||||||
|
<el-form-item label="订单号"><el-input v-model="query.orderSn" clearable placeholder="订单号" style="width:150px" /></el-form-item>
|
||||||
|
<el-form-item label="店铺">
|
||||||
|
<el-select v-model="query.shop" clearable filterable placeholder="全部店铺" style="width:150px">
|
||||||
|
<el-option v-for="name in shopOptions" :key="name" :label="name" :value="name" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="认领状态">
|
||||||
|
<el-select v-model="query.claimStatus" clearable placeholder="全部" style="width:130px">
|
||||||
|
<el-option v-for="item in claimStatusOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="创建时间"><el-date-picker v-model="createTimeRange" type="daterange" value-format="YYYY-MM-DD" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" style="width:230px" /></el-form-item>
|
||||||
|
<el-form-item label="匹配状态">
|
||||||
|
<el-select v-model="query.matchStatus" clearable placeholder="全部" style="width:130px">
|
||||||
|
<el-option v-for="item in matchStatusOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button></el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<el-alert v-if="loadError" :title="loadError" type="error" show-icon :closable="false" class="notice"><template #default><el-button link type="primary" @click="load">重新加载</el-button></template></el-alert>
|
||||||
|
<el-table v-loading="loading" :data="items" border stripe row-key="key" empty-text="暂无退货商品">
|
||||||
|
<el-table-column label="订单号" prop="orderSn" min-width="130" />
|
||||||
|
<el-table-column label="商品参考图" width="76">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-image v-if="row.hasItem && row.image" :src="row.image" fit="cover" class="thumb" :preview-src-list="[row.image]" preview-teleported />
|
||||||
|
<div v-else-if="row.hasItem" class="thumb placeholder">无图</div>
|
||||||
|
<span v-else>—</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="商品标题" min-width="220">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<template v-if="row.hasItem">{{ row.itemName || '—' }}</template>
|
||||||
|
<span v-else class="muted">(无商品明细)</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="蝦皮id / sku_id" min-width="140">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<template v-if="row.hasItem">
|
||||||
|
{{ row.shopeeItemId || '—' }}
|
||||||
|
<div class="muted">{{ row.variationId || '—' }}</div>
|
||||||
|
</template>
|
||||||
|
<span v-else>—</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="数量" width="70">
|
||||||
|
<template #default="{ row }">{{ row.hasItem ? row.quantity : '—' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="店铺" min-width="120"><template #default="{ row }">{{ row.shopName || '—' }}</template></el-table-column>
|
||||||
|
<el-table-column label="规格数据" min-width="180"><template #default="{ row }">{{ row.variationName || '—' }}</template></el-table-column>
|
||||||
|
<el-table-column label="认领状态" min-width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-if="row.statusUnrecognized" type="danger" effect="plain">未知:{{ row.claimStatus || '—' }}</el-tag>
|
||||||
|
<el-tag v-else :type="claimStatusMeta(row.claimStatus).type">{{ claimStatusMeta(row.claimStatus).label }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="yeeke 可用性" min-width="150">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-if="row.syncStatus === 'missing'" type="danger" effect="plain">不可用(yeeke 列表中已不存在)</el-tag>
|
||||||
|
<span v-else class="muted">—</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="上架时间" min-width="150"><template #default="{ row }">{{ formatTime(row.claimTime) }}</template></el-table-column>
|
||||||
|
<el-table-column label="销毁截止" min-width="150"><template #default="{ row }">{{ formatTime(row.destroyDeadLine) }}</template></el-table-column>
|
||||||
|
<el-table-column label="最近同步" min-width="150"><template #default="{ row }">{{ formatTime(row.lastSyncedAt) }}</template></el-table-column>
|
||||||
|
<el-table-column label="匹配状态" min-width="100">
|
||||||
|
<template #default="{ row }"><el-tag :type="matchStatusMeta(row.matchStatus).type" effect="plain">{{ matchStatusMeta(row.matchStatus).label }}</el-tag></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="被占用 SYB 订单商品" min-width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<template v-if="row.occupyingSybProductId">
|
||||||
|
<a class="link" href="javascript:void(0)" @click="openSybProduct(row.occupyingSybProductId)">{{ row.occupyingSybOrderCode }} ↗</a>
|
||||||
|
<div class="muted">{{ row.occupyingSybStageLabel }}</div>
|
||||||
|
<div v-if="isDeadlinePassed(row.destroyDeadLine)" class="deadline-passed">已过销毁截止</div>
|
||||||
|
</template>
|
||||||
|
<span v-else class="muted">—</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="90" fixed="right"><template #default="{ row }"><el-button type="primary" link @click="openDetail(row)">详情</el-button></template></el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<pagination v-show="total > 0" v-model:current-page="query.page" v-model:page-size="query.pageSize" :total="total" @pagination="load" />
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-drawer v-model="detail.open" title="退货商品详情" size="720px">
|
||||||
|
<div v-loading="detail.loading" class="drawer-body">
|
||||||
|
<template v-if="detail.placeholder && detail.pkg">
|
||||||
|
<el-alert title="该包裹无商品明细" type="info" show-icon :closable="false" class="notice" />
|
||||||
|
<h3 class="section-title">所属退货包裹</h3>
|
||||||
|
<el-descriptions :column="2" border size="small">
|
||||||
|
<el-descriptions-item label="订单号">{{ detail.pkg.orderSn }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="店铺">{{ detail.pkg.shopName || detail.pkg.shopId || '—' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="仓库/库位">{{ detail.pkg.wareHouse || detail.pkg.wareCode || '—' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="认领状态">
|
||||||
|
<el-tag v-if="detail.pkg.statusUnrecognized" type="danger" effect="plain">未知:{{ detail.pkg.claimStatus || '—' }}</el-tag>
|
||||||
|
<el-tag v-else :type="claimStatusMeta(detail.pkg.claimStatus).type">{{ claimStatusMeta(detail.pkg.claimStatus).label }}</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="认领时间">{{ formatTime(detail.pkg.claimTime) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="创建时间">{{ formatTime(detail.pkg.createTime) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="更新时间">{{ formatTime(detail.pkg.updateTime) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="销毁截止">{{ formatTime(detail.pkg.destroyDeadLine) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="最近同步">{{ formatTime(detail.pkg.lastSyncedAt) }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="detail.item">
|
||||||
|
<div class="item-hero">
|
||||||
|
<el-image v-if="detail.item.image" :src="detail.item.image" fit="cover" class="thumb-lg" :preview-src-list="[detail.item.image]" preview-teleported />
|
||||||
|
<div v-else class="thumb-lg placeholder">无图</div>
|
||||||
|
<div class="item-hero-info">
|
||||||
|
<div class="item-title">{{ detail.item.itemName || '—' }}</div>
|
||||||
|
<div class="muted">{{ detail.item.variationName || '—' }}</div>
|
||||||
|
<div class="muted">商品ID:{{ detail.item.itemId || '—' }} / 规格ID:{{ detail.item.variationId || '—' }}</div>
|
||||||
|
<div class="muted">退货数量:{{ detail.item.quantity }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h3 class="section-title">所属退货包裹</h3>
|
||||||
|
<el-descriptions v-if="detail.pkg" :column="2" border size="small">
|
||||||
|
<el-descriptions-item label="订单号">{{ detail.pkg.orderSn }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="店铺">{{ detail.pkg.shopName || detail.pkg.shopId || '—' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="仓库/库位">{{ detail.pkg.wareHouse || detail.pkg.wareCode || '—' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="认领状态">
|
||||||
|
<el-tag v-if="detail.pkg.statusUnrecognized" type="danger" effect="plain">未知:{{ detail.pkg.claimStatus || '—' }}</el-tag>
|
||||||
|
<el-tag v-else :type="claimStatusMeta(detail.pkg.claimStatus).type">{{ claimStatusMeta(detail.pkg.claimStatus).label }}</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="认领时间">{{ formatTime(detail.pkg.claimTime) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="创建时间">{{ formatTime(detail.pkg.createTime) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="更新时间">{{ formatTime(detail.pkg.updateTime) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="销毁截止">{{ formatTime(detail.pkg.destroyDeadLine) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="最近同步">{{ formatTime(detail.pkg.lastSyncedAt) }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
<h3 class="section-title">同包裹其他商品</h3>
|
||||||
|
<el-table :data="detail.siblings" border size="small" empty-text="无其他商品">
|
||||||
|
<el-table-column label="标题/规格" min-width="220"><template #default="{ row }">{{ row.itemName || '—' }}<div class="muted">{{ row.variationName || '—' }}</div></template></el-table-column>
|
||||||
|
<el-table-column label="数量" prop="quantity" width="80" />
|
||||||
|
</el-table>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</el-drawer>
|
||||||
|
</template>
|
||||||
|
</BasicLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { Search, RefreshLeft } from '@element-plus/icons-vue'
|
||||||
|
import { listYeekeReturnPackages, getYeekeReturnPackage, getYeekeReturnItem, listYeekeReturnShops, listYeekeSyncRuns } from '@/api/goauto/yeeke-returns'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'GoAutoYeekeReturns',
|
||||||
|
setup() { return { Search, RefreshLeft } },
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
loading: false, loadError: '', items: [], total: 0, createTimeRange: null,
|
||||||
|
summary: { lastSuccessAt: '', lastRun: null }, summaryError: '',
|
||||||
|
shopOptions: [],
|
||||||
|
query: { page: 1, pageSize: 20, orderSn: '', shop: '', claimStatus: '', createTimeFrom: '', createTimeTo: '', matchStatus: '' },
|
||||||
|
detail: { open: false, loading: false, placeholder: false, item: null, pkg: null, siblings: [] },
|
||||||
|
// #338: 匹配状态是只读信息筛选,未勾选/无写操作,按钮门禁规则里
|
||||||
|
// 「纯只读筛选可以照常展示」的部分——不需要 canPurchase 判断。
|
||||||
|
matchStatusOptions: [
|
||||||
|
{ label: '未匹配', value: 'unmatched' },
|
||||||
|
{ 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.
|
||||||
|
claimStatusOptions: [
|
||||||
|
{ label: '已认领', value: '1' },
|
||||||
|
{ 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' } },
|
||||||
|
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' } },
|
||||||
|
formatTime(value) { if (!value) return '—'; return new Date(value).toLocaleString('zh-CN', { hour12: false }) },
|
||||||
|
// #338: a matched return past its destroy deadline is flagged only, never auto-released.
|
||||||
|
isDeadlinePassed(value) { return Boolean(value) && new Date(value).getTime() <= Date.now() },
|
||||||
|
async load() {
|
||||||
|
this.loading = true; this.loadError = ''
|
||||||
|
try {
|
||||||
|
const params = { ...this.query }
|
||||||
|
const r = await listYeekeReturnPackages(params, { suppressNetworkError: true })
|
||||||
|
this.items = r.data.items
|
||||||
|
this.total = r.data.total
|
||||||
|
} catch (error) {
|
||||||
|
this.loadError = error?.response?.data?.message || error?.message || '退货商品加载失败'
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async loadShops() {
|
||||||
|
try {
|
||||||
|
const r = await listYeekeReturnShops({ suppressNetworkError: true, suppressErrorMessage: true })
|
||||||
|
this.shopOptions = r.data.shops || []
|
||||||
|
} catch (error) {
|
||||||
|
// 店铺下拉加载失败时不阻塞列表,筛选项保持为空。
|
||||||
|
this.shopOptions = []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async loadSummary() {
|
||||||
|
this.summaryError = ''
|
||||||
|
try {
|
||||||
|
const r = await listYeekeSyncRuns({ page: 1, pageSize: 1 }, { suppressNetworkError: true, suppressErrorMessage: true })
|
||||||
|
this.summary = { lastSuccessAt: r.data.lastSuccessAt, lastRun: r.data.items[0] || null }
|
||||||
|
} catch (error) {
|
||||||
|
// 同步失败时仍显示上次已加载的商品列表,只在摘要条提示。
|
||||||
|
this.summaryError = '同步状态摘要加载失败,仍显示已有退货商品数据'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
search() {
|
||||||
|
this.query.page = 1
|
||||||
|
const [from, to] = this.createTimeRange || ['', '']
|
||||||
|
this.query.createTimeFrom = from
|
||||||
|
this.query.createTimeTo = to
|
||||||
|
this.load()
|
||||||
|
},
|
||||||
|
reset() {
|
||||||
|
this.createTimeRange = null
|
||||||
|
this.query = { page: 1, pageSize: 20, orderSn: '', shop: '', claimStatus: '', createTimeFrom: '', createTimeTo: '', matchStatus: '' }
|
||||||
|
this.load()
|
||||||
|
},
|
||||||
|
async openDetail(row) {
|
||||||
|
this.detail = { open: true, loading: true, placeholder: !row.hasItem, item: null, pkg: null, siblings: [] }
|
||||||
|
try {
|
||||||
|
if (!row.hasItem) {
|
||||||
|
const r = await getYeekeReturnPackage(row.packageId)
|
||||||
|
this.detail.pkg = r.data.item
|
||||||
|
} else {
|
||||||
|
const r = await getYeekeReturnItem(row.itemId)
|
||||||
|
this.detail.item = r.data.item
|
||||||
|
this.detail.pkg = r.data.package
|
||||||
|
this.detail.siblings = r.data.siblings || []
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.detail.loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.page-card{min-height:calc(100vh - 124px)}
|
||||||
|
.page-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:16px}
|
||||||
|
.page-heading h1{margin:0 0 6px;font-size:24px;color:#1f2937}
|
||||||
|
.page-heading p{margin:0;color:#606266;line-height:1.5}
|
||||||
|
.heading-actions{display:flex;gap:8px;flex-shrink:0}
|
||||||
|
.summary-bar{display:flex;flex-wrap:wrap;gap:12px 24px;margin-bottom:16px;padding:10px 16px;border:1px solid #dbeafe;border-radius:8px;background:#f8fafc;color:#334155}
|
||||||
|
.search-form{padding:16px 16px 0;margin-bottom:16px;border:1px solid #e5e7eb;border-radius:8px;background:#f8fafc}
|
||||||
|
.notice{margin-bottom:16px}
|
||||||
|
.drawer-body{padding:0 4px 24px}
|
||||||
|
.section-title{margin:24px 0 12px}
|
||||||
|
.thumb{width:44px;height:44px;border-radius:4px;object-fit:cover}
|
||||||
|
.thumb.placeholder{display:flex;align-items:center;justify-content:center;background:#f1f5f9;color:#909399;font-size:11px}
|
||||||
|
.thumb-lg{width:96px;height:96px;border-radius:6px;object-fit:cover;flex-shrink:0}
|
||||||
|
.thumb-lg.placeholder{display:flex;align-items:center;justify-content:center;background:#f1f5f9;color:#909399;font-size:12px}
|
||||||
|
.item-hero{display:flex;gap:16px}
|
||||||
|
.item-hero-info{flex:1;min-width:0}
|
||||||
|
.item-title{font-size:15px;font-weight:600;color:#1f2937;margin-bottom:4px}
|
||||||
|
.muted{font-size:12px;color:#909399}
|
||||||
|
@media(max-width:800px){.page-heading{flex-direction:column}}
|
||||||
|
.deadline-passed{color:var(--el-color-danger);font-size:12px}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
<template>
|
||||||
|
<BasicLayout>
|
||||||
|
<template #wrapper>
|
||||||
|
<el-card class="page-card" shadow="never">
|
||||||
|
<el-form :model="query" :inline="true" class="search-form" @submit.prevent="search">
|
||||||
|
<el-form-item label="触发方式">
|
||||||
|
<el-select v-model="query.trigger" clearable placeholder="全部" style="width:120px">
|
||||||
|
<el-option label="手动" value="manual" />
|
||||||
|
<el-option label="定时" value="scheduled" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="状态">
|
||||||
|
<el-select v-model="query.status" clearable placeholder="全部" style="width:140px">
|
||||||
|
<el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="开始时间"><el-date-picker v-model="startedRange" type="daterange" value-format="YYYY-MM-DD" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" /></el-form-item>
|
||||||
|
<el-form-item><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button></el-form-item>
|
||||||
|
<el-form-item class="sync-action">
|
||||||
|
<el-button
|
||||||
|
v-if="canSync"
|
||||||
|
type="primary"
|
||||||
|
:loading="manualSyncStarting"
|
||||||
|
:disabled="manualSyncStarting"
|
||||||
|
@click="startManualSync"
|
||||||
|
>手动同步</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<el-alert v-if="loadError" :title="loadError" type="error" show-icon :closable="false" class="notice"><template #default><el-button link type="primary" @click="load">重新加载</el-button></template></el-alert>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<el-table v-loading="loading" :data="items" border stripe height="100%" empty-text="暂无同步记录">
|
||||||
|
<el-table-column label="运行ID" prop="id" width="90" />
|
||||||
|
<el-table-column label="触发方式" width="100"><template #default="{ row }">{{ triggerLabel(row.trigger) }}</template></el-table-column>
|
||||||
|
<el-table-column label="状态" width="110"><template #default="{ row }"><el-tag :type="statusMeta(row.status).type">{{ statusMeta(row.status).label }}</el-tag></template></el-table-column>
|
||||||
|
<el-table-column label="开始时间" min-width="160"><template #default="{ row }">{{ formatTime(row.startedAt) }}</template></el-table-column>
|
||||||
|
<el-table-column label="结束时间" min-width="160"><template #default="{ row }">{{ formatTime(row.finishedAt) }}</template></el-table-column>
|
||||||
|
<el-table-column label="页数" prop="totalPages" width="70" />
|
||||||
|
<el-table-column label="读取" prop="readCount" width="70" />
|
||||||
|
<el-table-column label="新增" prop="createdCount" width="70" />
|
||||||
|
<el-table-column label="更新" prop="updatedCount" width="70" />
|
||||||
|
<el-table-column label="跳过" prop="skippedCount" width="70" />
|
||||||
|
<el-table-column label="失败" prop="failedCount" width="70" />
|
||||||
|
<el-table-column label="标记不可用" prop="missingMarkedCount" width="96" />
|
||||||
|
<el-table-column label="恢复可用" prop="recoveredCount" width="84" />
|
||||||
|
<el-table-column label="脱敏原因" min-width="200"><template #default="{ row }">{{ row.errorMessage || '—' }}</template></el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
<pagination v-show="total > 0" v-model:current-page="query.page" v-model:page-size="query.pageSize" :total="total" @pagination="load" />
|
||||||
|
</el-card>
|
||||||
|
</template>
|
||||||
|
</BasicLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { Search, RefreshLeft } from '@element-plus/icons-vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { listYeekeSyncRuns, triggerYeekeSync } from '@/api/goauto/yeeke-returns'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'GoAutoYeekeSyncRuns',
|
||||||
|
setup() { return { Search, RefreshLeft } },
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
loading: false, loadError: '', items: [], total: 0, startedRange: null, manualSyncStarting: false,
|
||||||
|
query: { page: 1, pageSize: 20, trigger: '', status: '', startedFrom: '', startedTo: '' },
|
||||||
|
statusOptions: [
|
||||||
|
{ label: '执行中', value: 'running' }, { label: '成功', value: 'succeeded' },
|
||||||
|
{ label: '失败', value: 'failed' }, { label: '已中断', value: 'interrupted' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
canSync() { return (this.$store.getters.roles || []).some(role => role === 'admin' || role === 'purchaser' || role === 'after_sales') },
|
||||||
|
hasRunningSync() { return this.items.some(item => item.status === 'running') }
|
||||||
|
},
|
||||||
|
created() { this.load() },
|
||||||
|
methods: {
|
||||||
|
triggerLabel(trigger) { return { manual: '手动', scheduled: '定时' }[trigger] || trigger || '—' },
|
||||||
|
statusMeta(status) { return { running: { label: '执行中', type: 'primary' }, succeeded: { label: '成功', type: 'success' }, failed: { label: '失败', type: 'danger' }, interrupted: { label: '已中断', type: 'warning' }}[status] || { label: status || '-', type: 'info' } },
|
||||||
|
formatTime(value) { if (!value) return '—'; return new Date(value).toLocaleString('zh-CN', { hour12: false }) },
|
||||||
|
async load() {
|
||||||
|
this.loading = true; this.loadError = ''
|
||||||
|
try {
|
||||||
|
const r = await listYeekeSyncRuns(this.query, { suppressNetworkError: true })
|
||||||
|
this.items = r.data.items
|
||||||
|
this.total = r.data.total
|
||||||
|
} catch (error) {
|
||||||
|
this.loadError = error?.response?.data?.message || error?.message || '同步记录加载失败'
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
search() {
|
||||||
|
this.query.page = 1
|
||||||
|
const [from, to] = this.startedRange || ['', '']
|
||||||
|
this.query.startedFrom = from
|
||||||
|
this.query.startedTo = to
|
||||||
|
this.load()
|
||||||
|
},
|
||||||
|
reset() {
|
||||||
|
this.startedRange = null
|
||||||
|
this.query = { page: 1, pageSize: 20, trigger: '', status: '', startedFrom: '', startedTo: '' }
|
||||||
|
this.load()
|
||||||
|
},
|
||||||
|
async startManualSync() {
|
||||||
|
if (!this.canSync || this.manualSyncStarting || this.hasRunningSync) return
|
||||||
|
this.manualSyncStarting = true
|
||||||
|
try {
|
||||||
|
await triggerYeekeSync()
|
||||||
|
ElMessage.success('已开始同步')
|
||||||
|
await this.load()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error?.response?.data?.message || error?.message || '同步启动失败')
|
||||||
|
} finally {
|
||||||
|
this.manualSyncStarting = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
// The card fills the viewport and the table takes all remaining height, so
|
||||||
|
// the pagination sits at the bottom instead of floating mid-page.
|
||||||
|
.page-card{height:calc(100vh - 124px);display:flex;flex-direction:column}
|
||||||
|
.page-card :deep(.el-card__body){flex:1;min-height:0;display:flex;flex-direction:column}
|
||||||
|
.search-form{display:flex;flex-wrap:wrap;align-items:center;padding:16px 16px 0;margin-bottom:16px;border:1px solid #e5e7eb;border-radius:8px;background:#f8fafc}
|
||||||
|
.sync-action{margin-left:auto}
|
||||||
|
.notice{margin-bottom:16px}
|
||||||
|
.table-wrap{flex:1;min-height:240px}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user