feat: add BOM system with API, database repository, and FreeCAD workbench command
Implement the full Bill of Materials stack on top of the existing
relationships table and bom_single_level view from migration 001.
API endpoints (6 new routes under /api/items/{partNumber}/bom):
- GET /bom Single-level BOM for an item
- GET /bom/expanded Multi-level BOM via recursive CTE (depth param)
- GET /bom/where-used Reverse lookup: which parents use this item
- POST /bom Add child to BOM with quantity, ref designators
- PUT /bom/{child} Update relationship type, quantity, ref des
- DELETE /bom/{child} Remove child from BOM
Database layer (internal/db/relationships.go):
- RelationshipRepository with full CRUD operations
- Single-level BOM query joining relationships with items
- Multi-level BOM expansion via recursive CTE (max depth 20)
- Where-used reverse lookup query
- Cycle detection at insert time to prevent circular BOMs
- BOMEntry and BOMTreeEntry types for denormalized query results
Server wiring:
- Added RelationshipRepository to Server struct in handlers.go
- Registered BOM routes in routes.go under /{partNumber} subrouter
FreeCAD workbench (pkg/freecad/silo_commands.py):
- 9 new BOM methods on SiloClient (get, expanded, where-used, add,
update, delete)
- Silo_BOM command class with two-tab dialog:
- BOM tab: table of children with Add/Edit/Remove buttons
- Where Used tab: read-only table of parent assemblies
- Add sub-dialog with fields for part number, type, qty, unit, ref des
- Edit sub-dialog pre-populated with current values
- Remove with confirmation prompt
- silo-bom.svg icon matching existing toolbar style
- Command registered in InitGui.py toolbar
No new migrations required - uses existing relationships table and
bom_single_level view from 001_initial.sql.
This commit is contained in:
430
internal/db/relationships.go
Normal file
430
internal/db/relationships.go
Normal file
@@ -0,0 +1,430 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// Relationship represents a BOM relationship between two items.
|
||||
type Relationship struct {
|
||||
ID string
|
||||
ParentItemID string
|
||||
ChildItemID string
|
||||
RelType string // "component", "alternate", "reference"
|
||||
Quantity *float64
|
||||
Unit *string
|
||||
ReferenceDesignators []string
|
||||
ChildRevision *int
|
||||
Metadata map[string]any
|
||||
ParentRevisionID *string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// BOMEntry is a denormalized row for BOM display, combining relationship
|
||||
// and item data.
|
||||
type BOMEntry struct {
|
||||
RelationshipID string
|
||||
ParentItemID string
|
||||
ParentPartNumber string
|
||||
ParentDescription string
|
||||
ChildItemID string
|
||||
ChildPartNumber string
|
||||
ChildDescription string
|
||||
RelType string
|
||||
Quantity *float64
|
||||
Unit *string
|
||||
ReferenceDesignators []string
|
||||
ChildRevision *int
|
||||
EffectiveRevision int
|
||||
}
|
||||
|
||||
// BOMTreeEntry extends BOMEntry with depth for multi-level BOM expansion.
|
||||
type BOMTreeEntry struct {
|
||||
BOMEntry
|
||||
Depth int
|
||||
}
|
||||
|
||||
// RelationshipRepository provides BOM/relationship database operations.
|
||||
type RelationshipRepository struct {
|
||||
db *DB
|
||||
}
|
||||
|
||||
// NewRelationshipRepository creates a new relationship repository.
|
||||
func NewRelationshipRepository(db *DB) *RelationshipRepository {
|
||||
return &RelationshipRepository{db: db}
|
||||
}
|
||||
|
||||
// Create inserts a new relationship. Returns an error if a cycle would be
|
||||
// created or the relationship already exists.
|
||||
func (r *RelationshipRepository) Create(ctx context.Context, rel *Relationship) error {
|
||||
// Check for cycles before inserting
|
||||
hasCycle, err := r.HasCycle(ctx, rel.ParentItemID, rel.ChildItemID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("checking for cycles: %w", err)
|
||||
}
|
||||
if hasCycle {
|
||||
return fmt.Errorf("adding this relationship would create a cycle")
|
||||
}
|
||||
|
||||
var metadataJSON []byte
|
||||
if rel.Metadata != nil {
|
||||
metadataJSON, err = json.Marshal(rel.Metadata)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshaling metadata: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
err = r.db.pool.QueryRow(ctx, `
|
||||
INSERT INTO relationships (
|
||||
parent_item_id, child_item_id, rel_type, quantity, unit,
|
||||
reference_designators, child_revision, metadata, parent_revision_id
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING id, created_at, updated_at
|
||||
`, rel.ParentItemID, rel.ChildItemID, rel.RelType, rel.Quantity, rel.Unit,
|
||||
rel.ReferenceDesignators, rel.ChildRevision, metadataJSON, rel.ParentRevisionID,
|
||||
).Scan(&rel.ID, &rel.CreatedAt, &rel.UpdatedAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("inserting relationship: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update modifies an existing relationship's mutable fields.
|
||||
func (r *RelationshipRepository) Update(ctx context.Context, id string, relType *string, quantity *float64, unit *string, refDes []string, childRevision *int, metadata map[string]any) error {
|
||||
// Build dynamic update query
|
||||
query := "UPDATE relationships SET updated_at = now()"
|
||||
args := []any{}
|
||||
argNum := 1
|
||||
|
||||
if relType != nil {
|
||||
query += fmt.Sprintf(", rel_type = $%d", argNum)
|
||||
args = append(args, *relType)
|
||||
argNum++
|
||||
}
|
||||
|
||||
if quantity != nil {
|
||||
query += fmt.Sprintf(", quantity = $%d", argNum)
|
||||
args = append(args, *quantity)
|
||||
argNum++
|
||||
}
|
||||
|
||||
if unit != nil {
|
||||
query += fmt.Sprintf(", unit = $%d", argNum)
|
||||
args = append(args, *unit)
|
||||
argNum++
|
||||
}
|
||||
|
||||
if refDes != nil {
|
||||
query += fmt.Sprintf(", reference_designators = $%d", argNum)
|
||||
args = append(args, refDes)
|
||||
argNum++
|
||||
}
|
||||
|
||||
if childRevision != nil {
|
||||
query += fmt.Sprintf(", child_revision = $%d", argNum)
|
||||
args = append(args, *childRevision)
|
||||
argNum++
|
||||
}
|
||||
|
||||
if metadata != nil {
|
||||
metaJSON, err := json.Marshal(metadata)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshaling metadata: %w", err)
|
||||
}
|
||||
query += fmt.Sprintf(", metadata = $%d", argNum)
|
||||
args = append(args, metaJSON)
|
||||
argNum++
|
||||
}
|
||||
|
||||
query += fmt.Sprintf(" WHERE id = $%d", argNum)
|
||||
args = append(args, id)
|
||||
|
||||
result, err := r.db.pool.Exec(ctx, query, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("updating relationship: %w", err)
|
||||
}
|
||||
|
||||
if result.RowsAffected() == 0 {
|
||||
return fmt.Errorf("relationship not found")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes a relationship by ID.
|
||||
func (r *RelationshipRepository) Delete(ctx context.Context, id string) error {
|
||||
result, err := r.db.pool.Exec(ctx, `DELETE FROM relationships WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("deleting relationship: %w", err)
|
||||
}
|
||||
|
||||
if result.RowsAffected() == 0 {
|
||||
return fmt.Errorf("relationship not found")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetByID retrieves a relationship by its ID.
|
||||
func (r *RelationshipRepository) GetByID(ctx context.Context, id string) (*Relationship, error) {
|
||||
rel := &Relationship{}
|
||||
var metadataJSON []byte
|
||||
|
||||
err := r.db.pool.QueryRow(ctx, `
|
||||
SELECT id, parent_item_id, child_item_id, rel_type, quantity, unit,
|
||||
reference_designators, child_revision, metadata, parent_revision_id,
|
||||
created_at, updated_at
|
||||
FROM relationships
|
||||
WHERE id = $1
|
||||
`, id).Scan(
|
||||
&rel.ID, &rel.ParentItemID, &rel.ChildItemID, &rel.RelType, &rel.Quantity, &rel.Unit,
|
||||
&rel.ReferenceDesignators, &rel.ChildRevision, &metadataJSON, &rel.ParentRevisionID,
|
||||
&rel.CreatedAt, &rel.UpdatedAt,
|
||||
)
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("querying relationship: %w", err)
|
||||
}
|
||||
|
||||
if metadataJSON != nil {
|
||||
if err := json.Unmarshal(metadataJSON, &rel.Metadata); err != nil {
|
||||
return nil, fmt.Errorf("unmarshaling metadata: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return rel, nil
|
||||
}
|
||||
|
||||
// GetByParentAndChild retrieves a relationship between two specific items.
|
||||
func (r *RelationshipRepository) GetByParentAndChild(ctx context.Context, parentItemID, childItemID string) (*Relationship, error) {
|
||||
rel := &Relationship{}
|
||||
var metadataJSON []byte
|
||||
|
||||
err := r.db.pool.QueryRow(ctx, `
|
||||
SELECT id, parent_item_id, child_item_id, rel_type, quantity, unit,
|
||||
reference_designators, child_revision, metadata, parent_revision_id,
|
||||
created_at, updated_at
|
||||
FROM relationships
|
||||
WHERE parent_item_id = $1 AND child_item_id = $2
|
||||
`, parentItemID, childItemID).Scan(
|
||||
&rel.ID, &rel.ParentItemID, &rel.ChildItemID, &rel.RelType, &rel.Quantity, &rel.Unit,
|
||||
&rel.ReferenceDesignators, &rel.ChildRevision, &metadataJSON, &rel.ParentRevisionID,
|
||||
&rel.CreatedAt, &rel.UpdatedAt,
|
||||
)
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("querying relationship: %w", err)
|
||||
}
|
||||
|
||||
if metadataJSON != nil {
|
||||
if err := json.Unmarshal(metadataJSON, &rel.Metadata); err != nil {
|
||||
return nil, fmt.Errorf("unmarshaling metadata: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return rel, nil
|
||||
}
|
||||
|
||||
// GetBOM returns the single-level BOM for an item (its direct children).
|
||||
func (r *RelationshipRepository) GetBOM(ctx context.Context, parentItemID string) ([]*BOMEntry, error) {
|
||||
rows, err := r.db.pool.Query(ctx, `
|
||||
SELECT rel.id, rel.parent_item_id, parent.part_number, parent.description,
|
||||
rel.child_item_id, child.part_number, child.description,
|
||||
rel.rel_type, rel.quantity, rel.unit,
|
||||
rel.reference_designators, rel.child_revision,
|
||||
COALESCE(rel.child_revision, child.current_revision) AS effective_revision
|
||||
FROM relationships rel
|
||||
JOIN items parent ON parent.id = rel.parent_item_id
|
||||
JOIN items child ON child.id = rel.child_item_id
|
||||
WHERE rel.parent_item_id = $1
|
||||
AND parent.archived_at IS NULL
|
||||
AND child.archived_at IS NULL
|
||||
ORDER BY child.part_number
|
||||
`, parentItemID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("querying BOM: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return scanBOMEntries(rows)
|
||||
}
|
||||
|
||||
// GetWhereUsed returns all parent assemblies that reference the given item.
|
||||
func (r *RelationshipRepository) GetWhereUsed(ctx context.Context, childItemID string) ([]*BOMEntry, error) {
|
||||
rows, err := r.db.pool.Query(ctx, `
|
||||
SELECT rel.id, rel.parent_item_id, parent.part_number, parent.description,
|
||||
rel.child_item_id, child.part_number, child.description,
|
||||
rel.rel_type, rel.quantity, rel.unit,
|
||||
rel.reference_designators, rel.child_revision,
|
||||
COALESCE(rel.child_revision, child.current_revision) AS effective_revision
|
||||
FROM relationships rel
|
||||
JOIN items parent ON parent.id = rel.parent_item_id
|
||||
JOIN items child ON child.id = rel.child_item_id
|
||||
WHERE rel.child_item_id = $1
|
||||
AND parent.archived_at IS NULL
|
||||
AND child.archived_at IS NULL
|
||||
ORDER BY parent.part_number
|
||||
`, childItemID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("querying where-used: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return scanBOMEntries(rows)
|
||||
}
|
||||
|
||||
// GetExpandedBOM returns a multi-level BOM by recursively walking the
|
||||
// relationship tree. maxDepth limits recursion (capped at 20).
|
||||
func (r *RelationshipRepository) GetExpandedBOM(ctx context.Context, parentItemID string, maxDepth int) ([]*BOMTreeEntry, error) {
|
||||
if maxDepth <= 0 || maxDepth > 20 {
|
||||
maxDepth = 10
|
||||
}
|
||||
|
||||
rows, err := r.db.pool.Query(ctx, `
|
||||
WITH RECURSIVE bom_tree AS (
|
||||
-- Base case: direct children
|
||||
SELECT rel.id, rel.parent_item_id, parent.part_number AS parent_part_number,
|
||||
parent.description AS parent_description,
|
||||
rel.child_item_id, child.part_number AS child_part_number,
|
||||
child.description AS child_description,
|
||||
rel.rel_type, rel.quantity, rel.unit,
|
||||
rel.reference_designators, rel.child_revision,
|
||||
COALESCE(rel.child_revision, child.current_revision) AS effective_revision,
|
||||
1 AS depth
|
||||
FROM relationships rel
|
||||
JOIN items parent ON parent.id = rel.parent_item_id
|
||||
JOIN items child ON child.id = rel.child_item_id
|
||||
WHERE rel.parent_item_id = $1
|
||||
AND parent.archived_at IS NULL
|
||||
AND child.archived_at IS NULL
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Recursive case: children of children
|
||||
SELECT rel.id, rel.parent_item_id, parent.part_number,
|
||||
parent.description,
|
||||
rel.child_item_id, child.part_number,
|
||||
child.description,
|
||||
rel.rel_type, rel.quantity, rel.unit,
|
||||
rel.reference_designators, rel.child_revision,
|
||||
COALESCE(rel.child_revision, child.current_revision),
|
||||
bt.depth + 1
|
||||
FROM relationships rel
|
||||
JOIN items parent ON parent.id = rel.parent_item_id
|
||||
JOIN items child ON child.id = rel.child_item_id
|
||||
JOIN bom_tree bt ON bt.child_item_id = rel.parent_item_id
|
||||
WHERE bt.depth < $2
|
||||
AND parent.archived_at IS NULL
|
||||
AND child.archived_at IS NULL
|
||||
)
|
||||
SELECT id, parent_item_id, parent_part_number, parent_description,
|
||||
child_item_id, child_part_number, child_description,
|
||||
rel_type, quantity, unit, reference_designators,
|
||||
child_revision, effective_revision, depth
|
||||
FROM bom_tree
|
||||
ORDER BY depth, child_part_number
|
||||
`, parentItemID, maxDepth)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("querying expanded BOM: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var entries []*BOMTreeEntry
|
||||
for rows.Next() {
|
||||
e := &BOMTreeEntry{}
|
||||
var parentDesc, childDesc *string
|
||||
err := rows.Scan(
|
||||
&e.RelationshipID, &e.ParentItemID, &e.ParentPartNumber, &parentDesc,
|
||||
&e.ChildItemID, &e.ChildPartNumber, &childDesc,
|
||||
&e.RelType, &e.Quantity, &e.Unit,
|
||||
&e.ReferenceDesignators, &e.ChildRevision,
|
||||
&e.EffectiveRevision, &e.Depth,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scanning BOM tree entry: %w", err)
|
||||
}
|
||||
if parentDesc != nil {
|
||||
e.ParentDescription = *parentDesc
|
||||
}
|
||||
if childDesc != nil {
|
||||
e.ChildDescription = *childDesc
|
||||
}
|
||||
entries = append(entries, e)
|
||||
}
|
||||
|
||||
return entries, rows.Err()
|
||||
}
|
||||
|
||||
// HasCycle checks whether adding a relationship from parentItemID to
|
||||
// childItemID would create a cycle in the BOM graph. It walks upward
|
||||
// from parentItemID to see if childItemID is an ancestor.
|
||||
func (r *RelationshipRepository) HasCycle(ctx context.Context, parentItemID, childItemID string) (bool, error) {
|
||||
// A self-reference is always a cycle (also blocked by DB constraint).
|
||||
if parentItemID == childItemID {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Walk the ancestor chain of parentItemID. If childItemID appears
|
||||
// as an ancestor, adding childItemID as a child would create a cycle.
|
||||
var hasCycle bool
|
||||
err := r.db.pool.QueryRow(ctx, `
|
||||
WITH RECURSIVE ancestors AS (
|
||||
SELECT parent_item_id
|
||||
FROM relationships
|
||||
WHERE child_item_id = $1
|
||||
|
||||
UNION
|
||||
|
||||
SELECT rel.parent_item_id
|
||||
FROM relationships rel
|
||||
JOIN ancestors a ON a.parent_item_id = rel.child_item_id
|
||||
)
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM ancestors WHERE parent_item_id = $2
|
||||
)
|
||||
`, parentItemID, childItemID).Scan(&hasCycle)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("checking for cycle: %w", err)
|
||||
}
|
||||
|
||||
return hasCycle, nil
|
||||
}
|
||||
|
||||
// scanBOMEntries reads rows into BOMEntry slices.
|
||||
func scanBOMEntries(rows pgx.Rows) ([]*BOMEntry, error) {
|
||||
var entries []*BOMEntry
|
||||
for rows.Next() {
|
||||
e := &BOMEntry{}
|
||||
var parentDesc, childDesc *string
|
||||
err := rows.Scan(
|
||||
&e.RelationshipID, &e.ParentItemID, &e.ParentPartNumber, &parentDesc,
|
||||
&e.ChildItemID, &e.ChildPartNumber, &childDesc,
|
||||
&e.RelType, &e.Quantity, &e.Unit,
|
||||
&e.ReferenceDesignators, &e.ChildRevision,
|
||||
&e.EffectiveRevision,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scanning BOM entry: %w", err)
|
||||
}
|
||||
if parentDesc != nil {
|
||||
e.ParentDescription = *parentDesc
|
||||
}
|
||||
if childDesc != nil {
|
||||
e.ChildDescription = *childDesc
|
||||
}
|
||||
entries = append(entries, e)
|
||||
}
|
||||
|
||||
return entries, rows.Err()
|
||||
}
|
||||
Reference in New Issue
Block a user