feat(platform): consolidate production vehicle data workflows

This commit is contained in:
lingniu
2026-07-15 23:26:29 +08:00
parent 6cddc0a43d
commit 3fabcf181a
59 changed files with 6849 additions and 3532 deletions

View File

@@ -0,0 +1,205 @@
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
}