Initial commit - Facturation SANTINOVA

This commit is contained in:
manus-admin
2026-04-23 04:49:21 -04:00
commit 6ab833945c
55 changed files with 12642 additions and 0 deletions

7
backend/.env.example Normal file
View File

@@ -0,0 +1,7 @@
PORT=3001
DATABASE_URL=mysql://root:santinova_db_pass@localhost:3306/facturation_santinova
JWT_SECRET=santinova-jwt-secret-change-in-production
JWT_EXPIRES_IN=24h
UPLOAD_DIR=./uploads
OPENAI_API_KEY=your-openai-api-key-here
NODE_ENV=development

11
backend/drizzle.config.ts Normal file
View File

@@ -0,0 +1,11 @@
import type { Config } from 'drizzle-kit';
import 'dotenv/config';
export default {
schema: './src/db/schema.ts',
out: './drizzle',
dialect: 'mysql',
dbCredentials: {
url: process.env.DATABASE_URL || 'mysql://root:santinova_db_pass@localhost:3306/facturation_santinova',
},
} satisfies Config;

3500
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

39
backend/package.json Normal file
View File

@@ -0,0 +1,39 @@
{
"name": "facturation-santinova-backend",
"version": "1.0.0",
"description": "Backend API pour Facturation SANTINOVA",
"main": "dist/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"db:generate": "drizzle-kit generate",
"db:migrate": "tsx src/db/migrate.ts",
"db:seed": "tsx src/db/seed.ts"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"drizzle-orm": "^0.35.0",
"express": "^4.21.0",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"mysql2": "^3.11.0",
"openai": "^4.60.0",
"uuid": "^10.0.0",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.7",
"@types/multer": "^1.4.12",
"@types/node": "^22.0.0",
"@types/uuid": "^10.0.0",
"drizzle-kit": "^0.27.0",
"tsx": "^4.19.0",
"typescript": "^5.6.0"
}
}

View File

@@ -0,0 +1,27 @@
import { drizzle } from 'drizzle-orm/mysql2';
import mysql from 'mysql2/promise';
import * as schema from '../db/schema';
let db: ReturnType<typeof drizzle>;
let pool: mysql.Pool;
export async function initDatabase() {
const dbUrl = process.env.DATABASE_URL || 'mysql://root:santinova_db_pass@localhost:3306/facturation_santinova';
pool = mysql.createPool(dbUrl);
db = drizzle(pool, { schema, mode: 'default' });
console.log('✅ Connexion à la base de données établie');
return db;
}
export function getDb() {
if (!db) {
throw new Error('Base de données non initialisée. Appelez initDatabase() d\'abord.');
}
return db;
}
export function getPool() {
return pool;
}

196
backend/src/db/migrate.ts Normal file
View File

@@ -0,0 +1,196 @@
import 'dotenv/config';
import mysql from 'mysql2/promise';
import { drizzle } from 'drizzle-orm/mysql2';
import { sql } from 'drizzle-orm';
import * as schema from './schema';
async function migrate() {
const dbUrl = process.env.DATABASE_URL || 'mysql://root:santinova_db_pass@localhost:3306/facturation_santinova';
// Parse URL to get database name
const url = new URL(dbUrl);
const dbName = url.pathname.slice(1);
const baseUrl = `${url.protocol}//${url.username}:${url.password}@${url.host}`;
// Create database if not exists
const tempPool = mysql.createPool(baseUrl);
await tempPool.execute(`CREATE DATABASE IF NOT EXISTS \`${dbName}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`);
await tempPool.end();
// Connect to the database
const pool = mysql.createPool(dbUrl);
const db = drizzle(pool, { schema, mode: 'default' });
console.log('🔄 Création des tables...');
// Create tables using raw SQL
await pool.execute(`
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL,
role ENUM('admin', 'approbateur', 'validateur', 'operateur') NOT NULL DEFAULT 'operateur',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.execute(`
CREATE TABLE IF NOT EXISTS suppliers (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
siret VARCHAR(20),
address TEXT,
city VARCHAR(100),
postal_code VARCHAR(10),
country VARCHAR(100) DEFAULT 'France',
email VARCHAR(255),
phone VARCHAR(20),
contact_name VARCHAR(255),
iban VARCHAR(50),
notes TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL,
INDEX supplier_name_idx (name),
INDEX supplier_siret_idx (siret)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.execute(`
CREATE TABLE IF NOT EXISTS purchase_orders (
id INT AUTO_INCREMENT PRIMARY KEY,
order_number VARCHAR(100) NOT NULL UNIQUE,
supplier_id INT,
supplier_name VARCHAR(255),
order_date DATETIME NOT NULL,
expected_delivery_date DATETIME,
amount_ht DECIMAL(12,2),
amount_tva DECIMAL(12,2),
amount_ttc DECIMAL(12,2),
status ENUM('brouillon', 'envoyee', 'recue', 'facturee', 'annulee') NOT NULL DEFAULT 'brouillon',
description TEXT,
notes TEXT,
created_by INT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL,
INDEX po_order_number_idx (order_number),
INDEX po_supplier_idx (supplier_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.execute(`
CREATE TABLE IF NOT EXISTS invoices (
id INT AUTO_INCREMENT PRIMARY KEY,
invoice_number VARCHAR(100),
supplier_id INT,
supplier_name VARCHAR(255),
supplier_siret VARCHAR(20),
supplier_address TEXT,
invoice_date DATETIME,
due_date DATETIME,
reception_date DATETIME NOT NULL,
amount_ht DECIMAL(12,2),
amount_tva DECIMAL(12,2),
amount_ttc DECIMAL(12,2),
tva_rate DECIMAL(5,2),
currency VARCHAR(3) DEFAULT 'EUR',
status ENUM('recue', 'en_verification', 'validee', 'approuvee', 'payee', 'rejetee', 'archivee') NOT NULL DEFAULT 'recue',
source ENUM('upload', 'email', 'portail', 'scan') NOT NULL DEFAULT 'upload',
original_file_name VARCHAR(500),
file_path VARCHAR(1000),
file_type VARCHAR(50),
ocr_raw_data JSON,
ocr_confidence DECIMAL(5,2),
purchase_order_id INT,
matching_status ENUM('non_rapproche', 'rapproche', 'ecart_detecte') DEFAULT 'non_rapproche',
matching_notes TEXT,
notes TEXT,
full_text TEXT,
created_by INT,
validated_by INT,
approved_by INT,
validated_at DATETIME,
approved_at DATETIME,
paid_at DATETIME,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL,
INDEX invoice_status_idx (status),
INDEX invoice_supplier_idx (supplier_id),
INDEX invoice_due_date_idx (due_date),
INDEX invoice_number_idx (invoice_number),
FULLTEXT INDEX invoice_fulltext_idx (full_text)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.execute(`
CREATE TABLE IF NOT EXISTS invoice_lines (
id INT AUTO_INCREMENT PRIMARY KEY,
invoice_id INT NOT NULL,
description TEXT,
quantity DECIMAL(10,3),
unit_price DECIMAL(12,2),
amount_ht DECIMAL(12,2),
tva_rate DECIMAL(5,2),
amount_tva DECIMAL(12,2),
amount_ttc DECIMAL(12,2),
line_order INT DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
FOREIGN KEY (invoice_id) REFERENCES invoices(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.execute(`
CREATE TABLE IF NOT EXISTS purchase_order_lines (
id INT AUTO_INCREMENT PRIMARY KEY,
purchase_order_id INT NOT NULL,
description TEXT,
quantity DECIMAL(10,3),
unit_price DECIMAL(12,2),
amount_ht DECIMAL(12,2),
line_order INT DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
FOREIGN KEY (purchase_order_id) REFERENCES purchase_orders(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.execute(`
CREATE TABLE IF NOT EXISTS audit_log (
id INT AUTO_INCREMENT PRIMARY KEY,
entity_type VARCHAR(50) NOT NULL,
entity_id INT NOT NULL,
action VARCHAR(100) NOT NULL,
details JSON,
user_id INT,
user_name VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
INDEX audit_entity_idx (entity_type, entity_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.execute(`
CREATE TABLE IF NOT EXISTS notifications (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
title VARCHAR(255) NOT NULL,
message TEXT NOT NULL,
type ENUM('info', 'warning', 'error', 'success') DEFAULT 'info',
is_read BOOLEAN NOT NULL DEFAULT FALSE,
link VARCHAR(500),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
console.log('✅ Tables créées avec succès');
await pool.end();
process.exit(0);
}
migrate().catch((err) => {
console.error('❌ Erreur de migration:', err);
process.exit(1);
});

265
backend/src/db/schema.ts Normal file
View File

@@ -0,0 +1,265 @@
import {
mysqlTable,
varchar,
int,
text,
decimal,
datetime,
boolean,
mysqlEnum,
json,
timestamp,
index,
} from 'drizzle-orm/mysql-core';
import { relations } from 'drizzle-orm';
// ============================================================
// UTILISATEURS
// ============================================================
export const users = mysqlTable('users', {
id: int('id').primaryKey().autoincrement(),
email: varchar('email', { length: 255 }).notNull().unique(),
password: varchar('password', { length: 255 }).notNull(),
firstName: varchar('first_name', { length: 100 }).notNull(),
lastName: varchar('last_name', { length: 100 }).notNull(),
role: mysqlEnum('role', ['admin', 'approbateur', 'validateur', 'operateur']).notNull().default('operateur'),
isActive: boolean('is_active').notNull().default(true),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow().notNull(),
});
// ============================================================
// FOURNISSEURS
// ============================================================
export const suppliers = mysqlTable('suppliers', {
id: int('id').primaryKey().autoincrement(),
name: varchar('name', { length: 255 }).notNull(),
siret: varchar('siret', { length: 20 }),
address: text('address'),
city: varchar('city', { length: 100 }),
postalCode: varchar('postal_code', { length: 10 }),
country: varchar('country', { length: 100 }).default('France'),
email: varchar('email', { length: 255 }),
phone: varchar('phone', { length: 20 }),
contactName: varchar('contact_name', { length: 255 }),
iban: varchar('iban', { length: 50 }),
notes: text('notes'),
isActive: boolean('is_active').notNull().default(true),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow().notNull(),
}, (table) => ({
nameIdx: index('supplier_name_idx').on(table.name),
siretIdx: index('supplier_siret_idx').on(table.siret),
}));
// ============================================================
// FACTURES
// ============================================================
export const invoices = mysqlTable('invoices', {
id: int('id').primaryKey().autoincrement(),
invoiceNumber: varchar('invoice_number', { length: 100 }),
supplierId: int('supplier_id'),
supplierName: varchar('supplier_name', { length: 255 }),
supplierSiret: varchar('supplier_siret', { length: 20 }),
supplierAddress: text('supplier_address'),
invoiceDate: datetime('invoice_date'),
dueDate: datetime('due_date'),
receptionDate: datetime('reception_date').notNull(),
amountHT: decimal('amount_ht', { precision: 12, scale: 2 }),
amountTVA: decimal('amount_tva', { precision: 12, scale: 2 }),
amountTTC: decimal('amount_ttc', { precision: 12, scale: 2 }),
tvaRate: decimal('tva_rate', { precision: 5, scale: 2 }),
currency: varchar('currency', { length: 3 }).default('EUR'),
status: mysqlEnum('status', [
'recue',
'en_verification',
'validee',
'approuvee',
'payee',
'rejetee',
'archivee'
]).notNull().default('recue'),
source: mysqlEnum('source', ['upload', 'email', 'portail', 'scan']).notNull().default('upload'),
originalFileName: varchar('original_file_name', { length: 500 }),
filePath: varchar('file_path', { length: 1000 }),
fileType: varchar('file_type', { length: 50 }),
ocrRawData: json('ocr_raw_data'),
ocrConfidence: decimal('ocr_confidence', { precision: 5, scale: 2 }),
purchaseOrderId: int('purchase_order_id'),
matchingStatus: mysqlEnum('matching_status', ['non_rapproche', 'rapproche', 'ecart_detecte']).default('non_rapproche'),
matchingNotes: text('matching_notes'),
notes: text('notes'),
fullText: text('full_text'),
createdBy: int('created_by'),
validatedBy: int('validated_by'),
approvedBy: int('approved_by'),
validatedAt: datetime('validated_at'),
approvedAt: datetime('approved_at'),
paidAt: datetime('paid_at'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow().notNull(),
}, (table) => ({
statusIdx: index('invoice_status_idx').on(table.status),
supplierIdx: index('invoice_supplier_idx').on(table.supplierId),
dueDateIdx: index('invoice_due_date_idx').on(table.dueDate),
invoiceNumberIdx: index('invoice_number_idx').on(table.invoiceNumber),
}));
// ============================================================
// LIGNES DE FACTURE
// ============================================================
export const invoiceLines = mysqlTable('invoice_lines', {
id: int('id').primaryKey().autoincrement(),
invoiceId: int('invoice_id').notNull(),
description: text('description'),
quantity: decimal('quantity', { precision: 10, scale: 3 }),
unitPrice: decimal('unit_price', { precision: 12, scale: 2 }),
amountHT: decimal('amount_ht', { precision: 12, scale: 2 }),
tvaRate: decimal('tva_rate', { precision: 5, scale: 2 }),
amountTVA: decimal('amount_tva', { precision: 12, scale: 2 }),
amountTTC: decimal('amount_ttc', { precision: 12, scale: 2 }),
lineOrder: int('line_order').default(0),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
// ============================================================
// BONS DE COMMANDE
// ============================================================
export const purchaseOrders = mysqlTable('purchase_orders', {
id: int('id').primaryKey().autoincrement(),
orderNumber: varchar('order_number', { length: 100 }).notNull().unique(),
supplierId: int('supplier_id'),
supplierName: varchar('supplier_name', { length: 255 }),
orderDate: datetime('order_date').notNull(),
expectedDeliveryDate: datetime('expected_delivery_date'),
amountHT: decimal('amount_ht', { precision: 12, scale: 2 }),
amountTVA: decimal('amount_tva', { precision: 12, scale: 2 }),
amountTTC: decimal('amount_ttc', { precision: 12, scale: 2 }),
status: mysqlEnum('status', ['brouillon', 'envoyee', 'recue', 'facturee', 'annulee']).notNull().default('brouillon'),
description: text('description'),
notes: text('notes'),
createdBy: int('created_by'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow().notNull(),
}, (table) => ({
orderNumberIdx: index('po_order_number_idx').on(table.orderNumber),
supplierIdx: index('po_supplier_idx').on(table.supplierId),
}));
// ============================================================
// LIGNES DE BON DE COMMANDE
// ============================================================
export const purchaseOrderLines = mysqlTable('purchase_order_lines', {
id: int('id').primaryKey().autoincrement(),
purchaseOrderId: int('purchase_order_id').notNull(),
description: text('description'),
quantity: decimal('quantity', { precision: 10, scale: 3 }),
unitPrice: decimal('unit_price', { precision: 12, scale: 2 }),
amountHT: decimal('amount_ht', { precision: 12, scale: 2 }),
lineOrder: int('line_order').default(0),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
// ============================================================
// HISTORIQUE DES ACTIONS
// ============================================================
export const auditLog = mysqlTable('audit_log', {
id: int('id').primaryKey().autoincrement(),
entityType: varchar('entity_type', { length: 50 }).notNull(),
entityId: int('entity_id').notNull(),
action: varchar('action', { length: 100 }).notNull(),
details: json('details'),
userId: int('user_id'),
userName: varchar('user_name', { length: 255 }),
createdAt: timestamp('created_at').defaultNow().notNull(),
}, (table) => ({
entityIdx: index('audit_entity_idx').on(table.entityType, table.entityId),
}));
// ============================================================
// NOTIFICATIONS
// ============================================================
export const notifications = mysqlTable('notifications', {
id: int('id').primaryKey().autoincrement(),
userId: int('user_id').notNull(),
title: varchar('title', { length: 255 }).notNull(),
message: text('message').notNull(),
type: mysqlEnum('type', ['info', 'warning', 'error', 'success']).default('info'),
isRead: boolean('is_read').notNull().default(false),
link: varchar('link', { length: 500 }),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
// ============================================================
// RELATIONS
// ============================================================
export const usersRelations = relations(users, ({ many }) => ({
invoicesCreated: many(invoices),
notifications: many(notifications),
}));
export const suppliersRelations = relations(suppliers, ({ many }) => ({
invoices: many(invoices),
purchaseOrders: many(purchaseOrders),
}));
export const invoicesRelations = relations(invoices, ({ one, many }) => ({
supplier: one(suppliers, {
fields: [invoices.supplierId],
references: [suppliers.id],
}),
purchaseOrder: one(purchaseOrders, {
fields: [invoices.purchaseOrderId],
references: [purchaseOrders.id],
}),
creator: one(users, {
fields: [invoices.createdBy],
references: [users.id],
}),
lines: many(invoiceLines),
}));
export const invoiceLinesRelations = relations(invoiceLines, ({ one }) => ({
invoice: one(invoices, {
fields: [invoiceLines.invoiceId],
references: [invoices.id],
}),
}));
export const purchaseOrdersRelations = relations(purchaseOrders, ({ one, many }) => ({
supplier: one(suppliers, {
fields: [purchaseOrders.supplierId],
references: [suppliers.id],
}),
lines: many(purchaseOrderLines),
invoices: many(invoices),
}));
export const purchaseOrderLinesRelations = relations(purchaseOrderLines, ({ one }) => ({
purchaseOrder: one(purchaseOrders, {
fields: [purchaseOrderLines.purchaseOrderId],
references: [purchaseOrders.id],
}),
}));
export const notificationsRelations = relations(notifications, ({ one }) => ({
user: one(users, {
fields: [notifications.userId],
references: [users.id],
}),
}));

46
backend/src/db/seed.ts Normal file
View File

@@ -0,0 +1,46 @@
import 'dotenv/config';
import mysql from 'mysql2/promise';
import bcrypt from 'bcryptjs';
async function seed() {
const dbUrl = process.env.DATABASE_URL || 'mysql://root:santinova_db_pass@localhost:3306/facturation_santinova';
const pool = mysql.createPool(dbUrl);
console.log('🌱 Seeding de la base de données...');
// Créer l'utilisateur admin par défaut
const hashedPassword = await bcrypt.hash('Itinova69!', 12);
await pool.execute(`
INSERT IGNORE INTO users (email, password, first_name, last_name, role, is_active)
VALUES (?, ?, ?, ?, ?, ?)
`, ['adminItinova@santinova-soft.org', hashedPassword, 'Admin', 'Itinova', 'admin', true]);
console.log('✅ Utilisateur admin créé: adminItinova@santinova-soft.org');
// Créer quelques fournisseurs de démonstration
const suppliers = [
['Office Dépôt France', '32365094200034', '26 Bd de Villiers', 'Neuilly-sur-Seine', '92200', 'France', 'contact@officedepot.fr'],
['EDF Entreprises', '55208131766522', '22-30 Avenue de Wagram', 'Paris', '75008', 'France', 'pro@edf.fr'],
['Orange Business Services', '38012986600048', '1 Place des Droits de l\'Homme', 'Arcueil', '94110', 'France', 'contact@orange-business.com'],
['Boulanger Pro', '34800207600012', 'Avenue de la Motte', 'Lesquin', '59810', 'France', 'pro@boulanger.com'],
['Amazon Business', '48aborneE000000', '67 Boulevard du Général Leclerc', 'Clichy', '92110', 'France', 'business@amazon.fr'],
];
for (const s of suppliers) {
await pool.execute(`
INSERT IGNORE INTO suppliers (name, siret, address, city, postal_code, country, email)
VALUES (?, ?, ?, ?, ?, ?, ?)
`, s);
}
console.log('✅ Fournisseurs de démonstration créés');
await pool.end();
process.exit(0);
}
seed().catch((err) => {
console.error('❌ Erreur de seeding:', err);
process.exit(1);
});

77
backend/src/index.ts Normal file
View File

@@ -0,0 +1,77 @@
import 'dotenv/config';
import express from 'express';
import cors from 'cors';
import path from 'path';
import { initDatabase } from './config/database';
// Import routes
import authRoutes from './routes/auth';
import invoiceRoutes from './routes/invoices';
import supplierRoutes from './routes/suppliers';
import purchaseOrderRoutes from './routes/purchaseOrders';
import exportRoutes from './routes/exports';
import dashboardRoutes from './routes/dashboard';
import notificationRoutes from './routes/notifications';
const app = express();
const PORT = parseInt(process.env.PORT || '3001', 10);
// Middleware
app.use(cors({
origin: process.env.FRONTEND_URL || '*',
credentials: true,
}));
app.use(express.json({ limit: '50mb' }));
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
// Serve uploaded files
const uploadDir = process.env.UPLOAD_DIR || './uploads';
app.use('/uploads', express.static(path.resolve(uploadDir)));
// API Routes
app.use('/api/auth', authRoutes);
app.use('/api/invoices', invoiceRoutes);
app.use('/api/suppliers', supplierRoutes);
app.use('/api/purchase-orders', purchaseOrderRoutes);
app.use('/api/exports', exportRoutes);
app.use('/api/dashboard', dashboardRoutes);
app.use('/api/notifications', notificationRoutes);
// Health check
app.get('/api/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString(), version: '1.0.0' });
});
// Serve frontend in production
if (process.env.NODE_ENV === 'production') {
const frontendPath = path.join(__dirname, '../../frontend/dist');
app.use(express.static(frontendPath));
app.get('*', (req, res) => {
if (!req.path.startsWith('/api')) {
res.sendFile(path.join(frontendPath, 'index.html'));
}
});
}
// Error handling middleware
app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
console.error('Erreur non gérée:', err);
res.status(500).json({ error: 'Erreur interne du serveur' });
});
// Start server
async function start() {
try {
await initDatabase();
app.listen(PORT, '0.0.0.0', () => {
console.log(`🚀 Serveur Facturation SANTINOVA démarré sur le port ${PORT}`);
console.log(`📊 API disponible sur http://localhost:${PORT}/api`);
});
} catch (error) {
console.error('❌ Erreur au démarrage:', error);
process.exit(1);
}
}
start();

View File

@@ -0,0 +1,53 @@
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
export interface AuthRequest extends Request {
user?: {
id: number;
email: string;
role: string;
firstName: string;
lastName: string;
};
}
export function authenticate(req: AuthRequest, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Token d\'authentification requis' });
}
const token = authHeader.split(' ')[1];
try {
const secret = process.env.JWT_SECRET || 'santinova-jwt-secret';
const decoded = jwt.verify(token, secret) as any;
req.user = {
id: decoded.id,
email: decoded.email,
role: decoded.role,
firstName: decoded.firstName,
lastName: decoded.lastName,
};
next();
} catch (error) {
return res.status(401).json({ error: 'Token invalide ou expiré' });
}
}
export function authorize(...roles: string[]) {
return (req: AuthRequest, res: Response, next: NextFunction) => {
if (!req.user) {
return res.status(401).json({ error: 'Non authentifié' });
}
if (roles.length > 0 && !roles.includes(req.user.role)) {
return res.status(403).json({ error: 'Permissions insuffisantes' });
}
next();
};
}

View File

@@ -0,0 +1,61 @@
import multer from 'multer';
import path from 'path';
import { v4 as uuidv4 } from 'uuid';
import fs from 'fs';
const uploadDir = process.env.UPLOAD_DIR || './uploads';
// Ensure upload directory exists
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
const storage = multer.diskStorage({
destination: (req, file, cb) => {
const yearMonth = new Date().toISOString().slice(0, 7); // YYYY-MM
const destDir = path.join(uploadDir, yearMonth);
if (!fs.existsSync(destDir)) {
fs.mkdirSync(destDir, { recursive: true });
}
cb(null, destDir);
},
filename: (req, file, cb) => {
const ext = path.extname(file.originalname);
const uniqueName = `${uuidv4()}${ext}`;
cb(null, uniqueName);
},
});
const fileFilter = (req: any, file: Express.Multer.File, cb: multer.FileFilterCallback) => {
const allowedTypes = [
'application/pdf',
'image/jpeg',
'image/jpg',
'image/png',
'image/tiff',
'image/webp',
'image/bmp',
];
if (allowedTypes.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error(`Type de fichier non supporté: ${file.mimetype}. Formats acceptés: PDF, JPEG, PNG, TIFF, WebP, BMP`));
}
};
export const uploadSingle = multer({
storage,
fileFilter,
limits: {
fileSize: 20 * 1024 * 1024, // 20MB max
},
}).single('file');
export const uploadMultiple = multer({
storage,
fileFilter,
limits: {
fileSize: 20 * 1024 * 1024,
},
}).array('files', 10);

198
backend/src/routes/auth.ts Normal file
View File

@@ -0,0 +1,198 @@
import { Router, Request, Response } from 'express';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { getPool } from '../config/database';
import { authenticate, AuthRequest, authorize } from '../middleware/auth';
const router = Router();
// POST /api/auth/login
router.post('/login', async (req: Request, res: Response) => {
try {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ error: 'Email et mot de passe requis' });
}
const pool = getPool();
const [rows]: any = await pool.execute(
'SELECT * FROM users WHERE email = ? AND is_active = TRUE',
[email]
);
if (!rows.length) {
return res.status(401).json({ error: 'Identifiants incorrects' });
}
const user = rows[0];
const validPassword = await bcrypt.compare(password, user.password);
if (!validPassword) {
return res.status(401).json({ error: 'Identifiants incorrects' });
}
const secret = process.env.JWT_SECRET || 'santinova-jwt-secret';
const expiresIn = process.env.JWT_EXPIRES_IN || '24h';
const token = (jwt.sign as any)(
{
id: user.id,
email: user.email,
role: user.role,
firstName: user.first_name,
lastName: user.last_name,
},
secret,
{ expiresIn }
);
res.json({
token,
user: {
id: user.id,
email: user.email,
firstName: user.first_name,
lastName: user.last_name,
role: user.role,
},
});
} catch (error: any) {
console.error('Erreur login:', error);
res.status(500).json({ error: 'Erreur serveur' });
}
});
// GET /api/auth/me
router.get('/me', authenticate, async (req: AuthRequest, res: Response) => {
try {
const pool = getPool();
const [rows]: any = await pool.execute(
'SELECT id, email, first_name, last_name, role, is_active, created_at FROM users WHERE id = ?',
[req.user!.id]
);
if (!rows.length) {
return res.status(404).json({ error: 'Utilisateur non trouvé' });
}
const user = rows[0];
res.json({
id: user.id,
email: user.email,
firstName: user.first_name,
lastName: user.last_name,
role: user.role,
isActive: user.is_active,
createdAt: user.created_at,
});
} catch (error: any) {
console.error('Erreur me:', error);
res.status(500).json({ error: 'Erreur serveur' });
}
});
// GET /api/auth/users - Liste des utilisateurs (admin)
router.get('/users', authenticate, authorize('admin'), async (req: AuthRequest, res: Response) => {
try {
const pool = getPool();
const [rows]: any = await pool.execute(
'SELECT id, email, first_name, last_name, role, is_active, created_at, updated_at FROM users ORDER BY created_at DESC'
);
const users = rows.map((u: any) => ({
id: u.id,
email: u.email,
firstName: u.first_name,
lastName: u.last_name,
role: u.role,
isActive: u.is_active,
createdAt: u.created_at,
updatedAt: u.updated_at,
}));
res.json(users);
} catch (error: any) {
console.error('Erreur liste users:', error);
res.status(500).json({ error: 'Erreur serveur' });
}
});
// POST /api/auth/users - Créer un utilisateur (admin)
router.post('/users', authenticate, authorize('admin'), async (req: AuthRequest, res: Response) => {
try {
const { email, password, firstName, lastName, role } = req.body;
if (!email || !password || !firstName || !lastName) {
return res.status(400).json({ error: 'Tous les champs sont requis' });
}
const hashedPassword = await bcrypt.hash(password, 12);
const pool = getPool();
const [result]: any = await pool.execute(
'INSERT INTO users (email, password, first_name, last_name, role) VALUES (?, ?, ?, ?, ?)',
[email, hashedPassword, firstName, lastName, role || 'operateur']
);
res.status(201).json({
id: result.insertId,
email,
firstName,
lastName,
role: role || 'operateur',
});
} catch (error: any) {
if (error.code === 'ER_DUP_ENTRY') {
return res.status(409).json({ error: 'Cet email est déjà utilisé' });
}
console.error('Erreur création user:', error);
res.status(500).json({ error: 'Erreur serveur' });
}
});
// PUT /api/auth/users/:id - Modifier un utilisateur (admin)
router.put('/users/:id', authenticate, authorize('admin'), async (req: AuthRequest, res: Response) => {
try {
const { id } = req.params;
const { email, firstName, lastName, role, isActive, password } = req.body;
const pool = getPool();
let query = 'UPDATE users SET email = ?, first_name = ?, last_name = ?, role = ?, is_active = ?';
let params: any[] = [email, firstName, lastName, role, isActive !== false];
if (password) {
const hashedPassword = await bcrypt.hash(password, 12);
query += ', password = ?';
params.push(hashedPassword);
}
query += ' WHERE id = ?';
params.push(id);
await pool.execute(query, params);
res.json({ message: 'Utilisateur mis à jour' });
} catch (error: any) {
console.error('Erreur update user:', error);
res.status(500).json({ error: 'Erreur serveur' });
}
});
// DELETE /api/auth/users/:id - Désactiver un utilisateur (admin)
router.delete('/users/:id', authenticate, authorize('admin'), async (req: AuthRequest, res: Response) => {
try {
const { id } = req.params;
const pool = getPool();
await pool.execute('UPDATE users SET is_active = FALSE WHERE id = ?', [id]);
res.json({ message: 'Utilisateur désactivé' });
} catch (error: any) {
console.error('Erreur delete user:', error);
res.status(500).json({ error: 'Erreur serveur' });
}
});
export default router;

View File

@@ -0,0 +1,125 @@
import { Router, Response } from 'express';
import { getPool } from '../config/database';
import { authenticate, AuthRequest } from '../middleware/auth';
const router = Router();
// GET /api/dashboard
router.get('/', authenticate, async (req: AuthRequest, res: Response) => {
try {
const pool = getPool();
// Compteurs par statut
const [statusCounts]: any = await pool.execute(
`SELECT status, COUNT(*) as count, COALESCE(SUM(CAST(amount_ttc AS DECIMAL(12,2))), 0) as total
FROM invoices GROUP BY status`
);
// Total factures actives
const [activeTotals]: any = await pool.execute(
`SELECT COUNT(*) as count, COALESCE(SUM(CAST(amount_ttc AS DECIMAL(12,2))), 0) as total
FROM invoices WHERE status NOT IN ('rejetee', 'archivee')`
);
// Factures en retard
const [overdue]: any = await pool.execute(
`SELECT COUNT(*) as count, COALESCE(SUM(CAST(amount_ttc AS DECIMAL(12,2))), 0) as total
FROM invoices WHERE due_date < NOW() AND status NOT IN ('payee', 'archivee', 'rejetee')`
);
// Factures à échéance dans les 7 jours
const [dueSoon]: any = await pool.execute(
`SELECT COUNT(*) as count, COALESCE(SUM(CAST(amount_ttc AS DECIMAL(12,2))), 0) as total
FROM invoices WHERE due_date BETWEEN NOW() AND DATE_ADD(NOW(), INTERVAL 7 DAY) AND status NOT IN ('payee', 'archivee', 'rejetee')`
);
// Factures à échéance dans les 30 jours
const [dueMonth]: any = await pool.execute(
`SELECT COUNT(*) as count, COALESCE(SUM(CAST(amount_ttc AS DECIMAL(12,2))), 0) as total
FROM invoices WHERE due_date BETWEEN NOW() AND DATE_ADD(NOW(), INTERVAL 30 DAY) AND status NOT IN ('payee', 'archivee', 'rejetee')`
);
// Dernières factures
const [recentInvoices]: any = await pool.execute(
`SELECT i.id, i.invoice_number, i.supplier_name, i.amount_ttc, i.status, i.due_date, i.created_at
FROM invoices i ORDER BY i.created_at DESC LIMIT 10`
);
// Top 5 fournisseurs
const [topSuppliers]: any = await pool.execute(
`SELECT supplier_name, COUNT(*) as invoice_count, COALESCE(SUM(CAST(amount_ttc AS DECIMAL(12,2))), 0) as total
FROM invoices WHERE supplier_name IS NOT NULL AND supplier_name != ''
GROUP BY supplier_name ORDER BY total DESC LIMIT 5`
);
// Évolution mensuelle (6 derniers mois)
const [monthlyEvolution]: any = await pool.execute(
`SELECT DATE_FORMAT(invoice_date, '%Y-%m') as month,
COUNT(*) as count,
COALESCE(SUM(CAST(amount_ttc AS DECIMAL(12,2))), 0) as total
FROM invoices
WHERE invoice_date >= DATE_SUB(NOW(), INTERVAL 6 MONTH) AND invoice_date IS NOT NULL
GROUP BY month ORDER BY month ASC`
);
// Répartition par source
const [sourceCounts]: any = await pool.execute(
'SELECT source, COUNT(*) as count FROM invoices GROUP BY source'
);
// Factures en attente d'action selon le rôle
let pendingForUser: any[] = [];
const role = req.user!.role;
if (role === 'operateur' || role === 'admin') {
const [rows]: any = await pool.execute(
`SELECT COUNT(*) as count FROM invoices WHERE status = 'recue'`
);
pendingForUser.push({ action: 'À vérifier', count: rows[0].count });
}
if (role === 'validateur' || role === 'admin') {
const [rows]: any = await pool.execute(
`SELECT COUNT(*) as count FROM invoices WHERE status = 'en_verification'`
);
pendingForUser.push({ action: 'À valider', count: rows[0].count });
}
if (role === 'approbateur' || role === 'admin') {
const [rows]: any = await pool.execute(
`SELECT COUNT(*) as count FROM invoices WHERE status = 'validee'`
);
pendingForUser.push({ action: 'À approuver', count: rows[0].count });
}
if (role === 'admin') {
const [rows]: any = await pool.execute(
`SELECT COUNT(*) as count FROM invoices WHERE status = 'approuvee'`
);
pendingForUser.push({ action: 'À payer', count: rows[0].count });
}
res.json({
statusCounts: statusCounts.map((s: any) => ({ status: s.status, count: s.count, total: parseFloat(s.total) || 0 })),
activeTotals: { count: activeTotals[0].count, total: parseFloat(activeTotals[0].total) || 0 },
overdue: { count: overdue[0].count, total: parseFloat(overdue[0].total) || 0 },
dueSoon: { count: dueSoon[0].count, total: parseFloat(dueSoon[0].total) || 0 },
dueMonth: { count: dueMonth[0].count, total: parseFloat(dueMonth[0].total) || 0 },
recentInvoices: recentInvoices.map((i: any) => ({
id: i.id,
invoiceNumber: i.invoice_number,
supplierName: i.supplier_name,
amountTTC: i.amount_ttc,
status: i.status,
dueDate: i.due_date,
createdAt: i.created_at,
})),
topSuppliers,
monthlyEvolution,
sourceCounts,
pendingForUser,
});
} catch (error: any) {
console.error('Erreur dashboard:', error);
res.status(500).json({ error: 'Erreur serveur' });
}
});
export default router;

View File

@@ -0,0 +1,196 @@
import { Router, Response } from 'express';
import { getPool } from '../config/database';
import { authenticate, AuthRequest } from '../middleware/auth';
import XLSX from 'xlsx';
const router = Router();
// GET /api/exports/invoices/csv
router.get('/invoices/csv', authenticate, async (req: AuthRequest, res: Response) => {
try {
const pool = getPool();
const { status, startDate, endDate, supplierId } = req.query;
let query = `SELECT i.*, s.name as supplier_display_name
FROM invoices i LEFT JOIN suppliers s ON i.supplier_id = s.id WHERE 1=1`;
const params: any[] = [];
if (status) {
query += ' AND i.status = ?';
params.push(status);
}
if (startDate) {
query += ' AND i.invoice_date >= ?';
params.push(startDate);
}
if (endDate) {
query += ' AND i.invoice_date <= ?';
params.push(endDate);
}
if (supplierId) {
query += ' AND i.supplier_id = ?';
params.push(supplierId);
}
query += ' ORDER BY i.invoice_date ASC';
const [rows]: any = await pool.execute(query, params);
// Format CSV
const headers = [
'N° Facture', 'Fournisseur', 'SIRET', 'Date Facture', 'Date Échéance',
'Montant HT', 'TVA', 'Montant TTC', 'Taux TVA', 'Devise',
'Statut', 'Source', 'N° BC', 'Date Réception'
];
let csv = headers.join(';') + '\n';
for (const row of rows) {
const line = [
row.invoice_number || '',
row.supplier_name || row.supplier_display_name || '',
row.supplier_siret || '',
row.invoice_date ? new Date(row.invoice_date).toLocaleDateString('fr-FR') : '',
row.due_date ? new Date(row.due_date).toLocaleDateString('fr-FR') : '',
row.amount_ht || '0.00',
row.amount_tva || '0.00',
row.amount_ttc || '0.00',
row.tva_rate || '',
row.currency || 'EUR',
row.status,
row.source,
row.purchase_order_id || '',
row.reception_date ? new Date(row.reception_date).toLocaleDateString('fr-FR') : '',
];
csv += line.join(';') + '\n';
}
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="export_factures_${new Date().toISOString().slice(0, 10)}.csv"`);
res.send('\uFEFF' + csv); // BOM for Excel compatibility
} catch (error: any) {
console.error('Erreur export CSV:', error);
res.status(500).json({ error: 'Erreur serveur' });
}
});
// GET /api/exports/invoices/excel
router.get('/invoices/excel', authenticate, async (req: AuthRequest, res: Response) => {
try {
const pool = getPool();
const { status, startDate, endDate, supplierId } = req.query;
let query = `SELECT i.*, s.name as supplier_display_name
FROM invoices i LEFT JOIN suppliers s ON i.supplier_id = s.id WHERE 1=1`;
const params: any[] = [];
if (status) {
query += ' AND i.status = ?';
params.push(status);
}
if (startDate) {
query += ' AND i.invoice_date >= ?';
params.push(startDate);
}
if (endDate) {
query += ' AND i.invoice_date <= ?';
params.push(endDate);
}
if (supplierId) {
query += ' AND i.supplier_id = ?';
params.push(supplierId);
}
query += ' ORDER BY i.invoice_date ASC';
const [rows]: any = await pool.execute(query, params);
const data = rows.map((row: any) => ({
'N° Facture': row.invoice_number || '',
'Fournisseur': row.supplier_name || row.supplier_display_name || '',
'SIRET': row.supplier_siret || '',
'Date Facture': row.invoice_date ? new Date(row.invoice_date).toLocaleDateString('fr-FR') : '',
'Date Échéance': row.due_date ? new Date(row.due_date).toLocaleDateString('fr-FR') : '',
'Montant HT': parseFloat(row.amount_ht) || 0,
'TVA': parseFloat(row.amount_tva) || 0,
'Montant TTC': parseFloat(row.amount_ttc) || 0,
'Taux TVA': row.tva_rate ? `${row.tva_rate}%` : '',
'Devise': row.currency || 'EUR',
'Statut': row.status,
'Source': row.source,
'Rapprochement': row.matching_status || '',
'Date Réception': row.reception_date ? new Date(row.reception_date).toLocaleDateString('fr-FR') : '',
}));
const wb = XLSX.utils.book_new();
const ws = XLSX.utils.json_to_sheet(data);
// Set column widths
ws['!cols'] = [
{ wch: 15 }, { wch: 30 }, { wch: 18 }, { wch: 14 }, { wch: 14 },
{ wch: 12 }, { wch: 12 }, { wch: 12 }, { wch: 10 }, { wch: 8 },
{ wch: 15 }, { wch: 10 }, { wch: 15 }, { wch: 14 },
];
XLSX.utils.book_append_sheet(wb, ws, 'Factures');
const buffer = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.setHeader('Content-Disposition', `attachment; filename="export_factures_${new Date().toISOString().slice(0, 10)}.xlsx"`);
res.send(buffer);
} catch (error: any) {
console.error('Erreur export Excel:', error);
res.status(500).json({ error: 'Erreur serveur' });
}
});
// GET /api/exports/cegi - Préparation export CEGI Compta First (format futur)
router.get('/cegi', authenticate, async (req: AuthRequest, res: Response) => {
try {
const pool = getPool();
const { startDate, endDate } = req.query;
let query = `SELECT i.*, s.name as supplier_display_name, s.siret as supplier_siret_ref
FROM invoices i LEFT JOIN suppliers s ON i.supplier_id = s.id
WHERE i.status IN ('approuvee', 'payee')`;
const params: any[] = [];
if (startDate) {
query += ' AND i.invoice_date >= ?';
params.push(startDate);
}
if (endDate) {
query += ' AND i.invoice_date <= ?';
params.push(endDate);
}
query += ' ORDER BY i.invoice_date ASC';
const [rows]: any = await pool.execute(query, params);
// Format CEGI-compatible (structure préparatoire)
const cegiData = rows.map((row: any) => ({
codeJournal: 'ACH',
dateEcriture: row.invoice_date ? new Date(row.invoice_date).toLocaleDateString('fr-FR') : '',
numeroCompte: '401000',
libelle: `${row.supplier_name || ''} - ${row.invoice_number || ''}`,
debit: 0,
credit: parseFloat(row.amount_ttc) || 0,
numeroPiece: row.invoice_number || '',
dateEcheance: row.due_date ? new Date(row.due_date).toLocaleDateString('fr-FR') : '',
}));
res.json({
message: 'Export CEGI Compta First - Format préparatoire',
note: 'Ce format sera adapté lors de l\'intégration finale avec CEGI Compta First',
data: cegiData,
count: cegiData.length,
});
} catch (error: any) {
console.error('Erreur export CEGI:', error);
res.status(500).json({ error: 'Erreur serveur' });
}
});
export default router;

View File

@@ -0,0 +1,542 @@
import { Router, Response } from 'express';
import { getPool } from '../config/database';
import { authenticate, AuthRequest, authorize } from '../middleware/auth';
import { uploadSingle } from '../middleware/upload';
import { logAction } from '../services/auditService';
import { extractInvoiceData } from '../services/ocrService';
import path from 'path';
const router = Router();
// GET /api/invoices
router.get('/', authenticate, async (req: AuthRequest, res: Response) => {
try {
const pool = getPool();
const { status, supplierId, search, startDate, endDate, page = '1', limit = '20', sortBy = 'created_at', sortOrder = 'DESC' } = req.query;
let query = 'SELECT i.*, s.name as supplier_display_name FROM invoices i LEFT JOIN suppliers s ON i.supplier_id = s.id WHERE 1=1';
let countQuery = 'SELECT COUNT(*) as total FROM invoices i WHERE 1=1';
const params: any[] = [];
const countParams: any[] = [];
if (status) {
query += ' AND i.status = ?';
countQuery += ' AND i.status = ?';
params.push(status);
countParams.push(status);
}
if (supplierId) {
query += ' AND i.supplier_id = ?';
countQuery += ' AND i.supplier_id = ?';
params.push(supplierId);
countParams.push(supplierId);
}
if (search) {
query += ' AND (i.invoice_number LIKE ? OR i.supplier_name LIKE ? OR i.full_text LIKE ?)';
countQuery += ' AND (i.invoice_number LIKE ? OR i.supplier_name LIKE ? OR i.full_text LIKE ?)';
const s = `%${search}%`;
params.push(s, s, s);
countParams.push(s, s, s);
}
if (startDate) {
query += ' AND i.invoice_date >= ?';
countQuery += ' AND i.invoice_date >= ?';
params.push(startDate);
countParams.push(startDate);
}
if (endDate) {
query += ' AND i.invoice_date <= ?';
countQuery += ' AND i.invoice_date <= ?';
params.push(endDate);
countParams.push(endDate);
}
const allowedSortFields = ['created_at', 'invoice_date', 'due_date', 'amount_ttc', 'status', 'invoice_number'];
const sortField = allowedSortFields.includes(sortBy as string) ? sortBy : 'created_at';
const order = sortOrder === 'ASC' ? 'ASC' : 'DESC';
query += ` ORDER BY i.${sortField} ${order}`;
const pageNum = parseInt(page as string) || 1;
const limitNum = parseInt(limit as string) || 20;
const offset = (pageNum - 1) * limitNum;
query += ' LIMIT ? OFFSET ?';
params.push(limitNum, offset);
const [rows]: any = await pool.execute(query, params);
const [countRows]: any = await pool.execute(countQuery, countParams);
const invoices = rows.map((i: any) => ({
id: i.id,
invoiceNumber: i.invoice_number,
supplierId: i.supplier_id,
supplierName: i.supplier_name || i.supplier_display_name,
supplierSiret: i.supplier_siret,
supplierAddress: i.supplier_address,
invoiceDate: i.invoice_date,
dueDate: i.due_date,
receptionDate: i.reception_date,
amountHT: i.amount_ht,
amountTVA: i.amount_tva,
amountTTC: i.amount_ttc,
tvaRate: i.tva_rate,
currency: i.currency,
status: i.status,
source: i.source,
originalFileName: i.original_file_name,
filePath: i.file_path,
fileType: i.file_type,
ocrConfidence: i.ocr_confidence,
purchaseOrderId: i.purchase_order_id,
matchingStatus: i.matching_status,
matchingNotes: i.matching_notes,
notes: i.notes,
createdBy: i.created_by,
validatedBy: i.validated_by,
approvedBy: i.approved_by,
validatedAt: i.validated_at,
approvedAt: i.approved_at,
paidAt: i.paid_at,
createdAt: i.created_at,
updatedAt: i.updated_at,
}));
res.json({
data: invoices,
pagination: {
total: countRows[0].total,
page: pageNum,
limit: limitNum,
totalPages: Math.ceil(countRows[0].total / limitNum),
},
});
} catch (error: any) {
console.error('Erreur liste factures:', error);
res.status(500).json({ error: 'Erreur serveur' });
}
});
// GET /api/invoices/stats
router.get('/stats', authenticate, async (req: AuthRequest, res: Response) => {
try {
const pool = getPool();
// Statistiques globales
const [statusCounts]: any = await pool.execute(
'SELECT status, COUNT(*) as count, SUM(CAST(amount_ttc AS DECIMAL(12,2))) as total FROM invoices GROUP BY status'
);
// Factures en retard
const [overdue]: any = await pool.execute(
`SELECT COUNT(*) as count, SUM(CAST(amount_ttc AS DECIMAL(12,2))) as total
FROM invoices WHERE due_date < NOW() AND status NOT IN ('payee', 'archivee', 'rejetee')`
);
// Factures à échéance dans les 7 prochains jours
const [dueSoon]: any = await pool.execute(
`SELECT COUNT(*) as count, SUM(CAST(amount_ttc AS DECIMAL(12,2))) as total
FROM invoices WHERE due_date BETWEEN NOW() AND DATE_ADD(NOW(), INTERVAL 7 DAY) AND status NOT IN ('payee', 'archivee', 'rejetee')`
);
// Top fournisseurs par montant
const [topSuppliers]: any = await pool.execute(
`SELECT supplier_name, COUNT(*) as invoice_count, SUM(CAST(amount_ttc AS DECIMAL(12,2))) as total
FROM invoices WHERE supplier_name IS NOT NULL GROUP BY supplier_name ORDER BY total DESC LIMIT 10`
);
// Factures par mois (12 derniers mois)
const [monthlyData]: any = await pool.execute(
`SELECT DATE_FORMAT(invoice_date, '%Y-%m') as month, COUNT(*) as count, SUM(CAST(amount_ttc AS DECIMAL(12,2))) as total
FROM invoices WHERE invoice_date >= DATE_SUB(NOW(), INTERVAL 12 MONTH) AND invoice_date IS NOT NULL
GROUP BY month ORDER BY month ASC`
);
// Total général
const [totals]: any = await pool.execute(
`SELECT COUNT(*) as total_count, SUM(CAST(amount_ttc AS DECIMAL(12,2))) as total_amount FROM invoices WHERE status NOT IN ('rejetee', 'archivee')`
);
res.json({
statusCounts: statusCounts.map((s: any) => ({ status: s.status, count: s.count, total: s.total })),
overdue: { count: overdue[0].count, total: overdue[0].total || 0 },
dueSoon: { count: dueSoon[0].count, total: dueSoon[0].total || 0 },
topSuppliers,
monthlyData,
totals: { count: totals[0].total_count, amount: totals[0].total_amount || 0 },
});
} catch (error: any) {
console.error('Erreur stats factures:', error);
res.status(500).json({ error: 'Erreur serveur' });
}
});
// GET /api/invoices/:id
router.get('/:id', authenticate, async (req: AuthRequest, res: Response) => {
try {
const pool = getPool();
const [rows]: any = await pool.execute(
'SELECT i.*, s.name as supplier_display_name FROM invoices i LEFT JOIN suppliers s ON i.supplier_id = s.id WHERE i.id = ?',
[req.params.id]
);
if (!rows.length) {
return res.status(404).json({ error: 'Facture non trouvée' });
}
const i = rows[0];
// Get invoice lines
const [lines]: any = await pool.execute(
'SELECT * FROM invoice_lines WHERE invoice_id = ? ORDER BY line_order ASC',
[req.params.id]
);
// Get audit log
const [audit]: any = await pool.execute(
'SELECT * FROM audit_log WHERE entity_type = ? AND entity_id = ? ORDER BY created_at DESC',
['invoice', req.params.id]
);
res.json({
id: i.id,
invoiceNumber: i.invoice_number,
supplierId: i.supplier_id,
supplierName: i.supplier_name || i.supplier_display_name,
supplierSiret: i.supplier_siret,
supplierAddress: i.supplier_address,
invoiceDate: i.invoice_date,
dueDate: i.due_date,
receptionDate: i.reception_date,
amountHT: i.amount_ht,
amountTVA: i.amount_tva,
amountTTC: i.amount_ttc,
tvaRate: i.tva_rate,
currency: i.currency,
status: i.status,
source: i.source,
originalFileName: i.original_file_name,
filePath: i.file_path,
fileType: i.file_type,
ocrRawData: i.ocr_raw_data,
ocrConfidence: i.ocr_confidence,
purchaseOrderId: i.purchase_order_id,
matchingStatus: i.matching_status,
matchingNotes: i.matching_notes,
notes: i.notes,
fullText: i.full_text,
createdBy: i.created_by,
validatedBy: i.validated_by,
approvedBy: i.approved_by,
validatedAt: i.validated_at,
approvedAt: i.approved_at,
paidAt: i.paid_at,
createdAt: i.created_at,
updatedAt: i.updated_at,
lines: lines.map((l: any) => ({
id: l.id,
description: l.description,
quantity: l.quantity,
unitPrice: l.unit_price,
amountHT: l.amount_ht,
tvaRate: l.tva_rate,
amountTVA: l.amount_tva,
amountTTC: l.amount_ttc,
lineOrder: l.line_order,
})),
auditLog: audit.map((a: any) => ({
id: a.id,
action: a.action,
details: a.details,
userName: a.user_name,
createdAt: a.created_at,
})),
});
} catch (error: any) {
console.error('Erreur get facture:', error);
res.status(500).json({ error: 'Erreur serveur' });
}
});
// POST /api/invoices/upload - Upload et OCR
router.post('/upload', authenticate, (req: AuthRequest, res: Response) => {
uploadSingle(req, res, async (err) => {
if (err) {
return res.status(400).json({ error: err.message });
}
if (!req.file) {
return res.status(400).json({ error: 'Aucun fichier fourni' });
}
try {
const pool = getPool();
const file = req.file;
const source = (req.body.source as string) || 'upload';
// Create initial invoice record
const [result]: any = await pool.execute(
`INSERT INTO invoices (reception_date, source, original_file_name, file_path, file_type, status, created_by)
VALUES (NOW(), ?, ?, ?, ?, 'recue', ?)`,
[source, file.originalname, file.path, file.mimetype, req.user!.id]
);
const invoiceId = result.insertId;
await logAction('invoice', invoiceId, 'upload', req.user!.id, `${req.user!.firstName} ${req.user!.lastName}`, {
fileName: file.originalname,
fileSize: file.size,
source,
});
// Run OCR in background
extractInvoiceData(file.path, file.mimetype, invoiceId).catch((ocrError) => {
console.error('Erreur OCR:', ocrError);
});
res.status(201).json({
id: invoiceId,
message: 'Facture uploadée avec succès. L\'extraction OCR est en cours.',
fileName: file.originalname,
});
} catch (error: any) {
console.error('Erreur upload facture:', error);
res.status(500).json({ error: 'Erreur serveur' });
}
});
});
// PUT /api/invoices/:id - Modifier une facture
router.put('/:id', authenticate, async (req: AuthRequest, res: Response) => {
try {
const { invoiceNumber, supplierId, supplierName, supplierSiret, supplierAddress,
invoiceDate, dueDate, amountHT, amountTVA, amountTTC, tvaRate, currency, notes, purchaseOrderId } = req.body;
const pool = getPool();
await pool.execute(
`UPDATE invoices SET invoice_number = ?, supplier_id = ?, supplier_name = ?, supplier_siret = ?, supplier_address = ?,
invoice_date = ?, due_date = ?, amount_ht = ?, amount_tva = ?, amount_ttc = ?, tva_rate = ?, currency = ?, notes = ?, purchase_order_id = ?
WHERE id = ?`,
[invoiceNumber || null, supplierId || null, supplierName || null, supplierSiret || null, supplierAddress || null,
invoiceDate || null, dueDate || null, amountHT || null, amountTVA || null, amountTTC || null, tvaRate || null,
currency || 'EUR', notes || null, purchaseOrderId || null, req.params.id]
);
await logAction('invoice', parseInt(req.params.id), 'modification', req.user!.id, `${req.user!.firstName} ${req.user!.lastName}`);
res.json({ message: 'Facture mise à jour' });
} catch (error: any) {
console.error('Erreur update facture:', error);
res.status(500).json({ error: 'Erreur serveur' });
}
});
// PUT /api/invoices/:id/lines - Mettre à jour les lignes de facture
router.put('/:id/lines', authenticate, async (req: AuthRequest, res: Response) => {
try {
const { lines } = req.body;
const invoiceId = parseInt(req.params.id);
const pool = getPool();
// Delete existing lines
await pool.execute('DELETE FROM invoice_lines WHERE invoice_id = ?', [invoiceId]);
// Insert new lines
if (lines && lines.length > 0) {
for (let idx = 0; idx < lines.length; idx++) {
const line = lines[idx];
await pool.execute(
`INSERT INTO invoice_lines (invoice_id, description, quantity, unit_price, amount_ht, tva_rate, amount_tva, amount_ttc, line_order)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[invoiceId, line.description || null, line.quantity || null, line.unitPrice || null,
line.amountHT || null, line.tvaRate || null, line.amountTVA || null, line.amountTTC || null, idx]
);
}
}
await logAction('invoice', invoiceId, 'modification_lignes', req.user!.id, `${req.user!.firstName} ${req.user!.lastName}`);
res.json({ message: 'Lignes de facture mises à jour' });
} catch (error: any) {
console.error('Erreur update lignes:', error);
res.status(500).json({ error: 'Erreur serveur' });
}
});
// POST /api/invoices/:id/status - Changer le statut (workflow)
router.post('/:id/status', authenticate, async (req: AuthRequest, res: Response) => {
try {
const { status, notes } = req.body;
const invoiceId = parseInt(req.params.id);
const pool = getPool();
// Vérifier la facture actuelle
const [rows]: any = await pool.execute('SELECT status FROM invoices WHERE id = ?', [invoiceId]);
if (!rows.length) {
return res.status(404).json({ error: 'Facture non trouvée' });
}
const currentStatus = rows[0].status;
// Vérifier les transitions valides
const validTransitions: Record<string, string[]> = {
'recue': ['en_verification', 'rejetee'],
'en_verification': ['validee', 'rejetee', 'recue'],
'validee': ['approuvee', 'rejetee', 'en_verification'],
'approuvee': ['payee', 'rejetee', 'validee'],
'payee': ['archivee'],
'rejetee': ['recue'],
'archivee': [],
};
if (!validTransitions[currentStatus]?.includes(status)) {
return res.status(400).json({
error: `Transition invalide: ${currentStatus}${status}`,
validTransitions: validTransitions[currentStatus],
});
}
// Vérifier les permissions
const role = req.user!.role;
const rolePermissions: Record<string, string[]> = {
'operateur': ['en_verification', 'recue'],
'validateur': ['en_verification', 'validee', 'rejetee', 'recue'],
'approbateur': ['en_verification', 'validee', 'approuvee', 'rejetee', 'recue'],
'admin': ['en_verification', 'validee', 'approuvee', 'payee', 'rejetee', 'recue', 'archivee'],
};
if (!rolePermissions[role]?.includes(status)) {
return res.status(403).json({ error: 'Vous n\'avez pas les permissions pour cette action' });
}
// Mettre à jour le statut
let updateQuery = 'UPDATE invoices SET status = ?';
const updateParams: any[] = [status];
if (status === 'validee') {
updateQuery += ', validated_by = ?, validated_at = NOW()';
updateParams.push(req.user!.id);
} else if (status === 'approuvee') {
updateQuery += ', approved_by = ?, approved_at = NOW()';
updateParams.push(req.user!.id);
} else if (status === 'payee') {
updateQuery += ', paid_at = NOW()';
}
updateQuery += ' WHERE id = ?';
updateParams.push(invoiceId);
await pool.execute(updateQuery, updateParams);
await logAction('invoice', invoiceId, `statut_${status}`, req.user!.id, `${req.user!.firstName} ${req.user!.lastName}`, {
previousStatus: currentStatus,
newStatus: status,
notes,
});
res.json({ message: `Statut mis à jour: ${status}`, previousStatus: currentStatus, newStatus: status });
} catch (error: any) {
console.error('Erreur changement statut:', error);
res.status(500).json({ error: 'Erreur serveur' });
}
});
// POST /api/invoices/:id/match - Rapprochement avec bon de commande
router.post('/:id/match', authenticate, async (req: AuthRequest, res: Response) => {
try {
const { purchaseOrderId } = req.body;
const invoiceId = parseInt(req.params.id);
const pool = getPool();
// Récupérer la facture
const [invoiceRows]: any = await pool.execute('SELECT * FROM invoices WHERE id = ?', [invoiceId]);
if (!invoiceRows.length) {
return res.status(404).json({ error: 'Facture non trouvée' });
}
// Récupérer le bon de commande
const [poRows]: any = await pool.execute('SELECT * FROM purchase_orders WHERE id = ?', [purchaseOrderId]);
if (!poRows.length) {
return res.status(404).json({ error: 'Bon de commande non trouvé' });
}
const invoice = invoiceRows[0];
const po = poRows[0];
// Comparer les montants
const invoiceAmount = parseFloat(invoice.amount_ttc) || 0;
const poAmount = parseFloat(po.amount_ttc) || 0;
const ecart = Math.abs(invoiceAmount - poAmount);
const ecartPourcent = poAmount > 0 ? (ecart / poAmount) * 100 : 0;
let matchingStatus = 'rapproche';
let matchingNotes = `Rapproché avec BC ${po.order_number}`;
if (ecartPourcent > 1) {
matchingStatus = 'ecart_detecte';
matchingNotes = `Écart de ${ecart.toFixed(2)}€ (${ecartPourcent.toFixed(1)}%) avec BC ${po.order_number}`;
}
await pool.execute(
'UPDATE invoices SET purchase_order_id = ?, matching_status = ?, matching_notes = ? WHERE id = ?',
[purchaseOrderId, matchingStatus, matchingNotes, invoiceId]
);
// Mettre à jour le statut du BC
await pool.execute('UPDATE purchase_orders SET status = ? WHERE id = ?', ['facturee', purchaseOrderId]);
await logAction('invoice', invoiceId, 'rapprochement', req.user!.id, `${req.user!.firstName} ${req.user!.lastName}`, {
purchaseOrderId,
matchingStatus,
ecart: ecart.toFixed(2),
});
res.json({ matchingStatus, matchingNotes, ecart: ecart.toFixed(2), ecartPourcent: ecartPourcent.toFixed(1) });
} catch (error: any) {
console.error('Erreur rapprochement:', error);
res.status(500).json({ error: 'Erreur serveur' });
}
});
// GET /api/invoices/:id/file - Télécharger le fichier original
router.get('/:id/file', authenticate, async (req: AuthRequest, res: Response) => {
try {
const pool = getPool();
const [rows]: any = await pool.execute('SELECT file_path, original_file_name, file_type FROM invoices WHERE id = ?', [req.params.id]);
if (!rows.length || !rows[0].file_path) {
return res.status(404).json({ error: 'Fichier non trouvé' });
}
const { file_path, original_file_name, file_type } = rows[0];
res.setHeader('Content-Type', file_type || 'application/octet-stream');
res.setHeader('Content-Disposition', `inline; filename="${original_file_name}"`);
res.sendFile(path.resolve(file_path));
} catch (error: any) {
console.error('Erreur download fichier:', error);
res.status(500).json({ error: 'Erreur serveur' });
}
});
// DELETE /api/invoices/:id
router.delete('/:id', authenticate, authorize('admin'), async (req: AuthRequest, res: Response) => {
try {
const pool = getPool();
await pool.execute('DELETE FROM invoice_lines WHERE invoice_id = ?', [req.params.id]);
await pool.execute('DELETE FROM invoices WHERE id = ?', [req.params.id]);
await logAction('invoice', parseInt(req.params.id), 'suppression', req.user!.id, `${req.user!.firstName} ${req.user!.lastName}`);
res.json({ message: 'Facture supprimée' });
} catch (error: any) {
console.error('Erreur suppression facture:', error);
res.status(500).json({ error: 'Erreur serveur' });
}
});
export default router;

View File

@@ -0,0 +1,80 @@
import { Router, Response } from 'express';
import { getPool } from '../config/database';
import { authenticate, AuthRequest } from '../middleware/auth';
const router = Router();
// GET /api/notifications
router.get('/', authenticate, async (req: AuthRequest, res: Response) => {
try {
const pool = getPool();
const { unreadOnly } = req.query;
let query = 'SELECT * FROM notifications WHERE user_id = ?';
const params: any[] = [req.user!.id];
if (unreadOnly === 'true') {
query += ' AND is_read = FALSE';
}
query += ' ORDER BY created_at DESC LIMIT 50';
const [rows]: any = await pool.execute(query, params);
const notifications = rows.map((n: any) => ({
id: n.id,
title: n.title,
message: n.message,
type: n.type,
isRead: n.is_read,
link: n.link,
createdAt: n.created_at,
}));
res.json(notifications);
} catch (error: any) {
console.error('Erreur notifications:', error);
res.status(500).json({ error: 'Erreur serveur' });
}
});
// PUT /api/notifications/:id/read
router.put('/:id/read', authenticate, async (req: AuthRequest, res: Response) => {
try {
const pool = getPool();
await pool.execute('UPDATE notifications SET is_read = TRUE WHERE id = ? AND user_id = ?', [req.params.id, req.user!.id]);
res.json({ message: 'Notification marquée comme lue' });
} catch (error: any) {
console.error('Erreur mark read:', error);
res.status(500).json({ error: 'Erreur serveur' });
}
});
// PUT /api/notifications/read-all
router.put('/read-all', authenticate, async (req: AuthRequest, res: Response) => {
try {
const pool = getPool();
await pool.execute('UPDATE notifications SET is_read = TRUE WHERE user_id = ?', [req.user!.id]);
res.json({ message: 'Toutes les notifications marquées comme lues' });
} catch (error: any) {
console.error('Erreur mark all read:', error);
res.status(500).json({ error: 'Erreur serveur' });
}
});
// GET /api/notifications/count
router.get('/count', authenticate, async (req: AuthRequest, res: Response) => {
try {
const pool = getPool();
const [rows]: any = await pool.execute(
'SELECT COUNT(*) as count FROM notifications WHERE user_id = ? AND is_read = FALSE',
[req.user!.id]
);
res.json({ count: rows[0].count });
} catch (error: any) {
console.error('Erreur count notifications:', error);
res.status(500).json({ error: 'Erreur serveur' });
}
});
export default router;

View File

@@ -0,0 +1,248 @@
import { Router, Response } from 'express';
import { getPool } from '../config/database';
import { authenticate, AuthRequest } from '../middleware/auth';
import { logAction } from '../services/auditService';
const router = Router();
// GET /api/purchase-orders
router.get('/', authenticate, async (req: AuthRequest, res: Response) => {
try {
const pool = getPool();
const { status, supplierId, search, page = '1', limit = '20' } = req.query;
let query = 'SELECT po.*, s.name as supplier_display_name FROM purchase_orders po LEFT JOIN suppliers s ON po.supplier_id = s.id WHERE 1=1';
let countQuery = 'SELECT COUNT(*) as total FROM purchase_orders po WHERE 1=1';
const params: any[] = [];
const countParams: any[] = [];
if (status) {
query += ' AND po.status = ?';
countQuery += ' AND po.status = ?';
params.push(status);
countParams.push(status);
}
if (supplierId) {
query += ' AND po.supplier_id = ?';
countQuery += ' AND po.supplier_id = ?';
params.push(supplierId);
countParams.push(supplierId);
}
if (search) {
query += ' AND (po.order_number LIKE ? OR po.supplier_name LIKE ? OR po.description LIKE ?)';
countQuery += ' AND (po.order_number LIKE ? OR po.supplier_name LIKE ? OR po.description LIKE ?)';
const s = `%${search}%`;
params.push(s, s, s);
countParams.push(s, s, s);
}
query += ' ORDER BY po.created_at DESC';
const pageNum = parseInt(page as string) || 1;
const limitNum = parseInt(limit as string) || 20;
const offset = (pageNum - 1) * limitNum;
query += ' LIMIT ? OFFSET ?';
params.push(limitNum, offset);
const [rows]: any = await pool.execute(query, params);
const [countRows]: any = await pool.execute(countQuery, countParams);
const orders = rows.map((po: any) => ({
id: po.id,
orderNumber: po.order_number,
supplierId: po.supplier_id,
supplierName: po.supplier_name || po.supplier_display_name,
orderDate: po.order_date,
expectedDeliveryDate: po.expected_delivery_date,
amountHT: po.amount_ht,
amountTVA: po.amount_tva,
amountTTC: po.amount_ttc,
status: po.status,
description: po.description,
notes: po.notes,
createdBy: po.created_by,
createdAt: po.created_at,
updatedAt: po.updated_at,
}));
res.json({
data: orders,
pagination: {
total: countRows[0].total,
page: pageNum,
limit: limitNum,
totalPages: Math.ceil(countRows[0].total / limitNum),
},
});
} catch (error: any) {
console.error('Erreur liste BC:', error);
res.status(500).json({ error: 'Erreur serveur' });
}
});
// GET /api/purchase-orders/:id
router.get('/:id', authenticate, async (req: AuthRequest, res: Response) => {
try {
const pool = getPool();
const [rows]: any = await pool.execute(
'SELECT po.*, s.name as supplier_display_name FROM purchase_orders po LEFT JOIN suppliers s ON po.supplier_id = s.id WHERE po.id = ?',
[req.params.id]
);
if (!rows.length) {
return res.status(404).json({ error: 'Bon de commande non trouvé' });
}
const po = rows[0];
const [lines]: any = await pool.execute(
'SELECT * FROM purchase_order_lines WHERE purchase_order_id = ? ORDER BY line_order ASC',
[req.params.id]
);
// Get linked invoices
const [linkedInvoices]: any = await pool.execute(
'SELECT id, invoice_number, amount_ttc, status, matching_status FROM invoices WHERE purchase_order_id = ?',
[req.params.id]
);
res.json({
id: po.id,
orderNumber: po.order_number,
supplierId: po.supplier_id,
supplierName: po.supplier_name || po.supplier_display_name,
orderDate: po.order_date,
expectedDeliveryDate: po.expected_delivery_date,
amountHT: po.amount_ht,
amountTVA: po.amount_tva,
amountTTC: po.amount_ttc,
status: po.status,
description: po.description,
notes: po.notes,
createdBy: po.created_by,
createdAt: po.created_at,
updatedAt: po.updated_at,
lines: lines.map((l: any) => ({
id: l.id,
description: l.description,
quantity: l.quantity,
unitPrice: l.unit_price,
amountHT: l.amount_ht,
lineOrder: l.line_order,
})),
linkedInvoices: linkedInvoices.map((i: any) => ({
id: i.id,
invoiceNumber: i.invoice_number,
amountTTC: i.amount_ttc,
status: i.status,
matchingStatus: i.matching_status,
})),
});
} catch (error: any) {
console.error('Erreur get BC:', error);
res.status(500).json({ error: 'Erreur serveur' });
}
});
// POST /api/purchase-orders
router.post('/', authenticate, async (req: AuthRequest, res: Response) => {
try {
const { orderNumber, supplierId, supplierName, orderDate, expectedDeliveryDate,
amountHT, amountTVA, amountTTC, description, notes, lines } = req.body;
if (!orderNumber || !orderDate) {
return res.status(400).json({ error: 'Numéro de commande et date requis' });
}
const pool = getPool();
const [result]: any = await pool.execute(
`INSERT INTO purchase_orders (order_number, supplier_id, supplier_name, order_date, expected_delivery_date, amount_ht, amount_tva, amount_ttc, description, notes, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[orderNumber, supplierId || null, supplierName || null, orderDate, expectedDeliveryDate || null,
amountHT || null, amountTVA || null, amountTTC || null, description || null, notes || null, req.user!.id]
);
const poId = result.insertId;
// Insert lines
if (lines && lines.length > 0) {
for (let idx = 0; idx < lines.length; idx++) {
const line = lines[idx];
await pool.execute(
`INSERT INTO purchase_order_lines (purchase_order_id, description, quantity, unit_price, amount_ht, line_order)
VALUES (?, ?, ?, ?, ?, ?)`,
[poId, line.description || null, line.quantity || null, line.unitPrice || null, line.amountHT || null, idx]
);
}
}
await logAction('purchase_order', poId, 'creation', req.user!.id, `${req.user!.firstName} ${req.user!.lastName}`);
res.status(201).json({ id: poId, orderNumber });
} catch (error: any) {
if (error.code === 'ER_DUP_ENTRY') {
return res.status(409).json({ error: 'Ce numéro de commande existe déjà' });
}
console.error('Erreur création BC:', error);
res.status(500).json({ error: 'Erreur serveur' });
}
});
// PUT /api/purchase-orders/:id
router.put('/:id', authenticate, async (req: AuthRequest, res: Response) => {
try {
const { orderNumber, supplierId, supplierName, orderDate, expectedDeliveryDate,
amountHT, amountTVA, amountTTC, status, description, notes, lines } = req.body;
const pool = getPool();
await pool.execute(
`UPDATE purchase_orders SET order_number = ?, supplier_id = ?, supplier_name = ?, order_date = ?, expected_delivery_date = ?,
amount_ht = ?, amount_tva = ?, amount_ttc = ?, status = ?, description = ?, notes = ? WHERE id = ?`,
[orderNumber, supplierId || null, supplierName || null, orderDate, expectedDeliveryDate || null,
amountHT || null, amountTVA || null, amountTTC || null, status || 'brouillon', description || null, notes || null, req.params.id]
);
// Update lines if provided
if (lines) {
await pool.execute('DELETE FROM purchase_order_lines WHERE purchase_order_id = ?', [req.params.id]);
for (let idx = 0; idx < lines.length; idx++) {
const line = lines[idx];
await pool.execute(
`INSERT INTO purchase_order_lines (purchase_order_id, description, quantity, unit_price, amount_ht, line_order)
VALUES (?, ?, ?, ?, ?, ?)`,
[req.params.id, line.description || null, line.quantity || null, line.unitPrice || null, line.amountHT || null, idx]
);
}
}
await logAction('purchase_order', parseInt(req.params.id), 'modification', req.user!.id, `${req.user!.firstName} ${req.user!.lastName}`);
res.json({ message: 'Bon de commande mis à jour' });
} catch (error: any) {
console.error('Erreur update BC:', error);
res.status(500).json({ error: 'Erreur serveur' });
}
});
// DELETE /api/purchase-orders/:id
router.delete('/:id', authenticate, async (req: AuthRequest, res: Response) => {
try {
const pool = getPool();
await pool.execute('DELETE FROM purchase_order_lines WHERE purchase_order_id = ?', [req.params.id]);
await pool.execute('DELETE FROM purchase_orders WHERE id = ?', [req.params.id]);
await logAction('purchase_order', parseInt(req.params.id), 'suppression', req.user!.id, `${req.user!.firstName} ${req.user!.lastName}`);
res.json({ message: 'Bon de commande supprimé' });
} catch (error: any) {
console.error('Erreur suppression BC:', error);
res.status(500).json({ error: 'Erreur serveur' });
}
});
export default router;

View File

@@ -0,0 +1,151 @@
import { Router, Response } from 'express';
import { getPool } from '../config/database';
import { authenticate, AuthRequest } from '../middleware/auth';
import { logAction } from '../services/auditService';
const router = Router();
// GET /api/suppliers
router.get('/', authenticate, async (req: AuthRequest, res: Response) => {
try {
const pool = getPool();
const { search, active } = req.query;
let query = 'SELECT * FROM suppliers WHERE 1=1';
const params: any[] = [];
if (active !== undefined) {
query += ' AND is_active = ?';
params.push(active === 'true');
}
if (search) {
query += ' AND (name LIKE ? OR siret LIKE ? OR email LIKE ?)';
const s = `%${search}%`;
params.push(s, s, s);
}
query += ' ORDER BY name ASC';
const [rows]: any = await pool.execute(query, params);
const suppliers = rows.map((s: any) => ({
id: s.id,
name: s.name,
siret: s.siret,
address: s.address,
city: s.city,
postalCode: s.postal_code,
country: s.country,
email: s.email,
phone: s.phone,
contactName: s.contact_name,
iban: s.iban,
notes: s.notes,
isActive: s.is_active,
createdAt: s.created_at,
updatedAt: s.updated_at,
}));
res.json(suppliers);
} catch (error: any) {
console.error('Erreur liste fournisseurs:', error);
res.status(500).json({ error: 'Erreur serveur' });
}
});
// GET /api/suppliers/:id
router.get('/:id', authenticate, async (req: AuthRequest, res: Response) => {
try {
const pool = getPool();
const [rows]: any = await pool.execute('SELECT * FROM suppliers WHERE id = ?', [req.params.id]);
if (!rows.length) {
return res.status(404).json({ error: 'Fournisseur non trouvé' });
}
const s = rows[0];
res.json({
id: s.id,
name: s.name,
siret: s.siret,
address: s.address,
city: s.city,
postalCode: s.postal_code,
country: s.country,
email: s.email,
phone: s.phone,
contactName: s.contact_name,
iban: s.iban,
notes: s.notes,
isActive: s.is_active,
createdAt: s.created_at,
updatedAt: s.updated_at,
});
} catch (error: any) {
console.error('Erreur get fournisseur:', error);
res.status(500).json({ error: 'Erreur serveur' });
}
});
// POST /api/suppliers
router.post('/', authenticate, async (req: AuthRequest, res: Response) => {
try {
const { name, siret, address, city, postalCode, country, email, phone, contactName, iban, notes } = req.body;
if (!name) {
return res.status(400).json({ error: 'Le nom du fournisseur est requis' });
}
const pool = getPool();
const [result]: any = await pool.execute(
`INSERT INTO suppliers (name, siret, address, city, postal_code, country, email, phone, contact_name, iban, notes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[name, siret || null, address || null, city || null, postalCode || null, country || 'France', email || null, phone || null, contactName || null, iban || null, notes || null]
);
await logAction('supplier', result.insertId, 'creation', req.user!.id, `${req.user!.firstName} ${req.user!.lastName}`);
res.status(201).json({ id: result.insertId, name });
} catch (error: any) {
console.error('Erreur création fournisseur:', error);
res.status(500).json({ error: 'Erreur serveur' });
}
});
// PUT /api/suppliers/:id
router.put('/:id', authenticate, async (req: AuthRequest, res: Response) => {
try {
const { name, siret, address, city, postalCode, country, email, phone, contactName, iban, notes, isActive } = req.body;
const pool = getPool();
await pool.execute(
`UPDATE suppliers SET name = ?, siret = ?, address = ?, city = ?, postal_code = ?, country = ?, email = ?, phone = ?, contact_name = ?, iban = ?, notes = ?, is_active = ? WHERE id = ?`,
[name, siret || null, address || null, city || null, postalCode || null, country || 'France', email || null, phone || null, contactName || null, iban || null, notes || null, isActive !== false, req.params.id]
);
await logAction('supplier', parseInt(req.params.id), 'modification', req.user!.id, `${req.user!.firstName} ${req.user!.lastName}`);
res.json({ message: 'Fournisseur mis à jour' });
} catch (error: any) {
console.error('Erreur update fournisseur:', error);
res.status(500).json({ error: 'Erreur serveur' });
}
});
// DELETE /api/suppliers/:id
router.delete('/:id', authenticate, async (req: AuthRequest, res: Response) => {
try {
const pool = getPool();
await pool.execute('UPDATE suppliers SET is_active = FALSE WHERE id = ?', [req.params.id]);
await logAction('supplier', parseInt(req.params.id), 'desactivation', req.user!.id, `${req.user!.firstName} ${req.user!.lastName}`);
res.json({ message: 'Fournisseur désactivé' });
} catch (error: any) {
console.error('Erreur delete fournisseur:', error);
res.status(500).json({ error: 'Erreur serveur' });
}
});
export default router;

View File

@@ -0,0 +1,30 @@
import mysql from 'mysql2/promise';
import { getPool } from '../config/database';
export async function logAction(
entityType: string,
entityId: number,
action: string,
userId?: number,
userName?: string,
details?: any
) {
try {
const pool = getPool();
await pool.execute(
`INSERT INTO audit_log (entity_type, entity_id, action, user_id, user_name, details) VALUES (?, ?, ?, ?, ?, ?)`,
[entityType, entityId, action, userId || null, userName || null, details ? JSON.stringify(details) : null]
);
} catch (error) {
console.error('Erreur lors de l\'enregistrement de l\'audit:', error);
}
}
export async function getAuditLog(entityType: string, entityId: number) {
const pool = getPool();
const [rows] = await pool.execute(
`SELECT * FROM audit_log WHERE entity_type = ? AND entity_id = ? ORDER BY created_at DESC`,
[entityType, entityId]
);
return rows;
}

View File

@@ -0,0 +1,224 @@
import OpenAI from 'openai';
import fs from 'fs';
import path from 'path';
import { getPool } from '../config/database';
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
interface ExtractedInvoiceData {
invoiceNumber?: string;
supplierName?: string;
supplierSiret?: string;
supplierAddress?: string;
invoiceDate?: string;
dueDate?: string;
amountHT?: number;
amountTVA?: number;
amountTTC?: number;
tvaRate?: number;
currency?: string;
lines?: Array<{
description?: string;
quantity?: number;
unitPrice?: number;
amountHT?: number;
tvaRate?: number;
amountTVA?: number;
amountTTC?: number;
}>;
fullText?: string;
confidence?: number;
}
export async function extractInvoiceData(filePath: string, mimeType: string, invoiceId: number): Promise<ExtractedInvoiceData> {
try {
console.log(`🔍 OCR en cours pour la facture #${invoiceId}...`);
// Read file and convert to base64
const fileBuffer = fs.readFileSync(filePath);
const base64 = fileBuffer.toString('base64');
// Determine media type
let mediaType = 'image/jpeg';
if (mimeType === 'application/pdf') {
mediaType = 'application/pdf';
} else if (mimeType) {
mediaType = mimeType;
}
const dataUrl = `data:${mediaType};base64,${base64}`;
const prompt = `Tu es un expert en extraction de données de factures. Analyse cette facture et extrais les informations suivantes au format JSON strict.
IMPORTANT: Réponds UNIQUEMENT avec un objet JSON valide, sans texte avant ou après.
{
"invoiceNumber": "numéro de facture",
"supplierName": "nom du fournisseur",
"supplierSiret": "numéro SIRET du fournisseur",
"supplierAddress": "adresse complète du fournisseur",
"invoiceDate": "date de facture au format YYYY-MM-DD",
"dueDate": "date d'échéance au format YYYY-MM-DD",
"amountHT": montant_HT_nombre,
"amountTVA": montant_TVA_nombre,
"amountTTC": montant_TTC_nombre,
"tvaRate": taux_TVA_nombre,
"currency": "EUR",
"lines": [
{
"description": "description de la ligne",
"quantity": quantité_nombre,
"unitPrice": prix_unitaire_nombre,
"amountHT": montant_HT_ligne_nombre,
"tvaRate": taux_TVA_nombre,
"amountTVA": montant_TVA_ligne_nombre,
"amountTTC": montant_TTC_ligne_nombre
}
],
"fullText": "texte complet extrait de la facture",
"confidence": score_de_confiance_0_à_100
}
Si une information n'est pas trouvée, utilise null. Les montants doivent être des nombres (pas de chaînes). Les dates doivent être au format YYYY-MM-DD.`;
const response = await openai.chat.completions.create({
model: 'gpt-4.1-mini',
messages: [
{
role: 'user',
content: [
{ type: 'text', text: prompt },
{
type: 'image_url',
image_url: {
url: dataUrl,
detail: 'high',
},
},
],
},
],
max_tokens: 4096,
temperature: 0.1,
});
const content = response.choices[0]?.message?.content || '{}';
// Parse JSON response
let extracted: ExtractedInvoiceData;
try {
// Try to extract JSON from the response
const jsonMatch = content.match(/\{[\s\S]*\}/);
if (jsonMatch) {
extracted = JSON.parse(jsonMatch[0]);
} else {
extracted = JSON.parse(content);
}
} catch (parseError) {
console.error('Erreur parsing OCR JSON:', parseError);
extracted = { fullText: content, confidence: 0 };
}
// Update the invoice in the database
const pool = getPool();
// Try to find or create supplier
let supplierId: number | null = null;
if (extracted.supplierName) {
const [existingSuppliers]: any = await pool.execute(
'SELECT id FROM suppliers WHERE name LIKE ? OR siret = ? LIMIT 1',
[`%${extracted.supplierName}%`, extracted.supplierSiret || '']
);
if (existingSuppliers.length > 0) {
supplierId = existingSuppliers[0].id;
} else {
// Create new supplier
const [result]: any = await pool.execute(
'INSERT INTO suppliers (name, siret, address) VALUES (?, ?, ?)',
[extracted.supplierName, extracted.supplierSiret || null, extracted.supplierAddress || null]
);
supplierId = result.insertId;
}
}
await pool.execute(
`UPDATE invoices SET
invoice_number = COALESCE(?, invoice_number),
supplier_id = COALESCE(?, supplier_id),
supplier_name = COALESCE(?, supplier_name),
supplier_siret = COALESCE(?, supplier_siret),
supplier_address = COALESCE(?, supplier_address),
invoice_date = COALESCE(?, invoice_date),
due_date = COALESCE(?, due_date),
amount_ht = COALESCE(?, amount_ht),
amount_tva = COALESCE(?, amount_tva),
amount_ttc = COALESCE(?, amount_ttc),
tva_rate = COALESCE(?, tva_rate),
currency = COALESCE(?, currency),
ocr_raw_data = ?,
ocr_confidence = ?,
full_text = ?,
status = 'en_verification'
WHERE id = ?`,
[
extracted.invoiceNumber || null,
supplierId,
extracted.supplierName || null,
extracted.supplierSiret || null,
extracted.supplierAddress || null,
extracted.invoiceDate || null,
extracted.dueDate || null,
extracted.amountHT || null,
extracted.amountTVA || null,
extracted.amountTTC || null,
extracted.tvaRate || null,
extracted.currency || null,
JSON.stringify(extracted),
extracted.confidence || null,
extracted.fullText || null,
invoiceId,
]
);
// Insert invoice lines
if (extracted.lines && extracted.lines.length > 0) {
for (let idx = 0; idx < extracted.lines.length; idx++) {
const line = extracted.lines[idx];
await pool.execute(
`INSERT INTO invoice_lines (invoice_id, description, quantity, unit_price, amount_ht, tva_rate, amount_tva, amount_ttc, line_order)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[invoiceId, line.description || null, line.quantity || null, line.unitPrice || null,
line.amountHT || null, line.tvaRate || null, line.amountTVA || null, line.amountTTC || null, idx]
);
}
}
// Log the OCR action
await pool.execute(
`INSERT INTO audit_log (entity_type, entity_id, action, details) VALUES (?, ?, ?, ?)`,
['invoice', invoiceId, 'ocr_extraction', JSON.stringify({ confidence: extracted.confidence, linesCount: extracted.lines?.length || 0 })]
);
console.log(`✅ OCR terminé pour la facture #${invoiceId} (confiance: ${extracted.confidence}%)`);
return extracted;
} catch (error: any) {
console.error(`❌ Erreur OCR pour la facture #${invoiceId}:`, error);
// Update invoice with error status
try {
const pool = getPool();
await pool.execute(
`INSERT INTO audit_log (entity_type, entity_id, action, details) VALUES (?, ?, ?, ?)`,
['invoice', invoiceId, 'ocr_erreur', JSON.stringify({ error: error.message })]
);
} catch (logError) {
console.error('Erreur log OCR:', logError);
}
throw error;
}
}

20
backend/tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"moduleResolution": "node"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

1
backend/uploads/.gitkeep Normal file
View File

@@ -0,0 +1 @@