use yii\db\Expression;
use yii\db\Query;
use yii\helpers\ArrayHelper;
+use yii\web\Response;
use yii_app\records\CityStore;
+ use yii_app\records\MatrixBouquetForecast;
+ use yii_app\records\PricesDynamic;
+ use yii_app\records\Products1c;
use yii_app\records\Products1cNomenclature;
use yii_app\services\AutoPlannogrammaService;
+ use yii_app\services\StorePlanService;
class AutoPlannogrammaController extends BaseController
{
]);
}
+ public function action7()
+ {
+ $request = Yii::$app->request;
+
+ $filters = [
+ 'category' => $request->get('category'),
+ 'subcategory' => $request->get('subcategory'),
+ 'species' => $request->get('species'),
+ 'store_id' => $request->get('store_id'),
+ 'year' => $request->get('year'),
+ 'month' => $request->get('month'),
+ 'type' => $request->get('type'),
+ ];
+
+ $dataProvider = new ArrayDataProvider([
+ 'allModels' => [],
+ 'pagination' => ['pageSize' => 100],
+ ]);
+
+ $bouquetSpeciesForecast = [];
+ // Обработка даты на год и месяц
+ if (!empty($filters['year']) && !empty($filters['month'])) {
+ $matrixForecast = MatrixBouquetForecast::find()
+ ->where(['year' => $filters['year'], 'month' => $filters['month']])
+ ->asArray()
+ ->all();
+ $matrixGroups = array_unique(ArrayHelper::getColumn($matrixForecast, 'group'));
+ $bouquetForecast = StorePlanService::getBouquetSpiecesMonthGoalFromForecast($filters['month'], $filters['year'], $filters['store_id'], $matrixGroups);
+ $speciesData = $bouquetForecast['final'];
+ foreach ($speciesData as $store_id => $categoryData) {
+ foreach ($categoryData as $category => $subcategoryData) {
+ foreach ($subcategoryData as $subcategory => $species) {
+ foreach ($species as $speciesInd => $row) {
+ $bouquetSpeciesForecast[] = [
+ 'category' => $category,
+ 'subcategory' => $subcategory,
+ 'store_id' => $store_id,
+ 'species' => $speciesInd,
+ 'goal' => $row
+ ];
+ }
+ }
+ }
+
+ }
+ //var_dump($matrixGroups); die();
+ $flatData = array_filter($bouquetSpeciesForecast, function ($row) use ($filters) {
+ foreach ($filters as $key => $value) {
+ if (empty($value)) continue;
+ if (!isset($row[$key])) continue;
+
+ if (stripos((string)$row[$key], (string)$value) === false) {
+ return false;
+ }
+ }
+ return true;
+ });
+
+ $dataProvider = new ArrayDataProvider([
+ 'allModels' => $flatData,
+ 'pagination' => ['pageSize' => 100],
+ ]);
+ }
+ return $this->render('7', [
+ 'dataProvider' => $dataProvider,
+ 'filters' => $filters,
+ ]);
+ }
+
+
+ public function action8()
+ {
+ $request = Yii::$app->request;
+
+ $filters = [
+ 'category' => $request->get('category'),
+ 'subcategory' => $request->get('subcategory') ?? null,
+ 'species' => $request->get('species') ?? null,
+ 'store_id' => $request->get('store_id') ?? [],
+ 'year' => $request->get('year'),
+ 'month' => $request->get('month'),
+ 'type' => $request->get('type'),
+ ];
+
+ $dataProvider = new ArrayDataProvider([
+ 'allModels' => [],
+ 'pagination' => ['pageSize' => 100],
+ ]);
+
+
+ // Обработка даты на год и месяц
+ if (!empty($filters['year']) && !empty($filters['month'])) {
+ $filters['plan_date'] = $filters['year'] . '-' . str_pad($filters['month'], 2, '0', STR_PAD_LEFT) . '-01';
+ //var_dump($filters); die();
+ $service = new AutoPlannogrammaService();
+ $data = $service->calculateSpeciesForecastForProductsWithoutHistory($filters['plan_date'], $filters);
+
+ //var_dump($data); die();
+ $flatData = array_filter($data, function ($row) use ($filters) {
+ foreach ($filters as $key => $value) {
+ if (empty($value)) continue;
+ if (!isset($row[$key])) continue;
+
+ if (stripos((string)$row[$key], (string)$value) === false) {
+ return false;
+ }
+ }
+ return true;
+ });
+
+ $dataProvider = new ArrayDataProvider([
+ 'allModels' => $flatData,
+ 'pagination' => ['pageSize' => 100],
+ ]);
+ }
+ return $this->render('8', [
+ 'dataProvider' => $dataProvider,
+ 'filters' => $filters,
+ ]);
+ }
+
+ public function actionMonthProductsSpeciesShare()
+ {
+ $request = Yii::$app->request;
+
+ $filters = [
+ 'category' => $request->get('category'),
+ 'subcategory' => $request->get('subcategory') ?? null,
+ 'species' => $request->get('species') ?? null,
+ 'store_id' => $request->get('store_id') ?? [],
+ 'year' => $request->get('year'),
+ 'month' => $request->get('month'),
+ 'type' => $request->get('type'),
+ ];
+
+ $dataProvider = new ArrayDataProvider([
+ 'allModels' => [],
+ 'pagination' => ['pageSize' => 100],
+ ]);
+
+ $bouquetSpeciesForecast = [];
+ // Обработка даты на год и месяц
+ if (!empty($filters['year']) && !empty($filters['month'])) {
+ $filters['plan_date'] = $filters['year'] . '-' . str_pad($filters['month'], 2, '0', STR_PAD_LEFT) . '-01';
+ //var_dump($filters); die();
+ $service = new AutoPlannogrammaService();
+
+ //$goals = $service->calculateFullGoalChain($filters);
+ $monthCategoryShare = $service->getMonthCategoryShareOrWriteOff($filters['plan_date'], $filters);
+ $monthCategoryGoal = $service->getMonthCategoryGoal($monthCategoryShare, $filters['plan_date']);
+ $monthSubcategoryShare = $service->getMonthSubcategoryShareOrWriteOff($filters['plan_date'], $filters);
+ $monthSubcategoryGoal = $service->getMonthSubcategoryGoal($monthSubcategoryShare, $monthCategoryGoal);
+ $monthSpeciesShare = $service->getMonthSpeciesShareOrWriteOff($filters['plan_date'], $filters);
+ $goals = $service->getMonthSpeciesGoalDirty($monthSpeciesShare, $monthSubcategoryGoal);
+
+ if ($filters['type'] == AutoPlannogrammaService::TYPE_WRITE_OFFS) {
+ $monthCategoryWriteOffsShare = $service->getMonthCategoryShareOrWriteOff($filters['plan_date'], $filters, $filters['type']);
+ $monthCategoryWriteOffsGoal = $service->getMonthCategoryGoal($monthCategoryWriteOffsShare, $filters['plan_date'], $filters['type']);
+ $monthSubcategoryWriteOffsShare = $service->getMonthSubcategoryShareOrWriteOff($filters['plan_date'], $filters, $filters['type']);
+ $monthSubcategoryWriteOffsGoals = $service->getMonthSubcategoryGoal($monthSubcategoryWriteOffsShare, $monthCategoryWriteOffsGoal, $filters['type']);
+ $monthSpeciesWriteOffShare = $service->getMonthSpeciesShareOrWriteOff($filters['plan_date'], $filters, $filters['type']);
+ $goals = $service->getMonthSpeciesGoalDirty($monthSpeciesWriteOffShare, $monthSubcategoryWriteOffsGoals, $filters['type'], $data);
+ }
+
+
+ $result = StorePlanService::calculateHistoricalShare(
+ $filters['store_id'],
+ $filters['month'],
+ $filters['year'],
+ $filters['category'],
+ $filters['subcategory'],
+ $filters['species']
+ );
+
+ $noHistoryProductData = $service->calculateSpeciesForecastForProductsWithoutHistory($filters['plan_date'], $filters);
+
+ $productSalesShare = StorePlanService::calculateProductSalesShareProductsWithHistory(
+ $filters['store_id'],
+ $filters['month'],
+ $result['with_history']
+ );
+
+ $productSalesForecast = $service->calculateProductForecastInPiecesProductsWithHistory(
+ $filters['store_id'],
+ $filters['month'],
+ $productSalesShare,
+ $goals,
+ $filters['subcategory'],
+ $filters['category'],
+ $filters['species']
+ );
+
+ /* $matrixForecast = MatrixBouquetForecast::find()
+ ->where(['year' => $filters['year'], 'month' => $filters['month']])
+ ->asArray()
+ ->all();
+ $matrixGroups = array_unique(ArrayHelper::getColumn($matrixForecast, 'group'));
+ $bouquetForecast = StorePlanService::getBouquetSpiecesMonthGoalFromForecast($filters['month'], $filters['year'], $filters['store_id'], $matrixGroups);
+ $speciesData = $bouquetForecast['final'];
+ foreach ($speciesData as $store_id => $categoryData) {
+ foreach ($categoryData as $category => $subcategoryData) {
+ foreach ($subcategoryData as $subcategory => $species) {
+ foreach ($species as $speciesInd => $row) {
+ $bouquetSpeciesForecast[] = [
+ 'category' => $category,
+ 'subcategory' => $subcategory,
+ 'store_id' => $store_id,
+ 'species' => $speciesInd,
+ 'goal' => $row
+ ];
+ }
+ }
+ }
+
+ }
+ $cleanedSpeciesGoals = $service->subtractSpeciesGoals($goals, $bouquetSpeciesForecast, $noHistoryProductData);*/
+
+
+ $salesProductForecastShare = $service->calculateProductForecastShare($noHistoryProductData, $productSalesForecast);
+
+ // $productForecastSpecies = $service->calculateProductSalesBySpecies($salesProductForecastShare, $cleanedSpeciesGoals);
+
+ // var_dump($salesProductForecastShare); die();
+
+
+
+
+
+ $flatData = array_filter($salesProductForecastShare, function ($row) use ($filters) {
+ foreach ($filters as $key => $value) {
+ if (empty($value)) continue;
+ if (!isset($row[$key])) continue;
+
+ if (stripos((string)$row[$key], (string)$value) === false) {
+ return false;
+ }
+ }
+ return true;
+ });
+
+ $dataProvider = new ArrayDataProvider([
+ 'allModels' => $flatData,
+ 'pagination' => ['pageSize' => 100],
+ ]);
+ }
+ return $this->render('month-products-species-share', [
+ 'dataProvider' => $dataProvider,
+ 'filters' => $filters,
+ ]);
+ }
+
+
+ public function actionMonthProductsSpeciesForecast()
+ {
+ $request = Yii::$app->request;
+
+ $filters = [
+ 'category' => $request->get('category'),
+ 'subcategory' => $request->get('subcategory') ?? null,
+ 'species' => $request->get('species') ?? null,
+ 'store_id' => $request->get('store_id') ?? [],
+ 'year' => $request->get('year'),
+ 'month' => $request->get('month'),
+ 'type' => $request->get('type'),
+ ];
+
+ $dataProvider = new ArrayDataProvider([
+ 'allModels' => [],
+ 'pagination' => ['pageSize' => 100],
+ ]);
+
+ $bouquetSpeciesForecast = [];
+ // Обработка даты на год и месяц
+ if (!empty($filters['year']) && !empty($filters['month'])) {
+ $filters['plan_date'] = $filters['year'] . '-' . str_pad($filters['month'], 2, '0', STR_PAD_LEFT) . '-01';
+ //var_dump($filters); die();
+ $service = new AutoPlannogrammaService();
+
+ //$goals = $service->calculateFullGoalChain($filters);
+ $monthCategoryShare = $service->getMonthCategoryShareOrWriteOff($filters['plan_date'], $filters);
+ $monthCategoryGoal = $service->getMonthCategoryGoal($monthCategoryShare, $filters['plan_date']);
+ $monthSubcategoryShare = $service->getMonthSubcategoryShareOrWriteOff($filters['plan_date'], $filters);
+ $monthSubcategoryGoal = $service->getMonthSubcategoryGoal($monthSubcategoryShare, $monthCategoryGoal);
+ $monthSpeciesShare = $service->getMonthSpeciesShareOrWriteOff($filters['plan_date'], $filters);
+ $goals = $service->getMonthSpeciesGoalDirty($monthSpeciesShare, $monthSubcategoryGoal);
+
+ if ($filters['type'] == AutoPlannogrammaService::TYPE_WRITE_OFFS) {
+ $monthCategoryWriteOffsShare = $service->getMonthCategoryShareOrWriteOff($filters['plan_date'], $filters, $filters['type']);
+ $monthCategoryWriteOffsGoal = $service->getMonthCategoryGoal($monthCategoryWriteOffsShare, $filters['plan_date'], $filters['type']);
+ $monthSubcategoryWriteOffsShare = $service->getMonthSubcategoryShareOrWriteOff($filters['plan_date'], $filters, $filters['type']);
+ $monthSubcategoryWriteOffsGoals = $service->getMonthSubcategoryGoal($monthSubcategoryWriteOffsShare, $monthCategoryWriteOffsGoal, $filters['type']);
+ $monthSpeciesWriteOffShare = $service->getMonthSpeciesShareOrWriteOff($filters['plan_date'], $filters, $filters['type']);
+ $goals = $service->getMonthSpeciesGoalDirty($monthSpeciesWriteOffShare, $monthSubcategoryWriteOffsGoals, $filters['type'], $data);
+ }
+
+
+ $result = StorePlanService::calculateHistoricalShare(
+ $filters['store_id'],
+ $filters['month'],
+ $filters['year'],
+ $filters['category'],
+ $filters['subcategory'],
+ $filters['species']
+ );
+
+ $noHistoryProductData = $service->calculateSpeciesForecastForProductsWithoutHistory($filters['plan_date'], $filters);
+
+ $productSalesShare = StorePlanService::calculateProductSalesShareProductsWithHistory(
+ $filters['store_id'],
+ $filters['month'],
+ $result['with_history']
+ );
+
+ $productSalesForecast = $service->calculateProductForecastInPiecesProductsWithHistory(
+ $filters['store_id'],
+ $filters['month'],
+ $productSalesShare,
+ $goals,
+ $filters['subcategory'],
+ $filters['category'],
+ $filters['species']
+ );
+
+ $matrixForecast = MatrixBouquetForecast::find()
+ ->where(['year' => $filters['year'], 'month' => $filters['month']])
+ ->asArray()
+ ->all();
+ $matrixGroups = array_unique(ArrayHelper::getColumn($matrixForecast, 'group'));
+ $bouquetForecast = StorePlanService::getBouquetSpiecesMonthGoalFromForecast($filters['month'], $filters['year'], $filters['store_id'], $matrixGroups);
+ $speciesData = $bouquetForecast['final'];
+ foreach ($speciesData as $store_id => $categoryData) {
+ foreach ($categoryData as $category => $subcategoryData) {
+ foreach ($subcategoryData as $subcategory => $species) {
+ foreach ($species as $speciesInd => $row) {
+ $bouquetSpeciesForecast[] = [
+ 'category' => $category,
+ 'subcategory' => $subcategory,
+ 'store_id' => $store_id,
+ 'species' => $speciesInd,
+ 'goal' => $row
+ ];
+ }
+ }
+ }
+
+ }
+ $cleanedSpeciesGoals = $service->subtractSpeciesGoals($goals, $bouquetSpeciesForecast, $noHistoryProductData);
+
+
+ $salesProductForecastShare = $service->calculateProductForecastShare($noHistoryProductData, $productSalesForecast);
+
+ $productForecastSpecies = $service->calculateProductSalesBySpecies($salesProductForecastShare, $cleanedSpeciesGoals);
+
+
+
+ //var_dump($productForecastSpecies); die();
+
+
+
+
+
+ $flatData = array_filter($productForecastSpecies, function ($row) use ($filters) {
+ foreach ($filters as $key => $value) {
+ if (empty($value)) continue;
+ if (!isset($row[$key])) continue;
+
+ if (stripos((string)$row[$key], (string)$value) === false) {
+ return false;
+ }
+ }
+ return true;
+ });
+
+ $dataProvider = new ArrayDataProvider([
+ 'allModels' => $flatData,
+ 'pagination' => ['pageSize' => 100],
+ ]);
+ }
+ return $this->render('month-products-species-forecast', [
+ 'dataProvider' => $dataProvider,
+ 'filters' => $filters,
+ ]);
+ }
+
+ public function action9()
+ {
+ $request = Yii::$app->request;
+
+ $filters = [
+ 'category' => $request->get('category'),
+ 'subcategory' => $request->get('subcategory') ?? null,
+ 'species' => $request->get('species') ?? null,
+ 'store_id' => $request->get('store_id') ?? [],
+ 'year' => $request->get('year'),
+ 'month' => $request->get('month'),
+ 'type' => $request->get('type'),
+ ];
+
+ $dataProvider = new ArrayDataProvider([
+ 'allModels' => [],
+ 'pagination' => ['pageSize' => 100],
+ ]);
+
+ $bouquetSpeciesForecast = [];
+ // Обработка даты на год и месяц
+ if (!empty($filters['year']) && !empty($filters['month'])) {
+ $filters['plan_date'] = $filters['year'] . '-' . str_pad($filters['month'], 2, '0', STR_PAD_LEFT) . '-01';
+
+ $service = new AutoPlannogrammaService();
+ $monthCategoryShare = $service->getMonthCategoryShareOrWriteOff($filters['plan_date'], $filters, $filters['type']);
+
+ $monthCategoryGoal = $service->getMonthCategoryGoal($monthCategoryShare, $filters['plan_date']);
+ $monthSubcategoryShare = $service->getMonthSubcategoryShareOrWriteOff($filters['plan_date'], $filters);
+ $monthSubcategoryGoal = $service->getMonthSubcategoryGoal($monthSubcategoryShare, $monthCategoryGoal);
+ $monthSpeciesShare = $service->getMonthSpeciesShareOrWriteOff($filters['plan_date'], $filters);
+ $data = $service->getMonthSpeciesGoalDirty($monthSpeciesShare, $monthSubcategoryGoal);
+
+ if ($filters['type'] == AutoPlannogrammaService::TYPE_WRITE_OFFS) {
+ $monthCategoryWriteOffsShare = $service->getMonthCategoryShareOrWriteOff($filters['plan_date'], $filters, $filters['type']);
+ $monthCategoryWriteOffsGoal = $service->getMonthCategoryGoal($monthCategoryWriteOffsShare, $filters['plan_date'], $filters['type']);
+ $monthSubcategoryWriteOffsShare = $service->getMonthSubcategoryShareOrWriteOff($filters['plan_date'], $filters, $filters['type']);
+ $monthSubcategoryWriteOffsGoals = $service->getMonthSubcategoryGoal($monthSubcategoryWriteOffsShare, $monthCategoryWriteOffsGoal, $filters['type']);
+ $monthSpeciesWriteOffShare = $service->getMonthSpeciesShareOrWriteOff($filters['plan_date'], $filters, $filters['type']);
+ $data = $service->getMonthSpeciesGoalDirty($monthSpeciesWriteOffShare, $monthSubcategoryWriteOffsGoals, $filters['type'], $data);
+ }
+
+
+ $matrixForecast = MatrixBouquetForecast::find()
+ ->where(['year' => $filters['year'], 'month' => $filters['month']])
+ ->asArray()
+ ->all();
+ $matrixGroups = array_unique(ArrayHelper::getColumn($matrixForecast, 'group'));
+ $bouquetForecast = StorePlanService::getBouquetSpiecesMonthGoalFromForecast($filters['month'], $filters['year'], $filters['store_id'], $matrixGroups);
+ $speciesData = $bouquetForecast['final'];
+ foreach ($speciesData as $store_id => $categoryData) {
+ foreach ($categoryData as $category => $subcategoryData) {
+ foreach ($subcategoryData as $subcategory => $species) {
+ foreach ($species as $speciesInd => $row) {
+ $bouquetSpeciesForecast[] = [
+ 'category' => $category,
+ 'subcategory' => $subcategory,
+ 'store_id' => $store_id,
+ 'species' => $speciesInd,
+ 'goal' => $row
+ ];
+ }
+ }
+ }
+
+ }
+ //var_dump($bouquetSpeciesForecast); die();
+ $noHistoryProductData = $service->calculateSpeciesForecastForProductsWithoutHistory($filters['plan_date'], $filters);
+ // var_dump($noHistoryProductData); die();
+ $cleanedSpeciesGoals = $service->subtractSpeciesGoals($data, $bouquetSpeciesForecast, $noHistoryProductData);
+ //var_dump($cleanedSpeciesGoals); die();
+
+ $flatData = array_filter($cleanedSpeciesGoals, function ($row) use ($filters) {
+ foreach ($filters as $key => $value) {
+ if (empty($value)) continue;
+ if (!isset($row[$key])) continue;
+
+ if (stripos((string)$row[$key], (string)$value) === false) {
+ return false;
+ }
+ }
+ return true;
+ });
+
+ $dataProvider = new ArrayDataProvider([
+ 'allModels' => $flatData,
+ 'pagination' => ['pageSize' => 100],
+ ]);
+ }
+ return $this->render('9', [
+ 'dataProvider' => $dataProvider,
+ 'filters' => $filters,
+ ]);
+ }
+
+
+ public function actionWeekSalesSpeciesShare()
+ {
+ $request = Yii::$app->request;
+
+ $filters = [
+ 'category' => $request->get('category'),
+ 'subcategory' => $request->get('subcategory') ?? null,
+ 'species' => $request->get('species') ?? null,
+ 'store_id' => $request->get('store_id') ?? [],
+ 'year' => $request->get('year'),
+ 'month' => $request->get('month'),
+ 'type' => $request->get('type'),
+ ];
+
+ $dataProvider = new ArrayDataProvider([
+ 'allModels' => [],
+ 'pagination' => ['pageSize' => 100],
+ ]);
+
+ $bouquetSpeciesForecast = [];
+ // Обработка даты на год и месяц
+ if (!empty($filters['year']) && !empty($filters['month'])) {
+ $filters['plan_date'] = $filters['year'] . '-' . str_pad($filters['month'], 2, '0', STR_PAD_LEFT) . '-01';
+ // var_dump( $filters['plan_date']); die();
+ $service = new AutoPlannogrammaService();
+
+
+
+ $weeklySales = $service->getHistoricalSpeciesShareByWeek($filters['plan_date'], $filters);
+
+
+
+
+
+
+ $flatData = array_filter($weeklySales, function ($row) use ($filters) {
+ foreach ($filters as $key => $value) {
+ if (empty($value)) continue;
+ if (!isset($row[$key])) continue;
+
+ if (stripos((string)$row[$key], (string)$value) === false) {
+ return false;
+ }
+ }
+ return true;
+ });
+
+ $dataProvider = new ArrayDataProvider([
+ 'allModels' => $flatData,
+ 'pagination' => ['pageSize' => 100],
+ ]);
+ }
+ return $this->render('week-sales-species-share', [
+ 'dataProvider' => $dataProvider,
+ 'filters' => $filters,
+ ]);
+ }
+
+
+ public function actionWeekSalesProductsForecast()
+ {
+ $request = Yii::$app->request;
+
+ $filters = [
+ 'category' => $request->get('category'),
+ 'subcategory' => $request->get('subcategory') ?? null,
+ 'species' => $request->get('species') ?? null,
+ 'store_id' => $request->get('store_id') ?? [],
+ 'year' => $request->get('year'),
+ 'month' => $request->get('month'),
+ 'type' => $request->get('type'),
+ ];
+
+ $dataProvider = new ArrayDataProvider([
+ 'allModels' => [],
+ 'pagination' => ['pageSize' => 100],
+ ]);
+
+ $bouquetSpeciesForecast = [];
+ // Обработка даты на год и месяц
+ if (!empty($filters['year']) && !empty($filters['month'])) {
+ $filters['plan_date'] = $filters['year'] . '-' . str_pad($filters['month'], 2, '0', STR_PAD_LEFT) . '-01';
+ // var_dump( $filters['plan_date']); die();
+ $service = new AutoPlannogrammaService();
+ //$goals = $service->calculateFullGoalChain($filters);
+ $monthCategoryShare = $service->getMonthCategoryShareOrWriteOff($filters['plan_date'], $filters);
+ $monthCategoryGoal = $service->getMonthCategoryGoal($monthCategoryShare, $filters['plan_date']);
+ $monthSubcategoryShare = $service->getMonthSubcategoryShareOrWriteOff($filters['plan_date'], $filters);
+ $monthSubcategoryGoal = $service->getMonthSubcategoryGoal($monthSubcategoryShare, $monthCategoryGoal);
+ $monthSpeciesShare = $service->getMonthSpeciesShareOrWriteOff($filters['plan_date'], $filters);
+ $goals = $service->getMonthSpeciesGoalDirty($monthSpeciesShare, $monthSubcategoryGoal);
+
+ if ($filters['type'] == AutoPlannogrammaService::TYPE_WRITE_OFFS) {
+ $monthCategoryWriteOffsShare = $service->getMonthCategoryShareOrWriteOff($filters['plan_date'], $filters, $filters['type']);
+ $monthCategoryWriteOffsGoal = $service->getMonthCategoryGoal($monthCategoryWriteOffsShare, $filters['plan_date'], $filters['type']);
+ $monthSubcategoryWriteOffsShare = $service->getMonthSubcategoryShareOrWriteOff($filters['plan_date'], $filters, $filters['type']);
+ $monthSubcategoryWriteOffsGoals = $service->getMonthSubcategoryGoal($monthSubcategoryWriteOffsShare, $monthCategoryWriteOffsGoal, $filters['type']);
+ $monthSpeciesWriteOffShare = $service->getMonthSpeciesShareOrWriteOff($filters['plan_date'], $filters, $filters['type']);
+ $goals = $service->getMonthSpeciesGoalDirty($monthSpeciesWriteOffShare, $monthSubcategoryWriteOffsGoals, $filters['type'], $data);
+ }
+
+
+ $result = StorePlanService::calculateHistoricalShare(
+ $filters['store_id'],
+ $filters['month'],
+ $filters['year'],
+ $filters['category'],
+ $filters['subcategory'],
+ $filters['species']
+ );
+
+ $noHistoryProductData = $service->calculateSpeciesForecastForProductsWithoutHistory($filters['plan_date'], $filters);
+
+ $productSalesShare = StorePlanService::calculateProductSalesShareProductsWithHistory(
+ $filters['store_id'],
+ $filters['month'],
+ $result['with_history']
+ );
+
+ $productSalesForecast = $service->calculateProductForecastInPiecesProductsWithHistory(
+ $filters['store_id'],
+ $filters['month'],
+ $productSalesShare,
+ $goals,
+ $filters['subcategory'],
+ $filters['category'],
+ $filters['species']
+ );
+
+ $matrixForecast = MatrixBouquetForecast::find()
+ ->where(['year' => $filters['year'], 'month' => $filters['month']])
+ ->asArray()
+ ->all();
+ $matrixGroups = array_unique(ArrayHelper::getColumn($matrixForecast, 'group'));
+ $bouquetForecast = StorePlanService::getBouquetSpiecesMonthGoalFromForecast($filters['month'], $filters['year'], $filters['store_id'], $matrixGroups);
+ $speciesData = $bouquetForecast['final'];
+ foreach ($speciesData as $store_id => $categoryData) {
+ foreach ($categoryData as $category => $subcategoryData) {
+ foreach ($subcategoryData as $subcategory => $species) {
+ foreach ($species as $speciesInd => $row) {
+ $bouquetSpeciesForecast[] = [
+ 'category' => $category,
+ 'subcategory' => $subcategory,
+ 'store_id' => $store_id,
+ 'species' => $speciesInd,
+ 'goal' => $row
+ ];
+ }
+ }
+ }
+
+ }
+ $cleanedSpeciesGoals = $service->subtractSpeciesGoals($goals, $bouquetSpeciesForecast, $noHistoryProductData);
+
+
+ $salesProductForecastShare = $service->calculateProductForecastShare($noHistoryProductData, $productSalesForecast);
+
+ $productForecastSpecies = $service->calculateProductSalesBySpecies($salesProductForecastShare, $cleanedSpeciesGoals);
+
+
+ $weeklySales = $service->getHistoricalSpeciesShareByWeek($filters['plan_date'], $filters);
+
+ $weeklySalesForecast = $service->calculateWeeklyProductForecastPieces($productForecastSpecies, $weeklySales);
+
+
+ $weeklySalesForecastFormated = $service->pivotWeeklyForecast($weeklySalesForecast);
+
+ $flatData = array_filter($weeklySalesForecastFormated, function ($row) use ($filters) {
+ foreach ($filters as $key => $value) {
+ if (empty($value)) continue;
+ if (!isset($row[$key])) continue;
+
+ if (stripos((string)$row[$key], (string)$value) === false) {
+ return false;
+ }
+ }
+ return true;
+ });
+
+ $dataProvider = new ArrayDataProvider([
+ 'allModels' => $flatData,
+ 'pagination' => ['pageSize' => 100],
+ ]);
+ }
+ return $this->render('week-sales-products_forecast', [
+ 'dataProvider' => $dataProvider,
+ 'filters' => $filters,
+ ]);
+ }
+
+
++
+
+ public function actionControlSpeciesOld()
+ {
+ $model = new DynamicModel([
+ 'storeId', 'month', 'type',
+
+ ]);
+ $model->addRule(['month', 'type'], 'required')
+ ->addRule('storeId', 'integer');
+
+ $storeList = CityStore::find()
+ ->select(['name','id'])
+ ->where(['visible' => CityStore::IS_VISIBLE])
+ ->indexBy('id')
+ ->column();
+
+ $monthsList = [];
+ for ($i = 0; $i < 12; $i++) {
+ // получаем метку вида "03-2025"
+ $ts = strtotime("first day of -{$i} month");
+ $key = date('m-Y', $ts);
+ $monthsList[$key] = $key;
+ }
+
+ $monthResult = [];
+ $totals = [];
+ $weeksData = [];
+ $weeksShareResult = [];
+ $weeksGoalResult = [];
+ $monthCategoryShareResult = [];
+ $weeksProductForecast = [];
+
+ if ($model->load(Yii::$app->request->post()) && $model->validate()) {
+ $filters = [];
+
+ list($m, $y) = explode('-', $model->month);
+ $dateFrom = date("Y-m-d 00:00:00", strtotime(sprintf('%04d-%02d-01', $y, $m)));
+ $dateTo = date("Y-m-t 23:59:59", strtotime($dateFrom));
+
+ if ($model->storeId) {
+ $filters['store_id'] = $model->storeId;
+ $filters['type'] = $model->type;
+ $filters['plan_date'] = $dateFrom;
+ }
+
+ $service = new AutoPlannogrammaService();
+
+ if ($model->storeId) {
+ $totals = $service->getStoreTotals(
+ [$model->storeId],
+ $dateFrom,
+ null,
+ $model->type,
+ $dateTo
+ );
+ }
+
+
+
+ $monthSpeciesGoals = $service->calculateFullGoalChainWeighted($filters);
+ $monthSpeciesGoalsMap = [];
+ foreach ($monthSpeciesGoals as $monthSpeciesGoal) {
+ $monthSpeciesGoalsMap[$monthSpeciesGoal['store_id']]
+ [$monthSpeciesGoal['category']]
+ [$monthSpeciesGoal['subcategory']]
+ [$monthSpeciesGoal['species']] = $monthSpeciesGoal['goal'] ;
+ }
+
+
+
+
+ $weeksShareResult = $service->getHistoricalWeeklySpeciesShare($model->month, $filters, null, 'writeOffs');
+ $weeksData = $service->calculateWeeklySpeciesGoals($weeksShareResult['weeksData'], $monthSpeciesGoals) ;
+
+ $datePlan = $filters['plan_date'];
+ $monthCategoryShare = $service->getMonthCategoryShareOrWriteOffWeighted($datePlan, $filters, null, $filters['type']);
+ $monthCategoryGoal = $service->getMonthCategoryGoal($monthCategoryShare, $datePlan, $filters);
+ foreach ($monthCategoryShare as $sid => $cats) {
+ foreach($cats as $cat) {
+ $monthCategoryShareResult[$sid][$cat['category']]['total_sum_cat'] = $cat['total_sum_cat'];
+ $monthCategoryShareResult[$sid][$cat['category']]['share_of_total'] = $cat['share_of_total'];
+ }
+
+ }
+ foreach ($monthCategoryGoal as $cats) {
+ $monthCategoryShareResult[$cats['store_id']][$cats['category']]['goal'] = $cats['goal'];
+
+ }
+ //var_dump($monthCategoryShareResult); die();
+ $monthSubcategoryShare = $service->getMonthSubcategoryShareOrWriteOffWeighted($datePlan, $filters, null, $filters['type']);
+ $monthSubcategoryGoal = $service->getMonthSubcategoryGoal($monthSubcategoryShare, $monthCategoryGoal);
+
+ if ($filters['type'] === 'writeOffs') {
+ $salesSubShare = $service->getMonthSubcategoryShareOrWriteOffWeighted($datePlan, $filters, null, 'sales');
+ $salesSubGoal = $service->getMonthSubcategoryGoal($salesSubShare, $monthCategoryGoal);
+
+ $catGoalMap = [];
+ foreach ($monthCategoryGoal as $row) {
+ $catGoalMap[$row['category']] = $row['goal'];
+ }
+ $salesSubGoalMap = [];
+ foreach ($salesSubGoal as $row) {
+ $salesSubGoalMap[$row['category']][$row['subcategory']] = $row['goal'];
+ }
+
+
+ foreach ($monthSubcategoryShare as &$row) {
+ $cat = $row['category'];
+ $sub = $row['subcategory'];
+
+ $writeShare = $row['percent_of_month'];
+
+ $writeGoal = ($catGoalMap[$cat] ?? 0) * $writeShare;
+ $saleGoal = $salesSubGoalMap[$cat][$sub] ?? 0;
+
+ if ($saleGoal > 0 && $writeGoal > 0.1 * $saleGoal) {
+ $row['share'] = 0.1;
+ }
+ }
+ unset($row);
+ $monthSubcategoryGoal = $service->getMonthSubcategoryGoal($monthSubcategoryShare, $monthCategoryGoal);
+ }
+ // var_dump($monthSubcategoryShare); die();
+ foreach ($monthSubcategoryShare as $subcat) {
+ $monthCategoryShareResult[$subcat['store_id']][$subcat['category']][$subcat['subcategory']]['total_sum'] = $subcat['total_sum'];
+ $monthCategoryShareResult[$subcat['store_id']][$subcat['category']][$subcat['subcategory']]['percent_of_month'] = $subcat['percent_of_month'];
+ }
+ foreach ($monthSubcategoryGoal as $cats) {
+ $monthCategoryShareResult[$cats['store_id']][$cats['category']][$cats['subcategory']]['goal'] = $cats['goal'];
+
+ }
+ $monthSpeciesShare = $service->getMonthSpeciesShareOrWriteOffWeighted($datePlan, $datePlan, $filters, null, $filters['type']);
+ $monthSpeciesGoal = $service->getMonthSpeciesGoalDirty($monthSpeciesShare, $monthSubcategoryGoal);
+ if ($filters['type'] === 'writeOffs') {
+ $salesSpecShare = $service->getMonthSpeciesShareOrWriteOffWeighted($datePlan, $datePlan, $filters, null, 'sales');
+ $salesSpecGoal = $service->getMonthSpeciesGoalDirty($salesSpecShare, $monthSubcategoryGoal);
+
+ $subGoalMap = [];
+ foreach ($monthSubcategoryGoal as $row) {
+ $subGoalMap[$row['category']][$row['subcategory']] = $row['goal'];
+ }
+ $salesSpecGoalMap = [];
+ foreach ($salesSpecGoal as $row) {
+ $salesSpecGoalMap[$row['category']][$row['subcategory']][$row['species']] = $row['goal'];
+ }
+
+ foreach ($monthSpeciesShare as &$row) {
+ $cat = $row['category'];
+ $sub = $row['subcategory'];
+ $spec = $row['species'];
+
+ $writeShare = $row['percent_of_month'];
+ $writeGoal = ($subGoalMap[$cat][$sub] ?? 0) * $writeShare;
+ $saleGoal = $salesSpecGoalMap[$cat][$sub][$spec] ?? 0;
+
+ if ($saleGoal > 0 && $writeGoal > 0.1 * $saleGoal) {
+ $row['share'] = 0.1;
+ }
+ }
+ unset($row);
+
+ $monthSpeciesGoal = $service->getMonthSpeciesGoalDirty($monthSpeciesShare, $monthSubcategoryGoal);
+ }
+ foreach ($monthSpeciesShare as $species) {
+ $monthCategoryShareResult[$species['store_id']][$species['category']][$species['subcategory']][$species['species']]['total_sum'] = $species['total_sum'];
+ $monthCategoryShareResult[$species['store_id']][$species['category']][$species['subcategory']][$species['species']]['percent_of_month'] = $species['percent_of_month'];
+
+ }
+ foreach ($weeksShareResult['weeksData'] as $row) {
+ $monthCategoryShareResult[$row['store_id']][$row['category']][$row['subcategory']][$row['species']][$row['week']]['sumWeek'] = $row['sumWeek'];
+
+ }
+
+
+ foreach ($weeksData as $r) {
+ $forecasts = $service->calculateWeekForecastSpeciesProducts($r['category'], $r['subcategory'], $r['species'], $r['store_id'], $r['weekly_goal']);
+ foreach ($forecasts as $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' => $forecast['forecast'] ?? 0,
+ 'week' => $r['week'],
+ ];
+ }
+ }
+
+ usort($weeksProductForecast, function($a, $b) {
+ foreach (['category','subcategory','species','name','week'] as $key) {
+ $va = $a[$key];
+ $vb = $b[$key];
+ if ($va < $vb) return -1;
+ if ($va > $vb) return 1;
+ }
+ return 0;
+ });
+
+ }
+//var_dump($weeksProductForecast); die();
+ return $this->render('control-species-old', [
+ 'model' => $model,
+ 'result' => $monthResult,
+ 'weeksData' => $weeksData,
+ 'monthCategoryShare' => $monthCategoryShareResult,
+ 'weeksProductForecast' => $weeksProductForecast,
+ 'totals' => $totals,
+ 'storeList' => $storeList,
+ 'monthsList' => $monthsList,
+
+ ]);
+ }
+
+
+ public function actionControlSpecies()
+ {
+ $model = new DynamicModel([
+ 'storeId', 'month', 'type',
+
+ ]);
+ $model->addRule(['month', 'type'], 'required')
+ ->addRule('storeId', 'integer');
+
+ $storeList = CityStore::find()
+ ->select(['name','id'])
+ ->where(['visible' => CityStore::IS_VISIBLE])
+ ->indexBy('id')
+ ->column();
+
+ $monthsList = [];
+ for ($i = 0; $i < 12; $i++) {
+ // получаем метку вида "03-2025"
+ $ts = strtotime("first day of -{$i} month");
+ $key = date('m-Y', $ts);
+ $monthsList[$key] = $key;
+ }
+
+ $monthResult = [];
+ $totals = [];
+ $weeksData = [];
+ $weeksShareResult = [];
+ $weeksGoalResult = [];
+ $monthCategoryShareResult = [];
+ $weeksProductForecast = [];
+
+ if ($model->load(Yii::$app->request->post()) && $model->validate()) {
+ $filters = [];
+
+ list($m, $y) = explode('-', $model->month);
+ $dateFrom = date("Y-m-d 00:00:00", strtotime(sprintf('%04d-%02d-01', $y, $m)));
+ $dateTo = date("Y-m-t 23:59:59", strtotime($dateFrom));
+
+ if ($model->storeId) {
+ $filters['store_id'] = $model->storeId;
+ $filters['type'] = $model->type;
+ $filters['plan_date'] = $dateFrom;
+ }
+
+ $service = new AutoPlannogrammaService();
+
+ $monthSpeciesGoals = $service->calculateFullGoalChain($filters);
+ $monthSpeciesGoalsMap = [];
+ foreach ($monthSpeciesGoals as $monthSpeciesGoal) {
+ $monthSpeciesGoalsMap[$monthSpeciesGoal['store_id']]
+ [$monthSpeciesGoal['category']]
+ [$monthSpeciesGoal['subcategory']]
+ [$monthSpeciesGoal['species']] = $monthSpeciesGoal['goal'];
+ }
+
+
+ $weeksShareResult = $service->getHistoricalWeeklySpeciesShare($model->month, $filters, null, 'writeOffs');
+ $weeksData = $service->calculateWeeklySpeciesGoals($weeksShareResult['weeksData'], $monthSpeciesGoals) ;
+
+ $datePlan = $filters['plan_date'];
+ $monthCategoryShare = $service->getMonthCategoryShareOrWriteOff($datePlan, $filters, $filters['type']);
+ $monthCategoryGoal = $service->getMonthCategoryGoal($monthCategoryShare, $datePlan, $filters['type']);
+
+ $monthCategorySalesShare = $service->getMonthCategoryShareOrWriteOff($datePlan, $filters);
+ $monthCategorySalesGoal = $service->getMonthCategoryGoal($monthCategorySalesShare, $datePlan);
+
+ foreach ($monthCategoryShare as $sid => $cats) {
+ foreach($cats as $cat) {
+ $monthCategoryShareResult[$sid][$cat['category']]['total_sum'] = $cat['total_sum'];
+ $monthCategoryShareResult[$sid][$cat['category']]['percent'] = $cat['percent'];
+ }
+
+ }
+ foreach ($monthCategoryGoal as $cats) {
+ $monthCategoryShareResult[$cats['store_id']][$cats['category']]['goal'] = $cats['goal'];
+
+ }
+ $monthSubcategorySalesShare = $service->getMonthSubcategoryShareOrWriteOff($datePlan, $filters);
+ $monthSubcategorySalesGoal = $service->getMonthSubcategoryGoal($monthSubcategorySalesShare, $monthCategorySalesGoal);
+
+ $monthSubcategoryShare = $service->getMonthSubcategoryShareOrWriteOff($datePlan, $filters, $filters['type']);
+ $monthSubcategoryGoal = $service->getMonthSubcategoryGoal($monthSubcategoryShare, $monthCategoryGoal, $filters['type'], $monthSubcategorySalesGoal);
+
+
+
+ // var_dump($monthSubcategoryShare); die();
+ foreach ($monthSubcategoryShare as $subcat) {
+ $monthCategoryShareResult[$subcat['store_id']][$subcat['category']][$subcat['subcategory']]['total_sum'] = $subcat['total_sum'];
+ $monthCategoryShareResult[$subcat['store_id']][$subcat['category']][$subcat['subcategory']]['percent'] = $subcat['percent'];
+ }
+ foreach ($monthSubcategoryGoal as $cats) {
+ $monthCategoryShareResult[$cats['store_id']][$cats['category']][$cats['subcategory']]['goal'] = $cats['goal'];
+
+ }
+ $monthSpeciesSalesShare = $service->getMonthSpeciesShareOrWriteOff($datePlan, $filters);
+ $monthSpeciesSalesGoal = $service->getMonthSpeciesGoalDirty($monthSpeciesSalesShare, $monthSubcategoryGoal);
+
+ $monthSpeciesShare = $service->getMonthSpeciesShareOrWriteOff($datePlan, $filters, $filters['type']);
+ $monthSpeciesGoal = $service->getMonthSpeciesGoalDirty($monthSpeciesShare, $monthSubcategoryGoal, $filters['type'], $monthSpeciesSalesGoal);
+
+ foreach ($monthSpeciesShare as $species) {
+ $monthCategoryShareResult[$species['store_id']][$species['category']][$species['subcategory']][$species['species']]['total_sum'] = $species['total_sum'];
+ $monthCategoryShareResult[$species['store_id']][$species['category']][$species['subcategory']][$species['species']]['percent'] = $species['percent'];
+
+ }
+ foreach ($monthSpeciesGoal as $cats) {
+ $monthCategoryShareResult[$cats['store_id']][$cats['category']][$cats['subcategory']][$cats['species']]['goal'] = $cats['goal'];
+
+ }
+
+ foreach ($weeksShareResult['weeksData'] as $row) {
+ $monthCategoryShareResult[$row['store_id']][$row['category']][$row['subcategory']][$row['species']][$row['week']]['sumWeek'] = $row['sumWeek'];
+
+ }
+ //var_dump($monthCategoryShareResult); die();
+
+ foreach ($weeksData as $r) {
+ $forecasts = $service->calculateWeekForecastSpeciesProducts($r['category'], $r['subcategory'], $r['species'], $r['store_id'], $r['weekly_goal']);
+ foreach ($forecasts as $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' => $forecast['forecast'] ?? 0,
+ 'week' => $r['week'],
+ ];
+ }
+ }
+
+ usort($weeksProductForecast, function($a, $b) {
+ foreach (['category','subcategory','species','name','week'] as $key) {
+ $va = $a[$key];
+ $vb = $b[$key];
+ if ($va < $vb) return -1;
+ if ($va > $vb) return 1;
+ }
+ return 0;
+ });
+
+ }
+//var_dump($weeksProductForecast); die();
+ return $this->render('control-species', [
+ 'model' => $model,
+ 'result' => $monthResult,
+ 'weeksData' => $weeksData,
+ 'monthCategoryShare' => $monthCategoryShareResult,
+ 'weeksProductForecast' => $weeksProductForecast,
+ 'totals' => $totals,
+ 'storeList' => $storeList,
+ 'monthsList' => $monthsList,
+
+ ]);
+ }
+
+
+
}
namespace yii_app\services;
+ use DateTime;
+ use Yii;
use yii\db\Expression;
+ use yii\db\mssql\PDO;
use yii\db\Query;
use yii\helpers\ArrayHelper;
+ use yii_app\records\BouquetComposition;
use yii_app\records\CityStore;
-use yii_app\records\PricesDynamic;
+use yii_app\records\PricesDynamic;
+use yii_app\records\Products1cNomenclature;
+ use yii_app\records\CityStoreParams;
+ use yii_app\records\ExportImportTable;
-use yii_app\records\Products1cNomenclature;
+ use yii_app\records\Products1c;
+ use yii_app\records\Sales;
+ use yii_app\records\SalesProducts;
use yii_app\records\SalesWriteOffsPlan;
+ use yii_app\records\StorePlan;
+ use yii_app\records\WriteOffs;
+ use yii_app\records\WriteOffsProducts;
class AutoPlannogrammaService
{
':month3' => $month3,
]);
}
--
++
// Основной запрос с CTE
$query = (new Query())
->select([
}
+ // Недельные расчеты
+
+
+ /**
+ * Получает суммы продаж или списаний по видам (species) для каждой недели указанного месяца.
+ *
+ * @param string $monthYear месяц-год в формате MM-YYYY, например '03-2025'
+ * @param array|null $filters опциональные фильтры ['store_id'=>...]
+ * @param array|null $productFilter опциональный фильтр по product_id
+ * @param string $type 'sales' или 'writeOffs'
+ * @return array<int, array> массив строк: [
+ * ['week'=>1,'store_id'=>...,'category'=>...,'subcategory'=>...,'species'=>...,'sum'=>...],
+ * ...
+ * ]
+ */
+ public function getWeeklySpeciesDataForMonth(
+ string $monthYear,
+ ?array $filters = null,
+ ?array $productFilter = null,
+ string $type = 'sales'
+ ): array {
+ [$yearStr, $monthStr ] = explode('-', $monthYear);
+ $month = (int)$monthStr;
+ $year = (int)$yearStr;
+
+ $dateFrom = strtotime(sprintf('%04d-%02d-01 00:00:00', $year, $month));
+ $dateTo = strtotime('+1 month -1 second', $dateFrom);
+
+ $stores = $this->getVisibleStores();
+ $storeIds = array_map(fn($s)=>$s->id, $stores);
+ if (!empty($filters['store_id'])) {
+ $storeIds = array_intersect($storeIds, [(int)$filters['store_id']]);
+ }
+ if (empty($storeIds)) {
+ return [];
+ }
+
+ $dayOfWeek = (int)date('N', $dateFrom);
+ $firstMonday = $dayOfWeek === 1
+ ? $dateFrom
+ : strtotime('next monday', $dateFrom);
+
+
+ $weekRanges = [];
+ for ($wkStart = $firstMonday; $wkStart <= $dateTo; $wkStart += 7 * 86400) {
+ $wkEnd = $wkStart + 6 * 86400;
+ if ($wkEnd > $dateTo) {
+ $wkEnd = $dateTo;
+ }
+ $periodStart = max($wkStart, $dateFrom);
+ $periodEnd = min($wkEnd, $dateTo);
+ $daysInMonth = floor(($periodEnd - $periodStart) / 86400) + 1;
+ if ($daysInMonth >= 4) {
+ $weekRanges[] = [
+ 'index' => (int)date('W', $wkStart),
+ 'start' => date('Y-m-d H:i:s', $wkStart),
+ 'end' => date('Y-m-d 23:59:59', $wkEnd),
+ ];
+ }
+ }
+
+ $result = [];
+
+ foreach ($weekRanges as $range) {
+ $exprWeek = new Expression((string)$range['index']);
+ $query = (new Query())->select([
+ 'week' => $exprWeek,
+ 'store_id' => 'ex.entity_id',
+ 'category' => 'p1c.category',
+ 'subcategory' => 'p1c.subcategory',
+ 'species' => 'p1c.species',
+ 'total_sum' => new Expression(
+ $type === 'writeOffs'
+ ? 'SUM(CAST(wop.summ AS NUMERIC))'
+ : 'SUM(sp.summ)'
+ ),
+ ]);
+
+ if ($type === 'writeOffs') {
+ $query->from(['w' => 'write_offs'])
+ ->leftJoin(['ex' => 'export_import_table'], 'ex.export_val = w.store_id')
+ ->leftJoin(['wop'=> 'write_offs_products'], 'wop.write_offs_id = w.id')
+ ->leftJoin(['p1c'=> 'products_1c_nomenclature'], 'p1c.id = wop.product_id')
+ ->andWhere(['>=', 'w.date', $range['start']])
+ ->andWhere(['<=', 'w.date', $range['end']]);
+ if ($productFilter !== null) {
+ $query->andWhere(['wop.product_id' => $productFilter]);
+ }
+ } else {
+ $query->from(['s' => 'sales'])
+ ->leftJoin(['sp' => 'sales_products'], 'sp.check_id = s.id')
+ ->leftJoin(['ex' => 'export_import_table'], 'ex.export_val = s.store_id_1c')
+ ->leftJoin(['p1c'=> 'products_1c_nomenclature'], 'p1c.id = sp.product_id')
+ ->andWhere(['>=', 's.date', $range['start']])
+ ->andWhere(['<=', 's.date', $range['end']]);
+ if ($productFilter !== null) {
+ $query->andWhere(['sp.product_id' => $productFilter]);
+ }
+ }
+
+ $query->andWhere(['ex.entity_id' => $storeIds])
+ ->andWhere(['<>', 'p1c.species', ''])
+ ->groupBy(['week','ex.entity_id','p1c.category','p1c.subcategory','p1c.species']);
+
+ $rows = $query->all();
+ foreach ($rows as $row) {
+ $result[] = [
+ 'week' => $row['week'],
+ 'store_id' => $row['store_id'],
+ 'category' => $row['category'],
+ 'subcategory' => $row['subcategory'],
+ 'species' => $row['species'],
+ 'sum' => (float)$row['total_sum'],
+ ];
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * Возвращает диапазоны недель (index, start, end) для указанного года и месяца,
+ * взятые по правилу «неделя считается, если в неё входит ≥4 дня из этого месяца».
+ *
+ * @param int $year Год (например, 2025)
+ * @param int $month Месяц (1–12)
+ * @return array<array{index:int, start:string, end:string}>
+ */
+ public function getWeekRangesForMonth(int $year, int $month): array
+ {
+ $dateFrom = strtotime(sprintf('%04d-%02d-01 00:00:00', $year, $month));
+ $dateTo = strtotime('+1 month -1 second', $dateFrom);
+
+ $dayOfWeek = (int)date('N', $dateFrom);
+ $firstMonday = $dayOfWeek === 1
+ ? $dateFrom
+ : strtotime('next monday', $dateFrom);
+
+ $ranges = [];
+ for ($wkStart = $firstMonday; $wkStart <= $dateTo; $wkStart += 7 * 86400) {
+ $wkEnd = $wkStart + 6 * 86400;
+
+ $periodStart = max($wkStart, $dateFrom);
+ $periodEnd = min($wkEnd, $dateTo);
+ $daysInMonth = floor(($periodEnd - $periodStart) / 86400) + 1;
+
+ if ($daysInMonth >= 4) {
+ $ranges[] = [
+ 'index' => (int)date('W', $wkStart), // ISO-неделя от $wkStart
+ 'start' => date('Y-m-d H:i:s', $wkStart), // “год-месяц-день 00:00:00”
+ 'end' => date('Y-m-d 23:59:59', $wkEnd), // “год-месяц-день 23:59:59”
+ ];
+ }
+ }
+
+ return $ranges;
+ }
+
+ public function getHistoricalSpeciesShareByWeek(
+ string $monthYear,
+ ?array $filters = null,
+ string $type = self::TYPE_SALES
+ ): array {
+ [$yearStr, $monthStr, $_] = explode('-', $monthYear);
+ $year = (int)$yearStr;
+ $month = (int)$monthStr;
+
+ $stores = $this->getVisibleStores();
+ $storeIds = array_map(fn($s) => $s->id, $stores);
+ if (!empty($filters['store_id'])) {
+ $storeIds = array_intersect($storeIds, [(int)$filters['store_id']]);
+ }
+ if (empty($storeIds)) {
+ return [];
+ }
+
+ $sumExpression = $type === self::TYPE_WRITE_OFFS
+ ? 'SUM(wp.quantity)'
+ : 'SUM(sp.quantity)';
+
+ // Таблицы и условия join
+ $fromTable = $type === self::TYPE_WRITE_OFFS ? ['w' => 'write_offs'] : ['s' => 'sales'];
+ $alias = key($fromTable);
+
+ $productTableJoin = $type === self::TYPE_WRITE_OFFS ? ['wp' => 'write_offs_products'] : ['sp' => 'sales_products'];
+ $productAlias = key($productTableJoin); // 'wp' или 'sp'
+ $productTableJoinCondition = $type === self::TYPE_WRITE_OFFS ? 'wp.write_offs_id = w.id' : 'sp.check_id = s.id';
+ $storeJoinCondition = $type === self::TYPE_WRITE_OFFS ? 'ex.export_val = w.store_id' : 'ex.export_val = s.store_id_1c';
+
+
+ $monthQtyBySpecies = [];
+ $weekQtyByPos = [];
+
+ foreach ([$year - 2, $year - 1] as $histYear) {
+
+ $histMonthStart = sprintf('%04d-%02d-01 00:00:00', $histYear, $month);
+ $histMonthEnd = date('Y-m-d 23:59:59', strtotime("$histMonthStart +1 month -1 second"));
+
+ $monthQuery = (new Query())
+ ->select([
+ 'store_id' => 'ex.entity_id',
+ 'category' => 'p1c.category',
+ 'subcategory' => 'p1c.subcategory',
+ 'species' => 'p1c.species',
+ 'month_sum' => new Expression($sumExpression),
+ ])
+ ->from($fromTable)
+ ->leftJoin($productTableJoin, $productTableJoinCondition)
+ ->leftJoin('products_1c_nomenclature p1c', "p1c.id = {$productAlias}.product_id")
+ ->leftJoin('products_1c p1', "p1.id = {$productAlias}.product_id")
+ ->leftJoin('export_import_table ex', $storeJoinCondition)
+ ->andWhere(['ex.entity_id' => $storeIds])
+ ->andWhere(['p1.components' => ''])
+ ->andWhere(['not in', 'p1c.category', ['', 'букет', 'сборка', 'сервис']])
+ ->andWhere(['>=', "{$alias}.date", $histMonthStart])
+ ->andWhere(['<=', "{$alias}.date", $histMonthEnd])
+ ->groupBy(['ex.entity_id', 'p1c.category', 'p1c.subcategory', 'p1c.species']);
+
+ $monthRows = $monthQuery->all();
+ foreach ($monthRows as $row) {
+ $sid = $row['store_id'];
+ $cat = $row['category'];
+ $sub = $row['subcategory'];
+ $spec = $row['species'];
+ $qty = (float)$row['month_sum'];
+
+ $monthQtyBySpecies[$sid][$cat][$sub][$spec] =
+ ($monthQtyBySpecies[$sid][$cat][$sub][$spec] ?? 0.0) + $qty;
+ }
+
+ $histRanges = $this->getWeekRangesForMonth($histYear, $month);
+
+ $weekPos = 0;
+ foreach ($histRanges as $range) {
+ $weekPos++;
+
+ $startDt = new DateTime($range['start']);
+ $startDt->setDate(
+ $histYear,
+ (int)date('n', strtotime($range['start'])),
+ (int)date('j', strtotime($range['start']))
+ );
+ $endDt = new DateTime($range['end']);
+ $endDt->setDate(
+ $histYear,
+ (int)date('n', strtotime($range['end'])),
+ (int)date('j', strtotime($range['end']))
+ );
+
+ $weekQuery = (new Query())
+ ->select([
+ 'store_id' => 'ex.entity_id',
+ 'category' => 'p1c.category',
+ 'subcategory' => 'p1c.subcategory',
+ 'species' => 'p1c.species',
+ 'week_sum' => new Expression($sumExpression),
+ ])
+ ->from($fromTable)
+ ->leftJoin($productTableJoin, $productTableJoinCondition)
+ ->leftJoin('products_1c_nomenclature p1c', "p1c.id = {$productAlias}.product_id")
+ ->leftJoin('products_1c p1', "p1.id = {$productAlias}.product_id")
+ ->leftJoin('export_import_table ex', $storeJoinCondition)
+ ->andWhere(['ex.entity_id' => $storeIds])
+ ->andWhere(['p1.components' => ''])
+ ->andWhere(['not in', 'p1c.category', ['', 'букет', 'сборка', 'сервис']])
+ ->andWhere(new Expression(
+ "{$alias}.date BETWEEN :wstart AND :wend",
+ [
+ ':wstart' => $startDt->format('Y-m-d H:i:s'),
+ ':wend' => $endDt->format('Y-m-d H:i:s'),
+ ]
+ ))
+ ->groupBy(['ex.entity_id', 'p1c.category', 'p1c.subcategory', 'p1c.species']);
+
+ $weekRows = $weekQuery->all();
+ foreach ($weekRows as $row) {
+ $sid = $row['store_id'];
+ $cat = $row['category'];
+ $sub = $row['subcategory'];
+ $spec = $row['species'];
+ $qty = (float)$row['week_sum'];
+
+ if (!isset($weekQtyByPos[$weekPos])) {
+ $weekQtyByPos[$weekPos] = [];
+ }
+ $weekQtyByPos[$weekPos][$sid][$cat][$sub][$spec] =
+ ($weekQtyByPos[$weekPos][$sid][$cat][$sub][$spec] ?? 0.0) + $qty;
+ }
+ }
+ }
+
+ $shareByPos = [];
+ foreach ($weekQtyByPos as $weekPos => $storesMap) {
+ foreach ($storesMap as $sid => $byCat) {
+ foreach ($byCat as $cat => $bySub) {
+ foreach ($bySub as $sub => $bySpec) {
+ foreach ($bySpec as $spec => $weekQty) {
+ $monthQty = $monthQtyBySpecies[$sid][$cat][$sub][$spec] ?? 0.0;
+ if ($monthQty <= 0.0) {
+ continue;
+ }
+ $shareByPos[$weekPos][$sid][$cat][$sub][$spec] =
+ round($weekQty / $monthQty, 4);
+ }
+ }
+ }
+ }
+ }
+
+
+ $targetRanges = $this->getWeekRangesForMonth($year, $month);
+
+ $result = [];
+ foreach ($targetRanges as $posIndex => $range) {
+ $weekPos = $posIndex + 1;
+ $isoWeekNumber = $range['index'];
+
+ if (!isset($shareByPos[$weekPos])) {
+ continue;
+ }
+ foreach ($shareByPos[$weekPos] as $sid => $byCat) {
+ foreach ($byCat as $cat => $bySub) {
+ foreach ($bySub as $sub => $bySpec) {
+ foreach ($bySpec as $spec => $share) {
+ $result[] = [
+ 'store_id' => $sid,
+ 'category' => $cat,
+ 'subcategory' => $sub,
+ 'species' => $spec,
+ 'week' => $isoWeekNumber,
+ 'share' => $share,
+ 'sumMonth' => $monthQtyBySpecies[$sid][$cat][$sub][$spec] ?? 0.0,
+ 'sumWeek' => $weekQtyByPos[$weekPos][$sid][$cat][$sub][$spec] ?? 0.0
+ ];
+ }
+ }
+ }
+ }
+ }
+
+ $grouped = [];
+ foreach ($result as $idx => $row) {
+ $key = "{$row['store_id']}|{$row['category']}|{$row['subcategory']}|{$row['species']}";
+ $grouped[$key][] = $idx;
+ }
+ foreach ($grouped as $key => $indices) {
+ $sumPercent = 0.0;
+ foreach ($indices as $i) {
+ $sumPercent += $result[$i]['share'];
+ }
+ if ($sumPercent < 1.0) {
+ $diff = 1.0 - round($sumPercent, 4);
+ $count = count($indices);
+ $add = round($diff / $count, 4);
+ foreach ($indices as $i) {
+ $result[$i]['share'] = round($result[$i]['share'] + $add, 6);
+ }
+ }
+ }
+
+ return $result;
+ }
+
+ public function calculateWeeklyProductForecastPieces(
+ array $productForecastSpecies,
+ array $weeklySales
+ ): array {
+
+ $forecastMap = [];
+ foreach ($productForecastSpecies as $item) {
+ $sid = $item['store_id'];
+ $cat = $item['category'];
+ $sub = $item['subcategory'];
+ $spec = $item['species'];
+ $pid = $item['product_id'];
+ $piecesMon = (float)$item['product_sales_pieces'];
+
+ $forecastMap[$sid][$cat][$sub][$spec][$pid] = $piecesMon;
+ }
+
+ $result = [];
+
+ foreach ($weeklySales as $w) {
+ $sid = $w['store_id'];
+ $cat = $w['category'];
+ $sub = $w['subcategory'];
+ $spec = $w['species'];
+ $week = $w['week'];
+ $wShare = (float)$w['share'];
+
+ if (
+ ! isset(
+ $forecastMap[$sid],
+ $forecastMap[$sid][$cat],
+ $forecastMap[$sid][$cat][$sub],
+ $forecastMap[$sid][$cat][$sub][$spec]
+ )
+ ) {
+ continue;
+ }
+
+ $productsInSpec = $forecastMap[$sid][$cat][$sub][$spec];
+
+ foreach ($productsInSpec as $pid => $piecesMon) {
+ $forecastWeekPieces = round($piecesMon * $wShare, 2);
+
+ $result[] = [
+ 'week' => $week,
+ 'store_id' => $sid,
+ 'category' => $cat,
+ 'subcategory' => $sub,
+ 'species' => $spec,
+ 'product_id' => $pid,
+ 'forecast_month_pieces' => $piecesMon,
+ 'forecast_week_pieces' => $forecastWeekPieces,
+ ];
+ }
+ }
+
+ return $result;
+ }
+
+
+ public function pivotWeeklyForecast(array $flatRows): array
+ {
+ $grouped = [];
+
+ foreach ($flatRows as $row) {
+ $key = implode('|', [
+ $row['store_id'],
+ $row['category'],
+ $row['subcategory'],
+ $row['species'],
+ $row['product_id'],
+ ]);
+
+ if (!isset($grouped[$key])) {
+ $grouped[$key] = [
+ 'store_id' => $row['store_id'],
+ 'category' => $row['category'],
+ 'subcategory' => $row['subcategory'],
+ 'species' => $row['species'],
+ 'product_id' => $row['product_id'],
+ 'forecast_month_pieces' => $row['forecast_month_pieces'],
+ ];
+ }
+
+ $weekNum = (int)$row['week'];
+ $fieldName = 'week' . $weekNum;
+
+ $grouped[$key][$fieldName] = (float)$row['forecast_week_pieces'];
+ }
+
+ $allWeekFields = [];
+ foreach ($flatRows as $row) {
+ $allWeekFields['week' . ((int)$row['week'])] = true;
+ }
+ $allWeekFields = array_keys($allWeekFields);
+
+ foreach ($grouped as &$group) {
+ foreach ($allWeekFields as $fieldName) {
+ if (! isset($group[$fieldName])) {
+ $group[$fieldName] = 0.0;
+ }
+ }
+ }
+ unset($group);
+
+ return array_values($grouped);
+ }
+
+
+ /**
+ * Исторический недельный отчёт и доли по видам с учётом store_id.
+ *
+ * @param string $monthYear месяц-год в формате MM-YYYY
+ * @param array|null $filters
+ * @param array|null $productFilter
+ * @param string $type
+ * @return array{ 'weeksData': array<int, array> }
+ * возвращает плоский список строк:
+ * [
+ * ['week'=>1, 'store_id'=>2, 'category'=>'...', 'subcategory'=>'...', 'species'=>'...', 'percent'=>0.32],
+ * ...
+ * ]
+ */
+ public function getHistoricalWeeklySpeciesShare(
+ string $monthYear,
+ ?array $filters = null,
+ ?array $productFilter = null,
+ string $type = 'sales'
+ ): array {
+ [$yearStr, $monthStr, $_ ] = explode('-', $monthYear);
+ $month = (int)$monthStr;
+ $year = (int)$yearStr;
+ $yearData = [];
+ $historical = [];
+ for ($yr = $year - 2; $yr < $year; $yr++) {
+ $mYear = sprintf('%04d-%02d',$yr, $month);
+
+ $weeklyData = $this->getWeeklySpeciesDataForMonth(
+ $mYear, $filters, $productFilter, $type
+ );
+ $yearData[$mYear] = $weeklyData;
+ foreach ($weeklyData as $row) {
+ $week = $row['week'];
+ $sid = $row['store_id'];
+ $cat = $row['category'];
+ $sub = $row['subcategory'];
+ $spec = $row['species'];
+ $sumWeek = $row['sum'];
+
+ $historical[$week] ??= [];
+ $historical[$week][$sid] ??= [];
+ $historical[$week][$sid][$cat] ??= [];
+ $historical[$week][$sid][$cat][$sub] ??= [];
+ $historical[$week][$sid][$cat][$sub][$spec] =
+ ($historical[$week][$sid][$cat][$sub][$spec] ?? 0) + $sumWeek;
+ }
+ }
+
+ $dateFrom = sprintf('%04d-%02d-01 00:00:00', $year, $month);
+ $dateTo = date('Y-m-d H:i:s', strtotime("$dateFrom +1 month -1 second"));
+ $monthWeighted = $this->getMonthSpeciesShareOrWriteOff(
+ $dateFrom, $filters, $type
+ );
+ $monthMap = [];
+ foreach ($monthWeighted as $m) {
+ $sid = $m['store_id'];
+ $cat = $m['category'];
+ $sub = $m['subcategory'];
+ $spec = $m['species'];
+ $sumMonth = $m['total_sum'];
+
+ $monthMap[$sid] ??= [];
+ $monthMap[$sid][$cat] ??= [];
+ $monthMap[$sid][$cat][$sub]??= [];
+ $monthMap[$sid][$cat][$sub][$spec] =
+ ($monthMap[$sid][$cat][$sub][$spec] ?? 0) + $sumMonth;
+ }
+
+
+ $weeksList = array_keys($historical);
+ sort($weeksList, SORT_NUMERIC);
+
+ $speciesList = [];
+ foreach ($monthMap as $sid => $byCat) {
+ foreach ($byCat as $cat => $bySub) {
+ foreach ($bySub as $sub => $bySpec) {
+ foreach ($bySpec as $spec => $_) {
+ $speciesList[] = compact('sid','cat','sub','spec');
+ }
+ }
+ }
+ }
+
+
+ $rows = [];
+ foreach ($speciesList as $comb) {
+ $sid = $comb['sid'];
+ $cat = $comb['cat'];
+ $sub = $comb['sub'];
+ $spec = $comb['spec'];
+
+ $sumMonth = $monthMap[$sid][$cat][$sub][$spec] ?? 0;
+ if ($sumMonth <= 0) {
+ continue; // нет месячного итога
+ }
+
+ foreach ($weeksList as $week) {
+ $sumWeek = $historical[$week][$sid][$cat][$sub][$spec] ?? 0;
+ $percent = $sumWeek > 0 ? round($sumWeek / $sumMonth, 4) : null;
+
+ $rows[] = [
+ 'week' => $week,
+ 'store_id' => $sid,
+ 'category' => $cat,
+ 'subcategory' => $sub,
+ 'species' => $spec,
+ 'sumWeek' => $sumWeek,
+ 'percent' => $percent,
+ 'sumMonth' => $sumMonth
+ ];
+ }
+ }
+
+ $grouped = [];
+ foreach ($rows as $idx => $row) {
+ $key = "{$row['store_id']}|{$row['category']}|{$row['subcategory']}|{$row['species']}";
+ $grouped[$key][] = $idx;
+ }
+ foreach ($grouped as $key => $indices) {
+ $sumPercent = 0.0;
+ foreach ($indices as $i) {
+ $sumPercent += $rows[$i]['percent'];
+ }
+ if ($sumPercent < 1.0) {
+ $diff = 1.0 - $sumPercent;
+ $count = count($indices);
+ $add = $diff / $count;
+ foreach ($indices as $i) {
+ $rows[$i]['percent'] = round($rows[$i]['percent'] + $add, 4);
+ }
+ }
+ }
+
+ return $rows;
+ }
+
+
+ /**
+ * Рассчитывает недельную цель для каждого вида (species) по данным недельных долей
+ * и целям месяца.
+ * @param array $weeksShareData
+ * @param array $monthSpeciesGoals
+ * @return array
+ * Плоский массив строк с полями: week, store_id, category, subcategory,
+ * species, percent, monthly_goal, weekly_goal
+ */
+ public function calculateWeeklySpeciesGoals(
+ array $weeksShareData,
+ array $monthSpeciesGoals
+ ): array {
+ $monthSpeciesGoalsMap = [];
+ foreach ($monthSpeciesGoals as $monthSpeciesGoal) {
+ $monthSpeciesGoalsMap[$monthSpeciesGoal['store_id']]
+ [$monthSpeciesGoal['category']]
+ [$monthSpeciesGoal['subcategory']]
+ [$monthSpeciesGoal['species']] = $monthSpeciesGoal['goal'] ;
+ }
+ $result = [];
+ foreach ($weeksShareData as $row) {
+ $week = $row['week'];
+ $sid = $row['store_id'];
+ $cat = $row['category'];
+ $sub = $row['subcategory'];
+ $spec = $row['species'];
+ $percent = $row['percent'];
+
+ $monthlyGoal = $monthSpeciesGoalsMap[$sid][$cat][$sub][$spec] ?? null;
+
+ $weeklyGoal = 0;
+ if ($monthlyGoal !== null && $percent !== null) {
+ $weeklyGoal = round($percent * $monthlyGoal, 4);
+ }
+
+ $result[] = [
+ 'week' => $week,
+ 'store_id' => $sid,
+ 'category' => $cat,
+ 'subcategory' => $sub,
+ 'species' => $spec,
+ 'percent' => $percent,
+ 'monthly_goal' => $monthlyGoal,
+ 'weekly_goal' => $weeklyGoal,
+ ];
+ }
+ return $result;
+ }
+
+ /**
+ * Возвращает дату понедельника ISO-недели в формате YYYY-MM-DD
+ *
+ * @param int $year ISO-год (может отличаться от календарного в границах года)
+ * @param int $week номер ISO-недели (1–53)
+ * @return string дата понедельника, например '2025-03-10'
+ */
+ public static function getIsoWeekStart(int $year, int $week): string
+ {
+ $iso = $year . 'W' . str_pad($week, 2, '0', STR_PAD_LEFT) . '1';
+ return date('Y-m-d', strtotime($iso));
+ }
+
+
+ public static function calculateWeekForecastSpeciesProducts($category, $subcategory, $species, $storeId, $goal)
+ {
+ $speciesProductForecast = [];
+ $products = Products1cNomenclature::find()
+ ->select(['id', 'name'])
+ ->where(['category' => $category])
+ ->andWhere(['subcategory' => $subcategory])
+ ->andWhere(['species' => $species])
+ ->indexBy('id')
+ ->asArray()
+ ->all();
+
+ $productsIds = ArrayHelper::getColumn($products, 'id');
+ if (CityStore::find()->where(['id' => $storeId])->one()->city_id == 1342) {
+ $region = 52;
+ } elseif (CityStore::find()->where(['id' => $storeId])->one()->city_id == 1) {
+ $region = 77;
+ } else {
+ $region = null;
+ }
+ $priceRecords = PricesDynamic::find()
+ ->select(['product_id', 'price'])
+ ->where(['product_id' => $productsIds])
+ ->andWhere(['active' => 1])
+ ->andWhere(['or', ['region_id' => $region], ['region_id' => null]])
+ ->indexBy('product_id')
+ ->asArray()
+ ->all();
+
+ foreach ($priceRecords as $id => $record) {
+ if ($goal == 0 || (int)$record['price'] == 0) {
+ $forecast = 0;
+ } else {
+ $forecast = round(max($goal / (float)$record['price'], 1), 0);
+ }
+ $speciesProductForecast[] = [
+ 'category' => $category,
+ 'subcategory' => $subcategory,
+ 'species' => $species,
+ 'product_id' => $record['product_id'],
+ 'name' => $products[$id]['name'],
+ 'price' => $record['price'] ?? $goal ?? 1,
+ 'goal' => $goal ?? 0,
+ 'forecast' => $forecast
+
+ ];
+
+ }
+
+ return $speciesProductForecast;
+
+ }
+
+ /**
+ * Общий расчёт плана для заданной категории товаров без истории.
+ *
+ * @param int $storeId
+ * @param string $yearMonth строка "YYYY-MM", например "2025-03"
+ * @param string $category
+ * @param string|null $subcategory
+ * @param string|null $species
+ * @return array [
+ * [
+ * 'store_id' => …,
+ * 'category' => …,
+ * 'subcategory' => …,
+ * 'species' => …,
+ * 'month' => …,
+ * 'year' => …,
+ * 'sum' => …,
+ * ],
+ * …
+ * ]
+ *
+ */
+ public function calculateSpeciesForecastForProductsWithoutHistory($dateFrom, $filters): array
+ {
+ // Получение ID видимых магазинов
+ $storeIds = array_map(fn($store) => $store->id, $this->getVisibleStores());
+
+ $subcategory = !empty($filters['subcategory']) ? $filters['subcategory'] : null;
+ $species = !empty($filters['species']) ? $filters['species'] : null;
+
+ // Применение фильтра по магазину, если указан
+ if (!empty($filters['store_id'])) {
+ $storeIds = array_intersect($storeIds, [(int)$filters['store_id']]);
+ }
+ $date = new \DateTime($dateFrom);
+ $month = $date->format('m');
+ $year = $date->format('Y');
+
+ $result = [];
+
+ foreach ($storeIds as $storeId) {
+ $histResult = StorePlanService::calculateHistoricalShare(
+ $storeId,
+ $month,
+ $year,
+ $filters['category'],
+ $subcategory,
+ $species
+ );
+
+ $productsWithoutHistory = $histResult['without_history'] ?? [];
+ if (empty($productsWithoutHistory)) {
+ continue;
+ }
+
+ // ——————— WEIGHTED SALES ————————
+
+ $medianResults = StorePlanService::calculateMedianSalesForProductsWithoutHistoryExtended(
+ $storeId, $month, $year, $productsWithoutHistory
+ );
+
+ if (empty($medianResults)) {
+ continue;
+ }
+
+ // ——————— COST CALCULATION ————————
+
+ $costs = StorePlanService::calculateCostForProductsWithoutHistory(
+ $storeId, $month, $year, $medianResults
+ );
+
+ if (!empty($costs)) {
+ $result = array_merge($result, $costs);
+ }
+ }
+
+ return $result;
+ }
+
+ public function mapGoalsBySpecies(array $rows): array {
+ $mapped = [];
+ foreach ($rows as $row) {
+ $mapped[$row['store_id']][$row['category']][$row['subcategory']][$row['species']] = $row['goal'];
+ }
+ return $mapped;
+ }
+
+ public function subtractSpeciesGoals(array $data, array $bouquetForecast, array $noHistory): array {
+ $bouquetForecastMap = $this->mapGoalsBySpecies($bouquetForecast);
+ $noHistoryMap = $this->mapGoalsBySpecies($noHistory);
+ $result = [];
+
+ foreach ($data as $row) {
+ $storeId = $row['store_id'];
+ $category = $row['category'];
+ $subcategory = $row['subcategory'];
+ $species = $row['species'];
+ $originalGoal = $row['goal'];
+
+ $bouquetForecastGoal = $bouquetForecastMap[$storeId][$category][$subcategory][$species] ?? 0;
+ $noHistoryGoal = $noHistoryMap[$storeId][$category][$subcategory][$species] ?? 0;
+
+ $cleanGoal = $originalGoal - ($bouquetForecastGoal + $noHistoryGoal);
+
+
+ $result[] = [
+ 'store_id' => $storeId,
+ 'category' => $category,
+ 'subcategory' => $subcategory,
+ 'species' => $species,
+ 'dirtyGoal' => $originalGoal,
+ 'bouquetGoal' => $bouquetForecastGoal,
+ 'noHistoryGoal' => $noHistoryGoal,
+ 'goal' => $cleanGoal
+ ];
+ }
+
+ return $result;
+ }
+
+ /**
+ * @param array|int $storeIds
+ * @param int $month
+ * @param int $year
+ * @param int $regionId
+ * @return array список строк с полями:
+ * sale_id, sale_date, product_id, product_name,
+ * component_guid, component_name, component_category,
+ * quantity, price, cost
+ */
+ public function getUnmarkedProductsComponents(int $storeId, string $month, string $year, int $regionId = null, $typeFilter = null): array
+ {
+ $date = new \DateTimeImmutable(sprintf('%04d-%02d-01', $year, $month));
+
+
+ $region = CityStoreParams::find()
+ ->where(['store_id' => $storeId])
+ ->one()->address_region;
+
+ if (!$regionId && !$region) {
+ // определяем регион по городу
+ $cityId = CityStore::find()->select('city_id')->where(['id' => $storeId])->scalar();
+ if ($cityId == 1) {
+ $region = BouquetComposition::REGION_MSK;
+ } else {
+ $region = BouquetComposition::REGION_NN;
+ }
+ }
+
+ $monthStart = sprintf('%04d-%02d-01 00:00:00', $year, $month);
+ $daysInMonth = cal_days_in_month(CAL_GREGORIAN, $month, $year);
+ $monthEnd = sprintf('%04d-%02d-%02d 23:59:59', $year, $month, $daysInMonth);
+ $salesProducts = Sales::find()
+ ->alias('s')
+ ->select(['s.id', 's.date', 'sp.product_id', 'p1c.type', 'p1c.components' , 'p1c.name'])
+ ->innerJoin(
+ ['sp' => SalesProducts::tableName()],
+ 's.id = sp.check_id'
+ )
+ ->innerJoin(
+ ['p1c' => Products1c::tableName()],
+ 'p1c.id = sp.product_id'
+ )
+ ->leftJoin(
+ ['nom' => Products1cNomenclature::tableName()],
+ 'nom.id = sp.product_id'
+ )
+ ->andWhere(['s.store_id' => $storeId])
+ ->andWhere(['between', 's.date', $monthStart, $monthEnd])
+ ->andWhere(['not', ['p1c.components' => '']])
+ ->andWhere(['nom.category' => null])
+ ->asArray()
+ ->all();
+ // var_dump( $salesProducts); die();
+
+ $components = [];
+ $rows = [];
+ foreach ($salesProducts as $sp) {
+ /** @var SalesProducts $sp */
+ $js = trim($sp['components']);
+ if ($js === '' || $js[0] !== '{') {
+ continue;
+ }
+ $data = @json_decode($js, true);
+ if (!is_array($data)) {
+ continue;
+ }
+ foreach ($data as $guid => $qty) {
+ $qty = (int)$qty;
+ if ($qty <= 0) {
+ continue;
+ }
+
+ $components[$guid] = true;
+
+ $rows[] = [
+ 'sale_id' => $sp['id'],
+ 'sale_date' => $sp['date'],
+ 'product_id' => $sp['product_id'],
+ 'product_name'=> $sp['name'],
+ 'component_guid' => $guid,
+ 'quantity' => $qty,
+ ];
+ }
+ }
+
+ if (empty($rows)) {
+ return [];
+ }
+ $guids = array_keys($components);
+
+ $nomenclatures = Products1cNomenclature::find()
+ ->andWhere(['id' => $guids])
+ ->indexBy('id')
+ ->all();
+
+ $priceDynamics = PricesDynamic::find()
+ ->andWhere(['region_id' => $region])
+ ->andWhere(['product_id' => array_values( ArrayHelper::getColumn($nomenclatures, 'id') )])
+ // ->andWhere(['<=', 'date_from', $monthStart])
+ // ->andWhere(['>=', 'date_to', $monthEnd])
+ ->orderBy(['date_from' => SORT_DESC])
+ ->all();
+
+ $pricesByProduct = [];
+ foreach ($priceDynamics as $pd) {
+ /** @var PricesDynamic $pd */
+ $pid = $pd->product_id;
+ $pricesByProduct[$pid][] = $pd;
+ }
+
+ $result = [];
+ foreach ($rows as $r) {
+ $guid = $r['component_guid'];
+ $n = $nomenclatures[$guid] ?? null;
+ $pid = $n?->id;
+ $price = 0;
+ if ($pid && isset($pricesByProduct[$pid])) {
+ foreach ($pricesByProduct[$pid] as $pd) {
+ if ($pd->date_from <= $r['sale_date'] && $pd->date_to >= $r['sale_date']) {
+ $price = $pd->price;
+ break;
+ }
+ }
+ }
+ $cost = $r['quantity'] * $price;
+
+ $result[] = [
+ 'sale_id' => $r['sale_id'],
+ 'sale_date' => $r['sale_date'],
+ 'product_id' => $r['product_id'],
+ 'product_name' => $r['product_name'],
+ 'component_guid' => $guid,
+ 'component_name' => $n?->name,
+ 'component_category' => $n?->category,
+ 'quantity' => $r['quantity'],
+ 'price' => $price,
+ 'cost' => $cost,
+ ];
+ }
+
+ return $result;
+ }
+
+ public function calculateProductForecastInPiecesProductsWithHistory(
+ int $storeId,
+ string $month,
+ array $productSalesShare,
+ array $speciesGoals,
+ string $subcategory = null,
+ string $category = null,
+ string $species = null
+ ): array {
+ $result = [];
+
+ if (empty($speciesGoals)) {
+ return [];
+ }
+
+ $goalsMap = $this->mapGoalsBySpecies($speciesGoals);
+
+ $region = CityStoreParams::find()
+ ->where(['store_id' => $storeId])
+ ->one()->address_region ?? null;
+
+ if (!$region) {
+ $cityId = CityStore::find()->select('city_id')->where(['id' => $storeId])->scalar();
+ $region = ($cityId == 1)
+ ? BouquetComposition::REGION_MSK
+ : BouquetComposition::REGION_NN;
+ }
+
+ foreach ($productSalesShare as $productId => $data) {
+ $share = $data['share'] ?? 0.0;
+
+ if ($share <= 0.0) {
+ continue;
+ }
+
+ $priceRecord = PricesDynamic::find()
+ ->where([
+ 'product_id' => $productId,
+ 'region_id' => $region,
+ 'active' => 1,
+ ])
+ ->one();
+
+ if (!$priceRecord || $priceRecord->price <= 0) {
+ continue;
+ }
+
+ $storeId = $data['store_id'];
+ $cat = $data['category'];
+ $sub = $data['subcategory'];
+ $spec = $data['species'];
+ if (
+ ! isset(
+ $goalsMap[$storeId],
+ $goalsMap[$storeId][$cat],
+ $goalsMap[$storeId][$cat][$sub],
+ $goalsMap[$storeId][$cat][$sub][$spec]
+ )
+ ) {
+
+ continue;
+ }
+
+ $goal = $goalsMap[$storeId][$cat][$sub][$spec];
+
+ $forecastSum = $goal * $share;
+
+ $forecastCount = $forecastSum / $priceRecord->price;
+
+ $result[] = [
+ 'product_id' => $productId,
+ 'goal' => $goal,
+ 'goal_share' => round($forecastSum, 2),
+ 'price' => $priceRecord->price,
+ 'forecast_pieces' => round($forecastCount),
+ 'store_id' => $data['store_id'],
+ 'category' => $data['category'],
+ 'subcategory' => $data['subcategory'],
+ 'species' => $data['species'],
+ ];
+ }
+
+ return $result;
+ }
+
+ /**
+ * Рассчитывает долю каждого товара в общем прогнозе по штукам.
+ *
+ * Объединяет прогнозы без истории и с историей для полного списка товаров.
+ *
+ * @param array $pieciesForecastProductsNoHistyory Результат calculateSpeciesForecastForProductsWithoutHistory
+ * @param array $pieciesForecastProductWithHistory Результат calculateProductForecastInPiecesProductsWithHistory
+ * @return array
+ */
+ public function calculateProductForecastShare(
+ array $pieciesForecastProductsNoHistyory,
+ array $pieciesForecastProductWithHistory
+ ): array {
+ $shareResult = [];
+
+ $info = $pieciesForecastProductsNoHistyory[0] ?? null;
+ if (!$info) {
+ return [];
+ }
+
+ $noHistoryMap = $info['forecasts'] ?? [];
+
+ $piecesMap = [];
+ foreach ($pieciesForecastProductWithHistory as $item) {
+ if (isset($item['product_id'], $item['forecast_pieces'])) {
+ $piecesMap[$item['product_id']] = $item['forecast_pieces'];
+ }
+ }
+
+ $allProductIds = array_merge(
+ array_keys($noHistoryMap),
+ array_keys($piecesMap)
+ );
+
+ $quantityMap = [];
+ foreach ($allProductIds as $pid) {
+ if (isset($piecesMap[$pid])) {
+ $quantityMap[$pid] = $piecesMap[$pid];
+ } elseif (isset($noHistoryMap[$pid])) {
+ $quantityMap[$pid] = (float)$noHistoryMap[$pid];
+ } else {
+ $quantityMap[$pid] = 0;
+ }
+ }
+
+ $totalPieces = array_sum($quantityMap);
+ if ($totalPieces <= 0) {
+ return [];
+ }
+
+ $storeId = $info['store_id'];
+ $month = $info['month'];
+ $year = $info['year'];
+ $category = $info['category'];
+ $subcategory = $info['subcategory'];
+ $species = $info['species'];
+
+ foreach ($quantityMap as $pid => $count) {
+ $share = $count / $totalPieces;
+
+ $shareResult[] = [
+ 'store_id' => $storeId,
+ 'month' => $month,
+ 'year' => $year,
+ 'category' => $category,
+ 'subcategory' => $subcategory,
+ 'species' => $species,
+ 'product_id' => $pid,
+ 'forecast_pieces'=> $count,
+ 'share' => round($share, 4),
+ 'history_status' => in_array($pid, array_keys($noHistoryMap)) ? 'No history' : 'With history'
+ ];
+ }
+
+ return $shareResult;
+ }
+
+ /**
+ * Рассчитывает продажи по каждому товару внутри вида на основе долей и очищенной цели вида.
+ *
+ * @param array $productShares Результат calculateProductForecastShare
+ * @param array $speciesGoals Массив целей по видам с ключом 'goal'
+ * @return array
+ */
+ public static function calculateProductSalesBySpecies(
+ array $productShares,
+ array $speciesGoals
+ ): array {
+ $result = [];
+ $goalsMap = [];
+ foreach ($speciesGoals as $item) {
+ if (isset($item['store_id'], $item['category'], $item['subcategory'], $item['species'], $item['goal'])) {
+ $key = implode('|', [
+ $item['store_id'],
+ $item['category'],
+ $item['subcategory'],
+ $item['species']
+ ]);
+ $goalsMap[$key] = $item['goal'];
+ }
+ }
+ $regions = CityStoreParams::find()
+ ->select(['store_id', 'address_region'])
+ ->indexBy('store_id')
+ ->asArray()
+ ->all();
+ $products = ArrayHelper::getColumn($productShares, 'product_id');
+
+ $prices = PricesDynamic::find()
+ ->select(['product_id', 'price', 'region_id'])
+ ->where(['product_id' => $products])
+ ->andWhere(['active' => 1])
+ ->asArray()
+ ->all();
+
+ $pricesMap = [];
+ foreach ($prices as $price) {
+ $pricesMap[$price['product_id']][$price['region_id']][] = $price['price'];
+ }
+ foreach ($productShares as $shareItem) {
+ $storeId = $shareItem['store_id'];
+
+ $region = $regions[$storeId]['address_region']
+ ?? BouquetComposition::REGION_NN;
+
+ $priceList = $pricesMap[$shareItem['product_id']][$region] ?? null;
+ $price = is_array($priceList) && count($priceList) > 0
+ ? $priceList[0] ?? 1
+ : 1;
+
+
+ $key = implode('|', [
+ $shareItem['store_id'],
+ $shareItem['category'],
+ $shareItem['subcategory'],
+ $shareItem['species']
+ ]);
+
+ $cleanGoal = $goalsMap[$key] ?? 0;
+ $productSales = $shareItem['share'] * $cleanGoal;
+
+ $productSalesPieces = round($productSales / $price, 2);
+
+ $result[] = [
+ 'store_id' => $shareItem['store_id'],
+ 'month' => $shareItem['month'] ?? null,
+ 'year' => $shareItem['year'] ?? null,
+ 'category' => $shareItem['category'],
+ 'subcategory' => $shareItem['subcategory'],
+ 'species' => $shareItem['species'],
+ 'product_id' => $shareItem['product_id'],
+ 'forecast_pieces' => $shareItem['forecast_pieces'],
+ 'share' => round($shareItem['share'], 4),
+ 'history_status' => $shareItem['history_status'],
+ 'cleanGoal' => $cleanGoal,
+ 'product_sales' => round($productSales, 2),
+ 'product_sales_pieces' => $productSalesPieces
+ ];
+ }
+
+ return $result;
+ }
+
+
+ /**
+ * @param array|int $storeIds
+ * @param int $month
+ * @param int $year
+ * @param int $regionId
+ * @return array список строк с полями:
+ * sale_id, sale_date, product_id, product_name,
+ * component_guid, component_name, component_category,
+ * quantity, price, cost
+ */
+ public function getProductsComponentsInCategory(int $storeId, string $month, string $year, string $type = self::TYPE_SALES): array
+ {
+ $region = CityStoreParams::find()
+ ->where(['store_id' => $storeId])
+ ->one()->address_region ?? null;
+
+ if (!$region) {
+ $cityId = CityStore::find()->select('city_id')->where(['id' => $storeId])->scalar();
+ $region = $cityId == 1
+ ? BouquetComposition::REGION_MSK
+ : BouquetComposition::REGION_NN;
+ }
+
+ $monthStart = sprintf('%04d-%02d-01 00:00:00', $year, $month);
+ $daysInMonth = cal_days_in_month(CAL_GREGORIAN, $month, $year);
+ $monthEnd = sprintf('%04d-%02d-%02d 23:59:59', $year, $month, $daysInMonth);
+ $componentProducts = [];
+
+ if ($type == self::TYPE_SALES) {
+ $salesProducts = Sales::find()
+ ->alias('s')
+ ->select([
+ 's.id',
+ 's.date',
+ 's.operation',
+ 'sp.product_id',
+ 'p1c.type',
+ 'p1c.components',
+ 'p1c.name',
+ 'sp.quantity AS quantity_product'
+ ])
+ ->innerJoin(
+ ['sp' => SalesProducts::tableName()],
+ 's.id = sp.check_id'
+ )
+ ->innerJoin(
+ ['p1c' => Products1c::tableName()],
+ 'p1c.id = sp.product_id'
+ )
+ ->leftJoin(
+ ['nom' => Products1cNomenclature::tableName()],
+ 'nom.id = sp.product_id'
+ )
+ ->andWhere(['s.store_id' => $storeId])
+ ->andWhere(['not', ['s.operation' => ['Удален', 'Удаление']]])
+ ->andWhere(['between', 's.date', $monthStart, $monthEnd])
+ ->andWhere(['not', ['p1c.components' => '']])
+ ->asArray()
+ ->all();
+ $componentProducts = $salesProducts;
+ } else {
+ $writeOffsProducts = WriteOffs::find()
+ ->alias('w')
+ ->select([
+ 'w.id AS write_off_id',
+ 'w.date AS write_off_date',
+ 'wp.product_id',
+ 'p1c.type',
+ 'p1c.components',
+ 'p1c.name AS product_name',
+ 'ex.entity_id AS store_id',
+ 'wp.quantity AS quantity_product'
+ ])
+ ->innerJoin(
+ ['wp' => WriteOffsProducts::tableName()],
+ 'wp.write_offs_id = w.id'
+ )
+ ->innerJoin(
+ ['p1c' => Products1c::tableName()],
+ 'p1c.id = wp.product_id'
+ )
+ ->leftJoin(
+ ['nom' => Products1cNomenclature::tableName()],
+ 'nom.id = wp.product_id'
+ )
+ ->leftJoin(
+ ['ex' => ExportImportTable::tableName()],
+ 'ex.export_val = w.store_id'
+ )
+ ->andWhere(['between', 'w.date', $monthStart, $monthEnd])
+ ->andWhere(['ex.entity_id' => $storeId])
+ ->andWhere(['not', ['p1c.components' => '']])
+ ->asArray()
+ ->all();
+ $componentProducts = $writeOffsProducts;
+ }
+
+ $components = [];
+ $rows = [];
+ foreach ($componentProducts as $cp) {
+ $js = trim($cp['components']);
+ if ($js === '' || $js[0] !== '{') {
+ continue;
+ }
+ $data = @json_decode($js, true);
+ if (!is_array($data)) {
+ continue;
+ }
+ foreach ($data as $guid => $qty) {
+ $qty = (int)$qty;
+ if ($qty <= 0) {
+ continue;
+ }
+ $components[$guid] = true;
+ $rows[] = [
+ 'record_id' => $cp['id'] ?? $cp['write_off_id'],
+ 'sale_date' => $cp['date'] ?? $cp['write_off_date'],
+ 'product_id' => $cp['product_id'],
+ 'product_name' => $cp['name'] ?? $cp['product_name'],
+ 'quantity_product' => $cp['quantity_product'],
+ 'component_guid' => $guid,
+ 'quantity' => $qty,
+ 'type' => $type,
+ 'operation' => $cp['operation'] ?? '',
+ ];
+ }
+ }
+
+ if (empty($rows)) {
+ return [];
+ }
+
+ $guids = array_keys($components);
+ $nomenclatures = Products1cNomenclature::find()
+ ->andWhere(['id' => $guids])
+ ->andWhere(['not in', 'category', ['', 'букет', 'сборка', 'сервис']])
+ ->indexBy('id')
+ ->all();
+
+ $priceDynamics = PricesDynamic::find()
+ ->andWhere(['region_id' => $region])
+ ->andWhere(['product_id' => array_values(ArrayHelper::getColumn($nomenclatures, 'id'))])
+ ->orderBy(['date_from' => SORT_DESC])
+ ->all();
+
+ $pricesByProduct = [];
+ foreach ($priceDynamics as $pd) {
+ $pricesByProduct[$pd->product_id][] = $pd;
+ }
+
+ $result = [];
+ foreach ($rows as $r) {
+ $guid = $r['component_guid'];
+ $n = $nomenclatures[$guid] ?? null;
+ $pid = $n?->id;
+ $price = 0;
+ foreach ($pricesByProduct[$pid] ?? [] as $pd) {
+ if ($pd->date_from <= $r['sale_date'] && $pd->date_to >= $r['sale_date']) {
+ if ($pid == '2b72702a-792f-11e8-9edd-1c6f659fb563') {
+ $price = 8.66; // TODO: заглушка для цены гелия - исправить
+ } else {
+ $price = $pd->price;
+ }
+
+
+ break;
+ }
+ }
+ $cost = $r['quantity'] * $price * $r['quantity_product'];
+ $costComponent = $r['quantity'] * $price;
+
+ $result[] = [
+ 'store_id' => $storeId,
+ 'record_id' => $r['record_id'],
+ 'sale_date' => $r['sale_date'],
+ 'product_id' => $r['product_id'],
+ 'product_name' => $r['product_name'],
+ 'quantity_product' => $r['quantity_product'],
+ 'component_guid' => $guid,
+ 'component_name' => $n?->name,
+ 'component_category' => $n?->category,
+ 'component_subcategory'=> $n?->subcategory,
+ 'component_species' => $n?->species,
+ 'quantity' => $r['quantity'],
+ 'price' => $price,
+ 'cost' => $cost,
+ 'component_cost' => $costComponent,
+ 'type' => $type,
+ 'operation' => $r['operation'],
+ ];
+ }
+
+ return $result;
+ }
+
+
+
+ public function sumProductsComponentsByGroup(array $items, string $type, string $group = 'category'): array
+ {
+ $aggregated = [];
+
+ foreach ($items as $row) {
+ $storeId = $row['store_id'];
+ $category = $row['component_category'];
+ $subcategory = $row['component_subcategory'];
+ $species = $row['component_species'];
+ $operation = $row['operation'] ?? null;
+
+ $cost = (float)$row['cost'];
+
+ if (
+ $type === AutoPlannogrammaService::TYPE_SALES
+ || ($row['type'] ?? null) === AutoPlannogrammaService::TYPE_SALES
+ ) {
+ if ($operation === 'Возврат') {
+ $cost = -$cost;
+ }
+ }
+
+
+ $keyParts = [$storeId, $category];
+ if ($group === 'subcategory' || $group === 'species') {
+ $keyParts[] = $subcategory;
+ }
+ if ($group === 'species') {
+ $keyParts[] = $species;
+ }
+
+
+ $key = implode('|', $keyParts);
+
+ if (!isset($aggregated[$key])) {
+ // базовая структура
+ $aggregated[$key] = [
+ 'store_id' => $storeId,
+ 'category' => $category,
+ 'sum' => 0.0,
+ 'type' => $type,
+ ];
+
+ if ($group === 'subcategory' || $group === 'species') {
+ $aggregated[$key]['subcategory'] = $subcategory;
+ }
+ if ($group === 'species') {
+ $aggregated[$key]['species'] = $species;
+ }
+ }
+
+
+ $aggregated[$key]['sum'] += $cost;
+ }
+
+
+ return array_values($aggregated);
+ }
+
+
++
++
+
+
+ /**
+ * Считает взвешенную долю категорий за целевой месяц:
+ * берёт три предыдущих к «месяцу-цели» месяца (пропуская сразу предыдущий),
+ * присваивает им веса 3, 2 и 1 (самый старый месяц — 3, самый свежий — 1),
+ * суммирует взвешенные итоги по каждой категории и выдаёт их доли
+ * от общего взвешенного итога.
+ *
+ * @param string $month Целевой месяц в формате 'YYYY-MM'
+ * @param array|null $filters ['store_id'=>…, …]
+ * @param array|null $productFilter Опционально: [product_id, …]
+ * @param string $type 'sales' или 'writeOffs'
+ * @return array [
+ * <store_id> => [
+ * ['category'=>string, 'total_sum'=>float, 'share_of_total'=>float],
+ * …
+ * ],
+ * …
+ * ]
+ */
+ public function getMonthCategoryShareOrWriteOffWeighted(
+ string $month,
+ ?array $filters = null,
+ ?array $productFilter = null,
+ string $type = 'sales'
+ ): array
+ {
+ $stores = $this->getVisibleStores();
+ $storeIds = array_map(fn($s) => $s->id, $stores);
+ if (!empty($filters['store_id'])) {
+ $storeIds = array_intersect($storeIds, [(int)$filters['store_id']]);
+ }
+ if (empty($storeIds)) {
+ return [];
+ }
+
+ $baseMonth = strtotime("{$month}-01");
+
+ $monthOffsets = [3, 4, 5];
+ $monthWeights = [3, 2, 1];
+
+
+ $weightedSums = [];
+ $monthStoreTotalsWeighted = [];
+
+ foreach ($monthOffsets as $idx => $offsetMonths) {
+ $w = $monthWeights[$idx];
+ $start = date('Y-m-01 00:00:00', strtotime("-{$offsetMonths} months", $baseMonth));
+ $end = date('Y-m-t 23:59:59', strtotime($start));
+
+
+
+ $q = (new Query())
+ ->select([
+ 'store_id' => 'ex.entity_id',
+ 'category' => 'p1c.category',
+ 'month_sum' => new Expression('SUM(CAST(wop.summ AS NUMERIC))'),
+ ])
+ ->from(['w' => 'write_offs']);
+
+ if ($type === 'writeOffs') {
+ $q->leftJoin(['ex' => 'export_import_table'], 'ex.export_val = w.store_id')
+ ->leftJoin(['wop' => 'write_offs_products'], 'wop.write_offs_id = w.id')
+ ->leftJoin(['p1c' => 'products_1c_nomenclature'], 'p1c.id = wop.product_id')
+ ->andWhere(['>=', 'w.date', $start])
+ ->andWhere(['<=', 'w.date', $end]);
+
+ if ($productFilter !== null) {
+ $q->andWhere(['wop.product_id' => $productFilter]);
+ }
+ } else {
+ $q->leftJoin(['sp' => 'sales_products'], 'sp.check_id = s.id')
+ ->leftJoin(['ex' => 'export_import_table'], 'ex.export_val = s.store_id_1c')
+ ->leftJoin(['p1c' => 'products_1c_nomenclature'], 'p1c.id = sp.product_id')
+ ->andWhere(['>=', 's.date', $start])
+ ->andWhere(['<=', 's.date', $end]);
+
+ if ($productFilter !== null) {
+ $q->andWhere(['sp.product_id' => $productFilter]);
+ }
+ }
+
+ $q->andWhere(['ex.entity_id' => $storeIds])
+ // ->andWhere(['<>', 'p1c.category', ''])
+ ->groupBy(['ex.entity_id', 'p1c.category']);
+
+ $rows = $q->all();
+
+ foreach ($rows as $r) {
+ $sid = $r['store_id'];
+ $cat = $r['category'];
+ $sum = (float)$r['month_sum'] * $w;
+ $weightedSums[$sid][$cat] = ($weightedSums[$sid][$cat] ?? 0) + $sum;
+ }
+
+ }
+
+ $result = [];
+ foreach ($weightedSums as $storeId => $cats) {
+ $grand = array_sum($cats) ?: 1;
+
+ $unlabeledSum = $cats[''] ?? 0;
+ $labeledCount = count($cats) - (isset($cats['']) ? 1 : 0);
+
+ $distribution = $labeledCount > 0 ? $unlabeledSum / $labeledCount : 0;
+
+ foreach ($cats as $category => $weightedSum) {
+ if ($category === '') {
+ continue;
+ }
+
+ $adjustedSum = $weightedSum + $distribution;
+
+ $result[$storeId][] = [
+ 'category' => $category,
+ 'total_sum_cat' => $weightedSum,
+ 'total_sum_store' => $grand,
+ 'share_of_total' => round($adjustedSum / $grand, 4),
+ ];
+ }
+ }
+ return $result;
+ }
+
+ public function getMonthSubcategoryShareOrWriteOffWeighted(string $dateFrom, ?array $filters = null, ?array $productFilter = null, string $type = 'sales'): array
+ {
+ try {
+ $dt = new \DateTime($dateFrom);
+ } catch (\Exception $e) {
+ // Неверный формат даты
+ return [];
+ }
+ $month = (int)$dt->format('m');
+ $year = (int)$dt->format('Y');
+
+ $stores = $this->getVisibleStores();
+ $storeIds = array_map(fn($s) => $s->id, $stores);
+ if (!empty($filters['store_id'])) {
+ $storeIds = array_intersect($storeIds, [(int)$filters['store_id']]);
+ }
+ if (empty($storeIds)) {
+ return [];
+ }
+
+ $years = [$year - 2, $year - 1];
+
+ $query = (new Query())
+ ->select([
+ 'store_id' => 'ex.entity_id',
+ 'subcategory' => 'p1c.subcategory',
+ 'category' => 'p1c.category',
+ 'total_sum' => new Expression(
+ $type === 'writeOffs'
+ ? 'SUM(CAST(wop.summ AS NUMERIC))'
+ : 'SUM(sp.summ)'
+ ),
+ ]);
+
+ if ($type === 'writeOffs') {
+ $query->from(['w' => 'write_offs'])
+ ->leftJoin(['ex' => 'export_import_table'], 'ex.export_val = w.store_id')
+ ->leftJoin(['wop'=> 'write_offs_products'], 'wop.write_offs_id = w.id')
+ ->leftJoin(['p1c'=> 'products_1c_nomenclature'], 'p1c.id = wop.product_id')
+
+ ->andWhere(['in', new Expression('EXTRACT(YEAR FROM w.date)'), $years])
+ ->andWhere(['=', new Expression('EXTRACT(MONTH FROM w.date)'), $month]);
+ if ($productFilter !== null) {
+ $query->andWhere(['wop.product_id' => $productFilter]);
+ }
+ } else {
+ $query->from(['s' => 'sales'])
+ ->leftJoin(['sp' => 'sales_products'], 'sp.check_id = s.id')
+ ->leftJoin(['ex' => 'export_import_table'], 'ex.export_val = s.store_id_1c')
+ ->leftJoin(['p1c' => 'products_1c_nomenclature'],'p1c.id = sp.product_id')
+ ->andWhere(['in', new Expression('EXTRACT(YEAR FROM s.date)'), $years])
+ ->andWhere(['=', new Expression('EXTRACT(MONTH FROM s.date)'), $month]);
+ if ($productFilter !== null) {
+ $query->andWhere(['sp.product_id' => $productFilter]);
+ }
+ }
+
+ $query->andWhere(['ex.entity_id' => $storeIds])
+ ->andWhere(['<>', 'p1c.subcategory', ''])
+ ->groupBy(['ex.entity_id', 'p1c.subcategory', 'p1c.category']);
+
+ $rows = $query->all();
+ if (empty($rows)) {
+ return [];
+ }
+
+ $sumByStoreCategory = [];
+ foreach ($rows as $r) {
+ $sid = $r['store_id'];
+ $cat = $r['category'];
+ $sumByStoreCategory[$sid][$cat] = ($sumByStoreCategory[$sid][$cat] ?? 0) + $r['total_sum'];
+ }
+
+
+ $result = [];
+ foreach ($rows as $r) {
+ $sid = $r['store_id'];
+ $cat = $r['category'];
+ $total = $sumByStoreCategory[$sid][$cat] ?: 1;
+ $result[] = [
+ 'store_id' => $sid,
+ 'category' => $cat,
+ 'subcategory' => $r['subcategory'],
+ 'total_sum' => $r['total_sum'],
+ 'percent_of_month' => round($r['total_sum'] / $total, 4),
+ ];
+ }
+
+ return $result;
+ }
+
+ public function getMonthSpeciesShareOrWriteOffWeighted(
+ string $dateFrom,
+ string $dateTo,
+ ?array $filters = null,
+ ?array $productFilter = null,
+ string $type = 'sales'
+ ): array
+ {
+ try {
+ $dt = new \DateTime($dateFrom);
+ } catch (\Exception $e) {
+ return [];
+ }
+ $month = (int)$dt->format('m');
+ $year = (int)$dt->format('Y');
+
+ $stores = $this->getVisibleStores();
+ $storeIds = array_map(fn($s) => $s->id, $stores);
+ if (!empty($filters['store_id'])) {
+ $storeIds = array_intersect($storeIds, [(int)$filters['store_id']]);
+ }
+ if (empty($storeIds)) {
+ return [];
+ }
+
+ $years = [$year - 2, $year - 1];
+
+ $query = (new Query())->select([
+ 'store_id' => 'ex.entity_id',
+ 'category' => 'p1c.category',
+ 'subcategory'=> 'p1c.subcategory',
+ 'species' => 'p1c.species',
+ 'total_sum' => new Expression(
+ $type === 'writeOffs'
+ ? 'SUM(CAST(wop.summ AS NUMERIC))'
+ : 'SUM(sp.summ)'
+ ),
+ ]);
+
+ if ($type === 'writeOffs') {
+ $query->from(['w' => 'write_offs'])
+ ->leftJoin(['ex' => 'export_import_table'], 'ex.export_val = w.store_id')
+ ->leftJoin(['wop' => 'write_offs_products'], 'wop.write_offs_id = w.id')
+ ->leftJoin(['p1c' => 'products_1c_nomenclature'], 'p1c.id = wop.product_id')
+ ->andWhere(['IN', new Expression('EXTRACT(YEAR FROM w.date)'), $years])
+ ->andWhere(['=', new Expression('EXTRACT(MONTH FROM w.date)'), $month]);
+ if ($productFilter !== null) {
+ $query->andWhere(['wop.product_id' => $productFilter]);
+ }
+ } else {
+ $query->from(['s' => 'sales'])
+ ->leftJoin(['sp' => 'sales_products'], 'sp.check_id = s.id')
+ ->leftJoin(['ex' => 'export_import_table'], 'ex.export_val = s.store_id_1c')
+ ->leftJoin(['p1c' => 'products_1c_nomenclature'], 'p1c.id = sp.product_id')
+ ->andWhere(['IN', new Expression('EXTRACT(YEAR FROM s.date)'), $years])
+ ->andWhere(['=', new Expression('EXTRACT(MONTH FROM s.date)'), $month]);
+ if ($productFilter !== null) {
+ $query->andWhere(['sp.product_id' => $productFilter]);
+ }
+ }
+
+ $query->andWhere(['ex.entity_id' => $storeIds])
+ ->andWhere(['<>', 'p1c.species', ''])
+ ->groupBy([
+ 'ex.entity_id',
+ 'p1c.category',
+ 'p1c.subcategory',
+ 'p1c.species',
+ ]);
+
+ $rows = $query->all();
+ if (empty($rows)) {
+ return [];
+ }
+
+ $sumByStoreSubcategory = [];
+ foreach ($rows as $r) {
+ $sid = $r['store_id'];
+ $sub = $r['subcategory'];
+ $sumByStoreSubcategory[$sid][$sub] =
+ ($sumByStoreSubcategory[$sid][$sub] ?? 0) + $r['total_sum'];
+ }
+
+ $result = [];
+ foreach ($rows as $r) {
+ $sid = $r['store_id'];
+ $sub = $r['subcategory'];
+ $total = $sumByStoreSubcategory[$sid][$sub] ?: 1;
+ $result[] = [
+ 'store_id' => $sid,
+ 'category' => $r['category'],
+ 'subcategory' => $sub,
+ 'species' => $r['species'],
+ 'total_sum' => (float)$r['total_sum'],
+ 'percent_of_month' => round($r['total_sum'] / $total, 4),
+ ];
+ }
+
+ return $result;
+ }
+
+ // Недельные расчеты
+
+
+ /**
+ * Получает суммы продаж или списаний по видам (species) для каждой недели указанного месяца.
+ *
+ * @param string $monthYear месяц-год в формате MM-YYYY, например '03-2025'
+ * @param array|null $filters опциональные фильтры ['store_id'=>...]
+ * @param array|null $productFilter опциональный фильтр по product_id
+ * @param string $type 'sales' или 'writeOffs'
+ * @return array<int, array> массив строк: [
+ * ['week'=>1,'store_id'=>...,'category'=>...,'subcategory'=>...,'species'=>...,'sum'=>...],
+ * ...
+ * ]
+ */
+ public function getWeeklySpeciesDataForMonth(
+ string $monthYear,
+ ?array $filters = null,
+ ?array $productFilter = null,
+ string $type = 'sales'
+ ): array {
+ [$monthStr, $yearStr] = explode('-', $monthYear);
+ $month = (int)$monthStr;
+ $year = (int)$yearStr;
+
+ $dateFrom = strtotime(sprintf('%04d-%02d-01 00:00:00', $year, $month));
+ $dateTo = strtotime('+1 month -1 second', $dateFrom);
+
+ $stores = $this->getVisibleStores();
+ $storeIds = array_map(fn($s)=>$s->id, $stores);
+ if (!empty($filters['store_id'])) {
+ $storeIds = array_intersect($storeIds, [(int)$filters['store_id']]);
+ }
+ if (empty($storeIds)) {
+ return [];
+ }
+
+ $dayOfWeek = (int)date('N', $dateFrom);
+ $firstMonday = $dayOfWeek === 1
+ ? $dateFrom
+ : strtotime('next monday', $dateFrom);
+
+
+ $weekRanges = [];
+ for ($wkStart = $firstMonday; $wkStart <= $dateTo; $wkStart += 7 * 86400) {
+ $wkEnd = $wkStart + 6 * 86400;
+ if ($wkEnd > $dateTo) {
+ $wkEnd = $dateTo;
+ }
+ $periodStart = max($wkStart, $dateFrom);
+ $periodEnd = min($wkEnd, $dateTo);
+ $daysInMonth = floor(($periodEnd - $periodStart) / 86400) + 1;
+ if ($daysInMonth >= 4) {
+ $weekRanges[] = [
+ 'index' => (int)date('W', $wkStart),
+ 'start' => date('Y-m-d H:i:s', $wkStart),
+ 'end' => date('Y-m-d 23:59:59', $wkEnd),
+ ];
+ }
+ }
+
+ $result = [];
+
+ foreach ($weekRanges as $range) {
+ $exprWeek = new Expression((string)$range['index']);
+ $query = (new Query())->select([
+ 'week' => $exprWeek,
+ 'store_id' => 'ex.entity_id',
+ 'category' => 'p1c.category',
+ 'subcategory' => 'p1c.subcategory',
+ 'species' => 'p1c.species',
+ 'total_sum' => new Expression(
+ $type === 'writeOffs'
+ ? 'SUM(CAST(wop.summ AS NUMERIC))'
+ : 'SUM(sp.summ)'
+ ),
+ ]);
+
+ if ($type === 'writeOffs') {
+ $query->from(['w' => 'write_offs'])
+ ->leftJoin(['ex' => 'export_import_table'], 'ex.export_val = w.store_id')
+ ->leftJoin(['wop'=> 'write_offs_products'], 'wop.write_offs_id = w.id')
+ ->leftJoin(['p1c'=> 'products_1c_nomenclature'], 'p1c.id = wop.product_id')
+ ->andWhere(['>=', 'w.date', $range['start']])
+ ->andWhere(['<=', 'w.date', $range['end']]);
+ if ($productFilter !== null) {
+ $query->andWhere(['wop.product_id' => $productFilter]);
+ }
+ } else {
+ $query->from(['s' => 'sales'])
+ ->leftJoin(['sp' => 'sales_products'], 'sp.check_id = s.id')
+ ->leftJoin(['ex' => 'export_import_table'], 'ex.export_val = s.store_id_1c')
+ ->leftJoin(['p1c'=> 'products_1c_nomenclature'], 'p1c.id = sp.product_id')
+ ->andWhere(['>=', 's.date', $range['start']])
+ ->andWhere(['<=', 's.date', $range['end']]);
+ if ($productFilter !== null) {
+ $query->andWhere(['sp.product_id' => $productFilter]);
+ }
+ }
+
+ $query->andWhere(['ex.entity_id' => $storeIds])
+ ->andWhere(['<>', 'p1c.species', ''])
+ ->groupBy(['week','ex.entity_id','p1c.category','p1c.subcategory','p1c.species']);
+
+ $rows = $query->all();
+ foreach ($rows as $row) {
+ $result[] = [
+ 'week' => $row['week'],
+ 'store_id' => $row['store_id'],
+ 'category' => $row['category'],
+ 'subcategory' => $row['subcategory'],
+ 'species' => $row['species'],
+ 'sum' => (float)$row['total_sum'],
+ ];
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * Исторический недельный отчёт и доли по видам с учётом store_id.
+ *
+ * @param string $monthYear месяц-год в формате MM-YYYY
+ * @param array|null $filters
+ * @param array|null $productFilter
+ * @param string $type
+ * @return array{ 'weeksData': array<int, array> }
+ * возвращает плоский список строк:
+ * [
+ * ['week'=>1, 'store_id'=>2, 'category'=>'...', 'subcategory'=>'...', 'species'=>'...', 'percent'=>0.32],
+ * ...
+ * ]
+ */
+ public function getHistoricalWeeklySpeciesShare(
+ string $monthYear,
+ ?array $filters = null,
+ ?array $productFilter = null,
+ string $type = 'sales'
+ ): array {
+ [$monthStr, $yearStr] = explode('-', $monthYear);
+ $month = (int)$monthStr;
+ $year = (int)$yearStr;
+
+ $historical = [];
+ for ($yr = $year - 2; $yr < $year; $yr++) {
+ $mYear = sprintf('%02d-%d', $month, $yr);
+ $weeklyData = $this->getWeeklySpeciesDataForMonth(
+ $mYear, $filters, $productFilter, $type
+ );
+
+ foreach ($weeklyData as $row) {
+ $week = $row['week'];
+ $sid = $row['store_id'];
+ $cat = $row['category'];
+ $sub = $row['subcategory'];
+ $spec = $row['species'];
+ $sumWeek = $row['sum'];
+
+ $historical[$week] ??= [];
+ $historical[$week][$sid] ??= [];
+ $historical[$week][$sid][$cat] ??= [];
+ $historical[$week][$sid][$cat][$sub] ??= [];
+ $historical[$week][$sid][$cat][$sub][$spec] =
+ ($historical[$week][$sid][$cat][$sub][$spec] ?? 0) + $sumWeek;
+ }
+ }
+
+ $dateFrom = sprintf('%04d-%02d-01 00:00:00', $year, $month);
+ $dateTo = date('Y-m-d H:i:s', strtotime("$dateFrom +1 month -1 second"));
+ $monthWeighted = $this->getMonthSpeciesShareOrWriteOffWeighted(
+ $dateFrom, $dateTo, $filters, $productFilter, $type
+ );
+ $monthMap = [];
+ foreach ($monthWeighted as $m) {
+ $sid = $m['store_id'];
+ $cat = $m['category'];
+ $sub = $m['subcategory'];
+ $spec = $m['species'];
+ $sumMonth = $m['total_sum'];
+
+ $monthMap[$sid] ??= [];
+ $monthMap[$sid][$cat] ??= [];
+ $monthMap[$sid][$cat][$sub]??= [];
+ $monthMap[$sid][$cat][$sub][$spec] =
+ ($monthMap[$sid][$cat][$sub][$spec] ?? 0) + $sumMonth;
+ }
+
+
+ $weeksList = array_keys($historical);
+ sort($weeksList, SORT_NUMERIC);
+
+ $speciesList = [];
+ foreach ($monthMap as $sid => $byCat) {
+ foreach ($byCat as $cat => $bySub) {
+ foreach ($bySub as $sub => $bySpec) {
+ foreach ($bySpec as $spec => $_) {
+ $speciesList[] = compact('sid','cat','sub','spec');
+ }
+ }
+ }
+ }
+
+
+ $rows = [];
+ foreach ($speciesList as $comb) {
+ $sid = $comb['sid'];
+ $cat = $comb['cat'];
+ $sub = $comb['sub'];
+ $spec = $comb['spec'];
+
+ $sumMonth = $monthMap[$sid][$cat][$sub][$spec] ?? 0;
+ if ($sumMonth <= 0) {
+ continue; // нет месячного итога
+ }
+
+ foreach ($weeksList as $week) {
+ $sumWeek = $historical[$week][$sid][$cat][$sub][$spec] ?? 0;
+ $percent = $sumWeek > 0 ? round($sumWeek / $sumMonth, 4) : null;
+
+ $rows[] = [
+ 'week' => $week,
+ 'store_id' => $sid,
+ 'category' => $cat,
+ 'subcategory' => $sub,
+ 'species' => $spec,
+ 'sumWeek' => $sumWeek,
+ 'percent' => $percent,
+ ];
+ }
+ }
+
+ $grouped = [];
+ foreach ($rows as $idx => $row) {
+ $key = "{$row['store_id']}|{$row['category']}|{$row['subcategory']}|{$row['species']}";
+ $grouped[$key][] = $idx;
+ }
+ foreach ($grouped as $key => $indices) {
+ $sumPercent = 0.0;
+ foreach ($indices as $i) {
+ $sumPercent += $rows[$i]['percent'];
+ }
+ if ($sumPercent < 1.0) {
+ $diff = 1.0 - $sumPercent;
+ $count = count($indices);
+ $add = $diff / $count;
+ foreach ($indices as $i) {
+ $rows[$i]['percent'] = round($rows[$i]['percent'] + $add, 4);
+ }
+ }
+ }
+
+ return ['weeksData' => $rows];
+ }
+
+
+ /**
+ * Рассчитывает недельную цель для каждого вида (species) по данным недельных долей
+ * и целям месяца.
+ * @param array $weeksShareData
+ * @param array $monthSpeciesGoals
+ * @return array
+ * Плоский массив строк с полями: week, store_id, category, subcategory,
+ * species, percent, monthly_goal, weekly_goal
+ */
+ public function calculateWeeklySpeciesGoals(
+ array $weeksShareData,
+ array $monthSpeciesGoals
+ ): array {
+ $monthSpeciesGoalsMap = [];
+ foreach ($monthSpeciesGoals as $monthSpeciesGoal) {
+ $monthSpeciesGoalsMap[$monthSpeciesGoal['store_id']]
+ [$monthSpeciesGoal['category']]
+ [$monthSpeciesGoal['subcategory']]
+ [$monthSpeciesGoal['species']] = $monthSpeciesGoal['goal'] ;
+ }
+ $result = [];
+ foreach ($weeksShareData as $row) {
+ $week = $row['week'];
+ $sid = $row['store_id'];
+ $cat = $row['category'];
+ $sub = $row['subcategory'];
+ $spec = $row['species'];
+ $percent = $row['percent'];
+
+ $monthlyGoal = $monthSpeciesGoalsMap[$sid][$cat][$sub][$spec] ?? null;
+
+ $weeklyGoal = 0;
+ if ($monthlyGoal !== null && $percent !== null) {
+ $weeklyGoal = round($percent * $monthlyGoal, 4);
+ }
+
+ $result[] = [
+ 'week' => $week,
+ 'store_id' => $sid,
+ 'category' => $cat,
+ 'subcategory' => $sub,
+ 'species' => $spec,
+ 'percent' => $percent,
+ 'monthly_goal' => $monthlyGoal,
+ 'weekly_goal' => $weeklyGoal,
+ ];
+ }
+ return $result;
+ }
+
+ /**
+ * Возвращает дату понедельника ISO-недели в формате YYYY-MM-DD
+ *
+ * @param int $year ISO-год (может отличаться от календарного в границах года)
+ * @param int $week номер ISO-недели (1–53)
+ * @return string дата понедельника, например '2025-03-10'
+ */
+ public static function getIsoWeekStart(int $year, int $week): string
+ {
+ $iso = $year . 'W' . str_pad($week, 2, '0', STR_PAD_LEFT) . '1';
+ return date('Y-m-d', strtotime($iso));
+ }
+
+
+ public static function calculateWeekForecastSpeciesProducts($category, $subcategory, $species, $storeId, $goal)
+ {
+ $speciesProductForecast = [];
+ $products = Products1cNomenclature::find()
+ ->select(['id', 'name'])
+ ->where(['category' => $category])
+ ->andWhere(['subcategory' => $subcategory])
+ ->andWhere(['species' => $species])
+ ->indexBy('id')
+ ->asArray()
+ ->all();
+
+ $productsIds = ArrayHelper::getColumn($products, 'id');
+ if (CityStore::find()->where(['id' => $storeId])->one()->city_id == 1342) {
+ $region = 52;
+ } elseif (CityStore::find()->where(['id' => $storeId])->one()->city_id == 1) {
+ $region = 77;
+ } else {
+ $region = null;
+ }
+ $priceRecords = PricesDynamic::find()
+ ->select(['product_id', 'price'])
+ ->where(['product_id' => $productsIds])
+ ->andWhere(['active' => 1])
+ ->andWhere(['or', ['region_id' => $region], ['region_id' => null]])
+ ->indexBy('product_id')
+ ->asArray()
+ ->all();
+
+ foreach ($priceRecords as $id => $record) {
+ if ($goal == 0 || (int)$record['price'] == 0) {
+ $forecast = 0;
+ } else {
+ $forecast = round(max($goal / (float)$record['price'], 1), 0);
+ }
+ $speciesProductForecast[] = [
+ 'category' => $category,
+ 'subcategory' => $subcategory,
+ 'species' => $species,
+ 'product_id' => $record['product_id'],
+ 'name' => $products[$id]['name'],
+ 'price' => $record['price'] ?? $goal ?? 1,
+ 'goal' => $goal ?? 0,
+ 'forecast' => $forecast
+
+ ];
+
+ }
+
+ return $speciesProductForecast;
+
+ }
+
+ // альтернативные методы расчета списаний
+
+ public function calculateFullGoalChainWeighted(array $filters): array
+ {
+ $datePlan = $filters['plan_date'];
+ $dateFromForCategory = (new \DateTime($datePlan))->modify('-' . (self::CATEGORY_LOOKBACK_MONTHS + self::LOOKBACK_MONTHS) . ' months')->format('Y-m-d');
+
+ $monthCategoryShare = $this->getMonthCategoryShareOrWriteOffWeighted($datePlan, $filters, null, $filters['type']);
+ $monthCategoryGoal = $this->getMonthCategoryGoal($monthCategoryShare, $datePlan, $filters);
+
+ $monthSubcategoryShare = $this->getMonthSubcategoryShareOrWriteOff($datePlan, $filters);
+ $monthSubcategoryShare = $this->getMonthSubcategoryShareOrWriteOffWeighted($datePlan, $filters, null, $filters['type']);
+ $monthSubcategoryGoal = $this->getMonthSubcategoryGoal($monthSubcategoryShare, $monthCategoryGoal);
+
+ $monthSpeciesShare = $this->getMonthSpeciesShareOrWriteOff($datePlan, $filters);
+ if ($filters['type'] === 'writeOffs') {
+ $salesSubShare = $this->getMonthSubcategoryShareOrWriteOffWeighted($datePlan, $filters, null, 'sales');
+ $salesSubGoal = $this->getMonthSubcategoryGoal($salesSubShare, $monthCategoryGoal);
+
+ $catGoalMap = [];
+ foreach ($monthCategoryGoal as $row) {
+ $catGoalMap[$row['category']] = $row['goal'];
+ }
+ $salesSubGoalMap = [];
+ foreach ($salesSubGoal as $row) {
+ $salesSubGoalMap[$row['category']][$row['subcategory']] = $row['goal'];
+ }
+
+
+ foreach ($monthSubcategoryShare as &$row) {
+ $cat = $row['category'];
+ $sub = $row['subcategory'];
+
+ $writeShare = $row['percent_of_month'];
+
+ $writeGoal = ($catGoalMap[$cat] ?? 0) * $writeShare;
+ $saleGoal = $salesSubGoalMap[$cat][$sub] ?? 0;
+
+ if ($saleGoal > 0 && $writeGoal > 0.1 * $saleGoal) {
+ $row['share'] = 0.1;
+ }
+ }
+ unset($row);
+ $monthSubcategoryGoal = $this->getMonthSubcategoryGoal($monthSubcategoryShare, $monthCategoryGoal);
+ }
+
+ $monthSpeciesShare = $this->getMonthSpeciesShareOrWriteOffWeighted($datePlan, $datePlan, $filters, null, $filters['type']);
+ $monthSpeciesGoal = $this->getMonthSpeciesGoalDirty($monthSpeciesShare, $monthSubcategoryGoal);
+ if ($filters['type'] === 'writeOffs') {
+ $salesSpecShare = $this->getMonthSpeciesShareOrWriteOffWeighted($datePlan, $datePlan, $filters, null, 'sales');
+ $salesSpecGoal = $this->getMonthSpeciesGoalDirty($salesSpecShare, $monthSubcategoryGoal);
+
+ $subGoalMap = [];
+ foreach ($monthSubcategoryGoal as $row) {
+ $subGoalMap[$row['category']][$row['subcategory']] = $row['goal'];
+ }
+ $salesSpecGoalMap = [];
+ foreach ($salesSpecGoal as $row) {
+ $salesSpecGoalMap[$row['category']][$row['subcategory']][$row['species']] = $row['goal'];
+ }
+
+ foreach ($monthSpeciesShare as &$row) {
+ $cat = $row['category'];
+ $sub = $row['subcategory'];
+ $spec = $row['species'];
+
+ $writeShare = $row['percent_of_month'];
+ $writeGoal = ($subGoalMap[$cat][$sub] ?? 0) * $writeShare;
+ $saleGoal = $salesSpecGoalMap[$cat][$sub][$spec] ?? 0;
+
+ if ($saleGoal > 0 && $writeGoal > 0.1 * $saleGoal) {
+ $row['share'] = 0.1;
+ }
+ }
+ unset($row);
+
+ $monthSpeciesGoal = $this->getMonthSpeciesGoalDirty($monthSpeciesShare, $monthSubcategoryGoal);
+ }
+
+ $filtered = array_filter($monthSpeciesGoal, function ($row) use ($filters) {
+ foreach ($filters as $key => $value) {
+ if ($value === null || $value === '') {
+ continue;
+ }
+
+ if (!array_key_exists($key, $row)) {
+ continue;
+ }
+
+ if (is_numeric($row[$key]) && is_numeric($value)) {
+ if ((float)$row[$key] !== (float)$value) {
+ return false;
+ }
+ } else {
+ if (stripos((string)$row[$key], (string)$value) === false) {
+ return false;
+ }
+ }
+ }
+ return true;
+ });
+
+ return array_values($filtered);
+ }
+
}