1239 lines
36 KiB
Go
1239 lines
36 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/csv"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/kindredsystems/silo/internal/auth"
|
|
"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"`
|
|
ParentPartNumber string `json:"parent_part_number"`
|
|
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"`
|
|
Source string `json:"source"`
|
|
Metadata map[string]any `json:"metadata,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"`
|
|
Source string `json:"source,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,
|
|
Source: req.Source,
|
|
Metadata: req.Metadata,
|
|
}
|
|
if user := auth.UserFromContext(ctx); user != nil {
|
|
rel.CreatedBy = &user.Username
|
|
}
|
|
|
|
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,
|
|
Source: rel.Source,
|
|
Metadata: req.Metadata,
|
|
}
|
|
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
|
|
}
|
|
}
|
|
|
|
var bomUpdatedBy *string
|
|
if user := auth.UserFromContext(ctx); user != nil {
|
|
bomUpdatedBy = &user.Username
|
|
}
|
|
if err := s.relationships.Update(ctx, rel.ID, req.RelType, req.Quantity, req.Unit, req.ReferenceDesignators, req.ChildRevision, req.Metadata, bomUpdatedBy); 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,
|
|
ParentPartNumber: e.ParentPartNumber,
|
|
ChildPartNumber: e.ChildPartNumber,
|
|
ChildDescription: e.ChildDescription,
|
|
RelType: e.RelType,
|
|
Quantity: e.Quantity,
|
|
Unit: e.Unit,
|
|
ReferenceDesignators: refDes,
|
|
ChildRevision: e.ChildRevision,
|
|
EffectiveRevision: e.EffectiveRevision,
|
|
Source: e.Source,
|
|
Metadata: e.Metadata,
|
|
}
|
|
}
|
|
|
|
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,
|
|
}
|
|
}
|
|
|
|
// Flat BOM and cost response types
|
|
|
|
// FlatBOMResponse is the response for GET /api/items/{partNumber}/bom/flat.
|
|
type FlatBOMResponse struct {
|
|
PartNumber string `json:"part_number"`
|
|
FlatBOM []FlatBOMLineResponse `json:"flat_bom"`
|
|
}
|
|
|
|
// FlatBOMLineResponse represents one consolidated leaf part in a flat BOM.
|
|
type FlatBOMLineResponse struct {
|
|
PartNumber string `json:"part_number"`
|
|
Description string `json:"description"`
|
|
TotalQuantity float64 `json:"total_quantity"`
|
|
}
|
|
|
|
// CostResponse is the response for GET /api/items/{partNumber}/bom/cost.
|
|
type CostResponse struct {
|
|
PartNumber string `json:"part_number"`
|
|
TotalCost float64 `json:"total_cost"`
|
|
CostBreakdown []CostLineResponse `json:"cost_breakdown"`
|
|
}
|
|
|
|
// CostLineResponse represents one line in the cost breakdown.
|
|
type CostLineResponse struct {
|
|
PartNumber string `json:"part_number"`
|
|
Description string `json:"description"`
|
|
TotalQuantity float64 `json:"total_quantity"`
|
|
UnitCost float64 `json:"unit_cost"`
|
|
ExtendedCost float64 `json:"extended_cost"`
|
|
}
|
|
|
|
// HandleGetFlatBOM returns a flattened, consolidated BOM with rolled-up
|
|
// quantities for leaf parts only.
|
|
func (s *Server) HandleGetFlatBOM(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.GetFlatBOM(ctx, item.ID)
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "cycle detected") {
|
|
writeJSON(w, http.StatusConflict, map[string]string{
|
|
"error": "cycle_detected",
|
|
"detail": err.Error(),
|
|
})
|
|
return
|
|
}
|
|
s.logger.Error().Err(err).Msg("failed to get flat BOM")
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to flatten BOM")
|
|
return
|
|
}
|
|
|
|
lines := make([]FlatBOMLineResponse, len(entries))
|
|
for i, e := range entries {
|
|
lines[i] = FlatBOMLineResponse{
|
|
PartNumber: e.PartNumber,
|
|
Description: e.Description,
|
|
TotalQuantity: e.TotalQuantity,
|
|
}
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, FlatBOMResponse{
|
|
PartNumber: partNumber,
|
|
FlatBOM: lines,
|
|
})
|
|
}
|
|
|
|
// HandleGetBOMCost returns the total assembly cost by combining the flat BOM
|
|
// with each leaf part's standard_cost.
|
|
func (s *Server) HandleGetBOMCost(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.GetFlatBOM(ctx, item.ID)
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "cycle detected") {
|
|
writeJSON(w, http.StatusConflict, map[string]string{
|
|
"error": "cycle_detected",
|
|
"detail": err.Error(),
|
|
})
|
|
return
|
|
}
|
|
s.logger.Error().Err(err).Msg("failed to get flat BOM for costing")
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to flatten BOM")
|
|
return
|
|
}
|
|
|
|
var totalCost float64
|
|
breakdown := make([]CostLineResponse, len(entries))
|
|
for i, e := range entries {
|
|
unitCost := 0.0
|
|
leaf, err := s.items.GetByID(ctx, e.ItemID)
|
|
if err == nil && leaf != nil {
|
|
// Get standard_cost from revision properties
|
|
if revs, rerr := s.items.GetRevisions(ctx, leaf.ID); rerr == nil {
|
|
for _, rev := range revs {
|
|
if rev.RevisionNumber == leaf.CurrentRevision && rev.Properties != nil {
|
|
if sc, ok := rev.Properties["standard_cost"]; ok {
|
|
if cost, cok := sc.(float64); cok {
|
|
unitCost = cost
|
|
}
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
extCost := e.TotalQuantity * unitCost
|
|
totalCost += extCost
|
|
breakdown[i] = CostLineResponse{
|
|
PartNumber: e.PartNumber,
|
|
Description: e.Description,
|
|
TotalQuantity: e.TotalQuantity,
|
|
UnitCost: unitCost,
|
|
ExtendedCost: extCost,
|
|
}
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, CostResponse{
|
|
PartNumber: partNumber,
|
|
TotalCost: totalCost,
|
|
CostBreakdown: breakdown,
|
|
})
|
|
}
|
|
|
|
// 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",
|
|
"Unit Cost", "QTY", "Ext Cost", "Sourcing Link",
|
|
}
|
|
|
|
// getMetaString extracts a string value from metadata.
|
|
func getMetaString(m map[string]any, key string) string {
|
|
if m == nil {
|
|
return ""
|
|
}
|
|
if v, ok := m[key]; ok {
|
|
if s, ok := v.(string); ok {
|
|
return s
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// getMetaFloat extracts a float64 value from metadata.
|
|
func getMetaFloat(m map[string]any, key string) (float64, bool) {
|
|
if m == nil {
|
|
return 0, false
|
|
}
|
|
v, ok := m[key]
|
|
if !ok {
|
|
return 0, false
|
|
}
|
|
switch n := v.(type) {
|
|
case float64:
|
|
return n, true
|
|
case json.Number:
|
|
f, err := n.Float64()
|
|
return f, err == nil
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
// HandleExportBOMCSV exports the expanded BOM as a CSV file.
|
|
func (s *Server) HandleExportBOMCSV(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.GetExpandedBOM(ctx, item.ID, 10)
|
|
if err != nil {
|
|
s.logger.Error().Err(err).Msg("failed to get expanded BOM")
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get BOM")
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/csv")
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s-bom.csv"`, partNumber))
|
|
|
|
writer := csv.NewWriter(w)
|
|
defer writer.Flush()
|
|
|
|
// Write header
|
|
if err := writer.Write(bomCSVHeaders); err != nil {
|
|
s.logger.Error().Err(err).Msg("failed to write CSV header")
|
|
return
|
|
}
|
|
|
|
// Write rows
|
|
for i, e := range entries {
|
|
unitCost, hasUnitCost := getMetaFloat(e.Metadata, "unit_cost")
|
|
qty := 0.0
|
|
if e.Quantity != nil {
|
|
qty = *e.Quantity
|
|
}
|
|
|
|
extCost := ""
|
|
if hasUnitCost && qty > 0 {
|
|
extCost = fmt.Sprintf("%.2f", unitCost*qty)
|
|
}
|
|
|
|
unitCostStr := ""
|
|
if hasUnitCost {
|
|
unitCostStr = fmt.Sprintf("%.2f", unitCost)
|
|
}
|
|
|
|
qtyStr := ""
|
|
if e.Quantity != nil {
|
|
qtyStr = strconv.FormatFloat(*e.Quantity, 'f', -1, 64)
|
|
}
|
|
|
|
row := []string{
|
|
strconv.Itoa(i + 1), // Item
|
|
strconv.Itoa(e.Depth), // Level
|
|
e.Source, // Source
|
|
e.ChildPartNumber, // PN
|
|
getMetaString(e.Metadata, "seller_description"), // Seller Description
|
|
unitCostStr, // Unit Cost
|
|
qtyStr, // QTY
|
|
extCost, // Ext Cost
|
|
getMetaString(e.Metadata, "sourcing_link"), // Sourcing Link
|
|
}
|
|
if err := writer.Write(row); err != nil {
|
|
s.logger.Error().Err(err).Msg("failed to write CSV row")
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// HandleImportBOMCSV imports BOM entries from a CSV file.
|
|
func (s *Server) HandleImportBOMCSV(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
|
|
}
|
|
|
|
// Parse multipart form (32MB max)
|
|
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_form", "Failed to parse multipart form")
|
|
return
|
|
}
|
|
|
|
file, _, err := r.FormFile("file")
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "missing_file", "CSV file is required")
|
|
return
|
|
}
|
|
defer file.Close()
|
|
|
|
dryRun := r.FormValue("dry_run") == "true"
|
|
clearExisting := r.FormValue("clear_existing") == "true"
|
|
|
|
// Read CSV
|
|
reader := csv.NewReader(file)
|
|
reader.TrimLeadingSpace = true
|
|
|
|
headers, err := reader.Read()
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_csv", "Failed to read CSV headers")
|
|
return
|
|
}
|
|
|
|
// Build case-insensitive header index
|
|
headerIdx := make(map[string]int)
|
|
for i, h := range headers {
|
|
headerIdx[strings.ToLower(strings.TrimSpace(h))] = i
|
|
}
|
|
|
|
// Require PN column
|
|
pnIdx, hasPn := headerIdx["pn"]
|
|
if !hasPn {
|
|
writeError(w, http.StatusBadRequest, "missing_column", "CSV must have a 'PN' column")
|
|
return
|
|
}
|
|
|
|
// Clear existing BOM if requested (only on real import)
|
|
if clearExisting && !dryRun {
|
|
existing, err := s.relationships.GetBOM(ctx, parent.ID)
|
|
if err != nil {
|
|
s.logger.Error().Err(err).Msg("failed to get existing BOM for clearing")
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to clear existing BOM")
|
|
return
|
|
}
|
|
for _, e := range existing {
|
|
if err := s.relationships.Delete(ctx, e.RelationshipID); err != nil {
|
|
s.logger.Error().Err(err).Str("id", e.RelationshipID).Msg("failed to delete BOM entry during clear")
|
|
}
|
|
}
|
|
}
|
|
|
|
result := CSVImportResult{}
|
|
var createdItems []string
|
|
|
|
for {
|
|
record, err := reader.Read()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
result.TotalRows++
|
|
result.ErrorCount++
|
|
result.Errors = append(result.Errors, CSVImportErr{
|
|
Row: result.TotalRows + 1, // +1 for header
|
|
Message: fmt.Sprintf("Failed to read row: %s", err.Error()),
|
|
})
|
|
continue
|
|
}
|
|
|
|
result.TotalRows++
|
|
rowNum := result.TotalRows + 1 // +1 for header
|
|
|
|
// Get part number
|
|
if pnIdx >= len(record) {
|
|
result.ErrorCount++
|
|
result.Errors = append(result.Errors, CSVImportErr{
|
|
Row: rowNum,
|
|
Field: "PN",
|
|
Message: "Row has fewer columns than expected",
|
|
})
|
|
continue
|
|
}
|
|
|
|
childPN := strings.TrimSpace(record[pnIdx])
|
|
if childPN == "" {
|
|
// Skip blank PN rows silently
|
|
result.TotalRows--
|
|
continue
|
|
}
|
|
|
|
// Look up child item
|
|
child, err := s.items.GetByPartNumber(ctx, childPN)
|
|
if err != nil {
|
|
result.ErrorCount++
|
|
result.Errors = append(result.Errors, CSVImportErr{
|
|
Row: rowNum,
|
|
Field: "PN",
|
|
Message: fmt.Sprintf("Error looking up item: %s", err.Error()),
|
|
})
|
|
continue
|
|
}
|
|
if child == nil {
|
|
result.ErrorCount++
|
|
result.Errors = append(result.Errors, CSVImportErr{
|
|
Row: rowNum,
|
|
Field: "PN",
|
|
Message: fmt.Sprintf("Item '%s' not found", childPN),
|
|
})
|
|
continue
|
|
}
|
|
|
|
// Parse quantity
|
|
var quantity *float64
|
|
if idx, ok := headerIdx["qty"]; ok && idx < len(record) {
|
|
qtyStr := strings.TrimSpace(record[idx])
|
|
if qtyStr != "" {
|
|
q, err := strconv.ParseFloat(qtyStr, 64)
|
|
if err != nil {
|
|
result.ErrorCount++
|
|
result.Errors = append(result.Errors, CSVImportErr{
|
|
Row: rowNum,
|
|
Field: "QTY",
|
|
Message: fmt.Sprintf("Invalid quantity '%s'", qtyStr),
|
|
})
|
|
continue
|
|
}
|
|
quantity = &q
|
|
}
|
|
}
|
|
|
|
// Build metadata from CSV columns
|
|
source := ""
|
|
if idx, ok := headerIdx["source"]; ok && idx < len(record) {
|
|
source = strings.TrimSpace(record[idx])
|
|
}
|
|
metadata := make(map[string]any)
|
|
if idx, ok := headerIdx["seller description"]; ok && idx < len(record) {
|
|
if v := strings.TrimSpace(record[idx]); v != "" {
|
|
metadata["seller_description"] = v
|
|
}
|
|
}
|
|
if idx, ok := headerIdx["unit cost"]; ok && idx < len(record) {
|
|
if v := strings.TrimSpace(record[idx]); v != "" {
|
|
// Strip leading $ or currency symbols
|
|
v = strings.TrimLeft(v, "$£€ ")
|
|
if f, err := strconv.ParseFloat(v, 64); err == nil {
|
|
metadata["unit_cost"] = f
|
|
}
|
|
}
|
|
}
|
|
if idx, ok := headerIdx["sourcing link"]; ok && idx < len(record) {
|
|
if v := strings.TrimSpace(record[idx]); v != "" {
|
|
metadata["sourcing_link"] = v
|
|
}
|
|
}
|
|
|
|
if len(metadata) == 0 {
|
|
metadata = nil
|
|
}
|
|
|
|
// Cycle check
|
|
hasCycle, err := s.relationships.HasCycle(ctx, parent.ID, child.ID)
|
|
if err != nil {
|
|
result.ErrorCount++
|
|
result.Errors = append(result.Errors, CSVImportErr{
|
|
Row: rowNum,
|
|
Field: "PN",
|
|
Message: fmt.Sprintf("Error checking for cycles: %s", err.Error()),
|
|
})
|
|
continue
|
|
}
|
|
if hasCycle {
|
|
result.ErrorCount++
|
|
result.Errors = append(result.Errors, CSVImportErr{
|
|
Row: rowNum,
|
|
Field: "PN",
|
|
Message: fmt.Sprintf("Adding '%s' would create a cycle", childPN),
|
|
})
|
|
continue
|
|
}
|
|
|
|
if dryRun {
|
|
result.SuccessCount++
|
|
continue
|
|
}
|
|
|
|
// Check if relationship already exists (upsert)
|
|
existing, err := s.relationships.GetByParentAndChild(ctx, parent.ID, child.ID)
|
|
if err != nil {
|
|
result.ErrorCount++
|
|
result.Errors = append(result.Errors, CSVImportErr{
|
|
Row: rowNum,
|
|
Message: fmt.Sprintf("Error checking existing relationship: %s", err.Error()),
|
|
})
|
|
continue
|
|
}
|
|
|
|
var importUsername *string
|
|
if user := auth.UserFromContext(ctx); user != nil {
|
|
importUsername = &user.Username
|
|
}
|
|
|
|
if existing != nil {
|
|
// Update existing
|
|
if err := s.relationships.Update(ctx, existing.ID, nil, quantity, nil, nil, nil, metadata, importUsername); err != nil {
|
|
result.ErrorCount++
|
|
result.Errors = append(result.Errors, CSVImportErr{
|
|
Row: rowNum,
|
|
Message: fmt.Sprintf("Failed to update: %s", err.Error()),
|
|
})
|
|
continue
|
|
}
|
|
} else {
|
|
// Create new
|
|
rel := &db.Relationship{
|
|
ParentItemID: parent.ID,
|
|
ChildItemID: child.ID,
|
|
RelType: "component",
|
|
Quantity: quantity,
|
|
Source: source,
|
|
Metadata: metadata,
|
|
CreatedBy: importUsername,
|
|
}
|
|
if err := s.relationships.Create(ctx, rel); err != nil {
|
|
result.ErrorCount++
|
|
result.Errors = append(result.Errors, CSVImportErr{
|
|
Row: rowNum,
|
|
Message: fmt.Sprintf("Failed to create: %s", err.Error()),
|
|
})
|
|
continue
|
|
}
|
|
createdItems = append(createdItems, childPN)
|
|
}
|
|
|
|
result.SuccessCount++
|
|
}
|
|
|
|
result.CreatedItems = createdItems
|
|
|
|
s.logger.Info().
|
|
Str("parent", partNumber).
|
|
Bool("dry_run", dryRun).
|
|
Int("total", result.TotalRows).
|
|
Int("success", result.SuccessCount).
|
|
Int("errors", result.ErrorCount).
|
|
Msg("BOM CSV import completed")
|
|
|
|
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),
|
|
}))
|
|
|
|
// Trigger auto-jobs (e.g. assembly validation)
|
|
go s.triggerJobs(context.Background(), "bom_changed", parent.ID, parent)
|
|
|
|
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
|
|
}
|