---------------------------------------------------- server/server.js const express = require('express'); const cors = require('cors'); const helmet = require('helmet'); const dotenv = require('dotenv'); const path = require('path'); const fs = require('fs'); dotenv.config(); const app = express(); const PORT = process.env.PORT || 3000; // ================================================================ // MIDDLEWARES // ================================================================ app.set('trust proxy', true); app.use(helmet({ contentSecurityPolicy: false })); app.use(cors({ origin: true, credentials: true })); app.use(express.json({ limit: '10mb' })); app.use(express.urlencoded({ extended: true, limit: '10mb' })); // Service des fichiers statiques (pour Angular) app.use(express.static(path.join(__dirname, '../public'))); // Logger app.use((req, res, next) => { console.log(`[${new Date().toISOString()}] ${req.method} ${req.path}`); next(); }); // ================================================================ // CHARGER LES CONFIGS // ================================================================ let appConfigs = {}; try { appConfigs.countries = JSON.parse(fs.readFileSync(path.join(__dirname, '../config/countries.json'), 'utf8')); appConfigs.currencies = JSON.parse(fs.readFileSync(path.join(__dirname, '../config/currencies.json'), 'utf8')); appConfigs.taresRules = JSON.parse(fs.readFileSync(path.join(__dirname, '../config/tares_codes.json'), 'utf8')); // [FLAG] Vérification de la configuration critique const openRouterApiKey = process.env.OPENROUTER_API_KEY; if (!openRouterApiKey || openRouterApiKey.length < 10) { console.warn(`[FLAG-INIT-WARN] OPENROUTER_API_KEY semble manquant ou trop court.`); } else { console.log(`[FLAG-INIT-OK] OPENROUTER_API_KEY chargée (${openRouterApiKey.substring(0, 10)}...).`); } // On attache les configs à l'objet 'app' pour qu'il soit accessible dans les routes app.appConfigs = appConfigs; console.log('✓ Fichiers de configuration chargés'); } catch (error) { console.error('⚠ Erreur chargement config:', error.message); } // ================================================================ // ROUTES DE L'API // ================================================================ const apiRoutes = require('./routes/api'); app.use('/api', apiRoutes); // ================================================================ // ROUTE FALLBACK POUR ANGULAR (Single Page Application) // ================================================================ app.get('*', (req, res) => { res.sendFile(path.join(__dirname, '../public/index.html')); }); // ================================================================ // GESTION D'ERREURS // ================================================================ app.use((err, req, res, next) => { console.error('Erreur:', err.stack); res.status(err.status || 500).json({ error: process.env.NODE_ENV === 'production' ? 'Erreur interne du serveur' : err.message }); }); app.use((req, res) => { res.status(404).json({ error: 'Route API non trouvée: ' + req.path }); }); // ================================================================ // DÉMARRAGE // ================================================================ app.listen(PORT, '127.0.0.1', () => { console.log(`╔════════════════════════════════════════════════════════════╗`); console.log(`║ Backend e-dec démarré sur http://127.0.0.1:${PORT} ║`); console.log(`╚════════════════════════════════════════════════════════════╝`); }); process.on('SIGTERM', () => { console.log('SIGTERM reçu, arrêt gracieux...'); process.exit(0); }); ---------------------------------------------------- server/routes/api.js const express = require('express'); const router = express.Router(); const multer = require('multer'); const path = require('path'); const fs = require('fs'); const { body, validationResult } = require('express-validator'); const { pool } = require('../db'); const { v4: uuidv4 } = require('uuid'); // Services const ocrService = require('../services/ocrService'); const taresClassifier = require('../services/taresClassifier'); const edecGenerator = require('../services/edecGenerator'); const exchangeRateService = require('../services/exchangeRateService'); const declarationService = require('../services/declarationService'); const edecSubmissionService = require('../services/edecSubmissionService'); // Import direct pour SSE const DOWNLOAD_DIR = path.join(__dirname, '../../edecpdf'); const EXPIRATION_DAYS = 30; // Configuration Multer pour l'upload de fichiers const upload = multer({ dest: path.join(__dirname, '../../public/uploads'), limits: { fileSize: 10 * 1024 * 1024 }, // 10MB fileFilter: (req, file, cb) => { const allowed = ['image/jpeg', 'image/png', 'application/pdf']; if (allowed.includes(file.mimetype)) { cb(null, true); } else { cb(new Error('Formats supportés: JPG, PNG, PDF')); } } }); // ========================================================================= // ROUTES DE SERVICE ET DE DONNÉES DE RÉFÉRENCE // ========================================================================= // Health check router.get('/health', (req, res) => { res.json({ status: 'OK', timestamp: new Date().toISOString(), service: 'e-dec Node.js Backend', version: '1.2.0' // Version incrémentée }); }); // Données de référence (pays, devises) router.get('/countries', (req, res) => res.json(req.app.appConfigs.countries || {})); router.get('/currencies', (req, res) => res.json(req.app.appConfigs.currencies || {})); // Taux de change router.get('/exchange-rates', async (req, res) => { try { const rates = await exchangeRateService.getCurrentRates(); res.json(rates); } catch (e) { res.status(500).json({ error: e.message }); } }); // Conversion de devise router.post('/convert-currency', [ body('amount').isFloat({ min: 0 }), body('from').isString().isLength({ min: 3, max: 3 }), body('to').optional().isString().isLength({ min: 3, max: 3 }) ], async (req, res) => { const errors = validationResult(req); if (!errors.isEmpty()) { return res.status(400).json({ error: 'Paramètres invalides', details: errors.array() }); } try { const { amount, from, to = 'CHF' } = req.body; const converted = await exchangeRateService.convert(Number(amount), from, to); res.json({ converted }); } catch (e) { res.status(500).json({ error: e.message }); } }); // ========================================================================= // ROUTES MÉTIER (OCR, VIN, CLASSIFICATION) // ========================================================================= // OCR router.post('/ocr', upload.single('registration_card'), async (req, res) => { const filePath = req.file?.path; if (!filePath) { return res.status(400).json({ error: 'Aucun fichier fourni' }); } try { const extractedData = await ocrService.extractFromFile(filePath, req.file.mimetype); res.json(extractedData); } catch (error) { console.error('Erreur OCR complète:', error); res.status(500).json({ error: 'Erreur OCR', details: error.message, hint: 'Vérifiez que votre clé API OpenRouter est valide et que le fichier est lisible.' }); } finally { if (filePath && fs.existsSync(filePath)) { try { fs.unlinkSync(filePath); } catch (_) {} } } }); // Validation VIN router.post('/validate-vin', [ body('vin').isString().isLength({ min: 17, max: 17 }) ], async (req, res) => { const { vin } = req.body; const valid = /^[A-HJ-NPR-Z0-9]{17}$/.test((vin || '').toUpperCase()); let country = null; try { if (valid) { country = await taresClassifier.getCountryFromVIN_DB(vin); } res.json({ valid, country }); } catch (e) { res.status(500).json({ error: e.message }); } }); // Classification TARES router.post('/classify-vehicle', async (req, res) => { try { const classification = await taresClassifier.classify(req.body, req.app.appConfigs.taresRules); res.json(classification); } catch (error) { console.error('Erreur classification:', error); res.status(500).json({ error: error.message }); } }); // Recherche de Brand Code router.post('/brand-codes/lookup', [ body('brand').trim().notEmpty().withMessage("Le nom de la marque est requis.") ], async (req, res) => { const errors = validationResult(req); if (!errors.isEmpty()) { return res.status(400).json({ error: 'Champs invalides', details: errors.array() }); } try { const { brand } = req.body; const brandCode = await declarationService.getBrandCodeByBrandName(brand); if (brandCode) { return res.json({ brandCode }); } const options = await declarationService.getAllBrandCodes(); if (options.length === 0) { return res.status(404).json({ message: "Liste des codes de marque non disponible." }); } return res.json({ options }); } catch (error) { console.error('Erreur lors de la recherche du code de marque:', error); return res.status(500).json({ error: "Erreur interne du serveur lors de la recherche." }); } }); // ========================================================================= // ROUTE DE GÉNÉRATION D'IMPORTATION // ========================================================================= router.post('/generate-import', [ // ... validateurs (inchangés) body('user_name').isString().notEmpty(), body('user_zip').isString().notEmpty(), body('user_city').isString().notEmpty(), body('dispatch_country').isString().isLength({ min: 2, max: 2 }), body('transport_mode').isString().isIn(['9', '2', '3', '4', '7', '8']), body('purchase_price').isFloat({ min: 0 }), body('purchase_currency').isString().isLength({ min: 3, max: 3 }), body('vin').isString().isLength({ min: 17, max: 17 }), body('brand').isString().notEmpty(), body('brand_code').optional().isString().isLength({ min: 3, max: 3 }).isNumeric(), body('model').isString().notEmpty(), body('weight_empty').isInt({ min: 1 }), body('weight_total').isInt({ min: 1 }), ], async (req, res) => { const errors = validationResult(req); if (!errors.isEmpty()) { return res.status(400).json({ error: 'Champs invalides', details: errors.array() }); } const txId = uuidv4(); const conn = await pool.getConnection(); try { await conn.beginTransaction(); console.log(`[GENERATE-IMPORT] Début génération pour UUID: ${txId}`); const data = { ...req.body }; // Logique métier (classification, conversion, etc.) const classification = await taresClassifier.classify(data, req.app.appConfigs.taresRules); data.commodity_code = data.commodity_code || classification.commodity_code; data.statistical_key = data.statistical_key || classification.statistical_key || '911'; data.origin_country = await taresClassifier.getCountryFromVIN_DB(data.vin) || data.dispatch_country || 'FR'; data.brand_code = data.brand_code || await declarationService.getBrandCodeByBrandName(data.brand); const sum = Number(data.purchase_price || 0) + Number(data.transport_cost || 0) + Number(data.other_costs || 0); data.statistical_value_chf = await exchangeRateService.convert(Number(data.purchase_price || 0), data.purchase_currency, 'CHF'); data.vat_value_chf = await exchangeRateService.convert(sum, data.purchase_currency, 'CHF'); // Persistance via le service const declarationId = await declarationService.createFullImportDeclaration(conn, txId, data); console.log(`[GENERATE-IMPORT] Déclaration créée ID: ${declarationId}`); // Génération et sauvegarde XML const xml = await edecGenerator.generateImport(data); const xmlFilePath = await declarationService.saveXmlFile(conn, txId, xml, declarationId); console.log(`[GENERATE-IMPORT] XML sauvegardé: ${xmlFilePath}`); await conn.commit(); // Lancement de la soumission en arrière-plan console.log(`[GENERATE-IMPORT] Lancement soumission automatique pour: ${txId}`); setTimeout(() => { edecSubmissionService.submitDeclaration(xmlFilePath, txId).catch(err => { console.error(`[SUBMISSION-BACKGROUND] Erreur fatale non gérée pour ${txId}:`, err); }); }, 100); res.json({ success: true, sessionToken: txId, message: 'Déclaration générée et soumission automatique démarrée.' }); } catch (e) { await conn.rollback(); console.error('[GENERATE-IMPORT] Erreur:', e); res.status(500).json({ error: e.message }); } finally { conn.release(); } }); // ========================================================================= // ROUTE DE GÉNÉRATION D'EXPORTATION // ========================================================================= router.post('/generate-export', [ // ... validateurs body('user_name').isString().notEmpty(), body('user_zip').isString().notEmpty(), body('user_city').isString().notEmpty(), body('destination_country').isString().isLength({ min: 2, max: 2 }), body('vin').isString().isLength({ min: 17, max: 17 }), body('brand').isString().notEmpty(), body('model').isString().notEmpty(), ], async (req, res) => { const errors = validationResult(req); if (!errors.isEmpty()) { return res.status(400).json({ error: 'Champs invalides', details: errors.array() }); } const txId = uuidv4(); const conn = await pool.getConnection(); try { await conn.beginTransaction(); console.log(`[GENERATE-EXPORT] Début génération pour UUID: ${txId}`); const data = { ...req.body }; // Persistance via le service const declarationId = await declarationService.createFullExportDeclaration(conn, txId, data); console.log(`[GENERATE-EXPORT] Déclaration créée ID: ${declarationId}`); // Génération et sauvegarde XML const xml = await edecGenerator.generateExport(data); await declarationService.saveXmlFile(conn, txId, xml, declarationId); await conn.commit(); res.set('Content-Type', 'application/xml'); res.set('Content-Disposition', `attachment; filename="edec-export-${txId}.xml"`); res.send(xml); } catch (e) { await conn.rollback(); console.error('Erreur génération export:', e); res.status(500).json({ error: e.message }); } finally { conn.release(); } }); // ========================================================================= // ROUTES DE SUIVI ET TÉLÉCHARGEMENT // ========================================================================= // [NOUVEAU] Endpoint SSE pour le suivi en temps réel (Push du serveur) router.get('/submission-status-sse/:sessionToken', async (req, res) => { const { sessionToken } = req.params; console.log(`[SSE] Nouvelle connexion pour ${sessionToken}`); // Configuration des headers SSE res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.setHeader('Access-Control-Allow-Origin', '*'); // Fonction de callback pour envoyer des données au client const sendStatusUpdate = (data) => { try { // Assure la fermeture si l'état est final (redondant avec la gestion du service mais sécurisant) if (res.finished) return; res.write(`data: ${JSON.stringify(data)}\n\n`); if (data.status === 'submitted' || data.status === 'submission_error') { // Délai pour s'assurer que le dernier message est envoyé avant de fermer la connexion setTimeout(() => { if (!res.finished) res.end(); }, 100); } } catch (error) { console.error(`[SSE] Erreur envoi données pour ${sessionToken}:`, error); } }; // Enregistrer le listener dans le service edecSubmissionService.registerListener(sessionToken, sendStatusUpdate); // Vérifier l'état initial (TRÈS IMPORTANT pour les recharges de page) try { const [rows] = await pool.execute(` SELECT status, declaration_number, liste_importation_path, bulletin_delivrance_path, COALESCE(error_message, '') as error_message, submission_date FROM declarations WHERE uuid = ?`, [sessionToken]); if (rows.length > 0) { const declaration = rows[0]; const { progress, step } = declarationService.getSubmissionProgress(declaration.status); // Envoi de l'état actuel au client sendStatusUpdate({ status: declaration.status, progress, step, declarationNumber: declaration.declaration_number, listePath: declaration.liste_importation_path, bulletinPath: declaration.bulletin_delivrance_path, submissionDate: declaration.submission_date, error: declaration.error_message }); } } catch (error) { console.error(`[SSE] Erreur vérification état initial ${sessionToken}:`, error); } // Gérer la fermeture de la connexion (très important pour le nettoyage du service) const cleanup = () => { if (!res.finished) { console.log(`[SSE] Connexion fermée pour ${sessionToken} - Nettoyage`); edecSubmissionService.removeListener(sessionToken); res.end(); } }; // Événements de fermeture req.on('close', cleanup); req.on('error', cleanup); }); // Suivi de la soumission (Legacy - Route de Polling conservée mais désormais redondante) router.get('/submission-status/:sessionToken', async (req, res) => { const { sessionToken } = req.params; try { const [rows] = await pool.execute(` SELECT status, declaration_number, liste_importation_path, bulletin_delivrance_path, COALESCE(error_message, '') as error_message, submission_date FROM declarations WHERE uuid = ?`, [sessionToken]); if (rows.length === 0) { return res.status(404).json({ error: 'Session non trouvée' }); } const declaration = rows[0]; const { progress, step } = declarationService.getSubmissionProgress(declaration.status); res.json({ status: declaration.status, progress, step, declarationNumber: declaration.declaration_number, listePath: declaration.liste_importation_path, bulletinPath: declaration.bulletin_delivrance_path, submissionDate: declaration.submission_date, error: declaration.error_message }); } catch (error) { console.error('[SUBMISSION-STATUS] Erreur:', error); res.status(500).json({ error: 'Erreur serveur' }); } }); // Téléchargement sécurisé des PDF router.get('/download-pdf', async (req, res) => { const rawFilePath = req.query.path; if (!rawFilePath) { return res.status(400).json({ error: 'Paramètre de chemin manquant' }); } const safeFilePath = path.resolve(rawFilePath); if (!safeFilePath.startsWith(DOWNLOAD_DIR)) { console.warn(`[DOWNLOAD-SECURITY] Tentative d'accès illégal : ${rawFilePath}`); return res.status(403).json({ error: 'Accès refusé. Chemin non autorisé.' }); } if (!fs.existsSync(safeFilePath)) { return res.status(404).json({ error: 'Fichier non trouvé sur le serveur' }); } const sessionToken = path.basename(safeFilePath).split('_')[0]; try { const [rows] = await pool.execute( `SELECT submission_date FROM declarations WHERE uuid = ? AND status = 'submitted'`, [sessionToken] ); if (rows.length === 0 || !rows[0].submission_date) { return res.status(404).json({ error: 'Déclaration non trouvée ou non finalisée.' }); } const submissionDate = new Date(rows[0].submission_date); const expirationDate = new Date(submissionDate.getTime() + EXPIRATION_DAYS * 24 * 60 * 60 * 1000); if (new Date() > expirationDate) { return res.status(410).json({ error: `Le lien de téléchargement a expiré (limite de ${EXPIRATION_DAYS} jours).` }); } res.setHeader('Content-Type', 'application/pdf'); res.setHeader('Content-Disposition', `attachment; filename="${path.basename(safeFilePath)}"`); fs.createReadStream(safeFilePath).pipe(res); } catch (error) { console.error('[DOWNLOAD-PDF] Erreur:', error); res.status(500).json({ error: 'Erreur serveur.' }); } }); // NOUVELLE ROUTE : Signaler une erreur et enregistrer l'email router.post('/submission-error/report', [ body('sessionToken').isUUID(), body('email').isEmail() ], async (req, res) => { const errors = validationResult(req); if (!errors.isEmpty()) { return res.status(400).json({ error: 'Données invalides' }); } try { const { sessionToken, email } = req.body; await pool.execute( `UPDATE declarations SET user_email = ? WHERE uuid = ? AND status = 'submission_error'`, [email, sessionToken] ); console.log(`[ERROR-REPORT] Email ${email} enregistré pour la session ${sessionToken}`); res.json({ success: true, message: 'Votre email a été enregistré. Notre équipe vous contactera.' }); } catch (error) { console.error('[ERROR-REPORT] Erreur:', error); res.status(500).json({ error: 'Erreur lors de l`enregistrement de l`email.' }); } }); module.exports = router; ---------------------------------------------------- server/services/edecGenerator.js const edecGeneratorImport = require('./edecGeneratorImport'); const edecGeneratorExport = require('./edecGeneratorExport'); class EdecGenerator { generateImport(data) { return edecGeneratorImport.generateImport(data); } generateExport(data) { return edecGeneratorExport.generateExport(data); } } module.exports = new EdecGenerator(); ---------------------------------------------------- server/services/edecSubmissionService.js const { chromium } = require('playwright'); const fs = require('fs'); const path = require('path'); const { pool } = require('../db'); class EdecSubmissionService { constructor() { this.baseUrl = 'https://e-dec-web.ezv.admin.ch/webdec/main.xhtml'; this.downloadDir = path.join(__dirname, '../../edecpdf'); this.screenshotDir = path.join(__dirname, '../../screenshots'); this.statusListeners = new Map(); [this.downloadDir, this.screenshotDir].forEach(dir => { if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } }); } // ============= GESTION SSE & STATUS ============= registerListener(sessionToken, listener) { this.statusListeners.set(sessionToken, listener); console.log(`[SSE] Listener enregistré pour ${sessionToken}`); } removeListener(sessionToken) { this.statusListeners.delete(sessionToken); console.log(`[SSE] Listener supprimé pour ${sessionToken}`); } async updateStatus(sessionToken, status, step = null, progress = null) { const conn = await pool.getConnection(); try { console.log(`[STATUS] ${sessionToken} -> ${status} (${progress}%)`); const errorMessage = status.includes('error') ? String(status) : null; await conn.execute( `UPDATE declarations SET status = ?, error_message = ? WHERE uuid = ?`, [status, errorMessage, sessionToken] ); const listener = this.statusListeners.get(sessionToken); if (listener) { listener({ status, step: step || 'En cours...', progress: progress || 0 }); } } catch (error) { console.error(`[STATUS-ERROR] ${sessionToken}:`, error); throw error; } finally { conn.release(); } } // ============= UTILITAIRES ============= async takeScreenshot(page, name) { const screenshotPath = path.join(this.screenshotDir, `${name}_${Date.now()}.png`); await page.screenshot({ path: screenshotPath, fullPage: true }); console.log(`[SCREENSHOT] ${screenshotPath}`); return screenshotPath; } async clickById(page, elementId) { console.log(`[CLICK] Tentative de clic sur #${elementId}`); const clicked = await page.evaluate((id) => { const element = document.getElementById(id); if (element) { element.click(); return true; } return false; }, elementId); if (!clicked) { throw new Error(`Élément #${elementId} introuvable`); } console.log(`[CLICK] ✓ Clic réussi sur #${elementId}`); } async checkCheckbox(page, checkboxId) { console.log(`[CHECKBOX] Tentative de cocher #${checkboxId}`); const result = await page.evaluate((id) => { const checkbox = document.getElementById(id); if (checkbox && checkbox.type === 'checkbox') { checkbox.checked = true; return checkbox.checked; } return false; }, checkboxId); if (!result) { throw new Error(`Impossible de cocher la checkbox #${checkboxId}`); } console.log(`[CHECKBOX] ✓ Checkbox cochée #${checkboxId}`); } async isPopupVisible(page, popupId) { return await page.evaluate((id) => { const popup = document.getElementById(id); return popup && popup.style.visibility === 'visible'; }, popupId); } async waitForUrlChange(page, originalUrl, timeout = 10000) { try { await page.waitForFunction( (expectedUrl) => window.location.href !== expectedUrl, originalUrl, { timeout } ); console.log(`[URL] ✓ Changement détecté: ${page.url()}`); return true; } catch { console.log(`[URL] ✗ Aucun changement après ${timeout}ms`); return false; } } // ============= ÉTAPE 1: UPLOAD XML ============= async uploadXML(page, xmlFilePath, sessionToken) { console.log('[ÉTAPE 1] Début upload XML'); await this.updateStatus(sessionToken, 'submitting', 'Ouverture formulaire...', 15); // Clic sur "Zollanmeldung laden" console.log('[EDEC Submit] Clic sur "Zollanmeldung laden"...'); await page.click('#mainform\\:loadDeclaration'); await page.waitForTimeout(2000); console.log('[EDEC Submit] Attente de la popup de chargement...'); await page.waitForSelector('#mainform\\:declarationLoadPopup', { state: 'visible', timeout: 10000 }); console.log('[EDEC Submit] Recherche de l\'iframe d\'upload...'); const iframeElement = await page.waitForSelector( 'iframe#mainform\\:inputFileComponent\\:uploadFrame', { state: 'attached', timeout: 10000 } ); const frame = await iframeElement.contentFrame(); if (!frame) { throw new Error('Impossible d\'accéder au contenu de l\'iframe'); } // Upload du XML await this.updateStatus(sessionToken, 'submitting', 'Upload du fichier XML...', 30); console.log('[EDEC Submit] Upload du fichier XML...'); const fileInput = await frame.waitForSelector('input[type="file"]', { state: 'attached', timeout: 10000 }); await fileInput.setInputFiles(xmlFilePath); // Sauvegarde de l'URL avant confirmation const urlBeforeConfirm = page.url(); console.log(`[URL-BEFORE-CONFIRM] ${urlBeforeConfirm}`); console.log('[EDEC Submit] Clic sur le bouton OK...'); await page.click('a#mainform\\:confirmButton'); // Vérification que l'upload a réussi (changement d'URL ou élément visible) console.log('[EDEC Submit] Vérification succès upload...'); const uploadSuccess = await Promise.race([ this.waitForUrlChange(page, urlBeforeConfirm, 10000), page.waitForSelector('a#j_id49\\:j_id69', { timeout: 10000 }).then(() => 'button_found'), page.waitForSelector('span.iceOutFrmt', { timeout: 10000 }).then(() => 'confirmation_found') ]); if (!uploadSuccess) { await this.takeScreenshot(page, `upload_failed_${sessionToken}`); const currentUrl = page.url(); console.log(`[UPLOAD-ERROR] Aucun changement détecté. URL actuelle: ${currentUrl}`); throw new Error('Échec de l\'upload du XML - aucune confirmation détectée'); } console.log('[EDEC Submit] ✓ Upload XML réussi'); console.log(`[LANDING PAGE 2] URL actuelle: ${page.url()}`); } // ============= ÉTAPE 2: ENVOI & GESTION ERREURS ============= async submitAndHandleErrors(page, sessionToken) { console.log('[ÉTAPE 2] Début envoi et gestion erreurs'); await this.updateStatus(sessionToken, 'processing', 'Envoi de la déclaration...', 50); let maxAttempts = 5; let attempt = 0; while (attempt < maxAttempts) { attempt++; console.log(`[ENVOI] Tentative ${attempt}/${maxAttempts}`); // Clic sur le bouton Senden const urlBeforeSend = page.url(); await this.clickById(page, 'j_id49:j_id69'); // Attendre 4 secondes pour laisser le temps à la page de réagir await page.waitForTimeout(4000); // IMPORTANT: Vérifier d'abord si l'URL a changé (succès direct) const currentUrl = page.url(); if (currentUrl !== urlBeforeSend) { console.log('[ENVOI] ✓ URL changée immédiatement après Senden - Succès!'); console.log(`[ENVOI] Ancienne URL: ${urlBeforeSend}`); console.log(`[ENVOI] Nouvelle URL: ${currentUrl}`); return true; } // Si pas de changement d'URL, vérifier si popup d'invraisemblance apparaît const inplausibleVisible = await this.isPopupVisible(page, 'mainform:inplausibleDeclarationPopup'); if (inplausibleVisible) { console.log('[ENVOI] Popup d\'invraisemblance détectée, fermeture...'); await this.clickById(page, 'mainform:cancelButton'); await page.waitForTimeout(2000); // Vérifier les erreurs de validation await this.updateStatus(sessionToken, 'processing', 'Correction des erreurs...', 60 + (attempt * 5)); const errorResult = await this.checkAndFixValidationErrors(page, sessionToken); if (!errorResult.fixed) { throw new Error('Erreurs de validation non résolues'); } // Si l'URL a changé pendant la correction = SUCCÈS! if (errorResult.urlChanged) { console.log('[ÉTAPE 2] ✓ URL changée pendant la correction, passage à l\'étape 3'); return true; } // Réessayer l'envoi (continue = retour au début de la boucle while) console.log('[ENVOI] Erreurs corrigées, retour au début de la boucle...'); continue; } // Si on arrive ici sans popup et sans changement d'URL, réessayer console.log('[ENVOI] Pas de popup ni de changement d\'URL, nouvelle tentative...'); } throw new Error(`Échec après ${maxAttempts} tentatives d'envoi`); } // ============= GESTION DES ERREURS DE VALIDATION ============= async checkAndFixValidationErrors(page, sessionToken) { console.log('[ERREURS] Vérification erreurs de validation...'); const errorIconSelector = '#mainform\\:tree-d-1-0-c img[src*="exclamation.gif"][title*="Maske nicht korrekt ausgefüllt"]'; await page.waitForTimeout(3000); const errorIcons = await page.$$(errorIconSelector); if (!errorIcons || errorIcons.length === 0) { console.log('[ERREURS] ✓ Aucune erreur détectée'); return { fixed: true, urlChanged: false }; } console.log(`[ERREURS] ${errorIcons.length} erreur(s) détectée(s)`); await this.takeScreenshot(page, `errors_detected_${sessionToken}`); // FIX ICI: Utiliser une boucle au lieu de .map() const errorIds = []; for (const icon of errorIcons) { const id = await icon.getAttribute('id'); if (id) errorIds.push(id); } // Traiter chaque erreur for (let i = 0; i < errorIds.length; i++) { const errorId = errorIds[i]; console.log(`[ERREURS] Traitement erreur ${i + 1}/${errorIds.length}: ${errorId}`); const result = await this.fixErrorById(page, errorId, sessionToken, i); // Si l'URL a changé pendant la correction, c'est un succès direct! if (result === 'SUCCESS_URL_CHANGED') { console.log('[ERREURS] ✓ Correction réussie avec changement d\'URL!'); return { fixed: true, urlChanged: true }; } if (!result) { console.log(`[ERREURS] ✗ Impossible de corriger ${errorId}`); return { fixed: false, urlChanged: false }; } console.log(`[ERREURS] ✓ Correction de ${errorId} terminée`); await page.waitForTimeout(1000); } console.log('[ERREURS] ✓ Toutes les erreurs traitées'); return { fixed: true, urlChanged: false }; } async fixErrorById(page, errorId, sessionToken, errorIndex) { // Switch case basé sur l'ID de l'erreur switch (errorId) { case 'mainform:tree:n-1-0-1:j_id121': return await this.fixR125bError(page, sessionToken, errorIndex); // Ajouter d'autres cas ici pour d'autres types d'erreurs default: console.log(`[ERREURS] Type d'erreur non géré: ${errorId}`); return false; } } async fixR125bError(page, sessionToken, errorIndex) { try { console.log('[R125b] Début correction erreur R125b'); // Cliquer sur le lien de l'erreur await this.clickById(page, 'mainform:tree:n-1-0-1:link'); await page.waitForTimeout(2000); // Ouvrir le popup de détail await this.clickById(page, 'mainform:j_id224'); await page.waitForTimeout(2000); // Cocher la checkbox de correction await this.checkCheckbox(page, 'mainform:j_id367'); await page.waitForTimeout(1000); // Vérifier que la checkbox est bien cochée const isChecked = await page.evaluate(() => { return document.getElementById('mainform:j_id367')?.checked || false; }); if (!isChecked) { await this.takeScreenshot(page, `r125b_checkbox_fail_${sessionToken}_${errorIndex}`); throw new Error('Checkbox R125b non cochée'); } console.log('[R125b] ✓ Checkbox cochée'); // Fermer le popup await this.clickById(page, 'mainform:plausiPopupCloseButton'); await page.waitForTimeout(2000); await this.takeScreenshot(page, `r125b_fixed_${sessionToken}_${errorIndex}`); // NOUVEAU: Clic sur Senden après la correction const urlBeforeClick = page.url(); console.log('[R125b] Clic sur Senden après correction...'); await this.clickById(page, 'j_id49:j_id69'); await page.waitForTimeout(3000); // Vérifier si l'URL a changé (= succès, passage à l'étape 3) const currentUrl = page.url(); if (currentUrl !== urlBeforeClick) { console.log('[R125b] ✓ URL changée après correction! Passage direct à l\'étape 3'); console.log(`[R125b] Ancienne URL: ${urlBeforeClick}`); console.log(`[R125b] Nouvelle URL: ${currentUrl}`); return 'SUCCESS_URL_CHANGED'; // Code spécial pour indiquer le succès } console.log('[R125b] ✓ Erreur R125b corrigée, pas de changement d\'URL'); return true; } catch (error) { console.error(`[R125b] ✗ Échec correction: ${error.message}`); await this.takeScreenshot(page, `r125b_error_${sessionToken}_${errorIndex}`); return false; } } // ============= ÉTAPE 3: RÉCUPÉRATION DES PDF ============= async downloadPDFs(page, sessionToken) { console.log('[ÉTAPE 3] Récupération des PDF'); await this.updateStatus(sessionToken, 'processing', 'Téléchargement des PDF...', 90); // Extraction du numéro de déclaration let declarationNumber = null; try { const confirmationSpan = await page.waitForSelector('span.iceOutFrmt', { timeout: 10000 }); const spanText = await confirmationSpan.textContent(); console.log(`[PDF] Texte confirmation: ${spanText}`); const currentYear = new Date().getFullYear().toString().slice(-2); const match = spanText.match(new RegExp(`(${currentYear}CHWI[^\\s]+)`)) || spanText.match(/(\d{2}CHWI[^\\s]+)/); if (match) { declarationNumber = match[1]; console.log(`[PDF] Numéro de déclaration: ${declarationNumber}`); } } catch (error) { console.warn(`[PDF] Impossible d'extraire le numéro: ${error.message}`); } // Téléchargement des PDF await page.waitForSelector('a[href*=".pdf"]', { timeout: 15000 }); const pdfLinks = await page.$$('a.downloadLink[href*=".pdf"]'); if (pdfLinks.length < 2) { await this.takeScreenshot(page, `insufficient_pdfs_${sessionToken}`); throw new Error(`Seulement ${pdfLinks.length} PDF trouvé(s), 2 attendus`); } const listePath = path.join(this.downloadDir, `${sessionToken}_liste_importation.pdf`); const bulletinPath = path.join(this.downloadDir, `${sessionToken}_bulletin_delivrance.pdf`); // PDF 1 console.log('[PDF] Téléchargement PDF 1...'); const downloadPromise1 = page.waitForEvent('download', { timeout: 30000 }); await pdfLinks[0].click(); const download1 = await downloadPromise1; await download1.saveAs(listePath); console.log(`[PDF] ✓ PDF 1: ${listePath}`); // PDF 2 console.log('[PDF] Téléchargement PDF 2...'); await page.waitForTimeout(1000); const downloadPromise2 = page.waitForEvent('download', { timeout: 30000 }); await pdfLinks[1].click(); const download2 = await downloadPromise2; await download2.saveAs(bulletinPath); console.log(`[PDF] ✓ PDF 2: ${bulletinPath}`); // Sauvegarde en base de données await this.saveToDatabase(sessionToken, declarationNumber || 'UNKNOWN', listePath, bulletinPath); console.log('[ÉTAPE 3] ✓ PDF récupérés avec succès'); return { success: true, declarationNumber: declarationNumber || 'UNKNOWN', listePath, bulletinPath }; } async saveToDatabase(sessionToken, declarationNumber, listePath, bulletinPath) { const conn = await pool.getConnection(); try { await conn.execute(` UPDATE declarations SET declaration_number = ?, liste_importation_path = ?, bulletin_delivrance_path = ?, status = 'submitted', submission_date = NOW(), error_message = NULL WHERE uuid = ? `, [declarationNumber, listePath, bulletinPath, sessionToken]); const listener = this.statusListeners.get(sessionToken); if (listener) { listener({ status: 'submitted', step: 'Déclaration soumise avec succès', progress: 100, declarationNumber, listePath, bulletinPath }); } } finally { conn.release(); } } // ============= MÉTHODE PRINCIPALE ============= async submitDeclaration(xmlFilePath, sessionToken) { let browser = null; let page = null; try { console.log(`[DÉBUT] Soumission pour ${sessionToken}`); await this.updateStatus(sessionToken, 'submitting', 'Initialisation...', 5); // Lancement du navigateur browser = await chromium.launch({ headless: true, args: [ '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu' ] }); const context = await browser.newContext({ acceptDownloads: true, userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', viewport: { width: 1280, height: 720 } }); page = await context.newPage(); page.on('console', msg => console.log('[BROWSER]', msg.text())); page.on('pageerror', error => console.log('[BROWSER-ERROR]', error)); // Navigation initiale console.log('[NAVIGATION] Accès au portail...'); await page.goto(this.baseUrl, { waitUntil: 'networkidle', timeout: 60000 }); await page.waitForTimeout(2000); // ÉTAPE 1: Upload XML await this.uploadXML(page, xmlFilePath, sessionToken); // ÉTAPE 2: Envoi avec gestion des erreurs await this.submitAndHandleErrors(page, sessionToken); // ÉTAPE 3: Récupération des PDF const result = await this.downloadPDFs(page, sessionToken); await this.takeScreenshot(page, `success_${sessionToken}`); console.log('[FIN] ✓ Soumission terminée avec succès'); return result; } catch (error) { console.error('[ERREUR FATALE]', error); if (page) { await this.takeScreenshot(page, `fatal_error_${sessionToken}`); } await this.updateStatus( sessionToken, 'submission_error', `Échec: ${error.message}`, 100 ); throw error; } finally { if (browser) { await browser.close(); } } } } module.exports = new EdecSubmissionService(); ---------------------------------------------------- server/services/edecGeneratorImport.js function escapeXml(value) { if (value === null || value === undefined) return ''; return String(value) .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') .replace(/'/g, '''); } // invoiceCurrencyType mapping: // 1=CHF, 2=EUR, 3=USD, 4=Autre monnaie UE, 5=Autre monnaie function mapInvoiceCurrencyType(currency) { if (currency === 'CHF') return 1; if (currency === 'EUR') return 2; if (currency === 'USD') return 3; // Liste des pays de l'UE qui n'utilisent pas l'Euro (BGN, CZK, DKK, HUF, PLN, RON, SEK) const euNonEuroCurrencies = ['BGN', 'CZK', 'DKK', 'HUF', 'PLN', 'RON', 'SEK']; if (euNonEuroCurrencies.includes(currency)) { return 4; // Autre monnaie UE } return 5; // Autre monnaie } class EdecGeneratorImport { generateImport(data) { // CORRECTION: Remplace 'T' par un espace et 'Z' par ' UTC' pour le format e-dec (YYYY-MM-DD HH:mm:ss.sss UTC) const now = new Date().toISOString().replace('T', ' ').replace('Z', ' UTC'); const lang = (data.language || 'fr').toLowerCase(); const dispatchCountry = data.dispatch_country || 'FR'; const transportMode = data.transport_mode || '9'; // Le nom (raison sociale ou Prénom Nom) est désormais formaté par le controller client const importerName = escapeXml(data.user_name || ''); const importerStreet = escapeXml(data.user_address || ''); const importerZip = escapeXml(data.user_zip || ''); const importerCity = escapeXml(data.user_city || ''); const importerCountry = 'CH'; const ide = escapeXml(data.user_ide || 'CHE222251936'); // Particulier par défaut const consigneeName = escapeXml(data.consignee_name || data.user_name || ''); const consigneeStreet = escapeXml(data.consignee_address || data.user_address || ''); const consigneeZip = escapeXml(data.consignee_zip || data.user_zip || ''); const consigneeCity = escapeXml(data.consignee_city || data.user_city || ''); const consigneeIde = escapeXml(data.consignee_ide || ide); const declarantName = escapeXml(data.declarant_name || data.user_name || ''); const declarantStreet = escapeXml(data.declarant_address || data.user_address || ''); const declarantZip = escapeXml(data.declarant_zip || data.user_zip || ''); const declarantCity = escapeXml(data.declarant_city || data.user_city || ''); const declarantIde = escapeXml(data.declarant_ide || ''); const description = escapeXml(`${data.brand || ''} ${data.model || ''} ${data.year || ''}`.trim()); const commodityCode = escapeXml(data.commodity_code || '8703.9060'); const statisticalCode = escapeXml(data.statistical_key || '911'); // Les masses doivent être des nombres entiers (arrondi par précaution) const grossMass = Math.round(Number(data.weight_total || 0)); const netMass = Math.round(Number(data.weight_empty || 0)); // Correction: La valeur statistique et la base TVA sont arrondies à l'ENTIER. // statisticalValue (Prix d'achat hors coûts annexes) const statisticalValue = Math.round(Number(data.statistical_value_chf || data.statistical_value || 0)); // vatValue (Prix d'achat + coûts annexes) const vatValue = Math.round(Number(data.vat_value_chf || data.vatValue || statisticalValue)); const vin = escapeXml(data.vin || ''); const brandCode = escapeXml(data.brand_code || ''); const originCountry = escapeXml(data.origin_country || data.originCountry || dispatchCountry || 'FR'); const preference = 0; // pas de tarif préférentiel par défaut // TVA code: 1 normal, 3 déménagement const vatCode = data.is_relocation ? '3' : '1'; // Additional tax (IVA) RG 660: key 001 (assujetti) ou 002 (exonéré) const additionalTaxKey = data.is_iva_exempt ? '002' : '001'; // Assure que la quantité pour la taxe additionnelle est un entier const additionalTaxQty = Math.round(Number(data.additional_tax_value || data.purchase_price || statisticalValue || 0)); // Utilisation de data.purchase_currency pour déduire le type de monnaie const invoiceCurrencyType = mapInvoiceCurrencyType(data.purchase_currency); // Construction des détails de l'article de marchandise (VIN et Brand Code) let goodsItemDetailsXml = ''; if (vin || brandCode) { goodsItemDetailsXml = ` 2 ${vin} ${brandCode ? ` 1 ${brandCode} ` : ''} `; } // Ajout de l'entête XML et utilisation de la variable now corrigée const xml = ` 1 1 ${lang} ${dispatchCountry} ${transportMode} 0 ${importerName} ${importerStreet ? `${importerStreet}` : ''} ${importerZip} ${importerCity} ${importerCountry} ${ide} ${consigneeName} ${consigneeStreet ? `${consigneeStreet}` : ''} ${consigneeZip} ${consigneeCity} ${importerCountry} ${consigneeIde} ${declarantIde ? `${declarantIde}` : ''} ${declarantName} ${declarantStreet ? `${declarantStreet}` : ''} ${declarantZip} ${declarantCity} ${importerCountry} 0 0 0 ${invoiceCurrencyType} 0 ${description} ${commodityCode} ${statisticalCode} ${grossMass} ${netMass} 1 0 2 1 1 ${statisticalValue} 0 ${originCountry} ${preference} ${goodsItemDetailsXml} VN 1 ${description} 0 ${vatValue} ${vatCode} 660 ${additionalTaxKey} ${additionalTaxQty} `; return xml; } } module.exports = new EdecGeneratorImport(); ---------------------------------------------------- server/services/declarationService.js const { pool } = require('../db'); const path = require('path'); const fs = require('fs'); class DeclarationService { /** * Crée l'enregistrement principal dans la table 'declarations'. */ async createDeclarationRecord(conn, uuid, type, data) { const [result] = await conn.execute( `INSERT INTO declarations (uuid, declaration_type, user_name, user_firstname, user_address, user_zip, user_city, user_country, user_ide, language, status) VALUES (?, ?, ?, ?, ?, ?, ?, 'CH', ?, ?, 'draft')`, [ uuid, type.toUpperCase(), data.user_name || '', data.user_firstname || null, data.user_address || null, data.user_zip || '', data.user_city || '', data.user_ide || null, (data.language || 'fr').toLowerCase() ] ); return result.insertId; } /** * Crée l'enregistrement des détails d'importation. */ async createImportDetailsRecord(conn, declarationId, data) { return conn.execute( `INSERT INTO import_details (declaration_id, dispatch_country, transport_mode, is_relocation, purchase_price, purchase_currency, transport_cost, other_costs, statistical_value, vat_value) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ declarationId, data.dispatch_country, data.transport_mode, data.is_relocation ? 1 : 0, Number(data.purchase_price || 0), data.purchase_currency, Number(data.transport_cost || 0), Number(data.other_costs || 0), data.statistical_value_chf, data.vat_value_chf ] ); } /** * Crée l'enregistrement des détails d'exportation. */ async createExportDetailsRecord(conn, declarationId, data) { return conn.execute( `INSERT INTO export_details (declaration_id, destination_country, taxation_code) VALUES (?, ?, ?)`, [ declarationId, data.destination_country, data.taxation_code || null ] ); } /** * Crée l'enregistrement du véhicule. */ async createVehicleRecord(conn, declarationId, data) { return conn.execute( `INSERT INTO vehicles (declaration_id, vin, brand, model, year, cylinder_capacity, fuel_type, weight_empty, weight_total, tares_code, statistical_key, country_origin, brand_code) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ declarationId, (data.vin || '').toUpperCase(), data.brand, data.model, data.year || null, data.cylinder_capacity || null, data.fuel_type || null, data.weight_empty || null, data.weight_total || null, data.commodity_code || (data.declaration_type === 'EXPORT' ? '8703.9060' : null), data.statistical_key || '911', data.origin_country || null, data.brand_code || null ] ); } /** * Orchestre la création d'une déclaration d'importation complète. */ async createFullImportDeclaration(conn, txId, data) { const declarationId = await this.createDeclarationRecord(conn, txId, 'IMPORT', data); await this.createImportDetailsRecord(conn, declarationId, data); await this.createVehicleRecord(conn, declarationId, { ...data, declaration_type: 'IMPORT' }); return declarationId; } /** * Orchestre la création d'une déclaration d'exportation complète. */ async createFullExportDeclaration(conn, txId, data) { const declarationId = await this.createDeclarationRecord(conn, txId, 'EXPORT', data); await this.createExportDetailsRecord(conn, declarationId, data); await this.createVehicleRecord(conn, declarationId, { ...data, declaration_type: 'EXPORT' }); return declarationId; } /** * Sauvegarde le fichier XML et met à jour la base de données. */ async saveXmlFile(conn, txId, xml, declarationId) { const xmlDir = path.join(__dirname, '../../xml'); if (!fs.existsSync(xmlDir)) { fs.mkdirSync(xmlDir, { recursive: true }); } const xmlFilePath = path.join(xmlDir, `${txId}.xml`); fs.writeFileSync(xmlFilePath, xml, 'utf8'); await conn.execute( `UPDATE declarations SET xml_content = ?, xml_file_path = ?, status = 'generated' WHERE id = ?`, [xml, xmlFilePath, declarationId] ); return xmlFilePath; } /** * Traduit le statut de la base de données en progression et étape pour le client. */ getSubmissionProgress(status) { let progress = 0; let step = ''; switch (status) { case 'generated': progress = 25; step = 'Déclaration générée, en attente de soumission'; break; case 'submitting': progress = 50; step = 'Connexion au portail e-dec'; break; case 'processing': progress = 75; step = 'Traitement par la douane'; break; case 'submitted': progress = 100; step = 'Soumission terminée avec succès'; break; case 'submission_error': progress = 100; // L'erreur est une finalité step = 'Erreur de soumission'; break; default: progress = 10; step = 'Initialisation'; } return { progress, step }; } /** * Recherche le brand_code par le nom de la marque. */ async getBrandCodeByBrandName(brandName) { if (!brandName) return null; const cleanedBrandName = brandName.trim().toUpperCase(); const query = `SELECT brand_code FROM brand_codes WHERE brand_name = ?`; const [rows] = await pool.execute(query, [cleanedBrandName]); return rows.length > 0 ? rows[0].brand_code : null; } /** * Récupère la liste complète des brand_codes. */ async getAllBrandCodes() { const query = `SELECT brand_code, brand_name FROM brand_codes ORDER BY brand_name`; const [rows] = await pool.execute(query); return rows; } } module.exports = new DeclarationService(); ---------------------------------------------------- server/services/ocrService.js const axios = require('axios'); const fs = require('fs'); const sharp = require('sharp'); const path = require('path'); /** * Service pour l'OCR (Reconnaissance Optique de Caractères) * utilisant l'API OpenRouter (Gemini) pour extraire les données * de documents (images ou PDF) de type carte grise/certificat d'immatriculation. */ class OcrService { constructor() { // Initialisation des propriétés de configuration this.apiKey = process.env.OPENROUTER_API_KEY; this.model = process.env.OPENROUTER_MODEL || 'google/gemini-2.5-flash'; console.log('[OCR Service] Initialisé avec:', { hasApiKey: !!this.apiKey, apiKeyPreview: this.apiKey ? this.apiKey.substring(0, 10) + '...' : 'MANQUANT', model: this.model }); } /** * Construit le prompt détaillé pour l'extraction de données de carte grise. * @returns {string} Le prompt à envoyer au modèle. */ buildExtractionPrompt() { return `Tu es un expert en extraction de données de certificats d'immatriculation (cartes grises) européens (Suisse, France, Allemagne, Italie, etc.). Analyse l'image ou le document fourni et retourne UNIQUEMENT un objet JSON strict correspondant au format: {"vin":"VF3LCYHZPFS123456","brand":"Peugeot","model":"308","year":2015,"cylinder_capacity":1560,"fuel_type":"diesel","weight_empty":1245,"weight_total":1870,"type_approval":"e11*2007/46*0009*15"} RÈGLES ET TERMES DE RECHERCHE PAR CHAMP: - vin: 17 caractères alphanumériques. Cherche les champs E, 23, FIN, Chassis N., Fahrgestell-Nr, Telaio n., ou VIN. Supprime les espaces internes pour la valeur finale. IMPORTANT: Les lettres I, O et Q ne sont JAMAIS utilisées dans un VIN (norme ISO 3779). Si tu rencontres un I, considère-le comme un 1. Si tu rencontres un O ou un Q, considère-les comme des 0. - brand: D.1, Marke. - model: D.3, Modèle, Typ. - year: L'année de la première mise en circulation. - Cherche les champs B, 1. Inverkehrsetzung, 1ère mise en circulation, Data di prima immatricolazione. Isole UNIQUEMENT l'année (YYYY). - cylinder_capacity: P.1, Hubraum, Cilindrata, Cylindrée. (Int en cm³) - fuel_type: P.3, type de carburant, Carburant, Kraftstoff, Alimentazione. (essence|diesel|electrique|hybride|hybride_plugin) - weight_empty: G, Leergewicht, Poids à vide, massa a vuoto. (Int en kg) - weight_total: F.2, Gesamtgewicht, Poids total autorisé en charge, Massa massima ammissibile a pieno carico. (Int en kg) - type_approval: K, Réception par type, Typengenehmigung, Approvazione del tipo. Retourne strictement du JSON valide sans commentaire ni texte. Si une valeur est totalement inconnue, utilise: null.`.trim(); } /** * Valide et nettoie un numéro VIN. * @param {string} vin - Le VIN à valider. * @returns {string|null} Le VIN nettoyé ou null si invalide. */ validateVIN(vin) { if (!vin) return null; // Supprimer tous les espaces et normaliser en majuscules let clean = vin.replace(/\s/g, '').toUpperCase().trim(); // Correction automatique des lettres interdites (ISO 3779: I, O, Q) const corrected = clean .replace(/I/g, '1') .replace(/[OQ]/g, '0'); if (clean !== corrected) { console.warn('[OCR] VIN corrigé automatiquement:', { original: vin, corrected }); } // Vérifie que c'est bien un VIN valide (17 caractères, sans I, O, Q) return /^[A-HJ-NPR-Z0-9]{17}$/.test(corrected) ? corrected : null; } /** * Valide l'année. * @param {number|string} year - L'année à valider. * @returns {number|null} L'année ou null. */ validateYear(year) { if (!year) return null; const y = parseInt(year, 10); return (y >= 1900 && y <= new Date().getFullYear() + 1) ? y : null; } /** * Valide un entier positif. * @param {number|string} value - La valeur à valider. * @returns {number|null} L'entier ou null. */ validateInteger(value) { if (value === null || value === undefined || value === '') return null; const int = parseInt(value, 10); // Vérifie que c'est un nombre fini et strictement positif return Number.isFinite(int) && int > 0 ? int : null; } /** * Normalise le type de carburant. * @param {string} fuelType - Le type de carburant brut. * @returns {string|null} Le type normalisé ou null. */ normalizeFuelType(fuelType) { if (!fuelType) return null; const normalized = fuelType.toLowerCase().trim(); if (normalized.includes('plug')) return 'hybride_plugin'; if (normalized.includes('hybr')) return 'hybride'; if (normalized.includes('elec') || normalized.includes('élec')) return 'electrique'; if (normalized.includes('dies')) return 'diesel'; if (normalized.includes('ess') || normalized.includes('petrol') || normalized.includes('gasoline')) return 'essence'; return normalized; } /** * Nettoie et parse la réponse JSON du modèle. * @param {string} response - La réponse brute du modèle. * @returns {object} L'objet de données parsé et validé. */ parseGeminiJSON(response) { console.log('[OCR] ÉTAPE 7a - Parsing JSON, réponse brute:', response); // Supprime les balises de code Markdown JSON const clean = response.replace(/```json|```/g, '').trim(); console.log('[OCR] ÉTAPE 7b - Après nettoyage:', clean); try { const data = JSON.parse(clean); console.log('[OCR] ÉTAPE 7c - JSON parsé:', data); // Validation et normalisation des champs extraits return { vin: this.validateVIN(data.vin), brand: data.brand || null, model: data.model || null, year: this.validateYear(data.year), cylinder_capacity: this.validateInteger(data.cylinder_capacity), fuel_type: this.normalizeFuelType(data.fuel_type), weight_empty: this.validateInteger(data.weight_empty), weight_total: this.validateInteger(data.weight_total), type_approval: data.type_approval || null }; } catch (err) { console.error('[OCR] ERREUR ÉTAPE 7 - Parsing JSON:', err); throw new Error('Format de réponse OCR invalide: ' + err.message); } } /** * Extrait les données d'un fichier (image ou PDF) via l'API OpenRouter. * @param {string} filePath - Chemin d'accès au fichier. * @param {string} mimeType - Type MIME du fichier. * @returns {Promise} Les données extraites. */ async extractFromFile(filePath, mimeType) { console.log('[OCR] ÉTAPE 1 - Début extraction:', { filePath, mimeType }); if (!this.apiKey) { throw new Error('OPENROUTER_API_KEY manquante dans .env'); } // Validation simple du type MIME const safeMime = ['image/jpeg', 'image/png', 'application/pdf'].includes(mimeType) ? mimeType : 'image/jpeg'; console.log('[OCR] ÉTAPE 2 - Type MIME validé:', safeMime); let base64Data; let contentType; let dataUrlPreview; try { if (safeMime === 'application/pdf') { console.log('[OCR] ÉTAPE 3a - Traitement PDF'); const pdfBuffer = fs.readFileSync(filePath); console.log('[OCR] PDF lu, taille:', pdfBuffer.length, 'bytes'); // Conversion PDF en Base64 base64Data = Buffer.from(pdfBuffer).toString('base64'); contentType = 'application/pdf'; console.log('[OCR] PDF converti en base64, longueur:', base64Data.length); } else { // Traitement d'image (optimisation avec sharp) console.log('[OCR] ÉTAPE 3b - Traitement Image'); const optimized = await sharp(filePath) .rotate() .resize({ width: 1800, withoutEnlargement: true }) .jpeg({ quality: 80 }) .toBuffer(); console.log('[OCR] Image optimisée, taille:', optimized.length, 'bytes'); base64Data = optimized.toString('base64'); contentType = 'image/jpeg'; console.log('[OCR] Image convertie en base64, longueur:', base64Data.length); } dataUrlPreview = `data:${contentType};base64,...${base64Data.substring(base64Data.length - 10)}`; console.log('[OCR] ÉTAPE 3c - Data URL construite, aperçu:', dataUrlPreview); const dataSizeMB = base64Data.length * 0.75 / (1024 * 1024); console.log(`[OCR] ÉTAPE 3d - Taille du contenu à envoyer: ${dataSizeMB.toFixed(2)} MB`); if (dataSizeMB > 15) { console.warn('[OCR] AVERTISSEMENT: La taille du contenu est supérieure à 15MB, risque d\'échec API.'); } } catch (error) { console.error('[OCR] ERREUR ÉTAPE 3 - Lecture/conversion fichier:', error); throw new Error('Impossible de lire le fichier: ' + error.message); } // Début de la construction de la requête API (Étapes 4 et 5 fusionnées) const prompt = this.buildExtractionPrompt(); console.log('[OCR] ÉTAPE 4 - Prompt construit, longueur:', prompt.length); console.log('[OCR] ÉTAPE 5 - Envoi requête à OpenRouter'); const fileContent = {}; const dataUrl = `data:${contentType};base64,${base64Data}`; // Construction dynamique de l'objet pour le contenu multimédia (image_url ou file) if (contentType === 'application/pdf') { // Utilisation du type 'file' pour les PDFs fileContent.type = 'file'; fileContent.file = { file_data: dataUrl, filename: path.basename(filePath) }; } else { // Utilisation du type 'image_url' pour les images fileContent.type = 'image_url'; fileContent.image_url = { url: dataUrl }; } try { const response = await axios.post('https://openrouter.ai/api/v1/chat/completions', { model: this.model, messages: [ { role: 'user', content: [ { type: 'text', text: prompt }, fileContent // L'objet construit (image_url ou file) ] } ], // Paramètres spécifiques pour l'extraction de données temperature: 0.1, max_tokens: 4000 // Remis à 4000 (comme dans le 1er fragment) pour plus de flexibilité }, { headers: { 'Authorization': `Bearer ${this.apiKey}`, 'Content-Type': 'application/json', // Utilisez une logique de fallback pour le referer 'HTTP-Referer': process.env.REFERER_URL || 'http://localhost:3000', 'X-Title': 'e-dec Vehicles' // Maintenu le titre spécifique }, timeout: 30000 }); console.log('[OCR] ÉTAPE 6 - Réponse reçue:', { status: response.status, hasChoices: !!response.data?.choices, choicesLength: response.data?.choices?.length }); const extractedText = response.data.choices?.[0]?.message?.content || ''; console.log('[OCR] ÉTAPE 7 - Contenu extrait (aperçu):', extractedText.substring(0, 200)); // Parsing et validation const parsed = this.parseGeminiJSON(extractedText); console.log('[OCR] ÉTAPE 8 - JSON parsé et validé avec succès:', parsed); return parsed; } catch (error) { // Gestion détaillée des erreurs API (étapes 5/6) const status = error.response?.status || 'Aucun'; const code = error.code || 'Inconnu'; console.error('[OCR] ERREUR ÉTAPE 5/6 - Requête API:', { message: error.message, status: status, statusText: error.response?.statusText, data: error.response?.data }); if (status === 401) { throw new Error('Clé API OpenRouter invalide ou expirée (401)'); } if (status === 429) { throw new Error('Quota OpenRouter dépassé (429)'); } if (code === 'ECONNABORTED') { throw new Error('Timeout: OpenRouter n\'a pas répondu dans les 30 secondes'); } // Erreur générale throw new Error('Erreur API OpenRouter inattendue: ' + (error.response?.data?.error?.message || error.message)); } } } // Exportation de l'instance du service pour une utilisation directe module.exports = new OcrService(); ---------------------------------------------------- server/services/edecGeneratorExport.js function escapeXml(value) { if (value === null || value === undefined) return ''; return String(value) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } class EdecGeneratorExport { generateExport(data) { const now = new Date().toISOString().replace('T', ' ').replace('Z', ' UTC'); const lang = (data.language || 'fr').toLowerCase(); const exporterName = escapeXml(data.user_name || ''); const exporterStreet = escapeXml(data.user_address || ''); const exporterZip = escapeXml(data.user_zip || ''); const exporterCity = escapeXml(data.user_city || ''); const exporterCountry = 'CH'; // L'export se fait depuis la Suisse const exporterIde = escapeXml(data.user_ide || ''); const description = escapeXml(`${data.brand || ''} ${data.model || ''} ${data.year || ''}`.trim()); const commodityCode = escapeXml(data.commodity_code || '8703.9060'); const statisticalCode = escapeXml(data.statistical_key || '911'); const vin = escapeXml(data.vin || ''); const destinationCountry = escapeXml(data.destination_country || ''); // Les masses sont optionnelles pour l'export de base mais peuvent être utiles const grossMass = Math.round(Number(data.weight_total || 0)); const xml = ` 2 2 ${lang} ${destinationCountry} 9 0 ${exporterName} ${exporterStreet ? `${exporterStreet}` : ''} ${exporterZip} ${exporterCity} ${exporterCountry} ${exporterIde ? `${exporterIde}` : ''} 0 0 ${description} ${commodityCode} ${statisticalCode} ${grossMass > 0 ? `${grossMass}` : ''} 1 0 2 1 1 2 ${vin} VN 1 ${description} `; return xml; } } module.exports = new EdecGeneratorExport(); ---------------------------------------------------- server/services/exchangeRateService.js const mysql = require('mysql2/promise'); class ExchangeRateService { constructor() { this.dbConfig = { host: process.env.DB_HOST, user: process.env.DB_USER, password: process.env.DB_PASS, database: process.env.DB_NAME }; } async getCurrentRates() { const connection = await mysql.createConnection(this.dbConfig); try { const [rows] = await connection.execute(` SELECT currency_code, rate_to_chf, rate_date FROM daily_exchange_rates WHERE rate_date = ( SELECT MAX(rate_date) FROM daily_exchange_rates ) ORDER BY currency_code `); const rates = {}; rows.forEach(r => rates[r.currency_code] = { rate: parseFloat(r.rate_to_chf), date: r.rate_date }); return rates; } finally { await connection.end(); } } async getRate(currency) { if (!currency) return 1.0; if (currency === 'CHF') return 1.0; const connection = await mysql.createConnection(this.dbConfig); try { const [rows] = await connection.execute(` SELECT rate_to_chf FROM daily_exchange_rates WHERE currency_code = ? ORDER BY rate_date DESC LIMIT 1 `, [currency]); return rows.length > 0 ? parseFloat(rows[0].rate_to_chf) : 1.0; } finally { await connection.end(); } } async convert(amount, fromCurrency, toCurrency = 'CHF') { const amt = Number(amount || 0); if (!amt) return 0; if (fromCurrency === toCurrency) return Math.round(amt * 100) / 100; const rateFrom = await this.getRate(fromCurrency); const rateTo = await this.getRate(toCurrency); const amountInCHF = amt * rateFrom; const converted = amountInCHF / rateTo; return Math.round(converted * 100) / 100; } } module.exports = new ExchangeRateService(); ---------------------------------------------------- server/services/exchangeRateCronService.js const axios = require('axios'); const { pool } = require('../db'); const cron = require('node-cron'); class ExchangeRateCronService { constructor() { this.pdfUrl = 'https://www.backend-rates.bazg.admin.ch/pdf?activeSearchType=yesterday&locale=fr'; this.cronSchedule = '0 2 * * 1-5'; // Lundi à vendredi à 2h00 } /** * Parse le PDF des taux de change et extrait les données */ async fetchAndParseRates() { try { console.log('[EXCHANGE-CRON] Début récupération des taux de change...'); // Télécharger le PDF const response = await axios.get(this.pdfUrl, { responseType: 'arraybuffer', timeout: 30000 }); const pdfBuffer = Buffer.from(response.data); console.log('[EXCHANGE-CRON] PDF téléchargé, taille:', pdfBuffer.length, 'bytes'); // Parser le PDF avec pdf-parse const pdfParse = require('pdf-parse'); const pdfData = await pdfParse(pdfBuffer); const text = pdfData.text; console.log('[EXCHANGE-CRON] PDF parsé, extraction des taux...'); // Extraction des taux via regex // Format attendu: "EUR 1.0458" ou "USD 0.9123" etc. const rateRegex = /([A-Z]{3})\s+([\d.]+)/g; const rates = []; let match; while ((match = rateRegex.exec(text)) !== null) { const currency = match[1]; const rate = parseFloat(match[2]); // Filtrer les faux positifs (codes qui ne sont pas des devises) if (rate > 0 && rate < 1000 && currency.length === 3) { rates.push({ currency, rate }); } } console.log(`[EXCHANGE-CRON] ${rates.length} taux extraits`); if (rates.length === 0) { throw new Error('Aucun taux de change extrait du PDF'); } // Sauvegarder en base de données await this.saveRatesToDatabase(rates); console.log('[EXCHANGE-CRON] ✓ Mise à jour des taux terminée avec succès'); return rates; } catch (error) { console.error('[EXCHANGE-CRON] Erreur:', error); throw error; } } /** * Sauvegarde les taux en base de données (UPDATE ou INSERT) */ async saveRatesToDatabase(rates) { const conn = await pool.getConnection(); try { await conn.beginTransaction(); for (const { currency, rate } of rates) { // UPDATE si existe, sinon INSERT await conn.execute(` INSERT INTO daily_exchange_rates (currency_code, rate_to_chf, rate_date) VALUES (?, ?, CURDATE()) ON DUPLICATE KEY UPDATE rate_to_chf = VALUES(rate_to_chf), rate_date = VALUES(rate_date) `, [currency, rate]); } await conn.commit(); console.log(`[EXCHANGE-CRON] ${rates.length} taux sauvegardés en BDD`); } catch (error) { await conn.rollback(); console.error('[EXCHANGE-CRON] Erreur sauvegarde BDD:', error); throw error; } finally { conn.release(); } } /** * Démarre le CRON job */ start() { console.log('[EXCHANGE-CRON] Démarrage du CRON (lun-ven à 2h00)'); cron.schedule(this.cronSchedule, async () => { console.log('[EXCHANGE-CRON] Déclenchement du CRON'); try { await this.fetchAndParseRates(); } catch (error) { console.error('[EXCHANGE-CRON] Échec du CRON:', error.message); } }); // Exécution immédiate au démarrage (optionnel, commentez si non souhaité) // this.fetchAndParseRates().catch(err => console.error('[EXCHANGE-CRON] Erreur init:', err)); } } module.exports = new ExchangeRateCronService(); ---------------------------------------------------- server/services/cleanupService.old const { pool } = require('../db'); // Ajouter cette ligne class CleanupService { constructor() { this.stuckDeclarations = new Set(); } async checkForStuckSubmissions() { try { const conn = await pool.getConnection(); try { // Trouver les déclarations en processing depuis plus de 5 minutes const [rows] = await conn.execute(` SELECT uuid, created_at FROM declarations WHERE status = 'processing' AND created_at < DATE_SUB(NOW(), INTERVAL 5 MINUTE) `); for (const row of rows) { console.log(`[CLEANUP] Déclaration bloquée détectée: ${row.uuid}`); await conn.execute( `UPDATE declarations SET status = 'timeout', error_message = ? WHERE uuid = ?`, ['Processus interrompu - timeout système', row.uuid] ); this.stuckDeclarations.add(row.uuid); } if (rows.length > 0) { console.log(`[CLEANUP] ${rows.length} déclaration(s) bloquée(s) nettoyée(s)`); } } finally { conn.release(); } } catch (error) { console.error('[CLEANUP-ERROR]:', error); } } startCleanupInterval() { // Vérifier toutes les 5 minutes setInterval(() => { this.checkForStuckSubmissions(); }, 5 * 60 * 1000); } } module.exports = new CleanupService(); ---------------------------------------------------- server/services/taresClassifier.js const fs = require('fs'); const path = require('path'); // Assurez-vous que le chemin d'accès à votre pool de connexion DB est correct const { pool } = require('../db'); /** * Gère la logique de classification des véhicules selon les règles TARES * et la validation/lookup des VIN. * * NOTE: Les règles TARES (taresRules) et la classification s'attendent à être passées * par le contrôleur ou lues à l'initialisation du serveur, * car la classe n'est plus chargée statiquement par le constructeur. */ class TaresClassifier { // Suppression du constructeur qui lisait statiquement les fichiers. normalizeFuelType(fuelType) { if (!fuelType) return 'essence'; const normalized = fuelType.toLowerCase().trim(); // La nouvelle logique de normalisation est plus simple et utilise des sous-chaînes if (normalized.includes('plug')) return 'hybride_plugin'; if (normalized.includes('hybr')) return 'hybride'; if (normalized.includes('elec') || normalized.includes('élec')) return 'electrique'; if (normalized.includes('dies')) return 'diesel'; if (normalized.includes('ess') || normalized.includes('petrol') || normalized.includes('gasoline')) return 'essence'; return normalized; } matchesRule(fuel, cylinder, weightEmpty, rule) { if (rule.fuel && rule.fuel !== fuel) return false; if (rule.cylinder_min !== undefined && cylinder < rule.cylinder_min) return false; if (rule.cylinder_max !== undefined && cylinder > rule.cylinder_max) return false; // Utilisation de weightEmpty (poids à vide) pour la classification if (rule.weight_min !== undefined && weightEmpty < rule.weight_min) return false; if (rule.weight_max !== undefined && weightEmpty > rule.weight_max) return false; return true; } /** * Tente de classer le véhicule en utilisant les règles de configuration fournies. * Si aucune correspondance n'est trouvée, utilise un fallback détaillé. * @param {object} vehicleData - Données du véhicule (fuel_type, cc, weight, etc.) * @param {object} taresRules - Les règles TARES lues depuis le fichier de configuration (passées par l'appelant) */ async classify(vehicleData, taresRules = {}) { const fuelType = this.normalizeFuelType(vehicleData.fuel_type || 'essence'); const cc = parseInt(vehicleData.cylinder_capacity || 0, 10); const wEmpty = parseInt(vehicleData.weight_empty || 0, 10); const wTotal = parseInt(vehicleData.weight_total || 0, 10); // 1. Tente d'appliquer les règles de configuration d'abord for (const [code, rule] of Object.entries(taresRules || {})) { if (this.matchesRule(fuelType, cc, wEmpty, rule)) { const { statistical_keys = { '911': 'Automobile' }, description = 'Véhicule' } = rule; const { key, needs_user_selection, description: skDesc, available_keys } = this.selectStatisticalKey(statistical_keys, wTotal); return { commodity_code: code, description, statistical_key: needs_user_selection ? null : key, statistical_key_description: needs_user_selection ? null : (skDesc || statistical_keys[key]), needs_user_selection, available_keys: needs_user_selection ? statistical_keys : null }; } } // 2. Logique de fallback manuelle pour une classification plus précise (comme les règles 8703.xx) if (fuelType === 'diesel') { if (cc <= 1500) return { commodity_code: '8703.3100', statistical_key: '911', description: 'Véhicule diesel ≤1500 cm3', needs_user_selection: false }; if (cc > 1500 && cc <= 2500) { if (wEmpty <= 1200) return { commodity_code: '8703.3240', statistical_key: '911', description: 'Diesel >1500 ≤2500 cm3, ≤1200kg', needs_user_selection: false }; if (wEmpty > 1200 && wEmpty <= 1600) return { commodity_code: '8703.3250', statistical_key: '911', description: 'Diesel >1500 ≤2500 cm3, >1200kg ≤1600kg', needs_user_selection: false }; return { commodity_code: '8703.3260', statistical_key: '911', description: 'Diesel >1500 ≤2500 cm3, >1600kg', needs_user_selection: false }; } if (cc > 2500) { if (wEmpty <= 1600) return { commodity_code: '8703.3330', statistical_key: '911', description: 'Diesel >2500 cm3, ≤1600kg', needs_user_selection: false }; return { commodity_code: '8703.3340', statistical_key: '911', description: 'Diesel >2500 cm3, >1600kg', needs_user_selection: false }; } } // 3. Fallback générique si rien ne correspond return { commodity_code: '8703.9060', statistical_key: '911', description: 'Autres véhicules', needs_user_selection: false }; } selectStatisticalKey(keys, weightTotal) { if (!keys || Object.keys(keys).length === 0) return { key: '911', description: 'Automobile', needs_user_selection: false, available_keys: null }; // Cas 1: Une seule clé disponible if (Object.keys(keys).length === 1) { const k = Object.keys(keys)[0]; return { key: k, description: keys[k], needs_user_selection: false, available_keys: null }; } // Cas 2: Sélection automatique basée sur le Poids Total (ex: 921 > 3500kg, 923 <= 3500kg) if (keys['921'] && keys['923']) { return weightTotal > 3500 ? { key: '921', description: keys['921'], needs_user_selection: false, available_keys: null } : { key: '923', description: keys['923'], needs_user_selection: false, available_keys: null }; } // Cas 3: Sélection manuelle requise return { key: null, description: null, needs_user_selection: true, available_keys: keys }; } /** * Récupère le pays de fabrication (WMI) à partir du VIN en utilisant la base de données. * @param {string} vin - Numéro d'identification du véhicule. * @returns {Promise} - Code pays (ex: 'FR') ou null. */ async getCountryFromVIN_DB(vin) { if (!vin || vin.length < 2) return null; const wmi = vin.substring(0, 2).toUpperCase(); // Utilisation du pool pour la connexion à la base de données const conn = await pool.getConnection(); try { const [rows] = await conn.execute(` SELECT country_code FROM vin_wmi_country_codes WHERE ? BETWEEN wmi_start AND wmi_end LIMIT 1 `, [wmi]); // Assurez-vous d'avoir des colonnes 'wmi_start', 'wmi_end' et 'country_code' dans votre table SQL. return rows?.[0]?.country_code || null; } catch (error) { console.error("Erreur DB WMI:", error.message); return null; } finally { if (conn) conn.release(); } } } // Exportation en tant qu'instance unique (Singleton) module.exports = new TaresClassifier(); ---------------------------------------------------- server/db.js const mysql = require('mysql2/promise'); const pool = mysql.createPool({ host: process.env.DB_HOST, user: process.env.DB_USER, password: process.env.DB_PASS, database: process.env.DB_NAME, waitForConnections: true, connectionLimit: 10, queueLimit: 0 }); module.exports = { pool, getConnection: () => pool.getConnection() }; ---------------------------------------------------- server/controllers/homeController.js // Obsolète (SPA), conservé pour compatibilité si utilisé ailleurs const path = require('path'); const router = require('express').Router(); router.get('/', (req, res) => { res.sendFile(path.join(__dirname, '..', 'public', 'index.html')); }); router.get('/:lang', (req, res) => { res.sendFile(path.join(__dirname, '..', 'public', 'index.html')); }); module.exports = router; ---------------------------------------------------- server/controllers/importController.js // Obsolète, les routes actuelles sont dans /server/routes/api.js const router = require('express').Router(); router.get('/ping', (req, res) => res.json({ ok: true })); module.exports = router; ---------------------------------------------------- server/controllers/exportController.js // Obsolète, les routes actuelles sont dans /server/routes/api.js module.exports = { submit: (req, res) => { const xml = ` 2 `; res.set('Content-Type', 'application/xml'); res.set('Content-Disposition', `attachment; filename="edec-export-${Date.now()}.xml"`); res.send(xml); } }; ---------------------------------------------------- public/js/controllers/homeController.js angular.module('edecApp') .controller('HomeController', function($scope) { $scope.features = [ { icon: '⚡', titleKey: 'feature_fast_title', descriptionKey: 'feature_fast_desc' }, { icon: '💰', titleKey: 'feature_eco_title', descriptionKey: 'feature_eco_desc' }, { icon: '✅', titleKey: 'feature_simple_title', descriptionKey: 'feature_simple_desc' }, { icon: '🤖', titleKey: 'feature_ai_title', descriptionKey: 'feature_ai_desc' } ]; }); ---------------------------------------------------- public/js/controllers/importController.js angular.module('edecApp') .controller('ImportController', function($scope, $http, $window, $rootScope, $timeout, ConfigService, TranslationService) { // V3 initScope (inline state management) function initScope() { $scope.step = 1; $scope.method = null; $scope.vehicle = {}; $scope.parties = { importer: { name: '', firstname: '', address: '', zip: '', city: '', ide: '' }, consignee: { name: '', firstname: '', address: '', zip: '', city: '', ide: '' }, declarant: { name: '', firstname: '', address: '', zip: '', city: '', ide: '' } }; $scope.declarantType = 'importer'; // importer | consignee | other $scope.declaration = { transport_mode: '9', purchase_currency: 'EUR', purchase_price: 0, transport_cost: 0, other_costs: 0, dispatch_country: '', language: $rootScope.lang || 'fr' }; $scope.classification = null; $scope.ocrLoading = false; $scope.generating = false; $scope.ocrAttempted = false; $scope.vinValid = null; $scope.statisticalValueCHF = 0; $scope.vatValueCHF = 0; $scope.isCompany = false; $scope.sessionToken = null; $scope.submissionProgress = 0; $scope.submissionStep = ''; $scope.submissionResult = null; $scope.showProgress = false; $scope.eventSource = null; $scope.tooltips = { // V4 tooltips transportMode: false, relocation: false, ivaExempt: false }; } initScope(); // V3: Load reference data ConfigService.getCountries().then(data => $scope.countries = data); ConfigService.getCurrencies().then(data => $scope.currencies = data); $scope.transportModes = [ { value: '9', label: 'Autopropulsion' }, { value: '3', label: 'Trafic routier' }, { value: '2', label: 'Trafic ferroviaire' }, { value: '4', label: 'Trafic aérien' }, { value: '8', label: 'Trafic par eau' }, { value: '7', label: 'Pipeline, etc.' } ]; // V3: Watch for IDE change $scope.$watch('parties.importer.ide', function(newVal) { $scope.isCompany = !!newVal && newVal.trim() !== 'CHE222251936'; }); // V3: Method selection $scope.selectMethod = function(method) { $scope.method = method; $scope.step = 2; }; // V3: File upload trigger $scope.triggerFileInput = () => document.getElementById('file-upload').click(); // V3: Handle file select (with V4 notifications instead of alert) $scope.handleFileSelect = function(event) { const files = event.target.files; if (!files || files.length === 0) return; $scope.$apply(() => { $scope.ocrLoading = true; $scope.ocrAttempted = true; }); const formData = new FormData(); formData.append('registration_card', files[0]); $http.post('/api/ocr', formData, { transformRequest: angular.identity, headers: { 'Content-Type': undefined } }).then(function(response) { angular.extend($scope.vehicle, response.data); if ($scope.vehicle.vin) $scope.validateVIN(); $scope.updateClassification(); $rootScope.showNotification('Données extraites avec succès !', 'success'); }).catch(function(error) { const details = error.data?.details || error.data?.error || 'Erreur inconnue'; $rootScope.showNotification('Erreur OCR: ' + details, 'error'); }).finally(() => { $scope.ocrLoading = false; }); }; // V3: VIN validation $scope.validateVIN = function() { if (!$scope.vehicle.vin || $scope.vehicle.vin.length !== 17) { $scope.vinValid = false; return; } $http.post('/api/validate-vin', { vin: $scope.vehicle.vin }) .then(function(response) { $scope.vinValid = response.data.valid; if (response.data.valid && response.data.country && !$scope.declaration.dispatch_country) { $scope.declaration.dispatch_country = response.data.country; } }).catch(function() { $scope.vinValid = false; }); }; // V3: Update classification $scope.updateClassification = function() { if (!$scope.vehicle.fuel_type || !$scope.vehicle.weight_empty) return; $http.post('/api/classify-vehicle', $scope.vehicle) .then(function(response) { $scope.classification = response.data; $scope.vehicle.commodity_code = response.data.commodity_code; if (!response.data.needs_user_selection) { $scope.vehicle.statistical_key = response.data.statistical_key; } }); }; // V3: Calculate total (uses ConfigService.getExchangeRates for rates) $scope.calculateTotal = function() { const price = parseFloat($scope.declaration.purchase_price) || 0; const transport = parseFloat($scope.declaration.transport_cost) || 0; const other = parseFloat($scope.declaration.other_costs) || 0; const currency = $scope.declaration.purchase_currency; ConfigService.getExchangeRates().then(function(rates) { if (!rates || !rates[currency]) return; const rate = rates[currency].rate; $scope.statisticalValueCHF = Math.round(price * rate); $scope.vatValueCHF = Math.round((price + transport + other) * rate); }); }; // V3: Navigation $scope.nextStep = function() { if ($scope.step === 2) { if (!$scope.vehicle.vin || !$scope.vehicle.brand || !$scope.vehicle.model || !$scope.vehicle.weight_empty || !$scope.vehicle.weight_total) { $rootScope.showNotification('Veuillez remplir les informations obligatoires du véhicule.', 'error'); return; } } $scope.step++; }; $scope.prevStep = () => $scope.step--; // V4: Copy consignee to importer $scope.copyConsigneeToImporter = function() { $scope.parties.importer = angular.copy($scope.parties.consignee); $rootScope.showNotification('Informations copiées !', 'success'); }; // V4: Tooltip handling $scope.showTooltip = function(tooltip, show) { $scope.tooltips[tooltip] = show; }; $scope.toggleTooltip = function(tooltip) { $scope.tooltips[tooltip] = !$scope.tooltips[tooltip]; }; // V3: Copy link (with V4 notifications) $scope.copyLinkToClipboard = function(link) { const tempInput = document.createElement('input'); tempInput.style.position = 'absolute'; tempInput.style.left = '-9999px'; tempInput.value = 'monsite.ch' + link; document.body.appendChild(tempInput); tempInput.select(); document.execCommand('copy'); document.body.removeChild(tempInput); $rootScope.showNotification('Lien copié dans le presse-papiers !', 'success'); }; // V3: Generate declaration (with V4 SSE and notifications) $scope.generateDeclaration = function() { if ($scope.generating) return; const getFullName = (name, firstname, ide) => { const isCompany = !!ide && ide.trim() !== 'CHE222251936'; if (isCompany) return name || ''; return [firstname, name].filter(p => p && p.trim()).join(' '); }; const imp = $scope.parties.importer; if (!imp.name || !imp.zip || !imp.city || !$scope.declaration.dispatch_country) { $rootScope.showNotification('Veuillez renseigner les informations de l\'importateur et le pays d\'expédition.', 'error'); return; } $scope.generating = true; $scope.submissionProgress = 0; $scope.submissionStep = 'Initialisation...'; $scope.showProgress = true; $scope.step = 4; let declarantData = {}; if ($scope.declarantType === 'importer') { declarantData = angular.copy($scope.parties.importer); } else if ($scope.declarantType === 'consignee') { declarantData = angular.copy($scope.parties.consignee); } else if ($scope.declarantType === 'other') { declarantData = angular.copy($scope.parties.declarant); } const importerFullName = getFullName(imp.name, imp.firstname, imp.ide); const consigneeFullName = getFullName($scope.parties.consignee.name, $scope.parties.consignee.firstname, $scope.parties.consignee.ide); const declarantFullName = getFullName(declarantData.name, declarantData.firstname, declarantData.ide); const payload = { ...$scope.vehicle, ...$scope.declaration, user_name: declarantFullName, user_firstname: declarantData.firstname, user_address: declarantData.address, user_zip: declarantData.zip, user_city: declarantData.city, user_ide: declarantData.ide, user_country: 'CH', importer_name: importerFullName, importer_address: imp.address, importer_zip: imp.zip, importer_city: imp.city, importer_ide: imp.ide, consignee_name: consigneeFullName, consignee_address: $scope.parties.consignee.address, consignee_zip: $scope.parties.consignee.zip, consignee_city: $scope.parties.consignee.city, consignee_ide: $scope.parties.consignee.ide, language: $rootScope.lang || 'fr' }; $http.post('/api/generate-import', payload) .then(function(response) { console.log('[ImportController] Génération acceptée, session:', response.data.sessionToken); $scope.sessionToken = response.data.sessionToken; $scope.listenForSubmissionStatus(); }) .catch(function(error) { if ($scope.eventSource) $scope.eventSource.close(); $scope.generating = false; $scope.showProgress = false; $scope.step = 3; $rootScope.showNotification('Erreur: ' + (error.data?.error || 'Erreur de génération'), 'error'); $rootScope.setErrorContext($scope.sessionToken); }); }; // V4 SSE listener (integrated into V3 controller) $scope.listenForSubmissionStatus = function() { if (!$scope.sessionToken) return; if ($scope.eventSource) { $scope.eventSource.close(); } const source = new EventSource('/api/submission-status-sse/' + $scope.sessionToken); $scope.eventSource = source; source.onmessage = function(event) { $scope.$apply(() => { try { const status = JSON.parse(event.data); $scope.submissionProgress = status.progress; $scope.submissionStep = status.step; console.log(`[ImportController-SSE] Statut: ${status.status}, Progression: ${status.progress}%`); if (status.status === 'submitted') { $scope.submissionResult = status; $scope.showProgress = false; $scope.generating = false; source.close(); $rootScope.showNotification('Soumission réussie ! Documents prêts à télécharger.', 'success'); } else if (status.status === 'submission_error') { $scope.showProgress = false; $scope.generating = false; source.close(); $rootScope.setErrorContext($scope.sessionToken); $rootScope.showNotification('Erreur de soumission détectée. Consultez la page d\'erreur pour plus de détails.', 'error'); } } catch (e) { console.error('[ImportController-SSE] Erreur de données reçues:', e); } }); }; source.onerror = function(error) { console.error('[ImportController-SSE] Erreur EventSource:', error); $scope.$apply(() => { $scope.submissionStep = 'Erreur de connexion SSE. Tentative de reconnexion automatique...'; }); }; source.onopen = function() { console.log('[ImportController-SSE] Connexion SSE établie.'); }; }; // V3: Download PDF $scope.downloadPDF = function(path) { if (!path) return; $window.open('/api/download-pdf?path=' + encodeURIComponent(path), '_blank'); }; // V3: Reset form (closes SSE like V4) $scope.resetForm = function() { if ($scope.eventSource) $scope.eventSource.close(); initScope(); $rootScope.showNotification('Formulaire réinitialisé.', 'info'); }; }); ---------------------------------------------------- public/js/controllers/submissionErrorController.js angular.module('edecApp') .controller('SubmissionErrorController', function($scope, $http) { $scope.sessionToken = sessionStorage.getItem('error_session_token'); $scope.email = ''; $scope.submitted = false; $scope.errorMessage = ''; $scope.reportError = function() { if (!$scope.email || !$scope.sessionToken) { $scope.errorMessage = 'Email invalide.'; return; } $scope.errorMessage = ''; $http.post('/api/submission-error/report', { sessionToken: $scope.sessionToken, email: $scope.email }).then(function() { $scope.submitted = true; }).catch(function(error) { $scope.errorMessage = error.data?.error || 'Une erreur est survenue.'; }); }; }); ---------------------------------------------------- public/js/controllers/exportController.js angular.module('edecApp') .controller('ExportController', function($scope, $http, $window, $rootScope, ConfigService) { function initScope() { $scope.vehicle = {}; $scope.parties = { exporter: { name: '', address: '', zip: '', city: '', ide: '' } }; $scope.declaration = { destination_country: '', language: $rootScope.lang || 'fr' }; $scope.generating = false; } initScope(); // Load countries using the cached service ConfigService.getCountries().then(data => $scope.countries = data); $scope.generateExport = function() { if ($scope.generating || $scope.exportForm.$invalid) { if ($scope.exportForm.$invalid) { alert('Veuillez remplir tous les champs obligatoires (*).'); } return; } $scope.generating = true; const ex = $scope.parties.exporter; const payload = { ...$scope.vehicle, ...$scope.declaration, user_name: ex.name, user_address: ex.address, user_zip: ex.zip, user_city: ex.city, user_ide: ex.ide }; $http.post('/api/generate-export', payload, { responseType: 'blob' }) .then(function(response) { const blob = new Blob([response.data], { type: 'application/xml' }); const url = $window.URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `edec-export-${Date.now()}.xml`; document.body.appendChild(a); a.click(); document.body.removeChild(a); $window.URL.revokeObjectURL(url); alert('✅ Déclaration d\'exportation générée avec succès !'); initScope(); // Réinitialiser le formulaire $scope.exportForm.$setPristine(); $scope.exportForm.$setUntouched(); }) .catch(function(error) { alert('Erreur: ' + (error.data?.error || 'Erreur de génération')); }) .finally(function() { $scope.generating = false; }); }; }); ---------------------------------------------------- public/js/app.js angular.module('edecApp', ['ngRoute']) .config(['$routeProvider', '$locationProvider', function($routeProvider, $locationProvider) { $locationProvider.html5Mode({ enabled: true, requireBase: true }); $routeProvider .when('/', { templateUrl: 'views/home.html', controller: 'HomeController' }) .when('/import', { templateUrl: 'views/import.html', controller: 'ImportController' }) .when('/export', { templateUrl: 'views/export.html', controller: 'ExportController' }) .when('/submission-error', { templateUrl: 'views/submission-error.html', controller: 'SubmissionErrorController' }) .otherwise({ redirectTo: '/' }); }]) .controller('MainController', ['$scope', '$location', function($scope, $location) { $scope.currentPath = () => $location.path(); }]) .factory('ConfigService', ['$http', '$q', function($http, $q) { const cache = {}; function getCached(key, apiUrl) { if (cache[key]) { return $q.resolve(cache[key]); } const dataFromSession = sessionStorage.getItem(key); if (dataFromSession) { cache[key] = JSON.parse(dataFromSession); return $q.resolve(cache[key]); } return $http.get(apiUrl).then(function(response) { cache[key] = response.data; sessionStorage.setItem(key, JSON.stringify(response.data)); return response.data; }); } return { getCountries: () => getCached('countries', '/api/countries'), getCurrencies: () => getCached('currencies', '/api/currencies'), getExchangeRates: () => getCached('exchange_rates', '/api/exchange-rates') }; }]) // TranslationService from V4 .factory('TranslationService', ['$http', '$q', function($http, $q) { let translations = {}; const promise = $http.get('/translations/strings.json').then(function(response) { translations = response.data; return translations; }); return { getPromise: promise, get: function(key, lang) { if (translations[lang] && translations[lang][key]) { return translations[lang][key]; } return key; // Retourne la clé si la traduction n'est pas trouvée } }; }]) .run(['$rootScope', '$location', 'TranslationService', function($rootScope, $location, TranslationService) { // Language handling from V4 const SUPPORTED = ['fr', 'de', 'it', 'en']; const stored = localStorage.getItem('edec_lang'); const defaultLang = 'fr'; $rootScope.lang = SUPPORTED.includes(stored) ? stored : defaultLang; $rootScope.setLang = function(l) { if (SUPPORTED.includes(l)) { $rootScope.lang = l; localStorage.setItem('edec_lang', l); } }; $rootScope.isLang = l => $rootScope.lang === l; // Translation function from V4 TranslationService.getPromise.then(function() { $rootScope.t = function(key) { return TranslationService.get(key, $rootScope.lang); }; }); // Stocker le sessionToken d'erreur pour la page d'erreur from V4 $rootScope.setErrorContext = function(token) { sessionStorage.setItem('error_session_token', token); $location.path('/submission-error'); }; // Notifications system from V4 $rootScope.notifications = []; $rootScope.showNotification = function(message, type, duration) { type = type || 'info'; duration = duration || 5000; const notification = { id: Date.now(), message: message, type: type, visible: true }; $rootScope.notifications.push(notification); // Auto-suppression après la durée spécifiée if (duration > 0) { setTimeout(() => { $rootScope.$apply(() => { notification.visible = false; // Supprimer de la liste après l'animation de disparition setTimeout(() => { const index = $rootScope.notifications.indexOf(notification); if (index > -1) { $rootScope.notifications.splice(index, 1); } }, 300); }); }, duration); } return notification.id; }; $rootScope.hideNotification = function(id) { const notification = $rootScope.notifications.find(n => n.id === id); if (notification) { notification.visible = false; setTimeout(() => { const index = $rootScope.notifications.indexOf(notification); if (index > -1) { $rootScope.notifications.splice(index, 1); } }, 300); } }; }]); ---------------------------------------------------- public/index.html e-dec Véhicules - Déclaration Simplifiée
🚗 e-dec Véhicules
    {{ t('home_nav') }} {{ t('import_vehicle_nav') }} {{ t('export_vehicle_nav') }}

© 2025 e-dec Véhicules. Simplifiez vos déclarations douanières.

⚠️ Cet outil aide à créer votre déclaration. La responsabilité des informations incombe au déclarant.

---------------------------------------------------- public/translations/strings.json { "fr": { "import_vehicle_nav": "Importer", "export_vehicle_nav": "Exporter", "home_nav": "Accueil", "import_page_title": "Importer un Véhicule en Suisse", "export_page_title": "Exporter un Véhicule depuis la Suisse", "home_hero_title": "🚗 Déclaration e-dec Simplifiée", "home_hero_subtitle": "Importez ou exportez votre véhicule en Suisse en quelques clics", "home_hero_cta_import": "Importer un véhicule", "home_hero_cta_export": "Exporter un véhicule", "why_choose_us": "Pourquoi choisir notre service ?", "feature_fast_title": "Rapide", "feature_fast_desc": "Générez votre déclaration en moins de 5 minutes", "feature_eco_title": "Économique", "feature_eco_desc": "Tarifs transparents, outil personnel", "feature_simple_title": "Simple", "feature_simple_desc": "Interface intuitive, étapes guidées", "feature_ai_title": "IA intégrée", "feature_ai_desc": "Extraction automatique depuis votre carte grise" }, "de": { "import_vehicle_nav": "Importieren", "export_vehicle_nav": "Exportieren", "home_nav": "Startseite", "import_page_title": "Ein Fahrzeug in die Schweiz importieren", "export_page_title": "Ein Fahrzeug aus der Schweiz exportieren", "home_hero_title": "🚗 Vereinfachte e-dec Anmeldung", "home_hero_subtitle": "Importieren oder exportieren Sie Ihr Fahrzeug in wenigen Klicks in die Schweiz", "home_hero_cta_import": "Fahrzeug importieren", "home_hero_cta_export": "Fahrzeug exportieren", "why_choose_us": "Warum unseren Service wählen?", "feature_fast_title": "Schnell", "feature_fast_desc": "Erstellen Sie Ihre Anmeldung in weniger als 5 Minuten", "feature_eco_title": "Wirtschaftlich", "feature_eco_desc": "Transparente Preise, persönliches Tool", "feature_simple_title": "Einfach", "feature_simple_desc": "Intuitive Benutzeroberfläche, geführte Schritte", "feature_ai_title": "Integrierte KI", "feature_ai_desc": "Automatische Extraktion aus Ihrem Fahrzeugausweis" }, "it": { "import_vehicle_nav": "Importare", "export_vehicle_nav": "Esportare", "home_nav": "Home", "import_page_title": "Importare un Veicolo in Svizzera", "export_page_title": "Esportare un Veicolo dalla Svizzera", "home_hero_title": "🚗 Dichiarazione e-dec Semplificata", "home_hero_subtitle": "Importa o esporta il tuo veicolo in Svizzera in pochi clic", "home_hero_cta_import": "Importare un veicolo", "home_hero_cta_export": "Esportare un veicolo", "why_choose_us": "Perché scegliere il nostro servizio?", "feature_fast_title": "Veloce", "feature_fast_desc": "Genera la tua dichiarazione in meno di 5 minuti", "feature_eco_title": "Economico", "feature_eco_desc": "Tariffe trasparenti, strumento personale", "feature_simple_title": "Semplice", "feature_simple_desc": "Interfaccia intuitiva, passaggi guidati", "feature_ai_title": "IA integrata", "feature_ai_desc": "Estrazione automatica dalla tua carta di circolazione" }, "en": { "import_vehicle_nav": "Import", "export_vehicle_nav": "Export", "home_nav": "Home", "import_page_title": "Import a Vehicle to Switzerland", "export_page_title": "Export a Vehicle from Switzerland", "home_hero_title": "🚗 Simplified e-dec Declaration", "home_hero_subtitle": "Import or export your vehicle in Switzerland in just a few clicks", "home_hero_cta_import": "Import a vehicle", "home_hero_cta_export": "Export a vehicle", "why_choose_us": "Why choose our service?", "feature_fast_title": "Fast", "feature_fast_desc": "Generate your declaration in under 5 minutes", "feature_eco_title": "Economical", "feature_eco_desc": "Transparent pricing, personal tool", "feature_simple_title": "Simple", "feature_simple_desc": "Intuitive interface, guided steps", "feature_ai_title": "AI integrated", "feature_ai_desc": "Automatic data extraction from your registration card" } } ---------------------------------------------------- public/css/style.css /* Reset & Base */ * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #333; background: #f5f7fa; } .container { max-width: 1200px; margin: 0 auto; padding: 0 20px; } /* Navigation */ .navbar { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 1rem 0; box-shadow: 0 2px 10px rgba(0,0,0,0.1); } .navbar .container { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; } .logo { font-size: 1.5rem; font-weight: bold; color: white; text-decoration: none; } .nav-menu { display: flex; list-style: none; gap: 2rem; } .nav-menu a { color: rgba(255,255,255,0.9); text-decoration: none; font-weight: 500; transition: color 0.3s; } .nav-menu a:hover, .nav-menu a.active { color: white; } .lang-switcher { display: flex; gap: 0.5rem; } .btn-lang { padding: 0.5rem 1rem; border: 1px solid rgba(255,255,255,0.3); background: rgba(255,255,255,0.1); color: white; cursor: pointer; border-radius: 5px; transition: all 0.3s; } .btn-lang:hover, .btn-lang.active { background: rgba(255,255,255,0.2); border-color: white; } /* Hero */ .hero { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 5rem 0; text-align: center; } .hero h1 { font-size: 3rem; margin-bottom: 1rem; } .subtitle { font-size: 1.25rem; margin-bottom: 2rem; opacity: 0.9; } .cta-buttons { display: flex; gap: 1rem; justify-content: center; flex-wrap: wrap; } /* Buttons */ .btn { padding: 0.875rem 2rem; border: none; border-radius: 8px; font-size: 1rem; font-weight: 600; text-decoration: none; cursor: pointer; transition: all 0.3s; display: inline-block; } .btn-primary { background: #667eea; color: white; } .btn-primary:hover { background: #5568d3; transform: translateY(-2px); box-shadow: 0 4px 12px rgba(102,126,234,0.4); } .btn-secondary { background: #6c757d; color: white; } .btn-secondary:hover { background: #5a6268; } /* Features */ .features { padding: 5rem 0; background: white; } .features h2 { text-align: center; margin-bottom: 3rem; font-size: 2.5rem; color: #333; } .features-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 2rem; } .feature-card { text-align: center; padding: 2rem; border-radius: 12px; background: #f8f9fa; transition: transform 0.3s; } .feature-card:hover { transform: translateY(-5px); box-shadow: 0 8px 20px rgba(0,0,0,0.1); } .feature-icon { font-size: 3rem; margin-bottom: 1rem; } /* How it works */ .how-it-works { padding: 5rem 0; } .how-it-works h2 { text-align: center; margin-bottom: 3rem; font-size: 2.5rem; } .steps { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 2rem; } .step-item { text-align: center; } .step-number { width: 60px; height: 60px; background: #667eea; color: white; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 1.5rem; font-weight: bold; margin: 0 auto 1rem; } /* Progress Bar */ .progress-bar { display: flex; justify-content: space-between; margin: 2rem 0 3rem; position: relative; } .progress-bar::before { content: ''; position: absolute; top: 20px; left: 0; right: 0; height: 2px; background: #ddd; z-index: 0; } .progress-step { display: flex; flex-direction: column; align-items: center; position: relative; z-index: 1; } .step-num { width: 40px; height: 40px; background: #ddd; color: #666; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: bold; margin-bottom: 0.5rem; } .progress-step.active .step-num { background: #667eea; color: white; } .progress-step.completed .step-num { background: #28a745; color: white; } /* Method Cards */ .method-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 2rem; margin: 2rem 0; } .method-card { text-align: center; padding: 3rem 2rem; border: 2px solid #ddd; border-radius: 12px; cursor: pointer; transition: all 0.3s; } .method-card:hover { border-color: #667eea; transform: translateY(-5px); box-shadow: 0 8px 20px rgba(102,126,234,0.2); } .method-icon { font-size: 4rem; margin-bottom: 1rem; } /* Form */ .form { max-width: 800px; margin: 2rem auto; background: white; padding: 2rem; border-radius: 12px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); } fieldset { border: 1px solid #ddd; padding: 1.5rem; margin-bottom: 2rem; border-radius: 8px; } legend { font-weight: bold; padding: 0 0.5rem; color: #667eea; } .form-group { margin-bottom: 1.5rem; } .form-group label { display: block; margin-bottom: 0.5rem; font-weight: 500; color: #333; } .form-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; } /* Style des inputs: inclut text, email, number et select */ input[type="text"], input[type="email"], input[type="number"], select { width: 100%; padding: 0.75rem; border: 1px solid #ddd; border-radius: 6px; font-size: 1rem; transition: border-color 0.3s; } input:focus, select:focus { outline: none; border-color: #667eea; box-shadow: 0 0 0 3px rgba(102,126,234,0.1); } .hint { display: block; font-size: 0.875rem; color: #28a745; margin-top: 0.25rem; } .error { display: block; font-size: 0.875rem; color: #dc3545; margin-top: 0.25rem; } /* Upload Box */ .upload-box { border: 3px dashed #ddd; border-radius: 12px; padding: 3rem; text-align: center; margin: 2rem 0; cursor: pointer; transition: all 0.3s; } .upload-box:hover { border-color: #667eea; background: #f8f9fa; } .upload-label { font-size: 1.25rem; cursor: pointer; } /* Classification Info */ .classification-info { background: #e7f3ff; border-left: 4px solid #667eea; padding: 1.5rem; margin: 1.5rem 0; border-radius: 6px; } /* Total Box */ .total-box { background: #d4edda; border: 2px solid #28a745; padding: 1.5rem; border-radius: 8px; margin: 1.5rem 0; text-align: center; } .total-box h3 { color: #155724; margin: 0; } /* Checkbox */ .checkbox-label { display: flex; align-items: center; cursor: pointer; margin: 1rem 0; } .checkbox-label input[type="checkbox"] { width: auto; margin-right: 0.75rem; /* Mieux */ height: 1.1em; /* Mieux */ width: 1.1em; /* Mieux */ } /* Form Actions */ .form-actions { display: flex; gap: 1rem; justify-content: space-between; /* Mieux pour la navigation Précédent/Suivant */ margin-top: 2rem; } /* Success */ .success-content { text-align: center; padding: 3rem 0; } .success-icon { font-size: 5rem; margin-bottom: 1rem; color: #28a745; /* Mieux: couleur de succès */ } .next-steps-box { background: white; border: 1px solid #ddd; border-radius: 12px; padding: 2rem; margin: 2rem auto; max-width: 600px; text-align: left; } .next-steps-box ol { padding-left: 1.5rem; /* Mieux que margin-left */ } .next-steps-box li { margin-bottom: 0.5rem; } /* Barre de progression */ .progress-bar-container { width: 100%; height: 20px; background: #ddd; border-radius: 10px; margin: 1rem 0; overflow: hidden; } .progress-bar { height: 100%; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); transition: width 0.5s ease; } .progress-section { text-align: center; padding: 2rem 0; } /* Footer */ .footer { background: #2c3e50; color: white; padding: 2rem 0; text-align: center; margin-top: 3rem; } .disclaimer { font-size: 0.875rem; opacity: 0.8; margin-top: 0.5rem; } /* Main Content */ .main-content { min-height: calc(100vh - 300px); padding: 2rem 0; } /* Responsive */ @media (max-width: 768px) { .hero h1 { font-size: 2rem; } .navbar .container { flex-direction: column; gap: 1rem; } .nav-menu { flex-direction: column; gap: 0.5rem; text-align: center; width: 100%; } .form-row { grid-template-columns: 1fr; } .form-actions { flex-direction: column-reverse; /* Mieux: met le bouton principal en bas */ } .btn { width: 100%; } } ---------------------------------------------------- public/views/submission-error.html
⚠️

Erreur Technique de Soumission

Nous avons rencontré une erreur inattendue lors de la communication avec le portail de la douane. Votre déclaration a bien été générée mais n'a pas pu être soumise automatiquement.

Que faire ?

Notre équipe technique a été informée. Pour un suivi personnalisé, veuillez laisser votre adresse e-mail ci-dessous. Nous analyserons le problème et vous recontacterons dans les plus brefs délais avec une solution.

{{ errorMessage }}

Merci !

Nous avons bien reçu votre adresse e-mail. Notre équipe est sur le coup et reviendra vers vous très prochainement.

---------------------------------------------------- public/views/home.html

{{ t('home_hero_title') }}

{{ t('home_hero_subtitle') }}

{{ t('home_hero_cta_import') }} {{ t('home_hero_cta_export') }}

{{ t('why_choose_us') }}

{{ feature.icon }}

{{ t(feature.titleKey) }}

{{ t(feature.descriptionKey) }}

Comment ça marche ?

1

Choisissez votre méthode

Scanner la carte grise ou saisie manuelle

2

Remplissez les informations

Véhicule, finances et coordonnées

3

Générez le fichier XML

Téléchargez votre déclaration e-dec prête à l'emploi

4

Soumettez à la douane

Importez le fichier sur le portail officiel e-dec

---------------------------------------------------- public/views/export.html

Exporter un Véhicule depuis la Suisse

Cette page vous permet de générer une déclaration d'exportation pour un véhicule sortant du territoire suisse.

Informations Exportateur
Informations Véhicule
Informations d'Exportation
---------------------------------------------------- public/views/import.html
{{notification.message}}

{{ t('import_page_title') }}

1 Méthode
2 Véhicule
3 Déclaration
4 Résultat

Choisissez votre méthode de saisie

📸

Scanner la carte grise

Extraction automatique des données par IA (JPG, PNG, PDF)

⌨️

Saisie manuelle

Remplir le formulaire pas à pas

Informations du véhicule

ℹ️ Confidentialité : L'analyse de votre carte grise est effectuée par l'intelligence artificielle Google Gemini via API sécurisée. Si vous avez des préoccupations concernant la confidentialité de vos données, nous vous recommandons d'utiliser la saisie manuelle. Aucune donnée n'est conservée par les services tiers au-delà du traitement de votre demande.

✅ VIN valide. Pays d'origine détecté: {{declaration.dispatch_country || '...'}} ❌ Le format du VIN est invalide (17 caractères alphanumériques, sans I, O, Q).

📋 Classification douanière estimée

Code TARES: {{ classification.commodity_code }} - {{ classification.description }}

Sélectionnez la clé statistique: *

Clé statistique: {{ vehicle.statistical_key }} - {{ classification.statistical_key_description }}

Analyse de votre carte grise impossible. Veuillez vérifier le fichier et réessayer, ou passer en saisie manuelle.

Informations pour la déclaration

Parties Impliquées

📍 Destinataire

Est destinataire la personne ou l'entreprise domiciliée sur le territoire Suisse à qui la marchandise est expédiée.

4 chiffres uniquement (adresse suisse obligatoire)

📦 Importateur

Est importateur celui qui importe la marchandise dans le territoire Suisse ou la fait importer pour son compte. L'importateur et le destinataire peuvent être une seule et même personne / entreprise.

✍️ Déclarant

La personne qui effectue la présente déclaration.

Informations du déclarant
Régime Douanier
Pays d'expédition :
Le pays d'expédition correspond au dernier pays à partir
duquel une marchandise a été expédiée directement en Suisse.
Modes de transport :
• Autopropulsion : Le véhicule roule par ses propres moyens jusqu'à la frontière.
• Trafic routier : Le véhicule est transporté sur une remorque ou un camion.
• Trafic ferroviaire : Transport par train.
• Trafic aérien : Transport par avion (rare pour véhicules).
• Trafic par eau : Transport par bateau.
• Pipeline, etc. : Autres modes spécifiques.
Exemption TVA pour déménagement :
Cette exemption s'applique si vous transférez votre domicile en Suisse. La douane exigera des justificatifs...
Exonération IVA (RG 660) :
L'exonération s'applique notamment si le véhicule est utilisé depuis plus de 6 mois par son propriétaire...
Transactions Financières

Coûts additionnels (dans la même devise)

Valeur totale (base de calcul TVA/IVA): ~ {{ vatValueCHF | number:0 }} CHF

{{submissionStep}} ({{submissionProgress}}%)

✅

Soumission terminée avec succès !

Votre déclaration a été soumise au portail e-dec et les documents ont été récupérés.

Numéro de déclaration : {{ submissionResult.declarationNumber }}

Veuillez télécharger les documents ci-dessous. Ils vous seront demandés lors du passage en douane.


Accès futur et partage

Utilisez ce lien unique pour accéder à vos documents ultérieurement.

{{ 'monsite.ch/download/' + sessionToken }}

Les documents et ce lien d'accès **expireront dans 30 jours**.

---------------------------------------------------- package.json { "name": "edec-vehicles", "version": "1.1.0", "description": "Application e-dec Véhicules avec AngularJS et ExpressJS", "main": "server/server.js", "scripts": { "start": "node server/server.js", "dev": "nodemon server/server.js" }, "dependencies": { "axios": "^1.6.2", "bcryptjs": "^2.4.3", "cors": "^2.8.5", "dotenv": "^16.3.1", "express": "^4.18.2", "express-rate-limit": "^7.4.0", "express-validator": "^7.1.0", "helmet": "^7.1.0", "jsonwebtoken": "^9.0.2", "morgan": "^1.10.0", "multer": "^1.4.5-lts.1", "mysql2": "^3.6.5", "node-cron": "^3.0.3", "pdf-parse": "^1.1.1", "playwright": "^1.40.1", "sharp": "^0.33.4", "uuid": "^9.0.1", "winston": "^3.11.0" }, "devDependencies": { "nodemon": "^3.0.2" } }