package handlers import ( "errors" "log/slog" "net/http" "strings" "git.mchus.pro/mchus/quoteforge/internal/localdb" "git.mchus.pro/mchus/quoteforge/internal/repository" "git.mchus.pro/mchus/quoteforge/internal/services" syncsvc "git.mchus.pro/mchus/quoteforge/internal/services/sync" "github.com/gin-gonic/gin" ) // VendorSpecHandler handles vendor BOM spec operations for a configuration. type VendorSpecHandler struct { localDB *localdb.LocalDB configService *services.LocalConfigurationService syncService *syncsvc.Service // optional; nil = no server push } func NewVendorSpecHandler(localDB *localdb.LocalDB, syncService *syncsvc.Service) *VendorSpecHandler { return &VendorSpecHandler{ localDB: localDB, configService: services.NewLocalConfigurationService(localDB, nil, nil, func() bool { return false }), syncService: syncService, } } // lookupConfig finds an active configuration by UUID using the standard localDB method. func (h *VendorSpecHandler) lookupConfig(uuid string) (*localdb.LocalConfiguration, error) { cfg, err := h.localDB.GetConfigurationByUUID(uuid) if err != nil { return nil, err } if !cfg.IsActive { return nil, errors.New("not active") } return cfg, nil } // ParseText parses a pasted single-column text BOM (Inspur or Russian text BOM) // using the same parsers as the vendor file-import path. It is stateless: no // configuration is required. Returns the parsed rows and the detected format, or // an empty result when the text is not a recognized single-column format (the // client then falls back to manual column mapping). // POST /api/vendor-spec/parse-text func (h *VendorSpecHandler) ParseText(c *gin.Context) { var body struct { Text string `json:"text"` } if err := c.ShouldBindJSON(&body); err != nil { RespondError(c, http.StatusUnprocessableEntity, "invalid request", err) return } rows, format := services.ParsePastedBOMText(body.Text) if rows == nil { rows = []localdb.VendorSpecItem{} } c.JSON(http.StatusOK, gin.H{"rows": rows, "format": format}) } // GetVendorSpec returns the vendor spec (BOM) for a configuration. // GET /api/configs/:uuid/vendor-spec func (h *VendorSpecHandler) GetVendorSpec(c *gin.Context) { cfg, err := h.lookupConfig(c.Param("uuid")) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "configuration not found"}) return } spec := cfg.VendorSpec if spec == nil { spec = localdb.VendorSpec{} } c.JSON(http.StatusOK, gin.H{"vendor_spec": spec}) } // PutVendorSpec saves (replaces) the vendor spec for a configuration. // PUT /api/configs/:uuid/vendor-spec func (h *VendorSpecHandler) PutVendorSpec(c *gin.Context) { cfg, err := h.lookupConfig(c.Param("uuid")) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "configuration not found"}) return } var body struct { VendorSpec []localdb.VendorSpecItem `json:"vendor_spec"` } if err := c.ShouldBindJSON(&body); err != nil { RespondError(c, http.StatusUnprocessableEntity, "invalid request", err) return } for i := range body.VendorSpec { if body.VendorSpec[i].SortOrder == 0 { body.VendorSpec[i].SortOrder = (i + 1) * 10 } // Persist canonical LOT mapping only. body.VendorSpec[i].LotMappings = normalizeLotMappings(body.VendorSpec[i].LotMappings) body.VendorSpec[i].ResolvedLotName = "" body.VendorSpec[i].ResolutionSource = "" body.VendorSpec[i].ManualLotSuggestion = "" body.VendorSpec[i].LotQtyPerPN = 0 body.VendorSpec[i].LotAllocations = nil } spec := localdb.VendorSpec(body.VendorSpec) if _, err := h.configService.UpdateVendorSpecNoAuth(cfg.UUID, spec); err != nil { RespondError(c, http.StatusInternalServerError, "internal server error", err) return } // Push manual lot mappings as suggestions to the server (best-effort, non-blocking). h.pushLotSuggestions(body.VendorSpec) c.JSON(http.StatusOK, gin.H{"vendor_spec": spec}) } // pushLotSuggestions sends manual PN→LOT mappings to qt_vendor_partnumber_seen.lot_suggestion. // Errors are logged and silently dropped — they must not affect the HTTP response. func (h *VendorSpecHandler) pushLotSuggestions(spec []localdb.VendorSpecItem) { if h.syncService == nil { return } var items []syncsvc.SeenPartnumber for _, row := range spec { if row.VendorPartnumber == "" || len(row.LotMappings) == 0 { continue } suggestion := make([]syncsvc.LotSuggestionEntry, 0, len(row.LotMappings)) for _, m := range row.LotMappings { if m.LotName == "" { continue } qty := m.QuantityPerPN if qty < 1 { qty = 1 } suggestion = append(suggestion, syncsvc.LotSuggestionEntry{ LotName: m.LotName, Qty: qty, }) } if len(suggestion) == 0 { continue } items = append(items, syncsvc.SeenPartnumber{ Partnumber: row.VendorPartnumber, Description: row.Description, LotSuggestion: suggestion, }) } if len(items) == 0 { return } if err := h.syncService.PushPartnumberSeen(items); err != nil { slog.Warn("vendor_spec: failed to push lot suggestions to server", "error", err) } } func normalizeLotMappings(in []localdb.VendorSpecLotMapping) []localdb.VendorSpecLotMapping { if len(in) == 0 { return nil } merged := make(map[string]int, len(in)) order := make([]string, 0, len(in)) for _, m := range in { lot := strings.TrimSpace(m.LotName) if lot == "" { continue } qty := m.QuantityPerPN if qty < 1 { qty = 1 } if _, exists := merged[lot]; !exists { order = append(order, lot) } merged[lot] += qty } out := make([]localdb.VendorSpecLotMapping, 0, len(order)) for _, lot := range order { out = append(out, localdb.VendorSpecLotMapping{ LotName: lot, QuantityPerPN: merged[lot], }) } if len(out) == 0 { return nil } return out } // ResolveVendorSpec resolves vendor PN → LOT without modifying the cart. // POST /api/configs/:uuid/vendor-spec/resolve func (h *VendorSpecHandler) ResolveVendorSpec(c *gin.Context) { if _, err := h.lookupConfig(c.Param("uuid")); err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "configuration not found"}) return } var body struct { VendorSpec []localdb.VendorSpecItem `json:"vendor_spec"` } if err := c.ShouldBindJSON(&body); err != nil { RespondError(c, http.StatusUnprocessableEntity, "invalid request", err) return } bookRepo := repository.NewPartnumberBookRepository(h.localDB.DB()) resolver := services.NewVendorSpecResolver(bookRepo) resolved, err := resolver.Resolve(body.VendorSpec) if err != nil { RespondError(c, http.StatusInternalServerError, "internal server error", err) return } book, err := bookRepo.GetActiveBook() if err != nil { slog.Warn("vendor spec resolve: no active partnumber book", "err", err) book = nil } aggregated, err := services.AggregateLOTs(resolved, book, bookRepo) if err != nil { RespondError(c, http.StatusInternalServerError, "internal server error", err) return } c.JSON(http.StatusOK, gin.H{ "resolved": resolved, "aggregated": aggregated, }) } // ApplyVendorSpec applies the resolved BOM to the cart (Estimate items). // POST /api/configs/:uuid/vendor-spec/apply func (h *VendorSpecHandler) ApplyVendorSpec(c *gin.Context) { cfg, err := h.lookupConfig(c.Param("uuid")) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "configuration not found"}) return } var body struct { Items []struct { LotName string `json:"lot_name"` Quantity int `json:"quantity"` UnitPrice float64 `json:"unit_price"` } `json:"items"` } if err := c.ShouldBindJSON(&body); err != nil { RespondError(c, http.StatusUnprocessableEntity, "invalid request", err) return } newItems := make(localdb.LocalConfigItems, 0, len(body.Items)) for _, it := range body.Items { newItems = append(newItems, localdb.LocalConfigItem{ LotName: it.LotName, Quantity: it.Quantity, UnitPrice: it.UnitPrice, }) } if _, err := h.configService.ApplyVendorSpecItemsNoAuth(cfg.UUID, newItems); err != nil { RespondError(c, http.StatusInternalServerError, "internal server error", err) return } c.JSON(http.StatusOK, gin.H{"items": newItems}) }