Explorar el Código

code reengineered

master
Evgeniy Ierusalimov hace 2 semanas
padre
commit
6ff78dcead

+ 2
- 2
.env Ver fichero

31
 # DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db"
31
 # DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db"
32
 # DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=8.0.32&charset=utf8mb4"
32
 # DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=8.0.32&charset=utf8mb4"
33
 # DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=10.11.2-MariaDB&charset=utf8mb4"
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
 ###< doctrine/doctrine-bundle ###
35
 ###< doctrine/doctrine-bundle ###
36
 
36
 
37
 ###> symfony/telegram-notifier ###
37
 ###> symfony/telegram-notifier ###
46
 SECURITIES_MAP='{"RU000A1014L8":"LQDT", "RU000A107UL4":"T", "RU000A108X38":"X5"}'
46
 SECURITIES_MAP='{"RU000A1014L8":"LQDT", "RU000A107UL4":"T", "RU000A108X38":"X5"}'
47
 
47
 
48
 VALID_EMAIL_SUBJECT='Broker report'
48
 VALID_EMAIL_SUBJECT='Broker report'
49
-VALID_EMAIL_SENDER='@bcs.ru'
49
+VALID_EMAIL_SENDER='@bcs.ru'

+ 1
- 0
config/services.yaml Ver fichero

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

+ 9
- 46
src/Domain/Entity/ParsedPortfolio.php Ver fichero

1
 <?php
1
 <?php
2
 
2
 
3
+declare(strict_types=1);
4
+
3
 namespace App\Domain\Entity;
5
 namespace App\Domain\Entity;
4
 
6
 
5
 final readonly class ParsedPortfolio
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 Ver fichero

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 Ver fichero

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 Ver fichero

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 Ver fichero

1
 <?php
1
 <?php
2
 
2
 
3
+declare(strict_types=1);
4
+
3
 namespace App\Presentation;
5
 namespace App\Presentation;
4
 
6
 
5
 use App\Domain\Entity\ParsedPortfolio;
7
 use App\Domain\Entity\ParsedPortfolio;
8
+use App\Service\PortfolioPresenterInterface;
6
 use Symfony\Component\Console\Helper\Table;
9
 use Symfony\Component\Console\Helper\Table;
7
 use Symfony\Component\Console\Output\BufferedOutput;
10
 use Symfony\Component\Console\Output\BufferedOutput;
8
 use Symfony\Component\Console\Helper\TableStyle;
11
 use Symfony\Component\Console\Helper\TableStyle;
9
 
12
 
10
-class PortfolioPresenter
13
+class PortfolioPresenter implements PortfolioPresenterInterface
11
 {
14
 {
12
     private const FONT_SIZE = 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
     private const COPYRIGHT_MARK = ' ©ЕИ';
17
     private const COPYRIGHT_MARK = ' ©ЕИ';
18
+
19
+    /** @var array{array<string>, array<string>} */
15
     private array $securityMapPrintable = [['(в пути)'], ['±']];
20
     private array $securityMapPrintable = [['(в пути)'], ['±']];
21
+
22
+    /** @var array<string, string> */
16
     private array $mapSecurityTitle = [];
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
         array_unshift($this->securityMapPrintable[0], ...array_keys($securitiesMap));
29
         array_unshift($this->securityMapPrintable[0], ...array_keys($securitiesMap));
21
         array_unshift($this->securityMapPrintable[1], ...array_values($securitiesMap));
30
         array_unshift($this->securityMapPrintable[1], ...array_values($securitiesMap));
31
+
32
+        $this->fontPath = $fontDir . '/' . self::FONT_NAME;
22
     }
33
     }
23
 
34
 
24
     public function toImage(ParsedPortfolio $parsedPortfolio, string $filename): void
35
     public function toImage(ParsedPortfolio $parsedPortfolio, string $filename): void
28
         $grey = imagecolorallocate($im, 48, 48, 48);
39
         $grey = imagecolorallocate($im, 48, 48, 48);
29
         imagefill($im, 0, 0, $grey);
40
         imagefill($im, 0, 0, $grey);
30
 
41
 
31
-        putenv('GDFONTPATH=' . realpath('.'));
32
-        // Замена пути к шрифту на пользовательский
33
-
34
         $lines = explode("\n", $this->toText($parsedPortfolio));
42
         $lines = explode("\n", $this->toText($parsedPortfolio));
35
         $height = self::FONT_SIZE;
43
         $height = self::FONT_SIZE;
36
         foreach ($lines as $line) {
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
         $cropped = imagecropauto($im, IMG_CROP_SIDES);
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
         imagepng($im, $filename);
55
         imagepng($im, $filename);
50
 
58
 
51
     public function toText(ParsedPortfolio $parsedPortfolio): string
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
         $total = $this->calcTotalSum($parsedPortfolio);
64
         $total = $this->calcTotalSum($parsedPortfolio);
57
 
65
 
64
         $cellStyle->setPadType(STR_PAD_RIGHT);
72
         $cellStyle->setPadType(STR_PAD_RIGHT);
65
 
73
 
66
         $table = new Table($output);
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
             $table->addRow([
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
         $txt = $output->fetch();
92
         $txt = $output->fetch();
88
 
93
 
89
         $table = new Table($output);
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
             $table->addRow([
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
     {
116
     {
115
         $total = 0.0;
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
         return $total;
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
         return $title;
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 Ver fichero

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 Ver fichero

1
 <?php
1
 <?php
2
 
2
 
3
+declare(strict_types=1);
4
+
3
 namespace App\Service;
5
 namespace App\Service;
4
 
6
 
7
+use App\Presentation\PortfolioPresenter;
8
+use App\Service\TelegramNotifier;
9
+use PhpImap\Exceptions\ConnectionException;
5
 use PhpImap\IncomingMail;
10
 use PhpImap\IncomingMail;
6
-use PhpImap\IncomingMailAttachment;
7
 use Psr\Log\LoggerInterface;
11
 use Psr\Log\LoggerInterface;
8
 use SecIT\ImapBundle\Connection\ConnectionInterface;
12
 use SecIT\ImapBundle\Connection\ConnectionInterface;
9
 use Symfony\Component\DependencyInjection\Attribute\Target;
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
 readonly class MailFetcher
15
 readonly class MailFetcher
17
 {
16
 {
23
         private LoggerInterface     $logger,
22
         private LoggerInterface     $logger,
24
         private TelegramNotifier    $telegramNotifier,
23
         private TelegramNotifier    $telegramNotifier,
25
         private PortfolioPresenter  $portfolioPresenter,
24
         private PortfolioPresenter  $portfolioPresenter,
25
+        private AttachmentProcessor $attachmentProcessor,
26
         private string              $validEmailSubject,
26
         private string              $validEmailSubject,
27
         private string              $validEmailSender,
27
         private string              $validEmailSender,
28
     )
28
     )
33
     {
33
     {
34
         $mailbox = $this->connection->getMailbox();
34
         $mailbox = $this->connection->getMailbox();
35
         try {
35
         try {
36
-            // Get all emails (messages)
37
-            // PHP.net imap_search criteria: http://php.net/manual/en/function.imap-search.php
38
             $mailsIds = $mailbox->searchMailbox('ALL');
36
             $mailsIds = $mailbox->searchMailbox('ALL');
39
         } catch (ConnectionException $ex) {
37
         } catch (ConnectionException $ex) {
40
             $this->logger->error("IMAP connection failed: " . implode(",", $ex->getErrors('all')));
38
             $this->logger->error("IMAP connection failed: " . implode(",", $ex->getErrors('all')));
47
             return;
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
         foreach ($mailsIds as $mailId) {
48
         foreach ($mailsIds as $mailId) {
54
             $mail = $mailbox->getMail($mailId);
49
             $mail = $mailbox->getMail($mailId);
55
 
50
 
62
             ], true));
57
             ], true));
63
 
58
 
64
             if ($this->canProcessMail($mail)) {
59
             if ($this->canProcessMail($mail)) {
65
-                if ($this->processMail($mail)) {
66
-                    // for dev purposes
67
-                    //break;
68
-                }
69
-
60
+                $this->processMail($mail);
70
                 $mailbox->deleteMail($mailId);
61
                 $mailbox->deleteMail($mailId);
71
                 $this->logger->debug("Deleted email with id=" . $mailId);
62
                 $this->logger->debug("Deleted email with id=" . $mailId);
72
             } else {
63
             } else {
94
         return true;
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
         foreach ($mail->getAttachments() as $attachment) {
90
         foreach ($mail->getAttachments() as $attachment) {
102
-            if ($attachment->mimeType != 'application/zip') {
91
+            $xmlString = $this->attachmentProcessor->extractXmlFromAttachment($attachment);
92
+            if ($xmlString === null) {
103
                 continue;
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
                 continue;
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
             $parsedPortfolio = $this->xmlParser->processXml($xml);
102
             $parsedPortfolio = $this->xmlParser->processXml($xml);
137
 
103
 
138
             if ($this->portfolioManager->updatePortfolio($parsedPortfolio, $xmlString)) {
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
                 $this->portfolioPresenter->toImage($parsedPortfolio, $pngFilename);
106
                 $this->portfolioPresenter->toImage($parsedPortfolio, $pngFilename);
143
                 $this->telegramNotifier->notify('', $pngFilename);
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 Ver fichero

1
 <?php
1
 <?php
2
 
2
 
3
+declare(strict_types=1);
4
+
3
 namespace App\Service;
5
 namespace App\Service;
4
 
6
 
5
 use App\Domain\Entity\ParsedPortfolio;
7
 use App\Domain\Entity\ParsedPortfolio;
17
 
19
 
18
     public function updatePortfolio(ParsedPortfolio $parsedPortfolio, string $xmlString): bool
20
     public function updatePortfolio(ParsedPortfolio $parsedPortfolio, string $xmlString): bool
19
     {
21
     {
20
-        list($startDate, $endDate) = $parsedPortfolio->extractPeriod();
22
+        $header = $parsedPortfolio->header;
21
 
23
 
22
         $portfolio = $this->entityManager->getRepository(Portfolio::class)->findOneBy([
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
         if ($portfolio) {
30
         if ($portfolio) {
30
             return false;
32
             return false;
31
         }
33
         }
32
 
34
 
33
-        $newPortfolio = $this->savePortfolio($parsedPortfolio, $xmlString, $startDate, $endDate);
35
+        $newPortfolio = $this->savePortfolio($parsedPortfolio, $xmlString);
34
         $this->logger->debug("Saved new portfolio with id: " . $newPortfolio->getId());
36
         $this->logger->debug("Saved new portfolio with id: " . $newPortfolio->getId());
35
 
37
 
36
         return true;
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
         $this->logger->debug(print_r($parsedPortfolio, true));
43
         $this->logger->debug(print_r($parsedPortfolio, true));
46
 
44
 
45
+        $header = $parsedPortfolio->header;
46
+
47
         $portfolio = new Portfolio();
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
         $portfolio->setXmlData($xmlString);
52
         $portfolio->setXmlData($xmlString);
53
 
53
 
54
         $this->entityManager->wrapInTransaction(function () use ($parsedPortfolio, $portfolio): void {
54
         $this->entityManager->wrapInTransaction(function () use ($parsedPortfolio, $portfolio): void {
55
             $this->entityManager->persist($portfolio);
55
             $this->entityManager->persist($portfolio);
56
 
56
 
57
-            foreach ($parsedPortfolio->getDetails() as $parsedDetail) {
57
+            foreach ($parsedPortfolio->details as $item) {
58
                 $detail = new PortfolioDetail();
58
                 $detail = new PortfolioDetail();
59
 
59
 
60
                 $detail->setPortfolio($portfolio);
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
                 $this->entityManager->persist($detail);
70
                 $this->entityManager->persist($detail);
71
             }
71
             }
72
 
72
 
73
-            foreach ($parsedPortfolio->getMovements() as $parsedMovement) {
73
+            foreach ($parsedPortfolio->movements as $item) {
74
                 $movement = new PortfolioMovement();
74
                 $movement = new PortfolioMovement();
75
 
75
 
76
                 $movement->setPortfolio($portfolio);
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
                 $this->entityManager->persist($movement);
84
                 $this->entityManager->persist($movement);
85
             }
85
             }
89
 
89
 
90
         return $portfolio;
90
         return $portfolio;
91
     }
91
     }
92
-
93
 }
92
 }

+ 14
- 0
src/Service/PortfolioPresenterInterface.php Ver fichero

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 Ver fichero

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

+ 10
- 0
src/Service/TelegramNotifierInterface.php Ver fichero

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 Ver fichero

1
 <?php
1
 <?php
2
 
2
 
3
+declare(strict_types=1);
4
+
3
 namespace App\Service;
5
 namespace App\Service;
4
 
6
 
5
 use App\Domain\Entity\ParsedPortfolio;
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
 class XmlParser
12
 class XmlParser
8
 {
13
 {
9
     public function processXml(\SimpleXMLElement $xml): ParsedPortfolio
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
         foreach (get_object_vars($xml) as $key => $value) {
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
 }

Loading…
Cancelar
Guardar