return array_values($filtered);
}
+
+ // Недельные расчеты
+
+
+ /**
+ * Получает суммы продаж или списаний по видам (species) для каждой недели указанного месяца.
+ *
+ * @param string $monthYear месяц-год в формате MM-YYYY, например '03-2025'
+ * @param array|null $filters опциональные фильтры ['store_id'=>...]
+ * @param array|null $productFilter опциональный фильтр по product_id
+ * @param string $type 'sales' или 'writeOffs'
+ * @return array<int, array> массив строк: [
+ * ['week'=>1,'store_id'=>...,'category'=>...,'subcategory'=>...,'species'=>...,'sum'=>...],
+ * ...
+ * ]
+ */
+ public function getWeeklySpeciesDataForMonth(
+ string $monthYear,
+ ?array $filters = null,
+ ?array $productFilter = null,
+ string $type = 'sales'
+ ): array {
+ [$monthStr, $yearStr] = explode('-', $monthYear);
+ $month = (int)$monthStr;
+ $year = (int)$yearStr;
+
+ $dateFrom = strtotime(sprintf('%04d-%02d-01 00:00:00', $year, $month));
+ $dateTo = strtotime('+1 month -1 second', $dateFrom);
+
+ $stores = $this->getVisibleStores();
+ $storeIds = array_map(fn($s)=>$s->id, $stores);
+ if (!empty($filters['store_id'])) {
+ $storeIds = array_intersect($storeIds, [(int)$filters['store_id']]);
+ }
+ if (empty($storeIds)) {
+ return [];
+ }
+
+ $dayOfWeek = (int)date('N', $dateFrom);
+ $firstMonday = $dayOfWeek === 1
+ ? $dateFrom
+ : strtotime('next monday', $dateFrom);
+
+
+ $weekRanges = [];
+ for ($wkStart = $firstMonday; $wkStart <= $dateTo; $wkStart += 7 * 86400) {
+ $wkEnd = $wkStart + 6 * 86400;
+ if ($wkEnd > $dateTo) {
+ $wkEnd = $dateTo;
+ }
+ $periodStart = max($wkStart, $dateFrom);
+ $periodEnd = min($wkEnd, $dateTo);
+ $daysInMonth = floor(($periodEnd - $periodStart) / 86400) + 1;
+ if ($daysInMonth >= 4) {
+ $weekRanges[] = [
+ 'index' => (int)date('W', $wkStart),
+ 'start' => date('Y-m-d H:i:s', $wkStart),
+ 'end' => date('Y-m-d 23:59:59', $wkEnd),
+ ];
+ }
+ }
+
+ $result = [];
+
+ foreach ($weekRanges as $range) {
+ $exprWeek = new Expression((string)$range['index']);
+ $query = (new Query())->select([
+ 'week' => $exprWeek,
+ 'store_id' => 'ex.entity_id',
+ 'category' => 'p1c.category',
+ 'subcategory' => 'p1c.subcategory',
+ 'species' => 'p1c.species',
+ 'total_sum' => new Expression(
+ $type === 'writeOffs'
+ ? 'SUM(CAST(wop.summ AS NUMERIC))'
+ : 'SUM(sp.summ)'
+ ),
+ ]);
+
+ if ($type === 'writeOffs') {
+ $query->from(['w' => 'write_offs'])
+ ->leftJoin(['ex' => 'export_import_table'], 'ex.export_val = w.store_id')
+ ->leftJoin(['wop'=> 'write_offs_products'], 'wop.write_offs_id = w.id')
+ ->leftJoin(['p1c'=> 'products_1c_nomenclature'], 'p1c.id = wop.product_id')
+ ->andWhere(['>=', 'w.date', $range['start']])
+ ->andWhere(['<=', 'w.date', $range['end']]);
+ if ($productFilter !== null) {
+ $query->andWhere(['wop.product_id' => $productFilter]);
+ }
+ } else {
+ $query->from(['s' => 'sales'])
+ ->leftJoin(['sp' => 'sales_products'], 'sp.check_id = s.id')
+ ->leftJoin(['ex' => 'export_import_table'], 'ex.export_val = s.store_id_1c')
+ ->leftJoin(['p1c'=> 'products_1c_nomenclature'], 'p1c.id = sp.product_id')
+ ->andWhere(['>=', 's.date', $range['start']])
+ ->andWhere(['<=', 's.date', $range['end']]);
+ if ($productFilter !== null) {
+ $query->andWhere(['sp.product_id' => $productFilter]);
+ }
+ }
+
+ $query->andWhere(['ex.entity_id' => $storeIds])
+ ->andWhere(['<>', 'p1c.species', ''])
+ ->groupBy(['week','ex.entity_id','p1c.category','p1c.subcategory','p1c.species']);
+
+ $rows = $query->all();
+ foreach ($rows as $row) {
+ $result[] = [
+ 'week' => $row['week'],
+ 'store_id' => $row['store_id'],
+ 'category' => $row['category'],
+ 'subcategory' => $row['subcategory'],
+ 'species' => $row['species'],
+ 'sum' => (float)$row['total_sum'],
+ ];
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * Исторический недельный отчёт и доли по видам с учётом store_id.
+ *
+ * @param string $monthYear месяц-год в формате MM-YYYY
+ * @param array|null $filters
+ * @param array|null $productFilter
+ * @param string $type
+ * @return array{ 'weeksData': array<int, array> }
+ * возвращает плоский список строк:
+ * [
+ * ['week'=>1, 'store_id'=>2, 'category'=>'...', 'subcategory'=>'...', 'species'=>'...', 'percent'=>0.32],
+ * ...
+ * ]
+ */
+ public function getHistoricalWeeklySpeciesShare(
+ string $monthYear,
+ ?array $filters = null,
+ ?array $productFilter = null,
+ string $type = 'sales'
+ ): array {
+ [$monthStr, $yearStr] = explode('-', $monthYear);
+ $month = (int)$monthStr;
+ $year = (int)$yearStr;
+
+ $historical = [];
+ for ($yr = $year - 2; $yr < $year; $yr++) {
+ $mYear = sprintf('%02d-%d', $month, $yr);
+ $weeklyData = $this->getWeeklySpeciesDataForMonth(
+ $mYear, $filters, $productFilter, $type
+ );
+
+ foreach ($weeklyData as $row) {
+ $week = $row['week'];
+ $sid = $row['store_id'];
+ $cat = $row['category'];
+ $sub = $row['subcategory'];
+ $spec = $row['species'];
+ $sumWeek = $row['sum'];
+
+ $historical[$week] ??= [];
+ $historical[$week][$sid] ??= [];
+ $historical[$week][$sid][$cat] ??= [];
+ $historical[$week][$sid][$cat][$sub] ??= [];
+ $historical[$week][$sid][$cat][$sub][$spec] =
+ ($historical[$week][$sid][$cat][$sub][$spec] ?? 0) + $sumWeek;
+ }
+ }
+
+ $dateFrom = sprintf('%04d-%02d-01 00:00:00', $year, $month);
+ $dateTo = date('Y-m-d H:i:s', strtotime("$dateFrom +1 month -1 second"));
+ $monthWeighted = $this->getMonthSpeciesShareOrWriteOffWeighted(
+ $dateFrom, $dateTo, $filters, $productFilter, $type
+ );
+ $monthMap = [];
+ foreach ($monthWeighted as $m) {
+ $sid = $m['store_id'];
+ $cat = $m['category'];
+ $sub = $m['subcategory'];
+ $spec = $m['species'];
+ $sumMonth = $m['total_sum'];
+
+ $monthMap[$sid] ??= [];
+ $monthMap[$sid][$cat] ??= [];
+ $monthMap[$sid][$cat][$sub]??= [];
+ $monthMap[$sid][$cat][$sub][$spec] =
+ ($monthMap[$sid][$cat][$sub][$spec] ?? 0) + $sumMonth;
+ }
+
+
+ $weeksList = array_keys($historical);
+ sort($weeksList, SORT_NUMERIC);
+
+ $speciesList = [];
+ foreach ($monthMap as $sid => $byCat) {
+ foreach ($byCat as $cat => $bySub) {
+ foreach ($bySub as $sub => $bySpec) {
+ foreach ($bySpec as $spec => $_) {
+ $speciesList[] = compact('sid','cat','sub','spec');
+ }
+ }
+ }
+ }
+
+
+ $rows = [];
+ foreach ($speciesList as $comb) {
+ $sid = $comb['sid'];
+ $cat = $comb['cat'];
+ $sub = $comb['sub'];
+ $spec = $comb['spec'];
+
+ $sumMonth = $monthMap[$sid][$cat][$sub][$spec] ?? 0;
+ if ($sumMonth <= 0) {
+ continue; // нет месячного итога
+ }
+
+ foreach ($weeksList as $week) {
+ $sumWeek = $historical[$week][$sid][$cat][$sub][$spec] ?? 0;
+ $percent = $sumWeek > 0 ? round($sumWeek / $sumMonth, 4) : null;
+
+ $rows[] = [
+ 'week' => $week,
+ 'store_id' => $sid,
+ 'category' => $cat,
+ 'subcategory' => $sub,
+ 'species' => $spec,
+ 'sumWeek' => $sumWeek,
+ 'percent' => $percent,
+ ];
+ }
+ }
+
+ $grouped = [];
+ foreach ($rows as $idx => $row) {
+ $key = "{$row['store_id']}|{$row['category']}|{$row['subcategory']}|{$row['species']}";
+ $grouped[$key][] = $idx;
+ }
+ foreach ($grouped as $key => $indices) {
+ $sumPercent = 0.0;
+ foreach ($indices as $i) {
+ $sumPercent += $rows[$i]['percent'];
+ }
+ if ($sumPercent < 1.0) {
+ $diff = 1.0 - $sumPercent;
+ $count = count($indices);
+ $add = $diff / $count;
+ foreach ($indices as $i) {
+ $rows[$i]['percent'] = round($rows[$i]['percent'] + $add, 4);
+ }
+ }
+ }
+
+ return ['weeksData' => $rows];
+ }
+
+
+ /**
+ * Рассчитывает недельную цель для каждого вида (species) по данным недельных долей
+ * и целям месяца.
+ * @param array $weeksShareData
+ * @param array $monthSpeciesGoals
+ * @return array
+ * Плоский массив строк с полями: week, store_id, category, subcategory,
+ * species, percent, monthly_goal, weekly_goal
+ */
+ public function calculateWeeklySpeciesGoals(
+ array $weeksShareData,
+ array $monthSpeciesGoals
+ ): array {
+ $monthSpeciesGoalsMap = [];
+ foreach ($monthSpeciesGoals as $monthSpeciesGoal) {
+ $monthSpeciesGoalsMap[$monthSpeciesGoal['store_id']]
+ [$monthSpeciesGoal['category']]
+ [$monthSpeciesGoal['subcategory']]
+ [$monthSpeciesGoal['species']] = $monthSpeciesGoal['goal'] ;
+ }
+ $result = [];
+ foreach ($weeksShareData as $row) {
+ $week = $row['week'];
+ $sid = $row['store_id'];
+ $cat = $row['category'];
+ $sub = $row['subcategory'];
+ $spec = $row['species'];
+ $percent = $row['percent'];
+
+ $monthlyGoal = $monthSpeciesGoalsMap[$sid][$cat][$sub][$spec] ?? null;
+
+ $weeklyGoal = 0;
+ if ($monthlyGoal !== null && $percent !== null) {
+ $weeklyGoal = round($percent * $monthlyGoal, 4);
+ }
+
+ $result[] = [
+ 'week' => $week,
+ 'store_id' => $sid,
+ 'category' => $cat,
+ 'subcategory' => $sub,
+ 'species' => $spec,
+ 'percent' => $percent,
+ 'monthly_goal' => $monthlyGoal,
+ 'weekly_goal' => $weeklyGoal,
+ ];
+ }
+ return $result;
+ }
+
+ /**
+ * Возвращает дату понедельника ISO-недели в формате YYYY-MM-DD
+ *
+ * @param int $year ISO-год (может отличаться от календарного в границах года)
+ * @param int $week номер ISO-недели (1–53)
+ * @return string дата понедельника, например '2025-03-10'
+ */
+ public static function getIsoWeekStart(int $year, int $week): string
+ {
+ $iso = $year . 'W' . str_pad($week, 2, '0', STR_PAD_LEFT) . '1';
+ return date('Y-m-d', strtotime($iso));
+ }
+
+
+ public static function calculateWeekForecastSpeciesProducts($category, $subcategory, $species, $storeId, $goal)
+ {
+ $speciesProductForecast = [];
+ $products = Products1cNomenclature::find()
+ ->select(['id', 'name'])
+ ->where(['category' => $category])
+ ->andWhere(['subcategory' => $subcategory])
+ ->andWhere(['species' => $species])
+ ->indexBy('id')
+ ->asArray()
+ ->all();
+
+ $productsIds = ArrayHelper::getColumn($products, 'id');
+ if (CityStore::find()->where(['id' => $storeId])->one()->city_id == 1342) {
+ $region = 52;
+ } elseif (CityStore::find()->where(['id' => $storeId])->one()->city_id == 1) {
+ $region = 77;
+ } else {
+ $region = null;
+ }
+ $priceRecords = PricesDynamic::find()
+ ->select(['product_id', 'price'])
+ ->where(['product_id' => $productsIds])
+ ->andWhere(['active' => 1])
+ ->andWhere(['or', ['region_id' => $region], ['region_id' => null]])
+ ->indexBy('product_id')
+ ->asArray()
+ ->all();
+
+ foreach ($priceRecords as $id => $record) {
+ if ($goal == 0 || (int)$record['price'] == 0) {
+ $forecast = 0;
+ } else {
+ $forecast = round(max($goal / (float)$record['price'], 1), 0);
+ }
+ $speciesProductForecast[] = [
+ 'category' => $category,
+ 'subcategory' => $subcategory,
+ 'species' => $species,
+ 'product_id' => $record['product_id'],
+ 'name' => $products[$id]['name'],
+ 'price' => $record['price'] ?? $goal ?? 1,
+ 'goal' => $goal ?? 0,
+ 'forecast' => $forecast
+
+ ];
+
+ }
+
+ return $speciesProductForecast;
+
+ }
+
/**
* Общий расчёт плана для заданной категории товаров без истории.
*