Files
silo/internal/db/audit_queries.go
Forbes 73e6b813f4 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
2026-02-01 10:41:57 -06:00

167 lines
4.7 KiB
Go

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
}