From 842f87f576e2b9e066bf42bfd4c121dd750b7a90 Mon Sep 17 00:00:00 2001 From: Rinat Date: Wed, 15 Jul 2026 16:19:24 +0300 Subject: [PATCH] Initial commit: ozon-mock Go server - Mock Ozon Seller API endpoints - Products, Postings, Finance, Returns, Analytics - JSON fixtures for testing - Docker support - Gitea Actions CI/CD --- .gitea/workflows/deploy.yml | 39 +++++ .gitea/workflows/test.yml | 32 ++++ .gitignore | 33 ++++ Dockerfile | 11 ++ README.md | 106 +++++++++++++ cmd/server/main.go | 58 +++++++ go.mod | 5 + internal/config/config.go | 24 +++ internal/handlers/finance.go | 180 ++++++++++++++++++++++ internal/handlers/fixtures.go | 242 +++++++++++++++++++++++++++++ internal/handlers/postings.go | 235 ++++++++++++++++++++++++++++ internal/handlers/products.go | 282 ++++++++++++++++++++++++++++++++++ internal/middleware/auth.go | 38 +++++ 13 files changed, 1285 insertions(+) create mode 100644 .gitea/workflows/deploy.yml create mode 100644 .gitea/workflows/test.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 cmd/server/main.go create mode 100644 go.mod create mode 100644 internal/config/config.go create mode 100644 internal/handlers/finance.go create mode 100644 internal/handlers/fixtures.go create mode 100644 internal/handlers/postings.go create mode 100644 internal/handlers/products.go create mode 100644 internal/middleware/auth.go diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..57f965e --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,39 @@ +name: deploy + +on: + push: + branches: + - main + paths-ignore: + - '*.md' + workflow_dispatch: + +jobs: + deploy: + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: self-hosted + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: '1.25' + + - name: Build binary + run: | + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /tmp/ozon-mock ./cmd/server + + - name: Copy to ioffe + env: + IOFFE_HOST: ${{ vars.IOFFE_HOST }} + IOFFE_USER: ${{ vars.IOFFE_USER }} + run: | + ssh $IOFFE_USER@$IOFFE_HOST "mkdir -p /opt/ozon-mock" + scp /tmp/ozon-mock $IOFFE_USER@$IOFFE_HOST:/opt/ozon-mock/ozon-mock + + - name: Restart service + env: + IOFFE_HOST: ${{ vars.IOFFE_HOST }} + IOFFE_USER: ${{ vars.IOFFE_USER }} + run: | + ssh $IOFFE_USER@$IOFFE_HOST "sudo systemctl restart ozon-mock || true" diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml new file mode 100644 index 0000000..cd65986 --- /dev/null +++ b/.gitea/workflows/test.yml @@ -0,0 +1,32 @@ +name: test + +on: + - push + - pull_request + +jobs: + test: + runs-on: self-hosted + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: '1.25' + + - name: Download dependencies + run: go mod download + + - name: Vet + run: go vet ./... + + - name: Build + run: go build -v ./... + + - name: Test + run: go test -v -race ./... + + - name: Build Docker image + run: | + docker build -t ozon-mock:${{ github.sha }} . + docker tag ozon-mock:${{ github.sha }} ozon-mock:latest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0646764 --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# Binaries +ozon-mock +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary +*.test + +# Output of the go coverage tool +*.out + +# Dependency directories +vendor/ + +# Docker +.dockerignore + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Build +dist/ +build/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1399420 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM alpine:3.20 + +RUN apk --no-cache add ca-certificates tzdata + +COPY ozon-mock /usr/local/bin/ozon-mock + +EXPOSE 8080 + +ENV PORT=8080 + +CMD ["/usr/local/bin/ozon-mock"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..12a7650 --- /dev/null +++ b/README.md @@ -0,0 +1,106 @@ +# Ozon Mock API Server + +Mock-сервер для тестирования интеграции с Ozon Seller API. + +## Быстрый старт + +```bash +# Запуск +go run ./cmd/server + +# Или с переменными окружения +OZON_CLIENT_ID=test-client OZON_API_KEY=test-key PORT=8080 go run ./cmd/server +``` + +## Тестовые запросы + +```bash +# Stocks +curl -X POST http://localhost:8080/v3/product/info/stocks \ + -H "Client-Id: test-client" \ + -H "Api-Key: test-key" \ + -H "Content-Type: application/json" \ + -d '{"filter": {"offer_id": ["TEST-SKU-001"]}, "limit": 100}' + +# Postings list +curl -X POST http://localhost:8080/v2/posting/fbs/list \ + -H "Client-Id: test-client" \ + -H "Api-Key: test-key" \ + -H "Content-Type: application/json" \ + -d '{"dir": "desc", "limit": 100}' + +# Posting details +curl -X POST http://localhost:8080/v2/posting/fbs/get \ + -H "Client-Id: test-client" \ + -H "Api-Key: test-key" \ + -H "Content-Type: application/json" \ + -d '{"posting_number": "TEST-POST-001"}' + +# Import prices +curl -X POST http://localhost:8080/v1/product/import/prices \ + -H "Client-Id: test-client" \ + -H "Api-Key: test-key" \ + -H "Content-Type: application/json" \ + -d '{"items": [{"offer_id": "TEST-SKU-001", "price": 1990.00}]}' +``` + +## Переменные окружения + +| Переменная | Обязательна | По умолчанию | Описание | +|------------|-------------|---------------|----------| +| `PORT` | Нет | 8080 | Порт | +| `OZON_CLIENT_ID` | Нет | (пусто) | Ожидаемый Client-ID. Если пусто - не проверяется | +| `OZON_API_KEY` | Нет | (пусто) | Ожидаемый API-Key. Если пусто - не проверяется | + +## Эндпоинты + +### Products +- `POST /v1/product/import/prices` - Import prices +- `GET /v1/product/list` - List products +- `POST /v3/product/info/list` - Get product info +- `POST /v3/product/info/stocks` - Get product stocks +- `POST /v4/product/info/prices` - Get product prices + +### Postings +- `POST /v2/posting/fbs/list` - List FBS postings +- `POST /v2/posting/fbs/get` - Get posting details +- `POST /v1/posting/fbs/cancel` - Cancel posting + +### Finance +- `POST /v1/finance/transaction/list` - List transactions +- `POST /v1/finance/payout/list` - List payouts + +### Returns +- `POST /v1/returns/list` - List returns + +### Analytics +- `POST /v1/analytics/data` - Analytics data +- `POST /v1/analytics/turnover/stocks` - Stock turnover + +### Health +- `GET /health` - Health check + +## Тестовые данные + +### Offer IDs +- `TEST-SKU-001` - Тестовый товар 1 (price: 1990, stock: 150) +- `TEST-SKU-002` - Тестовый товар 2 (price: 990, stock: 75) +- `TEST-SKU-003` - Тестовый товар 3 (price: 3990, stock: 200) +- `TEST-SKU-004` - Тестовый товар 4 (price: 590, stock: 0) +- `TEST-SKU-005` - Тестовый товар 5 (price: 1590, stock: 50) + +### Posting Numbers +- `TEST-POST-001` - delivered +- `TEST-POST-002` - delivered +- `TEST-POST-003` - awaiting_deliver +- `TEST-POST-004` - cancelled + +## Deployment + +```bash +# Build +CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o ozon-mock ./cmd/server + +# Run +./ozon-mock +``` diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..db66a36 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,58 @@ +package main + +import ( + "log" + "net/http" + "os" + + "github.com/rinat/ozon-mock/internal/config" + "github.com/rinat/ozon-mock/internal/handlers" + "github.com/rinat/ozon-mock/internal/middleware" +) + +func main() { + cfg := config.Load() + + mux := http.NewServeMux() + + // Products + mux.HandleFunc("/v1/product/import/prices", middleware.Auth(cfg.ClientID, cfg.APIKey, handlers.ImportPrices)) + mux.HandleFunc("/v1/product/list", middleware.Auth(cfg.ClientID, cfg.APIKey, handlers.ProductList)) + mux.HandleFunc("/v3/product/info/list", middleware.Auth(cfg.ClientID, cfg.APIKey, handlers.ProductInfoList)) + mux.HandleFunc("/v3/product/info/stocks", middleware.Auth(cfg.ClientID, cfg.APIKey, handlers.ProductStocks)) + mux.HandleFunc("/v4/product/info/prices", middleware.Auth(cfg.ClientID, cfg.APIKey, handlers.ProductPrices)) + + // Postings + mux.HandleFunc("/v2/posting/fbs/list", middleware.Auth(cfg.ClientID, cfg.APIKey, handlers.PostingFBSList)) + mux.HandleFunc("/v2/posting/fbs/get", middleware.Auth(cfg.ClientID, cfg.APIKey, handlers.PostingFBSGet)) + mux.HandleFunc("/v1/posting/fbs/cancel", middleware.Auth(cfg.ClientID, cfg.APIKey, handlers.CancelPosting)) + + // Finance + mux.HandleFunc("/v1/finance/transaction/list", middleware.Auth(cfg.ClientID, cfg.APIKey, handlers.FinanceTransactionList)) + mux.HandleFunc("/v1/finance/payout/list", middleware.Auth(cfg.ClientID, cfg.APIKey, handlers.FinancePayoutList)) + + // Returns + mux.HandleFunc("/v1/returns/list", middleware.Auth(cfg.ClientID, cfg.APIKey, handlers.ReturnsList)) + + // Analytics + mux.HandleFunc("/v1/analytics/data", middleware.Auth(cfg.ClientID, cfg.APIKey, handlers.AnalyticsData)) + mux.HandleFunc("/v1/analytics/turnover/stocks", middleware.Auth(cfg.ClientID, cfg.APIKey, handlers.TurnoverStocks)) + + // Health + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"status":"ok"}`)) + }) + + // 404 handler + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(`{"code":404,"message":"not found"}`)) + }) + + addr := ":" + cfg.Port + log.Printf("Starting ozon-mock on %s", addr) + log.Printf("Auth: client_id=%s, api_key=%s", cfg.ClientID, cfg.APIKey) + log.Fatal(http.ListenAndServe(addr, mux)) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..118011d --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module github.com/rinat/ozon-mock + +go 1.25.0 + +require github.com/gin-gonic/gin v1.10.0 diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..06e1988 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,24 @@ +package config + +import "os" + +type Config struct { + Port string + ClientID string + APIKey string +} + +func Load() *Config { + return &Config{ + Port: getEnv("PORT", "8080"), + ClientID: os.Getenv("OZON_CLIENT_ID"), + APIKey: os.Getenv("OZON_API_KEY"), + } +} + +func getEnv(key, defaultVal string) string { + if val := os.Getenv(key); val != "" { + return val + } + return defaultVal +} diff --git a/internal/handlers/finance.go b/internal/handlers/finance.go new file mode 100644 index 0000000..a319ee5 --- /dev/null +++ b/internal/handlers/finance.go @@ -0,0 +1,180 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "time" +) + +// FinanceTransactionListRequest - POST /v1/finance/transaction/list +type FinanceTransactionListRequest struct { + Filter struct { + Since string `json:"since"` + To string `json:"to"` + TransactionType string `json:"transaction_type"` + PostingNumber []string `json:"posting_number"` + } `json:"filter"` + Limit int `json:"limit"` +} + +type Transaction struct { + TransactionType string `json:"transaction_type"` + Amount float64 `json:"amount"` + CurrencyCode string `json:"currency_code"` + PostingNumber string `json:"posting_number,omitempty"` + OrderID int64 `json:"order_id,omitempty"` + Items []TransactionItem `json:"items,omitempty"` + CreatedAt string `json:"created_at"` +} + +type TransactionItem struct { + Type string `json:"type"` + Amount float64 `json:"amount"` + Quantity int `json:"quantity,omitempty"` + ProductID int64 `json:"product_id,omitempty"` + OfferID string `json:"offer_id,omitempty"` +} + +func FinanceTransactionList(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", 405) + return + } + + var req FinanceTransactionListRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, 400, "invalid json") + return + } + + transactions := GetTransactionsFixtures() + + if req.Limit == 0 { + req.Limit = 100 + } + + if len(transactions) > req.Limit { + transactions = transactions[:req.Limit] + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "transactions": transactions, + "total": len(transactions), + }) +} + +// FinancePayoutListRequest - POST /v1/finance/payout/list +type FinancePayoutListRequest struct { + Filter struct { + Since string `json:"since"` + To string `json:"to"` + } `json:"filter"` + Limit int `json:"limit"` +} + +type Payout struct { + PayoutID int64 `json:"payout_id"` + Type string `json:"type"` + Status string `json:"status"` + Amount float64 `json:"amount"` + CurrencyCode string `json:"currency_code"` + CreatedAt string `json:"created_at"` + OperationsCount int `json:"operations_count"` +} + +func FinancePayoutList(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", 405) + return + } + + var req FinancePayoutListRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, 400, "invalid json") + return + } + + payouts := GetPayoutsFixtures() + + if req.Limit == 0 { + req.Limit = 100 + } + + if len(payouts) > req.Limit { + payouts = payouts[:req.Limit] + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "payouts": payouts, + "total": len(payouts), + }) +} + +// AnalyticsDataRequest - POST /v1/analytics/data +type AnalyticsDataRequest struct { + DateFrom string `json:"date_from"` + DateTo string `json:"date_to"` + Metrics []string `json:"metrics"` + Dimension []string `json:"dimension"` +} + +func AnalyticsData(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", 405) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "data": []map[string]any{ + { + "date": time.Now().Format("2006-01-02"), + "orders_count": 42, + "revenue": 125000.50, + "avg_order": 2976.20, + }, + }, + "total": 1, + }) +} + +// TurnoverStocksRequest - POST /v1/analytics/turnover/stocks +type TurnoverStocksRequest struct { + Date string `json:"date"` + SKU []string `json:"sku"` +} + +type TurnoverStocksResponse struct { + Items []struct { + SKU string `json:"sku"` + ProductID int64 `json:"product_id"` + OnHand int `json:"orders_count_canceled"` + Reserved int `json:"reserved"` + InWayToCustomer int `json:"in_way_to_customer"` + InWayFromCustomer int `json:"in_way_from_customer"` + } `json:"items"` +} + +func TurnoverStocks(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", 405) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(TurnoverStocksResponse{ + Items: []struct { + SKU string `json:"sku"` + ProductID int64 `json:"product_id"` + OnHand int `json:"orders_count_canceled"` + Reserved int `json:"reserved"` + InWayToCustomer int `json:"in_way_to_customer"` + InWayFromCustomer int `json:"in_way_from_customer"` + }{ + {SKU: "TEST-SKU-001", ProductID: 99001234001, OnHand: 150, Reserved: 0, InWayToCustomer: 10, InWayFromCustomer: 2}, + {SKU: "TEST-SKU-002", ProductID: 99001234002, OnHand: 75, Reserved: 5, InWayToCustomer: 15, InWayFromCustomer: 1}, + }, + }) +} diff --git a/internal/handlers/fixtures.go b/internal/handlers/fixtures.go new file mode 100644 index 0000000..0ff5241 --- /dev/null +++ b/internal/handlers/fixtures.go @@ -0,0 +1,242 @@ +package handlers + +import ( + "log" + "os" +) + +func GetStocksFixtures() []StockItem { + data := []StockItem{ + {ProductID: 99001234001, OfferID: "TEST-SKU-001", Stock: 150, WarehouseID: 99100001001, WarehouseName: "Moscow FBO"}, + {ProductID: 99001234002, OfferID: "TEST-SKU-002", Stock: 75, WarehouseID: 99100001001, WarehouseName: "Moscow FBO"}, + {ProductID: 99001234003, OfferID: "TEST-SKU-003", Stock: 200, WarehouseID: 99100001002, WarehouseName: "St. Petersburg FBO"}, + {ProductID: 99001234004, OfferID: "TEST-SKU-004", Stock: 0, WarehouseID: 99100001003, WarehouseName: "Kazan FBO"}, + {ProductID: 99001234005, OfferID: "TEST-SKU-005", Stock: 50, WarehouseID: 99100001001, WarehouseName: "Moscow FBO"}, + } + + // Try to load from file if exists + if dataStr := os.Getenv("MOCK_STOCKS_JSON"); dataStr != "" { + log.Printf("MOCK_STOCKS_JSON is set, ignoring fixtures") + } + + return data +} + +func GetProductsFixtures() []ProductInfoItem { + return []ProductInfoItem{ + { + ProductID: 99001234001, + OfferID: "TEST-SKU-001", + Name: "Тестовый товар 1", + Barcode: "4600000000001", + BuyPrice: 1000.00, + Price: 1990.00, + PremiumPrice: 2190.00, + CategoryID: 17000000, + }, + { + ProductID: 99001234002, + OfferID: "TEST-SKU-002", + Name: "Тестовый товар 2", + Barcode: "4600000000002", + BuyPrice: 500.00, + Price: 990.00, + PremiumPrice: 1090.00, + CategoryID: 17000001, + }, + { + ProductID: 99001234003, + OfferID: "TEST-SKU-003", + Name: "Тестовый товар 3", + Barcode: "4600000000003", + BuyPrice: 2000.00, + Price: 3990.00, + PremiumPrice: 4390.00, + CategoryID: 17000000, + }, + { + ProductID: 99001234004, + OfferID: "TEST-SKU-004", + Name: "Тестовый товар 4", + Barcode: "4600000000004", + BuyPrice: 300.00, + Price: 590.00, + PremiumPrice: 650.00, + CategoryID: 17000002, + }, + { + ProductID: 99001234005, + OfferID: "TEST-SKU-005", + Name: "Тестовый товар 5", + Barcode: "4600000000005", + BuyPrice: 800.00, + Price: 1590.00, + PremiumPrice: 1750.00, + CategoryID: 17000001, + }, + } +} + +func GetPricesFixtures() []ProductPriceItem { + return []ProductPriceItem{ + {OfferID: "TEST-SKU-001", Price: 1990.00, OldPrice: 2190.00, PremiumPrice: 2190.00, PremiumPriceOld: 2390.00}, + {OfferID: "TEST-SKU-002", Price: 990.00, OldPrice: 1090.00, PremiumPrice: 1090.00, PremiumPriceOld: 1190.00}, + {OfferID: "TEST-SKU-003", Price: 3990.00, OldPrice: 4490.00, PremiumPrice: 4390.00, PremiumPriceOld: 4890.00}, + {OfferID: "TEST-SKU-004", Price: 590.00, OldPrice: 690.00, PremiumPrice: 650.00, PremiumPriceOld: 750.00}, + {OfferID: "TEST-SKU-005", Price: 1590.00, OldPrice: 1790.00, PremiumPrice: 1750.00, PremiumPriceOld: 1950.00}, + } +} + +func GetPostingsFixtures() []Posting { + return []Posting{ + { + PostingNumber: "TEST-POST-001", + OrderID: 99000000001, + OrderNumber: "TEST-ORD-001", + Status: "delivered", + Products: []PostingItem{ + {ProductID: 99001234001, OfferID: "TEST-SKU-001", Name: "Тестовый товар 1", Quantity: 2, Price: 1990.00, ItemsCount: 1}, + }, + CreatedAt: "2024-01-15T10:30:00Z", + UpdatedAt: "2024-01-17T14:45:00Z", + WarehouseID: 99100001001, + OrderType: "fbs", + }, + { + PostingNumber: "TEST-POST-002", + OrderID: 99000000002, + OrderNumber: "TEST-ORD-002", + Status: "delivered", + Products: []PostingItem{ + {ProductID: 99001234002, OfferID: "TEST-SKU-002", Name: "Тестовый товар 2", Quantity: 1, Price: 990.00, ItemsCount: 1}, + {ProductID: 99001234003, OfferID: "TEST-SKU-003", Name: "Тестовый товар 3", Quantity: 1, Price: 3990.00, ItemsCount: 1}, + }, + CreatedAt: "2024-01-16T11:00:00Z", + UpdatedAt: "2024-01-18T09:30:00Z", + WarehouseID: 99100001001, + OrderType: "fbs", + }, + { + PostingNumber: "TEST-POST-003", + OrderID: 99000000003, + OrderNumber: "TEST-ORD-003", + Status: "awaiting_deliver", + Products: []PostingItem{ + {ProductID: 99001234004, OfferID: "TEST-SKU-004", Name: "Тестовый товар 4", Quantity: 3, Price: 590.00, ItemsCount: 1}, + }, + CreatedAt: "2024-01-18T08:15:00Z", + UpdatedAt: "2024-01-18T08:15:00Z", + WarehouseID: 99100001002, + OrderType: "fbs", + }, + { + PostingNumber: "TEST-POST-004", + OrderID: 99000000004, + OrderNumber: "TEST-ORD-004", + Status: "cancelled", + Products: []PostingItem{ + {ProductID: 99001234005, OfferID: "TEST-SKU-005", Name: "Тестовый товар 5", Quantity: 1, Price: 1590.00, ItemsCount: 1}, + }, + CreatedAt: "2024-01-17T16:00:00Z", + UpdatedAt: "2024-01-17T18:30:00Z", + WarehouseID: 99100001001, + OrderType: "fbs", + }, + } +} + +func GetReturnsFixtures() []Return { + return []Return{ + { + ReturnID: 99000001, + PostingNumber: "TEST-POST-001", + Status: "completed", + Reason: "wrong_item", + ReasonID: 10, + CreatedAt: "2024-01-20T10:00:00Z", + DeliveredAt: "2024-01-22T15:00:00Z", + Items: []ReturnItem{ + {ProductID: 99001234001, OfferID: "TEST-SKU-001", Name: "Тестовый товар 1", Quantity: 1, IsOptional: false}, + }, + }, + { + ReturnID: 99000002, + PostingNumber: "TEST-POST-002", + Status: "pending", + Reason: "buyer_remorse", + ReasonID: 20, + CreatedAt: "2024-01-21T14:30:00Z", + Items: []ReturnItem{ + {ProductID: 99001234002, OfferID: "TEST-SKU-002", Name: "Тестовый товар 2", Quantity: 1, IsOptional: true}, + }, + }, + } +} + +func GetTransactionsFixtures() []Transaction { + return []Transaction{ + { + TransactionType: "orders", + Amount: 3980.00, + CurrencyCode: "RUB", + PostingNumber: "TEST-POST-001", + OrderID: 99000000001, + CreatedAt: "2024-01-17T00:00:00Z", + Items: []TransactionItem{ + {Type: "sale", Amount: 1990.00, Quantity: 1, ProductID: 99001234001, OfferID: "TEST-SKU-001"}, + {Type: "sale", Amount: 1990.00, Quantity: 1, ProductID: 99001234001, OfferID: "TEST-SKU-001"}, + }, + }, + { + TransactionType: "orders", + Amount: 4980.00, + CurrencyCode: "RUB", + PostingNumber: "TEST-POST-002", + OrderID: 99000000002, + CreatedAt: "2024-01-18T00:00:00Z", + Items: []TransactionItem{ + {Type: "sale", Amount: 990.00, Quantity: 1, ProductID: 99001234002, OfferID: "TEST-SKU-002"}, + {Type: "sale", Amount: 3990.00, Quantity: 1, ProductID: 99001234003, OfferID: "TEST-SKU-003"}, + }, + }, + { + TransactionType: "commission", + Amount: -597.00, + CurrencyCode: "RUB", + PostingNumber: "TEST-POST-001", + OrderID: 99000000001, + CreatedAt: "2024-01-17T00:00:00Z", + }, + { + TransactionType: "refund", + Amount: -990.00, + CurrencyCode: "RUB", + PostingNumber: "TEST-POST-004", + OrderID: 99000000004, + CreatedAt: "2024-01-17T00:00:00Z", + }, + } +} + +func GetPayoutsFixtures() []Payout { + return []Payout{ + { + PayoutID: 99001, + Type: "payout", + Status: "completed", + Amount: 15000.00, + CurrencyCode: "RUB", + CreatedAt: "2024-01-15T00:00:00Z", + OperationsCount: 10, + }, + { + PayoutID: 99002, + Type: "payout", + Status: "completed", + Amount: 22500.00, + CurrencyCode: "RUB", + CreatedAt: "2024-01-22T00:00:00Z", + OperationsCount: 15, + }, + } +} diff --git a/internal/handlers/postings.go b/internal/handlers/postings.go new file mode 100644 index 0000000..965d234 --- /dev/null +++ b/internal/handlers/postings.go @@ -0,0 +1,235 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "time" +) + +// PostingFBSListRequest - POST /v2/posting/fbs/list +type PostingFBSListRequest struct { + Dir string `json:"dir"` // "asc" or "desc" + Filter struct { + Since string `json:"since"` + To string `json:"to"` + Status string `json:"status"` + PostingNumber []string `json:"posting_number"` + DeliveryMethod int `json:"delivery_method"` + } `json:"filter"` + Limit int `json:"limit"` + With struct { + AnalyticsData bool `json:"analytics_data"` + } `json:"with"` +} + +type PostingItem struct { + ProductID int64 `json:"product_id"` + OfferID string `json:"offer_id"` + Name string `json:"name"` + Quantity int `json:"quantity"` + Price float64 `json:"price"` + ItemsCount int `json:"items_count"` +} + +type Posting struct { + PostingNumber string `json:"posting_number"` + OrderID int64 `json:"order_id"` + OrderNumber string `json:"order_number"` + Status string `json:"status"` + Products []PostingItem `json:"products"` + AnalyticsData any `json:"analytics_data,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Warehouses []int64 `json:"warehouses"` + WarehouseID int64 `json:"warehouse_id"` + OrderType string `json:"order_type"` +} + +func PostingFBSList(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", 405) + return + } + + var req PostingFBSListRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, 400, "invalid json") + return + } + + if req.Limit == 0 { + req.Limit = 100 + } + if req.Limit > 1000 { + req.Limit = 1000 + } + + postings := GetPostingsFixtures() + + // Filter by status if provided + if req.Filter.Status != "" { + filtered := make([]Posting, 0) + for _, p := range postings { + if p.Status == req.Filter.Status { + filtered = append(filtered, p) + } + } + postings = filtered + } + + // Filter by posting_number if provided + if len(req.Filter.PostingNumber) > 0 { + filtered := make([]Posting, 0) + for _, p := range postings { + for _, num := range req.Filter.PostingNumber { + if p.PostingNumber == num { + filtered = append(filtered, p) + break + } + } + } + postings = filtered + } + + // Apply limit + if len(postings) > req.Limit { + postings = postings[:req.Limit] + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "postings": postings, + "total": len(postings), + }) +} + +// PostingFBSGetRequest - POST /v2/posting/fbs/get +type PostingFBSGetRequest struct { + PostingNumber string `json:"posting_number"` +} + +func PostingFBSGet(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", 405) + return + } + + var req PostingFBSGetRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, 400, "invalid json") + return + } + + postings := GetPostingsFixtures() + for _, p := range postings { + if p.PostingNumber == req.PostingNumber { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "posting": p, + }) + return + } + } + + writeError(w, 404, "posting not found") +} + +// CancelPostingRequest - POST /v1/posting/fbs/cancel +type CancelPostingRequest struct { + PostingNumber string `json:"posting_number"` +} + +type CancelPostingResponse struct { + PostingNumber string `json:"posting_number"` + Status string `json:"status"` +} + +func CancelPosting(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", 405) + return + } + + var req CancelPostingRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, 400, "invalid json") + return + } + + if req.PostingNumber == "" { + writeError(w, 400, "posting_number is required") + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(CancelPostingResponse{ + PostingNumber: req.PostingNumber, + Status: "cancelled", + }) +} + +// Returns + +type ReturnsListRequest struct { + Filter struct { + Since string `json:"since"` + To string `json:"to"` + Status string `json:"status"` + ReturnID int64 `json:"return_id"` + } `json:"filter"` + Limit int `json:"limit"` +} + +type ReturnItem struct { + ProductID int64 `json:"product_id"` + OfferID string `json:"offer_id"` + Name string `json:"name"` + Quantity int `json:"quantity"` + IsOptional bool `json:"is_optional"` +} + +type Return struct { + ReturnID int64 `json:"return_id"` + PostingNumber string `json:"posting_number"` + Status string `json:"status"` + Reason string `json:"reason"` + ReasonID int64 `json:"reason_id"` + CreatedAt string `json:"created_at"` + RejectedAt string `json:"rejected_at,omitempty"` + DeliveredAt string `json:"delivered_at,omitempty"` + Items []ReturnItem `json:"items"` +} + +func ReturnsList(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", 405) + return + } + + var req ReturnsListRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, 400, "invalid json") + return + } + + returns := GetReturnsFixtures() + + if req.Limit == 0 { + req.Limit = 100 + } + + if len(returns) > req.Limit { + returns = returns[:req.Limit] + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "result": returns, + "total": len(returns), + }) +} + +func init() { + // Ensure timestamps are consistent + _ = time.RFC3339 +} diff --git a/internal/handlers/products.go b/internal/handlers/products.go new file mode 100644 index 0000000..7442a39 --- /dev/null +++ b/internal/handlers/products.go @@ -0,0 +1,282 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "strings" +) + +// Common request/response types +type ErrorResponse struct { + Code int `json:"code"` + Message string `json:"message"` +} + +// ProductStocksRequest - POST /v3/product/info/stocks +type ProductStocksRequest struct { + Filter struct { + OfferIDs []string `json:"offer_id"` + ProductIDs []int64 `json:"product_id"` + Visibility string `json:"visibility"` + } `json:"filter"` + LastID string `json:"last_id"` + Limit int `json:"limit"` +} + +type StockItem struct { + ProductID int64 `json:"product_id"` + OfferID string `json:"offer_id"` + Stock int `json:"stock"` + WarehouseID int64 `json:"warehouse_id"` + WarehouseName string `json:"warehouse_name"` +} + +func ProductStocks(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", 405) + return + } + + var req ProductStocksRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, 400, "invalid json: "+err.Error()) + return + } + + items := GetStocksFixtures() + + // Filter by offer_ids if provided + if len(req.Filter.OfferIDs) > 0 { + filtered := make([]StockItem, 0) + for _, item := range items { + for _, oid := range req.Filter.OfferIDs { + if item.OfferID == oid { + filtered = append(filtered, item) + break + } + } + } + items = filtered + } + + // Filter by product_ids if provided + if len(req.Filter.ProductIDs) > 0 { + filtered := make([]StockItem, 0) + for _, item := range items { + for _, pid := range req.Filter.ProductIDs { + if item.ProductID == pid { + filtered = append(filtered, item) + break + } + } + } + items = filtered + } + + // Apply limit + if req.Limit > 0 && len(items) > req.Limit { + items = items[:req.Limit] + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "items": items, + "total": len(items), + }) +} + +// ProductInfoListRequest - POST /v3/product/info/list +type ProductInfoListRequest struct { + Filter struct { + OfferIDs []string `json:"offer_id"` + ProductIDs []int64 `json:"product_id"` + } `json:"filter"` + Limit int `json:"limit"` +} + +type ProductInfoItem struct { + ProductID int64 `json:"product_id"` + OfferID string `json:"offer_id"` + Name string `json:"name"` + Barcode string `json:"barcode,omitempty"` + BuyPrice float64 `json:"buy_price"` + Price float64 `json:"price"` + PremiumPrice float64 `json:"premium_price"` + CategoryID int64 `json:"category_id"` +} + +func ProductInfoList(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", 405) + return + } + + var req ProductInfoListRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, 400, "invalid json") + return + } + + items := GetProductsFixtures() + + if len(req.Filter.OfferIDs) > 0 { + filtered := make([]ProductInfoItem, 0) + for _, item := range items { + for _, oid := range req.Filter.OfferIDs { + if item.OfferID == oid { + filtered = append(filtered, item) + break + } + } + } + items = filtered + } + + if req.Limit > 0 && len(items) > req.Limit { + items = items[:req.Limit] + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "items": items, + "total": len(items), + }) +} + +// ImportPricesRequest - POST /v1/product/import/prices +type ImportPricesRequest struct { + Items []struct { + OfferID string `json:"offer_id"` + Price float64 `json:"price"` + OldPrice float64 `json:"old_price,omitempty"` + PremiumPrice float64 `json:"premium_price,omitempty"` + PremiumPriceOld float64 `json:"premium_price_old,omitempty"` + } `json:"items"` +} + +type ImportPricesResponse struct { + TaskID int64 `json:"task_id"` +} + +func ImportPrices(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", 405) + return + } + + var req ImportPricesRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, 400, "invalid json") + return + } + + if len(req.Items) == 0 { + writeError(w, 400, "items is required and must not be empty") + return + } + + // Validate required fields + for _, item := range req.Items { + if item.OfferID == "" { + writeError(w, 400, "offer_id is required for all items") + return + } + if item.Price <= 0 { + writeError(w, 400, "price must be positive") + return + } + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(ImportPricesResponse{ + TaskID: 1234567890, + }) +} + +// ProductPricesRequest - POST /v4/product/info/prices +type ProductPricesRequest struct { + Filter struct { + OfferIDs []string `json:"offer_id"` + ProductIDs []int64 `json:"product_id"` + } `json:"filter"` + Limit int `json:"limit"` +} + +type ProductPriceItem struct { + ProductID int64 `json:"product_id"` + OfferID string `json:"offer_id"` + Price float64 `json:"price"` + OldPrice float64 `json:"old_price,omitempty"` + PremiumPrice float64 `json:"premium_price,omitempty"` + PremiumPriceOld float64 `json:"premium_price_old,omitempty"` +} + +func ProductPrices(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", 405) + return + } + + var req ProductPricesRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, 400, "invalid json") + return + } + + items := GetPricesFixtures() + + if len(req.Filter.OfferIDs) > 0 { + filtered := make([]ProductPriceItem, 0) + for _, item := range items { + for _, oid := range req.Filter.OfferIDs { + if item.OfferID == oid { + filtered = append(filtered, item) + break + } + } + } + items = filtered + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "items": items, + "total": len(items), + }) +} + +// ProductListRequest - GET /v1/product/list +type ProductListRequest struct { + Page int `json:"page"` +} + +func ProductList(w http.ResponseWriter, r *http.Request) { + items := GetProductsFixtures() + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "items": items, + "total": len(items), + }) +} + +func writeError(w http.ResponseWriter, code int, msg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + json.NewEncoder(w).Encode(ErrorResponse{Code: code, Message: msg}) +} + +// Helper to parse offer_ids from query string +func parseOfferIDs(query string) []string { + if query == "" { + return nil + } + parts := strings.Split(query, ",") + result := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + result = append(result, p) + } + } + return result +} diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go new file mode 100644 index 0000000..cd1bfa2 --- /dev/null +++ b/internal/middleware/auth.go @@ -0,0 +1,38 @@ +package middleware + +import ( + "encoding/json" + "net/http" +) + +func Auth(expectedClientID, expectedAPIKey string, next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + clientID := r.Header.Get("Client-Id") + apiKey := r.Header.Get("Api-Key") + + if clientID == "" || apiKey == "" { + writeError(w, 401, "missing credentials: Client-Id and Api-Key headers are required") + return + } + + if expectedClientID != "" && clientID != expectedClientID { + writeError(w, 403, "invalid client id") + return + } + if expectedAPIKey != "" && apiKey != expectedAPIKey { + writeError(w, 403, "invalid api key") + return + } + + next(w, r) + } +} + +func writeError(w http.ResponseWriter, code int, msg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + json.NewEncoder(w).Encode(map[string]any{ + "code": code, + "message": msg, + }) +}