218 lines
6.5 KiB
Go
218 lines
6.5 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
|
|
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
|
|
}
|