database = $database; } /** * @param array> $stores * @return array> */ public function rows(array $stores): array { if ($stores === []) { return []; } $this->assertDatabaseFile(); $this->connection ??= $this->database->connection('bills_sqlite'); $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); $lifecycleNames = array_fill_keys(array_keys($lifecycles), true); $fallbackNames = array_values(array_diff($names, array_keys($lifecycleNames))); $fallbackSales = $this->fallbackSales($fallbackNames); $rows = []; foreach ($stores as $store) { $storeName = trim((string) ($store['erp_store_name'] ?? '')); $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) { $effectiveAt = $this->date($period['effective_at'] ?? null, self::DEFAULT_EFFECTIVE_AT); $expiresAt = $this->date($period['expires_at'] ?? null, self::DEFAULT_EXPIRES_AT); $saleData = $period['lifecycle_id'] === null ? ($fallbackSales[$storeName] ?? ['period' => 0.0, 'months' => []]) : ($lifecycleSales[(string) $period['lifecycle_id']] ?? ['period' => 0.0, 'months' => []]); $monthly = $saleData['months']; $sales = []; for ($month = 1; $month <= 12; $month++) { $key = sprintf('%s-%02d', self::REPORT_YEAR, $month); $sales[$month] = $monthly[$key] ?? 0.0; } $rows[] = [ ...$store, 'store_id' => $storeName, 'status' => $this->status($store['store_status'] ?? null), 'entity' => $period['company_name'] ?: ($store['company_name'] ?? null), 'effective_at' => $effectiveAt, 'invalid_at' => $expiresAt, 'period_sales' => $saleData['period'], ...array_combine( array_map(static fn (int $month): string => sprintf('sales_%02d', $month), range(1, 12)), array_values($sales), ), ]; } } 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, 900) as $chunk) { $rows = $this->connection->table('store_lifecycles') ->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, ]; } } 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, 900) as $chunk) { $placeholders = implode(',', array_fill(0, count($chunk), '?')); $rows = $this->connection->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], ); foreach ($rows as $row) { $key = (string) $row->lifecycle_id; $result[$key]['period'] = ($result[$key]['period'] ?? 0.0) + (float) $row->income_total; $result[$key]['months'][(string) $row->bill_month] = (float) $row->income_total; } } return $result; } /** @return array}> */ private function fallbackSales(array $names): array { if ($names === [] || ! $this->hasTable('bills')) { return []; } $result = []; foreach (array_chunk($names, 900) as $chunk) { $placeholders = implode(',', array_fill(0, count($chunk), '?')); $rows = $this->connection->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], ); foreach ($rows as $row) { $key = (string) $row->store_name; $result[$key]['period'] = ($result[$key]['period'] ?? 0.0) + (float) $row->income_total; $result[$key]['months'][(string) $row->bill_month] = (float) $row->income_total; } } return $result; } /** * 判断 SQLite 表是否存在。使用普通只读查询,避免 Schema Builder * 在只读/WAL 挂载下探测表结构时尝试写入数据库。 */ private function hasTable(string $table): bool { return $this->connection->select( "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1", [$table], ) !== []; } 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, }; } }