connection = $database->connection( (string) config('upstream_mirrors.connection', 'business_mirror'), ); } /** * 店铺维度报表的查询条件;日期统一左闭右开 [from, to)。 * * @return array */ public static function requestFilters(): array { $rules = []; foreach (['opened_at', 'effective_at', 'invalid_at'] as $prefix) { $rules[$prefix . '_from'] = ['nullable', 'date_format:Y-m-d']; $rules[$prefix . '_to'] = ['nullable', 'date_format:Y-m-d']; if (request()->filled($prefix . '_from')) { $rules[$prefix . '_to'][] = 'after_or_equal:' . $prefix . '_from'; } } foreach (['erp_store_name', 'store_name', 'store_id', 'profit_center', 'store_manager', 'department_manager', 'entity', 'company_type'] as $key) { $rules[$key] = ['nullable', 'string', 'max:191']; } $rules['status'] = ['nullable', 'string', 'in:0,3,6']; $rules['can_change'] = ['nullable', 'string', 'in:是,否']; return validator(request()->only(array_keys($rules)), $rules)->validate(); } /** * @param array> $stores * @param array $filters 未覆盖字段已在上游 allStores 过滤;这里过滤主体、生命周期区间及归属覆盖字段。 * @return array> */ public function rows(array $stores, ?int $reportYear = null, array $filters = []): array { if ($stores === []) { return []; } $dailyGeneration = $this->activeGeneration(self::DAILY_DATASET); $lifecycleGeneration = $this->activeGeneration(self::LIFECYCLES_DATASET); $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, $lifecycleGeneration, $dailyGeneration); $fallbackSales = $this->fallbackSales( array_values(array_diff($names, array_keys($lifecycles))), $dailyGeneration, ); $reportYear = $reportYear ?? (int) now()->format('Y'); $currentMonth = now()->startOfMonth(); $rollingMonths = collect(range(12, 1))->map( static fn (int $monthsAgo): string => $currentMonth->copy()->subMonthsNoOverflow($monthsAgo)->format('Y-m'), )->all(); $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' => self::DEFAULT_EFFECTIVE_AT, 'expires_at' => self::DEFAULT_EXPIRES_AT, 'lifecycle_id' => null, ]]; foreach ($periods as $period) { $saleData = $period['lifecycle_id'] === null ? ($fallbackSales[$storeName] ?? ['period' => 0.0, 'months' => []]) : ($lifecycleSales[(string) $period['lifecycle_id']] ?? ['period' => 0.0, 'months' => []]); $monthly = []; $monthlyIncome = []; for ($month = 1; $month <= 12; $month++) { $monthKey = sprintf('%d-%02d', $reportYear, $month); $monthly[sprintf('sales_%02d', $month)] = (float) ($saleData['months'][$monthKey] ?? 0); $monthlyIncome[sprintf('income_%02d', $month)] = $monthKey >= $currentMonth->format('Y-m') || ! array_key_exists($monthKey, $saleData['months']) ? null : (float) $saleData['months'][$monthKey]; } $rollingSales = array_sum(array_map( static fn (string $month): float => (float) ($saleData['months'][$month] ?? 0), $rollingMonths, )); $effectiveAt = $this->date($period['effective_at'] ?? null, self::DEFAULT_EFFECTIVE_AT); $expiresAt = $this->date($period['expires_at'] ?? null, self::DEFAULT_EXPIRES_AT); $row = [ ...$store, 'status' => $this->status($store['store_status'] ?? null), 'can_change' => $this->canChange($store['platform'] ?? null), 'entity' => $period['company_name'] ?: ($store['company_name'] ?? null), 'effective_at' => $effectiveAt, 'invalid_at' => $expiresAt === self::DEFAULT_EXPIRES_AT ? '存续中' : $expiresAt, 'period_sales' => (float) $saleData['period'], 'rolling_12m_income' => array_intersect($rollingMonths, array_keys($saleData['months'])) === [] ? null : $rollingSales, ...$monthly, ...$monthlyIncome, ]; if (! $this->matchesFilters($row, $expiresAt, $filters)) { continue; } $rows[] = $row; } } return $rows; } /** @return array>> */ private function lifecycles(array $names, string $generation): array { $result = []; foreach ($this->nameChunks($names) as $chunk) { $placeholders = implode(',', array_fill(0, count($chunk), '?')); $rows = $this->select( "SELECT id, erp_store_name, company_name, effective_at, expires_at FROM store_report_lifecycle_rows WHERE generation = ? AND erp_store_name IN ($placeholders) ORDER BY erp_store_name, effective_at, id", [$generation, ...$chunk], ); 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'], ]; } } return $result; } /** @return array}> */ private function lifecycleSales(array $names, string $lifecycleGeneration, string $dailyGeneration): array { if ($names === []) { return []; } $month = $this->connection->getDriverName() === 'sqlite' ? "substr(d.occurred_on, 1, 7)" : "DATE_FORMAT(d.occurred_on, '%Y-%m')"; $lowerBound = $this->connection->getDriverName() === 'sqlite' ? 'date(d.occurred_on) >= date(COALESCE(l.effective_at, ?))' : 'd.occurred_on >= COALESCE(l.effective_at, ?)'; $upperBound = $this->connection->getDriverName() === 'sqlite' ? 'date(d.occurred_on) < date(COALESCE(l.expires_at, ?))' : 'd.occurred_on < COALESCE(l.expires_at, ?)'; $result = []; foreach ($this->nameChunks($names) as $chunk) { $placeholders = implode(',', array_fill(0, count($chunk), '?')); $rows = $this->select( "SELECT l.id AS lifecycle_id, {$month} AS bill_month, SUM(COALESCE(d.income, 0)) AS income_total FROM store_report_lifecycle_rows AS l INNER JOIN store_report_daily_rows AS d ON d.store_name = l.erp_store_name AND d.generation = ? AND {$lowerBound} AND {$upperBound} WHERE l.generation = ? AND l.erp_store_name IN ($placeholders) GROUP BY l.id, {$month}", [ $dailyGeneration, self::DEFAULT_EFFECTIVE_AT, self::DEFAULT_EXPIRES_AT, $lifecycleGeneration, ...$chunk, ], ); $this->collectSales($result, $rows, 'lifecycle_id'); } return $result; } /** @return array}> */ private function fallbackSales(array $names, string $dailyGeneration): array { if ($names === []) { return []; } $month = $this->connection->getDriverName() === 'sqlite' ? "substr(occurred_on, 1, 7)" : "DATE_FORMAT(occurred_on, '%Y-%m')"; $result = []; foreach ($this->nameChunks($names) as $chunk) { $placeholders = implode(',', array_fill(0, count($chunk), '?')); $rows = $this->select( "SELECT store_name, {$month} AS bill_month, SUM(COALESCE(income, 0)) AS income_total FROM store_report_daily_rows WHERE generation = ? AND store_name IN ($placeholders) AND occurred_on >= ? AND occurred_on < ? GROUP BY store_name, {$month}", [$dailyGeneration, ...$chunk, self::DEFAULT_EFFECTIVE_AT, self::DEFAULT_EXPIRES_AT], ); $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']] = ($result[$key]['months'][(string) $row['bill_month']] ?? 0.0) + (float) $row['income_total']; } } /** @return array> */ private function select(string $query, array $bindings = []): array { return array_map( static fn (object $row): array => (array) $row, $this->connection->select($query, $bindings), ); } /** @return list> */ private function nameChunks(array $names): array { // SQLite fixtures keep the conservative variable limit. Production // MariaDB uses larger chunks to avoid turning one report page into // dozens of otherwise identical round trips. return array_chunk($names, $this->connection->getDriverName() === 'sqlite' ? 100 : 500); } private function activeGeneration(string $dataset): string { $schema = Schema::connection($this->connection->getName()); if (! $schema->hasTable('store_report_import_states')) { throw new RuntimeException('store_report_state_missing'); } $state = $this->connection->table('store_report_import_states') ->where('dataset_key', $dataset) ->first(); $generation = trim((string) ($state->active_generation ?? '')); if ($generation === '' || empty($state->initial_completed_at)) { throw new RuntimeException('store_report_dataset_not_ready:' . $dataset); } return $generation; } private function date(mixed $value, string $fallback): string { $value = trim((string) ($value ?? '')); return $value === '' ? $fallback : substr($value, 0, 10); } /** * 生命周期字段的行级过滤:主体、能否变更、负责人、利润中心精确匹配;生效、失效时间左闭右开。 * * 店铺负责人/部门负责人/利润中心展示店铺账号镜像(PostgreSQL)覆盖后的值, * 与页面筛选保持一致,因此在这里按行值匹配而不是上游镜像 SQL。 * * @param array $row * @param array $filters */ private function matchesFilters(array $row, string $expiresAt, array $filters): bool { foreach (['entity', 'can_change', 'store_manager', 'department_manager', 'profit_center'] as $key) { $expected = trim((string) ($filters[$key] ?? '')); if ($expected !== '' && trim((string) ($row[$key] ?? '')) !== $expected) { return false; } } foreach (['effective_at' => (string) $row['effective_at'], 'invalid_at' => $expiresAt] as $key => $value) { $from = trim((string) ($filters[$key . '_from'] ?? '')); $to = trim((string) ($filters[$key . '_to'] ?? '')); if ($from !== '' && strcmp($value, $from) < 0) { return false; } if ($to !== '' && strcmp($value, $to) >= 0) { return false; } } return true; } private function status(mixed $value): ?string { return match ((string) $value) { '0', '正常' => '开店中', '3', '关闭中' => '关店中', '6', '已关闭' => '关店', default => $value === null || $value === '' ? null : (string) $value, }; } /** 能否变更:平台为唯品会MP或淘工厂时为“否”,其余(含平台为空)为“是”。 */ private function canChange(mixed $platform): string { return in_array(trim((string) ($platform ?? '')), self::UNCHANGEABLE_PLATFORMS, true) ? '否' : '是'; } }