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:
Forbes
2026-01-31 08:09:26 -06:00
parent 8b66274a7a
commit a2a36141f0
7 changed files with 1357 additions and 16 deletions

View File

@@ -0,0 +1,440 @@
package api
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
"github.com/kindredsystems/silo/internal/db"
)
// BOM API request/response types
// BOMEntryResponse represents a BOM entry in API responses.
type BOMEntryResponse struct {
ID string `json:"id"`
ChildPartNumber string `json:"child_part_number"`
ChildDescription string `json:"child_description"`
RelType string `json:"rel_type"`
Quantity *float64 `json:"quantity"`
Unit *string `json:"unit,omitempty"`
ReferenceDesignators []string `json:"reference_designators,omitempty"`
ChildRevision *int `json:"child_revision,omitempty"`
EffectiveRevision int `json:"effective_revision"`
Depth *int `json:"depth,omitempty"`
}
// WhereUsedResponse represents a where-used entry in API responses.
type WhereUsedResponse struct {
ID string `json:"id"`
ParentPartNumber string `json:"parent_part_number"`
ParentDescription string `json:"parent_description"`
RelType string `json:"rel_type"`
Quantity *float64 `json:"quantity"`
Unit *string `json:"unit,omitempty"`
ReferenceDesignators []string `json:"reference_designators,omitempty"`
}
// AddBOMEntryRequest represents a request to add a child to a BOM.
type AddBOMEntryRequest struct {
ChildPartNumber string `json:"child_part_number"`
RelType string `json:"rel_type"`
Quantity *float64 `json:"quantity"`
Unit *string `json:"unit,omitempty"`
ReferenceDesignators []string `json:"reference_designators,omitempty"`
ChildRevision *int `json:"child_revision,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
}
// UpdateBOMEntryRequest represents a request to update a BOM entry.
type UpdateBOMEntryRequest struct {
RelType *string `json:"rel_type,omitempty"`
Quantity *float64 `json:"quantity,omitempty"`
Unit *string `json:"unit,omitempty"`
ReferenceDesignators []string `json:"reference_designators,omitempty"`
ChildRevision *int `json:"child_revision,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
}
// HandleGetBOM returns the single-level BOM for an item.
func (s *Server) HandleGetBOM(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).Msg("failed to get item")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get item")
return
}
if item == nil {
writeError(w, http.StatusNotFound, "not_found", "Item not found")
return
}
entries, err := s.relationships.GetBOM(ctx, item.ID)
if err != nil {
s.logger.Error().Err(err).Msg("failed to get BOM")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get BOM")
return
}
response := make([]BOMEntryResponse, len(entries))
for i, e := range entries {
response[i] = bomEntryToResponse(e)
}
writeJSON(w, http.StatusOK, response)
}
// HandleGetExpandedBOM returns the multi-level BOM for an item.
// Query param: ?depth=N (default 10, max 20).
func (s *Server) HandleGetExpandedBOM(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).Msg("failed to get item")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get item")
return
}
if item == nil {
writeError(w, http.StatusNotFound, "not_found", "Item not found")
return
}
maxDepth := 10
if d := r.URL.Query().Get("depth"); d != "" {
if parsed, err := strconv.Atoi(d); err == nil && parsed > 0 && parsed <= 20 {
maxDepth = parsed
}
}
entries, err := s.relationships.GetExpandedBOM(ctx, item.ID, maxDepth)
if err != nil {
s.logger.Error().Err(err).Msg("failed to get expanded BOM")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get expanded BOM")
return
}
response := make([]BOMEntryResponse, len(entries))
for i, e := range entries {
resp := bomEntryToResponse(&e.BOMEntry)
resp.Depth = &e.Depth
response[i] = resp
}
writeJSON(w, http.StatusOK, response)
}
// HandleGetWhereUsed returns all parent assemblies that use the given item.
func (s *Server) HandleGetWhereUsed(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).Msg("failed to get item")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get item")
return
}
if item == nil {
writeError(w, http.StatusNotFound, "not_found", "Item not found")
return
}
entries, err := s.relationships.GetWhereUsed(ctx, item.ID)
if err != nil {
s.logger.Error().Err(err).Msg("failed to get where-used")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get where-used")
return
}
response := make([]WhereUsedResponse, len(entries))
for i, e := range entries {
response[i] = whereUsedToResponse(e)
}
writeJSON(w, http.StatusOK, response)
}
// HandleAddBOMEntry adds a child item to a parent item's BOM.
func (s *Server) HandleAddBOMEntry(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
partNumber := chi.URLParam(r, "partNumber")
parent, err := s.items.GetByPartNumber(ctx, partNumber)
if err != nil {
s.logger.Error().Err(err).Msg("failed to get parent item")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get parent item")
return
}
if parent == nil {
writeError(w, http.StatusNotFound, "not_found", "Parent item not found")
return
}
var req AddBOMEntryRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_json", err.Error())
return
}
if req.ChildPartNumber == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "child_part_number is required")
return
}
child, err := s.items.GetByPartNumber(ctx, req.ChildPartNumber)
if err != nil {
s.logger.Error().Err(err).Msg("failed to get child item")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get child item")
return
}
if child == nil {
writeError(w, http.StatusNotFound, "not_found", "Child item not found")
return
}
// Check if relationship already exists
existing, err := s.relationships.GetByParentAndChild(ctx, parent.ID, child.ID)
if err != nil {
s.logger.Error().Err(err).Msg("failed to check existing relationship")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to check existing relationship")
return
}
if existing != nil {
writeError(w, http.StatusConflict, "already_exists", "Relationship already exists between these items")
return
}
// Default relationship type
relType := req.RelType
if relType == "" {
relType = "component"
}
// Validate relationship type
switch relType {
case "component", "alternate", "reference":
// Valid
default:
writeError(w, http.StatusBadRequest, "invalid_rel_type", "rel_type must be component, alternate, or reference")
return
}
rel := &db.Relationship{
ParentItemID: parent.ID,
ChildItemID: child.ID,
RelType: relType,
Quantity: req.Quantity,
Unit: req.Unit,
ReferenceDesignators: req.ReferenceDesignators,
ChildRevision: req.ChildRevision,
Metadata: req.Metadata,
}
if err := s.relationships.Create(ctx, rel); err != nil {
if strings.Contains(err.Error(), "cycle") {
writeError(w, http.StatusBadRequest, "cycle_detected", err.Error())
return
}
s.logger.Error().Err(err).Msg("failed to create relationship")
writeError(w, http.StatusInternalServerError, "create_failed", err.Error())
return
}
s.logger.Info().
Str("parent", partNumber).
Str("child", req.ChildPartNumber).
Str("rel_type", relType).
Msg("BOM entry added")
// Return the created entry with full denormalized data
entry := &BOMEntryResponse{
ID: rel.ID,
ChildPartNumber: req.ChildPartNumber,
ChildDescription: child.Description,
RelType: relType,
Quantity: req.Quantity,
Unit: req.Unit,
ReferenceDesignators: req.ReferenceDesignators,
ChildRevision: req.ChildRevision,
EffectiveRevision: child.CurrentRevision,
}
if req.ChildRevision != nil {
entry.EffectiveRevision = *req.ChildRevision
}
writeJSON(w, http.StatusCreated, entry)
}
// HandleUpdateBOMEntry updates an existing BOM relationship.
func (s *Server) HandleUpdateBOMEntry(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
partNumber := chi.URLParam(r, "partNumber")
childPartNumber := chi.URLParam(r, "childPartNumber")
parent, err := s.items.GetByPartNumber(ctx, partNumber)
if err != nil {
s.logger.Error().Err(err).Msg("failed to get parent item")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get parent item")
return
}
if parent == nil {
writeError(w, http.StatusNotFound, "not_found", "Parent item not found")
return
}
child, err := s.items.GetByPartNumber(ctx, childPartNumber)
if err != nil {
s.logger.Error().Err(err).Msg("failed to get child item")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get child item")
return
}
if child == nil {
writeError(w, http.StatusNotFound, "not_found", "Child item not found")
return
}
rel, err := s.relationships.GetByParentAndChild(ctx, parent.ID, child.ID)
if err != nil {
s.logger.Error().Err(err).Msg("failed to get relationship")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get relationship")
return
}
if rel == nil {
writeError(w, http.StatusNotFound, "not_found", "Relationship not found")
return
}
var req UpdateBOMEntryRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_json", err.Error())
return
}
// Validate rel_type if provided
if req.RelType != nil {
switch *req.RelType {
case "component", "alternate", "reference":
// Valid
default:
writeError(w, http.StatusBadRequest, "invalid_rel_type", "rel_type must be component, alternate, or reference")
return
}
}
if err := s.relationships.Update(ctx, rel.ID, req.RelType, req.Quantity, req.Unit, req.ReferenceDesignators, req.ChildRevision, req.Metadata); err != nil {
s.logger.Error().Err(err).Msg("failed to update relationship")
writeError(w, http.StatusInternalServerError, "update_failed", err.Error())
return
}
// Reload and return updated entry
entries, err := s.relationships.GetBOM(ctx, parent.ID)
if err == nil {
for _, e := range entries {
if e.ChildPartNumber == childPartNumber {
writeJSON(w, http.StatusOK, bomEntryToResponse(e))
return
}
}
}
// Fallback: return 200 with minimal info
writeJSON(w, http.StatusOK, map[string]string{"status": "updated"})
}
// HandleDeleteBOMEntry removes a child from a parent's BOM.
func (s *Server) HandleDeleteBOMEntry(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
partNumber := chi.URLParam(r, "partNumber")
childPartNumber := chi.URLParam(r, "childPartNumber")
parent, err := s.items.GetByPartNumber(ctx, partNumber)
if err != nil {
s.logger.Error().Err(err).Msg("failed to get parent item")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get parent item")
return
}
if parent == nil {
writeError(w, http.StatusNotFound, "not_found", "Parent item not found")
return
}
child, err := s.items.GetByPartNumber(ctx, childPartNumber)
if err != nil {
s.logger.Error().Err(err).Msg("failed to get child item")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get child item")
return
}
if child == nil {
writeError(w, http.StatusNotFound, "not_found", "Child item not found")
return
}
rel, err := s.relationships.GetByParentAndChild(ctx, parent.ID, child.ID)
if err != nil {
s.logger.Error().Err(err).Msg("failed to get relationship")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get relationship")
return
}
if rel == nil {
writeError(w, http.StatusNotFound, "not_found", "Relationship not found")
return
}
if err := s.relationships.Delete(ctx, rel.ID); err != nil {
s.logger.Error().Err(err).Msg("failed to delete relationship")
writeError(w, http.StatusInternalServerError, "delete_failed", err.Error())
return
}
s.logger.Info().
Str("parent", partNumber).
Str("child", childPartNumber).
Msg("BOM entry removed")
w.WriteHeader(http.StatusNoContent)
}
// Helper functions
func bomEntryToResponse(e *db.BOMEntry) BOMEntryResponse {
refDes := e.ReferenceDesignators
if refDes == nil {
refDes = []string{}
}
return BOMEntryResponse{
ID: e.RelationshipID,
ChildPartNumber: e.ChildPartNumber,
ChildDescription: e.ChildDescription,
RelType: e.RelType,
Quantity: e.Quantity,
Unit: e.Unit,
ReferenceDesignators: refDes,
ChildRevision: e.ChildRevision,
EffectiveRevision: e.EffectiveRevision,
}
}
func whereUsedToResponse(e *db.BOMEntry) WhereUsedResponse {
refDes := e.ReferenceDesignators
if refDes == nil {
refDes = []string{}
}
return WhereUsedResponse{
ID: e.RelationshipID,
ParentPartNumber: e.ParentPartNumber,
ParentDescription: e.ParentDescription,
RelType: e.RelType,
Quantity: e.Quantity,
Unit: e.Unit,
ReferenceDesignators: refDes,
}
}

View File

@@ -20,14 +20,15 @@ import (
// Server holds dependencies for HTTP handlers.
type Server struct {
logger zerolog.Logger
db *db.DB
items *db.ItemRepository
projects *db.ProjectRepository
schemas map[string]*schema.Schema
schemasDir string
partgen *partnum.Generator
storage *storage.Storage
logger zerolog.Logger
db *db.DB
items *db.ItemRepository
projects *db.ProjectRepository
relationships *db.RelationshipRepository
schemas map[string]*schema.Schema
schemasDir string
partgen *partnum.Generator
storage *storage.Storage
}
// NewServer creates a new API server.
@@ -40,18 +41,20 @@ func NewServer(
) *Server {
items := db.NewItemRepository(database)
projects := db.NewProjectRepository(database)
relationships := db.NewRelationshipRepository(database)
seqStore := &dbSequenceStore{db: database, schemas: schemas}
partgen := partnum.NewGenerator(schemas, seqStore)
return &Server{
logger: logger,
db: database,
items: items,
projects: projects,
schemas: schemas,
schemasDir: schemasDir,
partgen: partgen,
storage: store,
logger: logger,
db: database,
items: items,
projects: projects,
relationships: relationships,
schemas: schemas,
schemasDir: schemasDir,
partgen: partgen,
storage: store,
}
}

View File

@@ -99,6 +99,14 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler {
r.Post("/file", server.HandleUploadFile)
r.Get("/file", server.HandleDownloadLatestFile)
r.Get("/file/{revision}", server.HandleDownloadFile)
// BOM / Relationships
r.Get("/bom", server.HandleGetBOM)
r.Post("/bom", server.HandleAddBOMEntry)
r.Get("/bom/expanded", server.HandleGetExpandedBOM)
r.Get("/bom/where-used", server.HandleGetWhereUsed)
r.Put("/bom/{childPartNumber}", server.HandleUpdateBOMEntry)
r.Delete("/bom/{childPartNumber}", server.HandleDeleteBOMEntry)
})
})

View 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()
}