feat(overview): add DJF revenue and payment KPIs

The billing KPI queries fan out per currency with a FILTER (WHERE
payment.currency = '...') per column rather than a generic GROUP BY,
so DJF needs an explicit third variant at each of the 7 query sites
(revenueMtd*, amount*, revenue* pairs) plus the matching DTO fields.
The already-generic queries (getBookingsByCurrency,
getRevenueByCurrency, company-dashboard's sumPaidSpendByCurrency)
needed no change — they GROUP BY currency and pick up DJF on their own.

Claude-Session: https://claude.ai/code/session_01CZy77vCWhka3pnmVF9NDkL
This commit is contained in:
ghost2023
2026-09-04 11:52:48 +03:00
parent 3734ca3897
commit d8a12939ae
2 changed files with 53 additions and 8 deletions

View File

@@ -36,6 +36,7 @@ export class OverviewCustomerKpisDto {
export class OverviewBillingKpisDto { export class OverviewBillingKpisDto {
@ApiProperty() revenueMtdEtb!: number; @ApiProperty() revenueMtdEtb!: number;
@ApiProperty() revenueMtdUsd!: number; @ApiProperty() revenueMtdUsd!: number;
@ApiProperty() revenueMtdDjf!: number;
@ApiProperty() pendingPayments!: number; @ApiProperty() pendingPayments!: number;
@ApiProperty() successfulPaymentsMtd!: number; @ApiProperty() successfulPaymentsMtd!: number;
} }
@@ -84,6 +85,7 @@ export class OverviewPaymentTrendPointDto {
@ApiProperty({ example: '2026-06-01' }) date!: string; @ApiProperty({ example: '2026-06-01' }) date!: string;
@ApiProperty() amountEtb!: number; @ApiProperty() amountEtb!: number;
@ApiProperty() amountUsd!: number; @ApiProperty() amountUsd!: number;
@ApiProperty() amountDjf!: number;
} }
export class OverviewRecentBookingDto { export class OverviewRecentBookingDto {
@@ -113,6 +115,7 @@ export class OverviewPeriodTotalsDto {
@ApiProperty() bookingsCreated!: number; @ApiProperty() bookingsCreated!: number;
@ApiProperty() revenueEtb!: number; @ApiProperty() revenueEtb!: number;
@ApiProperty() revenueUsd!: number; @ApiProperty() revenueUsd!: number;
@ApiProperty() revenueDjf!: number;
@ApiProperty() tons!: number; @ApiProperty() tons!: number;
} }
@@ -120,6 +123,7 @@ export class OverviewRevenueSliceDto {
@ApiProperty() label!: string; @ApiProperty() label!: string;
@ApiProperty() amountEtb!: number; @ApiProperty() amountEtb!: number;
@ApiProperty() amountUsd!: number; @ApiProperty() amountUsd!: number;
@ApiProperty() amountDjf!: number;
} }
export class OverviewTonsTrendPointDto { export class OverviewTonsTrendPointDto {
@@ -132,6 +136,7 @@ export class OverviewRevenueFlowDto {
@ApiProperty() freightType!: string; @ApiProperty() freightType!: string;
@ApiProperty() amountEtb!: number; @ApiProperty() amountEtb!: number;
@ApiProperty() amountUsd!: number; @ApiProperty() amountUsd!: number;
@ApiProperty() amountDjf!: number;
} }
export class OverviewHeatmapCellDto { export class OverviewHeatmapCellDto {

View File

@@ -265,6 +265,7 @@ export class OverviewRepository {
async getBillingKpis(dirs?: string[]): Promise<{ async getBillingKpis(dirs?: string[]): Promise<{
revenueMtdEtb: number; revenueMtdEtb: number;
revenueMtdUsd: number; revenueMtdUsd: number;
revenueMtdDjf: number;
pendingPayments: number; pendingPayments: number;
successfulPaymentsMtd: number; successfulPaymentsMtd: number;
}> { }> {
@@ -279,6 +280,10 @@ export class OverviewRepository {
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`, `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
"revenueMtdUsd", "revenueMtdUsd",
) )
.addSelect(
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF'), 0)`,
"revenueMtdDjf",
)
.addSelect(`COUNT(*)::int`, "successfulPaymentsMtd") .addSelect(`COUNT(*)::int`, "successfulPaymentsMtd")
.where("payment.status = :status", { status: "success" }) .where("payment.status = :status", { status: "success" })
.andWhere( .andWhere(
@@ -298,6 +303,7 @@ export class OverviewRepository {
return { return {
revenueMtdEtb: Number(revenueRow?.revenueMtdEtb ?? 0), revenueMtdEtb: Number(revenueRow?.revenueMtdEtb ?? 0),
revenueMtdUsd: Number(revenueRow?.revenueMtdUsd ?? 0), revenueMtdUsd: Number(revenueRow?.revenueMtdUsd ?? 0),
revenueMtdDjf: Number(revenueRow?.revenueMtdDjf ?? 0),
pendingPayments, pendingPayments,
successfulPaymentsMtd: Number(revenueRow?.successfulPaymentsMtd ?? 0), successfulPaymentsMtd: Number(revenueRow?.successfulPaymentsMtd ?? 0),
}; };
@@ -370,7 +376,7 @@ export class OverviewRepository {
days: number, days: number,
dirs?: string[], dirs?: string[],
offsetDays = 0, offsetDays = 0,
): Promise<{ date: string; amountEtb: number; amountUsd: number }[]> { ): Promise<{ date: string; amountEtb: number; amountUsd: number; amountDjf: number }[]> {
const scope = bookingRefScopeSql("payment.ref_id", dirs); const scope = bookingRefScopeSql("payment.ref_id", dirs);
const rows = await this.paymentRepository const rows = await this.paymentRepository
.createQueryBuilder("payment") .createQueryBuilder("payment")
@@ -386,6 +392,10 @@ export class OverviewRepository {
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`, `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
"amountUsd", "amountUsd",
) )
.addSelect(
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF'), 0)`,
"amountDjf",
)
.where("payment.status = :status", { status: "success" }) .where("payment.status = :status", { status: "success" })
.andWhere( .andWhere(
`COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :offsetDays::int - :days::int + 1 AND COALESCE(payment.paid_at, payment.created_at) < CURRENT_DATE - :offsetDays::int + 1`, `COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :offsetDays::int - :days::int + 1 AND COALESCE(payment.paid_at, payment.created_at) < CURRENT_DATE - :offsetDays::int + 1`,
@@ -394,12 +404,13 @@ export class OverviewRepository {
.andWhere(scope.sql, scope.params) .andWhere(scope.sql, scope.params)
.groupBy(`COALESCE(payment.paid_at, payment.created_at)::date`) .groupBy(`COALESCE(payment.paid_at, payment.created_at)::date`)
.orderBy(`COALESCE(payment.paid_at, payment.created_at)::date`, "ASC") .orderBy(`COALESCE(payment.paid_at, payment.created_at)::date`, "ASC")
.getRawMany<{ date: string; amountEtb: string; amountUsd: string }>(); .getRawMany<{ date: string; amountEtb: string; amountUsd: string; amountDjf: string }>();
return rows.map((row) => ({ return rows.map((row) => ({
date: row.date, date: row.date,
amountEtb: Number(row.amountEtb), amountEtb: Number(row.amountEtb),
amountUsd: Number(row.amountUsd), amountUsd: Number(row.amountUsd),
amountDjf: Number(row.amountDjf),
})); }));
} }
@@ -510,7 +521,7 @@ export class OverviewRepository {
async getPaymentsByMethod( async getPaymentsByMethod(
dirs?: string[], dirs?: string[],
): Promise< ): Promise<
{ method: string; count: number; amountEtb: number; amountUsd: number }[] { method: string; count: number; amountEtb: number; amountUsd: number; amountDjf: number }[]
> { > {
const scope = bookingRefScopeSql("payment.ref_id", dirs); const scope = bookingRefScopeSql("payment.ref_id", dirs);
const rows = await this.paymentRepository const rows = await this.paymentRepository
@@ -525,6 +536,10 @@ export class OverviewRepository {
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD' AND payment.status = 'success'), 0)`, `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD' AND payment.status = 'success'), 0)`,
"amountUsd", "amountUsd",
) )
.addSelect(
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF' AND payment.status = 'success'), 0)`,
"amountDjf",
)
.where(scope.sql, scope.params) .where(scope.sql, scope.params)
.groupBy("payment.method") .groupBy("payment.method")
.orderBy("count", "DESC") .orderBy("count", "DESC")
@@ -533,6 +548,7 @@ export class OverviewRepository {
count: string; count: string;
amountEtb: string; amountEtb: string;
amountUsd: string; amountUsd: string;
amountDjf: string;
}>(); }>();
return rows.map((row) => ({ return rows.map((row) => ({
@@ -540,6 +556,7 @@ export class OverviewRepository {
count: Number(row.count), count: Number(row.count),
amountEtb: Number(row.amountEtb), amountEtb: Number(row.amountEtb),
amountUsd: Number(row.amountUsd), amountUsd: Number(row.amountUsd),
amountDjf: Number(row.amountDjf),
})); }));
} }
@@ -580,6 +597,7 @@ export class OverviewRepository {
bookingsCreated: number; bookingsCreated: number;
revenueEtb: number; revenueEtb: number;
revenueUsd: number; revenueUsd: number;
revenueDjf: number;
tons: number; tons: number;
}> { }> {
const bookingScope = directionScopeSql("booking.trade_direction", dirs); const bookingScope = directionScopeSql("booking.trade_direction", dirs);
@@ -605,13 +623,17 @@ export class OverviewRepository {
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`, `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
"revenueUsd", "revenueUsd",
) )
.addSelect(
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF'), 0)`,
"revenueDjf",
)
.where("payment.status = :status", { status: "success" }) .where("payment.status = :status", { status: "success" })
.andWhere( .andWhere(
windowSql("COALESCE(payment.paid_at, payment.created_at)"), windowSql("COALESCE(payment.paid_at, payment.created_at)"),
{ days, offsetDays }, { days, offsetDays },
) )
.andWhere(paymentScope.sql, paymentScope.params) .andWhere(paymentScope.sql, paymentScope.params)
.getRawOne<{ revenueEtb: string; revenueUsd: string }>(), .getRawOne<{ revenueEtb: string; revenueUsd: string; revenueDjf: string }>(),
this.cargoRepository this.cargoRepository
.createQueryBuilder("cargo") .createQueryBuilder("cargo")
.leftJoin(Booking, "booking", "booking.id = cargo.booking_id") .leftJoin(Booking, "booking", "booking.id = cargo.booking_id")
@@ -626,6 +648,7 @@ export class OverviewRepository {
bookingsCreated, bookingsCreated,
revenueEtb: Number(revenueRow?.revenueEtb ?? 0), revenueEtb: Number(revenueRow?.revenueEtb ?? 0),
revenueUsd: Number(revenueRow?.revenueUsd ?? 0), revenueUsd: Number(revenueRow?.revenueUsd ?? 0),
revenueDjf: Number(revenueRow?.revenueDjf ?? 0),
tons: Number(tonsRow?.tons ?? 0), tons: Number(tonsRow?.tons ?? 0),
}; };
} }
@@ -634,7 +657,7 @@ export class OverviewRepository {
async getRevenueByDirection( async getRevenueByDirection(
days: number, days: number,
dirs?: string[], dirs?: string[],
): Promise<{ label: string; amountEtb: number; amountUsd: number }[]> { ): Promise<{ label: string; amountEtb: number; amountUsd: number; amountDjf: number }[]> {
const scope = bookingRefScopeSql("payment.ref_id", dirs); const scope = bookingRefScopeSql("payment.ref_id", dirs);
const rows = await this.paymentRepository const rows = await this.paymentRepository
.createQueryBuilder("payment") .createQueryBuilder("payment")
@@ -648,6 +671,10 @@ export class OverviewRepository {
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`, `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
"amountUsd", "amountUsd",
) )
.addSelect(
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF'), 0)`,
"amountDjf",
)
.where("payment.status = :status", { status: "success" }) .where("payment.status = :status", { status: "success" })
.andWhere( .andWhere(
`COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`, `COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`,
@@ -656,12 +683,13 @@ export class OverviewRepository {
.andWhere(scope.sql, scope.params) .andWhere(scope.sql, scope.params)
.andWhere("booking.trade_direction IS NOT NULL") .andWhere("booking.trade_direction IS NOT NULL")
.groupBy("booking.trade_direction") .groupBy("booking.trade_direction")
.getRawMany<{ label: string; amountEtb: string; amountUsd: string }>(); .getRawMany<{ label: string; amountEtb: string; amountUsd: string; amountDjf: string }>();
return rows.map((row) => ({ return rows.map((row) => ({
label: row.label, label: row.label,
amountEtb: Number(row.amountEtb), amountEtb: Number(row.amountEtb),
amountUsd: Number(row.amountUsd), amountUsd: Number(row.amountUsd),
amountDjf: Number(row.amountDjf),
})); }));
} }
@@ -669,7 +697,7 @@ export class OverviewRepository {
async getRevenueByFreightType( async getRevenueByFreightType(
days: number, days: number,
dirs?: string[], dirs?: string[],
): Promise<{ label: string; amountEtb: number; amountUsd: number }[]> { ): Promise<{ label: string; amountEtb: number; amountUsd: number; amountDjf: number }[]> {
const scope = bookingRefScopeSql("payment.ref_id", dirs); const scope = bookingRefScopeSql("payment.ref_id", dirs);
const rows = await this.paymentRepository const rows = await this.paymentRepository
.createQueryBuilder("payment") .createQueryBuilder("payment")
@@ -683,6 +711,10 @@ export class OverviewRepository {
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`, `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
"amountUsd", "amountUsd",
) )
.addSelect(
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF'), 0)`,
"amountDjf",
)
.where("payment.status = :status", { status: "success" }) .where("payment.status = :status", { status: "success" })
.andWhere( .andWhere(
`COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`, `COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`,
@@ -691,12 +723,13 @@ export class OverviewRepository {
.andWhere(scope.sql, scope.params) .andWhere(scope.sql, scope.params)
.andWhere("booking.freight_type IS NOT NULL") .andWhere("booking.freight_type IS NOT NULL")
.groupBy("booking.freight_type") .groupBy("booking.freight_type")
.getRawMany<{ label: string; amountEtb: string; amountUsd: string }>(); .getRawMany<{ label: string; amountEtb: string; amountUsd: string; amountDjf: string }>();
return rows.map((row) => ({ return rows.map((row) => ({
label: row.label, label: row.label,
amountEtb: Number(row.amountEtb), amountEtb: Number(row.amountEtb),
amountUsd: Number(row.amountUsd), amountUsd: Number(row.amountUsd),
amountDjf: Number(row.amountDjf),
})); }));
} }
@@ -734,6 +767,7 @@ export class OverviewRepository {
freightType: string; freightType: string;
amountEtb: number; amountEtb: number;
amountUsd: number; amountUsd: number;
amountDjf: number;
}[] }[]
> { > {
const scope = bookingRefScopeSql("payment.ref_id", dirs); const scope = bookingRefScopeSql("payment.ref_id", dirs);
@@ -750,6 +784,10 @@ export class OverviewRepository {
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`, `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
"amountUsd", "amountUsd",
) )
.addSelect(
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF'), 0)`,
"amountDjf",
)
.where("payment.status = :status", { status: "success" }) .where("payment.status = :status", { status: "success" })
.andWhere( .andWhere(
`COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`, `COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`,
@@ -765,6 +803,7 @@ export class OverviewRepository {
freightType: string; freightType: string;
amountEtb: string; amountEtb: string;
amountUsd: string; amountUsd: string;
amountDjf: string;
}>(); }>();
return rows.map((row) => ({ return rows.map((row) => ({
@@ -772,6 +811,7 @@ export class OverviewRepository {
freightType: row.freightType, freightType: row.freightType,
amountEtb: Number(row.amountEtb), amountEtb: Number(row.amountEtb),
amountUsd: Number(row.amountUsd), amountUsd: Number(row.amountUsd),
amountDjf: Number(row.amountDjf),
})); }));
} }