143 lines
6.8 KiB
Go
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"
|
|
}
|