Merge branch 'worktree-kasse-kpi-charts'
This commit is contained in:
@@ -86,6 +86,16 @@ export class TeamsController {
|
||||
return this.service.getOverview(id);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@Roles([RoleEnum.user, RoleEnum.admin])
|
||||
@UseGuards(AuthGuard('jwt'), RolesGuard)
|
||||
@Get(':id/overview/stats')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
getOverviewStats(@Req() req, @Param('id') id: string) {
|
||||
const userId = req.user?.id;
|
||||
return this.service.getOverviewStats(id, userId);
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: 'Transactionen für ein Team',
|
||||
description:
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Repository } from 'typeorm';
|
||||
import { CreateTeamDTO } from './dto/create-team.dto';
|
||||
import { UpdatePlayerProfileDto } from './dto/update-player-profile.dto';
|
||||
import { Team } from './entities/team.entity';
|
||||
import { TeamAccessService } from './team-access.service';
|
||||
|
||||
@Injectable()
|
||||
export class TeamsService {
|
||||
@@ -28,6 +29,7 @@ export class TeamsService {
|
||||
@InjectRepository(TeamWalletTransaction)
|
||||
private teamWalletTransactionRepository: Repository<TeamWalletTransaction>,
|
||||
private logger: LoggingService,
|
||||
private access: TeamAccessService,
|
||||
) {}
|
||||
|
||||
async getOverview(teamId: string) {
|
||||
@@ -218,6 +220,129 @@ export class TeamsService {
|
||||
return result;
|
||||
}
|
||||
|
||||
async getOverviewStats(
|
||||
teamId: string | number,
|
||||
actorUserId: string | number,
|
||||
): Promise<{
|
||||
balanceHistory: { month: string; balance: number }[];
|
||||
monthlyFlow: { month: string; income: number; expense: number }[];
|
||||
topOutstanding: { playerId: number; playerName: string; balance: number }[];
|
||||
}> {
|
||||
const id = Number(teamId);
|
||||
|
||||
// Read access: any active team member may view the KPI charts (same
|
||||
// audience as the existing :id/overview page), not just managers.
|
||||
await this.access.assertMember(Number(actorUserId), id);
|
||||
|
||||
const team = await this.repository.findOneOrFail({
|
||||
where: { id },
|
||||
relations: ['players', 'players.transactions', 'transactions'],
|
||||
});
|
||||
|
||||
const players = team.players ?? [];
|
||||
const teamTransactions = team.transactions ?? [];
|
||||
|
||||
// Only payment/credit/expense movements represent actual cash flow and
|
||||
// are the only types that touch team.balance (see setBalance() on
|
||||
// TeamWalletTransaction/Transaction) — fine/levy/fee raise a player's
|
||||
// debt but never move money, so they are excluded entirely.
|
||||
const movements: { date: string; amount: number; type: string }[] = [];
|
||||
|
||||
for (const t of teamTransactions) {
|
||||
movements.push({ date: t.date, amount: Number(t.amount), type: t.type.name });
|
||||
}
|
||||
|
||||
for (const p of players) {
|
||||
for (const t of p.transactions ?? []) {
|
||||
if (t.type.name === 'payment') {
|
||||
movements.push({ date: t.date, amount: Number(t.amount), type: t.type.name });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
movements.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0));
|
||||
|
||||
const topOutstanding = players
|
||||
.filter((p) => p.active && Number(p.balance) < 0)
|
||||
.sort((a, b) => Number(a.balance) - Number(b.balance))
|
||||
.slice(0, 10)
|
||||
.map((p) => ({
|
||||
playerId: p.id,
|
||||
playerName: p.firstName + ' ' + p.lastName,
|
||||
balance: this.round(Math.abs(Number(p.balance))),
|
||||
}));
|
||||
|
||||
// A brand-new team with no relevant movements at all (ever, not just in
|
||||
// the last 12 months) has nothing to chart — return empty arrays so the
|
||||
// frontend's empty-state fires instead of rendering 12 flat zero points.
|
||||
if (movements.length === 0) {
|
||||
return { balanceHistory: [], monthlyFlow: [], topOutstanding };
|
||||
}
|
||||
|
||||
const months = this.getLast12Months();
|
||||
|
||||
// team.balance is the one authoritative, current value — it can include
|
||||
// historical adjustments (e.g. from the removed legacy backend) that
|
||||
// don't trace back to the visible payment/credit/expense rows. Forward-
|
||||
// summing the movements from zero would silently drift away from
|
||||
// team.balance for such teams. Instead we anchor to team.balance and
|
||||
// walk the movements backward (newest first), "undoing" each one to
|
||||
// reconstruct earlier month-end balances — this guarantees the most
|
||||
// recent point always equals team.balance by construction, regardless
|
||||
// of undocumented history.
|
||||
const descendingMovements = [...movements].sort((a, b) =>
|
||||
a.date > b.date ? -1 : a.date < b.date ? 1 : 0,
|
||||
);
|
||||
|
||||
const currentBalance = Number(team.balance);
|
||||
let futureSum = 0;
|
||||
let movementIndex = 0;
|
||||
const balanceHistory = [...months]
|
||||
.reverse()
|
||||
.map((month) => {
|
||||
while (
|
||||
movementIndex < descendingMovements.length &&
|
||||
descendingMovements[movementIndex].date.slice(0, 7) > month
|
||||
) {
|
||||
futureSum += this.signedFlowAmount(descendingMovements[movementIndex]);
|
||||
movementIndex++;
|
||||
}
|
||||
return { month, balance: this.round(currentBalance - futureSum) };
|
||||
})
|
||||
.reverse();
|
||||
|
||||
const monthlyFlow = months.map((month) => {
|
||||
const monthMovements = movements.filter((m) => m.date.slice(0, 7) === month);
|
||||
const income = monthMovements
|
||||
.filter((m) => m.type === 'payment' || m.type === 'credit')
|
||||
.reduce((sum, m) => sum + m.amount, 0);
|
||||
const expense = monthMovements
|
||||
.filter((m) => m.type === 'expense')
|
||||
.reduce((sum, m) => sum + m.amount, 0);
|
||||
return { month, income: this.round(income), expense: this.round(expense) };
|
||||
});
|
||||
|
||||
return { balanceHistory, monthlyFlow, topOutstanding };
|
||||
}
|
||||
|
||||
private getLast12Months(): string[] {
|
||||
const now = new Date();
|
||||
const months: string[] = [];
|
||||
for (let i = 11; i >= 0; i--) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
months.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`);
|
||||
}
|
||||
return months;
|
||||
}
|
||||
|
||||
private signedFlowAmount(movement: { amount: number; type: string }): number {
|
||||
return movement.type === 'expense' ? -movement.amount : movement.amount;
|
||||
}
|
||||
|
||||
private round(value: number): number {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
async updatePlayer(playerDTO: UpdatePlayerProfileDto) {
|
||||
const player = await this.playerRepository.findOneOrFail({
|
||||
where: {
|
||||
|
||||
Reference in New Issue
Block a user