mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-13 18:27:40 +02:00
Deux endpoints d'écriture dans /api/v1 :
- PUT /products/{codein}/gamme { gamme, fournisseur? } pour un article ;
- POST /gammes { fournisseur, changes[] } pour plusieurs articles d'un
fournisseur, en tout ou rien.
Ils passent par le même enregistrement que la Grille (extrait de
saveDraftChanges dans enregistrerGammes) : snapshot du fournisseur, cache
de la Grille et instantané grid_rows. Gamme validée contre la liste des
gammes (A, B, C, Y, Z) ; un article déjà dans la gamme demandée n'est pas
réécrit. Spec OpenAPI et page de connexion à l'API mises à jour ; la
légende des gammes de la spec, qui citait une gamme D inexistante, suit
désormais lib/gammes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VEXT61tGbsXHUDNBeghCXQ
486 lines
20 KiB
TypeScript
486 lines
20 KiB
TypeScript
/**
|
|
* CollectFlow — Instantané persisté de la grille (table `grid_rows`).
|
|
*
|
|
* `getProductRows()` reconstruit la grille en direct (6 requêtes SQL sur la base
|
|
* miroir FF + jointure Qlik) et ne la garde qu'en mémoire 10 minutes. L'API `/api/v1`
|
|
* ne doit **jamais** déclencher ce calcul : elle lit cette table, remplie en effet de
|
|
* bord quand quelqu'un ouvre la Grille.
|
|
*
|
|
* On n'introduit donc aucun calcul supplémentaire — on arrête de jeter un résultat
|
|
* qui existe déjà, et il survit désormais aux redémarrages.
|
|
*/
|
|
|
|
import "server-only";
|
|
|
|
import { db } from "@/db";
|
|
import { gridRows } from "@/db/schema";
|
|
import { and, asc, desc, eq, ilike, inArray, or, sql, lt, type SQL } from "drizzle-orm";
|
|
import type { ProductRow } from "@/types/grid";
|
|
|
|
/** Colonnes autorisées au tri — liste blanche, jamais l'entrée utilisateur brute. */
|
|
const SORTABLE = {
|
|
libelle1: gridRows.libelle1,
|
|
codein: gridRows.codein,
|
|
codeFournisseur: gridRows.codeFournisseur,
|
|
codeGamme: gridRows.codeGamme,
|
|
totalCa: gridRows.totalCa,
|
|
totalQuantite: gridRows.totalQuantite,
|
|
totalMarge: gridRows.totalMarge,
|
|
tauxMarge: gridRows.tauxMarge,
|
|
stockActuel: gridRows.stockActuel,
|
|
caReseau: gridRows.caReseau,
|
|
qteReseau: gridRows.qteReseau,
|
|
nbMagasinsReseau: gridRows.nbMagasinsReseau,
|
|
caParMagasinReseau: gridRows.caParMagasinReseau,
|
|
computedAt: gridRows.computedAt,
|
|
} as const;
|
|
|
|
export type GridSortKey = keyof typeof SORTABLE;
|
|
export const GRID_SORT_KEYS = Object.keys(SORTABLE) as GridSortKey[];
|
|
|
|
export interface GridQuery {
|
|
codeFournisseur?: string;
|
|
gamme?: string;
|
|
code1?: string;
|
|
code2?: string;
|
|
code3?: string;
|
|
/** Préfixe de nomenclature (« 32 », « 3202 », « 320211 ») — filtre sur code3. */
|
|
nomenclature?: string;
|
|
/** Recherche libre : libellé, codein, GTIN, référence, code centrale. */
|
|
search?: string;
|
|
sort?: GridSortKey;
|
|
order?: "asc" | "desc";
|
|
page?: number;
|
|
limit?: number;
|
|
}
|
|
|
|
export interface GridQueryResult {
|
|
rows: ProductRow[];
|
|
total: number;
|
|
/** Calcul le plus ancien parmi les lignes renvoyées — fraîcheur de la donnée. */
|
|
computedAt: string | null;
|
|
}
|
|
|
|
/**
|
|
* Version du format des lignes persistées. À incrémenter dès qu'un champ de
|
|
* `ProductRow` est ajouté ou change de sens : les instantanés plus anciens ne
|
|
* seront plus servis à la Grille (recalcul), mais restent lisibles par /api/v1.
|
|
*/
|
|
export const GRID_PAYLOAD_VERSION = 1;
|
|
|
|
/**
|
|
* Persiste les lignes calculées pour un fournisseur.
|
|
*
|
|
* Stratégie : toutes les lignes du lot portent le même `computedAt`, puis on supprime
|
|
* celles du fournisseur restées sur un `computedAt` antérieur. Cela purge les articles
|
|
* disparus du catalogue sans avoir à passer des centaines de codein en paramètres.
|
|
*/
|
|
export async function upsertGridRows(codeFournisseur: string, rows: ProductRow[]): Promise<number> {
|
|
if (!codeFournisseur || rows.length === 0) return 0;
|
|
const computedAt = new Date();
|
|
|
|
const values = rows
|
|
.filter((r) => r.codein)
|
|
.map((r) => ({
|
|
codeFournisseur,
|
|
codein: r.codein,
|
|
nomFournisseur: r.nomFournisseur ?? null,
|
|
libelle1: r.libelle1 ?? null,
|
|
gtin: r.gtin ?? null,
|
|
reference: r.reference ?? null,
|
|
codeCentrale: r.codeCentrale ?? null,
|
|
code1: r.code1 ?? null,
|
|
code2: r.code2 ?? null,
|
|
code3: r.code3 ?? null,
|
|
codeGamme: r.codeGamme ?? null,
|
|
codeGammeInit: r.codeGammeInit ?? null,
|
|
totalCa: r.totalCa != null ? String(r.totalCa) : null,
|
|
totalQuantite: r.totalQuantite != null ? String(r.totalQuantite) : null,
|
|
totalMarge: r.totalMarge != null ? String(r.totalMarge) : null,
|
|
tauxMarge: r.tauxMarge != null ? String(r.tauxMarge) : null,
|
|
stockActuel: r.stockActuel != null ? String(r.stockActuel) : null,
|
|
caReseau: r.caReseau != null ? String(r.caReseau) : null,
|
|
qteReseau: r.qteReseau != null ? String(r.qteReseau) : null,
|
|
nbMagasinsReseau: r.nbMagasinsReseau ?? null,
|
|
caParMagasinReseau: r.caParMagasinReseau != null ? String(r.caParMagasinReseau) : null,
|
|
margePctReseau: r.margePctReseau != null ? String(r.margePctReseau) : null,
|
|
payload: { ...r, payloadVersion: GRID_PAYLOAD_VERSION },
|
|
computedAt,
|
|
}));
|
|
if (values.length === 0) return 0;
|
|
|
|
const CHUNK = 200; // payload jsonb volumineux : lots plus petits que pour des scalaires
|
|
let written = 0;
|
|
for (let i = 0; i < values.length; i += CHUNK) {
|
|
const batch = values.slice(i, i + CHUNK);
|
|
await db
|
|
.insert(gridRows)
|
|
.values(batch)
|
|
.onConflictDoUpdate({
|
|
target: [gridRows.codeFournisseur, gridRows.codein],
|
|
set: {
|
|
nomFournisseur: sql`excluded.nom_fournisseur`,
|
|
libelle1: sql`excluded.libelle1`,
|
|
gtin: sql`excluded.gtin`,
|
|
reference: sql`excluded.reference`,
|
|
codeCentrale: sql`excluded.code_centrale`,
|
|
code1: sql`excluded.code1`,
|
|
code2: sql`excluded.code2`,
|
|
code3: sql`excluded.code3`,
|
|
codeGamme: sql`excluded.code_gamme`,
|
|
codeGammeInit: sql`excluded.code_gamme_init`,
|
|
totalCa: sql`excluded.total_ca`,
|
|
totalQuantite: sql`excluded.total_quantite`,
|
|
totalMarge: sql`excluded.total_marge`,
|
|
tauxMarge: sql`excluded.taux_marge`,
|
|
stockActuel: sql`excluded.stock_actuel`,
|
|
caReseau: sql`excluded.ca_reseau`,
|
|
qteReseau: sql`excluded.qte_reseau`,
|
|
nbMagasinsReseau: sql`excluded.nb_magasins_reseau`,
|
|
caParMagasinReseau: sql`excluded.ca_par_magasin_reseau`,
|
|
margePctReseau: sql`excluded.marge_pct_reseau`,
|
|
payload: sql`excluded.payload`,
|
|
computedAt: sql`excluded.computed_at`,
|
|
},
|
|
});
|
|
written += batch.length;
|
|
}
|
|
|
|
// Purge des articles qui ne font plus partie du catalogue du fournisseur.
|
|
await db.delete(gridRows).where(
|
|
and(eq(gridRows.codeFournisseur, codeFournisseur), lt(gridRows.computedAt, computedAt)),
|
|
);
|
|
|
|
return written;
|
|
}
|
|
|
|
/**
|
|
* Instantané complet d'un fournisseur, tel que persisté par le dernier calcul.
|
|
*
|
|
* Renvoie `null` (→ recalcul) si rien n'est persisté, si une ligne est d'une
|
|
* version de format antérieure, ou si le calcul date d'un autre mois que le mois
|
|
* en cours : la fenêtre des 12 mois glissants aurait changé entre-temps.
|
|
* Les lignes sont rendues dans l'ordre du calcul en direct (identifiant article).
|
|
*/
|
|
export async function readGridSnapshot(
|
|
codeFournisseur: string,
|
|
): Promise<{ rows: ProductRow[]; computedAt: Date } | null> {
|
|
const found = await db
|
|
.select({ payload: gridRows.payload, computedAt: gridRows.computedAt })
|
|
.from(gridRows)
|
|
.where(eq(gridRows.codeFournisseur, codeFournisseur));
|
|
if (found.length === 0) return null;
|
|
|
|
let oldest: Date | null = null;
|
|
const rows: ProductRow[] = [];
|
|
for (const r of found) {
|
|
const row = r.payload as ProductRow;
|
|
if (row.payloadVersion !== GRID_PAYLOAD_VERSION) return null;
|
|
if (!oldest || r.computedAt < oldest) oldest = r.computedAt;
|
|
rows.push(row);
|
|
}
|
|
const now = new Date();
|
|
if (!oldest || oldest.getFullYear() !== now.getFullYear() || oldest.getMonth() !== now.getMonth()) {
|
|
return null;
|
|
}
|
|
rows.sort((a, b) => (a.noid ?? 0) - (b.noid ?? 0));
|
|
return { rows, computedAt: oldest };
|
|
}
|
|
|
|
/**
|
|
* Reporte des gammes enregistrées dans l'instantané persisté (colonne et payload),
|
|
* pour que /api/v1 les voie sans attendre un recalcul complet de la grille.
|
|
*/
|
|
export async function updateGridRowsGamme(
|
|
codeFournisseur: string,
|
|
changes: ReadonlyArray<{ codein: string; codeGamme: string }>,
|
|
): Promise<void> {
|
|
const parGamme = new Map<string, string[]>();
|
|
for (const c of changes) {
|
|
const codeins = parGamme.get(c.codeGamme) ?? [];
|
|
codeins.push(c.codein);
|
|
parGamme.set(c.codeGamme, codeins);
|
|
}
|
|
const CHUNK = 5000; // bien en deçà des 65 535 paramètres liés de PostgreSQL
|
|
for (const [gamme, codeins] of parGamme) {
|
|
for (let i = 0; i < codeins.length; i += CHUNK) {
|
|
await db
|
|
.update(gridRows)
|
|
.set({
|
|
codeGamme: gamme,
|
|
payload: sql`jsonb_set(${gridRows.payload}, '{codeGamme}', to_jsonb(${gamme}::text))`,
|
|
})
|
|
.where(and(
|
|
eq(gridRows.codeFournisseur, codeFournisseur),
|
|
inArray(gridRows.codein, codeins.slice(i, i + CHUNK)),
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Construit la clause WHERE commune à la grille et à la recherche transversale. */
|
|
function buildWhere(q: GridQuery): SQL | undefined {
|
|
const clauses: SQL[] = [];
|
|
if (q.codeFournisseur) clauses.push(eq(gridRows.codeFournisseur, q.codeFournisseur));
|
|
if (q.gamme) clauses.push(eq(gridRows.codeGamme, q.gamme));
|
|
if (q.code1) clauses.push(eq(gridRows.code1, q.code1));
|
|
if (q.code2) clauses.push(eq(gridRows.code2, q.code2));
|
|
if (q.code3) clauses.push(eq(gridRows.code3, q.code3));
|
|
// Filtre par **préfixe** de nomenclature : « 32 », « 3202 » ou « 320211 ».
|
|
// C'est celui à utiliser avec /api/v1/nomenclatures, car il ne dépend pas de
|
|
// code1/code2 — parfois vides quand la hiérarchie n'a pas pu être remontée.
|
|
if (q.nomenclature) clauses.push(sql`${gridRows.code3} like ${q.nomenclature + "%"}`);
|
|
const search = q.search?.trim();
|
|
if (search) {
|
|
const pattern = `%${search}%`;
|
|
const alt = or(
|
|
ilike(gridRows.libelle1, pattern),
|
|
ilike(gridRows.codein, pattern),
|
|
ilike(gridRows.gtin, pattern),
|
|
ilike(gridRows.reference, pattern),
|
|
ilike(gridRows.codeCentrale, pattern),
|
|
);
|
|
if (alt) clauses.push(alt);
|
|
}
|
|
return clauses.length ? and(...clauses) : undefined;
|
|
}
|
|
|
|
/**
|
|
* Lit les lignes de grille persistées : filtre, tri et pagination faits en SQL.
|
|
* Ne déclenche aucun recalcul et ne contacte jamais Qlik.
|
|
*/
|
|
export async function queryGridRows(q: GridQuery): Promise<GridQueryResult> {
|
|
const page = Math.max(1, q.page ?? 1);
|
|
// `limit` absent = aucune limite : on renvoie toutes les lignes du filtre.
|
|
// Il y avait ici un Math.min(500, …) qui re-plafonnait en silence, quelle que
|
|
// soit la valeur demandée — la réponse était tronquée sans que rien ne le dise.
|
|
const limit = q.limit != null ? Math.max(1, q.limit) : null;
|
|
const where = buildWhere(q);
|
|
|
|
const sortCol = SORTABLE[q.sort ?? "totalCa"] ?? gridRows.totalCa;
|
|
// NULLS LAST dans les deux sens : un produit sans CA ne doit jamais occuper la tête.
|
|
const orderBy = q.order === "asc" ? asc(sortCol) : desc(sortCol);
|
|
|
|
const selection = db
|
|
.select({ payload: gridRows.payload, computedAt: gridRows.computedAt })
|
|
.from(gridRows)
|
|
.where(where)
|
|
.orderBy(orderBy, asc(gridRows.codein));
|
|
|
|
// Comptage et page en parallèle : deux connexions du pool, un seul temps d'attente.
|
|
const [[countRow], found] = await Promise.all([
|
|
db.select({ total: sql<number>`count(*)::int` }).from(gridRows).where(where),
|
|
limit != null ? selection.limit(limit).offset((page - 1) * limit) : selection,
|
|
]);
|
|
|
|
let oldest: Date | null = null;
|
|
for (const r of found) {
|
|
if (r.computedAt && (!oldest || r.computedAt < oldest)) oldest = r.computedAt;
|
|
}
|
|
|
|
return {
|
|
rows: found.map((r) => versLigneApi(r.payload)),
|
|
total: countRow?.total ?? 0,
|
|
computedAt: oldest ? oldest.toISOString() : null,
|
|
};
|
|
}
|
|
|
|
/** Ligne exposée par l'API : la version de format est un détail interne. */
|
|
function versLigneApi(payload: unknown): ProductRow {
|
|
const ligne = { ...(payload as ProductRow) };
|
|
delete ligne.payloadVersion;
|
|
return ligne;
|
|
}
|
|
|
|
/** Fiche complète d'un produit. `codeFournisseur` lève l'ambiguïté d'un article multi-fournisseurs. */
|
|
export async function getGridRowByCodein(
|
|
codein: string,
|
|
codeFournisseur?: string,
|
|
): Promise<{ row: ProductRow; computedAt: string | null } | null> {
|
|
const clauses: SQL[] = [eq(gridRows.codein, codein)];
|
|
if (codeFournisseur) clauses.push(eq(gridRows.codeFournisseur, codeFournisseur));
|
|
|
|
const [found] = await db
|
|
.select({ payload: gridRows.payload, computedAt: gridRows.computedAt })
|
|
.from(gridRows)
|
|
.where(and(...clauses))
|
|
.orderBy(desc(gridRows.computedAt))
|
|
.limit(1);
|
|
|
|
if (!found) return null;
|
|
return {
|
|
row: versLigneApi(found.payload),
|
|
computedAt: found.computedAt ? found.computedAt.toISOString() : null,
|
|
};
|
|
}
|
|
|
|
/** Fournisseurs chez lesquels un article figure dans l'instantané. */
|
|
export async function listGridSuppliersForCodein(
|
|
codein: string,
|
|
): Promise<Array<{ codeFournisseur: string; nomFournisseur: string | null }>> {
|
|
return db
|
|
.select({ codeFournisseur: gridRows.codeFournisseur, nomFournisseur: gridRows.nomFournisseur })
|
|
.from(gridRows)
|
|
.where(eq(gridRows.codein, codein))
|
|
.orderBy(asc(gridRows.codeFournisseur));
|
|
}
|
|
|
|
export interface GammeInstantane {
|
|
/** Gamme courante, modifications enregistrées comprises. */
|
|
codeGamme: string | null;
|
|
/** Gamme serveur au moment du calcul de la grille. */
|
|
codeGammeInit: string | null;
|
|
nomFournisseur: string | null;
|
|
}
|
|
|
|
/**
|
|
* Gammes d'articles d'un fournisseur dans l'instantané. Un codein absent de la
|
|
* carte n'appartient pas à ce fournisseur (ou n'a pas encore été calculé).
|
|
*/
|
|
export async function getGridRowsGammes(
|
|
codeFournisseur: string,
|
|
codeins: readonly string[],
|
|
): Promise<Map<string, GammeInstantane>> {
|
|
const out = new Map<string, GammeInstantane>();
|
|
const CHUNK = 5000; // bien en deçà des 65 535 paramètres liés de PostgreSQL
|
|
for (let i = 0; i < codeins.length; i += CHUNK) {
|
|
const found = await db
|
|
.select({
|
|
codein: gridRows.codein,
|
|
codeGamme: gridRows.codeGamme,
|
|
codeGammeInit: gridRows.codeGammeInit,
|
|
nomFournisseur: gridRows.nomFournisseur,
|
|
})
|
|
.from(gridRows)
|
|
.where(and(
|
|
eq(gridRows.codeFournisseur, codeFournisseur),
|
|
inArray(gridRows.codein, codeins.slice(i, i + CHUNK)),
|
|
));
|
|
for (const r of found) {
|
|
out.set(r.codein, { codeGamme: r.codeGamme, codeGammeInit: r.codeGammeInit, nomFournisseur: r.nomFournisseur });
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Indique si un fournisseur a déjà été calculé au moins une fois.
|
|
* Sert à répondre `202 not_ready` plutôt que de déclencher un calcul long.
|
|
*/
|
|
export async function getGridFreshness(
|
|
codeFournisseur: string,
|
|
): Promise<{ rowCount: number; computedAt: string | null } | null> {
|
|
const [found] = await db
|
|
.select({
|
|
rowCount: sql<number>`count(*)::int`,
|
|
computedAt: sql<Date | null>`max(${gridRows.computedAt})`,
|
|
})
|
|
.from(gridRows)
|
|
.where(eq(gridRows.codeFournisseur, codeFournisseur));
|
|
|
|
if (!found || found.rowCount === 0) return null;
|
|
return {
|
|
rowCount: found.rowCount,
|
|
computedAt: found.computedAt ? new Date(found.computedAt).toISOString() : null,
|
|
};
|
|
}
|
|
|
|
/** Fournisseurs présents dans l'instantané, avec leur fraîcheur. */
|
|
export async function listGridSuppliers(): Promise<
|
|
Array<{ codeFournisseur: string; nomFournisseur: string | null; rowCount: number; computedAt: string | null }>
|
|
> {
|
|
const found = await db
|
|
.select({
|
|
codeFournisseur: gridRows.codeFournisseur,
|
|
nomFournisseur: sql<string | null>`max(${gridRows.nomFournisseur})`,
|
|
rowCount: sql<number>`count(*)::int`,
|
|
computedAt: sql<Date | null>`max(${gridRows.computedAt})`,
|
|
})
|
|
.from(gridRows)
|
|
.groupBy(gridRows.codeFournisseur)
|
|
.orderBy(asc(gridRows.codeFournisseur));
|
|
|
|
return found.map((r) => ({
|
|
codeFournisseur: r.codeFournisseur,
|
|
nomFournisseur: r.nomFournisseur,
|
|
rowCount: r.rowCount,
|
|
computedAt: r.computedAt ? new Date(r.computedAt).toISOString() : null,
|
|
}));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Inventaire des nomenclatures d'un fournisseur
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Un poste de nomenclature, avec son poids — de quoi décider par où découper. */
|
|
export interface GridNomenclature {
|
|
code: string;
|
|
/** Libellé du poste, lu dans le payload (il n'a pas de colonne dédiée). */
|
|
libelle: string | null;
|
|
nbArticles: number;
|
|
totalCa: number;
|
|
totalQuantite: number;
|
|
}
|
|
|
|
/** Longueur du code pour chaque niveau : « 32 » → « 3202 » → « 320211 ». */
|
|
export const NOMENCLATURE_LONGUEURS: Record<1 | 2 | 3, number> = { 1: 2, 2: 4, 3: 6 };
|
|
|
|
/**
|
|
* Répartition des articles d'un fournisseur par niveau de nomenclature.
|
|
*
|
|
* Sans cela, un appelant qui veut découper un gros fournisseur par nomenclature
|
|
* est coincé : le filtre existe sur `/grid`, mais rien ne lui dit **quelles**
|
|
* valeurs existent — il devrait tout télécharger pour les découvrir, ce qui
|
|
* annule le bénéfice du découpage.
|
|
*
|
|
* Les niveaux sont dérivés par **préfixe de `code3`** (codes hiérarchiques à 6
|
|
* chiffres, de 31xxxx à 40xxxx), et non des colonnes `code1`/`code2`. Celles-ci
|
|
* ne sont renseignées que si `pgGetNomenclatureByFournisseur` a su remonter la
|
|
* hiérarchie, ce qui dépend d'une détection heuristique de la colonne parent de
|
|
* la table `nomenclature` ; quand elle échoue, seul `code3` est rempli. Le
|
|
* préfixe, lui, est toujours disponible.
|
|
*
|
|
* Tout est agrégé en SQL : la réponse fait quelques dizaines de lignes, même pour
|
|
* un fournisseur de 130 000 articles.
|
|
*/
|
|
export async function listGridNomenclatures(
|
|
codeFournisseur: string,
|
|
niveau: 1 | 2 | 3,
|
|
parent?: string,
|
|
): Promise<GridNomenclature[]> {
|
|
const longueur = NOMENCLATURE_LONGUEURS[niveau];
|
|
const codeExpr = sql<string>`left(${gridRows.code3}, ${longueur})`;
|
|
// Les libellés n'ont pas de colonne dédiée : on les lit dans le payload.
|
|
// Aux niveaux 1 et 2 ils peuvent être absents (même cause que ci-dessus) ;
|
|
// le code et les compteurs, eux, restent justes.
|
|
const libelleKey = niveau === 1 ? "libelleNiveau1" : niveau === 2 ? "libelleNiveau2" : "libelle3";
|
|
|
|
const clauses: SQL[] = [
|
|
eq(gridRows.codeFournisseur, codeFournisseur),
|
|
sql`${gridRows.code3} is not null and length(${gridRows.code3}) >= ${longueur}`,
|
|
];
|
|
// `parent` est lui aussi un préfixe : « 32 » pour descendre au niveau 2.
|
|
if (parent) clauses.push(sql`${gridRows.code3} like ${parent + "%"}`);
|
|
|
|
const rows = await db
|
|
.select({
|
|
code: codeExpr,
|
|
libelle: sql<string | null>`max(${gridRows.payload} ->> ${libelleKey})`,
|
|
nbArticles: sql<number>`count(*)::int`,
|
|
totalCa: sql<number>`coalesce(sum(${gridRows.totalCa}), 0)::float`,
|
|
totalQuantite: sql<number>`coalesce(sum(${gridRows.totalQuantite}), 0)::float`,
|
|
})
|
|
.from(gridRows)
|
|
.where(and(...clauses))
|
|
.groupBy(codeExpr)
|
|
.orderBy(desc(sql`coalesce(sum(${gridRows.totalCa}), 0)`));
|
|
|
|
return rows.map((r) => ({
|
|
code: String(r.code ?? ""),
|
|
libelle: r.libelle ?? null,
|
|
nbArticles: Number(r.nbArticles) || 0,
|
|
totalCa: Number(r.totalCa) || 0,
|
|
totalQuantite: Number(r.totalQuantite) || 0,
|
|
}));
|
|
}
|