package api import ( "encoding/json" "fmt" "math" "net/http" "sort" "strconv" "strings" "github.com/go-chi/chi/v5" "github.com/kindredsystems/silo/internal/db" "github.com/kindredsystems/silo/internal/schema" ) // --- Response types --- // AuditFieldResult represents one field's audit status. type AuditFieldResult struct { Key string `json:"key"` Source string `json:"source"` // "item", "property", "computed" Weight float64 `json:"weight"` Value any `json:"value"` Filled bool `json:"filled"` } // AuditItemResult represents one item's completeness score. type AuditItemResult struct { PartNumber string `json:"part_number"` Description string `json:"description"` Category string `json:"category"` CategoryName string `json:"category_name"` SourcingType string `json:"sourcing_type"` Projects []string `json:"projects"` Score float64 `json:"score"` Tier string `json:"tier"` WeightedFilled float64 `json:"weighted_filled"` WeightedTotal float64 `json:"weighted_total"` HasBOM bool `json:"has_bom"` BOMChildren int `json:"bom_children"` MissingCritical []string `json:"missing_critical"` Missing []string `json:"missing"` UpdatedAt string `json:"updated_at"` Fields []AuditFieldResult `json:"fields,omitempty"` } // CategorySummary holds per-category audit stats. type CategorySummary struct { Count int `json:"count"` AvgScore float64 `json:"avg_score"` } // AuditSummary holds aggregate audit statistics. type AuditSummary struct { TotalItems int `json:"total_items"` AvgScore float64 `json:"avg_score"` ManufacturedWithoutBOM int `json:"manufactured_without_bom"` ByTier map[string]int `json:"by_tier"` ByCategory map[string]CategorySummary `json:"by_category"` } // AuditCompletenessResponse is the top-level response for the list endpoint. type AuditCompletenessResponse struct { Items []AuditItemResult `json:"items"` Summary AuditSummary `json:"summary"` } // --- Weight tables --- // Purchased parts field weights. var purchasedWeights = map[string]float64{ // Weight 3: critical for procurement "manufacturer_pn": 3, "sourcing_link": 3, // Weight 2: core procurement data "manufacturer": 2, "supplier": 2, "supplier_pn": 2, "standard_cost": 2, // Weight 1: important but less blocking "description": 1, "sourcing_type": 1, "lead_time_days": 1, "minimum_order_qty": 1, "lifecycle_status": 1, // Weight 0.5: nice to have "rohs_compliant": 0.5, "country_of_origin": 0.5, "notes": 0.5, "long_description": 0.5, } // Manufactured parts field weights. var manufacturedWeights = map[string]float64{ // Weight 3: critical "has_bom": 3, // Weight 2: core identification "description": 2, "standard_cost": 2, // Weight 1: engineering detail (category-specific default) "sourcing_type": 1, "lifecycle_status": 1, // Weight 1: engineering detail "has_files": 1, // Weight 0.5: less relevant for in-house "manufacturer": 0.5, "supplier": 0.5, "notes": 0.5, "long_description": 0.5, } // Item-level fields that are stored on the items table, not in revision properties. var itemLevelFields = map[string]bool{ "description": true, "sourcing_type": true, "long_description": true, } // Fields to skip when scoring (internal metadata). var skipFields = map[string]bool{ "category": true, } // --- Scoring functions --- // extractCategory parses the category code from a part number (e.g., "F01" from "F01-0042"). func extractCategory(partNumber string) string { idx := strings.Index(partNumber, "-") if idx > 0 { return partNumber[:idx] } return "" } // tierForScore returns the tier name for a given score. func tierForScore(score float64) string { switch { case score >= 1.0: return "complete" case score >= 0.75: return "good" case score >= 0.50: return "partial" case score >= 0.25: return "low" default: return "critical" } } // isFieldFilled checks whether a field value counts as populated. func isFieldFilled(value any, fieldType string) bool { if value == nil { return false } switch fieldType { case "string": s, ok := value.(string) return ok && strings.TrimSpace(s) != "" case "number": switch v := value.(type) { case float64: return v != 0 case int: return v != 0 case json.Number: f, err := v.Float64() return err == nil && f != 0 default: return false } case "boolean": // Non-null means filled (false is a valid answer). _, ok := value.(bool) return ok default: // For unknown types, treat any non-nil, non-empty-string as filled. s, ok := value.(string) if ok { return strings.TrimSpace(s) != "" } return true } } // getWeightForField returns the weight for a field given the sourcing type. // Category-specific properties not in the weight tables default to 1.0. func getWeightForField(fieldKey string, sourcingType string, isAssembly bool) float64 { var table map[string]float64 if sourcingType == "purchased" { table = purchasedWeights } else { table = manufacturedWeights } if w, ok := table[fieldKey]; ok { return w } // Category-specific properties default to 1.0. return 1.0 } // scoreItem computes the completeness score for a single item. // When includeFields is true, the Fields slice is populated for detail view. func scoreItem( item *db.ItemWithProperties, categoryProps map[string]schema.PropertyDefinition, hasBOM bool, bomChildCount int, hasFiles bool, categoryName string, projects []string, includeFields bool, ) *AuditItemResult { category := extractCategory(item.PartNumber) sourcingType := item.SourcingType if sourcingType == "" { sourcingType = "manufactured" } isAssembly := len(category) > 0 && category[0] == 'A' var fields []AuditFieldResult var weightedFilled, weightedTotal float64 var missing, missingCritical []string // Helper to process a single field. processField := func(key, source, fieldType string, value any) { if skipFields[key] { return } weight := getWeightForField(key, sourcingType, isAssembly) filled := isFieldFilled(value, fieldType) weightedTotal += weight if filled { weightedFilled += weight } else { missing = append(missing, key) if weight >= 3 { missingCritical = append(missingCritical, key) } } if includeFields { fields = append(fields, AuditFieldResult{ Key: key, Source: source, Weight: weight, Value: value, Filled: filled, }) } } // Score item-level fields. processField("description", "item", "string", item.Description) processField("sourcing_type", "item", "string", item.SourcingType) var longDescVal any if item.LongDescription != nil { longDescVal = *item.LongDescription } processField("long_description", "item", "string", longDescVal) // Score has_bom for manufactured/assembly items. if sourcingType == "manufactured" || isAssembly { processField("has_bom", "computed", "boolean", hasBOM) processField("has_files", "computed", "boolean", hasFiles) } // Score property fields from schema. for key, def := range categoryProps { if skipFields[key] || itemLevelFields[key] { continue } value := item.Properties[key] processField(key, "property", def.Type, value) } // Compute score. var score float64 if weightedTotal > 0 { score = weightedFilled / weightedTotal } score = math.Round(score*10000) / 10000 // 4 decimal places result := &AuditItemResult{ PartNumber: item.PartNumber, Description: item.Description, Category: category, CategoryName: categoryName, SourcingType: sourcingType, Projects: projects, Score: score, Tier: tierForScore(score), WeightedFilled: math.Round(weightedFilled*100) / 100, WeightedTotal: math.Round(weightedTotal*100) / 100, HasBOM: hasBOM, BOMChildren: bomChildCount, MissingCritical: missingCritical, Missing: missing, UpdatedAt: item.UpdatedAt.Format("2006-01-02T15:04:05Z"), } if includeFields { result.Fields = fields } // Ensure nil slices become empty arrays in JSON. if result.Projects == nil { result.Projects = []string{} } if result.MissingCritical == nil { result.MissingCritical = []string{} } if result.Missing == nil { result.Missing = []string{} } return result } // --- HTTP handlers --- // HandleAuditCompleteness returns completeness scores for items. func (s *Server) HandleAuditCompleteness(w http.ResponseWriter, r *http.Request) { ctx := r.Context() // Parse query parameters. project := r.URL.Query().Get("project") category := r.URL.Query().Get("category") sortParam := r.URL.Query().Get("sort") if sortParam == "" { sortParam = "score_asc" } var minScore, maxScore float64 minScore = -1 // sentinel: no filter maxScore = 2 // sentinel: no filter if v := r.URL.Query().Get("min_score"); v != "" { if f, err := strconv.ParseFloat(v, 64); err == nil { minScore = f } } if v := r.URL.Query().Get("max_score"); v != "" { if f, err := strconv.ParseFloat(v, 64); err == nil { maxScore = f } } limit := 100 if v := r.URL.Query().Get("limit"); v != "" { if l, err := strconv.Atoi(v); err == nil && l > 0 { limit = l } } offset := 0 if v := r.URL.Query().Get("offset"); v != "" { if o, err := strconv.Atoi(v); err == nil && o >= 0 { offset = o } } // Fetch items with properties. Don't apply limit/offset here because // we need to score and filter in Go first. items, err := s.items.ListItemsWithProperties(ctx, db.AuditListOptions{ Project: project, Category: category, }) if err != nil { s.logger.Error().Err(err).Msg("failed to list items for audit") writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load items") return } if items == nil { items = []*db.ItemWithProperties{} } // Collect item IDs for batch queries. itemIDs := make([]string, len(items)) for i, item := range items { itemIDs[i] = item.ID } // Batch fetch BOM counts and project codes. bomCounts, err := s.items.BatchCheckBOM(ctx, itemIDs) if err != nil { s.logger.Error().Err(err).Msg("failed to batch check BOM") writeError(w, http.StatusInternalServerError, "internal_error", "Failed to check BOMs") return } projectCodes, err := s.items.BatchGetProjectCodes(ctx, itemIDs) if err != nil { s.logger.Error().Err(err).Msg("failed to batch get project codes") writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load project codes") return } fileStats, err := s.items.BatchGetFileStats(ctx, itemIDs) if err != nil { s.logger.Error().Err(err).Msg("failed to batch get file stats") writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load file stats") return } // Look up the schema for category resolution. sch := s.schemas["kindred-rd"] var catSegment *schema.Segment if sch != nil { catSegment = sch.GetSegment("category") } // Score each item. var allResults []AuditItemResult for _, item := range items { cat := extractCategory(item.PartNumber) // Get category name from schema segment values. categoryName := cat if catSegment != nil { if name, ok := catSegment.Values[cat]; ok { categoryName = name } } // Get applicable properties for this category. var categoryProps map[string]schema.PropertyDefinition if sch != nil && sch.PropertySchemas != nil { categoryProps = sch.PropertySchemas.GetPropertiesForCategory(cat) } bomCount := bomCounts[item.ID] hasBOM := bomCount > 0 hasFiles := fileStats[item.ID].Count > 0 projects := projectCodes[item.ID] result := scoreItem(item, categoryProps, hasBOM, bomCount, hasFiles, categoryName, projects, false) allResults = append(allResults, *result) } // Filter by score range. var filtered []AuditItemResult for _, r := range allResults { if r.Score >= minScore && r.Score <= maxScore { filtered = append(filtered, r) } } // Sort. switch sortParam { case "score_desc": sort.Slice(filtered, func(i, j int) bool { return filtered[i].Score > filtered[j].Score }) case "part_number": sort.Slice(filtered, func(i, j int) bool { return filtered[i].PartNumber < filtered[j].PartNumber }) case "updated_at": sort.Slice(filtered, func(i, j int) bool { return filtered[i].UpdatedAt > filtered[j].UpdatedAt }) default: // score_asc sort.Slice(filtered, func(i, j int) bool { return filtered[i].Score < filtered[j].Score }) } // Compute summary from all results (before pagination). summary := computeSummary(allResults) // Paginate. total := len(filtered) if offset > total { offset = total } end := offset + limit if end > total { end = total } page := filtered[offset:end] if page == nil { page = []AuditItemResult{} } writeJSON(w, http.StatusOK, AuditCompletenessResponse{ Items: page, Summary: summary, }) } // HandleAuditItemDetail returns a single item's field-by-field audit breakdown. func (s *Server) HandleAuditItemDetail(w http.ResponseWriter, r *http.Request) { ctx := r.Context() partNumber := chi.URLParam(r, "partNumber") item, err := s.items.GetByPartNumber(ctx, partNumber) if err != nil { s.logger.Error().Err(err).Str("pn", partNumber).Msg("failed to get item for audit") writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load item") return } if item == nil { writeError(w, http.StatusNotFound, "not_found", fmt.Sprintf("Item %s not found", partNumber)) return } // Get current revision properties. properties := make(map[string]any) if item.CurrentRevision > 0 { rev, err := s.items.GetRevision(ctx, item.ID, item.CurrentRevision) if err != nil { s.logger.Error().Err(err).Str("pn", partNumber).Msg("failed to get revision for audit") writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load properties") return } if rev != nil { properties = rev.Properties } } iwp := &db.ItemWithProperties{ Item: *item, Properties: properties, } // Get BOM count. bomCounts, err := s.items.BatchCheckBOM(ctx, []string{item.ID}) if err != nil { s.logger.Error().Err(err).Str("pn", partNumber).Msg("failed to check BOM for audit") writeError(w, http.StatusInternalServerError, "internal_error", "Failed to check BOM") return } bomCount := bomCounts[item.ID] hasBOM := bomCount > 0 // Get project codes. projectCodes, err := s.items.BatchGetProjectCodes(ctx, []string{item.ID}) if err != nil { s.logger.Error().Err(err).Str("pn", partNumber).Msg("failed to get projects for audit") writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load projects") return } projects := projectCodes[item.ID] // Get file stats. fileStats, err := s.items.BatchGetFileStats(ctx, []string{item.ID}) if err != nil { s.logger.Error().Err(err).Str("pn", partNumber).Msg("failed to get file stats for audit") writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load file stats") return } hasFiles := fileStats[item.ID].Count > 0 // Category resolution. cat := extractCategory(item.PartNumber) categoryName := cat sch := s.schemas["kindred-rd"] if sch != nil { if seg := sch.GetSegment("category"); seg != nil { if name, ok := seg.Values[cat]; ok { categoryName = name } } } var categoryProps map[string]schema.PropertyDefinition if sch != nil && sch.PropertySchemas != nil { categoryProps = sch.PropertySchemas.GetPropertiesForCategory(cat) } result := scoreItem(iwp, categoryProps, hasBOM, bomCount, hasFiles, categoryName, projects, true) writeJSON(w, http.StatusOK, result) } // computeSummary builds aggregate statistics from scored items. func computeSummary(results []AuditItemResult) AuditSummary { summary := AuditSummary{ TotalItems: len(results), ByTier: map[string]int{ "critical": 0, "low": 0, "partial": 0, "good": 0, "complete": 0, }, ByCategory: make(map[string]CategorySummary), } if len(results) == 0 { return summary } var totalScore float64 mfgWithoutBOM := 0 // Per-category accumulators. catCounts := make(map[string]int) catScoreSums := make(map[string]float64) for _, r := range results { totalScore += r.Score summary.ByTier[r.Tier]++ if r.SourcingType == "manufactured" && !r.HasBOM { mfgWithoutBOM++ } // Category prefix (first character). catPrefix := "" if len(r.Category) > 0 { catPrefix = string(r.Category[0]) } catCounts[catPrefix]++ catScoreSums[catPrefix] += r.Score } summary.AvgScore = math.Round(totalScore/float64(len(results))*10000) / 10000 summary.ManufacturedWithoutBOM = mfgWithoutBOM for prefix, count := range catCounts { summary.ByCategory[prefix] = CategorySummary{ Count: count, AvgScore: math.Round(catScoreSums[prefix]/float64(count)*10000) / 10000, } } return summary }