package db import ( "context" "encoding/json" "fmt" "sort" "strings" "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 CreatedBy *string UpdatedBy *string } // 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 Metadata map[string]any } // 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, created_by ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id, created_at, updated_at `, rel.ParentItemID, rel.ChildItemID, rel.RelType, rel.Quantity, rel.Unit, rel.ReferenceDesignators, rel.ChildRevision, metadataJSON, rel.ParentRevisionID, rel.CreatedBy, ).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, updatedBy *string) error { // Build dynamic update query query := "UPDATE relationships SET updated_at = now()" args := []any{} argNum := 1 if updatedBy != nil { query += fmt.Sprintf(", updated_by = $%d", argNum) args = append(args, *updatedBy) argNum++ } 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, rel.metadata 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, rel.metadata 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, rel.metadata, 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), rel.metadata, 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, metadata, 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 var metadataJSON []byte 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, &metadataJSON, &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 } if metadataJSON != nil { if err := json.Unmarshal(metadataJSON, &e.Metadata); err != nil { return nil, fmt.Errorf("unmarshaling BOM entry metadata: %w", err) } } 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 } // FlatBOMEntry represents a leaf part with its total rolled-up quantity // across the entire BOM tree. type FlatBOMEntry struct { ItemID string PartNumber string Description string TotalQuantity float64 } // GetFlatBOM returns a consolidated list of leaf parts (parts with no BOM // children) with total quantities rolled up through the tree. Quantities // are multiplied through each nesting level. Cycles are detected and // returned as an error containing the offending path. func (r *RelationshipRepository) GetFlatBOM(ctx context.Context, rootItemID string) ([]*FlatBOMEntry, error) { type stackItem struct { itemID string partNumber string description string qty float64 path []string // part numbers visited on this branch for cycle detection } // Seed the stack with the root's direct children. rootBOM, err := r.GetBOM(ctx, rootItemID) if err != nil { return nil, fmt.Errorf("getting root BOM: %w", err) } // Find root part number for the cycle path. var rootPN string if len(rootBOM) > 0 { rootPN = rootBOM[0].ParentPartNumber } stack := make([]stackItem, 0, len(rootBOM)) for _, e := range rootBOM { qty := 1.0 if e.Quantity != nil { qty = *e.Quantity } stack = append(stack, stackItem{ itemID: e.ChildItemID, partNumber: e.ChildPartNumber, description: e.ChildDescription, qty: qty, path: []string{rootPN}, }) } // Accumulate leaf quantities keyed by item ID. leaves := make(map[string]*FlatBOMEntry) for len(stack) > 0 { // Pop cur := stack[len(stack)-1] stack = stack[:len(stack)-1] // Cycle detection: check if current part number is already in the path. for _, pn := range cur.path { if pn == cur.partNumber { cyclePath := append(cur.path, cur.partNumber) return nil, fmt.Errorf("BOM cycle detected: %s", strings.Join(cyclePath, " → ")) } } // Get this item's children. children, err := r.GetBOM(ctx, cur.itemID) if err != nil { return nil, fmt.Errorf("getting BOM for %s: %w", cur.partNumber, err) } if len(children) == 0 { // Leaf node — accumulate quantity. if existing, ok := leaves[cur.itemID]; ok { existing.TotalQuantity += cur.qty } else { leaves[cur.itemID] = &FlatBOMEntry{ ItemID: cur.itemID, PartNumber: cur.partNumber, Description: cur.description, TotalQuantity: cur.qty, } } } else { // Sub-assembly — push children with multiplied quantity. newPath := make([]string, len(cur.path)+1) copy(newPath, cur.path) newPath[len(cur.path)] = cur.partNumber for _, child := range children { childQty := 1.0 if child.Quantity != nil { childQty = *child.Quantity } stack = append(stack, stackItem{ itemID: child.ChildItemID, partNumber: child.ChildPartNumber, description: child.ChildDescription, qty: cur.qty * childQty, path: newPath, }) } } } // Sort by part number. result := make([]*FlatBOMEntry, 0, len(leaves)) for _, e := range leaves { result = append(result, e) } sort.Slice(result, func(i, j int) bool { return result[i].PartNumber < result[j].PartNumber }) return result, 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 var metadataJSON []byte 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, &metadataJSON, ) if err != nil { return nil, fmt.Errorf("scanning BOM entry: %w", err) } if parentDesc != nil { e.ParentDescription = *parentDesc } if childDesc != nil { e.ChildDescription = *childDesc } if metadataJSON != nil { if err := json.Unmarshal(metadataJSON, &e.Metadata); err != nil { return nil, fmt.Errorf("unmarshaling BOM entry metadata: %w", err) } } entries = append(entries, e) } return entries, rows.Err() }