---------------------------------------------------- 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'))); // Injection de la clé Stripe dans index.html app.get('/', (req, res) => { const indexPath = path.join(__dirname, '../public/index.html'); let html = fs.readFileSync(indexPath, 'utf8'); html = html.replace('{{STRIPE_PUBLIC_KEY}}', process.env.STRIPE_PUBLIC_KEY || ''); res.send(html); }); // 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); // Ajouter cette route avant le fallback Angular app.get('/payment-success', (req, res) => { res.sendFile(path.join(__dirname, '../public/index.html')); }); // ================================================================ // 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'); const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY); // 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'); const DOWNLOAD_DIR = path.join(__dirname, '../../edecpdf'); const EXPIRATION_DAYS = 30; // Configuration 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')); } } }); // ========================================================================= // ROUTES PAIEMENT STRIPE // ========================================================================= router.post('/create-payment-intent', async (req, res) => { try { const paymentIntent = await stripe.paymentIntents.create({ amount: 5000, // 50.00 CHF en centimes currency: 'chf', payment_method_types: ['card', 'twint'], metadata: { service: 'edec-declaration' } }); res.json({ clientSecret: paymentIntent.client_secret, paymentIntentId: paymentIntent.id }); } catch (error) { console.error('[STRIPE] Erreur création payment intent:', error); res.status(500).json({ error: 'Erreur lors de la création du paiement' }); } }); router.post('/verify-payment', async (req, res) => { const { paymentIntentId } = req.body; if (!paymentIntentId) { return res.status(400).json({ valid: false, error: 'Payment ID manquant' }); } try { // Vérifier auprès de Stripe const paymentIntent = await stripe.paymentIntents.retrieve(paymentIntentId); if (paymentIntent.status !== 'succeeded') { return res.json({ valid: false, error: 'Paiement non finalisé' }); } // Vérifier si déjà utilisé en DB const [rows] = await pool.execute( 'SELECT id FROM declarations WHERE payment_id = ? AND status = "submitted"', [paymentIntentId] ); if (rows.length > 0) { return res.json({ valid: false, error: 'Paiement déjà utilisé pour une déclaration' }); } res.json({ valid: true, amount: paymentIntent.amount / 100, currency: paymentIntent.currency.toUpperCase() }); } catch (error) { console.error('[STRIPE] Erreur vérification:', error); res.status(500).json({ valid: false, error: 'Erreur serveur' }); } }); // ========================================================================= // ROUTES DE SERVICE // ========================================================================= router.get('/health', (req, res) => { res.json({ status: 'OK', timestamp: new Date().toISOString(), service: 'e-dec Node.js Backend', version: '1.3.0' }); }); router.get('/countries', (req, res) => res.json(req.app.appConfigs.countries || {})); router.get('/currencies', (req, res) => res.json(req.app.appConfigs.currencies || {})); 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 }); } }); 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 // ========================================================================= 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 (_) {} } } }); 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 }); } }); 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 }); } }); 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." }); } }); // ========================================================================= // GÉNÉRATION IMPORT // ========================================================================= router.post('/generate-import', [ body('payment_id').isString().notEmpty().withMessage('Payment ID requis'), 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 { payment_id } = req.body; // Vérification paiement const paymentValid = await declarationService.verifyPayment(payment_id); if (!paymentValid.valid) { return res.status(403).json({ error: paymentValid.error || 'Paiement invalide ou déjà utilisé' }); } 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 }; 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'); const declarationId = await declarationService.createFullImportDeclaration(conn, txId, data, payment_id); console.log(`[GENERATE-IMPORT] Déclaration créée ID: ${declarationId}`); 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(); 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(); } }); // ========================================================================= // GÉNÉRATION EXPORT // ========================================================================= router.post('/generate-export', [ body('payment_id').isString().notEmpty().withMessage('Payment ID requis'), 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('brand_code').optional().isString().isLength({ min: 3, max: 3 }).isNumeric(), 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 { payment_id } = req.body; // Vérification paiement const paymentValid = await declarationService.verifyPayment(payment_id); if (!paymentValid.valid) { return res.status(403).json({ error: paymentValid.error || 'Paiement invalide ou déjà utilisé' }); } 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 }; // Classification TARES (même logique que Import) 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.brand_code = data.brand_code || await declarationService.getBrandCodeByBrandName(data.brand); const declarationId = await declarationService.createFullExportDeclaration(conn, txId, data, payment_id); console.log(`[GENERATE-EXPORT] Déclaration créée ID: ${declarationId}`); const xml = await edecGenerator.generateExport(data); const xmlFilePath = await declarationService.saveXmlFile(conn, txId, xml, declarationId); await conn.commit(); console.log(`[GENERATE-EXPORT] Lancement soumission automatique pour: ${txId}`); setTimeout(() => { edecSubmissionService.submitDeclaration(xmlFilePath, txId).catch(err => { console.error(`[SUBMISSION-BACKGROUND-EXPORT] Erreur fatale pour ${txId}:`, err); }); }, 100); res.json({ success: true, sessionToken: txId, message: 'Déclaration export générée et soumission démarrée.' }); } catch (e) { await conn.rollback(); console.error('Erreur génération export:', e); res.status(500).json({ error: e.message }); } finally { conn.release(); } }); // ========================================================================= // SUIVI ET TÉLÉCHARGEMENT // ========================================================================= router.get('/submission-status-sse/:sessionToken', async (req, res) => { const { sessionToken } = req.params; console.log(`[SSE] Nouvelle connexion pour ${sessionToken}`); res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.setHeader('Access-Control-Allow-Origin', '*'); const sendStatusUpdate = (data) => { try { if (res.finished) return; res.write(`data: ${JSON.stringify(data)}\n\n`); if (data.status === 'submitted' || data.status === 'submission_error') { setTimeout(() => { if (!res.finished) res.end(); }, 100); } } catch (error) { console.error(`[SSE] Erreur envoi données pour ${sessionToken}:`, error); } }; edecSubmissionService.registerListener(sessionToken, sendStatusUpdate); 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); 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); } const cleanup = () => { if (!res.finished) { console.log(`[SSE] Connexion fermée pour ${sessionToken} - Nettoyage`); edecSubmissionService.removeListener(sessionToken); res.end(); } }; req.on('close', cleanup); req.on('error', cleanup); }); 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' }); } }); 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.' }); } }); 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 lenregistrement de lemail.' }); } }); 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'); const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY); class DeclarationService { /** * Vérifie la validité d'un paiement Stripe et qu'il n'a pas déjà été utilisé */ async verifyPayment(paymentIntentId) { try { // Vérifier auprès de Stripe const paymentIntent = await stripe.paymentIntents.retrieve(paymentIntentId); if (paymentIntent.status !== 'succeeded') { return { valid: false, error: 'Paiement non finalisé' }; } // Vérifier si déjà utilisé const [rows] = await pool.execute( 'SELECT id FROM declarations WHERE payment_id = ? AND status = "submitted"', [paymentIntentId] ); if (rows.length > 0) { return { valid: false, error: 'Paiement déjà utilisé pour une déclaration' }; } return { valid: true }; } catch (error) { console.error('[VERIFY-PAYMENT] Erreur:', error); return { valid: false, error: 'Erreur de vérification du paiement' }; } } async createDeclarationRecord(conn, uuid, type, data, paymentId = null) { 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, payment_id, 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(), paymentId ] ); return result.insertId; } 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 ] ); } 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 ] ); } 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, matriculation_number) 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, data.matriculation_number || null ] ); } async createFullImportDeclaration(conn, txId, data, paymentId) { const declarationId = await this.createDeclarationRecord(conn, txId, 'IMPORT', data, paymentId); await this.createImportDetailsRecord(conn, declarationId, data); await this.createVehicleRecord(conn, declarationId, { ...data, declaration_type: 'IMPORT' }); return declarationId; } async createFullExportDeclaration(conn, txId, data, paymentId) { const declarationId = await this.createDeclarationRecord(conn, txId, 'EXPORT', data, paymentId); await this.createExportDetailsRecord(conn, declarationId, data); await this.createVehicleRecord(conn, declarationId, { ...data, declaration_type: 'EXPORT' }); return declarationId; } 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; } 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; 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'); class OcrService { constructor() { 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 }); } // Modifier le prompt OCR pour inclure le matriculation_number 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":"e112007/460009*15","matriculation_number":"234.543.324"} 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. - matriculation_number: Numéro de matricule ou plaque d'immatriculation. En Suisse, cherche le point 18 (Kontrollschild-Nr, Numéro de plaque). Format typique: XXX.XXX.XXX (3 groupes de chiffres séparés par des points) ou format cantonal (ex: GE 12345, VD 123456). Si absent ou illisible, retourne null. Retourne strictement du JSON valide sans commentaire ni texte. Si une valeur est totalement inconnue, utilise: null.`.trim(); } validateVIN(vin) { if (!vin) return null; let clean = vin.replace(/\s/g, '').toUpperCase().trim(); const corrected = clean .replace(/I/g, '1') .replace(/[OQ]/g, '0'); if (clean !== corrected) { console.warn('[OCR] VIN corrigé automatiquement:', { original: vin, corrected }); } return /^[A-HJ-NPR-Z0-9]{17}$/.test(corrected) ? corrected : null; } validateYear(year) { if (!year) return null; const y = parseInt(year, 10); return (y >= 1900 && y <= new Date().getFullYear() + 1) ? y : null; } validateInteger(value) { if (value === null || value === undefined || value === '') return null; const int = parseInt(value, 10); return Number.isFinite(int) && int > 0 ? int : 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; } parseGeminiJSON(response) { console.log('[OCR] ÉTAPE 7a - Parsing JSON, réponse brute:', response); 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); 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, matriculation_number: data.matriculation_number || null }; } catch (err) { console.error('[OCR] ERREUR ÉTAPE 7 - Parsing JSON:', err); throw new Error('Format de réponse OCR invalide: ' + err.message); } } 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'); } 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'); base64Data = Buffer.from(pdfBuffer).toString('base64'); contentType = 'application/pdf'; console.log('[OCR] PDF converti en base64, longueur:', base64Data.length); } else { 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); } 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}`; if (contentType === 'application/pdf') { fileContent.type = 'file'; fileContent.file = { file_data: dataUrl, filename: path.basename(filePath) }; } else { 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 ] } ], temperature: 0.1, max_tokens: 4000 }, { headers: { 'Authorization': `Bearer ${this.apiKey}`, 'Content-Type': 'application/json', 'HTTP-Referer': process.env.REFERER_URL || 'http://localhost:3000', 'X-Title': 'e-dec Vehicles' }, 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)); const parsed = this.parseGeminiJSON(extractedText); console.log('[OCR] ÉTAPE 8 - JSON parsé et validé avec succès:', parsed); return parsed; } catch (error) { 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'); } throw new Error('Erreur API OpenRouter inattendue: ' + (error.response?.data?.error?.message || error.message)); } } } module.exports = new OcrService(); ---------------------------------------------------- server/services/edecGeneratorExport.js function escapeXml(value) { if (value === null || value === undefined) return ''; return String(value) .replace(/&/g, '&') // Note: & doit être échappé en premier .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); // Ligne corrigée } class EdecGeneratorExport { generateExport(data) { const now = new Date().toISOString().replace('T', ' ').replace('Z', ' UTC'); const lang = (data.language || 'fr').toLowerCase(); // Consignor (expéditeur CH) - équivalent importateur const consignorName = escapeXml(data.consignor_name || data.user_name || ''); const consignorStreet = escapeXml(data.consignor_address || data.user_address || ''); const consignorZip = escapeXml(data.consignor_zip || data.user_zip || ''); const consignorCity = escapeXml(data.consignor_city || data.user_city || ''); const consignorCountry = 'CH'; const consignorIde = escapeXml(data.consignor_ide || data.user_ide || 'CHE222251936'); // Consignee (destinataire étranger) const consigneeName = escapeXml(data.consignee_name || ''); const consigneeStreet = escapeXml(data.consignee_address || ''); const consigneeZip = escapeXml(data.consignee_zip || ''); const consigneeCity = escapeXml(data.consignee_city || ''); const consigneeCountry = escapeXml(data.destination_country || ''); // Declarant 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 declarantCountry = escapeXml(data.declarant_country || consigneeCountry || 'FR'); 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 brandCode = escapeXml(data.brand_code || ''); const matriculationNumber = escapeXml(data.matriculation_number || ''); const grossMass = Math.round(Number(data.weight_total || 0)); const statisticalValue = Math.round(Number(data.statistical_value || data.purchase_price || 0)); // Construction des détails du véhicule let goodsItemDetailsXml = ''; if (brandCode || vin || matriculationNumber) { goodsItemDetailsXml = ` `; if (brandCode) { goodsItemDetailsXml += ` 1 ${brandCode} `; } if (vin) { goodsItemDetailsXml += ` 2 ${vin} `; } if (matriculationNumber) { goodsItemDetailsXml += ` 3 ${matriculationNumber} `; } goodsItemDetailsXml += ` `; } const xml = ` 2 1 ${lang} ${consigneeCountry} 9 0 ${consignorName} ${consignorStreet ? `${consignorStreet}` : ''} ${consignorZip} ${consignorCity} ${consignorCountry} ${consignorIde} ${consigneeName} ${consigneeStreet ? `${consigneeStreet}` : ''} ${consigneeZip} ${consigneeCity} ${consigneeCountry} ${declarantIde ? `${declarantIde}` : ''} ${declarantName} ${declarantStreet ? `${declarantStreet}` : ''} ${declarantZip} ${declarantCity} ${declarantCountry} 0 1 0 ${description} ${commodityCode} ${statisticalCode} ${grossMass > 0 ? `${grossMass}` : ''} ${grossMass > 0 ? `${grossMass}` : ''} 1 2 2 1 1 ${statisticalValue > 0 ? `${statisticalValue}` : ''} 0 ${goodsItemDetailsXml} 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) { 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'; $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 = { transportMode: false, relocation: false, ivaExempt: false }; $scope.ocrCountdown = 0; $scope.ocrCountdownActive = false; } const SESSION_STORAGE_KEY = 'edec_import_form'; let saveTimeout = null; function saveState() { $timeout.cancel(saveTimeout); saveTimeout = $timeout(function() { const dataToSave = { step: Math.min($scope.step || 1, 3), method: $scope.method, vehicle: angular.copy($scope.vehicle), parties: angular.copy($scope.parties), declarantType: $scope.declarantType, declaration: angular.copy($scope.declaration) }; sessionStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(dataToSave)); }, 500); } function loadSavedState() { const savedData = sessionStorage.getItem(SESSION_STORAGE_KEY); if (savedData) { try { const parsedData = JSON.parse(savedData); $scope.step = parsedData.step || 1; $scope.method = parsedData.method || null; angular.extend($scope.vehicle, parsedData.vehicle || {}); angular.extend($scope.parties, parsedData.parties || { importer: {}, consignee: {}, declarant: {} }); $scope.declarantType = parsedData.declarantType || 'importer'; angular.extend($scope.declaration, parsedData.declaration || {}); $scope.isCompany = !!$scope.parties.importer.ide && $scope.parties.importer.ide.trim() !== 'CHE222251936'; $rootScope.showNotification($rootScope.t('data_restored'), 'info'); return true; } catch (e) { sessionStorage.removeItem(SESSION_STORAGE_KEY); return false; } } return false; } // Vérification paiement const paymentProof = localStorage.getItem('edec_payment_proof'); if (!paymentProof) { $window.location.href = '/payment'; return; } try { const proof = JSON.parse(paymentProof); $http.post('/api/verify-payment', { paymentIntentId: proof.sessionToken }) .then(function(response) { if (!response.data.valid) { localStorage.removeItem('edec_payment_proof'); $rootScope.showNotification($rootScope.t('payment_invalid'), 'error'); $timeout(function() { $window.location.href = '/payment'; }, 2000); } }) .catch(function() { localStorage.removeItem('edec_payment_proof'); $window.location.href = '/payment'; }); } catch (e) { localStorage.removeItem('edec_payment_proof'); $window.location.href = '/payment'; return; } initScope(); loadSavedState(); 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.' } ]; $scope.$watch('parties.importer.ide', function(newVal) { $scope.isCompany = !!newVal && newVal.trim() !== 'CHE222251936'; }); $scope.$watch('vehicle', saveState, true); $scope.$watch('parties', saveState, true); $scope.$watch('declaration', saveState, true); $scope.$watch('declarantType', saveState); $scope.$watch('step', saveState); $scope.$watch('method', saveState); $scope.$watch('submissionResult', function(newVal) { if (newVal) { sessionStorage.removeItem(SESSION_STORAGE_KEY); localStorage.removeItem('edec_payment_proof'); } }); $scope.selectMethod = function(method) { $scope.method = method; $scope.step = 2; }; $scope.triggerFileInput = () => document.getElementById('file-upload').click(); $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($rootScope.t('ocr_success'), 'success'); // NOUVEAU: Démarrer countdown 30s $scope.startOcrCountdown(); }).catch(function(error) { const details = error.data?.details || error.data?.error || 'Erreur inconnue'; $rootScope.showNotification('Erreur OCR: ' + details, 'error'); }).finally(() => { $scope.ocrLoading = false; }); }; // NOUVEAU: Countdown OCR $scope.startOcrCountdown = function() { $scope.ocrCountdown = 30; $scope.ocrCountdownActive = true; const interval = setInterval(function() { $scope.$apply(function() { $scope.ocrCountdown--; if ($scope.ocrCountdown <= 0) { $scope.ocrCountdownActive = false; clearInterval(interval); } }); }, 1000); }; $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; }); }; $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; } }); }; $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); }); }; $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($rootScope.t('fill_required_vehicle'), 'error'); return; } } $scope.step++; }; $scope.prevStep = () => $scope.step--; $scope.copyConsigneeToImporter = function() { $scope.parties.importer = angular.copy($scope.parties.consignee); $rootScope.showNotification($rootScope.t('info_copied'), 'success'); }; $scope.showTooltip = function(tooltip, show) { $scope.tooltips[tooltip] = show; }; $scope.toggleTooltip = function(tooltip) { $scope.tooltips[tooltip] = !$scope.tooltips[tooltip]; }; $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($rootScope.t('link_copied'), 'success'); }; $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($rootScope.t('fill_required_importer'), 'error'); return; } if (!$scope.declaration.purchase_price || $scope.declaration.purchase_price <= 0) { $rootScope.showNotification($rootScope.t('fill_valid_price'), '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 paymentProof = JSON.parse(localStorage.getItem('edec_payment_proof')); const payload = { ...$scope.vehicle, ...$scope.declaration, payment_id: paymentProof.sessionToken, 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) { $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); }); }; $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; if (status.status === 'submitted') { $scope.submissionResult = status; $scope.showProgress = false; $scope.generating = false; source.close(); $rootScope.showNotification($rootScope.t('submission_success'), 'success'); } else if (status.status === 'submission_error') { $scope.showProgress = false; $scope.generating = false; source.close(); $rootScope.setErrorContext($scope.sessionToken); $rootScope.showNotification($rootScope.t('submission_error_detected'), 'error'); } } catch (e) { console.error('[ImportController-SSE] Erreur:', e); } }); }; source.onerror = function(error) { console.error('[ImportController-SSE] Erreur:', error); $scope.$apply(() => { $scope.submissionStep = 'Erreur de connexion SSE...'; }); }; }; $scope.downloadPDF = function(path) { if (!path) return; $window.open('/api/download-pdf?path=' + encodeURIComponent(path), '_blank'); }; $scope.resetForm = function() { if ($scope.eventSource) $scope.eventSource.close(); sessionStorage.removeItem(SESSION_STORAGE_KEY); initScope(); $rootScope.showNotification($rootScope.t('form_reset'), 'info'); }; }); ---------------------------------------------------- public/js/controllers/paymentController.js angular.module('edecApp') .controller('PaymentController', function($scope, $http, $location, $window, $rootScope, $timeout) { // 🔴 CORRECTION IMPORTANTE : Remplacez par votre vraie clé Stripe const STRIPE_PUBLIC_KEY = 'pk_test_51SF3ZMDLAThCxOAyPFDmPowjqUJyyay34PQZu8SfEHDZaVNFSNCCPjE2dAJYpvHHtqQcR0I23gEstk9V24B83PAw00g4jIHw2r'; // Obtenez cette clé depuis votre dashboard Stripe $scope.loading = true; $scope.processing = false; $scope.stripe = null; $scope.elements = null; $scope.paymentElement = null; $scope.clientSecret = null; $scope.destinationType = sessionStorage.getItem('edec_intended_destination') || 'import'; $scope.paymentElementComplete = false; $scope.initializePayment = function() { if (window.Stripe) { $scope.initStripe(); return; } const script = document.createElement('script'); script.src = 'https://js.stripe.com/v3/'; script.onload = () => { $scope.$apply(() => { $scope.initStripe(); }); }; script.onerror = () => { console.error('❌ Erreur chargement Stripe.js'); $scope.$apply(() => { $scope.loading = false; $rootScope.showNotification('Erreur lors du chargement du système de paiement. Veuillez désactiver votre ad-blocker.', 'error'); }); }; document.head.appendChild(script); }; $scope.initStripe = function() { try { if ($scope.stripeInitialized) { return; } const maxAttempts = 20; let attempts = 0; const waitForElement = () => { attempts++; const el = document.getElementById('payment-element'); if (!el) { console.log(`⏳ Attente #payment-element (tentative ${attempts}/${maxAttempts})`); if (attempts < maxAttempts) { setTimeout(waitForElement, 200); } else { console.error('❌ #payment-element introuvable après essais'); } return; } console.log('✅ Élément #payment-element trouvé, initialisation Stripe'); $scope.stripe = Stripe(STRIPE_PUBLIC_KEY); $scope.createPaymentIntent(); $scope.stripeInitialized = true; }; waitForElement(); } catch (error) { console.error('❌ Erreur initStripe :', error); $scope.loading = false; $rootScope.showNotification('Erreur initialisation paiement', 'error'); } }; $scope.$on('$viewContentLoaded', () => { console.log('🟢 Vue payment chargée, appel initStripe()'); $scope.initStripe(); }); $scope.createPaymentIntent = function() { $http.post('/api/create-payment-intent') .then(function(response) { $scope.clientSecret = response.data.clientSecret; $scope.paymentIntentId = response.data.paymentIntentId; const appearance = { theme: 'stripe', variables: { colorPrimary: '#667eea', colorBackground: '#ffffff', colorText: '#30313d', colorDanger: '#df1b41', fontFamily: 'Inter, system-ui, sans-serif', spacingUnit: '4px', borderRadius: '8px' } }; const locale = $rootScope.lang || 'fr'; $scope.elements = $scope.stripe.elements({ clientSecret: $scope.clientSecret, appearance: appearance, locale: locale }); // 🌟 CORRECTION : Attendre que le template soit rendu $timeout(() => { $scope.setupPaymentElement(); }, 300); }) .catch(function(error) { console.error('Erreur lors de la création de Payment Intent:', error); $scope.loading = false; $rootScope.showNotification('Erreur de connexion au système de paiement', 'error'); }); }; $scope.setupPaymentElement = function() { const container = document.getElementById('payment-element'); if (!container) { // retry short time later $timeout(() => { $scope.setupPaymentElement(); }, 200); return; } try { // créer à partir de l'instance Elements déjà construite $scope.paymentElement = $scope.elements.create('payment', { layout: 'tabs' }); $scope.paymentElement.mount('#payment-element'); // écouter les changements et mettre à jour l'état Angular $scope.paymentElement.on('change', function(event) { $scope.$apply(() => { $scope.paymentElementComplete = !!event.complete; }); }); $scope.loading = false; console.log('✅ Élément de paiement Stripe monté avec succès'); } catch (e) { console.error('Erreur lors de la création/du montage du Payment Element', e); $scope.loading = false; } }; $scope.handleSubmit = async function(event) { if (event) event.preventDefault(); if ($scope.processing || !$scope.stripe || !$scope.elements) { return; } $scope.processing = true; // 🌟 CORRECTION: $scope.$apply() est souvent nécessaire après une modification synchrone dans un `async` // mais ici, la modification est simple et le `confirmPayment` est asynchrone, laissons-le. // $scope.$apply(); // Pas toujours nécessaire, mais une bonne pratique try { const { error, paymentIntent } = await $scope.stripe.confirmPayment({ elements: $scope.elements, confirmParams: { return_url: $window.location.origin + '/payment-success' }, // ⬅️ CORRECTION: Fermeture de `confirmParams` redirect: 'if_required' }); // ⬅️ CORRECTION: Fermeture de `confirmPayment` if (error) { console.error('❌ Erreur paiement:', error); $scope.$apply(() => { $rootScope.showNotification(error.message || 'Erreur lors du paiement', 'error'); $scope.processing = false; // ⬅️ CORRECTION: Fermeture de la fonction fléchée $scope.$apply }); } else { const paymentProof = { sessionToken: paymentIntent.id || $scope.paymentIntentId, paymentDate: new Date().toISOString(), amount: 50 }; localStorage.setItem('edec_payment_proof', JSON.stringify(paymentProof)); $rootScope.showNotification($rootScope.t('payment_success'), 'success'); $timeout(() => { $location.path('/' + $scope.destinationType); }, 1500); } } catch (confirmError) { console.error('❌ Erreur confirmation paiement:', confirmError); $scope.$apply(() => { $scope.processing = false; }); } }; $scope.verifyExistingPayment = function(paymentId) { $http.post('/api/verify-payment', { paymentIntentId: paymentId }) .then(function(response) { if (response.data.valid) { $rootScope.showNotification($rootScope.t('payment_already_valid'), 'success'); $timeout(() => { $location.path('/' + $scope.destinationType); }, 1500); } else { localStorage.removeItem('edec_payment_proof'); $scope.initializePayment(); } }) .catch(function(error) { console.error('Erreur vérification paiement:', error); localStorage.removeItem('edec_payment_proof'); $scope.initializePayment(); }); }; $scope.goBack = function() { sessionStorage.removeItem('edec_intended_destination'); $location.path('/'); }; // 🌟 CORRECTION : Initialiser le paiement après le rendu de la vue $scope.$on('$viewContentLoaded', function() { const existingPayment = $scope._pendingExistingPayment; if (existingPayment) { try { const proof = JSON.parse(existingPayment); $scope.verifyExistingPayment(proof.sessionToken); } catch (e) { localStorage.removeItem('edec_payment_proof'); $scope.initializePayment(); } } else { $scope.initializePayment(); } }); }); // ⬅️ CORRECTION: Fermeture de `angular.module('edecApp').controller` ---------------------------------------------------- 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, $timeout, ConfigService, TranslationService) { function initScope() { $scope.step = 1; $scope.method = null; $scope.vehicle = {}; $scope.parties = { consignor: { name: '', firstname: '', address: '', zip: '', city: '', ide: '' }, consignee: { name: '', firstname: '', address: '', zip: '', city: '', ide: '' }, declarant: { name: '', firstname: '', address: '', zip: '', city: '', ide: '' } }; $scope.declarantType = 'consignor'; $scope.declaration = { destination_country: '', language: $rootScope.lang || 'fr' }; $scope.classification = null; $scope.ocrLoading = false; $scope.generating = false; $scope.ocrAttempted = false; $scope.vinValid = null; $scope.sessionToken = null; $scope.submissionProgress = 0; $scope.submissionStep = ''; $scope.submissionResult = null; $scope.showProgress = false; $scope.eventSource = null; $scope.ocrCountdown = 0; $scope.ocrCountdownActive = false; } const SESSION_STORAGE_KEY = 'edec_export_form'; let saveTimeout = null; function saveState() { $timeout.cancel(saveTimeout); saveTimeout = $timeout(function() { const dataToSave = { step: Math.min($scope.step || 1, 3), method: $scope.method, vehicle: angular.copy($scope.vehicle), parties: angular.copy($scope.parties), declarantType: $scope.declarantType, declaration: angular.copy($scope.declaration) }; sessionStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(dataToSave)); }, 500); } function loadSavedState() { const savedData = sessionStorage.getItem(SESSION_STORAGE_KEY); if (savedData) { try { const parsedData = JSON.parse(savedData); $scope.step = parsedData.step || 1; $scope.method = parsedData.method || null; angular.extend($scope.vehicle, parsedData.vehicle || {}); angular.extend($scope.parties, parsedData.parties || { consignor: {}, consignee: {}, declarant: {} }); $scope.declarantType = parsedData.declarantType || 'consignor'; angular.extend($scope.declaration, parsedData.declaration || {}); $rootScope.showNotification($rootScope.t('data_restored'), 'info'); return true; } catch (e) { sessionStorage.removeItem(SESSION_STORAGE_KEY); return false; } } return false; } // Vérification paiement const paymentProof = localStorage.getItem('edec_payment_proof'); if (!paymentProof) { $window.location.href = '/payment'; return; } try { const proof = JSON.parse(paymentProof); $http.post('/api/verify-payment', { paymentIntentId: proof.sessionToken }) .then(function(response) { if (!response.data.valid) { localStorage.removeItem('edec_payment_proof'); $rootScope.showNotification($rootScope.t('payment_invalid'), 'error'); $timeout(function() { $window.location.href = '/payment'; }, 2000); } }) .catch(function() { localStorage.removeItem('edec_payment_proof'); $window.location.href = '/payment'; }); } catch (e) { localStorage.removeItem('edec_payment_proof'); $window.location.href = '/payment'; return; } initScope(); loadSavedState(); ConfigService.getCountries().then(data => $scope.countries = data); $scope.$watch('parties.consignor.ide', function(newVal) { $scope.isCompany = !!newVal && newVal.trim() !== 'CHE222251936'; }); $scope.$watch('vehicle', saveState, true); $scope.$watch('parties', saveState, true); $scope.$watch('declaration', saveState, true); $scope.$watch('declarantType', saveState); $scope.$watch('step', saveState); $scope.$watch('method', saveState); $scope.$watch('submissionResult', function(newVal) { if (newVal) { sessionStorage.removeItem(SESSION_STORAGE_KEY); localStorage.removeItem('edec_payment_proof'); } }); $scope.selectMethod = function(method) { $scope.method = method; $scope.step = 2; }; $scope.triggerFileInput = () => document.getElementById('file-upload').click(); $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($rootScope.t('ocr_success'), 'success'); // Countdown 30s $scope.startOcrCountdown(); }).catch(function(error) { const details = error.data?.details || error.data?.error || 'Erreur inconnue'; $rootScope.showNotification('Erreur OCR: ' + details, 'error'); }).finally(() => { $scope.ocrLoading = false; }); }; $scope.startOcrCountdown = function() { $scope.ocrCountdown = 30; $scope.ocrCountdownActive = true; const interval = setInterval(function() { $scope.$apply(function() { $scope.ocrCountdown--; if ($scope.ocrCountdown <= 0) { $scope.ocrCountdownActive = false; clearInterval(interval); } }); }, 1000); }; $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; }).catch(function() { $scope.vinValid = false; }); }; $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; } }); }; $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($rootScope.t('fill_required_vehicle'), 'error'); return; } } $scope.step++; }; $scope.prevStep = () => $scope.step--; $scope.copyConsigneeToConsignor = function() { $scope.parties.consignor = angular.copy($scope.parties.consignee); $rootScope.showNotification($rootScope.t('info_copied'), 'success'); }; $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 consignor = $scope.parties.consignor; if (!consignor.name || !consignor.zip || !consignor.city || !$scope.declaration.destination_country) { $rootScope.showNotification($rootScope.t('fill_required_export'), 'error'); return; } $scope.generating = true; $scope.submissionProgress = 0; $scope.submissionStep = 'Initialisation...'; $scope.showProgress = true; $scope.step = 4; let declarantData = {}; if ($scope.declarantType === 'consignor') { declarantData = angular.copy($scope.parties.consignor); } else if ($scope.declarantType === 'consignee') { declarantData = angular.copy($scope.parties.consignee); } else if ($scope.declarantType === 'other') { declarantData = angular.copy($scope.parties.declarant); } const consignorFullName = getFullName(consignor.name, consignor.firstname, consignor.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 paymentProof = JSON.parse(localStorage.getItem('edec_payment_proof')); const payload = { ...$scope.vehicle, ...$scope.declaration, payment_id: paymentProof.sessionToken, 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', consignor_name: consignorFullName, consignor_address: consignor.address, consignor_zip: consignor.zip, consignor_city: consignor.city, consignor_ide: consignor.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-export', payload) .then(function(response) { $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); }); }; $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; if (status.status === 'submitted') { $scope.submissionResult = status; $scope.showProgress = false; $scope.generating = false; source.close(); $rootScope.showNotification($rootScope.t('submission_success'), 'success'); } else if (status.status === 'submission_error') { $scope.showProgress = false; $scope.generating = false; source.close(); $rootScope.setErrorContext($scope.sessionToken); $rootScope.showNotification($rootScope.t('submission_error_detected'), 'error'); } } catch (e) { console.error('[ExportController-SSE] Erreur:', e); } }); }; source.onerror = function(error) { console.error('[ExportController-SSE] Erreur:', error); $scope.$apply(() => { $scope.submissionStep = 'Erreur de connexion SSE...'; }); }; }; $scope.downloadPDF = function(path) { if (!path) return; $window.open('/api/download-pdf?path=' + encodeURIComponent(path), '_blank'); }; $scope.resetForm = function() { if ($scope.eventSource) $scope.eventSource.close(); sessionStorage.removeItem(SESSION_STORAGE_KEY); initScope(); $rootScope.showNotification($rootScope.t('form_reset'), 'info'); }; }); ---------------------------------------------------- public/js/app.js // Définition du module principal AngularJS avec la dépendance à ngRoute angular.module('edecApp', ['ngRoute']) // Correction de l'erreur de syntaxe dans l'injection de dépendances pour .config // Les dépendances doivent être des chaînes de caractères valides, séparées par des virgules. .config(['$routeProvider', '$locationProvider', function($routeProvider, $locationProvider) { $locationProvider.html5Mode({ enabled: true, requireBase: true }); $routeProvider .when('/', { templateUrl: 'views/home.html', controller: 'HomeController' }) .when('/payment', { templateUrl: 'views/payment.html', controller: 'PaymentController' }) .when('/import', { templateUrl: 'views/import.html', controller: 'ImportController', resolve: { paymentCheck: ['$location', function($location) { const savedPayment = localStorage.getItem('edec_payment_proof'); if (!savedPayment) { sessionStorage.setItem('edec_intended_destination', 'import'); $location.path('/payment'); return false; } return true; }] } }) // Ajouter cette route dans app.js .when('/payment-success', { template: `
✅

{{ t('payment_success') }}

Redirection vers votre formulaire...

`, controller: ['$scope', '$location', '$timeout', function($scope, $location, $timeout) { $timeout(function() { const destination = sessionStorage.getItem('edec_intended_destination') || 'import'; $location.path('/' + destination); }, 2000); }] }) .when('/export', { templateUrl: 'views/export.html', controller: 'ExportController', resolve: { paymentCheck: ['$location', function($location) { const savedPayment = localStorage.getItem('edec_payment_proof'); if (!savedPayment) { sessionStorage.setItem('edec_intended_destination', 'export'); $location.path('/payment'); return false; } return true; }] } }) .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') }; }]) .factory('TranslationService', ['$http', '$q', function($http, $q) { let translations = {}; // L'appel à $http.get() retourne une promesse pour charger le fichier de traduction const promise = $http.get('/translations/strings.json').then(function(response) { translations = response.data; return translations; }); return { getPromise: promise, get: function(key, lang) { // La logique d'accès est inversée pour correspondre à la structure { "clé": { "fr": "...", "de": "..." } } if (translations[key] && translations[key][lang]) { return translations[key][lang]; } return key; } }; }]) .run(['$rootScope', '$location', 'TranslationService', function($rootScope, $location, TranslationService) { 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; TranslationService.getPromise.then(function() { $rootScope.t = function(key) { return TranslationService.get(key, $rootScope.lang); }; }); $rootScope.setErrorContext = function(token) { sessionStorage.setItem('error_session_token', token); $location.path('/submission-error'); }; $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); if (duration > 0) { setTimeout(() => { // Utiliser $rootScope.$apply pour mettre à jour la vue AngularJS $rootScope.$apply(() => { notification.visible = false; 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/script_convertion.sh #!/bin/bash INPUT_FILE="strings_old.json" OUTPUT_FILE="strings.json" TEMP_DIR=$(mktemp -d) echo "Transformation en cours..." # Étape 1: Extraire toutes les clés jq -r '[.fr, .en, .de, .it | keys] | flatten | unique | .[]' "$INPUT_FILE" > "$TEMP_DIR/keys.txt" # Étape 2: Créer un objet vide pour commencer echo '{}' > "$TEMP_DIR/result.json" # Étape 3: Pour chaque clé, construire l'objet de traduction while IFS= read -r key; do # Extraire les valeurs pour chaque langue fr_value=$(jq -r ".fr.\"$key\" // \"\"" "$INPUT_FILE" 2>/dev/null) en_value=$(jq -r ".en.\"$key\" // \"\"" "$INPUT_FILE" 2>/dev/null) de_value=$(jq -r ".de.\"$key\" // \"\"" "$INPUT_FILE" 2>/dev/null) it_value=$(jq -r ".it.\"$key\" // \"\"" "$INPUT_FILE" 2>/dev/null) # Construire l'objet JSON pour cette clé key_object=$(jq -n \ --arg key "$key" \ --arg fr "$fr_value" \ --arg en "$en_value" \ --arg de "$de_value" \ --arg it "$it_value" \ '{ ($key): { "fr": (if $fr == "" then null else $fr end), "en": (if $en == "" then null else $en end), "de": (if $de == "" then null else $de end), "it": (if $it == "" then null else $it end) } }') # Fusionner avec le résultat existant jq -s '.[0] * .[1]' "$TEMP_DIR/result.json" <(echo "$key_object") > "$TEMP_DIR/result_temp.json" mv "$TEMP_DIR/result_temp.json" "$TEMP_DIR/result.json" done < "$TEMP_DIR/keys.txt" # Étape 4: Trier et formater le résultat final jq -S '.' "$TEMP_DIR/result.json" > "$OUTPUT_FILE" # Nettoyer rm -rf "$TEMP_DIR" echo "✅ Transformation terminée! Fichier: $OUTPUT_FILE" # Vérification echo "" echo "Vérification des premières clés:" jq 'with_entries(select(.key | test("import_vehicle_nav|export_vehicle_nav|home_nav")))' "$OUTPUT_FILE" ---------------------------------------------------- public/translations/strings.json { "additional_costs":{ "de":"Zusätzliche Kosten (in derselben Währung)", "en":"Additional costs (in same currency)", "fr":"Coûts additionnels (dans la même devise)", "it":"Costi aggiuntivi (nella stessa valuta)" }, "address_street":{ "de":"Adresse (Strasse und Nummer)", "en":"Address (Street and number)", "fr":"Adresse (Rue et numéro)", "it":"Indirizzo (Via e numero)" }, "auto_extraction_ai":{ "de":"Automatische Datenextraktion durch KI (JPG, PNG, PDF)", "en":"Automatic data extraction by AI (JPG, PNG, PDF)", "fr":"Extraction automatique des données par IA (JPG, PNG, PDF)", "it":"Estrazione automatica dei dati tramite IA (JPG, PNG, PDF)" }, "back":{ "de":"← Zurück", "en":"← Back", "fr":"← Retour", "it":"← Indietro" }, "brand":{ "de":"Marke", "en":"Brand", "fr":"Marque", "it":"Marca" }, "choose_input_method":{ "de":"Wählen Sie Ihre Eingabemethode", "en":"Choose your input method", "fr":"Choisissez votre méthode de saisie", "it":"Scegli il tuo metodo di inserimento" }, "city":{ "de":"Ort", "en":"City", "fr":"Ville", "it":"Città" }, "companies_only":{ "de":"Nur für Unternehmen", "en":"For companies only", "fr":"Uniquement pour les entreprises", "it":"Solo per le aziende" }, "consignee":{ "de":"Empfänger", "en":"Consignee", "fr":"Destinataire", "it":"Destinatario" }, "consignee_definition":{ "de":"Empfänger ist die Person oder das Unternehmen mit Sitz in der Schweiz, an die die Ware versandt wird.", "en":"The consignee is the person or company domiciled in Switzerland to whom the goods are shipped.", "fr":"Est destinataire la personne ou l'entreprise domiciliée sur le territoire Suisse à qui la marchandise est expédiée.", "it":"È destinatario la persona o l'azienda domiciliata sul territorio svizzero a cui viene spedita la merce." }, "consignor":{ "de":"Versender", "en":"Consignor", "fr":"Expéditeur", "it":"Mittente" }, "consignor_definition":{ "de":"Versender ist die Person oder das Unternehmen mit Sitz in der Schweiz, die die Ware aus dem Gebiet versendet.", "en":"The consignor is the person or company domiciled in Switzerland who ships the goods out of the territory.", "fr":"Est expéditeur la personne ou l'entreprise domiciliée en Suisse qui expédie la marchandise hors du territoire.", "it":"È mittente la persona o l'azienda domiciliata in Svizzera che spedisce la merce fuori dal territorio." }, "continue":{ "de":"Weiter", "en":"Continue", "fr":"Continuer", "it":"Continua" }, "copy_access_link":{ "de":"Zugriffslink kopieren", "en":"Copy access link", "fr":"Copier le lien d'accès", "it":"Copiare il link di accesso" }, "copy_from_consignee":{ "de":"Von Empfänger kopieren", "en":"Copy from consignee", "fr":"Copier depuis destinataire", "it":"Copia da destinatario" }, "copy_from_consignor":{ "de":"Vom Versender kopieren", "en":"Copy from consignor", "fr":"Copier depuis expéditeur", "it":"Copia da mittente" }, "customs_regime":{ "de":"Zollverfahren", "en":"Customs Regime", "fr":"Régime Douanier", "it":"Regime Doganale" }, "cylinder_capacity":{ "de":"Hubraum", "en":"Engine displacement", "fr":"Cylindrée", "it":"Cilindrata" }, "data_restored":{ "de":"Vorherige Daten wiederhergestellt", "en":"Previous data restored", "fr":"Données précédentes restaurées", "it":"Dati precedenti ripristinati" }, "declarant":{ "de":"Anmelder", "en":"Declarant", "fr":"Déclarant", "it":"Dichiarante" }, "declarant_definition":{ "de":"Die Person, die diese Anmeldung durchführt.", "en":"The person making this declaration.", "fr":"La personne qui effectue la présente déclaration.", "it":"La persona che effettua la presente dichiarazione." }, "declarant_info":{ "de":"Informationen des Anmelders", "en":"Declarant information", "fr":"Informations du déclarant", "it":"Informazioni del dichiarante" }, "declaration":{ "de":"Anmeldung", "en":"Declaration", "fr":"Déclaration", "it":"Dichiarazione" }, "declaration_info":{ "de":"Informationen für die Anmeldung", "en":"Declaration information", "fr":"Informations pour la déclaration", "it":"Informazioni per la dichiarazione" }, "declaration_number":{ "de":"Anmeldungsnummer", "en":"Declaration number", "fr":"Numéro de déclaration", "it":"Numero di dichiarazione" }, "destination_country":{ "de":"Bestimmungsland", "en":"Destination country", "fr":"Pays de destination", "it":"Paese di destinazione" }, "diesel":{ "de":"Diesel", "en":"Diesel", "fr":"Diesel", "it":"Diesel" }, "dispatch_country":{ "de":"Versendungsland", "en":"Dispatch country", "fr":"Pays d'expédition", "it":"Paese di spedizione" }, "documents_expire_30_days":{ "de":"Die Dokumente und dieser Zugriffslink **verfallen in 30 Tagen**.", "en":"Documents and this access link **will expire in 30 days**.", "fr":"Les documents et ce lien d'accès **expireront dans 30 jours**.", "it":"I documenti e questo link di accesso **scadranno tra 30 giorni**." }, "download_delivery_bulletin":{ "de":"Abfertigungsschein herunterladen", "en":"Download Delivery Bulletin", "fr":"Télécharger le Bulletin de Délivrance", "it":"Scaricare il Bollettino di Consegna" }, "download_documents_text":{ "de":"Bitte laden Sie die folgenden Dokumente herunter. Sie werden beim Grenzübertritt benötigt.", "en":"Please download the documents below. They will be required at customs.", "fr":"Veuillez télécharger les documents ci-dessous. Ils vous seront demandés lors du passage en douane.", "it":"Si prega di scaricare i seguenti documenti. Ti saranno richiesti al passaggio in dogana." }, "download_export_list":{ "de":"Ausfuhranmeldung herunterladen", "en":"Download Export Declaration", "fr":"Télécharger la Déclaration d'Exportation", "it":"Scaricare la Dichiarazione di Esportazione" }, "download_import_list":{ "de":"Einfuhrliste herunterladen", "en":"Download Import List", "fr":"Télécharger la Liste d'Importation", "it":"Scaricare la Lista di Importazione" }, "electric":{ "de":"Elektrisch", "en":"Electric", "fr":"Électrique", "it":"Elettrico" }, "estimated_classification":{ "de":"Geschätzte Zollklassifizierung", "en":"Estimated customs classification", "fr":"Classification douanière estimée", "it":"Classificazione doganale stimata" }, "export_page_title":{ "de":"Ein Fahrzeug aus der Schweiz exportieren", "en":"Export a Vehicle from Switzerland", "fr":"Exporter un Véhicule depuis la Suisse", "it":"Esportare un Veicolo dalla Svizzera" }, "export_vehicle_nav":{ "de":"Exportieren", "en":"Export", "fr":"Exporter", "it":"Esportare" }, "extraction_in_progress":{ "de":"Extraktion läuft", "en":"Extraction in progress", "fr":"Extraction en cours", "it":"Estrazione in corso" }, "feature_ai_desc":{ "de":"Automatische Extraktion aus Ihrem Fahrzeugausweis", "en":"Automatic data extraction from your registration card", "fr":"Extraction automatique depuis votre carte grise", "it":"Estrazione automatica dalla tua carta di circolazione" }, "feature_ai_title":{ "de":"Integrierte KI", "en":"AI integrated", "fr":"IA intégrée", "it":"IA integrata" }, "feature_eco_desc":{ "de":"Transparente Preise, persönliches Tool", "en":"Transparent pricing, personal tool", "fr":"Tarifs transparents, outil personnel", "it":"Tariffe trasparenti, strumento personale" }, "feature_eco_title":{ "de":"Wirtschaftlich", "en":"Economical", "fr":"Économique", "it":"Economico" }, "feature_fast_desc":{ "de":"Erstellen Sie Ihre Anmeldung in weniger als 5 Minuten", "en":"Generate your declaration in under 5 minutes", "fr":"Générez votre déclaration en moins de 5 minutes", "it":"Genera la tua dichiarazione in meno di 5 minuti" }, "feature_fast_title":{ "de":"Schnell", "en":"Fast", "fr":"Rapide", "it":"Veloce" }, "feature_simple_desc":{ "de":"Intuitive Benutzeroberfläche, geführte Schritte", "en":"Intuitive interface, guided steps", "fr":"Interface intuitive, étapes guidées", "it":"Interfaccia intuitiva, passaggi guidati" }, "feature_simple_title":{ "de":"Einfach", "en":"Simple", "fr":"Simple", "it":"Semplice" }, "fill_form_step_by_step":{ "de":"Formular Schritt für Schritt ausfüllen", "en":"Fill out form step by step", "fr":"Remplir le formulaire pas à pas", "it":"Compilare il modulo passo dopo passo" }, "fill_required_export":{ "de":"Bitte geben Sie alle erforderlichen Informationen an.", "en":"Please provide all required information.", "fr":"Veuillez renseigner toutes les informations obligatoires.", "it":"Si prega di fornire tutte le informazioni obbligatorie." }, "fill_required_importer":{ "de":"Bitte geben Sie die Informationen des Importeurs und das Versendungsland an.", "en":"Please provide importer information and dispatch country.", "fr":"Veuillez renseigner les informations de l'importateur et le pays d'expédition.", "it":"Si prega di fornire le informazioni dell'importatore e il paese di spedizione." }, "fill_required_vehicle":{ "de":"Bitte füllen Sie die erforderlichen Fahrzeuginformationen aus.", "en":"Please fill in the required vehicle information.", "fr":"Veuillez remplir les informations obligatoires du véhicule.", "it":"Si prega di compilare le informazioni obbligatorie del veicolo." }, "fill_valid_price":{ "de":"Bitte geben Sie einen gültigen Kaufpreis an (größer als 0).", "en":"Please enter a valid purchase price (greater than 0).", "fr":"Veuillez renseigner un prix d'achat valide (supérieur à 0).", "it":"Si prega di inserire un prezzo d'acquisto valido (superiore a 0)." }, "financial_transactions":{ "de":"Finanzielle Transaktionen", "en":"Financial Transactions", "fr":"Transactions Financières", "it":"Transazioni Finanziarie" }, "firstname":{ "de":"Vorname", "en":"First name", "fr":"Prénom", "it":"Nome" }, "for_individuals":{ "de":"Für Privatpersonen", "en":"For individuals", "fr":"Pour les particuliers", "it":"Per i privati" }, "form_reset":{ "de":"Formular zurückgesetzt.", "en":"Form reset.", "fr":"Formulaire réinitialisé.", "it":"Modulo reimpostato." }, "fuel_type":{ "de":"Kraftstoffart", "en":"Fuel type", "fr":"Type de carburant", "it":"Tipo di carburante" }, "future_access_and_sharing":{ "de":"Zukünftiger Zugriff und Freigabe", "en":"Future access and sharing", "fr":"Accès futur et partage", "it":"Accesso futuro e condivisione" }, "home_hero_cta_export":{ "de":"Fahrzeug exportieren", "en":"Export a vehicle", "fr":"Exporter un véhicule", "it":"Esportare un veicolo" }, "home_hero_cta_import":{ "de":"Fahrzeug importieren", "en":"Import a vehicle", "fr":"Importer un véhicule", "it":"Importare un veicolo" }, "home_hero_subtitle":{ "de":"Importieren oder exportieren Sie Ihr Fahrzeug in wenigen Klicks", "en":"Import or export your vehicle to Switzerland in just a few clicks", "fr":"Importez ou exportez votre véhicule en Suisse en quelques clics", "it":"Importa o esporta il tuo veicolo in Svizzera in pochi clic" }, "home_hero_title":{ "de":"🚗 Vereinfachte e-dec Anmeldung", "en":"🚗 Simplified e-dec Declaration", "fr":"🚗 Déclaration e-dec Simplifiée", "it":"🚗 Dichiarazione e-dec Semplificata" }, "home_nav":{ "de":"Startseite", "en":"Home", "fr":"Accueil", "it":"Home" }, "hybrid":{ "de":"Hybrid", "en":"Hybrid", "fr":"Hybride", "it":"Ibrido" }, "hybrid_plugin":{ "de":"Plugin-Hybrid", "en":"Plug-in hybrid", "fr":"Hybride rechargeable", "it":"Ibrido plug-in" }, "ide_number":{ "de":"IDE-Nummer", "en":"IDE number", "fr":"Numéro IDE", "it":"Numero IDE" }, "import_page_title":{ "de":"Ein Fahrzeug in die Schweiz importieren", "en":"Import a Vehicle to Switzerland", "fr":"Importer un Véhicule en Suisse", "it":"Importare un Veicolo in Svizzera" }, "import_vehicle_nav":{ "de":"Importieren", "en":"Import", "fr":"Importer", "it":"Importare" }, "importer":{ "de":"Importeur", "en":"Importer", "fr":"Importateur", "it":"Importatore" }, "importer_definition":{ "de":"Importeur ist, wer die Ware in das Schweizer Gebiet einführt oder für seine Rechnung einführen lässt. Importeur und Empfänger können dieselbe Person/Firma sein.", "en":"The importer is the person who imports the goods into Swiss territory or has them imported on their behalf. The importer and consignee can be the same person/company.", "fr":"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.", "it":"È importatore chi importa la merce nel territorio svizzero o la fa importare per proprio conto. L'importatore e il destinatario possono essere la stessa persona/azienda." }, "info_copied":{ "de":"Informationen kopiert!", "en":"Information copied!", "fr":"Informations copiées !", "it":"Informazioni copiate!" }, "invoice_currency":{ "de":"Rechnungswährung", "en":"Invoice currency", "fr":"Monnaie de la facture", "it":"Valuta della fattura" }, "involved_parties":{ "de":"Beteiligte Parteien", "en":"Involved Parties", "fr":"Parties Impliquées", "it":"Parti Coinvolte" }, "iva_exempt":{ "de":"Von der Automobilsteuer befreit (IVA / RG 660)", "en":"Exempt from automobile tax (IVA / RG 660)", "fr":"Exonéré de l'impôt sur les véhicules automobiles (IVA / RG 660)", "it":"Esente dall'imposta sugli autoveicoli (IVA / RG 660)" }, "legal_notice_text_1":{ "de":"Unser Service ist eine vereinfachte Schnittstelle zum offiziellen e-dec Portal der Schweizerischen Eidgenossenschaft, das kostenlos, aber komplex zu bedienen ist. Als Selbstanmelder bleiben Sie für die Richtigkeit der eingegebenen Daten verantwortlich.", "en":"Our service is a simplified interface for the official e-dec portal of the Swiss Confederation, which remains free but complex to use. As a self-declarant, you remain responsible for the accuracy of the data entered.", "fr":"Notre service est une interface simplifiée pour le portail e-dec officiel de la Confédération Suisse, qui reste gratuit mais complexe à utiliser. En tant qu'auto-déclarant, vous restez responsable de l'exactitude des données saisies.", "it":"Il nostro servizio è un'interfaccia semplificata per il portale e-dec ufficiale della Confederazione Svizzera, che rimane gratuito ma complesso da usare. Come autodichiarante, rimani responsabile dell'accuratezza dei dati inseriti." }, "legal_notice_text_2":{ "de":"Wenn Ihre Informationen korrekt sind, gibt es keine Probleme. Unser Team verfügt über 7 Jahre Erfahrung in Zollanmeldungen und garantiert einen zuverlässigen und konformen Prozess. Unsere Lösung ist bis zu 12-mal schneller als ein traditioneller Spediteur (5 Minuten gegenüber 1 Stunde) und viel wirtschaftlicher.", "en":"If your information is correct, there are no problems. Our team has 7 years of experience in customs declarations and we guarantee a reliable and compliant process. Our solution is up to 12 times faster than a traditional freight forwarder (5 minutes vs 1 hour) and much more economical.", "fr":"Si vos informations sont correctes, il n'y a aucun problème. Notre équipe possède 7 ans d'expérience dans les déclarations douanières et nous vous garantissons un processus fiable et conforme. Notre solution est jusqu'à 12 fois plus rapide qu'un transitaire traditionnel (5 minutes contre 1 heure) et bien plus économique.", "it":"Se le tue informazioni sono corrette, non ci sono problemi. Il nostro team ha 7 anni di esperienza nelle dichiarazioni doganali e garantiamo un processo affidabile e conforme. La nostra soluzione è fino a 12 volte più veloce di uno spedizioniere tradizionale (5 minuti contro 1 ora) e molto più economica." }, "legal_notice_text_3":{ "de":"Die Zahlung ist für eine einzige Anmeldung (Import oder Export) gültig. Bei nachgewiesenen technischen Problemen unsererseits kontaktieren wir Sie für Unterstützung.", "en":"Payment is valid for a single declaration (import or export). In case of proven technical issues on our part, we will contact you for assistance.", "fr":"Le paiement est valable pour une seule déclaration (import ou export). En cas de problème technique avéré de notre part, nous vous recontacterons pour assistance.", "it":"Il pagamento è valido per una sola dichiarazione (import o export). In caso di problemi tecnici comprovati da parte nostra, ti ricontatteremo per assistenza." }, "legal_notice_title":{ "de":"⚖️ Rechtliche Hinweise", "en":"⚖️ Legal Information", "fr":"⚖️ Informations Légales", "it":"⚖️ Informazioni Legali" }, "link_copied":{ "de":"Link in die Zwischenablage kopiert!", "en":"Link copied to clipboard!", "fr":"Lien copié dans le presse-papiers !", "it":"Link copiato negli appunti!" }, "loading_payment":{ "de":"Zahlungsmodul wird geladen", "en":"Loading payment module", "fr":"Chargement du module de paiement", "it":"Caricamento modulo di pagamento" }, "manual_input":{ "de":"Manuelle Eingabe", "en":"Manual input", "fr":"Saisie manuelle", "it":"Inserimento manuale" }, "matriculation_number":{ "de":"Kontrollschild-Nr.", "en":"License plate number", "fr":"N° de matricule (plaque)", "it":"N° di targa" }, "matriculation_number_hint":{ "de":"Format: 234.543.324 oder GE 12345 (optional)", "en":"Format: 234.543.324 or GE 12345 (optional)", "fr":"Format: 234.543.324 ou GE 12345 (optionnel)", "it":"Formato: 234.543.324 o GE 12345 (opzionale)" }, "method":{ "de":"Methode", "en":"Method", "fr":"Méthode", "it":"Metodo" }, "model":{ "de":"Typ", "en":"Model", "fr":"Modèle", "it":"Modello" }, "name_or_company":{ "de":"Name oder Firma", "en":"Name or company name", "fr":"Nom ou raison sociale", "it":"Nome o ragione sociale" }, "new_declaration":{ "de":"Neue Anmeldung durchführen", "en":"Make a new declaration", "fr":"Effectuer une nouvelle déclaration", "it":"Effettuare una nuova dichiarazione" }, "ocr_countdown_text":{ "de":"Bitte überprüfen Sie die von der KI extrahierten Daten sorgfältig. Fehler sind möglich. Die Schaltfläche \"Weiter\" wird aktiviert in:", "en":"Please carefully verify the data extracted by AI. Errors are possible. The \"Continue\" button will be enabled in:", "fr":"Veuillez vérifier attentivement les données extraites par l'IA. Des erreurs sont possibles. Le bouton \"Continuer\" sera activé dans :", "it":"Si prega di verificare attentamente i dati estratti dall'IA. Sono possibili errori. Il pulsante \"Continua\" sarà attivato tra:" }, "ocr_failed_retry":{ "de":"Analyse Ihres Fahrzeugausweises nicht möglich. Bitte Datei überprüfen und erneut versuchen oder zur manuellen Eingabe wechseln.", "en":"Registration card analysis failed. Please check file and retry, or switch to manual input.", "fr":"Analyse de votre carte grise impossible. Veuillez vérifier le fichier et réessayer, ou passer en saisie manuelle.", "it":"Analisi della carta di circolazione impossibile. Verificare il file e riprovare, o passare all'inserimento manuale." }, "ocr_success":{ "de":"Daten erfolgreich extrahiert!", "en":"Data extracted successfully!", "fr":"Données extraites avec succès !", "it":"Dati estratti con successo!" }, "optional":{ "de":"Optional", "en":"Optional", "fr":"Optionnel", "it":"Opzionale" }, "other_costs":{ "de":"Sonstige Kosten (z.B. Versicherung)", "en":"Other costs (e.g. insurance)", "fr":"Autres frais (ex: assurance)", "it":"Altri costi (es: assicurazione)" }, "other_person":{ "de":"Andere Person", "en":"Other person", "fr":"Autre personne", "it":"Altra persona" }, "pay_now":{ "de":"50 CHF bezahlen", "en":"Pay 50 CHF", "fr":"Payer 50 CHF", "it":"Pagare 50 CHF" }, "payment_already_valid":{ "de":"Zahlung bereits erfolgt und gültig, Weiterleitung...", "en":"Payment already completed and valid, redirecting...", "fr":"Paiement déjà effectué et valide, redirection...", "it":"Pagamento già effettuato e valido, reindirizzamento..." }, "payment_init_error":{ "de":"Fehler bei der Zahlungsinitialisierung", "en":"Error initializing payment", "fr":"Erreur lors de l'initialisation du paiement", "it":"Errore durante l'inizializzazione del pagamento" }, "payment_invalid":{ "de":"Zahlung ungültig oder bereits verwendet", "en":"Payment invalid or already used", "fr":"Paiement invalide ou déjà utilisé", "it":"Pagamento non valido o già utilizzato" }, "payment_secure_info":{ "de":"Sichere Zahlung über Stripe. Ihre Bankdaten durchlaufen niemals unsere Server.", "en":"Secure payment via Stripe. Your banking data never passes through our servers.", "fr":"Paiement sécurisé par Stripe. Vos données bancaires ne transitent jamais par nos serveurs.", "it":"Pagamento sicuro tramite Stripe. I tuoi dati bancari non transitano mai sui nostri server." }, "payment_single_use":{ "de":"⚠️ Gültig für eine einzige Anmeldung", "en":"⚠️ Valid for one declaration only", "fr":"⚠️ Valable pour une seule déclaration", "it":"⚠️ Valido per una sola dichiarazione" }, "payment_subtitle":{ "de":"Eine Zahlung für eine Anmeldung", "en":"One payment for one declaration", "fr":"Un seul paiement pour une déclaration", "it":"Un solo pagamento per una dichiarazione" }, "payment_success":{ "de":"✅ Zahlung erfolgreich! Weiterleitung...", "en":"✅ Payment successful! Redirecting...", "fr":"✅ Paiement réussi ! Redirection...", "it":"✅ Pagamento riuscito! Reindirizzamento..." }, "payment_title":{ "de":"Sichere Zahlung", "en":"Secure Payment", "fr":"Paiement Sécurisé", "it":"Pagamento Sicuro" }, "petrol":{ "de":"Benzin", "en":"Petrol", "fr":"Essence", "it":"Benzina" }, "postal_code":{ "de":"PLZ", "en":"Postal code", "fr":"NPA", "it":"NAP" }, "postal_code_ch":{ "de":"PLZ (Schweiz)", "en":"Postal code (Switzerland)", "fr":"NPA (Suisse)", "it":"NAP (Svizzera)" }, "postal_code_ch_hint":{ "de":"Nur 4 Ziffern (Schweizer Adresse erforderlich)", "en":"4 digits only (Swiss address required)", "fr":"4 chiffres uniquement (adresse suisse obligatoire)", "it":"Solo 4 cifre (indirizzo svizzero obbligatorio)" }, "privacy":{ "de":"Datenschutz", "en":"Privacy", "fr":"Confidentialité", "it":"Privacy" }, "privacy_notice_ocr":{ "de":"Die Analyse Ihres Fahrzeugausweises erfolgt durch die KI Google Gemini über eine sichere API. Bei Datenschutzbedenken empfehlen wir die manuelle Eingabe. Keine Daten werden von Drittanbietern über die Anfrageverarbeitung hinaus gespeichert.", "en":"Your registration card analysis is performed by Google Gemini AI via secure API. If you have privacy concerns, we recommend manual input. No data is retained by third-party services beyond request processing.", "fr":"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.", "it":"L'analisi della tua carta di circolazione viene effettuata dall'intelligenza artificiale Google Gemini tramite API sicura. In caso di preoccupazioni sulla privacy dei dati, raccomandiamo l'inserimento manuale. Nessun dato viene conservato da servizi terzi oltre l'elaborazione della richiesta." }, "processing":{ "de":"Verarbeitung läuft", "en":"Processing", "fr":"Traitement en cours", "it":"Elaborazione in corso" }, "purchase_price":{ "de":"Kaufpreis des Fahrzeugs", "en":"Vehicle purchase price", "fr":"Prix d'achat du véhicule", "it":"Prezzo d'acquisto del veicolo" }, "relocation_import":{ "de":"Einfuhr wegen Wohnsitzverlegung (MwSt-Befreiung)", "en":"Import for relocation (VAT exemption)", "fr":"Importation pour déménagement (Exemption TVA)", "it":"Importazione per trasloco (Esenzione IVA)" }, "result":{ "de":"Ergebnis", "en":"Result", "fr":"Résultat", "it":"Risultato" }, "scan_registration_card":{ "de":"Fahrzeugausweis scannen", "en":"Scan registration card", "fr":"Scanner la carte grise", "it":"Scansiona la carta di circolazione" }, "select":{ "de":"Auswählen", "en":"Select", "fr":"Sélectionner", "it":"Selezionare" }, "select_statistical_key":{ "de":"Wählen Sie den statistischen Schlüssel", "en":"Select statistical key", "fr":"Sélectionnez la clé statistique", "it":"Seleziona la chiave statistica" }, "service_type":{ "de":"Art der Dienstleistung", "en":"Service type", "fr":"Type de service", "it":"Tipo di servizio" }, "statistical_key":{ "de":"Statistischer Schlüssel", "en":"Statistical key", "fr":"Clé statistique", "it":"Chiave statistica" }, "submission_completed":{ "de":"Einreichung erfolgreich abgeschlossen!", "en":"Submission completed successfully!", "fr":"Soumission terminée avec succès !", "it":"Invio completato con successo!" }, "submission_completed_text":{ "de":"Ihre Anmeldung wurde an das e-dec Portal übermittelt und die Dokumente wurden abgerufen.", "en":"Your declaration has been submitted to the e-dec portal and documents have been retrieved.", "fr":"Votre déclaration a été soumise au portail e-dec et les documents ont été récupérés.", "it":"La tua dichiarazione è stata inviata al portale e-dec e i documenti sono stati recuperati." }, "submission_error_detected":{ "de":"Einreichungsfehler erkannt. Konsultieren Sie die Fehlerseite für weitere Details.", "en":"Submission error detected. Check error page for details.", "fr":"Erreur de soumission détectée. Consultez la page d'erreur pour plus de détails.", "it":"Errore di invio rilevato. Consulta la pagina di errore per maggiori dettagli." }, "submission_in_progress":{ "de":"Einreichung läuft", "en":"Submission in progress", "fr":"Soumission en cours", "it":"Invio in corso" }, "submission_success":{ "de":"Einreichung erfolgreich! Dokumente zum Download bereit.", "en":"Submission successful! Documents ready for download.", "fr":"Soumission réussie ! Documents prêts à télécharger.", "it":"Invio riuscito! Documenti pronti per il download." }, "submit_declaration":{ "de":"Anmeldung einreichen", "en":"Submit declaration", "fr":"Soumettre la déclaration", "it":"Inviare la dichiarazione" }, "tares_code":{ "de":"TARES-Code", "en":"TARES code", "fr":"Code TARES", "it":"Codice TARES" }, "the_consignee":{ "de":"Der Empfänger", "en":"The consignee", "fr":"Le destinataire", "it":"Il destinatario" }, "the_consignor":{ "de":"Der Versender", "en":"The consignor", "fr":"L'expéditeur", "it":"Il mittente" }, "the_importer":{ "de":"Der Importeur", "en":"The importer", "fr":"L'importateur", "it":"L'importatore" }, "total_price":{ "de":"Gesamtpreis", "en":"Total price", "fr":"Prix total", "it":"Prezzo totale" }, "total_value_base":{ "de":"Gesamtwert (Berechnungsgrundlage MwSt/IVA)", "en":"Total value (VAT calculation base)", "fr":"Valeur totale (base de calcul TVA/IVA)", "it":"Valore totale (base di calcolo IVA)" }, "transport_costs":{ "de":"Transportkosten bis zur Grenze", "en":"Transport costs to border", "fr":"Frais de transport jusqu'à la frontière", "it":"Costi di trasporto fino al confine" }, "transport_mode":{ "de":"Transportart", "en":"Mode of transport", "fr":"Mode de transport", "it":"Modalità di trasporto" }, "unique_link_text":{ "de":"Verwenden Sie diesen eindeutigen Link, um später auf Ihre Dokumente zuzugreifen.", "en":"Use this unique link to access your documents later.", "fr":"Utilisez ce lien unique pour accéder à vos documents ultérieurement.", "it":"Usa questo link univoco per accedere ai tuoi documenti successivamente." }, "upload_photo_pdf":{ "de":"Foto aufnehmen oder Bild/PDF auswählen", "en":"Take photo or choose image/PDF", "fr":"Photographier ou choisir l'image/PDF", "it":"Fotografare o scegliere immagine/PDF" }, "vehicle":{ "de":"Fahrzeug", "en":"Vehicle", "fr":"Véhicule", "it":"Veicolo" }, "vehicle_info":{ "de":"Fahrzeuginformationen", "en":"Vehicle information", "fr":"Informations du véhicule", "it":"Informazioni sul veicolo" }, "verify_extracted_data":{ "de":"⚠️ Überprüfung Erforderlich", "en":"⚠️ Mandatory Verification", "fr":"⚠️ Vérification Obligatoire", "it":"⚠️ Verifica Obbligatoria" }, "vin_invalid":{ "de":"❌ FIN-Format ungültig (17 alphanumerische Zeichen, ohne I, O, Q)", "en":"❌ Invalid VIN format (17 alphanumeric characters, no I, O, Q)", "fr":"❌ Le format du VIN est invalide (17 caractères alphanumériques, sans I, O, Q)", "it":"❌ Formato VIN non valido (17 caratteri alfanumerici, senza I, O, Q)" }, "vin_label":{ "de":"FIN (Fahrgestellnummer / 17 Zeichen)", "en":"VIN (Vehicle Identification Number / 17 characters)", "fr":"VIN (Numéro de châssis / 17 caractères)", "it":"VIN (Numero di telaio / 17 caratteri)" }, "vin_valid":{ "de":"✅ FIN gültig. Herkunftsland erkannt", "en":"✅ Valid VIN. Country of origin detected", "fr":"✅ VIN valide. Pays d'origine détecté", "it":"✅ VIN valido. Paese di origine rilevato" }, "weight_empty":{ "de":"Leergewicht", "en":"Unladen weight", "fr":"Poids à vide", "it":"Massa a vuoto" }, "weight_total":{ "de":"Gesamtgewicht", "en":"Gross vehicle weight", "fr":"Poids total autorisé", "it":"Massa totale autorizzata" }, "who_is_declarant":{ "de":"Wer ist der Anmelder?", "en":"Who is the declarant?", "fr":"Qui est le déclarant ?", "it":"Chi è il dichiarante?" }, "why_choose_us":{ "de":"Warum unseren Service wählen?", "en":"Why choose our service?", "fr":"Pourquoi choisir notre service ?", "it":"Perché scegliere il nostro servizio?" }, "year_first_registration":{ "de":"Jahr der Erstzulassung", "en":"Year of first registration", "fr":"Année de 1ère mise en circulation", "it":"Anno di prima immatricolazione" }, "app_name":{ "fr":"Déclaration e-dec Simplifiée", "en":"Simplified e-dec Declaration", "de":"Vereinfachte e-dec-Anmeldung", "it":"Dichiarazione e-dec Semplificata" }, "home_title":{ "fr":"Bienvenue", "en":"Welcome", "de":"Willkommen", "it":"Benvenuto" }, "home_subtitle":{ "fr":"Sélectionnez votre type de déclaration", "en":"Select your declaration type", "de":"Wählen Sie Ihren Anmeldetyp", "it":"Seleziona il tipo di dichiarazione" }, "import_button":{ "fr":"Déclaration d'Importation (Entrée CH)", "en":"Import Declaration (Entry CH)", "de":"Einfuhranmeldung (Eintritt CH)", "it":"Dichiarazione d'Importazione (Entrata CH)" }, "export_button":{ "fr":"Déclaration d'Exportation (Sortie CH)", "en":"Export Declaration (Exit CH)", "de":"Ausfuhranmeldung (Ausgang CH)", "it":"Dichiarazione d'Esportazione (Uscita CH)" }, "step_1_title":{ "fr":"Étape 1: Saisie des informations", "en":"Step 1: Data Entry", "de":"Schritt 1: Dateneingabe", "it":"Passo 1: Inserimento dati" }, "step_2_title":{ "fr":"Étape 2: Confirmation et Validation", "en":"Step 2: Confirmation and Validation", "de":"Schritt 2: Bestätigung und Validierung", "it":"Passo 2: Conferma e Validazione" }, "step_3_title":{ "fr":"Étape 3: Génération du Fichier", "en":"Step 3: File Generation", "de":"Schritt 3: Dateigenerierung", "it":"Passo 3: Generazione del File" }, "continue_button":{ "fr":"Continuer", "en":"Continue", "de":"Weiter", "it":"Continua" }, "back_button":{ "fr":"Retour", "en":"Back", "de":"Zurück", "it":"Indietro" }, "download_xml":{ "fr":"Télécharger le Fichier XML", "en":"Download XML File", "de":"XML-Datei herunterladen", "it":"Scarica il File XML" }, "ocr_upload_label":{ "fr":"Télécharger un document (OCR)", "en":"Upload a document (OCR)", "de":"Dokument hochladen (OCR)", "it":"Carica un documento (OCR)" }, "manual_entry_label":{ "fr":"Saisie Manuelle", "en":"Manual Entry", "de":"Manuelle Eingabe", "it":"Inserimento manuale" }, "document_type_registration":{ "fr":"Carte Grise (CH/UE)", "en":"Registration Document (CH/EU)", "de":"Fahrzeugausweis (CH/EU)", "it":"Libretto di Circolazione (CH/UE)" }, "document_type_invoice":{ "fr":"Facture d'achat", "en":"Purchase Invoice", "de":"Kaufrechnung", "it":"Fattura d'acquisto" }, "importer_details":{ "fr":"Détails de l'Importateur (Destinataire)", "en":"Importer Details (Consignee)", "de":"Details des Importeurs (Empfänger)", "it":"Dettagli dell'Importatore (Destinatario)" }, "consignor_details":{ "fr":"Détails de l'Expéditeur (Consignor CH)", "en":"Consignor Details (Exporter CH)", "de":"Details des Absenders (Exporteur CH)", "it":"Dettagli dello Speditore (Consignante CH)" }, "consignee_details_export":{ "fr":"Détails du Destinataire (Consignee Étranger)", "en":"Consignee Details (Foreign Recipient)", "de":"Details des Empfängers (Ausländischer Empfänger)", "it":"Dettagli del Destinatario (Destinatario Estero)" }, "declarant_choice":{ "fr":"Qui est le Déclarant ?", "en":"Who is the Declarant?", "de":"Wer ist der Anmelder?", "it":"Chi è il Dichiarante?" }, "is_importer_declarant":{ "fr":"L'Importateur (Destinataire)", "en":"The Importer (Consignee)", "de":"Der Importeur (Empfänger)", "it":"L'Importatore (Destinatario)" }, "is_consignor_declarant":{ "fr":"L'Expéditeur (Consignor)", "en":"The Consignor (Exporter)", "de":"Der Absender (Exporteur)", "it":"Lo Speditore (Consignante)" }, "is_other_declarant":{ "fr":"Un Tiers (Transitaire ou Autre)", "en":"A Third Party (Forwarder or Other)", "de":"Ein Dritter (Spediteur oder Sonstiges)", "it":"Una Terza Parte (Spedizioniere o Altro)" }, "vehicle_details":{ "fr":"Détails du Véhicule", "en":"Vehicle Details", "de":"Fahrzeugdetails", "it":"Dettagli del Veicolo" }, "brand_code_label":{ "fr":"Code de Marque (e-dec)", "en":"Brand Code (e-dec)", "de":"Markencode (e-dec)", "it":"Codice Marca (e-dec)" }, "model_label":{ "fr":"Modèle", "en":"Model", "de":"Modell", "it":"Modello" }, "matriculation_number_label":{ "fr":"N° de Matricule (Point 18 Carte Grise CH, Optionnel)", "en":"Registration No. (Point 18 CH Registration, Optional)", "de":"Matrikelnummer (Feld 18 FA CH, Optional)", "it":"N° di Immatricolazione (Punto 18 LdC CH, Opzionale)" }, "country_of_origin":{ "fr":"Pays d'Origine", "en":"Country of Origin", "de":"Ursprungsland", "it":"Paese d'Origine" }, "dispatch_country_label":{ "fr":"Pays de provenance (Dispatch Country)", "en":"Dispatch Country", "de":"Versandland (Dispatch Country)", "it":"Paese di Provenienza (Dispatch Country)" }, "delivery_destination_label":{ "fr":"Pays de Destination (Delivery Destination)", "en":"Delivery Destination Country", "de":"Bestimmungsland (Delivery Destination)", "it":"Paese di Destinazione (Delivery Destination)" }, "price_details":{ "fr":"Détails de la Transaction (Import)", "en":"Transaction Details (Import)", "de":"Transaktionsdetails (Import)", "it":"Dettagli della Transazione (Importazione)" }, "net_price_label":{ "fr":"Prix Net (€/CHF)", "en":"Net Price (€/CHF)", "de":"Nettopreis (€/CHF)", "it":"Prezzo Netto (€/CHF)" }, "vat_iva_label":{ "fr":"Montant TVA/IVA (€/CHF)", "en":"VAT/IVA Amount (€/CHF)", "de":"MwSt/IVA Betrag (€/CHF)", "it":"Importo IVA/IVA (€/CHF)" }, "currency_label":{ "fr":"Devise", "en":"Currency", "de":"Währung", "it":"Valuta" }, "tares_classification":{ "fr":"Classification TARES", "en":"TARES Classification", "de":"TARES-Klassifikation", "it":"Classificazione TARES" }, "loading_ocr":{ "fr":"Analyse en cours...", "en":"Analysis in progress...", "de":"Analyse läuft...", "it":"Analisi in corso..." }, "ocr_error":{ "fr":"Erreur lors de l'analyse du document. Veuillez réessayer ou utiliser la saisie manuelle.", "en":"Error analyzing document. Please try again or use manual entry.", "de":"Fehler bei der Dokumentenanalyse. Bitte versuchen Sie es erneut oder verwenden Sie die manuelle Eingabe.", "it":"Errore durante l'analisi del documento. Riprova o usa l'inserimento manuale." }, "error_general":{ "fr":"Une erreur inattendue est survenue.", "en":"An unexpected error occurred.", "de":"Ein unerwarteter Fehler ist aufgetreten.", "it":"Si è verificato un errore inatteso." }, "ocr_countdown_title":{ "fr":"Vérification des données extraites (obligatoire)", "en":"Verification of extracted data (mandatory)", "de":"Überprüfung der extrahierten Daten (obligatorisch)", "it":"Verifica dei dati estratti (obbligatoria)" }, "ocr_countdown_message":{ "fr":"Pour des raisons légales et de précision, vous devez relire les données extraites par l'IA et vérifier qu'aucune erreur ne s'est glissée dans les champs. Vous pourrez continuer dans ", "en":"For legal and accuracy reasons, you must re-read the data extracted by the AI and verify that no errors have slipped into the fields. You can continue in ", "de":"Aus rechtlichen und Genauigkeitsgründen müssen Sie die von der KI extrahierten Daten erneut überprüfen, um sicherzustellen, dass keine Fehler in den Feldern enthalten sind. Sie können fortfahren in ", "it":"Per motivi legali e di accuratezza, è necessario rileggere i dati estratti dall'IA e verificare che non ci siano errori nei campi. Potrai continuare tra " }, "payment_page_title":{ "fr":"Accès aux formulaires de Déclaration", "en":"Access to Declaration Forms", "de":"Zugang zu den Anmeldeformularen", "it":"Accesso ai Moduli di Dichiarazione" }, "payment_intro":{ "fr":"Veuillez procéder au paiement de 50 CHF pour débloquer l'accès au formulaire de déclaration que vous avez choisi. Le paiement est valable pour une seule déclaration.", "en":"Please proceed with the payment of 50 CHF to unlock access to the declaration form you have chosen. The payment is valid for a single declaration.", "de":"Bitte bezahlen Sie 50 CHF, um den Zugang zum von Ihnen gewählten Anmeldeformular freizuschalten. Die Zahlung ist nur für eine einzige Anmeldung gültig.", "it":"Si prega di procedere al pagamento di 50 CHF per sbloccare l'accesso al modulo di dichiarazione scelto. Il pagamento è valido per una sola dichiarazione." }, "payment_legal_text":{ "fr":"Notre solution est une surcouche simplifiée à l'application e-dec web officielle (qui est gratuite mais complexe). Vous restez l'auto-déclarant responsable des données saisies sur notre site. Forts de 7 ans d'expérience dans les déclarations douanières, nous vous garantissons que si les données que vous fournissez sont correctes, votre déclaration sera valide. Notre service simplifie énormément la procédure, vous fait gagner du temps (5 minutes contre 1 heure avec un transitaire) et est bien moins cher qu'un transitaire.", "en":"Our solution is a simplified layer over the official e-dec web application (which is free but complex). You remain the self-declarant responsible for the data entered on our site. With 7 years of experience in customs declarations, we guarantee that if the data you provide is correct, your declaration will be valid. Our service significantly simplifies the procedure, saves you time (5 minutes compared to 1 hour with a forwarder) and is much cheaper than a forwarder.", "de":"Unsere Lösung ist eine vereinfachte Schnittstelle zur offiziellen e-dec-Webanwendung (die kostenlos, aber komplex ist). Sie bleiben der Selbstanmelder und sind für die auf unserer Website eingegebenen Daten verantwortlich. Mit 7 Jahren Erfahrung in Zollanmeldungen garantieren wir Ihnen, dass Ihre Anmeldung gültig ist, sofern die von Ihnen bereitgestellten Daten korrekt sind. Unser Service vereinfacht das Verfahren erheblich, spart Ihnen Zeit (5 Minuten im Gegensatz zu 1 Stunde bei einem Spediteur) und ist viel günstiger als ein Spediteur.", "it":"La nostra soluzione è un livello semplificato sopra l'applicazione web ufficiale e-dec (che è gratuita ma complessa). Rimanete l'autodichiarante responsabile dei dati inseriti sul nostro sito. Con 7 anni di esperienza nelle dichiarazioni doganali, garantiamo che se i dati forniti sono corretti, la vostra dichiarazione sarà valida. Il nostro servizio semplifica enormemente la procedura, vi fa risparmiare tempo (5 minuti contro 1 ora con uno spedizioniere) ed è molto più economico di uno spedizioniere." }, "payment_processing":{ "fr":"Traitement du paiement en cours...", "en":"Processing payment...", "de":"Zahlung wird verarbeitet...", "it":"Elaborazione del pagamento in corso..." }, "payment_error":{ "fr":"Échec du paiement. Veuillez réessayer.", "en":"Payment failed. Please try again.", "de":"Zahlung fehlgeschlagen. Bitte versuchen Sie es erneut.", "it":"Pagamento fallito. Si prega di riprovare." } } ---------------------------------------------------- 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; } /* Tooltips */ .tooltip-icon { display: inline-block; width: 20px; height: 20px; line-height: 20px; text-align: center; color: white; border-radius: 50%; font-size: 0.85rem; cursor: pointer; margin-left: 0.5rem; vertical-align: middle; transition: background 0.3s; } .tooltip-bubble { background: #333; color: white; padding: 1rem; border-radius: 8px; margin-top: 0.5rem; font-size: 0.9rem; line-height: 1.5; box-shadow: 0 4px 12px rgba(0,0,0,0.3); max-width: 30%; position: absolute; } .tooltip-bubble::before { content: ''; position: absolute; top: -8px; left: 20px; width: 0; height: 0; border-left: 8px solid transparent; border-right: 8px solid transparent; border-bottom: 8px solid #333; } /* Radio labels */ .radio-label { display: inline-flex; align-items: center; font-weight: normal; } .radio-label input[type="radio"] { width: auto; margin-right: 0.5rem; } /* ===== PAYMENT PAGE STYLES ===== */ .payment-container { max-width: 800px; margin: 2rem auto; } .payment-wrapper { background: white; border-radius: 12px; box-shadow: 0 4px 20px rgba(0,0,0,0.1); overflow: hidden; } .payment-header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 2rem; text-align: center; } .payment-header h1 { margin: 0 0 0.5rem 0; font-size: 2rem; } .payment-subtitle { margin: 0; opacity: 0.9; font-size: 1.1rem; } .legal-notice { background: #f8f9fa; border-left: 4px solid #667eea; padding: 1.5rem; margin: 2rem; } .legal-notice h3 { margin-top: 0; color: #333; } .legal-content p { margin-bottom: 1rem; line-height: 1.6; } .legal-content p:last-child { margin-bottom: 0; } .pricing-box { background: #e7f3ff; border: 2px solid #667eea; border-radius: 8px; padding: 1.5rem; margin: 0 2rem 2rem 2rem; } .price-item { display: flex; justify-content: space-between; align-items: center; padding: 0.75rem 0; border-bottom: 1px solid rgba(102,126,234,0.2); } .price-item:last-of-type { border-bottom: none; } .price-item.total { font-size: 1.3rem; font-weight: bold; color: #667eea; padding-top: 1rem; border-top: 2px solid #667eea; } .price-label { font-weight: 500; } .price-value { font-weight: 600; } .price-notice { margin: 1rem 0 0 0; padding: 0.75rem; background: #fff3cd; border-left: 4px solid #ffc107; border-radius: 4px; font-size: 0.95rem; } .payment-form-container { padding: 2rem; } #payment-element { margin-bottom: 1.5rem; } .payment-actions { display: flex; gap: 1rem; justify-content: space-between; } .payment-actions .btn { flex: 1; } .payment-loading { text-align: center; padding: 3rem 2rem; } .spinner { border: 4px solid #f3f3f3; border-top: 4px solid #667eea; border-radius: 50%; width: 50px; height: 50px; animation: spin 1s linear infinite; margin: 0 auto 1rem auto; } @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } .security-info { background: #d4edda; border-left: 4px solid #28a745; padding: 1rem; margin: 2rem; border-radius: 4px; font-size: 0.9rem; } /* ===== OCR COUNTDOWN STYLES ===== */ .ocr-countdown-notice { background: #fff3cd; border: 2px solid #ffc107; border-radius: 8px; padding: 1.5rem; margin: 1.5rem 0; text-align: center; animation: pulse-warning 2s ease-in-out infinite; } @keyframes pulse-warning { 0%, 100% { box-shadow: 0 0 0 0 rgba(255, 193, 7, 0.4); } 50% { box-shadow: 0 0 0 10px rgba(255, 193, 7, 0); } } .ocr-countdown-notice p { margin: 0.5rem 0; font-size: 1rem; } .ocr-countdown-notice p:first-child { font-weight: bold; font-size: 1.1rem; color: #856404; } .countdown-timer { font-size: 2.5rem; font-weight: bold; color: #856404; margin-top: 1rem; font-family: 'Courier New', monospace; background: rgba(255, 193, 7, 0.2); padding: 1rem 2rem; border-radius: 8px; display: inline-block; } /* Désactiver le bouton pendant le countdown */ button[ng-disabled="ocrCountdownActive"] { opacity: 0.5; cursor: not-allowed; pointer-events: none; } /* ===== RESPONSIVE ADJUSTMENTS ===== */ @media (max-width: 768px) { .payment-header h1 { font-size: 1.5rem; } .payment-subtitle { font-size: 1rem; } .legal-notice, .pricing-box, .payment-form-container, .security-info { margin-left: 1rem; margin-right: 1rem; } .payment-actions { flex-direction: column-reverse; } .countdown-timer { font-size: 2rem; padding: 0.75rem 1.5rem; } .ocr-countdown-notice { padding: 1rem; } } .notifications-container { position: fixed; top: 20px; right: 20px; z-index: 9999; max-width: 400px; } .notification { margin-bottom: 10px; padding: 15px; border-radius: 4px; box-shadow: 0 4px 8px rgba(0,0,0,0.1); transition: all 0.3s ease; transform: translateX(0); opacity: 1; } .notification-hidden { transform: translateX(120%); opacity: 0; } .notification-success { background-color: #d4edda; color: #155724; border-left: 5px solid #28a745; } .notification-error { background-color: #f8d7da; color: #721c24; border-left: 5px solid #dc3545; } .notification-warning { background-color: #fff3cd; color: #856404; border-left: 5px solid #ffc107; } .notification-info { background-color: #d1ecf1; color: #0c5460; border-left: 5px solid #17a2b8; } .notification-content { display: flex; justify-content: space-between; align-items: center; } .notification-close { background: none; border: none; font-size: 18px; cursor: pointer; margin-left: 10px; opacity: 0.7; } .notification-close:hover { opacity: 1; } .ocr-countdown-notice { background: #fff3cd; border: 2px solid #ffc107; border-radius: 8px; padding: 1.5rem; margin: 1.5rem 0; text-align: center; } .countdown-timer { font-size: 2rem; font-weight: bold; color: #856404; margin-top: 1rem; } /* ===== STRIPE ELEMENTS OVERRIDE ===== */ .StripeElement { background-color: white; padding: 12px; border-radius: 4px; border: 1px solid #ddd; transition: border-color 0.3s; } .StripeElement--focus { border-color: #667eea; box-shadow: 0 0 0 3px rgba(102,126,234,0.1); } .StripeElement--invalid { border-color: #dc3545; } .StripeElement--complete { border-color: #28a745; } /* 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/payment.html

{{ t('payment_title') }}

{{ t('payment_subtitle') }}

{{ t('legal_notice_title') }}

{{ t('legal_notice_text_1') }}

{{ t('legal_notice_text_2') }}

{{ t('legal_notice_text_3') }}

{{ t('service_type') }}: {{ t(destinationType === 'import' ? 'import_vehicle_nav' : 'export_vehicle_nav') }}
{{ t('total_price') }}: 50.00 CHF

{{ t('payment_single_use') }}

{{ t('loading_payment') }}...

🔒 {{ t('payment_secure_info') }}

---------------------------------------------------- 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 ---------------------------------------------------- public/views/export.html
{{notification.message}}

{{ t('export_page_title') }}

1 {{ t('method') }}
2 {{ t('vehicle') }}
3 {{ t('declaration') }}
4 {{ t('result') }}

{{ t('choose_input_method') }}

📸

{{ t('scan_registration_card') }}

{{ t('auto_extraction_ai') }}

⌨️

{{ t('manual_input') }}

{{ t('fill_form_step_by_step') }}

{{ t('vehicle_info') }}

ℹ️ {{ t('privacy') }}: {{ t('privacy_notice_ocr') }}

⚠️ {{ t('verify_extracted_data') }}

{{ t('ocr_countdown_text') }}

{{ ocrCountdown }}s
✅ {{ t('vin_valid') }} ❌ {{ t('vin_invalid') }}
{{ t('matriculation_number_hint') }}

📋 {{ t('estimated_classification') }}

{{ t('tares_code') }}: {{ classification.commodity_code }} - {{ classification.description }}

{{ t('select_statistical_key') }}: *

{{ t('statistical_key') }}: {{ vehicle.statistical_key }} - {{ classification.statistical_key_description }}

{{ t('ocr_failed_retry') }}

{{ t('declaration_info') }}

{{ t('involved_parties') }}

📍 {{ t('consignor') }}

{{ t('consignor_definition') }}

{{ t('postal_code_ch_hint') }}

📦 {{ t('consignee') }}

{{ t('consignee_definition') }}

✏️ {{ t('declarant') }}

{{ t('declarant_definition') }}

{{ t('declarant_info') }}
{{ t('customs_regime') }}

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

✅

{{ t('submission_completed') }}

{{ t('submission_completed_text') }}

{{ t('declaration_number') }}: {{ submissionResult.declarationNumber }}

{{ t('download_documents_text') }}


{{ t('future_access_and_sharing') }}

{{ t('unique_link_text') }}

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

{{ t('documents_expire_30_days') }}

---------------------------------------------------- public/views/import.html
{{notification.message}}

{{ t('import_page_title') }}

1 {{ t('method') }}
2 {{ t('vehicle') }}
3 {{ t('declaration') }}
4 {{ t('result') }}

{{ t('choose_input_method') }}

📸

{{ t('scan_registration_card') }}

{{ t('auto_extraction_ai') }}

⌨️

{{ t('manual_input') }}

{{ t('fill_form_step_by_step') }}

{{ t('vehicle_info') }}

ℹ️ {{ t('privacy') }}: {{ t('privacy_notice_ocr') }}

⚠️ {{ t('verify_extracted_data') }}

{{ t('ocr_countdown_text') }}

{{ ocrCountdown }}s
✅ {{ t('vin_valid') }}: {{declaration.dispatch_country || '...'}} ❌ {{ t('vin_invalid') }}
{{ t('matriculation_number_hint') }}

📋 {{ t('estimated_classification') }}

{{ t('tares_code') }}: {{ classification.commodity_code }} - {{ classification.description }}

{{ t('select_statistical_key') }}: *

{{ t('statistical_key') }}: {{ vehicle.statistical_key }} - {{ classification.statistical_key_description }}

{{ t('ocr_failed_retry') }}

{{ t('declaration_info') }}

{{ t('involved_parties') }}

📍 {{ t('consignee') }}

{{ t('consignee_definition') }}

{{ t('postal_code_ch_hint') }}

📦 {{ t('importer') }}

{{ t('importer_definition') }}

✏️ {{ t('declarant') }}

{{ t('declarant_definition') }}

{{ t('declarant_info') }}
{{ t('customs_regime') }}
{{ t('financial_transactions') }}

{{ t('additional_costs') }}

{{ t('total_value_base') }}: ~ {{ vatValueCHF | number:0 }} CHF

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

✅

{{ t('submission_completed') }}

{{ t('submission_completed_text') }}

{{ t('declaration_number') }}: {{ submissionResult.declarationNumber }}

{{ t('download_documents_text') }}


{{ t('future_access_and_sharing') }}

{{ t('unique_link_text') }}

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

{{ t('documents_expire_30_days') }}

---------------------------------------------------- package.json { "name": "edec-vehicles", "version": "1.3.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", "stripe": "^14.25.0", "uuid": "^9.0.1", "winston": "^3.11.0" }, "devDependencies": { "nodemon": "^3.0.2" } }