diff --git a/composer.json b/composer.json index ae59392..1c7e9b5 100644 --- a/composer.json +++ b/composer.json @@ -50,7 +50,10 @@ "doctrine/doctrine-bundle": "*", "doctrine/doctrine-migrations-bundle": "*", "knplabs/knp-paginator-bundle": "^6", + "league/csv": "^9.0", + "odolbeau/phone-number-bundle": "^3.0", "symfony/event-dispatcher": "^7", + "symfony/http-client": "^7", "symfony/serializer": "^7", "symfony/uid": "^7", "symfony/yaml": "^7", diff --git a/config/xml/Bitrix24.Lib.Bitrix24Partners.Entity.Bitrix24Partner.dcm.xml b/config/xml/Bitrix24.Lib.Bitrix24Partners.Entity.Bitrix24Partner.dcm.xml new file mode 100644 index 0000000..8bf39f3 --- /dev/null +++ b/config/xml/Bitrix24.Lib.Bitrix24Partners.Entity.Bitrix24Partner.dcm.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Bitrix24Partners/Console/ImportPartnersCsvCommand.php b/src/Bitrix24Partners/Console/ImportPartnersCsvCommand.php new file mode 100644 index 0000000..32302d3 --- /dev/null +++ b/src/Bitrix24Partners/Console/ImportPartnersCsvCommand.php @@ -0,0 +1,208 @@ +addArgument( + 'file', + InputArgument::REQUIRED, + 'Path to CSV file to import' + ) + ->addOption( + 'skip-errors', + 's', + InputOption::VALUE_NONE, + 'Skip rows with errors and continue processing' + ); + } + + #[\Override] + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $file = $input->getArgument('file'); + $skipErrors = $input->getOption('skip-errors'); + + if (!file_exists($file)) { + $io->error(sprintf('File not found: %s', $file)); + + return Command::FAILURE; + } + + $io->title('Importing Bitrix24 Partners from CSV'); + $io->info(sprintf('Reading file: %s', $file)); + + try { + $imported = $this->importFromCsv($file, $skipErrors, $io, $output); + + $io->success(sprintf('Successfully imported %d partners', $imported)); + + return Command::SUCCESS; + } catch (\Exception $e) { + $io->error(sprintf('Error: %s', $e->getMessage())); + + return Command::FAILURE; + } + } + + private function importFromCsv(string $file, bool $skipErrors, SymfonyStyle $io, OutputInterface $output): int + { + $csv = Reader::createFromPath($file, 'r'); + $csv->setHeaderOffset(0); + + $phoneUtil = PhoneNumberUtil::getInstance(); + $imported = 0; + $skipped = 0; + + // Validate header + $expectedHeaders = ['title', 'site', 'phone', 'email', 'bitrix24_partner_id', 'open_line_id', 'external_id']; + $actualHeaders = $csv->getHeader(); + if ($actualHeaders !== $expectedHeaders) { + $io->warning(sprintf( + 'CSV header mismatch. Expected: %s, Got: %s', + implode(', ', $expectedHeaders), + implode(', ', $actualHeaders) + )); + } + + // Get records + $records = Statement::create()->process($csv); + $totalRecords = iterator_count($records); + + if (0 === $totalRecords) { + $io->warning('No records found in CSV file'); + + return 0; + } + + // Reset iterator + $records = Statement::create()->process($csv); + + // Create progress bar + $progressBar = new ProgressBar($output, $totalRecords); + $progressBar->setFormat(' %current%/%max% [%bar%] %percent:3s%% %elapsed:6s%/%estimated:-6s% %memory:6s%'); + $progressBar->start(); + + $lineNumber = 1; // Header is line 1 + + foreach ($records as $record) { + $lineNumber++; + $progressBar->advance(); + + try { + // Skip empty rows + if (empty(array_filter($record))) { + continue; + } + + // Parse row data + $title = isset($record['title']) ? trim($record['title']) : ''; + $siteRaw = isset($record['site']) ? trim($record['site']) : ''; + $site = '' !== $siteRaw ? $siteRaw : null; + $phoneStringRaw = isset($record['phone']) ? trim($record['phone']) : ''; + $phoneString = '' !== $phoneStringRaw ? $phoneStringRaw : null; + $emailRaw = isset($record['email']) ? trim($record['email']) : ''; + $email = '' !== $emailRaw ? $emailRaw : null; + $bitrix24PartnerIdRaw = isset($record['bitrix24_partner_id']) ? trim($record['bitrix24_partner_id']) : ''; + $bitrix24PartnerId = '' !== $bitrix24PartnerIdRaw ? (int) $bitrix24PartnerIdRaw : null; + $openLineIdRaw = isset($record['open_line_id']) ? trim($record['open_line_id']) : ''; + $openLineId = '' !== $openLineIdRaw ? $openLineIdRaw : null; + $externalIdRaw = isset($record['external_id']) ? trim($record['external_id']) : ''; + $externalId = '' !== $externalIdRaw ? $externalIdRaw : null; + + // Validate required fields + if ('' === $title) { + throw new \InvalidArgumentException('Title is required'); + } + + if (null === $bitrix24PartnerId) { + throw new \InvalidArgumentException('Bitrix24 Partner ID is required'); + } + + // Parse phone number + $phone = null; + if (null !== $phoneString) { + try { + $phone = $phoneUtil->parse($phoneString, 'RU'); + } catch (NumberParseException $e) { + if (!$skipErrors) { + throw new \InvalidArgumentException( + sprintf('Invalid phone number: %s', $phoneString), + 0, + $e + ); + } + $phone = null; + } + } + + // Create partner + $command = new CreateCommand( + $title, + $bitrix24PartnerId, + $site, + $phone, + $email, + $openLineId, + $externalId + ); + + $this->createHandler->handle($command); + $imported++; + } catch (\Exception $e) { + if (!$skipErrors) { + $progressBar->finish(); + throw new \RuntimeException( + sprintf('Error on line %d: %s', $lineNumber, $e->getMessage()), + 0, + $e + ); + } + + $skipped++; + } + } + + $progressBar->finish(); + $io->newLine(2); + + if ($skipped > 0) { + $io->note(sprintf('Skipped %d rows due to errors', $skipped)); + } + + return $imported; + } +} diff --git a/src/Bitrix24Partners/Console/ScrapePartnersCommand.php b/src/Bitrix24Partners/Console/ScrapePartnersCommand.php new file mode 100644 index 0000000..27d2f6b --- /dev/null +++ b/src/Bitrix24Partners/Console/ScrapePartnersCommand.php @@ -0,0 +1,201 @@ +addOption( + 'output', + 'o', + InputOption::VALUE_OPTIONAL, + 'Output CSV file path', + 'partners.csv' + ) + ->addOption( + 'url', + 'u', + InputOption::VALUE_OPTIONAL, + 'URL to scrape partners from', + 'https://www.bitrix24.ru/partners/' + ); + } + + #[\Override] + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $url = $input->getOption('url'); + $outputFile = $input->getOption('output'); + + $io->title('Scraping Bitrix24 Partners'); + $io->info(sprintf('Fetching partners from: %s', $url)); + + try { + // Fetch HTML content + $html = $this->fetchUrl($url); + + // Parse partners from HTML + $partners = $this->parsePartners($html); + + if (empty($partners)) { + $io->warning('No partners found'); + + return Command::FAILURE; + } + + // Save to CSV + $this->saveToCsv($partners, $outputFile); + + $io->success(sprintf('Successfully scraped %d partners and saved to %s', count($partners), $outputFile)); + + return Command::SUCCESS; + } catch (\Exception $e) { + $io->error(sprintf('Error: %s', $e->getMessage())); + + return Command::FAILURE; + } + } + + private function fetchUrl(string $url): string + { + $response = $this->httpClient->request('GET', $url, [ + 'headers' => [ + 'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + ], + 'max_redirects' => 5, + 'verify_peer' => false, + 'verify_host' => false, + ]); + + $statusCode = $response->getStatusCode(); + + if (200 !== $statusCode) { + throw new \RuntimeException(sprintf('Failed to fetch URL: HTTP %d', $statusCode)); + } + + return $response->getContent(); + } + + /** + * @return array> + */ + private function parsePartners(string $html): array + { + $partners = []; + + // Create DOMDocument to parse HTML + $dom = new \DOMDocument(); + // Suppress warnings from malformed HTML + @$dom->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8')); + + $xpath = new \DOMXPath($dom); + + // Try to find partner cards/blocks + // This is a generic pattern - may need adjustment based on actual HTML structure + $partnerNodes = $xpath->query("//*[contains(@class, 'partner')]"); + + if (0 === $partnerNodes->length) { + // Try alternative selectors + $partnerNodes = $xpath->query("//article|//div[contains(@class, 'card')]"); + } + + foreach ($partnerNodes as $node) { + $partner = [ + 'title' => '', + 'site' => '', + 'phone' => '', + 'email' => '', + ]; + + // Extract title + $titleNode = $xpath->query(".//*[contains(@class, 'title')]|.//h1|.//h2|.//h3", $node); + if ($titleNode->length > 0) { + $partner['title'] = trim($titleNode->item(0)->textContent); + } + + // Extract website + $linkNode = $xpath->query(".//a[contains(@href, 'http')]", $node); + if ($linkNode->length > 0) { + $href = $linkNode->item(0)->getAttribute('href'); + if (!empty($href) && str_contains($href, 'http')) { + $partner['site'] = $href; + } + } + + // Extract email + $emailNode = $xpath->query(".//a[contains(@href, 'mailto:')]", $node); + if ($emailNode->length > 0) { + $email = str_replace('mailto:', '', $emailNode->item(0)->getAttribute('href')); + $partner['email'] = $email; + } + + // Extract phone + $phoneNode = $xpath->query(".//a[contains(@href, 'tel:')]", $node); + if ($phoneNode->length > 0) { + $phone = str_replace('tel:', '', $phoneNode->item(0)->getAttribute('href')); + $partner['phone'] = $phone; + } + + // Only add if we have at least a title + if (!empty($partner['title'])) { + $partners[] = $partner; + } + } + + return $partners; + } + + /** + * @param array> $partners + */ + private function saveToCsv(array $partners, string $filename): void + { + $fp = fopen($filename, 'w'); + if (false === $fp) { + throw new \RuntimeException(sprintf('Cannot open file: %s', $filename)); + } + + // Write header + fputcsv($fp, ['title', 'site', 'phone', 'email', 'bitrix24_partner_id', 'open_line_id', 'external_id']); + + // Write data + foreach ($partners as $partner) { + fputcsv($fp, [ + $partner['title'] ?? '', + $partner['site'] ?? '', + $partner['phone'] ?? '', + $partner['email'] ?? '', + '', // bitrix24_partner_id + '', // open_line_id + '', // external_id + ]); + } + + fclose($fp); + } +} diff --git a/src/Bitrix24Partners/Entity/Bitrix24Partner.php b/src/Bitrix24Partners/Entity/Bitrix24Partner.php new file mode 100644 index 0000000..a90eb14 --- /dev/null +++ b/src/Bitrix24Partners/Entity/Bitrix24Partner.php @@ -0,0 +1,356 @@ + + * + * For the full copyright and license information, please view the MIT-LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Bitrix24\Lib\Bitrix24Partners\Entity; + +use Bitrix24\Lib\AggregateRoot; +use Bitrix24\SDK\Application\Contracts\Bitrix24Partners\Entity\Bitrix24PartnerInterface; +use Bitrix24\SDK\Application\Contracts\Bitrix24Partners\Entity\Bitrix24PartnerStatus; +use Bitrix24\SDK\Application\Contracts\Bitrix24Partners\Events\Bitrix24PartnerBlockedEvent; +use Bitrix24\SDK\Application\Contracts\Bitrix24Partners\Events\Bitrix24PartnerCreatedEvent; +use Bitrix24\SDK\Application\Contracts\Bitrix24Partners\Events\Bitrix24PartnerDeletedEvent; +use Bitrix24\SDK\Application\Contracts\Bitrix24Partners\Events\Bitrix24PartnerEmailChangedEvent; +use Bitrix24\SDK\Application\Contracts\Bitrix24Partners\Events\Bitrix24PartnerExternalIdChangedEvent; +use Bitrix24\SDK\Application\Contracts\Bitrix24Partners\Events\Bitrix24PartnerIdChangedEvent; +use Bitrix24\SDK\Application\Contracts\Bitrix24Partners\Events\Bitrix24PartnerOpenLineIdChangedEvent; +use Bitrix24\SDK\Application\Contracts\Bitrix24Partners\Events\Bitrix24PartnerPhoneChangedEvent; +use Bitrix24\SDK\Application\Contracts\Bitrix24Partners\Events\Bitrix24PartnerSiteChangedEvent; +use Bitrix24\SDK\Application\Contracts\Bitrix24Partners\Events\Bitrix24PartnerTitleChangedEvent; +use Bitrix24\SDK\Application\Contracts\Bitrix24Partners\Events\Bitrix24PartnerUnblockedEvent; +use Bitrix24\SDK\Core\Exceptions\InvalidArgumentException; +use Carbon\CarbonImmutable; +use libphonenumber\PhoneNumber; +use Symfony\Component\Uid\Uuid; + +class Bitrix24Partner extends AggregateRoot implements Bitrix24PartnerInterface +{ + private readonly Uuid $id; + private readonly CarbonImmutable $createdAt; + private CarbonImmutable $updatedAt; + private Bitrix24PartnerStatus $status = Bitrix24PartnerStatus::active; + private ?string $comment = null; + + public function __construct( + private string $title, + private int $bitrix24PartnerId, + private ?string $site = null, + private ?PhoneNumber $phone = null, + private ?string $email = null, + private ?string $openLineId = null, + private ?string $externalId = null + ) { + $this->id = Uuid::v7(); + $this->createdAt = new CarbonImmutable(); + $this->updatedAt = new CarbonImmutable(); + $this->events[] = new Bitrix24PartnerCreatedEvent( + $this->id, + $this->createdAt + ); + } + + #[\Override] + public function getId(): Uuid + { + return $this->id; + } + + #[\Override] + public function getCreatedAt(): CarbonImmutable + { + return $this->createdAt; + } + + #[\Override] + public function getUpdatedAt(): CarbonImmutable + { + return $this->updatedAt; + } + + #[\Override] + public function getStatus(): Bitrix24PartnerStatus + { + return $this->status; + } + + #[\Override] + public function getTitle(): string + { + return $this->title; + } + + /** + * @throws InvalidArgumentException + */ + #[\Override] + public function setTitle(string $title): void + { + if ('' === trim($title)) { + throw new InvalidArgumentException('title cannot be empty'); + } + + $oldTitle = $this->title; + $this->title = $title; + $this->updatedAt = new CarbonImmutable(); + + if ($oldTitle !== $title) { + $this->events[] = new Bitrix24PartnerTitleChangedEvent( + $this->id, + new CarbonImmutable() + ); + } + } + + #[\Override] + public function getSite(): ?string + { + return $this->site; + } + + /** + * @throws InvalidArgumentException + */ + #[\Override] + public function setSite(?string $site): void + { + if (null !== $site && '' === trim($site)) { + throw new InvalidArgumentException('site cannot be empty string'); + } + + $oldSite = $this->site; + $this->site = $site; + $this->updatedAt = new CarbonImmutable(); + + if ($oldSite !== $site) { + $this->events[] = new Bitrix24PartnerSiteChangedEvent( + $this->id, + new CarbonImmutable() + ); + } + } + + #[\Override] + public function getPhone(): ?PhoneNumber + { + return $this->phone; + } + + #[\Override] + public function setPhone(?PhoneNumber $phone): void + { + $oldPhone = $this->phone; + $this->phone = $phone; + $this->updatedAt = new CarbonImmutable(); + + // Compare phone numbers - both null, or both equal + $isChanged = !($oldPhone === null && $phone === null) + && !($oldPhone !== null && $phone !== null && $oldPhone->equals($phone)); + + if ($isChanged) { + $this->events[] = new Bitrix24PartnerPhoneChangedEvent( + $this->id, + new CarbonImmutable() + ); + } + } + + #[\Override] + public function getEmail(): ?string + { + return $this->email; + } + + /** + * @throws InvalidArgumentException + */ + #[\Override] + public function setEmail(?string $email): void + { + if (null !== $email && '' === trim($email)) { + throw new InvalidArgumentException('email cannot be empty string'); + } + + $oldEmail = $this->email; + $this->email = $email; + $this->updatedAt = new CarbonImmutable(); + + if ($oldEmail !== $email) { + $this->events[] = new Bitrix24PartnerEmailChangedEvent( + $this->id, + new CarbonImmutable() + ); + } + } + + #[\Override] + public function getComment(): ?string + { + return $this->comment; + } + + #[\Override] + public function getBitrix24PartnerId(): int + { + return $this->bitrix24PartnerId; + } + + /** + * @deprecated This method is deprecated and should not be used. Bitrix24PartnerId is immutable. + * + * @todo Create issue in https://github.com/bitrix24/b24phpsdk to remove this method from interface + * + * @throws InvalidArgumentException + */ + #[\Override] + public function setBitrix24PartnerId(?int $bitrix24PartnerId): void + { + if (null === $bitrix24PartnerId || $bitrix24PartnerId < 0) { + throw new InvalidArgumentException('bitrix24PartnerId cannot be null or negative'); + } + + $oldId = $this->bitrix24PartnerId; + $this->bitrix24PartnerId = $bitrix24PartnerId; + $this->updatedAt = new CarbonImmutable(); + + if ($oldId !== $bitrix24PartnerId) { + $this->events[] = new Bitrix24PartnerIdChangedEvent( + $this->id, + new CarbonImmutable() + ); + } + } + + #[\Override] + public function getOpenLineId(): ?string + { + return $this->openLineId; + } + + /** + * @throws InvalidArgumentException + */ + #[\Override] + public function setOpenLineId(?string $openLineId): void + { + if (null !== $openLineId && '' === trim($openLineId)) { + throw new InvalidArgumentException('openLineId cannot be empty string'); + } + + $oldOpenLineId = $this->openLineId; + $this->openLineId = $openLineId; + $this->updatedAt = new CarbonImmutable(); + + if ($oldOpenLineId !== $openLineId) { + $this->events[] = new Bitrix24PartnerOpenLineIdChangedEvent( + $this->id, + new CarbonImmutable() + ); + } + } + + #[\Override] + public function getExternalId(): ?string + { + return $this->externalId; + } + + /** + * @throws InvalidArgumentException + */ + #[\Override] + public function setExternalId(?string $externalId): void + { + if (null !== $externalId && '' === trim($externalId)) { + throw new InvalidArgumentException('externalId cannot be empty string'); + } + + $oldExternalId = $this->externalId; + $this->externalId = $externalId; + $this->updatedAt = new CarbonImmutable(); + + if ($oldExternalId !== $externalId) { + $this->events[] = new Bitrix24PartnerExternalIdChangedEvent( + $this->id, + new CarbonImmutable() + ); + } + } + + /** + * @throws InvalidArgumentException + */ + #[\Override] + public function markAsActive(?string $comment): void + { + if (Bitrix24PartnerStatus::blocked !== $this->status) { + throw new InvalidArgumentException( + sprintf( + 'you can activate partner only in status «blocked», now partner in status «%s»', + $this->status->name + ) + ); + } + + $this->status = Bitrix24PartnerStatus::active; + $this->comment = $comment; + $this->updatedAt = new CarbonImmutable(); + + $this->events[] = new Bitrix24PartnerUnblockedEvent( + $this->id, + new CarbonImmutable(), + $this->comment + ); + } + + /** + * @throws InvalidArgumentException + */ + #[\Override] + public function markAsBlocked(?string $comment): void + { + if (Bitrix24PartnerStatus::deleted === $this->status) { + throw new InvalidArgumentException('you cannot block partner in status «deleted»'); + } + + $this->status = Bitrix24PartnerStatus::blocked; + $this->comment = $comment; + $this->updatedAt = new CarbonImmutable(); + + $this->events[] = new Bitrix24PartnerBlockedEvent( + $this->id, + new CarbonImmutable(), + $this->comment + ); + } + + /** + * @throws InvalidArgumentException + */ + #[\Override] + public function markAsDeleted(?string $comment): void + { + if (Bitrix24PartnerStatus::deleted === $this->status) { + throw new InvalidArgumentException('partner already in status «deleted»'); + } + + $this->status = Bitrix24PartnerStatus::deleted; + $this->comment = $comment; + $this->updatedAt = new CarbonImmutable(); + + $this->events[] = new Bitrix24PartnerDeletedEvent( + $this->id, + new CarbonImmutable(), + $this->comment + ); + } + +} diff --git a/src/Bitrix24Partners/Infrastructure/Doctrine/Bitrix24PartnerRepository.php b/src/Bitrix24Partners/Infrastructure/Doctrine/Bitrix24PartnerRepository.php new file mode 100644 index 0000000..19634a6 --- /dev/null +++ b/src/Bitrix24Partners/Infrastructure/Doctrine/Bitrix24PartnerRepository.php @@ -0,0 +1,158 @@ +getClassMetadata(Bitrix24Partner::class)); + } + + /** + * @phpstan-return Bitrix24PartnerInterface&AggregateRootEventsEmitterInterface + * + * @throws Bitrix24PartnerNotFoundException + */ + #[\Override] + public function getById(Uuid $uuid): Bitrix24PartnerInterface + { + $partner = $this->getEntityManager()->getRepository(Bitrix24Partner::class) + ->createQueryBuilder('p') + ->where('p.id = :id') + ->andWhere('p.status != :status') + ->setParameter('id', $uuid) + ->setParameter('status', Bitrix24PartnerStatus::deleted) + ->getQuery() + ->getOneOrNullResult() + ; + + if (null === $partner) { + throw new Bitrix24PartnerNotFoundException( + sprintf('bitrix24 partner not found by id %s', $uuid->toRfc4122()) + ); + } + + return $partner; + } + + #[\Override] + public function save(Bitrix24PartnerInterface $bitrix24Partner): void + { + $this->getEntityManager()->persist($bitrix24Partner); + } + + /** + * @throws InvalidArgumentException + * @throws Bitrix24PartnerNotFoundException + */ + #[\Override] + public function delete(Uuid $uuid): void + { + $bitrix24Partner = $this->getEntityManager()->getRepository(Bitrix24Partner::class)->find($uuid); + + if (null === $bitrix24Partner) { + throw new Bitrix24PartnerNotFoundException( + sprintf('bitrix24 partner not found by id %s', $uuid->toRfc4122()) + ); + } + + if (Bitrix24PartnerStatus::deleted !== $bitrix24Partner->getStatus()) { + throw new InvalidArgumentException( + sprintf( + 'you cannot delete bitrix24 partner «%s», they must be in status «deleted», current status «%s»', + $bitrix24Partner->getId()->toRfc4122(), + $bitrix24Partner->getStatus()->name + ) + ); + } + + $this->getEntityManager()->remove($bitrix24Partner); + } + + /** + * @throws InvalidArgumentException + */ + #[\Override] + public function findByBitrix24PartnerId(int $bitrix24PartnerId): ?Bitrix24PartnerInterface + { + if ($bitrix24PartnerId < 0) { + throw new InvalidArgumentException('bitrix24PartnerId cannot be negative'); + } + + return $this->getEntityManager()->getRepository(Bitrix24Partner::class) + ->createQueryBuilder('p') + ->where('p.bitrix24PartnerId = :partnerId') + ->andWhere('p.status != :status') + ->setParameter('partnerId', $bitrix24PartnerId) + ->setParameter('status', Bitrix24PartnerStatus::deleted) + ->getQuery() + ->getOneOrNullResult() + ; + } + + /** + * @return array + * + * @throws InvalidArgumentException + */ + #[\Override] + public function findByTitle(string $title): array + { + if ('' === trim($title)) { + throw new InvalidArgumentException('title cannot be empty'); + } + + return $this->getEntityManager()->getRepository(Bitrix24Partner::class) + ->createQueryBuilder('p') + ->where('p.title LIKE :title') + ->andWhere('p.status != :status') + ->setParameter('title', '%' . $title . '%') + ->setParameter('status', Bitrix24PartnerStatus::deleted) + ->getQuery() + ->getResult() + ; + } + + /** + * @return array + * + * @throws InvalidArgumentException + */ + #[\Override] + public function findByExternalId(string $externalId, ?Bitrix24PartnerStatus $status = null): array + { + if ('' === trim($externalId)) { + throw new InvalidArgumentException('externalId cannot be empty'); + } + + $qb = $this->getEntityManager()->getRepository(Bitrix24Partner::class) + ->createQueryBuilder('p') + ->where('p.externalId = :externalId') + ->setParameter('externalId', $externalId); + + if (null !== $status) { + $qb->andWhere('p.status = :status') + ->setParameter('status', $status); + } else { + $qb->andWhere('p.status != :status') + ->setParameter('status', Bitrix24PartnerStatus::deleted); + } + + return $qb->getQuery()->getResult(); + } +} diff --git a/src/Bitrix24Partners/UseCase/Create/Command.php b/src/Bitrix24Partners/UseCase/Create/Command.php new file mode 100644 index 0000000..9f7b39d --- /dev/null +++ b/src/Bitrix24Partners/UseCase/Create/Command.php @@ -0,0 +1,49 @@ +validate(); + } + + private function validate(): void + { + if ('' === trim($this->title)) { + throw new \InvalidArgumentException('title must be a non-empty string'); + } + + if ($this->bitrix24PartnerId < 0) { + throw new \InvalidArgumentException('bitrix24PartnerId must be non-negative integer'); + } + + if (null !== $this->site && '' === trim($this->site)) { + throw new \InvalidArgumentException('site must be null or non-empty string'); + } + + if (null !== $this->email && '' === trim($this->email)) { + throw new \InvalidArgumentException('email must be null or non-empty string'); + } + + if (null !== $this->openLineId && '' === trim($this->openLineId)) { + throw new \InvalidArgumentException('openLineId must be null or non-empty string'); + } + + if (null !== $this->externalId && '' === trim($this->externalId)) { + throw new \InvalidArgumentException('externalId must be null or non-empty string'); + } + } +} diff --git a/src/Bitrix24Partners/UseCase/Create/Handler.php b/src/Bitrix24Partners/UseCase/Create/Handler.php new file mode 100644 index 0000000..337832a --- /dev/null +++ b/src/Bitrix24Partners/UseCase/Create/Handler.php @@ -0,0 +1,45 @@ +logger->info('Bitrix24Partners.Create.start', [ + 'title' => $command->title, + 'bitrix24_partner_id' => $command->bitrix24PartnerId, + ]); + + $bitrix24Partner = new Bitrix24Partner( + $command->title, + $command->bitrix24PartnerId, + $command->site, + $command->phone, + $command->email, + $command->openLineId, + $command->externalId + ); + + $this->bitrix24PartnerRepository->save($bitrix24Partner); + $this->flusher->flush($bitrix24Partner); + + $this->logger->info('Bitrix24Partners.Create.finish', [ + 'partner_id' => $bitrix24Partner->getId()->toRfc4122(), + 'title' => $command->title, + ]); + } +} diff --git a/src/Bitrix24Partners/UseCase/Delete/Command.php b/src/Bitrix24Partners/UseCase/Delete/Command.php new file mode 100644 index 0000000..0a396a4 --- /dev/null +++ b/src/Bitrix24Partners/UseCase/Delete/Command.php @@ -0,0 +1,15 @@ +logger->info('Bitrix24Partners.Delete.start', [ + 'partner_id' => $command->id->toRfc4122(), + ]); + + $partner = $this->bitrix24PartnerRepository->getById($command->id); + $partner->markAsDeleted($command->comment); + + $this->bitrix24PartnerRepository->save($partner); + $this->flusher->flush($partner); + + $this->logger->info('Bitrix24Partners.Delete.finish', [ + 'partner_id' => $command->id->toRfc4122(), + ]); + } +} diff --git a/src/Bitrix24Partners/UseCase/MarkAsActive/Command.php b/src/Bitrix24Partners/UseCase/MarkAsActive/Command.php new file mode 100644 index 0000000..d8bc143 --- /dev/null +++ b/src/Bitrix24Partners/UseCase/MarkAsActive/Command.php @@ -0,0 +1,15 @@ +logger->info('Bitrix24Partners.MarkAsActive.start', [ + 'partner_id' => $command->id->toRfc4122(), + ]); + + $partner = $this->bitrix24PartnerRepository->getById($command->id); + $partner->markAsActive($command->comment); + + $this->bitrix24PartnerRepository->save($partner); + $this->flusher->flush($partner); + + $this->logger->info('Bitrix24Partners.MarkAsActive.finish', [ + 'partner_id' => $command->id->toRfc4122(), + ]); + } +} diff --git a/src/Bitrix24Partners/UseCase/MarkAsBlocked/Command.php b/src/Bitrix24Partners/UseCase/MarkAsBlocked/Command.php new file mode 100644 index 0000000..8c74b5a --- /dev/null +++ b/src/Bitrix24Partners/UseCase/MarkAsBlocked/Command.php @@ -0,0 +1,15 @@ +logger->info('Bitrix24Partners.MarkAsBlocked.start', [ + 'partner_id' => $command->id->toRfc4122(), + ]); + + $partner = $this->bitrix24PartnerRepository->getById($command->id); + $partner->markAsBlocked($command->comment); + + $this->bitrix24PartnerRepository->save($partner); + $this->flusher->flush($partner); + + $this->logger->info('Bitrix24Partners.MarkAsBlocked.finish', [ + 'partner_id' => $command->id->toRfc4122(), + ]); + } +} diff --git a/src/Bitrix24Partners/UseCase/Update/Command.php b/src/Bitrix24Partners/UseCase/Update/Command.php new file mode 100644 index 0000000..626600c --- /dev/null +++ b/src/Bitrix24Partners/UseCase/Update/Command.php @@ -0,0 +1,46 @@ +validate(); + } + + private function validate(): void + { + if (null !== $this->title && '' === trim($this->title)) { + throw new \InvalidArgumentException('title must be null or non-empty string'); + } + + if (null !== $this->site && '' === trim($this->site)) { + throw new \InvalidArgumentException('site must be null or non-empty string'); + } + + if (null !== $this->email && '' === trim($this->email)) { + throw new \InvalidArgumentException('email must be null or non-empty string'); + } + + if (null !== $this->openLineId && '' === trim($this->openLineId)) { + throw new \InvalidArgumentException('openLineId must be null or non-empty string'); + } + + if (null !== $this->externalId && '' === trim($this->externalId)) { + throw new \InvalidArgumentException('externalId must be null or non-empty string'); + } + } +} diff --git a/src/Bitrix24Partners/UseCase/Update/Handler.php b/src/Bitrix24Partners/UseCase/Update/Handler.php new file mode 100644 index 0000000..f5d8887 --- /dev/null +++ b/src/Bitrix24Partners/UseCase/Update/Handler.php @@ -0,0 +1,58 @@ +logger->info('Bitrix24Partners.Update.start', [ + 'partner_id' => $command->id->toRfc4122(), + ]); + + $partner = $this->bitrix24PartnerRepository->getById($command->id); + + if (null !== $command->title) { + $partner->setTitle($command->title); + } + + if (null !== $command->site) { + $partner->setSite($command->site); + } + + if (null !== $command->phone) { + $partner->setPhone($command->phone); + } + + if (null !== $command->email) { + $partner->setEmail($command->email); + } + + if (null !== $command->openLineId) { + $partner->setOpenLineId($command->openLineId); + } + + if (null !== $command->externalId) { + $partner->setExternalId($command->externalId); + } + + $this->bitrix24PartnerRepository->save($partner); + $this->flusher->flush($partner); + + $this->logger->info('Bitrix24Partners.Update.finish', [ + 'partner_id' => $command->id->toRfc4122(), + ]); + } +} diff --git a/tests/EntityManagerFactory.php b/tests/EntityManagerFactory.php index e3935cf..02102cb 100644 --- a/tests/EntityManagerFactory.php +++ b/tests/EntityManagerFactory.php @@ -14,6 +14,7 @@ use Doctrine\ORM\Exception\ORMException; use Doctrine\ORM\OptimisticLockException; use Doctrine\ORM\ORMSetup; +use Misd\PhoneNumberBundle\Doctrine\DBAL\Types\PhoneNumberType; use Symfony\Bridge\Doctrine\Types\UuidType; class EntityManagerFactory @@ -66,6 +67,10 @@ public static function get(): EntityManagerInterface Type::addType('carbon_immutable', CarbonImmutableType::class); } + if (!Type::hasType(PhoneNumberType::NAME)) { + Type::addType(PhoneNumberType::NAME, PhoneNumberType::class); + } + $configuration = ORMSetup::createXMLMetadataConfiguration($paths, $isDevMode); $connection = DriverManager::getConnection($connectionParams, $configuration); diff --git a/tests/Functional/Bitrix24Partners/Infrastructure/Doctrine/Bitrix24PartnerRepositoryTest.php b/tests/Functional/Bitrix24Partners/Infrastructure/Doctrine/Bitrix24PartnerRepositoryTest.php new file mode 100644 index 0000000..1ab633b --- /dev/null +++ b/tests/Functional/Bitrix24Partners/Infrastructure/Doctrine/Bitrix24PartnerRepositoryTest.php @@ -0,0 +1,76 @@ + + * + * For the full copyright and license information, please view the MIT-LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Bitrix24\Lib\Tests\Functional\Bitrix24Partners\Infrastructure\Doctrine; + +use Bitrix24\Lib\Bitrix24Partners\Entity\Bitrix24Partner; +use Bitrix24\Lib\Bitrix24Partners\Infrastructure\Doctrine\Bitrix24PartnerRepository; +use Bitrix24\Lib\Services\Flusher; +use Bitrix24\Lib\Tests\EntityManagerFactory; +use Bitrix24\Lib\Tests\Functional\FlusherDecorator; +use Bitrix24\SDK\Application\Contracts\Bitrix24Partners\Entity\Bitrix24PartnerInterface; +use Bitrix24\SDK\Application\Contracts\Bitrix24Partners\Repository\Bitrix24PartnerRepositoryInterface; +use Bitrix24\SDK\Tests\Application\Contracts\Bitrix24Partners\Repository\Bitrix24PartnerRepositoryInterfaceTest; +use Bitrix24\SDK\Tests\Application\Contracts\TestRepositoryFlusherInterface; +use libphonenumber\PhoneNumber; +use PHPUnit\Framework\Attributes\CoversClass; +use Symfony\Component\EventDispatcher\EventDispatcher; +use Symfony\Component\Uid\Uuid; + +/** + * @internal + */ +#[CoversClass(Bitrix24PartnerRepository::class)] +class Bitrix24PartnerRepositoryTest extends Bitrix24PartnerRepositoryInterfaceTest +{ + #[\Override] + protected function createBitrix24PartnerImplementation( + Uuid $uuid, + string $title, + ?string $site = null, + ?PhoneNumber $phone = null, + ?string $email = null, + ?int $bitrix24PartnerId = null, + ?string $openLineId = null, + ?string $externalId = null + ): Bitrix24PartnerInterface { + // UUID parameter is ignored as it's generated internally + // bitrix24PartnerId is required in our implementation, use default if null + return new Bitrix24Partner( + $title, + $bitrix24PartnerId ?? 1, + $site, + $phone, + $email, + $openLineId, + $externalId + ); + } + + #[\Override] + protected function createBitrix24PartnerRepositoryImplementation(): Bitrix24PartnerRepositoryInterface + { + $entityManager = EntityManagerFactory::get(); + + return new Bitrix24PartnerRepository($entityManager); + } + + #[\Override] + protected function createRepositoryFlusherImplementation(): TestRepositoryFlusherInterface + { + $entityManager = EntityManagerFactory::get(); + $eventDispatcher = new EventDispatcher(); + + return new FlusherDecorator(new Flusher($entityManager, $eventDispatcher)); + } +} diff --git a/tests/Functional/Bitrix24Partners/UseCase/Create/HandlerTest.php b/tests/Functional/Bitrix24Partners/UseCase/Create/HandlerTest.php new file mode 100644 index 0000000..93b808d --- /dev/null +++ b/tests/Functional/Bitrix24Partners/UseCase/Create/HandlerTest.php @@ -0,0 +1,82 @@ + + * + * For the full copyright and license information, please view the MIT-LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Bitrix24\Lib\Tests\Functional\Bitrix24Partners\UseCase\Create; + +use Bitrix24\Lib\Bitrix24Partners; +use Bitrix24\Lib\Bitrix24Partners\Infrastructure\Doctrine\Bitrix24PartnerRepository; +use Bitrix24\Lib\Services\Flusher; +use Bitrix24\Lib\Tests\EntityManagerFactory; +use Bitrix24\SDK\Application\Contracts\Bitrix24Partners\Events\Bitrix24PartnerCreatedEvent; +use Bitrix24\SDK\Application\Contracts\Bitrix24Partners\Repository\Bitrix24PartnerRepositoryInterface; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; +use Symfony\Component\EventDispatcher\Debug\TraceableEventDispatcher; +use Symfony\Component\EventDispatcher\EventDispatcher; +use Symfony\Component\Stopwatch\Stopwatch; + +/** + * @internal + */ +#[CoversClass(Bitrix24Partners\UseCase\Create\Handler::class)] +class HandlerTest extends TestCase +{ + private Bitrix24Partners\UseCase\Create\Handler $handler; + + private Flusher $flusher; + + private Bitrix24PartnerRepositoryInterface $repository; + + private TraceableEventDispatcher $eventDispatcher; + + #[\Override] + protected function setUp(): void + { + $entityManager = EntityManagerFactory::get(); + $this->eventDispatcher = new TraceableEventDispatcher(new EventDispatcher(), new Stopwatch()); + $this->repository = new Bitrix24PartnerRepository($entityManager); + $this->flusher = new Flusher($entityManager, $this->eventDispatcher); + $this->handler = new Bitrix24Partners\UseCase\Create\Handler( + $this->repository, + $this->flusher, + new NullLogger() + ); + } + + #[Test] + public function testCreatePartner(): void + { + $this->handler->handle( + new Bitrix24Partners\UseCase\Create\Command( + 'Test Partner', + 12345, + 'https://example.com', + null, + 'test@example.com', + 'line-123', + 'ext-123' + ) + ); + + $this->assertContains( + Bitrix24PartnerCreatedEvent::class, + $this->eventDispatcher->getOrphanedEvents(), + sprintf( + 'not found expected domain event «%s»', + Bitrix24PartnerCreatedEvent::class + ) + ); + } +} diff --git a/tests/Unit/Bitrix24Partners/Entity/Bitrix24PartnerTest.php b/tests/Unit/Bitrix24Partners/Entity/Bitrix24PartnerTest.php new file mode 100644 index 0000000..441dcbf --- /dev/null +++ b/tests/Unit/Bitrix24Partners/Entity/Bitrix24PartnerTest.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the MIT-LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Bitrix24\Lib\Tests\Unit\Bitrix24Partners\Entity; + +use Bitrix24\Lib\Bitrix24Partners\Entity\Bitrix24Partner; +use Bitrix24\SDK\Application\Contracts\Bitrix24Partners\Entity\Bitrix24PartnerInterface; +use Bitrix24\SDK\Tests\Application\Contracts\Bitrix24Partners\Entity\Bitrix24PartnerInterfaceTest; +use libphonenumber\PhoneNumber; +use PHPUnit\Framework\Attributes\CoversClass; +use Symfony\Component\Uid\Uuid; + +/** + * @internal + */ +#[CoversClass(Bitrix24Partner::class)] +class Bitrix24PartnerTest extends Bitrix24PartnerInterfaceTest +{ + #[\Override] + protected function createBitrix24PartnerImplementation( + Uuid $uuid, + string $title, + ?string $site = null, + ?PhoneNumber $phone = null, + ?string $email = null, + ?int $bitrix24PartnerId = null, + ?string $openLineId = null, + ?string $externalId = null + ): Bitrix24PartnerInterface { + // UUID parameter is ignored as it's generated internally + // bitrix24PartnerId is required in our implementation, use default if null + return new Bitrix24Partner( + $title, + $bitrix24PartnerId ?? 1, + $site, + $phone, + $email, + $openLineId, + $externalId + ); + } +} diff --git a/tests/Unit/Bitrix24Partners/UseCase/Create/CommandTest.php b/tests/Unit/Bitrix24Partners/UseCase/Create/CommandTest.php new file mode 100644 index 0000000..206a411 --- /dev/null +++ b/tests/Unit/Bitrix24Partners/UseCase/Create/CommandTest.php @@ -0,0 +1,129 @@ +expectException($expectedException); + } + + if (null !== $expectedExceptionMessage) { + $this->expectExceptionMessage($expectedExceptionMessage); + } + + new Command( + $title, + $bitrix24PartnerId, + $site, + null, // phone + $email, + $openLineId, + $externalId + ); + } + + public static function dataForCommand(): \Generator + { + yield 'validCommand' => [ + 'Test Partner', + 123, + 'https://example.com', + 'test@example.com', + 'line-123', + 'ext-123', + null, + null, + ]; + + yield 'emptyTitle' => [ + '', + 123, + 'https://example.com', + 'test@example.com', + 'line-123', + 'ext-123', + \InvalidArgumentException::class, + 'title must be a non-empty string', + ]; + + yield 'emptySite' => [ + 'Test Partner', + 123, + '', + 'test@example.com', + 'line-123', + 'ext-123', + \InvalidArgumentException::class, + 'site must be null or non-empty string', + ]; + + yield 'emptyEmail' => [ + 'Test Partner', + 123, + 'https://example.com', + '', + 'line-123', + 'ext-123', + \InvalidArgumentException::class, + 'email must be null or non-empty string', + ]; + + yield 'negativeBitrix24PartnerId' => [ + 'Test Partner', + -1, + 'https://example.com', + 'test@example.com', + 'line-123', + 'ext-123', + \InvalidArgumentException::class, + 'bitrix24PartnerId must be non-negative integer', + ]; + + yield 'emptyOpenLineId' => [ + 'Test Partner', + 123, + 'https://example.com', + 'test@example.com', + '', + 'ext-123', + \InvalidArgumentException::class, + 'openLineId must be null or non-empty string', + ]; + + yield 'emptyExternalId' => [ + 'Test Partner', + 123, + 'https://example.com', + 'test@example.com', + 'line-123', + '', + \InvalidArgumentException::class, + 'externalId must be null or non-empty string', + ]; + } +}