Files
silo/internal/api/audit_handlers.go
Forbes 50985ed805 feat: expose file attachment stats as item properties (#37)
Add file_count and files_total_size to item API responses, computed
via batch query on item_files table (no migration needed).

- Add BatchGetFileStats() to audit_queries.go (follows BatchCheckBOM pattern)
- Add file stats to ItemResponse, HandleListItems, HandleGetItem, HandleGetItemByUUID
- Add 'Files' column to ItemTable (default visible in vertical mode)
- Add has_files computed field to audit completeness scoring (weight 1 for manufactured)
2026-02-08 19:25:46 -06:00

644 lines
17 KiB
Go

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,
"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,
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 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)
processField("has_files", "computed", "boolean", hasFiles)
}
// 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
}
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
}