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',
+ ];
+ }
+}