Compare commits

...
Author SHA1 Message Date
QiuSWandClaude Sonnet 5.5 b311885ea8 docs: sync Wiki mirror for list export (#372)
Business-Rules-and-Glossary@1c57a86, Architecture-and-Code-Map@08da88a.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-10 09:01:11 +08:00
QiuSWandClaude Sonnet 5.5 a4a793f449 feat(web): export all filtered pages to Excel for purchase tasks and SYB products (#372)
- Web: shared list-export util (5000-row cap, progress, no partial file,
  filters frozen at click time) and Export buttons on the purchase
  management and SYB products pages; return matches are read per batch
  of product ids and only matched/confirmed ones are used.
- Server: purchase list gains inclusive orderSubmittedFrom/To day filter
  (UTC+8, same as SYB createdFrom/To, INVALID_REQUEST on bad dates);
  return-matches list items gain read-only yeekeQuantity from the
  existing join. No migration, no new endpoint or permission.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-10 08:58:28 +08:00
12 changed files with 610 additions and 18 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: 76854d900b2372ddedd46a2b82b5677d8bf5cb31
synchronized_at: 2026-10-09T07:45:10Z
wiki_revision: 08da88a4f106004cb5f02448260d2fc22a83e9cc
synchronized_at: 2026-10-10T00:59:09Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
@@ -673,3 +673,12 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
- `PddProductDetailCollector.collectColors` 只基于既有解析帧记录颜色阶段 UP 派发次数、空发现轮次和横向探索三态;不增加抓取、手势或等待。标签及签名只在内存使用,持久化仍为安全聚合。
- 不新增 Server/Web API 字段、服务端迁移或权限;普通结果 payload/missing 与采购探测失败策略未改变。完整遍历与不完整候选拦截仍属 #370 后续待证据实施范围,不能把本地诊断值作为已实现的业务门禁。
- 读取与三态限制见 Troubleshooting;旧版不保证降级打开 v5。JDBC 测试与实际 Android 安装升级分别验收,测试不代替真实竖向颜色采集验证。
## Admin 列表按当前筛选导出全部页(#372,待验收)
实现绑定 `a4a793f`(分支 `feat/372-list-export`,未合并、未发布)。
- 采购管理与 SYB 商品页各增「导出」按钮:`web/src/utils/list-export.js` 先按当前筛选读第 1 页取总数,超过 5000 条不导出,否则逐页读取(采购每页 100、SYB 每页 500)再用 `web/src/vendor/Export2Excel.js`(经 `utils/save-excel.js` 按需加载)生成 xlsx;任一页或批次失败不生成文件;筛选条件以点击时复制的参数为准。无服务端导出接口、无异步导出任务。
- `GET /api/admin/v1/purchase-tasks` 的 `AdminListRequest`(`server/app/goauto/purchase/admin_query.go`)新增可选 `orderSubmittedFrom` / `orderSubmittedTo`(`YYYY-MM-DD`,含首尾两天,按 `order_submitted_at`,UTC+8 与 SYB 创建时间筛选一致);格式错误或起止倒置返回 `INVALID_REQUEST`;无下单时间的任务在设置该筛选时不出现。采购页筛选栏用日期范围选择器。
- `GET /api/admin/v1/return-matches` 的 `returnmatch.ListItem` 新增只读 `yeekeQuantity`(`server/app/goauto/returnmatch/service.go`,来自既有 `yeeke_return_item` join,不参与匹配);该接口原本就支持一次传多个 `sybProductId`,SYB 导出按每批 200 个商品 ID 调用,不逐行请求,且只采用 `matched`/`confirmed` 的匹配。
- 无数据库迁移、无新接口、无新权限;导出按钮不单独鉴权,能打开列表的用户(含采购员)都可导出。
+43 -2
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: a47b6b3b7ef7745417d1c2c88932f5bb12228f27
synchronized_at: 2026-10-09T07:57:47Z
wiki_revision: 1c57a8670034fe08420bcac48b58a9c4b2755f23
synchronized_at: 2026-10-10T00:59:12Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
@@ -905,6 +905,8 @@ Android 0.9.64 / versionCode 77,源码 `6550b9f`(分支实现,尚未安装
## 下单前最终规格复核(#374 v3)
> 以下为 #374 版本行为;包含 #376 的分支构建已按本文后续“采购改地址前复核与提交提示检查”章节替代最终复核策略,尚未合并或发布。不把新规则归于未更新的旧 APK。
实现绑定 `6f1af70d0d2a4c70c31ba42e05c4321f08c6a78e`(原独立工单分支基于 `e26743c`);2026-10-09 已与 #373 集成并推送 main `555a954605a83c12457ed1ada86eb732fe3bb023`。集成版本同时包含导航等待与最终规格复核,未安装或发布,真机采购未验收。
- 仅替换 PurchaseLiveAutomation.finalConfirmation 的最终规格检查,不修改之前的规格选择/verifySummary、地址、单价、数量、提交目标与不可逆边界。没有 Server/Web、API 或数据库变化。
@@ -916,3 +918,42 @@ Android 0.9.64 / versionCode 77,源码 `6550b9f`(分支实现,尚未安装
- 多个不一致的可信摘要、已知组合歧义均拒绝。单个不匹配的摘要不能补证缺失维度;若所有必需维度已有当前精确选中证明且无上述冲突,则按选中证明优先通过。不声称候选缺失时能证明全部潜在 SKU 唯一性。
- 拒绝仍为 PURCHASE_SPEC_NOT_MATCHED,错误信息与诊断只附固定 dimension/stage/summaryPresent/selectedFound/conflict,不附页面文字、规格原文或地址。原本靠任意可见控件含有规格子串的通过路径已移除。
- 自动化证据:8 个相关套件共366项通过,0失败/错误/跳过;debug构建、release单测源码编译通过。完整单测因未修改的图搜返回测试忙循环而中止,不能宣称全量通过。结构夹具依据为 SpecPanelFixtures.sheet/taskOptionDedupSheet 及 PddProductDetailCollectorTest.prefixlessPanel;未执行真实采购,不以合成测试代替真机效果。
## 采购改地址前复核与提交提示检查(#376 v3)
实现绑定工单分支 `fix/376-pre-address-spec-verification` 提交 `9c7357609cee7c73b57edd030136ed12ef9485b6`(2026-10-09),尚未合并 main 或发布;此规则仅适用于包含 #376 的 Android 构建,旧版 #374 行为不因此改变。没有 Server/Web、业务库迁移或 API 边界协议变化。
- 改地址前,verifySummary 在每个目标维度和既有重定位滑动后的当前面板中检查未选提示。面板摘要的“请选择/請選擇”或唯一提交按钮的选择提示导致 PURCHASE_SPEC_SELECTION_UNCONFIRMED、reason=selection_prompt,停止后续地址动作;无唯一提交按钮时仍可检查摘要,不增加改地址前缺少按钮的门禁。
- 本次选择历史证明仅用于既有可见性缺口;目标当前可见、可选却未选中,同维无其他选中项且没有已选摘要时,不再由历史证明路径放行,reason=target_visible_unselected。目标不可见时保留原历史证明行为;不暴露选中属性且无摘要的布局可能提前失败。此前规格选择点击、数量设置、改地址前价格/数量复核不变。
- 修改地址后的 finalConfirmation 不再比较颜色/尺码值,不要求选中证据,也不核对当前数量;保留地址、提交目标唯一性和单价区间检查。摘要未选提示在此只记录,不拦截。唯一提交按钮的完整文字去空白后匹配 `^(选择|選擇).+(后|後)[,,](提交订单|提交訂單)$` 才新增拒绝 PURCHASE_SPEC_NOT_MATCHED、finalSpec;stage=selection_prompt。
- 按钮来源沿用原面板底部行 row 和点击文字节点 chosen;提示只组合该行内可见、自身带文字且没有带文字子孙、最近可点击祖先恰为 row 的节点,按 top/left 排序,不重复拼父子汇总、不跨独立按钮/摘要/规格列表或不可见节点。实际点击目标、行/叶并列选择和支付排除保持不变;enabled=true 不是已选规格的证明。
- finalConfirmation 位于 beforeOrderSubmit 之前,拒绝时不写提交边界、不点击,普通失败可让重购继续。submitOrderOnce 重新取屏后的同类提示只作兜底,零点击但边界已写入,仍为 order_result_unknown,重购停止等待人工核对;不移动持久化边界、不增加重试或付款。
- 本地 final_confirmation_json 新记录以 targetQuantity 表示下发数量,追加 observedQuantity(唯一可见可用数字数量输入,否则 null)、selectedSummaryPresent 与 summaryPromptPresent。仅记录数字/布尔观察值,不新增页面文字或个人信息;不用于拦截、不上传,旧记录不改写。
- 用户明确接受剩余风险:修改地址后规格被换成其他值,或数量重置,但按钮仍正常时会放行;价格与地址检查不能替代规格/数量一致性。观察值仅供事后核对,不证明采购结果正确。缺少提示也不构成 PDD 已选正确的正向证明。
- 不新增分享、OCR/VLM、滑动取证、自动重新选规格或付款;合成测试与 APK 构建不代表真实订单验证。安装、真实采购、合并和发布须按相应授权执行。
验证:19 个相关套件共418项通过,0失败/错误/跳过;Debug APK与release单测源码编译通过。执行器测试模拟提交边界回调,并验证本地JSON序列化;未执行真实SQLite/服务端边界端到端或真实采购验证。不宣称全量Android测试通过(既有图搜返回忙循环用例不在本次回归范围)。
## Android 采购结果明确拒收恢复(#377)
实现位于工单分支 fix/377-rejected-purchase-outbox(实现提交 8b75a95,运行占位与互斥补齐 309dce3);仅 Android,未合并 main、未安装或发布,真机恢复尚待独立授权验证。
- 每轮先发心跳,再在无任务执行锁时恢复中断任务及补传。心跳任务不一致不能挡住拒收收敛;收尾后只额外重发一次心跳。认证错误停止补传,只有最终心跳成功、没有未完成本地任务/待上传结果且收尾正常才领取新任务。不放宽服务端任务身份校验,不修改心跳间隔或离线阈值。
- 只有 HTTP 409 且错误码精确属于 PURCHASE_STATE_CONFLICT、PURCHASE_LEASE_EXPIRED、PURCHASE_RESULT_CONFLICT 才视为明确拒收。未知 409、网络异常、5xx 及认证错误不按拒收处理;缺少 retryable 不构成拒收证据。
- 本地拒收使用 rejected,不伪装 sent。按 outbox 与 task/attempt 归属事务保存,保留原 payload;不再自动补传该条,继续处理后续结果。拒收不触发上传成功或规格探测交接,交接凭证失效。拒收记录不再充当待上传占位;不改变服务端采购结果和原重购停止边界。
- order_created、order_result_unknown 或带订单号的拒收结果在采购页持续提示人工核对。“已核对”只持久隐藏提醒,不删除结果、不调用服务端、不允许再次下单;非订单拒收仍可在设置及任务详情查看。
- goauto_purchase.db v2→v3 只追加拒收时间、白名单错误码及人工核对时间三个可空列,旧结果与记录保持不变;v1 升级仍经过原 v2 追加列。旧版 SQLiteOpenHelper 不保证能打开 v3,不能靠卸载清数据回退,优先使用保留 v3 schema 的修复版本。
- 设置页区分同步中、真实心跳失败、已连接但有拒收;新说明采用固定文案,不展示任意服务端错误消息。本单只修复明确拒收后的持续阻塞,不代表最初心跳中断的原因已查明,历史诊断仍属于 #378。
- 拒收后只在执行锁空闲、当前运行标记属于同一采购任务且本地当前 attempt 已拒收时清理恢复占位,不清除其他采集/采购或新 attempt;手动当前页采集与同步共用既有 working/任务锁门禁。服务停止写 STOPPED,原服务实例的迟到状态更新不覆盖它。
## Admin 列表导出 Excel(#372,待验收)
实现绑定 `a4a793f`,分支 `feat/372-list-export`,未合并、未发布。
- 范围:采购管理和 SYB 商品页的「导出」导出**当前筛选条件下的全部页**,不是只导出当前页;筛选以点击导出时的值为准。总数超过 **5000** 条时提示缩小筛选范围并不导出;任一页读取失败不生成文件;无数据提示且不生成文件。分页读取期间数据变化可能有少量重复或遗漏,以导出时刻近似结果为准。
- 格式:时间 `YYYY-MM-DD HH:mm:ss`(浏览器本地时区);金额单位元,保留两位小数;空值留空;文件名 `采购导出_YYYYMMDD-HHmmss.xlsx`、`SYB商品导出_YYYYMMDD-HHmmss.xlsx`。不导出收货人、地址、账号等个人信息。
- 采购导出列(按顺序):SYB订单号、虾皮商品ID、颜色、尺码(均为 SYB 目标规格快照)、数量、状态(页面同款中文)、PDD单号、下单时间、价格(元)。价格取 `pddOrderAmountCent / 100`,即 Agent 在 PDD 待付款页读到的应付金额;旧版 Agent 或人工补录前为空则留空。要导出成功采购,先把状态筛为「订单已创建」并按需选择下单日期。
- 采购管理新增「下单日期」筛选:含首尾两天,按 `order_submitted_at`,日期边界 UTC+8,与 SYB 商品页创建时间筛选一致;没有下单时间的任务在设置该筛选时不出现。
- SYB 商品导出列(按顺序):SYB订单、虾皮商品ID、SYB颜色、SYB尺码、SYB数量、yeeke颜色、yeeke尺码、yeeke数量、匹配时间。yeeke 颜色/尺码来自该商品有效(`matched` 或 `confirmed`)退货匹配的 `variationName`,按第一个英文或中文逗号拆分,没有逗号时整段放 yeeke 颜色;yeeke 数量为对应退货商品数量;没有有效匹配时 yeeke 列与匹配时间留空。要导出已用退货,先把处理阶段筛为「已用退货」。
- 权限:不新增按钮权限,能打开列表的用户(含采购员)都可导出。
+50
View File
@@ -25,6 +25,46 @@ type AdminListRequest struct {
ShopeeOrderNo string
ShopeeOrderNos []string
PDDOrderNo string
// OrderSubmittedFrom/To are inclusive YYYY-MM-DD days (UTC+8, same as the
// SYB products createdFrom/createdTo filter) over order_submitted_at.
OrderSubmittedFrom string
OrderSubmittedTo string
}
// adminOrderDayLocation matches the SYB products page's createdFrom/createdTo
// day boundary (sybimport.sybStockLocation) so both pages filter the same way.
var adminOrderDayLocation = time.FixedZone("SYB UTC+8", 8*60*60)
// adminOrderSubmittedRange returns [start, endExclusive); a nil bound means
// that side is open. Both days are inclusive for the caller.
func adminOrderSubmittedRange(from, to string) (*time.Time, *time.Time, error) {
from, to = strings.TrimSpace(from), strings.TrimSpace(to)
parse := func(value, label string) (*time.Time, error) {
if value == "" {
return nil, nil
}
parsed, err := time.ParseInLocation("2006-01-02", value, adminOrderDayLocation)
if err != nil {
return nil, fail(CodeInvalidRequest, label+" 必须是 YYYY-MM-DD")
}
return &parsed, nil
}
start, err := parse(from, "orderSubmittedFrom")
if err != nil {
return nil, nil, err
}
endDay, err := parse(to, "orderSubmittedTo")
if err != nil {
return nil, nil, err
}
if start != nil && endDay != nil && start.After(*endDay) {
return nil, nil, fail(CodeInvalidRequest, "orderSubmittedFrom 不能晚于 orderSubmittedTo")
}
if endDay != nil {
end := endDay.AddDate(0, 0, 1)
endDay = &end
}
return start, endDay, nil
}
type AdminTaskItem struct {
@@ -175,7 +215,17 @@ func (s *Service) AdminList(ctx context.Context, req AdminListRequest) (AdminLis
if req.TaskType != "" && req.TaskType != models.PurchaseTaskTypeSYBOrder && req.TaskType != models.PurchaseTaskTypeStock {
return AdminListResponse{}, fail(CodeInvalidRequest, "taskType 无效")
}
submittedFrom, submittedTo, err := adminOrderSubmittedRange(req.OrderSubmittedFrom, req.OrderSubmittedTo)
if err != nil {
return AdminListResponse{}, err
}
query := s.DB.WithContext(ctx).Model(&models.PurchaseTask{})
if submittedFrom != nil {
query = query.Where("order_submitted_at >= ?", *submittedFrom)
}
if submittedTo != nil {
query = query.Where("order_submitted_at < ?", *submittedTo)
}
if req.TaskID > 0 {
query = query.Where("id = ?", req.TaskID)
}
@@ -0,0 +1,129 @@
package purchase
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"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"
)
// #372: order_submitted_at day filter, inclusive on both ends, UTC+8 days.
func TestAdminOrderSubmittedRange(t *testing.T) {
db := testDB(t)
f := seed(t, db, []string{purchasecontract.CapabilityPurchaseRehearsalV1}, true)
s := testService(db)
base := createAdminQueryTask(t, s, f)
loc := time.FixedZone("UTC+8", 8*3600)
at := func(day string, h, m int) *time.Time {
d, _ := time.ParseInLocation("2006-01-02", day, loc)
v := d.Add(time.Duration(h)*time.Hour + time.Duration(m)*time.Minute)
return &v
}
cases := []struct {
name string
status string
at *time.Time
}{
{"before", models.PurchaseTaskStatusOrderCreated, at("2026-10-01", 23, 59)},
{"first-day-start", models.PurchaseTaskStatusOrderCreated, at("2026-10-02", 0, 0)},
{"middle", models.PurchaseTaskStatusOrderCreated, at("2026-10-03", 12, 0)},
{"last-day-end", models.PurchaseTaskStatusOrderCreated, at("2026-10-04", 23, 59)},
{"after", models.PurchaseTaskStatusOrderCreated, at("2026-10-05", 0, 0)},
{"middle-failed", models.PurchaseTaskStatusFailed, at("2026-10-03", 13, 0)},
{"no-time", models.PurchaseTaskStatusOrderCreated, nil},
}
for _, tc := range cases {
row := base
row.ID = 0
row.CreateRequestID = uuid.NewString()
row.ShopeeOrderNoSnapshot = tc.name
row.Status = tc.status
row.OrderSubmittedAt = tc.at
if err := db.Create(&row).Error; err != nil {
t.Fatal(err)
}
}
names := func(req AdminListRequest) map[string]bool {
t.Helper()
out, err := s.AdminList(context.Background(), req)
if err != nil {
t.Fatal(err)
}
got := map[string]bool{}
for _, item := range out.Items {
got[item.ShopeeOrderNoSnapshot] = true
}
if int(out.Total) != len(got) {
t.Fatalf("total %d != items %d", out.Total, len(got))
}
return got
}
both := names(AdminListRequest{OrderSubmittedFrom: "2026-10-02", OrderSubmittedTo: "2026-10-04"})
if len(both) != 4 || !both["first-day-start"] || !both["middle"] || !both["last-day-end"] || !both["middle-failed"] {
t.Fatalf("both days must be inclusive: %v", both)
}
combined := names(AdminListRequest{OrderSubmittedFrom: "2026-10-02", OrderSubmittedTo: "2026-10-04", Status: models.PurchaseTaskStatusOrderCreated})
if len(combined) != 3 || combined["middle-failed"] {
t.Fatalf("must combine with status: %v", combined)
}
if only := names(AdminListRequest{OrderSubmittedFrom: "2026-10-04"}); len(only) != 2 || !only["last-day-end"] || !only["after"] {
t.Fatalf("from only: %v", only)
}
if only := names(AdminListRequest{OrderSubmittedTo: "2026-10-02"}); len(only) != 2 || !only["before"] || !only["first-day-start"] {
t.Fatalf("to only: %v", only)
}
if one := names(AdminListRequest{OrderSubmittedFrom: "2026-10-03", OrderSubmittedTo: "2026-10-03"}); len(one) != 2 {
t.Fatalf("single day: %v", one)
}
if none := names(AdminListRequest{}); !none["no-time"] {
t.Fatalf("no filter must keep rows without order time: %v", none)
}
}
func TestAdminOrderSubmittedRangeInvalid(t *testing.T) {
gin.SetMode(gin.TestMode)
db := testDB(t)
f := seed(t, db, []string{purchasecontract.CapabilityPurchaseRehearsalV1}, true)
s := testService(db)
createAdminQueryTask(t, s, f)
for _, req := range []AdminListRequest{
{OrderSubmittedFrom: "2026/10/02"},
{OrderSubmittedTo: "10-02"},
{OrderSubmittedFrom: "2026-13-01"},
{OrderSubmittedFrom: "2026-10-05", OrderSubmittedTo: "2026-10-04"},
} {
if _, err := s.AdminList(context.Background(), req); code(err) != CodeInvalidRequest {
t.Fatalf("%+v: expected invalid request, got %v", req, err)
}
}
for _, query := range []string{"orderSubmittedFrom=abc", "orderSubmittedTo=2026-02-30"} {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("JWT_PAYLOAD", jwt.MapClaims{"rolekey": "purchaser"})
c.Request = httptest.NewRequest(http.MethodGet, "/api/admin/v1/purchase-tasks?"+query, nil)
(Handler{DB: db}).AdminList(c)
if !strings.Contains(w.Body.String(), CodeInvalidRequest) {
t.Fatalf("handler accepted %s: %s", query, w.Body.String())
}
}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("JWT_PAYLOAD", jwt.MapClaims{"rolekey": "purchaser"})
c.Request = httptest.NewRequest(http.MethodGet, "/api/admin/v1/purchase-tasks?orderSubmittedFrom=2026-10-01&orderSubmittedTo=2026-10-02", nil)
(Handler{DB: db}).AdminList(c)
var result struct {
Data AdminListResponse `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil || w.Code != http.StatusOK {
t.Fatalf("valid range: %d %s", w.Code, w.Body.String())
}
}
+6 -4
View File
@@ -52,10 +52,12 @@ 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")),
ShopeeOrderNos: c.Request.URL.Query()["shopeeOrderNos"],
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")),
OrderSubmittedFrom: c.Query("orderSubmittedFrom"),
OrderSubmittedTo: c.Query("orderSubmittedTo"),
})
if err != nil {
writeError(c, err)
+11 -5
View File
@@ -495,9 +495,12 @@ type ListFilter struct {
// sn, spec) so it never has to issue a second request per row.
type ListItem struct {
models.ReturnMatch
OrderSN string `json:"orderSn,omitempty"`
Image string `json:"image,omitempty"`
VariationName string `json:"variationName,omitempty"`
OrderSN string `json:"orderSn,omitempty"`
Image string `json:"image,omitempty"`
VariationName string `json:"variationName,omitempty"`
// YeekeQuantity is the yeeke return item's own quantity (read-only, #372
// export); it comes from the same join and is never used for matching.
YeekeQuantity int64 `json:"yeekeQuantity"`
DestroyDeadline *time.Time `json:"destroyDeadline,omitempty"`
// SyncStatus/MissingSince (#338) surface the yeeke return item's own
// current availability ("ok"/"missing") so the SYB products match column
@@ -538,6 +541,7 @@ func (s *Service) List(ctx context.Context, filter ListFilter) ([]ListItem, erro
OrderSN string
Image string
VariationName string
Quantity int64
DestroyDeadLine *time.Time
SyncStatus string
MissingSince *time.Time
@@ -545,7 +549,7 @@ func (s *Service) List(ctx context.Context, filter ListFilter) ([]ListItem, erro
PackageMissingAt *time.Time `gorm:"column:package_missing_since"`
}
if err := s.DB.WithContext(ctx).Table("yeeke_return_item AS i").
Select("i.id AS id, p.order_sn AS order_sn, i.image AS image, i.variation_name AS variation_name, p.destroy_dead_line AS destroy_dead_line, "+
Select("i.id AS id, p.order_sn AS order_sn, i.image AS image, i.variation_name AS variation_name, i.quantity AS quantity, p.destroy_dead_line AS destroy_dead_line, "+
"i.sync_status AS sync_status, i.missing_since AS missing_since, p.sync_status AS package_sync_status, p.missing_since AS package_missing_since").
Joins("JOIN yeeke_return_package AS p ON p.id = i.package_id").
Where("i.id IN ?", returnIDs).
@@ -556,6 +560,7 @@ func (s *Service) List(ctx context.Context, filter ListFilter) ([]ListItem, erro
OrderSN string
Image string
VariationName string
Quantity int64
DestroyDeadLine *time.Time
SyncStatus string
MissingSince *time.Time
@@ -569,13 +574,14 @@ func (s *Service) List(ctx context.Context, filter ListFilter) ([]ListItem, erro
if j.PackageSyncStat != "ok" {
syncStatus, missingSince = j.PackageSyncStat, j.PackageMissingAt
}
byID[j.ID] = extraFields{j.OrderSN, j.Image, j.VariationName, j.DestroyDeadLine, syncStatus, missingSince}
byID[j.ID] = extraFields{j.OrderSN, j.Image, j.VariationName, j.Quantity, j.DestroyDeadLine, syncStatus, missingSince}
}
for i := range items {
if extra, ok := byID[items[i].YeekeReturnItemID]; ok {
items[i].OrderSN = extra.OrderSN
items[i].Image = extra.Image
items[i].VariationName = extra.VariationName
items[i].YeekeQuantity = extra.Quantity
items[i].DestroyDeadline = extra.DestroyDeadLine
items[i].SyncStatus = extra.SyncStatus
items[i].MissingSince = extra.MissingSince
@@ -2,6 +2,7 @@ package returnmatch
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
@@ -484,3 +485,27 @@ func TestList_EnrichesWithYeekeReturnDisplayFields(t *testing.T) {
t.Fatalf("expected enriched list item: %+v", items)
}
}
// #372: List exposes the yeeke return item's quantity (read-only export field).
func TestList_ExposesYeekeQuantity(t *testing.T) {
db := testDB(t)
s := NewService(db)
s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
syb := seedSYB(t, db, "SYB-Q", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
item := seedReturn(t, db, "白色,L", &deadline)
if err := db.Model(&models.YeekeReturnItem{}).Where("id = ?", item.ID).Update("quantity", 3).Error; err != nil {
t.Fatal(err)
}
if resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}}); err != nil || resp.MatchedCount != 1 {
t.Fatalf("setup failed: %v %+v", err, resp)
}
items, err := s.List(context.Background(), ListFilter{SYBProductIDs: []uint64{syb.ID}})
if err != nil || len(items) != 1 || items[0].YeekeQuantity != 3 {
t.Fatalf("expected yeekeQuantity 3: %+v %v", items, err)
}
raw, _ := json.Marshal(items[0])
if !strings.Contains(string(raw), `"yeekeQuantity":3`) {
t.Fatalf("json missing yeekeQuantity: %s", raw)
}
}
+136
View File
@@ -0,0 +1,136 @@
// #372: export every page of an admin list (current filters) to xlsx.
// Pure helpers with injected dependencies so they can be unit tested without a
// browser; the pages inject the API calls and the Export2Excel saver.
export const EXPORT_MAX_ROWS = 5000
const pad = (value, width = 2) => String(value).padStart(width, '0')
// YYYY-MM-DD HH:mm:ss in the browser's local time zone; empty for blank/invalid.
export function formatExportTime(value) {
if (!value) return ''
const date = new Date(value)
if (Number.isNaN(date.getTime())) return ''
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
// YYYYMMDD-HHmmss for file names.
export function formatExportStamp(date = new Date()) {
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`
}
// Cents -> yuan text with two decimals; null/undefined/'' stay empty.
export function centToYuan(cent) {
if (cent === null || cent === undefined || cent === '') return ''
const value = Number(cent)
if (!Number.isFinite(value)) return ''
const sign = value < 0 ? '-' : ''
const abs = Math.abs(Math.round(value))
return `${sign}${Math.floor(abs / 100)}.${pad(abs % 100)}`
}
// yeeke only has one variation_name ("color,size"); split at the first English
// or Chinese comma. Without a comma the whole text is the color.
export function splitYeekeVariation(name) {
const text = String(name ?? '').trim()
if (!text) return { color: '', size: '' }
const index = text.search(/[,,]/)
if (index < 0) return { color: text, size: '' }
return { color: text.slice(0, index).trim(), size: text.slice(index + 1).trim() }
}
const blank = value => (value === null || value === undefined ? '' : value)
export const PURCHASE_EXPORT_HEADER = ['SYB订单号', '虾皮商品ID', '颜色', '尺码', '数量', '状态', 'PDD单号', '下单时间', '价格(元)']
// statusLabel: the page's own status -> Chinese label function.
export function purchaseExportRow(task, statusLabel = value => value) {
return [
blank(task.shopeeOrderNoSnapshot), blank(task.shopeeItemIdSnapshot), blank(task.targetColorSnapshot), blank(task.targetSizeSnapshot),
blank(task.quantity), task.status ? statusLabel(task.status) : '', blank(task.pddOrderNo),
formatExportTime(task.orderSubmittedAt), centToYuan(task.pddOrderAmountCent)
]
}
export const SYB_PRODUCT_EXPORT_HEADER = ['SYB订单', '虾皮商品ID', 'SYB颜色', 'SYB尺码', 'SYB数量', 'yeeke颜色', 'yeeke尺码', 'yeeke数量', '匹配时间']
export const isActiveReturnMatch = match => Boolean(match) && (match.status === 'matched' || match.status === 'confirmed')
// match: the active (matched/confirmed) return match of this product, or null.
export function sybProductExportRow(product, match) {
const active = isActiveReturnMatch(match) ? match : null
const yeeke = active ? splitYeekeVariation(active.variationName) : { color: '', size: '' }
return [
blank(product.orderCode), blank(product.shopeeItemId), blank(product.targetColor), blank(product.targetSize), blank(product.quantity),
yeeke.color, yeeke.size, active ? blank(active.yeekeQuantity) : '', active ? formatExportTime(active.matchedAt) : ''
]
}
export function exportFailureMessage(result, maxRows = EXPORT_MAX_ROWS) {
switch (result.reason) {
case 'empty': return '当前筛选没有数据,未生成文件'
case 'too_many': return `共 ${result.total} 条,超过 ${maxRows} 条上限,请缩小筛选范围`
case 'page_failed': return `导出失败:第 ${result.page} 页读取失败,未生成文件`
case 'enrich_failed': return '导出失败:匹配信息读取失败,未生成文件'
default: return '导出失败,未生成文件'
}
}
function chunk(items, size) {
const result = []
for (let i = 0; i < items.length; i += size) result.push(items.slice(i, i + size))
return result
}
// Reads page 1 for the total, refuses above maxRows, then reads the remaining
// pages. Any failure returns { ok: false } and nothing is saved.
// fetchPage(page, pageSize) -> { items, total }
// enrich(items) -> decorated items of the same length (optional, per batch)
// mapRow(item) -> array of cells
// save({ header, data, filename }) -> writes the file (only after success)
export async function runListExport({
fetchPage, pageSize, maxRows = EXPORT_MAX_ROWS, enrich = null, enrichBatchSize = 200,
header, mapRow, filename, save, onProgress = () => {}
}) {
let first
try {
first = await fetchPage(1, pageSize)
} catch (error) {
return { ok: false, reason: 'page_failed', page: 1, error }
}
const total = Number(first?.total) || 0
if (total === 0) return { ok: false, reason: 'empty', total: 0 }
if (total > maxRows) return { ok: false, reason: 'too_many', total }
const pages = Math.ceil(total / pageSize)
const enrichSteps = enrich ? Math.ceil(total / enrichBatchSize) : 0
const steps = pages + enrichSteps
let done = 1
onProgress(done, steps)
let items = [...(first.items || [])]
for (let page = 2; page <= pages; page++) {
let response
try {
response = await fetchPage(page, pageSize)
} catch (error) {
return { ok: false, reason: 'page_failed', page, error }
}
items = items.concat(response?.items || [])
onProgress(++done, steps)
}
if (enrich) {
const decorated = []
for (const part of chunk(items, enrichBatchSize)) {
try {
decorated.push(...await enrich(part))
} catch (error) {
return { ok: false, reason: 'enrich_failed', error }
}
onProgress(++done, steps)
}
items = decorated
}
if (items.length === 0) return { ok: false, reason: 'empty', total: 0 }
const data = items.map(mapRow)
await save({ header, data, filename: `${filename}_${formatExportStamp()}` })
return { ok: true, total, rows: data.length }
}
+5
View File
@@ -0,0 +1,5 @@
// Loads the xlsx writer on demand so the list pages do not pull it into their chunk.
export async function saveExcel(file) {
const { export_json_to_excel: exportJsonToExcel } = await import('@/vendor/Export2Excel')
exportJsonToExcel(file)
}
+28 -4
View File
@@ -16,7 +16,8 @@
<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" :disabled="Boolean(orderFilter.error)" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button></el-form-item>
<el-form-item label="下单日期"><el-date-picker v-model="query.orderSubmittedRange" type="daterange" value-format="YYYY-MM-DD" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" clearable style="width:260px" /></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-button :disabled="exporting || Boolean(orderFilter.error)" @click="exportExcel">{{ exporting ? `导出中 ${exportProgress.done}/${exportProgress.steps}` : '导出' }}</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>
@@ -154,6 +155,8 @@ 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'
import { createRequestId } from '@/utils/request-id'
import { saveExcel } from '@/utils/save-excel'
import { EXPORT_MAX_ROWS, PURCHASE_EXPORT_HEADER, exportFailureMessage, purchaseExportRow, runListExport } from '@/utils/list-export'
import { listFailureSnapshots } from '@/api/goauto/purchase-failure-snapshots'
import FailureSnapshotCell from './FailureSnapshotCell.vue'
@@ -169,9 +172,10 @@ export default {
data() {
return {
loading: false, loadError: '', tasks: [], total: 0, statuses: [...statusOptions, { value: 'syb_writeback_succeeded', label: '已回填 SYB' }],
exporting: false, exportProgress: { done: 0, steps: 0 },
detailSession: 0, snapshots: { items: {}, loading: false, error: false },
orderWriteback: { open: false, saving: false, items: [] },
query: { page: 1, pageSize: 20, taskId: '', taskType: '', shopeeOrderNo: '', status: '', executionMode: 'live', sybProductId: '', pddOrderNo: '' },
query: { page: 1, pageSize: 20, taskId: '', taskType: '', shopeeOrderNo: '', status: '', executionMode: 'live', sybProductId: '', pddOrderNo: '', orderSubmittedRange: [] },
detail: { open: false, loading: false, task: null, attempts: [], matching: null },
matchingAction: { saving: false }, matchingDialog: { open: false, saving: false, color: '', size: '' },
retrySelection: [],
@@ -227,14 +231,34 @@ export default {
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 params = Object.fromEntries(Object.entries(this.query).filter(([key, value]) => key !== 'shopeeOrderNo' && key !== 'orderSubmittedRange' && value !== ''))
const [orderSubmittedFrom, orderSubmittedTo] = this.query.orderSubmittedRange || []
if (orderSubmittedFrom) params.orderSubmittedFrom = orderSubmittedFrom
if (orderSubmittedTo) params.orderSubmittedTo = orderSubmittedTo
const values = this.orderFilter.values
if (values.length === 1) params.shopeeOrderNo = values[0]
else if (values.length > 1) params.shopeeOrderNos = values
return params
},
search() { this.query.page = 1; this.load() },
reset() { this.query = { page: 1, pageSize: 20, taskId: '', taskType: '', shopeeOrderNo: '', status: '', executionMode: 'live', sybProductId: '', pddOrderNo: '' }; this.load() },
reset() { this.query = { page: 1, pageSize: 20, taskId: '', taskType: '', shopeeOrderNo: '', status: '', executionMode: 'live', sybProductId: '', pddOrderNo: '', orderSubmittedRange: [] }; this.load() },
// #372: export every page of the filters as they are at click time (params are copied once).
async exportExcel() {
if (this.exporting || this.orderFilter.error) return
const filters = this.cleanQuery(); delete filters.page; delete filters.pageSize
this.exporting = true; this.exportProgress = { done: 0, steps: 0 }
try {
const result = await runListExport({
pageSize: 100, maxRows: EXPORT_MAX_ROWS, header: PURCHASE_EXPORT_HEADER, filename: '采购导出',
fetchPage: async(pageNo, size) => { const r = await listPurchaseTasks({ ...filters, page: pageNo, pageSize: size }); return { items: r.data.items || [], total: r.data.total || 0 } },
mapRow: task => purchaseExportRow(task, this.statusLabel),
save: saveExcel,
onProgress: (done, steps) => { this.exportProgress = { done, steps } }
})
if (!result.ok) ElMessage.warning(exportFailureMessage(result, EXPORT_MAX_ROWS))
else ElMessage.success(`已导出 ${result.rows} 条;导出期间数据变化可能有少量差异`)
} catch (error) { ElMessage.error('导出失败,未生成文件') } finally { this.exporting = false }
},
clearSnapshots() { this.detailSession++; this.snapshots = { items: {}, loading: false, error: false } },
async loadSnapshots() {
if (!this.isSnapshotAdmin || !this.detail.open || !this.detail.task || this.snapshots.loading) return
+33 -1
View File
@@ -26,7 +26,7 @@
<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>
<el-form-item label="处理阶段"><el-select v-model="query.processStage" clearable placeholder="全部" style="width: 132px" @change="onProcessStageChange"><el-option v-for="item in processStageOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item>
<el-form-item label="采购类型"><el-select v-model="query.purchaseType" style="width: 132px"><el-option label="需 PDD 采购" value="pdd" /><el-option label="无需 PDD 采购" value="excluded" /><el-option label="全部" value="all" /></el-select></el-form-item>
<el-form-item class="toolbar-actions"><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button><el-tooltip v-if="canPurchase" :content="aiMatchButtonReason" :disabled="!aiMatchButtonReason" placement="top"><span class="action-button-wrap"><el-button :loading="specMatchLoading" :disabled="purchaseReadinessLoading || specMatchLoading || aiMatchCandidates.length === 0 || aiMatchButtonOverLimit" @click="runBatchSpecMatch">AI 匹配<span class="action-count">{{ aiMatchCandidates.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="imageSearchButtonReason" :disabled="!imageSearchButtonReason" placement="top"><span class="action-button-wrap"><el-button type="success" plain :disabled="imageSearchRows.length === 0 || imageSearchButtonOverLimit" @click="openImageSearchBatch">图搜采集<span class="action-count">{{ imageSearchRows.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="collectionButtonReason" :disabled="!collectionButtonReason" placement="top"><span class="action-button-wrap"><el-button type="primary" :loading="purchaseReadinessLoading" :disabled="purchaseReadinessLoading || specMatchLoading || collectionCandidates.length === 0 || collectionButtonOverLimit" @click="openCollectionBatch">创建采集<span class="action-count action-count-primary">{{ collectionCandidates.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="purchaseButtonReason" :disabled="!purchaseButtonReason" placement="top"><span class="action-button-wrap"><el-button type="primary" :loading="purchaseReadinessLoading" :disabled="purchaseReadinessLoading || specMatchLoading || purchaseCandidates.length === 0 || purchaseButtonOverLimit" @click="openPurchaseBatch">创建采购<span class="action-count action-count-primary">{{ purchaseCandidates.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="returnMatchButtonReason" :disabled="!returnMatchButtonReason" placement="top"><span class="action-button-wrap"><el-button :loading="returnMatchBatchLoading" :disabled="purchaseReadinessLoading || returnMatchBatchLoading || returnConfirmBatchLoading || returnCancelBatchLoading || returnTransitionLoading || matchDetail.saving || returnMatchCandidateIds.length === 0 || returnMatchButtonOverLimit" @click="runBatchMatchReturns">匹配退货<span class="action-count">{{ returnMatchCandidateIds.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="returnCancelButtonReason" :disabled="!returnCancelButtonReason" placement="top"><span class="action-button-wrap"><el-button :loading="returnCancelBatchLoading" :disabled="Boolean(returnCancelButtonReason)" @click="runBatchCancelReturns">取消匹配<span class="action-count">{{ returnCancelCandidates.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="returnConfirmButtonReason" :disabled="!returnConfirmButtonReason" placement="top"><span class="action-button-wrap"><el-button type="success" plain :loading="returnConfirmBatchLoading" :disabled="Boolean(returnConfirmButtonReason)" @click="runBatchConfirmReturns">使用退货<span class="action-count">{{ returnConfirmCandidates.length }}</span></el-button></span></el-tooltip></el-form-item>
<el-form-item class="toolbar-actions"><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button><el-button :disabled="exporting" @click="exportExcel">{{ exporting ? `导出中 ${exportProgress.done}/${exportProgress.steps}` : '导出' }}</el-button><el-tooltip v-if="canPurchase" :content="aiMatchButtonReason" :disabled="!aiMatchButtonReason" placement="top"><span class="action-button-wrap"><el-button :loading="specMatchLoading" :disabled="purchaseReadinessLoading || specMatchLoading || aiMatchCandidates.length === 0 || aiMatchButtonOverLimit" @click="runBatchSpecMatch">AI 匹配<span class="action-count">{{ aiMatchCandidates.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="imageSearchButtonReason" :disabled="!imageSearchButtonReason" placement="top"><span class="action-button-wrap"><el-button type="success" plain :disabled="imageSearchRows.length === 0 || imageSearchButtonOverLimit" @click="openImageSearchBatch">图搜采集<span class="action-count">{{ imageSearchRows.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="collectionButtonReason" :disabled="!collectionButtonReason" placement="top"><span class="action-button-wrap"><el-button type="primary" :loading="purchaseReadinessLoading" :disabled="purchaseReadinessLoading || specMatchLoading || collectionCandidates.length === 0 || collectionButtonOverLimit" @click="openCollectionBatch">创建采集<span class="action-count action-count-primary">{{ collectionCandidates.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="purchaseButtonReason" :disabled="!purchaseButtonReason" placement="top"><span class="action-button-wrap"><el-button type="primary" :loading="purchaseReadinessLoading" :disabled="purchaseReadinessLoading || specMatchLoading || purchaseCandidates.length === 0 || purchaseButtonOverLimit" @click="openPurchaseBatch">创建采购<span class="action-count action-count-primary">{{ purchaseCandidates.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="returnMatchButtonReason" :disabled="!returnMatchButtonReason" placement="top"><span class="action-button-wrap"><el-button :loading="returnMatchBatchLoading" :disabled="purchaseReadinessLoading || returnMatchBatchLoading || returnConfirmBatchLoading || returnCancelBatchLoading || returnTransitionLoading || matchDetail.saving || returnMatchCandidateIds.length === 0 || returnMatchButtonOverLimit" @click="runBatchMatchReturns">匹配退货<span class="action-count">{{ returnMatchCandidateIds.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="returnCancelButtonReason" :disabled="!returnCancelButtonReason" placement="top"><span class="action-button-wrap"><el-button :loading="returnCancelBatchLoading" :disabled="Boolean(returnCancelButtonReason)" @click="runBatchCancelReturns">取消匹配<span class="action-count">{{ returnCancelCandidates.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="returnConfirmButtonReason" :disabled="!returnConfirmButtonReason" placement="top"><span class="action-button-wrap"><el-button type="success" plain :loading="returnConfirmBatchLoading" :disabled="Boolean(returnConfirmButtonReason)" @click="runBatchConfirmReturns">使用退货<span class="action-count">{{ returnConfirmCandidates.length }}</span></el-button></span></el-tooltip></el-form-item>
</div>
<div class="search-row search-row-secondary">
<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>
@@ -395,6 +395,8 @@ import { createPurchaseTasksBatch, matchPurchaseSpecsBatch, previewPurchaseTasks
import { batchCreateCollectionTasks, batchCreateImageSearchCollectionTasks } from '@/api/goauto/collection-tasks'
import { listCollectionRules } from '@/api/goauto/collection-rules'
import { listReturnMatches, batchMatchReturns, getReturnMatch, confirmReturnMatch, cancelReturnMatch, remarkReturnMatch } from '@/api/goauto/return-matches'
import { saveExcel } from '@/utils/save-excel'
import { EXPORT_MAX_ROWS, SYB_PRODUCT_EXPORT_HEADER, exportFailureMessage, isActiveReturnMatch, runListExport, sybProductExportRow } from '@/utils/list-export'
import { createRequestId } from '@/utils/request-id'
import { readPurchaseDevice, rememberPurchaseDevice } from '@/utils/purchase-device-preference'
import ShopeeProductDetailDrawer from '../shopee-products/ShopeeProductDetailDrawer.vue'
@@ -430,6 +432,8 @@ export default {
collectionBatchData: { ruleId: null, deviceId: null },
collectionBatchRules: { ruleId: [{ required: true, message: '请选择采集规则', trigger: 'change' }] },
query: { page: 1, pageSize: 200, shopIds: [], orderCodesText: '', parseStatus: '', processStage: '', purchaseType: 'pdd', createdAtRange: [] },
exporting: false,
exportProgress: { done: 0, steps: 0 },
returnMatchByProductId: {},
returnMatchLoading: false,
returnMatchBatchLoading: false,
@@ -529,6 +533,34 @@ export default {
}
},
search() { this.query.page = 1; this.load() },
// #372: export every page of the filters as they are at click time (params are copied once);
// return matches are read per batch of product ids, never per row.
async exportExcel() {
if (this.exporting) return
const orderCodes = this.normalizedOrderCodes()
if (orderCodes === null) return
const [createdFrom = '', createdTo = ''] = this.query.createdAtRange || []
const filters = { shopIds: [...this.query.shopIds], orderCodes: orderCodes.join(','), parseStatus: this.query.parseStatus, processStage: this.query.processStage, purchaseType: this.query.purchaseType, createdFrom, createdTo }
this.exporting = true; this.exportProgress = { done: 0, steps: 0 }
try {
const result = await runListExport({
pageSize: 500, maxRows: EXPORT_MAX_ROWS, header: SYB_PRODUCT_EXPORT_HEADER, filename: 'SYB商品导出',
fetchPage: async(page, size) => { const r = await listSybProducts({ ...filters, page, pageSize: size }, { suppressErrorMessage: true }); return { items: r.data.items || [], total: r.data.total || 0 } },
enrichBatchSize: 200,
enrich: async items => {
const r = await listReturnMatches({ sybProductId: items.map(item => item.id) }, { suppressErrorMessage: true })
const byProduct = {}
for (const match of (r.data?.items || [])) if (isActiveReturnMatch(match) && !byProduct[match.sybProductId]) byProduct[match.sybProductId] = match
return items.map(item => ({ item, match: byProduct[item.id] || null }))
},
mapRow: ({ item, match }) => sybProductExportRow(item, match),
save: saveExcel,
onProgress: (done, steps) => { this.exportProgress = { done, steps } }
})
if (!result.ok) ElMessage.warning(exportFailureMessage(result, EXPORT_MAX_ROWS))
else ElMessage.success(`已导出 ${result.rows} 条;导出期间数据变化可能有少量差异`)
} catch (error) { ElMessage.error('导出失败,未生成文件') } finally { this.exporting = false }
},
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
+133
View File
@@ -0,0 +1,133 @@
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/list-export.js'), 'utf8')
const lib = new Function(source.replace(/^export /gm, '') + `
return { EXPORT_MAX_ROWS, formatExportTime, formatExportStamp, centToYuan, splitYeekeVariation, purchaseExportRow, PURCHASE_EXPORT_HEADER,
sybProductExportRow, SYB_PRODUCT_EXPORT_HEADER, exportFailureMessage, runListExport }`)()
test('time and stamp use local time, empty and invalid stay empty', () => {
const date = new Date(2026, 9, 9, 8, 5, 3)
assert.equal(lib.formatExportTime(date.toISOString()), '2026-10-09 08:05:03')
assert.equal(lib.formatExportTime(null), '')
assert.equal(lib.formatExportTime(''), '')
assert.equal(lib.formatExportTime('not a date'), '')
assert.equal(lib.formatExportStamp(date), '20261009-080503')
})
test('cent to yuan keeps two decimals and leaves empty values empty', () => {
assert.equal(lib.centToYuan(12345), '123.45')
assert.equal(lib.centToYuan(5), '0.05')
assert.equal(lib.centToYuan(1000), '10.00')
assert.equal(lib.centToYuan(0), '0.00')
for (const value of [null, undefined, '']) assert.equal(lib.centToYuan(value), '')
})
test('yeeke variation splits at the first English or Chinese comma, none puts all in color', () => {
assert.deepEqual(lib.splitYeekeVariation('白色,L【建議65-75公斤】'), { color: '白色', size: 'L【建議65-75公斤】' })
assert.deepEqual(lib.splitYeekeVariation('黑色+白色,簡約,L'), { color: '黑色+白色', size: '簡約,L' })
assert.deepEqual(lib.splitYeekeVariation('均碼'), { color: '均碼', size: '' })
assert.deepEqual(lib.splitYeekeVariation(''), { color: '', size: '' })
assert.deepEqual(lib.splitYeekeVariation(undefined), { color: '', size: '' })
})
test('purchase row maps columns in order with status label, amount and blanks', () => {
assert.equal(lib.PURCHASE_EXPORT_HEADER.length, 9)
const row = lib.purchaseExportRow({
shopeeOrderNoSnapshot: '001', shopeeItemIdSnapshot: '5020', targetColorSnapshot: '白色', targetSizeSnapshot: 'L', quantity: 2,
status: 'order_created', pddOrderNo: 'PDD-1', orderSubmittedAt: new Date(2026, 9, 9, 1, 2, 3).toISOString(), pddOrderAmountCent: 3990,
receiverName: 'must not leak', address: 'must not leak'
}, value => ({ order_created: '订单已创建' })[value])
assert.deepEqual(row, ['001', '5020', '白色', 'L', 2, '订单已创建', 'PDD-1', '2026-10-09 01:02:03', '39.90'])
assert.deepEqual(lib.purchaseExportRow({ quantity: 1 }), ['', '', '', '', 1, '', '', '', ''])
assert.equal(lib.purchaseExportRow({ shopeeOrderNoSnapshot: 'A', status: 'failed', pddOrderAmountCent: null })[8], '')
})
test('syb product row uses only an active match and ignores cancelled ones', () => {
const product = { orderCode: 'O1', shopeeItemId: '99', targetColor: '白色', targetSize: 'L', quantity: 1 }
const matchedAt = new Date(2026, 9, 9, 9, 0, 0).toISOString()
assert.equal(lib.SYB_PRODUCT_EXPORT_HEADER.length, 9)
assert.deepEqual(lib.sybProductExportRow(product, { status: 'confirmed', variationName: '白色,L', yeekeQuantity: 3, matchedAt }),
['O1', '99', '白色', 'L', 1, '白色', 'L', 3, '2026-10-09 09:00:00'])
assert.deepEqual(lib.sybProductExportRow(product, { status: 'matched', variationName: '均碼', yeekeQuantity: 1, matchedAt }).slice(5, 8), ['均碼', '', 1])
assert.deepEqual(lib.sybProductExportRow(product, { status: 'cancelled', variationName: 'x', yeekeQuantity: 1, matchedAt }), ['O1', '99', '白色', 'L', 1, '', '', '', ''])
assert.deepEqual(lib.sybProductExportRow(product, null), ['O1', '99', '白色', 'L', 1, '', '', '', ''])
})
function harness(total, options = {}) {
const calls = []; const saved = []; const progress = []
const pageSize = options.pageSize || 100
const run = () => lib.runListExport({
pageSize, header: ['id'], filename: '测试', mapRow: item => [item.id],
fetchPage: async (page, size) => {
calls.push(page)
if (options.failPage === page) throw new Error('boom')
const start = (page - 1) * size
return { total, items: Array.from({ length: Math.max(0, Math.min(size, total - start)) }, (_, i) => ({ id: start + i + 1 })) }
},
save: async file => { saved.push(file) }, onProgress: (done, steps) => progress.push([done, steps]), ...options.extra
})
return { run, calls, saved, progress }
}
test('reads every page, reports progress, saves once with a timestamped name', async () => {
const h = harness(250)
const result = await h.run()
assert.deepEqual(result, { ok: true, total: 250, rows: 250 })
assert.deepEqual(h.calls, [1, 2, 3])
assert.deepEqual(h.progress, [[1, 3], [2, 3], [3, 3]])
assert.equal(h.saved.length, 1)
assert.equal(h.saved[0].data.length, 250)
assert.deepEqual(h.saved[0].data[249], [250])
assert.match(h.saved[0].filename, /^测试_\d{8}-\d{6}$/)
})
test('exactly 5000 rows are allowed, 5001 is refused after reading only page 1', async () => {
const ok = harness(5000, { pageSize: 500 })
assert.equal((await ok.run()).ok, true)
assert.equal(ok.calls.length, 10)
const over = harness(5001, { pageSize: 500 })
const result = await over.run()
assert.deepEqual(result, { ok: false, reason: 'too_many', total: 5001 })
assert.deepEqual(over.calls, [1])
assert.equal(over.saved.length, 0)
assert.equal(lib.exportFailureMessage(result), '共 5001 条,超过 5000 条上限,请缩小筛选范围')
})
test('empty result does not save', async () => {
const h = harness(0)
const result = await h.run()
assert.deepEqual(result, { ok: false, reason: 'empty', total: 0 })
assert.equal(h.saved.length, 0)
assert.match(lib.exportFailureMessage(result), /没有数据/)
})
test('a failing page stops the export without saving', async () => {
const h = harness(450, { failPage: 3 })
const result = await h.run()
assert.equal(result.ok, false)
assert.equal(result.reason, 'page_failed')
assert.equal(result.page, 3)
assert.deepEqual(h.calls, [1, 2, 3])
assert.equal(h.saved.length, 0)
assert.equal(lib.exportFailureMessage(result), '导出失败:第 3 页读取失败,未生成文件')
const first = harness(10, { failPage: 1 })
assert.equal((await first.run()).page, 1)
assert.equal(first.saved.length, 0)
})
test('enrich runs per batch (not per row), counts in progress, and a failure saves nothing', async () => {
const batches = []
const h = harness(450, { pageSize: 200, extra: { enrichBatchSize: 200, enrich: async items => { batches.push(items.length); return items.map(item => ({ ...item, id: item.id * 10 })) } } })
const result = await h.run()
assert.equal(result.ok, true)
assert.deepEqual(batches, [200, 200, 50])
assert.deepEqual(h.saved[0].data[0], [10])
assert.deepEqual(h.progress[h.progress.length - 1], [6, 6])
const bad = harness(450, { pageSize: 200, extra: { enrich: async () => { throw new Error('boom') } } })
const failed = await bad.run()
assert.equal(failed.reason, 'enrich_failed')
assert.equal(bad.saved.length, 0)
})