refactor: agrega queries e cache do dashboard

This commit is contained in:
Felipe Coutinho
2026-03-20 18:38:20 +00:00
parent 5b8d25d894
commit 41fd8226cb
24 changed files with 1648 additions and 690 deletions

View File

@@ -8,12 +8,11 @@ import {
ne,
not,
or,
sql,
sum,
} from "drizzle-orm";
import { cards, categories, invoices, payers, transactions } from "@/db/schema";
import { cards, categories, invoices, transactions } from "@/db/schema";
import { db } from "@/shared/lib/db";
import { PAYER_ROLE_ADMIN } from "@/shared/lib/payers/constants";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
import { formatDateOnly } from "@/shared/utils/date";
import { safeToNumber } from "@/shared/utils/number";
import {
@@ -150,48 +149,51 @@ export async function fetchCartoesReportData(
}
const cardIds = allCards.map((c) => c.id);
const adminPayerId = await getAdminPayerId(userId);
// Fetch current period usage by card (recorrente só conta quando a data da ocorrência já passou)
const currentUsageData = (await db
.select({
cardId: transactions.cardId,
totalAmount: sum(transactions.amount).as("total"),
})
.from(transactions)
.innerJoin(payers, eq(transactions.payerId, payers.id))
.where(
and(
eq(transactions.userId, userId),
eq(transactions.period, currentPeriod),
eq(payers.role, PAYER_ROLE_ADMIN),
eq(transactions.transactionType, DESPESA),
inArray(transactions.cardId, cardIds),
or(
ne(transactions.condition, "Recorrente"),
sql`${transactions.purchaseDate} <= current_date`,
),
),
)
.groupBy(transactions.cardId)) as CardUsageRow[];
const currentUsageData = adminPayerId
? ((await db
.select({
cardId: transactions.cardId,
totalAmount: sum(transactions.amount).as("total"),
})
.from(transactions)
.where(
and(
eq(transactions.userId, userId),
eq(transactions.period, currentPeriod),
eq(transactions.payerId, adminPayerId),
eq(transactions.transactionType, DESPESA),
inArray(transactions.cardId, cardIds),
or(
ne(transactions.condition, "Recorrente"),
lte(transactions.purchaseDate, new Date()),
),
),
)
.groupBy(transactions.cardId)) as CardUsageRow[])
: [];
// Fetch previous period usage by card
const previousUsageData = (await db
.select({
cardId: transactions.cardId,
totalAmount: sum(transactions.amount).as("total"),
})
.from(transactions)
.innerJoin(payers, eq(transactions.payerId, payers.id))
.where(
and(
eq(transactions.userId, userId),
eq(transactions.period, previousPeriod),
eq(payers.role, PAYER_ROLE_ADMIN),
eq(transactions.transactionType, DESPESA),
inArray(transactions.cardId, cardIds),
),
)
.groupBy(transactions.cardId)) as CardUsageRow[];
const previousUsageData = adminPayerId
? ((await db
.select({
cardId: transactions.cardId,
totalAmount: sum(transactions.amount).as("total"),
})
.from(transactions)
.where(
and(
eq(transactions.userId, userId),
eq(transactions.period, previousPeriod),
eq(transactions.payerId, adminPayerId),
eq(transactions.transactionType, DESPESA),
inArray(transactions.cardId, cardIds),
),
)
.groupBy(transactions.cardId)) as CardUsageRow[])
: [];
const currentUsageMap = new Map<string, number>();
for (const row of currentUsageData) {
@@ -262,6 +264,7 @@ export async function fetchCartoesReportData(
targetCardId,
cardSummary,
currentPeriod,
adminPayerId,
);
}
}
@@ -280,6 +283,7 @@ async function fetchCardDetail(
cardId: string,
cardSummary: CardSummary,
currentPeriod: string,
adminPayerId: string | null,
): Promise<CardDetailData> {
// Build period range for last 12 months
const periods = buildPeriodWindow(currentPeriod, 12);
@@ -287,25 +291,26 @@ async function fetchCardDetail(
const startPeriod = periods[0];
// Fetch monthly usage
const monthlyData = (await db
.select({
period: transactions.period,
totalAmount: sum(transactions.amount).as("total"),
})
.from(transactions)
.innerJoin(payers, eq(transactions.payerId, payers.id))
.where(
and(
eq(transactions.userId, userId),
eq(transactions.cardId, cardId),
gte(transactions.period, startPeriod),
lte(transactions.period, currentPeriod),
eq(payers.role, PAYER_ROLE_ADMIN),
eq(transactions.transactionType, DESPESA),
),
)
.groupBy(transactions.period)
.orderBy(transactions.period)) as MonthlyUsageRow[];
const monthlyData = adminPayerId
? ((await db
.select({
period: transactions.period,
totalAmount: sum(transactions.amount).as("total"),
})
.from(transactions)
.where(
and(
eq(transactions.userId, userId),
eq(transactions.cardId, cardId),
gte(transactions.period, startPeriod),
lte(transactions.period, currentPeriod),
eq(transactions.payerId, adminPayerId),
eq(transactions.transactionType, DESPESA),
),
)
.groupBy(transactions.period)
.orderBy(transactions.period)) as MonthlyUsageRow[])
: [];
const monthlyUsage = periods.map((period) => {
const data = monthlyData.find((d) => d.period === period);
@@ -317,23 +322,24 @@ async function fetchCardDetail(
});
// Fetch category breakdown for current period
const categoryData = (await db
.select({
categoryId: transactions.categoryId,
totalAmount: sum(transactions.amount).as("total"),
})
.from(transactions)
.innerJoin(payers, eq(transactions.payerId, payers.id))
.where(
and(
eq(transactions.userId, userId),
eq(transactions.cardId, cardId),
eq(transactions.period, currentPeriod),
eq(payers.role, PAYER_ROLE_ADMIN),
eq(transactions.transactionType, DESPESA),
),
)
.groupBy(transactions.categoryId)) as CategoryAmountRow[];
const categoryData = adminPayerId
? ((await db
.select({
categoryId: transactions.categoryId,
totalAmount: sum(transactions.amount).as("total"),
})
.from(transactions)
.where(
and(
eq(transactions.userId, userId),
eq(transactions.cardId, cardId),
eq(transactions.period, currentPeriod),
eq(transactions.payerId, adminPayerId),
eq(transactions.transactionType, DESPESA),
),
)
.groupBy(transactions.categoryId)) as CategoryAmountRow[])
: [];
// Fetch category names
const categoryIds = categoryData
@@ -378,27 +384,28 @@ async function fetchCardDetail(
.slice(0, 10);
// Fetch top expenses for current period
const topExpensesData = (await db
.select({
id: transactions.id,
name: transactions.name,
amount: transactions.amount,
purchaseDate: transactions.purchaseDate,
categoryId: transactions.categoryId,
})
.from(transactions)
.innerJoin(payers, eq(transactions.payerId, payers.id))
.where(
and(
eq(transactions.userId, userId),
eq(transactions.cardId, cardId),
eq(transactions.period, currentPeriod),
eq(payers.role, PAYER_ROLE_ADMIN),
eq(transactions.transactionType, DESPESA),
),
)
.orderBy(transactions.amount)
.limit(10)) as TopExpenseRow[];
const topExpensesData = adminPayerId
? ((await db
.select({
id: transactions.id,
name: transactions.name,
amount: transactions.amount,
purchaseDate: transactions.purchaseDate,
categoryId: transactions.categoryId,
})
.from(transactions)
.where(
and(
eq(transactions.userId, userId),
eq(transactions.cardId, cardId),
eq(transactions.period, currentPeriod),
eq(transactions.payerId, adminPayerId),
eq(transactions.transactionType, DESPESA),
),
)
.orderBy(transactions.amount)
.limit(10)) as TopExpenseRow[])
: [];
const topExpenses = topExpensesData.map((expense) => {
const catInfo = expense.categoryId

View File

@@ -5,6 +5,7 @@ import {
eq,
gte,
ilike,
inArray,
isNull,
lte,
ne,
@@ -13,23 +14,17 @@ import {
sql,
sum,
} from "drizzle-orm";
import {
categories,
financialAccounts,
payers,
transactions,
} from "@/db/schema";
import { categories, financialAccounts, transactions } from "@/db/schema";
import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} from "@/shared/lib/accounts/constants";
import { db } from "@/shared/lib/db";
import { PAYER_ROLE_ADMIN } from "@/shared/lib/payers/constants";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
import { safeToNumber } from "@/shared/utils/number";
import { getPreviousPeriod } from "@/shared/utils/period";
const DESPESA = "Despesa";
const TRANSFERENCIA = "Transferência";
export type EstablishmentData = {
name: string;
@@ -81,6 +76,48 @@ export async function fetchTopEstablishmentsData(
const months = parseInt(periodFilter, 10);
const periods = buildPeriodRange(currentPeriod, months);
const startPeriod = periods[0];
const adminPayerId = await getAdminPayerId(userId);
const periodLabel =
months === 3
? "Últimos 3 meses"
: months === 6
? "Últimos 6 meses"
: "Últimos 12 meses";
if (!adminPayerId) {
return {
establishments: [],
topCategories: [],
summary: {
totalEstablishments: 0,
totalTransactions: 0,
totalSpent: 0,
avgPerTransaction: 0,
mostFrequent: null,
highestSpending: null,
},
periodLabel,
};
}
const baseExpenseConditions = [
eq(transactions.userId, userId),
gte(transactions.period, startPeriod),
lte(transactions.period, currentPeriod),
eq(transactions.payerId, adminPayerId),
eq(transactions.transactionType, DESPESA),
] as const;
const exclusionConditions = [
or(
isNull(transactions.note),
not(ilike(transactions.note, `${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`)),
),
or(
ne(transactions.note, INITIAL_BALANCE_NOTE),
isNull(financialAccounts.excludeInitialBalanceFromIncome),
eq(financialAccounts.excludeInitialBalanceFromIncome, false),
),
] as const;
// Fetch establishments with transaction count and total amount
const establishmentsData = await db
@@ -90,57 +127,41 @@ export async function fetchTopEstablishmentsData(
totalAmount: sum(transactions.amount).as("total"),
})
.from(transactions)
.innerJoin(payers, eq(transactions.payerId, payers.id))
.leftJoin(
financialAccounts,
eq(transactions.accountId, financialAccounts.id),
)
.where(
and(
eq(transactions.userId, userId),
gte(transactions.period, startPeriod),
lte(transactions.period, currentPeriod),
eq(payers.role, PAYER_ROLE_ADMIN),
eq(transactions.transactionType, DESPESA),
ne(transactions.transactionType, TRANSFERENCIA),
or(
isNull(transactions.note),
not(ilike(transactions.note, `${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`)),
),
or(
ne(transactions.note, INITIAL_BALANCE_NOTE),
isNull(financialAccounts.excludeInitialBalanceFromIncome),
eq(financialAccounts.excludeInitialBalanceFromIncome, false),
),
),
)
.where(and(...baseExpenseConditions, ...exclusionConditions))
.groupBy(transactions.name)
.orderBy(desc(sql`count`))
.limit(50);
// Fetch categories for each establishment
const _establishmentNames = establishmentsData.map(
(e: (typeof establishmentsData)[0]) => e.name,
);
const establishmentNames = establishmentsData
.map((est) => est.name)
.filter((name): name is string => !!name);
const categoriesByEstablishment = await db
.select({
establishmentName: transactions.name,
categoryId: transactions.categoryId,
count: count().as("count"),
})
.from(transactions)
.innerJoin(payers, eq(transactions.payerId, payers.id))
.where(
and(
eq(transactions.userId, userId),
gte(transactions.period, startPeriod),
lte(transactions.period, currentPeriod),
eq(payers.role, PAYER_ROLE_ADMIN),
eq(transactions.transactionType, DESPESA),
),
)
.groupBy(transactions.name, transactions.categoryId);
const categoriesByEstablishment =
establishmentNames.length > 0
? await db
.select({
establishmentName: transactions.name,
categoryId: transactions.categoryId,
count: count().as("count"),
})
.from(transactions)
.leftJoin(
financialAccounts,
eq(transactions.accountId, financialAccounts.id),
)
.where(
and(
...baseExpenseConditions,
...exclusionConditions,
inArray(transactions.name, establishmentNames),
),
)
.groupBy(transactions.name, transactions.categoryId)
: [];
// Fetch all category names
const allCategories = await db
@@ -159,23 +180,33 @@ export async function fetchTopEstablishmentsData(
// Build establishment data with categories
type EstablishmentRow = (typeof establishmentsData)[0];
type CategoryByEstRow = (typeof categoriesByEstablishment)[0];
const categoriesByEstablishmentMap = new Map<
string,
Array<{ name: string; count: number }>
>();
for (const categoryRow of categoriesByEstablishment) {
if (!categoryRow.establishmentName || !categoryRow.categoryId) {
continue;
}
const current =
categoriesByEstablishmentMap.get(categoryRow.establishmentName) ?? [];
current.push({
name:
categoryMap.get(categoryRow.categoryId as string)?.name ||
"Sem categoria",
count: Number(categoryRow.count) || 0,
});
categoriesByEstablishmentMap.set(categoryRow.establishmentName, current);
}
const establishments: EstablishmentData[] = establishmentsData.map(
(est: EstablishmentRow) => {
const cnt = Number(est.count) || 0;
const total = Math.abs(safeToNumber(est.totalAmount));
const estCategories = categoriesByEstablishment
.filter(
(c: CategoryByEstRow) =>
c.establishmentName === est.name && c.categoryId,
)
.map((c: CategoryByEstRow) => ({
name:
categoryMap.get(c.categoryId as string)?.name || "Sem categoria",
count: Number(c.count) || 0,
}))
const estCategories = (categoriesByEstablishmentMap.get(est.name) ?? [])
.sort(
(
a: { name: string; count: number },
@@ -202,29 +233,11 @@ export async function fetchTopEstablishmentsData(
count: count().as("count"),
})
.from(transactions)
.innerJoin(payers, eq(transactions.payerId, payers.id))
.leftJoin(
financialAccounts,
eq(transactions.accountId, financialAccounts.id),
)
.where(
and(
eq(transactions.userId, userId),
gte(transactions.period, startPeriod),
lte(transactions.period, currentPeriod),
eq(payers.role, PAYER_ROLE_ADMIN),
eq(transactions.transactionType, DESPESA),
or(
isNull(transactions.note),
not(ilike(transactions.note, `${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`)),
),
or(
ne(transactions.note, INITIAL_BALANCE_NOTE),
isNull(financialAccounts.excludeInitialBalanceFromIncome),
eq(financialAccounts.excludeInitialBalanceFromIncome, false),
),
),
)
.where(and(...baseExpenseConditions, ...exclusionConditions))
.groupBy(transactions.categoryId)
.orderBy(sql`total ASC`)
.limit(10);
@@ -257,13 +270,6 @@ export async function fetchTopEstablishmentsData(
const highestSpending =
sortedBySpending.length > 0 ? sortedBySpending[0].name : null;
const periodLabel =
months === 3
? "Últimos 3 meses"
: months === 6
? "Últimos 6 meses"
: "Últimos 12 meses";
return {
establishments,
topCategories,