diff --git a/ENDPOINTS.md b/ENDPOINTS.md index 4f98514..8e2a749 100644 --- a/ENDPOINTS.md +++ b/ENDPOINTS.md @@ -6,12 +6,12 @@ - [ ] Characteristics value directory ## Uploading and updating products -- [ ] Create or update a product +- [x] Create or update a product - [ ] Get the product import status - [ ] Create a product by Ozon ID - [ ] Upload and update product images - [ ] Check products images uploading status -- [ ] List of products +- [x] List of products - [x] Product details - [ ] Get products' content rating by SKU - [ ] Get a list of products by identifiers @@ -86,7 +86,7 @@ - [ ] Validate labeling codes - [ ] Check and save product items data - [ ] Get product items check statuses -- [ ] Pack the order (version 4) +- [x] Pack the order (version 4) ## FBS and rFBS - [x] List of unprocessed shipments (version 3) @@ -97,7 +97,6 @@ - [ ] Set the manufacturing country - [ ] Specify number of boxes for multi-box shipments - [ ] Get drop-off point restrictions -- [ ] Pack the order (version 3) - [ ] Partial pack the order - [ ] Create an acceptance and transfer certificate and a waybill - [ ] Status of acceptance and transfer certificate and waybill diff --git a/ozon/fbs.go b/ozon/fbs.go index 998398c..f83260c 100644 --- a/ozon/fbs.go +++ b/ozon/fbs.go @@ -108,15 +108,7 @@ type FBSPosting struct { ParentPostingNumber string `json:"parent_posting_number"` PostingNumber string `json:"posting_number"` - Products []struct { - MandatoryMark []string `json:"mandatory_mark"` - Name string `json:"name"` - OfferId string `json:"offer_id"` - CurrencyCode string `json:"currency_code"` - Price string `json:"price"` - Quantity int32 `json:"quantity"` - SKU int64 `json:"sku"` - } `json:"products"` + Products []PostingProduct `json:"products"` Requirements struct { ProductsRequiringGTD []string `json:"products_requiring_gtd"` @@ -131,6 +123,16 @@ type FBSPosting struct { TrackingNumber string `json:"tracking_number"` } +type PostingProduct struct { + MandatoryMark []string `json:"mandatory_mark"` + Name string `json:"name"` + OfferId string `json:"offer_id"` + CurrencyCode string `json:"currency_code"` + Price string `json:"price"` + Quantity int32 `json:"quantity"` + SKU int64 `json:"sku"` +} + type FBSCustomer struct { Address struct { AddressTail string `json:"address_tail"` @@ -300,3 +302,72 @@ func (c Client) GetFBSShipmentsList(params *GetFBSShipmentsListParams) (*GetFBSS return resp, nil } + +type PackOrderParams struct { + // List of packages. Each package contains a list of shipments that the order was divided into + Packages []PackOrderPackage `json:"packages"` + + // Shipment number + PostingNumber string `json:"posting_number"` + + // Additional information + With PackOrderWith `json:"with"` +} + +type PackOrderPackage struct { + Products []PackOrderPackageProduct `json:"products"` +} + +type PackOrderPackageProduct struct { + // Product identifier + ProductId int64 `json:"product_id"` + + // Product items quantity + Quantity int32 `json:"quantity"` +} + +type PackOrderWith struct { + // Pass true to get additional information + AdditionalData bool `json:"additional_data"` +} + +type PackOrderResponse struct { + core.CommonResponse + + // Additional information about shipments + AdditionalData []struct { + // Shipment number + PostingNumber string `json:"posting_number"` + + // List of products in the shipment + Products []PostingProduct `json:"products"` + } `json:"additional_data"` + + // Order packaging result + Result []string `json:"result"` +} + +// Divides the order into shipments and changes its status to awaiting_deliver. +// +// Each element of the packages may contain several instances of the products. One instance of the products is one shipment. Each element of the products is a product included into the shipment. +// +// It is necessary to split the order if: +// +// the products do not fit in one package, +// the products cannot be put in one package. +// Differs from /v2/posting/fbs/ship by the presence of the field exemplar_info in the request. +// +// If necessary, specify the number of the cargo customs declaration in the gtd parameter. If it is missing, pass the value is_gtd_absent = true +func (c Client) PackOrder(params *PackOrderParams) (*PackOrderResponse, error) { + url := "/v4/posting/fbs/ship" + + resp := &PackOrderResponse{} + + response, err := c.client.Request(http.MethodPost, url, params, resp) + if err != nil { + return nil, err + } + response.CopyCommonResponse(&resp.CommonResponse) + + return resp, nil +} diff --git a/ozon/fbs_test.go b/ozon/fbs_test.go index ff49ff6..ec549d9 100644 --- a/ozon/fbs_test.go +++ b/ozon/fbs_test.go @@ -262,3 +262,51 @@ func TestGetFBSShipmentsList(t *testing.T) { } } } + +func TestPackOrder(t *testing.T) { + tests := []struct { + statusCode int + headers map[string]string + params *PackOrderParams + response string + }{ + { + http.StatusOK, + map[string]string{"Client-Id": "my-client-id", "Api-Key": "my-api-key"}, + &PackOrderParams{ + Packages: []PackOrderPackage{ + { + Products: []PackOrderPackageProduct{ + { + ProductId: 185479045, + Quantity: 1, + }, + }, + }, + }, + PostingNumber: "89491381-0072-1", + With: PackOrderWith{ + AdditionalData: true, + }, + }, + `{ + "result": [ + "89491381-0072-1" + ] + }`, + }, + } + + for _, test := range tests { + c := NewMockClient(core.NewMockHttpHandler(test.statusCode, test.response, test.headers)) + + resp, err := c.PackOrder(test.params) + if err != nil { + t.Error(err) + } + + if resp.StatusCode != test.statusCode { + t.Errorf("got wrong status code: got: %d, expected: %d", resp.StatusCode, test.statusCode) + } + } +} diff --git a/ozon/products.go b/ozon/products.go index 959905f..bcd0c45 100644 --- a/ozon/products.go +++ b/ozon/products.go @@ -616,3 +616,238 @@ func (c Client) UpdatePrices(params *UpdatePricesParams) (*UpdatePricesResponse, return resp, nil } + +type CreateOrUpdateProductParams struct { + // Data array + Items []CreateOrUpdateProductItem `json:"items"` +} + +// Data array +type CreateOrUpdateProductItem struct { + // Array with the product characteristics. The characteristics depend on category. + // You can view them in Help Center or via API + Attributes []CreateOrUpdateAttribute `json:"attributes"` + + // Product barcode + Barcode string `json:"barcode"` + + // Category identifier + CategoryId int64 `json:"category_id"` + + // Marketing color. + // + // Pass the link to the image in the public cloud storage. The image format is JPG + ColorImage string `json:"color_image"` + + // Array of characteristics that have nested attributes + ComplexAttributes []CreateOrUpdateComplexAttribute `json:"complex_attributes"` + + // Package depth + Depth int32 `json:"depth"` + + // Dimensions measurement units: + // - mm — millimeters, + // - cm — centimeters, + // - in — inches + DimensionUnit string `json:"dimension_unit"` + + // Geo-restrictions. Pass a list consisting of name values received in the response of the /v1/products/geo-restrictions-catalog-by-filter method + GeoNames []string `json:"geo_names"` + + // Package height + Height int32 `json:"height"` + + // Array of images, up to 15 files. The images are displayed on the site in the same order as they are in the array. + // + // The first one will be set as the main image for the product if the primary_image parameter is not specified. + // + // If you use the primary_image parameter, the maximum number of images is 14. If the primary_image parameter is not specified, you can upload up to 15 images. + // + // Pass links to images in the public cloud storage. The image format is JPG or PNG + Images []string `json:"images"` + + // Link to main product image + PrimaryImage string `json:"primary_image"` + + // Array of 360 images—up to 70 files. + // + // Pass links to images in the public cloud storage. The image format is JPG + Images360 []string `json:"images_360"` + + // Product name. Up to 500 characters + Name string `json:"name"` + + // Product identifier in the seller's system. + // + // The maximum length of a string is 50 characters + OfferId string `json:"offer_id"` + + // Currency of your prices. The passed value must be the same as the one set in the personal account settings. + // By default, the passed value is RUB, Russian ruble. + // + // For example, if your currency set in the settings is yuan, pass the value CNY, otherwise an error will be returned + CurrencyCode string `json:"currency_code"` + + // Price before discounts. Displayed strikethrough on the product description page. Specified in rubles. The fractional part is separated by decimal point, up to two digits after the decimal point. + // + // If you specified the old_price before and updated the price parameter you should update the old_price too + OldPrice string `json:"old_price"` + + // List of PDF files + PDFList []CreateOrUpdateProductPDF `json:"pdf_list"` + + // Price for customers with an Ozon Premium subscription + PremiumPrice string `json:"premium_price"` + + // Product price including discounts. This value is shown on the product description card. + // If there are no discounts on the product, specify the old_price value + Price string `json:"price"` + + // Default: "IS_CODE_SERVICE" + // Service type. Pass one of the values in upper case: + // - IS_CODE_SERVICE, + // - IS_NO_CODE_SERVICE + ServiceType string `json:"service_type"` + + // VAT rate for the product: + // - 0 — not subject to VAT, + // - 0.1 — 10%, + // - 0.2 — 20% + VAT string `json:"vat"` + + // Product weight with the package. The limit value is 1000 kilograms or a corresponding converted value in other measurement units + Weight int32 `json:"weight"` + + // Weight measurement units: + // - g—grams, + // - kg—kilograms, + // - lb—pounds + WeightUnit string `json:"weight_unit"` + + // Package width + Width int32 `json:"width"` +} + +// Array with the product characteristics. The characteristics depend on category. +// You can view them in Help Center or via API +type CreateOrUpdateAttribute struct { + // Identifier of the characteristic that supports nested properties. + // For example, the "Processor" characteristic has nested characteristics "Manufacturer", "L2 Cache", and others. + // Each of the nested characteristics can have multiple value variants + ComplexId int64 `json:"complex_id"` + + // Characteristic identifier + Id int64 `json:"id"` + + Values []CreateOrUpdateAttributeValue `json:"values"` +} + +type CreateOrUpdateAttributeValue struct { + // Directory identifier + DictionaryValueId int64 `json:"dictrionary_value_id"` + + // Value from the directory + Value string `json:"value"` +} + +type CreateOrUpdateComplexAttribute struct { + Attributes []CreateOrUpdateAttribute `json:"attributes"` +} + +type CreateOrUpdateProductPDF struct { + // Storage order index + Index int64 `json:"index"` + + // File name + Name string `json:"name"` + + // File address + URL string `json:"url"` +} + +type CreateOrUpdateProductResponse struct { + core.CommonResponse + + // Method result + Result struct { + // Number of task for products upload + TaskId int64 `json:"task_id"` + } `json:"result"` +} + +// This method allows you to create products and update their details +func (c Client) CreateOrUpdateProduct(params *CreateOrUpdateProductParams) (*CreateOrUpdateProductResponse, error) { + url := "/v2/product/import" + + resp := &CreateOrUpdateProductResponse{} + + response, err := c.client.Request(http.MethodPost, url, params, resp) + if err != nil { + return nil, err + } + response.CopyCommonResponse(&resp.CommonResponse) + + return resp, nil +} + +type GetListOfProductsParams struct { + // Filter by product + Filter GetListOfProductsFilter `json:"filter"` + + // Identifier of the last value on the page. Leave this field blank in the first request. + // + // To get the next values, specify last_id from the response of the previous request + LastId string `json:"last_id"` + + // Number of values per page. Minimum is 1, maximum is 1000 + Limit int64 `json:"limit"` +} + +type GetListOfProductsFilter struct { + // Filter by the offer_id parameter. You can pass a list of values in this parameter + OfferId []string `json:"offer_id"` + + // Filter by the product_id parameter. You can pass a list of values in this parameter + ProductId []int64 `json:"product_id"` + + // Filter by product visibility + Visibility string `json:"visibility"` +} + +type GetListOfProductsResponse struct { + core.CommonResponse + + // Result + Result struct { + // Products list + Items []struct { + // Product identifier in the seller's system + OfferId string `json:"offer_id"` + + // Product ID + ProductId int64 `json:"product_id"` + } `json:"items"` + + // Identifier of the last value on the page. + // + // To get the next values, specify the recieved value in the next request in the last_id parameter + LastId string `json:"last_id"` + + // Total number of products + Total int32 `json:"total"` + } `json:"result"` +} + +func (c Client) GetListOfProducts(params *GetListOfProductsParams) (*GetListOfProductsResponse, error) { + url := "/v2/product/list" + + resp := &GetListOfProductsResponse{} + + response, err := c.client.Request(http.MethodPost, url, params, resp) + if err != nil { + return nil, err + } + response.CopyCommonResponse(&resp.CommonResponse) + + return resp, nil +} diff --git a/ozon/products_test.go b/ozon/products_test.go index 25c41d5..7788f07 100644 --- a/ozon/products_test.go +++ b/ozon/products_test.go @@ -396,3 +396,203 @@ func TestUpdatePrices(t *testing.T) { } } } + +func TestUpdateQuantityStockProducts(t *testing.T) { + tests := []struct { + statusCode int + headers map[string]string + params *UpdateQuantityStockProductsParams + response string + }{ + { + http.StatusOK, + map[string]string{"Client-Id": "my-client-id", "Api-Key": "my-api-key"}, + &UpdateQuantityStockProductsParams{ + Stocks: []UpdateQuantityStockProductsStock{ + { + OfferId: "PH11042", + ProductId: 313455276, + Stock: 100, + WarehouseId: 22142605386000, + }, + }, + }, + `{ + "result": [ + { + "warehouse_id": 22142605386000, + "product_id": 118597312, + "offer_id": "PH11042", + "updated": true, + "errors": [] + } + ] + }`, + }, + } + + for _, test := range tests { + c := NewMockClient(core.NewMockHttpHandler(test.statusCode, test.response, test.headers)) + + resp, err := c.UpdateQuantityStockProducts(test.params) + if err != nil { + t.Error(err) + } + + if resp.StatusCode != test.statusCode { + t.Errorf("got wrong status code: got: %d, expected: %d", resp.StatusCode, test.statusCode) + } + } +} + +func TestCreateOrUpdateProduct(t *testing.T) { + tests := []struct { + statusCode int + headers map[string]string + params *CreateOrUpdateProductParams + response string + }{ + { + http.StatusOK, + map[string]string{"Client-Id": "my-client-id", "Api-Key": "my-api-key"}, + &CreateOrUpdateProductParams{ + Items: []CreateOrUpdateProductItem{ + { + Attributes: []CreateOrUpdateAttribute{ + { + ComplexId: 0, + Id: 5076, + Values: []CreateOrUpdateAttributeValue{ + { + DictionaryValueId: 971082156, + Value: "Стойка для акустической системы", + }, + }, + }, + { + ComplexId: 0, + Id: 9048, + Values: []CreateOrUpdateAttributeValue{ + { + Value: "Комплект защитных плёнок для X3 NFC. Темный хлопок", + }, + }, + }, + { + ComplexId: 0, + Id: 8229, + Values: []CreateOrUpdateAttributeValue{ + { + DictionaryValueId: 95911, + Value: "Комплект защитных плёнок для X3 NFC. Темный хлопок", + }, + }, + }, + { + ComplexId: 0, + Id: 85, + Values: []CreateOrUpdateAttributeValue{ + { + DictionaryValueId: 5060050, + Value: "Samsung", + }, + }, + }, + { + ComplexId: 0, + Id: 10096, + Values: []CreateOrUpdateAttributeValue{ + { + DictionaryValueId: 61576, + Value: "серый", + }, + }, + }, + }, + Barcode: "112772873170", + CategoryId: 17033876, + CurrencyCode: "RUB", + Depth: 10, + DimensionUnit: "mm", + Height: 250, + Name: "Комплект защитных плёнок для X3 NFC. Темный хлопок", + OfferId: "143210608", + OldPrice: "1100", + PremiumPrice: "900", + Price: "1000", + VAT: "0.1", + Weight: 100, + WeightUnit: "g", + Width: 150, + }, + }, + }, + `{ + "result": { + "task_id": 172549793 + } + }`, + }, + } + + for _, test := range tests { + c := NewMockClient(core.NewMockHttpHandler(test.statusCode, test.response, test.headers)) + + resp, err := c.CreateOrUpdateProduct(test.params) + if err != nil { + t.Error(err) + } + + if resp.StatusCode != test.statusCode { + t.Errorf("got wrong status code: got: %d, expected: %d", resp.StatusCode, test.statusCode) + } + } +} + +func TestGetListOfProducts(t *testing.T) { + tests := []struct { + statusCode int + headers map[string]string + params *GetListOfProductsParams + response string + }{ + { + http.StatusOK, + map[string]string{"Client-Id": "my-client-id", "Api-Key": "my-api-key"}, + &GetListOfProductsParams{ + Filter: GetListOfProductsFilter{ + OfferId: []string{"136748"}, + ProductId: []int64{223681945}, + Visibility: "ALL", + }, + LastId: "", + Limit: 100, + }, + `{ + "result": { + "items": [ + { + "product_id": 223681945, + "offer_id": "136748" + } + ], + "total": 1, + "last_id": "bnVсbA==" + } + }`, + }, + } + + for _, test := range tests { + c := NewMockClient(core.NewMockHttpHandler(test.statusCode, test.response, test.headers)) + + resp, err := c.GetListOfProducts(test.params) + if err != nil { + t.Error(err) + } + + if resp.StatusCode != test.statusCode { + t.Errorf("got wrong status code: got: %d, expected: %d", resp.StatusCode, test.statusCode) + } + } +}