Product catalog sync
Mirror WinShop products, prices, variants and barcodes into an e-shop and keep them up to date incrementally.
Goal: keep a copy of the WinShop product catalog in another system, typically an e-shop. Do one full load, then pick up only what changed.
You will use:
POST /api/products/filter-paginatedfor the list, with pricesGET /api/products/product-detailsfor variants and barcodes- a handful of item lists for names
Read Filtering and pagination first if you have not yet.
Before you start
Agree three things with the customer and put them in your configuration:
- The price level. WinShop keeps 15 selling prices per product, called price levels. Which one is the e-shop price is the customer's decision. See Catalog.
- Which products belong in the e-shop. All of them, or only some groups or seasons? The search filter can narrow it down.
- What "removed" means. Usually an inactive product is hidden in the e-shop rather than deleted.
Setup
Every step below uses one small helper for authenticated JSON calls. The snippets build on each other, in page order. In C#, keep the record declarations of all snippets at the end of your file. GetAccessTokenAsync() and getAccessToken() are the token cache from Authentication.
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json.Nodes;
var http = new HttpClient { BaseAddress = new Uri("https://<your-instance>") };
async Task<T> GetAsync<T>(string path)
{
using var request = new HttpRequestMessage(HttpMethod.Get, path);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", await GetAccessTokenAsync());
using var response = await http.SendAsync(request);
response.EnsureSuccessStatusCode();
return (await response.Content.ReadFromJsonAsync<T>())!;
}
async Task<T> PostAsync<T>(string path, object body)
{
using var request = new HttpRequestMessage(HttpMethod.Post, path) { Content = JsonContent.Create(body) };
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", await GetAccessTokenAsync());
using var response = await http.SendAsync(request);
response.EnsureSuccessStatusCode();
return (await response.Content.ReadFromJsonAsync<T>())!;
}
record Page<T>(List<T> Data, int CurrentPage, int TotalPages, int TotalCount, int PageSize,
bool HasPreviousPage, bool HasNextPage);The sync, step by step
Load the item lists
Products refer to colours, sizes and other reference values by id. Load those lists once per run and keep them in memory as lookup tables.
async Task<Dictionary<int, string?>> LoadNamesAsync(string list)
{
var rows = await GetAsync<List<NamedRow>>($"/api/item-lists/{list}");
return rows.ToDictionary(row => row.Id, row => row.Name);
}
var colors = await LoadNamesAsync("colors");
var sizes = await LoadNamesAsync("sizes");
var lengths = await LoadNamesAsync("lengths");
var dimensions = await LoadNamesAsync("dimensions");
record NamedRow(int Id, string? Name);Page through the changed products
Search the product list for a window of lastModify, sorted by lastModify, 500 rows per page, and ask only for the fields you store.
lastModifyis maintained by the server. It is set to the current time on every insert and every update of a product, so it is a reliable change marker. There is alsocreatedAt.filter.additional.lastModifyFromandlastModifyTobound the window. Both are inclusive:lastModify >= lastModifyFromandlastModify <= lastModifyTo. For the first full load, leavelastModifyFromout.limitFieldskeeps the response small. Name the price fields of your price level: for level 1 that isprice1andprice1WoVat(without VAT).includeInactive: trueasks for deactivated products too. They come withisInactive: true. You need them, otherwise you never learn that a product was switched off.- Sort by
productIdsecond, so that rows with the same timestamp have a fixed order.
Reading pages while the data changes
Other users keep working while you page. If a product on a page you have already read is saved again, its lastModify jumps to "now", it moves to the end of the list, and every row behind it shifts up by one. The first row of your next page is then never read.
Two rules together make the paging safe:
- Always set
lastModifyToto a moment that is already in the past on the instance. Then nothing can enter the window any more. A product that is saved during your run gets a newerlastModifyand can only leave the window. - Compare
totalCounton every page with the first page. A product that leaves the window lowerstotalCount. If the number changes, a shift happened: throw the pages away and read the window again.
The product that left is not lost. It now has a newer timestamp and belongs to the next run.
Do not take "a moment in the past" from your own clock, because it may differ from the clock of the instance. Ask the instance instead: the newest lastModify in the catalog is, by definition, not in its future. One row is enough, sorted descending. Step back one second from that value so that a save in the very same instant cannot slip into the window.
const int priceLevel = 1; // from your configuration
string[] fields =
[
"productId", "productCode", "productName", "barcode", "groupName", "unitName", "vatRate",
"isColorAndSize", "isInactive", "lastModify", $"price{priceLevel}", $"price{priceLevel}WoVat",
];
// The upper bound of a run: one second before the newest change on the instance.
async Task<DateTime?> GetUpperBoundAsync()
{
var page = await PostAsync<Page<JsonObject>>("/api/products/filter-paginated", new
{
filter = new { basic = new { includeInactive = true } },
pageNumber = 1,
pageSize = 1,
orderBy = new[] { "lastModify desc" },
limitFields = new[] { "lastModify" },
});
return page.Data.FirstOrDefault()?["lastModify"]?.GetValue<DateTime>().AddSeconds(-1);
}
async Task<List<JsonObject>> LoadProductsAsync(DateTime? modifiedFrom, DateTime modifiedTo)
{
while (true)
{
var products = new List<JsonObject>();
int? expectedCount = null;
var shifted = false;
for (var pageNumber = 1; ; pageNumber++)
{
var page = await PostAsync<Page<JsonObject>>("/api/products/filter-paginated", new
{
filter = new
{
basic = new { includeInactive = true },
additional = new { lastModifyFrom = modifiedFrom, lastModifyTo = modifiedTo },
},
pageNumber,
pageSize = 500,
orderBy = new[] { "lastModify", "productId" },
limitFields = fields,
});
expectedCount ??= page.TotalCount;
if (page.TotalCount != expectedCount)
{
shifted = true; // a product left the window: read the window again
break;
}
products.AddRange(page.Data);
if (!page.HasNextPage) break;
}
if (!shifted) return products;
}
}
// the first full load: no lower bound
if (await GetUpperBoundAsync() is { } upperBound)
{
var products = await LoadProductsAsync(modifiedFrom: null, upperBound);
foreach (var product in products)
{
var id = product["productId"]!.GetValue<int>();
var price = product[$"price{priceLevel}"]!.GetValue<decimal>();
// upsert into your system by id
}
await SaveWatermarkAsync(upperBound);
}SaveWatermarkAsync / saveWatermark and, later, ReadWatermarkAsync / readWatermark store and read one timestamp in your own storage. The watermark is the upper bound of the last run that succeeded.
A level price is the list price. If the e-shop should show the price one specific customer pays, including discounts and promotions, resolve it with Get products price instead.
Fetch variants and barcodes
The list row has one barcode and tells you with isColorAndSize whether the product is sold in variants. A variant is one combination of colour, size, length and dimension. The combinations and all the barcodes are in the product detail.
Fetch the details of the products you just loaded, in batches, with GET /api/products/product-details. Ask only for the sub-collections you need through details; here ColorSizeVariants and Barcodes. Keep the batches small enough for a URL, for example 50 ids.
async Task<List<ProductDetail>> LoadDetailsAsync(IEnumerable<int> productIds)
{
var details = new List<ProductDetail>();
foreach (var batch in productIds.Chunk(50))
{
// arrays repeat the key: productIds=1&productIds=2
var query = string.Join("&", batch.Select(id => $"productIds={id}"));
details.AddRange(await GetAsync<List<ProductDetail>>(
$"/api/products/product-details?{query}&details=ColorSizeVariants&details=Barcodes"));
}
return details;
}
async Task StoreDetailsAsync(IEnumerable<int> productIds)
{
foreach (var detail in await LoadDetailsAsync(productIds))
{
foreach (var variant in detail.ColorSizeVariants ?? [])
{
var color = variant.ColorId is { } colorId ? colors.GetValueOrDefault(colorId) : null;
var size = variant.SizeId is { } sizeId ? sizes.GetValueOrDefault(sizeId) : null;
// store the variant with its names
}
foreach (var barcode in (detail.Barcodes ?? []).Where(barcode => barcode.IsInvalid != true))
{
// store barcode.Barcode for the product, or for its variant when ColorId/SizeId are set
}
}
}
record ProductDetail(int ProductId, List<Variant>? ColorSizeVariants, List<ProductBarcode>? Barcodes);
record Variant(int Id, int? ColorId, int? SizeId, int? LengthId, int? DimensionId);
record ProductBarcode(int Id, string? Barcode, int? ColorId, int? SizeId, int? LengthId, int? DimensionId,
bool? IsMain, bool? IsInvalid);Run it incrementally
After the first full load, every run reads only the window since the last one.
- The lower bound is the stored watermark minus one minute. The overlap covers a save that was still in flight when the previous run took its upper bound.
- The upper bound is taken from the instance again, as in step 2, and becomes the new watermark when the run succeeded.
- The overlap returns some products twice. That is harmless as long as you upsert by
productId.
Timestamps are local time
lastModify is the wall-clock time of the instance without an offset, see
Conventions. When the clocks go back in autumn one hour repeats
and timestamps are briefly not in order, so run the comparison of step 5 after that night.
async Task SyncChangesAsync()
{
if (await GetUpperBoundAsync() is not { } upperBound) return; // empty catalog
DateTime? watermark = await ReadWatermarkAsync(); // null before the first full load
var changed = await LoadProductsAsync(watermark?.AddMinutes(-1), upperBound);
foreach (var product in changed)
{
// upsert by productId; hide the product when isInactive is true
}
// replaces the variants and barcodes of the changed products
await StoreDetailsAsync(changed.Select(product => product["productId"]!.GetValue<int>()));
// move the watermark only after everything above succeeded
await SaveWatermarkAsync(upperBound);
}How often to run depends on how fresh the e-shop has to be. Every few minutes is common for an incremental run that usually returns little.
Reconcile ids: inactive, deleted and missed products
Inactive. A product that is no longer sold is normally deactivated, not deleted. Deactivating is an update, so the product comes through the incremental run with isInactive: true. Hide it in the e-shop.
Deleted. A deleted product is simply gone. It cannot appear in a list of changes.
Missed. A product without a lastModify value never matches a lastModify window.
One comparison covers the last two. From time to time, for example once a night, load all product ids, which is cheap with limitFields, and compare them with the ids your system has:
- An id you have but WinShop does not: the product was deleted. Remove it, but only after it was missing in two runs in a row. The id list is read with ordinary paging, so one run can, rarely, miss an id while products are being created and deleted.
- An id WinShop has but you do not: load that product with
filter.basic.productIdand store it.
async Task<HashSet<int>> LoadAllProductIdsAsync()
{
while (true)
{
var ids = new HashSet<int>();
int? expectedCount = null;
var shifted = false;
for (var pageNumber = 1; ; pageNumber++)
{
var page = await PostAsync<Page<JsonObject>>("/api/products/filter-paginated", new
{
filter = new { basic = new { includeInactive = true } },
pageNumber,
pageSize = 5000,
orderBy = new[] { "productId" },
limitFields = new[] { "productId" },
});
expectedCount ??= page.TotalCount;
if (page.TotalCount != expectedCount)
{
shifted = true; // products were created or deleted: read again
break;
}
foreach (var row in page.Data) ids.Add(row["productId"]!.GetValue<int>());
if (!page.HasNextPage) break;
}
if (!shifted) return ids;
}
}
async Task ReconcileAsync(HashSet<int> knownIds)
{
var existing = await LoadAllProductIdsAsync();
foreach (var deletedId in knownIds.Except(existing))
{
// remove the product once it was missing in two runs in a row
}
foreach (var batch in existing.Except(knownIds).Chunk(500))
{
var page = await PostAsync<Page<JsonObject>>("/api/products/filter-paginated", new
{
filter = new { basic = new { includeInactive = true, productId = batch } },
pageNumber = 1,
pageSize = 500,
orderBy = new[] { "productId" },
limitFields = fields,
});
// upsert page.Data, then load their variants and barcodes
await StoreDetailsAsync(batch);
}
}knownIds is the set of product ids your own system currently holds.
Checklist
- Price level, product scope and the meaning of "removed" are agreed with the customer.
- Every list call sets
pageSizeandlimitFields. - Writes into your system are upserts keyed by
productId. - Every
lastModifywindow has an upper bound taken from the instance, and a run restarts whentotalCountchanges between pages. - The watermark is that upper bound, and it is moved only after a run succeeded.
- A nightly id comparison catches deletions and anything the windows missed.
- If a field name is rejected on one instance, check Fields differ between instances.