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 CustomerName string CustomerPresent bool CustomerProfileExists bool ContractID int64 ContractPresent bool ContractProfileExists bool EffectiveCustomerID int64 EffectiveCustomerSet bool ContractCode string ProjectName string DepartmentID string DepartmentName string ResponsibleUserID string ResponsibleUserName string OperationStatus string ScopeStartAt time.Time SourceUpdatedAt *time.Time } type ScopeItem struct { VehicleID int64 VIN string PlateNumber string CustomerID int64 CustomerName string ContractID int64 ContractCode string ProjectName string DepartmentID string DepartmentName string ResponsibleUserID string ResponsibleUserName 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, CustomerName: strings.TrimSpace(candidate.CustomerName), ContractID: candidate.ContractID, ContractCode: strings.TrimSpace(candidate.ContractCode), ProjectName: strings.TrimSpace(candidate.ProjectName), DepartmentID: strings.TrimSpace(candidate.DepartmentID), DepartmentName: strings.TrimSpace(candidate.DepartmentName), ResponsibleUserID: strings.TrimSpace(candidate.ResponsibleUserID), ResponsibleUserName: strings.TrimSpace(candidate.ResponsibleUserName), 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 }