feat(sprint-3): Phase 4 — report controller + PDF/CSV generation

- Add report data models (MonthlyReport, MemberListReport, RecallReport)
- Implement ReportService with monthly aggregation, member list, recall batch tracing
- Add PdfReportGenerator using OpenPDF with minimal club branding
- Add PdfFooterHandler for timestamp + page numbers on every page
- Add CsvReportGenerator with UTF-8 BOM for Excel compatibility
- Create ReportController with 3 endpoints (monthly, members, recall)
  supporting JSON/PDF/CSV format negotiation via ?format= param
- Add DTO records (MonthlyReportResponse, MemberListResponse, RecallReportResponse)
- Extend DistributionRepository + MemberRepository with report queries
- Update Commons CSV from 1.11.0 to 1.12.0
- 10 unit tests (ReportServiceTest: 6, PdfReportGeneratorTest: 4) all passing

Endpoints:
  GET /api/v1/reports/monthly?month=YYYY-MM&format=json|pdf|csv
  GET /api/v1/reports/members?format=json|pdf|csv&status=ACTIVE
  GET /api/v1/reports/recall/{batchId}?format=json|pdf
This commit is contained in:
Patrick Plate
2026-06-12 09:38:57 +02:00
parent a267a90542
commit 64927a3244
16 changed files with 1497 additions and 1 deletions
@@ -0,0 +1,118 @@
package de.cannamanage.service;
import de.cannamanage.domain.entity.Club;
import de.cannamanage.service.model.report.MemberListReport;
import de.cannamanage.service.model.report.MonthlyReport;
import de.cannamanage.service.model.report.RecallReport;
import de.cannamanage.domain.enums.MemberStatus;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.time.YearMonth;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
class PdfReportGeneratorTest {
private PdfReportGenerator generator;
private Club club;
@BeforeEach
void setUp() {
generator = new PdfReportGenerator();
club = new Club();
club.setName("Grüne Freunde e.V.");
}
@Test
void testRenderMonthlyReport_validPdf() {
MonthlyReport report = new MonthlyReport();
report.setMonth(YearMonth.of(2026, 3));
report.setTotalDistributions(42);
report.setTotalGrams(new BigDecimal("210.50"));
report.setUniqueMembers(15);
report.setAveragePerMember(new BigDecimal("14.03"));
report.setTopStrains(List.of(
new MonthlyReport.StrainSummary("White Widow", new BigDecimal("80.00"), 18),
new MonthlyReport.StrainSummary("Amnesia Haze", new BigDecimal("60.00"), 12)
));
report.setDailyBreakdown(List.of(
new MonthlyReport.DailyEntry(LocalDate.of(2026, 3, 1), new BigDecimal("15.00"), 3),
new MonthlyReport.DailyEntry(LocalDate.of(2026, 3, 2), new BigDecimal("8.50"), 2)
));
byte[] pdf = generator.renderMonthlyReport(report, club);
assertThat(pdf).isNotEmpty();
assertThat(pdf).startsWith("%PDF".getBytes());
}
@Test
void testRenderMemberList_validPdf() {
MemberListReport report = new MemberListReport();
report.setGeneratedAt(Instant.now());
MemberListReport.MemberEntry entry = new MemberListReport.MemberEntry();
entry.setId(UUID.randomUUID());
entry.setFirstName("Max");
entry.setLastName("Mustermann");
entry.setMembershipNumber("M-001");
entry.setStatus(MemberStatus.ACTIVE);
entry.setJoinDate(LocalDate.of(2025, 6, 1));
entry.setTotalDistributions(12);
entry.setLastDistributionDate(Instant.parse("2026-03-15T10:00:00Z"));
report.setMembers(List.of(entry));
byte[] pdf = generator.renderMemberList(report, club);
assertThat(pdf).isNotEmpty();
assertThat(pdf).startsWith("%PDF".getBytes());
}
@Test
void testRenderRecallReport_validPdf() {
RecallReport report = new RecallReport();
report.setBatchId(UUID.randomUUID());
report.setStrainName("Northern Lights");
report.setBatchNumber("BATCH-2026-007");
report.setReceivedDate(LocalDate.of(2026, 2, 20));
report.setTotalGramsDistributed(new BigDecimal("45.00"));
RecallReport.AffectedMember am = new RecallReport.AffectedMember();
am.setMemberId(UUID.randomUUID());
am.setFirstName("Anna");
am.setLastName("Schmidt");
am.setDistributionDate(Instant.parse("2026-03-05T12:00:00Z"));
am.setGrams(new BigDecimal("5.00"));
report.setAffectedMembers(List.of(am));
byte[] pdf = generator.renderRecallReport(report, club);
assertThat(pdf).isNotEmpty();
assertThat(pdf).startsWith("%PDF".getBytes());
}
@Test
void testRenderMonthlyReport_emptyReport() {
MonthlyReport report = new MonthlyReport();
report.setMonth(YearMonth.of(2026, 1));
report.setTotalDistributions(0);
report.setTotalGrams(BigDecimal.ZERO);
report.setUniqueMembers(0);
report.setAveragePerMember(BigDecimal.ZERO);
report.setTopStrains(List.of());
report.setDailyBreakdown(List.of());
byte[] pdf = generator.renderMonthlyReport(report, club);
assertThat(pdf).isNotEmpty();
assertThat(pdf).startsWith("%PDF".getBytes());
}
}
@@ -0,0 +1,223 @@
package de.cannamanage.service;
import de.cannamanage.domain.entity.Batch;
import de.cannamanage.domain.entity.Distribution;
import de.cannamanage.domain.entity.Member;
import de.cannamanage.domain.entity.Strain;
import de.cannamanage.domain.enums.BatchStatus;
import de.cannamanage.domain.enums.MemberStatus;
import de.cannamanage.service.model.report.MemberListReport;
import de.cannamanage.service.model.report.MonthlyReport;
import de.cannamanage.service.model.report.RecallReport;
import de.cannamanage.service.repository.BatchRepository;
import de.cannamanage.service.repository.DistributionRepository;
import de.cannamanage.service.repository.MemberRepository;
import de.cannamanage.service.repository.StrainRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.time.YearMonth;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class ReportServiceTest {
@Mock
private DistributionRepository distributionRepository;
@Mock
private MemberRepository memberRepository;
@Mock
private BatchRepository batchRepository;
@Mock
private StrainRepository strainRepository;
@InjectMocks
private ReportService reportService;
private static final UUID TENANT_ID = UUID.randomUUID();
private static final UUID MEMBER_1 = UUID.randomUUID();
private static final UUID MEMBER_2 = UUID.randomUUID();
private static final UUID BATCH_ID = UUID.randomUUID();
private static final UUID STRAIN_ID = UUID.randomUUID();
@Test
void testGenerateMonthlyReport_withDistributions() {
YearMonth month = YearMonth.of(2026, 3);
Distribution d1 = createDistribution(MEMBER_1, BATCH_ID, new BigDecimal("3.50"),
Instant.parse("2026-03-10T14:00:00Z"));
Distribution d2 = createDistribution(MEMBER_2, BATCH_ID, new BigDecimal("5.00"),
Instant.parse("2026-03-15T10:00:00Z"));
Distribution d3 = createDistribution(MEMBER_1, BATCH_ID, new BigDecimal("2.50"),
Instant.parse("2026-03-15T16:00:00Z"));
when(distributionRepository.findByTenantIdAndDistributedAtBetween(eq(TENANT_ID), any(), any()))
.thenReturn(List.of(d1, d2, d3));
Batch batch = createBatch(BATCH_ID, STRAIN_ID, "BATCH-001");
when(batchRepository.findById(BATCH_ID)).thenReturn(Optional.of(batch));
Strain strain = new Strain();
strain.setId(STRAIN_ID);
strain.setName("White Widow");
when(strainRepository.findById(STRAIN_ID)).thenReturn(Optional.of(strain));
MonthlyReport report = reportService.generateMonthlyReport(TENANT_ID, month);
assertThat(report.getMonth()).isEqualTo(month);
assertThat(report.getTotalDistributions()).isEqualTo(3);
assertThat(report.getTotalGrams()).isEqualByComparingTo("11.00");
assertThat(report.getUniqueMembers()).isEqualTo(2);
assertThat(report.getAveragePerMember()).isEqualByComparingTo("5.50");
assertThat(report.getTopStrains()).hasSize(1);
assertThat(report.getTopStrains().get(0).getName()).isEqualTo("White Widow");
assertThat(report.getDailyBreakdown()).hasSize(31); // March has 31 days
}
@Test
void testGenerateMonthlyReport_emptyMonth() {
YearMonth month = YearMonth.of(2026, 1);
when(distributionRepository.findByTenantIdAndDistributedAtBetween(eq(TENANT_ID), any(), any()))
.thenReturn(List.of());
MonthlyReport report = reportService.generateMonthlyReport(TENANT_ID, month);
assertThat(report.getTotalDistributions()).isZero();
assertThat(report.getTotalGrams()).isEqualByComparingTo("0");
assertThat(report.getUniqueMembers()).isZero();
assertThat(report.getAveragePerMember()).isEqualByComparingTo("0");
assertThat(report.getTopStrains()).isEmpty();
assertThat(report.getDailyBreakdown()).hasSize(31);
}
@Test
void testGenerateMemberListReport_allMembers() {
Member m1 = createMember(MEMBER_1, "Max", "Mustermann", "M-001", MemberStatus.ACTIVE);
Member m2 = createMember(MEMBER_2, "Anna", "Muster", "M-002", MemberStatus.SUSPENDED);
when(memberRepository.findByTenantId(TENANT_ID)).thenReturn(List.of(m1, m2));
when(distributionRepository.countByTenantIdAndMemberId(TENANT_ID, MEMBER_1)).thenReturn(5L);
when(distributionRepository.countByTenantIdAndMemberId(TENANT_ID, MEMBER_2)).thenReturn(0L);
when(distributionRepository.findLatestByTenantIdAndMemberId(TENANT_ID, MEMBER_1)).thenReturn(null);
when(distributionRepository.findLatestByTenantIdAndMemberId(TENANT_ID, MEMBER_2)).thenReturn(null);
MemberListReport report = reportService.generateMemberListReport(TENANT_ID, null);
assertThat(report.getGeneratedAt()).isNotNull();
assertThat(report.getMembers()).hasSize(2);
assertThat(report.getMembers().get(0).getFirstName()).isEqualTo("Max");
assertThat(report.getMembers().get(0).getTotalDistributions()).isEqualTo(5);
assertThat(report.getMembers().get(1).getStatus()).isEqualTo(MemberStatus.SUSPENDED);
}
@Test
void testGenerateMemberListReport_filteredByStatus() {
Member m1 = createMember(MEMBER_1, "Max", "Mustermann", "M-001", MemberStatus.ACTIVE);
when(memberRepository.findByTenantIdAndStatus(TENANT_ID, MemberStatus.ACTIVE))
.thenReturn(List.of(m1));
when(distributionRepository.countByTenantIdAndMemberId(TENANT_ID, MEMBER_1)).thenReturn(3L);
when(distributionRepository.findLatestByTenantIdAndMemberId(TENANT_ID, MEMBER_1)).thenReturn(null);
MemberListReport report = reportService.generateMemberListReport(TENANT_ID, MemberStatus.ACTIVE);
assertThat(report.getMembers()).hasSize(1);
assertThat(report.getMembers().get(0).getStatus()).isEqualTo(MemberStatus.ACTIVE);
}
@Test
void testGenerateRecallReport_success() {
Batch batch = createBatch(BATCH_ID, STRAIN_ID, "BATCH-RECALL-01");
batch.setHarvestDate(LocalDate.of(2026, 2, 15));
Strain strain = new Strain();
strain.setId(STRAIN_ID);
strain.setName("Amnesia Haze");
Distribution d1 = createDistribution(MEMBER_1, BATCH_ID, new BigDecimal("5.00"),
Instant.parse("2026-03-01T10:00:00Z"));
Distribution d2 = createDistribution(MEMBER_2, BATCH_ID, new BigDecimal("3.00"),
Instant.parse("2026-03-02T14:00:00Z"));
when(batchRepository.findById(BATCH_ID)).thenReturn(Optional.of(batch));
when(strainRepository.findById(STRAIN_ID)).thenReturn(Optional.of(strain));
when(distributionRepository.findByTenantIdAndBatchId(TENANT_ID, BATCH_ID))
.thenReturn(List.of(d1, d2));
Member member1 = createMember(MEMBER_1, "Max", "Mustermann", "M-001", MemberStatus.ACTIVE);
Member member2 = createMember(MEMBER_2, "Anna", "Muster", "M-002", MemberStatus.ACTIVE);
when(memberRepository.findById(MEMBER_1)).thenReturn(Optional.of(member1));
when(memberRepository.findById(MEMBER_2)).thenReturn(Optional.of(member2));
RecallReport report = reportService.generateRecallReport(TENANT_ID, BATCH_ID);
assertThat(report.getBatchId()).isEqualTo(BATCH_ID);
assertThat(report.getStrainName()).isEqualTo("Amnesia Haze");
assertThat(report.getBatchNumber()).isEqualTo("BATCH-RECALL-01");
assertThat(report.getReceivedDate()).isEqualTo(LocalDate.of(2026, 2, 15));
assertThat(report.getTotalGramsDistributed()).isEqualByComparingTo("8.00");
assertThat(report.getAffectedMembers()).hasSize(2);
assertThat(report.getAffectedMembers().get(0).getFirstName()).isEqualTo("Max");
assertThat(report.getAffectedMembers().get(1).getGrams()).isEqualByComparingTo("3.00");
}
@Test
void testGenerateRecallReport_batchNotFound() {
when(batchRepository.findById(BATCH_ID)).thenReturn(Optional.empty());
assertThatThrownBy(() -> reportService.generateRecallReport(TENANT_ID, BATCH_ID))
.isInstanceOf(de.cannamanage.service.exception.BatchNotFoundException.class);
}
// --- Helper methods ---
private Distribution createDistribution(UUID memberId, UUID batchId, BigDecimal grams, Instant at) {
Distribution d = new Distribution();
d.setId(UUID.randomUUID());
d.setTenantId(TENANT_ID);
d.setMemberId(memberId);
d.setBatchId(batchId);
d.setQuantityGrams(grams);
d.setDistributedAt(at);
return d;
}
private Member createMember(UUID id, String first, String last, String number, MemberStatus status) {
Member m = new Member();
m.setId(id);
m.setTenantId(TENANT_ID);
m.setFirstName(first);
m.setLastName(last);
m.setMembershipNumber(number);
m.setStatus(status);
m.setMembershipDate(LocalDate.of(2025, 6, 1));
return m;
}
private Batch createBatch(UUID id, UUID strainId, String code) {
Batch b = new Batch();
b.setId(id);
b.setTenantId(TENANT_ID);
b.setStrainId(strainId);
b.setBatchCode(code);
b.setQuantityGrams(new BigDecimal("50.00"));
b.setStatus(BatchStatus.AVAILABLE);
return b;
}
}