# ValiseStock Pro — Project Overview

Full-codebase read-through, current as of 2026-07-27. This complements `CLAUDE.md` (terse
agent-facing conventions) with a deeper walkthrough of every module, workflow, and data
relationship — useful when picking up unfamiliar parts of the app.

## What this is

A multi-depot inventory & invoicing system for **Event Bag**, an Algerian luggage brand
(eventbagdz.com). Laravel 13 + Filament v3 admin panel is the *entire* application — there is
no separate customer-facing site, no API, just `/admin`. Production runs on Plesk at
`stock.eventbagdz.com`, MySQL database `valise_stock`, French locale throughout (UI text,
Carbon locale, `NumberToWords` helper for PDF amounts in words).

Business domain: EventBag sells luggage (valises) in collections (AERO/CORE/FRAME/MARQUIS) ×
sizes (cabine/medium/large/SET) × colors, across physical depots plus a virtual "E-Commerce"
depot and a virtual "Transit" depot, with wholesale/retail pricing, bundle packs, and
Algerian-format invoicing (NIF/NIS/RC/AI, 19% TVA).

## Tech stack

- PHP 8.3, Laravel 13, Filament 3.2 (single admin panel, id `admin`, path `/admin`)
- MySQL (production), session/cache/queue all on the `database` driver
- Spatie: `laravel-permission` (roles), `laravel-activitylog` (audit trail on all major models)
- PDF via `barryvdh/laravel-dompdf`; QR via `simplesoftwareio/simple-qrcode`; barcodes via
  `picqer/php-barcode-generator` (Code128)
- PWA support via `erag/laravel-pwa` (installable, manifest, service worker, `bip_scan.mp3` for
  scanner beep)
- No JS framework beyond Filament's Livewire/Alpine — camera QR scanning uses `BarcodeDetector`
  API with a `jsQR` CDN fallback, injected as raw Alpine HTML inside Filament form schemas

## Data model

```
Produit (parent product; is_bundle flag distinguishes catalog bundles)
  └─ Variation (sku, taille, couleur, prix_detail/gros, stock_alerte)
        ├─ Stock (variation_id × depot_id, quantite) — one row per pair, via Stock::getOrCreate()
        └─ MouvementStock (append-only audit log, polymorphic `source`)

Bundle (own SKU + its own "representative" Variation + Produit; composed independently)
  └─ BundleComposant (bundle_id, variation_id, quantite) — recipe of real variations

Client → Facture (numero, type, statut) → FactureLigne (variation_id OR bundle_id)
Depot  → Transfert (source→destination) → TransfertLigne (variation_id OR bundle_id)
Depot  → Inventaire (stock count session) → InventaireLigne (variation_id OR bundle_id)

User → Depot (nullable FK, scopes visibility for non-super-admins)
```

Every "line" table (`FactureLigne`, `TransfertLigne`, `InventaireLigne`) can point at either a
`Variation` or a `Bundle` (nullable FKs, mutually exclusive) — expanding a bundle into its
component variations happens in the service layer, not the schema.

`MouvementStock.source` is a polymorphic morph (`source_type`/`source_id`, both nullable since
a 2026-06-21 migration) pointing back to whatever triggered the movement: a `Facture`, a
`Transfert`, an `Inventaire`, or null for manual scanner operations.

### Enums (status machines)

- **Facture.statut**: `brouillon → confirme` (via `FactureService::confirmer()`, triggers stock
  `sortie`) `→ livre` / `annule`; also `expedie`, `retourne` exist in the enum (added later) but
  aren't driven by any service method yet — likely reserved for a future e-commerce fulfillment
  flow.
- **Transfert.statut**: `brouillon`/`en_preparation → expedie → recu`/`recu_avec_ecart`, or
  `annule`. Plus `demande_restock` — a *pull* request flow (destination depot asks source depot
  to send stock) that a `responsable_depot` at the source can `approuver` (→ `en_preparation`) or
  `refuser` (→ `annule`, with a required `motif_refus`).
- **Inventaire.statut**: `en_cours → termine → valide` (applies `StockService::ajustement` per
  line) or `annule`.

## Service layer (`app/Services/`)

All are registered as singletons in `AppServiceProvider`.

- **`StockService`** — the only place allowed to touch `stocks.quantite`. Every method wraps a
  DB transaction, updates the `Stock` row, and writes a `MouvementStock`. Methods: `entree`,
  `sortie` (throws if insufficient), `ajustement` (sets absolute quantity, logs the delta),
  `transfertSortie`/`transfertEntree`, `venteBundle`/`reapproBundle` (multiply each
  `BundleComposant.quantite` by the sold/returned bundle count and recurse into
  entree/sortie per component).
- **`FactureService`** — `creer()` (transactional create + lines + totals), `confirmer()`
  (brouillon-only guard, calls `stockService->sortie` or `venteBundle` per line, flips to
  `confirme`), `annulerEtSupprimer()` (confirme-only guard, restores stock via `entree`/
  `reapproBundle`, then soft-deletes), `genererPdf()` (DomPDF → `storage/factures/{numero}.pdf`).
- **`QrCodeService`** / **`BarcodeService`** — generate PNG/SVG codes for variations, bundles,
  transferts; store on `Storage::disk('public')`; SKUs are ASCII-sanitized for Code128.

## Filament admin panel

Panel id `admin`, brand "Event Bag", primary color `#8B5E3C` (configurable brand/logo/favicon
live via `Setting` model + `MonEntreprise` page — panel provider reads `Setting::get()` at
boot, so branding is admin-editable without redeploying). Nav groups: **Stock**, **Ventes**,
**Catalogue**, **Configuration** (collapsed).

### Stock group

| Page/Resource | Role |
|---|---|
| `Scanner` (`app/Filament/Pages/Scanner.php`) | Livewire page (not a Resource). Scan-to-queue UX: each scan adds/increments a line in `$this->queue` (keyed by SKU), user picks operation (entree/sortie/ajustement) + depot, then `validerTout()` applies everything through `StockService` in one pass. Handles both variations and bundles. |
| `EnStock` | Read-only stock listing per variation (SQL subquery for `stock_total`, superadmin sees cross-depot sum, others see their depot only) plus a separate `getBundleRows()` for bundle availability (min of composant stock / composant qty per depot). Modal drill-downs: per-depot stock detail, 100-row movement history. |
| `MouvementStockResource` | Read-only (`canCreate() => false`, empty form). The interesting part: it *groups* multi-line movements from a single source (e.g. all lines of one transfer reception) into one visual row with a "+N" badge and a "view group" modal, using correlated subqueries keyed on `(source_type, source_id, depot_id, type)`. Filterable by type/depot/date range, polls every 30s. |
| `InventaireResource` | Stock-count sessions. Repeater lines auto-compute `stock_theorique` from live `Stock`/`Bundle::getStockDisponible()` on SKU selection. `valider` action applies `StockService::ajustement` per line inside one transaction and locks the record (only re-deletable by super_admin with a warning). |
| `TransfertResource` (963 lines — the most complex resource) | See below. |

### TransfertResource in detail

- Create form has a **scan-to-add** `TextInput` (`scan_sku`) that resolves SKU → bundle or
  variation and appends/increments a repeater line — same UX pattern as the Scanner page, reused
  inline in a resource form.
- **Live stock-sufficiency warnings**: as quantities are typed, an `afterStateUpdated` hook
  queries `Stock` directly and fires a persistent warning notification if source-depot stock
  can't cover the request (checked again server-side before `expedier`).
- **`expedier`** action: re-validates stock across all lines (bundles expand into components),
  calls `StockService::transfertSortie` per line/component inside a transaction, flips to
  `expedie`, and notifies (`NotifHelper::sendNow` — bypasses the notification queue so it lands
  in the topbar bell immediately) every `responsable_depot`/`super_admin` at the destination
  depot.
- **`recevoir`** action opens a custom form containing raw injected Alpine/HTML for a live camera
  scanner (`BarcodeDetector` API, falls back to `jsQR` from CDN) dispatching a
  `reception-scan` window event that a hidden `scan_sku` field listens for, plus a "Quantités
  correctes" toggle that bulk-fills all `ligne_{id}_recu` fields from the sent quantities. On
  submit: writes `quantite_recue`/`ecart` per line, calls `transfertEntree`, sets status to
  `recu` or `recu_avec_ecart` depending on whether any line has a nonzero ecart, notifies the
  source depot.
- **Restock requests** (`demande_restock`): a destination depot can request stock (creation flow
  not fully traced here but the status exists); source-depot users see `approuver`/`refuser`
  actions. `approuver` shows a pre-flight stock-sufficiency check
  (`alertesStockDemande()`/`buildModalApprobation()`) but allows approving anyway ("Approuver
  quand même"). A navigation badge on the Transferts nav item shows the count of pending
  `demande_restock` rows scoped to the user's depot.
- **Locking**: `recu`/`recu_avec_ecart`/`annule`/`demande_restock` transferts cannot be edited by
  anyone (including super_admin) via the table Edit action; `EditTransfert` page additionally
  intercepts direct URL access in `authorizeAccess()`. Deleting an `expedie` transfer reverses
  the stock movement first (`before()` hook on `DeleteAction`).
- Row-level visibility throughout is `depot_source_id`/`depot_destination_id` scoped for non-
  super-admins (`getEloquentQuery()` override), matching the same "see only your depot" pattern
  used across the app.

### Ventes group

- **`ClientResource`** — particulier / entreprise / grossiste; fiscal fields
  (denomination_sociale, RC, NIF, AI, NIS, capital) only required/shown when `type === entreprise`.
- **`FactureResource`** — types `facture`/`bon_vente`/`bon_livraison` (avoir exists in the model
  but isn't creatable from the UI type select). `mode_prix` (HT/TTC) toggles the default TVA rate
  applied to new lines. A "Showroom" hint-action on the client select auto-creates throwaway
  walk-in clients named `Showroom Client N`. Lines pick a unified SKU dropdown
  (variations + bundles, prefixed `b_` for bundles) that auto-fills designation/sku/price; totals
  are recalculated automatically by the `FactureLigne`/`Facture` model hooks (see CLAUDE.md).
  Table actions: `confirmer` (draft→confirmed, triggers stock exit), `pdf` (opens
  `FacturePdfController`), and a super-admin-only `supprimer_confirme` that reverses stock via
  `FactureService::annulerEtSupprimer()` before deleting.

### Catalogue group (all super-admin-only create/edit/delete)

- **`ProduitResource`** — parent product + a `variations` repeater (relationship-backed) so a
  product and all its size/color variants can be created in one form. Cross-table SKU-uniqueness
  is enforced manually via closures checking `Produit`/`Variation`/`Bundle` tables against each
  other (no shared SKU namespace at the DB level, so this is app-level only).
- **`VariationResource`** — direct CRUD on individual SKUs when not going through the parent
  product; shows generated QR/barcode previews and has per-row/bulk "generate codes" and "print
  labels" actions.
- **`BundleResource`** — bundle metadata + a `composants` repeater picking real variations by SKU
  with a quantity multiplier.

### Configuration group

- **`DepotResource`** (super_admin only) — depot CRUD, `is_virtual` flag marks non-physical
  depots (Transit, E-Commerce) that hold stock in transit / awaiting fulfillment.
- **`UserResource`** — super_admin sees/manages everyone; `responsable_depot` can only create/
  edit `employe`-role users within their own depot (enforced in `canEdit`/`canDelete`/
  `scopeQuery` — a `responsable_depot` cannot elevate anyone to their own role or above).
- **`MonEntreprise`** (super_admin only) — a non-Resource settings page (`Setting::set()` per
  key) for company legal identity, contact info, and the three white-label images (light logo,
  dark logo, favicon) that `AdminPanelProvider` reads at boot.

## Dashboard widgets

`StatsOverview` (6 stat cards: stock total, valeur stock, ruptures, alertes seuil, CA du mois
±% vs mois précédent, factures brouillon), `StockParDepot` (super-admin only, per-depot
breakdown), `AlertesStock` (custom view widget listing rupture/low-stock items), `EcommerceStats`
(super-admin only — tracks `transfert_in`/`transfert_out` movements against the `code = 'ECOM'`
depot as a proxy for online-order fulfillment/returns, with 7-day trend sparklines),
`MeilleursProduitsWidget` (best-sellers table driven by the same ECOM-depot `transfert_in`
convention, with a date-range picker and period-over-period comparison). `MouvementsRecents`
exists but is explicitly undiscovered (`$isDiscovered = false`) — superseded by the dedicated
Mouvements nav page.

Note the **hardcoded depot IDs**: `MeilleursProduitsWidget::ECOM_DEPOT_ID = 5`. `EcommerceStats`
looks up the ECOM depot by `code` instead (more robust). Worth normalizing if depot IDs ever
change on a fresh seed.

## Roles & authorization

Three Spatie roles: `super_admin`, `responsable_depot`, `employe`. `AppServiceProvider` sets a
`Gate::before` that short-circuits everything to `true` for `super_admin` — so most
resource-level `can*()` overrides only need to handle the other two roles. `User.depot_id` is
the scoping key used almost everywhere (`getEloquentQuery()` overrides, form `options()`
callbacks, `canEdit`/`canDelete`). Only `Facture` has a dedicated Policy class
(`FacturePolicy`); everything else scopes ad-hoc inside the Resource.

## Import pipeline

`php artisan import:eventbag` (`app/Console/Commands/ImportEventBag.php`) scrapes
`eventbagdz.com`'s Shopify storefront JSON API (`/products/{handle}.json`) for 16 hardcoded
product handles (4 collections × {cabine, medium, large, SET}). For SET products, Shopify
returns every individual size as a variant too — the importer groups variants by color and keeps
only the highest-priced one per color (the true set price). Auto-detects which Shopify option
index is "color" by name-matching, maps known color names to a fixed hex+3-letter-code table
(`colorMap`), and falls back to a slugified 3-letter code for unknown colors. Downloads images,
strips Shopify's crop/width query params, generates QR+barcode, and creates `Stock` rows for
every active non-virtual depot. `--force` wipes existing EventBag-branded products first;
`--no-images` skips downloads (useful for fast re-imports/testing).

## Notifications

Filament's database notifications are enabled panel-wide (`databaseNotifications()`, 10s
polling). `NotifHelper::sendNow()` exists because `notify()` would queue the notification (and
this app doesn't run a persistent queue worker in prod reliably) — it calls `notifyNow()`
directly so the topbar bell updates synchronously. Used for: transfer expedited (notifies
destination), transfer received (notifies source, tone differs if there was an ecart), restock
request approved/refused (notifies destination).

## Known gaps / things to be aware of before changing code

- **Test coverage is essentially nonexistent** — only Laravel's default `ExampleTest` ×2 and one
  `PwaSmokeTest` (asserts the admin dashboard renders PWA markup). None of `StockService`,
  `FactureService`, or the Transfert lifecycle has automated tests. Change stock/invoice/transfer
  logic carefully and consider manual QA via `/admin` — there's no safety net.
- `Facture.statut` includes `expedie`/`retourne` values with no corresponding service methods or
  UI actions yet — likely half-built groundwork for a future flow.
- SKU uniqueness across `Produit`/`Variation`/`Bundle` is enforced only by manual validation
  closures repeated in three different Resource files, not a DB constraint or shared trait —
  if a fourth SKU-bearing model is ever added, remember to update all three closures too.
- `MeilleursProduitsWidget` hardcodes `ECOM_DEPOT_ID = 5`; `EcommerceStats` looks it up by depot
  `code = 'ECOM'` instead. Prefer the code-lookup pattern in new code.
- The Alpine `@script`/camera-scanner blocks are raw HTML strings embedded via
  `new \Illuminate\Support\HtmlString(...)` inside PHP (see `TransfertResource::recevoir` and
  `CLAUDE.md`'s note about `@script` comment gotchas) — easy to break silently since there's no
  linting or testing over embedded JS.
- Currency is Algerian Dinar (DA/DZD) with 19% default TVA throughout; `NumberToWords` only
  supports French number-to-words conversion for PDF amounts.

## Dev environment

```bash
composer setup   # install, .env, key:generate, migrate, npm build
composer dev      # serve + queue:listen + pail (logs) + vite, concurrently
composer test      # config:clear + php artisan test
./vendor/bin/pint  # lint
php artisan db:seed                                  # roles, 2 depots + transit, 3 users, sample catalog
php artisan db:seed --class=InventaireStockSeeder     # realistic per-depot stock via validated Inventaire
php artisan import:eventbag [--force] [--no-images]   # real EventBag catalog from Shopify
```

Seeded accounts: `admin@valisestock.dz` / `Admin@2024` (super_admin),
`resp.alger@valisestock.dz` / `Resp@2024` (responsable_depot), `employe@valisestock.dz` /
`Employe@2024` (employe). Production `.env` points at a live MySQL DB (`valise_stock`) on the
Plesk host — **be careful running seed/import commands there**, `import:eventbag --force`
force-deletes all EventBag-branded products first.
