feat(balance): add timeseries aggregator helpers + tests
Add four service helpers used by the upcoming `/balance` overview: - getSnapshotTotalsByDate(range?) — SUM(value) GROUP BY snapshot_date with an optional inclusive [from, to] range. LEFT JOIN preserves empty snapshots as zero rows so the chart shows continuity. - getSnapshotTotalsByCategoryAndDate(range?) — same aggregation broken down by balance_categories.key, returned as one row per snapshot date with a `byCategory` map. Powers the stacked-area variant. - getAccountsLatestSnapshot() — one row per active account with the value of its most-recent snapshot line (NULL when none exists). Filters archived accounts via WHERE is_active = 1 AND archived_at IS NULL, matches the listBalanceAccounts default. - getAccountsPeriodAnchor(range) — earliest snapshot_date >= from per account, with the value at that date — the anchor used to compute the per-account Δ% column on the accounts table. Tests cover empty DB, single/multi snapshot, archived exclusion via SQL inspection, date-range params (from-only, both bounds, open). Refs: #141 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
80c0a97841
commit
396310aa74
2 changed files with 397 additions and 0 deletions
|
|
@ -26,6 +26,10 @@ import {
|
||||||
validateLineKindInvariants,
|
validateLineKindInvariants,
|
||||||
PRICED_VALUE_TOLERANCE,
|
PRICED_VALUE_TOLERANCE,
|
||||||
BalanceServiceError,
|
BalanceServiceError,
|
||||||
|
getSnapshotTotalsByDate,
|
||||||
|
getSnapshotTotalsByCategoryAndDate,
|
||||||
|
getAccountsLatestSnapshot,
|
||||||
|
getAccountsPeriodAnchor,
|
||||||
} from "./balance.service";
|
} from "./balance.service";
|
||||||
|
|
||||||
const mockSelect = vi.fn();
|
const mockSelect = vi.fn();
|
||||||
|
|
@ -801,3 +805,172 @@ describe("upsertSnapshotLines — priced kind", () => {
|
||||||
expect(mockExecute.mock.calls[2][1]).toEqual([5, 7, 10, 50, 500]);
|
expect(mockExecute.mock.calls[2][1]).toEqual([5, 7, 10, 50, 500]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Time-series aggregators (Issue #141 / Bilan #3)
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("getSnapshotTotalsByDate", () => {
|
||||||
|
it("returns an empty array on an empty DB", async () => {
|
||||||
|
mockSelect.mockResolvedValueOnce([]);
|
||||||
|
expect(await getSnapshotTotalsByDate()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("aggregates SUM(value) and orders ASC by snapshot_date", async () => {
|
||||||
|
mockSelect.mockResolvedValueOnce([
|
||||||
|
{ snapshot_date: "2026-01-31", total: 1000 },
|
||||||
|
{ snapshot_date: "2026-02-28", total: 1100 },
|
||||||
|
{ snapshot_date: "2026-03-31", total: 1250 },
|
||||||
|
]);
|
||||||
|
const out = await getSnapshotTotalsByDate();
|
||||||
|
expect(out).toEqual([
|
||||||
|
{ snapshot_date: "2026-01-31", total: 1000 },
|
||||||
|
{ snapshot_date: "2026-02-28", total: 1100 },
|
||||||
|
{ snapshot_date: "2026-03-31", total: 1250 },
|
||||||
|
]);
|
||||||
|
const sql = mockSelect.mock.calls[0][0] as string;
|
||||||
|
expect(sql).toContain("FROM balance_snapshots");
|
||||||
|
expect(sql).toContain("LEFT JOIN balance_snapshot_lines");
|
||||||
|
expect(sql).toContain("GROUP BY s.snapshot_date");
|
||||||
|
expect(sql).toContain("ORDER BY s.snapshot_date ASC");
|
||||||
|
// Empty range → no WHERE clause + no params
|
||||||
|
expect(sql).not.toContain("WHERE");
|
||||||
|
expect(mockSelect.mock.calls[0][1]).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies an inclusive [from, to] date range filter", async () => {
|
||||||
|
mockSelect.mockResolvedValueOnce([]);
|
||||||
|
await getSnapshotTotalsByDate({ from: "2026-01-01", to: "2026-03-31" });
|
||||||
|
const sql = mockSelect.mock.calls[0][0] as string;
|
||||||
|
expect(sql).toContain("WHERE");
|
||||||
|
expect(sql).toContain("s.snapshot_date >=");
|
||||||
|
expect(sql).toContain("s.snapshot_date <=");
|
||||||
|
expect(mockSelect.mock.calls[0][1]).toEqual(["2026-01-01", "2026-03-31"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports an open-ended `from` only", async () => {
|
||||||
|
mockSelect.mockResolvedValueOnce([]);
|
||||||
|
await getSnapshotTotalsByDate({ from: "2026-01-01" });
|
||||||
|
const sql = mockSelect.mock.calls[0][0] as string;
|
||||||
|
expect(sql).toContain("s.snapshot_date >=");
|
||||||
|
expect(sql).not.toContain("s.snapshot_date <=");
|
||||||
|
expect(mockSelect.mock.calls[0][1]).toEqual(["2026-01-01"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getSnapshotTotalsByCategoryAndDate", () => {
|
||||||
|
it("returns [] on empty DB", async () => {
|
||||||
|
mockSelect.mockResolvedValueOnce([]);
|
||||||
|
expect(await getSnapshotTotalsByCategoryAndDate()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("buckets multiple category rows under the same snapshot_date", async () => {
|
||||||
|
mockSelect.mockResolvedValueOnce([
|
||||||
|
{ snapshot_date: "2026-01-31", category_key: "cash", total: 500 },
|
||||||
|
{ snapshot_date: "2026-01-31", category_key: "tfsa", total: 1500 },
|
||||||
|
{ snapshot_date: "2026-02-28", category_key: "cash", total: 700 },
|
||||||
|
{ snapshot_date: "2026-02-28", category_key: "tfsa", total: 1700 },
|
||||||
|
]);
|
||||||
|
const out = await getSnapshotTotalsByCategoryAndDate();
|
||||||
|
expect(out).toEqual([
|
||||||
|
{
|
||||||
|
snapshot_date: "2026-01-31",
|
||||||
|
byCategory: { cash: 500, tfsa: 1500 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
snapshot_date: "2026-02-28",
|
||||||
|
byCategory: { cash: 700, tfsa: 1700 },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const sql = mockSelect.mock.calls[0][0] as string;
|
||||||
|
expect(sql).toContain("INNER JOIN balance_snapshot_lines");
|
||||||
|
expect(sql).toContain("INNER JOIN balance_accounts");
|
||||||
|
expect(sql).toContain("INNER JOIN balance_categories");
|
||||||
|
expect(sql).toContain("GROUP BY s.snapshot_date, c.key");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies date range params when supplied", async () => {
|
||||||
|
mockSelect.mockResolvedValueOnce([]);
|
||||||
|
await getSnapshotTotalsByCategoryAndDate({
|
||||||
|
from: "2026-01-01",
|
||||||
|
to: "2026-12-31",
|
||||||
|
});
|
||||||
|
const sql = mockSelect.mock.calls[0][0] as string;
|
||||||
|
expect(sql).toContain("WHERE");
|
||||||
|
expect(mockSelect.mock.calls[0][1]).toEqual(["2026-01-01", "2026-12-31"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getAccountsLatestSnapshot", () => {
|
||||||
|
it("returns [] when there are no active accounts", async () => {
|
||||||
|
mockSelect.mockResolvedValueOnce([]);
|
||||||
|
expect(await getAccountsLatestSnapshot()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns one row per active account joined with category metadata", async () => {
|
||||||
|
mockSelect.mockResolvedValueOnce([
|
||||||
|
{
|
||||||
|
account_id: 1,
|
||||||
|
account_name: "BMO chequing",
|
||||||
|
symbol: null,
|
||||||
|
balance_category_id: 10,
|
||||||
|
category_key: "cash",
|
||||||
|
category_i18n_key: "balance.category.cash",
|
||||||
|
category_kind: "simple",
|
||||||
|
latest_snapshot_date: "2026-03-31",
|
||||||
|
latest_value: 1234.56,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
account_id: 2,
|
||||||
|
account_name: "Wealthsimple TFSA",
|
||||||
|
symbol: null,
|
||||||
|
balance_category_id: 11,
|
||||||
|
category_key: "tfsa",
|
||||||
|
category_i18n_key: "balance.category.tfsa",
|
||||||
|
category_kind: "simple",
|
||||||
|
latest_snapshot_date: null,
|
||||||
|
latest_value: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const out = await getAccountsLatestSnapshot();
|
||||||
|
expect(out).toHaveLength(2);
|
||||||
|
expect(out[0].latest_value).toBe(1234.56);
|
||||||
|
expect(out[1].latest_value).toBeNull();
|
||||||
|
const sql = mockSelect.mock.calls[0][0] as string;
|
||||||
|
// Filter: only active, non-archived accounts.
|
||||||
|
expect(sql).toContain("a.is_active = 1");
|
||||||
|
expect(sql).toContain("a.archived_at IS NULL");
|
||||||
|
// LEFT JOIN-equivalent: scalar subquery so accounts with no lines still surface.
|
||||||
|
expect(sql).toContain("ORDER BY s.snapshot_date DESC");
|
||||||
|
expect(sql).toContain("LIMIT 1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getAccountsPeriodAnchor", () => {
|
||||||
|
it("queries with a from-only filter", async () => {
|
||||||
|
mockSelect.mockResolvedValueOnce([
|
||||||
|
{ account_id: 1, anchor_snapshot_date: "2026-01-31", anchor_value: 1000 },
|
||||||
|
]);
|
||||||
|
const rows = await getAccountsPeriodAnchor({ from: "2026-01-01" });
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0].anchor_value).toBe(1000);
|
||||||
|
const sql = mockSelect.mock.calls[0][0] as string;
|
||||||
|
expect(sql).toContain("MIN(s.snapshot_date)");
|
||||||
|
expect(sql).toContain("GROUP BY l.account_id");
|
||||||
|
expect(mockSelect.mock.calls[0][1]).toEqual(["2026-01-01"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("queries with both from and to", async () => {
|
||||||
|
mockSelect.mockResolvedValueOnce([]);
|
||||||
|
await getAccountsPeriodAnchor({ from: "2026-01-01", to: "2026-12-31" });
|
||||||
|
expect(mockSelect.mock.calls[0][1]).toEqual(["2026-01-01", "2026-12-31"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("works with an empty range (open-ended)", async () => {
|
||||||
|
mockSelect.mockResolvedValueOnce([]);
|
||||||
|
await getAccountsPeriodAnchor({});
|
||||||
|
const sql = mockSelect.mock.calls[0][0] as string;
|
||||||
|
// No WHERE clause when neither bound is set.
|
||||||
|
expect(sql).not.toMatch(/WHERE\s+s\.snapshot_date/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -731,3 +731,227 @@ export async function getPreviousSnapshot(
|
||||||
);
|
);
|
||||||
return rows[0] ?? null;
|
return rows[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Time-series aggregators (Issue #141 / Bilan #3) — used by BalancePage.
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional [from, to] range filter expressed in ISO `YYYY-MM-DD` format.
|
||||||
|
* Both endpoints are inclusive. `from` and `to` may each be omitted to leave
|
||||||
|
* that side unbounded.
|
||||||
|
*/
|
||||||
|
export interface SnapshotDateRange {
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Aggregated total at a given snapshot date. */
|
||||||
|
export interface SnapshotTotalPoint {
|
||||||
|
snapshot_date: string;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDateRangeClause(
|
||||||
|
range: SnapshotDateRange | undefined,
|
||||||
|
baseAlias: string
|
||||||
|
): { clause: string; params: unknown[] } {
|
||||||
|
if (!range || (!range.from && !range.to)) {
|
||||||
|
return { clause: "", params: [] };
|
||||||
|
}
|
||||||
|
const parts: string[] = [];
|
||||||
|
const params: unknown[] = [];
|
||||||
|
if (range.from) {
|
||||||
|
const from = normalizeSnapshotDate(range.from);
|
||||||
|
parts.push(`${baseAlias}.snapshot_date >= $${params.length + 1}`);
|
||||||
|
params.push(from);
|
||||||
|
}
|
||||||
|
if (range.to) {
|
||||||
|
const to = normalizeSnapshotDate(range.to);
|
||||||
|
parts.push(`${baseAlias}.snapshot_date <= $${params.length + 1}`);
|
||||||
|
params.push(to);
|
||||||
|
}
|
||||||
|
return { clause: `WHERE ${parts.join(" AND ")}`, params };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the aggregated total value of every snapshot, sorted by date ASC.
|
||||||
|
* Used by the line variant of the evolution chart on `/balance`.
|
||||||
|
*
|
||||||
|
* The aggregation is `SUM(value) GROUP BY snapshot_date` — every account
|
||||||
|
* contributing to the snapshot is summed in. Snapshots with no lines
|
||||||
|
* collapse to a `total = 0` row (preserved so the chart shows continuity).
|
||||||
|
*/
|
||||||
|
export async function getSnapshotTotalsByDate(
|
||||||
|
range?: SnapshotDateRange
|
||||||
|
): Promise<SnapshotTotalPoint[]> {
|
||||||
|
const { clause, params } = buildDateRangeClause(range, "s");
|
||||||
|
const db = await getDb();
|
||||||
|
return db.select<SnapshotTotalPoint[]>(
|
||||||
|
`SELECT s.snapshot_date AS snapshot_date,
|
||||||
|
COALESCE(SUM(l.value), 0) AS total
|
||||||
|
FROM balance_snapshots s
|
||||||
|
LEFT JOIN balance_snapshot_lines l ON l.snapshot_id = s.id
|
||||||
|
${clause}
|
||||||
|
GROUP BY s.snapshot_date
|
||||||
|
ORDER BY s.snapshot_date ASC`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-snapshot breakdown by category. */
|
||||||
|
export interface SnapshotCategoryBreakdownPoint {
|
||||||
|
snapshot_date: string;
|
||||||
|
byCategory: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawCategoryBreakdownRow {
|
||||||
|
snapshot_date: string;
|
||||||
|
category_key: string;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns per-snapshot totals broken down by `balance_categories.key`,
|
||||||
|
* sorted by date ASC. Used by the stacked-area variant of the evolution
|
||||||
|
* chart. Categories with no value at a given date are omitted from the
|
||||||
|
* `byCategory` map (chart consumers should treat absent keys as zero).
|
||||||
|
*
|
||||||
|
* Lines whose joined account points to no category are skipped — that
|
||||||
|
* shouldn't happen given FK RESTRICT but the JOIN is defensive.
|
||||||
|
*/
|
||||||
|
export async function getSnapshotTotalsByCategoryAndDate(
|
||||||
|
range?: SnapshotDateRange
|
||||||
|
): Promise<SnapshotCategoryBreakdownPoint[]> {
|
||||||
|
const { clause, params } = buildDateRangeClause(range, "s");
|
||||||
|
const db = await getDb();
|
||||||
|
const rows = await db.select<RawCategoryBreakdownRow[]>(
|
||||||
|
`SELECT s.snapshot_date AS snapshot_date,
|
||||||
|
c.key AS category_key,
|
||||||
|
COALESCE(SUM(l.value), 0) AS total
|
||||||
|
FROM balance_snapshots s
|
||||||
|
INNER JOIN balance_snapshot_lines l ON l.snapshot_id = s.id
|
||||||
|
INNER JOIN balance_accounts a ON a.id = l.account_id
|
||||||
|
INNER JOIN balance_categories c ON c.id = a.balance_category_id
|
||||||
|
${clause}
|
||||||
|
GROUP BY s.snapshot_date, c.key
|
||||||
|
ORDER BY s.snapshot_date ASC, c.key ASC`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
// Bucket rows by snapshot_date — many rows per date, one per category.
|
||||||
|
const out: SnapshotCategoryBreakdownPoint[] = [];
|
||||||
|
let current: SnapshotCategoryBreakdownPoint | null = null;
|
||||||
|
for (const r of rows) {
|
||||||
|
if (!current || current.snapshot_date !== r.snapshot_date) {
|
||||||
|
current = { snapshot_date: r.snapshot_date, byCategory: {} };
|
||||||
|
out.push(current);
|
||||||
|
}
|
||||||
|
current.byCategory[r.category_key] = r.total;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Latest-snapshot value per active account (Issue #141). */
|
||||||
|
export interface AccountLatestSnapshot {
|
||||||
|
account_id: number;
|
||||||
|
account_name: string;
|
||||||
|
symbol: string | null;
|
||||||
|
balance_category_id: number;
|
||||||
|
category_key: string;
|
||||||
|
category_i18n_key: string;
|
||||||
|
category_kind: BalanceCategoryKind;
|
||||||
|
/** Date of the snapshot whose value is reported, or null if no snapshot exists. */
|
||||||
|
latest_snapshot_date: string | null;
|
||||||
|
/** Value at that snapshot, or null if the account has no snapshot lines. */
|
||||||
|
latest_value: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns one row per active (non-archived) account with the value of its
|
||||||
|
* most-recent snapshot line. Accounts with no snapshot rows yet still
|
||||||
|
* appear, with `latest_value = null`. Used by the accounts table on
|
||||||
|
* `/balance` (#141) and as a building block for the period Δ% column.
|
||||||
|
*
|
||||||
|
* Implementation: a correlated subquery picks the line with the largest
|
||||||
|
* `s.snapshot_date` for each account — SQLite handles this fine on the
|
||||||
|
* indexed `balance_snapshots.snapshot_date` and `balance_snapshot_lines.account_id`.
|
||||||
|
*/
|
||||||
|
export async function getAccountsLatestSnapshot(): Promise<
|
||||||
|
AccountLatestSnapshot[]
|
||||||
|
> {
|
||||||
|
const db = await getDb();
|
||||||
|
return db.select<AccountLatestSnapshot[]>(
|
||||||
|
`SELECT a.id AS account_id,
|
||||||
|
a.name AS account_name,
|
||||||
|
a.symbol AS symbol,
|
||||||
|
a.balance_category_id AS balance_category_id,
|
||||||
|
c.key AS category_key,
|
||||||
|
c.i18n_key AS category_i18n_key,
|
||||||
|
c.kind AS category_kind,
|
||||||
|
(SELECT s.snapshot_date
|
||||||
|
FROM balance_snapshot_lines l
|
||||||
|
JOIN balance_snapshots s ON s.id = l.snapshot_id
|
||||||
|
WHERE l.account_id = a.id
|
||||||
|
ORDER BY s.snapshot_date DESC
|
||||||
|
LIMIT 1) AS latest_snapshot_date,
|
||||||
|
(SELECT l.value
|
||||||
|
FROM balance_snapshot_lines l
|
||||||
|
JOIN balance_snapshots s ON s.id = l.snapshot_id
|
||||||
|
WHERE l.account_id = a.id
|
||||||
|
ORDER BY s.snapshot_date DESC
|
||||||
|
LIMIT 1) AS latest_value
|
||||||
|
FROM balance_accounts a
|
||||||
|
INNER JOIN balance_categories c ON c.id = a.balance_category_id
|
||||||
|
WHERE a.is_active = 1 AND a.archived_at IS NULL
|
||||||
|
ORDER BY c.sort_order, a.name`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the value at the earliest snapshot for each account whose
|
||||||
|
* `snapshot_date` is `>= range.from` (and `<= range.to` when set), so the
|
||||||
|
* accounts table can compute a per-account Δ% over the selected period.
|
||||||
|
*
|
||||||
|
* Returns one row per account with a snapshot in range. Accounts without
|
||||||
|
* any snapshot in the period are omitted — callers default their Δ% to
|
||||||
|
* `null` (rendered as "—").
|
||||||
|
*/
|
||||||
|
export interface AccountPeriodAnchor {
|
||||||
|
account_id: number;
|
||||||
|
anchor_snapshot_date: string;
|
||||||
|
anchor_value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAccountsPeriodAnchor(
|
||||||
|
range: SnapshotDateRange
|
||||||
|
): Promise<AccountPeriodAnchor[]> {
|
||||||
|
// For each account, find the earliest snapshot_date >= range.from (and
|
||||||
|
// <= range.to when set), then read that line's value.
|
||||||
|
const params: unknown[] = [];
|
||||||
|
const conditions: string[] = [];
|
||||||
|
if (range.from) {
|
||||||
|
conditions.push(`s.snapshot_date >= $${params.length + 1}`);
|
||||||
|
params.push(normalizeSnapshotDate(range.from));
|
||||||
|
}
|
||||||
|
if (range.to) {
|
||||||
|
conditions.push(`s.snapshot_date <= $${params.length + 1}`);
|
||||||
|
params.push(normalizeSnapshotDate(range.to));
|
||||||
|
}
|
||||||
|
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||||
|
const db = await getDb();
|
||||||
|
return db.select<AccountPeriodAnchor[]>(
|
||||||
|
`SELECT l.account_id AS account_id,
|
||||||
|
MIN(s.snapshot_date) AS anchor_snapshot_date,
|
||||||
|
(SELECT l2.value
|
||||||
|
FROM balance_snapshot_lines l2
|
||||||
|
JOIN balance_snapshots s2 ON s2.id = l2.snapshot_id
|
||||||
|
WHERE l2.account_id = l.account_id
|
||||||
|
AND s2.snapshot_date = MIN(s.snapshot_date)
|
||||||
|
LIMIT 1) AS anchor_value
|
||||||
|
FROM balance_snapshot_lines l
|
||||||
|
JOIN balance_snapshots s ON s.id = l.snapshot_id
|
||||||
|
${where}
|
||||||
|
GROUP BY l.account_id`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue