feat: add component audit tool and category properties in create form
- New /audit page with completeness scoring engine
- Weighted scoring by sourcing type (purchased vs manufactured)
- Batch DB queries for items+properties, BOM existence, project codes
- API endpoints: GET /api/audit/completeness, GET /api/audit/completeness/{pn}
- Audit UI: tier summary bar, filterable table, split-panel inline editing
- Create item form now shows category-specific property fields on category select
- Properties collected and submitted with item creation
This commit is contained in:
622
internal/api/audit_handlers.go
Normal file
622
internal/api/audit_handlers.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
1014
internal/api/templates/audit.html
Normal file
1014
internal/api/templates/audit.html
Normal file
File diff suppressed because it is too large
Load Diff
@@ -482,6 +482,7 @@
|
||||
<a href="/" class="{{if eq .Page "items"}}active{{end}}">Items</a>
|
||||
<a href="/projects" class="{{if eq .Page "projects"}}active{{end}}">Projects</a>
|
||||
<a href="/schemas" class="{{if eq .Page "schemas"}}active{{end}}">Schemas</a>
|
||||
<a href="/audit" class="{{if eq .Page "audit"}}active{{end}}">Audit</a>
|
||||
<a href="/settings" class="{{if eq .Page "settings"}}active{{end}}">Settings</a>
|
||||
</nav>
|
||||
{{if .User}}
|
||||
@@ -505,6 +506,8 @@
|
||||
{{template "projects_content" .}}
|
||||
{{else if eq .Page "schemas"}}
|
||||
{{template "schemas_content" .}}
|
||||
{{else if eq .Page "audit"}}
|
||||
{{template "audit_content" .}}
|
||||
{{else if eq .Page "settings"}}
|
||||
{{template "settings_content" .}}
|
||||
{{end}}
|
||||
@@ -516,6 +519,8 @@
|
||||
{{template "projects_scripts" .}}
|
||||
{{else if eq .Page "schemas"}}
|
||||
{{template "schemas_scripts" .}}
|
||||
{{else if eq .Page "audit"}}
|
||||
{{template "audit_scripts" .}}
|
||||
{{else if eq .Page "settings"}}
|
||||
{{template "settings_scripts" .}}
|
||||
{{end}}
|
||||
|
||||
@@ -434,7 +434,12 @@
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Category</label>
|
||||
<select class="form-input" id="category" required>
|
||||
<select
|
||||
class="form-input"
|
||||
id="category"
|
||||
required
|
||||
onchange="onCategoryChange()"
|
||||
>
|
||||
<option value="">Select category...</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -496,6 +501,51 @@
|
||||
<div class="selected-tags" id="selected-tags"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Category Properties (dynamically populated) -->
|
||||
<div id="category-properties-section" style="display: none">
|
||||
<div
|
||||
style="
|
||||
border-top: 1px solid var(--ctp-surface1);
|
||||
margin: 1rem 0 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
"
|
||||
>
|
||||
<div
|
||||
style="
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.75rem;
|
||||
"
|
||||
>
|
||||
<span
|
||||
style="
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--ctp-subtext0);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
"
|
||||
>Category Properties</span
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
style="
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--ctp-overlay0);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
"
|
||||
onclick="toggleCategoryProps()"
|
||||
>
|
||||
collapse
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="category-properties-fields"></div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button
|
||||
type="button"
|
||||
@@ -1955,6 +2005,196 @@
|
||||
}
|
||||
}
|
||||
|
||||
// --- Category Properties in Create Form ---
|
||||
|
||||
// Fields already handled by the main create form
|
||||
const createFormHandledFields = new Set([
|
||||
"category",
|
||||
"description",
|
||||
"sourcing_type",
|
||||
"sourcing_link",
|
||||
"standard_cost",
|
||||
"long_description",
|
||||
]);
|
||||
|
||||
// Global default property keys for grouping
|
||||
const globalPropKeys = new Set([
|
||||
"manufacturer",
|
||||
"manufacturer_pn",
|
||||
"supplier",
|
||||
"supplier_pn",
|
||||
"sourcing_link",
|
||||
"standard_cost",
|
||||
"lead_time_days",
|
||||
"minimum_order_qty",
|
||||
"lifecycle_status",
|
||||
"rohs_compliant",
|
||||
"country_of_origin",
|
||||
"notes",
|
||||
]);
|
||||
|
||||
let categoryPropsCache = {};
|
||||
let categoryPropsCollapsed = false;
|
||||
|
||||
async function onCategoryChange() {
|
||||
const category = document.getElementById("category").value;
|
||||
const section = document.getElementById("category-properties-section");
|
||||
const container = document.getElementById("category-properties-fields");
|
||||
|
||||
if (!category) {
|
||||
section.style.display = "none";
|
||||
container.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch schema properties for this category
|
||||
try {
|
||||
const resp = await fetch(
|
||||
"/api/schemas/kindred-rd/properties?category=" +
|
||||
encodeURIComponent(category),
|
||||
);
|
||||
if (!resp.ok) {
|
||||
section.style.display = "none";
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
const props = data.properties || {};
|
||||
categoryPropsCache = props;
|
||||
|
||||
// Filter out fields handled by the main form
|
||||
const entries = Object.entries(props).filter(
|
||||
([key]) => !createFormHandledFields.has(key),
|
||||
);
|
||||
if (entries.length === 0) {
|
||||
section.style.display = "none";
|
||||
return;
|
||||
}
|
||||
|
||||
// Group into procurement and category-specific
|
||||
const procurementFields = entries.filter(([key]) =>
|
||||
globalPropKeys.has(key),
|
||||
);
|
||||
const categoryFields = entries.filter(
|
||||
([key]) => !globalPropKeys.has(key),
|
||||
);
|
||||
|
||||
let html = "";
|
||||
|
||||
if (procurementFields.length > 0) {
|
||||
html +=
|
||||
'<div style="font-size:0.8rem;font-weight:600;color:var(--ctp-subtext0);margin-bottom:0.5rem;text-transform:uppercase;letter-spacing:0.04em;">Procurement</div>';
|
||||
procurementFields
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
.forEach(([key, def]) => {
|
||||
html += renderCreatePropertyField(key, def);
|
||||
});
|
||||
}
|
||||
|
||||
if (categoryFields.length > 0) {
|
||||
if (procurementFields.length > 0) {
|
||||
html +=
|
||||
'<div style="border-top:1px solid var(--ctp-surface1);margin:0.75rem 0;"></div>';
|
||||
}
|
||||
const prefix = category[0];
|
||||
const groupNames = {
|
||||
F: "Fastener",
|
||||
C: "Fitting",
|
||||
R: "Motion",
|
||||
S: "Structural",
|
||||
E: "Electrical",
|
||||
M: "Mechanical",
|
||||
T: "Tooling",
|
||||
A: "Assembly",
|
||||
P: "Purchased",
|
||||
X: "Fabrication",
|
||||
};
|
||||
const groupName = groupNames[prefix] || "Category";
|
||||
html +=
|
||||
'<div style="font-size:0.8rem;font-weight:600;color:var(--ctp-subtext0);margin-bottom:0.5rem;text-transform:uppercase;letter-spacing:0.04em;">' +
|
||||
groupName +
|
||||
" Properties</div>";
|
||||
categoryFields
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
.forEach(([key, def]) => {
|
||||
html += renderCreatePropertyField(key, def);
|
||||
});
|
||||
}
|
||||
|
||||
container.innerHTML = html;
|
||||
section.style.display = "block";
|
||||
categoryPropsCollapsed = false;
|
||||
} catch (e) {
|
||||
console.error("Failed to load category properties:", e);
|
||||
section.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
function renderCreatePropertyField(key, def) {
|
||||
const label = key
|
||||
.replace(/_/g, " ")
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
const unit = def.unit
|
||||
? ' <span style="font-size:0.75rem;color:var(--ctp-overlay0);">' +
|
||||
def.unit +
|
||||
"</span>"
|
||||
: "";
|
||||
const defaultVal = def.default != null ? def.default : "";
|
||||
|
||||
let input = "";
|
||||
if (def.type === "boolean") {
|
||||
const checked = defaultVal === true ? "checked" : "";
|
||||
input =
|
||||
'<input type="checkbox" class="prop-field" data-key="' +
|
||||
key +
|
||||
'" data-type="boolean" ' +
|
||||
checked +
|
||||
' style="width:auto;margin-top:0.35rem;">';
|
||||
} else if (def.type === "number") {
|
||||
input =
|
||||
'<input type="number" step="any" class="form-input prop-field" data-key="' +
|
||||
key +
|
||||
'" data-type="number" value="' +
|
||||
(defaultVal || "") +
|
||||
'" placeholder="0" style="font-size:0.9rem;">';
|
||||
} else if (key === "lifecycle_status") {
|
||||
const opts = ["active", "deprecated", "obsolete", "prototype"];
|
||||
input =
|
||||
'<select class="form-input prop-field" data-key="' +
|
||||
key +
|
||||
'" data-type="string" style="font-size:0.9rem;">';
|
||||
input += '<option value="">--</option>';
|
||||
opts.forEach((o) => {
|
||||
const sel = defaultVal === o ? " selected" : "";
|
||||
input +=
|
||||
'<option value="' + o + '"' + sel + ">" + o + "</option>";
|
||||
});
|
||||
input += "</select>";
|
||||
} else {
|
||||
input =
|
||||
'<input type="text" class="form-input prop-field" data-key="' +
|
||||
key +
|
||||
'" data-type="string" value="' +
|
||||
(defaultVal || "") +
|
||||
'" placeholder="" style="font-size:0.9rem;">';
|
||||
}
|
||||
|
||||
return (
|
||||
'<div class="form-group" style="margin-bottom:0.75rem;">' +
|
||||
'<label class="form-label" style="font-size:0.85rem;">' +
|
||||
label +
|
||||
unit +
|
||||
"</label>" +
|
||||
input +
|
||||
"</div>"
|
||||
);
|
||||
}
|
||||
|
||||
function toggleCategoryProps() {
|
||||
const container = document.getElementById("category-properties-fields");
|
||||
categoryPropsCollapsed = !categoryPropsCollapsed;
|
||||
container.style.display = categoryPropsCollapsed ? "none" : "block";
|
||||
}
|
||||
|
||||
// Project tag management for create form
|
||||
function addProjectTag() {
|
||||
const select = document.getElementById("project-select");
|
||||
@@ -2317,6 +2557,10 @@
|
||||
document.getElementById("create-form").reset();
|
||||
selectedProjectTags = [];
|
||||
renderSelectedTags();
|
||||
// Reset category properties section
|
||||
document.getElementById("category-properties-section").style.display =
|
||||
"none";
|
||||
document.getElementById("category-properties-fields").innerHTML = "";
|
||||
}
|
||||
|
||||
async function createItem(event) {
|
||||
@@ -2349,6 +2593,29 @@
|
||||
data.projects = selectedProjectTags;
|
||||
}
|
||||
|
||||
// Collect category properties from dynamic fields
|
||||
const properties = {};
|
||||
document
|
||||
.querySelectorAll("#category-properties-fields .prop-field")
|
||||
.forEach((field) => {
|
||||
const key = field.dataset.key;
|
||||
const type = field.dataset.type;
|
||||
let value;
|
||||
if (type === "boolean") {
|
||||
value = field.checked;
|
||||
} else if (type === "number") {
|
||||
value = field.value !== "" ? parseFloat(field.value) : null;
|
||||
} else {
|
||||
value = field.value.trim();
|
||||
}
|
||||
if (value !== null && value !== "" && value !== undefined) {
|
||||
properties[key] = value;
|
||||
}
|
||||
});
|
||||
if (Object.keys(properties).length > 0) {
|
||||
data.properties = properties;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/items", {
|
||||
method: "POST",
|
||||
|
||||
@@ -100,3 +100,19 @@ func (h *WebHandler) HandleSchemasPage(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleAuditPage serves the component audit page.
|
||||
func (h *WebHandler) HandleAuditPage(w http.ResponseWriter, r *http.Request) {
|
||||
data := PageData{
|
||||
Title: "Audit",
|
||||
Page: "audit",
|
||||
User: auth.UserFromContext(r.Context()),
|
||||
CSRFToken: nosurf.Token(r),
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := h.templates.ExecuteTemplate(w, "base.html", data); err != nil {
|
||||
h.logger.Error().Err(err).Msg("failed to render template")
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
166
internal/db/audit_queries.go
Normal file
166
internal/db/audit_queries.go
Normal file
@@ -0,0 +1,166 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// AuditListOptions controls filtering for the batch audit item query.
|
||||
type AuditListOptions struct {
|
||||
Project string // filter by project code
|
||||
Category string // filter by category prefix ("F", "F01")
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
// ItemWithProperties combines an Item with its current revision properties.
|
||||
type ItemWithProperties struct {
|
||||
Item
|
||||
Properties map[string]any
|
||||
}
|
||||
|
||||
// ListItemsWithProperties returns items joined with their current revision
|
||||
// properties in a single query, avoiding the N+1 pattern.
|
||||
func (r *ItemRepository) ListItemsWithProperties(ctx context.Context, opts AuditListOptions) ([]*ItemWithProperties, error) {
|
||||
args := []any{}
|
||||
argNum := 1
|
||||
|
||||
var query string
|
||||
if opts.Project != "" {
|
||||
query = `
|
||||
SELECT DISTINCT i.id, i.part_number, i.schema_id, i.item_type, i.description,
|
||||
i.created_at, i.updated_at, i.archived_at, i.current_revision,
|
||||
i.sourcing_type, i.sourcing_link, i.long_description, i.standard_cost,
|
||||
COALESCE(r.properties, '{}'::jsonb) as properties
|
||||
FROM items i
|
||||
LEFT JOIN revisions r ON r.item_id = i.id AND r.revision_number = i.current_revision
|
||||
JOIN item_projects ip ON ip.item_id = i.id
|
||||
JOIN projects p ON p.id = ip.project_id
|
||||
WHERE i.archived_at IS NULL AND p.code = $1
|
||||
`
|
||||
args = append(args, opts.Project)
|
||||
argNum++
|
||||
} else {
|
||||
query = `
|
||||
SELECT i.id, i.part_number, i.schema_id, i.item_type, i.description,
|
||||
i.created_at, i.updated_at, i.archived_at, i.current_revision,
|
||||
i.sourcing_type, i.sourcing_link, i.long_description, i.standard_cost,
|
||||
COALESCE(r.properties, '{}'::jsonb) as properties
|
||||
FROM items i
|
||||
LEFT JOIN revisions r ON r.item_id = i.id AND r.revision_number = i.current_revision
|
||||
WHERE i.archived_at IS NULL
|
||||
`
|
||||
}
|
||||
|
||||
if opts.Category != "" {
|
||||
query += fmt.Sprintf(" AND i.part_number LIKE $%d", argNum)
|
||||
args = append(args, opts.Category+"%")
|
||||
argNum++
|
||||
}
|
||||
|
||||
query += " ORDER BY i.part_number"
|
||||
|
||||
if opts.Limit > 0 {
|
||||
query += fmt.Sprintf(" LIMIT $%d", argNum)
|
||||
args = append(args, opts.Limit)
|
||||
argNum++
|
||||
}
|
||||
|
||||
if opts.Offset > 0 {
|
||||
query += fmt.Sprintf(" OFFSET $%d", argNum)
|
||||
args = append(args, opts.Offset)
|
||||
}
|
||||
|
||||
rows, err := r.db.pool.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("querying items with properties: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var items []*ItemWithProperties
|
||||
for rows.Next() {
|
||||
iwp := &ItemWithProperties{}
|
||||
var propsJSON []byte
|
||||
err := rows.Scan(
|
||||
&iwp.ID, &iwp.PartNumber, &iwp.SchemaID, &iwp.ItemType, &iwp.Description,
|
||||
&iwp.CreatedAt, &iwp.UpdatedAt, &iwp.ArchivedAt, &iwp.CurrentRevision,
|
||||
&iwp.SourcingType, &iwp.SourcingLink, &iwp.LongDescription, &iwp.StandardCost,
|
||||
&propsJSON,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scanning item with properties: %w", err)
|
||||
}
|
||||
iwp.Properties = make(map[string]any)
|
||||
if len(propsJSON) > 0 {
|
||||
if err := json.Unmarshal(propsJSON, &iwp.Properties); err != nil {
|
||||
return nil, fmt.Errorf("unmarshaling properties for %s: %w", iwp.PartNumber, err)
|
||||
}
|
||||
}
|
||||
items = append(items, iwp)
|
||||
}
|
||||
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// BatchCheckBOM returns a map of item ID to BOM child count for the given
|
||||
// item IDs. Items not in the map have zero children.
|
||||
func (r *ItemRepository) BatchCheckBOM(ctx context.Context, itemIDs []string) (map[string]int, error) {
|
||||
if len(itemIDs) == 0 {
|
||||
return map[string]int{}, nil
|
||||
}
|
||||
|
||||
rows, err := r.db.pool.Query(ctx, `
|
||||
SELECT parent_item_id, COUNT(*) as child_count
|
||||
FROM relationships
|
||||
WHERE parent_item_id = ANY($1) AND rel_type = 'component'
|
||||
GROUP BY parent_item_id
|
||||
`, itemIDs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("batch checking BOM: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := make(map[string]int)
|
||||
for rows.Next() {
|
||||
var itemID string
|
||||
var count int
|
||||
if err := rows.Scan(&itemID, &count); err != nil {
|
||||
return nil, fmt.Errorf("scanning BOM count: %w", err)
|
||||
}
|
||||
result[itemID] = count
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// BatchGetProjectCodes returns a map of item ID to project code list for
|
||||
// the given item IDs.
|
||||
func (r *ItemRepository) BatchGetProjectCodes(ctx context.Context, itemIDs []string) (map[string][]string, error) {
|
||||
if len(itemIDs) == 0 {
|
||||
return map[string][]string{}, nil
|
||||
}
|
||||
|
||||
rows, err := r.db.pool.Query(ctx, `
|
||||
SELECT ip.item_id, p.code
|
||||
FROM item_projects ip
|
||||
JOIN projects p ON p.id = ip.project_id
|
||||
WHERE ip.item_id = ANY($1)
|
||||
ORDER BY p.code
|
||||
`, itemIDs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("batch getting project codes: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := make(map[string][]string)
|
||||
for rows.Next() {
|
||||
var itemID, code string
|
||||
if err := rows.Scan(&itemID, &code); err != nil {
|
||||
return nil, fmt.Errorf("scanning project code: %w", err)
|
||||
}
|
||||
result[itemID] = append(result[itemID], code)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
Reference in New Issue
Block a user