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']; } foreach (['period_sales', ...array_map(static fn (int $month): string => sprintf('sales_%02d', $month), range(1, 12))] as $key) { $rules[$key] = ['nullable', 'string', 'max:64']; } $rules['status'] = ['nullable', 'string', 'in:0,3,6']; $rules['can_change'] = ['nullable', 'string', 'in:是,否']; $sortFields = [ 'erp_store_name', 'store_name', 'store_id', 'profit_center', 'store_manager', 'department_manager', 'opened_at', 'status', 'can_change', 'entity', 'company_type', 'effective_at', 'invalid_at', 'period_sales', ...array_map(static fn (int $month): string => sprintf('sales_%02d', $month), range(1, 12)), ]; $rules['sort'] = ['nullable', 'string', 'in:' . implode(',', $sortFields)]; $rules['direction'] = ['nullable', 'string', 'in:asc,desc']; $filters = validator(request()->only(array_keys($rules)), $rules)->validate(); if (! empty($filters['sort'])) { $filters['_sort'] = $filters['sort']; $filters['_direction'] = ($filters['direction'] ?? 'asc') === 'desc' ? 'desc' : 'asc'; } unset($filters['sort'], $filters['direction']); return $filters; } /** * @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 $this->sortRows($rows, $filters); } /** @param array> $rows @param array $filters */ private function sortRows(array $rows, array $filters): array { $key = (string) ($filters['_sort'] ?? ''); if ($key === '') { return $rows; } $allowed = [ 'erp_store_name' => 'erp_store_name', 'store_name' => 'store_name', 'store_id' => 'store_id', 'profit_center' => 'profit_center', 'store_manager' => 'store_manager', 'department_manager' => 'department_manager', 'opened_at' => 'opened_at', 'status' => 'status', 'can_change' => 'can_change', 'entity' => 'entity', 'company_type' => 'company_type', 'effective_at' => 'effective_at', 'invalid_at' => 'invalid_at', 'period_sales' => 'period_sales', ...array_combine( array_map(static fn (int $month): string => sprintf('sales_%02d', $month), range(1, 12)), array_map(static fn (int $month): string => sprintf('sales_%02d', $month), range(1, 12)), ), ]; $column = $allowed[$key] ?? null; if ($column === null) { return $rows; } $direction = ($filters['_direction'] ?? 'asc') === 'desc' ? -1 : 1; usort($rows, static function (array $left, array $right) use ($column, $direction): int { $leftValue = $left[$column] ?? null; $rightValue = $right[$column] ?? null; if (is_numeric($leftValue) && is_numeric($rightValue)) { $comparison = ((float) $leftValue <=> (float) $rightValue) * $direction; } else { $comparison = strcmp((string) ($leftValue ?? ''), (string) ($rightValue ?? '')) * $direction; } return $comparison !== 0 ? $comparison : strcmp((string) ($left['store_id'] ?? ''), (string) ($right['store_id'] ?? '')); }); 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')"; // 生命周期记录的生效时间严格来自存续表;只有没有存续表记录的店铺 // 才会在 fallbackSales() 中使用默认的 2026-07-01。 $lowerBound = $this->connection->getDriverName() === 'sqlite' ? 'date(d.occurred_on) >= date(l.effective_at)' : 'd.occurred_on >= 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_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', 'store_manager', 'department_manager', 'profit_center'] as $key) { $expected = trim((string) ($filters[$key] ?? '')); if ($expected !== '' && ! str_contains(mb_strtolower(trim((string) ($row[$key] ?? ''))), mb_strtolower($expected))) { return false; } } $canChange = trim((string) ($filters['can_change'] ?? '')); if ($canChange !== '' && trim((string) ($row['can_change'] ?? '')) !== $canChange) { 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; } } foreach (['period_sales', ...array_map(static fn (int $month): string => sprintf('sales_%02d', $month), range(1, 12))] as $key) { $needle = str_replace(',', '', trim((string) ($filters[$key] ?? ''))); if ($needle !== '' && (! is_numeric($needle) || ! is_numeric($row[$key] ?? null) || abs((float) $row[$key] - (float) $needle) > 0.000001)) { 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) ? '否' : '是'; } }