From e3db815cd594f3e9a18d85ba6e2fbe75956e0ae8 Mon Sep 17 00:00:00 2001
From: waritctd <148909369+waritctd@users.noreply.github.com>
Date: Wed, 28 Oct 2026 21:17:10 +0700
Subject: [PATCH 01/10] =?UTF-8?q?fix(sales):=20the=20UI-walk=20P0s=20?=
=?UTF-8?q?=E2=80=94=20VAT-inclusive=20deposit=20receipt,=20commission=20g?=
=?UTF-8?q?ross,=20direct-quote=20payable,=20stock=20flag=20at=20PCR,=20no?=
=?UTF-8?q?=20rewind=20below=20=E0=B8=82=E0=B8=B1=E0=B9=89=E0=B8=99=2010?=
=?UTF-8?q?=20(owner=20rulings=202026-10-28)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- QA-01 confirmDepositPaid books the notice's VAT-inclusive totalPayable.
- QA-02 commission gross pre-fill/compare/default = VAT-inclusive payable, labelled ยอดรับจริง (รวม VAT); CommissionCalculator unchanged (matches the owner's workbooks).
- QA-03 a confirmed DEAL_DIRECT quotation counts toward payable; ACCEPTED > APPROVED in all three payable queries; a confirmed direct quotation can't be revised, cloned, or superseded by another direct quotation.
- QA-04 pricing-request lines carry จากสต็อก (stockSource IN_THAILAND) seeded from the deal; stock lines skip ประเทศต้นทาง; CEO types the price.
- QA-05 ⋯ แก้ไขสถานะ can't move a deal at/after ORDER_RECEIVED below it (409, not offered).
- SK-02 ยืนยันคำสั่งซื้อ always asks first.
- SK-07 each tax invoice keeps round2(net x 7%) VAT; once a remaining invoice is issued, outstanding and the final receipt follow the issued documents; the remaining-invoice workbook prints stored VAT/total.
Co-Authored-By: Claude Opus 5.5 Owner confirmation needed: the exact Excel formula for how these two columns
- * combine with the others could not be extracted — LibreOffice dropped the cell formulas on
- * conversion, leaving only static values behind. This sign convention (WHT subtracted,
- * overpayment added, both at this stage, before the VAT strip) is a best-effort mirror of the
- * columns' evident intent, not a verified transcription of the source formula. The
- * sales-manager review step ({@link CommissionService#updateDeductions}) is the safety net for
- * this slice — flag this for the owner to confirm against the Excel/accounting team before
- * relying on it unreviewed.
+ * Matches the owner's commission workbooks (L = D-E-F-G-H-I-J+K, the amount received incl. VAT;
+ * M = L/1.07).
*
* Backward compatible: when {@code withholdingTax} and {@code overpayment} are both zero
* (or null), the result is identical to the pre-Slice-A1 five-column formula.
diff --git a/backend/src/main/java/th/co/glr/hr/commission/CommissionService.java b/backend/src/main/java/th/co/glr/hr/commission/CommissionService.java
index a1674dbd2..6e9f5c392 100644
--- a/backend/src/main/java/th/co/glr/hr/commission/CommissionService.java
+++ b/backend/src/main/java/th/co/glr/hr/commission/CommissionService.java
@@ -266,7 +266,7 @@ public CommissionRecord createFromDeal(
if (commissions.hasActiveCommissionForTicket(ticketId)) {
throw new ApiException(HttpStatus.CONFLICT, "มีรายการค่าคอมมิชชั่นสำหรับดีลนี้อยู่แล้ว");
}
- BigDecimal effectiveGrossAmount = grossAmount != null ? grossAmount : tickets.payableAmountExVat(ticketId);
+ BigDecimal effectiveGrossAmount = grossAmount != null ? grossAmount : tickets.payableAmount(ticketId);
SubmitCommissionRequest request = new SubmitCommissionRequest(
ticketId,
@@ -386,7 +386,7 @@ private DealLinkage resolveDealLinkage(SubmitCommissionRequest request) {
throw new ApiException(HttpStatus.UNPROCESSABLE_CONTENT,
"ดีลนี้ยังไม่ถึงขั้นตอนรับชำระเงินครบถ้วน (CLOSED_PAID) จึงยังยื่นค่าคอมมิชชั่นไม่ได้");
}
- BigDecimal payable = tickets.payableAmountExVat(ticketId);
+ BigDecimal payable = tickets.payableAmount(ticketId);
boolean mismatch = isMismatch(request.grossAmount(), payable);
return new DealLinkage(payable, mismatch);
}
diff --git a/backend/src/main/java/th/co/glr/hr/dealquotation/DealQuotationService.java b/backend/src/main/java/th/co/glr/hr/dealquotation/DealQuotationService.java
index cfc5f9f97..b6eeec7dd 100644
--- a/backend/src/main/java/th/co/glr/hr/dealquotation/DealQuotationService.java
+++ b/backend/src/main/java/th/co/glr/hr/dealquotation/DealQuotationService.java
@@ -1394,6 +1394,7 @@ public DealQuotationDto submit(long id, UserPrincipal actor) {
// was loaded above may be what the change we just waited for rewrote.
quotations.lockTicket(found.ticketId());
DealQuotationDto quotation = requireQuotation(id);
+ requireNoOtherDirectQuotationAfterConfirm(quotation);
// GLA-123 slice S2: submit is now LIVE for BOTH origins — every validation below (item
// completeness, payment term, validity date) is origin-agnostic and applies unchanged;
// only approve/reject/issue diverge (see #approve/#reject's own origin branch).
@@ -1651,6 +1652,7 @@ public DealQuotationDto approve(long id, ApproveRequest request, UserPrincipal a
// directly) -- a transaction here never holds a second ticket's lock at the same time, so
// this cannot deadlock against either of them.
quotations.lockTicket(quotation.ticketId());
+ requireNoOtherDirectQuotationAfterConfirm(quotation); // under the lock: confirm-order takes it too
LocalDate approvalDate = LocalDate.now(BANGKOK);
// V178: a DATE-mode document WITH special pricing names its OWN validity_date (the rep's
// ภายในวันที่, verbatim — approval never recomputes it); every other case (DAYS mode, or a
@@ -1711,16 +1713,6 @@ public DealQuotationDto approve(long id, ApproveRequest request, UserPrincipal a
quotations.supersede(ancestorId);
ancestorId = ancestor.parentQuotationId();
}
- // #1173 review F1: on a confirmed deal the system-owned deposit policy follows the newly
- // approved quotation, so a revision that adds or drops the deposit moves the deal with it.
- // A no-op before confirmation (confirmOrderFromDirectQuotation applies the confirmed
- // quotation's percent then). Null-tolerant for hand-wired tests, like
- // #advanceDealToRecipientStage. PRICING_REQUEST origin never reaches this: R8 allows one
- // accepted quotation per deal, and its percent is applied at OrderConfirmationService#confirmOrder.
- if (ticketService != null) {
- ticketService.followQuotationDepositPolicy(approved.ticketId(), approved.number(),
- DealQuotationRenderAdapter.effectiveDepositPercent(approved.depositPercent()), actor);
- }
tickets.addEventWithDocument(approved.ticketId(), actor.id(), actor.name(), TicketEventKind.QUOTATION_ISSUED,
null, null, "อนุมัติใบเสนอราคา " + approved.number(), RelatedDocumentType.QUOTATION, id);
String message = "ใบเสนอราคา " + approved.number() + " ได้รับอนุมัติแล้ว";
@@ -2399,6 +2391,9 @@ public DealQuotationDto createReorder(long id, UserPrincipal actor) {
// GLA-123 slice S1, defence in depth — see #createRevision's identical comment.
requireReorderAllowedForOrigin(source);
requireApprovedForClone(source, "สร้างจากใบเดิมได้จากใบเสนอราคาที่ได้รับอนุมัติแล้วเท่านั้น");
+ if (tickets.isOrderConfirmedFromQuotation(source.ticketId(), source.id())) {
+ throw new ApiException(HttpStatus.CONFLICT, "ใบเสนอราคานี้ยืนยันคำสั่งซื้อแล้ว จึงสร้างจากใบเดิมไม่ได้");
+ }
// No requireNoOpenRevision here (deliberately) — multiple clones of the same APPROVED
// source are allowed, and a clone never contends with an in-progress revision for anything.
// Still locks the ticket, same advisory lock #requireNoOpenRevision/#createRevision use, so
@@ -2572,6 +2567,21 @@ private DealQuotationDto requireApprovedForClone(DealQuotationDto quotation, Str
return quotation;
}
+ /**
+ * Owner ruling 2026-10-28: once an order is confirmed from a direct quotation, that quotation is the agreed
+ * order. No OTHER direct quotation on the deal may be submitted or approved (approval would supersede the
+ * confirmed one, move the payable and flip the deposit policy), and the confirmed one may not be cloned.
+ */
+ private void requireNoOtherDirectQuotationAfterConfirm(DealQuotationDto quotation) {
+ if (isPricingRequestOrigin(quotation)
+ || !tickets.hasPromotionFromQuotationEvent(quotation.ticketId())
+ || tickets.isOrderConfirmedFromQuotation(quotation.ticketId(), quotation.id())) {
+ return;
+ }
+ throw new ApiException(HttpStatus.CONFLICT,
+ "ดีลนี้ยืนยันคำสั่งซื้อจากใบเสนอราคาอื่นแล้ว จึงส่ง/อนุมัติใบเสนอราคานี้ไม่ได้");
+ }
+
private static boolean isPricingRequestOrigin(DealQuotationDto quotation) {
return "PRICING_REQUEST".equals(quotation.origin());
}
@@ -2590,6 +2600,11 @@ private DealQuotationDto requireRevisableStatus(DealQuotationDto quotation) {
}
return quotation;
}
+ // Owner ruling 2026-10-28: the quotation an order was confirmed from is the agreed order -- not revisable.
+ if (tickets.isOrderConfirmedFromQuotation(quotation.ticketId(), quotation.id())) {
+ throw new ApiException(HttpStatus.CONFLICT,
+ "ใบเสนอราคานี้ยืนยันคำสั่งซื้อแล้ว จึงสร้างฉบับแก้ไขไม่ได้");
+ }
return requireApprovedForClone(quotation, "สร้างฉบับแก้ไขได้จากใบเสนอราคาที่ได้รับอนุมัติแล้วเท่านั้น");
}
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 84afd8e65..050acf73d 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
@@ -456,6 +456,8 @@ record RemainingInvoiceMoney(BigDecimal netAmount, BigDecimal vatAmount, BigDeci
RemainingInvoiceMoney computeRemainingInvoiceMoney(BigDecimal itemsTotal, BigDecimal depositAmount) {
BigDecimal netAmount = itemsTotal.subtract(depositAmount).setScale(2, RoundingMode.HALF_UP);
+ // Owner ruling 2026-10-28 (SK-07): each tax invoice's VAT = round2(its own net x 7%) -- usual Thai practice;
+ // a satang gap against the deal's payable is normal and outstanding follows the ISSUED documents.
BigDecimal vatAmount = netAmount.multiply(VAT_RATE).setScale(2, RoundingMode.HALF_UP);
BigDecimal grandTotal = netAmount.add(vatAmount).setScale(2, RoundingMode.HALF_UP);
return new RemainingInvoiceMoney(netAmount, vatAmount, grandTotal);
diff --git a/backend/src/main/java/th/co/glr/hr/deposit/RemainingInvoiceDto.java b/backend/src/main/java/th/co/glr/hr/deposit/RemainingInvoiceDto.java
index 0840bf352..fc8f02c34 100644
--- a/backend/src/main/java/th/co/glr/hr/deposit/RemainingInvoiceDto.java
+++ b/backend/src/main/java/th/co/glr/hr/deposit/RemainingInvoiceDto.java
@@ -27,5 +27,18 @@ public record RemainingInvoiceDto(
String projectName,
BigDecimal depositAmount,
List A policy a person chose ({@link #waiveDeposit}: WAIVED, CREDIT_CUSTOMER, or a typed
* NOT_REQUIRED) is never touched, and nothing moves once a deposit notice is out. Both are
@@ -1029,8 +1028,10 @@ public TicketDto confirmDepositPaid(long ticketId, UserPrincipal actor) {
throw new ApiException(HttpStatus.CONFLICT, "ต้องออกใบแจ้งรับมัดจำก่อนจึงจะยืนยันรับชำระมัดจำได้");
}
var notice = tickets.latestIssuedDepositNotice(ticketId).orElse(null);
- BigDecimal amount = notice != null && notice.depositAmount() != null
- ? notice.depositAmount()
+ // QA-01: the deal payable is VAT-inclusive, so the receipt is the notice's total_payable
+ // (deposit + VAT), not the pre-VAT deposit_amount.
+ BigDecimal amount = notice != null && notice.totalPayable() != null
+ ? notice.totalPayable()
: payableAmount(ticket).multiply(new BigDecimal("0.50"));
if (amount.signum() <= 0) {
throw new ApiException(HttpStatus.CONFLICT,
@@ -2126,6 +2127,13 @@ private void requireStageMoveAllowed(TicketSummaryDto s, String targetStage, Use
if (targetStage.equals(s.salesStage())) {
throw new ApiException(HttpStatus.CONFLICT, "ดีลนี้อยู่ในขั้นตอน " + targetStage + " อยู่แล้ว");
}
+ // QA-05 (owner ruling 2026-10-28): once the order is confirmed (the deal is at or past ขั้น 10) a manual
+ // move may not rewind below ORDER_RECEIVED. Keyed on the CURRENT stage only: no repository call here.
+ if (DealStage.indexOf(s.salesStage()) >= DealStage.indexOf(DealStage.ORDER_RECEIVED)
+ && DealStage.indexOf(targetStage) < DealStage.indexOf(DealStage.ORDER_RECEIVED)) {
+ throw new ApiException(HttpStatus.CONFLICT,
+ "ย้อนสถานะกลับก่อนขั้นรับคำสั่งซื้อไม่ได้ — ยืนยันคำสั่งซื้อไปแล้ว");
+ }
// Rung 6 of the precedence ladder (design §12.1): the deal's ENTRY-CHANNEL route. Checked
// BEFORE the facts because an off-route target's actionable remedy is the channel, not the
// fact. Identical outcome for every reachable input: only S4–S8 can be off-route
diff --git a/backend/src/test/java/th/co/glr/hr/commission/CommissionAutoCreateIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/commission/CommissionAutoCreateIntegrationTest.java
index d3a9f196f..b70fad149 100644
--- a/backend/src/test/java/th/co/glr/hr/commission/CommissionAutoCreateIntegrationTest.java
+++ b/backend/src/test/java/th/co/glr/hr/commission/CommissionAutoCreateIntegrationTest.java
@@ -317,7 +317,7 @@ void salesCannotApprove_evenAManagerApprovedCommission() {
@Test
void createFromDeal_omittedGrossAmount_defaultsFromDealPayable() {
long ticketId = driveDealToClosedPaid(new BigDecimal("10"));
- BigDecimal payable = tickets.payableAmountExVat(ticketId);
+ BigDecimal payable = tickets.payableAmount(ticketId); // QA-02: VAT-inclusive (the calculator divides by 1.07 itself)
assertThat(payable.signum()).isPositive();
CommissionRecord created = commissionService.createFromDeal(
@@ -331,13 +331,12 @@ void createFromDeal_omittedGrossAmount_defaultsFromDealPayable() {
}
/**
- * OWNER RULING 2026-09-30: the deal's PAYABLE is now the VAT-inclusive grand total, but commission is
- * computed on the PRE-VAT figure it always got. This pins the default gross amount: it equals the ex-VAT
- * quotation total, and is strictly LESS than the (VAT-inclusive) payable the customer is billed.
- * Mutation check: point CommissionService at tickets.payableAmount(...) and this goes red.
+ * QA-02 (owner ruling 2026-10-28; supersedes the 2026-09-30 pre-VAT default): the commission workbooks take
+ * the amount received INCLUDING VAT and CommissionCalculator.calculateInvoice divides by 1.07, so the
+ * default gross is the VAT-inclusive payable, strictly MORE than the ex-VAT quotation total.
*/
@Test
- void createFromDeal_omittedGrossAmount_staysPreVat_whilePayableIsVatInclusive() {
+ void createFromDeal_omittedGrossAmount_isVatInclusive_equalToPayable() {
long ticketId = driveDealToClosedPaid(new BigDecimal("10"));
BigDecimal exVat = tickets.payableAmountExVat(ticketId);
BigDecimal inclVat = tickets.payableAmount(ticketId);
@@ -348,16 +347,15 @@ void createFromDeal_omittedGrossAmount_staysPreVat_whilePayableIsVatInclusive()
invoiceFile(), accountActor);
assertThat(inclVat).isGreaterThan(exVat);
- assertThat(inclVat).isEqualByComparingTo(exVat.multiply(new BigDecimal("1.07")).setScale(2, java.math.RoundingMode.HALF_UP));
- assertThat(created.invoiceDetails().grossAmount()).isEqualByComparingTo(exVat);
- assertThat(created.invoiceDetails().grossAmount()).isNotEqualByComparingTo(inclVat);
+ assertThat(created.invoiceDetails().grossAmount()).isEqualByComparingTo(inclVat);
+ assertThat(created.invoiceDetails().grossAmount()).isNotEqualByComparingTo(exVat);
assertThat(created.dealAmountMismatch()).isFalse();
}
@Test
void createFromDeal_providedGrossAmount_isUsedAsGiven_insteadOfDealPayable() {
long ticketId = driveDealToClosedPaid(new BigDecimal("10"));
- BigDecimal payable = tickets.payableAmountExVat(ticketId);
+ BigDecimal payable = tickets.payableAmount(ticketId);
BigDecimal override = payable.add(new BigDecimal("50000.00")); // deliberately different + beyond threshold
CommissionRecord created = commissionService.createFromDeal(
diff --git a/backend/src/test/java/th/co/glr/hr/commission/CommissionDealLinkageIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/commission/CommissionDealLinkageIntegrationTest.java
index 82de6f2ed..d81b6cd30 100644
--- a/backend/src/test/java/th/co/glr/hr/commission/CommissionDealLinkageIntegrationTest.java
+++ b/backend/src/test/java/th/co/glr/hr/commission/CommissionDealLinkageIntegrationTest.java
@@ -249,7 +249,7 @@ void submit_rejectsWhenLinkedDealHasNotReachedClosedPaid_andCreatesNoRecord() {
@Test
void submit_withGrossAmountWithinThreshold_succeedsWithNoMismatchFlag() {
long ticketId = driveDealToClosedPaid(new BigDecimal("10"));
- BigDecimal payable = tickets.payableAmountExVat(ticketId);
+ BigDecimal payable = tickets.payableAmount(ticketId); // QA-02: VAT-inclusive
assertThat(payable.signum()).isPositive();
// 2% above payable — within the 5% cross-check threshold.
BigDecimal grossAmount = payable.multiply(new BigDecimal("1.02")).setScale(2, java.math.RoundingMode.HALF_UP);
@@ -266,6 +266,21 @@ void submit_withGrossAmountWithinThreshold_succeedsWithNoMismatchFlag() {
assertThat(reloaded.dealPayableAmountSnapshot()).isEqualByComparingTo(payable);
}
+ /** QA-02: a gross 7% below the VAT-inclusive payable (i.e. the ex-VAT figure) IS a mismatch; equal is not. */
+ @Test
+ void submit_withGrossEqualToVatInclusivePayable_isNoMismatch_andExVatGrossIsFlagged() {
+ long ticketId = driveDealToClosedPaid(new BigDecimal("10"));
+ BigDecimal payable = tickets.payableAmount(ticketId);
+ CommissionRecord equal = commissionService.submit(submitRequestLinkedTo(ticketId, payable), invoiceFile(), accountActor);
+ assertThat(equal.dealAmountMismatch()).isFalse();
+ assertThat(equal.dealPayableAmountSnapshot()).isEqualByComparingTo(payable);
+
+ long other = driveDealToClosedPaid(new BigDecimal("10"));
+ BigDecimal below = tickets.payableAmount(other).multiply(new BigDecimal("0.93")).setScale(2, java.math.RoundingMode.HALF_UP);
+ assertThat(commissionService.submit(submitRequestLinkedTo(other, below), invoiceFile(), accountActor)
+ .dealAmountMismatch()).isTrue();
+ }
+
// ─────────────────────────────────────────────────────────────────────────────────────
// (4) Cross-check beyond threshold: NOT blocked, but flagged for reviewers.
// ─────────────────────────────────────────────────────────────────────────────────────
@@ -273,7 +288,7 @@ void submit_withGrossAmountWithinThreshold_succeedsWithNoMismatchFlag() {
@Test
void submit_withGrossAmountBeyondThreshold_succeedsButFlagsMismatch() {
long ticketId = driveDealToClosedPaid(new BigDecimal("10"));
- BigDecimal payable = tickets.payableAmountExVat(ticketId);
+ BigDecimal payable = tickets.payableAmount(ticketId); // QA-02: VAT-inclusive
assertThat(payable.signum()).isPositive();
// 25% above payable — well beyond the 5% threshold.
BigDecimal grossAmount = payable.multiply(new BigDecimal("1.25")).setScale(2, java.math.RoundingMode.HALF_UP);
diff --git a/backend/src/test/java/th/co/glr/hr/commission/CommissionServiceTest.java b/backend/src/test/java/th/co/glr/hr/commission/CommissionServiceTest.java
index 193e5b25d..70468595d 100644
--- a/backend/src/test/java/th/co/glr/hr/commission/CommissionServiceTest.java
+++ b/backend/src/test/java/th/co/glr/hr/commission/CommissionServiceTest.java
@@ -166,7 +166,7 @@ void submitWithLinkedTicketNotClosedPaid_rejectsWithUnprocessableEntity() {
void submitWithLinkedClosedPaidTicket_withinThreshold_succeedsAndRecordsNoMismatch() {
SubmitCommissionRequest request = submitRequestLinkedTo(42L, new BigDecimal("1000.00"));
when(tickets.findSalesStage(42L)).thenReturn(Optional.of(DealStage.CLOSED_PAID));
- when(tickets.payableAmountExVat(42L)).thenReturn(new BigDecimal("1030.00")); // 2.9% off — within 5%
+ when(tickets.payableAmount(42L)).thenReturn(new BigDecimal("1030.00")); // 2.9% off — within 5%
InvoiceCalculation calculation = new InvoiceCalculation(new BigDecimal("1000.00"), new BigDecimal("1000.00"));
when(calculator.calculateInvoice(any(), any(), any(), any(), any(), any(), any(), any())).thenReturn(calculation);
when(commissions.createInvoice(any(SubmitCommissionRequest.class))).thenReturn(500L);
@@ -192,7 +192,7 @@ void submitWithLinkedClosedPaidTicket_withinThreshold_succeedsAndRecordsNoMismat
void submitWithLinkedClosedPaidTicket_beyondThreshold_stillSucceedsButFlagsMismatch() {
SubmitCommissionRequest request = submitRequestLinkedTo(42L, new BigDecimal("1000.00"));
when(tickets.findSalesStage(42L)).thenReturn(Optional.of(DealStage.CLOSED_PAID));
- when(tickets.payableAmountExVat(42L)).thenReturn(new BigDecimal("1200.00")); // 16.7% off — beyond 5%
+ when(tickets.payableAmount(42L)).thenReturn(new BigDecimal("1200.00")); // 16.7% off — beyond 5%
InvoiceCalculation calculation = new InvoiceCalculation(new BigDecimal("1000.00"), new BigDecimal("1000.00"));
when(calculator.calculateInvoice(any(), any(), any(), any(), any(), any(), any(), any())).thenReturn(calculation);
when(commissions.createInvoice(any(SubmitCommissionRequest.class))).thenReturn(500L);
@@ -304,7 +304,7 @@ void createFromDeal_resolvesSalesRepFromTicketOwner_neverFromActor() {
when(tickets.findById(ticketId)).thenReturn(Optional.of(ticketWithOwner(ticketId, ownerId)));
when(commissions.hasActiveCommissionForTicket(ticketId)).thenReturn(false);
when(tickets.findSalesStage(ticketId)).thenReturn(Optional.of(DealStage.CLOSED_PAID));
- when(tickets.payableAmountExVat(ticketId)).thenReturn(new BigDecimal("1000.00"));
+ when(tickets.payableAmount(ticketId)).thenReturn(new BigDecimal("1000.00"));
InvoiceCalculation calculation = new InvoiceCalculation(new BigDecimal("1000.00"), new BigDecimal("1000.00"));
when(calculator.calculateInvoice(any(), any(), any(), any(), any(), any(), any(), any())).thenReturn(calculation);
when(commissions.createInvoice(any(SubmitCommissionRequest.class))).thenReturn(500L);
@@ -335,7 +335,7 @@ void createFromDeal_defaultsGrossAmountFromDealPayable_whenOmitted() {
when(tickets.findById(ticketId)).thenReturn(Optional.of(ticketWithOwner(ticketId, 30L)));
when(commissions.hasActiveCommissionForTicket(ticketId)).thenReturn(false);
when(tickets.findSalesStage(ticketId)).thenReturn(Optional.of(DealStage.CLOSED_PAID));
- when(tickets.payableAmountExVat(ticketId)).thenReturn(new BigDecimal("2500.00"));
+ when(tickets.payableAmount(ticketId)).thenReturn(new BigDecimal("2500.00"));
InvoiceCalculation calculation = new InvoiceCalculation(new BigDecimal("2500.00"), new BigDecimal("2500.00"));
when(calculator.calculateInvoice(eq(new BigDecimal("2500.00")), any(), any(), any(), any(), any(), any(), any()))
.thenReturn(calculation);
@@ -363,7 +363,7 @@ void createFromDeal_usesProvidedGrossAmount_whenGiven_insteadOfDealPayable() {
when(commissions.hasActiveCommissionForTicket(ticketId)).thenReturn(false);
when(tickets.findSalesStage(ticketId)).thenReturn(Optional.of(DealStage.CLOSED_PAID));
// Deliberately different from the payable amount, to prove the override wins.
- when(tickets.payableAmountExVat(ticketId)).thenReturn(new BigDecimal("2500.00"));
+ when(tickets.payableAmount(ticketId)).thenReturn(new BigDecimal("2500.00"));
InvoiceCalculation calculation = new InvoiceCalculation(new BigDecimal("9999.00"), new BigDecimal("9999.00"));
when(calculator.calculateInvoice(eq(new BigDecimal("9999.00")), any(), any(), any(), any(), any(), any(), any()))
.thenReturn(calculation);
@@ -423,7 +423,7 @@ void createFromDeal_shiftsPayrollMonthToTheMonthAfterInvoiceDate_flag10() {
when(tickets.findById(ticketId)).thenReturn(Optional.of(ticketWithOwner(ticketId, 30L)));
when(commissions.hasActiveCommissionForTicket(ticketId)).thenReturn(false);
when(tickets.findSalesStage(ticketId)).thenReturn(Optional.of(DealStage.CLOSED_PAID));
- when(tickets.payableAmountExVat(ticketId)).thenReturn(new BigDecimal("1000.00"));
+ when(tickets.payableAmount(ticketId)).thenReturn(new BigDecimal("1000.00"));
InvoiceCalculation calculation = new InvoiceCalculation(new BigDecimal("1000.00"), new BigDecimal("1000.00"));
when(calculator.calculateInvoice(any(), any(), any(), any(), any(), any(), any(), any())).thenReturn(calculation);
when(commissions.createInvoice(any(SubmitCommissionRequest.class))).thenReturn(500L);
diff --git a/backend/src/test/java/th/co/glr/hr/dealquotation/DealQuotationConfirmOrderIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/dealquotation/DealQuotationConfirmOrderIntegrationTest.java
index 9a5c6e4a6..542e2f518 100644
--- a/backend/src/test/java/th/co/glr/hr/dealquotation/DealQuotationConfirmOrderIntegrationTest.java
+++ b/backend/src/test/java/th/co/glr/hr/dealquotation/DealQuotationConfirmOrderIntegrationTest.java
@@ -407,100 +407,92 @@ void zeroDepositDirectQuotation_confirmsOntoTheNoDepositPath_andFinalPaymentIsNo
}
/**
- * PR #1173 review F1: a confirmed 0% deal whose approved revision adds a deposit used to be
- * stranded (NOT_REQUIRED kept, the draft 409'd, waiveDeposit refuses REQUIRED). While the
- * payment track is at CUSTOMER_CONFIRMED a policy the system set follows the newly approved
- * quotation.
+ * Owner ruling 2026-10-28: once the order is confirmed from a direct quotation, THAT quotation can no longer
+ * be revised (a revision's approval would supersede the agreed order and move the payable). Replaces the five
+ * #1173 tests that revised a confirmed quotation (their policy-sync scenarios are no longer reachable).
*/
@Test
- void zeroDepositConfirmed_thenARevisionAddsADeposit_policyFollowsBackToRequired_andTheDraftBillsIt() {
+ void confirmedDirectQuotation_cannotBeRevised_409_andNoRowIsWritten() {
long deal = pipelineTicket(salesActor);
- DealQuotationDto zero = approvedQuotation(deal, zeroDepositDirectDraft());
- confirmer.confirmOrderFromDirectQuotation(zero.id(), salesActor);
- assertThat(summary(deal).depositPolicy()).isEqualTo("NOT_REQUIRED");
+ DealQuotationDto fifty = approvedDirectQuotation(deal, salesActor);
+ confirmer.confirmOrderFromDirectQuotation(fifty.id(), salesActor);
+ int rowsBefore = quotationRows(deal);
- DealQuotationDto revision = approvedRevision(zero, directDraft());
+ assertConflict(() -> quotationService.createRevision(fifty.id(), salesActor), "ยืนยันคำสั่งซื้อแล้ว");
- TicketSummaryDto after = summary(deal);
- assertThat(after.depositPolicy()).isEqualTo("REQUIRED");
- assertThat(after.depositPolicyReason()).isEqualTo("ใบเสนอราคา " + revision.number() + " ระบุมัดจำ 50%");
- assertThat(after.paymentStatus()).isEqualTo("CUSTOMER_CONFIRMED");
- assertThat(policyChangedMessages(deal)).hasSize(2)
- .last().asString().contains("REQUIRED").contains(revision.number());
- DepositNoticeDto draft = depositNoticeService.createDraft(deal,
- new DepositNoticeDraftRequest(null, null, null, null, null, null, null, null), salesActor);
- assertThat(draft.depositPercent()).isEqualByComparingTo("0.50");
+ assertThat(quotationRows(deal)).isEqualTo(rowsBefore);
+ assertThat(quotationService.get(fifty.id(), salesActor).docStatus()).isEqualTo(QuotationStatus.APPROVED);
}
- /** Owner ruling 2026-10-23: a blank new-engine percent means 30 (DealQuotationRenderAdapter's PDF
- * default), so a 0% deal whose approved revision leaves the percent blank is back on the deposit path. */
+ /**
+ * Round 3: the two leaks around the revise gate. After the order is confirmed from v1, no OTHER direct
+ * quotation on the deal may be created from it (clone), submitted, or approved -- approving one would supersede
+ * the confirmed v1, move the payable and flip the deposit policy. v1 stays APPROVED, nothing is written.
+ */
@Test
- void zeroDepositConfirmed_thenARevisionWithABlankPercent_countsAsThirty_andIsRequired() {
+ void afterConfirm_aCloneOfTheConfirmedQuotation_isRefused() {
long deal = pipelineTicket(salesActor);
- DealQuotationDto zero = approvedQuotation(deal, zeroDepositDirectDraft());
- confirmer.confirmOrderFromDirectQuotation(zero.id(), salesActor);
- assertThat(summary(deal).depositPolicy()).isEqualTo("NOT_REQUIRED");
+ DealQuotationDto v1 = approvedDirectQuotation(deal, salesActor);
+ confirmer.confirmOrderFromDirectQuotation(v1.id(), salesActor);
+ int rows = quotationRows(deal);
- DealQuotationDto revision = approvedRevision(zero, blankDepositDirectDraft());
+ assertConflict(() -> quotationService.createReorder(v1.id(), salesActor), "ยืนยันคำสั่งซื้อแล้ว");
- assertThat(revision.depositPercent()).isNull();
- TicketSummaryDto after = summary(deal);
- assertThat(after.depositPolicy()).isEqualTo("REQUIRED");
- assertThat(after.depositPolicyReason()).isEqualTo("ใบเสนอราคา " + revision.number() + " ระบุมัดจำ 30%");
+ assertThat(quotationRows(deal)).isEqualTo(rows);
+ assertStillTheAgreedOrder(deal, v1);
}
- /** The other direction: a confirmed 50% deal whose approved revision says ไม่รับมัดจำ leaves
- * the deposit path (this was the PR's "a later revision to 0% does not sync" known risk). */
@Test
- void depositConfirmed_thenARevisionDropsTheDeposit_policyFollowsToNotRequired() {
+ void afterConfirm_aCloneOrRevisionDraftOpenedBeforeConfirm_cannotBeSubmitted() {
long deal = pipelineTicket(salesActor);
- DealQuotationDto fifty = approvedDirectQuotation(deal, salesActor);
- confirmer.confirmOrderFromDirectQuotation(fifty.id(), salesActor);
- assertThat(summary(deal).depositPolicy()).isEqualTo("REQUIRED");
+ DealQuotationDto v1 = approvedDirectQuotation(deal, salesActor);
+ DealQuotationDto clone = quotationService.createReorder(v1.id(), salesActor);
+ DealQuotationDto revisionDraft = quotationService.createRevision(v1.id(), salesActor);
+ confirmer.confirmOrderFromDirectQuotation(v1.id(), salesActor);
- DealQuotationDto revision = approvedRevision(fifty, zeroDepositDirectDraft());
+ assertConflict(() -> quotationService.submit(clone.id(), salesActor), "ยืนยันคำสั่งซื้อจากใบเสนอราคาอื่นแล้ว");
+ assertConflict(() -> quotationService.submit(revisionDraft.id(), salesActor), "ยืนยันคำสั่งซื้อจากใบเสนอราคาอื่นแล้ว");
- TicketSummaryDto after = summary(deal);
- assertThat(after.depositPolicy()).isEqualTo("NOT_REQUIRED");
- assertThat(after.depositPolicyReason())
- .isEqualTo("ใบเสนอราคา " + revision.number() + " ระบุไม่รับมัดจำ (ชำระเต็มจำนวน)");
- assertThat(policyChangedMessages(deal)).hasSize(1);
- assertConflict(() -> depositNoticeService.createDraft(deal,
- new DepositNoticeDraftRequest(null, null, null, null, null, null, null, null), salesActor), "ไม่ต้องเก็บมัดจำ");
+ assertStillTheAgreedOrder(deal, v1);
}
- /** Wrong-way-round: a policy a person chose (credit customer, typed reason) is never undone by
- * a revision, and no POLICY_CHANGED is written for the attempt. */
@Test
- void humanCreditCustomer_isKept_whenARevisionAddsADeposit() {
+ void afterConfirm_aRevisionSubmittedBeforeConfirm_cannotBeApproved() {
long deal = pipelineTicket(salesActor);
- DealQuotationDto zero = approvedQuotation(deal, zeroDepositDirectDraft());
- confirmer.confirmOrderFromDirectQuotation(zero.id(), salesActor);
- ticketService.waiveDeposit(deal, "CREDIT_CUSTOMER", "ลูกค้าเครดิต 30 วัน", salesActor);
+ DealQuotationDto v1 = approvedDirectQuotation(deal, salesActor);
+ DealQuotationDto revision = quotationService.createRevision(v1.id(), salesActor);
+ quotationService.update(revision.id(), zeroDepositDirectDraft(), salesActor);
+ quotationService.submit(revision.id(), salesActor);
+ confirmer.confirmOrderFromDirectQuotation(v1.id(), salesActor);
- approvedRevision(zero, directDraft());
+ assertConflict(() -> quotationService.approve(revision.id(), new ApproveRequest(null), salesManagerActor),
+ "ยืนยันคำสั่งซื้อจากใบเสนอราคาอื่นแล้ว");
- TicketSummaryDto after = summary(deal);
- assertThat(after.depositPolicy()).isEqualTo("CREDIT_CUSTOMER");
- assertThat(after.depositPolicyReason()).isEqualTo("ลูกค้าเครดิต 30 วัน");
- assertThat(policyChangedMessages(deal)).hasSize(2); // the sync at confirmation + the waiver
+ assertStillTheAgreedOrder(deal, v1);
+ }
+
+ private void assertStillTheAgreedOrder(long deal, DealQuotationDto v1) {
+ assertThat(quotationService.get(v1.id(), salesActor).docStatus()).isEqualTo(QuotationStatus.APPROVED);
+ assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM sales.quotation WHERE ticket_id = :id AND doc_status = 'APPROVED'",
+ Map.of("id", deal), Integer.class)).isEqualTo(1);
+ assertThat(summary(deal).depositPolicy()).isEqualTo("REQUIRED");
}
- /** Wrong-way-round, the case the set_by marker exists for: a NOT_REQUIRED a person typed is
- * not the system's, so a 50% revision leaves it and writes no event. */
+ /** ...and before the order is confirmed the same revision still works. */
@Test
- void humanNotRequired_isKept_whenARevisionStillCarriesADeposit() {
+ void unconfirmedApprovedDirectQuotation_canStillBeRevised() {
long deal = pipelineTicket(salesActor);
DealQuotationDto fifty = approvedDirectQuotation(deal, salesActor);
- confirmer.confirmOrderFromDirectQuotation(fifty.id(), salesActor);
- ticketService.waiveDeposit(deal, "NOT_REQUIRED", "ลูกค้าโอนเต็มจำนวนล่วงหน้า", salesActor);
- approvedRevision(fifty, directDraft());
+ DealQuotationDto revision = quotationService.createRevision(fifty.id(), salesActor);
- TicketSummaryDto after = summary(deal);
- assertThat(after.depositPolicy()).isEqualTo("NOT_REQUIRED");
- assertThat(after.depositPolicyReason()).isEqualTo("ลูกค้าโอนเต็มจำนวนล่วงหน้า");
- assertThat(policyChangedMessages(deal)).hasSize(1); // the waiver only
+ assertThat(revision.docStatus()).isEqualTo(QuotationStatus.DRAFT);
+ assertThat(revision.parentQuotationId()).isEqualTo(fifty.id());
+ }
+
+ private int quotationRows(long ticketId) {
+ return jdbc.queryForObject("SELECT COUNT(*) FROM sales.quotation WHERE ticket_id = :id",
+ Map.of("id", ticketId), Integer.class);
}
// ─────────────────────────────────────────────────────────────────────────────────────
diff --git a/backend/src/test/java/th/co/glr/hr/deposit/DepositNoticeServiceTest.java b/backend/src/test/java/th/co/glr/hr/deposit/DepositNoticeServiceTest.java
index 5358e59c0..58161a0d2 100644
--- a/backend/src/test/java/th/co/glr/hr/deposit/DepositNoticeServiceTest.java
+++ b/backend/src/test/java/th/co/glr/hr/deposit/DepositNoticeServiceTest.java
@@ -1257,4 +1257,28 @@ private void assertConflict(org.assertj.core.api.ThrowableAssert.ThrowingCallabl
.isInstanceOfSatisfying(ApiException.class, e ->
assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT));
}
+
+ // SK-07 (owner ruling 2026-10-28, final): each tax invoice's VAT is round2(its own net x 7%). Deal 11
+ // (PR-2026-0006): net 119,745.00 - 35,923.50 = 83,821.50 -> VAT 5,867.505 -> 5,867.51 -> grand 89,689.01.
+ // The 1-satang gap against the deal payable (128,127.15 - 38,438.15 = 89,689.00) is normal.
+ @Test
+ void remainingInvoiceMoney_deal11_isPerDocumentRounding() {
+ DepositNoticeService.RemainingInvoiceMoney m = service.computeRemainingInvoiceMoney(
+ new BigDecimal("119745.00"), new BigDecimal("35923.50"));
+
+ assertThat(m.netAmount()).isEqualByComparingTo("83821.50");
+ assertThat(m.vatAmount()).isEqualByComparingTo("5867.51");
+ assertThat(m.grandTotal()).isEqualByComparingTo("89689.01");
+ }
+
+ /** No deposit (bypass): unchanged -- net = items, VAT = round2(items x 7%). */
+ @Test
+ void remainingInvoiceMoney_withNoDeposit_isTheWholeDealInclVat() {
+ DepositNoticeService.RemainingInvoiceMoney m = service.computeRemainingInvoiceMoney(
+ new BigDecimal("33333.33"), BigDecimal.ZERO);
+
+ assertThat(m.netAmount()).isEqualByComparingTo("33333.33");
+ assertThat(m.vatAmount()).isEqualByComparingTo("2333.33");
+ assertThat(m.grandTotal()).isEqualByComparingTo("35666.66");
+ }
}
diff --git a/backend/src/test/java/th/co/glr/hr/deposit/RemainingInvoiceRendererTest.java b/backend/src/test/java/th/co/glr/hr/deposit/RemainingInvoiceRendererTest.java
index 9cfcd8caa..901e796a2 100644
--- a/backend/src/test/java/th/co/glr/hr/deposit/RemainingInvoiceRendererTest.java
+++ b/backend/src/test/java/th/co/glr/hr/deposit/RemainingInvoiceRendererTest.java
@@ -81,10 +81,8 @@ void rendersWithoutFormulaErrorsAsASinglePageWithTheCorrectTotal() throws Except
/**
* The branch's own reference sample (owner-supplied, matches the real template's formulas
* exactly): 46 แผ่น × 806.83 = 37,114.18; deposit 18,557.09 (ref AI2600145) deducted →
- * remainder 18,557.09; I43 stores the UNROUNDED VAT 1298.9963 (18557.09 * 0.07), which the
- * template's own cell format displays as 1,299.00 — this test evaluates the formula with POI
- * directly (no LibreOffice needed) and checks the raw numeric value, not a rendered string, so
- * it also pins that the stored figure is genuinely unrounded until display.
+ * remainder 18,557.09; the stored VAT 1,299.00 and total 19,856.09 are written as values (no
+ * LibreOffice needed: POI reads the cells directly).
*/
@Test
void referenceSampleFormulasEvaluateToTheOwnerSuppliedTotals() throws Exception {
@@ -103,7 +101,8 @@ void referenceSampleFormulasEvaluateToTheOwnerSuppliedTotals() throws Exception
List.of(new RemainingInvoiceItemDto(
1, "กระเบื้อง 60x60", new BigDecimal("46"), "แผ่น",
new BigDecimal("806.83"), null, new BigDecimal("806.83"), new BigDecimal("37114.18")
- ))
+ )),
+ new BigDecimal("1299.00"), new BigDecimal("19856.09")
);
byte[] xlsx = renderer.toXlsx(doc);
@@ -114,13 +113,9 @@ void referenceSampleFormulasEvaluateToTheOwnerSuppliedTotals() throws Exception
// I42 = SUM(I11:I41) = 37114.18 (item) - 18557.09 (deposit) = 18557.09.
assertThat(round2(eval.evaluate(cell(sh, 41, 8)).getNumberValue())).isEqualTo(18557.09);
- // I43 = I42 * 0.07 — the RAW value is unrounded (1298.9963); rounds to 1,299.00 for display.
- double vatRaw = eval.evaluate(cell(sh, 42, 8)).getNumberValue();
- assertThat(vatRaw).isCloseTo(1298.9963, org.assertj.core.data.Offset.offset(0.0001));
- assertThat(round2(vatRaw)).isEqualTo(1299.00);
- // I44 = I42 + I43 = 18557.09 + 1298.9963 = 19856.0863, rounds to 19,856.09.
- double totalRaw = eval.evaluate(cell(sh, 43, 8)).getNumberValue();
- assertThat(round2(totalRaw)).isEqualTo(19856.09);
+ // I43 / I44 are the STORED, already-rounded figures (owner ruling 2026-10-28), not template formulas.
+ assertThat(cell(sh, 42, 8).getNumericCellValue()).isEqualTo(1299.00);
+ assertThat(cell(sh, 43, 8).getNumericCellValue()).isEqualTo(19856.09);
// Item row present at row 13 (0-based 12).
assertThat(cell(sh, 12, 1).getStringCellValue()).isEqualTo("กระเบื้อง 60x60");
@@ -339,6 +334,33 @@ private Cell cell(Sheet sh, int rowIdx, int colIdx) {
return c;
}
+ /**
+ * SK-07 (owner ruling 2026-10-28): the sheet prints the STORED VAT and total as values. Deal 11 (PR-2026-0006):
+ * 119,745.00 - 35,923.50 deposit = net 83,821.50; VAT = round2(net x 7%) = 5,867.51 (the template's I42 x 7%
+ * formula would hold 5,867.505) and total 89,689.01 (not 89,689.005).
+ */
+ @Test
+ void storedVatAndGrandTotal_areWrittenAsValues_deal11() throws Exception {
+ RemainingInvoiceDto doc = new RemainingInvoiceDto(
+ "GLR6900002-1", LocalDate.of(2026, 10, 28), "QT-2026-0006", "GLRD69002", "บริษัท ดีล 11 จำกัด", null,
+ "1 ถนนตัวอย่าง", "0105500000000", "โครงการ 11", new BigDecimal("35923.50"), List.of(),
+ List.of(new RemainingInvoiceItemDto(1, "Tile", new BigDecimal("1"), "lot",
+ new BigDecimal("119745.00"), null, new BigDecimal("119745.00"), new BigDecimal("119745.00"))),
+ new BigDecimal("5867.51"), new BigDecimal("89689.01"));
+
+ byte[] xlsx = renderer.toXlsx(doc);
+
+ try (Workbook wb = WorkbookFactory.create(new ByteArrayInputStream(xlsx))) {
+ Sheet sh = wb.getSheetAt(0);
+ FormulaEvaluator eval = wb.getCreationHelper().createFormulaEvaluator();
+ assertThat(round2(eval.evaluate(cell(sh, 41, 8)).getNumberValue())).isEqualTo(83821.50);
+ assertThat(cell(sh, 42, 8).getCellType()).isEqualTo(org.apache.poi.ss.usermodel.CellType.NUMERIC);
+ assertThat(cell(sh, 42, 8).getNumericCellValue()).isEqualTo(5867.51);
+ assertThat(cell(sh, 43, 8).getCellType()).isEqualTo(org.apache.poi.ss.usermodel.CellType.NUMERIC);
+ assertThat(cell(sh, 43, 8).getNumericCellValue()).isEqualTo(89689.01);
+ }
+ }
+
private RemainingInvoiceDto document() {
return new RemainingInvoiceDto(
"GLRI69001",
@@ -361,7 +383,8 @@ private RemainingInvoiceDto document() {
null,
new BigDecimal("979.40"),
new BigDecimal("64640.40")
- ))
+ )),
+ new BigDecimal("2262.41"), new BigDecimal("34582.61")
);
}
}
diff --git a/backend/src/test/java/th/co/glr/hr/deposit/RemainingInvoiceServiceIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/deposit/RemainingInvoiceServiceIntegrationTest.java
index 8abc6a411..936b6a448 100644
--- a/backend/src/test/java/th/co/glr/hr/deposit/RemainingInvoiceServiceIntegrationTest.java
+++ b/backend/src/test/java/th/co/glr/hr/deposit/RemainingInvoiceServiceIntegrationTest.java
@@ -99,6 +99,7 @@ class RemainingInvoiceServiceIntegrationTest extends AbstractPostgresIntegration
private OrderConfirmationService orderConfirmation;
private RemainingInvoiceRepository remainingInvoiceRepository;
private RemainingInvoiceService remainingInvoiceService;
+ private TicketService ticketService;
private long salesRepId;
private long otherSalesRepId;
@@ -139,7 +140,7 @@ void wireStepsServices() {
decisionService = new PricingDecisionService(decisionRepository, pricingRequests, costingRepository,
tickets, fxRates, notifications, landedCostCalculator, formulaEngine);
- TicketService ticketService = new TicketService(tickets, notifications,
+ ticketService = new TicketService(tickets, notifications,
objectMapper, customers, new QuotationRenderer(), pricingRequestService, new th.co.glr.hr.auth.EmployeeAuthRepository(jdbc), new th.co.glr.hr.customer.ContactRepository(jdbc), new th.co.glr.hr.customer.ProjectRepository(jdbc),
new th.co.glr.hr.dealquotation.DealQuotationRepository(jdbc, new th.co.glr.hr.catalog.CatalogRepository(jdbc)));
@@ -176,6 +177,39 @@ objectMapper, customers, new QuotationRenderer(), pricingRequestService, new th.
// Lifecycle + numbering
// ─────────────────────────────────────────────────────────────────────────────────────
+ /**
+ * Owner ruling 2026-10-28 (SK-07): each tax invoice's VAT is rounded on its own net, so deposit + remaining can
+ * differ from the deal's VAT-inclusive payable by a satang -- and outstanding + the final BALANCE receipt then
+ * FOLLOW THE ISSUED DOCUMENTS: once the remaining invoice is issued, payable = deposit notice total + remaining
+ * invoice grand total, the deposit receipt is the notice total, the balance is exactly the invoice grand total
+ * and the deal closes FULLY_PAID with nothing left (no satang overpaid/underpaid).
+ */
+ @Test
+ void issuedRemainingInvoice_outstandingAndFinalPaymentFollowTheIssuedDocuments() {
+ DealFixture fixture = buildAcceptedDeal(salesActor, "FollowDocs");
+ long ticketId = fixture.ticketId();
+ BigDecimal depositNoticeTotal = new DepositNoticeRepository(jdbc).findById(fixture.depositNoticeId()).orElseThrow().totalPayable();
+ ticketService.confirmDepositPaid(ticketId, accountActor);
+
+ BigDecimal payableBeforeInvoice = tickets.payableAmount(ticketId);
+ RemainingInvoiceDocumentDto issued = remainingInvoiceService.issue(
+ remainingInvoiceService.createDraft(ticketId, null, salesActor).id(), salesActor);
+ // fixture precondition: per-document rounding really does drift from the deal payable here
+ assertThat(payableBeforeInvoice.subtract(depositNoticeTotal)).isNotEqualByComparingTo(issued.grandTotal());
+
+ assertThat(tickets.payableAmount(ticketId)).isEqualByComparingTo(depositNoticeTotal.add(issued.grandTotal()));
+ assertThat(tickets.findSummaryById(ticketId).orElseThrow().amountOutstanding()).isEqualByComparingTo(issued.grandTotal());
+
+ ticketService.confirmFinalPayment(ticketId, accountActor);
+
+ assertThat(jdbc.queryForObject(
+ "SELECT amount FROM sales.payment_receipt WHERE ticket_id = :id AND kind = 'BALANCE'",
+ Map.of("id", ticketId), BigDecimal.class)).isEqualByComparingTo(issued.grandTotal());
+ var after = tickets.findSummaryById(ticketId).orElseThrow();
+ assertThat(after.paymentStatus()).isEqualTo("FULLY_PAID");
+ assertThat(after.amountOutstanding()).isEqualByComparingTo("0");
+ }
+
@Test
void createDraft_thenIssue_mintsFirstVersionOfANewBase() {
DealFixture fixture = buildAcceptedDeal(salesActor, "Lifecycle1");
diff --git a/backend/src/test/java/th/co/glr/hr/finance/FinancePayableVatAndDueIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/finance/FinancePayableVatAndDueIntegrationTest.java
index 11681d5c3..4f569af23 100644
--- a/backend/src/test/java/th/co/glr/hr/finance/FinancePayableVatAndDueIntegrationTest.java
+++ b/backend/src/test/java/th/co/glr/hr/finance/FinancePayableVatAndDueIntegrationTest.java
@@ -82,6 +82,102 @@ void payingTheFullVatInclusiveAmount_isNotOverpayment_andReachesFullyPaid() {
assertThat(tickets.findSummaryById(ticketId).orElseThrow().amountOutstanding()).isEqualByComparingTo("0");
}
+ // ── A2. QA-03 (owner ruling 2026-10-28): a confirmed DEAL_DIRECT quotation stays APPROVED ──
+
+ /** A deal whose only quotation is a DEAL_DIRECT one in its terminal live state, APPROVED (subtotal 100,000). */
+ private long directApprovedDeal(String origin) {
+ long ticketId = unconfirmedDirectApprovedDeal(origin);
+ tickets.updatePaymentStatusUnchecked(ticketId, "CUSTOMER_CONFIRMED"); // confirm-order puts the deal on the payment track
+ return ticketId;
+ }
+
+ private long unconfirmedDirectApprovedDeal(String origin) {
+ long ticketId = createTicket("PROCUREMENT");
+ long q = insertAcceptedQuotation(ticketId, "QT-DD-" + ticketId, new BigDecimal("100000.00"));
+ jdbc.update("UPDATE sales.quotation SET origin = :o, doc_status = 'APPROVED' WHERE quotation_id = :q",
+ new MapSqlParameterSource("o", origin).addValue("q", q));
+ return ticketId;
+ }
+
+ @Test
+ void confirmedDirectQuotation_approved_countsTowardPayable_andShowsAsTheDealValue() {
+ long ticketId = directApprovedDeal("DEAL_DIRECT");
+
+ assertThat(tickets.payableAmount(ticketId)).isEqualByComparingTo("107000.00");
+ assertThat(tickets.payableAmountExVat(ticketId)).isEqualByComparingTo("100000.00");
+ FinanceDealDto dto = service.get(ticketId, accountUser);
+ assertThat(dto.money().amountPayable()).isEqualByComparingTo("107000.00");
+ assertThat(dto.money().amountOutstanding()).isEqualByComparingTo("107000.00");
+ }
+
+ @Test
+ void confirmedDirectQuotation_approved_isTheDealsPayableQuotationDocument() {
+ long ticketId = directApprovedDeal("DEAL_DIRECT");
+
+ // FinanceDealRepository.findPayableQuotation must agree with TicketRepository's payable SQL.
+ assertThat(service.get(ticketId, accountUser).documents().acceptedQuotation()).isNotNull();
+ assertThat(service.get(ticketId, accountUser).documents().acceptedQuotation().number())
+ .isEqualTo("QT-DD-" + ticketId);
+ }
+
+ @Test
+ void confirmedDirectQuotation_approved_balanceIsNotFullyPaidBeforeTheMoneyArrives() {
+ long ticketId = directApprovedDeal("DEAL_DIRECT");
+ tickets.updatePaymentStatusUnchecked(ticketId, "DEPOSIT_PAID");
+ insertPayment(ticketId, "DEPOSIT", new BigDecimal("53500.00"));
+
+ assertThat(tickets.findSummaryById(ticketId).orElseThrow().amountOutstanding()).isEqualByComparingTo("53500.00");
+ }
+
+ @Test
+ void confirmedDirectQuotation_approved_drivesPaymentTermsAndAccountScope() {
+ long ticketId = directApprovedDeal("DEAL_DIRECT");
+ jdbc.update("UPDATE sales.quotation SET remainder_mode = 'CREDIT', credit_days = 30 WHERE ticket_id = :id",
+ new MapSqlParameterSource("id", ticketId));
+ deliver(ticketId, today().minusDays(45).atTime(12, 0).atZone(BKK).toInstant());
+ // outside account's order-stage scope with a non-pending status: only overdue-with-balance gets it in.
+ tickets.updateSalesStage(ticketId, "NEGOTIATION");
+ tickets.updatePaymentStatusUnchecked(ticketId, "CUSTOMER_CONFIRMED");
+
+ TicketSummaryDto s = summary(ticketId);
+ assertThat(s.paymentDueBasis()).isEqualTo("CREDIT_FROM_DELIVERY");
+ assertThat(s.paymentDueDate()).isEqualTo(today().minusDays(15));
+ assertThat(s.overdue()).isTrue();
+ assertThat(inAccountList(ticketId)).isTrue();
+ }
+
+ @Test
+ void acceptedQuotation_outranksAnApprovedDirectOne_inAllThreeQueries() {
+ long ticketId = createS15Deal("CUSTOMER_CONFIRMED"); // ACCEPTED, 100,000, (recipient default)
+ jdbc.update("UPDATE sales.quotation SET recipient_type = 'OWNER', remainder_mode = 'CREDIT', credit_days = 30 WHERE ticket_id = :id",
+ new MapSqlParameterSource("id", ticketId));
+ long dd = tickets.createQuotation(ticketId, "QT-DD-RANK-" + ticketId, salesRepId, new BigDecimal("200000.00")).id();
+ jdbc.update("UPDATE sales.quotation SET origin = 'DEAL_DIRECT', doc_status = 'APPROVED', recipient_type = 'BUYER' WHERE quotation_id = :q",
+ new MapSqlParameterSource("q", dd));
+ deliver(ticketId, today().minusDays(10).atTime(12, 0).atZone(BKK).toInstant());
+
+ assertThat(tickets.payableAmount(ticketId)).isEqualByComparingTo("107000.00");
+ assertThat(service.get(ticketId, accountUser).documents().acceptedQuotation().number())
+ .isEqualTo("QT-LK-" + ticketId);
+ assertThat(summary(ticketId).paymentDueBasis()).isEqualTo("CREDIT_FROM_DELIVERY");
+ }
+
+ @Test
+ void approvedDirectQuotation_withNoOrderConfirmation_isNotPayable() {
+ long ticketId = unconfirmedDirectApprovedDeal("DEAL_DIRECT"); // payment_status still NULL
+
+ assertThat(tickets.payableAmount(ticketId)).isEqualByComparingTo("0");
+ assertThat(service.get(ticketId, accountUser).documents().acceptedQuotation()).isNull();
+ }
+
+ @Test
+ void approvedPricingRequestQuotation_isNotPayable_onlyDealDirectApprovedIs() {
+ long ticketId = directApprovedDeal("PRICING_REQUEST"); // a PCR approval lands on ISSUED, never APPROVED
+
+ assertThat(tickets.payableAmount(ticketId)).isEqualByComparingTo("0");
+ assertThat(service.get(ticketId, accountUser).documents().acceptedQuotation()).isNull();
+ }
+
// ── B. Overdue from the quotation's terms ──────────────────────────────────────────────
private long creditDeal(int creditDays, LocalDate deliveredOn) {
diff --git a/backend/src/test/java/th/co/glr/hr/orderconfirmation/OrderConfirmationIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/orderconfirmation/OrderConfirmationIntegrationTest.java
index dc1126cf5..31a20fe9d 100644
--- a/backend/src/test/java/th/co/glr/hr/orderconfirmation/OrderConfirmationIntegrationTest.java
+++ b/backend/src/test/java/th/co/glr/hr/orderconfirmation/OrderConfirmationIntegrationTest.java
@@ -301,7 +301,11 @@ SELECT COUNT(*) FROM sales.pricing_request_event
// just because payableAmount() used to be blind to the new chain's own quotation total.
BigDecimal paid = jdbc.queryForObject(
"SELECT amount FROM sales.payment_receipt WHERE ticket_id = :id", Map.of("id", ticketId), BigDecimal.class);
- assertThat(paid).isEqualByComparingTo(draftNotice.depositAmount());
+ // QA-01 (owner ruling 2026-10-28): the receipt is the notice's VAT-INCLUSIVE totalPayable
+ // (deal payable is VAT-inclusive), NOT the pre-VAT depositAmount.
+ assertThat(issuedNotice.vatAmount().signum()).as("fixture must carry VAT").isPositive();
+ assertThat(paid).isEqualByComparingTo(issuedNotice.totalPayable());
+ assertThat(paid).isGreaterThan(draftNotice.depositAmount());
assertThat(paid).isLessThan(payableBeforePayment); // strictly partial — not an overpayment by construction.
String note = jdbc.queryForObject(
"SELECT note FROM sales.payment_receipt WHERE ticket_id = :id", Map.of("id", ticketId), String.class);
diff --git a/backend/src/test/java/th/co/glr/hr/ticket/PaymentTrackIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/ticket/PaymentTrackIntegrationTest.java
index 61a005e91..4c1352b75 100644
--- a/backend/src/test/java/th/co/glr/hr/ticket/PaymentTrackIntegrationTest.java
+++ b/backend/src/test/java/th/co/glr/hr/ticket/PaymentTrackIntegrationTest.java
@@ -212,7 +212,7 @@ void requiredPath_walksEndToEnd_nullThroughDepositNoticeIssuedThroughFullyPaid()
// site 2: CUSTOMER_CONFIRMED -> DEPOSIT_NOTICE_ISSUED.
DepositNoticeDto draft = orderConfirmation.createDepositNoticeFromQuotation(
deal.pricingRequestId(), new CreateDepositNoticeFromQuotationRequest(null), salesActor);
- depositNoticeService.issue(draft.id(), salesActor);
+ DepositNoticeDto issuedNotice = depositNoticeService.issue(draft.id(), salesActor);
assertThat(paymentStatusOf(deal.ticketId())).isEqualTo(PaymentTrack.DEPOSIT_NOTICE_ISSUED);
// site 6 (REQUIRED branch, via reconcilePaymentStatus): DEPOSIT_NOTICE_ISSUED -> DEPOSIT_PAID,
@@ -235,6 +235,11 @@ void requiredPath_walksEndToEnd_nullThroughDepositNoticeIssuedThroughFullyPaid()
// the remaining ~50% is still outstanding): AWAITING_FINAL_PAYMENT -> FULLY_PAID.
ticketService.confirmFinalPayment(deal.ticketId(), accountActor);
assertThat(paymentStatusOf(deal.ticketId())).isEqualTo(PaymentTrack.FULLY_PAID);
+ // QA-01: the DEPOSIT receipt is the notice's VAT-inclusive totalPayable, not pre-VAT.
+ assertThat(jdbc.queryForObject(
+ "SELECT amount FROM sales.payment_receipt WHERE ticket_id = :id AND kind = 'DEPOSIT'",
+ java.util.Map.of("id", deal.ticketId()), BigDecimal.class))
+ .isEqualByComparingTo(issuedNotice.totalPayable());
}
@Test
diff --git a/backend/src/test/java/th/co/glr/hr/ticket/StageDecisionIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/ticket/StageDecisionIntegrationTest.java
index ff40e4e7c..79c9194a4 100644
--- a/backend/src/test/java/th/co/glr/hr/ticket/StageDecisionIntegrationTest.java
+++ b/backend/src/test/java/th/co/glr/hr/ticket/StageDecisionIntegrationTest.java
@@ -456,6 +456,57 @@ void everyAllowedDecisionIsAccepted_andEveryBlockedOneIsRefused() {
}
}
+ /**
+ * QA-05 (owner ruling 2026-10-28): once the order is confirmed (payment track reached
+ * CUSTOMER_CONFIRMED -- the deal is at or past ขั้น 10), "แก้ไขสถานะ" must not move the deal
+ * below ขั้น 10. The decision payload the modal renders says blocked, and the write is a 409.
+ */
+ @Test
+ void afterTheOrderIsConfirmed_everyStageBelowOrderReceived_isBlockedAndRefusedWith409() {
+ long ticketId = readyToAdvanceFrom(DealStage.PROCUREMENT);
+ tickets.updatePaymentStatusUnchecked(ticketId, PaymentTrack.CUSTOMER_CONFIRMED);
+
+ for (UserPrincipal actor : List.of(ownerRep, salesManager, ceo)) {
+ List