Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package ca.bc.gov.nrs.csp.backend.invoice.shared;

import ca.bc.gov.nrs.csp.backend.util.constants.ConstantsCode;

import java.math.BigDecimal;
import java.math.RoundingMode;

/**
* Single source of truth for a line item's {@code $Amount} (volume × price).
*
* <p>The amount is never stored — {@code coastal_log_sale_detail} has no amount
* column, so every channel derives it. It therefore has to be derived the SAME
* way everywhere: the read path that feeds the invoice screen and the CSV/PDF
* exports, the inbound mapper, and the totals-variance rules. Previously each
* did its own {@code volume.multiply(price)} and only the rules applied the
* adjustment sign fix, so an ADJ line showed a positive amount on screen while
* the validator calculated a negative one.
*/
public final class LineAmount {

private LineAmount() {}

/**
* One line's amount, HALF_UP to 2dp, or {@code null} when either input is absent.
*
* <p>Adjustment invoices are the only type allowed to carry a negative volume
* or price (every other type rejects them as errors — see
* {@link ca.bc.gov.nrs.csp.backend.invoice.shared.rules.InvoiceLineRuleSet}).
* A plain multiply of two negatives yields a positive, but an adjustment's
* amount must stay negative, so the volume is flipped positive and the
* negative price carries the sign.
*
* @param volume the line's volume
* @param price the line's price
* @param invoiceType the parent invoice's type code, e.g. {@code ADJ}
* @return the signed amount rounded to 2dp, or null if volume or price is null
*/
public static BigDecimal compute(BigDecimal volume, BigDecimal price, String invoiceType) {
if (volume == null || price == null) {
return null;
}
BigDecimal effectiveVolume = volume;
if (isAdjustment(invoiceType) && volume.signum() < 0 && price.signum() < 0) {
effectiveVolume = volume.abs();
}
return effectiveVolume.multiply(price).setScale(2, RoundingMode.HALF_UP);
}

/** Whether the given invoice type code is an adjustment. */
public static boolean isAdjustment(String invoiceType) {
return ConstantsCode.INVTYPE_ADJUST.equals(invoiceType);
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package ca.bc.gov.nrs.csp.backend.invoice.shared.rules;

import ca.bc.gov.nrs.csp.backend.invoice.shared.LineAmount;
import ca.bc.gov.nrs.csp.backend.invoice.shared.model.Finding;
import ca.bc.gov.nrs.csp.backend.invoice.shared.model.InvoiceTotals;
import ca.bc.gov.nrs.csp.backend.invoice.shared.model.Severity;
Expand Down Expand Up @@ -116,16 +117,13 @@ private static BigDecimal calculatedTotalAmount(InvoiceTotals t) {
return total.setScale(2, RoundingMode.HALF_UP);
}

/** One line's contribution to the total amount, or null if volume or price is absent. */
/**
* One line's contribution to the total amount, or null if volume or price is absent.
* Delegates to {@link LineAmount} so the screen, the exports and this variance
* calculation can never disagree about an ADJ line's sign.
*/
private static BigDecimal lineAmount(BigDecimal volume, BigDecimal price, boolean adjustment) {
if (volume == null || price == null) {
return null;
}
// A default multiply of two negatives yields a positive; for ADJ the amount must stay negative.
if (adjustment && volume.signum() < 0 && price.signum() < 0) {
volume = volume.abs();
}
return volume.multiply(price).setScale(2, RoundingMode.HALF_UP);
return LineAmount.compute(volume, price, adjustment ? ConstantsCode.INVTYPE_ADJUST : null);
}

/** Calculated total volume = Σ volume over the line items; lines missing a volume contribute nothing. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import java.sql.SQLException;
import java.sql.Types;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

Expand Down Expand Up @@ -601,14 +602,18 @@ public int countByCspSubmissionIdAndStatus(Long submissionId, String statusCode)
* in {@code csvInvoiceNumbers}, resolving each number to its coastal_log_sale_id via the
* submitter's client. The validator runs the same lookup, so any number reaching this
* method should already exist for the submitter.
*
* @return the coastal_log_sale_ids the numbers resolved to (unresolved numbers are
* omitted), so callers can act on the related invoices — e.g. cancelling the
* invoices a replacement supersedes.
*/
public void replaceRelatedInvoices(Long parentId, String refTypeCode, String csvInvoiceNumbers,
public List<Long> replaceRelatedInvoices(Long parentId, String refTypeCode, String csvInvoiceNumbers,
String submitterClientNum, String submitterLocnCode, String userId) {
jdbc.update("DELETE FROM THE.coastal_log_sale_rltd_invc"
+ " WHERE coastal_log_sale_id = :id AND csp_invoice_ref_type_code = :type",
new MapSqlParameterSource().addValue("id", parentId).addValue("type", refTypeCode));

if (csvInvoiceNumbers == null || csvInvoiceNumbers.isBlank()) return;
if (csvInvoiceNumbers == null || csvInvoiceNumbers.isBlank()) return List.of();

String resolveSql = """
SELECT cls.coastal_log_sale_id
Expand All @@ -632,6 +637,7 @@ public void replaceRelatedInvoices(Long parentId, String refTypeCode, String csv
)
""";

List<Long> relatedIds = new ArrayList<>();
for (String invNo : csvInvoiceNumbers.split(",")) {
String trimmed = invNo.trim();
if (trimmed.isEmpty()) continue;
Expand All @@ -648,7 +654,9 @@ public void replaceRelatedInvoices(Long parentId, String refTypeCode, String csv
.addValue("relatedId", ids.get(0))
.addValue("refTypeCode", refTypeCode)
.addValue("userId", userId));
relatedIds.add(ids.get(0));
}
return relatedIds;
}

public void replaceLogSources(Long invoiceId, String logSourceCode, List<String> values, String userId) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package ca.bc.gov.nrs.csp.backend.repository;

import ca.bc.gov.nrs.csp.backend.controller.dto.invoiceDetails.LineItem;
import ca.bc.gov.nrs.csp.backend.invoice.shared.LineAmount;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder;
Expand Down Expand Up @@ -35,18 +36,20 @@ public List<LineItem> findByInvoiceId(Long invoiceId) {
d.pieces,
d.price,
d.volume,
d.converted_price
d.converted_price,
cls.csp_invoice_type_code AS invoice_type
FROM THE.coastal_log_sale_detail d
JOIN THE.csp_species_grade_xref sgx ON d.csp_species_grade_xref_id = sgx.csp_species_grade_xref_id
LEFT JOIN THE.log_sale_species_code sc ON sgx.log_sale_species_code = sc.log_sale_species_code
JOIN THE.coastal_log_sale cls ON d.coastal_log_sale_id = cls.coastal_log_sale_id
WHERE d.coastal_log_sale_id = :id
ORDER BY d.coastal_log_sale_detail_id
""";
MapSqlParameterSource params = new MapSqlParameterSource("id", invoiceId);
return jdbc.query(sql, params, (rs, rowNum) -> {
BigDecimal price = rs.getBigDecimal("price");
BigDecimal volume = rs.getBigDecimal("volume");
BigDecimal amount = (price != null && volume != null) ? price.multiply(volume) : null;
BigDecimal amount = LineAmount.compute(volume, price, rs.getString("invoice_type"));
return new LineItem(
rs.getObject("line_item_id", Long.class),
rs.getObject("invoice_id", Long.class),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,14 @@ public class InvoiceService {

private static final Logger log = LoggerFactory.getLogger(InvoiceService.class);

/**
* Statuses in which an invoice sits in an active review queue, and therefore the
* ones whose cancellation has to cascade to the parent submission. Mirrors the
* statuses the UI offers Approve / Reject / Cancel from.
*/
private static final Set<String> REVIEW_QUEUE_STATUSES = Set.of(
ConstantsCode.INVENTRYSTATUS_PROCESSING, ConstantsCode.INVENTRYSTATUS_UNAPPROVED);

private final InvoiceRepository invoiceRepo;
private final LineItemRepository lineItemRepo;
private final CspSubmissionRepository submissionRepo;
Expand Down Expand Up @@ -132,7 +140,7 @@ public InvoiceResponse getById(Long id) {
public InvoiceResponse create(CreateInvoiceRequest request) {
String user = SecurityContextUtils.requireUsername();
InvoiceDetails details = mapper.toDetails(request, user);
List<LineItem> lines = mapper.toLineItems(request.lineItems(), null);
List<LineItem> lines = mapper.toLineItems(request.lineItems(), null, details.invType());

ValidationResult result = newValidator().validate(details, lines, request.manual(), ActionType.SAVE);
throwIfErrors(result, "Invoice failed validation on create.");
Expand Down Expand Up @@ -169,10 +177,11 @@ public InvoiceResponse create(CreateInvoiceRequest request) {
invoiceRepo.replaceLogSources(newInvoiceId, ConstantsCode.LOGSOURCECODE_BOOMNUMBER, details.boomNumbers(), user);
invoiceRepo.replaceLogSources(newInvoiceId, ConstantsCode.LOGSOURCECODE_TIMERMARK, details.timberMarks(), user);
invoiceRepo.replaceLogSources(newInvoiceId, ConstantsCode.LOGSOURCECODE_WEIGHSLIP, details.weightSlips(), user);
invoiceRepo.replaceRelatedInvoices(newInvoiceId, ConstantsCode.INVRELATETYPE_REPLACE,
List<Long> replacedIds = invoiceRepo.replaceRelatedInvoices(newInvoiceId, ConstantsCode.INVRELATETYPE_REPLACE,
details.replaceInvNum(), details.submitterClientNum(), details.submitterLocation(), user);
invoiceRepo.replaceRelatedInvoices(newInvoiceId, ConstantsCode.INVRELATETYPE_ADJUST,
details.adjustInvNum(), details.submitterClientNum(), details.submitterLocation(), user);
cancelReplacedInvoices(newInvoiceId, replacedIds, user);

InvoiceDetails saved = withId(details, newInvoiceId, ConstantsCode.INVENTRYSTATUS_DRAFT);
// A freshly-created submission has no business submission number yet
Expand Down Expand Up @@ -201,7 +210,7 @@ public InvoiceResponse update(Long id, UpdateInvoiceRequest request) {
}

InvoiceDetails details = mapper.toDetails(request, id, ConstantsCode.INVENTRYSTATUS_DRAFT, existing.details().entryUserID());
List<LineItem> lines = mapper.toLineItems(request.lineItems(), id);
List<LineItem> lines = mapper.toLineItems(request.lineItems(), id, details.invType());

ValidationResult result = newValidator().validate(details, lines, request.manual(), ActionType.SAVE);
throwIfErrors(result, "Invoice failed validation on update.");
Expand All @@ -220,10 +229,11 @@ public InvoiceResponse update(Long id, UpdateInvoiceRequest request) {
invoiceRepo.replaceLogSources(id, ConstantsCode.LOGSOURCECODE_BOOMNUMBER, details.boomNumbers(), user);
invoiceRepo.replaceLogSources(id, ConstantsCode.LOGSOURCECODE_TIMERMARK, details.timberMarks(), user);
invoiceRepo.replaceLogSources(id, ConstantsCode.LOGSOURCECODE_WEIGHSLIP, details.weightSlips(), user);
invoiceRepo.replaceRelatedInvoices(id, ConstantsCode.INVRELATETYPE_REPLACE,
List<Long> replacedIds = invoiceRepo.replaceRelatedInvoices(id, ConstantsCode.INVRELATETYPE_REPLACE,
details.replaceInvNum(), details.submitterClientNum(), details.submitterLocation(), user);
invoiceRepo.replaceRelatedInvoices(id, ConstantsCode.INVRELATETYPE_ADJUST,
details.adjustInvNum(), details.submitterClientNum(), details.submitterLocation(), user);
cancelReplacedInvoices(id, replacedIds, user);

// Saving an existing invoice reverts its submission to LOBBY.
if (existing.submissionId() != null) {
Expand Down Expand Up @@ -426,6 +436,49 @@ private void applySubmissionStatusOnStatusChange(Long submissionId, String newIn
log.info("Submission submissionId={} newStatus={} anyApproved={}", submissionId, newSubmissionStatus, anyApproved);
}

/**
* A replacement invoice supersedes every invoice named in its "Replaces invoice
* number(s)" field, so saving it cancels those originals. Called from create and
* update with the ids {@link InvoiceRepository#replaceRelatedInvoices} resolved for
* the REP relationship (never ADJ — an adjusting invoice leaves its target alone).
*
* <p>Skipped: an already-CANCELLED original (nothing to change) and a self-reference
* (the validator rejects "replaces itself", so this is only a defensive guard).</p>
*
* <p>The submission-status cascade only runs when the original was in a
* {@link #REVIEW_QUEUE_STATUSES review-queue status}, which is what the cascade was
* written for; it clears a replaced invoice out of the inbox. Cancelling a DRAFT
* original deliberately leaves its submission in the lobby:
* the cascade would otherwise push a lobby submission (possibly holding other drafts)
* to REJECTED.</p>
*/
private void cancelReplacedInvoices(Long replacementId, List<Long> replacedIds, String user) {
if (replacedIds == null) return;
for (Long replacedId : replacedIds) {
cancelReplacedInvoice(replacementId, replacedId, user);
}
}

/**
* Cancels a single replaced invoice. Skips a null id, a self-reference, an invoice
* that no longer exists, and one that is already CANCELLED.
*/
private void cancelReplacedInvoice(Long replacementId, Long replacedId, String user) {
if (replacedId == null || replacedId.equals(replacementId)) return;
LoadedInvoice replaced = invoiceRepo.findById(replacedId).orElse(null);
if (replaced == null) return;
String previousStatus = replaced.details().invStatus();
if (ConstantsCode.INVENTRYSTATUS_CANCELLED.equals(previousStatus)) return;

invoiceRepo.updateStatus(replacedId, ConstantsCode.INVENTRYSTATUS_CANCELLED, user);
log.info("Cancelled invoice id={} (was {}) as it is replaced by invoice id={}",
replacedId, previousStatus, replacementId);
if (REVIEW_QUEUE_STATUSES.contains(previousStatus)) {
applySubmissionStatusOnStatusChange(
replaced.submissionId(), ConstantsCode.INVENTRYSTATUS_CANCELLED, user);
}
}

// ---------------------------------------------------------------
// LINE ITEMS (sub-resource — only valid on DRAFT invoices)
// ---------------------------------------------------------------
Expand All @@ -439,7 +492,7 @@ public InvoiceResponse addLineItem(Long invoiceId, LineItemRequest request) {
// the full invoice validator against the full set so totals-variance
// / line-count rules still fire correctly.
List<LineItem> existingLines = lineItemRepo.findByInvoiceId(invoiceId);
LineItem newLine = mapper.toLineItem(request, invoiceId);
LineItem newLine = mapper.toLineItem(request, invoiceId, existing.details().invType());
List<LineItem> candidate = new ArrayList<>(existingLines);
candidate.add(newLine);

Expand All @@ -459,7 +512,7 @@ public InvoiceResponse updateLineItem(Long invoiceId, Long lineId, LineItemReque
ensureLineBelongsToInvoice(invoiceId, lineId);

List<LineItem> existingLines = lineItemRepo.findByInvoiceId(invoiceId);
LineItem mapped = mapper.toLineItem(request, invoiceId);
LineItem mapped = mapper.toLineItem(request, invoiceId, existing.details().invType());
LineItem updatedLine = new LineItem(
lineId, mapped.invoiceID(), mapped.secondSort(), mapped.clientSecondarySort(), mapped.species(),
mapped.speciesDescription(), mapped.grade(), mapped.numOfPieces(), mapped.price(), mapped.volume(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,14 @@
import ca.bc.gov.nrs.csp.backend.controller.dto.invoiceDetails.LineItemResponse;
import ca.bc.gov.nrs.csp.backend.controller.dto.invoiceDetails.UpdateInvoiceRequest;
import ca.bc.gov.nrs.csp.backend.controller.dto.invoiceDetails.ValidationMessageResponse;
import ca.bc.gov.nrs.csp.backend.invoice.shared.LineAmount;
import ca.bc.gov.nrs.csp.backend.invoice.shared.rules.SourceDocuments;
import ca.bc.gov.nrs.csp.backend.util.validation.ValidationMessage;
import ca.bc.gov.nrs.csp.backend.util.validation.ValidationResult;
import org.springframework.context.MessageSource;
import org.springframework.context.NoSuchMessageException;
import org.springframework.stereotype.Component;

import java.math.BigDecimal;
import java.util.List;
import java.util.Locale;

Expand Down Expand Up @@ -109,7 +109,11 @@ private static List<String> dedupSourceDocuments(List<String> values) {
return SourceDocuments.dedup(values);
}

public LineItem toLineItem(LineItemRequest req, Long invoiceId) {
/**
* @param invoiceType the parent invoice's type code — an ADJ line keeps a negative
* amount when both volume and price are negative.
*/
public LineItem toLineItem(LineItemRequest req, Long invoiceId, String invoiceType) {
if (req == null) return null;
// Description is resolved server-side from the species code lookup;
// null on the inbound path, populated when the row is read back.
Expand All @@ -125,13 +129,13 @@ public LineItem toLineItem(LineItemRequest req, Long invoiceId) {
req.price(),
req.volume(),
req.convertedPrice(),
computeAmount(req.volume(), req.price())
LineAmount.compute(req.volume(), req.price(), invoiceType)
);
}

public List<LineItem> toLineItems(List<LineItemRequest> requests, Long invoiceId) {
public List<LineItem> toLineItems(List<LineItemRequest> requests, Long invoiceId, String invoiceType) {
if (requests == null) return List.of();
return requests.stream().map(r -> toLineItem(r, invoiceId)).toList();
return requests.stream().map(r -> toLineItem(r, invoiceId, invoiceType)).toList();
}

public LineItemResponse toLineItemResponse(LineItem line) {
Expand Down Expand Up @@ -231,8 +235,4 @@ public InvoiceResponse toResponse(InvoiceDetails details, Long submissionId, Lon
);
}

private static BigDecimal computeAmount(BigDecimal volume, BigDecimal price) {
if (volume == null || price == null) return null;
return volume.multiply(price);
}
}
Loading
Loading