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/DealQuotationRepository.java b/backend/src/main/java/th/co/glr/hr/dealquotation/DealQuotationRepository.java
index 656cb6db8..d7d95078c 100644
--- a/backend/src/main/java/th/co/glr/hr/dealquotation/DealQuotationRepository.java
+++ b/backend/src/main/java/th/co/glr/hr/dealquotation/DealQuotationRepository.java
@@ -1418,6 +1418,18 @@ public List expireOverdueQuotations() {
rs.getLong("ticket_id"), rs.getString("number")));
}
+ /** Owner review 2026-10-28: confirming an order from {@code keepId} cancels the deal's OTHER open DEAL_DIRECT
+ * quotations (DRAFT / PENDING_APPROVAL), which could otherwise never be submitted or approved again. Returns the
+ * cancelled numbers so the caller can log them. PRICING_REQUEST-origin rows are never touched. */
+ public List cancelOtherOpenDirectQuotations(long ticketId, long keepId) {
+ return jdbc.queryForList("""
+ UPDATE sales.quotation SET doc_status = 'CANCELLED', updated_at = now()
+ WHERE ticket_id = :ticketId AND quotation_id <> :keepId AND origin = 'DEAL_DIRECT'
+ AND doc_status IN ('DRAFT', 'PENDING_APPROVAL')
+ RETURNING number
+ """, Map.of("ticketId", ticketId, "keepId", keepId), String.class);
+ }
+
/** Compare-and-set DRAFT -> CANCELLED. */
public int cancel(long quotationId) {
return jdbc.update("""
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..9ad5d723c 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() + " ได้รับอนุมัติแล้ว";
@@ -2184,6 +2176,12 @@ public PromoteToDealResultDto confirmOrderFromDirectQuotation(long quotationId,
tickets.addEventWithDocument(ticketId, actor.id(), actor.name(),
TicketEventKind.DEAL_PROMOTED_FROM_QUOTATION, TicketStatus.DRAFT, TicketStatus.QUOTATION_ISSUED,
"ยืนยันคำสั่งซื้อจากใบเสนอราคา " + fresh.number(), RelatedDocumentType.QUOTATION, quotationId);
+ // The agreed order is this quotation: the deal's other open direct quotations can never be submitted or
+ // approved now (see requireNoOtherDirectQuotationAfterConfirm), so cancel them rather than strand them.
+ for (String number : quotations.cancelOtherOpenDirectQuotations(ticketId, quotationId)) {
+ tickets.addEvent(ticketId, actor.id(), actor.name(), TicketEventKind.CANCELLED, null, null,
+ "ยกเลิกใบเสนอราคา " + number + " อัตโนมัติ เพราะยืนยันคำสั่งซื้อจากใบเสนอราคา " + fresh.number());
+ }
// The quotation is the agreed order, so it REPLACES whatever preliminary lines the deal
// carried. Safe at this point: the deal is still pre-order (status was draft, so no delivery
// or stock reservation exists — both key on quotation_issued or later), and the only other
@@ -2399,6 +2397,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 +2573,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 +2606,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..e54944746 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,12 +35,13 @@ 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 (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 ELSE 2 END,
q.accepted_at DESC NULLS LAST, q.issued_at DESC, q.quotation_id DESC
LIMIT 1
- """, Map.of("id", ticketId), (rs, n) -> new QuotationHeader(
+ """.replace("ANSWERED", th.co.glr.hr.ticket.TicketRepository.ANSWERED_QUOTATION_SQL),
+ Map.of("id", ticketId), (rs, n) -> new QuotationHeader(
rs.getLong("quotation_id"), rs.getString("number"), rs.getString("doc_status"),
rs.getBigDecimal("total_amount"), rs.getString("currency"),
rs.getTimestamp("issued_at") == null ? null : rs.getTimestamp("issued_at").toInstant(),
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..87e75e5a5 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,32 @@ public BigDecimal payableAmount(long ticketId) {
return value == null ? BigDecimal.ZERO : value;
}
+ /**
+ * QA-03, ONE rule for "the answered quotation" (payableAmountSelect, paymentTermsRowSql and
+ * {@code FinanceDealRepository#findPayableQuotation} all use it): ACCEPTED, or an APPROVED DEAL_DIRECT quotation
+ * that a DEAL_PROMOTED_FROM_QUOTATION event names -- confirm-order leaves a DEAL_DIRECT quotation APPROVED, and
+ * the event (related QUOTATION = q.quotation_id) is the per-quotation fact that its order was confirmed. An
+ * APPROVED, unconfirmed one is not payable. Table alias {@code q} must be sales.quotation.
+ */
+ public static final String ANSWERED_QUOTATION_SQL =
+ "(q.doc_status = 'ACCEPTED' OR (q.doc_status = 'APPROVED' AND q.origin = 'DEAL_DIRECT'"
+ + " AND EXISTS (SELECT 1 FROM sales.ticket_event pe WHERE pe.ticket_id = q.ticket_id"
+ + " AND pe.kind = '" + TicketEventKind.DEAL_PROMOTED_FROM_QUOTATION + "'"
+ + " AND pe.related_document_type = 'QUOTATION' AND pe.related_document_id = q.quotation_id)))";
+
+ /**
+ * 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 +1762,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 +1798,8 @@ SELECT COALESCE(
0
)
""".formatted(ticketIdRef)
+ .replace("ISSUED_DOCUMENTS", inclVat ? issuedDocumentsPayable(ticketIdRef) : "")
+ .replace("ANSWERED", ANSWERED_QUOTATION_SQL)
.replace("QUOTATION_AMOUNT", quotationAmount)
.replace("ITEMS_AMOUNT", itemsAmount);
}
@@ -1811,8 +1841,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 +1857,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", ANSWERED_QUOTATION_SQL);
}
/** The derived due DATE as a scalar subquery (NULL when there is none) -- the list-scope predicate's twin. */
@@ -2246,6 +2276,17 @@ public int markPromotedToPipeline(long ticketId) {
""", Map.of("id", ticketId));
}
+ /** 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);
+ }
+
/**
* GLA-136 — has this deal's order already been confirmed from a direct quotation
* ({@link TicketEventKind#DEAL_PROMOTED_FROM_QUOTATION}, written exactly once per confirmation by
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..da43c409e 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,112 @@ 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. */
+ /** Review finding 2: confirming the order must not strand the deal's other open direct quotations. */
@Test
- void humanNotRequired_isKept_whenARevisionStillCarriesADeposit() {
+ void confirmOrder_cancelsTheDealsOtherOpenDirectQuotations_andLeavesTheConfirmedOneAlone() {
+ long deal = pipelineTicket(salesActor);
+ DealQuotationDto v1 = approvedDirectQuotation(deal, salesActor);
+ DealQuotationDto clone = quotationService.createReorder(v1.id(), salesActor); // DRAFT clone
+ DealQuotationDto pending = quotationService.createRevision(v1.id(), salesActor); // revision ...
+ quotationService.update(pending.id(), zeroDepositDirectDraft(), salesActor);
+ quotationService.submit(pending.id(), salesActor); // ... PENDING_APPROVAL
+ assertThat(quotationService.get(pending.id(), salesActor).docStatus()).isEqualTo(QuotationStatus.PENDING_APPROVAL);
+
+ confirmer.confirmOrderFromDirectQuotation(v1.id(), salesActor);
+
+ assertThat(quotationService.get(v1.id(), salesActor).docStatus()).isEqualTo(QuotationStatus.APPROVED);
+ assertThat(quotationService.get(clone.id(), salesActor).docStatus()).isEqualTo(QuotationStatus.CANCELLED);
+ assertThat(quotationService.get(pending.id(), salesActor).docStatus()).isEqualTo(QuotationStatus.CANCELLED);
+ assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM sales.ticket_event WHERE ticket_id = :id AND kind = :k",
+ Map.of("id", deal, "k", TicketEventKind.CANCELLED), Integer.class)).isEqualTo(2);
+ }
+
+ /** ...and before the order is confirmed the same revision still works. */
+ @Test
+ 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..6b29ff06c 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,129 @@ 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
+ // ...and writes the per-quotation fact the payable rule keys on: the promotion event naming the quotation
+ long quotationId = jdbc.queryForObject("SELECT quotation_id FROM sales.quotation WHERE ticket_id = :id",
+ new MapSqlParameterSource("id", ticketId), Long.class);
+ tickets.addEventWithDocument(ticketId, salesRepId, "ทดสอบ", "DEAL_PROMOTED_FROM_QUOTATION", null, null,
+ "ยืนยันคำสั่งซื้อ", "QUOTATION", quotationId);
+ 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");
+ }
+
+ /** Review finding 5+6: the fact is per QUOTATION -- a deal on the payment track whose promotion event names a
+ * DIFFERENT quotation must not count this APPROVED direct one. */
+ @Test
+ void approvedDirectQuotation_isNotPayable_whenThePromotionEventNamesAnotherQuotation() {
+ long ticketId = createTicket("PROCUREMENT");
+ // "other" first (createQuotation supersedes earlier live rows), then the direct one, so the direct stays APPROVED
+ long other = tickets.createQuotation(ticketId, "QT-OTHER-" + ticketId, salesRepId, new BigDecimal("1.00")).id();
+ jdbc.update("UPDATE sales.quotation SET doc_status = 'CANCELLED' WHERE quotation_id = :q",
+ new MapSqlParameterSource("q", other)); // out of the payable set on its own
+ long direct = insertAcceptedQuotation(ticketId, "QT-DD-" + ticketId, new BigDecimal("100000.00"));
+ jdbc.update("UPDATE sales.quotation SET origin = 'DEAL_DIRECT', doc_status = 'APPROVED' WHERE quotation_id = :q",
+ new MapSqlParameterSource("q", direct));
+ tickets.updatePaymentStatusUnchecked(ticketId, "CUSTOMER_CONFIRMED");
+ tickets.addEventWithDocument(ticketId, salesRepId, "ทดสอบ", "DEAL_PROMOTED_FROM_QUOTATION", null, null,
+ "ยืนยันคำสั่งซื้อ", "QUOTATION", other);
+ assertThat(jdbc.queryForObject("SELECT doc_status FROM sales.quotation WHERE quotation_id = :q",
+ new MapSqlParameterSource("q", direct), String.class)).isEqualTo("APPROVED"); // not vacuous: it is still live
+
+ assertThat(tickets.payableAmount(ticketId)).isEqualByComparingTo("0");
+ assertThat(service.get(ticketId, accountUser).documents().acceptedQuotation()).isNull();
+ }
+
+ @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/e2e-real/write-commission-invoice-approval.spec.js b/frontend/e2e-real/write-commission-invoice-approval.spec.js
index 3599b945b..88f99b04f 100644
--- a/frontend/e2e-real/write-commission-invoice-approval.spec.js
+++ b/frontend/e2e-real/write-commission-invoice-approval.spec.js
@@ -178,8 +178,9 @@ test.describe('commission invoice approval journey (real stack)', () => {
const before = await readFinanceDeal('account');
expect(before.salesStage).toBe('CLOSED_PAID');
expect(before.commissionInvoice, 'fixture must start with no recorded invoice').toBeNull();
- const exVat = before.money.amountPayableExVat;
- expect(typeof exVat, 'money.amountPayableExVat served as a number').toBe('number');
+ // Owner ruling 2026-10-28 (QA-02): the commission gross is the amount received INCLUDING VAT (workbook column L).
+ const inclVat = before.money.amountPayable;
+ expect(typeof inclVat, 'money.amountPayable served as a number').toBe('number');
const notificationsBefore = await (await sessions.sales_manager.get('/api/notifications')).json();
const pendingNoticesBefore = notificationsBefore.filter((n) => n.type === 'COMMISSION_PENDING_MANAGER').length;
@@ -195,13 +196,13 @@ test.describe('commission invoice approval journey (real stack)', () => {
await shot(page, '01-account-finance-deal-primary-action');
});
- await test.step('clicking opens the in-page form, ยอดรวม defaulting to the pre-VAT figure', async () => {
+ await test.step('clicking opens the in-page form, ยอดรับจริง defaulting to the VAT-inclusive figure', async () => {
await primary.click();
await expect(page.getByRole('heading', { name: 'บันทึกใบกำกับภาษี' })).toBeVisible();
- const gross = page.getByLabel(/ยอดรวม \(ก่อน VAT\)/);
+ const gross = page.getByLabel(/ยอดรับจริง \(รวม VAT\)/);
await expect(gross).toBeVisible();
- await expect(gross).toHaveValue(String(exVat));
- expect(Number(await gross.inputValue())).toBeCloseTo(exVat, 2);
+ await expect(gross).toHaveValue(String(inclVat));
+ expect(Number(await gross.inputValue())).toBeCloseTo(inclVat, 2);
await shot(page, '02-account-invoice-form-opened');
});
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..785088301 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,9 @@ function itemFromExisting(item) {
piecesPerBox: item?.piecesPerBox ?? '',
roundToFullBox: item?.roundToFullBox !== false,
originCountry: item?.originCountry ?? '',
+ stockSource: item?.stockSource ?? null,
+ // remembered so un-ticking then re-ticking จากสต็อก restores the loaded value (IN_TRANSIT) instead of IN_THAILAND
+ loadedStockSource: item?.stockSource ?? null,
originCountryOther: item?.originCountryOther ?? null,
leadTimeMinDays: item?.leadTimeMinDays ?? null,
leadTimeMaxDays: item?.leadTimeMaxDays ?? null,
@@ -390,6 +395,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 +750,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 +1062,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 +1091,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..2389e398c 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: 'ผู้ออกแบบ ก.' } });
@@ -624,6 +682,27 @@ describe('PricingRequestCreateModal edit mode (Fix 2)', () => {
expect(onCreated).toHaveBeenCalledTimes(1);
});
+ // Review finding 3: the row remembers the stockSource it was loaded with, so un-ticking and re-ticking จากสต็อก
+ // restores IN_TRANSIT instead of silently turning it into IN_THAILAND. (PricingRequestItemRequest has no
+ // expectedArrivalDate, so the arrival date is not part of the item input and is not carried here.)
+ it('re-ticking จากสต็อก on a line loaded as IN_TRANSIT restores IN_TRANSIT', async () => {
+ const updateFn = vi.fn().mockResolvedValue({});
+ const initial = editInitialValue();
+ initial.items[0].stockSource = 'IN_TRANSIT';
+ render(
+ ,
+ );
+ const box = screen.getByRole('checkbox', { name: /จากสต็อก/ });
+ expect(box.checked).toBe(true);
+ fireEvent.click(box);
+ fireEvent.click(box);
+
+ fireEvent.click(screen.getByRole('button', { name: 'บันทึกการแก้ไข' }));
+
+ await waitFor(() => expect(updateFn).toHaveBeenCalledTimes(1));
+ expect(updateFn.mock.calls[0][1].items[0].stockSource).toBe('IN_TRANSIT');
+ });
+
// Owner request 2026-09-30: the "เงื่อนไขสำหรับใบเสนอราคา" box is gone, but the header terms it
// used to edit are still persisted on the wire — editing a draft that already carries them must
// NOT null them just because there is no input anymore.
diff --git a/frontend/src/features/pricingRequests/PricingRequestDetailPage.jsx b/frontend/src/features/pricingRequests/PricingRequestDetailPage.jsx
index ed27a51ba..4ffb8ff98 100644
--- a/frontend/src/features/pricingRequests/PricingRequestDetailPage.jsx
+++ b/frontend/src/features/pricingRequests/PricingRequestDetailPage.jsx
@@ -9,6 +9,7 @@ import { Icon } from '../../components/common/Icon.jsx';
import { Button } from '../../components/common/Button.jsx';
import { CollapsibleSection } from '../../components/common/CollapsibleSection.jsx';
import { ConfirmDialog } from '../../components/common/ConfirmDialog.jsx';
+import { ConfirmOrderDialog } from '../quotations/ConfirmOrderDialog.jsx';
import { FormField } from '../../components/common/FormField.jsx';
import { InfoTip } from '../../components/common/InfoTip.jsx';
import { Panel } from '../../components/common/Layout.jsx';
@@ -883,6 +884,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 +4325,7 @@ export function PricingRequestDetailPage({ user, showToast }) {
ลูกค้ายอมรับใบเสนอราคาแล้ว — ยืนยันคำสั่งซื้อเพื่อเริ่มขั้นตอนรับมัดจำและนำเข้าสินค้า
@@ -4363,6 +4366,13 @@ export function PricingRequestDetailPage({ user, showToast }) {
) : null}
+ setConfirmingOrder(false)}
+ onConfirm={() => { setConfirmingOrder(false); confirmOrder.mutate(); }}
+ />
expect(api.pricingRequests.confirmOrder).toHaveBeenCalledWith(
+ // SK-02: the confirm is irreversible -> a dialog first; cancel sends nothing, confirm sends once.
+ const dialog = await screen.findByRole('dialog', { name: 'ยืนยันคำสั่งซื้อ' });
+ expect(api.pricingRequests.confirmOrder).not.toHaveBeenCalled();
+ fireEvent.click(within(dialog).getByRole('button', { name: 'ยกเลิก' }));
+ await waitFor(() => expect(screen.queryByRole('dialog', { name: 'ยืนยันคำสั่งซื้อ' })).toBeNull());
+ expect(api.pricingRequests.confirmOrder).not.toHaveBeenCalled();
+ fireEvent.click(await screen.findByRole('button', { name: 'ยืนยันคำสั่งซื้อ' }));
+ fireEvent.click(within(await screen.findByRole('dialog', { name: 'ยืนยันคำสั่งซื้อ' })).getByRole('button', { name: 'ยืนยันคำสั่งซื้อ' }));
+ await waitFor(() => expect(api.pricingRequests.confirmOrder).toHaveBeenCalledTimes(1));
+ expect(api.pricingRequests.confirmOrder).toHaveBeenCalledWith(
request.summary.id,
expect.objectContaining({ clientRequestId: expect.any(String) }),
- ));
+ );
});
it('offers "สร้างใบแจ้งยอดเงินรับมัดจำ" (not the confirm button) once the bridge has already run', async () => {
diff --git a/frontend/src/features/quotations/ConfirmOrderDialog.jsx b/frontend/src/features/quotations/ConfirmOrderDialog.jsx
new file mode 100644
index 000000000..5e59d9fdf
--- /dev/null
+++ b/frontend/src/features/quotations/ConfirmOrderDialog.jsx
@@ -0,0 +1,27 @@
+import { ConfirmDialog } from '../../components/common/ConfirmDialog.jsx';
+import { CONFIRM_ORDER_FROM_QUOTATION_COPY } from './quotationMeta.js';
+
+/**
+ * The one confirm dialog in front of every door to the irreversible ยืนยันคำสั่งซื้อ (deal-page panel, pricing-request
+ * page, direct-quotation sticky CTA): same title, copy and button label everywhere. The caller owns the open state and
+ * what confirming does; `number` is the quotation / request code the copy names.
+ */
+export function ConfirmOrderDialog({ open, number, busy, onCancel, onConfirm, cancelLabel }) {
+ return (
+
+ {CONFIRM_ORDER_FROM_QUOTATION_COPY.lead(number)} {CONFIRM_ORDER_FROM_QUOTATION_COPY.stage}
+ {' '}{CONFIRM_ORDER_FROM_QUOTATION_COPY.tail}
+
+ )}
+ confirmLabel="ยืนยันคำสั่งซื้อ"
+ {...(cancelLabel ? { cancelLabel } : {})}
+ busy={busy}
+ onCancel={onCancel}
+ onConfirm={onConfirm}
+ />
+ );
+}
diff --git a/frontend/src/features/quotations/QuotationEditorPage.jsx b/frontend/src/features/quotations/QuotationEditorPage.jsx
index e5216d12e..a0f7ea649 100644
--- a/frontend/src/features/quotations/QuotationEditorPage.jsx
+++ b/frontend/src/features/quotations/QuotationEditorPage.jsx
@@ -21,7 +21,7 @@ import { addDaysIso, bangkokTodayIso, dealStageLabel } from '../../utils/format.
import {
canApproveDealQuotation, canApproveDealQuotationNow, canCancelDealQuotation, canCopyDealQuotation, canCreateDealQuotation,
canChangeDealCustomer, canCreateDealQuotationStandalone, canDecideDealQuotation, canEditDealQuotation,
- canPromoteDealQuotationToDeal, canReviseDealQuotation, canSubmitDealQuotation, DEPOSIT_PERCENT_PRESETS,
+ canPromoteDealQuotationToDeal, canReviseDealQuotation, orderConfirmationOf, canSubmitDealQuotation, DEPOSIT_PERCENT_PRESETS,
CONFIRM_ORDER_FROM_QUOTATION_COPY, REVISION_RECIPIENT_LOCKED_MESSAGE, quotationRecipientOption, recipientForDealStage, recipientForEntryChannel,
availablePriceModes, PRICE_MODE_OPTIONS, adjustmentDescriptionPreview, buildQuotationChecklist, currencyForLanguage, DOCUMENT_LANGUAGE_OPTIONS,
isEffectiveZeroDeposit,
@@ -287,7 +287,8 @@ export function QuotationEditorPage({ user, showToast }) {
const canPromoteToDeal = canPromoteDealQuotationToDeal(user, quotation);
const promotionTicketQuery = useQuery({
queryKey: [...queryKeys.ticketDetail(quotation?.ticketId), 'promotionSummary'],
- queryFn: () => api.tickets.get(quotation.ticketId).then((r) => r.ticket?.summary ?? null),
+ queryFn: () => api.tickets.get(quotation.ticketId).then((r) => (r.ticket?.summary
+ ? { ...r.ticket.summary, orderConfirmation: orderConfirmationOf(r.ticket.events) } : null)),
enabled: canPromoteToDeal && quotation?.ticketId != null,
retry: false,
});
@@ -2297,7 +2298,7 @@ export function QuotationEditorPage({ user, showToast }) {
มีรายการที่เปลี่ยนจากราคา CEO — ต้องให้ CEO อนุมัติ
) : null}
- {quotation && canReviseDealQuotation(user, quotation) ? (
+ {quotation && canReviseDealQuotation(user, quotation, promotionTicketQuery.isSuccess ? (promotionTicketQuery.data?.orderConfirmation ?? null) : null) ? (
) : null}
{/* ยืนยันคำสั่งซื้อ (GLA-136's promote, renamed per IA §7/§8 — the same words as the deal
diff --git a/frontend/src/features/quotations/QuotationEditorPage.test.jsx b/frontend/src/features/quotations/QuotationEditorPage.test.jsx
index ecb83ad51..a751363cd 100644
--- a/frontend/src/features/quotations/QuotationEditorPage.test.jsx
+++ b/frontend/src/features/quotations/QuotationEditorPage.test.jsx
@@ -591,6 +591,7 @@ describe('QuotationEditorPage reorder / สร้างจากใบเดิ
api.dealQuotations.get.mockResolvedValue({
quotation: baseQuotation({ docStatus: 'APPROVED', number: 'QT-2026-0005-1' }),
});
+ api.tickets.get.mockResolvedValue({ ticket: { summary: { id: 1, status: 'draft' }, events: [] } });
renderEditor('/quotations/5');
// The control: the sibling action that shared its old gate (canReviseDealQuotation) is there.
await screen.findByRole('button', { name: 'สร้างฉบับแก้ไข' });
@@ -598,6 +599,26 @@ describe('QuotationEditorPage reorder / สร้างจากใบเดิ
expect(screen.queryByRole('menuitem', { name: /สร้างจากใบเดิม|สั่งเหมือนเดิม/ })).toBeNull();
});
+ // Owner ruling 2026-10-28: revise is keyed on the per-quotation order-confirmation fact (the promotion
+ // event names the confirmed quotation), and an unknown fact (loading / errored) counts as hidden.
+ it('hides สร้างฉบับแก้ไข once the order was confirmed from the quotation, and while that is unknown', async () => {
+ api.dealQuotations.get.mockResolvedValue({
+ quotation: baseQuotation({ docStatus: 'APPROVED', number: 'QT-2026-0005-1' }),
+ });
+ api.tickets.get.mockResolvedValue({ ticket: { summary: { id: 1, status: 'quotation_issued' },
+ events: [{ kind: 'DEAL_PROMOTED_FROM_QUOTATION' }] } });
+ const confirmed = renderEditor('/quotations/5');
+ await waitFor(() => expect(api.tickets.get).toHaveBeenCalled());
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ expect(screen.queryByRole('button', { name: 'สร้างฉบับแก้ไข' })).toBeNull();
+ confirmed.unmount();
+
+ api.tickets.get.mockRejectedValue(new Error('boom'));
+ renderEditor('/quotations/5');
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ expect(screen.queryByRole('button', { name: 'สร้างฉบับแก้ไข' })).toBeNull();
+ });
+
// Opus review (2026-09-19): the note must NOT claim "stays approved" when the source has
// ALREADY been superseded by something else (a sibling's approval, not necessarily this
// clone's) -- at minimum, neutral wording only.
diff --git a/frontend/src/features/quotations/quotationMeta.js b/frontend/src/features/quotations/quotationMeta.js
index 66e1fa7a5..258f98451 100644
--- a/frontend/src/features/quotations/quotationMeta.js
+++ b/frontend/src/features/quotations/quotationMeta.js
@@ -155,15 +155,27 @@ export function canCancelDealQuotation(user, quotation) {
* ONLY by its owning sales rep (mirrors DealQuotationService#createRevision, which reuses the
* recordOutcome gate there) -- sales_manager / granted employees may NOT, unlike for DEAL_DIRECT.
* Display-only; the server re-checks. */
-export function canReviseDealQuotation(user, quotation) {
+export function canReviseDealQuotation(user, quotation, order) {
if (quotation?.origin === 'PRICING_REQUEST') {
return user?.role === 'sales'
&& quotation.salesRepId != null && Number(quotation.salesRepId) === Number(user.id)
&& (quotation.docStatus === 'ISSUED' || quotation.docStatus === 'REVISION_REQUESTED');
}
+ // Owner ruling 2026-10-28: once the order is confirmed from a direct quotation it is the agreed order and
+ // cannot be revised (the server's createRevision answers 409). `order` is the deal's confirmation fact from
+ // orderConfirmationOf: omitted = no gate asked for; null = unknown (loading / errored) -> hidden.
+ if (order === null || order?.confirmed) return false;
return canEditDealQuotation(user, quotation) && quotation?.docStatus === 'APPROVED';
}
+/** Whether the deal's order has been confirmed, read from its ticket events: true once a DEAL_PROMOTED_FROM_QUOTATION
+ * event exists. Returns null when the events are not loaded (unknown), which callers treat as "hide". */
+export function orderConfirmationOf(events) {
+ if (!Array.isArray(events)) return null;
+ const promoted = events.find((e) => e.kind === 'DEAL_PROMOTED_FROM_QUOTATION');
+ return { confirmed: Boolean(promoted) };
+}
+
/** GLA-136 (owner ruling 2026-09-30) — "สร้างดีลจากใบเสนอราคา": only an APPROVED direct
* (DEAL_DIRECT) quotation may be promoted into the pipeline, by the same audience that may write
* it (canEditDealQuotation mirrors DealQuotationService#requireEditAccess, which is exactly
diff --git a/frontend/src/features/quotations/quotationMeta.test.js b/frontend/src/features/quotations/quotationMeta.test.js
index 47ed1f209..82e4cd235 100644
--- a/frontend/src/features/quotations/quotationMeta.test.js
+++ b/frontend/src/features/quotations/quotationMeta.test.js
@@ -12,6 +12,7 @@ import {
canEditDealQuotation,
canPromoteDealQuotationToDeal,
canReviseDealQuotation,
+ orderConfirmationOf,
canSubmitDealQuotation,
canTransitionDealQuotation,
canViewDealQuotation,
@@ -226,6 +227,23 @@ describe('canSubmitDealQuotation / canCancelDealQuotation / canReviseDealQuotati
expect(canReviseDealQuotation(salesOwner, quotation({ docStatus: 'PENDING_APPROVAL' }))).toBe(false);
expect(canReviseDealQuotation(otherSales, quotation({ docStatus: 'APPROVED' }))).toBe(false);
});
+
+ // Owner ruling 2026-10-28: once the order is confirmed from a direct quotation it is the agreed order -- no
+ // revise action (the server's createRevision answers 409). The third argument is the deal's order-confirmation
+ // fact; null means "unknown" (still loading / errored) and hides the action.
+ it('revise is hidden once the order is confirmed or while that is unknown, shown only when known-unconfirmed', () => {
+ const approved = quotation({ docStatus: 'APPROVED' });
+ expect(canReviseDealQuotation(salesOwner, approved, { confirmed: false })).toBe(true);
+ expect(canReviseDealQuotation(salesOwner, approved, { confirmed: true })).toBe(false);
+ expect(canReviseDealQuotation(salesManager, approved, { confirmed: true })).toBe(false);
+ expect(canReviseDealQuotation(salesOwner, approved, null)).toBe(false);
+ });
+
+ it('orderConfirmationOf reads the promotion event and the quotation it names from the ticket events', () => {
+ expect(orderConfirmationOf(undefined)).toBeNull();
+ expect(orderConfirmationOf([{ kind: 'COMMENT' }])).toEqual({ confirmed: false });
+ expect(orderConfirmationOf([{ kind: 'DEAL_PROMOTED_FROM_QUOTATION' }])).toEqual({ confirmed: true });
+ });
});
// PR B (revise a PRICING_REQUEST-origin quotation). Mirrors DealQuotationService#createRevision's
diff --git a/frontend/src/features/tickets/DealQuotationPanel.jsx b/frontend/src/features/tickets/DealQuotationPanel.jsx
index 39651db27..980053c82 100644
--- a/frontend/src/features/tickets/DealQuotationPanel.jsx
+++ b/frontend/src/features/tickets/DealQuotationPanel.jsx
@@ -7,6 +7,7 @@ import { api } from '../../api/index.js';
import { queryKeys } from '../../api/queryKeys.js';
import { Button } from '../../components/common/Button.jsx';
import { ConfirmDialog } from '../../components/common/ConfirmDialog.jsx';
+import { ConfirmOrderDialog } from '../quotations/ConfirmOrderDialog.jsx';
import { Icon } from '../../components/common/Icon.jsx';
import { Panel } from '../../components/common/Layout.jsx';
import { StatusBadge } from '../../components/common/StatusBadge.jsx';
@@ -99,6 +100,9 @@ export const DealQuotationPanel = forwardRef(function DealQuotationPanel({
const [outcomeNote, setOutcomeNote] = useState('');
const [downloadingFormat, setDownloadingFormat] = useState(null);
const [confirmingAccept, setConfirmingAccept] = useState(false);
+ // SK-02: the order confirmation is irreversible (the deal can no longer go below ขั้น 10), so every door to it
+ // (sticky primary, ⋯, this panel) opens this dialog first.
+ const [confirmingOrder, setConfirmingOrder] = useState(false);
const pr = useMemo(() => pickRelevantPricingRequest(pricingRequests), [pricingRequests]);
const prQuotation = quotationByRequest?.get(pr?.id);
@@ -146,7 +150,7 @@ export const DealQuotationPanel = forwardRef(function DealQuotationPanel({
openConfirmOrder: () => {
if (confirmOrder.isPending) return;
if (pr && canConfirmOrder(user, pr)) {
- confirmOrder.mutate();
+ setConfirmingOrder(true);
} else {
showToast?.('error', confirmOrderBlockedReason(pr)
?? 'ยังยืนยันคำสั่งซื้อไม่ได้ — ตรวจสอบสถานะคำขอราคาในส่วน "ราคาและใบเสนอราคา" ด้านล่าง');
@@ -308,6 +312,13 @@ export const DealQuotationPanel = forwardRef(function DealQuotationPanel({
) : null}
+ setConfirmingOrder(false)}
+ onConfirm={() => { setConfirmingOrder(false); confirmOrder.mutate(); }}
+ />
canReviseDealQuotation(user, q))
+ .filter((q) => canReviseDealQuotation(user, q, orderConfirmationOf(ticket?.events)))
.reduce((best, q) => (best == null || q.id > best.id ? q : best), null);
const openRevisionDraft = reviseCandidate
? dealQuotationRows.find((q) => q.docStatus === 'DRAFT' && q.parentQuotationId === reviseCandidate.id)
@@ -3063,16 +3064,9 @@ export function TicketDetailPage({ user, ticketId, onBack, showToast }) {
{/* Slice 2 §E — the sticky "ยืนยันคำสั่งซื้อ" of an APPROVED direct quotation. The copy is the
quotation editor's own (quotationMeta's CONFIRM_ORDER_FROM_QUOTATION_COPY), so the two
doors to the same irreversible act say the same thing. */}
-
- {CONFIRM_ORDER_FROM_QUOTATION_COPY.lead(confirm?.number)} {CONFIRM_ORDER_FROM_QUOTATION_COPY.stage}
- {' '}{CONFIRM_ORDER_FROM_QUOTATION_COPY.tail}
-
- )}
- confirmLabel="ยืนยันคำสั่งซื้อ"
+ number={confirm?.number}
cancelLabel="ปิด"
busy={confirmOrderDirectMutation.isPending}
onConfirm={() => confirmOrderDirectMutation.mutate(confirm.quotationId)}
diff --git a/frontend/src/features/tickets/TicketDetailPage.test.jsx b/frontend/src/features/tickets/TicketDetailPage.test.jsx
index ff8b39439..8daf0219c 100644
--- a/frontend/src/features/tickets/TicketDetailPage.test.jsx
+++ b/frontend/src/features/tickets/TicketDetailPage.test.jsx
@@ -2209,7 +2209,7 @@ describe('TicketDetailPage', () => {
await waitFor(() => expect(location()).toBe(expectedLocation));
});
- it('renders exactly one "ยืนยันคำสั่งซื้อ" control (the sticky primary), and clicking it confirms the order directly', async () => {
+ it('renders exactly one "ยืนยันคำสั่งซื้อ" control (the sticky primary); clicking it opens a confirm dialog first (SK-02), and only the dialog confirms', async () => {
api.tickets.get.mockResolvedValue({
ticket: buildTicket({ summary: { lifecycle: 'ACTIVE', salesStage: 'QUOTE_BUYER', createdById: 1, status: 'draft' } }),
});
@@ -2232,10 +2232,24 @@ describe('TicketDetailPage', () => {
fireEvent.click(stickyButtons[0]);
- await waitFor(() => expect(api.pricingRequests.confirmOrder).toHaveBeenCalledWith(
+ // SK-02: irreversible (the deal can no longer go below ขั้น 10) -> a dialog first, nothing sent yet.
+ const dialog = await screen.findByRole('dialog', { name: 'ยืนยันคำสั่งซื้อ' });
+ expect(within(dialog).getByText(/ย้อนกลับไม่ได้/)).not.toBeNull();
+ expect(api.pricingRequests.confirmOrder).not.toHaveBeenCalled();
+
+ // Cancelling sends nothing.
+ fireEvent.click(within(dialog).getByRole('button', { name: 'ยกเลิก' }));
+ await waitFor(() => expect(screen.queryByRole('dialog', { name: 'ยืนยันคำสั่งซื้อ' })).toBeNull());
+ expect(api.pricingRequests.confirmOrder).not.toHaveBeenCalled();
+
+ // Confirming sends exactly once.
+ fireEvent.click(screen.getAllByRole('button', { name: /ยืนยันคำสั่งซื้อ/ })[0]);
+ const again = await screen.findByRole('dialog', { name: 'ยืนยันคำสั่งซื้อ' });
+ fireEvent.click(within(again).getByRole('button', { name: 'ยืนยันคำสั่งซื้อ' }));
+ await waitFor(() => expect(api.pricingRequests.confirmOrder).toHaveBeenCalledTimes(1));
+ expect(api.pricingRequests.confirmOrder).toHaveBeenCalledWith(
501, expect.objectContaining({ clientRequestId: expect.any(String) }),
- ));
- expect(screen.getAllByRole('button', { name: /ยืนยันคำสั่งซื้อ/ })).toHaveLength(1);
+ );
});
});
@@ -2469,6 +2483,9 @@ describe('TicketDetailPage', () => {
expect(actionBarOf(primary).textContent).not.toMatch(/เสนอ(ใบ)?ราคา(เจ้าของโครงการ|ผู้ซื้อ)/);
fireEvent.click(primary);
+ const dialog = await screen.findByRole('dialog', { name: 'ยืนยันคำสั่งซื้อ' });
+ expect(api.pricingRequests.confirmOrder).not.toHaveBeenCalled(); // SK-02: dialog first
+ fireEvent.click(within(dialog).getByRole('button', { name: 'ยืนยันคำสั่งซื้อ' }));
await waitFor(() => expect(api.pricingRequests.confirmOrder).toHaveBeenCalledWith(501, expect.anything()));
});
});