---------------------------------------------------- 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 // ================================================================ // AJOUT : Charger les routes depuis le fichier dédié const apiRoutes = require('./routes/api'); app.use('/api', apiRoutes); // ================================================================ // ROUTE FALLBACK POUR ANGULAR (Single Page Application) // ================================================================ // Cette route doit être après les routes API 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 ocrService = require('../services/ocrService'); const taresClassifier = require('../services/taresClassifier'); const edecGenerator = require('../services/edecGenerator'); const exchangeRateService = require('../services/exchangeRateService'); const { v4: uuidv4 } = require('uuid'); // Multer const upload = multer({ dest: path.join(__dirname, '../../public/uploads'), limits: { fileSize: 10 * 1024 * 1024 }, 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')); } }); // ========================================================================= // FONCTIONS DE RECHERCHE BRAND CODE (Optimisation : intégrées ici) // ========================================================================= /** * Recherche le brand_code par le nom de la marque. */ async function 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 function getAllBrandCodes() { const query = `SELECT brand_code, brand_name FROM brand_codes ORDER BY brand_name`; const [rows] = await pool.execute(query); return rows; } // Health router.get('/health', (req, res) => { res.json({ status: 'OK', timestamp: new Date().toISOString(), service: 'e-dec Node.js Backend', version: '1.1.0' }); }); // Reference data // CORRECTION : Utiliser req.app.appConfigs pour accéder aux configurations globales de l'application Express. router.get('/countries', (req, res) => res.json(req.app.appConfigs.countries || {})); router.get('/currencies', (req, res) => res.json(req.app.appConfigs.currencies || {})); // Exchange rates 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 }); } }); // Currency conversion 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) => { try { const errors = validationResult(req); if (!errors.isEmpty()) return res.status(400).json({ error: 'Paramètres invalides', details: errors.array() }); 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 }); } }); // ========================================================================= // NOUVELLE ROUTE : Brand Code Lookup // ========================================================================= 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() }); } const { brand } = req.body; try { const brandCode = await getBrandCodeByBrandName(brand); // <-- Appel direct if (brandCode) { return res.json({ brandCode }); } const options = await getAllBrandCodes(); // <-- Appel direct 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 du code de marque." }); } }); // ========================================================================= // OCR router.post('/ocr', upload.single('registration_card'), async (req, res) => { const filePath = req.file?.path; try { if (!filePath) return res.status(400).json({ error: 'Aucun fichier fourni' }); 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 (_) {} } }); // Validate VIN + country 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 { // CORRECTION : Utiliser req.app.appConfigs pour accéder aux configurations globales. 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 }); } }); // Generate Import XML + persist router.post('/generate-import', [ 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().withMessage("Le brand_code doit être une chaîne de 3 chiffres uniquement (ex. : 123)."), 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 conn = await pool.getConnection(); const txId = uuidv4(); try { await conn.beginTransaction(); // Convert to CHF const sum = Number(req.body.purchase_price || 0) + Number(req.body.transport_cost || 0) + Number(req.body.other_costs || 0); const statistical_value_chf = await exchangeRateService.convert(sum, req.body.purchase_currency, 'CHF'); // VAT value (for e-dec vatValue): vehicle cost + import costs in CHF const vat_value_chf = statistical_value_chf; // Determine TARES if not present let tar = req.body.commodity_code; let statKey = req.body.statistical_key; if (!tar || !statKey) { const cls = await taresClassifier.classify(req.body, req.app.appConfigs.taresRules); tar = tar || cls.commodity_code; statKey = statKey || cls.statistical_key || '911'; } // LOGIQUE DE RÉCUPÉRATION DU BRAND CODE let finalBrandCode = req.body.brand_code; if (!finalBrandCode && req.body.brand) { // Tente la recherche automatique si le code n'a pas été fourni par le client const autoCode = await getBrandCodeByBrandName(req.body.brand); // <-- Appel direct finalBrandCode = autoCode; } // Origin from VIN let origin = await taresClassifier.getCountryFromVIN_DB(req.body.vin); origin = origin || req.body.dispatch_country || 'FR'; // Persist declaration const [declRes] = 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 (?, 'IMPORT', ?, ?, ?, ?, ?, 'CH', ?, ?, 'draft') `, [ txId, req.body.user_name || '', req.body.user_firstname || null, req.body.user_address || null, req.body.user_zip || '', req.body.user_city || '', req.body.user_ide || null, (req.body.language || 'fr').toLowerCase() ]); const declarationId = declRes.insertId; await 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, req.body.dispatch_country, req.body.transport_mode, req.body.is_relocation ? 1 : 0, Number(req.body.purchase_price || 0), req.body.purchase_currency, Number(req.body.transport_cost || 0), Number(req.body.other_costs || 0), statistical_value_chf, vat_value_chf ]); await 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, req.body.vin.toUpperCase(), req.body.brand, req.body.model, req.body.year || null, req.body.cylinder_capacity || null, req.body.fuel_type || null, req.body.weight_empty, req.body.weight_total, tar, statKey, origin, finalBrandCode ]); // Build XML (with all business rules) const xml = await edecGenerator.generateImport({ ...req.body, commodity_code: tar, statistical_key: statKey, origin_country: origin, statistical_value_chf, vat_value_chf, brand_code: finalBrandCode }); // Sauvegarder le XML dans un fichier 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]); await conn.commit(); res.set('Content-Type', 'application/xml'); res.set('Content-Disposition', `attachment; filename="edec-import-${Date.now()}.xml"`); res.send(xml); } catch (e) { await conn.rollback(); console.error('Erreur génération import:', e); res.status(500).json({ error: e.message }); } finally { conn.release(); } }); // Generate Export XML + persist router.post('/generate-export', [ 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 conn = await pool.getConnection(); const txId = uuidv4(); try { await conn.beginTransaction(); const [declRes] = 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 (?, 'EXPORT', ?, ?, ?, ?, ?, 'CH', ?, ?, 'draft') `, [ txId, req.body.user_name || '', req.body.user_firstname || null, req.body.user_address || null, req.body.user_zip || '', req.body.user_city || '', req.body.user_ide || null, (req.body.language || 'fr').toLowerCase() ]); const declarationId = declRes.insertId; await conn.execute(` INSERT INTO export_details (declaration_id, destination_country, taxation_code) VALUES (?, ?, ?) `, [ declarationId, req.body.destination_country, req.body.taxation_code || null ]); await 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, req.body.vin.toUpperCase(), req.body.brand, req.body.model, req.body.year || null, req.body.cylinder_capacity || null, req.body.fuel_type || null, req.body.weight_empty || null, req.body.weight_total || null, req.body.commodity_code || '8703.9060', req.body.statistical_key || '911', req.body.origin_country || null, req.body.brand_code || null ]); const xml = await edecGenerator.generateExport(req.body); await conn.execute(`UPDATE declarations SET xml_content = ?, status = 'generated' WHERE id = ?`, [xml, declarationId]); await conn.commit(); res.set('Content-Type', 'application/xml'); res.set('Content-Disposition', `attachment; filename="edec-export-${Date.now()}.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(); } }); // Soumission automatique à e-dec router.post('/submit-to-edec', async (req, res) => { const { sessionToken, xmlPath } = req.body; if (!sessionToken || !xmlPath) { return res.status(400).json({ error: 'sessionToken et xmlPath requis' }); } const edecSubmissionService = require('../services/edecSubmissionService'); try { const result = await edecSubmissionService.submitDeclaration(xmlPath, sessionToken); res.json(result); } catch (error) { console.error('Erreur soumission e-dec:', error); res.status(500).json({ error: 'Erreur lors de la soumission automatique', details: error.message }); } }); 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'); if (!fs.existsSync(this.downloadDir)) { fs.mkdirSync(this.downloadDir, { recursive: true }); } } async submitDeclaration(xmlFilePath, sessionToken) { let browser = null; let declarationNumber = null; try { console.log(`[EDEC Submit] Démarrage soumission pour token: ${sessionToken}`); browser = await chromium.launch({ headless: true, args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'] }); 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' }); const page = await context.newPage(); console.log('[EDEC Submit] Navigation vers le portail...'); await page.goto(this.baseUrl, { waitUntil: 'networkidle', timeout: 60000 }); await page.waitForTimeout(2000); console.log('[EDEC Submit] Clic sur "Charger la déclaration"...'); await page.click('a#mainform\\:loadDeclaration'); await page.waitForTimeout(2000); console.log('[EDEC Submit] Upload du fichier XML...'); const fileInput = await page.locator('input[type="file"][name="upload"]'); await fileInput.setInputFiles(xmlFilePath); await page.waitForTimeout(3000); console.log('[EDEC Submit] Clic sur le bouton OK...'); await page.click('a#mainform\\:confirmButton'); await page.waitForTimeout(3000); console.log('[EDEC Submit] Clic sur le bouton Envoyer...'); await page.click('a#j_id49\\:j_id69'); await page.waitForTimeout(5000); console.log('[EDEC Submit] Extraction du numéro de déclaration...'); const declarationElement = await page.locator('span.iceOutFrmt:has-text("La déclaration en douane a été établie")').first(); const declarationText = await declarationElement.textContent(); const match = declarationText.match(/(\d{20})/); if (!match) { throw new Error('Numéro de déclaration introuvable dans la réponse'); } declarationNumber = match[1]; console.log(`[EDEC Submit] Numéro de déclaration: ${declarationNumber}`); console.log('[EDEC Submit] Téléchargement Liste d\'importation...'); const downloadPromise1 = page.waitForEvent('download'); await page.click('a#mainform\\:j_id86'); const download1 = await downloadPromise1; const listePath = path.join(this.downloadDir, `${sessionToken}_liste_importation.pdf`); await download1.saveAs(listePath); console.log(`[EDEC Submit] Liste d'importation sauvegardée: ${listePath}`); console.log('[EDEC Submit] Téléchargement Bulletin de délivrance...'); await page.waitForTimeout(1000); const bulletinLink = await page.locator('a.iceOutLnk.downloadLink').nth(1); const downloadPromise2 = page.waitForEvent('download'); await bulletinLink.click(); const download2 = await downloadPromise2; const bulletinPath = path.join(this.downloadDir, `${sessionToken}_bulletin_delivrance.pdf`); await download2.saveAs(bulletinPath); console.log(`[EDEC Submit] Bulletin de délivrance sauvegardé: ${bulletinPath}`); console.log('[EDEC Submit] Mise à jour de la base de données...'); await this.saveToDatabase(sessionToken, declarationNumber, listePath, bulletinPath); console.log('[EDEC Submit] Soumission terminée avec succès'); return { success: true, declarationNumber, listePath, bulletinPath }; } catch (error) { console.error('[EDEC Submit] Erreur:', error); throw error; } finally { if (browser) { await browser.close(); } } } 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', submitted_at = NOW() WHERE uuid = ? `, [declarationNumber, listePath, bulletinPath, sessionToken]); } finally { conn.release(); } } } 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/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 class EdecGeneratorExport { generateExport(data) { const now = new Date().toISOString(); const lang = (data.language || 'fr').toLowerCase(); const xml = ` 2 2 ${lang} ${data.destination_country} 9 ${data.user_name} ${data.user_address ? `${data.user_address}` : ''} ${data.user_zip || ''} ${data.user_city || ''} CH ${data.brand} ${data.model}${data.year ? ' ' + data.year : ''} ${data.commodity_code || '8703.9060'} ${data.statistical_key || '911'} 1 2 ${data.vin} VN 1 `; 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/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: '⚡', title: 'Rapide', description: 'Générez votre déclaration en moins de 5 minutes' }, { icon: '💰', title: 'Économique', description: 'Tarifs transparents, outil personnel' }, { icon: '✅', title: 'Simple', description: 'Interface intuitive, étapes guidées' }, { icon: '🤖', title: 'IA intégrée', description: 'Extraction automatique depuis votre carte grise' } ]; }); ---------------------------------------------------- public/js/controllers/importController.js angular.module('edecApp') .controller('ImportController', function($scope, $http, $window, $rootScope) { // Initialisation des variables $scope.step = 1; $scope.method = null; $scope.vehicle = {}; $scope.parties = { importer: { name: '', firstname: '', address: '', zip: '', city: '', ide: '' }, consignee: { name: '', firstname: '', address: '', zip: '', city: '', ide: '' }, // Ajout de firstname pour les parties declarant: { name: '', firstname: '', address: '', zip: '', city: '', ide: '' } // Ajout de firstname pour les parties }; $scope.business = { cash_payment: false }; $scope.declaration = { transport_mode: '9', purchase_currency: 'EUR', purchase_price: 0, transport_cost: 0, other_costs: 0, language: $rootScope.lang || 'fr', dispatch_country: '' }; $scope.countries = {}; $scope.currencies = {}; $scope.classification = null; $scope.ocrLoading = false; $scope.generating = false; // [NOUVEAU] Drapeau pour indiquer si une tentative d'OCR a eu lieu $scope.ocrAttempted = false; $scope.statisticalValueCHF = 0; $scope.vatValueCHF = 0; $scope.vinValid = null; // Nouvelle variable pour gérer l'état du champ prénom dans la vue (ng-disabled) $scope.isCompany = false; // Load configs $http.get('/api/countries').then(function(res) { $scope.countries = res.data; }); $http.get('/api/currencies').then(function(res) { $scope.currencies = res.data; }); // NOUVELLE LOGIQUE: Surveiller l'IDE pour déterminer si c'est une société et griser le prénom $scope.$watch('parties.importer.ide', function(newVal) { // Si l'IDE est rempli (et différent de la valeur par défaut pour particulier, si applicable), on suppose que c'est une société. $scope.isCompany = !!newVal && newVal.trim() !== 'CHE222251936'; if ($scope.isCompany) { // Optionnel : vider le prénom si on passe en mode société, mais l'utilisateur a demandé de ne pas vider, donc on laisse le contenu. } }); $scope.selectMethod = function(method) { $scope.method = method; $scope.step = 2; }; $scope.triggerFileInput = function() { document.getElementById('file-upload').click(); }; // OCR upload $scope.handleFileSelect = function(event) { const files = event.target.files; if (!files || files.length === 0) { return; } $scope.$apply(function() { $scope.ocrLoading = true; // [MODIFICATION] On marque la tentative au début de la fonction $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) { Object.assign($scope.vehicle, response.data); $scope.ocrLoading = false; // [MODIFICATION] Si l'OCR réussit, on peut considérer qu'il n'y a pas d'échec d'affichage du message d'erreur // Bien que 'ocrAttempted' soit déjà true, on ne le remet pas à false pour garder l'info de la tentative. if ($scope.vehicle.vin) { $scope.validateVIN(); } $scope.updateClassification(); }) .catch(function(error) { const details = error.data?.details || error.data?.error || 'Erreur inconnue'; const hint = error.data?.hint || ''; alert('Erreur OCR: ' + details + (hint ? '\n\n' + hint : '')); console.error('Détails complets:', error.data); $scope.ocrLoading = false; // [NOTE]: L'erreur sera affichée dans la vue car ocrAttempted est true // et $scope.vehicle est resté vide ou a été vidé lors de l'échec. }); }; $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 = response.data.country; } }); }; $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; } }); }; // CORRECTION: Assure que les valeurs CHF sont calculées et arrondies à l'entier $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; // 1. Valeur Statistique (Statistical Value) = Prix d'achat seul $http.post('/api/convert-currency', { amount: price, from: currency, to: 'CHF' }) .then(function(response) { // Conversion en entier obligatoire $scope.statisticalValueCHF = Math.round(response.data.converted); // 2. Base TVA (VAT Value) = Prix d'achat + Transport + Autres frais return $http.post('/api/convert-currency', { amount: price + transport + other, from: currency, to: 'CHF' }); }) .then(function(responseVat) { // Conversion en entier obligatoire $scope.vatValueCHF = Math.round(responseVat.data.converted); }); }; $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) { alert('Veuillez remplir les informations du véhicule'); return; } } $scope.step++; }; $scope.prevStep = function() { $scope.step--; }; $scope.transportModes = [ { value: '9', label: 'Autopropulsion' }, { value: '7', label: 'Pipeline, etc.' }, { value: '4', label: 'Trafic aérien' }, { value: '2', label: 'Trafic ferroviaire' }, { value: '8', label: 'Trafic par eau' }, { value: '3', label: 'Trafic routier' } ]; // FONCTION UTILITAIRE: Décide si on concatène Nom et Prénom ou si on prend juste le Nom de la société. const getFullName = function(name, firstname, ide) { // Vérifie si l'IDE est rempli et n'est pas l'IDE par défaut pour particulier const isCompany = !!ide && ide.trim() !== 'CHE222251936'; if (isCompany) { return name || ''; // Retourne la raison sociale } // Pour les particuliers, on concatène (Prénom Nom) const parts = [firstname, name].filter(function(p) { return p && p.trim(); }); return parts.join(' '); }; $scope.generateDeclaration = function() { if ($scope.generating) { return; } const imp = $scope.parties.importer; if (!imp.name || !imp.zip || !imp.city) { alert('Veuillez renseigner les infos Importateur'); return; } if (!$scope.declaration.dispatch_country) { alert('Veuillez sélectionner le pays d\'expédition'); return; } $scope.generating = true; // Détermination du nom formaté pour l'Importateur/User const importerFullName = getFullName(imp.name, imp.firstname, imp.ide); // Détermination du nom pour le Consignataire (prend ceux de l'Importateur si vides) const cons = $scope.parties.consignee; const consigneeIsImporter = !cons.name && !cons.ide; const consigneeName = consigneeIsImporter ? importerFullName : getFullName(cons.name, cons.firstname, cons.ide); const consigneeIde = cons.ide || imp.ide; // Détermination du nom pour le Déclarant (prend ceux de l'Importateur si vides) const decl = $scope.parties.declarant; const declarantName = getFullName( decl.name || imp.name, decl.firstname || imp.firstname, decl.ide || imp.ide ); const payload = Object.assign({}, $scope.vehicle, $scope.declaration, { user_name: importerFullName, user_address: imp.address, user_zip: imp.zip, user_city: imp.city, user_ide: imp.ide, consignee_name: consigneeName, consignee_address: cons.address || imp.address, consignee_zip: cons.zip || imp.zip, consignee_city: cons.city || imp.city, consignee_ide: consigneeIde, declarant_name: declarantName, declarant_address: decl.address || imp.address, declarant_zip: decl.zip || imp.zip, declarant_city: decl.city || imp.city, declarant_ide: decl.ide || '', cash_payment: $scope.business.cash_payment ? 1 : 0, statistical_value_chf: $scope.statisticalValueCHF, vat_value_chf: $scope.vatValueCHF, language: $rootScope.lang }); $http.post('/api/generate-import', 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-import-' + Date.now() + '.xml'; document.body.appendChild(a); a.click(); document.body.removeChild(a); $window.URL.revokeObjectURL(url); // Récupérer le sessionToken depuis les headers de réponse const sessionToken = response.headers('X-Session-Token'); if (sessionToken && confirm('Voulez-vous soumettre automatiquement cette déclaration au portail e-dec?')) { $scope.submitToEdec(sessionToken); } else { $scope.step = 4; $scope.generating = false; } }) .catch(function(error) { alert('Erreur: ' + (error.data?.error || 'Erreur de génération')); $scope.generating = false; }); }; $scope.submitToEdec = function(sessionToken) { $http.post('/api/submit-to-edec', { sessionToken: sessionToken, xmlPath: '/path/to/xml/' + sessionToken + '.xml' // Le backend construira le bon chemin }) .then(function(response) { alert('✅ Déclaration soumise avec succès!\nNuméro: ' + response.data.declarationNumber); $scope.step = 4; $scope.generating = false; }) .catch(function(error) { alert('⚠️ Soumission automatique échouée: ' + (error.data?.details || error.data?.error)); $scope.step = 4; $scope.generating = false; }); }; $scope.resetForm = function() { $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.business = { cash_payment: false }; $scope.declaration = { transport_mode: '9', purchase_currency: 'EUR', purchase_price: 0, transport_cost: 0, other_costs: 0, language: $rootScope.lang || 'fr', dispatch_country: '' }; $scope.classification = null; $scope.statisticalValueCHF = 0; $scope.vatValueCHF = 0; $scope.vinValid = null; $scope.isCompany = false; // [MODIFICATION] Réinitialisation de l'état $scope.ocrAttempted = false; }; }); ---------------------------------------------------- public/js/controllers/exportController.js angular.module('edecApp') .controller('ExportController', function($scope, $http, $window, $rootScope) { $scope.vehicle = {}; $scope.export = { export_type: 'temporary', language: $rootScope.lang || 'fr' }; $scope.parties = { exporter: { name: '', address: '', zip: '', city: '', ide: '' } }; $scope.countries = {}; $scope.generating = false; $http.get('/api/countries').then(res => $scope.countries = res.data); $scope.generateExport = function() { if ($scope.generating) return; const ex = $scope.parties.exporter; if (!ex.name || !ex.zip || !ex.city) { alert('Veuillez renseigner les infos Exportateur'); return; } if (!$scope.export.destination_country) { alert('Veuillez sélectionner le pays de destination'); return; } $scope.generating = true; const data = Object.assign({}, $scope.vehicle, $scope.export, { 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', data, { 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\'export générée avec succès !'); $scope.generating = false; }).catch(function(error) { alert('Erreur: ' + (error.data?.error || 'Erreur de génération')); $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' }) .otherwise({ redirectTo: '/' }); }]) .controller('MainController', ['$scope', '$location', function($scope, $location) { $scope.currentPath = function() { return $location.path(); }; }]) .run(['$rootScope', function($rootScope) { // Language handling 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 = function(l) { return $rootScope.lang === l; }; // Basic dictionary (minimal; page reste FR par défaut mais on met à jour XML/langue) $rootScope.t = function(s) { return s; }; $rootScope.apiUrl = '/api'; }]); ---------------------------------------------------- public/index.html e-dec Véhicules - Déclaration Simplifiée
🚗 e-dec Véhicules
    Importer Exporter Accueil

© 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/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; } /* 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/home.html

🚗 Déclaration e-dec Simplifiée

Importez ou exportez votre véhicule en Suisse en quelques clics

Importer un véhicule Exporter un véhicule

Pourquoi choisir notre service ?

{{ feature.icon }}

{{ feature.title }}

{{ feature.description }}

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

Importer un Véhicule en Suisse

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

✅ 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.

Informations pour la déclaration

Parties Impliquées

Importateur / Destinataire

La personne ou l'entreprise qui importe le véhicule en Suisse.

Par défaut, le Destinataire et le Déclarant sont identiques à l'Importateur.
Régime Douanier
Transactions Financières

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

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

✅

Déclaration générée avec succès !

Votre fichier XML a été téléchargé. Il est prêt à être importé sur le portail e-dec de la douane.

Prochaines étapes :

  1. Rendez-vous sur le portail e-dec web de l'Office fédéral de la douane.
  2. Authentifiez-vous et choisissez "Importer une déclaration".
  3. Importez le fichier XML que vous venez de télécharger.
  4. Vérifiez attentivement toutes les informations pré-remplies.
  5. Joignez les documents nécessaires (facture, carte grise).
  6. Soumettez votre déclaration.
Aller au portail e-dec →
---------------------------------------------------- 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" } }