Skip to content
Closed
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
Expand Up @@ -447,6 +447,39 @@ public List<DepositNoticeItemRequest> findDealQuotationItems(long quotationId) {
});
}

/** One line of a new-engine quotation, carrying that quotation's number. */
public record AcceptedDealQuotationLine(String quotationNumber, int seq, String description, BigDecimal qty,
String unit, BigDecimal unitPrice, BigDecimal salesDiscount,
BigDecimal finalUnitPrice, BigDecimal amount) {}

/** The lines of this ticket's newest customer-ACCEPTED new-engine quotation, by {@code seq};
* empty when it has none. Same columns and description/unit fallbacks as
* {@link #findDealQuotationItems}, plus the stored line {@code amount} (what the quotation's
* own subtotal sums), so a remaining invoice sourced from it totals exactly that subtotal. */
public List<AcceptedDealQuotationLine> findAcceptedDealQuotationLines(long ticketId) {
return jdbc.query("""
SELECT q.number, qi.seq, qi.description, qi.qty, qi.raw_unit, qi.unit_price,
qi.sales_discount, qi.final_unit_price, qi.amount
FROM sales.quotation q
JOIN sales.quotation_item qi ON qi.quotation_id = q.quotation_id
WHERE q.quotation_id = (SELECT quotation_id FROM sales.quotation
WHERE ticket_id = :ticketId
AND origin IN ('PRICING_REQUEST', 'DEAL_DIRECT')
AND doc_status = 'ACCEPTED'
ORDER BY quotation_id DESC
LIMIT 1)
ORDER BY qi.seq
""", Map.of("ticketId", ticketId), (rs, i) -> {
String description = rs.getString("description");
String unit = rs.getString("raw_unit");
return new AcceptedDealQuotationLine(rs.getString("number"), rs.getInt("seq"),
description == null || description.isBlank() ? "รายการสินค้า" : description,
rs.getBigDecimal("qty"), unit == null || unit.isBlank() ? "หน่วย" : unit,
rs.getBigDecimal("unit_price"), rs.getBigDecimal("sales_discount"),
rs.getBigDecimal("final_unit_price"), rs.getBigDecimal("amount"));
});
}

private List<DepositNoticeItemDto> findItems(long docId) {
return jdbc.query(
"SELECT * FROM sales.deposit_notice_item WHERE deposit_notice_id = :id ORDER BY seq",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -611,6 +611,9 @@ private List<QualifyingQuotation> findQualifyingQuotations(List<CustomerQuotatio
* <li>else, if an ACCEPTED quotation exists but none qualifies under a deposit-required
* policy (the deposit notice for it was never issued) — blocked, not a silent
* fallback;</li>
* <li>else, under a bypass policy with no ISSUED deposit notice, the newest ACCEPTED
* new-engine ({@code sales.quotation} origin PRICING_REQUEST/DEAL_DIRECT) quotation's
* own lines, referenced by its number, with no deposit deduction;</li>
* <li>else the pre-PCR-chain legacy fallbacks (issued deposit-notice item snapshot, then
* {@code ticket_item.approved_price}) — back-compat for a deal that predates the
* pricing-request chain and never had a CustomerQuotation at all.</li>
Expand Down Expand Up @@ -646,6 +649,17 @@ ResolvedRemainingInvoice resolveRemainingInvoice(long ticketId, TicketDto ticket
if (latestAccepted != null) {
return blocked("ยังไม่ได้ออกใบแจ้งยอดมัดจำจากใบเสนอราคา " + latestAccepted.number());
}
} else if (notices.stream().noneMatch(n -> "ISSUED".equals(n.status()))) {
// quotationCandidates holds legacy rows only, so a new-engine accepted quotation is read here.
List<DepositNoticeRepository.AcceptedDealQuotationLine> lines = docs.findAcceptedDealQuotationLines(ticketId);
if (!lines.isEmpty()) {
List<RemainingInvoiceItemDto> items = lines.stream()
.map(l -> new RemainingInvoiceItemDto(l.seq(), l.description(), l.qty(), l.unit(), l.unitPrice(),
shortDiscountLabel(l.salesDiscount()), l.finalUnitPrice(), l.amount()))
.toList();
return new ResolvedRemainingInvoice(items, BigDecimal.ZERO, lines.get(0).quotationNumber(),
List.of(), null, List.of(), List.of(), null, null, null);
}
}
return resolveLegacy(ticketId, ticket, notices);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,15 @@
import th.co.glr.hr.dealquotation.DealQuotationRequests.RecordOutcomeRequest;
import th.co.glr.hr.deposit.DepositNoticeDraftRequest;
import th.co.glr.hr.deposit.DepositNoticeDto;
import th.co.glr.hr.deposit.DepositNoticeItemRequest;
import th.co.glr.hr.deposit.DepositNoticeRenderer;
import th.co.glr.hr.deposit.DepositNoticeRepository;
import th.co.glr.hr.deposit.DepositNoticeService;
import th.co.glr.hr.deposit.RemainingInvoiceDocumentDto;
import th.co.glr.hr.deposit.RemainingInvoiceOptionsDto;
import th.co.glr.hr.deposit.RemainingInvoiceRenderer;
import th.co.glr.hr.deposit.RemainingInvoiceRepository;
import th.co.glr.hr.deposit.RemainingInvoiceService;
import th.co.glr.hr.employee.EmployeeCodeGenerator;
import th.co.glr.hr.employee.EmployeeReferenceRepository;
import th.co.glr.hr.employee.EmployeeRepository;
Expand Down Expand Up @@ -109,6 +114,7 @@ class DealQuotationOutcomeIntegrationTest extends AbstractPostgresIntegrationTes
private CustomerQuotationService legacyQuotationService;
private OrderConfirmationService orderConfirmation;
private DepositNoticeService depositNoticeService;
private RemainingInvoiceService remainingInvoiceService;
private TicketRepository tickets;
private TicketService ticketService;

Expand Down Expand Up @@ -196,6 +202,8 @@ notifications, new DepositNoticeRenderer(), new RemainingInvoiceRenderer(), cust
// falls back to the PR item's own requestedQty for every test in this file, which would
// make the "quantity edited on the quotation" regression test below pass vacuously.
orderConfirmation.wireDealQuotationRepository(quotationRepository);
remainingInvoiceService = new RemainingInvoiceService(new RemainingInvoiceRepository(jdbc), depositNoticeService,
tickets, new RemainingInvoiceRenderer());

salesRepId = createEmployee(employees, "พนักงานขาย S3", "sales-s3@glr.co.th", "SALES", "แผนกขาย");
otherSalesId = createEmployee(employees, "พนักงานขาย S3 อื่น", "sales-s3-other@glr.co.th", "SALES", "แผนกขาย");
Expand Down Expand Up @@ -408,6 +416,125 @@ void createDepositNoticeFromQuotation_newEngineAccepted_whenASiblingPricingReque
assertThat(draft.depositPercent()).isEqualByComparingTo("0.30");
}

// ── Remaining invoice (ใบแจ้งหนี้ส่วนที่เหลือ) on a deal whose accepted quotation is NEW-engine ──
// The resolver knew only legacy quotations and ticket_item.approved_price (never written by the
// new engine), so a deal whose deposit policy skips the notice had no source and 409'd.

@Test
void remainingInvoice_newEngineAcceptedQuotation_depositWaived_sourcesTheQuotationsLines_andIssues() {
DealQuotationDto accepted = acceptedNewOriginQuotation(PricingRequestRecipient.DESIGNER, new BigDecimal("10"));
orderConfirmation.confirmOrder(accepted.pricingRequestId(),
new OrderConfirmationRequests.ConfirmOrderRequest(UUID.randomUUID().toString()), salesActor);
ticketService.waiveDeposit(ticketId, "CREDIT_CUSTOMER", "ลูกค้าเครดิต", salesActor);
List<String> quotationLines = quotationLines(accepted.id());
assertThat(quotationLines).isNotEmpty();

RemainingInvoiceOptionsDto options = depositNoticeService.getRemainingInvoiceOptions(ticketId, salesActor);
assertThat(options.blockingReason()).isNull();
assertThat(options.itemCount()).isEqualTo(quotationLines.size());
assertThat(options.defaultReference()).isEqualTo(accepted.number());
assertThat(options.itemsTotal()).isEqualByComparingTo(accepted.subtotalAmount());
assertThat(options.depositAmount()).isEqualByComparingTo("0");

RemainingInvoiceDocumentDto draft = remainingInvoiceService.createDraft(ticketId, null, salesActor);
assertThat(draft.items().stream().map(it -> line(it.qty(), it.netUnitPrice())).toList())
.isEqualTo(quotationLines);
assertThat(draft.reference()).isEqualTo(accepted.number());
assertThat(draft.depositDeduction()).isEqualByComparingTo("0");
assertThat(draft.itemsTotal()).isEqualByComparingTo(accepted.subtotalAmount());
assertThat(draft.netAmount()).isEqualByComparingTo(accepted.subtotalAmount());

RemainingInvoiceDocumentDto issued = remainingInvoiceService.issue(draft.id(), salesActor);
assertThat(issued.status()).isEqualTo("ISSUED");
assertThat(issued.itemsTotal()).isEqualByComparingTo(accepted.subtotalAmount());
assertThat(issued.grandTotal()).isEqualByComparingTo(accepted.grandTotal());
}

/** Deposit required: refused until the notice is issued; then the ISSUED notice is the source —
* its (edited) lines, minus the deposit it billed, never the quotation's own lines — and stays so
* once the policy reads as a bypass. */
@Test
void remainingInvoice_newEngineDeal_issuedNotice_linesAndDeductionStillComeFromTheNotice_alsoUnderABypassPolicy() {
DealQuotationDto accepted = acceptedNewOriginQuotation(PricingRequestRecipient.DESIGNER, new BigDecimal("10"), 30);
long pricingRequestId = accepted.pricingRequestId();
orderConfirmation.confirmOrder(pricingRequestId,
new OrderConfirmationRequests.ConfirmOrderRequest(UUID.randomUUID().toString()), salesActor);
// Deposit required and not yet billed: never the quotation's lines with a zero deduction.
assertThatThrownBy(() -> depositNoticeService.getRemainingInvoiceOptions(ticketId, salesActor))
.isInstanceOf(ApiException.class)
.hasFieldOrPropertyWithValue("status", HttpStatus.CONFLICT)
.hasMessageContaining("ไม่พบใบเสนอราคาที่ลูกค้ายอมรับ");
DepositNoticeDto noticeDraft = orderConfirmation.createDepositNoticeFromQuotation(pricingRequestId,
new OrderConfirmationRequests.CreateDepositNoticeFromQuotationRequest(null), salesActor);
List<DepositNoticeItemRequest> editedLines = noticeDraft.items().stream()
.map(it -> new DepositNoticeItemRequest(it.seq(), it.description(), it.qty().subtract(new BigDecimal("4")),
it.unit(), it.unitPrice(), it.discountLabel(), it.netUnitPrice()))
.toList();
depositNoticeService.update(noticeDraft.id(), new DepositNoticeDraftRequest(noticeDraft.customerName(),
noticeDraft.customerTaxId(), noticeDraft.customerAddress(), noticeDraft.projectName(),
noticeDraft.reference(), null, noticeDraft.notes(), editedLines), salesActor);
DepositNoticeDto notice = depositNoticeService.issue(noticeDraft.id(), salesActor);
List<String> noticeLines = notice.items().stream().map(it -> line(it.qty(), it.netUnitPrice())).toList();
// The two sources must differ, or this test cannot tell which one the invoice used.
assertThat(noticeLines).isNotEqualTo(quotationLines(accepted.id()));
assertThat(notice.depositAmount()).isPositive();

RemainingInvoiceDocumentDto draft = remainingInvoiceService.createDraft(ticketId, null, salesActor);

assertThat(draft.items().stream().map(it -> line(it.qty(), it.netUnitPrice())).toList()).isEqualTo(noticeLines);
assertThat(draft.depositNoticeId()).isEqualTo(notice.id());
assertThat(draft.itemsTotal()).isEqualByComparingTo(notice.subtotal());
assertThat(draft.depositDeduction()).isEqualByComparingTo(notice.depositAmount());
assertThat(draft.netAmount()).isEqualByComparingTo(notice.subtotal().subtract(notice.depositAmount()));

// waiveDeposit refuses once a notice is issued (its rule 4), so SQL stands in for a bypass
// policy that arrives some other way: the issued notice must still win over the quotation.
assertThat(jdbc.update("UPDATE sales.ticket SET deposit_policy = 'WAIVED' WHERE ticket_id = :id",
Map.of("id", ticketId))).isEqualTo(1);
RemainingInvoiceOptionsDto options = depositNoticeService.getRemainingInvoiceOptions(ticketId, salesActor);
assertThat(options.itemsTotal()).isEqualByComparingTo(notice.subtotal());
assertThat(options.depositAmount()).isEqualByComparingTo(notice.depositAmount());
}

/** Only a quotation the customer accepted can be billed. No real path un-accepts one, so the row
* is set back to ISSUED by SQL after confirm + waive, isolating the status filter: the
* deposit-notice item picker does take ISSUED. */
@Test
void remainingInvoice_newEngineQuotationOnlyIssued_depositWaived_still409() {
DealQuotationDto accepted = acceptedNewOriginQuotation(PricingRequestRecipient.DESIGNER, new BigDecimal("10"));
orderConfirmation.confirmOrder(accepted.pricingRequestId(),
new OrderConfirmationRequests.ConfirmOrderRequest(UUID.randomUUID().toString()), salesActor);
ticketService.waiveDeposit(ticketId, "CREDIT_CUSTOMER", "ลูกค้าเครดิต", salesActor);
assertThat(jdbc.update("UPDATE sales.quotation SET doc_status = 'ISSUED' WHERE quotation_id = :id",
Map.of("id", accepted.id()))).isEqualTo(1);

assertThatThrownBy(() -> depositNoticeService.getRemainingInvoiceOptions(ticketId, salesActor))
.isInstanceOf(ApiException.class)
.hasFieldOrPropertyWithValue("status", HttpStatus.CONFLICT)
.hasMessageContaining("ไม่พบใบเสนอราคาที่ลูกค้ายอมรับ");
assertThatThrownBy(() -> remainingInvoiceService.createDraft(ticketId, null, salesActor))
.isInstanceOf(ApiException.class)
.hasFieldOrPropertyWithValue("status", HttpStatus.CONFLICT)
.hasMessageContaining("ไม่พบใบเสนอราคาที่ลูกค้ายอมรับ");
assertThat(remainingInvoiceCount()).isZero();
}

@Test
void remainingInvoice_newEngine_nonOwnerSales_refused_writesNothing() {
DealQuotationDto accepted = acceptedNewOriginQuotation(PricingRequestRecipient.DESIGNER, new BigDecimal("10"));
orderConfirmation.confirmOrder(accepted.pricingRequestId(),
new OrderConfirmationRequests.ConfirmOrderRequest(UUID.randomUUID().toString()), salesActor);
ticketService.waiveDeposit(ticketId, "CREDIT_CUSTOMER", "ลูกค้าเครดิต", salesActor);

assertThatThrownBy(() -> depositNoticeService.getRemainingInvoiceOptions(ticketId, otherSalesActor))
.isInstanceOf(ApiException.class)
.hasFieldOrPropertyWithValue("status", HttpStatus.FORBIDDEN);
assertThatThrownBy(() -> remainingInvoiceService.createDraft(ticketId, null, otherSalesActor))
.isInstanceOf(ApiException.class)
.hasFieldOrPropertyWithValue("status", HttpStatus.FORBIDDEN);
assertThat(remainingInvoiceCount()).isZero();
}

@Test
void recordOutcome_byNonOwningSales_refused_writesNothing() {
DealQuotationDto issued = issuedNewOriginQuotation(PricingRequestRecipient.DESIGNER, new BigDecimal("10"));
Expand Down Expand Up @@ -972,6 +1099,16 @@ private static String line(BigDecimal qty, BigDecimal netUnitPrice) {
+ (netUnitPrice == null ? "null" : netUnitPrice.stripTrailingZeros().toPlainString());
}

private List<String> quotationLines(long quotationId) {
return jdbc.query("SELECT qty, final_unit_price FROM sales.quotation_item WHERE quotation_id = :id ORDER BY seq",
Map.of("id", quotationId), (rs, i) -> line(rs.getBigDecimal("qty"), rs.getBigDecimal("final_unit_price")));
}

private long remainingInvoiceCount() {
return jdbc.queryForObject("SELECT COUNT(*) FROM sales.remaining_invoice WHERE ticket_id = :id",
Map.of("id", ticketId), Long.class);
}

private DealQuotationDto acceptedNewOriginQuotation(String recipient, BigDecimal qty) {
return acceptedNewOriginQuotation(recipient, qty, null);
}
Expand Down
Loading