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/dashboard/DashboardRepository.java b/backend/src/main/java/th/co/glr/hr/dashboard/DashboardRepository.java index eec9bbc46..36e2e5ffe 100644 --- a/backend/src/main/java/th/co/glr/hr/dashboard/DashboardRepository.java +++ b/backend/src/main/java/th/co/glr/hr/dashboard/DashboardRepository.java @@ -10,6 +10,7 @@ import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; import org.springframework.stereotype.Repository; import th.co.glr.hr.employee.ManagerApproverRepository; +import th.co.glr.hr.ticket.TicketRepository; @Repository public class DashboardRepository { @@ -135,56 +136,6 @@ public TicketSummaryDto tickets( WITH visible_tickets AS ( SELECT t.* """ + from + where + """ - ), enriched_tickets AS ( - SELECT vt.*, - COALESCE(accepted.total_amount, issued.total_amount, notice.total_payable, - item_total.total_amount, 0) AS payable_amount, - COALESCE(paid.total_paid, 0) AS paid_amount - FROM visible_tickets vt - LEFT JOIN LATERAL ( - SELECT q.total_amount - FROM sales.quotation q - WHERE q.ticket_id = vt.ticket_id AND q.doc_status = 'ACCEPTED' - ORDER BY 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 - ) accepted ON TRUE - LEFT JOIN LATERAL ( - SELECT q.total_amount - FROM sales.quotation q - WHERE q.ticket_id = vt.ticket_id AND q.doc_status IN ('ISSUED','SENT') - ORDER BY CASE q.recipient_type - WHEN 'BUYER' THEN 0 - WHEN 'OWNER' THEN 1 - ELSE 2 - END, - q.issued_at DESC, - q.quotation_id DESC - LIMIT 1 - ) issued ON TRUE - LEFT JOIN LATERAL ( - SELECT d.total_payable - FROM sales.deposit_notice d - WHERE d.ticket_id = vt.ticket_id AND d.status = 'ISSUED' - ORDER BY d.version DESC, d.deposit_notice_id DESC - LIMIT 1 - ) notice ON TRUE - LEFT JOIN LATERAL ( - SELECT COALESCE(SUM(COALESCE(ti.approved_price, 0) * ti.qty), 0) AS total_amount - FROM sales.ticket_item ti - WHERE ti.ticket_id = vt.ticket_id - ) item_total ON TRUE - LEFT JOIN LATERAL ( - SELECT COALESCE(SUM(CASE WHEN pr.kind = 'ADJUSTMENT' THEN -pr.amount ELSE pr.amount END), 0) AS total_paid - FROM sales.payment_receipt pr - WHERE pr.ticket_id = vt.ticket_id - ) paid ON TRUE ) SELECT COUNT(*) FILTER (WHERE status = 'draft') AS draft, COUNT(*) FILTER (WHERE status = 'submitted') AS submitted, @@ -205,12 +156,10 @@ WHERE status NOT IN ('closed','cancelled','draft') ) AS overdue_over_3days, COUNT(*) FILTER (WHERE lifecycle = 'ON_HOLD') AS on_hold, COUNT(*) FILTER (WHERE lifecycle = 'DORMANT') AS dormant, - COUNT(*) FILTER ( - WHERE due_date < CURRENT_DATE - AND GREATEST(payable_amount - paid_amount, 0) > 0 + COUNT(*) FILTER (WHERE """ + TicketRepository.paymentOverduePredicate("vt.ticket_id") + """ ) AS payment_overdue, COUNT(*) FILTER (WHERE fulfillment_status = 'PARTIALLY_DELIVERED') AS partially_delivered - FROM enriched_tickets + FROM visible_tickets vt """, params, (rs, rowNum) -> new TicketSummaryDto( diff --git a/backend/src/main/java/th/co/glr/hr/dealquotation/DealQuotationDtos.java b/backend/src/main/java/th/co/glr/hr/dealquotation/DealQuotationDtos.java index 36048363f..c74680bc6 100644 --- a/backend/src/main/java/th/co/glr/hr/dealquotation/DealQuotationDtos.java +++ b/backend/src/main/java/th/co/glr/hr/dealquotation/DealQuotationDtos.java @@ -835,11 +835,16 @@ public record DealQuotationItemDto( * Non-null ONLY on a TILE row of a document whose {@code priceMode} is {@code MIXED}; on * every other row it is null and the document's mode applies * ({@code WastageCalculator#rowPriceMode} is the one rule). */ - String priceMode + String priceMode, + /** V194 — "IN_THAILAND" | "IN_TRANSIT" when this row came from a stock-flagged pricing-request + * line (owner ruling 2026-10-28: stock lines skip import, so they carry no import lead time); + * null = สั่งนำเข้า, and null on every DEAL_DIRECT row. Read-only: set at insert from the + * pricing-request item, never accepted from the client. */ + String stockSource ) { /** Ruling 3 (2026-09-30): the account view -- net unit price and line amount stay; every list-price / discount / CEO pricing field is emptied. */ public DealQuotationItemDto withoutPriceInternals() { - return new DealQuotationItemDto(id, seq, locationLabel, null, productCode, brand, model, color, texture, sizeText, thicknessMm, sqmPerPiece, quantityMode, areaSqm, piecesInput, wastageMode, wastageValue, piecesPerBox, null, null, originCountry, leadTimeMinDays, leadTimeMaxDays, itemNotes, piecesPerSqm, piecesBeforeWastage, piecesAfterWastage, piecesFinal, boxes, netUnitPrice, lineAmount, descriptionLine, sizeLine, null, lineType, quantity, unit, null, null, adjustmentDeadline, null, null, hasPicture, picturePlacement, pictureUrl, sqmPerBox, roundToFullBox, false, null, null, null, null, null, null, directNetPriceSqm, null); + return new DealQuotationItemDto(id, seq, locationLabel, null, productCode, brand, model, color, texture, sizeText, thicknessMm, sqmPerPiece, quantityMode, areaSqm, piecesInput, wastageMode, wastageValue, piecesPerBox, null, null, originCountry, leadTimeMinDays, leadTimeMaxDays, itemNotes, piecesPerSqm, piecesBeforeWastage, piecesAfterWastage, piecesFinal, boxes, netUnitPrice, lineAmount, descriptionLine, sizeLine, null, lineType, quantity, unit, null, null, adjustmentDeadline, null, null, hasPicture, picturePlacement, pictureUrl, sqmPerBox, roundToFullBox, false, null, null, null, null, null, null, directNetPriceSqm, null, stockSource); } /** This item carrying {@code value} as its own {@link #priceMode} — the same appended-field @@ -854,7 +859,49 @@ public DealQuotationItemDto withPriceMode(String value) { unit, specialPriceSqm, adjustmentPct, adjustmentDeadline, specialPriceLine, adjustmentAmount, hasPicture, picturePlacement, pictureUrl, sqmPerBox, roundToFullBox, priceChangedFromCeo, ceoListUnitPrice, ceoDiscountPct, ceoSpecialPriceSqm, ceoDirectNetPrice, - ceoNetUnitPrice, listPriceSqm, directNetPriceSqm, value); + ceoNetUnitPrice, listPriceSqm, directNetPriceSqm, value, stockSource); + } + + /** The pre-stockSource canonical shape (58 components) -- stockSource null (import). */ + public DealQuotationItemDto( + long id, int seq, String locationLabel, Long catalogPriceId, String productCode, + String brand, String model, String color, String texture, String sizeText, + BigDecimal thicknessMm, BigDecimal sqmPerPiece, String quantityMode, BigDecimal areaSqm, + Integer piecesInput, String wastageMode, BigDecimal wastageValue, Integer piecesPerBox, + BigDecimal unitPrice, BigDecimal discountPct, String originCountry, + Integer leadTimeMinDays, Integer leadTimeMaxDays, String itemNotes, + BigDecimal piecesPerSqm, int piecesBeforeWastage, int piecesAfterWastage, int piecesFinal, + Integer boxes, BigDecimal netUnitPrice, BigDecimal lineAmount, + String descriptionLine, String sizeLine, String calculationLine, + String lineType, BigDecimal quantity, String unit, BigDecimal specialPriceSqm, + BigDecimal adjustmentPct, LocalDate adjustmentDeadline, String specialPriceLine, + BigDecimal adjustmentAmount, boolean hasPicture, String picturePlacement, String pictureUrl, + BigDecimal sqmPerBox, boolean roundToFullBox, boolean priceChangedFromCeo, + BigDecimal ceoListUnitPrice, BigDecimal ceoDiscountPct, BigDecimal ceoSpecialPriceSqm, + BigDecimal ceoDirectNetPrice, BigDecimal ceoNetUnitPrice, BigDecimal listPriceSqm, + BigDecimal directNetPriceSqm, String priceMode) { + this(id, seq, locationLabel, catalogPriceId, productCode, brand, model, color, texture, sizeText, + thicknessMm, sqmPerPiece, quantityMode, areaSqm, piecesInput, wastageMode, wastageValue, + piecesPerBox, unitPrice, discountPct, originCountry, leadTimeMinDays, leadTimeMaxDays, itemNotes, + piecesPerSqm, piecesBeforeWastage, piecesAfterWastage, piecesFinal, boxes, netUnitPrice, + lineAmount, descriptionLine, sizeLine, calculationLine, lineType, quantity, unit, + specialPriceSqm, adjustmentPct, adjustmentDeadline, specialPriceLine, adjustmentAmount, + hasPicture, picturePlacement, pictureUrl, sqmPerBox, roundToFullBox, priceChangedFromCeo, + ceoListUnitPrice, ceoDiscountPct, ceoSpecialPriceSqm, ceoDirectNetPrice, ceoNetUnitPrice, + listPriceSqm, directNetPriceSqm, priceMode, null); + } + + /** This item carrying {@code value} as its {@link #stockSource}. */ + public DealQuotationItemDto withStockSource(String value) { + return new DealQuotationItemDto(id, seq, locationLabel, catalogPriceId, productCode, brand, model, + color, texture, sizeText, thicknessMm, sqmPerPiece, quantityMode, areaSqm, piecesInput, + wastageMode, wastageValue, piecesPerBox, unitPrice, discountPct, originCountry, leadTimeMinDays, + leadTimeMaxDays, itemNotes, piecesPerSqm, piecesBeforeWastage, piecesAfterWastage, piecesFinal, + boxes, netUnitPrice, lineAmount, descriptionLine, sizeLine, calculationLine, lineType, quantity, + unit, specialPriceSqm, adjustmentPct, adjustmentDeadline, specialPriceLine, adjustmentAmount, + hasPicture, picturePlacement, pictureUrl, sqmPerBox, roundToFullBox, + priceChangedFromCeo, ceoListUnitPrice, ceoDiscountPct, ceoSpecialPriceSqm, ceoDirectNetPrice, + ceoNetUnitPrice, listPriceSqm, directNetPriceSqm, priceMode, value); } /** The pre-GLA-123 canonical shape (no CEO-original/changed-flag fields) — kept so every @@ -943,7 +990,7 @@ public DealQuotationItemDto withSqmPerBox(BigDecimal value) { unit, specialPriceSqm, adjustmentPct, adjustmentDeadline, specialPriceLine, adjustmentAmount, hasPicture, picturePlacement, pictureUrl, value, roundToFullBox, priceChangedFromCeo, ceoListUnitPrice, ceoDiscountPct, ceoSpecialPriceSqm, ceoDirectNetPrice, - ceoNetUnitPrice, listPriceSqm, directNetPriceSqm, priceMode); + ceoNetUnitPrice, listPriceSqm, directNetPriceSqm, priceMode, stockSource); } /** This item carrying {@code value} as its {@link #roundToFullBox} — same device as @@ -958,7 +1005,7 @@ public DealQuotationItemDto withRoundToFullBox(boolean value) { unit, specialPriceSqm, adjustmentPct, adjustmentDeadline, specialPriceLine, adjustmentAmount, hasPicture, picturePlacement, pictureUrl, sqmPerBox, value, priceChangedFromCeo, ceoListUnitPrice, ceoDiscountPct, ceoSpecialPriceSqm, ceoDirectNetPrice, - ceoNetUnitPrice, listPriceSqm, directNetPriceSqm, priceMode); + ceoNetUnitPrice, listPriceSqm, directNetPriceSqm, priceMode, stockSource); } /** This item carrying the GLA-123 CEO-comparison fields — same device as @@ -974,7 +1021,7 @@ public DealQuotationItemDto withCeoComparison(boolean changed, BigDecimal listUn unit, specialPriceSqm, adjustmentPct, adjustmentDeadline, specialPriceLine, adjustmentAmount, hasPicture, picturePlacement, pictureUrl, sqmPerBox, roundToFullBox, changed, listUnitPrice, discountPct2, specialPriceSqm2, directNetPrice, netUnitPrice2, - listPriceSqm, directNetPriceSqm, priceMode); + listPriceSqm, directNetPriceSqm, priceMode, stockSource); } /** This item carrying the per-ตร.ม. prices as the rep typed them (V192.2) — the same @@ -990,7 +1037,7 @@ public DealQuotationItemDto withPerSqmPrices(BigDecimal list, BigDecimal directN unit, specialPriceSqm, adjustmentPct, adjustmentDeadline, specialPriceLine, adjustmentAmount, hasPicture, picturePlacement, pictureUrl, sqmPerBox, roundToFullBox, priceChangedFromCeo, ceoListUnitPrice, ceoDiscountPct, ceoSpecialPriceSqm, ceoDirectNetPrice, - ceoNetUnitPrice, list, directNet, priceMode); + ceoNetUnitPrice, list, directNet, priceMode, stockSource); } /** This item with its picture fields set from the stored link (repository read path). */ @@ -1006,7 +1053,7 @@ public DealQuotationItemDto withPicture(long quotationId, String placement) { has ? "/api/deal-quotations/" + quotationId + "/items/" + id + "/picture" : null, sqmPerBox, roundToFullBox, priceChangedFromCeo, ceoListUnitPrice, ceoDiscountPct, ceoSpecialPriceSqm, ceoDirectNetPrice, - ceoNetUnitPrice, listPriceSqm, directNetPriceSqm, priceMode); + ceoNetUnitPrice, listPriceSqm, directNetPriceSqm, priceMode, stockSource); } } 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..6e6393f9a 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(""" @@ -1827,7 +1839,7 @@ private List findItems(long quotationId) { final_unit_price, amount, raw_unit, description, line_type, qi.special_price_sqm, adjustment_pct, adjustment_deadline, picture_placement, sqm_per_box, round_to_full_box, - qi.list_price_sqm, qi.direct_net_price_sqm, + qi.list_price_sqm, qi.direct_net_price_sqm, qi.stock_source, -- V192.5: the ROW's own mode. Aliased because "price_mode" below is already the -- DOCUMENT's (the subselect), and #mapItemColumns needs both to tell them apart. qi.price_mode AS item_price_mode, @@ -1878,7 +1890,7 @@ private Map> findItemsForQuotations(List final_unit_price, amount, raw_unit, description, line_type, qi.special_price_sqm, adjustment_pct, adjustment_deadline, picture_placement, sqm_per_box, round_to_full_box, - qi.list_price_sqm, qi.direct_net_price_sqm, + qi.list_price_sqm, qi.direct_net_price_sqm, qi.stock_source, -- V192.5: the ROW's own mode. Aliased because "price_mode" below is already the -- DOCUMENT's (the subselect), and #mapItemColumns needs both to tell them apart. qi.price_mode AS item_price_mode, @@ -2254,7 +2266,7 @@ private boolean ceoPriceModeChanged(ResultSet rs) throws SQLException { * the bytes are served separately by {@link #findItemPicture}. An item without a picture * keeps the legacy constructor's {@code hasPicture=false} shape exactly. */ private DealQuotationItemDto mapItem(ResultSet rs, Map basisByPriceId) throws SQLException { - DealQuotationItemDto item = mapItemColumns(rs, basisByPriceId); + DealQuotationItemDto item = mapItemColumns(rs, basisByPriceId).withStockSource(rs.getString("stock_source")); String placement = rs.getString("picture_placement"); return placement == null ? item : item.withPicture(rs.getLong("quotation_id"), placement); } 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..94fbf6f63 100644 --- a/backend/src/main/java/th/co/glr/hr/dealquotation/DealQuotationService.java +++ b/backend/src/main/java/th/co/glr/hr/dealquotation/DealQuotationService.java @@ -1394,6 +1394,7 @@ public DealQuotationDto submit(long id, UserPrincipal actor) { // was loaded above may be what the change we just waited for rewrote. quotations.lockTicket(found.ticketId()); DealQuotationDto quotation = requireQuotation(id); + requireNoOtherDirectQuotationAfterConfirm(quotation); // GLA-123 slice S2: submit is now LIVE for BOTH origins — every validation below (item // completeness, payment term, validity date) is origin-agnostic and applies unchanged; // only approve/reject/issue diverge (see #approve/#reject's own origin branch). @@ -1651,6 +1652,7 @@ public DealQuotationDto approve(long id, ApproveRequest request, UserPrincipal a // directly) -- a transaction here never holds a second ticket's lock at the same time, so // this cannot deadlock against either of them. quotations.lockTicket(quotation.ticketId()); + requireNoOtherDirectQuotationAfterConfirm(quotation); // under the lock: confirm-order takes it too LocalDate approvalDate = LocalDate.now(BANGKOK); // V178: a DATE-mode document WITH special pricing names its OWN validity_date (the rep's // ภายในวันที่, verbatim — approval never recomputes it); every other case (DAYS mode, or a @@ -1711,16 +1713,6 @@ public DealQuotationDto approve(long id, ApproveRequest request, UserPrincipal a quotations.supersede(ancestorId); ancestorId = ancestor.parentQuotationId(); } - // #1173 review F1: on a confirmed deal the system-owned deposit policy follows the newly - // approved quotation, so a revision that adds or drops the deposit moves the deal with it. - // A no-op before confirmation (confirmOrderFromDirectQuotation applies the confirmed - // quotation's percent then). Null-tolerant for hand-wired tests, like - // #advanceDealToRecipientStage. PRICING_REQUEST origin never reaches this: R8 allows one - // accepted quotation per deal, and its percent is applied at OrderConfirmationService#confirmOrder. - if (ticketService != null) { - ticketService.followQuotationDepositPolicy(approved.ticketId(), approved.number(), - DealQuotationRenderAdapter.effectiveDepositPercent(approved.depositPercent()), actor); - } tickets.addEventWithDocument(approved.ticketId(), actor.id(), actor.name(), TicketEventKind.QUOTATION_ISSUED, null, null, "อนุมัติใบเสนอราคา " + approved.number(), RelatedDocumentType.QUOTATION, id); String message = "ใบเสนอราคา " + approved.number() + " ได้รับอนุมัติแล้ว"; @@ -2184,6 +2176,12 @@ public PromoteToDealResultDto confirmOrderFromDirectQuotation(long quotationId, tickets.addEventWithDocument(ticketId, actor.id(), actor.name(), TicketEventKind.DEAL_PROMOTED_FROM_QUOTATION, TicketStatus.DRAFT, TicketStatus.QUOTATION_ISSUED, "ยืนยันคำสั่งซื้อจากใบเสนอราคา " + fresh.number(), RelatedDocumentType.QUOTATION, quotationId); + // The agreed order is this quotation: the deal's other open direct quotations can never be submitted or + // approved now (see requireNoOtherDirectQuotationAfterConfirm), so cancel them rather than strand them. + for (String number : quotations.cancelOtherOpenDirectQuotations(ticketId, quotationId)) { + tickets.addEvent(ticketId, actor.id(), actor.name(), TicketEventKind.CANCELLED, null, null, + "ยกเลิกใบเสนอราคา " + number + " อัตโนมัติ เพราะยืนยันคำสั่งซื้อจากใบเสนอราคา " + fresh.number()); + } // The quotation is the agreed order, so it REPLACES whatever preliminary lines the deal // carried. Safe at this point: the deal is still pre-order (status was draft, so no delivery // or stock reservation exists — both key on quotation_issued or later), and the only other @@ -2399,6 +2397,9 @@ public DealQuotationDto createReorder(long id, UserPrincipal actor) { // GLA-123 slice S1, defence in depth — see #createRevision's identical comment. requireReorderAllowedForOrigin(source); requireApprovedForClone(source, "สร้างจากใบเดิมได้จากใบเสนอราคาที่ได้รับอนุมัติแล้วเท่านั้น"); + if (tickets.isOrderConfirmedFromQuotation(source.ticketId(), source.id())) { + throw new ApiException(HttpStatus.CONFLICT, "ใบเสนอราคานี้ยืนยันคำสั่งซื้อแล้ว จึงสร้างจากใบเดิมไม่ได้"); + } // No requireNoOpenRevision here (deliberately) — multiple clones of the same APPROVED // source are allowed, and a clone never contends with an in-progress revision for anything. // Still locks the ticket, same advisory lock #requireNoOpenRevision/#createRevision use, so @@ -2572,6 +2573,21 @@ private DealQuotationDto requireApprovedForClone(DealQuotationDto quotation, Str return quotation; } + /** + * Owner ruling 2026-10-28: once an order is confirmed from a direct quotation, that quotation is the agreed + * order. No OTHER direct quotation on the deal may be submitted or approved (approval would supersede the + * confirmed one, move the payable and flip the deposit policy), and the confirmed one may not be cloned. + */ + private void requireNoOtherDirectQuotationAfterConfirm(DealQuotationDto quotation) { + if (isPricingRequestOrigin(quotation) + || !tickets.hasPromotionFromQuotationEvent(quotation.ticketId()) + || tickets.isOrderConfirmedFromQuotation(quotation.ticketId(), quotation.id())) { + return; + } + throw new ApiException(HttpStatus.CONFLICT, + "ดีลนี้ยืนยันคำสั่งซื้อจากใบเสนอราคาอื่นแล้ว จึงส่ง/อนุมัติใบเสนอราคานี้ไม่ได้"); + } + private static boolean isPricingRequestOrigin(DealQuotationDto quotation) { return "PRICING_REQUEST".equals(quotation.origin()); } @@ -2590,6 +2606,11 @@ private DealQuotationDto requireRevisableStatus(DealQuotationDto quotation) { } return quotation; } + // Owner ruling 2026-10-28: the quotation an order was confirmed from is the agreed order -- not revisable. + if (tickets.isOrderConfirmedFromQuotation(quotation.ticketId(), quotation.id())) { + throw new ApiException(HttpStatus.CONFLICT, + "ใบเสนอราคานี้ยืนยันคำสั่งซื้อแล้ว จึงสร้างฉบับแก้ไขไม่ได้"); + } return requireApprovedForClone(quotation, "สร้างฉบับแก้ไขได้จากใบเสนอราคาที่ได้รับอนุมัติแล้วเท่านั้น"); } @@ -4047,7 +4068,8 @@ private void requireEveryTileItemHasALeadTime(List items) List missingSeqs = new ArrayList<>(); for (DealQuotationItemDto item : items) { boolean tile = item.lineType() == null || WastageCalculator.LINE_TYPE_TILE.equals(item.lineType()); - if (tile && (item.leadTimeMinDays() == null || item.leadTimeMaxDays() == null)) { + // In-Thailand stock lines skip import (owner ruling 2026-10-28); IN_TRANSIT still needs a lead time (2026-10-29). + if (tile && !"IN_THAILAND".equals(item.stockSource()) && (item.leadTimeMinDays() == null || item.leadTimeMaxDays() == null)) { missingSeqs.add(String.valueOf(item.seq())); } } diff --git a/backend/src/main/java/th/co/glr/hr/deposit/DepositNoticeService.java b/backend/src/main/java/th/co/glr/hr/deposit/DepositNoticeService.java index 84afd8e65..050acf73d 100644 --- a/backend/src/main/java/th/co/glr/hr/deposit/DepositNoticeService.java +++ b/backend/src/main/java/th/co/glr/hr/deposit/DepositNoticeService.java @@ -456,6 +456,8 @@ record RemainingInvoiceMoney(BigDecimal netAmount, BigDecimal vatAmount, BigDeci RemainingInvoiceMoney computeRemainingInvoiceMoney(BigDecimal itemsTotal, BigDecimal depositAmount) { BigDecimal netAmount = itemsTotal.subtract(depositAmount).setScale(2, RoundingMode.HALF_UP); + // Owner ruling 2026-10-28 (SK-07): each tax invoice's VAT = round2(its own net x 7%) -- usual Thai practice; + // a satang gap against the deal's payable is normal and outstanding follows the ISSUED documents. BigDecimal vatAmount = netAmount.multiply(VAT_RATE).setScale(2, RoundingMode.HALF_UP); BigDecimal grandTotal = netAmount.add(vatAmount).setScale(2, RoundingMode.HALF_UP); return new RemainingInvoiceMoney(netAmount, vatAmount, grandTotal); diff --git a/backend/src/main/java/th/co/glr/hr/deposit/RemainingInvoiceDto.java b/backend/src/main/java/th/co/glr/hr/deposit/RemainingInvoiceDto.java index 0840bf352..fc8f02c34 100644 --- a/backend/src/main/java/th/co/glr/hr/deposit/RemainingInvoiceDto.java +++ b/backend/src/main/java/th/co/glr/hr/deposit/RemainingInvoiceDto.java @@ -27,5 +27,18 @@ public record RemainingInvoiceDto( String projectName, BigDecimal depositAmount, List notes, - List items -) {} + List items, + // The STORED document totals (owner ruling 2026-10-28, SK-07): the sheet prints these as values, not as + // template formulas, so the printed VAT / total are exactly what the issued document and the ledger hold. + // null = not supplied: the template's own formulas stay (only a caller that never stored a total). + BigDecimal vatAmount, + BigDecimal grandTotal +) { + public RemainingInvoiceDto(String docNumber, LocalDate issueDate, String reference, String depositReference, + String customerName, String customerBranch, String customerAddress, + String customerTaxId, String projectName, BigDecimal depositAmount, + List notes, List items) { + this(docNumber, issueDate, reference, depositReference, customerName, customerBranch, customerAddress, + customerTaxId, projectName, depositAmount, notes, items, null, null); + } +} diff --git a/backend/src/main/java/th/co/glr/hr/deposit/RemainingInvoiceRenderer.java b/backend/src/main/java/th/co/glr/hr/deposit/RemainingInvoiceRenderer.java index 2d37f4667..9dcbf5491 100644 --- a/backend/src/main/java/th/co/glr/hr/deposit/RemainingInvoiceRenderer.java +++ b/backend/src/main/java/th/co/glr/hr/deposit/RemainingInvoiceRenderer.java @@ -234,8 +234,15 @@ public byte[] toXlsx(RemainingInvoiceDto doc) throws Exception { // ── Summary rows auto-calculated by existing formulas ───────────── // I42 = SUM(I11:I41) includes item amounts and -depositAmount → remainder - // I43 = I42 * 0.07 (VAT) - // I44 = I42 + I43 (total payable) + // I43 / I44 (VAT, total payable): the STORED, already-rounded figures as VALUES when supplied + // (owner ruling 2026-10-28, SK-07) -- the template's I42 x 7% formula would print an unrounded + // 5,867.505 / 89,689.005 instead of what the issued document and the ledger hold. + if (doc.vatAmount() != null) { + setNum(sh, 42, 8, doc.vatAmount().doubleValue()); + } + if (doc.grandTotal() != null) { + setNum(sh, 43, 8, doc.grandTotal().doubleValue()); + } wb.setForceFormulaRecalculation(true); wb.write(out); return out.toByteArray(); diff --git a/backend/src/main/java/th/co/glr/hr/deposit/RemainingInvoiceService.java b/backend/src/main/java/th/co/glr/hr/deposit/RemainingInvoiceService.java index 30d7f223e..be0a74999 100644 --- a/backend/src/main/java/th/co/glr/hr/deposit/RemainingInvoiceService.java +++ b/backend/src/main/java/th/co/glr/hr/deposit/RemainingInvoiceService.java @@ -404,7 +404,7 @@ private RemainingInvoiceDto toRenderable(RemainingInvoiceDocumentDto doc) { doc.docNumber() != null ? doc.docNumber() : "(ร่าง)", doc.docDate(), doc.reference(), doc.depositReference(), doc.customerName(), doc.customerBranch(), doc.customerAddress(), doc.customerTaxId(), - doc.projectName(), nz(doc.depositDeduction()), doc.notes(), items); + doc.projectName(), nz(doc.depositDeduction()), doc.notes(), items, doc.vatAmount(), doc.grandTotal()); } private List defaultNotes() { diff --git a/backend/src/main/java/th/co/glr/hr/finance/FinanceDealRepository.java b/backend/src/main/java/th/co/glr/hr/finance/FinanceDealRepository.java index 66d730e32..e54944746 100644 --- a/backend/src/main/java/th/co/glr/hr/finance/FinanceDealRepository.java +++ b/backend/src/main/java/th/co/glr/hr/finance/FinanceDealRepository.java @@ -35,12 +35,13 @@ public Optional findPayableQuotation(long ticketId) { SELECT q.quotation_id, q.number, q.doc_status, q.total_amount, q.currency, q.issued_at, q.accepted_at, q.origin, q.pricing_request_id FROM sales.quotation q - WHERE q.ticket_id = :id AND q.doc_status IN ('ACCEPTED', 'ISSUED', 'SENT') - ORDER BY CASE WHEN q.doc_status = 'ACCEPTED' THEN 0 ELSE 1 END, + WHERE q.ticket_id = :id AND (ANSWERED OR q.doc_status IN ('ISSUED', 'SENT')) + ORDER BY CASE q.doc_status WHEN 'ACCEPTED' THEN 0 WHEN 'APPROVED' THEN 1 ELSE 2 END, CASE q.recipient_type WHEN 'BUYER' THEN 0 WHEN 'OWNER' THEN 1 ELSE 2 END, q.accepted_at DESC NULLS LAST, q.issued_at DESC, q.quotation_id DESC LIMIT 1 - """, Map.of("id", ticketId), (rs, n) -> new QuotationHeader( + """.replace("ANSWERED", th.co.glr.hr.ticket.TicketRepository.ANSWERED_QUOTATION_SQL), + Map.of("id", ticketId), (rs, n) -> new QuotationHeader( rs.getLong("quotation_id"), rs.getString("number"), rs.getString("doc_status"), rs.getBigDecimal("total_amount"), rs.getString("currency"), rs.getTimestamp("issued_at") == null ? null : rs.getTimestamp("issued_at").toInstant(), diff --git a/backend/src/main/java/th/co/glr/hr/ticket/TicketRepository.java b/backend/src/main/java/th/co/glr/hr/ticket/TicketRepository.java index b02fe84bc..9fc9049d0 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 @@ -233,13 +233,8 @@ OR t.sales_stage IN (:importStages) AND t.sales_stage IN (:accountOrderStages)) OR t.payment_status IN (:accountPendingStatuses) OR (""") - .append(paymentDueDateScalarSql("t.ticket_id")) - .append(" < ").append(BANGKOK_TODAY_SQL) - .append(" AND t.payment_status IS DISTINCT FROM 'DEPOSIT_NOTICE_ISSUED' AND ((") - .append(payableAmountSelect("t.ticket_id")) - .append(") - (") - .append(paidAmountSelect("t.ticket_id")) - .append(")) > 0)) "); + .append(paymentOverduePredicate("t.ticket_id")) + .append(")) "); params.addValue("accountOrderStages", ACCOUNT_ORDER_STAGE_SCOPE); params.addValue("accountPendingStatuses", ACCOUNT_PENDING_PAYMENT_STATUSES); } @@ -1694,6 +1689,32 @@ public BigDecimal payableAmount(long ticketId) { return value == null ? BigDecimal.ZERO : value; } + /** + * QA-03, ONE rule for "the answered quotation" (payableAmountSelect, paymentTermsRowSql and + * {@code FinanceDealRepository#findPayableQuotation} all use it): ACCEPTED, or an APPROVED DEAL_DIRECT quotation + * that a DEAL_PROMOTED_FROM_QUOTATION event names -- confirm-order leaves a DEAL_DIRECT quotation APPROVED, and + * the event (related QUOTATION = q.quotation_id) is the per-quotation fact that its order was confirmed. An + * APPROVED, unconfirmed one is not payable. Table alias {@code q} must be sales.quotation. + */ + public static final String ANSWERED_QUOTATION_SQL = + "(q.doc_status = 'ACCEPTED' OR (q.doc_status = 'APPROVED' AND q.origin = 'DEAL_DIRECT'" + + " AND EXISTS (SELECT 1 FROM sales.ticket_event pe WHERE pe.ticket_id = q.ticket_id" + + " AND pe.kind = '" + TicketEventKind.DEAL_PROMOTED_FROM_QUOTATION + "'" + + " AND pe.related_document_type = 'QUOTATION' AND pe.related_document_id = q.quotation_id)))"; + + /** + * SK-07 (owner ruling 2026-10-28): once a remaining invoice is ISSUED, what the customer owes follows the ISSUED + * documents -- the deposit notice's total_payable plus the remaining invoice's grand_total -- because each tax + * invoice rounds its own VAT, so the sum may sit a satang away from the deal's computed total. Trailing comma + * included; VAT-inclusive variant only (the ex-VAT figure stays the quotation's subtotal). + */ + private static String issuedDocumentsPayable(String ticketIdRef) { + return "(SELECT COALESCE(dn.total_payable, 0) + ri.grand_total FROM sales.remaining_invoice ri" + + " LEFT JOIN sales.deposit_notice dn ON dn.deposit_notice_id = ri.deposit_notice_id" + + " WHERE ri.ticket_id = " + ticketIdRef + " AND ri.status = 'ISSUED' AND ri.grand_total IS NOT NULL" + + " ORDER BY ri.issued_at DESC, ri.id DESC LIMIT 1),"; + } + /** * Full "amount payable" SELECT statement, correlated on {@code ticketIdRef} (either a bind * parameter like {@code ":ticketId"} for a standalone query, or an outer-query column @@ -1736,10 +1757,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 +1793,8 @@ SELECT COALESCE( 0 ) """.formatted(ticketIdRef) + .replace("ISSUED_DOCUMENTS", inclVat ? issuedDocumentsPayable(ticketIdRef) : "") + .replace("ANSWERED", ANSWERED_QUOTATION_SQL) .replace("QUOTATION_AMOUNT", quotationAmount) .replace("ITEMS_AMOUNT", itemsAmount); } @@ -1811,8 +1836,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 +1852,7 @@ CROSS JOIN (SELECT (MAX(dr.delivered_at) AT TIME ZONE 'Asia/Bangkok')::date AS d WHERE dr.ticket_id = %1$s AND (SELECT t2.fulfillment_status FROM sales.ticket t2 WHERE t2.ticket_id = %1$s) = 'FULLY_DELIVERED') dv - """.formatted(ticketIdRef); + """.formatted(ticketIdRef).replace("ANSWERED", ANSWERED_QUOTATION_SQL); } /** The derived due DATE as a scalar subquery (NULL when there is none) -- the list-scope predicate's twin. */ @@ -1835,6 +1860,18 @@ private static String paymentDueDateScalarSql(String ticketIdRef) { return "(SELECT x.delivered_on + x.days FROM (" + paymentTermsRowSql(ticketIdRef) + ") x)"; } + /** + * The ONE overdue rule, as a boolean SQL fragment: derived due date is past (Bangkok), the deal is not still + * on the deposit notice, and payable minus paid is positive. The account list scope and the dashboard's + * {@code payment_overdue} count both use it, so they cannot disagree. + */ + public static String paymentOverduePredicate(String ticketIdRef) { + return "(" + paymentDueDateScalarSql(ticketIdRef) + " < " + BANGKOK_TODAY_SQL + + " AND (SELECT tp.payment_status FROM sales.ticket tp WHERE tp.ticket_id = " + ticketIdRef + + ") IS DISTINCT FROM 'DEPOSIT_NOTICE_ISSUED' AND ((" + payableAmountSelect(ticketIdRef) + + ") - (" + paidAmountSelect(ticketIdRef) + ")) > 0)"; + } + /** When the balance is due and on what basis; see {@link #paymentTermsRowSql}. */ public record PaymentDue(LocalDate date /* null until delivered */, String basis, Integer creditDays) {} @@ -2246,6 +2283,17 @@ public int markPromotedToPipeline(long ticketId) { """, Map.of("id", ticketId)); } + /** The DEAL_PROMOTED_FROM_QUOTATION event names the quotation whose confirm-order made the deal an order. */ + public boolean isOrderConfirmedFromQuotation(long ticketId, long quotationId) { + Boolean value = jdbc.queryForObject(""" + SELECT EXISTS(SELECT 1 FROM sales.ticket_event + WHERE ticket_id = :ticketId AND kind = :kind + AND related_document_type = 'QUOTATION' AND related_document_id = :quotationId) + """, Map.of("ticketId", ticketId, "kind", TicketEventKind.DEAL_PROMOTED_FROM_QUOTATION, + "quotationId", quotationId), Boolean.class); + return Boolean.TRUE.equals(value); + } + /** * GLA-136 — has this deal's order already been confirmed from a direct quotation * ({@link TicketEventKind#DEAL_PROMOTED_FROM_QUOTATION}, written exactly once per confirmation by diff --git a/backend/src/main/java/th/co/glr/hr/ticket/TicketService.java b/backend/src/main/java/th/co/glr/hr/ticket/TicketService.java index b134178f6..877e67804 100644 --- a/backend/src/main/java/th/co/glr/hr/ticket/TicketService.java +++ b/backend/src/main/java/th/co/glr/hr/ticket/TicketService.java @@ -951,9 +951,8 @@ private TicketDto applyCustomerConfirmation(long ticketId, TicketSummaryDto s, S /** * Owner ruling 2026-10-22, widened after the #1173 review: while the payment track is at * CUSTOMER_CONFIRMED, a deposit policy the SYSTEM owns follows the deal's quotation. ไม่รับมัดจำ - * (0%) means NOT_REQUIRED; any deposit means REQUIRED. Called at the first confirmation and - * when a DEAL_DIRECT quotation is approved on a confirmed deal ({@code DealQuotationService - * #approve}), so a revision that adds or drops the deposit moves the deal with it. + * (0%) means NOT_REQUIRED; any deposit means REQUIRED. Called at the first confirmation only (the + * confirmed direct quotation can no longer be revised or replaced, owner ruling 2026-10-28). * *

A policy a person chose ({@link #waiveDeposit}: WAIVED, CREDIT_CUSTOMER, or a typed * NOT_REQUIRED) is never touched, and nothing moves once a deposit notice is out. Both are @@ -1029,8 +1028,10 @@ public TicketDto confirmDepositPaid(long ticketId, UserPrincipal actor) { throw new ApiException(HttpStatus.CONFLICT, "ต้องออกใบแจ้งรับมัดจำก่อนจึงจะยืนยันรับชำระมัดจำได้"); } var notice = tickets.latestIssuedDepositNotice(ticketId).orElse(null); - BigDecimal amount = notice != null && notice.depositAmount() != null - ? notice.depositAmount() + // QA-01: the deal payable is VAT-inclusive, so the receipt is the notice's total_payable + // (deposit + VAT), not the pre-VAT deposit_amount. + BigDecimal amount = notice != null && notice.totalPayable() != null + ? notice.totalPayable() : payableAmount(ticket).multiply(new BigDecimal("0.50")); if (amount.signum() <= 0) { throw new ApiException(HttpStatus.CONFLICT, @@ -2126,6 +2127,13 @@ private void requireStageMoveAllowed(TicketSummaryDto s, String targetStage, Use if (targetStage.equals(s.salesStage())) { throw new ApiException(HttpStatus.CONFLICT, "ดีลนี้อยู่ในขั้นตอน " + targetStage + " อยู่แล้ว"); } + // QA-05 (owner ruling 2026-10-28): once the order is confirmed (the deal is at or past ขั้น 10) a manual + // move may not rewind below ORDER_RECEIVED. Keyed on the CURRENT stage only: no repository call here. + if (DealStage.indexOf(s.salesStage()) >= DealStage.indexOf(DealStage.ORDER_RECEIVED) + && DealStage.indexOf(targetStage) < DealStage.indexOf(DealStage.ORDER_RECEIVED)) { + throw new ApiException(HttpStatus.CONFLICT, + "ย้อนสถานะกลับก่อนขั้นรับคำสั่งซื้อไม่ได้ — ยืนยันคำสั่งซื้อไปแล้ว"); + } // Rung 6 of the precedence ladder (design §12.1): the deal's ENTRY-CHANNEL route. Checked // BEFORE the facts because an off-route target's actionable remedy is the channel, not the // fact. Identical outcome for every reachable input: only S4–S8 can be off-route diff --git a/backend/src/test/java/th/co/glr/hr/commission/CommissionAutoCreateIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/commission/CommissionAutoCreateIntegrationTest.java index d3a9f196f..b70fad149 100644 --- a/backend/src/test/java/th/co/glr/hr/commission/CommissionAutoCreateIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/commission/CommissionAutoCreateIntegrationTest.java @@ -317,7 +317,7 @@ void salesCannotApprove_evenAManagerApprovedCommission() { @Test void createFromDeal_omittedGrossAmount_defaultsFromDealPayable() { long ticketId = driveDealToClosedPaid(new BigDecimal("10")); - BigDecimal payable = tickets.payableAmountExVat(ticketId); + BigDecimal payable = tickets.payableAmount(ticketId); // QA-02: VAT-inclusive (the calculator divides by 1.07 itself) assertThat(payable.signum()).isPositive(); CommissionRecord created = commissionService.createFromDeal( @@ -331,13 +331,12 @@ void createFromDeal_omittedGrossAmount_defaultsFromDealPayable() { } /** - * OWNER RULING 2026-09-30: the deal's PAYABLE is now the VAT-inclusive grand total, but commission is - * computed on the PRE-VAT figure it always got. This pins the default gross amount: it equals the ex-VAT - * quotation total, and is strictly LESS than the (VAT-inclusive) payable the customer is billed. - * Mutation check: point CommissionService at tickets.payableAmount(...) and this goes red. + * QA-02 (owner ruling 2026-10-28; supersedes the 2026-09-30 pre-VAT default): the commission workbooks take + * the amount received INCLUDING VAT and CommissionCalculator.calculateInvoice divides by 1.07, so the + * default gross is the VAT-inclusive payable, strictly MORE than the ex-VAT quotation total. */ @Test - void createFromDeal_omittedGrossAmount_staysPreVat_whilePayableIsVatInclusive() { + void createFromDeal_omittedGrossAmount_isVatInclusive_equalToPayable() { long ticketId = driveDealToClosedPaid(new BigDecimal("10")); BigDecimal exVat = tickets.payableAmountExVat(ticketId); BigDecimal inclVat = tickets.payableAmount(ticketId); @@ -348,16 +347,15 @@ void createFromDeal_omittedGrossAmount_staysPreVat_whilePayableIsVatInclusive() invoiceFile(), accountActor); assertThat(inclVat).isGreaterThan(exVat); - assertThat(inclVat).isEqualByComparingTo(exVat.multiply(new BigDecimal("1.07")).setScale(2, java.math.RoundingMode.HALF_UP)); - assertThat(created.invoiceDetails().grossAmount()).isEqualByComparingTo(exVat); - assertThat(created.invoiceDetails().grossAmount()).isNotEqualByComparingTo(inclVat); + assertThat(created.invoiceDetails().grossAmount()).isEqualByComparingTo(inclVat); + assertThat(created.invoiceDetails().grossAmount()).isNotEqualByComparingTo(exVat); assertThat(created.dealAmountMismatch()).isFalse(); } @Test void createFromDeal_providedGrossAmount_isUsedAsGiven_insteadOfDealPayable() { long ticketId = driveDealToClosedPaid(new BigDecimal("10")); - BigDecimal payable = tickets.payableAmountExVat(ticketId); + BigDecimal payable = tickets.payableAmount(ticketId); BigDecimal override = payable.add(new BigDecimal("50000.00")); // deliberately different + beyond threshold CommissionRecord created = commissionService.createFromDeal( diff --git a/backend/src/test/java/th/co/glr/hr/commission/CommissionDealLinkageIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/commission/CommissionDealLinkageIntegrationTest.java index 82de6f2ed..d81b6cd30 100644 --- a/backend/src/test/java/th/co/glr/hr/commission/CommissionDealLinkageIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/commission/CommissionDealLinkageIntegrationTest.java @@ -249,7 +249,7 @@ void submit_rejectsWhenLinkedDealHasNotReachedClosedPaid_andCreatesNoRecord() { @Test void submit_withGrossAmountWithinThreshold_succeedsWithNoMismatchFlag() { long ticketId = driveDealToClosedPaid(new BigDecimal("10")); - BigDecimal payable = tickets.payableAmountExVat(ticketId); + BigDecimal payable = tickets.payableAmount(ticketId); // QA-02: VAT-inclusive assertThat(payable.signum()).isPositive(); // 2% above payable — within the 5% cross-check threshold. BigDecimal grossAmount = payable.multiply(new BigDecimal("1.02")).setScale(2, java.math.RoundingMode.HALF_UP); @@ -266,6 +266,21 @@ void submit_withGrossAmountWithinThreshold_succeedsWithNoMismatchFlag() { assertThat(reloaded.dealPayableAmountSnapshot()).isEqualByComparingTo(payable); } + /** QA-02: a gross 7% below the VAT-inclusive payable (i.e. the ex-VAT figure) IS a mismatch; equal is not. */ + @Test + void submit_withGrossEqualToVatInclusivePayable_isNoMismatch_andExVatGrossIsFlagged() { + long ticketId = driveDealToClosedPaid(new BigDecimal("10")); + BigDecimal payable = tickets.payableAmount(ticketId); + CommissionRecord equal = commissionService.submit(submitRequestLinkedTo(ticketId, payable), invoiceFile(), accountActor); + assertThat(equal.dealAmountMismatch()).isFalse(); + assertThat(equal.dealPayableAmountSnapshot()).isEqualByComparingTo(payable); + + long other = driveDealToClosedPaid(new BigDecimal("10")); + BigDecimal below = tickets.payableAmount(other).multiply(new BigDecimal("0.93")).setScale(2, java.math.RoundingMode.HALF_UP); + assertThat(commissionService.submit(submitRequestLinkedTo(other, below), invoiceFile(), accountActor) + .dealAmountMismatch()).isTrue(); + } + // ───────────────────────────────────────────────────────────────────────────────────── // (4) Cross-check beyond threshold: NOT blocked, but flagged for reviewers. // ───────────────────────────────────────────────────────────────────────────────────── @@ -273,7 +288,7 @@ void submit_withGrossAmountWithinThreshold_succeedsWithNoMismatchFlag() { @Test void submit_withGrossAmountBeyondThreshold_succeedsButFlagsMismatch() { long ticketId = driveDealToClosedPaid(new BigDecimal("10")); - BigDecimal payable = tickets.payableAmountExVat(ticketId); + BigDecimal payable = tickets.payableAmount(ticketId); // QA-02: VAT-inclusive assertThat(payable.signum()).isPositive(); // 25% above payable — well beyond the 5% threshold. BigDecimal grossAmount = payable.multiply(new BigDecimal("1.25")).setScale(2, java.math.RoundingMode.HALF_UP); diff --git a/backend/src/test/java/th/co/glr/hr/commission/CommissionServiceTest.java b/backend/src/test/java/th/co/glr/hr/commission/CommissionServiceTest.java index 193e5b25d..70468595d 100644 --- a/backend/src/test/java/th/co/glr/hr/commission/CommissionServiceTest.java +++ b/backend/src/test/java/th/co/glr/hr/commission/CommissionServiceTest.java @@ -166,7 +166,7 @@ void submitWithLinkedTicketNotClosedPaid_rejectsWithUnprocessableEntity() { void submitWithLinkedClosedPaidTicket_withinThreshold_succeedsAndRecordsNoMismatch() { SubmitCommissionRequest request = submitRequestLinkedTo(42L, new BigDecimal("1000.00")); when(tickets.findSalesStage(42L)).thenReturn(Optional.of(DealStage.CLOSED_PAID)); - when(tickets.payableAmountExVat(42L)).thenReturn(new BigDecimal("1030.00")); // 2.9% off — within 5% + when(tickets.payableAmount(42L)).thenReturn(new BigDecimal("1030.00")); // 2.9% off — within 5% InvoiceCalculation calculation = new InvoiceCalculation(new BigDecimal("1000.00"), new BigDecimal("1000.00")); when(calculator.calculateInvoice(any(), any(), any(), any(), any(), any(), any(), any())).thenReturn(calculation); when(commissions.createInvoice(any(SubmitCommissionRequest.class))).thenReturn(500L); @@ -192,7 +192,7 @@ void submitWithLinkedClosedPaidTicket_withinThreshold_succeedsAndRecordsNoMismat void submitWithLinkedClosedPaidTicket_beyondThreshold_stillSucceedsButFlagsMismatch() { SubmitCommissionRequest request = submitRequestLinkedTo(42L, new BigDecimal("1000.00")); when(tickets.findSalesStage(42L)).thenReturn(Optional.of(DealStage.CLOSED_PAID)); - when(tickets.payableAmountExVat(42L)).thenReturn(new BigDecimal("1200.00")); // 16.7% off — beyond 5% + when(tickets.payableAmount(42L)).thenReturn(new BigDecimal("1200.00")); // 16.7% off — beyond 5% InvoiceCalculation calculation = new InvoiceCalculation(new BigDecimal("1000.00"), new BigDecimal("1000.00")); when(calculator.calculateInvoice(any(), any(), any(), any(), any(), any(), any(), any())).thenReturn(calculation); when(commissions.createInvoice(any(SubmitCommissionRequest.class))).thenReturn(500L); @@ -304,7 +304,7 @@ void createFromDeal_resolvesSalesRepFromTicketOwner_neverFromActor() { when(tickets.findById(ticketId)).thenReturn(Optional.of(ticketWithOwner(ticketId, ownerId))); when(commissions.hasActiveCommissionForTicket(ticketId)).thenReturn(false); when(tickets.findSalesStage(ticketId)).thenReturn(Optional.of(DealStage.CLOSED_PAID)); - when(tickets.payableAmountExVat(ticketId)).thenReturn(new BigDecimal("1000.00")); + when(tickets.payableAmount(ticketId)).thenReturn(new BigDecimal("1000.00")); InvoiceCalculation calculation = new InvoiceCalculation(new BigDecimal("1000.00"), new BigDecimal("1000.00")); when(calculator.calculateInvoice(any(), any(), any(), any(), any(), any(), any(), any())).thenReturn(calculation); when(commissions.createInvoice(any(SubmitCommissionRequest.class))).thenReturn(500L); @@ -335,7 +335,7 @@ void createFromDeal_defaultsGrossAmountFromDealPayable_whenOmitted() { when(tickets.findById(ticketId)).thenReturn(Optional.of(ticketWithOwner(ticketId, 30L))); when(commissions.hasActiveCommissionForTicket(ticketId)).thenReturn(false); when(tickets.findSalesStage(ticketId)).thenReturn(Optional.of(DealStage.CLOSED_PAID)); - when(tickets.payableAmountExVat(ticketId)).thenReturn(new BigDecimal("2500.00")); + when(tickets.payableAmount(ticketId)).thenReturn(new BigDecimal("2500.00")); InvoiceCalculation calculation = new InvoiceCalculation(new BigDecimal("2500.00"), new BigDecimal("2500.00")); when(calculator.calculateInvoice(eq(new BigDecimal("2500.00")), any(), any(), any(), any(), any(), any(), any())) .thenReturn(calculation); @@ -363,7 +363,7 @@ void createFromDeal_usesProvidedGrossAmount_whenGiven_insteadOfDealPayable() { when(commissions.hasActiveCommissionForTicket(ticketId)).thenReturn(false); when(tickets.findSalesStage(ticketId)).thenReturn(Optional.of(DealStage.CLOSED_PAID)); // Deliberately different from the payable amount, to prove the override wins. - when(tickets.payableAmountExVat(ticketId)).thenReturn(new BigDecimal("2500.00")); + when(tickets.payableAmount(ticketId)).thenReturn(new BigDecimal("2500.00")); InvoiceCalculation calculation = new InvoiceCalculation(new BigDecimal("9999.00"), new BigDecimal("9999.00")); when(calculator.calculateInvoice(eq(new BigDecimal("9999.00")), any(), any(), any(), any(), any(), any(), any())) .thenReturn(calculation); @@ -423,7 +423,7 @@ void createFromDeal_shiftsPayrollMonthToTheMonthAfterInvoiceDate_flag10() { when(tickets.findById(ticketId)).thenReturn(Optional.of(ticketWithOwner(ticketId, 30L))); when(commissions.hasActiveCommissionForTicket(ticketId)).thenReturn(false); when(tickets.findSalesStage(ticketId)).thenReturn(Optional.of(DealStage.CLOSED_PAID)); - when(tickets.payableAmountExVat(ticketId)).thenReturn(new BigDecimal("1000.00")); + when(tickets.payableAmount(ticketId)).thenReturn(new BigDecimal("1000.00")); InvoiceCalculation calculation = new InvoiceCalculation(new BigDecimal("1000.00"), new BigDecimal("1000.00")); when(calculator.calculateInvoice(any(), any(), any(), any(), any(), any(), any(), any())).thenReturn(calculation); when(commissions.createInvoice(any(SubmitCommissionRequest.class))).thenReturn(500L); diff --git a/backend/src/test/java/th/co/glr/hr/dashboard/DashboardPaymentOverdueMatchesFinanceIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/dashboard/DashboardPaymentOverdueMatchesFinanceIntegrationTest.java new file mode 100644 index 000000000..df1b8acbb --- /dev/null +++ b/backend/src/test/java/th/co/glr/hr/dashboard/DashboardPaymentOverdueMatchesFinanceIntegrationTest.java @@ -0,0 +1,168 @@ +package th.co.glr.hr.dashboard; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.math.BigDecimal; +import java.sql.Timestamp; +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import th.co.glr.hr.support.AbstractPostgresIntegrationTest; +import th.co.glr.hr.ticket.CreateTicketRequest; +import th.co.glr.hr.ticket.TicketRepository; + +/** + * The dashboard's {@code paymentOverdue} counts exactly the deals the finance side marks overdue + * ({@code TicketRepository}'s summary): the same VAT-inclusive payable (owner rulings 2026-09-30 / 2026-10-28), + * the same due date (the payable quotation's terms + delivery, never the stored {@code due_date}), and no + * overdue while the deposit is unpaid. Each test asserts the absolute count AND parity with finance. + */ +class DashboardPaymentOverdueMatchesFinanceIntegrationTest extends AbstractPostgresIntegrationTest { + private static final ZoneId BKK = ZoneId.of("Asia/Bangkok"); + + private DashboardRepository dashboard; + private TicketRepository tickets; + private long repId; + + @BeforeEach + void seed() { + dashboard = new DashboardRepository(jdbc); + tickets = new TicketRepository(jdbc); + long divisionId = jdbc.queryForObject(""" + INSERT INTO hr.division (source_code, name_th) VALUES ('SA-OD', 'Sales OD') RETURNING division_id + """, Map.of(), Number.class).longValue(); + repId = jdbc.queryForObject(""" + INSERT INTO hr.employee (employee_code, first_name_th, division_id, is_active) + VALUES ('EMP-OD', 'EMP-OD', :divisionId, TRUE) RETURNING employee_id + """, Map.of("divisionId", divisionId), Number.class).longValue(); + } + + @Test + void ownerDirectDeal_paidThePreVatAmount_isStillOverdue_forTheVat() { + long id = creditDealDelivered31DaysAgo("ACCEPTED", "CUSTOMER_CONFIRMED"); + pay(id, "100000.00"); // pre-VAT subtotal; payable is 107,000.00 + + assertOverdueCount(1, id); + } + + @Test + void ownerDirectDeal_paidTheVatInclusiveAmount_isNotOverdue() { + long id = creditDealDelivered31DaysAgo("ACCEPTED", "CUSTOMER_CONFIRMED"); + pay(id, "107000.00"); + + assertOverdueCount(0, id); + } + + @Test + void confirmedDealDirectQuotation_stillApproved_isPayable_andOverdue() { + long id = creditDealDelivered31DaysAgo("APPROVED", "CUSTOMER_CONFIRMED"); // confirm-order leaves it APPROVED + jdbc.update("UPDATE sales.quotation SET origin = 'DEAL_DIRECT' WHERE ticket_id = :id", Map.of("id", id)); + long quotationId = jdbc.queryForObject("SELECT quotation_id FROM sales.quotation WHERE ticket_id = :id", + Map.of("id", id), Long.class); + tickets.addEventWithDocument(id, repId, "ทดสอบ", "DEAL_PROMOTED_FROM_QUOTATION", null, null, + "ยืนยันคำสั่งซื้อ", "QUOTATION", quotationId); // the per-quotation fact the payable rule keys on + + assertOverdueCount(1, id); + } + + @Test + void issuedRemainingInvoice_payableFollowsTheIssuedDocuments_aSatangShortIsOverdue() { + long id = creditDealDelivered31DaysAgo("ACCEPTED", "CUSTOMER_CONFIRMED"); + // per-document VAT rounding: 32,100.00 + 74,900.01 = 107,000.01, one satang above the quotation's 107,000.00 + long notice = jdbc.queryForObject(""" + INSERT INTO sales.deposit_notice (ticket_id, version, status, customer_name, deposit_percent, + subtotal, deposit_amount, vat_percent, vat_amount, total_payable) + VALUES (:id, 1, 'ISSUED', 'x', 0.3, 100000.00, 30000.00, 0.07, 2100.00, 32100.00) + RETURNING deposit_notice_id""", Map.of("id", id), Number.class).longValue(); + jdbc.update(""" + INSERT INTO sales.remaining_invoice (ticket_id, deposit_notice_id, status, base_number, doc_number, + issued_at, grand_total) + VALUES (:id, :notice, 'ISSUED', 'GLR6999999', 'GLR6999999-1', now(), 74900.01)""", + new MapSqlParameterSource("id", id).addValue("notice", notice)); + pay(id, "107000.00"); + + assertOverdueCount(1, id); + } + + @Test + void storedDueDateInThePast_withoutQuotationTerms_isNotOverdue() { + long id = createDeal(); + insertQuotation(id, "ACCEPTED"); // no remainder_mode / credit_days: no derived due date + setStoredDueDate(id, today().minusDays(10)); + + assertOverdueCount(0, id); + } + + @Test + void unpaidDeposit_isNeverOverdue_evenLongAfterDelivery() { + long id = creditDealDelivered31DaysAgo("ACCEPTED", "DEPOSIT_NOTICE_ISSUED"); + + assertOverdueCount(0, id); + } + + // ── helpers ───────────────────────────────────────────────────────────────────────────── + + /** Quotation subtotal 100,000 (TH, 107,000 incl. VAT), CREDIT 30 days, fully delivered 31 days ago. */ + private long creditDealDelivered31DaysAgo(String quotationStatus, String paymentStatus) { + long id = createDeal(); + insertQuotation(id, quotationStatus); + jdbc.update("UPDATE sales.quotation SET remainder_mode = 'CREDIT', credit_days = 30 WHERE ticket_id = :id", + Map.of("id", id)); + tickets.updatePaymentStatusUnchecked(id, paymentStatus); + jdbc.update("UPDATE sales.ticket SET fulfillment_status = 'FULLY_DELIVERED' WHERE ticket_id = :id", + Map.of("id", id)); + jdbc.update(""" + INSERT INTO sales.delivery_record (ticket_id, source, delivered_at, delivered_by) + VALUES (:id, 'WAREHOUSE', :at, :by)""", new MapSqlParameterSource("id", id) + .addValue("at", Timestamp.from(today().minusDays(31).atTime(12, 0).atZone(BKK).toInstant())) + .addValue("by", repId)); + // the stored billing due_date is past too, so the pre-fix query is only wrong about the AMOUNT + setStoredDueDate(id, today().minusDays(1)); + return id; + } + + private long createDeal() { + long id = tickets.create( + new CreateTicketRequest("ดีลทดสอบ", "NORMAL", "ลูกค้าทดสอบ", null, null, null, null, null, List.of()), + tickets.nextTicketCode(), repId, "EMP-OD"); + tickets.updateSalesStage(id, "PROCUREMENT"); + return id; + } + + private void insertQuotation(long ticketId, String status) { + long q = tickets.createQuotation(ticketId, "QT-OD-" + ticketId, repId, new BigDecimal("100000.00")).id(); + jdbc.update("UPDATE sales.quotation SET doc_status = :s WHERE quotation_id = :q", + new MapSqlParameterSource("s", status).addValue("q", q)); + } + + private void setStoredDueDate(long ticketId, LocalDate dueDate) { + jdbc.update("UPDATE sales.ticket SET due_date = :d WHERE ticket_id = :id", + new MapSqlParameterSource("id", ticketId).addValue("d", dueDate)); + } + + private void pay(long ticketId, String amount) { + jdbc.update(""" + INSERT INTO sales.payment_receipt (ticket_id, kind, amount, currency, received_at, recorded_by, receipt_ref) + VALUES (:t, 'BALANCE', :a, 'THB', now(), :by, :ref)""", new MapSqlParameterSource() + .addValue("t", ticketId).addValue("a", new BigDecimal(amount)) + .addValue("by", repId).addValue("ref", "OD-" + ticketId)); + } + + private void assertOverdueCount(long expected, long ticketId) { + long dashboardCount = dashboard.tickets(DashboardQueryScope.self(repId), today().withDayOfMonth(1), + OffsetDateTime.now(BKK)).paymentOverdue(); + boolean financeOverdue = tickets.findSummaryById(ticketId).orElseThrow().overdue(); + + assertThat(financeOverdue).as("finance summary overdue").isEqualTo(expected == 1); + assertThat(dashboardCount).as("dashboard paymentOverdue").isEqualTo(expected); + } + + private static LocalDate today() { + return LocalDate.now(BKK); + } +} diff --git a/backend/src/test/java/th/co/glr/hr/dashboard/DashboardRepositoryIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/dashboard/DashboardRepositoryIntegrationTest.java index 5795d7e43..5baa20e40 100644 --- a/backend/src/test/java/th/co/glr/hr/dashboard/DashboardRepositoryIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/dashboard/DashboardRepositoryIntegrationTest.java @@ -47,6 +47,7 @@ void aggregatesDashboardSectionsWithScopes() { insertTicket("PR-3", "closed", salesEmployee, today.minusDays(1)); markOperationalBuckets("PR-1", "ON_HOLD", "PARTIALLY_DELIVERED", today.minusDays(2), BigDecimal.valueOf(100)); markOperationalBuckets("PR-2", "DORMANT", null, null, null); + insertOverdueCreditDeal("PR-2", salesEmployee); insertCommission(salesEmployee, monthStart); insertNotification(salesEmployee, false); insertNotification(salesEmployee, true); @@ -261,6 +262,22 @@ private void markOperationalBuckets(String code, String lifecycle, String fulfil } } + /** Finance's overdue rule: ACCEPTED quotation, CREDIT 30 days, fully delivered 31 days ago, nothing paid. */ + private void insertOverdueCreditDeal(String code, long deliveredBy) { + jdbc.update(""" + INSERT INTO sales.quotation (ticket_id, number, issued_by, total_amount, doc_status, + remainder_mode, credit_days) + SELECT ticket_id, 'QT-' || code, :by, 100000, 'ACCEPTED', 'CREDIT', 30 + FROM sales.ticket WHERE code = :code + """, Map.of("code", code, "by", deliveredBy)); + jdbc.update("UPDATE sales.ticket SET fulfillment_status = 'FULLY_DELIVERED' WHERE code = :code", + Map.of("code", code)); + jdbc.update(""" + INSERT INTO sales.delivery_record (ticket_id, source, delivered_at, delivered_by) + SELECT ticket_id, 'WAREHOUSE', now() - interval '31 days', :by FROM sales.ticket WHERE code = :code + """, Map.of("code", code, "by", deliveredBy)); + } + private void insertCommission(long salesRepId, LocalDate payrollMonth) { // V102 (chk_invoice_details_evidence_present) requires every row to show a real // attachment OR a recognized evidence_provenance -- this fixture has neither an diff --git a/backend/src/test/java/th/co/glr/hr/dealquotation/DealQuotationConfirmOrderIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/dealquotation/DealQuotationConfirmOrderIntegrationTest.java index 9a5c6e4a6..da43c409e 100644 --- a/backend/src/test/java/th/co/glr/hr/dealquotation/DealQuotationConfirmOrderIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/dealquotation/DealQuotationConfirmOrderIntegrationTest.java @@ -407,100 +407,112 @@ void zeroDepositDirectQuotation_confirmsOntoTheNoDepositPath_andFinalPaymentIsNo } /** - * PR #1173 review F1: a confirmed 0% deal whose approved revision adds a deposit used to be - * stranded (NOT_REQUIRED kept, the draft 409'd, waiveDeposit refuses REQUIRED). While the - * payment track is at CUSTOMER_CONFIRMED a policy the system set follows the newly approved - * quotation. + * Owner ruling 2026-10-28: once the order is confirmed from a direct quotation, THAT quotation can no longer + * be revised (a revision's approval would supersede the agreed order and move the payable). Replaces the five + * #1173 tests that revised a confirmed quotation (their policy-sync scenarios are no longer reachable). */ @Test - void zeroDepositConfirmed_thenARevisionAddsADeposit_policyFollowsBackToRequired_andTheDraftBillsIt() { + void confirmedDirectQuotation_cannotBeRevised_409_andNoRowIsWritten() { long deal = pipelineTicket(salesActor); - DealQuotationDto zero = approvedQuotation(deal, zeroDepositDirectDraft()); - confirmer.confirmOrderFromDirectQuotation(zero.id(), salesActor); - assertThat(summary(deal).depositPolicy()).isEqualTo("NOT_REQUIRED"); + DealQuotationDto fifty = approvedDirectQuotation(deal, salesActor); + confirmer.confirmOrderFromDirectQuotation(fifty.id(), salesActor); + int rowsBefore = quotationRows(deal); - DealQuotationDto revision = approvedRevision(zero, directDraft()); + assertConflict(() -> quotationService.createRevision(fifty.id(), salesActor), "ยืนยันคำสั่งซื้อแล้ว"); - TicketSummaryDto after = summary(deal); - assertThat(after.depositPolicy()).isEqualTo("REQUIRED"); - assertThat(after.depositPolicyReason()).isEqualTo("ใบเสนอราคา " + revision.number() + " ระบุมัดจำ 50%"); - assertThat(after.paymentStatus()).isEqualTo("CUSTOMER_CONFIRMED"); - assertThat(policyChangedMessages(deal)).hasSize(2) - .last().asString().contains("REQUIRED").contains(revision.number()); - DepositNoticeDto draft = depositNoticeService.createDraft(deal, - new DepositNoticeDraftRequest(null, null, null, null, null, null, null, null), salesActor); - assertThat(draft.depositPercent()).isEqualByComparingTo("0.50"); + assertThat(quotationRows(deal)).isEqualTo(rowsBefore); + assertThat(quotationService.get(fifty.id(), salesActor).docStatus()).isEqualTo(QuotationStatus.APPROVED); } - /** Owner ruling 2026-10-23: a blank new-engine percent means 30 (DealQuotationRenderAdapter's PDF - * default), so a 0% deal whose approved revision leaves the percent blank is back on the deposit path. */ + /** + * Round 3: the two leaks around the revise gate. After the order is confirmed from v1, no OTHER direct + * quotation on the deal may be created from it (clone), submitted, or approved -- approving one would supersede + * the confirmed v1, move the payable and flip the deposit policy. v1 stays APPROVED, nothing is written. + */ @Test - void zeroDepositConfirmed_thenARevisionWithABlankPercent_countsAsThirty_andIsRequired() { + void afterConfirm_aCloneOfTheConfirmedQuotation_isRefused() { long deal = pipelineTicket(salesActor); - DealQuotationDto zero = approvedQuotation(deal, zeroDepositDirectDraft()); - confirmer.confirmOrderFromDirectQuotation(zero.id(), salesActor); - assertThat(summary(deal).depositPolicy()).isEqualTo("NOT_REQUIRED"); + DealQuotationDto v1 = approvedDirectQuotation(deal, salesActor); + confirmer.confirmOrderFromDirectQuotation(v1.id(), salesActor); + int rows = quotationRows(deal); - DealQuotationDto revision = approvedRevision(zero, blankDepositDirectDraft()); + assertConflict(() -> quotationService.createReorder(v1.id(), salesActor), "ยืนยันคำสั่งซื้อแล้ว"); - assertThat(revision.depositPercent()).isNull(); - TicketSummaryDto after = summary(deal); - assertThat(after.depositPolicy()).isEqualTo("REQUIRED"); - assertThat(after.depositPolicyReason()).isEqualTo("ใบเสนอราคา " + revision.number() + " ระบุมัดจำ 30%"); + assertThat(quotationRows(deal)).isEqualTo(rows); + assertStillTheAgreedOrder(deal, v1); } - /** The other direction: a confirmed 50% deal whose approved revision says ไม่รับมัดจำ leaves - * the deposit path (this was the PR's "a later revision to 0% does not sync" known risk). */ @Test - void depositConfirmed_thenARevisionDropsTheDeposit_policyFollowsToNotRequired() { + void afterConfirm_aCloneOrRevisionDraftOpenedBeforeConfirm_cannotBeSubmitted() { long deal = pipelineTicket(salesActor); - DealQuotationDto fifty = approvedDirectQuotation(deal, salesActor); - confirmer.confirmOrderFromDirectQuotation(fifty.id(), salesActor); - assertThat(summary(deal).depositPolicy()).isEqualTo("REQUIRED"); + DealQuotationDto v1 = approvedDirectQuotation(deal, salesActor); + DealQuotationDto clone = quotationService.createReorder(v1.id(), salesActor); + DealQuotationDto revisionDraft = quotationService.createRevision(v1.id(), salesActor); + confirmer.confirmOrderFromDirectQuotation(v1.id(), salesActor); - DealQuotationDto revision = approvedRevision(fifty, zeroDepositDirectDraft()); + assertConflict(() -> quotationService.submit(clone.id(), salesActor), "ยืนยันคำสั่งซื้อจากใบเสนอราคาอื่นแล้ว"); + assertConflict(() -> quotationService.submit(revisionDraft.id(), salesActor), "ยืนยันคำสั่งซื้อจากใบเสนอราคาอื่นแล้ว"); - TicketSummaryDto after = summary(deal); - assertThat(after.depositPolicy()).isEqualTo("NOT_REQUIRED"); - assertThat(after.depositPolicyReason()) - .isEqualTo("ใบเสนอราคา " + revision.number() + " ระบุไม่รับมัดจำ (ชำระเต็มจำนวน)"); - assertThat(policyChangedMessages(deal)).hasSize(1); - assertConflict(() -> depositNoticeService.createDraft(deal, - new DepositNoticeDraftRequest(null, null, null, null, null, null, null, null), salesActor), "ไม่ต้องเก็บมัดจำ"); + assertStillTheAgreedOrder(deal, v1); } - /** Wrong-way-round: a policy a person chose (credit customer, typed reason) is never undone by - * a revision, and no POLICY_CHANGED is written for the attempt. */ @Test - void humanCreditCustomer_isKept_whenARevisionAddsADeposit() { + void afterConfirm_aRevisionSubmittedBeforeConfirm_cannotBeApproved() { long deal = pipelineTicket(salesActor); - DealQuotationDto zero = approvedQuotation(deal, zeroDepositDirectDraft()); - confirmer.confirmOrderFromDirectQuotation(zero.id(), salesActor); - ticketService.waiveDeposit(deal, "CREDIT_CUSTOMER", "ลูกค้าเครดิต 30 วัน", salesActor); + DealQuotationDto v1 = approvedDirectQuotation(deal, salesActor); + DealQuotationDto revision = quotationService.createRevision(v1.id(), salesActor); + quotationService.update(revision.id(), zeroDepositDirectDraft(), salesActor); + quotationService.submit(revision.id(), salesActor); + confirmer.confirmOrderFromDirectQuotation(v1.id(), salesActor); - approvedRevision(zero, directDraft()); + assertConflict(() -> quotationService.approve(revision.id(), new ApproveRequest(null), salesManagerActor), + "ยืนยันคำสั่งซื้อจากใบเสนอราคาอื่นแล้ว"); - TicketSummaryDto after = summary(deal); - assertThat(after.depositPolicy()).isEqualTo("CREDIT_CUSTOMER"); - assertThat(after.depositPolicyReason()).isEqualTo("ลูกค้าเครดิต 30 วัน"); - assertThat(policyChangedMessages(deal)).hasSize(2); // the sync at confirmation + the waiver + assertStillTheAgreedOrder(deal, v1); + } + + private void assertStillTheAgreedOrder(long deal, DealQuotationDto v1) { + assertThat(quotationService.get(v1.id(), salesActor).docStatus()).isEqualTo(QuotationStatus.APPROVED); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM sales.quotation WHERE ticket_id = :id AND doc_status = 'APPROVED'", + Map.of("id", deal), Integer.class)).isEqualTo(1); + assertThat(summary(deal).depositPolicy()).isEqualTo("REQUIRED"); } - /** Wrong-way-round, the case the set_by marker exists for: a NOT_REQUIRED a person typed is - * not the system's, so a 50% revision leaves it and writes no event. */ + /** Review finding 2: confirming the order must not strand the deal's other open direct quotations. */ @Test - void humanNotRequired_isKept_whenARevisionStillCarriesADeposit() { + void confirmOrder_cancelsTheDealsOtherOpenDirectQuotations_andLeavesTheConfirmedOneAlone() { + long deal = pipelineTicket(salesActor); + DealQuotationDto v1 = approvedDirectQuotation(deal, salesActor); + DealQuotationDto clone = quotationService.createReorder(v1.id(), salesActor); // DRAFT clone + DealQuotationDto pending = quotationService.createRevision(v1.id(), salesActor); // revision ... + quotationService.update(pending.id(), zeroDepositDirectDraft(), salesActor); + quotationService.submit(pending.id(), salesActor); // ... PENDING_APPROVAL + assertThat(quotationService.get(pending.id(), salesActor).docStatus()).isEqualTo(QuotationStatus.PENDING_APPROVAL); + + confirmer.confirmOrderFromDirectQuotation(v1.id(), salesActor); + + assertThat(quotationService.get(v1.id(), salesActor).docStatus()).isEqualTo(QuotationStatus.APPROVED); + assertThat(quotationService.get(clone.id(), salesActor).docStatus()).isEqualTo(QuotationStatus.CANCELLED); + assertThat(quotationService.get(pending.id(), salesActor).docStatus()).isEqualTo(QuotationStatus.CANCELLED); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM sales.ticket_event WHERE ticket_id = :id AND kind = :k", + Map.of("id", deal, "k", TicketEventKind.CANCELLED), Integer.class)).isEqualTo(2); + } + + /** ...and before the order is confirmed the same revision still works. */ + @Test + void unconfirmedApprovedDirectQuotation_canStillBeRevised() { long deal = pipelineTicket(salesActor); DealQuotationDto fifty = approvedDirectQuotation(deal, salesActor); - confirmer.confirmOrderFromDirectQuotation(fifty.id(), salesActor); - ticketService.waiveDeposit(deal, "NOT_REQUIRED", "ลูกค้าโอนเต็มจำนวนล่วงหน้า", salesActor); - approvedRevision(fifty, directDraft()); + DealQuotationDto revision = quotationService.createRevision(fifty.id(), salesActor); - TicketSummaryDto after = summary(deal); - assertThat(after.depositPolicy()).isEqualTo("NOT_REQUIRED"); - assertThat(after.depositPolicyReason()).isEqualTo("ลูกค้าโอนเต็มจำนวนล่วงหน้า"); - assertThat(policyChangedMessages(deal)).hasSize(1); // the waiver only + assertThat(revision.docStatus()).isEqualTo(QuotationStatus.DRAFT); + assertThat(revision.parentQuotationId()).isEqualTo(fifty.id()); + } + + private int quotationRows(long ticketId) { + return jdbc.queryForObject("SELECT COUNT(*) FROM sales.quotation WHERE ticket_id = :id", + Map.of("id", ticketId), Integer.class); } // ───────────────────────────────────────────────────────────────────────────────────── diff --git a/backend/src/test/java/th/co/glr/hr/dealquotation/DealQuotationIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/dealquotation/DealQuotationIntegrationTest.java index 0d6d1d2a4..750bc3008 100644 --- a/backend/src/test/java/th/co/glr/hr/dealquotation/DealQuotationIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/dealquotation/DealQuotationIntegrationTest.java @@ -2031,6 +2031,42 @@ void submit_reChecksStoredItems_missingLeadTimeIsBadRequest() { .hasMessageContaining("กรุณาระบุระยะเวลานำเข้า"); } + /** VW-01 (owner ruling 2026-10-28: stock lines skip import). A TILE row that came from a + * stock-flagged pricing-request line (quotation_item.stock_source set, V194) has no import + * lead time, so the submit gate must not demand one -- while the import row in the same + * document (stock_source NULL, still no lead time) is still refused, and only IT is named. */ + @Test + void submit_inThailandStockLineNeedsNoLeadTime_butInTransitAndImportStillDo() { + DealQuotationDto created = quotationService.create(ticketId, + upsertRequest(List.of(sampleItem("100.00", 10), sampleItem("100.00", 10))), salesActor); + jdbc.update(""" + UPDATE sales.quotation_item SET lead_time_min_days = NULL, lead_time_max_days = NULL + WHERE quotation_id = :id + """, java.util.Map.of("id", created.id())); + jdbc.update(""" + UPDATE sales.quotation_item SET stock_source = 'IN_THAILAND' + WHERE quotation_id = :id AND seq = 1 + """, java.util.Map.of("id", created.id())); + // Owner ruling 2026-10-29: only IN_THAILAND skips import; an IN_TRANSIT line still needs a lead time. + jdbc.update(""" + UPDATE sales.quotation_item SET stock_source = 'IN_TRANSIT' + WHERE quotation_id = :id AND seq = 2 + """, java.util.Map.of("id", created.id())); + + assertThatThrownBy(() -> quotationService.submit(created.id(), salesActor)) + .isInstanceOf(ApiException.class) + .hasFieldOrPropertyWithValue("status", HttpStatus.BAD_REQUEST) + .hasMessageContaining("กรุณาระบุระยะเวลานำเข้า (วัน) ของรายการที่ 2") + .satisfies(e -> assertThat(((ApiException) e).getMessage()).doesNotContain("1")); + + jdbc.update(""" + UPDATE sales.quotation_item SET stock_source = 'IN_TRANSIT', lead_time_min_days = 30, lead_time_max_days = 45 + WHERE quotation_id = :id AND seq = 2 + """, java.util.Map.of("id", created.id())); + assertThat(quotationService.submit(created.id(), salesActor).docStatus()) + .isEqualTo(QuotationStatus.PENDING_APPROVAL); + } + // ── Wording-scan fix 3 (2026-09-17): an ENTERED lead-time range must be min>=1, max>=min ──── /** {@code ประมาณ 0-3 วัน} saved on 2 approved quotations, and {@code 90-75}/negatives would too diff --git a/backend/src/test/java/th/co/glr/hr/deposit/DepositNoticeServiceTest.java b/backend/src/test/java/th/co/glr/hr/deposit/DepositNoticeServiceTest.java index 5358e59c0..58161a0d2 100644 --- a/backend/src/test/java/th/co/glr/hr/deposit/DepositNoticeServiceTest.java +++ b/backend/src/test/java/th/co/glr/hr/deposit/DepositNoticeServiceTest.java @@ -1257,4 +1257,28 @@ private void assertConflict(org.assertj.core.api.ThrowableAssert.ThrowingCallabl .isInstanceOfSatisfying(ApiException.class, e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT)); } + + // SK-07 (owner ruling 2026-10-28, final): each tax invoice's VAT is round2(its own net x 7%). Deal 11 + // (PR-2026-0006): net 119,745.00 - 35,923.50 = 83,821.50 -> VAT 5,867.505 -> 5,867.51 -> grand 89,689.01. + // The 1-satang gap against the deal payable (128,127.15 - 38,438.15 = 89,689.00) is normal. + @Test + void remainingInvoiceMoney_deal11_isPerDocumentRounding() { + DepositNoticeService.RemainingInvoiceMoney m = service.computeRemainingInvoiceMoney( + new BigDecimal("119745.00"), new BigDecimal("35923.50")); + + assertThat(m.netAmount()).isEqualByComparingTo("83821.50"); + assertThat(m.vatAmount()).isEqualByComparingTo("5867.51"); + assertThat(m.grandTotal()).isEqualByComparingTo("89689.01"); + } + + /** No deposit (bypass): unchanged -- net = items, VAT = round2(items x 7%). */ + @Test + void remainingInvoiceMoney_withNoDeposit_isTheWholeDealInclVat() { + DepositNoticeService.RemainingInvoiceMoney m = service.computeRemainingInvoiceMoney( + new BigDecimal("33333.33"), BigDecimal.ZERO); + + assertThat(m.netAmount()).isEqualByComparingTo("33333.33"); + assertThat(m.vatAmount()).isEqualByComparingTo("2333.33"); + assertThat(m.grandTotal()).isEqualByComparingTo("35666.66"); + } } diff --git a/backend/src/test/java/th/co/glr/hr/deposit/RemainingInvoiceRendererTest.java b/backend/src/test/java/th/co/glr/hr/deposit/RemainingInvoiceRendererTest.java index 9cfcd8caa..901e796a2 100644 --- a/backend/src/test/java/th/co/glr/hr/deposit/RemainingInvoiceRendererTest.java +++ b/backend/src/test/java/th/co/glr/hr/deposit/RemainingInvoiceRendererTest.java @@ -81,10 +81,8 @@ void rendersWithoutFormulaErrorsAsASinglePageWithTheCorrectTotal() throws Except /** * The branch's own reference sample (owner-supplied, matches the real template's formulas * exactly): 46 แผ่น × 806.83 = 37,114.18; deposit 18,557.09 (ref AI2600145) deducted → - * remainder 18,557.09; I43 stores the UNROUNDED VAT 1298.9963 (18557.09 * 0.07), which the - * template's own cell format displays as 1,299.00 — this test evaluates the formula with POI - * directly (no LibreOffice needed) and checks the raw numeric value, not a rendered string, so - * it also pins that the stored figure is genuinely unrounded until display. + * remainder 18,557.09; the stored VAT 1,299.00 and total 19,856.09 are written as values (no + * LibreOffice needed: POI reads the cells directly). */ @Test void referenceSampleFormulasEvaluateToTheOwnerSuppliedTotals() throws Exception { @@ -103,7 +101,8 @@ void referenceSampleFormulasEvaluateToTheOwnerSuppliedTotals() throws Exception List.of(new RemainingInvoiceItemDto( 1, "กระเบื้อง 60x60", new BigDecimal("46"), "แผ่น", new BigDecimal("806.83"), null, new BigDecimal("806.83"), new BigDecimal("37114.18") - )) + )), + new BigDecimal("1299.00"), new BigDecimal("19856.09") ); byte[] xlsx = renderer.toXlsx(doc); @@ -114,13 +113,9 @@ void referenceSampleFormulasEvaluateToTheOwnerSuppliedTotals() throws Exception // I42 = SUM(I11:I41) = 37114.18 (item) - 18557.09 (deposit) = 18557.09. assertThat(round2(eval.evaluate(cell(sh, 41, 8)).getNumberValue())).isEqualTo(18557.09); - // I43 = I42 * 0.07 — the RAW value is unrounded (1298.9963); rounds to 1,299.00 for display. - double vatRaw = eval.evaluate(cell(sh, 42, 8)).getNumberValue(); - assertThat(vatRaw).isCloseTo(1298.9963, org.assertj.core.data.Offset.offset(0.0001)); - assertThat(round2(vatRaw)).isEqualTo(1299.00); - // I44 = I42 + I43 = 18557.09 + 1298.9963 = 19856.0863, rounds to 19,856.09. - double totalRaw = eval.evaluate(cell(sh, 43, 8)).getNumberValue(); - assertThat(round2(totalRaw)).isEqualTo(19856.09); + // I43 / I44 are the STORED, already-rounded figures (owner ruling 2026-10-28), not template formulas. + assertThat(cell(sh, 42, 8).getNumericCellValue()).isEqualTo(1299.00); + assertThat(cell(sh, 43, 8).getNumericCellValue()).isEqualTo(19856.09); // Item row present at row 13 (0-based 12). assertThat(cell(sh, 12, 1).getStringCellValue()).isEqualTo("กระเบื้อง 60x60"); @@ -339,6 +334,33 @@ private Cell cell(Sheet sh, int rowIdx, int colIdx) { return c; } + /** + * SK-07 (owner ruling 2026-10-28): the sheet prints the STORED VAT and total as values. Deal 11 (PR-2026-0006): + * 119,745.00 - 35,923.50 deposit = net 83,821.50; VAT = round2(net x 7%) = 5,867.51 (the template's I42 x 7% + * formula would hold 5,867.505) and total 89,689.01 (not 89,689.005). + */ + @Test + void storedVatAndGrandTotal_areWrittenAsValues_deal11() throws Exception { + RemainingInvoiceDto doc = new RemainingInvoiceDto( + "GLR6900002-1", LocalDate.of(2026, 10, 28), "QT-2026-0006", "GLRD69002", "บริษัท ดีล 11 จำกัด", null, + "1 ถนนตัวอย่าง", "0105500000000", "โครงการ 11", new BigDecimal("35923.50"), List.of(), + List.of(new RemainingInvoiceItemDto(1, "Tile", new BigDecimal("1"), "lot", + new BigDecimal("119745.00"), null, new BigDecimal("119745.00"), new BigDecimal("119745.00"))), + new BigDecimal("5867.51"), new BigDecimal("89689.01")); + + byte[] xlsx = renderer.toXlsx(doc); + + try (Workbook wb = WorkbookFactory.create(new ByteArrayInputStream(xlsx))) { + Sheet sh = wb.getSheetAt(0); + FormulaEvaluator eval = wb.getCreationHelper().createFormulaEvaluator(); + assertThat(round2(eval.evaluate(cell(sh, 41, 8)).getNumberValue())).isEqualTo(83821.50); + assertThat(cell(sh, 42, 8).getCellType()).isEqualTo(org.apache.poi.ss.usermodel.CellType.NUMERIC); + assertThat(cell(sh, 42, 8).getNumericCellValue()).isEqualTo(5867.51); + assertThat(cell(sh, 43, 8).getCellType()).isEqualTo(org.apache.poi.ss.usermodel.CellType.NUMERIC); + assertThat(cell(sh, 43, 8).getNumericCellValue()).isEqualTo(89689.01); + } + } + private RemainingInvoiceDto document() { return new RemainingInvoiceDto( "GLRI69001", @@ -361,7 +383,8 @@ private RemainingInvoiceDto document() { null, new BigDecimal("979.40"), new BigDecimal("64640.40") - )) + )), + new BigDecimal("2262.41"), new BigDecimal("34582.61") ); } } diff --git a/backend/src/test/java/th/co/glr/hr/deposit/RemainingInvoiceServiceIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/deposit/RemainingInvoiceServiceIntegrationTest.java index 8abc6a411..936b6a448 100644 --- a/backend/src/test/java/th/co/glr/hr/deposit/RemainingInvoiceServiceIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/deposit/RemainingInvoiceServiceIntegrationTest.java @@ -99,6 +99,7 @@ class RemainingInvoiceServiceIntegrationTest extends AbstractPostgresIntegration private OrderConfirmationService orderConfirmation; private RemainingInvoiceRepository remainingInvoiceRepository; private RemainingInvoiceService remainingInvoiceService; + private TicketService ticketService; private long salesRepId; private long otherSalesRepId; @@ -139,7 +140,7 @@ void wireStepsServices() { decisionService = new PricingDecisionService(decisionRepository, pricingRequests, costingRepository, tickets, fxRates, notifications, landedCostCalculator, formulaEngine); - TicketService ticketService = new TicketService(tickets, notifications, + ticketService = new TicketService(tickets, notifications, objectMapper, customers, new QuotationRenderer(), pricingRequestService, new th.co.glr.hr.auth.EmployeeAuthRepository(jdbc), new th.co.glr.hr.customer.ContactRepository(jdbc), new th.co.glr.hr.customer.ProjectRepository(jdbc), new th.co.glr.hr.dealquotation.DealQuotationRepository(jdbc, new th.co.glr.hr.catalog.CatalogRepository(jdbc))); @@ -176,6 +177,39 @@ objectMapper, customers, new QuotationRenderer(), pricingRequestService, new th. // Lifecycle + numbering // ───────────────────────────────────────────────────────────────────────────────────── + /** + * Owner ruling 2026-10-28 (SK-07): each tax invoice's VAT is rounded on its own net, so deposit + remaining can + * differ from the deal's VAT-inclusive payable by a satang -- and outstanding + the final BALANCE receipt then + * FOLLOW THE ISSUED DOCUMENTS: once the remaining invoice is issued, payable = deposit notice total + remaining + * invoice grand total, the deposit receipt is the notice total, the balance is exactly the invoice grand total + * and the deal closes FULLY_PAID with nothing left (no satang overpaid/underpaid). + */ + @Test + void issuedRemainingInvoice_outstandingAndFinalPaymentFollowTheIssuedDocuments() { + DealFixture fixture = buildAcceptedDeal(salesActor, "FollowDocs"); + long ticketId = fixture.ticketId(); + BigDecimal depositNoticeTotal = new DepositNoticeRepository(jdbc).findById(fixture.depositNoticeId()).orElseThrow().totalPayable(); + ticketService.confirmDepositPaid(ticketId, accountActor); + + BigDecimal payableBeforeInvoice = tickets.payableAmount(ticketId); + RemainingInvoiceDocumentDto issued = remainingInvoiceService.issue( + remainingInvoiceService.createDraft(ticketId, null, salesActor).id(), salesActor); + // fixture precondition: per-document rounding really does drift from the deal payable here + assertThat(payableBeforeInvoice.subtract(depositNoticeTotal)).isNotEqualByComparingTo(issued.grandTotal()); + + assertThat(tickets.payableAmount(ticketId)).isEqualByComparingTo(depositNoticeTotal.add(issued.grandTotal())); + assertThat(tickets.findSummaryById(ticketId).orElseThrow().amountOutstanding()).isEqualByComparingTo(issued.grandTotal()); + + ticketService.confirmFinalPayment(ticketId, accountActor); + + assertThat(jdbc.queryForObject( + "SELECT amount FROM sales.payment_receipt WHERE ticket_id = :id AND kind = 'BALANCE'", + Map.of("id", ticketId), BigDecimal.class)).isEqualByComparingTo(issued.grandTotal()); + var after = tickets.findSummaryById(ticketId).orElseThrow(); + assertThat(after.paymentStatus()).isEqualTo("FULLY_PAID"); + assertThat(after.amountOutstanding()).isEqualByComparingTo("0"); + } + @Test void createDraft_thenIssue_mintsFirstVersionOfANewBase() { DealFixture fixture = buildAcceptedDeal(salesActor, "Lifecycle1"); diff --git a/backend/src/test/java/th/co/glr/hr/finance/FinancePayableVatAndDueIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/finance/FinancePayableVatAndDueIntegrationTest.java index 11681d5c3..6b29ff06c 100644 --- a/backend/src/test/java/th/co/glr/hr/finance/FinancePayableVatAndDueIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/finance/FinancePayableVatAndDueIntegrationTest.java @@ -82,6 +82,129 @@ void payingTheFullVatInclusiveAmount_isNotOverpayment_andReachesFullyPaid() { assertThat(tickets.findSummaryById(ticketId).orElseThrow().amountOutstanding()).isEqualByComparingTo("0"); } + // ── A2. QA-03 (owner ruling 2026-10-28): a confirmed DEAL_DIRECT quotation stays APPROVED ── + + /** A deal whose only quotation is a DEAL_DIRECT one in its terminal live state, APPROVED (subtotal 100,000). */ + private long directApprovedDeal(String origin) { + long ticketId = unconfirmedDirectApprovedDeal(origin); + tickets.updatePaymentStatusUnchecked(ticketId, "CUSTOMER_CONFIRMED"); // confirm-order puts the deal on the payment track + // ...and writes the per-quotation fact the payable rule keys on: the promotion event naming the quotation + long quotationId = jdbc.queryForObject("SELECT quotation_id FROM sales.quotation WHERE ticket_id = :id", + new MapSqlParameterSource("id", ticketId), Long.class); + tickets.addEventWithDocument(ticketId, salesRepId, "ทดสอบ", "DEAL_PROMOTED_FROM_QUOTATION", null, null, + "ยืนยันคำสั่งซื้อ", "QUOTATION", quotationId); + return ticketId; + } + + private long unconfirmedDirectApprovedDeal(String origin) { + long ticketId = createTicket("PROCUREMENT"); + long q = insertAcceptedQuotation(ticketId, "QT-DD-" + ticketId, new BigDecimal("100000.00")); + jdbc.update("UPDATE sales.quotation SET origin = :o, doc_status = 'APPROVED' WHERE quotation_id = :q", + new MapSqlParameterSource("o", origin).addValue("q", q)); + return ticketId; + } + + @Test + void confirmedDirectQuotation_approved_countsTowardPayable_andShowsAsTheDealValue() { + long ticketId = directApprovedDeal("DEAL_DIRECT"); + + assertThat(tickets.payableAmount(ticketId)).isEqualByComparingTo("107000.00"); + assertThat(tickets.payableAmountExVat(ticketId)).isEqualByComparingTo("100000.00"); + FinanceDealDto dto = service.get(ticketId, accountUser); + assertThat(dto.money().amountPayable()).isEqualByComparingTo("107000.00"); + assertThat(dto.money().amountOutstanding()).isEqualByComparingTo("107000.00"); + } + + @Test + void confirmedDirectQuotation_approved_isTheDealsPayableQuotationDocument() { + long ticketId = directApprovedDeal("DEAL_DIRECT"); + + // FinanceDealRepository.findPayableQuotation must agree with TicketRepository's payable SQL. + assertThat(service.get(ticketId, accountUser).documents().acceptedQuotation()).isNotNull(); + assertThat(service.get(ticketId, accountUser).documents().acceptedQuotation().number()) + .isEqualTo("QT-DD-" + ticketId); + } + + @Test + void confirmedDirectQuotation_approved_balanceIsNotFullyPaidBeforeTheMoneyArrives() { + long ticketId = directApprovedDeal("DEAL_DIRECT"); + tickets.updatePaymentStatusUnchecked(ticketId, "DEPOSIT_PAID"); + insertPayment(ticketId, "DEPOSIT", new BigDecimal("53500.00")); + + assertThat(tickets.findSummaryById(ticketId).orElseThrow().amountOutstanding()).isEqualByComparingTo("53500.00"); + } + + @Test + void confirmedDirectQuotation_approved_drivesPaymentTermsAndAccountScope() { + long ticketId = directApprovedDeal("DEAL_DIRECT"); + jdbc.update("UPDATE sales.quotation SET remainder_mode = 'CREDIT', credit_days = 30 WHERE ticket_id = :id", + new MapSqlParameterSource("id", ticketId)); + deliver(ticketId, today().minusDays(45).atTime(12, 0).atZone(BKK).toInstant()); + // outside account's order-stage scope with a non-pending status: only overdue-with-balance gets it in. + tickets.updateSalesStage(ticketId, "NEGOTIATION"); + tickets.updatePaymentStatusUnchecked(ticketId, "CUSTOMER_CONFIRMED"); + + TicketSummaryDto s = summary(ticketId); + assertThat(s.paymentDueBasis()).isEqualTo("CREDIT_FROM_DELIVERY"); + assertThat(s.paymentDueDate()).isEqualTo(today().minusDays(15)); + assertThat(s.overdue()).isTrue(); + assertThat(inAccountList(ticketId)).isTrue(); + } + + @Test + void acceptedQuotation_outranksAnApprovedDirectOne_inAllThreeQueries() { + long ticketId = createS15Deal("CUSTOMER_CONFIRMED"); // ACCEPTED, 100,000, (recipient default) + jdbc.update("UPDATE sales.quotation SET recipient_type = 'OWNER', remainder_mode = 'CREDIT', credit_days = 30 WHERE ticket_id = :id", + new MapSqlParameterSource("id", ticketId)); + long dd = tickets.createQuotation(ticketId, "QT-DD-RANK-" + ticketId, salesRepId, new BigDecimal("200000.00")).id(); + jdbc.update("UPDATE sales.quotation SET origin = 'DEAL_DIRECT', doc_status = 'APPROVED', recipient_type = 'BUYER' WHERE quotation_id = :q", + new MapSqlParameterSource("q", dd)); + deliver(ticketId, today().minusDays(10).atTime(12, 0).atZone(BKK).toInstant()); + + assertThat(tickets.payableAmount(ticketId)).isEqualByComparingTo("107000.00"); + assertThat(service.get(ticketId, accountUser).documents().acceptedQuotation().number()) + .isEqualTo("QT-LK-" + ticketId); + assertThat(summary(ticketId).paymentDueBasis()).isEqualTo("CREDIT_FROM_DELIVERY"); + } + + /** Review finding 5+6: the fact is per QUOTATION -- a deal on the payment track whose promotion event names a + * DIFFERENT quotation must not count this APPROVED direct one. */ + @Test + void approvedDirectQuotation_isNotPayable_whenThePromotionEventNamesAnotherQuotation() { + long ticketId = createTicket("PROCUREMENT"); + // "other" first (createQuotation supersedes earlier live rows), then the direct one, so the direct stays APPROVED + long other = tickets.createQuotation(ticketId, "QT-OTHER-" + ticketId, salesRepId, new BigDecimal("1.00")).id(); + jdbc.update("UPDATE sales.quotation SET doc_status = 'CANCELLED' WHERE quotation_id = :q", + new MapSqlParameterSource("q", other)); // out of the payable set on its own + long direct = insertAcceptedQuotation(ticketId, "QT-DD-" + ticketId, new BigDecimal("100000.00")); + jdbc.update("UPDATE sales.quotation SET origin = 'DEAL_DIRECT', doc_status = 'APPROVED' WHERE quotation_id = :q", + new MapSqlParameterSource("q", direct)); + tickets.updatePaymentStatusUnchecked(ticketId, "CUSTOMER_CONFIRMED"); + tickets.addEventWithDocument(ticketId, salesRepId, "ทดสอบ", "DEAL_PROMOTED_FROM_QUOTATION", null, null, + "ยืนยันคำสั่งซื้อ", "QUOTATION", other); + assertThat(jdbc.queryForObject("SELECT doc_status FROM sales.quotation WHERE quotation_id = :q", + new MapSqlParameterSource("q", direct), String.class)).isEqualTo("APPROVED"); // not vacuous: it is still live + + assertThat(tickets.payableAmount(ticketId)).isEqualByComparingTo("0"); + assertThat(service.get(ticketId, accountUser).documents().acceptedQuotation()).isNull(); + } + + @Test + void approvedDirectQuotation_withNoOrderConfirmation_isNotPayable() { + long ticketId = unconfirmedDirectApprovedDeal("DEAL_DIRECT"); // payment_status still NULL + + assertThat(tickets.payableAmount(ticketId)).isEqualByComparingTo("0"); + assertThat(service.get(ticketId, accountUser).documents().acceptedQuotation()).isNull(); + } + + @Test + void approvedPricingRequestQuotation_isNotPayable_onlyDealDirectApprovedIs() { + long ticketId = directApprovedDeal("PRICING_REQUEST"); // a PCR approval lands on ISSUED, never APPROVED + + assertThat(tickets.payableAmount(ticketId)).isEqualByComparingTo("0"); + assertThat(service.get(ticketId, accountUser).documents().acceptedQuotation()).isNull(); + } + // ── B. Overdue from the quotation's terms ────────────────────────────────────────────── private long creditDeal(int creditDays, LocalDate deliveredOn) { diff --git a/backend/src/test/java/th/co/glr/hr/orderconfirmation/OrderConfirmationIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/orderconfirmation/OrderConfirmationIntegrationTest.java index dc1126cf5..31a20fe9d 100644 --- a/backend/src/test/java/th/co/glr/hr/orderconfirmation/OrderConfirmationIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/orderconfirmation/OrderConfirmationIntegrationTest.java @@ -301,7 +301,11 @@ SELECT COUNT(*) FROM sales.pricing_request_event // just because payableAmount() used to be blind to the new chain's own quotation total. BigDecimal paid = jdbc.queryForObject( "SELECT amount FROM sales.payment_receipt WHERE ticket_id = :id", Map.of("id", ticketId), BigDecimal.class); - assertThat(paid).isEqualByComparingTo(draftNotice.depositAmount()); + // QA-01 (owner ruling 2026-10-28): the receipt is the notice's VAT-INCLUSIVE totalPayable + // (deal payable is VAT-inclusive), NOT the pre-VAT depositAmount. + assertThat(issuedNotice.vatAmount().signum()).as("fixture must carry VAT").isPositive(); + assertThat(paid).isEqualByComparingTo(issuedNotice.totalPayable()); + assertThat(paid).isGreaterThan(draftNotice.depositAmount()); assertThat(paid).isLessThan(payableBeforePayment); // strictly partial — not an overpayment by construction. String note = jdbc.queryForObject( "SELECT note FROM sales.payment_receipt WHERE ticket_id = :id", Map.of("id", ticketId), String.class); diff --git a/backend/src/test/java/th/co/glr/hr/ticket/PaymentTrackIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/ticket/PaymentTrackIntegrationTest.java index 61a005e91..4c1352b75 100644 --- a/backend/src/test/java/th/co/glr/hr/ticket/PaymentTrackIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/ticket/PaymentTrackIntegrationTest.java @@ -212,7 +212,7 @@ void requiredPath_walksEndToEnd_nullThroughDepositNoticeIssuedThroughFullyPaid() // site 2: CUSTOMER_CONFIRMED -> DEPOSIT_NOTICE_ISSUED. DepositNoticeDto draft = orderConfirmation.createDepositNoticeFromQuotation( deal.pricingRequestId(), new CreateDepositNoticeFromQuotationRequest(null), salesActor); - depositNoticeService.issue(draft.id(), salesActor); + DepositNoticeDto issuedNotice = depositNoticeService.issue(draft.id(), salesActor); assertThat(paymentStatusOf(deal.ticketId())).isEqualTo(PaymentTrack.DEPOSIT_NOTICE_ISSUED); // site 6 (REQUIRED branch, via reconcilePaymentStatus): DEPOSIT_NOTICE_ISSUED -> DEPOSIT_PAID, @@ -235,6 +235,11 @@ void requiredPath_walksEndToEnd_nullThroughDepositNoticeIssuedThroughFullyPaid() // the remaining ~50% is still outstanding): AWAITING_FINAL_PAYMENT -> FULLY_PAID. ticketService.confirmFinalPayment(deal.ticketId(), accountActor); assertThat(paymentStatusOf(deal.ticketId())).isEqualTo(PaymentTrack.FULLY_PAID); + // QA-01: the DEPOSIT receipt is the notice's VAT-inclusive totalPayable, not pre-VAT. + assertThat(jdbc.queryForObject( + "SELECT amount FROM sales.payment_receipt WHERE ticket_id = :id AND kind = 'DEPOSIT'", + java.util.Map.of("id", deal.ticketId()), BigDecimal.class)) + .isEqualByComparingTo(issuedNotice.totalPayable()); } @Test diff --git a/backend/src/test/java/th/co/glr/hr/ticket/StageDecisionIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/ticket/StageDecisionIntegrationTest.java index ff40e4e7c..79c9194a4 100644 --- a/backend/src/test/java/th/co/glr/hr/ticket/StageDecisionIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/ticket/StageDecisionIntegrationTest.java @@ -456,6 +456,57 @@ void everyAllowedDecisionIsAccepted_andEveryBlockedOneIsRefused() { } } + /** + * QA-05 (owner ruling 2026-10-28): once the order is confirmed (payment track reached + * CUSTOMER_CONFIRMED -- the deal is at or past ขั้น 10), "แก้ไขสถานะ" must not move the deal + * below ขั้น 10. The decision payload the modal renders says blocked, and the write is a 409. + */ + @Test + void afterTheOrderIsConfirmed_everyStageBelowOrderReceived_isBlockedAndRefusedWith409() { + long ticketId = readyToAdvanceFrom(DealStage.PROCUREMENT); + tickets.updatePaymentStatusUnchecked(ticketId, PaymentTrack.CUSTOMER_CONFIRMED); + + for (UserPrincipal actor : List.of(ownerRep, salesManager, ceo)) { + List decisions = decisionsFor(ticketId, actor); + for (String target : DealStage.ORDER) { + if (DealStage.indexOf(target) >= DealStage.indexOf(DealStage.ORDER_RECEIVED)) continue; + assertThat(allowedFor(decisions, target)) + .as("%s must not be offered to %s after the order is confirmed", target, actor.role()).isFalse(); + assertThat(reasonFor(decisions, target)).isNotBlank(); + } + } + // The write is refused too (not only hidden), with a conflict, and nothing moved. + for (String target : List.of(DealStage.QUOTE_OWNER, DealStage.SPEC_APPROVED, DealStage.NEGOTIATION)) { + assertThatThrownBy(() -> ticketService.updateStage(ticketId, target, "ย้อนกลับ", ownerRep)) + .as(target) + .isInstanceOfSatisfying(ApiException.class, + e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT)); + } + assertThat(stageOf(ticketId)).isEqualTo(DealStage.PROCUREMENT); + assertThat(stageChangedEvents(ticketId)).isZero(); + } + + /** The other half of the ruling: a move that stays at or above ขั้น 10 is unaffected by the new gate. */ + @Test + void afterTheOrderIsConfirmed_movesWithinTenAndAbove_stillWork() { + long ticketId = readyToAdvanceFrom(DealStage.PROCUREMENT); + tickets.updatePaymentStatusUnchecked(ticketId, PaymentTrack.CUSTOMER_CONFIRMED); + + assertThat(allowedFor(decisionsFor(ticketId, ownerRep), DealStage.DELIVERY_SCHEDULING)).isTrue(); + ticketService.updateStage(ticketId, DealStage.DELIVERY_SCHEDULING, "ส่งของ", ownerRep); + assertThat(stageOf(ticketId)).isEqualTo(DealStage.DELIVERY_SCHEDULING); + } + + /** ...and before the order exists the ordinary rewind (ขั้น 5 -> 3) is still legal, with its reason. */ + @Test + void beforeTheOrderIsConfirmed_aRewindWithinStagesOneToNine_stillWorks() { + long ticketId = readyToAdvanceFrom(DealStage.QUOTE_OWNER); + + assertThat(allowedFor(decisionsFor(ticketId, ownerRep), DealStage.SPEC_APPROVED)).isTrue(); + ticketService.updateStage(ticketId, DealStage.SPEC_APPROVED, "ลูกค้าขอแก้สเปก", ownerRep); + assertThat(stageOf(ticketId)).isEqualTo(DealStage.SPEC_APPROVED); + } + // ── helpers ────────────────────────────────────────────────────────────── // H1 lockdown: account reads its stage decisions through financeActions (same gates, no /tickets view diff --git a/docs/api/response-fields.json b/docs/api/response-fields.json index 43d48120a..dfe0f9442 100644 --- a/docs/api/response-fields.json +++ b/docs/api/response-fields.json @@ -1,7 +1,7 @@ { "$comment" : "GENERATED by ResponseFieldContractTest — do not hand-edit. Regenerate: cd backend && ./mvnw -Dtest=ResponseFieldContractTest -Dresponse.fields.update=true test", "source" : "Schema.field pairs from springdoc /v3/api-docs response schemas, kept where the field name is read in frontend/src (excluding mockApi, hrApi, routes and tests)", - "count" : 1462, + "count" : 1463, "fields" : [ "Action.requiredFields", "Action.targetStage", @@ -368,6 +368,7 @@ "DealQuotationItemDto.specialPriceSqm", "DealQuotationItemDto.sqmPerBox", "DealQuotationItemDto.sqmPerPiece", + "DealQuotationItemDto.stockSource", "DealQuotationItemDto.thicknessMm", "DealQuotationItemDto.unitPrice", "DealQuotationItemDto.wastageMode", diff --git a/docs/api/ui-reachable.json b/docs/api/ui-reachable.json index e7b4708ee..5792a9cb8 100644 --- a/docs/api/ui-reachable.json +++ b/docs/api/ui-reachable.json @@ -177,9 +177,9 @@ "POST /api/deal-quotations/{id}/approve", "POST /api/deal-quotations/{id}/attachments", "POST /api/deal-quotations/{id}/cancel", + "POST /api/deal-quotations/{id}/confirm-order", "POST /api/deal-quotations/{id}/items/{pricingDecisionItemId}/restore", "POST /api/deal-quotations/{id}/outcome", - "POST /api/deal-quotations/{id}/promote-to-deal", "POST /api/deal-quotations/{id}/reject", "POST /api/deal-quotations/{id}/revisions", "POST /api/deal-quotations/{id}/submit", diff --git a/frontend/e2e-real/write-commission-invoice-approval.spec.js b/frontend/e2e-real/write-commission-invoice-approval.spec.js index 3599b945b..88f99b04f 100644 --- a/frontend/e2e-real/write-commission-invoice-approval.spec.js +++ b/frontend/e2e-real/write-commission-invoice-approval.spec.js @@ -178,8 +178,9 @@ test.describe('commission invoice approval journey (real stack)', () => { const before = await readFinanceDeal('account'); expect(before.salesStage).toBe('CLOSED_PAID'); expect(before.commissionInvoice, 'fixture must start with no recorded invoice').toBeNull(); - const exVat = before.money.amountPayableExVat; - expect(typeof exVat, 'money.amountPayableExVat served as a number').toBe('number'); + // Owner ruling 2026-10-28 (QA-02): the commission gross is the amount received INCLUDING VAT (workbook column L). + const inclVat = before.money.amountPayable; + expect(typeof inclVat, 'money.amountPayable served as a number').toBe('number'); const notificationsBefore = await (await sessions.sales_manager.get('/api/notifications')).json(); const pendingNoticesBefore = notificationsBefore.filter((n) => n.type === 'COMMISSION_PENDING_MANAGER').length; @@ -195,13 +196,13 @@ test.describe('commission invoice approval journey (real stack)', () => { await shot(page, '01-account-finance-deal-primary-action'); }); - await test.step('clicking opens the in-page form, ยอดรวม defaulting to the pre-VAT figure', async () => { + await test.step('clicking opens the in-page form, ยอดรับจริง defaulting to the VAT-inclusive figure', async () => { await primary.click(); await expect(page.getByRole('heading', { name: 'บันทึกใบกำกับภาษี' })).toBeVisible(); - const gross = page.getByLabel(/ยอดรวม \(ก่อน VAT\)/); + const gross = page.getByLabel(/ยอดรับจริง \(รวม VAT\)/); await expect(gross).toBeVisible(); - await expect(gross).toHaveValue(String(exVat)); - expect(Number(await gross.inputValue())).toBeCloseTo(exVat, 2); + await expect(gross).toHaveValue(String(inclVat)); + expect(Number(await gross.inputValue())).toBeCloseTo(inclVat, 2); await shot(page, '02-account-invoice-form-opened'); }); diff --git a/frontend/src/api/mockApi.financeDeal.test.js b/frontend/src/api/mockApi.financeDeal.test.js index e3d5d5dba..eea95bcf9 100644 --- a/frontend/src/api/mockApi.financeDeal.test.js +++ b/frontend/src/api/mockApi.financeDeal.test.js @@ -164,9 +164,13 @@ describe('mockApi.finance routes -- role gates and scope', () => { && (t.lifecycle ?? 'ACTIVE') === 'ACTIVE'); expect(target).toBeTruthy(); await login2('account@glr.co.th'); + const notice = (await api.finance.getDeal(target.id)).deal.documents.depositNotices + .filter((d) => d.status === 'ISSUED').at(-1); const { deal } = await api.finance.confirmDepositPaid(target.id); expect(deal.money.paymentStatus).toBe('DEPOSIT_PAID'); expect(deal.money.payments.length).toBeGreaterThan(0); + // QA-01 (mirror of TicketService.confirmDepositPaid): the receipt is the notice's VAT-inclusive total. + if (notice) expect(deal.money.payments.find((p) => p.kind === 'DEPOSIT').amount).toBe(notice.totalPayable); const again = (await api.finance.getDeal(target.id)).deal; expect(again.money.paymentStatus).toBe('DEPOSIT_PAID'); }); diff --git a/frontend/src/api/mockApi.js b/frontend/src/api/mockApi.js index 3cc13d9d7..b6db6132b 100644 --- a/frontend/src/api/mockApi.js +++ b/frontend/src/api/mockApi.js @@ -4248,6 +4248,18 @@ function pushEvent(ticket, actor, kind, fromStatus, toStatus, message, itemSnaps ticket.events.push({ id: nextId, ticketId: ticket.id, actorId: actor.id, actorName: actor.name, kind, fromStatus, toStatus, message, createdAt: new Date().toISOString(), itemSnapshot }); } +// Mirrors TicketRepository#isOrderConfirmedFromQuotation / hasPromotionFromQuotationEvent (owner ruling 2026-10-28): +// the promotion event names the quotation the order was confirmed from; once it exists no OTHER direct quotation +// on the deal may be submitted/approved, and the confirmed one may not be revised or cloned. +function mockConfirmedQuotationEvent(ticket) { + return ticket?.events?.find((e) => e.kind === 'DEAL_PROMOTED_FROM_QUOTATION') ?? null; +} +function requireNoOtherDirectQuotationAfterConfirm(ticket, row) { + const promoted = mockConfirmedQuotationEvent(ticket); + if ((row.origin || 'DEAL_DIRECT') === 'PRICING_REQUEST' || !promoted || ticket.orderConfirmedQuotationId === row.id) return; + fail('ดีลนี้ยืนยันคำสั่งซื้อจากใบเสนอราคาอื่นแล้ว จึงส่ง/อนุมัติใบเสนอราคานี้ไม่ได้', 409); +} + function addNotification(userId, ticketId, ticketCode, type, message) { const nextId = Math.max(...db.notifications.map((n) => n.id), 0) + 1; db.notifications.unshift({ id: nextId, userId, ticketId, ticketCode, type, message, read: false, createdAt: new Date().toISOString() }); @@ -4483,6 +4495,10 @@ function mockStageDecisions(ticket, user) { blockedReason = `ดีลไม่ได้อยู่ในสถานะ ACTIVE (${ticket.lifecycle}) จึงแก้ไขขั้นตอนนี้ไม่ได้`; } else if (code === ticket.salesStage) { blockedReason = `ดีลนี้อยู่ในขั้นตอน ${code} อยู่แล้ว`; + } else if (dealStageIndex(ticket.salesStage) >= dealStageIndex('ORDER_RECEIVED') + && dealStageIndex(code) < dealStageIndex('ORDER_RECEIVED')) { + // QA-05: mirrors TicketService.requireStageMoveAllowed -- no rewind below ขั้น 10 once the order is confirmed. + blockedReason = 'ย้อนสถานะกลับก่อนขั้นรับคำสั่งซื้อไม่ได้ — ยืนยันคำสั่งซื้อไปแล้ว'; } else if (!mockIsOnRoute(ticket.entryChannel, code)) { // Rung 6 — the deal's entry-channel route, checked BEFORE the fact gates (mirrors // TicketService.requireStageMoveAllowed): an off-route target's remedy is the channel, not @@ -7809,7 +7825,7 @@ async function mockConfirmDepositPaid(id) { requireActive(ticket); if (ticket.paymentStatus !== 'DEPOSIT_NOTICE_ISSUED') fail('ต้องออกใบแจ้งรับมัดจำก่อนจึงจะยืนยันรับชำระมัดจำได้', 409); const notice = latestIssuedDepositNotice(ticket.id); - const amount = notice?.depositAmount ?? moneyValue(payableAmount(ticket) * 0.5); + const amount = notice?.totalPayable ?? moneyValue(payableAmount(ticket) * 0.5); // QA-01: VAT-inclusive if (amount <= 0) fail('ไม่พบยอดมัดจำสำหรับบันทึกรับชำระ', 409); recordPaymentForTicket(ticket, user, { kind: 'DEPOSIT', @@ -17254,6 +17270,7 @@ export const api = { if (!row) fail('ไม่พบใบเสนอราคานี้', 404); const ticket = db.tickets.find((t) => t.id === row.ticketId); requireDealQuotationWriteAccess(ticket, user, row.origin); + requireNoOtherDirectQuotationAfterConfirm(ticket, row); // GLA-123 slice S2: submit is now LIVE for a PRICING_REQUEST-origin quotation too — mirrors // DealQuotationService#submit, which widened its own origin predicate rather than keeping // this refusal. Every validation below already applied to both origins in the mock; only @@ -17327,6 +17344,7 @@ export const api = { if ((row.origin || 'DEAL_DIRECT') === 'PRICING_REQUEST') { return mockApproveDealQuotationPricingRequestOrigin(row, payload, user); } + requireNoOtherDirectQuotationAfterConfirm(db.tickets.find((t) => t.id === row.ticketId), row); if (!canTransitionDealQuotation(row.docStatus, 'APPROVED')) { fail(`อนุมัติไม่ได้ในสถานะ '${row.docStatus}'`, 409); } @@ -17379,9 +17397,6 @@ export const api = { } ancestorId = ancestor.parentQuotationId; } - // #1173 review F1 (DealQuotationService#approve -> TicketService#followQuotationDepositPolicy). - const dealTicket = db.tickets.find((t) => t.id === row.ticketId); - if (dealTicket) followQuotationDepositPolicy(dealTicket, row, user); return delay({ quotation: buildDealQuotationDto(row) }); }, @@ -17523,6 +17538,10 @@ export const api = { if (parent.docStatus !== 'APPROVED') { fail('สร้างฉบับแก้ไขได้เฉพาะใบเสนอราคาที่อนุมัติแล้วเท่านั้น', 409); } + // Owner ruling 2026-10-28: the confirmed quotation is the agreed order (mirrors DealQuotationService). + if (ticket?.orderConfirmedQuotationId === parent.id) { + fail('ใบเสนอราคานี้ยืนยันคำสั่งซื้อแล้ว จึงสร้างฉบับแก้ไขไม่ได้', 409); + } } // M3: mirrors DealQuotationRepository#hasOpenRevision's own guard -- a double-tap must not // silently mint two children sharing the same {base}-{n} number. @@ -17552,6 +17571,9 @@ export const api = { if (source.docStatus !== 'APPROVED') { fail('สร้างจากใบเดิมได้เฉพาะใบเสนอราคาที่อนุมัติแล้วเท่านั้น', 409); } + if (ticket?.orderConfirmedQuotationId === source.id) { + fail('ใบเสนอราคานี้ยืนยันคำสั่งซื้อแล้ว จึงสร้างจากใบเดิมไม่ได้', 409); + } const clone = mintDealQuotationReorder(source, user); mockDealQuotations.push(clone); return delay({ quotation: buildDealQuotationDto(clone) }); @@ -17598,6 +17620,7 @@ export const api = { ticket.quotationOnly = false; ticket.status = 'quotation_issued'; ticket.updatedAt = now; + ticket.orderConfirmedQuotationId = row.id; // mock-internal: which quotation the order was confirmed from pushEvent(ticket, user, 'DEAL_PROMOTED_FROM_QUOTATION', 'draft', 'quotation_issued', `ยืนยันคำสั่งซื้อจากใบเสนอราคา ${row.number}`); // DealQuotationService#ticketItemsFromQuotation: goods lines only (no ADJUSTMENT, no diff --git a/frontend/src/api/mockApi.promoteToDeal.test.js b/frontend/src/api/mockApi.promoteToDeal.test.js index ff9bb889f..f1483b748 100644 --- a/frontend/src/api/mockApi.promoteToDeal.test.js +++ b/frontend/src/api/mockApi.promoteToDeal.test.js @@ -126,6 +126,39 @@ describe('mock dealQuotations.promoteToDeal (GLA-136)', () => { expect(await listIds()).toContain(ghost); }); + // Owner ruling 2026-10-28 (mirror of DealQuotationService#createRevision): the quotation an order was + // confirmed from is the agreed order and cannot be revised. + it('refuses a revision of the confirmed quotation (409), but not before the order is confirmed', async () => { + const ghost = await quotationOnlyTicket(); + const approved = await approvedQuotation(ghost); + const { quotation: revision } = await api.dealQuotations.createRevision(approved.id); + expect(revision.parentQuotationId).toBe(approved.id); + await api.dealQuotations.cancel(revision.id); + + await api.dealQuotations.promoteToDeal(approved.id); + + await expect(api.dealQuotations.createRevision(approved.id)).rejects.toMatchObject({ status: 409 }); + }); + + // Round 3 (mirror of DealQuotationService.submit/approve/createReorder): after confirm-order no OTHER direct + // quotation on the deal may be cloned from the confirmed one, submitted or approved, and + // are refused with 409. + it('after confirm: no clone of the confirmed quotation, and a draft/pending one opened before cannot be submitted/approved', async () => { + const ghost = await quotationOnlyTicket(); + const v1 = await approvedQuotation(ghost); + const { quotation: clone } = await api.dealQuotations.createReorder(v1.id); + const { quotation: revision } = await api.dealQuotations.createRevision(v1.id); + await api.dealQuotations.submit(revision.id); + await api.dealQuotations.promoteToDeal(v1.id); + + await expect(api.dealQuotations.createReorder(v1.id)).rejects.toMatchObject({ status: 409 }); + await expect(api.dealQuotations.submit(clone.id)).rejects.toMatchObject({ status: 409 }); + await asRole('sales_manager'); + await expect(api.dealQuotations.approve(revision.id)).rejects.toMatchObject({ status: 409 }); + const { quotation: still } = await api.dealQuotations.get(v1.id); + expect(still.docStatus).toBe('APPROVED'); + }); + // Owner ruling 2026-10-23 (DepositNoticeRepository#foldAdjustments): the deposit draft carries no // qty -1 ส่วนลดพิเศษ row; a flat 95 is spread by line amount (TILE 8,500 takes 85, PLAIN 1,000 // takes 10), so the subtotal is the quotation's 9,405. The mock computes no % adjustment amount @@ -213,81 +246,4 @@ describe('mock dealQuotations.promoteToDeal (GLA-136)', () => { expect(ticket.events.filter((e) => e.kind === 'POLICY_CHANGED').map((e) => e.message)) .toEqual([expect.stringContaining(approved.number)]); }); - - // PR #1173 review F1, mirrors DealQuotationService#approve -> TicketService - // #followQuotationDepositPolicy: while CUSTOMER_CONFIRMED, a policy the system set follows the - // newly approved revision; a policy a person set is kept. - async function approvedWith(ticketId, payload) { - const { quotation: draft } = await api.dealQuotations.create(ticketId, { recipientType: 'DESIGNER', ...payload }); - await api.dealQuotations.submit(draft.id); - await asRole('sales_manager'); - const { quotation } = await api.dealQuotations.approve(draft.id); - await asRole('sales'); - return quotation; - } - - async function approvedRevisionOf(source, payload) { - const { quotation: revision } = await api.dealQuotations.createRevision(source.id); - await api.dealQuotations.update(revision.id, { recipientType: 'DESIGNER', ...payload }); - await api.dealQuotations.submit(revision.id); - await asRole('sales_manager'); - const { quotation } = await api.dealQuotations.approve(revision.id); - await asRole('sales'); - return quotation; - } - - const ZERO = { depositPercent: 0, fullPaymentTerm: 'BEFORE_DELIVERY', items: [TILE, PLAIN] }; - const FIFTY = { depositPercent: 50, items: [TILE, PLAIN] }; - const policyEvents = (ticket) => ticket.events.filter((e) => e.kind === 'POLICY_CHANGED').map((e) => e.message); - - it('a confirmed 0% deal whose approved revision adds a deposit goes back to REQUIRED', async () => { - const ghost = await quotationOnlyTicket(); - const zero = await approvedWith(ghost, ZERO); - await api.dealQuotations.promoteToDeal(zero.id); - - const revision = await approvedRevisionOf(zero, FIFTY); - - const { ticket } = await api.tickets.get(ghost); - expect(ticket.summary.depositPolicy).toBe('REQUIRED'); - expect(ticket.summary.depositPolicyReason).toBe(`ใบเสนอราคา ${revision.number} ระบุมัดจำ 50%`); - expect(policyEvents(ticket)).toHaveLength(2); - }); - - it('a confirmed 0% deal whose approved revision leaves the percent blank counts as 30 and is REQUIRED', async () => { - const ghost = await quotationOnlyTicket(); - const zero = await approvedWith(ghost, ZERO); - await api.dealQuotations.promoteToDeal(zero.id); - - const revision = await approvedRevisionOf(zero, { depositPercent: null, fullPaymentTerm: null, items: [TILE, PLAIN] }); - - const { ticket } = await api.tickets.get(ghost); - expect(ticket.summary.depositPolicy).toBe('REQUIRED'); - expect(ticket.summary.depositPolicyReason).toBe(`ใบเสนอราคา ${revision.number} ระบุมัดจำ 30%`); - }); - - it('a confirmed 50% deal whose approved revision says ไม่รับมัดจำ moves to NOT_REQUIRED', async () => { - const ghost = await quotationOnlyTicket(); - const fifty = await approvedWith(ghost, FIFTY); - await api.dealQuotations.promoteToDeal(fifty.id); - - const revision = await approvedRevisionOf(fifty, ZERO); - - const { ticket } = await api.tickets.get(ghost); - expect(ticket.summary.depositPolicy).toBe('NOT_REQUIRED'); - expect(policyEvents(ticket)).toEqual([expect.stringContaining(revision.number)]); - }); - - it('a NOT_REQUIRED a person set is kept when a revision still carries a deposit', async () => { - const ghost = await quotationOnlyTicket(); - const fifty = await approvedWith(ghost, FIFTY); - await api.dealQuotations.promoteToDeal(fifty.id); - await api.tickets.setDepositPolicy(ghost, { policy: 'NOT_REQUIRED', reason: 'ลูกค้าโอนเต็มจำนวนล่วงหน้า' }); - - await approvedRevisionOf(fifty, FIFTY); - - const { ticket } = await api.tickets.get(ghost); - expect(ticket.summary.depositPolicy).toBe('NOT_REQUIRED'); - expect(ticket.summary.depositPolicyReason).toBe('ลูกค้าโอนเต็มจำนวนล่วงหน้า'); - expect(policyEvents(ticket)).toHaveLength(1); - }); }); diff --git a/frontend/src/api/mockApi.stageDecisionsRoute.test.js b/frontend/src/api/mockApi.stageDecisionsRoute.test.js index 168329ec2..7d5f81dd1 100644 --- a/frontend/src/api/mockApi.stageDecisionsRoute.test.js +++ b/frontend/src/api/mockApi.stageDecisionsRoute.test.js @@ -86,3 +86,23 @@ describe('mockStageDecisions — onRoute mirrors DealRoute', () => { } }); }); + +// QA-05 (owner ruling 2026-10-28): mirror of TicketService's "order confirmed => no rewind below +// ขั้น 10". Plumbing only; the 409 itself is proved by StageDecisionIntegrationTest on the Java side. +describe('mockStageDecisions — no rewind below ขั้น 10 once the order is confirmed', () => { + it('offers no stage below ORDER_RECEIVED on a deal whose payment track has started', async () => { + await api.auth.login({ role: 'ceo' }); + const { tickets } = await api.tickets.list(); + const confirmed = tickets.find((t) => (t.lifecycle ?? 'ACTIVE') === 'ACTIVE' + && t.paymentStatus && ['CUSTOMER_CONFIRMED', 'DEPOSIT_NOTICE_ISSUED', 'DEPOSIT_PAID'].includes(t.paymentStatus)); + expect(confirmed, 'the mock must hold an order-confirmed deal').toBeTruthy(); + + const { stageDecisions } = await api.tickets.actions(confirmed.id); + const below = stageDecisions.filter((d) => d.no < 10 && d.stage !== confirmed.salesStage); + expect(below.length).toBeGreaterThan(0); + below.forEach((d) => { + expect(d.allowed, d.stage).toBe(false); + expect(d.blockedReason).toBeTruthy(); + }); + }); +}); diff --git a/frontend/src/api/routes.js b/frontend/src/api/routes.js index 9da322797..8692554f3 100644 --- a/frontend/src/api/routes.js +++ b/frontend/src/api/routes.js @@ -624,7 +624,7 @@ export const API_ROUTES = { // GLA-136 (owner ruling 2026-09-30) -- "สร้างดีลจากใบเสนอราคา": promote an APPROVED direct // quotation's quotation-only container ticket into the pipeline at ORDER_RECEIVED. Mirrors // DealQuotationController#promoteToDeal. - promoteToDeal: (id) => `/api/deal-quotations/${id}/promote-to-deal`, + promoteToDeal: (id) => `/api/deal-quotations/${id}/confirm-order`, cancel: (id) => `/api/deal-quotations/${id}/cancel`, file: (id, format) => `/api/deal-quotations/${id}/file?format=${format}`, // M4(d) fix (Opus review, 2026-09-20) — "คืนรายการ": re-adds a CEO-linked line a prior save diff --git a/frontend/src/api/serverContract.test.js b/frontend/src/api/serverContract.test.js index 3412ebb38..e8a048ce8 100644 --- a/frontend/src/api/serverContract.test.js +++ b/frontend/src/api/serverContract.test.js @@ -231,7 +231,7 @@ const SERVER_ONLY = { 'PATCH /api/deal-quotations/{}/items/{}/picture': 'GLA-75 quotation item pictures (V170), built BACKEND-FIRST: the per-item picture endpoints landed before the quotation editor UI that calls them (a separate frontend branch). Gated like editing / viewing the quotation and covered by DealQuotationPictureIntegrationTest. LIVE, not dead — delete this entry when hrApi.js calls it.', 'DELETE /api/deal-quotations/{}/items/{}/picture': 'GLA-75 quotation item pictures (V170), built BACKEND-FIRST: the per-item picture endpoints landed before the quotation editor UI that calls them (a separate frontend branch). Gated like editing / viewing the quotation and covered by DealQuotationPictureIntegrationTest. LIVE, not dead — delete this entry when hrApi.js calls it.', 'GET /api/deal-quotations/{}/items/{}/picture': 'GLA-75 quotation item pictures (V170), built BACKEND-FIRST: the per-item picture endpoints landed before the quotation editor UI that calls them (a separate frontend branch). Gated like editing / viewing the quotation and covered by DealQuotationPictureIntegrationTest. LIVE, not dead — delete this entry when hrApi.js calls it.', - 'POST /api/deal-quotations/{}/confirm-order': 'Quotation ↔ deal linking slice 1 (IA §7, 2026-09-30), built BACKEND-FIRST: the real name of GLA-136\'s promote-to-deal, which DealQuotationController maps to the SAME handler as a one-release alias. hrApi.js still calls …/promote-to-deal (identical behaviour) until the slice-1 frontend switches it over. Covered by DealQuotationConfirmOrderIntegrationTest and DealQuotationControllerTest. LIVE, not dead — delete this entry when hrApi.js calls it.', + 'POST /api/deal-quotations/{}/promote-to-deal': 'GLA-136 legacy alias of …/confirm-order (same handler), kept for ONE release so a still-cached frontend bundle keeps working; the frontend now calls …/confirm-order. Pinned by DealQuotationControllerTest. Remove with the controller alias.', // ── Formerly dormant, now GONE: ProcurementController ───────────────────── 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/deposits/DepositNoticePage.jsx b/frontend/src/features/deposits/DepositNoticePage.jsx index 9e5268354..5ef6fc5ba 100644 --- a/frontend/src/features/deposits/DepositNoticePage.jsx +++ b/frontend/src/features/deposits/DepositNoticePage.jsx @@ -15,6 +15,7 @@ import { fieldErrorId } from '../../components/common/FormField.jsx'; import { depositNoticeStatusLabel, formatThaiDate } from '../../utils/format.js'; import { downloadBlob } from '../../utils/download.js'; import { round2 } from '../quotations/quotationMeta.js'; +import { createDepositNoticeDraft } from './createDepositNoticeDraft.js'; function money(v) { if (v == null) return '—'; @@ -322,36 +323,8 @@ export function DepositNoticePage({ ticketId, onBack, onNavigateTickets, showToa } const createDraftMutation = useMutation({ - // Prefer the pricing-request-chain endpoint (createDepositNoticeFromQuotation) when this - // deal has a pricing request whose customer quotation was accepted — that route sources - // items from the accepted quotation and (per DepositNoticeService.createDraft, which it - // delegates to) autofills the header from the customer master + ticket project. Resolving - // "does this deal have an accepted quotation" only needs listForTicket's own summary shape - // (status === QUOTATION_ACCEPTED is set exactly when the customer accepts — see - // OrderConfirmationService's own Javadoc), so no extra round trip to the quotation list is - // needed. A failure ANYWHERE in this resolution step (network error, permission edge case, - // no pricing requests at all) degrades to today's ticket-level route — it must never break - // the button. - mutationFn: async () => { - const defaultNotes = noteTemplates.filter((t) => t.defaultSelected).map((t) => t.text); - let acceptedPricingRequestId = null; - try { - const { items: pricingRequests } = await api.pricingRequests.listForTicket(ticketId); - acceptedPricingRequestId = (pricingRequests ?? []) - .find((pr) => pr.status === 'QUOTATION_ACCEPTED')?.id ?? null; - } catch { - acceptedPricingRequestId = null; - } - // depositPercent is not sent on either branch below: the backend picks the new draft's - // percent (DepositNoticeService.createDraftFromQuotation; findGoverningQuotation, #1170). - // Sales changes it on the draft. - if (acceptedPricingRequestId != null) { - return api.pricingRequests.createDepositNoticeFromQuotation(acceptedPricingRequestId, {}); - } - return api.depositNotices.createDraft(ticketId, { - notes: defaultNotes, - }); - }, + // See createDepositNoticeDraft for the accepted-quotation vs ticket-level routing. + mutationFn: () => createDepositNoticeDraft(ticketId, noteTemplates), onSuccess: () => invalidateDepositNotices(), onError: (err) => showToast('error', err.message || 'สร้างเอกสารฉบับร่างไม่สำเร็จ'), }); diff --git a/frontend/src/features/deposits/createDepositNoticeDraft.js b/frontend/src/features/deposits/createDepositNoticeDraft.js new file mode 100644 index 000000000..04585029d --- /dev/null +++ b/frontend/src/features/deposits/createDepositNoticeDraft.js @@ -0,0 +1,30 @@ +import { api } from '../../api/index.js'; + +// One copy of "create this deal's deposit-notice draft", shared by DepositNoticePage's สร้าง button +// and the deal page's main button (owner rulings 2026-10-28). +// +// Prefer the pricing-request-chain endpoint (createDepositNoticeFromQuotation) when this deal has a +// pricing request whose customer quotation was accepted — it sources items from the accepted +// quotation and autofills the header from the customer master + ticket project. A failure ANYWHERE +// in that lookup (network error, permission edge case, no pricing requests at all — a direct-quotation +// deal) degrades to the ticket-level route; it must never break the button. +// depositPercent is not sent on either branch: the backend picks the new draft's percent +// (DepositNoticeService.createDraftFromQuotation; findGoverningQuotation, #1170). +// `noteTemplates` (already loaded) are optional. +export async function createDepositNoticeDraft(ticketId, noteTemplates) { + let acceptedPricingRequestId = null; + try { + const { items: pricingRequests } = await api.pricingRequests.listForTicket(ticketId); + acceptedPricingRequestId = (pricingRequests ?? []) + .find((pr) => pr.status === 'QUOTATION_ACCEPTED')?.id ?? null; + } catch { + acceptedPricingRequestId = null; + } + if (acceptedPricingRequestId != null) { + return api.pricingRequests.createDepositNoticeFromQuotation(acceptedPricingRequestId, {}); + } + // No templates (the deal page): omit `notes` and the backend applies the default notes. + return api.depositNotices.createDraft(ticketId, noteTemplates + ? { notes: noteTemplates.filter((t) => t.defaultSelected).map((t) => t.text) } + : {}); +} diff --git a/frontend/src/features/finance/FinanceDealPage.jsx b/frontend/src/features/finance/FinanceDealPage.jsx index 85a0fda0a..35cdc14f2 100644 --- a/frontend/src/features/finance/FinanceDealPage.jsx +++ b/frontend/src/features/finance/FinanceDealPage.jsx @@ -291,7 +291,7 @@ export function FinanceDealPage() { setFlowError(''); if (a.action === 'RECORD_INVOICE') { // ex-VAT default (the invoice is raised before VAT); fall back to the payable only for an older payload. - const gross = deal.money.amountPayableExVat ?? deal.money.amountPayable; + const gross = deal.money.amountPayable; setInvoiceForm(emptyInvoiceForm(gross != null ? String(gross) : '')); setInvoiceFileKey((k) => k + 1); } diff --git a/frontend/src/features/finance/FinanceDealPage.test.jsx b/frontend/src/features/finance/FinanceDealPage.test.jsx index 927b5a4b7..a70904985 100644 --- a/frontend/src/features/finance/FinanceDealPage.test.jsx +++ b/frontend/src/features/finance/FinanceDealPage.test.jsx @@ -360,7 +360,7 @@ describe('FinanceDealPage', () => { }); }); - describe('RECORD_INVOICE (invoice recording in-page, ex-VAT default)', () => { + describe('RECORD_INVOICE (invoice recording in-page, VAT-inclusive amount received default)', () => { const recordInvoice = () => action('RECORD_INVOICE', 'บันทึกใบกำกับ'); const closedDeal = (extra = {}) => { const deal = makeFinanceDeal({ @@ -385,13 +385,16 @@ describe('FinanceDealPage', () => { expect(screen.queryByText(/ออกค่าคอม/)).toBeNull(); }); - it('clicking it opens an in-page form (no navigation to /commissions) whose ยอดรวม defaults to the EX-VAT figure', async () => { + it('clicking it opens an in-page form (no navigation to /commissions) whose amount received defaults to the VAT-INCLUSIVE payable (QA-02)', async () => { api.finance.getDeal.mockResolvedValue({ deal: closedDeal() }); renderPage(); fireEvent.click(await screen.findByRole('button', { name: 'บันทึกใบกำกับ' })); - const gross = await screen.findByLabelText(/^ยอดรวม/); - expect(gross.value).toBe('100000'); - expect(gross.value).not.toBe('107000'); + // QA-02 (owner ruling 2026-10-28): CommissionCalculator.calculateInvoice divides by 1.07 itself, so the + // field is the amount received INCLUDING VAT; pre-filling the ex-VAT figure strips VAT twice. + const gross = await screen.findByLabelText(/ยอดรับจริง \(รวม VAT\)/); + expect(gross.value).toBe('107000'); + expect(gross.value).not.toBe('100000'); + expect(screen.queryByLabelText(/ก่อน VAT/)).toBeNull(); expect(screen.queryByText('WORKLIST-PAGE')).toBeNull(); expect(screen.getByLabelText(/เลขที่ใบกำกับ/)).not.toBeNull(); expect(screen.getByLabelText(/วันที่ใบกำกับ/)).not.toBeNull(); @@ -428,7 +431,7 @@ describe('FinanceDealPage', () => { expect(payload.ticketId).toBe(501); expect(payload.invoiceNumber).toBe('INV-0501'); expect(payload.invoiceDate).toBe('2026-09-20'); - expect(Number(payload.grossAmount)).toBe(100000); + expect(Number(payload.grossAmount)).toBe(107000); expect(Number(payload.bankFees)).toBe(50); expect(Number(payload.suspenseVat)).toBe(7000); expect(Number(payload.transportFee)).toBe(300); @@ -458,6 +461,8 @@ describe('FinanceDealPage', () => { expect(closed.getByText(/INV-0501/)).not.toBeNull(); expect(closed.getByText(/20 ก\.ย\. 2569|20\/09\/2569|2026-09-20/)).not.toBeNull(); expect(closed.getByText(/100,000\.00/)).not.toBeNull(); + expect(closed.getByText(/ยอดรับจริง \(รวม VAT\)/)).not.toBeNull(); + expect(closed.queryByText(/ก่อน VAT/)).toBeNull(); for (const v of ['50.00', '7,000.00', '300.00', '200.00', '10.00', '3,000.00', '5.00']) { expect(closed.getAllByText(new RegExp(v.replace('.', '\\.'))).length).toBeGreaterThan(0); } diff --git a/frontend/src/features/finance/FinanceMilestoneSections.jsx b/frontend/src/features/finance/FinanceMilestoneSections.jsx index 1016e2154..e315f6aff 100644 --- a/frontend/src/features/finance/FinanceMilestoneSections.jsx +++ b/frontend/src/features/finance/FinanceMilestoneSections.jsx @@ -301,7 +301,7 @@ const COMMISSION_APPROVAL = { }; const INVOICE_FIELDS = [ - ['grossAmount', 'ยอดรวม (ก่อน VAT)'], + ['grossAmount', 'ยอดรับจริง (รวม VAT)'], ['bankFees', 'ค่าธรรมเนียมธนาคาร'], ['suspenseVat', 'ภาษีพัก (Suspense VAT)'], ['transportFee', 'ค่าขนส่ง'], diff --git a/frontend/src/features/pricingRequests/PricingRequestCreateModal.jsx b/frontend/src/features/pricingRequests/PricingRequestCreateModal.jsx index 72599b76f..785088301 100644 --- a/frontend/src/features/pricingRequests/PricingRequestCreateModal.jsx +++ b/frontend/src/features/pricingRequests/PricingRequestCreateModal.jsx @@ -179,6 +179,8 @@ function emptyItemFromTicketItem(ticketItem) { // rounds up to a full box unless the rep opts out. roundToFullBox: true, originCountry: seededOrigin, + // QA-04: sales flags stock per line; seeded from the deal line. IN_THAILAND is the only value this form sets. + stockSource: ticketItem?.sourcedFromStock ? 'IN_THAILAND' : null, // GLA-125: typed name when originCountry = "อื่นๆ" — see QuotationItemRow's own comment. originCountryOther: null, leadTimeMinDays: seededLeadTime.leadTimeMinDays, @@ -316,6 +318,9 @@ function itemFromExisting(item) { piecesPerBox: item?.piecesPerBox ?? '', roundToFullBox: item?.roundToFullBox !== false, originCountry: item?.originCountry ?? '', + stockSource: item?.stockSource ?? null, + // remembered so un-ticking then re-ticking จากสต็อก restores the loaded value (IN_TRANSIT) instead of IN_THAILAND + loadedStockSource: item?.stockSource ?? null, originCountryOther: item?.originCountryOther ?? null, leadTimeMinDays: item?.leadTimeMinDays ?? null, leadTimeMaxDays: item?.leadTimeMaxDays ?? null, @@ -390,6 +395,7 @@ function pricingRequestItemInputFromRow(item) { sqmPerBox: item.sqmPerBox === '' || item.sqmPerBox == null ? null : Number(item.sqmPerBox), roundToFullBox: item.roundToFullBox !== false, originCountry: item.originCountry || null, + stockSource: item.stockSource ?? null, // GLA-125: sent as-typed; the server (PricingRequestService#resolveItem) is what clears this // when originCountry is not "อื่นๆ" — this row-level payload builder does no normalization // itself, matching how it never normalizes any other field either (server-side derivation @@ -744,7 +750,7 @@ export function PricingRequestCreateModal({ // GLA-125: ประเทศต้นทาง is required on THIS form (owner ruling 2026-09-18). ระยะเวลานำเข้า is // NOT (owner ruling 2026-10-18: import gives the time) — the origin's default still rides // along on the wire, and the field itself is hidden (hideLeadTime below). - requireOriginCountry: true, + requireOriginCountry: !item.stockSource, // QA-04: a stock line has no import origin (backend exempts it) }); // The lead-time range check still runs inside validateQuotationItem, but its error renders only in // the field hideLeadTime removes, so it could block a save with nothing red on screen. The server @@ -1056,7 +1062,7 @@ export function PricingRequestCreateModal({ // GLA-125 (owner ruling 2026-09-18): ประเทศต้นทาง (and its "อื่นๆ" typed name) is // required on THIS form only — direct-deal keeps it optional. ระยะเวลานำเข้า is not // asked here at all (owner ruling 2026-10-18: import gives the time). - requireOriginCountry + requireOriginCountry={!item.stockSource} hideLeadTime errors={itemFieldErrors[index] ?? {}} onChange={(patch) => updateItem(index, patch)} @@ -1085,6 +1091,11 @@ export function PricingRequestCreateModal({ {UNIT_BASIS_OPTIONS.map((option) => )} + ); }} diff --git a/frontend/src/features/pricingRequests/PricingRequestCreateModal.test.jsx b/frontend/src/features/pricingRequests/PricingRequestCreateModal.test.jsx index 9ed5cd262..2389e398c 100644 --- a/frontend/src/features/pricingRequests/PricingRequestCreateModal.test.jsx +++ b/frontend/src/features/pricingRequests/PricingRequestCreateModal.test.jsx @@ -221,6 +221,64 @@ describe('PricingRequestCreateModal', () => { expect(item.factory).toBe('โรงงาน A'); }); + // QA-04 (owner ruling 2026-10-28): SALES flags stock per line when creating the PCR (the CEO types the + // price, sales does not). The deal line's sourcedFromStock seeds the flag; the payload carries + // stockSource (backend PricingRequestItemRequest: IN_THAILAND | IN_TRANSIT; null = import). + describe('stock flag per line (QA-04)', () => { + const stockCheckbox = () => screen.getByRole('checkbox', { name: /จากสต็อก/ }); + const submitWith = async (overrides) => { + const { createFn } = renderModal(overrides); + fireEvent.change(screen.getByPlaceholderText('เช่น ชื่อผู้ออกแบบ หรือชื่อบริษัทผู้ซื้อ'), { target: { value: 'ผู้ออกแบบ ก.' } }); + fillRequiredFields(); + return { createFn, send: async () => { + fireEvent.click(screen.getByRole('button', { name: /ส่งให้ฝ่ายนำเข้า/ })); + await waitFor(() => expect(createFn).toHaveBeenCalledTimes(1)); + return createFn.mock.calls[0][0].items; + } }; + }; + + it('seeds the flag from the deal item sourcedFromStock, and the create payload carries stockSource', async () => { + const { send } = await submitWith({ ticketItems: [ticketItem({ sourcedFromStock: true })] }); + expect(stockCheckbox().checked).toBe(true); + const [item] = await send(); + expect(item.stockSource).toBe('IN_THAILAND'); + }); + + it('leaves an ordinary import line unflagged: stockSource is null', async () => { + const { send } = await submitWith({ ticketItems: [ticketItem({ sourcedFromStock: false })] }); + expect(stockCheckbox().checked).toBe(false); + const [item] = await send(); + expect(item.stockSource ?? null).toBeNull(); + }); + + it('lets sales tick the flag on a line the deal did not flag', async () => { + const on = await submitWith({ ticketItems: [ticketItem()] }); + fireEvent.click(stockCheckbox()); + expect((await on.send())[0].stockSource).toBe('IN_THAILAND'); + }); + + it('a stock line needs no ประเทศต้นทาง (backend exempts it); an import line still does', async () => { + const { createFn } = renderModal({ ticketItems: [ticketItem({ sourcedFromStock: true })] }); + fireEvent.change(screen.getByPlaceholderText('เช่น ชื่อผู้ออกแบบ หรือชื่อบริษัทผู้ซื้อ'), { target: { value: 'ผู้ออกแบบ ก.' } }); + fillRequiredFields(); + fireEvent.change(screen.getByLabelText(/^ประเทศต้นทาง/), { target: { value: '' } }); + fireEvent.click(screen.getByRole('button', { name: /ส่งให้ฝ่ายนำเข้า/ })); + await waitFor(() => expect(createFn).toHaveBeenCalledTimes(1)); + expect(createFn.mock.calls[0][0].items[0].originCountry).toBeNull(); + + fireEvent.click(stockCheckbox()); // back to an import line -> origin required again + fireEvent.click(screen.getByRole('button', { name: /ส่งให้ฝ่ายนำเข้า/ })); + expect(await screen.findByText('กรุณาระบุประเทศต้นทาง')).not.toBeNull(); + expect(createFn).toHaveBeenCalledTimes(1); + }); + + it('un-ticking a seeded line sends it as an import line', async () => { + const off = await submitWith({ ticketItems: [ticketItem({ sourcedFromStock: true })] }); + fireEvent.click(stockCheckbox()); + expect((await off.send())[0].stockSource ?? null).toBeNull(); + }); + }); + it('blocks an item with no model — the identity/completeness gate reuses รุ่น, not a dedicated identity field', async () => { const { createFn } = renderModal({ ticketItems: [] }); // no deal items to seed from -> one blank row fireEvent.change(screen.getByPlaceholderText('เช่น ชื่อผู้ออกแบบ หรือชื่อบริษัทผู้ซื้อ'), { target: { value: 'ผู้ออกแบบ ก.' } }); @@ -624,6 +682,27 @@ describe('PricingRequestCreateModal edit mode (Fix 2)', () => { expect(onCreated).toHaveBeenCalledTimes(1); }); + // Review finding 3: the row remembers the stockSource it was loaded with, so un-ticking and re-ticking จากสต็อก + // restores IN_TRANSIT instead of silently turning it into IN_THAILAND. (PricingRequestItemRequest has no + // expectedArrivalDate, so the arrival date is not part of the item input and is not carried here.) + it('re-ticking จากสต็อก on a line loaded as IN_TRANSIT restores IN_TRANSIT', async () => { + const updateFn = vi.fn().mockResolvedValue({}); + const initial = editInitialValue(); + initial.items[0].stockSource = 'IN_TRANSIT'; + render( + , + ); + const box = screen.getByRole('checkbox', { name: /จากสต็อก/ }); + expect(box.checked).toBe(true); + fireEvent.click(box); + fireEvent.click(box); + + fireEvent.click(screen.getByRole('button', { name: 'บันทึกการแก้ไข' })); + + await waitFor(() => expect(updateFn).toHaveBeenCalledTimes(1)); + expect(updateFn.mock.calls[0][1].items[0].stockSource).toBe('IN_TRANSIT'); + }); + // Owner request 2026-09-30: the "เงื่อนไขสำหรับใบเสนอราคา" box is gone, but the header terms it // used to edit are still persisted on the wire — editing a draft that already carries them must // NOT null them just because there is no input anymore. diff --git a/frontend/src/features/pricingRequests/PricingRequestDetailPage.jsx b/frontend/src/features/pricingRequests/PricingRequestDetailPage.jsx index ed27a51ba..4ffb8ff98 100644 --- a/frontend/src/features/pricingRequests/PricingRequestDetailPage.jsx +++ b/frontend/src/features/pricingRequests/PricingRequestDetailPage.jsx @@ -9,6 +9,7 @@ import { Icon } from '../../components/common/Icon.jsx'; import { Button } from '../../components/common/Button.jsx'; import { CollapsibleSection } from '../../components/common/CollapsibleSection.jsx'; import { ConfirmDialog } from '../../components/common/ConfirmDialog.jsx'; +import { ConfirmOrderDialog } from '../quotations/ConfirmOrderDialog.jsx'; import { FormField } from '../../components/common/FormField.jsx'; import { InfoTip } from '../../components/common/InfoTip.jsx'; import { Panel } from '../../components/common/Layout.jsx'; @@ -883,6 +884,8 @@ export function PricingRequestDetailPage({ user, showToast }) { const [emailDrafts, setEmailDrafts] = useState({}); const [receiveClientRequestIds, setReceiveClientRequestIds] = useState({}); const [confirmAction, setConfirmAction] = useState(null); + // SK-02: confirm-order is irreversible -> dialog first. + const [confirmingOrder, setConfirmingOrder] = useState(false); // Review remediation (COMMIT 5, P1 finding 3): the customer-change revision UI now reuses // PricingRequestCreateModal in mode="revision" (seeded from the current request, full item // editing, catalog picker, unit select) instead of the old inline reason-only form that copied @@ -4322,7 +4325,7 @@ export function PricingRequestDetailPage({ user, showToast }) { ลูกค้ายอมรับใบเสนอราคาแล้ว — ยืนยันคำสั่งซื้อเพื่อเริ่มขั้นตอนรับมัดจำและนำเข้าสินค้า

@@ -4363,6 +4366,13 @@ export function PricingRequestDetailPage({ user, showToast }) { ) : null} + setConfirmingOrder(false)} + onConfirm={() => { setConfirmingOrder(false); confirmOrder.mutate(); }} + /> expect(api.pricingRequests.confirmOrder).toHaveBeenCalledWith( + // SK-02: the confirm is irreversible -> a dialog first; cancel sends nothing, confirm sends once. + const dialog = await screen.findByRole('dialog', { name: 'ยืนยันคำสั่งซื้อ' }); + expect(api.pricingRequests.confirmOrder).not.toHaveBeenCalled(); + fireEvent.click(within(dialog).getByRole('button', { name: 'ยกเลิก' })); + await waitFor(() => expect(screen.queryByRole('dialog', { name: 'ยืนยันคำสั่งซื้อ' })).toBeNull()); + expect(api.pricingRequests.confirmOrder).not.toHaveBeenCalled(); + fireEvent.click(await screen.findByRole('button', { name: 'ยืนยันคำสั่งซื้อ' })); + fireEvent.click(within(await screen.findByRole('dialog', { name: 'ยืนยันคำสั่งซื้อ' })).getByRole('button', { name: 'ยืนยันคำสั่งซื้อ' })); + await waitFor(() => expect(api.pricingRequests.confirmOrder).toHaveBeenCalledTimes(1)); + expect(api.pricingRequests.confirmOrder).toHaveBeenCalledWith( request.summary.id, expect.objectContaining({ clientRequestId: expect.any(String) }), - )); + ); }); it('offers "สร้างใบแจ้งยอดเงินรับมัดจำ" (not the confirm button) once the bridge has already run', async () => { diff --git a/frontend/src/features/quotations/ConfirmOrderDialog.jsx b/frontend/src/features/quotations/ConfirmOrderDialog.jsx new file mode 100644 index 000000000..5e59d9fdf --- /dev/null +++ b/frontend/src/features/quotations/ConfirmOrderDialog.jsx @@ -0,0 +1,27 @@ +import { ConfirmDialog } from '../../components/common/ConfirmDialog.jsx'; +import { CONFIRM_ORDER_FROM_QUOTATION_COPY } from './quotationMeta.js'; + +/** + * The one confirm dialog in front of every door to the irreversible ยืนยันคำสั่งซื้อ (deal-page panel, pricing-request + * page, direct-quotation sticky CTA): same title, copy and button label everywhere. The caller owns the open state and + * what confirming does; `number` is the quotation / request code the copy names. + */ +export function ConfirmOrderDialog({ open, number, busy, onCancel, onConfirm, cancelLabel }) { + return ( + + {CONFIRM_ORDER_FROM_QUOTATION_COPY.lead(number)} {CONFIRM_ORDER_FROM_QUOTATION_COPY.stage} + {' '}{CONFIRM_ORDER_FROM_QUOTATION_COPY.tail} +

+ )} + confirmLabel="ยืนยันคำสั่งซื้อ" + {...(cancelLabel ? { cancelLabel } : {})} + busy={busy} + onCancel={onCancel} + onConfirm={onConfirm} + /> + ); +} diff --git a/frontend/src/features/quotations/QuotationEditorPage.jsx b/frontend/src/features/quotations/QuotationEditorPage.jsx index e5216d12e..3b4614191 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 @@ -3532,7 +3533,7 @@ export function QuotationEditorPage({ user, showToast }) { ); stickyPrimaryLabel = workStateAction.label; + } else if (actionKey === 'create_deposit_notice') { + // DRAFT already made: just open it; else make it (same path as DepositNoticePage's สร้าง) and open it. + stickyPrimaryAction = ( + + ); + stickyPrimaryLabel = workStateAction.label; + } else if (actionKey === 'create_remaining_invoice') { + stickyPrimaryAction = ( + + ); + stickyPrimaryLabel = workStateAction.label; } else if (actionKey === 'confirm_order') { // DealQuotationPanel now lives inside the "เอกสาร" tab (see comment above). stickyPrimaryAction = ( @@ -1878,7 +1928,7 @@ export function TicketDetailPage({ user, ticketId, onBack, showToast }) { {can.downloadRemainingInvoice && ( )} @@ -2549,7 +2599,8 @@ export function TicketDetailPage({ user, ticketId, onBack, showToast }) { want it. */} {sections.dealQuotation && canViewPricingRequests ? (
- +
) : null} {/* Legacy ticket-native quotation rows (region 12's "docs") join the @@ -2600,6 +2651,7 @@ export function TicketDetailPage({ user, ticketId, onBack, showToast }) { pricingRequests={pricingRequests} dealQuotations={dealQuotationRows} showToast={showToast} + mainButtonCreatesNotice={workStateAction?.key === 'create_deposit_notice'} /> ) : null} @@ -3063,19 +3115,13 @@ 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)} + // Close first (as the pricing-request door does): a second click on the open dialog would send twice. + onConfirm={() => { const { quotationId } = confirm; setConfirm(null); confirmOrderDirectMutation.mutate(quotationId); }} onCancel={() => setConfirm(null)} /> diff --git a/frontend/src/features/tickets/TicketDetailPage.test.jsx b/frontend/src/features/tickets/TicketDetailPage.test.jsx index ff8b39439..633977bac 100644 --- a/frontend/src/features/tickets/TicketDetailPage.test.jsx +++ b/frontend/src/features/tickets/TicketDetailPage.test.jsx @@ -126,6 +126,8 @@ vi.mock('../../api/index.js', async (importOriginal) => { // this namespace directly, same pattern as pricingRequests above. depositNotices: { listByTicket: vi.fn(), + // Direct-quotation deals: the sticky สร้างใบแจ้งยอดเงินรับมัดจำ creates the draft this way. + createDraft: vi.fn(), issue: vi.fn(), preview: vi.fn(), downloadXlsx: vi.fn(), @@ -2209,7 +2211,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 +2234,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 +2485,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())); }); }); @@ -3322,6 +3341,8 @@ describe('TicketDetailPage', () => { // { items: [] }) + a non-null paymentStatus is the "priced outside the PCR chain" shape // (salesActions.js bucket 1's own guard) — the same shape demoData.js tickets 13/14 already // have, and the same shape salesActions.test.js / workState.test.js pin at the unit level. + // Ordering ruling 2026-10-22: the remaining invoice wins over บันทึกส่งมอบ until issued — so seed it issued. + api.storedRemainingInvoices.listForTicket.mockResolvedValue({ remainingInvoices: [{ id: 901, ticketId: 701, status: 'ISSUED', docNumber: 'GLRI69001' }] }); api.tickets.get.mockResolvedValue({ ticket: buildTicket({ summary: { @@ -3342,6 +3363,7 @@ describe('TicketDetailPage', () => { await waitFor(() => expect(scrolledIds).toContain('deal-fulfilment-panel')); } finally { Element.prototype.scrollIntoView = original; + api.storedRemainingInvoices.listForTicket.mockResolvedValue({ remainingInvoices: [] }); } }); @@ -4007,7 +4029,9 @@ describe('TicketDetailPage', () => { // Finding 6 (both entry points must open the dialog, not download directly): this is one of // the two entry points — DealDocumentRegister.test.jsx pins the other. describe('remaining invoice download entry point (docActions button)', () => { + afterEach(() => { api.storedRemainingInvoices.listForTicket.mockResolvedValue({ remainingInvoices: [] }); }); it('clicking "ดาวน์โหลดใบแจ้งหนี้ส่วนที่เหลือ" opens the RemainingInvoiceDialog rather than downloading directly', async () => { + api.storedRemainingInvoices.listForTicket.mockResolvedValue({ remainingInvoices: [{ id: 901, ticketId: 701, status: 'ISSUED', docNumber: 'GLRI69001' }] }); api.tickets.get.mockResolvedValue({ ticket: buildTicket({ summary: { status: 'quotation_issued', fulfillmentStatus: 'GOODS_RECEIVED' }, @@ -4025,6 +4049,7 @@ describe('TicketDetailPage', () => { renderTicketDetailPage(salesOwnerUser); + await waitFor(() => expect(api.storedRemainingInvoices.listForTicket).toHaveBeenCalled()); const button = await screen.findByRole('button', { name: 'ดาวน์โหลดใบแจ้งหนี้ส่วนที่เหลือ' }); // Compact inline link like the quotation PDF, not a full-size bordered button. expect(button.className).not.toContain('border-border-input'); @@ -4081,14 +4106,17 @@ describe('TicketDetailPage', () => { // UI readiness only: DepositNoticeService#getRemainingInvoiceXlsx gates on ticket status alone. describe('remaining-invoice download gate', () => { const INVOICE_BUTTON = 'ดาวน์โหลดใบแจ้งหนี้ส่วนที่เหลือ'; + afterEach(() => { api.storedRemainingInvoices.listForTicket.mockResolvedValue({ remainingInvoices: [] }); }); it.each(['FROM_STOCK', 'PARTIALLY_DELIVERED', 'FULLY_DELIVERED', 'GOODS_RECEIVED'])( 'offers the download to the sales owner once fulfilment is %s', async (fulfillmentStatus) => { + api.storedRemainingInvoices.listForTicket.mockResolvedValue({ remainingInvoices: [{ id: 901, ticketId: 701, status: 'ISSUED', docNumber: 'GLRI69001' }] }); api.tickets.get.mockResolvedValueOnce({ ticket: buildTicket({ summary: { status: 'quotation_issued', fulfillmentStatus, createdById: 1 } }), }); renderTicketDetailPage(salesOwnerUser); + await waitFor(() => expect(api.storedRemainingInvoices.listForTicket).toHaveBeenCalled()); expect(await screen.findByRole('button', { name: INVOICE_BUTTON })).not.toBeNull(); }, ); @@ -4116,6 +4144,190 @@ describe('TicketDetailPage', () => { }); }); + // Owner rulings 2026-10-28: the deal page's ONE main button creates the deposit notice (ขั้น 10) + // and the remaining invoice (ขั้น 13); the panels no longer carry a second copy of either label. + describe('sticky primary — สร้างใบแจ้งยอดเงินรับมัดจำ (ขั้น 10) and สร้างใบแจ้งหนี้ (ขั้น 13), owner rulings 2026-10-28', () => { + const acceptedPr = { + id: 501, requestCode: 'PCR-2026-0501', ticketId: 701, ticketCreatedById: 1, + status: 'QUOTATION_ACCEPTED', recipientType: 'BUYER', recipientLabel: null, orderConfirmedAt: '2026-10-20T09:00:00.000Z', + }; + const nonOwnerSales = { id: 2, employeeId: 2, name: 'เซลส์อื่น', role: 'sales' }; + const primary = () => screen.queryByTestId('ticket-primary-action'); + const primaryAction = () => primary()?.getAttribute('data-action') ?? null; + const location = () => screen.getByTestId('location-probe').textContent; + + // A deal whose customer has confirmed the order (CUSTOMER_CONFIRMED) and has no notice yet. + function depositStageDeal({ prs = [acceptedPr], notices = [], availableActions = [], summary = {} } = {}) { + const state = { + lifecycle: 'ACTIVE', salesStage: 'ORDER_RECEIVED', paymentStatus: 'CUSTOMER_CONFIRMED', fulfillmentStatus: null, status: 'quotation_issued', + }; + api.tickets.get.mockResolvedValue({ ticket: buildTicket({ summary: { ...state, createdById: 1, ...summary } }) }); + api.tickets.actions.mockResolvedValue({ currentState: state, availableActions }); + api.pricingRequests.listForTicket.mockResolvedValue({ items: prs }); + api.dealQuotations.listForTicket.mockResolvedValue({ items: [] }); + api.depositNotices.listByTicket.mockResolvedValue({ depositNotices: notices }); + api.pricingRequests.createDepositNoticeFromQuotation.mockResolvedValue({ depositNotice: { id: 801, status: 'DRAFT' } }); + api.depositNotices.createDraft.mockResolvedValue({ depositNotice: { id: 801, status: 'DRAFT' } }); + } + + // A ขั้น 13 deal: goods delivered, deposit paid. + function invoiceStageDeal({ invoices = [] } = {}) { + const state = { + lifecycle: 'ACTIVE', salesStage: 'DELIVERY_SCHEDULING', paymentStatus: 'DEPOSIT_PAID', fulfillmentStatus: 'FULLY_DELIVERED', status: 'quotation_issued', + }; + api.tickets.get.mockResolvedValue({ ticket: buildTicket({ summary: { ...state, createdById: 1 } }) }); + api.tickets.actions.mockResolvedValue({ currentState: state, availableActions: [] }); + api.pricingRequests.listForTicket.mockResolvedValue({ items: [acceptedPr] }); + api.dealQuotations.listForTicket.mockResolvedValue({ items: [] }); + api.storedRemainingInvoices.listForTicket.mockResolvedValue({ remainingInvoices: invoices }); + api.tickets.remainingInvoiceOptions.mockResolvedValue({ + options: { + docNumber: 'GLRI69001', defaultIssueDate: '2026-09-01', defaultReference: null, + referenceOptions: [], defaultDepositReference: null, depositReferenceOptions: [], + noteTemplates: [], itemCount: 1, maxItems: 22, itemsTotal: 100, depositAmount: 0, + netAmount: 100, vatAmount: 7, totalPayable: 107, + quotationOptions: [], defaultQuotationId: null, blockingReason: null, + }, + }); + } + + // Later describes read these bare (a rejected query) — keep it that way. + afterEach(() => { + api.dealQuotations.listForTicket.mockReset(); + api.storedRemainingInvoices.listForTicket.mockReset(); + api.storedRemainingInvoices.listForTicket.mockResolvedValue({ remainingInvoices: [] }); + }); + + it('ขั้น 10, order-confirmed accepted PR, no notice: the main button is สร้างใบแจ้งยอดเงินรับมัดจำ; clicking creates it from the quotation and opens /deposit', async () => { + depositStageDeal(); + renderTicketDetailPageAtRoute(['/tickets/701'], salesOwnerUser); + await waitFor(() => expect(primaryAction()).toBe('create_deposit_notice')); + expect(primary().textContent).toContain('สร้างใบแจ้งยอดเงินรับมัดจำ'); + + fireEvent.click(primary()); + + await waitFor(() => expect(api.pricingRequests.createDepositNoticeFromQuotation).toHaveBeenCalledWith(501, {})); + await waitFor(() => expect(location()).toBe('/tickets/701/deposit')); + expect(api.depositNotices.createDraft).not.toHaveBeenCalled(); + }); + + it('the legacy ลูกค้ายืนยัน re-confirm (CONFIRM_CUSTOMER advertised) does not take the slot at CUSTOMER_CONFIRMED', async () => { + depositStageDeal({ availableActions: [{ action: 'CONFIRM_CUSTOMER', kind: 'operational', label: 'ลูกค้ายืนยัน' }] }); + renderTicketDetailPageAtRoute(['/tickets/701'], salesOwnerUser); + // The advertised action must have loaded, or the absence below proves nothing. + await waitFor(() => expect(api.tickets.actions).toHaveBeenCalled()); + await stickyInputsSettled(); + await waitFor(() => expect(primaryAction()).toBe('create_deposit_notice')); + expect(screen.queryByTestId('ticket-detail-confirm-customer')).toBeNull(); + }); + + it('a direct-quotation deal (no pricing requests): the click creates the draft by ticket (depositNotices.createDraft), not the PR endpoint, then opens /deposit', async () => { + // Real post-confirm flow-A shape: the direct quotation stays APPROVED + live, and the rep may revise it. + depositStageDeal({ prs: [], summary: { liveDirectQuotation: { id: 77, number: 'QT-2026-0077-1', docStatus: 'APPROVED', recipientType: 'DESIGNER' } } }); + api.dealQuotations.listForTicket.mockResolvedValue({ + items: [{ id: 77, number: 'QT-2026-0077-1', origin: 'DEAL_DIRECT', docStatus: 'APPROVED', recipientType: 'DESIGNER', salesRepId: 1, revisionNo: 1, parentQuotationId: null, pricingRequestId: null }], + }); + renderTicketDetailPageAtRoute(['/tickets/701'], salesOwnerUser); + await waitFor(() => expect(primaryAction()).toBe('create_deposit_notice')); + + fireEvent.click(primary()); + + await waitFor(() => expect(api.depositNotices.createDraft).toHaveBeenCalledWith(701, expect.any(Object))); + await waitFor(() => expect(location()).toBe('/tickets/701/deposit')); + expect(api.pricingRequests.createDepositNoticeFromQuotation).not.toHaveBeenCalled(); + }); + + it('a DRAFT notice already exists: the button reads ออกใบแจ้งยอดเงินรับมัดจำ and only opens /deposit — nothing is created', async () => { + depositStageDeal({ notices: [{ id: 801, status: 'DRAFT', ticketId: 701, docNumber: null }] }); + renderTicketDetailPageAtRoute(['/tickets/701'], salesOwnerUser); + await waitFor(() => expect(primaryAction()).toBe('create_deposit_notice')); + await waitFor(() => expect(primary().textContent).toContain('ออกใบแจ้งยอดเงินรับมัดจำ')); + + fireEvent.click(primary()); + + await waitFor(() => expect(location()).toBe('/tickets/701/deposit')); + expect(api.pricingRequests.createDepositNoticeFromQuotation).not.toHaveBeenCalled(); + expect(api.depositNotices.createDraft).not.toHaveBeenCalled(); + }); + + it('a DRAFT made elsewhere after the page loaded (stale cache): the click opens it — nothing is created', async () => { + depositStageDeal(); + renderTicketDetailPageAtRoute(['/tickets/701'], salesOwnerUser); + await waitFor(() => expect(primaryAction()).toBe('create_deposit_notice')); + await waitFor(() => expect(api.depositNotices.listByTicket).toHaveBeenCalled()); + api.depositNotices.listByTicket.mockResolvedValue({ depositNotices: [{ id: 801, status: 'DRAFT', ticketId: 701, docNumber: null }] }); + + fireEvent.click(primary()); + + await waitFor(() => expect(location()).toBe('/tickets/701/deposit')); + expect(api.pricingRequests.createDepositNoticeFromQuotation).not.toHaveBeenCalled(); + expect(api.depositNotices.createDraft).not.toHaveBeenCalled(); + }); + + it('a sales rep who does NOT own the deal gets no create_deposit_notice main button (the backend refuses non-owners)', async () => { + depositStageDeal(); + renderTicketDetailPageAtRoute(['/tickets/701'], nonOwnerSales); + await stickyInputsSettled(); + await screen.findByRole('heading', { level: 1 }); + expect(primaryAction()).not.toBe('create_deposit_notice'); + }); + + it('การเงิน tab: while the main button offers สร้างใบแจ้งยอดเงินรับมัดจำ the deposit panel has no second copy (deal-deposit-create-notice)', async () => { + depositStageDeal(); + renderTicketDetailPageAtRoute(['/tickets/701'], salesOwnerUser); + await waitFor(() => expect(primaryAction()).toBe('create_deposit_notice')); + await openTab(/การเงิน/); + await screen.findByRole('heading', { level: 2, name: 'มัดจำ' }); + await stickyInputsSettled(); + expect(screen.queryByTestId('deal-deposit-create-notice')).toBeNull(); + expect(screen.getAllByRole('button', { name: /สร้างใบแจ้งยอดเงินรับมัดจำ/ })).toHaveLength(1); + }); + + it('เอกสาร tab: after the order is confirmed the quotation panel points at the main button, not at the "มัดจำ" section below', async () => { + depositStageDeal(); + renderTicketDetailPageAtRoute(['/tickets/701'], salesOwnerUser); + await openTab(/เอกสาร/); + const panel = within(await screen.findByTestId('deal-quotation-panel')); + expect(await panel.findByText(/ยืนยันคำสั่งซื้อแล้วเมื่อ/)).not.toBeNull(); + expect(panel.queryByText(/ส่วน "มัดจำ"/)).toBeNull(); + expect(panel.getByText(/สร้างใบแจ้งยอดเงินรับมัดจำ/)).not.toBeNull(); + }); + + it('เอกสาร tab: at a later stage (notice already out) the main button no longer offers it, so the quotation panel keeps the neutral copy pointing at the "การเงิน" tab', async () => { + depositStageDeal({ summary: { paymentStatus: 'DEPOSIT_NOTICE_ISSUED' } }); + renderTicketDetailPageAtRoute(['/tickets/701'], salesOwnerUser); + await stickyInputsSettled(); + await openTab(/เอกสาร/); + const panel = within(await screen.findByTestId('deal-quotation-panel')); + expect(await panel.findByText(/ยืนยันคำสั่งซื้อแล้วเมื่อ/)).not.toBeNull(); + expect(panel.getByText(/ใบแจ้งยอดเงินรับมัดจำอยู่ที่แท็บ "การเงิน"/)).not.toBeNull(); + expect(panel.queryByText(/ด้านบน/)).toBeNull(); + }); + + it('ขั้น 13, ready deal, no remaining invoice: the main button is สร้างใบแจ้งหนี้ and opens the RemainingInvoiceDialog; the stage link reads สร้างใบแจ้งหนี้ส่วนที่เหลือ', async () => { + invoiceStageDeal(); + renderTicketDetailPageAtRoute(['/tickets/701'], salesOwnerUser); + await waitFor(() => expect(primaryAction()).toBe('create_remaining_invoice')); + expect(primary().textContent).toContain('สร้างใบแจ้งหนี้'); + expect(screen.getByRole('button', { name: 'สร้างใบแจ้งหนี้ส่วนที่เหลือ' })).not.toBeNull(); + expect(screen.queryByRole('button', { name: 'ดาวน์โหลดใบแจ้งหนี้ส่วนที่เหลือ' })).toBeNull(); + + fireEvent.click(primary()); + + expect(await screen.findByTestId('remaining-invoice-dialog')).not.toBeNull(); + }); + + it('ขั้น 13 with an ISSUED remaining invoice: no create_remaining_invoice main button, and the link reads ดาวน์โหลดใบแจ้งหนี้ส่วนที่เหลือ', async () => { + invoiceStageDeal({ invoices: [{ id: 901, ticketId: 701, status: 'ISSUED', docNumber: 'GLRI69001' }] }); + renderTicketDetailPageAtRoute(['/tickets/701'], salesOwnerUser); + expect(await screen.findByRole('button', { name: 'ดาวน์โหลดใบแจ้งหนี้ส่วนที่เหลือ' })).not.toBeNull(); + await waitFor(() => expect(api.storedRemainingInvoices.listForTicket).toHaveBeenCalled()); + await stickyInputsSettled(); + expect(primaryAction()).not.toBe('create_remaining_invoice'); + expect(screen.queryByRole('button', { name: 'สร้างใบแจ้งหนี้ส่วนที่เหลือ' })).toBeNull(); + }); + }); + // Slice 2 — flow B + the direct-quotation CTAs (SLICE-2-FLOW-A.md §D/§E). The ใบเสนอราคา panel // offers the two routes as sibling buttons — never a dialog (DESIGN.md §16) — and the sticky CTA // follows a live direct quotation (TicketSummaryDto.liveDirectQuotation, S2-B4). @@ -4218,6 +4430,20 @@ describe('TicketDetailPage', () => { await waitFor(() => expect(showToast).toHaveBeenCalledWith('success', 'ยืนยันคำสั่งซื้อแล้ว')); }); + it('a double-click on the dialog\'s confirm sends ONE confirm request (VW-05), same as the pricing-request door', async () => { + activeDeal({ liveDirectQuotation: { ...DRAFT_LIVE, docStatus: 'APPROVED' } }); + api.dealQuotations.promoteToDeal.mockReturnValue(new Promise(() => {})); // in flight: never settles + renderTicketDetailPage(salesOwnerUser); + await waitFor(() => expect(screen.getByTestId('ticket-primary-action').getAttribute('data-action')).toBe('confirm_order_direct')); + fireEvent.click(screen.getByTestId('ticket-primary-action')); + const confirmBtn = within(screen.getByRole('dialog')).getByRole('button', { name: 'ยืนยันคำสั่งซื้อ' }); + fireEvent.click(confirmBtn); + fireEvent.click(confirmBtn); + await waitFor(() => expect(api.dealQuotations.promoteToDeal).toHaveBeenCalled()); + await new Promise((r) => setTimeout(r, 50)); // let a second request surface + expect(api.dealQuotations.promoteToDeal).toHaveBeenCalledTimes(1); + }); + it('a refused confirm surfaces the server\'s own Thai message', async () => { activeDeal({ liveDirectQuotation: { ...DRAFT_LIVE, docStatus: 'APPROVED' } }); api.dealQuotations.promoteToDeal.mockRejectedValue(Object.assign(new Error('ดีลไม่ได้อยู่ในสถานะ ACTIVE'), { status: 409 })); diff --git a/frontend/src/features/tickets/salesActions.js b/frontend/src/features/tickets/salesActions.js index 3ba298504..2118adb62 100644 --- a/frontend/src/features/tickets/salesActions.js +++ b/frontend/src/features/tickets/salesActions.js @@ -51,6 +51,7 @@ import { bangkokTodayIso } from '../../utils/format.js'; import { DEAD_PRICING_REQUEST_STATUSES, QUOTATION_RECIPIENT_OPTIONS } from '../quotations/quotationMeta.js'; import { nextFulfilmentActionCode } from './importActions.js'; +import { isRemainingInvoiceReady } from './remainingInvoiceReadiness.js'; export const SALES_ACTION = { // Slice 2 — flow A (SLICE-2-FLOW-A.md §E): the three buckets a LIVE direct quotation drives. @@ -69,6 +70,9 @@ export const SALES_ACTION = { SUBMIT_PRICING_REQUEST: 'submit_pricing_request', ISSUE_QUOTATION: 'issue_quotation', CONFIRM_ORDER: 'confirm_order', + // Owner rulings 2026-10-28: the deal page's main button makes the ขั้น 10 deposit notice and the ขั้น 13 invoice. + CREATE_DEPOSIT_NOTICE: 'create_deposit_notice', + CREATE_REMAINING_INVOICE: 'create_remaining_invoice', RECORD_QUOTATION_OUTCOME: 'record_quotation_outcome', RECORD_DELIVERY: 'record_delivery', FOLLOW_UP: 'follow_up', @@ -86,6 +90,8 @@ const ACTION_LABEL = { [SALES_ACTION.ADVANCE_STAGE]: 'เลื่อนขั้น', [SALES_ACTION.REVISE_QUOTATION]: 'แก้ใบเสนอราคา', [SALES_ACTION.CONFIRM_ORDER]: 'ยืนยันคำสั่งซื้อ', + [SALES_ACTION.CREATE_DEPOSIT_NOTICE]: 'สร้างใบแจ้งยอดเงินรับมัดจำ', + [SALES_ACTION.CREATE_REMAINING_INVOICE]: 'สร้างใบแจ้งหนี้', // "Record the quotation outcome" — deliberately not "ติดตามผล"/"ติดตามลูกค้า" (that's // FOLLOW_UP's own label, a nudge to go chase the customer): by the time this bucket fires, the // quotation is already out and canRecordCustomerQuotationOutcome's own gate is what the button @@ -121,6 +127,8 @@ const ACTION_RANK = { [SALES_ACTION.CONFIRM_ORDER]: 1, [SALES_ACTION.QUOTE_NEXT_PARTY]: 1, [SALES_ACTION.CONFIRM_ORDER_DIRECT]: 1, + [SALES_ACTION.CREATE_DEPOSIT_NOTICE]: 1, + [SALES_ACTION.CREATE_REMAINING_INVOICE]: 1, [SALES_ACTION.ISSUE_QUOTATION]: 2, [SALES_ACTION.SUBMIT_DIRECT_QUOTATION]: 2, [SALES_ACTION.RECORD_QUOTATION_OUTCOME]: 3, @@ -180,6 +188,9 @@ const LIVE_PR_STATUSES = new Set(['DRAFT', 'CANCELLED', 'SUPERSEDED']); // pricingRequestMeta's BRIDGEABLE_TICKET_STATUSES). const BRIDGEABLE_TICKET_STATUSES = new Set(['draft', 'quotation_issued']); +// No deposit notice is made for these (deposit policy). +const DEPOSIT_NOT_NEEDED = new Set(['NOT_REQUIRED', 'WAIVED', 'CREDIT_CUSTOMER']); + const QUOTED_STATUSES = new Set(['quotation_issued', 'document_issued']); const PRE_SPEC_STAGES = new Set(['LEAD_APPROACH', 'PRESENTATION']); @@ -286,7 +297,7 @@ function routeChoiceAction(ownPrs, quotationOf, offRouteParties) { * while the route is unknown. `quotationByRequest`: a Map, pricingRequestId -> the newest * PRICING_REQUEST-origin quotation `{ id, number, docStatus }` — only the deal page has it. */ -export function nextSalesAction(deal, pricingRequests = [], { reviseTarget = null, offRouteParties = null, quotationByRequest = null } = {}) { +export function nextSalesAction(deal, pricingRequests = [], { reviseTarget = null, offRouteParties = null, quotationByRequest = null, depositNotice = null, remainingInvoice } = {}) { if (!deal || deal.lifecycle !== 'ACTIVE') return null; // 0. Slice 2 — flow A (SLICE-2-FLOW-A.md §E, IA §4): a LIVE direct quotation. Checked BEFORE @@ -305,7 +316,10 @@ export function nextSalesAction(deal, pricingRequests = [], { reviseTarget = nul if (directAction && !confirmRefused) return directAction; // PR A: an APPROVED direct quotation the confirm bucket refuses (deal already past 'draft') is // revised rather than left to fall through to CREATE_PCR. - if (confirmRefused && reviseTarget) return reviseAction(reviseTarget); + // Owner rulings 2026-10-28: a confirmed flow-A deal keeps its direct quotation APPROVED + live, so + // the deposit notice must outrank this revise. + const depositAction = depositNoticeAction(deal, depositNotice); + if (confirmRefused && reviseTarget) return depositAction ?? reviseAction(reviseTarget); // Owner ruling 2026-10-15: ขั้น 1 → 2 → 3 are moved by hand, and the price request starts at ขั้น 3. // So on ขั้น 1–2 the one action is moving the stage — nothing below (a due follow-up, an older @@ -391,6 +405,9 @@ export function nextSalesAction(deal, pricingRequests = [], { reviseTarget = nul return { key: SALES_ACTION.CONFIRM_ORDER, label: ACTION_LABEL[SALES_ACTION.CONFIRM_ORDER] }; } + // Owner rulings 2026-10-28: ขั้น 10, the customer confirmed the order -> make the deposit notice. + if (depositAction) return depositAction; + // A live request's quotation awaiting ผจก.ขาย/CEO: the rep waits. const pending = pendingRequestQuotation(deal, ownPrs, quotationByRequest); if (pending) { @@ -469,6 +486,16 @@ export function nextSalesAction(deal, pricingRequests = [], { reviseTarget = nul // below (see the delivery-vs-bucket-1 case in salesActions.test.js) and // already exercised, before this bucket existed, by workState.test.js's // 'still offers create_pcr after ... FROM_STOCK' case. + // Owner rulings 2026-10-28: ขั้น 13 — the invoice is made at นัดส่งสินค้า, before บันทึกส่งมอบ, until it is + // issued. `remainingInvoice` undefined = the caller (dashboard worklist) doesn't know -> never offered. + if (remainingInvoice !== undefined && isRemainingInvoiceReady(deal) + && (remainingInvoice === null || remainingInvoice.status === 'DRAFT')) { + return { + key: SALES_ACTION.CREATE_REMAINING_INVOICE, + label: remainingInvoice ? 'ออกใบแจ้งหนี้' : ACTION_LABEL[SALES_ACTION.CREATE_REMAINING_INVOICE], + }; + } + if (nextFulfilmentActionCode(deal) === 'recordDelivery') { return { key: SALES_ACTION.RECORD_DELIVERY, label: ACTION_LABEL[SALES_ACTION.RECORD_DELIVERY] }; } @@ -490,6 +517,15 @@ export function nextSalesAction(deal, pricingRequests = [], { reviseTarget = nul return null; } +/** Gated on paymentStatus only: a direct-quotation deal has no pricing request. */ +function depositNoticeAction(deal, depositNotice) { + if (deal.paymentStatus !== 'CUSTOMER_CONFIRMED' || DEPOSIT_NOT_NEEDED.has(deal.depositPolicy)) return null; + return { + key: SALES_ACTION.CREATE_DEPOSIT_NOTICE, + label: depositNotice?.status === 'DRAFT' ? 'ออกใบแจ้งยอดเงินรับมัดจำ' : ACTION_LABEL[SALES_ACTION.CREATE_DEPOSIT_NOTICE], + }; +} + export const REVISE_STAGES = new Set(['QUOTE_OWNER', 'QUOTE_BUYER']); /** `target` = { quotationId, number, openDraftId? }. With an open revision draft the action points diff --git a/frontend/src/features/tickets/salesActions.test.js b/frontend/src/features/tickets/salesActions.test.js index c49ab7694..15633ad62 100644 --- a/frontend/src/features/tickets/salesActions.test.js +++ b/frontend/src/features/tickets/salesActions.test.js @@ -663,4 +663,70 @@ describe('nextSalesAction — the route choice once a designer/owner quotation i ]).map((item) => item.deal.id); expect(sorted).toEqual([4, 3, 1, 2]); }); -}); \ No newline at end of file +}); + +// Owner rulings 2026-10-28: the deal page's main button creates the deposit notice (ขั้น 10) and the +// remaining invoice (ขั้น 13). `depositNotice` / `remainingInvoice` are page-only options; an +// absent `remainingInvoice` (the dashboard worklist) never offers the invoice. +describe('nextSalesAction — สร้างใบแจ้งยอดเงินรับมัดจำ / สร้างใบแจ้งหนี้ (owner rulings 2026-10-28)', () => { + const confirmedDeal = (overrides = {}) => baseDeal({ status: 'quotation_issued', paymentStatus: 'CUSTOMER_CONFIRMED', ...overrides }); + const CREATE_DEPOSIT = { key: 'create_deposit_notice', label: 'สร้างใบแจ้งยอดเงินรับมัดจำ' }; + + it('ขั้น 10, pricing-request flow (CUSTOMER_CONFIRMED, order-confirmed accepted PR) -> สร้างใบแจ้งยอดเงินรับมัดจำ', () => { + expect(nextSalesAction(confirmedDeal(), [orderConfirmedPr()])).toEqual(CREATE_DEPOSIT); + expect(nextSalesAction(confirmedDeal({ depositPolicy: 'REQUIRED' }), [orderConfirmedPr()])).toEqual(CREATE_DEPOSIT); + }); + + it('ขั้น 10, direct-quotation flow (no pricing requests) -> the same action: the gate is paymentStatus, not a PR', () => { + expect(nextSalesAction(confirmedDeal({ liveDirectQuotation: null }), [])).toEqual(CREATE_DEPOSIT); + }); + + it('ขั้น 10, flow A after confirm: the direct quotation stays APPROVED + live and a reviseTarget exists -> still สร้างใบแจ้งยอดเงินรับมัดจำ, not แก้ใบเสนอราคา', () => { + const deal = confirmedDeal({ liveDirectQuotation: { id: 77, number: 'QT-2026-0077-1', docStatus: 'APPROVED', recipientType: 'DESIGNER' } }); + expect(nextSalesAction(deal, [], { reviseTarget: { quotationId: 77, number: 'QT-2026-0077-1' } })).toEqual(CREATE_DEPOSIT); + }); + + it('a DRAFT notice already exists -> ออกใบแจ้งยอดเงินรับมัดจำ (same key)', () => { + expect(nextSalesAction(confirmedDeal(), [orderConfirmedPr()], { depositNotice: { status: 'DRAFT' } })) + .toEqual({ key: 'create_deposit_notice', label: 'ออกใบแจ้งยอดเงินรับมัดจำ' }); + }); + + it.each(['NOT_REQUIRED', 'WAIVED', 'CREDIT_CUSTOMER'])('depositPolicy %s -> no deposit-notice action', (depositPolicy) => { + expect(nextSalesAction(confirmedDeal({ depositPolicy }), [orderConfirmedPr()])?.key).not.toBe('create_deposit_notice'); + }); + + it.each(['DEPOSIT_NOTICE_ISSUED', 'DEPOSIT_PAID'])('paymentStatus %s (notice already out) -> no deposit-notice action', (paymentStatus) => { + expect(nextSalesAction(confirmedDeal({ paymentStatus }), [orderConfirmedPr()])?.key).not.toBe('create_deposit_notice'); + }); + + it('regression: ยืนยันคำสั่งซื้อ still wins while an accepted PR has no orderConfirmedAt', () => { + const pr = { id: 1, ticketId: 1, status: 'QUOTATION_ACCEPTED', orderConfirmedAt: null }; + expect(nextSalesAction(confirmedDeal(), [pr])).toMatchObject({ key: SALES_ACTION.CONFIRM_ORDER }); + }); + + // ขั้น 13 + const readyDeal = (overrides = {}) => baseDeal({ status: 'quotation_issued', fulfillmentStatus: 'FULLY_DELIVERED', paymentStatus: 'DEPOSIT_PAID', ...overrides }); + + it('ขั้น 13, ready deal with no remaining invoice -> สร้างใบแจ้งหนี้; a DRAFT one -> ออกใบแจ้งหนี้', () => { + expect(nextSalesAction(readyDeal(), [orderConfirmedPr()], { remainingInvoice: null })) + .toEqual({ key: 'create_remaining_invoice', label: 'สร้างใบแจ้งหนี้' }); + expect(nextSalesAction(readyDeal(), [orderConfirmedPr()], { remainingInvoice: { status: 'DRAFT' } })) + .toEqual({ key: 'create_remaining_invoice', label: 'ออกใบแจ้งหนี้' }); + }); + + it('an ISSUED remaining invoice, or the option omitted (dashboard worklist), -> no create_remaining_invoice', () => { + expect(nextSalesAction(readyDeal(), [orderConfirmedPr()], { remainingInvoice: { status: 'ISSUED' } })?.key).not.toBe('create_remaining_invoice'); + expect(nextSalesAction(readyDeal(), [orderConfirmedPr()])?.key).not.toBe('create_remaining_invoice'); + }); + + it('ordering (ruling 2026-10-22): the invoice is generated at นัดส่งสินค้า, so it wins over บันทึกส่งมอบ until it is issued', () => { + const deal = readyDeal({ fulfillmentStatus: 'GOODS_RECEIVED' }); + expect(nextSalesAction(deal, [orderConfirmedPr()], { remainingInvoice: null })).toMatchObject({ key: 'create_remaining_invoice' }); + expect(nextSalesAction(deal, [orderConfirmedPr()], { remainingInvoice: { status: 'ISSUED' } })).toMatchObject({ key: SALES_ACTION.RECORD_DELIVERY }); + }); + + it('a deal not yet ready (SHIPPING) -> no create_remaining_invoice', () => { + expect(nextSalesAction(readyDeal({ fulfillmentStatus: 'SHIPPING' }), [orderConfirmedPr()], { remainingInvoice: null })?.key) + .not.toBe('create_remaining_invoice'); + }); +}); diff --git a/frontend/src/features/tickets/workState.js b/frontend/src/features/tickets/workState.js index c25876851..bc34dc8bf 100644 --- a/frontend/src/features/tickets/workState.js +++ b/frontend/src/features/tickets/workState.js @@ -71,7 +71,7 @@ export const DIRECT_APPROVAL_ACTION_KEY = 'approve_direct_quotation'; * The same two apply to a PRICING_REQUEST-origin quotation awaiting approval (owner ruling * 2026-10-27), read from `quotationByRequest` (see nextSalesAction). */ -export function resolveWorkState(user, deal, pricingRequests = [], { reviseTarget = null, offRouteParties = null, quotationByRequest = null } = {}) { +export function resolveWorkState(user, deal, pricingRequests = [], { reviseTarget = null, offRouteParties = null, quotationByRequest = null, depositNotice = null, remainingInvoice } = {}) { const role = user?.role; if (!deal || deal.lifecycle !== 'ACTIVE') return { action: null }; @@ -97,7 +97,7 @@ export function resolveWorkState(user, deal, pricingRequests = [], { reviseTarge } } - const action = role === 'sales' ? nextSalesAction(deal, pricingRequests, { reviseTarget, offRouteParties, quotationByRequest }) + const action = role === 'sales' ? nextSalesAction(deal, pricingRequests, { reviseTarget, offRouteParties, quotationByRequest, depositNotice, remainingInvoice }) : role === 'import' ? nextImportAction(deal, pricingRequests) : role === 'account' ? nextAccountAction(deal) : null; @@ -138,11 +138,11 @@ function quoteNextPartyBanner({ anchorParty, anchorDeclined, parties }) { * is resolved by the caller through quotationMeta.canReviseDealQuotation (owner + status live * there). Skipped when revise is already the primary (approved direct quotation past draft). */ -export function secondaryWorkActions(user, deal, pricingRequests = [], { reviseTarget = null, offRouteParties = null, quotationByRequest = null } = {}) { +export function secondaryWorkActions(user, deal, pricingRequests = [], { reviseTarget = null, offRouteParties = null, quotationByRequest = null, depositNotice = null, remainingInvoice } = {}) { if (user?.role !== 'sales' || !deal || deal.lifecycle !== 'ACTIVE') return []; const outcome = outcomeSecondaryAction(deal, pricingRequests, quotationByRequest); const revise = reviseTarget && REVISE_STAGES.has(deal.salesStage) - && resolveWorkState(user, deal, pricingRequests, { reviseTarget, offRouteParties, quotationByRequest }).action?.key !== SALES_ACTION.REVISE_QUOTATION + && resolveWorkState(user, deal, pricingRequests, { reviseTarget, offRouteParties, quotationByRequest, depositNotice, remainingInvoice }).action?.key !== SALES_ACTION.REVISE_QUOTATION ? reviseAction(reviseTarget) : null; return [outcome, revise].filter(Boolean); } diff --git a/frontend/src/features/tickets/workState.test.js b/frontend/src/features/tickets/workState.test.js index 0b333d81f..a48470f99 100644 --- a/frontend/src/features/tickets/workState.test.js +++ b/frontend/src/features/tickets/workState.test.js @@ -472,3 +472,23 @@ describe('resolveWorkState — the route-choice line and the approval wait (owne expect(result.action).toMatchObject({ label: 'อนุมัติใบเสนอราคา', to: '/quotations/9105' }); }); }); + + +describe('resolveWorkState — forwards depositNotice / remainingInvoice to nextSalesAction (owner rulings 2026-10-28)', () => { + const deal = baseDeal({ status: 'quotation_issued', paymentStatus: 'CUSTOMER_CONFIRMED' }); + const prs = [{ id: 1, ticketId: 1, status: 'QUOTATION_ACCEPTED', orderConfirmedAt: '2026-10-01T00:00:00Z' }]; + + it('sales: a DRAFT depositNotice reaches nextSalesAction (ออกใบแจ้งยอดเงินรับมัดจำ); without it, สร้าง...', () => { + expect(resolveWorkState({ role: 'sales' }, deal, prs).action).toMatchObject({ key: 'create_deposit_notice', label: 'สร้างใบแจ้งยอดเงินรับมัดจำ' }); + expect(resolveWorkState({ role: 'sales' }, deal, prs, { depositNotice: { status: 'DRAFT' } }).action) + .toMatchObject({ key: 'create_deposit_notice', label: 'ออกใบแจ้งยอดเงินรับมัดจำ' }); + }); + + it('sales: remainingInvoice reaches nextSalesAction (null -> สร้างใบแจ้งหนี้, ISSUED -> not offered)', () => { + const ready = baseDeal({ status: 'quotation_issued', fulfillmentStatus: 'FULLY_DELIVERED', paymentStatus: 'DEPOSIT_PAID' }); + expect(resolveWorkState({ role: 'sales' }, ready, prs, { remainingInvoice: null }).action) + .toMatchObject({ key: 'create_remaining_invoice', label: 'สร้างใบแจ้งหนี้' }); + expect(resolveWorkState({ role: 'sales' }, ready, prs, { remainingInvoice: { status: 'ISSUED' } }).action?.key) + .not.toBe('create_remaining_invoice'); + }); +}); diff --git a/render.yaml b/render.yaml index 91d70e5e8..9f629265b 100644 --- a/render.yaml +++ b/render.yaml @@ -40,7 +40,7 @@ services: name: gl-r-erp runtime: image image: - url: ghcr.io/waritctd/glr-hr-backend:v2026-10-28 + url: ghcr.io/waritctd/glr-hr-backend:v2026-10-29 creds: fromRegistryCreds: name: ghcr-glr