]> gitweb.erp-flowers.ru Git - erp24_rep/yii-erp24/.git/commitdiff
Merge branch 'develop' into feature_fomichev_erp-363_week_forecast
authorVladimir Fomichev <vladimir.fomichev@erp-flowers.ru>
Wed, 18 Jun 2025 12:03:02 +0000 (15:03 +0300)
committerVladimir Fomichev <vladimir.fomichev@erp-flowers.ru>
Wed, 18 Jun 2025 12:03:02 +0000 (15:03 +0300)
# Conflicts:
# erp24/controllers/AutoPlannogrammaController.php
# erp24/services/AutoPlannogrammaService.php

1  2 
erp24/controllers/AutoPlannogrammaController.php
erp24/services/AutoPlannogrammaService.php

index 138aaa824ba7824eb23b01a4fa5ec2642bb8e5df,06b7fabde238446b72ae7f45ffa3d5c514b0f7a7..2d66a645e060a8f691f80b686e853181a959a32d
@@@ -14,7 -15,8 +15,9 @@@ use yii_app\records\MatrixBouquetForeca
  use yii_app\records\PricesDynamic;
  use yii_app\records\Products1c;
  use yii_app\records\Products1cNomenclature;
+ use yii_app\records\CityStoreParams;
+ use yii_app\records\StoreDynamic;
 +use yii_app\records\SalesWriteOffsPlan;
  use yii_app\services\AutoPlannogrammaService;
  use yii_app\services\StorePlanService;
  
@@@ -1746,74 -1782,24 +1783,93 @@@ class AutoPlannogrammaController extend
          ]);
      }
  
+     public function actionGetSubcategories(string $category, int $year, int $week): array
+     {
+         Yii::$app->response->format = Response::FORMAT_JSON;
+         $data = Autoplannogramma::find()
+             ->alias('a')
+             ->leftJoin('products_1c_nomenclature p', 'a.product_id = p.id')
+             ->where(['p.category' => $category])
+             ->andWhere(['a.year' => $year])
+             ->andWhere(['a.week' => $week])
+             ->select([
+                 'p.subcategory as name',
+                 new \yii\db\Expression("CASE WHEN COUNT(a.id) > 0 THEN 1 ELSE 0 END AS hasData")
+             ])
+             ->groupBy('p.subcategory')
+             ->asArray()
+             ->all();
+         return $data;
+     }
++
 +    public function actionWeeklyBouquetProductsForecast()
 +    {
 +        Yii::$app->response->format = \yii\web\Response::FORMAT_JSON;
 +
 +        $request = Yii::$app->request;
 +        $storeIdRequest = $request->get('storeId', null);
 +        $monthRequest   = $request->get('month');
 +        $yearRequest    = $request->get('year');
 +        $weekRequest    = $request->get('week');
 +
 +        if (!$monthRequest || !$yearRequest || $weekRequest === null) {
 +            return ['success' => false, 'message' => 'Нет параметров'];
 +        }
 +
 +        $service = new AutoPlannogrammaService();
 +        $result = $service->getWeeklyBouquetProductsForecast($monthRequest, $yearRequest, $storeIdRequest);
 +
 +        if (!is_array($result)) {
 +            return ['success' => false, 'message' => 'Ошибка структуры данных'];
 +        }
 +
 +        $grouped = [];
 +        $salesShares = [];
 +
 +        $plans = SalesWriteOffsPlan::find()
 +            ->where(['month' => $monthRequest, 'year' => $yearRequest])
 +            ->indexBy('store_id')
 +            ->asArray()
 +            ->all();
 +        if ($plans) {
 +            foreach ($plans as $storeId => $plan) {
 +                $total = $plan['total_sales_plan'];
 +                $offline = $plan['offline_sales_plan'];
 +                $online = $plan['online_sales_shop_plan'];
 +                $market = $plan['online_sales_marketplace_plan'];
 +                $salesShares[$storeId]['offline'] = round($offline / $total, 4);
 +                $salesShares[$storeId]['online'] = round($online / $total, 4);
 +                $salesShares[$storeId]['marketplace'] = round($market / $total, 4);
 +            }
 +        }
 +
 +        foreach ($result as $item) {
 +            $weekItem = (int) $item['week'];
 +            $storeItem = (int) $item['store_id'];
 +            $guid = (string) $item['product_guid'];
 +            $group = (string) $item['matrix_group'];
 +            $type = (string) $item['type'];
 +            $forecastValue = (float) $item['week_forecast'];
 +            if (isset($salesShares[$storeItem]) && isset($salesShares[$storeItem][$type])) {
 +                $grouped[$weekItem][$storeItem][$type]['share'] = $salesShares[$storeItem][$type];
 +            }
 +
 +            $grouped[$weekItem][$storeItem][$guid][$type][$group] = $forecastValue;
 +        }
 +
 +
 +        if ($weekRequest !== null) {
 +            $week = (int) $weekRequest;
 +            $grouped = isset($grouped[$week]) ? [$week => $grouped[$week]] : [];
 +        }
 +
 +        return [
 +            'success' => true,
 +            'data' => $grouped,
 +        ];
 +
 +
 +    }
  }
index 6f03a63f0d31855bd227bfa6399a3690fbca0750,c71df1bbe7e58d79b221a5bd70ab3e76bbd8bb3c..845cda45610c7db2d331745d997fd12ddea4d84b
@@@ -2786,7 -2795,7 +2795,7 @@@ class AutoPlannogrammaServic
          return $pricesMap;
      }
  
-    /* public function getWeeklyBouquetProductsForecast($month, $year, $storeId)
 -    public function getWeeklyBouquetProductsForecast($month, $year, $storeId = null, $weekNumber = null)
++   /* public function getWeeklyBouquetProductsForecast($month, $year, $storeId = null, $weekNumber = null)
      {
          $matrixGroups = ArrayHelper::map(
              MatrixBouquetForecast::find()->select(['group'])->distinct()->asArray()->all(),
                  ];
              }
          }
-         return $weeklyForecasts;
  
+         return $weeksProductForecast;
 +    }*/
 +
 +    public function getWeeklyBouquetProductsForecast($month, $year, $storeId = null, $weekNumber = null)
 +    {
 +        $matrixGroups = ArrayHelper::map(
 +            MatrixBouquetForecast::find()->select(['group'])->distinct()->asArray()->all(),
 +            'group',
 +            'group'
 +        );
 +
 +        $date = $year . '-' . str_pad($month, 2, '0', STR_PAD_LEFT) . '-01';
 +
 +        $result = StorePlanService::getBouquetSpiecesMonthGoalFromForecast($month, $year, $storeId, $matrixGroups);
 +        $weekShares = $this->getHistoricalSpeciesShareByWeek($date);
 +        $flatData = $result['flatData'];
 +
 +        $weekIndex = [];
 +        foreach ($weekShares as $shareRow) {
 +            $key = implode('|', [
 +                $shareRow['store_id'],
 +                $shareRow['category'],
 +                $shareRow['subcategory'],
 +                $shareRow['species'],
 +            ]);
 +            $weekIndex[$key][] = $shareRow;
 +        }
 +
 +        $weeklyForecasts = [];
 +
 +        foreach ($flatData as $item) {
 +            $key = implode('|', [
 +                $item['store_id'],
 +                $item['category'],
 +                $item['subcategory'],
 +                $item['species'],
 +            ]);
 +
 +            if (!isset($weekIndex[$key])) {
 +                continue;
 +            }
 +
 +            foreach ($weekIndex[$key] as $weekShare) {
 +                if ($weekNumber !== null && (int)$weekShare['week'] !== (int)$weekNumber) {
 +                    continue; // фильтрация по неделе
 +                }
 +
 +                $weeklyForecasts[] = [
 +                    'store_id' => $item['store_id'],
 +                    'category' => $item['category'],
 +                    'subcategory' => $item['subcategory'],
 +                    'species' => $item['species'],
 +                    'type' => $item['type'],
 +                    'product_guid' => $item['product_guid'],
 +                    'week_forecast' => round($item['full_forecast'] * $weekShare['share'], 2),
 +                    'full_forecast' => $item['full_forecast'],
 +                    'week' => $weekShare['week'],
 +                    'matrix_group' => $item['matrix_group'],
 +                    'month' => $item['month'],
 +                    'year' => $item['year'],
 +                ];
 +            }
 +        }
 +
 +        $grouped = [];
 +        $salesShares = [];
 +
 +        $plans = SalesWriteOffsPlan::find()
 +            ->where(['month' => $month, 'year' => $year])
 +            ->indexBy('store_id')
 +            ->asArray()
 +            ->all();
 +        if ($plans) {
 +            foreach ($plans as $storeId => $plan) {
 +                $total = $plan['total_sales_plan'];
 +                $offline = $plan['offline_sales_plan'];
 +                $online = $plan['online_sales_shop_plan'];
 +                $market = $plan['online_sales_marketplace_plan'];
 +                $salesShares[$storeId]['offline'] = round($offline / $total, 4);
 +                $salesShares[$storeId]['online'] = round($online / $total, 4);
 +                $salesShares[$storeId]['marketplace'] = round($market / $total, 4);
 +            }
 +        }
 +        foreach ($weeklyForecasts as $item) {
 +            $storeItem = (int)$item['store_id'];
 +            $guid = (string)$item['product_guid'];
 +            $group = (string)$item['matrix_group'];
 +            $type = (string)$item['type'];
 +            $forecastValue = (float)$item['week_forecast'];
 +            if (isset($salesShares[$storeItem]) && isset($salesShares[$storeItem][$type])) {
 +                $grouped[$storeItem][$type]['share'] = $salesShares[$storeItem][$type];
 +            }
 +            $grouped[$storeItem][$guid][$type][$group] = $forecastValue;
 +        }
 +
 +        return $grouped;
 +    }
 +
 +    public function getWeeklyProductsWriteoffsForecast($month, $year, $weeklySalesForecast, $storeId = null, $weekNumber = null)
 +    {
 +        $weeksProductForecast = [];
 +        $filters = [];
 +
 +        $dateFrom = date("Y-m-d 00:00:00", strtotime(sprintf('%04d-%02d-01', $year, $month)));
 +        $monthYear = date("m-Y", strtotime($dateFrom));
 +        $filters['store_id'] = $storeId;
 +        $filters['type'] = self::TYPE_WRITE_OFFS;
 +        $filters['plan_date'] = $dateFrom;
 +
 +        $monthSpeciesGoals = $this->calculateFullGoalChain($filters);
 +        $monthSpeciesGoalsMap = [];
 +        foreach ($monthSpeciesGoals as $monthSpeciesGoal) {
 +            $monthSpeciesGoalsMap[$monthSpeciesGoal['store_id']]
 +            [$monthSpeciesGoal['category']]
 +            [$monthSpeciesGoal['subcategory']]
 +            [$monthSpeciesGoal['species']] = $monthSpeciesGoal['goal'];
 +        }
 +        $productsWriteoffsShare = $this->calculateWriteOffShareByMonth($weeklySalesForecast, $month, $year);
 +        $productsWriteoffsShareMap = [];
 +        foreach ($productsWriteoffsShare as $productWriteoffs) {
 +            $productsWriteoffsShareMap[$productWriteoffs['store_id']][$productWriteoffs['product_id']] = $productWriteoffs['share'];
 +            }
 +
 +        $weeksShareResult = $this->getHistoricalWeeklySpeciesShare($monthYear, $filters, null, 'writeOffs');
 +        $weeksData = $this->calculateWeeklySpeciesGoals($weeksShareResult, $monthSpeciesGoals);
 +
 +        foreach ($weeksData as $r) {
 +            $forecasts = $this->calculateWeekForecastSpeciesProducts($r['category'], $r['subcategory'], $r['species'], $r['store_id'], $r['weekly_goal']);
 +
 +            foreach ($forecasts as $forecast) {
 +                $productWriteoffsForecastSpeciesShare = $productsWriteoffsShareMap[$r['store_id']][$forecast['product_id']] ?? 0;
 +                $productWriteoffsForecast = $productWriteoffsForecastSpeciesShare * $forecast['forecast'];
 +                $weeksProductForecast[] = [
 +                    'category' => $forecast['category'] ?? '',
 +                    'subcategory' => $forecast['subcategory'] ?? '',
 +                    'species' => $forecast['species'] ?? '',
 +                    'product_id' => $forecast['product_id'] ?? '',
 +                    'name' => $forecast['name'] ?? '',
 +                    'price' => $forecast['price'] ?? '',
 +                    'goal' => $forecast['goal'] ?? 0,
 +                    'forecast' => $productWriteoffsForecast ?? 0,
 +                    'week' => $r['week'],
 +                ];
 +            }
 +        }
 +
 +        return $weeksProductForecast;
 +    }
 +
 +    /**
 +     * Рассчитывает долю списания (write-offs) каждого товара за заданный месяц
 +     * на основании исторических данных за два предыдущих года.
 +     *
 +     * @param array  $productList  Список товаров:
 +     *                             [
 +     *                               [
 +     *                                 'week'             => int,
 +     *                                 'store_id'         => int,
 +     *                                 'category'         => string,
 +     *                                 'subcategory'      => string,
 +     *                                 'species'          => string,
 +     *                                 'product_id'       => string,
 +     *                                 'forecast_month_pieces' => float,
 +     *                                 'forecast_week_pieces'  => float,
 +     *                               ], …
 +     *                             ]
 +     * @param string $monthYear    Формат 'YYYY-MM'
 +     * @return array               [
 +     *                               [
 +     *                                 'store_id'   => int,
 +     *                                 'month'      => int,
 +     *                                 'year'       => int,
 +     *                                 'category'   => string,
 +     *                                 'subcategory'=> string,
 +     *                                 'species'    => string,
 +     *                                 'product_id' => string,
 +     *                                 'writeoff_qty'   => float,
 +     *                                 'species_qty'    => float,
 +     *                                 'share'          => float, // доля
 +     *                               ], …
 +     *                             ]
 +     */
 +    public function calculateWriteOffShareByMonth(array $productList, int $month, int $year ): array
 +    {
 +        $year  = $year ?? (int)date("Y");
 +        $month = $month ?? (int)date("m",  strtotime( "+ 2 month" . date('Y-m-01')));
 +
 +        $storeIds = array_unique(array_column($productList, 'store_id'));
 +        if (empty($storeIds)) {
 +            return [];
 +        }
 +
 +        $productQtyMap        = []; // [store][cat][sub][spec][pid] => qty
 +        $speciesQtyMap        = []; // [store][cat][sub][spec] => total qty
 +
 +        $storeJoinCondition = 'ex.export_val = w.store_id';
 +
 +        for ($offset = 2; $offset >= 1; $offset--) {
 +            $histYear = $year - $offset;
 +            $start = sprintf('%04d-%02d-01 00:00:00', $histYear, $month);
 +            $end   = date('Y-m-d 23:59:59', strtotime("$start +1 month -1 second"));
 +
 +            $productsWriteOffs = (new Query())
 +                ->select([
 +                    'store_id'    => 'ex.entity_id',
 +                    'category'    => 'p1c.category',
 +                    'subcategory' => 'p1c.subcategory',
 +                    'species'     => 'p1c.species',
 +                    'product_id'  => 'wp.product_id',
 +                    'qty'         => new \yii\db\Expression('SUM(wp.quantity)'),
 +                ])
 +                ->from(['w' => 'write_offs'])
 +                ->innerJoin(['wp' => 'write_offs_products'], 'wp.write_offs_id = w.id')
 +                ->leftJoin('export_import_table ex', $storeJoinCondition)
 +                ->leftJoin('products_1c_nomenclature p1c', 'p1c.id = wp.product_id')
 +                ->leftJoin('products_1c p1', 'p1.id = wp.product_id')
 +
 +                ->andWhere(['ex.entity_id' => $storeIds])
 +                ->andWhere(['>=', 'w.date', $start])
 +                ->andWhere(['<=', 'w.date', $end])
 +
 +                ->andWhere(['p1.components' => ''])
 +                ->andWhere(['not in', 'p1c.category', ['', 'букет', 'сборка', 'сервис']])
 +                ->groupBy(['ex.entity_id','p1c.category','p1c.subcategory','p1c.species','wp.product_id'])
 +                ->all();
 +
 +            foreach ($productsWriteOffs as $productsWriteOffsItem) {
 +                $sid  = (int)$productsWriteOffsItem['store_id'];
 +                $cat  = $productsWriteOffsItem['category'];
 +                $sub  = $productsWriteOffsItem['subcategory'];
 +                $spec = $productsWriteOffsItem['species'];
 +                $pid  = $productsWriteOffsItem['product_id'];
 +                $qty  = (float)$productsWriteOffsItem['qty'];
 +
 +
 +                $productQtyMap[$sid][$cat][$sub][$spec][$pid] =
 +                    ($productQtyMap[$sid][$cat][$sub][$spec][$pid] ?? 0.0) + $qty;
 +
 +                $speciesQtyMap[$sid][$cat][$sub][$spec] =
 +                    ($speciesQtyMap[$sid][$cat][$sub][$spec] ?? 0.0) + $qty;
 +            }
 +        }
 +
 +        $seen = [];
 +        $result = [];
 +        foreach ($productList as $item) {
 +            $pid  = $item['product_id'];
 +            $sid  = $item['store_id'];
 +            $cat  = $item['category'];
 +            $sub  = $item['subcategory'];
 +            $spec = $item['species'];
 +
 +            $key = "{$sid}|{$cat}|{$sub}|{$spec}|{$pid}";
 +            if (isset($seen[$key])) {
 +                continue;
 +            }
 +            $seen[$key] = true;
 +
 +            $productsQty = $productQtyMap[$sid][$cat][$sub][$spec][$pid] ?? 0.0;
 +            $speciesQty = $speciesQtyMap[$sid][$cat][$sub][$spec] ??  0.0;
 +            $share = $speciesQty > 0
 +                ? round($productsQty / $speciesQty, 4)
 +                : 0.0;
 +
 +            $result[] = [
 +                'store_id'        => $sid,
 +                'month'           => $month,
 +                'year'            => $year,
 +                'category'        => $cat,
 +                'subcategory'     => $sub,
 +                'species'         => $spec,
 +                'product_id'      => $pid,
 +                'writeoff_qty'    => $productsQty,
 +                'species_qty'     => $speciesQty,
 +                'share'           => $share,
 +            ];
 +        }
 +
 +        return $result;
      }
  
  }