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 1/3] =?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
---
.../hr/commission/CommissionCalculator.java | 10 +-
.../glr/hr/commission/CommissionService.java | 4 +-
.../dealquotation/DealQuotationService.java | 35 +++--
.../glr/hr/deposit/DepositNoticeService.java | 2 +
.../glr/hr/deposit/RemainingInvoiceDto.java | 17 ++-
.../hr/deposit/RemainingInvoiceRenderer.java | 11 +-
.../hr/deposit/RemainingInvoiceService.java | 2 +-
.../glr/hr/finance/FinanceDealRepository.java | 7 +-
.../th/co/glr/hr/ticket/TicketRepository.java | 49 ++++++-
.../th/co/glr/hr/ticket/TicketService.java | 18 ++-
.../CommissionAutoCreateIntegrationTest.java | 18 ++-
.../CommissionDealLinkageIntegrationTest.java | 19 ++-
.../hr/commission/CommissionServiceTest.java | 12 +-
...lQuotationConfirmOrderIntegrationTest.java | 120 ++++++++----------
.../hr/deposit/DepositNoticeServiceTest.java | 24 ++++
.../deposit/RemainingInvoiceRendererTest.java | 49 +++++--
...emainingInvoiceServiceIntegrationTest.java | 36 +++++-
...inancePayableVatAndDueIntegrationTest.java | 96 ++++++++++++++
.../OrderConfirmationIntegrationTest.java | 6 +-
.../ticket/PaymentTrackIntegrationTest.java | 7 +-
.../ticket/StageDecisionIntegrationTest.java | 51 ++++++++
frontend/src/api/mockApi.financeDeal.test.js | 4 +
frontend/src/api/mockApi.js | 31 ++++-
.../src/api/mockApi.promoteToDeal.test.js | 110 +++++-----------
.../api/mockApi.stageDecisionsRoute.test.js | 20 +++
.../features/commissions/CommissionPage.jsx | 2 +-
.../commissions/CommissionPage.test.jsx | 10 +-
.../commissions/InvoiceFromDealForm.jsx | 4 +-
.../src/features/finance/FinanceDealPage.jsx | 2 +-
.../features/finance/FinanceDealPage.test.jsx | 17 ++-
.../finance/FinanceMilestoneSections.jsx | 2 +-
.../PricingRequestCreateModal.jsx | 13 +-
.../PricingRequestCreateModal.test.jsx | 58 +++++++++
.../PricingRequestDetailPage.jsx | 20 ++-
.../PricingRequestDetailPage.test.jsx | 13 +-
.../quotations/QuotationEditorPage.jsx | 7 +-
.../quotations/QuotationEditorPage.test.jsx | 21 +++
.../src/features/quotations/quotationMeta.js | 14 +-
.../features/quotations/quotationMeta.test.js | 18 +++
.../features/tickets/DealQuotationPanel.jsx | 21 ++-
.../src/features/tickets/TicketDetailPage.jsx | 4 +-
.../tickets/TicketDetailPage.test.jsx | 25 +++-
42 files changed, 759 insertions(+), 250 deletions(-)
diff --git a/backend/src/main/java/th/co/glr/hr/commission/CommissionCalculator.java b/backend/src/main/java/th/co/glr/hr/commission/CommissionCalculator.java
index 05f94f4e2..d0c477077 100644
--- a/backend/src/main/java/th/co/glr/hr/commission/CommissionCalculator.java
+++ b/backend/src/main/java/th/co/glr/hr/commission/CommissionCalculator.java
@@ -39,14 +39,8 @@ public class CommissionCalculator {
* here) and รับเงินเกิน (overpayment received, added back). Both are applied at the same
* "actualReceived" stage as the existing five deduction columns.
*
- *
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 notes,
- List items
-) {}
+ List items,
+ // The STORED document totals (owner ruling 2026-10-28, SK-07): the sheet prints these as values, not as
+ // template formulas, so the printed VAT / total are exactly what the issued document and the ledger hold.
+ // null = not supplied: the template's own formulas stay (only a caller that never stored a total).
+ BigDecimal vatAmount,
+ BigDecimal grandTotal
+) {
+ public RemainingInvoiceDto(String docNumber, LocalDate issueDate, String reference, String depositReference,
+ String customerName, String customerBranch, String customerAddress,
+ String customerTaxId, String projectName, BigDecimal depositAmount,
+ List notes, List items) {
+ this(docNumber, issueDate, reference, depositReference, customerName, customerBranch, customerAddress,
+ customerTaxId, projectName, depositAmount, notes, items, null, null);
+ }
+}
diff --git a/backend/src/main/java/th/co/glr/hr/deposit/RemainingInvoiceRenderer.java b/backend/src/main/java/th/co/glr/hr/deposit/RemainingInvoiceRenderer.java
index 2d37f4667..9dcbf5491 100644
--- a/backend/src/main/java/th/co/glr/hr/deposit/RemainingInvoiceRenderer.java
+++ b/backend/src/main/java/th/co/glr/hr/deposit/RemainingInvoiceRenderer.java
@@ -234,8 +234,15 @@ public byte[] toXlsx(RemainingInvoiceDto doc) throws Exception {
// ── Summary rows auto-calculated by existing formulas ─────────────
// I42 = SUM(I11:I41) includes item amounts and -depositAmount → remainder
- // I43 = I42 * 0.07 (VAT)
- // I44 = I42 + I43 (total payable)
+ // I43 / I44 (VAT, total payable): the STORED, already-rounded figures as VALUES when supplied
+ // (owner ruling 2026-10-28, SK-07) -- the template's I42 x 7% formula would print an unrounded
+ // 5,867.505 / 89,689.005 instead of what the issued document and the ledger hold.
+ if (doc.vatAmount() != null) {
+ setNum(sh, 42, 8, doc.vatAmount().doubleValue());
+ }
+ if (doc.grandTotal() != null) {
+ setNum(sh, 43, 8, doc.grandTotal().doubleValue());
+ }
wb.setForceFormulaRecalculation(true);
wb.write(out);
return out.toByteArray();
diff --git a/backend/src/main/java/th/co/glr/hr/deposit/RemainingInvoiceService.java b/backend/src/main/java/th/co/glr/hr/deposit/RemainingInvoiceService.java
index 30d7f223e..be0a74999 100644
--- a/backend/src/main/java/th/co/glr/hr/deposit/RemainingInvoiceService.java
+++ b/backend/src/main/java/th/co/glr/hr/deposit/RemainingInvoiceService.java
@@ -404,7 +404,7 @@ private RemainingInvoiceDto toRenderable(RemainingInvoiceDocumentDto doc) {
doc.docNumber() != null ? doc.docNumber() : "(ร่าง)",
doc.docDate(), doc.reference(), doc.depositReference(),
doc.customerName(), doc.customerBranch(), doc.customerAddress(), doc.customerTaxId(),
- doc.projectName(), nz(doc.depositDeduction()), doc.notes(), items);
+ doc.projectName(), nz(doc.depositDeduction()), doc.notes(), items, doc.vatAmount(), doc.grandTotal());
}
private List defaultNotes() {
diff --git a/backend/src/main/java/th/co/glr/hr/finance/FinanceDealRepository.java b/backend/src/main/java/th/co/glr/hr/finance/FinanceDealRepository.java
index 66d730e32..21cff493f 100644
--- a/backend/src/main/java/th/co/glr/hr/finance/FinanceDealRepository.java
+++ b/backend/src/main/java/th/co/glr/hr/finance/FinanceDealRepository.java
@@ -35,8 +35,11 @@ public Optional findPayableQuotation(long ticketId) {
SELECT q.quotation_id, q.number, q.doc_status, q.total_amount, q.currency,
q.issued_at, q.accepted_at, q.origin, q.pricing_request_id
FROM sales.quotation q
- WHERE q.ticket_id = :id AND q.doc_status IN ('ACCEPTED', 'ISSUED', 'SENT')
- ORDER BY CASE WHEN q.doc_status = 'ACCEPTED' THEN 0 ELSE 1 END,
+ WHERE q.ticket_id = :id AND (q.doc_status IN ('ACCEPTED', 'ISSUED', 'SENT')
+ -- QA-03: same rule as TicketRepository.answeredQuotationPredicate (confirmed DEAL_DIRECT stays APPROVED)
+ OR (q.doc_status = 'APPROVED' AND q.origin = 'DEAL_DIRECT'
+ AND (SELECT tp.payment_status FROM sales.ticket tp WHERE tp.ticket_id = q.ticket_id) IS NOT NULL))
+ ORDER BY CASE q.doc_status WHEN 'ACCEPTED' THEN 0 WHEN 'APPROVED' THEN 1 ELSE 2 END,
CASE q.recipient_type WHEN 'BUYER' THEN 0 WHEN 'OWNER' THEN 1 ELSE 2 END,
q.accepted_at DESC NULLS LAST, q.issued_at DESC, q.quotation_id DESC
LIMIT 1
diff --git a/backend/src/main/java/th/co/glr/hr/ticket/TicketRepository.java b/backend/src/main/java/th/co/glr/hr/ticket/TicketRepository.java
index b02fe84bc..1818110e5 100644
--- a/backend/src/main/java/th/co/glr/hr/ticket/TicketRepository.java
+++ b/backend/src/main/java/th/co/glr/hr/ticket/TicketRepository.java
@@ -1694,6 +1694,30 @@ public BigDecimal payableAmount(long ticketId) {
return value == null ? BigDecimal.ZERO : value;
}
+ /**
+ * QA-03: a DEAL_DIRECT quotation confirmed via confirm-order stays APPROVED (it never reaches ACCEPTED), so
+ * it counts as the "answered" quotation -- but only once the order IS confirmed: confirm-order moves the
+ * ticket onto the payment track (payment_status non-null, CUSTOMER_CONFIRMED). An APPROVED, unconfirmed one
+ * is not payable. ACCEPTED still outranks it.
+ */
+ private static String answeredQuotationPredicate(String ticketIdRef) {
+ return "(q.doc_status = 'ACCEPTED' OR (q.doc_status = 'APPROVED' AND q.origin = 'DEAL_DIRECT'"
+ + " AND (SELECT tp.payment_status FROM sales.ticket tp WHERE tp.ticket_id = " + ticketIdRef + ") IS NOT NULL))";
+ }
+
+ /**
+ * SK-07 (owner ruling 2026-10-28): once a remaining invoice is ISSUED, what the customer owes follows the ISSUED
+ * documents -- the deposit notice's total_payable plus the remaining invoice's grand_total -- because each tax
+ * invoice rounds its own VAT, so the sum may sit a satang away from the deal's computed total. Trailing comma
+ * included; VAT-inclusive variant only (the ex-VAT figure stays the quotation's subtotal).
+ */
+ private static String issuedDocumentsPayable(String ticketIdRef) {
+ return "(SELECT COALESCE(dn.total_payable, 0) + ri.grand_total FROM sales.remaining_invoice ri"
+ + " LEFT JOIN sales.deposit_notice dn ON dn.deposit_notice_id = ri.deposit_notice_id"
+ + " WHERE ri.ticket_id = " + ticketIdRef + " AND ri.status = 'ISSUED' AND ri.grand_total IS NOT NULL"
+ + " ORDER BY ri.issued_at DESC, ri.id DESC LIMIT 1),";
+ }
+
/**
* Full "amount payable" SELECT statement, correlated on {@code ticketIdRef} (either a bind
* parameter like {@code ":ticketId"} for a standalone query, or an outer-query column
@@ -1736,10 +1760,12 @@ private static String payableAmountSelect(String ticketIdRef, boolean inclVat) {
: "(SELECT " + itemsSum;
return """
SELECT COALESCE(
+ ISSUED_DOCUMENTS
(SELECT QUOTATION_AMOUNT
FROM sales.quotation q
- WHERE q.ticket_id = %1$s AND q.doc_status = 'ACCEPTED'
- ORDER BY CASE q.recipient_type
+ WHERE q.ticket_id = %1$s AND ANSWERED
+ ORDER BY CASE WHEN q.doc_status = 'ACCEPTED' THEN 0 ELSE 1 END,
+ CASE q.recipient_type
WHEN 'BUYER' THEN 0
WHEN 'OWNER' THEN 1
ELSE 2
@@ -1770,6 +1796,8 @@ SELECT COALESCE(
0
)
""".formatted(ticketIdRef)
+ .replace("ISSUED_DOCUMENTS", inclVat ? issuedDocumentsPayable(ticketIdRef) : "")
+ .replace("ANSWERED", answeredQuotationPredicate(ticketIdRef))
.replace("QUOTATION_AMOUNT", quotationAmount)
.replace("ITEMS_AMOUNT", itemsAmount);
}
@@ -1811,8 +1839,8 @@ WHEN q.full_payment_term IN ('BEFORE_DELIVERY', 'ON_DELIVERY', 'ON_OR_BEFORE_DEL
END AS basis,
CASE WHEN q.remainder_mode = 'CREDIT' AND q.credit_days IS NOT NULL THEN q.credit_days::int END AS credit_days
FROM sales.quotation q
- WHERE q.ticket_id = %1$s AND q.doc_status IN ('ACCEPTED', 'ISSUED', 'SENT')
- ORDER BY CASE WHEN q.doc_status = 'ACCEPTED' THEN 0 ELSE 1 END,
+ WHERE q.ticket_id = %1$s AND (ANSWERED OR q.doc_status IN ('ISSUED', 'SENT'))
+ ORDER BY CASE q.doc_status WHEN 'ACCEPTED' THEN 0 WHEN 'APPROVED' THEN 1 ELSE 2 END,
CASE q.recipient_type
WHEN 'BUYER' THEN 0
WHEN 'OWNER' THEN 1
@@ -1827,7 +1855,7 @@ CROSS JOIN (SELECT (MAX(dr.delivered_at) AT TIME ZONE 'Asia/Bangkok')::date AS d
WHERE dr.ticket_id = %1$s
AND (SELECT t2.fulfillment_status FROM sales.ticket t2 WHERE t2.ticket_id = %1$s)
= 'FULLY_DELIVERED') dv
- """.formatted(ticketIdRef);
+ """.formatted(ticketIdRef).replace("ANSWERED", answeredQuotationPredicate(ticketIdRef));
}
/** The derived due DATE as a scalar subquery (NULL when there is none) -- the list-scope predicate's twin. */
@@ -2253,6 +2281,17 @@ public int markPromotedToPipeline(long ticketId) {
* method's idempotent replay: if the event exists, a replay returns the current state and
* writes nothing.
*/
+ /** The DEAL_PROMOTED_FROM_QUOTATION event names the quotation whose confirm-order made the deal an order. */
+ public boolean isOrderConfirmedFromQuotation(long ticketId, long quotationId) {
+ Boolean value = jdbc.queryForObject("""
+ SELECT EXISTS(SELECT 1 FROM sales.ticket_event
+ WHERE ticket_id = :ticketId AND kind = :kind
+ AND related_document_type = 'QUOTATION' AND related_document_id = :quotationId)
+ """, Map.of("ticketId", ticketId, "kind", TicketEventKind.DEAL_PROMOTED_FROM_QUOTATION,
+ "quotationId", quotationId), Boolean.class);
+ return Boolean.TRUE.equals(value);
+ }
+
public boolean hasPromotionFromQuotationEvent(long ticketId) {
Boolean value = jdbc.queryForObject("""
SELECT EXISTS(SELECT 1 FROM sales.ticket_event
diff --git a/backend/src/main/java/th/co/glr/hr/ticket/TicketService.java b/backend/src/main/java/th/co/glr/hr/ticket/TicketService.java
index b134178f6..877e67804 100644
--- a/backend/src/main/java/th/co/glr/hr/ticket/TicketService.java
+++ b/backend/src/main/java/th/co/glr/hr/ticket/TicketService.java
@@ -951,9 +951,8 @@ private TicketDto applyCustomerConfirmation(long ticketId, TicketSummaryDto s, S
/**
* Owner ruling 2026-10-22, widened after the #1173 review: while the payment track is at
* CUSTOMER_CONFIRMED, a deposit policy the SYSTEM owns follows the deal's quotation. ไม่รับมัดจำ
- * (0%) means NOT_REQUIRED; any deposit means REQUIRED. Called at the first confirmation and
- * when a DEAL_DIRECT quotation is approved on a confirmed deal ({@code DealQuotationService
- * #approve}), so a revision that adds or drops the deposit moves the deal with it.
+ * (0%) means NOT_REQUIRED; any deposit means REQUIRED. Called at the first confirmation only (the
+ * confirmed direct quotation can no longer be revised or replaced, owner ruling 2026-10-28).
*
*
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 decisions = decisionsFor(ticketId, actor);
+ for (String target : DealStage.ORDER) {
+ if (DealStage.indexOf(target) >= DealStage.indexOf(DealStage.ORDER_RECEIVED)) continue;
+ assertThat(allowedFor(decisions, target))
+ .as("%s must not be offered to %s after the order is confirmed", target, actor.role()).isFalse();
+ assertThat(reasonFor(decisions, target)).isNotBlank();
+ }
+ }
+ // The write is refused too (not only hidden), with a conflict, and nothing moved.
+ for (String target : List.of(DealStage.QUOTE_OWNER, DealStage.SPEC_APPROVED, DealStage.NEGOTIATION)) {
+ assertThatThrownBy(() -> ticketService.updateStage(ticketId, target, "ย้อนกลับ", ownerRep))
+ .as(target)
+ .isInstanceOfSatisfying(ApiException.class,
+ e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT));
+ }
+ assertThat(stageOf(ticketId)).isEqualTo(DealStage.PROCUREMENT);
+ assertThat(stageChangedEvents(ticketId)).isZero();
+ }
+
+ /** The other half of the ruling: a move that stays at or above ขั้น 10 is unaffected by the new gate. */
+ @Test
+ void afterTheOrderIsConfirmed_movesWithinTenAndAbove_stillWork() {
+ long ticketId = readyToAdvanceFrom(DealStage.PROCUREMENT);
+ tickets.updatePaymentStatusUnchecked(ticketId, PaymentTrack.CUSTOMER_CONFIRMED);
+
+ assertThat(allowedFor(decisionsFor(ticketId, ownerRep), DealStage.DELIVERY_SCHEDULING)).isTrue();
+ ticketService.updateStage(ticketId, DealStage.DELIVERY_SCHEDULING, "ส่งของ", ownerRep);
+ assertThat(stageOf(ticketId)).isEqualTo(DealStage.DELIVERY_SCHEDULING);
+ }
+
+ /** ...and before the order exists the ordinary rewind (ขั้น 5 -> 3) is still legal, with its reason. */
+ @Test
+ void beforeTheOrderIsConfirmed_aRewindWithinStagesOneToNine_stillWorks() {
+ long ticketId = readyToAdvanceFrom(DealStage.QUOTE_OWNER);
+
+ assertThat(allowedFor(decisionsFor(ticketId, ownerRep), DealStage.SPEC_APPROVED)).isTrue();
+ ticketService.updateStage(ticketId, DealStage.SPEC_APPROVED, "ลูกค้าขอแก้สเปก", ownerRep);
+ assertThat(stageOf(ticketId)).isEqualTo(DealStage.SPEC_APPROVED);
+ }
+
// ── helpers ──────────────────────────────────────────────────────────────
// H1 lockdown: account reads its stage decisions through financeActions (same gates, no /tickets view
diff --git a/frontend/src/api/mockApi.financeDeal.test.js b/frontend/src/api/mockApi.financeDeal.test.js
index e3d5d5dba..eea95bcf9 100644
--- a/frontend/src/api/mockApi.financeDeal.test.js
+++ b/frontend/src/api/mockApi.financeDeal.test.js
@@ -164,9 +164,13 @@ describe('mockApi.finance routes -- role gates and scope', () => {
&& (t.lifecycle ?? 'ACTIVE') === 'ACTIVE');
expect(target).toBeTruthy();
await login2('account@glr.co.th');
+ const notice = (await api.finance.getDeal(target.id)).deal.documents.depositNotices
+ .filter((d) => d.status === 'ISSUED').at(-1);
const { deal } = await api.finance.confirmDepositPaid(target.id);
expect(deal.money.paymentStatus).toBe('DEPOSIT_PAID');
expect(deal.money.payments.length).toBeGreaterThan(0);
+ // QA-01 (mirror of TicketService.confirmDepositPaid): the receipt is the notice's VAT-inclusive total.
+ if (notice) expect(deal.money.payments.find((p) => p.kind === 'DEPOSIT').amount).toBe(notice.totalPayable);
const again = (await api.finance.getDeal(target.id)).deal;
expect(again.money.paymentStatus).toBe('DEPOSIT_PAID');
});
diff --git a/frontend/src/api/mockApi.js b/frontend/src/api/mockApi.js
index 3cc13d9d7..b6db6132b 100644
--- a/frontend/src/api/mockApi.js
+++ b/frontend/src/api/mockApi.js
@@ -4248,6 +4248,18 @@ function pushEvent(ticket, actor, kind, fromStatus, toStatus, message, itemSnaps
ticket.events.push({ id: nextId, ticketId: ticket.id, actorId: actor.id, actorName: actor.name, kind, fromStatus, toStatus, message, createdAt: new Date().toISOString(), itemSnapshot });
}
+// Mirrors TicketRepository#isOrderConfirmedFromQuotation / hasPromotionFromQuotationEvent (owner ruling 2026-10-28):
+// the promotion event names the quotation the order was confirmed from; once it exists no OTHER direct quotation
+// on the deal may be submitted/approved, and the confirmed one may not be revised or cloned.
+function mockConfirmedQuotationEvent(ticket) {
+ return ticket?.events?.find((e) => e.kind === 'DEAL_PROMOTED_FROM_QUOTATION') ?? null;
+}
+function requireNoOtherDirectQuotationAfterConfirm(ticket, row) {
+ const promoted = mockConfirmedQuotationEvent(ticket);
+ if ((row.origin || 'DEAL_DIRECT') === 'PRICING_REQUEST' || !promoted || ticket.orderConfirmedQuotationId === row.id) return;
+ fail('ดีลนี้ยืนยันคำสั่งซื้อจากใบเสนอราคาอื่นแล้ว จึงส่ง/อนุมัติใบเสนอราคานี้ไม่ได้', 409);
+}
+
function addNotification(userId, ticketId, ticketCode, type, message) {
const nextId = Math.max(...db.notifications.map((n) => n.id), 0) + 1;
db.notifications.unshift({ id: nextId, userId, ticketId, ticketCode, type, message, read: false, createdAt: new Date().toISOString() });
@@ -4483,6 +4495,10 @@ function mockStageDecisions(ticket, user) {
blockedReason = `ดีลไม่ได้อยู่ในสถานะ ACTIVE (${ticket.lifecycle}) จึงแก้ไขขั้นตอนนี้ไม่ได้`;
} else if (code === ticket.salesStage) {
blockedReason = `ดีลนี้อยู่ในขั้นตอน ${code} อยู่แล้ว`;
+ } else if (dealStageIndex(ticket.salesStage) >= dealStageIndex('ORDER_RECEIVED')
+ && dealStageIndex(code) < dealStageIndex('ORDER_RECEIVED')) {
+ // QA-05: mirrors TicketService.requireStageMoveAllowed -- no rewind below ขั้น 10 once the order is confirmed.
+ blockedReason = 'ย้อนสถานะกลับก่อนขั้นรับคำสั่งซื้อไม่ได้ — ยืนยันคำสั่งซื้อไปแล้ว';
} else if (!mockIsOnRoute(ticket.entryChannel, code)) {
// Rung 6 — the deal's entry-channel route, checked BEFORE the fact gates (mirrors
// TicketService.requireStageMoveAllowed): an off-route target's remedy is the channel, not
@@ -7809,7 +7825,7 @@ async function mockConfirmDepositPaid(id) {
requireActive(ticket);
if (ticket.paymentStatus !== 'DEPOSIT_NOTICE_ISSUED') fail('ต้องออกใบแจ้งรับมัดจำก่อนจึงจะยืนยันรับชำระมัดจำได้', 409);
const notice = latestIssuedDepositNotice(ticket.id);
- const amount = notice?.depositAmount ?? moneyValue(payableAmount(ticket) * 0.5);
+ const amount = notice?.totalPayable ?? moneyValue(payableAmount(ticket) * 0.5); // QA-01: VAT-inclusive
if (amount <= 0) fail('ไม่พบยอดมัดจำสำหรับบันทึกรับชำระ', 409);
recordPaymentForTicket(ticket, user, {
kind: 'DEPOSIT',
@@ -17254,6 +17270,7 @@ export const api = {
if (!row) fail('ไม่พบใบเสนอราคานี้', 404);
const ticket = db.tickets.find((t) => t.id === row.ticketId);
requireDealQuotationWriteAccess(ticket, user, row.origin);
+ requireNoOtherDirectQuotationAfterConfirm(ticket, row);
// GLA-123 slice S2: submit is now LIVE for a PRICING_REQUEST-origin quotation too — mirrors
// DealQuotationService#submit, which widened its own origin predicate rather than keeping
// this refusal. Every validation below already applied to both origins in the mock; only
@@ -17327,6 +17344,7 @@ export const api = {
if ((row.origin || 'DEAL_DIRECT') === 'PRICING_REQUEST') {
return mockApproveDealQuotationPricingRequestOrigin(row, payload, user);
}
+ requireNoOtherDirectQuotationAfterConfirm(db.tickets.find((t) => t.id === row.ticketId), row);
if (!canTransitionDealQuotation(row.docStatus, 'APPROVED')) {
fail(`อนุมัติไม่ได้ในสถานะ '${row.docStatus}'`, 409);
}
@@ -17379,9 +17397,6 @@ export const api = {
}
ancestorId = ancestor.parentQuotationId;
}
- // #1173 review F1 (DealQuotationService#approve -> TicketService#followQuotationDepositPolicy).
- const dealTicket = db.tickets.find((t) => t.id === row.ticketId);
- if (dealTicket) followQuotationDepositPolicy(dealTicket, row, user);
return delay({ quotation: buildDealQuotationDto(row) });
},
@@ -17523,6 +17538,10 @@ export const api = {
if (parent.docStatus !== 'APPROVED') {
fail('สร้างฉบับแก้ไขได้เฉพาะใบเสนอราคาที่อนุมัติแล้วเท่านั้น', 409);
}
+ // Owner ruling 2026-10-28: the confirmed quotation is the agreed order (mirrors DealQuotationService).
+ if (ticket?.orderConfirmedQuotationId === parent.id) {
+ fail('ใบเสนอราคานี้ยืนยันคำสั่งซื้อแล้ว จึงสร้างฉบับแก้ไขไม่ได้', 409);
+ }
}
// M3: mirrors DealQuotationRepository#hasOpenRevision's own guard -- a double-tap must not
// silently mint two children sharing the same {base}-{n} number.
@@ -17552,6 +17571,9 @@ export const api = {
if (source.docStatus !== 'APPROVED') {
fail('สร้างจากใบเดิมได้เฉพาะใบเสนอราคาที่อนุมัติแล้วเท่านั้น', 409);
}
+ if (ticket?.orderConfirmedQuotationId === source.id) {
+ fail('ใบเสนอราคานี้ยืนยันคำสั่งซื้อแล้ว จึงสร้างจากใบเดิมไม่ได้', 409);
+ }
const clone = mintDealQuotationReorder(source, user);
mockDealQuotations.push(clone);
return delay({ quotation: buildDealQuotationDto(clone) });
@@ -17598,6 +17620,7 @@ export const api = {
ticket.quotationOnly = false;
ticket.status = 'quotation_issued';
ticket.updatedAt = now;
+ ticket.orderConfirmedQuotationId = row.id; // mock-internal: which quotation the order was confirmed from
pushEvent(ticket, user, 'DEAL_PROMOTED_FROM_QUOTATION', 'draft', 'quotation_issued',
`ยืนยันคำสั่งซื้อจากใบเสนอราคา ${row.number}`);
// DealQuotationService#ticketItemsFromQuotation: goods lines only (no ADJUSTMENT, no
diff --git a/frontend/src/api/mockApi.promoteToDeal.test.js b/frontend/src/api/mockApi.promoteToDeal.test.js
index ff9bb889f..f1483b748 100644
--- a/frontend/src/api/mockApi.promoteToDeal.test.js
+++ b/frontend/src/api/mockApi.promoteToDeal.test.js
@@ -126,6 +126,39 @@ describe('mock dealQuotations.promoteToDeal (GLA-136)', () => {
expect(await listIds()).toContain(ghost);
});
+ // Owner ruling 2026-10-28 (mirror of DealQuotationService#createRevision): the quotation an order was
+ // confirmed from is the agreed order and cannot be revised.
+ it('refuses a revision of the confirmed quotation (409), but not before the order is confirmed', async () => {
+ const ghost = await quotationOnlyTicket();
+ const approved = await approvedQuotation(ghost);
+ const { quotation: revision } = await api.dealQuotations.createRevision(approved.id);
+ expect(revision.parentQuotationId).toBe(approved.id);
+ await api.dealQuotations.cancel(revision.id);
+
+ await api.dealQuotations.promoteToDeal(approved.id);
+
+ await expect(api.dealQuotations.createRevision(approved.id)).rejects.toMatchObject({ status: 409 });
+ });
+
+ // Round 3 (mirror of DealQuotationService.submit/approve/createReorder): after confirm-order no OTHER direct
+ // quotation on the deal may be cloned from the confirmed one, submitted or approved, and
+ // are refused with 409.
+ it('after confirm: no clone of the confirmed quotation, and a draft/pending one opened before cannot be submitted/approved', async () => {
+ const ghost = await quotationOnlyTicket();
+ const v1 = await approvedQuotation(ghost);
+ const { quotation: clone } = await api.dealQuotations.createReorder(v1.id);
+ const { quotation: revision } = await api.dealQuotations.createRevision(v1.id);
+ await api.dealQuotations.submit(revision.id);
+ await api.dealQuotations.promoteToDeal(v1.id);
+
+ await expect(api.dealQuotations.createReorder(v1.id)).rejects.toMatchObject({ status: 409 });
+ await expect(api.dealQuotations.submit(clone.id)).rejects.toMatchObject({ status: 409 });
+ await asRole('sales_manager');
+ await expect(api.dealQuotations.approve(revision.id)).rejects.toMatchObject({ status: 409 });
+ const { quotation: still } = await api.dealQuotations.get(v1.id);
+ expect(still.docStatus).toBe('APPROVED');
+ });
+
// Owner ruling 2026-10-23 (DepositNoticeRepository#foldAdjustments): the deposit draft carries no
// qty -1 ส่วนลดพิเศษ row; a flat 95 is spread by line amount (TILE 8,500 takes 85, PLAIN 1,000
// takes 10), so the subtotal is the quotation's 9,405. The mock computes no % adjustment amount
@@ -213,81 +246,4 @@ describe('mock dealQuotations.promoteToDeal (GLA-136)', () => {
expect(ticket.events.filter((e) => e.kind === 'POLICY_CHANGED').map((e) => e.message))
.toEqual([expect.stringContaining(approved.number)]);
});
-
- // PR #1173 review F1, mirrors DealQuotationService#approve -> TicketService
- // #followQuotationDepositPolicy: while CUSTOMER_CONFIRMED, a policy the system set follows the
- // newly approved revision; a policy a person set is kept.
- async function approvedWith(ticketId, payload) {
- const { quotation: draft } = await api.dealQuotations.create(ticketId, { recipientType: 'DESIGNER', ...payload });
- await api.dealQuotations.submit(draft.id);
- await asRole('sales_manager');
- const { quotation } = await api.dealQuotations.approve(draft.id);
- await asRole('sales');
- return quotation;
- }
-
- async function approvedRevisionOf(source, payload) {
- const { quotation: revision } = await api.dealQuotations.createRevision(source.id);
- await api.dealQuotations.update(revision.id, { recipientType: 'DESIGNER', ...payload });
- await api.dealQuotations.submit(revision.id);
- await asRole('sales_manager');
- const { quotation } = await api.dealQuotations.approve(revision.id);
- await asRole('sales');
- return quotation;
- }
-
- const ZERO = { depositPercent: 0, fullPaymentTerm: 'BEFORE_DELIVERY', items: [TILE, PLAIN] };
- const FIFTY = { depositPercent: 50, items: [TILE, PLAIN] };
- const policyEvents = (ticket) => ticket.events.filter((e) => e.kind === 'POLICY_CHANGED').map((e) => e.message);
-
- it('a confirmed 0% deal whose approved revision adds a deposit goes back to REQUIRED', async () => {
- const ghost = await quotationOnlyTicket();
- const zero = await approvedWith(ghost, ZERO);
- await api.dealQuotations.promoteToDeal(zero.id);
-
- const revision = await approvedRevisionOf(zero, FIFTY);
-
- const { ticket } = await api.tickets.get(ghost);
- expect(ticket.summary.depositPolicy).toBe('REQUIRED');
- expect(ticket.summary.depositPolicyReason).toBe(`ใบเสนอราคา ${revision.number} ระบุมัดจำ 50%`);
- expect(policyEvents(ticket)).toHaveLength(2);
- });
-
- it('a confirmed 0% deal whose approved revision leaves the percent blank counts as 30 and is REQUIRED', async () => {
- const ghost = await quotationOnlyTicket();
- const zero = await approvedWith(ghost, ZERO);
- await api.dealQuotations.promoteToDeal(zero.id);
-
- const revision = await approvedRevisionOf(zero, { depositPercent: null, fullPaymentTerm: null, items: [TILE, PLAIN] });
-
- const { ticket } = await api.tickets.get(ghost);
- expect(ticket.summary.depositPolicy).toBe('REQUIRED');
- expect(ticket.summary.depositPolicyReason).toBe(`ใบเสนอราคา ${revision.number} ระบุมัดจำ 30%`);
- });
-
- it('a confirmed 50% deal whose approved revision says ไม่รับมัดจำ moves to NOT_REQUIRED', async () => {
- const ghost = await quotationOnlyTicket();
- const fifty = await approvedWith(ghost, FIFTY);
- await api.dealQuotations.promoteToDeal(fifty.id);
-
- const revision = await approvedRevisionOf(fifty, ZERO);
-
- const { ticket } = await api.tickets.get(ghost);
- expect(ticket.summary.depositPolicy).toBe('NOT_REQUIRED');
- expect(policyEvents(ticket)).toEqual([expect.stringContaining(revision.number)]);
- });
-
- it('a NOT_REQUIRED a person set is kept when a revision still carries a deposit', async () => {
- const ghost = await quotationOnlyTicket();
- const fifty = await approvedWith(ghost, FIFTY);
- await api.dealQuotations.promoteToDeal(fifty.id);
- await api.tickets.setDepositPolicy(ghost, { policy: 'NOT_REQUIRED', reason: 'ลูกค้าโอนเต็มจำนวนล่วงหน้า' });
-
- await approvedRevisionOf(fifty, FIFTY);
-
- const { ticket } = await api.tickets.get(ghost);
- expect(ticket.summary.depositPolicy).toBe('NOT_REQUIRED');
- expect(ticket.summary.depositPolicyReason).toBe('ลูกค้าโอนเต็มจำนวนล่วงหน้า');
- expect(policyEvents(ticket)).toHaveLength(1);
- });
});
diff --git a/frontend/src/api/mockApi.stageDecisionsRoute.test.js b/frontend/src/api/mockApi.stageDecisionsRoute.test.js
index 168329ec2..7d5f81dd1 100644
--- a/frontend/src/api/mockApi.stageDecisionsRoute.test.js
+++ b/frontend/src/api/mockApi.stageDecisionsRoute.test.js
@@ -86,3 +86,23 @@ describe('mockStageDecisions — onRoute mirrors DealRoute', () => {
}
});
});
+
+// QA-05 (owner ruling 2026-10-28): mirror of TicketService's "order confirmed => no rewind below
+// ขั้น 10". Plumbing only; the 409 itself is proved by StageDecisionIntegrationTest on the Java side.
+describe('mockStageDecisions — no rewind below ขั้น 10 once the order is confirmed', () => {
+ it('offers no stage below ORDER_RECEIVED on a deal whose payment track has started', async () => {
+ await api.auth.login({ role: 'ceo' });
+ const { tickets } = await api.tickets.list();
+ const confirmed = tickets.find((t) => (t.lifecycle ?? 'ACTIVE') === 'ACTIVE'
+ && t.paymentStatus && ['CUSTOMER_CONFIRMED', 'DEPOSIT_NOTICE_ISSUED', 'DEPOSIT_PAID'].includes(t.paymentStatus));
+ expect(confirmed, 'the mock must hold an order-confirmed deal').toBeTruthy();
+
+ const { stageDecisions } = await api.tickets.actions(confirmed.id);
+ const below = stageDecisions.filter((d) => d.no < 10 && d.stage !== confirmed.salesStage);
+ expect(below.length).toBeGreaterThan(0);
+ below.forEach((d) => {
+ expect(d.allowed, d.stage).toBe(false);
+ expect(d.blockedReason).toBeTruthy();
+ });
+ });
+});
diff --git a/frontend/src/features/commissions/CommissionPage.jsx b/frontend/src/features/commissions/CommissionPage.jsx
index 17265a634..17b01ff06 100644
--- a/frontend/src/features/commissions/CommissionPage.jsx
+++ b/frontend/src/features/commissions/CommissionPage.jsx
@@ -684,7 +684,7 @@ export function CommissionPage({ user, showToast }) {
return;
}
setLoadedTicket(summary);
- const defaultGross = summary.amountPayableExVat ?? summary.amountPayable;
+ const defaultGross = summary.amountPayable;
updateCreateForm('grossAmount', defaultGross != null ? String(defaultGross) : '');
} catch (error) {
setLoadedTicket(null);
diff --git a/frontend/src/features/commissions/CommissionPage.test.jsx b/frontend/src/features/commissions/CommissionPage.test.jsx
index 842bdb282..347c79d78 100644
--- a/frontend/src/features/commissions/CommissionPage.test.jsx
+++ b/frontend/src/features/commissions/CommissionPage.test.jsx
@@ -444,7 +444,7 @@ function financeDeal(overrides = {}) {
code: 'TCK-0042',
customerName: 'บริษัท ทดสอบ จำกัด',
salesStage: 'CLOSED_PAID',
- money: { amountPayable: 3210000 },
+ money: { amountPayable: 3210000, amountPayableExVat: 3000000 },
...overrides,
};
}
@@ -495,7 +495,7 @@ describe('CommissionPage — account create-from-deal tax invoice upload', () =>
// fields are mapped from the finance view: code, customer, and the amount from `money`
expect(screen.getByText('TCK-0042')).not.toBeNull();
expect(screen.getByText(/฿3,210,000.00/)).not.toBeNull();
- expect((screen.getByLabelText(/ยอดรวม/)).value).toBe('3210000');
+ expect((screen.getByLabelText(/ยอดรับจริง/)).value).toBe('3210000');
});
it('a finance-view deal that has not reached CLOSED_PAID is refused with the stage in the message', async () => {
@@ -529,7 +529,7 @@ describe('CommissionPage — account create-from-deal tax invoice upload', () =>
fireEvent.change(screen.getByLabelText(/เลขที่ใบกำกับ/), { target: { value: 'INV-0042' } });
const original = new File(['x'], 'tax-invoice-0042.jpg', { type: 'image/jpeg' });
fireEvent.change(document.getElementById('commission-invoice-file'), { target: { files: [original] } });
- fireEvent.change(screen.getByLabelText(/ยอดรวม/), { target: { value: '3210000' } });
+ fireEvent.change(screen.getByLabelText(/ยอดรับจริง/), { target: { value: '3210000' } });
fireEvent.submit(screen.getByRole('button', { name: 'บันทึกและสร้างคำขอค่าคอม' }).closest('form'));
await waitFor(() => expect(api.commissions.createFromDeal).toHaveBeenCalledTimes(1));
await waitFor(() => expect(spy).toHaveBeenCalledWith({ queryKey: ['finance'] }));
@@ -542,7 +542,7 @@ describe('CommissionPage — account create-from-deal tax invoice upload', () =>
fireEvent.change(screen.getByLabelText(/เลขที่ใบกำกับ/), { target: { value: 'INV-0042' } });
const original = new File(['fake-jpeg-bytes'], 'tax-invoice-0042.jpg', { type: 'image/jpeg' });
fireEvent.change(document.getElementById('commission-invoice-file'), { target: { files: [original] } });
- fireEvent.change(screen.getByLabelText(/ยอดรวม/), { target: { value: '3210000' } });
+ fireEvent.change(screen.getByLabelText(/ยอดรับจริง/), { target: { value: '3210000' } });
// fireEvent.click on the submit button runs jsdom's native constraint validation first,
// which (unlike a real browser) does not reliably see the file input as satisfied after a
@@ -569,7 +569,7 @@ describe('CommissionPage — account create-from-deal tax invoice upload', () =>
fireEvent.change(screen.getByLabelText(/เลขที่ใบกำกับ/), { target: { value: 'INV-0042' } });
const pdf = new File(['fake-pdf-bytes'], 'tax-invoice-0042.pdf', { type: 'application/pdf' });
fireEvent.change(document.getElementById('commission-invoice-file'), { target: { files: [pdf] } });
- fireEvent.change(screen.getByLabelText(/ยอดรวม/), { target: { value: '3210000' } });
+ fireEvent.change(screen.getByLabelText(/ยอดรับจริง/), { target: { value: '3210000' } });
// fireEvent.click on the submit button runs jsdom's native constraint validation first,
// which (unlike a real browser) does not reliably see the file input as satisfied after a
diff --git a/frontend/src/features/commissions/InvoiceFromDealForm.jsx b/frontend/src/features/commissions/InvoiceFromDealForm.jsx
index bc22dbe7e..12b8bc340 100644
--- a/frontend/src/features/commissions/InvoiceFromDealForm.jsx
+++ b/frontend/src/features/commissions/InvoiceFromDealForm.jsx
@@ -110,10 +110,10 @@ export function InvoiceFromDealForm({
-
+
diff --git a/frontend/src/features/finance/FinanceDealPage.jsx b/frontend/src/features/finance/FinanceDealPage.jsx
index 85a0fda0a..35cdc14f2 100644
--- a/frontend/src/features/finance/FinanceDealPage.jsx
+++ b/frontend/src/features/finance/FinanceDealPage.jsx
@@ -291,7 +291,7 @@ export function FinanceDealPage() {
setFlowError('');
if (a.action === 'RECORD_INVOICE') {
// ex-VAT default (the invoice is raised before VAT); fall back to the payable only for an older payload.
- const gross = deal.money.amountPayableExVat ?? deal.money.amountPayable;
+ const gross = deal.money.amountPayable;
setInvoiceForm(emptyInvoiceForm(gross != null ? String(gross) : ''));
setInvoiceFileKey((k) => k + 1);
}
diff --git a/frontend/src/features/finance/FinanceDealPage.test.jsx b/frontend/src/features/finance/FinanceDealPage.test.jsx
index 927b5a4b7..a70904985 100644
--- a/frontend/src/features/finance/FinanceDealPage.test.jsx
+++ b/frontend/src/features/finance/FinanceDealPage.test.jsx
@@ -360,7 +360,7 @@ describe('FinanceDealPage', () => {
});
});
- describe('RECORD_INVOICE (invoice recording in-page, ex-VAT default)', () => {
+ describe('RECORD_INVOICE (invoice recording in-page, VAT-inclusive amount received default)', () => {
const recordInvoice = () => action('RECORD_INVOICE', 'บันทึกใบกำกับ');
const closedDeal = (extra = {}) => {
const deal = makeFinanceDeal({
@@ -385,13 +385,16 @@ describe('FinanceDealPage', () => {
expect(screen.queryByText(/ออกค่าคอม/)).toBeNull();
});
- it('clicking it opens an in-page form (no navigation to /commissions) whose ยอดรวม defaults to the EX-VAT figure', async () => {
+ it('clicking it opens an in-page form (no navigation to /commissions) whose amount received defaults to the VAT-INCLUSIVE payable (QA-02)', async () => {
api.finance.getDeal.mockResolvedValue({ deal: closedDeal() });
renderPage();
fireEvent.click(await screen.findByRole('button', { name: 'บันทึกใบกำกับ' }));
- const gross = await screen.findByLabelText(/^ยอดรวม/);
- expect(gross.value).toBe('100000');
- expect(gross.value).not.toBe('107000');
+ // QA-02 (owner ruling 2026-10-28): CommissionCalculator.calculateInvoice divides by 1.07 itself, so the
+ // field is the amount received INCLUDING VAT; pre-filling the ex-VAT figure strips VAT twice.
+ const gross = await screen.findByLabelText(/ยอดรับจริง \(รวม VAT\)/);
+ expect(gross.value).toBe('107000');
+ expect(gross.value).not.toBe('100000');
+ expect(screen.queryByLabelText(/ก่อน VAT/)).toBeNull();
expect(screen.queryByText('WORKLIST-PAGE')).toBeNull();
expect(screen.getByLabelText(/เลขที่ใบกำกับ/)).not.toBeNull();
expect(screen.getByLabelText(/วันที่ใบกำกับ/)).not.toBeNull();
@@ -428,7 +431,7 @@ describe('FinanceDealPage', () => {
expect(payload.ticketId).toBe(501);
expect(payload.invoiceNumber).toBe('INV-0501');
expect(payload.invoiceDate).toBe('2026-09-20');
- expect(Number(payload.grossAmount)).toBe(100000);
+ expect(Number(payload.grossAmount)).toBe(107000);
expect(Number(payload.bankFees)).toBe(50);
expect(Number(payload.suspenseVat)).toBe(7000);
expect(Number(payload.transportFee)).toBe(300);
@@ -458,6 +461,8 @@ describe('FinanceDealPage', () => {
expect(closed.getByText(/INV-0501/)).not.toBeNull();
expect(closed.getByText(/20 ก\.ย\. 2569|20\/09\/2569|2026-09-20/)).not.toBeNull();
expect(closed.getByText(/100,000\.00/)).not.toBeNull();
+ expect(closed.getByText(/ยอดรับจริง \(รวม VAT\)/)).not.toBeNull();
+ expect(closed.queryByText(/ก่อน VAT/)).toBeNull();
for (const v of ['50.00', '7,000.00', '300.00', '200.00', '10.00', '3,000.00', '5.00']) {
expect(closed.getAllByText(new RegExp(v.replace('.', '\\.'))).length).toBeGreaterThan(0);
}
diff --git a/frontend/src/features/finance/FinanceMilestoneSections.jsx b/frontend/src/features/finance/FinanceMilestoneSections.jsx
index 1016e2154..e315f6aff 100644
--- a/frontend/src/features/finance/FinanceMilestoneSections.jsx
+++ b/frontend/src/features/finance/FinanceMilestoneSections.jsx
@@ -301,7 +301,7 @@ const COMMISSION_APPROVAL = {
};
const INVOICE_FIELDS = [
- ['grossAmount', 'ยอดรวม (ก่อน VAT)'],
+ ['grossAmount', 'ยอดรับจริง (รวม VAT)'],
['bankFees', 'ค่าธรรมเนียมธนาคาร'],
['suspenseVat', 'ภาษีพัก (Suspense VAT)'],
['transportFee', 'ค่าขนส่ง'],
diff --git a/frontend/src/features/pricingRequests/PricingRequestCreateModal.jsx b/frontend/src/features/pricingRequests/PricingRequestCreateModal.jsx
index 72599b76f..08a95dbde 100644
--- a/frontend/src/features/pricingRequests/PricingRequestCreateModal.jsx
+++ b/frontend/src/features/pricingRequests/PricingRequestCreateModal.jsx
@@ -179,6 +179,8 @@ function emptyItemFromTicketItem(ticketItem) {
// rounds up to a full box unless the rep opts out.
roundToFullBox: true,
originCountry: seededOrigin,
+ // QA-04: sales flags stock per line; seeded from the deal line. IN_THAILAND is the only value this form sets.
+ stockSource: ticketItem?.sourcedFromStock ? 'IN_THAILAND' : null,
// GLA-125: typed name when originCountry = "อื่นๆ" — see QuotationItemRow's own comment.
originCountryOther: null,
leadTimeMinDays: seededLeadTime.leadTimeMinDays,
@@ -316,6 +318,7 @@ function itemFromExisting(item) {
piecesPerBox: item?.piecesPerBox ?? '',
roundToFullBox: item?.roundToFullBox !== false,
originCountry: item?.originCountry ?? '',
+ stockSource: item?.stockSource ?? null,
originCountryOther: item?.originCountryOther ?? null,
leadTimeMinDays: item?.leadTimeMinDays ?? null,
leadTimeMaxDays: item?.leadTimeMaxDays ?? null,
@@ -390,6 +393,7 @@ function pricingRequestItemInputFromRow(item) {
sqmPerBox: item.sqmPerBox === '' || item.sqmPerBox == null ? null : Number(item.sqmPerBox),
roundToFullBox: item.roundToFullBox !== false,
originCountry: item.originCountry || null,
+ stockSource: item.stockSource ?? null,
// GLA-125: sent as-typed; the server (PricingRequestService#resolveItem) is what clears this
// when originCountry is not "อื่นๆ" — this row-level payload builder does no normalization
// itself, matching how it never normalizes any other field either (server-side derivation
@@ -744,7 +748,7 @@ export function PricingRequestCreateModal({
// GLA-125: ประเทศต้นทาง is required on THIS form (owner ruling 2026-09-18). ระยะเวลานำเข้า is
// NOT (owner ruling 2026-10-18: import gives the time) — the origin's default still rides
// along on the wire, and the field itself is hidden (hideLeadTime below).
- requireOriginCountry: true,
+ requireOriginCountry: !item.stockSource, // QA-04: a stock line has no import origin (backend exempts it)
});
// The lead-time range check still runs inside validateQuotationItem, but its error renders only in
// the field hideLeadTime removes, so it could block a save with nothing red on screen. The server
@@ -1056,7 +1060,7 @@ export function PricingRequestCreateModal({
// GLA-125 (owner ruling 2026-09-18): ประเทศต้นทาง (and its "อื่นๆ" typed name) is
// required on THIS form only — direct-deal keeps it optional. ระยะเวลานำเข้า is not
// asked here at all (owner ruling 2026-10-18: import gives the time).
- requireOriginCountry
+ requireOriginCountry={!item.stockSource}
hideLeadTime
errors={itemFieldErrors[index] ?? {}}
onChange={(patch) => updateItem(index, patch)}
@@ -1085,6 +1089,11 @@ export function PricingRequestCreateModal({
{UNIT_BASIS_OPTIONS.map((option) => )}
+
);
}}
diff --git a/frontend/src/features/pricingRequests/PricingRequestCreateModal.test.jsx b/frontend/src/features/pricingRequests/PricingRequestCreateModal.test.jsx
index 9ed5cd262..2b2d3c996 100644
--- a/frontend/src/features/pricingRequests/PricingRequestCreateModal.test.jsx
+++ b/frontend/src/features/pricingRequests/PricingRequestCreateModal.test.jsx
@@ -221,6 +221,64 @@ describe('PricingRequestCreateModal', () => {
expect(item.factory).toBe('โรงงาน A');
});
+ // QA-04 (owner ruling 2026-10-28): SALES flags stock per line when creating the PCR (the CEO types the
+ // price, sales does not). The deal line's sourcedFromStock seeds the flag; the payload carries
+ // stockSource (backend PricingRequestItemRequest: IN_THAILAND | IN_TRANSIT; null = import).
+ describe('stock flag per line (QA-04)', () => {
+ const stockCheckbox = () => screen.getByRole('checkbox', { name: /จากสต็อก/ });
+ const submitWith = async (overrides) => {
+ const { createFn } = renderModal(overrides);
+ fireEvent.change(screen.getByPlaceholderText('เช่น ชื่อผู้ออกแบบ หรือชื่อบริษัทผู้ซื้อ'), { target: { value: 'ผู้ออกแบบ ก.' } });
+ fillRequiredFields();
+ return { createFn, send: async () => {
+ fireEvent.click(screen.getByRole('button', { name: /ส่งให้ฝ่ายนำเข้า/ }));
+ await waitFor(() => expect(createFn).toHaveBeenCalledTimes(1));
+ return createFn.mock.calls[0][0].items;
+ } };
+ };
+
+ it('seeds the flag from the deal item sourcedFromStock, and the create payload carries stockSource', async () => {
+ const { send } = await submitWith({ ticketItems: [ticketItem({ sourcedFromStock: true })] });
+ expect(stockCheckbox().checked).toBe(true);
+ const [item] = await send();
+ expect(item.stockSource).toBe('IN_THAILAND');
+ });
+
+ it('leaves an ordinary import line unflagged: stockSource is null', async () => {
+ const { send } = await submitWith({ ticketItems: [ticketItem({ sourcedFromStock: false })] });
+ expect(stockCheckbox().checked).toBe(false);
+ const [item] = await send();
+ expect(item.stockSource ?? null).toBeNull();
+ });
+
+ it('lets sales tick the flag on a line the deal did not flag', async () => {
+ const on = await submitWith({ ticketItems: [ticketItem()] });
+ fireEvent.click(stockCheckbox());
+ expect((await on.send())[0].stockSource).toBe('IN_THAILAND');
+ });
+
+ it('a stock line needs no ประเทศต้นทาง (backend exempts it); an import line still does', async () => {
+ const { createFn } = renderModal({ ticketItems: [ticketItem({ sourcedFromStock: true })] });
+ fireEvent.change(screen.getByPlaceholderText('เช่น ชื่อผู้ออกแบบ หรือชื่อบริษัทผู้ซื้อ'), { target: { value: 'ผู้ออกแบบ ก.' } });
+ fillRequiredFields();
+ fireEvent.change(screen.getByLabelText(/^ประเทศต้นทาง/), { target: { value: '' } });
+ fireEvent.click(screen.getByRole('button', { name: /ส่งให้ฝ่ายนำเข้า/ }));
+ await waitFor(() => expect(createFn).toHaveBeenCalledTimes(1));
+ expect(createFn.mock.calls[0][0].items[0].originCountry).toBeNull();
+
+ fireEvent.click(stockCheckbox()); // back to an import line -> origin required again
+ fireEvent.click(screen.getByRole('button', { name: /ส่งให้ฝ่ายนำเข้า/ }));
+ expect(await screen.findByText('กรุณาระบุประเทศต้นทาง')).not.toBeNull();
+ expect(createFn).toHaveBeenCalledTimes(1);
+ });
+
+ it('un-ticking a seeded line sends it as an import line', async () => {
+ const off = await submitWith({ ticketItems: [ticketItem({ sourcedFromStock: true })] });
+ fireEvent.click(stockCheckbox());
+ expect((await off.send())[0].stockSource ?? null).toBeNull();
+ });
+ });
+
it('blocks an item with no model — the identity/completeness gate reuses รุ่น, not a dedicated identity field', async () => {
const { createFn } = renderModal({ ticketItems: [] }); // no deal items to seed from -> one blank row
fireEvent.change(screen.getByPlaceholderText('เช่น ชื่อผู้ออกแบบ หรือชื่อบริษัทผู้ซื้อ'), { target: { value: 'ผู้ออกแบบ ก.' } });
diff --git a/frontend/src/features/pricingRequests/PricingRequestDetailPage.jsx b/frontend/src/features/pricingRequests/PricingRequestDetailPage.jsx
index ed27a51ba..bc841a6c3 100644
--- a/frontend/src/features/pricingRequests/PricingRequestDetailPage.jsx
+++ b/frontend/src/features/pricingRequests/PricingRequestDetailPage.jsx
@@ -73,7 +73,7 @@ import {
import { useUnitBasisCatalog } from './unitBasisCatalog.js';
import { buttonVariants } from '../../components/common/Button.jsx';
import { cn } from '../../utils/cn.js';
-import { acceptOutcomeConfirmCopy, newestQuotationByRequest, PRICE_MODE_OPTIONS } from '../quotations/quotationMeta.js';
+import { acceptOutcomeConfirmCopy, CONFIRM_ORDER_FROM_QUOTATION_COPY, newestQuotationByRequest, PRICE_MODE_OPTIONS } from '../quotations/quotationMeta.js';
import { SearchableCombobox } from '../../components/common/SearchableCombobox.jsx';
// B6 (GLA-135): reuses the catalog page's own add-factory dialog rather than a second, drifting
// copy of the same five fields + validation — see ImportFactoryPicker's own comment below.
@@ -883,6 +883,8 @@ export function PricingRequestDetailPage({ user, showToast }) {
const [emailDrafts, setEmailDrafts] = useState({});
const [receiveClientRequestIds, setReceiveClientRequestIds] = useState({});
const [confirmAction, setConfirmAction] = useState(null);
+ // SK-02: confirm-order is irreversible -> dialog first.
+ const [confirmingOrder, setConfirmingOrder] = useState(false);
// Review remediation (COMMIT 5, P1 finding 3): the customer-change revision UI now reuses
// PricingRequestCreateModal in mode="revision" (seeded from the current request, full item
// editing, catalog picker, unit select) instead of the old inline reason-only form that copied
@@ -4322,7 +4324,7 @@ export function PricingRequestDetailPage({ user, showToast }) {
ลูกค้ายอมรับใบเสนอราคาแล้ว — ยืนยันคำสั่งซื้อเพื่อเริ่มขั้นตอนรับมัดจำและนำเข้าสินค้า