Initial commit - Facturation SANTINOVA
This commit is contained in:
7
backend/.env.example
Normal file
7
backend/.env.example
Normal 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
11
backend/drizzle.config.ts
Normal 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
3500
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
39
backend/package.json
Normal file
39
backend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
27
backend/src/config/database.ts
Normal file
27
backend/src/config/database.ts
Normal 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
196
backend/src/db/migrate.ts
Normal 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
265
backend/src/db/schema.ts
Normal 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
46
backend/src/db/seed.ts
Normal 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
77
backend/src/index.ts
Normal 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();
|
||||
53
backend/src/middleware/auth.ts
Normal file
53
backend/src/middleware/auth.ts
Normal 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();
|
||||
};
|
||||
}
|
||||
61
backend/src/middleware/upload.ts
Normal file
61
backend/src/middleware/upload.ts
Normal 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
198
backend/src/routes/auth.ts
Normal 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;
|
||||
125
backend/src/routes/dashboard.ts
Normal file
125
backend/src/routes/dashboard.ts
Normal 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;
|
||||
196
backend/src/routes/exports.ts
Normal file
196
backend/src/routes/exports.ts
Normal 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;
|
||||
542
backend/src/routes/invoices.ts
Normal file
542
backend/src/routes/invoices.ts
Normal 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;
|
||||
80
backend/src/routes/notifications.ts
Normal file
80
backend/src/routes/notifications.ts
Normal 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;
|
||||
248
backend/src/routes/purchaseOrders.ts
Normal file
248
backend/src/routes/purchaseOrders.ts
Normal 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;
|
||||
151
backend/src/routes/suppliers.ts
Normal file
151
backend/src/routes/suppliers.ts
Normal 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;
|
||||
30
backend/src/services/auditService.ts
Normal file
30
backend/src/services/auditService.ts
Normal 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;
|
||||
}
|
||||
224
backend/src/services/ocrService.ts
Normal file
224
backend/src/services/ocrService.ts
Normal 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
20
backend/tsconfig.json
Normal 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
1
backend/uploads/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
Reference in New Issue
Block a user