Files
goauto/server/app/goauto/models/replacement.go
T

143 lines
6.8 KiB
Go

package models
import (
"fmt"
"time"
"gorm.io/gorm"
)
const (
ReplacementOriginCollection = "collection"
ReplacementOriginPurchase = "purchase"
ReplacementStatusActive = "active"
ReplacementStatusSuperseded = "superseded"
ReplacementMappingMatching = "matching"
ReplacementMappingCompleted = "completed"
ReplacementMappingCompletedPartial = "completed_partial"
ReplacementItemMappingMatching = "matching"
ReplacementItemMappingMatched = "matched"
ReplacementItemMappingManualRequired = "manual_required"
ReplacementItemSourceAIMatch = "ai_match"
ReplacementItemSourceExactMatch = "exact_match"
ReplacementItemSourceManualMapping = "manual_mapping"
)
// PDDProductReplacement is an immutable audit record for one product
// replacement. ActiveSlot is a nullable uniqueness guard: exactly one active
// replacement may exist for a source product across SQLite, MySQL and
// PostgreSQL, while superseded history remains append-only.
type PDDProductReplacement struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
SourceProductID uint64 `json:"sourceProductId" gorm:"not null;index;uniqueIndex:ux_pdd_product_replacement_active,priority:1"`
SourceProduct PDDProduct `json:"-" gorm:"foreignKey:SourceProductID;constraint:OnUpdate:CASCADE,OnDelete:RESTRICT"`
TargetProductID uint64 `json:"targetProductId" gorm:"not null;index"`
TargetProduct PDDProduct `json:"-" gorm:"foreignKey:TargetProductID;constraint:OnUpdate:CASCADE,OnDelete:RESTRICT"`
OriginType string `json:"originType" gorm:"size:16;not null;index:ix_pdd_product_replacement_origin,priority:1;check:ck_pdd_product_replacement_origin_type,origin_type IN ('collection','purchase')"`
OriginTaskID uint64 `json:"originTaskId" gorm:"not null;index:ix_pdd_product_replacement_origin,priority:2"`
TargetCollectionTaskID uint64 `json:"targetCollectionTaskId" gorm:"not null;index"`
TargetCollectionTask CollectionTask `json:"-" gorm:"foreignKey:TargetCollectionTaskID;constraint:OnUpdate:CASCADE,OnDelete:RESTRICT"`
CreatedByDeviceID uint64 `json:"createdByDeviceId" gorm:"not null;index"`
CreatedByDevice AgentDevice `json:"-" gorm:"foreignKey:CreatedByDeviceID;constraint:OnUpdate:CASCADE,OnDelete:RESTRICT"`
Status string `json:"status" gorm:"size:16;not null;index;check:ck_pdd_product_replacement_status,status IN ('active','superseded')"`
ActiveSlot *uint8 `json:"-" gorm:"uniqueIndex:ux_pdd_product_replacement_active,priority:2;check:ck_pdd_product_replacement_active_slot,(status = 'active' AND active_slot = 1) OR (status = 'superseded' AND active_slot IS NULL)"`
MappingStatus string `json:"mappingStatus" gorm:"size:24;not null;index;check:ck_pdd_product_replacement_mapping_status,mapping_status IN ('matching','completed','completed_partial')"`
CreateRequestID string `json:"-" gorm:"size:36;not null;uniqueIndex:ux_pdd_product_replacement_create_request"`
CreatedAt time.Time `json:"createdAt"`
MappingUpdatedAt time.Time `json:"mappingUpdatedAt" gorm:"not null"`
ActivatedAt *time.Time `json:"activatedAt,omitempty" gorm:"index"`
}
func (PDDProductReplacement) TableName() string { return "pdd_product_replacement" }
func (replacement *PDDProductReplacement) BeforeCreate(_ *gorm.DB) error {
if replacement.Status == "" {
replacement.Status = ReplacementStatusActive
}
if replacement.MappingStatus == "" {
replacement.MappingStatus = ReplacementMappingMatching
}
if replacement.MappingUpdatedAt.IsZero() {
replacement.MappingUpdatedAt = time.Now().UTC()
}
return replacement.syncActiveSlot()
}
func (replacement *PDDProductReplacement) BeforeSave(_ *gorm.DB) error {
return replacement.syncActiveSlot()
}
func (replacement *PDDProductReplacement) SetStatus(status string) error {
replacement.Status = status
return replacement.syncActiveSlot()
}
func (replacement *PDDProductReplacement) syncActiveSlot() error {
one := uint8(1)
switch replacement.Status {
case ReplacementStatusActive:
replacement.ActiveSlot = &one
case ReplacementStatusSuperseded:
replacement.ActiveSlot = nil
default:
return fmt.Errorf("unsupported replacement status %q", replacement.Status)
}
return nil
}
// PDDProductReplacementItem freezes the Shopee products affected by one
// replacement and tracks matching independently for every product. The parent
// aggregate status must never be used to decide whether one purchase may
// continue.
type PDDProductReplacementItem struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
ReplacementID uint64 `json:"replacementId" gorm:"not null;index;uniqueIndex:ux_pdd_product_replacement_item,priority:1"`
Replacement PDDProductReplacement `json:"-" gorm:"constraint:OnUpdate:CASCADE,OnDelete:RESTRICT"`
ShopeeProductID uint64 `json:"shopeeProductId" gorm:"not null;index;uniqueIndex:ux_pdd_product_replacement_item,priority:2"`
ShopeeProduct ShopeeProduct `json:"-" gorm:"constraint:OnUpdate:CASCADE,OnDelete:RESTRICT"`
MappingStatus string `json:"mappingStatus" gorm:"size:24;not null;index;check:ck_pdd_product_replacement_item_status,mapping_status IN ('matching','matched','manual_required')"`
Source *string `json:"source,omitempty" gorm:"size:24;check:ck_pdd_product_replacement_item_source,source IS NULL OR source IN ('ai_match','exact_match','manual_mapping')"`
Confidence *float64 `json:"confidence,omitempty" gorm:"check:ck_pdd_product_replacement_item_confidence,confidence IS NULL OR (confidence >= 0 AND confidence <= 1)"`
Reason *string `json:"reason,omitempty" gorm:"size:500"`
AttemptCount int `json:"attemptCount" gorm:"not null;default:0;check:ck_pdd_product_replacement_item_attempt_count,attempt_count >= 0"`
LastErrorCode *string `json:"lastErrorCode,omitempty" gorm:"size:64"`
LastErrorAt *time.Time `json:"lastErrorAt,omitempty"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (PDDProductReplacementItem) TableName() string { return "pdd_product_replacement_item" }
func (item *PDDProductReplacementItem) BeforeCreate(_ *gorm.DB) error {
if item.MappingStatus == "" {
item.MappingStatus = ReplacementItemMappingMatching
}
return nil
}
// PDDProductReplacementWorkerLease serializes persistent matching workers
// across service instances. Work remains in replacement_item; the lease only
// elects one owner and is safe to steal after expiry.
type PDDProductReplacementWorkerLease struct {
ID uint8 `json:"-" gorm:"primaryKey;autoIncrement:false"`
OwnerID string `json:"-" gorm:"size:64;not null;default:''"`
ExpiresAt time.Time `json:"-" gorm:"not null;index"`
Version uint64 `json:"-" gorm:"not null;default:0"`
UpdatedAt time.Time `json:"-"`
}
func (PDDProductReplacementWorkerLease) TableName() string {
return "pdd_product_replacement_worker_lease"
}