Compare commits

..
23 changed files with 870 additions and 71 deletions
+11 -2
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: 04a4bcf81f2f2912d101d1aa1c2d8eb802c5719e
synchronized_at: 2026-09-30T01:50:27Z
wiki_revision: 3792feb41f8f74ffc69faae757e631254c3a23e2
synchronized_at: 2026-10-05T08:35:53Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
@@ -599,3 +599,12 @@ 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忽略集合筛选。
+12 -3
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: 76037bfdf5e986c478055e7338c21ee1ba561bad
synchronized_at: 2026-09-30T01:50:31Z
wiki_revision: 60b4c649f87178c079392a101faf00db9b355263
synchronized_at: 2026-10-05T08:35:56Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
@@ -70,7 +70,7 @@ Admin 的「创建时间」范围按 SYB 货运单列表的 `t_stock.created`(
- 关联虾皮商品档案:命中存活记录直接关联;命中软删除记录则复活并保留原有人工映射;均不存在时创建最小档案。命中既有档案时只补写为空的参考图与售价,不覆盖人工修改过的标题、店铺和映射。
- 重新解析只读取已保存的原始 JSON,不请求 SYB 接口,永不改写 `RawJSON`;标记 `manuallyConfirmed` 的明细默认跳过重新解析,除非显式指定强制覆盖。
- 人工修正的颜色/尺码视为与解析成功同等可信,同样会合并进虾皮商品档案;标记为已人工确认。
- Admin 商品列表的订单号筛选每行一个、去空去重后精确匹配,最多 100 个且单项最长 64 字符;店铺名称使用包含匹配。店铺、订单号与解析状态之间使用 AND,不再用该筛选框搜索蝦皮商品 ID 或商品标题。
- #353:Admin SYB 商品页仅从「店铺管理」选择店铺,支持最多 50 家多选,停用但未删除的店铺仍可选。所选店铺之间为 OR,与订单号、解析状态、处理阶段、采购类型及来源创建日期等条件为 AND,筛选后分页。订单号按完整值精确查询,最多 100 个去重非空值,每个最多 64 字符,不检索虾皮商品 ID 或标题。店铺名称与同步规则使用相同的首尾去空白、全角 ASCII 折半角及大小写归一,不合并名称中间空格。旧 API 保留 shopName 包含查询;新 Admin 不允许自由输入或选择已删除店铺。店铺改名后,保留旧名称的历史商品可能无法通过新名称选项查到,此限制已获用户接受。
- SYB 商品数据本身不保存 PDD 订单号、快递单号、支付或有效订单状态;Admin 的采购任务创建入口位于 SYB 商品列表,任务事实仍写入独立采购域。
@@ -772,3 +772,12 @@ 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,不新建任务。本单没有修改任何重试资格、执行、下单、支付或回填行为。
+10 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Troubleshooting
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Troubleshooting
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
synchronized_at: 2026-09-21T08:14:54Z
wiki_revision: 40e77a656d1c21d72454ad722a4f71a0a4491241
synchronized_at: 2026-10-05T07:18:16Z
<!-- gitea-wiki-mirror:end -->
# 故障排查
@@ -104,3 +104,11 @@ adb -s <device-serial> exec-out run-as cn.ilapage.goauto.agent cat files/purchas
```
先列出文件,再读取本次任务/attempt 的准确文件名;不得扩大为导出所有应用数据或完整数据库。新版安装前的失败不会补生成日志;需要用户授权后重试才能产生新证据。Release 若禁止 run-as 则停止,不更改权限绕过;首版没有导出界面。该功能用于确定拒绝分支,并不代表已经修复对应采购故障。
## SYB 商品列表查询等待(#355)
实现绑定 `a24c206`,仅 Web 客户端;2026-10-05 与 #353/#354 合并至 main `1b4f7cd` 并配套发布 Server/Web 至现有 167 服务器。已通过本地合成测试及线上只读页面验证:列表请求实际等待预算为 60000ms,原筛选组合超过 10 秒后正常返回。发布记录和回退目标见 #355 工单。
- `web/src/api/goauto/syb-products.js` 的 `listSybProducts` 为 `GET /api/admin/v1/syb-products` 单独设置默认 `timeout: 60000`,覆盖 SYB 商品页搜索、翻页和修改每页数量。全局请求默认仍为 10000ms,其他页面及详情、AI 匹配、采购、采集、退货操作的原超时不变。
- 保留现有加载、错误提示、取消和旧请求隔离逻辑,不增加自动重试。慢请求在 10~60 秒间完成时不再被原 10 秒客户端预算提前中止;超过 60 秒仍会超时,上游更短的超时也可能先终止请求。
- 此调整不优化后端执行速度。处理阶段筛选当前先加载候选并计算阶段、后分页;不选店铺且采购类型为全部时,候选可能很大。遇到持续慢查询,应另行分析候选预筛选与数据库执行计划,不能据此认为延长前端预算已解决后端性能问题。
- 只需发布包含该提交的 Web 资源即可生效,不要求数据库迁移、Android 安装或后端参数变更;发布仍需人工授权。
+13 -2
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: f92513e7184fe704d2b9d1c6606b16a86d8d3fb8
synchronized_at: 2026-09-30T01:58:59Z
wiki_revision: 0d3b04cf5318051b1179cbac47521d88e77e2a57
synchronized_at: 2026-10-05T08:36:13Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
@@ -83,6 +83,8 @@ POST /api/admin/v1/syb-products/reparse-batch
PATCH /api/admin/v1/syb-products/{productId}/correction
```
#353 SYB 商品列表店铺筛选:Admin 使用重复的 shopIds 查询参数,表示 1~50 个店铺管理 ID,集合内按 OR 匹配。shopIds 不可与旧 shopName 同传;非法、不存在或软删除的 ID 返回 422。服务端读取管理店铺(包含停用店铺),用与店铺同步一致的首尾去空白、全角 ASCII 折半角及大小写归一规则,与已存 SYB 商品的原始店铺名比较。其他筛选条件按 AND 组合,筛选后分页;无匹配时返回空列表,不放宽为全部店铺。旧 shopName 包含查询只供旧客户端兼容,新 Admin 仅提交管理店铺 ID。管理店铺改名后,历史商品保留的旧名称可能无法按新店铺选项查询,此限制已获 #353 用户确认。
`import` 允许已认证的管理员(admin)和采购员(purchaser)调用(#236),仍须通过 Casbin 权限校验;其他角色返回 403。采购员的 POST 权限由既有启动权限对账写入,不需要新增数据库迁移。操作人取已认证 claims,不接受客户端冒名。请求体提交 `dateFrom`、`dateTo` 后创建持久化后台任务并立即以 `202` 返回 `runId` 和 `status=running`;关闭弹窗、刷新或离开页面不影响任务。没有启用店铺时必须在读取凭据、登录、验证码 OCR 和任意 SYB 网络请求之前返回 `422`。任意时刻只能有一条 `running` 记录,内存锁与数据库唯一执行槽共同阻止单进程和跨进程重复导入;冲突时返回正在执行任务的日期范围。
`sync-runs` 列表支持 `page`、`pageSize`、`status`、`dateFrom`、`dateTo`,详情返回日期范围、状态(`running` / `succeeded` / `partial_success` / `failed` / `interrupted`)、处理天数、货运单/明细/新增/覆盖数量、店铺准入与跳过数量、店铺筛选快照哈希、按店铺的 `accepted` / `skipped` 统计、操作人和起止时间。列表和详情对已登录角色只读开放。服务启动时遗留的 `running` 任务改为 `interrupted`;中途失败或中断已经写入的数据保留,重新导入仍按唯一键覆盖。
@@ -1449,3 +1451,12 @@ 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。
@@ -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)
+17 -1
View File
@@ -32,6 +32,22 @@ func (handler Handler) logInternalFailure(c *gin.Context, stage string, err erro
}
func (handler Handler) List(c *gin.Context) {
var shopIDs []uint64
if values, present := c.Request.URL.Query()["shopIds"]; present {
if len(values) == 0 || len(values) > 50 {
writeError(c, invalidRequest("shopIds 数量必须为 1 到 50 个"))
return
}
shopIDs = make([]uint64, 0, len(values))
for _, value := range values {
id, err := strconv.ParseUint(value, 10, 64)
if err != nil || id == 0 {
writeError(c, invalidRequest("shopIds 必须是有效的店铺 ID"))
return
}
shopIDs = append(shopIDs, id)
}
}
page, err := queryInt(c.Query("page"), 1)
if err != nil {
writeError(c, invalidRequest("page 必须是正整数"))
@@ -47,7 +63,7 @@ func (handler Handler) List(c *gin.Context) {
return
}
response, err := service.List(c.Request.Context(), ListRequest{
Page: page, PageSize: pageSize, ShopName: c.Query("shopName"), OrderCodes: []string{c.Query("orderCodes")}, ParseStatus: strings.TrimSpace(c.Query("parseStatus")), ProcessStage: strings.TrimSpace(c.Query("processStage")), CreatedFrom: strings.TrimSpace(c.Query("createdFrom")), CreatedTo: strings.TrimSpace(c.Query("createdTo")), PurchaseType: strings.TrimSpace(c.Query("purchaseType")),
Page: page, PageSize: pageSize, ShopName: c.Query("shopName"), ShopIDs: shopIDs, OrderCodes: []string{c.Query("orderCodes")}, ParseStatus: strings.TrimSpace(c.Query("parseStatus")), ProcessStage: strings.TrimSpace(c.Query("processStage")), CreatedFrom: strings.TrimSpace(c.Query("createdFrom")), CreatedTo: strings.TrimSpace(c.Query("createdTo")), PurchaseType: strings.TrimSpace(c.Query("purchaseType")),
})
if err != nil {
writeError(c, err)
+43 -1
View File
@@ -9,6 +9,7 @@ import (
"go-admin/app/goauto/models"
"go-admin/app/goauto/purchase"
"go-admin/app/goauto/sybshop"
"gorm.io/gorm"
)
@@ -42,6 +43,7 @@ func NewService(db *gorm.DB) *Service { return &Service{DB: db} }
type ListRequest struct {
Page, PageSize int
ShopName string
ShopIDs []uint64
OrderCodes []string
ParseStatus string
ProcessStage string
@@ -95,7 +97,47 @@ func (service *Service) List(ctx context.Context, request ListRequest) (ListResp
if createdTo != nil {
query = query.Where("syb_stock_created_at < ?", *createdTo)
}
if request.ShopName = strings.TrimSpace(request.ShopName); request.ShopName != "" {
if request.ShopName = strings.TrimSpace(request.ShopName); request.ShopName != "" && len(request.ShopIDs) > 0 {
return ListResponse{}, invalidRequest("shopName 和 shopIds 不能同时使用")
}
if len(request.ShopIDs) > 0 {
if len(request.ShopIDs) > 50 {
return ListResponse{}, invalidRequest("一次最多选择 50 个店铺")
}
selected := make(map[uint64]struct{}, len(request.ShopIDs))
for _, id := range request.ShopIDs {
if id == 0 {
return ListResponse{}, invalidRequest("shopIds 必须是有效的店铺 ID")
}
selected[id] = struct{}{}
}
var shops []models.SYBShop
if err := service.DB.WithContext(ctx).Where("id IN ?", request.ShopIDs).Find(&shops).Error; err != nil {
return ListResponse{}, internalError(err)
}
if len(shops) != len(selected) {
return ListResponse{}, invalidRequest("所选店铺不存在或已删除")
}
shopNames := make(map[string]struct{}, len(shops))
for _, shop := range shops {
shopNames[shop.NormalizedName] = struct{}{}
}
var sourceNames []string
if err := service.DB.WithContext(ctx).Model(&models.SYBProduct{}).Distinct("shop_name").Pluck("shop_name", &sourceNames).Error; err != nil {
return ListResponse{}, internalError(err)
}
matchedNames := make([]string, 0)
for _, name := range sourceNames {
if _, ok := shopNames[sybshop.Normalize(name)]; ok {
matchedNames = append(matchedNames, name)
}
}
if len(matchedNames) == 0 {
query = query.Where("1 = 0")
} else {
query = query.Where("shop_name IN ?", matchedNames)
}
} else if request.ShopName != "" {
if len([]rune(request.ShopName)) > 255 {
return ListResponse{}, invalidRequest("店铺名称不能超过 255 个字符")
}
@@ -74,6 +74,57 @@ func TestServiceListFiltersByOrderCodesShopAndParseStatus(t *testing.T) {
}
}
func TestServiceListFiltersByManagedShopIDs(t *testing.T) {
db := openTestDB(t)
orders := []struct{ name, code string }{
{" ABC店 ", "ORDER-A"}, {"第二店", "ORDER-B"}, {"其他店", "ORDER-C"},
}
for i, fixture := range orders {
order := realOrder()
order.Code, order.ShopName, order.StockID = fixture.code, fixture.name, order.StockID+uint64(i)
detail := realDetailA()
detail.ID += uint64(i)
if _, err := sybimport.ApplyDetail(context.Background(), db, order, detail); err != nil {
t.Fatal(err)
}
}
shops := []models.SYBShop{
{DisplayName: "ABC店", NormalizedName: "abc店", Enabled: true},
{DisplayName: "第二店", NormalizedName: "第二店", Enabled: false},
{DisplayName: "未出现店", NormalizedName: "未出现店", Enabled: true},
}
if err := db.Create(&shops).Error; err != nil {
t.Fatal(err)
}
service := sybimport.NewService(db)
result, err := service.List(context.Background(), sybimport.ListRequest{ShopIDs: []uint64{shops[0].ID, shops[1].ID}, OrderCodes: []string{"ORDER-A", "ORDER-B", "ORDER-C"}})
if err != nil || result.Total != 2 {
t.Fatalf("multi-shop OR and order-code AND: result=%+v err=%v", result, err)
}
result, err = service.List(context.Background(), sybimport.ListRequest{ShopIDs: []uint64{shops[2].ID}})
if err != nil || result.Total != 0 {
t.Fatalf("unmatched managed shop must return empty: result=%+v err=%v", result, err)
}
for _, request := range []sybimport.ListRequest{
{ShopIDs: []uint64{shops[0].ID}, ShopName: "ABC"},
{ShopIDs: []uint64{shops[0].ID, 999999}},
{ShopIDs: []uint64{0}},
{ShopIDs: make([]uint64, 51)},
} {
_, err := service.List(context.Background(), request)
if code := serviceErrCode(t, err); code != sybimport.CodeInvalidRequest {
t.Fatalf("request %+v: %s", request, code)
}
}
if err := db.Delete(&shops[0]).Error; err != nil {
t.Fatal(err)
}
_, err = service.List(context.Background(), sybimport.ListRequest{ShopIDs: []uint64{shops[0].ID}})
if code := serviceErrCode(t, err); code != sybimport.CodeInvalidRequest {
t.Fatalf("deleted shop: %s", code)
}
}
func TestServiceListRejectsTooManyOrTooLongOrderCodes(t *testing.T) {
service := sybimport.NewService(openTestDB(t))
tooMany := make([]string, 101)
@@ -0,0 +1,36 @@
package sybimport_test
import (
"net/http"
"net/http/httptest"
"testing"
"go-admin/app/goauto/sybimport"
"github.com/gin-gonic/gin"
)
func TestListHandlerShopIDsValidation(t *testing.T) {
db := openTestDB(t)
gin.SetMode(gin.TestMode)
router := gin.New()
router.GET("/syb-products", (sybimport.Handler{DB: db}).List)
for _, target := range []string{
"/syb-products?shopIds=",
"/syb-products?shopIds=abc",
"/syb-products?shopIds=0",
"/syb-products?shopIds=1&shopName=test",
"/syb-products?shopIds=999999",
} {
response := httptest.NewRecorder()
router.ServeHTTP(response, httptest.NewRequest(http.MethodGet, target, nil))
if response.Code != http.StatusUnprocessableEntity {
t.Fatalf("%s: expected 422, got %d %s", target, response.Code, response.Body.String())
}
}
response := httptest.NewRecorder()
router.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/syb-products?shopName=legacy", nil))
if response.Code != http.StatusOK {
t.Fatalf("legacy shopName: %d %s", response.Code, response.Body.String())
}
}
+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 }) }
+8 -1
View File
@@ -1,7 +1,14 @@
import request from '@/utils/request'
export function listSybProducts(params, options = {}) {
return request({ url: '/api/admin/v1/syb-products', method: 'get', params, ...options })
return request({ url: '/api/admin/v1/syb-products', method: 'get', params, timeout: 60000, paramsSerializer: { serialize(values) {
const query = new URLSearchParams()
for (const [key, value] of Object.entries(values)) {
if (Array.isArray(value)) value.forEach(item => query.append(key, String(item)))
else if (value !== undefined && value !== null && value !== '') query.append(key, String(value))
}
return query.toString()
} }, ...options })
}
export function getSybProduct(productId) {
+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>
+61 -23
View File
@@ -4,11 +4,23 @@
<el-card class="page-card" shadow="never">
<el-form :model="query" :inline="true" class="search-form" @submit.prevent="search">
<div class="search-row search-row-primary">
<el-form-item v-if="canPurchase"><span class="selection-count" aria-live="polite">已选择 <strong>{{ selectedProducts.length }}</strong> 条</span></el-form-item>
<el-form-item label="店铺">
<el-autocomplete v-model="query.shopName" class="shop-input" placeholder="选择或输入店铺" clearable value-key="displayName" :fetch-suggestions="suggestSybShops">
<template #default="{ item }"><div class="shop-option" :title="item.displayName"><span class="shop-option-name">{{ item.displayName }}</span><el-tag v-if="!item.enabled" size="small" type="info" effect="plain">已停用</el-tag></div></template>
</el-autocomplete>
<el-select
v-model="query.shopIds"
class="shop-input"
multiple
filterable
collapse-tags
collapse-tags-tooltip
clearable
:multiple-limit="50"
:loading="shopOptionsLoading"
placeholder="选择店铺"
@visible-change="onShopVisible"
>
<el-option v-for="shop in shopOptions" :key="shop.id" :value="shop.id" :label="shop.displayName"><div class="shop-option" :title="shop.displayName"><span class="shop-option-name">{{ shop.displayName }}</span><el-tag v-if="!shop.enabled" size="small" type="info" effect="plain">已停用</el-tag></div></el-option>
<template #empty><div class="shop-empty"><template v-if="shopOptionsLoading">正在加载店铺…</template><template v-else-if="shopOptionsError">店铺加载失败 <el-button link type="primary" @click.stop="ensureShopOptions">重试</el-button></template><template v-else>暂无店铺</template></div></template>
</el-select>
</el-form-item>
<el-form-item label="订单号"><el-input v-model="query.orderCodesText" type="textarea" :autosize="{ minRows: 1, maxRows: 4 }" resize="none" class="order-codes-input" placeholder="每行一个订单号,最多 100 个" @keydown.ctrl.enter.prevent="search" /></el-form-item>
<el-form-item label="解析状态"><el-select v-model="query.parseStatus" clearable placeholder="全部" style="width: 110px"><el-option label="成功" value="success" /><el-option label="失败" value="failed" /></el-select></el-form-item>
@@ -20,7 +32,10 @@
<el-form-item label="创建时间"><el-date-picker v-model="query.createdAtRange" type="daterange" value-format="YYYY-MM-DD" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" clearable /></el-form-item>
</div>
</el-form>
<el-alert v-if="canPurchase" title="先完成并保存规格匹配,商品才可以创建采购;AI 匹配只处理已解析规格、已关联 PDD 且具备完整可售 SKU 组合的明细。表头全选仅作用于当前页。" type="info" :closable="false" show-icon class="notice compact-notice" />
<div class="top-pagination-row">
<span v-if="canPurchase" class="selection-count" aria-live="polite">本页选择 <strong>{{ selectedProducts.length }}</strong> 条</span>
<pagination v-show="total > 0" v-model:page="query.page" v-model:limit="query.pageSize" class="top-pagination" :auto-scroll="false" :page-sizes="[20, 50, 100, 200, 500]" :total="total" @pagination="onPagination" />
</div>
<el-table ref="productTable" v-loading="loading" :data="products" row-key="id" border stripe empty-text="暂无 SYB 商品明细" @selection-change="handleSelectionChange">
<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>
@@ -82,7 +97,7 @@
</el-table-column>
<el-table-column label="操作" width="132" fixed="right"><template #default="{ row }"><el-button type="primary" link @click="openDetail(row.id)">详情</el-button><el-button v-if="canPurchase && isPurchaseCandidate(row)" type="primary" link @click="openSinglePurchase(row)">采购</el-button></template></el-table-column>
</el-table>
<pagination v-show="total > 0" v-model:current-page="query.page" v-model:page-size="query.pageSize" :page-sizes="[20, 50, 100, 200, 500]" :total="total" @pagination="load" />
<pagination v-show="total > 0" v-model:page="query.page" v-model:limit="query.pageSize" :page-sizes="[20, 50, 100, 200, 500]" :total="total" @pagination="onPagination" />
</el-card>
<el-dialog v-model="quickPicker.open" :title="quickPicker.row ? '选择采集手机' : '切换采集手机'" width="460px" :close-on-click-modal="false" @close="quickCancelPicker">
@@ -400,9 +415,10 @@ export default {
return {
loading: false, products: [], selectedProducts: [], total: 0,
purchaseReadiness: {}, purchaseReadinessLoading: false, specMatchLoading: false, loadGeneration: 0,
paginationTimer: null, loadedPage: 1, loadedPageSize: 200,
purchaseDevices: [],
purchasePreviewGeneration: 0,
shopOptions: [], shopOptionsLoaded: false, shopOptionsPromise: null,
shopOptions: [], shopOptionsLoaded: false, shopOptionsLoading: false, shopOptionsError: false, shopOptionsPromise: null,
purchaseDialog: { open: false, loading: false, saving: false, ids: [], selectedCount: 0, deviceId: null, items: [], eligibleCount: 0, skippedCount: 0, previewError: '' },
purchaseResult: { open: false, items: [], createdCount: 0, failedCount: 0 },
specMatchResult: { open: false, items: [], selectedIDs: [], autoConfirmedCount: 0, pendingCount: 0, failedCount: 0, skippedCount: 0 },
@@ -410,7 +426,7 @@ export default {
imageSearchBatch: this.emptyImageSearchBatch(),
collectionBatchData: { ruleId: null, deviceId: null },
collectionBatchRules: { ruleId: [{ required: true, message: '请选择采集规则', trigger: 'change' }] },
query: { page: 1, pageSize: 200, shopName: '', orderCodesText: '', parseStatus: '', processStage: '', purchaseType: 'pdd', createdAtRange: [] },
query: { page: 1, pageSize: 200, shopIds: [], orderCodesText: '', parseStatus: '', processStage: '', purchaseType: 'pdd', createdAtRange: [] },
returnMatchByProductId: {},
returnMatchLoading: false,
returnMatchBatchLoading: false,
@@ -473,10 +489,13 @@ export default {
}
},
created() { this.load() },
beforeUnmount() { if (this.paginationTimer !== null) clearTimeout(this.paginationTimer) },
methods: {
async load(allowNetworkRetry = true, waitForReadiness = false) {
const orderCodes = this.normalizedOrderCodes()
if (orderCodes === null) return
this.loadedPage = this.query.page
this.loadedPageSize = this.query.pageSize
const generation = ++this.loadGeneration
this.loading = true
this.selectedProducts = []
@@ -484,7 +503,7 @@ export default {
const requestOptions = allowNetworkRetry ? { suppressNetworkError: true } : {}
try {
const [createdFrom = '', createdTo = ''] = this.query.createdAtRange || []
const r = await listSybProducts({ page: this.query.page, pageSize: this.query.pageSize, shopName: this.query.shopName.trim(), orderCodes: orderCodes.join(','), parseStatus: this.query.parseStatus, processStage: this.query.processStage, purchaseType: this.query.purchaseType, createdFrom, createdTo }, requestOptions)
const r = await listSybProducts({ page: this.query.page, pageSize: this.query.pageSize, shopIds: this.query.shopIds, orderCodes: orderCodes.join(','), parseStatus: this.query.parseStatus, processStage: this.query.processStage, purchaseType: this.query.purchaseType, createdFrom, createdTo }, requestOptions)
if (generation !== this.loadGeneration) return
this.products = r.data.items
this.total = r.data.total
@@ -507,7 +526,17 @@ export default {
}
},
search() { this.query.page = 1; this.load() },
reset() { this.query = { page: 1, pageSize: 200, shopName: '', orderCodesText: '', parseStatus: '', processStage: '', purchaseType: 'pdd', createdAtRange: [] }; this.load() },
reset() { this.query = { page: 1, pageSize: 200, shopIds: [], orderCodesText: '', parseStatus: '', processStage: '', purchaseType: 'pdd', createdAtRange: [] }; this.load() },
onPagination() {
// Both controls may emit after a size change clamps the old page. Coalesce
// those events and keep the user on page one with exactly one list load.
if (this.paginationTimer !== null) clearTimeout(this.paginationTimer)
this.paginationTimer = setTimeout(() => {
this.paginationTimer = null
if (this.query.pageSize !== this.loadedPageSize) this.query.page = 1
if (this.query.page !== this.loadedPage || this.query.pageSize !== this.loadedPageSize) this.load()
}, 0)
},
// #340: 选择「退货待确认/已用退货/无需采购」这些不属于「需 PDD 采购」默认范围
// 的阶段时,自动把采购类型切到「全部」,否则处理阶段过滤会和采购类型过滤
// 的 AND 组合出空列表;用户仍可再手动切回。
@@ -516,22 +545,27 @@ export default {
this.query.purchaseType = 'all'
}
},
normalizeShopName(value) { return String(value || '').normalize('NFKC').trim().toLocaleLowerCase() },
async ensureShopOptions() {
if (this.shopOptionsLoaded) return
if (this.shopOptionsPromise) return this.shopOptionsPromise
this.shopOptionsPromise = listSybShops({ page: 1, pageSize: 200 }, { suppressNetworkError: true, suppressErrorMessage: true })
.then((response) => { this.shopOptions = response.data.items || [] })
.catch(() => { this.shopOptions = []; ElMessage.warning('店铺列表加载失败,仍可直接输入店铺名称查询') })
.finally(() => { this.shopOptionsLoaded = true; this.shopOptionsPromise = null })
this.shopOptionsLoading = true
this.shopOptionsError = false
this.shopOptionsPromise = (async() => {
const options = []
for (let page = 1; ; page += 1) {
const response = await listSybShops({ page, pageSize: 200 }, { suppressNetworkError: true, suppressErrorMessage: true })
options.push(...(response.data.items || []))
if (options.length >= response.data.total || !response.data.items?.length) break
}
this.shopOptions = options
this.shopOptionsLoaded = true
})().catch(() => {
this.shopOptionsError = true
ElMessage.warning('店铺列表加载失败,请重试')
}).finally(() => { this.shopOptionsLoading = false; this.shopOptionsPromise = null })
return this.shopOptionsPromise
},
async suggestSybShops(value, callback) {
await this.ensureShopOptions()
const keyword = this.normalizeShopName(value)
const matches = keyword ? this.shopOptions.filter(item => this.normalizeShopName(item.displayName).includes(keyword)) : this.shopOptions
callback(matches.slice(0, 50))
},
onShopVisible(visible) { if (visible) this.ensureShopOptions() },
normalizedOrderCodes() {
const values = [...new Set(this.query.orderCodesText.split(/\r?\n/).map(value => value.trim()).filter(Boolean))]
if (values.length > 100) { ElMessage.warning('一次最多查询 100 个订单号'); return null }
@@ -942,9 +976,10 @@ export default {
.search-row-secondary{justify-content:flex-start}
.search-form :deep(.el-form-item){margin-right:0;margin-bottom:12px}
.search-form :deep(.el-form-item__content){flex-wrap:nowrap}
.shop-input{width:130px}.order-codes-input{width:210px}.unlinked-tag{margin-left:8px}
.shop-input{width:210px}.order-codes-input{width:210px}.unlinked-tag{margin-left:8px}
.shop-option{display:flex;align-items:center;justify-content:space-between;gap:12px}
.shop-option-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.shop-empty{padding:8px 12px;color:var(--el-text-color-secondary)}
.toolbar-actions :deep(.el-form-item__content){gap:8px}
.toolbar-actions :deep(.el-button + .el-button){margin-left:0}
.action-button-wrap{display:inline-flex}
@@ -952,6 +987,10 @@ export default {
.action-count-primary{background:rgba(255,255,255,.2);color:inherit}
.selection-count{display:inline-flex;align-items:center;min-height:32px;white-space:nowrap;color:#606266}
.selection-count strong{margin:0 4px;font-variant-numeric:tabular-nums;color:#1e40af}
.top-pagination-row{display:flex;align-items:center;justify-content:flex-end;flex-wrap:wrap;gap:8px 16px;margin-bottom:12px}
.top-pagination-row .selection-count{margin-right:auto}
.top-pagination{max-width:100%;min-width:0;overflow-x:auto}
.top-pagination.pagination-container{padding:4px 0}
.primary{font-weight:600;color:#1f2937}.muted{font-size:12px;color:#909399}
.missing{color:#b91c1c}
.quick-link-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:4px 0}
@@ -961,7 +1000,6 @@ export default {
.shopee-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.notice{margin-bottom:16px}
.return-missing-alert{margin:6px 0}
.compact-notice{margin-bottom:12px}
.purchase-summary{display:flex;flex-wrap:wrap;gap:12px 24px;margin-bottom:16px;padding:12px 16px;border:1px solid #dbeafe;border-radius:8px;background:#f8fafc}.purchase-summary strong{font-variant-numeric:tabular-nums;color:#1e40af}.success-text{color:#166534}.warning-text{color:#b45309}.danger-text,.purchase-reason{color:#b91c1c}.purchase-reason{margin-top:4px;font-size:12px;line-height:1.45}.purchase-settings{margin-bottom:12px}.field-help{margin-left:12px;color:#909399;font-size:12px}.ellipsis{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.image-search-tag{margin-left:6px;vertical-align:middle}
.spec-match-summary{gap:8px 24px}
.purchase-note{margin:0 0 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()
}
})
+22
View File
@@ -2,6 +2,28 @@ import { expect, test } from '@playwright/test'
const sybProductRoute = [{ path: '/goauto-order-workbench', component: 'Layout', menuName: 'GoAutoOrderWorkbench', title: '订单工作台', visible: '0', children: [{ path: '/syb-products', component: '/goauto/syb-products/index', menuName: 'GoAutoSybProducts', title: 'SYB 商品', visible: '0' }] }]
test('SYB 商品列表超过 10 秒后仍正常显示,不重复请求', async({ page, context }) => {
await context.addCookies([{ name: 'Admin-Token', value: 'prototype-test-token', domain: 'localhost', path: '/' }])
let listCalls = 0
await page.route('**/api/**', async route => {
const url = new URL(route.request().url())
if (url.pathname.startsWith('/src/api/')) return route.continue()
if (url.pathname.endsWith('/api/v1/getinfo')) return route.fulfill({ json: { code: 200, data: { roles: ['admin'], name: '测试管理员', avatar: '', introduction: '', permissions: [] }}})
if (url.pathname.endsWith('/api/v1/menurole')) return route.fulfill({ json: { code: 200, data: sybProductRoute }})
if (url.pathname.endsWith('/api/admin/v1/syb-products')) {
listCalls++
await new Promise(resolve => setTimeout(resolve, 12000))
return route.fulfill({ json: { code: 200, data: { items: [{ id: 355, orderCode: 'TEST-355', productTitle: '延迟查询成功的测试商品', quantity: 1, parseStatus: 'success' }], total: 1, page: 1, pageSize: 200 }}})
}
if (url.pathname.endsWith('/api/admin/v1/purchase-tasks/batch-preview')) return route.fulfill({ json: { code: 200, data: { items: [{ sybProductId: 355, eligible: false, processStage: 'return_pending', processStageLabel: '退货待确认' }] }}})
return route.fulfill({ json: { code: 200, data: [] }})
})
await page.goto('/#/syb-products')
await expect(page.getByText('延迟查询成功的测试商品')).toBeVisible({ timeout: 20000 })
expect(listCalls).toBe(1)
await expect(page.getByText(/timeout of 10000ms exceeded/)).toHaveCount(0)
})
test('SYB 商品首次网络失败时静默重试一次并恢复列表', async({ page, context }) => {
await context.addCookies([{ name: 'Admin-Token', value: 'prototype-test-token', domain: 'localhost', path: '/' }])
let listCalls = 0
+97 -15
View File
@@ -22,7 +22,7 @@ test('SYB 商品工具栏与列宽按紧凑布局显示', async({ page, context
await expect(page.getByRole('button', { name: '导入', exact: true })).toHaveCount(0)
await expect(page.getByRole('button', { name: '同步记录', exact: true })).toHaveCount(0)
await expect(page.getByRole('button', { name: '从 SYB 导入', exact: true })).toHaveCount(0)
await expect(page.getByText('已选择 0 条', { exact: true })).toBeVisible()
await expect(page.getByText('本页选择 0 条', { exact: true })).toBeVisible()
await expect(page.getByRole('button', { name: '重新解析', exact: true })).toHaveCount(0)
await expect(page.getByRole('columnheader', { name: '蝦皮关联' })).toHaveCount(0)
await expect(page.getByText('本页不展示采购任务、PDD 订单号、快递单号和支付状态。', { exact: true })).toHaveCount(0)
@@ -34,42 +34,46 @@ test('SYB 商品工具栏与列宽按紧凑布局显示', async({ page, context
await expect(shopeeTitle).toHaveAttribute('title', '用于验证加宽后的虾皮商品标题区域')
await expect(shopeeTitle).toHaveCSS('text-overflow', 'ellipsis')
const selectionCount = await page.getByText('已选择 0 条', { exact: true }).boundingBox()
const selectionCount = await page.getByText('本页选择 0 条', { exact: true }).boundingBox()
const queryButton = await page.getByRole('button', { name: '查询', exact: true }).boundingBox()
const reset = await page.getByRole('button', { name: '重置' }).boundingBox()
const purchase = await page.getByRole('button', { name: /创建采购\s*0/ }).boundingBox()
expect(selectionCount).not.toBeNull(); expect(queryButton).not.toBeNull(); expect(reset).not.toBeNull(); expect(purchase).not.toBeNull()
expect(Math.abs((selectionCount?.y || 0) - (queryButton?.y || 0))).toBeLessThan(2)
expect((selectionCount?.y || 0)).toBeGreaterThan((queryButton?.y || 0))
expect(Math.abs((reset?.y || 0) - (purchase?.y || 0))).toBeLessThan(2)
expect(purchase?.x || 0).toBeGreaterThan((reset?.x || 0) + (reset?.width || 0))
await expect(page.getByRole('button', { name: /创建采购\s*0/ })).toBeDisabled()
const shopControl = page.getByPlaceholder('选择或输入店铺')
const shopControl = page.getByRole('combobox', { name: '店铺' })
const shopInput = await shopControl.boundingBox()
const orderInput = await page.getByPlaceholder('每行一个订单号,最多 100 个').boundingBox()
expect(shopInput).not.toBeNull(); expect(orderInput).not.toBeNull()
expect(shopInput?.x || 0).toBeLessThan(orderInput?.x || 0)
await shopControl.click()
await page.locator('.shop-input').click()
await expect(page.getByText('大碼臻選', { exact: true }).last()).toBeVisible()
await expect(page.getByText('歷史店鋪', { exact: true }).last()).toBeVisible()
await expect(page.getByText('已停用', { exact: true })).toBeVisible()
await page.locator('.el-table__body-wrapper tbody tr').first().locator('.el-checkbox').click()
await expect(page.getByText('已选择 1 条', { exact: true })).toBeVisible()
await expect(page.getByText('本页选择 1 条', { exact: true })).toBeVisible()
await expect(page.getByRole('button', { name: /创建采购\s*1/ })).toBeEnabled()
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[10]).toBe(132)
expect(widths[11]).toBe(132)
await shopControl.fill('大碼')
await page.locator('.shop-input').click()
await page.getByRole('option', { name: '大碼臻選' }).click()
await page.locator('.shop-input').click()
await page.getByRole('option', { name: /歷史店鋪/ }).click()
await page.getByPlaceholder('每行一个订单号,最多 100 个').fill(' SYB001\n\nSYB002\nSYB001 ')
await page.getByPlaceholder('每行一个订单号,最多 100 个').press('Control+Enter')
await expect.poll(() => listURLs.length).toBeGreaterThan(1)
const latest = listURLs.at(-1)
expect(latest?.searchParams.get('shopName')).toBe('大碼')
expect(latest?.searchParams.getAll('shopIds')).toEqual(['1', '2'])
expect(latest?.searchParams.has('shopName')).toBe(false)
expect(latest?.searchParams.get('orderCodes')).toBe('SYB001,SYB002')
await page.locator('.el-form-item').filter({ hasText: '处理阶段' }).locator('.el-select').click()
@@ -78,6 +82,53 @@ test('SYB 商品工具栏与列宽按紧凑布局显示', async({ page, context
await expect.poll(() => listURLs.at(-1)?.searchParams.get('processStage')).toBe('pdd_pending')
})
test('SYB 商品上下分页同步且每次操作只请求一次列表', async({ page, context }) => {
await context.addCookies([{ name: 'Admin-Token', value: 'prototype-test-token', domain: 'localhost', path: '/' }])
const listURLs: URL[] = []
await page.route('**/api/**', async route => {
const url = new URL(route.request().url())
if (url.pathname.startsWith('/src/api/')) return route.continue()
if (url.pathname.endsWith('/api/v1/menurole')) return route.fulfill({ json: { code: 200, data: sybProductRoute }})
if (url.pathname.endsWith('/api/v1/getinfo')) return route.fulfill({ json: { code: 200, data: { roles: ['admin'], name: '管理员', avatar: '', introduction: '', permissions: [] }}})
if (url.pathname.endsWith('/api/admin/v1/syb-products')) {
listURLs.push(url)
const currentPage = Number(url.searchParams.get('page') || 1)
return route.fulfill({ json: { code: 200, data: { items: [{ id: currentPage, orderCode: `SYB-${currentPage}`, shopeeItemId: '1001', shopeeProductId: 1, productTitle: '分页验证', shopName: '测试店铺', quantity: 1, unitPriceCent: 1000, parseStatus: 'success' }], total: 980, page: currentPage, pageSize: Number(url.searchParams.get('pageSize') || 200) }}})
}
if (url.pathname.endsWith('/api/admin/v1/purchase-tasks/batch-preview')) {
return route.fulfill({ json: { code: 200, data: { items: [{ sybProductId: Number(listURLs.at(-1)?.searchParams.get('page') || 1), pddProductId: 1, eligible: true, processStage: 'purchase_ready', processStageLabel: '可创建采购' }], eligibleCount: 1, skippedCount: 0 }}})
}
return route.fulfill({ json: { code: 200, data: [] }})
})
await page.goto('/#/syb-products')
await expect.poll(() => listURLs.length).toBe(1)
await expect(page.locator('.top-pagination .el-pagination')).toBeVisible()
await expect(page.locator('.pagination-container')).toHaveCount(2)
await expect(page.getByText('先完成并保存规格匹配')).toHaveCount(0)
await page.locator('.el-table__body-wrapper tbody tr').first().locator('.el-checkbox').click()
await expect(page.getByText('本页选择 1 条', { exact: true })).toBeVisible()
await page.locator('.top-pagination .el-pager .number').filter({ hasText: '5' }).click()
await expect.poll(() => listURLs.length).toBe(2)
expect(listURLs.at(-1)?.searchParams.get('page')).toBe('5')
await expect(page.getByText('本页选择 0 条', { exact: true })).toBeVisible()
await expect(page.locator('.pagination-container .el-pager .number.is-active')).toHaveCount(2)
await page.locator('.top-pagination .el-pagination__sizes').click()
await page.getByRole('option', { name: /500/ }).click()
await expect.poll(() => listURLs.length).toBe(3)
expect(listURLs.at(-1)?.searchParams.get('page')).toBe('1')
expect(listURLs.at(-1)?.searchParams.get('pageSize')).toBe('500')
await page.waitForTimeout(100)
expect(listURLs).toHaveLength(3)
await expect(page.locator('.pagination-container .el-pager .number.is-active')).toHaveCount(2)
await page.locator('.pagination-container:not(.top-pagination) .el-pager .number').filter({ hasText: '2' }).click()
await expect.poll(() => listURLs.length).toBe(4)
expect(listURLs.at(-1)?.searchParams.get('page')).toBe('2')
expect(listURLs.at(-1)?.searchParams.get('pageSize')).toBe('500')
})
test('批量 AI 匹配展示混合结果并在关闭后刷新采购候选', async({ page, context }) => {
await context.addCookies([{ name: 'Admin-Token', value: 'prototype-test-token', domain: 'localhost', path: '/' }])
let matched = false
@@ -127,7 +178,7 @@ test('批量 AI 匹配展示混合结果并在关闭后刷新采购候选', asyn
await expect(page.getByRole('button', { name: /AI 匹配\s*1/ })).toBeEnabled()
})
test('店铺选项加载失败时仍可输入历史店铺查询', async({ page, context }) => {
test('店铺选项加载失败时提示重试且不能输入任意店铺', async({ page, context }) => {
await context.addCookies([{ name: 'Admin-Token', value: 'prototype-test-token', domain: 'localhost', path: '/' }])
const listURLs: URL[] = []
await page.route('**/api/**', async route => {
@@ -141,13 +192,44 @@ test('店铺选项加载失败时仍可输入历史店铺查询', async({ page,
})
await page.goto('/#/syb-products')
const shopControl = page.getByPlaceholder('选择或输入店铺')
await shopControl.click()
await expect(page.getByText('店铺列表加载失败,仍可直接输入店铺名称查询', { exact: true })).toBeVisible()
const shopControl = page.getByRole('combobox', { name: '店铺' })
await page.locator('.shop-input').click()
await expect(page.getByRole('tooltip', { name: /店铺加载失败/ })).toBeVisible()
await expect(page.getByRole('button', { name: '重试' })).toBeVisible()
await shopControl.fill('已删除店铺')
await page.getByRole('button', { name: '查询', exact: true }).click()
await expect.poll(() => listURLs.length).toBeGreaterThan(1)
expect(listURLs.at(-1)?.searchParams.get('shopName')).toBe('已删除店铺')
expect(listURLs.at(-1)?.searchParams.has('shopIds')).toBe(false)
expect(listURLs.at(-1)?.searchParams.has('shopName')).toBe(false)
})
test('店铺管理超过 200 家时仍可搜索并选择后续页店铺', async({ page, context }) => {
await context.addCookies([{ name: 'Admin-Token', value: 'prototype-test-token', domain: 'localhost', path: '/' }])
const shopPages: number[] = []
const listURLs: URL[] = []
await page.route('**/api/**', async route => {
const url = new URL(route.request().url())
if (url.pathname.startsWith('/src/api/')) return route.continue()
if (url.pathname.endsWith('/api/v1/menurole')) return route.fulfill({ json: { code: 200, data: sybProductRoute }})
if (url.pathname.endsWith('/api/v1/getinfo')) return route.fulfill({ json: { code: 200, data: { roles: ['admin'], name: '管理员', avatar: '', introduction: '', permissions: [] }}})
if (url.pathname.endsWith('/api/admin/v1/syb-shops')) {
const currentPage = Number(url.searchParams.get('page'))
shopPages.push(currentPage)
const start = (currentPage - 1) * 200 + 1
const count = currentPage === 1 ? 200 : 1
const items = Array.from({ length: count }, (_, index) => ({ id: start + index, displayName: `店铺${start + index}`, enabled: true }))
return route.fulfill({ json: { code: 200, data: { items, total: 201, page: currentPage, pageSize: 200 }}})
}
if (url.pathname.endsWith('/api/admin/v1/syb-products')) { listURLs.push(url); return route.fulfill({ json: { code: 200, data: { items: [], total: 0, page: 1, pageSize: 200 }}}) }
return route.fulfill({ json: { code: 200, data: [] }})
})
await page.goto('/#/syb-products')
await page.locator('.shop-input').click()
await expect.poll(() => shopPages).toEqual([1, 2])
await page.getByRole('combobox', { name: '店铺' }).fill('店铺201')
await page.getByRole('option', { name: '店铺201' }).click()
await page.getByRole('button', { name: '查询', exact: true }).click()
await expect.poll(() => listURLs.at(-1)?.searchParams.getAll('shopIds')).toEqual(['201'])
})
test('PDD 待采集的下一步在 SYB 列表上方打开对应 PDD 商品抽屉', async({ page, context }) => {
@@ -257,7 +339,7 @@ test('混选后采购与采集分别提交候选子集且采集按 PDD 去重',
const rows = page.locator('.el-table__body-wrapper tbody tr')
await expect(rows).toHaveCount(3)
for (let index = 0; index < 3; index += 1) await rows.nth(index).locator('.el-checkbox').click()
await expect(page.getByText('已选择 3 条', { exact: true })).toBeVisible()
await expect(page.getByText('本页选择 3 条', { exact: true })).toBeVisible()
await expect(page.getByRole('button', { name: /创建采购\s*1/ })).toBeEnabled()
await expect(page.getByRole('button', { name: /创建采集\s*1/ })).toBeEnabled()
+2 -2
View File
@@ -61,7 +61,7 @@ test('one confirmation, mixed selection, progress, success and filter retention'
expect(writes).toEqual([11, 12])
expect(lists.at(-1)?.searchParams.get('pageSize')).toBe('200')
await page.getByRole('button', { name: '返回列表', exact: true }).click()
await expect(page.getByText('已选择 0 条', { exact: true })).toBeVisible()
await expect(page.getByText('本页选择 0 条', { exact: true })).toBeVisible()
})
for (const mode of ['partial', 'conflict', 'auth']) {
@@ -75,7 +75,7 @@ for (const mode of ['partial', 'conflict', 'auth']) {
await expect(page.getByText(mode === 'auth' ? '失败 2 条' : mode === 'partial' ? '失败 1 条' : '跳过 2 条', { exact: true })).toBeVisible()
await page.getByRole('button', { name: '返回列表', exact: true }).click()
if (mode === 'partial') {
await expect(page.getByText('已选择 1 条', { exact: true })).toBeVisible()
await expect(page.getByText('本页选择 1 条', { exact: true })).toBeVisible()
await expect(page.getByRole('button', { name: /取消匹配\s*1/ }).first()).toBeEnabled()
}
})
+2 -2
View File
@@ -65,7 +65,7 @@ test('v2 button order, one confirmation, no write on return, progress and mutual
await expect(page.getByText('跳过 1 条', { exact: true })).toBeVisible()
await page.getByRole('button', { name: '返回列表', exact: true }).click()
await expect(page.getByRole('dialog', { name: '批量使用退货结果', exact: true })).toBeHidden()
await expect(page.getByText('已选择 0 条', { exact: true })).toBeVisible()
await expect(page.getByText('本页选择 0 条', { exact: true })).toBeVisible()
})
for (const mode of ['partial', 'conflict', 'auth', 'readback-confirmed']) {
@@ -81,7 +81,7 @@ for (const mode of ['partial', 'conflict', 'auth', 'readback-confirmed']) {
await page.getByRole('button', { name: '返回列表', exact: true }).click()
await expect(page.getByRole('dialog', { name: '批量使用退货结果', exact: true })).toBeHidden()
if (mode === 'partial') {
await expect(page.getByText('已选择 1 条', { exact: true })).toBeVisible()
await expect(page.getByText('本页选择 1 条', { exact: true })).toBeVisible()
await expect(page.getByRole('button', { name: /使用退货\s*1/ })).toBeEnabled()
}
})
@@ -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)
})
@@ -0,0 +1,60 @@
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/api/goauto/syb-products.js'), 'utf8')
const names = [...source.matchAll(/export function (\w+)/g)].map(match => match[1])
const load = request => new Function('request', source.replace(/^import .+\n/gm, '').replace(/export function /g, 'function ') + `\nreturn { ${names.join(',')} }`)(request)
test('search, pagination and page-size changes use the list-only 60-second timeout', () => {
const api = load(config => config)
for (const params of [
{ page: 1, pageSize: 200, shopName: '', processStage: 'return_pending', purchaseType: 'all' },
{ page: 2, pageSize: 200 },
{ page: 1, pageSize: 500 }
]) {
const config = api.listSybProducts(params)
assert.equal(config.timeout, 60000)
assert.equal(config.params, params)
assert.equal(config.url, '/api/admin/v1/syb-products')
assert.equal(config.method, 'get')
}
})
test('existing cancellation and error options are preserved', () => {
const controller = new AbortController()
const config = load(config => config).listSybProducts({}, { signal: controller.signal, suppressNetworkError: true })
assert.equal(config.timeout, 60000)
assert.equal(config.signal, controller.signal)
assert.equal(config.suppressNetworkError, true)
controller.abort()
assert.equal(config.signal.aborted, true)
})
test('multi-shop serialization and explicit timeout override survive integration', () => {
const api = load(config => config)
const config = api.listSybProducts({ shopIds: [1, 2], page: 1 })
assert.equal(config.timeout, 60000)
assert.equal(config.paramsSerializer.serialize(config.params), 'shopIds=1&shopIds=2&page=1')
assert.equal(api.listSybProducts({}, { timeout: 15000 }).timeout, 15000)
})
test('other SYB API functions do not acquire the list timeout; global timeout stays 10 seconds', () => {
const api = load(config => config)
for (const name of names.filter(name => name !== 'listSybProducts')) {
assert.equal(api[name](1, {}).timeout, undefined, name)
}
const globalRequest = fs.readFileSync(path.join(__dirname, '../../src/utils/request.js'), 'utf8')
assert.match(globalRequest, /timeout:\s*10000\b/)
})
test('timeout and cancellation failures propagate without new retries', async () => {
for (const code of ['ECONNABORTED', 'ERR_CANCELED']) {
let calls = 0
const failure = Object.assign(new Error(code), { code })
const api = load(() => { calls++; return Promise.reject(failure) })
await assert.rejects(api.listSybProducts({}), error => error === failure)
assert.equal(calls, 1)
}
})