Compare commits

..
24 Commits
Author SHA1 Message Date
QiuSW abdf90775c docs: record unified SYB writeback sessions (#343) 2026-09-27 23:00:46 +08:00
QiuSW 934a7beabc fix(purchase): unify SYB session refresh for writeback (#343) 2026-09-27 22:56:54 +08:00
QiuSW 24100f04d4 merge: place return matching after purchase (#342) 2026-09-27 17:24:06 +08:00
QiuSW 266418ac47 fix(syb): place return matching after purchase (#342) 2026-09-27 17:23:57 +08:00
QiuSW 5e5fdd797e merge: arrange SYB filter row (#342) 2026-09-27 17:19:45 +08:00
QiuSW 58c8c077ad fix(syb): arrange created-time filter row (#342) 2026-09-27 17:19:35 +08:00
QiuSW 9f679f6403 merge: SYB created-time filter and page size (#342) 2026-09-27 16:59:54 +08:00
QiuSW 977ac235e2 feat(syb): filter products by created time (#342) 2026-09-27 16:59:22 +08:00
QiuSWandClaude Opus 5.5 573f5b8cf3 feat(syb): support 200/500 rows per page on SYB products list (#339)
Bump the syb-products page-size options to 20/50/100/200/500 with a new
100 default, cap the server-side sybimport.List page size at 500, chunk
the per-page purchase-readiness preview into <=100-id requests, and add
per-button selection limits (with disabled+tooltip) for AI 匹配, 创建采购,
创建采集, 图搜采集 and 匹配退货 so a larger page never silently exceeds a
batch endpoint's cap. 创建采购's 100-item server cap is left untouched.
Also caps returnmatch.BatchMatch at 500 ids (INVALID_REQUEST beyond that).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-27 16:52:09 +08:00
QiuSW 1491a9677a fix(access): allow after-sales purchase readiness (#341) 2026-09-27 11:59:31 +08:00
QiuSW d4d32c6e89 docs: restore wiki mirrors after return-match update (#338) 2026-09-27 11:46:28 +08:00
QiuSW b7038633f0 test(returnmatch): cover after-sales access and migration repair (#341) 2026-09-27 11:38:43 +08:00
QiuSW 844f611d77 fix(access): repair return-match API catalog migration (#341) 2026-09-27 11:25:01 +08:00
QiuSW d415971268 feat(returnmatch): publish return matching and after-sales access (#338 #341) 2026-09-27 11:16:18 +08:00
QiuSW 6fe588d5e3 fix(web): show after-sales GoAuto actions (#341) 2026-09-27 11:04:25 +08:00
QiuSW 17cbfaec85 feat(access): add after-sales role and accounts (#341) 2026-09-27 10:49:34 +08:00
QiuSWandClaude Opus 5.5 b72fa105a8 feat(web): flag matched returns past their destroy deadline (#338)
A match whose return has passed its destroy deadline keeps blocking
purchase (user decision: remind, don't auto-cancel). Show 「退货已过销毁截止」
in the SYB products match column and compare dialog, and 「已过销毁截止」
under the occupying SYB product on the yeeke returns page. Uses the live
package deadline, so a resync that moves the deadline updates the flag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-24 16:27:38 +08:00
QiuSWandClaude Opus 5.5 5ac8e4c8fd feat(yeeke): show missing-marked and recovered counts per sync run (#338)
Persist how many return items each sync run flipped to "missing" and how
many came back to "ok" (yeeke_sync_run.missing_marked_count /
recovered_count, migration 1789801000000), return them from the sync-runs
API and add 「标记不可用」「恢复可用」 columns to the sync-runs page. When the
20% safety valve skips marking the count stays 0 and the reason remains in
error_message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-24 16:14:30 +08:00
QiuSWandClaude Opus 5.5 ff6e87649c feat(returnmatch): mark yeeke returns unavailable when a complete sync drops them (#338)
A COMPLETE yeeke sync run (natural page-walk end, not stopped by a
duplicate-fingerprint break or MaxPages exhaustion) that also wrote zero
record failures now flips any yeeke_return_package/yeeke_return_item still
"ok" from before the run to sync_status="missing" with missing_since
stamped, in one transaction. Rows are never deleted. A 20% safety valve
skips marking (and records why in error_message) when the candidate count
would be too large; a reappearing record recovers to "ok" with
missing_since cleared.

returnmatch.availableReturnPool now excludes missing items/packages from
matching, without auto-cancelling any existing active match; List()/Detail()
surface syncStatus/missingSince so the SYB products match column, its
compare dialog, and the yeeke returns list can warn or label rows as
unavailable.

Adds migration 1789800900000_return_missing (AutoMigrate alone does not
reach existing databases).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-24 15:57:51 +08:00
QiuSWandClaude Opus 5.5 1e944b9d2e feat(returnmatch): record every batch-match submission and its outcomes (#338)
Each click of 「匹配退货」 now writes a return_match_batch row: operator,
time, submitted/matched/skipped counts and the per-SYB-product result
(in submission order), plus the error if the batch aborted. Recording
happens after the per-row transactions commit, so a logging failure is
logged server-side instead of failing an already-applied match.
GET /return-matches/batches and /return-matches/batches/:batchId expose
the records. Migration version 1789800800000 creates the table.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-24 14:22:06 +08:00
QiuSWandClaude Opus 5.5 20a8144be0 fix(db): add migration version that creates return_match tables (#338)
Registering the models in MigratedModels alone does nothing on databases
whose earlier versions are already recorded in sys_migration; the migrate
command's post-check rejected the run with "迁移后仍缺少表". Add the
1789800700000 version so existing databases create return_match and
return_match_log.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-24 14:03:06 +08:00
QiuSWandClaude Opus 5.5 f2c8307c17 feat(web): return-matching UI on SYB products and yeeke returns pages (#338)
SYB 订单商品页 (web/src/views/goauto/syb-products/index.vue):
- 「匹配退货」按钮,作用于勾选行中处于参与匹配阶段的商品(与服务端
  returnmatch.participatingStages 同一口径),canPurchase 门禁,忙碌时
  loading,无候选时禁用。
- 批量结果对话框:成功匹配/无候选/阶段不参与跳过/并发冲突跳过四类计数
  + 逐行原因表格。
- 处理阶段筛选与展示新增「退货待确认」「已用退货」两个选项/标签(复用
  sybimport 现有的、基于 purchase.ProcessStages 的阶段筛选机制,无需
  额外服务端改动——process_stage.go 上一次提交已让这两个阶段参与其中)。
- 新增「匹配到的退货商品」列(缩略图/退货订单号/规格/销毁截止),逐行
  「查看对比」「备注」「取消匹配」(后两者仅 canPurchase 可见)。
- 已有效匹配的行不可勾选(isReturnMatchCandidate 排除 return_pending/
  return_used 两个阶段)。
- 匹配详情对话框:SYB/yeeke 双方字段与归一化规格文字并排对比、备注
  编辑、操作日志列表、确认(仅 matched 状态可用)/取消匹配(matched
  或 confirmed 可用)按钮,取消前均有二次确认。

退货商品页 (web/src/views/goauto/yeeke-returns/index.vue):
- 新增「匹配状态」筛选(全部/未匹配/退货待确认/已用退货),对所有用户
  可见(纯只读筛选,不需要 canPurchase)。
- 新增「被占用 SYB 订单商品」列(订单号 + 当前阶段,点击跳转 SYB 商品
  页)。

New file web/src/api/goauto/return-matches.js: listReturnMatches/
getReturnMatch/batchMatchReturns/confirmReturnMatch/cancelReturnMatch/
remarkReturnMatch.

Backend support for the match column: returnmatch.Service.List now
returns ListItem (match + orderSn/image/variationName/destroyDeadline)
via one bounded join over the page's yeeke_return_item ids, so the SYB
product page's match column never needs a second request per row
(service.go, service_test.go: TestList_EnrichesWithYeekeReturnDisplayFields).

采购创建拦截的 UI 呈现沿用现有机制:ProcessStageReturnPending/
ProcessStageReturnUsed 让 isPurchaseCandidate(row) 天然为 false(与其余
非 purchase_ready 阶段的既有行为一致),「采购」按钮不显示,处理阶段列
展示服务端返回的拦截原因文案;未额外改动采购创建流程/UI。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-24 12:03:10 +08:00
QiuSWandClaude Opus 5.5 92c42ba1fe feat(yeeke): add match-status filter and occupying-SYB column to returns list (#338)
ListReturnPackages (app/goauto/yeeke/list.go) gains a single bounded
LEFT JOIN onto return_match (active match only) and syb_product, plus
a matchStatus query param (unmatched/matched/confirmed — invalid
values are rejected, not ignored). ReturnItemRowDTO now carries
matchId/matchStatus/occupyingSybProductId/occupyingSybOrderCode/
occupyingSybStage(+Label). The occupying SYB product's current stage
label is filled from one extra bounded purchase.NewService(db).
ProcessStages call over the page's distinct occupying SYB ids — not a
per-row lookup — so the yeeke returns page's new 匹配状态 filter and
被占用 SYB 订单商品 column need no N+1 query.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-24 11:58:24 +08:00
QiuSWandClaude Opus 5.5 fc99be0392 fix(returnmatch): dedicated log table, race-safe matching, spec/remark fixes (#338)
Review fixes on the #338 backend:

1. New table return_match_log (models/return_match_log.go, registered
   in migrations.MigratedModels()): match_id/action/operator/detail/
   created_at. sys_opera_log is a generic per-HTTP-call framework log,
   not queryable per match and not carrying operator/detail in a
   stable shape, so match/confirm/cancel/remark each write their own
   log row in the SAME transaction as the state change. Detail now
   returns the logs (newest first).

2. Race between matching and purchase creation: BatchMatch's outer
   screening pass (stage check outside any lock) is now followed by
   matchOneWithLock, which takes the same clause.Locking{Strength:
   "UPDATE"} lock on syb_product that purchase.Service.create takes,
   re-computes the stage inside that transaction via
   purchase.NewService(tx).ProcessStages, and returns
   errStageNoLongerEligible (surfaced as reasonCode
   stage_ineligible) if the product is no longer in a participating
   stage instead of inserting a stale match.
   TestMatchOneWithLock_SkipsWhenStageNoLongerParticipatesUnderLock
   covers the skip path.

3. matchOneWithLock now fills YeekeSpecText (raw variation_name) and
   PreviousProcessStage (the stage code at match time) on the
   inserted row. Remark takes an operator (for its log row) and
   rejects input over 500 runes with errRemarkTooLong instead of
   truncating (varchar(500) is a character-count limit in MySQL, so
   the check is utf8.RuneCountInString, not len()).

4. returnmatch.SYBSpecText now joins only non-empty color/size parts,
   so a single-dimension spec (e.g. color-only) no longer produces a
   stray leading/trailing comma ("黑色" instead of "黑色,"); matchKey
   additionally trims leading/trailing commas from both normalized
   sides via the new trimCommas() helper. New tests cover color-only
   and size-only matching through SelectMatches plus SYBSpecText/
   trimCommas directly.

5. Detail (service.go) now returns MatchDetail: SYBDetailView (order
   code, shopee item id, shop, title, target color/size, quantity,
   image, current computed stage+label), YeekeDetailView (return
   order sn, item id, variation id, shop, item name, variation name,
   quantity, image, destroy deadline), both sides' normalized spec
   text, the match row itself, and the operation logs — everything
   the prototype's compare screen (screen 3) needs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-24 11:56:14 +08:00
56 changed files with 3217 additions and 216 deletions
+28 -1
View File
@@ -1,3 +1,19 @@
<!-- gitea-wiki-mirror:start -->
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Architecture-and-Code-Map
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.-
wiki_revision: ad4a5c3c7e4c2e3631856b3021e11560be2e702e
synchronized_at: 2026-09-27T03:42:29Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Architecture-and-Code-Map
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.-
wiki_revision: c021b5b8251fecaf1cfa4b96d731964f6b04ca52
synchronized_at: 2026-09-27T02:47:22Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Architecture-and-Code-Map
@@ -488,7 +504,7 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
### 会话类失败自动重试(#330)
- purchase/order_writeback_worker.go:restoreOrderWritebackClient 在 ImportCookiesJSON 后调用 sybclient.CheckSession,UserID<=0 显式判不可用;finishSessionUnavailable 复用 lease_expires_at 作为退避到期时间(maxSessionRetryAttempts=6,sessionRetryBackoff 5/10/15/30/30m),领取条件增加 failed+SYB_SESSION_UNAVAILABLE+到期+未达上限。无迁移。
- purchase/order_writeback_worker.go:回填通过 `sybclient.AcquireSession` 统一复用/刷新 SYB 会话;`syb_session_auth_lease` 保证同步与回填同时只有一个 worker 触发 OCR 登录,其他任务等待新会话。回填终态同步 `purchase_task.writeback_status/writeback_at`;迁移为 `1789801400000_syb_session_auth_lease.go`。会话类失败仍使用有界退避,冲突不覆盖。
- purchase/order_writeback.go:会话类失败的 CanSubmit 不受退避租约限制;手工重新提交 attempt_count 置 0。
- 验证:go test ./app/goauto/purchase(含 httptest 模拟 /am/user/get 与断言 syb_session 未删除)。
@@ -520,3 +536,14 @@ 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` 路由,支持批量匹配、列表/详情、确认、取消和备注;相关表由版本化迁移创建,采购创建在服务端拦截有效退货匹配,售后角色可执行这些接口。
+28 -1
View File
@@ -1,3 +1,19 @@
<!-- gitea-wiki-mirror:start -->
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Business-Rules-and-Glossary
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.-
wiki_revision: 57af37bf6acf16f86785c989e5c37dce85a3c588
synchronized_at: 2026-09-27T03:42:33Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Business-Rules-and-Glossary
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.-
wiki_revision: 25e61fdbdfe1b3e70fec6d353620170ce14eb31f
synchronized_at: 2026-09-27T02:47:26Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Business-Rules-and-Glossary
@@ -632,7 +648,7 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
采购管理增加独立状态列、批量回填和详情补偿;复用既有访问权限,不增支付确认或审批。批量受理与最终成功分开展示;重试采购和回填分别筛选勾选项。远端无原子CAS,对系统外人工并发修改/超长延迟请求不能承诺绝对互斥;有冲突应人工核对,禁止强制覆盖。
### SYB 会话类失败的有界自动重试(#330)
实现 01510a8/08b7095(2026-09-21,已合并 main,未部署、未生产验证)。回填 worker 从缓存会话恢复客户端后调用 SYB 会话校验;会话缺失/过期、串号失效、校验网络错误等均记为 `SYB_SESSION_UNAVAILABLE`,error_message 只记录类别和“将自动重试;如持续失败请恢复登录后重试”,不含原始错误。该类失败发生在任何写入之前,最多自动重试 6 次,退避 5/10/15/30/30 分钟(约 90 分钟,大于一个整点同步周期),达上限保持 failed 等人工。会话仍只由每小时 SYB 同步刷新;回填不登录、不 OCR、不删除或写入会话。其他失败码仍不自动重试。退避期内可手工重新回填,手工提交重置尝试次数。历史失败记录不会被自动领取。
实现 934a7be(#343,2026-09-27)。回填 worker 与 SYB 同步共用 `sybclient.AcquireSession`:有效缓存会话直接复用,明确失效时由 `syb_session_auth_lease` 保证单 worker OCR 登录,其他任务等待新会话后继续原回填。网络错误不清除有效会话;OCR 未配置或登录失败保留结构化失败并允许人工重试。回填创建时采购任务进入 `pending`,终态同步 `succeeded/failed` 与 `writeback_at`;冲突仍拒绝覆盖。会话类失败继续使用有界退避,成功须回读确认。
## Agent 回填订单入口兼容(#307)
@@ -680,3 +696,14 @@ SYB 回填与资料完整性独立:正式SYB任务已有单号且仍关联有
### 采购手机环境前提
执行采购的 Android 手机必须:PDD App 已登录、微信已登录、PDD 已设置默认收货地址、PDD 默认支付方式为微信支付。规格面板的地址与支付证据依赖该前提;不为支付宝等其他支付方式补充识别别名(有意决定)。条件不满足时 Agent 明确失败,不自动登录、不切换支付方式、不填写地址。
## 管理端售后角色(#341)
- 管理端固定增加 `after_sales`(显示名“售后”)业务角色。售后账号拥有采购员当前权限,并额外可访问“yeeke 退货包裹”和“yeeke 同步记录”两个模块及其接口。
- 售后角色与账号由版本化迁移幂等维护;创建初始账号时由部署环境临时提供密码,迁移不输出或持久化明文密码。已存在账号若绑定其他角色则迁移失败并要求人工处理。
- 本角色不获得管理员专属的用户、角色、设备凭据、规则配置或 AI Provider 管理权限,也不新增支付能力。
## 退货匹配(#338)
退货匹配只由人工勾选 SYB 商品后触发。按虾皮商品 ID 与归一化规格匹配,状态为“退货待确认”或“已用退货”时拦截新采购,取消后恢复可采购;已有采购任务不受影响。售后与采购员可操作匹配、确认、取消和备注。
+7
View File
@@ -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 里被推翻了,
原文和推翻理由都留在这里,方便后来人知道这个决定变过、为什么变:
+27
View File
@@ -1,3 +1,19 @@
<!-- gitea-wiki-mirror:start -->
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Deployment-and-Operations
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-and-Operations.-
wiki_revision: 9c10d971fe1dde057b13972566444a2234dd7f0e
synchronized_at: 2026-09-27T03:42:42Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Deployment-and-Operations
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-and-Operations.-
wiki_revision: 1195a0fab799c064c7e279443dd57977da63c94b
synchronized_at: 2026-09-27T02:47:34Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Deployment-and-Operations
@@ -153,3 +169,14 @@ 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 首页、售后登录及退货匹配只读接口,不用真实商品提交作为健康检查。
+2
View File
@@ -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 ""
}
+12
View File
@@ -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},
+3
View File
@@ -41,6 +41,7 @@ func MigratedModels() []any {
&models.SYBSpecAIParseRun{},
&models.SYBSpecAIParseWorkItem{},
&models.SYBSession{},
&models.SYBSessionAuthLease{},
&models.SYBShop{},
&models.SYBProductFilter{},
&models.SYBSyncRun{},
@@ -80,6 +81,8 @@ func MigratedModels() []any {
&models.PDDProductReplacementItem{},
&models.PDDProductReplacementWorkerLease{},
&models.ReturnMatch{},
&models.ReturnMatchLog{},
&models.ReturnMatchBatch{},
}
}
@@ -0,0 +1,25 @@
package models
import "time"
// ReturnMatchBatch records one click of 「匹配退货」 (#338): who submitted it,
// which SYB products were submitted and the per-product outcome, so a later
// question like "why did only N of my selection match" can be answered from
// data instead of reconstructing the page selection. ItemsJSON holds the
// []BatchMatchItem result array (sybProductId/matched/reasonCode/reason/
// matchId) in submission order.
type ReturnMatchBatch struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
Operator string `json:"operator" gorm:"size:64;not null;default:'';index"`
SubmittedCount int `json:"submittedCount" gorm:"not null;default:0"`
MatchedCount int `json:"matchedCount" gorm:"not null;default:0"`
SkippedCount int `json:"skippedCount" gorm:"not null;default:0"`
ItemsJSON string `json:"-" gorm:"type:longtext;not null"`
// Error is non-empty when the batch aborted part-way; ItemsJSON then
// holds only the rows processed before the failure.
Error string `json:"error" gorm:"size:500;not null;default:''"`
CreatedAt time.Time `json:"createdAt" gorm:"index"`
}
func (ReturnMatchBatch) TableName() string { return "return_match_batch" }
@@ -0,0 +1,28 @@
package models
import "time"
// ReturnMatchLog is #338's dedicated operation log for the return-matching
// feature. The framework's generic sys_opera_log records raw HTTP
// request/response per call and is not queryable per match id nor does it
// carry a stable action/operator/detail shape, so this table is written
// explicitly, in the same transaction as the action it records (match /
// confirm / cancel / remark).
const (
ReturnMatchLogActionMatched = "matched"
ReturnMatchLogActionConfirmed = "confirmed"
ReturnMatchLogActionCancelled = "cancelled"
ReturnMatchLogActionRemark = "remark"
)
type ReturnMatchLog struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
MatchID uint64 `json:"matchId" gorm:"not null;index"`
Action string `json:"action" gorm:"size:16;not null;index;check:ck_return_match_log_action,action IN ('matched','confirmed','cancelled','remark')"`
Operator string `json:"operator" gorm:"size:64;not null;default:''"`
Detail string `json:"detail" gorm:"size:1000;not null;default:''"`
CreatedAt time.Time `json:"createdAt"`
}
func (ReturnMatchLog) TableName() string { return "return_match_log" }
+11
View File
@@ -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
+37 -24
View File
@@ -38,9 +38,14 @@ type YeekeReturnPackage struct {
UpdateTime *time.Time
DestroyDeadLine *time.Time
LastSyncedAt time.Time `gorm:"not null;index"`
SyncStatus string `gorm:"size:32;not null;default:'ok'"`
CreatedAt time.Time
UpdatedAt time.Time
// SyncStatus is "ok" while the package still appears in a COMPLETE yeeke
// sync; #338 sets it to "missing" (with MissingSince stamped) once a
// completed sync no longer sees it, so return matching stops using it.
// It is never deleted or marked "已销毁" — only flagged unavailable.
SyncStatus string `gorm:"size:32;not null;default:'ok';index"`
MissingSince *time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
func (YeekeReturnPackage) TableName() string { return "yeeke_return_package" }
@@ -56,32 +61,40 @@ type YeekeReturnItem struct {
Image string `gorm:"type:text;not null"`
Quantity int64 `gorm:"not null;default:0"`
LastSyncedAt time.Time `gorm:"not null;index"`
SyncStatus string `gorm:"size:32;not null;default:'ok'"`
CreatedAt time.Time
UpdatedAt time.Time
// SyncStatus/MissingSince mirror YeekeReturnPackage's fields (#338): once
// a COMPLETE yeeke sync no longer sees this item it is flagged "missing"
// so returnmatch.availableReturnPool stops offering it, without ever
// deleting the row.
SyncStatus string `gorm:"size:32;not null;default:'ok';index"`
MissingSince *time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
func (YeekeReturnItem) TableName() string { return "yeeke_return_item" }
type YeekeSyncRun struct {
ID uint64 `gorm:"primaryKey;autoIncrement"`
Status string `gorm:"size:32;not null;index"`
Trigger string `gorm:"size:32;not null;index"`
TotalPages int `gorm:"not null;default:0"`
ReadCount int `gorm:"not null;default:0"`
CreatedCount int `gorm:"not null;default:0"`
UpdatedCount int `gorm:"not null;default:0"`
SkippedCount int `gorm:"not null;default:0"`
FailedCount int `gorm:"not null;default:0"`
ErrorMessage string `gorm:"size:1000;not null;default:''"`
StartedAt time.Time `gorm:"not null"`
FinishedAt *time.Time
LastSuccessAt *time.Time
ActiveSlot *uint8 `gorm:"uniqueIndex:ux_yeeke_sync_run_active_slot"`
LeaseOwner string `gorm:"size:128;not null;default:''"`
LeaseExpiresAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
ID uint64 `gorm:"primaryKey;autoIncrement"`
Status string `gorm:"size:32;not null;index"`
Trigger string `gorm:"size:32;not null;index"`
TotalPages int `gorm:"not null;default:0"`
ReadCount int `gorm:"not null;default:0"`
CreatedCount int `gorm:"not null;default:0"`
UpdatedCount int `gorm:"not null;default:0"`
SkippedCount int `gorm:"not null;default:0"`
FailedCount int `gorm:"not null;default:0"`
// #338: items flipped to "missing" / back to "ok" by this run.
MissingMarkedCount int `gorm:"not null;default:0"`
RecoveredCount int `gorm:"not null;default:0"`
ErrorMessage string `gorm:"size:1000;not null;default:''"`
StartedAt time.Time `gorm:"not null"`
FinishedAt *time.Time
LastSuccessAt *time.Time
ActiveSlot *uint8 `gorm:"uniqueIndex:ux_yeeke_sync_run_active_slot"`
LeaseOwner string `gorm:"size:128;not null;default:''"`
LeaseExpiresAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
func (YeekeSyncRun) TableName() string { return "yeeke_sync_run" }
+2 -2
View File
@@ -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
}
@@ -0,0 +1,26 @@
package purchase
import (
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
)
func TestAllowedOperatorIncludesAfterSales(t *testing.T) {
for _, role := range []string{"admin", "purchaser", "after_sales", "other", ""} {
t.Run(role, func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("JWT_PAYLOAD", jwt.MapClaims{"rolekey": role})
want := role == "admin" || role == "purchaser" || role == "after_sales"
if got := allowedOperator(c); got != want {
t.Fatalf("role %q allowed=%v want %v", role, got, want)
}
if !want && w.Code != 403 {
t.Fatalf("denied role status=%d", w.Code)
}
})
}
}
@@ -96,7 +96,7 @@ func TestOrderBackfillMixedBatchAndReplay(t *testing.T) {
if saved.StatusVersion != a.StatusVersion+1 || saved.ErrorCode != nil || saved.ErrorMessage != nil || saved.DeviceRunSlot != nil || saved.AccountRunSlot != nil || saved.ActiveSlot == nil || saved.Status != models.PurchaseTaskStatusOrderCreated {
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()} {
@@ -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) {
@@ -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")
}
})
@@ -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 {
@@ -0,0 +1,80 @@
package returnmatch
import (
"context"
"testing"
"time"
"go-admin/app/goauto/models"
)
// #338: every 「匹配退货」 click is recorded with the submitted SYB products in
// order and each one's outcome, so "why did only some of my selection match"
// can be answered from data.
func TestBatchMatch_RecordsSubmittedProductsAndOutcomes(t *testing.T) {
db := testDB(t)
s := NewService(db)
s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
hit := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 2, 0, 0, 0, 0, time.UTC))
miss := seedSYB(t, db, "SYB-2", 2, "黑色", "XL", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
seedReturn(t, db, "白色,L", &deadline)
missingID := uint64(999999)
submitted := []uint64{miss.ID, hit.ID, missingID}
resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: submitted, Operator: "caigou1"})
if err != nil {
t.Fatal(err)
}
batches, err := s.ListBatches(context.Background(), 10, true)
if err != nil {
t.Fatal(err)
}
if len(batches) != 1 {
t.Fatalf("want exactly one batch record, got %d", len(batches))
}
b := batches[0]
if b.Operator != "caigou1" || b.SubmittedCount != 3 || b.MatchedCount != 1 || b.SkippedCount != 2 || b.Error != "" {
t.Fatalf("unexpected batch header: %+v", b.ReturnMatchBatch)
}
if len(b.Items) != len(submitted) {
t.Fatalf("want %d items, got %+v", len(submitted), b.Items)
}
for i, id := range submitted {
if b.Items[i].SYBProductID != id {
t.Fatalf("item %d: want submitted order id %d, got %d", i, id, b.Items[i].SYBProductID)
}
}
if b.Items[0].ReasonCode != ReasonNoCandidate || !b.Items[1].Matched || b.Items[1].MatchID != resp.Items[1].MatchID || b.Items[2].ReasonCode != ReasonStageIneligible {
t.Fatalf("unexpected recorded outcomes: %+v", b.Items)
}
got, err := s.GetBatch(context.Background(), b.ID)
if err != nil {
t.Fatal(err)
}
if len(got.Items) != 3 {
t.Fatalf("GetBatch items: %+v", got.Items)
}
// The list without items stays small.
light, err := s.ListBatches(context.Background(), 10, false)
if err != nil || len(light) != 1 || light[0].Items != nil {
t.Fatalf("list without items: %+v err=%v", light, err)
}
}
// An empty submission is rejected before matching and leaves no record.
func TestBatchMatch_EmptySubmissionNotRecorded(t *testing.T) {
db := testDB(t)
if _, err := NewService(db).BatchMatch(context.Background(), BatchMatchRequest{Operator: "x"}); err != nil {
t.Fatal(err)
}
var n int64
db.Model(&models.ReturnMatchBatch{}).Count(&n)
if n != 0 {
t.Fatalf("empty submission recorded %d batches", n)
}
}
+2 -2
View File
@@ -70,7 +70,7 @@ func SelectMatches(products []SYBCandidate, returns []ReturnCandidate, now time.
if !r.DestroyDeadline.After(now) {
continue
}
key := matchKey(r.ItemID, Normalize(r.VariationName))
key := matchKey(r.ItemID, trimCommas(Normalize(r.VariationName)))
buckets[key] = append(buckets[key], r)
}
for key := range buckets {
@@ -84,7 +84,7 @@ func SelectMatches(products []SYBCandidate, returns []ReturnCandidate, now time.
used := make(map[uint64]bool)
outcomes := make([]MatchOutcome, 0, len(products))
for _, p := range products {
key := matchKey(p.ShopeeItemID, Normalize(SYBSpecText(p.TargetColor, p.TargetSize)))
key := matchKey(p.ShopeeItemID, trimCommas(Normalize(SYBSpecText(p.TargetColor, p.TargetSize))))
var picked *ReturnCandidate
for i := range buckets[key] {
cand := buckets[key][i]
@@ -118,6 +118,26 @@ func TestSelectMatches_QuantityIgnored(t *testing.T) {
}
}
func TestSelectMatches_ColorOnlySYBSpecMatchesPlainYeekeText(t *testing.T) {
now := t1(0)
products := []SYBCandidate{{SYBProductID: 1, ShopeeItemID: "100", TargetColor: "黑色", TargetSize: "", CreatedAt: t1(-1)}}
returns := []ReturnCandidate{{ReturnItemID: 900, ItemID: "100", VariationName: "黑色", DestroyDeadline: t1(10)}}
out := SelectMatches(products, returns, now)
if !out[0].Matched || out[0].ReturnItemID != 900 {
t.Fatalf("color-only spec should still match: %+v", out[0])
}
}
func TestSelectMatches_SizeOnlySYBSpecMatchesPlainYeekeText(t *testing.T) {
now := t1(0)
products := []SYBCandidate{{SYBProductID: 1, ShopeeItemID: "100", TargetColor: "", TargetSize: "L", CreatedAt: t1(-1)}}
returns := []ReturnCandidate{{ReturnItemID: 900, ItemID: "100", VariationName: "L", DestroyDeadline: t1(10)}}
out := SelectMatches(products, returns, now)
if !out[0].Matched || out[0].ReturnItemID != 900 {
t.Fatalf("size-only spec should still match: %+v", out[0])
}
}
func TestSelectMatches_DifferentItemIDNeverMatches(t *testing.T) {
now := t1(0)
products := []SYBCandidate{{SYBProductID: 1, ShopeeItemID: "100", TargetColor: "白色", TargetSize: "L", CreatedAt: t1(-1)}}
+54 -6
View File
@@ -44,14 +44,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
@@ -77,6 +77,10 @@ func (h Handler) BatchMatch(c *gin.Context) {
}
_, operator := operatorFromContext(c)
resp, err := NewService(db).BatchMatch(c.Request.Context(), BatchMatchRequest{SYBProductIDs: body.SYBProductIDs, Operator: operator})
if errors.Is(err, ErrTooManyItems) {
c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": ErrTooManyItems.Error()})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
return
@@ -116,7 +120,7 @@ func (h Handler) Detail(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "id 无效"})
return
}
match, err := NewService(db).Detail(c.Request.Context(), id)
detail, err := NewService(db).Detail(c.Request.Context(), id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"code": "NOT_FOUND", "message": "匹配记录不存在"})
@@ -125,7 +129,7 @@ func (h Handler) Detail(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
return
}
c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{"item": match}})
c.JSON(http.StatusOK, gin.H{"code": 200, "data": detail})
}
func (h Handler) Confirm(c *gin.Context) {
@@ -192,12 +196,17 @@ func (h Handler) Remark(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "请求体无效"})
return
}
match, err := NewService(db).Remark(c.Request.Context(), id, body.Remark)
_, operator := operatorFromContext(c)
match, err := NewService(db).Remark(c.Request.Context(), id, operator, body.Remark)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"code": "NOT_FOUND", "message": "匹配记录不存在"})
return
}
if errors.Is(err, errRemarkTooLong) {
c.JSON(http.StatusBadRequest, gin.H{"code": "REMARK_TOO_LONG", "message": "备注不能超过 500 字"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
return
}
@@ -213,3 +222,42 @@ func parseUint64List(raw []string) []uint64 {
}
return out
}
// ListBatches returns recent 「匹配退货」 batch records (who, when, submitted
// and matched counts); pass withItems=1 to include each row's outcome.
func (h Handler) ListBatches(c *gin.Context) {
db, ok := h.db(c)
if !ok {
return
}
limit, _ := strconv.Atoi(c.Query("limit"))
rows, err := NewService(db).ListBatches(c.Request.Context(), limit, c.Query("withItems") == "1")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
return
}
c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{"items": rows}})
}
// GetBatch returns one batch record with every submitted SYB product's outcome.
func (h Handler) GetBatch(c *gin.Context) {
db, ok := h.db(c)
if !ok {
return
}
id, err := strconv.ParseUint(c.Param("batchId"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "batchId 无效"})
return
}
rec, err := NewService(db).GetBatch(c.Request.Context(), id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"code": "NOT_FOUND", "message": "批量匹配记录不存在"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
return
}
c.JSON(http.StatusOK, gin.H{"code": 200, "data": rec})
}
@@ -0,0 +1,26 @@
package returnmatch
import (
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
)
func TestReturnMatchWriteRoles(t *testing.T) {
for _, role := range []string{"admin", "purchaser", "after_sales", "other", ""} {
t.Run(role, func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("JWT_PAYLOAD", jwt.MapClaims{"rolekey": role})
want := role == "admin" || role == "purchaser" || role == "after_sales"
if got := requireCanPurchase(c); got != want {
t.Fatalf("role %q allowed=%v want %v", role, got, want)
}
if !want && w.Code != 403 {
t.Fatalf("denied role status=%d", w.Code)
}
})
}
}
@@ -0,0 +1,158 @@
package returnmatch
import (
"context"
"testing"
"time"
"go-admin/app/goauto/models"
)
// TestBatchMatch_MissingItemNotMatched: a return item whose sync_status is
// "missing" (#338: dropped from a COMPLETE yeeke sync) must never be offered
// to matching, even though it otherwise satisfies every other rule (an
// eligible SYB candidate, a future destroy deadline, no active match).
func TestBatchMatch_MissingItemNotMatched(t *testing.T) {
db := testDB(t)
s := NewService(db)
s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
syb := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
ret := seedReturn(t, db, "白色,L【建議65-75公斤】", &deadline)
now := time.Now().UTC()
if err := db.Model(&models.YeekeReturnItem{}).Where("id = ?", ret.ID).
Updates(map[string]any{"sync_status": "missing", "missing_since": now}).Error; err != nil {
t.Fatal(err)
}
resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}, Operator: "tester"})
if err != nil {
t.Fatal(err)
}
if resp.MatchedCount != 0 || resp.SkippedCount != 1 {
t.Fatalf("expected the missing item to be skipped, not matched: %+v", resp)
}
if resp.Items[0].ReasonCode != ReasonNoCandidate {
t.Fatalf("expected no_candidate (the only candidate is missing): %+v", resp.Items[0])
}
}
// TestBatchMatch_MissingPackageNotMatched: same as above but the ITEM itself
// is still "ok" while its PACKAGE is "missing" — availableReturnPool must
// exclude it too, since #338's rule is "an item, or an item whose package,
// is no longer ok".
func TestBatchMatch_MissingPackageNotMatched(t *testing.T) {
db := testDB(t)
s := NewService(db)
s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
syb := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
ret := seedReturn(t, db, "白色,L【建議65-75公斤】", &deadline)
now := time.Now().UTC()
if err := db.Model(&models.YeekeReturnPackage{}).Where("id = ?", ret.PackageID).
Updates(map[string]any{"sync_status": "missing", "missing_since": now}).Error; err != nil {
t.Fatal(err)
}
resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}, Operator: "tester"})
if err != nil {
t.Fatal(err)
}
if resp.MatchedCount != 0 || resp.SkippedCount != 1 {
t.Fatalf("expected the item to be skipped because its package is missing: %+v", resp)
}
}
// TestActiveMatchSurvivesItemGoingMissing: issue #338's rule 6 — an existing
// active match is NOT auto-cancelled when its return item later becomes
// missing. Detail() must still report it (SYB side unaffected) and surface
// the yeeke side's syncStatus="missing" so the UI can show the warning.
func TestActiveMatchSurvivesItemGoingMissing(t *testing.T) {
db := testDB(t)
s := NewService(db)
s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
syb := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
ret := seedReturn(t, db, "白色,L【建議65-75公斤】", &deadline)
resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}, Operator: "tester"})
if err != nil {
t.Fatal(err)
}
if resp.MatchedCount != 1 {
t.Fatalf("expected a match before the item goes missing: %+v", resp)
}
matchID := resp.Items[0].MatchID
// The return item now drops out of a COMPLETE yeeke sync.
missingSince := time.Now().UTC()
if err := db.Model(&models.YeekeReturnItem{}).Where("id = ?", ret.ID).
Updates(map[string]any{"sync_status": "missing", "missing_since": missingSince}).Error; err != nil {
t.Fatal(err)
}
var match models.ReturnMatch
if err := db.First(&match, matchID).Error; err != nil {
t.Fatal(err)
}
if match.Status != models.ReturnMatchStatusMatched || match.ActiveYeekeReturnItemID == nil {
t.Fatalf("existing match must stay active when its return item goes missing, got %+v", match)
}
detail, err := s.Detail(context.Background(), matchID)
if err != nil {
t.Fatal(err)
}
if detail.Yeeke == nil {
t.Fatal("Detail must still resolve the yeeke side (the row was never deleted)")
}
if detail.Yeeke.SyncStatus != "missing" || detail.Yeeke.MissingSince == nil {
t.Fatalf("Detail must report the return item's syncStatus=missing, got %+v", detail.Yeeke)
}
if detail.SYB == nil || detail.SYB.SYBProductID != syb.ID {
t.Fatalf("SYB side must be unaffected by the return item going missing, got %+v", detail.SYB)
}
}
// TestList_ReportsMissingReturnSyncStatus: the SYB products match column and
// the compare dialog read List()'s syncStatus/missingSince fields (#338); a
// match whose return item is missing must carry them through.
func TestList_ReportsMissingReturnSyncStatus(t *testing.T) {
db := testDB(t)
s := NewService(db)
s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
syb := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
ret := seedReturn(t, db, "白色,L【建議65-75公斤】", &deadline)
resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}, Operator: "tester"})
if err != nil {
t.Fatal(err)
}
if resp.MatchedCount != 1 {
t.Fatalf("expected a match: %+v", resp)
}
missingSince := time.Now().UTC()
if err := db.Model(&models.YeekeReturnItem{}).Where("id = ?", ret.ID).
Updates(map[string]any{"sync_status": "missing", "missing_since": missingSince}).Error; err != nil {
t.Fatal(err)
}
items, err := s.List(context.Background(), ListFilter{SYBProductIDs: []uint64{syb.ID}})
if err != nil {
t.Fatal(err)
}
if len(items) != 1 {
t.Fatalf("expected one list row, got %d", len(items))
}
if items[0].SyncStatus != "missing" || items[0].MissingSince == nil {
t.Fatalf("List() must surface the missing return item's status, got %+v", items[0])
}
}
+21 -2
View File
@@ -94,7 +94,26 @@ func isSpace(r rune) bool {
}
// SYBSpecText builds the SYB-side comparable spec text from the parser's
// target_color/target_size fields, per issue #338's data design.
// target_color/target_size fields, per issue #338's data design. Only
// non-empty parts are joined so a single-dimension spec (color-only or
// size-only) doesn't pick up a stray leading/trailing comma that would
// prevent it from normalizing equal to the yeeke side's single-value
// variation_name (e.g. SYBSpecText("黑色", "") must be "黑色", not "黑色,").
func SYBSpecText(targetColor, targetSize string) string {
return targetColor + "," + targetSize
parts := make([]string, 0, 2)
if targetColor != "" {
parts = append(parts, targetColor)
}
if targetSize != "" {
parts = append(parts, targetSize)
}
return strings.Join(parts, ",")
}
// trimCommas removes leading/trailing commas left over after normalization
// (e.g. a source value that itself started or ended with a comma). Used only
// when building the matching key, so exact-text comparisons/tests elsewhere
// are unaffected.
func trimCommas(s string) string {
return strings.Trim(s, ",")
}
@@ -49,3 +49,30 @@ func TestSYBSpecText(t *testing.T) {
t.Fatalf("got %q", got)
}
}
func TestSYBSpecText_SingleDimension(t *testing.T) {
if got := SYBSpecText("黑色", ""); got != "黑色" {
t.Fatalf("color-only: got %q, want %q (no trailing comma)", got, "黑色")
}
if got := SYBSpecText("", "L"); got != "L" {
t.Fatalf("size-only: got %q, want %q (no leading comma)", got, "L")
}
if got := SYBSpecText("", ""); got != "" {
t.Fatalf("both empty: got %q, want empty", got)
}
}
func TestTrimCommas(t *testing.T) {
cases := map[string]string{
"黑色,": "黑色",
",黑色": "黑色",
",黑色,": "黑色",
"黑色": "黑色",
"": "",
}
for in, want := range cases {
if got := trimCommas(in); got != want {
t.Fatalf("trimCommas(%q) = %q, want %q", in, got, want)
}
}
}
+4 -1
View File
@@ -7,12 +7,15 @@ 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{}
group := engine.Group("/api/admin/v1/return-matches").Use(auth.MiddlewareFunc())
group.GET("", handler.List)
// Static batch routes are registered before /:id so they never parse as a match id.
group.GET("/batches", handler.ListBatches)
group.GET("/batches/:batchId", handler.GetBatch)
group.GET("/:id", handler.Detail)
group.POST("/batch-match", handler.BatchMatch)
group.POST("/:id/confirm", handler.Confirm)
+379 -29
View File
@@ -2,9 +2,13 @@ package returnmatch
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"strings"
"time"
"unicode/utf8"
"go-admin/app/goauto/models"
"go-admin/app/goauto/purchase"
@@ -64,10 +68,110 @@ const (
ReasonConflict = "conflict"
)
// maxBatchMatchItems caps a single「匹配退货」submission (#339: SYB list page
// size can now go up to 500/page, so the button's selection can exceed the
// previously-unbounded batch-match request size).
const maxBatchMatchItems = 500
// ErrTooManyItems is returned by BatchMatch when the caller submits more than
// maxBatchMatchItems SYB product ids; the handler turns this into an
// INVALID_REQUEST response instead of a 500.
var ErrTooManyItems = errors.New("sybProductIds 一次最多 500 条")
// BatchMatch implements issue #338's manual "匹配退货" trigger. It is only
// ever called from the batch-match button (ticked rows) — no scheduler, no
// yeeke-sync/SYB-import hook calls this (rule: 手动触发, 无定时任务).
func (s *Service) BatchMatch(ctx context.Context, req BatchMatchRequest) (BatchMatchResponse, error) {
if len(req.SYBProductIDs) > maxBatchMatchItems {
return BatchMatchResponse{}, ErrTooManyItems
}
resp, err := s.batchMatch(ctx, req)
if len(req.SYBProductIDs) > 0 {
// The batch record is written after the per-row transactions have
// committed, so a failure here must not turn an already-applied
// match into an error response; it is logged instead.
if logErr := s.recordBatch(ctx, req, resp, err); logErr != nil {
log.Printf("return match batch record failed: operator=%s submitted=%d: %v", req.Operator, len(req.SYBProductIDs), logErr)
}
}
return resp, err
}
// recordBatch stores one ReturnMatchBatch row describing this click of
// 「匹配退货」: submitted SYB products in order and each one's outcome.
func (s *Service) recordBatch(ctx context.Context, req BatchMatchRequest, resp BatchMatchResponse, batchErr error) error {
items := resp.Items
if items == nil {
items = []BatchMatchItem{}
}
raw, err := json.Marshal(items)
if err != nil {
return err
}
row := models.ReturnMatchBatch{
Operator: req.Operator,
SubmittedCount: len(req.SYBProductIDs),
MatchedCount: resp.MatchedCount,
SkippedCount: resp.SkippedCount,
ItemsJSON: string(raw),
}
if batchErr != nil {
row.Error = truncateRunes(batchErr.Error(), 500)
}
return s.DB.WithContext(ctx).Create(&row).Error
}
func truncateRunes(v string, max int) string {
r := []rune(v)
if len(r) <= max {
return v
}
return string(r[:max])
}
// BatchRecord is the API view of one ReturnMatchBatch row.
type BatchRecord struct {
models.ReturnMatchBatch
Items []BatchMatchItem `json:"items,omitempty"`
}
// ListBatches returns the most recent batch records, newest first; items are
// only expanded when withItems is true to keep the list response small.
func (s *Service) ListBatches(ctx context.Context, limit int, withItems bool) ([]BatchRecord, error) {
if limit <= 0 || limit > 100 {
limit = 20
}
var rows []models.ReturnMatchBatch
if err := s.DB.WithContext(ctx).Order("id DESC").Limit(limit).Find(&rows).Error; err != nil {
return nil, err
}
out := make([]BatchRecord, 0, len(rows))
for _, row := range rows {
rec := BatchRecord{ReturnMatchBatch: row}
if withItems {
if err := json.Unmarshal([]byte(row.ItemsJSON), &rec.Items); err != nil {
return nil, err
}
}
out = append(out, rec)
}
return out, nil
}
// GetBatch returns one batch record with its items expanded.
func (s *Service) GetBatch(ctx context.Context, id uint64) (BatchRecord, error) {
var row models.ReturnMatchBatch
if err := s.DB.WithContext(ctx).First(&row, id).Error; err != nil {
return BatchRecord{}, err
}
rec := BatchRecord{ReturnMatchBatch: row}
if err := json.Unmarshal([]byte(row.ItemsJSON), &rec.Items); err != nil {
return BatchRecord{}, err
}
return rec, nil
}
func (s *Service) batchMatch(ctx context.Context, req BatchMatchRequest) (BatchMatchResponse, error) {
resp := BatchMatchResponse{Items: make([]BatchMatchItem, 0, len(req.SYBProductIDs))}
if len(req.SYBProductIDs) == 0 {
return resp, nil
@@ -132,8 +236,19 @@ func (s *Service) BatchMatch(ctx context.Context, req BatchMatchRequest) (BatchM
resp.SkippedCount++
continue
}
match, insertErr := s.insertMatch(ctx, sybByID[id], outcome, req.Operator)
match, insertErr := s.matchOneWithLock(ctx, id, outcome, req.Operator)
if insertErr != nil {
if errors.Is(insertErr, errStageNoLongerEligible) {
// #338 review fix: the stage was re-checked under the same
// FOR UPDATE lock purchase.create takes, right before insert.
// A purchase task could have been created for this row
// between the outer screening pass above and this point;
// when that happens the product is no longer in a
// participating stage and this row is skipped, not matched.
resp.Items = append(resp.Items, BatchMatchItem{SYBProductID: id, Matched: false, ReasonCode: ReasonStageIneligible, Reason: "处理阶段已变化,不再参与匹配"})
resp.SkippedCount++
continue
}
if isUniqueConstraintErr(insertErr) {
// Rule 7 / acceptance item 9: a concurrent insert lost the
// race for either side — skip this row and report it, never
@@ -162,6 +277,9 @@ func sortSYBCandidatesDesc(items []SYBCandidate) {
// availableReturnPool loads every yeeke return item with no active match and
// a destroy deadline (rule 2: empty deadline is unavailable, handled by the
// NULL exclusion below; "later than now" is enforced by SelectMatches).
// #338 scope addition: an item, or an item whose package, is no longer
// sync_status="ok" (i.e. a COMPLETE yeeke sync stopped seeing it) is
// excluded here so matching never offers a return yeeke has dropped.
func (s *Service) availableReturnPool(ctx context.Context) ([]ReturnCandidate, error) {
var rows []struct {
ID uint64
@@ -173,7 +291,7 @@ func (s *Service) availableReturnPool(ctx context.Context) ([]ReturnCandidate, e
Select("i.id AS id, i.item_id AS item_id, i.variation_name AS variation_name, p.destroy_dead_line AS destroy_dead_line").
Joins("JOIN yeeke_return_package AS p ON p.id = i.package_id").
Joins("LEFT JOIN return_match AS m ON m.active_yeeke_return_item_id = i.id").
Where("m.id IS NULL").
Where("m.id IS NULL AND i.sync_status = ? AND p.sync_status = ?", "ok", "ok").
Find(&rows).Error
if err != nil {
return nil, err
@@ -188,23 +306,66 @@ func (s *Service) availableReturnPool(ctx context.Context) ([]ReturnCandidate, e
return out, nil
}
func (s *Service) insertMatch(ctx context.Context, syb models.SYBProduct, outcome MatchOutcome, operator string) (models.ReturnMatch, error) {
sybID := syb.ID
returnID := outcome.ReturnItemID
now := s.Now()
match := models.ReturnMatch{
SYBProductID: sybID, YeekeReturnItemID: returnID,
ActiveSYBProductID: &sybID, ActiveYeekeReturnItemID: &returnID,
Status: models.ReturnMatchStatusMatched,
SYBSpecText: SYBSpecText(syb.TargetColor, syb.TargetSize),
NormalizedKey: outcome.NormalizedKey,
MatchedBy: operator, MatchedAt: now,
DestroyDeadlineSnapshot: &outcome.DestroyDeadline,
}
if err := s.DB.WithContext(ctx).Create(&match).Error; err != nil {
return models.ReturnMatch{}, err
}
return match, nil
// errStageNoLongerEligible is returned by matchOneWithLock when the SYB
// product's process stage, re-checked under lock immediately before insert,
// is no longer in participatingStages — e.g. a purchase task was created for
// it between BatchMatch's outer screening pass and this point (#338 review
// fix: race between matching and purchase creation).
var errStageNoLongerEligible = errors.New("syb product stage no longer participates in matching")
// matchOneWithLock takes the SAME row lock purchase.Service.create takes on
// syb_product (clause.Locking{Strength: "UPDATE"}) and re-computes the
// process stage inside that transaction via purchase.NewService(tx) before
// inserting the match, so a purchase task creation racing with this batch
// match can never both succeed: whichever gets the row lock first commits,
// and the other sees the now-current state (an active return_match row, or
// a task_created/order_review/purchase_succeeded stage) and is rejected/
// skipped instead of double-committing an inconsistent state.
func (s *Service) matchOneWithLock(ctx context.Context, sybID uint64, outcome MatchOutcome, operator string) (models.ReturnMatch, error) {
var match models.ReturnMatch
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var syb models.SYBProduct
if err := tx.Clauses(clauseLockUpdate()).First(&syb, sybID).Error; err != nil {
return err
}
stages, err := purchase.NewService(tx).ProcessStages(ctx, []uint64{sybID})
if err != nil {
return err
}
stage := stages[sybID]
if !participatingStages[stage.Stage] {
return errStageNoLongerEligible
}
var returnItem models.YeekeReturnItem
if err := tx.First(&returnItem, outcome.ReturnItemID).Error; err != nil {
return err
}
sybIDCopy := syb.ID
returnIDCopy := outcome.ReturnItemID
deadline := outcome.DestroyDeadline
now := s.Now()
match = models.ReturnMatch{
SYBProductID: sybIDCopy, YeekeReturnItemID: returnIDCopy,
ActiveSYBProductID: &sybIDCopy, ActiveYeekeReturnItemID: &returnIDCopy,
Status: models.ReturnMatchStatusMatched,
SYBSpecText: SYBSpecText(syb.TargetColor, syb.TargetSize),
YeekeSpecText: returnItem.VariationName,
NormalizedKey: outcome.NormalizedKey,
PreviousProcessStage: stage.Stage,
MatchedBy: operator,
MatchedAt: now,
DestroyDeadlineSnapshot: &deadline,
}
if err := tx.Create(&match).Error; err != nil {
return err
}
logRow := models.ReturnMatchLog{
MatchID: match.ID, Action: models.ReturnMatchLogActionMatched, Operator: operator,
Detail: fmt.Sprintf("匹配退货商品 #%d(%s),此前处理阶段:%s", returnIDCopy, returnItem.VariationName, stage.Label),
}
return tx.Create(&logRow).Error
})
return match, err
}
func isUniqueConstraintErr(err error) bool {
@@ -227,7 +388,10 @@ func (s *Service) Confirm(ctx context.Context, matchID uint64, operator string)
match.Status = models.ReturnMatchStatusConfirmed
match.ConfirmedBy = operator
match.ConfirmedAt = &now
return tx.Save(&match).Error
if err := tx.Save(&match).Error; err != nil {
return err
}
return tx.Create(&models.ReturnMatchLog{MatchID: match.ID, Action: models.ReturnMatchLogActionConfirmed, Operator: operator, Detail: "确认匹配,商品状态变为已用退货"}).Error
})
return match, err
}
@@ -251,19 +415,35 @@ func (s *Service) Cancel(ctx context.Context, matchID uint64, operator string) (
match.ActiveYeekeReturnItemID = nil
match.CancelledBy = operator
match.CancelledAt = &now
return tx.Save(&match).Error
if err := tx.Save(&match).Error; err != nil {
return err
}
return tx.Create(&models.ReturnMatchLog{MatchID: match.ID, Action: models.ReturnMatchLogActionCancelled, Operator: operator, Detail: "取消匹配,SYB 商品恢复可采购,退货商品回到可用池"}).Error
})
return match, err
}
func (s *Service) Remark(ctx context.Context, matchID uint64, remark string) (models.ReturnMatch, error) {
// errRemarkTooLong is returned instead of silently truncating; remark is a
// varchar(500) column and MySQL VARCHAR length is a character count, so this
// checks runes, not bytes.
var errRemarkTooLong = errors.New("remark exceeds 500 characters")
const maxRemarkLength = 500
func (s *Service) Remark(ctx context.Context, matchID uint64, operator, remark string) (models.ReturnMatch, error) {
if utf8.RuneCountInString(remark) > maxRemarkLength {
return models.ReturnMatch{}, errRemarkTooLong
}
var match models.ReturnMatch
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.First(&match, matchID).Error; err != nil {
return err
}
match.Remark = remark
return tx.Save(&match).Error
if err := tx.Save(&match).Error; err != nil {
return err
}
return tx.Create(&models.ReturnMatchLog{MatchID: match.ID, Action: models.ReturnMatchLogActionRemark, Operator: operator, Detail: remark}).Error
})
return match, err
}
@@ -276,7 +456,24 @@ type ListFilter struct {
Status string
}
func (s *Service) List(ctx context.Context, filter ListFilter) ([]models.ReturnMatch, error) {
// ListItem is one row of List's result: the match itself plus a few display
// fields the SYB product page's match column needs (thumbnail, return order
// sn, spec) so it never has to issue a second request per row.
type ListItem struct {
models.ReturnMatch
OrderSN string `json:"orderSn,omitempty"`
Image string `json:"image,omitempty"`
VariationName string `json:"variationName,omitempty"`
DestroyDeadline *time.Time `json:"destroyDeadline,omitempty"`
// SyncStatus/MissingSince (#338) surface the yeeke return item's own
// current availability ("ok"/"missing") so the SYB products match column
// and the compare dialog can warn even on an existing, already-matched
// pair whose return later dropped out of a COMPLETE yeeke sync.
SyncStatus string `json:"syncStatus,omitempty"`
MissingSince *time.Time `json:"missingSince,omitempty"`
}
func (s *Service) List(ctx context.Context, filter ListFilter) ([]ListItem, error) {
q := s.DB.WithContext(ctx).Model(&models.ReturnMatch{})
if len(filter.SYBProductIDs) > 0 {
q = q.Where("syb_product_id IN ?", filter.SYBProductIDs)
@@ -288,14 +485,167 @@ func (s *Service) List(ctx context.Context, filter ListFilter) ([]models.ReturnM
q = q.Where("status = ?", filter.Status)
}
var rows []models.ReturnMatch
err := q.Order("id DESC").Find(&rows).Error
return rows, err
if err := q.Order("id DESC").Find(&rows).Error; err != nil {
return nil, err
}
items := make([]ListItem, len(rows))
returnIDs := make([]uint64, 0, len(rows))
for i, r := range rows {
items[i] = ListItem{ReturnMatch: r}
returnIDs = append(returnIDs, r.YeekeReturnItemID)
}
if len(returnIDs) == 0 {
return items, nil
}
// One bounded join query for every return item referenced on this page —
// never a per-row lookup.
var joined []struct {
ID uint64
OrderSN string
Image string
VariationName string
DestroyDeadLine *time.Time
SyncStatus string
MissingSince *time.Time
PackageSyncStat string `gorm:"column:package_sync_status"`
PackageMissingAt *time.Time `gorm:"column:package_missing_since"`
}
if err := s.DB.WithContext(ctx).Table("yeeke_return_item AS i").
Select("i.id AS id, p.order_sn AS order_sn, i.image AS image, i.variation_name AS variation_name, p.destroy_dead_line AS destroy_dead_line, "+
"i.sync_status AS sync_status, i.missing_since AS missing_since, p.sync_status AS package_sync_status, p.missing_since AS package_missing_since").
Joins("JOIN yeeke_return_package AS p ON p.id = i.package_id").
Where("i.id IN ?", returnIDs).
Find(&joined).Error; err != nil {
return nil, err
}
type extraFields struct {
OrderSN string
Image string
VariationName string
DestroyDeadLine *time.Time
SyncStatus string
MissingSince *time.Time
}
byID := make(map[uint64]extraFields, len(joined))
for _, j := range joined {
// A missing package makes its items unavailable too (#338), even
// if the item row itself is still "ok" — surface the package's
// missing_since in that case since it is the more accurate reason.
syncStatus, missingSince := j.SyncStatus, j.MissingSince
if j.PackageSyncStat != "ok" {
syncStatus, missingSince = j.PackageSyncStat, j.PackageMissingAt
}
byID[j.ID] = extraFields{j.OrderSN, j.Image, j.VariationName, j.DestroyDeadLine, syncStatus, missingSince}
}
for i := range items {
if extra, ok := byID[items[i].YeekeReturnItemID]; ok {
items[i].OrderSN = extra.OrderSN
items[i].Image = extra.Image
items[i].VariationName = extra.VariationName
items[i].DestroyDeadline = extra.DestroyDeadLine
items[i].SyncStatus = extra.SyncStatus
items[i].MissingSince = extra.MissingSince
}
}
return items, nil
}
func (s *Service) Detail(ctx context.Context, matchID uint64) (models.ReturnMatch, error) {
// SYBDetailView/YeekeDetailView/MatchDetail back the prototype's compare
// screen (screen 3): both sides' fields and images side by side, normalized
// spec text for both, and the operation log.
type SYBDetailView struct {
SYBProductID uint64 `json:"sybProductId"`
OrderCode string `json:"orderCode"`
ShopeeItemID string `json:"shopeeItemId"`
ShopName string `json:"shopName"`
ProductTitle string `json:"productTitle"`
TargetColor string `json:"targetColor"`
TargetSize string `json:"targetSize"`
Quantity int64 `json:"quantity"`
ImageURL string `json:"imageUrl,omitempty"`
CurrentStage string `json:"currentStage"`
CurrentStageLabel string `json:"currentStageLabel"`
}
type YeekeDetailView struct {
YeekeReturnItemID uint64 `json:"yeekeReturnItemId"`
OrderSN string `json:"orderSn"`
ItemID string `json:"itemId"`
VariationID string `json:"variationId"`
ShopName string `json:"shopName"`
ItemName string `json:"itemName"`
VariationName string `json:"variationName"`
Quantity int64 `json:"quantity"`
Image string `json:"image,omitempty"`
DestroyDeadline *time.Time `json:"destroyDeadline,omitempty"`
// SyncStatus/MissingSince (#338): "missing" when either the item or its
// package fell out of a COMPLETE yeeke sync, so the compare dialog can
// show the warning even on a match made before the item went missing.
SyncStatus string `json:"syncStatus"`
MissingSince *time.Time `json:"missingSince,omitempty"`
}
type MatchDetail struct {
Match models.ReturnMatch `json:"match"`
SYB *SYBDetailView `json:"syb,omitempty"`
Yeeke *YeekeDetailView `json:"yeeke,omitempty"`
NormalizedSYBSpec string `json:"normalizedSybSpec"`
NormalizedYeekeSpec string `json:"normalizedYeekeSpec"`
Logs []models.ReturnMatchLog `json:"logs"`
}
func (s *Service) Detail(ctx context.Context, matchID uint64) (MatchDetail, error) {
var match models.ReturnMatch
err := s.DB.WithContext(ctx).First(&match, matchID).Error
return match, err
if err := s.DB.WithContext(ctx).First(&match, matchID).Error; err != nil {
return MatchDetail{}, err
}
detail := MatchDetail{Match: match}
var syb models.SYBProduct
if err := s.DB.WithContext(ctx).First(&syb, match.SYBProductID).Error; err == nil {
stages, stageErr := purchase.NewService(s.DB).ProcessStages(ctx, []uint64{syb.ID})
var stage purchase.ProcessStageResult
if stageErr == nil {
stage = stages[syb.ID]
}
detail.SYB = &SYBDetailView{
SYBProductID: syb.ID, OrderCode: syb.OrderCode, ShopeeItemID: syb.ShopeeItemID,
ShopName: syb.ShopName, ProductTitle: syb.ProductTitle,
TargetColor: syb.TargetColor, TargetSize: syb.TargetSize, Quantity: syb.Quantity,
ImageURL: syb.ImageURL, CurrentStage: stage.Stage, CurrentStageLabel: stage.Label,
}
detail.NormalizedSYBSpec = trimCommas(Normalize(SYBSpecText(syb.TargetColor, syb.TargetSize)))
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return detail, err
}
var item models.YeekeReturnItem
if err := s.DB.WithContext(ctx).First(&item, match.YeekeReturnItemID).Error; err == nil {
var pkg models.YeekeReturnPackage
if pkgErr := s.DB.WithContext(ctx).First(&pkg, item.PackageID).Error; pkgErr != nil && !errors.Is(pkgErr, gorm.ErrRecordNotFound) {
return detail, pkgErr
}
syncStatus, missingSince := item.SyncStatus, item.MissingSince
if pkg.SyncStatus != "" && pkg.SyncStatus != "ok" {
syncStatus, missingSince = pkg.SyncStatus, pkg.MissingSince
}
detail.Yeeke = &YeekeDetailView{
YeekeReturnItemID: item.ID, OrderSN: pkg.OrderSN, ItemID: item.ItemID, VariationID: item.VariationID,
ShopName: pkg.ShopName, ItemName: item.ItemName, VariationName: item.VariationName,
Quantity: item.Quantity, Image: item.Image, DestroyDeadline: pkg.DestroyDeadLine,
SyncStatus: syncStatus, MissingSince: missingSince,
}
detail.NormalizedYeekeSpec = trimCommas(Normalize(item.VariationName))
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return detail, err
}
var logs []models.ReturnMatchLog
if err := s.DB.WithContext(ctx).Where("match_id = ?", matchID).Order("id DESC").Find(&logs).Error; err != nil {
return detail, err
}
detail.Logs = logs
return detail, nil
}
var errStateConflict = errors.New("return match state conflict")
@@ -2,6 +2,7 @@ package returnmatch
import (
"context"
"errors"
"fmt"
"strings"
"sync"
@@ -110,6 +111,31 @@ func TestBatchMatch_EndToEnd(t *testing.T) {
}
}
func TestBatchMatch_RejectsMoreThan500Items(t *testing.T) {
db := testDB(t)
s := NewService(db)
ids := make([]uint64, maxBatchMatchItems+1)
for i := range ids {
ids[i] = uint64(i + 1)
}
resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: ids, Operator: "tester"})
if !errors.Is(err, ErrTooManyItems) {
t.Fatalf("expected ErrTooManyItems, got %v", err)
}
if len(resp.Items) != 0 {
t.Fatalf("expected empty response on rejection, got %+v", resp)
}
var batchCount int64
if err := db.Model(&models.ReturnMatchBatch{}).Count(&batchCount).Error; err != nil {
t.Fatal(err)
}
if batchCount != 0 {
t.Fatalf("rejected oversized batch must not be recorded, got %d rows", batchCount)
}
}
func TestBatchMatch_ExpiredDeadlineNotMatched(t *testing.T) {
db := testDB(t)
s := NewService(db)
@@ -227,6 +253,173 @@ func TestConfirmThenCancel_RestoresAvailability(t *testing.T) {
}
}
// TestBatchMatch_SkipsWhenStageRacesToTaskCreatedUnderLock is the #338
// review fix-2 regression: a purchase task gets created for the SYB product
// (moving its stage to task_created, which does not participate) AFTER
// BatchMatch's outer screening pass already judged it eligible but BEFORE
// matchOneWithLock's own re-check runs. The row must be skipped with
// stage_ineligible, not matched.
func TestMatchOneWithLock_SkipsWhenStageNoLongerParticipatesUnderLock(t *testing.T) {
db := testDB(t)
s := NewService(db)
s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
syb := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
ret := seedReturn(t, db, "白色,L", &deadline)
// Simulate the exact race the review flagged: BatchMatch's outer
// screening pass already computed `outcome` while the product was still
// participating (pdd_unlinked). Before matchOneWithLock's own re-check
// runs (which takes the same row lock purchase.create takes and
// recomputes the stage inside that transaction), something else moves
// the product out of a participating stage — here simulated directly by
// flipping it to a parse-failed/manual_action state, same effect as a
// purchase task having been created for it in the meantime.
if err := db.Model(&models.SYBProduct{}).Where("id = ?", syb.ID).Update("parse_status", models.SYBParseStatusFailed).Error; err != nil {
t.Fatal(err)
}
outcome := MatchOutcome{SYBProductID: syb.ID, Matched: true, ReturnItemID: ret.ID, NormalizedKey: "100|白色,l"}
_, err := s.matchOneWithLock(context.Background(), syb.ID, outcome, "tester")
if !errors.Is(err, errStageNoLongerEligible) {
t.Fatalf("expected errStageNoLongerEligible, got %v", err)
}
var count int64
db.Model(&models.ReturnMatch{}).Where("syb_product_id = ?", syb.ID).Count(&count)
if count != 0 {
t.Fatalf("no return_match row should have been created when the stage race is caught: count=%d", count)
}
}
func TestBatchMatch_WritesLogRowInSameTransaction(t *testing.T) {
db := testDB(t)
s := NewService(db)
s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
syb := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
seedReturn(t, db, "白色,L【建議65-75公斤】", &deadline)
resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}, Operator: "tester"})
if err != nil || resp.MatchedCount != 1 {
t.Fatalf("setup match failed: %v %+v", err, resp)
}
var logs []models.ReturnMatchLog
if err := db.Where("match_id = ?", resp.Items[0].MatchID).Find(&logs).Error; err != nil {
t.Fatal(err)
}
if len(logs) != 1 || logs[0].Action != models.ReturnMatchLogActionMatched || logs[0].Operator != "tester" {
t.Fatalf("expected one matched log row: %+v", logs)
}
}
func TestConfirmCancelRemark_EachWritesOwnLogRow(t *testing.T) {
db := testDB(t)
s := NewService(db)
s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
syb := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
seedReturn(t, db, "白色,L", &deadline)
resp, _ := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}, Operator: "matcher"})
matchID := resp.Items[0].MatchID
if _, err := s.Remark(context.Background(), matchID, "reviewer", "看起来没问题"); err != nil {
t.Fatal(err)
}
if _, err := s.Confirm(context.Background(), matchID, "reviewer"); err != nil {
t.Fatal(err)
}
if _, err := s.Cancel(context.Background(), matchID, "reviewer2"); err != nil {
t.Fatal(err)
}
var logs []models.ReturnMatchLog
if err := db.Where("match_id = ?", matchID).Order("id ASC").Find(&logs).Error; err != nil {
t.Fatal(err)
}
wantActions := []string{models.ReturnMatchLogActionMatched, models.ReturnMatchLogActionRemark, models.ReturnMatchLogActionConfirmed, models.ReturnMatchLogActionCancelled}
if len(logs) != len(wantActions) {
t.Fatalf("expected %d log rows, got %d: %+v", len(wantActions), len(logs), logs)
}
for i, action := range wantActions {
if logs[i].Action != action {
t.Fatalf("log[%d].Action = %q, want %q (%+v)", i, logs[i].Action, action, logs)
}
}
if logs[1].Detail != "看起来没问题" {
t.Fatalf("remark log must carry the remark text: %+v", logs[1])
}
}
func TestRemark_RejectsOverLongInputInsteadOfTruncating(t *testing.T) {
db := testDB(t)
s := NewService(db)
s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
syb := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
seedReturn(t, db, "白色,L", &deadline)
resp, _ := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}})
matchID := resp.Items[0].MatchID
tooLong := strings.Repeat("字", 501)
if _, err := s.Remark(context.Background(), matchID, "op", tooLong); !errors.Is(err, errRemarkTooLong) {
t.Fatalf("expected errRemarkTooLong, got %v", err)
}
var match models.ReturnMatch
db.First(&match, matchID)
if match.Remark != "" {
t.Fatalf("rejected remark must not be saved (partially or fully): %q", match.Remark)
}
exactly500 := strings.Repeat("字", 500)
if _, err := s.Remark(context.Background(), matchID, "op", exactly500); err != nil {
t.Fatalf("exactly 500 runes must be accepted: %v", err)
}
}
func TestDetail_ReturnsBothSidesNormalizedSpecsAndLogs(t *testing.T) {
db := testDB(t)
s := NewService(db)
s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
syb := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
ret := seedReturn(t, db, "白色,L【建議65-75公斤】", &deadline)
resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}, Operator: "matcher"})
if err != nil || resp.MatchedCount != 1 {
t.Fatalf("setup failed: %v %+v", err, resp)
}
matchID := resp.Items[0].MatchID
if _, err := s.Remark(context.Background(), matchID, "op", "备注文字"); err != nil {
t.Fatal(err)
}
detail, err := s.Detail(context.Background(), matchID)
if err != nil {
t.Fatal(err)
}
if detail.SYB == nil || detail.SYB.SYBProductID != syb.ID || detail.SYB.OrderCode != "SYB-1" {
t.Fatalf("missing/wrong SYB detail: %+v", detail.SYB)
}
if detail.Yeeke == nil || detail.Yeeke.YeekeReturnItemID != ret.ID || detail.Yeeke.OrderSN != "ORD1" {
t.Fatalf("missing/wrong yeeke detail: %+v", detail.Yeeke)
}
if detail.Yeeke.DestroyDeadline == nil || !detail.Yeeke.DestroyDeadline.Equal(deadline) {
t.Fatalf("expected destroy deadline to be carried through: %+v", detail.Yeeke)
}
if detail.NormalizedSYBSpec == "" || detail.NormalizedSYBSpec != detail.NormalizedYeekeSpec {
t.Fatalf("expected both sides to normalize equal: syb=%q yeeke=%q", detail.NormalizedSYBSpec, detail.NormalizedYeekeSpec)
}
if len(detail.Logs) != 2 {
t.Fatalf("expected 2 log rows (matched + remark), got %+v", detail.Logs)
}
if detail.Logs[0].Action != models.ReturnMatchLogActionRemark {
t.Fatalf("expected newest-first ordering (remark first): %+v", detail.Logs)
}
if detail.Match.YeekeSpecText == "" || detail.Match.PreviousProcessStage == "" {
t.Fatalf("insertMatch must fill YeekeSpecText and PreviousProcessStage: %+v", detail.Match)
}
}
// TestBatchMatch_ConcurrentInsertSkipsConflictRow exercises the DB unique
// constraint directly: two goroutines racing to insert an active match for
// the exact same SYB product must have exactly one winner, and the loser
@@ -268,3 +461,25 @@ func TestBatchMatch_ConcurrentInsertSkipsConflictRow(t *testing.T) {
t.Fatalf("expected exactly one winner and one unique-constraint conflict, got success=%d conflict=%d results=%v", successCount, conflictCount, results)
}
}
func TestList_EnrichesWithYeekeReturnDisplayFields(t *testing.T) {
db := testDB(t)
s := NewService(db)
s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
syb := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
seedReturn(t, db, "白色,L【建議65-75公斤】", &deadline)
resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}})
if err != nil || resp.MatchedCount != 1 {
t.Fatalf("setup failed: %v %+v", err, resp)
}
items, err := s.List(context.Background(), ListFilter{SYBProductIDs: []uint64{syb.ID}})
if err != nil {
t.Fatal(err)
}
if len(items) != 1 || items[0].OrderSN != "ORD1" || items[0].DestroyDeadline == nil {
t.Fatalf("expected enriched list item: %+v", items)
}
}
+4
View File
@@ -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
@@ -0,0 +1,147 @@
package sybclient
import (
"context"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"go-admin/app/goauto/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// LoginConfig contains the non-secret connection settings and credentials
// needed to acquire a SYB session. Password is used only during LoginWithOCR.
type LoginConfig struct {
BaseURL string
Username string
Password string
OcrURL string
OcrMaxAttempts int
}
// AcquireSession reuses a valid cached session and performs at most one
// re-login per account at a time. Callers waiting for another process to
// refresh the session never request another captcha.
func AcquireSession(ctx context.Context, db *gorm.DB, cfg LoginConfig) (*Client, error) {
if cfg.Username == "" {
return nil, fmt.Errorf("顺云宝账号未配置")
}
client, err := New(cfg.BaseURL)
if err != nil {
return nil, err
}
store := NewSessionStore(db)
if cached, loadErr := store.Load(ctx, cfg.Username, time.Now()); loadErr == nil {
if importErr := client.ImportCookiesJSON(cached.CookiesJSON); importErr != nil {
return nil, importErr
}
checkErr := client.CheckSession(ctx, cached.UserID, cfg.Username)
if checkErr == nil {
return client, nil
}
if !errors.Is(checkErr, ErrSessionInvalid) {
return nil, checkErr
}
if cfg.Password == "" {
return nil, checkErr
}
} else if !errors.Is(loadErr, ErrNoSession) {
return nil, loadErr
} else if cfg.Password == "" {
return nil, loadErr
}
owner := uuid.NewString()
if claimed, err := claimAuthLease(ctx, db, cfg.Username, owner, 2*time.Minute); err != nil {
return nil, err
} else if claimed {
defer releaseAuthLease(context.Background(), db, cfg.Username, owner)
// Another worker may have completed login between our first probe and
// acquiring the lease; always re-check before requesting a captcha.
if c, ok := validCachedSession(ctx, client, store, cfg.Username); ok {
return c, nil
}
if err := refreshSession(ctx, db, client, store, cfg); err != nil {
return nil, err
}
return validSessionOrError(ctx, client, store, cfg.Username)
}
// A different worker owns the login lease. Wait for its session, bounded by
// the caller's context; do not trigger a second login.
for {
if c, ok := validCachedSession(ctx, client, store, cfg.Username); ok {
return c, nil
}
if err := ctx.Err(); err != nil {
return nil, fmt.Errorf("等待顺云宝会话刷新超时: %w", err)
}
select {
case <-time.After(500 * time.Millisecond):
case <-ctx.Done():
return nil, ctx.Err()
}
}
}
func validSessionOrError(ctx context.Context, client *Client, store *SessionStore, username string) (*Client, error) {
s, err := store.Load(ctx, username, time.Now())
if err != nil {
return nil, err
}
if err := client.ImportCookiesJSON(s.CookiesJSON); err != nil {
return nil, err
}
if err := client.CheckSession(ctx, s.UserID, username); err != nil {
return nil, err
}
return client, nil
}
func validCachedSession(ctx context.Context, client *Client, store *SessionStore, username string) (*Client, bool) {
s, err := store.Load(ctx, username, time.Now())
if err != nil {
return nil, false
}
if err := client.ImportCookiesJSON(s.CookiesJSON); err != nil {
return nil, false
}
if err := client.CheckSession(ctx, s.UserID, username); err != nil {
// Network failures are not treated as logout, but they also do not
// authorize a write; the caller will retry through the normal worker.
return nil, false
}
return client, true
}
func refreshSession(ctx context.Context, db *gorm.DB, client *Client, store *SessionStore, cfg LoginConfig) error {
ocr, err := NewOcrClient(cfg.OcrURL, 0)
if err != nil {
return fmt.Errorf("顺云宝验证码识别服务不可用: %w", err)
}
result, reason := client.LoginWithOCR(ctx, ocr, cfg.Username, cfg.Password, cfg.OcrMaxAttempts)
if result == nil {
return fmt.Errorf("顺云宝自动登录失败,需要手工输入验证码: %s", reason)
}
cookies, err := client.ExportCookiesJSON()
if err != nil {
return err
}
return store.Save(ctx, Session{Username: cfg.Username, UserID: result.User.ID, CookiesJSON: cookies, ExpiresAt: result.ExpiresAt})
}
func claimAuthLease(ctx context.Context, db *gorm.DB, username, owner string, ttl time.Duration) (bool, error) {
now := time.Now().UTC()
if err := db.WithContext(ctx).Clauses(clause.OnConflict{DoNothing: true}).Create(&models.SYBSessionAuthLease{ID: 1, Username: username, Owner: "", ExpiresAt: now.Add(-time.Second)}).Error; err != nil {
return false, err
}
r := db.WithContext(ctx).Model(&models.SYBSessionAuthLease{}).Where("id = 1 AND username = ? AND (expires_at <= ? OR owner = '')", username, now).Updates(map[string]any{"owner": owner, "expires_at": now.Add(ttl)})
return r.RowsAffected == 1, r.Error
}
func releaseAuthLease(ctx context.Context, db *gorm.DB, username, owner string) {
_ = db.WithContext(ctx).Model(&models.SYBSessionAuthLease{}).Where("id = 1 AND username = ? AND owner = ?", username, owner).Updates(map[string]any{"owner": "", "expires_at": time.Now().UTC().Add(-time.Second)})
}
@@ -0,0 +1,35 @@
package sybclient
import (
"context"
"testing"
"time"
"go-admin/app/goauto/models"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestAuthLeaseAllowsOnlyOneRefreshOwner(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:syb-auth-lease?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&models.SYBSessionAuthLease{}); err != nil {
t.Fatal(err)
}
ctx := context.Background()
first, err := claimAuthLease(ctx, db, "operator", "first", time.Minute)
if err != nil || !first {
t.Fatalf("first owner should claim: %v %v", first, err)
}
second, err := claimAuthLease(ctx, db, "operator", "second", time.Minute)
if err != nil || second {
t.Fatalf("second owner must wait: %v %v", second, err)
}
releaseAuthLease(ctx, db, "operator", "first")
second, err = claimAuthLease(ctx, db, "operator", "second", time.Minute)
if err != nil || !second {
t.Fatalf("lease should be reusable: %v %v", second, err)
}
}
+1 -1
View File
@@ -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")),
Page: page, PageSize: pageSize, ShopName: c.Query("shopName"), OrderCodes: []string{c.Query("orderCodes")}, ParseStatus: strings.TrimSpace(c.Query("parseStatus")), ProcessStage: strings.TrimSpace(c.Query("processStage")), CreatedFrom: strings.TrimSpace(c.Query("createdFrom")), CreatedTo: strings.TrimSpace(c.Query("createdTo")),
})
if err != nil {
writeError(c, err)
+52 -2
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"strings"
"time"
"go-admin/app/goauto/models"
"go-admin/app/goauto/purchase"
@@ -44,6 +45,8 @@ type ListRequest struct {
OrderCodes []string
ParseStatus string
ProcessStage string
CreatedFrom string
CreatedTo string
}
type ListResponse struct {
@@ -65,10 +68,20 @@ func (service *Service) List(ctx context.Context, request ListRequest) (ListResp
if request.PageSize < 1 {
request.PageSize = 20
}
if request.PageSize > 100 {
request.PageSize = 100
if request.PageSize > 500 {
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 个字符")
@@ -142,6 +155,43 @@ func (service *Service) List(ctx context.Context, request ListRequest) (ListResp
return ListResponse{Items: items, Total: total, Page: request.Page, PageSize: request.PageSize}, nil
}
// 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))
@@ -7,6 +7,7 @@ import (
"fmt"
"strings"
"testing"
"time"
"go-admin/app/goauto/models"
"go-admin/app/goauto/sybimport"
@@ -93,6 +94,76 @@ func TestServiceListRejectsTooManyOrTooLongOrderCodes(t *testing.T) {
}
}
func TestServiceListCapsPageSizeAt500(t *testing.T) {
db := openTestDB(t)
order := realOrder()
for i := 0; i < 3; i++ {
detail := realDetailA()
detail.ID += uint64(i)
order.Code = fmt.Sprintf("260728TB95MJTQ-%d", i)
if _, err := sybimport.ApplyDetail(context.Background(), db, order, detail); err != nil {
t.Fatalf("apply detail %d: %v", i, err)
}
}
service := sybimport.NewService(db)
overLimit, err := service.List(context.Background(), sybimport.ListRequest{Page: 1, PageSize: 600})
if err != nil {
t.Fatalf("list with oversized page size: %v", err)
}
if overLimit.PageSize != 500 {
t.Fatalf("expected page size capped at 500, got %d", overLimit.PageSize)
}
within, err := service.List(context.Background(), sybimport.ListRequest{Page: 1, PageSize: 500})
if err != nil {
t.Fatalf("list at exactly 500: %v", err)
}
if within.PageSize != 500 {
t.Fatalf("expected page size of exactly 500 to pass through unchanged, got %d", within.PageSize)
}
}
func TestServiceListFiltersByCreatedDateInclusive(t *testing.T) {
db := openTestDB(t)
order := realOrder()
for i, day := range []string{"2026-09-01", "2026-09-02", "2026-09-03"} {
rowOrder := order
rowOrder.Code = fmt.Sprintf("CREATED-%d", i)
rowOrder.StockID += uint64(i)
detail := realDetailA()
detail.ID += uint64(i)
result, err := sybimport.ApplyDetail(context.Background(), db, rowOrder, detail)
if err != nil {
t.Fatal(err)
}
created, err := time.ParseInLocation("2006-01-02", day, time.Local)
if err != nil {
t.Fatal(err)
}
if err := db.Model(&models.SYBProduct{}).Where("id = ?", result.SYBProduct.ID).Update("created_at", created).Error; err != nil {
t.Fatal(err)
}
}
service := sybimport.NewService(db)
between, err := service.List(context.Background(), sybimport.ListRequest{CreatedFrom: "2026-09-01", CreatedTo: "2026-09-02"})
if err != nil || between.Total != 2 {
t.Fatalf("inclusive created date range should return two rows, total=%d err=%v", between.Total, err)
}
fromOnly, err := service.List(context.Background(), sybimport.ListRequest{CreatedFrom: "2026-09-03"})
if err != nil || fromOnly.Total != 1 {
t.Fatalf("created-from filter should return one row, total=%d err=%v", fromOnly.Total, err)
}
toOnly, err := service.List(context.Background(), sybimport.ListRequest{CreatedTo: "2026-09-01"})
if err != nil || toOnly.Total != 1 {
t.Fatalf("created-to filter should return one row, total=%d err=%v", toOnly.Total, err)
}
if _, err := service.List(context.Background(), sybimport.ListRequest{CreatedFrom: "2026-09-04", CreatedTo: "2026-09-01"}); serviceErrCode(t, err) != sybimport.CodeInvalidRequest {
t.Fatalf("reversed created date range should be rejected: %v", err)
}
}
func TestServiceListRejectsInvalidParseStatus(t *testing.T) {
db := openTestDB(t)
service := sybimport.NewService(db)
+4 -48
View File
@@ -480,54 +480,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
+17 -14
View File
@@ -43,26 +43,29 @@ func (h Handler) db(c *gin.Context) (*gorm.DB, bool) {
// only the summary fields already computed by the sync run itself; it never
// carries yeeke_session (token/cookies) or a raw page response.
type SyncRunDTO struct {
ID uint64 `json:"id"`
Status string `json:"status"`
Trigger string `json:"trigger"`
TotalPages int `json:"totalPages"`
ReadCount int `json:"readCount"`
CreatedCount int `json:"createdCount"`
UpdatedCount int `json:"updatedCount"`
SkippedCount int `json:"skippedCount"`
FailedCount int `json:"failedCount"`
ErrorMessage string `json:"errorMessage"`
StartedAt string `json:"startedAt"`
FinishedAt *string `json:"finishedAt"`
LastSuccessAt *string `json:"lastSuccessAt"`
ID uint64 `json:"id"`
Status string `json:"status"`
Trigger string `json:"trigger"`
TotalPages int `json:"totalPages"`
ReadCount int `json:"readCount"`
CreatedCount int `json:"createdCount"`
UpdatedCount int `json:"updatedCount"`
SkippedCount int `json:"skippedCount"`
FailedCount int `json:"failedCount"`
MissingMarkedCount int `json:"missingMarkedCount"`
RecoveredCount int `json:"recoveredCount"`
ErrorMessage string `json:"errorMessage"`
StartedAt string `json:"startedAt"`
FinishedAt *string `json:"finishedAt"`
LastSuccessAt *string `json:"lastSuccessAt"`
}
func toDTO(r models.YeekeSyncRun) SyncRunDTO {
dto := SyncRunDTO{
ID: r.ID, Status: r.Status, Trigger: r.Trigger, TotalPages: r.TotalPages,
ReadCount: r.ReadCount, CreatedCount: r.CreatedCount, UpdatedCount: r.UpdatedCount,
SkippedCount: r.SkippedCount, FailedCount: r.FailedCount, ErrorMessage: r.ErrorMessage,
SkippedCount: r.SkippedCount, FailedCount: r.FailedCount,
MissingMarkedCount: r.MissingMarkedCount, RecoveredCount: r.RecoveredCount, ErrorMessage: r.ErrorMessage,
StartedAt: r.StartedAt.UTC().Format("2006-01-02T15:04:05Z"),
}
if r.FinishedAt != nil {
+129 -10
View File
@@ -9,6 +9,7 @@ import (
"time"
"go-admin/app/goauto/models"
"go-admin/app/goauto/purchase"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
@@ -19,13 +20,15 @@ import (
// image field is always the external yeeke URL (never downloaded/proxied,
// per #337 non-goal).
type ReturnItemDTO struct {
ID uint64 `json:"id"`
ItemID string `json:"itemId"`
VariationID string `json:"variationId"`
ItemName string `json:"itemName"`
VariationName string `json:"variationName"`
Image string `json:"image"`
Quantity int64 `json:"quantity"`
ID uint64 `json:"id"`
ItemID string `json:"itemId"`
VariationID string `json:"variationId"`
ItemName string `json:"itemName"`
VariationName string `json:"variationName"`
Image string `json:"image"`
Quantity int64 `json:"quantity"`
SyncStatus string `json:"syncStatus"`
MissingSince *string `json:"missingSince,omitempty"`
}
// ReturnPackageDTO is the read-only shape of one return package returned to
@@ -49,6 +52,7 @@ type ReturnPackageDTO struct {
DestroyDeadLine *string `json:"destroyDeadLine"`
LastSyncedAt string `json:"lastSyncedAt"`
SyncStatus string `json:"syncStatus"`
MissingSince *string `json:"missingSince,omitempty"`
Items []ReturnItemDTO `json:"items,omitempty"`
}
@@ -76,8 +80,29 @@ type ReturnItemRowDTO struct {
DestroyDeadLine *string `json:"destroyDeadLine"`
LastSyncedAt string `json:"lastSyncedAt"`
HasItem bool `json:"hasItem"`
// SyncStatus/MissingSince (#338 scope addition): "missing" when this
// item, or its package, has fallen out of a COMPLETE yeeke sync — the
// row is never dropped from this list, only flagged unavailable.
SyncStatus string `json:"syncStatus,omitempty"`
MissingSince *string `json:"missingSince,omitempty"`
// #338: match status/占用信息, joined from return_match (active match
// only). MatchStatus is "unmatched" when there is no active match.
MatchID uint64 `json:"matchId,omitempty"`
MatchStatus string `json:"matchStatus"`
OccupyingSYBProductID uint64 `json:"occupyingSybProductId,omitempty"`
OccupyingSYBOrderCode string `json:"occupyingSybOrderCode,omitempty"`
OccupyingSYBStage string `json:"occupyingSybStage,omitempty"`
OccupyingSYBStageLabel string `json:"occupyingSybStageLabel,omitempty"`
}
const (
ReturnMatchFilterUnmatched = "unmatched"
ReturnMatchFilterMatched = "matched"
ReturnMatchFilterConfirmed = "confirmed"
)
func formatTimePtr(t *time.Time) *string {
if t == nil {
return nil
@@ -94,6 +119,7 @@ func toPackageDTO(p models.YeekeReturnPackage, itemCount int) ReturnPackageDTO {
ClaimTime: formatTimePtr(p.ClaimTime), CreateTime: formatTimePtr(p.CreateTime),
UpdateTime: formatTimePtr(p.UpdateTime), DestroyDeadLine: formatTimePtr(p.DestroyDeadLine),
LastSyncedAt: p.LastSyncedAt.UTC().Format("2006-01-02T15:04:05Z"), SyncStatus: p.SyncStatus,
MissingSince: formatTimePtr(p.MissingSince),
}
}
@@ -101,6 +127,7 @@ func toItemDTO(i models.YeekeReturnItem) ReturnItemDTO {
return ReturnItemDTO{
ID: i.ID, ItemID: i.ItemID, VariationID: i.VariationID, ItemName: i.ItemName,
VariationName: i.VariationName, Image: i.Image, Quantity: i.Quantity,
SyncStatus: i.SyncStatus, MissingSince: formatTimePtr(i.MissingSince),
}
}
@@ -126,6 +153,20 @@ type itemRow struct {
ItemImage *string
ItemQuantity *int64
ItemLastSynced *time.Time
// #338 scope addition: item's own and its package's sync_status/
// missing_since, so the row can be flagged unavailable regardless of
// which side dropped out of a COMPLETE yeeke sync.
ItemSyncStatus *string `gorm:"column:item_sync_status"`
ItemMissingSince *time.Time `gorm:"column:item_missing_since"`
PackageSyncStatus string `gorm:"column:package_sync_status"`
PackageMissingSince *time.Time `gorm:"column:package_missing_since"`
// #338 match columns, from the LEFT JOIN onto return_match/syb_product.
MatchID *uint64 `gorm:"column:match_id"`
MatchStatus *string `gorm:"column:match_status"`
OccupyingSYBProductID *uint64 `gorm:"column:occupying_syb_product_id"`
OccupyingSYBOrderCode *string `gorm:"column:occupying_syb_order_code"`
}
func toItemRowDTO(r itemRow) ReturnItemRowDTO {
@@ -166,6 +207,35 @@ func toItemRowDTO(r itemRow) ReturnItemRowDTO {
if r.ItemLastSynced != nil {
dto.LastSyncedAt = r.ItemLastSynced.UTC().Format("2006-01-02T15:04:05Z")
}
// #338: the package being "missing" makes the item unavailable too,
// even if the item row itself is still "ok" — its reason/timestamp wins.
syncStatus, missingSince := "ok", r.ItemMissingSince
if r.ItemSyncStatus != nil {
syncStatus = *r.ItemSyncStatus
}
if r.PackageSyncStatus != "" && r.PackageSyncStatus != "ok" {
syncStatus, missingSince = r.PackageSyncStatus, r.PackageMissingSince
}
dto.SyncStatus = syncStatus
dto.MissingSince = formatTimePtr(missingSince)
dto.MatchStatus = ReturnMatchFilterUnmatched
if r.MatchID != nil {
dto.MatchID = *r.MatchID
if r.MatchStatus != nil {
switch *r.MatchStatus {
case models.ReturnMatchStatusConfirmed:
dto.MatchStatus = ReturnMatchFilterConfirmed
default:
dto.MatchStatus = ReturnMatchFilterMatched
}
}
if r.OccupyingSYBProductID != nil {
dto.OccupyingSYBProductID = *r.OccupyingSYBProductID
}
if r.OccupyingSYBOrderCode != nil {
dto.OccupyingSYBOrderCode = *r.OccupyingSYBOrderCode
}
}
return dto
}
@@ -190,7 +260,12 @@ func (h Handler) ListReturnPackages(c *gin.Context) {
}
base := db.Table("yeeke_return_package p").
Joins("LEFT JOIN yeeke_return_item i ON i.package_id = p.id")
Joins("LEFT JOIN yeeke_return_item i ON i.package_id = p.id").
// #338: single bounded LEFT JOIN onto the active match (if any) and
// the SYB product it occupies, so 匹配状态 filtering/column never
// needs a per-row lookup.
Joins("LEFT JOIN return_match m ON m.active_yeeke_return_item_id = i.id").
Joins("LEFT JOIN syb_product sp ON sp.id = m.syb_product_id")
if v := strings.TrimSpace(c.Query("orderSn")); v != "" {
base = base.Where("p.order_sn LIKE ?", "%"+v+"%")
}
@@ -235,6 +310,19 @@ func (h Handler) ListReturnPackages(c *gin.Context) {
if v := strings.TrimSpace(c.Query("variationId")); v != "" {
base = base.Where("i.variation_id = ?", v)
}
switch matchStatus := strings.TrimSpace(c.Query("matchStatus")); matchStatus {
case "":
// no filter
case ReturnMatchFilterUnmatched:
base = base.Where("m.id IS NULL")
case ReturnMatchFilterMatched:
base = base.Where("m.status = ?", models.ReturnMatchStatusMatched)
case ReturnMatchFilterConfirmed:
base = base.Where("m.status = ?", models.ReturnMatchStatusConfirmed)
default:
c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "matchStatus 无效"})
return
}
var total int64
if err := base.Session(&gorm.Session{}).Count(&total).Error; err != nil {
@@ -247,7 +335,10 @@ func (h Handler) ListReturnPackages(c *gin.Context) {
"p.id as package_id, p.order_sn, p.shop_name, p.ware_house, p.ware_code, p.claim_status, " +
"p.status_unrecognized, p.claim_time, p.destroy_dead_line as destroy_dead_line, p.last_synced_at as package_last_synced, " +
"i.id as item_id, i.item_id as item_external_item, i.variation_id as item_variation_id, i.item_name as item_name, " +
"i.variation_name as item_variation_name, i.image as item_image, i.quantity as item_quantity, i.last_synced_at as item_last_synced",
"i.variation_name as item_variation_name, i.image as item_image, i.quantity as item_quantity, i.last_synced_at as item_last_synced, " +
"i.sync_status as item_sync_status, i.missing_since as item_missing_since, " +
"p.sync_status as package_sync_status, p.missing_since as package_missing_since, " +
"m.id as match_id, m.status as match_status, sp.id as occupying_syb_product_id, sp.order_code as occupying_syb_order_code",
).Order("p.create_time desc, i.id asc").
Offset((page - 1) * pageSize).Limit(pageSize)
if err := q.Find(&rows).Error; err != nil {
@@ -255,9 +346,37 @@ func (h Handler) ListReturnPackages(c *gin.Context) {
return
}
// #338: one bounded ProcessStages call for the distinct occupying SYB
// product ids on this page (never per-row), so the 「被占用 SYB 订单
// 商品」 column can also show its current stage.
sybIDs := make([]uint64, 0, len(rows))
seenSYB := make(map[uint64]bool, len(rows))
for _, r := range rows {
if r.OccupyingSYBProductID != nil && !seenSYB[*r.OccupyingSYBProductID] {
seenSYB[*r.OccupyingSYBProductID] = true
sybIDs = append(sybIDs, *r.OccupyingSYBProductID)
}
}
var stages map[uint64]purchase.ProcessStageResult
if len(sybIDs) > 0 {
var stageErr error
stages, stageErr = purchase.NewService(db).ProcessStages(c.Request.Context(), sybIDs)
if stageErr != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
return
}
}
items := make([]ReturnItemRowDTO, 0, len(rows))
for _, r := range rows {
items = append(items, toItemRowDTO(r))
dto := toItemRowDTO(r)
if r.OccupyingSYBProductID != nil {
if stage, ok := stages[*r.OccupyingSYBProductID]; ok {
dto.OccupyingSYBStage = stage.Stage
dto.OccupyingSYBStageLabel = stage.Label
}
}
items = append(items, dto)
}
c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{
+113
View File
@@ -0,0 +1,113 @@
package yeeke
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
"go-admin/app/goauto/models"
"github.com/gin-gonic/gin"
)
// TestListReturnItems_MatchStatusFilterAndOccupyingColumn is the #338 web-UI
// support test: the yeeke returns list must be able to filter by match
// status and show which SYB order product occupies a matched return, using
// the single bounded LEFT JOIN added to ListReturnPackages (no N+1).
func TestListReturnItems_MatchStatusFilterAndOccupyingColumn(t *testing.T) {
gin.SetMode(gin.TestMode)
db := testDB(t)
now := time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC)
deadline := now.Add(30 * 24 * time.Hour)
pkg := models.YeekeReturnPackage{ExternalID: "pkg-m1", OrderSN: "ORD-M1", TrackingNo: "T1", DestroyDeadLine: &deadline, LastSyncedAt: now, CreateTime: &now}
if err := db.Create(&pkg).Error; err != nil {
t.Fatal(err)
}
matchedItem := models.YeekeReturnItem{PackageID: pkg.ID, ExternalKey: "k-matched", ItemID: "100", VariationName: "白色,L", LastSyncedAt: now}
unmatchedItem := models.YeekeReturnItem{PackageID: pkg.ID, ExternalKey: "k-unmatched", ItemID: "100", VariationName: "黑色,M", LastSyncedAt: now}
if err := db.Create(&matchedItem).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&unmatchedItem).Error; err != nil {
t.Fatal(err)
}
syb := models.SYBProduct{OrderCode: "SYB-OCC-1", DetailID: 1, StockID: 1, ShopeeItemID: "100", TargetColor: "白色", TargetSize: "L", Quantity: 1, UnitPriceCent: 100, ParseStatus: models.SYBParseStatusSuccess, RawJSON: "{}"}
if err := db.Create(&syb).Error; err != nil {
t.Fatal(err)
}
match := models.ReturnMatch{
SYBProductID: syb.ID, YeekeReturnItemID: matchedItem.ID,
ActiveSYBProductID: &syb.ID, ActiveYeekeReturnItemID: &matchedItem.ID,
Status: models.ReturnMatchStatusMatched, MatchedAt: now,
}
if err := db.Create(&match).Error; err != nil {
t.Fatal(err)
}
handler := Handler{DB: db}
engine := gin.New()
engine.GET("/api/admin/v1/yeeke-returns", handler.ListReturnPackages)
do := func(req *http.Request) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
engine.ServeHTTP(w, req)
return w
}
// No filter: both rows present, matched row carries occupying info.
w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns", nil))
var resp itemRowListResp
decodeJSON(t, w.Body.Bytes(), &resp)
if resp.Data.Total != 2 {
t.Fatalf("expected 2 rows, got %+v", resp.Data)
}
byKey := map[string]ReturnItemRowDTO{}
for _, item := range resp.Data.Items {
byKey[item.Key] = item
}
matchedDTO := byKey[strconv.FormatUint(matchedItem.ID, 10)]
if matchedDTO.MatchStatus != ReturnMatchFilterMatched {
t.Fatalf("expected matched status: %+v", matchedDTO)
}
if matchedDTO.OccupyingSYBOrderCode != "SYB-OCC-1" || matchedDTO.OccupyingSYBProductID != syb.ID {
t.Fatalf("expected occupying SYB info: %+v", matchedDTO)
}
if matchedDTO.OccupyingSYBStageLabel == "" {
t.Fatalf("expected occupying SYB stage label to be filled: %+v", matchedDTO)
}
unmatchedDTO := byKey[strconv.FormatUint(unmatchedItem.ID, 10)]
if unmatchedDTO.MatchStatus != ReturnMatchFilterUnmatched || unmatchedDTO.OccupyingSYBProductID != 0 {
t.Fatalf("expected unmatched status with no occupying info: %+v", unmatchedDTO)
}
// matchStatus=unmatched filters down to just the unmatched row.
w = do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns?matchStatus=unmatched", nil))
decodeJSON(t, w.Body.Bytes(), &resp)
if resp.Data.Total != 1 || len(resp.Data.Items) != 1 || resp.Data.Items[0].Key != strconv.FormatUint(unmatchedItem.ID, 10) {
t.Fatalf("expected only the unmatched row: %+v", resp.Data)
}
// matchStatus=matched filters down to just the matched row.
w = do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns?matchStatus=matched", nil))
decodeJSON(t, w.Body.Bytes(), &resp)
if resp.Data.Total != 1 || len(resp.Data.Items) != 1 || resp.Data.Items[0].Key != strconv.FormatUint(matchedItem.ID, 10) {
t.Fatalf("expected only the matched row: %+v", resp.Data)
}
// Invalid value is rejected, not silently ignored.
w = do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns?matchStatus=bogus", nil))
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for invalid matchStatus, got %d", w.Code)
}
}
func decodeJSON(t *testing.T, body []byte, v any) {
t.Helper()
if err := json.Unmarshal(body, v); err != nil {
t.Fatalf("decode response: %v (%s)", err, body)
}
}
+103 -8
View File
@@ -40,6 +40,17 @@ type Report struct {
RunID uint64
TotalPages, Read, Created, Updated, Skipped, Failed int
Status string
// MissingMarked is the number of yeeke_return_item rows flipped from
// "ok" to "missing" by this run's completion (#338). It is only ever
// non-zero on a run whose page walk finished naturally, wrote no
// failures, and stayed under the 20% safety-valve threshold. Persisted
// as yeeke_sync_run.missing_marked_count; when the safety valve skips
// marking it stays 0 and the reason goes to error_message.
MissingMarked int
// Recovered is the number of yeeke_return_item rows that were "missing"
// before this run and reappeared in it, flipping back to "ok" (#338).
// Persisted as yeeke_sync_run.recovered_count.
Recovered int
}
// knownClaimStatuses lists the status values the sync code currently
@@ -177,7 +188,7 @@ func (s *Service) run(ctx context.Context, r *models.YeekeSyncRun) (Report, erro
var runErr error
defer func() {
now := time.Now().UTC()
updates := map[string]any{"status": rep.Status, "total_pages": rep.TotalPages, "read_count": rep.Read, "created_count": rep.Created, "updated_count": rep.Updated, "skipped_count": rep.Skipped, "failed_count": rep.Failed, "error_message": errMsg, "active_slot": nil, "lease_owner": "", "lease_expires_at": nil, "finished_at": now}
updates := map[string]any{"status": rep.Status, "total_pages": rep.TotalPages, "read_count": rep.Read, "created_count": rep.Created, "updated_count": rep.Updated, "skipped_count": rep.Skipped, "failed_count": rep.Failed, "missing_marked_count": rep.MissingMarked, "recovered_count": rep.Recovered, "error_message": errMsg, "active_slot": nil, "lease_owner": "", "lease_expires_at": nil, "finished_at": now}
if rep.Status == "succeeded" {
updates["last_success_at"] = now
}
@@ -185,6 +196,13 @@ func (s *Service) run(ctx context.Context, r *models.YeekeSyncRun) (Report, erro
}()
seen := map[string]bool{}
var firstWriteErr error
// complete tracks whether the page walk ended NATURALLY (empty page,
// short page, or reaching p.Pages) as opposed to the duplicate-
// fingerprint break or MaxPages exhaustion (#338): only a naturally
// complete run is trusted to mark absent items/packages "missing" below,
// since a duplicate/MaxPages stop means the walk never actually finished
// seeing everything yeeke currently has.
complete := false
for page := 1; page <= s.cfg.MaxPages; page++ {
var p yeekeclient.ReturnPage
var e error
@@ -211,6 +229,7 @@ func (s *Service) run(ctx context.Context, r *models.YeekeSyncRun) (Report, erro
}
rep.TotalPages = page
if len(p.Records) == 0 {
complete = true
break
}
finger := pageFingerprint(p)
@@ -220,7 +239,7 @@ func (s *Service) run(ctx context.Context, r *models.YeekeSyncRun) (Report, erro
}
seen[finger] = true
for _, x := range p.Records {
created, updated, err := s.upsert(ctx, x)
created, updated, recovered, err := s.upsert(ctx, x)
if err != nil {
rep.Failed++
if firstWriteErr == nil {
@@ -229,6 +248,7 @@ func (s *Service) run(ctx context.Context, r *models.YeekeSyncRun) (Report, erro
continue
}
rep.Read++
rep.Recovered += recovered
if created {
rep.Created++
} else if updated {
@@ -238,9 +258,11 @@ func (s *Service) run(ctx context.Context, r *models.YeekeSyncRun) (Report, erro
}
}
if len(p.Records) < s.cfg.PageSize {
complete = true
break
}
if p.Pages > 0 && page >= p.Pages {
complete = true
break
}
}
@@ -253,9 +275,73 @@ func (s *Service) run(ctx context.Context, r *models.YeekeSyncRun) (Report, erro
runErr = errors.New(errMsg)
}
}
// #338: only a naturally complete run with zero write failures is
// trusted to mark items/packages the sync no longer sees as "missing".
if complete && rep.Failed == 0 && rep.Status == "succeeded" {
marked, valveReason, mErr := s.markMissing(ctx, r)
switch {
case mErr != nil:
errMsg = truncateRunError(fmt.Sprintf("标记 yeeke 退货不可用失败:%v", mErr))
case valveReason != "":
errMsg = truncateRunError(valveReason)
default:
rep.MissingMarked = marked
}
}
return rep, nil
}
// markMissing implements #338's completion-triggered availability flip: any
// yeeke_return_item/yeeke_return_package row still marked sync_status="ok"
// but whose last_synced_at predates this run's start was not touched by
// this (complete, failure-free) run's upsert calls, meaning yeeke no longer
// reports it. Both tables are flipped to "missing" with missing_since
// stamped, in one transaction, so a reader never observes the item flipped
// without its package (or vice versa). Rows are never deleted.
//
// Safety valve: if the number of items that would be marked exceeds 20% of
// the items currently "ok", nothing is marked and valveReason explains why
// (the run itself still finishes as "succeeded" — this is a caution, not a
// sync failure).
func (s *Service) markMissing(ctx context.Context, r *models.YeekeSyncRun) (marked int, valveReason string, err error) {
var totalOkItems int64
if e := s.db.WithContext(ctx).Model(&models.YeekeReturnItem{}).Where("sync_status = ?", "ok").Count(&totalOkItems).Error; e != nil {
return 0, "", e
}
var candidateItems int64
if e := s.db.WithContext(ctx).Model(&models.YeekeReturnItem{}).
Where("sync_status = ? AND last_synced_at < ?", "ok", r.StartedAt).
Count(&candidateItems).Error; e != nil {
return 0, "", e
}
if candidateItems == 0 {
return 0, "", nil
}
// candidateItems/totalOkItems > 20% <=> candidateItems*5 > totalOkItems
// (integer-only, avoids float rounding).
if totalOkItems > 0 && candidateItems*5 > totalOkItems {
return 0, fmt.Sprintf(
"未标记 yeeke 退货不可用:待标记 %d 条超过当前可用退货商品 %d 条的 20%% 安全阈值,需人工核查后再处理",
candidateItems, totalOkItems,
), nil
}
now := time.Now().UTC()
txErr := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if e := tx.Model(&models.YeekeReturnItem{}).
Where("sync_status = ? AND last_synced_at < ?", "ok", r.StartedAt).
Updates(map[string]any{"sync_status": "missing", "missing_since": now}).Error; e != nil {
return e
}
return tx.Model(&models.YeekeReturnPackage{}).
Where("sync_status = ? AND last_synced_at < ?", "ok", r.StartedAt).
Updates(map[string]any{"sync_status": "missing", "missing_since": now}).Error
})
if txErr != nil {
return 0, "", txErr
}
return int(candidateItems), "", nil
}
// truncateRunError keeps error_message inside the column's size limit. It
// never includes request bodies or headers, so it cannot leak a captcha,
// token or credential: every error path above passes only Go error text from
@@ -273,7 +359,10 @@ func pageFingerprint(p yeekeclient.ReturnPage) string {
h := sha256.Sum256(b)
return hex.EncodeToString(h[:])
}
func (s *Service) upsert(ctx context.Context, p yeekeclient.ReturnPackage) (bool, bool, error) {
// upsert writes one package and its items. recovered counts items that were
// marked "missing" before and reappeared in this run (#338).
func (s *Service) upsert(ctx context.Context, p yeekeclient.ReturnPackage) (created bool, updated bool, recovered int, err error) {
now := time.Now().UTC()
key := packageKey(p)
status := external(p.Status)
@@ -282,7 +371,7 @@ func (s *Service) upsert(ctx context.Context, p yeekeclient.ReturnPackage) (bool
// behind without its items. Rows are always inserted fully populated —
// MySQL strict mode (NO_ZERO_DATE) rejects the zero last_synced_at an
// empty placeholder insert would carry, which failed every record.
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var row models.YeekeReturnPackage
e := tx.Where("external_id = ?", key).First(&row).Error
isNew = errors.Is(e, gorm.ErrRecordNotFound)
@@ -304,7 +393,10 @@ func (s *Service) upsert(ctx context.Context, p yeekeclient.ReturnPackage) (bool
return e
}
} else {
vals := map[string]any{"order_sn": fields.OrderSN, "tracking_no": fields.TrackingNo, "shop_id": fields.ShopID, "shop_name": fields.ShopName, "ware_code": fields.WareCode, "ware_house": fields.WareHouse, "ware_name": fields.WareName, "claim_status": fields.ClaimStatus, "status_unrecognized": fields.StatusUnrecognized, "claim_time": fields.ClaimTime, "create_time": fields.CreateTime, "update_time": fields.UpdateTime, "destroy_dead_line": fields.DestroyDeadLine, "last_synced_at": now, "sync_status": "ok"}
// #338: missing_since is reset to NULL whenever a package
// reappears in a sync so it recovers cleanly, whatever its prior
// sync_status was.
vals := map[string]any{"order_sn": fields.OrderSN, "tracking_no": fields.TrackingNo, "shop_id": fields.ShopID, "shop_name": fields.ShopName, "ware_code": fields.WareCode, "ware_house": fields.WareHouse, "ware_name": fields.WareName, "claim_status": fields.ClaimStatus, "status_unrecognized": fields.StatusUnrecognized, "claim_time": fields.ClaimTime, "create_time": fields.CreateTime, "update_time": fields.UpdateTime, "destroy_dead_line": fields.DestroyDeadLine, "last_synced_at": now, "sync_status": "ok", "missing_since": (*time.Time)(nil)}
if e = tx.Model(&row).Updates(vals).Error; e != nil {
return e
}
@@ -327,7 +419,10 @@ func (s *Service) upsert(ctx context.Context, p yeekeclient.ReturnPackage) (bool
case ie != nil:
return ie
default:
iv := map[string]any{"item_id": item.ItemID, "variation_id": item.VariationID, "item_name": item.ItemName, "variation_name": item.VariationName, "image": item.Image, "quantity": item.Quantity, "last_synced_at": now, "sync_status": "ok"}
if existing.SyncStatus == "missing" {
recovered++
}
iv := map[string]any{"item_id": item.ItemID, "variation_id": item.VariationID, "item_name": item.ItemName, "variation_name": item.VariationName, "image": item.Image, "quantity": item.Quantity, "last_synced_at": now, "sync_status": "ok", "missing_since": (*time.Time)(nil)}
if e = tx.Model(&existing).Updates(iv).Error; e != nil {
return e
}
@@ -336,7 +431,7 @@ func (s *Service) upsert(ctx context.Context, p yeekeclient.ReturnPackage) (bool
return nil
})
if err != nil {
return false, false, err
return false, false, 0, err
}
return isNew, !isNew, nil
return isNew, !isNew, recovered, nil
}
@@ -0,0 +1,406 @@
package yeeke
import (
"context"
"fmt"
"go-admin/app/goauto/models"
"go-admin/app/goauto/yeekeclient"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
)
// fivePackagesFirstReport builds a first-sync page of 5 distinct packages
// (p1..p5), so a follow-up run dropping exactly one of them (1/5 = 20%,
// the safety-valve boundary, which is not ">20%") still marks it missing.
func fivePackagesFirstReport() []string {
recs := make([]string, 0, 5)
for i := 1; i <= 5; i++ {
id := fmt.Sprintf("p%d", i)
recs = append(recs, record(id, "i", "v"+id, 1))
}
return recs
}
// TestCompleteRunMarksAbsentItemsAndPackagesMissing: p1 exists from an
// earlier sync of 5 packages; a later, naturally complete run only reports
// the other 4 (p2..p5). p1's item and package must both flip to
// sync_status="missing" with missing_since set; p2..p5 must stay "ok".
func TestCompleteRunMarksAbsentItemsAndPackagesMissing(t *testing.T) {
db := testDB(t)
c, _ := yeekeclient.New("http://unused.invalid")
s := NewService(db, c, Config{PageSize: 10})
srv1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, page(fivePackagesFirstReport(), 5, 1))
}))
s.client, _ = yeekeclient.New(srv1.URL)
if _, err := s.Sync(context.Background(), "manual"); err != nil {
t.Fatalf("first sync: %v", err)
}
srv1.Close()
time.Sleep(1100 * time.Millisecond) // ensure StartedAt of run 2 is strictly later
srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, page([]string{record("p2", "i", "vp2", 1), record("p3", "i", "vp3", 1), record("p4", "i", "vp4", 1), record("p5", "i", "vp5", 1)}, 4, 1))
}))
defer srv2.Close()
s.client, _ = yeekeclient.New(srv2.URL)
rep, err := s.Sync(context.Background(), "manual")
if err != nil {
t.Fatalf("second sync: %v", err)
}
if rep.Status != "succeeded" {
t.Fatalf("rep=%+v", rep)
}
if rep.MissingMarked != 1 {
t.Fatalf("MissingMarked=%d, want 1", rep.MissingMarked)
}
var p1 models.YeekeReturnPackage
if e := db.Where("external_id = ?", "p1").First(&p1).Error; e != nil {
t.Fatal(e)
}
if p1.SyncStatus != "missing" || p1.MissingSince == nil {
t.Fatalf("p1 package=%+v, want sync_status=missing with missing_since set", p1)
}
var i1 models.YeekeReturnItem
if e := db.Where("package_id = ?", p1.ID).First(&i1).Error; e != nil {
t.Fatal(e)
}
if i1.SyncStatus != "missing" || i1.MissingSince == nil {
t.Fatalf("p1 item=%+v, want sync_status=missing with missing_since set", i1)
}
var p2 models.YeekeReturnPackage
if e := db.Where("external_id = ?", "p2").First(&p2).Error; e != nil {
t.Fatal(e)
}
if p2.SyncStatus != "ok" || p2.MissingSince != nil {
t.Fatalf("p2 package=%+v, want sync_status=ok with no missing_since", p2)
}
}
// TestFailedPageStopsMarking: page 2 always fails, so the run ends "failed".
// Nothing must be marked missing even though p1 (from an earlier run) is
// absent from this run's (incomplete) output.
func TestFailedPageStopsMarking(t *testing.T) {
db := testDB(t)
c, _ := yeekeclient.New("http://unused.invalid")
s := NewService(db, c, Config{PageSize: 1, Retry: 0})
srv1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, page([]string{record("p1", "i", "v1", 1)}, 1, 1))
}))
s.client, _ = yeekeclient.New(srv1.URL)
if _, err := s.Sync(context.Background(), "manual"); err != nil {
t.Fatalf("first sync: %v", err)
}
srv1.Close()
var calls int32
srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n := atomic.AddInt32(&calls, 1)
if n == 1 {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, page([]string{record("p2", "i", "v2", 1)}, 2, 2))
return
}
http.Error(w, "boom", http.StatusInternalServerError)
}))
defer srv2.Close()
s.client, _ = yeekeclient.New(srv2.URL)
if _, err := s.Sync(context.Background(), "manual"); err == nil {
t.Fatal("expected the second run (failed page 2) to error")
}
var p1 models.YeekeReturnPackage
if e := db.Where("external_id = ?", "p1").First(&p1).Error; e != nil {
t.Fatal(e)
}
if p1.SyncStatus != "ok" || p1.MissingSince != nil {
t.Fatalf("p1 must stay ok after a failed page, got %+v", p1)
}
}
// TestCtxCancelStopsMarking: the context is cancelled mid-walk. The run ends
// with an error and must not mark anything missing.
func TestCtxCancelStopsMarking(t *testing.T) {
db := testDB(t)
c, _ := yeekeclient.New("http://unused.invalid")
s := NewService(db, c, Config{PageSize: 1, Retry: 0})
srv1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, page([]string{record("p1", "i", "v1", 1)}, 1, 1))
}))
s.client, _ = yeekeclient.New(srv1.URL)
if _, err := s.Sync(context.Background(), "manual"); err != nil {
t.Fatalf("first sync: %v", err)
}
srv1.Close()
ctx, cancel := context.WithCancel(context.Background())
var calls int32
srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n := atomic.AddInt32(&calls, 1)
if n == 1 {
cancel()
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, page([]string{record("p2", "i", "v2", 1)}, 2, 2))
return
}
http.Error(w, "should not be reached", http.StatusInternalServerError)
}))
defer srv2.Close()
s.client, _ = yeekeclient.New(srv2.URL)
if _, err := s.Sync(ctx, "manual"); err == nil {
t.Fatal("expected a context-cancellation error")
}
var p1 models.YeekeReturnPackage
if e := db.Where("external_id = ?", "p1").First(&p1).Error; e != nil {
t.Fatal(e)
}
if p1.SyncStatus != "ok" || p1.MissingSince != nil {
t.Fatalf("p1 must stay ok after a context-cancelled run, got %+v", p1)
}
}
// TestDuplicateFingerprintStopsMarking: the walk ends via the
// duplicate-page break, not a natural stop, so nothing must be marked even
// though only p1 (not p2 from an earlier sync) is ever reported.
func TestDuplicateFingerprintStopsMarking(t *testing.T) {
db := testDB(t)
c, _ := yeekeclient.New("http://unused.invalid")
s := NewService(db, c, Config{PageSize: 1})
srv1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, page([]string{record("p2", "i", "v2", 1)}, 1, 1))
}))
s.client, _ = yeekeclient.New(srv1.URL)
if _, err := s.Sync(context.Background(), "manual"); err != nil {
t.Fatalf("first sync (seeds p2): %v", err)
}
srv1.Close()
// Second run: page 1 returns p1, but the server (mis)reports pages=5 and
// then serves the exact same page again, triggering the duplicate break
// before ever reaching a natural stop.
body := page([]string{record("p1", "i", "v1", 1)}, 10, 5)
var calls int32
srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&calls, 1)
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, body)
}))
defer srv2.Close()
s.client, _ = yeekeclient.New(srv2.URL)
rep, err := s.Sync(context.Background(), "manual")
if err != nil {
t.Fatalf("second sync: %v", err)
}
if rep.MissingMarked != 0 {
t.Fatalf("MissingMarked=%d, want 0 (duplicate-fingerprint stop is not complete)", rep.MissingMarked)
}
var p2 models.YeekeReturnPackage
if e := db.Where("external_id = ?", "p2").First(&p2).Error; e != nil {
t.Fatal(e)
}
if p2.SyncStatus != "ok" || p2.MissingSince != nil {
t.Fatalf("p2 must stay ok after a duplicate-fingerprint stop, got %+v", p2)
}
}
// TestMaxPagesExhaustionStopsMarking: MaxPages is reached without any
// natural stop condition being hit, so nothing must be marked.
func TestMaxPagesExhaustionStopsMarking(t *testing.T) {
db := testDB(t)
c, _ := yeekeclient.New("http://unused.invalid")
s := NewService(db, c, Config{PageSize: 1})
srv1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, page([]string{record("p2", "i", "v2", 1)}, 1, 1))
}))
s.client, _ = yeekeclient.New(srv1.URL)
if _, err := s.Sync(context.Background(), "manual"); err != nil {
t.Fatalf("first sync (seeds p2): %v", err)
}
srv1.Close()
// Second run: every page returns a distinct full page (never short,
// never empty, pages always reported far beyond MaxPages), so the walk
// only stops because MaxPages is exhausted.
var n int32
srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
k := atomic.AddInt32(&n, 1)
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, page([]string{record(fmt.Sprintf("p1-%d", k), "i", "v1", 1)}, 1000, 1000))
}))
defer srv2.Close()
s.client, _ = yeekeclient.New(srv2.URL)
s.cfg.MaxPages = 2
rep, err := s.Sync(context.Background(), "manual")
if err != nil {
t.Fatalf("second sync: %v", err)
}
if rep.MissingMarked != 0 {
t.Fatalf("MissingMarked=%d, want 0 (MaxPages exhaustion is not complete)", rep.MissingMarked)
}
var p2 models.YeekeReturnPackage
if e := db.Where("external_id = ?", "p2").First(&p2).Error; e != nil {
t.Fatal(e)
}
if p2.SyncStatus != "ok" || p2.MissingSince != nil {
t.Fatalf("p2 must stay ok after MaxPages exhaustion, got %+v", p2)
}
}
// TestSafetyValveSkipsMarkingWhenOverThreshold: 3 packages are "ok"; a
// complete follow-up run only reports 1 of them (2 of 3 = 67% would be
// marked, well over the 20% threshold). Nothing must be marked, and the
// run's error_message must explain why.
func TestSafetyValveSkipsMarkingWhenOverThreshold(t *testing.T) {
db := testDB(t)
c, _ := yeekeclient.New("http://unused.invalid")
s := NewService(db, c, Config{PageSize: 10})
srv1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, page([]string{record("p1", "i", "v1", 1), record("p2", "i", "v2", 1), record("p3", "i", "v3", 1)}, 3, 1))
}))
s.client, _ = yeekeclient.New(srv1.URL)
if _, err := s.Sync(context.Background(), "manual"); err != nil {
t.Fatalf("first sync: %v", err)
}
srv1.Close()
time.Sleep(1100 * time.Millisecond)
srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, page([]string{record("p1", "i", "v1", 1)}, 1, 1))
}))
defer srv2.Close()
s.client, _ = yeekeclient.New(srv2.URL)
rep, err := s.Sync(context.Background(), "manual")
if err != nil {
t.Fatalf("second sync: %v", err)
}
if rep.Status != "succeeded" {
t.Fatalf("safety valve must not fail the run, got status=%q", rep.Status)
}
if rep.MissingMarked != 0 {
t.Fatalf("MissingMarked=%d, want 0 (over the 20%% safety valve)", rep.MissingMarked)
}
var run models.YeekeSyncRun
if e := db.First(&run, rep.RunID).Error; e != nil {
t.Fatal(e)
}
if !strings.Contains(run.ErrorMessage, "20%") {
t.Fatalf("run.ErrorMessage=%q, want an explanation mentioning the 20%% safety valve", run.ErrorMessage)
}
for _, ext := range []string{"p2", "p3"} {
var p models.YeekeReturnPackage
if e := db.Where("external_id = ?", ext).First(&p).Error; e != nil {
t.Fatal(e)
}
if p.SyncStatus != "ok" || p.MissingSince != nil {
t.Fatalf("%s must stay ok when the safety valve trips, got %+v", ext, p)
}
}
}
// TestReappearingRecordRecoversFromMissing: a package/item marked missing by
// an earlier complete run reappears in a later sync and must recover to
// sync_status="ok" with missing_since cleared.
func TestReappearingRecordRecoversFromMissing(t *testing.T) {
db := testDB(t)
c, _ := yeekeclient.New("http://unused.invalid")
s := NewService(db, c, Config{PageSize: 10})
srv1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, page(fivePackagesFirstReport(), 5, 1))
}))
s.client, _ = yeekeclient.New(srv1.URL)
if _, err := s.Sync(context.Background(), "manual"); err != nil {
t.Fatalf("run 1: %v", err)
}
srv1.Close()
time.Sleep(1100 * time.Millisecond)
srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, page([]string{record("p2", "i", "vp2", 1), record("p3", "i", "vp3", 1), record("p4", "i", "vp4", 1), record("p5", "i", "vp5", 1)}, 4, 1))
}))
s.client, _ = yeekeclient.New(srv2.URL)
rep2, err := s.Sync(context.Background(), "manual")
if err != nil {
t.Fatalf("run 2: %v", err)
}
if rep2.MissingMarked != 1 {
t.Fatalf("run2 MissingMarked=%d, want 1", rep2.MissingMarked)
}
srv2.Close()
var p1 models.YeekeReturnPackage
db.Where("external_id = ?", "p1").First(&p1)
if p1.SyncStatus != "missing" || p1.MissingSince == nil {
t.Fatalf("p1 must be missing before recovery, got %+v", p1)
}
srv3 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, page(fivePackagesFirstReport(), 5, 1))
}))
defer srv3.Close()
s.client, _ = yeekeclient.New(srv3.URL)
rep3, err := s.Sync(context.Background(), "manual")
if err != nil {
t.Fatalf("run 3 (recovery): %v", err)
}
if rep3.Recovered != 1 || rep3.MissingMarked != 0 {
t.Fatalf("run3 Recovered=%d MissingMarked=%d, want 1 and 0", rep3.Recovered, rep3.MissingMarked)
}
// Both counters are persisted on the run rows shown by the sync-runs page.
var run2, run3 models.YeekeSyncRun
if e := db.First(&run2, rep2.RunID).Error; e != nil {
t.Fatal(e)
}
if e := db.First(&run3, rep3.RunID).Error; e != nil {
t.Fatal(e)
}
if run2.MissingMarkedCount != 1 || run2.RecoveredCount != 0 || run3.MissingMarkedCount != 0 || run3.RecoveredCount != 1 {
t.Fatalf("persisted counts run2=(%d,%d) run3=(%d,%d), want (1,0) and (0,1)", run2.MissingMarkedCount, run2.RecoveredCount, run3.MissingMarkedCount, run3.RecoveredCount)
}
// A fresh variable is used here (not the p1 declared above): GORM's Scan
// does not reset an already non-nil pointer field to nil when the new
// row's column is NULL, so reusing the earlier struct would misreport a
// stale MissingSince even though the row itself recovered correctly.
var recovered models.YeekeReturnPackage
if e := db.Where("external_id = ?", "p1").First(&recovered).Error; e != nil {
t.Fatal(e)
}
if recovered.SyncStatus != "ok" || recovered.MissingSince != nil {
t.Fatalf("p1 must recover to ok with missing_since cleared, got %+v", recovered)
}
var i1 models.YeekeReturnItem
if e := db.Where("package_id = ?", recovered.ID).First(&i1).Error; e != nil {
t.Fatal(e)
}
if i1.SyncStatus != "ok" || i1.MissingSince != nil {
t.Fatalf("p1's item must recover to ok with missing_since cleared, got %+v", i1)
}
}
@@ -0,0 +1,25 @@
package version_local
import (
"go-admin/app/goauto/migrations"
"go-admin/cmd/migrate/migration"
common "go-admin/common/models"
"gorm.io/gorm"
"runtime"
)
// #338: creates return_match and return_match_log (registered in
// migrations.MigratedModels) on databases whose earlier versions are already
// recorded in sys_migration.
func init() {
_, f, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(f), migrateReturnMatch)
}
func migrateReturnMatch(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := migrations.Migrate(tx); err != nil {
return err
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
@@ -0,0 +1,25 @@
package version_local
import (
"go-admin/app/goauto/migrations"
"go-admin/cmd/migrate/migration"
common "go-admin/common/models"
"gorm.io/gorm"
"runtime"
)
// #338: creates return_match_batch (registered in
// migrations.MigratedModels) on databases whose earlier versions are already
// recorded in sys_migration.
func init() {
_, f, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(f), migrateReturnMatchBatch)
}
func migrateReturnMatchBatch(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := migrations.Migrate(tx); err != nil {
return err
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
@@ -0,0 +1,28 @@
package version_local
import (
"go-admin/app/goauto/migrations"
"go-admin/cmd/migrate/migration"
common "go-admin/common/models"
"gorm.io/gorm"
"runtime"
)
// #338 scope addition: adds the nullable missing_since column to
// yeeke_return_package and yeeke_return_item (models.YeekeReturnPackage /
// models.YeekeReturnItem, registered in migrations.MigratedModels) on
// databases whose earlier versions are already recorded in sys_migration.
// AutoMigrate alone never reaches an existing database without a version
// file like this one being run by the migrate command.
func init() {
_, f, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(f), migrateReturnMissing)
}
func migrateReturnMissing(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := migrations.Migrate(tx); err != nil {
return err
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
@@ -0,0 +1,25 @@
package version_local
import (
"go-admin/app/goauto/migrations"
"go-admin/cmd/migrate/migration"
common "go-admin/common/models"
"gorm.io/gorm"
"runtime"
)
// #338: adds yeeke_sync_run.missing_marked_count and recovered_count (registered in
// migrations.MigratedModels) on databases whose earlier versions are already
// recorded in sys_migration.
func init() {
_, f, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(f), migrateYeekeSyncRunMissingCounts)
}
func migrateYeekeSyncRunMissingCounts(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := migrations.Migrate(tx); err != nil {
return err
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
@@ -0,0 +1,138 @@
package version_local
import (
"errors"
"fmt"
"os"
"runtime"
"strings"
adminmodels "go-admin/app/admin/models"
"go-admin/app/goauto/access"
"go-admin/cmd/migrate/migration"
migrationmodels "go-admin/cmd/migrate/migration/models"
common "go-admin/common/models"
"gorm.io/gorm"
)
const afterSalesInitialPasswordEnv = "GOAUTO_AFTER_SALES_INITIAL_PASSWORD"
var afterSalesUsers = []string{"zengyt", "huangyj", "zhuyt", "wangxy"}
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateAfterSalesRole)
}
// migrateAfterSalesRole creates the internal 售后 role and accounts. It
// copies the current purchaser menu/API grants, so the two roles stay aligned
// for the current product surface (including yeeke 退货同步 and 退货商品).
// The initial password is deliberately supplied only at migration time via an
// environment variable; it is never stored in source, logs, or issue text.
func migrateAfterSalesRole(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
role, err := ensureAfterSalesRole(tx)
if err != nil {
return err
}
if err := clonePurchaserPermissions(tx, role.RoleId); err != nil {
return err
}
if err := ensureAfterSalesUsers(tx, role.RoleId); err != nil {
return err
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
func ensureAfterSalesRole(db *gorm.DB) (migrationmodels.SysRole, error) {
var purchaser migrationmodels.SysRole
if err := db.Where("role_key = ?", access.RolePurchaser).First(&purchaser).Error; err != nil {
return migrationmodels.SysRole{}, fmt.Errorf("find purchaser role: %w", err)
}
role := migrationmodels.SysRole{}
if err := db.Where("role_key = ?", access.RoleAfterSales).
Assign(migrationmodels.SysRole{
RoleName: "售后", Status: "2", RoleSort: purchaser.RoleSort + 1,
Admin: false, DataScope: purchaser.DataScope,
Remark: "GoAuto 售后业务角色(系统维护)",
}).FirstOrCreate(&role, migrationmodels.SysRole{RoleKey: access.RoleAfterSales}).Error; err != nil {
return migrationmodels.SysRole{}, err
}
return role, nil
}
func clonePurchaserPermissions(db *gorm.DB, roleID int) error {
var purchaser migrationmodels.SysRole
if err := db.Where("role_key = ?", access.RolePurchaser).First(&purchaser).Error; err != nil {
return err
}
if err := db.Exec(`
INSERT INTO sys_role_menu (role_id, menu_id)
SELECT ?, source.menu_id
FROM sys_role_menu AS source
WHERE source.role_id = ?
AND NOT EXISTS (
SELECT 1 FROM sys_role_menu AS target
WHERE target.role_id = ? AND target.menu_id = source.menu_id
)`, roleID, purchaser.RoleId, roleID).Error; err != nil {
return fmt.Errorf("clone purchaser menus: %w", err)
}
if err := db.Exec(`
INSERT INTO casbin_rule (ptype, v0, v1, v2, v3, v4, v5)
SELECT source.ptype, ?, source.v1, source.v2, source.v3, source.v4, source.v5
FROM casbin_rule AS source
WHERE source.ptype = 'p' AND source.v0 = ?
AND NOT EXISTS (
SELECT 1 FROM casbin_rule AS target
WHERE target.ptype = source.ptype AND target.v0 = ?
AND target.v1 = source.v1 AND target.v2 = source.v2
AND COALESCE(target.v3, '') = COALESCE(source.v3, '')
AND COALESCE(target.v4, '') = COALESCE(source.v4, '')
AND COALESCE(target.v5, '') = COALESCE(source.v5, '')
)`, access.RoleAfterSales, access.RolePurchaser, access.RoleAfterSales).Error; err != nil {
return fmt.Errorf("clone purchaser API policies: %w", err)
}
return nil
}
func ensureAfterSalesUsers(db *gorm.DB, roleID int) error {
missing := make([]string, 0, len(afterSalesUsers))
for _, username := range afterSalesUsers {
var existing adminmodels.SysUser
err := db.Unscoped().Where("username = ?", username).First(&existing).Error
switch {
case errors.Is(err, gorm.ErrRecordNotFound):
missing = append(missing, username)
case err != nil:
return fmt.Errorf("find after-sales user %q: %w", username, err)
case existing.RoleId != roleID:
return fmt.Errorf("after-sales user %q already exists with another role", username)
}
}
if len(missing) == 0 {
return nil
}
password := strings.TrimSpace(os.Getenv(afterSalesInitialPasswordEnv))
if password == "" {
return fmt.Errorf("%s must be set when creating after-sales users", afterSalesInitialPasswordEnv)
}
// Validate before creating any account; the value itself is never logged or
// written to a repository artifact.
if len(password) < 1 {
return errors.New("after-sales initial password is empty")
}
for _, username := range missing {
user := adminmodels.SysUser{
Username: username, Password: password, NickName: username,
RoleId: roleID, Status: "2", Remark: "GoAuto 售后账号",
}
if err := db.Create(&user).Error; err != nil {
return fmt.Errorf("create after-sales user %q: %w", username, err)
}
}
return nil
}
@@ -0,0 +1,87 @@
package version_local
import (
"os"
"testing"
adminmodels "go-admin/app/admin/models"
"go-admin/app/goauto/access"
migrationmodels "go-admin/cmd/migrate/migration/models"
"golang.org/x/crypto/bcrypt"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestEnsureAfterSalesRoleAndUsersIsIdempotent(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err = db.AutoMigrate(&migrationmodels.SysRole{}, &migrationmodels.SysMenu{}, &adminmodels.SysUser{}); err != nil {
t.Fatal(err)
}
if err = db.Exec(`CREATE TABLE IF NOT EXISTS sys_role_menu (role_id integer NOT NULL, menu_id integer NOT NULL, PRIMARY KEY (role_id, menu_id))`).Error; err != nil {
t.Fatal(err)
}
if err = db.Exec(`CREATE TABLE IF NOT EXISTS casbin_rule (id integer PRIMARY KEY AUTOINCREMENT, ptype varchar(100), v0 varchar(100), v1 varchar(100), v2 varchar(100), v3 varchar(100), v4 varchar(100), v5 varchar(100))`).Error; err != nil {
t.Fatal(err)
}
purchaser := migrationmodels.SysRole{RoleName: "采购员", RoleKey: access.RolePurchaser, Status: "2", RoleSort: 20, DataScope: "1"}
if err = db.Create(&purchaser).Error; err != nil {
t.Fatal(err)
}
if err = db.Exec(`INSERT INTO sys_menu (menu_name, title, menu_type, parent_id) VALUES ('GoAutoYeekeReturns', 'yeeke 退货包裹', 'C', 0)`).Error; err != nil {
t.Fatal(err)
}
if err = db.Exec(`INSERT INTO sys_role_menu (role_id, menu_id) SELECT ?, menu_id FROM sys_menu`, purchaser.RoleId).Error; err != nil {
t.Fatal(err)
}
if err = db.Exec(`INSERT INTO casbin_rule (ptype, v0, v1, v2) VALUES ('p', ?, '/api/admin/v1/yeeke-returns', 'GET')`, access.RolePurchaser).Error; err != nil {
t.Fatal(err)
}
oldPassword := os.Getenv(afterSalesInitialPasswordEnv)
defer os.Setenv(afterSalesInitialPasswordEnv, oldPassword)
if err = os.Setenv(afterSalesInitialPasswordEnv, "test-only-password"); err != nil {
t.Fatal(err)
}
role, err := ensureAfterSalesRole(db)
if err != nil {
t.Fatal(err)
}
if err = clonePurchaserPermissions(db, role.RoleId); err != nil {
t.Fatal(err)
}
if err = ensureAfterSalesUsers(db, role.RoleId); err != nil {
t.Fatal(err)
}
if err = clonePurchaserPermissions(db, role.RoleId); err != nil {
t.Fatal(err)
}
if err = ensureAfterSalesUsers(db, role.RoleId); err != nil {
t.Fatal(err)
}
var count int64
if err = db.Model(&adminmodels.SysUser{}).Where("role_id = ?", role.RoleId).Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != int64(len(afterSalesUsers)) {
t.Fatalf("got %d after-sales users, want %d", count, len(afterSalesUsers))
}
var user adminmodels.SysUser
if err = db.Where("username = ?", afterSalesUsers[0]).First(&user).Error; err != nil {
t.Fatal(err)
}
if bcrypt.CompareHashAndPassword([]byte(user.Password), []byte("test-only-password")) != nil {
t.Fatal("initial password was not stored as a bcrypt hash")
}
if err = db.Table("casbin_rule").Where("ptype = 'p' AND v0 = ?", access.RoleAfterSales).Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("got %d after-sales policies, want 1", count)
}
}
@@ -0,0 +1,76 @@
package version_local
import (
"fmt"
"runtime"
"go-admin/app/goauto/access"
"go-admin/cmd/migrate/migration"
common "go-admin/common/models"
"gorm.io/gorm"
)
// #341 follow-up: the after-sales role was created before #338 added the
// return-match API surface. Add only the reviewed return-match grants so the
// existing role can use the new SYB action without recreating or changing its
// users.
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateAfterSalesReturnMatch)
}
type afterSalesReturnMatchAPI struct {
ID int `gorm:"column:id;primaryKey;autoIncrement"`
Title string `gorm:"column:title;size:128"`
Path string `gorm:"column:path;size:128"`
Type string `gorm:"column:type;size:16"`
Action string `gorm:"column:action;size:16"`
}
func (afterSalesReturnMatchAPI) TableName() string { return "sys_api" }
type afterSalesReturnMatchPolicy struct {
ID uint `gorm:"column:id;primaryKey;autoIncrement"`
Ptype string `gorm:"column:ptype;size:100"`
V0 string `gorm:"column:v0;size:100"`
V1 string `gorm:"column:v1;size:100"`
V2 string `gorm:"column:v2;size:100"`
V3 string `gorm:"column:v3;size:100"`
V4 string `gorm:"column:v4;size:100"`
V5 string `gorm:"column:v5;size:100"`
}
func (afterSalesReturnMatchPolicy) TableName() string { return "casbin_rule" }
func migrateAfterSalesReturnMatch(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
var role struct {
RoleID int `gorm:"column:role_id"`
}
if err := tx.Table("sys_role").Select("role_id").Where("role_key = ?", access.RoleAfterSales).First(&role).Error; err != nil {
return fmt.Errorf("find after-sales role: %w", err)
}
for _, permission := range access.AdminAPIs {
if len(permission.Path) < len("/api/admin/v1/return-matches") || permission.Path[:len("/api/admin/v1/return-matches")] != "/api/admin/v1/return-matches" {
continue
}
api := afterSalesReturnMatchAPI{Path: permission.Path, Action: permission.Method}
if err := tx.Where("path = ? AND action = ?", permission.Path, permission.Method).
Assign(afterSalesReturnMatchAPI{Title: permission.Title, Path: permission.Path, Action: permission.Method, Type: "BUS"}).
FirstOrCreate(&api).Error; err != nil {
return fmt.Errorf("ensure return-match API: %w", err)
}
policy := afterSalesReturnMatchPolicy{Ptype: "p", V0: access.RoleAfterSales, V1: permission.Path, V2: permission.Method}
var count int64
if err := tx.Model(&afterSalesReturnMatchPolicy{}).Where("ptype = ? AND v0 = ? AND v1 = ? AND v2 = ?", policy.Ptype, policy.V0, policy.V1, policy.V2).Count(&count).Error; err != nil {
return err
}
if count == 0 {
if err := tx.Create(&policy).Error; err != nil {
return err
}
}
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
@@ -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)
}
}
@@ -0,0 +1,34 @@
package version_local
import (
"runtime"
"go-admin/app/goauto/access"
"go-admin/cmd/migrate/migration"
common "go-admin/common/models"
"gorm.io/gorm"
)
// Repair the API catalogue rows created by 1789801200000 before the path and
// action fields were populated. This is idempotent and only touches the
// return-match endpoints.
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateFixReturnMatchAPICatalog)
}
func migrateFixReturnMatchAPICatalog(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
for _, permission := range access.AdminAPIs {
if len(permission.Path) < len("/api/admin/v1/return-matches") || permission.Path[:len("/api/admin/v1/return-matches")] != "/api/admin/v1/return-matches" {
continue
}
if err := tx.Table("sys_api").Where("title = ?", permission.Title).Updates(map[string]any{
"title": permission.Title, "path": permission.Path, "action": permission.Method, "type": "BUS",
}).Error; err != nil {
return err
}
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
@@ -0,0 +1,26 @@
package version_local
import (
"runtime"
"go-admin/app/goauto/models"
"go-admin/cmd/migrate/migration"
common "go-admin/common/models"
"gorm.io/gorm"
)
func init() {
_, file, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(file), migrateSYBSessionAuthLease)
}
// The lease is additive and contains no credentials. It serializes automatic
// SYB re-authentication across sync and purchase writeback workers.
func migrateSYBSessionAuthLease(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := tx.AutoMigrate(&models.SYBSessionAuthLease{}); err != nil {
return err
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
+25
View File
@@ -0,0 +1,25 @@
import request from '@/utils/request'
export function listReturnMatches(params, options = {}) {
return request({ url: '/api/admin/v1/return-matches', method: 'get', params, ...options })
}
export function getReturnMatch(matchId, options = {}) {
return request({ url: `/api/admin/v1/return-matches/${matchId}`, method: 'get', ...options })
}
export function batchMatchReturns(data) {
return request({ url: '/api/admin/v1/return-matches/batch-match', method: 'post', data })
}
export function confirmReturnMatch(matchId) {
return request({ url: `/api/admin/v1/return-matches/${matchId}/confirm`, method: 'post' })
}
export function cancelReturnMatch(matchId) {
return request({ url: `/api/admin/v1/return-matches/${matchId}/cancel`, method: 'post' })
}
export function remarkReturnMatch(matchId, data) {
return request({ url: `/api/admin/v1/return-matches/${matchId}/remark`, method: 'post', data })
}
@@ -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') },
+282 -25
View File
@@ -3,16 +3,21 @@
<template #wrapper>
<el-card class="page-card" shadow="never">
<el-form :model="query" :inline="true" class="search-form" @submit.prevent="search">
<el-form-item v-if="canPurchase"><span class="selection-count" aria-live="polite">已选择 <strong>{{ selectedProducts.length }}</strong> 条</span></el-form-item>
<el-form-item label="店铺">
<el-autocomplete v-model="query.shopName" class="shop-input" placeholder="选择或输入店铺" clearable value-key="displayName" :fetch-suggestions="suggestSybShops">
<template #default="{ item }"><div class="shop-option" :title="item.displayName"><span class="shop-option-name">{{ item.displayName }}</span><el-tag v-if="!item.enabled" size="small" type="info" effect="plain">已停用</el-tag></div></template>
</el-autocomplete>
</el-form-item>
<el-form-item label="订单号"><el-input v-model="query.orderCodesText" type="textarea" :autosize="{ minRows: 1, maxRows: 4 }" resize="none" class="order-codes-input" placeholder="每行一个订单号,最多 100 个" @keydown.ctrl.enter.prevent="search" /></el-form-item>
<el-form-item label="解析状态"><el-select v-model="query.parseStatus" clearable placeholder="全部" style="width: 110px"><el-option label="成功" value="success" /><el-option label="失败" value="failed" /></el-select></el-form-item>
<el-form-item label="处理阶段"><el-select v-model="query.processStage" clearable placeholder="全部" style="width: 132px"><el-option v-for="item in processStageOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item>
<el-form-item class="toolbar-actions"><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button><el-tooltip v-if="canPurchase" :content="aiMatchButtonReason" :disabled="!aiMatchButtonReason" placement="top"><span class="action-button-wrap"><el-button :loading="specMatchLoading" :disabled="purchaseReadinessLoading || specMatchLoading || aiMatchCandidates.length === 0" @click="runBatchSpecMatch">AI 匹配<span class="action-count">{{ aiMatchCandidates.length }}</span></el-button></span></el-tooltip><el-button v-if="canPurchase" type="success" plain :disabled="imageSearchRows.length === 0" @click="openImageSearchBatch">图搜采集<span class="action-count">{{ imageSearchRows.length }}</span></el-button><el-button v-if="canPurchase" type="primary" :loading="purchaseReadinessLoading" :disabled="purchaseReadinessLoading || specMatchLoading || collectionCandidates.length === 0" @click="openCollectionBatch">创建采集<span class="action-count action-count-primary">{{ collectionCandidates.length }}</span></el-button><el-button v-if="canPurchase" type="primary" :loading="purchaseReadinessLoading" :disabled="purchaseReadinessLoading || specMatchLoading || purchaseCandidates.length === 0" @click="openPurchaseBatch">创建采购<span class="action-count action-count-primary">{{ purchaseCandidates.length }}</span></el-button></el-form-item>
<div class="search-row search-row-primary">
<el-form-item v-if="canPurchase"><span class="selection-count" aria-live="polite">已选择 <strong>{{ selectedProducts.length }}</strong> 条</span></el-form-item>
<el-form-item label="店铺">
<el-autocomplete v-model="query.shopName" class="shop-input" placeholder="选择或输入店铺" clearable value-key="displayName" :fetch-suggestions="suggestSybShops">
<template #default="{ item }"><div class="shop-option" :title="item.displayName"><span class="shop-option-name">{{ item.displayName }}</span><el-tag v-if="!item.enabled" size="small" type="info" effect="plain">已停用</el-tag></div></template>
</el-autocomplete>
</el-form-item>
<el-form-item label="订单号"><el-input v-model="query.orderCodesText" type="textarea" :autosize="{ minRows: 1, maxRows: 4 }" resize="none" class="order-codes-input" placeholder="每行一个订单号,最多 100 个" @keydown.ctrl.enter.prevent="search" /></el-form-item>
<el-form-item label="解析状态"><el-select v-model="query.parseStatus" clearable placeholder="全部" style="width: 110px"><el-option label="成功" value="success" /><el-option label="失败" value="failed" /></el-select></el-form-item>
<el-form-item label="处理阶段"><el-select v-model="query.processStage" clearable placeholder="全部" style="width: 132px"><el-option v-for="item in processStageOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item>
<el-form-item class="toolbar-actions"><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button><el-tooltip v-if="canPurchase" :content="aiMatchButtonReason" :disabled="!aiMatchButtonReason" placement="top"><span class="action-button-wrap"><el-button :loading="specMatchLoading" :disabled="purchaseReadinessLoading || specMatchLoading || aiMatchCandidates.length === 0 || aiMatchButtonOverLimit" @click="runBatchSpecMatch">AI 匹配<span class="action-count">{{ aiMatchCandidates.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="imageSearchButtonReason" :disabled="!imageSearchButtonReason" placement="top"><span class="action-button-wrap"><el-button type="success" plain :disabled="imageSearchRows.length === 0 || imageSearchButtonOverLimit" @click="openImageSearchBatch">图搜采集<span class="action-count">{{ imageSearchRows.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="collectionButtonReason" :disabled="!collectionButtonReason" placement="top"><span class="action-button-wrap"><el-button type="primary" :loading="purchaseReadinessLoading" :disabled="purchaseReadinessLoading || specMatchLoading || collectionCandidates.length === 0 || collectionButtonOverLimit" @click="openCollectionBatch">创建采集<span class="action-count action-count-primary">{{ collectionCandidates.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="purchaseButtonReason" :disabled="!purchaseButtonReason" placement="top"><span class="action-button-wrap"><el-button type="primary" :loading="purchaseReadinessLoading" :disabled="purchaseReadinessLoading || specMatchLoading || purchaseCandidates.length === 0 || purchaseButtonOverLimit" @click="openPurchaseBatch">创建采购<span class="action-count action-count-primary">{{ purchaseCandidates.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="returnMatchButtonReason" :disabled="!returnMatchButtonReason" placement="top"><span class="action-button-wrap"><el-button :loading="returnMatchBatchLoading" :disabled="purchaseReadinessLoading || returnMatchBatchLoading || returnMatchCandidateIds.length === 0 || returnMatchButtonOverLimit" @click="runBatchMatchReturns">匹配退货<span class="action-count">{{ returnMatchCandidateIds.length }}</span></el-button></span></el-tooltip></el-form-item>
</div>
<div class="search-row search-row-secondary">
<el-form-item label="创建时间"><el-date-picker v-model="query.createdAtRange" type="daterange" value-format="YYYY-MM-DD" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" clearable /></el-form-item>
</div>
</el-form>
<el-alert v-if="canPurchase" title="先完成并保存规格匹配,商品才可以创建采购;AI 匹配只处理已解析规格、已关联 PDD 且具备完整可售 SKU 组合的明细。表头全选仅作用于当前页。" type="info" :closable="false" show-icon class="notice compact-notice" />
<el-table ref="productTable" v-loading="loading" :data="products" row-key="id" border stripe empty-text="暂无 SYB 商品明细" @selection-change="handleSelectionChange">
@@ -51,9 +56,31 @@
<div v-if="quickBusyId === row.id || quickMessages[row.id]" class="muted" aria-live="polite">{{ quickBusyId === row.id ? quickPhase : quickMessages[row.id] }}</div>
</template>
</el-table-column>
<el-table-column label="匹配到的退货商品" min-width="200">
<template #default="{ row }">
<template v-if="returnMatchByProductId[row.id]">
<div class="return-match-cell">
<el-image v-if="returnMatchByProductId[row.id].image" :src="returnMatchByProductId[row.id].image" fit="cover" class="thumb" preview-teleported />
<div>
<div>{{ returnMatchByProductId[row.id].orderSn || '—' }}</div>
<div class="muted">{{ returnMatchByProductId[row.id].variationName || '—' }}</div>
<div class="muted">销毁截止:{{ formatMatchDeadline(returnMatchByProductId[row.id].destroyDeadline) }}</div>
</div>
</div>
<el-alert v-if="returnMatchByProductId[row.id].syncStatus === 'missing'" title="退货已不在 yeeke 列表" type="error" :closable="false" show-icon class="return-missing-alert" />
<el-alert v-if="isDeadlinePassed(returnMatchByProductId[row.id].destroyDeadline)" title="退货已过销毁截止" type="error" :closable="false" show-icon class="return-missing-alert" />
<div class="quick-link-actions">
<el-button type="primary" link @click="openMatchDetail(returnMatchByProductId[row.id].id)">查看对比</el-button>
<el-button v-if="canPurchase" type="primary" link @click="openMatchDetail(returnMatchByProductId[row.id].id, true)">备注</el-button>
<el-button v-if="canPurchase" type="danger" link @click="quickCancelMatch(returnMatchByProductId[row.id].id)">取消匹配</el-button>
</div>
</template>
<span v-else class="muted">未匹配</span>
</template>
</el-table-column>
<el-table-column label="操作" width="132" fixed="right"><template #default="{ row }"><el-button type="primary" link @click="openDetail(row.id)">详情</el-button><el-button v-if="canPurchase && isPurchaseCandidate(row)" type="primary" link @click="openSinglePurchase(row)">采购</el-button></template></el-table-column>
</el-table>
<pagination v-show="total > 0" v-model:current-page="query.page" v-model:page-size="query.pageSize" :total="total" @pagination="load" />
<pagination v-show="total > 0" v-model:current-page="query.page" v-model:page-size="query.pageSize" :page-sizes="[20, 50, 100, 200, 500]" :total="total" @pagination="load" />
</el-card>
<el-dialog v-model="quickPicker.open" :title="quickPicker.row ? '选择采集手机' : '切换采集手机'" width="460px" :close-on-click-modal="false" @close="quickCancelPicker">
@@ -162,6 +189,82 @@
<template #footer><el-button type="primary" @click="specMatchResult.open = false">关闭</el-button></template>
</el-dialog>
<!-- 匹配退货批量结果 -->
<el-dialog v-model="returnMatchBatchResult.open" title="批量匹配退货结果" width="820px">
<div class="purchase-summary" aria-live="polite">
<span class="success-text">成功匹配 <strong>{{ returnMatchBatchResult.matchedCount }}</strong> 条</span>
<span>无候选 <strong>{{ returnMatchBatchResult.noCandidateCount }}</strong> 条</span>
<span>阶段不参与跳过 <strong>{{ returnMatchBatchResult.stageIneligibleCount }}</strong> 条</span>
<span v-if="returnMatchBatchResult.conflictCount" class="danger-text">并发冲突跳过 <strong>{{ returnMatchBatchResult.conflictCount }}</strong> 条</span>
</div>
<el-table :data="returnMatchBatchResult.items" border size="small" max-height="420" empty-text="没有匹配结果">
<el-table-column label="SYB 商品 ID" prop="sybProductId" width="120" />
<el-table-column label="结果" width="100"><template #default="{ row }"><el-tag :type="row.matched ? 'success' : 'warning'">{{ row.matched ? '已匹配' : '跳过' }}</el-tag></template></el-table-column>
<el-table-column label="原因" min-width="260"><template #default="{ row }">{{ row.matched ? '—' : (row.reason || matchReasonMeta(row.reasonCode)) }}</template></el-table-column>
<el-table-column label="操作" width="100"><template #default="{ row }"><el-button v-if="row.matched && row.matchId" type="primary" link @click="openMatchDetail(row.matchId)">查看对比</el-button></template></el-table-column>
</el-table>
<template #footer><el-button type="primary" @click="returnMatchBatchResult.open = false">关闭</el-button></template>
</el-dialog>
<!-- 匹配详情对比 -->
<el-dialog v-model="matchDetail.open" title="退货匹配详情" width="900px" :close-on-click-modal="false">
<div v-loading="matchDetail.loading">
<template v-if="matchDetail.data">
<el-alert :title="'当前状态:' + matchStatusLabel(matchDetail.data.match.status)" :type="matchDetail.data.match.status === 'confirmed' ? 'success' : matchDetail.data.match.status === 'cancelled' ? 'info' : 'warning'" :closable="false" show-icon class="notice" />
<div class="split">
<div class="split-col">
<h3 class="section-title">SYB 商品</h3>
<el-descriptions :column="1" border size="small" v-if="matchDetail.data.syb">
<el-descriptions-item label="订单号">{{ matchDetail.data.syb.orderCode }}</el-descriptions-item>
<el-descriptions-item label="虾皮商品ID">{{ matchDetail.data.syb.shopeeItemId }}</el-descriptions-item>
<el-descriptions-item label="店铺">{{ matchDetail.data.syb.shopName }}</el-descriptions-item>
<el-descriptions-item label="标题">{{ matchDetail.data.syb.productTitle }}</el-descriptions-item>
<el-descriptions-item label="目标颜色/尺码">{{ matchDetail.data.syb.targetColor || '—' }} / {{ matchDetail.data.syb.targetSize || '—' }}</el-descriptions-item>
<el-descriptions-item label="数量">{{ matchDetail.data.syb.quantity }}</el-descriptions-item>
<el-descriptions-item label="当前阶段"><el-tag :type="processMeta(matchDetail.data.syb.currentStage).type">{{ matchDetail.data.syb.currentStageLabel }}</el-tag></el-descriptions-item>
<el-descriptions-item label="归一化规格文字">{{ matchDetail.data.normalizedSybSpec }}</el-descriptions-item>
</el-descriptions>
<p v-else class="muted">SYB 商品不存在或已删除</p>
</div>
<div class="split-col">
<h3 class="section-title">yeeke 退货商品</h3>
<el-alert v-if="matchDetail.data.yeeke && matchDetail.data.yeeke.syncStatus === 'missing'" title="退货已不在 yeeke 列表" type="error" :closable="false" show-icon class="notice" />
<el-alert v-if="matchDetail.data.yeeke && isDeadlinePassed(matchDetail.data.yeeke.destroyDeadline)" title="退货已过销毁截止,请核实退货是否仍在库" type="error" :closable="false" show-icon class="notice" />
<el-descriptions :column="1" border size="small" v-if="matchDetail.data.yeeke">
<el-descriptions-item label="退货订单号">{{ matchDetail.data.yeeke.orderSn }}</el-descriptions-item>
<el-descriptions-item label="商品ID">{{ matchDetail.data.yeeke.itemId }}</el-descriptions-item>
<el-descriptions-item label="规格ID">{{ matchDetail.data.yeeke.variationId }}</el-descriptions-item>
<el-descriptions-item label="店铺">{{ matchDetail.data.yeeke.shopName }}</el-descriptions-item>
<el-descriptions-item label="商品名">{{ matchDetail.data.yeeke.itemName }}</el-descriptions-item>
<el-descriptions-item label="规格文字">{{ matchDetail.data.yeeke.variationName }}</el-descriptions-item>
<el-descriptions-item label="数量">{{ matchDetail.data.yeeke.quantity }}</el-descriptions-item>
<el-descriptions-item label="销毁截止">{{ formatMatchDeadline(matchDetail.data.yeeke.destroyDeadline) }}</el-descriptions-item>
<el-descriptions-item label="归一化规格文字">{{ matchDetail.data.normalizedYeekeSpec }}</el-descriptions-item>
</el-descriptions>
<p v-else class="muted">退货商品不存在或已删除</p>
</div>
</div>
<h3 class="section-title">备注</h3>
<el-input ref="matchRemarkInput" v-model="matchDetail.remarkText" type="textarea" :rows="2" maxlength="500" show-word-limit :disabled="!canPurchase" />
<div class="drawer-actions">
<el-button v-if="canPurchase" :loading="matchDetail.saving" @click="saveMatchRemark">保存备注</el-button>
</div>
<h3 class="section-title">操作日志</h3>
<el-table :data="matchDetail.data.logs" border size="small" max-height="220" empty-text="暂无日志">
<el-table-column label="时间" width="170"><template #default="{ row }">{{ formatMatchDeadline(row.createdAt) }}</template></el-table-column>
<el-table-column label="动作" width="90" prop="action" />
<el-table-column label="操作人" width="100" prop="operator" />
<el-table-column label="详情" min-width="200" prop="detail" />
</el-table>
</template>
</div>
<template #footer>
<el-button @click="matchDetail.open = false">关闭</el-button>
<el-button v-if="canPurchase && matchDetail.data && matchDetail.data.match.status === 'matched'" type="success" @click="confirmMatchDetail">确认</el-button>
<el-button v-if="canPurchase && matchDetail.data && matchDetail.data.match.status !== 'cancelled'" type="danger" @click="cancelMatchDetail">取消匹配</el-button>
</template>
</el-dialog>
<!-- 详情 -->
<el-drawer v-model="detail.open" title="SYB 商品明细详情" size="960px">
<div v-loading="detail.loading" class="drawer-body">
@@ -227,6 +330,7 @@ import { listDevices } from '@/api/goauto/devices'
import { createPurchaseTasksBatch, matchPurchaseSpecsBatch, previewPurchaseTasks } from '@/api/goauto/purchase-tasks'
import { batchCreateCollectionTasks, batchCreateImageSearchCollectionTasks } from '@/api/goauto/collection-tasks'
import { listCollectionRules } from '@/api/goauto/collection-rules'
import { listReturnMatches, batchMatchReturns, getReturnMatch, confirmReturnMatch, cancelReturnMatch, remarkReturnMatch } from '@/api/goauto/return-matches'
import { createRequestId } from '@/utils/request-id'
import { readPurchaseDevice, rememberPurchaseDevice } from '@/utils/purchase-device-preference'
import ShopeeProductDetailDrawer from '../shopee-products/ShopeeProductDetailDrawer.vue'
@@ -234,6 +338,12 @@ import PddProductDetailDrawer from '../pdd-products/PddProductDetailDrawer.vue'
// 与服务端 image_search.go 的 imageSearchMaxBatchTasks 保持一致(单批去重后任务数上限,#280)。
const IMAGE_SEARCH_MAX_BATCH_TASKS = 50
// #339: 各批量按钮的服务端上限,用于勾选数超限时禁用按钮并提示原因。
// 创建采购 / AI 匹配:purchase/batch.go maxBatchPurchaseItems;创建采集(去重后 PDD 商品数):
// task/admin_service.go BatchCreate 的 100 上限;匹配退货:returnmatch/service.go maxBatchMatchItems。
const PURCHASE_BATCH_MAX = 100
const COLLECTION_BATCH_MAX = 100
const RETURN_MATCH_BATCH_MAX = 500
export default {
name: 'GoAutoSybProducts',
@@ -254,7 +364,12 @@ export default {
imageSearchBatch: this.emptyImageSearchBatch(),
collectionBatchData: { ruleId: null, deviceId: null },
collectionBatchRules: { ruleId: [{ required: true, message: '请选择采集规则', trigger: 'change' }] },
query: { page: 1, pageSize: 20, shopName: '', orderCodesText: '', parseStatus: '', processStage: '' },
query: { page: 1, pageSize: 200, shopName: '', orderCodesText: '', parseStatus: '', processStage: '', createdAtRange: [] },
returnMatchByProductId: {},
returnMatchLoading: false,
returnMatchBatchLoading: false,
returnMatchBatchResult: { open: false, items: [], matchedCount: 0, noCandidateCount: 0, stageIneligibleCount: 0, conflictCount: 0 },
matchDetail: { open: false, loading: false, saving: false, id: null, data: null, remarkText: '' },
detail: { open: false, loading: false, item: null },
correct: { open: false, saving: false, productId: null, targetColor: '', targetSize: '' },
shopeeDetail: { open: false, productId: null, targetColor: '', action: '' },
@@ -264,23 +379,38 @@ 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') },
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: '待人工核对' }] },
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) },
aiMatchButtonOverLimit() { return this.aiMatchCandidates.length > PURCHASE_BATCH_MAX },
aiMatchButtonReason() {
if (this.purchaseReadinessLoading) return '正在检查 AI 匹配资格'
if (!this.selectedProducts.length) return '请先勾选当前页中可处理的明细'
if (this.aiMatchButtonOverLimit) return `一次最多 ${PURCHASE_BATCH_MAX} 条`
if (this.aiMatchCandidates.length) return ''
const reasons = [...new Set(this.selectedProducts.map(row => this.purchaseReady(row).aiMatchDisabledReason).filter(Boolean))]
return reasons[0] || '所选明细不满足 AI 匹配前提'
},
purchaseCandidates() { return this.selectedProducts.filter(row => this.isPurchaseCandidate(row)) },
// #339: 创建采购上限保持 100(服务端 maxBatchPurchaseItems 不放宽),超出勾选数时禁用并提示。
purchaseButtonOverLimit() { return this.purchaseCandidates.length > PURCHASE_BATCH_MAX },
purchaseButtonReason() { return this.purchaseButtonOverLimit ? `一次最多 ${PURCHASE_BATCH_MAX} 条` : '' },
collectionCandidateRows() { return this.selectedProducts.filter(row => this.isCollectionCandidate(row)) },
collectionCandidates() { return [...new Set(this.collectionCandidateRows.map(row => this.purchaseReady(row).pddProductId))] },
// #339: 创建采集按去重后的 PDD 商品数计数,与服务端 task.BatchCreate 的 100 上限口径一致。
collectionButtonOverLimit() { return this.collectionCandidates.length > COLLECTION_BATCH_MAX },
collectionButtonReason() { return this.collectionButtonOverLimit ? `一次最多 ${COLLECTION_BATCH_MAX} 个 PDD 商品` : '' },
imageSearchRows() { return this.selectedProducts.filter(row => row.shopeeProductId && row.imageUrl) },
// 服务端按去重后的蝦皮商品数创建任务(见 image_search.go 的 imageSearchMaxBatchTasks),
// 这里用相同口径预估,避免提交后才发现超限。
imageSearchMaxBatchTasks() { return IMAGE_SEARCH_MAX_BATCH_TASKS },
imageSearchButtonTaskCount() { return new Set(this.imageSearchRows.map(row => row.shopeeProductId)).size },
imageSearchButtonOverLimit() { return this.imageSearchButtonTaskCount > IMAGE_SEARCH_MAX_BATCH_TASKS },
imageSearchButtonReason() { return this.imageSearchButtonOverLimit ? `一次最多 ${IMAGE_SEARCH_MAX_BATCH_TASKS} 个蝦皮商品` : '' },
// #339: 匹配退货服务端上限(returnmatch.maxBatchMatchItems),超出直接勾选数即禁用。
returnMatchButtonOverLimit() { return this.returnMatchCandidateIds.length > RETURN_MATCH_BATCH_MAX },
returnMatchButtonReason() { return this.returnMatchButtonOverLimit ? `一次最多 ${RETURN_MATCH_BATCH_MAX} 条` : '' },
imageSearchBatchTaskCount() { return new Set(this.imageSearchBatch.rows.map(row => row.shopeeProductId)).size },
imageSearchBatchOverLimit() { return this.imageSearchBatchTaskCount > IMAGE_SEARCH_MAX_BATCH_TASKS },
imageSearchBatchDurationText() {
@@ -307,12 +437,14 @@ 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)
} catch (error) {
if (generation !== this.loadGeneration) return
if (allowNetworkRetry && error?.message === 'Network Error' && !error?.response) {
@@ -326,7 +458,7 @@ export default {
}
},
search() { this.query.page = 1; this.load() },
reset() { this.query = { page: 1, pageSize: 20, shopName: '', orderCodesText: '', parseStatus: '', processStage: '' }; this.load() },
reset() { this.query = { page: 1, pageSize: 200, shopName: '', orderCodesText: '', parseStatus: '', processStage: '', createdAtRange: [] }; this.load() },
normalizeShopName(value) { return String(value || '').normalize('NFKC').trim().toLocaleLowerCase() },
async ensureShopOptions() {
if (this.shopOptionsLoaded) return
@@ -357,24 +489,43 @@ export default {
// 不能要求已关联——另外三个判据都要求,于是图搜入口对它最该服务的那类商品
// 一直不可达(#291)。这里与 imageSearchRows 的过滤条件保持一致。
isImageSearchCandidate(row) { return Boolean(row.shopeeProductId && row.imageUrl) },
// #338: 参与匹配退货的处理阶段范围(服务端 returnmatch.participatingStages 同一口径)。
isReturnMatchCandidate(row) {
const participating = new Set(['pdd_unlinked', 'pdd_pending', 'pdd_collecting', 'pdd_collection_failed', 'color_mapping', 'purchase_ready'])
return participating.has(this.purchaseReady(row).processStage)
},
isActivelyReturnMatched(row) { const stage = this.purchaseReady(row).processStage; return stage === 'return_pending' || stage === 'return_used' },
// `[必须]` 可勾选不等于可采购。放宽的只有勾选:purchaseCandidates / collectionCandidates
// / aiMatchCandidates 仍各自用自己的判据过滤,采购门禁不受影响。
isSelectableCandidate(row) { return this.isPurchaseCandidate(row) || this.isCollectionCandidate(row) || this.isAIMatchCandidate(row) || this.isImageSearchCandidate(row) },
// / aiMatchCandidates / returnMatch 仍各自用自己的判据过滤,采购门禁不受影响。
isSelectableCandidate(row) { return this.isPurchaseCandidate(row) || this.isCollectionCandidate(row) || this.isAIMatchCandidate(row) || this.isImageSearchCandidate(row) || this.isReturnMatchCandidate(row) },
rowSelectable(row) { return this.canPurchase && !this.purchaseReadinessLoading && this.isSelectableCandidate(row) },
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)}` },
processMeta(stage) { return { manual_action: { label: '待人工处理', type: 'warning' }, pdd_unlinked: { label: '未关联 PDD', type: 'info' }, pdd_pending: { label: 'PDD 待采集', type: 'info' }, pdd_collecting: { label: 'PDD 采集中', type: 'primary' }, pdd_collection_failed: { label: 'PDD 采集失败', type: 'danger' }, color_mapping: { label: '规格待匹配', type: 'warning' }, purchase_ready: { label: '可创建采购', type: 'success' }, task_created: { label: '已创建任务', type: 'primary' }, purchase_succeeded: { label: '采购成功', type: 'success' }, order_review: { label: '待人工核对', type: 'danger' }}[stage] || { label: '待人工处理', type: 'warning' } },
processMeta(stage) { return { manual_action: { label: '待人工处理', type: 'warning' }, pdd_unlinked: { label: '未关联 PDD', type: 'info' }, pdd_pending: { label: 'PDD 待采集', type: 'info' }, pdd_collecting: { label: 'PDD 采集中', type: 'primary' }, pdd_collection_failed: { label: 'PDD 采集失败', type: 'danger' }, color_mapping: { label: '规格待匹配', type: 'warning' }, purchase_ready: { label: '可创建采购', type: 'success' }, task_created: { label: '已创建任务', type: 'primary' }, purchase_succeeded: { label: '采购成功', type: 'success' }, order_review: { label: '待人工核对', type: 'danger' }, return_pending: { label: '退货待确认', type: 'warning' }, return_used: { label: '已用退货', type: 'info' }}[stage] || { label: '待人工处理', type: 'warning' } },
purchaseActionLabel(item) { return { open_pdd_link: '去关联', open_mapping: '去匹配', open_shopee: '查看蝦皮商品', open_pdd: '查看 PDD 商品', open_task: '查看任务', reparse: '查看并处理', select_device: '重新选择设备', refresh: '刷新' }[item.nextAction] || '' },
// #339: the page can now show up to 500 rows, but the preview endpoint
// (previewPurchaseTasks -> purchase/batch.go maxBatchPurchaseItems) still
// caps a single request at 100 ids, so this splits the current page into
// sequential chunks of <=100 and merges their results. Any chunk failing
// falls back to the same page-wide failure state the single-request path
// used before, so rows never end up in a partially-updated mix of real
// and failed readiness.
async loadPurchaseReadiness(ids, requestOptions = {}, generation = this.loadGeneration, selectedIDsOverride = null) {
if (generation !== this.loadGeneration) return
const selectedIDs = selectedIDsOverride || new Set(this.selectedProducts.map(row => row.id))
this.purchaseReadiness = {}
if (!this.canPurchase || !ids.length) { this.purchaseReadinessLoading = false; return }
this.purchaseReadinessLoading = true
const chunkSize = 100
const merged = {}
try {
const r = await previewPurchaseTasks({ sybProductIds: ids }, { ...requestOptions, suppressErrorMessage: true })
if (generation !== this.loadGeneration) return
this.purchaseReadiness = Object.fromEntries(r.data.items.map(item => [item.sybProductId, item]))
for (let start = 0; start < ids.length; start += chunkSize) {
const chunk = ids.slice(start, start + chunkSize)
const r = await previewPurchaseTasks({ sybProductIds: chunk }, { ...requestOptions, suppressErrorMessage: true })
if (generation !== this.loadGeneration) return
for (const item of r.data.items) merged[item.sybProductId] = item
}
this.purchaseReadiness = merged
await this.$nextTick()
const valid = this.products.filter(row => selectedIDs.has(row.id) && this.isSelectableCandidate(row))
this.$refs.productTable?.clearSelection()
@@ -428,6 +579,109 @@ export default {
},
formatConfidence(value) { return Number.isFinite(value) ? `${Math.round(value * 100)}%` : '—' },
// ---------------- 退货匹配 (#338) ----------------
formatMatchDeadline(value) { return value ? new Date(value).toLocaleString() : '—' },
// #338: an already-matched return whose destroy deadline has passed is only flagged, never auto-cancelled.
isDeadlinePassed(value) { return Boolean(value) && new Date(value).getTime() <= Date.now() },
matchStatusLabel(status) { return { matched: '待确认', confirmed: '已确认', cancelled: '已取消' }[status] || status },
async loadReturnMatches(ids, generation = this.loadGeneration) {
this.returnMatchByProductId = {}
if (!ids.length) return
this.returnMatchLoading = true
try {
const r = await listReturnMatches({ sybProductId: ids }, { suppressErrorMessage: true })
if (generation !== this.loadGeneration) return
const map = {}
for (const item of (r.data?.items || [])) {
if (item.status === 'cancelled') continue
if (!map[item.sybProductId]) map[item.sybProductId] = item
}
this.returnMatchByProductId = map
} catch {
// 展示失败不影响主列表;用户可重新查询重试。
} finally {
if (generation === this.loadGeneration) this.returnMatchLoading = false
}
},
matchReasonMeta(code) {
return {
stage_ineligible: '阶段不参与',
no_candidate: '无候选',
conflict: '并发冲突'
}[code] || code
},
async runBatchMatchReturns() {
const ids = this.returnMatchCandidateIds
if (!ids.length || this.returnMatchBatchLoading) return
this.returnMatchBatchLoading = true
try {
const r = await batchMatchReturns({ sybProductIds: ids })
const items = r.data.items || []
this.returnMatchBatchResult = {
open: true, items,
matchedCount: r.data.matchedCount || 0,
noCandidateCount: items.filter(i => i.reasonCode === 'no_candidate').length,
stageIneligibleCount: items.filter(i => i.reasonCode === 'stage_ineligible').length,
conflictCount: items.filter(i => i.reasonCode === 'conflict').length
}
await this.load()
} finally {
this.returnMatchBatchLoading = false
}
},
async openMatchDetail(matchId, focusRemark = false) {
this.matchDetail = { open: true, loading: true, saving: false, id: matchId, data: null, remarkText: '' }
try {
const r = await getReturnMatch(matchId)
this.matchDetail.data = r.data
this.matchDetail.remarkText = r.data.match.remark || ''
} finally {
this.matchDetail.loading = false
}
if (focusRemark) this.$nextTick(() => this.$refs.matchRemarkInput?.focus())
},
async refreshMatchDetail() {
const r = await getReturnMatch(this.matchDetail.id)
this.matchDetail.data = r.data
this.matchDetail.remarkText = r.data.match.remark || ''
await this.load()
},
async saveMatchRemark() {
this.matchDetail.saving = true
try {
await remarkReturnMatch(this.matchDetail.id, { remark: this.matchDetail.remarkText })
ElMessage.success('已保存备注')
await this.refreshMatchDetail()
} finally {
this.matchDetail.saving = false
}
},
async confirmMatchDetail() {
try {
await this.$confirm('确认后该 SYB 商品状态变为「已用退货」,仍不能创建采购任务。确定继续?', '确认匹配', { type: 'warning' })
} catch { return }
await confirmReturnMatch(this.matchDetail.id)
ElMessage.success('已确认匹配')
await this.refreshMatchDetail()
},
async cancelMatchDetail() {
try {
await this.$confirm('取消后该 SYB 商品恢复可创建采购,对应退货商品回到可用池。确定取消匹配?', '取消匹配', { type: 'warning', confirmButtonClass: 'el-button--danger' })
} catch { return }
await cancelReturnMatch(this.matchDetail.id)
ElMessage.success('已取消匹配')
this.matchDetail.open = false
await this.load()
},
async quickCancelMatch(matchId) {
try {
await this.$confirm('取消后该 SYB 商品恢复可创建采购,对应退货商品回到可用池。确定取消匹配?', '取消匹配', { type: 'warning', confirmButtonClass: 'el-button--danger' })
} catch { return }
await cancelReturnMatch(matchId)
ElMessage.success('已取消匹配')
await this.load()
},
// ---------------- 批量创建采购任务 ----------------
async openSinglePurchase(row) {
this.$refs.productTable?.clearSelection()
@@ -608,7 +862,9 @@ export default {
<style lang="scss" scoped>
.page-card { min-height: calc(100vh - 124px); }
.page-heading { display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:16px;h1{margin:0 0 6px;font-size:24px;color:#1f2937}p{margin:0;color:#606266;line-height:1.5} }
.search-form{display:flex;flex-wrap:nowrap;align-items:center;column-gap:8px;padding:12px 16px 0;margin-bottom:16px;border:1px solid #e5e7eb;border-radius:8px;background:#f8fafc}
.search-form{display:flex;flex-direction:column;align-items:stretch;gap:0;padding:12px 16px 0;margin-bottom:16px;border:1px solid #e5e7eb;border-radius:8px;background:#f8fafc}
.search-row{display:flex;flex-wrap:wrap;align-items:center;column-gap:8px}
.search-row-secondary{justify-content:flex-start}
.search-form :deep(.el-form-item){margin-right:0;margin-bottom:12px}
.search-form :deep(.el-form-item__content){flex-wrap:nowrap}
.shop-input{width:130px}.order-codes-input{width:210px}.unlinked-tag{margin-left:8px}
@@ -625,9 +881,11 @@ export default {
.missing{color:#b91c1c}
.quick-link-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:4px 0}
.thumb{width:44px;height:44px;border-radius:4px;object-fit:cover}.thumb.placeholder{display:flex;align-items:center;justify-content:center;background:#f1f5f9;color:#909399;font-size:11px}
.return-match-cell{display:flex;gap:8px;align-items:flex-start}
.link{color:#1677ff;cursor:pointer}
.shopee-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.notice{margin-bottom:16px}
.return-missing-alert{margin:6px 0}
.compact-notice{margin-bottom:12px}
.purchase-summary{display:flex;flex-wrap:wrap;gap:12px 24px;margin-bottom:16px;padding:12px 16px;border:1px solid #dbeafe;border-radius:8px;background:#f8fafc}.purchase-summary strong{font-variant-numeric:tabular-nums;color:#1e40af}.success-text{color:#166534}.warning-text{color:#b45309}.danger-text,.purchase-reason{color:#b91c1c}.purchase-reason{margin-top:4px;font-size:12px;line-height:1.45}.purchase-settings{margin-bottom:12px}.field-help{margin-left:12px;color:#909399;font-size:12px}.ellipsis{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.image-search-tag{margin-left:6px;vertical-align:middle}
.spec-match-summary{gap:8px 24px}
@@ -638,7 +896,6 @@ export default {
.section-title{margin:0 0 12px}
.drawer-actions{display:flex;gap:12px;margin-top:16px}
.raw-json{max-height:520px;overflow:auto;padding:12px;background:#0f172a;color:#e2e8f0;border-radius:6px;font-size:12px;line-height:1.5;white-space:pre-wrap;word-break:break-all}
@media(max-width:1280px){.search-form{flex-wrap:wrap}}
@media(max-width:1280px){.search-row-primary{align-items:flex-start}.search-row-primary .toolbar-actions{margin-left:auto}}
@media(max-width:900px){.split{grid-template-columns:1fr}}
</style>
+1 -1
View File
@@ -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) }) },
+38 -2
View File
@@ -22,6 +22,11 @@
</el-select>
</el-form-item>
<el-form-item label="创建时间"><el-date-picker v-model="createTimeRange" type="daterange" value-format="YYYY-MM-DD" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" style="width:230px" /></el-form-item>
<el-form-item label="匹配状态">
<el-select v-model="query.matchStatus" clearable placeholder="全部" style="width:130px">
<el-option v-for="item in matchStatusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button></el-form-item>
</el-form>
@@ -61,9 +66,28 @@
<el-tag v-else :type="claimStatusMeta(row.claimStatus).type">{{ claimStatusMeta(row.claimStatus).label }}</el-tag>
</template>
</el-table-column>
<el-table-column label="yeeke 可用性" min-width="150">
<template #default="{ row }">
<el-tag v-if="row.syncStatus === 'missing'" type="danger" effect="plain">不可用(yeeke 列表中已不存在)</el-tag>
<span v-else class="muted">—</span>
</template>
</el-table-column>
<el-table-column label="上架时间" min-width="150"><template #default="{ row }">{{ formatTime(row.claimTime) }}</template></el-table-column>
<el-table-column label="销毁截止" min-width="150"><template #default="{ row }">{{ formatTime(row.destroyDeadLine) }}</template></el-table-column>
<el-table-column label="最近同步" min-width="150"><template #default="{ row }">{{ formatTime(row.lastSyncedAt) }}</template></el-table-column>
<el-table-column label="匹配状态" min-width="100">
<template #default="{ row }"><el-tag :type="matchStatusMeta(row.matchStatus).type" effect="plain">{{ matchStatusMeta(row.matchStatus).label }}</el-tag></template>
</el-table-column>
<el-table-column label="被占用 SYB 订单商品" min-width="180">
<template #default="{ row }">
<template v-if="row.occupyingSybProductId">
<a class="link" href="javascript:void(0)" @click="openSybProduct(row.occupyingSybProductId)">{{ row.occupyingSybOrderCode }} ↗</a>
<div class="muted">{{ row.occupyingSybStageLabel }}</div>
<div v-if="isDeadlinePassed(row.destroyDeadLine)" class="deadline-passed">已过销毁截止</div>
</template>
<span v-else class="muted">—</span>
</template>
</el-table-column>
<el-table-column label="操作" width="90" fixed="right"><template #default="{ row }"><el-button type="primary" link @click="openDetail(row)">详情</el-button></template></el-table-column>
</el-table>
<pagination v-show="total > 0" v-model:current-page="query.page" v-model:page-size="query.pageSize" :total="total" @pagination="load" />
@@ -139,8 +163,15 @@ export default {
loading: false, loadError: '', items: [], total: 0, createTimeRange: null,
summary: { lastSuccessAt: '', lastRun: null }, summaryError: '',
shopOptions: [],
query: { page: 1, pageSize: 20, orderSn: '', shop: '', claimStatus: '', createTimeFrom: '', createTimeTo: '' },
query: { page: 1, pageSize: 20, orderSn: '', shop: '', claimStatus: '', createTimeFrom: '', createTimeTo: '', matchStatus: '' },
detail: { open: false, loading: false, placeholder: false, item: null, pkg: null, siblings: [] },
// #338: 匹配状态是只读信息筛选,未勾选/无写操作,按钮门禁规则里
// 「纯只读筛选可以照常展示」的部分——不需要 canPurchase 判断。
matchStatusOptions: [
{ label: '未匹配', value: 'unmatched' },
{ label: '退货待确认', value: 'matched' },
{ label: '已用退货', value: 'confirmed' }
],
// yeeke's only confirmed claim-status value is the raw string "1"
// (HAR evidence, #336 requirement doc). Any other value the sync sees
// is preserved verbatim and flagged status_unrecognized — never
@@ -159,8 +190,12 @@ export default {
// unmapped value we have not seen yet — show it as-is rather than a
// bare digit or a guessed Chinese label.
claimStatusMeta(status) { return status === '1' ? { label: '已认领', type: 'success' } : { label: status ? `其他:${status}` : '—', type: 'info' } },
matchStatusMeta(status) { return { unmatched: { label: '未匹配', type: 'info' }, matched: { label: '退货待确认', type: 'warning' }, confirmed: { label: '已用退货', type: 'success' }}[status] || { label: status || '—', type: 'info' } },
openSybProduct(sybProductId) { this.$router.push({ path: '/syb-products/index', query: { sybProductId } }) },
statusMeta(status) { return { running: { label: '执行中', type: 'primary' }, succeeded: { label: '成功', type: 'success' }, failed: { label: '失败', type: 'danger' }, interrupted: { label: '已中断', type: 'warning' }}[status] || { label: status || '-', type: 'info' } },
formatTime(value) { if (!value) return '—'; return new Date(value).toLocaleString('zh-CN', { hour12: false }) },
// #338: a matched return past its destroy deadline is flagged only, never auto-released.
isDeadlinePassed(value) { return Boolean(value) && new Date(value).getTime() <= Date.now() },
async load() {
this.loading = true; this.loadError = ''
try {
@@ -202,7 +237,7 @@ export default {
},
reset() {
this.createTimeRange = null
this.query = { page: 1, pageSize: 20, orderSn: '', shop: '', claimStatus: '', createTimeFrom: '', createTimeTo: '' }
this.query = { page: 1, pageSize: 20, orderSn: '', shop: '', claimStatus: '', createTimeFrom: '', createTimeTo: '', matchStatus: '' }
this.load()
},
async openDetail(row) {
@@ -245,4 +280,5 @@ export default {
.item-title{font-size:15px;font-weight:600;color:#1f2937;margin-bottom:4px}
.muted{font-size:12px;color:#909399}
@media(max-width:800px){.page-heading{flex-direction:column}}
.deadline-passed{color:var(--el-color-danger);font-size:12px}
</style>
@@ -41,6 +41,8 @@
<el-table-column label="更新" prop="updatedCount" width="70" />
<el-table-column label="跳过" prop="skippedCount" width="70" />
<el-table-column label="失败" prop="failedCount" width="70" />
<el-table-column label="标记不可用" prop="missingMarkedCount" width="96" />
<el-table-column label="恢复可用" prop="recoveredCount" width="84" />
<el-table-column label="脱敏原因" min-width="200"><template #default="{ row }">{{ row.errorMessage || '—' }}</template></el-table-column>
</el-table>
</div>
@@ -69,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() },