Compare commits

..
Author SHA1 Message Date
QiuSW 1858087a56 docs(#356): sync purchase order filter contract 2026-10-05 16:37:54 +08:00
QiuSW bb3990e432 feat(#356): support pasted purchase order filters 2026-10-05 16:33:38 +08:00
22 changed files with 438 additions and 425 deletions
+2 -11
View File
@@ -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: 570383858b934c4d0550f2dff7b755f1dd962888
synchronized_at: 2026-10-05T10:13:30Z
wiki_revision: 3792feb41f8f74ffc69faae757e631254c3a23e2
synchronized_at: 2026-10-05T08:35:53Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
@@ -608,12 +608,3 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
- `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 `4567a24`,经用户授权在线执行迁移 `1791200000000` 并发布 Server/Web 到 122.228.200.167。仅展示状态,未触发真实同步;本机业务库未迁移。
- `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 执行或真实同步后的页面。先授权迁移,再发布才能用于业务环境。
+2 -11
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: 8ee23d9c80ebba3cb6cff51e8e25eb1579ab9ac7
synchronized_at: 2026-10-05T10:13:34Z
wiki_revision: 60b4c649f87178c079392a101faf00db9b355263
synchronized_at: 2026-10-05T08:35:56Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
@@ -781,12 +781,3 @@ Android 0.9.64 / versionCode 77,源码 `6550b9f`(分支实现,尚未安装
原位置输入框默认两行、最多四行;多值显示数量,超限字段提示且不提交,Enter 换行,点击原查询按钮提交。搜索回第一页,翻页保留条件,清空/重置恢复原查询;失败保留输入、清除旧勾选和旧结果,不自动重试请求。沿用现有筛选表单和提示规范,不增加审批或批次管理。
检索本身不重试采购;采购员仍须选中当前页可重试失败任务,经原确认流程执行。#303 的现行普通批量重试为原任务重置,保留CG编号并增加attempt,不新建任务。本单没有修改任何重试资格、执行、下单、支付或回填行为。
## SYB 订单取消状态只读展示(#357)
实现绑定 `d6639a4`;2026-10-05 已合并 main `4567a24`,在线迁移并发布至 122.228.200.167。旧记录保持未获取,随后按既有正常同步范围更新;不新增采购拦截。
- “SYB状态”仅显示来源取消标记:`isCancel=1` 为“已取消”,`0` 为“未取消”,没有有效证据为“未获取”。“未取消”不等于可采购,不表达其他 SYB 业务状态,不由 status/orderStatus、规格解析或 PDD 排除标记推断。
- 正常同步成功取得有效值后覆盖原值,允许由取消变回未取消;失败、字段缺失/null、未知值或空页不改变既有取消状态与取得时间。历史记录不默认未取消。
- 仅更新同步范围内实际导入的明细;超出同步创建日期范围的旧订单保留上次状态,未经同步的显示未获取。此列不是实时核验,列表加载不会请求 SYB。
- 本单严格只做展示:不修改创建、批量、重试、Claim/Start、下单边界、采购资格或任务状态,不自动拦截已取消订单,不影响已存在 PDD 订单或回填事实。后续采购拦截另行评估。
- “匹配退货”仅是原“匹配到的退货商品”的表头缩短和列宽调整,内容、权限、匹配/使用/取消规则不变。允许少量表格横向滚动。
+2 -14
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: 5818776a54c3784fbe5d691d830920f9b8f8b888
synchronized_at: 2026-10-05T10:14:02Z
wiki_revision: 0d3b04cf5318051b1179cbac47521d88e77e2a57
synchronized_at: 2026-10-05T08:36:13Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
@@ -1460,15 +1460,3 @@ Web 批量入口冻结原匹配 ID 并顺序调用;HTTP 409 状态冲突跳过
集合条目支持空白(包括换行、Tab)与中英文逗号拆分、去空项、去重,标识保留为字符串;最多100个去重值,每个最多64个 Unicode 字符。显式集合全空、超限、过长或同时提供非空 `shopeeOrderNo` 返回既有 `PURCHASE_INVALID_REQUEST` 错误,不忽略错误后查全部。集合参数只有一个值时仍按精确值查询。旧标量 `shopeeOrderNo` 保留包含查询,首尾去空白,长度上限64。
Web 输入去重后一个值提交旧标量、多个值提交重复集合键,空输入不提交订单筛选。查询不执行采购、重试或回填;权限、分页上限、当前页重试资格和接口均不变。100个订单号可能对应超过100个任务,仍逐页展示/勾选,不提供跨页全选。Server 与 Web 成套发布/回退,旧 Server 未支持集合参数前不单独上线新 Web。
## 管理端 SYB 商品来源取消状态(#357)
实现绑定 `d6639a4`;2026-10-05 已合并 main `4567a24`,经用户授权在线执行追加迁移 `1791200000000_syb_cancel_status.go` 并发布至 122.228.200.167,商品列表只读接口已核验 nullable 字段。本机业务库未迁移;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 对旧响应缺字段显示未获取。列表与详情不触发额外上游请求。取消状态仅展示,不改变采购准备阶段、创建/重试资格、任务执行或现有订单事实。
+2 -13
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Deployment-and-Operations
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-and-Operations.-
wiki_revision: 84231a25479bb6563645f5b8104805d4f5336349
synchronized_at: 2026-10-05T10:13:42Z
wiki_revision: 6d3c599a45813ad844395e93a02832f0d28dd3f5
synchronized_at: 2026-09-29T03:47:47Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
@@ -246,14 +246,3 @@ Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、
- 旧版本出现崩溃现场,必须先确认原 worker 已退出、目标记录没有匹配计划和远端写执行证据,再按授权做精确条件事务收敛;禁止无条件整日重置或重试仍会崩溃的旧接口。修复上线后手动勾选失败项匹配,ready 后另行确认回写。
实现绑定:`fix/350-inner-code-matching`,源码提交 `9c5e20e`;未合并 main、未部署,正式环境仍需发布后启用。
## #357 来源取消状态展示发布(2026-10-05)
- 用户授权合并 main、发布及线上追加迁移;发布源码 `4567a24f05c9307d66d189a1fd28225b98b2a16e`,目录 `/home/goauto/releases/20261005-4567a24-357`,`current` 已原子切换并重启 `goauto.service`。只部署 Server/Web,不安装 Android;本机业务库未迁移。
- MySQL 8.4.8 上唯一待执行版本为 `1791200000000`。使用同版本 mysqldump 的 single-transaction 备份到 `/home/goauto/backups/20261005-357/database.sql`(目录0700、文件0600),不下载生产数据。迁移输出保存在同目录受限 `migrate.log`,不复制原始日志到工单。
- 迁移追加 `syb_product.syb_is_cancelled`(nullable tinyint(1))和 `syb_status_synced_at`(nullable datetime(3));迁移记录回读通过,无剩余待执行版本。历史状态保持 null,不猜测或批量回填。
- 切换前采集、采购、同步、AI、回填及批次运行检查无执行中工作,既有 pending 任务保留;未主动触发同步、采购或回填,没有修改 sys_job 的启停、Cron 与参数。原有开启任务继续正常运行。
- 服务沿用原 config、环境文件和 Nginx;Nginx 配置检查通过,本次无配置改动,无需 reload。static/temp/var 分别直接引用已有真实目录 `20260928-344-9dace6a/static`、`20260930-352-1f3ce61/temp`、`20260929-340/var`(均位于 releases 下),保留已有图片、APK和运行文件。
- 公网首页与本次 dist/index.html 字节一致,SPA入口与10项JS/CSS资源、健康及验证码接口正常;已认证商品列表返回 nullable 新字段。旧记录显示“未获取”,正常同步对应来源日期后才更新;本次不自动补同步,不宣称真实同步三态已验收。
- Server SHA256:`73f169cc8091c34dac29f76b6125e68596b54517a18c4e1c7507a3f02f1dbd5d`;Web包 SHA256:`3edd072d0e44a8ea9768ba906f603c84ca872fad3f553ee91aff0815b58ab488`。
- 回滚代码目标 `/home/goauto/releases/20261005-ddc18f5-353-355` 保留;需先检查运行任务再切回并重启,保留追加列与新数据,不通过删列或恢复旧备份覆盖后续业务。
-3
View File
@@ -535,9 +535,6 @@ 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
@@ -0,0 +1,153 @@
package purchase
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"reflect"
"strings"
"testing"
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"github.com/google/uuid"
"go-admin/app/goauto/models"
"go-admin/app/goauto/purchasecontract"
)
func TestNormalizeAdminOrderNos(t *testing.T) {
hundred := make([]string, 100)
for i := range hundred {
hundred[i] = fmt.Sprintf("DEMO-%03d", i)
}
for _, tc := range []struct {
name, single string
raw, want []string
invalid bool
}{
{name: "absent"},
{name: "legacy single", single: "PARTIAL"},
{name: "mixed and duplicate", raw: []string{" 001\r\nDEMO-A,DEMO-B\t001", "DEMO-A,DEMO-C\u0085\uFEFF"}, want: []string{"001", "DEMO-A", "DEMO-B", "DEMO-C"}},
{name: "100", raw: hundred, want: hundred},
{name: "duplicates do not count", raw: append(append([]string{}, hundred...), hundred...), want: hundred},
{name: "101", raw: append(append([]string{}, hundred...), "EXTRA"), invalid: true},
{name: "64 runes", raw: []string{strings.Repeat("中", 64)}, want: []string{strings.Repeat("中", 64)}},
{name: "65 runes", raw: []string{"GOOD", strings.Repeat("中", 65)}, invalid: true},
{name: "single too long", single: strings.Repeat("A", 65), invalid: true},
{name: "empty collection", raw: []string{}, invalid: true},
{name: "blank collection", raw: []string{" ,,\r\n\t"}, invalid: true},
{name: "conflicting parameters", single: "PART", raw: []string{"DEMO-A"}, invalid: true},
} {
t.Run(tc.name, func(t *testing.T) {
got, err := normalizeAdminOrderNos(tc.single, tc.raw)
if tc.invalid {
if code(err) != CodeInvalidRequest {
t.Fatalf("expected invalid request, got %v", err)
}
return
}
if err != nil || !reflect.DeepEqual(got, tc.want) {
t.Fatalf("got %v, %v; want %v", got, err, tc.want)
}
})
}
}
func TestAdminOrderNosExactFilterAndPagination(t *testing.T) {
db := testDB(t)
f := seed(t, db, []string{purchasecontract.CapabilityPurchaseRehearsalV1}, true)
s := testService(db)
base := createAdminQueryTask(t, s, f)
// Isolated fixture records only; no device execution or external order creation.
for i, orderNo := range []string{"001", "DEMO-A", "DEMO-A", "XDEMO-A", "DEMO-A2", "DEMO-B", "' OR 1=1 --"} {
row := base
row.ID = 0
row.CreateRequestID = uuid.NewString()
row.ShopeeOrderNoSnapshot = orderNo
row.ShopeeTitleSnapshot = "DEMO-A"
row.Status = models.PurchaseTaskStatusFailed
if i == 5 {
row.Status = models.PurchaseTaskStatusCancelled
}
if err := db.Create(&row).Error; err != nil {
t.Fatal(err)
}
}
req := AdminListRequest{Page: 1, PageSize: 2, Status: models.PurchaseTaskStatusFailed, ShopeeOrderNos: []string{"001", "DEMO-A", "DEMO-B"}}
first, err := s.AdminList(context.Background(), req)
if err != nil || first.Total != 3 || len(first.Items) != 2 {
t.Fatalf("first: %+v %v", first, err)
}
req.Page = 2
second, err := s.AdminList(context.Background(), req)
if err != nil || second.Total != 3 || len(second.Items) != 1 {
t.Fatalf("second: %+v %v", second, err)
}
seen := map[uint64]bool{}
for _, row := range append(first.Items, second.Items...) {
if seen[row.ID] || (row.ShopeeOrderNoSnapshot != "001" && row.ShopeeOrderNoSnapshot != "DEMO-A") {
t.Fatalf("unexpected row %+v", row)
}
seen[row.ID] = true
}
for _, query := range [][]string{{"MISSING", "DEMO"}, {"%", "_"}, {"' OR 1=1 --", "MISSING"}} {
out, err := s.AdminList(context.Background(), AdminListRequest{ShopeeOrderNos: query})
if err != nil || out.Total != 0 {
t.Fatalf("unexpected broadened query %v: %+v %v", query, out, err)
}
}
// A repeated-key collection remains exact even with one item; old scalar stays LIKE.
out, err := s.AdminList(context.Background(), AdminListRequest{ShopeeOrderNos: []string{"DEMO-A"}})
if err != nil || out.Total != 2 {
t.Fatalf("one-item collection: %+v %v", out, err)
}
out, err = s.AdminList(context.Background(), AdminListRequest{ShopeeOrderNo: "DEMO-A"})
if err != nil || out.Total != 4 {
t.Fatalf("legacy LIKE: %+v %v", out, err)
}
}
func TestAdminOrderNosHandler(t *testing.T) {
gin.SetMode(gin.TestMode)
db := testDB(t)
f := seed(t, db, []string{purchasecontract.CapabilityPurchaseRehearsalV1}, true)
s := testService(db)
row := createAdminQueryTask(t, s, f)
if err := db.Model(&row).Update("shopee_order_no_snapshot", "DEMO-A").Error; err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
query string
invalid bool
}{
{"shopeeOrderNos=DEMO-A&shopeeOrderNos=DEMO-B", false},
{"shopeeOrderNos=DEMO-A%2CDEMO-B", false},
{"shopeeOrderNo=DEMO", false},
{"shopeeOrderNos=", true},
{"shopeeOrderNos=DEMO-A&shopeeOrderNo=DEMO", true},
{"shopeeOrderNos=" + url.QueryEscape(strings.Repeat("A", 65)), true},
} {
for _, role := range []string{"admin", "purchaser", "after_sales"} {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("JWT_PAYLOAD", jwt.MapClaims{"rolekey": role})
c.Request = httptest.NewRequest(http.MethodGet, "/api/admin/v1/purchase-tasks?"+tc.query, nil)
(Handler{DB: db}).AdminList(c)
if tc.invalid {
if !strings.Contains(w.Body.String(), CodeInvalidRequest) {
t.Fatalf("invalid query accepted: %s", w.Body.String())
}
continue
}
var result struct {
Data AdminListResponse `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil || w.Code != http.StatusOK || result.Data.Total != 1 {
t.Fatalf("query %s: %d %s", tc.query, w.Code, w.Body.String())
}
}
}
}
+56 -9
View File
@@ -6,6 +6,8 @@ import (
"errors"
"strings"
"time"
"unicode"
"unicode/utf8"
"go-admin/app/goauto/models"
@@ -13,15 +15,16 @@ import (
)
type AdminListRequest struct {
Page int
PageSize int
TaskID uint64
Status string
ExecutionMode string
TaskType string
SYBProductID uint64
ShopeeOrderNo string
PDDOrderNo string
Page int
PageSize int
TaskID uint64
Status string
ExecutionMode string
TaskType string
SYBProductID uint64
ShopeeOrderNo string
ShopeeOrderNos []string
PDDOrderNo string
}
type AdminTaskItem struct {
@@ -112,7 +115,48 @@ type AdminDetailResponse struct {
// Query-only value: never a PurchaseTask status or a logistics writeback state.
const adminStatusSYBWritebackSucceeded = "syb_writeback_succeeded"
// A nil list means the optional filter was absent; an explicitly empty list
// must not silently turn a restricted lookup into an unfiltered query.
func normalizeAdminOrderNos(single string, raw []string) ([]string, error) {
single = strings.TrimSpace(single)
if utf8.RuneCountInString(single) > 64 {
return nil, fail(CodeInvalidRequest, "每个蝦皮订单号最多 64 个字符")
}
if raw == nil {
return nil, nil
}
if single != "" {
return nil, fail(CodeInvalidRequest, "蝦皮订单号单值与批量参数不能同时使用")
}
values := make([]string, 0, len(raw))
seen := make(map[string]bool)
for _, entry := range raw {
for _, value := range strings.FieldsFunc(entry, func(r rune) bool {
return r == ',' || r == ',' || unicode.IsSpace(r) || r == '\uFEFF'
}) {
if utf8.RuneCountInString(value) > 64 {
return nil, fail(CodeInvalidRequest, "每个蝦皮订单号最多 64 个字符")
}
if !seen[value] {
seen[value] = true
values = append(values, value)
if len(values) > 100 {
return nil, fail(CodeInvalidRequest, "最多查询 100 个蝦皮订单号,请分批查询")
}
}
}
}
if len(values) == 0 {
return nil, fail(CodeInvalidRequest, "批量蝦皮订单号不能为空")
}
return values, nil
}
func (s *Service) AdminList(ctx context.Context, req AdminListRequest) (AdminListResponse, error) {
orderNos, err := normalizeAdminOrderNos(req.ShopeeOrderNo, req.ShopeeOrderNos)
if err != nil {
return AdminListResponse{}, err
}
if req.Page < 1 {
req.Page = 1
}
@@ -152,6 +196,9 @@ func (s *Service) AdminList(ctx context.Context, req AdminListRequest) (AdminLis
if orderNo := strings.TrimSpace(req.ShopeeOrderNo); orderNo != "" {
query = query.Where("shopee_order_no_snapshot LIKE ?", "%"+orderNo+"%")
}
if len(orderNos) > 0 {
query = query.Where("shopee_order_no_snapshot IN ?", orderNos)
}
if orderNo := strings.TrimSpace(req.PDDOrderNo); orderNo != "" {
query = query.Where("pdd_order_no LIKE ?", "%"+orderNo+"%")
}
+4 -3
View File
@@ -52,9 +52,10 @@ 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")),
TaskType: strings.TrimSpace(c.Query("taskType")),
ShopeeOrderNo: strings.TrimSpace(c.Query("shopeeOrderNo")),
PDDOrderNo: strings.TrimSpace(c.Query("pddOrderNo")),
TaskType: strings.TrimSpace(c.Query("taskType")),
ShopeeOrderNo: strings.TrimSpace(c.Query("shopeeOrderNo")),
ShopeeOrderNos: c.Request.URL.Query()["shopeeOrderNos"],
PDDOrderNo: strings.TrimSpace(c.Query("pddOrderNo")),
})
if err != nil {
writeError(c, err)
+1 -11
View File
@@ -26,8 +26,6 @@ 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
@@ -141,8 +139,7 @@ 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,
SYBIsCancelled: order.SYBIsCancelled, SYBStatusSyncedAt: order.SYBStatusSyncedAt,
ShopeeItemID: shopeeItemID, ProductTitle: detail.ProductTitle,
ShopeeItemID: shopeeItemID, ProductTitle: detail.ProductTitle,
TargetColor: parsed.Color, TargetSize: parsed.Size,
Quantity: detail.ProductQty, UnitPriceCent: roundCents(detail.ProductPrice),
ImageURL: buildImageURL(detail.ProductThumb),
@@ -201,13 +198,6 @@ 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 &&
@@ -1,26 +0,0 @@
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
}
@@ -1,155 +0,0 @@
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,11 +413,6 @@ 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
@@ -1,29 +0,0 @@
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
})
}
@@ -1,60 +0,0 @@
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")
}
}
+19 -1
View File
@@ -1,6 +1,24 @@
import request from '@/utils/request'
export function listPurchaseTasks(params) { return request({ url: '/api/admin/v1/purchase-tasks', method: 'get', params }) }
export function listPurchaseTasks(params) {
return request({
url: '/api/admin/v1/purchase-tasks', method: 'get', params,
paramsSerializer: {
serialize(values) {
const query = new URLSearchParams()
Object.entries(values).forEach(([key, value]) => {
if (value == null) return
if (key === 'shopeeOrderNos' && Array.isArray(value)) {
// Preserve an explicitly empty list so the server rejects it.
if (!value.length) query.append(key, '')
value.forEach(item => query.append(key, item))
} else query.append(key, value)
})
return query.toString()
}
}
})
}
export function getPurchaseTask(taskId) { return request({ url: `/api/admin/v1/purchase-tasks/${taskId}`, method: 'get' }) }
export function previewPurchaseTasks(data, options = {}) { return request({ url: '/api/admin/v1/purchase-tasks/batch-preview', method: 'post', data, ...options }) }
export function matchPurchaseSpecsBatch(data) { return request({ url: '/api/admin/v1/purchase-tasks/batch-spec-match', method: 'post', data, timeout: 610000 }) }
+10
View File
@@ -0,0 +1,10 @@
// Match the Server's whitespace/comma splitting; keep identifiers as strings.
export function parsePurchaseOrderFilter(raw = '') {
const values = [...new Set(raw.split(/[,,\s\u0085]+/u).filter(Boolean))]
const error = values.length > 100
? '最多查询 100 个蝦皮订单号,请分批查询'
: values.some(value => [...value].length > 64)
? '每个蝦皮订单号最多 64 个字符'
: ''
return { values, error }
}
+23 -4
View File
@@ -6,11 +6,17 @@
<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-select v-model="query.taskType" clearable placeholder="全部类型" style="width:130px"><el-option label="订单采购" value="syb_order" /><el-option label="备货采购" value="stock" /></el-select></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="蝦皮订单号" for="purchase-order-filter">
<div class="shopee-order-filter">
<el-input id="purchase-order-filter" v-model="query.shopeeOrderNo" type="textarea" :autosize="{ minRows: 2, maxRows: 4 }" resize="none" placeholder="可粘贴多个订单号,换行、逗号或空格分隔" aria-describedby="purchase-order-hint purchase-order-error" :aria-invalid="Boolean(orderFilter.error)" />
<div id="purchase-order-hint" class="order-filter-hint"><span v-if="orderFilter.values.length > 1" aria-live="polite">已输入 {{ orderFilter.values.length }} 个订单号。<br></span>单个支持包含查询,多个按完整订单号匹配。</div>
<div v-if="orderFilter.error" id="purchase-order-error" class="order-filter-error" role="alert">{{ orderFilter.error }}</div>
</div>
</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>
<el-form-item><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button></el-form-item>
<el-form-item><el-button type="primary" :icon="Search" :disabled="Boolean(orderFilter.error)" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button></el-form-item>
</el-form>
<div class="batch-actions"><span class="selected-copy">已选择 <strong>{{ retrySelection.length }}</strong> 条</span><el-button type="warning" :disabled="retryCandidates.length === 0" :loading="retryDialog.saving" @click="openRetryDialog">重试({{ retryCandidates.length }})</el-button><el-button type="primary" plain :disabled="orderWritebackCandidates.length === 0" :loading="orderWriteback.saving" @click="submitOrderWriteback(orderWritebackCandidates)">批量回填 SYB({{ orderWritebackCandidates.length }})</el-button></div>
@@ -142,6 +148,7 @@
</template>
<script>
import { parsePurchaseOrderFilter } from '@/utils/purchase-order-filter'
import { ElMessage, ElMessageBox } from 'element-plus'
import { RefreshLeft, Search } from '@element-plus/icons-vue'
import { authorizeRepurchase, cancelPurchaseTask, choosePurchaseMatching, getPurchaseTask, listPurchaseTasks, requeuePurchaseMatching, resolveUnknownPurchaseTask, retryPurchaseTasksBatch, reviewPurchasePayment, selectPurchaseWriteback, writebackPurchaseOrderNumbers } from '@/api/goauto/purchase-tasks'
@@ -171,6 +178,7 @@ export default {
}
},
computed: {
orderFilter() { return parsePurchaseOrderFilter(this.query.shopeeOrderNo) },
retryCandidates() { return this.retrySelection.filter(this.isRetrySelectable) },
orderWritebackCandidates() { return this.retrySelection.filter(this.canOrderWriteback) },
detailAlert() {
@@ -200,10 +208,19 @@ export default {
},
methods: {
async load() {
this.retrySelection = []
this.$refs.taskTable?.clearSelection()
if (this.orderFilter.error) { this.tasks = []; this.total = 0; return }
this.loading = true; this.loadError = ''
try { const r = await listPurchaseTasks(this.cleanQuery()); this.tasks = r.data.items || []; this.total = r.data.total || 0; this.retrySelection = []; this.$nextTick(() => this.$refs.taskTable?.clearSelection()) } catch (error) { this.loadError = error?.message || '采购任务加载失败,请稍后重试' } finally { this.loading = false }
try { const r = await listPurchaseTasks(this.cleanQuery()); this.tasks = r.data.items || []; this.total = r.data.total || 0; this.retrySelection = []; this.$nextTick(() => this.$refs.taskTable?.clearSelection()) } catch (error) { this.tasks = []; this.total = 0; this.loadError = error?.message || '采购任务加载失败,请稍后重试' } finally { this.loading = false }
},
cleanQuery() {
const params = Object.fromEntries(Object.entries(this.query).filter(([key, value]) => key !== 'shopeeOrderNo' && value !== ''))
const values = this.orderFilter.values
if (values.length === 1) params.shopeeOrderNo = values[0]
else if (values.length > 1) params.shopeeOrderNos = values
return params
},
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: '', taskType: '', shopeeOrderNo: '', status: '', executionMode: 'live', sybProductId: '', pddOrderNo: '' }; this.load() },
async openDetail(row) { this.detail = { open: true, loading: true, task: null, attempts: [], matching: null }; try { await this.refreshDetail(row.id) } finally { this.detail.loading = false } },
@@ -294,6 +311,8 @@ export default {
</script>
<style lang="scss" scoped>
.shopee-order-filter{width:240px;max-width:100%;white-space:normal}.order-filter-hint,.order-filter-error{font-size:12px;line-height:1.5;margin-top:4px}.order-filter-hint{color:var(--el-text-color-regular)}.order-filter-error{color:var(--el-color-danger)}
.page-card .search-form{flex-wrap:wrap;align-items:flex-start}.search-form :deep(.el-form-item){max-width:100%}.search-form :deep(.el-form-item__content){min-width:0}
.batch-actions{display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin-bottom:16px}.batch-actions .el-button+.el-button{margin-left:0}
.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}.image-search-tag{margin-left:6px;vertical-align:middle}.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>
+1 -6
View File
@@ -40,9 +40,6 @@
<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>
@@ -76,7 +73,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="160">
<el-table-column label="匹配到的退货商品" min-width="200">
<template #default="{ row }">
<template v-if="returnMatchByProductId[row.id]">
<div class="return-match-cell">
@@ -999,8 +996,6 @@ 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}
+118
View File
@@ -0,0 +1,118 @@
import { expect, test } from '@playwright/test'
async function mockList(page: any) {
const state = { queries: [] as URLSearchParams[], fail: false, empty: false, writes: 0 }
await page.context().addCookies([{ name: 'Admin-Token', value: 'mock-token', domain: 'localhost', path: '/' }])
await page.route('**/api/**', async route => {
const req = route.request()
const url = new URL(req.url())
if (url.pathname.startsWith('/src/api/')) return route.continue()
if (req.method() !== 'GET') { state.writes++; return route.abort() }
if (url.pathname.endsWith('/getinfo')) return route.fulfill({ json: { code: 200, data: { roles: ['purchaser'], name: '测试采购员', avatar: '', introduction: '', permissions: [] } } })
if (url.pathname.endsWith('/menurole')) return route.fulfill({ json: { code: 200, data: [{ path: '/collection-purchase', component: 'Layout', menuName: 'Purchase', title: '采集采购', visible: '0', children: [{ path: '/purchase-tasks/index', component: '/goauto/purchase-tasks/index', menuName: 'PurchaseTasks', title: '采购管理', visible: '0' }] }] } })
if (url.pathname.endsWith('/purchase-tasks')) {
state.queries.push(url.searchParams)
if (state.fail) return route.fulfill({ status: 500, json: { code: 500, message: '模拟查询失败' } })
return route.fulfill({ json: { code: 200, data: { items: state.empty ? [] : [{ id: 101, taskType: 'syb_order', status: 'failed', executionMode: 'live', retryable: true, shopeeOrderNoSnapshot: 'DEMO-A', quantity: 1, createdAt: '2026-10-01T00:00:00Z' }], total: state.empty ? 0 : 21 } } })
}
return route.fulfill({ json: { code: 200, data: [] } })
})
await page.goto('/#/purchase-tasks/index')
await expect(page.getByText('CG-101', { exact: true })).toBeVisible()
return state
}
test('批量粘贴去重、精确集合与状态组合,翻页保留条件并复用重试确认', async ({ page }) => {
const state = await mockList(page)
const field = page.getByLabel('蝦皮订单号', { exact: true })
await field.fill('001\r\nDEMO-A,DEMO-B\t001 DEMO-A')
await expect(page.getByText('已输入 3 个订单号。')).toBeVisible()
const before = state.queries.length
await field.press('End')
await field.press('Enter')
await expect(field).toHaveValue(/\n$/)
expect(state.queries.length).toBe(before)
await page.locator('.el-form-item').filter({ has: page.locator('label').filter({ hasText: /^状态$/ }) }).locator('.el-select').click()
await page.getByRole('option', { name: '采购失败', exact: true }).click()
await page.getByRole('button', { name: '查询', exact: true }).click()
await expect.poll(() => state.queries.at(-1)?.getAll('shopeeOrderNos')).toEqual(['001', 'DEMO-A', 'DEMO-B'])
expect(state.queries.at(-1)?.get('shopeeOrderNo')).toBeNull()
expect(state.queries.at(-1)?.get('status')).toBe('failed')
await page.locator('.btn-next').click()
await expect.poll(() => state.queries.at(-1)?.get('page')).toBe('2')
expect(state.queries.at(-1)?.getAll('shopeeOrderNos')).toEqual(['001', 'DEMO-A', 'DEMO-B'])
await page.locator('.el-table__body .el-checkbox').first().click()
await page.getByRole('button', { name: '重试(1)', exact: true }).click()
await expect(page.getByRole('button', { name: '确认重试', exact: true })).toBeVisible()
expect(state.writes).toBe(0)
})
test('单号兼容、清空重置及空列表', async ({ page }) => {
const state = await mockList(page)
const field = page.getByLabel('蝦皮订单号', { exact: true })
await field.fill(' DEMO-A\nDEMO-A ')
await page.getByRole('button', { name: '查询', exact: true }).click()
await expect.poll(() => state.queries.at(-1)?.get('shopeeOrderNo')).toBe('DEMO-A')
expect(state.queries.at(-1)?.has('shopeeOrderNos')).toBe(false)
await field.fill('')
await page.getByRole('button', { name: '查询', exact: true }).click()
await expect.poll(() => state.queries.at(-1)?.has('shopeeOrderNo')).toBe(false)
await field.fill('MISSING-A,MISSING-B')
state.empty = true
await page.getByRole('button', { name: '查询', exact: true }).click()
await expect(page.getByText('暂无采购任务')).toBeVisible()
await expect(field).toHaveValue('MISSING-A,MISSING-B')
await page.getByRole('button', { name: '重置', exact: true }).click()
await expect(field).toHaveValue('')
await expect.poll(() => state.queries.at(-1)?.has('shopeeOrderNos')).toBe(false)
expect(state.writes).toBe(0)
})
test('100个可查、101个或过长就地报错且不请求', async ({ page }) => {
const state = await mockList(page)
const field = page.getByLabel('蝦皮订单号', { exact: true })
const hundred = Array.from({ length: 100 }, (_, i) => `DEMO-${i}`)
await field.fill(hundred.join('\n'))
await page.getByRole('button', { name: '查询', exact: true }).click()
await expect.poll(() => state.queries.at(-1)?.getAll('shopeeOrderNos').length).toBe(100)
const before = state.queries.length
await field.fill([...hundred, 'EXTRA'].join('\n'))
await expect(page.getByRole('alert').filter({ hasText: '最多查询 100' })).toBeVisible()
await expect(page.getByRole('button', { name: '查询', exact: true })).toBeDisabled()
await field.fill('A'.repeat(65))
await expect(page.getByRole('alert').filter({ hasText: '64' })).toBeVisible()
expect(state.queries.length).toBe(before)
expect(state.writes).toBe(0)
})
test('查询失败保留输入、清除旧勾选且不自动重试', async ({ page }) => {
const state = await mockList(page)
await page.locator('.el-table__body .el-checkbox').first().click()
await expect(page.getByRole('button', { name: '重试(1)', exact: true })).toBeEnabled()
const field = page.getByLabel('蝦皮订单号', { exact: true })
await field.fill('DEMO-A DEMO-B')
state.fail = true
const before = state.queries.length
await page.getByRole('button', { name: '查询', exact: true }).click()
await expect(page.getByRole('button', { name: '重新加载', exact: true })).toBeVisible()
await expect(field).toHaveValue('DEMO-A DEMO-B')
await expect(page.getByRole('button', { name: '重试(0)', exact: true })).toBeDisabled()
expect(state.queries.length).toBe(before + 1)
expect(state.writes).toBe(0)
})
test('轻量布局在桌面和窄屏可读,输入与查询可键盘访问', async ({ page }) => {
await page.emulateMedia({ reducedMotion: 'reduce' })
await mockList(page)
for (const viewport of [{ width: 1440, height: 900 }, { width: 375, height: 812 }, { width: 812, height: 375 }]) {
await page.setViewportSize(viewport)
const field = page.getByLabel('蝦皮订单号', { exact: true })
await field.focus()
await expect(field).toBeFocused()
await expect(field).toBeInViewport()
const box = await field.boundingBox()
expect(box!.width).toBeLessThanOrEqual(viewport.width)
expect(box!.height).toBeGreaterThan(40)
await expect(page.getByRole('button', { name: '查询', exact: true })).toBeVisible()
}
})
-60
View File
@@ -1,60 +0,0 @@
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 })
}
})
}
+3 -4
View File
@@ -60,10 +60,9 @@ 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(72)
expect(widths[4]).toBe(260)
expect(widths[10]).toBeGreaterThanOrEqual(210)
expect(widths[12]).toBe(132)
expect(widths[3]).toBe(260)
expect(widths[9]).toBeGreaterThanOrEqual(210)
expect(widths[11]).toBe(132)
await page.locator('.shop-input').click()
await page.getByRole('option', { name: '大碼臻選' }).click()
@@ -0,0 +1,42 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const source = fs.readFileSync(path.join(__dirname, '../../src/utils/purchase-order-filter.js'), 'utf8')
const parse = new Function(source.replace('export function', 'function') + '\nreturn parsePurchaseOrderFilter')()
const apiSource = fs.readFileSync(path.join(__dirname, '../../src/api/goauto/purchase-tasks.js'), 'utf8')
const api = new Function('request', apiSource.replace(/^import .+\n/gm, '').replace(/export function /g, 'function ') + '\nreturn { listPurchaseTasks, retryPurchaseTasksBatch }')(config => config)
test('split mixed delimiters, remove empty/duplicates, preserve identifiers', () => {
assert.deepEqual(parse(' 001\r\nDEMO-A,DEMO-B\t001,DEMO-C\u0085\uFEFF'), { values: ['001', 'DEMO-A', 'DEMO-B', 'DEMO-C'], error: '' })
assert.deepEqual(parse(' ,,\t\r\n'), { values: [], error: '' })
assert.deepEqual(parse('DEMO-A\nDEMO-A').values, ['DEMO-A'])
})
test('100 unique orders and 64 Unicode code points allowed, overflow rejected without truncation', () => {
const hundred = Array.from({ length: 100 }, (_, i) => `DEMO-${i}`)
assert.equal(parse(hundred.join('\n')).error, '')
assert.equal(parse([...hundred, ...hundred].join(',')).error, '')
assert.equal(parse([...hundred, 'EXTRA'].join(',')).values.length, 101)
assert.match(parse([...hundred, 'EXTRA'].join(',')).error, /100/)
assert.equal(parse('中'.repeat(64)).error, '')
assert.equal(parse('😀'.repeat(64)).error, '')
assert.match(parse('中'.repeat(65)).error, /64/)
})
test('API serializes repeated keys and special characters without changing other filters', () => {
const params = { shopeeOrderNos: ['001', 'A&B', 'C+D'], page: 2, status: 'failed', pddOrderNo: 'PDD-1' }
const config = api.listPurchaseTasks(params)
const query = new URLSearchParams(config.paramsSerializer.serialize(params))
assert.deepEqual(query.getAll('shopeeOrderNos'), params.shopeeOrderNos)
assert.equal(query.get('shopeeOrderNos[]'), null)
assert.equal(query.get('page'), '2')
assert.equal(query.get('status'), 'failed')
assert.equal(query.get('pddOrderNo'), 'PDD-1')
assert.equal(config.timeout, undefined)
assert.equal(config.paramsSerializer.serialize({ shopeeOrderNos: [] }), 'shopeeOrderNos=')
assert.equal(config.paramsSerializer.serialize({ shopeeOrderNo: 'PART' }), 'shopeeOrderNo=PART')
assert.equal(api.retryPurchaseTasksBatch({ taskIds: [1] }).paramsSerializer, undefined)
assert.equal(api.retryPurchaseTasksBatch({ taskIds: [1] }).timeout, 60000)
})