mirror of
https://github.com/R0m1k3/LogiFlow.git
synced 2026-10-11 17:27:31 +02:00
feat(api): API externe de rapprochement BL / factures
Nouvelle API sous /api/ext/v1, authentifiée par clé (EXTERNAL_API_KEYS, en-tête X-API-Key ou Authorization: Bearer), pour qu'un outil tiers lise magasins, fournisseurs et livraisons livrées (n° BL) puis écrive la référence, les montants et l'échéance de la facture, avec validation/dévalidation optionnelle du rapprochement. - normalizeDateString extrait dans server/dateUtils.ts pour être partagé - /api/ext/ exempté du CSRF (pas de cookie de session) - EXTERNAL_API_KEYS ajouté à .env.example et docker-compose.yml - documentation : docs/API-RAPPROCHEMENT.md Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2274c9bb4a
commit
6f1a39e4b2
7 files changed
+575
-73
No files matched your search
@@ -30,3 +30,8 @@ ENCRYPTION_KEY=
|
||||
# Route de secours POST /api/emergency-admin-reset : désactivée si vide.
|
||||
# Ne définir qu'en cas de besoin, puis retirer.
|
||||
EMERGENCY_SECRET=
|
||||
|
||||
# API externe de rapprochement BL / factures (docs/API-RAPPROCHEMENT.md).
|
||||
# Une ou plusieurs clés séparées par des virgules ; vide = API désactivée.
|
||||
# Générer une clé : node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
EXTERNAL_API_KEYS=
|
||||
@@ -17,6 +17,8 @@ services:
|
||||
POSTGRES_PASSWORD: LogiFlow2025!
|
||||
POSTGRES_DB: logiflow_db
|
||||
BACKUP_DIR: /app/backups
|
||||
# Clé(s) de l'API externe de rapprochement, lues depuis le .env de l'hôte (vide = API désactivée)
|
||||
EXTERNAL_API_KEYS: ${EXTERNAL_API_KEYS:-}
|
||||
ports:
|
||||
- "3000:3000"
|
||||
networks:
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
# API externe — Rapprochement BL / factures
|
||||
|
||||
Permet à un outil tiers (logiciel comptable, n8n, script…) de lire les magasins,
|
||||
les fournisseurs et les livraisons livrées avec leur numéro de BL, puis d'écrire
|
||||
la référence et le montant de la facture, comme dans la page **Rapprochement**.
|
||||
|
||||
Code : `server/externalApi.ts`. Préfixe : `/api/ext/v1`.
|
||||
|
||||
## Activation et authentification
|
||||
|
||||
Définir une ou plusieurs clés (séparées par des virgules) dans l'environnement,
|
||||
puis redémarrer :
|
||||
|
||||
```
|
||||
EXTERNAL_API_KEYS=3f9c…a1,7b20…e4
|
||||
```
|
||||
|
||||
Générer une clé :
|
||||
|
||||
```bash
|
||||
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
```
|
||||
|
||||
Variable vide ou absente : l'API répond `503` (désactivée).
|
||||
|
||||
Chaque requête envoie la clé dans l'un de ces en-têtes :
|
||||
|
||||
```
|
||||
X-API-Key: <clé>
|
||||
Authorization: Bearer <clé>
|
||||
```
|
||||
|
||||
Clé absente ou fausse : `401`. Une clé donne accès à **tous les magasins** ;
|
||||
pour révoquer un outil, retirer sa clé de la liste et redémarrer.
|
||||
Exposer l'API uniquement en HTTPS (la clé circule dans chaque requête).
|
||||
|
||||
## Points d'accès
|
||||
|
||||
### `GET /stores` — magasins
|
||||
|
||||
```json
|
||||
[{ "id": 1, "name": "Magasin Nancy" }]
|
||||
```
|
||||
|
||||
### `GET /suppliers` — fournisseurs
|
||||
|
||||
```json
|
||||
[{ "id": 4, "name": "Lactalis", "code": "LAC01", "paymentMethod": "Virement", "automaticReconciliation": false }]
|
||||
```
|
||||
|
||||
`code` = code fournisseur (`codefou`).
|
||||
|
||||
### `GET /deliveries` — livraisons à rapprocher
|
||||
|
||||
Seules les livraisons **livrées** sont renvoyées (même périmètre que la page
|
||||
Rapprochement), de la plus récente à la plus ancienne.
|
||||
|
||||
| Paramètre | Exemple | Effet |
|
||||
|--------------|---------------|------------------------------------------------------------|
|
||||
| `storeId` | `1` | Un seul magasin |
|
||||
| `supplierId` | `4` | Un seul fournisseur |
|
||||
| `blNumber` | `BL-4567` | N° de BL exact (casse et espaces de bord ignorés) |
|
||||
| `reconciled` | `false` | `true` = rapprochées, `false` = en attente |
|
||||
| `hasBl` | `true` | Avec / sans numéro de BL |
|
||||
| `hasInvoice` | `false` | Avec / sans référence facture |
|
||||
| `from`, `to` | `2026-10-01` | Bornes incluses sur la date de livraison (sinon date prévue) |
|
||||
| `limit` | `100` | Taille de page, 100 par défaut, 500 maximum |
|
||||
| `offset` | `0` | Décalage de pagination |
|
||||
|
||||
Paramètre mal formé : `400` avec la liste des paramètres fautifs.
|
||||
|
||||
```json
|
||||
{
|
||||
"total": 1,
|
||||
"limit": 100,
|
||||
"offset": 0,
|
||||
"items": [
|
||||
{
|
||||
"id": 812,
|
||||
"storeId": 1,
|
||||
"storeName": "Magasin Nancy",
|
||||
"supplierId": 4,
|
||||
"supplierName": "Lactalis",
|
||||
"supplierCode": "LAC01",
|
||||
"status": "delivered",
|
||||
"scheduledDate": "2026-10-08",
|
||||
"deliveredDate": "2026-10-08T07:42:00.000Z",
|
||||
"blNumber": "BL-4567",
|
||||
"blAmount": 1234.5,
|
||||
"invoiceReference": null,
|
||||
"invoiceAmount": null,
|
||||
"invoiceAmountTTC": null,
|
||||
"dueDate": null,
|
||||
"reconciled": false,
|
||||
"validatedAt": null,
|
||||
"updatedAt": "2026-10-08T07:42:00.000Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Montants en nombres (euros), dates `YYYY-MM-DD`, horodatages ISO 8601 UTC.
|
||||
|
||||
### `GET /deliveries/:id` — une livraison
|
||||
|
||||
Même objet qu'un élément de `items`. Inconnue : `404`.
|
||||
|
||||
### `PATCH /deliveries/:id` — écrire la facture
|
||||
|
||||
Tous les champs sont facultatifs, au moins un est requis ; tout autre champ est refusé.
|
||||
|
||||
| Champ | Type | Effet |
|
||||
|--------------------|-------------------------|------------------------------------------------------------------|
|
||||
| `invoiceReference` | texte (100 car.) / null | Référence facture ; `null` ou `""` l'efface |
|
||||
| `invoiceAmount` | nombre / texte / null | Montant HT. Accepte `1234.5`, `"1234,50"`, `"1 234,50"` |
|
||||
| `invoiceAmountTTC` | nombre / texte / null | Montant TTC, mêmes formats |
|
||||
| `dueDate` | `YYYY-MM-DD` / null | Échéance. Si absente et que la référence change, elle est reprise de NocoDB comme dans le webUI |
|
||||
| `reconciled` | booléen | `true` valide le rapprochement, `false` le dévalide |
|
||||
|
||||
```bash
|
||||
curl -X PATCH https://logiflow.example/api/ext/v1/deliveries/812 \
|
||||
-H "X-API-Key: $LOGIFLOW_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"invoiceReference":"FAC-2026-0042","invoiceAmount":"1234,50"}'
|
||||
```
|
||||
|
||||
Réponse : la livraison mise à jour.
|
||||
|
||||
Règles :
|
||||
|
||||
- livraison non livrée : `409` ;
|
||||
- livraison déjà rapprochée : sa facture est figée (`409`). Envoyer
|
||||
`"reconciled": false` dans la même requête pour la dévalider et la modifier ;
|
||||
- corps invalide : `400` avec le détail par champ.
|
||||
|
||||
## Exemple : rapprocher une facture reçue
|
||||
|
||||
1. `GET /suppliers` → retrouver l'`id` du fournisseur via son `code`.
|
||||
2. `GET /deliveries?supplierId=4&blNumber=BL-4567` → récupérer l'`id` de la livraison.
|
||||
3. `PATCH /deliveries/812` avec `invoiceReference` et `invoiceAmount`
|
||||
(ajouter `"reconciled": true` pour valider directement).
|
||||
@@ -0,0 +1,73 @@
|
||||
// Normalisation des dates d'échéance renvoyées par NocoDB (formats variés)
|
||||
// vers YYYY-MM-DD. Partagée par les routes web et l'API externe.
|
||||
export function normalizeDateString(dateString: string | null | undefined): string | null {
|
||||
if (!dateString || typeof dateString !== 'string') return null;
|
||||
|
||||
const trimmed = dateString.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
try {
|
||||
// Si déjà au format ISO (YYYY-MM-DD), le retourner tel quel
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
// Format slash ou tiret : DD/MM/YYYY, MM/DD/YYYY, DD-MM-YYYY, MM-DD-YYYY
|
||||
const slashMatch = trimmed.match(/^(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{4})$/);
|
||||
if (slashMatch) {
|
||||
const [, first, second, year] = slashMatch;
|
||||
const firstNum = parseInt(first);
|
||||
const secondNum = parseInt(second);
|
||||
|
||||
// Validation basique des valeurs
|
||||
if (firstNum > 31 || secondNum > 31 || firstNum === 0 || secondNum === 0) {
|
||||
console.warn(`⚠️ Date invalide (valeurs hors limites): ${trimmed}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Déterminer le format en fonction des valeurs
|
||||
let day: string, month: string;
|
||||
|
||||
if (firstNum > 12) {
|
||||
// first > 12 → forcément DD/MM (format français/européen)
|
||||
day = first.padStart(2, '0');
|
||||
month = second.padStart(2, '0');
|
||||
} else if (secondNum > 12) {
|
||||
// second > 12 → forcément MM/DD (format américain)
|
||||
day = second.padStart(2, '0');
|
||||
month = first.padStart(2, '0');
|
||||
} else {
|
||||
// Ambiguïté (les deux < 12) → on assume format français DD/MM par défaut
|
||||
// Pour être plus sûr, on pourrait vérifier la configuration du groupe/locale
|
||||
day = first.padStart(2, '0');
|
||||
month = second.padStart(2, '0');
|
||||
}
|
||||
|
||||
// Validation finale : mois entre 1-12, jour entre 1-31
|
||||
const monthNum = parseInt(month);
|
||||
const dayNum = parseInt(day);
|
||||
if (monthNum < 1 || monthNum > 12 || dayNum < 1 || dayNum > 31) {
|
||||
console.warn(`⚠️ Date invalide après parsing: ${trimmed} → month=${month}, day=${day}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
// Essayer de parser avec Date (format ISO complet avec heures)
|
||||
const date = new Date(trimmed);
|
||||
if (!isNaN(date.getTime())) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
// Si aucun format reconnu, retourner null (ne pas persister une date invalide)
|
||||
console.warn(`⚠️ Format de date non reconnu: ${trimmed}`);
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur normalisation date:', error);
|
||||
return null; // Retourner null en cas d'erreur pour éviter de persister des données invalides
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
// API externe de rapprochement BL / factures.
|
||||
//
|
||||
// Permet à un outil tiers (comptabilité, n8n, script...) de lire les magasins,
|
||||
// les fournisseurs et les livraisons livrées (numéros de BL), puis d'écrire la
|
||||
// référence et le montant de la facture sur une livraison.
|
||||
//
|
||||
// Authentification par clé d'API, indépendante des sessions du webUI :
|
||||
// - variable d'environnement EXTERNAL_API_KEYS (une ou plusieurs clés
|
||||
// séparées par des virgules) ; vide => API désactivée ;
|
||||
// - clé transmise dans l'en-tête "X-API-Key" ou "Authorization: Bearer <clé>".
|
||||
//
|
||||
// Documentation : docs/API-RAPPROCHEMENT.md
|
||||
|
||||
import type { Express, Request, Response, NextFunction } from "express";
|
||||
import { createHash, timingSafeEqual } from "crypto";
|
||||
import { z } from "zod";
|
||||
import { storage } from "./storage";
|
||||
import { normalizeDateString } from "./dateUtils";
|
||||
import { invoiceVerificationService } from "./invoiceVerification";
|
||||
|
||||
export const EXTERNAL_API_PREFIX = "/api/ext/v1";
|
||||
|
||||
const MAX_LIMIT = 500;
|
||||
const DEFAULT_LIMIT = 100;
|
||||
|
||||
function digest(value: string): Buffer {
|
||||
return createHash("sha256").update(value).digest();
|
||||
}
|
||||
|
||||
// Clés relues à chaque requête : une rotation via l'environnement ne demande
|
||||
// qu'un redémarrage, et aucune clé n'est gardée en clair plus que nécessaire.
|
||||
function configuredKeyDigests(): Buffer[] {
|
||||
return (process.env.EXTERNAL_API_KEYS || "")
|
||||
.split(",")
|
||||
.map((k) => k.trim())
|
||||
.filter((k) => k.length > 0)
|
||||
.map(digest);
|
||||
}
|
||||
|
||||
function extractKey(req: Request): string | null {
|
||||
const header = req.headers["x-api-key"];
|
||||
if (typeof header === "string" && header.trim()) return header.trim();
|
||||
|
||||
const auth = req.headers.authorization;
|
||||
if (typeof auth === "string" && auth.toLowerCase().startsWith("bearer ")) {
|
||||
const token = auth.slice(7).trim();
|
||||
if (token) return token;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function requireApiKey(req: Request, res: Response, next: NextFunction) {
|
||||
const keys = configuredKeyDigests();
|
||||
if (keys.length === 0) {
|
||||
return res.status(503).json({ error: "API externe désactivée (EXTERNAL_API_KEYS non défini)" });
|
||||
}
|
||||
|
||||
const provided = extractKey(req);
|
||||
// Comparaison à temps constant sur les empreintes (longueur fixe)
|
||||
const providedDigest = provided ? digest(provided) : null;
|
||||
const valid = providedDigest !== null && keys.some((k) => timingSafeEqual(k, providedDigest));
|
||||
|
||||
if (!valid) {
|
||||
console.warn(`🚨 [EXT-API] Clé d'API invalide ou absente : ${req.method} ${req.path} depuis ${req.ip}`);
|
||||
return res.status(401).json({ error: "Clé d'API invalide ou absente" });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
// Montant : nombre, ou chaîne au format "1234.56", "1234,56" ou "1 234,56"
|
||||
const amountSchema = z
|
||||
.union([z.number(), z.string()])
|
||||
.nullable()
|
||||
.transform((value, ctx) => {
|
||||
if (value === null) return null;
|
||||
const raw = typeof value === "number" ? String(value) : value.replace(/[\s ]/g, "").replace(",", ".");
|
||||
if (raw === "") return null;
|
||||
const num = Number(raw);
|
||||
if (!Number.isFinite(num) || Math.abs(num) >= 1e8) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Montant invalide" });
|
||||
return z.NEVER;
|
||||
}
|
||||
return num.toFixed(2); // colonne decimal(10,2)
|
||||
});
|
||||
|
||||
const updateInvoiceSchema = z
|
||||
.object({
|
||||
invoiceReference: z.string().trim().max(100).nullable().optional(),
|
||||
invoiceAmount: amountSchema.optional(),
|
||||
invoiceAmountTTC: amountSchema.optional(),
|
||||
dueDate: z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/, "Format attendu : YYYY-MM-DD")
|
||||
.nullable()
|
||||
.optional(),
|
||||
reconciled: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
.refine((body) => Object.keys(body).length > 0, { message: "Aucun champ à mettre à jour" });
|
||||
|
||||
const INVOICE_FIELDS = ["invoiceReference", "invoiceAmount", "invoiceAmountTTC", "dueDate"] as const;
|
||||
|
||||
function toNumber(value: unknown): number | null {
|
||||
if (value === null || value === undefined || value === "") return null;
|
||||
const num = Number(value);
|
||||
return Number.isFinite(num) ? num : null;
|
||||
}
|
||||
|
||||
function toIsoDate(value: unknown): string | null {
|
||||
if (!value) return null;
|
||||
const date = value instanceof Date ? value : new Date(value as string);
|
||||
return isNaN(date.getTime()) ? null : date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function toIsoDateTime(value: unknown): string | null {
|
||||
if (!value) return null;
|
||||
const date = value instanceof Date ? value : new Date(value as string);
|
||||
return isNaN(date.getTime()) ? null : date.toISOString();
|
||||
}
|
||||
|
||||
// Format public d'une livraison : champs utiles au rapprochement uniquement
|
||||
function toApiDelivery(d: any) {
|
||||
return {
|
||||
id: d.id,
|
||||
storeId: d.groupId,
|
||||
storeName: d.group?.name ?? null,
|
||||
supplierId: d.supplierId,
|
||||
supplierName: d.supplier?.name ?? null,
|
||||
supplierCode: d.supplier?.codefou ?? null,
|
||||
status: d.status,
|
||||
scheduledDate: toIsoDate(d.scheduledDate),
|
||||
deliveredDate: toIsoDateTime(d.deliveredDate),
|
||||
blNumber: d.blNumber ?? null,
|
||||
blAmount: toNumber(d.blAmount),
|
||||
invoiceReference: d.invoiceReference ?? null,
|
||||
invoiceAmount: toNumber(d.invoiceAmount),
|
||||
invoiceAmountTTC: toNumber(d.invoiceAmountTTC),
|
||||
dueDate: toIsoDate(d.dueDate),
|
||||
reconciled: !!d.reconciled,
|
||||
validatedAt: toIsoDateTime(d.validatedAt),
|
||||
updatedAt: toIsoDateTime(d.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
function parseIntParam(value: unknown): number | undefined | null {
|
||||
if (value === undefined || value === "") return undefined;
|
||||
const num = Number(value);
|
||||
return Number.isInteger(num) && num > 0 ? num : null; // null = invalide
|
||||
}
|
||||
|
||||
function parseBoolParam(value: unknown): boolean | undefined | null {
|
||||
if (value === undefined || value === "") return undefined;
|
||||
if (value === "true") return true;
|
||||
if (value === "false") return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseDateParam(value: unknown): string | undefined | null {
|
||||
if (value === undefined || value === "") return undefined;
|
||||
return typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value) ? value : null;
|
||||
}
|
||||
|
||||
// Échéance lue dans NocoDB pour une référence facture (même logique que le webUI)
|
||||
async function lookupDueDate(invoiceReference: string, groupId: number, reconciled: boolean): Promise<Date | null> {
|
||||
try {
|
||||
const result = await invoiceVerificationService.verifyInvoice(invoiceReference, groupId, true, reconciled);
|
||||
const normalized = result.exists ? normalizeDateString(result.dueDate) : null;
|
||||
return normalized ? new Date(normalized) : null;
|
||||
} catch (error) {
|
||||
console.error("❌ [EXT-API] Erreur récupération échéance NocoDB:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function registerExternalApi(app: Express) {
|
||||
const base = EXTERNAL_API_PREFIX;
|
||||
app.use(base, requireApiKey);
|
||||
|
||||
// Magasins
|
||||
app.get(`${base}/stores`, async (_req, res) => {
|
||||
try {
|
||||
const groups = await storage.getGroups();
|
||||
res.json(groups.map((g: any) => ({ id: g.id, name: g.name })));
|
||||
} catch (error) {
|
||||
console.error("❌ [EXT-API] stores:", error);
|
||||
res.status(500).json({ error: "Erreur lors de la lecture des magasins" });
|
||||
}
|
||||
});
|
||||
|
||||
// Fournisseurs
|
||||
app.get(`${base}/suppliers`, async (_req, res) => {
|
||||
try {
|
||||
const suppliers = await storage.getSuppliers();
|
||||
res.json(
|
||||
suppliers.map((s: any) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
code: s.codefou ?? null,
|
||||
paymentMethod: s.paymentMethod ?? null,
|
||||
automaticReconciliation: !!s.automaticReconciliation,
|
||||
})),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("❌ [EXT-API] suppliers:", error);
|
||||
res.status(500).json({ error: "Erreur lors de la lecture des fournisseurs" });
|
||||
}
|
||||
});
|
||||
|
||||
// Livraisons livrées (périmètre du rapprochement BL / factures)
|
||||
app.get(`${base}/deliveries`, async (req, res) => {
|
||||
const storeId = parseIntParam(req.query.storeId);
|
||||
const supplierId = parseIntParam(req.query.supplierId);
|
||||
const reconciled = parseBoolParam(req.query.reconciled);
|
||||
const hasBl = parseBoolParam(req.query.hasBl);
|
||||
const hasInvoice = parseBoolParam(req.query.hasInvoice);
|
||||
const from = parseDateParam(req.query.from);
|
||||
const to = parseDateParam(req.query.to);
|
||||
const limit = parseIntParam(req.query.limit);
|
||||
const offsetRaw = req.query.offset;
|
||||
const offset = offsetRaw === undefined || offsetRaw === "" ? 0 : Number(offsetRaw);
|
||||
const blNumber = typeof req.query.blNumber === "string" ? req.query.blNumber.trim().toLowerCase() : "";
|
||||
|
||||
const invalid = Object.entries({ storeId, supplierId, reconciled, hasBl, hasInvoice, from, to, limit })
|
||||
.filter(([, v]) => v === null)
|
||||
.map(([k]) => k);
|
||||
if (!Number.isInteger(offset) || offset < 0) invalid.push("offset");
|
||||
if (invalid.length > 0) {
|
||||
return res.status(400).json({ error: `Paramètre(s) invalide(s) : ${invalid.join(", ")}` });
|
||||
}
|
||||
|
||||
try {
|
||||
let rows: any[] = await storage.getDeliveries(storeId ? [storeId] : undefined, { status: "delivered" });
|
||||
|
||||
rows = rows.filter((d) => {
|
||||
if (supplierId && d.supplierId !== supplierId) return false;
|
||||
if (reconciled !== undefined && !!d.reconciled !== reconciled) return false;
|
||||
if (hasBl !== undefined && !!d.blNumber?.trim() !== hasBl) return false;
|
||||
if (hasInvoice !== undefined && !!d.invoiceReference?.trim() !== hasInvoice) return false;
|
||||
if (blNumber && (d.blNumber || "").trim().toLowerCase() !== blNumber) return false;
|
||||
if (from || to) {
|
||||
// Date de livraison effective, sinon date prévue
|
||||
const day = toIsoDate(d.deliveredDate) ?? toIsoDate(d.scheduledDate);
|
||||
if (!day) return false;
|
||||
if (from && day < from) return false;
|
||||
if (to && day > to) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
rows.sort((a, b) => {
|
||||
const da = toIsoDateTime(a.deliveredDate) ?? toIsoDate(a.scheduledDate) ?? "";
|
||||
const db = toIsoDateTime(b.deliveredDate) ?? toIsoDate(b.scheduledDate) ?? "";
|
||||
return da < db ? 1 : da > db ? -1 : b.id - a.id;
|
||||
});
|
||||
|
||||
const pageSize = Math.min(limit ?? DEFAULT_LIMIT, MAX_LIMIT);
|
||||
res.json({
|
||||
total: rows.length,
|
||||
limit: pageSize,
|
||||
offset,
|
||||
items: rows.slice(offset, offset + pageSize).map(toApiDelivery),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("❌ [EXT-API] deliveries:", error);
|
||||
res.status(500).json({ error: "Erreur lors de la lecture des livraisons" });
|
||||
}
|
||||
});
|
||||
|
||||
app.get(`${base}/deliveries/:id`, async (req, res) => {
|
||||
const id = parseIntParam(req.params.id);
|
||||
if (!id) return res.status(400).json({ error: "Identifiant invalide" });
|
||||
|
||||
try {
|
||||
const delivery = await storage.getDelivery(id);
|
||||
if (!delivery) return res.status(404).json({ error: "Livraison introuvable" });
|
||||
res.json(toApiDelivery(delivery));
|
||||
} catch (error) {
|
||||
console.error("❌ [EXT-API] delivery:", error);
|
||||
res.status(500).json({ error: "Erreur lors de la lecture de la livraison" });
|
||||
}
|
||||
});
|
||||
|
||||
// Écriture de la facture (référence, montants, échéance) et validation optionnelle
|
||||
app.patch(`${base}/deliveries/:id`, async (req, res) => {
|
||||
const id = parseIntParam(req.params.id);
|
||||
if (!id) return res.status(400).json({ error: "Identifiant invalide" });
|
||||
|
||||
const parsed = updateInvoiceSchema.safeParse(req.body ?? {});
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({
|
||||
error: "Corps de requête invalide",
|
||||
details: parsed.error.issues.map((i) => ({ field: i.path.join("."), message: i.message })),
|
||||
});
|
||||
}
|
||||
const body = parsed.data;
|
||||
|
||||
try {
|
||||
const delivery = await storage.getDelivery(id);
|
||||
if (!delivery) return res.status(404).json({ error: "Livraison introuvable" });
|
||||
|
||||
if (delivery.status !== "delivered") {
|
||||
return res.status(409).json({ error: "Seules les livraisons livrées peuvent être rapprochées" });
|
||||
}
|
||||
|
||||
const touchesInvoice = INVOICE_FIELDS.some((f) => body[f] !== undefined);
|
||||
// Une livraison rapprochée est figée, comme dans le webUI : il faut la
|
||||
// dévalider (reconciled: false) pour modifier sa facture.
|
||||
if (delivery.reconciled && touchesInvoice && body.reconciled !== false) {
|
||||
return res.status(409).json({
|
||||
error: "Livraison déjà rapprochée : envoyer reconciled: false pour la dévalider avant de modifier la facture",
|
||||
});
|
||||
}
|
||||
|
||||
const update: Record<string, any> = {};
|
||||
if (body.invoiceReference !== undefined) update.invoiceReference = body.invoiceReference || null;
|
||||
if (body.invoiceAmount !== undefined) update.invoiceAmount = body.invoiceAmount;
|
||||
if (body.invoiceAmountTTC !== undefined) update.invoiceAmountTTC = body.invoiceAmountTTC;
|
||||
|
||||
if (body.dueDate !== undefined) {
|
||||
update.dueDate = body.dueDate ? new Date(body.dueDate) : null;
|
||||
} else if (update.invoiceReference !== undefined && update.invoiceReference !== delivery.invoiceReference) {
|
||||
// Échéance non fournie : reprise depuis NocoDB, ou vidée avec la référence
|
||||
update.dueDate = update.invoiceReference
|
||||
? await lookupDueDate(update.invoiceReference, delivery.groupId, !!delivery.reconciled)
|
||||
: null;
|
||||
}
|
||||
|
||||
if (body.reconciled !== undefined) {
|
||||
update.reconciled = body.reconciled;
|
||||
update.validatedAt = body.reconciled ? new Date() : null;
|
||||
}
|
||||
|
||||
await storage.updateDelivery(id, update);
|
||||
console.log(`🔌 [EXT-API] Livraison #${id} mise à jour : ${Object.keys(update).join(", ")}`);
|
||||
|
||||
const updated = await storage.getDelivery(id);
|
||||
res.json(toApiDelivery(updated));
|
||||
} catch (error) {
|
||||
console.error("❌ [EXT-API] update delivery:", error);
|
||||
res.status(500).json({ error: "Erreur lors de la mise à jour de la livraison" });
|
||||
}
|
||||
});
|
||||
|
||||
// Toute autre route sous le préfixe : 404 JSON plutôt que la page du webUI
|
||||
app.use(base, (_req, res) => {
|
||||
res.status(404).json({ error: "Route inconnue" });
|
||||
});
|
||||
}
|
||||
+5
-73
@@ -4,6 +4,8 @@ import { storage } from "./storage";
|
||||
import { setupLocalAuth, requireAuth } from "./localAuth";
|
||||
import { requireModulePermission, requireAdmin, requirePermission } from "./permissions";
|
||||
import { stripSmtpPassword } from "./sanitize";
|
||||
import { normalizeDateString } from "./dateUtils";
|
||||
import { registerExternalApi } from "./externalApi";
|
||||
|
||||
// Corps de requête sans les champs secrets : pour les logs uniquement
|
||||
function redactBody(body: any): any {
|
||||
@@ -29,79 +31,6 @@ const require = createRequire(import.meta.url);
|
||||
console.log('🔍 Using development storage and authentication');
|
||||
|
||||
|
||||
// Fonction de normalisation des dates pour gérer différents formats de NocoDB
|
||||
function normalizeDateString(dateString: string | null | undefined): string | null {
|
||||
if (!dateString || typeof dateString !== 'string') return null;
|
||||
|
||||
const trimmed = dateString.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
try {
|
||||
// Si déjà au format ISO (YYYY-MM-DD), le retourner tel quel
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
// Format slash ou tiret : DD/MM/YYYY, MM/DD/YYYY, DD-MM-YYYY, MM-DD-YYYY
|
||||
const slashMatch = trimmed.match(/^(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{4})$/);
|
||||
if (slashMatch) {
|
||||
const [, first, second, year] = slashMatch;
|
||||
const firstNum = parseInt(first);
|
||||
const secondNum = parseInt(second);
|
||||
|
||||
// Validation basique des valeurs
|
||||
if (firstNum > 31 || secondNum > 31 || firstNum === 0 || secondNum === 0) {
|
||||
console.warn(`⚠️ Date invalide (valeurs hors limites): ${trimmed}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Déterminer le format en fonction des valeurs
|
||||
let day: string, month: string;
|
||||
|
||||
if (firstNum > 12) {
|
||||
// first > 12 → forcément DD/MM (format français/européen)
|
||||
day = first.padStart(2, '0');
|
||||
month = second.padStart(2, '0');
|
||||
} else if (secondNum > 12) {
|
||||
// second > 12 → forcément MM/DD (format américain)
|
||||
day = second.padStart(2, '0');
|
||||
month = first.padStart(2, '0');
|
||||
} else {
|
||||
// Ambiguïté (les deux < 12) → on assume format français DD/MM par défaut
|
||||
// Pour être plus sûr, on pourrait vérifier la configuration du groupe/locale
|
||||
day = first.padStart(2, '0');
|
||||
month = second.padStart(2, '0');
|
||||
}
|
||||
|
||||
// Validation finale : mois entre 1-12, jour entre 1-31
|
||||
const monthNum = parseInt(month);
|
||||
const dayNum = parseInt(day);
|
||||
if (monthNum < 1 || monthNum > 12 || dayNum < 1 || dayNum > 31) {
|
||||
console.warn(`⚠️ Date invalide après parsing: ${trimmed} → month=${month}, day=${day}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
// Essayer de parser avec Date (format ISO complet avec heures)
|
||||
const date = new Date(trimmed);
|
||||
if (!isNaN(date.getTime())) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
// Si aucun format reconnu, retourner null (ne pas persister une date invalide)
|
||||
console.warn(`⚠️ Format de date non reconnu: ${trimmed}`);
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur normalisation date:', error);
|
||||
return null; // Retourner null en cas d'erreur pour éviter de persister des données invalides
|
||||
}
|
||||
}
|
||||
|
||||
// Simple hash password function using crypto
|
||||
async function hashPasswordSimple(password: string) {
|
||||
const crypto = await import('crypto');
|
||||
@@ -165,6 +94,9 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
const environment = process.env.NODE_ENV || 'development';
|
||||
console.log('🌍 Environment detected:', environment);
|
||||
|
||||
// API externe de rapprochement (clé d'API, hors session) : docs/API-RAPPROCHEMENT.md
|
||||
registerExternalApi(app);
|
||||
|
||||
// Le mot de passe SMTP des magasins ne doit jamais sortir du serveur.
|
||||
// Les objets "group" sont joints à de nombreuses réponses (livraisons,
|
||||
// commandes, utilisateurs...) : plutôt que de filtrer chaque requête, on
|
||||
|
||||
@@ -50,6 +50,7 @@ export function setupCsrfProtection(app: Express) {
|
||||
const csrfExemptPaths = [
|
||||
'/api/health',
|
||||
'/api/webhook', // External webhook callbacks
|
||||
'/api/ext/', // API externe : authentifiée par clé d'API, sans cookie de session
|
||||
];
|
||||
|
||||
if (csrfExemptPaths.some(path => req.path.startsWith(path))) {
|
||||
|
||||
Reference in new issue
Block a user