From da839dc73b017a4f18030615bed2724365fdce02 Mon Sep 17 00:00:00 2001 From: waritctd <148909369+waritctd@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:11:14 +0700 Subject: [PATCH 1/3] test(sales): remaining invoice sources a new-engine accepted quotation when the deposit is waived (RED) Co-Authored-By: Claude Opus 5.5 --- .../DealQuotationOutcomeIntegrationTest.java | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) diff --git a/backend/src/test/java/th/co/glr/hr/dealquotation/DealQuotationOutcomeIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/dealquotation/DealQuotationOutcomeIntegrationTest.java index 1815e4db0..b8872f62f 100644 --- a/backend/src/test/java/th/co/glr/hr/dealquotation/DealQuotationOutcomeIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/dealquotation/DealQuotationOutcomeIntegrationTest.java @@ -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; @@ -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; @@ -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", "แผนกขาย"); @@ -408,6 +416,119 @@ 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 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()); + } + + /** An ISSUED notice stays the source: its (edited) lines, minus the deposit it billed, never the + * quotation's own lines — with the deposit required, and still 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); + DepositNoticeDto noticeDraft = orderConfirmation.createDepositNoticeFromQuotation(pricingRequestId, + new OrderConfirmationRequests.CreateDepositNoticeFromQuotationRequest(null), salesActor); + List 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 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")); @@ -972,6 +1093,16 @@ private static String line(BigDecimal qty, BigDecimal netUnitPrice) { + (netUnitPrice == null ? "null" : netUnitPrice.stripTrailingZeros().toPlainString()); } + private List 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); } From c8f479c9e1363b48e073cb796a3920a41d39a037 Mon Sep 17 00:00:00 2001 From: waritctd <148909369+waritctd@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:18:54 +0700 Subject: [PATCH 2/3] fix(sales): remaining invoice sources the accepted new-engine quotation when no deposit notice was issued Co-Authored-By: Claude Opus 5.5 --- .../hr/deposit/DepositNoticeRepository.java | 33 +++++++++++++++++++ .../glr/hr/deposit/DepositNoticeService.java | 14 ++++++++ 2 files changed, 47 insertions(+) diff --git a/backend/src/main/java/th/co/glr/hr/deposit/DepositNoticeRepository.java b/backend/src/main/java/th/co/glr/hr/deposit/DepositNoticeRepository.java index 7fe0ee83b..1177008b9 100644 --- a/backend/src/main/java/th/co/glr/hr/deposit/DepositNoticeRepository.java +++ b/backend/src/main/java/th/co/glr/hr/deposit/DepositNoticeRepository.java @@ -447,6 +447,39 @@ public List 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 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 findItems(long docId) { return jdbc.query( "SELECT * FROM sales.deposit_notice_item WHERE deposit_notice_id = :id ORDER BY seq", diff --git a/backend/src/main/java/th/co/glr/hr/deposit/DepositNoticeService.java b/backend/src/main/java/th/co/glr/hr/deposit/DepositNoticeService.java index da2d1c40a..e38ad50eb 100644 --- a/backend/src/main/java/th/co/glr/hr/deposit/DepositNoticeService.java +++ b/backend/src/main/java/th/co/glr/hr/deposit/DepositNoticeService.java @@ -611,6 +611,9 @@ private List findQualifyingQuotations(Listelse, 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; + *
  • 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;
  • *
  • 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.
  • @@ -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 lines = docs.findAcceptedDealQuotationLines(ticketId); + if (!lines.isEmpty()) { + List 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); } From 8ad1ae62129cd9d53826bc6d6cc89482bc00fae7 Mon Sep 17 00:00:00 2001 From: waritctd <148909369+waritctd@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:37:08 +0700 Subject: [PATCH 3/3] test(sales): pin the deposit-required, no-notice refusal on a new-engine deal's remaining invoice Co-Authored-By: Claude Opus 5.5 --- .../DealQuotationOutcomeIntegrationTest.java | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/backend/src/test/java/th/co/glr/hr/dealquotation/DealQuotationOutcomeIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/dealquotation/DealQuotationOutcomeIntegrationTest.java index b8872f62f..47cdb6d7a 100644 --- a/backend/src/test/java/th/co/glr/hr/dealquotation/DealQuotationOutcomeIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/dealquotation/DealQuotationOutcomeIntegrationTest.java @@ -450,14 +450,20 @@ void remainingInvoice_newEngineAcceptedQuotation_depositWaived_sourcesTheQuotati assertThat(issued.grandTotal()).isEqualByComparingTo(accepted.grandTotal()); } - /** An ISSUED notice stays the source: its (edited) lines, minus the deposit it billed, never the - * quotation's own lines — with the deposit required, and still once the policy reads as a bypass. */ + /** 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 editedLines = noticeDraft.items().stream()