Merge pull request #13 from anxi0uz/feature/back-13

Логика из CreateOrder ручки была вынесена в OrderService.CreateOrder,…
This commit is contained in:
2026-03-30 20:38:27 +05:00
committed by GitHub
7 changed files with 248 additions and 176 deletions

View File

@@ -474,6 +474,10 @@ components:
volumeM3: volumeM3:
type: number type: number
nullable: true nullable: true
destinationWarehouseId:
type: string
format: uuid
nullable: true
OrderStatusUpdate: OrderStatusUpdate:
type: object type: object

View File

@@ -254,6 +254,7 @@ type ManagerCreate struct {
type OrderCreate struct { type OrderCreate struct {
CargoDescription *string `json:"cargoDescription,omitempty"` CargoDescription *string `json:"cargoDescription,omitempty"`
DestinationAddress string `json:"destinationAddress"` DestinationAddress string `json:"destinationAddress"`
DestinationWarehouseId *openapi_types.UUID `json:"destinationWarehouseId,omitempty"`
OriginAddress *string `json:"originAddress,omitempty"` OriginAddress *string `json:"originAddress,omitempty"`
OriginWarehouseId *openapi_types.UUID `json:"originWarehouseId,omitempty"` OriginWarehouseId *openapi_types.UUID `json:"originWarehouseId,omitempty"`
VolumeM3 *float32 `json:"volumeM3,omitempty"` VolumeM3 *float32 `json:"volumeM3,omitempty"`

View File

@@ -2,17 +2,11 @@ package handler
import ( import (
"encoding/json" "encoding/json"
"fmt"
"log/slog" "log/slog"
"net/http" "net/http"
"time"
"github.com/anxi0uz/logiflow/internal/api" "github.com/anxi0uz/logiflow/internal/api"
"github.com/anxi0uz/logiflow/internal/models"
storage "github.com/anxi0uz/logiflow/pkg"
"github.com/google/uuid"
openapi_types "github.com/oapi-codegen/runtime/types" openapi_types "github.com/oapi-codegen/runtime/types"
"golang.org/x/sync/errgroup"
) )
func (s *Server) ListOrders(w http.ResponseWriter, r *http.Request, params api.ListOrdersParams) {} func (s *Server) ListOrders(w http.ResponseWriter, r *http.Request, params api.ListOrdersParams) {}
@@ -32,130 +26,16 @@ func (s *Server) CreateOrder(w http.ResponseWriter, r *http.Request) {
return return
} }
var ( result, err := s.OrderSerice.CreateOrder(ctx, req, claims.ID)
originLat, originLon float64
destLat, destLon float64
)
g, gctx := errgroup.WithContext(ctx)
g.Go(func() error {
var err error
originLat, originLon, err = s.geocode(gctx, *req.OriginAddress)
return err
})
g.Go(func() error {
var err error
destLat, destLon, err = s.geocode(gctx, req.DestinationAddress)
return err
})
if err := g.Wait(); err != nil {
s.JSON(w, r, http.StatusBadRequest, "Не удалось определить координаты", RespError)
return
}
osrmURL := fmt.Sprintf(
"http://router.project-osrm.org/route/v1/driving/%f,%f;%f,%f?overview=full&geometries=geojson",
originLon, originLat, destLon, destLat,
)
osrmReq, _ := http.NewRequestWithContext(ctx, http.MethodGet, osrmURL, nil)
osrmReq.Header.Set("User-Agent", "logiflow/1.0")
osrmResp, err := http.DefaultClient.Do(osrmReq)
if err != nil { if err != nil {
slog.ErrorContext(ctx, "OSRM request failed", slog.String("error", err.Error())) slog.ErrorContext(ctx, "create order failed", slog.String("error", err.Error()))
s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError)
return
}
defer osrmResp.Body.Close()
var osrmResult struct {
Routes []struct {
Geometry struct {
Coordinates [][]float64 `json:"coordinates"`
} `json:"geometry"`
Distance float64 `json:"distance"`
Duration float64 `json:"duration"`
} `json:"routes"`
}
if err := json.NewDecoder(osrmResp.Body).Decode(&osrmResult); err != nil || len(osrmResult.Routes) == 0 {
s.JSON(w, r, http.StatusBadRequest, "Не удалось построить маршрут", RespError)
return
}
route := osrmResult.Routes[0]
distanceKm := route.Distance / 1000
var weightKg, volumeM3 float64
if req.WeightKg != nil {
weightKg = float64(*req.WeightKg)
}
if req.VolumeM3 != nil {
volumeM3 = float64(*req.VolumeM3)
}
price := s.Config.Pricing.BaseFee + distanceKm*s.Config.Pricing.PerKm + weightKg*s.Config.Pricing.PerKg + volumeM3*s.Config.Pricing.PerM3
now := time.Now
orderID := uuid.New()
order := models.Order{
ID: orderID,
CreatedByID: &claims.ID,
DestinationAddress: req.DestinationAddress,
Status: "pending",
TotalPrice: price,
CreatedAt: now(),
}
if *req.OriginAddress != "" {
order.OriginAddress = *req.OriginAddress
}
if req.CargoDescription != nil {
order.CargoDescription = *req.CargoDescription
}
if req.WeightKg != nil {
order.WeightKg = float64(*req.WeightKg)
}
if req.VolumeM3 != nil {
order.VolumeM3 = volumeM3
}
coordsJSON, err := json.Marshal(route.Geometry.Coordinates)
if err != nil {
s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError)
return
}
routeModel := models.Route{
ID: uuid.New(),
OrderID: orderID,
Coordinates: coordsJSON,
DurationSec: int(route.Duration),
Status: "pending",
}
tx, err := s.DB.Begin(ctx)
if err != nil {
s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError)
return
}
defer tx.Rollback(ctx)
if err := storage.Create(ctx, "orders", order, tx); err != nil {
slog.ErrorContext(ctx, "Failed to create order", slog.String("error", err.Error()))
s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError)
return
}
if err := storage.Create(ctx, "routes", routeModel, tx); err != nil {
slog.ErrorContext(ctx, "Failed to create route", slog.String("error", err.Error()))
s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError)
return
}
if err := tx.Commit(ctx); err != nil {
s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError) s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError)
return return
} }
s.JSON(w, r, http.StatusCreated, map[string]any{ s.JSON(w, r, http.StatusCreated, map[string]any{
"order": order, "order": result.Order,
"route": routeModel, "route": result.Route,
}, "order") }, "order")
} }

View File

@@ -7,12 +7,11 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"net/http" "net/http"
neturl "net/url"
"strconv"
"time" "time"
"github.com/anxi0uz/logiflow/internal/api" "github.com/anxi0uz/logiflow/internal/api"
"github.com/anxi0uz/logiflow/internal/config" "github.com/anxi0uz/logiflow/internal/config"
"github.com/anxi0uz/logiflow/internal/services"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/cors" "github.com/go-chi/cors"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
@@ -54,6 +53,7 @@ type Server struct {
ctx context.Context ctx context.Context
Redis *redis.Client Redis *redis.Client
JwtKey []byte JwtKey []byte
OrderSerice services.OrderService
} }
func NewServer(db *pgxpool.Pool, redis *redis.Client, cfg *config.Config) *Server { func NewServer(db *pgxpool.Pool, redis *redis.Client, cfg *config.Config) *Server {
@@ -63,6 +63,7 @@ func NewServer(db *pgxpool.Pool, redis *redis.Client, cfg *config.Config) *Serve
ctx: context.Background(), ctx: context.Background(),
Config: cfg, Config: cfg,
JwtKey: []byte(cfg.JwtOpt.Key), JwtKey: []byte(cfg.JwtOpt.Key),
OrderSerice: *services.NewOrderService(db, *cfg),
} }
} }
@@ -236,36 +237,3 @@ func (s *Server) validateAccessToken(ctx context.Context, tokenStr string) (*Cla
return claims, nil return claims, nil
} }
func (s *Server) geocode(ctx context.Context, address string) (lat, lon float64, err error) {
url := fmt.Sprintf(
"https://nominatim.openstreetmap.org/search?q=%s&format=json&limit=1",
neturl.QueryEscape(address),
)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return 0, 0, err
}
req.Header.Set("User-Agent", "logiflow/1.0")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return 0, 0, err
}
defer resp.Body.Close()
var results []struct {
Lat string `json:"lat"`
Lon string `json:"lon"`
}
if err := json.NewDecoder(resp.Body).Decode(&results); err != nil {
return 0, 0, err
}
if len(results) == 0 {
return 0, 0, fmt.Errorf("address not found: %s", address)
}
lat, _ = strconv.ParseFloat(results[0].Lat, 64)
lon, _ = strconv.ParseFloat(results[0].Lon, 64)
return lat, lon, nil
}

View File

@@ -11,6 +11,7 @@ import (
"github.com/anxi0uz/logiflow/internal/api" "github.com/anxi0uz/logiflow/internal/api"
"github.com/anxi0uz/logiflow/internal/models" "github.com/anxi0uz/logiflow/internal/models"
storage "github.com/anxi0uz/logiflow/pkg" storage "github.com/anxi0uz/logiflow/pkg"
"github.com/anxi0uz/logiflow/pkg/geocode"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/gosimple/slug" "github.com/gosimple/slug"
"github.com/huandu/go-sqlbuilder" "github.com/huandu/go-sqlbuilder"
@@ -38,7 +39,7 @@ func (s *Server) CreateWarehouse(w http.ResponseWriter, r *http.Request) {
return return
} }
fullAddress := fmt.Sprintf("%s, %s", req.City, req.Address) fullAddress := fmt.Sprintf("%s, %s", req.City, req.Address)
lat, lon, err := s.geocode(ctx, fullAddress) lat, lon, err := geocode.Geocode(ctx, fullAddress)
if err != nil { if err != nil {
slog.ErrorContext(ctx, "Failed to geocode address", slog.String("address", req.Address), slog.String("error", err.Error())) slog.ErrorContext(ctx, "Failed to geocode address", slog.String("address", req.Address), slog.String("error", err.Error()))
s.JSON(w, r, http.StatusBadRequest, "Не удалось определить координаты по адресу", RespError) s.JSON(w, r, http.StatusBadRequest, "Не удалось определить координаты по адресу", RespError)

164
internal/services/order.go Normal file
View File

@@ -0,0 +1,164 @@
package services
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"time"
"github.com/anxi0uz/logiflow/internal/api"
"github.com/anxi0uz/logiflow/internal/config"
"github.com/anxi0uz/logiflow/internal/models"
storage "github.com/anxi0uz/logiflow/pkg"
"github.com/anxi0uz/logiflow/pkg/geocode"
"github.com/google/uuid"
"github.com/huandu/go-sqlbuilder"
"github.com/jackc/pgx/v5/pgxpool"
"golang.org/x/sync/errgroup"
)
type OrderService struct {
db *pgxpool.Pool
config config.Config
}
type CreateOrderResult struct {
Order models.Order
Route models.Route
}
func NewOrderService(db *pgxpool.Pool, cfg config.Config) *OrderService {
return &OrderService{db: db, config: cfg}
}
func (s *OrderService) CreateOrder(ctx context.Context, req api.OrderCreate, userID uuid.UUID) (*CreateOrderResult, error) {
var (
originLat, originLon float64
destLat, destLon float64
)
g, gctx := errgroup.WithContext(ctx)
g.Go(func() error {
if req.OriginWarehouseId != nil {
wh, err := storage.GetOne[models.Warehouse](gctx, s.db, "warehouses", func(sb *sqlbuilder.SelectBuilder) {
sb.Where(sb.Equal("id", req.OriginWarehouseId))
})
if err != nil {
return err
}
originLat = wh.Latitude
originLon = wh.Longitude
return nil
}
var err error
originLat, originLon, err = geocode.Geocode(gctx, *req.OriginAddress)
return err
})
g.Go(func() error {
if req.DestinationWarehouseId != nil {
wh, err := storage.GetOne[models.Warehouse](gctx, s.db, "warehouses", func(sb *sqlbuilder.SelectBuilder) {
sb.Where(sb.Equal("id", req.DestinationWarehouseId))
})
if err != nil {
return err
}
destLat = wh.Latitude
destLon = wh.Longitude
return nil
}
var err error
destLat, originLon, err = geocode.Geocode(ctx, req.DestinationAddress)
return err
})
if err := g.Wait(); err != nil {
slog.ErrorContext(ctx, "error while getting coordinates", slog.String("error", err.Error()))
return nil, fmt.Errorf("error while getting coordinates: %w", err)
}
osrmURL := fmt.Sprintf(
"http://router.project-osrm.org/route/v1/driving/%f,%f;%f,%f?overview=full&geometries=geojson",
originLon, originLat, destLon, destLat,
)
osrmReq, _ := http.NewRequestWithContext(ctx, http.MethodGet, osrmURL, nil)
osrmReq.Header.Set("User-Agent", "logiflow/1.0")
osrmResp, err := http.DefaultClient.Do(osrmReq)
if err != nil {
return nil, fmt.Errorf("osrm request: %w", err)
}
defer osrmResp.Body.Close()
var osrmResult geocode.OsrmResult
if err := json.NewDecoder(osrmResp.Body).Decode(&osrmResult); err != nil || len(osrmResult.Routes) == 0 {
return nil, fmt.Errorf("osrm: no route found")
}
route := osrmResult.Routes[0]
distanceKm := route.Distance / 1000
var weightKg, volumeM3 float64
if req.WeightKg != nil {
weightKg = float64(*req.WeightKg)
}
if req.VolumeM3 != nil {
volumeM3 = float64(*req.VolumeM3)
}
price := s.config.Pricing.BaseFee +
distanceKm*s.config.Pricing.PerKm +
weightKg*s.config.Pricing.PerKg +
volumeM3*s.config.Pricing.PerM3
orderID := uuid.New()
order := models.Order{
ID: orderID,
CreatedByID: &userID,
DestinationAddress: req.DestinationAddress,
Status: "pending",
TotalPrice: price,
CreatedAt: time.Now(),
}
if req.OriginAddress != nil && *req.OriginAddress != "" {
order.OriginAddress = *req.OriginAddress
}
if req.CargoDescription != nil {
order.CargoDescription = *req.CargoDescription
}
if req.WeightKg != nil {
order.WeightKg = weightKg
}
if req.VolumeM3 != nil {
order.VolumeM3 = volumeM3
}
coordsJSON, err := json.Marshal(route.Geometry.Coordinates)
if err != nil {
return nil, fmt.Errorf("marshal coordinates: %w", err)
}
routeModel := models.Route{
ID: uuid.New(),
OrderID: orderID,
Coordinates: coordsJSON,
DurationSec: int(route.Duration),
Status: "pending",
}
tx, err := s.db.Begin(ctx)
if err != nil {
return nil, fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback(ctx)
if err := storage.Create(ctx, "orders", order, tx); err != nil {
return nil, fmt.Errorf("create order: %w", err)
}
if err := storage.Create(ctx, "routes", routeModel, tx); err != nil {
return nil, fmt.Errorf("create route: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("commit: %w", err)
}
return &CreateOrderResult{Order: order, Route: routeModel}, nil
}

54
pkg/geocode/geocode.go Normal file
View File

@@ -0,0 +1,54 @@
package geocode
import (
"context"
"encoding/json"
"fmt"
"net/http"
neturl "net/url"
"strconv"
)
type OsrmResult struct {
Routes []struct {
Geometry struct {
Coordinates [][]float64 `json:"coordinates"`
} `json:"geometry"`
Distance float64 `json:"distance"`
Duration float64 `json:"duration"`
} `json:"routes"`
}
func Geocode(ctx context.Context, address string) (lat, lon float64, err error) {
url := fmt.Sprintf(
"https://nominatim.openstreetmap.org/search?q=%s&format=json&limit=1",
neturl.QueryEscape(address),
)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return 0, 0, err
}
req.Header.Set("User-Agent", "logiflow/1.0")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return 0, 0, err
}
defer resp.Body.Close()
var results []struct {
Lat string `json:"lat"`
Lon string `json:"lon"`
}
if err := json.NewDecoder(resp.Body).Decode(&results); err != nil {
return 0, 0, err
}
if len(results) == 0 {
return 0, 0, fmt.Errorf("address not found: %s", address)
}
lat, _ = strconv.ParseFloat(results[0].Lat, 64)
lon, _ = strconv.ParseFloat(results[0].Lon, 64)
return lat, lon, nil
}