diff --git a/internal/api/bom_handlers.go b/internal/api/bom_handlers.go index 633ed5d..83c6008 100644 --- a/internal/api/bom_handlers.go +++ b/internal/api/bom_handlers.go @@ -594,6 +594,56 @@ func (s *Server) HandleGetBOMCost(w http.ResponseWriter, r *http.Request) { }) } +// BOM merge request/response types + +// MergeBOMRequest represents a request to merge assembly BOM entries. +type MergeBOMRequest struct { + Source string `json:"source"` + Entries []MergeBOMEntry `json:"entries"` +} + +// MergeBOMEntry represents a single entry in a merge request. +type MergeBOMEntry struct { + ChildPartNumber string `json:"child_part_number"` + Quantity *float64 `json:"quantity"` +} + +// MergeBOMResponse represents the result of a BOM merge. +type MergeBOMResponse struct { + Status string `json:"status"` + Diff MergeBOMDiff `json:"diff"` + Warnings []MergeWarning `json:"warnings"` + ResolveURL string `json:"resolve_url"` +} + +// MergeBOMDiff categorizes changes from a merge operation. +type MergeBOMDiff struct { + Added []MergeDiffEntry `json:"added"` + Removed []MergeDiffEntry `json:"removed"` + QuantityChanged []MergeQtyChange `json:"quantity_changed"` + Unchanged []MergeDiffEntry `json:"unchanged"` +} + +// MergeDiffEntry represents an added, removed, or unchanged BOM entry. +type MergeDiffEntry struct { + PartNumber string `json:"part_number"` + Quantity *float64 `json:"quantity"` +} + +// MergeQtyChange represents a BOM entry whose quantity changed. +type MergeQtyChange struct { + PartNumber string `json:"part_number"` + OldQuantity *float64 `json:"old_quantity"` + NewQuantity *float64 `json:"new_quantity"` +} + +// MergeWarning represents a warning generated during merge. +type MergeWarning struct { + Type string `json:"type"` + PartNumber string `json:"part_number"` + Message string `json:"message"` +} + // BOM CSV headers matching the user-specified format. var bomCSVHeaders = []string{ "Item", "Level", "Source", "PN", "Seller Description", @@ -976,3 +1026,197 @@ func (s *Server) HandleImportBOMCSV(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, result) } + +// HandleMergeBOM merges assembly-derived BOM entries into the server's BOM. +// Added entries are created, quantity changes are applied, and entries present +// in the server but missing from the request are flagged as warnings (not deleted). +func (s *Server) HandleMergeBOM(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 MergeBOMRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_json", err.Error()) + return + } + if len(req.Entries) == 0 { + writeError(w, http.StatusBadRequest, "invalid_request", "entries must not be empty") + return + } + + // Fetch existing BOM (includes Source field) + existing, err := s.relationships.GetBOM(ctx, parent.ID) + if err != nil { + s.logger.Error().Err(err).Msg("failed to get existing BOM") + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get existing BOM") + return + } + + // Build lookup map by child part number + existingMap := make(map[string]*db.BOMEntry, len(existing)) + for _, e := range existing { + existingMap[e.ChildPartNumber] = e + } + + var username *string + if user := auth.UserFromContext(ctx); user != nil { + username = &user.Username + } + + diff := MergeBOMDiff{ + Added: make([]MergeDiffEntry, 0), + Removed: make([]MergeDiffEntry, 0), + QuantityChanged: make([]MergeQtyChange, 0), + Unchanged: make([]MergeDiffEntry, 0), + } + var warnings []MergeWarning + + // Process incoming entries + for _, entry := range req.Entries { + if entry.ChildPartNumber == "" { + continue + } + + child, err := s.items.GetByPartNumber(ctx, entry.ChildPartNumber) + if err != nil { + s.logger.Error().Err(err).Str("child", entry.ChildPartNumber).Msg("failed to look up child") + warnings = append(warnings, MergeWarning{ + Type: "error", + PartNumber: entry.ChildPartNumber, + Message: fmt.Sprintf("Error looking up item: %s", err.Error()), + }) + continue + } + if child == nil { + warnings = append(warnings, MergeWarning{ + Type: "not_found", + PartNumber: entry.ChildPartNumber, + Message: fmt.Sprintf("Item '%s' not found in database", entry.ChildPartNumber), + }) + continue + } + + if ex, ok := existingMap[entry.ChildPartNumber]; ok { + // Entry already exists — check quantity + oldQty := ex.Quantity + newQty := entry.Quantity + if quantitiesEqual(oldQty, newQty) { + diff.Unchanged = append(diff.Unchanged, MergeDiffEntry{ + PartNumber: entry.ChildPartNumber, + Quantity: newQty, + }) + } else { + // Update quantity + if err := s.relationships.Update(ctx, ex.RelationshipID, nil, newQty, nil, nil, nil, nil, username); err != nil { + s.logger.Error().Err(err).Str("child", entry.ChildPartNumber).Msg("failed to update quantity") + warnings = append(warnings, MergeWarning{ + Type: "error", + PartNumber: entry.ChildPartNumber, + Message: fmt.Sprintf("Failed to update quantity: %s", err.Error()), + }) + } else { + diff.QuantityChanged = append(diff.QuantityChanged, MergeQtyChange{ + PartNumber: entry.ChildPartNumber, + OldQuantity: oldQty, + NewQuantity: newQty, + }) + } + } + delete(existingMap, entry.ChildPartNumber) + } else { + // New entry — create + rel := &db.Relationship{ + ParentItemID: parent.ID, + ChildItemID: child.ID, + RelType: "component", + Quantity: entry.Quantity, + Source: "assembly", + CreatedBy: username, + } + if err := s.relationships.Create(ctx, rel); err != nil { + if strings.Contains(err.Error(), "cycle") { + warnings = append(warnings, MergeWarning{ + Type: "cycle", + PartNumber: entry.ChildPartNumber, + Message: fmt.Sprintf("Adding '%s' would create a cycle", entry.ChildPartNumber), + }) + } else { + s.logger.Error().Err(err).Str("child", entry.ChildPartNumber).Msg("failed to create relationship") + warnings = append(warnings, MergeWarning{ + Type: "error", + PartNumber: entry.ChildPartNumber, + Message: fmt.Sprintf("Failed to create: %s", err.Error()), + }) + } + continue + } + diff.Added = append(diff.Added, MergeDiffEntry{ + PartNumber: entry.ChildPartNumber, + Quantity: entry.Quantity, + }) + } + } + + // Remaining entries in existingMap are not in the merge request + for pn, e := range existingMap { + if e.Source == "assembly" { + diff.Removed = append(diff.Removed, MergeDiffEntry{ + PartNumber: pn, + Quantity: e.Quantity, + }) + warnings = append(warnings, MergeWarning{ + Type: "unreferenced", + PartNumber: pn, + Message: "Present in server BOM but not in assembly", + }) + } + } + + resp := MergeBOMResponse{ + Status: "merged", + Diff: diff, + Warnings: warnings, + ResolveURL: fmt.Sprintf("/items/%s/bom", partNumber), + } + + s.logger.Info(). + Str("parent", partNumber). + Int("added", len(diff.Added)). + Int("updated", len(diff.QuantityChanged)). + Int("unchanged", len(diff.Unchanged)). + Int("unreferenced", len(diff.Removed)). + Int("warnings", len(warnings)). + Msg("BOM merge completed") + + s.broker.Publish("bom.merged", mustMarshal(map[string]any{ + "part_number": partNumber, + "added": len(diff.Added), + "quantity_changed": len(diff.QuantityChanged), + "unchanged": len(diff.Unchanged), + "unreferenced": len(diff.Removed), + })) + + writeJSON(w, http.StatusOK, resp) +} + +// quantitiesEqual compares two nullable float64 quantities. +func quantitiesEqual(a, b *float64) bool { + if a == nil && b == nil { + return true + } + if a == nil || b == nil { + return false + } + return *a == *b +} diff --git a/internal/api/routes.go b/internal/api/routes.go index 32d760a..0e8e215 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -165,6 +165,7 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler { r.Put("/thumbnail", server.HandleSetItemThumbnail) r.Post("/bom", server.HandleAddBOMEntry) r.Post("/bom/import", server.HandleImportBOMCSV) + r.Post("/bom/merge", server.HandleMergeBOM) r.Put("/bom/{childPartNumber}", server.HandleUpdateBOMEntry) r.Delete("/bom/{childPartNumber}", server.HandleDeleteBOMEntry) })