e-dec Véhicules - Déclaration Simplifiée
----------------------------------------------------
public/js/app.js
Dernière modification: 2025-10-06
// 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;
}]
}
})
.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) {
if (translations[lang] && translations[lang][key]) {
return translations[lang][key];
}
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);
}
};
}])
// Contrôleur de paiement (précédemment dans 'public/js/controllers/paymentController.js')
.controller('PaymentController', function($scope, $http, $location, $window, $rootScope, $timeout) {
$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';
// Vérifier si paiement déjà effectué
const existingPayment = localStorage.getItem('edec_payment_proof');
if (existingPayment) {
try {
const proof = JSON.parse(existingPayment);
$scope.verifyExistingPayment(proof.sessionToken);
} catch (e) {
localStorage.removeItem('edec_payment_proof');
$scope.initializePayment();
}
} else {
$scope.initializePayment();
}
$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(function() {
$location.path('/' + $scope.destinationType);
}, 1500);
} else {
localStorage.removeItem('edec_payment_proof');
$scope.initializePayment();
}
})
.catch(function() {
localStorage.removeItem('edec_payment_proof');
$scope.initializePayment();
});
};
$scope.initializePayment = function() {
// Charger Stripe.js dynamiquement
const script = document.createElement('script');
script.src = 'https://js.stripe.com/v3/';
script.onload = function() {
$scope.$apply(function() {
// Initialisation de Stripe avec la clé publique (à remplacer par une clé réelle/secrète en production)
$scope.stripe = Stripe('pk_test_51SF3ZMDLAThCxOAyPFDmPowjqUJyyay34PQZu8SfEHDZaVNFSNCCPjE2dAJYpvHHtqQcR0I23gEstk9V24B83PAw00g4jIHw2r');
$scope.createPaymentIntent();
});
};
document.head.appendChild(script);
};
$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'
};
const locale = $rootScope.lang || 'fr';
$scope.elements = $scope.stripe.elements({
clientSecret: $scope.clientSecret,
appearance: appearance,
locale: locale
});
// Création de l'élément d'interface utilisateur de paiement Stripe
$scope.paymentElement = $scope.elements.create('payment', {
layout: 'tabs'
});
$scope.paymentElement.mount('#payment-element');
$scope.loading = false;
})
.catch(function(error) {
$rootScope.showNotification($rootScope.t('payment_init_error'), 'error');
console.error('Erreur création payment intent:', error);
$scope.loading = false;
});
};
$scope.handleSubmit = async function() {
if ($scope.processing) return;
$scope.processing = true;
$scope.$apply();
const {
error
} = await $scope.stripe.confirmPayment({
elements: $scope.elements,
confirmParams: {
// Définir une URL de retour pour le cas où la redirection est nécessaire (ex: 3DS Secure)
return_url: $window.location.origin + '/payment-success'
},
// Permet de gérer le paiement sans redirection si possible
redirect: 'if_required'
});
if (error) {
$scope.$apply(function() {
$rootScope.showNotification(error.message, 'error');
$scope.processing = false;
});
} else {
// Paiement réussi (seulement si redirect: 'if_required' a été contourné)
// Note: Dans un environnement de production réel, l'étape de succès
// serait gérée par le endpoint return_url ou un webhook après une redirection.
$scope.$apply(function() {
const paymentProof = {
sessionToken: $scope.paymentIntentId,
paymentDate: new Date().toISOString(),
amount: 50
};
localStorage.setItem('edec_payment_proof', JSON.stringify(paymentProof));
$rootScope.showNotification($rootScope.t('payment_success'), 'success');
$timeout(function() {
$location.path('/' + $scope.destinationType);
}, 1500);
});
}
};
$scope.goBack = function() {
sessionStorage.removeItem('edec_intended_destination');
$location.path('/');
};
});
----------------------------------------------------
public/js/controllers/exportController.js
Dernière modification: 2025-10-06
angular.module('edecApp', ['ngRoute'])
.config(['routeProvider′,′routeProvider', '
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;
}]
}
})
.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 = {};
const promise = $http.get('/translations/strings.json').then(function(response) {
translations = response.data;
return translations;
});
return {
getPromise: promise,
get: function(key, lang) {
if (translations[lang] && translations[lang][key]) {
return translations[lang][key];
}
return key;
}
};
}])
.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(() => {
$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 / js / controllers / paymentController.js
angular.module('edecApp')
.controller('PaymentController', function($scope, $http, $location, $window, $rootScope, $timeout) {
$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';
// Vérifier si paiement déjà effectué
const existingPayment = localStorage.getItem('edec_payment_proof');
if (existingPayment) {
try {
const proof = JSON.parse(existingPayment);
$scope.verifyExistingPayment(proof.sessionToken);
} catch (e) {
localStorage.removeItem('edec_payment_proof');
$scope.initializePayment();
}
} else {
$scope.initializePayment();
}
$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(function() {
$location.path('/' + $scope.destinationType);
}, 1500);
} else {
localStorage.removeItem('edec_payment_proof');
$scope.initializePayment();
}
})
.catch(function() {
localStorage.removeItem('edec_payment_proof');
$scope.initializePayment();
});
};
$scope.initializePayment = function() {
// Charger Stripe.js
const script = document.createElement('script');
script.src = 'https://js.stripe.com/v3/';
script.onload = function() {
$scope.$apply(function() {
$scope.stripe = Stripe('pk_test_51SF3ZMDLAThCxOAyPFDmPowjqUJyyay34PQZu8SfEHDZaVNFSNCCPjE2dAJYpvHHtqQcR0I23gEstk9V24B83PAw00g4jIHw2r');
$scope.createPaymentIntent();
});
};
document.head.appendChild(script);
};
$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'
};
const locale = $rootScope.lang || 'fr';
$scope.elements = $scope.stripe.elements({
clientSecret: $scope.clientSecret,
appearance: appearance,
locale: locale
});
$scope.paymentElement = $scope.elements.create('payment', {
layout: 'tabs'
});
$scope.paymentElement.mount('#payment-element');
$scope.loading = false;
})
.catch(function(error) {
$rootScope.showNotification($rootScope.t('payment_init_error'), 'error');
console.error('Erreur création payment intent:', error);
$scope.loading = false;
});
};
$scope.handleSubmit = async function() {
if ($scope.processing) return;
$scope.processing = true;
$scope.$apply();
const {
error
} = await $scope.stripe.confirmPayment({
elements: $scope.elements,
confirmParams: {
return_url: $window.location.origin + '/payment-success'
},
redirect: 'if_required'
});
if (error) {
$scope.$apply(function() {
$rootScope.showNotification(error.message, 'error');
$scope.processing = false;
});
} else {
// Paiement réussi
$scope.$apply(function() {
const paymentProof = {
sessionToken: $scope.paymentIntentId,
paymentDate: new Date().toISOString(),
amount: 50
};
localStorage.setItem('edec_payment_proof', JSON.stringify(paymentProof));
$rootScope.showNotification($rootScope.t('payment_success'), 'success');
$timeout(function() {
$location.path('/' + $scope.destinationType);
}, 1500);
});
}
};
$scope.goBack = function() {
sessionStorage.removeItem('edec_intended_destination');
$location.path('/');
};
});
----------------------------------------------------
public/js/controllers/homeController.js
Dernière modification: 2025-10-06
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
Dernière modification: 2025-10-06
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
Dernière modification:
----------------------------------------------------
public/js/controllers/submissionErrorController.js
Dernière modification: 2025-10-06
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/translations/script_convertion.sh
Dernière modification:
#!/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
Dernière modification: 2025-10-06
{
"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/views/export.html
Dernière modification: 2025-10-06
Exporter un Véhicule depuis la Suisse
Cette page vous permet de générer une déclaration d'exportation pour un véhicule sortant du territoire suisse.
----------------------------------------------------
public/views/home.html
Dernière modification: 2025-10-06
----------------------------------------------------
public/views/submission-error.html
Dernière modification: 2025-10-06
⚠️
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.
Merci !
Nous avons bien reçu votre adresse e-mail. Notre équipe est sur le coup et reviendra vers vous très prochainement.
----------------------------------------------------
server/controllers/exportController.js
Dernière modification: 2025-10-06
// 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);
}
};
----------------------------------------------------
server/controllers/homeController.js
Dernière modification: 2025-10-06
// 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
Dernière modification: 2025-10-06
// 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/routes/api.js
Dernière modification: 2025-10-06
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/server.js
Dernière modification: 2025-10-06
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const dotenv = require('dotenv');
const path = require('path');
const fs = require('fs');
dotenv.config();
const app = express();
const PORT = process.env.PORT || 3000;
// ================================================================
// MIDDLEWARES
// ================================================================
app.set('trust proxy', true);
app.use(helmet({ contentSecurityPolicy: false }));
app.use(cors({ origin: true, credentials: true }));
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
// Service des fichiers statiques (pour Angular)
app.use(express.static(path.join(__dirname, '../public')));
// Logger
app.use((req, res, next) => {
console.log(`[${new Date().toISOString()}] ${req.method} ${req.path}`);
next();
});
// ================================================================
// CHARGER LES CONFIGS
// ================================================================
let appConfigs = {};
try {
appConfigs.countries = JSON.parse(fs.readFileSync(path.join(__dirname, '../config/countries.json'), 'utf8'));
appConfigs.currencies = JSON.parse(fs.readFileSync(path.join(__dirname, '../config/currencies.json'), 'utf8'));
appConfigs.taresRules = JSON.parse(fs.readFileSync(path.join(__dirname, '../config/tares_codes.json'), 'utf8'));
// [FLAG] Vérification de la configuration critique
const openRouterApiKey = process.env.OPENROUTER_API_KEY;
if (!openRouterApiKey || openRouterApiKey.length < 10) {
console.warn(`[FLAG-INIT-WARN] OPENROUTER_API_KEY semble manquant ou trop court.`);
} else {
console.log(`[FLAG-INIT-OK] OPENROUTER_API_KEY chargée (${openRouterApiKey.substring(0, 10)}...).`);
}
// On attache les configs à l'objet 'app' pour qu'il soit accessible dans les routes
app.appConfigs = appConfigs;
console.log('✓ Fichiers de configuration chargés');
} catch (error) {
console.error('⚠ Erreur chargement config:', error.message);
}
// ================================================================
// ROUTES DE L'API
// ================================================================
const apiRoutes = require('./routes/api');
app.use('/api', apiRoutes);
// ================================================================
// ROUTE FALLBACK POUR ANGULAR (Single Page Application)
// ================================================================
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, '../public/index.html'));
});
// ================================================================
// GESTION D'ERREURS
// ================================================================
app.use((err, req, res, next) => {
console.error('Erreur:', err.stack);
res.status(err.status || 500).json({
error: process.env.NODE_ENV === 'production'
? 'Erreur interne du serveur'
: err.message
});
});
app.use((req, res) => {
res.status(404).json({ error: 'Route API non trouvée: ' + req.path });
});
// ================================================================
// DÉMARRAGE
// ================================================================
app.listen(PORT, '127.0.0.1', () => {
console.log(`╔════════════════════════════════════════════════════════════╗`);
console.log(`║ Backend e-dec démarré sur http://127.0.0.1:${PORT} ║`);
console.log(`╚════════════════════════════════════════════════════════════╝`);
});
process.on('SIGTERM', () => {
console.log('SIGTERM reçu, arrêt gracieux...');
process.exit(0);
});
----------------------------------------------------
server/services/cleanupService.old
Dernière modification: 2025-10-06
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/declarationService.js
Dernière modification: 2025-10-06
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/edecGeneratorExport.js
Dernière modification: 2025-10-06
function escapeXml(value) {
if (value === null || value === undefined) return '';
return String(value)
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
class EdecGeneratorExport {
generateExport(data) {
const now = new Date().toISOString().replace('T', ' ').replace('Z', ' UTC');
const lang = (data.language || 'fr').toLowerCase();
// 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 = `
21${lang}${consigneeCountry}90${consignorName}
${consignorStreet ? `${consignorStreet}` : ''}
${consignorZip}${consignorCity}${consignorCountry}${consignorIde}${consigneeName}
${consigneeStreet ? `${consigneeStreet}` : ''}
${consigneeZip}${consigneeCity}${consigneeCountry}
${declarantIde ? `${declarantIde}` : ''}
${declarantName}
${declarantStreet ? `${declarantStreet}` : ''}
${declarantZip}${declarantCity}${declarantCountry}010${description}${commodityCode}${statisticalCode}
${grossMass > 0 ? `${grossMass}` : ''}
${grossMass > 0 ? `${grossMass}` : ''}
12211
${statisticalValue > 0 ? `${statisticalValue}` : ''}
0
${goodsItemDetailsXml}
VN1${description}`;
return xml;
}
}
module.exports = new EdecGeneratorExport();
----------------------------------------------------
server/services/edecGeneratorImport.js
Dernière modification: 2025-10-06
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 = `
11${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}000${invoiceCurrencyType}0${description}${commodityCode}${statisticalCode}${grossMass}${netMass}10211${statisticalValue}0${originCountry}${preference}
${goodsItemDetailsXml}
VN1${description}0${vatValue}${vatCode}660${additionalTaxKey}${additionalTaxQty}`;
return xml;
}
}
module.exports = new EdecGeneratorImport();
----------------------------------------------------
server/services/edecGenerator.js
Dernière modification: 2025-10-06
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
Dernière modification: 2025-10-06
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/exchangeRateCronService.js
Dernière modification: 2025-10-06
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/exchangeRateService.js
Dernière modification: 2025-10-06
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/ocrService.js
Dernière modification: 2025-10-06
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
});
}
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/taresClassifier.js
Dernière modification: 2025-10-06
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();