--- /dev/null
+<?php
+declare(strict_types = 1);
+
+namespace yii_app\actions\dashboard;
+
+use Yii;
+use yii\base\Action;
+use yii\helpers\ArrayHelper;
+use yii\widgets\ActiveForm;
+use yii_app\forms\dashboard\DaysSearchForm;
+use yii_app\records\CityStore;
+use yii_app\records\Products1c;
+use yii_app\services\ExportImportService;
+use yii_app\services\MarketplaceSalesMatchingService;
+
+/**
+ * Отчет по сопоставлению заказов маркетплейсов с чеками продаж
+ *
+ * @package yii_app\actions\dashboard
+ */
+class MarketplaceSalesReportAction extends Action
+{
+ /**
+ * @throws \Exception
+ */
+ public function run()
+ {
+ $request = Yii::$app->getRequest();
+
+ $date1 = date("Y-m-d", time());
+ $date2 = date("Y-m-d", time());
+
+ $daysSearchForm = new DaysSearchForm();
+
+ if (Yii::$app->request->isAjax && $daysSearchForm->load(Yii::$app->request->post())) {
+ Yii::$app->response->format = \yii\web\Response::FORMAT_JSON;
+ return ActiveForm::validate($daysSearchForm);
+ }
+
+ if ($request->isPost && !empty($this->controller->request->post())) {
+ $daysSearchForm->load($this->controller->request->post());
+ $daysSearchForm->validate();
+ }
+
+ if (empty($daysSearchForm->dateFrom)) {
+ $daysSearchForm->dateFrom = $date1;
+ }
+
+ if (empty($daysSearchForm->dateTo)) {
+ $daysSearchForm->dateTo = $date2;
+ }
+
+ $getDate1 = $daysSearchForm->dateFrom;
+
+ if (!empty($getDate1)) {
+ $date_s = htmlentities($getDate1);
+ $date1 = "$date_s";
+ $date2 = "$date_s";
+ }
+
+ $getDate2 = $daysSearchForm->dateTo;
+
+ if (!empty($getDate2)) {
+ $date_s = htmlentities($getDate2);
+ $date2 = "$date_s";
+ }
+
+ $entityByCityStore = ExportImportService::getEntityByCityStore();
+ $exportData = ExportImportService::getExportData($entityByCityStore);
+
+ $export = ArrayHelper::getValue($exportData, 'export');
+ $export_revers = ArrayHelper::getValue($exportData, 'export_revers');
+
+ $storeIds = array_values($export_revers);
+
+ $cityStores = CityStore::find()
+ ->select(['id', 'name', 'export_val'])
+ ->joinWith('storeGuid')
+ ->andWhere(['id' => $storeIds])
+ ->indexBy('id')
+ ->asArray()
+ ->all();
+
+ $city_stores = Products1c::find()
+ ->select(['name', 'id'])
+ ->indexBy('id')
+ ->andWhere('tip=:tip', [':tip' => 'city_store'])
+ ->column();
+
+ // Получение заказов МП и чеков продаж
+ $matchingService = new MarketplaceSalesMatchingService();
+
+ $marketplaceOrders = $matchingService->getMarketplaceOrdersForPeriod($date1, $date2);
+
+ $sales = $matchingService->getSalesForPeriod($date1, $date2);
+
+ // Сопоставление заказов с чеками
+ $matchedData = $matchingService->matchOrdersWithSales($marketplaceOrders, $sales);
+
+ // Группировка по магазинам
+ $marketplaceOrdersByStore = $this->groupOrdersByStore($matchedData);
+ $params = [
+ 'date1' => $date1,
+ 'date2' => $date2,
+ 'daysSearchForm' => $daysSearchForm,
+ 'cityStores' => $cityStores,
+ 'city_stores' => $city_stores,
+ 'export_revers' => $export_revers,
+ 'marketplaceOrdersByStore' => $marketplaceOrdersByStore,
+ ];
+
+ return $this->controller->render('/dashboard/marketplace-sales-report.php', $params);
+ }
+
+ /**
+ * Группировка заказов по магазинам
+ *
+ * @param array $matchedData
+ * @return array
+ */
+ private function groupOrdersByStore(array $matchedData): array
+ {
+ $grouped = [];
+
+ foreach ($matchedData as $orderId => $orderData) {
+ $order = $orderData['order'];
+
+ // Получаем store_id_1c через связь с магазином
+ $storeId1c = null;
+ if ($order->store && $order->store->storeGuid) {
+ $storeId1c = $order->store->storeGuid->export_val;
+ }
+
+ // Если не удалось получить store_id_1c, пропускаем заказ
+ if (empty($storeId1c)) {
+ continue;
+ }
+
+ if (!isset($grouped[$storeId1c])) {
+ $grouped[$storeId1c] = [
+ 'store_id_1c' => $storeId1c,
+ 'orders' => [],
+ 'total_orders' => 0,
+ 'total_matched' => 0,
+ 'total_unmatched' => 0,
+ 'total_summ' => 0,
+ ];
+ }
+
+ $grouped[$storeId1c]['orders'][$orderId] = $orderData;
+ $grouped[$storeId1c]['total_orders']++;
+ $grouped[$storeId1c]['total_summ'] += $order->total;
+
+ if (!empty($orderData['matched_sales'])) {
+ $grouped[$storeId1c]['total_matched']++;
+ } else {
+ $grouped[$storeId1c]['total_unmatched']++;
+ }
+ }
+
+ // Сортировка по сумме (убывание)
+ uasort($grouped, function ($a, $b) {
+ return $b['total_summ'] <=> $a['total_summ'];
+ });
+
+ return $grouped;
+ }
+}
+
'index' => \yii_app\actions\dashboard\IndexAction::class,
'sales' => \yii_app\actions\dashboard\SalesAction::class,
'sales-detail' => \yii_app\actions\dashboard\SalesDetailAction::class,
+ 'marketplace-sales-report' => \yii_app\actions\dashboard\MarketplaceSalesReportAction::class,
'commercial' => \yii_app\actions\dashboard\CommercialAction::class,
'commercial-detail-info' => \yii_app\actions\dashboard\CommercialDetailInfoAction::class,
'commercial-sales-info' => \yii_app\actions\dashboard\CommercialSalesInfoAction::class,
{
const CANSELLED_CODE = 'CANCELLED';
const READY_CODE = 'READY_TO_SHIP';
+ const DELIVERED_CODE = 'DELIVERED';
+ const DELIVERY_SERVICE_DELIVERED_CODE = 'DELIVERY_SERVICE_DELIVERED';
public function behaviors()
{
--- /dev/null
+<?php
+
+namespace yii_app\services;
+
+use Yii;
+use yii\helpers\ArrayHelper;
+use yii_app\helpers\DateHelper;
+use yii_app\records\MarketplaceOrders;
+use yii_app\records\MarketplaceOrderItems;
+use yii_app\records\MarketplaceOrderStatusHistory;
+use yii_app\records\MarketplaceOrderStatusTypes;
+use yii_app\records\Sales;
+use yii_app\records\SalesProducts;
+use yii_app\records\Products1c;
+use yii_app\records\Products1cOptions;
+
+/**
+ * Сервис для сопоставления заказов с маркетплейсов с чеками продаж
+ *
+ * @package yii_app\services
+ */
+class MarketplaceSalesMatchingService
+{
+ /**
+ * Получить заказы МП за период
+ *
+ * Учитывает дату завершения заказа из истории статусов (MarketplaceOrderStatusHistory)
+ * Фильтрует по суб-статусу DELIVERY_SERVICE_DELIVERED (гарантированно завершенные заказы)
+ *
+ * @param string $dateFrom
+ * @param string $dateTo
+ * @param int|null $storeId
+ * @param int|null $marketplaceId
+ * @return MarketplaceOrders[]
+ * @throws \Exception
+ */
+ public function getMarketplaceOrdersForPeriod(
+ string $dateFrom,
+ string $dateTo,
+ ?int $storeId = null,
+ ?int $marketplaceId = null
+ ): array {
+ // Получаем ID статусов для фильтрации
+ $deliveredStatus = MarketplaceOrderStatusTypes::findOne([
+ 'code' => MarketplaceOrderStatusTypes::DELIVERED_CODE
+ ]);
+
+ $deliveryServiceDeliveredSubstatus = MarketplaceOrderStatusTypes::findOne([
+ 'code' => MarketplaceOrderStatusTypes::DELIVERY_SERVICE_DELIVERED_CODE
+ ]);
+
+ if (!$deliveredStatus || !$deliveryServiceDeliveredSubstatus) {
+ Yii::warning('Не найдены статусы DELIVERED или DELIVERY_SERVICE_DELIVERED');
+ return [];
+ }
+
+ // Основная логика: получаем заказы, у которых дата завершения (из истории) в нужном диапазоне
+ $tableName = MarketplaceOrders::tableName();
+ $historyTableName = MarketplaceOrderStatusHistory::tableName();
+
+ $query = MarketplaceOrders::find()
+ ->with(['items', 'store', 'store.storeGuid', 'status', 'substatus'])
+ ->innerJoin(
+ "$historyTableName mosh",
+ "$tableName.id = mosh.order_id"
+ )
+ ->andWhere(["$tableName.fake" => 0]) // Только реальные заказы
+ ->andWhere(["$tableName.status_id" => $deliveredStatus->id]) // Статус DELIVERED
+ ->andWhere(["$tableName.substatus_id" => $deliveryServiceDeliveredSubstatus->id]) // Суб-статус DELIVERY_SERVICE_DELIVERED
+ ->andWhere(['mosh.active' => 1]) // Активная запись в истории
+ ->andWhere(['>=', 'mosh.date_from', DateHelper::getDateTimeStartDay($dateFrom, true)])
+ ->andWhere(['<=', 'mosh.date_from', DateHelper::getDateTimeEndDay($dateTo, true)])
+ ->orderBy(['mosh.date_from' => SORT_DESC, "$tableName.id" => SORT_DESC]);
+
+ if (!empty($storeId)) {
+ $query->andWhere(["$tableName.store_id" => $storeId]);
+ }
+
+ if (!empty($marketplaceId)) {
+ $query->andWhere(["$tableName.marketplace_id" => $marketplaceId]);
+ }
+
+ return $query->all();
+ }
+
+ /**
+ * Получить чеки продаж за период (+ 3 дня)
+ *
+ * @param string $dateFrom
+ * @param string $dateTo
+ * @param int|null $storeId
+ * @return Sales[]
+ * @throws \Exception
+ */
+ public function getSalesForPeriod(
+ string $dateFrom,
+ string $dateTo,
+ ?int $storeId = null
+ ): array {
+ // Расширяем период на 3 дня для поиска чеков, которые могли поступить позже
+ $dateToExtended = date('Y-m-d', strtotime($dateTo) + 3 * 86400);
+
+ $query = Sales::find()
+ ->with(['products', 'store'])
+ ->andWhere(['>=', 'date', DateHelper::getDateTimeStartDay($dateFrom, true)])
+ ->andWhere(['<=', 'date', DateHelper::getDateTimeEndDay($dateToExtended, true)])
+ ->andWhere(['operation' => Sales::OPERATION_SALE])
+ ->orderBy(['date' => SORT_DESC, 'id' => SORT_DESC]);
+
+ if (!empty($storeId)) {
+ $query->andWhere(['store_id' => $storeId]);
+ }
+
+ return $query->all();
+ }
+
+ /**
+ * Сопоставить заказы МП с чеками продаж
+ *
+ * @param array $orders
+ * @param array $sales
+ * @return array
+ */
+ public function matchOrdersWithSales(array $orders, array $sales): array
+ {
+ $result = [];
+ $productsCache = $this->buildProductsCache();
+
+ foreach ($orders as $order) {
+ // Гарантируем загрузку статусов
+ $statusName = 'Неизвестный статус';
+ $substatusName = 'Неизвестный суб-статус';
+
+ // Пытаемся получить статус
+ if (!empty($order->status_id)) {
+ // Сначала проверяем загруженную связь
+ $status = $order->isRelationPopulated('status') ? $order->status : null;
+
+ // Если не загружена, загружаем вручную
+ if (!$status) {
+ $status = MarketplaceOrderStatusTypes::findOne(['id' => $order->status_id]);
+ if ($status) {
+ $order->populateRelation('status', $status);
+ Yii::warning("Статус загружен вручную для заказа #{$order->id}: {$status->name}", __METHOD__);
+ } else {
+ Yii::error("Не найден статус с ID={$order->status_id} для заказа #{$order->id}", __METHOD__);
+ }
+ }
+
+ if ($status && !empty($status->name)) {
+ $statusName = $status->name;
+ } else {
+ $statusName = $status->code;
+ }
+
+ }
+
+ // Пытаемся получить суб-статус
+ if (!empty($order->substatus_id)) {
+ // Сначала проверяем загруженную связь
+ $substatus = $order->isRelationPopulated('substatus') ? $order->substatus : null;
+
+ // Если не загружена, загружаем вручную
+ if (!$substatus) {
+ $substatus = MarketplaceOrderStatusTypes::findOne(['id' => $order->substatus_id]);
+ if ($substatus) {
+ $order->populateRelation('substatus', $substatus);
+ Yii::warning("Суб-статус загружен вручную для заказа #{$order->id}: {$substatus->name}", __METHOD__);
+ } else {
+ Yii::error("Не найден суб-статус с ID={$order->substatus_id} для заказа #{$order->id}", __METHOD__);
+ }
+ }
+
+ if ($substatus && !empty($substatus->name)) {
+ $substatusName = $substatus->name;
+ } else {
+ $substatusName = $substatus->code;
+ }
+ }
+
+ $orderData = [
+ 'order' => $order,
+ 'order_items' => $this->enrichOrderItems($order->items, $productsCache),
+ 'matched_sales' => [],
+ 'status_name' => $statusName,
+ 'substatus_name' => $substatusName,
+ ];
+
+ // Получаем сумму товаров в заказе (без доставки, только товары)
+ $orderTotalSum = (float)$order->total;
+
+ // Ищем подходящие чеки
+ foreach ($sales as $sale) {
+ $saleSum = (float)($sale->summ - $sale->skidka);
+
+ // Сравниваем суммы (точное совпадение)
+ if (abs($saleSum - $orderTotalSum) < 0.01) {
+ // Получаем товары в чеке
+ $saleProducts = $sale->products ?? [];
+ $enrichedSaleProducts = $this->enrichSalesProducts($saleProducts, $productsCache);
+
+ // Сравниваем товары
+ $productMatch = $this->compareOrderProducts(
+ $order->items,
+ $saleProducts,
+ $productsCache
+ );
+
+ if ($productMatch['match_percent'] >= 80) {
+ $orderData['matched_sales'][] = [
+ 'sale' => $sale,
+ 'sale_products' => $enrichedSaleProducts,
+ 'match_score' => $this->calculateMatchScore(
+ $order,
+ $sale,
+ $productMatch
+ ),
+ 'sum_diff' => $saleSum - $orderTotalSum,
+ 'products_match_percent' => $productMatch['match_percent'],
+ 'products_match_details' => $productMatch['details'],
+ ];
+ }
+ }
+ }
+
+ // ОТЛАДКА перед добавлением в результат
+ Yii::info("OrderData for order #{$order->id}: Keys=" . implode(', ', array_keys($orderData)) . ", status_name={$statusName}, substatus_name={$substatusName}", __METHOD__);
+
+ $result[$order->id] = $orderData;
+
+ // Отладка: проверка содержимого orderData после добавления
+ if (count($orderData) !== 7) {
+ Yii::error("OrderData имеет " . count($orderData) . " ключей вместо 7 для заказа #{$order->id}. Keys: " . implode(', ', array_keys($orderData)), __METHOD__);
+ }
+
+ if (empty($orderData['status_name']) || empty($orderData['substatus_name'])) {
+ Yii::error("OrderData не содержит status_name или substatus_name для заказа #{$order->id}. status_name=" . ($orderData['status_name'] ?? 'MISSING') . ", substatus_name=" . ($orderData['substatus_name'] ?? 'MISSING'), __METHOD__);
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * Сравнить товары между заказом МП и чеком продаж
+ *
+ * @param MarketplaceOrderItems[] $orderItems
+ * @param SalesProducts[] $saleProducts
+ * @param array $productsCache
+ * @return array
+ */
+ public function compareOrderProducts(
+ array $orderItems,
+ array $saleProducts,
+ array $productsCache
+ ): array {
+ // Категория упаковки - исключаем из сравнения
+ $wrappingProductIds = $this->getWrappingProductIds();
+
+ // Преобразуем товары заказа в массив (offer_id => quantity)
+ $orderProductsMap = [];
+ foreach ($orderItems as $item) {
+ $productId = $this->getProductIdByArticle($item->offer_id, $productsCache);
+ if ($productId && !in_array($productId, $wrappingProductIds)) {
+ $key = $productId;
+ if (!isset($orderProductsMap[$key])) {
+ $orderProductsMap[$key] = 0;
+ }
+ $orderProductsMap[$key] += (int)$item->count;
+ }
+ }
+
+ // Преобразуем товары чека в массив (product_id => quantity), исключая упаковку
+ $saleProductsMap = [];
+ foreach ($saleProducts as $product) {
+ if (!in_array($product->product_id, $wrappingProductIds)) {
+ $key = $product->product_id;
+ if (!isset($saleProductsMap[$key])) {
+ $saleProductsMap[$key] = 0;
+ }
+ $saleProductsMap[$key] += (int)$product->quantity;
+ }
+ }
+
+ // Сравниваем товары
+ $matchedCount = 0;
+ $totalOrderProducts = count($orderProductsMap);
+ $details = [];
+
+ foreach ($orderProductsMap as $productId => $orderQty) {
+ $saleQty = $saleProductsMap[$productId] ?? 0;
+ if ($orderQty === $saleQty) {
+ $matchedCount++;
+ $details[$productId] = [
+ 'matched' => true,
+ 'order_qty' => $orderQty,
+ 'sale_qty' => $saleQty,
+ ];
+ } else {
+ $details[$productId] = [
+ 'matched' => false,
+ 'order_qty' => $orderQty,
+ 'sale_qty' => $saleQty,
+ ];
+ }
+ }
+
+ $matchPercent = $totalOrderProducts > 0 ? round(($matchedCount / $totalOrderProducts) * 100) : 0;
+
+ return [
+ 'match_percent' => $matchPercent,
+ 'matched_count' => $matchedCount,
+ 'total_count' => $totalOrderProducts,
+ 'details' => $details,
+ ];
+ }
+
+ /**
+ * Расчет степени совпадения
+ *
+ * @param MarketplaceOrders $order
+ * @param Sales $sale
+ * @param array $productMatch
+ * @return float
+ */
+ public function calculateMatchScore(
+ MarketplaceOrders $order,
+ Sales $sale,
+ array $productMatch
+ ): float {
+ // Базовая оценка совпадения товаров (80% от оценки)
+ $productScore = $productMatch['match_percent'] / 100 * 0.8;
+
+ // Проверяем совпадение дат (20% от оценки)
+ // Чем ближе даты, тем выше оценка
+ try {
+ $orderDate = new \DateTime($order->creation_date);
+ $saleDate = new \DateTime($sale->date);
+ $daysDiff = abs($orderDate->diff($saleDate)->days);
+
+ // Идеальное совпадение: 0 дней = 20%, за каждый день минус 3.3% (макс 3 дня = 0%)
+ $dateScore = max(0, (1 - ($daysDiff / 3)) * 0.2);
+ } catch (\Exception $e) {
+ $dateScore = 0;
+ }
+
+ return round(($productScore + $dateScore), 2);
+ }
+
+ /**
+ * Обогатить товары заказа информацией о названиях
+ *
+ * @param MarketplaceOrderItems[] $items
+ * @param array $productsCache
+ * @return array
+ */
+ private function enrichOrderItems(array $items, array $productsCache): array
+ {
+ $result = [];
+ foreach ($items as $item) {
+ $productId = $this->getProductIdByArticle($item->offer_id, $productsCache);
+ $productName = $this->getProductName($productId, $productsCache);
+
+ $result[] = [
+ 'offer_id' => $item->offer_id,
+ 'offer_name' => $item->offer_name,
+ 'product_name' => $productName,
+ 'product_id' => $productId,
+ 'quantity' => $item->count,
+ 'price' => $item->price,
+ 'buyer_price' => $item->buyer_price,
+ 'summ' => $item->count * $item->price,
+ ];
+ }
+ return $result;
+ }
+
+ /**
+ * Обогатить товары продажи информацией о названиях
+ *
+ * @param SalesProducts[] $products
+ * @param array $productsCache
+ * @return array
+ */
+ private function enrichSalesProducts(array $products, array $productsCache): array
+ {
+ $result = [];
+ foreach ($products as $product) {
+ $productName = $this->getProductName($product->product_id, $productsCache);
+
+ $result[] = [
+ 'product_id' => $product->product_id,
+ 'product_name' => $productName,
+ 'quantity' => $product->quantity,
+ 'price' => $product->price,
+ 'discount' => $product->discount,
+ 'summ' => $product->summ,
+ 'color' => $product->color,
+ ];
+ }
+ return $result;
+ }
+
+ /**
+ * Получить ID товара по артикулу (offer_id)
+ *
+ * @param string $articule
+ * @param array $cache
+ * @return string|null
+ */
+ private function getProductIdByArticle(string $articule, array &$cache): ?string
+ {
+ $cacheKey = "article_{$articule}";
+ if (isset($cache[$cacheKey])) {
+ return $cache[$cacheKey];
+ }
+
+ $product = Products1c::findOne(['articule' => $articule]);
+ $productId = $product ? $product->id : null;
+
+ $cache[$cacheKey] = $productId;
+ return $productId;
+ }
+
+ /**
+ * Получить название товара
+ *
+ * @param string|null $productId
+ * @param array $cache
+ * @return string
+ */
+ private function getProductName(?string $productId, array &$cache): string
+ {
+ if (empty($productId)) {
+ return 'Неизвестный товар';
+ }
+
+ $cacheKey = "product_{$productId}";
+ if (isset($cache[$cacheKey])) {
+ return $cache[$cacheKey];
+ }
+
+ $product = Products1c::findOne(['id' => $productId]);
+ $name = $product ? $product->name : 'Неизвестный товар';
+
+ $cache[$cacheKey] = $name;
+ return $name;
+ }
+
+ /**
+ * Построить кэш товаров для оптимизации
+ *
+ * @return array
+ */
+ private function buildProductsCache(): array
+ {
+ return [];
+ }
+
+ /**
+ * Получить ID товаров категории "упаковка"
+ *
+ * @return array
+ */
+ private function getWrappingProductIds(): array
+ {
+ $productsArr = Products1c::getProductsFromClass(['wrap']);
+ return $productsArr['wrap'] ?? [];
+ }
+}
+
--- /dev/null
+<?php
+/**
+ * @var $date1
+ * @var $date2
+ * @var $daysSearchForm
+ * @var $cityStores
+ * @var $city_stores
+ * @var $export_revers
+ * @var $marketplaceOrdersByStore
+ * @var \yii\web\View $this
+ */
+
+use yii\helpers\Html;
+use yii\widgets\ActiveForm;
+?>
+
+<h2>Отчет по заказам маркетплейсов и чекам продаж</h2>
+
+<?php $searchForm = ActiveForm::begin([
+ 'id' => 'days-search-form-marketplace',
+ 'enableAjaxValidation' => true,
+ 'validationUrl' => 'validate',
+ 'options' => ['enctype' => 'multipart/form-data']
+]); ?>
+
+<div class="row mb-3">
+ <div class="col-lg-4">
+ <?= $searchForm->field($daysSearchForm, 'dateFrom', [
+ 'inputOptions' => [
+ 'class' => 'form-control datetime',
+ 'type' => 'date',
+ 'placeholder' => 'начало',
+ ],
+ 'options' => ['tag' => null],
+ ])->label(false)->textInput() ?>
+ </div>
+ <div class="col-lg-4">
+ <?= $searchForm->field($daysSearchForm, 'dateTo', [
+ 'inputOptions' => [
+ 'class' => 'form-control datetime',
+ 'type' => 'date',
+ 'placeholder' => 'конец',
+ ],
+ 'options' => ['tag' => null],
+ ])->label(false)->textInput() ?>
+ </div>
+ <div class="col-lg-4">
+ <div class="form-group">
+ <label class="control-label"> </label>
+ <?= Html::submitButton('Показать', ['class' => 'btn btn-success']) ?>
+ </div>
+ </div>
+</div>
+
+<?php ActiveForm::end() ?>
+
+<div class="marketplace-report-container">
+ <?php if (!empty($marketplaceOrdersByStore)): ?>
+
+ <div class="mb-4">
+ <div class="alert alert-info">
+ <strong>Период:</strong> <?= $date1 ?> - <?= $date2 ?>
+ <strong class="ms-3">Всего магазинов:</strong> <?= count($marketplaceOrdersByStore) ?>
+ </div>
+ </div>
+
+ <!-- АККОРДЕОН ПО МАГАЗИНАМ -->
+ <div class="accordion" id="accordionStores">
+ <?php foreach ($marketplaceOrdersByStore as $storeId1c => $storeData): ?>
+ <?php
+ $storeNameExport = $city_stores[$storeId1c] ?? 'Неизвестный магазин';
+ $totalOrders = $storeData['total_orders'];
+ $totalMatched = $storeData['total_matched'];
+ $totalUnmatched = $storeData['total_unmatched'];
+ $totalSumm = $storeData['total_summ'];
+ $matchPercent = $totalOrders > 0 ? round(($totalMatched / $totalOrders) * 100) : 0;
+ ?>
+ <div class="accordion-item">
+ <h2 class="accordion-header" id="headingStore<?= htmlspecialchars($storeId1c) ?>">
+ <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
+ data-bs-target="#collapseStore<?= htmlspecialchars($storeId1c) ?>"
+ aria-expanded="false"
+ aria-controls="collapseStore<?= htmlspecialchars($storeId1c) ?>">
+ <strong><?= Html::encode($storeNameExport) ?></strong>
+ <span class="badge bg-primary ms-2"><?= $totalOrders ?> заказов</span>
+ <span class="badge bg-success ms-2"><?= $totalMatched ?> найдено</span>
+ <span class="badge bg-danger ms-2"><?= $totalUnmatched ?> не найдено</span>
+ <span class="badge bg-warning ms-2 text-dark"><?= $matchPercent ?>%</span>
+ <span class="badge bg-info ms-2"><?= number_format($totalSumm, 2, '.', ' ') ?> ₽</span>
+ </button>
+ </h2>
+ <div id="collapseStore<?= htmlspecialchars($storeId1c) ?>" class="accordion-collapse collapse"
+ aria-labelledby="headingStore<?= htmlspecialchars($storeId1c) ?>"
+ data-bs-parent="#accordionStores">
+ <div class="accordion-body p-0">
+
+ <!-- ВЛОЖЕННЫЙ АККОРДЕОН ПО ЗАКАЗАМ -->
+ <div class="accordion" id="accordionOrders<?= htmlspecialchars($storeId1c) ?>">
+ <?php foreach ($storeData['orders'] as $orderId => $orderData): ?>
+ <?php
+ $order = $orderData['order'];
+ $orderItems = $orderData['order_items'];
+ $matchedSales = $orderData['matched_sales'];
+ $marketplaceNames = [
+ 1 => 'Flowwow',
+ 2 => 'Яндекс Маркет',
+ ];
+ $marketplaceName = $marketplaceNames[$order->marketplace_id] ?? 'Неизвестный МП';
+ $creationDate = date('d.m.Y H:i:s', strtotime($order->creation_date));
+ $hasMatches = !empty($matchedSales);
+ ?>
+ <div class="accordion-item">
+ <h2 class="accordion-header" id="headingOrder<?= htmlspecialchars($orderId) ?>">
+ <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
+ data-bs-target="#collapseOrder<?= htmlspecialchars($orderId) ?>"
+ aria-expanded="false"
+ aria-controls="collapseOrder<?= htmlspecialchars($orderId) ?>">
+ <strong>Заказ #<?= htmlspecialchars($order->marketplace_order_id) ?></strong>
+ <span class="badge <?= $hasMatches ? 'bg-success' : 'bg-warning' ?> ms-2">
+ <?= $hasMatches ? '✓ Найдены чеки' : '⚠ Чеки не найдены' ?>
+ </span>
+ <span class="badge bg-secondary ms-2"><?= $marketplaceName ?></span>
+ <span class="badge bg-info ms-2"><?= number_format($order->total, 2, '.', ' ') ?> ₽</span>
+ <small class="text-muted ms-2"><?= $creationDate ?></small>
+ </button>
+ </h2>
+ <div id="collapseOrder<?= htmlspecialchars($orderId) ?>" class="accordion-collapse collapse"
+ aria-labelledby="headingOrder<?= htmlspecialchars($orderId) ?>"
+ data-bs-parent="#accordionOrders<?= htmlspecialchars($storeId1c) ?>">
+ <div class="accordion-body">
+
+ <!-- ИНФОРМАЦИЯ О ЗАКАЗЕ -->
+ <div class="mb-4">
+ <h5 class="text-info">Информация о заказе МП</h5>
+ <table class="table table-sm table-bordered">
+ <tr>
+ <td><strong>ID заказа:</strong></td>
+ <td><?= Html::encode($order->marketplace_order_id) ?></td>
+ </tr>
+ <tr>
+ <td><strong>Дата создания:</strong></td>
+ <td><?= $creationDate ?></td>
+ </tr>
+ <tr>
+ <td><strong>Дата обновления:</strong></td>
+ <td><?= date('d.m.Y H:i:s', strtotime($order->updated_at)) ?></td>
+ </tr>
+ <tr>
+ <td><strong>Маркетплейс:</strong></td>
+ <td><?= $marketplaceName ?></td>
+ </tr>
+ <tr>
+ <td><strong>Сумма заказа:</strong></td>
+ <td><?= number_format($order->total, 2, '.', ' ') ?> ₽</td>
+ </tr>
+ <tr>
+ <td><strong>Статус:</strong></td>
+ <td>
+ <span class="badge bg-primary">
+
+ <?= Html::encode($orderData['status_name'] ?? 'Неизвестный статус') ?>
+
+ </span>
+ </td>
+ </tr>
+ <tr>
+ <td><strong>Суб-статус:</strong></td>
+ <td>
+ <span class="badge bg-success">
+ <?= Html::encode($orderData['substatus_name'] ?? 'Неизвестный суб-статус') ?>
+ </span>
+ </td>
+ </tr>
+ </table>
+ </div>
+
+ <!-- ТОВАРЫ В ЗАКАЗЕ -->
+ <div class="mb-4">
+ <h5 class="text-info">Товары в заказе (<?= count($orderItems) ?>)</h5>
+ <table class="table table-sm table-hover">
+ <thead class="table-light">
+ <tr>
+ <th style="width: 40%">Наименование</th>
+ <th style="width: 15%">Кол-во</th>
+ <th style="width: 15%">Цена</th>
+ <th style="width: 15%">Сумма</th>
+ <th style="width: 15%">Артикул</th>
+ </tr>
+ </thead>
+ <tbody>
+ <?php foreach ($orderItems as $item): ?>
+ <tr>
+ <td>
+ <small>
+ <strong><?= Html::encode($item['product_name']) ?></strong>
+ <?php if ($item['product_name'] !== $item['offer_name']): ?>
+ <br/>
+ <span class="text-muted">(<?= Html::encode($item['offer_name']) ?>)</span>
+ <?php endif; ?>
+ </small>
+ </td>
+ <td><small><?= $item['quantity'] ?></small></td>
+ <td><small><?= number_format($item['price'], 2, '.', ' ') ?> ₽</small></td>
+ <td><small><?= number_format($item['summ'], 2, '.', ' ') ?> ₽</small></td>
+ <td><small class="text-muted"><?= Html::encode($item['offer_id']) ?></small></td>
+ </tr>
+ <?php endforeach; ?>
+ </tbody>
+ </table>
+ </div>
+
+ <!-- НАЙДЕННЫЕ ЧЕКИ ИЛИ ПРЕДУПРЕЖДЕНИЕ -->
+ <?php if ($hasMatches): ?>
+ <div class="mb-4">
+ <h5 class="text-success">Найденные чеки продаж (<?= count($matchedSales) ?>)</h5>
+
+ <?php foreach ($matchedSales as $matchIdx => $saleMatch): ?>
+ <?php
+ $sale = $saleMatch['sale'];
+ $saleProducts = $saleMatch['sale_products'];
+ $matchPercent = $saleMatch['products_match_percent'];
+ $sumDiff = $saleMatch['sum_diff'];
+ $matchScore = $saleMatch['match_score'];
+ $saleDate = date('d.m.Y H:i:s', strtotime($sale->date));
+ ?>
+ <div class="card mb-3 border-success">
+ <div class="card-header bg-success text-white">
+ <strong>Чек #<?= htmlspecialchars($sale->number) ?></strong>
+ <span class="float-end">
+ <span class="badge bg-light text-dark"><?= $saleDate ?></span>
+ <span class="badge bg-info">Совпадение: <?= $matchPercent ?>%</span>
+ <span class="badge bg-warning text-dark">Оценка: <?= $matchScore ?></span>
+ </span>
+ </div>
+ <div class="card-body">
+ <table class="table table-sm mb-3">
+ <tr>
+ <td><strong>ID чека:</strong></td>
+ <td><?= Html::encode($sale->id) ?></td>
+ </tr>
+ <tr>
+ <td><strong>Дата чека:</strong></td>
+ <td><?= $saleDate ?></td>
+ </tr>
+ <tr>
+ <td><strong>Сумма чека:</strong></td>
+ <td><?= number_format($sale->summ - $sale->skidka, 2, '.', ' ') ?> ₽</td>
+ </tr>
+ <tr>
+ <td><strong>Разница в сумме:</strong></td>
+ <td>
+ <span class="<?= abs($sumDiff) < 0.01 ? 'text-success' : 'text-warning' ?>">
+ <?= number_format($sumDiff, 2, '.', ' ') ?> ₽
+ </span>
+ </td>
+ </tr>
+ </table>
+
+ <h6 class="mb-2">Товары в чеке (<?= count($saleProducts) ?>):</h6>
+ <table class="table table-sm table-hover">
+ <thead class="table-light">
+ <tr>
+ <th style="width: 40%">Наименование</th>
+ <th style="width: 15%">Кол-во</th>
+ <th style="width: 15%">Цена</th>
+ <th style="width: 15%">Сумма</th>
+ <th style="width: 15%">Скидка</th>
+ </tr>
+ </thead>
+ <tbody>
+ <?php foreach ($saleProducts as $product): ?>
+ <tr>
+ <td><small><?= Html::encode($product['product_name']) ?></small></td>
+ <td><small><?= $product['quantity'] ?></small></td>
+ <td><small><?= number_format($product['price'], 2, '.', ' ') ?> ₽</small></td>
+ <td><small><?= number_format($product['summ'], 2, '.', ' ') ?> ₽</small></td>
+ <td><small><?= number_format($product['discount'], 2, '.', ' ') ?> ₽</small></td>
+ </tr>
+ <?php endforeach; ?>
+ </tbody>
+ </table>
+
+ <?php if (!empty($saleMatch['products_match_details'])): ?>
+ <h6 class="mt-3 mb-2">Анализ совпадения товаров:</h6>
+ <table class="table table-sm table-bordered">
+ <thead class="table-light">
+ <tr>
+ <th>Товар</th>
+ <th style="width: 20%">В заказе</th>
+ <th style="width: 20%">В чеке</th>
+ <th style="width: 20%">Статус</th>
+ </tr>
+ </thead>
+ <tbody>
+ <?php foreach ($saleMatch['products_match_details'] as $productId => $matchDetail): ?>
+ <tr>
+ <td><small><?= htmlspecialchars($productId) ?></small></td>
+ <td><?= $matchDetail['order_qty'] ?></td>
+ <td><?= $matchDetail['sale_qty'] ?></td>
+ <td>
+ <?php if ($matchDetail['matched']): ?>
+ <span class="badge bg-success">✓ Совпадает</span>
+ <?php else: ?>
+ <span class="badge bg-warning text-dark">⚠ Отличие</span>
+ <?php endif; ?>
+ </td>
+ </tr>
+ <?php endforeach; ?>
+ </tbody>
+ </table>
+ <?php endif; ?>
+ </div>
+ </div>
+ <?php endforeach; ?>
+ </div>
+ <?php else: ?>
+ <div class="alert alert-warning alert-dismissible fade show" role="alert">
+ <strong>⚠ Чеки не найдены</strong>
+ <p class="mb-0">Для этого заказа не найдены соответствующие чеки продаж за период с совпадением суммы и товаров.</p>
+ <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
+ </div>
+ <?php endif; ?>
+
+ </div>
+ </div>
+ </div>
+ <?php endforeach; ?>
+ </div>
+
+ </div>
+ </div>
+ </div>
+ <?php endforeach; ?>
+ </div>
+
+ <?php else: ?>
+ <div class="alert alert-warning">
+ <strong>Нет данных</strong> за выбранный период <?= $date1 ?> - <?= $date2 ?>
+ </div>
+ <?php endif; ?>
+</div>
+
+<?php
+// CSS для оформления
+$this->registerCss('
+.marketplace-report-container {
+ padding: 15px;
+}
+
+.accordion-button {
+ padding: 12px 15px;
+ font-size: 14px;
+}
+
+.accordion-button:not(.collapsed) {
+ background-color: #f8f9fa;
+ color: #000;
+}
+
+.card {
+ box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
+}
+
+.card-header {
+ font-weight: 500;
+ padding: 10px 15px;
+}
+
+.table-hover tbody tr:hover {
+ background-color: #f5f5f5;
+}
+
+.badge {
+ margin-left: 5px;
+ padding: 5px 8px;
+ font-size: 11px;
+}
+
+.text-muted {
+ color: #999;
+}
+
+.text-success {
+ color: #28a745;
+}
+
+.text-danger {
+ color: #dc3545;
+}
+
+.text-warning {
+ color: #ffc107;
+}
+
+.text-info {
+ color: #17a2b8;
+}
+
+h5 {
+ border-bottom: 2px solid #e0e0e0;
+ padding-bottom: 10px;
+ margin-bottom: 15px;
+}
+
+h6 {
+ font-weight: 600;
+ color: #333;
+}
+');
+?>
+
?>
<div class="d-flex justify-content-between align-items-center mb-3">
<h2>Продажи</h2>
- <a href="<?= \yii\helpers\Url::to(['dashboard/sales-detail']) ?>" class="btn btn-info btn-sm">
- <i class="fas fa-list-ul"></i> Детальный просмотр
- </a>
+ <div>
+ <a href="<?= \yii\helpers\Url::to(['dashboard/sales-detail']) ?>" class="btn btn-info btn-sm me-2">
+ <i class="fas fa-list-ul"></i> Детальный просмотр
+ </a>
+ <a href="<?= \yii\helpers\Url::to(['dashboard/marketplace-sales-report']) ?>" class="btn btn-warning btn-sm">
+ <i class="fas fa-shopping-cart"></i> Отчет по МП
+ </a>
+ </div>
</div>
<?php $searchForm = \yii\widgets\ActiveForm::begin([