diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md index 084769d..875db05 100644 --- a/docs/02-architecture-and-code-map.md +++ b/docs/02-architecture-and-code-map.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Architecture-and-Code-Map wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.- -wiki_revision: 1e87b8832cecd454e58f0ff49dbe05bbf8d61544 -synchronized_at: 2026-09-29T03:47:39Z +wiki_revision: 4bf8482e49509812b41ee678f40cf3c7c0cf5474 +synchronized_at: 2026-09-29T08:16:37Z @@ -578,3 +578,12 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态 - 原页面默认用户浏览器本地今天范围,工具栏“匹配 N”复用现有 rematch 和任务进度;只处理本页合格项,同日提交,原回写需独立确认。 实现绑定:`fix/350-inner-code-matching`,源码提交 `9c5e20e`;未合并 main、未部署,正式环境仍需发布后启用。 + +## SYB 过滤商品标记与规则重算(#340) + +- 数据:`syb_product` 新增 `pdd_purchase_excluded`(索引)、`excluded_rule_id`、`excluded_rule_kind`、`excluded_rule_keyword`、`excluded_at`;新表 `syb_product_filter_recompute_log`(操作人、两方向变更数、两类跳过数、时间)。迁移 `1789801500000_syb_pdd_purchase_excluded`,只新增,存量行默认需 PDD 采购。 +- 入库:`sybimport/sync.go applyStockDetail` 命中规则不再 `continue`,把规则快照随明细交给 `apply.go ApplyDetail`;仅新建分支写标记,更新分支保留原标记。 +- 阶段与隔离:`purchase/process_stage.go` 新增 `pdd_excluded`(「无需采购」);`purchase/return_match_block.go rejectIfPDDExcluded`(`service.create`,覆盖单条/批量)、`batch.go` 预检、`collection_eligibility.go`、`ai_match_eligibility.go`、`task/image_search.go` 统一拒绝;`returnmatch` 的参与阶段包含 `pdd_excluded`。 +- 列表:`sybimport/service.go List` 的 `purchaseType`(`pdd` 默认 / `excluded` / `all`),与 `processStage`、`createdFrom/To` 为 AND。 +- 重算:`sybproductfilter/recompute.go`——`recomputeChanges` 规划(只读所需列);`recomputeFingerprint` 对排序后的(id、方向、规则 ID、类型、关键词)JSON 做 sha256;`writeRecomputeChanges` 逐行 `FOR UPDATE` 锁 `syb_product`,并以 `FOR SHARE` 加锁读复查 `purchase_task` / `return_match`(MySQL REPEATABLE-READ 下普通读看不到快照后的提交)。`recompute_mysql_integration_test.go` 在设置 `GOAUTO_IT_MYSQL_DSN` 时于临时库做双连接并发验证。 +- Web:`syb-products/index.vue`(采购类型筛选、阶段自动切换、规则显示、按钮计数)、`syb-product-filters/index.vue`(命中/标记口径、重算预览与执行)、`syb-sync-runs/index.vue`(「其中本次规则命中 N 条」)。 diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index 9c4fdab..d58090c 100644 --- a/docs/03-business-rules-and-glossary.md +++ b/docs/03-business-rules-and-glossary.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Business-Rules-and-Glossary wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.- -wiki_revision: 32c6b496de47b54ca04e081271114c28ff439e0c -synchronized_at: 2026-09-29T06:57:51Z +wiki_revision: c0b1a842c79b4d1dba4c4435df106349e65e5eb1 +synchronized_at: 2026-09-29T08:16:48Z @@ -75,6 +75,7 @@ synchronized_at: 2026-09-22T02:56:27Z - SYB 商品页保留“解析状态”,另按 SYB、蝦皮、PDD、采集任务和采购任务的当前事实动态派生唯一“处理阶段”;不在 `syb_product` 保存容易过期的阶段字段。 - 对采购人员展示的阶段固定为:待人工处理、未关联 PDD、PDD 待采集、PDD 采集中、PDD 采集失败、规格待匹配、可创建采购、已创建任务、采购成功、待人工核对。 - 主阶段优先级为:待人工核对 → 采购成功 → 已创建任务 → 待人工处理 → 未关联 PDD → PDD 待采集/采集中/采集失败 → 规格待匹配 → 可创建采购。每行只显示一个阶段和对应下一步。 +- #338/#340 后在「已创建任务」之后、「待人工处理」之前依次插入:有效退货匹配(退货待确认 / 已用退货)→ 无需采购(`pdd_purchase_excluded`)。即进行中、已成功及结果不明的采购任务仍优先显示;失败/取消的旧任务不占主阶段时,标记为无需 PDD 采购的行显示「无需采购」。 - “待人工核对”表示订单结果不明确,必须先人工核查并禁止自动重试;“采购成功”表示已取得 PDD 订单号和下单时间,不代表已经支付。 - 一个 PDD 订单号只能属于一个采购任务,该唯一性在采购任务保存路径上全局强制(#241)。人工处理结果未知、取消及 lifecycle 保存路径撞号时返回 `PURCHASE_ORDER_NUMBER_ALREADY_USED`,提示订单号已属于哪个任务,由采购员人工核对,不静默覆盖原值。 - 不可逆边界之后的 `order_created` 结果回传是上述规则的例外:此时 PDD 真单已创建,发现订单号已属于其他任务时不回滚、不判失败,而是把任务降级为 `order_result_unknown`,冲突订单号以「读到订单号 X,但该号已属于任务 CG-yy」保存在任务与 attempt 的 `error_message`,`pdd_order_no` 留空以维持唯一性,保留下单时间与不可逆时间,进入既有人工处理结果未知通道。首要目标是保住「真单已存在」这一事实,不制造无记录的真实订单。 @@ -749,3 +750,13 @@ SYB 回填与资料完整性独立:正式SYB任务已有单号且仍关联有 ### 档口入库码混选回写范围(#351) 列表“回写 N”仅统计当前页勾选且 ready 的唯一记录,混选其他状态不阻塞合法项。打开确认弹窗时冻结本次选中数量及合格 ID;只预览、确认提交这些 ID,不随列表刷新或勾选变化扩大范围。弹窗明确显示选中/可回写/跳过数量,入库码、占位及替换旧码指标仅针对待提交记录。 预览发现候选状态变化时剔除 blocked 项,最多再进行一次只读预览以刷新指标;无合格项、预览失败/范围不一致或再次变化时禁用确认,提示重新预览。确认提交中防重复,未知提交结果不自动重试,沿用批次查询与只读复核。服务端原 ready 状态、唯一匹配计划及执行校验不变;仅用户人工确认后才进入回写,不自动写入。 + +## SYB 过滤商品入库与「无需采购」(#340) + +- 命中 SYB 过滤规则(结构 `-#` 或关键词)的订单明细不再跳过,而是入库并标记「无需 PDD 采购」,同时保存命中规则快照;店铺过滤不变。 +- 标记只在明细首次入库时确定;重复同步不改标记,规则启停或删除也不自动改变已有标记。历史被跳过的明细不自动补同步。 +- 带标记的商品**硬性隔离所有 PDD 流程**:创建采购(单条、批量)、采购预检可采购结果、创建采集资格、AI 规格匹配、图搜采集均由服务端拒绝或判为不可用,错误码 `PURCHASE_PDD_EXCLUDED`;前端隐藏只是辅助。 +- 带标记的商品可参与退货匹配:匹配后「退货待确认」,确认后「已用退货」,取消后回到「无需采购」。 +- SYB 商品页新增「采购类型」筛选:需 PDD 采购(默认、重置后同)/ 无需 PDD 采购 / 全部,与处理阶段、创建时间等筛选同时生效;选择「退货待确认」「已用退货」「无需采购」阶段时自动切到「全部」,可手动改回。无需采购行显示命中规则,可勾选但只计入「匹配退货」,不显示单条「采购」。 +- 管理员「按当前规则重算」:先预览(两个方向的变更数、因已有采购任务 / 有效退货匹配跳过的数量、最多 20 条样例)再确认执行。执行必须携带预览指纹,数据、规则或其证据变化时拒绝并提示重新预览,不静默执行另一批。执行时逐行加锁并以加锁读复查:该商品有任何采购任务或有效退货匹配则跳过;审计日志与返回数量按实际写入/跳过计。规则变更从不自动重算。 +- 统计口径:同步记录与规则列表的「命中」是本次同步命中规则的次数(含已存在明细);「当前带此规则标记的明细 N 条」按数据库实际统计。两者不得混用。 diff --git a/docs/08-agent-api-contract.md b/docs/08-agent-api-contract.md index 47d1d0f..a64fdbf 100644 --- a/docs/08-agent-api-contract.md +++ b/docs/08-agent-api-contract.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Android-Agent-API-Contract wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.- -wiki_revision: da26af9e01087c4970b01783a4807c689fd79d51 -synchronized_at: 2026-09-29T03:47:57Z +wiki_revision: 92e9f8568c9c0da1d78159391c1545f09c4cec14 +synchronized_at: 2026-09-29T08:17:45Z @@ -87,7 +87,7 @@ PATCH /api/admin/v1/syb-products/{productId}/correction `sync-runs` 列表支持 `page`、`pageSize`、`status`、`dateFrom`、`dateTo`,详情返回日期范围、状态(`running` / `succeeded` / `partial_success` / `failed` / `interrupted`)、处理天数、货运单/明细/新增/覆盖数量、店铺准入与跳过数量、店铺筛选快照哈希、按店铺的 `accepted` / `skipped` 统计、操作人和起止时间。列表和详情对已登录角色只读开放。服务启动时遗留的 `running` 任务改为 `interrupted`;中途失败或中断已经写入的数据保留,重新导入仍按唯一键覆盖。 -列表返回结构化字段(`orderCode`、`shopeeItemId`、`productTitle`、`targetColor`、`targetSize`、`quantity`、`unitPriceCent`、`imageUrl`、`parseStatus`、`parseNote`、`manuallyConfirmed`),不含原始 JSON;`keyword` 匹配订单号、虾皮商品ID 或商品标题,`parseStatus` 筛选 `success`/`uncertain`/`failed`。详情额外返回 `rawJson`(原始 `details[]` 元素,未做任何改写)。 +列表返回结构化字段(`orderCode`、`shopeeItemId`、`productTitle`、`targetColor`、`targetSize`、`quantity`、`unitPriceCent`、`imageUrl`、`parseStatus`、`parseNote`、`manuallyConfirmed`,#340 起另有 `pddPurchaseExcluded`、`excludedRuleId`、`excludedRuleKind`、`excludedRuleKeyword`、`excludedAt`),不含原始 JSON;`purchaseType`(#340)取 `pdd`(默认)/ `excluded` / `all`,其他值 422,与 `processStage`、`createdFrom`/`createdTo` 同时生效;`keyword` 匹配订单号、虾皮商品ID 或商品标题,`parseStatus` 筛选 `success`/`uncertain`/`failed`。详情额外返回 `rawJson`(原始 `details[]` 元素,未做任何改写)。 `reparse` 与 `reparse-batch` 只读取已保存的 `rawJson` 重新执行解析规则,不请求 SYB 接口;请求体 `force` 为 `true` 时才覆盖 `manuallyConfirmed` 的行,默认跳过并在批量结果中标记 `skipped_manual`。批量结果逐条返回 `outcome`(`reparsed` / `skipped_manual` / `unchanged`)与解析状态变化,`ids` 中任何一个不存在都会使整个请求返回错误(与虾皮商品批量删除的"部分成功"语义不同:ID 不存在通常是操作员选错了页面)。 @@ -115,9 +115,11 @@ GET /api/admin/v1/syb-product-filters POST /api/admin/v1/syb-product-filters PATCH /api/admin/v1/syb-product-filters/{id}/enabled DELETE /api/admin/v1/syb-product-filters/{id} +GET /api/admin/v1/syb-product-filters/recompute/preview +POST /api/admin/v1/syb-product-filters/recompute/execute ``` -同步时按 `variationSku` 决定明细是否入库。规则分两类,由 `kind` 区分:`char` 是结构判据 +同步时按 `variationSku` 判定明细是否「无需 PDD 采购」(#340 起命中者照常入库并打标记,不再跳过)。规则分两类,由 `kind` 区分:`char` 是结构判据 (`-`、`#`),`keyword` 是关键词清单。列表支持 `kind`、`keyword` 和分页,返回 `keyword`、 `normalizedKeyword`、`enabled`、`lastHitCount`、`lastHitAt`、`note`,`char` 另有 `lastChangedBy`、`lastChangedAt`。 @@ -126,9 +128,20 @@ DELETE /api/admin/v1/syb-product-filters/{id} `FORBIDDEN`(`结构过滤不可新增` / `结构过滤不可删除`),不得只依赖前端隐藏按钮。 `kind=char` 的 `PATCH enabled` 允许,并记录操作人与时间。 -`[必须]` `lastHitCount` 是每条规则各自的命中数,由同步结束时回写;未命中的规则写 0, -从未同步过为 `null`。停用 `kind=char` 的二次确认提示条数必须取该行实时的 `lastHitCount`, -不得写死——写死的数字在数据变化后会给出错误警告。 +`[必须]` `lastHitCount` 是每条规则各自在上次同步中的**命中次数**(新建与已存在明细都计), +由同步结束时回写;未命中的规则写 0,从未同步过为 `null`。页面称「上次同步命中」。 +列表另返回 `markedCount`(#340):查询时按数据库实际统计的「当前 `pdd_purchase_excluded=true` +且 `excluded_rule_id` 为该规则」的明细数,一次分组查询得出。停用 `kind=char` 的二次确认提示 +条数必须取实时的 `markedCount`,不得写死,也不得用 `lastHitCount` 冒充已标记数量。 + +`recompute/preview`、`recompute/execute` 仅管理员可用(其他角色 403)。预览只读,返回 +`excludedToPdd`、`pddToExcluded`、`skippedHasTask`、`skippedReturnMatch`、最多 20 条 +`samples`(`orderCode`、`shopeeItemId`、`direction`=`excluded_to_pdd`/`pdd_to_excluded`、 +`ruleKind`、`ruleKeyword`)及 `fingerprint`。执行请求体 `{"fingerprint": "..."}` 必填(空值 422); +服务端在事务内重新规划并比对指纹(涵盖商品、方向、规则 ID、类型、关键词),不一致返回 +`409 RECOMPUTE_PREVIEW_STALE`(「数据或规则已变化,请重新预览后再执行」)且不写入。 +执行逐行加锁复查,有采购任务或有效退货匹配的商品跳过;返回实际写入/跳过数量与 `operator`, +并写 `syb_product_filter_recompute_log`。 关键词归一化与店铺一致:去首尾空白、全角/半角统一、忽略大小写;**不做简繁转换**,SYB 数据 为繁体。唯一性按 (`kind`, `normalizedKeyword`) 判定,重复添加返回 diff --git a/docs/12-syb-erp-interface.md b/docs/12-syb-erp-interface.md index b1a7077..bbec2e5 100644 --- a/docs/12-syb-erp-interface.md +++ b/docs/12-syb-erp-interface.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: SYB-ERP-Interface-Contract wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/SYB-ERP-Interface-Contract.- -wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9 -synchronized_at: 2026-09-21T08:15:30Z +wiki_revision: 5979a14250c96c3c92348a947c2bc8d3a078c9f4 +synchronized_at: 2026-09-29T08:18:40Z # 12 顺云宝(SYB)ERP 接口契约 @@ -496,12 +496,14 @@ POST /am/stock/detail/updateDetailCode?t=0&id={stockID}&detailId={detailID}&code 店铺过滤只决定明细获取和入库,不能减少原始列表完整性校验范围;已保存不代表整日完整。 `[必须]` **店铺准入之后再按 `variationSku` 过滤商品(#269)。** 顺序固定为:店铺准入 → -结构过滤 → 关键词过滤 → 入库。两类规则都存在 `syb_product_filter`,由 `kind` 区分: +结构过滤 → 关键词过滤 → 入库。两类规则都存在 `syb_product_filter`,由 `kind` 区分。 +自 #340 起命中规则的明细**同样入库**,只在 `syb_product` 上标记「无需 PDD 采购」,不再跳过(见本节末尾): -- `kind=char`:`-` 和 `#` 两条,命中任意一条即跳过(**OR,不是 AND**)。这两个字符是 - 「档口-供应商#货号」编码格式的判据。2026-09-11 核验线上 13010 行明细:含 `#` 8498 行、 - 含 `-` 6392 行、两者都含 6386 行、任一 8504 行(65%)。用 AND 只命中 6386 行,会漏掉 - `DD#004`、`300斤牛奶絲圓領#A057` 这类只含 `#` 的 2118 行。 +- `kind=char`:当前只有一条结构规则 `-#`,`variationSku` 须**同时包含** `-` 和 `#` 才命中 + (#286 起合并为一条、要求全部字符;代码见 `sybproductfilter.RuleSet.Match` 的 `containsAllRunes`)。 + 这是「档口-供应商#货号」编码格式的判据。`[历史]` #269 初版曾是 `-`、`#` 两条、任一命中 + (2026-09-11 核验线上 13010 行:含 `#` 8498、含 `-` 6392、两者都含 6386);#286 改为 + 同时包含,只含 `#` 的编码(如 `DD#004`)不再命中结构规则。 - `kind=keyword`:关键词清单,匹配 `variationSku`,**不匹配 `productTitle`**。同一次核验 中六条初始关键词在 `productTitle` 上命中为 0,且全部已被结构过滤覆盖,净增为 0;保留它 是为档口改用不含 `#` 的编码时兜底。 @@ -516,6 +518,15 @@ POST /am/stock/detail/updateDetailCode?t=0&id={stockID}&detailId={detailID}&code `[必须]` `kind=char` 不可新增、不可删除,只能停用,且停用需要二次确认并记录操作人与时间。 判据本身需要变更(例如档口改用 `/` 或 `@`)属于范围变化,应另建工单评估。 +`[必须]` **命中规则 = 标记无需 PDD 采购,不再跳过入库(#340)。** 启用店铺内命中结构或关键词规则的 +明细照常写入 `syb_product`,并记录 `pdd_purchase_excluded=true` 与命中规则快照 +(`excluded_rule_id`、`excluded_rule_kind`、`excluded_rule_keyword`、`excluded_at`)。 +标记**只在该明细首次创建时**按当次启用规则确定;之后重复同步同一明细不改变标记, +规则启停或删除也不会自动改变已有标记,只能由管理员「按当前规则重算」变更。 +历史上被跳过的明细不自动补同步;需要时按日期手动导入即可补入并打标记。 +`char_filter_skipped` / `keyword_filter_skipped` 与每条规则的 `last_hit_count` 仍是**本次命中次数** +(新建与已存在明细都计),不是「本次被标记的数量」,页面文案须称「命中」。 + `[必须]` 同步开始时只读取一次启用店铺,整次运行使用同一个快照。列表允许但明细 响应中的 `shopName` 变为空或非允许店铺时再次拦截。没有启用店铺时在会话/OCR/ 验证码等任何顺运宝请求之前停止,并且不推进覆盖游标。该过滤只影响后续入库, diff --git a/server/app/goauto/migrations/migrate.go b/server/app/goauto/migrations/migrate.go index 62c583b..b60fb83 100644 --- a/server/app/goauto/migrations/migrate.go +++ b/server/app/goauto/migrations/migrate.go @@ -44,6 +44,7 @@ func MigratedModels() []any { &models.SYBSessionAuthLease{}, &models.SYBShop{}, &models.SYBProductFilter{}, + &models.SYBProductFilterRecomputeLog{}, &models.SYBSyncRun{}, &models.YeekeSession{}, &models.YeekeReturnPackage{}, diff --git a/server/app/goauto/models/schema.go b/server/app/goauto/models/schema.go index 10861b8..e63b179 100644 --- a/server/app/goauto/models/schema.go +++ b/server/app/goauto/models/schema.go @@ -579,12 +579,45 @@ type SYBProduct struct { // parse-rule change, only the derived fields above may change. RawJSON string `json:"-" gorm:"type:json;not null"` + // PDDExcluded marks that this row hit an enabled product filter + // rule (char/keyword) at the moment it was first created by the sync + // (#340). It replaces the old "skip on filter hit" behaviour: a filtered + // row is still stored, but every PDD purchase/collection/AI-match entry + // point must hard-reject it. The mark is decided once, at creation time + // (sybimport.applyStockDetail / ApplyDetail); a later sync of the SAME + // existing row never changes it, even if the enabled rules changed in + // the meantime — only the admin "按当前规则重新计算" action + // (sybproductfilter recompute) may flip it, and only for rows with no + // purchase task and no active return match. + PDDExcluded bool `json:"pddPurchaseExcluded" gorm:"column:pdd_purchase_excluded;not null;default:false;index"` + // ExcludedRuleID/Kind/Keyword are a snapshot of the rule that matched at + // mark time, kept even if the rule is later edited or deleted, so the + // mark stays explainable in the UI and in return matching (#340). + ExcludedRuleID *uint64 `json:"excludedRuleId,omitempty"` + ExcludedRuleKind string `json:"excludedRuleKind,omitempty" gorm:"size:16;not null;default:''"` + ExcludedRuleKeyword string `json:"excludedRuleKeyword,omitempty" gorm:"size:200;not null;default:''"` + ExcludedAt *time.Time `json:"excludedAt,omitempty"` + CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } func (SYBProduct) TableName() string { return "syb_product" } +// SYBProductFilterRecomputeLog audits the admin-only "按当前规则重新计算" +// action (#340 decision 7). Every execute run writes exactly one row here. +type SYBProductFilterRecomputeLog struct { + ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` + Operator string `json:"operator" gorm:"size:128;not null;default:''"` + ExcludedToPDD int `json:"excludedToPdd" gorm:"not null;default:0"` + PDDToExcluded int `json:"pddToExcluded" gorm:"not null;default:0"` + SkippedHasTask int `json:"skippedHasTask" gorm:"not null;default:0"` + SkippedReturnMatch int `json:"skippedReturnMatch" gorm:"not null;default:0"` + CreatedAt time.Time `json:"createdAt"` +} + +func (SYBProductFilterRecomputeLog) TableName() string { return "syb_product_filter_recompute_log" } + func (SYBSession) TableName() string { return "syb_session" } // SYBShop is the list of SYB shops whose shipment orders are imported (#49). diff --git a/server/app/goauto/purchase/ai_match_eligibility.go b/server/app/goauto/purchase/ai_match_eligibility.go index add170d..3f08971 100644 --- a/server/app/goauto/purchase/ai_match_eligibility.go +++ b/server/app/goauto/purchase/ai_match_eligibility.go @@ -131,6 +131,9 @@ func aiMatchQualificationForDataset(id uint64, dataset batchPreviewDataset) aiMa if !found { return disabled("SYB 商品不存在或已删除") } + if syb.PDDExcluded { + return disabled("该商品已标记为无需 PDD 采购") + } if strings.TrimSpace(syb.TargetColor) == "" && strings.TrimSpace(syb.TargetSize) == "" { return disabled("未解析出需要采购的颜色或尺码") } diff --git a/server/app/goauto/purchase/batch.go b/server/app/goauto/purchase/batch.go index 67f631d..f377041 100644 --- a/server/app/goauto/purchase/batch.go +++ b/server/app/goauto/purchase/batch.go @@ -399,6 +399,14 @@ func (s *Service) previewFromDataset(id uint64, dataset batchPreviewDataset, gua } item.OrderCode, item.ShopeeProductID, item.ShopeeItemID = syb.OrderCode, syb.ShopeeProductID, syb.ShopeeItemID item.ProductTitle, item.TargetColor, item.TargetSize, item.Quantity = syb.ProductTitle, syb.TargetColor, syb.TargetSize, syb.Quantity + // #340: hard-exclude before anything else in the pipeline. This preview + // is the single computation shared by BatchPreview, BatchCreate's + // pre-check and quick-replace's ValidateQuickReplacement, so this one + // check covers all of them. + if syb.PDDExcluded { + item.ReasonCode, item.Reason, item.NextAction = CodePDDExcluded, "该商品已标记为无需 PDD 采购", "" + return item + } if !sybSpecsTrusted(syb) { item.ReasonCode, item.NextAction = "SYB_PARSE_FAILED", "reparse" if syb.ParseStatus == models.SYBParseStatusUncertain { diff --git a/server/app/goauto/purchase/collection_eligibility.go b/server/app/goauto/purchase/collection_eligibility.go index fe270f8..6c53b87 100644 --- a/server/app/goauto/purchase/collection_eligibility.go +++ b/server/app/goauto/purchase/collection_eligibility.go @@ -9,6 +9,10 @@ func (item *BatchPreviewItem) applyCollectionEligibility(id uint64, dataset batc item.CollectionDisabledReason = "SYB 商品不存在或已删除" return } + if syb.PDDExcluded { + item.CollectionDisabledReason = "该商品已标记为无需 PDD 采购" + return + } if syb.ShopeeProductID == nil { item.CollectionDisabledReason = "尚未关联蝦皮商品" return diff --git a/server/app/goauto/purchase/pdd_excluded_block_test.go b/server/app/goauto/purchase/pdd_excluded_block_test.go new file mode 100644 index 0000000..fa82fc0 --- /dev/null +++ b/server/app/goauto/purchase/pdd_excluded_block_test.go @@ -0,0 +1,152 @@ +package purchase + +import ( + "context" + "testing" + "time" + + "go-admin/app/goauto/models" + + "github.com/google/uuid" +) + +// #340: a SYB product marked pdd_purchase_excluded must be hard-rejected by +// every PDD entry point, mirroring #338's return-match block tests above. + +func TestCreate_RejectsWhenPDDExcluded(t *testing.T) { + db := testDB(t) + s := testService(db) + f := seed(t, db, liveCaps(), true) + if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID). + Update("pdd_purchase_excluded", true).Error; err != nil { + t.Fatal(err) + } + + _, err := createLive(t, s, f) + if err == nil { + t.Fatalf("expected rejection, got success") + } + if se, ok := asServiceError(err); !ok || se.Code != CodePDDExcluded { + t.Fatalf("expected CodePDDExcluded, got %v", err) + } +} + +func TestBatchPreview_ExcludedRowReportsNotEligible(t *testing.T) { + db := testDB(t) + s := testService(db) + f := seed(t, db, liveCaps(), true) + setCollectedPDDPrice(t, db, f.pdd.ID) + if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID). + Update("pdd_purchase_excluded", true).Error; err != nil { + t.Fatal(err) + } + + resp, err := s.BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{f.syb.ID}, DeviceID: &f.device.ID}) + if err != nil { + t.Fatalf("preview call itself must not fail: %v", err) + } + if len(resp.Items) != 1 { + t.Fatalf("expected 1 item, got %d", len(resp.Items)) + } + item := resp.Items[0] + if item.Eligible { + t.Fatalf("excluded row must not be eligible: %+v", item) + } + if item.CollectionEligible { + t.Fatalf("excluded row must not be collection-eligible: %+v", item) + } + if item.AIMatchEligible { + t.Fatalf("excluded row must not be AI-match-eligible: %+v", item) + } + if item.ReasonCode != CodePDDExcluded { + t.Fatalf("expected CodePDDExcluded reason, got %+v", item) + } + if item.ProcessStage != ProcessStagePDDExcluded { + t.Fatalf("expected pdd_excluded stage, got %+v", item) + } +} + +func TestBatchCreate_SkipsExcludedRowNotWholeBatch(t *testing.T) { + db := testDB(t) + s := testService(db) + f1 := seed(t, db, liveCaps(), true) + setCollectedPDDPrice(t, db, f1.pdd.ID) + + syb2 := models.SYBProduct{OrderCode: "SYB-EXCL-2", DetailID: 2, StockID: 3, ShopeeItemID: f1.shopee.ShopeeItemID, ShopeeProductID: &f1.shopee.ID, ProductTitle: f1.shopee.Title, TargetColor: "黑色", TargetSize: "XL", Quantity: 1, UnitPriceCent: 2000, ImageURL: "", ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{}`} + if err := db.Create(&syb2).Error; err != nil { + t.Fatal(err) + } + if err := db.Model(&models.SYBProduct{}).Where("id = ?", f1.syb.ID). + Update("pdd_purchase_excluded", true).Error; err != nil { + t.Fatal(err) + } + + resp, err := s.BatchCreate(context.Background(), BatchCreateRequest{ + RequestID: uuid.NewString(), SYBProductIDs: []uint64{f1.syb.ID, syb2.ID}, DeviceID: &f1.device.ID, + }) + if err != nil { + t.Fatalf("batch call itself must not fail: %v", err) + } + if resp.CreatedCount != 1 || resp.FailedCount != 1 { + t.Fatalf("expected 1 created + 1 failed, got created=%d failed=%d items=%+v", resp.CreatedCount, resp.FailedCount, resp.Items) + } + for _, item := range resp.Items { + if item.SYBProductID == f1.syb.ID && item.Created { + t.Fatalf("excluded row must not be created: %+v", item) + } + if item.SYBProductID == syb2.ID && !item.Created { + t.Fatalf("clean row must still be created: %+v", item) + } + } +} + +// TestProcessStage_ExcludedRowStage covers priority: active return match wins +// over the exclusion mark; once the match is cancelled the stage falls back +// to pdd_excluded (#340 decision 3). +func TestProcessStage_ExcludedRowStagePriority(t *testing.T) { + db := testDB(t) + s := testService(db) + f := seed(t, db, liveCaps(), true) + if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID). + Update("pdd_purchase_excluded", true).Error; err != nil { + t.Fatal(err) + } + + stages, err := s.ProcessStages(context.Background(), []uint64{f.syb.ID}) + if err != nil { + t.Fatal(err) + } + if stages[f.syb.ID].Stage != ProcessStagePDDExcluded { + t.Fatalf("expected pdd_excluded, got %+v", stages[f.syb.ID]) + } + + match := models.ReturnMatch{ + SYBProductID: f.syb.ID, YeekeReturnItemID: 1, + ActiveSYBProductID: &f.syb.ID, Status: models.ReturnMatchStatusMatched, + MatchedAt: time.Now(), + } + if err := db.Create(&match).Error; err != nil { + t.Fatal(err) + } + stages, err = s.ProcessStages(context.Background(), []uint64{f.syb.ID}) + if err != nil { + t.Fatal(err) + } + if stages[f.syb.ID].Stage != ProcessStageReturnPending { + t.Fatalf("active return match must win over exclusion mark, got %+v", stages[f.syb.ID]) + } + + // Cancel: no more active match -> falls back to pdd_excluded, not the + // normal pipeline. + if err := db.Model(&models.ReturnMatch{}).Where("id = ?", match.ID). + Updates(map[string]any{"active_syb_product_id": nil, "status": models.ReturnMatchStatusCancelled}).Error; err != nil { + t.Fatal(err) + } + stages, err = s.ProcessStages(context.Background(), []uint64{f.syb.ID}) + if err != nil { + t.Fatal(err) + } + if stages[f.syb.ID].Stage != ProcessStagePDDExcluded { + t.Fatalf("after cancel, stage should return to pdd_excluded, got %+v", stages[f.syb.ID]) + } +} diff --git a/server/app/goauto/purchase/process_stage.go b/server/app/goauto/purchase/process_stage.go index bcd668f..4f6713e 100644 --- a/server/app/goauto/purchase/process_stage.go +++ b/server/app/goauto/purchase/process_stage.go @@ -27,7 +27,12 @@ const ( // to (see rejectIfActiveReturnMatch and processStageFromDataset below). ProcessStageReturnPending = "return_pending" ProcessStageReturnUsed = "return_used" - processActionOpenPDDLink = "open_pdd_link" + // ProcessStagePDDExcluded is #340's stage for a SYB product marked + // pdd_purchase_excluded: it never needs a PDD purchase. It only yields to + // an active return match (checked first, same priority order #338 uses + // for the pipeline below it) — see processStageFromDataset. + ProcessStagePDDExcluded = "pdd_excluded" + processActionOpenPDDLink = "open_pdd_link" ) var processStageLabels = map[string]string{ @@ -43,6 +48,7 @@ var processStageLabels = map[string]string{ ProcessStageOrderReview: "待人工核对", ProcessStageReturnPending: "退货待确认", ProcessStageReturnUsed: "已用退货", + ProcessStagePDDExcluded: "无需采购", } type ProcessStageResult struct { @@ -151,6 +157,13 @@ func processStageFromDataset(id uint64, dataset batchPreviewDataset, preview Bat return stage(ProcessStageReturnPending, "已匹配退货待人工确认,暂不能创建采购任务", "open_return_match") } + // #340: an excluded row's mark also only overrides after the active-task + // and active-return-match checks above, and before every other pipeline + // branch below. + if syb, ok := dataset.sybByID[id]; ok && syb.PDDExcluded { + return stage(ProcessStagePDDExcluded, "顺云宝导入时已按过滤规则标记为无需 PDD 采购", "") + } + syb, ok := dataset.sybByID[id] if !ok { return stage(ProcessStageManualAction, "SYB 商品不存在或已删除", "refresh") diff --git a/server/app/goauto/purchase/return_match_block.go b/server/app/goauto/purchase/return_match_block.go index 812375a..f122106 100644 --- a/server/app/goauto/purchase/return_match_block.go +++ b/server/app/goauto/purchase/return_match_block.go @@ -28,3 +28,14 @@ func rejectIfActiveReturnMatch(tx *gorm.DB, sybProductID uint64) error { } return internal(err) } + +// rejectIfPDDExcluded implements #340's hard purchase-creation block: a SYB +// product marked pdd_purchase_excluded must never get a purchase task, +// single or batch (BatchCreate calls s.Create per row, so this one check +// point covers both, mirroring rejectIfActiveReturnMatch above). +func rejectIfPDDExcluded(syb models.SYBProduct) error { + if syb.PDDExcluded { + return fail(CodePDDExcluded, "该商品已标记为无需 PDD 采购,不能创建采购任务") + } + return nil +} diff --git a/server/app/goauto/purchase/service.go b/server/app/goauto/purchase/service.go index fb25908..6591b8c 100644 --- a/server/app/goauto/purchase/service.go +++ b/server/app/goauto/purchase/service.go @@ -181,6 +181,9 @@ func (s *Service) create(ctx context.Context, req CreateRequest) (models.Purchas if err := rejectIfActiveReturnMatch(tx, syb.ID); err != nil { return err } + if err := rejectIfPDDExcluded(syb); err != nil { + return err + } if syb.ShopeeProductID == nil { return fail(CodeInvalidRequest, "该商品尚未关联蝦皮商品") } diff --git a/server/app/goauto/purchase/types.go b/server/app/goauto/purchase/types.go index 332e71e..a452ced 100644 --- a/server/app/goauto/purchase/types.go +++ b/server/app/goauto/purchase/types.go @@ -41,7 +41,14 @@ const ( // tasks for such a product until the match is cancelled. Resuming or // reparsing an existing task is not affected. CodeReturnMatched = "PURCHASE_RETURN_MATCHED" - CodeInternal = "INTERNAL_ERROR" + // CodePDDExcluded is returned by every PDD entry point (purchase create, + // collection task creation, AI spec match, image search collection, + // quick-replace, ...) when the SYB product is marked + // pdd_purchase_excluded (#340): it hit a product filter rule at import + // time and is recorded as 「无需 PDD 采购」, so it must never start any + // PDD flow, regardless of the rest of its pipeline state. + CodePDDExcluded = "PURCHASE_PDD_EXCLUDED" + CodeInternal = "INTERNAL_ERROR" ) type ServiceError struct { diff --git a/server/app/goauto/returnmatch/pdd_excluded_test.go b/server/app/goauto/returnmatch/pdd_excluded_test.go new file mode 100644 index 0000000..a553d73 --- /dev/null +++ b/server/app/goauto/returnmatch/pdd_excluded_test.go @@ -0,0 +1,33 @@ +package returnmatch + +import ( + "context" + "testing" + "time" + + "go-admin/app/goauto/models" +) + +// #340: a pdd_purchase_excluded SYB product still needs no PDD purchase but +// must remain eligible to take part in return matching. +func TestBatchMatch_ExcludedStageParticipates(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-EXCL-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)) + if err := db.Model(&models.SYBProduct{}).Where("id = ?", syb.ID). + Update("pdd_purchase_excluded", true).Error; err != nil { + t.Fatal(err) + } + seedReturn(t, db, "白色,L【建議65-75公斤】", &deadline) + + resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}, Operator: "tester"}) + if err != nil { + t.Fatal(err) + } + if resp.MatchedCount != 1 || len(resp.Items) != 1 || !resp.Items[0].Matched { + t.Fatalf("excluded-stage row must still be eligible for return matching: %+v", resp) + } +} diff --git a/server/app/goauto/returnmatch/service.go b/server/app/goauto/returnmatch/service.go index 7ff8fca..9300269 100644 --- a/server/app/goauto/returnmatch/service.go +++ b/server/app/goauto/returnmatch/service.go @@ -29,6 +29,12 @@ var participatingStages = map[string]bool{ purchase.ProcessStagePDDCollectionFail: true, purchase.ProcessStageColorMapping: true, purchase.ProcessStagePurchaseReady: true, + // #340: a pdd_purchase_excluded row still needs no PDD purchase and can + // still take part in return matching — matching it just confirms there is + // nothing left to buy back. After a cancel it returns to pdd_excluded, + // which stays true here (see processStageFromDataset), so the row is + // eligible again the same way any other participating stage is. + purchase.ProcessStagePDDExcluded: true, } type Service struct { diff --git a/server/app/goauto/sybimport/apply.go b/server/app/goauto/sybimport/apply.go index 7acf7ab..11d0168 100644 --- a/server/app/goauto/sybimport/apply.go +++ b/server/app/goauto/sybimport/apply.go @@ -8,6 +8,7 @@ import ( "math" "strconv" "strings" + "time" "go-admin/app/goauto/models" "go-admin/app/goauto/shopeeproduct" @@ -41,6 +42,18 @@ type DetailInput struct { ProductTitle string ProductThumb uint64 Raw json.RawMessage + + // Excluded and the ExcludedRule* fields are #340's product-filter mark: + // Excluded reports whether this line hit an enabled filter rule during + // THIS sync's matching, and the ExcludedRule* fields are a snapshot of + // that rule. They are only applied when ApplyDetail is about to CREATE a + // new syb_product row (decision 4: a later sync of an existing row must + // never change its existing mark, even if the rules or the match outcome + // changed since). + Excluded bool + ExcludedRuleID *uint64 + ExcludedRuleKind string + ExcludedRuleKeyword string } // ApplyResult reports what ApplyDetail actually did, for the import-result @@ -138,12 +151,32 @@ func ApplyDetail(ctx context.Context, db *gorm.DB, order OrderInput, detail Deta err = tx.Where("order_code = ? AND detail_id = ?", order.Code, detail.ID).First(&existing).Error switch { case errors.Is(err, gorm.ErrRecordNotFound): + // #340 decision 4: the mark is set only when the row is first + // created, from this sync's own filter match — never on update. + if detail.Excluded { + now := time.Now().UTC() + record.PDDExcluded = true + record.ExcludedRuleID = detail.ExcludedRuleID + record.ExcludedRuleKind = detail.ExcludedRuleKind + record.ExcludedRuleKeyword = detail.ExcludedRuleKeyword + record.ExcludedAt = &now + } if err := tx.Create(&record).Error; err != nil { return err } result.Outcome = OutcomeCreated case err == nil: record.ID = existing.ID + // #340 decision 4: the exclusion mark is decided once, at row + // creation. A resync of an already-existing row must never + // change it (nor is it included in `updates` below), but the + // returned/in-memory record must still reflect the existing + // mark rather than the zero value this fresh struct starts with. + record.PDDExcluded = existing.PDDExcluded + record.ExcludedRuleID = existing.ExcludedRuleID + record.ExcludedRuleKind = existing.ExcludedRuleKind + record.ExcludedRuleKeyword = existing.ExcludedRuleKeyword + record.ExcludedAt = existing.ExcludedAt // Human-confirmed target values are authoritative and survive every // source re-import. ParseStatus/ParseNote below still record what the // current deterministic parser observed for audit. diff --git a/server/app/goauto/sybimport/handler.go b/server/app/goauto/sybimport/handler.go index 2a8f417..68693a0 100644 --- a/server/app/goauto/sybimport/handler.go +++ b/server/app/goauto/sybimport/handler.go @@ -47,7 +47,7 @@ func (handler Handler) List(c *gin.Context) { return } response, err := service.List(c.Request.Context(), ListRequest{ - Page: page, PageSize: pageSize, ShopName: c.Query("shopName"), OrderCodes: []string{c.Query("orderCodes")}, ParseStatus: strings.TrimSpace(c.Query("parseStatus")), ProcessStage: strings.TrimSpace(c.Query("processStage")), CreatedFrom: strings.TrimSpace(c.Query("createdFrom")), CreatedTo: strings.TrimSpace(c.Query("createdTo")), + Page: page, PageSize: pageSize, ShopName: c.Query("shopName"), OrderCodes: []string{c.Query("orderCodes")}, ParseStatus: strings.TrimSpace(c.Query("parseStatus")), ProcessStage: strings.TrimSpace(c.Query("processStage")), CreatedFrom: strings.TrimSpace(c.Query("createdFrom")), CreatedTo: strings.TrimSpace(c.Query("createdTo")), PurchaseType: strings.TrimSpace(c.Query("purchaseType")), }) if err != nil { writeError(c, err) diff --git a/server/app/goauto/sybimport/pdd_exclusion_list_test.go b/server/app/goauto/sybimport/pdd_exclusion_list_test.go new file mode 100644 index 0000000..6c02d81 --- /dev/null +++ b/server/app/goauto/sybimport/pdd_exclusion_list_test.go @@ -0,0 +1,71 @@ +package sybimport_test + +import ( + "context" + "testing" + + "go-admin/app/goauto/sybimport" +) + +// #340: List's purchaseType filter combines with processStage as AND. +func TestServiceListPurchaseType(t *testing.T) { + db := openTestDB(t) + pdd, err := sybimport.ApplyDetail(context.Background(), db, realOrder(), realDetailA()) + if err != nil { + t.Fatalf("apply pdd row: %v", err) + } + excludedOrder := realOrder() + excludedOrder.Code = "260728EXCL" + excludedOrder.StockID++ + excludedDetail := realDetailB() + excludedDetail.ID++ + excludedDetail.Excluded = true + ruleID := uint64(1) + excludedDetail.ExcludedRuleID = &ruleID + excludedDetail.ExcludedRuleKind = "keyword" + excludedDetail.ExcludedRuleKeyword = "档口" + excluded, err := sybimport.ApplyDetail(context.Background(), db, excludedOrder, excludedDetail) + if err != nil { + t.Fatalf("apply excluded row: %v", err) + } + if !excluded.SYBProduct.PDDExcluded { + t.Fatalf("seed row was not marked excluded") + } + service := sybimport.NewService(db) + + defaultResp, err := service.List(context.Background(), sybimport.ListRequest{}) + if err != nil { + t.Fatal(err) + } + if defaultResp.Total != 1 || defaultResp.Items[0].ID != pdd.SYBProduct.ID { + t.Fatalf("default purchaseType must show only pdd rows: %+v", defaultResp) + } + + pddResp, err := service.List(context.Background(), sybimport.ListRequest{PurchaseType: sybimport.PurchaseTypePDD}) + if err != nil { + t.Fatal(err) + } + if pddResp.Total != 1 || pddResp.Items[0].ID != pdd.SYBProduct.ID { + t.Fatalf("explicit pdd purchaseType mismatch: %+v", pddResp) + } + + excludedResp, err := service.List(context.Background(), sybimport.ListRequest{PurchaseType: sybimport.PurchaseTypeExcluded}) + if err != nil { + t.Fatal(err) + } + if excludedResp.Total != 1 || excludedResp.Items[0].ID != excluded.SYBProduct.ID { + t.Fatalf("excluded purchaseType mismatch: %+v", excludedResp) + } + + allResp, err := service.List(context.Background(), sybimport.ListRequest{PurchaseType: sybimport.PurchaseTypeAll}) + if err != nil { + t.Fatal(err) + } + if allResp.Total != 2 { + t.Fatalf("all purchaseType must show both rows, got %+v", allResp) + } + + if _, err := service.List(context.Background(), sybimport.ListRequest{PurchaseType: "bogus"}); err == nil { + t.Fatalf("invalid purchaseType must be rejected") + } +} diff --git a/server/app/goauto/sybimport/pdd_exclusion_test.go b/server/app/goauto/sybimport/pdd_exclusion_test.go new file mode 100644 index 0000000..f8b0e2e --- /dev/null +++ b/server/app/goauto/sybimport/pdd_exclusion_test.go @@ -0,0 +1,104 @@ +package sybimport_test + +import ( + "context" + "encoding/json" + "testing" + + "go-admin/app/goauto/models" + "go-admin/app/goauto/sybimport" +) + +// #340: applyStockDetail no longer skips a filter-hit row; it stores it and +// marks it. These tests exercise the same public entry point (ApplyDetail) +// applyStockDetail calls, with the Excluded fields it now always passes in. + +func TestApplyDetailMarksExcludedRowOnCreate(t *testing.T) { + db := openTestDB(t) + order := sybimport.OrderInput{Code: "EXCL-ORDER", StockID: 1, ShopName: "测试店铺"} + ruleID := uint64(9) + raw, _ := json.Marshal(map[string]any{"variationSku": "档口-123"}) + result, err := sybimport.ApplyDetail(context.Background(), db, order, sybimport.DetailInput{ + ID: 1, ProductID: 100, ProductQty: 1, ProductPrice: 10, ProductSpec: "黑色,L", ProductTitle: "t", + Raw: raw, + Excluded: true, + ExcludedRuleID: &ruleID, + ExcludedRuleKind: "keyword", + ExcludedRuleKeyword: "档口", + }) + if err != nil { + t.Fatalf("ApplyDetail: %v", err) + } + if !result.SYBProduct.PDDExcluded { + t.Fatalf("expected row to be marked excluded") + } + if result.SYBProduct.ExcludedRuleID == nil || *result.SYBProduct.ExcludedRuleID != ruleID { + t.Fatalf("expected excluded rule id snapshot %d, got %v", ruleID, result.SYBProduct.ExcludedRuleID) + } + if result.SYBProduct.ExcludedRuleKind != "keyword" || result.SYBProduct.ExcludedRuleKeyword != "档口" { + t.Fatalf("unexpected rule snapshot: %+v", result.SYBProduct) + } + if result.SYBProduct.ExcludedAt == nil { + t.Fatalf("expected excludedAt to be set") + } +} + +func TestApplyDetailNonHitRowIsNotMarked(t *testing.T) { + db := openTestDB(t) + order := sybimport.OrderInput{Code: "OK-ORDER", StockID: 1, ShopName: "测试店铺"} + raw, _ := json.Marshal(map[string]any{"variationSku": ""}) + result, err := sybimport.ApplyDetail(context.Background(), db, order, sybimport.DetailInput{ + ID: 1, ProductID: 100, ProductQty: 1, ProductPrice: 10, ProductSpec: "黑色,L", ProductTitle: "t", + Raw: raw, + }) + if err != nil { + t.Fatalf("ApplyDetail: %v", err) + } + if result.SYBProduct.PDDExcluded { + t.Fatalf("expected row not to be marked excluded") + } + if result.SYBProduct.ExcludedRuleID != nil || result.SYBProduct.ExcludedAt != nil { + t.Fatalf("expected no rule snapshot on a non-hit row: %+v", result.SYBProduct) + } +} + +// TestApplyDetailResyncKeepsMarkEvenIfRulesChanged is decision 4: the mark +// is fixed at row-creation time. A later sync of the SAME existing row must +// not flip it even when it is re-applied with a different Excluded value +// (representing a rule that started/stopped matching since). +func TestApplyDetailResyncKeepsMarkEvenIfRulesChanged(t *testing.T) { + db := openTestDB(t) + order := sybimport.OrderInput{Code: "RESYNC-ORDER", StockID: 1, ShopName: "测试店铺"} + ruleID := uint64(1) + raw, _ := json.Marshal(map[string]any{"variationSku": "档口-123"}) + first, err := sybimport.ApplyDetail(context.Background(), db, order, sybimport.DetailInput{ + ID: 1, ProductID: 100, ProductQty: 1, ProductPrice: 10, ProductSpec: "黑色,L", ProductTitle: "t", + Raw: raw, Excluded: true, ExcludedRuleID: &ruleID, ExcludedRuleKind: "keyword", ExcludedRuleKeyword: "档口", + }) + if err != nil || !first.SYBProduct.PDDExcluded { + t.Fatalf("seed create failed: %v %+v", err, first.SYBProduct) + } + + // Re-sync the same detail, this time with the rule disabled (no hit). + second, err := sybimport.ApplyDetail(context.Background(), db, order, sybimport.DetailInput{ + ID: 1, ProductID: 100, ProductQty: 1, ProductPrice: 10, ProductSpec: "黑色,L", ProductTitle: "t", + Raw: raw, Excluded: false, + }) + if err != nil { + t.Fatalf("ApplyDetail update: %v", err) + } + if second.Outcome != sybimport.OutcomeUpdated { + t.Fatalf("expected update outcome, got %s", second.Outcome) + } + if !second.SYBProduct.PDDExcluded { + t.Fatalf("expected existing mark to be preserved across resync, got unmarked: %+v", second.SYBProduct) + } + + var stored models.SYBProduct + if err := db.Where("order_code = ? AND detail_id = ?", order.Code, uint64(1)).First(&stored).Error; err != nil { + t.Fatalf("reload: %v", err) + } + if !stored.PDDExcluded || stored.ExcludedRuleKeyword != "档口" { + t.Fatalf("mark was changed by resync: %+v", stored) + } +} diff --git a/server/app/goauto/sybimport/service.go b/server/app/goauto/sybimport/service.go index 3fe2e00..c3c0eee 100644 --- a/server/app/goauto/sybimport/service.go +++ b/server/app/goauto/sybimport/service.go @@ -47,8 +47,21 @@ type ListRequest struct { ProcessStage string CreatedFrom string CreatedTo string + // PurchaseType is #340's list-side isolation filter: "pdd" (default when + // empty) shows only rows that still need a PDD purchase, + // "excluded" shows only pdd_purchase_excluded rows, "all" shows both. It + // combines with ProcessStage and the created-time range as AND; the + // auto-switch to 全部 mentioned in the issue is a front-end behaviour, + // not a server default. + PurchaseType string } +const ( + PurchaseTypePDD = "pdd" + PurchaseTypeExcluded = "excluded" + PurchaseTypeAll = "all" +) + type ListResponse struct { Items []models.SYBProduct `json:"items"` Total int64 `json:"total"` @@ -105,6 +118,16 @@ func (service *Service) List(ctx context.Context, request ListRequest) (ListResp if !purchase.ValidProcessStage(request.ProcessStage) { return ListResponse{}, invalidRequest("processStage 无效") } + request.PurchaseType = strings.TrimSpace(request.PurchaseType) + switch request.PurchaseType { + case "", PurchaseTypePDD: + query = query.Where("pdd_purchase_excluded = ?", false) + case PurchaseTypeExcluded: + query = query.Where("pdd_purchase_excluded = ?", true) + case PurchaseTypeAll: + default: + return ListResponse{}, invalidRequest("purchaseType 无效") + } if request.ProcessStage != "" { var candidates []models.SYBProduct if err := query.Order("updated_at DESC, id DESC").Find(&candidates).Error; err != nil { diff --git a/server/app/goauto/sybimport/sync.go b/server/app/goauto/sybimport/sync.go index a3a7c6b..c28b312 100644 --- a/server/app/goauto/sybimport/sync.go +++ b/server/app/goauto/sybimport/sync.go @@ -395,12 +395,16 @@ func applyStockDetail(ctx context.Context, db *gorm.DB, row sybclient.StockRow, } for _, item := range detail.Details { variation := stringField(item.Raw, "variationSku") + // `[必须]` #340: a filter hit no longer skips the row. It is still + // stored, marked with a rule snapshot, and left to the hard PDD + // exclusion guards in purchase/task/returnmatch. Char/keyword hit + // counts and per-rule hits (#269) keep the same meaning — "marked", + // not "skipped" — so the disable-confirmation dialog's figures are + // still accurate. + var excluded bool + var excludedRuleID *uint64 + var excludedRuleKind, excludedRuleKeyword string if rule := filters.Match(variation); rule != nil { - // `[必须]` Count the two kinds separately and per rule. A combined - // total would hide a structural rule that stopped matching because - // 档口 changed its code format, and a per-kind total would make the - // disable-confirmation dialog quote the same figure for "#" and "-" - // even though they match very different numbers of rows (#269). if rule.Kind == "char" { report.CharFilterSkipped++ } else { @@ -409,7 +413,11 @@ func applyStockDetail(ctx context.Context, db *gorm.DB, row sybclient.StockRow, if report.filterHits != nil { report.filterHits.Add(rule) } - continue + excluded = true + ruleID := rule.ID + excludedRuleID = &ruleID + excludedRuleKind = rule.Kind + excludedRuleKeyword = rule.Keyword } raw, err := json.Marshal(item.Raw) if err != nil { @@ -424,6 +432,9 @@ func applyStockDetail(ctx context.Context, db *gorm.DB, row sybclient.StockRow, ProductTitle: item.ProductTitle, ProductThumb: uint64(item.ProductThumb), Raw: raw, + + Excluded: excluded, ExcludedRuleID: excludedRuleID, + ExcludedRuleKind: excludedRuleKind, ExcludedRuleKeyword: excludedRuleKeyword, }) if err != nil { return fmt.Errorf("写入货运单 %s 明细 %d 失败(本次同步停止;已写入的数据保留): %w", diff --git a/server/app/goauto/sybimport/sync_test.go b/server/app/goauto/sybimport/sync_test.go index dfd07ed..983c444 100644 --- a/server/app/goauto/sybimport/sync_test.go +++ b/server/app/goauto/sybimport/sync_test.go @@ -68,6 +68,9 @@ type fakeSYB struct { // 用于构造「列表说是 A 店、明细说是 B 店」的不一致。 detailShopName string blankDetailShopName bool + // detailVariationSku, when non-empty, is used as every detail's + // variationSku (#340 filter-marking tests). + detailVariationSku string } func (f *fakeSYB) shopFor(i int) string { @@ -142,7 +145,7 @@ func (f *fakeSYB) server(t *testing.T) *httptest.Server { "shopName": detailShopName, "details": []any{map[string]any{ "id": float64(id*10 + 1), "productId": float64(9001), - "productTitle": "测试商品", "productSpec": "白色,L", + "productTitle": "测试商品", "productSpec": "白色,L", "variationSku": f.detailVariationSku, "productQty": float64(2), "productPrice": 39.5, "productThumb": float64(77), }}, }) @@ -466,3 +469,64 @@ func TestSyncRejectsBlankShopOnDetailResponse(t *testing.T) { report.DetailCount, report.AcceptedCount, report.ShopSkipped) } } + +// #340: a filter hit no longer skips the row — it is still stored, counted +// (as "marked", same counters as before) and left for the hard PDD exclusion +// guards elsewhere in the pipeline to enforce. +func TestSyncStoresAndMarksFilterHitRowsInsteadOfSkipping(t *testing.T) { + db := newSyncTestDB(t) + if err := db.Create(&models.SYBProductFilter{ + Kind: "keyword", Keyword: "档口", NormalizedKeyword: "档口", Enabled: true, + }).Error; err != nil { + t.Fatalf("seed filter: %v", err) + } + f := &fakeSYB{perDay: map[string]int{"2026-08-01": 2}, detailVariationSku: "档口-99"} + + report, err := Sync(context.Background(), db, newSyncClient(t, f), + SyncConfig{PageSize: 10, MaxMatches: 1000}, "2026-08-01", "2026-08-01") + if err != nil { + t.Fatalf("同步失败: %v", err) + } + if report.KeywordFilterSkipped != 2 { + t.Fatalf("expected 2 marked hits, got %d", report.KeywordFilterSkipped) + } + if report.DetailCount != 2 || report.Created != 2 { + t.Fatalf("filter hits must still be stored: detailCount=%d created=%d", report.DetailCount, report.Created) + } + var count int64 + db.Model(&models.SYBProduct{}).Where("pdd_purchase_excluded = ?", true).Count(&count) + if count != 2 { + t.Fatalf("expected 2 rows marked excluded, got %d", count) + } + var filter models.SYBProductFilter + if err := db.Where("keyword = ?", "档口").First(&filter).Error; err != nil { + t.Fatalf("reload filter: %v", err) + } + if filter.LastHitCount == nil || *filter.LastHitCount != 2 { + t.Fatalf("expected rule's own hit count to be 2, got %v", filter.LastHitCount) + } +} + +func TestSyncNonHitRowsAreNotMarked(t *testing.T) { + db := newSyncTestDB(t) + if err := db.Create(&models.SYBProductFilter{ + Kind: "keyword", Keyword: "档口", NormalizedKeyword: "档口", Enabled: true, + }).Error; err != nil { + t.Fatalf("seed filter: %v", err) + } + f := &fakeSYB{perDay: map[string]int{"2026-08-01": 1}, detailVariationSku: "普通-1"} + + report, err := Sync(context.Background(), db, newSyncClient(t, f), + SyncConfig{PageSize: 10, MaxMatches: 1000}, "2026-08-01", "2026-08-01") + if err != nil { + t.Fatalf("同步失败: %v", err) + } + if report.KeywordFilterSkipped != 0 { + t.Fatalf("unexpected marked hits: %d", report.KeywordFilterSkipped) + } + var count int64 + db.Model(&models.SYBProduct{}).Where("pdd_purchase_excluded = ?", true).Count(&count) + if count != 0 { + t.Fatalf("no rows should be marked excluded, got %d", count) + } +} diff --git a/server/app/goauto/sybproductfilter/handler.go b/server/app/goauto/sybproductfilter/handler.go index bb3bfbc..30389bd 100644 --- a/server/app/goauto/sybproductfilter/handler.go +++ b/server/app/goauto/sybproductfilter/handler.go @@ -77,6 +77,46 @@ func (h Handler) SetEnabled(c *gin.Context) { } c.JSON(200, gin.H{"code": 200, "data": x}) } + +// RecomputePreview and RecomputeExecute implement #340 decision 7's admin-only +// "按当前规则重新计算" action. Both are gated by middleware.RequireRoleKey +// ("admin") at the router, the same admin gate this package already uses for +// Create/SetEnabled/Delete. +func (h Handler) RecomputePreview(c *gin.Context) { + s, ok := h.service(c) + if !ok { + return + } + counts, e := s.RecomputePreview(c.Request.Context()) + if e != nil { + writeError(c, e) + return + } + c.JSON(200, gin.H{"code": 200, "data": counts}) +} + +type recomputeExecuteBody struct { + Fingerprint string `json:"fingerprint"` +} + +func (h Handler) RecomputeExecute(c *gin.Context) { + var body recomputeExecuteBody + if !decode(c, &body) { + return + } + s, ok := h.service(c) + if !ok { + return + } + claims := jwt.ExtractClaims(c) + name, _ := claims["nice"].(string) + result, e := s.RecomputeExecute(c.Request.Context(), name, body.Fingerprint) + if e != nil { + writeError(c, e) + return + } + c.JSON(200, gin.H{"code": 200, "data": result}) +} func (h Handler) Delete(c *gin.Context) { id, ok := idParam(c) if !ok { @@ -120,6 +160,8 @@ func writeError(c *gin.Context, e error) { status = 422 case CodeConflict: status = 409 + case CodeRecomputeStale: + status = 409 case CodeNotFound: status = 404 case CodeForbidden: diff --git a/server/app/goauto/sybproductfilter/recompute.go b/server/app/goauto/sybproductfilter/recompute.go new file mode 100644 index 0000000..a4af910 --- /dev/null +++ b/server/app/goauto/sybproductfilter/recompute.go @@ -0,0 +1,423 @@ +package sybproductfilter + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "sort" + "strings" + "time" + + "go-admin/app/goauto/models" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// RecomputeCounts is shared by preview and execute (#340 decision 7). The +// preview's counts are a PLAN computed against an unlocked read; the +// execute's counts are what was ACTUALLY written, re-checked row by row +// under lock (phase 3 review fix) — the two normally agree, but execute's +// counts are authoritative when something changed concurrently between the +// two calls (a purchase task created, a return matched, or the same row +// recomputed by another admin). +type RecomputeCounts struct { + ExcludedToPDD int `json:"excludedToPdd"` + PDDToExcluded int `json:"pddToExcluded"` + SkippedHasTask int `json:"skippedHasTask"` + SkippedReturnMatch int `json:"skippedReturnMatch"` +} + +type recomputeChange struct { + id uint64 + orderCode string + shopeeItemID string + toExcluded bool // true: pdd -> excluded; false: excluded -> pdd + ruleID *uint64 + ruleKind, ruleKeyword string +} + +// recomputeSourceRow selects only the columns recomputeChanges needs (phase 3 +// review item 4) instead of the full syb_product row. +type recomputeSourceRow struct { + ID uint64 `gorm:"column:id"` + OrderCode string `gorm:"column:order_code"` + ShopeeItemID string `gorm:"column:shopee_item_id"` + RawJSON string `gorm:"column:raw_json"` + PDDExcluded bool `gorm:"column:pdd_purchase_excluded"` +} + +// RecomputeSample is one preview row (#340 phase 2): up to maxRecomputeSamples +// changes are shown so an admin can sanity-check the run before executing it. +type RecomputeSample struct { + OrderCode string `json:"orderCode"` + ShopeeItemID string `json:"shopeeItemId"` + // Direction is "excluded_to_pdd" or "pdd_to_excluded". + Direction string `json:"direction"` + // RuleKind/RuleKeyword are empty when Direction is excluded_to_pdd (no + // rule matches any more). + RuleKind string `json:"ruleKind,omitempty"` + RuleKeyword string `json:"ruleKeyword,omitempty"` +} + +const ( + DirectionExcludedToPDD = "excluded_to_pdd" + DirectionPDDToExcluded = "pdd_to_excluded" + maxRecomputeSamples = 20 +) + +// recomputeChanges computes, against the CURRENT enabled rules, every +// syb_product row whose mark should flip, skipping any row that has ever had +// a purchase task or currently has an active return match — those never +// change (#340 decision 7). This is a PLAN: preview uses it directly, and +// execute uses it as the candidate list, re-checking each row under lock in +// writeRecomputeChanges before actually writing (phase 3 review item 1). +func recomputeChanges(ctx context.Context, tx *gorm.DB) (RecomputeCounts, []recomputeChange, error) { + filters, err := LoadEnabled(ctx, tx) + if err != nil { + return RecomputeCounts{}, nil, err + } + var rows []recomputeSourceRow + if err := tx.WithContext(ctx).Model(&models.SYBProduct{}). + Select("id, order_code, shopee_item_id, raw_json, pdd_purchase_excluded"). + Order("id ASC").Find(&rows).Error; err != nil { + return RecomputeCounts{}, nil, err + } + hasTask := make(map[uint64]bool) + var taskSYBIDs []uint64 + if err := tx.WithContext(ctx).Table("purchase_task").Distinct("syb_product_id"). + Where("syb_product_id IS NOT NULL").Pluck("syb_product_id", &taskSYBIDs).Error; err != nil { + return RecomputeCounts{}, nil, err + } + for _, id := range taskSYBIDs { + hasTask[id] = true + } + activeReturnMatch := make(map[uint64]bool) + var matchedSYBIDs []uint64 + if err := tx.WithContext(ctx).Table("return_match").Where("active_syb_product_id IS NOT NULL").Pluck("syb_product_id", &matchedSYBIDs).Error; err != nil { + return RecomputeCounts{}, nil, err + } + for _, id := range matchedSYBIDs { + activeReturnMatch[id] = true + } + + counts := RecomputeCounts{} + changes := make([]recomputeChange, 0) + for _, row := range rows { + variation := recomputeVariationSku(row.RawJSON) + rule := filters.Match(variation) + wouldExclude := rule != nil + if wouldExclude == row.PDDExcluded { + continue + } + if hasTask[row.ID] { + counts.SkippedHasTask++ + continue + } + if activeReturnMatch[row.ID] { + counts.SkippedReturnMatch++ + continue + } + change := recomputeChange{id: row.ID, orderCode: row.OrderCode, shopeeItemID: row.ShopeeItemID, toExcluded: wouldExclude} + if wouldExclude { + ruleID := rule.ID + change.ruleID, change.ruleKind, change.ruleKeyword = &ruleID, rule.Kind, rule.Keyword + counts.PDDToExcluded++ + } else { + counts.ExcludedToPDD++ + } + changes = append(changes, change) + } + return counts, changes, nil +} + +// recomputeVariationSku mirrors sybimport.stringField(item.Raw, "variationSku") +// without importing that package (sybimport already imports this one). +func recomputeVariationSku(rawJSON string) string { + if strings.TrimSpace(rawJSON) == "" { + return "" + } + var raw map[string]any + if json.Unmarshal([]byte(rawJSON), &raw) != nil { + return "" + } + value, _ := raw["variationSku"].(string) + return value +} + +// recomputeFingerprintEntry is one change's canonical, unambiguous +// representation for hashing (#340 phase 4 review item 1). It is JSON, not +// naive string concatenation: a naive "id:direction:ruleId" (or any other +// delimiter-joined string) can collide between two different plans whenever +// a field's own text can contain the delimiter or vary in length — e.g. a +// rule keyword containing ":" or newlines could make two distinct plans hash +// identically. encoding/json's field ordering for a fixed struct is stable, +// so this is both deterministic and injective for our purposes. +type recomputeFingerprintEntry struct { + ID uint64 `json:"id"` + Direction string `json:"direction"` + RuleID uint64 `json:"ruleId"` + RuleKind string `json:"ruleKind"` + RuleKeyword string `json:"ruleKeyword"` +} + +// recomputeFingerprint binds a preview to the exact plan it showed, INCLUDING +// the rule evidence that will be written to excluded_rule_kind/ +// excluded_rule_keyword (#340 phase 4 review item 1): two plans that flip the +// exact same id+direction but via a different (or since-edited) rule must +// hash differently, because RecomputeExecute is about to persist exactly +// this rule kind/keyword as this row's excluded_rule_* snapshot — a +// fingerprint that ignored them could let a stale plan through unnoticed +// whenever a rule's keyword/kind changed between preview and execute but the +// set of affected ids/directions happened to stay the same. For the +// excluded_to_pdd direction there is no rule (the row is losing its mark), +// so RuleID/RuleKind/RuleKeyword are left at their zero values, matching what +// gets written (nil/""/""). +// +// It is a sha256 over the JSON-encoded, sorted (by id, then direction) list +// of recomputeFingerprintEntry — sorting the decoded entries themselves +// (not pre-serialized strings) keeps the ordering rule obviously correct +// regardless of how any field is later escaped. +func recomputeFingerprint(changes []recomputeChange) string { + entries := make([]recomputeFingerprintEntry, 0, len(changes)) + for _, change := range changes { + entry := recomputeFingerprintEntry{ID: change.id} + if change.toExcluded { + entry.Direction = DirectionPDDToExcluded + if change.ruleID != nil { + entry.RuleID = *change.ruleID + } + entry.RuleKind = change.ruleKind + entry.RuleKeyword = change.ruleKeyword + } else { + entry.Direction = DirectionExcludedToPDD + } + entries = append(entries, entry) + } + sort.Slice(entries, func(i, j int) bool { + if entries[i].ID != entries[j].ID { + return entries[i].ID < entries[j].ID + } + return entries[i].Direction < entries[j].Direction + }) + // Marshal errors are impossible here (every field is a plain string/uint64 + // with no cycles), so it is safe to ignore the error and hash whatever + // was produced rather than plumb an error return through every caller. + payload, _ := json.Marshal(entries) + sum := sha256.Sum256(payload) + return hex.EncodeToString(sum[:]) +} + +// RecomputePreviewResult is preview-only: it carries the same RecomputeCounts +// execute would produce absent any race, plus up to maxRecomputeSamples +// sample rows, and the Fingerprint execute requires to prove nothing moved +// between preview and execute. +type RecomputePreviewResult struct { + RecomputeCounts + Samples []RecomputeSample `json:"samples"` + Fingerprint string `json:"fingerprint"` +} + +// RecomputePreview is read-only. +func (s *Service) RecomputePreview(ctx context.Context) (RecomputePreviewResult, error) { + counts, changes, err := recomputeChanges(ctx, s.DB) + if err != nil { + return RecomputePreviewResult{}, internal(err) + } + samples := make([]RecomputeSample, 0, maxRecomputeSamples) + for _, change := range changes { + if len(samples) >= maxRecomputeSamples { + break + } + sample := RecomputeSample{OrderCode: change.orderCode, ShopeeItemID: change.shopeeItemID} + if change.toExcluded { + sample.Direction = DirectionPDDToExcluded + sample.RuleKind, sample.RuleKeyword = change.ruleKind, change.ruleKeyword + } else { + sample.Direction = DirectionExcludedToPDD + } + samples = append(samples, sample) + } + return RecomputePreviewResult{RecomputeCounts: counts, Samples: samples, Fingerprint: recomputeFingerprint(changes)}, nil +} + +type RecomputeExecuteResult struct { + RecomputeCounts + Operator string `json:"operator"` +} + +// countPurchaseTasksLocked and countActiveReturnMatchesLocked are the two +// rechecks writeRecomputeChanges runs after taking the syb_product row lock. +// +// `[必须]` They MUST use a locking read (FOR SHARE), not a plain COUNT(*). +// Production and local are MySQL 8.4 under REPEATABLE-READ, where a plain +// read inside a transaction reuses the snapshot taken at that transaction's +// FIRST read (here, recomputeChanges' own SELECT) — so a purchase_task or +// return_match row committed by another connection AFTER that snapshot is +// invisible to a plain COUNT(*) even after this code has waited for and +// obtained the syb_product row's FOR UPDATE lock. The row lock only +// serializes writers against each other; it does not by itself make a later +// plain read see newer committed data under REPEATABLE-READ. A locking read +// (FOR SHARE is enough since these two only need to observe committed rows, +// not lock them for update) forces MySQL to use a fresh "current read" +// instead of the snapshot, which is exactly what closes the race (verified +// against real MySQL 8.4 with two connections: after the other transaction +// committed a task, plain COUNT returned 0 while COUNT ... FOR SHARE +// correctly returned 1). SQLite — used by this package's tests — drops +// locking clauses entirely (gorm.io/driver/sqlite's "FOR" clause builder is +// a no-op) and has no multi-connection snapshot semantics to reproduce this +// race in the first place, so no SQLite-backed test can catch a regression +// here; see TestRecheckQueriesUseLockingReads below for the SQL-shape test +// that does. +// sybProductRowLockQuery, purchaseTaskLockedQuery and returnMatchLockedQuery +// build (but do not execute) the three locking reads writeRecomputeChanges +// runs per row. They are split out from the count*/lock helpers below purely +// so a test can call db.ToSQL against the exact same query construction the +// production code runs, and assert the FOR UPDATE / FOR SHARE clause is +// actually present in the generated SQL (#340 phase 3 review follow-up). +func sybProductRowLockQuery(tx *gorm.DB) *gorm.DB { + return tx.Clauses(clause.Locking{Strength: clause.LockingStrengthUpdate}) +} + +func purchaseTaskLockedQuery(tx *gorm.DB, sybID uint64) *gorm.DB { + return tx.Clauses(clause.Locking{Strength: clause.LockingStrengthShare}). + Model(&models.PurchaseTask{}).Where("syb_product_id = ?", sybID) +} + +func returnMatchLockedQuery(tx *gorm.DB, sybID uint64) *gorm.DB { + return tx.Clauses(clause.Locking{Strength: clause.LockingStrengthShare}). + Table("return_match").Where("syb_product_id = ? AND active_syb_product_id IS NOT NULL", sybID) +} + +func countPurchaseTasksLocked(ctx context.Context, tx *gorm.DB, sybID uint64) (int64, error) { + var count int64 + err := purchaseTaskLockedQuery(tx.WithContext(ctx), sybID).Count(&count).Error + return count, err +} + +func countActiveReturnMatchesLocked(ctx context.Context, tx *gorm.DB, sybID uint64) (int64, error) { + var count int64 + err := returnMatchLockedQuery(tx.WithContext(ctx), sybID).Count(&count).Error + return count, err +} + +// writeRecomputeChanges is the write phase, kept separate from planning so it +// is independently testable (#340 phase 3 review item 1): for every planned +// change it takes the SAME row lock purchase.Service.create and returnmatch's +// matchOneWithLock take (clause.Locking{Strength:"UPDATE"}), then re-checks +// under that lock — a purchase task or active return match created for the +// row after planning skips it (counted the same as a plan-time skip), and a +// row whose mark already matches the target (e.g. flipped by a concurrent +// recompute) is left alone and not counted at all. Only rows that still need +// the change are written. The returned counts are what was ACTUALLY done. +func writeRecomputeChanges(ctx context.Context, tx *gorm.DB, planned []recomputeChange) (RecomputeCounts, error) { + actual := RecomputeCounts{} + now := time.Now().UTC() + for _, change := range planned { + var locked models.SYBProduct + if err := sybProductRowLockQuery(tx.WithContext(ctx)).First(&locked, change.id).Error; err != nil { + return RecomputeCounts{}, err + } + taskCount, err := countPurchaseTasksLocked(ctx, tx, change.id) + if err != nil { + return RecomputeCounts{}, err + } + if taskCount > 0 { + actual.SkippedHasTask++ + continue + } + matchCount, err := countActiveReturnMatchesLocked(ctx, tx, change.id) + if err != nil { + return RecomputeCounts{}, err + } + if matchCount > 0 { + actual.SkippedReturnMatch++ + continue + } + if locked.PDDExcluded == change.toExcluded { + // Already at the target (e.g. another recompute got there first + // under its own lock) — nothing to do, and this is not a + // "skipped" outcome in the reported sense since there was never + // a real conflict blocking it. + continue + } + updates := map[string]any{"pdd_purchase_excluded": change.toExcluded} + if change.toExcluded { + updates["excluded_rule_id"] = change.ruleID + updates["excluded_rule_kind"] = change.ruleKind + updates["excluded_rule_keyword"] = change.ruleKeyword + updates["excluded_at"] = now + } else { + updates["excluded_rule_id"] = nil + updates["excluded_rule_kind"] = "" + updates["excluded_rule_keyword"] = "" + updates["excluded_at"] = nil + } + if err := tx.WithContext(ctx).Model(&models.SYBProduct{}).Where("id = ?", change.id).Updates(updates).Error; err != nil { + return RecomputeCounts{}, err + } + if change.toExcluded { + actual.PDDToExcluded++ + } else { + actual.ExcludedToPDD++ + } + } + return actual, nil +} + +// RecomputeExecute re-validates the plan against a fingerprint the caller +// must have gotten from a RecomputePreview call (#340 phase 3 review item 2): +// if the current plan's fingerprint no longer matches — the underlying data +// or the enabled rules changed since that preview — nothing is written and +// CodeRecomputeStale is returned. Otherwise every planned change is written +// through writeRecomputeChanges under its own per-row lock, and one audit log +// row is written in the same transaction using the ACTUAL counts. +func (s *Service) RecomputeExecute(ctx context.Context, operator, fingerprint string) (RecomputeExecuteResult, error) { + fingerprint = strings.TrimSpace(fingerprint) + if fingerprint == "" { + return RecomputeExecuteResult{}, invalid("fingerprint 不能为空,请先调用预览接口") + } + var result RecomputeExecuteResult + err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + planCounts, changes, err := recomputeChanges(ctx, tx) + if err != nil { + return err + } + if recomputeFingerprint(changes) != fingerprint { + return &ServiceError{Code: CodeRecomputeStale, Message: "数据或规则已变化,请重新预览后再执行"} + } + written, err := writeRecomputeChanges(ctx, tx, changes) + if err != nil { + return err + } + // The ACTUAL, reported counts combine what planning already knew was + // ineligible (rows never even handed to the write phase) with what + // the write phase itself caught under lock — so a plan-time skip and + // a write-time race both show up, and Excluded/PDD counts are always + // exactly what got written (#340 phase 3 review item 1c). + actual := RecomputeCounts{ + ExcludedToPDD: written.ExcludedToPDD, + PDDToExcluded: written.PDDToExcluded, + SkippedHasTask: planCounts.SkippedHasTask + written.SkippedHasTask, + SkippedReturnMatch: planCounts.SkippedReturnMatch + written.SkippedReturnMatch, + } + log := models.SYBProductFilterRecomputeLog{ + Operator: strings.TrimSpace(operator), ExcludedToPDD: actual.ExcludedToPDD, + PDDToExcluded: actual.PDDToExcluded, SkippedHasTask: actual.SkippedHasTask, + SkippedReturnMatch: actual.SkippedReturnMatch, + } + if err := tx.Create(&log).Error; err != nil { + return err + } + result = RecomputeExecuteResult{RecomputeCounts: actual, Operator: log.Operator} + return nil + }) + if err != nil { + if se, ok := err.(*ServiceError); ok { + return RecomputeExecuteResult{}, se + } + return RecomputeExecuteResult{}, internal(err) + } + return result, nil +} diff --git a/server/app/goauto/sybproductfilter/recompute_locking_test.go b/server/app/goauto/sybproductfilter/recompute_locking_test.go new file mode 100644 index 0000000..23aa071 --- /dev/null +++ b/server/app/goauto/sybproductfilter/recompute_locking_test.go @@ -0,0 +1,89 @@ +package sybproductfilter + +import ( + "strings" + "testing" + + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +// mysqlDryRunDB opens a gorm session against the MySQL dialector with +// DryRun+DisableAutomaticPing, so no real network connection is ever made +// (sql.Open is lazy and gorm skips the startup ping) but the SQL gorm would +// send to a real MySQL 8.4 server can still be inspected via db.ToSQL. +// +// `[必须]` This must be the MySQL dialector, not SQLite: gorm.io/driver/ +// sqlite's own "FOR" clause builder silently drops clause.Locking entirely +// (SQLite has no row-level locking), so a SQLite-backed test would show +// these queries with no FOR clause at all regardless of whether the +// production code asks for one — it would pass even with the bug this test +// exists to catch. Only a MySQL-dialect SQL string proves the FOR UPDATE / +// FOR SHARE clauses are actually being sent. +func mysqlDryRunDB(t *testing.T) *gorm.DB { + t.Helper() + // SkipInitializeWithVersion is required, not just DisableAutomaticPing: + // gorm's MySQL dialector otherwise runs `SELECT VERSION()` against the + // DSN's ConnPool during Initialize (Open), before DryRun/ping settings + // even come into play, to decide version-gated feature flags such as + // DontSupportForShareClause. With it set, sql.Open's lazy connection + // pool is never dialed at all. + db, err := gorm.Open(mysql.New(mysql.Config{ + DSN: "user:pass@tcp(127.0.0.1:3306)/goauto_test?parseTime=true", SkipInitializeWithVersion: true, + }), &gorm.Config{DryRun: true, DisableAutomaticPing: true}) + if err != nil { + t.Fatalf("open dry-run mysql session: %v", err) + } + return db +} + +// TestRecheckQueriesUseLockingReads pins the exact SQL shape behind #340 +// phase 3's real fix: writeRecomputeChanges' syb_product row lock must be +// FOR UPDATE, and its two rechecks (purchase_task, return_match) must be +// FOR SHARE — a plain COUNT(*) for either recheck is invisible to a +// transaction's already-taken REPEATABLE-READ snapshot on real MySQL 8.4 +// even after the row's FOR UPDATE lock is granted, which is exactly the race +// this test guards against ever regressing to (verified against a real +// MySQL 8.4 server with two connections: a purchase_task committed by the +// other connection after the snapshot was invisible to plain COUNT(*), but +// visible to COUNT(*) ... FOR SHARE). SQLite, which every other test in this +// package runs against, cannot reproduce any of this: it has no +// multi-connection snapshot isolation and gorm's SQLite driver drops locking +// clauses outright, so this test intentionally talks MySQL SQL shape only, +// never a real database. +func TestRecheckQueriesUseLockingReads(t *testing.T) { + db := mysqlDryRunDB(t) + + rowLockSQL := db.ToSQL(func(tx *gorm.DB) *gorm.DB { + var dest map[string]any + return sybProductRowLockQuery(tx).Table("syb_product").Where("id = ?", uint64(1)).Find(&dest) + }) + t.Logf("ROW LOCK SQL: %s", rowLockSQL) + if !strings.Contains(rowLockSQL, "FOR UPDATE") { + t.Fatalf("expected the syb_product row lock to be FOR UPDATE, got SQL: %s", rowLockSQL) + } + + var taskCount int64 + taskSQL := db.ToSQL(func(tx *gorm.DB) *gorm.DB { + return purchaseTaskLockedQuery(tx, 1).Count(&taskCount) + }) + t.Logf("PURCHASE TASK RECHECK SQL: %s", taskSQL) + if !strings.Contains(taskSQL, "FOR SHARE") { + t.Fatalf("expected the purchase_task recheck to be a locking (FOR SHARE) read, got SQL: %s", taskSQL) + } + if !strings.Contains(taskSQL, "syb_product_id") { + t.Fatalf("expected the purchase_task recheck to filter by syb_product_id, got SQL: %s", taskSQL) + } + + var matchCount int64 + matchSQL := db.ToSQL(func(tx *gorm.DB) *gorm.DB { + return returnMatchLockedQuery(tx, 1).Count(&matchCount) + }) + t.Logf("RETURN MATCH RECHECK SQL: %s", matchSQL) + if !strings.Contains(matchSQL, "FOR SHARE") { + t.Fatalf("expected the return_match recheck to be a locking (FOR SHARE) read, got SQL: %s", matchSQL) + } + if !strings.Contains(matchSQL, "active_syb_product_id IS NOT NULL") { + t.Fatalf("expected the return_match recheck to filter on an active match, got SQL: %s", matchSQL) + } +} diff --git a/server/app/goauto/sybproductfilter/recompute_mysql_integration_test.go b/server/app/goauto/sybproductfilter/recompute_mysql_integration_test.go new file mode 100644 index 0000000..c4745cd --- /dev/null +++ b/server/app/goauto/sybproductfilter/recompute_mysql_integration_test.go @@ -0,0 +1,351 @@ +package sybproductfilter + +import ( + "context" + "database/sql" + "fmt" + "os" + "regexp" + "testing" + "time" + + "go-admin/app/goauto/migrations" + "go-admin/app/goauto/models" + + _ "github.com/go-sql-driver/mysql" + "gorm.io/driver/mysql" + "gorm.io/gorm" + "gorm.io/gorm/clause" + "gorm.io/gorm/logger" +) + +// dsnPathReplacer swaps the database name in a Go MySQL DSN of the form +// user:pass@tcp(host:port)/dbname?params — used to connect first to the +// server (no specific throwaway database yet) and then to the freshly +// created throwaway database. +var dsnPathReplacer = regexp.MustCompile(`^(.*/)([^/?]*)(\?.*)?$`) + +func dsnWithDatabase(dsn, dbName string) string { + if dsnPathReplacer.MatchString(dsn) { + return dsnPathReplacer.ReplaceAllString(dsn, "${1}"+dbName+"${3}") + } + return dsn +} + +// setupMySQLIntegrationDB is #340 phase 4 review item 2's throwaway-database +// harness: it never touches an existing database. GOAUTO_IT_MYSQL_DSN must +// point at a MySQL SERVER (any connectable path, e.g. the system "mysql" +// database) with permission to CREATE/DROP DATABASE; a uniquely named +// zz_goauto_it_340_ database is created, migrated, and guaranteed +// dropped via t.Cleanup even if the test fails or panics. +func setupMySQLIntegrationDB(t *testing.T) (dsn string, dbName string) { + t.Helper() + baseDSN := os.Getenv("GOAUTO_IT_MYSQL_DSN") + if baseDSN == "" { + t.Skip("GOAUTO_IT_MYSQL_DSN not set; skipping MySQL concurrency integration test") + } + admin, err := sql.Open("mysql", baseDSN) + if err != nil { + t.Fatalf("open admin connection: %v", err) + } + if err := admin.Ping(); err != nil { + admin.Close() + t.Fatalf("ping MySQL server: %v", err) + } + dbName = fmt.Sprintf("zz_goauto_it_340_%d", time.Now().UnixNano()) + if _, err := admin.Exec("CREATE DATABASE `" + dbName + "`"); err != nil { + admin.Close() + t.Fatalf("create throwaway database %s: %v", dbName, err) + } + t.Cleanup(func() { + defer admin.Close() + if _, err := admin.Exec("DROP DATABASE IF EXISTS `" + dbName + "`"); err != nil { + t.Errorf("failed to drop throwaway database %s (manual cleanup required): %v", dbName, err) + } + }) + + dsn = dsnWithDatabase(baseDSN, dbName) + gdb, err := gorm.Open(mysql.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + if err != nil { + t.Fatalf("open throwaway database: %v", err) + } + if err := migrations.Migrate(gdb); err != nil { + t.Fatalf("migrate throwaway database: %v", err) + } + return dsn, dbName +} + +func newMySQLIntegrationConn(t *testing.T, dsn string) *gorm.DB { + t.Helper() + conn, err := gorm.Open(mysql.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + if err != nil { + t.Fatalf("open MySQL connection: %v", err) + } + return conn +} + +// TestRecomputeConcurrentPurchaseTaskUnderRealMySQL is #340 phase 4 review +// item 2: it reproduces, against a real MySQL server under REPEATABLE-READ, +// the exact race writeRecomputeChanges' locking rechecks exist to close. +// +// Timeline: +// 1. Connection A begins a transaction and runs the planning step +// (recomputeChanges) — this is A's FIRST read, so it fixes A's +// REPEATABLE-READ snapshot with zero purchase_task rows. +// 2. Connection B, concurrently, takes the SAME row's FOR UPDATE lock, +// confirmed via a channel before A is allowed to proceed. +// 3. A's write phase (writeRecomputeChanges) is started in a goroutine; it +// must BLOCK trying to take the same FOR UPDATE lock B already holds — +// the test asserts A has NOT finished after a wait window, proving a +// real block happened (not just a fast, uncontended lock grant). +// 4. B inserts a purchase_task for the row and commits, releasing the lock. +// 5. A's write phase unblocks, re-checks purchase_task under lock, and must +// see B's now-committed row and skip — this only holds because the +// recheck is a locking (FOR SHARE) read; a plain COUNT(*) would still be +// bound to A's step-1 snapshot (zero rows) and would wrongly write. +func TestRecomputeConcurrentPurchaseTaskUnderRealMySQL(t *testing.T) { + dsn, _ := setupMySQLIntegrationDB(t) + seedConn := newMySQLIntegrationConn(t, dsn) + + rule := models.SYBProductFilter{Kind: "keyword", Keyword: "档口", NormalizedKeyword: "档口", Enabled: true} + if err := seedConn.Create(&rule).Error; err != nil { + t.Fatal(err) + } + row := models.SYBProduct{ + OrderCode: "ORD-IT-TASK", DetailID: 1, StockID: 1, ShopeeItemID: "1", Quantity: 1, + ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"档口-1"}`, + } + if err := seedConn.Create(&row).Error; err != nil { + t.Fatal(err) + } + // purchase_task.pdd_product_id has a real FK (unlike this package's + // SQLite-backed tests, which don't enable foreign key enforcement) — + // MySQL requires an actual pdd_product row to reference. + pdd := models.PDDProduct{GoodsID: "IT-PDD-1", URL: "https://example.invalid/it", SpecsJSON: "[]"} + if err := seedConn.Create(&pdd).Error; err != nil { + t.Fatal(err) + } + + connA := newMySQLIntegrationConn(t, dsn) + connB := newMySQLIntegrationConn(t, dsn) + ctx := context.Background() + + txA := connA.Begin() + // Guard against ANY early return (t.Fatalf, panic) leaving txA open: an + // abandoned open transaction holds a connection into this throwaway + // database and blocks the DROP DATABASE cleanup indefinitely. Rollback + // on an already-committed transaction is a harmless no-op error, which + // is why the plain Commit() path below intentionally does not disable + // this cleanup. + t.Cleanup(func() { txA.Rollback() }) + _, planned, err := recomputeChanges(ctx, txA) + if err != nil { + t.Fatalf("plan: %v", err) + } + if len(planned) != 1 || planned[0].id != row.ID { + t.Fatalf("expected exactly the seeded row to be planned, got %+v", planned) + } + + txB := connB.Begin() + t.Cleanup(func() { txB.Rollback() }) + + bHoldingLock := make(chan struct{}) + bCanCommit := make(chan struct{}) + bDone := make(chan error, 1) + go func() { + var locked models.SYBProduct + if err := txB.Clauses(clause.Locking{Strength: clause.LockingStrengthUpdate}). + First(&locked, row.ID).Error; err != nil { + bDone <- fmt.Errorf("B lock row: %w", err) + return + } + close(bHoldingLock) + <-bCanCommit + task := models.PurchaseTask{ + SYBProductID: &row.ID, PDDProductID: pdd.ID, Quantity: 1, CreateRequestID: "it-race-task", + Status: models.PurchaseTaskStatusFailed, ExecutionMode: models.PurchaseExecutionModeLive, + TaskType: models.PurchaseTaskTypeSYBOrder, RuleSnapshot: "{}", + } + if err := txB.Create(&task).Error; err != nil { + bDone <- fmt.Errorf("B insert task: %w", err) + return + } + bDone <- txB.Commit().Error + }() + + select { + case <-bHoldingLock: + case err := <-bDone: + t.Fatalf("B failed before taking the row lock: %v", err) + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for B to take the row lock") + } + + aDone := make(chan struct{}) + var aActual RecomputeCounts + var aErr error + go func() { + aActual, aErr = writeRecomputeChanges(ctx, txA, planned) + close(aDone) + }() + + // A must still be blocked on B's row lock at this point — this is the + // test's proof that a real MySQL row lock, not just program logic, is + // what's being exercised. + select { + case <-aDone: + t.Fatal("A's write phase returned before B committed — it should have blocked on the row's FOR UPDATE lock") + case <-time.After(300 * time.Millisecond): + } + + close(bCanCommit) + if err := <-bDone; err != nil { + t.Fatalf("B failed: %v", err) + } + + select { + case <-aDone: + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for A's write phase to unblock after B committed") + } + if aErr != nil { + t.Fatalf("A's write phase failed: %v", aErr) + } + if err := txA.Commit().Error; err != nil { + t.Fatalf("commit A: %v", err) + } + + if aActual.SkippedHasTask != 1 || aActual.PDDToExcluded != 0 { + t.Fatalf("expected A to skip the row for the concurrently-created task, got %+v", aActual) + } + var reloaded models.SYBProduct + if err := seedConn.First(&reloaded, row.ID).Error; err != nil { + t.Fatal(err) + } + if reloaded.PDDExcluded { + t.Fatalf("row must not have been marked excluded — the concurrent task should have blocked it: %+v", reloaded) + } +} + +// TestRecomputeConcurrentReturnMatchUnderRealMySQL is the same scenario as +// TestRecomputeConcurrentPurchaseTaskUnderRealMySQL, with an active +// return_match row instead of a purchase_task as B's concurrent write. +func TestRecomputeConcurrentReturnMatchUnderRealMySQL(t *testing.T) { + dsn, _ := setupMySQLIntegrationDB(t) + seedConn := newMySQLIntegrationConn(t, dsn) + + rule := models.SYBProductFilter{Kind: "keyword", Keyword: "档口", NormalizedKeyword: "档口", Enabled: true} + if err := seedConn.Create(&rule).Error; err != nil { + t.Fatal(err) + } + row := models.SYBProduct{ + OrderCode: "ORD-IT-MATCH", DetailID: 1, StockID: 1, ShopeeItemID: "1", Quantity: 1, + ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"档口-1"}`, + } + if err := seedConn.Create(&row).Error; err != nil { + t.Fatal(err) + } + yeekeItem := models.YeekeReturnItem{PackageID: 0, ExternalKey: "it-race-return", ItemID: "1", VariationName: "档口-1", LastSyncedAt: time.Now()} + // A package row is required by the return_match/yeeke schema's foreign + // key; seed a minimal one. + pkg := models.YeekeReturnPackage{ExternalID: "it-race-pkg", OrderSN: "IT-ORD", TrackingNo: "IT-TRK", LastSyncedAt: time.Now()} + if err := seedConn.Create(&pkg).Error; err != nil { + t.Fatal(err) + } + yeekeItem.PackageID = pkg.ID + if err := seedConn.Create(&yeekeItem).Error; err != nil { + t.Fatal(err) + } + + connA := newMySQLIntegrationConn(t, dsn) + connB := newMySQLIntegrationConn(t, dsn) + ctx := context.Background() + + txA := connA.Begin() + // See TestRecomputeConcurrentPurchaseTaskUnderRealMySQL for why this + // unconditional cleanup is necessary regardless of the Commit() below. + t.Cleanup(func() { txA.Rollback() }) + _, planned, err := recomputeChanges(ctx, txA) + if err != nil { + t.Fatalf("plan: %v", err) + } + if len(planned) != 1 || planned[0].id != row.ID { + t.Fatalf("expected exactly the seeded row to be planned, got %+v", planned) + } + + txB := connB.Begin() + t.Cleanup(func() { txB.Rollback() }) + + bHoldingLock := make(chan struct{}) + bCanCommit := make(chan struct{}) + bDone := make(chan error, 1) + go func() { + var locked models.SYBProduct + if err := txB.Clauses(clause.Locking{Strength: clause.LockingStrengthUpdate}). + First(&locked, row.ID).Error; err != nil { + bDone <- fmt.Errorf("B lock row: %w", err) + return + } + close(bHoldingLock) + <-bCanCommit + match := models.ReturnMatch{ + SYBProductID: row.ID, YeekeReturnItemID: yeekeItem.ID, + ActiveSYBProductID: &row.ID, Status: models.ReturnMatchStatusMatched, MatchedAt: time.Now(), + } + if err := txB.Create(&match).Error; err != nil { + bDone <- fmt.Errorf("B insert match: %w", err) + return + } + bDone <- txB.Commit().Error + }() + + select { + case <-bHoldingLock: + case err := <-bDone: + t.Fatalf("B failed before taking the row lock: %v", err) + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for B to take the row lock") + } + + aDone := make(chan struct{}) + var aActual RecomputeCounts + var aErr error + go func() { + aActual, aErr = writeRecomputeChanges(ctx, txA, planned) + close(aDone) + }() + + select { + case <-aDone: + t.Fatal("A's write phase returned before B committed — it should have blocked on the row's FOR UPDATE lock") + case <-time.After(300 * time.Millisecond): + } + + close(bCanCommit) + if err := <-bDone; err != nil { + t.Fatalf("B failed: %v", err) + } + + select { + case <-aDone: + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for A's write phase to unblock after B committed") + } + if aErr != nil { + t.Fatalf("A's write phase failed: %v", aErr) + } + if err := txA.Commit().Error; err != nil { + t.Fatalf("commit A: %v", err) + } + + if aActual.SkippedReturnMatch != 1 || aActual.PDDToExcluded != 0 { + t.Fatalf("expected A to skip the row for the concurrently-created return match, got %+v", aActual) + } + var reloaded models.SYBProduct + if err := seedConn.First(&reloaded, row.ID).Error; err != nil { + t.Fatal(err) + } + if reloaded.PDDExcluded { + t.Fatalf("row must not have been marked excluded — the concurrent return match should have blocked it: %+v", reloaded) + } +} diff --git a/server/app/goauto/sybproductfilter/recompute_test.go b/server/app/goauto/sybproductfilter/recompute_test.go new file mode 100644 index 0000000..14018a2 --- /dev/null +++ b/server/app/goauto/sybproductfilter/recompute_test.go @@ -0,0 +1,496 @@ +package sybproductfilter + +import ( + "context" + "fmt" + "testing" + + "go-admin/app/goauto/models" + + "gorm.io/gorm" +) + +func TestRecomputePreviewMatchesExecute(t *testing.T) { + db := testDB(t) + // A row that currently needs a PDD purchase but now matches a keyword rule. + pddToExcluded := models.SYBProduct{ + OrderCode: "ORD-1", DetailID: 1, StockID: 1, ShopeeItemID: "1", Quantity: 1, + ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"档口-1"}`, + } + if err := db.Create(&pddToExcluded).Error; err != nil { + t.Fatal(err) + } + // A row currently marked excluded whose rule no longer matches. + excludedToPDD := models.SYBProduct{ + OrderCode: "ORD-2", DetailID: 2, StockID: 2, ShopeeItemID: "2", Quantity: 1, + ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"普通-2"}`, + PDDExcluded: true, + } + if err := db.Create(&excludedToPDD).Error; err != nil { + t.Fatal(err) + } + // A row that should flip, but has a purchase task -> must be skipped. + hasTask := models.SYBProduct{ + OrderCode: "ORD-3", DetailID: 3, StockID: 3, ShopeeItemID: "3", Quantity: 1, + ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"档口-3"}`, + } + if err := db.Create(&hasTask).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&models.PurchaseTask{SYBProductID: &hasTask.ID, PDDProductID: 1, Quantity: 1, CreateRequestID: "req-3", Status: models.PurchaseTaskStatusFailed, ExecutionMode: models.PurchaseExecutionModeLive, TaskType: models.PurchaseTaskTypeSYBOrder, RuleSnapshot: "{}"}).Error; err != nil { + t.Fatal(err) + } + // A row that should flip, but has an active return match -> must be skipped. + hasMatch := models.SYBProduct{ + OrderCode: "ORD-4", DetailID: 4, StockID: 4, ShopeeItemID: "4", Quantity: 1, + ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"档口-4"}`, + } + if err := db.Create(&hasMatch).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&models.ReturnMatch{SYBProductID: hasMatch.ID, YeekeReturnItemID: 1, ActiveSYBProductID: &hasMatch.ID, Status: models.ReturnMatchStatusMatched}).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&models.SYBProductFilter{Kind: "keyword", Keyword: "档口", NormalizedKeyword: "档口", Enabled: true}).Error; err != nil { + t.Fatal(err) + } + + s := NewService(db) + preview, err := s.RecomputePreview(context.Background()) + if err != nil { + t.Fatal(err) + } + if preview.PDDToExcluded != 1 || preview.ExcludedToPDD != 1 || preview.SkippedHasTask != 1 || preview.SkippedReturnMatch != 1 { + t.Fatalf("unexpected preview counts: %+v", preview) + } + + result, err := s.RecomputeExecute(context.Background(), "admin1", preview.Fingerprint) + if err != nil { + t.Fatal(err) + } + if result.RecomputeCounts != preview.RecomputeCounts { + t.Fatalf("execute counts must match preview: preview=%+v execute=%+v", preview.RecomputeCounts, result.RecomputeCounts) + } + if len(preview.Samples) != 2 { + t.Fatalf("expected 2 sample rows, got %+v", preview.Samples) + } + var sawPDDToExcluded, sawExcludedToPDD bool + for _, sample := range preview.Samples { + switch sample.Direction { + case DirectionPDDToExcluded: + sawPDDToExcluded = true + if sample.OrderCode != "ORD-1" || sample.RuleKeyword != "档口" { + t.Fatalf("unexpected pdd_to_excluded sample: %+v", sample) + } + case DirectionExcludedToPDD: + sawExcludedToPDD = true + if sample.OrderCode != "ORD-2" || sample.RuleKind != "" || sample.RuleKeyword != "" { + t.Fatalf("unexpected excluded_to_pdd sample: %+v", sample) + } + default: + t.Fatalf("unexpected direction: %+v", sample) + } + } + if !sawPDDToExcluded || !sawExcludedToPDD { + t.Fatalf("expected both directions represented: %+v", preview.Samples) + } + + var reloadedPDDToExcluded, reloadedExcludedToPDD, reloadedHasTask, reloadedHasMatch models.SYBProduct + db.First(&reloadedPDDToExcluded, pddToExcluded.ID) + db.First(&reloadedExcludedToPDD, excludedToPDD.ID) + db.First(&reloadedHasTask, hasTask.ID) + db.First(&reloadedHasMatch, hasMatch.ID) + + if !reloadedPDDToExcluded.PDDExcluded || reloadedPDDToExcluded.ExcludedRuleKeyword != "档口" { + t.Fatalf("expected row 1 to become excluded: %+v", reloadedPDDToExcluded) + } + if reloadedExcludedToPDD.PDDExcluded || reloadedExcludedToPDD.ExcludedRuleID != nil { + t.Fatalf("expected row 2 to become un-excluded: %+v", reloadedExcludedToPDD) + } + if reloadedHasTask.PDDExcluded { + t.Fatalf("row with a purchase task must never change: %+v", reloadedHasTask) + } + if reloadedHasMatch.PDDExcluded { + t.Fatalf("row with an active return match must never change: %+v", reloadedHasMatch) + } + + var logs []models.SYBProductFilterRecomputeLog + if err := db.Find(&logs).Error; err != nil { + t.Fatal(err) + } + if len(logs) != 1 || logs[0].Operator != "admin1" || logs[0].PDDToExcluded != 1 || logs[0].ExcludedToPDD != 1 { + t.Fatalf("expected exactly one audit log row matching the counts: %+v", logs) + } + + // Preview and execute must still agree on a no-op run. + secondPreview, err := s.RecomputePreview(context.Background()) + if err != nil { + t.Fatal(err) + } + if secondPreview.PDDToExcluded != 0 || secondPreview.ExcludedToPDD != 0 { + t.Fatalf("expected a no-op second preview, got %+v", secondPreview) + } +} + +func TestRecomputePreviewCapsSamplesAt20(t *testing.T) { + db := testDB(t) + if err := db.Create(&models.SYBProductFilter{Kind: "keyword", Keyword: "档口", NormalizedKeyword: "档口", Enabled: true}).Error; err != nil { + t.Fatal(err) + } + for i := 0; i < 25; i++ { + row := models.SYBProduct{ + OrderCode: fmt.Sprintf("ORD-CAP-%d", i), DetailID: uint64(i + 1), StockID: uint64(i + 1), + ShopeeItemID: fmt.Sprintf("%d", i), Quantity: 1, ParseStatus: models.SYBParseStatusSuccess, + RawJSON: `{"variationSku":"档口-x"}`, + } + if err := db.Create(&row).Error; err != nil { + t.Fatal(err) + } + } + s := NewService(db) + preview, err := s.RecomputePreview(context.Background()) + if err != nil { + t.Fatal(err) + } + if preview.PDDToExcluded != 25 { + t.Fatalf("expected 25 counted changes, got %d", preview.PDDToExcluded) + } + if len(preview.Samples) != 20 { + t.Fatalf("expected samples capped at 20, got %d", len(preview.Samples)) + } +} + +func TestRecomputeExecuteRejectsStaleFingerprint(t *testing.T) { + db := testDB(t) + if err := db.Create(&models.SYBProductFilter{Kind: "keyword", Keyword: "档口", NormalizedKeyword: "档口", Enabled: true}).Error; err != nil { + t.Fatal(err) + } + row := models.SYBProduct{OrderCode: "ORD-STALE", DetailID: 1, StockID: 1, ShopeeItemID: "1", Quantity: 1, ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"档口-1"}`} + if err := db.Create(&row).Error; err != nil { + t.Fatal(err) + } + s := NewService(db) + preview, err := s.RecomputePreview(context.Background()) + if err != nil { + t.Fatal(err) + } + if preview.PDDToExcluded != 1 { + t.Fatalf("unexpected preview: %+v", preview) + } + + // Data changes after the preview: a second row appears that would also + // flip, so the plan's fingerprint is now stale. + row2 := models.SYBProduct{OrderCode: "ORD-STALE-2", DetailID: 2, StockID: 2, ShopeeItemID: "2", Quantity: 1, ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"档口-2"}`} + if err := db.Create(&row2).Error; err != nil { + t.Fatal(err) + } + + _, err = s.RecomputeExecute(context.Background(), "admin1", preview.Fingerprint) + if err == nil { + t.Fatalf("expected stale fingerprint to be rejected") + } + se, ok := err.(*ServiceError) + if !ok || se.Code != CodeRecomputeStale { + t.Fatalf("expected CodeRecomputeStale, got %v", err) + } + + var reloaded, reloaded2 models.SYBProduct + db.First(&reloaded, row.ID) + db.First(&reloaded2, row2.ID) + if reloaded.PDDExcluded || reloaded2.PDDExcluded { + t.Fatalf("stale execute must write nothing: %+v %+v", reloaded, reloaded2) + } + var logCount int64 + db.Model(&models.SYBProductFilterRecomputeLog{}).Count(&logCount) + if logCount != 0 { + t.Fatalf("stale execute must not write an audit log, got %d", logCount) + } + + freshPreview, err := s.RecomputePreview(context.Background()) + if err != nil { + t.Fatal(err) + } + if freshPreview.PDDToExcluded != 2 { + t.Fatalf("expected fresh preview to see both rows, got %+v", freshPreview) + } + if _, err := s.RecomputeExecute(context.Background(), "admin1", freshPreview.Fingerprint); err != nil { + t.Fatalf("fresh fingerprint should be accepted: %v", err) + } +} + +func TestRecomputeExecuteRejectsEmptyFingerprint(t *testing.T) { + db := testDB(t) + s := NewService(db) + if _, err := s.RecomputeExecute(context.Background(), "admin1", ""); err == nil { + t.Fatalf("expected empty fingerprint to be rejected") + } +} + +// TestWriteRecomputeChangesSkipsRowThatGotAPurchaseTaskConcurrently is #340 +// phase 3 review item 1(a): a purchase task created for a planned row AFTER +// planning but BEFORE the write phase must cause that row to be skipped +// (counted as SkippedHasTask), not written. +func TestWriteRecomputeChangesSkipsRowThatGotAPurchaseTaskConcurrently(t *testing.T) { + db := testDB(t) + row := models.SYBProduct{OrderCode: "ORD-RACE-TASK", DetailID: 1, StockID: 1, ShopeeItemID: "1", Quantity: 1, ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"档口-1"}`} + if err := db.Create(&row).Error; err != nil { + t.Fatal(err) + } + ruleID := uint64(9) + planned := []recomputeChange{{id: row.ID, orderCode: row.OrderCode, shopeeItemID: row.ShopeeItemID, toExcluded: true, ruleID: &ruleID, ruleKind: "keyword", ruleKeyword: "档口"}} + + if err := db.Create(&models.PurchaseTask{SYBProductID: &row.ID, PDDProductID: 1, Quantity: 1, CreateRequestID: "race-task", Status: models.PurchaseTaskStatusFailed, ExecutionMode: models.PurchaseExecutionModeLive, TaskType: models.PurchaseTaskTypeSYBOrder, RuleSnapshot: "{}"}).Error; err != nil { + t.Fatal(err) + } + + var actual RecomputeCounts + err := db.Transaction(func(tx *gorm.DB) error { + var writeErr error + actual, writeErr = writeRecomputeChanges(context.Background(), tx, planned) + return writeErr + }) + if err != nil { + t.Fatal(err) + } + if actual.SkippedHasTask != 1 || actual.PDDToExcluded != 0 { + t.Fatalf("expected the row to be skipped for having a task, got %+v", actual) + } + var reloaded models.SYBProduct + db.First(&reloaded, row.ID) + if reloaded.PDDExcluded { + t.Fatalf("row must not have been marked excluded: %+v", reloaded) + } +} + +// TestWriteRecomputeChangesSkipsRowWithConcurrentReturnMatch is item 1(b): +// same race, but with an active return match instead of a purchase task. +func TestWriteRecomputeChangesSkipsRowWithConcurrentReturnMatch(t *testing.T) { + db := testDB(t) + row := models.SYBProduct{OrderCode: "ORD-RACE-MATCH", DetailID: 1, StockID: 1, ShopeeItemID: "1", Quantity: 1, ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"档口-1"}`} + if err := db.Create(&row).Error; err != nil { + t.Fatal(err) + } + ruleID := uint64(9) + planned := []recomputeChange{{id: row.ID, orderCode: row.OrderCode, shopeeItemID: row.ShopeeItemID, toExcluded: true, ruleID: &ruleID, ruleKind: "keyword", ruleKeyword: "档口"}} + + if err := db.Create(&models.ReturnMatch{SYBProductID: row.ID, YeekeReturnItemID: 1, ActiveSYBProductID: &row.ID, Status: models.ReturnMatchStatusMatched}).Error; err != nil { + t.Fatal(err) + } + + var actual RecomputeCounts + err := db.Transaction(func(tx *gorm.DB) error { + var writeErr error + actual, writeErr = writeRecomputeChanges(context.Background(), tx, planned) + return writeErr + }) + if err != nil { + t.Fatal(err) + } + if actual.SkippedReturnMatch != 1 || actual.PDDToExcluded != 0 { + t.Fatalf("expected the row to be skipped for an active return match, got %+v", actual) + } +} + +// TestWriteRecomputeChangesReturnsCountsMatchingActualWrites is item 1(c): +// given a plan with one row that should write cleanly and one that races +// with a concurrently-created purchase task, writeRecomputeChanges' returned +// counts must equal exactly what it actually wrote — a real update for the +// clean row, and a skip (not a write) for the raced one. This is the same +// property RecomputeExecute relies on for its response and audit log +// (verified end-to-end, without a race, by TestRecomputePreviewMatchesExecute +// above; a race inside one execute call always changes the plan itself, so +// it surfaces as a fingerprint mismatch — see +// TestRecomputeExecuteRejectsStaleFingerprint — rather than reaching this +// path in a real request). +func TestWriteRecomputeChangesReturnsCountsMatchingActualWrites(t *testing.T) { + db := testDB(t) + rowA := models.SYBProduct{OrderCode: "ORD-ACTUAL-A", DetailID: 1, StockID: 1, ShopeeItemID: "1", Quantity: 1, ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"档口-1"}`} + rowB := models.SYBProduct{OrderCode: "ORD-ACTUAL-B", DetailID: 2, StockID: 2, ShopeeItemID: "2", Quantity: 1, ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"档口-2"}`} + if err := db.Create(&rowA).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&rowB).Error; err != nil { + t.Fatal(err) + } + ruleID := uint64(9) + planned := []recomputeChange{ + {id: rowA.ID, orderCode: rowA.OrderCode, shopeeItemID: rowA.ShopeeItemID, toExcluded: true, ruleID: &ruleID, ruleKind: "keyword", ruleKeyword: "档口"}, + {id: rowB.ID, orderCode: rowB.OrderCode, shopeeItemID: rowB.ShopeeItemID, toExcluded: true, ruleID: &ruleID, ruleKind: "keyword", ruleKeyword: "档口"}, + } + // Simulate a purchase task created for rowB strictly between planning + // and this write phase call. + if err := db.Create(&models.PurchaseTask{SYBProductID: &rowB.ID, PDDProductID: 1, Quantity: 1, CreateRequestID: "actual-task", Status: models.PurchaseTaskStatusFailed, ExecutionMode: models.PurchaseExecutionModeLive, TaskType: models.PurchaseTaskTypeSYBOrder, RuleSnapshot: "{}"}).Error; err != nil { + t.Fatal(err) + } + + var actual RecomputeCounts + err := db.Transaction(func(tx *gorm.DB) error { + var writeErr error + actual, writeErr = writeRecomputeChanges(context.Background(), tx, planned) + return writeErr + }) + if err != nil { + t.Fatal(err) + } + if actual.PDDToExcluded != 1 || actual.SkippedHasTask != 1 { + t.Fatalf("expected one written and one skipped, got %+v", actual) + } + var reloadedA, reloadedB models.SYBProduct + db.First(&reloadedA, rowA.ID) + db.First(&reloadedB, rowB.ID) + if !reloadedA.PDDExcluded { + t.Fatalf("rowA should have been marked excluded") + } + if reloadedB.PDDExcluded { + t.Fatalf("rowB must not have been marked excluded (has a task)") + } +} + +func TestMarkedCountsByRule(t *testing.T) { + db := testDB(t) + ruleA := models.SYBProductFilter{Kind: "keyword", Keyword: "档口A", NormalizedKeyword: "档口a", Enabled: true} + ruleB := models.SYBProductFilter{Kind: "keyword", Keyword: "档口B", NormalizedKeyword: "档口b", Enabled: true} + if err := db.Create(&ruleA).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&ruleB).Error; err != nil { + t.Fatal(err) + } + ruleAID, ruleBID := ruleA.ID, ruleB.ID + for i := 0; i < 3; i++ { + row := models.SYBProduct{OrderCode: fmt.Sprintf("ORD-MARKED-A-%d", i), DetailID: uint64(i + 1), StockID: uint64(i + 1), ShopeeItemID: fmt.Sprintf("a%d", i), Quantity: 1, ParseStatus: models.SYBParseStatusSuccess, RawJSON: "{}", PDDExcluded: true, ExcludedRuleID: &ruleAID} + if err := db.Create(&row).Error; err != nil { + t.Fatal(err) + } + } + row := models.SYBProduct{OrderCode: "ORD-MARKED-B-0", DetailID: 100, StockID: 100, ShopeeItemID: "b0", Quantity: 1, ParseStatus: models.SYBParseStatusSuccess, RawJSON: "{}", PDDExcluded: true, ExcludedRuleID: &ruleBID} + if err := db.Create(&row).Error; err != nil { + t.Fatal(err) + } + unmarked := models.SYBProduct{OrderCode: "ORD-MARKED-NONE", DetailID: 200, StockID: 200, ShopeeItemID: "none", Quantity: 1, ParseStatus: models.SYBParseStatusSuccess, RawJSON: "{}"} + if err := db.Create(&unmarked).Error; err != nil { + t.Fatal(err) + } + + s := NewService(db) + resp, err := s.List(context.Background(), ListRequest{Page: 1, PageSize: 50}) + if err != nil { + t.Fatal(err) + } + byID := make(map[uint64]int64, len(resp.Items)) + for _, item := range resp.Items { + byID[item.ID] = item.MarkedCount + } + if byID[ruleAID] != 3 { + t.Fatalf("expected rule A marked count 3, got %d", byID[ruleAID]) + } + if byID[ruleBID] != 1 { + t.Fatalf("expected rule B marked count 1, got %d", byID[ruleBID]) + } +} + +// TestRecomputeFingerprintChangesWhenRuleEvidenceChanges is #340 phase 4 +// review item 1: the fingerprint must depend on the rule's kind/keyword, not +// just its id — because those are exactly what RecomputeExecute is about to +// write into excluded_rule_kind/excluded_rule_keyword. Same rule id, same +// affected product, same direction, but the rule's own keyword changed +// between preview and execute (simulated by editing the row directly since +// the API has no edit endpoint) must be rejected as stale. +func TestRecomputeFingerprintChangesWhenRuleEvidenceChanges(t *testing.T) { + db := testDB(t) + rule := models.SYBProductFilter{Kind: "keyword", Keyword: "档口", NormalizedKeyword: "档口", Enabled: true} + if err := db.Create(&rule).Error; err != nil { + t.Fatal(err) + } + row := models.SYBProduct{OrderCode: "ORD-RULE-EDIT", DetailID: 1, StockID: 1, ShopeeItemID: "1", Quantity: 1, ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"档口-1"}`} + if err := db.Create(&row).Error; err != nil { + t.Fatal(err) + } + + s := NewService(db) + preview, err := s.RecomputePreview(context.Background()) + if err != nil { + t.Fatal(err) + } + if preview.PDDToExcluded != 1 || len(preview.Samples) != 1 || preview.Samples[0].RuleKeyword != "档口" { + t.Fatalf("unexpected preview: %+v", preview) + } + + // The rule's own keyword and normalized_keyword change (same id, same + // kind, still matches the same variationSku prefix) — the plan's set of + // affected ids/directions is unchanged, but the evidence that would be + // written is not. + if err := db.Model(&models.SYBProductFilter{}).Where("id = ?", rule.ID). + Updates(map[string]any{"keyword": "档口新", "normalized_keyword": "档口"}).Error; err != nil { + t.Fatal(err) + } + + _, err = s.RecomputeExecute(context.Background(), "admin1", preview.Fingerprint) + if err == nil { + t.Fatalf("expected the changed rule evidence to be rejected as stale") + } + se, ok := err.(*ServiceError) + if !ok || se.Code != CodeRecomputeStale { + t.Fatalf("expected CodeRecomputeStale, got %v", err) + } + + var reloaded models.SYBProduct + db.First(&reloaded, row.ID) + if reloaded.PDDExcluded { + t.Fatalf("nothing should have been written: %+v", reloaded) + } + var logCount int64 + db.Model(&models.SYBProductFilterRecomputeLog{}).Count(&logCount) + if logCount != 0 { + t.Fatalf("no audit log row should have been written, got %d", logCount) + } + + // A fresh preview reflects the new keyword and executes normally. + freshPreview, err := s.RecomputePreview(context.Background()) + if err != nil { + t.Fatal(err) + } + if freshPreview.Samples[0].RuleKeyword != "档口新" { + t.Fatalf("expected fresh preview to see the new keyword, got %+v", freshPreview.Samples) + } + if _, err := s.RecomputeExecute(context.Background(), "admin1", freshPreview.Fingerprint); err != nil { + t.Fatalf("fresh fingerprint should be accepted: %v", err) + } + db.First(&reloaded, row.ID) + if !reloaded.PDDExcluded || reloaded.ExcludedRuleKeyword != "档口新" { + t.Fatalf("expected the row to be excluded with the new keyword snapshot: %+v", reloaded) + } +} + +// TestRecomputeFingerprintStableAcrossUnchangedPreviews is the companion +// regression: an unchanged dataset must give the SAME fingerprint on two +// consecutive previews (map/slice iteration order must never leak into the +// hash), and that fingerprint must still execute successfully. +func TestRecomputeFingerprintStableAcrossUnchangedPreviews(t *testing.T) { + db := testDB(t) + if err := db.Create(&models.SYBProductFilter{Kind: "keyword", Keyword: "档口", NormalizedKeyword: "档口", Enabled: true}).Error; err != nil { + t.Fatal(err) + } + for i := 0; i < 5; i++ { + row := models.SYBProduct{OrderCode: fmt.Sprintf("ORD-STABLE-%d", i), DetailID: uint64(i + 1), StockID: uint64(i + 1), ShopeeItemID: fmt.Sprintf("s%d", i), Quantity: 1, ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"档口-x"}`} + if err := db.Create(&row).Error; err != nil { + t.Fatal(err) + } + } + + s := NewService(db) + first, err := s.RecomputePreview(context.Background()) + if err != nil { + t.Fatal(err) + } + second, err := s.RecomputePreview(context.Background()) + if err != nil { + t.Fatal(err) + } + if first.Fingerprint == "" || first.Fingerprint != second.Fingerprint { + t.Fatalf("expected a stable, non-empty fingerprint across two previews of the same data: %q vs %q", first.Fingerprint, second.Fingerprint) + } + if _, err := s.RecomputeExecute(context.Background(), "admin1", second.Fingerprint); err != nil { + t.Fatalf("unchanged-data fingerprint should execute successfully: %v", err) + } +} diff --git a/server/app/goauto/sybproductfilter/router.go b/server/app/goauto/sybproductfilter/router.go index 6a6c721..c6a34b9 100644 --- a/server/app/goauto/sybproductfilter/router.go +++ b/server/app/goauto/sybproductfilter/router.go @@ -12,4 +12,6 @@ func InitRouter(e *gin.Engine, a *jwt.GinJWTMiddleware) { g.POST("", middleware.RequireRoleKey("admin"), Handler{}.Create) g.PATCH("/:filterId/enabled", middleware.RequireRoleKey("admin"), Handler{}.SetEnabled) g.DELETE("/:filterId", middleware.RequireRoleKey("admin"), Handler{}.Delete) + g.GET("/recompute/preview", middleware.RequireRoleKey("admin"), Handler{}.RecomputePreview) + g.POST("/recompute/execute", middleware.RequireRoleKey("admin"), Handler{}.RecomputeExecute) } diff --git a/server/app/goauto/sybproductfilter/service.go b/server/app/goauto/sybproductfilter/service.go index 22e8806..fe42426 100644 --- a/server/app/goauto/sybproductfilter/service.go +++ b/server/app/goauto/sybproductfilter/service.go @@ -30,6 +30,10 @@ const ( CodeNotFound = "SYB_PRODUCT_FILTER_NOT_FOUND" CodeConflict = "SYB_PRODUCT_FILTER_DUPLICATE" CodeForbidden = "FORBIDDEN" + // CodeRecomputeStale is returned by RecomputeExecute when the plan its + // fingerprint describes no longer matches the current data/rules (#340 + // phase 3 review item 2): the caller must re-preview before executing. + CodeRecomputeStale = "RECOMPUTE_PREVIEW_STALE" ) func invalid(m string) error { return &ServiceError{Code: CodeInvalidRequest, Message: m} } @@ -46,12 +50,24 @@ type ListRequest struct { Kind, Keyword string EnabledOnly bool } + +// FilterItem adds MarkedCount to the stored rule: the REAL, live count of +// syb_product rows currently marked pdd_purchase_excluded by this rule +// (#340 phase 3 review item 3), as opposed to LastHitCount which is a +// point-in-time snapshot written back by the most recent sync run and never +// updated by a recompute. It is deliberately not a model field — it is +// derived, never stored. +type FilterItem struct { + models.SYBProductFilter + MarkedCount int64 `json:"markedCount"` +} + type ListResponse struct { - Items []models.SYBProductFilter `json:"items"` - Total int64 `json:"total"` - EnabledCount int64 `json:"enabledCount"` - Page int `json:"page"` - PageSize int `json:"pageSize"` + Items []FilterItem `json:"items"` + Total int64 `json:"total"` + EnabledCount int64 `json:"enabledCount"` + Page int `json:"page"` + PageSize int `json:"pageSize"` } func (s *Service) List(ctx context.Context, r ListRequest) (ListResponse, error) { @@ -75,16 +91,46 @@ func (s *Service) List(ctx context.Context, r ListRequest) (ListResponse, error) if err := q.Count(&total).Error; err != nil { return ListResponse{}, internal(err) } - items := make([]models.SYBProductFilter, 0, r.PageSize) - if err := q.Order("kind ASC, id ASC").Offset((r.Page - 1) * r.PageSize).Limit(r.PageSize).Find(&items).Error; err != nil { + rows := make([]models.SYBProductFilter, 0, r.PageSize) + if err := q.Order("kind ASC, id ASC").Offset((r.Page - 1) * r.PageSize).Limit(r.PageSize).Find(&rows).Error; err != nil { return ListResponse{}, internal(err) } if err := s.DB.WithContext(ctx).Model(&models.SYBProductFilter{}).Where("enabled = ?", true).Count(&enabled).Error; err != nil { return ListResponse{}, internal(err) } + marked, err := s.markedCountsByRule(ctx) + if err != nil { + return ListResponse{}, internal(err) + } + items := make([]FilterItem, 0, len(rows)) + for _, row := range rows { + items = append(items, FilterItem{SYBProductFilter: row, MarkedCount: marked[row.ID]}) + } return ListResponse{Items: items, Total: total, EnabledCount: enabled, Page: r.Page, PageSize: r.PageSize}, nil } +// markedCountsByRule is the single grouped query (no N+1) behind MarkedCount: +// COUNT(*) FROM syb_product WHERE pdd_purchase_excluded = 1, grouped by +// excluded_rule_id, covering every rule at once regardless of page size. +func (s *Service) markedCountsByRule(ctx context.Context) (map[uint64]int64, error) { + type row struct { + ExcludedRuleID uint64 + Count int64 + } + var rows []row + if err := s.DB.WithContext(ctx).Model(&models.SYBProduct{}). + Select("excluded_rule_id, COUNT(*) AS count"). + Where("pdd_purchase_excluded = ? AND excluded_rule_id IS NOT NULL", true). + Group("excluded_rule_id").Scan(&rows).Error; err != nil { + return nil, err + } + result := make(map[uint64]int64, len(rows)) + for _, r := range rows { + result[r.ExcludedRuleID] = r.Count + } + return result, nil +} + type CreateRequest struct { Kind string `json:"kind"` Keyword string `json:"keyword"` diff --git a/server/app/goauto/task/image_search.go b/server/app/goauto/task/image_search.go index a3a6e2f..a3419d7 100644 --- a/server/app/goauto/task/image_search.go +++ b/server/app/goauto/task/image_search.go @@ -158,6 +158,11 @@ func (service *Service) BatchCreateImageSearch(ctx context.Context, request Imag response.Items = append(response.Items, ImageSearchBatchItem{SYBProductIDs: []uint64{id}, Code: "SYB_PRODUCT_UNAVAILABLE", Message: "SYB 商品不存在或未关联蝦皮商品"}) continue } + // #340: hard-exclude before grouping/creating any image search task. + if row.PDDExcluded { + response.Items = append(response.Items, ImageSearchBatchItem{SYBProductIDs: []uint64{id}, Code: "PURCHASE_PDD_EXCLUDED", Message: "该商品已标记为无需 PDD 采购"}) + continue + } if index, ok := groups[*row.ShopeeProductID]; ok { response.Items[index].SYBProductIDs = append(response.Items[index].SYBProductIDs, id) } else { diff --git a/server/app/goauto/task/pdd_excluded_test.go b/server/app/goauto/task/pdd_excluded_test.go new file mode 100644 index 0000000..3656c8f --- /dev/null +++ b/server/app/goauto/task/pdd_excluded_test.go @@ -0,0 +1,44 @@ +package task + +import ( + "context" + "testing" + + "go-admin/app/goauto/models" + + "github.com/google/uuid" +) + +// #340: image search collection is a PDD entry point keyed by sybProductIds; +// an excluded row must be rejected without touching the batch's other rows. +func TestBatchCreateImageSearchRejectsExcludedRow(t *testing.T) { + db := openTaskDatabase(t) + rule := models.CollectionRule{Name: "image-search-excluded", ContentJSON: v2TaskRuleSnapshot()} + if err := db.Create(&rule).Error; err != nil { + t.Fatal(err) + } + shopee := models.ShopeeProduct{ShopeeItemID: "excl-1", Title: "t", Currency: "CNY", SpecsJSON: "[]"} + if err := db.Create(&shopee).Error; err != nil { + t.Fatal(err) + } + syb := models.SYBProduct{OrderCode: "ORD-EXCL", DetailID: 1, StockID: 1, ShopeeItemID: shopee.ShopeeItemID, ShopeeProductID: &shopee.ID, Quantity: 1, UnitPriceCent: 100, ImageURL: "https://example.invalid/excl.jpg", ParseStatus: "success", RawJSON: "{}", PDDExcluded: true} + if err := db.Create(&syb).Error; err != nil { + t.Fatal(err) + } + service := NewService(db) + service.FetchImageSearchImage = func(ctx context.Context, url string) (ImageSearchImage, error) { + t.Fatalf("must not fetch image for an excluded row") + return ImageSearchImage{}, nil + } + request := ImageSearchBatchRequest{RequestID: uuid.NewString(), SYBProductIDs: []uint64{syb.ID}, RuleID: rule.ID} + response, err := service.BatchCreateImageSearch(context.Background(), request) + if err != nil { + t.Fatalf("batch call itself must not fail: %v", err) + } + if response.SuccessCount != 0 || len(response.Items) != 1 { + t.Fatalf("expected the excluded row to fail, got %+v", response) + } + if response.Items[0].Code != "PURCHASE_PDD_EXCLUDED" { + t.Fatalf("expected PURCHASE_PDD_EXCLUDED, got %+v", response.Items[0]) + } +} diff --git a/server/cmd/migrate/migration/version-local/1789801500000_syb_pdd_purchase_excluded.go b/server/cmd/migrate/migration/version-local/1789801500000_syb_pdd_purchase_excluded.go new file mode 100644 index 0000000..5bdc73d --- /dev/null +++ b/server/cmd/migrate/migration/version-local/1789801500000_syb_pdd_purchase_excluded.go @@ -0,0 +1,26 @@ +package version_local + +import ( + "go-admin/app/goauto/migrations" + "go-admin/cmd/migrate/migration" + common "go-admin/common/models" + "gorm.io/gorm" + "runtime" +) + +// #340: adds syb_product's pdd_purchase_excluded/excluded_rule_*/excluded_at +// columns (additive, all default to "需 PDD 采购") and creates +// syb_product_filter_recompute_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), migrateSYBPDDPurchaseExcluded) +} +func migrateSYBPDDPurchaseExcluded(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 + }) +} diff --git a/web/src/api/goauto/syb-product-filters.js b/web/src/api/goauto/syb-product-filters.js index 0cc607b..ea3b748 100644 --- a/web/src/api/goauto/syb-product-filters.js +++ b/web/src/api/goauto/syb-product-filters.js @@ -4,3 +4,5 @@ export function listSybProductFilters(params) { return request({ url: base, meth export function createSybProductFilter(data) { return request({ url: base, method: 'post', data }) } export function setSybProductFilterEnabled(id, data) { return request({ url: `${base}/${id}/enabled`, method: 'patch', data }) } export function deleteSybProductFilter(id) { return request({ url: `${base}/${id}`, method: 'delete' }) } +export function previewSybProductFilterRecompute() { return request({ url: `${base}/recompute/preview`, method: 'get' }) } +export function executeSybProductFilterRecompute(fingerprint) { return request({ url: `${base}/recompute/execute`, method: 'post', data: { fingerprint } }) } diff --git a/web/src/views/goauto/syb-product-filters/index.vue b/web/src/views/goauto/syb-product-filters/index.vue index 7756b71..4f3c57d 100644 --- a/web/src/views/goauto/syb-product-filters/index.vue +++ b/web/src/views/goauto/syb-product-filters/index.vue @@ -28,8 +28,15 @@ />
-

结构过滤(不可增删,仅可停用)

-

variationSku 需要同时包含下方列出的每一个字符,才会被当成档口商品不入库;只包含其中一个的会正常入库。需要变更判据请另行建单评估。

+
+

结构过滤(不可增删,仅可停用)

+ + + 按当前规则重算 + + +
+

variationSku 需要同时包含下方列出的每一个字符,才会被标记为无需 PDD 采购;只包含其中一个的仍按需要 PDD 采购处理。需要变更判据请另行建单评估。

@@ -116,7 +123,7 @@
-

停用或删除只影响后续同步,已经入库的商品明细不会被删除。

+

停用或删除规则只影响之后的同步;已经标记的商品明细不会自动变化,需要点击「按当前规则重算」才会按新规则重新判定。

@@ -140,6 +147,57 @@ 保存 + + +
+ + +
+ +
@@ -150,9 +208,15 @@ import { listSybProductFilters, createSybProductFilter, setSybProductFilterEnabled, - deleteSybProductFilter + deleteSybProductFilter, + previewSybProductFilterRecompute, + executeSybProductFilterRecompute } from '@/api/goauto/syb-product-filters' +function emptyRecomputeResult() { + return { pddToExcluded: 0, excludedToPdd: 0, skippedHasTask: 0, skippedReturnMatch: 0, samples: [], fingerprint: '' } +} + export default { name: 'GoAutoSybProductFilters', data() { @@ -161,12 +225,26 @@ export default { chars: [], keywords: [], query: { keyword: '' }, - editor: { open: false, saving: false, keyword: '', note: '' } + editor: { open: false, saving: false, keyword: '', note: '' }, + recompute: { + open: false, + loading: false, + executing: false, + loaded: false, + error: '', + result: emptyRecomputeResult() + } } }, computed: { isAdmin() { return (this.$store.getters.roles || []).includes('admin') + }, + recomputeButtonReason() { + return this.isAdmin ? '' : '只有管理员可以重算' + }, + recomputeTotalChanges() { + return this.recompute.result.pddToExcluded + this.recompute.result.excludedToPdd } }, created() { @@ -205,12 +283,15 @@ export default { const who = row.lastChangedBy || '-' return `${this.formatTime(row.lastChangedAt)} ${who} ${row.enabled ? '启用' : '停用'}` }, - // 停用结构规则会让大量档口明细恢复入库,所以必须二次确认。 - // 条数取该行实时的 lastHitCount,不能写死:数据会变,写死的警告会骗人(#269)。 + // 停用结构规则不会撤销已有标记,所以必须二次确认。 + // #340 phase 3:警告文字改用该规则当前实时的 markedCount(服务端按 + // excluded_rule_id 分组统计的真实数量),不再用 lastHitCount——后者只是 + // 上一次同步的快照,规则改动或重算之后会和当前标记数脱节,继续用它会 + // 把过期数字当成现状讲给管理员听(#269 沿用的旧口径在 #340 后不再准确)。 async confirmDisableChar(row) { - const scale = row.lastHitCount == null ? '尚不清楚有多少' : `约 ${row.lastHitCount} 条` + const scale = row.markedCount == null ? '尚不清楚有多少' : `当前约 ${row.markedCount} 条` await ElMessageBox.confirm( - `停用后,SYB 同步将不再过滤任何档口商品(结构过滤只有这一条)。按最近一次同步数据估算,${scale}明细会恢复入库。`, + `停用后,SYB 同步将不再把任何档口商品标记为无需 PDD 采购(结构过滤只有这一条)。这只影响之后的同步,${scale}明细带此规则的无需采购标记不会自动变化,需要使用「按当前规则重算」才会更新。`, `确认停用结构过滤「${row.keyword}」?`, { type: 'warning', confirmButtonText: '确认停用', cancelButtonText: '取消', confirmButtonClass: 'el-button--danger' } ) @@ -267,6 +348,46 @@ export default { await deleteSybProductFilter(row.id) ElMessage.success('关键词已删除') this.load() + }, + resetRecompute() { + this.recompute = { open: false, loading: false, executing: false, loaded: false, error: '', result: emptyRecomputeResult() } + }, + async openRecompute() { + this.recompute.open = true + this.recompute.loading = true + this.recompute.loaded = false + this.recompute.error = '' + try { + const res = await previewSybProductFilterRecompute() + this.recompute.result = { ...emptyRecomputeResult(), ...(res.data || {}) } + this.recompute.loaded = true + } catch (e) { + this.recompute.error = (e && e.message) || '预览失败,请重试' + } finally { + this.recompute.loading = false + } + }, + // #340 phase 3:执行必须带上预览返回的 fingerprint;服务端会在写入前用 + // 当前数据重新计算一次并比对,不一致(数据或规则在预览之后变化了)会 + // 返回 RECOMPUTE_PREVIEW_STALE(409),此时不写入任何数据,这里提示后 + // 自动重新预览,而不是让用户对着过期的计划再点一次「确认执行」。 + async confirmRecompute() { + this.recompute.executing = true + try { + await executeSybProductFilterRecompute(this.recompute.result.fingerprint) + ElMessage.success('已按当前规则重新计算') + this.recompute.open = false + this.load() + } catch (e) { + if (e?.response?.data?.code === 'RECOMPUTE_PREVIEW_STALE' || e?.data?.code === 'RECOMPUTE_PREVIEW_STALE') { + ElMessage.warning('数据或规则已变化,请重新预览后再执行') + this.openRecompute() + return + } + throw e + } finally { + this.recompute.executing = false + } } } } @@ -312,4 +433,26 @@ h3 { .scope-note { margin-top: 12px; } +.recompute-counts { + display: flex; + gap: 16px; + margin-bottom: 16px; +} +.count-item { + flex: 1; + text-align: center; + padding: 12px; + background: #f5f7fa; + border-radius: 4px; +} +.count-value { + font-size: 22px; + font-weight: 600; + color: #303133; +} +.count-label { + font-size: 12px; + color: #909399; + margin-top: 4px; +} diff --git a/web/src/views/goauto/syb-products/index.vue b/web/src/views/goauto/syb-products/index.vue index d0e1517..cac047a 100644 --- a/web/src/views/goauto/syb-products/index.vue +++ b/web/src/views/goauto/syb-products/index.vue @@ -12,7 +12,8 @@ - + + 查询重置AI 匹配{{ aiMatchCandidates.length }}图搜采集{{ imageSearchRows.length }}创建采集{{ collectionCandidates.length }}创建采购{{ purchaseCandidates.length }}匹配退货{{ returnMatchCandidateIds.length }}取消匹配{{ returnCancelCandidates.length }}使用退货{{ returnConfirmCandidates.length }}
@@ -37,6 +38,7 @@ {{ purchaseReady(row).processStageLabel || processMeta(purchaseReady(row).processStage).label }}
{{ purchaseReady(row).processStageReason || purchaseReady(row).reason || '请刷新后重试' }}
{{ purchasePriceText(purchaseReady(row)) }}
+
规则:{{ excludedRuleText(row) }}