Reduce cognitive complexity, improve test coverage, fix SonarQube issues
Cognitive complexity refactors: - cart.js: extract buildCart, handleCheckout, updateStockLabel, updateItemStock, startStockPolling (21→~8) - tabs.js: use .at(-1) instead of [length-1] - MeilisearchConsistencyCommand: extract checkAllIndexes, accumulate, reportSummary (18→~8) - TranslateCommand: extract processDomain, processLanguage, loadExisting, findMissingKeys, removeObsoleteKeys, handleUpToDate, mergeAndOrder (36→~10) - AccountController::index: extract computeFinanceStats with statusMap pattern (19→~12) Test coverage additions: - HomeController: expired invitation view, stock not found, stock with billets, search+city with mock results - AdminController: delete/resend invitation not found (404) - AccountController: item without billet (codeCoverageIgnore - NOT NULL in DB) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,100 @@ function formatEur(value) {
|
|||||||
return value.toFixed(2).replace('.', ',') + ' \u20AC'
|
return value.toFixed(2).replace('.', ',') + ' \u20AC'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildCart(items) {
|
||||||
|
const cart = []
|
||||||
|
for (const item of items) {
|
||||||
|
const qty = Number.parseInt(item.querySelector('[data-cart-qty]').value, 10) || 0
|
||||||
|
if (qty > 0) {
|
||||||
|
cart.push({ billetId: item.dataset.billetId, qty })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cart
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCheckout(checkoutBtn, items, errorEl, errorText) {
|
||||||
|
const cart = buildCart(items)
|
||||||
|
if (cart.length === 0) return
|
||||||
|
|
||||||
|
const orderUrl = checkoutBtn.dataset.orderUrl
|
||||||
|
if (!orderUrl) return
|
||||||
|
|
||||||
|
checkoutBtn.disabled = true
|
||||||
|
checkoutBtn.textContent = 'Chargement...'
|
||||||
|
if (errorEl) errorEl.classList.add('hidden')
|
||||||
|
|
||||||
|
globalThis.fetch(orderUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(cart),
|
||||||
|
})
|
||||||
|
.then(r => {
|
||||||
|
if (!r.ok) throw new Error(r.status)
|
||||||
|
return r.json()
|
||||||
|
})
|
||||||
|
.then(data => {
|
||||||
|
if (data.redirect) {
|
||||||
|
globalThis.location.href = data.redirect
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
checkoutBtn.disabled = false
|
||||||
|
checkoutBtn.textContent = 'Commander'
|
||||||
|
if (errorEl && errorText) {
|
||||||
|
errorText.textContent = 'Une erreur est survenue. Veuillez reessayer.'
|
||||||
|
errorEl.classList.remove('hidden')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStockLabel(label, max) {
|
||||||
|
if (max === 0) {
|
||||||
|
label.innerHTML = '<span class="text-red-600">Rupture de stock</span>'
|
||||||
|
} else if (max <= 10) {
|
||||||
|
label.innerHTML = '<span class="text-orange-500">Plus que ' + max + ' place' + (max > 1 ? 's' : '') + ' !</span>'
|
||||||
|
} else {
|
||||||
|
label.innerHTML = '<span class="text-gray-400">' + max + ' places disponibles</span>'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateItemStock(item) {
|
||||||
|
const qtyInput = item.querySelector('[data-cart-qty]')
|
||||||
|
const max = Number.parseInt(item.dataset.max, 10) || 0
|
||||||
|
const current = Number.parseInt(qtyInput.value, 10) || 0
|
||||||
|
|
||||||
|
if (max > 0 && current > max) {
|
||||||
|
qtyInput.value = max
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = item.querySelector('[data-stock-label]')
|
||||||
|
if (label) {
|
||||||
|
updateStockLabel(label, max)
|
||||||
|
if (max === 0 && current > 0) {
|
||||||
|
qtyInput.value = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startStockPolling(stockUrl, items, updateTotals) {
|
||||||
|
setInterval(() => {
|
||||||
|
globalThis.fetch(stockUrl)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(stock => {
|
||||||
|
for (const item of items) {
|
||||||
|
const qty = stock[item.dataset.billetId]
|
||||||
|
if (qty === undefined || qty === null) continue
|
||||||
|
|
||||||
|
item.dataset.max = String(qty)
|
||||||
|
item.querySelector('[data-cart-qty]').max = qty
|
||||||
|
updateItemStock(item)
|
||||||
|
}
|
||||||
|
updateTotals()
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}, 30000)
|
||||||
|
}
|
||||||
|
|
||||||
export function initCart() {
|
export function initCart() {
|
||||||
const billetterie = document.getElementById('billetterie')
|
const billetterie = document.getElementById('billetterie')
|
||||||
if (!billetterie) return
|
if (!billetterie) return
|
||||||
@@ -63,90 +157,13 @@ export function initCart() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (checkoutBtn) {
|
if (checkoutBtn) {
|
||||||
checkoutBtn.addEventListener('click', () => {
|
checkoutBtn.addEventListener('click', () => handleCheckout(checkoutBtn, items, errorEl, errorText))
|
||||||
const cart = []
|
|
||||||
for (const item of items) {
|
|
||||||
const qty = Number.parseInt(item.querySelector('[data-cart-qty]').value, 10) || 0
|
|
||||||
if (qty > 0) {
|
|
||||||
cart.push({ billetId: item.dataset.billetId, qty })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cart.length === 0) return
|
|
||||||
|
|
||||||
const orderUrl = checkoutBtn.dataset.orderUrl
|
|
||||||
if (!orderUrl) return
|
|
||||||
|
|
||||||
checkoutBtn.disabled = true
|
|
||||||
checkoutBtn.textContent = 'Chargement...'
|
|
||||||
if (errorEl) errorEl.classList.add('hidden')
|
|
||||||
|
|
||||||
globalThis.fetch(orderUrl, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(cart),
|
|
||||||
})
|
|
||||||
.then(r => {
|
|
||||||
if (!r.ok) throw new Error(r.status)
|
|
||||||
return r.json()
|
|
||||||
})
|
|
||||||
.then(data => {
|
|
||||||
if (data.redirect) {
|
|
||||||
globalThis.location.href = data.redirect
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
checkoutBtn.disabled = false
|
|
||||||
checkoutBtn.textContent = 'Commander'
|
|
||||||
if (errorEl && errorText) {
|
|
||||||
errorText.textContent = 'Une erreur est survenue. Veuillez reessayer.'
|
|
||||||
errorEl.classList.remove('hidden')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updateTotals()
|
updateTotals()
|
||||||
|
|
||||||
const stockUrl = billetterie.dataset.stockUrl
|
const stockUrl = billetterie.dataset.stockUrl
|
||||||
if (stockUrl) {
|
if (stockUrl) {
|
||||||
setInterval(() => {
|
startStockPolling(stockUrl, items, updateTotals)
|
||||||
globalThis.fetch(stockUrl)
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(stock => {
|
|
||||||
for (const item of items) {
|
|
||||||
const billetId = item.dataset.billetId
|
|
||||||
const qty = stock[billetId]
|
|
||||||
if (qty === undefined || qty === null) continue
|
|
||||||
|
|
||||||
const max = qty
|
|
||||||
item.dataset.max = String(max)
|
|
||||||
|
|
||||||
const qtyInput = item.querySelector('[data-cart-qty]')
|
|
||||||
qtyInput.max = max
|
|
||||||
|
|
||||||
const current = Number.parseInt(qtyInput.value, 10) || 0
|
|
||||||
if (max > 0 && current > max) {
|
|
||||||
qtyInput.value = max
|
|
||||||
}
|
|
||||||
|
|
||||||
const label = item.querySelector('[data-stock-label]')
|
|
||||||
if (label) {
|
|
||||||
if (max === 0) {
|
|
||||||
label.innerHTML = '<span class="text-red-600">Rupture de stock</span>'
|
|
||||||
if (current > 0) {
|
|
||||||
qtyInput.value = 0
|
|
||||||
}
|
|
||||||
} else if (max <= 10) {
|
|
||||||
label.innerHTML = '<span class="text-orange-500">Plus que ' + max + ' place' + (max > 1 ? 's' : '') + ' !</span>'
|
|
||||||
} else {
|
|
||||||
label.innerHTML = '<span class="text-gray-400">' + max + ' places disponibles</span>'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
updateTotals()
|
|
||||||
})
|
|
||||||
.catch(() => {})
|
|
||||||
}, 30000)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export function initTabs() {
|
|||||||
target = tabs[0]
|
target = tabs[0]
|
||||||
} else if (e.key === 'End') {
|
} else if (e.key === 'End') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
target = tabs[tabs.length - 1]
|
target = tabs.at(-1)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (target) {
|
if (target) {
|
||||||
|
|||||||
@@ -54,57 +54,72 @@ class MeilisearchConsistencyCommand extends Command
|
|||||||
return Command::FAILURE;
|
return Command::FAILURE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[$totalOrphans, $totalMissing] = $this->checkAllIndexes($indexes, $fix, $io);
|
||||||
|
|
||||||
|
$this->reportSummary($io, $totalOrphans, $totalMissing, $fix);
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $indexes
|
||||||
|
*
|
||||||
|
* @return array{int, int}
|
||||||
|
*/
|
||||||
|
private function checkAllIndexes(array $indexes, bool $fix, SymfonyStyle $io): array
|
||||||
|
{
|
||||||
|
$totals = [0, 0];
|
||||||
|
|
||||||
$io->section('Event indexes');
|
$io->section('Event indexes');
|
||||||
[$orphans, $missing] = $this->checkEventIndex('event_global', $fix, $io);
|
$this->accumulate($totals, $this->checkEventIndex('event_global', $fix, $io));
|
||||||
$totalOrphans += $orphans;
|
$this->accumulate($totals, $this->checkEventIndex('event_admin', $fix, $io));
|
||||||
$totalMissing += $missing;
|
|
||||||
|
|
||||||
[$orphans, $missing] = $this->checkEventIndex('event_admin', $fix, $io);
|
foreach ($this->em->getRepository(User::class)->findBy([], []) as $user) {
|
||||||
$totalOrphans += $orphans;
|
|
||||||
$totalMissing += $missing;
|
|
||||||
|
|
||||||
$organizers = $this->em->getRepository(User::class)->findBy([], []);
|
|
||||||
foreach ($organizers as $user) {
|
|
||||||
$idx = 'event_'.$user->getId();
|
$idx = 'event_'.$user->getId();
|
||||||
if (\in_array($idx, $indexes, true)) {
|
if (\in_array($idx, $indexes, true)) {
|
||||||
[$orphans, $missing] = $this->checkEventIndex($idx, $fix, $io, $user->getId());
|
$this->accumulate($totals, $this->checkEventIndex($idx, $fix, $io, $user->getId()));
|
||||||
$totalOrphans += $orphans;
|
|
||||||
$totalMissing += $missing;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$io->section('Order indexes');
|
$io->section('Order indexes');
|
||||||
$events = $this->em->getRepository(Event::class)->findAll();
|
foreach ($this->em->getRepository(Event::class)->findAll() as $event) {
|
||||||
foreach ($events as $event) {
|
|
||||||
$idx = 'order_event_'.$event->getId();
|
$idx = 'order_event_'.$event->getId();
|
||||||
if (\in_array($idx, $indexes, true)) {
|
if (\in_array($idx, $indexes, true)) {
|
||||||
[$orphans, $missing] = $this->checkOrderIndex($event, $fix, $io);
|
$this->accumulate($totals, $this->checkOrderIndex($event, $fix, $io));
|
||||||
$totalOrphans += $orphans;
|
|
||||||
$totalMissing += $missing;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$io->section('User indexes');
|
$io->section('User indexes');
|
||||||
[$orphans, $missing] = $this->checkUserIndex('buyers', false, $fix, $io);
|
$this->accumulate($totals, $this->checkUserIndex('buyers', false, $fix, $io));
|
||||||
$totalOrphans += $orphans;
|
$this->accumulate($totals, $this->checkUserIndex('organizers', true, $fix, $io));
|
||||||
$totalMissing += $missing;
|
|
||||||
|
|
||||||
[$orphans, $missing] = $this->checkUserIndex('organizers', true, $fix, $io);
|
return $totals;
|
||||||
$totalOrphans += $orphans;
|
}
|
||||||
$totalMissing += $missing;
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{int, int} $totals
|
||||||
|
* @param array{int, int} $result
|
||||||
|
*/
|
||||||
|
private function accumulate(array &$totals, array $result): void
|
||||||
|
{
|
||||||
|
$totals[0] += $result[0];
|
||||||
|
$totals[1] += $result[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function reportSummary(SymfonyStyle $io, int $totalOrphans, int $totalMissing, bool $fix): void
|
||||||
|
{
|
||||||
if (0 === $totalOrphans && 0 === $totalMissing) {
|
if (0 === $totalOrphans && 0 === $totalMissing) {
|
||||||
$io->success('All indexes are consistent.');
|
$io->success('All indexes are consistent.');
|
||||||
} else {
|
|
||||||
$msg = sprintf('%d orphan(s), %d missing document(s).', $totalOrphans, $totalMissing);
|
return;
|
||||||
if ($fix) {
|
|
||||||
$io->success('Fixed: '.$msg);
|
|
||||||
} else {
|
|
||||||
$io->warning($msg.' Run with --fix to repair.');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Command::SUCCESS;
|
$msg = sprintf('%d orphan(s), %d missing document(s).', $totalOrphans, $totalMissing);
|
||||||
|
if ($fix) {
|
||||||
|
$io->success('Fixed: '.$msg);
|
||||||
|
} else {
|
||||||
|
$io->warning($msg.' Run with --fix to repair.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -55,62 +55,9 @@ class TranslateCommand extends Command
|
|||||||
$totalFiles = 0;
|
$totalFiles = 0;
|
||||||
|
|
||||||
foreach ($frFiles as $frFile) {
|
foreach ($frFiles as $frFile) {
|
||||||
$basename = basename($frFile);
|
[$keys, $files] = $this->processDomain($frFile, $translationsDir, $apiUrl, $io);
|
||||||
$domain = str_replace('.fr.yaml', '', $basename);
|
$totalKeys += $keys;
|
||||||
|
$totalFiles += $files;
|
||||||
$io->section('Translating: '.$basename);
|
|
||||||
|
|
||||||
$frData = Yaml::parseFile($frFile);
|
|
||||||
if (!\is_array($frData) || 0 === \count($frData)) {
|
|
||||||
$io->text(' Skipped (empty)');
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (self::TARGET_LANGUAGES as $lang) {
|
|
||||||
$targetFile = $translationsDir.'/'.$domain.'.'.$lang.'.yaml';
|
|
||||||
|
|
||||||
$existing = [];
|
|
||||||
if (file_exists($targetFile)) {
|
|
||||||
$existing = Yaml::parseFile($targetFile) ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$toTranslate = [];
|
|
||||||
foreach ($frData as $key => $value) {
|
|
||||||
if (!isset($existing[$key]) || '' === $existing[$key]) {
|
|
||||||
$toTranslate[$key] = $value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$removed = array_diff_keys_recursive($existing, $frData);
|
|
||||||
foreach (array_keys($removed) as $key) {
|
|
||||||
unset($existing[$key]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (0 === \count($toTranslate)) {
|
|
||||||
$io->text(sprintf(' [%s] Already up to date (%d keys)', $lang, \count($frData)));
|
|
||||||
if (\count($removed) > 0) {
|
|
||||||
file_put_contents($targetFile, Yaml::dump($existing, 4, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK));
|
|
||||||
$io->text(sprintf(' [%s] Removed %d obsolete keys', $lang, \count($removed)));
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$translated = $this->translateBatch($apiUrl, $toTranslate, $lang, $io);
|
|
||||||
$merged = array_merge($existing, $translated);
|
|
||||||
|
|
||||||
$ordered = [];
|
|
||||||
foreach ($frData as $key => $value) {
|
|
||||||
if (isset($merged[$key])) {
|
|
||||||
$ordered[$key] = $merged[$key];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
file_put_contents($targetFile, Yaml::dump($ordered, 4, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK));
|
|
||||||
|
|
||||||
$io->text(sprintf(' [%s] Translated %d new keys (%d total)', $lang, \count($toTranslate), \count($ordered)));
|
|
||||||
$totalKeys += \count($toTranslate);
|
|
||||||
++$totalFiles;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$io->success(sprintf('Done: %d keys translated across %d files.', $totalKeys, $totalFiles));
|
$io->success(sprintf('Done: %d keys translated across %d files.', $totalKeys, $totalFiles));
|
||||||
@@ -118,6 +65,146 @@ class TranslateCommand extends Command
|
|||||||
return Command::SUCCESS;
|
return Command::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{int, int}
|
||||||
|
*/
|
||||||
|
private function processDomain(string $frFile, string $translationsDir, string $apiUrl, SymfonyStyle $io): array
|
||||||
|
{
|
||||||
|
$basename = basename($frFile);
|
||||||
|
$domain = str_replace('.fr.yaml', '', $basename);
|
||||||
|
|
||||||
|
$io->section('Translating: '.$basename);
|
||||||
|
|
||||||
|
$frData = Yaml::parseFile($frFile);
|
||||||
|
if (!\is_array($frData) || 0 === \count($frData)) {
|
||||||
|
$io->text(' Skipped (empty)');
|
||||||
|
|
||||||
|
return [0, 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalKeys = 0;
|
||||||
|
$totalFiles = 0;
|
||||||
|
|
||||||
|
foreach (self::TARGET_LANGUAGES as $lang) {
|
||||||
|
$targetFile = $translationsDir.'/'.$domain.'.'.$lang.'.yaml';
|
||||||
|
$result = $this->processLanguage($frData, $targetFile, $lang, $apiUrl, $io);
|
||||||
|
$totalKeys += $result[0];
|
||||||
|
$totalFiles += $result[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$totalKeys, $totalFiles];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, string> $frData
|
||||||
|
*
|
||||||
|
* @return array{int, int}
|
||||||
|
*/
|
||||||
|
private function processLanguage(array $frData, string $targetFile, string $lang, string $apiUrl, SymfonyStyle $io): array
|
||||||
|
{
|
||||||
|
$existing = $this->loadExisting($targetFile);
|
||||||
|
$existingCount = \count($existing);
|
||||||
|
$toTranslate = $this->findMissingKeys($frData, $existing);
|
||||||
|
$existing = $this->removeObsoleteKeys($existing, $frData);
|
||||||
|
$removedCount = $existingCount - \count($existing);
|
||||||
|
|
||||||
|
if (0 === \count($toTranslate)) {
|
||||||
|
return $this->handleUpToDate($frData, $existing, $removedCount, $targetFile, $lang, $io);
|
||||||
|
}
|
||||||
|
|
||||||
|
$translated = $this->translateBatch($apiUrl, $toTranslate, $lang, $io);
|
||||||
|
$ordered = $this->mergeAndOrder($frData, array_merge($existing, $translated));
|
||||||
|
|
||||||
|
file_put_contents($targetFile, Yaml::dump($ordered, 4, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK));
|
||||||
|
|
||||||
|
$io->text(sprintf(' [%s] Translated %d new keys (%d total)', $lang, \count($toTranslate), \count($ordered)));
|
||||||
|
|
||||||
|
return [\count($toTranslate), 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function loadExisting(string $targetFile): array
|
||||||
|
{
|
||||||
|
if (!file_exists($targetFile)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Yaml::parseFile($targetFile) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, string> $frData
|
||||||
|
* @param array<string, string> $existing
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function findMissingKeys(array $frData, array $existing): array
|
||||||
|
{
|
||||||
|
$toTranslate = [];
|
||||||
|
foreach ($frData as $key => $value) {
|
||||||
|
if (!isset($existing[$key]) || '' === $existing[$key]) {
|
||||||
|
$toTranslate[$key] = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $toTranslate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, string> $existing
|
||||||
|
* @param array<string, string> $frData
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function removeObsoleteKeys(array $existing, array $frData): array
|
||||||
|
{
|
||||||
|
foreach (array_keys($existing) as $key) {
|
||||||
|
if (!\array_key_exists($key, $frData)) {
|
||||||
|
unset($existing[$key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, string> $frData
|
||||||
|
* @param array<string, string> $existing
|
||||||
|
*
|
||||||
|
* @return array{int, int}
|
||||||
|
*/
|
||||||
|
private function handleUpToDate(array $frData, array $existing, int $removedCount, string $targetFile, string $lang, SymfonyStyle $io): array
|
||||||
|
{
|
||||||
|
$io->text(sprintf(' [%s] Already up to date (%d keys)', $lang, \count($frData)));
|
||||||
|
|
||||||
|
if ($removedCount > 0) {
|
||||||
|
file_put_contents($targetFile, Yaml::dump($existing, 4, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK));
|
||||||
|
$io->text(sprintf(' [%s] Removed %d obsolete keys', $lang, $removedCount));
|
||||||
|
}
|
||||||
|
|
||||||
|
return [0, 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, string> $frData
|
||||||
|
* @param array<string, string> $merged
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function mergeAndOrder(array $frData, array $merged): array
|
||||||
|
{
|
||||||
|
$ordered = [];
|
||||||
|
foreach ($frData as $key => $value) {
|
||||||
|
if (isset($merged[$key])) {
|
||||||
|
$ordered[$key] = $merged[$key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $ordered;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, string> $texts
|
* @param array<string, string> $texts
|
||||||
*
|
*
|
||||||
@@ -130,8 +217,7 @@ class TranslateCommand extends Command
|
|||||||
foreach ($texts as $key => $text) {
|
foreach ($texts as $key => $text) {
|
||||||
$translated = $this->translateText($apiUrl, (string) $text, $targetLang);
|
$translated = $this->translateText($apiUrl, (string) $text, $targetLang);
|
||||||
if (null !== $translated) {
|
if (null !== $translated) {
|
||||||
$translated = $this->preservePlaceholders((string) $text, $translated);
|
$result[$key] = $this->preservePlaceholders((string) $text, $translated);
|
||||||
$result[$key] = $translated;
|
|
||||||
} else {
|
} else {
|
||||||
$io->warning(sprintf(' Failed to translate key "%s" to %s', $key, $targetLang));
|
$io->warning(sprintf(' Failed to translate key "%s" to %s', $key, $targetLang));
|
||||||
$result[$key] = (string) $text;
|
$result[$key] = (string) $text;
|
||||||
@@ -198,21 +284,3 @@ class TranslateCommand extends Command
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $array1
|
|
||||||
* @param array<string, mixed> $array2
|
|
||||||
*
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
function array_diff_keys_recursive(array $array1, array $array2): array
|
|
||||||
{
|
|
||||||
$diff = [];
|
|
||||||
foreach ($array1 as $key => $value) {
|
|
||||||
if (!\array_key_exists($key, $array2)) {
|
|
||||||
$diff[$key] = $value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $diff;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -73,36 +73,9 @@ class AccountController extends AbstractController
|
|||||||
$events = $paginator->paginate($eventsQuery, $request->query->getInt('page', 1), 10);
|
$events = $paginator->paginate($eventsQuery, $request->query->getInt('page', 1), 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
$financeStats = ['paid' => 0, 'pending' => 0, 'refunded' => 0, 'cancelled' => 0, 'commissionEticket' => 0, 'commissionStripe' => 0, 'net' => 0];
|
$financeStats = $isOrganizer
|
||||||
if ($isOrganizer) {
|
? $this->computeFinanceStats($user, $em)
|
||||||
$orgaOrders = $em->createQueryBuilder()
|
: ['paid' => 0, 'pending' => 0, 'refunded' => 0, 'cancelled' => 0, 'commissionEticket' => 0, 'commissionStripe' => 0, 'net' => 0];
|
||||||
->select('o')
|
|
||||||
->from(BilletBuyer::class, 'o')
|
|
||||||
->join('o.event', 'e')
|
|
||||||
->where('e.account = :user')
|
|
||||||
->setParameter('user', $user)
|
|
||||||
->getQuery()
|
|
||||||
->getResult();
|
|
||||||
|
|
||||||
$rate = $user->getCommissionRate() ?? 3;
|
|
||||||
foreach ($orgaOrders as $o) {
|
|
||||||
$ht = $o->getTotalHT() / 100;
|
|
||||||
if (BilletBuyer::STATUS_PAID === $o->getStatus()) {
|
|
||||||
$financeStats['paid'] += $ht;
|
|
||||||
$financeStats['commissionEticket'] += $ht * ($rate / 100);
|
|
||||||
$stripeFeeRate = (float) $this->getParameter('stripe_fee_rate');
|
|
||||||
$stripeFeeFixed = (int) $this->getParameter('stripe_fee_fixed');
|
|
||||||
$financeStats['commissionStripe'] += $ht * $stripeFeeRate + $stripeFeeFixed / 100;
|
|
||||||
} elseif (BilletBuyer::STATUS_PENDING === $o->getStatus()) {
|
|
||||||
$financeStats['pending'] += $ht;
|
|
||||||
} elseif (BilletBuyer::STATUS_REFUNDED === $o->getStatus()) {
|
|
||||||
$financeStats['refunded'] += $ht;
|
|
||||||
} elseif (BilletBuyer::STATUS_CANCELLED === $o->getStatus()) {
|
|
||||||
$financeStats['cancelled'] += $ht;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$financeStats['net'] = $financeStats['paid'] - $financeStats['commissionEticket'] - $financeStats['commissionStripe'];
|
|
||||||
}
|
|
||||||
|
|
||||||
$orders = $em->getRepository(BilletBuyer::class)->findBy(
|
$orders = $em->getRepository(BilletBuyer::class)->findBy(
|
||||||
['user' => $user],
|
['user' => $user],
|
||||||
@@ -1239,9 +1212,9 @@ class AccountController extends AbstractController
|
|||||||
foreach ($order->getItems() as $item) {
|
foreach ($order->getItems() as $item) {
|
||||||
$totalSold += $item->getQuantity();
|
$totalSold += $item->getQuantity();
|
||||||
$billetId = $item->getBillet()?->getId();
|
$billetId = $item->getBillet()?->getId();
|
||||||
if (!$billetId) {
|
if (!$billetId) { // @codeCoverageIgnore
|
||||||
continue;
|
continue; // @codeCoverageIgnore
|
||||||
}
|
} // @codeCoverageIgnore
|
||||||
if (!isset($billetStats[$billetId])) {
|
if (!isset($billetStats[$billetId])) {
|
||||||
$billetStats[$billetId] = ['name' => $item->getBilletName(), 'sold' => 0, 'revenue' => 0];
|
$billetStats[$billetId] = ['name' => $item->getBilletName(), 'sold' => 0, 'revenue' => 0];
|
||||||
}
|
}
|
||||||
@@ -1280,6 +1253,50 @@ class AccountController extends AbstractController
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{paid: float, pending: float, refunded: float, cancelled: float, commissionEticket: float, commissionStripe: float, net: float}
|
||||||
|
*/
|
||||||
|
private function computeFinanceStats(User $user, EntityManagerInterface $em): array
|
||||||
|
{
|
||||||
|
$stats = ['paid' => 0, 'pending' => 0, 'refunded' => 0, 'cancelled' => 0, 'commissionEticket' => 0, 'commissionStripe' => 0, 'net' => 0];
|
||||||
|
|
||||||
|
$orgaOrders = $em->createQueryBuilder()
|
||||||
|
->select('o')
|
||||||
|
->from(BilletBuyer::class, 'o')
|
||||||
|
->join('o.event', 'e')
|
||||||
|
->where('e.account = :user')
|
||||||
|
->setParameter('user', $user)
|
||||||
|
->getQuery()
|
||||||
|
->getResult();
|
||||||
|
|
||||||
|
$rate = $user->getCommissionRate() ?? 3;
|
||||||
|
$stripeFeeRate = (float) $this->getParameter('stripe_fee_rate');
|
||||||
|
$stripeFeeFixed = (int) $this->getParameter('stripe_fee_fixed');
|
||||||
|
|
||||||
|
$statusMap = [
|
||||||
|
BilletBuyer::STATUS_PENDING => 'pending',
|
||||||
|
BilletBuyer::STATUS_REFUNDED => 'refunded',
|
||||||
|
BilletBuyer::STATUS_CANCELLED => 'cancelled',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($orgaOrders as $o) {
|
||||||
|
$ht = $o->getTotalHT() / 100;
|
||||||
|
$status = $o->getStatus();
|
||||||
|
|
||||||
|
if (BilletBuyer::STATUS_PAID === $status) {
|
||||||
|
$stats['paid'] += $ht;
|
||||||
|
$stats['commissionEticket'] += $ht * ($rate / 100);
|
||||||
|
$stats['commissionStripe'] += $ht * $stripeFeeRate + $stripeFeeFixed / 100;
|
||||||
|
} elseif (isset($statusMap[$status])) {
|
||||||
|
$stats[$statusMap[$status]] += $ht;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$stats['net'] = $stats['paid'] - $stats['commissionEticket'] - $stats['commissionStripe'];
|
||||||
|
|
||||||
|
return $stats;
|
||||||
|
}
|
||||||
|
|
||||||
private function hydrateBilletFromRequest(Billet $billet, Request $request): void
|
private function hydrateBilletFromRequest(Billet $billet, Request $request): void
|
||||||
{
|
{
|
||||||
$billet->setName(trim($request->request->getString('name')));
|
$billet->setName(trim($request->request->getString('name')));
|
||||||
|
|||||||
@@ -817,6 +817,28 @@ class AdminControllerTest extends WebTestCase
|
|||||||
self::assertSame(\App\Entity\OrganizerInvitation::STATUS_SENT, $updated->getStatus());
|
self::assertSame(\App\Entity\OrganizerInvitation::STATUS_SENT, $updated->getStatus());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testDeleteInvitationNotFound(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
$admin = $this->createUser(['ROLE_ROOT']);
|
||||||
|
|
||||||
|
$client->loginUser($admin);
|
||||||
|
$client->request('POST', '/admin/organisateurs/invitation/999999/supprimer');
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testResendInvitationNotFound(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
$admin = $this->createUser(['ROLE_ROOT']);
|
||||||
|
|
||||||
|
$client->loginUser($admin);
|
||||||
|
$client->request('POST', '/admin/organisateurs/invitation/999999/renvoyer');
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(404);
|
||||||
|
}
|
||||||
|
|
||||||
public function testExportCsv(): void
|
public function testExportCsv(): void
|
||||||
{
|
{
|
||||||
$client = static::createClient();
|
$client = static::createClient();
|
||||||
|
|||||||
@@ -177,6 +177,43 @@ class HomeControllerTest extends WebTestCase
|
|||||||
self::assertResponseIsSuccessful();
|
self::assertResponseIsSuccessful();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testEventsPageWithSearchAndCityMatchingResults(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
$em = static::getContainer()->get(EntityManagerInterface::class);
|
||||||
|
|
||||||
|
$user = new User();
|
||||||
|
$user->setEmail('test-events-search-'.uniqid().'@example.com');
|
||||||
|
$user->setFirstName('Search');
|
||||||
|
$user->setLastName('Test');
|
||||||
|
$user->setPassword('hashed');
|
||||||
|
$user->setRoles(['ROLE_ORGANIZER']);
|
||||||
|
$user->setIsApproved(true);
|
||||||
|
$user->setIsVerified(true);
|
||||||
|
$em->persist($user);
|
||||||
|
|
||||||
|
$event = new \App\Entity\Event();
|
||||||
|
$event->setAccount($user);
|
||||||
|
$event->setTitle('Brocante Geante');
|
||||||
|
$event->setStartAt(new \DateTimeImmutable('2026-08-01 10:00'));
|
||||||
|
$event->setEndAt(new \DateTimeImmutable('2026-08-01 18:00'));
|
||||||
|
$event->setAddress('1 rue');
|
||||||
|
$event->setZipcode('75001');
|
||||||
|
$event->setCity('Paris');
|
||||||
|
$event->setIsOnline(true);
|
||||||
|
$em->persist($event);
|
||||||
|
$em->flush();
|
||||||
|
|
||||||
|
// Mock EventIndexService to return the event (simulates Meilisearch match)
|
||||||
|
$eventIndex = $this->createMock(\App\Service\EventIndexService::class);
|
||||||
|
$eventIndex->method('searchEvents')->willReturn([$event]);
|
||||||
|
static::getContainer()->set(\App\Service\EventIndexService::class, $eventIndex);
|
||||||
|
|
||||||
|
$client->request('GET', '/evenements?q=Brocante&city=Paris');
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
}
|
||||||
|
|
||||||
public function testEventStockRoute(): void
|
public function testEventStockRoute(): void
|
||||||
{
|
{
|
||||||
$client = static::createClient();
|
$client = static::createClient();
|
||||||
@@ -202,10 +239,34 @@ class HomeControllerTest extends WebTestCase
|
|||||||
$event->setCity('Paris');
|
$event->setCity('Paris');
|
||||||
$event->setIsOnline(true);
|
$event->setIsOnline(true);
|
||||||
$em->persist($event);
|
$em->persist($event);
|
||||||
|
|
||||||
|
$category = new \App\Entity\Category();
|
||||||
|
$category->setName('Cat Stock');
|
||||||
|
$category->setEvent($event);
|
||||||
|
$em->persist($category);
|
||||||
|
|
||||||
|
$billet = new \App\Entity\Billet();
|
||||||
|
$billet->setName('Entree');
|
||||||
|
$billet->setCategory($category);
|
||||||
|
$billet->setPriceHT(1000);
|
||||||
|
$billet->setQuantity(50);
|
||||||
|
$em->persist($billet);
|
||||||
$em->flush();
|
$em->flush();
|
||||||
|
|
||||||
$client->request('GET', '/evenement/'.$event->getId().'/stock');
|
$client->request('GET', '/evenement/'.$event->getId().'/stock');
|
||||||
self::assertResponseIsSuccessful();
|
self::assertResponseIsSuccessful();
|
||||||
|
|
||||||
|
$data = json_decode($client->getResponse()->getContent(), true);
|
||||||
|
self::assertSame(50, $data[$billet->getId()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEventStockNotFound(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
$client->request('GET', '/evenement/999999/stock');
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
self::assertSame('[]', $client->getResponse()->getContent());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testEventDetailNotFoundReturns404(): void
|
public function testEventDetailNotFoundReturns404(): void
|
||||||
|
|||||||
@@ -68,6 +68,21 @@ class InvitationFlowTest extends WebTestCase
|
|||||||
self::assertSame(OrganizerInvitation::STATUS_OPENED, $invitation->getStatus());
|
self::assertSame(OrganizerInvitation::STATUS_OPENED, $invitation->getStatus());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testViewExpiredInvitation(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
$em = static::getContainer()->get(EntityManagerInterface::class);
|
||||||
|
|
||||||
|
$invitation = $this->createInvitation($em);
|
||||||
|
$ref = new \ReflectionProperty($invitation, 'createdAt');
|
||||||
|
$ref->setValue($invitation, new \DateTimeImmutable('-10 days'));
|
||||||
|
$em->flush();
|
||||||
|
|
||||||
|
$client->request('GET', '/invitation/'.$invitation->getToken());
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
self::assertStringContainsString('expiree', $client->getResponse()->getContent());
|
||||||
|
}
|
||||||
|
|
||||||
public function testViewInvitationInvalidTokenReturns404(): void
|
public function testViewInvitationInvalidTokenReturns404(): void
|
||||||
{
|
{
|
||||||
$client = static::createClient();
|
$client = static::createClient();
|
||||||
|
|||||||
Reference in New Issue
Block a user