diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index df0c1f3..d4c5604 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: 092d7df7156b270dbb2528eb8f28f40640b23cd2 -synchronized_at: 2026-08-22T02:10:40Z +wiki_revision: 1e8dff56860d92c22cba44b2e9c966e58f67d721 +synchronized_at: 2026-08-22T02:37:22Z # 业务规则与术语 @@ -36,7 +36,7 @@ synchronized_at: 2026-08-22T02:10:40Z ## SYB 商品明细 -- 一行对应 SYB 一条明确的商品/颜色/尺码/数量明细;唯一键为「货运单 `code` + 来源明细 `id`」组合,不是全局唯一 ID(未在多货运单样本中验证过全局唯一性)。 +- 一行对应 SYB 一条明确的商品/颜色/尺码/数量明细;唯一键为「蝦皮订单号 `code` + 来源明细 `id`」组合,不是全局唯一 ID(未在多货运单样本中验证过全局唯一性)。 - `productSpec` 自由文本解析:按最后一个逗号切分为颜色/尺码 → 剥离【】备注 → 判断残留分隔符;解析状态分 `success`/`uncertain`/`failed`,永不猜测原文中不存在的颜色或尺码。 - 只有 `success` 状态的解析结果会合并进虾皮商品档案的规格值(来源标记 `import`);`uncertain`/`failed` 只停留在明细行上供人工复核,不污染共享档案。 - 金额单位按接口分别定义:`/am/stock/detail/listByStock` 的 `productPrice` 是十进制金额,与 `/am/stock/list` 的 `amtOrder`(×100 整数)不是同一套换算,不可共用。 @@ -58,7 +58,7 @@ synchronized_at: 2026-08-22T02:10:40Z - 导入由管理员创建,创建成功后立即转入后台执行;页面关闭不取消任务。采购员等其他已登录角色可以查看同步记录,不能开始导入。 - 系统同一时刻只运行一个 SYB 导入任务。`syb_sync_run` 的唯一执行槽负责跨进程互斥,内存锁减少同一进程内的竞争;服务重启后遗留的执行中记录标记为“已中断”。 -- 导入失败或中断时保留已写入商品,记录明确的失败原因和已处理进度;重新导入相同范围按「货运单号 + 明细 ID」覆盖,不产生重复商品。 +- 导入失败或中断时保留已写入商品,记录明确的失败原因和已处理进度;重新导入相同范围按「订单号 + 明细 ID」覆盖,不产生重复商品。 - 每条同步记录保存本次启用店铺集合的 SHA-256 哈希以及各店铺“已导入/已跳过”数量,不保存账号、密码、Cookie、验证码图片或 SYB 原始响应。 - 后台任务创建前的内部失败仍向普通用户显示“服务端处理失败”,但使用稳定错误码区分阶段:店铺预检为 `SYNC_SHOP_PREFLIGHT_FAILED`,同步记录创建为 `SYNC_RUN_CREATE_FAILED`。服务端只记录阶段和经过脱敏、截断的底层原因,不记录请求体、凭据、Cookie、Token、验证码或原始响应。 @@ -109,7 +109,7 @@ synchronized_at: 2026-08-22T02:10:40Z - 采购域使用独立的 `purchase_task`、`purchase_task_attempt` 和可选 `pdd_account` 引用;PDD、虾皮和 SYB 商品表不保存采购订单、支付、物流或回填字段。 - 正式任务必须引用一条 SYB 商品明细;演练任务可以从 PDD 商品人工创建且不引用 SYB。 -- 创建时固化三个商品身份、目标和映射规格、数量、价格区间、币种、URL、`goods_id`、规则、设备和可选账号引用。商品档案后续修改不改变任务解释。 +- 创建时固化三个商品身份、蝦皮订单号、目标和映射规格、数量、价格区间、币种、URL、`goods_id`、规则、设备和可选账号引用。商品档案后续修改不改变任务解释。正式任务的蝦皮订单号来自 SYB 商品 `order_code`,同一订单号可以对应多条商品和多个采购任务,不作为唯一键。 - Android 无法可靠识别登录中的 PDD 账号,因此账号引用可空;已知账号才参与账号级串行,未知账号不会阻止采购任务。 - 同一 SYB 明细可以保留多个历史采购任务,但最多只能有一个活动任务。重新采购新建任务,旧订单不删除、不覆盖。 - `execution_mode` 创建后不可变:`rehearsal` 只能完成商品、规格、数量和价格复核;`live` 才可能获得改地址和创建订单能力。任何模式永远禁止支付。 @@ -133,7 +133,7 @@ synchronized_at: 2026-08-22T02:10:40Z - 已创建订单默认禁止再次采购;管理员或采购员可以做一次性重新采购授权,新任务创建成功时在同一事务消耗授权,旧任务和旧订单保留。已标记为已支付的订单不能授权或创建重新采购任务。 - 人工回填候选只允许从已支付订单选择;同一 SYB 明细后来选择的订单覆盖旧候选,但不删除旧订单事实。 - Admin 采购管理只查看和处理已有任务,不提供创建入口或支付按钮;单条和批量采购任务都从 SYB 商品列表发起。订单结果未知时必须先人工核对并解除;处于该状态时页面不提供重新采购授权。 -- 采购失败任务可在采购管理当前页批量勾选重试,最多 100 条。重试不修改旧任务,而是用当前 SYB/PDD 档案、当前规格映射、当前价格保护和最新内置采购规则创建新的 `pending` 任务,并生成新任务编号与地址后缀。 +- 采购失败任务可在采购管理当前页批量勾选重试,最多 100 条。重试不修改旧任务,而是用当前 SYB/PDD 档案、当前规格映射、当前价格保护和最新内置采购规则创建新的 `pending` 任务,并生成新任务编号与地址后缀;来源蝦皮订单号沿用失败任务的不可变快照。 - 只有正式采购、未进入不可逆边界、没有订单号或下单时间、且仍是同一 SYB 最新记录的 `failed` 任务可重试。原设备离线、停用、忙碌或能力不足时该项失败且不自动换机;未指定设备时仍由空闲设备领取。 - 批量重试逐项处理并允许部分成功;同一请求幂等重放不会重复创建。失败任务不再使用一次性重新采购授权,该授权只保留给已经创建过订单且满足条件的任务。 diff --git a/docs/08-agent-api-contract.md b/docs/08-agent-api-contract.md index 28a3305..b791fb6 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: 092d7df7156b270dbb2528eb8f28f40640b23cd2 -synchronized_at: 2026-08-22T02:10:40Z +wiki_revision: 47ecaff133f837394c3d14a9819c37e90855d801 +synchronized_at: 2026-08-22T02:37:41Z # MVP 共享 API 契约 @@ -74,7 +74,7 @@ PATCH /api/admin/v1/syb-products/{productId}/correction `sync-runs` 列表支持 `page`、`pageSize`、`status`、`dateFrom`、`dateTo`,详情返回日期范围、状态(`running` / `succeeded` / `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`),不含原始 JSON;`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 不存在通常是操作员选错了页面)。 @@ -386,7 +386,7 @@ POST /api/agent/v1/tasks/{taskId}/fail | `DEVICE_BUSY` | 设备已有活动任务 | 否 | | `DEVICE_CAPABILITY_MISMATCH` | 设备缺少任务规则要求的版本化能力 | 否 | -## 采购任务共享契约(#33、#34、#36、#42、#44、#53) +## 采购任务共享契约(#33、#34、#36、#42、#44、#53、#67) 本节固定采购域的数据和接口边界;`purchase_task`、`purchase_task_attempt`、规则校验、服务端 HTTP 状态机、Admin 已有任务管理、SYB 创建入口、Android 演练执行及 #36 正式地址/订单动作已经落地。#36 尚未获得真机创建订单授权和验收。任何实现都不得扩展为自动支付。 @@ -394,7 +394,7 @@ POST /api/agent/v1/tasks/{taskId}/fail - `executionMode` 只能是 `rehearsal` 或 `live`,创建后不可修改。 - 正式 `live` 任务必须引用一条 `syb_product`;无副作用的 `rehearsal` 可以不引用 SYB。 -- 创建时固化 SYB、虾皮商品、PDD 商品、目标规格、映射规格、数量、价格区间、币种、URL、`goods_id`、规则和可选 PDD 账号引用。以后商品档案修改不会改写任务。 +- 创建时固化 SYB、蝦皮订单号、虾皮商品、PDD 商品、目标规格、映射规格、数量、价格区间、币种、URL、`goods_id`、规则和可选 PDD 账号引用。正式任务的 `shopeeOrderNoSnapshot` 来自创建时的 `syb_product.order_code`;同一订单号可对应多条任务,之后来源商品修改不会改写任务快照。 - Android 无法可靠读取当前 PDD 账号,因此 `pddAccountId` 和 `pddAccountRefSnapshot` 均可空。已知账号时参与账号级串行,未知时不阻止任务。 - 地址后缀由任务 ID 唯一确定为 `_cg{taskId}`;#34 创建任务时必须在同一事务内回写快照。 - 一个任务最多对应一个 PDD 订单;重新采购必须新建任务,旧任务和旧订单保留。 @@ -477,7 +477,7 @@ POST /api/agent/v1/tasks/{taskId}/fail | 方法 | 路径 | 幂等键 / 说明 | |---|---|---| -| `GET` | `/api/admin/v1/purchase-tasks` | 分页列表;可按任务 ID、状态、模式、SYB 商品 ID 和 PDD 订单号筛选 | +| `GET` | `/api/admin/v1/purchase-tasks` | 分页列表;可按任务 ID、蝦皮订单号 `shopeeOrderNo`、状态、模式、SYB 商品 ID 和 PDD 订单号筛选 | | `GET` | `/api/admin/v1/purchase-tasks/{taskId}` | 只读任务详情与 attempt 历史;不返回规则原文、Token、凭据、完整地址、控件树或截图 | | `POST` | `/api/admin/v1/purchase-tasks` | `requestId`;单条创建 | | `POST` | `/api/admin/v1/purchase-tasks/batch-preview` | `sybProductIds`(1~100)和可选 `deviceId`;逐条返回是否可创建、价格区间、原因和下一步,不创建任务 | @@ -489,7 +489,7 @@ POST /api/agent/v1/tasks/{taskId}/fail | `POST` | `/api/admin/v1/purchase-tasks/{taskId}/resolve-unknown` | 人工把结果未知解除为已创建订单或已取消 | | `POST` | `/api/admin/v1/purchase-tasks/{taskId}/spec-decision` | 固化第一趟规格探测决策;同一 attempt 不可改写 | -Admin 列表与详情由 #35 实现;#44 在 SYB 商品列表提供单条/当前页批量创建入口。页面预检只负责提前解释,`batch` 提交时仍逐条执行现有单任务事务和活动任务、订单、支付、设备能力门禁;返回项包含任务号,或失败原因与下一步建议。只有管理员和采购员可以调用。 +Admin 列表与详情由 #35 实现;#67 增加 `shopeeOrderNoSnapshot` 的列表/详情返回和 `shopeeOrderNo` 筛选,空快照返回空字符串并由页面显示“—”,不与 `pddOrderNo` 混用;#44 在 SYB 商品列表提供单条/当前页批量创建入口。页面预检只负责提前解释,`batch` 提交时仍逐条执行现有单任务事务和活动任务、订单、支付、设备能力门禁;返回项包含任务号,或失败原因与下一步建议。只有管理员和采购员可以调用。 创建请求的价格保护使用整数分:`referenceUnitPriceCent`、`minUnitPriceCent`、`maxUnitPriceCent` 和 `currency`。执行时以 PDD App 实际单价校验;低于最小值或高于最大值均返回普通人可理解的价格越界错误,不考虑优惠券,不以订单总价替代单价判断。 #44 批量创建不接受浏览器提交价格:服务端从 PDD 颜色规格价格生成 CNY 快照,已映射颜色按该颜色的 0.2~1.5 倍,待规格探测时按全部可用颜色最低价的 0.2 倍到最高价的 1.5 倍;没有可用颜色价格时逐条拒绝。SYB/Shopee 的 TWD 售价不参与 PDD 采购价格保护。 diff --git a/server/app/goauto/migrations/purchase_contract_test.go b/server/app/goauto/migrations/purchase_contract_test.go index e7a9a1a..7635468 100644 --- a/server/app/goauto/migrations/purchase_contract_test.go +++ b/server/app/goauto/migrations/purchase_contract_test.go @@ -67,6 +67,9 @@ func TestPurchaseContractTablesAreMigrated(t *testing.T) { t.Fatalf("missing table %s", table) } } + if !db.Migrator().HasColumn(&models.PurchaseTask{}, "ShopeeOrderNoSnapshot") { + t.Fatal("purchase_task missing shopee_order_no_snapshot") + } } func TestPurchaseSYBForeignKeyDoesNotUseMySQLIncompatibleCheck(t *testing.T) { diff --git a/server/app/goauto/models/purchase.go b/server/app/goauto/models/purchase.go index 5547b68..3536dda 100644 --- a/server/app/goauto/models/purchase.go +++ b/server/app/goauto/models/purchase.go @@ -89,6 +89,7 @@ type PurchaseTask struct { AccountRunSlot *uint8 `json:"-" gorm:"uniqueIndex:ux_purchase_task_running_account,priority:2"` ShopeeItemIDSnapshot string `json:"shopeeItemIdSnapshot" gorm:"size:64;not null"` + ShopeeOrderNoSnapshot string `json:"shopeeOrderNoSnapshot" gorm:"size:64;not null;default:'';index"` ShopeeTitleSnapshot string `json:"shopeeTitleSnapshot" gorm:"size:500;not null;default:''"` ShopeeShopNameSnapshot string `json:"shopeeShopNameSnapshot" gorm:"size:255;not null;default:''"` PDDURLSnapshot string `json:"pddUrlSnapshot" gorm:"type:text;not null"` diff --git a/server/app/goauto/models/schema.go b/server/app/goauto/models/schema.go index 6ccb194..2be6a85 100644 --- a/server/app/goauto/models/schema.go +++ b/server/app/goauto/models/schema.go @@ -352,7 +352,8 @@ type SYBSession struct { // available sample data, so a global-uniqueness assumption is unverified. type SYBProduct struct { ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` - // OrderCode is the shipment order's `code` (e.g. 260728TB95MJTQ). + // OrderCode is SYB's source `code` and represents the Shopee order number + // shown to procurement users (e.g. 260728TB95MJTQ). OrderCode string `json:"orderCode" gorm:"size:64;not null;uniqueIndex:ux_syb_product_order_detail,priority:1"` // DetailID is `details[].id` from SYB, e.g. 145306175. DetailID uint64 `json:"detailId" gorm:"not null;uniqueIndex:ux_syb_product_order_detail,priority:2"` diff --git a/server/app/goauto/purchase/admin_query.go b/server/app/goauto/purchase/admin_query.go index f2ec79e..119d513 100644 --- a/server/app/goauto/purchase/admin_query.go +++ b/server/app/goauto/purchase/admin_query.go @@ -18,6 +18,7 @@ type AdminListRequest struct { Status string ExecutionMode string SYBProductID uint64 + ShopeeOrderNo string PDDOrderNo string } @@ -31,6 +32,7 @@ type AdminTaskItem struct { DeviceID *uint64 `json:"deviceId,omitempty"` DeviceName string `json:"deviceName,omitempty"` ShopeeItemIDSnapshot string `json:"shopeeItemIdSnapshot"` + ShopeeOrderNoSnapshot string `json:"shopeeOrderNoSnapshot"` ShopeeTitleSnapshot string `json:"shopeeTitleSnapshot"` ShopeeShopNameSnapshot string `json:"shopeeShopNameSnapshot"` PDDGoodsIDSnapshot string `json:"pddGoodsIdSnapshot"` @@ -128,6 +130,9 @@ func (s *Service) AdminList(ctx context.Context, req AdminListRequest) (AdminLis if req.SYBProductID > 0 { query = query.Where("syb_product_id = ?", req.SYBProductID) } + if orderNo := strings.TrimSpace(req.ShopeeOrderNo); orderNo != "" { + query = query.Where("shopee_order_no_snapshot LIKE ?", "%"+orderNo+"%") + } if orderNo := strings.TrimSpace(req.PDDOrderNo); orderNo != "" { query = query.Where("pdd_order_no LIKE ?", "%"+orderNo+"%") } @@ -205,7 +210,7 @@ func adminTaskItem(task models.PurchaseTask, deviceNames map[uint64]string, retr item := AdminTaskItem{ ID: task.ID, ExecutionMode: task.ExecutionMode, Status: task.Status, SYBProductID: task.SYBProductID, ShopeeProductID: task.ShopeeProductID, PDDProductID: task.PDDProductID, - DeviceID: task.DeviceID, ShopeeItemIDSnapshot: task.ShopeeItemIDSnapshot, + DeviceID: task.DeviceID, ShopeeItemIDSnapshot: task.ShopeeItemIDSnapshot, ShopeeOrderNoSnapshot: task.ShopeeOrderNoSnapshot, ShopeeTitleSnapshot: task.ShopeeTitleSnapshot, ShopeeShopNameSnapshot: task.ShopeeShopNameSnapshot, PDDGoodsIDSnapshot: task.PDDGoodsIDSnapshot, PDDTitleSnapshot: task.PDDTitleSnapshot, TargetColorSnapshot: task.TargetColorSnapshot, TargetSizeSnapshot: task.TargetSizeSnapshot, diff --git a/server/app/goauto/purchase/admin_query_test.go b/server/app/goauto/purchase/admin_query_test.go index 88c7f6d..7e60002 100644 --- a/server/app/goauto/purchase/admin_query_test.go +++ b/server/app/goauto/purchase/admin_query_test.go @@ -71,6 +71,27 @@ func TestAdminListAndDetailExposeSafePurchaseViews(t *testing.T) { } } +func TestAdminListFiltersByShopeeOrderNumberSnapshot(t *testing.T) { + db := testDB(t) + f := seed(t, db, liveCaps(), true) + s := testService(db) + task, err := createLive(t, s, f) + if err != nil { + t.Fatalf("create live task: %v", err) + } + list, err := s.AdminList(context.Background(), AdminListRequest{Page: 1, PageSize: 20, ShopeeOrderNo: "YB-"}) + if err != nil { + t.Fatalf("admin list: %v", err) + } + if list.Total != 1 || len(list.Items) != 1 || list.Items[0].ID != task.ID || list.Items[0].ShopeeOrderNoSnapshot != f.syb.OrderCode { + t.Fatalf("unexpected order query result: %+v", list) + } + empty, err := s.AdminList(context.Background(), AdminListRequest{Page: 1, PageSize: 20, ShopeeOrderNo: "NOT-FOUND"}) + if err != nil || empty.Total != 0 { + t.Fatalf("unexpected empty result: %+v %v", empty, err) + } +} + func TestAdminQueryValidationAndNotFound(t *testing.T) { s := testService(testDB(t)) if _, err := s.AdminList(context.Background(), AdminListRequest{Status: "unknown"}); code(err) != CodeInvalidRequest { diff --git a/server/app/goauto/purchase/handler.go b/server/app/goauto/purchase/handler.go index 776926b..fdce62c 100644 --- a/server/app/goauto/purchase/handler.go +++ b/server/app/goauto/purchase/handler.go @@ -51,7 +51,8 @@ func (h Handler) AdminList(c *gin.Context) { response, err := service.AdminList(c.Request.Context(), AdminListRequest{ Page: page, PageSize: pageSize, TaskID: taskID, SYBProductID: sybProductID, Status: strings.TrimSpace(c.Query("status")), ExecutionMode: strings.TrimSpace(c.Query("executionMode")), - PDDOrderNo: strings.TrimSpace(c.Query("pddOrderNo")), + ShopeeOrderNo: strings.TrimSpace(c.Query("shopeeOrderNo")), + PDDOrderNo: strings.TrimSpace(c.Query("pddOrderNo")), }) if err != nil { writeError(c, err) diff --git a/server/app/goauto/purchase/retry.go b/server/app/goauto/purchase/retry.go index fc57e69..1ca6fa1 100644 --- a/server/app/goauto/purchase/retry.go +++ b/server/app/goauto/purchase/retry.go @@ -105,11 +105,12 @@ func (s *Service) BatchRetry(ctx context.Context, req BatchRetryRequest) (BatchR task, replayed, createErr := s.Create(ctx, CreateRequest{ RequestID: candidate.requestID, ExecutionMode: models.PurchaseExecutionModeLive, SYBProductID: &sybID, DeviceID: candidate.source.DeviceID, - ReferenceUnitPriceCent: candidate.preview.ReferenceUnitPriceCent, - MinUnitPriceCent: candidate.preview.MinUnitPriceCent, - MaxUnitPriceCent: candidate.preview.MaxUnitPriceCent, - Currency: candidate.preview.Currency, - RuleSnapshot: purchasecontract.DefaultLiveRule(), + shopeeOrderNoSnapshotOverride: candidate.source.ShopeeOrderNoSnapshot, + ReferenceUnitPriceCent: candidate.preview.ReferenceUnitPriceCent, + MinUnitPriceCent: candidate.preview.MinUnitPriceCent, + MaxUnitPriceCent: candidate.preview.MaxUnitPriceCent, + Currency: candidate.preview.Currency, + RuleSnapshot: purchasecontract.DefaultLiveRule(), }) item := response.Items[candidate.resultIndex] if createErr != nil { diff --git a/server/app/goauto/purchase/retry_test.go b/server/app/goauto/purchase/retry_test.go index be24552..19593dc 100644 --- a/server/app/goauto/purchase/retry_test.go +++ b/server/app/goauto/purchase/retry_test.go @@ -42,6 +42,9 @@ func TestBatchRetryCreatesNewTaskPreservesFailureAndReplays(t *testing.T) { setCollectedPDDPrice(t, db, f.pdd.ID) service := testService(db) failed := failedLiveTask(t, db, service, f) + if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Update("order_code", "SYB-CHANGED").Error; err != nil { + t.Fatal(err) + } request := BatchRetryRequest{RequestID: uuid.NewString(), TaskIDs: []uint64{failed.ID}} first, err := service.BatchRetry(context.Background(), request) @@ -64,6 +67,9 @@ func TestBatchRetryCreatesNewTaskPreservesFailureAndReplays(t *testing.T) { if oldTask.Status != models.PurchaseTaskStatusFailed || newTask.Status != models.PurchaseTaskStatusPending || newTask.AddressSuffix == failed.AddressSuffix || newTask.DeviceID == nil || *newTask.DeviceID != f.device.ID { t.Fatalf("retry facts mismatch: old=%+v new=%+v", oldTask, newTask) } + if newTask.ShopeeOrderNoSnapshot != failed.ShopeeOrderNoSnapshot { + t.Fatalf("retry changed order snapshot: old=%q new=%q", failed.ShopeeOrderNoSnapshot, newTask.ShopeeOrderNoSnapshot) + } replay, err := service.BatchRetry(context.Background(), request) if err != nil { diff --git a/server/app/goauto/purchase/service.go b/server/app/goauto/purchase/service.go index a927ea1..f39a2df 100644 --- a/server/app/goauto/purchase/service.go +++ b/server/app/goauto/purchase/service.go @@ -191,8 +191,12 @@ func (s *Service) Create(ctx context.Context, req CreateRequest) (models.Purchas return fail(CodeInvalidRequest, "数量、价格区间或币种无效") } required, _ := json.Marshal(purchasecontract.RequiredCapabilities(rule)) + shopeeOrderNoSnapshot := syb.OrderCode + if req.shopeeOrderNoSnapshotOverride != "" { + shopeeOrderNoSnapshot = req.shopeeOrderNoSnapshotOverride + } out = models.PurchaseTask{SYBProductID: sybID, ShopeeProductID: shopeeID, PDDProductID: pdd.ID, DeviceID: req.DeviceID, PDDAccountID: req.PDDAccountID, - ExecutionMode: req.ExecutionMode, Status: models.PurchaseTaskStatusPending, ShopeeItemIDSnapshot: shopee.ShopeeItemID, ShopeeTitleSnapshot: shopee.Title, ShopeeShopNameSnapshot: shopee.ShopName, + ExecutionMode: req.ExecutionMode, Status: models.PurchaseTaskStatusPending, ShopeeItemIDSnapshot: shopee.ShopeeItemID, ShopeeOrderNoSnapshot: shopeeOrderNoSnapshot, ShopeeTitleSnapshot: shopee.Title, ShopeeShopNameSnapshot: shopee.ShopName, PDDURLSnapshot: pdd.URL, PDDGoodsIDSnapshot: pdd.GoodsID, PDDTitleSnapshot: pdd.Title, TargetColorSnapshot: targetColor, TargetSizeSnapshot: targetSize, MappedColorSnapshot: mappedColor, MappedSizeSnapshot: mappedSize, SpecSource: specSource, SpecDecisionSnapshot: decisionSnapshot, Quantity: quantity, ReferenceUnitPriceCent: ref, MinUnitPriceCent: minPrice, MaxUnitPriceCent: maxPrice, Currency: currency, RuleType: rule.RuleType, RuleSchemaVersion: rule.SchemaVersion, RequiredCapabilitiesJSON: string(required), RuleSnapshot: string(req.RuleSnapshot), CreateRequestID: req.RequestID, PaymentReviewStatus: models.PurchasePaymentReviewPending, LogisticsStatus: models.PurchaseLogisticsStatusPending, WritebackStatus: models.PurchaseWritebackStatusNotSelected} diff --git a/server/app/goauto/purchase/service_test.go b/server/app/goauto/purchase/service_test.go index bd74738..4c71fd5 100644 --- a/server/app/goauto/purchase/service_test.go +++ b/server/app/goauto/purchase/service_test.go @@ -113,6 +113,18 @@ func TestRehearsalCannotContainOrderActions(t *testing.T) { } } +func TestCreateLiveSnapshotsShopeeOrderNumber(t *testing.T) { + db := testDB(t) + f := seed(t, db, liveCaps(), true) + task, err := createLive(t, testService(db), f) + if err != nil { + t.Fatalf("create live task: %v", err) + } + if task.ShopeeOrderNoSnapshot != f.syb.OrderCode { + t.Fatalf("order number snapshot=%q want %q", task.ShopeeOrderNoSnapshot, f.syb.OrderCode) + } +} + func TestCreateRehearsalPreservesParameterizedRuleSnapshot(t *testing.T) { db := testDB(t) f := seed(t, db, []string{purchasecontract.CapabilityPurchaseRehearsalV1}, true) diff --git a/server/app/goauto/purchase/types.go b/server/app/goauto/purchase/types.go index 253c52a..2fb9b0f 100644 --- a/server/app/goauto/purchase/types.go +++ b/server/app/goauto/purchase/types.go @@ -59,6 +59,10 @@ type CreateRequest struct { MaxUnitPriceCent int64 `json:"maxUnitPriceCent,omitempty"` Currency string `json:"currency,omitempty"` RuleSnapshot json.RawMessage `json:"ruleSnapshot"` + // shopeeOrderNoSnapshotOverride is server-only. Batch retry uses the + // immutable source-task value instead of accepting a client-supplied + // identity snapshot. + shopeeOrderNoSnapshotOverride string } type ActionRequest struct { diff --git a/server/cmd/migrate/migration/version-local/1786701500000_purchase_shopee_order_snapshot.go b/server/cmd/migrate/migration/version-local/1786701500000_purchase_shopee_order_snapshot.go new file mode 100644 index 0000000..1472693 --- /dev/null +++ b/server/cmd/migrate/migration/version-local/1786701500000_purchase_shopee_order_snapshot.go @@ -0,0 +1,43 @@ +package version_local + +import ( + "runtime" + + goautomigrations "go-admin/app/goauto/migrations" + "go-admin/cmd/migrate/migration" + common "go-admin/common/models" + + "gorm.io/gorm" +) + +// This additive #67 migration records the Shopee order number that explains +// each purchase task. Existing tasks are backfilled from their immutable SYB +// product relation when it is still available; unresolved legacy rows stay +// empty instead of receiving a fabricated identifier. +func init() { + _, fileName, _, _ := runtime.Caller(0) + migration.Migrate.SetVersion(migration.GetFilename(fileName), migratePurchaseShopeeOrderSnapshot) +} + +func migratePurchaseShopeeOrderSnapshot(db *gorm.DB, version string) error { + return db.Transaction(func(tx *gorm.DB) error { + if err := goautomigrations.Migrate(tx); err != nil { + return err + } + if err := backfillPurchaseShopeeOrderSnapshot(tx); err != nil { + return err + } + return tx.Create(&common.Migration{Version: version}).Error + }) +} + +func backfillPurchaseShopeeOrderSnapshot(db *gorm.DB) error { + return db.Exec(`UPDATE purchase_task + SET shopee_order_no_snapshot = COALESCE(( + SELECT syb_product.order_code + FROM syb_product + WHERE syb_product.id = purchase_task.syb_product_id + ), '') + WHERE shopee_order_no_snapshot = '' + AND syb_product_id IS NOT NULL`).Error +} diff --git a/server/cmd/migrate/migration/version-local/1786701500000_purchase_shopee_order_snapshot_test.go b/server/cmd/migrate/migration/version-local/1786701500000_purchase_shopee_order_snapshot_test.go new file mode 100644 index 0000000..e4519b9 --- /dev/null +++ b/server/cmd/migrate/migration/version-local/1786701500000_purchase_shopee_order_snapshot_test.go @@ -0,0 +1,38 @@ +package version_local + +import ( + "testing" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestBackfillPurchaseShopeeOrderSnapshot(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:purchase-order-snapshot?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + for _, statement := range []string{ + `CREATE TABLE syb_product (id INTEGER PRIMARY KEY, order_code TEXT NOT NULL)`, + `CREATE TABLE purchase_task (id INTEGER PRIMARY KEY, syb_product_id INTEGER, shopee_order_no_snapshot TEXT NOT NULL DEFAULT '')`, + `INSERT INTO syb_product (id, order_code) VALUES (1, '260728TB95MJTQ')`, + `INSERT INTO purchase_task (id, syb_product_id, shopee_order_no_snapshot) VALUES (1, 1, ''), (2, NULL, ''), (3, 1, 'IMMUTABLE')`, + } { + if err = db.Exec(statement).Error; err != nil { + t.Fatal(err) + } + } + if err = backfillPurchaseShopeeOrderSnapshot(db); err != nil { + t.Fatal(err) + } + var rows []struct { + ID uint64 + Snapshot string `gorm:"column:shopee_order_no_snapshot"` + } + if err = db.Table("purchase_task").Select("id, shopee_order_no_snapshot").Order("id").Scan(&rows).Error; err != nil { + t.Fatal(err) + } + if len(rows) != 3 || rows[0].Snapshot != "260728TB95MJTQ" || rows[1].Snapshot != "" || rows[2].Snapshot != "IMMUTABLE" { + t.Fatalf("unexpected backfill rows: %+v", rows) + } +} diff --git a/web/src/views/goauto/purchase-tasks/index.vue b/web/src/views/goauto/purchase-tasks/index.vue index 9306504..fc40b8b 100644 --- a/web/src/views/goauto/purchase-tasks/index.vue +++ b/web/src/views/goauto/purchase-tasks/index.vue @@ -5,6 +5,7 @@ + @@ -15,6 +16,7 @@ + @@ -34,8 +36,9 @@
CG-{{ detail.task.id }}{{ statusLabel(detail.task.status) }}{{ modeLabel(detail.task.executionMode) }}
状态更新时间:{{ formatTime(detail.task.statusChangedAt) }}

任务快照

- SYB-{{ detail.task.sybProductId || '—' }}{{ detail.task.shopeeItemIdSnapshot || '—' }} - {{ detail.task.shopeeTitleSnapshot || '未记录' }}{{ detail.task.shopeeShopNameSnapshot || '未记录' }}{{ detail.task.pddGoodsIdSnapshot || '—' }} + SYB-{{ detail.task.sybProductId || '—' }}{{ detail.task.shopeeOrderNoSnapshot || '—' }} + {{ detail.task.shopeeItemIdSnapshot || '—' }}{{ detail.task.shopeeShopNameSnapshot || '未记录' }} + {{ detail.task.shopeeTitleSnapshot || '未记录' }}{{ detail.task.pddGoodsIdSnapshot || '—' }} {{ detail.task.pddTitleSnapshot || '未记录' }} {{ specText(detail.task.targetColorSnapshot, detail.task.targetSizeSnapshot) }}{{ specText(detail.task.mappedColorSnapshot, detail.task.mappedSizeSnapshot) }} {{ detail.task.quantity }} 件{{ specSourceLabel(detail.task.specSource) }} 查看映射 @@ -128,7 +131,7 @@ export default { data() { return { loading: false, loadError: '', tasks: [], total: 0, statuses: statusOptions, - query: { page: 1, pageSize: 20, taskId: '', status: '', executionMode: 'live', sybProductId: '', pddOrderNo: '' }, + query: { page: 1, pageSize: 20, taskId: '', shopeeOrderNo: '', status: '', executionMode: 'live', sybProductId: '', pddOrderNo: '' }, detail: { open: false, loading: false, task: null, attempts: [] }, retrySelection: [], retryDialog: { open: false, saving: false, items: [] }, @@ -170,7 +173,7 @@ export default { }, cleanQuery() { return Object.fromEntries(Object.entries(this.query).filter(([, value]) => value !== '')) }, search() { this.query.page = 1; this.load() }, - reset() { this.query = { page: 1, pageSize: 20, taskId: '', status: '', executionMode: 'live', sybProductId: '', pddOrderNo: '' }; this.load() }, + reset() { this.query = { page: 1, pageSize: 20, taskId: '', shopeeOrderNo: '', status: '', executionMode: 'live', sybProductId: '', pddOrderNo: '' }; this.load() }, async openDetail(row) { this.detail = { open: true, loading: true, task: null, attempts: [] }; try { await this.refreshDetail(row.id) } finally { this.detail.loading = false } }, async refreshDetail(id = this.detail.task?.id) { const r = await getPurchaseTask(id); this.detail.task = r.data.task; this.detail.attempts = r.data.attempts || [] }, statusLabel(value) { return statusOptions.find(item => item.value === value)?.label || value || '—' }, @@ -222,5 +225,5 @@ export default { diff --git a/web/src/views/goauto/syb-products/index.vue b/web/src/views/goauto/syb-products/index.vue index f503c1a..bb52f89 100644 --- a/web/src/views/goauto/syb-products/index.vue +++ b/web/src/views/goauto/syb-products/index.vue @@ -7,7 +7,7 @@ 同步记录 已选择 {{ selectedProducts.length }} 条 创建采购重新解析 - + 查询重置重新解析创建采购任务 @@ -24,7 +24,7 @@ - + @@ -119,7 +119,7 @@

结构化字段

- {{ detail.item.orderCode }} + {{ detail.item.orderCode }} {{ detail.item.detailId }} {{ detail.item.shopeeItemId }} ↗ {{ detail.item.productTitle || '未取到标题' }}