From a2a36141f08f46c0b899b46e9e5e46dfedca2b42 Mon Sep 17 00:00:00 2001 From: Forbes Date: Sat, 31 Jan 2026 08:09:26 -0600 Subject: [PATCH] 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. --- internal/api/bom_handlers.go | 440 ++++++++++++++++++++++ internal/api/handlers.go | 35 +- internal/api/routes.go | 8 + internal/db/relationships.go | 430 ++++++++++++++++++++++ pkg/freecad/InitGui.py | 1 + pkg/freecad/resources/icons/silo-bom.svg | 12 + pkg/freecad/silo_commands.py | 447 +++++++++++++++++++++++ 7 files changed, 1357 insertions(+), 16 deletions(-) create mode 100644 internal/api/bom_handlers.go create mode 100644 internal/db/relationships.go create mode 100644 pkg/freecad/resources/icons/silo-bom.svg 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())