first
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
package orders
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"backend/internal/modules/pricing"
|
||||
"backend/internal/modules/products"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
service *Service
|
||||
}
|
||||
|
||||
func NewHandler(service *Service) *Handler {
|
||||
return &Handler{service: service}
|
||||
}
|
||||
|
||||
type itemRequest struct {
|
||||
ProductID uuid.UUID `json:"product_id" binding:"required"`
|
||||
PriceTierID uuid.UUID `json:"price_tier_id" binding:"required"`
|
||||
Multiplier int64 `json:"multiplier" binding:"required,gte=1"`
|
||||
}
|
||||
|
||||
type createRequest struct {
|
||||
CustomerName string `json:"customer_name" binding:"required"`
|
||||
CustomerEmail string `json:"customer_email" binding:"required,email"`
|
||||
CustomerPhone string `json:"customer_phone"`
|
||||
Notes string `json:"notes"`
|
||||
Items []itemRequest `json:"items" binding:"required,min=1,dive"`
|
||||
}
|
||||
|
||||
type orderItemResponse struct {
|
||||
ProductID *uuid.UUID `json:"product_id,omitempty"`
|
||||
ProductName string `json:"product_name"`
|
||||
UnitSymbol string `json:"unit_symbol"`
|
||||
TierQuantity float64 `json:"tier_quantity"`
|
||||
Multiplier int64 `json:"multiplier"`
|
||||
UnitPriceCents int64 `json:"unit_price_cents"`
|
||||
TotalCents int64 `json:"total_cents"`
|
||||
}
|
||||
|
||||
type orderResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
CustomerName string `json:"customer_name"`
|
||||
CustomerEmail string `json:"customer_email"`
|
||||
CustomerPhone string `json:"customer_phone"`
|
||||
Status string `json:"status"`
|
||||
TotalCents int64 `json:"total_cents"`
|
||||
Notes string `json:"notes"`
|
||||
Items []orderItemResponse `json:"items,omitempty"`
|
||||
}
|
||||
|
||||
func toResponse(order *Order, items []*OrderItem) orderResponse {
|
||||
resp := orderResponse{
|
||||
ID: order.ID,
|
||||
CustomerName: order.CustomerName,
|
||||
CustomerEmail: order.CustomerEmail,
|
||||
CustomerPhone: order.CustomerPhone,
|
||||
Status: order.Status,
|
||||
TotalCents: order.TotalCents,
|
||||
Notes: order.Notes,
|
||||
}
|
||||
for _, item := range items {
|
||||
resp.Items = append(resp.Items, orderItemResponse{
|
||||
ProductID: item.ProductID,
|
||||
ProductName: item.ProductName,
|
||||
UnitSymbol: item.UnitSymbol,
|
||||
TierQuantity: item.TierQuantity,
|
||||
Multiplier: item.Multiplier,
|
||||
UnitPriceCents: item.UnitPriceCents,
|
||||
TotalCents: item.TotalCents,
|
||||
})
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// Create is public: a visitor (connected or not, per spec section 17) can
|
||||
// place an order without an account.
|
||||
func (h *Handler) Create(c *gin.Context) {
|
||||
var req createRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "validation error", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
items := make([]ItemInput, 0, len(req.Items))
|
||||
for _, item := range req.Items {
|
||||
items = append(items, ItemInput{
|
||||
ProductID: item.ProductID,
|
||||
PriceTierID: item.PriceTierID,
|
||||
Multiplier: item.Multiplier,
|
||||
})
|
||||
}
|
||||
|
||||
order, orderItems, err := h.service.Create(c.Request.Context(), CreateInput{
|
||||
CustomerName: req.CustomerName,
|
||||
CustomerEmail: req.CustomerEmail,
|
||||
CustomerPhone: req.CustomerPhone,
|
||||
Notes: req.Notes,
|
||||
Items: items,
|
||||
})
|
||||
if err != nil {
|
||||
h.respondCreateError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, toResponse(order, orderItems))
|
||||
}
|
||||
|
||||
func (h *Handler) respondCreateError(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, ErrEmptyOrder), errors.Is(err, ErrProductMismatch):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
case errors.Is(err, pricing.ErrNotFound):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "unknown price tier"})
|
||||
case errors.Is(err, products.ErrNotFound):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "unknown product"})
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create order"})
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) ListAdmin(c *gin.Context) {
|
||||
list, err := h.service.List(c.Request.Context(), c.Query("status"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list orders"})
|
||||
return
|
||||
}
|
||||
resp := make([]orderResponse, 0, len(list))
|
||||
for _, order := range list {
|
||||
resp = append(resp, toResponse(order, nil))
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"orders": resp})
|
||||
}
|
||||
|
||||
func (h *Handler) GetAdmin(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
order, items, err := h.service.Get(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "order not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load order"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, toResponse(order, items))
|
||||
}
|
||||
|
||||
type updateStatusRequest struct {
|
||||
Status string `json:"status" binding:"required"`
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateStatus(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
var req updateStatusRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "validation error", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
order, err := h.service.UpdateStatus(c.Request.Context(), id, req.Status)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, ErrNotFound):
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "order not found"})
|
||||
case errors.Is(err, ErrInvalidStatus):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid status"})
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update order status"})
|
||||
}
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, toResponse(order, nil))
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// Package orders lets a customer (connected or not, per spec section 17-19)
|
||||
// place an order directly from the storefront, and lets the admin track
|
||||
// and update it. Pricing is never trusted from the client: each line is
|
||||
// resolved against the pricing module's authoritative price tier at
|
||||
// creation time.
|
||||
package orders
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
StatusPending = "pending"
|
||||
StatusConfirmed = "confirmed"
|
||||
StatusPreparing = "preparing"
|
||||
StatusShipped = "shipped"
|
||||
StatusCompleted = "completed"
|
||||
StatusCancelled = "cancelled"
|
||||
)
|
||||
|
||||
var ValidStatuses = map[string]bool{
|
||||
StatusPending: true,
|
||||
StatusConfirmed: true,
|
||||
StatusPreparing: true,
|
||||
StatusShipped: true,
|
||||
StatusCompleted: true,
|
||||
StatusCancelled: true,
|
||||
}
|
||||
|
||||
type Order struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primaryKey"`
|
||||
CustomerName string `gorm:"not null"`
|
||||
CustomerEmail string `gorm:"not null"`
|
||||
CustomerPhone string `gorm:"not null;default:''"`
|
||||
Status string `gorm:"not null;default:pending"`
|
||||
TotalCents int64 `gorm:"not null;default:0"`
|
||||
Notes string `gorm:"not null;default:''"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (Order) TableName() string { return "orders" }
|
||||
|
||||
// OrderItem snapshots the product name, unit symbol and unit price at order
|
||||
// time, so the order stays accurate even if the product/unit is later
|
||||
// renamed or deleted.
|
||||
type OrderItem struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primaryKey"`
|
||||
OrderID uuid.UUID `gorm:"type:uuid;not null;index"`
|
||||
ProductID *uuid.UUID `gorm:"type:uuid"`
|
||||
ProductName string `gorm:"not null"`
|
||||
PriceTierID *uuid.UUID `gorm:"type:uuid"`
|
||||
UnitSymbol string `gorm:"not null"`
|
||||
TierQuantity float64 `gorm:"not null"`
|
||||
Multiplier int64 `gorm:"not null;default:1"`
|
||||
UnitPriceCents int64 `gorm:"not null"`
|
||||
TotalCents int64 `gorm:"not null"`
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func (OrderItem) TableName() string { return "order_items" }
|
||||
@@ -0,0 +1,85 @@
|
||||
package orders
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("order not found")
|
||||
|
||||
type Repository interface {
|
||||
Create(ctx context.Context, order *Order, items []*OrderItem) error
|
||||
FindByID(ctx context.Context, id uuid.UUID) (*Order, []*OrderItem, error)
|
||||
List(ctx context.Context, status string) ([]*Order, error)
|
||||
UpdateStatus(ctx context.Context, id uuid.UUID, status string) (*Order, error)
|
||||
}
|
||||
|
||||
type gormRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewRepository(db *gorm.DB) Repository {
|
||||
return &gormRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *gormRepository) Create(ctx context.Context, order *Order, items []*OrderItem) error {
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(order).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, item := range items {
|
||||
item.OrderID = order.ID
|
||||
}
|
||||
if len(items) > 0 {
|
||||
if err := tx.Create(&items).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (r *gormRepository) FindByID(ctx context.Context, id uuid.UUID) (*Order, []*OrderItem, error) {
|
||||
var order Order
|
||||
if err := r.db.WithContext(ctx).Where("id = ?", id).First(&order).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil, ErrNotFound
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
var items []*OrderItem
|
||||
if err := r.db.WithContext(ctx).Where("order_id = ?", id).Order("created_at asc").Find(&items).Error; err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return &order, items, nil
|
||||
}
|
||||
|
||||
func (r *gormRepository) List(ctx context.Context, status string) ([]*Order, error) {
|
||||
q := r.db.WithContext(ctx).Order("created_at desc")
|
||||
if status != "" {
|
||||
q = q.Where("status = ?", status)
|
||||
}
|
||||
var list []*Order
|
||||
if err := q.Find(&list).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func (r *gormRepository) UpdateStatus(ctx context.Context, id uuid.UUID, status string) (*Order, error) {
|
||||
res := r.db.WithContext(ctx).Model(&Order{}).Where("id = ?", id).Update("status", status)
|
||||
if res.Error != nil {
|
||||
return nil, res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
var order Order
|
||||
if err := r.db.WithContext(ctx).Where("id = ?", id).First(&order).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &order, nil
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package orders
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
// RegisterPublicRoutes exposes order creation to any visitor, connected or
|
||||
// not (spec section 17-18). rateLimit throttles it to blunt spam/abuse
|
||||
// since no authentication gates this endpoint.
|
||||
func RegisterPublicRoutes(rg *gin.RouterGroup, h *Handler, rateLimit gin.HandlerFunc) {
|
||||
rg.POST("/orders", rateLimit, h.Create)
|
||||
}
|
||||
|
||||
func RegisterAdminRoutes(rg *gin.RouterGroup, h *Handler, requireAdmin gin.HandlerFunc) {
|
||||
group := rg.Group("/admin/orders", requireAdmin)
|
||||
group.GET("", h.ListAdmin)
|
||||
group.GET("/:id", h.GetAdmin)
|
||||
group.PATCH("/:id/status", h.UpdateStatus)
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package orders
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"backend/internal/modules/pricing"
|
||||
"backend/internal/modules/products"
|
||||
"backend/internal/modules/units"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrEmptyOrder = errors.New("order must contain at least one item")
|
||||
ErrInvalidStatus = errors.New("invalid order status")
|
||||
ErrProductMismatch = errors.New("price tier does not belong to the requested product")
|
||||
)
|
||||
|
||||
// The dependencies below are the minimal slices of other modules' services
|
||||
// this module needs, defined on the consumer side (Go idiom) so orders
|
||||
// never has to import their concrete handler/repository types.
|
||||
type ProductFinder interface {
|
||||
Get(ctx context.Context, id uuid.UUID) (*products.Product, error)
|
||||
}
|
||||
|
||||
type UnitFinder interface {
|
||||
Get(ctx context.Context, id uuid.UUID) (*units.Unit, error)
|
||||
}
|
||||
|
||||
type PriceResolver interface {
|
||||
PriceForQuantity(ctx context.Context, tierID uuid.UUID, multiplier int64) (int64, *pricing.PriceTier, error)
|
||||
}
|
||||
|
||||
// OrderNotifier is satisfied by the telegram module's Service (and, later,
|
||||
// any other notification channel) without orders ever depending on it
|
||||
// directly.
|
||||
type OrderNotifier interface {
|
||||
NotifyNewOrder(ctx context.Context, summary string)
|
||||
NotifyOrderStatusChange(ctx context.Context, summary string)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repo Repository
|
||||
products ProductFinder
|
||||
units UnitFinder
|
||||
prices PriceResolver
|
||||
notifier OrderNotifier
|
||||
}
|
||||
|
||||
func NewService(repo Repository, products ProductFinder, units UnitFinder, prices PriceResolver, notifier OrderNotifier) *Service {
|
||||
return &Service{repo: repo, products: products, units: units, prices: prices, notifier: notifier}
|
||||
}
|
||||
|
||||
type ItemInput struct {
|
||||
ProductID uuid.UUID
|
||||
PriceTierID uuid.UUID
|
||||
Multiplier int64
|
||||
}
|
||||
|
||||
type CreateInput struct {
|
||||
CustomerName string
|
||||
CustomerEmail string
|
||||
CustomerPhone string
|
||||
Notes string
|
||||
Items []ItemInput
|
||||
}
|
||||
|
||||
func (s *Service) Create(ctx context.Context, in CreateInput) (*Order, []*OrderItem, error) {
|
||||
if len(in.Items) == 0 {
|
||||
return nil, nil, ErrEmptyOrder
|
||||
}
|
||||
|
||||
order := &Order{
|
||||
ID: uuid.New(),
|
||||
CustomerName: in.CustomerName,
|
||||
CustomerEmail: in.CustomerEmail,
|
||||
CustomerPhone: in.CustomerPhone,
|
||||
Status: StatusPending,
|
||||
Notes: in.Notes,
|
||||
}
|
||||
|
||||
items := make([]*OrderItem, 0, len(in.Items))
|
||||
var total int64
|
||||
|
||||
for _, in := range in.Items {
|
||||
multiplier := in.Multiplier
|
||||
if multiplier < 1 {
|
||||
multiplier = 1
|
||||
}
|
||||
|
||||
lineTotal, tier, err := s.prices.PriceForQuantity(ctx, in.PriceTierID, multiplier)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("resolve price tier %s: %w", in.PriceTierID, err)
|
||||
}
|
||||
if tier.ProductID != in.ProductID {
|
||||
return nil, nil, ErrProductMismatch
|
||||
}
|
||||
|
||||
product, err := s.products.Get(ctx, in.ProductID)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("resolve product %s: %w", in.ProductID, err)
|
||||
}
|
||||
unit, err := s.units.Get(ctx, tier.UnitID)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("resolve unit %s: %w", tier.UnitID, err)
|
||||
}
|
||||
|
||||
items = append(items, &OrderItem{
|
||||
ID: uuid.New(),
|
||||
ProductID: &product.ID,
|
||||
ProductName: product.Name,
|
||||
PriceTierID: &tier.ID,
|
||||
UnitSymbol: unit.Symbol,
|
||||
TierQuantity: tier.Quantity,
|
||||
Multiplier: multiplier,
|
||||
UnitPriceCents: tier.PriceCents,
|
||||
TotalCents: lineTotal,
|
||||
})
|
||||
total += lineTotal
|
||||
}
|
||||
|
||||
order.TotalCents = total
|
||||
|
||||
if err := s.repo.Create(ctx, order, items); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
s.notifier.NotifyNewOrder(context.Background(), summarizeNewOrder(order, items))
|
||||
|
||||
return order, items, nil
|
||||
}
|
||||
|
||||
func (s *Service) Get(ctx context.Context, id uuid.UUID) (*Order, []*OrderItem, error) {
|
||||
return s.repo.FindByID(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context, status string) ([]*Order, error) {
|
||||
return s.repo.List(ctx, status)
|
||||
}
|
||||
|
||||
func (s *Service) UpdateStatus(ctx context.Context, id uuid.UUID, status string) (*Order, error) {
|
||||
if !ValidStatuses[status] {
|
||||
return nil, ErrInvalidStatus
|
||||
}
|
||||
order, err := s.repo.UpdateStatus(ctx, id, status)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.notifier.NotifyOrderStatusChange(context.Background(), fmt.Sprintf(
|
||||
"Order %s status changed to %q (customer: %s)", order.ID, order.Status, order.CustomerName,
|
||||
))
|
||||
return order, nil
|
||||
}
|
||||
|
||||
func summarizeNewOrder(order *Order, items []*OrderItem) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "New order from %s (%s)\n", order.CustomerName, order.CustomerEmail)
|
||||
for _, item := range items {
|
||||
fmt.Fprintf(&b, "- %dx %s (%.2f %s) = %.2f\n",
|
||||
item.Multiplier, item.ProductName, item.TierQuantity, item.UnitSymbol, centsToUnits(item.TotalCents))
|
||||
}
|
||||
fmt.Fprintf(&b, "Total: %.2f", centsToUnits(order.TotalCents))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func centsToUnits(cents int64) float64 {
|
||||
return float64(cents) / 100
|
||||
}
|
||||
Reference in New Issue
Block a user