diff --git a/internal/api/bom_handlers.go b/internal/api/bom_handlers.go
new file mode 100644
index 0000000..1e763cc
--- /dev/null
+++ b/internal/api/bom_handlers.go
@@ -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,
+ }
+}
diff --git a/internal/api/handlers.go b/internal/api/handlers.go
index d62fab3..f664de3 100644
--- a/internal/api/handlers.go
+++ b/internal/api/handlers.go
@@ -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,
}
}
diff --git a/internal/api/routes.go b/internal/api/routes.go
index d9593ff..63db163 100644
--- a/internal/api/routes.go
+++ b/internal/api/routes.go
@@ -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)
})
})
diff --git a/internal/db/relationships.go b/internal/db/relationships.go
new file mode 100644
index 0000000..dfcc0ed
--- /dev/null
+++ b/internal/db/relationships.go
@@ -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()
+}
diff --git a/pkg/freecad/InitGui.py b/pkg/freecad/InitGui.py
index ae2ea67..293569a 100644
--- a/pkg/freecad/InitGui.py
+++ b/pkg/freecad/InitGui.py
@@ -35,6 +35,7 @@ class SiloWorkbench(FreeCADGui.Workbench):
"Silo_Pull",
"Silo_Push",
"Silo_Info",
+ "Silo_BOM",
"Silo_Settings",
]
diff --git a/pkg/freecad/resources/icons/silo-bom.svg b/pkg/freecad/resources/icons/silo-bom.svg
new file mode 100644
index 0000000..1ea69dc
--- /dev/null
+++ b/pkg/freecad/resources/icons/silo-bom.svg
@@ -0,0 +1,12 @@
+
diff --git a/pkg/freecad/silo_commands.py b/pkg/freecad/silo_commands.py
index f7591c1..e27297a 100644
--- a/pkg/freecad/silo_commands.py
+++ b/pkg/freecad/silo_commands.py
@@ -491,6 +491,79 @@ class SiloClient:
"PATCH", f"/items/{part_number}/revisions/{revision}", data
)
+ # BOM / Relationship methods
+
+ def get_bom(self, part_number: str) -> list:
+ """Get single-level BOM for an item."""
+ return self._request("GET", f"/items/{part_number}/bom")
+
+ def get_bom_expanded(self, part_number: str, depth: int = 10) -> list:
+ """Get multi-level BOM for an item."""
+ return self._request("GET", f"/items/{part_number}/bom/expanded?depth={depth}")
+
+ def get_bom_where_used(self, part_number: str) -> list:
+ """Get assemblies that use this item."""
+ return self._request("GET", f"/items/{part_number}/bom/where-used")
+
+ def add_bom_entry(
+ self,
+ parent_pn: str,
+ child_pn: str,
+ quantity: float = None,
+ unit: str = None,
+ rel_type: str = "component",
+ ref_des: list = None,
+ ) -> Dict[str, Any]:
+ """Add a child item to a parent's BOM."""
+ data: Dict[str, Any] = {
+ "child_part_number": child_pn,
+ "rel_type": rel_type,
+ }
+ if quantity is not None:
+ data["quantity"] = quantity
+ if unit:
+ data["unit"] = unit
+ if ref_des:
+ data["reference_designators"] = ref_des
+ return self._request("POST", f"/items/{parent_pn}/bom", data)
+
+ def update_bom_entry(
+ self,
+ parent_pn: str,
+ child_pn: str,
+ quantity: float = None,
+ unit: str = None,
+ rel_type: str = None,
+ ref_des: list = None,
+ ) -> Dict[str, Any]:
+ """Update a BOM entry."""
+ data: Dict[str, Any] = {}
+ if quantity is not None:
+ data["quantity"] = quantity
+ if unit is not None:
+ data["unit"] = unit
+ if rel_type is not None:
+ data["rel_type"] = rel_type
+ if ref_des is not None:
+ data["reference_designators"] = ref_des
+ return self._request("PUT", f"/items/{parent_pn}/bom/{child_pn}", data)
+
+ def delete_bom_entry(self, parent_pn: str, child_pn: str) -> None:
+ """Remove a child from a parent's BOM."""
+ url = f"{self.base_url}/items/{parent_pn}/bom/{child_pn}"
+ req = urllib.request.Request(
+ url,
+ headers={"Content-Type": "application/json"},
+ method="DELETE",
+ )
+ try:
+ urllib.request.urlopen(req, context=_get_ssl_context())
+ except urllib.error.HTTPError as e:
+ error_body = e.read().decode()
+ raise RuntimeError(f"API error {e.code}: {error_body}")
+ except urllib.error.URLError as e:
+ raise RuntimeError(f"Connection error: {e.reason}")
+
_client = SiloClient()
@@ -1937,6 +2010,379 @@ class Silo_Settings:
return True
+class Silo_BOM:
+ """View and manage Bill of Materials for the current item."""
+
+ def GetResources(self):
+ return {
+ "MenuText": "BOM",
+ "ToolTip": "View and manage Bill of Materials",
+ "Pixmap": _icon("bom"),
+ }
+
+ def Activated(self):
+ from PySide import QtCore, QtGui
+
+ doc = FreeCAD.ActiveDocument
+ if not doc:
+ FreeCAD.Console.PrintError("No active document\n")
+ return
+
+ obj = get_tracked_object(doc)
+ if not obj:
+ FreeCAD.Console.PrintError("No tracked Silo item in active document.\n")
+ from PySide import QtGui as _qg
+
+ _qg.QMessageBox.warning(
+ None,
+ "BOM",
+ "This document is not registered with Silo.\n"
+ "Use Silo > New to register it first.",
+ )
+ return
+
+ part_number = obj.SiloPartNumber
+
+ try:
+ item = _client.get_item(part_number)
+ except Exception as e:
+ QtGui.QMessageBox.warning(None, "BOM", f"Failed to get item info:\n{e}")
+ return
+
+ # Build the dialog
+ dialog = QtGui.QDialog()
+ dialog.setWindowTitle(f"BOM - {part_number}")
+ dialog.setMinimumWidth(750)
+ dialog.setMinimumHeight(450)
+ layout = QtGui.QVBoxLayout(dialog)
+
+ # Item header
+ header = QtGui.QLabel(f"{part_number} - {item.get('description', '')}")
+ layout.addWidget(header)
+
+ # Tab widget
+ tabs = QtGui.QTabWidget()
+ layout.addWidget(tabs)
+
+ # ── Tab 1: BOM (children of this item) ──
+ bom_widget = QtGui.QWidget()
+ bom_layout = QtGui.QVBoxLayout(bom_widget)
+
+ bom_table = QtGui.QTableWidget()
+ bom_table.setColumnCount(7)
+ bom_table.setHorizontalHeaderLabels(
+ ["Part Number", "Description", "Type", "Qty", "Unit", "Ref Des", "Rev"]
+ )
+ bom_table.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows)
+ bom_table.setSelectionMode(QtGui.QAbstractItemView.SingleSelection)
+ bom_table.setEditTriggers(QtGui.QAbstractItemView.NoEditTriggers)
+ bom_table.horizontalHeader().setStretchLastSection(True)
+ bom_layout.addWidget(bom_table)
+
+ # BOM button bar
+ bom_btn_layout = QtGui.QHBoxLayout()
+ add_btn = QtGui.QPushButton("Add")
+ edit_btn = QtGui.QPushButton("Edit")
+ remove_btn = QtGui.QPushButton("Remove")
+ bom_btn_layout.addWidget(add_btn)
+ bom_btn_layout.addWidget(edit_btn)
+ bom_btn_layout.addWidget(remove_btn)
+ bom_btn_layout.addStretch()
+ bom_layout.addLayout(bom_btn_layout)
+
+ tabs.addTab(bom_widget, "BOM")
+
+ # ── Tab 2: Where Used (parents of this item) ──
+ wu_widget = QtGui.QWidget()
+ wu_layout = QtGui.QVBoxLayout(wu_widget)
+
+ wu_table = QtGui.QTableWidget()
+ wu_table.setColumnCount(5)
+ wu_table.setHorizontalHeaderLabels(
+ ["Parent Part Number", "Type", "Qty", "Unit", "Ref Des"]
+ )
+ wu_table.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows)
+ wu_table.setSelectionMode(QtGui.QAbstractItemView.SingleSelection)
+ wu_table.setEditTriggers(QtGui.QAbstractItemView.NoEditTriggers)
+ wu_table.horizontalHeader().setStretchLastSection(True)
+ wu_layout.addWidget(wu_table)
+
+ tabs.addTab(wu_widget, "Where Used")
+
+ # ── Data loading ──
+
+ bom_data = []
+
+ def load_bom():
+ nonlocal bom_data
+ try:
+ bom_data = _client.get_bom(part_number)
+ except Exception as exc:
+ FreeCAD.Console.PrintWarning(f"BOM load error: {exc}\n")
+ bom_data = []
+
+ bom_table.setRowCount(len(bom_data))
+ for row, entry in enumerate(bom_data):
+ bom_table.setItem(
+ row, 0, QtGui.QTableWidgetItem(entry.get("child_part_number", ""))
+ )
+ bom_table.setItem(
+ row, 1, QtGui.QTableWidgetItem(entry.get("child_description", ""))
+ )
+ bom_table.setItem(
+ row, 2, QtGui.QTableWidgetItem(entry.get("rel_type", ""))
+ )
+ qty = entry.get("quantity")
+ bom_table.setItem(
+ row, 3, QtGui.QTableWidgetItem(str(qty) if qty is not None else "")
+ )
+ bom_table.setItem(
+ row, 4, QtGui.QTableWidgetItem(entry.get("unit") or "")
+ )
+ ref_des = entry.get("reference_designators") or []
+ bom_table.setItem(row, 5, QtGui.QTableWidgetItem(", ".join(ref_des)))
+ bom_table.setItem(
+ row,
+ 6,
+ QtGui.QTableWidgetItem(str(entry.get("effective_revision", ""))),
+ )
+ bom_table.resizeColumnsToContents()
+
+ def load_where_used():
+ try:
+ wu_data = _client.get_bom_where_used(part_number)
+ except Exception as exc:
+ FreeCAD.Console.PrintWarning(f"Where-used load error: {exc}\n")
+ wu_data = []
+
+ wu_table.setRowCount(len(wu_data))
+ for row, entry in enumerate(wu_data):
+ wu_table.setItem(
+ row, 0, QtGui.QTableWidgetItem(entry.get("parent_part_number", ""))
+ )
+ wu_table.setItem(
+ row, 1, QtGui.QTableWidgetItem(entry.get("rel_type", ""))
+ )
+ qty = entry.get("quantity")
+ wu_table.setItem(
+ row, 2, QtGui.QTableWidgetItem(str(qty) if qty is not None else "")
+ )
+ wu_table.setItem(
+ row, 3, QtGui.QTableWidgetItem(entry.get("unit") or "")
+ )
+ ref_des = entry.get("reference_designators") or []
+ wu_table.setItem(row, 4, QtGui.QTableWidgetItem(", ".join(ref_des)))
+ wu_table.resizeColumnsToContents()
+
+ # ── Button handlers ──
+
+ def on_add():
+ add_dlg = QtGui.QDialog(dialog)
+ add_dlg.setWindowTitle("Add BOM Entry")
+ add_dlg.setMinimumWidth(400)
+ al = QtGui.QFormLayout(add_dlg)
+
+ child_input = QtGui.QLineEdit()
+ child_input.setPlaceholderText("e.g. F01-0001")
+ al.addRow("Child Part Number:", child_input)
+
+ type_combo = QtGui.QComboBox()
+ type_combo.addItems(["component", "alternate", "reference"])
+ al.addRow("Relationship Type:", type_combo)
+
+ qty_input = QtGui.QLineEdit()
+ qty_input.setPlaceholderText("e.g. 4")
+ al.addRow("Quantity:", qty_input)
+
+ unit_input = QtGui.QLineEdit()
+ unit_input.setPlaceholderText("e.g. ea, m, kg")
+ al.addRow("Unit:", unit_input)
+
+ refdes_input = QtGui.QLineEdit()
+ refdes_input.setPlaceholderText("e.g. R1, R2, R3")
+ al.addRow("Ref Designators:", refdes_input)
+
+ btn_box = QtGui.QDialogButtonBox(
+ QtGui.QDialogButtonBox.Ok | QtGui.QDialogButtonBox.Cancel
+ )
+ btn_box.accepted.connect(add_dlg.accept)
+ btn_box.rejected.connect(add_dlg.reject)
+ al.addRow(btn_box)
+
+ if add_dlg.exec_() != QtGui.QDialog.Accepted:
+ return
+
+ child_pn = child_input.text().strip()
+ if not child_pn:
+ return
+
+ qty = None
+ qty_text = qty_input.text().strip()
+ if qty_text:
+ try:
+ qty = float(qty_text)
+ except ValueError:
+ QtGui.QMessageBox.warning(
+ dialog, "BOM", "Quantity must be a number."
+ )
+ return
+
+ unit = unit_input.text().strip() or None
+ rel_type = type_combo.currentText()
+
+ ref_des = None
+ refdes_text = refdes_input.text().strip()
+ if refdes_text:
+ ref_des = [r.strip() for r in refdes_text.split(",") if r.strip()]
+
+ try:
+ _client.add_bom_entry(
+ part_number,
+ child_pn,
+ quantity=qty,
+ unit=unit,
+ rel_type=rel_type,
+ ref_des=ref_des,
+ )
+ load_bom()
+ except Exception as exc:
+ QtGui.QMessageBox.warning(dialog, "BOM", f"Failed to add entry:\n{exc}")
+
+ def on_edit():
+ selected = bom_table.selectedItems()
+ if not selected:
+ return
+ row = selected[0].row()
+ if row < 0 or row >= len(bom_data):
+ return
+
+ entry = bom_data[row]
+ child_pn = entry.get("child_part_number", "")
+
+ edit_dlg = QtGui.QDialog(dialog)
+ edit_dlg.setWindowTitle(f"Edit BOM Entry - {child_pn}")
+ edit_dlg.setMinimumWidth(400)
+ el = QtGui.QFormLayout(edit_dlg)
+
+ type_combo = QtGui.QComboBox()
+ type_combo.addItems(["component", "alternate", "reference"])
+ current_type = entry.get("rel_type", "component")
+ idx = type_combo.findText(current_type)
+ if idx >= 0:
+ type_combo.setCurrentIndex(idx)
+ el.addRow("Relationship Type:", type_combo)
+
+ qty_input = QtGui.QLineEdit()
+ qty = entry.get("quantity")
+ if qty is not None:
+ qty_input.setText(str(qty))
+ el.addRow("Quantity:", qty_input)
+
+ unit_input = QtGui.QLineEdit()
+ unit_input.setText(entry.get("unit") or "")
+ el.addRow("Unit:", unit_input)
+
+ refdes_input = QtGui.QLineEdit()
+ ref_des = entry.get("reference_designators") or []
+ refdes_input.setText(", ".join(ref_des))
+ el.addRow("Ref Designators:", refdes_input)
+
+ btn_box = QtGui.QDialogButtonBox(
+ QtGui.QDialogButtonBox.Ok | QtGui.QDialogButtonBox.Cancel
+ )
+ btn_box.accepted.connect(edit_dlg.accept)
+ btn_box.rejected.connect(edit_dlg.reject)
+ el.addRow(btn_box)
+
+ if edit_dlg.exec_() != QtGui.QDialog.Accepted:
+ return
+
+ new_qty = None
+ qty_text = qty_input.text().strip()
+ if qty_text:
+ try:
+ new_qty = float(qty_text)
+ except ValueError:
+ QtGui.QMessageBox.warning(
+ dialog, "BOM", "Quantity must be a number."
+ )
+ return
+
+ new_unit = unit_input.text().strip() or None
+ new_type = type_combo.currentText()
+
+ new_ref_des = None
+ refdes_text = refdes_input.text().strip()
+ if refdes_text:
+ new_ref_des = [r.strip() for r in refdes_text.split(",") if r.strip()]
+ else:
+ new_ref_des = []
+
+ try:
+ _client.update_bom_entry(
+ part_number,
+ child_pn,
+ quantity=new_qty,
+ unit=new_unit,
+ rel_type=new_type,
+ ref_des=new_ref_des,
+ )
+ load_bom()
+ except Exception as exc:
+ QtGui.QMessageBox.warning(
+ dialog, "BOM", f"Failed to update entry:\n{exc}"
+ )
+
+ def on_remove():
+ selected = bom_table.selectedItems()
+ if not selected:
+ return
+ row = selected[0].row()
+ if row < 0 or row >= len(bom_data):
+ return
+
+ entry = bom_data[row]
+ child_pn = entry.get("child_part_number", "")
+
+ reply = QtGui.QMessageBox.question(
+ dialog,
+ "Remove BOM Entry",
+ f"Remove {child_pn} from BOM?",
+ QtGui.QMessageBox.Yes | QtGui.QMessageBox.No,
+ )
+ if reply != QtGui.QMessageBox.Yes:
+ return
+
+ try:
+ _client.delete_bom_entry(part_number, child_pn)
+ load_bom()
+ except Exception as exc:
+ QtGui.QMessageBox.warning(
+ dialog, "BOM", f"Failed to remove entry:\n{exc}"
+ )
+
+ add_btn.clicked.connect(on_add)
+ edit_btn.clicked.connect(on_edit)
+ remove_btn.clicked.connect(on_remove)
+
+ # Close button
+ close_layout = QtGui.QHBoxLayout()
+ close_layout.addStretch()
+ close_btn = QtGui.QPushButton("Close")
+ close_btn.clicked.connect(dialog.accept)
+ close_layout.addWidget(close_btn)
+ layout.addLayout(close_layout)
+
+ # Initial data load
+ load_bom()
+ load_where_used()
+
+ dialog.exec_()
+
+ def IsActive(self):
+ return FreeCAD.ActiveDocument is not None
+
+
# Register commands
FreeCADGui.addCommand("Silo_Open", Silo_Open())
FreeCADGui.addCommand("Silo_New", Silo_New())
@@ -1945,6 +2391,7 @@ FreeCADGui.addCommand("Silo_Commit", Silo_Commit())
FreeCADGui.addCommand("Silo_Pull", Silo_Pull())
FreeCADGui.addCommand("Silo_Push", Silo_Push())
FreeCADGui.addCommand("Silo_Info", Silo_Info())
+FreeCADGui.addCommand("Silo_BOM", Silo_BOM())
FreeCADGui.addCommand("Silo_TagProjects", Silo_TagProjects())
FreeCADGui.addCommand("Silo_Rollback", Silo_Rollback())
FreeCADGui.addCommand("Silo_SetStatus", Silo_SetStatus())