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
This commit is contained in:
39
.gitea/workflows/deploy.yml
Normal file
39
.gitea/workflows/deploy.yml
Normal file
@ -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"
|
||||||
32
.gitea/workflows/test.yml
Normal file
32
.gitea/workflows/test.yml
Normal file
@ -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
|
||||||
33
.gitignore
vendored
Normal file
33
.gitignore
vendored
Normal file
@ -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/
|
||||||
11
Dockerfile
Normal file
11
Dockerfile
Normal file
@ -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"]
|
||||||
106
README.md
Normal file
106
README.md
Normal file
@ -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
|
||||||
|
```
|
||||||
58
cmd/server/main.go
Normal file
58
cmd/server/main.go
Normal file
@ -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))
|
||||||
|
}
|
||||||
5
go.mod
Normal file
5
go.mod
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
module github.com/rinat/ozon-mock
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require github.com/gin-gonic/gin v1.10.0
|
||||||
24
internal/config/config.go
Normal file
24
internal/config/config.go
Normal file
@ -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
|
||||||
|
}
|
||||||
180
internal/handlers/finance.go
Normal file
180
internal/handlers/finance.go
Normal file
@ -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},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
242
internal/handlers/fixtures.go
Normal file
242
internal/handlers/fixtures.go
Normal file
@ -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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
235
internal/handlers/postings.go
Normal file
235
internal/handlers/postings.go
Normal file
@ -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
|
||||||
|
}
|
||||||
282
internal/handlers/products.go
Normal file
282
internal/handlers/products.go
Normal file
@ -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
|
||||||
|
}
|
||||||
38
internal/middleware/auth.go
Normal file
38
internal/middleware/auth.go
Normal file
@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user