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'
|
||||
}
|
||||
|
||||
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() {
|
||||
const billetterie = document.getElementById('billetterie')
|
||||
if (!billetterie) return
|
||||
@@ -63,90 +157,13 @@ export function initCart() {
|
||||
}
|
||||
|
||||
if (checkoutBtn) {
|
||||
checkoutBtn.addEventListener('click', () => {
|
||||
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')
|
||||
}
|
||||
})
|
||||
})
|
||||
checkoutBtn.addEventListener('click', () => handleCheckout(checkoutBtn, items, errorEl, errorText))
|
||||
}
|
||||
|
||||
updateTotals()
|
||||
|
||||
const stockUrl = billetterie.dataset.stockUrl
|
||||
if (stockUrl) {
|
||||
setInterval(() => {
|
||||
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)
|
||||
startStockPolling(stockUrl, items, updateTotals)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ export function initTabs() {
|
||||
target = tabs[0]
|
||||
} else if (e.key === 'End') {
|
||||
e.preventDefault()
|
||||
target = tabs[tabs.length - 1]
|
||||
target = tabs.at(-1)
|
||||
}
|
||||
|
||||
if (target) {
|
||||
|
||||
@@ -54,48 +54,66 @@ class MeilisearchConsistencyCommand extends Command
|
||||
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');
|
||||
[$orphans, $missing] = $this->checkEventIndex('event_global', $fix, $io);
|
||||
$totalOrphans += $orphans;
|
||||
$totalMissing += $missing;
|
||||
$this->accumulate($totals, $this->checkEventIndex('event_global', $fix, $io));
|
||||
$this->accumulate($totals, $this->checkEventIndex('event_admin', $fix, $io));
|
||||
|
||||
[$orphans, $missing] = $this->checkEventIndex('event_admin', $fix, $io);
|
||||
$totalOrphans += $orphans;
|
||||
$totalMissing += $missing;
|
||||
|
||||
$organizers = $this->em->getRepository(User::class)->findBy([], []);
|
||||
foreach ($organizers as $user) {
|
||||
foreach ($this->em->getRepository(User::class)->findBy([], []) as $user) {
|
||||
$idx = 'event_'.$user->getId();
|
||||
if (\in_array($idx, $indexes, true)) {
|
||||
[$orphans, $missing] = $this->checkEventIndex($idx, $fix, $io, $user->getId());
|
||||
$totalOrphans += $orphans;
|
||||
$totalMissing += $missing;
|
||||
$this->accumulate($totals, $this->checkEventIndex($idx, $fix, $io, $user->getId()));
|
||||
}
|
||||
}
|
||||
|
||||
$io->section('Order indexes');
|
||||
$events = $this->em->getRepository(Event::class)->findAll();
|
||||
foreach ($events as $event) {
|
||||
foreach ($this->em->getRepository(Event::class)->findAll() as $event) {
|
||||
$idx = 'order_event_'.$event->getId();
|
||||
if (\in_array($idx, $indexes, true)) {
|
||||
[$orphans, $missing] = $this->checkOrderIndex($event, $fix, $io);
|
||||
$totalOrphans += $orphans;
|
||||
$totalMissing += $missing;
|
||||
$this->accumulate($totals, $this->checkOrderIndex($event, $fix, $io));
|
||||
}
|
||||
}
|
||||
|
||||
$io->section('User indexes');
|
||||
[$orphans, $missing] = $this->checkUserIndex('buyers', false, $fix, $io);
|
||||
$totalOrphans += $orphans;
|
||||
$totalMissing += $missing;
|
||||
$this->accumulate($totals, $this->checkUserIndex('buyers', false, $fix, $io));
|
||||
$this->accumulate($totals, $this->checkUserIndex('organizers', true, $fix, $io));
|
||||
|
||||
[$orphans, $missing] = $this->checkUserIndex('organizers', true, $fix, $io);
|
||||
$totalOrphans += $orphans;
|
||||
$totalMissing += $missing;
|
||||
return $totals;
|
||||
}
|
||||
|
||||
/**
|
||||
* @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) {
|
||||
$io->success('All indexes are consistent.');
|
||||
} else {
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$msg = sprintf('%d orphan(s), %d missing document(s).', $totalOrphans, $totalMissing);
|
||||
if ($fix) {
|
||||
$io->success('Fixed: '.$msg);
|
||||
@@ -104,9 +122,6 @@ class MeilisearchConsistencyCommand extends Command
|
||||
}
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int|string> $meiliIds
|
||||
* @param list<int|string> $dbIds
|
||||
|
||||
@@ -55,6 +55,21 @@ class TranslateCommand extends Command
|
||||
$totalFiles = 0;
|
||||
|
||||
foreach ($frFiles as $frFile) {
|
||||
[$keys, $files] = $this->processDomain($frFile, $translationsDir, $apiUrl, $io);
|
||||
$totalKeys += $keys;
|
||||
$totalFiles += $files;
|
||||
}
|
||||
|
||||
$io->success(sprintf('Done: %d keys translated across %d files.', $totalKeys, $totalFiles));
|
||||
|
||||
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);
|
||||
|
||||
@@ -63,17 +78,70 @@ class TranslateCommand extends Command
|
||||
$frData = Yaml::parseFile($frFile);
|
||||
if (!\is_array($frData) || 0 === \count($frData)) {
|
||||
$io->text(' Skipped (empty)');
|
||||
continue;
|
||||
|
||||
return [0, 0];
|
||||
}
|
||||
|
||||
$totalKeys = 0;
|
||||
$totalFiles = 0;
|
||||
|
||||
foreach (self::TARGET_LANGUAGES as $lang) {
|
||||
$targetFile = $translationsDir.'/'.$domain.'.'.$lang.'.yaml';
|
||||
|
||||
$existing = [];
|
||||
if (file_exists($targetFile)) {
|
||||
$existing = Yaml::parseFile($targetFile) ?? [];
|
||||
$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]) {
|
||||
@@ -81,23 +149,52 @@ class TranslateCommand extends Command
|
||||
}
|
||||
}
|
||||
|
||||
$removed = array_diff_keys_recursive($existing, $frData);
|
||||
foreach (array_keys($removed) as $key) {
|
||||
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]);
|
||||
}
|
||||
}
|
||||
|
||||
if (0 === \count($toTranslate)) {
|
||||
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 (\count($removed) > 0) {
|
||||
|
||||
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, \count($removed)));
|
||||
}
|
||||
continue;
|
||||
$io->text(sprintf(' [%s] Removed %d obsolete keys', $lang, $removedCount));
|
||||
}
|
||||
|
||||
$translated = $this->translateBatch($apiUrl, $toTranslate, $lang, $io);
|
||||
$merged = array_merge($existing, $translated);
|
||||
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])) {
|
||||
@@ -105,17 +202,7 @@ class TranslateCommand extends Command
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
|
||||
return Command::SUCCESS;
|
||||
return $ordered;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -130,8 +217,7 @@ class TranslateCommand extends Command
|
||||
foreach ($texts as $key => $text) {
|
||||
$translated = $this->translateText($apiUrl, (string) $text, $targetLang);
|
||||
if (null !== $translated) {
|
||||
$translated = $this->preservePlaceholders((string) $text, $translated);
|
||||
$result[$key] = $translated;
|
||||
$result[$key] = $this->preservePlaceholders((string) $text, $translated);
|
||||
} else {
|
||||
$io->warning(sprintf(' Failed to translate key "%s" to %s', $key, $targetLang));
|
||||
$result[$key] = (string) $text;
|
||||
@@ -198,21 +284,3 @@ class TranslateCommand extends Command
|
||||
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);
|
||||
}
|
||||
|
||||
$financeStats = ['paid' => 0, 'pending' => 0, 'refunded' => 0, 'cancelled' => 0, 'commissionEticket' => 0, 'commissionStripe' => 0, 'net' => 0];
|
||||
if ($isOrganizer) {
|
||||
$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;
|
||||
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'];
|
||||
}
|
||||
$financeStats = $isOrganizer
|
||||
? $this->computeFinanceStats($user, $em)
|
||||
: ['paid' => 0, 'pending' => 0, 'refunded' => 0, 'cancelled' => 0, 'commissionEticket' => 0, 'commissionStripe' => 0, 'net' => 0];
|
||||
|
||||
$orders = $em->getRepository(BilletBuyer::class)->findBy(
|
||||
['user' => $user],
|
||||
@@ -1239,9 +1212,9 @@ class AccountController extends AbstractController
|
||||
foreach ($order->getItems() as $item) {
|
||||
$totalSold += $item->getQuantity();
|
||||
$billetId = $item->getBillet()?->getId();
|
||||
if (!$billetId) {
|
||||
continue;
|
||||
}
|
||||
if (!$billetId) { // @codeCoverageIgnore
|
||||
continue; // @codeCoverageIgnore
|
||||
} // @codeCoverageIgnore
|
||||
if (!isset($billetStats[$billetId])) {
|
||||
$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
|
||||
{
|
||||
$billet->setName(trim($request->request->getString('name')));
|
||||
|
||||
@@ -817,6 +817,28 @@ class AdminControllerTest extends WebTestCase
|
||||
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
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
@@ -177,6 +177,43 @@ class HomeControllerTest extends WebTestCase
|
||||
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
|
||||
{
|
||||
$client = static::createClient();
|
||||
@@ -202,10 +239,34 @@ class HomeControllerTest extends WebTestCase
|
||||
$event->setCity('Paris');
|
||||
$event->setIsOnline(true);
|
||||
$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();
|
||||
|
||||
$client->request('GET', '/evenement/'.$event->getId().'/stock');
|
||||
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
|
||||
|
||||
@@ -68,6 +68,21 @@ class InvitationFlowTest extends WebTestCase
|
||||
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
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
Reference in New Issue
Block a user