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:
@@ -1,7 +1,12 @@
|
||||
/** Brand as returned by Hiboutik `GET /brands`. */
|
||||
export interface BrandHiboutikDto {
|
||||
brand_id: number;
|
||||
brand_name: string;
|
||||
import { z } from 'zod';
|
||||
import { hiboutikId, hiboutikInt, hiboutikText } from './hiboutik.schema';
|
||||
|
||||
/** Brand as returned by Hiboutik `GET /brands` (only the fields used by the sync). */
|
||||
export const brandHiboutikSchema = z.object({
|
||||
brand_id: hiboutikId,
|
||||
brand_name: hiboutikText,
|
||||
/** 1 = enabled, 0 = disabled. */
|
||||
brand_enabled: number;
|
||||
}
|
||||
brand_enabled: hiboutikInt,
|
||||
});
|
||||
|
||||
export type BrandHiboutikDto = z.output<typeof brandHiboutikSchema>;
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
/** Category as returned by Hiboutik `GET /categories`. */
|
||||
export interface CategoryHiboutikDto {
|
||||
category_id: number;
|
||||
category_name: string;
|
||||
import { z } from 'zod';
|
||||
import { hiboutikId, hiboutikInt, hiboutikRef, hiboutikText } from './hiboutik.schema';
|
||||
|
||||
/** Category as returned by Hiboutik `GET /categories` (only the fields used by the sync). */
|
||||
export const categoryHiboutikSchema = z.object({
|
||||
category_id: hiboutikId,
|
||||
category_name: hiboutikText,
|
||||
/** Id of the parent category, 0 for a root category. */
|
||||
category_id_parent: number;
|
||||
category_id_parent: hiboutikRef,
|
||||
/** Enabled in the POS: 1 = yes, 0 = no. */
|
||||
category_enabled: number;
|
||||
category_enabled: hiboutikInt,
|
||||
/** Displayed on the website: 1 = yes, 0 = no. */
|
||||
category_enabled_www: number;
|
||||
category_enabled_www: hiboutikInt,
|
||||
/** Display order among its siblings. */
|
||||
category_position: number;
|
||||
category_bck_color: string;
|
||||
category_color: string;
|
||||
category_desc: string;
|
||||
accounting_account: string;
|
||||
category_ref_ext: string;
|
||||
}
|
||||
category_position: hiboutikInt,
|
||||
});
|
||||
|
||||
export type CategoryHiboutikDto = z.output<typeof categoryHiboutikSchema>;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
87
src/models/hiboutikDTO/hiboutik.schema.spec.ts
Normal file
87
src/models/hiboutikDTO/hiboutik.schema.spec.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { parseHiboutik } from './hiboutik.schema';
|
||||
import { customerAddressHiboutikSchema, fullCustomerHiboutikSchema } from './customer.hiboutik.dto';
|
||||
import { productHiboutikSchema } from './product.hiboutik.dto';
|
||||
import { saleHiboutikSchema } from './sale.hiboutik.dto';
|
||||
import { stockHiboutikSchema } from './stock.hiboutik.dto';
|
||||
|
||||
const parse = <T extends Parameters<typeof parseHiboutik>[0]>(schema: T, data: unknown) => parseHiboutik(schema, data, '/x');
|
||||
|
||||
describe('Hiboutik schemas', () => {
|
||||
const product = {
|
||||
product_id: 1,
|
||||
product_model: 'BT21 bottle',
|
||||
product_barcode: '8435497280048',
|
||||
product_brand: 1,
|
||||
product_supplier: 2,
|
||||
product_price: '13.99',
|
||||
product_discount_price: '0.00',
|
||||
product_category: 19,
|
||||
product_display_www: 1,
|
||||
product_arch: 0,
|
||||
products_desc: '',
|
||||
updated_at: '2026-06-28 17:02:55',
|
||||
product_specific_rules: [],
|
||||
};
|
||||
|
||||
it('accepts ids, references and flags sent as strings', () => {
|
||||
const parsed = parse(customerAddressHiboutikSchema, { address_id: ' 8 ', customers_id: '43', default: '0' });
|
||||
expect(parsed.address_id).toBe(8);
|
||||
expect(parsed.customers_id).toBe(43);
|
||||
expect(parsed.default).toBe(0);
|
||||
});
|
||||
|
||||
it('turns empty references into 0 and empty text into ""', () => {
|
||||
const parsed = parse(productHiboutikSchema, { ...product, product_brand: '', product_category: null, products_desc: null });
|
||||
expect(parsed.product_brand).toBe(0);
|
||||
expect(parsed.product_category).toBe(0);
|
||||
expect(parsed.products_desc).toBe('');
|
||||
});
|
||||
|
||||
it('keeps numeric text (zip code) and prices as strings', () => {
|
||||
expect(parse(customerAddressHiboutikSchema, { address_id: 1, zip_code: 75002 }).zip_code).toBe('75002');
|
||||
expect(parse(productHiboutikSchema, { ...product, product_price: 13.5 }).product_price).toBe('13.5');
|
||||
});
|
||||
|
||||
it('defaults missing lists to [] and strips unknown fields', () => {
|
||||
const parsed = parse(productHiboutikSchema, { ...product, product_specific_rules: '', product_supply_price: '7.49' });
|
||||
expect(parsed.product_specific_rules).toEqual([]);
|
||||
expect(parsed.product_size_details).toEqual([]);
|
||||
expect(parsed).not.toHaveProperty('product_supply_price');
|
||||
expect(parse(saleHiboutikSchema, { sale_id: 5, total: '0.00' }).line_items).toEqual([]);
|
||||
});
|
||||
|
||||
it('leaves addresses and sales undefined when they are not arrays, so that stored data is kept', () => {
|
||||
const parsed = parse(fullCustomerHiboutikSchema, { customers_id: 1, addresses: '', sales: { error: 'x' } });
|
||||
expect(parsed.addresses).toBeUndefined();
|
||||
expect(parsed.sales).toBeUndefined();
|
||||
});
|
||||
|
||||
it('parses stocks sent as decimal strings', () => {
|
||||
expect(parse(stockHiboutikSchema, { product_id: '1', product_size: '0', stock_available: '3.000' })).toEqual({
|
||||
product_id: 1,
|
||||
product_size: 0,
|
||||
stock_available: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a missing id', { ...product, product_id: undefined }],
|
||||
['a zero id', { ...product, product_id: 0 }],
|
||||
['a non-numeric id', { ...product, product_id: 'abc' }],
|
||||
['a negative reference', { ...product, product_brand: -1 }],
|
||||
['an invalid price', { ...product, product_price: 'free' }],
|
||||
['a list that is not an array', { ...product, product_size_details: { size_id: 1 } }],
|
||||
])('rejects %s', (_, data) => {
|
||||
expect(() => parse(productHiboutikSchema, data)).toThrow('Unexpected answer from Hiboutik /x');
|
||||
});
|
||||
|
||||
it('names the faulty fields, never their values', () => {
|
||||
expect.assertions(2);
|
||||
try {
|
||||
parse(fullCustomerHiboutikSchema, { customers_id: 'jane@example.com', addresses: [{ address_id: 'Jane Doe' }] });
|
||||
} catch (error) {
|
||||
expect((error as Error).message).toMatch(/customers_id.*addresses\.0\.address_id/);
|
||||
expect((error as Error).message).not.toMatch(/jane|Doe/i);
|
||||
}
|
||||
});
|
||||
});
|
||||
58
src/models/hiboutikDTO/hiboutik.schema.ts
Normal file
58
src/models/hiboutikDTO/hiboutik.schema.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Field types shared by the Hiboutik payload schemas. Hiboutik sends numbers either as JSON numbers or as numeric
|
||||
* strings, and empty values as "", null or a missing key: the schemas normalize all that, so that the rest of the
|
||||
* code gets real numbers and strings. Strict on what the sync relies on (identifiers, relations, prices),
|
||||
* lenient (empty string) on free text.
|
||||
*/
|
||||
|
||||
const trimmed = (value: unknown) => (typeof value === 'string' ? value.trim() : value);
|
||||
const emptyAsZero = (value: unknown) => {
|
||||
const v = trimmed(value);
|
||||
return v == null || v === '' ? 0 : v;
|
||||
};
|
||||
|
||||
/** Identifier of a Hiboutik entity (join key): a positive integer. */
|
||||
export const hiboutikId = z.preprocess(trimmed, z.coerce.number().int().positive());
|
||||
|
||||
/** Reference to another entity: its id, or 0 (also sent as "" or null) when none. */
|
||||
export const hiboutikRef = z.preprocess(emptyAsZero, z.coerce.number().int().nonnegative());
|
||||
|
||||
/** Integer, e.g. a 1/0 flag, a position or a quantity (0 when empty). */
|
||||
export const hiboutikInt = z.preprocess(emptyAsZero, z.coerce.number().int());
|
||||
|
||||
/** Number, e.g. a stock that may be sent as "3.000" (0 when empty). */
|
||||
export const hiboutikNumber = z.preprocess(emptyAsZero, z.coerce.number());
|
||||
|
||||
/** Free text, "" when empty. */
|
||||
export const hiboutikText = z.preprocess((value) => (value == null ? '' : typeof value === 'number' ? String(value) : value), z.string());
|
||||
|
||||
/** Required decimal kept as a string for Prisma `Decimal`, e.g. "13.99". */
|
||||
export const hiboutikDecimal = z.preprocess(
|
||||
(value) => (typeof value === 'number' ? String(value) : trimmed(value)),
|
||||
z.string().regex(/^-?\d+(\.\d+)?$/, 'decimal expected'),
|
||||
);
|
||||
|
||||
/** List that is empty when missing ("", null or no key). */
|
||||
export const hiboutikList = <T extends z.ZodType>(item: T) =>
|
||||
z.preprocess((value) => (value == null || value === '' ? [] : value), z.array(item));
|
||||
|
||||
/** List that is `undefined` when it is not an array: the caller then keeps the stored data instead of emptying it. */
|
||||
export const hiboutikOptionalList = <T extends z.ZodType>(item: T) =>
|
||||
z.preprocess((value) => (Array.isArray(value) ? value : undefined), z.array(item).optional());
|
||||
|
||||
/**
|
||||
* Validates a Hiboutik payload. The error names the faulty fields (path and issue code) but never their values,
|
||||
* which may be personal data.
|
||||
*/
|
||||
export function parseHiboutik<T extends z.ZodType>(schema: T, data: unknown, path: string): z.output<T> {
|
||||
const result = schema.safeParse(data);
|
||||
if (result.success) return result.data;
|
||||
|
||||
const issues = result.error.issues
|
||||
.slice(0, 5)
|
||||
.map((issue) => `${issue.path.join('.') || '(root)'} (${issue.code})`)
|
||||
.join(', ');
|
||||
throw new Error(`Unexpected answer from Hiboutik ${path}: invalid ${issues}`);
|
||||
}
|
||||
@@ -1,46 +1,51 @@
|
||||
/** Product as returned by Hiboutik `GET /products` (archived products included). */
|
||||
export interface ProductHiboutikDto {
|
||||
product_id: number;
|
||||
/** Product name. */
|
||||
product_model: string;
|
||||
/** Empty string when the product has no barcode. */
|
||||
product_barcode: string;
|
||||
/** Brand id, 0 when none. */
|
||||
product_brand: number;
|
||||
/** Supplier id, 0 when none. */
|
||||
product_supplier: number;
|
||||
/** Decimal as a string, e.g. "13.99". */
|
||||
product_price: string;
|
||||
/** "0.00" when the product has no discount. */
|
||||
product_discount_price: string;
|
||||
product_supply_price: string;
|
||||
/** Category id, 0 when none. */
|
||||
product_category: number;
|
||||
/** Displayed on the website: 1 = yes, 0 = no. */
|
||||
product_display_www: number;
|
||||
/** Archived: 1 = yes, 0 = no. */
|
||||
product_arch: number;
|
||||
products_desc: string;
|
||||
/** Local time, "YYYY-MM-DD HH:mm:ss". */
|
||||
updated_at: string;
|
||||
/** Per-size overrides of the product's price, discount and barcode. */
|
||||
product_specific_rules: ProductSpecificRulesHiboutikDto[];
|
||||
/** Sizes of the product; missing or empty for a product without sizes. */
|
||||
product_size_details?: ProductSizeDetailsHiboutikDto[];
|
||||
}
|
||||
import { z } from 'zod';
|
||||
import { hiboutikDecimal, hiboutikId, hiboutikInt, hiboutikList, hiboutikRef, hiboutikText } from './hiboutik.schema';
|
||||
|
||||
/** Override of a product's values for one size. Prices are "0.00" (or missing) when not overridden. */
|
||||
export interface ProductSpecificRulesHiboutikDto {
|
||||
size_id: number;
|
||||
barcode: string;
|
||||
product_price?: string;
|
||||
product_discount_price?: string;
|
||||
}
|
||||
export const productSpecificRulesHiboutikSchema = z.object({
|
||||
size_id: hiboutikRef,
|
||||
product_price: hiboutikText,
|
||||
product_discount_price: hiboutikText,
|
||||
});
|
||||
|
||||
/** One size of a product (e.g. "S", "M"). */
|
||||
export interface ProductSizeDetailsHiboutikDto {
|
||||
size_id: number;
|
||||
size_name: string;
|
||||
/** Empty string when the size has no barcode. */
|
||||
barcode: string;
|
||||
}
|
||||
export const productSizeDetailsHiboutikSchema = z.object({
|
||||
size_id: hiboutikId,
|
||||
size_name: hiboutikText,
|
||||
/** Empty when the size has no barcode. */
|
||||
barcode: hiboutikText,
|
||||
});
|
||||
|
||||
/** Product as returned by Hiboutik `GET /products`, archived products included (only the fields used by the sync). */
|
||||
export const productHiboutikSchema = z.object({
|
||||
product_id: hiboutikId,
|
||||
/** Product name. */
|
||||
product_model: hiboutikText,
|
||||
/** Empty when the product has no barcode. */
|
||||
product_barcode: hiboutikText,
|
||||
/** Brand id, 0 when none. */
|
||||
product_brand: hiboutikRef,
|
||||
/** Supplier id, 0 when none. */
|
||||
product_supplier: hiboutikRef,
|
||||
/** e.g. "13.99". */
|
||||
product_price: hiboutikDecimal,
|
||||
/** "0.00" when the product has no discount. */
|
||||
product_discount_price: hiboutikText,
|
||||
/** Category id, 0 when none. */
|
||||
product_category: hiboutikRef,
|
||||
/** Displayed on the website: 1 = yes, 0 = no. */
|
||||
product_display_www: hiboutikInt,
|
||||
/** Archived: 1 = yes, 0 = no. */
|
||||
product_arch: hiboutikInt,
|
||||
products_desc: hiboutikText,
|
||||
/** French local time, "YYYY-MM-DD HH:mm:ss". */
|
||||
updated_at: hiboutikText,
|
||||
/** Per-size overrides of the product's price, discount and barcode. */
|
||||
product_specific_rules: hiboutikList(productSpecificRulesHiboutikSchema),
|
||||
/** Sizes of the product; empty for a product without sizes. */
|
||||
product_size_details: hiboutikList(productSizeDetailsHiboutikSchema),
|
||||
});
|
||||
|
||||
export type ProductSpecificRulesHiboutikDto = z.output<typeof productSpecificRulesHiboutikSchema>;
|
||||
export type ProductSizeDetailsHiboutikDto = z.output<typeof productSizeDetailsHiboutikSchema>;
|
||||
export type ProductHiboutikDto = z.output<typeof productHiboutikSchema>;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { z } from 'zod';
|
||||
import { hiboutikId, hiboutikNumber, hiboutikRef } from './hiboutik.schema';
|
||||
|
||||
/** Available stock of one product size, as returned by Hiboutik `GET /stock_available/all_wh` (all warehouses). */
|
||||
export interface StockHiboutikDto {
|
||||
product_id: number;
|
||||
export const stockHiboutikSchema = z.object({
|
||||
product_id: hiboutikId,
|
||||
/** Size id, 0 for a product without sizes. */
|
||||
product_size: number;
|
||||
/** Decimal as a string, e.g. "3". Stays at 0 while stock management is disabled in Hiboutik. */
|
||||
stock_available: string;
|
||||
product_barcode: string;
|
||||
product_supplier_reference: string;
|
||||
}
|
||||
product_size: hiboutikRef,
|
||||
/** Sent as a decimal string, e.g. "3". Stays at 0 while stock management is disabled in Hiboutik. */
|
||||
stock_available: hiboutikNumber,
|
||||
});
|
||||
|
||||
export type StockHiboutikDto = z.output<typeof stockHiboutikSchema>;
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
/** Supplier as returned by Hiboutik `GET /suppliers`. */
|
||||
export interface SupplierHiboutikDto {
|
||||
supplier_id: number;
|
||||
supplier_name: string;
|
||||
import { z } from 'zod';
|
||||
import { hiboutikId, hiboutikInt, hiboutikText } from './hiboutik.schema';
|
||||
|
||||
/** Supplier as returned by Hiboutik `GET /suppliers` (only the fields used by the sync). */
|
||||
export const supplierHiboutikSchema = z.object({
|
||||
supplier_id: hiboutikId,
|
||||
supplier_name: hiboutikText,
|
||||
/** 1 = enabled, 0 = disabled. */
|
||||
supplier_enabled: number;
|
||||
}
|
||||
supplier_enabled: hiboutikInt,
|
||||
});
|
||||
|
||||
export type SupplierHiboutikDto = z.output<typeof supplierHiboutikSchema>;
|
||||
|
||||
Reference in New Issue
Block a user