Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a56df9d908 | ||
|
|
cd4bd6acb1 | ||
|
|
baa20af530 | ||
|
|
f54ace7ccf | ||
|
|
1e582cd868 | ||
|
|
d72d77ddf4 | ||
|
|
ee8f80f987 | ||
|
|
4959f3d17f | ||
|
|
15d331bee9 | ||
|
|
897c1817d2 | ||
|
|
8c95790135 | ||
|
|
316d5aa412 | ||
|
|
e55645ff66 | ||
|
|
30c6b2ab50 | ||
|
|
27d9560f0a | ||
|
|
018e3566c0 | ||
|
|
ddd875d989 | ||
|
|
8b4db9c89e | ||
|
|
00fb274fd4 |
@@ -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: 214ac317abfef23d71362968d5b30f078fa528b6
|
||||
synchronized_at: 2026-09-29T03:47:42Z
|
||||
wiki_revision: 32c6b496de47b54ca04e081271114c28ff439e0c
|
||||
synchronized_at: 2026-09-29T06:57:51Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
@@ -740,3 +740,12 @@ SYB 回填与资料完整性独立:正式SYB任务已有单号且仍关联有
|
||||
- 超时/中断以及启动恢复把未完成 pending/matching 改为明确失败并将旧批次结束;既有 ready/updated/回写证据和范围外记录不修改,用户可手动重试。恢复不调用 SYB 写接口,不自动回写、不删除成功结果。
|
||||
|
||||
实现绑定:`fix/350-inner-code-matching`,源码提交 `9c5e20e`;未合并 main、未部署,正式环境仍需发布后启用。
|
||||
|
||||
|
||||
### 档口入库码排版字符兼容(#350 v2)
|
||||
匹配比较时,双方原始 SKU、variationSku 及档口名称仅去除 CR/LF/TAB 排版控制字符,并沿用首尾空白 trim;普通内部空格、大小写、标点与货号不变。导入原始值和回写计划中的 SYB 原始证据保持原样。归一化后重复候选、SKU/档口冲突仍拒绝,原精确规格、货号、数量与占用校验不放宽。成功匹配只形成可回写计划,不自动执行 SYB 写入。
|
||||
|
||||
|
||||
### 档口入库码混选回写范围(#351)
|
||||
列表“回写 N”仅统计当前页勾选且 ready 的唯一记录,混选其他状态不阻塞合法项。打开确认弹窗时冻结本次选中数量及合格 ID;只预览、确认提交这些 ID,不随列表刷新或勾选变化扩大范围。弹窗明确显示选中/可回写/跳过数量,入库码、占位及替换旧码指标仅针对待提交记录。
|
||||
预览发现候选状态变化时剔除 blocked 项,最多再进行一次只读预览以刷新指标;无合格项、预览失败/范围不一致或再次变化时禁用确认,提示重新预览。确认提交中防重复,未知提交结果不自动重试,沿用批次查询与只读复核。服务端原 ready 状态、唯一匹配计划及执行校验不变;仅用户人工确认后才进入回写,不自动写入。
|
||||
|
||||
@@ -44,6 +44,7 @@ func MigratedModels() []any {
|
||||
&models.SYBSessionAuthLease{},
|
||||
&models.SYBShop{},
|
||||
&models.SYBProductFilter{},
|
||||
&models.SYBProductFilterRecomputeLog{},
|
||||
&models.SYBSyncRun{},
|
||||
&models.YeekeSession{},
|
||||
&models.YeekeReturnPackage{},
|
||||
|
||||
@@ -579,12 +579,45 @@ type SYBProduct struct {
|
||||
// parse-rule change, only the derived fields above may change.
|
||||
RawJSON string `json:"-" gorm:"type:json;not null"`
|
||||
|
||||
// PDDExcluded marks that this row hit an enabled product filter
|
||||
// rule (char/keyword) at the moment it was first created by the sync
|
||||
// (#340). It replaces the old "skip on filter hit" behaviour: a filtered
|
||||
// row is still stored, but every PDD purchase/collection/AI-match entry
|
||||
// point must hard-reject it. The mark is decided once, at creation time
|
||||
// (sybimport.applyStockDetail / ApplyDetail); a later sync of the SAME
|
||||
// existing row never changes it, even if the enabled rules changed in
|
||||
// the meantime — only the admin "按当前规则重新计算" action
|
||||
// (sybproductfilter recompute) may flip it, and only for rows with no
|
||||
// purchase task and no active return match.
|
||||
PDDExcluded bool `json:"pddPurchaseExcluded" gorm:"column:pdd_purchase_excluded;not null;default:false;index"`
|
||||
// ExcludedRuleID/Kind/Keyword are a snapshot of the rule that matched at
|
||||
// mark time, kept even if the rule is later edited or deleted, so the
|
||||
// mark stays explainable in the UI and in return matching (#340).
|
||||
ExcludedRuleID *uint64 `json:"excludedRuleId,omitempty"`
|
||||
ExcludedRuleKind string `json:"excludedRuleKind,omitempty" gorm:"size:16;not null;default:''"`
|
||||
ExcludedRuleKeyword string `json:"excludedRuleKeyword,omitempty" gorm:"size:200;not null;default:''"`
|
||||
ExcludedAt *time.Time `json:"excludedAt,omitempty"`
|
||||
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (SYBProduct) TableName() string { return "syb_product" }
|
||||
|
||||
// SYBProductFilterRecomputeLog audits the admin-only "按当前规则重新计算"
|
||||
// action (#340 decision 7). Every execute run writes exactly one row here.
|
||||
type SYBProductFilterRecomputeLog struct {
|
||||
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Operator string `json:"operator" gorm:"size:128;not null;default:''"`
|
||||
ExcludedToPDD int `json:"excludedToPdd" gorm:"not null;default:0"`
|
||||
PDDToExcluded int `json:"pddToExcluded" gorm:"not null;default:0"`
|
||||
SkippedHasTask int `json:"skippedHasTask" gorm:"not null;default:0"`
|
||||
SkippedReturnMatch int `json:"skippedReturnMatch" gorm:"not null;default:0"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
func (SYBProductFilterRecomputeLog) TableName() string { return "syb_product_filter_recompute_log" }
|
||||
|
||||
func (SYBSession) TableName() string { return "syb_session" }
|
||||
|
||||
// SYBShop is the list of SYB shops whose shipment orders are imported (#49).
|
||||
|
||||
@@ -131,6 +131,9 @@ func aiMatchQualificationForDataset(id uint64, dataset batchPreviewDataset) aiMa
|
||||
if !found {
|
||||
return disabled("SYB 商品不存在或已删除")
|
||||
}
|
||||
if syb.PDDExcluded {
|
||||
return disabled("该商品已标记为无需 PDD 采购")
|
||||
}
|
||||
if strings.TrimSpace(syb.TargetColor) == "" && strings.TrimSpace(syb.TargetSize) == "" {
|
||||
return disabled("未解析出需要采购的颜色或尺码")
|
||||
}
|
||||
|
||||
@@ -399,6 +399,14 @@ func (s *Service) previewFromDataset(id uint64, dataset batchPreviewDataset, gua
|
||||
}
|
||||
item.OrderCode, item.ShopeeProductID, item.ShopeeItemID = syb.OrderCode, syb.ShopeeProductID, syb.ShopeeItemID
|
||||
item.ProductTitle, item.TargetColor, item.TargetSize, item.Quantity = syb.ProductTitle, syb.TargetColor, syb.TargetSize, syb.Quantity
|
||||
// #340: hard-exclude before anything else in the pipeline. This preview
|
||||
// is the single computation shared by BatchPreview, BatchCreate's
|
||||
// pre-check and quick-replace's ValidateQuickReplacement, so this one
|
||||
// check covers all of them.
|
||||
if syb.PDDExcluded {
|
||||
item.ReasonCode, item.Reason, item.NextAction = CodePDDExcluded, "该商品已标记为无需 PDD 采购", ""
|
||||
return item
|
||||
}
|
||||
if !sybSpecsTrusted(syb) {
|
||||
item.ReasonCode, item.NextAction = "SYB_PARSE_FAILED", "reparse"
|
||||
if syb.ParseStatus == models.SYBParseStatusUncertain {
|
||||
|
||||
@@ -9,6 +9,10 @@ func (item *BatchPreviewItem) applyCollectionEligibility(id uint64, dataset batc
|
||||
item.CollectionDisabledReason = "SYB 商品不存在或已删除"
|
||||
return
|
||||
}
|
||||
if syb.PDDExcluded {
|
||||
item.CollectionDisabledReason = "该商品已标记为无需 PDD 采购"
|
||||
return
|
||||
}
|
||||
if syb.ShopeeProductID == nil {
|
||||
item.CollectionDisabledReason = "尚未关联蝦皮商品"
|
||||
return
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package purchase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// #340: a SYB product marked pdd_purchase_excluded must be hard-rejected by
|
||||
// every PDD entry point, mirroring #338's return-match block tests above.
|
||||
|
||||
func TestCreate_RejectsWhenPDDExcluded(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := testService(db)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).
|
||||
Update("pdd_purchase_excluded", true).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := createLive(t, s, f)
|
||||
if err == nil {
|
||||
t.Fatalf("expected rejection, got success")
|
||||
}
|
||||
if se, ok := asServiceError(err); !ok || se.Code != CodePDDExcluded {
|
||||
t.Fatalf("expected CodePDDExcluded, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchPreview_ExcludedRowReportsNotEligible(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := testService(db)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
setCollectedPDDPrice(t, db, f.pdd.ID)
|
||||
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).
|
||||
Update("pdd_purchase_excluded", true).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resp, err := s.BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{f.syb.ID}, DeviceID: &f.device.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("preview call itself must not fail: %v", err)
|
||||
}
|
||||
if len(resp.Items) != 1 {
|
||||
t.Fatalf("expected 1 item, got %d", len(resp.Items))
|
||||
}
|
||||
item := resp.Items[0]
|
||||
if item.Eligible {
|
||||
t.Fatalf("excluded row must not be eligible: %+v", item)
|
||||
}
|
||||
if item.CollectionEligible {
|
||||
t.Fatalf("excluded row must not be collection-eligible: %+v", item)
|
||||
}
|
||||
if item.AIMatchEligible {
|
||||
t.Fatalf("excluded row must not be AI-match-eligible: %+v", item)
|
||||
}
|
||||
if item.ReasonCode != CodePDDExcluded {
|
||||
t.Fatalf("expected CodePDDExcluded reason, got %+v", item)
|
||||
}
|
||||
if item.ProcessStage != ProcessStagePDDExcluded {
|
||||
t.Fatalf("expected pdd_excluded stage, got %+v", item)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreate_SkipsExcludedRowNotWholeBatch(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := testService(db)
|
||||
f1 := seed(t, db, liveCaps(), true)
|
||||
setCollectedPDDPrice(t, db, f1.pdd.ID)
|
||||
|
||||
syb2 := models.SYBProduct{OrderCode: "SYB-EXCL-2", DetailID: 2, StockID: 3, ShopeeItemID: f1.shopee.ShopeeItemID, ShopeeProductID: &f1.shopee.ID, ProductTitle: f1.shopee.Title, TargetColor: "黑色", TargetSize: "XL", Quantity: 1, UnitPriceCent: 2000, ImageURL: "", ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{}`}
|
||||
if err := db.Create(&syb2).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f1.syb.ID).
|
||||
Update("pdd_purchase_excluded", true).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resp, err := s.BatchCreate(context.Background(), BatchCreateRequest{
|
||||
RequestID: uuid.NewString(), SYBProductIDs: []uint64{f1.syb.ID, syb2.ID}, DeviceID: &f1.device.ID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("batch call itself must not fail: %v", err)
|
||||
}
|
||||
if resp.CreatedCount != 1 || resp.FailedCount != 1 {
|
||||
t.Fatalf("expected 1 created + 1 failed, got created=%d failed=%d items=%+v", resp.CreatedCount, resp.FailedCount, resp.Items)
|
||||
}
|
||||
for _, item := range resp.Items {
|
||||
if item.SYBProductID == f1.syb.ID && item.Created {
|
||||
t.Fatalf("excluded row must not be created: %+v", item)
|
||||
}
|
||||
if item.SYBProductID == syb2.ID && !item.Created {
|
||||
t.Fatalf("clean row must still be created: %+v", item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestProcessStage_ExcludedRowStage covers priority: active return match wins
|
||||
// over the exclusion mark; once the match is cancelled the stage falls back
|
||||
// to pdd_excluded (#340 decision 3).
|
||||
func TestProcessStage_ExcludedRowStagePriority(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := testService(db)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).
|
||||
Update("pdd_purchase_excluded", true).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
stages, err := s.ProcessStages(context.Background(), []uint64{f.syb.ID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stages[f.syb.ID].Stage != ProcessStagePDDExcluded {
|
||||
t.Fatalf("expected pdd_excluded, got %+v", stages[f.syb.ID])
|
||||
}
|
||||
|
||||
match := models.ReturnMatch{
|
||||
SYBProductID: f.syb.ID, YeekeReturnItemID: 1,
|
||||
ActiveSYBProductID: &f.syb.ID, Status: models.ReturnMatchStatusMatched,
|
||||
MatchedAt: time.Now(),
|
||||
}
|
||||
if err := db.Create(&match).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stages, err = s.ProcessStages(context.Background(), []uint64{f.syb.ID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stages[f.syb.ID].Stage != ProcessStageReturnPending {
|
||||
t.Fatalf("active return match must win over exclusion mark, got %+v", stages[f.syb.ID])
|
||||
}
|
||||
|
||||
// Cancel: no more active match -> falls back to pdd_excluded, not the
|
||||
// normal pipeline.
|
||||
if err := db.Model(&models.ReturnMatch{}).Where("id = ?", match.ID).
|
||||
Updates(map[string]any{"active_syb_product_id": nil, "status": models.ReturnMatchStatusCancelled}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stages, err = s.ProcessStages(context.Background(), []uint64{f.syb.ID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stages[f.syb.ID].Stage != ProcessStagePDDExcluded {
|
||||
t.Fatalf("after cancel, stage should return to pdd_excluded, got %+v", stages[f.syb.ID])
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,12 @@ const (
|
||||
// to (see rejectIfActiveReturnMatch and processStageFromDataset below).
|
||||
ProcessStageReturnPending = "return_pending"
|
||||
ProcessStageReturnUsed = "return_used"
|
||||
processActionOpenPDDLink = "open_pdd_link"
|
||||
// ProcessStagePDDExcluded is #340's stage for a SYB product marked
|
||||
// pdd_purchase_excluded: it never needs a PDD purchase. It only yields to
|
||||
// an active return match (checked first, same priority order #338 uses
|
||||
// for the pipeline below it) — see processStageFromDataset.
|
||||
ProcessStagePDDExcluded = "pdd_excluded"
|
||||
processActionOpenPDDLink = "open_pdd_link"
|
||||
)
|
||||
|
||||
var processStageLabels = map[string]string{
|
||||
@@ -43,6 +48,7 @@ var processStageLabels = map[string]string{
|
||||
ProcessStageOrderReview: "待人工核对",
|
||||
ProcessStageReturnPending: "退货待确认",
|
||||
ProcessStageReturnUsed: "已用退货",
|
||||
ProcessStagePDDExcluded: "无需采购",
|
||||
}
|
||||
|
||||
type ProcessStageResult struct {
|
||||
@@ -151,6 +157,13 @@ func processStageFromDataset(id uint64, dataset batchPreviewDataset, preview Bat
|
||||
return stage(ProcessStageReturnPending, "已匹配退货待人工确认,暂不能创建采购任务", "open_return_match")
|
||||
}
|
||||
|
||||
// #340: an excluded row's mark also only overrides after the active-task
|
||||
// and active-return-match checks above, and before every other pipeline
|
||||
// branch below.
|
||||
if syb, ok := dataset.sybByID[id]; ok && syb.PDDExcluded {
|
||||
return stage(ProcessStagePDDExcluded, "顺云宝导入时已按过滤规则标记为无需 PDD 采购", "")
|
||||
}
|
||||
|
||||
syb, ok := dataset.sybByID[id]
|
||||
if !ok {
|
||||
return stage(ProcessStageManualAction, "SYB 商品不存在或已删除", "refresh")
|
||||
|
||||
@@ -28,3 +28,14 @@ func rejectIfActiveReturnMatch(tx *gorm.DB, sybProductID uint64) error {
|
||||
}
|
||||
return internal(err)
|
||||
}
|
||||
|
||||
// rejectIfPDDExcluded implements #340's hard purchase-creation block: a SYB
|
||||
// product marked pdd_purchase_excluded must never get a purchase task,
|
||||
// single or batch (BatchCreate calls s.Create per row, so this one check
|
||||
// point covers both, mirroring rejectIfActiveReturnMatch above).
|
||||
func rejectIfPDDExcluded(syb models.SYBProduct) error {
|
||||
if syb.PDDExcluded {
|
||||
return fail(CodePDDExcluded, "该商品已标记为无需 PDD 采购,不能创建采购任务")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -181,6 +181,9 @@ func (s *Service) create(ctx context.Context, req CreateRequest) (models.Purchas
|
||||
if err := rejectIfActiveReturnMatch(tx, syb.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rejectIfPDDExcluded(syb); err != nil {
|
||||
return err
|
||||
}
|
||||
if syb.ShopeeProductID == nil {
|
||||
return fail(CodeInvalidRequest, "该商品尚未关联蝦皮商品")
|
||||
}
|
||||
|
||||
@@ -41,7 +41,14 @@ const (
|
||||
// tasks for such a product until the match is cancelled. Resuming or
|
||||
// reparsing an existing task is not affected.
|
||||
CodeReturnMatched = "PURCHASE_RETURN_MATCHED"
|
||||
CodeInternal = "INTERNAL_ERROR"
|
||||
// CodePDDExcluded is returned by every PDD entry point (purchase create,
|
||||
// collection task creation, AI spec match, image search collection,
|
||||
// quick-replace, ...) when the SYB product is marked
|
||||
// pdd_purchase_excluded (#340): it hit a product filter rule at import
|
||||
// time and is recorded as 「无需 PDD 采购」, so it must never start any
|
||||
// PDD flow, regardless of the rest of its pipeline state.
|
||||
CodePDDExcluded = "PURCHASE_PDD_EXCLUDED"
|
||||
CodeInternal = "INTERNAL_ERROR"
|
||||
)
|
||||
|
||||
type ServiceError struct {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package returnmatch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
)
|
||||
|
||||
// #340: a pdd_purchase_excluded SYB product still needs no PDD purchase but
|
||||
// must remain eligible to take part in return matching.
|
||||
func TestBatchMatch_ExcludedStageParticipates(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) }
|
||||
|
||||
deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
|
||||
syb := seedSYB(t, db, "SYB-EXCL-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
|
||||
if err := db.Model(&models.SYBProduct{}).Where("id = ?", syb.ID).
|
||||
Update("pdd_purchase_excluded", true).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seedReturn(t, db, "白色,L【建議65-75公斤】", &deadline)
|
||||
|
||||
resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}, Operator: "tester"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.MatchedCount != 1 || len(resp.Items) != 1 || !resp.Items[0].Matched {
|
||||
t.Fatalf("excluded-stage row must still be eligible for return matching: %+v", resp)
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,12 @@ var participatingStages = map[string]bool{
|
||||
purchase.ProcessStagePDDCollectionFail: true,
|
||||
purchase.ProcessStageColorMapping: true,
|
||||
purchase.ProcessStagePurchaseReady: true,
|
||||
// #340: a pdd_purchase_excluded row still needs no PDD purchase and can
|
||||
// still take part in return matching — matching it just confirms there is
|
||||
// nothing left to buy back. After a cancel it returns to pdd_excluded,
|
||||
// which stays true here (see processStageFromDataset), so the row is
|
||||
// eligible again the same way any other participating stage is.
|
||||
purchase.ProcessStagePDDExcluded: true,
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/shopeeproduct"
|
||||
@@ -41,6 +42,18 @@ type DetailInput struct {
|
||||
ProductTitle string
|
||||
ProductThumb uint64
|
||||
Raw json.RawMessage
|
||||
|
||||
// Excluded and the ExcludedRule* fields are #340's product-filter mark:
|
||||
// Excluded reports whether this line hit an enabled filter rule during
|
||||
// THIS sync's matching, and the ExcludedRule* fields are a snapshot of
|
||||
// that rule. They are only applied when ApplyDetail is about to CREATE a
|
||||
// new syb_product row (decision 4: a later sync of an existing row must
|
||||
// never change its existing mark, even if the rules or the match outcome
|
||||
// changed since).
|
||||
Excluded bool
|
||||
ExcludedRuleID *uint64
|
||||
ExcludedRuleKind string
|
||||
ExcludedRuleKeyword string
|
||||
}
|
||||
|
||||
// ApplyResult reports what ApplyDetail actually did, for the import-result
|
||||
@@ -138,12 +151,32 @@ func ApplyDetail(ctx context.Context, db *gorm.DB, order OrderInput, detail Deta
|
||||
err = tx.Where("order_code = ? AND detail_id = ?", order.Code, detail.ID).First(&existing).Error
|
||||
switch {
|
||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||
// #340 decision 4: the mark is set only when the row is first
|
||||
// created, from this sync's own filter match — never on update.
|
||||
if detail.Excluded {
|
||||
now := time.Now().UTC()
|
||||
record.PDDExcluded = true
|
||||
record.ExcludedRuleID = detail.ExcludedRuleID
|
||||
record.ExcludedRuleKind = detail.ExcludedRuleKind
|
||||
record.ExcludedRuleKeyword = detail.ExcludedRuleKeyword
|
||||
record.ExcludedAt = &now
|
||||
}
|
||||
if err := tx.Create(&record).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
result.Outcome = OutcomeCreated
|
||||
case err == nil:
|
||||
record.ID = existing.ID
|
||||
// #340 decision 4: the exclusion mark is decided once, at row
|
||||
// creation. A resync of an already-existing row must never
|
||||
// change it (nor is it included in `updates` below), but the
|
||||
// returned/in-memory record must still reflect the existing
|
||||
// mark rather than the zero value this fresh struct starts with.
|
||||
record.PDDExcluded = existing.PDDExcluded
|
||||
record.ExcludedRuleID = existing.ExcludedRuleID
|
||||
record.ExcludedRuleKind = existing.ExcludedRuleKind
|
||||
record.ExcludedRuleKeyword = existing.ExcludedRuleKeyword
|
||||
record.ExcludedAt = existing.ExcludedAt
|
||||
// Human-confirmed target values are authoritative and survive every
|
||||
// source re-import. ParseStatus/ParseNote below still record what the
|
||||
// current deterministic parser observed for audit.
|
||||
|
||||
@@ -47,7 +47,7 @@ func (handler Handler) List(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
response, err := service.List(c.Request.Context(), ListRequest{
|
||||
Page: page, PageSize: pageSize, ShopName: c.Query("shopName"), OrderCodes: []string{c.Query("orderCodes")}, ParseStatus: strings.TrimSpace(c.Query("parseStatus")), ProcessStage: strings.TrimSpace(c.Query("processStage")), CreatedFrom: strings.TrimSpace(c.Query("createdFrom")), CreatedTo: strings.TrimSpace(c.Query("createdTo")),
|
||||
Page: page, PageSize: pageSize, ShopName: c.Query("shopName"), OrderCodes: []string{c.Query("orderCodes")}, ParseStatus: strings.TrimSpace(c.Query("parseStatus")), ProcessStage: strings.TrimSpace(c.Query("processStage")), CreatedFrom: strings.TrimSpace(c.Query("createdFrom")), CreatedTo: strings.TrimSpace(c.Query("createdTo")), PurchaseType: strings.TrimSpace(c.Query("purchaseType")),
|
||||
})
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package sybimport_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"go-admin/app/goauto/sybimport"
|
||||
)
|
||||
|
||||
// #340: List's purchaseType filter combines with processStage as AND.
|
||||
func TestServiceListPurchaseType(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
pdd, err := sybimport.ApplyDetail(context.Background(), db, realOrder(), realDetailA())
|
||||
if err != nil {
|
||||
t.Fatalf("apply pdd row: %v", err)
|
||||
}
|
||||
excludedOrder := realOrder()
|
||||
excludedOrder.Code = "260728EXCL"
|
||||
excludedOrder.StockID++
|
||||
excludedDetail := realDetailB()
|
||||
excludedDetail.ID++
|
||||
excludedDetail.Excluded = true
|
||||
ruleID := uint64(1)
|
||||
excludedDetail.ExcludedRuleID = &ruleID
|
||||
excludedDetail.ExcludedRuleKind = "keyword"
|
||||
excludedDetail.ExcludedRuleKeyword = "档口"
|
||||
excluded, err := sybimport.ApplyDetail(context.Background(), db, excludedOrder, excludedDetail)
|
||||
if err != nil {
|
||||
t.Fatalf("apply excluded row: %v", err)
|
||||
}
|
||||
if !excluded.SYBProduct.PDDExcluded {
|
||||
t.Fatalf("seed row was not marked excluded")
|
||||
}
|
||||
service := sybimport.NewService(db)
|
||||
|
||||
defaultResp, err := service.List(context.Background(), sybimport.ListRequest{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if defaultResp.Total != 1 || defaultResp.Items[0].ID != pdd.SYBProduct.ID {
|
||||
t.Fatalf("default purchaseType must show only pdd rows: %+v", defaultResp)
|
||||
}
|
||||
|
||||
pddResp, err := service.List(context.Background(), sybimport.ListRequest{PurchaseType: sybimport.PurchaseTypePDD})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if pddResp.Total != 1 || pddResp.Items[0].ID != pdd.SYBProduct.ID {
|
||||
t.Fatalf("explicit pdd purchaseType mismatch: %+v", pddResp)
|
||||
}
|
||||
|
||||
excludedResp, err := service.List(context.Background(), sybimport.ListRequest{PurchaseType: sybimport.PurchaseTypeExcluded})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if excludedResp.Total != 1 || excludedResp.Items[0].ID != excluded.SYBProduct.ID {
|
||||
t.Fatalf("excluded purchaseType mismatch: %+v", excludedResp)
|
||||
}
|
||||
|
||||
allResp, err := service.List(context.Background(), sybimport.ListRequest{PurchaseType: sybimport.PurchaseTypeAll})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if allResp.Total != 2 {
|
||||
t.Fatalf("all purchaseType must show both rows, got %+v", allResp)
|
||||
}
|
||||
|
||||
if _, err := service.List(context.Background(), sybimport.ListRequest{PurchaseType: "bogus"}); err == nil {
|
||||
t.Fatalf("invalid purchaseType must be rejected")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package sybimport_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/sybimport"
|
||||
)
|
||||
|
||||
// #340: applyStockDetail no longer skips a filter-hit row; it stores it and
|
||||
// marks it. These tests exercise the same public entry point (ApplyDetail)
|
||||
// applyStockDetail calls, with the Excluded fields it now always passes in.
|
||||
|
||||
func TestApplyDetailMarksExcludedRowOnCreate(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
order := sybimport.OrderInput{Code: "EXCL-ORDER", StockID: 1, ShopName: "测试店铺"}
|
||||
ruleID := uint64(9)
|
||||
raw, _ := json.Marshal(map[string]any{"variationSku": "档口-123"})
|
||||
result, err := sybimport.ApplyDetail(context.Background(), db, order, sybimport.DetailInput{
|
||||
ID: 1, ProductID: 100, ProductQty: 1, ProductPrice: 10, ProductSpec: "黑色,L", ProductTitle: "t",
|
||||
Raw: raw,
|
||||
Excluded: true,
|
||||
ExcludedRuleID: &ruleID,
|
||||
ExcludedRuleKind: "keyword",
|
||||
ExcludedRuleKeyword: "档口",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyDetail: %v", err)
|
||||
}
|
||||
if !result.SYBProduct.PDDExcluded {
|
||||
t.Fatalf("expected row to be marked excluded")
|
||||
}
|
||||
if result.SYBProduct.ExcludedRuleID == nil || *result.SYBProduct.ExcludedRuleID != ruleID {
|
||||
t.Fatalf("expected excluded rule id snapshot %d, got %v", ruleID, result.SYBProduct.ExcludedRuleID)
|
||||
}
|
||||
if result.SYBProduct.ExcludedRuleKind != "keyword" || result.SYBProduct.ExcludedRuleKeyword != "档口" {
|
||||
t.Fatalf("unexpected rule snapshot: %+v", result.SYBProduct)
|
||||
}
|
||||
if result.SYBProduct.ExcludedAt == nil {
|
||||
t.Fatalf("expected excludedAt to be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyDetailNonHitRowIsNotMarked(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
order := sybimport.OrderInput{Code: "OK-ORDER", StockID: 1, ShopName: "测试店铺"}
|
||||
raw, _ := json.Marshal(map[string]any{"variationSku": ""})
|
||||
result, err := sybimport.ApplyDetail(context.Background(), db, order, sybimport.DetailInput{
|
||||
ID: 1, ProductID: 100, ProductQty: 1, ProductPrice: 10, ProductSpec: "黑色,L", ProductTitle: "t",
|
||||
Raw: raw,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyDetail: %v", err)
|
||||
}
|
||||
if result.SYBProduct.PDDExcluded {
|
||||
t.Fatalf("expected row not to be marked excluded")
|
||||
}
|
||||
if result.SYBProduct.ExcludedRuleID != nil || result.SYBProduct.ExcludedAt != nil {
|
||||
t.Fatalf("expected no rule snapshot on a non-hit row: %+v", result.SYBProduct)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyDetailResyncKeepsMarkEvenIfRulesChanged is decision 4: the mark
|
||||
// is fixed at row-creation time. A later sync of the SAME existing row must
|
||||
// not flip it even when it is re-applied with a different Excluded value
|
||||
// (representing a rule that started/stopped matching since).
|
||||
func TestApplyDetailResyncKeepsMarkEvenIfRulesChanged(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
order := sybimport.OrderInput{Code: "RESYNC-ORDER", StockID: 1, ShopName: "测试店铺"}
|
||||
ruleID := uint64(1)
|
||||
raw, _ := json.Marshal(map[string]any{"variationSku": "档口-123"})
|
||||
first, err := sybimport.ApplyDetail(context.Background(), db, order, sybimport.DetailInput{
|
||||
ID: 1, ProductID: 100, ProductQty: 1, ProductPrice: 10, ProductSpec: "黑色,L", ProductTitle: "t",
|
||||
Raw: raw, Excluded: true, ExcludedRuleID: &ruleID, ExcludedRuleKind: "keyword", ExcludedRuleKeyword: "档口",
|
||||
})
|
||||
if err != nil || !first.SYBProduct.PDDExcluded {
|
||||
t.Fatalf("seed create failed: %v %+v", err, first.SYBProduct)
|
||||
}
|
||||
|
||||
// Re-sync the same detail, this time with the rule disabled (no hit).
|
||||
second, err := sybimport.ApplyDetail(context.Background(), db, order, sybimport.DetailInput{
|
||||
ID: 1, ProductID: 100, ProductQty: 1, ProductPrice: 10, ProductSpec: "黑色,L", ProductTitle: "t",
|
||||
Raw: raw, Excluded: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyDetail update: %v", err)
|
||||
}
|
||||
if second.Outcome != sybimport.OutcomeUpdated {
|
||||
t.Fatalf("expected update outcome, got %s", second.Outcome)
|
||||
}
|
||||
if !second.SYBProduct.PDDExcluded {
|
||||
t.Fatalf("expected existing mark to be preserved across resync, got unmarked: %+v", second.SYBProduct)
|
||||
}
|
||||
|
||||
var stored models.SYBProduct
|
||||
if err := db.Where("order_code = ? AND detail_id = ?", order.Code, uint64(1)).First(&stored).Error; err != nil {
|
||||
t.Fatalf("reload: %v", err)
|
||||
}
|
||||
if !stored.PDDExcluded || stored.ExcludedRuleKeyword != "档口" {
|
||||
t.Fatalf("mark was changed by resync: %+v", stored)
|
||||
}
|
||||
}
|
||||
@@ -47,8 +47,21 @@ type ListRequest struct {
|
||||
ProcessStage string
|
||||
CreatedFrom string
|
||||
CreatedTo string
|
||||
// PurchaseType is #340's list-side isolation filter: "pdd" (default when
|
||||
// empty) shows only rows that still need a PDD purchase,
|
||||
// "excluded" shows only pdd_purchase_excluded rows, "all" shows both. It
|
||||
// combines with ProcessStage and the created-time range as AND; the
|
||||
// auto-switch to 全部 mentioned in the issue is a front-end behaviour,
|
||||
// not a server default.
|
||||
PurchaseType string
|
||||
}
|
||||
|
||||
const (
|
||||
PurchaseTypePDD = "pdd"
|
||||
PurchaseTypeExcluded = "excluded"
|
||||
PurchaseTypeAll = "all"
|
||||
)
|
||||
|
||||
type ListResponse struct {
|
||||
Items []models.SYBProduct `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
@@ -105,6 +118,16 @@ func (service *Service) List(ctx context.Context, request ListRequest) (ListResp
|
||||
if !purchase.ValidProcessStage(request.ProcessStage) {
|
||||
return ListResponse{}, invalidRequest("processStage 无效")
|
||||
}
|
||||
request.PurchaseType = strings.TrimSpace(request.PurchaseType)
|
||||
switch request.PurchaseType {
|
||||
case "", PurchaseTypePDD:
|
||||
query = query.Where("pdd_purchase_excluded = ?", false)
|
||||
case PurchaseTypeExcluded:
|
||||
query = query.Where("pdd_purchase_excluded = ?", true)
|
||||
case PurchaseTypeAll:
|
||||
default:
|
||||
return ListResponse{}, invalidRequest("purchaseType 无效")
|
||||
}
|
||||
if request.ProcessStage != "" {
|
||||
var candidates []models.SYBProduct
|
||||
if err := query.Order("updated_at DESC, id DESC").Find(&candidates).Error; err != nil {
|
||||
|
||||
@@ -395,12 +395,16 @@ func applyStockDetail(ctx context.Context, db *gorm.DB, row sybclient.StockRow,
|
||||
}
|
||||
for _, item := range detail.Details {
|
||||
variation := stringField(item.Raw, "variationSku")
|
||||
// `[必须]` #340: a filter hit no longer skips the row. It is still
|
||||
// stored, marked with a rule snapshot, and left to the hard PDD
|
||||
// exclusion guards in purchase/task/returnmatch. Char/keyword hit
|
||||
// counts and per-rule hits (#269) keep the same meaning — "marked",
|
||||
// not "skipped" — so the disable-confirmation dialog's figures are
|
||||
// still accurate.
|
||||
var excluded bool
|
||||
var excludedRuleID *uint64
|
||||
var excludedRuleKind, excludedRuleKeyword string
|
||||
if rule := filters.Match(variation); rule != nil {
|
||||
// `[必须]` Count the two kinds separately and per rule. A combined
|
||||
// total would hide a structural rule that stopped matching because
|
||||
// 档口 changed its code format, and a per-kind total would make the
|
||||
// disable-confirmation dialog quote the same figure for "#" and "-"
|
||||
// even though they match very different numbers of rows (#269).
|
||||
if rule.Kind == "char" {
|
||||
report.CharFilterSkipped++
|
||||
} else {
|
||||
@@ -409,7 +413,11 @@ func applyStockDetail(ctx context.Context, db *gorm.DB, row sybclient.StockRow,
|
||||
if report.filterHits != nil {
|
||||
report.filterHits.Add(rule)
|
||||
}
|
||||
continue
|
||||
excluded = true
|
||||
ruleID := rule.ID
|
||||
excludedRuleID = &ruleID
|
||||
excludedRuleKind = rule.Kind
|
||||
excludedRuleKeyword = rule.Keyword
|
||||
}
|
||||
raw, err := json.Marshal(item.Raw)
|
||||
if err != nil {
|
||||
@@ -424,6 +432,9 @@ func applyStockDetail(ctx context.Context, db *gorm.DB, row sybclient.StockRow,
|
||||
ProductTitle: item.ProductTitle,
|
||||
ProductThumb: uint64(item.ProductThumb),
|
||||
Raw: raw,
|
||||
|
||||
Excluded: excluded, ExcludedRuleID: excludedRuleID,
|
||||
ExcludedRuleKind: excludedRuleKind, ExcludedRuleKeyword: excludedRuleKeyword,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("写入货运单 %s 明细 %d 失败(本次同步停止;已写入的数据保留): %w",
|
||||
|
||||
@@ -68,6 +68,9 @@ type fakeSYB struct {
|
||||
// 用于构造「列表说是 A 店、明细说是 B 店」的不一致。
|
||||
detailShopName string
|
||||
blankDetailShopName bool
|
||||
// detailVariationSku, when non-empty, is used as every detail's
|
||||
// variationSku (#340 filter-marking tests).
|
||||
detailVariationSku string
|
||||
}
|
||||
|
||||
func (f *fakeSYB) shopFor(i int) string {
|
||||
@@ -142,7 +145,7 @@ func (f *fakeSYB) server(t *testing.T) *httptest.Server {
|
||||
"shopName": detailShopName,
|
||||
"details": []any{map[string]any{
|
||||
"id": float64(id*10 + 1), "productId": float64(9001),
|
||||
"productTitle": "测试商品", "productSpec": "白色,L",
|
||||
"productTitle": "测试商品", "productSpec": "白色,L", "variationSku": f.detailVariationSku,
|
||||
"productQty": float64(2), "productPrice": 39.5, "productThumb": float64(77),
|
||||
}},
|
||||
})
|
||||
@@ -466,3 +469,64 @@ func TestSyncRejectsBlankShopOnDetailResponse(t *testing.T) {
|
||||
report.DetailCount, report.AcceptedCount, report.ShopSkipped)
|
||||
}
|
||||
}
|
||||
|
||||
// #340: a filter hit no longer skips the row — it is still stored, counted
|
||||
// (as "marked", same counters as before) and left for the hard PDD exclusion
|
||||
// guards elsewhere in the pipeline to enforce.
|
||||
func TestSyncStoresAndMarksFilterHitRowsInsteadOfSkipping(t *testing.T) {
|
||||
db := newSyncTestDB(t)
|
||||
if err := db.Create(&models.SYBProductFilter{
|
||||
Kind: "keyword", Keyword: "档口", NormalizedKeyword: "档口", Enabled: true,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed filter: %v", err)
|
||||
}
|
||||
f := &fakeSYB{perDay: map[string]int{"2026-08-01": 2}, detailVariationSku: "档口-99"}
|
||||
|
||||
report, err := Sync(context.Background(), db, newSyncClient(t, f),
|
||||
SyncConfig{PageSize: 10, MaxMatches: 1000}, "2026-08-01", "2026-08-01")
|
||||
if err != nil {
|
||||
t.Fatalf("同步失败: %v", err)
|
||||
}
|
||||
if report.KeywordFilterSkipped != 2 {
|
||||
t.Fatalf("expected 2 marked hits, got %d", report.KeywordFilterSkipped)
|
||||
}
|
||||
if report.DetailCount != 2 || report.Created != 2 {
|
||||
t.Fatalf("filter hits must still be stored: detailCount=%d created=%d", report.DetailCount, report.Created)
|
||||
}
|
||||
var count int64
|
||||
db.Model(&models.SYBProduct{}).Where("pdd_purchase_excluded = ?", true).Count(&count)
|
||||
if count != 2 {
|
||||
t.Fatalf("expected 2 rows marked excluded, got %d", count)
|
||||
}
|
||||
var filter models.SYBProductFilter
|
||||
if err := db.Where("keyword = ?", "档口").First(&filter).Error; err != nil {
|
||||
t.Fatalf("reload filter: %v", err)
|
||||
}
|
||||
if filter.LastHitCount == nil || *filter.LastHitCount != 2 {
|
||||
t.Fatalf("expected rule's own hit count to be 2, got %v", filter.LastHitCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncNonHitRowsAreNotMarked(t *testing.T) {
|
||||
db := newSyncTestDB(t)
|
||||
if err := db.Create(&models.SYBProductFilter{
|
||||
Kind: "keyword", Keyword: "档口", NormalizedKeyword: "档口", Enabled: true,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed filter: %v", err)
|
||||
}
|
||||
f := &fakeSYB{perDay: map[string]int{"2026-08-01": 1}, detailVariationSku: "普通-1"}
|
||||
|
||||
report, err := Sync(context.Background(), db, newSyncClient(t, f),
|
||||
SyncConfig{PageSize: 10, MaxMatches: 1000}, "2026-08-01", "2026-08-01")
|
||||
if err != nil {
|
||||
t.Fatalf("同步失败: %v", err)
|
||||
}
|
||||
if report.KeywordFilterSkipped != 0 {
|
||||
t.Fatalf("unexpected marked hits: %d", report.KeywordFilterSkipped)
|
||||
}
|
||||
var count int64
|
||||
db.Model(&models.SYBProduct{}).Where("pdd_purchase_excluded = ?", true).Count(&count)
|
||||
if count != 0 {
|
||||
t.Fatalf("no rows should be marked excluded, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -417,12 +417,18 @@ func matchSpec(spec string, items []sybclient.DetailItem) []sybclient.DetailItem
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Compare layout-only controls without changing stored evidence or ordinary spaces.
|
||||
func normalizeMatchLayout(value string) string {
|
||||
return strings.TrimSpace(strings.NewReplacer("\r", "", "\n", "", "\t", "").Replace(value))
|
||||
}
|
||||
|
||||
func matchEvidence(stall, sourceSKU string, items []sybclient.DetailItem) ([]sybclient.DetailItem, string) {
|
||||
sourceSKU = strings.TrimSpace(sourceSKU)
|
||||
sourceSKU = normalizeMatchLayout(sourceSKU)
|
||||
if sourceSKU != "" {
|
||||
skuMatches := []sybclient.DetailItem{}
|
||||
for _, item := range items {
|
||||
if strings.TrimSpace(rawText(item.Raw["sku"])) == sourceSKU || strings.TrimSpace(rawText(item.Raw["variationSku"])) == sourceSKU {
|
||||
if normalizeMatchLayout(rawText(item.Raw["sku"])) == sourceSKU || normalizeMatchLayout(rawText(item.Raw["variationSku"])) == sourceSKU {
|
||||
skuMatches = append(skuMatches, item)
|
||||
}
|
||||
}
|
||||
@@ -453,7 +459,7 @@ func matchEvidence(stall, sourceSKU string, items []sybclient.DetailItem) ([]syb
|
||||
return fallback, ""
|
||||
}
|
||||
func strictStall(stall string, items []sybclient.DetailItem) []sybclient.DetailItem {
|
||||
stall = strings.TrimSpace(stall)
|
||||
stall = normalizeMatchLayout(stall)
|
||||
if stall == "" {
|
||||
return nil
|
||||
}
|
||||
@@ -471,8 +477,12 @@ func strictStall(stall string, items []sybclient.DetailItem) []sybclient.DetailI
|
||||
// 候选中同时包含档口名才允许前导零等价(如 "067"≡"67");非数字货号要求精确
|
||||
// token 匹配;ProductSpec 只在以货号开头时才算命中;货号为空时回退为档口名包含判断。
|
||||
func stallMatches(stall string, item sybclient.DetailItem) bool {
|
||||
sku := rawText(item.Raw["sku"])
|
||||
variation := rawText(item.Raw["variationSku"])
|
||||
stall = normalizeMatchLayout(stall)
|
||||
if stall == "" {
|
||||
return false
|
||||
}
|
||||
sku := normalizeMatchLayout(rawText(item.Raw["sku"]))
|
||||
variation := normalizeMatchLayout(rawText(item.Raw["variationSku"]))
|
||||
blob := sku + " " + variation + " " + item.ProductSpec
|
||||
if strings.Contains(blob, stall) {
|
||||
return true
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package sybinnercode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/sybclient"
|
||||
)
|
||||
|
||||
func TestMatchEvidenceNormalizesLayoutOnBothSides(t *testing.T) {
|
||||
for _, layout := range []string{"\n", "\r", "\r\n", "\t"} {
|
||||
for _, sourceHasLayout := range []bool{true, false} {
|
||||
source, candidate := "市场-档口甲#731", "市场-档口甲#731"
|
||||
if sourceHasLayout {
|
||||
source = "市场-" + layout + "档口甲#731"
|
||||
} else {
|
||||
candidate = "市场-" + layout + "档口甲#731"
|
||||
}
|
||||
item := detail(1, "黑色,L", 1, "", candidate, "")
|
||||
matches, reason := matchEvidence(source, source, []sybclient.DetailItem{item})
|
||||
if reason != "" || len(matches) != 1 || !stallMatches(source, item) {
|
||||
t.Fatalf("layout=%q sourceLayout=%v: matches=%v reason=%s", layout, sourceHasLayout, matches, reason)
|
||||
}
|
||||
if item.Raw["variationSku"] != candidate {
|
||||
t.Fatal("comparison modified raw candidate evidence")
|
||||
}
|
||||
}
|
||||
}
|
||||
item := detail(1, "黑色,L", 1, "SKU-\n1", "", "")
|
||||
if matches, reason := matchEvidence("", "SKU-1", []sybclient.DetailItem{item}); reason != "" || len(matches) != 1 {
|
||||
t.Fatalf("raw sku layout did not match: %v %s", matches, reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchLayoutPreservesIdentityAndAmbiguityChecks(t *testing.T) {
|
||||
for _, candidate := range []string{"市场-档口乙#731", "市场-档口甲#732", "市场-档 口甲#731", "市场-档口甲#PDD7319"} {
|
||||
item := detail(1, "黑色,L", 1, "", candidate, "")
|
||||
matches, _ := matchEvidence("市场-\n档口甲#731", "市场-\n档口甲#731", []sybclient.DetailItem{item})
|
||||
if len(matches) != 0 {
|
||||
t.Fatalf("different identity unexpectedly matched: %q", candidate)
|
||||
}
|
||||
}
|
||||
items := []sybclient.DetailItem{detail(1, "黑色,L", 1, "SKU-1", "", ""), detail(2, "黑色,L", 1, "SKU-\n1", "", "")}
|
||||
if matches, reason := matchEvidence("", "SKU-1", items); len(matches) != 0 || reason == "" {
|
||||
t.Fatal("layout-equivalent duplicate SKUs must remain ambiguous")
|
||||
}
|
||||
items = []sybclient.DetailItem{detail(1, "黑色,L", 1, "SKU-1", "市场-档口乙#732", ""), detail(2, "黑色,L", 1, "OTHER", "市场-档口甲#731", "")}
|
||||
if matches, reason := matchEvidence("市场-\n档口甲#731", "SKU-\t1", items); len(matches) != 0 || reason == "" {
|
||||
t.Fatal("SKU/stall conflict must remain rejected")
|
||||
}
|
||||
if normalizeMatchLayout("A B") == normalizeMatchLayout("AB") || normalizeMatchLayout("a") == normalizeMatchLayout("A") {
|
||||
t.Fatal("ordinary spaces and case must remain significant")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanRecordWithLayoutEvidenceKeepsOriginalValues(t *testing.T) {
|
||||
record := matchRecord("ORDER-1", "市场-\n档口甲#731", "市场-\n档口甲#731", "IC-1")
|
||||
item := detail(20, "黑色,L", 1, "", "市场-档口甲#731", "")
|
||||
reader := &fakeMatchReader{rows: map[string][]sybclient.StockRow{"ORDER-1": {{ID: 10}}}, stocks: map[int64]sybclient.StockDetail{10: {ID: 10, Details: []sybclient.DetailItem{item}}}}
|
||||
plan, status, reason, err := planRecord(context.Background(), reader, record, map[int64]bool{})
|
||||
if err != nil || status != models.SYBInnerCodeReady || plan == nil || plan.DetailID != 20 {
|
||||
t.Fatalf("expected unique ready plan, got %v %s %s %v", plan, status, reason, err)
|
||||
}
|
||||
if record.Stall != "市场-\n档口甲#731" || record.SourceSKURaw != record.Stall || plan.SYBVariationSKU != "市场-档口甲#731" {
|
||||
t.Fatal("original import and plan evidence must remain unchanged")
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,46 @@ func (h Handler) SetEnabled(c *gin.Context) {
|
||||
}
|
||||
c.JSON(200, gin.H{"code": 200, "data": x})
|
||||
}
|
||||
|
||||
// RecomputePreview and RecomputeExecute implement #340 decision 7's admin-only
|
||||
// "按当前规则重新计算" action. Both are gated by middleware.RequireRoleKey
|
||||
// ("admin") at the router, the same admin gate this package already uses for
|
||||
// Create/SetEnabled/Delete.
|
||||
func (h Handler) RecomputePreview(c *gin.Context) {
|
||||
s, ok := h.service(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
counts, e := s.RecomputePreview(c.Request.Context())
|
||||
if e != nil {
|
||||
writeError(c, e)
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"code": 200, "data": counts})
|
||||
}
|
||||
|
||||
type recomputeExecuteBody struct {
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
}
|
||||
|
||||
func (h Handler) RecomputeExecute(c *gin.Context) {
|
||||
var body recomputeExecuteBody
|
||||
if !decode(c, &body) {
|
||||
return
|
||||
}
|
||||
s, ok := h.service(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
claims := jwt.ExtractClaims(c)
|
||||
name, _ := claims["nice"].(string)
|
||||
result, e := s.RecomputeExecute(c.Request.Context(), name, body.Fingerprint)
|
||||
if e != nil {
|
||||
writeError(c, e)
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"code": 200, "data": result})
|
||||
}
|
||||
func (h Handler) Delete(c *gin.Context) {
|
||||
id, ok := idParam(c)
|
||||
if !ok {
|
||||
@@ -120,6 +160,8 @@ func writeError(c *gin.Context, e error) {
|
||||
status = 422
|
||||
case CodeConflict:
|
||||
status = 409
|
||||
case CodeRecomputeStale:
|
||||
status = 409
|
||||
case CodeNotFound:
|
||||
status = 404
|
||||
case CodeForbidden:
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
package sybproductfilter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// RecomputeCounts is shared by preview and execute (#340 decision 7). The
|
||||
// preview's counts are a PLAN computed against an unlocked read; the
|
||||
// execute's counts are what was ACTUALLY written, re-checked row by row
|
||||
// under lock (phase 3 review fix) — the two normally agree, but execute's
|
||||
// counts are authoritative when something changed concurrently between the
|
||||
// two calls (a purchase task created, a return matched, or the same row
|
||||
// recomputed by another admin).
|
||||
type RecomputeCounts struct {
|
||||
ExcludedToPDD int `json:"excludedToPdd"`
|
||||
PDDToExcluded int `json:"pddToExcluded"`
|
||||
SkippedHasTask int `json:"skippedHasTask"`
|
||||
SkippedReturnMatch int `json:"skippedReturnMatch"`
|
||||
}
|
||||
|
||||
type recomputeChange struct {
|
||||
id uint64
|
||||
orderCode string
|
||||
shopeeItemID string
|
||||
toExcluded bool // true: pdd -> excluded; false: excluded -> pdd
|
||||
ruleID *uint64
|
||||
ruleKind, ruleKeyword string
|
||||
}
|
||||
|
||||
// recomputeSourceRow selects only the columns recomputeChanges needs (phase 3
|
||||
// review item 4) instead of the full syb_product row.
|
||||
type recomputeSourceRow struct {
|
||||
ID uint64 `gorm:"column:id"`
|
||||
OrderCode string `gorm:"column:order_code"`
|
||||
ShopeeItemID string `gorm:"column:shopee_item_id"`
|
||||
RawJSON string `gorm:"column:raw_json"`
|
||||
PDDExcluded bool `gorm:"column:pdd_purchase_excluded"`
|
||||
}
|
||||
|
||||
// RecomputeSample is one preview row (#340 phase 2): up to maxRecomputeSamples
|
||||
// changes are shown so an admin can sanity-check the run before executing it.
|
||||
type RecomputeSample struct {
|
||||
OrderCode string `json:"orderCode"`
|
||||
ShopeeItemID string `json:"shopeeItemId"`
|
||||
// Direction is "excluded_to_pdd" or "pdd_to_excluded".
|
||||
Direction string `json:"direction"`
|
||||
// RuleKind/RuleKeyword are empty when Direction is excluded_to_pdd (no
|
||||
// rule matches any more).
|
||||
RuleKind string `json:"ruleKind,omitempty"`
|
||||
RuleKeyword string `json:"ruleKeyword,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
DirectionExcludedToPDD = "excluded_to_pdd"
|
||||
DirectionPDDToExcluded = "pdd_to_excluded"
|
||||
maxRecomputeSamples = 20
|
||||
)
|
||||
|
||||
// recomputeChanges computes, against the CURRENT enabled rules, every
|
||||
// syb_product row whose mark should flip, skipping any row that has ever had
|
||||
// a purchase task or currently has an active return match — those never
|
||||
// change (#340 decision 7). This is a PLAN: preview uses it directly, and
|
||||
// execute uses it as the candidate list, re-checking each row under lock in
|
||||
// writeRecomputeChanges before actually writing (phase 3 review item 1).
|
||||
func recomputeChanges(ctx context.Context, tx *gorm.DB) (RecomputeCounts, []recomputeChange, error) {
|
||||
filters, err := LoadEnabled(ctx, tx)
|
||||
if err != nil {
|
||||
return RecomputeCounts{}, nil, err
|
||||
}
|
||||
var rows []recomputeSourceRow
|
||||
if err := tx.WithContext(ctx).Model(&models.SYBProduct{}).
|
||||
Select("id, order_code, shopee_item_id, raw_json, pdd_purchase_excluded").
|
||||
Order("id ASC").Find(&rows).Error; err != nil {
|
||||
return RecomputeCounts{}, nil, err
|
||||
}
|
||||
hasTask := make(map[uint64]bool)
|
||||
var taskSYBIDs []uint64
|
||||
if err := tx.WithContext(ctx).Table("purchase_task").Distinct("syb_product_id").
|
||||
Where("syb_product_id IS NOT NULL").Pluck("syb_product_id", &taskSYBIDs).Error; err != nil {
|
||||
return RecomputeCounts{}, nil, err
|
||||
}
|
||||
for _, id := range taskSYBIDs {
|
||||
hasTask[id] = true
|
||||
}
|
||||
activeReturnMatch := make(map[uint64]bool)
|
||||
var matchedSYBIDs []uint64
|
||||
if err := tx.WithContext(ctx).Table("return_match").Where("active_syb_product_id IS NOT NULL").Pluck("syb_product_id", &matchedSYBIDs).Error; err != nil {
|
||||
return RecomputeCounts{}, nil, err
|
||||
}
|
||||
for _, id := range matchedSYBIDs {
|
||||
activeReturnMatch[id] = true
|
||||
}
|
||||
|
||||
counts := RecomputeCounts{}
|
||||
changes := make([]recomputeChange, 0)
|
||||
for _, row := range rows {
|
||||
variation := recomputeVariationSku(row.RawJSON)
|
||||
rule := filters.Match(variation)
|
||||
wouldExclude := rule != nil
|
||||
if wouldExclude == row.PDDExcluded {
|
||||
continue
|
||||
}
|
||||
if hasTask[row.ID] {
|
||||
counts.SkippedHasTask++
|
||||
continue
|
||||
}
|
||||
if activeReturnMatch[row.ID] {
|
||||
counts.SkippedReturnMatch++
|
||||
continue
|
||||
}
|
||||
change := recomputeChange{id: row.ID, orderCode: row.OrderCode, shopeeItemID: row.ShopeeItemID, toExcluded: wouldExclude}
|
||||
if wouldExclude {
|
||||
ruleID := rule.ID
|
||||
change.ruleID, change.ruleKind, change.ruleKeyword = &ruleID, rule.Kind, rule.Keyword
|
||||
counts.PDDToExcluded++
|
||||
} else {
|
||||
counts.ExcludedToPDD++
|
||||
}
|
||||
changes = append(changes, change)
|
||||
}
|
||||
return counts, changes, nil
|
||||
}
|
||||
|
||||
// recomputeVariationSku mirrors sybimport.stringField(item.Raw, "variationSku")
|
||||
// without importing that package (sybimport already imports this one).
|
||||
func recomputeVariationSku(rawJSON string) string {
|
||||
if strings.TrimSpace(rawJSON) == "" {
|
||||
return ""
|
||||
}
|
||||
var raw map[string]any
|
||||
if json.Unmarshal([]byte(rawJSON), &raw) != nil {
|
||||
return ""
|
||||
}
|
||||
value, _ := raw["variationSku"].(string)
|
||||
return value
|
||||
}
|
||||
|
||||
// recomputeFingerprintEntry is one change's canonical, unambiguous
|
||||
// representation for hashing (#340 phase 4 review item 1). It is JSON, not
|
||||
// naive string concatenation: a naive "id:direction:ruleId" (or any other
|
||||
// delimiter-joined string) can collide between two different plans whenever
|
||||
// a field's own text can contain the delimiter or vary in length — e.g. a
|
||||
// rule keyword containing ":" or newlines could make two distinct plans hash
|
||||
// identically. encoding/json's field ordering for a fixed struct is stable,
|
||||
// so this is both deterministic and injective for our purposes.
|
||||
type recomputeFingerprintEntry struct {
|
||||
ID uint64 `json:"id"`
|
||||
Direction string `json:"direction"`
|
||||
RuleID uint64 `json:"ruleId"`
|
||||
RuleKind string `json:"ruleKind"`
|
||||
RuleKeyword string `json:"ruleKeyword"`
|
||||
}
|
||||
|
||||
// recomputeFingerprint binds a preview to the exact plan it showed, INCLUDING
|
||||
// the rule evidence that will be written to excluded_rule_kind/
|
||||
// excluded_rule_keyword (#340 phase 4 review item 1): two plans that flip the
|
||||
// exact same id+direction but via a different (or since-edited) rule must
|
||||
// hash differently, because RecomputeExecute is about to persist exactly
|
||||
// this rule kind/keyword as this row's excluded_rule_* snapshot — a
|
||||
// fingerprint that ignored them could let a stale plan through unnoticed
|
||||
// whenever a rule's keyword/kind changed between preview and execute but the
|
||||
// set of affected ids/directions happened to stay the same. For the
|
||||
// excluded_to_pdd direction there is no rule (the row is losing its mark),
|
||||
// so RuleID/RuleKind/RuleKeyword are left at their zero values, matching what
|
||||
// gets written (nil/""/"").
|
||||
//
|
||||
// It is a sha256 over the JSON-encoded, sorted (by id, then direction) list
|
||||
// of recomputeFingerprintEntry — sorting the decoded entries themselves
|
||||
// (not pre-serialized strings) keeps the ordering rule obviously correct
|
||||
// regardless of how any field is later escaped.
|
||||
func recomputeFingerprint(changes []recomputeChange) string {
|
||||
entries := make([]recomputeFingerprintEntry, 0, len(changes))
|
||||
for _, change := range changes {
|
||||
entry := recomputeFingerprintEntry{ID: change.id}
|
||||
if change.toExcluded {
|
||||
entry.Direction = DirectionPDDToExcluded
|
||||
if change.ruleID != nil {
|
||||
entry.RuleID = *change.ruleID
|
||||
}
|
||||
entry.RuleKind = change.ruleKind
|
||||
entry.RuleKeyword = change.ruleKeyword
|
||||
} else {
|
||||
entry.Direction = DirectionExcludedToPDD
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
if entries[i].ID != entries[j].ID {
|
||||
return entries[i].ID < entries[j].ID
|
||||
}
|
||||
return entries[i].Direction < entries[j].Direction
|
||||
})
|
||||
// Marshal errors are impossible here (every field is a plain string/uint64
|
||||
// with no cycles), so it is safe to ignore the error and hash whatever
|
||||
// was produced rather than plumb an error return through every caller.
|
||||
payload, _ := json.Marshal(entries)
|
||||
sum := sha256.Sum256(payload)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// RecomputePreviewResult is preview-only: it carries the same RecomputeCounts
|
||||
// execute would produce absent any race, plus up to maxRecomputeSamples
|
||||
// sample rows, and the Fingerprint execute requires to prove nothing moved
|
||||
// between preview and execute.
|
||||
type RecomputePreviewResult struct {
|
||||
RecomputeCounts
|
||||
Samples []RecomputeSample `json:"samples"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
}
|
||||
|
||||
// RecomputePreview is read-only.
|
||||
func (s *Service) RecomputePreview(ctx context.Context) (RecomputePreviewResult, error) {
|
||||
counts, changes, err := recomputeChanges(ctx, s.DB)
|
||||
if err != nil {
|
||||
return RecomputePreviewResult{}, internal(err)
|
||||
}
|
||||
samples := make([]RecomputeSample, 0, maxRecomputeSamples)
|
||||
for _, change := range changes {
|
||||
if len(samples) >= maxRecomputeSamples {
|
||||
break
|
||||
}
|
||||
sample := RecomputeSample{OrderCode: change.orderCode, ShopeeItemID: change.shopeeItemID}
|
||||
if change.toExcluded {
|
||||
sample.Direction = DirectionPDDToExcluded
|
||||
sample.RuleKind, sample.RuleKeyword = change.ruleKind, change.ruleKeyword
|
||||
} else {
|
||||
sample.Direction = DirectionExcludedToPDD
|
||||
}
|
||||
samples = append(samples, sample)
|
||||
}
|
||||
return RecomputePreviewResult{RecomputeCounts: counts, Samples: samples, Fingerprint: recomputeFingerprint(changes)}, nil
|
||||
}
|
||||
|
||||
type RecomputeExecuteResult struct {
|
||||
RecomputeCounts
|
||||
Operator string `json:"operator"`
|
||||
}
|
||||
|
||||
// countPurchaseTasksLocked and countActiveReturnMatchesLocked are the two
|
||||
// rechecks writeRecomputeChanges runs after taking the syb_product row lock.
|
||||
//
|
||||
// `[必须]` They MUST use a locking read (FOR SHARE), not a plain COUNT(*).
|
||||
// Production and local are MySQL 8.4 under REPEATABLE-READ, where a plain
|
||||
// read inside a transaction reuses the snapshot taken at that transaction's
|
||||
// FIRST read (here, recomputeChanges' own SELECT) — so a purchase_task or
|
||||
// return_match row committed by another connection AFTER that snapshot is
|
||||
// invisible to a plain COUNT(*) even after this code has waited for and
|
||||
// obtained the syb_product row's FOR UPDATE lock. The row lock only
|
||||
// serializes writers against each other; it does not by itself make a later
|
||||
// plain read see newer committed data under REPEATABLE-READ. A locking read
|
||||
// (FOR SHARE is enough since these two only need to observe committed rows,
|
||||
// not lock them for update) forces MySQL to use a fresh "current read"
|
||||
// instead of the snapshot, which is exactly what closes the race (verified
|
||||
// against real MySQL 8.4 with two connections: after the other transaction
|
||||
// committed a task, plain COUNT returned 0 while COUNT ... FOR SHARE
|
||||
// correctly returned 1). SQLite — used by this package's tests — drops
|
||||
// locking clauses entirely (gorm.io/driver/sqlite's "FOR" clause builder is
|
||||
// a no-op) and has no multi-connection snapshot semantics to reproduce this
|
||||
// race in the first place, so no SQLite-backed test can catch a regression
|
||||
// here; see TestRecheckQueriesUseLockingReads below for the SQL-shape test
|
||||
// that does.
|
||||
// sybProductRowLockQuery, purchaseTaskLockedQuery and returnMatchLockedQuery
|
||||
// build (but do not execute) the three locking reads writeRecomputeChanges
|
||||
// runs per row. They are split out from the count*/lock helpers below purely
|
||||
// so a test can call db.ToSQL against the exact same query construction the
|
||||
// production code runs, and assert the FOR UPDATE / FOR SHARE clause is
|
||||
// actually present in the generated SQL (#340 phase 3 review follow-up).
|
||||
func sybProductRowLockQuery(tx *gorm.DB) *gorm.DB {
|
||||
return tx.Clauses(clause.Locking{Strength: clause.LockingStrengthUpdate})
|
||||
}
|
||||
|
||||
func purchaseTaskLockedQuery(tx *gorm.DB, sybID uint64) *gorm.DB {
|
||||
return tx.Clauses(clause.Locking{Strength: clause.LockingStrengthShare}).
|
||||
Model(&models.PurchaseTask{}).Where("syb_product_id = ?", sybID)
|
||||
}
|
||||
|
||||
func returnMatchLockedQuery(tx *gorm.DB, sybID uint64) *gorm.DB {
|
||||
return tx.Clauses(clause.Locking{Strength: clause.LockingStrengthShare}).
|
||||
Table("return_match").Where("syb_product_id = ? AND active_syb_product_id IS NOT NULL", sybID)
|
||||
}
|
||||
|
||||
func countPurchaseTasksLocked(ctx context.Context, tx *gorm.DB, sybID uint64) (int64, error) {
|
||||
var count int64
|
||||
err := purchaseTaskLockedQuery(tx.WithContext(ctx), sybID).Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
func countActiveReturnMatchesLocked(ctx context.Context, tx *gorm.DB, sybID uint64) (int64, error) {
|
||||
var count int64
|
||||
err := returnMatchLockedQuery(tx.WithContext(ctx), sybID).Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
// writeRecomputeChanges is the write phase, kept separate from planning so it
|
||||
// is independently testable (#340 phase 3 review item 1): for every planned
|
||||
// change it takes the SAME row lock purchase.Service.create and returnmatch's
|
||||
// matchOneWithLock take (clause.Locking{Strength:"UPDATE"}), then re-checks
|
||||
// under that lock — a purchase task or active return match created for the
|
||||
// row after planning skips it (counted the same as a plan-time skip), and a
|
||||
// row whose mark already matches the target (e.g. flipped by a concurrent
|
||||
// recompute) is left alone and not counted at all. Only rows that still need
|
||||
// the change are written. The returned counts are what was ACTUALLY done.
|
||||
func writeRecomputeChanges(ctx context.Context, tx *gorm.DB, planned []recomputeChange) (RecomputeCounts, error) {
|
||||
actual := RecomputeCounts{}
|
||||
now := time.Now().UTC()
|
||||
for _, change := range planned {
|
||||
var locked models.SYBProduct
|
||||
if err := sybProductRowLockQuery(tx.WithContext(ctx)).First(&locked, change.id).Error; err != nil {
|
||||
return RecomputeCounts{}, err
|
||||
}
|
||||
taskCount, err := countPurchaseTasksLocked(ctx, tx, change.id)
|
||||
if err != nil {
|
||||
return RecomputeCounts{}, err
|
||||
}
|
||||
if taskCount > 0 {
|
||||
actual.SkippedHasTask++
|
||||
continue
|
||||
}
|
||||
matchCount, err := countActiveReturnMatchesLocked(ctx, tx, change.id)
|
||||
if err != nil {
|
||||
return RecomputeCounts{}, err
|
||||
}
|
||||
if matchCount > 0 {
|
||||
actual.SkippedReturnMatch++
|
||||
continue
|
||||
}
|
||||
if locked.PDDExcluded == change.toExcluded {
|
||||
// Already at the target (e.g. another recompute got there first
|
||||
// under its own lock) — nothing to do, and this is not a
|
||||
// "skipped" outcome in the reported sense since there was never
|
||||
// a real conflict blocking it.
|
||||
continue
|
||||
}
|
||||
updates := map[string]any{"pdd_purchase_excluded": change.toExcluded}
|
||||
if change.toExcluded {
|
||||
updates["excluded_rule_id"] = change.ruleID
|
||||
updates["excluded_rule_kind"] = change.ruleKind
|
||||
updates["excluded_rule_keyword"] = change.ruleKeyword
|
||||
updates["excluded_at"] = now
|
||||
} else {
|
||||
updates["excluded_rule_id"] = nil
|
||||
updates["excluded_rule_kind"] = ""
|
||||
updates["excluded_rule_keyword"] = ""
|
||||
updates["excluded_at"] = nil
|
||||
}
|
||||
if err := tx.WithContext(ctx).Model(&models.SYBProduct{}).Where("id = ?", change.id).Updates(updates).Error; err != nil {
|
||||
return RecomputeCounts{}, err
|
||||
}
|
||||
if change.toExcluded {
|
||||
actual.PDDToExcluded++
|
||||
} else {
|
||||
actual.ExcludedToPDD++
|
||||
}
|
||||
}
|
||||
return actual, nil
|
||||
}
|
||||
|
||||
// RecomputeExecute re-validates the plan against a fingerprint the caller
|
||||
// must have gotten from a RecomputePreview call (#340 phase 3 review item 2):
|
||||
// if the current plan's fingerprint no longer matches — the underlying data
|
||||
// or the enabled rules changed since that preview — nothing is written and
|
||||
// CodeRecomputeStale is returned. Otherwise every planned change is written
|
||||
// through writeRecomputeChanges under its own per-row lock, and one audit log
|
||||
// row is written in the same transaction using the ACTUAL counts.
|
||||
func (s *Service) RecomputeExecute(ctx context.Context, operator, fingerprint string) (RecomputeExecuteResult, error) {
|
||||
fingerprint = strings.TrimSpace(fingerprint)
|
||||
if fingerprint == "" {
|
||||
return RecomputeExecuteResult{}, invalid("fingerprint 不能为空,请先调用预览接口")
|
||||
}
|
||||
var result RecomputeExecuteResult
|
||||
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
planCounts, changes, err := recomputeChanges(ctx, tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if recomputeFingerprint(changes) != fingerprint {
|
||||
return &ServiceError{Code: CodeRecomputeStale, Message: "数据或规则已变化,请重新预览后再执行"}
|
||||
}
|
||||
written, err := writeRecomputeChanges(ctx, tx, changes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// The ACTUAL, reported counts combine what planning already knew was
|
||||
// ineligible (rows never even handed to the write phase) with what
|
||||
// the write phase itself caught under lock — so a plan-time skip and
|
||||
// a write-time race both show up, and Excluded/PDD counts are always
|
||||
// exactly what got written (#340 phase 3 review item 1c).
|
||||
actual := RecomputeCounts{
|
||||
ExcludedToPDD: written.ExcludedToPDD,
|
||||
PDDToExcluded: written.PDDToExcluded,
|
||||
SkippedHasTask: planCounts.SkippedHasTask + written.SkippedHasTask,
|
||||
SkippedReturnMatch: planCounts.SkippedReturnMatch + written.SkippedReturnMatch,
|
||||
}
|
||||
log := models.SYBProductFilterRecomputeLog{
|
||||
Operator: strings.TrimSpace(operator), ExcludedToPDD: actual.ExcludedToPDD,
|
||||
PDDToExcluded: actual.PDDToExcluded, SkippedHasTask: actual.SkippedHasTask,
|
||||
SkippedReturnMatch: actual.SkippedReturnMatch,
|
||||
}
|
||||
if err := tx.Create(&log).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
result = RecomputeExecuteResult{RecomputeCounts: actual, Operator: log.Operator}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
if se, ok := err.(*ServiceError); ok {
|
||||
return RecomputeExecuteResult{}, se
|
||||
}
|
||||
return RecomputeExecuteResult{}, internal(err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package sybproductfilter
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// mysqlDryRunDB opens a gorm session against the MySQL dialector with
|
||||
// DryRun+DisableAutomaticPing, so no real network connection is ever made
|
||||
// (sql.Open is lazy and gorm skips the startup ping) but the SQL gorm would
|
||||
// send to a real MySQL 8.4 server can still be inspected via db.ToSQL.
|
||||
//
|
||||
// `[必须]` This must be the MySQL dialector, not SQLite: gorm.io/driver/
|
||||
// sqlite's own "FOR" clause builder silently drops clause.Locking entirely
|
||||
// (SQLite has no row-level locking), so a SQLite-backed test would show
|
||||
// these queries with no FOR clause at all regardless of whether the
|
||||
// production code asks for one — it would pass even with the bug this test
|
||||
// exists to catch. Only a MySQL-dialect SQL string proves the FOR UPDATE /
|
||||
// FOR SHARE clauses are actually being sent.
|
||||
func mysqlDryRunDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
// SkipInitializeWithVersion is required, not just DisableAutomaticPing:
|
||||
// gorm's MySQL dialector otherwise runs `SELECT VERSION()` against the
|
||||
// DSN's ConnPool during Initialize (Open), before DryRun/ping settings
|
||||
// even come into play, to decide version-gated feature flags such as
|
||||
// DontSupportForShareClause. With it set, sql.Open's lazy connection
|
||||
// pool is never dialed at all.
|
||||
db, err := gorm.Open(mysql.New(mysql.Config{
|
||||
DSN: "user:pass@tcp(127.0.0.1:3306)/goauto_test?parseTime=true", SkipInitializeWithVersion: true,
|
||||
}), &gorm.Config{DryRun: true, DisableAutomaticPing: true})
|
||||
if err != nil {
|
||||
t.Fatalf("open dry-run mysql session: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
// TestRecheckQueriesUseLockingReads pins the exact SQL shape behind #340
|
||||
// phase 3's real fix: writeRecomputeChanges' syb_product row lock must be
|
||||
// FOR UPDATE, and its two rechecks (purchase_task, return_match) must be
|
||||
// FOR SHARE — a plain COUNT(*) for either recheck is invisible to a
|
||||
// transaction's already-taken REPEATABLE-READ snapshot on real MySQL 8.4
|
||||
// even after the row's FOR UPDATE lock is granted, which is exactly the race
|
||||
// this test guards against ever regressing to (verified against a real
|
||||
// MySQL 8.4 server with two connections: a purchase_task committed by the
|
||||
// other connection after the snapshot was invisible to plain COUNT(*), but
|
||||
// visible to COUNT(*) ... FOR SHARE). SQLite, which every other test in this
|
||||
// package runs against, cannot reproduce any of this: it has no
|
||||
// multi-connection snapshot isolation and gorm's SQLite driver drops locking
|
||||
// clauses outright, so this test intentionally talks MySQL SQL shape only,
|
||||
// never a real database.
|
||||
func TestRecheckQueriesUseLockingReads(t *testing.T) {
|
||||
db := mysqlDryRunDB(t)
|
||||
|
||||
rowLockSQL := db.ToSQL(func(tx *gorm.DB) *gorm.DB {
|
||||
var dest map[string]any
|
||||
return sybProductRowLockQuery(tx).Table("syb_product").Where("id = ?", uint64(1)).Find(&dest)
|
||||
})
|
||||
t.Logf("ROW LOCK SQL: %s", rowLockSQL)
|
||||
if !strings.Contains(rowLockSQL, "FOR UPDATE") {
|
||||
t.Fatalf("expected the syb_product row lock to be FOR UPDATE, got SQL: %s", rowLockSQL)
|
||||
}
|
||||
|
||||
var taskCount int64
|
||||
taskSQL := db.ToSQL(func(tx *gorm.DB) *gorm.DB {
|
||||
return purchaseTaskLockedQuery(tx, 1).Count(&taskCount)
|
||||
})
|
||||
t.Logf("PURCHASE TASK RECHECK SQL: %s", taskSQL)
|
||||
if !strings.Contains(taskSQL, "FOR SHARE") {
|
||||
t.Fatalf("expected the purchase_task recheck to be a locking (FOR SHARE) read, got SQL: %s", taskSQL)
|
||||
}
|
||||
if !strings.Contains(taskSQL, "syb_product_id") {
|
||||
t.Fatalf("expected the purchase_task recheck to filter by syb_product_id, got SQL: %s", taskSQL)
|
||||
}
|
||||
|
||||
var matchCount int64
|
||||
matchSQL := db.ToSQL(func(tx *gorm.DB) *gorm.DB {
|
||||
return returnMatchLockedQuery(tx, 1).Count(&matchCount)
|
||||
})
|
||||
t.Logf("RETURN MATCH RECHECK SQL: %s", matchSQL)
|
||||
if !strings.Contains(matchSQL, "FOR SHARE") {
|
||||
t.Fatalf("expected the return_match recheck to be a locking (FOR SHARE) read, got SQL: %s", matchSQL)
|
||||
}
|
||||
if !strings.Contains(matchSQL, "active_syb_product_id IS NOT NULL") {
|
||||
t.Fatalf("expected the return_match recheck to filter on an active match, got SQL: %s", matchSQL)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
package sybproductfilter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/migrations"
|
||||
"go-admin/app/goauto/models"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// dsnPathReplacer swaps the database name in a Go MySQL DSN of the form
|
||||
// user:pass@tcp(host:port)/dbname?params — used to connect first to the
|
||||
// server (no specific throwaway database yet) and then to the freshly
|
||||
// created throwaway database.
|
||||
var dsnPathReplacer = regexp.MustCompile(`^(.*/)([^/?]*)(\?.*)?$`)
|
||||
|
||||
func dsnWithDatabase(dsn, dbName string) string {
|
||||
if dsnPathReplacer.MatchString(dsn) {
|
||||
return dsnPathReplacer.ReplaceAllString(dsn, "${1}"+dbName+"${3}")
|
||||
}
|
||||
return dsn
|
||||
}
|
||||
|
||||
// setupMySQLIntegrationDB is #340 phase 4 review item 2's throwaway-database
|
||||
// harness: it never touches an existing database. GOAUTO_IT_MYSQL_DSN must
|
||||
// point at a MySQL SERVER (any connectable path, e.g. the system "mysql"
|
||||
// database) with permission to CREATE/DROP DATABASE; a uniquely named
|
||||
// zz_goauto_it_340_<random> database is created, migrated, and guaranteed
|
||||
// dropped via t.Cleanup even if the test fails or panics.
|
||||
func setupMySQLIntegrationDB(t *testing.T) (dsn string, dbName string) {
|
||||
t.Helper()
|
||||
baseDSN := os.Getenv("GOAUTO_IT_MYSQL_DSN")
|
||||
if baseDSN == "" {
|
||||
t.Skip("GOAUTO_IT_MYSQL_DSN not set; skipping MySQL concurrency integration test")
|
||||
}
|
||||
admin, err := sql.Open("mysql", baseDSN)
|
||||
if err != nil {
|
||||
t.Fatalf("open admin connection: %v", err)
|
||||
}
|
||||
if err := admin.Ping(); err != nil {
|
||||
admin.Close()
|
||||
t.Fatalf("ping MySQL server: %v", err)
|
||||
}
|
||||
dbName = fmt.Sprintf("zz_goauto_it_340_%d", time.Now().UnixNano())
|
||||
if _, err := admin.Exec("CREATE DATABASE `" + dbName + "`"); err != nil {
|
||||
admin.Close()
|
||||
t.Fatalf("create throwaway database %s: %v", dbName, err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
defer admin.Close()
|
||||
if _, err := admin.Exec("DROP DATABASE IF EXISTS `" + dbName + "`"); err != nil {
|
||||
t.Errorf("failed to drop throwaway database %s (manual cleanup required): %v", dbName, err)
|
||||
}
|
||||
})
|
||||
|
||||
dsn = dsnWithDatabase(baseDSN, dbName)
|
||||
gdb, err := gorm.Open(mysql.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
if err != nil {
|
||||
t.Fatalf("open throwaway database: %v", err)
|
||||
}
|
||||
if err := migrations.Migrate(gdb); err != nil {
|
||||
t.Fatalf("migrate throwaway database: %v", err)
|
||||
}
|
||||
return dsn, dbName
|
||||
}
|
||||
|
||||
func newMySQLIntegrationConn(t *testing.T, dsn string) *gorm.DB {
|
||||
t.Helper()
|
||||
conn, err := gorm.Open(mysql.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
if err != nil {
|
||||
t.Fatalf("open MySQL connection: %v", err)
|
||||
}
|
||||
return conn
|
||||
}
|
||||
|
||||
// TestRecomputeConcurrentPurchaseTaskUnderRealMySQL is #340 phase 4 review
|
||||
// item 2: it reproduces, against a real MySQL server under REPEATABLE-READ,
|
||||
// the exact race writeRecomputeChanges' locking rechecks exist to close.
|
||||
//
|
||||
// Timeline:
|
||||
// 1. Connection A begins a transaction and runs the planning step
|
||||
// (recomputeChanges) — this is A's FIRST read, so it fixes A's
|
||||
// REPEATABLE-READ snapshot with zero purchase_task rows.
|
||||
// 2. Connection B, concurrently, takes the SAME row's FOR UPDATE lock,
|
||||
// confirmed via a channel before A is allowed to proceed.
|
||||
// 3. A's write phase (writeRecomputeChanges) is started in a goroutine; it
|
||||
// must BLOCK trying to take the same FOR UPDATE lock B already holds —
|
||||
// the test asserts A has NOT finished after a wait window, proving a
|
||||
// real block happened (not just a fast, uncontended lock grant).
|
||||
// 4. B inserts a purchase_task for the row and commits, releasing the lock.
|
||||
// 5. A's write phase unblocks, re-checks purchase_task under lock, and must
|
||||
// see B's now-committed row and skip — this only holds because the
|
||||
// recheck is a locking (FOR SHARE) read; a plain COUNT(*) would still be
|
||||
// bound to A's step-1 snapshot (zero rows) and would wrongly write.
|
||||
func TestRecomputeConcurrentPurchaseTaskUnderRealMySQL(t *testing.T) {
|
||||
dsn, _ := setupMySQLIntegrationDB(t)
|
||||
seedConn := newMySQLIntegrationConn(t, dsn)
|
||||
|
||||
rule := models.SYBProductFilter{Kind: "keyword", Keyword: "档口", NormalizedKeyword: "档口", Enabled: true}
|
||||
if err := seedConn.Create(&rule).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
row := models.SYBProduct{
|
||||
OrderCode: "ORD-IT-TASK", DetailID: 1, StockID: 1, ShopeeItemID: "1", Quantity: 1,
|
||||
ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"档口-1"}`,
|
||||
}
|
||||
if err := seedConn.Create(&row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// purchase_task.pdd_product_id has a real FK (unlike this package's
|
||||
// SQLite-backed tests, which don't enable foreign key enforcement) —
|
||||
// MySQL requires an actual pdd_product row to reference.
|
||||
pdd := models.PDDProduct{GoodsID: "IT-PDD-1", URL: "https://example.invalid/it", SpecsJSON: "[]"}
|
||||
if err := seedConn.Create(&pdd).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
connA := newMySQLIntegrationConn(t, dsn)
|
||||
connB := newMySQLIntegrationConn(t, dsn)
|
||||
ctx := context.Background()
|
||||
|
||||
txA := connA.Begin()
|
||||
// Guard against ANY early return (t.Fatalf, panic) leaving txA open: an
|
||||
// abandoned open transaction holds a connection into this throwaway
|
||||
// database and blocks the DROP DATABASE cleanup indefinitely. Rollback
|
||||
// on an already-committed transaction is a harmless no-op error, which
|
||||
// is why the plain Commit() path below intentionally does not disable
|
||||
// this cleanup.
|
||||
t.Cleanup(func() { txA.Rollback() })
|
||||
_, planned, err := recomputeChanges(ctx, txA)
|
||||
if err != nil {
|
||||
t.Fatalf("plan: %v", err)
|
||||
}
|
||||
if len(planned) != 1 || planned[0].id != row.ID {
|
||||
t.Fatalf("expected exactly the seeded row to be planned, got %+v", planned)
|
||||
}
|
||||
|
||||
txB := connB.Begin()
|
||||
t.Cleanup(func() { txB.Rollback() })
|
||||
|
||||
bHoldingLock := make(chan struct{})
|
||||
bCanCommit := make(chan struct{})
|
||||
bDone := make(chan error, 1)
|
||||
go func() {
|
||||
var locked models.SYBProduct
|
||||
if err := txB.Clauses(clause.Locking{Strength: clause.LockingStrengthUpdate}).
|
||||
First(&locked, row.ID).Error; err != nil {
|
||||
bDone <- fmt.Errorf("B lock row: %w", err)
|
||||
return
|
||||
}
|
||||
close(bHoldingLock)
|
||||
<-bCanCommit
|
||||
task := models.PurchaseTask{
|
||||
SYBProductID: &row.ID, PDDProductID: pdd.ID, Quantity: 1, CreateRequestID: "it-race-task",
|
||||
Status: models.PurchaseTaskStatusFailed, ExecutionMode: models.PurchaseExecutionModeLive,
|
||||
TaskType: models.PurchaseTaskTypeSYBOrder, RuleSnapshot: "{}",
|
||||
}
|
||||
if err := txB.Create(&task).Error; err != nil {
|
||||
bDone <- fmt.Errorf("B insert task: %w", err)
|
||||
return
|
||||
}
|
||||
bDone <- txB.Commit().Error
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-bHoldingLock:
|
||||
case err := <-bDone:
|
||||
t.Fatalf("B failed before taking the row lock: %v", err)
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timed out waiting for B to take the row lock")
|
||||
}
|
||||
|
||||
aDone := make(chan struct{})
|
||||
var aActual RecomputeCounts
|
||||
var aErr error
|
||||
go func() {
|
||||
aActual, aErr = writeRecomputeChanges(ctx, txA, planned)
|
||||
close(aDone)
|
||||
}()
|
||||
|
||||
// A must still be blocked on B's row lock at this point — this is the
|
||||
// test's proof that a real MySQL row lock, not just program logic, is
|
||||
// what's being exercised.
|
||||
select {
|
||||
case <-aDone:
|
||||
t.Fatal("A's write phase returned before B committed — it should have blocked on the row's FOR UPDATE lock")
|
||||
case <-time.After(300 * time.Millisecond):
|
||||
}
|
||||
|
||||
close(bCanCommit)
|
||||
if err := <-bDone; err != nil {
|
||||
t.Fatalf("B failed: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-aDone:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timed out waiting for A's write phase to unblock after B committed")
|
||||
}
|
||||
if aErr != nil {
|
||||
t.Fatalf("A's write phase failed: %v", aErr)
|
||||
}
|
||||
if err := txA.Commit().Error; err != nil {
|
||||
t.Fatalf("commit A: %v", err)
|
||||
}
|
||||
|
||||
if aActual.SkippedHasTask != 1 || aActual.PDDToExcluded != 0 {
|
||||
t.Fatalf("expected A to skip the row for the concurrently-created task, got %+v", aActual)
|
||||
}
|
||||
var reloaded models.SYBProduct
|
||||
if err := seedConn.First(&reloaded, row.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reloaded.PDDExcluded {
|
||||
t.Fatalf("row must not have been marked excluded — the concurrent task should have blocked it: %+v", reloaded)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecomputeConcurrentReturnMatchUnderRealMySQL is the same scenario as
|
||||
// TestRecomputeConcurrentPurchaseTaskUnderRealMySQL, with an active
|
||||
// return_match row instead of a purchase_task as B's concurrent write.
|
||||
func TestRecomputeConcurrentReturnMatchUnderRealMySQL(t *testing.T) {
|
||||
dsn, _ := setupMySQLIntegrationDB(t)
|
||||
seedConn := newMySQLIntegrationConn(t, dsn)
|
||||
|
||||
rule := models.SYBProductFilter{Kind: "keyword", Keyword: "档口", NormalizedKeyword: "档口", Enabled: true}
|
||||
if err := seedConn.Create(&rule).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
row := models.SYBProduct{
|
||||
OrderCode: "ORD-IT-MATCH", DetailID: 1, StockID: 1, ShopeeItemID: "1", Quantity: 1,
|
||||
ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"档口-1"}`,
|
||||
}
|
||||
if err := seedConn.Create(&row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
yeekeItem := models.YeekeReturnItem{PackageID: 0, ExternalKey: "it-race-return", ItemID: "1", VariationName: "档口-1", LastSyncedAt: time.Now()}
|
||||
// A package row is required by the return_match/yeeke schema's foreign
|
||||
// key; seed a minimal one.
|
||||
pkg := models.YeekeReturnPackage{ExternalID: "it-race-pkg", OrderSN: "IT-ORD", TrackingNo: "IT-TRK", LastSyncedAt: time.Now()}
|
||||
if err := seedConn.Create(&pkg).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
yeekeItem.PackageID = pkg.ID
|
||||
if err := seedConn.Create(&yeekeItem).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
connA := newMySQLIntegrationConn(t, dsn)
|
||||
connB := newMySQLIntegrationConn(t, dsn)
|
||||
ctx := context.Background()
|
||||
|
||||
txA := connA.Begin()
|
||||
// See TestRecomputeConcurrentPurchaseTaskUnderRealMySQL for why this
|
||||
// unconditional cleanup is necessary regardless of the Commit() below.
|
||||
t.Cleanup(func() { txA.Rollback() })
|
||||
_, planned, err := recomputeChanges(ctx, txA)
|
||||
if err != nil {
|
||||
t.Fatalf("plan: %v", err)
|
||||
}
|
||||
if len(planned) != 1 || planned[0].id != row.ID {
|
||||
t.Fatalf("expected exactly the seeded row to be planned, got %+v", planned)
|
||||
}
|
||||
|
||||
txB := connB.Begin()
|
||||
t.Cleanup(func() { txB.Rollback() })
|
||||
|
||||
bHoldingLock := make(chan struct{})
|
||||
bCanCommit := make(chan struct{})
|
||||
bDone := make(chan error, 1)
|
||||
go func() {
|
||||
var locked models.SYBProduct
|
||||
if err := txB.Clauses(clause.Locking{Strength: clause.LockingStrengthUpdate}).
|
||||
First(&locked, row.ID).Error; err != nil {
|
||||
bDone <- fmt.Errorf("B lock row: %w", err)
|
||||
return
|
||||
}
|
||||
close(bHoldingLock)
|
||||
<-bCanCommit
|
||||
match := models.ReturnMatch{
|
||||
SYBProductID: row.ID, YeekeReturnItemID: yeekeItem.ID,
|
||||
ActiveSYBProductID: &row.ID, Status: models.ReturnMatchStatusMatched, MatchedAt: time.Now(),
|
||||
}
|
||||
if err := txB.Create(&match).Error; err != nil {
|
||||
bDone <- fmt.Errorf("B insert match: %w", err)
|
||||
return
|
||||
}
|
||||
bDone <- txB.Commit().Error
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-bHoldingLock:
|
||||
case err := <-bDone:
|
||||
t.Fatalf("B failed before taking the row lock: %v", err)
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timed out waiting for B to take the row lock")
|
||||
}
|
||||
|
||||
aDone := make(chan struct{})
|
||||
var aActual RecomputeCounts
|
||||
var aErr error
|
||||
go func() {
|
||||
aActual, aErr = writeRecomputeChanges(ctx, txA, planned)
|
||||
close(aDone)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-aDone:
|
||||
t.Fatal("A's write phase returned before B committed — it should have blocked on the row's FOR UPDATE lock")
|
||||
case <-time.After(300 * time.Millisecond):
|
||||
}
|
||||
|
||||
close(bCanCommit)
|
||||
if err := <-bDone; err != nil {
|
||||
t.Fatalf("B failed: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-aDone:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timed out waiting for A's write phase to unblock after B committed")
|
||||
}
|
||||
if aErr != nil {
|
||||
t.Fatalf("A's write phase failed: %v", aErr)
|
||||
}
|
||||
if err := txA.Commit().Error; err != nil {
|
||||
t.Fatalf("commit A: %v", err)
|
||||
}
|
||||
|
||||
if aActual.SkippedReturnMatch != 1 || aActual.PDDToExcluded != 0 {
|
||||
t.Fatalf("expected A to skip the row for the concurrently-created return match, got %+v", aActual)
|
||||
}
|
||||
var reloaded models.SYBProduct
|
||||
if err := seedConn.First(&reloaded, row.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reloaded.PDDExcluded {
|
||||
t.Fatalf("row must not have been marked excluded — the concurrent return match should have blocked it: %+v", reloaded)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,496 @@
|
||||
package sybproductfilter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestRecomputePreviewMatchesExecute(t *testing.T) {
|
||||
db := testDB(t)
|
||||
// A row that currently needs a PDD purchase but now matches a keyword rule.
|
||||
pddToExcluded := models.SYBProduct{
|
||||
OrderCode: "ORD-1", DetailID: 1, StockID: 1, ShopeeItemID: "1", Quantity: 1,
|
||||
ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"档口-1"}`,
|
||||
}
|
||||
if err := db.Create(&pddToExcluded).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A row currently marked excluded whose rule no longer matches.
|
||||
excludedToPDD := models.SYBProduct{
|
||||
OrderCode: "ORD-2", DetailID: 2, StockID: 2, ShopeeItemID: "2", Quantity: 1,
|
||||
ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"普通-2"}`,
|
||||
PDDExcluded: true,
|
||||
}
|
||||
if err := db.Create(&excludedToPDD).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A row that should flip, but has a purchase task -> must be skipped.
|
||||
hasTask := models.SYBProduct{
|
||||
OrderCode: "ORD-3", DetailID: 3, StockID: 3, ShopeeItemID: "3", Quantity: 1,
|
||||
ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"档口-3"}`,
|
||||
}
|
||||
if err := db.Create(&hasTask).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&models.PurchaseTask{SYBProductID: &hasTask.ID, PDDProductID: 1, Quantity: 1, CreateRequestID: "req-3", Status: models.PurchaseTaskStatusFailed, ExecutionMode: models.PurchaseExecutionModeLive, TaskType: models.PurchaseTaskTypeSYBOrder, RuleSnapshot: "{}"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A row that should flip, but has an active return match -> must be skipped.
|
||||
hasMatch := models.SYBProduct{
|
||||
OrderCode: "ORD-4", DetailID: 4, StockID: 4, ShopeeItemID: "4", Quantity: 1,
|
||||
ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"档口-4"}`,
|
||||
}
|
||||
if err := db.Create(&hasMatch).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&models.ReturnMatch{SYBProductID: hasMatch.ID, YeekeReturnItemID: 1, ActiveSYBProductID: &hasMatch.ID, Status: models.ReturnMatchStatusMatched}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&models.SYBProductFilter{Kind: "keyword", Keyword: "档口", NormalizedKeyword: "档口", Enabled: true}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s := NewService(db)
|
||||
preview, err := s.RecomputePreview(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if preview.PDDToExcluded != 1 || preview.ExcludedToPDD != 1 || preview.SkippedHasTask != 1 || preview.SkippedReturnMatch != 1 {
|
||||
t.Fatalf("unexpected preview counts: %+v", preview)
|
||||
}
|
||||
|
||||
result, err := s.RecomputeExecute(context.Background(), "admin1", preview.Fingerprint)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.RecomputeCounts != preview.RecomputeCounts {
|
||||
t.Fatalf("execute counts must match preview: preview=%+v execute=%+v", preview.RecomputeCounts, result.RecomputeCounts)
|
||||
}
|
||||
if len(preview.Samples) != 2 {
|
||||
t.Fatalf("expected 2 sample rows, got %+v", preview.Samples)
|
||||
}
|
||||
var sawPDDToExcluded, sawExcludedToPDD bool
|
||||
for _, sample := range preview.Samples {
|
||||
switch sample.Direction {
|
||||
case DirectionPDDToExcluded:
|
||||
sawPDDToExcluded = true
|
||||
if sample.OrderCode != "ORD-1" || sample.RuleKeyword != "档口" {
|
||||
t.Fatalf("unexpected pdd_to_excluded sample: %+v", sample)
|
||||
}
|
||||
case DirectionExcludedToPDD:
|
||||
sawExcludedToPDD = true
|
||||
if sample.OrderCode != "ORD-2" || sample.RuleKind != "" || sample.RuleKeyword != "" {
|
||||
t.Fatalf("unexpected excluded_to_pdd sample: %+v", sample)
|
||||
}
|
||||
default:
|
||||
t.Fatalf("unexpected direction: %+v", sample)
|
||||
}
|
||||
}
|
||||
if !sawPDDToExcluded || !sawExcludedToPDD {
|
||||
t.Fatalf("expected both directions represented: %+v", preview.Samples)
|
||||
}
|
||||
|
||||
var reloadedPDDToExcluded, reloadedExcludedToPDD, reloadedHasTask, reloadedHasMatch models.SYBProduct
|
||||
db.First(&reloadedPDDToExcluded, pddToExcluded.ID)
|
||||
db.First(&reloadedExcludedToPDD, excludedToPDD.ID)
|
||||
db.First(&reloadedHasTask, hasTask.ID)
|
||||
db.First(&reloadedHasMatch, hasMatch.ID)
|
||||
|
||||
if !reloadedPDDToExcluded.PDDExcluded || reloadedPDDToExcluded.ExcludedRuleKeyword != "档口" {
|
||||
t.Fatalf("expected row 1 to become excluded: %+v", reloadedPDDToExcluded)
|
||||
}
|
||||
if reloadedExcludedToPDD.PDDExcluded || reloadedExcludedToPDD.ExcludedRuleID != nil {
|
||||
t.Fatalf("expected row 2 to become un-excluded: %+v", reloadedExcludedToPDD)
|
||||
}
|
||||
if reloadedHasTask.PDDExcluded {
|
||||
t.Fatalf("row with a purchase task must never change: %+v", reloadedHasTask)
|
||||
}
|
||||
if reloadedHasMatch.PDDExcluded {
|
||||
t.Fatalf("row with an active return match must never change: %+v", reloadedHasMatch)
|
||||
}
|
||||
|
||||
var logs []models.SYBProductFilterRecomputeLog
|
||||
if err := db.Find(&logs).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(logs) != 1 || logs[0].Operator != "admin1" || logs[0].PDDToExcluded != 1 || logs[0].ExcludedToPDD != 1 {
|
||||
t.Fatalf("expected exactly one audit log row matching the counts: %+v", logs)
|
||||
}
|
||||
|
||||
// Preview and execute must still agree on a no-op run.
|
||||
secondPreview, err := s.RecomputePreview(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if secondPreview.PDDToExcluded != 0 || secondPreview.ExcludedToPDD != 0 {
|
||||
t.Fatalf("expected a no-op second preview, got %+v", secondPreview)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecomputePreviewCapsSamplesAt20(t *testing.T) {
|
||||
db := testDB(t)
|
||||
if err := db.Create(&models.SYBProductFilter{Kind: "keyword", Keyword: "档口", NormalizedKeyword: "档口", Enabled: true}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 25; i++ {
|
||||
row := models.SYBProduct{
|
||||
OrderCode: fmt.Sprintf("ORD-CAP-%d", i), DetailID: uint64(i + 1), StockID: uint64(i + 1),
|
||||
ShopeeItemID: fmt.Sprintf("%d", i), Quantity: 1, ParseStatus: models.SYBParseStatusSuccess,
|
||||
RawJSON: `{"variationSku":"档口-x"}`,
|
||||
}
|
||||
if err := db.Create(&row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
s := NewService(db)
|
||||
preview, err := s.RecomputePreview(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if preview.PDDToExcluded != 25 {
|
||||
t.Fatalf("expected 25 counted changes, got %d", preview.PDDToExcluded)
|
||||
}
|
||||
if len(preview.Samples) != 20 {
|
||||
t.Fatalf("expected samples capped at 20, got %d", len(preview.Samples))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecomputeExecuteRejectsStaleFingerprint(t *testing.T) {
|
||||
db := testDB(t)
|
||||
if err := db.Create(&models.SYBProductFilter{Kind: "keyword", Keyword: "档口", NormalizedKeyword: "档口", Enabled: true}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
row := models.SYBProduct{OrderCode: "ORD-STALE", DetailID: 1, StockID: 1, ShopeeItemID: "1", Quantity: 1, ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"档口-1"}`}
|
||||
if err := db.Create(&row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := NewService(db)
|
||||
preview, err := s.RecomputePreview(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if preview.PDDToExcluded != 1 {
|
||||
t.Fatalf("unexpected preview: %+v", preview)
|
||||
}
|
||||
|
||||
// Data changes after the preview: a second row appears that would also
|
||||
// flip, so the plan's fingerprint is now stale.
|
||||
row2 := models.SYBProduct{OrderCode: "ORD-STALE-2", DetailID: 2, StockID: 2, ShopeeItemID: "2", Quantity: 1, ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"档口-2"}`}
|
||||
if err := db.Create(&row2).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err = s.RecomputeExecute(context.Background(), "admin1", preview.Fingerprint)
|
||||
if err == nil {
|
||||
t.Fatalf("expected stale fingerprint to be rejected")
|
||||
}
|
||||
se, ok := err.(*ServiceError)
|
||||
if !ok || se.Code != CodeRecomputeStale {
|
||||
t.Fatalf("expected CodeRecomputeStale, got %v", err)
|
||||
}
|
||||
|
||||
var reloaded, reloaded2 models.SYBProduct
|
||||
db.First(&reloaded, row.ID)
|
||||
db.First(&reloaded2, row2.ID)
|
||||
if reloaded.PDDExcluded || reloaded2.PDDExcluded {
|
||||
t.Fatalf("stale execute must write nothing: %+v %+v", reloaded, reloaded2)
|
||||
}
|
||||
var logCount int64
|
||||
db.Model(&models.SYBProductFilterRecomputeLog{}).Count(&logCount)
|
||||
if logCount != 0 {
|
||||
t.Fatalf("stale execute must not write an audit log, got %d", logCount)
|
||||
}
|
||||
|
||||
freshPreview, err := s.RecomputePreview(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if freshPreview.PDDToExcluded != 2 {
|
||||
t.Fatalf("expected fresh preview to see both rows, got %+v", freshPreview)
|
||||
}
|
||||
if _, err := s.RecomputeExecute(context.Background(), "admin1", freshPreview.Fingerprint); err != nil {
|
||||
t.Fatalf("fresh fingerprint should be accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecomputeExecuteRejectsEmptyFingerprint(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := NewService(db)
|
||||
if _, err := s.RecomputeExecute(context.Background(), "admin1", ""); err == nil {
|
||||
t.Fatalf("expected empty fingerprint to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteRecomputeChangesSkipsRowThatGotAPurchaseTaskConcurrently is #340
|
||||
// phase 3 review item 1(a): a purchase task created for a planned row AFTER
|
||||
// planning but BEFORE the write phase must cause that row to be skipped
|
||||
// (counted as SkippedHasTask), not written.
|
||||
func TestWriteRecomputeChangesSkipsRowThatGotAPurchaseTaskConcurrently(t *testing.T) {
|
||||
db := testDB(t)
|
||||
row := models.SYBProduct{OrderCode: "ORD-RACE-TASK", DetailID: 1, StockID: 1, ShopeeItemID: "1", Quantity: 1, ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"档口-1"}`}
|
||||
if err := db.Create(&row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ruleID := uint64(9)
|
||||
planned := []recomputeChange{{id: row.ID, orderCode: row.OrderCode, shopeeItemID: row.ShopeeItemID, toExcluded: true, ruleID: &ruleID, ruleKind: "keyword", ruleKeyword: "档口"}}
|
||||
|
||||
if err := db.Create(&models.PurchaseTask{SYBProductID: &row.ID, PDDProductID: 1, Quantity: 1, CreateRequestID: "race-task", Status: models.PurchaseTaskStatusFailed, ExecutionMode: models.PurchaseExecutionModeLive, TaskType: models.PurchaseTaskTypeSYBOrder, RuleSnapshot: "{}"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var actual RecomputeCounts
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
var writeErr error
|
||||
actual, writeErr = writeRecomputeChanges(context.Background(), tx, planned)
|
||||
return writeErr
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if actual.SkippedHasTask != 1 || actual.PDDToExcluded != 0 {
|
||||
t.Fatalf("expected the row to be skipped for having a task, got %+v", actual)
|
||||
}
|
||||
var reloaded models.SYBProduct
|
||||
db.First(&reloaded, row.ID)
|
||||
if reloaded.PDDExcluded {
|
||||
t.Fatalf("row must not have been marked excluded: %+v", reloaded)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteRecomputeChangesSkipsRowWithConcurrentReturnMatch is item 1(b):
|
||||
// same race, but with an active return match instead of a purchase task.
|
||||
func TestWriteRecomputeChangesSkipsRowWithConcurrentReturnMatch(t *testing.T) {
|
||||
db := testDB(t)
|
||||
row := models.SYBProduct{OrderCode: "ORD-RACE-MATCH", DetailID: 1, StockID: 1, ShopeeItemID: "1", Quantity: 1, ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"档口-1"}`}
|
||||
if err := db.Create(&row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ruleID := uint64(9)
|
||||
planned := []recomputeChange{{id: row.ID, orderCode: row.OrderCode, shopeeItemID: row.ShopeeItemID, toExcluded: true, ruleID: &ruleID, ruleKind: "keyword", ruleKeyword: "档口"}}
|
||||
|
||||
if err := db.Create(&models.ReturnMatch{SYBProductID: row.ID, YeekeReturnItemID: 1, ActiveSYBProductID: &row.ID, Status: models.ReturnMatchStatusMatched}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var actual RecomputeCounts
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
var writeErr error
|
||||
actual, writeErr = writeRecomputeChanges(context.Background(), tx, planned)
|
||||
return writeErr
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if actual.SkippedReturnMatch != 1 || actual.PDDToExcluded != 0 {
|
||||
t.Fatalf("expected the row to be skipped for an active return match, got %+v", actual)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteRecomputeChangesReturnsCountsMatchingActualWrites is item 1(c):
|
||||
// given a plan with one row that should write cleanly and one that races
|
||||
// with a concurrently-created purchase task, writeRecomputeChanges' returned
|
||||
// counts must equal exactly what it actually wrote — a real update for the
|
||||
// clean row, and a skip (not a write) for the raced one. This is the same
|
||||
// property RecomputeExecute relies on for its response and audit log
|
||||
// (verified end-to-end, without a race, by TestRecomputePreviewMatchesExecute
|
||||
// above; a race inside one execute call always changes the plan itself, so
|
||||
// it surfaces as a fingerprint mismatch — see
|
||||
// TestRecomputeExecuteRejectsStaleFingerprint — rather than reaching this
|
||||
// path in a real request).
|
||||
func TestWriteRecomputeChangesReturnsCountsMatchingActualWrites(t *testing.T) {
|
||||
db := testDB(t)
|
||||
rowA := models.SYBProduct{OrderCode: "ORD-ACTUAL-A", DetailID: 1, StockID: 1, ShopeeItemID: "1", Quantity: 1, ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"档口-1"}`}
|
||||
rowB := models.SYBProduct{OrderCode: "ORD-ACTUAL-B", DetailID: 2, StockID: 2, ShopeeItemID: "2", Quantity: 1, ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"档口-2"}`}
|
||||
if err := db.Create(&rowA).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&rowB).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ruleID := uint64(9)
|
||||
planned := []recomputeChange{
|
||||
{id: rowA.ID, orderCode: rowA.OrderCode, shopeeItemID: rowA.ShopeeItemID, toExcluded: true, ruleID: &ruleID, ruleKind: "keyword", ruleKeyword: "档口"},
|
||||
{id: rowB.ID, orderCode: rowB.OrderCode, shopeeItemID: rowB.ShopeeItemID, toExcluded: true, ruleID: &ruleID, ruleKind: "keyword", ruleKeyword: "档口"},
|
||||
}
|
||||
// Simulate a purchase task created for rowB strictly between planning
|
||||
// and this write phase call.
|
||||
if err := db.Create(&models.PurchaseTask{SYBProductID: &rowB.ID, PDDProductID: 1, Quantity: 1, CreateRequestID: "actual-task", Status: models.PurchaseTaskStatusFailed, ExecutionMode: models.PurchaseExecutionModeLive, TaskType: models.PurchaseTaskTypeSYBOrder, RuleSnapshot: "{}"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var actual RecomputeCounts
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
var writeErr error
|
||||
actual, writeErr = writeRecomputeChanges(context.Background(), tx, planned)
|
||||
return writeErr
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if actual.PDDToExcluded != 1 || actual.SkippedHasTask != 1 {
|
||||
t.Fatalf("expected one written and one skipped, got %+v", actual)
|
||||
}
|
||||
var reloadedA, reloadedB models.SYBProduct
|
||||
db.First(&reloadedA, rowA.ID)
|
||||
db.First(&reloadedB, rowB.ID)
|
||||
if !reloadedA.PDDExcluded {
|
||||
t.Fatalf("rowA should have been marked excluded")
|
||||
}
|
||||
if reloadedB.PDDExcluded {
|
||||
t.Fatalf("rowB must not have been marked excluded (has a task)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkedCountsByRule(t *testing.T) {
|
||||
db := testDB(t)
|
||||
ruleA := models.SYBProductFilter{Kind: "keyword", Keyword: "档口A", NormalizedKeyword: "档口a", Enabled: true}
|
||||
ruleB := models.SYBProductFilter{Kind: "keyword", Keyword: "档口B", NormalizedKeyword: "档口b", Enabled: true}
|
||||
if err := db.Create(&ruleA).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&ruleB).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ruleAID, ruleBID := ruleA.ID, ruleB.ID
|
||||
for i := 0; i < 3; i++ {
|
||||
row := models.SYBProduct{OrderCode: fmt.Sprintf("ORD-MARKED-A-%d", i), DetailID: uint64(i + 1), StockID: uint64(i + 1), ShopeeItemID: fmt.Sprintf("a%d", i), Quantity: 1, ParseStatus: models.SYBParseStatusSuccess, RawJSON: "{}", PDDExcluded: true, ExcludedRuleID: &ruleAID}
|
||||
if err := db.Create(&row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
row := models.SYBProduct{OrderCode: "ORD-MARKED-B-0", DetailID: 100, StockID: 100, ShopeeItemID: "b0", Quantity: 1, ParseStatus: models.SYBParseStatusSuccess, RawJSON: "{}", PDDExcluded: true, ExcludedRuleID: &ruleBID}
|
||||
if err := db.Create(&row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
unmarked := models.SYBProduct{OrderCode: "ORD-MARKED-NONE", DetailID: 200, StockID: 200, ShopeeItemID: "none", Quantity: 1, ParseStatus: models.SYBParseStatusSuccess, RawJSON: "{}"}
|
||||
if err := db.Create(&unmarked).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s := NewService(db)
|
||||
resp, err := s.List(context.Background(), ListRequest{Page: 1, PageSize: 50})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
byID := make(map[uint64]int64, len(resp.Items))
|
||||
for _, item := range resp.Items {
|
||||
byID[item.ID] = item.MarkedCount
|
||||
}
|
||||
if byID[ruleAID] != 3 {
|
||||
t.Fatalf("expected rule A marked count 3, got %d", byID[ruleAID])
|
||||
}
|
||||
if byID[ruleBID] != 1 {
|
||||
t.Fatalf("expected rule B marked count 1, got %d", byID[ruleBID])
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecomputeFingerprintChangesWhenRuleEvidenceChanges is #340 phase 4
|
||||
// review item 1: the fingerprint must depend on the rule's kind/keyword, not
|
||||
// just its id — because those are exactly what RecomputeExecute is about to
|
||||
// write into excluded_rule_kind/excluded_rule_keyword. Same rule id, same
|
||||
// affected product, same direction, but the rule's own keyword changed
|
||||
// between preview and execute (simulated by editing the row directly since
|
||||
// the API has no edit endpoint) must be rejected as stale.
|
||||
func TestRecomputeFingerprintChangesWhenRuleEvidenceChanges(t *testing.T) {
|
||||
db := testDB(t)
|
||||
rule := models.SYBProductFilter{Kind: "keyword", Keyword: "档口", NormalizedKeyword: "档口", Enabled: true}
|
||||
if err := db.Create(&rule).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
row := models.SYBProduct{OrderCode: "ORD-RULE-EDIT", DetailID: 1, StockID: 1, ShopeeItemID: "1", Quantity: 1, ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"档口-1"}`}
|
||||
if err := db.Create(&row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s := NewService(db)
|
||||
preview, err := s.RecomputePreview(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if preview.PDDToExcluded != 1 || len(preview.Samples) != 1 || preview.Samples[0].RuleKeyword != "档口" {
|
||||
t.Fatalf("unexpected preview: %+v", preview)
|
||||
}
|
||||
|
||||
// The rule's own keyword and normalized_keyword change (same id, same
|
||||
// kind, still matches the same variationSku prefix) — the plan's set of
|
||||
// affected ids/directions is unchanged, but the evidence that would be
|
||||
// written is not.
|
||||
if err := db.Model(&models.SYBProductFilter{}).Where("id = ?", rule.ID).
|
||||
Updates(map[string]any{"keyword": "档口新", "normalized_keyword": "档口"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err = s.RecomputeExecute(context.Background(), "admin1", preview.Fingerprint)
|
||||
if err == nil {
|
||||
t.Fatalf("expected the changed rule evidence to be rejected as stale")
|
||||
}
|
||||
se, ok := err.(*ServiceError)
|
||||
if !ok || se.Code != CodeRecomputeStale {
|
||||
t.Fatalf("expected CodeRecomputeStale, got %v", err)
|
||||
}
|
||||
|
||||
var reloaded models.SYBProduct
|
||||
db.First(&reloaded, row.ID)
|
||||
if reloaded.PDDExcluded {
|
||||
t.Fatalf("nothing should have been written: %+v", reloaded)
|
||||
}
|
||||
var logCount int64
|
||||
db.Model(&models.SYBProductFilterRecomputeLog{}).Count(&logCount)
|
||||
if logCount != 0 {
|
||||
t.Fatalf("no audit log row should have been written, got %d", logCount)
|
||||
}
|
||||
|
||||
// A fresh preview reflects the new keyword and executes normally.
|
||||
freshPreview, err := s.RecomputePreview(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if freshPreview.Samples[0].RuleKeyword != "档口新" {
|
||||
t.Fatalf("expected fresh preview to see the new keyword, got %+v", freshPreview.Samples)
|
||||
}
|
||||
if _, err := s.RecomputeExecute(context.Background(), "admin1", freshPreview.Fingerprint); err != nil {
|
||||
t.Fatalf("fresh fingerprint should be accepted: %v", err)
|
||||
}
|
||||
db.First(&reloaded, row.ID)
|
||||
if !reloaded.PDDExcluded || reloaded.ExcludedRuleKeyword != "档口新" {
|
||||
t.Fatalf("expected the row to be excluded with the new keyword snapshot: %+v", reloaded)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecomputeFingerprintStableAcrossUnchangedPreviews is the companion
|
||||
// regression: an unchanged dataset must give the SAME fingerprint on two
|
||||
// consecutive previews (map/slice iteration order must never leak into the
|
||||
// hash), and that fingerprint must still execute successfully.
|
||||
func TestRecomputeFingerprintStableAcrossUnchangedPreviews(t *testing.T) {
|
||||
db := testDB(t)
|
||||
if err := db.Create(&models.SYBProductFilter{Kind: "keyword", Keyword: "档口", NormalizedKeyword: "档口", Enabled: true}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 5; i++ {
|
||||
row := models.SYBProduct{OrderCode: fmt.Sprintf("ORD-STABLE-%d", i), DetailID: uint64(i + 1), StockID: uint64(i + 1), ShopeeItemID: fmt.Sprintf("s%d", i), Quantity: 1, ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"档口-x"}`}
|
||||
if err := db.Create(&row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
s := NewService(db)
|
||||
first, err := s.RecomputePreview(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := s.RecomputePreview(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first.Fingerprint == "" || first.Fingerprint != second.Fingerprint {
|
||||
t.Fatalf("expected a stable, non-empty fingerprint across two previews of the same data: %q vs %q", first.Fingerprint, second.Fingerprint)
|
||||
}
|
||||
if _, err := s.RecomputeExecute(context.Background(), "admin1", second.Fingerprint); err != nil {
|
||||
t.Fatalf("unchanged-data fingerprint should execute successfully: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -12,4 +12,6 @@ func InitRouter(e *gin.Engine, a *jwt.GinJWTMiddleware) {
|
||||
g.POST("", middleware.RequireRoleKey("admin"), Handler{}.Create)
|
||||
g.PATCH("/:filterId/enabled", middleware.RequireRoleKey("admin"), Handler{}.SetEnabled)
|
||||
g.DELETE("/:filterId", middleware.RequireRoleKey("admin"), Handler{}.Delete)
|
||||
g.GET("/recompute/preview", middleware.RequireRoleKey("admin"), Handler{}.RecomputePreview)
|
||||
g.POST("/recompute/execute", middleware.RequireRoleKey("admin"), Handler{}.RecomputeExecute)
|
||||
}
|
||||
|
||||
@@ -30,6 +30,10 @@ const (
|
||||
CodeNotFound = "SYB_PRODUCT_FILTER_NOT_FOUND"
|
||||
CodeConflict = "SYB_PRODUCT_FILTER_DUPLICATE"
|
||||
CodeForbidden = "FORBIDDEN"
|
||||
// CodeRecomputeStale is returned by RecomputeExecute when the plan its
|
||||
// fingerprint describes no longer matches the current data/rules (#340
|
||||
// phase 3 review item 2): the caller must re-preview before executing.
|
||||
CodeRecomputeStale = "RECOMPUTE_PREVIEW_STALE"
|
||||
)
|
||||
|
||||
func invalid(m string) error { return &ServiceError{Code: CodeInvalidRequest, Message: m} }
|
||||
@@ -46,12 +50,24 @@ type ListRequest struct {
|
||||
Kind, Keyword string
|
||||
EnabledOnly bool
|
||||
}
|
||||
|
||||
// FilterItem adds MarkedCount to the stored rule: the REAL, live count of
|
||||
// syb_product rows currently marked pdd_purchase_excluded by this rule
|
||||
// (#340 phase 3 review item 3), as opposed to LastHitCount which is a
|
||||
// point-in-time snapshot written back by the most recent sync run and never
|
||||
// updated by a recompute. It is deliberately not a model field — it is
|
||||
// derived, never stored.
|
||||
type FilterItem struct {
|
||||
models.SYBProductFilter
|
||||
MarkedCount int64 `json:"markedCount"`
|
||||
}
|
||||
|
||||
type ListResponse struct {
|
||||
Items []models.SYBProductFilter `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
EnabledCount int64 `json:"enabledCount"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
Items []FilterItem `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
EnabledCount int64 `json:"enabledCount"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context, r ListRequest) (ListResponse, error) {
|
||||
@@ -75,16 +91,46 @@ func (s *Service) List(ctx context.Context, r ListRequest) (ListResponse, error)
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
return ListResponse{}, internal(err)
|
||||
}
|
||||
items := make([]models.SYBProductFilter, 0, r.PageSize)
|
||||
if err := q.Order("kind ASC, id ASC").Offset((r.Page - 1) * r.PageSize).Limit(r.PageSize).Find(&items).Error; err != nil {
|
||||
rows := make([]models.SYBProductFilter, 0, r.PageSize)
|
||||
if err := q.Order("kind ASC, id ASC").Offset((r.Page - 1) * r.PageSize).Limit(r.PageSize).Find(&rows).Error; err != nil {
|
||||
return ListResponse{}, internal(err)
|
||||
}
|
||||
if err := s.DB.WithContext(ctx).Model(&models.SYBProductFilter{}).Where("enabled = ?", true).Count(&enabled).Error; err != nil {
|
||||
return ListResponse{}, internal(err)
|
||||
}
|
||||
marked, err := s.markedCountsByRule(ctx)
|
||||
if err != nil {
|
||||
return ListResponse{}, internal(err)
|
||||
}
|
||||
items := make([]FilterItem, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
items = append(items, FilterItem{SYBProductFilter: row, MarkedCount: marked[row.ID]})
|
||||
}
|
||||
return ListResponse{Items: items, Total: total, EnabledCount: enabled, Page: r.Page, PageSize: r.PageSize}, nil
|
||||
}
|
||||
|
||||
// markedCountsByRule is the single grouped query (no N+1) behind MarkedCount:
|
||||
// COUNT(*) FROM syb_product WHERE pdd_purchase_excluded = 1, grouped by
|
||||
// excluded_rule_id, covering every rule at once regardless of page size.
|
||||
func (s *Service) markedCountsByRule(ctx context.Context) (map[uint64]int64, error) {
|
||||
type row struct {
|
||||
ExcludedRuleID uint64
|
||||
Count int64
|
||||
}
|
||||
var rows []row
|
||||
if err := s.DB.WithContext(ctx).Model(&models.SYBProduct{}).
|
||||
Select("excluded_rule_id, COUNT(*) AS count").
|
||||
Where("pdd_purchase_excluded = ? AND excluded_rule_id IS NOT NULL", true).
|
||||
Group("excluded_rule_id").Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make(map[uint64]int64, len(rows))
|
||||
for _, r := range rows {
|
||||
result[r.ExcludedRuleID] = r.Count
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type CreateRequest struct {
|
||||
Kind string `json:"kind"`
|
||||
Keyword string `json:"keyword"`
|
||||
|
||||
@@ -158,6 +158,11 @@ func (service *Service) BatchCreateImageSearch(ctx context.Context, request Imag
|
||||
response.Items = append(response.Items, ImageSearchBatchItem{SYBProductIDs: []uint64{id}, Code: "SYB_PRODUCT_UNAVAILABLE", Message: "SYB 商品不存在或未关联蝦皮商品"})
|
||||
continue
|
||||
}
|
||||
// #340: hard-exclude before grouping/creating any image search task.
|
||||
if row.PDDExcluded {
|
||||
response.Items = append(response.Items, ImageSearchBatchItem{SYBProductIDs: []uint64{id}, Code: "PURCHASE_PDD_EXCLUDED", Message: "该商品已标记为无需 PDD 采购"})
|
||||
continue
|
||||
}
|
||||
if index, ok := groups[*row.ShopeeProductID]; ok {
|
||||
response.Items[index].SYBProductIDs = append(response.Items[index].SYBProductIDs, id)
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// #340: image search collection is a PDD entry point keyed by sybProductIds;
|
||||
// an excluded row must be rejected without touching the batch's other rows.
|
||||
func TestBatchCreateImageSearchRejectsExcludedRow(t *testing.T) {
|
||||
db := openTaskDatabase(t)
|
||||
rule := models.CollectionRule{Name: "image-search-excluded", ContentJSON: v2TaskRuleSnapshot()}
|
||||
if err := db.Create(&rule).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
shopee := models.ShopeeProduct{ShopeeItemID: "excl-1", Title: "t", Currency: "CNY", SpecsJSON: "[]"}
|
||||
if err := db.Create(&shopee).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
syb := models.SYBProduct{OrderCode: "ORD-EXCL", DetailID: 1, StockID: 1, ShopeeItemID: shopee.ShopeeItemID, ShopeeProductID: &shopee.ID, Quantity: 1, UnitPriceCent: 100, ImageURL: "https://example.invalid/excl.jpg", ParseStatus: "success", RawJSON: "{}", PDDExcluded: true}
|
||||
if err := db.Create(&syb).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := NewService(db)
|
||||
service.FetchImageSearchImage = func(ctx context.Context, url string) (ImageSearchImage, error) {
|
||||
t.Fatalf("must not fetch image for an excluded row")
|
||||
return ImageSearchImage{}, nil
|
||||
}
|
||||
request := ImageSearchBatchRequest{RequestID: uuid.NewString(), SYBProductIDs: []uint64{syb.ID}, RuleID: rule.ID}
|
||||
response, err := service.BatchCreateImageSearch(context.Background(), request)
|
||||
if err != nil {
|
||||
t.Fatalf("batch call itself must not fail: %v", err)
|
||||
}
|
||||
if response.SuccessCount != 0 || len(response.Items) != 1 {
|
||||
t.Fatalf("expected the excluded row to fail, got %+v", response)
|
||||
}
|
||||
if response.Items[0].Code != "PURCHASE_PDD_EXCLUDED" {
|
||||
t.Fatalf("expected PURCHASE_PDD_EXCLUDED, got %+v", response.Items[0])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"go-admin/app/goauto/migrations"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
"gorm.io/gorm"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// #340: adds syb_product's pdd_purchase_excluded/excluded_rule_*/excluded_at
|
||||
// columns (additive, all default to "需 PDD 采购") and creates
|
||||
// syb_product_filter_recompute_log (registered in migrations.MigratedModels)
|
||||
// on databases whose earlier versions are already recorded in sys_migration.
|
||||
func init() {
|
||||
_, f, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(f), migrateSYBPDDPurchaseExcluded)
|
||||
}
|
||||
func migrateSYBPDDPurchaseExcluded(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := migrations.Migrate(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
@@ -4,3 +4,5 @@ export function listSybProductFilters(params) { return request({ url: base, meth
|
||||
export function createSybProductFilter(data) { return request({ url: base, method: 'post', data }) }
|
||||
export function setSybProductFilterEnabled(id, data) { return request({ url: `${base}/${id}/enabled`, method: 'patch', data }) }
|
||||
export function deleteSybProductFilter(id) { return request({ url: `${base}/${id}`, method: 'delete' }) }
|
||||
export function previewSybProductFilterRecompute() { return request({ url: `${base}/recompute/preview`, method: 'get' }) }
|
||||
export function executeSybProductFilterRecompute(fingerprint) { return request({ url: `${base}/recompute/execute`, method: 'post', data: { fingerprint } }) }
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<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="primary" :disabled="!applyEligibleCount || busy || importing || matchingBusy || loading || !!loadError || applyDialog.open" @click="openApply">回写({{ applyEligibleCount }})</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>
|
||||
@@ -35,13 +35,14 @@
|
||||
<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>
|
||||
|
||||
<el-dialog v-model="applyDialog.open" :title="`确认回写已选择的 ${selected.length} 条业务记录吗?`" width="640px" :close-on-click-modal="false">
|
||||
<el-dialog v-model="applyDialog.open" :title="`确认回写 ${applyDialog.ids.length} 条业务记录吗?`" width="640px" :close-on-click-modal="false" :close-on-press-escape="!applyDialog.saving" :show-close="!applyDialog.saving">
|
||||
<div v-loading="applyDialog.loading">
|
||||
<el-alert title="远端写入将全局串行执行;超时或结果不明确时不会自动重试,只能只读复核。" type="warning" show-icon :closable="false" class="notice" />
|
||||
<p aria-live="polite">选中 {{ applyDialog.selectedCount }} 条,可回写 {{ applyDialog.ids.length }} 条,跳过 {{ applyDialog.selectedCount - applyDialog.ids.length }} 条;只提交本次确认的可回写记录。</p>
|
||||
<div class="metric-grid" aria-live="polite"><div><span>业务记录数</span><strong>{{ applyDialog.preview.records || 0 }}</strong></div><div><span>入库码总数</span><strong>{{ applyDialog.preview.inboundCodes || 0 }}</strong></div><div><span>预计占位明细数</span><strong>{{ applyDialog.preview.placeholderDetails || 0 }}</strong></div><div><span>替换旧码数</span><strong>{{ applyDialog.preview.replaceOldCodes || 0 }}</strong></div></div>
|
||||
<el-alert v-if="applyDialog.preview.blocked?.length" :title="`${applyDialog.preview.blocked.length} 条当前不可回写,请返回列表重新选择。`" type="error" show-icon :closable="false" />
|
||||
<el-alert v-if="applyDialog.error" :title="applyDialog.error" type="error" show-icon :closable="false" />
|
||||
</div>
|
||||
<template #footer><el-button :disabled="applyDialog.saving" @click="applyDialog.open = false">取消</el-button><el-button type="primary" :loading="applyDialog.saving" :disabled="applyDialog.loading || !!applyDialog.preview.blocked?.length" @click="confirmApply">回写 {{ applyDialog.preview.records || 0 }} 条</el-button></template>
|
||||
<template #footer><el-button :disabled="applyDialog.saving" @click="applyDialog.open = false">取消</el-button><el-button type="primary" :loading="applyDialog.saving" :disabled="applyDialog.loading || applyDialog.saving || !applyDialog.ready || !applyDialog.ids.length" @click="confirmApply">回写 {{ applyDialog.ids.length }} 条</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="deleteDialog.open" :title="`确定删除已选择的 ${selected.length} 条数据吗?`" width="600px" :close-on-click-modal="false">
|
||||
@@ -67,12 +68,14 @@ import { createRequestId } from '@/utils/request-id'
|
||||
export default {
|
||||
name: 'GoAutoSybInnerCodes',
|
||||
setup() { return { FolderOpened, RefreshLeft, Search, Upload } },
|
||||
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 }} },
|
||||
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, ready: false, error: '', ids: [], selectedCount: 0, 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 },
|
||||
applySelectionRows() { const ids = new Set(this.selected.map(item => item.id)); return this.items.filter(item => ids.has(item.id)) },
|
||||
applyEligibleRows() { return this.applySelectionRows.filter(item => item.status === 'ready') },
|
||||
applyEligibleCount() { return this.applyEligibleRows.length },
|
||||
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 },
|
||||
@@ -89,8 +92,41 @@ export default {
|
||||
async load() { this.loading = true; this.loadError = ''; try { const r = await listSybInnerCodes(this.query); this.items = r.data.items; this.total = r.data.total } catch (error) { this.loadError = error?.response?.data?.message || error?.message || '列表加载失败' } finally { this.loading = false } },
|
||||
search() { [this.query.dateFrom, this.query.dateTo] = this.dateRange || ['', '']; this.query.page = 1; this.load() }, clearFilters() { this.dateRange = null; Object.assign(this.query, { page: 1, dateFrom: '', dateTo: '', keyword: '' }); this.load() },
|
||||
async submitImport() { if (!this.selectedFile) return; this.importing = true; try { const r = await importSybInnerCodes(this.selectedFile, createRequestId()); ElMessage.success(`导入 ${r.data.recordCount} 条业务记录,已自动开始匹配`); this.activeJob = { id: r.data.matchJobId, status: 'pending', total: r.data.recordCount, processed: 0, ready: 0, failed: 0 }; this.startPolling(); await this.load() } finally { this.importing = false } },
|
||||
async openApply() { this.applyDialog = { open: true, loading: true, saving: false, preview: {}}; try { const r = await previewSybInnerCodeApply(this.selected.map(item => item.id)); this.applyDialog.preview = r.data } finally { this.applyDialog.loading = false } },
|
||||
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 } },
|
||||
async openApply() {
|
||||
if (!this.applyEligibleCount || this.busy || this.importing || this.matchingBusy || this.loading || this.loadError || this.applyDialog.open) return
|
||||
this.applyDialog = { open: true, loading: true, saving: false, ready: false, error: '', ids: [...new Set(this.applyEligibleRows.map(item => item.id))], selectedCount: new Set(this.applySelectionRows.map(item => item.id)).size, preview: {}}
|
||||
const dialog = this.applyDialog
|
||||
try {
|
||||
// One read-only refresh handles candidates whose status changed since listing.
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
const r = await previewSybInnerCodeApply([...dialog.ids])
|
||||
if (this.applyDialog !== dialog || !dialog.open) return
|
||||
const preview = r.data
|
||||
const blocked = preview?.blocked || []
|
||||
if (preview?.records !== dialog.ids.length || !Array.isArray(blocked) || blocked.some(item => !dialog.ids.includes(item.id))) throw new Error('回写预览范围不一致,请关闭后重新选择')
|
||||
if (!blocked.length) { dialog.preview = preview; dialog.ready = true; return }
|
||||
const blockedIds = new Set(blocked.map(item => item.id))
|
||||
dialog.ids = dialog.ids.filter(id => !blockedIds.has(id))
|
||||
if (!dialog.ids.length) { dialog.error = '当前没有可回写记录,请刷新后重新选择'; return }
|
||||
}
|
||||
dialog.error = '记录状态再次变化,请关闭后重新预览'
|
||||
} catch (error) {
|
||||
if (this.applyDialog === dialog && dialog.open) dialog.error = error?.response?.data?.message || error?.message || '回写预览失败,请关闭后重试'
|
||||
} finally { dialog.loading = false }
|
||||
},
|
||||
async confirmApply() {
|
||||
const dialog = this.applyDialog
|
||||
if (!dialog.open || dialog.loading || dialog.saving || !dialog.ready || !dialog.ids.length || this.busy) return
|
||||
dialog.saving = true; this.busy = true
|
||||
try {
|
||||
const r = await applySybInnerCodes({ requestId: createRequestId(), ids: [...dialog.ids] })
|
||||
this.activeBatch = { id: r.data.batchId, status: 'queued', requested: r.data.queued, processed: 0 }
|
||||
dialog.open = false; ElMessage.success('回写已提交,页面可以继续使用'); this.startPolling(); await this.load()
|
||||
} catch (error) {
|
||||
dialog.ready = false
|
||||
dialog.error = error?.response?.data?.message || '回写提交未确认,请刷新列表或只读复核;不会自动重试'
|
||||
} finally { dialog.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 matchSelected() { await this.startMatch(this.matchEligibleRows) },
|
||||
|
||||
@@ -28,8 +28,15 @@
|
||||
/>
|
||||
|
||||
<section>
|
||||
<h3>结构过滤(不可增删,仅可停用)</h3>
|
||||
<p class="muted">variationSku 需要<strong>同时包含</strong>下方列出的每一个字符,才会被当成档口商品不入库;只包含其中一个的会正常入库。需要变更判据请另行建单评估。</p>
|
||||
<div class="section-head">
|
||||
<h3>结构过滤(不可增删,仅可停用)</h3>
|
||||
<el-tooltip v-if="isAdmin" :disabled="!recomputeButtonReason" :content="recomputeButtonReason" placement="bottom">
|
||||
<span>
|
||||
<el-button :disabled="Boolean(recomputeButtonReason)" @click="openRecompute">按当前规则重算</el-button>
|
||||
</span>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
<p class="muted">variationSku 需要<strong>同时包含</strong>下方列出的每一个字符,才会被标记为无需 PDD 采购;只包含其中一个的仍按需要 PDD 采购处理。需要变更判据请另行建单评估。</p>
|
||||
<el-table v-loading="loading" :data="chars" row-key="id" border stripe>
|
||||
<el-table-column label="必需字符" prop="keyword" width="180" />
|
||||
<el-table-column label="状态" width="150">
|
||||
@@ -116,7 +123,7 @@
|
||||
</el-table>
|
||||
</section>
|
||||
|
||||
<p class="scope-note">停用或删除只影响后续同步,已经入库的商品明细不会被删除。</p>
|
||||
<p class="scope-note">停用或删除规则只影响之后的同步;已经标记的商品明细不会自动变化,需要点击「按当前规则重算」才会按新规则重新判定。</p>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="editor.open" title="新增关键词" width="520px" :close-on-click-modal="false" @closed="resetEditor">
|
||||
@@ -140,6 +147,57 @@
|
||||
<el-button type="primary" :loading="editor.saving" @click="save">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="recompute.open" title="按当前规则重算" width="720px" :close-on-click-modal="false" @closed="resetRecompute">
|
||||
<div v-loading="recompute.loading">
|
||||
<el-alert
|
||||
v-if="recompute.error"
|
||||
:title="recompute.error"
|
||||
type="error"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="notice"
|
||||
/>
|
||||
<template v-else-if="recompute.loaded">
|
||||
<el-alert
|
||||
title="重算只按当前启用规则重新判定标记;已有采购任务或正处于有效退货匹配的商品不会被改变。"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="notice"
|
||||
/>
|
||||
<div class="recompute-counts">
|
||||
<div class="count-item"><div class="count-value">{{ recompute.result.pddToExcluded }}</div><div class="count-label">将标记为无需 PDD 采购</div></div>
|
||||
<div class="count-item"><div class="count-value">{{ recompute.result.excludedToPdd }}</div><div class="count-label">将恢复为需要 PDD 采购</div></div>
|
||||
<div class="count-item"><div class="count-value">{{ recompute.result.skippedHasTask }}</div><div class="count-label">已有采购任务,跳过</div></div>
|
||||
<div class="count-item"><div class="count-value">{{ recompute.result.skippedReturnMatch }}</div><div class="count-label">有效退货匹配中,跳过</div></div>
|
||||
</div>
|
||||
<p v-if="recomputeTotalChanges === 0" class="muted">按当前规则重算,没有需要变更的商品。</p>
|
||||
<template v-else>
|
||||
<p class="muted">变更示例(最多显示 20 条,实际变更数为 {{ recomputeTotalChanges }} 条):</p>
|
||||
<el-table :data="recompute.result.samples" border size="small" empty-text="无样例">
|
||||
<el-table-column label="订单号" prop="orderCode" min-width="140" />
|
||||
<el-table-column label="蝦皮商品" prop="shopeeItemId" min-width="120" />
|
||||
<el-table-column label="变更方向" width="150">
|
||||
<template #default="{ row }">{{ row.direction === 'pdd_to_excluded' ? '需采购 → 无需采购' : '无需采购 → 需采购' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="命中规则" min-width="160">
|
||||
<template #default="{ row }">{{ row.ruleKeyword ? `${row.ruleKind === 'char' ? '结构' : '关键词'}:${row.ruleKeyword}` : '(不再命中任何规则)' }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button :disabled="recompute.executing" @click="recompute.open = false">取消</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="recompute.executing"
|
||||
:disabled="!recompute.loaded || Boolean(recompute.error) || recomputeTotalChanges === 0"
|
||||
@click="confirmRecompute"
|
||||
>确认执行</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
</BasicLayout>
|
||||
</template>
|
||||
@@ -150,9 +208,15 @@ import {
|
||||
listSybProductFilters,
|
||||
createSybProductFilter,
|
||||
setSybProductFilterEnabled,
|
||||
deleteSybProductFilter
|
||||
deleteSybProductFilter,
|
||||
previewSybProductFilterRecompute,
|
||||
executeSybProductFilterRecompute
|
||||
} from '@/api/goauto/syb-product-filters'
|
||||
|
||||
function emptyRecomputeResult() {
|
||||
return { pddToExcluded: 0, excludedToPdd: 0, skippedHasTask: 0, skippedReturnMatch: 0, samples: [], fingerprint: '' }
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'GoAutoSybProductFilters',
|
||||
data() {
|
||||
@@ -161,12 +225,26 @@ export default {
|
||||
chars: [],
|
||||
keywords: [],
|
||||
query: { keyword: '' },
|
||||
editor: { open: false, saving: false, keyword: '', note: '' }
|
||||
editor: { open: false, saving: false, keyword: '', note: '' },
|
||||
recompute: {
|
||||
open: false,
|
||||
loading: false,
|
||||
executing: false,
|
||||
loaded: false,
|
||||
error: '',
|
||||
result: emptyRecomputeResult()
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isAdmin() {
|
||||
return (this.$store.getters.roles || []).includes('admin')
|
||||
},
|
||||
recomputeButtonReason() {
|
||||
return this.isAdmin ? '' : '只有管理员可以重算'
|
||||
},
|
||||
recomputeTotalChanges() {
|
||||
return this.recompute.result.pddToExcluded + this.recompute.result.excludedToPdd
|
||||
}
|
||||
},
|
||||
created() {
|
||||
@@ -205,12 +283,15 @@ export default {
|
||||
const who = row.lastChangedBy || '-'
|
||||
return `${this.formatTime(row.lastChangedAt)} ${who} ${row.enabled ? '启用' : '停用'}`
|
||||
},
|
||||
// 停用结构规则会让大量档口明细恢复入库,所以必须二次确认。
|
||||
// 条数取该行实时的 lastHitCount,不能写死:数据会变,写死的警告会骗人(#269)。
|
||||
// 停用结构规则不会撤销已有标记,所以必须二次确认。
|
||||
// #340 phase 3:警告文字改用该规则当前实时的 markedCount(服务端按
|
||||
// excluded_rule_id 分组统计的真实数量),不再用 lastHitCount——后者只是
|
||||
// 上一次同步的快照,规则改动或重算之后会和当前标记数脱节,继续用它会
|
||||
// 把过期数字当成现状讲给管理员听(#269 沿用的旧口径在 #340 后不再准确)。
|
||||
async confirmDisableChar(row) {
|
||||
const scale = row.lastHitCount == null ? '尚不清楚有多少' : `约 ${row.lastHitCount} 条`
|
||||
const scale = row.markedCount == null ? '尚不清楚有多少' : `当前约 ${row.markedCount} 条`
|
||||
await ElMessageBox.confirm(
|
||||
`停用后,SYB 同步将不再过滤任何档口商品(结构过滤只有这一条)。按最近一次同步数据估算,${scale}明细会恢复入库。`,
|
||||
`停用后,SYB 同步将不再把任何档口商品标记为无需 PDD 采购(结构过滤只有这一条)。这只影响之后的同步,${scale}明细带此规则的无需采购标记不会自动变化,需要使用「按当前规则重算」才会更新。`,
|
||||
`确认停用结构过滤「${row.keyword}」?`,
|
||||
{ type: 'warning', confirmButtonText: '确认停用', cancelButtonText: '取消', confirmButtonClass: 'el-button--danger' }
|
||||
)
|
||||
@@ -267,6 +348,46 @@ export default {
|
||||
await deleteSybProductFilter(row.id)
|
||||
ElMessage.success('关键词已删除')
|
||||
this.load()
|
||||
},
|
||||
resetRecompute() {
|
||||
this.recompute = { open: false, loading: false, executing: false, loaded: false, error: '', result: emptyRecomputeResult() }
|
||||
},
|
||||
async openRecompute() {
|
||||
this.recompute.open = true
|
||||
this.recompute.loading = true
|
||||
this.recompute.loaded = false
|
||||
this.recompute.error = ''
|
||||
try {
|
||||
const res = await previewSybProductFilterRecompute()
|
||||
this.recompute.result = { ...emptyRecomputeResult(), ...(res.data || {}) }
|
||||
this.recompute.loaded = true
|
||||
} catch (e) {
|
||||
this.recompute.error = (e && e.message) || '预览失败,请重试'
|
||||
} finally {
|
||||
this.recompute.loading = false
|
||||
}
|
||||
},
|
||||
// #340 phase 3:执行必须带上预览返回的 fingerprint;服务端会在写入前用
|
||||
// 当前数据重新计算一次并比对,不一致(数据或规则在预览之后变化了)会
|
||||
// 返回 RECOMPUTE_PREVIEW_STALE(409),此时不写入任何数据,这里提示后
|
||||
// 自动重新预览,而不是让用户对着过期的计划再点一次「确认执行」。
|
||||
async confirmRecompute() {
|
||||
this.recompute.executing = true
|
||||
try {
|
||||
await executeSybProductFilterRecompute(this.recompute.result.fingerprint)
|
||||
ElMessage.success('已按当前规则重新计算')
|
||||
this.recompute.open = false
|
||||
this.load()
|
||||
} catch (e) {
|
||||
if (e?.response?.data?.code === 'RECOMPUTE_PREVIEW_STALE' || e?.data?.code === 'RECOMPUTE_PREVIEW_STALE') {
|
||||
ElMessage.warning('数据或规则已变化,请重新预览后再执行')
|
||||
this.openRecompute()
|
||||
return
|
||||
}
|
||||
throw e
|
||||
} finally {
|
||||
this.recompute.executing = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -312,4 +433,26 @@ h3 {
|
||||
.scope-note {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.recompute-counts {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.count-item {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 12px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.count-value {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
.count-label {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
</el-form-item>
|
||||
<el-form-item label="订单号"><el-input v-model="query.orderCodesText" type="textarea" :autosize="{ minRows: 1, maxRows: 4 }" resize="none" class="order-codes-input" placeholder="每行一个订单号,最多 100 个" @keydown.ctrl.enter.prevent="search" /></el-form-item>
|
||||
<el-form-item label="解析状态"><el-select v-model="query.parseStatus" clearable placeholder="全部" style="width: 110px"><el-option label="成功" value="success" /><el-option label="失败" value="failed" /></el-select></el-form-item>
|
||||
<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 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>
|
||||
</div>
|
||||
<div class="search-row search-row-secondary">
|
||||
@@ -37,6 +38,7 @@
|
||||
<el-tag :type="processMeta(purchaseReady(row).processStage).type">{{ purchaseReady(row).processStageLabel || processMeta(purchaseReady(row).processStage).label }}</el-tag>
|
||||
<div class="purchase-reason">{{ purchaseReady(row).processStageReason || purchaseReady(row).reason || '请刷新后重试' }}</div>
|
||||
<div v-if="purchaseReady(row).processStage === 'purchase_ready'" class="muted">{{ purchasePriceText(purchaseReady(row)) }}</div>
|
||||
<div v-if="purchaseReady(row).processStage === 'pdd_excluded'" class="muted">规则:{{ excludedRuleText(row) }}</div>
|
||||
<div class="quick-link-actions">
|
||||
<el-button v-if="purchaseActionLabel({ nextAction: purchaseReady(row).processNextAction })" type="primary" link @click="runPurchaseNextAction(row, { ...purchaseReady(row), nextAction: purchaseReady(row).processNextAction })">{{ purchaseActionLabel({ nextAction: purchaseReady(row).processNextAction }) }}</el-button>
|
||||
<el-button-group v-if="quickCanLink(row)">
|
||||
@@ -326,6 +328,10 @@
|
||||
<el-descriptions-item label="解析状态"><el-tag :type="parseMeta(detail.item.parseStatus).type">{{ parseMeta(detail.item.parseStatus).label }}</el-tag></el-descriptions-item>
|
||||
<el-descriptions-item label="解析说明">{{ detail.item.parseNote || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="人工确认">{{ detail.item.manuallyConfirmed ? '是' : '否' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="采购类型">
|
||||
<el-tag :type="detail.item.pddPurchaseExcluded ? 'info' : 'success'">{{ detail.item.pddPurchaseExcluded ? '无需 PDD 采购' : '需 PDD 采购' }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item v-if="detail.item.pddPurchaseExcluded" label="命中规则">{{ excludedRuleText(detail.item) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<div class="drawer-actions">
|
||||
<el-button @click="openReparseSingle">重新解析</el-button>
|
||||
@@ -404,7 +410,7 @@ export default {
|
||||
imageSearchBatch: this.emptyImageSearchBatch(),
|
||||
collectionBatchData: { ruleId: null, deviceId: null },
|
||||
collectionBatchRules: { ruleId: [{ required: true, message: '请选择采集规则', trigger: 'change' }] },
|
||||
query: { page: 1, pageSize: 200, shopName: '', orderCodesText: '', parseStatus: '', processStage: '', createdAtRange: [] },
|
||||
query: { page: 1, pageSize: 200, shopName: '', orderCodesText: '', parseStatus: '', processStage: '', purchaseType: 'pdd', createdAtRange: [] },
|
||||
returnMatchByProductId: {},
|
||||
returnMatchLoading: false,
|
||||
returnMatchBatchLoading: false,
|
||||
@@ -420,7 +426,7 @@ export default {
|
||||
purchaseDeviceUnavailable() { return !!this.purchaseDialog.deviceId && !this.purchaseDevices.some(device => device.id === this.purchaseDialog.deviceId) },
|
||||
purchaseImageSearchLinkedCount() { return this.purchaseDialog.items.filter(item => item.imageSearchLinked).length },
|
||||
canPurchase() { const roles = this.$store.getters.roles || []; return roles.includes('admin') || roles.includes('purchaser') || roles.includes('after_sales') },
|
||||
processStageOptions() { return [{ value: 'manual_action', label: '待人工处理' }, { value: 'pdd_unlinked', label: '未关联 PDD' }, { value: 'pdd_pending', label: 'PDD 待采集' }, { value: 'pdd_collecting', label: 'PDD 采集中' }, { value: 'pdd_collection_failed', label: 'PDD 采集失败' }, { value: 'color_mapping', label: '规格待匹配' }, { value: 'purchase_ready', label: '可创建采购' }, { value: 'task_created', label: '已创建任务' }, { value: 'purchase_succeeded', label: '采购成功' }, { value: 'order_review', label: '待人工核对' }, { value: 'return_pending', label: '退货待确认' }, { value: 'return_used', label: '已用退货' }] },
|
||||
processStageOptions() { return [{ value: 'manual_action', label: '待人工处理' }, { value: 'pdd_unlinked', label: '未关联 PDD' }, { value: 'pdd_pending', label: 'PDD 待采集' }, { value: 'pdd_collecting', label: 'PDD 采集中' }, { value: 'pdd_collection_failed', label: 'PDD 采集失败' }, { value: 'color_mapping', label: '规格待匹配' }, { value: 'purchase_ready', label: '可创建采购' }, { value: 'task_created', label: '已创建任务' }, { value: 'purchase_succeeded', label: '采购成功' }, { value: 'order_review', label: '待人工核对' }, { value: 'return_pending', label: '退货待确认' }, { value: 'return_used', label: '已用退货' }, { value: 'pdd_excluded', label: '无需采购' }] },
|
||||
returnMatchCandidateIds() { return this.selectedProducts.filter(row => this.isReturnMatchCandidate(row)).map(row => row.id) },
|
||||
aiMatchCandidates() { return this.selectedProducts.filter(row => this.isAIMatchCandidate(row)) },
|
||||
aiMatchButtonOverLimit() { return this.aiMatchCandidates.length > PURCHASE_BATCH_MAX },
|
||||
@@ -478,7 +484,7 @@ export default {
|
||||
const requestOptions = allowNetworkRetry ? { suppressNetworkError: true } : {}
|
||||
try {
|
||||
const [createdFrom = '', createdTo = ''] = this.query.createdAtRange || []
|
||||
const r = await listSybProducts({ page: this.query.page, pageSize: this.query.pageSize, shopName: this.query.shopName.trim(), orderCodes: orderCodes.join(','), parseStatus: this.query.parseStatus, processStage: this.query.processStage, createdFrom, createdTo }, requestOptions)
|
||||
const r = await listSybProducts({ page: this.query.page, pageSize: this.query.pageSize, shopName: this.query.shopName.trim(), orderCodes: orderCodes.join(','), parseStatus: this.query.parseStatus, processStage: this.query.processStage, purchaseType: this.query.purchaseType, createdFrom, createdTo }, requestOptions)
|
||||
if (generation !== this.loadGeneration) return
|
||||
this.products = r.data.items
|
||||
this.total = r.data.total
|
||||
@@ -501,7 +507,15 @@ export default {
|
||||
}
|
||||
},
|
||||
search() { this.query.page = 1; this.load() },
|
||||
reset() { this.query = { page: 1, pageSize: 200, shopName: '', orderCodesText: '', parseStatus: '', processStage: '', createdAtRange: [] }; this.load() },
|
||||
reset() { this.query = { page: 1, pageSize: 200, shopName: '', orderCodesText: '', parseStatus: '', processStage: '', purchaseType: 'pdd', createdAtRange: [] }; this.load() },
|
||||
// #340: 选择「退货待确认/已用退货/无需采购」这些不属于「需 PDD 采购」默认范围
|
||||
// 的阶段时,自动把采购类型切到「全部」,否则处理阶段过滤会和采购类型过滤
|
||||
// 的 AND 组合出空列表;用户仍可再手动切回。
|
||||
onProcessStageChange(value) {
|
||||
if (['return_pending', 'return_used', 'pdd_excluded'].includes(value) && this.query.purchaseType !== 'all') {
|
||||
this.query.purchaseType = 'all'
|
||||
}
|
||||
},
|
||||
normalizeShopName(value) { return String(value || '').normalize('NFKC').trim().toLocaleLowerCase() },
|
||||
async ensureShopOptions() {
|
||||
if (this.shopOptionsLoaded) return
|
||||
@@ -531,10 +545,13 @@ export default {
|
||||
// 图搜采集的用途就是给未关联 PDD 的虾皮商品找到 PDD 商品,所以它的可勾选条件
|
||||
// 不能要求已关联——另外三个判据都要求,于是图搜入口对它最该服务的那类商品
|
||||
// 一直不可达(#291)。这里与 imageSearchRows 的过滤条件保持一致。
|
||||
isImageSearchCandidate(row) { return !this.isActivelyReturnMatched(row) && Boolean(row.shopeeProductId && row.imageUrl) },
|
||||
// #338: 参与匹配退货的处理阶段范围(服务端 returnmatch.participatingStages 同一口径)。
|
||||
isImageSearchCandidate(row) { return !this.isActivelyReturnMatched(row) && !this.isPDDExcluded(row) && Boolean(row.shopeeProductId && row.imageUrl) },
|
||||
// #340: pdd_purchase_excluded 商品仍可参与退货匹配(服务端 returnmatch 把
|
||||
// pdd_excluded 加入了 participatingStages),但不参与其他任何 PDD 相关操作。
|
||||
isPDDExcluded(row) { return this.purchaseReady(row).processStage === 'pdd_excluded' },
|
||||
// #338/#340: 参与匹配退货的处理阶段范围(服务端 returnmatch.participatingStages 同一口径)。
|
||||
isReturnMatchCandidate(row) {
|
||||
const participating = new Set(['pdd_unlinked', 'pdd_pending', 'pdd_collecting', 'pdd_collection_failed', 'color_mapping', 'purchase_ready'])
|
||||
const participating = new Set(['pdd_unlinked', 'pdd_pending', 'pdd_collecting', 'pdd_collection_failed', 'color_mapping', 'purchase_ready', 'pdd_excluded'])
|
||||
return participating.has(this.purchaseReady(row).processStage)
|
||||
},
|
||||
isActivelyReturnMatched(row) { const stage = this.purchaseReady(row).processStage; return stage === 'return_pending' || stage === 'return_used' },
|
||||
@@ -543,8 +560,13 @@ export default {
|
||||
isSelectableCandidate(row) { return this.purchaseReady(row).processStage === 'return_pending' || this.isPurchaseCandidate(row) || this.isCollectionCandidate(row) || this.isAIMatchCandidate(row) || this.isImageSearchCandidate(row) || this.isReturnMatchCandidate(row) },
|
||||
rowSelectable(row) { return this.canPurchase && !this.purchaseReadinessLoading && this.isSelectableCandidate(row) },
|
||||
purchaseReady(row) { return this.purchaseReadiness[row.id] || { sybProductId: row.id, eligible: false, reason: this.purchaseReadinessLoading ? '正在检查' : '请刷新后重试' } },
|
||||
// #340: 规则快照固定在标记时刻,与当前规则是否被改名/停用无关。
|
||||
excludedRuleText(item) {
|
||||
if (!item || !item.excludedRuleKeyword) return '(规则快照缺失)'
|
||||
return `${item.excludedRuleKind === 'char' ? '结构' : '关键词'} ${item.excludedRuleKeyword}`
|
||||
},
|
||||
purchasePriceText(item) { if (item.minUnitPriceCent === undefined || item.maxUnitPriceCent === undefined) return ''; return `允许单价 ¥${(item.minUnitPriceCent / 100).toFixed(2)}~¥${(item.maxUnitPriceCent / 100).toFixed(2)}` },
|
||||
processMeta(stage) { return { manual_action: { label: '待人工处理', type: 'warning' }, pdd_unlinked: { label: '未关联 PDD', type: 'info' }, pdd_pending: { label: 'PDD 待采集', type: 'info' }, pdd_collecting: { label: 'PDD 采集中', type: 'primary' }, pdd_collection_failed: { label: 'PDD 采集失败', type: 'danger' }, color_mapping: { label: '规格待匹配', type: 'warning' }, purchase_ready: { label: '可创建采购', type: 'success' }, task_created: { label: '已创建任务', type: 'primary' }, purchase_succeeded: { label: '采购成功', type: 'success' }, order_review: { label: '待人工核对', type: 'danger' }, return_pending: { label: '退货待确认', type: 'warning' }, return_used: { label: '已用退货', type: 'info' }}[stage] || { label: '待人工处理', type: 'warning' } },
|
||||
processMeta(stage) { return { manual_action: { label: '待人工处理', type: 'warning' }, pdd_unlinked: { label: '未关联 PDD', type: 'info' }, pdd_pending: { label: 'PDD 待采集', type: 'info' }, pdd_collecting: { label: 'PDD 采集中', type: 'primary' }, pdd_collection_failed: { label: 'PDD 采集失败', type: 'danger' }, color_mapping: { label: '规格待匹配', type: 'warning' }, purchase_ready: { label: '可创建采购', type: 'success' }, task_created: { label: '已创建任务', type: 'primary' }, purchase_succeeded: { label: '采购成功', type: 'success' }, order_review: { label: '待人工核对', type: 'danger' }, return_pending: { label: '退货待确认', type: 'warning' }, return_used: { label: '已用退货', type: 'info' }, pdd_excluded: { label: '无需采购', type: 'info' }}[stage] || { label: '待人工处理', type: 'warning' } },
|
||||
purchaseActionLabel(item) { return { open_pdd_link: '去关联', open_mapping: '去匹配', open_shopee: '查看蝦皮商品', open_pdd: '查看 PDD 商品', open_task: '查看任务', reparse: '查看并处理', select_device: '重新选择设备', refresh: '刷新' }[item.nextAction] || '' },
|
||||
// #339: the page can now show up to 500 rows, but the preview endpoint
|
||||
// (previewPurchaseTasks -> purchase/batch.go maxBatchPurchaseItems) still
|
||||
|
||||
@@ -45,11 +45,11 @@
|
||||
<el-descriptions-item label="导入范围">{{ detail.item.dateFrom }} 至 {{ detail.item.dateTo }}</el-descriptions-item>
|
||||
<el-descriptions-item label="进度">{{ detail.item.daysProcessed }}/{{ detail.item.daysTotal }} 天({{ detail.item.progressPercent }}%)</el-descriptions-item>
|
||||
<el-descriptions-item label="货运单">{{ detail.item.orderCount }} 张</el-descriptions-item>
|
||||
<el-descriptions-item label="商品明细">{{ detail.item.detailCount }} 条</el-descriptions-item>
|
||||
<el-descriptions-item label="商品明细">{{ detail.item.detailCount }} 条(其中本次规则命中 {{ excludedMarkedCount(detail.item) }} 条)</el-descriptions-item>
|
||||
<el-descriptions-item label="符合店铺">{{ detail.item.acceptedCount }} 张</el-descriptions-item>
|
||||
<el-descriptions-item label="跳过店铺">{{ detail.item.shopSkipped }} 张</el-descriptions-item>
|
||||
<el-descriptions-item label="结构过滤命中">{{ detail.item.charFilterSkipped || 0 }} 条</el-descriptions-item>
|
||||
<el-descriptions-item label="关键词过滤命中">{{ detail.item.keywordFilterSkipped || 0 }} 条</el-descriptions-item>
|
||||
<el-descriptions-item label="结构过滤标记">{{ detail.item.charFilterSkipped || 0 }} 条</el-descriptions-item>
|
||||
<el-descriptions-item label="关键词过滤标记">{{ detail.item.keywordFilterSkipped || 0 }} 条</el-descriptions-item>
|
||||
<el-descriptions-item label="新增 / 覆盖">{{ detail.item.created }} / {{ detail.item.updated }}</el-descriptions-item>
|
||||
<el-descriptions-item label="操作人">{{ detail.item.operatorName || `用户 ${detail.item.operatorId || '-'}` }}</el-descriptions-item>
|
||||
<el-descriptions-item label="开始时间">{{ formatTime(detail.item.startedAt) }}</el-descriptions-item>
|
||||
@@ -97,6 +97,10 @@ export default {
|
||||
methods: {
|
||||
statusMeta(status) { return { running: { label: '执行中', type: 'primary' }, succeeded: { label: '成功', type: 'success' }, partial_success: { label: '部分成功', type: 'warning' }, failed: { label: '失败', type: 'danger' }, interrupted: { label: '已中断', type: 'warning' }}[status] || { label: status || '-', type: 'info' } },
|
||||
formatTime(value) { if (!value) return '—'; return new Date(value).toLocaleString('zh-CN', { hour12: false }) },
|
||||
// #340: 自本次改动起,命中过滤规则的明细不再跳过导入,而是入库并标记为
|
||||
// 无需 PDD 采购;这个数量就是本次运行标记的行数,等于两类命中之和,
|
||||
// 无需服务端新增字段。
|
||||
excludedMarkedCount(item) { return (item?.charFilterSkipped || 0) + (item?.keywordFilterSkipped || 0) },
|
||||
async load() {
|
||||
this.loading = true; this.loadError = ''
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
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 previews: number[][] = []; const writes: number[][] = []
|
||||
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') return reply({ items: ['updated', 'ready', 'failed', 'ready'].map((status, i) => ({ id: i + 1, businessDate: '2026-09-29', orderNumber: `MOCK-${i + 1}`, status, items: [{ code: `MOCK-CODE-${i}`, ordinal: 1 }], specRaw: '模拟规格' })), total: 4 })
|
||||
if (pathname.endsWith('/apply-preview')) {
|
||||
const ids = route.request().postDataJSON().ids; previews.push(ids)
|
||||
if (mode === 'preview-error') return route.fulfill({ status: 500, json: { code: 'INTERNAL', message: '模拟预览失败' } })
|
||||
return reply({ records: ids.length, inboundCodes: ids.length === 1 ? 3 : 2, blocked: mode === 'changed' && ids.length === 2 ? [{ id: 4, status: 'updated' }] : [] })
|
||||
}
|
||||
if (pathname.endsWith('/apply')) {
|
||||
writes.push(route.request().postDataJSON().ids)
|
||||
return reply({ batchId: 'mock-batch', queued: writes.at(-1)!.length })
|
||||
}
|
||||
if (pathname.includes('/apply-batches/')) return reply({ batch: { id: 'mock-batch', status: 'running', requested: writes.at(-1)!.length, processed: 0 } })
|
||||
return reply({ items: [], total: 0 })
|
||||
})
|
||||
await page.goto('/#/syb-inner-codes')
|
||||
await expect(page.getByRole('cell', { name: 'MOCK-1', exact: true })).toBeVisible()
|
||||
await page.locator('.el-table__header-wrapper .el-checkbox').first().click()
|
||||
await page.getByRole('button', { name: '回写(2)', exact: true }).click()
|
||||
return { previews, writes, dialog: page.getByRole('dialog') }
|
||||
}
|
||||
|
||||
test('mixed selection reports skipped items and submits only ready records', async ({ page, context }) => {
|
||||
const { previews, writes, dialog } = await setup(page, context)
|
||||
await expect(dialog.getByText('选中 4 条,可回写 2 条,跳过 2 条;只提交本次确认的可回写记录。')).toBeVisible()
|
||||
await expect(dialog.getByRole('button', { name: '回写 2 条', exact: true })).toBeEnabled()
|
||||
expect(previews).toEqual([[2, 4]]); expect(writes).toEqual([])
|
||||
await dialog.getByRole('button', { name: '回写 2 条', exact: true }).click()
|
||||
await expect.poll(() => writes.length).toBe(1); expect(writes[0]).toEqual([2, 4])
|
||||
await expect(dialog).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('preview status changes update count and only submit remaining candidate', async ({ page, context }) => {
|
||||
const { previews, writes, dialog } = await setup(page, context, 'changed')
|
||||
await expect(dialog.getByText('选中 4 条,可回写 1 条,跳过 3 条;只提交本次确认的可回写记录。')).toBeVisible()
|
||||
await expect(dialog.getByRole('button', { name: '回写 1 条', exact: true })).toBeEnabled()
|
||||
expect(previews).toEqual([[2, 4], [2]])
|
||||
await dialog.getByRole('button', { name: '回写 1 条', exact: true }).click()
|
||||
await expect.poll(() => writes.length).toBe(1); expect(writes[0]).toEqual([2])
|
||||
})
|
||||
|
||||
test('preview failure is visible and cannot submit', async ({ page, context }) => {
|
||||
const { writes, dialog } = await setup(page, context, 'preview-error')
|
||||
await expect(dialog.locator('.el-alert--error')).toBeVisible()
|
||||
await expect(dialog.getByRole('button', { name: '回写 2 条', exact: true })).toBeDisabled()
|
||||
expect(writes).toEqual([])
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
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(preview = async ids => ({ data: { records: ids.length, inboundCodes: ids.length, blocked: [] } }), apply = async body => ({ data: { batchId: 'mock-batch', queued: body.ids.length } })) {
|
||||
const previews = []; const writes = []
|
||||
const options = new Function('previewSybInnerCodeApply', 'applySybInnerCodes', 'createRequestId', 'ElMessage', source)(async ids => { previews.push(ids); return preview(ids) }, async body => { writes.push(body); return apply(body) }, () => 'mock-request', { success() {} })
|
||||
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 = ['updated', 'ready', 'failed', 'ready'].map((status, i) => ({ id: i + 1, status }))
|
||||
return { vm, previews, writes }
|
||||
}
|
||||
|
||||
test('mixed selection only previews and submits frozen current-page ready IDs', async () => {
|
||||
const { vm, previews, writes } = instance()
|
||||
vm.selected = [...vm.items, vm.items[1], { id: 999, status: 'ready' }]
|
||||
assert.equal(vm.applyEligibleCount, 2)
|
||||
await vm.openApply()
|
||||
assert.deepEqual(previews, [[2, 4]])
|
||||
assert.equal(vm.applyDialog.selectedCount, 4)
|
||||
assert.equal(vm.applyDialog.ready, true)
|
||||
vm.selected = [{ id: 123, status: 'ready' }]; vm.items = vm.selected
|
||||
await vm.confirmApply()
|
||||
assert.deepEqual(writes[0].ids, [2, 4]); assert.equal(writes.length, 1)
|
||||
assert.equal(vm.applyDialog.open, false)
|
||||
})
|
||||
|
||||
test('blocked preview candidates are removed and metrics reloaded for remaining IDs', async () => {
|
||||
const { vm, previews, writes } = instance(async ids => ({ data: { records: ids.length, inboundCodes: ids.length === 2 ? 7 : 3, blocked: ids.length === 2 ? [{ id: 4, status: 'updated' }] : [] } }))
|
||||
vm.selected = vm.items
|
||||
await vm.openApply()
|
||||
assert.deepEqual(previews, [[2, 4], [2]])
|
||||
assert.deepEqual(vm.applyDialog.ids, [2]); assert.equal(vm.applyDialog.preview.inboundCodes, 3)
|
||||
assert.equal(vm.applyDialog.selectedCount - vm.applyDialog.ids.length, 3)
|
||||
await vm.confirmApply(); assert.deepEqual(writes[0].ids, [2])
|
||||
})
|
||||
|
||||
test('all blocked candidates or preview failures cannot submit', async () => {
|
||||
for (const preview of [async ids => ({ data: { records: ids.length, blocked: ids.map(id => ({ id })) } }), async () => { throw new Error('mock network error') }, async () => ({ data: { records: 999, blocked: [] } })]) {
|
||||
const { vm, writes } = instance(preview); vm.selected = vm.items
|
||||
await vm.openApply(); assert.equal(vm.applyDialog.ready, false); assert.ok(vm.applyDialog.error)
|
||||
await vm.confirmApply(); assert.equal(writes.length, 0)
|
||||
}
|
||||
})
|
||||
|
||||
test('zero eligible, loading, error, busy and active matching do not preview', async () => {
|
||||
const empty = instance(); await empty.vm.openApply(); assert.equal(empty.previews.length, 0)
|
||||
for (const field of ['loading', 'loadError', 'busy', 'importing', 'matchSubmitting']) {
|
||||
const { vm, previews } = instance(); vm.selected = vm.items; vm[field] = field === 'loadError' ? 'mock' : true
|
||||
await vm.openApply(); assert.equal(previews.length, 0)
|
||||
}
|
||||
})
|
||||
|
||||
test('canceled preview cannot authorize a later dialog and double clicks do not submit twice', async () => {
|
||||
let resolvePreview
|
||||
const { vm, writes } = instance(() => new Promise(resolve => { resolvePreview = resolve }))
|
||||
vm.selected = vm.items; const pending = vm.openApply(); const original = vm.applyDialog
|
||||
original.open = false; vm.applyDialog = { ...original, ready: false }
|
||||
resolvePreview({ data: { records: 2, blocked: [] } }); await pending
|
||||
assert.equal(vm.applyDialog.ready, false); await vm.confirmApply(); assert.equal(writes.length, 0)
|
||||
|
||||
let resolveApply
|
||||
const running = instance(undefined, () => new Promise(resolve => { resolveApply = resolve }))
|
||||
running.vm.selected = running.vm.items; await running.vm.openApply()
|
||||
const submit = running.vm.confirmApply(); await running.vm.confirmApply()
|
||||
assert.equal(running.writes.length, 1)
|
||||
resolveApply({ data: { batchId: 'mock-batch', queued: 2 } }); await submit
|
||||
})
|
||||
|
||||
test('submission conflict or unknown result disables retry without changing selection', async () => {
|
||||
const { vm, writes } = instance(undefined, async () => { throw new Error('mock unknown result') })
|
||||
vm.selected = vm.items; await vm.openApply(); await vm.confirmApply(); await vm.confirmApply()
|
||||
assert.equal(writes.length, 1); assert.equal(vm.applyDialog.ready, false); assert.ok(vm.applyDialog.error)
|
||||
assert.equal(vm.busy, false); assert.equal(vm.applyDialog.saving, false); assert.equal(vm.selected.length, 4)
|
||||
})
|
||||
Reference in New Issue
Block a user