feat(#67): add Shopee order snapshots to purchase tasks

This commit is contained in:
QiuSW
2026-08-22 10:41:05 +08:00
parent 0ceb248233
commit 9490bddf8f
17 changed files with 173 additions and 30 deletions
+6 -6
View File
@@ -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
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -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` 任务可重试。原设备离线、停用、忙碌或能力不足时该项失败且不自动换机;未指定设备时仍由空闲设备领取。
- 批量重试逐项处理并允许部分成功;同一请求幂等重放不会重复创建。失败任务不再使用一次性重新采购授权,该授权只保留给已经创建过订单且满足条件的任务。
+7 -7
View File
@@ -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
<!-- gitea-wiki-mirror:end -->
# 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 采购价格保护。
@@ -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) {
+1
View File
@@ -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"`
+2 -1
View File
@@ -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"`
+6 -1
View File
@@ -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,
@@ -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 {
+2 -1
View File
@@ -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)
+6 -5
View File
@@ -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 {
+6
View File
@@ -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 {
+5 -1
View File
@@ -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}
@@ -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)
+4
View File
@@ -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 {
@@ -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
}
@@ -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)
}
}
@@ -5,6 +5,7 @@
<el-alert v-if="loadError" :title="loadError" type="error" :closable="false" show-icon class="notice"><template #default><el-button type="primary" link @click="load">重新加载</el-button></template></el-alert>
<el-form :model="query" :inline="true" class="search-form" @submit.prevent="search">
<el-form-item label="任务"><el-input v-model="query.taskId" class="task-input" clearable placeholder="编号" @keyup.enter="search" /></el-form-item>
<el-form-item label="蝦皮订单号"><el-input v-model="query.shopeeOrderNo" class="shopee-order-input" clearable placeholder="订单号" @keyup.enter="search" /></el-form-item>
<el-form-item label="状态"><el-select v-model="query.status" clearable placeholder="全部状态" style="width:160px"><el-option v-for="item in statuses" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item>
<el-form-item label="SYB 商品"><el-input v-model="query.sybProductId" class="syb-input" clearable placeholder="商品 ID" @keyup.enter="search" /></el-form-item>
<el-form-item label="PDD 订单号"><el-input v-model="query.pddOrderNo" class="order-input" clearable placeholder="订单号" @keyup.enter="search" /></el-form-item>
@@ -15,6 +16,7 @@
<el-table ref="taskTable" v-loading="loading" :data="tasks" row-key="id" border stripe empty-text="暂无采购任务" :row-class-name="retryRowClass" @selection-change="handleRetrySelection">
<el-table-column type="selection" width="48" :selectable="isRetrySelectable" />
<el-table-column label="任务" width="105"><template #default="{ row }"><div class="primary">CG-{{ row.id }}</div><el-tag size="small" effect="plain" :type="row.executionMode === 'live' ? 'success' : 'info'">{{ modeLabel(row.executionMode) }}</el-tag></template></el-table-column>
<el-table-column label="蝦皮订单号" min-width="160"><template #default="{ row }"><span class="order-number">{{ row.shopeeOrderNoSnapshot || '—' }}</span></template></el-table-column>
<el-table-column label="SYB / 虾皮商品" min-width="220"><template #default="{ row }"><div class="primary ellipsis">{{ row.shopeeTitleSnapshot || `虾皮商品 ${row.shopeeItemIdSnapshot || '—'}` }}</div><div class="muted">SYB-{{ row.sybProductId || '—' }} · {{ row.shopeeShopNameSnapshot || '店铺未记录' }}</div></template></el-table-column>
<el-table-column label="PDD 商品" min-width="190"><template #default="{ row }"><div class="primary ellipsis">{{ row.pddTitleSnapshot || '标题未记录' }}</div><div class="muted">goods_id {{ row.pddGoodsIdSnapshot || '—' }}</div></template></el-table-column>
<el-table-column label="采购规格 / 数量" min-width="190"><template #default="{ row }"><div>{{ specText(row.mappedColorSnapshot, row.mappedSizeSnapshot) }}</div><div class="muted">目标:{{ specText(row.targetColorSnapshot, row.targetSizeSnapshot) }} · {{ row.quantity }} 件</div></template></el-table-column>
@@ -34,8 +36,9 @@
<div class="detail-heading"><div><span class="task-number">CG-{{ detail.task.id }}</span><el-tag :type="statusType(detail.task.status)">{{ statusLabel(detail.task.status) }}</el-tag><el-tag effect="plain" :type="detail.task.executionMode === 'live' ? 'success' : 'info'">{{ modeLabel(detail.task.executionMode) }}</el-tag></div><span class="muted">状态更新时间:{{ formatTime(detail.task.statusChangedAt) }}</span></div>
<section><h3>任务快照</h3><el-descriptions :column="2" border>
<el-descriptions-item label="SYB 商品">SYB-{{ detail.task.sybProductId || '—' }}</el-descriptions-item><el-descriptions-item label="虾皮商品 ID">{{ detail.task.shopeeItemIdSnapshot || '—' }}</el-descriptions-item>
<el-descriptions-item label="虾皮标题" :span="2">{{ detail.task.shopeeTitleSnapshot || '未记录' }}</el-descriptions-item><el-descriptions-item label="虾皮店铺">{{ detail.task.shopeeShopNameSnapshot || '未记录' }}</el-descriptions-item><el-descriptions-item label="PDD goods_id">{{ detail.task.pddGoodsIdSnapshot || '—' }}</el-descriptions-item>
<el-descriptions-item label="SYB 商品">SYB-{{ detail.task.sybProductId || '—' }}</el-descriptions-item><el-descriptions-item label="蝦皮订单号">{{ detail.task.shopeeOrderNoSnapshot || '—' }}</el-descriptions-item>
<el-descriptions-item label="虾皮商品 ID">{{ detail.task.shopeeItemIdSnapshot || '—' }}</el-descriptions-item><el-descriptions-item label="虾皮店铺">{{ detail.task.shopeeShopNameSnapshot || '未记录' }}</el-descriptions-item>
<el-descriptions-item label="虾皮标题" :span="2">{{ detail.task.shopeeTitleSnapshot || '未记录' }}</el-descriptions-item><el-descriptions-item label="PDD goods_id">{{ detail.task.pddGoodsIdSnapshot || '—' }}</el-descriptions-item>
<el-descriptions-item label="PDD 标题" :span="2">{{ detail.task.pddTitleSnapshot || '未记录' }}</el-descriptions-item>
<el-descriptions-item label="目标规格">{{ specText(detail.task.targetColorSnapshot, detail.task.targetSizeSnapshot) }}</el-descriptions-item><el-descriptions-item label="执行规格">{{ specText(detail.task.mappedColorSnapshot, detail.task.mappedSizeSnapshot) }}</el-descriptions-item>
<el-descriptions-item label="数量">{{ detail.task.quantity }} 件</el-descriptions-item><el-descriptions-item label="映射来源">{{ specSourceLabel(detail.task.specSource) }} <el-button v-if="detail.task.shopeeProductId" type="primary" link @click="openShopeeMapping">查看映射</el-button></el-descriptions-item>
@@ -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 {
</script>
<style lang="scss" scoped>
.page-card{min-height:calc(100vh - 124px)}.search-form{display:flex;align-items:flex-end;padding:16px 16px 0;margin-bottom:16px;border:1px solid #e5e7eb;border-radius:8px;background:#f8fafc;white-space:nowrap}.retry-action{margin-left:auto;margin-right:0}.selected-copy{margin-right:10px;color:#606266}.selected-copy strong{color:#337ecc;font-variant-numeric:tabular-nums}.task-input{width:72px}.syb-input{width:100px}.order-input{width:120px}.notice{margin-bottom:18px}.primary{color:#303133;font-weight:600}.muted{color:#909399;font-size:12px;line-height:1.5}.ellipsis{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.retry-disabled{display:block;margin-top:4px;color:#b88230;font-size:11px;line-height:1.2;cursor:help}.dialog-help{margin:14px 0 0;color:#606266;line-height:1.6}.drawer-body{min-height:240px;padding:0 4px 28px}.detail-heading{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:18px}.detail-heading>div{display:flex;align-items:center;gap:8px}.task-number{font-size:22px;font-weight:700;color:#1f2937}.drawer-body section{margin-top:24px}.drawer-body h3{margin:0 0 12px;color:#303133;font-size:17px}.state-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.state-card{display:flex;min-height:104px;flex-direction:column;gap:7px;padding:16px;border:1px solid #e5e7eb;border-radius:8px;background:#f8fafc}.state-card span,.state-card small{color:#606266}.state-card strong{color:#1f2937;font-size:16px;overflow-wrap:anywhere}.drawer-actions{position:sticky;bottom:0;z-index:2;display:flex;justify-content:flex-end;gap:8px;margin:24px -4px -28px;padding:16px 4px;background:#fff;border-top:1px solid #e5e7eb}:deep(.el-table .retry-disabled-row){color:#909399;background:#fafafa}:deep(.el-table .retry-disabled-row .el-checkbox){cursor:not-allowed}@media(max-width:1200px){.search-form{overflow-x:auto}.retry-action{margin-left:24px}}@media(max-width:768px){.search-form{white-space:normal}.state-grid{grid-template-columns:1fr}.detail-heading{align-items:flex-start;flex-direction:column}.drawer-actions{flex-wrap:wrap}}
.page-card{min-height:calc(100vh - 124px)}.search-form{display:flex;align-items:flex-end;padding:16px 16px 0;margin-bottom:16px;border:1px solid #e5e7eb;border-radius:8px;background:#f8fafc;white-space:nowrap}.retry-action{margin-left:auto;margin-right:0}.selected-copy{margin-right:10px;color:#606266}.selected-copy strong{color:#337ecc;font-variant-numeric:tabular-nums}.task-input{width:72px}.shopee-order-input{width:140px}.syb-input{width:100px}.order-input{width:120px}.order-number{font-variant-numeric:tabular-nums;overflow-wrap:anywhere}.notice{margin-bottom:18px}.primary{color:#303133;font-weight:600}.muted{color:#909399;font-size:12px;line-height:1.5}.ellipsis{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.retry-disabled{display:block;margin-top:4px;color:#b88230;font-size:11px;line-height:1.2;cursor:help}.dialog-help{margin:14px 0 0;color:#606266;line-height:1.6}.drawer-body{min-height:240px;padding:0 4px 28px}.detail-heading{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:18px}.detail-heading>div{display:flex;align-items:center;gap:8px}.task-number{font-size:22px;font-weight:700;color:#1f2937}.drawer-body section{margin-top:24px}.drawer-body h3{margin:0 0 12px;color:#303133;font-size:17px}.state-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.state-card{display:flex;min-height:104px;flex-direction:column;gap:7px;padding:16px;border:1px solid #e5e7eb;border-radius:8px;background:#f8fafc}.state-card span,.state-card small{color:#606266}.state-card strong{color:#1f2937;font-size:16px;overflow-wrap:anywhere}.drawer-actions{position:sticky;bottom:0;z-index:2;display:flex;justify-content:flex-end;gap:8px;margin:24px -4px -28px;padding:16px 4px;background:#fff;border-top:1px solid #e5e7eb}:deep(.el-table .retry-disabled-row){color:#909399;background:#fafafa}:deep(.el-table .retry-disabled-row .el-checkbox){cursor:not-allowed}@media(max-width:1200px){.search-form{overflow-x:auto}.retry-action{margin-left:24px}}@media(max-width:768px){.search-form{white-space:normal}.state-grid{grid-template-columns:1fr}.detail-heading{align-items:flex-start;flex-direction:column}.drawer-actions{flex-wrap:wrap}}
</style>
+3 -3
View File
@@ -7,7 +7,7 @@
<el-form-item><el-button @click="$router.push('/syb-sync-runs/index')">同步记录</el-button></el-form-item>
<el-form-item><span class="selection-count" aria-live="polite">已选择 <strong>{{ selectedProducts.length }}</strong> 条</span></el-form-item>
<el-form-item v-if="canPurchase"><el-radio-group v-model="batchMode" size="small" aria-label="批量操作用途" @change="changeBatchMode"><el-radio-button value="purchase">创建采购</el-radio-button><el-radio-button value="reparse">重新解析</el-radio-button></el-radio-group></el-form-item>
<el-form-item label="搜索"><el-input v-model="query.keyword" placeholder="货运单号、虾皮商品ID或商品标题" clearable @keyup.enter="search" /></el-form-item>
<el-form-item label="搜索"><el-input v-model="query.keyword" placeholder="订单号、虾皮商品ID或商品标题" clearable @keyup.enter="search" /></el-form-item>
<el-form-item label="解析状态"><el-select v-model="query.parseStatus" clearable placeholder="全部" style="width: 140px"><el-option label="成功" value="success" /><el-option label="存疑" value="uncertain" /><el-option label="失败" value="failed" /></el-select></el-form-item>
<el-form-item><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button><el-button v-if="batchMode === 'reparse'" :disabled="selectedProducts.length === 0" @click="openReparseBatch">重新解析</el-button><el-button v-if="canPurchase && batchMode === 'purchase'" type="primary" :disabled="selectedProducts.length === 0" @click="openPurchaseBatch">创建采购任务</el-button></el-form-item>
</el-form>
@@ -24,7 +24,7 @@
<el-table ref="productTable" v-loading="loading" :data="products" row-key="id" border stripe empty-text="暂无 SYB 商品明细" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="48" :selectable="rowSelectable" />
<el-table-column label="参考图" width="76"><template #default="{ row }"><el-image v-if="row.imageUrl" :src="row.imageUrl" fit="cover" class="thumb" :preview-src-list="[row.imageUrl]" preview-teleported /><div v-else class="thumb placeholder">无图</div></template></el-table-column>
<el-table-column label="货运单号" prop="orderCode" min-width="120" />
<el-table-column label="订单号" prop="orderCode" min-width="120" />
<el-table-column label="虾皮商品" min-width="421"><template #default="{ row }"><a class="link" href="javascript:void(0)" @click="openShopeeDetail(row.shopeeProductId)">{{ row.shopeeItemId }} ↗</a><div class="muted">{{ row.productTitle || '未取到标题' }}</div></template></el-table-column>
<el-table-column label="店铺" prop="shopName" min-width="100" />
<el-table-column label="目标颜色 / 尺码" min-width="128"><template #default="{ row }"><div :class="{ 'missing': !row.targetColor }">颜色:{{ row.targetColor || '—' }}</div><div :class="{ 'missing': !row.targetSize }">尺码:{{ row.targetSize || '—' }}</div></template></el-table-column>
@@ -119,7 +119,7 @@
<div class="split-col">
<h3 class="section-title">结构化字段</h3>
<el-descriptions :column="1" border size="small">
<el-descriptions-item label="货运单号">{{ detail.item.orderCode }}</el-descriptions-item>
<el-descriptions-item label="订单号">{{ detail.item.orderCode }}</el-descriptions-item>
<el-descriptions-item label="来源明细ID">{{ detail.item.detailId }}</el-descriptions-item>
<el-descriptions-item label="虾皮商品ID"><a class="link" href="javascript:void(0)" @click="openShopeeDetail(detail.item.shopeeProductId)">{{ detail.item.shopeeItemId }} ↗</a></el-descriptions-item>
<el-descriptions-item label="商品标题">{{ detail.item.productTitle || '未取到标题' }}</el-descriptions-item>