Promote BOM source from metadata JSONB to a dedicated VARCHAR(20)
column with CHECK constraint ('manual' or 'assembly').
- Add migration 012_bom_source.sql (column, data migration, cleanup)
- Add Source field to Relationship and BOMEntry structs
- Update all SQL queries (GetBOM, GetWhereUsed, GetExpandedBOM, Create)
- Update API response/request types with source field
- Update CSV/ODS export to read e.Source instead of metadata
- Update CSV import to set source on relationship directly
- Update frontend types and BOMTab to use top-level source field
584 lines
17 KiB
Go
584 lines
17 KiB
Go
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
|
|
Source string // "manual" or "assembly"
|
|
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
|
|
Source string
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
source := rel.Source
|
|
if source == "" {
|
|
source = "manual"
|
|
}
|
|
|
|
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, source
|
|
)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
|
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, source,
|
|
).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, rel.source
|
|
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, rel.source
|
|
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, rel.source,
|
|
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, rel.source,
|
|
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, source, 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.Source, &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, &e.Source,
|
|
)
|
|
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()
|
|
}
|