366 lines
11 KiB
Go
366 lines
11 KiB
Go
package handler
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/gofrs/uuid/v5"
|
|
|
|
"github.com/saveinmed/backend-go/internal/domain"
|
|
)
|
|
|
|
// GetShippingSettings godoc
|
|
// @Summary Get vendor shipping settings
|
|
// @Description Returns pickup and delivery settings for a vendor.
|
|
// @Tags Shipping
|
|
// @Produce json
|
|
// @Param vendor_id path string true "Vendor ID"
|
|
// @Success 200 {object} domain.ShippingSettings
|
|
// @Failure 400 {object} map[string]string
|
|
// @Failure 403 {object} map[string]string
|
|
// @Failure 500 {object} map[string]string
|
|
// @Router /api/v1/shipping/settings/{vendor_id} [get]
|
|
func (h *Handler) GetShippingSettings(w http.ResponseWriter, r *http.Request) {
|
|
vendorID, err := parseUUIDFromPath(r.URL.Path)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err)
|
|
return
|
|
}
|
|
|
|
// Any authenticated user can view shipping settings (needed for checkout)
|
|
// No role-based restriction here - shipping settings are public info for buyers
|
|
|
|
settings, err := h.svc.GetShippingSettings(r.Context(), vendorID)
|
|
if err != nil {
|
|
// Log error if needed, but for 404/not found we might return empty object
|
|
writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
if settings == nil {
|
|
// Return defaults
|
|
settings = &domain.ShippingSettings{VendorID: vendorID, Active: false}
|
|
}
|
|
writeJSON(w, http.StatusOK, settings)
|
|
}
|
|
|
|
// UpsertShippingSettings godoc
|
|
// @Summary Update vendor shipping settings
|
|
// @Description Stores pickup and delivery settings for a vendor.
|
|
// @Tags Shipping
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param vendor_id path string true "Vendor ID"
|
|
// @Param payload body shippingSettingsRequest true "Shipping settings"
|
|
// @Success 200 {object} domain.ShippingSettings
|
|
// @Failure 400 {object} map[string]string
|
|
// @Failure 403 {object} map[string]string
|
|
// @Failure 500 {object} map[string]string
|
|
// @Router /api/v1/shipping/settings/{vendor_id} [put]
|
|
func (h *Handler) UpsertShippingSettings(w http.ResponseWriter, r *http.Request) {
|
|
vendorID, err := parseUUIDFromPath(r.URL.Path)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err)
|
|
return
|
|
}
|
|
|
|
requester, err := getRequester(r)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err)
|
|
return
|
|
}
|
|
if !strings.EqualFold(requester.Role, "Admin") {
|
|
if requester.CompanyID == nil || *requester.CompanyID != vendorID {
|
|
writeError(w, http.StatusForbidden, errors.New("not allowed to update shipping settings"))
|
|
return
|
|
}
|
|
}
|
|
|
|
var req shippingSettingsRequest
|
|
if err := decodeJSON(r.Context(), r, &req); err != nil {
|
|
writeError(w, http.StatusBadRequest, err)
|
|
return
|
|
}
|
|
|
|
if req.Active {
|
|
if req.MaxRadiusKm < 0 {
|
|
writeError(w, http.StatusBadRequest, errors.New("max_radius_km must be >= 0"))
|
|
return
|
|
}
|
|
if req.PricePerKmCents < 0 || req.MinFeeCents < 0 {
|
|
writeError(w, http.StatusBadRequest, errors.New("pricing fields must be >= 0"))
|
|
return
|
|
}
|
|
}
|
|
if req.PickupActive {
|
|
if strings.TrimSpace(req.PickupAddress) == "" || strings.TrimSpace(req.PickupHours) == "" {
|
|
writeError(w, http.StatusBadRequest, errors.New("pickup_address and pickup_hours are required for active pickup"))
|
|
return
|
|
}
|
|
}
|
|
|
|
settings := &domain.ShippingSettings{
|
|
VendorID: vendorID,
|
|
Active: req.Active,
|
|
MaxRadiusKm: req.MaxRadiusKm,
|
|
PricePerKmCents: req.PricePerKmCents,
|
|
MinFeeCents: req.MinFeeCents,
|
|
FreeShippingThresholdCents: req.FreeShippingThresholdCents,
|
|
PickupActive: req.PickupActive,
|
|
PickupAddress: req.PickupAddress,
|
|
PickupHours: req.PickupHours,
|
|
Latitude: req.Latitude,
|
|
Longitude: req.Longitude,
|
|
}
|
|
|
|
if err := h.svc.UpsertShippingSettings(r.Context(), settings); err != nil {
|
|
writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, settings)
|
|
}
|
|
|
|
// shippingCalculateV2Item is a single cart item in the new frontend request format.
|
|
type shippingCalculateV2Item struct {
|
|
SellerID uuid.UUID `json:"seller_id"`
|
|
ProductID uuid.UUID `json:"product_id"`
|
|
Quantity int64 `json:"quantity"`
|
|
PriceCents int64 `json:"price_cents"`
|
|
}
|
|
|
|
// shippingCalculateV2Request is the frontend-native calculate request.
|
|
type shippingCalculateV2Request struct {
|
|
BuyerID uuid.UUID `json:"buyer_id"`
|
|
OrderTotalCents int64 `json:"order_total_cents"`
|
|
Items []shippingCalculateV2Item `json:"items"`
|
|
}
|
|
|
|
// shippingOptionV2Response is the frontend-native shipping option per seller.
|
|
type shippingOptionV2Response struct {
|
|
SellerID string `json:"seller_id"`
|
|
DeliveryFeeCents int64 `json:"delivery_fee_cents"`
|
|
DistanceKm float64 `json:"distance_km"`
|
|
EstimatedDays int `json:"estimated_days"`
|
|
PickupAvailable bool `json:"pickup_available"`
|
|
PickupAddress string `json:"pickup_address,omitempty"`
|
|
PickupHours string `json:"pickup_hours,omitempty"`
|
|
}
|
|
|
|
// CalculateShipping godoc
|
|
// @Summary Calculate shipping options
|
|
// @Description Accepts two formats:
|
|
// (1) Legacy: { vendor_id, cart_total_cents, buyer_latitude, buyer_longitude }
|
|
// (2) Frontend: { buyer_id, order_total_cents, items: [{seller_id, ...}] }
|
|
// Returns options per seller in format { options: [...] }.
|
|
// @Tags Shipping
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Success 200 {object} map[string]interface{}
|
|
// @Failure 400 {object} map[string]string
|
|
// @Failure 500 {object} map[string]string
|
|
// @Router /api/v1/shipping/calculate [post]
|
|
func (h *Handler) CalculateShipping(w http.ResponseWriter, r *http.Request) {
|
|
// Decode into a generic map to detect request format
|
|
var raw map[string]interface{}
|
|
if err := decodeJSON(r.Context(), r, &raw); err != nil {
|
|
writeError(w, http.StatusBadRequest, err)
|
|
return
|
|
}
|
|
|
|
// Detect frontend format: has "items" array with seller_id
|
|
_, hasItems := raw["items"]
|
|
_, hasBuyerID := raw["buyer_id"]
|
|
|
|
if hasItems && hasBuyerID {
|
|
// New frontend format
|
|
h.calculateShippingV2(w, r, raw)
|
|
return
|
|
}
|
|
|
|
// Legacy format: vendor_id + buyer coordinates
|
|
h.calculateShippingLegacy(w, r, raw)
|
|
}
|
|
|
|
// calculateShippingV2 handles the frontend's multi-seller shipping calculation.
|
|
func (h *Handler) calculateShippingV2(w http.ResponseWriter, r *http.Request, raw map[string]interface{}) {
|
|
// Re-parse items from raw map
|
|
buyerIDStr, _ := raw["buyer_id"].(string)
|
|
buyerID, err := uuid.FromString(buyerIDStr)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, errors.New("invalid buyer_id"))
|
|
return
|
|
}
|
|
|
|
orderTotalCents := int64(0)
|
|
if v, ok := raw["order_total_cents"].(float64); ok {
|
|
orderTotalCents = int64(v)
|
|
}
|
|
|
|
itemsRaw, _ := raw["items"].([]interface{})
|
|
|
|
// Group items by seller_id to get totals per seller
|
|
sellerTotals := map[uuid.UUID]int64{}
|
|
for _, itemRaw := range itemsRaw {
|
|
item, ok := itemRaw.(map[string]interface{})
|
|
if !ok {
|
|
continue
|
|
}
|
|
sellerIDStr, _ := item["seller_id"].(string)
|
|
sellerID, err := uuid.FromString(sellerIDStr)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
qty := int64(1)
|
|
if q, ok := item["quantity"].(float64); ok {
|
|
qty = int64(q)
|
|
}
|
|
price := int64(0)
|
|
if p, ok := item["price_cents"].(float64); ok {
|
|
price = int64(p)
|
|
}
|
|
sellerTotals[sellerID] += qty * price
|
|
}
|
|
|
|
if len(sellerTotals) == 0 {
|
|
writeError(w, http.StatusBadRequest, errors.New("no valid items provided"))
|
|
return
|
|
}
|
|
|
|
// Try to get buyer's primary address for coordinates
|
|
buyerAddr := &domain.Address{}
|
|
addresses, _ := h.svc.ListAddresses(r.Context(), buyerID)
|
|
if len(addresses) > 0 {
|
|
buyerAddr = &addresses[0]
|
|
}
|
|
|
|
// Calculate options per seller
|
|
results := make([]shippingOptionV2Response, 0, len(sellerTotals))
|
|
for sellerID, sellerTotal := range sellerTotals {
|
|
opt := shippingOptionV2Response{
|
|
SellerID: sellerID.String(),
|
|
}
|
|
|
|
// Use per-seller total if available, otherwise distribute order total equally
|
|
cartTotal := sellerTotal
|
|
if cartTotal == 0 && orderTotalCents > 0 {
|
|
cartTotal = orderTotalCents / int64(len(sellerTotals))
|
|
}
|
|
|
|
fee, distKm, calcErr := h.svc.CalculateShipping(r.Context(), buyerAddr, sellerID, cartTotal)
|
|
if calcErr == nil {
|
|
opt.DeliveryFeeCents = fee
|
|
opt.DistanceKm = distKm
|
|
opt.EstimatedDays = 1
|
|
if distKm > 50 {
|
|
opt.EstimatedDays = 3
|
|
} else if distKm > 20 {
|
|
opt.EstimatedDays = 2
|
|
}
|
|
}
|
|
|
|
// Check pickup availability
|
|
settings, _ := h.svc.GetShippingSettings(r.Context(), sellerID)
|
|
if settings != nil && settings.PickupActive {
|
|
opt.PickupAvailable = true
|
|
opt.PickupAddress = settings.PickupAddress
|
|
opt.PickupHours = settings.PickupHours
|
|
}
|
|
|
|
results = append(results, opt)
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{"options": results})
|
|
}
|
|
|
|
// calculateShippingLegacy handles the original vendor_id + coordinates format.
|
|
func (h *Handler) calculateShippingLegacy(w http.ResponseWriter, r *http.Request, raw map[string]interface{}) {
|
|
vendorIDStr, _ := raw["vendor_id"].(string)
|
|
vendorID, err := uuid.FromString(vendorIDStr)
|
|
if err != nil || vendorID == uuid.Nil {
|
|
writeError(w, http.StatusBadRequest, errors.New("vendor_id is required"))
|
|
return
|
|
}
|
|
|
|
cartTotal := int64(0)
|
|
if v, ok := raw["cart_total_cents"].(float64); ok {
|
|
cartTotal = int64(v)
|
|
}
|
|
|
|
buyerLat, hasLat := raw["buyer_latitude"].(float64)
|
|
buyerLng, hasLng := raw["buyer_longitude"].(float64)
|
|
if !hasLat || !hasLng {
|
|
writeError(w, http.StatusBadRequest, errors.New("buyer_latitude and buyer_longitude are required"))
|
|
return
|
|
}
|
|
|
|
buyerAddr := &domain.Address{Latitude: buyerLat, Longitude: buyerLng}
|
|
options := make([]domain.ShippingOption, 0)
|
|
|
|
fee, distKm, calcErr := h.svc.CalculateShipping(r.Context(), buyerAddr, vendorID, cartTotal)
|
|
if calcErr == nil {
|
|
desc := "Entrega padrão"
|
|
if fee == 0 {
|
|
desc = "Frete Grátis"
|
|
}
|
|
options = append(options, domain.ShippingOption{
|
|
Type: "delivery",
|
|
Description: desc,
|
|
ValueCents: fee,
|
|
DistanceKm: distKm,
|
|
EstimatedMinutes: 120,
|
|
})
|
|
}
|
|
|
|
settings, _ := h.svc.GetShippingSettings(r.Context(), vendorID)
|
|
if settings != nil && settings.PickupActive {
|
|
options = append(options, domain.ShippingOption{
|
|
Type: "pickup",
|
|
Description: settings.PickupAddress,
|
|
ValueCents: 0,
|
|
EstimatedMinutes: 0,
|
|
})
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, options)
|
|
}
|
|
|
|
// ListShipments godoc
|
|
// @Summary List shipments
|
|
// @Description Returns shipments. Admins see all, Tenants see only their own.
|
|
// @Tags Shipments
|
|
// @Security BearerAuth
|
|
// @Produce json
|
|
// @Param page query int false "Página"
|
|
// @Param page_size query int false "Tamanho da página"
|
|
// @Success 200 {object} domain.ShipmentPage
|
|
// @Failure 401 {object} map[string]string
|
|
// @Failure 500 {object} map[string]string
|
|
// @Router /api/v1/shipments [get]
|
|
func (h *Handler) ListShipments(w http.ResponseWriter, r *http.Request) {
|
|
page, pageSize := parsePagination(r)
|
|
|
|
requester, err := getRequester(r)
|
|
if err != nil {
|
|
writeError(w, http.StatusUnauthorized, err)
|
|
return
|
|
}
|
|
|
|
filter := domain.ShipmentFilter{}
|
|
if !strings.EqualFold(requester.Role, "Admin") {
|
|
if requester.CompanyID == nil {
|
|
writeError(w, http.StatusForbidden, errors.New("user has no company associated"))
|
|
return
|
|
}
|
|
// Shipments logic:
|
|
// Shipments are linked to orders, and orders belong to sellers.
|
|
filter.SellerID = requester.CompanyID
|
|
}
|
|
|
|
result, err := h.svc.ListShipments(r.Context(), filter, page, pageSize)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, result)
|
|
}
|