diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md index 5828ced..6289c66 100644 --- a/docs/02-architecture-and-code-map.md +++ b/docs/02-architecture-and-code-map.md @@ -1,3 +1,19 @@ + +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: 9b4a1a06b8e2f6a8dfdbaef87817d840dfa45389 +synchronized_at: 2026-09-29T02:23:54Z + + + +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 + + generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Architecture-and-Code-Map @@ -520,3 +536,37 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态 - `automation/PurchaseRehearsalExecutor.kt`:`waitForSpecPanel` 对回退类型要求连续两次结构一致;`selectionFailureEvidence` 选中失败诊断。 - `automation/PurchaseLiveAutomation.kt`:`waitForSettledPanelAfterAddressSave` 保存地址后稳定等待(不按返回键);`finalSubmitTargets` 拒绝零尺寸文字/容器;新增构造参数 `panelDiagnostic`(仅布尔/计数)。 - 测试:`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` 路由,支持批量匹配、列表/详情、确认、取消和备注;相关表由版本化迁移创建,采购创建在服务端拦截有效退货匹配,售后角色可执行这些接口。 + + +## Yeeke 已重出同步与匹配边界(#345) + +- server/app/goauto/yeekeclient/client.go:List 保留默认待出货兼容调用;ListStatus 仅接受状态 1/2,复用 relation/list 请求及原过滤条件。 +- server/app/goauto/yeeke/sync.go:每轮顺序遍历两种状态,分页指纹各自独立;共享包裹键处理跨状态重复,状态 2 优先。TotalPages 汇总实际取得的两组页数。两组完成且无写入错误才执行既有 markMissing;不完整分页明确返回失败。 +- server/app/goauto/returnmatch/service.go:候选池过滤待出货、已识别与同步可用状态;匹配事务按包裹再明细顺序沿用行锁,并在插入前再次校验。状态变化的候选记为跳过,不撤销既有匹配。 +- web/src/views/goauto/yeeke-returns/index.vue:复用原状态筛选和标签组件,增加已重出,待出货使用明确业务名称;接口、数据库结构及权限不变。 +- 回归覆盖双状态请求、跨页/跨状态重复与身份保持、第二状态网络失败不标 missing、已重出候选排除和最终校验、Web 状态标签及重置。未调用真实 Yeeke,也未执行线上数据库或发布。 +## SYB 批量取消退货匹配(#347) + +- Web `syb-products/return-cancel.js` 与列表组件复用单条取消接口;冻结当前页勾选的 SYB 商品 ID 与原匹配 ID,一次确认后串行请求,独立汇总成功、跳过及失败。取消结果不会驱动已有采购或采集任务。 +- `returnmatch.Service.Cancel` 在锁定原匹配记录的事务中校验可选状态前提,再释放有效占用指针并写取消审计;事务内审计失败则整体回滚。无额外数据库迁移、权限、批量后台任务或外部写入。 +- 元数据加载失败时禁止批量取消;执行时互斥匹配、确认、取消。网络不确定时只读回原匹配,不自动重复写入;登录失效停止后续请求。列表刷新与失败项勾选恢复受查询 generation 约束,不跨页、不覆盖较新的筛选结果。 + +## SYB 当前页批量使用退货(#349) + +实现绑定 4280115(2026-09-29),feat/349-batch-return-use;以 #347 a4c7b5e 为基线,未合并 main、未发布或真实业务验收。 + +- Web syb-products/index.vue 新增“使用退货 N”,紧跟“取消匹配”;return-confirm.js 冻结当前页原 SYB / match 身份,去重后一次确认、串行复用既有 confirm API,结果独立汇总。return-cancel.js 和单条操作接入使用期间互斥,不重构取消逻辑。 +- return-matches.js 的 confirmReturnMatch 增加可选客户端请求选项,仅用于批量汇总时抑制逐项错误弹窗;无新增 HTTP 字段。Server 生产代码、确认锁内 matched 检查、ConfirmedBy/ConfirmedAt、审计和占用指针不变,无迁移或权限变化。 +- 未知写结果只读回原 match,回读确认不宣称为本次成功;登录失效停止余项。刷新保持筛选与页码,失败选择恢复受 generation、当前页及原 matched 身份约束。未调用 Yeeke 发货或修改采购任务。 +- 回归入口:Web tests/unit/syb-return-confirm.test.cjs、tests/e2e/syb-return-confirm.spec.ts;Server returnmatch/confirm_batch_test.go。测试使用合成数据和隔离 SQLite,不代表生产 MySQL 并发或真实退货验证。 diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index 1341571..c1c0505 100644 --- a/docs/03-business-rules-and-glossary.md +++ b/docs/03-business-rules-and-glossary.md @@ -1,3 +1,19 @@ + +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: 9a7317728132c450c7abf244c3e5eaac7bfb71bc +synchronized_at: 2026-09-29T02:23:57Z + + + +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 + + generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Business-Rules-and-Glossary @@ -680,3 +696,39 @@ SYB 回填与资料完整性独立:正式SYB任务已有单号且仍关联有 ### 采购手机环境前提 执行采购的 Android 手机必须:PDD App 已登录、微信已登录、PDD 已设置默认收货地址、PDD 默认支付方式为微信支付。规格面板的地址与支付证据依赖该前提;不为支付宝等其他支付方式补充识别别名(有意决定)。条件不满足时 Agent 明确失败,不自动登录、不切换支付方式、不填写地址。 + +## 管理端售后角色(#341) + +- 管理端固定增加 `after_sales`(显示名“售后”)业务角色。售后账号拥有采购员当前权限,并额外可访问“yeeke 退货包裹”和“yeeke 同步记录”两个模块及其接口。 +- 售后角色与账号由版本化迁移幂等维护;创建初始账号时由部署环境临时提供密码,迁移不输出或持久化明文密码。已存在账号若绑定其他角色则迁移失败并要求人工处理。 +- 本角色不获得管理员专属的用户、角色、设备凭据、规则配置或 AI Provider 管理权限,也不新增支付能力。 + + +## 退货匹配(#338) + +退货匹配只由人工勾选 SYB 商品后触发。按虾皮商品 ID 与归一化规格匹配,状态为“退货待确认”或“已用退货”时拦截新采购,取消后恢复可采购;已有采购任务不受影响。售后与采购员可操作匹配、确认、取消和备注。 + + +## Yeeke 双状态退货同步(#345) + +- 同一次同步顺序拉取待出货(status=1)和已重出(status=2);保持 claimFlag=1、relationFlag=1 和创建时间倒序。两种业务状态均是已识别状态,列表及详情分别展示并可按状态筛选。 +- 两组分页独立判重;仅两组均自然完整结束且无写入失败时,才沿用既有安全阀标记未见数据为 missing。重复页、页数上限、网络或写入错误均记录失败,已成功写入的页保留,不误报完整成功。 +- 沿用既有包裹和明细标识幂等更新;同一轮跨状态重复时已重出优先,不新增副本。 +- 新退货匹配仅使用状态为待出货、已识别且同步可用的包裹和明细;落库前再次校验。已重出不参与新匹配,不自动取消已有匹配。 +- 本次无新增字段、迁移、权限或自动采购动作;真实 Yeeke 数据及发布后页面效果仍需人工验收。 +## SYB 当前页批量取消匹配(#347) + +- 管理员、采购员、售后可在 SYB 商品列表“匹配退货”后使用“取消匹配 N”。仅处理当前页勾选且处理阶段为 `return_pending`、原匹配仍为 `matched` 的明细;`return_used` / `confirmed` 不参与批量取消。新增勾选资格不扩大采购、采集、图搜或 AI 匹配候选范围。 +- 一次确认后按原匹配编号逐条取消。并发确认、已经取消等状态冲突跳过;其他错误保留失败。网络异常先回读原记录,不盲目重试,不追随后来重新匹配的新记录;登录失效停止剩余写入。 +- 成功后重新计算采购准备状态,并按当前退货状态与同步可用性判断退货池资格,不保证所有商品立即可采购,不改变既有采购或采集任务。保持查询条件刷新;仅保留仍在当前页、仍为原匹配且可取消的失败项勾选,其余失败项保留在结果表。 +- 原有单条确认及取消兼容:未携带状态前提的单条取消仍允许取消已确认匹配;本次不新增权限或迁移,不自动创建采购或执行付款。 + +## SYB 当前页批量使用退货(#349) + +实现绑定 4280115(2026-09-29),尚未合并/发布,待验收。 + +- 管理员、采购员及售后沿用既有权限,工具栏为“匹配退货 → 取消匹配 → 使用退货”。仅当前页勾选且处理阶段 return_pending、原有效匹配 matched 的明细计入使用数量;已用退货与未匹配行不参与。 +- 一次确认后逐条标记 confirmed / 已用退货,保留有效占用;不是 Yeeke 发货或重出,不修改已有采购/采集任务,不自动创建采购或支付,不扩大原候选资格。 +- 只操作冻结的原 match ID。并发确认或取消冲突跳过,不追随后来的替代匹配。其他错误失败,各项不互相回滚;登录失效停止后续写入并明确未执行。 +- 网络结果未知先回读原记录:已确认/已取消显示回读状态而不是本次成功;仍为原 matched 可人工重试;回读失败保留不确定结果,不自动重复提交。 +- 完成保持筛选、页码和结果明细。成功项在退货待确认筛选下移出;仅当前页仍为原 matched 的可重试失败项恢复勾选,离页结果不丢失、不跨页操作。操作中互斥匹配、使用、取消及单条操作,无新增审批或权限。 diff --git a/docs/08-agent-api-contract.md b/docs/08-agent-api-contract.md index 5b7b7e7..c10bab3 100644 --- a/docs/08-agent-api-contract.md +++ b/docs/08-agent-api-contract.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Android-Agent-API-Contract wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.- -wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9 -synchronized_at: 2026-09-21T08:15:05Z +wiki_revision: 102d40fb2d960b05684e2a82e85729362070fdb5 +synchronized_at: 2026-09-29T02:24:16Z @@ -1399,3 +1399,21 @@ Android 仅人工“回填”扫描识别单节点“实付”标签,兼容实 GET /api/admin/v1/purchase-tasks 的既有 status 参数新增查询专用值 syb_writeback_succeeded。该值按独立 purchase_order_writeback.status=succeeded 且 purchase_task_id 对应当前采购任务过滤,不查询旧物流 writeback_status,不新增或改写任务状态。原 status 合法值及其他参数行为保持不变。 匹配成功回填的历史任务,不额外要求当前采购状态为 order_created;返回的 task.status 仍为真实采购状态。无单号队列及 pending/running/failed/unknown/conflict 不命中。count及分页共用子查询过滤,可与 taskId、executionMode、taskType、sybProductId、shopeeOrderNo、pddOrderNo 组合;status为空恢复不限制状态。权限与返回信封不变,不写入数据、不触发采购或SYB请求。先发布Server再发布新增筛选值的Web,无数据库迁移或Android变更。 +## 管理端退货匹配取消前提(#347) + +此为 Admin 共享管理接口补充,不改变 Android Agent 契约。 + +`POST /api/admin/v1/return-matches/:id/cancel` 可携带 `{"expectedStatus":"matched"}`。仅允许此值;其他值(含空字符串、null)或错误 JSON 返回 HTTP 400 / `INVALID_REQUEST`。省略请求体或属性兼容旧单条取消。 + +携带前提时,服务端在锁定 `:id` 原记录的事务中检查状态仍为 `matched` 且有效占用指针存在,否则 HTTP 409 / `STATE_CONFLICT`,不取消已确认或已取消的匹配。不存在返回 HTTP 404 / `NOT_FOUND`。成功仍为 `{code:200,data:{item:ReturnMatch}}`,释放占用并记录 CancelledBy / CancelledAt 和审计日志。权限沿用 admin / purchaser / after_sales,没有新增批量接口或权限点。 + +Web 批量入口冻结原匹配 ID 并顺序调用;HTTP 409 状态冲突跳过,权限/网络错误失败,HTTP 401 或登录失效停止后续操作。写入结果未知时只允许只读回读原记录,不自动重发取消;不以 SYB ID 重新查询替代匹配并取消。 + +## Admin 批量使用退货复用确认接口(#349) + +实现绑定 4280115(2026-09-29),未合并/发布;仅补充管理端调用规则,Android Agent 契约不变。 + +- Web 当前页批量使用复用 POST /api/admin/v1/return-matches/:id/confirm,无新增请求体、批量接口、状态或权限。Server 沿用锁定原记录后仅接受 matched;成功为 {code:200,data:{item:ReturnMatch}},写 confirmed、确认人/时间及审计,保留 active 占用。 +- HTTP 409 / STATE_CONFLICT 跳过,HTTP 404 / NOT_FOUND 或其他处理错误明确失败。沿用 admin / purchaser / after_sales 权限;HTTP 401、业务认证错误及旧拦截器 false 结果均停止余项,不能误计为成功。 +- 写结果不明确时仅 GET /api/admin/v1/return-matches/:id 回读冻结原记录,验证 match 身份;已 confirmed/cancelled 显示回读已处理,仍 matched 才允许手动重试。不得自动重发 POST 或改查新匹配后使用。 +- 无数据库、Server 生产行为、Agent 协议或外部 Yeeke 写入变化。真实业务确认及部署另需授权。 diff --git a/docs/12-syb-erp-interface.md b/docs/12-syb-erp-interface.md index b32725e..b1a7077 100644 --- a/docs/12-syb-erp-interface.md +++ b/docs/12-syb-erp-interface.md @@ -525,6 +525,13 @@ POST /am/stock/detail/updateDetailCode?t=0&id={stockID}&detailId={detailID}&code 它是反复启动的一次性脚本,进程间要传会话;GoAuto 服务端是常驻进程,没有这个需求, 持久化只为重启后免登录,复用现有数据库即可。表见 `syb_session`。 +`[必须]` 同步和采购订单回填共用统一会话获取器:先校验 `syb_session`,只有明确 +会话失效或不存在时才进入登录。登录由 `syb_session_auth_lease` 的单账号租约串行化, +租约内其他任务等待新会话,不重复请求验证码;登录成功后原回填任务重新执行并回读确认。 +网络超时、5xx 或 OCR 不可用不得清除有效会话,必须保留结构化失败状态供人工重试。 +回填终态同时同步到 `purchase_task.writeback_status/writeback_at`,页面不得继续显示旧的 +`not_selected`。 + `[决定已变更]` ~~不引入 OCR 服务。~~ 这条判断在上游工单 #47 里被推翻了, 原文和推翻理由都留在这里,方便后来人知道这个决定变过、为什么变: diff --git a/docs/13-deployment-and-operations.md b/docs/13-deployment-and-operations.md index c7f93c2..1d0f490 100644 --- a/docs/13-deployment-and-operations.md +++ b/docs/13-deployment-and-operations.md @@ -1,3 +1,19 @@ + +generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) +wiki_page: Deployment-and-Operations +wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-and-Operations.- +wiki_revision: 3a76e16d43c940425f68cc4748944213e980ad60 +synchronized_at: 2026-09-28T08:10:00Z + + + +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 + + generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Deployment-and-Operations @@ -10,13 +26,47 @@ synchronized_at: 2026-09-22T02:56:36Z ## 当前线上拓扑 -- 外部入口:`http://185.216.248.75:9527`,Nginx 同时承载 Admin 静态资源并反向代理 GoAuto API。 +- 外部入口:`http://122.228.200.167:9527`(2026-09-28 起;此前为 `185.216.248.75:9527`,旧机 `goauto.service` 已停止)。Nginx 在 9527 **直接提供 Admin 静态资源**,未命中静态文件的请求反向代理到 GoAuto API,配置见下节「Nginx 入口(9527)」。 - GoAuto 服务监听:`127.0.0.1:8010`。 -- 常驻服务:systemd `goauto.service`。 +- 常驻服务:systemd `goauto.service`(新机依赖 `mysql84-cmhub.service`)。 - 工作目录:`/home/goauto/current`,指向 `/home/goauto/releases/<发布标识>`。 - 服务配置:`/home/goauto/current/config/settings.yml`;敏感环境变量由 `/etc/goauto/goauto.env` 提供,不写入 Git、Wiki、工单或日志。 - Agent APK 私有目录:相对工作目录的 `var/goauto-agent-releases`;下载必须通过已认证接口。 +## Nginx 入口(9527) + +线上 vhost:`/www/server/panel/vhost/nginx/goauto-9527.conf`(宝塔面板目录)。标准配置: + +```nginx +server { + listen 9527 default_server; + server_name 122.228.200.167 _; + root /home/goauto/current/dist; + index index.html; + client_max_body_size 100m; + allow all; + location = / { + try_files /index.html =404; + } + location / { + try_files $uri @goauto_backend; + } + location @goauto_backend { + proxy_pass http://127.0.0.1:8010; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_read_timeout 300s; + } +} +``` + +`[必须]` 不得把 9527 写成「全部 `proxy_pass` 到 8010」:GoAuto 服务端(go-admin)在 `GET /` 注册了「GO-ADMIN欢迎您」欢迎页,前端 SPA 只通过未匹配路由兜底提供,全部转发时首页就会显示欢迎页而不是 Admin 后台(2026-09-28 迁移时发生过,见 #346)。`location = /` 必须由 Nginx 返回 `dist/index.html`;其余路径先找静态文件,找不到再交给后端(后端对非 API 的 GET 返回 index.html 以支持前端路由,对 `/api/` 等返回真实结果)。修改后先 `nginx -t` 再 `nginx -s reload`,改前备份原文件。 + ## Agent HTTP 例外 服务端生产模式默认要求 Agent 使用 HTTPS。当前线上入口只有 HTTP,因此经 #181 用户明确确认,在 `/etc/goauto/goauto.env` 设置: @@ -33,7 +83,13 @@ GOAUTO_ALLOW_INSECURE_AGENT_HTTP=true 1. 在本地完成服务端测试/构建和 Android 单测/APK 构建,记录提交、versionCode、SHA-256 与大小。 2. 创建新的 `/home/goauto/releases/<发布标识>`,复制服务端二进制、Web 静态资源和非敏感配置;保留旧发布目录用于回滚。 -3. 原子切换 `/home/goauto/current` 后重启 `goauto.service`,确认 `systemctl is-active goauto.service` 为 `active`,并从外部入口验证 API。 +3. 原子切换 `/home/goauto/current` 后重启 `goauto.service`,确认 `systemctl is-active goauto.service` 为 `active`,再从外部入口**按内容**验收(只看 HTTP 200 不算通过——欢迎页、错误页也可能是 200): + - `GET /`:返回 HTML,包含 `id="app"`,且**不包含**「GO-ADMIN欢迎您」; + - 前端路由(如 `GET /login`):同样返回 Admin 的 index.html; + - `index.html` 引用的 `/js/…`、`/css/…` 资源:HTTP 200; + - `GET /api/v1/captcha`:`application/json` 且 `code=200`; + - 任一业务接口未登录访问(如 `GET /api/admin/v1/yeeke-returns`):JSON 业务码 401; + - 结构日志无 panic/fatal/1146/1054。 4. 上传 APK 到 Admin Agent 版本并按需设为当前;服务端解析 Manifest,校验 versionCode 唯一性并保存 SHA-256。 5. 真机安装前确认设备没有运行中的任务。Android 系统安装确认仍由人工完成,Agent 不静默安装。 @@ -45,6 +101,19 @@ Admin 蝦皮规格 AI 匹配会同步等待外部 Provider:Provider 配置允 Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、上游 HTTP 状态码或网络错误分类;不得记录 API Key、Authorization、Provider URL、模型输入、候选规格、商品原始内容或响应正文。Provider 失败时接口返回 HTTP 503 与 `AI_MATCHING_UNAVAILABLE`,供 Admin 显示安全中文提示。 +## 服务器迁移清单 + +更换线上服务器时逐项核对,每项都要在新机上回读确认: + +1. **Nginx**:9527 vhost 按上节标准配置写入(root 指向 `/home/goauto/current/dist`,`location = /` 返回 index.html),`nginx -t` 通过后 reload; +2. **发布目录**:`/home/goauto/releases/<发布标识>` 含二进制、`dist/index.html`、`config/settings.yml`(含 `extend.syb`、`extend.yeeke` 段);`current` 软链接指向它;静态目录与 `var`(APK 私有目录)随迁;属主 `goauto`; +3. **环境变量**:`/etc/goauto/goauto.env` 与旧机字段一致(`GOAUTO_DB_*`、`GOAUTO_SERVER_PORT=8010`、`GOAUTO_WEB_DIST`、`GOAUTO_CONFIG`、SYB/yeeke 账号、Agent HTTP 例外等),权限 600 `root:goauto`;含中文等非 ASCII 值时按字节核对,不能只比长度; +4. **systemd**:`goauto.service` 的 `WorkingDirectory`、`EnvironmentFile`、`ExecStart` 与依赖的数据库服务; +5. **数据库**:数据完整迁移后,`sys_migration` 最新版本与旧机一致; +6. **定时任务**:`sys_job` 启用状态与旧机一致,避免新旧两机同时执行同一定时任务(旧机须停服); +7. **外部依赖**:SYB、yeeke、OCR 服务可达,先用手动同步验证登录; +8. 按「发布与验证」第 3 步做按内容验收;更新本页「当前线上拓扑」。 + ## 回滚 服务异常时把 `/home/goauto/current` 切回上一已验证发布目录并重启 `goauto.service`,随后复核服务状态和 Agent 接口。不要删除当前或历史 APK/发布目录来代替回滚;数据库变化如需回退必须单独评估。 @@ -153,3 +222,20 @@ Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、 5. GoAuto Agent 已启动,无障碍服务已开启,Admin 设备列表显示在线并已归属对应采购员账号。 任一项不满足时采购会明确失败并提示缺失项(如“未找到支付入口:请确认 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 首页、售后登录及退货匹配只读接口,不用真实商品提交作为健康检查。 + +## 2026-09-28 迁移到 122.228.200.167 与首页修复(#346) + +- 线上服务由 185.216.248.75 迁移到 122.228.200.167(发布目录 `20260928-344-9dace6a`)。 +- 迁移后 `http://122.228.200.167:9527/` 显示 go-admin 欢迎页:新机 9527 vhost 写成了全部 `proxy_pass`,缺少 `root /home/goauto/current/dist` 与 `location = /`。已按「Nginx 入口(9527)」标准配置修复(原文件备份为 `goauto-9527.conf.bak-20260928150822`),`nginx -t` 通过后 reload;按内容验收首页、前端路由、静态资源、验证码与未登录接口均通过。 +- 代码侧根治见 #346:存在 dist 时服务端 `GET /` 也返回 SPA index.html,即使 Nginx 误配为全部转发也不再出现欢迎页。 diff --git a/server/app/admin/router/spa.go b/server/app/admin/router/spa.go index 9903f77..3685bc1 100644 --- a/server/app/admin/router/spa.go +++ b/server/app/admin/router/spa.go @@ -25,12 +25,8 @@ const SPADirEnv = "GOAUTO_WEB_DIST" // dist; a NoRoute handler installed anyway would turn every genuine 404 into // an HTML page, which is far more confusing than a plain 404. func InitSPARouter(engine *gin.Engine) { - dist := strings.TrimSpace(os.Getenv(SPADirEnv)) - if dist == "" { - dist = "dist" - } - index := filepath.Join(dist, "index.html") - if _, err := os.Stat(index); err != nil { + dist, index, ok := spaIndex() + if !ok { return } @@ -56,6 +52,38 @@ func InitSPARouter(engine *gin.Engine) { }) } +// spaIndex resolves the built frontend directory and reports whether its +// index.html exists. +func spaIndex() (dist, index string, ok bool) { + dist = strings.TrimSpace(os.Getenv(SPADirEnv)) + if dist == "" { + dist = "dist" + } + index = filepath.Join(dist, "index.html") + if _, err := os.Stat(index); err != nil { + return dist, index, false + } + return dist, index, true +} + +// registerRootRoute decides what `GET /` returns (#346). +// +// `[必须]` When the built frontend exists, `/` must be the Admin SPA. go-admin's +// welcome page used to own `/` in every non-prod mode, so any reverse proxy +// that forwarded `/` to this server (instead of serving dist itself) showed +// "GO-ADMIN欢迎您" instead of the Admin — which is exactly what happened after +// the 2026-09-28 server migration. The welcome page is kept only for +// development without a dist, where the frontend runs under vite. +func registerRootRoute(r gin.IRoutes, mode string, welcome gin.HandlerFunc) { + if _, index, ok := spaIndex(); ok { + r.GET("/", func(c *gin.Context) { c.File(index) }) + return + } + if mode != "prod" { + r.GET("/", welcome) + } +} + // isAPIPath reports whether a path belongs to the server rather than the SPA. func isAPIPath(path string) bool { for _, prefix := range []string{"/api/", "/swagger/", "/static/", "/form-generator/", "/ws/", "/wslogout/", "/info"} { diff --git a/server/app/admin/router/spa_test.go b/server/app/admin/router/spa_test.go index 6d9a112..553779e 100644 --- a/server/app/admin/router/spa_test.go +++ b/server/app/admin/router/spa_test.go @@ -91,3 +91,46 @@ func TestWithoutDistNoFallbackIsInstalled(t *testing.T) { t.Fatalf("没有 dist 时接口仍应正常: %d", response.Code) } } + +// #346: with a built frontend, `/` must be the Admin SPA — never go-admin's +// welcome page, even in non-prod modes where the welcome page used to own `/`. +func TestRootServesSPAWhenDistExists(t *testing.T) { + gin.SetMode(gin.TestMode) + dist := filepath.Join(t.TempDir(), "dist") + if err := os.MkdirAll(dist, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dist, "index.html"), []byte("SPA"), 0o644); err != nil { + t.Fatal(err) + } + t.Setenv(SPADirEnv, dist) + for _, mode := range []string{"dev", "test", "prod"} { + engine := gin.New() + registerRootRoute(engine, mode, func(c *gin.Context) { c.String(http.StatusOK, "GO-ADMIN欢迎您") }) + InitSPARouter(engine) + response := do(engine, http.MethodGet, "/") + if response.Code != http.StatusOK || response.Body.String() != "SPA" { + t.Fatalf("mode=%s: / should serve index.html, got %d %q", mode, response.Code, response.Body.String()) + } + } +} + +// Without a dist (development under vite) the previous behaviour is kept: +// welcome page outside prod, nothing registered in prod. +func TestRootWithoutDistKeepsPreviousBehaviour(t *testing.T) { + gin.SetMode(gin.TestMode) + t.Setenv(SPADirEnv, filepath.Join(t.TempDir(), "missing-dist")) + welcome := func(c *gin.Context) { c.String(http.StatusOK, "GO-ADMIN欢迎您") } + + dev := gin.New() + registerRootRoute(dev, "dev", welcome) + if response := do(dev, http.MethodGet, "/"); response.Code != http.StatusOK || response.Body.String() != "GO-ADMIN欢迎您" { + t.Fatalf("dev without dist should keep the welcome page, got %d %q", response.Code, response.Body.String()) + } + + prod := gin.New() + registerRootRoute(prod, "prod", welcome) + if response := do(prod, http.MethodGet, "/"); response.Code != http.StatusNotFound { + t.Fatalf("prod without dist should not register /, got %d", response.Code) + } +} diff --git a/server/app/admin/router/sys_router.go b/server/app/admin/router/sys_router.go index 3176a17..885d218 100644 --- a/server/app/admin/router/sys_router.go +++ b/server/app/admin/router/sys_router.go @@ -40,9 +40,7 @@ func sysBaseRouter(r *gin.RouterGroup) { go ws.WebsocketManager.SendService() go ws.WebsocketManager.SendAllService() - if config.ApplicationConfig.Mode != "prod" { - r.GET("/", apis.GoAdmin) - } + registerRootRoute(r, config.ApplicationConfig.Mode, apis.GoAdmin) r.GET("/info", handler.Ping) } diff --git a/server/app/goauto/access/modules.go b/server/app/goauto/access/modules.go index 72cc71b..d48cd7c 100644 --- a/server/app/goauto/access/modules.go +++ b/server/app/goauto/access/modules.go @@ -173,6 +173,8 @@ func moduleKeyForAPI(path string) string { 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: return "" } diff --git a/server/app/goauto/access/purchaser.go b/server/app/goauto/access/purchaser.go index 0fdb88d..bd76654 100644 --- a/server/app/goauto/access/purchaser.go +++ b/server/app/goauto/access/purchaser.go @@ -3,6 +3,9 @@ package access // RolePurchaser is the fixed role key used by the GoAuto purchaser account. 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 // APIs that the purchaser role may call; every other API remains admin-only. type APIPermission struct { @@ -132,6 +135,15 @@ var AdminAPIs = []APIPermission{ {"查看 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", "PUT", false}, {"测试 AI 服务连接", "/api/admin/v1/ai-matching-settings/test", "POST", false}, diff --git a/server/app/goauto/migrations/migrate.go b/server/app/goauto/migrations/migrate.go index bec32d5..b60fb83 100644 --- a/server/app/goauto/migrations/migrate.go +++ b/server/app/goauto/migrations/migrate.go @@ -41,6 +41,7 @@ func MigratedModels() []any { &models.SYBSpecAIParseRun{}, &models.SYBSpecAIParseWorkItem{}, &models.SYBSession{}, + &models.SYBSessionAuthLease{}, &models.SYBShop{}, &models.SYBProductFilter{}, &models.SYBProductFilterRecomputeLog{}, diff --git a/server/app/goauto/models/schema.go b/server/app/goauto/models/schema.go index 0b55186..e63b179 100644 --- a/server/app/goauto/models/schema.go +++ b/server/app/goauto/models/schema.go @@ -504,6 +504,17 @@ type SYBSession struct { 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 // several Shopee product lines, and the same Shopee product can appear more // than once within one order at different colors/sizes/quantities — each such diff --git a/server/app/goauto/purchase/handler.go b/server/app/goauto/purchase/handler.go index 52d2935..51aa077 100644 --- a/server/app/goauto/purchase/handler.go +++ b/server/app/goauto/purchase/handler.go @@ -475,10 +475,10 @@ func allowedOperator(c *gin.Context) bool { return true } role, _ := jwt.ExtractClaims(c)["rolekey"].(string) - if role == "admin" || role == "purchaser" { + if role == "admin" || role == "purchaser" || role == "after_sales" { return true } - c.JSON(http.StatusForbidden, gin.H{"code": "FORBIDDEN", "message": "只有管理员或采购员可以操作采购任务"}) + c.JSON(http.StatusForbidden, gin.H{"code": "FORBIDDEN", "message": "只有管理员、采购员或售后可以操作采购任务"}) c.Abort() return false } diff --git a/server/app/goauto/purchase/handler_access_test.go b/server/app/goauto/purchase/handler_access_test.go new file mode 100644 index 0000000..651d171 --- /dev/null +++ b/server/app/goauto/purchase/handler_access_test.go @@ -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) + } + }) + } +} diff --git a/server/app/goauto/purchase/order_backfill_test.go b/server/app/goauto/purchase/order_backfill_test.go index b22b405..2eb0135 100644 --- a/server/app/goauto/purchase/order_backfill_test.go +++ b/server/app/goauto/purchase/order_backfill_test.go @@ -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 { 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") } for _, replayID := range []string{rid, uuid.NewString()} { diff --git a/server/app/goauto/purchase/order_writeback.go b/server/app/goauto/purchase/order_writeback.go index 715a845..7319ed5 100644 --- a/server/app/goauto/purchase/order_writeback.go +++ b/server/app/goauto/purchase/order_writeback.go @@ -60,7 +60,10 @@ func ensureOrderWriteback(tx *gorm.DB, t models.PurchaseTask) error { return nil } 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) { diff --git a/server/app/goauto/purchase/order_writeback_test.go b/server/app/goauto/purchase/order_writeback_test.go index a2dcd40..7d43cd1 100644 --- a/server/app/goauto/purchase/order_writeback_test.go +++ b/server/app/goauto/purchase/order_writeback_test.go @@ -116,7 +116,11 @@ func TestOrderWritebackRemoteOutcomes(t *testing.T) { t.Fatal("automatically repeated write") } 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") } }) diff --git a/server/app/goauto/purchase/order_writeback_worker.go b/server/app/goauto/purchase/order_writeback_worker.go index b2421e4..b8ab969 100644 --- a/server/app/goauto/purchase/order_writeback_worker.go +++ b/server/app/goauto/purchase/order_writeback_worker.go @@ -84,38 +84,16 @@ func sessionUnavailableMessage(err error) string { return "SYB会话不可用(" + category + "),将自动重试;如持续失败请恢复登录后重试" } -// restoreOrderWritebackClient rebuilds a SYB client from the cached session -// only. It never logs in, never triggers OCR and never deletes the cached -// session (that stays the exclusive responsibility of sybimport.Connect's -// login/refresh path) — it only reports whether the cached cookies still -// work, via CheckSession, so the caller can classify the failure (#330). +// restoreOrderWritebackClient uses the same session acquisition path as SYB +// sync. A valid cached session is reused; an explicitly invalid session is +// refreshed under the shared database auth lease so concurrent workers do not +// request multiple captcha codes. func restoreOrderWritebackClient(ctx context.Context, db *gorm.DB) (OrderNumberClient, error) { cfg := config.ExtConfig.SYB.Resolved() - session, err := sybclient.NewSessionStore(db).Load(ctx, cfg.Username, time.Now()) - if err != nil { - return nil, err - } - 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 + return sybclient.AcquireSession(ctx, db, sybclient.LoginConfig{ + BaseURL: cfg.BaseURL, Username: cfg.Username, Password: cfg.Password, + OcrURL: cfg.OcrURL, OcrMaxAttempts: cfg.OcrMaxAttempts, + }) } // 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" { 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 // 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 { 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 if err = db.First(&task, item.PurchaseTaskID).Error; err != nil { diff --git a/server/app/goauto/returnmatch/cancel_precondition_test.go b/server/app/goauto/returnmatch/cancel_precondition_test.go new file mode 100644 index 0000000..d269a9e --- /dev/null +++ b/server/app/goauto/returnmatch/cancel_precondition_test.go @@ -0,0 +1,120 @@ +package returnmatch + +import ( + "context" + "errors" + "fmt" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" + jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth" + "go-admin/app/goauto/models" + "gorm.io/gorm" +) + +func seedCancelMatch(t *testing.T, db *gorm.DB, status string) models.ReturnMatch { + t.Helper() + syb := seedSYB(t, db, "CANCEL-TEST", 1, "白色", "L", time.Now()) + item := seedReturn(t, db, "白色,L", nil) + m := models.ReturnMatch{SYBProductID: syb.ID, YeekeReturnItemID: item.ID, ActiveSYBProductID: &syb.ID, ActiveYeekeReturnItemID: &item.ID, Status: status, MatchedAt: time.Now()} + if err := db.Create(&m).Error; err != nil { + t.Fatal(err) + } + return m +} + +func TestCancelExpectedStatusHTTP(t *testing.T) { + for _, tc := range []struct { + name, status, body, role string + code int + }{ + {"matched", "matched", `{"expectedStatus":"matched"}`, "purchaser", 200}, + {"confirmed_conflict", "confirmed", `{"expectedStatus":"matched"}`, "after_sales", 409}, + {"cancelled_conflict", "cancelled", `{"expectedStatus":"matched"}`, "admin", 409}, + {"legacy_empty", "confirmed", "", "after_sales", 200}, + {"legacy_object", "confirmed", `{}`, "admin", 200}, + {"invalid_status", "matched", `{"expectedStatus":"confirmed"}`, "admin", 400}, + {"invalid_empty", "matched", `{"expectedStatus":""}`, "admin", 400}, + {"invalid_null", "matched", `{"expectedStatus":null}`, "admin", 400}, + {"malformed", "matched", `{`, "admin", 400}, + {"forbidden", "matched", `{"expectedStatus":"matched"}`, "other", 403}, + } { + t.Run(tc.name, func(t *testing.T) { + db := testDB(t) + m := seedCancelMatch(t, db, tc.status) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("POST", fmt.Sprintf("/return-matches/%d/cancel", m.ID), strings.NewReader(tc.body)) + c.Params = gin.Params{{Key: "id", Value: fmt.Sprint(m.ID)}} + c.Set("JWT_PAYLOAD", jwt.MapClaims{"rolekey": tc.role, "username": "cancel-tester"}) + Handler{DB: db}.Cancel(c) + if w.Code != tc.code { + t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) + } + var saved models.ReturnMatch + db.First(&saved, m.ID) + var logs int64 + db.Model(&models.ReturnMatchLog{}).Where("match_id = ?", m.ID).Count(&logs) + if tc.code == 200 { + if saved.Status != "cancelled" || saved.ActiveSYBProductID != nil || saved.ActiveYeekeReturnItemID != nil || saved.CancelledBy != "cancel-tester" || saved.CancelledAt == nil || logs != 1 { + t.Fatalf("cancel or audit incomplete: %+v logs=%d", saved, logs) + } + _, err := NewService(db).Cancel(context.Background(), m.ID, "repeat", "matched") + if err != errStateConflict { + t.Fatalf("repeat err=%v", err) + } + } else if saved.Status != tc.status || logs != 0 { + t.Fatalf("rejected transition mutated state: %+v logs=%d", saved, logs) + } + }) + } +} + +func TestCancelExpectedStatusMissingAndConfirmedRace(t *testing.T) { + db := testDB(t) + s := NewService(db) + if _, err := s.Cancel(context.Background(), 99999, "tester", "matched"); err != gorm.ErrRecordNotFound { + t.Fatalf("missing err=%v", err) + } + m := seedCancelMatch(t, db, "matched") + if _, err := s.Confirm(context.Background(), m.ID, "reviewer"); err != nil { + t.Fatal(err) + } + // A batch holding the earlier matched snapshot must not undo a completed confirmation. + if _, err := s.Cancel(context.Background(), m.ID, "batch", "matched"); err != errStateConflict { + t.Fatalf("stale batch err=%v", err) + } + var saved models.ReturnMatch + db.First(&saved, m.ID) + if saved.Status != "confirmed" || saved.ActiveSYBProductID == nil { + t.Fatalf("confirmation lost: %+v", saved) + } + if _, err := s.Cancel(context.Background(), m.ID, "legacy"); err != nil { + t.Fatalf("legacy confirmed cancellation: %v", err) + } +} + +func TestCancelAuditFailureRollsBackTransition(t *testing.T) { + db := testDB(t) + m := seedCancelMatch(t, db, "matched") + if err := db.Callback().Create().Before("gorm:create").Register("test:reject_cancel_log", func(tx *gorm.DB) { + if tx.Statement.Schema != nil && tx.Statement.Schema.Name == "ReturnMatchLog" { + tx.AddError(errors.New("synthetic audit failure")) + } + }); err != nil { + t.Fatal(err) + } + if _, err := NewService(db).Cancel(context.Background(), m.ID, "tester", "matched"); err == nil { + t.Fatal("expected audit failure") + } + var saved models.ReturnMatch + if err := db.First(&saved, m.ID).Error; err != nil { + t.Fatal(err) + } + if saved.Status != "matched" || saved.ActiveSYBProductID == nil || saved.ActiveYeekeReturnItemID == nil || saved.CancelledAt != nil { + t.Fatalf("failed audit did not roll back: %+v", saved) + } +} diff --git a/server/app/goauto/returnmatch/confirm_batch_test.go b/server/app/goauto/returnmatch/confirm_batch_test.go new file mode 100644 index 0000000..9c645e9 --- /dev/null +++ b/server/app/goauto/returnmatch/confirm_batch_test.go @@ -0,0 +1,103 @@ +package returnmatch + +import ( + "context" + "errors" + "fmt" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth" + "go-admin/app/goauto/models" + "gorm.io/gorm" +) + +// #349 reuses the existing single-record transaction; no production batch API. +func TestConfirmBatchExistingHTTPContract(t *testing.T) { + for _, tc := range []struct { + name, status, role string + code int + }{ + {"admin", "matched", "admin", 200}, + {"purchaser", "matched", "purchaser", 200}, + {"after_sales", "matched", "after_sales", 200}, + {"already_confirmed", "confirmed", "after_sales", 409}, + {"cancelled", "cancelled", "purchaser", 409}, + {"forbidden", "matched", "viewer", 403}, + } { + t.Run(tc.name, func(t *testing.T) { + db := testDB(t) + m := seedCancelMatch(t, db, tc.status) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("POST", fmt.Sprintf("/return-matches/%d/confirm", m.ID), nil) + c.Params = gin.Params{{Key: "id", Value: fmt.Sprint(m.ID)}} + c.Set("JWT_PAYLOAD", jwt.MapClaims{"rolekey": tc.role, "username": "confirm-tester"}) + Handler{DB: db}.Confirm(c) + if w.Code != tc.code { + t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) + } + var saved models.ReturnMatch + db.First(&saved, m.ID) + var logs int64 + db.Model(&models.ReturnMatchLog{}).Where("match_id = ?", m.ID).Count(&logs) + if tc.code == 200 { + if saved.Status != "confirmed" || saved.ConfirmedBy != "confirm-tester" || saved.ConfirmedAt == nil || saved.ActiveSYBProductID == nil || saved.ActiveYeekeReturnItemID == nil || logs != 1 { + t.Fatalf("confirmation audit or occupancy lost: %+v logs=%d", saved, logs) + } + } else if saved.Status != tc.status || logs != 0 { + t.Fatalf("rejected confirm changed state: %+v logs=%d", saved, logs) + } + }) + } +} + +func TestConfirmBatchOriginalRecordStateChanges(t *testing.T) { + db := testDB(t) + s := NewService(db) + if _, err := s.Confirm(context.Background(), 99999, "tester"); err != gorm.ErrRecordNotFound { + t.Fatalf("missing=%v", err) + } + m := seedCancelMatch(t, db, "matched") + if _, err := s.Cancel(context.Background(), m.ID, "other"); err != nil { + t.Fatal(err) + } + // Simulate a later rematch with the same product/return, but a new identity. + replacement := m + replacement.ID = 0 + replacement.CancelledAt = nil + replacement.CancelledBy = "" + if err := db.Create(&replacement).Error; err != nil { + t.Fatal(err) + } + if _, err := s.Confirm(context.Background(), m.ID, "batch"); err != errStateConflict { + t.Fatalf("stale original=%v", err) + } + if _, err := s.Confirm(context.Background(), replacement.ID, "reviewer"); err != nil { + t.Fatal(err) + } + if _, err := s.Confirm(context.Background(), replacement.ID, "repeat"); err != errStateConflict { + t.Fatalf("duplicate=%v", err) + } +} + +func TestConfirmBatchAuditFailureRollsBack(t *testing.T) { + db := testDB(t) + m := seedCancelMatch(t, db, "matched") + if err := db.Callback().Create().Before("gorm:create").Register("test:reject_confirm_log", func(tx *gorm.DB) { + if tx.Statement.Schema != nil && tx.Statement.Schema.Name == "ReturnMatchLog" { + tx.AddError(errors.New("synthetic audit failure")) + } + }); err != nil { + t.Fatal(err) + } + if _, err := NewService(db).Confirm(context.Background(), m.ID, "tester"); err == nil { + t.Fatal("expected audit failure") + } + var saved models.ReturnMatch + db.First(&saved, m.ID) + if saved.Status != "matched" || saved.ConfirmedAt != nil || saved.ActiveSYBProductID == nil || saved.ActiveYeekeReturnItemID == nil { + t.Fatalf("confirm did not roll back: %+v", saved) + } +} diff --git a/server/app/goauto/returnmatch/handler.go b/server/app/goauto/returnmatch/handler.go index 80263ad..281e508 100644 --- a/server/app/goauto/returnmatch/handler.go +++ b/server/app/goauto/returnmatch/handler.go @@ -1,7 +1,9 @@ package returnmatch import ( + "encoding/json" "errors" + "io" "net/http" "strconv" @@ -44,14 +46,14 @@ func operatorFromContext(c *gin.Context) (string, string) { return role, username } -// requireCanPurchase mirrors the admin/purchaser write gate this codebase +// 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" { - c.JSON(http.StatusForbidden, gin.H{"code": "FORBIDDEN", "message": "只有管理员或采购员可以操作退货匹配"}) + if role != "admin" && role != "purchaser" && role != "after_sales" { + c.JSON(http.StatusForbidden, gin.H{"code": "FORBIDDEN", "message": "只有管理员、采购员或售后可以操作退货匹配"}) return false } return true @@ -140,7 +142,19 @@ func (h Handler) Confirm(c *gin.Context) { 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) + var body map[string]json.RawMessage + if err := ctx.ShouldBindJSON(&body); err != nil && !errors.Is(err, io.EOF) { + return models.ReturnMatch{}, errInvalidExpectedStatus + } + raw, supplied := body["expectedStatus"] + if !supplied { + return s.Cancel(ctx.Request.Context(), id, operator) + } + var expectedStatus string + if err := json.Unmarshal(raw, &expectedStatus); err != nil { + return models.ReturnMatch{}, errInvalidExpectedStatus + } + return s.Cancel(ctx.Request.Context(), id, operator, expectedStatus) }) } @@ -160,6 +174,10 @@ func (h Handler) transition(c *gin.Context, fn func(*Service, *gin.Context, uint _, operator := operatorFromContext(c) match, err := fn(NewService(db), c, id, operator) if err != nil { + if errors.Is(err, errInvalidExpectedStatus) { + c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "expectedStatus 只允许 matched,或省略请求体"}) + return + } if errors.Is(err, gorm.ErrRecordNotFound) { c.JSON(http.StatusNotFound, gin.H{"code": "NOT_FOUND", "message": "匹配记录不存在"}) return diff --git a/server/app/goauto/returnmatch/handler_access_test.go b/server/app/goauto/returnmatch/handler_access_test.go new file mode 100644 index 0000000..59d1d26 --- /dev/null +++ b/server/app/goauto/returnmatch/handler_access_test.go @@ -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) + } + }) + } +} diff --git a/server/app/goauto/returnmatch/reship_test.go b/server/app/goauto/returnmatch/reship_test.go new file mode 100644 index 0000000..561cb65 --- /dev/null +++ b/server/app/goauto/returnmatch/reship_test.go @@ -0,0 +1,38 @@ +package returnmatch + +import ( + "context" + "errors" + "testing" + "time" + + "go-admin/app/goauto/models" +) + +func TestReshippedReturnExcludedAndRecheckedBeforeInsert(t *testing.T) { + db := testDB(t) + s := NewService(db) + deadline := time.Now().Add(24 * time.Hour) + ret := seedReturn(t, db, "红色", &deadline) + sy := seedSYB(t, db, "TEST", 1, "红色", "", time.Now()) + pool, err := s.availableReturnPool(context.Background()) + if err != nil || len(pool) != 1 { + t.Fatalf("waiting pool=%v err=%v", pool, err) + } + if err := db.Model(&models.YeekeReturnPackage{}).Where("id = ?", ret.PackageID).Update("claim_status", "2").Error; err != nil { + t.Fatal(err) + } + pool, err = s.availableReturnPool(context.Background()) + if err != nil || len(pool) != 0 { + t.Fatalf("reshipped pool=%v err=%v", pool, err) + } + _, err = s.matchOneWithLock(context.Background(), sy.ID, MatchOutcome{ReturnItemID: ret.ID, DestroyDeadline: deadline}, "test") + if !errors.Is(err, errReturnNoLongerEligible) { + t.Fatalf("final recheck=%v", err) + } + var count int64 + db.Model(&models.ReturnMatch{}).Count(&count) + if count != 0 { + t.Fatal("reshipped item was allocated") + } +} diff --git a/server/app/goauto/returnmatch/router.go b/server/app/goauto/returnmatch/router.go index 899fc72..c0ebae4 100644 --- a/server/app/goauto/returnmatch/router.go +++ b/server/app/goauto/returnmatch/router.go @@ -7,7 +7,7 @@ import ( // 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 via +// 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{} diff --git a/server/app/goauto/returnmatch/service.go b/server/app/goauto/returnmatch/service.go index a945c2c..9300269 100644 --- a/server/app/goauto/returnmatch/service.go +++ b/server/app/goauto/returnmatch/service.go @@ -244,6 +244,11 @@ func (s *Service) batchMatch(ctx context.Context, req BatchMatchRequest) (BatchM } match, insertErr := s.matchOneWithLock(ctx, id, outcome, req.Operator) if insertErr != nil { + if errors.Is(insertErr, errReturnNoLongerEligible) { + resp.Items = append(resp.Items, BatchMatchItem{SYBProductID: id, ReasonCode: ReasonNoCandidate, Reason: "退货商品已重出或不再可用"}) + resp.SkippedCount++ + continue + } if errors.Is(insertErr, errStageNoLongerEligible) { // #338 review fix: the stage was re-checked under the same // FOR UPDATE lock purchase.create takes, right before insert. @@ -298,6 +303,7 @@ func (s *Service) availableReturnPool(ctx context.Context) ([]ReturnCandidate, e Joins("JOIN yeeke_return_package AS p ON p.id = i.package_id"). Joins("LEFT JOIN return_match AS m ON m.active_yeeke_return_item_id = i.id"). Where("m.id IS NULL AND i.sync_status = ? AND p.sync_status = ?", "ok", "ok"). + Where("p.claim_status = ? AND p.status_unrecognized = ?", "1", false). Find(&rows).Error if err != nil { return nil, err @@ -318,6 +324,7 @@ func (s *Service) availableReturnPool(ctx context.Context) ([]ReturnCandidate, e // it between BatchMatch's outer screening pass and this point (#338 review // fix: race between matching and purchase creation). var errStageNoLongerEligible = errors.New("syb product stage no longer participates in matching") +var errReturnNoLongerEligible = errors.New("yeeke return is no longer waiting to ship") // matchOneWithLock takes the SAME row lock purchase.Service.create takes on // syb_product (clause.Locking{Strength: "UPDATE"}) and re-computes the @@ -346,6 +353,17 @@ func (s *Service) matchOneWithLock(ctx context.Context, sybID uint64, outcome Ma if err := tx.First(&returnItem, outcome.ReturnItemID).Error; err != nil { return err } + var pkg models.YeekeReturnPackage + if err := tx.Clauses(clauseLockUpdate()).First(&pkg, returnItem.PackageID).Error; err != nil { + return err + } + // Same package -> item lock order as sync upsert; avoid a lock cycle. + if err := tx.Clauses(clauseLockUpdate()).First(&returnItem, outcome.ReturnItemID).Error; err != nil { + return err + } + if pkg.ClaimStatus != "1" || pkg.StatusUnrecognized || pkg.SyncStatus != "ok" || returnItem.SyncStatus != "ok" { + return errReturnNoLongerEligible + } sybIDCopy := syb.ID returnIDCopy := outcome.ReturnItemID deadline := outcome.DestroyDeadline @@ -402,11 +420,18 @@ func (s *Service) Confirm(ctx context.Context, matchID uint64, operator string) 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, +// Cancel releases the active SYB product and return item pointers; actual +// purchase readiness and return-pool eligibility are then re-evaluated. +// Without a precondition it accepts either matched or confirmed state, // per issue #338 rule: 取消匹配后再次点击「匹配退货」若配回同一对,允许. -func (s *Service) Cancel(ctx context.Context, matchID uint64, operator string) (models.ReturnMatch, error) { +var errInvalidExpectedStatus = errors.New("expectedStatus must be matched") + +// Optional matched precondition is checked under the same row lock as cancellation. +// Legacy callers without it retain the confirmed-to-cancelled transition. +func (s *Service) Cancel(ctx context.Context, matchID uint64, operator string, expectedStatus ...string) (models.ReturnMatch, error) { + if len(expectedStatus) > 1 || (len(expectedStatus) == 1 && expectedStatus[0] != models.ReturnMatchStatusMatched) { + return models.ReturnMatch{}, errInvalidExpectedStatus + } 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 { @@ -415,6 +440,9 @@ func (s *Service) Cancel(ctx context.Context, matchID uint64, operator string) ( if match.Status == models.ReturnMatchStatusCancelled { return errStateConflict } + if len(expectedStatus) == 1 && (match.Status != expectedStatus[0] || match.ActiveSYBProductID == nil || match.ActiveYeekeReturnItemID == nil) { + return errStateConflict + } now := s.Now() match.Status = models.ReturnMatchStatusCancelled match.ActiveSYBProductID = nil @@ -424,7 +452,7 @@ func (s *Service) Cancel(ctx context.Context, matchID uint64, operator string) ( 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 tx.Create(&models.ReturnMatchLog{MatchID: match.ID, Action: models.ReturnMatchLogActionCancelled, Operator: operator, Detail: "取消匹配,释放 SYB 与退货商品占用,采购准备状态重新计算"}).Error }) return match, err } diff --git a/server/app/goauto/returnmatch/service_test.go b/server/app/goauto/returnmatch/service_test.go index 0de5464..95e02b8 100644 --- a/server/app/goauto/returnmatch/service_test.go +++ b/server/app/goauto/returnmatch/service_test.go @@ -69,7 +69,8 @@ func seedSYB(t *testing.T, db *gorm.DB, orderCode string, detailID uint64, color func seedReturn(t *testing.T, db *gorm.DB, variationName string, deadline *time.Time) models.YeekeReturnItem { t.Helper() pkg := models.YeekeReturnPackage{ - ExternalID: "pkg-" + variationName + fmt.Sprint(time.Now().UnixNano()), OrderSN: "ORD1", TrackingNo: "TRK1", + ClaimStatus: "1", + ExternalID: "pkg-" + variationName + fmt.Sprint(time.Now().UnixNano()), OrderSN: "ORD1", TrackingNo: "TRK1", DestroyDeadLine: deadline, LastSyncedAt: time.Now(), } if err := db.Create(&pkg).Error; err != nil { diff --git a/server/app/goauto/sybclient/session.go b/server/app/goauto/sybclient/session.go index 9d8d8a4..1848481 100644 --- a/server/app/goauto/sybclient/session.go +++ b/server/app/goauto/sybclient/session.go @@ -30,6 +30,10 @@ type SessionStore struct{ db *gorm.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. type Session struct { Username string diff --git a/server/app/goauto/sybclient/session_manager.go b/server/app/goauto/sybclient/session_manager.go new file mode 100644 index 0000000..9ceb4a8 --- /dev/null +++ b/server/app/goauto/sybclient/session_manager.go @@ -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)}) +} diff --git a/server/app/goauto/sybclient/session_manager_test.go b/server/app/goauto/sybclient/session_manager_test.go new file mode 100644 index 0000000..4e8e37d --- /dev/null +++ b/server/app/goauto/sybclient/session_manager_test.go @@ -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) + } +} diff --git a/server/app/goauto/sybimport/handler.go b/server/app/goauto/sybimport/handler.go index 58e5e33..68693a0 100644 --- a/server/app/goauto/sybimport/handler.go +++ b/server/app/goauto/sybimport/handler.go @@ -47,7 +47,7 @@ func (handler Handler) List(c *gin.Context) { return } 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")), PurchaseType: strings.TrimSpace(c.Query("purchaseType")), + 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")), PurchaseType: strings.TrimSpace(c.Query("purchaseType")), }) if err != nil { writeError(c, err) diff --git a/server/app/goauto/sybimport/service.go b/server/app/goauto/sybimport/service.go index 00aefb0..c3c0eee 100644 --- a/server/app/goauto/sybimport/service.go +++ b/server/app/goauto/sybimport/service.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "strings" + "time" "go-admin/app/goauto/models" "go-admin/app/goauto/purchase" @@ -44,11 +45,14 @@ type ListRequest struct { OrderCodes []string ParseStatus string ProcessStage string + CreatedFrom string + CreatedTo string // PurchaseType is #340's list-side isolation filter: "pdd" (default when // empty) shows only rows that still need a PDD purchase, // "excluded" shows only pdd_purchase_excluded rows, "all" shows both. It - // combines with ProcessStage as AND; the auto-switch to 全部 mentioned in - // the issue is a front-end behaviour, not a server default. + // combines with ProcessStage and the created-time range as AND; the + // auto-switch to 全部 mentioned in the issue is a front-end behaviour, + // not a server default. PurchaseType string } @@ -81,6 +85,16 @@ func (service *Service) List(ctx context.Context, request ListRequest) (ListResp request.PageSize = 500 } 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 len([]rune(request.ShopName)) > 255 { return ListResponse{}, invalidRequest("店铺名称不能超过 255 个字符") @@ -164,6 +178,43 @@ func (service *Service) List(ctx context.Context, request ListRequest) (ListResp 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) { seen := make(map[string]bool, len(raw)) result := make([]string, 0, len(raw)) diff --git a/server/app/goauto/sybimport/service_test.go b/server/app/goauto/sybimport/service_test.go index ca7c6e9..e104afc 100644 --- a/server/app/goauto/sybimport/service_test.go +++ b/server/app/goauto/sybimport/service_test.go @@ -7,6 +7,7 @@ import ( "fmt" "strings" "testing" + "time" "go-admin/app/goauto/models" "go-admin/app/goauto/sybimport" @@ -123,6 +124,46 @@ func TestServiceListCapsPageSizeAt500(t *testing.T) { } } +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) { db := openTestDB(t) service := sybimport.NewService(db) diff --git a/server/app/goauto/sybimport/sync.go b/server/app/goauto/sybimport/sync.go index d3523cc..c28b312 100644 --- a/server/app/goauto/sybimport/sync.go +++ b/server/app/goauto/sybimport/sync.go @@ -491,54 +491,10 @@ func splitDateRange(dateFrom, dateTo string) ([]string, error) { // blip as a logout would trigger needless logins and could throw away a // perfectly good session. func Connect(ctx context.Context, store *sybclient.SessionStore, cfg ConnectConfig) (*sybclient.Client, error) { - if cfg.Username == "" || cfg.Password == "" { - return nil, errors.New("顺云宝账号或密码未配置,请设置 GOAUTO_SYB_USERNAME 和 GOAUTO_SYB_PASSWORD") - } - 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 + return sybclient.AcquireSession(ctx, store.DB(), sybclient.LoginConfig{ + BaseURL: cfg.BaseURL, Username: cfg.Username, Password: cfg.Password, + OcrURL: cfg.OcrURL, OcrMaxAttempts: cfg.OcrMaxAttempts, + }) } // ConnectConfig carries the login settings. The password is passed through and diff --git a/server/app/goauto/sybinnercode/match.go b/server/app/goauto/sybinnercode/match.go index 31c63f5..544b6f5 100644 --- a/server/app/goauto/sybinnercode/match.go +++ b/server/app/goauto/sybinnercode/match.go @@ -10,6 +10,7 @@ import ( "sort" "strings" "time" + "unicode" "go-admin/app/goauto/models" "go-admin/app/goauto/sybclient" @@ -193,13 +194,20 @@ func planRecord(ctx context.Context, reader MatchReader, record models.SYBInnerC if len(matches) == 1 && matches[0].ProductQty == count { chosen = []sybclient.DetailItem{matches[0]} } else if len(matches) == count && count > 1 { - sort.Slice(matches, func(i, j int) bool { return matches[i].ID < matches[j].ID }) for _, item := range matches { if item.ProductQty != 1 { return nil, models.SYBInnerCodeSkipped, "相同规格候选数量不明确,不能自动分配", nil } } - chosen = matches + codes := make([]string, count) + for i, it := range record.Items { + codes[i] = it.Code + } + assigned, reason := assignExistingBoundItems(record.Stall, codes, matches) + if reason != "" { + return nil, models.SYBInnerCodeSkipped, reason, nil + } + chosen = assigned } else if len(matches) > 1 { return nil, models.SYBInnerCodeSkipped, "同一订单存在多条相同规格候选商品,不能自动选择", nil } else { @@ -246,6 +254,83 @@ func planRecord(ctx context.Context, reader MatchReader, record models.SYBInnerC return plan, models.SYBInnerCodeReady, "唯一匹配,等待确认回写", nil } +// assignExistingBoundItems 把 N 个待写入入库码按顺序分配给 N 个数量为 1 的候选商品明细。 +// 修复 #289:候选明细的匹配顺序(按 ID 排序)未必与目标码顺序一致,若单纯按下标 +// 对应,会把已经正确绑定某个目标码的明细错误地重新分配给另一个码。这里先按“候选 +// 明细已有的入库码值”精确匹配对应的目标码,保留既有正确绑定不动;再把剩余尚未 +// 写入任何码的空白明细(按 ID 排序)依次填充给还没有候选的目标码位置。 +// 移植自 cmautobuy `planExistingMatchedInnerCodeItems` 的一致性护栏(代码评审补充): +// 候选来自 NormalizeSpecKey 归一化匹配,原始 ProductSpec/sku/variationSku 可能在 +// 归一化后相同但原始值不同,必须逐一比对最低 ID 候选,避免跨真正不同商品自动分配; +// 无 SKU 回退路径可能返回从未做过档口校验的候选,这里逐一重新校验;同时拒绝无效 +// 或重复的商品明细 ID。 +func assignExistingBoundItems(stall string, codes []string, matches []sybclient.DetailItem) ([]sybclient.DetailItem, string) { + sorted := append([]sybclient.DetailItem(nil), matches...) + sort.Slice(sorted, func(i, j int) bool { return sorted[i].ID < sorted[j].ID }) + seenIDs := make(map[int64]bool, len(sorted)) + for _, item := range sorted { + if item.ID <= 0 || seenIDs[item.ID] { + return nil, "重复候选包含无效或重复的商品明细 ID,不能自动逐件分配" + } + seenIDs[item.ID] = true + } + first := sorted[0] + wantSpec := first.ProductSpec + wantSKU := rawText(first.Raw["sku"]) + wantVariationSKU := rawText(first.Raw["variationSku"]) + for _, item := range sorted { + if item.ProductSpec != wantSpec || rawText(item.Raw["sku"]) != wantSKU || rawText(item.Raw["variationSku"]) != wantVariationSKU { + return nil, "重复候选的规格或 SKU 身份不一致,不能自动逐件分配" + } + } + if strings.TrimSpace(stall) != "" { + for _, item := range sorted { + if !stallMatches(stall, item) { + return nil, "重复候选的档口及货号不一致,不能自动逐件分配" + } + } + } + assigned := make([]sybclient.DetailItem, len(codes)) + taken := make([]bool, len(codes)) + codeIndex := make(map[string]int, len(codes)) + for i, code := range codes { + codeIndex[code] = i + } + blanks := make([]int, 0, len(sorted)) + for si, item := range sorted { + remote := rawText(item.Raw["innerExpCode"]) + if remote == "" { + blanks = append(blanks, si) + continue + } + idx, ok := codeIndex[remote] + if !ok { + return nil, "候选商品明细存在非目标入库码,不能自动逐件分配" + } + if taken[idx] { + return nil, "同一入库码在候选商品中出现多次,不能自动逐件分配" + } + assigned[idx] = item + taken[idx] = true + } + bi := 0 + for i := range assigned { + if taken[i] { + continue + } + if bi >= len(blanks) { + return nil, "现成空白明细不足,不能完成逐件分配" + } + assigned[i] = sorted[blanks[bi]] + taken[i] = true + bi++ + } + if bi != len(blanks) { + return nil, "现成空白明细多于待写入入库码,不能自动逐件分配" + } + return assigned, "" +} + func matchSpec(spec string, items []sybclient.DetailItem) []sybclient.DetailItem { result := []sybclient.DetailItem{} for _, item := range items { @@ -305,15 +390,131 @@ func strictStall(stall string, items []sybclient.DetailItem) []sybclient.DetailI return nil } result := []sybclient.DetailItem{} - name, article, has := strings.Cut(stall, "#") for _, item := range items { - blob := rawText(item.Raw["sku"]) + " " + rawText(item.Raw["variationSku"]) + " " + item.ProductSpec - if strings.Contains(blob, stall) || (has && strings.Contains(blob, strings.TrimSpace(name)) && strings.Contains(blob, strings.TrimSpace(article))) { + if stallMatches(stall, item) { result = append(result, item) } } return result } + +// stallMatches 移植自 cmautobuy `innerCodeStallMatches`(#259/#273 修复): +// 档口名与货号以最后一个 `#` 切分;货号只与字母数字 token 比较;纯数字货号要求 +// 候选中同时包含档口名才允许前导零等价(如 "067"≡"67");非数字货号要求精确 +// token 匹配;ProductSpec 只在以货号开头时才算命中;货号为空时回退为档口名包含判断。 +func stallMatches(stall string, item sybclient.DetailItem) bool { + sku := rawText(item.Raw["sku"]) + variation := rawText(item.Raw["variationSku"]) + blob := sku + " " + variation + " " + item.ProductSpec + if strings.Contains(blob, stall) { + return true + } + name, article, hasArticle := splitStall(stall) + if !hasArticle { + return false + } + if article == "" { + return name != "" && (strings.Contains(sku, name) || strings.Contains(variation, name)) + } + if isNumericArticle(article) { + nameMatches := name != "" && (strings.Contains(sku, name) || strings.Contains(variation, name)) + if !nameMatches { + return false + } + return textHasNumericArticle(sku, article) || + textHasNumericArticle(variation, article) || + productSpecStartsWithArticle(item.ProductSpec, article, true) + } + return textHasExactArticle(sku, article) || + textHasExactArticle(variation, article) || + productSpecStartsWithArticle(item.ProductSpec, article, false) +} + +// splitStall 从档口名称#货号取最后一个 #,避免档口名称本身含 # 时截错。 +func splitStall(stall string) (name, article string, ok bool) { + stall = strings.TrimSpace(stall) + separator := strings.LastIndex(stall, "#") + if separator < 0 { + return stall, "", false + } + return strings.TrimSpace(stall[:separator]), strings.TrimSpace(stall[separator+1:]), true +} + +func isNumericArticle(article string) bool { + if article == "" { + return false + } + for _, char := range article { + if !unicode.IsDigit(char) { + return false + } + } + return true +} + +func textHasNumericArticle(text, article string) bool { + target := normalizeNumericArticle(article) + for _, token := range articleTokens(text) { + if isNumericArticle(token) && normalizeNumericArticle(token) == target { + return true + } + } + return false +} + +func normalizeNumericArticle(article string) string { + normalized := strings.TrimLeft(article, "0") + if normalized == "" { + return "0" + } + return normalized +} + +func textHasExactArticle(text, article string) bool { + for _, token := range articleTokens(text) { + if token == article { + return true + } + } + return false +} + +func productSpecStartsWithArticle(productSpec, article string, numeric bool) bool { + productSpec = strings.TrimSpace(productSpec) + separator := strings.IndexAny(productSpec, " ,,") + if separator <= 0 { + return false + } + prefix := strings.TrimSpace(productSpec[:separator]) + if numeric { + return isNumericArticle(prefix) && normalizeNumericArticle(prefix) == normalizeNumericArticle(article) + } + return prefix == article +} + +// articleTokens 只把连续字母或数字视为货号候选,标点、【】、#、横线、空格自然成为 +// 边界:能识别 "067【档口】",又不会把 "PDD256437" 中间的数字误认为独立货号。 +func articleTokens(text string) []string { + tokens := make([]string, 0) + start := -1 + runes := []rune(text) + for index, char := range runes { + if unicode.IsLetter(char) || unicode.IsDigit(char) { + if start < 0 { + start = index + } + continue + } + if start >= 0 { + tokens = append(tokens, string(runes[start:index])) + start = -1 + } + } + if start >= 0 { + tokens = append(tokens, string(runes[start:])) + } + return tokens +} func rawText(value any) string { switch v := value.(type) { case string: diff --git a/server/app/goauto/sybinnercode/match_test.go b/server/app/goauto/sybinnercode/match_test.go index 3ef7209..ab920d6 100644 --- a/server/app/goauto/sybinnercode/match_test.go +++ b/server/app/goauto/sybinnercode/match_test.go @@ -147,6 +147,209 @@ func TestRunMatchJobReservesDifferentDetailsForRecordsInOneBatch(t *testing.T) { } } +// --- #259/#273/#289 stall-matching regression tests (ported from cmautobuy) --- + +func TestStallMatchesIgnoresWeightLikeNumberAsArticle(t *testing.T) { + // #273: "50公斤" must not be treated as if the article were the bare number 50. + item := detail(1, "50公斤,黑色", 1, "SKU-X", "", "") + if stallMatches("档口甲#50", item) { + t.Fatalf("weight-like text must not match numeric article 50") + } +} + +func TestStallMatchesRejectsSubstringInsideLongCode(t *testing.T) { + // #273: a long code with internal digits (PDD256437) must not spuriously + // match a short numeric article (256) via substring containment. + item := detail(1, "黑色,L", 1, "PDD256437", "档口甲", "") + if stallMatches("档口甲#256", item) { + t.Fatalf("long code must not match numeric article 256 via substring") + } +} + +func TestStallMatchesLeadingZeroEquivalenceRequiresStallName(t *testing.T) { + // #259/#273: "067" and "67" are equivalent articles only when the stall + // name also matches; a different stall name must not match. + sameStall := detail(1, "黑色,L", 1, "档口甲-067", "", "") + if !stallMatches("档口甲#67", sameStall) { + t.Fatalf("067 should be treated as equivalent to 67 when stall name matches") + } + differentStall := detail(2, "黑色,L", 1, "档口乙-067", "", "") + if stallMatches("档口甲#67", differentStall) { + t.Fatalf("067 must not match 67 when the stall name differs") + } +} + +func TestStallMatchesNonNumericArticleRequiresExactToken(t *testing.T) { + item := detail(1, "黑色,L", 1, "ABC12", "", "") + if stallMatches("档口甲#AB", item) { + t.Fatalf("non-numeric article must require an exact token match, not substring") + } + exact := detail(2, "黑色,L", 1, "AB", "", "") + if !stallMatches("档口甲#AB", exact) { + t.Fatalf("exact non-numeric token should match") + } +} + +func TestStallMatchesHandlesHashInsideStallName(t *testing.T) { + // Splits on the LAST '#' so a stall name that itself contains '#' still + // yields the correct article. + item := detail(1, "黑色,L", 1, "档口#甲-67", "", "") + if !stallMatches("档口#甲#67", item) { + t.Fatalf("stall name containing '#' should still resolve article via last '#'") + } +} + +func TestStallMatchesProductSpecPrefixMatchesArticle(t *testing.T) { + // Non-numeric article: ProductSpec prefix match does not additionally + // require the stall name to appear in sku/variationSku. + item := detail(1, "AB 黑色,L", 1, "", "", "") + if !stallMatches("档口甲#AB", item) { + t.Fatalf("ProductSpec starting with the article should match") + } + notPrefix := detail(2, "黑色,ABL", 1, "", "", "") + if stallMatches("档口甲#AB", notPrefix) { + t.Fatalf("article appearing mid-spec (not as prefix) must not match") + } +} + +func TestStallMatchesEmptyArticleFallsBackToStallName(t *testing.T) { + item := detail(1, "黑色,L", 1, "档口甲专柜", "", "") + if !stallMatches("档口甲#", item) { + t.Fatalf("empty article should fall back to stall-name containment") + } +} + +// --- #289: existing-binding-preserving multi-piece assignment --- + +func TestAssignExistingBoundItemsPreservesOutOfOrderBindings(t *testing.T) { + // Two single-piece candidates already carry codes, but the previously + // bound code (IC-2) sits on the LOWER-ID detail while the target order + // expects it second. A naive ID-order/index assignment would strip the + // existing correct binding from detail 20 and try to overwrite it. + d20 := detail(20, "黑色,L", 1, "SKU-1", "A#1", "IC-2") + d21 := detail(21, "黑色,L", 1, "SKU-1", "A#1", "") + assigned, reason := assignExistingBoundItems("A#1", []string{"IC-1", "IC-2"}, []sybclient.DetailItem{d20, d21}) + if reason != "" { + t.Fatalf("unexpected reason: %s", reason) + } + if assigned[1].ID != 20 { + t.Fatalf("expected detail 20 (already bound to IC-2) preserved at index 1, got %+v", assigned[1]) + } + if assigned[0].ID != 21 { + t.Fatalf("expected the blank detail 21 filled in at index 0, got %+v", assigned[0]) + } +} + +func TestAssignExistingBoundItemsRejectsForeignCode(t *testing.T) { + d20 := detail(20, "黑色,L", 1, "SKU-1", "A#1", "IC-OTHER") + d21 := detail(21, "黑色,L", 1, "SKU-1", "A#1", "") + _, reason := assignExistingBoundItems("A#1", []string{"IC-1", "IC-2"}, []sybclient.DetailItem{d20, d21}) + if reason == "" { + t.Fatalf("expected rejection for detail already holding a non-target code") + } +} + +func TestAssignExistingBoundItemsRejectsInconsistentIdentity(t *testing.T) { + // Candidates can share a NormalizeSpecKey-normalized spec while their raw + // ProductSpec/sku/variationSku differ; auto-assignment across genuinely + // different items must be rejected. + d20 := detail(20, "黑色, L", 1, "SKU-1", "A#1", "") + d21 := detail(21, "黑色,L", 1, "SKU-2", "A#1", "") + _, reason := assignExistingBoundItems("A#1", []string{"IC-1", "IC-2"}, []sybclient.DetailItem{d20, d21}) + if reason != "重复候选的规格或 SKU 身份不一致,不能自动逐件分配" { + t.Fatalf("expected identity-mismatch rejection, got %q", reason) + } +} + +func TestAssignExistingBoundItemsRejectsCandidateFailingStallCheck(t *testing.T) { + // The no-SKU fallback path in matchEvidence can hand back candidates that + // were never checked against the stall at all. Both candidates share an + // identical identity (so the identity guard passes) but neither one's + // sku/spec actually satisfies the record's stall/article requirement. + d20 := detail(20, "黑色,L", 1, "SKU-1", "ZZZ", "") + d21 := detail(21, "黑色,L", 1, "SKU-1", "ZZZ", "") + _, reason := assignExistingBoundItems("甲档口#88", []string{"IC-1", "IC-2"}, []sybclient.DetailItem{d20, d21}) + if reason != "重复候选的档口及货号不一致,不能自动逐件分配" { + t.Fatalf("expected stall-mismatch rejection, got %q", reason) + } +} + +func TestAssignExistingBoundItemsRejectsInvalidOrDuplicateDetailID(t *testing.T) { + invalidID := detail(0, "黑色,L", 1, "SKU-1", "A#1", "") + valid := detail(21, "黑色,L", 1, "SKU-1", "A#1", "") + if _, reason := assignExistingBoundItems("A#1", []string{"IC-1", "IC-2"}, []sybclient.DetailItem{invalidID, valid}); reason == "" { + t.Fatalf("expected rejection for non-positive detail ID") + } + dup1 := detail(20, "黑色,L", 1, "SKU-1", "A#1", "") + dup2 := detail(20, "黑色,L", 1, "SKU-1", "A#1", "") + if _, reason := assignExistingBoundItems("A#1", []string{"IC-1", "IC-2"}, []sybclient.DetailItem{dup1, dup2}); reason == "" { + t.Fatalf("expected rejection for duplicate detail IDs") + } +} + +func TestAssignExistingBoundItemsHappyPathStillAssignsWithGuards(t *testing.T) { + // Both candidates share identical raw spec/sku/variationSku and both + // individually satisfy the stall check; the guards must not block the + // legitimate happy path. + d20 := detail(20, "黑色,L", 1, "SKU-1", "A#1", "IC-2") + d21 := detail(21, "黑色,L", 1, "SKU-1", "A#1", "") + assigned, reason := assignExistingBoundItems("A#1", []string{"IC-1", "IC-2"}, []sybclient.DetailItem{d20, d21}) + if reason != "" { + t.Fatalf("unexpected reason: %s", reason) + } + if assigned[0].ID != 21 || assigned[1].ID != 20 { + t.Fatalf("assigned=%+v", assigned) + } +} + +func TestRunMatchJobPreservesExistingBindingWhenCandidateOrderDiffers(t *testing.T) { + // End-to-end regression for #289: N=2 single-piece candidates already + // carrying one previously bound code, with the bound detail's ID not + // matching sequential/ID order relative to the target codes. The plan + // must leave the already-correct binding untouched and only place the + // missing code onto the still-blank detail. + db := testDB(t) + // SourceSKURaw is intentionally left blank: with the same source SKU on + // both candidate rows the source-SKU path would reject as "duplicate" + // before ever reaching the stall-based multi-item assignment being + // regression-tested here. + record := models.SYBInnerCodeRecord{BusinessDate: "2026-08-28", OrderNumber: "ORDER-1", Stall: "A#1", SpecKey: "黑色,L", SpecRaw: "黑色,L", Status: models.SYBInnerCodePending, CreatedBy: 1, ImportRequestID: uuid.NewString(), Items: []models.SYBInnerCodeItem{{BusinessDate: "2026-08-28", Code: "IC-1", Ordinal: 1, SourceRow: 2}, {BusinessDate: "2026-08-28", Code: "IC-2", Ordinal: 2, SourceRow: 3}}} + records := []models.SYBInnerCodeRecord{record} + jobID := createMatchJob(t, db, records) + recordID := records[0].ID + // Detail 20 (lower ID) already carries IC-2 (bound out of sequence); + // detail 21 (higher ID) is still blank and should receive IC-1. + stock := sybclient.StockDetail{ID: 10, Details: []sybclient.DetailItem{ + detail(20, "黑色,L", 1, "SKU-1", "A#1", "IC-2"), + detail(21, "黑色,L", 1, "SKU-1", "A#1", ""), + }} + reader := &fakeMatchReader{rows: map[string][]sybclient.StockRow{"ORDER-1": {{ID: 10, Code: "ORDER-1"}}}, stocks: map[int64]sybclient.StockDetail{10: stock}} + if err := RunMatchJob(context.Background(), db, reader, jobID); err != nil { + t.Fatal(err) + } + var plan models.SYBInnerCodePlan + if err := db.First(&plan, "record_id = ?", recordID).Error; err != nil { + t.Fatal(err) + } + var items []plannedRemoteItem + if err := json.Unmarshal([]byte(plan.RemoteItemsJSON), &items); err != nil { + t.Fatal(err) + } + if len(items) != 2 { + t.Fatalf("items=%+v", items) + } + byCode := map[string]plannedRemoteItem{} + for _, item := range items { + byCode[item.Code] = item + } + if byCode["IC-2"].DetailID != 20 { + t.Fatalf("existing binding for IC-2 must stay on detail 20, got %+v", byCode["IC-2"]) + } + if byCode["IC-1"].DetailID != 21 { + t.Fatalf("missing IC-1 should be assigned to the blank detail 21, got %+v", byCode["IC-1"]) + } +} + type captureStarter struct { jobID string committed bool diff --git a/server/app/goauto/yeeke/reship_test.go b/server/app/goauto/yeeke/reship_test.go new file mode 100644 index 0000000..f071228 --- /dev/null +++ b/server/app/goauto/yeeke/reship_test.go @@ -0,0 +1,112 @@ +package yeeke + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "go-admin/app/goauto/models" + "go-admin/app/goauto/yeekeclient" +) + +func TestSyncBothStatusesPreservesIdentityAndAvailability(t *testing.T) { + db := testDB(t) + phase := 0 + var calls []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req struct { + Status string `json:"status"` + Page int `json:"pageNo"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Error(err) + return + } + calls = append(calls, fmt.Sprintf("%s/%d", req.Status, req.Page)) + w.Header().Set("Content-Type", "application/json") + if phase == 0 && req.Status == "2" { + fmt.Fprint(w, page(nil, 0, 0)) + return + } + if req.Status == "1" { + if req.Page == 1 { + fmt.Fprint(w, page([]string{record("p1", "i1", "v1", 1)}, 2, 2)) + } else { + fmt.Fprint(w, page([]string{record("p2", "i2", "v2", 1)}, 2, 2)) + } + } else { + fmt.Fprint(w, page([]string{record("p1", "i1", "v1", 2)}, 1, 1)) + } + })) + defer srv.Close() + c, _ := yeekeclient.New(srv.URL) + s := NewService(db, c, Config{PageSize: 1}) + if _, err := s.Sync(context.Background(), "manual"); err != nil { + t.Fatal(err) + } + var original models.YeekeReturnPackage + db.Where("external_id = ?", "p1").First(&original) + phase = 1 + calls = nil + for run := 0; run < 2; run++ { + rep, err := s.Sync(context.Background(), "manual") + if err != nil || rep.Status != "succeeded" || rep.MissingMarked != 0 { + t.Fatalf("rep=%+v err=%v", rep, err) + } + } + if fmt.Sprint(calls) != "[1/1 1/2 2/1 1/1 1/2 2/1]" { + t.Fatalf("independent pagination: %v", calls) + } + var current models.YeekeReturnPackage + db.First(¤t, original.ID) + if current.ClaimStatus != "2" || current.StatusUnrecognized || current.SyncStatus != "ok" { + t.Fatalf("current=%+v", current) + } + var count int64 + db.Model(&models.YeekeReturnPackage{}).Count(&count) + if count != 2 { + t.Fatalf("packages=%d", count) + } + db.Model(&models.YeekeReturnItem{}).Count(&count) + if count != 2 { + t.Fatalf("items=%d", count) + } +} + +func TestReshipPageFailureDoesNotMarkMissing(t *testing.T) { + db := testDB(t) + old := models.YeekeReturnPackage{ExternalID: "old", ClaimStatus: "2", SyncStatus: "ok"} + if err := db.Create(&old).Error; err != nil { + t.Fatal(err) + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req struct { + Status string `json:"status"` + } + json.NewDecoder(r.Body).Decode(&req) + if req.Status == "2" { + w.WriteHeader(http.StatusBadGateway) + return + } + fmt.Fprint(w, page([]string{record("new", "i", "v", 1)}, 1, 1)) + })) + defer srv.Close() + c, _ := yeekeclient.New(srv.URL) + s := NewService(db, c, Config{PageSize: 10}) + rep, err := s.Sync(context.Background(), "manual") + if err == nil || rep.Status != "failed" || rep.Created != 1 || rep.MissingMarked != 0 { + t.Fatalf("rep=%+v err=%v", rep, err) + } + db.First(&old, old.ID) + if old.SyncStatus != "ok" { + t.Fatal("incomplete combined sync marked reshipped package missing") + } + var run models.YeekeSyncRun + db.First(&run, rep.RunID) + if run.ErrorMessage == "" { + t.Fatal("missing state/page failure diagnostic") + } +} diff --git a/server/app/goauto/yeeke/sync.go b/server/app/goauto/yeeke/sync.go index ef68630..519da7b 100644 --- a/server/app/goauto/yeeke/sync.go +++ b/server/app/goauto/yeeke/sync.go @@ -54,10 +54,8 @@ type Report struct { } // knownClaimStatuses lists the status values the sync code currently -// understands. The list surface (POST .../relation/list) is queried with -// status=1, so "1" is the only value observed in practice; anything else is -// flagged rather than silently accepted or rejected (#336). -var knownClaimStatuses = map[string]bool{"1": true} +// understands: waiting to ship (1) and reshipped (2), confirmed by HAR. +var knownClaimStatuses = map[string]bool{"1": true, "2": true} func external(v any) string { return fmt.Sprint(v) } func stamp(t *yeekeclient.Timestamp) *time.Time { @@ -194,86 +192,103 @@ func (s *Service) run(ctx context.Context, r *models.YeekeSyncRun) (Report, erro } s.db.Model(r).Updates(updates) }() - seen := map[string]bool{} var firstWriteErr error + seenPackages := map[string]string{} // complete tracks whether the page walk ended NATURALLY (empty page, // short page, or reaching p.Pages) as opposed to the duplicate- // fingerprint break or MaxPages exhaustion (#338): only a naturally // complete run is trusted to mark absent items/packages "missing" below, // since a duplicate/MaxPages stop means the walk never actually finished // seeing everything yeeke currently has. - complete := false - for page := 1; page <= s.cfg.MaxPages; page++ { - var p yeekeclient.ReturnPage - var e error - for a := 0; ; a++ { - p, e = s.client.List(ctx, page, s.cfg.PageSize) - if e == nil || a >= s.cfg.Retry { + complete := true + for _, status := range []string{"1", "2"} { + seen := map[string]bool{} + statusComplete := false + for page := 1; page <= s.cfg.MaxPages; page++ { + var p yeekeclient.ReturnPage + var e error + for a := 0; ; a++ { + p, e = s.client.ListStatus(ctx, page, s.cfg.PageSize, status) + if e == nil || a >= s.cfg.Retry { + break + } + select { + case <-ctx.Done(): + runErr = ctx.Err() + errMsg = truncateRunError(runErr.Error()) + return rep, runErr + case <-time.After(time.Duration(a+1) * 100 * time.Millisecond): + } + } + if e != nil { + // A failed page never overwrites what earlier pages already wrote + // (#336): the run simply stops here and everything upserted so far + // stays as-is, reported through Read/Created/Updated above. + runErr = e + errMsg = truncateRunError(fmt.Sprintf("状态 %s 第 %d 页拉取失败:%v", status, page, e)) + return rep, runErr + } + rep.TotalPages++ + if len(p.Records) == 0 { + statusComplete = true break } - select { - case <-ctx.Done(): - runErr = ctx.Err() - errMsg = truncateRunError(runErr.Error()) - return rep, runErr - case <-time.After(time.Duration(a+1) * 100 * time.Millisecond): + finger := pageFingerprint(p) + if seen[finger] { + rep.Skipped += len(p.Records) + break } - } - if e != nil { - // A failed page never overwrites what earlier pages already wrote - // (#336): the run simply stops here and everything upserted so far - // stays as-is, reported through Read/Created/Updated above. - runErr = e - errMsg = truncateRunError(e.Error()) - return rep, runErr - } - rep.TotalPages = page - if len(p.Records) == 0 { - complete = true - break - } - finger := pageFingerprint(p) - if seen[finger] { - rep.Skipped += len(p.Records) - break - } - seen[finger] = true - for _, x := range p.Records { - created, updated, recovered, err := s.upsert(ctx, x) - if err != nil { - rep.Failed++ - if firstWriteErr == nil { - firstWriteErr = err + seen[finger] = true + for _, x := range p.Records { + key, currentStatus := packageKey(x), external(x.Status) + if previous, ok := seenPackages[key]; ok && (previous == "2" || previous == currentStatus) { + rep.Skipped++ + continue + } + created, updated, recovered, err := s.upsert(ctx, x) + seenPackages[key] = currentStatus + if err != nil { + rep.Failed++ + if firstWriteErr == nil { + firstWriteErr = err + } + continue + } + rep.Read++ + rep.Recovered += recovered + if created { + rep.Created++ + } else if updated { + rep.Updated++ + } else { + rep.Skipped++ } - continue } - rep.Read++ - rep.Recovered += recovered - if created { - rep.Created++ - } else if updated { - rep.Updated++ - } else { - rep.Skipped++ + if len(p.Records) < s.cfg.PageSize { + statusComplete = true + break + } + if p.Pages > 0 && page >= p.Pages { + statusComplete = true + break } } - if len(p.Records) < s.cfg.PageSize { - complete = true - break - } - if p.Pages > 0 && page >= p.Pages { - complete = true + if !statusComplete { + complete = false + errMsg = fmt.Sprintf("状态 %s 分页未完整结束(重复页或达到页数上限),未执行缺失标记", status) break } } + if !complete { + runErr = errors.New(errMsg) + return rep, runErr + } rep.Status = "succeeded" if rep.Failed > 0 && firstWriteErr != nil { // Surface why records failed instead of a bare counter. errMsg = truncateRunError(fmt.Sprintf("%d 条写入失败,首个原因:%v", rep.Failed, firstWriteErr)) - if rep.Read == 0 { - rep.Status = "failed" - runErr = errors.New(errMsg) - } + rep.Status = "failed" + runErr = errors.New(errMsg) } // #338: only a naturally complete run with zero write failures is // trusted to mark items/packages the sync no longer sees as "missing". @@ -288,7 +303,7 @@ func (s *Service) run(ctx context.Context, r *models.YeekeSyncRun) (Report, erro rep.MissingMarked = marked } } - return rep, nil + return rep, runErr } // markMissing implements #338's completion-triggered availability flip: any diff --git a/server/app/goauto/yeeke/sync_missing_test.go b/server/app/goauto/yeeke/sync_missing_test.go index 0e80fc8..a842bd6 100644 --- a/server/app/goauto/yeeke/sync_missing_test.go +++ b/server/app/goauto/yeeke/sync_missing_test.go @@ -204,11 +204,8 @@ func TestDuplicateFingerprintStopsMarking(t *testing.T) { defer srv2.Close() s.client, _ = yeekeclient.New(srv2.URL) rep, err := s.Sync(context.Background(), "manual") - if err != nil { - t.Fatalf("second sync: %v", err) - } - if rep.MissingMarked != 0 { - t.Fatalf("MissingMarked=%d, want 0 (duplicate-fingerprint stop is not complete)", rep.MissingMarked) + if err == nil || rep.Status != "failed" || rep.MissingMarked != 0 { + t.Fatalf("rep=%+v err=%v (duplicate-fingerprint stop is not complete)", rep, err) } var p2 models.YeekeReturnPackage @@ -250,11 +247,8 @@ func TestMaxPagesExhaustionStopsMarking(t *testing.T) { s.client, _ = yeekeclient.New(srv2.URL) s.cfg.MaxPages = 2 rep, err := s.Sync(context.Background(), "manual") - if err != nil { - t.Fatalf("second sync: %v", err) - } - if rep.MissingMarked != 0 { - t.Fatalf("MissingMarked=%d, want 0 (MaxPages exhaustion is not complete)", rep.MissingMarked) + if err == nil || rep.Status != "failed" || rep.MissingMarked != 0 { + t.Fatalf("rep=%+v err=%v (MaxPages exhaustion is not complete)", rep, err) } var p2 models.YeekeReturnPackage diff --git a/server/app/goauto/yeeke/sync_paging_test.go b/server/app/goauto/yeeke/sync_paging_test.go index a9a502d..3c494d8 100644 --- a/server/app/goauto/yeeke/sync_paging_test.go +++ b/server/app/goauto/yeeke/sync_paging_test.go @@ -108,10 +108,7 @@ func TestPagingSkipsARepeatedDuplicatePage(t *testing.T) { c, _ := yeekeclient.New(srv.URL) s := NewService(db, c, Config{PageSize: 1}) rep, err := s.Sync(context.Background(), "manual") - if err != nil { - t.Fatal(err) - } - if rep.Status != "succeeded" { + if err == nil || rep.Status != "failed" { t.Fatalf("rep=%+v", rep) } var n int64 @@ -138,7 +135,7 @@ func TestPagingStopsOnEmptyPage(t *testing.T) { if err != nil { t.Fatal(err) } - if rep.Status != "succeeded" || rep.TotalPages != 1 || rep.Read != 0 { + if rep.Status != "succeeded" || rep.TotalPages != 2 || rep.Read != 0 { t.Fatalf("rep=%+v", rep) } } diff --git a/server/app/goauto/yeekeclient/client.go b/server/app/goauto/yeekeclient/client.go index 3a64fca..a462798 100644 --- a/server/app/goauto/yeekeclient/client.go +++ b/server/app/goauto/yeekeclient/client.go @@ -371,8 +371,16 @@ func (f *FlexInt) UnmarshalJSON(b []byte) error { } func (c *Client) List(ctx context.Context, pageNo, pageSize int) (ReturnPage, error) { + return c.ListStatus(ctx, pageNo, pageSize, "1") +} + +// ListStatus reads only the two HAR-confirmed return statuses. +func (c *Client) ListStatus(ctx context.Context, pageNo, pageSize int, status string) (ReturnPage, error) { + if status != "1" && status != "2" { + return ReturnPage{}, fmt.Errorf("unsupported yeeke return status") + } // Same shape the web client posts (HAR): sort via column/order, filters as strings. - body := map[string]any{"pageNo": pageNo, "pageSize": pageSize, "claimFlag": "1", "status": "1", "relationFlag": "1", "column": "createTime", "order": "desc"} + body := map[string]any{"pageNo": pageNo, "pageSize": pageSize, "claimFlag": "1", "status": status, "relationFlag": "1", "column": "createTime", "order": "desc"} raw, e := c.do(ctx, http.MethodPost, "/agent-foreign/packageClaimRec/relation/list", body, nil) if e != nil { return ReturnPage{}, e diff --git a/server/app/goauto/yeekeclient/reship_test.go b/server/app/goauto/yeekeclient/reship_test.go new file mode 100644 index 0000000..3fca99e --- /dev/null +++ b/server/app/goauto/yeekeclient/reship_test.go @@ -0,0 +1,36 @@ +package yeekeclient + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestListStatusUsesConfirmedHARFilters(t *testing.T) { + var statuses []string + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + json.NewDecoder(r.Body).Decode(&body) + if r.Method != http.MethodPost || r.URL.Path != "/agent-foreign/packageClaimRec/relation/list" || body["claimFlag"] != "1" || body["relationFlag"] != "1" || body["column"] != "createTime" || body["order"] != "desc" { + t.Error("HAR filters changed") + } + statuses = append(statuses, body["status"].(string)) + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"success":true,"result":{"records":[],"pages":0,"total":0}}`)) + })) + defer s.Close() + c, _ := New(s.URL) + for _, status := range []string{"1", "2"} { + if _, err := c.ListStatus(context.Background(), 1, 20, status); err != nil { + t.Fatal(err) + } + } + if _, err := c.ListStatus(context.Background(), 1, 20, "3"); err == nil { + t.Fatal("unsupported status allowed") + } + if len(statuses) != 2 || statuses[0] != "1" || statuses[1] != "2" { + t.Fatal(statuses) + } +} diff --git a/server/cmd/migrate/migration/version-local/1789801100000_after_sales_role.go b/server/cmd/migrate/migration/version-local/1789801100000_after_sales_role.go new file mode 100644 index 0000000..3d7319f --- /dev/null +++ b/server/cmd/migrate/migration/version-local/1789801100000_after_sales_role.go @@ -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 +} diff --git a/server/cmd/migrate/migration/version-local/1789801100000_after_sales_role_test.go b/server/cmd/migrate/migration/version-local/1789801100000_after_sales_role_test.go new file mode 100644 index 0000000..4c3c693 --- /dev/null +++ b/server/cmd/migrate/migration/version-local/1789801100000_after_sales_role_test.go @@ -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) + } +} diff --git a/server/cmd/migrate/migration/version-local/1789801200000_after_sales_return_match.go b/server/cmd/migrate/migration/version-local/1789801200000_after_sales_return_match.go new file mode 100644 index 0000000..77f88f5 --- /dev/null +++ b/server/cmd/migrate/migration/version-local/1789801200000_after_sales_return_match.go @@ -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 + }) +} diff --git a/server/cmd/migrate/migration/version-local/1789801200000_after_sales_return_match_test.go b/server/cmd/migrate/migration/version-local/1789801200000_after_sales_return_match_test.go new file mode 100644 index 0000000..a26af29 --- /dev/null +++ b/server/cmd/migrate/migration/version-local/1789801200000_after_sales_return_match_test.go @@ -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) + } +} diff --git a/server/cmd/migrate/migration/version-local/1789801300000_fix_return_match_api_catalog.go b/server/cmd/migrate/migration/version-local/1789801300000_fix_return_match_api_catalog.go new file mode 100644 index 0000000..c7e1a27 --- /dev/null +++ b/server/cmd/migrate/migration/version-local/1789801300000_fix_return_match_api_catalog.go @@ -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 + }) +} diff --git a/server/cmd/migrate/migration/version-local/1789801400000_syb_session_auth_lease.go b/server/cmd/migrate/migration/version-local/1789801400000_syb_session_auth_lease.go new file mode 100644 index 0000000..aa5128b --- /dev/null +++ b/server/cmd/migrate/migration/version-local/1789801400000_syb_session_auth_lease.go @@ -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 + }) +} diff --git a/server/cmd/migrate/migration/version-local/1789801100000_syb_pdd_purchase_excluded.go b/server/cmd/migrate/migration/version-local/1789801500000_syb_pdd_purchase_excluded.go similarity index 100% rename from server/cmd/migrate/migration/version-local/1789801100000_syb_pdd_purchase_excluded.go rename to server/cmd/migrate/migration/version-local/1789801500000_syb_pdd_purchase_excluded.go diff --git a/web/src/api/goauto/return-matches.js b/web/src/api/goauto/return-matches.js index 792b9dc..074ccf1 100644 --- a/web/src/api/goauto/return-matches.js +++ b/web/src/api/goauto/return-matches.js @@ -12,12 +12,12 @@ 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 confirmReturnMatch(matchId, options = {}) { + return request({ url: `/api/admin/v1/return-matches/${matchId}/confirm`, method: 'post', ...options }) } -export function cancelReturnMatch(matchId) { - return request({ url: `/api/admin/v1/return-matches/${matchId}/cancel`, method: 'post' }) +export function cancelReturnMatch(matchId, data, options = {}) { + return request({ url: `/api/admin/v1/return-matches/${matchId}/cancel`, method: 'post', data, ...options }) } export function remarkReturnMatch(matchId, data) { diff --git a/web/src/views/goauto/pdd-products/PddProductDetailDrawer.vue b/web/src/views/goauto/pdd-products/PddProductDetailDrawer.vue index d7316b3..a650bff 100644 --- a/web/src/views/goauto/pdd-products/PddProductDetailDrawer.vue +++ b/web/src/views/goauto/pdd-products/PddProductDetailDrawer.vue @@ -91,7 +91,7 @@ export default { } }, 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)) }, stockColorOptions() { return this.stockSpecValues('color').filter(item => item.priceCent !== null && item.priceCent !== undefined && item.priceCent >= 0) }, stockSizeOptions() { return this.stockSpecValues('size') }, diff --git a/web/src/views/goauto/syb-products/index.vue b/web/src/views/goauto/syb-products/index.vue index 7e0c161..d0e1517 100644 --- a/web/src/views/goauto/syb-products/index.vue +++ b/web/src/views/goauto/syb-products/index.vue @@ -3,16 +3,21 @@ 未匹配 @@ -185,6 +190,44 @@ + +
+ 已处理 {{ returnCancelResult.processed }} / {{ returnCancelResult.total }} 条 + 成功 {{ returnCancelResult.successCount }} 条 + 跳过 {{ returnCancelResult.skippedCount }} 条 + 失败 {{ returnCancelResult.failedCount }} 条 +
+ + +

仅取消原退货匹配,不修改已有采集或采购任务。失败项仅在当前页仍可取消时保留勾选;不在当前页的失败项仍在下表中。

+ + + + + + + +
+ + +
+ 已处理 {{ returnConfirmResult.processed }} / {{ returnConfirmResult.total }} 条 + 成功 {{ returnConfirmResult.successCount }} 条 + 跳过 {{ returnConfirmResult.skippedCount }} 条 + 失败 {{ returnConfirmResult.failedCount }} 条 +
+ + +

仅标记已用退货,不修改已有采购任务,也不会自动让 Yeeke 发货。失败项仅在当前页仍为原待确认匹配时保留勾选;离页失败项仍保留在下表。

+ + + + + + + +
+
成功匹配 {{ returnMatchBatchResult.matchedCount }} 条 @@ -209,7 +252,7 @@

SYB 商品

- + {{ matchDetail.data.syb.orderCode }} {{ matchDetail.data.syb.shopeeItemId }} {{ matchDetail.data.syb.shopName }} @@ -225,7 +268,7 @@

yeeke 退货商品

- + {{ matchDetail.data.yeeke.orderSn }} {{ matchDetail.data.yeeke.itemId }} {{ matchDetail.data.yeeke.variationId }} @@ -242,7 +285,7 @@

备注

- 保存备注 + 保存备注

操作日志

@@ -255,8 +298,8 @@
@@ -319,6 +362,8 @@ import { ElMessage } from 'element-plus' import { Search, RefreshLeft, ArrowDown } from '@element-plus/icons-vue' import quickLink from './quick-link' +import returnCancel from './return-cancel' +import returnConfirm from './return-confirm' import { listSybProducts, getSybProduct, reparseSybProduct, correctSybProduct } from '@/api/goauto/syb-products' import { listSybShops } from '@/api/goauto/syb-shops' import { listDevices } from '@/api/goauto/devices' @@ -343,7 +388,7 @@ const RETURN_MATCH_BATCH_MAX = 500 export default { name: 'GoAutoSybProducts', components: { ShopeeProductDetailDrawer, PddProductDetailDrawer }, - mixins: [quickLink], + mixins: [quickLink, returnCancel, returnConfirm], setup() { return { Search, RefreshLeft, ArrowDown } }, data() { return { @@ -359,7 +404,7 @@ export default { imageSearchBatch: this.emptyImageSearchBatch(), collectionBatchData: { ruleId: null, deviceId: null }, collectionBatchRules: { ruleId: [{ required: true, message: '请选择采集规则', trigger: 'change' }] }, - query: { page: 1, pageSize: 100, shopName: '', orderCodesText: '', parseStatus: '', processStage: '' }, + query: { page: 1, pageSize: 200, shopName: '', orderCodesText: '', parseStatus: '', processStage: '', createdAtRange: [] }, returnMatchByProductId: {}, returnMatchLoading: false, returnMatchBatchLoading: false, @@ -374,10 +419,10 @@ export default { computed: { purchaseDeviceUnavailable() { return !!this.purchaseDialog.deviceId && !this.purchaseDevices.some(device => device.id === this.purchaseDialog.deviceId) }, 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: '待人工核对' }, { 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.isAIMatchCandidate(row)) }, aiMatchButtonOverLimit() { return this.aiMatchCandidates.length > PURCHASE_BATCH_MAX }, aiMatchButtonReason() { if (this.purchaseReadinessLoading) return '正在检查 AI 匹配资格' @@ -396,7 +441,7 @@ export default { // #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 => this.isImageSearchCandidate(row)) }, // 服务端按去重后的蝦皮商品数创建任务(见 image_search.go 的 imageSearchMaxBatchTasks), // 这里用相同口径预估,避免提交后才发现超限。 imageSearchMaxBatchTasks() { return IMAGE_SEARCH_MAX_BATCH_TASKS }, @@ -423,7 +468,7 @@ export default { }, created() { this.load() }, methods: { - async load(allowNetworkRetry = true) { + async load(allowNetworkRetry = true, waitForReadiness = false) { const orderCodes = this.normalizedOrderCodes() if (orderCodes === null) return const generation = ++this.loadGeneration @@ -432,13 +477,17 @@ export default { this.$refs.productTable?.clearSelection() const requestOptions = allowNetworkRetry ? { suppressNetworkError: true } : {} 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 this.products = r.data.items this.total = r.data.total this.loading = false - void this.loadPurchaseReadiness(this.products.map(item => item.id), requestOptions, generation) - void this.loadReturnMatches(this.products.map(item => item.id), generation) + const pending = Promise.all([ + this.loadPurchaseReadiness(this.products.map(item => item.id), requestOptions, generation), + this.loadReturnMatches(this.products.map(item => item.id), generation) + ]) + if (waitForReadiness) await pending } catch (error) { if (generation !== this.loadGeneration) return if (allowNetworkRetry && error?.message === 'Network Error' && !error?.response) { @@ -452,7 +501,7 @@ export default { } }, search() { this.query.page = 1; this.load() }, - reset() { this.query = { page: 1, pageSize: 100, 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() }, async ensureShopOptions() { if (this.shopOptionsLoaded) return @@ -476,13 +525,13 @@ export default { return values }, handleSelectionChange(rows) { this.selectedProducts = rows }, - isPurchaseCandidate(row) { const ready = this.purchaseReady(row); return ready.eligible === true && ready.processStage === 'purchase_ready' }, - isCollectionCandidate(row) { const ready = this.purchaseReady(row); return ready.collectionEligible === true && Number.isInteger(ready.pddProductId) && ready.pddProductId > 0 }, - isAIMatchCandidate(row) { return this.purchaseReady(row).aiMatchEligible === true }, + isPurchaseCandidate(row) { const ready = this.purchaseReady(row); return !this.isActivelyReturnMatched(row) && ready.eligible === true && ready.processStage === 'purchase_ready' }, + isCollectionCandidate(row) { const ready = this.purchaseReady(row); return !this.isActivelyReturnMatched(row) && ready.collectionEligible === true && Number.isInteger(ready.pddProductId) && ready.pddProductId > 0 }, + isAIMatchCandidate(row) { return !this.isActivelyReturnMatched(row) && this.purchaseReady(row).aiMatchEligible === true }, // 图搜采集的用途就是给未关联 PDD 的虾皮商品找到 PDD 商品,所以它的可勾选条件 // 不能要求已关联——另外三个判据都要求,于是图搜入口对它最该服务的那类商品 // 一直不可达(#291)。这里与 imageSearchRows 的过滤条件保持一致。 - isImageSearchCandidate(row) { return Boolean(row.shopeeProductId && row.imageUrl) }, + isImageSearchCandidate(row) { return !this.isActivelyReturnMatched(row) && 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']) @@ -491,7 +540,7 @@ export default { isActivelyReturnMatched(row) { const stage = this.purchaseReady(row).processStage; return stage === 'return_pending' || stage === 'return_used' }, // `[必须]` 可勾选不等于可采购。放宽的只有勾选:purchaseCandidates / collectionCandidates // / aiMatchCandidates / returnMatch 仍各自用自己的判据过滤,采购门禁不受影响。 - isSelectableCandidate(row) { return this.isPurchaseCandidate(row) || this.isCollectionCandidate(row) || this.isAIMatchCandidate(row) || this.isImageSearchCandidate(row) || this.isReturnMatchCandidate(row) }, + isSelectableCandidate(row) { return this.purchaseReady(row).processStage === 'return_pending' || 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) }, 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)}` }, @@ -521,6 +570,7 @@ export default { } this.purchaseReadiness = merged await this.$nextTick() + if (generation !== this.loadGeneration) return const valid = this.products.filter(row => selectedIDs.has(row.id) && this.isSelectableCandidate(row)) this.$refs.productTable?.clearSelection() valid.forEach(row => this.$refs.productTable?.toggleRowSelection(row, true)) @@ -556,7 +606,7 @@ export default { async runBatchSpecMatch() { if (!this.aiMatchCandidates.length || this.specMatchLoading) return - const selectedIDs = this.selectedProducts.map(row => row.id) + const selectedIDs = this.selectedProducts.filter(row => !this.isActivelyReturnMatched(row)).map(row => row.id) this.specMatchLoading = true try { const response = await matchPurchaseSpecsBatch({ sybProductIds: selectedIDs }) @@ -579,8 +629,10 @@ export default { 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) { + if (generation !== this.loadGeneration) return this.returnMatchByProductId = {} - if (!ids.length) return + this.returnMatchLoadError = false + if (!ids.length) { this.returnMatchLoading = false; return } this.returnMatchLoading = true try { const r = await listReturnMatches({ sybProductId: ids }, { suppressErrorMessage: true }) @@ -592,7 +644,7 @@ export default { } this.returnMatchByProductId = map } catch { - // 展示失败不影响主列表;用户可重新查询重试。 + if (generation === this.loadGeneration) this.returnMatchLoadError = true } finally { if (generation === this.loadGeneration) this.returnMatchLoading = false } @@ -606,7 +658,7 @@ export default { }, async runBatchMatchReturns() { const ids = this.returnMatchCandidateIds - if (!ids.length || this.returnMatchBatchLoading) return + if (!ids.length || this.returnMatchBatchLoading || this.returnConfirmBatchLoading || this.returnCancelBatchLoading || this.returnTransitionLoading || this.matchDetail.saving) return this.returnMatchBatchLoading = true try { const r = await batchMatchReturns({ sybProductIds: ids }) @@ -641,6 +693,7 @@ export default { await this.load() }, async saveMatchRemark() { + if (this.returnConfirmBatchLoading || this.returnCancelBatchLoading || this.returnTransitionLoading || this.returnMatchBatchLoading || this.matchDetail.saving) return this.matchDetail.saving = true try { await remarkReturnMatch(this.matchDetail.id, { remark: this.matchDetail.remarkText }) @@ -651,29 +704,35 @@ export default { } }, async confirmMatchDetail() { - try { - await this.$confirm('确认后该 SYB 商品状态变为「已用退货」,仍不能创建采购任务。确定继续?', '确认匹配', { type: 'warning' }) - } catch { return } - await confirmReturnMatch(this.matchDetail.id) - ElMessage.success('已确认匹配') - await this.refreshMatchDetail() + return this.runReturnTransition(async() => { + 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() + return this.runReturnTransition(async() => { + 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() + return this.runReturnTransition(async() => { + try { + await this.$confirm('取消后该 SYB 商品恢复可创建采购,对应退货商品回到可用池。确定取消匹配?', '取消匹配', { type: 'warning', confirmButtonClass: 'el-button--danger' }) + } catch { return } + await cancelReturnMatch(matchId) + ElMessage.success('已取消匹配') + await this.load() + }) }, // ---------------- 批量创建采购任务 ---------------- @@ -856,10 +915,12 @@ export default { - diff --git a/web/src/views/goauto/syb-products/return-cancel.js b/web/src/views/goauto/syb-products/return-cancel.js new file mode 100644 index 0000000..c894718 --- /dev/null +++ b/web/src/views/goauto/syb-products/return-cancel.js @@ -0,0 +1,110 @@ +import { cancelReturnMatch, getReturnMatch } from '@/api/goauto/return-matches' + +const requestOptions = { suppressErrorMessage: true, suppressNetworkError: true } +function isUnauthorized(error) { + return error?.response?.status === 401 || error?.response?.data?.code === 401 || error?.message === 'Unauthorized' +} + +export default { + data() { + return { + returnCancelBatchLoading: false, returnTransitionLoading: false, returnMatchLoadError: false, + returnCancelStopped: false, + returnCancelResult: { open: false, items: [], total: 0, processed: 0, successCount: 0, skippedCount: 0, failedCount: 0, refreshError: '' } + } + }, + computed: { + returnCancelCandidates() { return this.selectedProducts.filter(row => this.isReturnCancelCandidate(row)) }, + returnCancelButtonReason() { + if (this.loading || this.purchaseReadinessLoading || this.returnMatchLoading) return '正在读取处理阶段与退货匹配' + if (this.returnMatchLoadError) return '退货匹配读取失败,请刷新重试' + if (this.returnConfirmBatchLoading || this.returnCancelBatchLoading || this.returnMatchBatchLoading || this.returnTransitionLoading || this.matchDetail.saving) return '正在处理退货匹配,请稍候' + if (!this.returnCancelCandidates.length) return '请勾选退货待确认的已匹配商品;已用退货不参与批量取消' + return '' + } + }, + beforeUnmount() { this.returnCancelStopped = true; this.loadGeneration++ }, + methods: { + isReturnCancelCandidate(row) { + const match = this.returnMatchByProductId[row.id] + return this.purchaseReady(row).processStage === 'return_pending' && match?.status === 'matched' && Number(match.id) > 0 + }, + async runReturnTransition(action) { + if (this.returnConfirmBatchLoading || this.returnCancelBatchLoading || this.returnMatchBatchLoading || this.returnTransitionLoading || this.matchDetail.saving) return + this.returnTransitionLoading = true + try { await action() } finally { this.returnTransitionLoading = false } + }, + async runBatchCancelReturns() { + if (!this.canPurchase || this.returnCancelButtonReason) return + // Freeze both identities before the confirmation; never follow a later rematch. + const seen = new Set() + const snapshot = this.selectedProducts.filter(row => { + if (seen.has(row.id)) return false + seen.add(row.id); return true + }).map(row => ({ sybProductId: row.id, orderCode: row.orderCode, matchId: this.returnMatchByProductId[row.id]?.id, eligible: this.isReturnCancelCandidate(row) })) + const count = snapshot.filter(item => item.eligible).length + this.returnCancelBatchLoading = true + try { + try { + await this.$confirm(`取消选中的 ${count} 条退货匹配?取消后商品将重新计算采购准备状态。`, '批量取消匹配', { type: 'warning', confirmButtonText: '确认取消匹配', cancelButtonText: '返回', confirmButtonClass: 'el-button--danger' }) + } catch { return } + if (this.returnCancelStopped) return + this.returnCancelResult = { open: true, items: [], total: snapshot.length, processed: 0, successCount: 0, skippedCount: 0, failedCount: 0, refreshError: '' } + const result = this.returnCancelResult + let authExpired = false + for (const original of snapshot) { + const item = { ...original, status: 'skipped', reason: '', retryable: false } + if (!original.eligible) item.reason = '非退货待确认的已匹配商品,跳过' + else if (authExpired || this.returnCancelStopped) { item.status = 'failed'; item.reason = '登录失效或已离开页面,未继续执行' } else { + try { + const response = await cancelReturnMatch(original.matchId, { expectedStatus: 'matched' }, requestOptions) + // The shared interceptor returns false for the legacy 6401 login expiry. + if (response === false) throw new Error('Unauthorized') + item.status = 'success'; item.reason = '已取消匹配,重新计算采购准备状态' + } catch (error) { + if (isUnauthorized(error)) { + authExpired = true; item.status = 'failed'; item.reason = '登录失效,请重新登录后检查结果' + } else if (error?.response?.status === 409 && error?.response?.data?.code === 'STATE_CONFLICT') { + item.reason = '原匹配状态已变化,未取消' + } else { + item.status = 'failed' + item.reason = error?.response ? '取消失败,请刷新后检查原匹配' : '网络异常,取消结果未确认' + // Read-only recovery; never repeat an uncertain POST automatically. + try { + const response = await getReturnMatch(original.matchId, requestOptions) + if (response === false) throw new Error('Unauthorized') + const match = response.data?.match + if (match?.status === 'cancelled' || match?.status === 'confirmed') { + item.status = 'skipped'; item.reason = `回读原匹配已${match.status === 'cancelled' ? '取消' : '确认'},未重复操作` + } else { + item.retryable = match?.status === 'matched' && Number(match.id) === Number(original.matchId) && Number(match.sybProductId) === Number(original.sybProductId) + if (item.retryable) item.reason += ';原匹配仍待确认,可手动重试' + } + } catch (readError) { + if (isUnauthorized(readError)) authExpired = true + item.reason += ';回读失败,请刷新检查,不自动重试' + } + } + } + } + result.items.push(item); result.processed++ + result[`${item.status}Count`]++ + } + if (this.returnCancelStopped || authExpired) return + const generation = this.loadGeneration + 1 + try { + await this.load(false, true) + if (generation !== this.loadGeneration || this.returnCancelStopped) return + if (this.returnMatchLoadError || Object.values(this.purchaseReadiness).some(ready => ready.processNextAction === 'refresh')) { + result.refreshError = '刷新处理阶段或退货匹配失败,请刷新列表后重试'; return + } + const failed = new Map(result.items.filter(item => item.status === 'failed' && item.retryable).map(item => [item.sybProductId, item.matchId])) + await this.$nextTick() + if (generation !== this.loadGeneration || this.returnCancelStopped) return + this.$refs.productTable?.clearSelection() + this.products.filter(row => failed.get(row.id) === this.returnMatchByProductId[row.id]?.id && this.isReturnCancelCandidate(row)).forEach(row => this.$refs.productTable?.toggleRowSelection(row, true)) + } catch { result.refreshError = '列表刷新失败;执行结果已保留,请手动刷新,勿盲目重复取消' } + } finally { this.returnCancelBatchLoading = false } + } + } +} diff --git a/web/src/views/goauto/syb-products/return-confirm.js b/web/src/views/goauto/syb-products/return-confirm.js new file mode 100644 index 0000000..2cbc7f1 --- /dev/null +++ b/web/src/views/goauto/syb-products/return-confirm.js @@ -0,0 +1,102 @@ +import { confirmReturnMatch, getReturnMatch } from '@/api/goauto/return-matches' + +const requestOptions = { suppressErrorMessage: true, suppressNetworkError: true } +function isUnauthorized(error) { + return error?.response?.status === 401 || error?.response?.data?.code === 401 || error?.message === 'Unauthorized' +} +const emptyResult = () => ({ open: false, items: [], total: 0, processed: 0, successCount: 0, skippedCount: 0, failedCount: 0, refreshError: '' }) + +export default { + data() { + return { returnConfirmBatchLoading: false, returnConfirmStopped: false, returnConfirmResult: emptyResult() } + }, + computed: { + returnConfirmCandidates() { return this.returnConfirmSnapshot().filter(item => item.eligible) }, + returnConfirmButtonReason() { + if (this.loading || this.purchaseReadinessLoading || this.returnMatchLoading) return '正在读取处理阶段与退货匹配' + if (this.returnMatchLoadError) return '退货匹配读取失败,请刷新重试' + if (this.returnConfirmBatchLoading || this.returnCancelBatchLoading || this.returnMatchBatchLoading || this.returnTransitionLoading || this.matchDetail.saving) return '正在处理退货匹配,请稍候' + if (!this.returnConfirmCandidates.length) return '请勾选当前页退货待确认的已匹配商品;已用退货不参与批量使用' + return '' + } + }, + beforeUnmount() { this.returnConfirmStopped = true }, + methods: { + returnConfirmSnapshot() { + const current = new Set(this.products.map(row => row.id)) + const seen = new Set() + return this.selectedProducts.filter(row => { + if (!current.has(row.id) || seen.has(row.id)) return false + seen.add(row.id); return true + }).map(row => ({ sybProductId: row.id, orderCode: row.orderCode, matchId: this.returnMatchByProductId[row.id]?.id, eligible: this.isReturnCancelCandidate(row) })) + }, + async runBatchConfirmReturns() { + if (!this.canPurchase || this.returnConfirmButtonReason) return + // Freeze original identities before asking. Never follow a later replacement match. + const snapshot = this.returnConfirmSnapshot() + const count = snapshot.filter(item => item.eligible).length + this.returnConfirmBatchLoading = true + try { + try { + await this.$confirm(`确认使用选中的 ${count} 条退货商品?确认后将标记为已用退货,不再创建采购任务。不修改已有采购任务,也不会自动让 Yeeke 发货。`, '使用退货', { type: 'warning', confirmButtonText: '确认使用', cancelButtonText: '返回' }) + } catch { return } + if (this.returnConfirmStopped) return + this.returnConfirmResult = { ...emptyResult(), open: true, total: snapshot.length } + const result = this.returnConfirmResult + let authExpired = false + for (const original of snapshot) { + const item = { ...original, status: 'skipped', reason: '', retryable: false } + if (!original.eligible) item.reason = '非退货待确认的原匹配,跳过' + else if (authExpired || this.returnConfirmStopped) { item.status = 'failed'; item.reason = '登录失效或已离开页面,未继续执行' } else { + try { + const response = await confirmReturnMatch(original.matchId, requestOptions) + if (response === false) throw new Error('Unauthorized') + item.status = 'success'; item.reason = '已用退货;不修改已有采购任务,不自动发货' + } catch (error) { + if (isUnauthorized(error)) { + authExpired = true; item.status = 'failed'; item.reason = '登录失效,请重新登录后检查原匹配' + } else if (error?.response?.status === 409 && error?.response?.data?.code === 'STATE_CONFLICT') { + item.reason = '原匹配已确认或已取消,未重复使用' + } else { + item.status = 'failed' + item.reason = error?.response ? '使用失败,请刷新检查原匹配' : '网络异常,使用结果未确认' + // Unknown write outcomes are recovered by reading only, not by repeating POST. + try { + const response = await getReturnMatch(original.matchId, requestOptions) + if (response === false) throw new Error('Unauthorized') + const match = response.data?.match + const sameOriginal = Number(match?.id) === Number(original.matchId) && Number(match?.sybProductId) === Number(original.sybProductId) + if (sameOriginal && ['confirmed', 'cancelled'].includes(match.status)) { + item.status = 'skipped'; item.reason = `回读原匹配已${match.status === 'confirmed' ? '确认使用' : '取消'},未重复操作` + } else { + item.retryable = sameOriginal && match.status === 'matched' + item.reason += item.retryable ? ';原匹配仍待确认,可手动重试' : ';原匹配无法确认,请刷新检查' + } + } catch (readError) { + if (isUnauthorized(readError)) authExpired = true + item.reason += ';回读失败,请刷新检查,不自动重试' + } + } + } + } + result.items.push(item); result.processed++ + result[`${item.status}Count`]++ + } + if (this.returnConfirmStopped || authExpired) return + const generation = this.loadGeneration + 1 + try { + await this.load(false, true) + if (generation !== this.loadGeneration || this.returnConfirmStopped) return + if (this.returnMatchLoadError || Object.values(this.purchaseReadiness).some(ready => ready.processNextAction === 'refresh')) { + result.refreshError = '刷新处理阶段或退货匹配失败,请刷新列表后重试'; return + } + const failed = new Map(result.items.filter(item => item.status === 'failed' && item.retryable).map(item => [item.sybProductId, item.matchId])) + await this.$nextTick() + if (generation !== this.loadGeneration || this.returnConfirmStopped) return + this.$refs.productTable?.clearSelection() + this.products.filter(row => failed.get(row.id) === this.returnMatchByProductId[row.id]?.id && this.isReturnCancelCandidate(row)).forEach(row => this.$refs.productTable?.toggleRowSelection(row, true)) + } catch { result.refreshError = '列表刷新失败;执行结果已保留,请手动刷新,勿盲目重复使用' } + } finally { this.returnConfirmBatchLoading = false } + } + } +} diff --git a/web/src/views/goauto/syb-sync-runs/index.vue b/web/src/views/goauto/syb-sync-runs/index.vue index 845e413..6b70031 100644 --- a/web/src/views/goauto/syb-sync-runs/index.vue +++ b/web/src/views/goauto/syb-sync-runs/index.vue @@ -89,7 +89,7 @@ export default { } }, 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') } }, created() { this.load().then(() => { const id = Number(this.$route.query.runId); if (id > 0) this.openDetail(id) }) }, diff --git a/web/src/views/goauto/yeeke-returns/index.vue b/web/src/views/goauto/yeeke-returns/index.vue index 714a9c9..427e592 100644 --- a/web/src/views/goauto/yeeke-returns/index.vue +++ b/web/src/views/goauto/yeeke-returns/index.vue @@ -172,24 +172,17 @@ export default { { label: '退货待确认', value: 'matched' }, { label: '已用退货', value: 'confirmed' } ], - // yeeke's only confirmed claim-status value is the raw string "1" - // (HAR evidence, #336 requirement doc). Any other value the sync sees - // is preserved verbatim and flagged status_unrecognized — never - // guessed into a label here. "unknown" is a server-side pseudo-filter - // matching status_unrecognized=true, not a real yeeke value. + // HAR-confirmed business statuses; unknown remains a server-side filter. claimStatusOptions: [ - { label: '已认领', value: '1' }, + { label: '待出货', value: '1' }, + { label: '已重出', value: '2' }, { label: '未知(原样展示)', value: 'unknown' } ] } }, created() { this.load(); this.loadSummary(); this.loadShops() }, methods: { - // "1" is yeeke's only confirmed known status; anything else reaching - // this function (status_unrecognized=false from the server) is an - // unmapped value we have not seen yet — show it as-is rather than a - // bare digit or a guessed Chinese label. - claimStatusMeta(status) { return status === '1' ? { label: '已认领', type: 'success' } : { label: status ? `其他:${status}` : '—', type: 'info' } }, + claimStatusMeta(status) { return { '1': { label: '待出货', type: 'success' }, '2': { label: '已重出', type: 'info' }}[status] || { label: status ? `其他:${status}` : '—', type: 'info' } }, matchStatusMeta(status) { return { unmatched: { label: '未匹配', type: 'info' }, matched: { label: '退货待确认', type: 'warning' }, confirmed: { label: '已用退货', type: 'success' }}[status] || { label: status || '—', type: 'info' } }, openSybProduct(sybProductId) { this.$router.push({ path: '/syb-products/index', query: { sybProductId } }) }, statusMeta(status) { return { running: { label: '执行中', type: 'primary' }, succeeded: { label: '成功', type: 'success' }, failed: { label: '失败', type: 'danger' }, interrupted: { label: '已中断', type: 'warning' }}[status] || { label: status || '-', type: 'info' } }, diff --git a/web/src/views/goauto/yeeke-sync-runs/index.vue b/web/src/views/goauto/yeeke-sync-runs/index.vue index 1084c04..0c23a25 100644 --- a/web/src/views/goauto/yeeke-sync-runs/index.vue +++ b/web/src/views/goauto/yeeke-sync-runs/index.vue @@ -71,7 +71,7 @@ export default { } }, computed: { - canSync() { return (this.$store.getters.roles || []).some(role => role === 'admin' || role === 'purchaser') }, + 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() }, diff --git a/web/tests/e2e/syb-return-cancel.spec.ts b/web/tests/e2e/syb-return-cancel.spec.ts new file mode 100644 index 0000000..aa841ed --- /dev/null +++ b/web/tests/e2e/syb-return-cancel.spec.ts @@ -0,0 +1,108 @@ +import { expect, test, type Page, type BrowserContext } from '@playwright/test' + +const menus = [{ path: '/workbench', component: 'Layout', menuName: 'Workbench', title: '工作台', visible: '0', children: [{ path: '/syb-products', component: '/goauto/syb-products/index', menuName: 'GoAutoSybProducts', title: 'SYB 商品', visible: '0' }] }] +async function setup(page: Page, context: BrowserContext, mode = 'success', role = 'after_sales') { + await page.setViewportSize({ width: 1920, height: 1080 }) + await context.addCookies([{ name: 'Admin-Token', value: 'isolated-test-token', domain: 'localhost', path: '/' }]) + const writes: number[] = [] + const cancelled = new Set() + const lists: URL[] = [] + await page.route('**/api/**', async route => { + const url = new URL(route.request().url()); const pathname = url.pathname + if (pathname.startsWith('/src/api/')) return route.continue() + const reply = (data: unknown) => route.fulfill({ json: { code: 200, data } }) + if (pathname.endsWith('/api/v1/menurole')) return reply(menus) + if (pathname.endsWith('/api/v1/getinfo')) return reply({ roles: [role], name: '模拟售后', avatar: '', permissions: [] }) + if (pathname.endsWith('/api/admin/v1/syb-products')) { + lists.push(url) + const items = [1, 2, 3, 4].filter(id => url.searchParams.get('processStage') !== 'return_pending' || (id <= 2 && !cancelled.has(id))).map(id => ({ id, orderCode: `MOCK-${id}`, shopeeItemId: String(100 + id), shopeeProductId: 100 + id, imageUrl: '', productTitle: '模拟商品', quantity: 1, parseStatus: 'success' })) + return reply({ items, total: items.length }) + } + if (pathname.endsWith('/purchase-tasks/batch-preview')) return reply({ items: [1, 2, 3, 4].map(id => ({ sybProductId: id, processStage: cancelled.has(id) ? 'pdd_unlinked' : id <= 2 ? 'return_pending' : id === 3 ? 'return_used' : 'purchase_ready', processStageLabel: cancelled.has(id) ? '未关联 PDD' : id <= 2 ? '退货待确认' : id === 3 ? '已用退货' : '可创建采购', eligible: id === 4, collectionEligible: false, aiMatchEligible: false })) }) + if (pathname.endsWith('/return-matches')) { + if (mode === 'metadata-error') return route.fulfill({ status: 500, json: { code: 'INTERNAL' } }) + return reply({ items: [1, 2, 3].filter(id => !cancelled.has(id)).map(id => ({ id: id + 10, sybProductId: id, status: id <= 2 ? 'matched' : 'confirmed', variationName: '模拟规格' })) }) + } + if (/\/return-matches\/\d+\/cancel$/.test(pathname)) { + const id = Number(pathname.split('/').at(-2)); writes.push(id) + expect(route.request().postDataJSON()).toEqual({ expectedStatus: 'matched' }) + if (mode === 'partial' && id === 12) return route.fulfill({ status: 500, json: { code: 'INTERNAL', message: '模拟取消失败' } }) + if (mode === 'auth') return route.fulfill({ status: 401, json: { code: 401, message: '模拟登录失效' } }) + if (mode === 'conflict' && id === 12) return route.fulfill({ status: 409, json: { code: 'STATE_CONFLICT' } }) + await new Promise(resolve => setTimeout(resolve, 150)) + cancelled.add(id - 10); return reply({ item: { id, status: 'cancelled' } }) + } + if (/\/return-matches\/\d+$/.test(pathname)) { + const id = Number(pathname.split('/').at(-1)); return reply({ match: { id, sybProductId: id - 10, status: 'matched' }, logs: [] }) + } + return reply({ items: [], total: 0 }) + }) + await page.goto('/#/syb-products') + await expect(page.getByRole('cell', { name: 'MOCK-1', exact: true })).toBeVisible() + return { writes, lists } +} + +test('one confirmation, mixed selection, progress, success and filter retention', async ({ page, context }) => { + const { writes, lists } = await setup(page, context) + const cancel = page.getByRole('button', { name: /取消匹配\s*0/ }).first() + await expect(cancel).toBeDisabled() + await page.locator('thead .el-checkbox').first().click() + await expect(page.getByRole('button', { name: /创建采购\s*1/ })).toBeEnabled() + await page.getByRole('button', { name: /取消匹配\s*2/ }).click() + await expect(page.getByText('取消选中的 2 条退货匹配?取消后商品将重新计算采购准备状态。', { exact: true })).toBeVisible() + await page.getByRole('button', { name: '返回', exact: true }).click() + expect(writes).toEqual([]) + await page.getByRole('button', { name: /取消匹配\s*2/ }).click() + await page.getByRole('button', { name: '确认取消匹配', exact: true }).click() + await expect(page.getByRole('button', { name: '返回列表', exact: true })).toBeDisabled() + await expect(page.getByText('成功 2 条', { exact: true })).toBeVisible() + await expect(page.getByText('跳过 1 条', { exact: true })).toBeVisible() + await expect(page.getByRole('button', { name: '返回列表', exact: true })).toBeEnabled() + expect(writes).toEqual([11, 12]) + expect(lists.at(-1)?.searchParams.get('pageSize')).toBe('200') + await page.getByRole('button', { name: '返回列表', exact: true }).click() + await expect(page.getByText('已选择 0 条', { exact: true })).toBeVisible() +}) + +for (const mode of ['partial', 'conflict', 'auth']) { + test(`${mode}: per-row outcome, failed selection and stop on login failure`, async ({ page, context }) => { + const { writes } = await setup(page, context, mode) + await page.locator('thead .el-checkbox').first().click() + await page.getByRole('button', { name: /取消匹配\s*2/ }).click() + await page.getByRole('button', { name: '确认取消匹配', exact: true }).click() + await expect(page.getByRole('button', { name: '返回列表', exact: true })).toBeEnabled() + expect(writes).toEqual(mode === 'auth' ? [11] : [11, 12]) + await expect(page.getByText(mode === 'auth' ? '失败 2 条' : mode === 'partial' ? '失败 1 条' : '跳过 2 条', { exact: true })).toBeVisible() + await page.getByRole('button', { name: '返回列表', exact: true }).click() + if (mode === 'partial') { + await expect(page.getByText('已选择 1 条', { exact: true })).toBeVisible() + await expect(page.getByRole('button', { name: /取消匹配\s*1/ }).first()).toBeEnabled() + } + }) +} + +test('match load failure disables bulk cancellation', async ({ page, context }) => { + await setup(page, context, 'metadata-error') + await page.locator('thead .el-checkbox').first().click() + await expect(page.getByRole('button', { name: /取消匹配\s*0/ }).first()).toBeDisabled() +}) +test('unprivileged role cannot see cancellation or selection', async ({ page, context }) => { + await setup(page, context, 'success', 'viewer') + await expect(page.getByRole('button', { name: /取消匹配/ })).toHaveCount(0) + await expect(page.locator('thead .el-checkbox')).toHaveCount(0) +}) + +test('return-pending filter retained; successful rows leave current page', async ({ page, context }) => { + const { lists } = await setup(page, context) + await page.locator('.el-form-item').filter({ hasText: '处理阶段' }).locator('.el-select__wrapper').click() + await page.getByRole('option', { name: '退货待确认', exact: true }).click() + await page.getByRole('button', { name: '查询', exact: true }).click() + await expect(page.getByRole('cell', { name: 'MOCK-4', exact: true })).toHaveCount(0) + await page.locator('thead .el-checkbox').first().click() + await page.getByRole('button', { name: /取消匹配\s*2/ }).click() + await page.getByRole('button', { name: '确认取消匹配', exact: true }).click() + await expect(page.getByRole('button', { name: '返回列表', exact: true })).toBeEnabled() + expect(lists.at(-1)?.searchParams.get('processStage')).toBe('return_pending') + await page.getByRole('button', { name: '返回列表', exact: true }).click() + await expect(page.getByText('暂无 SYB 商品明细', { exact: true })).toBeVisible() +}) diff --git a/web/tests/e2e/syb-return-confirm.spec.ts b/web/tests/e2e/syb-return-confirm.spec.ts new file mode 100644 index 0000000..1d0d9ea --- /dev/null +++ b/web/tests/e2e/syb-return-confirm.spec.ts @@ -0,0 +1,117 @@ +import { expect, test, type Page, type BrowserContext } from '@playwright/test' + +const menus = [{ path: '/workbench', component: 'Layout', menuName: 'Workbench', title: '工作台', visible: '0', children: [{ path: '/syb-products', component: '/goauto/syb-products/index', menuName: 'GoAutoSybProducts', title: 'SYB 商品', visible: '0' }] }] +async function setup(page: Page, context: BrowserContext, mode = 'success', role = 'after_sales') { + await page.setViewportSize({ width: 1920, height: 1080 }) + await context.addCookies([{ name: 'Admin-Token', value: 'isolated-test-token', domain: 'localhost', path: '/' }]) + const writes: number[] = [] + const confirmed = new Set() + const lists: URL[] = [] + await page.route('**/api/**', async route => { + const url = new URL(route.request().url()); const pathname = url.pathname + if (pathname.startsWith('/src/api/')) return route.continue() + const reply = (data: unknown) => route.fulfill({ json: { code: 200, data } }) + if (pathname.endsWith('/api/v1/menurole')) return reply(menus) + if (pathname.endsWith('/api/v1/getinfo')) return reply({ roles: [role], name: '模拟售后', avatar: '', permissions: [] }) + if (pathname.endsWith('/api/admin/v1/syb-products')) { + lists.push(url) + const items = [1, 2, 3, 4].filter(id => url.searchParams.get('processStage') !== 'return_pending' || (id <= 2 && !confirmed.has(id))).map(id => ({ id, orderCode: `MOCK-${id}`, shopeeItemId: String(100 + id), shopeeProductId: 100 + id, imageUrl: '', productTitle: '模拟商品', quantity: 1, parseStatus: 'success' })) + return reply({ items, total: items.length }) + } + if (pathname.endsWith('/purchase-tasks/batch-preview')) return reply({ items: [1, 2, 3, 4].map(id => ({ sybProductId: id, processStage: confirmed.has(id) ? 'return_used' : id <= 2 ? 'return_pending' : id === 3 ? 'return_used' : 'purchase_ready', processStageLabel: confirmed.has(id) ? '已用退货' : id <= 2 ? '退货待确认' : id === 3 ? '已用退货' : '可创建采购', eligible: id === 4, collectionEligible: false, aiMatchEligible: false })) }) + if (pathname.endsWith('/return-matches')) { + if (mode === 'metadata-error') return route.fulfill({ status: 500, json: { code: 'INTERNAL' } }) + return reply({ items: [1, 2, 3].map(id => ({ id: id + 10, sybProductId: id, status: id <= 2 && !confirmed.has(id) ? 'matched' : 'confirmed', variationName: '模拟规格' })) }) + } + if (/\/return-matches\/\d+\/confirm$/.test(pathname)) { + const id = Number(pathname.split('/').at(-2)); writes.push(id) + expect(route.request().method()).toBe('POST') + if (mode === 'readback-confirmed') return route.fulfill({ status: 504, json: { code: 'TIMEOUT' } }) + if (mode === 'partial' && id === 12) return route.fulfill({ status: 500, json: { code: 'INTERNAL', message: '模拟使用失败' } }) + if (mode === 'auth') return route.fulfill({ status: 401, json: { code: 401, message: '模拟登录失效' } }) + if (mode === 'conflict' && id === 12) return route.fulfill({ status: 409, json: { code: 'STATE_CONFLICT' } }) + await new Promise(resolve => setTimeout(resolve, 150)) + confirmed.add(id - 10); return reply({ item: { id, status: 'confirmed' } }) + } + if (/\/return-matches\/\d+$/.test(pathname)) { + const id = Number(pathname.split('/').at(-1)); return reply({ match: { id, sybProductId: id - 10, status: mode === 'readback-confirmed' || confirmed.has(id - 10) ? 'confirmed' : 'matched' }, logs: [] }) + } + return reply({ items: [], total: 0 }) + }) + await page.goto('/#/syb-products') + await expect(page.getByRole('cell', { name: 'MOCK-1', exact: true })).toBeVisible() + return { writes, lists } +} + +test('v2 button order, one confirmation, no write on return, progress and mutual exclusion', async ({ page, context }) => { + const { writes } = await setup(page, context) + const use = page.getByRole('button', { name: /使用退货\s*0/ }) + await expect(use).toBeDisabled() + const toolbar = await page.locator('.toolbar-actions button').allTextContents() + expect(toolbar.findIndex(t => t.includes('匹配退货'))).toBeLessThan(toolbar.findIndex(t => t.includes('取消匹配'))) + expect(toolbar.findIndex(t => t.includes('取消匹配'))).toBeLessThan(toolbar.findIndex(t => t.includes('使用退货'))) + await page.locator('thead .el-checkbox').first().click() + await page.getByRole('button', { name: /使用退货\s*2/ }).click() + await expect(page.getByText(/不会自动让 Yeeke 发货/, { exact: false }).last()).toBeVisible() + await page.getByRole('button', { name: '返回', exact: true }).click() + expect(writes).toEqual([]) + await page.getByRole('button', { name: /使用退货\s*2/ }).click() + await page.getByRole('button', { name: '确认使用', exact: true }).click() + await expect(page.getByRole('button', { name: '返回列表', exact: true })).toBeDisabled() + await expect(page.getByRole('button', { name: /取消匹配\s*2/ }).first()).toBeDisabled() + await expect(page.getByRole('button', { name: '返回列表', exact: true })).toBeEnabled() + expect(writes).toEqual([11, 12]) + await expect(page.getByText('成功 2 条', { exact: true })).toBeVisible() + await expect(page.getByText('跳过 1 条', { exact: true })).toBeVisible() + await page.getByRole('button', { name: '返回列表', exact: true }).click() + await expect(page.getByRole('dialog', { name: '批量使用退货结果', exact: true })).toBeHidden() + await expect(page.getByText('已选择 0 条', { exact: true })).toBeVisible() +}) + +for (const mode of ['partial', 'conflict', 'auth', 'readback-confirmed']) { + test(`${mode}: independent outcomes, failed selection, auth stop and readback without retry`, async ({ page, context }) => { + const { writes } = await setup(page, context, mode) + await page.locator('thead .el-checkbox').first().click() + await page.getByRole('button', { name: /使用退货\s*2/ }).click() + await page.getByRole('button', { name: '确认使用', exact: true }).click() + await expect(page.getByRole('button', { name: '返回列表', exact: true })).toBeEnabled() + expect(writes).toEqual(mode === 'auth' ? [11] : [11, 12]) + const summary = mode === 'auth' ? '失败 2 条' : mode === 'partial' ? '失败 1 条' : mode === 'conflict' ? '跳过 2 条' : '跳过 3 条' + await expect(page.getByText(summary, { exact: true })).toBeVisible() + await page.getByRole('button', { name: '返回列表', exact: true }).click() + await expect(page.getByRole('dialog', { name: '批量使用退货结果', exact: true })).toBeHidden() + if (mode === 'partial') { + await expect(page.getByText('已选择 1 条', { exact: true })).toBeVisible() + await expect(page.getByRole('button', { name: /使用退货\s*1/ })).toBeEnabled() + } + }) +} + +test('metadata load error disables use', async ({ page, context }) => { + await setup(page, context, 'metadata-error') + await page.locator('thead .el-checkbox').first().click() + await expect(page.getByRole('button', { name: /使用退货\s*0/ })).toBeDisabled() +}) + +test('viewer cannot see use button or selection', async ({ page, context }) => { + await setup(page, context, 'success', 'viewer') + await expect(page.getByRole('button', { name: /使用退货/ })).toHaveCount(0) + await expect(page.locator('thead .el-checkbox')).toHaveCount(0) +}) + +test('pending filter and page size retained; used rows disappear', async ({ page, context }) => { + const { lists } = await setup(page, context) + await page.locator('.el-form-item').filter({ hasText: '处理阶段' }).locator('.el-select__wrapper').click() + await page.getByRole('option', { name: '退货待确认', exact: true }).click() + await page.getByRole('button', { name: '查询', exact: true }).click() + await expect(page.getByRole('cell', { name: 'MOCK-4', exact: true })).toHaveCount(0) + await page.locator('thead .el-checkbox').first().click() + await page.getByRole('button', { name: /使用退货\s*2/ }).click() + await page.getByRole('button', { name: '确认使用', exact: true }).click() + await expect(page.getByRole('button', { name: '返回列表', exact: true })).toBeEnabled() + expect(lists.at(-1)?.searchParams.get('processStage')).toBe('return_pending') + expect(lists.at(-1)?.searchParams.get('pageSize')).toBe('200') + await page.getByRole('button', { name: '返回列表', exact: true }).click() + await expect(page.getByRole('dialog', { name: '批量使用退货结果', exact: true })).toBeHidden() + await expect(page.getByText('暂无 SYB 商品明细', { exact: true })).toBeVisible() +}) diff --git a/web/tests/unit/syb-return-cancel.test.cjs b/web/tests/unit/syb-return-cancel.test.cjs new file mode 100644 index 0000000..54dc9b5 --- /dev/null +++ b/web/tests/unit/syb-return-cancel.test.cjs @@ -0,0 +1,111 @@ +const { test } = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') +const source = fs.readFileSync(path.join(__dirname, '../../src/views/goauto/syb-products/return-cancel.js'), 'utf8').replace(/import[^\n]+\n/g, '').replace('export default', 'return') +const tick = () => new Promise(resolve => setImmediate(resolve)) +function instance(overrides = {}) { + const writes = []; const reads = []; const selected = []; const confirmations = [] + const dependencies = { + cancelReturnMatch: async (id, body) => { writes.push({ id, body }) }, + getReturnMatch: async id => { reads.push(id); return { data: { match: { id, sybProductId: id - 10, status: 'matched' } } } }, ...overrides + } + const options = new Function(...Object.keys(dependencies), source)(...Object.values(dependencies)) + const vm = { ...options.methods, ...options.data(), canPurchase: true, loading: false, returnMatchLoading: false, purchaseReadinessLoading: false, matchDetail: {}, loadGeneration: 0, + products: [1, 2, 3].map(id => ({ id, orderCode: `TEST-${id}` })), + purchaseReadiness: { 1: { processStage: 'return_pending' }, 2: { processStage: 'return_pending' }, 3: { processStage: 'return_used' } }, + returnMatchByProductId: { 1: { id: 11, status: 'matched' }, 2: { id: 12, status: 'matched' }, 3: { id: 13, status: 'confirmed' } }, + purchaseReady(row) { return this.purchaseReadiness[row.id] || {} }, + $confirm: async text => { confirmations.push(text) }, $nextTick: async () => {}, + $refs: { productTable: { clearSelection: () => selected.splice(0), toggleRowSelection: row => selected.push(row.id) } }, + load: async function() { this.loadGeneration++ } + } + vm.selectedProducts = [...vm.products] + Object.entries(options.computed).forEach(([key, fn]) => Object.defineProperty(vm, key, { get: () => fn.call(vm) })) + return { vm, options, writes, reads, selected, confirmations } +} +test('matched pending count only; serial requests include frozen matched precondition and skip confirmed', async () => { + const { vm, writes, confirmations } = instance() + assert.equal(vm.returnCancelCandidates.length, 2) + await vm.runBatchCancelReturns() + assert.deepEqual(writes, [{ id: 11, body: { expectedStatus: 'matched' } }, { id: 12, body: { expectedStatus: 'matched' } }]) + assert.match(confirmations[0], /2 条/) + assert.equal(vm.returnCancelResult.successCount, 2); assert.equal(vm.returnCancelResult.skippedCount, 1) + assert.equal(vm.returnCancelBatchLoading, false) +}) +test('permission, empty, metadata failure/loading and conflicting actions disable entry', async () => { + for (const [key, value] of [['canPurchase', false], ['returnMatchLoadError', true], ['returnMatchLoading', true], ['purchaseReadinessLoading', true], ['returnTransitionLoading', true], ['returnMatchBatchLoading', true]]) { + const { vm, writes } = instance(); vm[key] = value; await vm.runBatchCancelReturns(); assert.equal(writes.length, 0) + } + const { vm, writes } = instance(); vm.selectedProducts = []; await vm.runBatchCancelReturns(); assert.equal(writes.length, 0) +}) +test('confirmation dismissal does not write', async () => { + const { vm, writes } = instance(); vm.$confirm = async () => { throw new Error('cancel') }; await vm.runBatchCancelReturns() + assert.equal(writes.length, 0); assert.equal(vm.returnCancelResult.open, false); assert.equal(vm.returnCancelBatchLoading, false) +}) +test('freeze identities, dedupe, prevent duplicate execution and serialize', async () => { + let release; const wait = new Promise(resolve => { release = resolve }); const writes = [] + const { vm } = instance({ cancelReturnMatch: async id => { writes.push(id); if (id === 11) await wait } }) + vm.selectedProducts.push(vm.products[0]); const running = vm.runBatchCancelReturns(); await tick() + vm.returnMatchByProductId[2].id = 99; vm.selectedProducts = []; await vm.runBatchCancelReturns() + assert.deepEqual(writes, [11]); release(); await running; assert.deepEqual(writes, [11, 12]) +}) +test('partial failure retains only readback-confirmed eligible failed row', async () => { + const { vm, selected, reads } = instance({ cancelReturnMatch: async id => { if (id === 12) throw { response: { status: 403 } } } }) + await vm.runBatchCancelReturns() + assert.equal(vm.returnCancelResult.failedCount, 1); assert.deepEqual(reads, [12]); assert.deepEqual(selected, [2]) +}) +test('state conflict skips and never recancels original or replacement', async () => { + const { vm, reads } = instance({ cancelReturnMatch: async () => { throw { response: { status: 409, data: { code: 'STATE_CONFLICT' } } } } }) + await vm.runBatchCancelReturns(); assert.equal(vm.returnCancelResult.skippedCount, 3); assert.equal(reads.length, 0) +}) +test('uncertain timeout reads original once, no blind POST retry', async () => { + let writes = 0 + const { vm, reads, selected } = instance({ cancelReturnMatch: async () => { writes++; throw new Error('timeout') }, getReturnMatch: async id => { reads.push(id); return { data: { match: { id, status: 'cancelled' } } } } }) + await vm.runBatchCancelReturns(); assert.equal(writes, 2); assert.deepEqual(reads, [11, 12]); assert.equal(vm.returnCancelResult.skippedCount, 3); assert.deepEqual(selected, []) +}) +test('failed readback leaves uncertain results unselected', async () => { + const { vm, selected } = instance({ cancelReturnMatch: async () => { throw new Error('offline') }, getReturnMatch: async () => { throw new Error('offline') } }) + await vm.runBatchCancelReturns(); assert.equal(vm.returnCancelResult.failedCount, 2); assert.deepEqual(selected, []) +}) +for (const error of [new Error('Unauthorized'), { response: { status: 401 } }]) { + test(`auth failure stops remaining writes (${error.message || 'HTTP'})`, async () => { + let writes = 0; const { vm } = instance({ cancelReturnMatch: async () => { writes++; throw error } }) + await vm.runBatchCancelReturns(); assert.equal(writes, 1); assert.equal(vm.returnCancelResult.failedCount, 2); assert.equal(vm.returnCancelBatchLoading, false) + }) +} +test('new query generation does not restore stale selection', async () => { + const { vm, selected } = instance({ cancelReturnMatch: async () => { throw new Error('timeout') } }) + vm.load = async () => { vm.loadGeneration += 2 }; await vm.runBatchCancelReturns(); assert.deepEqual(selected, []) +}) +test('filter removes failed row but keeps failure result; refresh failure preserves result', async () => { + const { vm, selected } = instance({ cancelReturnMatch: async () => { throw new Error('timeout') } }) + vm.load = async () => { vm.loadGeneration++; vm.products = [] }; await vm.runBatchCancelReturns() + assert.equal(vm.returnCancelResult.failedCount, 2); assert.deepEqual(selected, []) + vm.products = [{ id: 1 }]; vm.selectedProducts = vm.products; vm.load = async () => { throw new Error('offline') }; await vm.runBatchCancelReturns() + assert.match(vm.returnCancelResult.refreshError, /刷新失败/) +}) +test('unmount prevents further writes after in-flight request', async () => { + let release; const wait = new Promise(resolve => { release = resolve }); let writes = 0 + const { vm, options } = instance({ cancelReturnMatch: async () => { writes++; await wait } }) + const running = vm.runBatchCancelReturns(); await tick(); options.beforeUnmount.call(vm); release(); await running + assert.equal(writes, 1); assert.equal(vm.returnCancelBatchLoading, false) +}) + +test('legacy interceptor false/6401 result is auth failure, not cancellation success', async () => { + let writes = 0 + const { vm } = instance({ cancelReturnMatch: async () => { writes++; return false } }) + await vm.runBatchCancelReturns(); assert.equal(writes, 1); assert.equal(vm.returnCancelResult.successCount, 0); assert.equal(vm.returnCancelResult.failedCount, 2) +}) + +test('pending rows can be selected for cancel but never become collection, purchase, image-search or AI candidates', () => { + const script = fs.readFileSync(path.join(__dirname, '../../src/views/goauto/syb-products/index.vue'), 'utf8').split('')[0].replace(/import[^\n]+\n/g, '').replace('export default', 'return') + const options = new Function('quickLink', 'returnCancel', 'returnConfirm', 'ShopeeProductDetailDrawer', 'PddProductDetailDrawer', script)({}, {}, {}, {}, {}) + const row = { id: 1, shopeeProductId: 11, imageUrl: 'mock-image' } + const ready = { processStage: 'return_pending', eligible: true, collectionEligible: true, aiMatchEligible: true, pddProductId: 9 } + const vm = { ...options.methods, selectedProducts: [row], purchaseReadiness: { 1: ready }, canPurchase: true } + assert.equal(vm.isSelectableCandidate(row), true) + for (const method of ['isPurchaseCandidate', 'isCollectionCandidate', 'isAIMatchCandidate', 'isImageSearchCandidate', 'isReturnMatchCandidate']) assert.equal(vm[method](row), false, method) + ready.processStage = 'return_used'; assert.equal(vm.isSelectableCandidate(row), false) + ready.processStage = 'purchase_ready'; assert.equal(vm.isPurchaseCandidate(row), true) +}) diff --git a/web/tests/unit/syb-return-confirm.test.cjs b/web/tests/unit/syb-return-confirm.test.cjs new file mode 100644 index 0000000..97f08ee --- /dev/null +++ b/web/tests/unit/syb-return-confirm.test.cjs @@ -0,0 +1,112 @@ +const { test } = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') +const readOptions = (file, dependencies) => new Function(...Object.keys(dependencies), fs.readFileSync(path.join(__dirname, '../../src/views/goauto/syb-products', file), 'utf8').replace(/import[^\n]+\n/g, '').replace('export default', 'return'))(...Object.values(dependencies)) +const tick = () => new Promise(resolve => setImmediate(resolve)) +function instance(overrides = {}) { + const writes = []; const reads = []; const selected = []; const confirmations = [] + const dependencies = { + confirmReturnMatch: async (id, options) => { writes.push({ id, options }); return { code: 200 } }, + getReturnMatch: async id => { reads.push(id); return { data: { match: { id, sybProductId: id - 10, status: 'matched' } } } }, ...overrides + } + const options = readOptions('return-confirm.js', dependencies) + const cancel = readOptions('return-cancel.js', { cancelReturnMatch: async () => {}, getReturnMatch: dependencies.getReturnMatch }) + const vm = { ...cancel.methods, ...options.methods, ...cancel.data(), ...options.data(), canPurchase: true, loading: false, returnMatchLoading: false, purchaseReadinessLoading: false, matchDetail: {}, loadGeneration: 0, + products: [1, 2, 3].map(id => ({ id, orderCode: `TEST-${id}` })), + purchaseReadiness: { 1: { processStage: 'return_pending' }, 2: { processStage: 'return_pending' }, 3: { processStage: 'return_used' } }, + returnMatchByProductId: { 1: { id: 11, status: 'matched' }, 2: { id: 12, status: 'matched' }, 3: { id: 13, status: 'confirmed' } }, + purchaseReady(row) { return this.purchaseReadiness[row.id] || {} }, + $confirm: async text => { confirmations.push(text) }, $nextTick: async () => {}, + $refs: { productTable: { clearSelection: () => selected.splice(0), toggleRowSelection: row => selected.push(row.id) } }, + load: async function() { this.loadGeneration++ } + } + vm.selectedProducts = [...vm.products] + Object.entries({ ...cancel.computed, ...options.computed }).forEach(([key, fn]) => Object.defineProperty(vm, key, { get: () => fn.call(vm) })) + return { vm, options, writes, reads, selected, confirmations } +} +test('current-page count, deduplication, mixed rows, original confirm API and one confirmation', async () => { + const { vm, writes, confirmations } = instance() + vm.selectedProducts.push(vm.products[0], { id: 99 }) + assert.equal(vm.returnConfirmCandidates.length, 2) + await vm.runBatchConfirmReturns() + assert.deepEqual(writes.map(w => w.id), [11, 12]) + assert.ok(writes.every(w => w.options.suppressErrorMessage && w.options.suppressNetworkError)) + assert.equal(confirmations.length, 1); assert.match(confirmations[0], /2 条/); assert.match(confirmations[0], /不会自动让 Yeeke 发货/) + assert.equal(vm.returnConfirmResult.successCount, 2); assert.equal(vm.returnConfirmResult.skippedCount, 1) + assert.equal(vm.returnConfirmBatchLoading, false) +}) +test('permission, metadata, busy and empty states prohibit writes', async () => { + for (const [key, value] of [['canPurchase', false], ['loading', true], ['returnMatchLoadError', true], ['returnMatchLoading', true], ['purchaseReadinessLoading', true], ['returnTransitionLoading', true], ['returnMatchBatchLoading', true], ['returnCancelBatchLoading', true]]) { + const { vm, writes } = instance(); vm[key] = value; await vm.runBatchConfirmReturns(); assert.equal(writes.length, 0, key) + } + const { vm, writes } = instance(); vm.matchDetail.saving = true; await vm.runBatchConfirmReturns(); assert.equal(writes.length, 0) + vm.matchDetail.saving = false; vm.selectedProducts = []; await vm.runBatchConfirmReturns(); assert.equal(writes.length, 0) +}) +test('return dismisses without a write or result; confirm busy blocks cancel and single transitions', async () => { + const { vm, writes } = instance(); vm.$confirm = async () => { throw new Error('cancel') }; await vm.runBatchConfirmReturns() + assert.equal(writes.length, 0); assert.equal(vm.returnConfirmResult.open, false); assert.equal(vm.returnConfirmBatchLoading, false) + vm.returnConfirmBatchLoading = true; let ran = false + assert.match(vm.returnCancelButtonReason, /正在处理/) + await vm.runReturnTransition(async () => { ran = true }); assert.equal(ran, false) +}) +test('serial, frozen identities and no duplicate execution while waiting', async () => { + let release; const wait = new Promise(resolve => { release = resolve }); const writes = [] + const { vm } = instance({ confirmReturnMatch: async id => { writes.push(id); if (id === 11) await wait } }) + const running = vm.runBatchConfirmReturns(); await tick() + vm.returnMatchByProductId[2].id = 99; vm.selectedProducts = []; await vm.runBatchConfirmReturns() + assert.deepEqual(writes, [11]); release(); await running; assert.deepEqual(writes, [11, 12]) +}) +test('partial business failure keeps only readback-proven current-page failed selection', async () => { + const { vm, selected, reads } = instance({ confirmReturnMatch: async id => { if (id === 12) throw { response: { status: 500 } } } }) + await vm.runBatchConfirmReturns(); assert.equal(vm.returnConfirmResult.failedCount, 1); assert.deepEqual(reads, [12]); assert.deepEqual(selected, [2]) +}) +test('state conflict skips original; neither readback nor replacement write', async () => { + const { vm, reads } = instance({ confirmReturnMatch: async () => { throw { response: { status: 409, data: { code: 'STATE_CONFLICT' } } } } }) + await vm.runBatchConfirmReturns(); assert.equal(vm.returnConfirmResult.skippedCount, 3); assert.equal(reads.length, 0) +}) +for (const status of ['confirmed', 'cancelled', 'matched']) { + test(`unknown outcome reads original ${status} once, never resends`, async () => { + let writes = 0 + const { vm, reads, selected } = instance({ confirmReturnMatch: async () => { writes++; throw new Error('timeout') }, getReturnMatch: async id => { reads.push(id); return { data: { match: { id, sybProductId: id - 10, status } } } } }) + await vm.runBatchConfirmReturns(); assert.equal(writes, 2); assert.deepEqual(reads, [11, 12]) + assert.equal(vm.returnConfirmResult.successCount, 0) + assert.deepEqual(selected, status === 'matched' ? [1, 2] : []) + assert.equal(vm.returnConfirmResult.skippedCount, status === 'matched' ? 1 : 3) + }) +} +test('unreadable/mismatched original never treated as confirmed or retryable', async () => { + for (const getReturnMatch of [async () => { throw new Error('offline') }, async id => ({ data: { match: { id: id + 1, sybProductId: 999, status: 'confirmed' } } })]) { + const { vm, selected } = instance({ confirmReturnMatch: async () => { throw new Error('timeout') }, getReturnMatch }) + await vm.runBatchConfirmReturns(); assert.equal(vm.returnConfirmResult.failedCount, 2); assert.deepEqual(selected, []) + } +}) +for (const response of [false, new Error('Unauthorized'), { response: { status: 401 } }]) { + test(`auth failure/legacy false stops remaining writes (${String(response)})`, async () => { + let writes = 0 + const { vm } = instance({ confirmReturnMatch: async () => { writes++; if (response === false) return false; throw response } }) + await vm.runBatchConfirmReturns(); assert.equal(writes, 1); assert.equal(vm.returnConfirmResult.failedCount, 2); assert.equal(vm.returnConfirmBatchLoading, false) + assert.match(vm.returnConfirmResult.items[1].reason, /未继续执行/) + }) +} +test('login failure during readback stops remaining POSTs', async () => { + let writes = 0 + const { vm } = instance({ confirmReturnMatch: async () => { writes++; throw new Error('timeout') }, getReturnMatch: async () => { throw new Error('Unauthorized') } }) + await vm.runBatchConfirmReturns(); assert.equal(writes, 1); assert.equal(vm.returnConfirmResult.failedCount, 2) +}) +test('new generation, replaced original and vanished failed row cannot restore old selection', async () => { + for (const load of [async function() { this.loadGeneration += 2 }, async function() { this.loadGeneration++; this.products = [] }, async function() { this.loadGeneration++; this.returnMatchByProductId[1].id = 99; this.returnMatchByProductId[2].id = 98 }]) { + const { vm, selected } = instance({ confirmReturnMatch: async () => { throw new Error('timeout') } }); vm.load = load + await vm.runBatchConfirmReturns(); assert.equal(vm.returnConfirmResult.failedCount, 2); assert.deepEqual(selected, []) + } +}) +test('refresh failure keeps completed result and does not write again', async () => { + const { vm, writes } = instance(); vm.load = async () => { throw new Error('offline') }; await vm.runBatchConfirmReturns() + assert.match(vm.returnConfirmResult.refreshError, /刷新失败/); assert.equal(vm.returnConfirmResult.successCount, 2); assert.equal(writes.length, 2) +}) +test('unmount stops remaining writes after in-flight response', async () => { + let release; const wait = new Promise(resolve => { release = resolve }); let writes = 0 + const { vm, options } = instance({ confirmReturnMatch: async () => { writes++; await wait } }) + const running = vm.runBatchConfirmReturns(); await tick(); options.beforeUnmount.call(vm); release(); await running + assert.equal(writes, 1); assert.equal(vm.returnConfirmBatchLoading, false) +}) diff --git a/web/tests/unit/yeeke-status.test.cjs b/web/tests/unit/yeeke-status.test.cjs new file mode 100644 index 0000000..428b0cc --- /dev/null +++ b/web/tests/unit/yeeke-status.test.cjs @@ -0,0 +1,27 @@ +const { test } = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') +const file = fs.readFileSync(path.join(__dirname, '../../src/views/goauto/yeeke-returns/index.vue'), 'utf8') +const script = file.match(/