· 4 years ago · Mar 10, 2021, 10:38 AM
1<?php
2
3namespace App\Rcon\Connector\YandexMarket;
4
5use App\Entity\YandexMarketReportRow;
6use App\Rcon\Connector\AbstractConnector;
7use App\Rcon\Connector\DateRangeLimitableInterface;
8use App\Rcon\ConnectorInterface;
9use App\Rcon\Exception\InvalidArgumentException;
10use DateTime;
11use DateTimeInterface;
12use Doctrine\ORM\EntityManagerInterface;
13use Symfony\Component\Form\Exception\InvalidConfigurationException;
14use Symfony\Component\HttpClient\HttpClient;
15use Symfony\Contracts\HttpClient\HttpClientInterface;
16use Symfony\Contracts\HttpClient\ResponseInterface;
17use Throwable;
18
19/**
20 * Токен получать по этой инструкции: https://yandex.ru/dev/oauth/doc/dg/reference/console-client.html
21 *
22 * Class YandexMarketConnector
23 * @package App\Rcon\Connector\YandexMarket
24 */
25class YandexMarketConnector extends AbstractConnector implements DateRangeLimitableInterface
26{
27 private const CHUNK_SIZE = 1000;
28
29 protected string $appId;
30 protected string $token;
31 protected array $excludeCampaign = [];
32
33 protected ?DateTime $from = null;
34 protected ?DateTime $to = null;
35 protected string $tmpDir;
36 protected string $reportTmpFile;
37
38 private EntityManagerInterface $entityManager;
39
40 public function setConfig(array $config): ConnectorInterface
41 {
42 if (array_key_exists("app-id", $config) && mb_strlen($config["app-id"]) > 0) {
43 $this->appId = $config["app-id"];
44 } else {
45 throw new InvalidConfigurationException();
46 }
47
48 if (array_key_exists("token", $config) && mb_strlen($config["token"]) > 0) {
49 $this->token = $config["token"];
50 } else {
51 throw new InvalidConfigurationException();
52 }
53
54 if (array_key_exists("exclude-campaign", $config) && is_array($config["exclude-campaign"])) {
55 $this->excludeCampaign = $config["exclude-campaign"];
56 }
57
58 return $this;
59 }
60
61 public function checkConfig(): array
62 {
63 //TODO:
64 return [true, []];
65 }
66
67 public function checkConnectivity(): array
68 {
69 try {
70 $this->getCampaigns();
71 return [true, []];
72 } catch (Throwable $e) {
73 return [false, [$e->getMessage()]];
74 }
75 }
76
77 public function setTmpDir(string $dirname)
78 {
79 $this->tmpDir = $dirname;
80
81 $this->logger->debug("Временная директория коннектора: {$dirname}");
82 }
83
84 public function setEntityManager(EntityManagerInterface $entityManager)
85 {
86 $this->entityManager = $entityManager;
87 }
88
89 public function import()
90 {
91 try {
92 $this->startTransaction();
93 $this->doImport();
94 $this->commitTransaction();
95 } catch (\Throwable $e) {
96 $this->rollbackTransaction();
97 $this->logger->critical('Ошибка во время импорта', [
98 'exception' => sprintf("%s\n%s:%s", $e->getMessage(), $e->getFile(), $e->getLine()),
99 'trace' => $e->getTraceAsString()
100 ]);
101 throw $e;
102 } finally {
103 $this->finalize();
104 }
105
106 return true;
107 }
108
109 protected function doImport(): void
110 {
111 $this->entityManager->getConnection()->getConfiguration()->setSQLLogger(null);
112
113 foreach ($this->getCampaigns() as $campaign) {
114 if (!in_array($campaign['id'], $this->excludeCampaign)) {
115 $this->importForCampaign($campaign['id']);
116 }
117 }
118 }
119
120 protected function importForCampaign(int $campaignId): void
121 {
122 $from = $this->from ?? $this->getLastImportedDate($campaignId);
123 $to = $this->to ?? (new DateTime())->modify('-1 day')->setTime(0, 0);
124
125 while ($from->format('Y-m-d') <= $to->format('Y-m-d')) {
126 $this->logger->info('Импорт статистики для ' . $campaignId . ' за ' . $from->format('c'));
127 $offers = $this->getOfferStats($campaignId, $from);
128 $this->insertOrUpdateBatch($offers, $campaignId, $from);
129 $from->modify('+1 day');
130 }
131 }
132
133 protected function insertOrUpdateBatch(array $offers, int $campaignId, DateTimeInterface $date): void
134 {
135 $count = 0;
136
137 foreach ($offers as $offer) {
138 $this->entityManager->persist(
139 $this->getEntity($offer, $campaignId, $date)
140 ->setConnectorId($this->id)
141 ->setCampaignId($campaignId)
142 ->setDate($date)
143 ->setFeedId($offer['feedId'])
144 ->setOfferId($offer['offerId'])
145 ->setOfferName($offer['offerName'])
146 ->setClicks($offer['clicks'])
147 ->setSpending($offer['spending'])
148 );
149
150 $count = ($count + 1) % self::CHUNK_SIZE;
151
152 if (0 === $count) {
153 $this->entityManager->flush();
154 $this->entityManager->clear();
155 }
156 }
157
158 if ($count) {
159 $this->entityManager->flush();
160 $this->entityManager->clear();
161 }
162 }
163
164 protected function getEntity(array $offer, int $campaignId, DateTimeInterface $date): YandexMarketReportRow
165 {
166 return $this->entityManager->getRepository(YandexMarketReportRow::class)->findOneBy([
167 'campaignId' => $campaignId,
168 'offerId' => $offer['offerId'],
169 'date' => $date
170 ]) ?? new YandexMarketReportRow();
171 }
172
173 protected function startTransaction(): void
174 {
175 $this->logger->info("Старт транзакции");
176 $this->entityManager->beginTransaction();
177 $this->logger->info("Транзакция начата");
178 }
179
180 protected function commitTransaction(): void
181 {
182 $this->logger->info("Коммит транзакции");
183 $this->entityManager->commit();
184 $this->logger->info("Транзакция завершена");
185 }
186
187 protected function rollbackTransaction(): void
188 {
189 $this->logger->info("Откат транзакции");
190 $this->entityManager->rollback();
191 $this->logger->info("Транзакция откачена");
192 }
193
194 public function setDateRange(DateTime $from, DateTime $to): void
195 {
196 $this->from = $from;
197 $this->to = $to;
198 }
199
200 public function setParams(array $params): void
201 {
202 if(count($params) === 1) {
203 if(!($this->from = DateTime::createFromFormat("Y-m-d", $params[0]))) {
204 throw new InvalidArgumentException("Не удалось распознать дату '{$params[0]}'.");
205 }
206
207 $this->to = (new DateTime())->modify("yesterday midnight");
208 } elseif (count($params) === 2) {
209 if(!($this->from = DateTime::createFromFormat("Y-m-d", $params[0]))) {
210 throw new InvalidArgumentException("Не удалось распознать дату '{$params[0]}'.");
211 }
212 if(!($this->to = DateTime::createFromFormat("Y-m-d", $params[1]))) {
213 throw new InvalidArgumentException("Не удалось распознать дату '{$params[1]}'.");
214 }
215 }
216 }
217
218 protected function getLastImportedDate(int $campaignId): DateTimeInterface
219 {
220 return DateTime::createFromFormat('Y-m-d', $this->entityManager
221 ->createQuery('SELECT max(r.date) as date FROM App\Entity\YandexMarketReportRow r where r.campaignId = :campaignId')
222 ->setParameter(':campaignId', $campaignId)
223 ->getSingleScalarResult()) ?: (new DateTime())->setTime(0, 0)->modify('-30 days');
224 }
225
226 public function getParamsDescription(): string
227 {
228 return "[[YYYY-MM-DD начала периода] YYYY-MM-DD конца периода]\n\
229 Если указано только дата начала, то конец будет сегодня.\n\
230 Если параметры опущены, то диапазон будет определен автоматически/";
231 }
232
233 private function finalize(): void
234 {
235 $this->entityManager->flush();
236 }
237
238 protected function getClient(): HttpClientInterface
239 {
240 static $client = null;
241
242 if (null === $client) {
243 $client = HttpClient::createForBaseUri('https://api.partner.market.yandex.ru/');
244 }
245
246 return $client;
247 }
248
249 protected function makeAuthHeader(): string
250 {
251 return strtr('OAuth oauth_token={oauth_token}, oauth_client_id={oauth_client_id}', [
252 '{oauth_token}' => $this->token,
253 '{oauth_client_id}' => $this->appId,
254 ]);
255 }
256
257 protected function readJsonResponse(ResponseInterface $response): array
258 {
259 return json_decode(
260 $response->getContent(true),
261 JSON_OBJECT_AS_ARRAY,
262 512,
263 JSON_THROW_ON_ERROR
264 );
265 }
266
267 /**
268 * Список магазинов
269 *
270 * @return array ["id" => int, "clientId" => int, "domain" => string, "state" => int]
271 * @throws \JsonException
272 * @throws \Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface
273 * @throws \Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface
274 * @throws \Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface
275 * @throws \Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface
276 */
277 protected function getCampaigns(): array
278 {
279 $data = $this->readJsonResponse(
280 $this->getClient()->request('GET', '/v2/campaigns.json', ['headers' => [
281 'Authorization' => $this->makeAuthHeader()
282 ]])
283 );
284
285 return $data['campaigns'];
286 }
287
288 /**
289 * @param int $campaignId
290 * @param DateTimeInterface $date
291 * @return array ["offerId" => ["clicks" => int, "spending" => string, "offerName" => string, "feedId" => int,
292 * "offerId" => string], ...]
293 * @throws \JsonException
294 * @throws \Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface
295 * @throws \Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface
296 * @throws \Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface
297 * @throws \Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface
298 */
299 protected function getOfferStats(int $campaignId, DateTimeInterface $date): array
300 {
301 $page = 1;
302 $offers = [];
303
304 do {
305 $data = $this->readJsonResponse(
306 $this->getClient()->request(
307 'GET',
308 "/v2/campaigns/$campaignId/stats/offers.json?" . http_build_query([
309 'fromDate' => $date->format('d-m-Y'),
310 'toDate' => $date->format('d-m-Y'),
311 'pageSize' => 1000,
312 'page' => $page,
313 ]),
314 ['headers' => ['Authorization' => $this->makeAuthHeader()]]
315 )
316 )['offersStats'];
317
318 $offers = array_merge($offers, $data['offerStats']);
319
320 $page++;
321 } while (isset($data['toOffer'], $data['totalOffersCount']) && $data['toOffer'] !== $data['totalOffersCount']);
322
323 return $offers;
324 }
325}