import { fetchJson } from '../../../auth/api-client'; import type { H2BiDailyResponse, H2BiDailyTreeQuery, H2BiDailyTreeResponse, H2BiDrillReadOptions, H2BiFullDrillOptions, H2BiFullDrillResponse, H2BiDrillQuery, H2BiDrillResponse, H2BiMetaResponse, H2BiOverviewResponse, H2BiQuery, } from './types'; const BASE = '/api/energy/h2/v2'; function queryString(query: Record) { const params = new URLSearchParams(); for (const [key, value] of Object.entries(query)) { if (value !== undefined && value !== null && value !== '') params.set(key, String(value)); } return params.toString(); } function request(path: string, query: object = {}, options?: RequestInit) { const qs = queryString(query as Record); return fetchJson(`${BASE}/${path}${qs ? `?${qs}` : ''}`, options); } export function fetchH2BiMeta() { return request('meta'); } export function fetchH2BiOverview(query: H2BiQuery) { return request('overview', query); } export function fetchH2BiDaily(query: H2BiQuery) { return request('daily', query).catch(async () => { // The date-group drill is backed by the same read-only ledger and has a // simpler query plan. Keep the date view usable when the aggregate daily // endpoint times out/fails, without substituting mock data. const drill = await request('drill', { ...query, groupBy: 'date', pageSize: 400, }); const startDate = query.startDate ?? `${query.year}-01-01`; const endDate = query.endDate ?? new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', }).format(new Date()); const byDate = new Map(drill.groups.map((row) => [row.name, row])); const days = [] as H2BiDailyResponse['trend']; for (let cursor = new Date(`${startDate}T00:00:00Z`); cursor <= new Date(`${endDate}T00:00:00Z`); cursor.setUTCDate(cursor.getUTCDate() + 1)) { const date = cursor.toISOString().slice(0, 10); const row = byDate.get(date); days.push({ date, kg: Number(row?.kg ?? 0), lingniuKg: Number(row?.lingniuKg ?? 0), externalKg: Number(row?.externalKg ?? 0), cost: Number(row?.cost ?? 0), recordCount: Number(row?.recordCount ?? 0), }); } const totalKg = days.reduce((sum, row) => sum + row.kg, 0); const totalCost = days.reduce((sum, row) => sum + row.cost, 0); return { range: { startDate, endDate }, watermark: { ledgerAt: null, paymentAt: null }, filters: query, kpis: { totalKg, totalCost, averageDailyKg: totalKg / Math.max(1, days.length), stationCount: Number(drill.summary.stationCount ?? 0), activeDays: days.filter((row) => row.kg > 0).length, }, trend: days, days: [...days].reverse(), }; }); } export function fetchH2BiDailyTree(date: string, query: H2BiDailyTreeQuery) { return request('daily-tree', { date, ...query }); } export async function fetchH2BiDrill( query: H2BiDrillQuery, options?: H2BiDrillReadOptions, ) { const page = query.page ?? 1; const pageSize = Math.min(Math.max(1, query.pageSize ?? 100), 200); const response = await request( 'drill', { ...query, page, pageSize }, options, ); const itemCount = query.groupBy === 'record' ? response.records.length : response.groups.length; // Older servers only set hasMore for record pages. A full page is therefore // also treated as potentially incomplete; the final empty/short page proves // completion without silently dropping grouped rows. return { ...response, page: { ...response.page, page, pageSize, itemCount, hasMore: Boolean(response.page?.hasMore) || itemCount === pageSize, }, } satisfies H2BiDrillResponse; } function abortError() { const error = new Error('已取消全量读取'); error.name = 'AbortError'; return error; } /** * Reads every page only after a caller explicitly asks for a complete result. * It never returns a partial collection: server errors, cancellation, and the * page guard all reject before an export can be created. */ export async function fetchAllH2BiDrill( query: Omit, options: H2BiFullDrillOptions = {}, ): Promise { const pageSize = Math.min(Math.max(1, options.pageSize ?? 200), 200); const maxPages = Math.max(1, options.maxPages ?? 250); const maxRows = Math.max(1, options.maxRows ?? 50_000); const groups: H2BiDrillResponse['groups'] = []; const records: H2BiDrillResponse['records'] = []; let first: H2BiDrillResponse | null = null; for (let page = 1; page <= maxPages; page += 1) { if (options.signal?.aborted) throw abortError(); const result = await fetchH2BiDrill( { ...query, page, pageSize }, { signal: options.signal }, ); if (!first) first = result; groups.push(...result.groups); records.push(...result.records); const itemCount = query.groupBy === 'record' ? records.length : groups.length; if (itemCount > maxRows) throw new Error(`全量读取超过 ${maxRows} 条保护上限,未生成不完整结果;请缩小日期范围后重新导出`); if (!result.page.hasMore) { return { ...first, groups, records, page: { ...result.page, page: 1, pageSize, itemCount, hasMore: false, pagesRead: page, complete: true, }, }; } } throw new Error(`全量读取超过 ${maxPages} 页保护上限,未生成不完整结果;请缩小日期范围后重新导出`); } /** Reusable contract for station-detail consumers that need every raw record. */ export function fetchAllH2BiDrillRecords( query: Omit, options?: H2BiFullDrillOptions, ) { return fetchAllH2BiDrill({ ...query, groupBy: 'record' }, options); }