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