Bladeren bron

code reengineered

master
Evgeniy Ierusalimov 2 weken geleden
bovenliggende
commit
6ff78dcead

+ 2
- 2
.env Bestand weergeven

@@ -31,7 +31,7 @@ IMAP_STORE_ATTACHMENTS_DIR=var/imap/attachments
31 31
 # DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db"
32 32
 # DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=8.0.32&charset=utf8mb4"
33 33
 # DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=10.11.2-MariaDB&charset=utf8mb4"
34
-DATABASE_URL="postgresql://db_user:secret@127.0.0.1:5432/finfollow?serverVersion=11&charset=utf8"
34
+DATABASE_URL="postgresql://db_user:secret@127.0.0.1:5432/finfollow?serverVersion=13&charset=utf8"
35 35
 ###< doctrine/doctrine-bundle ###
36 36
 
37 37
 ###> symfony/telegram-notifier ###
@@ -46,4 +46,4 @@ TELEGRAM_CHAT_ID=
46 46
 SECURITIES_MAP='{"RU000A1014L8":"LQDT", "RU000A107UL4":"T", "RU000A108X38":"X5"}'
47 47
 
48 48
 VALID_EMAIL_SUBJECT='Broker report'
49
-VALID_EMAIL_SENDER='@bcs.ru'
49
+VALID_EMAIL_SENDER='@bcs.ru'

+ 1
- 0
config/services.yaml Bestand weergeven

@@ -37,6 +37,7 @@ services:
37 37
     App\Presentation\PortfolioPresenter:
38 38
         arguments:
39 39
             $securitiesMap: '%securitiesMap%'
40
+            $fontDir: '%kernel.project_dir%'
40 41
 
41 42
     App\Service\MailFetcher:
42 43
         arguments:

+ 9
- 46
src/Domain/Entity/ParsedPortfolio.php Bestand weergeven

@@ -1,54 +1,17 @@
1 1
 <?php
2 2
 
3
+declare(strict_types=1);
4
+
3 5
 namespace App\Domain\Entity;
4 6
 
5 7
 final readonly class ParsedPortfolio
6 8
 {
7
-    private array $header;
8
-
9
-    private array $details;
10
-
11
-    private array $movements;
12
-
13
-
14
-    public function __construct(array $header, array $details, array $movements)
15
-    {
16
-        $this->header = $header;
17
-        $this->details = $details;
18
-        $this->movements = $movements;
19
-    }
20
-
21
-    public function getHeader(): array
22
-    {
23
-        return $this->header;
24
-    }
25
-
26
-    public function getDetails(): array
27
-    {
28
-        return $this->details;
29
-    }
30
-
31
-    public function getMovements(): array
32
-    {
33
-        return $this->movements;
34
-    }
35
-
36
-    public function extractPeriod(): array
37
-    {
38
-        if (array_key_exists('НачПериода', $this->header)) {
39
-            $startDate = \DateTime::createFromFormat('d.m.Y', $this->header['НачПериода']);
40
-            $endDate = \DateTime::createFromFormat('d.m.Y', $this->header['КонПериода']);
41
-        } elseif (array_key_exists('СтрокаПериода', $this->header)) {
42
-            $chunks = explode(' ', $this->header['СтрокаПериода']);
43
-
44
-            if(count($chunks) !== 4) throw new \Exception('Unexpected period format!');
45
-
46
-            $startDate = \DateTime::createFromFormat('d.m.Y', $chunks[1]);
47
-            $endDate = \DateTime::createFromFormat('d.m.Y', $chunks[3]);
48
-        } else {
49
-            throw new \Exception('Can not detect xml header version!');
50
-        }
51
-
52
-        return [$startDate, $endDate];
9
+    /** @param PortfolioDetailItem[] $details */
10
+    /** @param PortfolioMovementItem[] $movements */
11
+    public function __construct(
12
+        public PortfolioHeader $header,
13
+        public array $details,
14
+        public array $movements,
15
+    ) {
53 16
     }
54 17
 }

+ 21
- 0
src/Domain/Entity/PortfolioDetailItem.php Bestand weergeven

@@ -0,0 +1,21 @@
1
+<?php
2
+
3
+declare(strict_types=1);
4
+
5
+namespace App\Domain\Entity;
6
+
7
+final readonly class PortfolioDetailItem
8
+{
9
+    public function __construct(
10
+        public string $issuer,
11
+        public string $security,
12
+        public string $priceStart,
13
+        public string $priceEnd,
14
+        public int $quantityStart,
15
+        public int $quantityEnd,
16
+        public string $sumStart,
17
+        public string $sumEnd,
18
+        public string $sumTotal,
19
+    ) {
20
+    }
21
+}

+ 15
- 0
src/Domain/Entity/PortfolioHeader.php Bestand weergeven

@@ -0,0 +1,15 @@
1
+<?php
2
+
3
+declare(strict_types=1);
4
+
5
+namespace App\Domain\Entity;
6
+
7
+final readonly class PortfolioHeader
8
+{
9
+    public function __construct(
10
+        public string $clientAgreement,
11
+        public \DateTimeImmutable $startDate,
12
+        public \DateTimeImmutable $endDate,
13
+    ) {
14
+    }
15
+}

+ 18
- 0
src/Domain/Entity/PortfolioMovementItem.php Bestand weergeven

@@ -0,0 +1,18 @@
1
+<?php
2
+
3
+declare(strict_types=1);
4
+
5
+namespace App\Domain\Entity;
6
+
7
+final readonly class PortfolioMovementItem
8
+{
9
+    public function __construct(
10
+        public string $security,
11
+        public \DateTimeImmutable $period,
12
+        public int $quantityStart,
13
+        public int $quantityEnd,
14
+        public int $quantityIncome,
15
+        public int $quantityOutcome,
16
+    ) {
17
+    }
18
+}

+ 62
- 57
src/Presentation/PortfolioPresenter.php Bestand weergeven

@@ -1,24 +1,35 @@
1 1
 <?php
2 2
 
3
+declare(strict_types=1);
4
+
3 5
 namespace App\Presentation;
4 6
 
5 7
 use App\Domain\Entity\ParsedPortfolio;
8
+use App\Service\PortfolioPresenterInterface;
6 9
 use Symfony\Component\Console\Helper\Table;
7 10
 use Symfony\Component\Console\Output\BufferedOutput;
8 11
 use Symfony\Component\Console\Helper\TableStyle;
9 12
 
10
-class PortfolioPresenter
13
+class PortfolioPresenter implements PortfolioPresenterInterface
11 14
 {
12 15
     private const FONT_SIZE = 12;
13
-    private const FONT_NAME = 'droid_sans_mono';
16
+    private const FONT_NAME = 'droid_sans_mono.ttf';
14 17
     private const COPYRIGHT_MARK = ' ©ЕИ';
18
+
19
+    /** @var array{array<string>, array<string>} */
15 20
     private array $securityMapPrintable = [['(в пути)'], ['±']];
21
+
22
+    /** @var array<string, string> */
16 23
     private array $mapSecurityTitle = [];
17 24
 
18
-    public function __construct(array $securitiesMap)
25
+    private string $fontPath;
26
+
27
+    public function __construct(array $securitiesMap, string $fontDir)
19 28
     {
20 29
         array_unshift($this->securityMapPrintable[0], ...array_keys($securitiesMap));
21 30
         array_unshift($this->securityMapPrintable[1], ...array_values($securitiesMap));
31
+
32
+        $this->fontPath = $fontDir . '/' . self::FONT_NAME;
22 33
     }
23 34
 
24 35
     public function toImage(ParsedPortfolio $parsedPortfolio, string $filename): void
@@ -28,20 +39,17 @@ class PortfolioPresenter
28 39
         $grey = imagecolorallocate($im, 48, 48, 48);
29 40
         imagefill($im, 0, 0, $grey);
30 41
 
31
-        putenv('GDFONTPATH=' . realpath('.'));
32
-        // Замена пути к шрифту на пользовательский
33
-
34 42
         $lines = explode("\n", $this->toText($parsedPortfolio));
35 43
         $height = self::FONT_SIZE;
36 44
         foreach ($lines as $line) {
37
-            imagefttext($im, self::FONT_SIZE, 0, 0, $height, $whitey, self::FONT_NAME, $line);
38
-            $height += self::FONT_SIZE + round(0.5 * self::FONT_SIZE);
45
+            imagefttext($im, self::FONT_SIZE, 0, 0, $height, $whitey, $this->fontPath, $line);
46
+            $height += self::FONT_SIZE + (int) round(0.5 * self::FONT_SIZE);
39 47
         }
40 48
 
41 49
         $cropped = imagecropauto($im, IMG_CROP_SIDES);
42
-        if ($cropped !== false) { // in case a new image object was returned
43
-            imagedestroy($im);    // we destroy the original image
44
-            $im = $cropped;       // and assign the cropped image to $im
50
+        if ($cropped !== false) {
51
+            imagedestroy($im);
52
+            $im = $cropped;
45 53
         }
46 54
 
47 55
         imagepng($im, $filename);
@@ -50,8 +58,8 @@ class PortfolioPresenter
50 58
 
51 59
     public function toText(ParsedPortfolio $parsedPortfolio): string
52 60
     {
53
-        list($startDate, $endDate) = $parsedPortfolio->extractPeriod();
54
-        $periodTitle = $startDate->format('d.m.y').' - '.$endDate->format('d.m.y');
61
+        $header = $parsedPortfolio->header;
62
+        $periodTitle = $header->startDate->format('d.m.y') . ' - ' . $header->endDate->format('d.m.y');
55 63
 
56 64
         $total = $this->calcTotalSum($parsedPortfolio);
57 65
 
@@ -64,20 +72,17 @@ class PortfolioPresenter
64 72
         $cellStyle->setPadType(STR_PAD_RIGHT);
65 73
 
66 74
         $table = new Table($output);
67
-        $table->setHeaderTitle('ПОРТФЕЛЬ: '.$periodTitle);
68
-        $table->setHeaders([
69
-            'ЦБ', 'Кол-во', 'Цн', 'Цк', 'Сумм', '%'
70
-        ]);
75
+        $table->setHeaderTitle('ПОРТФЕЛЬ: ' . $periodTitle);
76
+        $table->setHeaders(['ЦБ', 'Кол-во', 'Цн', 'Цк', 'Сумм', '%']);
71 77
 
72
-        foreach ($parsedPortfolio->getDetails() as $detail) {
78
+        foreach ($parsedPortfolio->details as $detail) {
73 79
             $table->addRow([
74
-                $this->getPrintableSecurityTitle($detail),
75
-                $this->formatQuantity($detail),
76
-                $detail['НОЦена'],
77
-                //$detail['СуммаНКДНО'],
78
-                $detail['КОЦена'],
79
-                round($detail['КОСумма']),
80
-                round(100 * $detail['КОСумма'] / $total, 2),
80
+                $this->getPrintableSecurityTitle($detail->security, $detail->issuer),
81
+                $this->formatQuantity($detail->quantityStart, $detail->quantityEnd),
82
+                $detail->priceStart,
83
+                $detail->priceEnd,
84
+                round((float) $detail->sumTotal),
85
+                round(100 * (float) $detail->sumTotal / $total, 2),
81 86
             ]);
82 87
         }
83 88
 
@@ -87,19 +92,16 @@ class PortfolioPresenter
87 92
         $txt = $output->fetch();
88 93
 
89 94
         $table = new Table($output);
90
-        $table->setHeaderTitle('ДВИЖ: '.$periodTitle);
91
-        $table->setHeaders([
92
-            'ЦБ', 'Кн', 'In', 'Out', 'Кк'
93
-        ]);
95
+        $table->setHeaderTitle('ДВИЖ: ' . $periodTitle);
96
+        $table->setHeaders(['ЦБ', 'Кн', 'In', 'Out', 'Кк']);
94 97
 
95
-        foreach ($parsedPortfolio->getMovements() as $movement) {
98
+        foreach ($parsedPortfolio->movements as $movement) {
96 99
             $table->addRow([
97
-                $this->getPrintableSecurityTitle($movement),
98
-                //$movement['Период'],
99
-                $movement['НО'],
100
-                $movement['Приход'] > 0 ? '+' . $movement['Приход'] : $movement['Приход'],
101
-                $movement['Расход'] > 0 ? '-' . $movement['Расход'] : $movement['Расход'],
102
-                $movement['КО'],
100
+                $this->getPrintableSecurityTitle($movement->security),
101
+                $movement->quantityStart,
102
+                $movement->quantityIncome > 0 ? '+' . $movement->quantityIncome : $movement->quantityIncome,
103
+                $movement->quantityOutcome > 0 ? '-' . $movement->quantityOutcome : $movement->quantityOutcome,
104
+                $movement->quantityEnd,
103 105
             ]);
104 106
         }
105 107
 
@@ -114,44 +116,47 @@ class PortfolioPresenter
114 116
     {
115 117
         $total = 0.0;
116 118
 
117
-        foreach ($parsedPortfolio->getDetails() as $detail) {
118
-            $total += $detail['КОСумма'];
119
+        foreach ($parsedPortfolio->details as $detail) {
120
+            $total += (float) $detail->sumTotal;
119 121
         }
120 122
 
121 123
         return $total;
122 124
     }
123 125
 
124
-    private function getPrintableSecurityTitle(array $security): string
126
+    private function getPrintableSecurityTitle(string $security, ?string $issuer = null): string
125 127
     {
126
-        $security['ЦБ'] = str_replace($this->securityMapPrintable[0], $this->securityMapPrintable[1], $security['ЦБ']);
127
-
128
-        if (!str_starts_with($security['ЦБ'], 'RU00')) {
129
-            return $security['ЦБ'];
128
+        $security = str_replace(
129
+            $this->securityMapPrintable[0],
130
+            $this->securityMapPrintable[1],
131
+            $security,
132
+        );
133
+
134
+        if (!str_starts_with($security, 'RU00')) {
135
+            return $security;
130 136
         }
131 137
 
132
-        if (isset($this->mapSecurityTitle[$security['ЦБ']])) {
133
-            return $this->mapSecurityTitle[$security['ЦБ']];
138
+        if (isset($this->mapSecurityTitle[$security])) {
139
+            return $this->mapSecurityTitle[$security];
134 140
         }
135 141
 
136
-        if (!isset($security['Эмитент'])) {
137
-            return $security['ЦБ'];
142
+        if ($issuer === null) {
143
+            return $security;
138 144
         }
139 145
 
140
-        $title = str_replace(['МКПАО', 'ПАО', '"', ' ', '(', ')'], ['', '', '', '', '', ''], $security['Эмитент']);
141
-        $this->mapSecurityTitle[$security['ЦБ']] = $title;
146
+        $title = str_replace(['МКПАО', 'ПАО', '"', ' ', '(', ')'], '', $issuer);
147
+        $this->mapSecurityTitle[$security] = $title;
142 148
 
143 149
         return $title;
144 150
     }
145 151
 
146
-    private function formatQuantity(array $detail): string
152
+    private function formatQuantity(int $quantityStart, int $quantityEnd): string
147 153
     {
148
-        if ($detail['КоличествоНО'] == $detail['КоличествоКО']) {
149
-            return $detail['КоличествоНО'];
150
-        } else {
151
-            return ($detail['КоличествоНО'] > 0 ? $detail['КоличествоНО'] . ' ' : '')
152
-                . ($detail['КоличествоКО'] > $detail['КоличествоНО'] ? '+' : '')
153
-                . ($detail['КоличествоКО'] - $detail['КоличествоНО']);
154
+        if ($quantityStart === $quantityEnd) {
155
+            return (string) $quantityStart;
154 156
         }
155
-    }
156 157
 
158
+        return ($quantityStart > 0 ? $quantityStart . ' ' : '')
159
+            . ($quantityEnd > $quantityStart ? '+' : '')
160
+            . ($quantityEnd - $quantityStart);
161
+    }
157 162
 }

+ 67
- 0
src/Service/AttachmentProcessor.php Bestand weergeven

@@ -0,0 +1,67 @@
1
+<?php
2
+
3
+declare(strict_types=1);
4
+
5
+namespace App\Service;
6
+
7
+use PhpImap\IncomingMailAttachment;
8
+use Psr\Log\LoggerInterface;
9
+use Symfony\Component\Filesystem\Filesystem;
10
+
11
+readonly class AttachmentProcessor
12
+{
13
+    public function __construct(private LoggerInterface $logger)
14
+    {
15
+    }
16
+
17
+    public function extractXmlFromAttachment(IncomingMailAttachment $attachment): ?string
18
+    {
19
+        if (strtolower($attachment->mimeType) !== 'application/zip') {
20
+            return null;
21
+        }
22
+
23
+        $zip = new \ZipArchive();
24
+        if ($zip->open($attachment->filePath) !== true) {
25
+            $this->logger->error('ZIP open error');
26
+            return null;
27
+        }
28
+
29
+        $xmlString = null;
30
+        $tmpdir = null;
31
+
32
+        for ($i = 0; $i < $zip->numFiles; $i++) {
33
+            $stat = $zip->statIndex($i);
34
+
35
+            if (pathinfo($stat['name'], PATHINFO_EXTENSION) !== 'xml') {
36
+                continue;
37
+            }
38
+
39
+            $tmpdir = $this->getTmpDir();
40
+            $zip->extractTo($tmpdir, [$stat['name']]);
41
+            $this->logger->debug("Extracted '{$stat['name']}' into '$tmpdir'");
42
+
43
+            $content = file_get_contents($tmpdir . '/' . $stat['name']);
44
+            if ($content !== false) {
45
+                $xmlString = $content;
46
+                break;
47
+            }
48
+        }
49
+
50
+        $zip->close();
51
+
52
+        if ($tmpdir !== null) {
53
+            (new Filesystem())->remove($tmpdir);
54
+        }
55
+
56
+        return $xmlString;
57
+    }
58
+
59
+    private function getTmpDir(): string
60
+    {
61
+        $tmpdir = sys_get_temp_dir() . '/FINFOLLOW-' . uniqid();
62
+
63
+        (new Filesystem())->mkdir($tmpdir);
64
+
65
+        return $tmpdir;
66
+    }
67
+}

+ 15
- 69
src/Service/MailFetcher.php Bestand weergeven

@@ -1,17 +1,16 @@
1 1
 <?php
2 2
 
3
+declare(strict_types=1);
4
+
3 5
 namespace App\Service;
4 6
 
7
+use App\Presentation\PortfolioPresenter;
8
+use App\Service\TelegramNotifier;
9
+use PhpImap\Exceptions\ConnectionException;
5 10
 use PhpImap\IncomingMail;
6
-use PhpImap\IncomingMailAttachment;
7 11
 use Psr\Log\LoggerInterface;
8 12
 use SecIT\ImapBundle\Connection\ConnectionInterface;
9 13
 use Symfony\Component\DependencyInjection\Attribute\Target;
10
-use Symfony\Component\Filesystem\Filesystem;
11
-use Symfony\Component\Filesystem\Path;
12
-use App\Presentation\PortfolioPresenter;
13
-use App\Service\TelegramNotifier;
14
-use PhpImap\Exceptions\ConnectionException;
15 14
 
16 15
 readonly class MailFetcher
17 16
 {
@@ -23,6 +22,7 @@ readonly class MailFetcher
23 22
         private LoggerInterface     $logger,
24 23
         private TelegramNotifier    $telegramNotifier,
25 24
         private PortfolioPresenter  $portfolioPresenter,
25
+        private AttachmentProcessor $attachmentProcessor,
26 26
         private string              $validEmailSubject,
27 27
         private string              $validEmailSender,
28 28
     )
@@ -33,8 +33,6 @@ readonly class MailFetcher
33 33
     {
34 34
         $mailbox = $this->connection->getMailbox();
35 35
         try {
36
-            // Get all emails (messages)
37
-            // PHP.net imap_search criteria: http://php.net/manual/en/function.imap-search.php
38 36
             $mailsIds = $mailbox->searchMailbox('ALL');
39 37
         } catch (ConnectionException $ex) {
40 38
             $this->logger->error("IMAP connection failed: " . implode(",", $ex->getErrors('all')));
@@ -47,9 +45,6 @@ readonly class MailFetcher
47 45
             return;
48 46
         }
49 47
 
50
-        // If '__DIR__' was defined in the first line, it will automatically
51
-        // save all attachments to the specified directory
52
-
53 48
         foreach ($mailsIds as $mailId) {
54 49
             $mail = $mailbox->getMail($mailId);
55 50
 
@@ -62,11 +57,7 @@ readonly class MailFetcher
62 57
             ], true));
63 58
 
64 59
             if ($this->canProcessMail($mail)) {
65
-                if ($this->processMail($mail)) {
66
-                    // for dev purposes
67
-                    //break;
68
-                }
69
-
60
+                $this->processMail($mail);
70 61
                 $mailbox->deleteMail($mailId);
71 62
                 $this->logger->debug("Deleted email with id=" . $mailId);
72 63
             } else {
@@ -94,72 +85,27 @@ readonly class MailFetcher
94 85
         return true;
95 86
     }
96 87
 
97
-    private function processMail(IncomingMail $mail): bool
88
+    private function processMail(IncomingMail $mail): void
98 89
     {
99
-        $mailProcessed = false;
100
-
101 90
         foreach ($mail->getAttachments() as $attachment) {
102
-            if ($attachment->mimeType != 'application/zip') {
91
+            $xmlString = $this->attachmentProcessor->extractXmlFromAttachment($attachment);
92
+            if ($xmlString === null) {
103 93
                 continue;
104 94
             }
105 95
 
106
-            $mailProcessed |= $this->processZipArchiveAttachment($attachment);
107
-        }
108
-
109
-        return $mailProcessed;
110
-    }
111
-
112
-    private function processZipArchiveAttachment(IncomingMailAttachment $attachment): bool
113
-    {
114
-        $zip = new \ZipArchive();
115
-        if ($zip->open($attachment->filePath) !== true) {
116
-            $this->logger->error('ZIP open error: ' . $zip);
117
-            return false;
118
-        }
119
-
120
-        $xmlFound = false;
121
-        for ($i = 0; $i < $zip->numFiles; $i++) {
122
-            $stat = $zip->statIndex($i);
123
-
124
-            if (pathinfo($stat['name'])['extension'] !== 'xml') {
96
+            $xml = simplexml_load_string($xmlString);
97
+            if ($xml === false) {
98
+                $this->logger->error('Invalid XML from attachment');
125 99
                 continue;
126 100
             }
127 101
 
128
-            $tmpdir = $this->getTmpDir();
129
-
130
-            $zip->extractTo($tmpdir, [$stat['name']]);
131
-            $this->logger->debug("Extracted '$stat[name]' into '$tmpdir'");
132
-
133
-            $xmlString = file_get_contents($tmpdir . '/' . $stat['name']);
134
-            $xml = simplexml_load_string($xmlString);
135
-
136 102
             $parsedPortfolio = $this->xmlParser->processXml($xml);
137 103
 
138 104
             if ($this->portfolioManager->updatePortfolio($parsedPortfolio, $xmlString)) {
139
-                //$this->telegramNotifier->notify('<pre>' . (new PresentationPortfolio)->toText($parsedPortfolio) . '</pre>');
140
-
141
-                $pngFilename = $tmpdir . '/portfolio.png';
105
+                $pngFilename = tempnam(sys_get_temp_dir(), 'finfollow-portfolio-');
142 106
                 $this->portfolioPresenter->toImage($parsedPortfolio, $pngFilename);
143 107
                 $this->telegramNotifier->notify('', $pngFilename);
144 108
             }
145
-
146
-            $fs = new Filesystem();
147
-            $fs->remove($tmpdir);
148
-
149
-            $xmlFound = true;
150 109
         }
151
-
152
-        return $xmlFound;
153
-    }
154
-
155
-    private function getTmpDir(): string
156
-    {
157
-        $tmpdir = Path::normalize(sys_get_temp_dir() . '/FINFOLLOW-' . random_int(0, 100));
158
-
159
-        $filesystem = new Filesystem();
160
-        $filesystem->mkdir($tmpdir);
161
-
162
-        return $tmpdir;
163 110
     }
164
-
165
-}
111
+}

+ 30
- 31
src/Service/PortfolioManager.php Bestand weergeven

@@ -1,5 +1,7 @@
1 1
 <?php
2 2
 
3
+declare(strict_types=1);
4
+
3 5
 namespace App\Service;
4 6
 
5 7
 use App\Domain\Entity\ParsedPortfolio;
@@ -17,12 +19,12 @@ readonly class PortfolioManager
17 19
 
18 20
     public function updatePortfolio(ParsedPortfolio $parsedPortfolio, string $xmlString): bool
19 21
     {
20
-        list($startDate, $endDate) = $parsedPortfolio->extractPeriod();
22
+        $header = $parsedPortfolio->header;
21 23
 
22 24
         $portfolio = $this->entityManager->getRepository(Portfolio::class)->findOneBy([
23
-            'clientAgreement' => $parsedPortfolio->getHeader()['Клиент'],
24
-            'startDate' => new \DateTimeImmutable($startDate->format('Y-m-d')),
25
-            'endDate' => new \DateTimeImmutable($endDate->format('Y-m-d')),
25
+            'clientAgreement' => $header->clientAgreement,
26
+            'startDate' => $header->startDate,
27
+            'endDate' => $header->endDate,
26 28
         ]);
27 29
 
28 30
         if ($portfolio) {
@@ -30,56 +32,54 @@ readonly class PortfolioManager
30 32
             return false;
31 33
         }
32 34
 
33
-        $newPortfolio = $this->savePortfolio($parsedPortfolio, $xmlString, $startDate, $endDate);
35
+        $newPortfolio = $this->savePortfolio($parsedPortfolio, $xmlString);
34 36
         $this->logger->debug("Saved new portfolio with id: " . $newPortfolio->getId());
35 37
 
36 38
         return true;
37 39
     }
38 40
 
39
-    private function savePortfolio(
40
-        ParsedPortfolio $parsedPortfolio,
41
-        string $xmlString,
42
-        \DateTime $startDate,
43
-        \DateTime $endDate,
44
-    ): Portfolio {
41
+    private function savePortfolio(ParsedPortfolio $parsedPortfolio, string $xmlString): Portfolio
42
+    {
45 43
         $this->logger->debug(print_r($parsedPortfolio, true));
46 44
 
45
+        $header = $parsedPortfolio->header;
46
+
47 47
         $portfolio = new Portfolio();
48 48
 
49
-        $portfolio->setClientAgreement($parsedPortfolio->getHeader()['Клиент']);
50
-        $portfolio->setStartDate(new \DateTimeImmutable($startDate->format('Y-m-d')));
51
-        $portfolio->setEndDate(new \DateTimeImmutable($endDate->format('Y-m-d')));
49
+        $portfolio->setClientAgreement($header->clientAgreement);
50
+        $portfolio->setStartDate($header->startDate);
51
+        $portfolio->setEndDate($header->endDate);
52 52
         $portfolio->setXmlData($xmlString);
53 53
 
54 54
         $this->entityManager->wrapInTransaction(function () use ($parsedPortfolio, $portfolio): void {
55 55
             $this->entityManager->persist($portfolio);
56 56
 
57
-            foreach ($parsedPortfolio->getDetails() as $parsedDetail) {
57
+            foreach ($parsedPortfolio->details as $item) {
58 58
                 $detail = new PortfolioDetail();
59 59
 
60 60
                 $detail->setPortfolio($portfolio);
61
-                $detail->setIssuer($parsedDetail['Эмитент']);
62
-                $detail->setSecurity($parsedDetail['ЦБ']);
63
-                $detail->setPriceStart($parsedDetail['НОЦена']);
64
-                $detail->setPriceEnd($parsedDetail['КОЦена']);
65
-                $detail->setQuantityStart($parsedDetail['КоличествоНО']);
66
-                $detail->setQuantityEnd($parsedDetail['КоличествоКО']);
67
-                $detail->setSumStart($parsedDetail['СуммаНКДНО']);
68
-                $detail->setSumEnd($parsedDetail['СуммаНКДКО']);
61
+                $detail->setIssuer($item->issuer);
62
+                $detail->setSecurity($item->security);
63
+                $detail->setPriceStart($item->priceStart);
64
+                $detail->setPriceEnd($item->priceEnd);
65
+                $detail->setQuantityStart($item->quantityStart);
66
+                $detail->setQuantityEnd($item->quantityEnd);
67
+                $detail->setSumStart($item->sumStart);
68
+                $detail->setSumEnd($item->sumEnd);
69 69
 
70 70
                 $this->entityManager->persist($detail);
71 71
             }
72 72
 
73
-            foreach ($parsedPortfolio->getMovements() as $parsedMovement) {
73
+            foreach ($parsedPortfolio->movements as $item) {
74 74
                 $movement = new PortfolioMovement();
75 75
 
76 76
                 $movement->setPortfolio($portfolio);
77
-                $movement->setSecurity($parsedMovement['ЦБ']);
78
-                $movement->setPeriod(new \DateTimeImmutable($parsedMovement['Период']));
79
-                $movement->setQuantityStart($parsedMovement['НО']);
80
-                $movement->setQuantityEnd($parsedMovement['КО']);
81
-                $movement->setQuantityIncome($parsedMovement['Приход']);
82
-                $movement->setQuantityOutcome($parsedMovement['Расход']);
77
+                $movement->setSecurity($item->security);
78
+                $movement->setPeriod($item->period);
79
+                $movement->setQuantityStart($item->quantityStart);
80
+                $movement->setQuantityEnd($item->quantityEnd);
81
+                $movement->setQuantityIncome($item->quantityIncome);
82
+                $movement->setQuantityOutcome($item->quantityOutcome);
83 83
 
84 84
                 $this->entityManager->persist($movement);
85 85
             }
@@ -89,5 +89,4 @@ readonly class PortfolioManager
89 89
 
90 90
         return $portfolio;
91 91
     }
92
-
93 92
 }

+ 14
- 0
src/Service/PortfolioPresenterInterface.php Bestand weergeven

@@ -0,0 +1,14 @@
1
+<?php
2
+
3
+declare(strict_types=1);
4
+
5
+namespace App\Service;
6
+
7
+use App\Domain\Entity\ParsedPortfolio;
8
+
9
+interface PortfolioPresenterInterface
10
+{
11
+    public function toImage(ParsedPortfolio $parsedPortfolio, string $filename): void;
12
+
13
+    public function toText(ParsedPortfolio $parsedPortfolio): string;
14
+}

+ 1
- 1
src/Service/TelegramNotifier.php Bestand weergeven

@@ -9,7 +9,7 @@ use Symfony\Component\Notifier\Bridge\Telegram\TelegramOptions;
9 9
 use Symfony\Component\Notifier\Message\ChatMessage;
10 10
 use Symfony\Contracts\HttpClient\HttpClientInterface;
11 11
 
12
-readonly class TelegramNotifier
12
+readonly class TelegramNotifier implements TelegramNotifierInterface
13 13
 {
14 14
     public function __construct(
15 15
         private string $telegramBotToken,

+ 10
- 0
src/Service/TelegramNotifierInterface.php Bestand weergeven

@@ -0,0 +1,10 @@
1
+<?php
2
+
3
+declare(strict_types=1);
4
+
5
+namespace App\Service;
6
+
7
+interface TelegramNotifierInterface
8
+{
9
+    public function notify(string $message, ?string $filename = null): void;
10
+}

+ 94
- 26
src/Service/XmlParser.php Bestand weergeven

@@ -1,59 +1,127 @@
1 1
 <?php
2 2
 
3
+declare(strict_types=1);
4
+
3 5
 namespace App\Service;
4 6
 
5 7
 use App\Domain\Entity\ParsedPortfolio;
8
+use App\Domain\Entity\PortfolioDetailItem;
9
+use App\Domain\Entity\PortfolioHeader;
10
+use App\Domain\Entity\PortfolioMovementItem;
6 11
 
7 12
 class XmlParser
8 13
 {
9 14
     public function processXml(\SimpleXMLElement $xml): ParsedPortfolio
10 15
     {
11
-        $portfolio = [
12
-            'header' => $this->processXmlHeader($xml),
13
-            'details' => $this->processXmlElement($xml->{'ЗаголовокПортфель'}->{'ШапкаПортфель'}),
14
-            'movements' => $this->processXmlElement($xml->{'ШапкаДвиженияИОстаткиЦБ'}),
15
-        ];
16
-
17
-        return new ParsedPortfolio(...$portfolio);
16
+        return new ParsedPortfolio(
17
+            header: $this->processXmlHeader($xml),
18
+            details: $this->processDetails($xml->{'ЗаголовокПортфель'}->{'ШапкаПортфель'}),
19
+            movements: $this->processMovements($xml->{'ШапкаДвиженияИОстаткиЦБ'}),
20
+        );
18 21
     }
19 22
 
20
-
21
-    private function processXmlHeader(\SimpleXMLElement $xml): array
23
+    private function processXmlHeader(\SimpleXMLElement $xml): PortfolioHeader
22 24
     {
23
-        $header = [];
24
-        foreach (['ШапкаГенСог', 'ШапкаПериод'] as $h) {
25
-            foreach ($xml->{$h}->attributes() as $key => $value) {
26
-                $header[$key] = get_object_vars($value)[0];
25
+        $attrs = [];
26
+        foreach (['ШапкаГенСог', 'ШапкаПериод'] as $section) {
27
+            foreach ($xml->{$section}->attributes() as $key => $value) {
28
+                $attrs[$key] = get_object_vars($value)[0];
29
+            }
30
+        }
31
+
32
+        if (array_key_exists('НачПериода', $attrs)) {
33
+            $startDate = \DateTimeImmutable::createFromFormat('d.m.Y', $attrs['НачПериода']);
34
+            $endDate   = \DateTimeImmutable::createFromFormat('d.m.Y', $attrs['КонПериода']);
35
+        } elseif (array_key_exists('СтрокаПериода', $attrs)) {
36
+            $chunks = explode(' ', $attrs['СтрокаПериода']);
37
+            if (count($chunks) !== 4) {
38
+                throw new \RuntimeException('Unexpected period format');
27 39
             }
40
+            $startDate = \DateTimeImmutable::createFromFormat('d.m.Y', $chunks[1]);
41
+            $endDate   = \DateTimeImmutable::createFromFormat('d.m.Y', $chunks[3]);
42
+        } else {
43
+            throw new \RuntimeException('Cannot detect XML header version');
28 44
         }
29 45
 
30
-        return $header;
46
+        if (!$startDate || !$endDate) {
47
+            throw new \RuntimeException('Invalid date format in XML header');
48
+        }
49
+
50
+        return new PortfolioHeader(
51
+            clientAgreement: $attrs['Клиент'],
52
+            startDate: $startDate,
53
+            endDate: $endDate,
54
+        );
31 55
     }
32 56
 
33
-    private function processXmlElement(\SimpleXMLElement $xml): array
57
+    private function processDetails(\SimpleXMLElement $xml): array
34 58
     {
35
-        $securities = [];
59
+        $items = [];
36 60
 
37 61
         foreach (get_object_vars($xml) as $key => $value) {
62
+            if (mb_strpos($key, 'Детали') === false) {
63
+                continue;
64
+            }
38 65
 
39
-            if (mb_strpos($key, 'Детали') !== false) {
66
+            if (!is_array($value)) {
67
+                $value = [$value];
68
+            }
40 69
 
41
-                if (!is_array($value)) {
42
-                    $value = [$value];
70
+            foreach ($value as $line) {
71
+                $attr = [];
72
+                foreach ($line->attributes() as $attrName => $attrValue) {
73
+                    $attr[$attrName] = get_object_vars($attrValue)[0];
43 74
                 }
44 75
 
45
-                foreach ($value as $line) {
46
-                    $attr = [];
76
+                $items[] = new PortfolioDetailItem(
77
+                    issuer:        $attr['Эмитент'],
78
+                    security:      $attr['ЦБ'],
79
+                    priceStart:    $attr['НОЦена'],
80
+                    priceEnd:      $attr['КОЦена'],
81
+                    quantityStart: (int) $attr['КоличествоНО'],
82
+                    quantityEnd:   (int) $attr['КоличествоКО'],
83
+                    sumStart:      $attr['СуммаНКДНО'],
84
+                    sumEnd:        $attr['СуммаНКДКО'],
85
+                    sumTotal:      $attr['КОСумма'],
86
+                );
87
+            }
88
+        }
89
+
90
+        return $items;
91
+    }
47 92
 
48
-                    foreach ($line->attributes() as $k1 => $v1) {
49
-                        $attr[$k1] = get_object_vars($v1)[0];
50
-                    }
93
+    private function processMovements(\SimpleXMLElement $xml): array
94
+    {
95
+        $items = [];
51 96
 
52
-                    $securities[$attr['ЦБ']] = $attr;
97
+        foreach (get_object_vars($xml) as $key => $value) {
98
+            if (mb_strpos($key, 'Детали') === false) {
99
+                continue;
100
+            }
101
+
102
+            if (!is_array($value)) {
103
+                $value = [$value];
104
+            }
105
+
106
+            foreach ($value as $line) {
107
+                $attr = [];
108
+                foreach ($line->attributes() as $attrName => $attrValue) {
109
+                    $attr[$attrName] = get_object_vars($attrValue)[0];
53 110
                 }
111
+
112
+                $period = new \DateTimeImmutable($attr['Период']);
113
+
114
+                $items[] = new PortfolioMovementItem(
115
+                    security:       $attr['ЦБ'],
116
+                    period:         $period,
117
+                    quantityStart:  (int) $attr['НО'],
118
+                    quantityEnd:    (int) $attr['КО'],
119
+                    quantityIncome: (int) $attr['Приход'],
120
+                    quantityOutcome:(int) $attr['Расход'],
121
+                );
54 122
             }
55 123
         }
56 124
 
57
-        return $securities;
125
+        return $items;
58 126
     }
59 127
 }

Laden…
Annuleren
Opslaan