package analytics import ( "context" "database/sql" "math" "sort" "strconv" "time" ) type Repository struct { db *sql.DB } func NewRepository(db *sql.DB) *Repository { return &Repository{db: db} } type LotMetrics struct { LotID *int64 LotCode *string Failures int64 ComponentCount int64 ExposureSeconds float64 ExposureHours float64 ExposureYears float64 AFR float64 MTBFHours *float64 } type FirmwareRisk struct { FirmwareVersion string Failures int64 ComponentCount int64 FailureRate *float64 } type SpareForecast struct { LotID *int64 LotCode *string ComponentCount int64 AFR float64 ExpectedFailures float64 SparesNeeded int64 } func (r *Repository) ListLotMetrics(ctx context.Context, start, end time.Time) ([]LotMetrics, error) { exposures, err := r.loadLotExposures(ctx, start, end) if err != nil { return nil, err } failures, err := r.loadLotFailures(ctx, start, end) if err != nil { return nil, err } metrics := map[string]*LotMetrics{} for key, exposure := range exposures { metrics[key] = exposure } for key, failure := range failures { entry, ok := metrics[key] if !ok { entry = &LotMetrics{ LotID: failure.LotID, LotCode: failure.LotCode, } metrics[key] = entry } entry.Failures = failure.Failures } items := make([]LotMetrics, 0, len(metrics)) for _, metric := range metrics { metric.ExposureHours = metric.ExposureSeconds / 3600 metric.ExposureYears = metric.ExposureSeconds / (3600 * 24 * 365) if metric.ExposureYears > 0 { metric.AFR = float64(metric.Failures) / metric.ExposureYears } if metric.Failures > 0 && metric.ExposureHours > 0 { value := metric.ExposureHours / float64(metric.Failures) metric.MTBFHours = &value } items = append(items, *metric) } sort.Slice(items, func(i, j int) bool { if items[i].LotCode == nil && items[j].LotCode != nil { return false } if items[i].LotCode != nil && items[j].LotCode == nil { return true } if items[i].LotCode != nil && items[j].LotCode != nil && *items[i].LotCode != *items[j].LotCode { return *items[i].LotCode < *items[j].LotCode } if items[i].LotID == nil && items[j].LotID != nil { return false } if items[i].LotID != nil && items[j].LotID == nil { return true } if items[i].LotID != nil && items[j].LotID != nil { return *items[i].LotID < *items[j].LotID } return false }) return items, nil } func (r *Repository) ListFirmwareRisk(ctx context.Context, start, end time.Time) ([]FirmwareRisk, error) { failures, err := r.loadFirmwareFailures(ctx, start, end) if err != nil { return nil, err } components, err := r.loadFirmwareComponents(ctx, start, end) if err != nil { return nil, err } items := make([]FirmwareRisk, 0, len(components)) seen := map[string]bool{} for firmware, count := range components { entry := FirmwareRisk{ FirmwareVersion: firmware, ComponentCount: count, Failures: failures[firmware], } if entry.ComponentCount > 0 { value := float64(entry.Failures) / float64(entry.ComponentCount) entry.FailureRate = &value } items = append(items, entry) seen[firmware] = true } for firmware, count := range failures { if seen[firmware] { continue } entry := FirmwareRisk{ FirmwareVersion: firmware, ComponentCount: 0, Failures: count, } items = append(items, entry) } sort.Slice(items, func(i, j int) bool { return items[i].FirmwareVersion < items[j].FirmwareVersion }) return items, nil } func (r *Repository) ForecastSpare(ctx context.Context, start, end time.Time, horizonDays int, multiplier float64) ([]SpareForecast, error) { metrics, err := r.ListLotMetrics(ctx, start, end) if err != nil { return nil, err } horizonYears := float64(horizonDays) / 365 items := make([]SpareForecast, 0, len(metrics)) for _, metric := range metrics { expected := metric.AFR * float64(metric.ComponentCount) * horizonYears if expected < 0 { expected = 0 } spares := int64(math.Ceil(expected * multiplier)) items = append(items, SpareForecast{ LotID: metric.LotID, LotCode: metric.LotCode, ComponentCount: metric.ComponentCount, AFR: metric.AFR, ExpectedFailures: expected, SparesNeeded: spares, }) } return items, nil } type lotMetricsRow struct { LotID *int64 LotCode *string Failures int64 ComponentCount int64 ExposureSeconds float64 } func (r *Repository) loadLotExposures(ctx context.Context, start, end time.Time) (map[string]*LotMetrics, error) { rows, err := r.db.QueryContext(ctx, ` SELECT l.id, l.code, COUNT(DISTINCT i.component_id) AS component_count, SUM(GREATEST(0, TIMESTAMPDIFF(SECOND, GREATEST(i.installed_at, ?), LEAST(COALESCE(i.removed_at, ?), ?) ))) AS exposure_seconds FROM installations i JOIN components c ON c.id = i.component_id LEFT JOIN lots l ON l.id = c.lot_id WHERE i.installed_at <= ? AND (i.removed_at IS NULL OR i.removed_at >= ?) GROUP BY l.id, l.code ORDER BY l.code, l.id`, start, end, end, end, start, ) if err != nil { return nil, err } defer rows.Close() metrics := map[string]*LotMetrics{} for rows.Next() { var lotID sql.NullInt64 var lotCode sql.NullString var componentCount sql.NullInt64 var exposureSeconds sql.NullFloat64 if err := rows.Scan(&lotID, &lotCode, &componentCount, &exposureSeconds); err != nil { return nil, err } entry := LotMetrics{ LotID: nullInt64Ptr(lotID), LotCode: nullStringPtr(lotCode), ComponentCount: nullInt64Value(componentCount), ExposureSeconds: nullFloatValue(exposureSeconds), } metrics[lotKey(entry.LotID, entry.LotCode)] = &entry } if err := rows.Err(); err != nil { return nil, err } return metrics, nil } func (r *Repository) loadLotFailures(ctx context.Context, start, end time.Time) (map[string]lotMetricsRow, error) { rows, err := r.db.QueryContext(ctx, ` SELECT l.id, l.code, COUNT(*) AS failures FROM failure_events f JOIN components c ON c.id = f.component_id LEFT JOIN lots l ON l.id = c.lot_id WHERE f.failure_time >= ? AND f.failure_time <= ? GROUP BY l.id, l.code ORDER BY l.code, l.id`, start, end, ) if err != nil { return nil, err } defer rows.Close() metrics := map[string]lotMetricsRow{} for rows.Next() { var lotID sql.NullInt64 var lotCode sql.NullString var failures sql.NullInt64 if err := rows.Scan(&lotID, &lotCode, &failures); err != nil { return nil, err } entry := lotMetricsRow{ LotID: nullInt64Ptr(lotID), LotCode: nullStringPtr(lotCode), Failures: nullInt64Value(failures), } metrics[lotKey(entry.LotID, entry.LotCode)] = entry } if err := rows.Err(); err != nil { return nil, err } return metrics, nil } func (r *Repository) loadFirmwareFailures(ctx context.Context, start, end time.Time) (map[string]int64, error) { rows, err := r.db.QueryContext(ctx, ` SELECT o.firmware_version FROM failure_events f LEFT JOIN observations o ON o.component_id = f.component_id AND o.observed_at = ( SELECT MAX(o2.observed_at) FROM observations o2 WHERE o2.component_id = f.component_id AND o2.observed_at <= f.failure_time ) WHERE f.failure_time >= ? AND f.failure_time <= ?`, start, end, ) if err != nil { return nil, err } defer rows.Close() failures := map[string]int64{} for rows.Next() { var firmware sql.NullString if err := rows.Scan(&firmware); err != nil { return nil, err } key := firmwareValue(firmware) failures[key]++ } if err := rows.Err(); err != nil { return nil, err } return failures, nil } func (r *Repository) loadFirmwareComponents(ctx context.Context, start, end time.Time) (map[string]int64, error) { rows, err := r.db.QueryContext(ctx, ` SELECT o.component_id, o.firmware_version FROM observations o JOIN ( SELECT component_id, MAX(observed_at) AS observed_at FROM observations WHERE observed_at >= ? AND observed_at <= ? GROUP BY component_id ) latest ON latest.component_id = o.component_id AND latest.observed_at = o.observed_at`, start, end, ) if err != nil { return nil, err } defer rows.Close() components := map[string]int64{} for rows.Next() { var componentID sql.NullInt64 var firmware sql.NullString if err := rows.Scan(&componentID, &firmware); err != nil { return nil, err } key := firmwareValue(firmware) components[key]++ } if err := rows.Err(); err != nil { return nil, err } return components, nil } func lotKey(id *int64, code *string) string { if id == nil && code == nil { return "unknown" } if id == nil { return "code:" + *code } if code == nil { return "id:" + int64ToString(*id) } return "id:" + int64ToString(*id) + "|code:" + *code } func firmwareValue(value sql.NullString) string { if value.Valid && value.String != "" { return value.String } return "unknown" } func nullInt64Ptr(value sql.NullInt64) *int64 { if !value.Valid { return nil } v := value.Int64 return &v } func nullStringPtr(value sql.NullString) *string { if !value.Valid { return nil } v := value.String return &v } func nullInt64Value(value sql.NullInt64) int64 { if !value.Valid { return 0 } return value.Int64 } func nullFloatValue(value sql.NullFloat64) float64 { if !value.Valid { return 0 } return value.Float64 } func int64ToString(value int64) string { return strconv.FormatInt(value, 10) }