Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30c6b2ab50 | ||
|
|
27d9560f0a | ||
|
|
b72fa105a8 |
@@ -43,6 +43,7 @@ func MigratedModels() []any {
|
||||
&models.SYBSession{},
|
||||
&models.SYBShop{},
|
||||
&models.SYBProductFilter{},
|
||||
&models.SYBProductFilterRecomputeLog{},
|
||||
&models.SYBSyncRun{},
|
||||
&models.YeekeSession{},
|
||||
&models.YeekeReturnPackage{},
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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")),
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -63,6 +63,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<el-alert v-if="returnMatchByProductId[row.id].syncStatus === 'missing'" title="退货已不在 yeeke 列表" type="error" :closable="false" show-icon class="return-missing-alert" />
|
||||
<el-alert v-if="isDeadlinePassed(returnMatchByProductId[row.id].destroyDeadline)" title="退货已过销毁截止" type="error" :closable="false" show-icon class="return-missing-alert" />
|
||||
<div class="quick-link-actions">
|
||||
<el-button type="primary" link @click="openMatchDetail(returnMatchByProductId[row.id].id)">查看对比</el-button>
|
||||
<el-button v-if="canPurchase" type="primary" link @click="openMatchDetail(returnMatchByProductId[row.id].id, true)">备注</el-button>
|
||||
@@ -223,6 +224,7 @@
|
||||
<div class="split-col">
|
||||
<h3 class="section-title">yeeke 退货商品</h3>
|
||||
<el-alert v-if="matchDetail.data.yeeke && matchDetail.data.yeeke.syncStatus === 'missing'" title="退货已不在 yeeke 列表" type="error" :closable="false" show-icon class="notice" />
|
||||
<el-alert v-if="matchDetail.data.yeeke && isDeadlinePassed(matchDetail.data.yeeke.destroyDeadline)" title="退货已过销毁截止,请核实退货是否仍在库" type="error" :closable="false" show-icon class="notice" />
|
||||
<el-descriptions :column="1" border size="small" v-if="matchDetail.data.yeeke">
|
||||
<el-descriptions-item label="退货订单号">{{ matchDetail.data.yeeke.orderSn }}</el-descriptions-item>
|
||||
<el-descriptions-item label="商品ID">{{ matchDetail.data.yeeke.itemId }}</el-descriptions-item>
|
||||
@@ -573,6 +575,8 @@ export default {
|
||||
|
||||
// ---------------- 退货匹配 (#338) ----------------
|
||||
formatMatchDeadline(value) { return value ? new Date(value).toLocaleString() : '—' },
|
||||
// #338: an already-matched return whose destroy deadline has passed is only flagged, never auto-cancelled.
|
||||
isDeadlinePassed(value) { return Boolean(value) && new Date(value).getTime() <= Date.now() },
|
||||
matchStatusLabel(status) { return { matched: '待确认', confirmed: '已确认', cancelled: '已取消' }[status] || status },
|
||||
async loadReturnMatches(ids, generation = this.loadGeneration) {
|
||||
this.returnMatchByProductId = {}
|
||||
|
||||
@@ -83,6 +83,7 @@
|
||||
<template v-if="row.occupyingSybProductId">
|
||||
<a class="link" href="javascript:void(0)" @click="openSybProduct(row.occupyingSybProductId)">{{ row.occupyingSybOrderCode }} ↗</a>
|
||||
<div class="muted">{{ row.occupyingSybStageLabel }}</div>
|
||||
<div v-if="isDeadlinePassed(row.destroyDeadLine)" class="deadline-passed">已过销毁截止</div>
|
||||
</template>
|
||||
<span v-else class="muted">—</span>
|
||||
</template>
|
||||
@@ -193,6 +194,8 @@ export default {
|
||||
openSybProduct(sybProductId) { this.$router.push({ path: '/syb-products/index', query: { sybProductId } }) },
|
||||
statusMeta(status) { return { running: { label: '执行中', type: 'primary' }, succeeded: { label: '成功', type: 'success' }, 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 }) },
|
||||
// #338: a matched return past its destroy deadline is flagged only, never auto-released.
|
||||
isDeadlinePassed(value) { return Boolean(value) && new Date(value).getTime() <= Date.now() },
|
||||
async load() {
|
||||
this.loading = true; this.loadError = ''
|
||||
try {
|
||||
@@ -277,4 +280,5 @@ export default {
|
||||
.item-title{font-size:15px;font-weight:600;color:#1f2937;margin-bottom:4px}
|
||||
.muted{font-size:12px;color:#909399}
|
||||
@media(max-width:800px){.page-heading{flex-direction:column}}
|
||||
.deadline-passed{color:var(--el-color-danger);font-size:12px}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user