Skip to content

Commit f785ed9

Browse files
gpascucciclaude
andauthored
feat(schedule10): Schedule 10 print section (Story 20.4) (#340)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c5cb22f commit f785ed9

10 files changed

Lines changed: 1169 additions & 8 deletions

File tree

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,7 @@ private static Predicate<ScheduleKey> selectionOf(PrintRequest request) {
181181
case SCHEDULE_7A -> request.schedule7a();
182182
case SCHEDULE_7B -> request.schedule7b();
183183
case SCHEDULE_9 -> request.schedule9();
184+
case SCHEDULE_10 -> request.schedule10();
184185
case SCHEDULE_11 -> request.schedule11();
185186
};
186187
}
@@ -197,7 +198,6 @@ private void logUnimplementedSelections(PrintRequest request) {
197198
|| request.schedule3()
198199
|| request.schedule4()
199200
|| request.schedule8()
200-
|| request.schedule10()
201201
|| request.printMillInformationReport();
202202
if (anyUnimplemented) {
203203
// DEBUG, not INFO: the SPA (Story 20.3) disables the deferred schedules/options, so this is
@@ -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/2/3/4/8 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
@@ -1,6 +1,7 @@
11
package ca.bc.gov.nrs.ilcr.reporting;
22

33
import ca.bc.gov.nrs.ilcr.millcontext.ScheduleNotFoundException;
4+
import ca.bc.gov.nrs.ilcr.schedule10.Schedule10Service;
45
import ca.bc.gov.nrs.ilcr.schedule11.Schedule11Service;
56
import ca.bc.gov.nrs.ilcr.schedule5.Schedule5Service;
67
import ca.bc.gov.nrs.ilcr.schedule6.Schedule6Service;
@@ -64,6 +65,7 @@ public class ReportService {
6465
private final Schedule7aService schedule7aService;
6566
private final Schedule7bService schedule7bService;
6667
private final Schedule9Service schedule9Service;
68+
private final Schedule10Service schedule10Service;
6769
private final Schedule11Service schedule11Service;
6870
private final ReportVirtualizerFactory virtualizerFactory;
6971

@@ -85,6 +87,7 @@ public class ReportService {
8587
* @param schedule7bService the Schedule 7B read (bean-datasource feed)
8688
* @param schedule9Service the Schedule 9 read seam, used for the empty-schedule pre-check (29.10
8789
* — through the service, not the repository)
90+
* @param schedule10Service the Schedule 10 read (bean-datasource feed, Story 20.4)
8891
* @param schedule11Service the Schedule 11 read (bean-datasource feed)
8992
* @param virtualizerFactory builds the per-render Jasper swap-file virtualizer (Story 29.2) so a
9093
* large or combined fill spills page objects to disk instead of pinning them on the heap
@@ -96,6 +99,7 @@ public ReportService(
9699
Schedule7aService schedule7aService,
97100
Schedule7bService schedule7bService,
98101
Schedule9Service schedule9Service,
102+
Schedule10Service schedule10Service,
99103
Schedule11Service schedule11Service,
100104
ReportVirtualizerFactory virtualizerFactory) {
101105
this.dataSource = dataSource;
@@ -104,6 +108,7 @@ public ReportService(
104108
this.schedule7aService = schedule7aService;
105109
this.schedule7bService = schedule7bService;
106110
this.schedule9Service = schedule9Service;
111+
this.schedule10Service = schedule10Service;
107112
this.schedule11Service = schedule11Service;
108113
this.virtualizerFactory = virtualizerFactory;
109114
}
@@ -218,6 +223,16 @@ public JasperPrint fillSection(
218223
bookmarkTitle,
219224
Schedule7bSectionMapper.map(schedule7bService.getSchedule7b(millId, year, false)),
220225
virtualizer);
226+
case SCHEDULE_10 ->
227+
fillBean(
228+
key,
229+
millId,
230+
year,
231+
options,
232+
millTitleBlock,
233+
bookmarkTitle,
234+
Schedule10SectionMapper.map(schedule10Service.getSchedule10(millId, year, false)),
235+
virtualizer);
221236
case SCHEDULE_11 ->
222237
fillBean(
223238
key,
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
package ca.bc.gov.nrs.ilcr.reporting;
2+
3+
import ca.bc.gov.nrs.ilcr.dto.base.CodeDescriptionDto;
4+
import ca.bc.gov.nrs.ilcr.schedule10.dto.ConstructionPage;
5+
import ca.bc.gov.nrs.ilcr.schedule10.dto.MaterialComposition;
6+
import ca.bc.gov.nrs.ilcr.schedule10.dto.RoadDetail;
7+
import ca.bc.gov.nrs.ilcr.schedule10.dto.Schedule10CodeLists;
8+
import ca.bc.gov.nrs.ilcr.schedule10.dto.Schedule10Response;
9+
import ca.bc.gov.nrs.ilcr.schedule10.dto.Stabilizing;
10+
import ca.bc.gov.nrs.ilcr.schedule10.dto.SubGrade;
11+
import java.math.BigDecimal;
12+
import java.math.RoundingMode;
13+
import java.util.ArrayList;
14+
import java.util.HashMap;
15+
import java.util.LinkedHashMap;
16+
import java.util.List;
17+
import java.util.Map;
18+
19+
/**
20+
* Maps the Schedule 10 (New Road Construction Costs) read document to the print section datasource:
21+
* ONE detail row per road detail (flattened across construction pages), each carrying BOTH the road
22+
* detail's own fields AND its construction-page/project context (division, period, region,
23+
* TSA-or-TFL, supply block, TFL, road group) plus the page/road ordinals — so {@code
24+
* schedule10.jrxml} can group by construction page (printing the project header once) and
25+
* page-break per road detail, and the footer can read {@code Page {pageNumber} - Road {rowNumber}
26+
* of {roadDetailCount}} (the legacy form layout). The service ({@code Schedule10Service}) has
27+
* already derived the road group, the sub-grade / stabilizing totals and $/km, and the material
28+
* percentages — this mapper only formats and resolves code → label for the header.
29+
*
30+
* <p>Two deliberate deviations from the legacy report, both following the rebuilt Schedule 10 model
31+
* (Story 20.4 decisions):
32+
*
33+
* <ul>
34+
* <li>The LD-1/LD-2/LD-3 fields the legacy report showed — Moisture/ASM Code, Soil Moisture Code,
35+
* and Boulder Area % — are OMITTED: the rebuilt Schedule 10 removed them by business
36+
* direction and does not expose them on read, so the print does not show them either.
37+
* <li>End Haul / Overland {@code $/m3/km} is not carried by the read DTO, so it is computed here
38+
* for display: {@code lessEndHaul / (volume × distance)} (and the overland pair), dashed when
39+
* the volume or distance is absent/zero.
40+
* </ul>
41+
*/
42+
final class Schedule10SectionMapper {
43+
44+
/** The sentinel the frontend/legacy shows in the "TSA or TFL" field for a TFL-located page. */
45+
private static final String TFL_SENTINEL = "Tree Farm Licensee";
46+
47+
private Schedule10SectionMapper() {}
48+
49+
static SectionData map(Schedule10Response response) {
50+
if (response == null || response.pages() == null || response.pages().isEmpty()) {
51+
return null;
52+
}
53+
Schedule10CodeLists codeLists = response.codeLists();
54+
List<Map<String, ?>> rows = new ArrayList<>();
55+
for (ConstructionPage page : response.pages()) {
56+
List<RoadDetail> details = page.roadDetails() == null ? List.of() : page.roadDetails();
57+
for (RoadDetail detail : details) {
58+
rows.add(toRow(page, detail, codeLists));
59+
}
60+
}
61+
if (rows.isEmpty()) {
62+
return null;
63+
}
64+
return new SectionData(rows, new HashMap<>());
65+
}
66+
67+
private static Map<String, ?> toRow(
68+
ConstructionPage page, RoadDetail detail, Schedule10CodeLists codeLists) {
69+
boolean tflLocated = page.tflNumberCode() != null && !page.tflNumberCode().isBlank();
70+
Map<String, Object> row = new LinkedHashMap<>();
71+
72+
// Construction-page / project context (drives the Jasper group header + the page/road footer).
73+
row.put("pageNumber", page.pageNumber());
74+
row.put("rowNumber", detail.rowNumber());
75+
row.put("roadDetailCount", page.roadDetailCount());
76+
row.put("division", SectionFormat.text(page.divisionName()));
77+
row.put("period", SectionFormat.text(page.constructionPeriod()));
78+
row.put(
79+
"region",
80+
describe(codeLists == null ? null : codeLists.forestRegions(), page.forestRegionCode()));
81+
row.put(
82+
"tsaOrTfl",
83+
tflLocated
84+
? TFL_SENTINEL
85+
: describe(codeLists == null ? null : codeLists.tsaNumbers(), page.tsaNumber()));
86+
row.put(
87+
"supplyBlock",
88+
tflLocated
89+
? ""
90+
: describe(codeLists == null ? null : codeLists.supplyBlocks(), page.tsbNumberCode()));
91+
row.put("tfl", tflLocated ? SectionFormat.text(page.tflNumberCode()) : "");
92+
row.put("roadGroup", page.roadGroup() == null ? "" : page.roadGroup());
93+
94+
// Road information.
95+
row.put("roadName", SectionFormat.text(detail.roadName()));
96+
row.put(
97+
"roadType",
98+
describe(codeLists == null ? null : codeLists.roadLifetimes(), detail.roadLifetimeCode()));
99+
row.put(
100+
"biogeoVariant",
101+
SectionFormat.text(
102+
detail.becClassification() == null ? null : detail.becClassification().label()));
103+
row.put(
104+
"rsmsClass",
105+
describeRsmr(
106+
codeLists == null ? null : codeLists.rsmrClasses(), detail.relSoilMoistRgmClsCode()));
107+
row.put("sideSlope", SectionFormat.percent(detail.sideSlopePct()));
108+
109+
// Material composition percentages.
110+
MaterialComposition material = detail.materialComposition();
111+
row.put(
112+
"matSolidRock", SectionFormat.percent(material == null ? null : material.solidRockPct()));
113+
row.put(
114+
"matRippableRock",
115+
SectionFormat.percent(material == null ? null : material.rippableRockPct()));
116+
row.put("matCoarse", SectionFormat.percent(material == null ? null : material.coarsePct()));
117+
row.put("matFine", SectionFormat.percent(material == null ? null : material.finePct()));
118+
row.put("matOrganic", SectionFormat.percent(material == null ? null : material.organicPct()));
119+
row.put("matTotal", SectionFormat.percent(material == null ? null : material.totalPct()));
120+
121+
// Sub-grade.
122+
SubGrade sg = detail.subGrade();
123+
row.put("sgLength", SectionFormat.measure(sg == null ? null : sg.length(), "km"));
124+
row.put("sgSurfaceWidth", SectionFormat.measure(sg == null ? null : sg.surfaceWidth(), "m"));
125+
row.put("sgActualCost", SectionFormat.money(sg == null ? null : sg.actualCost()));
126+
row.put("sgTtTransfer", SectionFormat.money(sg == null ? null : sg.ttTransfer()));
127+
row.put("sgOtherTransfer", SectionFormat.money(sg == null ? null : sg.otherTransfer()));
128+
row.put("sgTotalCosts", SectionFormat.money(sg == null ? null : sg.totalCosts()));
129+
row.put("sgLessBridges", SectionFormat.money(sg == null ? null : sg.lessBridges()));
130+
row.put("sgLessCulverts", SectionFormat.money(sg == null ? null : sg.lessCulverts()));
131+
row.put("sgLessLandings", SectionFormat.money(sg == null ? null : sg.lessLandings()));
132+
row.put("sgLessEndHaul", SectionFormat.money(sg == null ? null : sg.lessEndHaul()));
133+
row.put("sgLessOverland", SectionFormat.money(sg == null ? null : sg.lessOverland()));
134+
row.put("sgLessOtherEng", SectionFormat.money(sg == null ? null : sg.lessOtherEng()));
135+
row.put("sgTotal", SectionFormat.money(sg == null ? null : sg.total()));
136+
row.put("sgCostPerLength", SectionFormat.decimal(sg == null ? null : sg.costPerLength()));
137+
138+
// Additional stabilizing.
139+
Stabilizing st = detail.stabilizing();
140+
row.put(
141+
"stCode",
142+
describe(
143+
codeLists == null ? null : codeLists.ballastMethods(),
144+
st == null ? null : st.ballastMethodCode()));
145+
row.put("stLength", SectionFormat.measure(st == null ? null : st.length(), "km"));
146+
row.put("stSurfaceWidth", SectionFormat.measure(st == null ? null : st.surfaceWidth(), "m"));
147+
row.put(
148+
"stType",
149+
describe(
150+
codeLists == null ? null : codeLists.ballastMaterials(),
151+
st == null ? null : st.ballastMaterialCode()));
152+
row.put("stDepth", SectionFormat.measure(st == null ? null : st.depth(), "m"));
153+
row.put(
154+
"stDistanceToSource",
155+
SectionFormat.measure(st == null ? null : st.distanceToSource(), "km"));
156+
row.put("stActualCost", SectionFormat.money(st == null ? null : st.actualCost()));
157+
row.put("stTtTransfer", SectionFormat.money(st == null ? null : st.ttTransfer()));
158+
row.put("stOtherTransfer", SectionFormat.money(st == null ? null : st.otherTransfer()));
159+
row.put("stTotal", SectionFormat.money(st == null ? null : st.total()));
160+
row.put("stCostPerLength", SectionFormat.decimal(st == null ? null : st.costPerLength()));
161+
162+
// Include-detail-engineering flag + end-haul / overland detail table.
163+
row.put("includeDetailEng", SectionFormat.text(detail.detailedEngineeringCostInd()));
164+
row.put("endHaulDistance", SectionFormat.plain(detail.endHaulDistance()));
165+
row.put("endHaulVolume", SectionFormat.plain(detail.endHaulVolume()));
166+
row.put(
167+
"endHaulPerUnit",
168+
SectionFormat.decimal(
169+
perM3PerKm(
170+
sg == null ? null : sg.lessEndHaul(),
171+
detail.endHaulVolume(),
172+
detail.endHaulDistance())));
173+
row.put("overlandDistance", SectionFormat.plain(detail.overlandDistance()));
174+
row.put("overlandVolume", SectionFormat.plain(detail.overlandVolume()));
175+
row.put(
176+
"overlandPerUnit",
177+
SectionFormat.decimal(
178+
perM3PerKm(
179+
sg == null ? null : sg.lessOverland(),
180+
detail.overlandVolume(),
181+
detail.overlandDistance())));
182+
183+
row.put("comments", SectionFormat.text(detail.comments()));
184+
return row;
185+
}
186+
187+
/**
188+
* The legacy {@code $/m3/km} for an end-haul / overland row: the deducted cost spread over the
189+
* hauled volume-kilometres, {@code cost / (volume × distance)} at two decimals. Returns {@code
190+
* null} (rendered as a dash) when the cost, volume, or distance is absent or the denominator is
191+
* zero — so a road with no end-haul / overland leaves the column dashed, matching the legacy
192+
* report.
193+
*/
194+
private static BigDecimal perM3PerKm(BigDecimal cost, BigDecimal volume, BigDecimal distance) {
195+
if (cost == null || volume == null || distance == null) {
196+
return null;
197+
}
198+
BigDecimal denominator = volume.multiply(distance);
199+
if (denominator.signum() <= 0) {
200+
return null;
201+
}
202+
return cost.divide(denominator, 2, RoundingMode.HALF_UP);
203+
}
204+
205+
/** Resolve an RSMR class code to `{code} - {desc}` via the code list. */
206+
private static String describeRsmr(List<CodeDescriptionDto> options, String code) {
207+
if (code == null || code.isBlank()) {
208+
return "-";
209+
}
210+
if (options != null) {
211+
for (CodeDescriptionDto option : options) {
212+
if (code.equals(option.code())) {
213+
return code + " - " + option.description();
214+
}
215+
}
216+
}
217+
return code;
218+
}
219+
220+
/**
221+
* Resolve a code to its description via the code list, falling back to the raw code (never
222+
* dashed).
223+
*/
224+
private static String describe(List<CodeDescriptionDto> options, String code) {
225+
if (code == null || code.isBlank()) {
226+
return "-";
227+
}
228+
if (options != null) {
229+
for (CodeDescriptionDto option : options) {
230+
if (code.equals(option.code())) {
231+
return option.description();
232+
}
233+
}
234+
}
235+
return code;
236+
}
237+
}

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

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,18 @@
22

33
/**
44
* 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.
5+
* Declared in the FIXED legacy print order (5 → 6 → 7A → 7B → 9 → 10 → 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. Schedules 1/2/3/8 and the Mill Information report are out of scope (no
8+
* enum constant) — a selected-but-unimplemented schedule is skipped-with-a-log by the orchestrator.
99
*/
1010
public enum ScheduleKey {
1111
SCHEDULE_5("reports/schedule5.jrxml", "Schedule 5: Camp and Access Expenses"),
1212
SCHEDULE_6("reports/schedule6.jrxml", "Schedule 6: Road Management Costs"),
1313
SCHEDULE_7A("reports/schedule7a.jrxml", "Schedule 7A: Bridge Costs"),
1414
SCHEDULE_7B("reports/schedule7b.jrxml", "Schedule 7B: Culvert Costs"),
1515
SCHEDULE_9("reports/schedule9.jrxml", "Schedule 9: Miscellaneous/Unique Logging Costs"),
16+
SCHEDULE_10("reports/schedule10.jrxml", "Schedule 10: New Road Construction Costs"),
1617
SCHEDULE_11("reports/schedule11.jrxml", "Schedule 11: Basic Silviculture");
1718

1819
private final String templatePath;

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,32 @@ static String money(Integer value) {
3636
return value == null ? DASH : new DecimalFormat("#,##0", SYMBOLS).format(value.longValue());
3737
}
3838

39+
/**
40+
* A whole-dollar cost from a {@code BigDecimal} source, grouped with no decimals, or {@code "-"}
41+
* when null. Schedule 10's cost fields are {@code BigDecimal} (unlike Schedule 6's {@code Long});
42+
* the legacy report renders them grouped with no cents (e.g. {@code 25,000}).
43+
*/
44+
static String money(BigDecimal value) {
45+
return value == null ? DASH : new DecimalFormat("#,##0", SYMBOLS).format(value);
46+
}
47+
48+
/**
49+
* A measurement at its stored precision with a trailing unit (e.g. {@code 2.123 m}, {@code 1.1
50+
* km}), or a bare {@code "-"} (no unit) when null — matching the legacy Schedule 10 report, which
51+
* drops the unit for an absent measure. Uses the value's own scale (not a fixed two decimals) so
52+
* {@code 4.5} stays {@code 4.5} and {@code 1.500} stays {@code 1.500}.
53+
*/
54+
static String measure(BigDecimal value, String unit) {
55+
return value == null ? DASH : value.toPlainString() + " " + unit;
56+
}
57+
58+
/**
59+
* A raw numeric figure at its stored precision (no unit, no grouping), or {@code "-"} when null.
60+
*/
61+
static String plain(BigDecimal value) {
62+
return value == null ? DASH : value.toPlainString();
63+
}
64+
3965
/** A decimal figure (volume / $-per-unit) at two decimals, or {@code "-"} when null. */
4066
static String decimal(BigDecimal value) {
4167
return value == null ? DASH : new DecimalFormat("#,##0.00", SYMBOLS).format(value);
@@ -46,6 +72,11 @@ static String integer(Integer value) {
4672
return value == null ? DASH : String.valueOf(value);
4773
}
4874

75+
/** A percentage text (e.g. {@code 45 %}), or {@code "-"} when null. */
76+
static String percent(Integer value) {
77+
return value == null ? DASH : value.toString() + " %";
78+
}
79+
4980
/** A stored text value verbatim, or {@code "-"} when null/blank. */
5081
static String text(String value) {
5182
return value == null || value.isBlank() ? DASH : value;

0 commit comments

Comments
 (0)