feat(#357): display SYB cancellation status without changing procurement

This commit is contained in:
QiuSW
2026-10-05 17:51:07 +08:00
parent e4d4a6fa79
commit d6639a4a0d
10 changed files with 359 additions and 5 deletions
+3
View File
@@ -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
+11 -1
View File
@@ -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 &&
@@ -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
}
@@ -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 }
+5
View File
@@ -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
@@ -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
})
}
@@ -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")
}
}
+6 -1
View File
@@ -40,6 +40,9 @@
<el-table-column v-if="canPurchase" 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="SYB状态" width="72" class-name="syb-status-column">
<template #default="{ row }"><el-tag size="small" :type="row.sybIsCancelled === true ? 'danger' : 'info'">{{ row.sybIsCancelled === true ? '已取消' : row.sybIsCancelled === false ? '未取消' : '未获取' }}</el-tag></template>
</el-table-column>
<el-table-column label="虾皮商品" width="260"><template #default="{ row }"><template v-if="row.shopeeProductId"><a class="link" href="javascript:void(0)" @click="openShopeeDetail(row.shopeeProductId)">{{ row.shopeeItemId }} ↗</a></template><template v-else><span>{{ row.shopeeItemId || '—' }}</span><el-tag size="small" type="warning" effect="plain" class="unlinked-tag">未关联</el-tag></template><div class="muted shopee-title" :title="row.productTitle || ''">{{ 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>
@@ -73,7 +76,7 @@
<div v-if="quickBusyId === row.id || quickMessages[row.id]" class="muted" aria-live="polite">{{ quickBusyId === row.id ? quickPhase : quickMessages[row.id] }}</div>
</template>
</el-table-column>
<el-table-column label="匹配到的退货商品" min-width="200">
<el-table-column label="匹配退货" min-width="160">
<template #default="{ row }">
<template v-if="returnMatchByProductId[row.id]">
<div class="return-match-cell">
@@ -996,6 +999,8 @@ export default {
.quick-link-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:4px 0}
.thumb{width:44px;height:44px;border-radius:4px;object-fit:cover}.thumb.placeholder{display:flex;align-items:center;justify-content:center;background:#f1f5f9;color:#909399;font-size:11px}
.return-match-cell{display:flex;gap:8px;align-items:flex-start}
.return-match-cell>.thumb{flex-shrink:0}.return-match-cell>div{min-width:0;overflow-wrap:anywhere}
:deep(.syb-status-column .cell){padding:0 6px;white-space:nowrap}
.link{color:#1677ff;cursor:pointer}
.shopee-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.notice{margin-bottom:16px}
+60
View File
@@ -0,0 +1,60 @@
import { expect, test } from '@playwright/test'
const menus = [{ path: '/workbench', component: 'Layout', menuName: 'Workbench', title: '工作台', visible: '0', children: [{ path: '/syb-products', component: '/goauto/syb-products/index', menuName: 'GoAutoSybProducts', title: 'SYB 商品', visible: '0' }] }]
for (const [width, role] of [[1920, 'admin'], [1440, 'after_sales'], [375, 'purchaser'], [812, 'purchaser']] as const) {
test(`status display and retained return controls ${width} ${role}`, async ({ page, context }, testInfo) => {
await page.setViewportSize({ width, height: width === 812 ? 375 : 1080 })
await page.emulateMedia({ reducedMotion: 'reduce' })
await context.addCookies([{ name: 'Admin-Token', value: 'mock-token', domain: 'localhost', path: '/' }])
const writes: string[] = []
await page.route('**/api/**', async route => {
const path = new URL(route.request().url()).pathname
if (path.startsWith('/src/api/')) return route.continue()
const reply = (data: unknown) => route.fulfill({ json: { code: 200, data } })
if (path.endsWith('/api/v1/menurole')) return reply(menus)
if (path.endsWith('/api/v1/getinfo')) return reply({ roles: [role], name: '模拟用户', avatar: '', permissions: [] })
if (path.endsWith('/syb-products')) return reply({ items: [true, false, null, undefined].map((sybIsCancelled, index) => ({ id: index + 1, orderCode: `MOCK-${index + 1}`, shopeeItemId: String(index + 101), shopeeProductId: index + 101, productTitle: '模拟商品', quantity: 1, parseStatus: 'success', targetColor: '黑色', targetSize: 'XL', sybIsCancelled })), total: 4 })
if (path.endsWith('/purchase-tasks/batch-preview')) return reply({ items: [1, 2, 3, 4].map(id => ({ sybProductId: id, processStage: id === 1 ? 'purchase_ready' : 'return_pending', processStageLabel: id === 1 ? '可创建采购' : '退货待确认', processStageReason: id === 1 ? '模拟:原采购资格不变' : '模拟:等待确认使用退货', eligible: id === 1, collectionEligible: false, aiMatchEligible: false })) })
if (path.endsWith('/return-matches')) return reply({ items: [2, 3].map(id => ({ id: id + 10, sybProductId: id, status: 'matched', image: 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="44" height="44"%3E%3Crect width="44" height="44" fill="%23dbeafe"/%3E%3C/svg%3E', orderSn: 'MOCK-RETURN-LONG-123456', variationName: '黑色长规格ABCDEFGHIJKLMNOPQRSTUVWXYZ', destroyDeadline: '2020-01-01T00:00:00Z', syncStatus: 'missing' })) })
if (route.request().method() !== 'GET') writes.push(path)
return reply({ items: [], total: 0 })
})
await page.goto('/#/syb-products')
const table = page.locator('.el-table').first()
const rows = table.locator('tbody tr')
await expect(rows).toHaveCount(4)
await expect(rows.nth(0).locator('.syb-status-column')).toHaveText('已取消')
await expect(rows.nth(0).locator('.syb-status-column .el-tag')).toHaveClass(/el-tag--danger/)
await expect(rows.nth(1).locator('.syb-status-column')).toHaveText('未取消')
await expect(rows.nth(2).locator('.syb-status-column')).toHaveText('未获取')
await expect(rows.nth(3).locator('.syb-status-column')).toHaveText('未获取')
await expect(page.getByRole('columnheader', { name: '匹配退货', exact: true })).toHaveCount(1)
await expect(page.getByRole('columnheader', { name: '匹配到的退货商品', exact: true })).toHaveCount(0)
// Display-only: a cancelled row retains the old purchase eligibility.
await expect(rows.first().getByRole('button', { name: '采购', exact: true })).toBeEnabled()
await expect(rows.nth(1).getByRole('button', { name: '查看对比' })).toHaveCount(1)
await expect(rows.nth(1).getByRole('button', { name: '取消匹配', exact: true })).toBeEnabled()
if (width >= 1440) {
await expect(rows.nth(1).locator('.return-match-cell .thumb')).toHaveCSS('width', '44px')
await rows.first().locator('.el-checkbox').click()
await expect(page.getByText('本页选择 1 条', { exact: true })).toBeVisible()
}
const metrics = await table.evaluate(el => {
const status = el.querySelector('th.syb-status-column')!
const cells = [...el.querySelectorAll('.syb-status-column .cell')]
const wrap = el.querySelector('.el-scrollbar__wrap')!
return { width: status.getBoundingClientRect().width, fits: cells.every(cell => cell.scrollWidth <= cell.clientWidth && cell.getBoundingClientRect().height <= 24), client: wrap.clientWidth, scroll: wrap.scrollWidth }
})
expect(metrics.width).toBe(72)
expect(metrics.fits).toBe(true)
expect(writes).toEqual([])
await testInfo.attach('layout-metrics', { body: JSON.stringify(metrics), contentType: 'application/json' })
if (width >= 1440) {
await page.screenshot({ path: testInfo.outputPath(`syb-status-${width}.png`), fullPage: true })
await table.locator('.el-scrollbar__wrap').first().evaluate(el => { el.scrollLeft = el.scrollWidth })
await expect(rows.nth(1).getByRole('button', { name: '取消匹配', exact: true })).toBeVisible()
await page.screenshot({ path: testInfo.outputPath(`syb-return-${width}.png`), fullPage: true })
}
})
}
+4 -3
View File
@@ -60,9 +60,10 @@ test('SYB 商品工具栏与列宽按紧凑布局显示', async({ page, context
await expect(page.getByRole('button', { name: /创建采集\s*1/ })).toBeEnabled()
const widths = await page.locator('.el-table__header-wrapper colgroup col').evaluateAll(cols => cols.map(col => Number(col.getAttribute('width'))))
expect(widths[3]).toBe(260)
expect(widths[9]).toBeGreaterThanOrEqual(210)
expect(widths[11]).toBe(132)
expect(widths[3]).toBe(72)
expect(widths[4]).toBe(260)
expect(widths[10]).toBeGreaterThanOrEqual(210)
expect(widths[12]).toBe(132)
await page.locator('.shop-input').click()
await page.getByRole('option', { name: '大碼臻選' }).click()