diff --git a/.tasks/92/mark-old-installations-plan.md b/.tasks/92/mark-old-installations-plan.md
new file mode 100644
index 00000000..8b745f8b
--- /dev/null
+++ b/.tasks/92/mark-old-installations-plan.md
@@ -0,0 +1,174 @@
+## План по issue #92: фоновая очистка зависших установок — статус needReinstall
+
+### Summary
+
+Issue #92 — фоновая очистка зависших установок в статусе `new`, до которых не дошёл `ONAPPINSTALL`.
+Реализуется в два этапа: сначала добавление нового статуса `needReinstall` в SDK, затем использование его в этой библиотеке.
+
+### Scope
+
+**В scope:**
+- Новый статус `needReinstall` в SDK (`ApplicationInstallationStatus`)
+- Метод `markAsNeedReinstall()` в SDK entity и интерфейсе
+- Обновление guard в `applicationUninstalled()` для `needReinstall`
+- Новое доменное событие `ApplicationInstallationMarkedNeedReinstallEvent`
+- UseCase `MarkOldInstallations` (Workflow → Handler, поиск + перевод в `needReinstall`)
+- Repository метод `findStaleInstallations()` в существующем `ApplicationInstallationRepository`
+- Console-команда `bitrix24:installations:mark-old`
+- Unit + Functional тесты
+- Документация и CHANGELOG
+
+**Out of scope:**
+- Фильтр по `memberId` — ищем все зависшие установки независимо от портала
+- `dry-run` режим — убрали, не нужен
+- Проверка реального состояния портала через SDK (`--verify-portal`)
+- Автоматическое планирование (cron, worker) — задача потребителя
+- Изменение `Bitrix24AccountStatus` — статус `needReinstall` только у `ApplicationInstallation`
+- UseCase переустановки — достаточно существующего `Install`, который через `applicationUninstalled` удалит `needReinstall` установку
+
+### Target Contract
+
+1. **Машина состояний ApplicationInstallation после изменений:**
+```
+new → active (applicationInstalled)
+new → blocked (markAsBlocked)
+new → needReinstall (markAsNeedReinstall) ← НОВОЕ
+blocked → active (markAsActive)
+blocked → deleted (applicationUninstalled)
+active → blocked (markAsBlocked)
+active → deleted (applicationUninstalled)
+needReinstall → deleted (applicationUninstalled) ← НОВОЕ
+```
+
+2. **markOldInstallations flow (после рефакторинга по ревью):**
+```
+Console command (ttl=3600 по дефолту)
+ → валидация ttl inline (>= 0)
+ → repository.findStaleInstallations(status=new, olderThan=NOW()-ttl) // query side, CQRS read
+ → foreach stale:
+ → MarkAsNeedReinstall\Handler::handle(Command(installationId, comment)) // command side
+ → getById(installationId) // identity map: без доп. SQL, ре-проверка guard от race с ONAPPINSTALL
+ → installation.markAsNeedReinstall(comment)
+ → repository.save + flusher.flush → событие ApplicationInstallationMarkedNeedReinstallEvent
+ → catch LogicException → skip (race condition: статус изменился параллельно)
+ → рендер: помеченные ID + count + пропущенные ID
+```
+
+> Рефакторинг по итогам ревью: use case — единоразовая операция над одним агрегатом (без foreach),
+> батч-оркестрация вынесена в Console command. Workflow/Config/Result/Collector удалены.
+
+3. **Переустановка после markOldInstallations:**
+```
+Install\Command (повторная установка)
+ → Install\Handler::handle()
+ → findByBitrix24AccountMemberId находит installation в needReinstall
+ → deactivateCurrentInstallation()
+ → markAsBlocked пропускается (только для status=new)
+ → applicationUninstalled(null) // needReinstall → deleted (guard обновлён)
+ → удаляются ВСЕ аккаунты портала (не только master), см. Install/Handler.php:125-138
+ → создание новой пары
+```
+
+### Implementation Changes
+
+#### PR 1: SDK (bitrix24/b24phpsdk) — разбит на issues #576–#580
+
+**Файлы SDK:**
+
+| Файл | Изменение |
+|---|---|
+| `src/Application/Contracts/ApplicationInstallations/Entity/ApplicationInstallationStatus.php` | Добавить `case needReinstall = 'needReinstall'` |
+| `src/Application/Contracts/ApplicationInstallations/Entity/ApplicationInstallationInterface.php` | Добавить метод `markAsNeedReinstall(?string $comment): void` + обновить docblock `applicationUninstalled` |
+| `src/Application/Contracts/ApplicationInstallations/Events/ApplicationInstallationMarkedNeedReinstallEvent.php` | Новое событие (`applicationInstallationId: Uuid`, `timestamp: CarbonImmutable`, `comment: ?string`) |
+
+> Временно изменения применены прямо в `vendor/bitrix24/b24phpsdk/`, будут формализованы в SDK PR.
+
+#### PR 2: bitrix24-php-lib (этот репозиторий)
+
+**Домен — `src/ApplicationInstallations/Entity/ApplicationInstallation.php`:**
+
+| Изменение | Детали |
+|---|---|
+| `markAsNeedReinstall(?string $comment)` | Переход `new → needReinstall`, emit `ApplicationInstallationMarkedNeedReinstallEvent` |
+| Guard в `applicationUninstalled()` | Добавить `needReinstall` в допустимые статусы (прямой переход `needReinstall → deleted`, без `blocked`) |
+
+**Repository — `src/ApplicationInstallations/Infrastructure/Doctrine/ApplicationInstallationRepository.php`:**
+
+Метод `findStaleInstallations(ApplicationInstallationStatus $status, CarbonImmutable $olderThan): array`:
+- Без `memberId` фильтра
+- Без JOIN к `Bitrix24Account`
+- WHERE `status = :status AND createdAt < :olderThan`
+- ORDER BY `createdAt ASC`
+
+**UseCase — `src/ApplicationInstallations/UseCase/MarkAsNeedReinstall/`** (единоразовая операция над одним агрегатом):
+
+| Файл | Содержание |
+|---|---|
+| `Command.php` | `installationId: Uuid`, `comment: ?string` |
+| `Handler.php` | `handle(Command): void` — `getById` → `markAsNeedReinstall(comment)` → save → flush → лог. Комментарий у `getById` про identity map |
+
+**Удалено (после рефакторинга по ревью):** весь `UseCase/MarkOldInstallations/` — Workflow, Handler, Command, MarkOldInstallationsConfig, MarkOldInstallationsResult, MarkOldInstallationsCollector.
+
+**Console — `src/ApplicationInstallations/Console/MarkOldInstallationsCommand.php`** (батч-оркестратор):
+
+```php
+#[AsCommand(name: 'bitrix24:installations:mark-old')]
+class MarkOldInstallationsCommand extends Command
+{
+ public const DEFAULT_TTL = 3600;
+ // аргумент: ttl (опциональный, дефолт DEFAULT_TTL), валидация inline
+ // конструктор: ApplicationInstallationRepository + MarkAsNeedReinstall\Handler
+ // findStaleInstallations(new, NOW()-ttl) → foreach → handler->handle(new Command(id, comment))
+ // catch LogicException → skip (race condition с ONAPPINSTALL)
+ // рендер: помеченные ID + count, пропущенные ID
+}
+```
+
+**Документация — `src/ApplicationInstallations/Docs/application-installations.md`:**
+- Добавить секцию «Stale Installation Cleanup» с описанием flow
+- Sequence diagram для markOldInstallations
+- Sequence diagram для reinstall после needReinstall
+- Обновить state machine — новые переходы
+- Обновить секцию Follow-Up — отметить что issue #92 реализован
+
+**CHANGELOG.md:**
+- Новая запись: feature — mark old installations as needReinstall via background command
+
+### Test Cases
+
+#### Unit Tests — удалены после рефакторинга
+
+> ConfigTest удалён вместе с MarkOldInstallationsConfig (валидация TTL теперь inline в Console command, консоль не тестируем unit-тестами).
+> Entity-тесты (`markAsNeedReinstall` transitions) — идут в SDK, не в этот репо.
+
+#### Functional Tests — ✅ реализованы
+
+**MarkAsNeedReinstall/HandlerTest** (`tests/Functional/ApplicationInstallations/UseCase/MarkAsNeedReinstall/HandlerTest.php`):
+1. Pending installation в `new` → handle → статус `needReinstall`, comment сохранён, событие `ApplicationInstallationMarkedNeedReinstallEvent` диспатчнуто
+2. Active installation → `LogicException`
+3. Неизвестный installationId → `ApplicationInstallationNotFoundException`
+
+**ApplicationInstallationRepositoryTest** — добавлен `testFindStaleInstallationsReturnsOnlyOldEnoughNewOnes`:
+- Old `new`-installation (createdAt=NOW()-2h, TTL=3600) → найдена
+- Fresh `new`-installation → не найдена
+- Old `active`-installation (createdAt=NOW()-2h) → не найдена (статус не тот)
+- `backdateCreatedAt` через DQL UPDATE (createdAt readonly в конструкторе)
+
+**Install/HandlerTest** — добавлен тест `testReinstallOverNeedReinstallInstallationDeletesOldEntitiesAndCreatesNewPendingPair`:
+- Reinstall поверх `needReinstall` → старая `deleted`, новая пара создана
+- `markAsBlocked` пропущен (срабатывает только для `new`), сразу `applicationUninstalled(null)` → `deleted`
+- Событие `ApplicationInstallationBlockedEvent` НЕ диспатчнуто
+
+> Удалены после рефакторинга: MarkOldInstallations/ConfigTest, HandlerTest, WorkflowTest (unit + functional).
+
+### Assumptions
+
+- `needReinstall` добавляется только в `ApplicationInstallationStatus`, не в `Bitrix24AccountStatus`
+- Мастер-аккаунт при markOld не меняет статус — остаётся в `new`
+- `applicationUninstalled(null)` из `needReinstall → deleted` не требует блокировки
+- Метод `findStaleInstallations` временно живёт в локальном репозитории; в SDK-интерфейс пойдёт отдельным issue ([b24phpsdk#579](https://github.com/bitrix24/b24phpsdk/issues/579))
+- Scheduling (cron/worker) — ответственность потребителя библиотеки
+- Константа TTL по умолчанию = 3600 секунд, находится в Console command (`DEFAULT_TTL`)
+- При reinstall удаляются ВСЕ аккаунты портала (master + child), а не только master — см. `Install/Handler.php:125-138`
+- Use case обрабатывает ровно один агрегат; повторная загрузка через `getById` не даёт доп. SQL (Doctrine identity map) и защищает guard от race с ONAPPINSTALL
+- SDK issues: [#576](https://github.com/bitrix24/b24phpsdk/issues/576) (enum), [#577](https://github.com/bitrix24/b24phpsdk/issues/577) (interface method), [#578](https://github.com/bitrix24/b24phpsdk/issues/578) (event), [#579](https://github.com/bitrix24/b24phpsdk/issues/579) (repo interface), [#580](https://github.com/bitrix24/b24phpsdk/issues/580) (reference impl + tests)
diff --git a/src/ApplicationInstallations/Console/MarkOldInstallationsCommand.php b/src/ApplicationInstallations/Console/MarkOldInstallationsCommand.php
new file mode 100644
index 00000000..79379709
--- /dev/null
+++ b/src/ApplicationInstallations/Console/MarkOldInstallationsCommand.php
@@ -0,0 +1,139 @@
+addArgument(
+ 'ttl',
+ InputArgument::OPTIONAL,
+ sprintf('Time to live in seconds (default: %d)', self::DEFAULT_TTL),
+ (string) self::DEFAULT_TTL
+ )
+ ->setHelp(
+ <<<'HELP'
+The bitrix24:installations:mark-old command finds pending installations
+in status "new" older than the given TTL and marks them as "needReinstall".
+
+Mark installations older than 1 hour (default):
+ php bin/console bitrix24:installations:mark-old
+
+Mark installations older than 30 minutes:
+ php bin/console bitrix24:installations:mark-old 1800
+HELP
+ )
+ ;
+ }
+
+ #[\Override]
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $this->io = new SymfonyStyle($input, $output);
+
+ $ttl = (int) $input->getArgument('ttl');
+ if ($ttl < 0) {
+ $this->io->error('TTL in seconds must be a non-negative integer.');
+
+ return Command::FAILURE;
+ }
+
+ $olderThan = new CarbonImmutable();
+ $olderThan = $olderThan->subSeconds($ttl);
+
+ $staleInstallations = $this->applicationInstallationRepository->findStaleInstallations(
+ ApplicationInstallationStatus::new,
+ $olderThan
+ );
+
+ return $this->processStaleInstallations($staleInstallations, $ttl);
+ }
+
+ private function processStaleInstallations(array $staleInstallations, int $ttl): int
+ {
+ if ([] === $staleInstallations) {
+ $this->io->success('No stale installations found.');
+
+ return Command::SUCCESS;
+ }
+
+ $comment = sprintf('installation timed out without ONAPPINSTALL, TTL = %d seconds', $ttl);
+
+ $markedIds = [];
+ $failedIds = [];
+
+ foreach ($staleInstallations as $staleInstallation) {
+ $installationId = $staleInstallation->getId();
+
+ try {
+ $this->markAsNeedReinstallHandler->handle(
+ new MarkAsNeedReinstallCommand($installationId, $comment)
+ );
+
+ $markedIds[] = $installationId;
+ } catch (LogicException) {
+ // Installation changed status concurrently (e.g. ONAPPINSTALL arrived) — skip it.
+ $failedIds[] = $installationId;
+ }
+ }
+
+ return $this->renderResult($markedIds, $failedIds);
+ }
+
+ /**
+ * @param Uuid[] $markedIds
+ * @param Uuid[] $failedIds
+ */
+ private function renderResult(array $markedIds, array $failedIds): int
+ {
+ $this->io->success(sprintf('Marked %d installation(s) as needReinstall:', count($markedIds)));
+
+ foreach ($markedIds as $installationId) {
+ $this->io->text(sprintf(' - installation %s', $installationId->toRfc4122()));
+ }
+
+ if ([] !== $failedIds) {
+ $this->io->warning(sprintf('Skipped %d installation(s) changed status concurrently:', count($failedIds)));
+
+ foreach ($failedIds as $installationId) {
+ $this->io->text(sprintf(' - installation %s', $installationId->toRfc4122()));
+ }
+ }
+
+ return Command::SUCCESS;
+ }
+}
diff --git a/src/ApplicationInstallations/Entity/ApplicationInstallation.php b/src/ApplicationInstallations/Entity/ApplicationInstallation.php
index bda0eea2..6463c170 100644
--- a/src/ApplicationInstallations/Entity/ApplicationInstallation.php
+++ b/src/ApplicationInstallations/Entity/ApplicationInstallation.php
@@ -13,6 +13,7 @@
use Bitrix24\SDK\Application\Contracts\ApplicationInstallations\Events\ApplicationInstallationContactPersonLinkedEvent;
use Bitrix24\SDK\Application\Contracts\ApplicationInstallations\Events\ApplicationInstallationCreatedEvent;
use Bitrix24\SDK\Application\Contracts\ApplicationInstallations\Events\ApplicationInstallationFinishedEvent;
+use Bitrix24\SDK\Application\Contracts\ApplicationInstallations\Events\ApplicationInstallationMarkedNeedReinstallEvent;
use Bitrix24\SDK\Application\Contracts\ApplicationInstallations\Events\ApplicationInstallationUnblockedEvent;
use Bitrix24\SDK\Application\Contracts\ApplicationInstallations\Events\ApplicationInstallationUninstalledEvent;
use Bitrix24\SDK\Application\PortalLicenseFamily;
@@ -166,10 +167,11 @@ public function applicationUninstalled(?string $applicationToken = null): void
if (
ApplicationInstallationStatus::active !== $this->status
&& ApplicationInstallationStatus::blocked !== $this->status
+ && ApplicationInstallationStatus::needReinstall !== $this->status
) {
throw new LogicException(
sprintf(
- 'installation was interrupted because status must be in active or blocked, but your status is %s',
+ 'installation was interrupted because status must be in active, blocked or needReinstall, but your status is %s',
$this->status->value
)
);
@@ -241,6 +243,26 @@ public function markAsBlocked(?string $comment): void
);
}
+ public function markAsNeedReinstall(?string $comment): void
+ {
+ if (ApplicationInstallationStatus::new !== $this->status) {
+ throw new LogicException(sprintf(
+ 'you can mark application installation as need reinstall only in status new, but your status is «%s»',
+ $this->status->value
+ ));
+ }
+
+ $this->status = ApplicationInstallationStatus::needReinstall;
+ $this->comment = $comment;
+ $this->updatedAt = new CarbonImmutable();
+
+ $this->events[] = new ApplicationInstallationMarkedNeedReinstallEvent(
+ $this->id,
+ new CarbonImmutable(),
+ $this->comment
+ );
+ }
+
#[\Override]
public function getApplicationStatus(): ApplicationStatus
{
diff --git a/src/ApplicationInstallations/Infrastructure/Doctrine/ApplicationInstallationRepository.php b/src/ApplicationInstallations/Infrastructure/Doctrine/ApplicationInstallationRepository.php
index 28717da4..066ea233 100644
--- a/src/ApplicationInstallations/Infrastructure/Doctrine/ApplicationInstallationRepository.php
+++ b/src/ApplicationInstallations/Infrastructure/Doctrine/ApplicationInstallationRepository.php
@@ -11,6 +11,7 @@
use Bitrix24\SDK\Application\Contracts\ApplicationInstallations\Exceptions\ApplicationInstallationNotFoundException;
use Bitrix24\SDK\Application\Contracts\ApplicationInstallations\Repository\ApplicationInstallationRepositoryInterface;
use Bitrix24\SDK\Core\Exceptions\InvalidArgumentException;
+use Carbon\CarbonImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\Query\Expr\Join;
@@ -184,4 +185,26 @@ public function findByBitrix24AccountMemberId(string $memberId): ?ApplicationIns
->getOneOrNullResult()
;
}
+
+ public function findStaleInstallations(
+ ApplicationInstallationStatus $status,
+ CarbonImmutable $olderThan
+ ): array {
+ $queryBuilder = $this->getEntityManager()->getRepository(ApplicationInstallation::class)
+ ->createQueryBuilder('ai')
+ ;
+
+ $queryBuilder
+ ->where('ai.status = :status')
+ ->andWhere('ai.createdAt < :olderThan')
+ ->setParameter('status', $status)
+ ->setParameter('olderThan', $olderThan)
+ ;
+
+ return $queryBuilder
+ ->orderBy('ai.createdAt', 'ASC')
+ ->getQuery()
+ ->getResult()
+ ;
+ }
}
diff --git a/src/ApplicationInstallations/UseCase/MarkAsNeedReinstall/Command.php b/src/ApplicationInstallations/UseCase/MarkAsNeedReinstall/Command.php
new file mode 100644
index 00000000..5429631f
--- /dev/null
+++ b/src/ApplicationInstallations/UseCase/MarkAsNeedReinstall/Command.php
@@ -0,0 +1,15 @@
+logger->info('ApplicationInstallations.MarkAsNeedReinstall.start', [
+ 'installationId' => $command->installationId->toRfc4122(),
+ ]);
+
+ // getById does not produce an extra SQL query when the entity is already
+ // managed by Doctrine's identity map (e.g. loaded by findStaleInstallations
+ // in the same session). Re-loading here also re-checks the status guard
+ // against a race with a concurrently arriving ONAPPINSTALL.
+ $applicationInstallation = $this->applicationInstallationRepository->getById($command->installationId);
+ assert($applicationInstallation instanceof AggregateRootEventsEmitterInterface);
+
+ $applicationInstallation->markAsNeedReinstall($command->comment);
+
+ $this->applicationInstallationRepository->save($applicationInstallation);
+ $this->flusher->flush($applicationInstallation);
+
+ $this->logger->info('ApplicationInstallations.MarkAsNeedReinstall.finish', [
+ 'installationId' => $command->installationId->toRfc4122(),
+ 'createdAt' => $applicationInstallation->getCreatedAt()->toAtomString(),
+ ]);
+ }
+}
diff --git a/tests/Functional/ApplicationInstallations/Infrastructure/Doctrine/ApplicationInstallationRepositoryTest.php b/tests/Functional/ApplicationInstallations/Infrastructure/Doctrine/ApplicationInstallationRepositoryTest.php
index 925d0aca..5d0b2e2b 100644
--- a/tests/Functional/ApplicationInstallations/Infrastructure/Doctrine/ApplicationInstallationRepositoryTest.php
+++ b/tests/Functional/ApplicationInstallations/Infrastructure/Doctrine/ApplicationInstallationRepositoryTest.php
@@ -6,14 +6,19 @@
use Bitrix24\Lib\ApplicationInstallations\Entity\ApplicationInstallation;
use Bitrix24\Lib\ApplicationInstallations\Infrastructure\Doctrine\ApplicationInstallationRepository;
+use Bitrix24\Lib\Bitrix24Accounts\Entity\Bitrix24Account;
use Bitrix24\SDK\Application\ApplicationStatus;
use Bitrix24\SDK\Application\Contracts\ApplicationInstallations\Entity\ApplicationInstallationInterface;
use Bitrix24\SDK\Application\Contracts\ApplicationInstallations\Entity\ApplicationInstallationStatus;
use Bitrix24\SDK\Application\Contracts\ApplicationInstallations\Repository\ApplicationInstallationRepositoryInterface;
use Bitrix24\SDK\Application\PortalLicenseFamily;
+use Bitrix24\SDK\Core\Credentials\AuthToken;
+use Bitrix24\SDK\Core\Credentials\Scope;
use Bitrix24\SDK\Tests\Application\Contracts\ApplicationInstallations\Repository\ApplicationInstallationRepositoryInterfaceTest;
use Bitrix24\SDK\Tests\Application\Contracts\TestRepositoryFlusherInterface;
use Carbon\CarbonImmutable;
+use Doctrine\ORM\EntityManagerInterface;
+use PHPUnit\Framework\Attributes\Test;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\Uid\Uuid;
use PHPUnit\Framework\Attributes\CoversClass;
@@ -72,5 +77,83 @@ protected function createRepositoryFlusherImplementation(): TestRepositoryFlushe
return new FlusherDecorator(new Flusher($entityManager, $eventDispatcher));
}
+ #[Test]
+ public function testFindStaleInstallationsReturnsOnlyOldEnoughNewOnes(): void
+ {
+ $entityManager = EntityManagerFactory::get();
+ $repository = new ApplicationInstallationRepository($entityManager);
+
+ $oldInstallation = $this->persistInstallation($entityManager);
+ $this->backdateCreatedAt($entityManager, $oldInstallation->getId(), new CarbonImmutable('-2 hours'));
+
+ $freshInstallation = $this->persistInstallation($entityManager);
+ $activeInstallation = $this->persistInstallation($entityManager);
+ $activeInstallation->applicationInstalled(Uuid::v7()->toRfc4122());
+ $this->backdateCreatedAt($entityManager, $activeInstallation->getId(), new CarbonImmutable('-2 hours'));
+ $entityManager->flush();
+ $entityManager->clear();
+
+ $found = $repository->findStaleInstallations(
+ ApplicationInstallationStatus::new,
+ (new CarbonImmutable())->subSeconds(3600)
+ );
+
+ $foundIds = array_map(
+ static fn (ApplicationInstallationInterface $installation): string => $installation->getId()->toRfc4122(),
+ $found
+ );
+
+ self::assertContains($oldInstallation->getId()->toRfc4122(), $foundIds);
+ self::assertNotContains($freshInstallation->getId()->toRfc4122(), $foundIds);
+ self::assertNotContains($activeInstallation->getId()->toRfc4122(), $foundIds);
+ }
+
+ private function persistInstallation(EntityManagerInterface $entityManager): ApplicationInstallation
+ {
+ $bitrix24Account = new Bitrix24Account(
+ Uuid::v7(),
+ 1,
+ true,
+ Uuid::v4()->toRfc4122(),
+ 'example.bitrix24.test',
+ new AuthToken('access', 'refresh', 3600, time() + 3600),
+ 1,
+ new Scope(['crm']),
+ true
+ );
+
+ $applicationInstallation = new ApplicationInstallation(
+ Uuid::v7(),
+ $bitrix24Account->getId(),
+ new ApplicationStatus('F'),
+ PortalLicenseFamily::free,
+ 10,
+ null,
+ null,
+ null,
+ 'lead-1',
+ 'install'
+ );
+
+ $entityManager->persist($bitrix24Account);
+ $entityManager->persist($applicationInstallation);
+ $entityManager->flush();
+
+ return $applicationInstallation;
+ }
+
+ private function backdateCreatedAt(
+ EntityManagerInterface $entityManager,
+ Uuid $installationId,
+ CarbonImmutable $createdAt
+ ): void {
+ $entityManager->createQuery(
+ 'UPDATE ' . ApplicationInstallation::class . ' ai SET ai.createdAt = :createdAt WHERE ai.id = :id'
+ )
+ ->setParameter('createdAt', $createdAt)
+ ->setParameter('id', $installationId, 'uuid')
+ ->execute();
+ }
+
}
diff --git a/tests/Functional/ApplicationInstallations/UseCase/Install/HandlerTest.php b/tests/Functional/ApplicationInstallations/UseCase/Install/HandlerTest.php
index e3b6103b..e1eb3944 100644
--- a/tests/Functional/ApplicationInstallations/UseCase/Install/HandlerTest.php
+++ b/tests/Functional/ApplicationInstallations/UseCase/Install/HandlerTest.php
@@ -152,6 +152,36 @@ public function testReinstallOverPendingInstallationDeletesOldEntitiesAndCreates
self::assertNotContains(ApplicationInstallationFinishedEvent::class, $events);
}
+ #[Test]
+ public function testReinstallOverNeedReinstallInstallationDeletesOldEntitiesAndCreatesNewPendingPair(): void
+ {
+ $memberId = Uuid::v4()->toRfc4122();
+ $bitrix24Account = $this->createAccount($memberId);
+ $applicationInstallation = $this->createInstallation($bitrix24Account->getId());
+ $applicationInstallation->markAsNeedReinstall('installation timed out without ONAPPINSTALL');
+
+ $this->entityManager->persist($bitrix24Account);
+ $this->entityManager->persist($applicationInstallation);
+ $this->entityManager->flush();
+ $this->entityManager->clear();
+
+ $this->handler->handle($this->createCommand(null, $memberId));
+ $this->entityManager->clear();
+
+ /** @var ApplicationInstallation $deletedInstallation */
+ $deletedInstallation = $this->entityManager->find(ApplicationInstallation::class, $applicationInstallation->getId());
+ $currentInstallation = $this->installationRepository->findByBitrix24AccountMemberId($memberId);
+
+ self::assertNotNull($currentInstallation);
+ self::assertSame(ApplicationInstallationStatus::deleted, $deletedInstallation->getStatus());
+ self::assertSame(ApplicationInstallationStatus::new, $currentInstallation->getStatus());
+ self::assertNotSame($applicationInstallation->getId()->toRfc4122(), $currentInstallation->getId()->toRfc4122());
+
+ $events = $this->eventDispatcher->getOrphanedEvents();
+ self::assertContains(ApplicationInstallationUninstalledEvent::class, $events);
+ self::assertNotContains(ApplicationInstallationBlockedEvent::class, $events);
+ }
+
private function createCommand(?string $applicationToken = null, ?string $memberId = null): Command
{
return new Command(
diff --git a/tests/Functional/ApplicationInstallations/UseCase/MarkAsNeedReinstall/HandlerTest.php b/tests/Functional/ApplicationInstallations/UseCase/MarkAsNeedReinstall/HandlerTest.php
new file mode 100644
index 00000000..930c24eb
--- /dev/null
+++ b/tests/Functional/ApplicationInstallations/UseCase/MarkAsNeedReinstall/HandlerTest.php
@@ -0,0 +1,130 @@
+entityManager = EntityManagerFactory::get();
+ $this->eventDispatcher = new TraceableEventDispatcher(new EventDispatcher(), new Stopwatch());
+ $this->installationRepository = new ApplicationInstallationRepository($this->entityManager);
+
+ $this->handler = new Handler(
+ $this->installationRepository,
+ new Flusher($this->entityManager, $this->eventDispatcher),
+ new NullLogger()
+ );
+ }
+
+ #[Test]
+ public function testPendingInstallationIsMarkedAsNeedReinstall(): void
+ {
+ $installation = $this->persistInstallation();
+
+ $this->handler->handle(new Command($installation->getId(), 'installation timed out without ONAPPINSTALL'));
+ $this->entityManager->clear();
+
+ $updated = $this->installationRepository->getById($installation->getId());
+
+ self::assertSame(ApplicationInstallationStatus::needReinstall, $updated->getStatus());
+ self::assertSame('installation timed out without ONAPPINSTALL', $updated->getComment());
+
+ $events = $this->eventDispatcher->getOrphanedEvents();
+ self::assertContains(ApplicationInstallationMarkedNeedReinstallEvent::class, $events);
+ }
+
+ #[Test]
+ public function testActiveInstallationThrowsLogicException(): void
+ {
+ $installation = $this->persistInstallation();
+ $installation->applicationInstalled(Uuid::v7()->toRfc4122());
+ $this->entityManager->flush();
+
+ $this->expectException(LogicException::class);
+
+ $this->handler->handle(new Command($installation->getId(), 'installation timed out without ONAPPINSTALL'));
+ }
+
+ #[Test]
+ public function testUnknownInstallationIdThrowsNotFoundException(): void
+ {
+ $this->expectException(ApplicationInstallationNotFoundException::class);
+
+ $this->handler->handle(new Command(Uuid::v7(), 'installation timed out without ONAPPINSTALL'));
+ }
+
+ private function persistInstallation(): ApplicationInstallation
+ {
+ $bitrix24Account = new Bitrix24Account(
+ Uuid::v7(),
+ 1,
+ true,
+ Uuid::v4()->toRfc4122(),
+ 'example.bitrix24.test',
+ new AuthToken('access', 'refresh', 3600, time() + 3600),
+ 1,
+ new Scope(['crm']),
+ true
+ );
+
+ $installation = new ApplicationInstallation(
+ Uuid::v7(),
+ $bitrix24Account->getId(),
+ new ApplicationStatus('F'),
+ PortalLicenseFamily::free,
+ 10,
+ null,
+ null,
+ null,
+ 'lead-1',
+ 'install'
+ );
+
+ $this->entityManager->persist($bitrix24Account);
+ $this->entityManager->persist($installation);
+ $this->entityManager->flush();
+
+ return $installation;
+ }
+}