Merge pull request #8 from rede5/codex/implement-inventory-control-routes

Add inventory and cart endpoints
This commit is contained in:
Tiago Yamamoto 2025-12-18 12:47:23 -03:00 committed by GitHub
commit 94c27ec7dc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 439 additions and 1 deletions

View file

@ -59,6 +59,32 @@ type Product struct {
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// InventoryItem exposes stock tracking tied to product batches.
type InventoryItem struct {
ProductID uuid.UUID `db:"product_id" json:"product_id"`
SellerID uuid.UUID `db:"seller_id" json:"seller_id"`
Name string `db:"name" json:"name"`
Batch string `db:"batch" json:"batch"`
ExpiresAt time.Time `db:"expires_at" json:"expires_at"`
Quantity int64 `db:"quantity" json:"quantity"`
PriceCents int64 `db:"price_cents" json:"price_cents"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// InventoryFilter allows filtering by expiration window.
type InventoryFilter struct {
ExpiringBefore *time.Time
}
// InventoryAdjustment records manual stock corrections.
type InventoryAdjustment struct {
ID uuid.UUID `db:"id" json:"id"`
ProductID uuid.UUID `db:"product_id" json:"product_id"`
Delta int64 `db:"delta" json:"delta"`
Reason string `db:"reason" json:"reason"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
}
// Order captures the status lifecycle and payment intent.
type Order struct {
ID uuid.UUID `db:"id" json:"id"`
@ -101,3 +127,26 @@ const (
OrderStatusInvoiced OrderStatus = "Faturado"
OrderStatusDelivered OrderStatus = "Entregue"
)
// CartItem stores buyer selections with unit pricing.
type CartItem struct {
ID uuid.UUID `db:"id" json:"id"`
BuyerID uuid.UUID `db:"buyer_id" json:"buyer_id"`
ProductID uuid.UUID `db:"product_id" json:"product_id"`
Quantity int64 `db:"quantity" json:"quantity"`
UnitCents int64 `db:"unit_cents" json:"unit_cents"`
ProductName string `db:"product_name" json:"product_name"`
Batch string `db:"batch" json:"batch"`
ExpiresAt time.Time `db:"expires_at" json:"expires_at"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// CartSummary aggregates cart totals and discounts.
type CartSummary struct {
Items []CartItem `json:"items"`
SubtotalCents int64 `json:"subtotal_cents"`
DiscountCents int64 `json:"discount_cents"`
TotalCents int64 `json:"total_cents"`
DiscountReason string `json:"discount_reason,omitempty"`
}

View file

@ -46,8 +46,13 @@ func (h *Handler) Register(w http.ResponseWriter, r *http.Request) {
}
}
var companyID uuid.UUID
if req.CompanyID != nil {
companyID = *req.CompanyID
}
user := &domain.User{
CompanyID: req.CompanyID,
CompanyID: companyID,
Role: req.Role,
Name: req.Name,
Email: req.Email,
@ -222,6 +227,50 @@ func (h *Handler) ListProducts(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, products)
}
// ListInventory exposes stock with expiring batch filters.
func (h *Handler) ListInventory(w http.ResponseWriter, r *http.Request) {
var filter domain.InventoryFilter
if days := r.URL.Query().Get("expires_in_days"); days != "" {
n, err := strconv.Atoi(days)
if err != nil || n < 0 {
writeError(w, http.StatusBadRequest, errors.New("invalid expires_in_days"))
return
}
expires := time.Now().Add(time.Duration(n) * 24 * time.Hour)
filter.ExpiringBefore = &expires
}
inventory, err := h.svc.ListInventory(r.Context(), filter)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, inventory)
}
// AdjustInventory handles manual stock corrections.
func (h *Handler) AdjustInventory(w http.ResponseWriter, r *http.Request) {
var req inventoryAdjustRequest
if err := decodeJSON(r.Context(), r, &req); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
if req.Delta == 0 {
writeError(w, http.StatusBadRequest, errors.New("delta must be non-zero"))
return
}
item, err := h.svc.AdjustInventory(r.Context(), req.ProductID, req.Delta, req.Reason)
if err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
writeJSON(w, http.StatusOK, item)
}
// CreateOrder godoc
// @Summary Criação de pedido com split
// @Tags Pedidos
@ -315,6 +364,68 @@ func (h *Handler) UpdateOrderStatus(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// AddToCart appends an item to the authenticated buyer cart respecting stock.
func (h *Handler) AddToCart(w http.ResponseWriter, r *http.Request) {
claims, ok := middleware.GetClaims(r.Context())
if !ok || claims.CompanyID == nil {
writeError(w, http.StatusBadRequest, errors.New("missing buyer context"))
return
}
var req addCartItemRequest
if err := decodeJSON(r.Context(), r, &req); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
summary, err := h.svc.AddItemToCart(r.Context(), *claims.CompanyID, req.ProductID, req.Quantity)
if err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
writeJSON(w, http.StatusCreated, summary)
}
// GetCart returns cart contents and totals for the authenticated buyer.
func (h *Handler) GetCart(w http.ResponseWriter, r *http.Request) {
claims, ok := middleware.GetClaims(r.Context())
if !ok || claims.CompanyID == nil {
writeError(w, http.StatusBadRequest, errors.New("missing buyer context"))
return
}
summary, err := h.svc.ListCart(r.Context(), *claims.CompanyID)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, summary)
}
// DeleteCartItem removes a product from the cart and returns the updated totals.
func (h *Handler) DeleteCartItem(w http.ResponseWriter, r *http.Request) {
claims, ok := middleware.GetClaims(r.Context())
if !ok || claims.CompanyID == nil {
writeError(w, http.StatusBadRequest, errors.New("missing buyer context"))
return
}
id, err := parseUUIDFromPath(r.URL.Path)
if err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
summary, err := h.svc.RemoveCartItem(r.Context(), *claims.CompanyID, id)
if err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
writeJSON(w, http.StatusOK, summary)
}
// CreatePaymentPreference godoc
// @Summary Cria preferência de pagamento Mercado Pago com split nativo
// @Tags Pagamentos
@ -579,6 +690,17 @@ type authResponse struct {
ExpiresAt time.Time `json:"expires_at"`
}
type inventoryAdjustRequest struct {
ProductID uuid.UUID `json:"product_id"`
Delta int64 `json:"delta"`
Reason string `json:"reason"`
}
type addCartItemRequest struct {
ProductID uuid.UUID `json:"product_id"`
Quantity int64 `json:"quantity"`
}
type updateUserRequest struct {
CompanyID *uuid.UUID `json:"company_id,omitempty"`
Role *string `json:"role,omitempty"`

View file

@ -95,6 +95,15 @@ func (r *Repository) ListProducts(ctx context.Context) ([]domain.Product, error)
return products, nil
}
func (r *Repository) GetProduct(ctx context.Context, id uuid.UUID) (*domain.Product, error) {
var product domain.Product
query := `SELECT id, seller_id, name, description, batch, expires_at, price_cents, stock, created_at, updated_at FROM products WHERE id = $1`
if err := r.db.GetContext(ctx, &product, query, id); err != nil {
return nil, err
}
return &product, nil
}
func (r *Repository) CreateOrder(ctx context.Context, order *domain.Order) error {
now := time.Now().UTC()
order.CreatedAt = now
@ -158,6 +167,80 @@ func (r *Repository) UpdateOrderStatus(ctx context.Context, id uuid.UUID, status
return nil
}
func (r *Repository) AdjustInventory(ctx context.Context, productID uuid.UUID, delta int64, reason string) (*domain.InventoryItem, error) {
tx, err := r.db.BeginTxx(ctx, nil)
if err != nil {
return nil, err
}
var product domain.Product
if err := tx.GetContext(ctx, &product, `SELECT id, seller_id, name, batch, expires_at, price_cents, stock, updated_at FROM products WHERE id = $1 FOR UPDATE`, productID); err != nil {
_ = tx.Rollback()
return nil, err
}
newStock := product.Stock + delta
if newStock < 0 {
_ = tx.Rollback()
return nil, errors.New("inventory cannot be negative")
}
now := time.Now().UTC()
if _, err := tx.ExecContext(ctx, `UPDATE products SET stock = $1, updated_at = $2 WHERE id = $3`, newStock, now, productID); err != nil {
_ = tx.Rollback()
return nil, err
}
adj := domain.InventoryAdjustment{
ID: uuid.Must(uuid.NewV7()),
ProductID: productID,
Delta: delta,
Reason: reason,
CreatedAt: now,
}
if _, err := tx.NamedExecContext(ctx, `INSERT INTO inventory_adjustments (id, product_id, delta, reason, created_at) VALUES (:id, :product_id, :delta, :reason, :created_at)`, &adj); err != nil {
_ = tx.Rollback()
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, err
}
return &domain.InventoryItem{
ProductID: productID,
SellerID: product.SellerID,
Name: product.Name,
Batch: product.Batch,
ExpiresAt: product.ExpiresAt,
Quantity: newStock,
PriceCents: product.PriceCents,
UpdatedAt: now,
}, nil
}
func (r *Repository) ListInventory(ctx context.Context, filter domain.InventoryFilter) ([]domain.InventoryItem, error) {
args := []any{}
clauses := []string{}
if filter.ExpiringBefore != nil {
clauses = append(clauses, fmt.Sprintf("expires_at <= $%d", len(args)+1))
args = append(args, *filter.ExpiringBefore)
}
where := ""
if len(clauses) > 0 {
where = " WHERE " + strings.Join(clauses, " AND ")
}
query := fmt.Sprintf(`SELECT id AS product_id, seller_id, name, batch, expires_at, stock AS quantity, price_cents, updated_at FROM products%s ORDER BY expires_at ASC`, where)
var items []domain.InventoryItem
if err := r.db.SelectContext(ctx, &items, query, args...); err != nil {
return nil, err
}
return items, nil
}
func (r *Repository) CreateUser(ctx context.Context, user *domain.User) error {
now := time.Now().UTC()
user.CreatedAt = now
@ -256,6 +339,67 @@ func (r *Repository) DeleteUser(ctx context.Context, id uuid.UUID) error {
return nil
}
func (r *Repository) AddCartItem(ctx context.Context, item *domain.CartItem) (*domain.CartItem, error) {
now := time.Now().UTC()
item.CreatedAt = now
item.UpdatedAt = now
query := `INSERT INTO cart_items (id, buyer_id, product_id, quantity, unit_cents, batch, expires_at, created_at, updated_at)
VALUES (:id, :buyer_id, :product_id, :quantity, :unit_cents, :batch, :expires_at, :created_at, :updated_at)
ON CONFLICT (buyer_id, product_id) DO UPDATE
SET quantity = cart_items.quantity + EXCLUDED.quantity,
unit_cents = EXCLUDED.unit_cents,
batch = EXCLUDED.batch,
expires_at = EXCLUDED.expires_at,
updated_at = EXCLUDED.updated_at
RETURNING id, buyer_id, product_id, quantity, unit_cents, batch, expires_at, created_at, updated_at`
var saved domain.CartItem
rows, err := r.db.NamedQueryContext(ctx, query, item)
if err != nil {
return nil, err
}
defer rows.Close()
if rows.Next() {
if err := rows.StructScan(&saved); err != nil {
return nil, err
}
return &saved, nil
}
return nil, errors.New("failed to persist cart item")
}
func (r *Repository) ListCartItems(ctx context.Context, buyerID uuid.UUID) ([]domain.CartItem, error) {
query := `SELECT ci.id, ci.buyer_id, ci.product_id, ci.quantity, ci.unit_cents, ci.batch, ci.expires_at, ci.created_at, ci.updated_at,
p.name AS product_name
FROM cart_items ci
JOIN products p ON p.id = ci.product_id
WHERE ci.buyer_id = $1
ORDER BY ci.created_at DESC`
var items []domain.CartItem
if err := r.db.SelectContext(ctx, &items, query, buyerID); err != nil {
return nil, err
}
return items, nil
}
func (r *Repository) DeleteCartItem(ctx context.Context, id uuid.UUID, buyerID uuid.UUID) error {
res, err := r.db.ExecContext(ctx, "DELETE FROM cart_items WHERE id = $1 AND buyer_id = $2", id, buyerID)
if err != nil {
return err
}
rows, err := res.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
return errors.New("cart item not found")
}
return nil
}
// InitSchema applies a minimal schema for development environments.
func (r *Repository) InitSchema(ctx context.Context) error {
schema := `
@ -294,6 +438,14 @@ CREATE TABLE IF NOT EXISTS products (
updated_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE IF NOT EXISTS inventory_adjustments (
id UUID PRIMARY KEY,
product_id UUID NOT NULL REFERENCES products(id),
delta BIGINT NOT NULL,
reason TEXT,
created_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE IF NOT EXISTS orders (
id UUID PRIMARY KEY,
buyer_id UUID NOT NULL REFERENCES companies(id),
@ -313,6 +465,19 @@ CREATE TABLE IF NOT EXISTS order_items (
batch TEXT NOT NULL,
expires_at DATE NOT NULL
);
CREATE TABLE IF NOT EXISTS cart_items (
id UUID PRIMARY KEY,
buyer_id UUID NOT NULL REFERENCES companies(id),
product_id UUID NOT NULL REFERENCES products(id),
quantity BIGINT NOT NULL,
unit_cents BIGINT NOT NULL,
batch TEXT,
expires_at DATE,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
UNIQUE (buyer_id, product_id)
);
`
if _, err := r.db.ExecContext(ctx, schema); err != nil {

View file

@ -70,6 +70,9 @@ func New(cfg config.Config) (*Server, error) {
mux.Handle("POST /api/products", chain(http.HandlerFunc(h.CreateProduct), middleware.Logger, middleware.Gzip))
mux.Handle("GET /api/products", chain(http.HandlerFunc(h.ListProducts), middleware.Logger, middleware.Gzip))
mux.Handle("GET /api/v1/inventory", chain(http.HandlerFunc(h.ListInventory), middleware.Logger, middleware.Gzip, auth))
mux.Handle("POST /api/v1/inventory/adjust", chain(http.HandlerFunc(h.AdjustInventory), middleware.Logger, middleware.Gzip, auth))
mux.Handle("POST /api/orders", chain(http.HandlerFunc(h.CreateOrder), middleware.Logger, middleware.Gzip, auth))
mux.Handle("GET /api/orders/", chain(http.HandlerFunc(h.GetOrder), middleware.Logger, middleware.Gzip, auth))
mux.Handle("PATCH /api/orders/", chain(http.HandlerFunc(h.UpdateOrderStatus), middleware.Logger, middleware.Gzip, auth))
@ -84,6 +87,10 @@ func New(cfg config.Config) (*Server, error) {
mux.Handle("PUT /api/v1/users/", chain(http.HandlerFunc(h.UpdateUser), middleware.Logger, middleware.Gzip, auth))
mux.Handle("DELETE /api/v1/users/", chain(http.HandlerFunc(h.DeleteUser), middleware.Logger, middleware.Gzip, auth))
mux.Handle("POST /api/v1/cart", chain(http.HandlerFunc(h.AddToCart), middleware.Logger, middleware.Gzip, auth))
mux.Handle("GET /api/v1/cart", chain(http.HandlerFunc(h.GetCart), middleware.Logger, middleware.Gzip, auth))
mux.Handle("DELETE /api/v1/cart/", chain(http.HandlerFunc(h.DeleteCartItem), middleware.Logger, middleware.Gzip, auth))
mux.Handle("GET /swagger/", httpSwagger.Handler(httpSwagger.URL("/swagger/doc.json")))
return &Server{cfg: cfg, db: db, mux: mux}, nil

View file

@ -22,6 +22,9 @@ type Repository interface {
CreateProduct(ctx context.Context, product *domain.Product) error
ListProducts(ctx context.Context) ([]domain.Product, error)
GetProduct(ctx context.Context, id uuid.UUID) (*domain.Product, error)
AdjustInventory(ctx context.Context, productID uuid.UUID, delta int64, reason string) (*domain.InventoryItem, error)
ListInventory(ctx context.Context, filter domain.InventoryFilter) ([]domain.InventoryItem, error)
CreateOrder(ctx context.Context, order *domain.Order) error
GetOrder(ctx context.Context, id uuid.UUID) (*domain.Order, error)
@ -33,6 +36,10 @@ type Repository interface {
GetUserByEmail(ctx context.Context, email string) (*domain.User, error)
UpdateUser(ctx context.Context, user *domain.User) error
DeleteUser(ctx context.Context, id uuid.UUID) error
AddCartItem(ctx context.Context, item *domain.CartItem) (*domain.CartItem, error)
ListCartItems(ctx context.Context, buyerID uuid.UUID) ([]domain.CartItem, error)
DeleteCartItem(ctx context.Context, id uuid.UUID, buyerID uuid.UUID) error
}
// PaymentGateway abstracts Mercado Pago integration.
@ -74,6 +81,14 @@ func (s *Service) ListProducts(ctx context.Context) ([]domain.Product, error) {
return s.repo.ListProducts(ctx)
}
func (s *Service) ListInventory(ctx context.Context, filter domain.InventoryFilter) ([]domain.InventoryItem, error) {
return s.repo.ListInventory(ctx, filter)
}
func (s *Service) AdjustInventory(ctx context.Context, productID uuid.UUID, delta int64, reason string) (*domain.InventoryItem, error) {
return s.repo.AdjustInventory(ctx, productID, delta, reason)
}
func (s *Service) CreateOrder(ctx context.Context, order *domain.Order) error {
order.ID = uuid.Must(uuid.NewV7())
order.Status = domain.OrderStatusPending
@ -147,6 +162,86 @@ func (s *Service) DeleteUser(ctx context.Context, id uuid.UUID) error {
return s.repo.DeleteUser(ctx, id)
}
// AddItemToCart validates stock, persists the item and returns the refreshed summary.
func (s *Service) AddItemToCart(ctx context.Context, buyerID, productID uuid.UUID, quantity int64) (*domain.CartSummary, error) {
if quantity <= 0 {
return nil, errors.New("quantity must be greater than zero")
}
product, err := s.repo.GetProduct(ctx, productID)
if err != nil {
return nil, err
}
cartItems, err := s.repo.ListCartItems(ctx, buyerID)
if err != nil {
return nil, err
}
var currentQty int64
for _, it := range cartItems {
if it.ProductID == productID {
currentQty += it.Quantity
}
}
if product.Stock < currentQty+quantity {
return nil, errors.New("insufficient stock for requested quantity")
}
_, err = s.repo.AddCartItem(ctx, &domain.CartItem{
ID: uuid.Must(uuid.NewV7()),
BuyerID: buyerID,
ProductID: productID,
Quantity: quantity,
UnitCents: product.PriceCents,
Batch: product.Batch,
ExpiresAt: product.ExpiresAt,
})
if err != nil {
return nil, err
}
return s.cartSummary(ctx, buyerID)
}
// ListCart returns the current cart with B2B discounts applied.
func (s *Service) ListCart(ctx context.Context, buyerID uuid.UUID) (*domain.CartSummary, error) {
return s.cartSummary(ctx, buyerID)
}
// RemoveCartItem deletes a cart row and returns the refreshed cart summary.
func (s *Service) RemoveCartItem(ctx context.Context, buyerID, cartItemID uuid.UUID) (*domain.CartSummary, error) {
if err := s.repo.DeleteCartItem(ctx, cartItemID, buyerID); err != nil {
return nil, err
}
return s.cartSummary(ctx, buyerID)
}
func (s *Service) cartSummary(ctx context.Context, buyerID uuid.UUID) (*domain.CartSummary, error) {
items, err := s.repo.ListCartItems(ctx, buyerID)
if err != nil {
return nil, err
}
var subtotal int64
for i := range items {
subtotal += items[i].UnitCents * items[i].Quantity
}
summary := &domain.CartSummary{
Items: items,
SubtotalCents: subtotal,
}
if subtotal >= 100000 { // apply 5% B2B discount for large baskets
summary.DiscountCents = int64(float64(subtotal) * 0.05)
summary.DiscountReason = "5% B2B volume discount"
}
summary.TotalCents = subtotal - summary.DiscountCents
return summary, nil
}
// RegisterAccount creates a company when needed and persists a user bound to it.
func (s *Service) RegisterAccount(ctx context.Context, company *domain.Company, user *domain.User, password string) error {
if company != nil {