diff --git a/ApiCore-Backend/src/app.module.ts b/ApiCore-Backend/src/app.module.ts index cb10733..e86a0b0 100644 --- a/ApiCore-Backend/src/app.module.ts +++ b/ApiCore-Backend/src/app.module.ts @@ -27,6 +27,7 @@ import { BottlingModule } from './bottling/bottling.module'; import { EquipmentModule } from './equipment/equipment.module'; import { CalendarModule } from './calendar/calendar.module'; import { NotificationsModule } from './notifications/notifications.module'; +import { StatisticsModule } from './statistics/statistics.module'; import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; import { RolesGuard } from './auth/guards/roles.guard'; @@ -59,6 +60,7 @@ import { RolesGuard } from './auth/guards/roles.guard'; EquipmentModule, CalendarModule, NotificationsModule, + StatisticsModule, ], controllers: [], providers: [ diff --git a/ApiCore-Backend/src/statistics/statistics.controller.ts b/ApiCore-Backend/src/statistics/statistics.controller.ts new file mode 100644 index 0000000..d9ee809 --- /dev/null +++ b/ApiCore-Backend/src/statistics/statistics.controller.ts @@ -0,0 +1,40 @@ +import { Controller, Get } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { StatisticsService } from './statistics.service'; + +@ApiTags('statistics') +@ApiBearerAuth() +@Controller('statistics') +export class StatisticsController { + constructor(private readonly statisticsService: StatisticsService) {} + + @Get('colonies') + colonies() { + return this.statisticsService.colonies(); + } + + @Get('queens') + queens() { + return this.statisticsService.queens(); + } + + @Get('apiaries') + apiaries() { + return this.statisticsService.apiaries(); + } + + @Get('varroa') + varroa() { + return this.statisticsService.varroa(); + } + + @Get('honey') + honey() { + return this.statisticsService.honey(); + } + + @Get('feeding') + feeding() { + return this.statisticsService.feeding(); + } +} diff --git a/ApiCore-Backend/src/statistics/statistics.module.ts b/ApiCore-Backend/src/statistics/statistics.module.ts new file mode 100644 index 0000000..14b3199 --- /dev/null +++ b/ApiCore-Backend/src/statistics/statistics.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { StatisticsController } from './statistics.controller'; +import { StatisticsService } from './statistics.service'; + +@Module({ + controllers: [StatisticsController], + providers: [StatisticsService], +}) +export class StatisticsModule {} diff --git a/ApiCore-Backend/src/statistics/statistics.service.spec.ts b/ApiCore-Backend/src/statistics/statistics.service.spec.ts new file mode 100644 index 0000000..7a0f827 --- /dev/null +++ b/ApiCore-Backend/src/statistics/statistics.service.spec.ts @@ -0,0 +1,216 @@ +import { StatisticsService } from './statistics.service'; +import { PrismaService } from '../prisma/prisma.service'; + +describe('StatisticsService', () => { + let service: StatisticsService; + let prisma: { + colony: { groupBy: jest.Mock; findMany: jest.Mock }; + inspection: { findMany: jest.Mock }; + queen: { findMany: jest.Mock }; + queenEvaluation: { findMany: jest.Mock }; + apiary: { findMany: jest.Mock }; + varroaMeasurement: { findMany: jest.Mock }; + treatment: { groupBy: jest.Mock }; + harvest: { findMany: jest.Mock }; + honeyBatch: { findMany: jest.Mock }; + feeding: { findMany: jest.Mock }; + }; + + beforeEach(() => { + prisma = { + colony: { + groupBy: jest.fn().mockResolvedValue([]), + findMany: jest.fn().mockResolvedValue([]), + }, + inspection: { findMany: jest.fn().mockResolvedValue([]) }, + queen: { findMany: jest.fn().mockResolvedValue([]) }, + queenEvaluation: { findMany: jest.fn().mockResolvedValue([]) }, + apiary: { findMany: jest.fn().mockResolvedValue([]) }, + varroaMeasurement: { findMany: jest.fn().mockResolvedValue([]) }, + treatment: { groupBy: jest.fn().mockResolvedValue([]) }, + harvest: { findMany: jest.fn().mockResolvedValue([]) }, + honeyBatch: { findMany: jest.fn().mockResolvedValue([]) }, + feeding: { findMany: jest.fn().mockResolvedValue([]) }, + }; + service = new StatisticsService(prisma as unknown as PrismaService); + }); + + describe('colonies', () => { + it('groups losses by year using deactivatedAt, falling back to updatedAt', async () => { + prisma.colony.groupBy.mockResolvedValueOnce([ + { status: 'ACTIVE', _count: { _all: 5 } }, + ]); + prisma.colony.findMany.mockResolvedValueOnce([ + { + deactivatedAt: new Date('2025-09-01'), + updatedAt: new Date('2025-09-01'), + }, + { deactivatedAt: null, updatedAt: new Date('2024-03-01') }, + ]); + + const result = await service.colonies(); + + expect(result.byStatus).toEqual([{ status: 'ACTIVE', count: 5 }]); + expect(result.lossesByYear).toEqual({ '2025': 1, '2024': 1 }); + }); + }); + + describe('queens', () => { + it('averages evaluation dimensions per queen and sorts by honey yield descending', async () => { + prisma.queen.findMany.mockResolvedValueOnce([ + { + id: 'q1', + code: 'K-001', + status: 'ACTIVE', + createdAt: new Date('2024-01-01'), + deactivatedAt: null, + }, + { + id: 'q2', + code: 'K-002', + status: 'ACTIVE', + createdAt: new Date('2024-01-01'), + deactivatedAt: null, + }, + ]); + prisma.queenEvaluation.findMany.mockResolvedValueOnce([ + { + queenId: 'q1', + colonyStrength: 3, + gentleness: 3, + combBuild: 3, + swarmingTendency: 3, + broodPattern: 3, + honeyYield: 2, + }, + { + queenId: 'q2', + colonyStrength: 5, + gentleness: 5, + combBuild: 5, + swarmingTendency: 5, + broodPattern: 5, + honeyYield: 5, + }, + ]); + + const result = await service.queens(); + + expect(result.evaluationAverages.map((row) => row.queenId)).toEqual([ + 'q2', + 'q1', + ]); + expect(result.evaluationAverages[0].honeyYield).toBe(5); + }); + + it('computes average lifespan only from retired queens', async () => { + prisma.queen.findMany.mockResolvedValueOnce([ + { + id: 'q1', + code: 'K-001', + status: 'RETIRED', + createdAt: new Date('2024-01-01'), + deactivatedAt: new Date('2024-01-11'), + }, + { + id: 'q2', + code: 'K-002', + status: 'ACTIVE', + createdAt: new Date('2024-01-01'), + deactivatedAt: null, + }, + ]); + + const result = await service.queens(); + + expect(result.averageLifespanDays).toBe(10); + }); + }); + + describe('apiaries', () => { + it('computes honey totals and colony counts per apiary', async () => { + prisma.apiary.findMany.mockResolvedValueOnce([ + { + id: 'a1', + name: 'Wabenhain Nord', + colonies: [ + { id: 'c1', status: 'ACTIVE' }, + { id: 'c2', status: 'DECEASED' }, + ], + harvests: [{ netWeight: 10 }, { netWeight: 5 }], + }, + ]); + + const result = await service.apiaries(); + + expect(result).toEqual([ + { + apiaryId: 'a1', + name: 'Wabenhain Nord', + activeColonies: 1, + lostColonies: 1, + totalHoneyKg: 15, + averageHoneyKgPerColony: 7.5, + }, + ]); + }); + }); + + describe('varroa', () => { + it('returns measurements and treatment counts per colony', async () => { + prisma.varroaMeasurement.findMany.mockResolvedValueOnce([ + { + colonyId: 'c1', + date: new Date('2025-07-01'), + miteCount: 4, + method: 'PUDERZUCKER', + }, + ]); + prisma.treatment.groupBy.mockResolvedValueOnce([ + { colonyId: 'c1', _count: { _all: 2 } }, + ]); + + const result = await service.varroa(); + + expect(result.measurements).toHaveLength(1); + expect(result.treatmentCountByColony).toEqual([ + { colonyId: 'c1', treatmentCount: 2 }, + ]); + }); + }); + + describe('honey', () => { + it('sums yield per year and per apiary', async () => { + prisma.harvest.findMany.mockResolvedValueOnce([ + { apiaryId: 'a1', date: new Date('2025-06-01'), netWeight: 10 }, + { apiaryId: 'a1', date: new Date('2024-06-01'), netWeight: 6 }, + { apiaryId: 'a2', date: new Date('2025-06-01'), netWeight: 4 }, + ]); + prisma.honeyBatch.findMany.mockResolvedValueOnce([ + { amount: 8, createdAt: new Date() }, + ]); + + const result = await service.honey(); + + expect(result.totalHarvestKg).toBe(20); + expect(result.totalHoneyBatchKg).toBe(8); + expect(result.yieldPerYear).toEqual({ '2025': 14, '2024': 6 }); + expect(result.yieldPerApiary).toEqual({ a1: 16, a2: 4 }); + }); + }); + + describe('feeding', () => { + it('sums feeding amount per colony and per season', async () => { + prisma.feeding.findMany.mockResolvedValueOnce([ + { colonyId: 'c1', date: new Date('2025-09-01'), amount: 5, unit: 'l' }, + { colonyId: 'c1', date: new Date('2025-09-15'), amount: 3, unit: 'l' }, + { colonyId: 'c2', date: new Date('2024-09-01'), amount: 2, unit: 'l' }, + ]); + + const result = await service.feeding(); + + expect(result.byColony).toEqual({ c1: 8, c2: 2 }); + expect(result.bySeason).toEqual({ '2025': 8, '2024': 2 }); + }); + }); +}); diff --git a/ApiCore-Backend/src/statistics/statistics.service.ts b/ApiCore-Backend/src/statistics/statistics.service.ts new file mode 100644 index 0000000..26d8301 --- /dev/null +++ b/ApiCore-Backend/src/statistics/statistics.service.ts @@ -0,0 +1,220 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; + +@Injectable() +export class StatisticsService { + constructor(private readonly prisma: PrismaService) {} + + async colonies() { + const [byStatus, strengthOverTime, losses] = await Promise.all([ + this.prisma.colony.groupBy({ by: ['status'], _count: { _all: true } }), + this.prisma.inspection.findMany({ + where: { colonyStrength: { not: null } }, + select: { colonyId: true, date: true, colonyStrength: true }, + orderBy: { date: 'asc' }, + }), + this.prisma.colony.findMany({ + where: { status: { in: ['DECEASED', 'DISSOLVED'] } }, + select: { deactivatedAt: true, updatedAt: true }, + }), + ]); + + const lossesByYear: Record = {}; + for (const loss of losses) { + const year = (loss.deactivatedAt ?? loss.updatedAt) + .getFullYear() + .toString(); + lossesByYear[year] = (lossesByYear[year] ?? 0) + 1; + } + + return { + byStatus: byStatus.map((row) => ({ + status: row.status, + count: row._count._all, + })), + strengthOverTime, + lossesByYear, + }; + } + + async queens() { + const [queens, evaluations] = await Promise.all([ + this.prisma.queen.findMany({ + select: { + id: true, + code: true, + status: true, + createdAt: true, + deactivatedAt: true, + }, + }), + this.prisma.queenEvaluation.findMany({ + select: { + queenId: true, + colonyStrength: true, + gentleness: true, + combBuild: true, + swarmingTendency: true, + broodPattern: true, + honeyYield: true, + }, + }), + ]); + + const retiredQueens = queens.filter( + (queen) => queen.deactivatedAt !== null, + ); + const averageLifespanDays = + retiredQueens.length > 0 + ? retiredQueens.reduce((sum, queen) => { + const days = + (queen.deactivatedAt!.getTime() - queen.createdAt.getTime()) / + (1000 * 60 * 60 * 24); + return sum + days; + }, 0) / retiredQueens.length + : null; + + const byQueen = new Map(); + for (const evaluation of evaluations) { + const bucket = byQueen.get(evaluation.queenId) ?? []; + bucket.push(evaluation); + byQueen.set(evaluation.queenId, bucket); + } + + function average(values: number[]): number { + return values.reduce((sum, value) => sum + value, 0) / values.length; + } + + const evaluationAverages = queens + .filter((queen) => byQueen.has(queen.id)) + .map((queen) => { + const rows = byQueen.get(queen.id)!; + return { + queenId: queen.id, + code: queen.code, + evaluationCount: rows.length, + colonyStrength: average(rows.map((row) => row.colonyStrength)), + gentleness: average(rows.map((row) => row.gentleness)), + combBuild: average(rows.map((row) => row.combBuild)), + swarmingTendency: average(rows.map((row) => row.swarmingTendency)), + broodPattern: average(rows.map((row) => row.broodPattern)), + honeyYield: average(rows.map((row) => row.honeyYield)), + }; + }) + .sort((a, b) => b.honeyYield - a.honeyYield); + + return { + totalActive: queens.filter((queen) => queen.status === 'ACTIVE').length, + averageLifespanDays, + evaluationAverages, + }; + } + + async apiaries() { + const apiaries = await this.prisma.apiary.findMany({ + select: { + id: true, + name: true, + colonies: { select: { id: true, status: true } }, + harvests: { select: { netWeight: true } }, + }, + }); + + return apiaries.map((apiary) => { + const totalHoneyKg = apiary.harvests.reduce( + (sum, harvest) => sum + (harvest.netWeight ?? 0), + 0, + ); + const activeColonies = apiary.colonies.filter( + (c) => c.status === 'ACTIVE', + ).length; + const lostColonies = apiary.colonies.filter((c) => + ['DECEASED', 'DISSOLVED'].includes(c.status), + ).length; + return { + apiaryId: apiary.id, + name: apiary.name, + activeColonies, + lostColonies, + totalHoneyKg, + averageHoneyKgPerColony: + apiary.colonies.length > 0 + ? totalHoneyKg / apiary.colonies.length + : 0, + }; + }); + } + + async varroa() { + const measurements = await this.prisma.varroaMeasurement.findMany({ + where: { miteCount: { not: null } }, + select: { colonyId: true, date: true, miteCount: true, method: true }, + orderBy: { date: 'asc' }, + }); + const treatmentsByColony = await this.prisma.treatment.groupBy({ + by: ['colonyId'], + _count: { _all: true }, + }); + + return { + measurements, + treatmentCountByColony: treatmentsByColony.map((row) => ({ + colonyId: row.colonyId, + treatmentCount: row._count._all, + })), + }; + } + + async honey() { + const [harvests, honeyBatches] = await Promise.all([ + this.prisma.harvest.findMany({ + select: { apiaryId: true, date: true, netWeight: true }, + }), + this.prisma.honeyBatch.findMany({ + select: { amount: true, createdAt: true }, + }), + ]); + + const totalHarvestKg = harvests.reduce( + (sum, h) => sum + (h.netWeight ?? 0), + 0, + ); + const totalHoneyBatchKg = honeyBatches.reduce( + (sum, b) => sum + (b.amount ?? 0), + 0, + ); + + const yieldPerYear: Record = {}; + const yieldPerApiary: Record = {}; + for (const harvest of harvests) { + const year = harvest.date.getFullYear().toString(); + yieldPerYear[year] = (yieldPerYear[year] ?? 0) + (harvest.netWeight ?? 0); + yieldPerApiary[harvest.apiaryId] = + (yieldPerApiary[harvest.apiaryId] ?? 0) + (harvest.netWeight ?? 0); + } + + return { + totalHarvestKg, + totalHoneyBatchKg, + yieldPerYear, + yieldPerApiary, + }; + } + + async feeding() { + const feedings = await this.prisma.feeding.findMany({ + select: { colonyId: true, date: true, amount: true, unit: true }, + }); + + const byColony: Record = {}; + const bySeason: Record = {}; + for (const feeding of feedings) { + byColony[feeding.colonyId] = + (byColony[feeding.colonyId] ?? 0) + feeding.amount; + const year = feeding.date.getFullYear().toString(); + bySeason[year] = (bySeason[year] ?? 0) + feeding.amount; + } + + return { byColony, bySeason }; + } +} diff --git a/ApiCore-Backend/test/statistics.e2e-spec.ts b/ApiCore-Backend/test/statistics.e2e-spec.ts new file mode 100644 index 0000000..133e417 --- /dev/null +++ b/ApiCore-Backend/test/statistics.e2e-spec.ts @@ -0,0 +1,219 @@ +import { INestApplication } from '@nestjs/common'; +import * as bcrypt from 'bcryptjs'; +import request from 'supertest'; +import { App } from 'supertest/types'; +import { PrismaService } from '../src/prisma/prisma.service'; +import { createTestApp } from './utils/create-test-app'; +import { login } from './utils/api'; + +interface ColonyStatsBody { + byStatus: { status: string; count: number }[]; + lossesByYear: Record; +} + +interface QueenStatsBody { + totalActive: number; + evaluationAverages: { queenId: string; honeyYield: number }[]; +} + +interface ApiaryStatsBody { + apiaryId: string; + totalHoneyKg: number; +} + +interface VarroaStatsBody { + measurements: { colonyId: string; miteCount: number }[]; +} + +interface HoneyStatsBody { + totalHarvestKg: number; + yieldPerApiary: Record; +} + +interface FeedingStatsBody { + byColony: Record; +} + +describe('Statistics (e2e)', () => { + let app: INestApplication; + let prisma: PrismaService; + const beekeeperEmail = `statistics-e2e-beekeeper-${Date.now()}@example.com`; + const password = 'SuperSecret123!'; + let beekeeperId: string; + let beekeeperToken: string; + let apiaryId: string; + let colonyId: string; + let queenId: string; + let evaluationId: string; + let varroaId: string; + let treatmentId: string; + let feedingId: string; + let harvestId: string; + + beforeAll(async () => { + app = await createTestApp(); + prisma = app.get(PrismaService); + + const passwordHash = await bcrypt.hash(password, 12); + const beekeeper = await prisma.user.create({ + data: { + email: beekeeperEmail, + passwordHash, + name: 'Beekeeper', + role: 'BEEKEEPER', + }, + }); + beekeeperId = beekeeper.id; + beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken; + + const apiary = await prisma.apiary.create({ + data: { name: `Statistik-Stand ${Date.now()}` }, + }); + apiaryId = apiary.id; + const colony = await prisma.colony.create({ + data: { + code: `V-STAT-${Date.now()}`, + apiaryId, + colonyType: 'PRODUCTION_COLONY', + }, + }); + colonyId = colony.id; + const queen = await prisma.queen.create({ + data: { code: `K-STAT-${Date.now()}` }, + }); + queenId = queen.id; + const evaluation = await prisma.queenEvaluation.create({ + data: { + queenId, + colonyId, + date: new Date('2026-05-01'), + colonyStrength: 4, + gentleness: 4, + combBuild: 4, + swarmingTendency: 2, + broodPattern: 4, + honeyYield: 5, + userId: beekeeperId, + }, + }); + evaluationId = evaluation.id; + const varroa = await prisma.varroaMeasurement.create({ + data: { + colonyId, + userId: beekeeperId, + date: new Date('2026-06-01'), + method: 'POWDERED_SUGAR', + miteCount: 3, + }, + }); + varroaId = varroa.id; + const treatment = await prisma.treatment.create({ + data: { + colonyId, + userId: beekeeperId, + date: new Date('2026-06-05'), + product: 'Ameisensaeure', + }, + }); + treatmentId = treatment.id; + const feeding = await prisma.feeding.create({ + data: { + colonyId, + userId: beekeeperId, + date: new Date('2026-09-01'), + feedType: 'SUGAR_WATER', + amount: 7, + unit: 'l', + }, + }); + feedingId = feeding.id; + const harvest = await prisma.harvest.create({ + data: { + apiaryId, + userId: beekeeperId, + date: new Date('2026-07-01'), + netWeight: 12, + }, + }); + harvestId = harvest.id; + }); + + afterAll(async () => { + await prisma.harvest.deleteMany({ where: { id: harvestId } }); + await prisma.feeding.deleteMany({ where: { id: feedingId } }); + await prisma.treatment.deleteMany({ where: { id: treatmentId } }); + await prisma.varroaMeasurement.deleteMany({ where: { id: varroaId } }); + await prisma.queenEvaluation.deleteMany({ where: { id: evaluationId } }); + await prisma.queen.deleteMany({ where: { id: queenId } }); + await prisma.colony.deleteMany({ where: { id: colonyId } }); + await prisma.apiary.deleteMany({ where: { id: apiaryId } }); + await prisma.refreshToken.deleteMany({ where: { userId: beekeeperId } }); + await prisma.auditLog.deleteMany({ where: { userId: beekeeperId } }); + await prisma.user.deleteMany({ where: { id: beekeeperId } }); + await app.close(); + }); + + it('returns colony status breakdown', async () => { + const response = await request(app.getHttpServer()) + .get('/api/v1/statistics/colonies') + .set('Authorization', `Bearer ${beekeeperToken}`) + .expect(200); + const body = response.body as ColonyStatsBody; + expect( + body.byStatus.some((row) => row.status === 'ACTIVE' && row.count >= 1), + ).toBe(true); + }); + + it('returns queen evaluation averages including the fixture queen', async () => { + const response = await request(app.getHttpServer()) + .get('/api/v1/statistics/queens') + .set('Authorization', `Bearer ${beekeeperToken}`) + .expect(200); + const body = response.body as QueenStatsBody; + const row = body.evaluationAverages.find( + (entry) => entry.queenId === queenId, + ); + expect(row?.honeyYield).toBe(5); + }); + + it('returns per-apiary honey totals including the fixture harvest', async () => { + const response = await request(app.getHttpServer()) + .get('/api/v1/statistics/apiaries') + .set('Authorization', `Bearer ${beekeeperToken}`) + .expect(200); + const body = response.body as ApiaryStatsBody[]; + const row = body.find((entry) => entry.apiaryId === apiaryId); + expect(row?.totalHoneyKg).toBe(12); + }); + + it('returns varroa measurements including the fixture value', async () => { + const response = await request(app.getHttpServer()) + .get('/api/v1/statistics/varroa') + .set('Authorization', `Bearer ${beekeeperToken}`) + .expect(200); + const body = response.body as VarroaStatsBody; + expect( + body.measurements.some( + (entry) => entry.colonyId === colonyId && entry.miteCount === 3, + ), + ).toBe(true); + }); + + it('returns honey yield aggregates including the fixture apiary', async () => { + const response = await request(app.getHttpServer()) + .get('/api/v1/statistics/honey') + .set('Authorization', `Bearer ${beekeeperToken}`) + .expect(200); + const body = response.body as HoneyStatsBody; + expect(body.yieldPerApiary[apiaryId]).toBe(12); + }); + + it('returns feeding totals per colony including the fixture value', async () => { + const response = await request(app.getHttpServer()) + .get('/api/v1/statistics/feeding') + .set('Authorization', `Bearer ${beekeeperToken}`) + .expect(200); + const body = response.body as FeedingStatsBody; + expect(body.byColony[colonyId]).toBe(7); + }); +});