# 2. Base de données

## Choix généraux

- MySQL 8 ou MariaDB récent, moteur InnoDB, jeu `utf8mb4`, collation Unicode adaptée à la version du serveur.
- Clés primaires `BIGINT UNSIGNED AUTO_INCREMENT`.
- Montants et quantités en `DECIMAL`, jamais en `FLOAT` : montants `DECIMAL(19,4)`, quantités `DECIMAL(19,6)`, taux `DECIMAL(9,6)`.
- Dates métier en `DATE`, instants techniques en `DATETIME` stockés en UTC.
- Codes monétaires en `CHAR(3)`, codes pays en `CHAR(2)`.
- États métier en `VARCHAR(30)` contrôlé par `CHECK`, plutôt que des `ENUM` difficiles à faire évoluer.
- Toutes les tables métier portent `company_id` afin d’isoler les sociétés.
- Suppression logique (`deleted_at`) seulement pour les référentiels. Les écritures validées ne sont jamais supprimées.
- Les documents mémorisent les libellés, adresses, prix, taxes et totaux historiques : une modification ultérieure de la fiche tierce ou produit ne réécrit pas l’histoire.

Abréviations : `PK` clé primaire, `FK` clé étrangère, `NN` non nul, `UQ` unique. Sauf indication contraire, chaque table possède `id BIGINT UNSIGNED PK`, `created_at DATETIME NN`, `updated_at DATETIME NN`.

## Noyau, configuration et sécurité

### `companies`

Sociétés juridiquement séparées gérées par l’installation.

Colonnes : `legal_name VARCHAR(190) NN`, `trade_name VARCHAR(190)`, `tax_id VARCHAR(60)`, `registration_no VARCHAR(60)`, `email VARCHAR(190)`, `phone VARCHAR(40)`, `currency_code CHAR(3) NN`, `timezone VARCHAR(64) NN`, `fiscal_year_start TINYINT UNSIGNED NN`, adresse structurée, `logo_path VARCHAR(255)`, `is_active BOOLEAN NN DEFAULT 1`. Contraintes : `fiscal_year_start BETWEEN 1 AND 12`. Index : UQ `(tax_id)` si renseigné, index `(is_active)`.

### `company_settings`

Paramètres extensibles non secrets d’une société.

Colonnes : `company_id FK NN`, `setting_key VARCHAR(100) NN`, `setting_value TEXT`, `value_type VARCHAR(20) NN`. UQ `(company_id, setting_key)`, FK vers `companies` avec `ON DELETE CASCADE`.

### `users`

Comptes humains ; jamais de mot de passe en clair.

Colonnes : `email VARCHAR(190) NN`, `password_hash VARCHAR(255) NN`, `first_name VARCHAR(100) NN`, `last_name VARCHAR(100) NN`, `phone VARCHAR(40)`, `locale VARCHAR(10) NN`, `timezone VARCHAR(64)`, `status VARCHAR(20) NN`, `failed_login_count SMALLINT UNSIGNED NN DEFAULT 0`, `locked_until DATETIME`, `last_login_at DATETIME`, `password_changed_at DATETIME`, `must_change_password BOOLEAN NN DEFAULT 0`, `remember_token_hash CHAR(64)`, `remember_token_expires_at DATETIME`. UQ `(email)`. CHECK `status IN ('pending','active','suspended','disabled')`. Index `(status)`, `(locked_until)`.

### `company_users`

Appartenance d’un utilisateur à une société.

Colonnes : `company_id FK NN`, `user_id FK NN`, `is_default BOOLEAN NN DEFAULT 0`, `status VARCHAR(20) NN`. UQ `(company_id,user_id)`, index `(user_id,status)`. FKs en `RESTRICT`.

### `roles`, `permissions`, `role_permissions`, `user_roles`

RBAC : rôles par société, capacités globales, associations N–N.

- `roles` : `company_id FK NN`, `name VARCHAR(100) NN`, `code VARCHAR(80) NN`, `description VARCHAR(255)`, `is_system BOOLEAN NN DEFAULT 0`; UQ `(company_id,code)`.
- `permissions` : `code VARCHAR(120) NN UQ`, `module VARCHAR(60) NN`, `description VARCHAR(255) NN` ; index `(module)`.
- `role_permissions` : `role_id FK NN`, `permission_id FK NN`, PK composite `(role_id,permission_id)`, cascades sur suppression du rôle.
- `user_roles` : `company_id FK NN`, `user_id FK NN`, `role_id FK NN`, PK composite `(company_id,user_id,role_id)` ; cohérence société vérifiée par service et contraintes composites lors de la migration.

### `user_warehouse_access`

Restriction facultative d’un utilisateur à certains entrepôts.

Colonnes : `company_id FK NN`, `user_id FK NN`, `warehouse_id FK NN`, `access_level VARCHAR(20) NN`; UQ `(company_id,user_id,warehouse_id)`, CHECK `access_level IN ('read','operate','manage')`.

### `user_sessions`, `password_reset_tokens`

Sessions révocables et réinitialisation sécurisée.

- `user_sessions` : `user_id FK NN`, `token_hash CHAR(64) NN UQ`, `ip_address VARBINARY(16)`, `user_agent VARCHAR(500)`, `last_activity_at DATETIME NN`, `expires_at DATETIME NN`; index `(user_id,expires_at)`.
- `password_reset_tokens` : `user_id FK NN`, `token_hash CHAR(64) NN UQ`, `expires_at DATETIME NN`, `used_at DATETIME`; index `(user_id,expires_at)`.

### `audit_logs`

Journal append-only des actions sensibles.

Colonnes : `company_id FK`, `user_id FK`, `event_type VARCHAR(80) NN`, `entity_type VARCHAR(80)`, `entity_id BIGINT UNSIGNED`, `request_id CHAR(36)`, `ip_address VARBINARY(16)`, `user_agent VARCHAR(500)`, `old_values JSON`, `new_values JSON`, `metadata JSON`, `created_at DATETIME NN`. Aucun `updated_at`. Index `(company_id,created_at)`, `(entity_type,entity_id)`, `(user_id,created_at)`, `(event_type,created_at)`.

### `document_sequences`

Numérotation atomique par société, type et exercice.

Colonnes : `company_id FK NN`, `document_type VARCHAR(40) NN`, `fiscal_year SMALLINT UNSIGNED NN`, `prefix VARCHAR(30) NN`, `next_value BIGINT UNSIGNED NN`, `padding TINYINT UNSIGNED NN DEFAULT 5`, `reset_policy VARCHAR(20) NN`. UQ `(company_id,document_type,fiscal_year)`. Verrouillage `SELECT ... FOR UPDATE` pendant l’attribution.

### `attachments`

Métadonnées de fichiers privés liés à toute entité autorisée.

Colonnes : `company_id FK NN`, `entity_type VARCHAR(80) NN`, `entity_id BIGINT UNSIGNED NN`, `original_name VARCHAR(255) NN`, `stored_name VARCHAR(255) NN UQ`, `mime_type VARCHAR(120) NN`, `size_bytes BIGINT UNSIGNED NN`, `sha256 CHAR(64) NN`, `uploaded_by FK NN`. Index `(company_id,entity_type,entity_id)`, `(sha256)`.

## Référentiels et tiers

### `countries`, `currencies`, `units`, `tax_rates`, `payment_terms`, `payment_methods`

Référentiels normalisés utilisés par les documents.

- `countries` : `code CHAR(2) PK`, `name VARCHAR(100) NN`, `is_active BOOLEAN NN`.
- `currencies` : `code CHAR(3) PK`, `name VARCHAR(80) NN`, `symbol VARCHAR(10)`, `decimal_places TINYINT UNSIGNED NN`.
- `units` : `company_id FK NN`, `code VARCHAR(20) NN`, `name VARCHAR(80) NN`, `precision_scale TINYINT UNSIGNED NN DEFAULT 3`; UQ `(company_id,code)`.
- `tax_rates` : `company_id FK NN`, `name VARCHAR(80) NN`, `rate DECIMAL(9,6) NN`, `valid_from DATE`, `valid_to DATE`, `is_active BOOLEAN NN`; CHECK taux `0..100`, UQ `(company_id,name,valid_from)`.
- `payment_terms` : `company_id FK NN`, `name VARCHAR(100) NN`, `days SMALLINT UNSIGNED NN`, `end_of_month BOOLEAN NN DEFAULT 0`; UQ `(company_id,name)`.
- `payment_methods` : `company_id FK NN`, `code VARCHAR(30) NN`, `name VARCHAR(80) NN`, `is_cash BOOLEAN NN DEFAULT 0`, `is_active BOOLEAN NN`; UQ `(company_id,code)`.

### `third_parties`

Fiche commune aux clients, prospects et fournisseurs, évitant les doublons lorsqu’un tiers a plusieurs rôles.

Colonnes : `company_id FK NN`, `type VARCHAR(20) NN`, `code VARCHAR(40) NN`, `legal_name VARCHAR(190) NN`, `trade_name VARCHAR(190)`, `tax_id VARCHAR(60)`, `registration_no VARCHAR(60)`, `email VARCHAR(190)`, `phone VARCHAR(40)`, `website VARCHAR(255)`, `currency_code CHAR(3) FK NN`, `payment_term_id FK`, `credit_limit DECIMAL(19,4) NN DEFAULT 0`, `status VARCHAR(20) NN`, `notes TEXT`, `deleted_at DATETIME`. CHECK type `IN ('customer','supplier','both','prospect')`, statut `IN ('active','blocked','inactive')`, limite `>=0`. UQ `(company_id,code)`, index `(company_id,legal_name)`, `(company_id,tax_id)`, `(company_id,type,status)`.

### `third_party_addresses`, `contacts`

- `third_party_addresses` : `company_id FK NN`, `third_party_id FK NN`, `address_type VARCHAR(20) NN`, `label VARCHAR(80)`, `line1 VARCHAR(190) NN`, `line2 VARCHAR(190)`, `postal_code VARCHAR(20)`, `city VARCHAR(100) NN`, `state VARCHAR(100)`, `country_code CHAR(2) FK NN`, `is_default BOOLEAN NN`; index `(third_party_id,address_type,is_default)`.
- `contacts` : `company_id FK NN`, `third_party_id FK NN`, `first_name VARCHAR(100) NN`, `last_name VARCHAR(100) NN`, `job_title VARCHAR(100)`, `email VARCHAR(190)`, `phone VARCHAR(40)`, `mobile VARCHAR(40)`, `is_default BOOLEAN NN`, `is_active BOOLEAN NN`; index `(third_party_id,is_active)`, `(email)`.

## Catalogue

### `categories`, `products`, `product_categories`

- `categories` : `company_id FK NN`, `parent_id FK` auto-référence, `name VARCHAR(120) NN`, `slug VARCHAR(140) NN`, `description TEXT`, `sort_order INT NN DEFAULT 0`, `is_active BOOLEAN NN`; UQ `(company_id,slug)`, index `(company_id,parent_id,sort_order)`, parent en `RESTRICT`.
- `products` : `company_id FK NN`, `sku VARCHAR(80) NN`, `barcode VARCHAR(80)`, `type VARCHAR(20) NN`, `name VARCHAR(190) NN`, `description TEXT`, `unit_id FK NN`, `purchase_price DECIMAL(19,4) NN DEFAULT 0`, `sale_price DECIMAL(19,4) NN DEFAULT 0`, `tax_rate_id FK`, `track_stock BOOLEAN NN`, `allow_negative_stock BOOLEAN NN DEFAULT 0`, `minimum_stock DECIMAL(19,6) NN DEFAULT 0`, `status VARCHAR(20) NN`, `deleted_at DATETIME`; CHECK prix et minimum `>=0`, type `IN ('product','service')`, UQ `(company_id,sku)`, UQ `(company_id,barcode)` si renseigné, index `(company_id,name)`, `(company_id,status)`.
- `product_categories` : `product_id FK NN`, `category_id FK NN`, PK `(product_id,category_id)`.

### `product_prices`

Historique et tarifs spécifiques.

Colonnes : `company_id FK NN`, `product_id FK NN`, `third_party_id FK`, `price_type VARCHAR(20) NN`, `unit_price DECIMAL(19,4) NN`, `currency_code CHAR(3) FK NN`, `minimum_quantity DECIMAL(19,6) NN DEFAULT 1`, `valid_from DATE NN`, `valid_to DATE`; CHECK prix `>=0`, dates cohérentes, index `(company_id,product_id,third_party_id,valid_from,valid_to)`.

## Documents de vente

Tous les en-têtes ci-dessous stockent également un instantané de facturation/livraison (`billing_*`, `shipping_*`) et les totaux `subtotal_excl_tax`, `discount_total`, `tax_total`, `total_incl_tax DECIMAL(19,4) NN`, avec CHECK montants `>=0` quand applicable. Toutes les lignes stockent `description`, `quantity`, `unit_price`, `discount_rate`, `tax_rate`, `line_subtotal`, `line_tax`, `line_total` afin de figer l’historique.

### `sales_quotes`, `sales_quote_lines`

- `sales_quotes` : `company_id FK NN`, `number VARCHAR(40) NN`, `customer_id FK NN`, `contact_id FK`, `quote_date DATE NN`, `valid_until DATE`, `currency_code CHAR(3) FK NN`, `status VARCHAR(30) NN`, `customer_reference VARCHAR(100)`, `notes TEXT`, `terms TEXT`, `accepted_at DATETIME`, `validated_by FK`, `validated_at DATETIME`; UQ `(company_id,number)`, CHECK statut `draft/sent/accepted/rejected/expired/cancelled`, index `(company_id,customer_id,quote_date)`, `(company_id,status,valid_until)`.
- `sales_quote_lines` : `quote_id FK NN`, `line_no SMALLINT UNSIGNED NN`, `product_id FK`, champs instantanés, `unit_id FK NN`; UQ `(quote_id,line_no)`, CHECK quantité `>0`, remises `0..100`, montants cohérents non négatifs. Suppression en cascade autorisée uniquement tant que le devis est brouillon, imposée par service.

### `sales_orders`, `sales_order_lines`

- `sales_orders` : mêmes champs communs, plus `order_date DATE NN`, `expected_date DATE`, `status VARCHAR(30) NN`, `quote_id FK`, `customer_reference`, validation ; UQ numéro, CHECK `draft/confirmed/partially_delivered/delivered/partially_invoiced/invoiced/cancelled`, index client/date et statut/date.
- `sales_order_lines` : `order_id FK NN`, `quote_line_id FK`, champs ligne, plus `delivered_quantity DECIMAL(19,6) NN DEFAULT 0`, `invoiced_quantity DECIMAL(19,6) NN DEFAULT 0`; UQ `(order_id,line_no)`, CHECK quantités `>=0` et ne dépassant pas la quantité hors tolérance explicitement autorisée.

### `delivery_notes`, `delivery_note_lines`

- `delivery_notes` : `company_id FK NN`, `number VARCHAR(40) NN`, `customer_id FK NN`, `warehouse_id FK NN`, `delivery_date DATE NN`, `status VARCHAR(30) NN`, `carrier VARCHAR(100)`, `tracking_number VARCHAR(100)`, `delivered_to VARCHAR(190)`, `validated_by FK`, `validated_at DATETIME`, `cancelled_at DATETIME`; UQ numéro, CHECK `draft/validated/delivered/cancelled`, index client/date, entrepôt/date, statut.
- `delivery_note_lines` : `delivery_note_id FK NN`, `order_line_id FK NN`, `product_id FK NN`, `line_no`, `description`, `quantity DECIMAL(19,6) NN`, `unit_id FK NN`, `stock_movement_id FK`; UQ `(delivery_note_id,line_no)`, CHECK quantité `>0`.

### `sales_invoices`, `sales_invoice_lines`, `sales_credit_allocations`

- `sales_invoices` : champs communs, `number`, `customer_id`, `invoice_type VARCHAR(20) NN`, `invoice_date DATE NN`, `due_date DATE NN`, `status VARCHAR(30) NN`, `source_invoice_id FK`, `paid_total DECIMAL(19,4) NN DEFAULT 0`, `balance_due DECIMAL(19,4) NN`, validation ; UQ numéro, CHECK type `invoice/credit_note/deposit`, statut `draft/validated/partially_paid/paid/overdue/cancelled`, index client/date, statut/due_date.
- `sales_invoice_lines` : `invoice_id FK NN`, `order_line_id FK`, `delivery_line_id FK`, champs de ligne ; UQ `(invoice_id,line_no)`.
- `sales_credit_allocations` : `credit_invoice_id FK NN`, `target_invoice_id FK NN`, `amount DECIMAL(19,4) NN`, `allocated_at DATETIME NN`; UQ `(credit_invoice_id,target_invoice_id)`, CHECK montant `>0` et documents du même client/devise.

## Documents d’achat

Les documents d’achat reprennent les mêmes principes d’instantané et de totaux que les ventes.

### `purchase_orders`, `purchase_order_lines`

- `purchase_orders` : `company_id FK NN`, `number VARCHAR(40) NN`, `supplier_id FK NN`, `supplier_reference VARCHAR(100)`, `order_date DATE NN`, `expected_date DATE`, `currency_code CHAR(3) FK NN`, `status VARCHAR(30) NN`, totaux, notes, validation ; UQ numéro, CHECK `draft/approved/sent/partially_received/received/partially_invoiced/invoiced/cancelled`, index fournisseur/date et statut.
- `purchase_order_lines` : `purchase_order_id FK NN`, `line_no`, `product_id FK`, champs instantanés, `received_quantity`, `invoiced_quantity`; UQ ligne, CHECK quantités positives/cohérentes.

### `goods_receipts`, `goods_receipt_lines`

- `goods_receipts` : `company_id FK NN`, `number VARCHAR(40) NN`, `supplier_id FK NN`, `warehouse_id FK NN`, `receipt_date DATE NN`, `supplier_delivery_ref VARCHAR(100)`, `status VARCHAR(20) NN`, validation ; UQ numéro, CHECK `draft/validated/cancelled`, index fournisseur/date et entrepôt/date.
- `goods_receipt_lines` : `goods_receipt_id FK NN`, `purchase_order_line_id FK NN`, `product_id FK NN`, `line_no`, `description`, `quantity DECIMAL(19,6) NN`, `unit_id FK NN`, `unit_cost DECIMAL(19,4) NN`, `stock_movement_id FK`; UQ ligne, CHECK quantité `>0`, coût `>=0`.

### `supplier_invoices`, `supplier_invoice_lines`, `supplier_credit_allocations`

- `supplier_invoices` : `company_id FK NN`, numéro interne, `supplier_invoice_no VARCHAR(100) NN`, `supplier_id FK NN`, `invoice_type`, dates facture/échéance, devise, statut, totaux, payé et solde, `source_invoice_id FK`; UQ `(company_id,number)` et UQ `(company_id,supplier_id,supplier_invoice_no)`, CHECK types/statuts analogues aux ventes, index fournisseur/date et échéance.
- `supplier_invoice_lines` : `supplier_invoice_id FK NN`, `purchase_order_line_id FK`, `goods_receipt_line_id FK`, champs instantanés ; UQ ligne.
- `supplier_credit_allocations` : crédit fournisseur, facture cible, montant et date ; mêmes contraintes que côté ventes.

## Trésorerie, paiements et dépenses

### `financial_accounts`

Modèle commun pour banque et caisse.

Colonnes : `company_id FK NN`, `type VARCHAR(20) NN`, `code VARCHAR(40) NN`, `name VARCHAR(120) NN`, `currency_code CHAR(3) FK NN`, `bank_name VARCHAR(120)`, `iban VARCHAR(64)`, `account_number_masked VARCHAR(80)`, `opening_balance DECIMAL(19,4) NN DEFAULT 0`, `opening_date DATE`, `status VARCHAR(20) NN`; CHECK type `bank/cash`, statut `active/inactive`, UQ `(company_id,code)`, index `(company_id,type,status)`.

### `payments`, `payment_allocations`

Un paiement est un flux de trésorerie, alloué à une ou plusieurs factures.

- `payments` : `company_id FK NN`, `number VARCHAR(40) NN`, `direction VARCHAR(10) NN`, `third_party_id FK`, `payment_date DATE NN`, `amount DECIMAL(19,4) NN`, `currency_code CHAR(3) FK NN`, `exchange_rate DECIMAL(19,8) NN DEFAULT 1`, `payment_method_id FK NN`, `financial_account_id FK NN`, `reference VARCHAR(120)`, `status VARCHAR(20) NN`, `notes TEXT`, `validated_by FK`, `validated_at DATETIME`; CHECK direction `in/out`, montant et taux `>0`, statut `draft/validated/cancelled`, UQ numéro, index compte/date, tiers/date, statut.
- `payment_allocations` : `payment_id FK NN`, `sales_invoice_id FK`, `supplier_invoice_id FK`, `allocated_amount DECIMAL(19,4) NN`; exactement une facture cible via CHECK XOR, UQ sur `(payment_id,sales_invoice_id)` et `(payment_id,supplier_invoice_id)`, montant `>0`. Total alloué `<= payment.amount`, même tiers/devise/direction, imposé transactionnellement.

### `account_transactions`

Journal financier append-only produit par paiements, dépenses, dépôts et transferts.

Colonnes : `company_id FK NN`, `financial_account_id FK NN`, `transaction_date DATETIME NN`, `direction VARCHAR(10) NN`, `amount DECIMAL(19,4) NN`, `currency_code CHAR(3) FK NN`, `source_type VARCHAR(40) NN`, `source_id BIGINT UNSIGNED NN`, `reference VARCHAR(120)`, `description VARCHAR(255)`, `reversal_of_id FK`, `created_by FK NN`. CHECK direction `credit/debit`, montant `>0`; UQ `(source_type,source_id,financial_account_id,direction)` selon source ; index compte/date, source.

### `expense_categories`, `expenses`

- `expense_categories` : `company_id FK NN`, `parent_id FK`, `code VARCHAR(40) NN`, `name VARCHAR(120) NN`, `is_active BOOLEAN NN`; UQ `(company_id,code)`.
- `expenses` : `company_id FK NN`, `number VARCHAR(40) NN`, `category_id FK NN`, `supplier_id FK`, `expense_date DATE NN`, `description VARCHAR(255) NN`, `amount_excl_tax`, `tax_amount`, `total_amount DECIMAL(19,4) NN`, `currency_code CHAR(3) FK NN`, `status VARCHAR(20) NN`, `financial_account_id FK`, `payment_method_id FK`, `paid_at DATETIME`, `approved_by FK`, `attachment_required BOOLEAN NN DEFAULT 0`; CHECK montants `>=0` et total cohérent, statut `draft/submitted/approved/paid/rejected/cancelled`, UQ numéro, index catégorie/date, statut/date.

### `account_transfers`

Transferts entre comptes, générant deux écritures liées.

Colonnes : `company_id FK NN`, `number VARCHAR(40) NN`, `from_account_id FK NN`, `to_account_id FK NN`, `transfer_date DATE NN`, `amount DECIMAL(19,4) NN`, `fees DECIMAL(19,4) NN DEFAULT 0`, `status VARCHAR(20) NN`, `out_transaction_id FK`, `in_transaction_id FK`, `created_by FK NN`; CHECK comptes différents, montant `>0`, frais `>=0`, statut `draft/validated/cancelled`, UQ numéro.

## Stock et entrepôts

### `warehouses`, `warehouse_locations`

- `warehouses` : `company_id FK NN`, `code VARCHAR(40) NN`, `name VARCHAR(120) NN`, adresse structurée, `is_active BOOLEAN NN`; UQ `(company_id,code)`.
- `warehouse_locations` : `company_id FK NN`, `warehouse_id FK NN`, `code VARCHAR(50) NN`, `name VARCHAR(100)`, `is_active BOOLEAN NN`; UQ `(warehouse_id,code)`.

### `stock_movements`

Source de vérité append-only de chaque variation physique.

Colonnes : `company_id FK NN`, `product_id FK NN`, `warehouse_id FK NN`, `location_id FK`, `movement_type VARCHAR(30) NN`, `quantity_delta DECIMAL(19,6) NN`, `unit_cost DECIMAL(19,4)`, `movement_date DATETIME NN`, `source_type VARCHAR(40) NN`, `source_id BIGINT UNSIGNED NN`, `source_line_id BIGINT UNSIGNED`, `reversal_of_id FK`, `reference VARCHAR(100)`, `notes VARCHAR(255)`, `created_by FK NN`. CHECK delta `<>0`, type `opening/receipt/delivery/return_in/return_out/transfer_in/transfer_out/adjustment/count/reversal`; UQ source/ligne/type/entrepôt pour l’idempotence ; index `(company_id,product_id,warehouse_id,movement_date)`, `(source_type,source_id)`, `(reversal_of_id)`.

### `stock_balances`

Projection rapide reconstruisible à partir des mouvements.

Colonnes : `company_id FK NN`, `product_id FK NN`, `warehouse_id FK NN`, `location_id FK`, `quantity_on_hand DECIMAL(19,6) NN DEFAULT 0`, `quantity_reserved DECIMAL(19,6) NN DEFAULT 0`, `average_unit_cost DECIMAL(19,4) NN DEFAULT 0`, `updated_at DATETIME NN`. UQ tenant compte d’une localisation normalisée ; CHECK réservé `>=0`, coût `>=0`, index entrepôt/produit. Mise à jour dans la même transaction que le mouvement.

### `stock_reservations`

Réservations de commandes confirmées sans mouvement physique.

Colonnes : `company_id FK NN`, `sales_order_line_id FK NN`, `product_id FK NN`, `warehouse_id FK NN`, `quantity DECIMAL(19,6) NN`, `status VARCHAR(20) NN`, `reserved_at DATETIME NN`, `released_at DATETIME`; CHECK quantité `>0`, statut `active/consumed/released/cancelled`, index `(warehouse_id,product_id,status)`, UQ réservation active par ligne/entrepôt garanti par stratégie de service/index adapté au SGBD.

### `stock_transfers`, `stock_transfer_lines`

- `stock_transfers` : `company_id FK NN`, `number VARCHAR(40) NN`, `from_warehouse_id FK NN`, `to_warehouse_id FK NN`, `transfer_date DATE NN`, `status VARCHAR(20) NN`, validation/réception ; CHECK entrepôts distincts, statut `draft/in_transit/received/cancelled`, UQ numéro.
- `stock_transfer_lines` : `stock_transfer_id FK NN`, `line_no`, `product_id FK NN`, `quantity DECIMAL(19,6) NN`, mouvements sortie/entrée FKs ; UQ ligne, CHECK quantité `>0`.

### `stock_counts`, `stock_count_lines`

- `stock_counts` : `company_id FK NN`, `number VARCHAR(40) NN`, `warehouse_id FK NN`, `count_date DATE NN`, `status VARCHAR(20) NN`, `started_by FK NN`, `validated_by FK`; CHECK `draft/in_progress/validated/cancelled`, UQ numéro.
- `stock_count_lines` : `stock_count_id FK NN`, `product_id FK NN`, `location_id FK`, `system_quantity DECIMAL(19,6) NN`, `counted_quantity DECIMAL(19,6)`, `difference_quantity DECIMAL(19,6)`, `stock_movement_id FK`; UQ produit/localisation par inventaire, cohérence différence imposée à la validation.

## Notifications et rapports

### `notifications`

Alertes internes : échéances, stock faible, documents à approuver.

Colonnes : `company_id FK NN`, `user_id FK NN`, `type VARCHAR(60) NN`, `title VARCHAR(190) NN`, `body TEXT`, `entity_type VARCHAR(80)`, `entity_id BIGINT UNSIGNED`, `read_at DATETIME`; index `(user_id,read_at,created_at)`.

### `saved_reports`

Filtres de rapports sauvegardés, sans dupliquer les données comptables.

Colonnes : `company_id FK NN`, `user_id FK NN`, `report_code VARCHAR(80) NN`, `name VARCHAR(120) NN`, `filters JSON NN`, `is_shared BOOLEAN NN DEFAULT 0`; index `(company_id,report_code)`, UQ `(user_id,report_code,name)`.

## Relations principales

```text
companies 1──N users (via company_users), roles, third_parties, products, warehouses
third_parties 1──N quotes/orders/deliveries/invoices/payments
sales_quotes 1──N quote_lines ──0..1→ sales_order_lines
sales_orders 1──N order_lines ──N→ delivery_note_lines / invoice_lines
delivery_notes 1──N delivery_lines ──1→ stock_movements
sales_invoices 1──N invoice_lines; payments N──N invoices via payment_allocations
purchase_orders 1──N lines ──N→ receipt_lines / supplier_invoice_lines
goods_receipts 1──N lines ──1→ stock_movements
warehouses + products 1──N stock_movements; stock_balances est leur projection
financial_accounts 1──N account_transactions
```

## Règles d’intégrité incontournables

1. Toute FK métier doit rester dans la même `company_id`; des clés uniques composites permettent de le renforcer en base quand nécessaire.
2. Les numéros ne sont attribués qu’à la validation et sont uniques par société/type.
3. Un document validé ne peut plus perdre ses lignes ni voir ses montants réécrits.
4. Les totaux d’en-tête sont recalculés côté serveur depuis les lignes dans une transaction.
5. Toute sortie/entrée de stock validée crée un mouvement idempotent et met à jour le solde sous verrou.
6. Tout paiement validé crée une écriture financière ; son annulation crée une écriture inverse.
7. La somme des allocations ne dépasse ni le paiement ni le solde des factures.
8. Les FKs des écritures validées utilisent `RESTRICT`; `CASCADE` est réservé aux tables d’association et brouillons dépendants.
9. L’état « en retard » peut être calculé depuis échéance/solde ou matérialisé par tâche contrôlée ; il ne doit pas diverger.
10. Les tableaux de bord lisent les tables métier ou des vues SQL/indexées adaptées ; aucune table de rapport ne devient une source de vérité.
