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);
+ }
+
+
}