Compare commits

..
19 changed files with 951 additions and 40 deletions
+19 -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: 0e0ad0b3312c524d90e063167dd2e945fb22b0e9
synchronized_at: 2026-09-29T01:36:01Z
wiki_revision: 1e87b8832cecd454e58f0ff49dbe05bbf8d61544
synchronized_at: 2026-09-29T03:47:39Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
@@ -561,3 +561,20 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
- Web `syb-products/return-cancel.js` 与列表组件复用单条取消接口;冻结当前页勾选的 SYB 商品 ID 与原匹配 ID,一次确认后串行请求,独立汇总成功、跳过及失败。取消结果不会驱动已有采购或采集任务。
- `returnmatch.Service.Cancel` 在锁定原匹配记录的事务中校验可选状态前提,再释放有效占用指针并写取消审计;事务内审计失败则整体回滚。无额外数据库迁移、权限、批量后台任务或外部写入。
- 元数据加载失败时禁止批量取消;执行时互斥匹配、确认、取消。网络不确定时只读回原匹配,不自动重复写入;登录失效停止后续请求。列表刷新与失败项勾选恢复受查询 generation 约束,不跨页、不覆盖较新的筛选结果。
## SYB 当前页批量使用退货(#349)
实现绑定 4280115(2026-09-29),feat/349-batch-return-use;以 #347 a4c7b5e 为基线,未合并 main、未发布或真实业务验收。
- Web syb-products/index.vue 新增“使用退货 N”,紧跟“取消匹配”;return-confirm.js 冻结当前页原 SYB / match 身份,去重后一次确认、串行复用既有 confirm API,结果独立汇总。return-cancel.js 和单条操作接入使用期间互斥,不重构取消逻辑。
- return-matches.js 的 confirmReturnMatch 增加可选客户端请求选项,仅用于批量汇总时抑制逐项错误弹窗;无新增 HTTP 字段。Server 生产代码、确认锁内 matched 检查、ConfirmedBy/ConfirmedAt、审计和占用指针不变,无迁移或权限变化。
- 未知写结果只读回原 match,回读确认不宣称为本次成功;登录失效停止余项。刷新保持筛选与页码,失败选择恢复受 generation、当前页及原 matched 身份约束。未调用 Yeeke 发货或修改采购任务。
- 回归入口:Web tests/unit/syb-return-confirm.test.cjs、tests/e2e/syb-return-confirm.spec.ts;Server returnmatch/confirm_batch_test.go。测试使用合成数据和隔离 SQLite,不代表生产 MySQL 并发或真实退货验证。
## 档口入库码匹配容错与手动批量重匹配(#350)
- `sybinnercode/match.go` 在 SKU/档口证据筛选与分配结果为空时明确受限,不访问空数组;只读单条规划通过 `safePlanRecord` 隔离异常,下一条继续处理,不调用远端写接口。
- `failMatchJob` 用独立 10 秒清理上下文收敛中断批次及其 pending/matching 记录;成功计划和回写记录保留。`cmd/api/server.go` 在服务启动时调用 `RecoverInterruptedMatches`,不自动重跑匹配或写入。
- rematch 在原行锁事务内检查资格和活动批次占用,排队记录改为 matching,阻止重叠请求;matching 记录不可删除。
- 原页面默认用户浏览器本地今天范围,工具栏“匹配 N”复用现有 rematch 和任务进度;只处理本页合格项,同日提交,原回写需独立确认。
实现绑定:`fix/350-inner-code-matching`,源码提交 `9c5e20e`;未合并 main、未部署,正式环境仍需发布后启用。
+22 -4
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: e95c53cec27080e073e6f7a4aaed46d5421dddf5
synchronized_at: 2026-09-29T01:36:04Z
wiki_revision: 214ac317abfef23d71362968d5b30f078fa528b6
synchronized_at: 2026-09-29T03:47:42Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
@@ -335,11 +335,11 @@ synchronized_at: 2026-09-22T02:56:27Z
## SYB 档口入库码导入、匹配与回写
- 只接受不超过 10 MB、最多 5000 条非空数据行且包含“标签入库码映射”工作表的 Excel。一次文件只能包含一个营业日期;同日单件入库码必须唯一,同一业务键按营业日期、订单号、档口和规范化规格合并并保留 Excel 顺序。
- 导入事务提交后自动创建并启动只读匹配任务,不提供独立“匹配”按钮。再次导入同日数据只允许整体替换仍处于等待匹配的数据;存在匹配或回写证据时拒绝覆盖。
- 导入事务提交后自动创建并启动只读匹配任务,手动批量重匹配入口见 #350 的版本绑定规则。再次导入同日数据只允许整体替换仍处于等待匹配的数据;存在匹配或回写证据时拒绝覆盖。
- 匹配必须按精确订单号取得唯一货运单,再以规格、原始 SKU 和档口证据确定唯一商品;歧义、冲突或数量不一致均不猜测。匹配阶段不能调用任何 SYB 写接口。
- 回写前弹窗展示业务记录数、入库码总数、预计占位明细数和替换旧码数。只有 `ready` 记录可提交;所有服务实例共用数据库租约全局串行执行。
- 每个远端写动作只发送一次,动作前重读整张货运单并校验匹配计划未漂移;每件写入后重读确认目标码唯一位于预期明细。超时、5xx、响应无法确认或服务在写入期间重启时转为 `needs_check`,禁止自动重试;“只读复核”只能读取远端状态。
- 列表支持勾选后批量物理删除。删除必须再次确认;选中项包含 `queued`、`applying` 或 `needs_check` 时整批拒绝,不做部分删除。其余选中业务记录、逐件码、计划和终态执行证据在同一事务中物理删除;已写入 SYB 的远端值不会撤销。
- 列表支持勾选后批量物理删除。删除必须再次确认;选中项包含 `matching`、`queued`、`applying` 或 `needs_check` 时整批拒绝,不做部分删除。其余选中业务记录、逐件码、计划和终态执行证据在同一事务中物理删除;已写入 SYB 的远端值不会撤销。
## PDD 失效或售罄商品替换(#129)
@@ -722,3 +722,21 @@ SYB 回填与资料完整性独立:正式SYB任务已有单号且仍关联有
- 一次确认后按原匹配编号逐条取消。并发确认、已经取消等状态冲突跳过;其他错误保留失败。网络异常先回读原记录,不盲目重试,不追随后来重新匹配的新记录;登录失效停止剩余写入。
- 成功后重新计算采购准备状态,并按当前退货状态与同步可用性判断退货池资格,不保证所有商品立即可采购,不改变既有采购或采集任务。保持查询条件刷新;仅保留仍在当前页、仍为原匹配且可取消的失败项勾选,其余失败项保留在结果表。
- 原有单条确认及取消兼容:未携带状态前提的单条取消仍允许取消已确认匹配;本次不新增权限或迁移,不自动创建采购或执行付款。
## SYB 当前页批量使用退货(#349)
实现绑定 4280115(2026-09-29),尚未合并/发布,待验收。
- 管理员、采购员及售后沿用既有权限,工具栏为“匹配退货 → 取消匹配 → 使用退货”。仅当前页勾选且处理阶段 return_pending、原有效匹配 matched 的明细计入使用数量;已用退货与未匹配行不参与。
- 一次确认后逐条标记 confirmed / 已用退货,保留有效占用;不是 Yeeke 发货或重出,不修改已有采购/采集任务,不自动创建采购或支付,不扩大原候选资格。
- 只操作冻结的原 match ID。并发确认或取消冲突跳过,不追随后来的替代匹配。其他错误失败,各项不互相回滚;登录失效停止后续写入并明确未执行。
- 网络结果未知先回读原记录:已确认/已取消显示回读状态而不是本次成功;仍为原 matched 可人工重试;回读失败保留不确定结果,不自动重复提交。
- 完成保持筛选、页码和结果明细。成功项在退货待确认筛选下移出;仅当前页仍为原 matched 的可重试失败项恢复勾选,离页结果不丢失、不跨页操作。操作中互斥匹配、使用、取消及单条操作,无新增审批或权限。
## 档口入库码手动匹配与中断恢复(#350)
- 页面首次进入默认本地今天~今天;清除筛选明确取消日期限制。可勾选当前页 pending/failed/skipped 记录点击“匹配 N”;混选只提交合格项,跨营业日期提示分日期处理。加载/列表失败/提交/活动匹配期间禁用匹配。
- 手动匹配仍按原精确订单、规格、SKU/档口和数量证据,不可强行选近似商品。空候选明确 skipped;单条规划异常 failed 并继续后续记录;匹配成功仅 ready,不自动回写。
- 原 rematch 可接受同日 pending/failed/skipped;已有活动匹配任务占用或其他状态均拒绝;同 requestId 幂等重放,同记录排队为 matching。matching 期间不可删除,成功/回写/需复核记录不能重新匹配。
- 超时/中断以及启动恢复把未完成 pending/matching 改为明确失败并将旧批次结束;既有 ready/updated/回写证据和范围外记录不修改,用户可手动重试。恢复不调用 SYB 写接口,不自动回写、不删除成功结果。
实现绑定:`fix/350-inner-code-matching`,源码提交 `9c5e20e`;未合并 main、未部署,正式环境仍需发布后启用。
+19 -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: fff074b921368be646cda1bf0a434608fc5082c8
synchronized_at: 2026-09-29T01:36:20Z
wiki_revision: da26af9e01087c4970b01783a4807c689fd79d51
synchronized_at: 2026-09-29T03:47:57Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
@@ -1408,3 +1408,20 @@ GET /api/admin/v1/purchase-tasks 的既有 status 参数新增查询专用值 sy
携带前提时,服务端在锁定 `:id` 原记录的事务中检查状态仍为 `matched` 且有效占用指针存在,否则 HTTP 409 / `STATE_CONFLICT`,不取消已确认或已取消的匹配。不存在返回 HTTP 404 / `NOT_FOUND`。成功仍为 `{code:200,data:{item:ReturnMatch}}`,释放占用并记录 CancelledBy / CancelledAt 和审计日志。权限沿用 admin / purchaser / after_sales,没有新增批量接口或权限点。
Web 批量入口冻结原匹配 ID 并顺序调用;HTTP 409 状态冲突跳过,权限/网络错误失败,HTTP 401 或登录失效停止后续操作。写入结果未知时只允许只读回读原记录,不自动重发取消;不以 SYB ID 重新查询替代匹配并取消。
## Admin 批量使用退货复用确认接口(#349)
实现绑定 4280115(2026-09-29),未合并/发布;仅补充管理端调用规则,Android Agent 契约不变。
- Web 当前页批量使用复用 POST /api/admin/v1/return-matches/:id/confirm,无新增请求体、批量接口、状态或权限。Server 沿用锁定原记录后仅接受 matched;成功为 {code:200,data:{item:ReturnMatch}},写 confirmed、确认人/时间及审计,保留 active 占用。
- HTTP 409 / STATE_CONFLICT 跳过,HTTP 404 / NOT_FOUND 或其他处理错误明确失败。沿用 admin / purchaser / after_sales 权限;HTTP 401、业务认证错误及旧拦截器 false 结果均停止余项,不能误计为成功。
- 写结果不明确时仅 GET /api/admin/v1/return-matches/:id 回读冻结原记录,验证 match 身份;已 confirmed/cancelled 显示回读已处理,仍 matched 才允许手动重试。不得自动重发 POST 或改查新匹配后使用。
- 无数据库、Server 生产行为、Agent 协议或外部 Yeeke 写入变化。真实业务确认及部署另需授权。
## Admin 档口入库码 rematch 补充(#350,Agent 接口不变)
- `POST /api/admin/v1/syb-inner-codes/rematch` 保留 `{requestId, ids}` 与响应 `{matchJobId, queued}`;原权限与客户端密钥 match 功能权限不变,不新增权限项或迁移。
- 同日 pending/failed/skipped 可以排队;matching、ready、updated、already_filled、queued、applying、needs_check 和活动匹配占用拒绝 409。排队事务记录 matching,避免新 requestId 重叠提交;同 requestId 返回原结果。
- 任务正常结束沿用 succeeded + ready/failed 分项计数;批次中断/服务启动恢复为 failed 并提供安全说明与 finishedAt,未完成记录 failed 可重新匹配,已完成结果不回滚。匹配仅只读 SYB,成功需另行 apply 确认。
- batch-delete 在既有阻塞状态基础上增加 matching,409 整批拒绝;避免删除正在匹配的记录。Android/采购/付款流程无变化。
实现绑定:`fix/350-inner-code-matching`,源码提交 `9c5e20e`;未合并 main、未部署,正式环境仍需发布后启用。
+9 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Deployment-and-Operations
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-and-Operations.-
wiki_revision: 3a76e16d43c940425f68cc4748944213e980ad60
synchronized_at: 2026-09-28T08:10:00Z
wiki_revision: 6d3c599a45813ad844395e93a02832f0d28dd3f5
synchronized_at: 2026-09-29T03:47:47Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
@@ -239,3 +239,10 @@ Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、
- 线上服务由 185.216.248.75 迁移到 122.228.200.167(发布目录 `20260928-344-9dace6a`)。
- 迁移后 `http://122.228.200.167:9527/` 显示 go-admin 欢迎页:新机 9527 vhost 写成了全部 `proxy_pass`,缺少 `root /home/goauto/current/dist` 与 `location = /`。已按「Nginx 入口(9527)」标准配置修复(原文件备份为 `goauto-9527.conf.bak-20260928150822`),`nginx -t` 通过后 reload;按内容验收首页、前端路由、静态资源、验证码与未登录接口均通过。
- 代码侧根治见 #346:存在 dist 时服务端 `GET /` 也返回 SPA index.html,即使 Nginx 误配为全部转发也不再出现欢迎页。
## 档口入库码匹配中断排错与恢复(#350)
- 匹配后台总时限仍为 20 分钟,SYB 客户端单请求 60 秒;候选数组越界造成的进程退出不能通过增大时间解决。受控日志查看运行服务的 panic/退出/重启时间,结合匹配 processed/total 与记录状态判断中断;不导出原始生产记录。
- 修复版本启动时在接受新任务前将旧 pending/running 匹配批次终结,未完成 pending/matching 记录设为失败,已成功结果保留;仅状态恢复,不自动匹配或 SYB 回写。
- 旧版本出现崩溃现场,必须先确认原 worker 已退出、目标记录没有匹配计划和远端写执行证据,再按授权做精确条件事务收敛;禁止无条件整日重置或重试仍会崩溃的旧接口。修复上线后手动勾选失败项匹配,ready 后另行确认回写。
实现绑定:`fix/350-inner-code-matching`,源码提交 `9c5e20e`;未合并 main、未部署,正式环境仍需发布后启用。
@@ -0,0 +1,103 @@
package returnmatch
import (
"context"
"errors"
"fmt"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"go-admin/app/goauto/models"
"gorm.io/gorm"
)
// #349 reuses the existing single-record transaction; no production batch API.
func TestConfirmBatchExistingHTTPContract(t *testing.T) {
for _, tc := range []struct {
name, status, role string
code int
}{
{"admin", "matched", "admin", 200},
{"purchaser", "matched", "purchaser", 200},
{"after_sales", "matched", "after_sales", 200},
{"already_confirmed", "confirmed", "after_sales", 409},
{"cancelled", "cancelled", "purchaser", 409},
{"forbidden", "matched", "viewer", 403},
} {
t.Run(tc.name, func(t *testing.T) {
db := testDB(t)
m := seedCancelMatch(t, db, tc.status)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", fmt.Sprintf("/return-matches/%d/confirm", m.ID), nil)
c.Params = gin.Params{{Key: "id", Value: fmt.Sprint(m.ID)}}
c.Set("JWT_PAYLOAD", jwt.MapClaims{"rolekey": tc.role, "username": "confirm-tester"})
Handler{DB: db}.Confirm(c)
if w.Code != tc.code {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
var saved models.ReturnMatch
db.First(&saved, m.ID)
var logs int64
db.Model(&models.ReturnMatchLog{}).Where("match_id = ?", m.ID).Count(&logs)
if tc.code == 200 {
if saved.Status != "confirmed" || saved.ConfirmedBy != "confirm-tester" || saved.ConfirmedAt == nil || saved.ActiveSYBProductID == nil || saved.ActiveYeekeReturnItemID == nil || logs != 1 {
t.Fatalf("confirmation audit or occupancy lost: %+v logs=%d", saved, logs)
}
} else if saved.Status != tc.status || logs != 0 {
t.Fatalf("rejected confirm changed state: %+v logs=%d", saved, logs)
}
})
}
}
func TestConfirmBatchOriginalRecordStateChanges(t *testing.T) {
db := testDB(t)
s := NewService(db)
if _, err := s.Confirm(context.Background(), 99999, "tester"); err != gorm.ErrRecordNotFound {
t.Fatalf("missing=%v", err)
}
m := seedCancelMatch(t, db, "matched")
if _, err := s.Cancel(context.Background(), m.ID, "other"); err != nil {
t.Fatal(err)
}
// Simulate a later rematch with the same product/return, but a new identity.
replacement := m
replacement.ID = 0
replacement.CancelledAt = nil
replacement.CancelledBy = ""
if err := db.Create(&replacement).Error; err != nil {
t.Fatal(err)
}
if _, err := s.Confirm(context.Background(), m.ID, "batch"); err != errStateConflict {
t.Fatalf("stale original=%v", err)
}
if _, err := s.Confirm(context.Background(), replacement.ID, "reviewer"); err != nil {
t.Fatal(err)
}
if _, err := s.Confirm(context.Background(), replacement.ID, "repeat"); err != errStateConflict {
t.Fatalf("duplicate=%v", err)
}
}
func TestConfirmBatchAuditFailureRollsBack(t *testing.T) {
db := testDB(t)
m := seedCancelMatch(t, db, "matched")
if err := db.Callback().Create().Before("gorm:create").Register("test:reject_confirm_log", func(tx *gorm.DB) {
if tx.Statement.Schema != nil && tx.Statement.Schema.Name == "ReturnMatchLog" {
tx.AddError(errors.New("synthetic audit failure"))
}
}); err != nil {
t.Fatal(err)
}
if _, err := NewService(db).Confirm(context.Background(), m.ID, "tester"); err == nil {
t.Fatal("expected audit failure")
}
var saved models.ReturnMatch
db.First(&saved, m.ID)
if saved.Status != "matched" || saved.ConfirmedAt != nil || saved.ActiveSYBProductID == nil || saved.ActiveYeekeReturnItemID == nil {
t.Fatalf("confirm did not roll back: %+v", saved)
}
}
+74 -6
View File
@@ -17,6 +17,7 @@ import (
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type MatchReader interface {
@@ -45,12 +46,19 @@ func (m Matcher) runBackground(db *gorm.DB, jobID string) {
err = RunMatchJob(ctx, db, reader, jobID)
}
if err != nil {
now := time.Now().UTC()
_ = db.Model(&models.SYBInnerCodeMatchJob{}).Where("id = ? AND status IN ?", jobID, []string{"pending", "running"}).Updates(map[string]any{"status": "failed", "error_message": compact(err.Error(), 1000), "finished_at": now}).Error
_ = failMatchJob(db, jobID, "匹配任务中断,请重新匹配")
}
}
func RunMatchJob(ctx context.Context, db *gorm.DB, reader MatchReader, jobID string) error {
func RunMatchJob(ctx context.Context, db *gorm.DB, reader MatchReader, jobID string) (runErr error) {
claimedJob := false
defer func() {
if runErr != nil && claimedJob {
if err := failMatchJob(db, jobID, "匹配任务中断,请重新匹配"); err != nil {
runErr = errors.Join(runErr, err)
}
}
}()
now := time.Now().UTC()
claimed := db.WithContext(ctx).Model(&models.SYBInnerCodeMatchJob{}).Where("id = ? AND status = ?", jobID, "pending").Updates(map[string]any{"status": "running", "started_at": now})
if claimed.Error != nil {
@@ -66,6 +74,7 @@ func RunMatchJob(ctx context.Context, db *gorm.DB, reader MatchReader, jobID str
}
return conflict("匹配任务状态不允许执行")
}
claimedJob = true
var job models.SYBInnerCodeMatchJob
if err := db.WithContext(ctx).First(&job, "id = ?", jobID).Error; err != nil {
return err
@@ -75,7 +84,7 @@ func RunMatchJob(ctx context.Context, db *gorm.DB, reader MatchReader, jobID str
return fmt.Errorf("匹配任务记录范围无效")
}
var records []models.SYBInnerCodeRecord
if err := db.WithContext(ctx).Preload("Items", func(q *gorm.DB) *gorm.DB { return q.Order("ordinal ASC") }).Where("id IN ? AND business_date = ? AND status IN ?", recordIDs, job.BusinessDate, []string{models.SYBInnerCodePending, models.SYBInnerCodeFailed, models.SYBInnerCodeSkipped}).Order("source_row,id").Find(&records).Error; err != nil {
if err := db.WithContext(ctx).Preload("Items", func(q *gorm.DB) *gorm.DB { return q.Order("ordinal ASC") }).Where("id IN ? AND business_date = ? AND status IN ?", recordIDs, job.BusinessDate, []string{models.SYBInnerCodePending, models.SYBInnerCodeMatching, models.SYBInnerCodeFailed, models.SYBInnerCodeSkipped}).Order("source_row,id").Find(&records).Error; err != nil {
return err
}
used, err := loadReservedDetails(ctx, db, records)
@@ -84,7 +93,10 @@ func RunMatchJob(ctx context.Context, db *gorm.DB, reader MatchReader, jobID str
}
ready, failed := 0, 0
for _, record := range records {
plan, status, message, planErr := planRecord(ctx, reader, record, used)
if err := ctx.Err(); err != nil {
return err
}
plan, status, message, planErr := safePlanRecord(ctx, reader, record, used)
if planErr != nil {
status = models.SYBInnerCodeFailed
message = "读取 SYB 失败:" + compact(planErr.Error(), 900)
@@ -111,12 +123,62 @@ func RunMatchJob(ctx context.Context, db *gorm.DB, reader MatchReader, jobID str
} else {
failed++
}
db.Model(&models.SYBInnerCodeMatchJob{}).Where("id = ?", jobID).Updates(map[string]any{"processed": gorm.Expr("processed + 1"), "ready": ready, "failed": failed})
if err := db.WithContext(ctx).Model(&models.SYBInnerCodeMatchJob{}).Where("id = ?", jobID).Updates(map[string]any{"processed": gorm.Expr("processed + 1"), "ready": ready, "failed": failed}).Error; err != nil {
return err
}
}
finished := time.Now().UTC()
return db.WithContext(ctx).Model(&models.SYBInnerCodeMatchJob{}).Where("id = ? AND status = ?", jobID, "running").Updates(map[string]any{"status": "succeeded", "finished_at": finished, "ready": ready, "failed": failed}).Error
}
// Only the read/plan step is isolated: no remote write is retried here.
func safePlanRecord(ctx context.Context, reader MatchReader, record models.SYBInnerCodeRecord, used map[int64]bool) (plan *models.SYBInnerCodePlan, status, message string, err error) {
defer func() {
if recover() != nil {
plan, status, message = nil, models.SYBInnerCodeFailed, "匹配处理异常,请重新匹配"
err = nil
}
}()
return planRecord(ctx, reader, record, used)
}
// Cleanup must not inherit an expired job or HTTP request context.
func failMatchJob(db *gorm.DB, jobID, message string) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var job models.SYBInnerCodeMatchJob
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&job, "id = ?", jobID).Error; err != nil {
return err
}
if job.Status != "pending" && job.Status != "running" {
return nil
}
var ids []uint64
if err := json.Unmarshal([]byte(job.RecordIDsJSON), &ids); err != nil {
return err
}
if err := tx.Model(&models.SYBInnerCodeRecord{}).Where("id IN ? AND business_date = ? AND status IN ?", ids, job.BusinessDate, []string{models.SYBInnerCodePending, models.SYBInnerCodeMatching}).Updates(map[string]any{"status": models.SYBInnerCodeFailed, "result_message": message}).Error; err != nil {
return err
}
return tx.Model(&job).Updates(map[string]any{"status": "failed", "error_message": message, "finished_at": time.Now().UTC()}).Error
})
}
// Called at startup before new jobs can be submitted; never resumes remote writes.
func RecoverInterruptedMatches(db *gorm.DB) error {
var jobs []models.SYBInnerCodeMatchJob
if err := db.Where("status IN ?", []string{"pending", "running"}).Find(&jobs).Error; err != nil {
return err
}
for _, job := range jobs {
if err := failMatchJob(db, job.ID, "服务重启,匹配任务中断,请重新匹配"); err != nil {
return err
}
}
return nil
}
func loadReservedDetails(ctx context.Context, db *gorm.DB, selected []models.SYBInnerCodeRecord) (map[int64]bool, error) {
selectedIDs := map[uint64]bool{}
for _, r := range selected {
@@ -186,6 +248,9 @@ func planRecord(ctx context.Context, reader MatchReader, record models.SYBInnerC
if reason != "" {
return nil, models.SYBInnerCodeSkipped, reason, nil
}
if len(matches) == 0 {
return nil, models.SYBInnerCodeSkipped, "相同规格候选的原始 SKU 或档口货号未匹配", nil
}
count := len(record.Items)
if count == 0 {
return nil, models.SYBInnerCodeSkipped, "记录没有入库码", nil
@@ -213,6 +278,9 @@ func planRecord(ctx context.Context, reader MatchReader, record models.SYBInnerC
} else {
return nil, models.SYBInnerCodeSkipped, fmt.Sprintf("SYB 商品数量与入库码数量不一致(%d/%d)", matches[0].ProductQty, count), nil
}
if len(chosen) == 0 {
return nil, models.SYBInnerCodeSkipped, "未形成唯一的商品分配,不能自动选择", nil
}
primary := chosen[0]
items := make([]plannedRemoteItem, 0, count)
placeholder := 0
@@ -0,0 +1,181 @@
package sybinnercode
import (
"context"
"errors"
"testing"
"github.com/google/uuid"
"go-admin/app/goauto/models"
"go-admin/app/goauto/sybclient"
)
type interruptingReader struct {
*fakeMatchReader
panicOrder string
cancel context.CancelFunc
}
func (r interruptingReader) ListByOrderNumber(ctx context.Context, order string) ([]sybclient.StockRow, error) {
if order == r.panicOrder {
panic("must not escape or be persisted")
}
if r.cancel != nil {
r.cancel()
return nil, ctx.Err()
}
return r.fakeMatchReader.ListByOrderNumber(ctx, order)
}
func TestEmptyEvidenceDoesNotPanicAndNextRecordContinues(t *testing.T) {
db := testDB(t)
job := createMatchJob(t, db, []models.SYBInnerCodeRecord{matchRecord("BAD", "NO-SKU", "NO#9", "IC-1"), matchRecord("GOOD", "SKU-A", "A#1", "IC-2")})
r := &fakeMatchReader{rows: map[string][]sybclient.StockRow{"BAD": {{ID: 10}}, "GOOD": {{ID: 11}}}, stocks: map[int64]sybclient.StockDetail{10: {ID: 10, Details: []sybclient.DetailItem{detail(20, "黑色,L", 1, "SKU-A", "A#1", "")}}, 11: {ID: 11, Details: []sybclient.DetailItem{detail(21, "黑色,L", 1, "SKU-A", "A#1", "")}}}}
if err := RunMatchJob(context.Background(), db, r, job); err != nil {
t.Fatal(err)
}
var rows []models.SYBInnerCodeRecord
db.Order("id").Find(&rows)
if rows[0].Status != models.SYBInnerCodeSkipped || rows[1].Status != models.SYBInnerCodeReady {
t.Fatal("empty evidence must be limited, next row ready")
}
var plans int64
db.Model(&models.SYBInnerCodePlan{}).Count(&plans)
if plans != 1 {
t.Fatalf("unexpected plans=%d", plans)
}
var j models.SYBInnerCodeMatchJob
db.First(&j, "id = ?", job)
if j.Processed != 2 || j.Ready != 1 || j.Failed != 1 || j.Status != "succeeded" {
t.Fatalf("counts=%+v", j)
}
}
func TestSingleRecordPanicIsIsolated(t *testing.T) {
db := testDB(t)
job := createMatchJob(t, db, []models.SYBInnerCodeRecord{matchRecord("PANIC", "S", "A", "IC-1"), matchRecord("NEXT", "S", "A", "IC-2")})
r := interruptingReader{fakeMatchReader: &fakeMatchReader{rows: map[string][]sybclient.StockRow{}}, panicOrder: "PANIC"}
if err := RunMatchJob(context.Background(), db, r, job); err != nil {
t.Fatal(err)
}
var rows []models.SYBInnerCodeRecord
db.Order("id").Find(&rows)
if rows[0].Status != models.SYBInnerCodeFailed || rows[0].ResultMessage != "匹配处理异常,请重新匹配" || rows[1].Status != models.SYBInnerCodeFailed {
t.Fatal("panic was not safely persisted or next row not processed")
}
var j models.SYBInnerCodeMatchJob
db.First(&j, "id = ?", job)
if j.Processed != 2 {
t.Fatal("remaining row was not processed")
}
}
func TestCancelledMatchPersistsFailureWithIndependentContext(t *testing.T) {
db := testDB(t)
job := createMatchJob(t, db, []models.SYBInnerCodeRecord{matchRecord("A", "S", "A", "IC-1"), matchRecord("B", "S", "B", "IC-2")})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
err := RunMatchJob(ctx, db, interruptingReader{fakeMatchReader: &fakeMatchReader{}, cancel: cancel}, job)
if !errors.Is(err, context.Canceled) {
t.Fatalf("err=%v", err)
}
var rows []models.SYBInnerCodeRecord
db.Find(&rows)
for _, r := range rows {
if r.Status != models.SYBInnerCodeFailed {
t.Fatal("cancel left pending row")
}
}
var j models.SYBInnerCodeMatchJob
db.First(&j, "id = ?", job)
if j.Status != "failed" || j.FinishedAt == nil {
t.Fatal("cancel left running job")
}
}
func TestRestartRecoveryPreservesSuccessfulRecordsAndScope(t *testing.T) {
db := testDB(t)
job := createMatchJob(t, db, []models.SYBInnerCodeRecord{matchRecord("A", "S", "A", "IC-1"), matchRecord("B", "S", "B", "IC-2")})
db.Model(&models.SYBInnerCodeMatchJob{}).Where("id = ?", job).Update("status", "running")
db.Model(&models.SYBInnerCodeRecord{}).Where("order_number = ?", "A").Update("status", models.SYBInnerCodeUpdated)
outside := matchRecord("OUTSIDE", "S", "C", "IC-3")
db.Create(&outside)
if err := RecoverInterruptedMatches(db); err != nil {
t.Fatal(err)
}
if err := RecoverInterruptedMatches(db); err != nil {
t.Fatal(err)
}
var rows []models.SYBInnerCodeRecord
db.Order("id").Find(&rows)
if rows[0].Status != models.SYBInnerCodeUpdated || rows[1].Status != models.SYBInnerCodeFailed || rows[2].Status != models.SYBInnerCodePending {
t.Fatal("recovery altered success or outside scope")
}
var j models.SYBInnerCodeMatchJob
db.First(&j, "id = ?", job)
if j.Status != "failed" || j.FinishedAt == nil {
t.Fatal("recovery left running job")
}
}
func TestBatchRematchClaimsPendingFailedSkippedAndIsIdempotent(t *testing.T) {
db := testDB(t)
statuses := []string{models.SYBInnerCodePending, models.SYBInnerCodeFailed, models.SYBInnerCodeSkipped}
ids := []uint64{}
for i, status := range statuses {
r := matchRecord(string(rune('A'+i)), "S", "A", string(rune('X'+i)))
r.Status = status
if err := db.Create(&r).Error; err != nil {
t.Fatal(err)
}
ids = append(ids, r.ID)
}
s := NewService(db)
req := RematchRequest{RequestID: uuid.NewString(), IDs: ids}
result, err := s.QueueRematch(context.Background(), req)
if err != nil || result.Queued != 3 {
t.Fatalf("result=%+v err=%v", result, err)
}
replay, err := s.QueueRematch(context.Background(), req)
if err != nil || replay.MatchJobID != result.MatchJobID {
t.Fatal("idempotent replay failed")
}
if _, err := s.QueueRematch(context.Background(), RematchRequest{RequestID: uuid.NewString(), IDs: ids}); err == nil {
t.Fatal("overlapping job accepted")
}
var rows []models.SYBInnerCodeRecord
db.Find(&rows)
for _, r := range rows {
if r.Status != models.SYBInnerCodeMatching {
t.Fatal("not claimed")
}
}
if _, err := s.Delete(context.Background(), 1, DeleteRequest{RequestID: uuid.NewString(), IDs: ids}); err == nil {
t.Fatal("matching records can be deleted during execution")
}
if err := RunMatchJob(context.Background(), db, &fakeMatchReader{rows: map[string][]sybclient.StockRow{}}, result.MatchJobID); err != nil {
t.Fatal(err)
}
var job models.SYBInnerCodeMatchJob
db.First(&job, "id = ?", result.MatchJobID)
if job.Processed != 3 || job.Status != "succeeded" {
t.Fatal("claimed matching records were not executed")
}
}
func TestRematchRejectsPendingOwnedByImportAndProtectedStates(t *testing.T) {
db := testDB(t)
createMatchJob(t, db, []models.SYBInnerCodeRecord{matchRecord("A", "S", "A", "X")})
var r models.SYBInnerCodeRecord
db.First(&r)
s := NewService(db)
if _, err := s.QueueRematch(context.Background(), RematchRequest{RequestID: uuid.NewString(), IDs: []uint64{r.ID}}); err == nil {
t.Fatal("active import overlapped")
}
for _, status := range []string{models.SYBInnerCodeReady, models.SYBInnerCodeUpdated, models.SYBInnerCodeAlreadyFilled, models.SYBInnerCodeQueued, models.SYBInnerCodeApplying, models.SYBInnerCodeNeedsCheck} {
db.Model(&r).Update("status", status)
if _, err := s.QueueRematch(context.Background(), RematchRequest{RequestID: uuid.NewString(), IDs: []uint64{r.ID}}); err == nil {
t.Fatalf("protected status accepted: %s", status)
}
}
}
+24 -5
View File
@@ -200,8 +200,8 @@ func (s *Service) QueueRematch(ctx context.Context, request RematchRequest) (Rem
return conflict("部分记录不存在")
}
for _, record := range records {
if record.Status != models.SYBInnerCodeFailed && record.Status != models.SYBInnerCodeSkipped {
return conflict("只有读取失败或匹配受限记录可以重新匹配")
if record.Status != models.SYBInnerCodePending && record.Status != models.SYBInnerCodeFailed && record.Status != models.SYBInnerCodeSkipped {
return conflict("只有待匹配、读取失败或匹配受限记录可以重新匹配")
}
if date == "" {
date = record.BusinessDate
@@ -209,10 +209,29 @@ func (s *Service) QueueRematch(ctx context.Context, request RematchRequest) (Rem
return conflict("重新匹配记录必须属于同一营业日期")
}
}
var activeJobs []models.SYBInnerCodeMatchJob
if err := tx.Where("business_date = ? AND status IN ?", date, []string{"pending", "running"}).Find(&activeJobs).Error; err != nil {
return err
}
selected := make(map[uint64]bool, len(ids))
for _, id := range ids {
selected[id] = true
}
for _, job := range activeJobs {
var jobIDs []uint64
if err := json.Unmarshal([]byte(job.RecordIDsJSON), &jobIDs); err != nil {
return err
}
for _, id := range jobIDs {
if selected[id] {
return conflict("选中记录已有匹配任务,请等待任务结束")
}
}
}
if err := tx.Where("record_id IN ?", ids).Delete(&models.SYBInnerCodePlan{}).Error; err != nil {
return err
}
if err := tx.Model(&models.SYBInnerCodeRecord{}).Where("id IN ?", ids).Updates(map[string]any{"status": models.SYBInnerCodePending, "result_message": "等待重新匹配"}).Error; err != nil {
if err := tx.Model(&models.SYBInnerCodeRecord{}).Where("id IN ?", ids).Updates(map[string]any{"status": models.SYBInnerCodeMatching, "result_message": "等待重新匹配"}).Error; err != nil {
return err
}
recordIDsJSON, _ := json.Marshal(ids)
@@ -267,13 +286,13 @@ func (s *Service) Delete(ctx context.Context, actor uint64, request DeleteReques
return conflict("部分记录不存在,未删除任何数据")
}
for _, record := range records {
if record.Status == models.SYBInnerCodeQueued || record.Status == models.SYBInnerCodeApplying || record.Status == models.SYBInnerCodeNeedsCheck {
if record.Status == models.SYBInnerCodeMatching || record.Status == models.SYBInnerCodeQueued || record.Status == models.SYBInnerCodeApplying || record.Status == models.SYBInnerCodeNeedsCheck {
result.Blocked = append(result.Blocked, BlockedRecord{ID: record.ID, Status: record.Status})
}
}
if len(result.Blocked) > 0 {
sort.Slice(result.Blocked, func(i, j int) bool { return result.Blocked[i].ID < result.Blocked[j].ID })
return &ServiceError{Code: CodeConflict, Message: "选中记录包含排队中、回写中或需复核状态,未删除任何数据", Details: map[string]any{"blocked": result.Blocked}}
return &ServiceError{Code: CodeConflict, Message: "选中记录包含匹配中、排队中、回写中或需复核状态,未删除任何数据", Details: map[string]any{"blocked": result.Blocked}}
}
// The state gate above is the dynamic restriction for active writeback
// evidence. Terminal evidence belongs to imported data and is physically
+3
View File
@@ -117,6 +117,9 @@ func run() error {
if err := goautosybinnercode.RecoverInterrupted(db); err != nil {
return fmt.Errorf("recover interrupted SYB inner-code writes: %w", err)
}
if err := goautosybinnercode.RecoverInterruptedMatches(db); err != nil {
return fmt.Errorf("recover interrupted SYB inner-code matches: %w", err)
}
goautoreplacement.RecoverMatching(db)
goautopurchase.RecoverPurchaseMatching(db)
goautopurchase.RecoverOrderWritebacks(db)
+2 -2
View File
@@ -12,8 +12,8 @@ export function batchMatchReturns(data) {
return request({ url: '/api/admin/v1/return-matches/batch-match', method: 'post', data })
}
export function confirmReturnMatch(matchId) {
return request({ url: `/api/admin/v1/return-matches/${matchId}/confirm`, method: 'post' })
export function confirmReturnMatch(matchId, options = {}) {
return request({ url: `/api/admin/v1/return-matches/${matchId}/confirm`, method: 'post', ...options })
}
export function cancelReturnMatch(matchId, data, options = {}) {
+22 -6
View File
@@ -5,14 +5,15 @@
<div class="page-heading"><div><h1>档口入库码</h1><p>导入 Excel 后自动匹配 SYB 商品;确认后逐件回写,结果不明确时只读复核。</p></div></div>
<div class="toolbar" role="search" aria-label="档口入库码筛选与批量操作">
<el-upload ref="upload" :auto-upload="false" :limit="1" accept=".xlsx" :show-file-list="false" :on-change="selectFile" :on-exceed="replaceFile"><el-button :icon="FolderOpened">选择 Excel</el-button></el-upload>
<el-button type="primary" :icon="Upload" :loading="importing" :disabled="!selectedFile || importing" @click="submitImport">导入</el-button>
<el-button type="primary" :icon="Upload" :loading="importing" :disabled="!selectedFile || importing || matchingBusy" @click="submitImport">导入</el-button>
<el-date-picker v-model="dateRange" type="daterange" value-format="YYYY-MM-DD" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" class="date-range" />
<el-input v-model="query.keyword" clearable placeholder="订单号或入库码" class="keyword" @keyup.enter="search" />
<el-button :icon="RefreshLeft" @click="clearFilters">清除</el-button>
<el-button type="primary" :icon="Search" @click="search">搜索</el-button>
<span class="toolbar-spacer" />
<el-button :loading="matchSubmitting" :disabled="!matchEligibleCount || loading || !!loadError || importing || busy || matchingBusy || !!rowActionId" @click="matchSelected">匹配 {{ matchEligibleCount }}</el-button>
<el-button type="primary" :disabled="selected.length === 0 || busy" @click="openApply">回写({{ applyEligibleCount }})</el-button>
<el-button type="danger" plain :disabled="selected.length === 0 || busy" @click="openDelete">删除({{ deleteEligibleCount }})</el-button>
<el-button type="danger" plain :disabled="selected.length === 0 || busy || matchingBusy" @click="openDelete">删除({{ deleteEligibleCount }})</el-button>
</div>
<div class="file-state" aria-live="polite"><template v-if="selectedFile">已选择:<strong>{{ selectedFile.name }}</strong>({{ fileSize(selectedFile.size) }})</template><template v-else>请选择“标签入库码映射”Excel 文件,最大 10MB。</template></div>
@@ -29,7 +30,7 @@
<el-table-column label="本地规格" prop="specRaw" min-width="170" />
<el-table-column label="SYB 规格" min-width="170"><template #default="{ row }"><span v-if="row.plan">{{ row.plan.sybSpec || '已读取,规格为空' }}</span><span v-else class="muted">{{ sybSpecPlaceholder(row.status) }}</span></template></el-table-column>
<el-table-column label="状态" min-width="180"><template #default="{ row }"><el-tag :type="statusMeta(row.status).type">{{ statusMeta(row.status).label }}</el-tag><div v-if="row.resultMessage" class="status-note">{{ row.resultMessage }}</div></template></el-table-column>
<el-table-column label="操作" width="190" fixed="right"><template #default="{ row }"><el-button type="primary" link @click="openDetail(row.id)">详情</el-button><el-button v-if="['failed', 'skipped'].includes(row.status)" type="primary" link :loading="rowActionId === row.id" @click="rematch(row)">重新匹配</el-button><el-button v-if="row.status === 'needs_check'" type="warning" link :loading="rowActionId === row.id" @click="recheck(row)">只读复核</el-button></template></el-table-column>
<el-table-column label="操作" width="190" fixed="right"><template #default="{ row }"><el-button type="primary" link @click="openDetail(row.id)">详情</el-button><el-button v-if="['pending', 'failed', 'skipped'].includes(row.status)" type="primary" link :loading="rowActionId === row.id" :disabled="matchingBusy || busy || importing || loading || !!rowActionId" @click="rematch(row)">重新匹配</el-button><el-button v-if="row.status === 'needs_check'" type="warning" link :loading="rowActionId === row.id" @click="recheck(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]" :total="total" @pagination="load" />
</el-card>
@@ -66,10 +67,13 @@ import { createRequestId } from '@/utils/request-id'
export default {
name: 'GoAutoSybInnerCodes',
setup() { return { FolderOpened, RefreshLeft, Search, Upload } },
data() { return { loading: false, importing: false, busy: false, loadError: '', items: [], total: 0, selected: [], selectedFile: null, dateRange: null, rowActionId: 0, pollTimer: null, activeJob: null, activeBatch: null, query: { page: 1, pageSize: 100, dateFrom: '', dateTo: '', keyword: '' }, applyDialog: { open: false, loading: false, saving: false, preview: {}}, deleteDialog: { open: false, saving: false }, detail: { open: false, loading: false, item: null }} },
data() { const now = new Date(); const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; return { loading: false, importing: false, busy: false, matchSubmitting: false, loadError: '', items: [], total: 0, selected: [], selectedFile: null, dateRange: [today, today], rowActionId: 0, pollTimer: null, activeJob: null, activeBatch: null, query: { page: 1, pageSize: 100, dateFrom: today, dateTo: today, keyword: '' }, applyDialog: { open: false, loading: false, saving: false, preview: {}}, deleteDialog: { open: false, saving: false }, detail: { open: false, loading: false, item: null }} },
computed: {
matchingBusy() { return this.matchSubmitting || ['pending', 'running'].includes(this.activeJob?.status) },
matchEligibleRows() { const ids = new Set(this.selected.map(item => item.id)); return this.items.filter(item => ids.has(item.id) && ['pending', 'failed', 'skipped'].includes(item.status)) },
matchEligibleCount() { return this.matchEligibleRows.length },
applyEligibleCount() { return this.selected.filter(item => item.status === 'ready').length },
deleteBlocked() { return this.selected.filter(item => ['queued', 'applying', 'needs_check'].includes(item.status)) },
deleteBlocked() { return this.selected.filter(item => ['matching', 'queued', 'applying', 'needs_check'].includes(item.status)) },
deleteEligibleCount() { return this.deleteBlocked.length ? 0 : this.selected.length },
jobPercent() { return this.activeJob?.total ? Math.round(this.activeJob.processed * 100 / this.activeJob.total) : 0 },
jobTitle() { const job = this.activeJob; return job?.status === 'failed' ? '自动匹配失败' : `自动匹配:${job?.processed || 0}/${job?.total || 0},可回写 ${job?.ready || 0},异常 ${job?.failed || 0}` },
@@ -89,7 +93,19 @@ export default {
async confirmApply() { this.applyDialog.saving = true; this.busy = true; try { const r = await applySybInnerCodes({ requestId: createRequestId(), ids: this.selected.map(item => item.id) }); this.activeBatch = { id: r.data.batchId, status: 'queued', requested: r.data.queued, processed: 0 }; this.applyDialog.open = false; ElMessage.success('回写已提交,页面可以继续使用'); this.startPolling(); await this.load() } finally { this.applyDialog.saving = false; this.busy = false } },
openDelete() { this.deleteDialog.open = true }, async confirmDelete() { this.deleteDialog.saving = true; this.busy = true; try { const r = await deleteSybInnerCodes({ requestId: createRequestId(), ids: this.selected.map(item => item.id) }); ElMessage.success(`已物理删除 ${r.data.deleted} 条数据`); this.deleteDialog.open = false; await this.load() } finally { this.deleteDialog.saving = false; this.busy = false } },
async recheck(row) { this.rowActionId = row.id; try { await recheckSybInnerCode(row.id); ElMessage.success('只读复核完成'); await this.load() } finally { this.rowActionId = 0 } },
async rematch(row) { this.rowActionId = row.id; try { const r = await rematchSybInnerCodes({ requestId: createRequestId(), ids: [row.id] }); this.activeJob = { id: r.data.matchJobId, status: 'pending', total: 1, processed: 0, ready: 0, failed: 0 }; ElMessage.success('已开始重新匹配'); this.startPolling(); await this.load() } finally { this.rowActionId = 0 } },
async matchSelected() { await this.startMatch(this.matchEligibleRows) },
async rematch(row) { if (this.matchingBusy || this.rowActionId) return; this.rowActionId = row.id; try { await this.startMatch([row]) } finally { this.rowActionId = 0 } },
async startMatch(rows) {
if (!rows.length || this.matchingBusy || this.busy || this.importing || this.loading || this.loadError) return
if (new Set(rows.map(row => row.businessDate)).size !== 1) { ElMessage.warning('请按同一营业日期分别匹配'); return }
this.matchSubmitting = true
try {
const r = await rematchSybInnerCodes({ requestId: createRequestId(), ids: [...new Set(rows.map(row => row.id))] })
this.activeJob = { id: r.data.matchJobId, status: 'pending', total: r.data.queued, processed: 0, ready: 0, failed: 0 }
ElMessage.success(`已开始匹配 ${r.data.queued} 条记录;成功后请另行确认回写`)
this.startPolling(); await this.load()
} finally { this.matchSubmitting = false }
},
async openDetail(id) { this.detail = { open: true, loading: true, item: null }; try { const r = await getSybInnerCode(id); this.detail.item = r.data.item } finally { this.detail.loading = false } },
startPolling() { if (!this.pollTimer) this.pollTimer = window.setInterval(this.poll, 2000); this.poll() }, stopPolling() { if (this.pollTimer) window.clearInterval(this.pollTimer); this.pollTimer = null },
async poll() { let active = false; if (this.activeJob && ['pending', 'running'].includes(this.activeJob.status)) { const r = await getSybInnerCodeMatchJob(this.activeJob.id); this.activeJob = r.data.item; active = ['pending', 'running'].includes(this.activeJob.status) || active } if (this.activeBatch && ['queued', 'running'].includes(this.activeBatch.status)) { const r = await getSybInnerCodeApplyBatch(this.activeBatch.id); this.activeBatch = r.data.batch; active = ['queued', 'running'].includes(this.activeBatch.status) || active } await this.load(); if (!active) this.stopPolling() }
+28 -8
View File
@@ -13,7 +13,7 @@
<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>
<el-form-item label="处理阶段"><el-select v-model="query.processStage" clearable placeholder="全部" style="width: 132px"><el-option v-for="item in processStageOptions" :key="item.value" :label="item.label" :value="item.value" /></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 || 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-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>
</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>
@@ -72,7 +72,7 @@
<div class="quick-link-actions">
<el-button type="primary" link @click="openMatchDetail(returnMatchByProductId[row.id].id)">查看对比</el-button>
<el-button v-if="canPurchase" type="primary" link @click="openMatchDetail(returnMatchByProductId[row.id].id, true)">备注</el-button>
<el-button v-if="canPurchase" type="danger" link :disabled="returnCancelBatchLoading || returnTransitionLoading || returnMatchBatchLoading || matchDetail.saving" @click="quickCancelMatch(returnMatchByProductId[row.id].id)">取消匹配</el-button>
<el-button v-if="canPurchase" type="danger" link :disabled="returnConfirmBatchLoading || returnCancelBatchLoading || returnTransitionLoading || returnMatchBatchLoading || matchDetail.saving" @click="quickCancelMatch(returnMatchByProductId[row.id].id)">取消匹配</el-button>
</div>
</template>
<span v-else class="muted">未匹配</span>
@@ -209,6 +209,25 @@
<template #footer><el-button type="primary" :disabled="returnCancelBatchLoading" @click="returnCancelResult.open = false">返回列表</el-button></template>
</el-dialog>
<el-dialog v-model="returnConfirmResult.open" :title="returnConfirmBatchLoading ? '正在使用退货' : '批量使用退货结果'" width="820px" :close-on-click-modal="false" :close-on-press-escape="!returnConfirmBatchLoading" :show-close="!returnConfirmBatchLoading">
<div aria-live="polite" class="result-summary">
<span>已处理 {{ returnConfirmResult.processed }} / {{ returnConfirmResult.total }} 条</span>
<span class="success-text">成功 {{ returnConfirmResult.successCount }} 条</span>
<span>跳过 {{ returnConfirmResult.skippedCount }} 条</span>
<span class="danger-text">失败 {{ returnConfirmResult.failedCount }} 条</span>
</div>
<el-progress v-if="returnConfirmBatchLoading" :percentage="returnConfirmResult.total ? Math.round(returnConfirmResult.processed / returnConfirmResult.total * 100) : 0" />
<el-alert v-if="returnConfirmResult.refreshError" :title="returnConfirmResult.refreshError" type="warning" :closable="false" show-icon />
<p class="muted">仅标记已用退货,不修改已有采购任务,也不会自动让 Yeeke 发货。失败项仅在当前页仍为原待确认匹配时保留勾选;离页失败项仍保留在下表。</p>
<el-table :data="returnConfirmResult.items" border size="small" max-height="420" empty-text="正在处理,请稍候">
<el-table-column label="SYB 订单" prop="orderCode" min-width="180" />
<el-table-column label="原匹配编号" prop="matchId" width="100" />
<el-table-column label="结果" width="90"><template #default="{ row }">{{ { success: '成功', skipped: '跳过', failed: '失败' }[row.status] }}</template></el-table-column>
<el-table-column label="说明" prop="reason" min-width="280" />
</el-table>
<template #footer><el-button type="primary" :disabled="returnConfirmBatchLoading" @click="returnConfirmResult.open = false">返回列表</el-button></template>
</el-dialog>
<el-dialog v-model="returnMatchBatchResult.open" title="批量匹配退货结果" width="820px">
<div class="purchase-summary" aria-live="polite">
<span class="success-text">成功匹配 <strong>{{ returnMatchBatchResult.matchedCount }}</strong> 条</span>
@@ -266,7 +285,7 @@
<h3 class="section-title">备注</h3>
<el-input ref="matchRemarkInput" v-model="matchDetail.remarkText" type="textarea" :rows="2" maxlength="500" show-word-limit :disabled="!canPurchase" />
<div class="drawer-actions">
<el-button v-if="canPurchase" :loading="matchDetail.saving" @click="saveMatchRemark">保存备注</el-button>
<el-button v-if="canPurchase" :loading="matchDetail.saving" :disabled="returnConfirmBatchLoading || returnCancelBatchLoading || returnTransitionLoading || returnMatchBatchLoading" @click="saveMatchRemark">保存备注</el-button>
</div>
<h3 class="section-title">操作日志</h3>
<el-table :data="matchDetail.data.logs" border size="small" max-height="220" empty-text="暂无日志">
@@ -279,8 +298,8 @@
</div>
<template #footer>
<el-button @click="matchDetail.open = false">关闭</el-button>
<el-button v-if="canPurchase && matchDetail.data && matchDetail.data.match.status === 'matched'" type="success" :disabled="returnCancelBatchLoading || returnTransitionLoading || returnMatchBatchLoading || matchDetail.saving" @click="confirmMatchDetail">确认</el-button>
<el-button v-if="canPurchase && matchDetail.data && matchDetail.data.match.status !== 'cancelled'" type="danger" :disabled="returnCancelBatchLoading || returnTransitionLoading || returnMatchBatchLoading || matchDetail.saving" @click="cancelMatchDetail">取消匹配</el-button>
<el-button v-if="canPurchase && matchDetail.data && matchDetail.data.match.status === 'matched'" type="success" :disabled="returnConfirmBatchLoading || returnCancelBatchLoading || returnTransitionLoading || returnMatchBatchLoading || matchDetail.saving" @click="confirmMatchDetail">确认</el-button>
<el-button v-if="canPurchase && matchDetail.data && matchDetail.data.match.status !== 'cancelled'" type="danger" :disabled="returnConfirmBatchLoading || returnCancelBatchLoading || returnTransitionLoading || returnMatchBatchLoading || matchDetail.saving" @click="cancelMatchDetail">取消匹配</el-button>
</template>
</el-dialog>
@@ -344,6 +363,7 @@ import { ElMessage } from 'element-plus'
import { Search, RefreshLeft, ArrowDown } from '@element-plus/icons-vue'
import quickLink from './quick-link'
import returnCancel from './return-cancel'
import returnConfirm from './return-confirm'
import { listSybProducts, getSybProduct, reparseSybProduct, correctSybProduct } from '@/api/goauto/syb-products'
import { listSybShops } from '@/api/goauto/syb-shops'
import { listDevices } from '@/api/goauto/devices'
@@ -368,7 +388,7 @@ const RETURN_MATCH_BATCH_MAX = 500
export default {
name: 'GoAutoSybProducts',
components: { ShopeeProductDetailDrawer, PddProductDetailDrawer },
mixins: [quickLink, returnCancel],
mixins: [quickLink, returnCancel, returnConfirm],
setup() { return { Search, RefreshLeft, ArrowDown } },
data() {
return {
@@ -638,7 +658,7 @@ export default {
},
async runBatchMatchReturns() {
const ids = this.returnMatchCandidateIds
if (!ids.length || this.returnMatchBatchLoading || this.returnCancelBatchLoading || this.returnTransitionLoading || this.matchDetail.saving) return
if (!ids.length || this.returnMatchBatchLoading || this.returnConfirmBatchLoading || this.returnCancelBatchLoading || this.returnTransitionLoading || this.matchDetail.saving) return
this.returnMatchBatchLoading = true
try {
const r = await batchMatchReturns({ sybProductIds: ids })
@@ -673,7 +693,7 @@ export default {
await this.load()
},
async saveMatchRemark() {
if (this.returnCancelBatchLoading || this.returnTransitionLoading || this.returnMatchBatchLoading || this.matchDetail.saving) return
if (this.returnConfirmBatchLoading || this.returnCancelBatchLoading || this.returnTransitionLoading || this.returnMatchBatchLoading || this.matchDetail.saving) return
this.matchDetail.saving = true
try {
await remarkReturnMatch(this.matchDetail.id, { remark: this.matchDetail.remarkText })
@@ -18,7 +18,7 @@ export default {
returnCancelButtonReason() {
if (this.loading || this.purchaseReadinessLoading || this.returnMatchLoading) return '正在读取处理阶段与退货匹配'
if (this.returnMatchLoadError) return '退货匹配读取失败,请刷新重试'
if (this.returnCancelBatchLoading || this.returnMatchBatchLoading || this.returnTransitionLoading || this.matchDetail.saving) return '正在处理退货匹配,请稍候'
if (this.returnConfirmBatchLoading || this.returnCancelBatchLoading || this.returnMatchBatchLoading || this.returnTransitionLoading || this.matchDetail.saving) return '正在处理退货匹配,请稍候'
if (!this.returnCancelCandidates.length) return '请勾选退货待确认的已匹配商品;已用退货不参与批量取消'
return ''
}
@@ -30,7 +30,7 @@ export default {
return this.purchaseReady(row).processStage === 'return_pending' && match?.status === 'matched' && Number(match.id) > 0
},
async runReturnTransition(action) {
if (this.returnCancelBatchLoading || this.returnMatchBatchLoading || this.returnTransitionLoading || this.matchDetail.saving) return
if (this.returnConfirmBatchLoading || this.returnCancelBatchLoading || this.returnMatchBatchLoading || this.returnTransitionLoading || this.matchDetail.saving) return
this.returnTransitionLoading = true
try { await action() } finally { this.returnTransitionLoading = false }
},
@@ -0,0 +1,102 @@
import { confirmReturnMatch, getReturnMatch } from '@/api/goauto/return-matches'
const requestOptions = { suppressErrorMessage: true, suppressNetworkError: true }
function isUnauthorized(error) {
return error?.response?.status === 401 || error?.response?.data?.code === 401 || error?.message === 'Unauthorized'
}
const emptyResult = () => ({ open: false, items: [], total: 0, processed: 0, successCount: 0, skippedCount: 0, failedCount: 0, refreshError: '' })
export default {
data() {
return { returnConfirmBatchLoading: false, returnConfirmStopped: false, returnConfirmResult: emptyResult() }
},
computed: {
returnConfirmCandidates() { return this.returnConfirmSnapshot().filter(item => item.eligible) },
returnConfirmButtonReason() {
if (this.loading || this.purchaseReadinessLoading || this.returnMatchLoading) return '正在读取处理阶段与退货匹配'
if (this.returnMatchLoadError) return '退货匹配读取失败,请刷新重试'
if (this.returnConfirmBatchLoading || this.returnCancelBatchLoading || this.returnMatchBatchLoading || this.returnTransitionLoading || this.matchDetail.saving) return '正在处理退货匹配,请稍候'
if (!this.returnConfirmCandidates.length) return '请勾选当前页退货待确认的已匹配商品;已用退货不参与批量使用'
return ''
}
},
beforeUnmount() { this.returnConfirmStopped = true },
methods: {
returnConfirmSnapshot() {
const current = new Set(this.products.map(row => row.id))
const seen = new Set()
return this.selectedProducts.filter(row => {
if (!current.has(row.id) || seen.has(row.id)) return false
seen.add(row.id); return true
}).map(row => ({ sybProductId: row.id, orderCode: row.orderCode, matchId: this.returnMatchByProductId[row.id]?.id, eligible: this.isReturnCancelCandidate(row) }))
},
async runBatchConfirmReturns() {
if (!this.canPurchase || this.returnConfirmButtonReason) return
// Freeze original identities before asking. Never follow a later replacement match.
const snapshot = this.returnConfirmSnapshot()
const count = snapshot.filter(item => item.eligible).length
this.returnConfirmBatchLoading = true
try {
try {
await this.$confirm(`确认使用选中的 ${count} 条退货商品?确认后将标记为已用退货,不再创建采购任务。不修改已有采购任务,也不会自动让 Yeeke 发货。`, '使用退货', { type: 'warning', confirmButtonText: '确认使用', cancelButtonText: '返回' })
} catch { return }
if (this.returnConfirmStopped) return
this.returnConfirmResult = { ...emptyResult(), open: true, total: snapshot.length }
const result = this.returnConfirmResult
let authExpired = false
for (const original of snapshot) {
const item = { ...original, status: 'skipped', reason: '', retryable: false }
if (!original.eligible) item.reason = '非退货待确认的原匹配,跳过'
else if (authExpired || this.returnConfirmStopped) { item.status = 'failed'; item.reason = '登录失效或已离开页面,未继续执行' } else {
try {
const response = await confirmReturnMatch(original.matchId, requestOptions)
if (response === false) throw new Error('Unauthorized')
item.status = 'success'; item.reason = '已用退货;不修改已有采购任务,不自动发货'
} catch (error) {
if (isUnauthorized(error)) {
authExpired = true; item.status = 'failed'; item.reason = '登录失效,请重新登录后检查原匹配'
} else if (error?.response?.status === 409 && error?.response?.data?.code === 'STATE_CONFLICT') {
item.reason = '原匹配已确认或已取消,未重复使用'
} else {
item.status = 'failed'
item.reason = error?.response ? '使用失败,请刷新检查原匹配' : '网络异常,使用结果未确认'
// Unknown write outcomes are recovered by reading only, not by repeating POST.
try {
const response = await getReturnMatch(original.matchId, requestOptions)
if (response === false) throw new Error('Unauthorized')
const match = response.data?.match
const sameOriginal = Number(match?.id) === Number(original.matchId) && Number(match?.sybProductId) === Number(original.sybProductId)
if (sameOriginal && ['confirmed', 'cancelled'].includes(match.status)) {
item.status = 'skipped'; item.reason = `回读原匹配已${match.status === 'confirmed' ? '确认使用' : '取消'},未重复操作`
} else {
item.retryable = sameOriginal && match.status === 'matched'
item.reason += item.retryable ? ';原匹配仍待确认,可手动重试' : ';原匹配无法确认,请刷新检查'
}
} catch (readError) {
if (isUnauthorized(readError)) authExpired = true
item.reason += ';回读失败,请刷新检查,不自动重试'
}
}
}
}
result.items.push(item); result.processed++
result[`${item.status}Count`]++
}
if (this.returnConfirmStopped || authExpired) return
const generation = this.loadGeneration + 1
try {
await this.load(false, true)
if (generation !== this.loadGeneration || this.returnConfirmStopped) return
if (this.returnMatchLoadError || Object.values(this.purchaseReadiness).some(ready => ready.processNextAction === 'refresh')) {
result.refreshError = '刷新处理阶段或退货匹配失败,请刷新列表后重试'; return
}
const failed = new Map(result.items.filter(item => item.status === 'failed' && item.retryable).map(item => [item.sybProductId, item.matchId]))
await this.$nextTick()
if (generation !== this.loadGeneration || this.returnConfirmStopped) return
this.$refs.productTable?.clearSelection()
this.products.filter(row => failed.get(row.id) === this.returnMatchByProductId[row.id]?.id && this.isReturnCancelCandidate(row)).forEach(row => this.$refs.productTable?.toggleRowSelection(row, true))
} catch { result.refreshError = '列表刷新失败;执行结果已保留,请手动刷新,勿盲目重复使用' }
} finally { this.returnConfirmBatchLoading = false }
}
}
}
@@ -0,0 +1,73 @@
import { expect, test, type Page, type BrowserContext } from '@playwright/test'
async function setup(page: Page, context: BrowserContext, mode = 'success') {
await page.setViewportSize({ width: 1920, height: 1080 })
await context.addCookies([{ name: 'Admin-Token', value: 'isolated-test-token', domain: 'localhost', path: '/' }])
const lists: URL[] = []; const writes: number[][] = []; const otherWrites: string[] = []
let submitted = false
await page.route('**/api/**', async route => {
const url = new URL(route.request().url()); const pathname = url.pathname
if (pathname.startsWith('/src/api/')) return route.continue()
const reply = (data: unknown) => route.fulfill({ json: { code: 200, data } })
if (pathname.endsWith('/api/v1/menurole')) return reply([{ path: '/workbench', component: 'Layout', menuName: 'Workbench', title: '工作台', visible: '0', children: [{ path: '/syb-inner-codes', component: '/goauto/syb-inner-codes/index', menuName: 'GoAutoSybInnerCodes', title: '档口入库码', visible: '0' }] }])
if (pathname.endsWith('/api/v1/getinfo')) return reply({ roles: ['purchaser'], name: '模拟采购员', avatar: '', permissions: [] })
if (pathname.endsWith('/syb-inner-codes') && route.request().method() === 'GET') {
lists.push(url)
if (mode === 'list-error') return route.fulfill({ status: 500, json: { code: 'INTERNAL', message: '模拟加载失败' } })
const statuses = ['pending', 'failed', 'skipped', 'updated']
return reply({ items: statuses.map((status, i) => ({ id: i + 1, businessDate: mode === 'mixed-dates' && i === 1 ? '2026-09-28' : '2026-09-29', orderNumber: `MOCK-${i + 1}`, status: submitted && i < 3 ? 'matching' : status, items: [{ code: `MOCK-CODE-${i}`, ordinal: 1 }], specRaw: '模拟规格' })), total: 4 })
}
if (pathname.endsWith('/rematch')) {
writes.push(route.request().postDataJSON().ids)
if (mode === 'submit-error') return route.fulfill({ status: 409, json: { code: 'CONFLICT', message: '模拟状态已变化' } })
submitted = true
return reply({ matchJobId: 'isolated-job', queued: writes.at(-1)!.length })
}
if (pathname.includes('/match-jobs/')) return reply({ item: { id: 'isolated-job', status: 'running', total: 3, processed: 1, ready: 1, failed: 0 } })
if (route.request().method() !== 'GET') otherWrites.push(pathname)
return reply({ items: [], total: 0 })
})
await page.goto('/#/syb-inner-codes')
if (mode !== 'list-error') await expect(page.getByRole('cell', { name: 'MOCK-1', exact: true })).toBeVisible()
return { lists, writes, otherWrites }
}
test('today default, mixed selection only matches eligible rows and never writes SYB', async ({ page, context }) => {
const { lists, writes, otherWrites } = await setup(page, context)
const today = await page.evaluate(() => { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}` })
expect(lists[0].searchParams.get('dateFrom')).toBe(today); expect(lists[0].searchParams.get('dateTo')).toBe(today)
await expect(page.getByRole('button', { name: '匹配 0', exact: true })).toBeDisabled()
await page.locator('.el-table__header-wrapper .el-checkbox').first().click()
await page.getByRole('button', { name: '匹配 3', exact: true }).click()
await expect.poll(() => writes.length).toBe(1); expect(writes[0]).toEqual([1, 2, 3])
await expect(page.getByText('自动匹配:1/3,可回写 1,异常 0')).toBeVisible()
await expect(page.getByRole('button', { name: /^匹配 \d+$/ })).toBeDisabled()
expect(otherWrites).toEqual([])
})
test('explicitly clear filters removes date range', async ({ page, context }) => {
const { lists } = await setup(page, context)
await page.getByRole('button', { name: '清除', exact: true }).click()
await expect.poll(() => lists.length).toBeGreaterThan(1)
expect(lists.at(-1)!.searchParams.get('dateFrom')).toBe('')
})
test('mixed dates warn without submitting', async ({ page, context }) => {
const { writes } = await setup(page, context, 'mixed-dates')
await page.locator('.el-table__header-wrapper .el-checkbox').first().click()
await page.getByRole('button', { name: '匹配 3', exact: true }).click()
await expect(page.getByText('请按同一营业日期分别匹配')).toBeVisible(); expect(writes).toEqual([])
})
test('submission failure keeps retry available without automatic retries', async ({ page, context }) => {
const { writes } = await setup(page, context, 'submit-error')
await page.locator('.el-table__header-wrapper .el-checkbox').first().click()
await page.getByRole('button', { name: '匹配 3', exact: true }).click()
await expect(page.getByRole('button', { name: '匹配 3', exact: true })).toBeEnabled()
expect(writes.length).toBe(1)
})
test('list failure disables matching', async ({ page, context }) => {
await setup(page, context, 'list-error')
await expect(page.getByRole('button', { name: '匹配 0', exact: true })).toBeDisabled()
})
+117
View File
@@ -0,0 +1,117 @@
import { expect, test, type Page, type BrowserContext } from '@playwright/test'
const menus = [{ path: '/workbench', component: 'Layout', menuName: 'Workbench', title: '工作台', visible: '0', children: [{ path: '/syb-products', component: '/goauto/syb-products/index', menuName: 'GoAutoSybProducts', title: 'SYB 商品', visible: '0' }] }]
async function setup(page: Page, context: BrowserContext, mode = 'success', role = 'after_sales') {
await page.setViewportSize({ width: 1920, height: 1080 })
await context.addCookies([{ name: 'Admin-Token', value: 'isolated-test-token', domain: 'localhost', path: '/' }])
const writes: number[] = []
const confirmed = new Set<number>()
const lists: URL[] = []
await page.route('**/api/**', async route => {
const url = new URL(route.request().url()); const pathname = url.pathname
if (pathname.startsWith('/src/api/')) return route.continue()
const reply = (data: unknown) => route.fulfill({ json: { code: 200, data } })
if (pathname.endsWith('/api/v1/menurole')) return reply(menus)
if (pathname.endsWith('/api/v1/getinfo')) return reply({ roles: [role], name: '模拟售后', avatar: '', permissions: [] })
if (pathname.endsWith('/api/admin/v1/syb-products')) {
lists.push(url)
const items = [1, 2, 3, 4].filter(id => url.searchParams.get('processStage') !== 'return_pending' || (id <= 2 && !confirmed.has(id))).map(id => ({ id, orderCode: `MOCK-${id}`, shopeeItemId: String(100 + id), shopeeProductId: 100 + id, imageUrl: '', productTitle: '模拟商品', quantity: 1, parseStatus: 'success' }))
return reply({ items, total: items.length })
}
if (pathname.endsWith('/purchase-tasks/batch-preview')) return reply({ items: [1, 2, 3, 4].map(id => ({ sybProductId: id, processStage: confirmed.has(id) ? 'return_used' : id <= 2 ? 'return_pending' : id === 3 ? 'return_used' : 'purchase_ready', processStageLabel: confirmed.has(id) ? '已用退货' : id <= 2 ? '退货待确认' : id === 3 ? '已用退货' : '可创建采购', eligible: id === 4, collectionEligible: false, aiMatchEligible: false })) })
if (pathname.endsWith('/return-matches')) {
if (mode === 'metadata-error') return route.fulfill({ status: 500, json: { code: 'INTERNAL' } })
return reply({ items: [1, 2, 3].map(id => ({ id: id + 10, sybProductId: id, status: id <= 2 && !confirmed.has(id) ? 'matched' : 'confirmed', variationName: '模拟规格' })) })
}
if (/\/return-matches\/\d+\/confirm$/.test(pathname)) {
const id = Number(pathname.split('/').at(-2)); writes.push(id)
expect(route.request().method()).toBe('POST')
if (mode === 'readback-confirmed') return route.fulfill({ status: 504, json: { code: 'TIMEOUT' } })
if (mode === 'partial' && id === 12) return route.fulfill({ status: 500, json: { code: 'INTERNAL', message: '模拟使用失败' } })
if (mode === 'auth') return route.fulfill({ status: 401, json: { code: 401, message: '模拟登录失效' } })
if (mode === 'conflict' && id === 12) return route.fulfill({ status: 409, json: { code: 'STATE_CONFLICT' } })
await new Promise(resolve => setTimeout(resolve, 150))
confirmed.add(id - 10); return reply({ item: { id, status: 'confirmed' } })
}
if (/\/return-matches\/\d+$/.test(pathname)) {
const id = Number(pathname.split('/').at(-1)); return reply({ match: { id, sybProductId: id - 10, status: mode === 'readback-confirmed' || confirmed.has(id - 10) ? 'confirmed' : 'matched' }, logs: [] })
}
return reply({ items: [], total: 0 })
})
await page.goto('/#/syb-products')
await expect(page.getByRole('cell', { name: 'MOCK-1', exact: true })).toBeVisible()
return { writes, lists }
}
test('v2 button order, one confirmation, no write on return, progress and mutual exclusion', async ({ page, context }) => {
const { writes } = await setup(page, context)
const use = page.getByRole('button', { name: /使用退货\s*0/ })
await expect(use).toBeDisabled()
const toolbar = await page.locator('.toolbar-actions button').allTextContents()
expect(toolbar.findIndex(t => t.includes('匹配退货'))).toBeLessThan(toolbar.findIndex(t => t.includes('取消匹配')))
expect(toolbar.findIndex(t => t.includes('取消匹配'))).toBeLessThan(toolbar.findIndex(t => t.includes('使用退货')))
await page.locator('thead .el-checkbox').first().click()
await page.getByRole('button', { name: /使用退货\s*2/ }).click()
await expect(page.getByText(/不会自动让 Yeeke 发货/, { exact: false }).last()).toBeVisible()
await page.getByRole('button', { name: '返回', exact: true }).click()
expect(writes).toEqual([])
await page.getByRole('button', { name: /使用退货\s*2/ }).click()
await page.getByRole('button', { name: '确认使用', exact: true }).click()
await expect(page.getByRole('button', { name: '返回列表', exact: true })).toBeDisabled()
await expect(page.getByRole('button', { name: /取消匹配\s*2/ }).first()).toBeDisabled()
await expect(page.getByRole('button', { name: '返回列表', exact: true })).toBeEnabled()
expect(writes).toEqual([11, 12])
await expect(page.getByText('成功 2 条', { exact: true })).toBeVisible()
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()
})
for (const mode of ['partial', 'conflict', 'auth', 'readback-confirmed']) {
test(`${mode}: independent outcomes, failed selection, auth stop and readback without retry`, async ({ page, context }) => {
const { writes } = await setup(page, context, mode)
await page.locator('thead .el-checkbox').first().click()
await page.getByRole('button', { name: /使用退货\s*2/ }).click()
await page.getByRole('button', { name: '确认使用', exact: true }).click()
await expect(page.getByRole('button', { name: '返回列表', exact: true })).toBeEnabled()
expect(writes).toEqual(mode === 'auth' ? [11] : [11, 12])
const summary = mode === 'auth' ? '失败 2 条' : mode === 'partial' ? '失败 1 条' : mode === 'conflict' ? '跳过 2 条' : '跳过 3 条'
await expect(page.getByText(summary, { exact: true })).toBeVisible()
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.getByRole('button', { name: /使用退货\s*1/ })).toBeEnabled()
}
})
}
test('metadata load error disables use', async ({ page, context }) => {
await setup(page, context, 'metadata-error')
await page.locator('thead .el-checkbox').first().click()
await expect(page.getByRole('button', { name: /使用退货\s*0/ })).toBeDisabled()
})
test('viewer cannot see use button or selection', async ({ page, context }) => {
await setup(page, context, 'success', 'viewer')
await expect(page.getByRole('button', { name: /使用退货/ })).toHaveCount(0)
await expect(page.locator('thead .el-checkbox')).toHaveCount(0)
})
test('pending filter and page size retained; used rows disappear', async ({ page, context }) => {
const { lists } = await setup(page, context)
await page.locator('.el-form-item').filter({ hasText: '处理阶段' }).locator('.el-select__wrapper').click()
await page.getByRole('option', { name: '退货待确认', exact: true }).click()
await page.getByRole('button', { name: '查询', exact: true }).click()
await expect(page.getByRole('cell', { name: 'MOCK-4', exact: true })).toHaveCount(0)
await page.locator('thead .el-checkbox').first().click()
await page.getByRole('button', { name: /使用退货\s*2/ }).click()
await page.getByRole('button', { name: '确认使用', exact: true }).click()
await expect(page.getByRole('button', { name: '返回列表', exact: true })).toBeEnabled()
expect(lists.at(-1)?.searchParams.get('processStage')).toBe('return_pending')
expect(lists.at(-1)?.searchParams.get('pageSize')).toBe('200')
await page.getByRole('button', { name: '返回列表', exact: true }).click()
await expect(page.getByRole('dialog', { name: '批量使用退货结果', exact: true })).toBeHidden()
await expect(page.getByText('暂无 SYB 商品明细', { exact: true })).toBeVisible()
})
@@ -0,0 +1,38 @@
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/views/goauto/syb-inner-codes/index.vue'), 'utf8').split('<script>')[1].split('</script>')[0].replace(/^import .*$/gm, '').replace('export default', 'return')
function instance(write = async () => ({ data: { matchJobId: 'test-job', queued: 2 } })) {
const writes = []; const messages = []
const options = new Function('rematchSybInnerCodes', 'createRequestId', 'ElMessage', source)(async body => { writes.push(body); return write(body) }, () => 'isolated-id', { success: m => messages.push(m), warning: m => messages.push(m) })
const vm = { ...options.data(), ...options.methods, load: async () => {}, startPolling: () => {} }
for (const [key, get] of Object.entries(options.computed)) Object.defineProperty(vm, key, { get: () => get.call(vm) })
vm.items = ['pending', 'failed', 'skipped', 'updated', 'ready', 'needs_check', 'matching'].map((status, i) => ({ id: i + 1, status, businessDate: '2026-09-29' }))
return { vm, writes, messages }
}
test('initial range and request use local today, clear explicitly removes it', () => {
const { vm } = instance(); const now = new Date(); const today = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}`
assert.deepEqual(vm.dateRange, [today, today]); assert.equal(vm.query.dateFrom, today); assert.equal(vm.query.dateTo, today)
vm.clearFilters(); assert.equal(vm.dateRange, null); assert.equal(vm.query.dateFrom, '')
})
test('mixed selection submits only current page eligible rows, does not auto apply', async () => {
const { vm, writes } = instance(); vm.selected = [...vm.items, { id: 999, status: 'failed' }]
assert.equal(vm.matchEligibleCount, 3); await vm.matchSelected(); assert.deepEqual(writes[0].ids, [1, 2, 3]); assert.equal(writes.length, 1); assert.equal(vm.activeJob.id, 'test-job'); assert.equal(vm.activeBatch, null)
await vm.matchSelected(); assert.equal(writes.length, 1)
})
test('busy, loading, error and empty selection do not submit', async () => {
for (const field of ['busy', 'importing', 'loading', 'loadError']) {
const { vm, writes } = instance(); vm.selected = [vm.items[0]]; vm[field] = field === 'loadError' ? 'error' : true
await vm.matchSelected(); assert.equal(writes.length, 0)
}
const { vm, writes } = instance(); await vm.matchSelected(); assert.equal(writes.length, 0)
})
test('mixed dates warn without writes', async () => {
const { vm, writes, messages } = instance(); vm.items[1].businessDate = '2026-09-28'; vm.selected = vm.items.slice(0, 2)
await vm.matchSelected(); assert.equal(writes.length, 0); assert.equal(messages.length, 1)
})
test('submission error retains selection and releases busy flag', async () => {
const { vm } = instance(async () => { throw new Error('test-only error') }); vm.selected = [vm.items[0]]
await assert.rejects(vm.matchSelected()); assert.equal(vm.matchSubmitting, false); assert.equal(vm.selected.length, 1); assert.equal(vm.activeJob, null)
})
+1 -1
View File
@@ -100,7 +100,7 @@ test('legacy interceptor false/6401 result is auth failure, not cancellation suc
test('pending rows can be selected for cancel but never become collection, purchase, image-search or AI candidates', () => {
const script = fs.readFileSync(path.join(__dirname, '../../src/views/goauto/syb-products/index.vue'), 'utf8').split('<script>')[1].split('</script>')[0].replace(/import[^\n]+\n/g, '').replace('export default', 'return')
const options = new Function('quickLink', 'returnCancel', 'ShopeeProductDetailDrawer', 'PddProductDetailDrawer', script)({}, {}, {}, {})
const options = new Function('quickLink', 'returnCancel', 'returnConfirm', 'ShopeeProductDetailDrawer', 'PddProductDetailDrawer', script)({}, {}, {}, {}, {})
const row = { id: 1, shopeeProductId: 11, imageUrl: 'mock-image' }
const ready = { processStage: 'return_pending', eligible: true, collectionEligible: true, aiMatchEligible: true, pddProductId: 9 }
const vm = { ...options.methods, selectedProducts: [row], purchaseReadiness: { 1: ready }, canPurchase: true }
+112
View File
@@ -0,0 +1,112 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const readOptions = (file, dependencies) => new Function(...Object.keys(dependencies), fs.readFileSync(path.join(__dirname, '../../src/views/goauto/syb-products', file), 'utf8').replace(/import[^\n]+\n/g, '').replace('export default', 'return'))(...Object.values(dependencies))
const tick = () => new Promise(resolve => setImmediate(resolve))
function instance(overrides = {}) {
const writes = []; const reads = []; const selected = []; const confirmations = []
const dependencies = {
confirmReturnMatch: async (id, options) => { writes.push({ id, options }); return { code: 200 } },
getReturnMatch: async id => { reads.push(id); return { data: { match: { id, sybProductId: id - 10, status: 'matched' } } } }, ...overrides
}
const options = readOptions('return-confirm.js', dependencies)
const cancel = readOptions('return-cancel.js', { cancelReturnMatch: async () => {}, getReturnMatch: dependencies.getReturnMatch })
const vm = { ...cancel.methods, ...options.methods, ...cancel.data(), ...options.data(), canPurchase: true, loading: false, returnMatchLoading: false, purchaseReadinessLoading: false, matchDetail: {}, loadGeneration: 0,
products: [1, 2, 3].map(id => ({ id, orderCode: `TEST-${id}` })),
purchaseReadiness: { 1: { processStage: 'return_pending' }, 2: { processStage: 'return_pending' }, 3: { processStage: 'return_used' } },
returnMatchByProductId: { 1: { id: 11, status: 'matched' }, 2: { id: 12, status: 'matched' }, 3: { id: 13, status: 'confirmed' } },
purchaseReady(row) { return this.purchaseReadiness[row.id] || {} },
$confirm: async text => { confirmations.push(text) }, $nextTick: async () => {},
$refs: { productTable: { clearSelection: () => selected.splice(0), toggleRowSelection: row => selected.push(row.id) } },
load: async function() { this.loadGeneration++ }
}
vm.selectedProducts = [...vm.products]
Object.entries({ ...cancel.computed, ...options.computed }).forEach(([key, fn]) => Object.defineProperty(vm, key, { get: () => fn.call(vm) }))
return { vm, options, writes, reads, selected, confirmations }
}
test('current-page count, deduplication, mixed rows, original confirm API and one confirmation', async () => {
const { vm, writes, confirmations } = instance()
vm.selectedProducts.push(vm.products[0], { id: 99 })
assert.equal(vm.returnConfirmCandidates.length, 2)
await vm.runBatchConfirmReturns()
assert.deepEqual(writes.map(w => w.id), [11, 12])
assert.ok(writes.every(w => w.options.suppressErrorMessage && w.options.suppressNetworkError))
assert.equal(confirmations.length, 1); assert.match(confirmations[0], /2 条/); assert.match(confirmations[0], /不会自动让 Yeeke 发货/)
assert.equal(vm.returnConfirmResult.successCount, 2); assert.equal(vm.returnConfirmResult.skippedCount, 1)
assert.equal(vm.returnConfirmBatchLoading, false)
})
test('permission, metadata, busy and empty states prohibit writes', async () => {
for (const [key, value] of [['canPurchase', false], ['loading', true], ['returnMatchLoadError', true], ['returnMatchLoading', true], ['purchaseReadinessLoading', true], ['returnTransitionLoading', true], ['returnMatchBatchLoading', true], ['returnCancelBatchLoading', true]]) {
const { vm, writes } = instance(); vm[key] = value; await vm.runBatchConfirmReturns(); assert.equal(writes.length, 0, key)
}
const { vm, writes } = instance(); vm.matchDetail.saving = true; await vm.runBatchConfirmReturns(); assert.equal(writes.length, 0)
vm.matchDetail.saving = false; vm.selectedProducts = []; await vm.runBatchConfirmReturns(); assert.equal(writes.length, 0)
})
test('return dismisses without a write or result; confirm busy blocks cancel and single transitions', async () => {
const { vm, writes } = instance(); vm.$confirm = async () => { throw new Error('cancel') }; await vm.runBatchConfirmReturns()
assert.equal(writes.length, 0); assert.equal(vm.returnConfirmResult.open, false); assert.equal(vm.returnConfirmBatchLoading, false)
vm.returnConfirmBatchLoading = true; let ran = false
assert.match(vm.returnCancelButtonReason, /正在处理/)
await vm.runReturnTransition(async () => { ran = true }); assert.equal(ran, false)
})
test('serial, frozen identities and no duplicate execution while waiting', async () => {
let release; const wait = new Promise(resolve => { release = resolve }); const writes = []
const { vm } = instance({ confirmReturnMatch: async id => { writes.push(id); if (id === 11) await wait } })
const running = vm.runBatchConfirmReturns(); await tick()
vm.returnMatchByProductId[2].id = 99; vm.selectedProducts = []; await vm.runBatchConfirmReturns()
assert.deepEqual(writes, [11]); release(); await running; assert.deepEqual(writes, [11, 12])
})
test('partial business failure keeps only readback-proven current-page failed selection', async () => {
const { vm, selected, reads } = instance({ confirmReturnMatch: async id => { if (id === 12) throw { response: { status: 500 } } } })
await vm.runBatchConfirmReturns(); assert.equal(vm.returnConfirmResult.failedCount, 1); assert.deepEqual(reads, [12]); assert.deepEqual(selected, [2])
})
test('state conflict skips original; neither readback nor replacement write', async () => {
const { vm, reads } = instance({ confirmReturnMatch: async () => { throw { response: { status: 409, data: { code: 'STATE_CONFLICT' } } } } })
await vm.runBatchConfirmReturns(); assert.equal(vm.returnConfirmResult.skippedCount, 3); assert.equal(reads.length, 0)
})
for (const status of ['confirmed', 'cancelled', 'matched']) {
test(`unknown outcome reads original ${status} once, never resends`, async () => {
let writes = 0
const { vm, reads, selected } = instance({ confirmReturnMatch: async () => { writes++; throw new Error('timeout') }, getReturnMatch: async id => { reads.push(id); return { data: { match: { id, sybProductId: id - 10, status } } } } })
await vm.runBatchConfirmReturns(); assert.equal(writes, 2); assert.deepEqual(reads, [11, 12])
assert.equal(vm.returnConfirmResult.successCount, 0)
assert.deepEqual(selected, status === 'matched' ? [1, 2] : [])
assert.equal(vm.returnConfirmResult.skippedCount, status === 'matched' ? 1 : 3)
})
}
test('unreadable/mismatched original never treated as confirmed or retryable', async () => {
for (const getReturnMatch of [async () => { throw new Error('offline') }, async id => ({ data: { match: { id: id + 1, sybProductId: 999, status: 'confirmed' } } })]) {
const { vm, selected } = instance({ confirmReturnMatch: async () => { throw new Error('timeout') }, getReturnMatch })
await vm.runBatchConfirmReturns(); assert.equal(vm.returnConfirmResult.failedCount, 2); assert.deepEqual(selected, [])
}
})
for (const response of [false, new Error('Unauthorized'), { response: { status: 401 } }]) {
test(`auth failure/legacy false stops remaining writes (${String(response)})`, async () => {
let writes = 0
const { vm } = instance({ confirmReturnMatch: async () => { writes++; if (response === false) return false; throw response } })
await vm.runBatchConfirmReturns(); assert.equal(writes, 1); assert.equal(vm.returnConfirmResult.failedCount, 2); assert.equal(vm.returnConfirmBatchLoading, false)
assert.match(vm.returnConfirmResult.items[1].reason, /未继续执行/)
})
}
test('login failure during readback stops remaining POSTs', async () => {
let writes = 0
const { vm } = instance({ confirmReturnMatch: async () => { writes++; throw new Error('timeout') }, getReturnMatch: async () => { throw new Error('Unauthorized') } })
await vm.runBatchConfirmReturns(); assert.equal(writes, 1); assert.equal(vm.returnConfirmResult.failedCount, 2)
})
test('new generation, replaced original and vanished failed row cannot restore old selection', async () => {
for (const load of [async function() { this.loadGeneration += 2 }, async function() { this.loadGeneration++; this.products = [] }, async function() { this.loadGeneration++; this.returnMatchByProductId[1].id = 99; this.returnMatchByProductId[2].id = 98 }]) {
const { vm, selected } = instance({ confirmReturnMatch: async () => { throw new Error('timeout') } }); vm.load = load
await vm.runBatchConfirmReturns(); assert.equal(vm.returnConfirmResult.failedCount, 2); assert.deepEqual(selected, [])
}
})
test('refresh failure keeps completed result and does not write again', async () => {
const { vm, writes } = instance(); vm.load = async () => { throw new Error('offline') }; await vm.runBatchConfirmReturns()
assert.match(vm.returnConfirmResult.refreshError, /刷新失败/); assert.equal(vm.returnConfirmResult.successCount, 2); assert.equal(writes.length, 2)
})
test('unmount stops remaining writes after in-flight response', async () => {
let release; const wait = new Promise(resolve => { release = resolve }); let writes = 0
const { vm, options } = instance({ confirmReturnMatch: async () => { writes++; await wait } })
const running = vm.runBatchConfirmReturns(); await tick(); options.beforeUnmount.call(vm); release(); await running
assert.equal(writes, 1); assert.equal(vm.returnConfirmBatchLoading, false)
})