Catalog and pricing
Brands, the category tree, products and variants with their three customer-type prices, filterable attributes, visibility rules, favorites and price history; V2 price agreements.
The B2B catalog (cahier §4.3, §5.1, §5.2, §15.4 — CAT, PRC). Prisma file: catalog.prisma.
Pricing in V1 (decision of 17 Oct, PRC-01, PRC-02): every ProductVariant holds three HT selling prices, one per CustomerType. The reseller's customerType picks the column; promotions apply on top; guests see no price at all (AUTH-10). There are no segment price lists and no quantity tiers in V1; per-reseller agreements come in V2 (PRC-03).
| Customer type | Column | Typical position |
|---|---|---|
SUPER_GROS (Super-gros) | priceSuperGros | lowest |
DEMI_GROS (Demi-gros) | priceDemiGros | middle |
DETAIL (Détail) | priceDetail | highest |
Advanced filters (CAT-12, CAT-13): categories form a tree (Category.parentId, categories and sub-categories); each category declares its filterable attributes (AttributeDefinition) and each variant stores typed values (VariantAttributeValue). catalog.facets counts products per value with the indexes listed on that table; price-range facets use the caller's price column and are only offered to signed-in resellers.
Stock shown to resellers and guests is only IN_STOCK / OUT_OF_STOCK; quantities stay in the back-office (stock).
Catalog
Brand
A brand distributed by HOCO — HOCO, ACEFAST, BOROFONE — managed as data (CAT-01). Table brands · written by catalog (seeded; admin.brands.crud, BO-29).
| Field | Prisma type | Null | Default | Constraints / notes |
|---|---|---|---|---|
id | String @db.Uuid | — | uuid(7) | PK. |
name | String @db.VarChar(60) | — | — | Unique. |
slug | String @db.VarChar(60) | — | — | Unique. |
logoFileId | String @db.Uuid | yes | — | FK → File · SetNull. Purpose BRAND_LOGO (public CDN). |
description | String | yes | — | — |
sortOrder | Int | — | 0 | — |
isActive | Boolean | — | true | Inactive brands disappear from catalog.brands and filters; their products stay referenced. |
archivedAt | DateTime | yes | — | DELETE on a referenced brand archives it (endpoints.json). |
createdAt | DateTime | — | now() | — |
updatedAt | DateTime | — | @updatedAt | — |
- Relations:
logoFile→ File (logoFileId,onDelete: SetNull). - Referenced by: Product.
brandId, InventorySession.brandId, PromotionScope.brandId. - Keys & indexes:
nameunique ·slugunique.
Example row:
{
"id": "01a15db0-5680-7d82-83b0-3aeda0783e3d",
"name": "HOCO",
"slug": "hoco",
"logoFileId": "01a19b48-2c33-72b2-8fe3-139325991f21",
"description": "Accessoires mobiles : chargeurs, câbles, audio, powerbanks.",
"sortOrder": 1,
"isActive": true,
"archivedAt": null,
"createdAt": "2026-10-21T09:00:00.000Z",
"updatedAt": "2026-10-21T09:00:00.000Z"
}Category
Node of the category tree (CAT-02): top-level categories (chargers, cables, earphones, power banks, car holders, audio, watches, accessories) and their sub-categories through parentId (CAT-12 filters by category and sub-category). Table categories · written by catalog (seeded; admin.categories.*, BO-30).
| Field | Prisma type | Null | Default | Constraints / notes |
|---|---|---|---|---|
id | String @db.Uuid | — | uuid(7) | PK. |
parentId | String @db.Uuid | yes | — | FK → Category · Restrict. null for a top-level category, else the parent category (sub-category). Depth ≤ 3; the service rejects cycles. Products may be attached to any level; a filter on a category includes its sub-categories. |
name | String | — | — | — |
nameAr | String | yes | — | — |
nameEn | String | yes | — | — |
slug | String @db.VarChar(80) | — | — | Unique. |
iconKey | String @db.VarChar(40) | yes | — | Icon name from the design system (no uploaded image). |
sortOrder | Int | — | 0 | — |
isFeatured | Boolean | — | false | Curated catégories populaires tile on the reseller home (CAT-11). |
isActive | Boolean | — | true | — |
archivedAt | DateTime | yes | — | — |
createdAt | DateTime | — | now() | — |
updatedAt | DateTime | — | @updatedAt | — |
- Relations:
parent→ Category (parentId,onDelete: Restrict). - Referenced by: Category.
parentId, Product.categoryId, AttributeDefinition.categoryId, InventorySession.categoryId, PromotionScope.categoryId. - Keys & indexes:
slugunique ·@@index([parentId, sortOrder]). - Derived, not stored: product counts per category visible to the caller (
catalog.categories) — counted per request through the visibility rules and cached 5 min per segment; descendants of a node — resolved from the cached tree (≈ 50 rows), no closure table.
Constraints and indexes Prisma cannot express (raw SQL in the migration):
ALTER TABLE categories ADD CONSTRAINT categories_not_own_parent CHECK (parent_id <> id);Example row:
{
"id": "01a15db0-5680-7eea-aabe-b4ba8c5fd08d",
"parentId": null,
"name": "Câbles",
"nameAr": "كابلات",
"nameEn": "Cables",
"slug": "cables",
"iconKey": "cable",
"sortOrder": 2,
"isFeatured": true,
"isActive": true,
"archivedAt": null,
"createdAt": "2026-10-21T09:00:00.000Z",
"updatedAt": "2026-10-21T09:00:00.000Z"
}Product
A catalog reference with the minimum data of cahier §15.4 (CAT-03). Sellable units, prices and stock live on ProductVariant. Table products · written by catalog (admin.products.*, imports); inventory flips OUT_OF_STOCK automatically; analytics refreshes popularityScore.
| Field | Prisma type | Null | Default | Constraints / notes |
|---|---|---|---|---|
id | String @db.Uuid | — | uuid(7) | PK. |
brandId | String @db.Uuid | — | — | FK → Brand · Restrict. |
categoryId | String @db.Uuid | — | — | FK → Category · Restrict. |
reference | String @db.VarChar(40) | — | — | Manufacturer model reference (référence), e.g. X88. Unique per brand. |
name | String | — | — | French name (V1 language). |
nameAr | String | yes | — | — |
nameEn | String | yes | — | — |
slug | String @db.VarChar(120) | — | — | Unique. Used in share links. |
description | String | yes | — | — |
descriptionAr | String | yes | — | — |
descriptionEn | String | yes | — | — |
specs | Json | — | [] | Technical characteristics (caractéristiques): array of key, label, value, optional unit, in display order; Zod schema ProductSpecs. |
warrantyMonths | Int | yes | — | Garantie (§15.4). |
status | ProductStatus | — | DRAFT | — |
isFeatured | Boolean | — | false | Eligible for produits recommandés (CAT-11). |
popularityScore | Int | — | 0 | Cache: units delivered over the last 90 days, refreshed nightly; sort key popularité. |
publishedAt | DateTime | yes | — | First time the product became ACTIVE; sort key nouveauté. |
announceAsNew | Boolean | — | true | BO-28 « Annoncer comme nouveauté »: the first publication enters the new-arrivals digest product.arrived (SN-01). |
createdById | String @db.Uuid | yes | — | FK → User · SetNull. |
createdAt | DateTime | — | now() | — |
updatedAt | DateTime | — | @updatedAt | — |
- Relations:
brand→ Brand (brandId,onDelete: Restrict) ·category→ Category (categoryId,onDelete: Restrict) ·createdBy→ User (createdById,onDelete: SetNull). - Referenced by: ProductVariant.
productId, ProductImage.productId, ProductVisibilityRule.productId, Favorite.productId, OrderItem.productId, PromotionScope.productId. - Keys & indexes:
slugunique ·@@unique([brandId, reference])·@@index([status, categoryId])·@@index([status, brandId])·@@index([status, popularityScore(sort: Desc)])·@@index([status, publishedAt(sort: Desc)]). - Enums: ProductStatus.
- Lifecycle:
DRAFT → ACTIVE ⇄ HIDDEN,ACTIVE ⇄ OUT_OF_STOCK(automatic when every variant's available stock is 0, back on restock), any →ARCHIVED. OnlyACTIVEandOUT_OF_STOCKproducts are visible to resellers (subject to visibility rules);OUT_OF_STOCKones cannot be added to the cart. - History: Status changes and edits → AuditLog; price changes are on the variants (PriceChange).
- Derived, not stored: similar and complementary products (CAT-08) — computed from the category at read time; en promotion flag — active Promotion scopes; the caller's price — the variant price of the reseller's
customerType, then promotions (nullfor guests, AUTH-10); availability —IN_STOCK/OUT_OF_STOCKfrom StockLevel (quantities never leave the back-office, STK-07).
Constraints and indexes Prisma cannot express (raw SQL in the migration):
-- catalog search by name / reference (CAT-07, under 3 s)
CREATE INDEX products_name_trgm ON products USING gin (f_unaccent(name) gin_trgm_ops);
CREATE INDEX products_reference_trgm ON products USING gin (f_unaccent(reference) gin_trgm_ops);Example row:
{
"id": "01a18298-9480-70cc-80a7-8ddb761bc4d8",
"brandId": "01a15db0-5680-7d82-83b0-3aeda0783e3d",
"categoryId": "01a15db0-5680-7eea-aabe-b4ba8c5fd08d",
"reference": "X88",
"name": "Câble X88 USB-A vers USB-C 3 A",
"nameAr": null,
"nameEn": "X88 USB-A to USB-C cable 3 A",
"slug": "hoco-x88-usb-a-usb-c",
"description": "Câble tressé, charge rapide 3 A, transfert de données.",
"descriptionAr": null,
"descriptionEn": null,
"specs": [
{
"key": "length",
"label": "Longueur",
"value": "1",
"unit": "m"
},
{
"key": "current",
"label": "Intensité max",
"value": "3",
"unit": "A"
},
{
"key": "material",
"label": "Gaine",
"value": "Nylon tressé"
}
],
"warrantyMonths": 6,
"status": "ACTIVE",
"isFeatured": false,
"popularityScore": 1840,
"publishedAt": "2026-10-29T08:00:00.000Z",
"createdById": "01a19f89-c802-701b-83ec-271e6091a1ed",
"createdAt": "2026-10-28T13:00:00.000Z",
"updatedAt": "2026-11-01T07:00:00.000Z"
}ProductVariant
The sellable unit (SKU): a colour/length/capacity variant of a product, carrying its three selling prices (one per CustomerType), VAT and quantity limits. Every product has at least one (isDefault). Table product_variants · written by catalog (variant data, limits, attribute values); pricing (the price columns, always with a PriceChange).
| Field | Prisma type | Null | Default | Constraints / notes |
|---|---|---|---|---|
id | String @db.Uuid | — | uuid(7) | PK. |
productId | String @db.Uuid | — | — | FK → Product · Cascade. |
sku | String @db.VarChar(40) | — | — | Unique. Internal stock-keeping unit, natural key of imports. |
barcode | String @db.VarChar(32) | yes | — | Unique. EAN-13/UPC; looked up by catalog.products.byBarcode (CAT-10). |
name | String @db.VarChar(80) | — | — | Variant label shown after the product name; the structured values are in VariantAttributeValue. |
priceSuperGros | Int | — | — | Unit price for SUPER_GROS resellers, HT centimes (PRC-01). |
priceDemiGros | Int | — | — | Unit price for DEMI_GROS resellers, HT centimes. |
priceDetail | Int | — | — | Unit price for DETAIL resellers, HT centimes. |
recommendedPrice | Int | yes | — | Recommended retail unit price (prix conseillé), HT centimes; displayed TTC. |
purchasePrice | Int | yes | — | Purchase cost, HT centimes. Restricted (ACL-04): returned only to holders of pricing.view + finance.view; never to resellers, delegates or guests. |
vatRate | Int | — | 1900 | Basis points (1900 = 19 %). |
minQty | Int | — | 1 | Minimum quantity per order line. |
maxQtyPerOrder | Int | yes | — | Maximum per order; null = no limit beyond stock. |
qtyStep | Int | — | 1 | Allowed quantities: minQty + k × qtyStep. |
weightGrams | Int | yes | — | Shipping weight of one unit, grams; CHECK > 0 (API weightGrams, import and export column weight_g — SRV-42). |
isDefault | Boolean | — | false | Exactly one default variant per product. |
isActive | Boolean | — | true | Inactive variants are hidden and not orderable. |
archivedAt | DateTime | yes | — | API status ARCHIVED (SRV-42): inactive for good, kept for its orders and price history, out of the price grid. isActive = false without it is HIDDEN. Set by status: ARCHIVED or by deleting a referenced variant; any other status clears it. Backfilled from the audit trail of archived variants. |
sortOrder | Int | — | 0 | — |
createdAt | DateTime | — | now() | — |
updatedAt | DateTime | — | @updatedAt | — |
- Relations:
product→ Product (productId,onDelete: Cascade). - Referenced by: ProductImage.
variantId, VariantAttributeValue.variantId, PriceChange.variantId, PriceListItem.variantId, StockLevel.variantId, StockMovement.variantId, InventoryLine.variantId, CartItem.variantId, OrderItem.variantId, ReturnItem.variantId, PromotionScope.variantId, PackItem.variantId. - Keys & indexes:
skuunique ·barcodeunique ·@@index([productId, sortOrder])·@@index([priceSuperGros])·@@index([priceDemiGros])·@@index([priceDetail]). - History: Every change of a selling price,
recommendedPrice,purchasePriceorvatRatewrites a PriceChange in the same transaction (PRC-01). - Derived, not stored: the caller's price — the column of the reseller's
customerType(SUPER_GROS→priceSuperGros,DEMI_GROS→priceDemiGros,DETAIL→priceDetail), then promotions (PRC-02); guests getnull(AUTH-10). TTC =round(HT × (10000 + vatRate) / 10000)at read time. - Derived, not stored: availability (
IN_STOCK/OUT_OF_STOCK) — from StockLevel; the price-range facet uses the column of the caller's type (signed-in callers only, CAT-12). - The editor warns (no CHECK) when the prices are not ordered
priceSuperGros ≤ priceDemiGros ≤ priceDetail, the normal shape of the three tiers. Quantity tiers are not part of V1 (dropped by the 17 Oct decision).
Constraints and indexes Prisma cannot express (raw SQL in the migration):
ALTER TABLE product_variants ADD CONSTRAINT product_variants_qty_rules CHECK (
min_qty >= 1 AND qty_step >= 1 AND (max_qty_per_order IS NULL OR max_qty_per_order >= min_qty));
ALTER TABLE product_variants ADD CONSTRAINT product_variants_prices CHECK (
price_super_gros >= 0 AND price_demi_gros >= 0 AND price_detail >= 0
AND (recommended_price IS NULL OR recommended_price >= 0)
AND (purchase_price IS NULL OR purchase_price >= 0) AND vat_rate BETWEEN 0 AND 10000);
CREATE UNIQUE INDEX product_variants_one_default ON product_variants (product_id) WHERE is_default;
CREATE INDEX product_variants_sku_trgm ON product_variants USING gin (sku gin_trgm_ops);Example row:
{
"id": "01a18298-9480-7f48-8522-52331c7620e9",
"productId": "01a18298-9480-70cc-80a7-8ddb761bc4d8",
"sku": "HOCO-X88-C-BK-1M",
"barcode": "6931474783042",
"name": "Noir · 1 m",
"priceSuperGros": 31000,
"priceDemiGros": 35000,
"priceDetail": 39000,
"recommendedPrice": 50000,
"purchasePrice": 21000,
"vatRate": 1900,
"minQty": 5,
"maxQtyPerOrder": 500,
"qtyStep": 5,
"isDefault": true,
"isActive": true,
"sortOrder": 0,
"createdAt": "2026-10-28T13:00:00.000Z",
"updatedAt": "2026-11-01T07:00:00.000Z"
}ProductImage
An image of the product gallery, optionally specific to a variant. Table product_images · written by catalog.
| Field | Prisma type | Null | Default | Constraints / notes |
|---|---|---|---|---|
id | String @db.Uuid | — | uuid(7) | PK. |
productId | String @db.Uuid | — | — | FK → Product · Cascade. |
variantId | String @db.Uuid | yes | — | FK → ProductVariant · SetNull. |
fileId | String @db.Uuid | — | — | Unique. FK → File · Restrict. Purpose PRODUCT_IMAGE (public CDN, ≤ 5 MB). |
sortOrder | Int | — | 0 | 0 = cover image. |
altText | String | yes | — | — |
createdAt | DateTime | — | now() | — |
updatedAt | DateTime | — | @updatedAt | — |
- Relations:
product→ Product (productId,onDelete: Cascade) ·variant→ ProductVariant (variantId,onDelete: SetNull) ·file→ File (fileId,onDelete: Restrict). - Keys & indexes:
fileIdunique ·@@index([productId, sortOrder]). - Removing an image from the gallery deletes the row (not a business record) and marks the File
DELETED.
Example row:
{
"id": "01a1829d-2860-727f-9b75-049df0576d13",
"productId": "01a18298-9480-70cc-80a7-8ddb761bc4d8",
"variantId": null,
"fileId": "01a19b67-9409-7eb8-9344-665d55c91f89",
"sortOrder": 0,
"altText": "Câble HOCO X88 noir",
"createdAt": "2026-10-28T13:05:00.000Z",
"updatedAt": "2026-10-28T13:05:00.000Z"
}ProductComplement
A complementary product set on BO-28 (« Souvent achetés avec », SN-14, NOT-15): listed first in catalog.products.similar and in the product detail's complements, and suggested the day after an order (complement.suggested). Table product_complements · written by catalog (admin.products.update, complementProductIds).
| Field | Prisma type | Null | Default | Constraints / notes |
|---|---|---|---|---|
id | String @db.Uuid | — | uuid(7) | PK. |
productId | String @db.Uuid | — | — | FK → Product · Cascade. |
complementProductId | String @db.Uuid | — | — | FK → Product · Cascade. Never the product itself (CHECK). |
position | Int | — | 0 | Display order; at most 5 per product (API rule). |
createdById | String @db.Uuid | yes | — | FK → User · SetNull. |
createdAt | DateTime | — | now() | — |
- Relations:
productandcomplementProduct→ Product (onDelete: Cascade) ·createdBy→ User (onDelete: SetNull). - Keys & indexes:
@@unique([productId, complementProductId])·@@index([productId, position])·@@index([complementProductId]).
Constraints and indexes Prisma cannot express (raw SQL in the migration):
ALTER TABLE product_complements ADD CONSTRAINT product_complements_not_self
CHECK (product_id <> complement_product_id);Filterable attributes
AttributeDefinition
A filterable product attribute defined for a category (CAT-13) — e.g. power (W), connector, cable length (m), colour, battery capacity (mAh), Bluetooth version. It applies to the products of the category and of its sub-categories. Table attribute_definitions · written by catalog (admin.attributes.crud, BO-71).
| Field | Prisma type | Null | Default | Constraints / notes |
|---|---|---|---|---|
id | String @db.Uuid | — | uuid(7) | PK. |
categoryId | String @db.Uuid | — | — | FK → Category · Restrict. |
key | String @db.VarChar(40) | — | — | Stable snake_case key used in filter query strings (e.g. attr.connector=USB_C); never reused for another meaning. |
labelFr | String | — | — | — |
labelAr | String | yes | — | — |
labelEn | String | yes | — | — |
type | AttributeType | — | — | — |
unit | String @db.VarChar(12) | yes | — | Display unit of NUMBER attributes: W, mAh, m, A… |
allowedValues | Json | yes | — | ENUM only: ordered array of value (stable UPPER_SNAKE code), labelFr, optional labelAr, labelEn, colorHex (swatches). Zod schema AttributeAllowedValues; required for ENUM, null otherwise (CHECK). |
isFilterable | Boolean | — | true | Shown as a facet in catalog.facets and accepted as a filter. |
filterWidget | AttributeFilterWidget | — | CHECKBOXES | RANGE only for NUMBER, TOGGLE only for BOOLEAN (validated by the service). |
isRequired | Boolean | — | false | A variant of the category cannot be published without a value. |
minValue | Decimal @db.Decimal(12, 2) | yes | — | NUMBER only: lowest accepted value; checked on variant writes and product imports (SRV-41). CHECK min_value <= max_value. |
maxValue | Decimal @db.Decimal(12, 2) | yes | — | NUMBER only: highest accepted value. |
showOnProductSheet | Boolean | — | true | Listed in the product sheet's specifications; the filters and the variant choice keep it either way (SRV-41). |
sortOrder | Int | — | 0 | Facet order in the filter sheet. |
archivedAt | DateTime | yes | — | Archived definitions disappear from facets; their values stay for history. |
createdAt | DateTime | — | now() | — |
updatedAt | DateTime | — | @updatedAt | — |
- Relations:
category→ Category (categoryId,onDelete: Restrict). - Referenced by: VariantAttributeValue.
attributeId. - Keys & indexes:
@@unique([categoryId, key])·@@index([categoryId, sortOrder]). - Enums: AttributeType (proposed), AttributeFilterWidget (proposed).
- A key defined on a category must not be redefined on one of its sub-categories (service check), so a product inherits at most one definition per key.
Constraints and indexes Prisma cannot express (raw SQL in the migration):
ALTER TABLE attribute_definitions ADD CONSTRAINT attribute_definitions_enum_values
CHECK ((type = 'ENUM') = (allowed_values IS NOT NULL));Example row:
{
"id": "01a17de0-1580-7b30-9ac8-37a0752a1008",
"categoryId": "01a15db0-5680-7eea-aabe-b4ba8c5fd08d",
"key": "connector",
"labelFr": "Connecteur",
"labelAr": "الموصل",
"labelEn": "Connector",
"type": "ENUM",
"unit": null,
"allowedValues": [
{
"value": "USB_C",
"labelFr": "USB-C"
},
{
"value": "LIGHTNING",
"labelFr": "Lightning"
},
{
"value": "MICRO_USB",
"labelFr": "Micro-USB"
}
],
"isFilterable": true,
"filterWidget": "CHECKBOXES",
"isRequired": true,
"sortOrder": 1,
"archivedAt": null,
"createdAt": "2026-10-27T15:00:00.000Z",
"updatedAt": "2026-10-27T15:00:00.000Z"
}VariantAttributeValue
The value of one attribute for one variant — the data behind the advanced filters and their counts (CAT-12, CAT-13). Table variant_attribute_values · written by catalog (variant editor, product import).
| Field | Prisma type | Null | Default | Constraints / notes |
|---|---|---|---|---|
id | String @db.Uuid | — | uuid(7) | PK. |
variantId | String @db.Uuid | — | — | FK → ProductVariant · Cascade. |
attributeId | String @db.Uuid | — | — | FK → AttributeDefinition · Restrict. |
valueText | String @db.VarChar(120) | yes | — | TEXT value, or the allowedValues[].value code of an ENUM. |
valueNumber | Decimal @db.Decimal(12,3) | yes | — | NUMBER value in the definition's unit (decimals allowed: 1.5 m, Bluetooth 5.3). |
valueBool | Boolean | yes | — | BOOLEAN value. |
createdAt | DateTime | — | now() | — |
updatedAt | DateTime | — | @updatedAt | — |
- Relations:
variant→ ProductVariant (variantId,onDelete: Cascade) ·attribute→ AttributeDefinition (attributeId,onDelete: Restrict). - Keys & indexes:
@@unique([variantId, attributeId])·@@index([attributeId, valueText])·@@index([attributeId, valueNumber])·@@index([attributeId, valueBool]). - Facet queries (
catalog.facets): over the visible variants matching the current filters,GROUP BY attribute_id, value_text(or bucketedvalue_number,value_bool) withcount(DISTINCT product_id); the three(attributeId, value…)indexes serve both the counts and the filters (oneEXISTSper selected attribute). Results are cached 5 min per customer type and filter set. - A product is listed once even when several variants match; it matches a filter when any active variant matches.
Constraints and indexes Prisma cannot express (raw SQL in the migration):
ALTER TABLE variant_attribute_values ADD CONSTRAINT variant_attribute_values_one_value
CHECK (num_nonnulls(value_text, value_number, value_bool) = 1);Example row:
{
"id": "01a18298-9480-7ad0-a909-0f89909d4b39",
"variantId": "01a18298-9480-7f48-8522-52331c7620e9",
"attributeId": "01a17de0-1580-7b30-9ac8-37a0752a1008",
"valueText": "USB_C",
"valueNumber": null,
"valueBool": null,
"createdAt": "2026-10-28T13:00:00.000Z",
"updatedAt": "2026-10-28T13:00:00.000Z"
}Visibility and favorites
ProductVisibilityRule
Restricts who sees a product, per segment or per reseller (CAT-05). Table product_visibility_rules · written by catalog (admin.products.visibility).
| Field | Prisma type | Null | Default | Constraints / notes |
|---|---|---|---|---|
id | String @db.Uuid | — | uuid(7) | PK. |
productId | String @db.Uuid | — | — | FK → Product · Cascade. |
effect | VisibilityRuleEffect | — | — | — |
segmentId | String @db.Uuid | yes | — | FK → Segment · Restrict. |
resellerId | String @db.Uuid | yes | — | FK → Reseller · Restrict. |
createdById | String @db.Uuid | yes | — | FK → User · SetNull. |
createdAt | DateTime | — | now() | — |
- Relations:
product→ Product (productId,onDelete: Cascade) ·segment→ Segment (segmentId,onDelete: Restrict) ·reseller→ Reseller (resellerId,onDelete: Restrict) ·createdBy→ User (createdById,onDelete: SetNull). - Keys & indexes:
@@unique([productId, segmentId])·@@unique([productId, resellerId])·@@index([resellerId])·@@index([segmentId]). - Enums: VisibilityRuleEffect (proposed).
- Rule: a product is visible to reseller R when its status is
ACTIVEorOUT_OF_STOCK, noDENYrule matches R (byresellerIdor one of R's segments), and either it has noALLOWrule or oneALLOWrule matches R. Guests see only products without anyALLOWrule.PUT …/visibilityreplaces the set (link rows; AuditLog keeps the diff).
Constraints and indexes Prisma cannot express (raw SQL in the migration):
ALTER TABLE product_visibility_rules ADD CONSTRAINT product_visibility_rules_one_target
CHECK (num_nonnulls(segment_id, reseller_id) = 1);Example row:
{
"id": "01a1968d-3900-7e3a-a5dd-4d02e54d9a8b",
"productId": "01a18298-9480-70cc-80a7-8ddb761bc4d8",
"effect": "DENY",
"segmentId": null,
"resellerId": "01a19ad1-9bc2-7660-88e3-2ce0d7290ab6",
"createdById": "01a1a00a-4535-7ff7-af24-a0a3bb095b70",
"createdAt": "2026-11-01T10:00:00.000Z"
}Favorite
A product bookmarked by a reseller user (CAT-09). Table favorites · written by catalog (favorites.*).
| Field | Prisma type | Null | Default | Constraints / notes |
|---|---|---|---|---|
userId | String @db.Uuid | — | — | PK. FK → User · Cascade. Part of the composite PK. |
productId | String @db.Uuid | — | — | PK. FK → Product · Cascade. Part of the composite PK. |
createdAt | DateTime | — | now() | — |
- Relations:
user→ User (userId,onDelete: Cascade) ·product→ Product (productId,onDelete: Cascade). - Keys & indexes:
@@id([userId, productId])·@@index([productId]). PUTis an upsert,DELETEremoves the row. Favorites of products no longer visible are filtered out at read time, not deleted. Guests have no favorites.
Example row:
{
"userId": "01a1a077-1080-7aed-87e0-11ae957ae11e",
"productId": "01a18298-9480-70cc-80a7-8ddb761bc4d8",
"createdAt": "2026-11-04T19:22:00.000Z"
}Price history
PriceChange
Append-only history of the three selling prices and the other price columns (PRC-01, cahier §8 historique des modifications). Table price_changes · written by pricing (only writer).
| Field | Prisma type | Null | Default | Constraints / notes |
|---|---|---|---|---|
id | String @db.Uuid | — | uuid(7) | PK. |
variantId | String @db.Uuid | — | — | FK → ProductVariant · Restrict. |
field | PriceField | — | — | — |
oldValue | Int | yes | — | Centimes HT, or basis points for VAT_RATE; null when the variant is created. |
newValue | Int | — | — | — |
reason | String | yes | — | — |
importJobId | String @db.Uuid | yes | — | FK → ImportJob · SetNull. Set when the change came from an import. |
changedById | String @db.Uuid | yes | — | FK → User · SetNull. |
createdAt | DateTime | — | now() | Effective time of the change. |
- Relations:
variant→ ProductVariant (variantId,onDelete: Restrict) ·importJob→ ImportJob (importJobId,onDelete: SetNull) ·changedBy→ User (changedById,onDelete: SetNull). - Keys & indexes:
@@index([variantId, createdAt(sort: Desc)])·@@index([field, createdAt]). - Enums: PriceField (proposed).
- One row per changed column: editing the three prices at once writes three rows sharing
createdAt. - Rows about
PURCHASE_PRICEare returned only with the restricted permission pair (ACL-04). V2 adds a nullablepriceListIdfor agreement prices.
Example row:
{
"id": "01a195e8-6d80-7e56-9fb4-dd41badf333b",
"variantId": "01a18298-9480-7f48-8522-52331c7620e9",
"field": "PRICE_DEMI_GROS",
"oldValue": 36000,
"newValue": 35000,
"reason": "Baisse tarifaire novembre",
"importJobId": null,
"changedById": "01a1a00a-4535-7ff7-af24-a0a3bb095b70",
"createdAt": "2026-11-01T07:00:00.000Z"
}V2 — per-reseller agreements
Removed from V1 by the 17 Oct decisions (AUTHORING §1b): V1 prices are the three variant columns. The two tables below are the V2 baseline (PRC-03); QuantityTier was dropped altogether.
PriceList
A per-reseller price agreement (or a loyalty-level price list) applied on top of the customer-type prices. Not created in V1: the V1 price is always the variant column of the reseller's customerType. Table price_lists · written by pricing (V2, PRC-03) · V2.
| Field | Prisma type | Null | Default | Constraints / notes |
|---|---|---|---|---|
id | String @db.Uuid | — | uuid(7) | PK. |
code | String @db.VarChar(30) | — | — | Unique. |
name | String | — | — | — |
type | PriceListType | — | — | — |
loyaltyLevel | LoyaltyLevel | yes | — | Required for LEVEL, null for AGREEMENT (CHECK). |
priority | Int | — | 0 | Higher wins when an agreement and a level list price the same variant. |
validFrom | DateTime | yes | — | — |
validTo | DateTime | yes | — | Exclusive. |
isActive | Boolean | — | true | — |
archivedAt | DateTime | yes | — | — |
note | String | yes | — | — |
createdById | String @db.Uuid | yes | — | FK → User · SetNull. |
createdAt | DateTime | — | now() | — |
updatedAt | DateTime | — | @updatedAt | — |
- Relations:
createdBy→ User (createdById,onDelete: SetNull). - Referenced by: PriceListItem.
priceListId. - Keys & indexes:
codeunique ·@@index([type, isActive]). - Enums: PriceListType (proposed), LoyaltyLevel.
- V2 resolution: valid agreement of
Reseller.priceListId(V2 column) > level list of the reseller's loyalty level > customer-type price; then promotions.
Constraints and indexes Prisma cannot express (raw SQL in the migration):
ALTER TABLE price_lists ADD CONSTRAINT price_lists_type_level CHECK ((type = 'LEVEL') = (loyalty_level IS NOT NULL));
ALTER TABLE price_lists ADD CONSTRAINT price_lists_validity CHECK (valid_to IS NULL OR valid_from IS NULL OR valid_to > valid_from);Example row:
{
"id": "01a49ff6-8e80-799f-881b-1cb7d6cad621",
"code": "ACC-REV-000123",
"name": "Accord Boutique Amine Mobile 2027",
"type": "AGREEMENT",
"loyaltyLevel": null,
"priority": 10,
"validFrom": "2027-04-01T00:00:00.000Z",
"validTo": null,
"isActive": true,
"archivedAt": null,
"note": null,
"createdById": "01a1a00a-4535-7ff7-af24-a0a3bb095b70",
"createdAt": "2027-04-01T09:00:00.000Z",
"updatedAt": "2027-04-01T09:00:00.000Z"
}PriceListItem
The price of one variant in a V2 agreement: a fixed price or a discount off the customer-type price. Table price_list_items · written by pricing (V2) · V2.
| Field | Prisma type | Null | Default | Constraints / notes |
|---|---|---|---|---|
id | String @db.Uuid | — | uuid(7) | PK. |
priceListId | String @db.Uuid | — | — | FK → PriceList · Cascade. |
variantId | String @db.Uuid | — | — | FK → ProductVariant · Restrict. |
price | Int | yes | — | Fixed unit price, HT centimes. |
discountBp | Int | yes | — | Discount off the customer-type price, basis points. |
createdAt | DateTime | — | now() | — |
updatedAt | DateTime | — | @updatedAt | — |
- Relations:
priceList→ PriceList (priceListId,onDelete: Cascade) ·variant→ ProductVariant (variantId,onDelete: Restrict). - Keys & indexes:
@@unique([priceListId, variantId])·@@index([variantId]).
Constraints and indexes Prisma cannot express (raw SQL in the migration):
ALTER TABLE price_list_items ADD CONSTRAINT price_list_items_one_rule CHECK (
num_nonnulls(price, discount_bp) = 1
AND (price IS NULL OR price >= 0)
AND (discount_bp IS NULL OR discount_bp BETWEEN 1 AND 10000));Example row:
{
"id": "01a49ffb-2260-7027-9751-59308e3ab3e9",
"priceListId": "01a49ff6-8e80-799f-881b-1cb7d6cad621",
"variantId": "01a18298-9480-7f48-8522-52331c7620e9",
"price": 33000,
"discountBp": null,
"createdAt": "2027-04-01T09:05:00.000Z",
"updatedAt": "2027-04-01T09:05:00.000Z"
}CRM
Resellers and prospects with the cahier's minimum data set, addresses, documents, photos, lifecycle and assignment history, timeline, follow-up tasks and segments.
Stock
Warehouses, stock levels, the movement ledger and inventory sessions — physical, reserved and available quantities, back-office only.