*/ 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_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 []; } $this->assertDatabaseFile(); $this->connection ??= $this->readOnlyConnection(); $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); $lifecycleSales = $this->lifecycleSales($names); $fallbackSales = $this->fallbackSales(array_values(array_diff($names, array_keys($lifecycles)))); $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, 'store_id' => $storeName, '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; } private function assertDatabaseFile(): void { $path = (string) config('database.connections.bills_sqlite.database'); if ($path === '' || ! is_file($path)) { throw new RuntimeException('店铺账单 SQLite 尚未挂载:' . $path); } } /** @return array>> */ private function lifecycles(array $names): array { if (! $this->hasTable('store_lifecycles')) { return []; } $result = []; foreach (array_chunk($names, 100) 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_lifecycles WHERE erp_store_name IN ($placeholders) ORDER BY erp_store_name, effective_at, id", $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): array { if ($names === [] || ! $this->hasTable('store_lifecycles') || ! $this->hasTable('bills')) { return []; } $result = []; foreach (array_chunk($names, 100) as $chunk) { $placeholders = implode(',', array_fill(0, count($chunk), '?')); $rows = $this->select( "SELECT l.id AS lifecycle_id, substr(b.occurred_at, 1, 7) AS bill_month, SUM(COALESCE(b.income, 0)) AS income_total FROM store_lifecycles AS l INNER JOIN bills AS b ON b.store_name = l.erp_store_name AND b.occurred_at IS NOT NULL AND b.occurred_at <> '' AND b.occurred_at >= COALESCE(NULLIF(l.effective_at, ''), ?) AND b.occurred_at < COALESCE(NULLIF(l.expires_at, ''), ?) WHERE l.erp_store_name IN ($placeholders) GROUP BY l.id, substr(b.occurred_at, 1, 7)", [self::DEFAULT_EFFECTIVE_AT, self::DEFAULT_EXPIRES_AT, ...$chunk], ); $this->collectSales($result, $rows, 'lifecycle_id'); } return $result; } /** @return array}> */ private function fallbackSales(array $names): array { if ($names === [] || ! $this->hasTable('bills')) { return []; } $result = []; foreach (array_chunk($names, 100) as $chunk) { $placeholders = implode(',', array_fill(0, count($chunk), '?')); $rows = $this->select( "SELECT store_name, substr(occurred_at, 1, 7) AS bill_month, SUM(COALESCE(income, 0)) AS income_total FROM bills WHERE store_name IN ($placeholders) AND occurred_at IS NOT NULL AND occurred_at <> '' AND occurred_at >= ? AND occurred_at < ? GROUP BY store_name, substr(occurred_at, 1, 7)", [...$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 { $statement = $this->connection->prepare($query); $statement->execute($bindings); return $statement->fetchAll(PDO::FETCH_ASSOC); } private function readOnlyConnection(): PDO { $path = realpath((string) config('database.connections.bills_sqlite.database')); if ($path === false) { throw new RuntimeException('店铺账单 SQLite 尚未挂载。'); } return new PDO( 'sqlite:file:' . $path . '?mode=ro&immutable=1', null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION], ); } /** SQLite Schema Builder 在只读/WAL 挂载下可能尝试写入,因此使用普通查询。 */ private function hasTable(string $table): bool { // 本地账单文件由导入脚本一次性创建,避免 Laravel 在只读挂载上执行元数据写检查。 return in_array($table, ['bills', 'store_lifecycles'], true); } 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) ? '否' : '是'; } }