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 }