connection = $databases->connection( (string) config('upstream_mirrors.connection', 'business_mirror'), ); } /** * @param array> $stores * @return array> */ public function rows(array $stores, ?int $reportYear = null): array { if ($stores === []) { return []; } [$billGeneration, $lifecycleGeneration] = $this->activeGenerations(); $reportYear ??= (int) now()->format('Y'); if ($reportYear < 2000 || $reportYear > 2999) { throw new RuntimeException('店铺维度报表年份无效。'); } $names = array_values(array_unique(array_filter(array_map( static fn (array $row): string => trim((string) ($row['erp_store_name'] ?? '')), $stores, )))); $lifecycles = $this->lifecycles($names, $lifecycleGeneration); $lifecycleSales = $this->lifecycleSales($names, $billGeneration, $lifecycleGeneration); $fallbackSales = $this->fallbackSales(array_values(array_diff($names, array_keys($lifecycles))), $billGeneration); $rows = []; foreach ($stores as $store) { $storeName = trim((string) ($store['erp_store_name'] ?? '')); if ($storeName === '') { continue; } $periods = $lifecycles[$storeName] ?? [[ 'company_name' => $store['company_name'] ?? null, 'effective_at' => $store['opened_at'] ?? sprintf('%d-01-01', $reportYear), 'expires_at' => self::DEFAULT_EXPIRES_AT, 'lifecycle_id' => null, ]]; foreach ($periods as $period) { $saleData = $period['lifecycle_id'] === null ? ($fallbackSales[$storeName] ?? ['period' => 0.0, 'months' => []]) : $this->combinedSales($lifecycleSales, (array) ($period['lifecycle_ids'] ?? [$period['lifecycle_id']])); $monthly = []; for ($month = 1; $month <= 12; $month++) { $monthly[sprintf('sales_%02d', $month)] = (float) ( $saleData['months'][sprintf('%d-%02d', $reportYear, $month)] ?? 0 ); } $rollingMonths = collect(range(12, 1))->map( static fn (int $monthsAgo): string => now()->startOfMonth()->subMonthsNoOverflow($monthsAgo)->format('Y-m'), )->all(); $recentMonths = collect(range(3, 1))->map( static fn (int $monthsAgo): string => now()->startOfMonth()->subMonthsNoOverflow($monthsAgo)->format('Y-m'), )->values()->all(); $rows[] = [ ...$store, 'store_id' => $store['store_id'] ?? $storeName, 'status' => $this->status($store['store_status'] ?? null), 'entity' => $period['company_name'] ?: ($store['company_name'] ?? null), 'effective_at' => $this->date($period['effective_at'] ?? null, sprintf('%d-01-01', $reportYear)), 'invalid_at' => $this->date($period['expires_at'] ?? null, self::DEFAULT_EXPIRES_AT), 'period_sales' => (float) $saleData['period'], 'store_sales_12m' => array_sum(array_map( static fn (string $month): float => (float) ($saleData['months'][$month] ?? 0), $rollingMonths, )), 'recent_three_month_sales_1' => (float) ($saleData['months'][$recentMonths[0]] ?? 0), 'recent_three_month_sales_2' => (float) ($saleData['months'][$recentMonths[1]] ?? 0), 'recent_three_month_sales_3' => (float) ($saleData['months'][$recentMonths[2]] ?? 0), ...$monthly, ]; } } return $rows; } /** @return array{0:string,1:string} */ private function activeGenerations(): array { $schema = Schema::connection($this->connection->getName()); foreach (['store_report_import_states', 'store_report_bill_rows', 'store_report_lifecycle_rows'] as $table) { if (! $schema->hasTable($table)) { throw new RuntimeException('店铺维度报表共享镜像尚未完成迁移。'); } } $states = $this->connection->table('store_report_import_states') ->whereIn('dataset_key', [self::BILLS_DATASET, self::LIFECYCLES_DATASET]) ->get(['dataset_key', 'status', 'active_generation', 'row_count']) ->keyBy('dataset_key'); foreach ([self::BILLS_DATASET, self::LIFECYCLES_DATASET] as $dataset) { $state = $states->get($dataset); if ($state === null || empty($state->active_generation)) { $label = $dataset === self::BILLS_DATASET ? '账单' : '主体历史'; $rows = number_format((int) ($state->row_count ?? 0)); throw new RuntimeException("店铺维度报表中央同步中:{$label}已写入 {$rows} 行,完整数据就绪后自动显示。"); } } return [ (string) $states->get(self::BILLS_DATASET)->active_generation, (string) $states->get(self::LIFECYCLES_DATASET)->active_generation, ]; } /** @return array>> */ private function lifecycles(array $names, string $generation): array { $result = []; foreach (array_chunk($names, 500) as $chunk) { $rows = $this->connection->table('store_report_lifecycle_rows') ->where('generation', $generation) ->whereIn('erp_store_name', $chunk) ->orderBy('erp_store_name')->orderBy('effective_at')->orderBy('id') ->get(['id', 'erp_store_name', 'company_name', 'effective_at', 'expires_at']); foreach ($rows as $row) { $result[(string) $row->erp_store_name][] = [ 'lifecycle_id' => (int) $row->id, 'company_name' => $row->company_name, 'effective_at' => $row->effective_at, 'expires_at' => $row->expires_at, ]; } } foreach ($result as $storeName => $periods) { $normalized = []; foreach ($periods as $period) { $last = array_key_last($normalized); if ($last !== null && (string) ($normalized[$last]['company_name'] ?? '') === (string) ($period['company_name'] ?? '')) { $normalized[$last]['lifecycle_ids'][] = $period['lifecycle_id']; continue; } $period['lifecycle_ids'] = [$period['lifecycle_id']]; $normalized[] = $period; } foreach ($normalized as $index => &$period) { $period['expires_at'] = $period['expires_at'] ?: ($normalized[$index + 1]['effective_at'] ?? self::DEFAULT_EXPIRES_AT); } unset($period); $result[$storeName] = $normalized; } return $result; } /** @return array}> */ private function lifecycleSales(array $names, string $billGeneration, string $lifecycleGeneration): array { $result = []; $month = $this->monthExpression('b.occurred_at'); foreach (array_chunk($names, 500) as $chunk) { $placeholders = implode(',', array_fill(0, count($chunk), '?')); $rows = $this->connection->select( "WITH lifecycle_periods AS ( SELECT id, erp_store_name, effective_at, COALESCE(expires_at, LEAD(effective_at) OVER (PARTITION BY erp_store_name ORDER BY effective_at, id), '2999-01-01 00:00:00') AS computed_expires_at FROM store_report_lifecycle_rows WHERE generation = ? ) SELECT l.id AS lifecycle_id, {$month} AS bill_month, SUM(COALESCE(b.income, 0)) AS income_total FROM lifecycle_periods AS l INNER JOIN store_report_bill_rows AS b ON b.generation = ? AND b.store_name = l.erp_store_name AND b.occurred_at IS NOT NULL AND b.occurred_at >= COALESCE(l.effective_at, '1000-01-01 00:00:00') AND b.occurred_at < l.computed_expires_at WHERE l.erp_store_name IN ($placeholders) GROUP BY l.id, {$month}", [$lifecycleGeneration, $billGeneration, ...$chunk], ); $this->collectSales($result, $rows, 'lifecycle_id'); } return $result; } /** @return array}> */ private function fallbackSales(array $names, string $generation): array { $result = []; $month = $this->monthExpression('occurred_at'); foreach (array_chunk($names, 500) as $chunk) { $placeholders = implode(',', array_fill(0, count($chunk), '?')); $rows = $this->connection->select( "SELECT store_name, {$month} AS bill_month, SUM(COALESCE(income, 0)) AS income_total FROM store_report_bill_rows WHERE generation = ? AND store_name IN ($placeholders) AND occurred_at IS NOT NULL GROUP BY store_name, {$month}", [$generation, ...$chunk], ); $this->collectSales($result, $rows, 'store_name'); } return $result; } /** @param array}> $result */ private function collectSales(array &$result, array $rows, string $keyColumn): void { foreach ($rows as $row) { $key = (string) $row->{$keyColumn}; $result[$key]['period'] = ($result[$key]['period'] ?? 0.0) + (float) $row->income_total; $result[$key]['months'][(string) $row->bill_month] = (float) $row->income_total; } } /** @param array}> $sales */ private function combinedSales(array $sales, array $ids): array { $combined = ['period' => 0.0, 'months' => []]; foreach ($ids as $id) { $row = $sales[(string) $id] ?? ['period' => 0.0, 'months' => []]; $combined['period'] += (float) $row['period']; foreach ($row['months'] as $month => $amount) { $combined['months'][$month] = ($combined['months'][$month] ?? 0.0) + (float) $amount; } } return $combined; } private function monthExpression(string $column): string { return $this->connection->getDriverName() === 'sqlite' ? "strftime('%Y-%m', {$column})" : "DATE_FORMAT({$column}, '%Y-%m')"; } private function date(mixed $value, string $fallback): string { $value = trim((string) ($value ?? '')); return $value === '' ? $fallback : substr($value, 0, 10); } private function status(mixed $value): ?string { return match ((string) $value) { '0', '正常' => '开店中', '3', '关闭中' => '关店中', '6', '已关闭' => '关店', default => $value === null || $value === '' ? null : (string) $value, }; } }