|
|
|
|
@@ -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
|
|
|
|
|
}
|
|
|
|
|
|