diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md
index d07c0ec..8b4e089 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: 04a4bcf81f2f2912d101d1aa1c2d8eb802c5719e
-synchronized_at: 2026-09-30T01:50:27Z
+wiki_revision: f38dbf924894686f3ac1f91b669163ea11fc862b
+synchronized_at: 2026-10-05T09:52:40Z
@@ -599,3 +599,21 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
- `PurchaseRehearsalExecutor.applyPostAction` 仅在 openSpecPanel 的揭示滑动遇到 NO_SCROLLABLE_TARGET 时跳过剩余次数;保留 NON_SCROLLABLE_CONFIRMATION 原例外。其他动作仍走原 Boolean 必需滑动路径;root 不可用或有目标而手势失败仍 RULE_ACTION_FAILED。
- 失败/跳过通过既有 panelDiagnostic 回调写 GoAutoPurchasePanel 结构日志,仅含面板枚举、scrollables 计数、reveal 布尔与固定结果原因;失败摘要带同样标量并沿原任务结果提交。没有节点文字、坐标、树或截图。
- probeSpecs、selectSpec、最终复核、规则 JSON、能力、服务端、Admin、数据库、任务租约及下单/支付边界不变。无滑动不代表规格探测成功。
+## 采购任务批量订单号检索(#356)
+
+实现绑定 `bb3990e`,2026-10-05,尚未合并 main / 发布。
+
+- `web/src/utils/purchase-order-filter.js`:页面专用拆分、去重、数量与长度校验;`views/goauto/purchase-tasks/index.vue` 复用已有筛选、分页和当前页勾选。
+- `web/src/api/goauto/purchase-tasks.js` 的列表函数用重复键序列化 `shopeeOrderNos`,不修改其他API或全局超时。
+- `server/app/goauto/purchase/handler.go` 保留集合参数是否出现的信息;`admin_query.go` 在查询前校验,在分页/计数前对快照字段做参数化 IN;旧单值 LIKE 保持兼容。
+- `admin_order_filter_test.go`、`web/tests/unit/purchase-order-filter.test.cjs`、`web/tests/e2e/purchase-order-filter.spec.ts` 覆盖参数、精确匹配、分页、边界和错误状态。浏览器验证使用模拟接口,不访问真实采购数据或设备。
+- 无新表、迁移、权限或执行器改动;Server/Web 应成套发布和回退,避免旧Server忽略集合筛选。
+## SYB 来源取消状态展示(#357)
+
+实现绑定 `d6639a4`,2026-10-05,分支已实现,尚未合并 main、执行业务库迁移或发布。
+
+- `models.SYBProduct` 新增可空 `SYBIsCancelled` 和 `SYBStatusSyncedAt`。追加幂等迁移 `1791200000000_syb_cancel_status.go` 添加 nullable boolean/time 列,历史值留 null,不猜测、不改权限、不新增索引。
+- `sybimport/cancel_status.go` 仅解析上游 `isCancel` 的数值 0/1;`sync.go` 将明确的状态与同一货运单的取得时间传入 `OrderInput`,`ApplyDetail` 随正常同步创建/更新各明细。false 通过 map 更新,避免零值被省略;缺失/未知值保留原状态与时间。
+- `sybimport.Handler.List/Detail` 复用产品模型返回 nullable 字段。列表不额外访问 SYB;不新增旧订单扫描、并发、定时任务或采购拦截。
+- Web 只在 SYB 商品列表订单号后增加固定 72px 的“SYB状态”列;退货列改名“匹配退货”、min-width 200→160,保留图片/告警/操作并允许换行。使用现有标签与加载/失败态,其他列及 Android 不变。
+- 迁移重复执行和三态接口/导入已在隔离 SQLite 测试,Server/Web 构建通过;尚未验证正式 MySQL 执行或真实同步后的页面。先授权迁移,再发布才能用于业务环境。
diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md
index ce15c2e..ab6989f 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: 9dfe6566032003ed16f7c693d5d257500b7c5025
-synchronized_at: 2026-10-05T07:05:36Z
+wiki_revision: bfb8d7abd4d11466fc107ef8f26e0abf5ab13f41
+synchronized_at: 2026-10-05T09:52:46Z
@@ -772,3 +772,21 @@ SYB 回填与资料完整性独立:正式SYB任务已有单号且仍关联有
Android 0.9.64 / versionCode 77,源码 `6550b9f`(分支实现,尚未安装/发布)。已安全打开规格面板后,规则 openSpecPanel.swipeAfter 的某次揭示滑动若没有任何可见可滚动目标,则无需滑动,跳过本动作剩余次数并记录诊断;不再因缺少滚动节点而失败。保留 NON_SCROLLABLE_CONFIRMATION 原跳过行为。
有可滚动目标却派发失败、被取消或等待失败,以及无障碍 root 不可用,仍明确失败;其他动作的必需滑动不放宽。有可滚动目标时保留规则 count、direction、durationMs、intervalMs 与 waitAfterMs。后续规格探测、精确选择与最终复核全部照常,缺规格或探测无结果仍失败,不猜测、不新增点击目标或支付。
+## 采购管理蝦皮订单号批量粘贴(#356)
+
+实现绑定 `bb3990e`,2026-10-05,分支已实现并通过隔离测试,尚未合并 main 或部署。
+
+采购管理的“蝦皮订单号”支持换行、空格、Tab、中英文逗号分隔,去重后最多100个,每个最多64个字符;保留前导零,不转换为数值。单值保留包含查询,多值按完整来源订单号匹配,与状态等条件叠加。PDD订单号和CG任务号筛选不变。
+
+原位置输入框默认两行、最多四行;多值显示数量,超限字段提示且不提交,Enter 换行,点击原查询按钮提交。搜索回第一页,翻页保留条件,清空/重置恢复原查询;失败保留输入、清除旧勾选和旧结果,不自动重试请求。沿用现有筛选表单和提示规范,不增加审批或批次管理。
+
+检索本身不重试采购;采购员仍须选中当前页可重试失败任务,经原确认流程执行。#303 的现行普通批量重试为原任务重置,保留CG编号并增加attempt,不新建任务。本单没有修改任何重试资格、执行、下单、支付或回填行为。
+## SYB 订单取消状态只读展示(#357)
+
+实现绑定 `d6639a4`,2026-10-05,分支已实现,未合并/迁移/发布。
+
+- “SYB状态”仅显示来源取消标记:`isCancel=1` 为“已取消”,`0` 为“未取消”,没有有效证据为“未获取”。“未取消”不等于可采购,不表达其他 SYB 业务状态,不由 status/orderStatus、规格解析或 PDD 排除标记推断。
+- 正常同步成功取得有效值后覆盖原值,允许由取消变回未取消;失败、字段缺失/null、未知值或空页不改变既有取消状态与取得时间。历史记录不默认未取消。
+- 仅更新同步范围内实际导入的明细;超出同步创建日期范围的旧订单保留上次状态,未经同步的显示未获取。此列不是实时核验,列表加载不会请求 SYB。
+- 本单严格只做展示:不修改创建、批量、重试、Claim/Start、下单边界、采购资格或任务状态,不自动拦截已取消订单,不影响已存在 PDD 订单或回填事实。后续采购拦截另行评估。
+- “匹配退货”仅是原“匹配到的退货商品”的表头缩短和列宽调整,内容、权限、匹配/使用/取消规则不变。允许少量表格横向滚动。
diff --git a/docs/08-agent-api-contract.md b/docs/08-agent-api-contract.md
index da01caf..bec501d 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: 60bac77693f31b33162a65dc902456f324774f7b
-synchronized_at: 2026-10-05T07:06:06Z
+wiki_revision: 730ddd7a4596a1ff3ed20395f84a9f0760af1d23
+synchronized_at: 2026-10-05T09:53:04Z
@@ -1451,3 +1451,24 @@ Web 批量入口冻结原匹配 ID 并顺序调用;HTTP 409 状态冲突跳过
仅 openSpecPanel.swipeAfter 的 NO_SCROLLABLE_TARGET 可跳过剩余揭示滑动;NON_SCROLLABLE_CONFIRMATION 原例外保持。存在目标而手势失败、窗口 root 不可用,以及其他动作的必需滑动失败,仍返回 RULE_ACTION_FAILED;后续 probeSpecs/selectSpec/最终复核不跳过。
既有本地 GoAutoPurchasePanel 回调增加 ruleSwipe 事件:outcome=skipped_confirmed_panel|skipped_no_scroll_target|failed;panel 为面板枚举,scrollables 为非负计数,reveal 为布尔,失败/无目标 reason=success|no_scrollable_target|root_unavailable|failed 中实际适用的固定值。失败 error message 增加这些标量,原 task/attempt/device/规则快照绑定与结果提交字段不变。不记录节点文本、规格、地址、订单、坐标、树、整屏截图或凭据。
+## 采购管理批量订单号查询(#356)
+
+实现绑定 `bb3990e`,2026-10-05,分支已实现并通过隔离测试;尚未合并 main 或部署。
+
+`GET /api/admin/v1/purchase-tasks` 增加可选重复查询键 `shopeeOrderNos`,例如 `shopeeOrderNos=DEMO-A&shopeeOrderNos=DEMO-B`。对采购任务的 `shopee_order_no_snapshot` 做参数化 IN 查询;集合内为 OR,与其他已有筛选为 AND,在 count 和分页之前过滤。不查询 PDD 订单号、CG 编号、商品 ID 或标题。
+
+集合条目支持空白(包括换行、Tab)与中英文逗号拆分、去空项、去重,标识保留为字符串;最多100个去重值,每个最多64个 Unicode 字符。显式集合全空、超限、过长或同时提供非空 `shopeeOrderNo` 返回既有 `PURCHASE_INVALID_REQUEST` 错误,不忽略错误后查全部。集合参数只有一个值时仍按精确值查询。旧标量 `shopeeOrderNo` 保留包含查询,首尾去空白,长度上限64。
+
+Web 输入去重后一个值提交旧标量、多个值提交重复集合键,空输入不提交订单筛选。查询不执行采购、重试或回填;权限、分页上限、当前页重试资格和接口均不变。100个订单号可能对应超过100个任务,仍逐页展示/勾选,不提供跨页全选。Server 与 Web 成套发布/回退,旧 Server 未支持集合参数前不单独上线新 Web。
+## 管理端 SYB 商品来源取消状态(#357)
+
+实现绑定 `d6639a4`,2026-10-05,分支已实现,未合并 main 或部署;需要追加迁移 `1791200000000_syb_cancel_status.go`,业务库执行仍待授权。Android API 与采购 API 行为不变。
+
+`GET /api/admin/v1/syb-products` 的 `data.items[]` 及 `GET /api/admin/v1/syb-products/:productId` 的 `data.item` 追加以下只读字段,原请求、权限、筛选、响应信封不变:
+
+| 字段 | 类型 | 语义 |
+|---|---|---|
+| sybIsCancelled | boolean 或 null | 上游数值 isCancel=1→true,0→false,尚未获取有效值→null;false/null 均明确返回,不省略 |
+| sybStatusSyncedAt | RFC3339 时间字符串或 null | 最近一次正常同步取得有效取消值的 UTC 时间,不代表上游取消发生时间 |
+
+只使用上游 isCancel;不兼容性猜测字符串/布尔/其他数值,缺失或无效值保留原字段。成功同步的 false 可覆盖 true。无有效值的新记录两个字段均为 null;旧客户端可忽略新增字段,新 Web 对旧响应缺字段显示未获取。列表与详情不触发额外上游请求。取消状态仅展示,不改变采购准备阶段、创建/重试资格、任务执行或现有订单事实。
diff --git a/server/app/goauto/models/schema.go b/server/app/goauto/models/schema.go
index 7645846..d9f93d1 100644
--- a/server/app/goauto/models/schema.go
+++ b/server/app/goauto/models/schema.go
@@ -535,6 +535,9 @@ type SYBProduct struct {
// kept for reference when calling back into SYB.
StockID uint64 `json:"stockId" gorm:"not null"`
ShopName string `json:"shopName" gorm:"size:255;not null;default:''"`
+ // Source cancellation is display-only; nil means not acquired, not active.
+ SYBIsCancelled *bool `json:"sybIsCancelled"`
+ SYBStatusSyncedAt *time.Time `json:"sybStatusSyncedAt"`
// ShopeeItemID is `details[].productId` as a string, matching
// shopee_product.shopee_item_id's type. ShopeeProductID links to the
diff --git a/server/app/goauto/sybimport/apply.go b/server/app/goauto/sybimport/apply.go
index 023d22d..312b456 100644
--- a/server/app/goauto/sybimport/apply.go
+++ b/server/app/goauto/sybimport/apply.go
@@ -26,6 +26,8 @@ type OrderInput struct {
StockID uint64 // SYB stock/list internal `id`, e.g. 75104587
ShopName string
SYBStockCreatedAt *time.Time // SYB stock/list `created`; nil for legacy/manual test inputs
+ SYBIsCancelled *bool
+ SYBStatusSyncedAt *time.Time
}
// DetailInput mirrors one element of SYB's `details[]` array from
@@ -139,7 +141,8 @@ func ApplyDetail(ctx context.Context, db *gorm.DB, order OrderInput, detail Deta
record := models.SYBProduct{
OrderCode: order.Code, DetailID: detail.ID, StockID: order.StockID, ShopName: order.ShopName,
SYBStockCreatedAt: order.SYBStockCreatedAt,
- ShopeeItemID: shopeeItemID, ProductTitle: detail.ProductTitle,
+ SYBIsCancelled: order.SYBIsCancelled, SYBStatusSyncedAt: order.SYBStatusSyncedAt,
+ ShopeeItemID: shopeeItemID, ProductTitle: detail.ProductTitle,
TargetColor: parsed.Color, TargetSize: parsed.Size,
Quantity: detail.ProductQty, UnitPriceCent: roundCents(detail.ProductPrice),
ImageURL: buildImageURL(detail.ProductThumb),
@@ -198,6 +201,13 @@ func ApplyDetail(ctx context.Context, db *gorm.DB, order OrderInput, detail Deta
} else {
record.SYBStockCreatedAt = existing.SYBStockCreatedAt
}
+ if order.SYBIsCancelled != nil {
+ updates["syb_is_cancelled"] = *order.SYBIsCancelled
+ updates["syb_status_synced_at"] = order.SYBStatusSyncedAt
+ } else {
+ record.SYBIsCancelled = existing.SYBIsCancelled
+ record.SYBStatusSyncedAt = existing.SYBStatusSyncedAt
+ }
// An identical re-import keeps a valid AI decision. Changed source,
// link, or a newly deterministic parse invalidates it atomically.
preserveAI := existing.AIConfirmed && !existing.ManuallyConfirmed && parsed.Status != models.SYBParseStatusSuccess &&
diff --git a/server/app/goauto/sybimport/cancel_status.go b/server/app/goauto/sybimport/cancel_status.go
new file mode 100644
index 0000000..163449b
--- /dev/null
+++ b/server/app/goauto/sybimport/cancel_status.go
@@ -0,0 +1,26 @@
+package sybimport
+
+import "encoding/json"
+
+// SYB returns numeric isCancel: 1 cancelled, 0 not cancelled. Do not infer
+// anything from status/orderStatus, missing fields or undocumented values.
+func parseSYBIsCancelled(raw map[string]any) *bool {
+ var number float64
+ switch value := raw["isCancel"].(type) {
+ case float64: // encoding/json's default map decoding
+ number = value
+ case json.Number:
+ var err error
+ number, err = value.Float64()
+ if err != nil {
+ return nil
+ }
+ default:
+ return nil
+ }
+ if number != 0 && number != 1 {
+ return nil
+ }
+ cancelled := number == 1
+ return &cancelled
+}
diff --git a/server/app/goauto/sybimport/cancel_status_test.go b/server/app/goauto/sybimport/cancel_status_test.go
new file mode 100644
index 0000000..8491e3d
--- /dev/null
+++ b/server/app/goauto/sybimport/cancel_status_test.go
@@ -0,0 +1,155 @@
+package sybimport
+
+import (
+ "context"
+ "encoding/json"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "go-admin/app/goauto/models"
+ "go-admin/app/goauto/sybclient"
+)
+
+func TestParseSYBIsCancelled(t *testing.T) {
+ for _, tc := range []struct{ input, want string }{
+ {`{"isCancel":1}`, "true"}, {`{"isCancel":0}`, "false"},
+ {`{}`, "null"}, {`{"isCancel":null}`, "null"},
+ {`{"isCancel":2}`, "null"}, {`{"isCancel":-1}`, "null"},
+ {`{"isCancel":0.5}`, "null"}, {`{"isCancel":"1"}`, "null"},
+ {`{"isCancel":true}`, "null"}, {`{"status":79,"orderStatus":"已取消"}`, "null"},
+ } {
+ var raw map[string]any
+ if err := json.Unmarshal([]byte(tc.input), &raw); err != nil {
+ t.Fatal(err)
+ }
+ got, _ := json.Marshal(parseSYBIsCancelled(raw))
+ if string(got) != tc.want {
+ t.Errorf("%s: %s, want %s", tc.input, got, tc.want)
+ }
+ }
+ if got := parseSYBIsCancelled(map[string]any{"isCancel": json.Number("1")}); got == nil || !*got {
+ t.Fatal("json.Number")
+ }
+}
+
+func TestSyncCancellationDisplayAndListContract(t *testing.T) {
+ db := newSyncTestDB(t)
+ ctx := context.Background()
+ row := sybclient.StockRow{ID: 71, Code: "MOCK-CANCEL", Raw: map[string]any{"shopName": "测试店铺", "created": "2026-10-01 12:00:00"}}
+ detail := sybclient.StockDetail{ID: row.ID, Code: row.Code, Details: []sybclient.DetailItem{
+ {ID: 711, ProductID: 701, ProductQty: 1, ProductSpec: "黑色,XL"},
+ {ID: 712, ProductID: 702, ProductQty: 1, ProductSpec: "白色,L"},
+ }}
+ var previous *time.Time
+ for _, tc := range []struct {
+ raw string
+ want *bool
+ }{
+ {`{}`, nil}, {`{"isCancel":1}`, boolPointer(true)},
+ {`{"isCancel":null}`, boolPointer(true)}, {`{"isCancel":7}`, boolPointer(true)},
+ {`{"isCancel":0}`, boolPointer(false)}, {`{}`, boolPointer(false)},
+ } {
+ var values map[string]any
+ if err := json.Unmarshal([]byte(tc.raw), &values); err != nil {
+ t.Fatal(err)
+ }
+ delete(row.Raw, "isCancel")
+ for key, value := range values {
+ row.Raw[key] = value
+ }
+ if err := applyStockDetail(ctx, db, row, detail, &SyncReport{}); err != nil {
+ t.Fatal(err)
+ }
+ var records []models.SYBProduct
+ if err := db.Order("id").Find(&records).Error; err != nil {
+ t.Fatal(err)
+ }
+ if len(records) != 2 {
+ t.Fatal("must keep both details")
+ }
+ valid := parseSYBIsCancelled(values) != nil
+ for _, record := range records {
+ got, _ := json.Marshal(record.SYBIsCancelled)
+ want, _ := json.Marshal(tc.want)
+ if string(got) != string(want) {
+ t.Fatalf("%s: got %s want %s", tc.raw, got, want)
+ }
+ if tc.want == nil && record.SYBStatusSyncedAt != nil {
+ t.Fatal("unknown must have no timestamp")
+ }
+ if tc.want != nil && record.SYBStatusSyncedAt == nil {
+ t.Fatal("known must have timestamp")
+ }
+ if !valid && previous != nil && !record.SYBStatusSyncedAt.Equal(*previous) {
+ t.Fatal("missing/invalid must preserve time")
+ }
+ if record.PDDExcluded {
+ t.Fatal("display status must not exclude procurement")
+ }
+ }
+ if records[0].SYBStatusSyncedAt != nil && !records[0].SYBStatusSyncedAt.Equal(*records[1].SYBStatusSyncedAt) {
+ t.Fatal("same order must use same timestamp")
+ }
+ previous = records[0].SYBStatusSyncedAt
+ }
+ // List and detail reuse the model, including false/null (never omitempty).
+ if err := db.Model(&models.SYBProduct{}).Where("detail_id = ?", 712).Updates(map[string]any{"syb_is_cancelled": nil, "syb_status_synced_at": nil}).Error; err != nil {
+ t.Fatal(err)
+ }
+ gin.SetMode(gin.TestMode)
+ router := gin.New()
+ handler := Handler{DB: db}
+ router.GET("/products", handler.List)
+ router.GET("/products/:productId", handler.Detail)
+ response := httptest.NewRecorder()
+ router.ServeHTTP(response, httptest.NewRequest("GET", "/products?purchaseType=all", nil))
+ if response.Code != 200 {
+ t.Fatalf("list: %d %s", response.Code, response.Body.String())
+ }
+ var payload struct {
+ Data struct {
+ Items []map[string]any `json:"items"`
+ } `json:"data"`
+ }
+ if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
+ t.Fatal(err)
+ }
+ if len(payload.Data.Items) != 2 {
+ t.Fatal("list count")
+ }
+ for _, item := range payload.Data.Items {
+ value, present := item["sybIsCancelled"]
+ if !present {
+ t.Fatal("nullable field absent")
+ }
+ if item["detailId"] == float64(711) && value != false {
+ t.Fatal("false omitted")
+ }
+ if item["detailId"] == float64(712) && value != nil {
+ t.Fatal("unknown not null")
+ }
+ if _, present := item["sybStatusSyncedAt"]; !present {
+ t.Fatal("timestamp absent")
+ }
+ }
+ response = httptest.NewRecorder()
+ router.ServeHTTP(response, httptest.NewRequest("GET", "/products/1", nil))
+ if response.Code != 200 {
+ t.Fatalf("detail: %d", response.Code)
+ }
+ var detailPayload struct {
+ Data struct {
+ Item models.SYBProduct `json:"item"`
+ } `json:"data"`
+ }
+ if err := json.Unmarshal(response.Body.Bytes(), &detailPayload); err != nil {
+ t.Fatal(err)
+ }
+ if detailPayload.Data.Item.SYBIsCancelled == nil || *detailPayload.Data.Item.SYBIsCancelled || detailPayload.Data.Item.SYBStatusSyncedAt == nil {
+ t.Fatal("detail must preserve false and time")
+ }
+}
+
+func boolPointer(v bool) *bool { return &v }
diff --git a/server/app/goauto/sybimport/sync.go b/server/app/goauto/sybimport/sync.go
index 624c0b1..60a46a4 100644
--- a/server/app/goauto/sybimport/sync.go
+++ b/server/app/goauto/sybimport/sync.go
@@ -413,6 +413,11 @@ func applyStockDetail(ctx context.Context, db *gorm.DB, row sybclient.StockRow,
StockID: uint64(detail.ID),
ShopName: stringField(row.Raw, "shopName"),
SYBStockCreatedAt: stockCreated,
+ SYBIsCancelled: parseSYBIsCancelled(row.Raw),
+ }
+ if order.SYBIsCancelled != nil {
+ now := time.Now().UTC()
+ order.SYBStatusSyncedAt = &now
}
if order.Code == "" {
order.Code = row.Code
diff --git a/server/cmd/migrate/migration/version-local/1791200000000_syb_cancel_status.go b/server/cmd/migrate/migration/version-local/1791200000000_syb_cancel_status.go
new file mode 100644
index 0000000..15e95bb
--- /dev/null
+++ b/server/cmd/migrate/migration/version-local/1791200000000_syb_cancel_status.go
@@ -0,0 +1,29 @@
+package version_local
+
+import (
+ "runtime"
+
+ "go-admin/app/goauto/models"
+ "go-admin/cmd/migrate/migration"
+ common "go-admin/common/models"
+ "gorm.io/gorm"
+)
+
+func init() {
+ _, file, _, _ := runtime.Caller(0)
+ migration.Migrate.SetVersion(migration.GetFilename(file), migrateSYBCancelStatus)
+}
+
+// Nullable, display-only source evidence. Never assume legacy rows are active.
+func migrateSYBCancelStatus(db *gorm.DB, version string) error {
+ return db.Transaction(func(tx *gorm.DB) error {
+ for _, column := range []string{"SYBIsCancelled", "SYBStatusSyncedAt"} {
+ if !tx.Migrator().HasColumn(&models.SYBProduct{}, column) {
+ if err := tx.Migrator().AddColumn(&models.SYBProduct{}, column); err != nil {
+ return err
+ }
+ }
+ }
+ return tx.Where("version = ?", version).FirstOrCreate(&common.Migration{Version: version}).Error
+ })
+}
diff --git a/server/cmd/migrate/migration/version-local/1791200000000_syb_cancel_status_test.go b/server/cmd/migrate/migration/version-local/1791200000000_syb_cancel_status_test.go
new file mode 100644
index 0000000..2caabcf
--- /dev/null
+++ b/server/cmd/migrate/migration/version-local/1791200000000_syb_cancel_status_test.go
@@ -0,0 +1,60 @@
+package version_local
+
+import (
+ common "go-admin/common/models"
+ "gorm.io/driver/sqlite"
+ "gorm.io/gorm"
+ "testing"
+)
+
+func TestMigrateSYBCancelStatusIdempotentAndNullable(t *testing.T) {
+ db, err := gorm.Open(sqlite.Open("file:syb_cancel_migration?mode=memory&cache=shared"), &gorm.Config{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ sqlDB, _ := db.DB()
+ t.Cleanup(func() { sqlDB.Close() })
+ if err := db.Exec("CREATE TABLE syb_product (id integer primary key)").Error; err != nil {
+ t.Fatal(err)
+ }
+ if err := db.Exec("INSERT INTO syb_product(id) VALUES(1)").Error; err != nil {
+ t.Fatal(err)
+ }
+ if err := db.AutoMigrate(&common.Migration{}); err != nil {
+ t.Fatal(err)
+ }
+ for i := 0; i < 2; i++ {
+ if err := migrateSYBCancelStatus(db, "test_cancel_status"); err != nil {
+ t.Fatal(err)
+ }
+ }
+ var row struct {
+ SYBIsCancelled *bool
+ SYBStatusSyncedAt *string
+ }
+ if err := db.Table("syb_product").Take(&row).Error; err != nil {
+ t.Fatal(err)
+ }
+ if row.SYBIsCancelled != nil || row.SYBStatusSyncedAt != nil {
+ t.Fatal("legacy must remain unknown")
+ }
+ var count int64
+ if err := db.Model(&common.Migration{}).Where("version = ?", "test_cancel_status").Count(&count).Error; err != nil {
+ t.Fatal(err)
+ }
+ if count != 1 {
+ t.Fatal("duplicate migration record")
+ }
+ if err := db.Exec("UPDATE syb_product SET syb_is_cancelled = 0 WHERE id = 1").Error; err != nil {
+ t.Fatal(err)
+ }
+ if err := migrateSYBCancelStatus(db, "test_cancel_status"); err != nil {
+ t.Fatal(err)
+ }
+ if err := db.Table("syb_product").Take(&row).Error; err != nil {
+ t.Fatal(err)
+ }
+ if row.SYBIsCancelled == nil || *row.SYBIsCancelled {
+ t.Fatal("rerun must retain explicit false")
+ }
+}
diff --git a/web/src/views/goauto/syb-products/index.vue b/web/src/views/goauto/syb-products/index.vue
index d3e3a7e..734a2ae 100644
--- a/web/src/views/goauto/syb-products/index.vue
+++ b/web/src/views/goauto/syb-products/index.vue
@@ -40,6 +40,9 @@