From: fomichev Date: Mon, 26 May 2025 13:34:19 +0000 (+0300) Subject: Merge branch 'refs/heads/feature_fomichev_erp-419_month_clear_goal' into feature_fomi... X-Git-Url: https://gitweb.erp-flowers.ru/?a=commitdiff_plain;h=3eb848a601266423a308d20d8965e4cbe8181693;p=erp24_rep%2Fyii-erp24%2F.git Merge branch 'refs/heads/feature_fomichev_erp-419_month_clear_goal' into feature_fomichev_erp-419_calculate_cleared_species_month_goal # Conflicts: # erp24/services/AutoPlannogrammaService.php --- 3eb848a601266423a308d20d8965e4cbe8181693 diff --cc erp24/services/AutoPlannogrammaService.php index 4cdaf1a0,0fe26cbe..92eaf457 --- a/erp24/services/AutoPlannogrammaService.php +++ b/erp24/services/AutoPlannogrammaService.php @@@ -542,138 -593,238 +594,373 @@@ class AutoPlannogrammaServic return array_values($filtered); } + /** + * Общий расчёт плана для заданной категории товаров без истории. + * + * @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 + { + $t0 = hrtime(true); + // Получение 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 = []; + $initTime = (hrtime(true) - $t0) / 1e6; // миллисекунды + Yii::warning( "Init (getVisibleStores + filters): {$initTime} ms\n"); + foreach ($storeIds as $storeId) { + $t1 = hrtime(true); + $histResult = StorePlanService::calculateHistoricalShare( + $storeId, + $month, + $year, + $filters['category'], + $subcategory, + $species + ); + $dur = (hrtime(true) - $t1) / 1e6; + Yii::warning( "calculateHistoricalShare for store {$storeId}: {$dur} ms\n"); + + $productsWithoutHistory = $histResult['without_history'] ?? []; + if (empty($productsWithoutHistory)) { + continue; + } + + // ——————— WEIGHTED SALES ———————— + $t2 = hrtime(true); + $weightedResults = StorePlanService::calculateMedianSalesForProductsWithoutHistory( + $storeId, $month, $year, $productsWithoutHistory + ); + $dur = (hrtime(true) - $t2) / 1e6; + Yii::warning("calculateMedianSalesForProductsWithoutHistory for store {$storeId}: {$dur} ms\n"); + + if (empty($weightedResults)) { + continue; + } + + // ——————— COST CALCULATION ———————— + $t3 = hrtime(true); + $costs = StorePlanService::calculateCostForProductsWithoutHistory( + $storeId, $month, $year, $weightedResults + ); + $dur = (hrtime(true) - $t3) / 1e6; + Yii::warning( "calculateCostForProductsWithoutHistory for store {$storeId}: {$dur} ms\n"); + + if (!empty($costs)) { + $result = array_merge($result, $costs); + } + } + + $totalTime = (hrtime(true) - $t0) / 1e6; + Yii::warning( "Total calculateSpeciesForecastForProductsWithoutHistory: {$totalTime} ms\n"); + + 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, $type = 'sales'): array + { + $region = CityStoreParams::find() + ->where(['store_id' => $storeId]) + ->one()->address_region; + + if (!$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); + $unmarkedProducts = []; + + 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']) + ->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' => '']]) + ->andWhere(['nom.category' => null]) + ->asArray() + ->all(); + $unmarkedProducts = $salesProducts; + } else { + $storeJoinCondition = $type === self::TYPE_WRITE_OFFS ? 'ex.export_val = w.store_id' : 'ex.export_val = s.store_id_1c'; + $writeOffsProducts = WriteOffs::find() + ->alias('w') + ->select([ + 'write_off_id' => 'w.id', + 'write_off_date' => 'w.date', + 'product_id' => 'wp.product_id', + 'type' => 'p1c.type', + 'components' => 'p1c.components', + 'product_name' => 'p1c.name', + 'store_id' => 'ex.entity_id', + ]) + ->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(['nom.category' => null]) + ->andWhere(['<>', 'p1c.components', '']) + ->asArray() + ->all(); + //var_dump($writeOffsProducts); die(); + $unmarkedProducts = $writeOffsProducts; + } + + //var_dump( $salesProducts); die(); + + $components = []; + $rows = []; + foreach ($unmarkedProducts as $up) { + + $js = trim($up['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' => $up['id'], + 'sale_date' => $up['date'], + 'product_id' => $up['product_id'], + 'product_name'=> $up['name'], + 'component_guid' => $guid, + 'quantity' => $qty, + 'type' => $type, + 'operation' => $up['operation'] ?? '', + ]; + } + } + + 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') )]) + ->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[] = [ + 'store_id' => $storeId, + '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, + 'month' => $month, + 'year' => $year, + 'type' => $r['type'], + 'operation' => $r['operation'] ?? '', + ]; + } + + return $result; + } + + + public function sumUnmarkedProductsComponentsByCategory(array $items, string $type): array + { + $aggregated = []; + + foreach ($items as $row) { + $storeId = $row['store_id']; + $category = $row['component_category']; + $month = $row['month']; + $year = $row['year']; + $operation = $row['operation'] ?? null; + + $cost = (float)$row['cost']; + + if ( + $type === AutoPlannogrammaService::TYPE_SALES + || ($row['type'] ?? null) === AutoPlannogrammaService::TYPE_SALES + ) { + if ($operation === 'Возврат') { + $cost = -$cost; + } + + } + + $key = implode('|', [$storeId, $category, $year, $month]); + + if (!isset($aggregated[$key])) { + $aggregated[$key] = [ + 'store_id' => $storeId, + 'category' => $category, + 'sum' => 0.0, + 'month' => $month, + 'year' => $year, + 'type' => $type, + ]; + } + + $aggregated[$key]['sum'] += $cost; + } + + return array_values($aggregated); + } + + }