From 612faa0aa79506015996d364c2bec67da7144c33 Mon Sep 17 00:00:00 2001 From: anxi0uz Date: Mon, 30 Mar 2026 20:37:35 +0500 Subject: [PATCH] =?UTF-8?q?=D0=9B=D0=BE=D0=B3=D0=B8=D0=BA=D0=B0=20=D0=B8?= =?UTF-8?q?=D0=B7=20CreateOrder=20=D1=80=D1=83=D1=87=D0=BA=D0=B8=20=D0=B1?= =?UTF-8?q?=D1=8B=D0=BB=D0=B0=20=D0=B2=D1=8B=D0=BD=D0=B5=D1=81=D0=B5=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=B2=20OrderService.CreateOrder,=20=D1=82=D0=B0?= =?UTF-8?q?=D0=BA=20=D0=B6=D0=B5=20Geocode=20=D0=B2=D1=8B=D0=BD=D0=B5?= =?UTF-8?q?=D1=81=D0=B5=D0=BD=20=D0=B2=20pkg,=20=D0=B0=20=D0=B5=D1=89?= =?UTF-8?q?=D1=91=20=D1=81=D0=BB=D0=B5=D0=B3=D0=BA=D0=B0=20=D0=BF=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=B0=D0=BD=D0=B0=20?= =?UTF-8?q?=D0=BB=D0=BE=D0=B3=D0=B8=D0=BA=D0=B0=20=D0=BF=D0=BE=D0=B8=D1=81?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=BA=D0=BE=D0=BE=D1=80=D0=B4=D0=B8=D0=BD=D0=B0?= =?UTF-8?q?=D1=82=20=D1=81=D0=BA=D0=BB=D0=B0=D0=B4=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/api/api.swagger.yaml | 4 + internal/api/gen.go | 13 +-- internal/handler/order.go | 128 +------------------------ internal/handler/server_impl.go | 58 +++-------- internal/handler/warehouse.go | 3 +- internal/services/order.go | 164 ++++++++++++++++++++++++++++++++ pkg/geocode/geocode.go | 54 +++++++++++ 7 files changed, 248 insertions(+), 176 deletions(-) create mode 100644 internal/services/order.go create mode 100644 pkg/geocode/geocode.go diff --git a/internal/api/api.swagger.yaml b/internal/api/api.swagger.yaml index 633f8cd..30bb4a1 100644 --- a/internal/api/api.swagger.yaml +++ b/internal/api/api.swagger.yaml @@ -474,6 +474,10 @@ components: volumeM3: type: number nullable: true + destinationWarehouseId: + type: string + format: uuid + nullable: true OrderStatusUpdate: type: object diff --git a/internal/api/gen.go b/internal/api/gen.go index bf8d551..7138ee1 100644 --- a/internal/api/gen.go +++ b/internal/api/gen.go @@ -252,12 +252,13 @@ type ManagerCreate struct { // OrderCreate defines model for OrderCreate. type OrderCreate struct { - CargoDescription *string `json:"cargoDescription,omitempty"` - DestinationAddress string `json:"destinationAddress"` - OriginAddress *string `json:"originAddress,omitempty"` - OriginWarehouseId *openapi_types.UUID `json:"originWarehouseId,omitempty"` - VolumeM3 *float32 `json:"volumeM3,omitempty"` - WeightKg *float32 `json:"weightKg,omitempty"` + CargoDescription *string `json:"cargoDescription,omitempty"` + DestinationAddress string `json:"destinationAddress"` + DestinationWarehouseId *openapi_types.UUID `json:"destinationWarehouseId,omitempty"` + OriginAddress *string `json:"originAddress,omitempty"` + OriginWarehouseId *openapi_types.UUID `json:"originWarehouseId,omitempty"` + VolumeM3 *float32 `json:"volumeM3,omitempty"` + WeightKg *float32 `json:"weightKg,omitempty"` } // OrderStatusUpdate defines model for OrderStatusUpdate. diff --git a/internal/handler/order.go b/internal/handler/order.go index 6317cf5..c1ace3c 100644 --- a/internal/handler/order.go +++ b/internal/handler/order.go @@ -2,17 +2,11 @@ package handler import ( "encoding/json" - "fmt" "log/slog" "net/http" - "time" "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" - "golang.org/x/sync/errgroup" ) 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 } - var ( - 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) + result, err := s.OrderSerice.CreateOrder(ctx, req, claims.ID) if err != nil { - slog.ErrorContext(ctx, "OSRM request 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 { + slog.ErrorContext(ctx, "create order failed", slog.String("error", err.Error())) s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError) return } s.JSON(w, r, http.StatusCreated, map[string]any{ - "order": order, - "route": routeModel, + "order": result.Order, + "route": result.Route, }, "order") } diff --git a/internal/handler/server_impl.go b/internal/handler/server_impl.go index 17c8ba6..e29ae10 100644 --- a/internal/handler/server_impl.go +++ b/internal/handler/server_impl.go @@ -7,12 +7,11 @@ import ( "fmt" "log/slog" "net/http" - neturl "net/url" - "strconv" "time" "github.com/anxi0uz/logiflow/internal/api" "github.com/anxi0uz/logiflow/internal/config" + "github.com/anxi0uz/logiflow/internal/services" "github.com/go-chi/chi/v5" "github.com/go-chi/cors" "github.com/golang-jwt/jwt/v5" @@ -49,20 +48,22 @@ type responseOptions struct { } type Server struct { - DB *pgxpool.Pool - Config *config.Config - ctx context.Context - Redis *redis.Client - JwtKey []byte + DB *pgxpool.Pool + Config *config.Config + ctx context.Context + Redis *redis.Client + JwtKey []byte + OrderSerice services.OrderService } func NewServer(db *pgxpool.Pool, redis *redis.Client, cfg *config.Config) *Server { return &Server{ - DB: db, - Redis: redis, - ctx: context.Background(), - Config: cfg, - JwtKey: []byte(cfg.JwtOpt.Key), + DB: db, + Redis: redis, + ctx: context.Background(), + Config: cfg, + 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 } -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 -} diff --git a/internal/handler/warehouse.go b/internal/handler/warehouse.go index da92b96..ff909a2 100644 --- a/internal/handler/warehouse.go +++ b/internal/handler/warehouse.go @@ -11,6 +11,7 @@ import ( "github.com/anxi0uz/logiflow/internal/api" "github.com/anxi0uz/logiflow/internal/models" storage "github.com/anxi0uz/logiflow/pkg" + "github.com/anxi0uz/logiflow/pkg/geocode" "github.com/google/uuid" "github.com/gosimple/slug" "github.com/huandu/go-sqlbuilder" @@ -38,7 +39,7 @@ func (s *Server) CreateWarehouse(w http.ResponseWriter, r *http.Request) { return } 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 { slog.ErrorContext(ctx, "Failed to geocode address", slog.String("address", req.Address), slog.String("error", err.Error())) s.JSON(w, r, http.StatusBadRequest, "Не удалось определить координаты по адресу", RespError) diff --git a/internal/services/order.go b/internal/services/order.go new file mode 100644 index 0000000..caa3f4e --- /dev/null +++ b/internal/services/order.go @@ -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 +} diff --git a/pkg/geocode/geocode.go b/pkg/geocode/geocode.go new file mode 100644 index 0000000..4194019 --- /dev/null +++ b/pkg/geocode/geocode.go @@ -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 +}