- 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
167 lines
4.7 KiB
Go
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
|
|
}
|