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

106 lines
5.0 KiB
Go

package businessscope
import (
"context"
"database/sql"
"fmt"
"time"
)
type PublishResult struct {
Changed bool
ActiveVersion string
}
func ValidateSnapshot(snapshot Snapshot, maxRejected int, maxRejectRatio float64) error {
if snapshot.Candidates <= 0 {
return fmt.Errorf("OneOS scope snapshot contains no candidate rows")
}
if len(snapshot.Items) <= 0 {
return fmt.Errorf("OneOS scope snapshot contains no publishable rows")
}
if snapshot.Candidates != len(snapshot.Items)+len(snapshot.Rejections) {
return fmt.Errorf("scope accounting mismatch: candidates=%d accepted=%d rejected=%d", snapshot.Candidates, len(snapshot.Items), len(snapshot.Rejections))
}
if maxRejected >= 0 && len(snapshot.Rejections) > maxRejected {
return fmt.Errorf("scope rejected rows exceed limit: rejected=%d limit=%d", len(snapshot.Rejections), maxRejected)
}
ratio := float64(len(snapshot.Rejections)) / float64(snapshot.Candidates)
if maxRejectRatio >= 0 && ratio > maxRejectRatio {
return fmt.Errorf("scope rejected ratio exceeds limit: ratio=%.6f limit=%.6f", ratio, maxRejectRatio)
}
return nil
}
func Publish(ctx context.Context, db *sql.DB, snapshot Snapshot) (PublishResult, error) {
tx, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
if err != nil {
return PublishResult{}, fmt.Errorf("begin scope publish: %w", err)
}
defer tx.Rollback()
var activeVersion sql.NullString
if err := tx.QueryRowContext(ctx, `SELECT active_version FROM business_scope_state WHERE id=1 FOR UPDATE`).Scan(&activeVersion); err != nil {
return PublishResult{}, fmt.Errorf("lock business scope state; apply migration 012_business_scope_projection.sql: %w", err)
}
status := "published"
changed := !activeVersion.Valid || activeVersion.String != snapshot.SourceVersion
if !changed {
status = "unchanged"
}
now := time.Now().UTC()
if _, err := tx.ExecContext(ctx, `INSERT INTO business_scope_sync_run(
run_id,source_system,source_version,status,candidate_count,accepted_count,rejected_count,source_checksum,started_at,finished_at
) VALUES(?,?,?,?,?,?,?,?,?,?)`, snapshot.RunID, SourceSystem, snapshot.SourceVersion, status, snapshot.Candidates, len(snapshot.Items), len(snapshot.Rejections), snapshot.Checksum, snapshot.GeneratedAt, now); err != nil {
return PublishResult{}, fmt.Errorf("insert business scope sync run: %w", err)
}
if changed {
// A source version can become active again after the source data reverts.
// Rebuild that inactive version while holding the state lock so publishing
// remains atomic and cannot fail on its existing primary keys.
if _, err := tx.ExecContext(ctx, `DELETE FROM business_customer_vehicle_scope WHERE source_version=?`, snapshot.SourceVersion); err != nil {
return PublishResult{}, fmt.Errorf("clear inactive business scope version: %w", err)
}
statement, err := tx.PrepareContext(ctx, `INSERT INTO business_customer_vehicle_scope(
source_version,customer_id,vin,vehicle_id,contract_id,contract_code,plate_number,project_name,operation_status,scope_start_at,source_updated_at,published_at
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`)
if err != nil {
return PublishResult{}, fmt.Errorf("prepare business scope insert: %w", err)
}
defer statement.Close()
for _, item := range snapshot.Items {
var contractID any
if item.ContractID > 0 {
contractID = item.ContractID
}
if _, err := statement.ExecContext(ctx, snapshot.SourceVersion, item.CustomerID, item.VIN, item.VehicleID, contractID,
item.ContractCode, item.PlateNumber, item.ProjectName, item.OperationStatus, item.ScopeStartAt, item.SourceUpdatedAt, now); err != nil {
return PublishResult{}, fmt.Errorf("insert business scope item: %w", err)
}
}
}
if len(snapshot.Rejections) > 0 {
statement, err := tx.PrepareContext(ctx, `INSERT INTO business_scope_rejection(
run_id,row_number,vehicle_id,vin,customer_id,contract_id,reason_code,created_at
) VALUES(?,?,?,?,?,?,?,?)`)
if err != nil {
return PublishResult{}, fmt.Errorf("prepare scope rejection insert: %w", err)
}
defer statement.Close()
for _, rejected := range snapshot.Rejections {
if _, err := statement.ExecContext(ctx, snapshot.RunID, rejected.RowNumber, rejected.VehicleID, rejected.VIN,
rejected.CustomerID, rejected.ContractID, rejected.ReasonCode, now); err != nil {
return PublishResult{}, fmt.Errorf("insert business scope rejection: %w", err)
}
}
}
if _, err := tx.ExecContext(ctx, `UPDATE business_scope_state SET
active_version=?,source_checksum=?,candidate_count=?,accepted_count=?,rejected_count=?,generated_at=?,published_at=?,last_success_at=?
WHERE id=1`, snapshot.SourceVersion, snapshot.Checksum, snapshot.Candidates, len(snapshot.Items), len(snapshot.Rejections), snapshot.GeneratedAt, now, now); err != nil {
return PublishResult{}, fmt.Errorf("activate business scope snapshot: %w", err)
}
if err := tx.Commit(); err != nil {
return PublishResult{}, fmt.Errorf("commit business scope snapshot: %w", err)
}
return PublishResult{Changed: changed, ActiveVersion: snapshot.SourceVersion}, nil
}