From: fomichev Date: Fri, 30 May 2025 12:50:31 +0000 (+0300) Subject: Merge branch 'refs/heads/feature_fomichev_erp-419_calculate_cleared_species_month_goa... X-Git-Url: https://gitweb.erp-flowers.ru/?a=commitdiff_plain;h=0cc92a895d9241f9135c814d125601bfcbf085ea;p=erp24_rep%2Fyii-erp24%2F.git Merge branch 'refs/heads/feature_fomichev_erp-419_calculate_cleared_species_month_goal' into feature_fomichev_erp-363_count_offline_week_sales_forecast # Conflicts: # erp24/controllers/CategoryPlanController.php # erp24/services/AutoPlannogrammaService.php # erp24/services/StorePlanService.php --- 0cc92a895d9241f9135c814d125601bfcbf085ea diff --cc erp24/services/AutoPlannogrammaService.php index 34877f1e,5ca42a25..dec3bf92 --- a/erp24/services/AutoPlannogrammaService.php +++ b/erp24/services/AutoPlannogrammaService.php @@@ -1072,361 -925,264 +1327,619 @@@ var_dump($yearData); die() 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 static function calculateProductForecastInPiecesProductsWithHistory( + int $storeId, + string $month, + string $category, + string $subcategory, + string $species, + array $productSalesShare, + array $speciesGoals + ): array { + $result = []; + + $goal = null; + foreach ($speciesGoals as $item) { + if ( + $item['store_id'] == $storeId && + $item['category'] == $category && + $item['subcategory'] == $subcategory && + $item['species'] == $species + ) { + $goal = $item['goal']; + break; + } + } + + if ($goal === null) { + return []; + } + + $region = CityStoreParams::find() + ->where(['store_id' => $storeId]) + ->select('address_region') + ->scalar(); + 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; + } + + $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' => $storeId, + 'category' => $category, + 'subcategory' => $subcategory, + 'species' => $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']; + } + } + + foreach ($productShares as $shareItem) { + $key = implode('|', [ + $shareItem['store_id'], + $shareItem['category'], + $shareItem['subcategory'], + $shareItem['species'] + ]); + if (!isset($goalsMap[$key])) { + continue; + } + $cleanGoal = $goalsMap[$key]; + $productSales = $shareItem['share'] * $cleanGoal; + + $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), + ]; + } + + 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; //заглушка исправить + } 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); + } + + } diff --cc erp24/services/StorePlanService.php index 5d3f38dd,20d954b5..b99448cd --- a/erp24/services/StorePlanService.php +++ b/erp24/services/StorePlanService.php @@@ -430,7 -433,7 +433,6 @@@ class StorePlanServic $hasHistoryInAllPeriods = false; } } -- $weeklySalesData[$periodKey] = $weekData; }