feat(sync): validate Hiboutik payloads, read French dates and guard deletions

- Hiboutik payloads are zod schemas, validated by HiboutikClient (getOne
  added for single resources: null on 404 or empty answer).
- Hiboutik dates are read as Europe/Paris whatever the server time zone.
- Paginated lists driving deletions are walked twice when they span several
  pages (brands, categories, products); categories are sorted by id.
- A customer missing from the list is deleted only once Hiboutik confirms
  it; completed orders that lost their customer are linked back.
- Each product and its versions are written in one transaction.
- An email removed in Hiboutik is erased in database.
- A 429 is retried with backoff, then stops the synchronization.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
MastersGame
2026-11-08 12:20:58 +01:00
parent 4b0cda1740
commit 35a7c27cb7
44 changed files with 1065 additions and 467 deletions

View File

@@ -1,40 +1,39 @@
/**
* Sale as returned by Hiboutik `GET /sales/:sale_id`, with its lines.
* Only the fields used by the sync are typed: the payload also carries many computed copies (dates split in parts,
* totals by tax, customer and store details...).
*/
export interface SaleHiboutikDto {
sale_id: number;
/** Local time, "YYYY-MM-DD HH:mm:ss". */
created_at: string;
/** Local time, "0000-00-00 00:00:00" while the sale is not completed. */
completed_at: string;
store_id: number;
customer_id: number;
currency: string;
/** Decimal as a string, taxes included. */
total: string;
/** Payment type code, e.g. "CB", "ESP", "WEB". */
payment: string;
/** 1 for a quotation. */
quotation: number;
line_items: SaleLineHiboutikDto[];
}
import { z } from 'zod';
import { hiboutikDecimal, hiboutikId, hiboutikInt, hiboutikList, hiboutikRef, hiboutikText } from './hiboutik.schema';
/** Line of a {@link SaleHiboutikDto}: a snapshot of the product at sale time. */
export interface SaleLineHiboutikDto {
line_item_id: number;
product_id: number;
/** Line of a sale: a snapshot of the product at sale time. */
export const saleLineHiboutikSchema = z.object({
product_id: hiboutikRef,
/** Hiboutik size id, 0 for a product without sizes. */
product_size: number;
product_size: hiboutikRef,
/** Negative for a returned item. */
quantity: number;
product_model: string;
product_barcode: string;
quantity: hiboutikInt,
product_model: hiboutikText,
product_barcode: hiboutikText,
/** Size name, empty for a product without sizes. */
size_name: string;
/** Unit price before discount, taxes included (decimal as a string). */
item_unit_gross_wo_discount: string;
/** Unit price actually paid, discount deducted, taxes included (decimal as a string). */
item_unit_gross: string;
}
size_name: hiboutikText,
/** Unit price actually paid, discount deducted, taxes included. */
item_unit_gross: hiboutikDecimal,
});
/**
* Sale as returned by Hiboutik `GET /sales/:sale_id`, with its lines. Only the fields used by the sync: the payload
* also carries many computed copies (dates split in parts, totals by tax, customer and store details...).
*/
export const saleHiboutikSchema = z.object({
sale_id: hiboutikId,
/** French local time, "YYYY-MM-DD HH:mm:ss". */
created_at: hiboutikText,
/** French local time, "0000-00-00 00:00:00" while the sale is not completed. */
completed_at: hiboutikText,
/** 0 for an anonymous sale. */
customer_id: hiboutikRef,
/** Taxes included. */
total: hiboutikDecimal,
/** 1 for a quotation. */
quotation: hiboutikInt,
line_items: hiboutikList(saleLineHiboutikSchema),
});
export type SaleLineHiboutikDto = z.output<typeof saleLineHiboutikSchema>;
export type SaleHiboutikDto = z.output<typeof saleHiboutikSchema>;