Skip to content

Commit 33f66ca

Browse files
gpascucciclaude
andcommitted
feat(schedule2): Schedule 2 print section (Story 20.6)
Adds the Schedule 2 (Purchased and Private Log Costs and Sales) section to the combined Print Schedules PDF, following the 20.2 SectionMapper + bean-datasource pattern. Schedule 2 is a SINGLE aggregate document. - ScheduleKey.SCHEDULE_2 declared FIRST (prints before Schedule 5 in the fixed legacy order, BR-08) + reports/schedule2.jrxml (portrait; seven fixed CostBlock rows — Volume m³ / Cost $ / $-per-m³ — with grouping separators and body/comment gating) - Schedule2SectionMapper: maps Schedule2Response to ONE section row; derived figures (subtotal / net / totals / perUnit, carried Sch1/3 terms) reused verbatim, format-only (whole-dollar cost, 2dp volume + $/m³, "-" for null); returns null when every cost block is null (skip-empty, BR-09) - ReportService.fillSection wiring + Schedule2Service injection; PrintService selectionOf case; removed schedule2 from logUnimplementedSelections - frontend printSchedules: schedule2 renderable: true Tests: Schedule2SectionMapperTest (5) + ScheduleKeyTest (order, SCHEDULE_2 first); PrintScheduleIT extended — 621 renders + one bookmark, 515 Sch-2-only -> 404, and the allSchedules case now asserts Schedule 2 first in the bookmark order (517). Green under Oracle Testcontainers (17/17). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c5cb22f commit 33f66ca

9 files changed

Lines changed: 625 additions & 24 deletions

File tree

backend/src/main/java/ca/bc/gov/nrs/ilcr/reporting/PrintService.java

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,13 @@
1818
/**
1919
* Orchestrates the combined Print Schedules PDF (Epic 20.2). Given a validated mill/year context
2020
* and a {@link PrintRequest}, it fills each SELECTED in-scope schedule section in the FIXED legacy
21-
* order (5 → 6 → 7A → 7B → 9 → 11, the {@link ScheduleKey} declaration order), SKIPS any section
22-
* with no data (BR-09 skip-empty), and exports the accumulated sections to ONE bookmarked PDF
23-
* (BR-08) — one top-level bookmark per rendered schedule. When no selected content yields any data
24-
* the result is all-empty and no PDF is produced: {@link ScheduleNotFoundException} (→ 404
21+
* order (2 → 5 → 6 → 7A → 7B → 9 → 11, the {@link ScheduleKey} declaration order), SKIPS any
22+
* section with no data (BR-09 skip-empty), and exports the accumulated sections to ONE bookmarked
23+
* PDF (BR-08) — one top-level bookmark per rendered schedule. When no selected content yields any
24+
* data the result is all-empty and no PDF is produced: {@link ScheduleNotFoundException} (→ 404
2525
* ERR-005).
2626
*
27-
* <p>Selected-but-unimplemented schedules (1/2/3/8/10) and the mill-information-report option are
27+
* <p>Selected-but-unimplemented schedules (1/3/8/10) and the mill-information-report option are
2828
* accepted for forward-compatibility but produce no section yet — they are skipped-with-a-log
2929
* (documented interim gap) until their story lands. Selection VALIDATION (ERR-002/003/004) is the
3030
* controller's responsibility and runs before this.
@@ -176,6 +176,7 @@ private static Predicate<ScheduleKey> selectionOf(PrintRequest request) {
176176
}
177177
return key ->
178178
switch (key) {
179+
case SCHEDULE_2 -> request.schedule2();
179180
case SCHEDULE_5 -> request.schedule5();
180181
case SCHEDULE_6 -> request.schedule6();
181182
case SCHEDULE_7A -> request.schedule7a();
@@ -193,7 +194,6 @@ private void logUnimplementedSelections(PrintRequest request) {
193194
boolean anyUnimplemented =
194195
request.allSchedules()
195196
|| request.schedule1()
196-
|| request.schedule2()
197197
|| request.schedule3()
198198
|| request.schedule4()
199199
|| request.schedule8()
@@ -206,8 +206,8 @@ private void logUnimplementedSelections(PrintRequest request) {
206206
// interim gap is documented; raise to DEBUG logging when diagnosing a caller that still sends
207207
// them.
208208
log.debug(
209-
"Print selection includes schedules/options not yet implemented in Epic 20.2 "
210-
+ "(1/2/3/4/8/10 and/or the Mill Information report); those are skipped for now");
209+
"Print selection includes schedules/options not yet implemented in Epic 20 "
210+
+ "(1/3/4/8/10 and/or the Mill Information report); those are skipped for now");
211211
}
212212
}
213213
}

backend/src/main/java/ca/bc/gov/nrs/ilcr/reporting/ReportService.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import ca.bc.gov.nrs.ilcr.millcontext.ScheduleNotFoundException;
44
import ca.bc.gov.nrs.ilcr.schedule11.Schedule11Service;
5+
import ca.bc.gov.nrs.ilcr.schedule2.Schedule2Service;
56
import ca.bc.gov.nrs.ilcr.schedule5.Schedule5Service;
67
import ca.bc.gov.nrs.ilcr.schedule6.Schedule6Service;
78
import ca.bc.gov.nrs.ilcr.schedule7a.Schedule7aService;
@@ -59,6 +60,7 @@ public class ReportService {
5960
private static final Logger log = LoggerFactory.getLogger(ReportService.class);
6061

6162
private final DataSource dataSource;
63+
private final Schedule2Service schedule2Service;
6264
private final Schedule5Service schedule5Service;
6365
private final Schedule6Service schedule6Service;
6466
private final Schedule7aService schedule7aService;
@@ -79,6 +81,7 @@ public class ReportService {
7981
* from — its own small pool, isolated from the {@code @Primary} transactional pool so a burst
8082
* of report renders cannot starve ordinary schedule requests (its connections are read-only
8183
* as a hint, not an enforced privilege)
84+
* @param schedule2Service the Schedule 2 read (bean-datasource feed, Story 20.6)
8285
* @param schedule5Service the Schedule 5 read (bean-datasource feed)
8386
* @param schedule6Service the Schedule 6 read (bean-datasource feed)
8487
* @param schedule7aService the Schedule 7A read (bean-datasource feed)
@@ -91,6 +94,7 @@ public class ReportService {
9194
*/
9295
public ReportService(
9396
@Qualifier("reportingDataSource") DataSource dataSource,
97+
Schedule2Service schedule2Service,
9498
Schedule5Service schedule5Service,
9599
Schedule6Service schedule6Service,
96100
Schedule7aService schedule7aService,
@@ -99,6 +103,7 @@ public ReportService(
99103
Schedule11Service schedule11Service,
100104
ReportVirtualizerFactory virtualizerFactory) {
101105
this.dataSource = dataSource;
106+
this.schedule2Service = schedule2Service;
102107
this.schedule5Service = schedule5Service;
103108
this.schedule6Service = schedule6Service;
104109
this.schedule7aService = schedule7aService;
@@ -178,6 +183,16 @@ public JasperPrint fillSection(
178183
String bookmarkTitle,
179184
JRSwapFileVirtualizer virtualizer) {
180185
return switch (key) {
186+
case SCHEDULE_2 ->
187+
fillBean(
188+
key,
189+
millId,
190+
year,
191+
options,
192+
millTitleBlock,
193+
bookmarkTitle,
194+
Schedule2SectionMapper.map(schedule2Service.getSchedule2(millId, year, false)),
195+
virtualizer);
181196
case SCHEDULE_5 ->
182197
fillBean(
183198
key,
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package ca.bc.gov.nrs.ilcr.reporting;
2+
3+
import ca.bc.gov.nrs.ilcr.schedule2.dto.CostBlock;
4+
import ca.bc.gov.nrs.ilcr.schedule2.dto.Schedule2Response;
5+
import java.util.LinkedHashMap;
6+
import java.util.List;
7+
import java.util.Map;
8+
9+
/**
10+
* Maps the Schedule 2 (Purchased/Private Log Costs and Sales) read document to the print section
11+
* datasource. Unlike the per-record fan-out sections (Schedule 5's one-row-per-camp), Schedule 2 is
12+
* a SINGLE aggregate document, so this emits exactly ONE row holding the seven fixed cost blocks —
13+
* {@code purchasedLogCost}, {@code purchasedWoodOverhead}, {@code subtotal}, {@code lessLogSales},
14+
* {@code netPurchased}, {@code totalCompanyLogging}, {@code totalAverage} — each as a
15+
* volume/cost/$-per-m³ triple. Every derived figure (the computed subtotal / net / totals and every
16+
* {@code perUnit}, plus the carried Schedule-1/3 terms) is already computed by {@code
17+
* Schedule2Service}; this only formats: whole-dollar cost, fractional volume + $-per-m³ at two
18+
* decimals, and {@code "-"} for null.
19+
*
20+
* <p>Skip-empty (BR-09) keys off the DATA, not a detail-row count: when every cost block is null
21+
* (an unsaved Schedule 2 with no carried Schedule-3 figures) this returns {@code null} so the
22+
* orchestrator omits the section.
23+
*/
24+
final class Schedule2SectionMapper {
25+
26+
private Schedule2SectionMapper() {}
27+
28+
static SectionData map(Schedule2Response response) {
29+
if (response == null || isEmpty(response)) {
30+
return null;
31+
}
32+
Map<String, Object> row = new LinkedHashMap<>();
33+
putBlock(row, "purchasedLogCost", response.purchasedLogCost());
34+
putBlock(row, "purchasedWoodOverhead", response.purchasedWoodOverhead());
35+
putBlock(row, "subtotal", response.subtotal());
36+
putBlock(row, "lessLogSales", response.lessLogSales());
37+
putBlock(row, "netPurchased", response.netPurchased());
38+
putBlock(row, "totalCompanyLogging", response.totalCompanyLogging());
39+
putBlock(row, "totalAverage", response.totalAverage());
40+
row.put("comments", SectionFormat.text(response.comments()));
41+
return new SectionData(List.of(row), Map.of());
42+
}
43+
44+
/** Emit a block's three cells ({@code <name>Vol/Cos/Cal}), pre-formatted with "-" for null. */
45+
private static void putBlock(Map<String, Object> row, String name, CostBlock block) {
46+
row.put(name + "Vol", SectionFormat.decimal(block == null ? null : block.volume()));
47+
row.put(name + "Cos", SectionFormat.money(block == null ? null : block.cost()));
48+
row.put(name + "Cal", SectionFormat.decimal(block == null ? null : block.perUnit()));
49+
}
50+
51+
/** True when no cost block carries any figure — the skip-empty signal (BR-09). */
52+
private static boolean isEmpty(Schedule2Response r) {
53+
return allNull(r.purchasedLogCost())
54+
&& allNull(r.purchasedWoodOverhead())
55+
&& allNull(r.subtotal())
56+
&& allNull(r.lessLogSales())
57+
&& allNull(r.netPurchased())
58+
&& allNull(r.totalCompanyLogging())
59+
&& allNull(r.totalAverage());
60+
}
61+
62+
private static boolean allNull(CostBlock block) {
63+
return block == null
64+
|| (block.volume() == null && block.cost() == null && block.perUnit() == null);
65+
}
66+
}

backend/src/main/java/ca/bc/gov/nrs/ilcr/reporting/ScheduleKey.java

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
package ca.bc.gov.nrs.ilcr.reporting;
22

33
/**
4-
* The schedules Epic 20.2 can render, each bound to its classpath template and its bookmark title.
5-
* Declared in the FIXED legacy print order (5 → 6 → 7A → 7B → 9 → 11 within the 1..11 sequence), so
6-
* iterating the enum yields the combined PDF's section order (BR-08) without a separate ordering
7-
* table. Schedules 1/2/3/8/10 and the Mill Information report are out of scope (no enum constant) —
8-
* a selected-but-unimplemented schedule is skipped-with-a-log by the orchestrator.
4+
* The schedules Epic 20 can render, each bound to its classpath template and its bookmark title.
5+
* Declared in the FIXED legacy print order (2 → 5 → 6 → 7A → 7B → 9 → 11 within the 1..11
6+
* sequence), so iterating the enum yields the combined PDF's section order (BR-08) without a
7+
* separate ordering table. Schedule 2 sits before Schedule 5 in the legacy sequence, so its
8+
* constant is declared FIRST. Schedules 1/3/8/10 and the Mill Information report are out of scope
9+
* (no enum constant) — a selected-but-unimplemented schedule is skipped-with-a-log by the
10+
* orchestrator.
911
*/
1012
public enum ScheduleKey {
13+
SCHEDULE_2("reports/schedule2.jrxml", "Schedule 2: Purchased and Private Log Costs and Sales"),
1114
SCHEDULE_5("reports/schedule5.jrxml", "Schedule 5: Camp and Access Expenses"),
1215
SCHEDULE_6("reports/schedule6.jrxml", "Schedule 6: Road Management Costs"),
1316
SCHEDULE_7A("reports/schedule7a.jrxml", "Schedule 7A: Bridge Costs"),

0 commit comments

Comments
 (0)