From ea0d7104288ec971dbb4dd800c36fe0adf7af43b Mon Sep 17 00:00:00 2001 From: anxi0uz Date: Thu, 26 Mar 2026 21:37:52 +0500 Subject: [PATCH] geocoding added, but working so-so, need to fix some lines --- go.mod | 1 + go.sum | 2 + internal/api/api.swagger.yaml | 11 +-- internal/api/gen.go | 11 +-- internal/handler/order.go | 139 +++++++++++++++++++++++++++++++- internal/handler/server_impl.go | 51 ++++++++++-- internal/handler/warehouse.go | 101 +++++++++++++++++++++-- pkg/storage.go | 4 - 8 files changed, 287 insertions(+), 33 deletions(-) diff --git a/go.mod b/go.mod index c5d7d86..b3ee56c 100644 --- a/go.mod +++ b/go.mod @@ -28,6 +28,7 @@ require ( github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/doppiogancio/go-nominatim v2.0.1+incompatible // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/gosimple/unidecode v1.0.1 // indirect diff --git a/go.sum b/go.sum index 7aa4e93..387ca8f 100644 --- a/go.sum +++ b/go.sum @@ -13,6 +13,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/doppiogancio/go-nominatim v2.0.1+incompatible h1:S0PYXIVKwV6vF+JNBSO6T7BdvH4Ktuaf5HVL51i0lyQ= +github.com/doppiogancio/go-nominatim v2.0.1+incompatible/go.mod h1:lePiHgediF5zQ6qRyynsDoilUOBVFSpOpsWiDd21Dic= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= diff --git a/internal/api/api.swagger.yaml b/internal/api/api.swagger.yaml index c05ed64..633f8cd 100644 --- a/internal/api/api.swagger.yaml +++ b/internal/api/api.swagger.yaml @@ -258,21 +258,16 @@ components: WarehouseCreate: type: object - required: [name, address] + required: [name, address, city] properties: name: type: string address: type: string + description: Улица и номер дома city: type: string - nullable: true - latitude: - type: number - nullable: true - longitude: - type: number - nullable: true + description: Город — используется вместе с address для геокодинга WarehouseUpdate: type: object diff --git a/internal/api/gen.go b/internal/api/gen.go index c81fb9a..bf8d551 100644 --- a/internal/api/gen.go +++ b/internal/api/gen.go @@ -314,11 +314,12 @@ type VehicleUpdateStatus string // WarehouseCreate defines model for WarehouseCreate. type WarehouseCreate struct { - Address string `json:"address"` - City *string `json:"city,omitempty"` - Latitude *float32 `json:"latitude,omitempty"` - Longitude *float32 `json:"longitude,omitempty"` - Name string `json:"name"` + // Address Улица и номер дома + Address string `json:"address"` + + // City Город — используется вместе с address для геокодинга + City string `json:"city"` + Name string `json:"name"` } // WarehouseUpdate defines model for WarehouseUpdate. diff --git a/internal/handler/order.go b/internal/handler/order.go index 4c8c8e9..cf87963 100644 --- a/internal/handler/order.go +++ b/internal/handler/order.go @@ -1,15 +1,152 @@ 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" ) func (s *Server) ListOrders(w http.ResponseWriter, r *http.Request, params api.ListOrdersParams) {} -func (s *Server) CreateOrder(w http.ResponseWriter, r *http.Request) {} +func (s *Server) CreateOrder(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + claims, ok := ctx.Value("user").(*Claims) + if !ok { + slog.ErrorContext(ctx, "error while casting claims") + s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError) + return + } + + var req api.OrderCreate + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + s.JSON(w, r, http.StatusBadRequest, MsgInvalidBody, RespError) + return + } + + originLat, originLon, err := s.geocode(ctx, *req.OriginAddress) + if err != nil { + slog.ErrorContext(ctx, "Failed to geocode origin", slog.String("error", err.Error())) + s.JSON(w, r, http.StatusBadRequest, "Не удалось определить координаты адреса отправки", RespError) + return + } + destLat, destLon, err := s.geocode(ctx, req.DestinationAddress) + if err != nil { + slog.ErrorContext(ctx, "Failed to geocode destination", slog.String("error", err.Error())) + 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 { + 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 weightKm, volumeM3 float64 + if req.WeightKg != nil { + weightKm = float64(*req.WeightKg) + } + if req.VolumeM3 != nil { + volumeM3 = float64(*req.VolumeM3) + } + price := s.Config.Pricing.BaseFee + distanceKm*s.Config.Pricing.PerKg + weightKm*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) + return + } + + s.JSON(w, r, http.StatusCreated, map[string]any{ + "order": order, + "route": routeModel, + }, "order") +} func (s *Server) GetOrder(w http.ResponseWriter, r *http.Request, id openapi_types.UUID) {} diff --git a/internal/handler/server_impl.go b/internal/handler/server_impl.go index a4549ea..17c8ba6 100644 --- a/internal/handler/server_impl.go +++ b/internal/handler/server_impl.go @@ -7,6 +7,8 @@ import ( "fmt" "log/slog" "net/http" + neturl "net/url" + "strconv" "time" "github.com/anxi0uz/logiflow/internal/api" @@ -28,16 +30,16 @@ const ( tokenKey ctxKey = "Authorization" // response messages - MsgInternalError = "Internal server error" - MsgInvalidBody = "Invalid request body" - MsgNotFound = "Not found" - MsgUnauthorized = "Unauthorized" - MsgMissingToken = "Missing token" - MsgForbidden = "Forbidden" + MsgInternalError = "Internal server error" + MsgInvalidBody = "Invalid request body" + MsgNotFound = "Not found" + MsgUnauthorized = "Unauthorized" + MsgMissingToken = "Missing token" + MsgForbidden = "Forbidden" // response types - RespError = "error" - RespSuccess = "success" + RespError = "error" + RespSuccess = "success" RespNotFound = "not found" ) @@ -234,3 +236,36 @@ 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 35c6d03..da92b96 100644 --- a/internal/handler/warehouse.go +++ b/internal/handler/warehouse.go @@ -2,6 +2,8 @@ package handler import ( "encoding/json" + "errors" + "fmt" "log/slog" "net/http" "time" @@ -11,9 +13,20 @@ import ( storage "github.com/anxi0uz/logiflow/pkg" "github.com/google/uuid" "github.com/gosimple/slug" + "github.com/huandu/go-sqlbuilder" ) -func (s *Server) ListWarehouses(w http.ResponseWriter, r *http.Request) {} +func (s *Server) ListWarehouses(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + warehouses, err := storage.GetAll[models.Warehouse](ctx, "warehouses", s.DB) + if err != nil { + slog.ErrorContext(ctx, "Error while getting all warehouses", slog.String("error", err.Error())) + s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError) + return + } + s.JSON(w, r, http.StatusOK, warehouses, RespSuccess) +} func (s *Server) CreateWarehouse(w http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -24,16 +37,24 @@ func (s *Server) CreateWarehouse(w http.ResponseWriter, r *http.Request) { s.JSON(w, r, http.StatusBadRequest, MsgInvalidBody, RespError) return } + fullAddress := fmt.Sprintf("%s, %s", req.City, req.Address) + lat, lon, err := s.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) + return + } + now := time.Now() id := uuid.New() warehouse := models.Warehouse{ ID: id, Name: req.Name, Address: req.Address, - City: *req.City, + City: req.City, Slug: slug.Make(req.Name), - Latitude: float64(*req.Latitude), - Longitude: float64(*req.Longitude), + Latitude: lat, + Longitude: lon, CreatedAt: now, } if err := storage.Create(ctx, "warehouses", warehouse, s.DB); err != nil { @@ -44,8 +65,74 @@ func (s *Server) CreateWarehouse(w http.ResponseWriter, r *http.Request) { s.JSON(w, r, http.StatusCreated, warehouse, RespSuccess) } -func (s *Server) GetWarehouse(w http.ResponseWriter, r *http.Request, slug string) {} +func (s *Server) GetWarehouse(w http.ResponseWriter, r *http.Request, slug string) { + ctx := r.Context() -func (s *Server) UpdateWarehouse(w http.ResponseWriter, r *http.Request, slug string) {} + warehouse, err := storage.GetOne[models.Warehouse](ctx, s.DB, "warehouses", func(sb *sqlbuilder.SelectBuilder) { + sb.Where(sb.EQ("slug", slug)) + }) + if err != nil { + if errors.Is(err, storage.ErrNotFound) { + slog.WarnContext(ctx, "No warehouse with that slug was found", slog.String("slug", slug)) + s.JSON(w, r, http.StatusNotFound, MsgNotFound, RespNotFound) + return + } + slog.ErrorContext(ctx, "Error while getting warehouse", slog.String("slug", slug), slog.String("error", err.Error())) + s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError) + return + } + s.JSON(w, r, http.StatusOK, warehouse, RespSuccess) +} -func (s *Server) DeleteWarehouse(w http.ResponseWriter, r *http.Request, slug string) {} +func (s *Server) UpdateWarehouse(w http.ResponseWriter, r *http.Request, slug string) { + ctx := r.Context() + var req api.WarehouseUpdate + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + slog.ErrorContext(ctx, "Invalid json body", slog.String("error", err.Error())) + s.JSON(w, r, http.StatusBadRequest, MsgInvalidBody, RespError) + return + } + warehouse, err := storage.GetOne[models.Warehouse](ctx, s.DB, "warehouses", func(sb *sqlbuilder.SelectBuilder) { + sb.Where(sb.Equal("slug", slug)) + }) + if err != nil { + if errors.Is(err, storage.ErrNotFound) { + slog.WarnContext(ctx, "No warehouse with that slug was found", slog.String("slug", slug), slog.String("error", err.Error())) + s.JSON(w, r, http.StatusNotFound, MsgNotFound, RespNotFound) + return + } + slog.ErrorContext(ctx, "Error while getting warehouse with that slug", slog.String("slug", slug), slog.String("error", err.Error())) + s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError) + return + } + + warehouse.Address = *req.Address + warehouse.City = *req.City + warehouse.Latitude = float64(*req.Latitude) + warehouse.Longitude = float64(*req.Longitude) + warehouse.Status = string(*req.Status) + warehouse.Name = *req.Name + if err := storage.Update(ctx, "warehouses", warehouse, s.DB, func(sb *sqlbuilder.UpdateBuilder) { + sb.Where(sb.Equal("slug", slug)) + }); err != nil { + slog.ErrorContext(ctx, "Error while updating warehouse with that slug", slog.String("slug", slug), slog.String("error", err.Error())) + s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError) + return + } + s.JSON(w, r, http.StatusOK, warehouse, RespSuccess) +} + +func (s *Server) DeleteWarehouse(w http.ResponseWriter, r *http.Request, slug string) { + ctx := r.Context() + + if err := storage.Delete[models.Warehouse](ctx, "warehouses", s.DB, func(sb *sqlbuilder.DeleteBuilder) { + sb.Where(sb.EQ("slug", slug)) + }); err != nil { + slog.ErrorContext(ctx, "Error while deleting warehoue", slog.String("slug", slug), slog.String("error", err.Error())) + s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError) + return + } + + s.JSON(w, r, http.StatusOK, slug, RespSuccess) +} diff --git a/pkg/storage.go b/pkg/storage.go index b8af72a..3e45d47 100644 --- a/pkg/storage.go +++ b/pkg/storage.go @@ -85,15 +85,11 @@ func GetOne[T any](ctx context.Context, db Querier, table string, opts ...func(* func Create[T any](ctx context.Context, table string, item T, db Querier, opts ...func(*sqlbuilder.SelectBuilder)) error { structs := sqlbuilder.NewStruct(new(T)) - slog.Info("Items", slog.Any("Item", item)) - sb := structs.WithoutTag("db", "-").InsertInto(table, item) sb.SetFlavor(sqlbuilder.PostgreSQL) query, args := sb.Build() - slog.Info("Query", slog.String("query", query), slog.Any("args", args)) - if _, err := db.Exec(ctx, query, args...); err != nil { slog.ErrorContext(ctx, "cannot create item", slog.String("query", query),