PageSourceSearch

https://www.chargeops.net/assets/tech-warehouse-rma-Btj75g-5.js

js chargeops.net collected 2026-10-02 02:06:24 UTC 215,876 bytes, 418 lines download raw bytes

1import{j as e}from"./vendor-react-QE7wLE3r.js";import{D as m,i as j,a as o,c as a,P as r,e as s,U as d,L as i,C as p,S as n,d as u,K as c,m as l,l as t,O as x,h,W as v}from"./ui-BfT7Kz5y.js";import"./index-C5UqyXey.js";import"./vendor-radix-C7HJG0re.js";import"./vendor-motion-DpnOviVe.js";function I(){return e.jsxs(m,{title:"05 — Entrepôt & RMA",intro:e.jsxs(r,{children:["Ce chapitre couvre les deux systèmes de gestion d'inventaire physique de ChargeOps : l'",e.jsx(n,{children:"Entrepôt (Warehouse)"})," — bornes pré-installation stockées avant assignation à un site, identifiées par un PE-ID unique — et le ",e.jsx(n,{children:"RMA (Return Merchandise Authorization)"})," — bornes défectueuses retournées du terrain, suivies avec leur historique de garantie, leurs pièces jointes et leurs SIM cards. Les deux systèmes partagent la table ",e.jsx(s,{children:"chargers"})," (avec ",e.jsx(s,{children:"siteId"})," ","nullable) et sont reliés par les mouvements bidirectionnels enregistrés dans"," ",e.jsx(s,{children:"chargerMovementHistory"}),"."]}),children:[e.jsx(j,{items:[{label:"Développeur",tone:"indigo"},{label:"Ops",tone:"teal"}]}),e.jsxs(o,{id:"partie-1-entrepot",title:"Partie 1 — Entrepôt (Warehouse)",children:[e.jsxs(a,{id:"entrepot-vue-densemble",title:"1. Vue d'ensemble",children:[e.jsxs(r,{children:["L'entrepôt est un espace logique (et non une table dédiée) qui regroupe toutes les bornes de la table ",e.jsx(s,{children:"chargers"})," dont :"]}),e.jsxs(d,{children:[e.jsxs(i,{children:[e.jsx(s,{children:"siteId"})," est ",e.jsx(s,{children:"undefined"})," (",e.jsx(s,{children:"null"})," côté client),"]}),e.jsxs(i,{children:[e.jsx(s,{children:"deletedAt"})," est ",e.jsx(s,{children:"undefined"}),","]}),e.jsxs(i,{children:["aucun ",e.jsx(s,{children:"rmaChargers"})," actif n'est lié au ",e.jsx(s,{children:"chargerId"}),"."]})]}),e.jsxs(r,{children:["Cette séparation par ",e.jsx(s,{children:"siteId=null"})," permet de réutiliser une seule table"," ",e.jsx(s,{children:"chargers"})," pour les bornes installées et les bornes en stock, tout en garantissant une vue claire de l'inventaire pré-installation."]}),e.jsxs(p,{tone:"info",title:"Convention clé",children:["Une borne entre dans l'entrepôt à sa création (formulaire individuel ou bulk), reçoit un PE-ID, puis quitte l'entrepôt par un mouvement vers un site (",e.jsx(s,{children:"moveToSite"}),") ou un RMA. Elle peut revenir ensuite via"," ",e.jsx(s,{children:"moveToWarehouse"}),"."]}),e.jsxs(p,{tone:"warning",title:"Cadrage par organisation",children:["Les utilisateurs restreints par organisation (",e.jsx(s,{children:"users.organizationIds"})," ","non-vide, ex. ",e.jsx(s,{children:"site-operator"}),") ne voient ",e.jsx(n,{children:"aucune"})," borne d'entrepôt : une borne sans ",e.jsx(s,{children:"siteId"})," n'a pas d'organisation et sort de leur périmètre (",e.jsx(s,{children:"convex/lib/scope.ts"}),"). Le rôle ",e.jsx(s,{children:"site-operator"})," ","est de plus bloqué de la route ",e.jsx(u,{children:"/warehouse"}),"."]}),e.jsx(c,{rows:[{key:e.jsx(n,{children:"Source de la liste"}),value:e.jsx(s,{children:"api.chargers.queries.getWarehouseChargers"})},{key:e.jsx(n,{children:"Index utilisé"}),value:e.jsxs(e.Fragment,{children:[e.jsx(s,{children:"by_tenant_siteId_deletedAt"})," (sur ",e.jsx(s,{children:"chargers"}),"), puis"," ",e.jsx(s,{children:"by_tenant_deletedAt"})," (sur ",e.jsx(s,{children:"rmaChargers"}),") pour l'exclusion des RMA — tous deux préfixés par ",e.jsx(s,{children:"tenantId"})]})},{key:e.jsx(n,{children:"Page UI"}),value:e.jsx(u,{children:"client/src/pages/warehouse.tsx"})},{key:e.jsx(n,{children:"Filtre RMA"}),value:e.jsxs(e.Fragment,{children:["exclusion automatique (",e.jsx(s,{children:"inRma=true"}),")"]})}]})]}),e.jsxs(a,{id:"pe-id",title:"2. PE-ID",children:[e.jsxs(r,{children:["Le ",e.jsx(n,{children:"PE-ID"})," (Polara Equipment ID) est l'identifiant interne unique d'une borne tout au long de sa vie. Format :"]}),e.jsx(l,{language:"text",children:"PE-<n>       où n est un entier sans zéro de tête, ex. PE-1, PE-42, PE-1150"}),e.jsxs(r,{children:["Il est stocké dans ",e.jsx(s,{children:"chargers.name"})," (le champ ",e.jsx(s,{children:"name"})," joue le double rôle d'identifiant lisible et de clé fonctionnelle)."]}),e.jsxs(t,{id:"pool-attribution",title:"2.1 Pool & attribution",children:[e.jsxs(r,{children:["Le pool de PE-IDs est borné à ",e.jsx(n,{children:"PE-1 .. PE-1450"})," (constante"," ",e.jsx(s,{children:"max: 1450"})," passée par le frontend). La logique est implémentée par"," ",e.jsx(s,{children:"convex/warehouse/queries.ts::getUnusedPeIdsUpTo"})," :"]}),e.jsx(l,{language:"ts",children:`// convex/warehouse/queries.ts
2export const getUnusedPeIdsUpTo = moduleQuery("warehouse")({
3  args: { max: v.number() },
4  handler: async (ctx, { max }) => {
5    const ceiling = Math.max(1, Math.min(5000, Math.floor(max)));
6
7    // Every charger of the tenant, deleted ones included (CHARGEOPS-253).
8    const usedNumbers = await loadUsedPeIdNumbers(ctx);
9
10    const unused: string[] = [];
11    for (let n = 1; n <= ceiling; n++) {
12      if (!usedNumbers.has(n)) unused.push(\`PE-\${n}\`);
13    }
14    return unused;
15  },
16});`}),e.jsxs(r,{children:["L'attribution est ",e.jsx(n,{children:"séquentielle"})," : la liste retournée est triée par n croissant, donc le premier PE-ID dis
16ponible est toujours le plus petit numéro libre. La borne de 5000 est défensive (si un appelant passe une valeur trop grande)."]}),e.jsxs(r,{children:["Le helper ",e.jsx(s,{children:"loadUsedPeIdNumbers"})," (partagé avec"," ",e.jsx(s,{children:"getAvailablePeIds"})," et ",e.jsx(s,{children:"getNextAvailablePeId"}),") lit"," ",e.jsx(n,{children:"toutes"})," les bornes du locataire de l'appelant par"," ",e.jsx(s,{children:"chargers.by_tenant"}),", ",e.jsx(n,{children:"supprimées comprises"})," ","(CHARGEOPS-253) : un PE-ID n'est jamais réutilisé. Il ne lit plus"," ",e.jsx(s,{children:"rmaChargers"})," — une borne en RMA est une ligne supprimée comme une autre, donc déjà comptée. La lecture couvre aussi les bornes supprimées avant cette règle, dont la ligne ",e.jsx(s,{children:"availablePeIds"})," avait été libérée. Une borne supprimée (seule, en lot, avec son site ou par l'API) ou en RMA garde donc son PE-ID pour de bon ; seul un ",e.jsx(n,{children:"renommage"})," libère l'ancien nom."]})]}),e.jsx(t,{id:"source-de-verite",title:"2.2 Source de vérité",children:e.jsxs(r,{children:["La source de vérité est la table ",e.jsx(s,{children:"chargers"})," elle-même (et non"," ",e.jsx(s,{children:"availablePeIds"}),"). Cela garantit que la liste reflète toujours les données réelles, même si la table cache ",e.jsx(s,{children:"availablePeIds"})," dérive."]})}),e.jsxs(t,{id:"normalisation",title:"2.3 Normalisation",children:[e.jsxs(r,{children:["L'utilitaire ",e.jsx(s,{children:"normalizePeId"})," accepte les deux formes courantes (",e.jsx(s,{children:"PE-1150"})," ou ",e.jsx(s,{children:"1150"}),") :"]}),e.jsx(l,{language:"ts",children:`// convex/lib/peIdUtils.ts
17export function normalizePeId(name: string): string | null {
18  const trimmed = name.trim();
19  const withoutPrefix = trimmed.replace(/^PE-/i, "");
20  return /^\\d+$/.test(withoutPrefix) ? withoutPrefix : null;
21}
22
23export function toPeIdVariants(name: string): string[] {
24  const num = normalizePeId(name);
25  if (!num) return [name];
26  return [\`PE-\${num}\`, num];
27}`}),e.jsxs(r,{children:[e.jsx(s,{children:"toPeIdVariants"})," est utilisé pour vérifier l'unicité d'un PE-ID dans"," ",e.jsx(s,{children:"chargers.by_name"})," peu importe la forme stockée (legacy avec ou sans préfixe)."]})]}),e.jsxs(t,{id:"available-pe-ids",title:"2.4 Table availablePeIds (fallback / cache)",children:[e.jsxs(r,{children:["Une table ",e.jsx(s,{children:"availablePeIds"})," sert de cache rapide et de log d'attribution. Elle n'est plus la source de vérité pour la liste UI, mais elle est conservée pour deux raisons :"]}),e.jsxs(x,{children:[e.jsx(i,{children:"Permettre une réservation explicite d'un PE-ID (par exemple en lot)."}),e.jsx(i,{children:"Servir de référence rapide dans les exports de backup."})]}),e.jsxs(r,{children:["Schéma (",e.jsx(s,{children:"convex/schema.ts"}),") :"]}),e.jsx(l,{language:"ts",children:`const availablePeIds = defineTable({
28  tenantId: v.id("tenants"),
29  peId: v.string(),
30  isUsed: v.boolean(),
31  usedByChargerId: v.optional(v.id("chargers")),
32  assignedAt: v.optional(v.number()),
33  createdAt: v.number(),
34})
35  .index("by_peId", ["peId"])
36  .index("by_tenant", ["tenantId"])
37  .index("by_tenant_peId", ["tenantId", "peId"]);  // recherche d'un PE-ID dans ce locataire`}),e.jsxs(r,{children:["Les mutations correspondantes (",e.jsx(s,{children:"convex/warehouse/mutations.ts"}),") :"]}),e.jsx(h,{headers:["Mutation","Rôle","Permission"],rows:[[e.jsx(s,{children:"markPeIdAsUsed(peId, chargerId)"},"m1"),"Marque un PE-ID comme utilisé.","Admin"],[e.jsx(s,{children:"releasePeId(peId)"},"m2"),"Libère manuellement une ligne du pool (admin). La suppression d'une borne ne l'appelle plus (CHARGEOPS-253), et le PE-ID n'est pas reproposé tant qu'une borne, même supprimée, porte ce nom.","Admin"],[e.jsx(s,{children:"bulkInsertPeIds(peIds)"},"m3"),"Initialise/peuple le pool.","Admin"]]})]})]}),e.jsxs(a,{id:"schema-donnees-warehouse",title:"3. Schéma de données",children:[e.jsx(t,{id:"table-chargers",title:"3.1 Table chargers (extrait — champs entrepôt)",children:e.jsx(l,{language:"ts",children:`// convex/schema.ts:309
38const chargers = defineTable({
39  siteId: v.optional(v.id("sites")),         // undefined = entrepôt
40  transformerId: v.optional(v.id("transformers")),
41  name: v.string(),                          // "PE-XXXX"
42  type: v.string(),                          // "single-phase" | "three-phase"
43  phase: v.string(),                         // "A" | "B" | "C" | "all"
44  maxPower: v.float64(),
45  currentPower: v.float64(),
46  isActive: v.boolean(),
47  includeInCalculations: v.boolean(),
48  brandId: v.optional(v.id("chargerBrands")),
49  modelId: v.optional(v.id("chargerModels")),
50  serialNumber: v.optional(v.string()),
51  warrantyDuration: v.optional(v.number()),  // années
52  installationDate: v.optional(v.string()),
53  notes: v.optional(v.string()),
54  // Connectivité
55  connectivity: v.optional(v.string()),
56  carrier: v.optional(v.string()),
57  simIccid: v.optional(v.string()),
58  // Layout (forcé à 0 en entrepôt)
59  layoutX: v.float64(),
60  layoutY: v.float64(),
61  layoutRotation: v.number(),
62  createdAt: v.number(),
63  updatedAt: v.number(),
64  deletedAt: v.optional(v.number()),
65})
66  .index("by_siteId", ["siteId"])
67  .index("by_siteId_deletedAt", ["siteId", "deletedAt"])
68  .index("by_tenant_deletedAt", ["tenantId", "deletedAt"])
69  .index("by_tenant_siteId_deletedAt", ["tenantId", "siteId", "deletedAt"])  // inventaire entrepôt
70  .index("by_name", ["name"])
71  .index("by_tenant_serialNumber", ["tenantId", "serialNumber"])
72  // …`})}),e.jsx(t,{id:"table-charger-movement-history",title:"3.2 Table chargerMovementHistory",children:e.jsx(l,{language:"ts",children:`// convex/schema.ts
73const chargerMovementHistory = defineTable({
74  tenantId: v.id("tenants"),
75  chargerId: v.id("chargers"),
76  fromSiteId: v.optional(v.id("sites")),  // undefined = depuis entrepôt
77  toSiteId: v.optional(v.id("sites")),    // undefined = vers entrepôt
78  movedBy: v.id("users"),
79  notes: v.optional(v.string()),
80  movedAt: v.number(),
81})
82  .index("by_chargerId", ["chargerId"])
83  .index("by_tenant", ["tenantId"]);`})}),e.jsx(t,{id:"table-available-pe-ids",title:"3.3 Table availablePeIds",children:e.jsx(r,{children:"Voir section 2.4."})})]}),e.jsxs(a,{id:"ajout-borne-entrepot",title:"4. Ajout d'une borne en entrepôt",children:[e.jsxs(t,{id:"formulaire-individuel",title:"4.1 Formulaire individuel",children:[e.jsxs(r,{children:["Composant : ",e.jsx(s,{children:"WarehouseInventory"})," dans"," ",e.jsx(u,{children:"client/src/pages/warehouse.tsx"}),"."]}),e.jsx(r,{children:"Schéma de validation (Zod) :"}),e.jsx(l,{language:"ts",children:`const createWarehouseChargerSchema = z.object({
84  peId: z.string().optional(),
85  brandId: z.string({ required_error: t("selectBrand") }),
86  modelId: z.string({ required_error: t("selectModel") }),
87  simIccid: z.string().optional(),
88  carrier: z.string().optional(),
89  connectivity: z.enum([
90    "no-connectivity", "wifi", "ethernet", "4g", "ctrlbx",
91  ]).optional(),
92  notes: z.string().optional(),
93  serialNumber: z.string().optional(),
94});`}),e.jsxs(r,{children:["Le PE-ID est pré-rempli à partir du premier élément de"," ",e.jsx(s,{children:"getUnusedPeIdsUpTo({ max: 1450 })"}),". Tous les autres champs sont optionnels en entrepôt — voir section 12."]}),e.jsxs(r,{children:["À la soumission, la mutation ",e.jsx(s,{children:"api.chargers.mutations.create"})," est appelée avec :"]}),e.jsx(c,{rows:[{key:e.jsx(s,{children:"name"}),value:e.jsxs(e.Fragment,{children:["PE-ID choisi (",e.jsx(s,{children:'"PE-1234"'}),")"]})},{key:e.jsx(s,{children:"siteId"}),value:e.jsxs(e.Fragment,{children:["non fourni → ",e.jsx(s,{children:"undefined"})]})},{key:e.jsx(s,{children:"type"}),value:e.jsxs(e.Fragment,{children:[e.jsx(s,{children:'"AC"'})," (par défaut)"]})},{key:e.jsx(s,{children:"phase"}),value:e.jsx(s,{children:'"single"'})},{key:e.jsxs(e.Fragment,{children:[e.jsx(s,{children:"maxPower"}),", ",e.jsx(s,{children:"currentPower"})]}),value:e.jsx(s,{children:"0"})},{key:e.jsx(s,{children:"isActive"}),value:e.jsx(s,{children:"true"})},{key:e.jsx(s,{children:"includeInCalculations"}),value:e.jsx(s,{children:"false"})},{key:e.jsx(s,{children:"layoutX/Y/Rotation"}),value:e.jsx(s,{children:"0"})}]})]}),e.jsx(t,{id:"validation-creation",title:"4.2 Validation",children:e.jsxs(d,{children:[e.jsxs(i,{children:[e.jsx(n,{children:"Unicité du PE-ID"})," : la mutation"," ",e.jsx(s,{children:"chargers.mutations.create"})," rejette si ",e.jsx(s,{children:"name"})," existe déjà (vérification via ",e.jsx(s,{children:"by_name"})," et variantes)."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Cohérence brand/model"})," : le ",e.jsx(s,{children:"modelId"})," doit appartenir à ",e.jsx(s,{children:"brandId"})," (validé côté UI par filtrage de la liste des modèles)."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Format SIM ICCID"})," : pas de validation stricte (champ libre), mais l'unicité est vérifiée à la création."]})]})})]}),e.jsxs(a,{id:"ajout-bulk",title:"5. Ajout en lot (bulk)",children:[e.jsxs(r,{children:["Le composant ",e.jsx(s,{children:"AddMultipleChargersDialog"})," (lignes 191–700 de"," ",e.jsx(u,{children:"warehouse.tsx"}),") permet d'ajouter jusqu'à ",e.jsx(n,{children:"100 bornes"})," en une opération."]}),e.jsxs(t,{id:"bulk-flow",title:"5.1 Flow en deux étapes",children:[e.jsx(r,{children:e.jsx(n,{children:"Étape 1 — Compteur :"})}),e.jsxs(d,{children:[e.jsx(i,{children:"L'utilisateur saisit un nombre n entre 1 et 100."}),e.jsxs(i,{children:["L'UI vérifie que ",e.jsx(s,{children:"availablePeIds.length >= n"}),". Si non, toast d'erreur :"]})]}),e.jsx(p,{tone:"warning",children:e.jsx(s,{children:"insufficientPeIds (Available: X, Requested: Y)"})}),e.jsx(r,{children:e.jsx(n,{children:"Étape 2 — Tableau Excel-like :"})}),e.jsxs(d,{children:[e.jsx(i,{children:"n lignes pré-remplies avec les n premiers PE-IDs disponibles."}),e.jsx(i,{children:"Chaque ligne expose : PE-ID (select), Brand (select), Model (select dépen
94dant), Serial Number, SIM ICCID, Carrier, Connectivity, Notes."}),e.jsx(i,{children:"Le PE-ID peut être modifié via la liste déroulante (autres PE-IDs disponibles)."}),e.jsx(i,{children:"Le modèle se vide automatiquement quand la marque change."})]})]}),e.jsxs(t,{id:"bulk-mutation",title:"5.2 Mutation bulk",children:[e.jsxs(r,{children:["À la soumission, le payload dérive ",e.jsx(s,{children:"type"})," / ",e.jsx(s,{children:"phase"})," /"," ",e.jsx(s,{children:"maxPower"})," du modèle choisi (",e.jsx(s,{children:"inferChargerType"})," depuis"," ",e.jsx(u,{children:"client/src/lib/charger-import.ts"}),"), puis"," ",e.jsx(s,{children:"api.chargers.mutations.bulkImport"})," reçoit le tableau de"," ",e.jsx(s,{children:"chargers"})," complet, en un seul appel : ce dialogue plafonne à 100 bornes, soit une seule tranche, bien en deçà du plafond serveur"," ",e.jsx(s,{children:"MAX_BULK_IMPORT = 500"})," (",e.jsx(u,{children:"shared/charger-bulk-limits.ts"}),") au-delà duquel la mutation refuse l'appel avant toute écriture. La mutation :"]}),e.jsxs(x,{children:[e.jsx(i,{children:"Itère et insère chaque borne (création séquentielle pour respecter les contraintes d'unicité)."}),e.jsxs(i,{children:[e.jsx(n,{children:"Saute les doublons"})," : un nom déjà porté par une borne, vivante ou supprimée (toute variante ",e.jsx(s,{children:"PE-123"})," ↔ ",e.jsx(s,{children:"123"}),", index"," ",e.jsx(s,{children:"by_name"})," ; un PE-ID n'est jamais réutilisé, CHARGEOPS-253) ou répété dans le lot est refusé ligne par ligne, jamais en échec global."]}),e.jsxs(i,{children:["Émet un seul audit log ",e.jsx(s,{children:"Bulk import (N chargers)"})," par appel (aucun si 0 création) — l'import Excel (§5.3), envoyé par tranches, en écrit donc un par tranche de 100."]}),e.jsxs(i,{children:["Renvoie ",e.jsx(s,{children:"{ created, errors }"})," : les IDs Convex créés + les lignes refusées (",e.jsx(s,{children:"index"}),", ",e.jsx(s,{children:"name"}),", ",e.jsx(s,{children:"message"}),") que l'UI affiche en toast/rapport."]})]})]}),e.jsxs(t,{id:"bulk-import-excel",title:"5.3 Import depuis Excel",children:[e.jsxs(r,{children:["Un troisième mode (",e.jsx(s,{children:"isImportDialogOpen"}),") permet d'importer un fichier"," ",e.jsx(s,{children:".xlsx"}),". Le template est généré par ",e.jsx(s,{children:"downloadTemplate()"})," via"," ",e.jsx(s,{children:'chargerTemplateSpec("warehouse", language)'})," (",e.jsx(u,{children:"client/src/lib/charger-import.ts"}),") : une feuille"," ",e.jsx(s,{children:"Chargers"})," avec les en-têtes + une feuille"," ",e.jsx(s,{children:"Instructions"})," localisée listant les valeurs acceptées par colonne."]}),e.jsx(c,{rows:[{key:e.jsx(s,{children:"PE-ID"}),value:"Format PE-XXXX (sinon ignoré)"},{key:e.jsx(s,{children:"Brand"}),value:e.jsxs(e.Fragment,{children:["Nom de la marque (résolu en ",e.jsx(s,{children:"brandId"}),")"]})},{key:e.jsx(s,{children:"Model"}),value:e.jsxs(e.Fragment,{children:["Nom du modèle (résolu en ",e.jsx(s,{children:"modelId"}),")"]})},{key:e.jsx(s,{children:"Serial Number"}),value:"optionnel"},{key:e.jsx(s,{children:"SIM ICCID"}),value:"optionnel"},{key:e.jsx(s,{children:"Carrier"}),value:"optionnel"},{key:e.jsx(s,{children:"Connectivity"}),value:e.jsxs(e.Fragment,{children:[e.jsx(s,{children:"no-connectivity"})," | ",e.jsx(s,{children:"wifi"})," | ",e.jsx(s,{children:"ethernet"})," |"," ",e.jsx(s,{children:"4g"})," | ",e.jsx(s,{children:"ctrlbx"})]})},{key:e.jsx(s,{children:"Notes"}),value:"optionnel"}]}),e.jsxs(r,{children:["Le parsing passe par ",e.jsxs(s,{children:["parseChargerRows(rows, ",'{ mode: "warehouse" }',")"]})," ","(",e.jsx(u,{children:"client/src/lib/charger-import.ts"}),", partagé avec l'import de la page Toutes les Bornes) :"]}),e.jsxs(d,{children:[e.jsxs(i,{children:["Coercition des cellules (dates Excel → ",e.jsx(s,{children:"YYYY-MM-DD"}),", nombres, richText/hyperlink/formules) et en-têtes tolérants (FR/EN, casse, accents)."]}),e.jsxs(i,{children:[e.jsx(s,{children:"type"})," / ",e.jsx(s,{children:"phase"})," / ",e.jsx(s,{children:"maxPower"})," /"," ",e.jsx(s,{children:"warrantyDuration"})," dérivés du modèle du catalogue — fini les valeurs invalides ",e.jsx(s,{children:'type: "AC"'})," / ",e.jsx(s,{children:'phase: "single"'})," écrites par l'ancien import."]}),e.jsxs(i,{children:["Doublons détectés ",e.jsx(n,{children:"avant"})," l'envoi :"," ",e.jsx(s,{children:"checkPeIdAvailabilityBatch"})," (un seul round-trip) marque les PE-IDs déjà utilisés ;
94 les doublons internes au fichier sont aussi refusés."]}),e.jsxs(i,{children:["Limite ",e.jsx(s,{children:"MAX_IMPORT_ROWS = 500"})," lignes par fichier ; erreurs listées par numéro de ligne Excel via ",e.jsx(s,{children:"formatImportError"})," (FR/EN)."]}),e.jsxs(i,{children:["Envoi par tranches : les lignes prêtes partent vers"," ",e.jsx(s,{children:"bulkImport"})," par tranches séquentielles de"," ",e.jsx(s,{children:"BULK_IMPORT_CHUNK = 100"})," via ",e.jsx(s,{children:"runInChunks"})," (",e.jsx(u,{children:"client/src/lib/chunked-mutation.ts"}),") ;"," ",e.jsx(s,{children:"mergeBulkImport"})," replie les résultats et re-base l'",e.jsx(s,{children:"index"})," des erreurs serveur sur la soumission complète, si bien que le rapport garde les numéros de ligne Excel. Chaque tranche est sa propre transaction : si une tranche échoue après que d'autres ont réussi, les bornes déjà importées sont conservées et le toast « Importation interrompue » (",e.jsx(s,{children:"bulkImportStoppedTitle"})," /"," ",e.jsx(s,{children:"bulkImportStoppedDesc"}),") indique N bornes sur M ; réimporter le même fichier les ignore. Si la première tranche échoue, rien n'est écrit et le toast d'échec habituel s'affiche. Détails et justification des plafonds : « 03 — Sites, Bornes, Transformateurs », §14.2."]})]}),e.jsx(l,{language:"ts",children:`// client/src/pages/warehouse.tsx
95const run = await runInChunks(
96  readyRows.map((r) => r.payload),
97  BULK_IMPORT_CHUNK,
98  (chunk) => bulkImportFn({ chargers: chunk as any }),
99);
100// Nothing committed yet: report it as a plain failed import.
101if (run.error !== undefined && run.processed === 0) throw run.error;
102stoppedBy = run.error;
103const result = mergeBulkImport(run);`})]})]}),e.jsxs(a,{id:"filtrage-intelligent",title:"6. Filtrage intelligent",children:[e.jsxs(r,{children:["La query ",e.jsx(s,{children:"getWarehouseChargers"})," (",e.jsx(s,{children:"convex/chargers/queries.ts:817"}),") ",e.jsx(n,{children:"exclut automatiquement"})," ","toutes les bornes liées à un RMA actif :"]}),e.jsx(l,{language:"ts",children:`// convex/chargers/queries.ts:817
104export const getWarehouseChargers = tenantQuery({
105  args: {},
106  handler: async (ctx) => {
107    assertFeatureEnabled(ctx, "warehouse");
108    // Les utilisateurs limités à des organisations ne voient jamais le stock en entrepôt (pas de siteId = hors périmètre).
109    if (ctx.orgIds !== null) return [];
110
111    const warehouseChargerCandidates = await ctx.db
112      .query("chargers")
113      .withIndex("by_tenant_siteId_deletedAt", (q) =>
114        q
115          .eq("tenantId", ctx.tenantId)
116          .eq("siteId", undefined)
117          .eq("deletedAt", undefined),
118      )
119      .collect();
120
121    if (warehouseChargerCandidates.length === 0) return [];
122
123    // Exclusion des bornes en RMA actif
124    const allRmas = await ctx.db
125      .query("rmaChargers")
126      .withIndex("by_tenant_deletedAt", (q) =>
127        q.eq("tenantId", ctx.tenantId).eq("deletedAt", undefined),
128      )
129      .collect();
130
131    const rmaChargerIds = new Set<string>(allRmas.map((r) => r.chargerId));
132
133    const warehouseChargers = warehouseChargerCandidates.filter(
134      (c) => !rmaChargerIds.has(c._id),
135    );
136    // …
137  },
138});`}),e.jsxs(r,{children:["Les deux lectures sont ",e.jsx(n,{children:"préfixées par le locataire"}),". Les plages non préfixées ",e.jsx(s,{children:"chargers.by_siteId_deletedAt"})," /"," ",e.jsx(s,{children:"rmaChargers.by_deletedAt"})," couvraient le stock d'entrepôt et les RMA de"," ",e.jsx(n,{children:"tous"})," les locataires : elles étaient lues en entier, puis le lecteur RLS écartait une à une les lignes des autres locataires — un coût qui croissait avec le déploiement plutôt qu'avec le locataire. Le résultat est identique ; seules les lignes lues se réduisent à celles de ce locataire. ",e.jsx(s,{children:"chargers.by_siteId_deletedAt"})," ","existe toujours et sert les lecteurs qui passent un ",e.jsx(s,{children:"siteId"})," concret ;"," ",e.jsx(s,{children:"rmaChargers.by_deletedAt"}),", qui n'avait plus aucun lecteur, a été retiré du schéma (CHARGEOPS-144)."]}),e.jsxs(p,{tone:"info",title:"Conséquence",children:["Une borne déplacée en RMA disparaît de la vue entrepôt même si son"," ",e.jsx(s,{children:"siteId"})," est ",e.jsx(s,{children:"undefined"}),". Elle réapparaît automatiquement après hard-delete du RMA ou résolution complète (le RMA est ",e.jsx(s,{children:"deletedAt"})," ≠"," ",e.jsx(s,{children:"undefined"}),")."]})]}),e.jsxs(a,{id:"recherche-filtres",title:"7. Recherche & filtres",children:[e.jsxs(r,{children:["Côté UI (",e.jsx(u,{children:"warehouse.tsx"}),", ",e.jsx(s,{children:"filteredAndSortedChargers"}),") :"]}),e.jsxs(d,{children:[e.jsxs(i,{children:[e.jsx(n,{children:"Recherche full-text"})," sur PE-ID (",e.jsx(s,{children:"name"}),"),"," ",e.jsx(s,{children:"serialNumber"})," et ",e.jsx(s,{children:"simIccid"})," (insensible à la casse,"," ",e.jsx(s,{children:"includes()"}),")."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Tri"})," sur n'importe quelle colonne (",e.jsx(s,{children:"name"}),","," ",e.jsx(s,{children:"brandName"}),", ",e.jsx(s,{children:"modelName"}),", ",e.jsx(s,{children:"serialNumber"}),","," ",e.jsx(s,{children:"simIccid"}),", ",e.jsx(s,{children:"carrier"}),", ",e.jsx(s,{children:"connectivity"}),","," ",e.jsx(s,{children:"createdAt"}),") avec inversion ASC ↔ DESC au clic répété."]})]}),e.jsxs(r,{children:["Les filtres sont gérés en mémoire (React ",e.jsx(s,{children:"useMemo"}),") car le volume reste modeste (~1-2 milliers de bornes max)."]})]}),e.jsxs(a,{id:"assignation-site",title:"8. Assignation à un site",children:[e.jsxs(r,{children:["Mutation : ",e.jsx(s,{children:"api.chargers.mutations.moveToSite"})," (",e.jsx(s,{children:"convex/chargers/mutations.ts:506"}),")."]}),e.jsxs(t,{id:"assignation-dialog",title:"8.1 Dialog UI",children:[e.jsxs(r,{children:["Composant ",e.jsx(s,{children:"AssignToSite"})," :"]}),e.jsx(c,{rows:[{key:e.jsx(n,{children:"Site"}),value:e.jsxs(e.Fragment,{children:[e.jsx(s,{children:"SiteCombobox"})," (recherche par nom)"]})},{key:e.jsx(n,{children:"Transformer"}),value:"dépendant du site (vide si aucun, sinon select)"},{key:e.jsx(n,{children:"Notes"}),value:e.jsxs(e.Fragment,{children:["textarea libre (capturée dans ",e.jsx(s,{children:"chargerMovementHistory.notes"}),")"]})}]})]}),e.jsxs(t,{id:"assignation-logique",title:"8.2 Logique de la mutation",children:[e.jsx(l,{language:"ts",children:`// convex/chargers/mutations.ts:506 (extrait)
139export const moveToSite = mutation({
140  args: {
141    id: v.id("chargers"),
142    siteId: v.id("sites"),
143    transformerId: v.optional(v.id("transformers")),
144    clearTransformer: v.optional(v.boolean()),
145    notes: v.optional(v.string()),
146  },
147  handler: async (ctx, { id, siteId, transformerId, clearTransformer, notes }) => {
148    const user = await requireAuth(ctx);
149    const charger = await ctx.db.get(id);
150    if (!charger) throw new Error("Charger not found");
151    const site = await ctx.db.get(siteId);
152    if (!site) throw new Error("Site not found");
153
154    if (transformerId) {
155      const t = await ctx.db.get(transformerId);
156      if (!t) throw new Error("Transformer not found");
157      if (t.siteId !== siteId) {
158        throw new Error("Transformer does not belong to the destination site");
159      }
160    }
161
162    const fromSiteId = charger.siteId;
163    const now = Date.now();
164
165    const patch: Record<string, unknown> = { siteId, updatedAt: now };
166    if (transformerId) patch.transformerId = transformerId;
167    else if (clearTransformer) patch.transformerId = undefined;
168    await ctx.db.patch(id, patch);
169
170    await ctx.db.insert("chargerMovementHistory", {
171      chargerId: id, fromSiteId, toSiteId: siteId,
172      movedBy: user._id, notes, movedAt: now,
173    });
174
175    await insertAuditLog(ctx, { /* … */ });
176    return id;
177  },
178});`}),e.jsx(r,{children:"Étapes :"}),e.jsxs(x,{children:[e.jsxs(i,{children:["Vérification ",e.jsx(s,{children:"charger"})," et ",e.jsx(s,{children:"site"})," existent."]}),e.jsxs(i,{children:["Si ",e.jsx(s,{children:"transformerId"})," fourni, vérifier qu'il appartient au site cible (sinon ",e.jsx(s,{children:"throw"}),")."]}),e.jsxs(i,{children:["Patch ",e.jsx(s,{children:"chargers"})," avec le nouveau ",e.jsx(s,{children:"siteId"})," (et"," ",e.jsx(s,{children:"transformerId"})," ou ",e.jsx(s,{children:"undefined"}),")."]}),e.jsxs(i,{children:["Insertion d'un enregistrement ",e.jsx(s,{children:"chargerMovementHistory"})," (snapshot avant/après)."]}),e.jsx(i,{children:"Audit log."})]}),e.jsxs(r,{children:["Le PE-ID (stocké dans ",e.jsx(s,{children:"name"}),') est conservé tel quel — il "voyage" avec la borne.']})]}),e.jsx(t,{id:"mises-a-jour-temps-reel",title:"8.3 Mises à jour temps réel",children:e.jsxs(r,{children:["Convex étant réactif, l'UI entrepôt et la liste des bornes du site cible sont mises à jour instantanément (les ",e.jsx(s,{children:"useQuery"})," correspondants se réinvalident automatiquement)."]})}),e.jsx(t,{id:"variante-bulk",title:"8.4 Variante bulk",children:e.jsxs(r,{children:[e.jsx(s,{children:"moveManyToSite"})," (",e.jsx(s,{children:"convex/chargers/mutations.ts:971"}),") déplace plusieurs bornes vers le même site/transformateur. Renvoie un résumé"," ",e.jsx(s,{children:"{ movedCount, failedCount, errors[] }"})," permettant à l'UI d'afficher un partial success plutôt qu'un rollback global. Les ids sont dédoublonnés, puis l'appel est refusé au-delà de"," ",e.jsx(s,{children:"MAX_BULK_CHARGERS = 100"})," bornes distinctes ; la page Toutes les Bornes envoie une grande sélection par tranches de"," ",e.jsx(s,{children:"BULK_MOVE_CHUNK = 50"})," via ",e.jsx(s,{children:"runInChunks"})," et additionne les résumés. Si une tranche échoue après d'autres, les bornes déjà déplacées le restent et le toast (",e.jsx(s,{children:"chargersMoveStoppedDesc"}),") indique N bornes sur M."]})})]}),e.jsxs(a,{id:"retour-entrepot",title:"9. Retour vers entrepôt",children:[e.jsxs(r,{children:["Mutation : ",e.jsx(s,{children:"api.chargers.mutations.moveToWarehouse"})," (",e.jsx(s,{children:"convex/chargers/mutations.ts:671"}),")."]}),e.jsx(l,{language:"ts",children:`export const moveToWarehouse = mutation({
179  args: { id: v.id("chargers"), notes: v.optional(v.string()) },
180  handler: async (ctx, { id, notes }) => {
181    const user = await requireAuth(ctx);
182    const charger = await ctx.db.get(id);
183    if (!charger) throw new Error("Charger not found");
184    if (!charger.siteId) {
185      throw new Error("Charger is already in the warehouse");
186    }
187    const fromSiteId = charger.siteId;
188    const now = Date.now();
189
190    // Convex traite undefined comme suppression du champ
191    await ctx.db.patch(id, { siteId: undefined, updatedAt: now });
192
193    await ctx.db.insert("chargerMovementHistory", {
194      chargerId: id,
195      fromSiteId,
196      toSiteId: undefined,
197      movedBy: user._id,
198      notes,
199      movedAt: now,
200    });
201    // … audit log
202  },
203});`}),e.jsx(r,{children:e.jsx(n,{children:"Points clés :"})}),e.jsxs(d,{children:[e.jsxs(i,{children:[e.jsx(s,{children:"siteId: undefined"}),' retire le champ du document (pattern Convex pour "no value").']}),e.jsxs(i,{children:[e.jsx(s,{children:"transformerId"}
203)," n'est PAS effacé : la borne le re-trouvera si elle revient sur le même site."]}),e.jsxs(i,{children:["Toutes les métadonnées (brand/model/serial/SIM/connectivity/notes) sont"," ",e.jsx(n,{children:"conservées"}),"."]}),e.jsx(i,{children:"L'historique de mouvement est complet (la borne peut faire des allers-retours entrepôt ↔ site)."})]}),e.jsxs(r,{children:["L'UI exige une ",e.jsx(n,{children:"confirmation"}),` (AlertDialog) avant d'invoquer la mutation, et permet de saisir des notes de retour (ex. "Démontée par tech X, défaut intermittent").`]})]}),e.jsxs(a,{id:"historique-mouvements",title:"10. Historique de mouvements",children:[e.jsxs(r,{children:["Query : ",e.jsx(s,{children:"api.chargers.queries.getMovementHistory(chargerId)"})," (",e.jsx(s,{children:"convex/chargers/queries.ts"}),")."]}),e.jsxs(r,{children:["Retourne tous les mouvements en ordre ",e.jsx(n,{children:"antichronologique"})," (plus récent d'abord), enrichis avec :"]}),e.jsxs(d,{children:[e.jsxs(i,{children:[e.jsx(s,{children:"from"}),", ",e.jsx(s,{children:"to"})," : un ",e.jsx(s,{children:"SiteLabel"})," (",e.jsx(s,{children:"shared/charger-timeline.ts"}),") construit par"," ",e.jsx(s,{children:"convex/lib/scope.ts::siteLabeler"})," — le même masque que le passeport de la borne : ",e.jsx(s,{children:'{ kind: "site", id, name }'})," si le site est dans la portée de l'appelant (",e.jsx(s,{children:"loadSitesByIds"})," + ",e.jsx(s,{children:"filterSitesByScope"}),": organisations, liste de sites d'un sous-exploitant, locataire),"," ",e.jsx(s,{children:'{ kind: "outOfScope" }'})," sinon ou si le site ne se charge plus — sans nom ni identifiant —, ",e.jsx(s,{children:'{ kind: "none" }'})," pour l'entrepôt. Les champs bruts ",e.jsx(s,{children:"fromSiteId"})," / ",e.jsx(s,{children:"toSiteId"})," ne sont jamais renvoyés, et pour un appelant restreint (organisations ou liste de sites),"," ",e.jsx(s,{children:"notes"})," vaut ",e.jsx(s,{children:"null"})," sur une ligne dont une extrémité est"," ",e.jsx(s,{children:"outOfScope"})," — le texte libre peut nommer le site (CHARGEOPS-257)."]}),e.jsxs(i,{children:["Rien sur l'auteur du déplacement : ni ",e.jsx(s,{children:"movedBy"})," ni nom — aucun écran ne l'affiche, et les événements de déplacement du passeport n'en portent pas non plus (CHARGEOPS-262)."]}),e.jsxs(i,{children:["Accès : réservé au personnel (",e.jsx(s,{children:"requireStaffAppAccess"})," — le portail des exploitants ne montre jamais les déplacements ; le personnel plateforme qui porte aussi le rôle exploitant de site passe, toujours masqué à ses organisations hors d'une représentation, CHARGEOPS-290), puis"," ",e.jsx(s,{children:"loadPassportSubject"})," : une borne inconnue, d'un autre locataire ou hors de la portée de l'appelant reçoit le même refus neutre « Borne introuvable. », comme le passeport (CHARGEOPS-262)."]})]}),e.jsx(l,{language:"ts",children:`const sitesById = await loadSitesByIds(ctx, siteIds);
204const label = siteLabeler(ctx, sitesById);
205return history.map((h) => ({
206  _id: h._id, _creationTime: h._creationTime, chargerId: h.chargerId,
207  from: label(h.fromSiteId),
208  to: label(h.toSiteId),
209  movedAt: h.movedAt,
210  notes: h.notes ?? null,
211}));`}),e.jsxs(t,{id:"dialog-historique",title:"10.1 Dialog historique",children:[e.jsxs(r,{children:["Composant : ",e.jsx(s,{children:"isHistoryDialogOpen"})," dans ",e.jsx(u,{children:"warehouse.tsx"}),". Affiche une timeline verticale :"]}),e.jsx(c,{rows:[{key:e.jsx(n,{children:"Date / heure"}),value:e.jsxs(e.Fragment,{children:[e.jsx(s,{children:"movedAt"})," formaté ",e.jsx(s,{children:"PP p"})]})},{key:e.jsx(n,{children:"De → Vers"}),value:e.jsxs(e.Fragment,{children:["Nom du site (",e.jsx(s,{children:'kind: "site"'}),"), ",e.jsx(s,{children:'t("warehouse")'})," (",e.jsx(s,{children:"none"}),") ou ",e.jsx(s,{children:'t("movementOtherSite")'})," « Un autre site » (",e.jsx(s,{children:"outOfScope"}),")"]})},{key:e.jsx(n,{children:"Notes"}),value:e.jsxs(e.Fragment,{children:[e.jsx(s,{children:"notes"})," (libre, multilignes)"]})}]}),e.jsx(r,{children:"L'UI ne reçoit plus d'identifiant de site : elle affichait auparavant « Site #<id> », et « Site #undefined » pour une extrémité entrepôt."})]})]}),e.jsx(a,{id:"export-excel-warehouse",title:"11. Pas d'export d'inventaire",children:e.jsxs(r,{children:["La page Entrepôt (",e.jsx(s,{children:"/warehouse"}),") n'exporte pas son inventaire : aucun bouton ne génère de fic
211hier à partir de la table. Le seul fichier qu'elle produit est le"," ",e.jsx(n,{children:"modèle d'import"})," (« Télécharger le modèle Excel ») :"," ",e.jsx(s,{children:"warehouse-charger-import-template.xlsx"}),", construit par"," ",e.jsx(s,{children:'chargerTemplateSpec("warehouse", language)'})," et ",e.jsx(s,{children:"@/lib/excel"}),", avec une feuille de colonnes (en-têtes en anglais) et une feuille d'instructions dans la langue de l'app (§5.3)."]})}),e.jsxs(a,{id:"champs-nullables",title:"12. Champs nullables",children:[e.jsxs(r,{children:["Lors d'une création en entrepôt, plusieurs champs habituellement requis pour une borne installée deviennent ",e.jsx(n,{children:"optionnels"})," :"]}),e.jsx(h,{headers:["Champ","Site assigné","Entrepôt"],rows:[[e.jsx(s,{children:"siteId"},"f1"),"requis",e.jsx(s,{children:"undefined"},"v1")],[e.jsx(s,{children:"transformerId"},"f2"),"optionnel",e.jsx(s,{children:"undefined"},"v2")],[e.jsx(s,{children:"installationDate"},"f3"),"requis",e.jsx(s,{children:"undefined"},"v3")],[e.jsx(s,{children:"maxPower"},"f4"),"> 0 attendu",e.jsxs(e.Fragment,{children:[e.jsx(s,{children:"0"})," (placeholder)"]})],[e.jsxs(e.Fragment,{children:[e.jsx(s,{children:"phase"},"f5")," / ",e.jsx(s,{children:"type"})]}),"spécifié",e.jsxs(e.Fragment,{children:[e.jsx(s,{children:'"single"'})," / ",e.jsx(s,{children:'"AC"'})," (placeholder)"]})],[e.jsx(s,{children:"layoutX/Y/Rotation"},"f6"),"calculés par layout designer",e.jsx(s,{children:"0"},"v6")],[e.jsx(s,{children:"connectivity"},"f7"),"requis",e.jsxs(e.Fragment,{children:[e.jsx(s,{children:'"no-connectivity"'})," (default)"]})]]}),e.jsxs(r,{children:["Ces champs sont ",e.jsx(n,{children:"complétés au moment du déplacement vers un site"}),", soit via le dialog d'assignation, soit via le layout designer (positions XY)."]})]})]}),e.jsxs(o,{id:"partie-2-rma",title:"Partie 2 — RMA (Return Merchandise Authorization)",children:[e.jsxs(a,{id:"rma-vue-densemble",title:"13. Vue d'ensemble",children:[e.jsxs(r,{children:["Le système RMA suit les bornes ",e.jsx(n,{children:"défectueuses retournées du terrain"})," :"]}),e.jsxs(d,{children:[e.jsxs(i,{children:["Une borne sur site présente un défaut → un ",e.jsx(s,{children:"rmaChargers"})," est créé."]}),e.jsx(i,{children:"La borne quitte la vue entrepôt (filtrage automatique, section 6) le temps du RMA."}),e.jsx(i,{children:"Le statut évolue le long du cycle de vie (section 14)."}),e.jsxs(i,{children:["Toutes les pièces (notes, fichiers, SIM cards, historique de statut) sont rattachées via ",e.jsx(s,{children:"rmaId"}),"."]}),e.jsxs(i,{children:["La traçabilité du ",e.jsx(n,{children:"site d'origine"})," est figée à la création (snapshot)."]})]}),e.jsx(c,{rows:[{key:e.jsx(n,{children:"Source de la liste"}),value:e.jsxs(e.Fragment,{children:[e.jsx(s,{children:"api.rma.queries.getAll"})," (actifs), ",e.jsx(s,{children:"getHistory"})," ","(historique)"]})},{key:e.jsx(n,{children:"Page UI"}),value:e.jsxs(e.Fragment,{children:[e.jsx(u,{children:"client/src/pages/rma/rma-page.tsx"})," (CHARGEOPS-586 a séparé l'ancien ",e.jsx(u,{children:"rma.tsx"})," dans ",e.jsx(u,{children:"client/src/pages/rma/"})," ;"," ",e.jsx(u,{children:"rma.tsx"})," est maintenant une simple réexportation)"]})},{key:e.jsx(n,{children:"Tables liées"}),value:e.jsxs(e.Fragment,{children:[e.jsx(s,{children:"rmaChargers"}),", ",e.jsx(s,{children:"rmaFiles"}),", ",e.jsx(s,{children:"rmaSimCards"}),","," ",e.jsx(s,{children:"rmaStatusHistory"})]})},{key:e.jsx(n,{children:"Storage"}),value:e.jsxs(e.Fragment,{children:[e.jsx(s,{children:"_storage"})," Convex (uploads)"]})}]})]}),e.jsxs(a,{id:"cycle-de-vie",title:"14. Cycle de vie",children:[e.jsxs(r,{children:["Le statut d'un RMA est une ",e.jsx(n,{children:"string"}),' validée côté client. Dix valeurs sont possibles, regroupées en trois phases logiques (selon le README "3 états" : Not Received → Received → Processed) :']}),e.jsx(h,{headers:["status","Phase","Couleur badge","Icône","Description"],rows:[[e.jsx(s,{children:"awaiting_return"},"s1"),"Not Received","ambre","Clock","RMA créé, borne pas encore reçue à l'entrepôt"],[e.jsx(s,{children:"received"},"s2"),"Received","bleu","AlertTriangle","Borne reçue physiquement"],[e.jsx(s,{children:"processed_warehouse"},"s3"),"Processed","violet","PackageX","Diagnostiquée et conditionnée"],[e.jsx(s,{children:"ready_to_ship"},"s4"),"Processed","indigo","CheckCircle2","Prête à expédier au manufacturier"],[e.jsx(s,{children:"shipped_to_mfr"},"s5"),"Processed","cyan","Upload","Expédiée au manufacturier"],[e.jsx(s,{children:"mfr_processing"},"s6"),"Processed","teal","RefreshCw","En traitement chez le manufacturier"],[e.jsx(s,{children:"replacement_shipped"},"s7"),"Processed","emerald","Download","Remplacement expédié vers Polara"],[e.jsx(s,{children:"replacement_received"},"s8"),"Processed","lime","CheckCircle2","Remplacement reçu"],[e.jsx(s,{children:"rma_closed"},"s9"),"(terminal)","vert","CheckCircle2","Cas clôturé — passe en History"],[e.jsx(s,{children:"rma_cancelled"},"s10"),"(terminal)","neutre","Ban","RMA ouvert par erreur, borne remise sur son site — passe en History (CHARGEOPS-289)"]]}),e.jsxs(r,{children:["Le statut initial à la création est toujours ",e.jsx(s,{children:"awaiting_return"}),". Le passage à ",e.jsx(s,{children:"rma_closed"})," ou à ",e.jsx(s,{children:"rma_cancelled"})," retire le RMA de l'onglet ",e.jsx(n,{children:"Active"})," et l'envoie dans ",e.jsx(n,{children:"History"}),". Il pose aussi"," ",e.jsx(s,{children:"targetClosedAt"})," sur les liens ",e.jsx(s,{children:"defectFollowUps"})," du RMA (suivi d'un défaut d'entretien, CHARGEOPS-131), et ",e.jsx(s,{children:"reopen"})," l'efface."]}),e.jsxs(r,{children:["Ces deux statuts sont les ",e.jsx(n,{children:"statuts terminaux"})," :"," ",e.jsx(s,{children:'RMA_TERMINAL_STATUSES = ["rma_closed", "rma_cancelled"]'})," dans"," ",e.jsx(s,{children:"convex/rma/mutations.ts"}),", et ",e.jsx(s,{children:"isRmaOpen(rma)"})," (vivant et dans aucun statut terminal) est ",e.jsx(n,{children:"la"})," seule définition d'un RMA ouvert — ",e.jsx(s,{children:"findOpenRma"})," s'en sert, ",e.jsx(s,{children:"getAll"})," /"," ",e.jsx(s,{children:"getHistory"})," partagent la même liste. ",e.jsx(s,{children:"rma_closed"})," n'en sort que par le ",e.jsx(s,{children:"reopen"})," réservé aux admins ; ",e.jsx(s,{children:"rma_cancelled"})," n'en sort jamais."]}),e.jsxs(t,{id:"annulation-rma",title:"14.1 Annulation (CHARGEOPS-289)",children:[e.jsxs(r,{children:[e.jsxs(s,{children:["api.rma.mutations.cancel(","{ id, reason? }",")"]})," (",e.jsx(s,{children:'moduleMutation("rma")'}),") délègue à"," ",e.jsxs(s,{children:["cancelRmaRecord(ctx, ","{ id, reason? }",")"]}),", que"," ",e.jsx(s,{children:"updateRmaStatusRecord"})," appelle aussi dès que"," ",e.jsx(s,{children:'newStatus === "rma_cancelled"'})," : aucune voie — le PATCH de l'API publique compris — n'atteint ce statut sans remettre la borne en place. Le sélecteur de statut de la page ne le propose pas ; c'est l'action"," ",e.jsx(n,{children:"Annuler le RMA"})," qui l'appelle."]}),e.jsxs(x,{children:[e.jsxs(i,{children:[e.jsx(s,{children:'requirePermission("editRMA")'})," (pas réservé aux admins, contrairement à ",e.jsx(s,{children:"softDelete"}),"), RMA existant et non supprimé,"," ",e.jsx(s,{children:"assertChargerInScope"})," sur la borne."]}),e.jsxs(i,{children:["Refus d'un RMA déjà ",e.jsx(s,{children:"rma_cancelled"})," ou ",e.jsx(s,{children:"rma_closed"})," (la fermeture veut dire que le retour a eu lieu)."]}),e.jsxs(i,{children:["Site d'origine : ",e.jsx(s,{children:"originalSiteId"})," doit passer"," ",e.jsx(s,{children:'normalizeId("sites", …)'})," et désigner un site vivant du locataire (",e.jsx(s,{children:"assertSiteInScope"}),"), sinon refus avec un message qui nomme"," ",e.jsx(s,{children:"originalSiteName"})," — restaurer sur un site supprimé rendrait la borne invisible. Un admin peut d'abord relier le RMA à un site."]}),e.jsxs(i,{children:["Patch du RMA : ",e.jsx(s,{children:'status: "rma_cancelled"'}),","," ",e.jsx(s,{children:"replacementChargerId"})," vidé, et le motif (s'il y en a un, après"," ",e.jsx(s,{children:"trim"}
211),") ajouté en note ",e.jsx(s,{children:"Cancelled: …"}),"."]}),e.jsxs(i,{children:["Borne de remplacement : la ligne ",e.jsx(s,{children:"Replacement for … (RMA …)"})," que"," ",e.jsx(s,{children:"setReplacementCharger"})," avait posée est retirée de ses notes (helper"," ",e.jsx(s,{children:"removeChargerNoteLine"}),", partagé avec"," ",e.jsx(s,{children:"setReplacementCharger"}),")."]}),e.jsxs(i,{children:["Borne restaurée : ",e.jsx(s,{children:"deletedAt: undefined"}),", ",e.jsx(s,{children:"isActive: true"}),","," ",e.jsx(s,{children:"siteId"})," = le site d'origine, plus une ligne d'audit"," ",e.jsx(s,{children:"CHARGER"}),"."]}),e.jsxs(i,{children:[e.jsx(s,{children:"deleteFollowUpsForRma"})," : les défauts suivis par ce RMA reviennent à « pas encore de RMA »."]}),e.jsxs(i,{children:["Ligne ",e.jsx(s,{children:"rmaStatusHistory"}),", audit du RMA et évènement"," ",e.jsx(s,{children:"rma_status_changed"})," (courriels de notification et webhooks)."]})]}),e.jsxs(r,{children:["Un RMA annulé est définitif : ",e.jsx(s,{children:"updateRmaStatusRecord"}),","," ",e.jsx(s,{children:"reopen"})," et ",e.jsx(s,{children:"setReplacementCharger"})," le refusent avec"," ",e.jsx(s,{children:"RMA_CANCELLED_REFUSAL"})," (constante exportée, que l'API publique traduit en 409). Comme la borne est de retour, ",e.jsx(s,{children:"findOpenRma"})," ne bloque plus un nouveau RMA. Lecteurs ajustés : ",e.jsx(s,{children:"getWarehouseChargers"})," ignore les RMA annulés, ",e.jsx(s,{children:"reliabilityKpis.getModelReliability"})," ne les compte pas comme des pannes, et ",e.jsx(s,{children:"peIdUtils.rmaHoldsChargerName"})," renvoie"," ",e.jsx(s,{children:"false"})," (le refus d'un PE-ID ne cite jamais un RMA annulé)."]})]})]}),e.jsxs(a,{id:"schema-donnees-rma",title:"15. Schéma de données",children:[e.jsxs(t,{id:"rma-table-rma-chargers",title:"15.1 Table rmaChargers",children:[e.jsx(l,{language:"ts",children:`// convex/schema.ts
212const rmaChargers = defineTable({
213  tenantId: v.id("tenants"),
214  internalId: v.string(),                                   // 5 chars alphanum, ex. "A7K2P"
215  chargerId: v.id("chargers"),
216  originalSiteId: v.union(v.number(), v.string()),          // legacy int ou Convex ID
217  originalSiteName: v.string(),                             // snapshot du nom de site
218  organizationId: v.optional(v.id("organizations")),        // dénormalisé du site de la borne (CHARGEOPS-180)
219  status: v.string(),                                       // voir section 14
220  notes: v.array(rmaNoteObject),                            // notes timestampées
221  createdBy: v.optional(v.id("users")),
222  replacementChargerId: v.optional(v.id("chargers")),
223  supportTicketId: v.optional(v.id("supportTickets")),      // cas lié (CHARGEOPS-130)
224  serviceReportId: v.optional(v.id("serviceReports")),      // rapport d'intervention lié (CHARGEOPS-130)
225  createdAt: v.number(),
226  updatedAt: v.number(),
227  deletedAt: v.optional(v.number()),
228})
229  .index("by_chargerId", ["chargerId"])
230  .index("by_supportTicketId", ["supportTicketId"])  // RMA liés à un cas
231  .index("by_serviceReportId", ["serviceReportId"])  // RMA liés à un rapport d'intervention
232  .index("by_tenant", ["tenantId"])
233  .index("by_tenant_internalId", ["tenantId", "internalId"])  // unicité de internalId par locataire
234  .index("by_tenant_deletedAt", ["tenantId", "deletedAt"])  // RMA actifs de ce locataire
235  .index("by_tenant_status_deletedAt", ["tenantId", "status", "deletedAt"])  // onglets Actifs / Historique
236  .index("by_tenant_createdAt", ["tenantId", "createdAt"])  // API REST /api/v1/rma-cases
237  .index("by_organizationId", ["organizationId"]);  // progression de backfillRmaOrganizationId`}),e.jsxs(r,{children:[e.jsx(s,{children:"internalId"})," est généré par ",e.jsx(s,{children:"generateCandidateId()"})," (5 caractères majuscules ",e.jsx(s,{children:"A-Z0-9"}),"), avec retry jusqu'à 20 fois en cas de collision."]})]}),e.jsx(t,{id:"rma-table-rma-files",title:"15.2 Table rmaFiles",children:e.jsx(l,{language:"ts",children:`// convex/schema.ts
238const rmaFiles = defineTable({
239  tenantId: v.id("tenants"),
240  rmaId: v.id("rmaChargers"),
241  fileName: v.string(),
242  fileType: v.string(),                  // "pdf" | "image"
243  filePath: v.string(),
244  bucketKey: v.optional(v.string()),     // legacy S3
245  storageId: v.optional(v.id("_storage")), // Convex storage
246  fileSize: v.number(),                  // bytes
247  uploadedAt: v.number(),
248})
249  .index("by_rmaId", ["rmaId"])
250  .index("by_tenant", ["tenantId"]);`})}),e.jsx(t,{id:"rma-table-rma-sim-cards",title:"15.3 Table rmaSimCards",children:e.jsx(l,{language:"ts",children:`// convex/schema.ts
251const rmaSimCards = defineTable({
252  tenantId: v.id("tenants"),
253  rmaId: v.id("rmaChargers"),
254  simIccid: v.string(),
255  carrier: v.string(),
256  chargerName: v.string(),               // snapshot du PE-ID
257  status: v.string(),                    // "activated" | "suspended" | "terminated"
258  createdAt: v.number(),
259  updatedAt: v.number(),
260})
261  .index("by_rmaId", ["rmaId"])
262  .index("by_tenant", ["tenantId"])
263  .index("by_tenant_createdAt", ["tenantId", "createdAt"]);  // API REST /api/v1/rma-sim-cards`})}),e.jsxs(t,{id:"rma-table-rma-status-history",title:"15.4 Table rmaStatusHistory",children:[e.jsx(l,{language:"ts",children:`// convex/schema.ts
264const rmaStatusHistory = defineTable({
265  tenantId: v.id("tenants"),
266  rmaId: v.id("rmaChargers"),
267  oldStatus: v.optional(v.string()),     // undefined pour la création
268  newStatus: v.string(),
269  changedBy: v.optional(v.id("users")),
270  changedAt: v.number(),
271})
272  .index("by_rmaId", ["rmaId"])
273  .index("by_tenant", ["tenantId"]);`}),e.jsxs(r,{children:["Chaque appel à ",e.jsx(s,{children:"updateStatus"})," insère une nouvelle ligne ici (audit immuable des transitions)."]})]}),e.jsxs(t,{id:"rma-note-object",title:"15.5 Validateur rmaNoteObject",children:[e.jsx(l,{language:"ts",children:`// convex/schema.ts:159
274export const rmaNoteObject = v.object({
275  id: v.string(),                                  // "<timestamp>-<rand>"
276  text: v.string(),
277  timestamp: v.union(v.number(), v.string()),
278  userId: v.union(v.string(), v.number()),
279  userEmail: v.string(),
280  userFirstName: v.string(),
281  userLastName: v.string(),
282  userProfileImageUrl: v.optional(v.union(v.string(), v.null())),
283});`}),e.jsxs(r,{children:["Les notes sont stockées ",e.jsx(n,{children:"inline"})," dans ",e.jsx(s,{children:"rmaChargers.notes"})," ","(et non dans une table séparée), ce qui simplifie la lecture mais limite légèrement la mutabilité (toute modification réécrit le tableau complet)."]})]})]}),e.jsxs(a,{id:"onglets-active-history",title:"16. Onglets Active / History",children:[e.jsxs(r,{children:["La page RMA expose ",e.jsx(n,{children:"trois onglets"})," (",e.jsx(s,{children:"activeTab"}),") :"]}),e.jsx(h,{headers:["Onglet","Source","Filtre"],rows:[[e.jsx(n,{children:"Active"},"t1"),e.jsx(s,{children:"api.rma.queries.getAll"},"src1"),e.jsxs(e.Fragment,{children:[e.jsx(s,{children:"deletedAt === undefined"})," ET statut hors"," ",e.jsx(s,{children:"RMA_TERMINAL_STATUSES"})]})],[e.jsx(n,{children:"History"},"t2"),e.jsx(s,{children:"api.rma.queries.getHistory"},"src2"),e.jsxs(e.Fragment,{children:[e.jsx(s,{children:"deletedAt !== undefined"})," OU statut dans"," ",e.jsx(s,{children:"RMA_TERMINAL_STATUSES"})," (",e.jsx(s,{children:"rma_closed"}),","," ",e.jsx(s,{children:"rma_cancelled"}),")"]})],[e.jsx(n,{children:"SIM Cards"},"t3"),e.jsx(s,{children:"api.rma.queries.getSimCards"},"src3"),e.jsxs(e.Fragment,{children:["tous les ",e.jsx(s,{children:"rmaSimCards"})," (max 5000)"]})]]}),e.jsx(r,{children:"La séparation Active/History garantit que la vue principale reste épurée et performante (en pratique, la majorité des RMAs sont fermés)."}),e.jsxs(r,{children:[e.jsx(n,{children:"Lectures bornées (CHARGEOPS-138)."})," ",e.jsx(s,{children:"getAll"})," et"," ",e.jsx(s,{children:"getHistory"})," ne lisent plus toute la table : chacune renvoie"," ",e.jsx(s,{children:"{ rows, truncated }"}),", soit au plus ",e.jsx(s,{children:"RMA_LIST_CAP"})," ","(500) dossiers, du plus récent au plus ancien (",e.jsx(s,{children:"_creationTime"}),"). L'onglet Active lit un statut ouvert à la fois sur l'index"," ",e.jsx(s,{children:"by_tenant_status_deletedAt"}),", plus une lecture par statut hérité hors ",e.jsx(s,{children:"rmaStatusValidator"})," : ces statuts sont trouvés en sondant l'index dans les intervalles entre les statuts connus (au-delà de"," ",e.jsx(s,{children:"LEGACY_STATUS_CAP"})," = 4 statuts distincts, ",e.jsx(s,{children:"truncated"})," ","passe à vrai) ; History lit les dossiers fermés sur le même index et les supprimés sur ",e.jsx(s,{children:"by_tenant_deletedAt"}),". Ainsi les dossiers d'un onglet ne mangent jamais la limite de l'autre. Les fichiers et cartes SIM restent lus par dossier (",e.jsx(s,{children:"by_rmaId"}),"), mais seulement pour les lignes retenues et visibles, et plafonnés à 25 fichiers / 10 SIM par ligne (le dialogue de détail les lit tous). Les fichiers sont lus avec un de plus que la limite : au-delà, la ligne porte ",e.jsx(s,{children:"filesTruncated"}),", le tableau Active affiche « 25+ fichiers » et History l'indique sous la liste. Quand ",e.jsx(s,{children:"truncated"})," est vrai, la page affiche un avis au-dessus de la liste."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Utilisateurs restreints à des organisations (CHARGEOPS-294)."})," Un appelant sans restriction (",e.jsx(s,{children:"orgIds === null"}),") prend ces fenêtres telles quelles. Un appelant restreint est filtré dossier par dossier (par le site actuel de la borne) : couper à 500 d'abord lui laissait quelques lignes, voire aucune, alors que des RMA plus anciens de son périmètre restaient au-delà. Sa liste est donc ",e.jsx(n,{children:"remplie"})," (",e.jsx(s,{children:"scopedNewestWindow"}),") : chaque source (chaque lecture par statut, la lecture des supprimés) est parcourue du plus récent au plus ancien, par pages de 100, à tour de rôle, en filtrant chaque page à l'arrivée (les bornes et sites lus sont mis en cache et réutilisés par l'enrichissement). Une source s'arrête quand elle est épuisée, quand elle compte 501 dossiers visibles, ou quand sa dernière ligne est plus ancienne que le 501ᵉ dossier visible le plus récent trouvé toutes sources confondues. Le parcours s'arrête quand le tour suivant ne tient plus dans les budgets :"," ",e.jsx(s,{children:"SCOPED_SCAN_ROW_BUDGET"})," (1 500) lignes candidates et"," ",e.jsx(s,{children:"SCOPED_CALL_BUDGET"})," (2 500) appels à la base. Les appels sont comptés, pas estimés : chaque requête de page et chaque lecture de borne ou de site absente du cache, plus le coût exact de l'enrichissement des lignes déjà retenues (2 requêtes par ligne pour les fichiers et les cartes SIM, plus une lecture par créateur, remplaçante, marque, modèle et organisation distincts). Chaque tour est dimensionné pour son pire cas (par ligne : une lecture de borne, une de site et 7 appels d'enrichissement), donc le budget n'est jamais dépassé. Avec le contexte de l'espace de travail (au plus 10 lectures) et les sondes de statuts hérités (au plus 16), une lecture restreinte fait au plus 2 526 appels, bien sous la limite de 4 096 par requête de Convex. Un appelant dont la liste d'accès est vide reçoit une liste vide sans aucun parcours. Si des sources restaient inachevées, les lignes plus anciennes que la plus récente de leurs dernières lignes lues (le « filigrane ») sont écartées, car une ligne non lue pourrait les devancer. Les lignes renvoyées sont donc toujours les RMA visibles les plus récents, le ",e.jsx(s,{children:"{count}"})," de l'avis compte de vraies lignes, et ",e.jsx(s,{children:"truncated"})," est vrai dès que le parcours s'est arrêté inachevé ou a trouvé plus de 500 dossiers."]}),e.jsxs(r,{children:["Les deux onglets filtrent par site avec ",e.jsx(s,{children:"SiteCombobox"})," — le composant partagé du sélecteur de l'en-tête d'accueil, donc"," ",e.jsx(n,{children:"groupé par organisation"}),". Les options (",e.jsx(s,{children:"activeSites"})," / ",e.jsx(s,{children:"historySites"}),") sont dérivées des RMAs affichées, dédoublonnées sur ",e.jsx(s,{children:"originalSiteId"})," — un site n'apparaît que s'il porte au moins un dossier dans l'onglet. Elles portent"," ",e.jsx(s,{children:"organizationId"}),", repris de l'",e.jsx(s,{children:"organization"})," que la requête résout déjà pour chaque ligne : c'est ce champ qui déclenche le groupement, sans lui ",e.jsx(s,{children:"SiteCombobox"})," retombe silencieusement sur une liste plate. La résolution passe par le site ",e.jsx(n,{children:"effectif"})," (site courant de la borne, sinon celui figé sur le RMA), donc une borne déplacée en entrepôt — ",e.jsx(s,{children:"siteId === null"})," — garde son organisation dans le filtre. Le sélecteur n'apparaît qu'à partir de deux sites."]})]}),e.jsxs(a,{id:"creation-rma",title:"17. Création d'un RMA",children:[e.jsxs(r,{children:["Mutation : ",e.jsx(s,{children:"api.rma.mutations.create"})," (",e.jsx(s,{children:"convex/rma/mutations.ts:38"}),")."]}),e.jsxs(t,{id:"rma-flow",title:"17.1 Flow",children:[e.jsx(r,{children:"Deux entrées possibles :"}),e.jsxs(x,{children:[e.jsxs(i,{children:[e.jsx(n,{children:"Depuis la fiche d'un site"})," (page ",e.jsx(s,{children:"site-detail"}),') — bouton "Send to RMA" sur une borne installée.']}),e.jsxs(i,{children:[e.jsx(n,{children:"Depuis l'entrepôt"})," — moins courant, pour une borne reçue défectueuse non encore installée."]})]}),e.jsxs(r,{children:["Dans les deux cas, la mutation reçoit ",e.jsx(s,{children:"chargerId"})," et un tableau optionnel de ",e.jsx(s,{children:"notes"})," initiales."]})]}),e.jsxs(t,{id:"rma-logique",title:"17.2 Logique",children:[e.jsx(l,{language:"ts",children:`// convex/rma/mutations.ts (extrait)
284export const create = moduleMutation("rma")({
285  args: {
286    chargerId: v.id("chargers"),
287    notes: v.optional(v.array(rmaNoteObject)),
288  },
289  handler: async (ctx, { chargerId, notes }) => {
290    requirePermission("editRMA", ctx.user);
291    const charger = await assertChargerInScope(ctx, chargerId);
292    const { rmaId } = await createRmaRecord(ctx, { charger, notes: notes ?? [] });
293    return rmaId;
294  },
295});
296
297export async function createRmaRecord(ctx, { charger, notes }) {
298  // Refus : borne déjà supprimée, ou RMA déjà ouvert (findOpenRma)
299  // …
300
301  // Snapshot du site à la création
302  const site = charger.siteId ? await ctx.db.get(charger.siteId) : null;
303  const originalSiteId = charger.siteId ? (charger.siteId as string) : "";
304  const originalSiteName = site?.name ?? "Unknown Site";
305
306  // Génération d'un internalId unique DANS CE LOCATAIRE (by_tenant_internalId) :
307  // deux locataires peuvent tirer le même code de 5 caractères.
308  let internalId = "";
309  for (let attempt = 0; attempt < 20; attempt++) {
310    const candidate = generateCandidateId();
311    const existing = await ctx.db
312      .query("rmaChargers")
313      .withIndex("by_tenant_internalId", (q) =>
314        q.eq("tenantId", ctx.tenantId).eq("internalId", candidate),
315      )
316      .first();
317    if (!existing) { internalId = candidate; break; }
318  }
319  if (!internalId) {
320    throw new ConvexError("Could not generate a unique RMA number. Please try again.");
321  }
322
323  const now = Date.now();
324  const initialStatus = "awaiting_return";
325
326  const rmaId = await ctx.insertTenant("rmaChargers", {
327    internalId, chargerId: charger._id, originalSiteId, originalSiteName,
328    organizationId: site?.organizationId,
329    status: initialStatus, notes,
330    createdBy: ctx.user._id, createdAt: now, updatedAt: now,
331  });
332
333  // Première entrée d'historique
334  await ctx.insertTenant("rmaStatusHistory", {
335    rmaId, oldStatus: undefined, newStatus: initialStatus,
336    changedBy: ctx.user._id, changedAt: now,
337  });
338
339  // Audit, puis suppression douce de la borne + son propre audit
340  // …
341
342  return { rmaId, internalId };
343}`}),e.jsxs(p,{tone:"info",title:"À noter",children:[e.jsx(s,{children:"create"})," ",e.jsx(n,{children:"retire la borne de son site"})," (suppression douce :"," ",e.jsx(s,{children:"deletedAt"}),", ",e.jsx(s,{children:"isActive: false"}),") dans la même mutation, et annuler le RMA avant sa fermeture la ramène (voir 17.3)."]})]}),e.jsxs(t,{id:"rma-liens",title:"17.3 Liens : défaut, cas, rapport d'intervention",children:[e.jsxs(r,{children:["Depuis CHARGEOPS-130, le corps de ",e.jsx(s,{children:"create"})," vit dans"," ",e.jsxs(s,{children:["createRmaRecord(ctx, ","{ charger, notes }",")"]})," (refus d'une borne supprimée ou d'un second RMA ouvert via ",e.jsx(s,{children:"findOpenRma"})," — fondé sur"," ",e.jsx(s,{children:"isRmaOpen"})," depuis CHARGEOPS-289 —, numéro, historique, audit, puis suppression douce de la borne) ; l'appelant garde ",e.jsx(s,{children:"editRMA"})," et"," ",e.jsx(s,{children:"assertChargerInScope"}),". Il est partagé avec"," ",e.jsx(s,{children:"maintenanceReports.followUps.createRmaFromDefect"}),"."," ",e.jsx(s,{children:"rmaChargers"})," gagne ",e.jsx(s,{children:"supportTicketId?"})," et"," ",e.jsx(s,{children:"serviceReportId?"})," (index ",e.jsx(s,{children:"by_supportTicketId"}),","," ",e.jsx(s,{children:"by_serviceReportId"}),") ; ",e.jsx(s,{children:"linkCase"})," et"," ",e.jsx(s,{children:"linkServiceReport"})," (",e.jsx(s,{children:"editRMA"}),", borne du RMA et cible dans la portée, ",e.jsx(s,{children:"null"})," pour délier) les posent, et un changement de cas écrit"," ",e.jsx(s,{children:"rma_linked"})," / ",e.jsx(s,{children:"rma_unlinked"})," (champ ",e.jsx(s,{children:"rmaId"}),","," ",e.jsx(s,{children:"text"})," = numéro, toujours ",e.jsx(s,{children:"internal"}),") sur chaque cas touché. Lectures : ",e.jsx(s,{children:"getLinks"})," (carte RMA : défauts d'origine par"," ",e.jsx(s,{children:"defectFollowUps.by_rmaId"}),", cas, rapport, site de la borne pour les sélecteurs) et ",e.jsx(s,{children:"listLinked"})," (pastilles sur le cas et le rapport), jamais pour un compte du portail. Les autres lectures RMA (",e.jsx(s,{children:"getAll"}),", ",e.jsx(s,{children:"getHistory"}),", ",e.jsx(s,{children:"getById"}),", ",e.jsx(s,{children:"getStatusHistory"}),", ",e.jsx(s,{children:"getFiles"}),", ",e.jsx(s,{children:"getSimCards"}),", ",e.jsx(s,{children:"getReplacementCandidates"}),", ",e.jsx(s,{children:"getReplacementsForChargers"}),") ne servent que l'application du personnel et appellent ",e.jsx(s,{children:"requireStaffAppAccess"})," ","avant toute lecture : un compte du portail est refusé, le personnel plateforme qui porte aussi le rôle exploitant de site passe (CHARGEOPS-408). UI :"," ",e.jsx(s,{children:"components/rma/rma-links.tsx"})," et"," ",e.jsx(s,{children:"linked-rma-chips.tsx"}),". Supprimer le RMA retire ses liens de défaut ; un cas ou un rapport disparu n'est simplement plus affiché."]}),e.jsxs(r,{children:["Soumissions de réparation (CHARGEOPS-306) : sous ces liens, la carte monte"," ",e.jsx(s,{children:"components/rma/rma-repair-quotes.tsx"})," — les pastilles de"," ",e.jsx(s,{children:"repairQuotes.queries.listForRma"})," (index ",e.jsx(s,{children:"repairQuotes.by_source_rma"}),", numéro + statut) et ",e.jsx(n,{children:"Soumissionner la réparation"}),", qui appelle"," ",e.jsx(s,{children:"repairQuotes.mutations.create"})," avec"," ",e.jsx(s,{children:'source: { kind: "rma", rmaId }'})," sur le site ",e.jsx(s,{children:"originalSiteId"})," ","(un RMA hérité à identifiant numérique n'a ni bouton ni pastille). Réservé à qui peut ouvrir l'éditeur (",e.jsx(s,{children:"useSalesHelper"}),", module Centre de maintenance actif)."]})]}),e.jsxs(t,{id:"rma-cote-fabricant",title:"17.4 Côté fabricant : dossier lié, n° RMA, suivi",children:[e.jsxs(r,{children:["Récupération de garantie, tranche 1 (CHARGEOPS-329). ",e.jsx(s,{children:"rmaChargers"})," gagne quatre colonnes optionnelles, sans argent : ",e.jsx(s,{children:"vendorRmaNumber"})," (≤ 60),"," ",e.jsx(s,{children:"manufacturerCaseId"}),", ",e.jsx(s,{children:"outboundShipment"})," et"," ",e.jsx(s,{children:"inboundShipment"})," (",e.jsx(s,{children:"rmaShipmentValidator"})," :"," ",e.jsx(s,{children:"carrier?"})," ≤ 60, ",e.jsx(s,{children:"trackingNumber"})," ≤ 100,"," ",e.jsx(s,{children:"shippedOn?"})," jour ISO), plus l'index"," ",e.jsx(s,{children:"by_tenant_manufacturerCaseId"}),". Les règles (trim, plafonds, date réelle, suivi obligatoire dans une expédition) et les codes d'erreur vivent dans"," ",e.jsx(s,{children:"shared/rma-vendor-logistics.ts"}),", partagé par le serveur et la fenêtre."]}),e.jsxs(r,{children:[e.jsxs(s,{children:["linkManufacturerCase(","{ id, manufacturerCaseId | null }",")"]})," :"," ",e.jsx(s,{children:"requireStaffAccount"})," puis ",e.jsx(s,{children:"loadRmaForLink"})," (",e.jsx(s,{children:"editRMA"}),", RMA vivant, borne dans la portée) ; pour lier, module ",e.jsx(s,{children:"supportCases"}),", dossier du locataire (un dossier d'un autre locataire est lu absent par le lecteur RLS et refusé pareil, ",e.jsx(s,{children:"RMA_VENDOR_CASE_NOT_FOUND"}),"), cas de support non supprimé et dans la portée, et ",e.jsx(n,{children:"même borne"})," que le RMA (sinon"," ",e.jsx(s,{children:"RMA_VENDOR_CASE_OTHER_CHARGER"}),"). Un dossier fermé se lie ; un RMA annulé ne se lie pas (",e.jsx(s,{children:"RMA_VENDOR_RMA_CANCELLED"}),"). Plusieurs RMA peuvent pointer le même dossier. Délier reste possible module éteint. Ligne d'audit, pas d'entrée sur le fil du cas."," ",e.jsxs(s,{children:["setLogistics(","{ id, vendorRmaNumber?, outboundShipment?, inboundShipment? }",")"]})," ",": même garde ; un argument absent ne change rien, ",e.jsx(s,{children:"null"})," ou vide efface ; refusé sur un RMA annulé (",e.jsx(s,{children:"RMA_VENDOR_RMA_CANCELLED"}),"), accepté sur un RMA fermé ; l'audit liste les champs changés."]}),e.jsxs(r,{children:["Lectures : ",e.jsx(s,{children:"getLinks"})," renvoie ",e.jsx(s,{children:"hasManufacturerCase"})," (un lien existe, même invisible pour l'appelant : la carte n'offre alors pas de lien) et ",e.jsx(s,{children:"manufacturerCase"})," aux mêmes conditions que le cas lié (module, ",e.jsx(s,{children:"viewTickets"}),", cas vivant et dans la portée) ; ",e.jsxs(s,{children:["manufacturerCaseOptions(","{ id }",")"]})," liste les dossiers de la borne (",e.jsx(s,{children:"by_tenant_charger"}),", ≤ 50) pour le sélecteur, vide sans"," ",e.jsx(s,{children:"editRMA"})," ; ",e.jsxs(s,{children:["forManufacturerCases(","{ ticketId }",")"]})," donne, par dossier du cas, ses RMA liés (",e.jsx(s,{children:"by_tenant_manufacturerCaseId"}),", ≤ 20) et, pour un éditeur, les RMA vivants non annulés de la borne qui ne sont liés à aucun dossier (vide sans ",e.jsx(s,{children:"supportCases"})," ou ",e.jsx(s,{children:"viewTickets"}),"). Un compte du portail ne lit rien, et"," ",e.jsx(s,{children:"getAll"})," / ",e.jsx(s,{children:"getHistory"})," / ",e.jsx(s,{children:"getById"})," le refusent d'emblée (",e.jsx(s,{children:"requireStaffAppAccess"}),", CHARGEOPS-408). Les nouvelles colonnes restent hors de la projection ",e.jsx(s,{children:"rma-cases"})," de l'API REST publique (verrouillé par un test) et des jetons de ",e.jsx(s,{children:"rma_status_changed"}),". UI :"," ",e.jsx(s,{children:"components/rma/rma-vendor-block.tsx"})," et"," ",e.jsx(s,{children:"vendor-case-rma-links.tsx"}),"."]})]}),e.jsxs(t,{id:"rma-reclamations-garantie",title:"17.5 Réclamations de garantie (backend)",children:[e.jsxs(r,{children:["Récupération de garantie, tranche 2 (CHARGEOPS-330) — le backend seul : l'onglet, les fenêtres et les pastilles des cartes arrivent en tranche 3, rien n'est encore visible. Nouvelle table de locataire ",e.jsx(s,{children:"warrantyClaims"})," (dans"," ",e.jsx(s,{children:"TENANT_TABLES"})," et ",e.jsx(s,{children:"BACKUP_TABLES"}),") : une ligne par tentative de récupérer de l'argent auprès d'un fabricant, numérotée ",e.jsx(s,{children:"WC-XXXXX"})," ","(",e.jsx(s,{children:"internalId"})," unique par locataire). Elle lie la borne (instantanés"," ",e.jsx(s,{children:"chargerName"}),", ",e.jsx(s,{children:"serialNumber"}),", ",e.jsx(s,{children:"siteId"}),","," ",e.jsx(s,{children:"organizationId"})," — la portée d'organisation lit ces instantanés, pas la borne, souvent supprimée en douceur par son RMA), et en option le RMA, le dossier fabricant, le cas (",e.jsx(s,{children:"ticketId"}),") et une pièce (",e.jsx(s,{children:"partId"}),","," ",e.jsx(s,{children:"partQuantity"}),"). Index : ",e.jsx(s,{children:"by_tenant"}),","," ",e.jsx(s,{children:"by_tenant_internalId"}),","," ",e.jsx(s,{children:"by_tenant_status_deletedAt_updatedAt"})," (listes, lignes vivantes seulement),"," ",e.jsx(s,{children:"by_tenant_rmaId"}),", ",e.jsx(s,{children:"by_tenant_manufacturerCaseId"}),","," ",e.jsx(s,{children:"by_tenant_ticketId"}),", ",e.jsx(s,{children:"by_tenant_settledAt"}),". Les règles pures (transitions, échéances, totaux, validation des montants, codes d'erreur) vivent dans"," ",e.jsx(s,{children:"shared/warranty-claims.ts"}),"."]}),e.jsxs(r,{children:["Cycle de vie : ",e.jsx(s,{children:"draft → submitted → approved | denied"}),","," ",e.jsx(s,{children:"approved → settled"}),", tout statut ouvert ",e.jsx(s,{children:"→ written_off"}),". Refus et radiation exigent une raison (",e.jsx(s,{children:"statusReason"}),"). Chaque écriture estampille"," ",e.jsx(s,{children:"submittedAt"})," / ",e.jsx(s,{children:"decidedAt"})," / ",e.jsx(s,{children:"settledAt"})," selon le cas — et ",e.jsx(s,{children:"closedAt"})," à chaque fermeture (refusée, réglée, radiée), seul repère de fermeture d'une radiation —, relance l'échéance ",e.jsx(s,{children:"followUpDueAt"})," (7, 21, 30 jours pour brouillon, soumise, approuvée ; aucune une fois fermée) et efface le verrou"," ",e.jsx(s,{children:"followUpNotifiedAt"})," — le balayage des réclamations en retard (17.7) lit les deux. Rouvrir (admin) : refusée → soumise, réglée → approuvée, radiée → le point le plus avancé qu'elle avait atteint ; ",e.jsx(s,{children:"closedAt"})," est effacé."]}),e.jsxs(r,{children:["Écritures (",e.jsx(s,{children:"convex/warrantyClaims/mutations.ts"}),","," ",e.jsx(s,{children:'moduleMutation("rma")'}),", ",e.jsx(s,{children:"requireStaffAccount"})," partout) :"," ",e.jsx(s,{children:"create"}),", ",e.jsx(s,{children:"update"}),", ",e.jsx(s,{children:"setStatus"})," et"," ",e.jsx(s,{children:"recordReplacement"})," demandent ",e.jsx(s,{children:"editRMA"})," ; ",e.jsx(s,{children:"setAmounts"}),","," ",e.jsx(s,{children:"recordCredit"})," et le passage à ",e.jsx(s,{children:"settled"})," demandent"," ",e.jsx(s,{children:"viewFinancials"})," ; ",e.jsx(s,{children:"reopen"})," et ",e.jsx(s,{children:"remove"})," (suppression douce) sont réservés à l'admin. ",e.jsx(s,{children:"create"})," exige un RMA vivant, non annulé et de la ",e.jsx(n,{children:"même borne"}),", un dossier fabricant du locataire (cas vivant, même borne, module ",e.jsx(s,{children:"supportCases"}),") et une pièce active (module"," ",e.jsx(s,{children:"warehouse"}),") ; il recopie les deux bouts d'un lien RMA ↔ dossier, refuse un dossier qui n'est pas celui du RMA et refuse une ",e.jsx(n,{children:"seconde réclamation vivante"})," sur le même RMA ou dossier (",e.jsx(s,{children:"WARRANTY_CLAIM_DUPLICATE"}),", avec"," ",e.jsx(s,{children:"claimId"}),"). Le fabricant vient de la saisie, sinon de la marque, sinon du dossier. Un identifiant d'un autre locataire est refusé comme un identifiant absent. Montants en CAD, 0 à 1 000 000, deux décimales ; la valeur d'un remplacement n'est prise que d'un appelant ",e.jsx(s,{children:"viewFinancials"})," (sinon elle reprend le matériel réclamé). L'audit (",e.jsx(s,{children:'entityType: "warrantyClaims"'}),") nomme les champs changés et les statuts, ",e.jsx(n,{children:"jamais un montant"}),". Le cas reçoit"," ",e.jsx(s,{children:"warranty_claim_opened"})," / ",e.jsx(s,{children:"warranty_claim_settled"})," ","(",e.jsx(s,{children:"warrantyClaimId"}),", ",e.jsx(s,{children:"text"})," = numéro, toujours"," ",e.jsx(s,{children:"internal"}),"), et une fusion de cas déplace ",e.jsx(s,{children:"warrantyClaims.ticketId"})," ","vers le survivant."]}),e.jsxs(r,{children:["Lectures (",e.jsx(s,{children:"convex/warrantyClaims/queries.ts"}),", ",e.jsx(s,{children:'moduleQuery("rma")'}),","," ",e.jsx(s,{children:"requireStaffAppAccess"}),") : ",e.jsxs(s,{children:["list(","{ view: open | stale | closed }",")"]})," ","(une lecture bornée par statut, ≤ 500 ; la vue « en retard » lit la plage"," ",e.jsx(s,{children:"updatedAt ≤ maintenant − délai"})," de l'index ; ",e.jsx(s,{children:"truncated"})," est calculé après le filtre de portée, et un lecteur limité à des organisations balaie jusqu'à 1 500 lignes par statut sans jamais apprendre le volume du locataire),"," ",e.jsx(s,{children:"summary()"})," (réclamations ouvertes et en retard ; avec l'argent, ce que les réclamations ouvertes doivent encore et ce que les réclamations réglées dans l'année civile de Toronto ont récupéré), ",e.jsx(s,{children:"get"}),", ",e.jsx(s,{children:"forRma"})," et"," ",e.jsx(s,{children:"forTicket"})," (ce dernier ne renvoie rien à un compte du portail, la page du cas y étant montée, ni quand le module ",e.jsx(s,{children:"rma"})," est éteint). Le responsable suit la règle hors pupitre : seul un lecteur du pupitre (",e.jsx(s,{children:"canH
343oldTicket"}),") reçoit l'identifiant d'un autre utilisateur (",e.jsx(s,{children:"userIdForViewer"}),"), et un nom qui n'est qu'un courriel est réduit à sa partie locale (",e.jsx(s,{children:"maskEmailName"}),"). L'objet ",e.jsx(s,{children:"money"})," n'existe que pour"," ",e.jsx(s,{children:"viewFinancials"})," (",e.jsx(s,{children:"canSeeMoney"}),") : absent, jamais à zéro, pour un technicien ou un utilisateur. Rien n'est exposé au Portail Opérateur, à l'API REST publique ni aux webhooks."]})]}),e.jsxs(t,{id:"rma-reclamations-onglet",title:"17.6 Réclamations de garantie : onglet, fenêtres, entrées (CHARGEOPS-331)",children:[e.jsxs(r,{children:["Tranche 3. Quatrième sous-onglet de ",e.jsx(s,{children:"/rma"}),", ",e.jsx(s,{children:"claims"})," ","(",e.jsx(s,{children:"subnav.rma:claims"})," dans ",e.jsx(s,{children:"menu-registry.ts"})," et"," ",e.jsx(s,{children:"shared/modules.ts"}),"), rendu par"," ",e.jsx(s,{children:"client/src/components/rma/warranty-claims-tab.tsx"})," : tuiles"," ",e.jsx(s,{children:"ListStatStrip"})," depuis ",e.jsx(s,{children:"summary()"})," (Ouvertes et En retard basculent la vue ; À recevoir et Récupéré cette année seulement avec"," ",e.jsx(s,{children:"viewFinancials"})," et un objet ",e.jsx(s,{children:"money"}),"), vues"," ",e.jsx(s,{children:"open | stale | closed"})," sur ",e.jsx(s,{children:"list"}),", tableau à en-tête violet, colonnes de montants seulement quand ",e.jsx(s,{children:"money"})," est présent, avis de troncature. Lien profond : ",e.jsx(s,{children:"/rma?tab=claims&claim=<id>"})," ouvre la fenêtre de la réclamation ; la fermer revient à ",e.jsx(s,{children:"?tab=claims"}),"."]}),e.jsxs(r,{children:["Fenêtres (",e.jsx(s,{children:"warranty-claim-dialog.tsx"}),","," ",e.jsx(s,{children:"claim-recovery-dialog.tsx"}),") : création (préréglage depuis une entrée, ou site → borne via ",e.jsx(s,{children:"getPickerBySiteId"})," et pièce du catalogue via"," ",e.jsx(s,{children:"parts.listParts"})," avec l'Entrepôt et ",e.jsx(s,{children:"viewInventory"})," ; pastille informative ",e.jsx(s,{children:"factoryCoverageAt"})," quand la date d'installation et la durée sont connues ; montants réclamés passés à ",e.jsx(s,{children:"setAmounts"})," après"," ",e.jsx(s,{children:"create"})," pour ",e.jsx(s,{children:"viewFinancials"}),") ; consultation (détails via"," ",e.jsx(s,{children:"update"}),", « M'en charger » = ",e.jsx(s,{children:"ownerUserId"})," de l'appelant, boutons de statut filtrés par ",e.jsx(s,{children:"CLAIM_TRANSITIONS"})," — ",e.jsx(s,{children:"settled"})," pour"," ",e.jsx(s,{children:"viewFinancials"})," seulement, raison demandée avant ",e.jsx(s,{children:"denied"})," /"," ",e.jsx(s,{children:"written_off"})," —, section d'argent, rouvrir et supprimer pour l'admin) ; réception (crédit : ",e.jsx(s,{children:"recordCredit"})," ; remplacement :"," ",e.jsx(s,{children:"recordReplacement"}),", sa valeur seulement pour ",e.jsx(s,{children:"viewFinancials"})," ;",e.jsx(s,{children:"fullyRecovered"})," propose « Marquer réglée », jamais automatique). Les codes de refus passent par ",e.jsx(s,{children:"warrantyClaimErrorMessage"})," vers"," ",e.jsx(s,{children:"warrantyClaims.error.*"})," ; aucun message serveur (en français) n'atteint un toast."]}),e.jsxs(r,{children:["Doublon : le refus ",e.jsx(s,{children:"WARRANTY_CLAIM_DUPLICATE"})," porte"," ",e.jsx(s,{children:"claimId"})," ; la fenêtre propose d'ouvrir cette réclamation ou d'y rattacher le RMA / dossier de l'entrée. ",e.jsx(s,{children:"update"})," accepte pour cela"," ",e.jsx(s,{children:"rmaId"})," et ",e.jsx(s,{children:"manufacturerCaseId"})," (ajout seulement, jamais d'effacement ni de remplacement d'un lien existant — ",e.jsx(s,{children:"linkMismatch"}),") aux règles de ",e.jsx(s,{children:"create"})," : vivant, même locataire, même borne, RMA non annulé, module ",e.jsx(s,{children:"supportCases"})," pour un dossier, aucune autre réclamation vivante, et le dossier lié au RMA doit concorder. Rattacher un dossier recopie son"," ",e.jsx(s,{children:"vendorKey"})," et son ",e.jsx(s,{children:"ticketId"})," ; rattacher un RMA recopie son dossier fabricant aux gardes de ",e.jsx(s,{children:"create"}),". Quand le lien amène la réclamation sur un cas, ce cas reçoit sa ligne ",e.jsx(s,{children:"warranty_claim_opened"}),". La création valide les montants avant ",e.jsx(s,{children:"create"})," et se ferme dès que la réclamation existe, même si ",e.jsx(s,{children:"setAmounts"}
343)," échoue (toast dédié) : un nouvel essai ne crée jamais de doublon."]}),e.jsxs(r,{children:["Entrées (",e.jsx(s,{children:"warranty-claim-entry.tsx"}),") : sur la carte RMA,"," ",e.jsx(s,{children:"forRma"})," donne la pastille ou « Ouvrir une réclamation de garantie » (",e.jsx(s,{children:"editRMA"}),", jamais sur un RMA annulé), et « Consigner le remplacement » quand le RMA est ",e.jsx(s,{children:"replacement_received"}),", la réclamation approuvée et sans réception ; sur chaque dossier fabricant d'un cas,"," ",e.jsx(s,{children:"ticket-manufacturer-cases-card.tsx"})," lit ",e.jsx(s,{children:"forTicket"})," une fois (sauté dans le portail et sans le module ",e.jsx(s,{children:"rma"}),"). L'aperçu de fusion de cas compte désormais les réclamations déplacées (",e.jsx(s,{children:"moves.warrantyClaims"}),")."]})]}),e.jsxs(t,{id:"rma-reclamations-rappels",title:"17.7 Rappels des réclamations en retard (CHARGEOPS-332)",children:[e.jsxs(r,{children:["Tranche 4. ",e.jsx(s,{children:"convex/warrantyClaims/staleScan.ts"}),", cron"," ",e.jsx(s,{children:"warranty-claim-stale-scan"})," (",e.jsx(s,{children:"convex/crons.ts"}),", chaque jour à 13 h 00 UTC). ",e.jsx(s,{children:"scanStaleClaims"})," lit les locataires (plafond 1 000, journalisé) et planifie un ",e.jsx(s,{children:"scanTenantStaleClaims"})," par locataire"," ",e.jsx(n,{children:"actif"})," dont le module ",e.jsx(s,{children:"rma"})," est activé, tous au même"," ",e.jsx(s,{children:"now"}),". Un passage par locataire dépense un budget de"," ",e.jsx(s,{children:"STALE_CLAIM_BATCH"})," (100) lignes par invocation sur ",e.jsx(s,{children:"draft"}),","," ",e.jsx(s,{children:"submitted"})," puis ",e.jsx(s,{children:"approved"}),", se replanifie là où le budget s'est épuisé et s'arrête après ",e.jsx(s,{children:"STALE_CLAIM_MAX_BATCHES"})," (20) invocations avec un"," ",e.jsx(s,{children:"console.warn"}),". ",e.jsx(s,{children:"internalMutation"})," brute : chaque plage est préfixée par le ",e.jsx(s,{children:"tenantId"})," épinglé et chaque écriture le porte. Un locataire dont le module est coupé (ou suspendu depuis la répartition) est sauté"," ",e.jsx(n,{children:"sans verrou"}),"."]}),e.jsxs(r,{children:["Index ",e.jsx(s,{children:"by_tenant_status_deletedAt_followUpNotifiedAt_followUpDueAt"}),", lu avec ",e.jsx(s,{children:"deletedAt"})," et ",e.jsx(s,{children:"followUpNotifiedAt"})," épinglés à"," ",e.jsx(s,{children:"undefined"})," et ",e.jsx(s,{children:"followUpDueAt"})," dans ",e.jsx(s,{children:"[1, now]"})," : une réclamation supprimée, une fermée (non balayée, et son échéance est effacée) et une ligne sans échéance ne sont jamais lues. ",e.jsx(n,{children:"Le verrou vit dans l'index"})," ","(la leçon du balayage des relances fabricant) : ",e.jsx(s,{children:"followUpNotifiedAt = now"})," ","est posé ",e.jsx(n,{children:"avant"})," la cloche, dans la même transaction, donc une réclamation rappelée sort aussitôt de la plage — pas de curseur, et un passage relancé ne sonne personne deux fois. Chaque écriture sur la réclamation (",e.jsx(s,{children:"mutations.ts::touch"}),") repose ",e.jsx(s,{children:"followUpDueAt"})," et efface le verrou : un rappel par période sans changement. Une fusion de cas ne fait que repointer"," ",e.jsx(s,{children:"ticketId"})," (",e.jsx(s,{children:"tickets/merge.ts"}),") : elle ne relance ni l'échéance ni le rappel."]}),e.jsxs(r,{children:["Cloche : événement de matrice ",e.jsx(s,{children:"warranty_claim_stale"})," ","(",e.jsx(s,{children:"shared/notification-policy.ts"}),", groupe ",e.jsx(s,{children:"cases"}),", audience"," ",e.jsx(s,{children:"target"})," = le responsable, cloche par défaut, courriel"," ",e.jsx(n,{children:"verrouillé"})," éteint — aucun modèle personnel ; pas une entrée de"," ",e.jsx(s,{children:"TENANT_EVENT_KEYS"}),"), vérifié par ",e.jsx(s,{children:"loadNotificationPolicy"})," +"," ",e.jsx(s,{children:"allowsUser"})," (la matrice de l'espace ET les préférences du responsable). Ligne : ",e.jsx(s,{children:"kind"})," ",e.jsx(s,{children:"warranty_claim_stale"}),", ",e.jsx(s,{children:"entityType"})," ",e.jsx(s,{children:"WARRANTY_CLAIM"}),", ",e.jsx(s,{children:"linkPath"})," ",e.jsx(s,{children:"/rma?tab=claims&claim=<id>"}),", jetons ",e.jsx(s,{children:"claimRef"}),","," ",e.jsx(s,{children:"chargerName"}),", ",e.jsx(s,{children:"vendorName"}),", ",e.jsx(s,{children:"status"}),","," ",e.jsx(s,{children:"days"})," — ",e.jsx(n,{children:"jamais de montant"}),", quel que soit le responsable (une ligne de cloche survit à un changement de rôle). Un responsable disparu, inactif, d'un autre locataire ou compte du portail (",e.jsx(s,{children:"isPortalAccount"}),") est verrouillé sans cloche, avec un ",e.jsx(s,{children:"console.warn"})," ; un responsable qui a coupé l'avis est verrouillé en silence. Aucun des deux n'est relancé avant un changement de la réclamation. Tests : ",e.jsx(s,{children:"convex/warrantyClaims/__tests__/staleScan.test.ts"}),"."]})]}),e.jsxs(t,{id:"rma-reclamations-export",title:"17.8 Export comptable des réclamations (CHARGEOPS-333)",children:[e.jsxs(r,{children:["Tranche 5. Cinquième jeu de l'export comptable, ",e.jsx(s,{children:"warrantyClaims"})," ","(",e.jsx(s,{children:"ACCOUNTING_DATASETS"})," dans ",e.jsx(s,{children:"shared/accounting-export.ts"}),","," ",e.jsx(s,{children:"accountingExportDatasetValidator"})," dans ",e.jsx(s,{children:"convex/schema.ts"}),"), possédé par le module ",e.jsx(s,{children:"rma"}
343)," : offert et permis tant que le RMA est actif, même Centre d'entretien coupé. Collecteur"," ",e.jsx(s,{children:"convex/accounting/queries.ts::exportWarrantyClaims"}),","," ",e.jsx(s,{children:'moduleQuery("rma")'}),", appelé par ",e.jsx(s,{children:"actions.ts::createExport"})," ","comme les trois autres collecteurs — même enveloppe, même fichier stocké, même lien de 7 jours, ligne d'audit avec ",e.jsx(s,{children:'dataset: "warrantyClaims"'}),". Fichier"," ",e.jsx(s,{children:"chargeops-warranty-claims_<from>_<to>.csv"}),", passé par"," ",e.jsx(s,{children:"buildCsv"})," (garde contre les formules)."]}),e.jsxs(r,{children:["Barrières, avant toute lecture : ",e.jsx(s,{children:"requireStaffAccount"})," puis"," ",e.jsx(s,{children:'requirePermission("viewFinancials")'})," (admin, finance) — un"," ",e.jsx(s,{children:"site-operator"})," est refusé même si un rôle lui donne"," ",e.jsx(s,{children:"viewFinancials"}),". La portée d'organisation est appliquée au serveur : un lecteur sans restriction lit chaque statut exporté une fois sur"," ",e.jsx(s,{children:"by_tenant_status_deletedAt_updatedAt"})," ; un lecteur limité lit chacune de ses organisations (au plus ",e.jsx(s,{children:"MAX_WARRANTY_CLAIM_EXPORT_ORGS"})," = 200) par statut sur le nouvel index"," ",e.jsx(s,{children:"by_tenant_organizationId_status_deletedAt_updatedAt"}),", pour qu'un refus ne compte jamais les réclamations d'une autre organisation. ",e.jsx(s,{children:"claimInScope"})," ","(",e.jsx(s,{children:"convex/warrantyClaims/helpers.ts"}),") repasse ensuite la liste des sites et la règle d'organisation."]}),e.jsxs(r,{children:["Période (",e.jsx(s,{children:"warrantyClaimInPeriod"}),", jours UTC comme les autres jeux) : une réclamation y entre si elle a été soumise (",e.jsx(s,{children:"submittedAt"}),") au plus tard à la fin et n'a pas été fermée (",e.jsx(s,{children:"closedAt"})," ; refusée, réglée ou radiée) avant le début. Statuts lus : ",e.jsx(s,{children:"WARRANTY_CLAIM_EXPORT_STATUSES"})," ","(",e.jsx(s,{children:"submitted"}),", ",e.jsx(s,{children:"approved"}),", ",e.jsx(s,{children:"denied"}),","," ",e.jsx(s,{children:"settled"}),", ",e.jsx(s,{children:"written_off"}),") — jamais un brouillon, ni une radiation directe depuis le brouillon (sans ",e.jsx(s,{children:"submittedAt"}),"). Avec une période, un statut fermé n'est lu qu'à partir de ",e.jsx(s,{children:"updatedAt ≥ from"})," : sûr, car chaque fermeture estampille aussi ",e.jsx(s,{children:"updatedAt"}),". La colonne Statut donne le statut actuel de la réclamation au moment de l'export, pas celui qu'elle avait pendant la période."]}),e.jsxs(r,{children:["Bornes, toutes comptées avant la moindre jointure : au plus"," ",e.jsx(s,{children:"MAX_WARRANTY_CLAIM_EXPORT_CLAIMS"})," (1 000) candidates, puis le plafond de 5 000 lignes, puis ",e.jsx(s,{children:"WARRANTY_CLAIMS_EXPORT_CALL_BUDGET"})," (3 500 appels : les lectures d'index plus un ",e.jsx(s,{children:"get"})," par RMA, dossier fabricant, pièce, organisation et site distincts — ",e.jsx(s,{children:"warrantyClaimExportCalls"}),"). Au-delà de l'une d'elles, refus « Export trop volumineux : réduisez la période. », jamais un fichier écourté. Un RMA, un dossier ou une pièce introuvable (ou d'un autre locataire) laisse sa cellule vide."]}),e.jsxs(r,{children:["Lignes (",e.jsx(s,{children:"warrantyClaimRowFor"}),", colonnes ",e.jsx(s,{children:"WARRANTY_CLAIM_COLUMNS"}),") : réclamation ",e.jsx(s,{children:"WC-…"}),", fabricant, n° de réclamation et de dossier du fabricant, n° de RMA, borne, n° de série, SKU et quantité de la pièce, organisation, site, statut, dates de soumission, de décision, de règlement et de fermeture, réclamé (matériel, main-d'œuvre, transport, total), approuvé, crédit (montant, note, date), remplacement (valeur, date), total récupéré, solde à recevoir, devise"," ",e.jsx(s,{children:"CAD"}),", ID de la réclamation et du site. L'argent reste en cents entiers jusqu'à la cellule ; un montant jamais saisi reste une cellule vide, jamais 0. Le solde n'est non nul que pour ",e.jsx(s,{children:"submitted"})," / ",e.jsx(s,{children:"approved"})," : approuvé (sinon total réclamé) moins récupéré, plancher 
3430."]}),e.jsxs(r,{children:["UI : l'onglet Réclamations de garantie (",e.jsx(s,{children:"warranty-claims-tab.tsx"}),") montre un bouton ",e.jsx(n,{children:"Export comptable"})," aux seuls détenteurs de"," ",e.jsx(s,{children:"viewFinancials"})," ; il ouvre ",e.jsx(s,{children:"AccountingExportDialog"})," avec"," ",e.jsx(s,{children:'initialDataset="warrantyClaims"'}),". Tests :"," ",e.jsx(s,{children:"shared/__tests__/accounting-export-warranty-claims.test.ts"}),","," ",e.jsx(s,{children:"convex/accounting/__tests__/exportWarrantyClaims.test.ts"}),"."]})]})]}),e.jsxs(a,{id:"suivi-garantie",title:"18. Suivi de la garantie",children:[e.jsxs(r,{children:["Calculé côté client dans ",e.jsx(s,{children:"RMACard"}),", à partir de la borne sous-jacente."," ",e.jsx(s,{children:"rma.queries"})," renvoie ",e.jsx(s,{children:"installationDate"})," et"," ",e.jsx(s,{children:"warrantyYears"})," (lu depuis ",e.jsx(s,{children:"chargers.warrantyDuration"}),") :"]}),e.jsx(l,{language:"ts",children:`// client/src/pages/rma/rma-card.tsx:113 (extrait ; CHARGEOPS-586 a séparé rma.tsx)
344const parsedInstallDate = safeParseDateString(rma.charger.installationDate);
345const warrantyEndDate =
346  parsedInstallDate && rma.charger.warrantyYears != null
347    ? addYears(parsedInstallDate, rma.charger.warrantyYears)
348    : null;
349const isWarrantyExpired = warrantyEndDate ? new Date() > warrantyEndDate : false;`}),e.jsxs(t,{id:"indicateurs-colores",title:"18.1 Pastille de garantie",children:[e.jsxs(r,{children:["Quand la fin de garantie est connue, la carte ajoute une pastille"," ",e.jsx(s,{children:"co-field"})," dans sa rangée de pastilles, juste après le numéro de série :"]}),e.jsx(h,{headers:["État","Condition","Rendu"],rows:[[e.jsxs(e.Fragment,{children:["Garantie valide (",e.jsx(s,{children:"warrantyValid"},"k1"),")"]}),e.jsx(s,{children:"now <= warrantyEndDate"},"c1"),"bouclier ShieldCheck, texte vert (emerald)"],[e.jsxs(e.Fragment,{children:["Hors garantie (",e.jsx(s,{children:"outOfWarranty"},"k2"),")"]}),e.jsx(s,{children:"now > warrantyEndDate"},"c2"),"bouclier ShieldAlert, texte rouge"]]}),e.jsxs(r,{children:["L'infobulle (",e.jsx(s,{children:"title"}),") donne la date de fin :"," ",e.jsx(s,{children:"warrantyUntil"})," suivi de ",e.jsx(s,{children:"formatDate(warrantyEndDate)"}),", dans la langue de l'application. Si ",e.jsx(s,{children:"installationDate"})," ou"," ",e.jsx(s,{children:"warrantyYears"})," manque, ",e.jsx(n,{children:"aucune pastille"})," n'est affichée : une borne sans date d'installation n'est pas une borne hors garantie. Il n'y a pas d'état « expire bientôt » sur les cartes RMA."]}),e.jsx(r,{children:"Le calcul ne touche jamais la base : la garantie est dérivée à la lecture."})]})]}),e.jsxs(a,{id:"site-origine",title:"19. Site d'origine",children:[e.jsxs(r,{children:[e.jsx(s,{children:"originalSiteId"})," et ",e.jsx(s,{children:"originalSiteName"})," sont"," ",e.jsx(n,{children:"figés à la création"})," du RMA. Cela garantit que la traçabilité reste correcte même si :"]}),e.jsxs(d,{children:[e.jsx(i,{children:"Le site est renommé après la création."}),e.jsxs(i,{children:["Le site est supprimé (",e.jsx(s,{children:"deletedAt"}),")."]}),e.jsxs(i,{children:["La borne est déplacée vers l'entrepôt (siteId devient ",e.jsx(s,{children:"undefined"}),")."]})]}),e.jsxs(r,{children:["Dans la query ",e.jsx(s,{children:"getAll"})," (lignes 156-157 de"," ",e.jsx(u,{children:"convex/rma/queries.ts"}),") :"]}),e.jsx(l,{language:"ts",children:`// Use the originalSiteName stored on the RMA record (captured at creation time).
350// The charger's current siteId may be null if it was moved to warehouse for RMA.
351const resolvedSiteName = rma.originalSiteName || site?.name || "Unknown";`}),e.jsxs(r,{children:["L'UI rend ce nom comme un lien"," ",e.jsx(s,{children:"<Link href={`/sites/${rma.originalSiteId}`}>"})," quand l'ID est un Convex ID string, sinon comme un simple span (cas legacy ",e.jsx(s,{children:"number"}),")."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Organisation."})," ",e.jsx(s,{children:"getAll"})," et ",e.jsx(s,{children:"getHistory"})," résolvent aussi l'",e.jsx(s,{children:"organization"})," du site (",e.jsx(s,{children:"{ id, name, color }"}),") via"," ",e.jsx(s,{children:"site.organizationId"}),", avec repli sur le site figé (",e.jsx(s,{children:"originalSiteId"})," ","normalisé via ",e.jsx(s,{children:"ctx.db.normalizeId"}),") quand la borne a quitté le site (",e.jsx(s,{children:"charger.siteId === null"}),"). L'UI l'affiche à côté du site via"," ",e.jsx(s,{children:"OrganizationBadge"})," (pastille à la couleur de l'organisation + nom) : première pastille de la rangée sur les cartes RMA, et sous le nom du site dans les tableaux Actif / Historique. Aucune organisation → aucun badge."]})]}),e.jsxs(a,{id:"notes-documentation",title:"20. Notes & documentation",children:[e.jsx(r,{children:"Trois mutations gèrent les notes :"}),e.jsx(h,{headers:["Mutation","Rôle"],rows:[[e.jsx(s,{children:"addNote(id, text)"},"n1"),"Append une note timestampée"],[e.jsx(s,{children:"deleteNote(id, noteId)"},"n2"),"Retire la note ciblée"],["(édition)",e.jsxs(e.Fragment,{children:["géré par patch direct côté UI sur ",e.jsx(s,{children:"editedAt"})," /"," ",e.jsx(s,{children:"editedByUserId"})]})]]}),e.jsx(l,{language:"ts",children:`// convex/rma/mutations.ts:235 (addNote)
352const note = {
353  id: \`\${now}-\${Math.random().toString(36).slice(2, 9)}\`,
354  text: text.trim(),
355  timestamp: now,
356  userId: user._id as string,
357  userEmail: user.email,
358  userFirstName: user.firstName,
359  userLastName: user.lastName,
360};
361const existingNotes = Array.isArray(rma.notes) ? rma.notes : [];
362await ctx.db.patch(id, {
363  notes: [...existingNotes, note],
364  updatedAt: now,
365});`}),e.jsxs(t,{id:"notes-cas-usage",title:"20.1 Cas d'usage",children:[e.jsxs(d,{children:[e.jsxs(i,{children:[e.jsx(n,{children:"Défauts"})," : description du symptôme observé sur site."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Réparations"})," : étapes du diagnostic technique."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Résolutions"})," : décision finale (réparé / remplacé / scrappé)."]})]}),e.jsxs(r,{children:["Chaque note est affichée avec l'avatar de l'auteur (résolu via"," ",e.jsx(s,{children:"userProfileImageUrl"}),"), la date formatée et le texte multiligne."]})]})]}),e.jsxs(a,{id:"pieces-jointes",title:"21. Pièces jointes (fichiers)",children:[e.jsxs(t,{id:"types-acceptes",title:"21.1 Types acceptés",children:[e.jsx(h,{headers:["Type","Extensions","Stockage"],rows:[["PDF (formulaires)",e.jsx(s,{children:".pdf"},"ext1"),e.jsxs(e.Fragment,{children:["Convex ",e.jsx(s,{children:"_storage"})]})],["Images",e.jsxs(e.Fragment,{children:[e.jsx(s,{children:".jpg"}),", ",e.jsx(s,{children:".jpeg"}),", ",e.jsx(s,{children:".png"}),","," ",e.jsx(s,{children:".gif"}),", ",e.jsx(s,{children:".webp"})]}),e.jsxs(e.Fragment,{children:["Convex ",e.jsx(s,{children:"_storage"})]})]]}),e.jsxs(r,{children:[e.jsx(s,{children:"fileType"})," stocké est ",e.jsx(s,{children:'"pdf"'})," ou ",e.jsx(s,{children:'"image"'})," ","(catégorie haute), tandis que le MIME exact est dans ",e.jsx(s,{children:"filePath"})," ou récupéré via ",e.jsx(s,{children:"application/pdf"})," / ",e.jsx(s,{children:"image/*"}),"."]})]}),e.jsxs(t,{id:"limite-taille",title:"21.2 Limite de taille",children:[e.jsxs(r,{children:[e.jsx(n,{children:"10 MB par fichier"})," (validation côté client) :"]}),e.jsx(l,{language:"ts",children:`// client/src/pages/rma/rma-card.tsx:177 (CHARGEOPS-586 a séparé rma.tsx)
366if (selectedFile.size > 10 * 1024 * 1024) {
367  toast({
368    title: t("failedToUploadFile"),
369    description: "File size must be less than 10MB",
370    variant: "destructive",
371  });
372  return;
373}`}),e.jsxs(r,{children:["Pas de limite côté serveur (Convex ",e.jsx(s,{children:"_storage"})," supporte plus, mais 10 MB couvre largement les besoins typiques : photos haute résolution, formulaires PDF multipages)."]})]}),e.jsxs(t,{id:"flow-upload",title:"21.3 Flow d'upload",children:[e.jsx(r,{children:"Pattern Convex storage en trois temps :"}),e.jsxs(x,{children:[e.jsxs(i,{children:[e.jsx(s,{children:"generateUploadUrl(
373)"})," retourne une URL pré-signée (",e.jsx(s,{children:"api.files.mutations.generateUploadUrl"}),")."]}),e.jsxs(i,{children:["Le client ",e.jsx(s,{children:"POST"})," le binaire directement à l'URL et reçoit un"," ",e.jsx(s,{children:"storageId"}),"."]}),e.jsxs(i,{children:[e.jsx(s,{children:"createRmaFile({ rmaId, fileName, fileType, filePath, storageId, fileSize })"})," ","crée la ligne ",e.jsx(s,{children:"rmaFiles"}),"."]})]}),e.jsx(l,{language:"ts",children:`// client/src/pages/rma/rma-card.tsx:138 (extrait, CHARGEOPS-586 a séparé rma.tsx)
374const uploadUrl = await generateUploadUrlFn({});
375const uploadResp = await fetch(uploadUrl, {
376  method: "POST",
377  headers: { "Content-Type": file.type || "application/octet-stream" },
378  body: file,
379});
380const { storageId } = await uploadResp.json();
381
382await createRmaFileFn({
383  rmaId: rma.id as any,
384  fileName: file.name,
385  fileType: file.type || "application/octet-stream",
386  filePath: file.name,
387  storageId,
388  fileSize: file.size,
389});`}),e.jsxs(r,{children:[e.jsx(s,{children:"files.mutations.createRmaFile"})," et"," ",e.jsx(s,{children:"rma.mutations.uploadFile"})," sont une seule implémentation (",e.jsx(s,{children:"convex/rma/mutations.ts::insertRmaFile"}),", CHARGEOPS-446), avec les barrières de toute autre écriture RMA : le module"," ",e.jsx(s,{children:"rma"})," activé, un compte du personnel qui détient"," ",e.jsx(s,{children:"editRMA"})," (un compte du Portail exploitant et un utilisateur"," ",e.jsx(s,{children:"finance"})," sont refusés), et un RMA actif dont la borne est dans le périmètre de l'appelant — un RMA supprimé (soft delete) est refusé. Le"," ",e.jsx(s,{children:"storageId"})," est ensuite ",e.jsx(n,{children:"réclamé"})," (",e.jsx(s,{children:"claimUploadedBlob"}),", usage ",e.jsx(s,{children:"rmaFile"}),") : le blob doit d'abord exister, avoir été stocké dans la dernière heure (",e.jsx(s,{children:"RMA_FILE_UPLOAD_MAX_AGE_MS"}),", CHARGEOPS-468) et n'appartenir à aucun autre enregistrement — y compris d'un autre espace de travail. Ces trois vérifications donnent un seul refus, « That upload could not be found — it may have expired » (CHARGEOPS-503) : un ancien blob qu'aucun chemin n'a réclamé se lit exactement comme un blob introuvable. Ensuite seulement, il doit correspondre au ",e.jsx(s,{children:"fileSize"})," déclaré, peser au plus 10 Mo (",e.jsx(s,{children:"RMA_FILE_MAX_BYTES"}),") et porter un type PDF ou PNG/JPEG/GIF/WebP si un type a été enregistré (",e.jsx(s,{children:"RMA_FILE_CONTENT_TYPES"}),", la liste du sélecteur), chacun avec son propre message. Avant,"," ",e.jsx(s,{children:"createRmaFile"})," ne vérifiait que le périmètre d'organisation et stockait l'id tel quel. Tests :"," ",e.jsx(s,{children:"convex/rma/__tests__/fileGates.test.ts"}),","," ",e.jsx(s,{children:"convex/rma/__tests__/blobAge.test.ts"}),"."]})]}),e.jsxs(t,{id:"lecture-suppression-fichiers",title:"21.4 Lecture & suppression",children:[e.jsxs(d,{children:[e.jsxs(i,{children:[e.jsx(s,{children:"getFiles(rmaId)"})," et ",e.jsx(s,{children:"getById"})," enrichissent chaque fichier avec son URL pré-signée (",e.jsx(s,{children:"rmaFileUrl"}),"), mais seulement pour un blob que la ligne possède : réclamé comme ",e.jsx(s,{children:"rmaFile"}),", ou non réclamé et stocké au plus une heure avant le ",e.jsx(s,{children:"uploadedAt"})," de la ligne (CHARGEOPS-468). Sinon — une ligne greffée avant la réclamation sur le blob d'un autre enregistrement — ",e.jsx(s,{children:"url"})," vaut"," ",e.jsx(s,{children:"null"}),". Aucune des deux ne renvoie le ",e.jsx(s,{children:"storageId"}),"."," ",e.jsx(s,{children:"getHistory"})," liste les noms de fichiers sans URL (la vérification coûte des appels à la base qu'une liste de 500 lignes ne peut pas se permettre) ; l'aperçu de l'onglet Historique lit"," ",e.jsx(s,{children:"getFiles"})," pour le RMA qu'il ouvre."]}),e.jsxs(i,{children:["L'UI propose un preview inline (PDF dans ",e.jsx(s,{children:"<iframe>"}),", image dans"," ",e.jsx(s,{children:"<img>"}),")."]}),e.jsxs(i,{children:[e.jsx(s,{children:"deleteFile(fileId)"})," (et ",e.jsx(s,{children:"files.mutations.deleteRmaFile"}),", la même implémentation, ",e.jsx(s,{children:"removeRmaFile"}),") retire la ligne"," ",e.jsx(s,{children:"rmaFiles"}),", supprime son blob ",e.jsx(s,{children:"_storage"})," et libère la ligne ",e.jsx(s,{children:"storageClaims"})," du blob — sauf si un autre type d'enregistrement a réclamé 
389ce blob, ou s'il n'est pas réclamé et a été stocké plus d'une heure avant la ligne (une ligne greffée avant la réclamation), auquel cas seule la ligne disparaît. Mêmes barrières que l'envoi ; un fichier dont le RMA a disparu ou est supprimé est refusé plutôt que supprimé sans vérification de périmètre. Un hard delete libère le blob et la réclamation de chaque fichier de la même façon."]})]}),e.jsx(r,{children:"L'upload multiple est supporté : sélection de plusieurs fichiers → upload séquentiel."})]})]}),e.jsxs(a,{id:"suivi-sim-cards",title:"22. Suivi des SIM cards RMA",children:[e.jsxs(r,{children:["Onglet dédié ",e.jsx(n,{children:"SIM Cards"})," dans la page RMA. Données : table"," ",e.jsx(s,{children:"rmaSimCards"}),"."]}),e.jsx(t,{id:"sim-vue-tabulaire",title:"22.1 Vue tabulaire",children:e.jsx(c,{rows:[{key:e.jsx(n,{children:"ICCID"}),value:e.jsx(s,{children:"simIccid"})},{key:e.jsx(n,{children:"Carrier"}),value:e.jsx(s,{children:"carrier"})},{key:e.jsx(n,{children:"Charger"}),value:e.jsxs(e.Fragment,{children:[e.jsx(s,{children:"chargerName"})," (snapshot du PE-ID au moment de la création du RMA)"]})},{key:e.jsx(n,{children:"Status"}),value:e.jsxs(e.Fragment,{children:["badge selon ",e.jsx(s,{children:"status"})]})},{key:e.jsx(n,{children:"RMA ID"}),value:e.jsxs(e.Fragment,{children:[e.jsx(s,{children:"internalId"})," du RMA parent (lien)"]})},{key:e.jsx(n,{children:"Created"}),value:e.jsx(s,{children:"createdAt"})}]})}),e.jsx(t,{id:"sim-statuts",title:"22.2 Statuts",children:e.jsx(h,{headers:["status","Description","Couleur badge"],rows:[[e.jsx(s,{children:"activated"},"ss1"),"SIM active chez le carrier","vert"],[e.jsx(s,{children:"suspended"},"ss2"),"SIM suspendue (pause facturation)","ambre"],[e.jsx(s,{children:"terminated"},"ss3"),"SIM résiliée définitivement","rouge"]]})}),e.jsx(t,{id:"sim-mutations",title:"22.3 Mutations",children:e.jsx(l,{language:"ts",children:`// convex/rma/mutations.ts:492
390export const updateSimCardStatus = mutation({
391  args: { simCardId: v.id("rmaSimCards"), status: v.string() },
392  handler: async (ctx, { simCardId, status }) => {
393    const validStatuses = ["activated", "suspended", "terminated"];
394    if (!validStatuses.includes(status)) {
395      throw new Error(\`Invalid SIM card status "\${status}"…\`);
396    }
397    // … patch + auditLog
398  },
399});
400
401export const deleteSimCard = mutation({
402  args: { simCardId: v.id("rmaSimCards") },
403  // … delete + auditLog
404});`})}),e.jsxs(t,{id:"sim-recherche-filtrage",title:"22.4 Recherche & filtrage",children:[e.jsx(r,{children:"L'UI permet :"}),e.jsxs(d,{children:[e.jsxs(i,{children:[e.jsx(n,{children:"Recherche"})," insensible à la casse sur ",e.jsx(s,{children:"simIccid"})," et"," ",e.jsx(s,{children:"carrier"}),"."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Filtre par statut"})," (dropdown)."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Tri"})," par toutes les colonnes."]})]})]}),e.jsx(t,{id:"sim-export-csv",title:"22.5 Export CSV",children:e.jsxs(r,{children:["Le bouton « Exporter CSV » génère un ",e.jsx(s,{children:"rma_sim_cards_AAAA-MM-JJ.csv"})," à cinq colonnes — SIM ICCID, Carrier, Charger Name, Status, Created Date (§25) — pour transmission au gestionnaire de carriers (ex. Eseye, Telus). Comme l'export de l'historique RMA, il passe par les assistants partagés ",e.jsx(s,{children:"toCsv"})," / ",e.jsx(s,{children:"downloadCsv"})," de"," ",e.jsx(u,{children:"client/src/lib/csv-export.ts"})," (échappement RFC 4180 des guillemets, BOM UTF-8, révocation de l'URL objet)."]})}),e.jsx(t,{id:"sim-suppression-confirmation",title:"22.6 Suppression avec confirmation",children:e.jsxs(r,{children:["Le bouton Trash sur chaque ligne ouvre un ",e.jsx(s,{children:"AlertDialog"})," exigeant confirmation explicite. La suppression est ",e.jsx(n,{children:"permanente"})," (pas de soft-delete pour les SIM cards RMA — la donnée est secondaire et peut toujours être recréée à partir du carrier)."]})})]}),e.jsxs(a,{id:"integration-entrepot",title:"23. Intégration entrepôt",children:[e.jsx(r,{children:"Le découplage entrepôt/RMA est intentionnel mais bien chorégraphié :"}),e.jsx(h,{headers:["Évènement","Effet sur entrepôt","Effet sur RMA"],rows:[["Création d'un RMA",e.jsxs(e.Fragment,{children:["borne disparaît de la vue (filtre ",e.jsx(s,{children:"inRma"}),")"]}),e.jsxs(e.Fragment,{children:["nouveau ",e.jsx(s,{children:"rmaChargers"})," actif"]})],[e.jsx(s,{children:"moveToWarehouse"},"evt2"),"borne réapparaît","rien"],[e.jsxs(e.Fragment,{children:[e.jsx(s,{children:"moveToWarehouse"})," (avec RMA actif)"]}),"borne reste cachée (filtre RMA prime)","rien"],[e.jsx(s,{children:"cancel"},"evt-cancel"),"borne remise en service sur son site d'origine",e.jsxs(e.Fragment,{children:[e.jsx(s,{children:"rma_cancelled"}),", remplacement délié, RMA passe en History"]})],[e.jsx(s,{children:"softDelete"},"evt4"),e.jsxs(e.Fragment,{children:["borne réapparaît si ",e.jsx(s,{children:"siteId"})," est ",e.jsx(s,{children:"undefined"})]}),"RMA passe en History"],["Hard-delete du RMA",e.jsxs(e.Fragment,{children:["borne réapparaît si ",e.jsx(s,{children:"siteId"})," est ",e.jsx(s,{children:"undefined"})]}),"enfants supprimés (files, sim cards, statusHistory)"],[e.jsx(s,{children:"setReplacementCharger"},"evt6"),"borne de remplacement reste dans son site","RMA pointe vers la borne neuve"]]}),e.jsx(v,{title:"Workflow typique",steps:[e.jsxs(e.Fragment,{children:["Borne défectueuse sur site A → création RMA (status ",e.jsx(s,{children:"awaiting_return"}),")."]}),e.jsxs(e.Fragment,{children:["Tech démonte la borne, la rapporte → ",e.jsx(s,{children:"moveToWarehouse"})," + status"," ",e.jsx(s,{children:"received"}),"."]}),e.jsxs(e.Fragment,{children:["Diagnostic → status ",e.jsx(s,{children:"processed_warehouse"})," ou ",e.jsx(s,{children:"ready_to_ship"}),"."]}),e.jsxs(e.Fragment,{children:["Expédition au manufacturier → ",e.jsx(s,{children:"shipped_to_mfr"}),","," ",e.jsx(s,{children:"mfr_processing"}),"."]}),e.jsxs(e.Fragment,{children:["Remplacement reçu → ",e.jsx(s,{children:"replacement_received"}),", puis l'admin associe une autre borne via ",e.jsx(s,{children:"setReplacementCharger"}),"."]}),e.jsxs(e.Fragment,{children:["Clôture → ",e.jsx(s,{children:"rma_closed"})," (RMA passe en History, borne d'origine peut être hard-supprimée)."]})]})]}),e.jsxs(a,{id:"set-replacement-dialog",title:"24. Set Replacement dialog",children:[e.jsxs(r,{children:[`Le dialog "Set Replacement" associe une borne de remplacement (déjà sur le site d'origine) au RMA. Source :`," ",e.jsx(s,{children:"client/src/pages/rma/set-replacement-charger-dialog.tsx"})," (CHARGEOPS-586 a séparé rma.tsx)."]}),e.jsxs(t,{id:"correction-recente",title:"24.1 Correction récente (commit 72f5246)",children:[e.jsxs(r,{children:["Avant cette c
404orrection, le dialog ne listait pas correctement les bornes du site. Le commit ",e.jsx(s,{children:"fix(rma): list site chargers in Set Replacement dialog"})," ","corrige cela en utilisant ",e.jsx(s,{children:"api.chargers.queries.getBySiteId"})," :"]}),e.jsx(l,{language:"ts",children:`// client/src/pages/rma/set-replacement-charger-dialog.tsx (CHARGEOPS-586 a
405// séparé rma.tsx). Le composant appelle désormais
406// api.rma.queries.getReplacementCandidates (ligne 42) plutôt que
407// getBySiteId ci-dessous — cet extrait a dérivé depuis l'écriture de
408// cette section, indépendamment du split.
409const chargersRaw = useQuery(
410  api.chargers.queries.getBySiteId,
411  open && typeof rma.originalSiteId === "string"
412    ? { siteId: rma.originalSiteId as any }
413    : "skip",
414);
415const chargers: Charger[] = (chargersRaw ?? []) as unknown as Charger[];`}),e.jsxs(r,{children:["L'UI affiche désormais"," ",e.jsx(n,{children:"toutes les bornes encore présentes sur le site d'origine"})," (",e.jsx(s,{children:"originalSiteId"}),"), avec leur PE-ID (",e.jsx(s,{children:"name"}),") et leur numéro de série. L'utilisateur sélectionne une borne et confirme — la mutation"," ",e.jsx(s,{children:"setReplacementCharger"})," (",e.jsx(s,{children:"convex/rma/mutations.ts:186"}),") patche le RMA :"]}),e.jsx(l,{language:"ts",children:`await ctx.db.patch(id, {
416  replacementChargerId,
417  updatedAt: now,
418});`}),e.jsxs(r,{children:["Le ",e.jsx(s,{children:"replacementChargerId"})," est ensuite affiché dans la fiche RMA (Active et History) avec le nom et le S/N de la borne de remplacement."]}),e.jsxs(r,{children:["Sur la page du site, le badge « remplace la borne X » de la liste des bornes vient de ",e.jsxs(s,{children:["rma.getReplacementsForChargers(","{ chargerIds }",")"]})," (CHARGEOPS-294), et non plus de la liste plafonnée ",e.jsx(s,{children:"getAll"})," : une lecture par borne affichée sur l'index ",e.jsx(s,{children:"by_tenant_replacementChargerId_deletedAt"}),", le RMA ouvert le plus récent (",e.jsx(s,{children:"isRmaOpen"})," : vivant, ni fermé ni annulé) l'emportant, avec la règle de périmètre des listes appliquée à la borne défectueuse. Le coût suit le nombre de bornes de la page (au plus 500 identifiants par appel, soit au plus 1 510 appels à la base), pas le nombre de RMA de l'espace de travail : un RMA ouvert au-delà de la limite de la liste garde son badge."]})]}),e.jsx(t,{id:"cas-original-site-id-number",title:"24.2 Cas où originalSiteId est un number",children:e.jsxs(r,{children:["Quand ",e.jsx(s,{children:"originalSiteId"})," est un legacy int (RMAs anciens), le dialog n'affiche pas la liste : la query est sautée (",e.jsx(s,{children:'"skip"'}),"). L'utilisateur doit alors mettre à jour manuellement l'",e.jsx(s,{children:"originalSiteId"})," ou créer le replacement via une autre voie."]})})]}),e.jsxs(a,{id:"export-excel-rma",title:"25. Exports CSV",children:[e.jsxs(r,{children:["La page RMA a trois exports, tous en CSV UTF-8 avec BOM (",e.jsx(s,{children:"toCsv"})," et"," ",e.jsx(s,{children:"downloadCsv"}),", ",e.jsx(s,{children:"client/src/lib/csv-export.ts"}),") :"]}),e.jsx(c,{rows:[{key:e.jsx(n,{children:"RMA Actifs — Tout exporter (CSV)"}),value:e.jsxs(e.Fragment,{children:["RMA actives et historique fusionnés, dédoublonnés par id, du plus récent au plus ancien : ",e.jsx(s,{children:"rma-all-AAAA-MM-JJ.csv"}),"."]})},{key:e.jsx(n,{children:"Historique — Exporter CSV"}),value:e.jsxs(e.Fragment,{children:["L'historique seul : ",e.jsx(s,{children:"rma-history-AAAA-MM-JJ.csv"}),"."]})},{key:e.jsx(n,{children:"Cartes SIM RMA — Exporter CSV"}),value:e.jsxs(e.Fragment,{children:[e.jsx(s,{children:"rma_sim_cards_AAAA-MM-JJ.csv"})," : SIM ICCID, Carrier, Charger Name, Status, Created Date (date dans la langue de l'app)."]})}]}),e.jsxs(r,{children:["Les deux exports de RMA partagent douze colonnes (",e.jsx(s,{children:"buildCSVRows"}),","," ",e.jsx(s,{children:"client/src/pages/rma/rma-page.tsx:586"}),") :"]}),e.jsx(c,{rows:[{key:e.jsx(n,{children:"RMA ID"}),value:e.jsx(s,{children:"internalId"})},{key:e.jsx(n,{children:"Charger Name"}),value:e.jsx(s,{children:"charger.name"})},{key:e.jsx(n,{children:"Brand"}),value:e.jsx(s,{children:"charger.brand"})},{key:e.jsx(n,{children:"Model"}),value:e.jsx(s,{children:"charger.model"})},{key:e.jsx(n,{children:"Serial Number"}),value:e.jsx(s,{children:"charger.serialNumber"})},{key:e.jsx(n,{children:"Organization"}),value:e.jsx(s,{children:"organization.name"})},{key:e.jsx(n,{children:"Original Site"}),value:e.jsx(s,{children:"originalSiteName"})},{key:e.jsx(n,{children:"Status"}),value:e.jsxs(e.Fragment,{children:[e.jsx(s,{children:"status"})," — la valeur brute, pas le libellé traduit"]})},{key:e.jsx(n,{children:"Created Date"}),value:e.jsxs(e.Fragment,{children:[e.jsx(s,{children:"createdAt"}),", ",e.jsx(s,{children:"yyyy-MM-dd HH:mm:ss"})," en heure locale"]})},{key:e.jsx(n,{children:"Deleted Date"}),value:e.jsxs(e.Fragment,{children:[e.jsx(s,{children:"deletedAt"}),", même format ; vide sinon"]})},{key:e.jsx(n,{children:"Created By"}),value:"Prénom et nom du créateur, sinon son courriel"},{key:e.jsx(n,{children:"Notes"}
418),value:e.jsxs(e.Fragment,{children:["Toutes les notes, ",e.jsx(s,{children:"[yyyy-MM-dd] texte"}),", séparées par ",e.jsx(s,{children:"|"})]})}]}),e.jsx(r,{children:"Les en-têtes restent en anglais. Aucune colonne de garantie, de borne de remplacement, de fichiers ni de cartes SIM : ces champs n'ont jamais fait partie de l'export."})]}),e.jsxs(a,{id:"rma-recherche-filtrage",title:"26. Recherche & filtrage",children:[e.jsx(t,{id:"rma-onglet-active",title:"26.1 Onglet Active",children:e.jsx(c,{rows:[{key:e.jsx(n,{children:"Recherche libre"}),value:e.jsxs(e.Fragment,{children:[e.jsx(s,{children:"searchQuery"})," sur ",e.jsx(s,{children:"internalId"}),","," ",e.jsx(s,{children:"charger.name"}),", ",e.jsx(s,{children:"charger.serialNumber"}),","," ",e.jsx(s,{children:"originalSiteName"})]})},{key:e.jsx(n,{children:"Créateur"}),value:e.jsxs(e.Fragment,{children:["dropdown des users distincts (",e.jsx(s,{children:"activeCreatorFilter"}),")"]})},{key:e.jsx(n,{children:"Vue"}),value:"toggle Card / Table"},{key:e.jsx(n,{children:"Tri"}),value:"par date asc/desc (table view)"}]})}),e.jsx(t,{id:"rma-onglet-history",title:"26.2 Onglet History",children:e.jsx(c,{rows:[{key:e.jsx(n,{children:"Recherche libre"}),value:e.jsxs(e.Fragment,{children:[e.jsx(s,{children:"historySearchQuery"})," (mêmes champs qu'Active)"]})},{key:e.jsx(n,{children:"Site"}),value:e.jsxs(e.Fragment,{children:["dropdown des ",e.jsx(s,{children:"originalSiteName"})," distincts (",e.jsx(s,{children:"historySiteFilter"}),")"]})}]})}),e.jsx(t,{id:"rma-onglet-sim-cards",title:"26.3 Onglet SIM Cards",children:e.jsx(c,{rows:[{key:e.jsx(n,{children:"Recherche"}),value:e.jsxs(e.Fragment,{children:["sur ",e.jsx(s,{children:"simIccid"})," et ",e.jsx(s,{children:"carrier"})]})},{key:e.jsx(n,{children:"Statut"}),value:e.jsxs(e.Fragment,{children:["dropdown (",e.jsx(s,{children:"activated"})," / ",e.jsx(s,{children:"suspended"})," /"," ",e.jsx(s,{children:"terminated"}),")"]})}]})})]})]}),e.jsxs(o,{id:"cas-limites-integrations",title:"Cas limites & intégrations",children:[e.jsxs(a,{id:"restauration-borne-rma",title:"27. Restauration d'une borne en RMA",children:[e.jsxs(r,{children:["Quand un RMA est ",e.jsx(s,{children:"softDelete"})," (admin), il sort de la vue Active mais reste en History :"]}),e.jsxs(x,{children:[e.jsxs(i,{children:["La borne est restaurée par ",e.jsx(s,{children:"restoreChargerForCancelledRma"})," ","(",e.jsx(s,{children:"deletedAt"})," retiré, ",e.jsx(s,{children:"isActive"})," vrai) — sauf si le RMA est dans un statut terminal (",e.jsx(s,{children:"RMA_TERMINAL_STATUSES"}),"). Fermé (",e.jsx(s,{children:"rma_closed"}),") : la borne reste retirée. Annulé (",e.jsx(s,{children:"rma_cancelled"}),") : ",e.jsx(s,{children:"cancelRmaRecord"})," l'a déjà remise sur son site, et la restaurer une seconde fois ressusciterait une borne supprimée depuis (CHARGEOPS-289)."]}),e.jsxs(i,{children:["Le filtre ",e.jsx(s,{children:"inRma"})," (section 6) cesse de la masquer car"," ",e.jsx(s,{children:"getWarehouseChargers"})," ne retient que les RMA dont"," ",e.jsx(s,{children:"deletedAt === undefined"})," et dont le statut n'est pas"," ",e.jsx(s,{children:"rma_cancelled"}),"."]}),e.jsxs(i,{children:["Si la borne avait ",e.jsx(s,{children:"siteId === undefined"})," au moment du RMA, elle réapparaît dans l'entrepôt."]}),e.jsxs(i,{children:["Si la borne avait un ",e.jsx(s,{children:"siteId"})," figé (RMA créé sans"," ",e.jsx(s,{children:"moveToWarehouse"}),"), elle réapparaît sur ce site."]})]}),e.jsxs(r,{children:["La restauration ne peut jamais créer de doublon (CHARGEOPS-147) : tant qu'une borne appartient à un RMA non supprimé — ",e.jsx(n,{children:"fermé compris"})," — ou à un RMA supprimé après sa fermeture, son nom / PE-ID reste réservé (un RMA supprimé dans un autre statut était une annulation et ne réserve rien, pas plus qu'un RMA"," ",e.jsx(s,{children:"rma_cancelled"}),", supprimé ou non). ",e.jsx(s,{children:"create"}),", ",e.jsx(s,{children:"update"})," (renommage),"," ",e.jsx(s,{children:"bulkImport"})," et ",e.jsx(s,{children:"checkPeIdAvailability"})," passent tous par"," ",e.jsx(s,{children:"convex/lib/peIdUtils.ts::findChargerHoldingName"})," et refusent ce nom en citant le numéro du RMA. La vérification a lieu quand on prend le nom, pas à la restauration. Depuis CHARGEOPS-253, une borne supprimée hors RMA garde aussi son nom : toute ligne ",e.jsx(s,{children:"chargers"})," du locataire, vivante ou supprimée, tient son nom, et le refus dit alors que le PE-ID appartenait à une borne supprimée. Une restauration (annulation de RMA ou restauration admin) ne peut donc jamais créer de doublon."]}),e.jsxs(r,{children:["Pour un ",e.jsx(n,{children:"hard delete"})," (admin), tous les enfants ",e.jsx(s,{children:"rmaFiles"})," ","/ ",e.jsx(s,{children:"rmaSimCards"})," / ",e.jsx(s,{children:"rmaStatusHistory"})," sont supprimés en cascade (",e.jsx(s,{children:"convex/rma/mutations.ts::hardDelete"}),"). Le blob ",e.jsx(s,{children:"_storage"})," ","de chaque fichier est supprimé avec sa ligne, et sa ligne"," ",e.jsx(s,{children:"storageClaims"})," libérée — selon la même règle de propriété qu'une suppression unitaire (",e.jsx(s,{children:"releaseBlobIfOwned"}),", CHARGEOPS-468) : un blob que la ligne ne possède pas est laissé à son propriétaire."]})]}),e.jsxs(a,{id:"affichage-nom-site",title:"28. Affichage du nom de site",children:[e.jsx(r,{children:"L'UI affiche un libellé contextuel selon l'état :"}),e.jsx(h,{headers:["Contexte","Affichage"],rows:[[e.jsxs(e.Fragment,{children:["Borne avec ",e.jsx(s,{children:"siteId"})," valide"]}),"nom du site (lien cliquable)"],[e.jsxs(e.Fragment,{children:["Borne en entrepôt (",e.jsx(s,{children:"siteId === undefined"}),")"]}),'"Warehouse" (label localisé)'],["Borne en RMA actif",'"RMA" (label localisé, dans certaines vues)'],[e.jsxs(e.Fragment,{children:["RMA dans la fiche : ",e.jsx(s,{children:"originalSiteName"})]}),e.jsxs(e.Fragment,{children:["nom de site figé (lien si ",e.jsx(s,{children:"originalSiteId"})," est une Convex ID)"]})],["Site historique supprimé",e.jsxs(e.Fragment,{children:[e.jsx(s,{children:"originalSiteName"}),' du snapshot ou "Unknown" en fallback']})]]}),e.jsxs(r,{children:["L'ordre de fallback dans ",e.jsx(s,{children:"getAll"})," :"]}),e.jsx(l,{language:"ts",children:'const resolvedSiteName = rma.originalSiteName || site?.name || "Unknown";'})]}),e.jsxs(a,{id:"backup",title:"29. Backup",children:[e.jsx(r,{children:"Le système de backup global de ChargeOps inclut :"}),e.jsx(h,{headers:["Table","Inclusion","Note"],rows:[[e.jsx(s,{children:"chargers"},"b1"),"toujours","source principale"],[e.jsx(s,{children:"chargerMovementHistory"},"b2"),"toujours","piste d'audit physique"],[e.jsx(s,{children:"availablePeIds"},"b3"),"toujours","référence rapide du pool"],[e.jsx(s,{children:"rmaChargers"},"b4"),"toujours",e.jsxs(e.Fragment,{children:["y compris RMAs ",e.jsx(s,{children:"deletedAt"})]})],[e.jsx(s,{children:"rmaFiles"},"b5"),"métadonnées seulement",e.jsxs(e.Fragment,{children:["les blobs ",e.jsx(s,{children:"_storage"})," sont sauvegardés à part"]})],[e.jsx(s,{children:"rmaSimCards"},"b6"),"toujours",""],[e.jsx(s,{children:"rmaStatusHistory"},"b7"),"toujours","timeline immuable"]]}),e.jsxs(r,{children:["Les blobs Convex ",e.jsx(s,{children:"_storage"})," (PDFs, images RMA) sont sauvegardés via le mécanisme natif Convex (ex
418port storage), distinct de l'export tabulaire."]})]})]}),e.jsx(o,{id:"annexe-cheatsheet",title:"Annexe — Cheatsheet API Convex",children:e.jsx(c,{rows:[{key:e.jsx(n,{children:"Lister entrepôt"}),value:e.jsx(s,{children:"api.chargers.queries.getWarehouseChargers"})},{key:e.jsx(n,{children:"Lister PE-IDs disponibles"}),value:e.jsx(s,{children:"api.warehouse.queries.getUnusedPeIdsUpTo({ max: 1450 })"})},{key:e.jsx(n,{children:"Créer une borne entrepôt"}),value:e.jsx(s,{children:"api.chargers.mutations.create"})},{key:e.jsx(n,{children:"Bulk insert chargers"}),value:e.jsx(s,{children:"api.chargers.mutations.bulkImport"})},{key:e.jsx(n,{children:"Déplacer vers site"}),value:e.jsx(s,{children:"api.chargers.mutations.moveToSite"})},{key:e.jsx(n,{children:"Retour entrepôt"}),value:e.jsx(s,{children:"api.chargers.mutations.moveToWarehouse"})},{key:e.jsx(n,{children:"Historique mouvements"}),value:e.jsx(s,{children:"api.chargers.queries.getMovementHistory({ chargerId })"})},{key:e.jsx(n,{children:"Lister RMAs actifs"}),value:e.jsx(s,{children:"api.rma.queries.getAll"})},{key:e.jsx(n,{children:"Lister RMAs historiques"}),value:e.jsx(s,{children:"api.rma.queries.getHistory"})},{key:e.jsx(n,{children:"Détails RMA"}),value:e.jsx(s,{children:"api.rma.queries.getById({ id })"})},{key:e.jsx(n,{children:"Créer RMA"}),value:e.jsx(s,{children:"api.rma.mutations.create({ chargerId, notes })"})},{key:e.jsx(n,{children:"Changer statut"}),value:e.jsx(s,{children:"api.rma.mutations.updateStatus({ id, newStatus })"})},{key:e.jsx(n,{children:"Annuler un RMA"}),value:e.jsxs(e.Fragment,{children:[e.jsx(s,{children:"api.rma.mutations.cancel({ id, reason? })"})," (",e.jsx(s,{children:"editRMA"}),")"]})},{key:e.jsx(n,{children:"Set replacement"}),value:e.jsx(s,{children:"api.rma.mutations.setReplacementCharger"})},{key:e.jsx(n,{children:"Ajouter une note"}),value:e.jsx(s,{children:"api.rma.mutations.addNote({ id, text })"})},{key:e.jsx(n,{children:"Upload fichier"}),value:e.jsxs(e.Fragment,{children:[e.jsx(s,{children:"api.files.mutations.generateUploadUrl"})," +"," ",e.jsx(s,{children:"createRmaFile"})]})},{key:e.jsx(n,{children:"Lister SIM cards"}),value:e.jsx(s,{children:"api.rma.queries.getSimCards"})},{key:e.jsx(n,{children:"Update SIM status"}),value:e.jsx(s,{children:"api.rma.mutations.updateSimCardStatus"})},{key:e.jsx(n,{children:"Soft-delete RMA"}),value:e.jsxs(e.Fragment,{children:[e.jsx(s,{children:"api.rma.mutations.softDelete"})," (admin)"]})},{key:e.jsx(n,{children:"Hard-delete RMA"}),value:e.jsxs(e.Fragment,{children:[e.jsx(s,{children:"api.rma.mutations.hardDelete"})," (admin)"]})}]})}),e.jsxs(o,{id:"partie-3-pieces",title:"Partie 3 — Pièces de rechange (CHARGEOPS-33)",children:[e.jsxs(r,{children:["Catalogue de pièces de rechange par tenant et ses emplacements de stock, en"," ",e.jsx(s,{children:"convex/parts/"}
418),". Distinct des deux systèmes ci-dessus : les pièces ne sont pas des ",e.jsx(s,{children:"chargers"}),", et le catalogue est proprié par le tenant (contrairement aux catalogues ",e.jsx(s,{children:"chargerBrands"}),"/",e.jsx(s,{children:"chargerModels"})," curés par la plateforme). Slice 1 de 6 de l'épique inventaire de pièces — voir"," ",e.jsx(s,{children:"docs/superpowers/specs/2026-09-19-spare-parts-inventory-design.md"}),"."]}),e.jsxs(a,{id:"pieces-modele",title:"1. Modèle de données",children:[e.jsxs(r,{children:["Deux tables, toutes deux dans ",e.jsx(s,{children:"TENANT_TABLES"})," et"," ",e.jsx(s,{children:"BACKUP_TABLES"})," :"]}),e.jsxs(d,{children:[e.jsxs(i,{children:[e.jsx(s,{children:"parts"})," — ",e.jsx(s,{children:"sku"})," (index ",e.jsx(s,{children:"by_tenant_sku"}),"),"," ",e.jsx(s,{children:"name"}),", ",e.jsx(s,{children:"category"}),", ",e.jsx(s,{children:"unit"}),","," ",e.jsx(s,{children:"compatibleModelIds"})," (référence ",e.jsx(s,{children:"chargerModels"}),"),"," ",e.jsx(s,{children:"minStock"}),", ",e.jsx(s,{children:"reorderQuantity"}),","," ",e.jsx(s,{children:"supplierName"}),"/",e.jsx(s,{children:"supplierSku"}),", ",e.jsx(s,{children:"avgUnitCost"})," ","(viewFinancials seulement), ",e.jsx(s,{children:"sellPrice"})," (prix de vente en dollars, écrit avec ",e.jsx(s,{children:"manageInventory"}),", jamais retiré du payload — CHARGEOPS-305),"," ",e.jsx(s,{children:"lowStockNotifiedAt"})," (verrou d'alerte, écrit à partir de la slice 5), ",e.jsx(s,{children:"isActive"}),", suppression douce via"," ",e.jsx(s,{children:"deletedAt"}),", restaurée par"," ",e.jsx(s,{children:"restorePart"}),". ",e.jsx(s,{children:"skuKey"})," (SKU rogné et mis en minuscules, index ",e.jsx(s,{children:"by_tenant_skuKey"}),") porte l'unicité — voir « Unicité du SKU » ci-dessous. ",e.jsx(s,{children:"searchText"})," (SKU + nom + SKU fournisseur, en minuscules) alimente l'index de recherche ",e.jsx(s,{children:"search_text"})," — voir « Recherche »."]}),e.jsxs(i,{children:[e.jsx(s,{children:"stockLocations"})," — ",e.jsx(s,{children:"name"}),", ",e.jsx(s,{children:"kind"})," (",e.jsx(s,{children:'"warehouse"'})," | ",e.jsx(s,{children:'"vehicle"'}),", validateur exporté"," ",e.jsx(s,{children:"stockLocationKindValidator"}),"), ",e.jsx(s,{children:"assignedUserId"})," optionnel (le camion d'un technicien actif, jamais un site-operator),"," ",e.jsx(s,{children:"isActive"}),", ",e.jsx(s,{children:"isDefault"})," (mis sur la seule ligne que"," ",e.jsx(s,{children:"ensureDefaultLocation"})," a créée — voir « Emplacement par défaut »)."]})]}),e.jsxs(p,{tone:"info",title:"Le grand livre",children:[e.jsx(s,{children:"partStockLevels"})," et ",e.jsx(s,{children:"partStockMovements"})," — et leur seul écrivain, ",e.jsx(s,{children:"postMovement"})," (CHARGEOPS-34) — sont documentés dans la section suivante."]})]}),e.jsxs(a,{id:"pieces-sku-unicite",title:"1b. Unicité du SKU et recherche",children:[e.jsxs(r,{children:[e.jsx(s,{children:"assertSkuAvailable"})," (",e.jsx(s,{children:"convex/parts/lib.ts"}),") vérifie l'unicité par une lecture d'égalité sur ",e.jsx(s,{children:"by_tenant_skuKey"})," — un"," ",e.jsx(s,{children:".first()"})," sur l'index, pas un scan du catalogue. Le premier tour de la slice comparait ",e.jsx(s,{children:"sku.trim().toLowerCase()"})," contre TOUT le catalogue du tenant collecté en mémoire ; correct sous concurrence, mais un scan complet à chaque sauvegarde, qui serait devenu quadratique une fois l'import de la slice 3 en boucle sur ",e.jsx(s,{children:"createPart"}),"/",e.jsx(s,{children:"updatePart"}),". La conflit sous concurrence reste correct avec l'index : deux créations concurrentes qui visent le même ",e.jsx(s,{children:"skuKey"})," lisent la même entrée d'index, donc le contrôle de concurrence optimiste (OCC) de Convex fait rejouer l'une contre la ligne de l'autre devenue visible. L'unicité couvre les pièces archivées : un SKU reste réservé après archivage (",e.jsx(s,{children:"restorePart"})," est le chemin de retour), ce qui évite un historique ambigu entre deux pièces répondant au même identifiant."]}),e.jsxs(r,{children:[e.jsx(s,{children:"listParts"})," utilise l'index de recherche ",e.jsx(s,{children:"search_text"})," (",e.jsx(s,{children:'searchField: "searchText"'}),", ",e.jsx(s,{children:'filterFields: ["tenantId"]'}),", même forme que le premier index de recherche du dépôt sur ",e.jsx(s,{children:"messages"}),") quand un terme de recherche est fourni — ce qui trouve une pièce même au-delà du"," ",e.jsx(s,{children:"SCAN_CAP"})," de 500 lignes de la liste bornée ordinaire. Sans terme de recherche, ",e.jsx(s,{children:"listParts"})," garde le chemin borné habituel sur"," ",e.jsx(s,{children:"by_tenant_sku"}),". ",e.jsx(s,{children:"searchText"})," est recalculé à chaque"," ",e.jsx(s,{children:"createPart"}),"/",e.jsx(s,{children:"updatePart"})," touchant ",e.jsx(s,{children:"sku"}),","," ",e.jsx(s,{children:"name"})," ou ",e.jsx(s,{children:"supplierSku"}),", jamais laissé périmé."]})]}),e.jsx(a,{id:"pieces-emplacement-defaut",title:"1c. Emplacement par défaut",children:e.jsxs(r,{children:[e.jsx(s,{children:"ensureDefaultLocation"})," (",e.jsx(s,{children:"convex/parts/lib.ts"}),") crée l'emplacement « Entrepôt principal » de façon idempotente (elle vérifie d'abord qu'aucun emplacement n'existe déjà pour le tenant) et pose"," ",e.jsx(s,{children:"isDefault: true"}),". Le seul appelant est ",e.jsx(s,{children:"createPart"}),", à la première pièce créée pour un tenant qui n'a encore aucun emplacement — il n'y a plus de mutation publique dédiée que le client appellerait au montage (retirée : elle était accessible à un titulaire de ",e.jsx(s,{children:"viewInventory"})," seul, alors que le gestionnaire d'emplacements l'appelait sans condition, ce qui faisait planter le dialogue pour un technicien ou un financier). Le client affiche le nom traduit « Main warehouse » / « Entrepôt principal » quand ",e.jsx(s,{children:"isDefault"})," est vrai ET que le nom stocké est encore la valeur française d'origine — une pièce renommée garde son propre nom, dans les deux langues."]})}),e.jsxs(a,{id:"pieces-modeles-compatibles",title:"1d. Modèles de borne compatibles (CHARGEOPS-341)",children:[e.jsxs(r,{children:[e.jsx(s,{children:"parts.compatibleModelIds"})," alimente les suggestions de la réservation de pièces (épique CHARGEOPS-340) : les pièces dont les modèles recoupent ceux des bornes du site. Une seule règle, ",e.jsx(s,{children:"normalizeCompatibleModelIds"})," dans"," ",e.jsx(s,{children:"convex/parts/lib.ts"}),", pour les trois chemins d'écriture —"," ",e.jsx(s,{children:"createPart"}),", ",e.jsx(s,{children:"updatePart"})," et la ligne serveur de"," ",e.jsx(s,{children:"importParts"})," : chaque id doit passer"," ",e.jsx(s,{children:'normalizeId("chargerModels", …)'})," puis ",e.jsx(s,{children:"db.get"})," (un id d'une autre table ou un modèle supprimé par la plateforme est refusé, « Modèle de borne introuvable. »), les doublons tombent (premier gardé), au plus"," ",e.jsx(s,{children:"MAX_COMPATIBLE_MODELS"})," = 100, et une liste vide rend"," ",e.jsx(s,{children:"undefined"})," — sur ",e.jsx(s,{children:"updatePart"}),", ",e.jsx(s,{children:"[]"})," efface le champ. ",e.jsx(s,{children:"chargerModels"})," est dans ",e.jsx(s,{children:"SHARED_CATALOG_TABLES"})," : aucun test inter-tenant n'a d'objet, le catalogue est commun et en lecture seule côté tenant."]}),e.jsxs(r,{children:[e.jsx(s,{children:"getPart"})," renvoie ",e.jsx(s,{children:"compatibleModels"})," (",e.jsx(s,{children:"{ _id, name, brandName }"}),", trié par marque puis modèle), en sautant un id que la plateforme a supprimé depuis. Côté client,"," ",e.jsx(s,{children:"components/parts/compatible-models-picker.tsx"})," est un sélecteur en ligne (filtre + cases groupées par marque, sélection en ",e.jsx(s,{children:"co-chip"}),") alimenté par"," ",e.jsx(s,{children:"configuration.queries.getBrandsWithModels"})," ;"," ",e.jsx(s,{children:"part-form-dialog.tsx"})," retire à l'enregistrement tout id absent du catalogue chargé, envoie toujours la liste en modification (",e.jsx(s,{children:"[]"})," efface) et la laisse de côté tant que le catalogue charge. L'import CSV n'a toujours pas de colonne : un nom de modèle libre est ambigu d'une marque à l'autre. Tests :"," ",e.jsx(s,{children:"convex/parts/__tests__/compatibleModels.test.ts"}),","," ",e.jsx(s,{children:"client/src/components/parts/__tests__/part-form-dialog.test.tsx"}),"."]})]}),e.jsxs(a,{id:"pieces-permissions",title:"2. Permissions et module",children:[e.jsxs(r,{children:["Deux clés dans ",e.jsx(s,{children:"shared/permissions.ts"}),", rattachées au module"," ",e.jsx(s,{children:"warehouse"})," dans ",e.jsx(s,{children:"shared/modules.ts"})," (donc plafonnées quand ce module est désactivé pour le tenant) :"]}),e.jsx(c,{rows:[{key:e.jsx(n,{children:"viewInventory"}),value:"admin, user, technician, finance."},{key:e.jsx(n,{children:"manageInventory"}),value:"admin, user."},{key:e.jsx(n,{children:"reserveParts"}),value:"admin, user, technician — un technicien seulement sur ses propres visites (règle par ligne, convex/parts/reservations.ts). Jamais finance ni site-operator (widensExternalRole refuse de l'élever). CHARGEOPS-342."}]}),e.jsxs(r,{children:["Chaque fonction publique de ",e.jsx(s,{children:"convex/parts/"})," utilise"," ",e.jsx(s,{children:'moduleQuery("warehouse")'})," / ",e.jsx(s,{children:'moduleMutation("warehouse")'})," — qui applique déjà ",e.jsx(s,{children:'assertFeatureEnabled("warehouse")'})," à la construction du contexte, avant même que le handler ne s'exécute — puis appelle"," ",e.jsx(s,{children:"requirePermission"})," comme toute première ligne du handler, avant toute lecture. Les coûts (",e.jsx(s,{children:"avgUnitCost"}
418),") sont retirés du payload sans"," ",e.jsx(s,{children:"viewFinancials"})," par ",e.jsx(s,{children:"sanitizePart"})," (",e.jsx(s,{children:"convex/parts/lib.ts"}),"), même discipline que"," ",e.jsx(s,{children:"extendedWarranties/interventions.ts::sanitizeParts"}),"."]})]}),e.jsx(a,{id:"pieces-fonctions",title:"3. Fonctions",children:e.jsx(c,{rows:[{key:e.jsx(n,{children:"listParts"}),value:"Recherche/catégorie/actif, bornée (SCAN_CAP = 500, truncated)."},{key:e.jsx(n,{children:"getPart"}),value:"Une pièce, coût retiré sans viewFinancials."},{key:e.jsx(n,{children:"listLocations"}),value:"Tous les emplacements du tenant."},{key:e.jsx(n,{children:"listAssignableTechnicians"}),value:"Techniciens actifs, pour le picker du gestionnaire d'emplacements (manageInventory, pas admin — plus large que users.queries.getAll)."},{key:e.jsx(n,{children:"createPart / updatePart"}),value:"SKU unique par tenant (rogné, insensible à la casse ; equality lookup sur by_tenant_skuKey, y compris contre les pièces archivées). updatePart clarifie ou touche champ par champ : null efface un champ, absent le laisse intact."},{key:e.jsx(n,{children:"archivePart"}),value:"Suppression douce (isActive: false + deletedAt), idempotente. Le SKU reste réservé. Refusé tant qu'un SEUL emplacement a un solde non nul (CHARGEOPS-34) — voir la section suivante — ou qu'une réservation ouverte la vise (CHARGEOPS-342)."},{key:e.jsx(n,{children:"restorePart"}),value:"Inverse d'archivePart (isActive: true, deletedAt effacé), idempotente."},{key:e.jsx(n,{children:"upsertLocation"}),value:"Crée ou modifie un emplacement. assignedUserId doit être un utilisateur actif et jamais un site-operator. La désactivation est refusée tant qu'il y reste du stock ou une réservation ouverte (CHARGEOPS-342)."}]})}),e.jsxs(a,{id:"pieces-client",title:"4. Client",children:[e.jsxs(r,{children:["Route ",e.jsx(s,{children:"/warehouse/parts"})," (lazy,"," ",e.jsx(s,{children:"client/src/pages/warehouse-parts.tsx"}),", gate ",e.jsx(s,{children:"viewInventory"}),"), composants dans ",e.jsx(s,{children:"client/src/components/parts/"})," :"," ",e.jsx(s,{children:"parts-catalog-card.tsx"})," (recherche/filtres/tableau/archiver/restaurer),"," ",e.jsx(s,{children:"part-form-dialog.tsx"})," (créer/modifier — envoie ",e.jsx(s,{children:"null"})," pour effacer un champ en édition, ",e.jsx(s,{children:"undefined"})," pour l'omettre à la création) et"," ",e.jsx(s,{children:"locations-manager-dialog.tsx"})," (emplacements, lecture seule sans"," ",e.jsx(s,{children:"manageInventory"})," — ",e.jsx(s,{children:"listAssignableTechnicians"})," n'est interrogée que si le titulaire a cette permission)."]}),e.jsxs(r,{children:["Les deux pages de l'entrepôt partagent un sous-menu,"," ",e.jsx(s,{children:"client/src/components/warehouse-section-tabs.tsx"})," (le"," ",e.jsx(s,{children:"SectionSubNav"})," commun, piloté par la route comme celui de la Maintenance) : l'onglet Pièces n'apparaît qu'avec ",e.jsx(s,{children:"viewInventory"}),", et la rangée disparaît quand il ne reste qu'un onglet. Ses onglets forment la surface de personnalisation ",e.jsx(s,{children:"subnav.warehouse"}),", rattachée au module"," ",e.jsx(s,{children:"warehouse"}),". Pour l'accueillir, ",e.jsx(s,{children:"warehouse.tsx"})," passe en"," ",e.jsx(s,{children:"fullWidth"})," avec son propre conteneur ",e.jsx(s,{children:"page-max"}),", comme"," ",e.jsx(s,{children:"rma.tsx"}),"."]})]}),e.jsxs(a,{id:"pieces-import",title:"5. Import et export (CHARGEOPS-35, slice 3)",children:[e.jsxs(r,{children:[e.jsx(s,{children:"convex/parts/import.ts::importParts"})," — suit le patron de"," ",e.jsx(s,{children:"importContracts"})," (",e.jsx(s,{children:"convex/maintenance/contracts.ts"}),") : validateur de ligne, ",e.jsx(s,{children:"MAX_IMPORT_ROWS = 200"}),", résultat"," ",e.jsx(s,{children:"{ created, updated, skipped, errors[], warnings[] }"}),". Gate"," ",e.jsx(s,{children:"manageInventory"})," avant toute lecture ; les colonnes de coût exigent en plus ",e.jsx(s,{children:"viewFinancials"})," — sinon la ligne entière est refusée (erreur par ligne, même discipline que ",e.jsx(s,{children:"createPart"}),"/",e.jsx(s,{children:"updatePart"}),"). Une seule entrée ",e.jsx(s,{children:"logAudit"})," résume l'appel."]}),e.jsxs(p,{tone:"info",title:"Trois phases strictement séparées",children:[e.jsx(n,{children:"1. Valider"})," chaque ligne — lectures seules, aucune écriture. Chaque précondition qu'une réception peut échouer est vérifiée ICI : pièce non archivée (",e.jsx(s,{children:"assertActivePart"}),"), emplacement actif (",e.jsx(s,{children:"assertActiveLocation"}),"), unités entières sur l'unité EFFECTIVE (",e.jsx(s,{children:"fields.unit ?? existing.unit"}
418),"), et la recherche d'idempotence du stock initial. Un SKU en double dans le même fichier est aussi une erreur de ligne ici, nommant la ligne précédente."," ",e.jsx(n,{children:"2. Écrire"})," le catalogue (créer/patch) pour chaque ligne ayant survécu à la phase 1. ",e.jsx(n,{children:"3. Un seul"})," appel"," ",e.jsx(s,{children:"lib.ts::withLedger"})," qui poste toutes les réceptions de stock initial du lot, ",e.jsx(n,{children:"sans try/catch"})," : toute précondition qu'une réception pourrait violer a déjà été vérifiée en phase 1, donc un lever d'exception ici signale que la phase 1 a manqué quelque chose (un bug) — tout le lot doit échouer et revenir en arrière via la garantie transactionnelle de Convex, pas être avalé comme une erreur de ligne."]}),e.jsxs(r,{children:["Une pièce est retrouvée par SKU (rogné, insensible à la casse) sur tout le catalogue du tenant, y compris les pièces archivées — même règle qu'à la création. Une colonne absente d'une ligne ne touche jamais le champ existant sur une pièce déjà là (seule une valeur non vide écrit) ; une référence"," ",e.jsx(s,{children:"compatibleModelIds"})," inconnue est une erreur de ligne, jamais un plantage. Aucune colonne « modèles compatibles » dans le modèle CSV lui-même — un nom de modèle tapé à la main ne se résout pas de façon sûre sans un sélecteur — mais le validateur de ligne l'accepte pour un appel programmatique."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Stock initial."})," Le ",e.jsx(s,{children:"refId"})," déterministe (",e.jsx(s,{children:"computeOpeningRefId"}),") est ",e.jsx(s,{children:"`opening:${partId}`"}
418)," — la PIÈCE SEULE, jamais l'emplacement : « stock initial » est un concept d'amorçage par pièce, pas par emplacement. Une clé incluant l'emplacement laissait une réimportation qui ajoute un second emplacement poster une DEUXIÈME réception, doublant ",e.jsx(s,{children:"totalOnHand"})," — le bug exact que ce choix de clé élimine. Une fonction locale (",e.jsx(s,{children:"openingReceiptAlreadyPosted"}),") vérifie"," ",e.jsx(s,{children:"by_ref"})," en phase 1 : une réimportation ne pose jamais une seconde réception, quel que soit l'emplacement nommé cette fois, et une quantité changée entre deux imports est ignorée (pas refusée) — signalée par une entrée"," ",e.jsx(s,{children:"warnings[]"}),", jamais en silence, y compris quand un coût sur cette ligne n'a pas été appliqué. La colonne « coût » d'une ligne AVEC stock initial n'écrit jamais ",e.jsx(s,{children:"avgUnitCost"})," directement : c'est le ",e.jsx(s,{children:"unitCost"})," de la réception qui pilote le recalcul de moyenne mobile (exactement comme un"," ",e.jsx(n,{children:"Recevoir"})," manuel)."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Stock bas."})," Relever ",e.jsx(s,{children:"minStock"})," à l'import d'une pièce existante appelle ",e.jsx(s,{children:"reconcileLowStockLatch"})," directement (même schéma que"," ",e.jsx(s,{children:"updatePart"})," — hors ",e.jsx(s,{children:"withLedger"}),", car le stock lui-même ne bouge pas). Une pièce toute neuve importée déjà sous son minimum n'alerte jamais à tort : son solde avant mutation vaut 0 (une pièce qui vient d'être créée), donc"," ",e.jsx(s,{children:"wasBelow"})," et ",e.jsx(s,{children:"isBelow"})," sont tous deux vrais et rien ne franchit."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Analyse client."})," ",e.jsx(s,{children:"client/src/lib/parts-import.ts"})," ","distingue une cellule vide (",e.jsx(s,{children:"undefined"}),", aucune écriture) d'une cellule non vide illisible (erreur de ligne — jamais silencieusement perdue) via"," ",e.jsx(s,{children:"parseAmountCell"})," : un point rend toute virgule non ambiguë (séparateur de milliers), sans point une seule virgule suivie de 1-2 chiffres est une décimale européenne (« 12,5 »), exactement 3 chiffres est un groupement de milliers (« 1 000 » écrit « 1,000 » ne devient jamais 1), tout le reste est refusé comme ambigu. ",e.jsx(s,{children:"decodeCsvBuffer"})," décode d'abord en UTF-8 et retente en windows-1252 si le résultat contient le caractère de remplacement (U+FFFD) — un export Excel plus ancien typique. Le dialogue"," ","(",e.jsx(s,{children:"parts-import-dialog.tsx"}),") ajoute une étape d'aperçu avant tout envoi : estimation création/mise à jour (contre un instantané ponctuel de"," ",e.jsx(s,{children:"listParts"})," via ",e.jsx(s,{children:"useConvex().query"}),", jamais un abonnement), erreurs de lecture et doublons de SKU par numéro de ligne, avis quand une colonne coût sera ignorée. Un échec de chunk en cours d'exécution rapporte les totaux DÉJÀ posés par les chunks précédents plutôt que « 0 créée »."]}),e.jsxs(r,{children:["Export : ",e.jsx(s,{children:"client/src/components/parts/parts-catalog-card.tsx"})," construit le CSV directement via ",e.jsx(s,{children:"csv-export.ts"})," à partir de la vue déjà chargée (filtrée/recherchée), plus deux lectures ponctuelles au clic —"," ",e.jsx(s,{children:"useConvex().query"}),", jamais ",e.jsx(s,{children:"useQuery"}),", sur"," ",e.jsx(s,{children:"api.parts.queries.listStockLevels"})," (tout ",e.jsx(s,{children:"partStockLevels"})," du tenant, jamais le grand livre) et ",e.jsx(s,{children:"listLocations"})," — pour ne pas garder un abonnement ambiant sur 5000 lignes ouvert en permanence juste pour un bouton d'export. Les en-têtes des colonnes que l'importateur reconnaît aussi (",e.jsx(s,{children:"sku"}),", ",e.jsx(s,{children:"name"}),", ",e.jsx(s,{children:"category"}),", …) sont les MÊMES clés machine que ",e.jsx(s,{children:"PARTS_IMPORT_COLUMNS"}),", jamais un libellé traduit, pour qu'un export réimporté ne tombe pas sur une colonne « non reconnue ». Un avertissement (toast) apparaît si ",e.jsx(s,{children:"listParts"})," ou les niveaux fraîchement lus signalent ",e.jsx(s,{children:"truncated"}),"."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Injection CSV."})," ",e.jsx(s,{children:"csv-export.ts::escapeCsvValue"})," — le point d'échappement partagé par tout export CSV de l'app — préf
418ixe désormais d'une apostrophe littérale toute cellule commençant par ",e.jsx(s,{children:"="}),", ",e.jsx(s,{children:"+"}),","," ",e.jsx(s,{children:"-"})," ou ",e.jsx(s,{children:"@"})," (mitigation OWASP standard : forcé en texte par tout tableur). Compromis assumé : un nombre négatif légitime (le stock disponible peut l'être) s'affiche en texte plutôt qu'en nombre calculé une fois le fichier ouvert — les caractères eux-mêmes ne sont jamais altérés."]})]})]}),e.jsxs(o,{id:"pieces-ledger-tech",title:"Grand livre des pièces — implémentation",children:[e.jsxs(r,{children:["Trois tables additives : ",e.jsx(s,{children:"parts.totalOnHand"})," (solde total de la pièce, tous emplacements confondus), ",e.jsx(s,{children:"partStockLevels"})," (solde par pièce × emplacement) et"," ",e.jsx(s,{children:"partStockMovements"})," (journal, jamais modifié ni supprimé). Une seule fonction écrit les trois : ",e.jsx(s,{children:"convex/parts/lib.ts::postMovementUnchecked"}),", qui insère le mouvement puis met à jour (ou crée) la ligne de solde ET patch"," ",e.jsx(s,{children:"totalOnHand"})," dans la MÊME mutation — mais elle n'est PAS exportée. L'entrée publique est ",e.jsx(s,{children:"convex/parts/lib.ts::withLedger(ctx, callback)"})," : receiveStock/adjustStock/transferStock (et le futur syncConsumption, CHARGEOPS-36) l'appellent avec un callback qui reçoit un ",e.jsx(s,{children:"post(m)"})," lié au mouvement en cours ; ",e.jsx(s,{children:"withLedger"})," réévalue ensuite l'alerte de stock bas une seule fois (voir plus bas), après que le callback a terminé. Aucune autre fonction de"," ",e.jsx(s,{children:"convex/"})," n'écrit ces tables — vérifié par un test statique (",e.jsx(s,{children:"soleWriter.test.ts"}),", qui scanne tout"," ",e.jsx(s,{children:"convex/"}),", pas seulement ",e.jsx(s,{children:"convex/parts/"}),") qui recherche un accès direct par nom de table ou par variable nommée d'après un solde/mouvement — ET vérifie que ",e.jsx(s,{children:"postMovementUnchecked"})," n'est jamais référencée ailleurs que dans le corps de ",e.jsx(s,{children:"withLedger"}),", pour qu'un appel direct qui contournerait l'alerte de stock bas ne puisse pas être ajouté sans faire échouer le test."]}),e.jsxs(r,{children:["Une seule exception homologuée, elle aussi allowlistée dans"," ",e.jsx(s,{children:"soleWriter.test.ts"})," :"," ",e.jsx(s,{children:"convex/parts/reconcile.ts::reconcileTenantStock"})," (CHARGEOPS-238)."," ",e.jsx(s,{children:"upsertRecords"})," (restauration de sauvegarde) ne supprime jamais de ligne — après une restauration, les tables des pièces sont un mélange de l'instantané et de tout ce qui a été écrit depuis, et ",e.jsx(s,{children:"postMovementUnchecked"})," n'applique qu'un DELTA : rien ne recalcule le total à partir de zéro. ",e.jsx(s,{children:"reconcileTenantStock"})," recalcule donc chaque ",e.jsx(s,{children:"partStockLevels.onHand"})," et ",e.jsx(s,{children:"parts.totalOnHand"})," à partir du grand livre — jamais l'inverse — et n'écrit JAMAIS"," ",e.jsx(s,{children:"partStockMovements"}),". Planifiée une fois par locataire restauré par"," ",e.jsx(s,{children:"restoreDatabase"})," (convex/backups/actions.ts),"," ",e.jsx(n,{children:"strictement APRÈS sa passe de reliaison des références"})," — jamais avant : un document recréé (une purge, ou une restauration vers un autre déploiement) obtient un nouvel identifiant, et tant que la passe de reliaison n'a pas réécrit le"," ",e.jsx(s,{children:"partId"})," du grand livre vers ce nouvel id, ces lignes pointent encore vers l'ancienne pièce, disparue. Planifier plus tôt faisait lire au reconcile un"," ",e.jsx(s,{children:"partId"})," périmé, ne voir aucun mouvement, et mettre à zéro une pièce qui avait pourtant conservé tout son stock."]}),e.jsxs(r,{children:["Bornée par hop via un ",e.jsx(n,{children:"budget de lecture"}),", pas un plafond fixe par pièce : chaque hop dépense jusqu'à 4 000 lectures de mouvements réparties sur autant de pièces que nécessaire, et une pièce dont le grand livre dépasse le budget d'un seul hop est lue sur PLUSIEURS hops (ses sommes partielles par emplacement transportées dans les arguments du planificateur) et n'est ÉCRITE — soldes, ",e.jsx(s,{children:"totalOnHand"}),", loquet — que dans le hop qui lit sa DERNIÈRE page, dans cette même transaction. Une borne secondaire (200 pièces DÉMARRÉES au maximum par hop) plafonne le volume d'écritures d'un hop même pour un locataire à des milliers de pièces presque vides, indépendamment du budget de mouvements. Convex n'autorise qu'UN SEUL appel ",e.jsx(s,{children:".paginate()"})," par exécution de fonction, donc la boucle des pièces et la lecture des mouvements par pièce utilisent ",e.jsx(s,{children:".take()"})," avec un point de reprise scalaire simple à la place : le"," ",e.jsx(s,{children:"_creationTime"})," de la dernière pièce (unique, sans égalité possible) et, par pièce, le ",e.jsx(s,{children:"createdAt"})," du dernier mouvement (un champ métier ordinaire —"," ",e.jsx(n,{children:"pas"})," garanti unique). Risque résiduel accepté : si une coupure de budget tombe exactement sur un groupe de mouvements partageant un même ",e.jsx(s,{children:"createdAt"}),", seule une partie de ce groupe peut avoir été lue avant la coupure, et reprendre après ce timestamp saute le reste — il faut soit un ",e.jsx(s,{children:"createdAt"})," antidaté, soit plusieurs mouvements dans la même milliseconde pile à une frontière de budget sur une pièce assez grosse pour s'étaler sur plusieurs hops ; une exécution ultérieure du reconcile corrige la situation. Une ligne ",e.jsx(s,{children:"partStockLevels"})," DUPLIQUÉE pour le même couple (pièce, emplacement) — une invariante qu'une restauration ratée pourrait quand même produire — est détectée, journalisée, et laissée INTACTE plutôt qu'écrite au total complet sur les deux lignes (ce qui compterait deux fois à la lecture)."]}),e.jsxs(r,{children:["Le loquet de stock bas est re-dérivé sans état (fixé si le total repasse sous le minimum, effacé sinon) mais ",e.jsx(n,{children:"aucune alerte n'est jamais planifiée"})," : une restauration ne doit jamais sonner une cloche pour un état qui, du point de vue du locataire, était déjà vrai avant la sauvegarde. Note pour le support : fixer ce loquet ici, en silence, veut dire que le PROCHAIN franchissement réel de stock bas pour cette pièce restera 
418silencieux tant que le stock ne sera pas d'abord remonté au-dessus de"," ",e.jsx(s,{children:"minStock"})," puis redescendu — une restauration peut donc supprimer une future alerte pour une pièce déjà basse au moment de la sauvegarde."]}),e.jsxs(r,{children:["Interne uniquement, jamais publique — il n'existe volontairement aucune mutation d'administration plateforme pour la déclencher (une fonction publique nommant"," ",e.jsx(s,{children:'v.id("tenants")'})," dans ses arguments hors ",e.jsx(s,{children:"convex/platform/**"}),"/",e.jsx(s,{children:"convex/backups/**"})," est exactement le signal que le crochet"," ",e.jsx(s,{children:"check-tenant-wrappers"})," existe pour attraper). Un opérateur plateforme la relance à la main avec"," ",e.jsx(s,{children:`npx convex run parts/reconcile:reconcileTenantStock '{"tenantId":"..."}'`})," ","(ajouter ",e.jsx(s,{children:"--prod"})," ou ",e.jsx(s,{children:"--deployment <nom>"})," au besoin) — sans risque de le relancer à tout moment, puisque recalculer une valeur déjà correcte ne fait rien."]}),e.jsxs(d,{children:[e.jsxs(i,{children:["Coût moyen mobile sur réception :"," ",e.jsx(s,{children:"(totalOnHand×moyenne + qté×coût) / (totalOnHand+qté)"}),". Pondéré par le"," ",e.jsx(n,{children:"total de la pièce"}),", jamais par le solde du seul emplacement qui reçoit — sinon une réception dans un nouvel emplacement vide réinitialiserait la moyenne au lieu de s'y ajouter (100 @ 10 $ en entrepôt puis 100 @ 20 $ dans un véhicule vide doit donner 15 $, pas 20 $). Un total ≤ 0 avant la réception est ramené à 0 pour la pondération ; l'absence de coût laisse la moyenne inchangée. Le résultat est arrondi au cent."]}),e.jsxs(i,{children:["Règle du solde négatif, par ",e.jsx(n,{children:"signe"}),", pas seulement par type :"," ",e.jsx(s,{children:"transfer"})," et ",e.jsx(s,{children:"return"})," sont refusés seulement si le mouvement est un RETRAIT (",e.jsx(s,{children:"quantity < 0"}),") qui viderait un emplacement sous zéro — les deux jambes d'un transfert partagent le même type ",e.jsx(s,{children:"transfer"}),", donc le dépôt à la destination (quantité positive) réussit toujours, même dans un emplacement déjà négatif. ",e.jsx(s,{children:"adjustment"})," et ",e.jsx(s,{children:"consumption"})," peuvent aller sous zéro sans condition ; ",e.jsx(s,{children:"receipt"})," exige une quantité strictement positive et n'est donc jamais concerné."]}),e.jsxs(i,{children:["Un transfert poste deux mouvements (",e.jsx(s,{children:"transferGroupId"})," commun) dans une seule mutation Convex — la source est toujours postée en premier, donc un refus n'écrit ni l'une ni l'autre jambe."]}),e.jsxs(i,{children:[e.jsx(s,{children:"receiveStock"}),"/",e.jsx(s,{children:"adjustStock"}),"/",e.jsx(s,{children:"transferStock"})," refusent une pièce archivée (",e.jsx(s,{children:"deletedAt"})," ou ",e.jsx(s,{children:"isActive: false"}),") et un emplacement inactif ; ",e.jsx(s,{children:"upsertLocation"})," refuse de désactiver un emplacement dont un solde par pièce est encore non nul. ",e.jsx(s,{children:"archivePart"})," refuse tant qu'un SEUL emplacement a un solde non nul (une somme à zéro ne suffit pas : +5 ici et -5 ailleurs n'est pas réconcilié)."]}),e.jsxs(i,{children:["Coût strippé sans ",e.jsx(s,{children:"viewFinancials"})," : ",e.jsx(s,{children:"avgUnitCost"})," sur la pièce,"," ",e.jsx(s,{children:"unitCost"})," sur chaque mouvement, et l'entrée de coût de"," ",e.jsx(s,{children:"receiveStock"})," côté client est masquée (le serveur la refuse de toute façon)."]}),e.jsxs(i,{children:["Les deux tables portent un index ",e.jsx(s,{children:"by_tenant"})," simple (en plus de leurs index composés) — c'est celui que lit l'export de sauvegarde par locataire (",e.jsx(s,{children:"convex/backups/internalQueries.ts::getTableChunkForTenant"}),")."]})]}),e.jsx(c,{rows:[{key:e.jsx(n,{children:"Recevoir"}),value:e.jsx(s,{children:"api.parts.mutations.receiveStock"})},{key:e.jsx(n,{children:"Ajuster"}),value:e.jsx(s,{children:"api.parts.mutations.adjustStock"})},{key:e.jsx(n,{children:"Transférer"}),value:e.jsx(s,{children:"api.parts.mutations.transferStock"})},{key:e.jsx(n,{children:"Détail d'une pièce (soldes + historique paginé)"}),value:e.jsx(s,{children:"api.parts.queries.getPart"})}]})]}),e.jsxs(o,{id:"pieces-kpis-tech",title:"Indicateurs d'inventaire et pièce attendue — implémentation (CHARGEOPS-38)",children:[e.jsxs(r,{children:["Deux fonctions de lecture pure dans ",e.jsx(s,{children:"convex/parts/lib.ts"}),", appelées par"," ",e.jsx(s,{children:"convex/statistics/inventoryKpis.ts::getInventoryKpis"})," — sa PROPRE requête, sa propre souscription, sous la même porte"," ",e.jsx(s,{children:'requirePermission("viewFinancials")'})," exécutée avant toute lecture. Volontairement ",e.jsx(n,{children:"séparée"})," de ",e.jsx(s,{children:"getFinancialKpis"})," (garanties + plans) : chaque ",e.jsx(s,{children:"postMovement"})," patch ",e.jsx(s,{children:"parts.totalOnHand"}),", donc garder ces deux chiffres dans la requête combinée aurait invalidé les tuiles de garanties et de plans à chaque mouvement de stock du locataire, et faisait grimper son budget de lecture d'environ 18,5k à environ 25,5k documents — proche de la limite par requête.",e.jsx(s,{children:"getInventoryKpis"})," ne prend ",e.jsx(n,{children:"aucun"})," argument de portée (site/organisation) : les deux figures sont volontairement pour tout le locataire."]}),e.jsxs(d,{children:[e.jsxs(i,{children:[e.jsx(n,{children:"Valeur de l'inventaire"})," (",e.jsx(s,{children:"computeInventoryValue"}),") : instantané, pas une fenêtre — Σ de ",e.jsx(s,{children:"max(totalOnHand, 0) × avgUnitCost"})," sur les pièces actives (",e.jsx(s,{children:"deletedAt"})," exclu), lu par l'index simple"," ",e.jsx(s,{children:"by_tenant"})," sur ",e.jsx(s,{children:"parts"}),", jamais par un scan du grand livre. Un solde négatif est ramené à 0 pour cette somme (c'est une anomalie à réconcilier, pas une dette qui doit réduire la valeur du reste du catalogue) ; le catalogue continue de la signaler « négative » séparément."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Coût des pièces consommées"})," (",e.jsx(s,{children:"computeConsumptionCost"}),") —",e.jsx(n,{children:" inclut la consommation sous garantie prolongée"})," (voir plus bas) : Σ de ",e.jsx(s,{children:"-quantité × unitCost"})," sur les mouvements de type ",e.jsx(s,{children:"consumption"})," ","dans ",e.jsx(s,{children:"[from, to]"})," (bornes incluses), lu par le nouvel index"," ",e.jsx(s,{children:"by_tenant_type_createdAt"})," sur ",e.jsx(s,{children:"partStockMovements"}),", en ordre"," ",e.jsx(n,{children:"décroissant"})," (",e.jsx(s,{children:'.order("desc")'}),") — une lecture croissante aurait gardé les lignes les plus ANCIENNES sous le plafond au lieu des plus récentes, l'exact opposé de ce qu'une fenêtre récente doit montrer, tout en annonçant quand même une troncature « les plus récents » qui aurait été fausse. Une annulation (une ligne"," ",e.jsx(s,{children:"consumption"})," à quantité positive, postée par le correcteur de la tranche 4) 
418se soustrait donc automatiquement du total — le signe fait le travail de compensation, pas une valeur absolue. Un mouvement sans ",e.jsx(s,{children:"unitCost"})," est ignoré (contribue 0) plutôt que reconstitué au coût moyen ",e.jsx(n,{children:"actuel"})," de la pièce, pour qu'une réception ultérieure ne fasse jamais bouger le total d'une fenêtre déjà passée."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Fenêtre"})," : ",e.jsx(s,{children:"consumptionWindowDays"}),", propre à ce seul indicateur (les garanties et les plans restent des chiffres de durée de vie, sans fenêtre) — 30 jours par défaut, bornée à ",e.jsx(s,{children:"[1, 3650]"}),". Toute valeur qui n'est pas un nombre fini (",e.jsx(s,{children:"NaN"}),", ",e.jsx(s,{children:"Infinity"}),", absente) retombe sur le défaut plutôt que de produire une fenêtre négative ou sans borne."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Troncature séparée."})," ",e.jsx(s,{children:"valueTruncated"})," et"," ",e.jsx(s,{children:"consumptionTruncated"})," sont deux champs distincts, jamais fusionnés en un seul — ce sont deux lectures bornées indépendantes, et le bandeau ne doit jamais afficher un indice de troncature qu'une seule des deux figures a réellement mérité."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Rapport avec le coût des garanties prolongées."})," ",e.jsx(s,{children:"warranties.parts"})," (dans ",e.jsx(s,{children:"getFinancialKpis"}),", inchangé) reste calculé à partir de l'instantané ",e.jsx(s,{children:"unitCost"})," stocké sur chaque ligne d'une intervention — jamais de ",e.jsx(s,{children:"avgUnitCost"})," du catalogue. Une intervention qui consomme une pièce du catalogue peut donc apparaître dans LES DEUX indicateurs (le coût de garantie ET le coût de consommation, qui l'inclut) : ce sont deux questions différentes — « combien la garantie a coûté » contre « combien le rayon a perdu » — jamais fusionnées ni nettées l'une contre l'autre."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Absence, pas zéro."})," Quand le module Entrepôt est désactivé,"," ",e.jsx(s,{children:"getInventoryKpis"})," répond ",e.jsx(s,{children:"null"})," (jamais une erreur, jamais des zéros) — distinct de ",e.jsx(s,{children:"undefined"}),", l'état Convex « pas encore résolu », ce qui permet au tableau de bord et à la rangée d'accueil de distinguer « rien à montrer » de « en chargement »."]})]}),e.jsxs(r,{children:[e.jsx(s,{children:"supportTickets.waitingPartId"})," (",e.jsx(s,{children:'v.optional(v.id("parts"))'}),") : lien « En attente de » sur un cas. Réglé/retiré par"," ",e.jsx(s,{children:"convex/tickets/mutations.ts::setWaitingPart"}),", derrière la même permission"," ",e.jsx(s,{children:"editTicket"})," que toute autre modification du cas (",e.jsx(s,{children:"loadTicketWithPermission"}),"). Refuse une pièce d'un autre locataire (la lecture RLS résout déjà l'id à ",e.jsx(s,{children:"null"}),") et une pièce archivée."]}),e.jsxs(d,{children:[e.jsxs(i,{children:[e.jsx(n,{children:"Règle de statut :"})," réglable uniquement pendant que le cas est"," ",e.jsx(s,{children:"waiting_parts"})," ; ",e.jsx(s,{children:"applyStatusChange"})," le retire automatiquement dès que le cas quitte ce statut (dans un sens ou dans l'autre), pour qu'un cas reparqué plus tard reparte sans la pièce de la fois précédente. Aucune ligne de chronologie n'est écrite pour ce changement — un indice léger, pas un fait du dossier."]}),e.jsxs(i,{children:["La lecture du cas (",e.jsx(s,{children:"tickets/queries.ts::getById"}),") projette seulement le nom, le SKU et le ",e.jsx(s,{children:"totalOnHand"})," courant de la pièce — jamais de coût — à quiconque peut voir le cas. Elle est ",e.jsx(n,{children:"toujours"})," ",e.jsx(s,{children:"null"})," pour un"," ",e.jsx(s,{children:"site-operator"})," même quand le champ est réglé : le Portail opérateur monte cette même requête, et l'inventaire est un concept du centre de service, pas du client."]})]})]}),e.jsxs(o,{id:"pieces-stock-bas-tech",title:"Alerte de stock bas — implémentation (CHARGEOPS-37)",children:[e.jsxs(r,{children:["Le loquet vit sur ",e.jsx(s,{children:"parts.lowStockNotifiedAt"}),". Il est posé le mouvement où le stock TOTAL d'une pièce (somme de ",e.jsx(s,{children:"partStockLevels"})," sur tous ses emplacements) passe d'un niveau ",e.jsx(n,{children:"au moins égal"})," au minimum à un niveau"," ",e.jsx(n,{children:"strictement inférieur"})," — « égal au minimum » n'est jamais bas — et il est retiré au retour à un niveau au moins égal au minimum."]}),e.jsxs(d,{children:[e.jsxs(i,{children:[e.jsx(s,{children:"convex/parts/lib.ts::withLedger"})," évalue le loquet UNE SEULE fois par mutation, après que tous les appels ",e.jsx(s,{children:"post(m)"})," de son callback ont été appliqués — jamais à chaque mouvement individuel. Un transfert poste deux mouvements sur la MÊME pièce dans une seule mutation ; évaluer le loquet à chaque mouvement faisait déclencher une fausse alerte sur la jambe sortante, aussitôt annulée par la jambe entrante — mais l'alerte était déjà partie. ",e.jsx(s,{children:"postMovementUnchecked"})," ","se contente d'enregistrer le ",e.jsx(s,{children:"totalOnHand"})," d'avant-mutation de chaque pièce touchée (premier contact gagne) ; ",e.jsx(s,{children:"finalizeLowStockLatches"}),", appelée une seule fois par ",e.jsx(s,{children:"withLedger"}),", compare ce solde de départ au solde RÉEL de fin de mutation."]}),e.jsxs(i,{children:[e.jsx(s,{children:"convex/parts/lib.ts::reconcileLowStockLatch"})," porte la logique de franchissement, partagée par DEUX appelants : ",e.jsx(s,{children:"finalizeLowStockLatches"})," (le stock bouge, le minimum reste fixe) et ",e.jsx(s,{children:"updatePart"})," (le minimum change, le stock reste fixe). Décision documentée dans le code : modifier le seuil minimum de façon à faire franchir le stock actuel ",e.jsx(n,{children:"déclenche l'alerte"}),", exactement comme un mouvement — pas seulement « armer » le loquet en silence — sinon le service n'apprendrait jamais qu'une pièce est passée sous son minimum tant qu'un mouvement de stock non lié ne survient pas."]}),e.jsxs(i,{children:["Une pièce archivée (",e.jsx(s,{children:"deletedAt"})," défini) n'alerte jamais — vérifié directement dans ",e.jsx(s,{children:"reconcileLowStockLatch"}),", pas seulement en amont."]}),e.jsxs(i,{children:["Résolution des destinataires : ",e.jsx(s,{children:"convex/parts/notifications.ts::notifyLowStock"})," ","(planifiée par ",e.jsx(s,{children:"ctx.scheduler.runAfter"}),", hors de la transaction du mouvement) balaie ",e.jsx(s,{children:"users.by_tenant"})," (même plafond que"," ",e.jsx(s,{children:"convex/compliance/lib.ts::getComplianceRecipients"}),", dépassement journalisé) puis filtre par permission EFFECTIVE (",e.jsx(s,{children:"resolveEffectivePermissions"})," contre les surcharges"," ",e.jsx(s,{children:"rolePermissions"})," du locataire, même appel que"," ",e.jsx(s,{children:"convex/maintenance/visits.ts::checkAssigneeEligibility"}),") — délibérément PAS"," ",e.jsx(s,{children:"convex/tickets/helpers.ts::loadDeskCandidates"}),", dont le « superset » exclut structurellement les rôles ",e.jsx(s,{children:"finance"})," et ",e.jsx(s,{children:"user"}),", qui détiennent pourtant ",e.jsx(s,{children:"viewInventory"}),"."]}),e.jsxs(i,{children:["Trois canaux, un seul garde-fou : ",e.jsx(s,{children:"notifyLowStock"})," écrit la cloche personnelle (audience ",e.jsx(s,{children:"inventory"}),") ET appelle"," ",e.jsx(s,{children:"emitDomainEvent"})," — qui planifie à la fois le courriel vers la"," ",e.jsx(n,{children:"liste de diffusion"})," du locataire (Configuration → Notifications courriel) et tout ",e.jsx(n,{children:"webhook"})," abonné à ",e.jsx(s,{children:"part_low_stock"})," — APRÈS sa propre revérification du franchissement, jamais avant. Les trois canaux partagent donc exactement le même sort : soit ils partent tous, soit aucun ne part (par exemple si le stock est redescendu sous le minimum puis remonté avant l'exécution de la tâche planifiée)."]})]}),e.jsxs(p,{tone:"info",children:["L'événement ",e.jsx(s,{children:"part_low_stock"})," (groupe ",e.jsx(s,{children:"inventory"}),", shared/notification-policy.ts) n'offre que la cloche par défaut dans la matrice personnelle — aucun modèle de courriel personnalisé n'existe pour un destinataire individuel, même raisonnement que ",e.jsx(s,{children:"maintenance_visit_assigned"}),". La liste de diffusion du locataire et les webhooks restent le vrai canal courriel de cet événement."]}),e.jsxs(r,{children:[e.jsx(s,{children:"api.parts.queries.listToReorder"}
418)," (onglet « À réapprovisionner ») filtre les pièces actives avec un minimum dont le stock total est sous ce minimum, quantité suggérée = ",e.jsx(s,{children:"reorderQuantity"})," si définie, sinon ",e.jsx(s,{children:"minStock − totalOnHand"}),". Même plafond de lecture que ",e.jsx(s,{children:"listParts"})," (",e.jsx(s,{children:"truncated"}),"), même discipline de retrait du coût sans ",e.jsx(s,{children:"viewFinancials"}),"."]})]}),e.jsxs(o,{id:"pieces-fournisseurs-tech",title:"Répertoire des fournisseurs — implémentation (CHARGEOPS-430)",children:[e.jsxs(r,{children:["Slice 1 de l'épique bons de commande (CHARGEOPS-429) — voir"," ",e.jsx(s,{children:"docs/superpowers/specs/2026-09-26-purchase-orders-design.md"})," et le plan"," ",e.jsx(s,{children:"docs/superpowers/plans/2026-09-26-purchase-orders.md"}),"."]}),e.jsxs(d,{children:[e.jsxs(i,{children:["Table ",e.jsx(s,{children:"suppliers"})," (",e.jsx(s,{children:"TENANT_TABLES"}),", ",e.jsx(s,{children:"BACKUP_TABLES"}),") :"," ",e.jsx(s,{children:"name"}),", ",e.jsx(s,{children:"nameKey"}),", ",e.jsx(s,{children:"contactName"}),", ",e.jsx(s,{children:"email"}),","," ",e.jsx(s,{children:"phone"}),", ",e.jsx(s,{children:"accountNumber"}),", ",e.jsx(s,{children:"language"})," (",e.jsx(s,{children:"fr"})," | ",e.jsx(s,{children:"en"}),"), ",e.jsx(s,{children:"leadTimeDays"})," (entier 0–365),"," ",e.jsx(s,{children:"notes"}),", ",e.jsx(s,{children:"isActive"}),". Index ",e.jsx(s,{children:"by_tenant"})," et"," ",e.jsx(s,{children:"by_tenant_nameKey"}),". Jamais supprimée : ",e.jsx(s,{children:"isActive: false"})," la retire des listes de choix."]}),e.jsxs(i,{children:[e.jsx(s,{children:"nameKey"})," = ",e.jsx(s,{children:"shared/purchasing.ts::supplierNameKey"})," (rogné, espaces multiples réduits à un, minuscules). Unicité par tenant sur les fournisseurs actifs ET inactifs, par une lecture d'égalité ",e.jsx(s,{children:".take(5)"})," sur"," ",e.jsx(s,{children:"by_tenant_nameKey"})," (même raison que ",e.jsx(s,{children:"assertSkuAvailable"}),")."]}),e.jsxs(i,{children:[e.jsx(s,{children:"parts.supplierId"})," optionnel, à côté de ",e.jsx(s,{children:"supplierName"})," qui reste le repli de lecture et n'est jamais effacé par un lien."," ",e.jsx(s,{children:"createPart"}),"/",e.jsx(s,{children:"updatePart"})," acceptent ",e.jsx(s,{children:"supplierId"})," ","(",e.jsx(s,{children:"null"})," retire le lien) ; un lien nouveau ou changé doit viser un fournisseur actif du tenant (",e.jsx(s,{children:"assertLinkableSupplier"}),"), un lien inchangé vers un fournisseur désactivé depuis est c
418onservé."]}),e.jsxs(i,{children:[e.jsx(s,{children:"listParts"}),", ",e.jsx(s,{children:"getPart"})," et ",e.jsx(s,{children:"listToReorder"})," renvoient"," ",e.jsx(s,{children:"supplierLabel"})," = nom du fournisseur lié, sinon ",e.jsx(s,{children:"supplierName"})," ","(",e.jsx(s,{children:"loadSupplierNames"})," : un ",e.jsx(s,{children:"get"})," par fournisseur distinct de la page, jamais un par pièce)."]})]}),e.jsx(h,{headers:["Fonction (convex/parts/suppliers.ts)","Permission","Comportement"],rows:[[e.jsx(s,{children:"listSuppliers"}),"viewInventory","by_tenant .take(501) → { suppliers, truncated }, trié par nom ; activeOnly pour les listes de choix."],[e.jsx(s,{children:"upsertSupplier"}),"manageInventory","Formulaire complet ; un champ optionnel vide efface (patch à undefined, jamais replace). Nom unique, courriel vérifié, audité."],[e.jsx(s,{children:"setSupplierActive"}),"manageInventory","Idempotent, audité (ARCHIVE / RESTORE)."],[e.jsx(s,{children:"suggestFromPartNames"}),"manageInventory","Lit parts.by_tenant_supplierId_supplierName avec supplierId = undefined et supplierName > startAfterName : seulement les pièces non liées qui portent un nom, par ordre de nom. Groupe par nameKey. Pages de 2000 lignes ; nextAfterName = dernier nom compté en entier, à repasser comme startAfterName."],[e.jsx(s,{children:"createFromNames"}),"manageInventory","≤ 50 noms. Réutilise le fournisseur ACTIF de chaque clé ou en crée un ; une clé tenue par un fournisseur inactif ne crée ni ne lie rien et revient dans needsReactivation. Lie ensuite une page (200 pièces, .paginate) de pièces non liées au nom correspondant. Le client boucle sur continueCursor. Idempotent."]]}),e.jsxs(p,{tone:"info",title:"Ordre des vérifications",children:["Chaque fonction passe par ",e.jsx(s,{children:"moduleQuery"}),"/",e.jsx(s,{children:'moduleMutation("warehouse")'})," ","et appelle ",e.jsx(s,{children:"requirePermission"})," avant toute lecture ; les ids passent par le"," ",e.jsx(s,{children:"ctx.db"})," soumis aux règles du tenant, donc un fournisseur d'un autre tenant se lit comme absent. Tests : ",e.jsx(s,{children:"convex/parts/__tests__/suppliers.test.ts"}),"."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Import CSV"})," (",e.jsx(s,{children:"parts/import.ts"}),", phase 1) : quand la ligne porte un fournisseur, ",e.jsx(s,{children:"resolveImportSupplier"})," cherche sa clé sur"," ",e.jsx(s,{children:"by_tenant_nameKey"})," (cache par appel). Fournisseur actif → ",e.jsx(s,{children:"supplierId"})," ","posé (remplace l'ancien lien). Fournisseur inactif → pas de nouveau lien, avertissement ; une pièce déjà liée à ce même fournisseur le garde. Aucun fournisseur → pas de lien ; un lien existant est retiré, avec un avertissement, et ",e.jsx(s,{children:"supplierName"})," est conservé. Une cellule vide ne touche à aucun des deux champs ; un texte vidé retire aussi le lien. L'avertissement n'est ajouté qu'une fois la ligne validée en entier."]})]}),e.jsxs(o,{id:"pieces-reservations-tech",title:"Réservations de pièces — implémentation (CHARGEOPS-342)",children:[e.jsxs(r,{children:["Épique CHARGEOPS-340, tranche 2 (spec"," ",e.jsx(s,{children:"docs/superpowers/specs/2026-09-25-parts-reservations-design.md"}),"). Une réservation est une ",e.jsx(n,{children:"allocation souple"}),", jamais un mouvement de stock :"," ",e.jsx(s,{children:"convex/parts/reservations.ts"})," n'appelle pas ",e.jsx(s,{children:"withLedger"}),", n'écrit ni ",e.jsx(s,{children:"partStockLevels"})," ni ",e.jsx(s,{children:"partStockMovements"})," ni"," ",e.jsx(s,{children:"parts.totalOnHand"}),", et ne touche pas le verrou de stock bas ;"," ",e.jsx(s,{children:"soleWriter.test.ts"})," reste vert sans nouvelle exception. Seule exception, ajoutée par la tranche 5 : ",e.jsx(s,{children:"loadOnVan"}),", qui poste un vrai transfert par"," ",e.jsx(s,{children:"withLedger"})," (voir « chargement dans le camion » plus bas)."]}),e.jsx(c,{rows:[{key:e.jsx(n,{children:"Table partReservations"}),value:"Une ligne par visite × pièce × emplacement : partId, locationId, quantity (> 0, entière pour une pièce « ea »), visitKind + maintenanceVisitId / correctiveVisitId optionnels typés (précédent defectFollowUps), siteId dénormalisé, status open | consumed | released, releaseReason, champs de chargement (tranche 5), createdBy/closedBy. Index by_tenant, by_tenant_status, by_tenant_part_status, by_tenant_location_status, by_tenant_maintenanceVisit_status, by_tenant_correctiveVisit_status (le statut en dernier : une lecture préfixe liste toute la visite, la fusion ne lit que ses lignes ouvertes). Dans TENANT_TABLES et BACKUP_TABLES ; des enregistrements, pas un cache — rien pour reconcileTenantStock."},{key:e.jsx(n,{children:"listForVisit"}),value:"viewInventory. Les lignes de la visite (ouvertes d'abord) : SKU, nom, emplacement, loaded (depuis la tranche 5 : l'emplacement de la ligne est de type vehicle), onAssigneeVan et le disponible actuel à cet emplacement, plus assigneeVan { locationId, name } — seulement quand l'assigné a exactement un camion actif, sinon null. Aucun coût."},{key:e.jsx(n,{children:"suggestForVisit"}),value:"viewInventory. Modèles des bornes de l'intervention corrective, sinon des bornes vivantes du site (by_tenant_siteId_deletedAt, .take(201)) ; pièces actives du catalogue (by_tenant_sku, .take(501)) dont compatibleModelIds les recoupe, avec stock, réservé, disponible, alreadyReserved et bestLocationId (l'emplacement actif le plus disponible parmi les entrepôts et le camion de l'assigné — jamais le camion d'un autre technicien). Jamais réservé tout seul."},{key:e.jsx(n,{children:"locationsForPart"}),value:"viewInventory. Stock / réservé / disponible d'une pièce à chaque emplacement actif, le plus disponible d'abord — la source par défaut de la fenêtre."},{key:e.jsx(n,{children:"reserve"}),value:"reserveParts. ≤ 30 lignes ; visite planifiée seulement ; assertActivePart / assertActiveLocation ; les lignes d'une même pièce × emplacement s'additionnent et fusionnent dans la ligne ouverte de la visite ; ≤ 200 lignes ouvertes par visite. Renvoie { ids, warnings } — overReserved { partId, locationId, available } quand le disponible devient négatif, jamais une erreur. Audité (RESERVE)."},{key:e.jsx(n,{children:"updateQuantity / release"}),value:"reserveParts, ligne ouverte seulement (« Cette réservation n'est plus ouverte. ») ; updateQuantity exige une visite planifiée, release fonctionne quel que soit le statut de la visite (une promesse ne reste jamais coincée). Release pose released / manual, closedAt, closedBy. Audités (UPDATE, RELEASE)."}]}),e.jsxs(r,{children:["Murs, dans l'ordre : le module ",e.jsx(s,{children:"warehouse"})," (la fabrique), la clé de permission avant toute lecture, le module propre de la visite (",e.jsx(s,{children:"maintenanceCenter"})," / ",e.jsx(s,{children:"supportCases"}),"), puis"," ",e.jsx(s,{children:"assertSiteInScope"})," sur le site de la visite — une visite hors portée ou d'un autre tenant répond « Visite introuvable. ». En écriture, un technicien doit être l'assigné de la visite (sur ",e.jsx(s,{children:"ctx.user.role"}),", comme la carte Interventions) ; la lecture suit la visibilité de la visite. Le ",e.jsx(n,{children:"disponible"})," = solde de l'emplacement − réservations ouvertes de la pièce, sommées par"," ",e.jsx(s,{children:"loadOpenReservedForPart"})," (",e.jsx(s,{children:"by_tenant_part_status"}),", une pièce à la fois, jamais un balayage de toutes les réservations ouvertes du tenant — qui ferait entrer chaque réservation en conflit avec toutes les autres)"," ",e.jsx(n,{children:"à l'échelle du tenant"}),", quelle que soit la portée d'organisation de l'appelant. Gardes ajoutées :"," ",e.jsx(s,{children:"archivePart"})," (index ",e.jsx(s,{children:"by_tenant_part_status"}),") et la désactivation dans ",e.jsx(s,{children:"upsertLocation"})," (",e.jsx(s,{children:"by_tenant_location_status"}),")."]}),e.jsxs(r,{children:["Client : ",e.jsx(s,{children:"components/parts/visit-parts-dialog.tsx"})," (titre Radix = h2, trois blocs ",e.jsx(s,{children:"co-card"})," en h3 ; lecture seule sans ",e.jsx(s,{children:"reserveParts"}),", pour une visite non planifiée ou pour un technicien sur la visite d'un autre — « Libérer » reste offert sur une ligne ouverte quel que soit le statut de la visite ; suggestions, catalogue et emplacements ne sont même pas interrogés dans ce cas) et"," ",e.jsx(s,{children:"components/parts/visit-parts-entry.tsx"})," — le bloc « Pièces » du tiroir de visite d'entretien et la puce « Pièces (n) » de la carte Interventions, montés seulement avec ",e.jsx(s,{children:"viewInventory"})," (déjà à faux quand le module Entrepôt est désactivé) et jamais dans le Portail. Tests : ",e.jsx(s,{children:"convex/parts/__tests__/reservations.test.ts"}),","," ",e.jsx(s,{children:"visit-parts-dialog.test.tsx"}),", ",e.jsx(s,{children:"visit-drawer.test.tsx"}),","," ",e.jsx(s,{children:"visits-card-parts-chip.test.tsx"}
418),". Hors de cette tranche : colonnes Réservé / Disponible (4, section suivante), chargement dans le camion (5)."]})]}),e.jsxs(o,{id:"pieces-reservations-cloture-tech",title:"Réservations de pièces — clôture avec la visite (CHARGEOPS-343)",children:[e.jsxs(r,{children:["Une réservation ne reste jamais ouverte pour une visite terminée. Quatre helpers de"," ",e.jsx(s,{children:"convex/parts/reservations.ts"}),", appelés par chaque chemin qui termine une visite (jamais par le client), ne font que ",e.jsx(n,{children:"fermer des lignes"})," : le stock sort toujours par le ",e.jsx(s,{children:"syncConsumption"})," du rapport, donc"," ",e.jsx(s,{children:"soleWriter.test.ts"})," reste vert. Ils s'exécutent quel que soit l'interrupteur"," ",e.jsx(s,{children:"warehouse"})," (un module coupé ne doit pas bloquer une promesse) et n'écrivent pas d'audit propre : l'audit COMPLETE / CANCEL de la visite couvre l'événement, chaque ligne porte closedAt, closedBy et releaseReason. Une ligne fermée ne se rouvre jamais : rouvrir le rapport, le modifier et le finaliser de nouveau ne change pas son consumedQuantity."]}),e.jsx(c,{rows:[{key:e.jsx(n,{children:"settleVisitReservations"}),value:"Visite terminée : la règle pure shared/parts-reservation.ts::matchReservationsToPartsUsed additionne les lignes catalogue (partId) du rapport par pièce, tous emplacements confondus, et parcourt les lignes ouvertes de la plus ancienne à la plus récente (createdAt, puis _id) : consumedQuantity = min(quantity, reste) → consumed ; plus rien → released / visit_completed_unused. Une ligne en texte libre ne consomme rien. Appelé dans maintenanceReports.finalize (visite liée et ad hoc, report.partsUsed) et dans tickets/dispatch.ts::completeVisit, qui reçoit désormais les partsUsed du rapport depuis les deux branches de completeVisitForReport (lien direct, règle ad hoc)."},{key:e.jsx(n,{children:"releaseVisitReservations"}),value:"Visite annulée → released / visit_cancelled : maintenance/visits.ts::cancelVisit et dispatch.ts::cancelVisitWithReason, par où passent toutes les annulations correctives (action cancel, case « annuler la visite » à la fermeture du cas, déplacement vers un site où l'assigné n'est plus admissible, suppression douce du cas, cascade de suppression du site). tickets/mutations.ts::hardDelete supprime les interventions du cas : leurs lignes deviennent released / orphaned dans la même transaction."},{key:e.jsx(n,{children:"restampVisitReservationsSite"}),value:"restampOpenVisitSite garde l'intervention sur le nouveau site du cas : le siteId dénormalisé de ses lignes OUVERTES suit (les lignes fermées gardent leur site historique)."},{key:e.jsx(n,{children:"openReservationLinesForVisit"}),value:"Pré-remplissage, correctif et entretien. Correctif : dispatch.ts::startReport (createServiceReportDraft) démarre le brouillon avec une ligne par réservation ouverte — nom de la pièce, partId, emplacement actuel, quantité, sans chargerId — la plus ancienne d'abord, au plus MAX_PARTS_USED (50), seulement avec warehouse actif, seulement pour un brouillon neuf (le retour idempotent ne touche à rien) et jamais quand le cas a déjà un brouillon (lien effacé par un déplacement ou une réaffectation — sinon les lignes seraient doublées). Un brouillon ne poste rien. Entretien (CHARGEOPS-499) : maintenance/visits.ts::startReportFromVisit remplit de même partsUsed d'un brouillon NEUF — des lignes sans chargerId, que l'assistant affiche dans la carte visible « Pièces de la visite » (hydrateReport renvoie visitPartsUsed) ; ignoré quand un brouillon du même site nomme déjà la visite (by_siteId_status draft avec visitId = la visite, 50 lus), et le second appel idempotent (la visite a déjà un reportId) ne remplit jamais. Les réservations restent ouvertes, aucun stock ne bouge."},{key:e.jsx(n,{children:"Sans effet"}),value:"maintenanceReports.reopen / deleteReport et dispatch.ts::unlinkVisitFromReport (la visite revient au plan) laissent les lignes fermées ; la fusion de cas déplace les interventions sans changer leur statut."},{key:e.jsx(n,{children:"parts/reservationSweep.ts"}),value:"Filet de sécurité, cron release-orphan-part-reservations à 5 h 30 UTC. run
418Daily liste les tenants actifs (.take(1000), avertissement au plafond) et planifie runForTenant pour chacun — pas seulement ceux avec warehouse, des lignes peuvent survivre à un module coupé. runForTenant lit by_tenant_status (tenantId, open) par pages de 100 (un seul paginate, auto-replanifié) sur la base brute et libère en orphaned (closedBy absent) une ligne sans id de visite, dont la visite manque, est d'un autre tenant, est completed / cancelled, ou dont le site ACTUEL de la visite manque ou est supprimé. Une visite en retard encore planifiée garde ses pièces : aucune expiration dans le temps."}]}),e.jsxs(r,{children:["Client : la fenêtre des pièces d'une visite affiche comment une ligne fermée s'est terminée — « Utilisée », « Utilisée : 1 sur 2 », « Libérée · non utilisée / visite annulée / visite disparue ». Tests : ",e.jsx(s,{children:"shared/__tests__/parts-reservation.test.ts"}),","," ",e.jsx(s,{children:"convex/parts/__tests__/reservationSettle.test.ts"})," (chaque chemin ci-dessus),"," ",e.jsx(s,{children:"convex/parts/__tests__/reservationSweep.test.ts"}),","," ",e.jsx(s,{children:"visit-parts-dialog.test.tsx"}),"."]})]}),e.jsxs(o,{id:"pieces-reserve-disponible-tech",title:"Réservations de pièces — réservé et disponible (CHARGEOPS-344)",children:[e.jsxs(r,{children:["Tranche 4 : la page Pièces affiche ",e.jsx(n,{children:"réservé"})," et"," ",e.jsx(n,{children:"disponible"})," (= en main − réservations ouvertes). Les totaux sont"," ",e.jsx(n,{children:"à l'échelle du tenant"})," quelle que soit la portée de l'appelant ; toute ligne qui nomme une visite ou un site est filtrée par portée côté serveur. Aucun coût : une réservation n'en porte pas."]}),e.jsx(c,{rows:[{key:e.jsx(n,{children:"loadOpenReservedTotals"}),value:"Une seule lecture by_tenant_status (open) .take(2001) sommée par pièce (OPEN_RESERVED_TOTALS_CAP, shared/parts-reservation.ts) — au lieu d'une lecture par pièce pour 500 pièces. Requêtes seulement : aucune mutation ne lit cette plage, les réservations n'entrent donc pas en conflit. Au-delà du plafond : reservedTruncated et un avis sur la carte."},{key:e.jsx(n,{children:"listParts"}),value:"Ajoute reserved, available, overPromised (réservé > 0 et disponible < 0) et reservedTruncated."},{key:e.jsx(n,{children:"getPart"}),value:"reserved / available au total et par emplacement (un emplacement promis sans ligne de niveau apparaît à 0), reservations (« Réservée pour », filtrée par portée) et reservationsTruncated calculé après le filtre — via openReservationsForPart (by_tenant_part_status)."},{key:e.jsx(n,{children:"listToReorder"}),value:"shared/purchasing.ts::reorderDemand(part, onHand, onOrder, available) — le disponible remplace le stock en main : listée si disponible + en commande < minStock, ou < 0 sans minimum (y compris un stock négatif sans réservation) ; suggéré = max(reorderQuantity ?? 0, (minStock ?? 0) − disponible − en commande). Renvoie onOrder, reserved, available, onOrderTruncated et reservedTruncated. Si la lecture du tenant est tronquée, reservedForListedParts re-somme chaque pièce listée (≤ 500) par by_tenant_part_status : une suggestion n'est jamais calculée sur une somme partielle (listParts aussi). getPart passe aussi available à reorderDemand pour suggestedOrderQty. L'alerte part_low_stock reste sur le stock physique."},{key:e.jsx(n,{children:"reservations.listOpen"}),value:"Onglet Réservées, viewInventory. Modèle readStatuses (warrantyClaims/queries.ts) : non restreint → OPEN_LIST_CAP (500) + 1 ; restreint → balaye SCOPED_OPEN_SCAN_BUDGET (1 500) lignes, filtre filterSitesByScope (organisation + site), truncated calculé APRÈS le filtre. Jointure pièce, emplacement, visite (scheduledDate), cas (ticketNumber), site, assigné (technicianDisplayName), canRelease (reserveParts, module de la visite actif — maintenanceCenter / supportCases —, un technicien sur ses visites seulement ; le client confirme avant de libérer). Tri par date de visite, sans date en dernier. Le retard est jugé dans le navigateur contre la date locale."},{key:e.jsx(n,{children:"Codes d'erreur"}),value:"shared/parts-reservation-errors.ts : visitNotFound, notFound, notOpen, notOwnVisit, et depuis la tranche 5 
418noAssignee, noVan, severalVans (avec technician), mixedVisits, insufficientStock (avec sku et location) — ConvexError({ code, message }) ; message garde le texte français. client/src/lib/parts-reservation-errors.ts traduit chaque code via t() (parts.reservedStock.errors.<nom>) pour l'onglet Réservées, la fenêtre de la visite et Ma journée."}]}),e.jsxs(r,{children:["Client : ",e.jsx(s,{children:"reserved-card.tsx"})," (onglet Réservées),"," ",e.jsx(s,{children:"parts-catalog-card.tsx"}),", ",e.jsx(s,{children:"to-reorder-card.tsx"}),","," ",e.jsx(s,{children:"part-drawer.tsx"}),", et l'indice « N réservée(s) » de"," ",e.jsx(s,{children:"part-picker.tsx"})," (prop ",e.jsx(s,{children:"reservedHint"}),", passée par les deux formulaires de rapport). Tests : ",e.jsx(s,{children:"convex/parts/__tests__/reservedStock.test.ts"}),","," ",e.jsx(s,{children:"shared/__tests__/parts-reservation.test.ts"}),","," ",e.jsx(s,{children:"reserved-card.test.tsx"}),", ",e.jsx(s,{children:"part-picker.test.tsx"})," et les tests du catalogue, d'À réapprovisionner et de la fiche."]})]}),e.jsxs(o,{id:"pieces-reservations-camion-tech",title:"Réservations de pièces — chargement dans le camion (CHARGEOPS-345)",children:[e.jsxs(r,{children:["Tranche 5 (plan ",e.jsx(s,{children:"docs/superpowers/plans/2026-09-26-parts-reservations-5-van.md"}),") : charger les pièces réservées dans le camion du technicien assigné. C'est la"," ",e.jsx(n,{children:"seule"})," action sur une réservation qui déplace du stock, et elle passe par le registre — jamais par une écriture directe des niveaux ;"," ",e.jsx(s,{children:"soleWriter.test.ts"})," reste inchangé et vert."]}),e.jsx(c,{rows:[{key:e.jsx(n,{children:"parts.reservations.loadOnVan"}),value:`moduleMutation("warehouse"), { ids } (dédoublonnés, au plus 200 — le plafond de lignes ouvertes d'une visite). reserveParts : admin, user, technician (un technicien seulement sur les visites qui lui sont assignées) ; jamais finance ni site-operator. Toutes les lignes d'UNE visite planifiée (sinon mixedVisits). Destination = l'unique emplacement vehicle ACTIF de l'assigné de la visite ; pas d'assigné → noAssignee, aucun camion → noVan, plusieurs → severalVans (les deux nomment le technicien). Renvoie { loaded, alreadyOnVan, vanName }.`},{key:e.jsx(n,{children:"Mouvements"}),value:`UN seul appel withLedger pour toutes les lignes qui ne sont pas déjà sur ce camion : par ligne, une jambe transfer sortante (emplacement actuel) et une entrante (camion) partageant un transferGroupId (un par ligne, crypto.randomUUID()), refType: "partReservation" (nouvelle valeur de partStockMovementRefTypeValidator), refId = l'id de la réservation. Puis la ligne pointe sur le camion : locationId, loadedAt, loadedBy, loadTransferGroupId. Audit LOAD (entityType partReservation, une ligne par appel, SKU et quantités).`},{key:e.jsx(n,{children:"Refus et annulation"}),value:"La règle « jamais sous zéro » du registre s'applique à la source : un manque annule TOUTES les lignes de l'appel et répond insufficientStock (SKU + emplacement source). assertActivePart / assertActiveLocation : une pièce archivée ou un emplacement inactif refusent le chargement, la ligne reste ouverte."},{key:e.jsx(n,{children:"Idempotence"}),value:"Une ligne déjà sur le camion ne déplace rien (un second appel ne poste rien). Une ligne n'est jamais coupée : un chargement partiel est un sous-ensemble de lignes. Après une réaffectation, recharger déplace la ligne d'un camion à l'autre."},{key:e.jsx(n,{children:"Totaux et argent"}),value:"parts.totalOnHand inchangé et aucune alerte de stock bas (le verrou est évalué une fois par appel withLedger, sur le total après mutation). Aucun coût sur les jambes de transfert. Le disponible reste juste : une ligne chargée est toujours réservée, désormais au camion."},{key:e.jsx(n,{children:"« Chargée »"}),value:"loaded = l'emplacement de la ligne est de type vehicle, dans listForVisit comme dans l'onglet Réservées : une pièce réservée directement dans le camion de l'assigné est déjà chargée, sans transfert. loadedAt / loadedBy / loadTransferGroupId gardent la trace du dernier chargement."},{key:e.jsx(n,{children:"Solde après chargement"}
418),value:"openReservationLinesForVisit pré-remplit le rapport à l'emplacement ACTUEL de la ligne (le camion) : la finalisation consomme donc dans le camion. Un brouillon démarré AVANT le chargement est repointé par loadOnVan (repointDraftPartsToVan + repointLinesToVan, pur) : par ligne déplacée, les lignes de la même pièce à son ancien emplacement, la plus ancienne d'abord, jusqu'à la quantité déplacée — une ligne plus grande est coupée, sans dépasser MAX_PARTS_USED (50). Brouillon seulement. Correctif : le serviceReportId de la visite, toutes les lignes. Entretien : le brouillon atteint par visit.reportId, non supprimé, et seulement les lignes de visite (sans chargerId) — une ligne de borne reste où le technicien l'a mise. settleVisitReservations rapproche par pièce, quel que soit l'emplacement."},{key:e.jsx(n,{children:"myDay.queries.getMyPartsToLoad"}),value:"tenantQuery simple. null hors du public de Ma journée ou sans viewInventory (à faux quand warehouse est coupé) ; un type de visite n'est lu que si son module est actif (maintenanceCenter / supportCases). Les visites PLANIFIÉES de l'appelant dans la fenêtre de Ma journée (readAssigneeVisitWindow), dans sa portée, avec au moins une réservation ouverte (parts/reservations.ts::partsToLoadForMyVisits) : lignes (SKU, nom, quantité, emplacement, onMyVan), count / onVanCount ; van { locationId, name } quand l'appelant a exactement un camion actif, sinon null + vanCount ; canLoad = reserveParts ; truncated. Aucun coût."}]}),e.jsxs(r,{children:["Client : ",e.jsx(s,{children:"components/parts/visit-parts-dialog.tsx"})," — bouton « Charger dans <camion> » dans le bloc des réservées quand l'appelant peut modifier, que"," ",e.jsx(s,{children:"assigneeVan"})," est connu et qu'une ligne ouverte n'y est pas ; il envoie toutes ces lignes en un appel. Trois états : Chargée (sur le camion de l'assigné), « Dans <camion> — pas le camion du technicien assigné », Pas chargée."," ",e.jsx(s,{children:"visit-parts-entry.tsx"})," compte les lignes ",e.jsx(s,{children:"onAssigneeVan"})," pour le résumé du tiroir. ",e.jsx(s,{children:"pages/my-day.tsx"})," : puce par carte de visite et carte « Pièces à charger » (son propre ",e.jsx(s,{children:"ErrorBoundary"}),", hors du calcul « Rien de prévu ») avec « Charger dans mon camion » par visite, et un indice quand l'appelant n'a aucun camion ou en a plusieurs. Pas de chemin hors ligne : le chargement reste en ligne (spec). Tests : ",e.jsx(s,{children:"convex/parts/__tests__/reservationLoad.test.ts"}),","," ",e.jsx(s,{children:"convex/myDay/__tests__/partsToLoad.test.ts"}),","," ",e.jsx(s,{children:"visit-parts-dialog.test.tsx"}),", ",e.jsx(s,{children:"my-day.test.tsx"}),"."]})]}),e.jsxs(o,{id:"pieces-commandes-tech",title:"Bons de commande — implémentation (CHARGEOPS-431)",children:[e.jsxs(r,{children:["Slice 2 de l'épique bons de commande (CHARGEOPS-429) — tâche 2 du plan"," ",e.jsx(s,{children:"docs/superpowers/plans/2026-09-26-purchase-orders.md"}),"."]}),e.jsxs(d,{children:[e.jsxs(i,{children:["Tables ",e.jsx(s,{children:"purchaseOrders"}),", ",e.jsx(s,{children:"purchaseOrderLines"})," et"," ",e.jsx(s,{children:"purchaseOrderCounters"})," (",e.jsx(s,{children:"TENANT_TABLES"}),", ",e.jsx(s,{children:"BACKUP_TABLES"}),"), index de la spec plus ",e.jsx(s,{children:"purchaseOrders.by_tenant_number"}),"."," ",e.jsx(s,{children:"number"})," est optionnel : un brouillon n'a pas de numéro, et un brouillon supprimé n'en consomme pas."]}),e.jsxs(i,{children:["Numérotation ",e.jsx(s,{children:"PO-AAAA-NNNN"})," : ",e.jsx(s,{children:"allocateOrderNumber"})," lit la ligne (tenant, année UTC) de ",e.jsx(s,{children:"by_tenant_year"})," et la corrige dans la même mutation que la commande ; les transactions sérialisables de Convex rejouent l'une de deux allocations concurrentes. Un numéro déjà porté par une commande est sauté (compteur restauré d'une sauvegarde plus ancienne)."]}),e.jsxs(i,{children:[e.jsx(s,{children:"purchaseOrderLines.open"})," n'est vrai que sur une commande envoyée avec une quantité ouverte (",e.jsx(s,{children:"lineOpenQty"}),") : les lignes d'un brouillon sont"," ",e.jsx(s,{children:"false"}),", pour que le « en commande » de la slice 4 ne compte que ce qui a été demandé au fournisseur."]}),e.jsxs(i,{children:["Argent : ",e.jsx(s,{children:"unitCost"}),", ",e.jsx(s,{children:"totals"})," et ",e.jsx(s,{children:"total"})," sont absents (jamais 0) sans ",e.jsx(s,{children:"viewFinancials"})," ; ",e.jsx(s,{children:"saveDraft"})," refuse un coût d'un tel appelant et garde le coût stocké ; le coût prérempli par"," ",e.jsx(s,{children:"createFromReorder"})," ne l'est que pour ",e.jsx(s,{children:"viewFinancials"}),". Aucun journal d'audit ne porte de coût. Rien ne bouge le stock."]})]}),e.jsx(h,{headers:["Fonction (convex/parts/purchasing.ts)","Permission","Comportement"],rows:[[e.jsx(s,{children:"listOrders"}),"viewInventory","take(201) sur by_tenant_status_createdAt, by_tenant_supplier ou by_tenant (desc) → { orders, truncated } ; total et missingCost seulement avec viewFinancials."],[e.jsx(s,{children:"getOrder"}),"viewInventory","En-tête, fournisseur, lignes (pièce, emplacement, quantités, coût si viewFinancials), historique d'envoi ; null si absente."],[e.jsx(s,{children:"createFromReorder"}),"manageInventory","≤ 100 lignes, (pièce, emplacement) fusionnés ; un brouillon par fournisseur actif lié, les autres pièces dans UN brouillon sans fournisseur."],[e.jsxs(e.Fragment,{children:[e.jsx(s,{children:"createDraft"})," / ",e.jsx(s,{children:"saveDraft"})," / ",e.jsx(s,{children:"discardDraft"})]}),"manageInventory","Brouillon seulement. saveDraft remplace les lignes (≤ 100, une par pièce × emplacement). discardDraft supprime un brouillon sans numéro, annule un brouillon numéroté."],[e.jsxs(e.Fragment,{children:[e.jsx(s,{children:"reserveNumber"})," / ",e.jsx(s,{children:"markSent"})]}),"manageInventory","Exigent un brouillon, au moins une ligne et un fournisseur actif. markSent : numéro, sentAt, date prévue = délai du fournisseur si vide, lignes open, historique « marked »."],[e.jsxs(e.Fragment,{children:[e.jsx(s,{children:"generateSendUploadUrl"})," / ",e.jsx(s,{children:"claimSend"})]}),"manageInventory","Fente d'envoi du PDF ; claimSend revérifie l'envoi (brouillon numéroté, lignes, fournisseur actif, pièces et emplacements de livraison actifs) et pose sendClaim, qui refuse un second envoi tant qu'un premier est en cours (bail de 15 minutes)."],[e.jsxs(e.Fragment,{children:[e.jsx(s,{children:"recordSend"})," / ",e.jsx(s,{children:"releaseSendClaim"})," (interne)"]}),"manageInventory (revérifiée)","recordSend journalise la tentative (≤ 20 gardées) et libère le verrou ; succès → même transition draft → sent que markSent. releaseSendClaim libère le verrou quand l'action abandonne avant l'envoi."]]}),e.jsxs(r,{children:[e.jsx(n,{children:"Envoi"})," (",e.jsx(s,{children:"convex/parts/purchaseOrderActions.ts::sendOrder"}),", « use node ») : le dialogue réserve le numéro, le navigateur rend le PDF (",e.jsx(s,{children:"components/parts/pdf/purchase-order-document.tsx"}),", moteur html2canvas, blocs"," ",e.jsx(s,{children:"data-pdf-block"}),", lignes ",e.jsx(s,{children:"data-pdf-atom"}),") et le téléverse ;
418 l'action revérifie tout et réserve l'envoi via ",e.jsx(s,{children:"claimSend"})," AVANT d'envoyer (une commande envoyée ou vide est refusée, tout comme un second envoi pendant que le premier courriel part : le fournisseur n'en reçoit jamais deux ; le verrou bloque aussi « marquer comme envoyée », l'enregistrement et la suppression), envoie UN courriel par ",e.jsx(s,{children:"actions/email.sendEmail"})," (adresse supprimée → « skipped »), journalise par ",e.jsx(s,{children:"recordSend"})," et supprime le blob dans un"," ",e.jsx(s,{children:"finally"}),". Juste après ",e.jsx(s,{children:"claimSend"}),", le PDF téléversé est lui-même réclamé par ",e.jsx(s,{children:"reports.mutations.claimShareUpload"})," (un PDF récent et non réclamé, usage ",e.jsx(s,{children:"purchaseOrderShare"})," — CHARGEOPS-490), et le"," ",e.jsx(s,{children:"finally"})," ne supprime qu'un blob que cet envoi a réclamé (",e.jsx(s,{children:"dropShareUpload"}),", blob et réclamation ensemble) : ",e.jsx(s,{children:"storageId"})," est la parole du client, et l'ancienne suppression inconditionnelle laissait un appelant refusé détruire tout blob dont il connaissait l'identifiant. Un envoi refusé avant la réclamation laisse son propre téléversement plutôt que de risquer un autre fichier. Le corps du courriel ne porte aucun montant ; le PDF d'un expéditeur sans ",e.jsx(s,{children:"viewFinancials"})," est sans prix puisque ",e.jsx(s,{children:"getOrder"})," les retire."]}),e.jsxs(p,{tone:"info",title:"Ordre des vérifications",children:[e.jsx(s,{children:"moduleQuery"}),"/",e.jsx(s,{children:'moduleMutation("warehouse")'})," puis"," ",e.jsx(s,{children:"requirePermission"})," avant toute lecture ; ids lus par le ",e.jsx(s,{children:"ctx.db"})," ","soumis aux règles du tenant. Tests : ",e.jsx(s,{children:"convex/parts/__tests__/purchasing.test.ts"}),","," ",e.jsx(s,{children:"shared/__tests__/purchasing.test.ts"}),"."]})]}),e.jsxs(o,{id:"pieces-reception-tech",title:"Réception des commandes — implémentation (CHARGEOPS-432)",children:[e.jsxs(r,{children:["Slice 3 de l'épique bons de commande (CHARGEOPS-429) — tâche 3 du plan"," ",e.jsx(s,{children:"docs/superpowers/plans/2026-09-26-purchase-orders.md"}),". Acceptation : une livraison partielle poste des réceptions au coût commandé et laisse le reste ouvert."]}),e.jsxs(d,{children:[e.jsxs(i,{children:["Schéma : ",e.jsx(s,{children:"partStockMovementRefTypeValidator"})," gagne"," ",e.jsx(s,{children:'"purchaseOrder"'})," ; le ",e.jsx(s,{children:"refId"})," d'une réception est l'id de la"," ",e.jsx(n,{children:"ligne"}),", donc ",e.jsx(s,{children:"partStockMovements.by_ref"})," répond « ce qui est arrivé sur cette ligne ». ",e.jsx(s,{children:"purchaseOrderLines"})," gagne l'index"," ",e.jsx(s,{children:"by_tenant_location_open"})," (hors spec) pour la garde de désactivation d'un emplacement. Pas de nouveau module : ",e.jsx(s,{children:"api.d.ts"})," ne change pas. Comme pour les autres ",e.jsx(s,{children:"refType"}),", un ",e.jsx(s,{children:"refId"})," (chaîne) n'est pas relié à la restauration d'une sauvegarde."]}),e.jsxs(i,{children:[e.jsx(s,{children:"receive"})," fait UN appel ",e.jsx(s,{children:"withLedger"})," : une ",e.jsx(s,{children:"receipt"})," par ligne vers l'emplacement de livraison de la ligne, au ",e.jsx(s,{children:"unitCost"})," de la ligne, donc le coût moyen, ",e.jsx(s,{children:"totalOnHand"}),", le niveau et le verrou de stock bas (",e.jsx(s,{children:"lowStockNotifiedAt"}),") bougent exactement comme avec ",e.jsx(s,{children:"receiveStock"}),". Une ligne sans coût reçoit sans coût (moyenne inchangée). Le coût vient toujours de la ligne, jamais de l'appelant : un réceptionnaire sans ",e.jsx(s,{children:"viewFinancials"})," reçoit et ne lit aucun coût (ni dans le retour, ni dans ",e.jsx(s,{children:"getOrder.receipts"}),")."]}),e.jsxs(i,{children:["Surlivraison : au-delà de la quantité ouverte, acceptée et retournée en avertissement ; un total reçu au-delà de ",e.jsx(s,{children:"OVER_DELIVERY_FACTOR"})," (3) × la quantité commandée est refusé (",e.jsx(s,{children:"shared/purchasing.ts::exceedsOverDeliveryCeiling"}),")."," ",e.jsx(s,{children:"assertActivePart"})," / ",e.jsx(s,{children:"assertActiveLocation"})," par ligne avant tout poste : une pièce archivée ou un emplacement inactif refuse toute la livraison."]}),e.jsxs(i,{children:["Statut : ",e.jsx(s,{children:"orderStatus"})," (",e.jsx(s,{children:"partiallyReceived"})," /"," ",e.jsx(s,{children:"received"})," ; ",e.jsx(s,{children:"closed"})," / ",e.jsx(s,{children:"cancelled"})," après"," ",e.jsx(s,{children:"cancelRemainder"}),"), ",e.jsx(s,{children:"open"})," recalculé sur chaque ligne (vrai seulement sur une commande vivante avec une quantité ouverte), ",e.jsx(s,{children:"closedAt"})," à la fin. La note de réception (≤ 500) ne vit que dans le journal d'audit, qui ne porte jamais de coût. Les sommes reçues / annulées et l'ouvert passent par"," ",e.jsx(s,{children:"roundQuantity"})," (au millionième) : 0,1 + 0,7 m sur une ligne de 0,8 m la ferme au lieu de la laisser ouverte de 1e-16."]}),e.jsxs(i,{children:["Gardes (",e.jsx(s,{children:"convex/parts/mutations.ts"}),", à côté de celles des réservations) :"," ",e.jsx(s,{children:"archivePart"})," refuse tant qu'une ligne ouverte attend la pièce (",e.jsx(s,{children:"by_tenant_part_open"}),") ; ",e.jsx(s,{children:"upsertLocation"})," refuse de désactiver l'emplacement de livraison d'une ligne ouverte (",e.jsx(s,{children:"by_tenant_location_open"}),"). Une ligne de brouillon n'est jamais ouverte et ne bloque rien ; aucune garde sur la désactivation d'un fournisseur (la spec n'en prévoit pas, la réception ne le lit pas)."]})]}),e.jsx(h,{headers:["Fonction (convex/parts/purchasing.ts)","Permission","Comportement"],rows:[[e.jsx(s,{children:"receive"}),"manageInventory","{ id, lines: [{ lineId, quantity }], note? } ; commande sent / partiallyReceived, sans sendClaim vivant ;
418 lignes de cette commande, une fois chacune, quantités positives (entières pour « ea ») → { status, warnings } sans montant."],[e.jsx(s,{children:"cancelRemainder"}),"manageInventory","{ id, lineIds? } ; commande vivante ; les quantités ouvertes visées passent en cancelledQuantity, open: false ; refusé si rien n'est ouvert → closed, cancelled ou inchangé."],[e.jsx(s,{children:"getOrder"}),"viewInventory","Gagne receipts : mouvements by_ref de chaque ligne (≤ 50, du plus récent), date, quantité, emplacement, réceptionnaire — jamais le unitCost."]]}),e.jsxs(r,{children:["Client : ",e.jsx(s,{children:"components/parts/purchase-order-receive-dialog.tsx"})," (quantités préremplies avec l'ouvert, avertissement de surlivraison, refus au-delà de 3×, lignes à 0 non envoyées), boutons ",e.jsx(n,{children:"Recevoir une livraison"})," et"," ",e.jsx(n,{children:"Annuler le reste"})," sur ",e.jsx(s,{children:"pages/purchase-order.tsx"}),", colonne Annulé, carte Réceptions, puce de statut avec les modificateurs ",e.jsx(s,{children:"co-chip"})," ","(",e.jsx(s,{children:"is-pending"}),", ",e.jsx(s,{children:"is-ok"}),", ",e.jsx(s,{children:"is-muted"}),"). Tests :"," ",e.jsx(s,{children:"convex/parts/__tests__/purchasing.test.ts"})," (blocs « receiving »),"," ",e.jsx(s,{children:"purchase-order-receive-dialog.test.tsx"}),", ",e.jsx(s,{children:"purchase-order-render.test.tsx"})," ;"," ",e.jsx(s,{children:"soleWriter.test.ts"})," inchangé."]})]}),e.jsxs(o,{id:"pieces-en-commande-tech",title:"Quantités en commande et retard — implémentation (CHARGEOPS-433)",children:[e.jsxs(r,{children:["Slice 4 de l'épique bons de commande (CHARGEOPS-429) — tâche 4 du plan"," ",e.jsx(s,{children:"docs/superpowers/plans/2026-09-26-purchase-orders.md"}),". Rien ne bouge le stock : aucune écriture au grand livre, aucun nouveau module (",e.jsx(s,{children:"api.d.ts"})," inchangé)."]}),e.jsxs(d,{children:[e.jsxs(i,{children:[e.jsx(s,{children:"shared/purchasing.ts::reorderDemand(part, onHand, onOrder, available?)"})," →"," ",e.jsx(s,{children:"{ listed, suggestedQty }"})," : listée quand"," ",e.jsx(s,{children:"base + onOrder < minStock"}),", suggérée"," ",e.jsx(s,{children:"max(reorderQuantity ?? 0, minStock − base − onOrder)"}),", où ",e.jsx(s,{children:"base"})," ","est ",e.jsx(s,{children:"available"})," s'il est fourni, sinon le stock. Avec ",e.jsx(s,{children:"available"})," ","(réservations), une pièce sans minimum est listée quand"," ",e.jsx(s,{children:"available + onOrder < 0"}),". La slice 4 des réservations (totaux réservés à l'échelle du locataire) n'est pas livrée : ",e.jsx(s,{children:"listToReorder"})," ne passe pas encore ",e.jsx(s,{children:"available"}),"."]}),e.jsxs(i,{children:[e.jsx(s,{children:"purchasing.ts::loadOnOrderTotals(ctx)"})," : ",e.jsx(s,{children:"by_tenant_open"})," (",e.jsx(s,{children:"open: true"}),") ",e.jsx(s,{children:".take(2001)"})," → par pièce et par (pièce, emplacement de livraison), Σ ",e.jsx(s,{children:"lineOpenQty"})," (commandé − reçu − annulé), plus"," ",e.jsx(s,{children:"truncated"}),". ",e.jsx(s,{children:"open"})," n'est vrai que sur une commande"," ",e.jsx(s,{children:"sent"})," / ",e.jsx(s,{children:"partiallyReceived"})," avec une quantité ouverte (slices 2–3) : brouillons, commandes annulées, fermées et reçues ne sont jamais lus."," ",e.jsx(s,{children:"loadOnOrderForPart(ctx, partId)"})," : ",e.jsx(s,{children:"by_tenant_part_open"})," ",e.jsx(s,{children:".take(201)"}),", groupé par commande (numéro, statut, ouvert, livraison prévue). Aucun montant."]}),e.jsxs(i,{children:[e.jsx(s,{children:"queries.ts::listToReorder"})," applique ",e.jsx(s,{children:"reorderDemand"})," à chaque pièce active ; lignes avec ",e.jsx(s,{children:"onOrder"}),", résultat avec ",e.jsx(s,{children:"onOrderTruncated"}),"."," ",e.jsx(s,{children:"getPart"})," gagne ",e.jsx(s,{children:"onOrder"}),", ",e.jsx(s,{children:"openOrders"}),","," ",e.jsx(s,{children:"onOrderTruncated"})," et ",e.jsx(s,{children:"suggestedOrderQty"})," (suggestion si la pièce est listée, sinon ",e.jsx(s,{children:"reorderQuantity"}),", sinon 1). ",e.jsx(s,{children:"listParts"})," ne change pas (aucune colonne du catalogue ne l'affiche). L'alerte de stock bas reste sur le stock seul."]}),e.jsxs(i,{children:[e.jsx(s,{children:"purchase_order_overdue"})," (",e.jsx(s,{children:"shared/notification-policy.ts"}),") : groupe"," ",e.jsx(s,{children:"inventory"}),", public ",e.jsx(s,{children:"inventory"}),", cloche par défaut, courriel verrouillé désactivé (pas de modèle personnel) ; pas un événement de liste de diffusion."]}),e.jsxs(i,{children:["Cron ",e.jsx(s,{children:"purchase-orders-overdue-sweep"})," (",e.jsx(s,{children:'"0 12 * * *"'}),") →"," ",e.jsx(s,{children:"parts/notifications.ts::sweepOverduePurchaseOrders"})," : un"," ",e.jsx(s,{children:"sweepOverduePurchaseOrdersTenant"})," par locataire actif avec le module"," ",e.jsx(s,{children:"warehouse"})," (une erreur est journalisée, les autres continuent). Lecture par le nouvel index ",e.jsx(s,{children:"purchaseOrders.by_tenant_status_overdue"})," (",e.jsx(s,{children:"tenantId, status, overdueNotifiedAt, expectedDate"}),", hors spec) : verrou non posé (",e.jsx(s,{children:"undefined"}),") et ",e.jsx(s,{children:'"" < expectedDate < today'})," ","(le jour civil du locataire, que sa chaîne résout elle-même ; la date prévue est comptée depuis ce même jour d'envoi, ",e.jsx(s,{children:"shared/purchasing.ts::expectedDate"})," ","— CHARGEOPS-540), ≤ 200 par statut et par passage — une fenêtre pleine se replanifie aussitôt, et l'arriéré s'écoule dans le même cycle. Le balayage ne lit que les commandes, jamais leurs lignes (une commande vivante a toujours une ligne ouverte ; lire jusqu'à 100 lignes × 400 commandes pourrait dépasser la limite de lectures d'une transaction) ; le verrou"," ",e.jsx(s,{children:"overdueNotifiedAt"})," est posé dans cette transaction et"," ",e.jsx(s,{children:"notifyPurchaseOrderOverdue"})," est planifié. Celui-ci revérifie (module, commande vivante, verrou, ligne ouverte) puis sonne chaque détenteur effectif de"," ",e.jsx(s,{children:"viewInventory"})," que la politique autorise (helper partagé avec"," ",e.jsx(s,{children:"notifyLowStock"}),"), lien ",e.jsx(s,{children:"/warehouse/orders/:id"}),", données"," ",e.jsx(s,{children:"number"}),", ",e.jsx(s,{children:"supplierName"}),", ",e.jsx(s,{children:"expectedDate"}),","," ",e.jsx(s,{children:"openLines"})," — jamais un coût. Le verrou n'est jamais levé : une fois par commande."]})]}),e.jsxs(r,{children:["Client : colonne ",e.jsx(n,{children:"En commande"})," (et colonne CSV) dans"," ",e.jsx(s,{children:"to-reorder-card.tsx"}),", avis de troncature ; dans ",e.jsx(s,{children:"part-drawer.tsx"}),", la ligne « En commande : n » avec un ",e.jsx(s,{children:"co-link-chip"})," par commande et, pour"," ",e.jsx(s,{children:"manageInventory"}),", le bouton ",e.jsx(n,{children:"Commander"})," :"," ",e.jsx(s,{children:"createFromReorder"})," avec une ligne (",e.jsx(s,{children:"suggestedOrderQty"}),", emplacement actif par défaut), puis la page de la commande — pas d'ajout à un brouillon existant. Libellés de la cloche (",e.jsx(s,{children:"inbox-list.tsx"}),", ",e.jsx(s,{children:"notification-labels.ts"}),", extension). Tests : ",e.jsx(s,{children:"convex/parts/__tests__/onOrder.test.ts"}),","," ",e.jsx(s,{children:"shared/__tests__/purchasing.test.ts"}),", ",e.jsx(s,{children:"to-reorder-card.test.tsx"}),","," ",e.jsx(s,{children:"part-drawer.test.tsx"}),", ",e.jsx(s,{children:"notification-bell.test.ts"}),"."]})]}),e.jsxs(o,{id:"pieces-export-comptable-tech",title:"Export comptable des réceptions — implémentation (CHARGEOPS-434)",children:[e.jsxs(r,{children:["Slice 5 de l'épique bons de commande (CHARGEOPS-429) — tâche 5 du plan"," ",e.jsx(s,{children:"docs/superpowers/plans/2026-09-26-purchase-orders.md"}),". L'export comptable existant (",e.jsx(s,{children:"accountingExports"}),", voir la documentation technique du Centre de maintenance) gagne le jeu de données ",e.jsx(s,{children:'"purchaseOrders"'}),"."]}),e.jsxs(d,{children:[e.jsxs(i,{children:[e.jsx(n,{children:"Grain"})," : une ligne par mouvement de réception (",e.jsx(s,{children:'type: "receipt"'}),","," ",e.jsx(s,{children:'refType: "purchaseOrder"'}),") — une ligne de commande reçue en deux livraisons donne deux lignes, chacune avec sa date (jour UTC du ",e.jsx(s,{children:"createdAt"}),") et sa quantité. Colonnes : reçu le, numéro du bon, fournisseur, numéro de compte fournisseur, SKU, description, SKU fournisseur, emplacement, quantité, unité, coût unitaire, total de la ligne, coût manquant, devise (CAD), id de ligne, id de réception."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Coût"})," : l'instantané ",e.jsx(s,{children:"unitCost"})," du mouvement (le coût commandé au moment de la réception). Absent → coût et total vides,"," ",e.jsx(s,{children:"costMissing: true"})," ; jamais 0."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Garde"})," : ",e.jsx(s,{children:"exportPurchaseReceipts"})," est un"," ",e.jsx(s,{children:'moduleQuery("warehouse")'})," ; ",e.jsx(s,{children:"viewFinancials"})," puis"," ",e.jsx(s,{children:"viewInventory"})," avant toute lecture. Pas de filtre de portée d'organisation : une commande n'en porte aucune."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Index"})," : ",e.jsx(s,{children:"partStockMovements.by_tenant_refType_createdAt"})," ","(",e.jsx(s,{children:"tenantId"}),", ",e.jsx(s,{children:"refType"}),", ",e.jsx(s,{children:"createdAt"}),") — la période est une plage d'index, bornée à 5 001 lignes (au-delà : trop volumineux) ;"," ",e.jsx(s,{children:"by_tenant_type_createdAt"})," renverrait aussi les réceptions manuelles."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Budget de lectures"})," : chaque ligne est nommée par un"," ",e.jsx(s,{children:"db.get"})," par ligne de commande, commande, fournisseur, pièce et emplacement distincts, comptés avant chaque lot contre ",e.jsx(s,{children:"PURCHASE_RECEIPTS_CALL_BUDGET"})," ","(3 500, sous les 4 096 appels par requête de Convex) — au-delà, l'export est refusé comme trop volumineux plutôt que d'échouer sur une erreur de plateforme. Une réception dont la ligne de commande n'est plus lisible (une restauration de sauvegarde la recrée sous un nouvel id) est quand même exportée, colonnes du bon vides."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Interface"})," : bouton ",e.jsx(n,{children:"Export comptable"})," de"," ",e.jsx(s,{children:"components/parts/purchase-orders-card.tsx"})," (",e.jsx(s,{children:"viewFinancials"})," ","seulement), qui ouvre ",e.jsx(s,{children:"AccountingExportDialog"})," avec"," ",e.jsx(s,{children:'initialDataset="purchaseOrders"'})," ; la boîte ne liste que les jeux de données dont le module est actif."]})]}),e.jsxs(r,{children:["Tests : ",e.jsx(s,{children:"convex/accounting/__tests__/purchaseReceipts.test.ts"}),","," ",e.jsx(s,{children:"purchaseReceiptsBudget.test.ts"}),","," ",e.jsx(s,{children:"shared/__tests__/accounting-export.test.ts"}),","," ",e.jsx(s,{children:"accounting-export-dialog.test.tsx"}),", ",e.jsx(s,{children:"purchase-orders-card.test.tsx"}),"."]})]})]})}export{I as default};

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.