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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user