From f8e064fb236f413afb855484ccaadd2b5c260fb5 Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Fri, 22 May 2026 13:22:07 +0200 Subject: [PATCH 01/10] feat: add matchfunding bool property --- migrations/Version20260522111940.php | 26 +++++++++++++++++++ .../Project/SupportApiResource.php | 7 +++++ src/Entity/Project/Support.php | 10 +++++++ 3 files changed, 43 insertions(+) create mode 100644 migrations/Version20260522111940.php diff --git a/migrations/Version20260522111940.php b/migrations/Version20260522111940.php new file mode 100644 index 0000000..8b1b112 --- /dev/null +++ b/migrations/Version20260522111940.php @@ -0,0 +1,26 @@ +addSql('ALTER TABLE project_support ADD matchfunding TINYINT(1) DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE project_support DROP matchfunding'); + } +} diff --git a/src/ApiResource/Project/SupportApiResource.php b/src/ApiResource/Project/SupportApiResource.php index 62c3f0e..43f627a 100644 --- a/src/ApiResource/Project/SupportApiResource.php +++ b/src/ApiResource/Project/SupportApiResource.php @@ -98,6 +98,13 @@ class SupportApiResource #[API\ApiFilter(BooleanFilter::class)] public bool $anonymous = true; + /** + * If this ProjectSupport comes from a MatchCall this flag will be true. + */ + #[API\ApiProperty(writable: false)] + #[API\ApiFilter(BooleanFilter::class)] + public bool $matchfunding; + /** * A message of support from the User to the Project. */ diff --git a/src/Entity/Project/Support.php b/src/Entity/Project/Support.php index 9b41fe5..5c6dcb8 100644 --- a/src/Entity/Project/Support.php +++ b/src/Entity/Project/Support.php @@ -5,6 +5,7 @@ namespace App\Entity\Project; use App\Entity\Accounting\Accounting; use App\Entity\Accounting\Transaction; use App\Entity\EmbeddableMoney; +use App\Entity\Matchfunding\MatchCall; use App\Mapping\Provider\EntityMapProvider; use App\Repository\Project\SupportRepository; use AutoMapper\Attribute\MapProvider; @@ -50,6 +51,9 @@ class Support #[ORM\Column(type: Types::TEXT, nullable: true)] private ?string $message = null; + #[ORM\Column(nullable: true)] + private ?bool $matchfunding = null; + public function __construct() { $this->transactions = new ArrayCollection(); @@ -68,6 +72,7 @@ class Support public function setOrigin(?Accounting $origin): static { $this->origin = $origin; + $this->matchfunding = $origin->getOwnerClass() === MatchCall::class; return $this; } @@ -143,4 +148,9 @@ class Support return $this; } + + public function isMatchfunding(): ?bool + { + return $this->matchfunding; + } } From 2a34fe435576a5a2b41e5af9c9bf632a2a07f200 Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Fri, 22 May 2026 13:28:31 +0200 Subject: [PATCH 02/10] feat: add displayName to ProjectSupport --- .../Project/SupportApiResource.php | 6 +++++ .../SupportDisplayNameMapTransformer.php | 26 +++++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 src/Mapping/Transformer/SupportDisplayNameMapTransformer.php diff --git a/src/ApiResource/Project/SupportApiResource.php b/src/ApiResource/Project/SupportApiResource.php index 43f627a..6531595 100644 --- a/src/ApiResource/Project/SupportApiResource.php +++ b/src/ApiResource/Project/SupportApiResource.php @@ -10,10 +10,12 @@ use App\ApiResource\Accounting\AccountingApiResource; use App\ApiResource\Accounting\TransactionApiResource; use App\ApiResource\MoneyOutput; use App\Entity\Project\Support; +use App\Mapping\Transformer\SupportDisplayNameMapTransformer; use App\Money\Totalization\TotalizedMoney; use App\State\ApiResourceStateProvider; use App\State\MoneyTotalStateProvider; use App\State\Project\SupportStateProcessor; +use AutoMapper\Attribute\MapFrom; use Symfony\Component\Validator\Constraints as Assert; /** @@ -76,6 +78,10 @@ class SupportApiResource #[API\ApiFilter(filterClass: SearchFilter::class, strategy: 'exact')] public ?AccountingApiResource $origin; + #[API\ApiProperty(writable: false)] + #[MapFrom(Support::class, transformer: SupportDisplayNameMapTransformer::class)] + public string $displayName; + /** * The Transactions that were issued to the Project by the origin. * diff --git a/src/Mapping/Transformer/SupportDisplayNameMapTransformer.php b/src/Mapping/Transformer/SupportDisplayNameMapTransformer.php new file mode 100644 index 0000000..a090f5f --- /dev/null +++ b/src/Mapping/Transformer/SupportDisplayNameMapTransformer.php @@ -0,0 +1,26 @@ +getOrigin()->getOwnerClass()) { + User::class => $this->userDisplayNameMapTransformer->transform($value, $source->getOrigin()->getUser(), $context), + MatchCall::class => $source->getOrigin()->getMatchCall()->getTitle(), + }; + } +} From 0436ad341410bc6845a242d521f0c8bde377da02 Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Fri, 22 May 2026 14:56:09 +0200 Subject: [PATCH 03/10] feat: update MatchfundingListener to update respective ProjectSupports --- src/EventListener/MatchfundingListener.php | 52 +++++++-------- .../RecomputingListenerTrait.php | 25 +++++++ .../Matchfunding/MatchfundingService.php | 3 +- src/Service/Project/SupportService.php | 66 +++++++++++++++++++ 4 files changed, 116 insertions(+), 30 deletions(-) create mode 100644 src/EventListener/RecomputingListenerTrait.php create mode 100644 src/Service/Project/SupportService.php diff --git a/src/EventListener/MatchfundingListener.php b/src/EventListener/MatchfundingListener.php index e5ef57a..b7bff18 100644 --- a/src/EventListener/MatchfundingListener.php +++ b/src/EventListener/MatchfundingListener.php @@ -3,10 +3,11 @@ namespace App\EventListener; use App\Entity\Gateway\Charge; +use App\Entity\Project\Project; use App\Gateway\ChargeStatus; use App\Service\Matchfunding\MatchfundingService; +use App\Service\Project\SupportService; use Doctrine\Bundle\DoctrineBundle\Attribute\AsEntityListener; -use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Event\PreUpdateEventArgs; use Doctrine\ORM\Events; @@ -15,52 +16,45 @@ use Doctrine\ORM\Events; method: 'preUpdate', entity: Charge::class )] -#[AsEntityListener( - event: Events::postUpdate, - method: 'postUpdate', - entity: Charge::class -)] final class MatchfundingListener { - private ?Charge $charge = null; + use RecomputingListenerTrait; public function __construct( private MatchfundingService $matchfunding, - private EntityManagerInterface $entityManager, + private SupportService $supportService, ) {} - public function preUpdate(Charge $charge, PreUpdateEventArgs $event) + public function preUpdate(Charge $charge, PreUpdateEventArgs $event): void { - $this->charge = null; - - if (count($event->getEntityChangeSet()) === 0) { + if (!$event->hasChangedField('status')) { return; } - if ($charge->getStatus() !== ChargeStatus::InCharge) { + if ($event->getNewValue('status') !== ChargeStatus::InCharge) { + return; + } + + $target = $charge->getTarget(); + if (!$target instanceof Project) { return; } $transactions = $this->matchfunding->match($charge); - - if (\count($transactions) > 0) { - foreach ($transactions as $transaction) { - $charge->addTransaction($transaction); - } - - $this->charge = $charge; - } - } - - public function postUpdate() - { - if ($this->charge === null) { + if ($transactions === []) { return; } - $this->entityManager->persist($this->charge); - $this->entityManager->flush(); + $em = $event->getObjectManager(); + foreach ($transactions as $transaction) { + $support = $this->supportService->getSupport($target, $charge->getCheckout()->getOrigin()); + $support = $this->supportService->withTransactions($support, [$transaction]); + $this->computeChangeSet($em, $support); - $this->charge = null; + $matchSupport = $this->supportService->getSupport($target, $transaction->getOrigin()); + $matchSupport = $this->supportService->withTransactions($support, [$transaction]); + $matchSupport->setAnonymous(false); + $this->computeChangeSet($em, $matchSupport); + } } } diff --git a/src/EventListener/RecomputingListenerTrait.php b/src/EventListener/RecomputingListenerTrait.php new file mode 100644 index 0000000..d58e0f1 --- /dev/null +++ b/src/EventListener/RecomputingListenerTrait.php @@ -0,0 +1,25 @@ +getUnitOfWork(); + + $meta = $em->getClassMetadata($entity::class); + + if ($entity->getId()) { + $uow->recomputeSingleEntityChangeSet($meta, $entity); + } else { + $em->persist($entity); + $uow->computeChangeSet($meta, $entity); + } + } +} diff --git a/src/Service/Matchfunding/MatchfundingService.php b/src/Service/Matchfunding/MatchfundingService.php index 5424906..917a718 100644 --- a/src/Service/Matchfunding/MatchfundingService.php +++ b/src/Service/Matchfunding/MatchfundingService.php @@ -3,6 +3,7 @@ namespace App\Service\Matchfunding; use App\Entity\Accounting\Transaction; +use App\Entity\EmbeddableMoney; use App\Entity\Gateway\Charge; use App\Entity\Matchfunding\MatchAgainst; use App\Entity\Matchfunding\MatchCallSubmissionStatus; @@ -61,7 +62,7 @@ class MatchfundingService ->match($strategy->getFactor(), $toBeMatched, $strategy->getLimit()); $transaction = new Transaction(); - $transaction->setMoney($matched); + $transaction->setMoney(EmbeddableMoney::of($matched)); $transaction->setOrigin($submission->getCall()->getAccounting()); $transaction->setTarget($submission->getProject()->getAccounting()); diff --git a/src/Service/Project/SupportService.php b/src/Service/Project/SupportService.php new file mode 100644 index 0000000..279af36 --- /dev/null +++ b/src/Service/Project/SupportService.php @@ -0,0 +1,66 @@ +supportRepository->findOneBy([ + 'project' => $project, + 'origin' => $origin, + ]); + + if ($support === null) { + $support = new Support(); + $support->setProject($project); + $support->setOrigin($origin); + } + + return $support; + } + + /** + * Add new Transactions and recalculate the Money for an existing Support. + * + * @param Transaction[] $transactions + */ + public function withTransactions(Support $support, array $transactions): Support + { + foreach ($transactions as $transaction) { + $support->addTransaction($transaction); + } + + $money = new Money(0, $support->getProject()->getAccounting()->getCurrency()); + foreach ($support->getTransactions() as $transaction) { + $money = $this->moneyService->add($transaction->getMoney(), $money); + } + + $support->setMoney(EmbeddableMoney::of($money)); + + return $support; + } +} From dc4df54990549f18121870a4bf9e78edf4d0401c Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Fri, 22 May 2026 15:09:52 +0200 Subject: [PATCH 04/10] fix: refactor GatewayCheckoutSupportsListener to leaner listener with common trait and services --- .../CheckoutSupportsListener.php | 80 +++++++++++ .../GatewayCheckoutSupportsListener.php | 125 ------------------ .../RecomputingListenerTrait.php | 9 +- 3 files changed, 82 insertions(+), 132 deletions(-) create mode 100644 src/EventListener/CheckoutSupportsListener.php delete mode 100644 src/EventListener/GatewayCheckoutSupportsListener.php diff --git a/src/EventListener/CheckoutSupportsListener.php b/src/EventListener/CheckoutSupportsListener.php new file mode 100644 index 0000000..6ae1fd2 --- /dev/null +++ b/src/EventListener/CheckoutSupportsListener.php @@ -0,0 +1,80 @@ +hasChangedField('status')) { + return; + } + + if ($event->getNewValue('status') !== CheckoutStatus::Charged) { + return; + } + + $supports = $this->prepareSupports($checkout); + + foreach ($supports as $support) { + $this->computeChangeSet($event->getObjectManager(), $support); + } + } + + /** + * @return Support[] + */ + public function prepareSupports(Checkout $checkout): array + { + /** @var Charge[] */ + $charges = $checkout->getCharges()->toArray(); + $origin = $checkout->getOrigin(); + + $projects = []; + $transactionsByProject = []; + foreach ($charges as $charge) { + $project = $charge->getTarget()->getProject(); + + if (!$project) { + continue; + } + + $projectId = $project->getId(); + + $projects[$projectId] = $project; + $transactionsByProject[$projectId] = [ + ...$transactionsByProject[$projectId] ?? [], + ...$charge->getTransactions(), + ]; + } + + $supports = []; + foreach ($transactionsByProject as $projectId => $transactions) { + $support = $this->supportService->getSupport($projects[$projectId], $origin); + $support = $this->supportService->withTransactions($support, $transactions); + $supports[] = $support; + } + + return $supports; + } +} diff --git a/src/EventListener/GatewayCheckoutSupportsListener.php b/src/EventListener/GatewayCheckoutSupportsListener.php deleted file mode 100644 index 860685c..0000000 --- a/src/EventListener/GatewayCheckoutSupportsListener.php +++ /dev/null @@ -1,125 +0,0 @@ -getObjectManager(); - $uow = $em->getUnitOfWork(); - - foreach ($uow->getScheduledEntityUpdates() as $entity) { - if (!$entity instanceof Checkout) { - continue; - } - - $changes = $uow->getEntityChangeSet($entity); - - if (!isset($changes['status']) || !$entity->isCharged()) { - continue; - } - - $supports = $this->prepareSupports($entity); - - foreach ($supports as $support) { - $em->persist($support); - $uow->computeChangeSet($em->getClassMetadata(Support::class), $support); - } - } - } - - /** - * @return Support[] - */ - public function prepareSupports(Checkout $checkout): array - { - /** @var Charge[] */ - $charges = $checkout->getCharges()->toArray(); - $origin = $checkout->getOrigin(); - - $projects = []; - $transactionsByProject = []; - foreach ($charges as $charge) { - $project = $charge->getTarget()->getProject(); - - if (!$project) { - continue; - } - - $projectId = $project->getId(); - - $projects[$projectId] = $project; - $transactionsByProject[$projectId] = [ - ...$transactionsByProject[$projectId] ?? [], - ...$charge->getTransactions(), - ]; - } - - $supports = []; - foreach ($transactionsByProject as $projectId => $transactions) { - $supports[] = $this->getSupport($projects[$projectId], $origin, $transactions); - } - - return $supports; - } - - /** - * Create ProjectSupport for the given data. - * - * @param Transaction[] $transactions - */ - private function getSupport(Project $project, Accounting $origin, array $transactions): Support - { - /** @var Support|null */ - $projectSupport = $this->supportRepository->findOneBy([ - 'project' => $project, - 'origin' => $origin, - ]); - - if (!$projectSupport) { - $projectSupport = new Support(); - } - - $projectSupport->setProject($project); - $projectSupport->setOrigin($origin); - $projectSupport->setAnonymous(false); - - $money = new Money(0, $project->getAccounting()->getCurrency()); - foreach ($transactions as $transaction) { - $money = $this->moneyService->add($transaction->getMoney(), $money); - - $projectSupport->addTransaction($transaction); - } - - $projectSupport->setMoney(EmbeddableMoney::of($money)); - - return $projectSupport; - } -} diff --git a/src/EventListener/RecomputingListenerTrait.php b/src/EventListener/RecomputingListenerTrait.php index d58e0f1..4a0490d 100644 --- a/src/EventListener/RecomputingListenerTrait.php +++ b/src/EventListener/RecomputingListenerTrait.php @@ -12,14 +12,9 @@ trait RecomputingListenerTrait private function computeChangeSet(EntityManagerInterface $em, object $entity): void { $uow = $em->getUnitOfWork(); - $meta = $em->getClassMetadata($entity::class); - if ($entity->getId()) { - $uow->recomputeSingleEntityChangeSet($meta, $entity); - } else { - $em->persist($entity); - $uow->computeChangeSet($meta, $entity); - } + $em->persist($entity); + $uow->computeChangeSet($meta, $entity); } } From bc6d39e4d1c77a5deb30b3b5049d97abd83d0c47 Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Fri, 22 May 2026 15:34:28 +0200 Subject: [PATCH 05/10] fix: reference to different Support instance --- src/EventListener/MatchfundingListener.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/EventListener/MatchfundingListener.php b/src/EventListener/MatchfundingListener.php index b7bff18..54d883e 100644 --- a/src/EventListener/MatchfundingListener.php +++ b/src/EventListener/MatchfundingListener.php @@ -52,7 +52,7 @@ final class MatchfundingListener $this->computeChangeSet($em, $support); $matchSupport = $this->supportService->getSupport($target, $transaction->getOrigin()); - $matchSupport = $this->supportService->withTransactions($support, [$transaction]); + $matchSupport = $this->supportService->withTransactions($matchSupport, [$transaction]); $matchSupport->setAnonymous(false); $this->computeChangeSet($em, $matchSupport); } From 7a8d9291560f118e0d9049893dc1cf1bc5a3af97 Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Fri, 22 May 2026 15:34:47 +0200 Subject: [PATCH 06/10] fix: improve error handling in SupportDisplayNameMapTransformer --- .../SupportDisplayNameMapTransformer.php | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/src/Mapping/Transformer/SupportDisplayNameMapTransformer.php b/src/Mapping/Transformer/SupportDisplayNameMapTransformer.php index a090f5f..49212ae 100644 --- a/src/Mapping/Transformer/SupportDisplayNameMapTransformer.php +++ b/src/Mapping/Transformer/SupportDisplayNameMapTransformer.php @@ -2,6 +2,7 @@ namespace App\Mapping\Transformer; +use App\Entity\Accounting\AccountingOwnerInterface; use App\Entity\Matchfunding\MatchCall; use App\Entity\Project\Support; use App\Entity\User\User; @@ -18,9 +19,28 @@ class SupportDisplayNameMapTransformer implements PropertyTransformerInterface */ public function transform(mixed $value, object|array $source, array $context): mixed { - return match ($source->getOrigin()->getOwnerClass()) { - User::class => $this->userDisplayNameMapTransformer->transform($value, $source->getOrigin()->getUser(), $context), - MatchCall::class => $source->getOrigin()->getMatchCall()->getTitle(), + $origin = $source->getOrigin(); + if (!$origin) { + throw new \Exception(\sprintf('The Support with ID %s does not have an origin', $source->getId())); + } + + return match ($origin->getOwnerClass()) { + User::class => $this->userDisplayNameMapTransformer->transform($value, $origin->getUser(), $context), + MatchCall::class => $origin->getMatchCall()->getTitle(), + default => $this->attemptDisplayName($origin->getOwner()), }; } + + private function attemptDisplayName(AccountingOwnerInterface $owner): string + { + if (\method_exists($owner, 'getTitle')) { + return $owner->getTitle(); + } + + if (\method_exists($owner, 'getName')) { + return $owner->getName(); + } + + throw new \Exception(\sprintf("AccountingOwnerInterface '%s' does not provide a displayable name", $owner::class)); + } } From ee0bcda8e133b90bfe1e440e7ffbd07689206bc9 Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Fri, 22 May 2026 16:37:05 +0200 Subject: [PATCH 07/10] feat: improve address cleaning for Territory searches --- src/Benzina/TerritoryPumpTrait.php | 94 +++++++++++++++--------------- 1 file changed, 48 insertions(+), 46 deletions(-) diff --git a/src/Benzina/TerritoryPumpTrait.php b/src/Benzina/TerritoryPumpTrait.php index d2a4174..172f7a2 100644 --- a/src/Benzina/TerritoryPumpTrait.php +++ b/src/Benzina/TerritoryPumpTrait.php @@ -12,72 +12,74 @@ trait TerritoryPumpTrait */ public static function cleanLocation(string $location, int $detailLevel = 3): string { - // Skip web addresses + $location = trim($location); + + // 1. Skip URLs if ( - \str_starts_with($location, 'www.') - || \str_starts_with($location, 'http://') - || \str_starts_with($location, 'https://') + str_starts_with($location, 'http://') || + str_starts_with($location, 'https://') || + str_starts_with($location, 'www.') ) { return ''; } - // Remove secondary conjoined places from locations - // e.g: "España y el mundo" -> "España" - foreach ([' / ', ' | ', ' - ', ' y ', ' i ', ' and ', ' & '] as $conjoinment) { - if (\str_contains($location, $conjoinment)) { - $location = \explode($conjoinment, $location)[0]; - } - } + // 2. Normalize separators ONLY when clearly semantic (not street hyphens) + $location = preg_replace('/\s+(\/|\||&|\by\b|\bi\b|and)\s+/iu', ',', $location); - // Normalize parenthesis - if (\str_contains($location, '(') || \str_contains($location, ')')) { - $location = \str_replace('(', ',', $location); - $location = \str_replace(')', '', $location); - } + // 3. Normalize parentheses -> commas + $location = str_replace(['(', ')'], [',', ''], $location); - // Remove colon specifications - // e.g: "Universidad Carlos III de Madrid: Campus de Getafe, Calle Madrid, Getafe, España" -> "Campus de Getafe, Calle Madrid, Getafe, España" - $location = \preg_replace('/^[\w ]+:/', '', $location); + // 4. Remove leading labels like "University X:" + $location = preg_replace('/^[^:]{1,80}:\s*/u', '', $location); - // Process comma-separated address pieces - $location = \explode(',', $location); + // 5. Tokenize + $parts = array_map( + fn($p) => trim($p), + explode(',', $location) + ); - // Clean non desired location pieces - $location = \array_filter($location, function ($l) { - if (empty($l)) { - return false; - } + // 6. Clean tokens + $parts = array_values(array_filter($parts, function ($p) { + if ($p === '') return false; - // Skip numeric only pieces: coordinates, street numbers, etc - if (\preg_match('/^[-\d.]*$/', $l) || \str_contains($l, 'º')) { + // Remove pure noise like empty numbers or coordinates + if (preg_match('/^\s*[-+]?\d+(\.\d+)?\s*$/', $p)) { return false; } return true; - }); + })); - // Normalize location pieces - $location = \array_map(function ($l) { - // Trim spaces and numbers at start of piece - $l = \preg_replace('/^[\d ]+/', '', \trim($l)); + // 7. Normalize tokens + $parts = array_map(function ($p) { + $p = trim($p); - // Normalize typos and name variations - foreach (self::COMMON_VARIATIONS as $standard => $variations) { - if (in_array(\mb_strtoupper($l), $variations)) { - $l = $standard; + // remove leading house numbers but keep street names + $p = preg_replace('/^\d+\s*/u', '', $p); + + $upper = mb_strtoupper($p, 'UTF-8'); + foreach (self::COMMON_VARIATIONS as $standard => $variants) { + if (in_array($upper, $variants, true)) { + return $standard; } } - return $l; - }, $location); + return $p; + }, $parts); - $location = \join(', ', \array_slice($location, -1 * $detailLevel)); + // 8. Keep only last N components (most specific context) + if (count($parts) > $detailLevel) { + $parts = array_slice($parts, -$detailLevel); + } - // Trim remaining numbers and punctuation marks - $location = \preg_replace('/^[\d\.\,\-;]+/', '', $location); - $location = \preg_replace('/[\d\.\,\-;]+$/', '', $location); + // 9. Join deterministically + $result = implode(', ', $parts); - return \mb_strtoupper(\trim($location)); + // 10. Final cleanup + $result = preg_replace('/\s+/u', ' ', $result); + $result = trim($result, " \t\n\r\0\x0B,.-;"); + + return mb_strtoupper($result, 'UTF-8'); } /** @@ -86,7 +88,7 @@ trait TerritoryPumpTrait private const COMMON_VARIATIONS = [ 'PERÚ' => ['PÉROU'], 'ECUADOR' => ['EQUADOR'], - 'MÉXICO' => ['MESSICO', 'MX'], + 'MÉXICO' => ['MEXICO', 'MESSICO', 'MX'], 'CIUDAD DE MÉXICO' => ['CDMX'], 'EUROPA' => ['EUROPEAN UNION', 'EUROPE'], 'FRANCIA' => ['FRANCE'], @@ -124,7 +126,7 @@ trait TerritoryPumpTrait 'VIZCAYA' => ['BIZKAIA'], 'IRÚN' => ['IRUN'], 'NAVARRA' => ['NAVARRE', 'NAFARROA'], - 'PAMLONA' => ['IRUÑEA'], + 'PAMPLONA' => ['IRUÑEA'], 'CANARIAS' => ['ISLAS CANARIAS', 'CANARY ISLANDS'], ]; } From a39312710fc63779af0c55bedbe9091cf584bbb5 Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Fri, 22 May 2026 16:42:03 +0200 Subject: [PATCH 08/10] fix: add throttle to NominatimService --- src/Service/Nominatim/NominatimService.php | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/Service/Nominatim/NominatimService.php b/src/Service/Nominatim/NominatimService.php index 5ec3db8..5ce989f 100644 --- a/src/Service/Nominatim/NominatimService.php +++ b/src/Service/Nominatim/NominatimService.php @@ -10,6 +10,8 @@ class NominatimService { public const NOMINATIM_BASE_URI = 'https://nominatim.openstreetmap.org'; + private float $lastRequestTime = 0.0; + /** * Cache calls to Nominatim for 1 hour. * This ensures freshness of the data but minimizes bulk-operations hit rates to Nominatim. @@ -46,6 +48,7 @@ class NominatimService function (CacheItemInterface $item) use ($endpoint, $options) { $item->expiresAfter(self::NOMINATIM_CACHE_TTL); + $this->throttle(); $response = $this->httpClient->request('GET', $endpoint, $options); return \json_decode($response->getContent(), true); @@ -76,4 +79,18 @@ class NominatimService ], ]); } + + private function throttle(): void + { + $minInterval = 1.0; + + $now = microtime(true); + $delta = $now - $this->lastRequestTime; + + if ($delta < $minInterval) { + usleep((int)(($minInterval - $delta) * 1000000)); + } + + $this->lastRequestTime = microtime(true); + } } From 3bcdef9e4f58fcb6aa5e701b6e3fd269e17cde01 Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Fri, 22 May 2026 17:01:28 +0200 Subject: [PATCH 09/10] fix: bugfixes --- src/Benzina/TerritoryPumpTrait.php | 10 ++++++---- src/Entity/User/User.php | 6 +++--- src/Service/Nominatim/NominatimService.php | 4 ++-- src/Service/UserService.php | 2 +- 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/Benzina/TerritoryPumpTrait.php b/src/Benzina/TerritoryPumpTrait.php index 172f7a2..3cfe9ca 100644 --- a/src/Benzina/TerritoryPumpTrait.php +++ b/src/Benzina/TerritoryPumpTrait.php @@ -16,9 +16,9 @@ trait TerritoryPumpTrait // 1. Skip URLs if ( - str_starts_with($location, 'http://') || - str_starts_with($location, 'https://') || - str_starts_with($location, 'www.') + str_starts_with($location, 'http://') + || str_starts_with($location, 'https://') + || str_starts_with($location, 'www.') ) { return ''; } @@ -40,7 +40,9 @@ trait TerritoryPumpTrait // 6. Clean tokens $parts = array_values(array_filter($parts, function ($p) { - if ($p === '') return false; + if ($p === '') { + return false; + } // Remove pure noise like empty numbers or coordinates if (preg_match('/^\s*[-+]?\d+(\.\d+)?\s*$/', $p)) { diff --git a/src/Entity/User/User.php b/src/Entity/User/User.php index d279ba5..bad0900 100644 --- a/src/Entity/User/User.php +++ b/src/Entity/User/User.php @@ -119,7 +119,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, Account private ?array $links = null; #[ORM\Embedded(class: Territory::class)] - private ?object $territory = null; + private ?Territory $territory = null; #[ORM\Column(type: Types::TEXT, nullable: true)] private ?string $description = null; @@ -382,12 +382,12 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, Account return $this; } - public function getTerritory(): ?object + public function getTerritory(): ?Territory { return $this->territory; } - public function setTerritory(?object $territory): static + public function setTerritory(?Territory $territory): static { $this->territory = $territory; diff --git a/src/Service/Nominatim/NominatimService.php b/src/Service/Nominatim/NominatimService.php index 5ce989f..7d4fa75 100644 --- a/src/Service/Nominatim/NominatimService.php +++ b/src/Service/Nominatim/NominatimService.php @@ -82,13 +82,13 @@ class NominatimService private function throttle(): void { - $minInterval = 1.0; + $minInterval = 1.1; $now = microtime(true); $delta = $now - $this->lastRequestTime; if ($delta < $minInterval) { - usleep((int)(($minInterval - $delta) * 1000000)); + usleep((int) (($minInterval - $delta) * 1000000)); } $this->lastRequestTime = microtime(true); diff --git a/src/Service/UserService.php b/src/Service/UserService.php index 6c2eadc..358911e 100644 --- a/src/Service/UserService.php +++ b/src/Service/UserService.php @@ -53,7 +53,7 @@ class UserService // Only lowercase a-z, numbers, underscore and middle dots in user handles $nval = \preg_replace('/[^a-z0-9_.]|^\.|\.$/', '_', \strtolower($nval)); - $nval = \str_pad($nval, $min, \sprintf('#%s', \hash('md5', $value))); + $nval = \str_pad($nval, $min, \sprintf('_%s', \hash('md5', $value))); $nval = \substr($nval, 0, $max); if (strlen(str_replace('_', '', $nval)) < 1) { From 9eb034f1a36c18947feca7f3bbe458f351fce63d Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Fri, 22 May 2026 18:11:59 +0200 Subject: [PATCH 10/10] fix: clanker suggestions broke the tests --- src/Benzina/TerritoryPumpTrait.php | 88 ++++++++++++++---------------- 1 file changed, 42 insertions(+), 46 deletions(-) diff --git a/src/Benzina/TerritoryPumpTrait.php b/src/Benzina/TerritoryPumpTrait.php index 3cfe9ca..f9924de 100644 --- a/src/Benzina/TerritoryPumpTrait.php +++ b/src/Benzina/TerritoryPumpTrait.php @@ -12,76 +12,72 @@ trait TerritoryPumpTrait */ public static function cleanLocation(string $location, int $detailLevel = 3): string { - $location = trim($location); - - // 1. Skip URLs + // Skip web addresses if ( - str_starts_with($location, 'http://') - || str_starts_with($location, 'https://') - || str_starts_with($location, 'www.') + \str_starts_with($location, 'www.') + || \str_starts_with($location, 'http://') + || \str_starts_with($location, 'https://') ) { return ''; } - // 2. Normalize separators ONLY when clearly semantic (not street hyphens) - $location = preg_replace('/\s+(\/|\||&|\by\b|\bi\b|and)\s+/iu', ',', $location); + // Remove secondary conjoined places from locations + // e.g: "España y el mundo" -> "España" + foreach ([' / ', ' | ', ' - ', ' y ', ' i ', ' and ', ' & '] as $conjoinment) { + if (\str_contains($location, $conjoinment)) { + $location = \explode($conjoinment, $location)[0]; + } + } - // 3. Normalize parentheses -> commas - $location = str_replace(['(', ')'], [',', ''], $location); + // Normalize parenthesis + if (\str_contains($location, '(') || \str_contains($location, ')')) { + $location = \str_replace('(', ',', $location); + $location = \str_replace(')', '', $location); + } - // 4. Remove leading labels like "University X:" - $location = preg_replace('/^[^:]{1,80}:\s*/u', '', $location); + // Remove colon specifications + // e.g: "Universidad Carlos III de Madrid: Campus de Getafe, Calle Madrid, Getafe, España" -> "Campus de Getafe, Calle Madrid, Getafe, España" + $location = \preg_replace('/^[\w ]+:/', '', $location); - // 5. Tokenize - $parts = array_map( - fn($p) => trim($p), - explode(',', $location) - ); + // Process comma-separated address pieces + $location = \explode(',', $location); - // 6. Clean tokens - $parts = array_values(array_filter($parts, function ($p) { - if ($p === '') { + // Clean non desired location pieces + $location = \array_filter($location, function ($l) { + if (empty($l)) { return false; } - // Remove pure noise like empty numbers or coordinates - if (preg_match('/^\s*[-+]?\d+(\.\d+)?\s*$/', $p)) { + // Skip numeric only pieces: coordinates, street numbers, etc + if (\preg_match('/^[-\d.]*$/', $l) || \str_contains($l, 'º')) { return false; } return true; - })); + }); - // 7. Normalize tokens - $parts = array_map(function ($p) { - $p = trim($p); + // Normalize location pieces + $location = \array_map(function ($l) { + // Trim spaces and numbers at start of piece + $l = \preg_replace('/^[\d ]+/', '', \trim($l)); - // remove leading house numbers but keep street names - $p = preg_replace('/^\d+\s*/u', '', $p); - - $upper = mb_strtoupper($p, 'UTF-8'); - foreach (self::COMMON_VARIATIONS as $standard => $variants) { - if (in_array($upper, $variants, true)) { - return $standard; + // Normalize typos and name variations + foreach (self::COMMON_VARIATIONS as $standard => $variations) { + if (in_array(\mb_strtoupper($l), $variations)) { + $l = $standard; } } - return $p; - }, $parts); + return $l; + }, $location); - // 8. Keep only last N components (most specific context) - if (count($parts) > $detailLevel) { - $parts = array_slice($parts, -$detailLevel); - } + $location = \join(', ', \array_slice($location, -1 * $detailLevel)); - // 9. Join deterministically - $result = implode(', ', $parts); + // Trim remaining numbers and punctuation marks + $location = \preg_replace('/^[\d\.\,\-;]+/', '', $location); + $location = \preg_replace('/[\d\.\,\-;]+$/', '', $location); - // 10. Final cleanup - $result = preg_replace('/\s+/u', ' ', $result); - $result = trim($result, " \t\n\r\0\x0B,.-;"); - - return mb_strtoupper($result, 'UTF-8'); + return \mb_strtoupper(\trim($location)); } /**