From: Vladimir Fomichev Date: Mon, 19 Jan 2026 13:37:11 +0000 (+0300) Subject: корректировка логики X-Git-Url: https://gitweb.erp-flowers.ru/?a=commitdiff_plain;h=5084caa6aba29141a0794d7aa0d3b1775b35e192;p=erp24_rep%2Fyii-erp24%2F.git корректировка логики --- diff --git a/erp24/records/MarketplaceOrder1cStatuses.php b/erp24/records/MarketplaceOrder1cStatuses.php index b013fe5d..71482ec7 100644 --- a/erp24/records/MarketplaceOrder1cStatuses.php +++ b/erp24/records/MarketplaceOrder1cStatuses.php @@ -145,4 +145,59 @@ class MarketplaceOrder1cStatuses extends \yii\db\ActiveRecord { return $this->hasOne(MarketplaceOrderStatusTypes::class, ['id' => 'order_substatus_id']); } + + /** + * Получает ID статусов "Успех" (successful_order = 1) + * + * @return int[] + */ + public static function getSuccessfulOrderIds(): array + { + return self::find() + ->select('id') + ->where(['successful_order' => 1]) + ->column(); + } + + /** + * Получает ID статусов "Отказ" (cancelled_order = 1) + * + * @return int[] + */ + public static function getCancelledOrderIds(): array + { + return self::find() + ->select('id') + ->where(['cancelled_order' => 1]) + ->column(); + } + + /** + * Получает ID статусов "Передан курьеру" по связи с МП-статусами DELIVERY/COURIER_RECEIVED + * + * @return int[] + */ + public static function getCourierOrderIds(): array + { + // Получаем ID МП-статусов доставки + $deliveryStatusIds = MarketplaceOrderStatusTypes::find() + ->select('id') + ->where(['code' => [ + MarketplaceOrderStatusTypes::DELIVERY_CODE, + MarketplaceOrderStatusTypes::COURIER_RECEIVED_CODE, + ]]) + ->column(); + + if (empty($deliveryStatusIds)) { + return []; + } + + return self::find() + ->select('id') + ->where(['or', + ['order_status_id' => $deliveryStatusIds], + ['order_substatus_id' => $deliveryStatusIds], + ]) + ->column(); + } } diff --git a/erp24/services/OrderControlReportService.php b/erp24/services/OrderControlReportService.php index 7e702fed..7ef95e33 100644 --- a/erp24/services/OrderControlReportService.php +++ b/erp24/services/OrderControlReportService.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace yii_app\services; use Yii; +use yii_app\records\MarketplaceOrder1cStatuses; use yii_app\records\MarketplaceOrderStatusTypes; use yii_app\records\MarketplaceOrderDailyIssues; use yii_app\services\dto\OrderIssue; @@ -59,22 +60,61 @@ class OrderControlReportService private const TELEGRAM_BOT_PROD = '5456741805:AAG7xOSiYDwUdV5NMb2v9vh8CWzEczDP4yU'; /** - * Статусы 1С для проверок + * Конфигурация отчёта */ - private const RMK_STATUS_COURIER = ['1004', '1011']; // "Передан курьеру" - private const RMK_STATUS_SUCCESS = ['1005', '1012']; // "Успех" - private const RMK_STATUS_CANCEL = ['1006', '1013']; // "Отказ" + private array $config; /** - * Конфигурация отчёта + * Кеш ID статусов 1С (ленивая загрузка) */ - private array $config; + private ?array $rmkStatusCourier = null; + private ?array $rmkStatusSuccess = null; + private ?array $rmkStatusCancel = null; public function __construct() { $this->config = Yii::$app->params['MARKETPLACE_ORDER_CONTROL_REPORT'] ?? []; } + /** + * Получает ID статусов 1С "Передан курьеру" (с кешированием) + * + * @return int[] + */ + private function getRmkStatusCourier(): array + { + if ($this->rmkStatusCourier === null) { + $this->rmkStatusCourier = MarketplaceOrder1cStatuses::getCourierOrderIds(); + } + return $this->rmkStatusCourier; + } + + /** + * Получает ID статусов 1С "Успех" (successful_order = 1) с кешированием + * + * @return int[] + */ + private function getRmkStatusSuccess(): array + { + if ($this->rmkStatusSuccess === null) { + $this->rmkStatusSuccess = MarketplaceOrder1cStatuses::getSuccessfulOrderIds(); + } + return $this->rmkStatusSuccess; + } + + /** + * Получает ID статусов 1С "Отказ" (cancelled_order = 1) с кешированием + * + * @return int[] + */ + private function getRmkStatusCancel(): array + { + if ($this->rmkStatusCancel === null) { + $this->rmkStatusCancel = MarketplaceOrder1cStatuses::getCancelledOrderIds(); + } + return $this->rmkStatusCancel; + } + /** * Генерирует отчёт контроля статусов заказов МП * @@ -82,7 +122,7 @@ class OrderControlReportService * @param bool $onlyNew Отправлять только новые проблемы * @return ControlReportResult */ - public function generateControlReport(int $hoursAgo = 24, bool $onlyNew = true): ControlReportResult + public function generateControlReport(int $hoursAgo = 12, bool $onlyNew = true): ControlReportResult { $result = new ControlReportResult(); @@ -175,9 +215,10 @@ class OrderControlReportService { $this->logInfo('Выборка кандидатов "Завис в доставке"', ['hours_ago' => $hoursAgo]); - $startDate = new \DateTime('now', new \DateTimeZone(self::TIMEZONE)); - $startDate->modify("-{$hoursAgo} hours"); - $startDateStr = $startDate->format('Y-m-d H:i:s'); + // Получаем диапазон дат на основе конца смены + $dateRange = $this->getShiftBasedDateRange($hoursAgo); + $startDateStr = $dateRange['startDate']; + $endDateStr = $dateRange['endDate']; // Выбираем заказы с РМК-статусом "Передан курьеру", где МП-статус НЕ "Выполнен" $sql = " @@ -194,7 +235,7 @@ class OrderControlReportService mocs.status as rmk_status, most.code as mp_status_code, mosub.code as mp_substatus_code, - most.name as mp_status_name + COALESCE(most.name, mosub.name) as mp_status_name FROM marketplace_orders mo LEFT JOIN city_store cs ON cs.id = mo.store_id LEFT JOIN marketplace_order_1c_statuses mocs ON mocs.id = mo.status_processing_1c::integer @@ -203,6 +244,7 @@ class OrderControlReportService WHERE mo.fake = 0 AND mo.status_processing_1c::integer IN (:rmk_1004, :rmk_1011) AND mo.updated_at >= :start_date + AND mo.updated_at <= :end_date AND ( most.code IS NULL OR (most.code != :delivered AND mosub.code IS DISTINCT FROM :delivery_service_delivered) @@ -214,6 +256,7 @@ class OrderControlReportService ':rmk_1004' => 1004, ':rmk_1011' => 1011, ':start_date' => $startDateStr, + ':end_date' => $endDateStr, ':delivered' => MarketplaceOrderStatusTypes::DELIVERED_CODE, ':delivery_service_delivered' => MarketplaceOrderStatusTypes::DELIVERY_SERVICE_DELIVERED_CODE, ])->queryAll(); @@ -302,7 +345,7 @@ class OrderControlReportService 'report_date' => $prevDate, 'interval' => $prevInterval, ]) - ->andWhere(['rmk_status_id' => self::RMK_STATUS_COURIER]) + ->andWhere(['rmk_status_id' => $this->getRmkStatusCourier()]) ->indexBy('order_id') ->asArray() ->all(); @@ -334,20 +377,47 @@ class OrderControlReportService * Получает заказы типа "Успех без чека" * * Критерий: МП статус = "Выполнен" (DELIVERED или DELIVERY_SERVICE_DELIVERED) - * + РМК статус НЕ "Успех" (НЕ 1005/1012) + * + (seller_id пустой/нулевой ИЛИ чек не создан в create_checks) * - * @param int $hoursAgo Период выборки в часах (по умолчанию 24) + * Причина проблемы: + * - Если seller_id пустой или '00000000-0000-0000-0000-000000000000' — чек не создаётся + * - Если чек не создан — 1С не получает сигнала о доставке заказа + * + * @see MarketplaceService::createCheckForMarketplaceOrder() — логика создания чека + * + * @param int $hoursAgo Период выборки в часах (по умолчанию 12) * @return OrderIssue[] */ - public function getSuccessNoCheckOrders(int $hoursAgo = 24): array + public function getSuccessNoCheckOrders(int $hoursAgo = 12): array { $this->logInfo('Выборка заказов "Успех без чека"', ['hours_ago' => $hoursAgo]); - $startDate = new \DateTime('now', new \DateTimeZone(self::TIMEZONE)); - $startDate->modify("-{$hoursAgo} hours"); - $startDateStr = $startDate->format('Y-m-d H:i:s'); + // Получаем диапазон дат на основе конца смены + $dateRange = $this->getShiftBasedDateRange($hoursAgo); + $startDateStr = $dateRange['startDate']; + $endDateStr = $dateRange['endDate']; + + // Нулевой GUID — признак отсутствия продавца + $emptySellerGuid = '00000000-0000-0000-0000-000000000000'; - // Выбираем заказы с МП-статусом "Выполнен", где РМК-статус НЕ "Успех" + // Получаем ID статусов "Успех" из БД + $rmkSuccessIds = $this->getRmkStatusSuccess(); + + // Формируем плейсхолдеры для IN-условия + $rmkSuccessPlaceholders = []; + $rmkSuccessParams = []; + foreach ($rmkSuccessIds as $index => $id) { + $placeholder = ':rmk_success_' . $index; + $rmkSuccessPlaceholders[] = $placeholder; + $rmkSuccessParams[$placeholder] = $id; + } + $rmkSuccessInClause = !empty($rmkSuccessPlaceholders) + ? implode(', ', $rmkSuccessPlaceholders) + : '0'; // fallback если статусов нет + + // Выбираем заказы с МП-статусом "Выполнен", где: + // 1. РМК-статус НЕ "Успех" (1С не знает о доставке) + // 2. Причина: seller_id пустой или нулевой GUID, ИЛИ чек не создан в create_checks $sql = " SELECT mo.id, @@ -358,43 +428,73 @@ class OrderControlReportService mo.marketplace_id, mo.total, mo.creation_date, + mo.seller_id, mo.status_processing_1c as rmk_status_id, mocs.status as rmk_status, most.code as mp_status_code, mosub.code as mp_substatus_code, - most.name as mp_status_name + COALESCE(most.name, mosub.name) as mp_status_name, + cc.id as check_id, + CASE + WHEN cc.id IS NOT NULL THEN true + ELSE false + END as check_exists, + CASE + WHEN mo.seller_id IS NULL OR mo.seller_id = '' OR mo.seller_id = :empty_seller_guid + THEN 'no_seller_id' + WHEN cc.id IS NULL + THEN 'no_check' + ELSE 'unknown' + END as issue_reason FROM marketplace_orders mo LEFT JOIN city_store cs ON cs.id = mo.store_id LEFT JOIN marketplace_order_1c_statuses mocs ON mocs.id = mo.status_processing_1c::integer LEFT JOIN marketplace_order_status_types most ON most.id = mo.status_id LEFT JOIN marketplace_order_status_types mosub ON mosub.id = mo.substatus_id + LEFT JOIN create_checks cc ON cc.marketplace_order_id = mo.marketplace_order_id WHERE mo.fake = 0 AND mo.updated_at >= :start_date + AND mo.updated_at <= :end_date + -- МП-статус = Выполнен (DELIVERED) AND ( most.code = :delivered OR mosub.code = :delivery_service_delivered ) + -- РМК-статус НЕ Успех (1С не знает о доставке) AND ( mo.status_processing_1c IS NULL - OR mo.status_processing_1c::integer NOT IN (:rmk_1005, :rmk_1012) + OR mo.status_processing_1c::integer NOT IN ({$rmkSuccessInClause}) + ) + -- Причина: seller_id пустой ИЛИ чек не создан + AND ( + mo.seller_id IS NULL + OR mo.seller_id = '' + OR mo.seller_id = :empty_seller_guid + OR cc.id IS NULL ) ORDER BY cs.name ASC, mo.creation_date DESC "; - $orders = Yii::$app->db->createCommand($sql, [ + $params = array_merge([ ':start_date' => $startDateStr, + ':end_date' => $endDateStr, ':delivered' => MarketplaceOrderStatusTypes::DELIVERED_CODE, ':delivery_service_delivered' => MarketplaceOrderStatusTypes::DELIVERY_SERVICE_DELIVERED_CODE, - ':rmk_1005' => 1005, - ':rmk_1012' => 1012, - ])->queryAll(); + ':empty_seller_guid' => $emptySellerGuid, + ], $rmkSuccessParams); + + $orders = Yii::$app->db->createCommand($sql, $params)->queryAll(); $issues = []; foreach ($orders as $orderData) { $issues[] = OrderIssue::fromOrderData(OrderIssue::TYPE_SUCCESS_NO_CHECK, $orderData); } - $this->logInfo('Найдено "Успех без чека"', ['count' => count($issues)]); + $this->logInfo('Найдено "Успех без чека"', [ + 'count' => count($issues), + 'no_seller_id' => count(array_filter($orders, fn($o) => $o['issue_reason'] === 'no_seller_id')), + 'no_check' => count(array_filter($orders, fn($o) => $o['issue_reason'] === 'no_check')), + ]); return $issues; } @@ -412,9 +512,10 @@ class OrderControlReportService { $this->logInfo('Выборка заказов "Отмена без обработки"', ['hours_ago' => $hoursAgo]); - $startDate = new \DateTime('now', new \DateTimeZone(self::TIMEZONE)); - $startDate->modify("-{$hoursAgo} hours"); - $startDateStr = $startDate->format('Y-m-d H:i:s'); + // Получаем диапазон дат на основе конца смены + $dateRange = $this->getShiftBasedDateRange($hoursAgo); + $startDateStr = $dateRange['startDate']; + $endDateStr = $dateRange['endDate']; // Выбираем заказы с МП-статусом "Отменён", где РМК-статус НЕ "Отказ" $sql = " @@ -431,7 +532,7 @@ class OrderControlReportService mocs.status as rmk_status, most.code as mp_status_code, mosub.code as mp_substatus_code, - most.name as mp_status_name + COALESCE(most.name, mosub.name) as mp_status_name FROM marketplace_orders mo LEFT JOIN city_store cs ON cs.id = mo.store_id LEFT JOIN marketplace_order_1c_statuses mocs ON mocs.id = mo.status_processing_1c::integer @@ -439,6 +540,7 @@ class OrderControlReportService LEFT JOIN marketplace_order_status_types mosub ON mosub.id = mo.substatus_id WHERE mo.fake = 0 AND mo.updated_at >= :start_date + AND mo.updated_at <= :end_date AND most.code = :cancelled AND ( mo.status_processing_1c IS NULL @@ -449,6 +551,7 @@ class OrderControlReportService $orders = Yii::$app->db->createCommand($sql, [ ':start_date' => $startDateStr, + ':end_date' => $endDateStr, ':cancelled' => MarketplaceOrderStatusTypes::CANSELLED_CODE, ':rmk_1006' => 1006, ':rmk_1013' => 1013, @@ -573,71 +676,78 @@ class OrderControlReportService /** * Формирует текстовый отчёт контроля статусов для Telegram (MarkdownV2) * + * Использует моноширинный блок для корректного отображения таблицы. + * * @param ControlReportResult $result Результат отчёта * @return string Текст сообщения */ public function formatTelegramControlReport(ControlReportResult $result): string { $lines = []; - $lines[] = $this->escapeMarkdownV2('[Контроль MP]') . ' *Отчёт за ' . $this->escapeMarkdownV2($result->reportDate) . ' ' . $this->escapeMarkdownV2($result->interval) . '*'; + $lines[] = '*\[Контроль MP\]* Отчёт за ' . $this->escapeMarkdownV2($result->reportDate) . ' ' . $this->escapeMarkdownV2($result->interval); $lines[] = ''; // Секция "Завис в доставке" if (!empty($result->hungInDelivery)) { - $lines[] = '*' . $this->escapeMarkdownV2('Завис в доставке') . '*'; - $lines[] = $this->escapeMarkdownV2('| Дата | Интервал | Заказ | РМК | МП'); - - foreach ($result->hungInDelivery as $issue) { - $line = $this->formatIssueLineForTelegram($issue); - $lines[] = $this->escapeMarkdownV2($line); - } + $lines[] = '*Завис в доставке* \\(' . count($result->hungInDelivery) . '\\)'; + $lines[] = $this->formatIssuesTable($result->hungInDelivery); $lines[] = ''; } // Секция "Успех без чека" if (!empty($result->successNoCheck)) { - $lines[] = '*' . $this->escapeMarkdownV2('Успех без чека') . '*'; - $lines[] = $this->escapeMarkdownV2('| Дата | Интервал | Заказ | РМК | МП'); - - foreach ($result->successNoCheck as $issue) { - $line = $this->formatIssueLineForTelegram($issue); - $lines[] = $this->escapeMarkdownV2($line); - } + $lines[] = '*Успех без чека* \\(' . count($result->successNoCheck) . '\\)'; + $lines[] = $this->formatIssuesTable($result->successNoCheck); $lines[] = ''; } // Секция "Отмена без обработки" if (!empty($result->cancelNoProcess)) { - $lines[] = '*' . $this->escapeMarkdownV2('Отмена без обработки') . '*'; - $lines[] = $this->escapeMarkdownV2('| Дата | Интервал | Заказ | РМК | МП'); - - foreach ($result->cancelNoProcess as $issue) { - $line = $this->formatIssueLineForTelegram($issue); - $lines[] = $this->escapeMarkdownV2($line); - } + $lines[] = '*Отмена без обработки* \\(' . count($result->cancelNoProcess) . '\\)'; + $lines[] = $this->formatIssuesTable($result->cancelNoProcess); $lines[] = ''; } + $lines[] = '*Всего:* ' . $this->escapeMarkdownV2((string)$result->totalIssues); + return implode("\n", $lines); } /** - * Форматирует строку проблемы для Telegram + * Форматирует таблицу проблем для Telegram (моноширинный блок) + * + * @param OrderIssue[] $issues + * @return string + */ + private function formatIssuesTable(array $issues): string + { + $rows = []; + $rows[] = '```'; + + foreach ($issues as $issue) { + $rows[] = $this->formatIssueRow($issue); + } + + $rows[] = '```'; + + return implode("\n", $rows); + } + + /** + * Форматирует строку таблицы для проблемы + * + * Формат: Заказ | РМК | МП * * @param OrderIssue $issue * @return string */ - private function formatIssueLineForTelegram(OrderIssue $issue): string + private function formatIssueRow(OrderIssue $issue): string { - $date = $issue->reportDate ?: date('d.m.Y'); - $interval = $issue->interval ?: ((int)date('H') < 12 ? '08:00' : '20:00'); $rmk = $issue->rmkStatus ?? '-'; $mp = $issue->mpStatus ?? '-'; return sprintf( - '| %s | %s | %s | %s | %s', - $date, - $interval, + '%s | %s | %s', $issue->orderNumber, $rmk, $mp @@ -1067,6 +1177,54 @@ class OrderControlReportService return $recipients; } + /** + * Вычисляет диапазон дат на основе конца смены + * + * Логика: + * - Если текущий час < 20, база = 08:00 сегодня (конец утренней смены) + * - Если текущий час >= 20, база = 20:00 сегодня (конец вечерней смены) + * - startDate = база - $hoursAgo часов + * - endDate = база + * + * Пример: + * - 15:00, hoursAgo=12 → база 08:00 → диапазон: вчера 20:00 - сегодня 08:00 + * - 22:00, hoursAgo=12 → база 20:00 → диапазон: сегодня 08:00 - сегодня 20:00 + * + * @param int $hoursAgo Количество часов назад от конца смены + * @return array{startDate: string, endDate: string} Массив с датами в формате 'Y-m-d H:i:s' + */ + private function getShiftBasedDateRange(int $hoursAgo): array + { + $now = new \DateTime('now', new \DateTimeZone(self::TIMEZONE)); + $currentHour = (int)$now->format('H'); + + // Определяем конец текущей смены (база для расчёта) + $shiftEnd = clone $now; + if ($currentHour < 20) { + // До 20:00 — база 08:00 сегодня + $shiftEnd->setTime(8, 0, 0); + } else { + // После 20:00 — база 20:00 сегодня + $shiftEnd->setTime(20, 0, 0); + } + + // Вычисляем начало периода + $shiftStart = clone $shiftEnd; + $shiftStart->modify("-{$hoursAgo} hours"); + + $this->logInfo('Вычислен диапазон дат на основе смены', [ + 'current_time' => $now->format('Y-m-d H:i:s'), + 'shift_end' => $shiftEnd->format('Y-m-d H:i:s'), + 'shift_start' => $shiftStart->format('Y-m-d H:i:s'), + 'hours_ago' => $hoursAgo, + ]); + + return [ + 'startDate' => $shiftStart->format('Y-m-d H:i:s'), + 'endDate' => $shiftEnd->format('Y-m-d H:i:s'), + ]; + } + /** * Логирование в структурированном JSON-формате * diff --git a/erp24/services/dto/OrderIssue.php b/erp24/services/dto/OrderIssue.php index c938157f..51166277 100644 --- a/erp24/services/dto/OrderIssue.php +++ b/erp24/services/dto/OrderIssue.php @@ -121,6 +121,30 @@ class OrderIssue */ public ?string $creationDate; + /** + * ID продавца (seller_id) + */ + public ?string $sellerId; + + /** + * Существует ли чек в create_checks + */ + public bool $checkExists = false; + + /** + * Причина проблемы (no_seller_id, no_check) + */ + public ?string $issueReason; + + /** + * Человекочитаемые метки причин + */ + public const ISSUE_REASON_LABELS = [ + 'no_seller_id' => 'Нет seller_id', + 'no_check' => 'Чек не создан', + 'unknown' => 'Неизвестно', + ]; + /** * Конструктор с параметрами * @@ -172,9 +196,20 @@ class OrderIssue $issue->rmkStatus = $orderData['rmk_status'] ?? null; $issue->rmkStatusId = isset($orderData['rmk_status_id']) ? (string)$orderData['rmk_status_id'] : null; - $issue->mpStatus = $orderData['mp_status_name'] ?? null; $issue->mpStatusCode = $orderData['mp_status_code'] ?? null; $issue->mpSubstatusCode = $orderData['mp_substatus_code'] ?? null; + + // МП статус: приоритет — mp_status_name, затем формируем из кодов + $mpStatusName = $orderData['mp_status_name'] ?? null; + if ($mpStatusName) { + $issue->mpStatus = $mpStatusName; + } elseif ($issue->mpStatusCode || $issue->mpSubstatusCode) { + // Если название пустое, но есть коды — показываем коды + $codes = array_filter([$issue->mpStatusCode, $issue->mpSubstatusCode]); + $issue->mpStatus = implode('/', $codes); + } else { + $issue->mpStatus = null; + } $issue->storeId = isset($orderData['store_id']) ? (int)$orderData['store_id'] : null; $issue->storeName = $orderData['store_name'] ?? null; $issue->marketplaceName = $orderData['marketplace_name'] ?? null; @@ -182,6 +217,11 @@ class OrderIssue $issue->total = (float)($orderData['total'] ?? 0); $issue->creationDate = $orderData['creation_date'] ?? null; + // Поля для диагностики "Успех без чека" + $issue->sellerId = $orderData['seller_id'] ?? null; + $issue->checkExists = (bool)($orderData['check_exists'] ?? false); + $issue->issueReason = $orderData['issue_reason'] ?? null; + return $issue; } @@ -210,6 +250,10 @@ class OrderIssue 'marketplace_id' => $this->marketplaceId, 'total' => $this->total, 'creation_date' => $this->creationDate, + 'seller_id' => $this->sellerId, + 'check_exists' => $this->checkExists, + 'issue_reason' => $this->issueReason, + 'issue_reason_label' => $this->getIssueReasonLabel(), ]; } @@ -246,4 +290,18 @@ class OrderIssue { return number_format($this->total, 0, '.', ' ') . ' ₽'; } + + /** + * Получает человекочитаемую метку причины проблемы + * + * @return string|null + */ + public function getIssueReasonLabel(): ?string + { + if ($this->issueReason === null) { + return null; + } + + return self::ISSUE_REASON_LABELS[$this->issueReason] ?? $this->issueReason; + } }