Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/api/internal/businessscope/model.go

206 lines
5.9 KiB
Go

package businessscope
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"sort"
"strings"
"time"
)
const SourceSystem = "oneos"
const (
ReasonVehicleMissing = "VEHICLE_MISSING"
ReasonVINMissing = "VIN_MISSING"
ReasonCustomerMissing = "CUSTOMER_MISSING"
ReasonCustomerProfileMissing = "CUSTOMER_PROFILE_MISSING"
ReasonContractMissing = "CONTRACT_MISSING"
ReasonContractProfileMissing = "CONTRACT_PROFILE_MISSING"
ReasonContractCustomerMissing = "CONTRACT_CUSTOMER_MISSING"
ReasonCustomerMismatch = "CUSTOMER_MISMATCH"
ReasonDuplicateVehicle = "DUPLICATE_VEHICLE_SCOPE"
ReasonDuplicateVIN = "DUPLICATE_VIN_SCOPE"
)
type Candidate struct {
RowNumber int
VehicleID int64
VehiclePresent bool
VIN string
PlateNumber string
CustomerID int64
CustomerPresent bool
CustomerProfileExists bool
ContractID int64
ContractPresent bool
ContractProfileExists bool
EffectiveCustomerID int64
EffectiveCustomerSet bool
ContractCode string
ProjectName string
OperationStatus string
ScopeStartAt time.Time
SourceUpdatedAt *time.Time
}
type ScopeItem struct {
VehicleID int64
VIN string
PlateNumber string
CustomerID int64
ContractID int64
ContractCode string
ProjectName string
OperationStatus string
ScopeStartAt time.Time
SourceUpdatedAt *time.Time
}
type Rejection struct {
RowNumber int
VehicleID *int64
VIN string
CustomerID *int64
ContractID *int64
ReasonCode string
}
type Snapshot struct {
RunID string
SourceVersion string
Checksum string
GeneratedAt time.Time
Candidates int
Items []ScopeItem
Rejections []Rejection
}
func BuildSnapshot(candidates []Candidate, generatedAt time.Time) (Snapshot, error) {
generatedAt = generatedAt.UTC()
reasons := make([]string, len(candidates))
vehicleRows := map[int64][]int{}
vinRows := map[string][]int{}
for i, candidate := range candidates {
candidate.VIN = normalizeVIN(candidate.VIN)
candidates[i] = candidate
reasons[i] = validateCandidate(candidate)
if reasons[i] == "" {
vehicleRows[candidate.VehicleID] = append(vehicleRows[candidate.VehicleID], i)
vinRows[candidate.VIN] = append(vinRows[candidate.VIN], i)
}
}
for _, indexes := range vehicleRows {
if len(indexes) > 1 {
for _, index := range indexes {
reasons[index] = ReasonDuplicateVehicle
}
}
}
for _, indexes := range vinRows {
if len(indexes) > 1 {
for _, index := range indexes {
if reasons[index] == "" {
reasons[index] = ReasonDuplicateVIN
}
}
}
}
items := make([]ScopeItem, 0, len(candidates))
rejections := make([]Rejection, 0)
for index, candidate := range candidates {
if reasons[index] != "" {
rejections = append(rejections, rejectionFor(candidate, reasons[index]))
continue
}
items = append(items, ScopeItem{
VehicleID: candidate.VehicleID, VIN: candidate.VIN, PlateNumber: strings.TrimSpace(candidate.PlateNumber),
CustomerID: candidate.CustomerID, ContractID: candidate.ContractID,
ContractCode: strings.TrimSpace(candidate.ContractCode), ProjectName: strings.TrimSpace(candidate.ProjectName),
OperationStatus: strings.TrimSpace(candidate.OperationStatus), ScopeStartAt: candidate.ScopeStartAt,
SourceUpdatedAt: candidate.SourceUpdatedAt,
})
}
sort.Slice(items, func(i, j int) bool {
if items[i].CustomerID != items[j].CustomerID {
return items[i].CustomerID < items[j].CustomerID
}
return items[i].VIN < items[j].VIN
})
sort.Slice(rejections, func(i, j int) bool { return rejections[i].RowNumber < rejections[j].RowNumber })
canonical, err := json.Marshal(items)
if err != nil {
return Snapshot{}, fmt.Errorf("marshal canonical scope: %w", err)
}
sum := sha256.Sum256(canonical)
checksum := hex.EncodeToString(sum[:])
runID, err := randomHex(16)
if err != nil {
return Snapshot{}, err
}
return Snapshot{
RunID: runID, SourceVersion: "oneos-v1:" + checksum, Checksum: checksum,
GeneratedAt: generatedAt, Candidates: len(candidates), Items: items, Rejections: rejections,
}, nil
}
func validateCandidate(candidate Candidate) string {
if !candidate.VehiclePresent || candidate.VehicleID <= 0 {
return ReasonVehicleMissing
}
if normalizeVIN(candidate.VIN) == "" {
return ReasonVINMissing
}
if !candidate.CustomerPresent || candidate.CustomerID <= 0 {
return ReasonCustomerMissing
}
if !candidate.CustomerProfileExists {
return ReasonCustomerProfileMissing
}
if !candidate.ContractPresent || candidate.ContractID <= 0 {
return ReasonContractMissing
}
if !candidate.ContractProfileExists {
return ReasonContractProfileMissing
}
if !candidate.EffectiveCustomerSet || candidate.EffectiveCustomerID <= 0 {
return ReasonContractCustomerMissing
}
if candidate.CustomerID != candidate.EffectiveCustomerID {
return ReasonCustomerMismatch
}
if candidate.ScopeStartAt.IsZero() {
return ReasonVehicleMissing
}
return ""
}
func rejectionFor(candidate Candidate, reason string) Rejection {
rejection := Rejection{RowNumber: candidate.RowNumber, VIN: normalizeVIN(candidate.VIN), ReasonCode: reason}
if candidate.VehicleID > 0 {
rejection.VehicleID = int64Pointer(candidate.VehicleID)
}
if candidate.CustomerPresent && candidate.CustomerID > 0 {
rejection.CustomerID = int64Pointer(candidate.CustomerID)
}
if candidate.ContractPresent && candidate.ContractID > 0 {
rejection.ContractID = int64Pointer(candidate.ContractID)
}
return rejection
}
func normalizeVIN(value string) string { return strings.ToUpper(strings.TrimSpace(value)) }
func int64Pointer(value int64) *int64 { return &value }
func randomHex(bytes int) (string, error) {
buffer := make([]byte, bytes)
if _, err := rand.Read(buffer); err != nil {
return "", fmt.Errorf("generate run id: %w", err)
}
return hex.EncodeToString(buffer), nil
}