diff --git a/internal/api/audit_handlers.go b/internal/api/audit_handlers.go new file mode 100644 index 0000000..9ca9c9f --- /dev/null +++ b/internal/api/audit_handlers.go @@ -0,0 +1,622 @@ +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 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, + "sourcing_link": true, + "standard_cost": 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, + 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 sourcingLinkVal any + if item.SourcingLink != nil { + sourcingLinkVal = *item.SourcingLink + } + processField("sourcing_link", "item", "string", sourcingLinkVal) + + var stdCostVal any + if item.StandardCost != nil { + stdCostVal = *item.StandardCost + } + processField("standard_cost", "item", "number", stdCostVal) + + 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) + } + + // Score property fields from schema. + for key, def := range categoryProps { + if skipFields[key] || itemLevelFields[key] { + continue + } + // sourcing_link and standard_cost are already handled at item level. + if key == "sourcing_link" || key == "standard_cost" { + 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 + } + + // 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 + projects := projectCodes[item.ID] + + result := scoreItem(item, categoryProps, hasBOM, bomCount, 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] + + // 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, 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 +} diff --git a/internal/api/routes.go b/internal/api/routes.go index b246a4f..eeab8f1 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -68,6 +68,7 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler { r.Get("/", webHandler.HandleIndex) r.Get("/projects", webHandler.HandleProjectsPage) r.Get("/schemas", webHandler.HandleSchemasPage) + r.Get("/audit", webHandler.HandleAuditPage) r.Get("/settings", server.HandleSettingsPage) r.Post("/settings/tokens", server.HandleCreateTokenWeb) r.Post("/settings/tokens/{id}/revoke", server.HandleRevokeTokenWeb) @@ -164,6 +165,12 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler { }) }) + // Audit (read-only, viewer role) + r.Route("/audit", func(r chi.Router) { + r.Get("/completeness", server.HandleAuditCompleteness) + r.Get("/completeness/{partNumber}", server.HandleAuditItemDetail) + }) + // Integrations (read: viewer, write: editor) r.Route("/integrations/odoo", func(r chi.Router) { r.Get("/config", server.HandleGetOdooConfig) diff --git a/internal/api/templates/audit.html b/internal/api/templates/audit.html new file mode 100644 index 0000000..f150c5b --- /dev/null +++ b/internal/api/templates/audit.html @@ -0,0 +1,1014 @@ +{{define "audit_content"}} + +
+ + +| Score | +Part Number | +Description | +Category | +Sourcing | +Missing | +
|---|---|---|---|---|---|
Select an item to audit
+Click a row to see field-by-field breakdown
+