diff --git a/server/app/goauto/migrations/migrate.go b/server/app/goauto/migrations/migrate.go index 443a094..bec32d5 100644 --- a/server/app/goauto/migrations/migrate.go +++ b/server/app/goauto/migrations/migrate.go @@ -43,6 +43,7 @@ func MigratedModels() []any { &models.SYBSession{}, &models.SYBShop{}, &models.SYBProductFilter{}, + &models.SYBProductFilterRecomputeLog{}, &models.SYBSyncRun{}, &models.YeekeSession{}, &models.YeekeReturnPackage{}, diff --git a/server/app/goauto/models/schema.go b/server/app/goauto/models/schema.go index fa881f4..0b55186 100644 --- a/server/app/goauto/models/schema.go +++ b/server/app/goauto/models/schema.go @@ -568,12 +568,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). diff --git a/server/app/goauto/purchase/ai_match_eligibility.go b/server/app/goauto/purchase/ai_match_eligibility.go index add170d..3f08971 100644 --- a/server/app/goauto/purchase/ai_match_eligibility.go +++ b/server/app/goauto/purchase/ai_match_eligibility.go @@ -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("未解析出需要采购的颜色或尺码") } diff --git a/server/app/goauto/purchase/batch.go b/server/app/goauto/purchase/batch.go index 67f631d..f377041 100644 --- a/server/app/goauto/purchase/batch.go +++ b/server/app/goauto/purchase/batch.go @@ -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 { diff --git a/server/app/goauto/purchase/collection_eligibility.go b/server/app/goauto/purchase/collection_eligibility.go index fe270f8..6c53b87 100644 --- a/server/app/goauto/purchase/collection_eligibility.go +++ b/server/app/goauto/purchase/collection_eligibility.go @@ -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 diff --git a/server/app/goauto/purchase/pdd_excluded_block_test.go b/server/app/goauto/purchase/pdd_excluded_block_test.go new file mode 100644 index 0000000..fa82fc0 --- /dev/null +++ b/server/app/goauto/purchase/pdd_excluded_block_test.go @@ -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]) + } +} diff --git a/server/app/goauto/purchase/process_stage.go b/server/app/goauto/purchase/process_stage.go index bcd668f..4f6713e 100644 --- a/server/app/goauto/purchase/process_stage.go +++ b/server/app/goauto/purchase/process_stage.go @@ -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") diff --git a/server/app/goauto/purchase/return_match_block.go b/server/app/goauto/purchase/return_match_block.go index 812375a..f122106 100644 --- a/server/app/goauto/purchase/return_match_block.go +++ b/server/app/goauto/purchase/return_match_block.go @@ -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 +} diff --git a/server/app/goauto/purchase/service.go b/server/app/goauto/purchase/service.go index fb25908..6591b8c 100644 --- a/server/app/goauto/purchase/service.go +++ b/server/app/goauto/purchase/service.go @@ -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, "该商品尚未关联蝦皮商品") } diff --git a/server/app/goauto/purchase/types.go b/server/app/goauto/purchase/types.go index 332e71e..a452ced 100644 --- a/server/app/goauto/purchase/types.go +++ b/server/app/goauto/purchase/types.go @@ -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 { diff --git a/server/app/goauto/returnmatch/pdd_excluded_test.go b/server/app/goauto/returnmatch/pdd_excluded_test.go new file mode 100644 index 0000000..a553d73 --- /dev/null +++ b/server/app/goauto/returnmatch/pdd_excluded_test.go @@ -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) + } +} diff --git a/server/app/goauto/returnmatch/service.go b/server/app/goauto/returnmatch/service.go index 2b3f26c..a945c2c 100644 --- a/server/app/goauto/returnmatch/service.go +++ b/server/app/goauto/returnmatch/service.go @@ -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 { diff --git a/server/app/goauto/sybimport/apply.go b/server/app/goauto/sybimport/apply.go index 7acf7ab..11d0168 100644 --- a/server/app/goauto/sybimport/apply.go +++ b/server/app/goauto/sybimport/apply.go @@ -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. diff --git a/server/app/goauto/sybimport/handler.go b/server/app/goauto/sybimport/handler.go index 7d0b823..58e5e33 100644 --- a/server/app/goauto/sybimport/handler.go +++ b/server/app/goauto/sybimport/handler.go @@ -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")), + 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")), PurchaseType: strings.TrimSpace(c.Query("purchaseType")), }) if err != nil { writeError(c, err) diff --git a/server/app/goauto/sybimport/pdd_exclusion_list_test.go b/server/app/goauto/sybimport/pdd_exclusion_list_test.go new file mode 100644 index 0000000..6c02d81 --- /dev/null +++ b/server/app/goauto/sybimport/pdd_exclusion_list_test.go @@ -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") + } +} diff --git a/server/app/goauto/sybimport/pdd_exclusion_test.go b/server/app/goauto/sybimport/pdd_exclusion_test.go new file mode 100644 index 0000000..f8b0e2e --- /dev/null +++ b/server/app/goauto/sybimport/pdd_exclusion_test.go @@ -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) + } +} diff --git a/server/app/goauto/sybimport/service.go b/server/app/goauto/sybimport/service.go index 480158c..00aefb0 100644 --- a/server/app/goauto/sybimport/service.go +++ b/server/app/goauto/sybimport/service.go @@ -44,8 +44,20 @@ type ListRequest struct { OrderCodes []string ParseStatus string ProcessStage 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 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"` @@ -92,6 +104,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 { diff --git a/server/app/goauto/sybimport/sync.go b/server/app/goauto/sybimport/sync.go index 3cd5a7d..d3523cc 100644 --- a/server/app/goauto/sybimport/sync.go +++ b/server/app/goauto/sybimport/sync.go @@ -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", diff --git a/server/app/goauto/sybimport/sync_test.go b/server/app/goauto/sybimport/sync_test.go index dfd07ed..983c444 100644 --- a/server/app/goauto/sybimport/sync_test.go +++ b/server/app/goauto/sybimport/sync_test.go @@ -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) + } +} diff --git a/server/app/goauto/sybproductfilter/handler.go b/server/app/goauto/sybproductfilter/handler.go index bb3bfbc..b5a58d7 100644 --- a/server/app/goauto/sybproductfilter/handler.go +++ b/server/app/goauto/sybproductfilter/handler.go @@ -77,6 +77,37 @@ 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}) +} +func (h Handler) RecomputeExecute(c *gin.Context) { + s, ok := h.service(c) + if !ok { + return + } + claims := jwt.ExtractClaims(c) + name, _ := claims["nice"].(string) + result, e := s.RecomputeExecute(c.Request.Context(), name) + 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 { diff --git a/server/app/goauto/sybproductfilter/recompute.go b/server/app/goauto/sybproductfilter/recompute.go new file mode 100644 index 0000000..e2d4ece --- /dev/null +++ b/server/app/goauto/sybproductfilter/recompute.go @@ -0,0 +1,163 @@ +package sybproductfilter + +import ( + "context" + "encoding/json" + "strings" + "time" + + "go-admin/app/goauto/models" + + "gorm.io/gorm" +) + +// RecomputeCounts is shared by preview and execute (#340 decision 7): the two +// must agree, so both are computed by recomputeChanges below. +type RecomputeCounts struct { + ExcludedToPDD int `json:"excludedToPdd"` + PDDToExcluded int `json:"pddToExcluded"` + SkippedHasTask int `json:"skippedHasTask"` + SkippedReturnMatch int `json:"skippedReturnMatch"` +} + +type recomputeChange struct { + id uint64 + toExcluded bool // true: pdd -> excluded; false: excluded -> pdd + ruleID *uint64 + ruleKind, ruleKeyword string +} + +// 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). Preview and execute both call this so they can +// never disagree. +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 []models.SYBProduct + if err := tx.WithContext(ctx).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, 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 +} + +// RecomputePreview is read-only: it must produce exactly the counts execute +// would produce. +func (s *Service) RecomputePreview(ctx context.Context) (RecomputeCounts, error) { + counts, _, err := recomputeChanges(ctx, s.DB) + if err != nil { + return RecomputeCounts{}, internal(err) + } + return counts, nil +} + +type RecomputeExecuteResult struct { + RecomputeCounts + Operator string `json:"operator"` +} + +// RecomputeExecute writes every eligible flip and one audit log row, all in +// one transaction so the log always matches what was actually changed. +func (s *Service) RecomputeExecute(ctx context.Context, operator string) (RecomputeExecuteResult, error) { + var result RecomputeExecuteResult + err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + counts, changes, err := recomputeChanges(ctx, tx) + if err != nil { + return err + } + now := time.Now().UTC() + for _, change := range changes { + 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.Model(&models.SYBProduct{}).Where("id = ?", change.id).Updates(updates).Error; err != nil { + return err + } + } + log := models.SYBProductFilterRecomputeLog{ + Operator: strings.TrimSpace(operator), ExcludedToPDD: counts.ExcludedToPDD, + PDDToExcluded: counts.PDDToExcluded, SkippedHasTask: counts.SkippedHasTask, + SkippedReturnMatch: counts.SkippedReturnMatch, + } + if err := tx.Create(&log).Error; err != nil { + return err + } + result = RecomputeExecuteResult{RecomputeCounts: counts, Operator: log.Operator} + return nil + }) + if err != nil { + return RecomputeExecuteResult{}, internal(err) + } + return result, nil +} diff --git a/server/app/goauto/sybproductfilter/recompute_test.go b/server/app/goauto/sybproductfilter/recompute_test.go new file mode 100644 index 0000000..f503d3a --- /dev/null +++ b/server/app/goauto/sybproductfilter/recompute_test.go @@ -0,0 +1,107 @@ +package sybproductfilter + +import ( + "context" + "testing" + + "go-admin/app/goauto/models" +) + +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") + if err != nil { + t.Fatal(err) + } + if result.RecomputeCounts != preview { + t.Fatalf("execute counts must match preview: preview=%+v execute=%+v", preview, result.RecomputeCounts) + } + + 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) + } +} diff --git a/server/app/goauto/sybproductfilter/router.go b/server/app/goauto/sybproductfilter/router.go index 6a6c721..c6a34b9 100644 --- a/server/app/goauto/sybproductfilter/router.go +++ b/server/app/goauto/sybproductfilter/router.go @@ -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) } diff --git a/server/app/goauto/task/image_search.go b/server/app/goauto/task/image_search.go index a3a6e2f..a3419d7 100644 --- a/server/app/goauto/task/image_search.go +++ b/server/app/goauto/task/image_search.go @@ -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 { diff --git a/server/app/goauto/task/pdd_excluded_test.go b/server/app/goauto/task/pdd_excluded_test.go new file mode 100644 index 0000000..3656c8f --- /dev/null +++ b/server/app/goauto/task/pdd_excluded_test.go @@ -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]) + } +} diff --git a/server/cmd/migrate/migration/version-local/1789801100000_syb_pdd_purchase_excluded.go b/server/cmd/migrate/migration/version-local/1789801100000_syb_pdd_purchase_excluded.go new file mode 100644 index 0000000..5bdc73d --- /dev/null +++ b/server/cmd/migrate/migration/version-local/1789801100000_syb_pdd_purchase_excluded.go @@ -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 + }) +}