From: fomichev Date: Mon, 9 Jun 2025 15:13:13 +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=51bd9318ef70cdbac2d85dedddcba6e851119b2a;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 --- 51bd9318ef70cdbac2d85dedddcba6e851119b2a diff --cc erp24/controllers/AutoPlannogrammaController.php index 54465788,5e44bd9b..5f672918 --- a/erp24/controllers/AutoPlannogrammaController.php +++ b/erp24/controllers/AutoPlannogrammaController.php @@@ -1267,9 -1286,11 +1272,11 @@@ class AutoPlannogrammaController extend // Обработка даты на год и месяц 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); + //$forecast = $service->calculateFullForecastForWeek($filters); + //var_dump( $forecast); die(); $monthCategoryShare = $service->getMonthCategoryShareOrWriteOff($filters['plan_date'], $filters); $monthCategoryGoal = $service->getMonthCategoryGoal($monthCategoryShare, $filters['plan_date']); $monthSubcategoryShare = $service->getMonthSubcategoryShareOrWriteOff($filters['plan_date'], $filters); diff --cc erp24/services/AutoPlannogrammaService.php index f868f682,7ab2e473..0d7be355 --- a/erp24/services/AutoPlannogrammaService.php +++ b/erp24/services/AutoPlannogrammaService.php @@@ -10,11 -10,12 +10,12 @@@ 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\MatrixBouquetForecast; -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; @@@ -2304,433 -2385,47 +2384,476 @@@ class AutoPlannogrammaServic } + public function calculateFullForecastForWeek(array $filters): array + { + $bouquetSpeciesForecast = []; + $goals = $this->calculateFullGoalChain($filters); + $noHistoryProductData = $this->calculateSpeciesForecastForProductsWithoutHistory($filters['plan_date'], $filters); + $historyProductData = $this->calculateSpeciesForecastForProductsWithHistory($filters['plan_date'], $filters, $goals); + $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 = $this->subtractSpeciesGoals($goals, $bouquetSpeciesForecast, $noHistoryProductData); + + $salesProductForecastShare = $this->calculateProductForecastShare($noHistoryProductData, $historyProductData); + + $productForecastSpecies = $this->calculateProductSalesBySpecies($salesProductForecastShare, $cleanedSpeciesGoals); + + $weeklySales = $this->getHistoricalSpeciesShareByWeek($filters['plan_date'], $filters); + + $weeklySalesForecast = $this->calculateWeeklyProductForecastPieces($productForecastSpecies, $weeklySales); + + return $weeklySalesForecast; + } + + + + + + + /** + * Считает взвешенную долю категорий за целевой месяц: + * берёт три предыдущих к «месяцу-цели» месяца (пропуская сразу предыдущий), + * присваивает им веса 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; + } + + // Недельные расчеты + + + + + // альтернативные методы расчета списаний + + 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); + } + }