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,91 +1,65 @@
/** Customer summary as returned by Hiboutik `GET /customers` (no addresses nor sales). */
export interface CustomerHiboutikDto {
customers_id: number;
last_name: string;
first_name: string;
company: string;
email: string;
phone: string;
vat: string;
country: string;
/** "YYYY-MM-DD", "0000-00-00" when unknown. */
date_of_birth: string;
customers_code: string;
loyalty_points: number;
intial_loyalty_points: number;
validity: string;
prepaid_purchases: string;
store_credit: string;
turnover: string;
orders: number;
customer_ref_ext: string;
last_order_date: string;
/** Local time, "YYYY-MM-DD HH:mm:ss". */
updated_at: string;
}
import { z } from 'zod';
import { hiboutikId, hiboutikInt, hiboutikOptionalList, hiboutikRef, hiboutikText } from './hiboutik.schema';
/** Full customer as returned by Hiboutik `GET /customer/:customers_id`, with its addresses and sales. */
export interface FullCustomerHiboutikDto extends CustomerHiboutikDto {
gender: number;
comments: string;
customers_id_referrer: number;
customers_misc: string;
accounting_account: string;
addresses: CustomerAddressHiboutikDto[];
sales: CustomerSaleHiboutikDto[];
tags: unknown[];
customers_referee: unknown[];
}
/** Customer summary as returned by Hiboutik `GET /customers`: only its id is used (no addresses nor sales). */
export const customerHiboutikSchema = z.object({
customers_id: hiboutikId,
});
/** Address of a customer, embedded in {@link FullCustomerHiboutikDto}. Name and phone can differ from the customer's. */
export interface CustomerAddressHiboutikDto {
address_id: number;
gender: string;
first_name: string;
last_name: string;
creation_date: string;
email: string;
phone: string;
company: string;
address: string;
zip_code: string;
city: string;
state: string;
country: string;
customers_id: number;
display: number;
editable: number;
/** Address of a customer, embedded in the full customer. Name and phone can differ from the customer's. */
export const customerAddressHiboutikSchema = z.object({
address_id: hiboutikId,
first_name: hiboutikText,
last_name: hiboutikText,
phone: hiboutikText,
company: hiboutikText,
address: hiboutikText,
zip_code: hiboutikText,
city: hiboutikText,
state: hiboutikText,
country: hiboutikText,
customers_id: hiboutikRef,
/** 1 for the customer's default address. */
default: number;
other: string;
}
default: hiboutikInt,
other: hiboutikText,
});
/**
* Address as returned by Hiboutik `GET /customers_addresses/:address_id`: the embedded address plus the company
* details and its last update. One API call per address, so only fetched for addresses with a company.
* details. One API call per address, so only fetched for addresses with a company.
*/
export interface CustomerAddressDetailsHiboutikDto extends CustomerAddressHiboutikDto {
tax_number: string;
company_number: string;
legal_status: string;
/** Local time, "YYYY-MM-DD HH:mm:ss". */
updated_at: string;
}
export const customerAddressDetailsHiboutikSchema = customerAddressHiboutikSchema.extend({
tax_number: hiboutikText,
company_number: hiboutikText,
legal_status: hiboutikText,
/** French local time, "YYYY-MM-DD HH:mm:ss". */
updated_at: hiboutikText,
});
/** Sale header embedded in {@link FullCustomerHiboutikDto}: no sale lines, they come from `GET /sales/:sale_id`. */
export interface CustomerSaleHiboutikDto {
sale_id: number;
created_at: string;
completed_at: string;
date_z: number;
store_id: number;
vendor_id: number;
unique_sale_id: string;
customer_id: number;
currency: string;
/** Decimal as a string. */
total: string;
points: number;
billing_address: number;
shipping_address: number;
}
/** Sale header embedded in the full customer: no sale lines, they come from `GET /sales/:sale_id`. */
export const customerSaleHiboutikSchema = z.object({
sale_id: hiboutikId,
});
/** Full customer as returned by Hiboutik `GET /customer/:customers_id`, with its addresses and sales. */
export const fullCustomerHiboutikSchema = z.object({
customers_id: hiboutikId,
last_name: hiboutikText,
first_name: hiboutikText,
email: hiboutikText,
phone: hiboutikText,
/** "YYYY-MM-DD", "0000-00-00" when unknown. */
date_of_birth: hiboutikText,
/** French local time, "YYYY-MM-DD HH:mm:ss". */
updated_at: hiboutikText,
/** `undefined` when not an array: the stored addresses are then kept. */
addresses: hiboutikOptionalList(customerAddressHiboutikSchema),
/** `undefined` when not an array: the orders sync is then skipped. */
sales: hiboutikOptionalList(customerSaleHiboutikSchema),
});
export type CustomerHiboutikDto = z.output<typeof customerHiboutikSchema>;
export type CustomerAddressHiboutikDto = z.output<typeof customerAddressHiboutikSchema>;
export type CustomerAddressDetailsHiboutikDto = z.output<typeof customerAddressDetailsHiboutikSchema>;
export type CustomerSaleHiboutikDto = z.output<typeof customerSaleHiboutikSchema>;
export type FullCustomerHiboutikDto = z.output<typeof fullCustomerHiboutikSchema>;