- Add migration 013 to copy sourcing_link/standard_cost values into current revision properties JSONB and drop the columns from items table - Remove SourcingLink/StandardCost from Go Item struct and all DB queries (items.go, audit_queries.go, projects.go) - Remove from API request/response structs and handlers - Update CSV/ODS/BOM export/import to read these from revision properties - Update audit handlers to score as regular property fields - Remove from frontend Item type and hardcoded form fields - MainTab now reads sourcing_link/standard_cost from item.properties - CreateItemPane/EditItemPane no longer have dedicated fields for these; they will be rendered as schema-driven property fields
626 lines
17 KiB
Go
626 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,
|
|
"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
|
|
}
|