From e3db815cd594f3e9a18d85ba6e2fbe75956e0ae8 Mon Sep 17 00:00:00 2001 From: waritctd <148909369+waritctd@users.noreply.github.com> Date: Wed, 28 Oct 2026 21:17:10 +0700 Subject: [PATCH 1/3] =?UTF-8?q?fix(sales):=20the=20UI-walk=20P0s=20?= =?UTF-8?q?=E2=80=94=20VAT-inclusive=20deposit=20receipt,=20commission=20g?= =?UTF-8?q?ross,=20direct-quote=20payable,=20stock=20flag=20at=20PCR,=20no?= =?UTF-8?q?=20rewind=20below=20=E0=B8=82=E0=B8=B1=E0=B9=89=E0=B8=99=2010?= =?UTF-8?q?=20(owner=20rulings=202026-10-28)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - QA-01 confirmDepositPaid books the notice's VAT-inclusive totalPayable. - QA-02 commission gross pre-fill/compare/default = VAT-inclusive payable, labelled ยอดรับจริง (รวม VAT); CommissionCalculator unchanged (matches the owner's workbooks). - QA-03 a confirmed DEAL_DIRECT quotation counts toward payable; ACCEPTED > APPROVED in all three payable queries; a confirmed direct quotation can't be revised, cloned, or superseded by another direct quotation. - QA-04 pricing-request lines carry จากสต็อก (stockSource IN_THAILAND) seeded from the deal; stock lines skip ประเทศต้นทาง; CEO types the price. - QA-05 ⋯ แก้ไขสถานะ can't move a deal at/after ORDER_RECEIVED below it (409, not offered). - SK-02 ยืนยันคำสั่งซื้อ always asks first. - SK-07 each tax invoice keeps round2(net x 7%) VAT; once a remaining invoice is issued, outstanding and the final receipt follow the issued documents; the remaining-invoice workbook prints stored VAT/total. Co-Authored-By: Claude Opus 5.5 --- .../hr/commission/CommissionCalculator.java | 10 +- .../glr/hr/commission/CommissionService.java | 4 +- .../dealquotation/DealQuotationService.java | 35 +++-- .../glr/hr/deposit/DepositNoticeService.java | 2 + .../glr/hr/deposit/RemainingInvoiceDto.java | 17 ++- .../hr/deposit/RemainingInvoiceRenderer.java | 11 +- .../hr/deposit/RemainingInvoiceService.java | 2 +- .../glr/hr/finance/FinanceDealRepository.java | 7 +- .../th/co/glr/hr/ticket/TicketRepository.java | 49 ++++++- .../th/co/glr/hr/ticket/TicketService.java | 18 ++- .../CommissionAutoCreateIntegrationTest.java | 18 ++- .../CommissionDealLinkageIntegrationTest.java | 19 ++- .../hr/commission/CommissionServiceTest.java | 12 +- ...lQuotationConfirmOrderIntegrationTest.java | 120 ++++++++---------- .../hr/deposit/DepositNoticeServiceTest.java | 24 ++++ .../deposit/RemainingInvoiceRendererTest.java | 49 +++++-- ...emainingInvoiceServiceIntegrationTest.java | 36 +++++- ...inancePayableVatAndDueIntegrationTest.java | 96 ++++++++++++++ .../OrderConfirmationIntegrationTest.java | 6 +- .../ticket/PaymentTrackIntegrationTest.java | 7 +- .../ticket/StageDecisionIntegrationTest.java | 51 ++++++++ frontend/src/api/mockApi.financeDeal.test.js | 4 + frontend/src/api/mockApi.js | 31 ++++- .../src/api/mockApi.promoteToDeal.test.js | 110 +++++----------- .../api/mockApi.stageDecisionsRoute.test.js | 20 +++ .../features/commissions/CommissionPage.jsx | 2 +- .../commissions/CommissionPage.test.jsx | 10 +- .../commissions/InvoiceFromDealForm.jsx | 4 +- .../src/features/finance/FinanceDealPage.jsx | 2 +- .../features/finance/FinanceDealPage.test.jsx | 17 ++- .../finance/FinanceMilestoneSections.jsx | 2 +- .../PricingRequestCreateModal.jsx | 13 +- .../PricingRequestCreateModal.test.jsx | 58 +++++++++ .../PricingRequestDetailPage.jsx | 20 ++- .../PricingRequestDetailPage.test.jsx | 13 +- .../quotations/QuotationEditorPage.jsx | 7 +- .../quotations/QuotationEditorPage.test.jsx | 21 +++ .../src/features/quotations/quotationMeta.js | 14 +- .../features/quotations/quotationMeta.test.js | 18 +++ .../features/tickets/DealQuotationPanel.jsx | 21 ++- .../src/features/tickets/TicketDetailPage.jsx | 4 +- .../tickets/TicketDetailPage.test.jsx | 25 +++- 42 files changed, 759 insertions(+), 250 deletions(-) diff --git a/backend/src/main/java/th/co/glr/hr/commission/CommissionCalculator.java b/backend/src/main/java/th/co/glr/hr/commission/CommissionCalculator.java index 05f94f4e2..d0c477077 100644 --- a/backend/src/main/java/th/co/glr/hr/commission/CommissionCalculator.java +++ b/backend/src/main/java/th/co/glr/hr/commission/CommissionCalculator.java @@ -39,14 +39,8 @@ public class CommissionCalculator { * here) and รับเงินเกิน (overpayment received, added back). Both are applied at the same * "actualReceived" stage as the existing five deduction columns. * - *

Owner confirmation needed: the exact Excel formula for how these two columns - * combine with the others could not be extracted — LibreOffice dropped the cell formulas on - * conversion, leaving only static values behind. This sign convention (WHT subtracted, - * overpayment added, both at this stage, before the VAT strip) is a best-effort mirror of the - * columns' evident intent, not a verified transcription of the source formula. The - * sales-manager review step ({@link CommissionService#updateDeductions}) is the safety net for - * this slice — flag this for the owner to confirm against the Excel/accounting team before - * relying on it unreviewed. + *

Matches the owner's commission workbooks (L = D-E-F-G-H-I-J+K, the amount received incl. VAT; + * M = L/1.07). * *

Backward compatible: when {@code withholdingTax} and {@code overpayment} are both zero * (or null), the result is identical to the pre-Slice-A1 five-column formula. diff --git a/backend/src/main/java/th/co/glr/hr/commission/CommissionService.java b/backend/src/main/java/th/co/glr/hr/commission/CommissionService.java index a1674dbd2..6e9f5c392 100644 --- a/backend/src/main/java/th/co/glr/hr/commission/CommissionService.java +++ b/backend/src/main/java/th/co/glr/hr/commission/CommissionService.java @@ -266,7 +266,7 @@ public CommissionRecord createFromDeal( if (commissions.hasActiveCommissionForTicket(ticketId)) { throw new ApiException(HttpStatus.CONFLICT, "มีรายการค่าคอมมิชชั่นสำหรับดีลนี้อยู่แล้ว"); } - BigDecimal effectiveGrossAmount = grossAmount != null ? grossAmount : tickets.payableAmountExVat(ticketId); + BigDecimal effectiveGrossAmount = grossAmount != null ? grossAmount : tickets.payableAmount(ticketId); SubmitCommissionRequest request = new SubmitCommissionRequest( ticketId, @@ -386,7 +386,7 @@ private DealLinkage resolveDealLinkage(SubmitCommissionRequest request) { throw new ApiException(HttpStatus.UNPROCESSABLE_CONTENT, "ดีลนี้ยังไม่ถึงขั้นตอนรับชำระเงินครบถ้วน (CLOSED_PAID) จึงยังยื่นค่าคอมมิชชั่นไม่ได้"); } - BigDecimal payable = tickets.payableAmountExVat(ticketId); + BigDecimal payable = tickets.payableAmount(ticketId); boolean mismatch = isMismatch(request.grossAmount(), payable); return new DealLinkage(payable, mismatch); } diff --git a/backend/src/main/java/th/co/glr/hr/dealquotation/DealQuotationService.java b/backend/src/main/java/th/co/glr/hr/dealquotation/DealQuotationService.java index cfc5f9f97..b6eeec7dd 100644 --- a/backend/src/main/java/th/co/glr/hr/dealquotation/DealQuotationService.java +++ b/backend/src/main/java/th/co/glr/hr/dealquotation/DealQuotationService.java @@ -1394,6 +1394,7 @@ public DealQuotationDto submit(long id, UserPrincipal actor) { // was loaded above may be what the change we just waited for rewrote. quotations.lockTicket(found.ticketId()); DealQuotationDto quotation = requireQuotation(id); + requireNoOtherDirectQuotationAfterConfirm(quotation); // GLA-123 slice S2: submit is now LIVE for BOTH origins — every validation below (item // completeness, payment term, validity date) is origin-agnostic and applies unchanged; // only approve/reject/issue diverge (see #approve/#reject's own origin branch). @@ -1651,6 +1652,7 @@ public DealQuotationDto approve(long id, ApproveRequest request, UserPrincipal a // directly) -- a transaction here never holds a second ticket's lock at the same time, so // this cannot deadlock against either of them. quotations.lockTicket(quotation.ticketId()); + requireNoOtherDirectQuotationAfterConfirm(quotation); // under the lock: confirm-order takes it too LocalDate approvalDate = LocalDate.now(BANGKOK); // V178: a DATE-mode document WITH special pricing names its OWN validity_date (the rep's // ภายในวันที่, verbatim — approval never recomputes it); every other case (DAYS mode, or a @@ -1711,16 +1713,6 @@ public DealQuotationDto approve(long id, ApproveRequest request, UserPrincipal a quotations.supersede(ancestorId); ancestorId = ancestor.parentQuotationId(); } - // #1173 review F1: on a confirmed deal the system-owned deposit policy follows the newly - // approved quotation, so a revision that adds or drops the deposit moves the deal with it. - // A no-op before confirmation (confirmOrderFromDirectQuotation applies the confirmed - // quotation's percent then). Null-tolerant for hand-wired tests, like - // #advanceDealToRecipientStage. PRICING_REQUEST origin never reaches this: R8 allows one - // accepted quotation per deal, and its percent is applied at OrderConfirmationService#confirmOrder. - if (ticketService != null) { - ticketService.followQuotationDepositPolicy(approved.ticketId(), approved.number(), - DealQuotationRenderAdapter.effectiveDepositPercent(approved.depositPercent()), actor); - } tickets.addEventWithDocument(approved.ticketId(), actor.id(), actor.name(), TicketEventKind.QUOTATION_ISSUED, null, null, "อนุมัติใบเสนอราคา " + approved.number(), RelatedDocumentType.QUOTATION, id); String message = "ใบเสนอราคา " + approved.number() + " ได้รับอนุมัติแล้ว"; @@ -2399,6 +2391,9 @@ public DealQuotationDto createReorder(long id, UserPrincipal actor) { // GLA-123 slice S1, defence in depth — see #createRevision's identical comment. requireReorderAllowedForOrigin(source); requireApprovedForClone(source, "สร้างจากใบเดิมได้จากใบเสนอราคาที่ได้รับอนุมัติแล้วเท่านั้น"); + if (tickets.isOrderConfirmedFromQuotation(source.ticketId(), source.id())) { + throw new ApiException(HttpStatus.CONFLICT, "ใบเสนอราคานี้ยืนยันคำสั่งซื้อแล้ว จึงสร้างจากใบเดิมไม่ได้"); + } // No requireNoOpenRevision here (deliberately) — multiple clones of the same APPROVED // source are allowed, and a clone never contends with an in-progress revision for anything. // Still locks the ticket, same advisory lock #requireNoOpenRevision/#createRevision use, so @@ -2572,6 +2567,21 @@ private DealQuotationDto requireApprovedForClone(DealQuotationDto quotation, Str return quotation; } + /** + * Owner ruling 2026-10-28: once an order is confirmed from a direct quotation, that quotation is the agreed + * order. No OTHER direct quotation on the deal may be submitted or approved (approval would supersede the + * confirmed one, move the payable and flip the deposit policy), and the confirmed one may not be cloned. + */ + private void requireNoOtherDirectQuotationAfterConfirm(DealQuotationDto quotation) { + if (isPricingRequestOrigin(quotation) + || !tickets.hasPromotionFromQuotationEvent(quotation.ticketId()) + || tickets.isOrderConfirmedFromQuotation(quotation.ticketId(), quotation.id())) { + return; + } + throw new ApiException(HttpStatus.CONFLICT, + "ดีลนี้ยืนยันคำสั่งซื้อจากใบเสนอราคาอื่นแล้ว จึงส่ง/อนุมัติใบเสนอราคานี้ไม่ได้"); + } + private static boolean isPricingRequestOrigin(DealQuotationDto quotation) { return "PRICING_REQUEST".equals(quotation.origin()); } @@ -2590,6 +2600,11 @@ private DealQuotationDto requireRevisableStatus(DealQuotationDto quotation) { } return quotation; } + // Owner ruling 2026-10-28: the quotation an order was confirmed from is the agreed order -- not revisable. + if (tickets.isOrderConfirmedFromQuotation(quotation.ticketId(), quotation.id())) { + throw new ApiException(HttpStatus.CONFLICT, + "ใบเสนอราคานี้ยืนยันคำสั่งซื้อแล้ว จึงสร้างฉบับแก้ไขไม่ได้"); + } return requireApprovedForClone(quotation, "สร้างฉบับแก้ไขได้จากใบเสนอราคาที่ได้รับอนุมัติแล้วเท่านั้น"); } diff --git a/backend/src/main/java/th/co/glr/hr/deposit/DepositNoticeService.java b/backend/src/main/java/th/co/glr/hr/deposit/DepositNoticeService.java index 84afd8e65..050acf73d 100644 --- a/backend/src/main/java/th/co/glr/hr/deposit/DepositNoticeService.java +++ b/backend/src/main/java/th/co/glr/hr/deposit/DepositNoticeService.java @@ -456,6 +456,8 @@ record RemainingInvoiceMoney(BigDecimal netAmount, BigDecimal vatAmount, BigDeci RemainingInvoiceMoney computeRemainingInvoiceMoney(BigDecimal itemsTotal, BigDecimal depositAmount) { BigDecimal netAmount = itemsTotal.subtract(depositAmount).setScale(2, RoundingMode.HALF_UP); + // Owner ruling 2026-10-28 (SK-07): each tax invoice's VAT = round2(its own net x 7%) -- usual Thai practice; + // a satang gap against the deal's payable is normal and outstanding follows the ISSUED documents. BigDecimal vatAmount = netAmount.multiply(VAT_RATE).setScale(2, RoundingMode.HALF_UP); BigDecimal grandTotal = netAmount.add(vatAmount).setScale(2, RoundingMode.HALF_UP); return new RemainingInvoiceMoney(netAmount, vatAmount, grandTotal); diff --git a/backend/src/main/java/th/co/glr/hr/deposit/RemainingInvoiceDto.java b/backend/src/main/java/th/co/glr/hr/deposit/RemainingInvoiceDto.java index 0840bf352..fc8f02c34 100644 --- a/backend/src/main/java/th/co/glr/hr/deposit/RemainingInvoiceDto.java +++ b/backend/src/main/java/th/co/glr/hr/deposit/RemainingInvoiceDto.java @@ -27,5 +27,18 @@ public record RemainingInvoiceDto( String projectName, BigDecimal depositAmount, List notes, - List items -) {} + List items, + // The STORED document totals (owner ruling 2026-10-28, SK-07): the sheet prints these as values, not as + // template formulas, so the printed VAT / total are exactly what the issued document and the ledger hold. + // null = not supplied: the template's own formulas stay (only a caller that never stored a total). + BigDecimal vatAmount, + BigDecimal grandTotal +) { + public RemainingInvoiceDto(String docNumber, LocalDate issueDate, String reference, String depositReference, + String customerName, String customerBranch, String customerAddress, + String customerTaxId, String projectName, BigDecimal depositAmount, + List notes, List items) { + this(docNumber, issueDate, reference, depositReference, customerName, customerBranch, customerAddress, + customerTaxId, projectName, depositAmount, notes, items, null, null); + } +} diff --git a/backend/src/main/java/th/co/glr/hr/deposit/RemainingInvoiceRenderer.java b/backend/src/main/java/th/co/glr/hr/deposit/RemainingInvoiceRenderer.java index 2d37f4667..9dcbf5491 100644 --- a/backend/src/main/java/th/co/glr/hr/deposit/RemainingInvoiceRenderer.java +++ b/backend/src/main/java/th/co/glr/hr/deposit/RemainingInvoiceRenderer.java @@ -234,8 +234,15 @@ public byte[] toXlsx(RemainingInvoiceDto doc) throws Exception { // ── Summary rows auto-calculated by existing formulas ───────────── // I42 = SUM(I11:I41) includes item amounts and -depositAmount → remainder - // I43 = I42 * 0.07 (VAT) - // I44 = I42 + I43 (total payable) + // I43 / I44 (VAT, total payable): the STORED, already-rounded figures as VALUES when supplied + // (owner ruling 2026-10-28, SK-07) -- the template's I42 x 7% formula would print an unrounded + // 5,867.505 / 89,689.005 instead of what the issued document and the ledger hold. + if (doc.vatAmount() != null) { + setNum(sh, 42, 8, doc.vatAmount().doubleValue()); + } + if (doc.grandTotal() != null) { + setNum(sh, 43, 8, doc.grandTotal().doubleValue()); + } wb.setForceFormulaRecalculation(true); wb.write(out); return out.toByteArray(); diff --git a/backend/src/main/java/th/co/glr/hr/deposit/RemainingInvoiceService.java b/backend/src/main/java/th/co/glr/hr/deposit/RemainingInvoiceService.java index 30d7f223e..be0a74999 100644 --- a/backend/src/main/java/th/co/glr/hr/deposit/RemainingInvoiceService.java +++ b/backend/src/main/java/th/co/glr/hr/deposit/RemainingInvoiceService.java @@ -404,7 +404,7 @@ private RemainingInvoiceDto toRenderable(RemainingInvoiceDocumentDto doc) { doc.docNumber() != null ? doc.docNumber() : "(ร่าง)", doc.docDate(), doc.reference(), doc.depositReference(), doc.customerName(), doc.customerBranch(), doc.customerAddress(), doc.customerTaxId(), - doc.projectName(), nz(doc.depositDeduction()), doc.notes(), items); + doc.projectName(), nz(doc.depositDeduction()), doc.notes(), items, doc.vatAmount(), doc.grandTotal()); } private List defaultNotes() { diff --git a/backend/src/main/java/th/co/glr/hr/finance/FinanceDealRepository.java b/backend/src/main/java/th/co/glr/hr/finance/FinanceDealRepository.java index 66d730e32..21cff493f 100644 --- a/backend/src/main/java/th/co/glr/hr/finance/FinanceDealRepository.java +++ b/backend/src/main/java/th/co/glr/hr/finance/FinanceDealRepository.java @@ -35,8 +35,11 @@ public Optional findPayableQuotation(long ticketId) { SELECT q.quotation_id, q.number, q.doc_status, q.total_amount, q.currency, q.issued_at, q.accepted_at, q.origin, q.pricing_request_id FROM sales.quotation q - WHERE q.ticket_id = :id AND q.doc_status IN ('ACCEPTED', 'ISSUED', 'SENT') - ORDER BY CASE WHEN q.doc_status = 'ACCEPTED' THEN 0 ELSE 1 END, + WHERE q.ticket_id = :id AND (q.doc_status IN ('ACCEPTED', 'ISSUED', 'SENT') + -- QA-03: same rule as TicketRepository.answeredQuotationPredicate (confirmed DEAL_DIRECT stays APPROVED) + OR (q.doc_status = 'APPROVED' AND q.origin = 'DEAL_DIRECT' + AND (SELECT tp.payment_status FROM sales.ticket tp WHERE tp.ticket_id = q.ticket_id) IS NOT NULL)) + ORDER BY CASE q.doc_status WHEN 'ACCEPTED' THEN 0 WHEN 'APPROVED' THEN 1 ELSE 2 END, CASE q.recipient_type WHEN 'BUYER' THEN 0 WHEN 'OWNER' THEN 1 ELSE 2 END, q.accepted_at DESC NULLS LAST, q.issued_at DESC, q.quotation_id DESC LIMIT 1 diff --git a/backend/src/main/java/th/co/glr/hr/ticket/TicketRepository.java b/backend/src/main/java/th/co/glr/hr/ticket/TicketRepository.java index b02fe84bc..1818110e5 100644 --- a/backend/src/main/java/th/co/glr/hr/ticket/TicketRepository.java +++ b/backend/src/main/java/th/co/glr/hr/ticket/TicketRepository.java @@ -1694,6 +1694,30 @@ public BigDecimal payableAmount(long ticketId) { return value == null ? BigDecimal.ZERO : value; } + /** + * QA-03: a DEAL_DIRECT quotation confirmed via confirm-order stays APPROVED (it never reaches ACCEPTED), so + * it counts as the "answered" quotation -- but only once the order IS confirmed: confirm-order moves the + * ticket onto the payment track (payment_status non-null, CUSTOMER_CONFIRMED). An APPROVED, unconfirmed one + * is not payable. ACCEPTED still outranks it. + */ + private static String answeredQuotationPredicate(String ticketIdRef) { + return "(q.doc_status = 'ACCEPTED' OR (q.doc_status = 'APPROVED' AND q.origin = 'DEAL_DIRECT'" + + " AND (SELECT tp.payment_status FROM sales.ticket tp WHERE tp.ticket_id = " + ticketIdRef + ") IS NOT NULL))"; + } + + /** + * SK-07 (owner ruling 2026-10-28): once a remaining invoice is ISSUED, what the customer owes follows the ISSUED + * documents -- the deposit notice's total_payable plus the remaining invoice's grand_total -- because each tax + * invoice rounds its own VAT, so the sum may sit a satang away from the deal's computed total. Trailing comma + * included; VAT-inclusive variant only (the ex-VAT figure stays the quotation's subtotal). + */ + private static String issuedDocumentsPayable(String ticketIdRef) { + return "(SELECT COALESCE(dn.total_payable, 0) + ri.grand_total FROM sales.remaining_invoice ri" + + " LEFT JOIN sales.deposit_notice dn ON dn.deposit_notice_id = ri.deposit_notice_id" + + " WHERE ri.ticket_id = " + ticketIdRef + " AND ri.status = 'ISSUED' AND ri.grand_total IS NOT NULL" + + " ORDER BY ri.issued_at DESC, ri.id DESC LIMIT 1),"; + } + /** * Full "amount payable" SELECT statement, correlated on {@code ticketIdRef} (either a bind * parameter like {@code ":ticketId"} for a standalone query, or an outer-query column @@ -1736,10 +1760,12 @@ private static String payableAmountSelect(String ticketIdRef, boolean inclVat) { : "(SELECT " + itemsSum; return """ SELECT COALESCE( + ISSUED_DOCUMENTS (SELECT QUOTATION_AMOUNT FROM sales.quotation q - WHERE q.ticket_id = %1$s AND q.doc_status = 'ACCEPTED' - ORDER BY CASE q.recipient_type + WHERE q.ticket_id = %1$s AND ANSWERED + ORDER BY CASE WHEN q.doc_status = 'ACCEPTED' THEN 0 ELSE 1 END, + CASE q.recipient_type WHEN 'BUYER' THEN 0 WHEN 'OWNER' THEN 1 ELSE 2 @@ -1770,6 +1796,8 @@ SELECT COALESCE( 0 ) """.formatted(ticketIdRef) + .replace("ISSUED_DOCUMENTS", inclVat ? issuedDocumentsPayable(ticketIdRef) : "") + .replace("ANSWERED", answeredQuotationPredicate(ticketIdRef)) .replace("QUOTATION_AMOUNT", quotationAmount) .replace("ITEMS_AMOUNT", itemsAmount); } @@ -1811,8 +1839,8 @@ WHEN q.full_payment_term IN ('BEFORE_DELIVERY', 'ON_DELIVERY', 'ON_OR_BEFORE_DEL END AS basis, CASE WHEN q.remainder_mode = 'CREDIT' AND q.credit_days IS NOT NULL THEN q.credit_days::int END AS credit_days FROM sales.quotation q - WHERE q.ticket_id = %1$s AND q.doc_status IN ('ACCEPTED', 'ISSUED', 'SENT') - ORDER BY CASE WHEN q.doc_status = 'ACCEPTED' THEN 0 ELSE 1 END, + WHERE q.ticket_id = %1$s AND (ANSWERED OR q.doc_status IN ('ISSUED', 'SENT')) + ORDER BY CASE q.doc_status WHEN 'ACCEPTED' THEN 0 WHEN 'APPROVED' THEN 1 ELSE 2 END, CASE q.recipient_type WHEN 'BUYER' THEN 0 WHEN 'OWNER' THEN 1 @@ -1827,7 +1855,7 @@ CROSS JOIN (SELECT (MAX(dr.delivered_at) AT TIME ZONE 'Asia/Bangkok')::date AS d WHERE dr.ticket_id = %1$s AND (SELECT t2.fulfillment_status FROM sales.ticket t2 WHERE t2.ticket_id = %1$s) = 'FULLY_DELIVERED') dv - """.formatted(ticketIdRef); + """.formatted(ticketIdRef).replace("ANSWERED", answeredQuotationPredicate(ticketIdRef)); } /** The derived due DATE as a scalar subquery (NULL when there is none) -- the list-scope predicate's twin. */ @@ -2253,6 +2281,17 @@ public int markPromotedToPipeline(long ticketId) { * method's idempotent replay: if the event exists, a replay returns the current state and * writes nothing. */ + /** The DEAL_PROMOTED_FROM_QUOTATION event names the quotation whose confirm-order made the deal an order. */ + public boolean isOrderConfirmedFromQuotation(long ticketId, long quotationId) { + Boolean value = jdbc.queryForObject(""" + SELECT EXISTS(SELECT 1 FROM sales.ticket_event + WHERE ticket_id = :ticketId AND kind = :kind + AND related_document_type = 'QUOTATION' AND related_document_id = :quotationId) + """, Map.of("ticketId", ticketId, "kind", TicketEventKind.DEAL_PROMOTED_FROM_QUOTATION, + "quotationId", quotationId), Boolean.class); + return Boolean.TRUE.equals(value); + } + public boolean hasPromotionFromQuotationEvent(long ticketId) { Boolean value = jdbc.queryForObject(""" SELECT EXISTS(SELECT 1 FROM sales.ticket_event diff --git a/backend/src/main/java/th/co/glr/hr/ticket/TicketService.java b/backend/src/main/java/th/co/glr/hr/ticket/TicketService.java index b134178f6..877e67804 100644 --- a/backend/src/main/java/th/co/glr/hr/ticket/TicketService.java +++ b/backend/src/main/java/th/co/glr/hr/ticket/TicketService.java @@ -951,9 +951,8 @@ private TicketDto applyCustomerConfirmation(long ticketId, TicketSummaryDto s, S /** * Owner ruling 2026-10-22, widened after the #1173 review: while the payment track is at * CUSTOMER_CONFIRMED, a deposit policy the SYSTEM owns follows the deal's quotation. ไม่รับมัดจำ - * (0%) means NOT_REQUIRED; any deposit means REQUIRED. Called at the first confirmation and - * when a DEAL_DIRECT quotation is approved on a confirmed deal ({@code DealQuotationService - * #approve}), so a revision that adds or drops the deposit moves the deal with it. + * (0%) means NOT_REQUIRED; any deposit means REQUIRED. Called at the first confirmation only (the + * confirmed direct quotation can no longer be revised or replaced, owner ruling 2026-10-28). * *

A policy a person chose ({@link #waiveDeposit}: WAIVED, CREDIT_CUSTOMER, or a typed * NOT_REQUIRED) is never touched, and nothing moves once a deposit notice is out. Both are @@ -1029,8 +1028,10 @@ public TicketDto confirmDepositPaid(long ticketId, UserPrincipal actor) { throw new ApiException(HttpStatus.CONFLICT, "ต้องออกใบแจ้งรับมัดจำก่อนจึงจะยืนยันรับชำระมัดจำได้"); } var notice = tickets.latestIssuedDepositNotice(ticketId).orElse(null); - BigDecimal amount = notice != null && notice.depositAmount() != null - ? notice.depositAmount() + // QA-01: the deal payable is VAT-inclusive, so the receipt is the notice's total_payable + // (deposit + VAT), not the pre-VAT deposit_amount. + BigDecimal amount = notice != null && notice.totalPayable() != null + ? notice.totalPayable() : payableAmount(ticket).multiply(new BigDecimal("0.50")); if (amount.signum() <= 0) { throw new ApiException(HttpStatus.CONFLICT, @@ -2126,6 +2127,13 @@ private void requireStageMoveAllowed(TicketSummaryDto s, String targetStage, Use if (targetStage.equals(s.salesStage())) { throw new ApiException(HttpStatus.CONFLICT, "ดีลนี้อยู่ในขั้นตอน " + targetStage + " อยู่แล้ว"); } + // QA-05 (owner ruling 2026-10-28): once the order is confirmed (the deal is at or past ขั้น 10) a manual + // move may not rewind below ORDER_RECEIVED. Keyed on the CURRENT stage only: no repository call here. + if (DealStage.indexOf(s.salesStage()) >= DealStage.indexOf(DealStage.ORDER_RECEIVED) + && DealStage.indexOf(targetStage) < DealStage.indexOf(DealStage.ORDER_RECEIVED)) { + throw new ApiException(HttpStatus.CONFLICT, + "ย้อนสถานะกลับก่อนขั้นรับคำสั่งซื้อไม่ได้ — ยืนยันคำสั่งซื้อไปแล้ว"); + } // Rung 6 of the precedence ladder (design §12.1): the deal's ENTRY-CHANNEL route. Checked // BEFORE the facts because an off-route target's actionable remedy is the channel, not the // fact. Identical outcome for every reachable input: only S4–S8 can be off-route diff --git a/backend/src/test/java/th/co/glr/hr/commission/CommissionAutoCreateIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/commission/CommissionAutoCreateIntegrationTest.java index d3a9f196f..b70fad149 100644 --- a/backend/src/test/java/th/co/glr/hr/commission/CommissionAutoCreateIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/commission/CommissionAutoCreateIntegrationTest.java @@ -317,7 +317,7 @@ void salesCannotApprove_evenAManagerApprovedCommission() { @Test void createFromDeal_omittedGrossAmount_defaultsFromDealPayable() { long ticketId = driveDealToClosedPaid(new BigDecimal("10")); - BigDecimal payable = tickets.payableAmountExVat(ticketId); + BigDecimal payable = tickets.payableAmount(ticketId); // QA-02: VAT-inclusive (the calculator divides by 1.07 itself) assertThat(payable.signum()).isPositive(); CommissionRecord created = commissionService.createFromDeal( @@ -331,13 +331,12 @@ void createFromDeal_omittedGrossAmount_defaultsFromDealPayable() { } /** - * OWNER RULING 2026-09-30: the deal's PAYABLE is now the VAT-inclusive grand total, but commission is - * computed on the PRE-VAT figure it always got. This pins the default gross amount: it equals the ex-VAT - * quotation total, and is strictly LESS than the (VAT-inclusive) payable the customer is billed. - * Mutation check: point CommissionService at tickets.payableAmount(...) and this goes red. + * QA-02 (owner ruling 2026-10-28; supersedes the 2026-09-30 pre-VAT default): the commission workbooks take + * the amount received INCLUDING VAT and CommissionCalculator.calculateInvoice divides by 1.07, so the + * default gross is the VAT-inclusive payable, strictly MORE than the ex-VAT quotation total. */ @Test - void createFromDeal_omittedGrossAmount_staysPreVat_whilePayableIsVatInclusive() { + void createFromDeal_omittedGrossAmount_isVatInclusive_equalToPayable() { long ticketId = driveDealToClosedPaid(new BigDecimal("10")); BigDecimal exVat = tickets.payableAmountExVat(ticketId); BigDecimal inclVat = tickets.payableAmount(ticketId); @@ -348,16 +347,15 @@ void createFromDeal_omittedGrossAmount_staysPreVat_whilePayableIsVatInclusive() invoiceFile(), accountActor); assertThat(inclVat).isGreaterThan(exVat); - assertThat(inclVat).isEqualByComparingTo(exVat.multiply(new BigDecimal("1.07")).setScale(2, java.math.RoundingMode.HALF_UP)); - assertThat(created.invoiceDetails().grossAmount()).isEqualByComparingTo(exVat); - assertThat(created.invoiceDetails().grossAmount()).isNotEqualByComparingTo(inclVat); + assertThat(created.invoiceDetails().grossAmount()).isEqualByComparingTo(inclVat); + assertThat(created.invoiceDetails().grossAmount()).isNotEqualByComparingTo(exVat); assertThat(created.dealAmountMismatch()).isFalse(); } @Test void createFromDeal_providedGrossAmount_isUsedAsGiven_insteadOfDealPayable() { long ticketId = driveDealToClosedPaid(new BigDecimal("10")); - BigDecimal payable = tickets.payableAmountExVat(ticketId); + BigDecimal payable = tickets.payableAmount(ticketId); BigDecimal override = payable.add(new BigDecimal("50000.00")); // deliberately different + beyond threshold CommissionRecord created = commissionService.createFromDeal( diff --git a/backend/src/test/java/th/co/glr/hr/commission/CommissionDealLinkageIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/commission/CommissionDealLinkageIntegrationTest.java index 82de6f2ed..d81b6cd30 100644 --- a/backend/src/test/java/th/co/glr/hr/commission/CommissionDealLinkageIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/commission/CommissionDealLinkageIntegrationTest.java @@ -249,7 +249,7 @@ void submit_rejectsWhenLinkedDealHasNotReachedClosedPaid_andCreatesNoRecord() { @Test void submit_withGrossAmountWithinThreshold_succeedsWithNoMismatchFlag() { long ticketId = driveDealToClosedPaid(new BigDecimal("10")); - BigDecimal payable = tickets.payableAmountExVat(ticketId); + BigDecimal payable = tickets.payableAmount(ticketId); // QA-02: VAT-inclusive assertThat(payable.signum()).isPositive(); // 2% above payable — within the 5% cross-check threshold. BigDecimal grossAmount = payable.multiply(new BigDecimal("1.02")).setScale(2, java.math.RoundingMode.HALF_UP); @@ -266,6 +266,21 @@ void submit_withGrossAmountWithinThreshold_succeedsWithNoMismatchFlag() { assertThat(reloaded.dealPayableAmountSnapshot()).isEqualByComparingTo(payable); } + /** QA-02: a gross 7% below the VAT-inclusive payable (i.e. the ex-VAT figure) IS a mismatch; equal is not. */ + @Test + void submit_withGrossEqualToVatInclusivePayable_isNoMismatch_andExVatGrossIsFlagged() { + long ticketId = driveDealToClosedPaid(new BigDecimal("10")); + BigDecimal payable = tickets.payableAmount(ticketId); + CommissionRecord equal = commissionService.submit(submitRequestLinkedTo(ticketId, payable), invoiceFile(), accountActor); + assertThat(equal.dealAmountMismatch()).isFalse(); + assertThat(equal.dealPayableAmountSnapshot()).isEqualByComparingTo(payable); + + long other = driveDealToClosedPaid(new BigDecimal("10")); + BigDecimal below = tickets.payableAmount(other).multiply(new BigDecimal("0.93")).setScale(2, java.math.RoundingMode.HALF_UP); + assertThat(commissionService.submit(submitRequestLinkedTo(other, below), invoiceFile(), accountActor) + .dealAmountMismatch()).isTrue(); + } + // ───────────────────────────────────────────────────────────────────────────────────── // (4) Cross-check beyond threshold: NOT blocked, but flagged for reviewers. // ───────────────────────────────────────────────────────────────────────────────────── @@ -273,7 +288,7 @@ void submit_withGrossAmountWithinThreshold_succeedsWithNoMismatchFlag() { @Test void submit_withGrossAmountBeyondThreshold_succeedsButFlagsMismatch() { long ticketId = driveDealToClosedPaid(new BigDecimal("10")); - BigDecimal payable = tickets.payableAmountExVat(ticketId); + BigDecimal payable = tickets.payableAmount(ticketId); // QA-02: VAT-inclusive assertThat(payable.signum()).isPositive(); // 25% above payable — well beyond the 5% threshold. BigDecimal grossAmount = payable.multiply(new BigDecimal("1.25")).setScale(2, java.math.RoundingMode.HALF_UP); diff --git a/backend/src/test/java/th/co/glr/hr/commission/CommissionServiceTest.java b/backend/src/test/java/th/co/glr/hr/commission/CommissionServiceTest.java index 193e5b25d..70468595d 100644 --- a/backend/src/test/java/th/co/glr/hr/commission/CommissionServiceTest.java +++ b/backend/src/test/java/th/co/glr/hr/commission/CommissionServiceTest.java @@ -166,7 +166,7 @@ void submitWithLinkedTicketNotClosedPaid_rejectsWithUnprocessableEntity() { void submitWithLinkedClosedPaidTicket_withinThreshold_succeedsAndRecordsNoMismatch() { SubmitCommissionRequest request = submitRequestLinkedTo(42L, new BigDecimal("1000.00")); when(tickets.findSalesStage(42L)).thenReturn(Optional.of(DealStage.CLOSED_PAID)); - when(tickets.payableAmountExVat(42L)).thenReturn(new BigDecimal("1030.00")); // 2.9% off — within 5% + when(tickets.payableAmount(42L)).thenReturn(new BigDecimal("1030.00")); // 2.9% off — within 5% InvoiceCalculation calculation = new InvoiceCalculation(new BigDecimal("1000.00"), new BigDecimal("1000.00")); when(calculator.calculateInvoice(any(), any(), any(), any(), any(), any(), any(), any())).thenReturn(calculation); when(commissions.createInvoice(any(SubmitCommissionRequest.class))).thenReturn(500L); @@ -192,7 +192,7 @@ void submitWithLinkedClosedPaidTicket_withinThreshold_succeedsAndRecordsNoMismat void submitWithLinkedClosedPaidTicket_beyondThreshold_stillSucceedsButFlagsMismatch() { SubmitCommissionRequest request = submitRequestLinkedTo(42L, new BigDecimal("1000.00")); when(tickets.findSalesStage(42L)).thenReturn(Optional.of(DealStage.CLOSED_PAID)); - when(tickets.payableAmountExVat(42L)).thenReturn(new BigDecimal("1200.00")); // 16.7% off — beyond 5% + when(tickets.payableAmount(42L)).thenReturn(new BigDecimal("1200.00")); // 16.7% off — beyond 5% InvoiceCalculation calculation = new InvoiceCalculation(new BigDecimal("1000.00"), new BigDecimal("1000.00")); when(calculator.calculateInvoice(any(), any(), any(), any(), any(), any(), any(), any())).thenReturn(calculation); when(commissions.createInvoice(any(SubmitCommissionRequest.class))).thenReturn(500L); @@ -304,7 +304,7 @@ void createFromDeal_resolvesSalesRepFromTicketOwner_neverFromActor() { when(tickets.findById(ticketId)).thenReturn(Optional.of(ticketWithOwner(ticketId, ownerId))); when(commissions.hasActiveCommissionForTicket(ticketId)).thenReturn(false); when(tickets.findSalesStage(ticketId)).thenReturn(Optional.of(DealStage.CLOSED_PAID)); - when(tickets.payableAmountExVat(ticketId)).thenReturn(new BigDecimal("1000.00")); + when(tickets.payableAmount(ticketId)).thenReturn(new BigDecimal("1000.00")); InvoiceCalculation calculation = new InvoiceCalculation(new BigDecimal("1000.00"), new BigDecimal("1000.00")); when(calculator.calculateInvoice(any(), any(), any(), any(), any(), any(), any(), any())).thenReturn(calculation); when(commissions.createInvoice(any(SubmitCommissionRequest.class))).thenReturn(500L); @@ -335,7 +335,7 @@ void createFromDeal_defaultsGrossAmountFromDealPayable_whenOmitted() { when(tickets.findById(ticketId)).thenReturn(Optional.of(ticketWithOwner(ticketId, 30L))); when(commissions.hasActiveCommissionForTicket(ticketId)).thenReturn(false); when(tickets.findSalesStage(ticketId)).thenReturn(Optional.of(DealStage.CLOSED_PAID)); - when(tickets.payableAmountExVat(ticketId)).thenReturn(new BigDecimal("2500.00")); + when(tickets.payableAmount(ticketId)).thenReturn(new BigDecimal("2500.00")); InvoiceCalculation calculation = new InvoiceCalculation(new BigDecimal("2500.00"), new BigDecimal("2500.00")); when(calculator.calculateInvoice(eq(new BigDecimal("2500.00")), any(), any(), any(), any(), any(), any(), any())) .thenReturn(calculation); @@ -363,7 +363,7 @@ void createFromDeal_usesProvidedGrossAmount_whenGiven_insteadOfDealPayable() { when(commissions.hasActiveCommissionForTicket(ticketId)).thenReturn(false); when(tickets.findSalesStage(ticketId)).thenReturn(Optional.of(DealStage.CLOSED_PAID)); // Deliberately different from the payable amount, to prove the override wins. - when(tickets.payableAmountExVat(ticketId)).thenReturn(new BigDecimal("2500.00")); + when(tickets.payableAmount(ticketId)).thenReturn(new BigDecimal("2500.00")); InvoiceCalculation calculation = new InvoiceCalculation(new BigDecimal("9999.00"), new BigDecimal("9999.00")); when(calculator.calculateInvoice(eq(new BigDecimal("9999.00")), any(), any(), any(), any(), any(), any(), any())) .thenReturn(calculation); @@ -423,7 +423,7 @@ void createFromDeal_shiftsPayrollMonthToTheMonthAfterInvoiceDate_flag10() { when(tickets.findById(ticketId)).thenReturn(Optional.of(ticketWithOwner(ticketId, 30L))); when(commissions.hasActiveCommissionForTicket(ticketId)).thenReturn(false); when(tickets.findSalesStage(ticketId)).thenReturn(Optional.of(DealStage.CLOSED_PAID)); - when(tickets.payableAmountExVat(ticketId)).thenReturn(new BigDecimal("1000.00")); + when(tickets.payableAmount(ticketId)).thenReturn(new BigDecimal("1000.00")); InvoiceCalculation calculation = new InvoiceCalculation(new BigDecimal("1000.00"), new BigDecimal("1000.00")); when(calculator.calculateInvoice(any(), any(), any(), any(), any(), any(), any(), any())).thenReturn(calculation); when(commissions.createInvoice(any(SubmitCommissionRequest.class))).thenReturn(500L); diff --git a/backend/src/test/java/th/co/glr/hr/dealquotation/DealQuotationConfirmOrderIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/dealquotation/DealQuotationConfirmOrderIntegrationTest.java index 9a5c6e4a6..542e2f518 100644 --- a/backend/src/test/java/th/co/glr/hr/dealquotation/DealQuotationConfirmOrderIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/dealquotation/DealQuotationConfirmOrderIntegrationTest.java @@ -407,100 +407,92 @@ void zeroDepositDirectQuotation_confirmsOntoTheNoDepositPath_andFinalPaymentIsNo } /** - * PR #1173 review F1: a confirmed 0% deal whose approved revision adds a deposit used to be - * stranded (NOT_REQUIRED kept, the draft 409'd, waiveDeposit refuses REQUIRED). While the - * payment track is at CUSTOMER_CONFIRMED a policy the system set follows the newly approved - * quotation. + * Owner ruling 2026-10-28: once the order is confirmed from a direct quotation, THAT quotation can no longer + * be revised (a revision's approval would supersede the agreed order and move the payable). Replaces the five + * #1173 tests that revised a confirmed quotation (their policy-sync scenarios are no longer reachable). */ @Test - void zeroDepositConfirmed_thenARevisionAddsADeposit_policyFollowsBackToRequired_andTheDraftBillsIt() { + void confirmedDirectQuotation_cannotBeRevised_409_andNoRowIsWritten() { long deal = pipelineTicket(salesActor); - DealQuotationDto zero = approvedQuotation(deal, zeroDepositDirectDraft()); - confirmer.confirmOrderFromDirectQuotation(zero.id(), salesActor); - assertThat(summary(deal).depositPolicy()).isEqualTo("NOT_REQUIRED"); + DealQuotationDto fifty = approvedDirectQuotation(deal, salesActor); + confirmer.confirmOrderFromDirectQuotation(fifty.id(), salesActor); + int rowsBefore = quotationRows(deal); - DealQuotationDto revision = approvedRevision(zero, directDraft()); + assertConflict(() -> quotationService.createRevision(fifty.id(), salesActor), "ยืนยันคำสั่งซื้อแล้ว"); - TicketSummaryDto after = summary(deal); - assertThat(after.depositPolicy()).isEqualTo("REQUIRED"); - assertThat(after.depositPolicyReason()).isEqualTo("ใบเสนอราคา " + revision.number() + " ระบุมัดจำ 50%"); - assertThat(after.paymentStatus()).isEqualTo("CUSTOMER_CONFIRMED"); - assertThat(policyChangedMessages(deal)).hasSize(2) - .last().asString().contains("REQUIRED").contains(revision.number()); - DepositNoticeDto draft = depositNoticeService.createDraft(deal, - new DepositNoticeDraftRequest(null, null, null, null, null, null, null, null), salesActor); - assertThat(draft.depositPercent()).isEqualByComparingTo("0.50"); + assertThat(quotationRows(deal)).isEqualTo(rowsBefore); + assertThat(quotationService.get(fifty.id(), salesActor).docStatus()).isEqualTo(QuotationStatus.APPROVED); } - /** Owner ruling 2026-10-23: a blank new-engine percent means 30 (DealQuotationRenderAdapter's PDF - * default), so a 0% deal whose approved revision leaves the percent blank is back on the deposit path. */ + /** + * Round 3: the two leaks around the revise gate. After the order is confirmed from v1, no OTHER direct + * quotation on the deal may be created from it (clone), submitted, or approved -- approving one would supersede + * the confirmed v1, move the payable and flip the deposit policy. v1 stays APPROVED, nothing is written. + */ @Test - void zeroDepositConfirmed_thenARevisionWithABlankPercent_countsAsThirty_andIsRequired() { + void afterConfirm_aCloneOfTheConfirmedQuotation_isRefused() { long deal = pipelineTicket(salesActor); - DealQuotationDto zero = approvedQuotation(deal, zeroDepositDirectDraft()); - confirmer.confirmOrderFromDirectQuotation(zero.id(), salesActor); - assertThat(summary(deal).depositPolicy()).isEqualTo("NOT_REQUIRED"); + DealQuotationDto v1 = approvedDirectQuotation(deal, salesActor); + confirmer.confirmOrderFromDirectQuotation(v1.id(), salesActor); + int rows = quotationRows(deal); - DealQuotationDto revision = approvedRevision(zero, blankDepositDirectDraft()); + assertConflict(() -> quotationService.createReorder(v1.id(), salesActor), "ยืนยันคำสั่งซื้อแล้ว"); - assertThat(revision.depositPercent()).isNull(); - TicketSummaryDto after = summary(deal); - assertThat(after.depositPolicy()).isEqualTo("REQUIRED"); - assertThat(after.depositPolicyReason()).isEqualTo("ใบเสนอราคา " + revision.number() + " ระบุมัดจำ 30%"); + assertThat(quotationRows(deal)).isEqualTo(rows); + assertStillTheAgreedOrder(deal, v1); } - /** The other direction: a confirmed 50% deal whose approved revision says ไม่รับมัดจำ leaves - * the deposit path (this was the PR's "a later revision to 0% does not sync" known risk). */ @Test - void depositConfirmed_thenARevisionDropsTheDeposit_policyFollowsToNotRequired() { + void afterConfirm_aCloneOrRevisionDraftOpenedBeforeConfirm_cannotBeSubmitted() { long deal = pipelineTicket(salesActor); - DealQuotationDto fifty = approvedDirectQuotation(deal, salesActor); - confirmer.confirmOrderFromDirectQuotation(fifty.id(), salesActor); - assertThat(summary(deal).depositPolicy()).isEqualTo("REQUIRED"); + DealQuotationDto v1 = approvedDirectQuotation(deal, salesActor); + DealQuotationDto clone = quotationService.createReorder(v1.id(), salesActor); + DealQuotationDto revisionDraft = quotationService.createRevision(v1.id(), salesActor); + confirmer.confirmOrderFromDirectQuotation(v1.id(), salesActor); - DealQuotationDto revision = approvedRevision(fifty, zeroDepositDirectDraft()); + assertConflict(() -> quotationService.submit(clone.id(), salesActor), "ยืนยันคำสั่งซื้อจากใบเสนอราคาอื่นแล้ว"); + assertConflict(() -> quotationService.submit(revisionDraft.id(), salesActor), "ยืนยันคำสั่งซื้อจากใบเสนอราคาอื่นแล้ว"); - TicketSummaryDto after = summary(deal); - assertThat(after.depositPolicy()).isEqualTo("NOT_REQUIRED"); - assertThat(after.depositPolicyReason()) - .isEqualTo("ใบเสนอราคา " + revision.number() + " ระบุไม่รับมัดจำ (ชำระเต็มจำนวน)"); - assertThat(policyChangedMessages(deal)).hasSize(1); - assertConflict(() -> depositNoticeService.createDraft(deal, - new DepositNoticeDraftRequest(null, null, null, null, null, null, null, null), salesActor), "ไม่ต้องเก็บมัดจำ"); + assertStillTheAgreedOrder(deal, v1); } - /** Wrong-way-round: a policy a person chose (credit customer, typed reason) is never undone by - * a revision, and no POLICY_CHANGED is written for the attempt. */ @Test - void humanCreditCustomer_isKept_whenARevisionAddsADeposit() { + void afterConfirm_aRevisionSubmittedBeforeConfirm_cannotBeApproved() { long deal = pipelineTicket(salesActor); - DealQuotationDto zero = approvedQuotation(deal, zeroDepositDirectDraft()); - confirmer.confirmOrderFromDirectQuotation(zero.id(), salesActor); - ticketService.waiveDeposit(deal, "CREDIT_CUSTOMER", "ลูกค้าเครดิต 30 วัน", salesActor); + DealQuotationDto v1 = approvedDirectQuotation(deal, salesActor); + DealQuotationDto revision = quotationService.createRevision(v1.id(), salesActor); + quotationService.update(revision.id(), zeroDepositDirectDraft(), salesActor); + quotationService.submit(revision.id(), salesActor); + confirmer.confirmOrderFromDirectQuotation(v1.id(), salesActor); - approvedRevision(zero, directDraft()); + assertConflict(() -> quotationService.approve(revision.id(), new ApproveRequest(null), salesManagerActor), + "ยืนยันคำสั่งซื้อจากใบเสนอราคาอื่นแล้ว"); - TicketSummaryDto after = summary(deal); - assertThat(after.depositPolicy()).isEqualTo("CREDIT_CUSTOMER"); - assertThat(after.depositPolicyReason()).isEqualTo("ลูกค้าเครดิต 30 วัน"); - assertThat(policyChangedMessages(deal)).hasSize(2); // the sync at confirmation + the waiver + assertStillTheAgreedOrder(deal, v1); + } + + private void assertStillTheAgreedOrder(long deal, DealQuotationDto v1) { + assertThat(quotationService.get(v1.id(), salesActor).docStatus()).isEqualTo(QuotationStatus.APPROVED); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM sales.quotation WHERE ticket_id = :id AND doc_status = 'APPROVED'", + Map.of("id", deal), Integer.class)).isEqualTo(1); + assertThat(summary(deal).depositPolicy()).isEqualTo("REQUIRED"); } - /** Wrong-way-round, the case the set_by marker exists for: a NOT_REQUIRED a person typed is - * not the system's, so a 50% revision leaves it and writes no event. */ + /** ...and before the order is confirmed the same revision still works. */ @Test - void humanNotRequired_isKept_whenARevisionStillCarriesADeposit() { + void unconfirmedApprovedDirectQuotation_canStillBeRevised() { long deal = pipelineTicket(salesActor); DealQuotationDto fifty = approvedDirectQuotation(deal, salesActor); - confirmer.confirmOrderFromDirectQuotation(fifty.id(), salesActor); - ticketService.waiveDeposit(deal, "NOT_REQUIRED", "ลูกค้าโอนเต็มจำนวนล่วงหน้า", salesActor); - approvedRevision(fifty, directDraft()); + DealQuotationDto revision = quotationService.createRevision(fifty.id(), salesActor); - TicketSummaryDto after = summary(deal); - assertThat(after.depositPolicy()).isEqualTo("NOT_REQUIRED"); - assertThat(after.depositPolicyReason()).isEqualTo("ลูกค้าโอนเต็มจำนวนล่วงหน้า"); - assertThat(policyChangedMessages(deal)).hasSize(1); // the waiver only + assertThat(revision.docStatus()).isEqualTo(QuotationStatus.DRAFT); + assertThat(revision.parentQuotationId()).isEqualTo(fifty.id()); + } + + private int quotationRows(long ticketId) { + return jdbc.queryForObject("SELECT COUNT(*) FROM sales.quotation WHERE ticket_id = :id", + Map.of("id", ticketId), Integer.class); } // ───────────────────────────────────────────────────────────────────────────────────── diff --git a/backend/src/test/java/th/co/glr/hr/deposit/DepositNoticeServiceTest.java b/backend/src/test/java/th/co/glr/hr/deposit/DepositNoticeServiceTest.java index 5358e59c0..58161a0d2 100644 --- a/backend/src/test/java/th/co/glr/hr/deposit/DepositNoticeServiceTest.java +++ b/backend/src/test/java/th/co/glr/hr/deposit/DepositNoticeServiceTest.java @@ -1257,4 +1257,28 @@ private void assertConflict(org.assertj.core.api.ThrowableAssert.ThrowingCallabl .isInstanceOfSatisfying(ApiException.class, e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT)); } + + // SK-07 (owner ruling 2026-10-28, final): each tax invoice's VAT is round2(its own net x 7%). Deal 11 + // (PR-2026-0006): net 119,745.00 - 35,923.50 = 83,821.50 -> VAT 5,867.505 -> 5,867.51 -> grand 89,689.01. + // The 1-satang gap against the deal payable (128,127.15 - 38,438.15 = 89,689.00) is normal. + @Test + void remainingInvoiceMoney_deal11_isPerDocumentRounding() { + DepositNoticeService.RemainingInvoiceMoney m = service.computeRemainingInvoiceMoney( + new BigDecimal("119745.00"), new BigDecimal("35923.50")); + + assertThat(m.netAmount()).isEqualByComparingTo("83821.50"); + assertThat(m.vatAmount()).isEqualByComparingTo("5867.51"); + assertThat(m.grandTotal()).isEqualByComparingTo("89689.01"); + } + + /** No deposit (bypass): unchanged -- net = items, VAT = round2(items x 7%). */ + @Test + void remainingInvoiceMoney_withNoDeposit_isTheWholeDealInclVat() { + DepositNoticeService.RemainingInvoiceMoney m = service.computeRemainingInvoiceMoney( + new BigDecimal("33333.33"), BigDecimal.ZERO); + + assertThat(m.netAmount()).isEqualByComparingTo("33333.33"); + assertThat(m.vatAmount()).isEqualByComparingTo("2333.33"); + assertThat(m.grandTotal()).isEqualByComparingTo("35666.66"); + } } diff --git a/backend/src/test/java/th/co/glr/hr/deposit/RemainingInvoiceRendererTest.java b/backend/src/test/java/th/co/glr/hr/deposit/RemainingInvoiceRendererTest.java index 9cfcd8caa..901e796a2 100644 --- a/backend/src/test/java/th/co/glr/hr/deposit/RemainingInvoiceRendererTest.java +++ b/backend/src/test/java/th/co/glr/hr/deposit/RemainingInvoiceRendererTest.java @@ -81,10 +81,8 @@ void rendersWithoutFormulaErrorsAsASinglePageWithTheCorrectTotal() throws Except /** * The branch's own reference sample (owner-supplied, matches the real template's formulas * exactly): 46 แผ่น × 806.83 = 37,114.18; deposit 18,557.09 (ref AI2600145) deducted → - * remainder 18,557.09; I43 stores the UNROUNDED VAT 1298.9963 (18557.09 * 0.07), which the - * template's own cell format displays as 1,299.00 — this test evaluates the formula with POI - * directly (no LibreOffice needed) and checks the raw numeric value, not a rendered string, so - * it also pins that the stored figure is genuinely unrounded until display. + * remainder 18,557.09; the stored VAT 1,299.00 and total 19,856.09 are written as values (no + * LibreOffice needed: POI reads the cells directly). */ @Test void referenceSampleFormulasEvaluateToTheOwnerSuppliedTotals() throws Exception { @@ -103,7 +101,8 @@ void referenceSampleFormulasEvaluateToTheOwnerSuppliedTotals() throws Exception List.of(new RemainingInvoiceItemDto( 1, "กระเบื้อง 60x60", new BigDecimal("46"), "แผ่น", new BigDecimal("806.83"), null, new BigDecimal("806.83"), new BigDecimal("37114.18") - )) + )), + new BigDecimal("1299.00"), new BigDecimal("19856.09") ); byte[] xlsx = renderer.toXlsx(doc); @@ -114,13 +113,9 @@ void referenceSampleFormulasEvaluateToTheOwnerSuppliedTotals() throws Exception // I42 = SUM(I11:I41) = 37114.18 (item) - 18557.09 (deposit) = 18557.09. assertThat(round2(eval.evaluate(cell(sh, 41, 8)).getNumberValue())).isEqualTo(18557.09); - // I43 = I42 * 0.07 — the RAW value is unrounded (1298.9963); rounds to 1,299.00 for display. - double vatRaw = eval.evaluate(cell(sh, 42, 8)).getNumberValue(); - assertThat(vatRaw).isCloseTo(1298.9963, org.assertj.core.data.Offset.offset(0.0001)); - assertThat(round2(vatRaw)).isEqualTo(1299.00); - // I44 = I42 + I43 = 18557.09 + 1298.9963 = 19856.0863, rounds to 19,856.09. - double totalRaw = eval.evaluate(cell(sh, 43, 8)).getNumberValue(); - assertThat(round2(totalRaw)).isEqualTo(19856.09); + // I43 / I44 are the STORED, already-rounded figures (owner ruling 2026-10-28), not template formulas. + assertThat(cell(sh, 42, 8).getNumericCellValue()).isEqualTo(1299.00); + assertThat(cell(sh, 43, 8).getNumericCellValue()).isEqualTo(19856.09); // Item row present at row 13 (0-based 12). assertThat(cell(sh, 12, 1).getStringCellValue()).isEqualTo("กระเบื้อง 60x60"); @@ -339,6 +334,33 @@ private Cell cell(Sheet sh, int rowIdx, int colIdx) { return c; } + /** + * SK-07 (owner ruling 2026-10-28): the sheet prints the STORED VAT and total as values. Deal 11 (PR-2026-0006): + * 119,745.00 - 35,923.50 deposit = net 83,821.50; VAT = round2(net x 7%) = 5,867.51 (the template's I42 x 7% + * formula would hold 5,867.505) and total 89,689.01 (not 89,689.005). + */ + @Test + void storedVatAndGrandTotal_areWrittenAsValues_deal11() throws Exception { + RemainingInvoiceDto doc = new RemainingInvoiceDto( + "GLR6900002-1", LocalDate.of(2026, 10, 28), "QT-2026-0006", "GLRD69002", "บริษัท ดีล 11 จำกัด", null, + "1 ถนนตัวอย่าง", "0105500000000", "โครงการ 11", new BigDecimal("35923.50"), List.of(), + List.of(new RemainingInvoiceItemDto(1, "Tile", new BigDecimal("1"), "lot", + new BigDecimal("119745.00"), null, new BigDecimal("119745.00"), new BigDecimal("119745.00"))), + new BigDecimal("5867.51"), new BigDecimal("89689.01")); + + byte[] xlsx = renderer.toXlsx(doc); + + try (Workbook wb = WorkbookFactory.create(new ByteArrayInputStream(xlsx))) { + Sheet sh = wb.getSheetAt(0); + FormulaEvaluator eval = wb.getCreationHelper().createFormulaEvaluator(); + assertThat(round2(eval.evaluate(cell(sh, 41, 8)).getNumberValue())).isEqualTo(83821.50); + assertThat(cell(sh, 42, 8).getCellType()).isEqualTo(org.apache.poi.ss.usermodel.CellType.NUMERIC); + assertThat(cell(sh, 42, 8).getNumericCellValue()).isEqualTo(5867.51); + assertThat(cell(sh, 43, 8).getCellType()).isEqualTo(org.apache.poi.ss.usermodel.CellType.NUMERIC); + assertThat(cell(sh, 43, 8).getNumericCellValue()).isEqualTo(89689.01); + } + } + private RemainingInvoiceDto document() { return new RemainingInvoiceDto( "GLRI69001", @@ -361,7 +383,8 @@ private RemainingInvoiceDto document() { null, new BigDecimal("979.40"), new BigDecimal("64640.40") - )) + )), + new BigDecimal("2262.41"), new BigDecimal("34582.61") ); } } diff --git a/backend/src/test/java/th/co/glr/hr/deposit/RemainingInvoiceServiceIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/deposit/RemainingInvoiceServiceIntegrationTest.java index 8abc6a411..936b6a448 100644 --- a/backend/src/test/java/th/co/glr/hr/deposit/RemainingInvoiceServiceIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/deposit/RemainingInvoiceServiceIntegrationTest.java @@ -99,6 +99,7 @@ class RemainingInvoiceServiceIntegrationTest extends AbstractPostgresIntegration private OrderConfirmationService orderConfirmation; private RemainingInvoiceRepository remainingInvoiceRepository; private RemainingInvoiceService remainingInvoiceService; + private TicketService ticketService; private long salesRepId; private long otherSalesRepId; @@ -139,7 +140,7 @@ void wireStepsServices() { decisionService = new PricingDecisionService(decisionRepository, pricingRequests, costingRepository, tickets, fxRates, notifications, landedCostCalculator, formulaEngine); - TicketService ticketService = new TicketService(tickets, notifications, + ticketService = new TicketService(tickets, notifications, objectMapper, customers, new QuotationRenderer(), pricingRequestService, new th.co.glr.hr.auth.EmployeeAuthRepository(jdbc), new th.co.glr.hr.customer.ContactRepository(jdbc), new th.co.glr.hr.customer.ProjectRepository(jdbc), new th.co.glr.hr.dealquotation.DealQuotationRepository(jdbc, new th.co.glr.hr.catalog.CatalogRepository(jdbc))); @@ -176,6 +177,39 @@ objectMapper, customers, new QuotationRenderer(), pricingRequestService, new th. // Lifecycle + numbering // ───────────────────────────────────────────────────────────────────────────────────── + /** + * Owner ruling 2026-10-28 (SK-07): each tax invoice's VAT is rounded on its own net, so deposit + remaining can + * differ from the deal's VAT-inclusive payable by a satang -- and outstanding + the final BALANCE receipt then + * FOLLOW THE ISSUED DOCUMENTS: once the remaining invoice is issued, payable = deposit notice total + remaining + * invoice grand total, the deposit receipt is the notice total, the balance is exactly the invoice grand total + * and the deal closes FULLY_PAID with nothing left (no satang overpaid/underpaid). + */ + @Test + void issuedRemainingInvoice_outstandingAndFinalPaymentFollowTheIssuedDocuments() { + DealFixture fixture = buildAcceptedDeal(salesActor, "FollowDocs"); + long ticketId = fixture.ticketId(); + BigDecimal depositNoticeTotal = new DepositNoticeRepository(jdbc).findById(fixture.depositNoticeId()).orElseThrow().totalPayable(); + ticketService.confirmDepositPaid(ticketId, accountActor); + + BigDecimal payableBeforeInvoice = tickets.payableAmount(ticketId); + RemainingInvoiceDocumentDto issued = remainingInvoiceService.issue( + remainingInvoiceService.createDraft(ticketId, null, salesActor).id(), salesActor); + // fixture precondition: per-document rounding really does drift from the deal payable here + assertThat(payableBeforeInvoice.subtract(depositNoticeTotal)).isNotEqualByComparingTo(issued.grandTotal()); + + assertThat(tickets.payableAmount(ticketId)).isEqualByComparingTo(depositNoticeTotal.add(issued.grandTotal())); + assertThat(tickets.findSummaryById(ticketId).orElseThrow().amountOutstanding()).isEqualByComparingTo(issued.grandTotal()); + + ticketService.confirmFinalPayment(ticketId, accountActor); + + assertThat(jdbc.queryForObject( + "SELECT amount FROM sales.payment_receipt WHERE ticket_id = :id AND kind = 'BALANCE'", + Map.of("id", ticketId), BigDecimal.class)).isEqualByComparingTo(issued.grandTotal()); + var after = tickets.findSummaryById(ticketId).orElseThrow(); + assertThat(after.paymentStatus()).isEqualTo("FULLY_PAID"); + assertThat(after.amountOutstanding()).isEqualByComparingTo("0"); + } + @Test void createDraft_thenIssue_mintsFirstVersionOfANewBase() { DealFixture fixture = buildAcceptedDeal(salesActor, "Lifecycle1"); diff --git a/backend/src/test/java/th/co/glr/hr/finance/FinancePayableVatAndDueIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/finance/FinancePayableVatAndDueIntegrationTest.java index 11681d5c3..4f569af23 100644 --- a/backend/src/test/java/th/co/glr/hr/finance/FinancePayableVatAndDueIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/finance/FinancePayableVatAndDueIntegrationTest.java @@ -82,6 +82,102 @@ void payingTheFullVatInclusiveAmount_isNotOverpayment_andReachesFullyPaid() { assertThat(tickets.findSummaryById(ticketId).orElseThrow().amountOutstanding()).isEqualByComparingTo("0"); } + // ── A2. QA-03 (owner ruling 2026-10-28): a confirmed DEAL_DIRECT quotation stays APPROVED ── + + /** A deal whose only quotation is a DEAL_DIRECT one in its terminal live state, APPROVED (subtotal 100,000). */ + private long directApprovedDeal(String origin) { + long ticketId = unconfirmedDirectApprovedDeal(origin); + tickets.updatePaymentStatusUnchecked(ticketId, "CUSTOMER_CONFIRMED"); // confirm-order puts the deal on the payment track + return ticketId; + } + + private long unconfirmedDirectApprovedDeal(String origin) { + long ticketId = createTicket("PROCUREMENT"); + long q = insertAcceptedQuotation(ticketId, "QT-DD-" + ticketId, new BigDecimal("100000.00")); + jdbc.update("UPDATE sales.quotation SET origin = :o, doc_status = 'APPROVED' WHERE quotation_id = :q", + new MapSqlParameterSource("o", origin).addValue("q", q)); + return ticketId; + } + + @Test + void confirmedDirectQuotation_approved_countsTowardPayable_andShowsAsTheDealValue() { + long ticketId = directApprovedDeal("DEAL_DIRECT"); + + assertThat(tickets.payableAmount(ticketId)).isEqualByComparingTo("107000.00"); + assertThat(tickets.payableAmountExVat(ticketId)).isEqualByComparingTo("100000.00"); + FinanceDealDto dto = service.get(ticketId, accountUser); + assertThat(dto.money().amountPayable()).isEqualByComparingTo("107000.00"); + assertThat(dto.money().amountOutstanding()).isEqualByComparingTo("107000.00"); + } + + @Test + void confirmedDirectQuotation_approved_isTheDealsPayableQuotationDocument() { + long ticketId = directApprovedDeal("DEAL_DIRECT"); + + // FinanceDealRepository.findPayableQuotation must agree with TicketRepository's payable SQL. + assertThat(service.get(ticketId, accountUser).documents().acceptedQuotation()).isNotNull(); + assertThat(service.get(ticketId, accountUser).documents().acceptedQuotation().number()) + .isEqualTo("QT-DD-" + ticketId); + } + + @Test + void confirmedDirectQuotation_approved_balanceIsNotFullyPaidBeforeTheMoneyArrives() { + long ticketId = directApprovedDeal("DEAL_DIRECT"); + tickets.updatePaymentStatusUnchecked(ticketId, "DEPOSIT_PAID"); + insertPayment(ticketId, "DEPOSIT", new BigDecimal("53500.00")); + + assertThat(tickets.findSummaryById(ticketId).orElseThrow().amountOutstanding()).isEqualByComparingTo("53500.00"); + } + + @Test + void confirmedDirectQuotation_approved_drivesPaymentTermsAndAccountScope() { + long ticketId = directApprovedDeal("DEAL_DIRECT"); + jdbc.update("UPDATE sales.quotation SET remainder_mode = 'CREDIT', credit_days = 30 WHERE ticket_id = :id", + new MapSqlParameterSource("id", ticketId)); + deliver(ticketId, today().minusDays(45).atTime(12, 0).atZone(BKK).toInstant()); + // outside account's order-stage scope with a non-pending status: only overdue-with-balance gets it in. + tickets.updateSalesStage(ticketId, "NEGOTIATION"); + tickets.updatePaymentStatusUnchecked(ticketId, "CUSTOMER_CONFIRMED"); + + TicketSummaryDto s = summary(ticketId); + assertThat(s.paymentDueBasis()).isEqualTo("CREDIT_FROM_DELIVERY"); + assertThat(s.paymentDueDate()).isEqualTo(today().minusDays(15)); + assertThat(s.overdue()).isTrue(); + assertThat(inAccountList(ticketId)).isTrue(); + } + + @Test + void acceptedQuotation_outranksAnApprovedDirectOne_inAllThreeQueries() { + long ticketId = createS15Deal("CUSTOMER_CONFIRMED"); // ACCEPTED, 100,000, (recipient default) + jdbc.update("UPDATE sales.quotation SET recipient_type = 'OWNER', remainder_mode = 'CREDIT', credit_days = 30 WHERE ticket_id = :id", + new MapSqlParameterSource("id", ticketId)); + long dd = tickets.createQuotation(ticketId, "QT-DD-RANK-" + ticketId, salesRepId, new BigDecimal("200000.00")).id(); + jdbc.update("UPDATE sales.quotation SET origin = 'DEAL_DIRECT', doc_status = 'APPROVED', recipient_type = 'BUYER' WHERE quotation_id = :q", + new MapSqlParameterSource("q", dd)); + deliver(ticketId, today().minusDays(10).atTime(12, 0).atZone(BKK).toInstant()); + + assertThat(tickets.payableAmount(ticketId)).isEqualByComparingTo("107000.00"); + assertThat(service.get(ticketId, accountUser).documents().acceptedQuotation().number()) + .isEqualTo("QT-LK-" + ticketId); + assertThat(summary(ticketId).paymentDueBasis()).isEqualTo("CREDIT_FROM_DELIVERY"); + } + + @Test + void approvedDirectQuotation_withNoOrderConfirmation_isNotPayable() { + long ticketId = unconfirmedDirectApprovedDeal("DEAL_DIRECT"); // payment_status still NULL + + assertThat(tickets.payableAmount(ticketId)).isEqualByComparingTo("0"); + assertThat(service.get(ticketId, accountUser).documents().acceptedQuotation()).isNull(); + } + + @Test + void approvedPricingRequestQuotation_isNotPayable_onlyDealDirectApprovedIs() { + long ticketId = directApprovedDeal("PRICING_REQUEST"); // a PCR approval lands on ISSUED, never APPROVED + + assertThat(tickets.payableAmount(ticketId)).isEqualByComparingTo("0"); + assertThat(service.get(ticketId, accountUser).documents().acceptedQuotation()).isNull(); + } + // ── B. Overdue from the quotation's terms ────────────────────────────────────────────── private long creditDeal(int creditDays, LocalDate deliveredOn) { diff --git a/backend/src/test/java/th/co/glr/hr/orderconfirmation/OrderConfirmationIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/orderconfirmation/OrderConfirmationIntegrationTest.java index dc1126cf5..31a20fe9d 100644 --- a/backend/src/test/java/th/co/glr/hr/orderconfirmation/OrderConfirmationIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/orderconfirmation/OrderConfirmationIntegrationTest.java @@ -301,7 +301,11 @@ SELECT COUNT(*) FROM sales.pricing_request_event // just because payableAmount() used to be blind to the new chain's own quotation total. BigDecimal paid = jdbc.queryForObject( "SELECT amount FROM sales.payment_receipt WHERE ticket_id = :id", Map.of("id", ticketId), BigDecimal.class); - assertThat(paid).isEqualByComparingTo(draftNotice.depositAmount()); + // QA-01 (owner ruling 2026-10-28): the receipt is the notice's VAT-INCLUSIVE totalPayable + // (deal payable is VAT-inclusive), NOT the pre-VAT depositAmount. + assertThat(issuedNotice.vatAmount().signum()).as("fixture must carry VAT").isPositive(); + assertThat(paid).isEqualByComparingTo(issuedNotice.totalPayable()); + assertThat(paid).isGreaterThan(draftNotice.depositAmount()); assertThat(paid).isLessThan(payableBeforePayment); // strictly partial — not an overpayment by construction. String note = jdbc.queryForObject( "SELECT note FROM sales.payment_receipt WHERE ticket_id = :id", Map.of("id", ticketId), String.class); diff --git a/backend/src/test/java/th/co/glr/hr/ticket/PaymentTrackIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/ticket/PaymentTrackIntegrationTest.java index 61a005e91..4c1352b75 100644 --- a/backend/src/test/java/th/co/glr/hr/ticket/PaymentTrackIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/ticket/PaymentTrackIntegrationTest.java @@ -212,7 +212,7 @@ void requiredPath_walksEndToEnd_nullThroughDepositNoticeIssuedThroughFullyPaid() // site 2: CUSTOMER_CONFIRMED -> DEPOSIT_NOTICE_ISSUED. DepositNoticeDto draft = orderConfirmation.createDepositNoticeFromQuotation( deal.pricingRequestId(), new CreateDepositNoticeFromQuotationRequest(null), salesActor); - depositNoticeService.issue(draft.id(), salesActor); + DepositNoticeDto issuedNotice = depositNoticeService.issue(draft.id(), salesActor); assertThat(paymentStatusOf(deal.ticketId())).isEqualTo(PaymentTrack.DEPOSIT_NOTICE_ISSUED); // site 6 (REQUIRED branch, via reconcilePaymentStatus): DEPOSIT_NOTICE_ISSUED -> DEPOSIT_PAID, @@ -235,6 +235,11 @@ void requiredPath_walksEndToEnd_nullThroughDepositNoticeIssuedThroughFullyPaid() // the remaining ~50% is still outstanding): AWAITING_FINAL_PAYMENT -> FULLY_PAID. ticketService.confirmFinalPayment(deal.ticketId(), accountActor); assertThat(paymentStatusOf(deal.ticketId())).isEqualTo(PaymentTrack.FULLY_PAID); + // QA-01: the DEPOSIT receipt is the notice's VAT-inclusive totalPayable, not pre-VAT. + assertThat(jdbc.queryForObject( + "SELECT amount FROM sales.payment_receipt WHERE ticket_id = :id AND kind = 'DEPOSIT'", + java.util.Map.of("id", deal.ticketId()), BigDecimal.class)) + .isEqualByComparingTo(issuedNotice.totalPayable()); } @Test diff --git a/backend/src/test/java/th/co/glr/hr/ticket/StageDecisionIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/ticket/StageDecisionIntegrationTest.java index ff40e4e7c..79c9194a4 100644 --- a/backend/src/test/java/th/co/glr/hr/ticket/StageDecisionIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/ticket/StageDecisionIntegrationTest.java @@ -456,6 +456,57 @@ void everyAllowedDecisionIsAccepted_andEveryBlockedOneIsRefused() { } } + /** + * QA-05 (owner ruling 2026-10-28): once the order is confirmed (payment track reached + * CUSTOMER_CONFIRMED -- the deal is at or past ขั้น 10), "แก้ไขสถานะ" must not move the deal + * below ขั้น 10. The decision payload the modal renders says blocked, and the write is a 409. + */ + @Test + void afterTheOrderIsConfirmed_everyStageBelowOrderReceived_isBlockedAndRefusedWith409() { + long ticketId = readyToAdvanceFrom(DealStage.PROCUREMENT); + tickets.updatePaymentStatusUnchecked(ticketId, PaymentTrack.CUSTOMER_CONFIRMED); + + for (UserPrincipal actor : List.of(ownerRep, salesManager, ceo)) { + List decisions = decisionsFor(ticketId, actor); + for (String target : DealStage.ORDER) { + if (DealStage.indexOf(target) >= DealStage.indexOf(DealStage.ORDER_RECEIVED)) continue; + assertThat(allowedFor(decisions, target)) + .as("%s must not be offered to %s after the order is confirmed", target, actor.role()).isFalse(); + assertThat(reasonFor(decisions, target)).isNotBlank(); + } + } + // The write is refused too (not only hidden), with a conflict, and nothing moved. + for (String target : List.of(DealStage.QUOTE_OWNER, DealStage.SPEC_APPROVED, DealStage.NEGOTIATION)) { + assertThatThrownBy(() -> ticketService.updateStage(ticketId, target, "ย้อนกลับ", ownerRep)) + .as(target) + .isInstanceOfSatisfying(ApiException.class, + e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT)); + } + assertThat(stageOf(ticketId)).isEqualTo(DealStage.PROCUREMENT); + assertThat(stageChangedEvents(ticketId)).isZero(); + } + + /** The other half of the ruling: a move that stays at or above ขั้น 10 is unaffected by the new gate. */ + @Test + void afterTheOrderIsConfirmed_movesWithinTenAndAbove_stillWork() { + long ticketId = readyToAdvanceFrom(DealStage.PROCUREMENT); + tickets.updatePaymentStatusUnchecked(ticketId, PaymentTrack.CUSTOMER_CONFIRMED); + + assertThat(allowedFor(decisionsFor(ticketId, ownerRep), DealStage.DELIVERY_SCHEDULING)).isTrue(); + ticketService.updateStage(ticketId, DealStage.DELIVERY_SCHEDULING, "ส่งของ", ownerRep); + assertThat(stageOf(ticketId)).isEqualTo(DealStage.DELIVERY_SCHEDULING); + } + + /** ...and before the order exists the ordinary rewind (ขั้น 5 -> 3) is still legal, with its reason. */ + @Test + void beforeTheOrderIsConfirmed_aRewindWithinStagesOneToNine_stillWorks() { + long ticketId = readyToAdvanceFrom(DealStage.QUOTE_OWNER); + + assertThat(allowedFor(decisionsFor(ticketId, ownerRep), DealStage.SPEC_APPROVED)).isTrue(); + ticketService.updateStage(ticketId, DealStage.SPEC_APPROVED, "ลูกค้าขอแก้สเปก", ownerRep); + assertThat(stageOf(ticketId)).isEqualTo(DealStage.SPEC_APPROVED); + } + // ── helpers ────────────────────────────────────────────────────────────── // H1 lockdown: account reads its stage decisions through financeActions (same gates, no /tickets view diff --git a/frontend/src/api/mockApi.financeDeal.test.js b/frontend/src/api/mockApi.financeDeal.test.js index e3d5d5dba..eea95bcf9 100644 --- a/frontend/src/api/mockApi.financeDeal.test.js +++ b/frontend/src/api/mockApi.financeDeal.test.js @@ -164,9 +164,13 @@ describe('mockApi.finance routes -- role gates and scope', () => { && (t.lifecycle ?? 'ACTIVE') === 'ACTIVE'); expect(target).toBeTruthy(); await login2('account@glr.co.th'); + const notice = (await api.finance.getDeal(target.id)).deal.documents.depositNotices + .filter((d) => d.status === 'ISSUED').at(-1); const { deal } = await api.finance.confirmDepositPaid(target.id); expect(deal.money.paymentStatus).toBe('DEPOSIT_PAID'); expect(deal.money.payments.length).toBeGreaterThan(0); + // QA-01 (mirror of TicketService.confirmDepositPaid): the receipt is the notice's VAT-inclusive total. + if (notice) expect(deal.money.payments.find((p) => p.kind === 'DEPOSIT').amount).toBe(notice.totalPayable); const again = (await api.finance.getDeal(target.id)).deal; expect(again.money.paymentStatus).toBe('DEPOSIT_PAID'); }); diff --git a/frontend/src/api/mockApi.js b/frontend/src/api/mockApi.js index 3cc13d9d7..b6db6132b 100644 --- a/frontend/src/api/mockApi.js +++ b/frontend/src/api/mockApi.js @@ -4248,6 +4248,18 @@ function pushEvent(ticket, actor, kind, fromStatus, toStatus, message, itemSnaps ticket.events.push({ id: nextId, ticketId: ticket.id, actorId: actor.id, actorName: actor.name, kind, fromStatus, toStatus, message, createdAt: new Date().toISOString(), itemSnapshot }); } +// Mirrors TicketRepository#isOrderConfirmedFromQuotation / hasPromotionFromQuotationEvent (owner ruling 2026-10-28): +// the promotion event names the quotation the order was confirmed from; once it exists no OTHER direct quotation +// on the deal may be submitted/approved, and the confirmed one may not be revised or cloned. +function mockConfirmedQuotationEvent(ticket) { + return ticket?.events?.find((e) => e.kind === 'DEAL_PROMOTED_FROM_QUOTATION') ?? null; +} +function requireNoOtherDirectQuotationAfterConfirm(ticket, row) { + const promoted = mockConfirmedQuotationEvent(ticket); + if ((row.origin || 'DEAL_DIRECT') === 'PRICING_REQUEST' || !promoted || ticket.orderConfirmedQuotationId === row.id) return; + fail('ดีลนี้ยืนยันคำสั่งซื้อจากใบเสนอราคาอื่นแล้ว จึงส่ง/อนุมัติใบเสนอราคานี้ไม่ได้', 409); +} + function addNotification(userId, ticketId, ticketCode, type, message) { const nextId = Math.max(...db.notifications.map((n) => n.id), 0) + 1; db.notifications.unshift({ id: nextId, userId, ticketId, ticketCode, type, message, read: false, createdAt: new Date().toISOString() }); @@ -4483,6 +4495,10 @@ function mockStageDecisions(ticket, user) { blockedReason = `ดีลไม่ได้อยู่ในสถานะ ACTIVE (${ticket.lifecycle}) จึงแก้ไขขั้นตอนนี้ไม่ได้`; } else if (code === ticket.salesStage) { blockedReason = `ดีลนี้อยู่ในขั้นตอน ${code} อยู่แล้ว`; + } else if (dealStageIndex(ticket.salesStage) >= dealStageIndex('ORDER_RECEIVED') + && dealStageIndex(code) < dealStageIndex('ORDER_RECEIVED')) { + // QA-05: mirrors TicketService.requireStageMoveAllowed -- no rewind below ขั้น 10 once the order is confirmed. + blockedReason = 'ย้อนสถานะกลับก่อนขั้นรับคำสั่งซื้อไม่ได้ — ยืนยันคำสั่งซื้อไปแล้ว'; } else if (!mockIsOnRoute(ticket.entryChannel, code)) { // Rung 6 — the deal's entry-channel route, checked BEFORE the fact gates (mirrors // TicketService.requireStageMoveAllowed): an off-route target's remedy is the channel, not @@ -7809,7 +7825,7 @@ async function mockConfirmDepositPaid(id) { requireActive(ticket); if (ticket.paymentStatus !== 'DEPOSIT_NOTICE_ISSUED') fail('ต้องออกใบแจ้งรับมัดจำก่อนจึงจะยืนยันรับชำระมัดจำได้', 409); const notice = latestIssuedDepositNotice(ticket.id); - const amount = notice?.depositAmount ?? moneyValue(payableAmount(ticket) * 0.5); + const amount = notice?.totalPayable ?? moneyValue(payableAmount(ticket) * 0.5); // QA-01: VAT-inclusive if (amount <= 0) fail('ไม่พบยอดมัดจำสำหรับบันทึกรับชำระ', 409); recordPaymentForTicket(ticket, user, { kind: 'DEPOSIT', @@ -17254,6 +17270,7 @@ export const api = { if (!row) fail('ไม่พบใบเสนอราคานี้', 404); const ticket = db.tickets.find((t) => t.id === row.ticketId); requireDealQuotationWriteAccess(ticket, user, row.origin); + requireNoOtherDirectQuotationAfterConfirm(ticket, row); // GLA-123 slice S2: submit is now LIVE for a PRICING_REQUEST-origin quotation too — mirrors // DealQuotationService#submit, which widened its own origin predicate rather than keeping // this refusal. Every validation below already applied to both origins in the mock; only @@ -17327,6 +17344,7 @@ export const api = { if ((row.origin || 'DEAL_DIRECT') === 'PRICING_REQUEST') { return mockApproveDealQuotationPricingRequestOrigin(row, payload, user); } + requireNoOtherDirectQuotationAfterConfirm(db.tickets.find((t) => t.id === row.ticketId), row); if (!canTransitionDealQuotation(row.docStatus, 'APPROVED')) { fail(`อนุมัติไม่ได้ในสถานะ '${row.docStatus}'`, 409); } @@ -17379,9 +17397,6 @@ export const api = { } ancestorId = ancestor.parentQuotationId; } - // #1173 review F1 (DealQuotationService#approve -> TicketService#followQuotationDepositPolicy). - const dealTicket = db.tickets.find((t) => t.id === row.ticketId); - if (dealTicket) followQuotationDepositPolicy(dealTicket, row, user); return delay({ quotation: buildDealQuotationDto(row) }); }, @@ -17523,6 +17538,10 @@ export const api = { if (parent.docStatus !== 'APPROVED') { fail('สร้างฉบับแก้ไขได้เฉพาะใบเสนอราคาที่อนุมัติแล้วเท่านั้น', 409); } + // Owner ruling 2026-10-28: the confirmed quotation is the agreed order (mirrors DealQuotationService). + if (ticket?.orderConfirmedQuotationId === parent.id) { + fail('ใบเสนอราคานี้ยืนยันคำสั่งซื้อแล้ว จึงสร้างฉบับแก้ไขไม่ได้', 409); + } } // M3: mirrors DealQuotationRepository#hasOpenRevision's own guard -- a double-tap must not // silently mint two children sharing the same {base}-{n} number. @@ -17552,6 +17571,9 @@ export const api = { if (source.docStatus !== 'APPROVED') { fail('สร้างจากใบเดิมได้เฉพาะใบเสนอราคาที่อนุมัติแล้วเท่านั้น', 409); } + if (ticket?.orderConfirmedQuotationId === source.id) { + fail('ใบเสนอราคานี้ยืนยันคำสั่งซื้อแล้ว จึงสร้างจากใบเดิมไม่ได้', 409); + } const clone = mintDealQuotationReorder(source, user); mockDealQuotations.push(clone); return delay({ quotation: buildDealQuotationDto(clone) }); @@ -17598,6 +17620,7 @@ export const api = { ticket.quotationOnly = false; ticket.status = 'quotation_issued'; ticket.updatedAt = now; + ticket.orderConfirmedQuotationId = row.id; // mock-internal: which quotation the order was confirmed from pushEvent(ticket, user, 'DEAL_PROMOTED_FROM_QUOTATION', 'draft', 'quotation_issued', `ยืนยันคำสั่งซื้อจากใบเสนอราคา ${row.number}`); // DealQuotationService#ticketItemsFromQuotation: goods lines only (no ADJUSTMENT, no diff --git a/frontend/src/api/mockApi.promoteToDeal.test.js b/frontend/src/api/mockApi.promoteToDeal.test.js index ff9bb889f..f1483b748 100644 --- a/frontend/src/api/mockApi.promoteToDeal.test.js +++ b/frontend/src/api/mockApi.promoteToDeal.test.js @@ -126,6 +126,39 @@ describe('mock dealQuotations.promoteToDeal (GLA-136)', () => { expect(await listIds()).toContain(ghost); }); + // Owner ruling 2026-10-28 (mirror of DealQuotationService#createRevision): the quotation an order was + // confirmed from is the agreed order and cannot be revised. + it('refuses a revision of the confirmed quotation (409), but not before the order is confirmed', async () => { + const ghost = await quotationOnlyTicket(); + const approved = await approvedQuotation(ghost); + const { quotation: revision } = await api.dealQuotations.createRevision(approved.id); + expect(revision.parentQuotationId).toBe(approved.id); + await api.dealQuotations.cancel(revision.id); + + await api.dealQuotations.promoteToDeal(approved.id); + + await expect(api.dealQuotations.createRevision(approved.id)).rejects.toMatchObject({ status: 409 }); + }); + + // Round 3 (mirror of DealQuotationService.submit/approve/createReorder): after confirm-order no OTHER direct + // quotation on the deal may be cloned from the confirmed one, submitted or approved, and + // are refused with 409. + it('after confirm: no clone of the confirmed quotation, and a draft/pending one opened before cannot be submitted/approved', async () => { + const ghost = await quotationOnlyTicket(); + const v1 = await approvedQuotation(ghost); + const { quotation: clone } = await api.dealQuotations.createReorder(v1.id); + const { quotation: revision } = await api.dealQuotations.createRevision(v1.id); + await api.dealQuotations.submit(revision.id); + await api.dealQuotations.promoteToDeal(v1.id); + + await expect(api.dealQuotations.createReorder(v1.id)).rejects.toMatchObject({ status: 409 }); + await expect(api.dealQuotations.submit(clone.id)).rejects.toMatchObject({ status: 409 }); + await asRole('sales_manager'); + await expect(api.dealQuotations.approve(revision.id)).rejects.toMatchObject({ status: 409 }); + const { quotation: still } = await api.dealQuotations.get(v1.id); + expect(still.docStatus).toBe('APPROVED'); + }); + // Owner ruling 2026-10-23 (DepositNoticeRepository#foldAdjustments): the deposit draft carries no // qty -1 ส่วนลดพิเศษ row; a flat 95 is spread by line amount (TILE 8,500 takes 85, PLAIN 1,000 // takes 10), so the subtotal is the quotation's 9,405. The mock computes no % adjustment amount @@ -213,81 +246,4 @@ describe('mock dealQuotations.promoteToDeal (GLA-136)', () => { expect(ticket.events.filter((e) => e.kind === 'POLICY_CHANGED').map((e) => e.message)) .toEqual([expect.stringContaining(approved.number)]); }); - - // PR #1173 review F1, mirrors DealQuotationService#approve -> TicketService - // #followQuotationDepositPolicy: while CUSTOMER_CONFIRMED, a policy the system set follows the - // newly approved revision; a policy a person set is kept. - async function approvedWith(ticketId, payload) { - const { quotation: draft } = await api.dealQuotations.create(ticketId, { recipientType: 'DESIGNER', ...payload }); - await api.dealQuotations.submit(draft.id); - await asRole('sales_manager'); - const { quotation } = await api.dealQuotations.approve(draft.id); - await asRole('sales'); - return quotation; - } - - async function approvedRevisionOf(source, payload) { - const { quotation: revision } = await api.dealQuotations.createRevision(source.id); - await api.dealQuotations.update(revision.id, { recipientType: 'DESIGNER', ...payload }); - await api.dealQuotations.submit(revision.id); - await asRole('sales_manager'); - const { quotation } = await api.dealQuotations.approve(revision.id); - await asRole('sales'); - return quotation; - } - - const ZERO = { depositPercent: 0, fullPaymentTerm: 'BEFORE_DELIVERY', items: [TILE, PLAIN] }; - const FIFTY = { depositPercent: 50, items: [TILE, PLAIN] }; - const policyEvents = (ticket) => ticket.events.filter((e) => e.kind === 'POLICY_CHANGED').map((e) => e.message); - - it('a confirmed 0% deal whose approved revision adds a deposit goes back to REQUIRED', async () => { - const ghost = await quotationOnlyTicket(); - const zero = await approvedWith(ghost, ZERO); - await api.dealQuotations.promoteToDeal(zero.id); - - const revision = await approvedRevisionOf(zero, FIFTY); - - const { ticket } = await api.tickets.get(ghost); - expect(ticket.summary.depositPolicy).toBe('REQUIRED'); - expect(ticket.summary.depositPolicyReason).toBe(`ใบเสนอราคา ${revision.number} ระบุมัดจำ 50%`); - expect(policyEvents(ticket)).toHaveLength(2); - }); - - it('a confirmed 0% deal whose approved revision leaves the percent blank counts as 30 and is REQUIRED', async () => { - const ghost = await quotationOnlyTicket(); - const zero = await approvedWith(ghost, ZERO); - await api.dealQuotations.promoteToDeal(zero.id); - - const revision = await approvedRevisionOf(zero, { depositPercent: null, fullPaymentTerm: null, items: [TILE, PLAIN] }); - - const { ticket } = await api.tickets.get(ghost); - expect(ticket.summary.depositPolicy).toBe('REQUIRED'); - expect(ticket.summary.depositPolicyReason).toBe(`ใบเสนอราคา ${revision.number} ระบุมัดจำ 30%`); - }); - - it('a confirmed 50% deal whose approved revision says ไม่รับมัดจำ moves to NOT_REQUIRED', async () => { - const ghost = await quotationOnlyTicket(); - const fifty = await approvedWith(ghost, FIFTY); - await api.dealQuotations.promoteToDeal(fifty.id); - - const revision = await approvedRevisionOf(fifty, ZERO); - - const { ticket } = await api.tickets.get(ghost); - expect(ticket.summary.depositPolicy).toBe('NOT_REQUIRED'); - expect(policyEvents(ticket)).toEqual([expect.stringContaining(revision.number)]); - }); - - it('a NOT_REQUIRED a person set is kept when a revision still carries a deposit', async () => { - const ghost = await quotationOnlyTicket(); - const fifty = await approvedWith(ghost, FIFTY); - await api.dealQuotations.promoteToDeal(fifty.id); - await api.tickets.setDepositPolicy(ghost, { policy: 'NOT_REQUIRED', reason: 'ลูกค้าโอนเต็มจำนวนล่วงหน้า' }); - - await approvedRevisionOf(fifty, FIFTY); - - const { ticket } = await api.tickets.get(ghost); - expect(ticket.summary.depositPolicy).toBe('NOT_REQUIRED'); - expect(ticket.summary.depositPolicyReason).toBe('ลูกค้าโอนเต็มจำนวนล่วงหน้า'); - expect(policyEvents(ticket)).toHaveLength(1); - }); }); diff --git a/frontend/src/api/mockApi.stageDecisionsRoute.test.js b/frontend/src/api/mockApi.stageDecisionsRoute.test.js index 168329ec2..7d5f81dd1 100644 --- a/frontend/src/api/mockApi.stageDecisionsRoute.test.js +++ b/frontend/src/api/mockApi.stageDecisionsRoute.test.js @@ -86,3 +86,23 @@ describe('mockStageDecisions — onRoute mirrors DealRoute', () => { } }); }); + +// QA-05 (owner ruling 2026-10-28): mirror of TicketService's "order confirmed => no rewind below +// ขั้น 10". Plumbing only; the 409 itself is proved by StageDecisionIntegrationTest on the Java side. +describe('mockStageDecisions — no rewind below ขั้น 10 once the order is confirmed', () => { + it('offers no stage below ORDER_RECEIVED on a deal whose payment track has started', async () => { + await api.auth.login({ role: 'ceo' }); + const { tickets } = await api.tickets.list(); + const confirmed = tickets.find((t) => (t.lifecycle ?? 'ACTIVE') === 'ACTIVE' + && t.paymentStatus && ['CUSTOMER_CONFIRMED', 'DEPOSIT_NOTICE_ISSUED', 'DEPOSIT_PAID'].includes(t.paymentStatus)); + expect(confirmed, 'the mock must hold an order-confirmed deal').toBeTruthy(); + + const { stageDecisions } = await api.tickets.actions(confirmed.id); + const below = stageDecisions.filter((d) => d.no < 10 && d.stage !== confirmed.salesStage); + expect(below.length).toBeGreaterThan(0); + below.forEach((d) => { + expect(d.allowed, d.stage).toBe(false); + expect(d.blockedReason).toBeTruthy(); + }); + }); +}); diff --git a/frontend/src/features/commissions/CommissionPage.jsx b/frontend/src/features/commissions/CommissionPage.jsx index 17265a634..17b01ff06 100644 --- a/frontend/src/features/commissions/CommissionPage.jsx +++ b/frontend/src/features/commissions/CommissionPage.jsx @@ -684,7 +684,7 @@ export function CommissionPage({ user, showToast }) { return; } setLoadedTicket(summary); - const defaultGross = summary.amountPayableExVat ?? summary.amountPayable; + const defaultGross = summary.amountPayable; updateCreateForm('grossAmount', defaultGross != null ? String(defaultGross) : ''); } catch (error) { setLoadedTicket(null); diff --git a/frontend/src/features/commissions/CommissionPage.test.jsx b/frontend/src/features/commissions/CommissionPage.test.jsx index 842bdb282..347c79d78 100644 --- a/frontend/src/features/commissions/CommissionPage.test.jsx +++ b/frontend/src/features/commissions/CommissionPage.test.jsx @@ -444,7 +444,7 @@ function financeDeal(overrides = {}) { code: 'TCK-0042', customerName: 'บริษัท ทดสอบ จำกัด', salesStage: 'CLOSED_PAID', - money: { amountPayable: 3210000 }, + money: { amountPayable: 3210000, amountPayableExVat: 3000000 }, ...overrides, }; } @@ -495,7 +495,7 @@ describe('CommissionPage — account create-from-deal tax invoice upload', () => // fields are mapped from the finance view: code, customer, and the amount from `money` expect(screen.getByText('TCK-0042')).not.toBeNull(); expect(screen.getByText(/฿3,210,000.00/)).not.toBeNull(); - expect((screen.getByLabelText(/ยอดรวม/)).value).toBe('3210000'); + expect((screen.getByLabelText(/ยอดรับจริง/)).value).toBe('3210000'); }); it('a finance-view deal that has not reached CLOSED_PAID is refused with the stage in the message', async () => { @@ -529,7 +529,7 @@ describe('CommissionPage — account create-from-deal tax invoice upload', () => fireEvent.change(screen.getByLabelText(/เลขที่ใบกำกับ/), { target: { value: 'INV-0042' } }); const original = new File(['x'], 'tax-invoice-0042.jpg', { type: 'image/jpeg' }); fireEvent.change(document.getElementById('commission-invoice-file'), { target: { files: [original] } }); - fireEvent.change(screen.getByLabelText(/ยอดรวม/), { target: { value: '3210000' } }); + fireEvent.change(screen.getByLabelText(/ยอดรับจริง/), { target: { value: '3210000' } }); fireEvent.submit(screen.getByRole('button', { name: 'บันทึกและสร้างคำขอค่าคอม' }).closest('form')); await waitFor(() => expect(api.commissions.createFromDeal).toHaveBeenCalledTimes(1)); await waitFor(() => expect(spy).toHaveBeenCalledWith({ queryKey: ['finance'] })); @@ -542,7 +542,7 @@ describe('CommissionPage — account create-from-deal tax invoice upload', () => fireEvent.change(screen.getByLabelText(/เลขที่ใบกำกับ/), { target: { value: 'INV-0042' } }); const original = new File(['fake-jpeg-bytes'], 'tax-invoice-0042.jpg', { type: 'image/jpeg' }); fireEvent.change(document.getElementById('commission-invoice-file'), { target: { files: [original] } }); - fireEvent.change(screen.getByLabelText(/ยอดรวม/), { target: { value: '3210000' } }); + fireEvent.change(screen.getByLabelText(/ยอดรับจริง/), { target: { value: '3210000' } }); // fireEvent.click on the submit button runs jsdom's native constraint validation first, // which (unlike a real browser) does not reliably see the file input as satisfied after a @@ -569,7 +569,7 @@ describe('CommissionPage — account create-from-deal tax invoice upload', () => fireEvent.change(screen.getByLabelText(/เลขที่ใบกำกับ/), { target: { value: 'INV-0042' } }); const pdf = new File(['fake-pdf-bytes'], 'tax-invoice-0042.pdf', { type: 'application/pdf' }); fireEvent.change(document.getElementById('commission-invoice-file'), { target: { files: [pdf] } }); - fireEvent.change(screen.getByLabelText(/ยอดรวม/), { target: { value: '3210000' } }); + fireEvent.change(screen.getByLabelText(/ยอดรับจริง/), { target: { value: '3210000' } }); // fireEvent.click on the submit button runs jsdom's native constraint validation first, // which (unlike a real browser) does not reliably see the file input as satisfied after a diff --git a/frontend/src/features/commissions/InvoiceFromDealForm.jsx b/frontend/src/features/commissions/InvoiceFromDealForm.jsx index bc22dbe7e..12b8bc340 100644 --- a/frontend/src/features/commissions/InvoiceFromDealForm.jsx +++ b/frontend/src/features/commissions/InvoiceFromDealForm.jsx @@ -110,10 +110,10 @@ export function InvoiceFromDealForm({ - + diff --git a/frontend/src/features/finance/FinanceDealPage.jsx b/frontend/src/features/finance/FinanceDealPage.jsx index 85a0fda0a..35cdc14f2 100644 --- a/frontend/src/features/finance/FinanceDealPage.jsx +++ b/frontend/src/features/finance/FinanceDealPage.jsx @@ -291,7 +291,7 @@ export function FinanceDealPage() { setFlowError(''); if (a.action === 'RECORD_INVOICE') { // ex-VAT default (the invoice is raised before VAT); fall back to the payable only for an older payload. - const gross = deal.money.amountPayableExVat ?? deal.money.amountPayable; + const gross = deal.money.amountPayable; setInvoiceForm(emptyInvoiceForm(gross != null ? String(gross) : '')); setInvoiceFileKey((k) => k + 1); } diff --git a/frontend/src/features/finance/FinanceDealPage.test.jsx b/frontend/src/features/finance/FinanceDealPage.test.jsx index 927b5a4b7..a70904985 100644 --- a/frontend/src/features/finance/FinanceDealPage.test.jsx +++ b/frontend/src/features/finance/FinanceDealPage.test.jsx @@ -360,7 +360,7 @@ describe('FinanceDealPage', () => { }); }); - describe('RECORD_INVOICE (invoice recording in-page, ex-VAT default)', () => { + describe('RECORD_INVOICE (invoice recording in-page, VAT-inclusive amount received default)', () => { const recordInvoice = () => action('RECORD_INVOICE', 'บันทึกใบกำกับ'); const closedDeal = (extra = {}) => { const deal = makeFinanceDeal({ @@ -385,13 +385,16 @@ describe('FinanceDealPage', () => { expect(screen.queryByText(/ออกค่าคอม/)).toBeNull(); }); - it('clicking it opens an in-page form (no navigation to /commissions) whose ยอดรวม defaults to the EX-VAT figure', async () => { + it('clicking it opens an in-page form (no navigation to /commissions) whose amount received defaults to the VAT-INCLUSIVE payable (QA-02)', async () => { api.finance.getDeal.mockResolvedValue({ deal: closedDeal() }); renderPage(); fireEvent.click(await screen.findByRole('button', { name: 'บันทึกใบกำกับ' })); - const gross = await screen.findByLabelText(/^ยอดรวม/); - expect(gross.value).toBe('100000'); - expect(gross.value).not.toBe('107000'); + // QA-02 (owner ruling 2026-10-28): CommissionCalculator.calculateInvoice divides by 1.07 itself, so the + // field is the amount received INCLUDING VAT; pre-filling the ex-VAT figure strips VAT twice. + const gross = await screen.findByLabelText(/ยอดรับจริง \(รวม VAT\)/); + expect(gross.value).toBe('107000'); + expect(gross.value).not.toBe('100000'); + expect(screen.queryByLabelText(/ก่อน VAT/)).toBeNull(); expect(screen.queryByText('WORKLIST-PAGE')).toBeNull(); expect(screen.getByLabelText(/เลขที่ใบกำกับ/)).not.toBeNull(); expect(screen.getByLabelText(/วันที่ใบกำกับ/)).not.toBeNull(); @@ -428,7 +431,7 @@ describe('FinanceDealPage', () => { expect(payload.ticketId).toBe(501); expect(payload.invoiceNumber).toBe('INV-0501'); expect(payload.invoiceDate).toBe('2026-09-20'); - expect(Number(payload.grossAmount)).toBe(100000); + expect(Number(payload.grossAmount)).toBe(107000); expect(Number(payload.bankFees)).toBe(50); expect(Number(payload.suspenseVat)).toBe(7000); expect(Number(payload.transportFee)).toBe(300); @@ -458,6 +461,8 @@ describe('FinanceDealPage', () => { expect(closed.getByText(/INV-0501/)).not.toBeNull(); expect(closed.getByText(/20 ก\.ย\. 2569|20\/09\/2569|2026-09-20/)).not.toBeNull(); expect(closed.getByText(/100,000\.00/)).not.toBeNull(); + expect(closed.getByText(/ยอดรับจริง \(รวม VAT\)/)).not.toBeNull(); + expect(closed.queryByText(/ก่อน VAT/)).toBeNull(); for (const v of ['50.00', '7,000.00', '300.00', '200.00', '10.00', '3,000.00', '5.00']) { expect(closed.getAllByText(new RegExp(v.replace('.', '\\.'))).length).toBeGreaterThan(0); } diff --git a/frontend/src/features/finance/FinanceMilestoneSections.jsx b/frontend/src/features/finance/FinanceMilestoneSections.jsx index 1016e2154..e315f6aff 100644 --- a/frontend/src/features/finance/FinanceMilestoneSections.jsx +++ b/frontend/src/features/finance/FinanceMilestoneSections.jsx @@ -301,7 +301,7 @@ const COMMISSION_APPROVAL = { }; const INVOICE_FIELDS = [ - ['grossAmount', 'ยอดรวม (ก่อน VAT)'], + ['grossAmount', 'ยอดรับจริง (รวม VAT)'], ['bankFees', 'ค่าธรรมเนียมธนาคาร'], ['suspenseVat', 'ภาษีพัก (Suspense VAT)'], ['transportFee', 'ค่าขนส่ง'], diff --git a/frontend/src/features/pricingRequests/PricingRequestCreateModal.jsx b/frontend/src/features/pricingRequests/PricingRequestCreateModal.jsx index 72599b76f..08a95dbde 100644 --- a/frontend/src/features/pricingRequests/PricingRequestCreateModal.jsx +++ b/frontend/src/features/pricingRequests/PricingRequestCreateModal.jsx @@ -179,6 +179,8 @@ function emptyItemFromTicketItem(ticketItem) { // rounds up to a full box unless the rep opts out. roundToFullBox: true, originCountry: seededOrigin, + // QA-04: sales flags stock per line; seeded from the deal line. IN_THAILAND is the only value this form sets. + stockSource: ticketItem?.sourcedFromStock ? 'IN_THAILAND' : null, // GLA-125: typed name when originCountry = "อื่นๆ" — see QuotationItemRow's own comment. originCountryOther: null, leadTimeMinDays: seededLeadTime.leadTimeMinDays, @@ -316,6 +318,7 @@ function itemFromExisting(item) { piecesPerBox: item?.piecesPerBox ?? '', roundToFullBox: item?.roundToFullBox !== false, originCountry: item?.originCountry ?? '', + stockSource: item?.stockSource ?? null, originCountryOther: item?.originCountryOther ?? null, leadTimeMinDays: item?.leadTimeMinDays ?? null, leadTimeMaxDays: item?.leadTimeMaxDays ?? null, @@ -390,6 +393,7 @@ function pricingRequestItemInputFromRow(item) { sqmPerBox: item.sqmPerBox === '' || item.sqmPerBox == null ? null : Number(item.sqmPerBox), roundToFullBox: item.roundToFullBox !== false, originCountry: item.originCountry || null, + stockSource: item.stockSource ?? null, // GLA-125: sent as-typed; the server (PricingRequestService#resolveItem) is what clears this // when originCountry is not "อื่นๆ" — this row-level payload builder does no normalization // itself, matching how it never normalizes any other field either (server-side derivation @@ -744,7 +748,7 @@ export function PricingRequestCreateModal({ // GLA-125: ประเทศต้นทาง is required on THIS form (owner ruling 2026-09-18). ระยะเวลานำเข้า is // NOT (owner ruling 2026-10-18: import gives the time) — the origin's default still rides // along on the wire, and the field itself is hidden (hideLeadTime below). - requireOriginCountry: true, + requireOriginCountry: !item.stockSource, // QA-04: a stock line has no import origin (backend exempts it) }); // The lead-time range check still runs inside validateQuotationItem, but its error renders only in // the field hideLeadTime removes, so it could block a save with nothing red on screen. The server @@ -1056,7 +1060,7 @@ export function PricingRequestCreateModal({ // GLA-125 (owner ruling 2026-09-18): ประเทศต้นทาง (and its "อื่นๆ" typed name) is // required on THIS form only — direct-deal keeps it optional. ระยะเวลานำเข้า is not // asked here at all (owner ruling 2026-10-18: import gives the time). - requireOriginCountry + requireOriginCountry={!item.stockSource} hideLeadTime errors={itemFieldErrors[index] ?? {}} onChange={(patch) => updateItem(index, patch)} @@ -1085,6 +1089,11 @@ export function PricingRequestCreateModal({ {UNIT_BASIS_OPTIONS.map((option) => )} + ); }} diff --git a/frontend/src/features/pricingRequests/PricingRequestCreateModal.test.jsx b/frontend/src/features/pricingRequests/PricingRequestCreateModal.test.jsx index 9ed5cd262..2b2d3c996 100644 --- a/frontend/src/features/pricingRequests/PricingRequestCreateModal.test.jsx +++ b/frontend/src/features/pricingRequests/PricingRequestCreateModal.test.jsx @@ -221,6 +221,64 @@ describe('PricingRequestCreateModal', () => { expect(item.factory).toBe('โรงงาน A'); }); + // QA-04 (owner ruling 2026-10-28): SALES flags stock per line when creating the PCR (the CEO types the + // price, sales does not). The deal line's sourcedFromStock seeds the flag; the payload carries + // stockSource (backend PricingRequestItemRequest: IN_THAILAND | IN_TRANSIT; null = import). + describe('stock flag per line (QA-04)', () => { + const stockCheckbox = () => screen.getByRole('checkbox', { name: /จากสต็อก/ }); + const submitWith = async (overrides) => { + const { createFn } = renderModal(overrides); + fireEvent.change(screen.getByPlaceholderText('เช่น ชื่อผู้ออกแบบ หรือชื่อบริษัทผู้ซื้อ'), { target: { value: 'ผู้ออกแบบ ก.' } }); + fillRequiredFields(); + return { createFn, send: async () => { + fireEvent.click(screen.getByRole('button', { name: /ส่งให้ฝ่ายนำเข้า/ })); + await waitFor(() => expect(createFn).toHaveBeenCalledTimes(1)); + return createFn.mock.calls[0][0].items; + } }; + }; + + it('seeds the flag from the deal item sourcedFromStock, and the create payload carries stockSource', async () => { + const { send } = await submitWith({ ticketItems: [ticketItem({ sourcedFromStock: true })] }); + expect(stockCheckbox().checked).toBe(true); + const [item] = await send(); + expect(item.stockSource).toBe('IN_THAILAND'); + }); + + it('leaves an ordinary import line unflagged: stockSource is null', async () => { + const { send } = await submitWith({ ticketItems: [ticketItem({ sourcedFromStock: false })] }); + expect(stockCheckbox().checked).toBe(false); + const [item] = await send(); + expect(item.stockSource ?? null).toBeNull(); + }); + + it('lets sales tick the flag on a line the deal did not flag', async () => { + const on = await submitWith({ ticketItems: [ticketItem()] }); + fireEvent.click(stockCheckbox()); + expect((await on.send())[0].stockSource).toBe('IN_THAILAND'); + }); + + it('a stock line needs no ประเทศต้นทาง (backend exempts it); an import line still does', async () => { + const { createFn } = renderModal({ ticketItems: [ticketItem({ sourcedFromStock: true })] }); + fireEvent.change(screen.getByPlaceholderText('เช่น ชื่อผู้ออกแบบ หรือชื่อบริษัทผู้ซื้อ'), { target: { value: 'ผู้ออกแบบ ก.' } }); + fillRequiredFields(); + fireEvent.change(screen.getByLabelText(/^ประเทศต้นทาง/), { target: { value: '' } }); + fireEvent.click(screen.getByRole('button', { name: /ส่งให้ฝ่ายนำเข้า/ })); + await waitFor(() => expect(createFn).toHaveBeenCalledTimes(1)); + expect(createFn.mock.calls[0][0].items[0].originCountry).toBeNull(); + + fireEvent.click(stockCheckbox()); // back to an import line -> origin required again + fireEvent.click(screen.getByRole('button', { name: /ส่งให้ฝ่ายนำเข้า/ })); + expect(await screen.findByText('กรุณาระบุประเทศต้นทาง')).not.toBeNull(); + expect(createFn).toHaveBeenCalledTimes(1); + }); + + it('un-ticking a seeded line sends it as an import line', async () => { + const off = await submitWith({ ticketItems: [ticketItem({ sourcedFromStock: true })] }); + fireEvent.click(stockCheckbox()); + expect((await off.send())[0].stockSource ?? null).toBeNull(); + }); + }); + it('blocks an item with no model — the identity/completeness gate reuses รุ่น, not a dedicated identity field', async () => { const { createFn } = renderModal({ ticketItems: [] }); // no deal items to seed from -> one blank row fireEvent.change(screen.getByPlaceholderText('เช่น ชื่อผู้ออกแบบ หรือชื่อบริษัทผู้ซื้อ'), { target: { value: 'ผู้ออกแบบ ก.' } }); diff --git a/frontend/src/features/pricingRequests/PricingRequestDetailPage.jsx b/frontend/src/features/pricingRequests/PricingRequestDetailPage.jsx index ed27a51ba..bc841a6c3 100644 --- a/frontend/src/features/pricingRequests/PricingRequestDetailPage.jsx +++ b/frontend/src/features/pricingRequests/PricingRequestDetailPage.jsx @@ -73,7 +73,7 @@ import { import { useUnitBasisCatalog } from './unitBasisCatalog.js'; import { buttonVariants } from '../../components/common/Button.jsx'; import { cn } from '../../utils/cn.js'; -import { acceptOutcomeConfirmCopy, newestQuotationByRequest, PRICE_MODE_OPTIONS } from '../quotations/quotationMeta.js'; +import { acceptOutcomeConfirmCopy, CONFIRM_ORDER_FROM_QUOTATION_COPY, newestQuotationByRequest, PRICE_MODE_OPTIONS } from '../quotations/quotationMeta.js'; import { SearchableCombobox } from '../../components/common/SearchableCombobox.jsx'; // B6 (GLA-135): reuses the catalog page's own add-factory dialog rather than a second, drifting // copy of the same five fields + validation — see ImportFactoryPicker's own comment below. @@ -883,6 +883,8 @@ export function PricingRequestDetailPage({ user, showToast }) { const [emailDrafts, setEmailDrafts] = useState({}); const [receiveClientRequestIds, setReceiveClientRequestIds] = useState({}); const [confirmAction, setConfirmAction] = useState(null); + // SK-02: confirm-order is irreversible -> dialog first. + const [confirmingOrder, setConfirmingOrder] = useState(false); // Review remediation (COMMIT 5, P1 finding 3): the customer-change revision UI now reuses // PricingRequestCreateModal in mode="revision" (seeded from the current request, full item // editing, catalog picker, unit select) instead of the old inline reason-only form that copied @@ -4322,7 +4324,7 @@ export function PricingRequestDetailPage({ user, showToast }) { ลูกค้ายอมรับใบเสนอราคาแล้ว — ยืนยันคำสั่งซื้อเพื่อเริ่มขั้นตอนรับมัดจำและนำเข้าสินค้า

@@ -4363,6 +4365,20 @@ export function PricingRequestDetailPage({ user, showToast }) { ) : null} + + {CONFIRM_ORDER_FROM_QUOTATION_COPY.lead(summary?.requestCode)} {CONFIRM_ORDER_FROM_QUOTATION_COPY.stage} + {' '}{CONFIRM_ORDER_FROM_QUOTATION_COPY.tail} +

+ )} + confirmLabel="ยืนยันคำสั่งซื้อ" + busy={confirmOrder.isPending} + onCancel={() => 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/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..dea137fa7 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'; } +/** The deal's order-confirmation fact from its ticket events: the DEAL_PROMOTED_FROM_QUOTATION event names the + * deal's order is confirmed. null when events are not loaded. */ +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..6fa6aea02 100644 --- a/frontend/src/features/tickets/DealQuotationPanel.jsx +++ b/frontend/src/features/tickets/DealQuotationPanel.jsx @@ -22,7 +22,7 @@ import { canManageCustomerQuotation, canRecordCustomerQuotationOutcome, canViewCustomerQuotation, isCustomerQuotationEditable, pricingRequestRecipientLabel, } from '../pricingRequests/pricingRequestMeta.js'; -import { acceptOutcomeConfirmCopy, dealQuotationStatusLabel } from '../quotations/quotationMeta.js'; +import { acceptOutcomeConfirmCopy, CONFIRM_ORDER_FROM_QUOTATION_COPY, dealQuotationStatusLabel } from '../quotations/quotationMeta.js'; import { buttonVariants } from '../../components/common/Button.jsx'; function generateClientRequestId() { @@ -99,6 +99,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 +149,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 +311,20 @@ export const DealQuotationPanel = forwardRef(function DealQuotationPanel({ ) : null} + + {CONFIRM_ORDER_FROM_QUOTATION_COPY.lead(current?.number)} {CONFIRM_ORDER_FROM_QUOTATION_COPY.stage} + {' '}{CONFIRM_ORDER_FROM_QUOTATION_COPY.tail} +

+ )} + confirmLabel="ยืนยันคำสั่งซื้อ" + busy={confirmOrder.isPending} + onCancel={() => 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) 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())); }); }); From e4d6564090a7f86b9b7255986103123591579f9e Mon Sep 17 00:00:00 2001 From: waritctd <148909369+waritctd@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:58:44 +0700 Subject: [PATCH 2/3] =?UTF-8?q?fix(sales):=20PR=20#1182=20review=20?= =?UTF-8?q?=E2=80=94=20cancel=20other=20open=20direct=20quotations=20at=20?= =?UTF-8?q?confirm,=20one=20answered-quotation=20rule=20keyed=20on=20the?= =?UTF-8?q?=20promotion=20event,=20keep=20IN=5FTRANSIT=20on=20re-tick,=20s?= =?UTF-8?q?hared=20ConfirmOrderDialog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Confirm-order cancels the deal's other DEAL_DIRECT quotations in DRAFT/PENDING_APPROVAL (one CANCELLED event each), so none is stranded in the approver's queue. - TicketRepository.ANSWERED_QUOTATION_SQL: ACCEPTED, or an APPROVED DEAL_DIRECT quotation named by the DEAL_PROMOTED_FROM_QUOTATION event; used by payableAmountSelect, paymentTermsRowSql and FinanceDealRepository.findPayableQuotation (inline copy removed). - PCR modal: re-ticking จากสต็อก restores a loaded IN_TRANSIT source. - ConfirmOrderDialog shared by DealQuotationPanel, PricingRequestDetailPage and TicketDetailPage. - Javadoc fixes (GLA-136 doc back on hasPromotionFromQuotationEvent; orderConfirmationOf). Co-Authored-By: Claude Opus 5.5 --- .../DealQuotationRepository.java | 12 +++++++ .../dealquotation/DealQuotationService.java | 6 ++++ .../glr/hr/finance/FinanceDealRepository.java | 8 ++--- .../th/co/glr/hr/ticket/TicketRepository.java | 36 ++++++++++--------- ...lQuotationConfirmOrderIntegrationTest.java | 20 +++++++++++ ...inancePayableVatAndDueIntegrationTest.java | 27 ++++++++++++++ .../PricingRequestCreateModal.jsx | 4 ++- .../PricingRequestCreateModal.test.jsx | 21 +++++++++++ .../PricingRequestDetailPage.jsx | 14 +++----- .../quotations/ConfirmOrderDialog.jsx | 27 ++++++++++++++ .../src/features/quotations/quotationMeta.js | 4 +-- .../features/tickets/DealQuotationPanel.jsx | 14 +++----- .../src/features/tickets/TicketDetailPage.jsx | 14 +++----- 13 files changed, 152 insertions(+), 55 deletions(-) create mode 100644 frontend/src/features/quotations/ConfirmOrderDialog.jsx 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 b6eeec7dd..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 @@ -2176,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 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 21cff493f..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,15 +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') - -- QA-03: same rule as TicketRepository.answeredQuotationPredicate (confirmed DEAL_DIRECT stays APPROVED) - OR (q.doc_status = 'APPROVED' AND q.origin = 'DEAL_DIRECT' - AND (SELECT tp.payment_status FROM sales.ticket tp WHERE tp.ticket_id = q.ticket_id) IS NOT NULL)) + 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 1818110e5..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 @@ -1695,15 +1695,17 @@ public BigDecimal payableAmount(long ticketId) { } /** - * QA-03: a DEAL_DIRECT quotation confirmed via confirm-order stays APPROVED (it never reaches ACCEPTED), so - * it counts as the "answered" quotation -- but only once the order IS confirmed: confirm-order moves the - * ticket onto the payment track (payment_status non-null, CUSTOMER_CONFIRMED). An APPROVED, unconfirmed one - * is not payable. ACCEPTED still outranks it. + * 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. */ - private static String answeredQuotationPredicate(String ticketIdRef) { - return "(q.doc_status = 'ACCEPTED' OR (q.doc_status = 'APPROVED' AND q.origin = 'DEAL_DIRECT'" - + " AND (SELECT tp.payment_status FROM sales.ticket tp WHERE tp.ticket_id = " + ticketIdRef + ") IS NOT NULL))"; - } + 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 @@ -1797,7 +1799,7 @@ SELECT COALESCE( ) """.formatted(ticketIdRef) .replace("ISSUED_DOCUMENTS", inclVat ? issuedDocumentsPayable(ticketIdRef) : "") - .replace("ANSWERED", answeredQuotationPredicate(ticketIdRef)) + .replace("ANSWERED", ANSWERED_QUOTATION_SQL) .replace("QUOTATION_AMOUNT", quotationAmount) .replace("ITEMS_AMOUNT", itemsAmount); } @@ -1855,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).replace("ANSWERED", answeredQuotationPredicate(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. */ @@ -2274,13 +2276,6 @@ public int markPromotedToPipeline(long ticketId) { """, Map.of("id", ticketId)); } - /** - * 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 - * {@code DealQuotationService#confirmOrderFromDirectQuotation})? The discriminator for that - * method's idempotent replay: if the event exists, a replay returns the current state and - * writes nothing. - */ /** The DEAL_PROMOTED_FROM_QUOTATION event names the quotation whose confirm-order made the deal an order. */ public boolean isOrderConfirmedFromQuotation(long ticketId, long quotationId) { Boolean value = jdbc.queryForObject(""" @@ -2292,6 +2287,13 @@ SELECT EXISTS(SELECT 1 FROM sales.ticket_event 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 + * {@code DealQuotationService#confirmOrderFromDirectQuotation})? The discriminator for that + * method's idempotent replay: if the event exists, a replay returns the current state and + * writes nothing. + */ public boolean hasPromotionFromQuotationEvent(long ticketId) { Boolean value = jdbc.queryForObject(""" SELECT EXISTS(SELECT 1 FROM sales.ticket_event 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 542e2f518..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 @@ -478,6 +478,26 @@ private void assertStillTheAgreedOrder(long deal, DealQuotationDto v1) { assertThat(summary(deal).depositPolicy()).isEqualTo("REQUIRED"); } + /** Review finding 2: confirming the order must not strand the deal's other open direct quotations. */ + @Test + 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() { 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 4f569af23..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 @@ -88,6 +88,11 @@ void payingTheFullVatInclusiveAmount_isNotOverpayment_andReachesFullyPaid() { 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; } @@ -162,6 +167,28 @@ void acceptedQuotation_outranksAnApprovedDirectOne_inAllThreeQueries() { 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 diff --git a/frontend/src/features/pricingRequests/PricingRequestCreateModal.jsx b/frontend/src/features/pricingRequests/PricingRequestCreateModal.jsx index 08a95dbde..785088301 100644 --- a/frontend/src/features/pricingRequests/PricingRequestCreateModal.jsx +++ b/frontend/src/features/pricingRequests/PricingRequestCreateModal.jsx @@ -319,6 +319,8 @@ function itemFromExisting(item) { 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, @@ -1091,7 +1093,7 @@ export function PricingRequestCreateModal({ diff --git a/frontend/src/features/pricingRequests/PricingRequestCreateModal.test.jsx b/frontend/src/features/pricingRequests/PricingRequestCreateModal.test.jsx index 2b2d3c996..2389e398c 100644 --- a/frontend/src/features/pricingRequests/PricingRequestCreateModal.test.jsx +++ b/frontend/src/features/pricingRequests/PricingRequestCreateModal.test.jsx @@ -682,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 bc841a6c3..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'; @@ -73,7 +74,7 @@ import { import { useUnitBasisCatalog } from './unitBasisCatalog.js'; import { buttonVariants } from '../../components/common/Button.jsx'; import { cn } from '../../utils/cn.js'; -import { acceptOutcomeConfirmCopy, CONFIRM_ORDER_FROM_QUOTATION_COPY, newestQuotationByRequest, PRICE_MODE_OPTIONS } from '../quotations/quotationMeta.js'; +import { acceptOutcomeConfirmCopy, newestQuotationByRequest, PRICE_MODE_OPTIONS } from '../quotations/quotationMeta.js'; import { SearchableCombobox } from '../../components/common/SearchableCombobox.jsx'; // B6 (GLA-135): reuses the catalog page's own add-factory dialog rather than a second, drifting // copy of the same five fields + validation — see ImportFactoryPicker's own comment below. @@ -4365,16 +4366,9 @@ export function PricingRequestDetailPage({ user, showToast }) { ) : null} - - {CONFIRM_ORDER_FROM_QUOTATION_COPY.lead(summary?.requestCode)} {CONFIRM_ORDER_FROM_QUOTATION_COPY.stage} - {' '}{CONFIRM_ORDER_FROM_QUOTATION_COPY.tail} -

- )} - confirmLabel="ยืนยันคำสั่งซื้อ" + number={summary?.requestCode} busy={confirmOrder.isPending} onCancel={() => setConfirmingOrder(false)} onConfirm={() => { setConfirmingOrder(false); confirmOrder.mutate(); }} 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/quotationMeta.js b/frontend/src/features/quotations/quotationMeta.js index dea137fa7..258f98451 100644 --- a/frontend/src/features/quotations/quotationMeta.js +++ b/frontend/src/features/quotations/quotationMeta.js @@ -168,8 +168,8 @@ export function canReviseDealQuotation(user, quotation, order) { return canEditDealQuotation(user, quotation) && quotation?.docStatus === 'APPROVED'; } -/** The deal's order-confirmation fact from its ticket events: the DEAL_PROMOTED_FROM_QUOTATION event names the - * deal's order is confirmed. null when events are not loaded. */ +/** 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'); diff --git a/frontend/src/features/tickets/DealQuotationPanel.jsx b/frontend/src/features/tickets/DealQuotationPanel.jsx index 6fa6aea02..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'; @@ -22,7 +23,7 @@ import { canManageCustomerQuotation, canRecordCustomerQuotationOutcome, canViewCustomerQuotation, isCustomerQuotationEditable, pricingRequestRecipientLabel, } from '../pricingRequests/pricingRequestMeta.js'; -import { acceptOutcomeConfirmCopy, CONFIRM_ORDER_FROM_QUOTATION_COPY, dealQuotationStatusLabel } from '../quotations/quotationMeta.js'; +import { acceptOutcomeConfirmCopy, dealQuotationStatusLabel } from '../quotations/quotationMeta.js'; import { buttonVariants } from '../../components/common/Button.jsx'; function generateClientRequestId() { @@ -311,16 +312,9 @@ export const DealQuotationPanel = forwardRef(function DealQuotationPanel({ ) : null} - - {CONFIRM_ORDER_FROM_QUOTATION_COPY.lead(current?.number)} {CONFIRM_ORDER_FROM_QUOTATION_COPY.stage} - {' '}{CONFIRM_ORDER_FROM_QUOTATION_COPY.tail} -

- )} - confirmLabel="ยืนยันคำสั่งซื้อ" + number={current?.number} busy={confirmOrder.isPending} onCancel={() => setConfirmingOrder(false)} onConfirm={() => { setConfirmingOrder(false); confirmOrder.mutate(); }} diff --git a/frontend/src/features/tickets/TicketDetailPage.jsx b/frontend/src/features/tickets/TicketDetailPage.jsx index 3eeb908a9..14721b05b 100644 --- a/frontend/src/features/tickets/TicketDetailPage.jsx +++ b/frontend/src/features/tickets/TicketDetailPage.jsx @@ -18,6 +18,7 @@ import { queryKeys } from '../../api/queryKeys.js'; import { Breadcrumbs } from '../../components/common/Breadcrumbs.jsx'; import { Button, buttonVariants } from '../../components/common/Button.jsx'; import { ConfirmDialog } from '../../components/common/ConfirmDialog.jsx'; +import { ConfirmOrderDialog } from '../quotations/ConfirmOrderDialog.jsx'; import { EmptyState } from '../../components/common/EmptyState.jsx'; import { fieldErrorId } from '../../components/common/FormField.jsx'; import { Icon } from '../../components/common/Icon.jsx'; @@ -54,7 +55,7 @@ import { DealMoneyTimeline } from './DealMoneyTimeline.jsx'; import { DealRemainingInvoiceCard } from './DealRemainingInvoiceCard.jsx'; import { DealQuotationPanel } from './DealQuotationPanel.jsx'; import { - canReviseDealQuotation, orderConfirmationOf, canViewDealQuotation, CONFIRM_ORDER_FROM_QUOTATION_COPY, newestQuotationByRequest, + canReviseDealQuotation, orderConfirmationOf, canViewDealQuotation, newestQuotationByRequest, } from '../quotations/quotationMeta.js'; import { DealStagePanel } from './DealStagePanel.jsx'; import { DealStateHeader } from './DealStateHeader.jsx'; @@ -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)} From 55706fd15493b0c55ffc20dcf5d1987b962f6b0a Mon Sep 17 00:00:00 2001 From: waritctd <148909369+waritctd@users.noreply.github.com> Date: Wed, 28 Oct 2026 23:14:00 +0700 Subject: [PATCH 3/3] =?UTF-8?q?test(e2e):=20commission=20invoice=20form=20?= =?UTF-8?q?defaults=20to=20the=20VAT-inclusive=20=E0=B8=A2=E0=B8=AD?= =?UTF-8?q?=E0=B8=94=E0=B8=A3=E0=B8=B1=E0=B8=9A=E0=B8=88=E0=B8=A3=E0=B8=B4?= =?UTF-8?q?=E0=B8=87=20(QA-02=20ruling=202026-10-28)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../write-commission-invoice-approval.spec.js | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) 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'); });