From: fomichev Date: Fri, 6 Jun 2025 11:56:55 +0000 (+0300) Subject: Merge branch 'refs/heads/develop' into feature_fomichev_erp-361_week_write_offs_share... X-Git-Url: https://gitweb.erp-flowers.ru/?a=commitdiff_plain;h=98ede454082628077d734119e062f2b1061c0a43;p=erp24_rep%2Fyii-erp24%2F.git Merge branch 'refs/heads/develop' into feature_fomichev_erp-361_week_write_offs_share_calculation # Conflicts: # erp24/controllers/AutoPlannogrammaController.php # erp24/services/AutoPlannogrammaService.php --- 98ede454082628077d734119e062f2b1061c0a43 diff --cc erp24/controllers/AutoPlannogrammaController.php index 5c01737a,62b2c833..e927abfc --- a/erp24/controllers/AutoPlannogrammaController.php +++ b/erp24/controllers/AutoPlannogrammaController.php @@@ -8,10 -7,13 +8,14 @@@ use yii\data\ArrayDataProvider 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 { @@@ -540,381 -721,675 +721,1053 @@@ ]); } + 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, + + ]); + } + + + } diff --cc erp24/services/AutoPlannogrammaService.php index 41f60b4d,e0ee09a2..d33643e7 --- a/erp24/services/AutoPlannogrammaService.php +++ b/erp24/services/AutoPlannogrammaService.php @@@ -2,13 -2,25 +2,25 @@@ 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\Products1cNomenclature; + use yii_app\records\CityStoreParams; + use yii_app\records\ExportImportTable; -use yii_app\records\PricesDynamic; + use yii_app\records\Products1c; -use yii_app\records\Products1cNomenclature; + 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 { @@@ -101,7 -113,7 +113,7 @@@ ':month3' => $month3, ]); } -- ++ // Основной запрос с CTE $query = (new Query()) ->select([ @@@ -558,799 -808,1501 +808,2298 @@@ } + // Недельные расчеты + + + /** + * Получает суммы продаж или списаний по видам (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 массив строк: [ + * ['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 + */ + 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 } + * возвращает плоский список строк: + * [ + * ['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 [ + * => [ + * ['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 массив строк: [ + * ['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 } + * возвращает плоский список строк: + * [ + * ['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); + } + }