diff --git a/backend/src/main/java/th/co/glr/hr/dealquotation/DealQuotationRenderAdapter.java b/backend/src/main/java/th/co/glr/hr/dealquotation/DealQuotationRenderAdapter.java index 34e069bb0..03c6bc36a 100644 --- a/backend/src/main/java/th/co/glr/hr/dealquotation/DealQuotationRenderAdapter.java +++ b/backend/src/main/java/th/co/glr/hr/dealquotation/DealQuotationRenderAdapter.java @@ -37,6 +37,11 @@ private DealQuotationRenderAdapter() {} * what the accepted PDF promised. */ public static final int DEFAULT_DEPOSIT_PERCENT = 30; + /** The percent a new-engine quotation bills and prints: a blank one means {@link #DEFAULT_DEPOSIT_PERCENT}. */ + public static int effectiveDepositPercent(Integer stored) { + return stored != null ? stored : DEFAULT_DEPOSIT_PERCENT; + } + /** Overload for callers with no configured bank block — the English document then prints the * proforma-invoice line instead. Kept so tests and any hand-wired caller need not thread a * config value they do not care about. */ 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 14e0f4871..cfc5f9f97 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 @@ -1711,6 +1711,16 @@ 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() + " ได้รับอนุมัติแล้ว"; @@ -2107,9 +2117,10 @@ private String outcomeLabel(String outcome) { *
  • {@code sales.ticket_item} replaced by the quotation's own goods lines (see * {@link #ticketItemsFromQuotation}) — the delivery machinery's quantity source; the * quotation is the agreed order, so it replaces any preliminary lines the deal carried;
  • - *
  • {@code TicketService#confirmCustomerForPromotedQuotation} — the unmodified - * confirmCustomer body: {@code payment_status = CUSTOMER_CONFIRMED}, stage auto-advanced to - * {@code ORDER_RECEIVED}.
  • + *
  • {@code TicketService#confirmCustomerForPromotedQuotation} — confirmCustomer's shared + * body: {@code payment_status = CUSTOMER_CONFIRMED}, the deposit policy following this + * quotation's percent ({@code TicketService#followQuotationDepositPolicy}), stage + * auto-advanced to {@code ORDER_RECEIVED}.
  • * * *

    Order of checks: quotation exists on this engine (404 — a LEGACY @@ -2178,7 +2189,8 @@ public PromoteToDealResultDto confirmOrderFromDirectQuotation(long quotationId, // or stock reservation exists — both key on quotation_issued or later), and the only other // reference to a ticket_item, pricing_request_item.source_ticket_item_id, is ON DELETE SET NULL. tickets.replaceItems(ticketId, ticketItemsFromQuotation(fresh)); - ticketService.confirmCustomerForPromotedQuotation(ticketId, actor); + ticketService.confirmCustomerForPromotedQuotation(ticketId, fresh.number(), + DealQuotationRenderAdapter.effectiveDepositPercent(fresh.depositPercent()), actor); return promotionResult(ticketId, quotationId); } 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 35576c6e6..b042d76a1 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 @@ -105,6 +105,12 @@ public DepositNoticeDto createDraftFromQuotation(long ticketId, DepositNoticeDra requireTicketOwner(ticketId, actor); TicketSummaryDto s = requireApprovedTicket(ticketId, actor); requireActiveLifecycle(s); + // Both routes: a deal off the deposit path (a ไม่รับมัดจำ quotation, a waiver, a credit + // customer) has no deposit to bill, and issue() would refuse the draft anyway. + if (DepositPolicy.bypassesDepositNotice(s.depositPolicy())) { + throw new ApiException(HttpStatus.CONFLICT, "ดีลนี้ไม่ต้องเก็บมัดจำ (นโยบายมัดจำ " + + s.depositPolicy() + ") จึงสร้างใบแจ้งรับมัดจำไม่ได้"); + } // Ticket route: the governing quotation (DepositNoticeRepository#findGoverningQuotation) // always supplies the percent. It supplies the lines, and with them the reference, unless @@ -223,8 +229,8 @@ public DepositNoticeDto issue(long docId, UserPrincipal actor) { // not left to fall through to advancePaymentStatus below, which would throw an uncaught // IllegalStateException (ApiExceptionHandler has no handler for it, so it would surface as // an opaque 500, not a controlled conflict). Found while writing PaymentTrackIntegrationTest's - // off-path case; a bypass-policy deal CAN reach a DRAFT deposit notice today (createDraft - // does not check deposit_policy), so this is a genuinely reachable path, not hypothetical. + // off-path case; a bypass-policy deal CAN still hold a DRAFT deposit notice (one drafted while + // the deal was REQUIRED survives a later waiver), so this is a genuinely reachable path. // On a bypass policy, issuing a deposit notice now throws — a deliberate behaviour change // from rule 4; see the branch report. boolean paymentTrackReady = !DepositPolicy.bypassesDepositNotice(s.depositPolicy()) @@ -233,6 +239,11 @@ public DepositNoticeDto issue(long docId, UserPrincipal actor) { throw new ApiException(HttpStatus.CONFLICT, "ออกใบแจ้งรับมัดจำได้เฉพาะเมื่อออกใบเสนอราคาแล้วและลูกค้ายืนยันคำสั่งซื้อแล้วเท่านั้น"); } + // A 0-baht draft (e.g. a ticket-route draft on a deal whose quotation says ไม่รับมัดจำ but + // whose policy predates the confirmation sync) must not take a document number. + if (doc.depositAmount() == null || doc.depositAmount().signum() <= 0) { + throw new ApiException(HttpStatus.CONFLICT, "ใบแจ้งรับมัดจำนี้มียอดมัดจำ 0 บาท จึงออกเอกสารไม่ได้"); + } // Issuing advances the payment track — a paused/terminal deal must not move // (Phase 1 lifecycle gate; mirrors TicketService.requireActive). requireActiveLifecycle(s); diff --git a/backend/src/main/java/th/co/glr/hr/orderconfirmation/OrderConfirmationService.java b/backend/src/main/java/th/co/glr/hr/orderconfirmation/OrderConfirmationService.java index 78590856d..c8b6b8e7a 100644 --- a/backend/src/main/java/th/co/glr/hr/orderconfirmation/OrderConfirmationService.java +++ b/backend/src/main/java/th/co/glr/hr/orderconfirmation/OrderConfirmationService.java @@ -217,10 +217,25 @@ public OrderConfirmationResultDto confirmOrder(long pricingRequestId, ConfirmOrd // customer-confirmed yet; a later revision's own confirmOrder call only needs the // reconciliation (already done above) and its own event/notification below. TicketSummaryDto currentTicketState = requireTicketSummary(locked.ticketId()); - TicketDto ticketDto = currentTicketState.paymentStatus() == null - ? ticketService.confirmCustomer(locked.ticketId(), actor) - : tickets.findById(locked.ticketId()) + TicketDto ticketDto; + if (currentTicketState.paymentStatus() == null) { + // The accepted new-engine quotation's percent decides the deposit policy (owner ruling + // 2026-10-22, see TicketService#applyCustomerConfirmation; a blank percent means 30, ruling 10-23). + // A legacy quotation carries none, so the policy is left alone. + th.co.glr.hr.dealquotation.DealQuotationDtos.DealQuotationDto confirmed = dealQuotations == null + ? null + : dealQuotations.findAcceptedForPricingRequest(pricingRequestId) + .flatMap(dealQuotations::findById) + .orElse(null); + ticketDto = ticketService.confirmCustomer(locked.ticketId(), + confirmed == null ? null : confirmed.number(), + confirmed == null ? null + : th.co.glr.hr.dealquotation.DealQuotationRenderAdapter.effectiveDepositPercent(confirmed.depositPercent()), + actor); + } else { + ticketDto = tickets.findById(locked.ticketId()) .orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "ไม่พบดีลนี้")); + } pricingRequests.addEvent(pricingRequestId, locked.ticketId(), actor.id(), actor.name(), PricingRequestEventKind.ORDER_CONFIRMED, locked.status(), locked.status(), @@ -472,9 +487,9 @@ public DepositNoticeDto createDepositNoticeFromQuotation(long pricingRequestId, throw new ApiException(HttpStatus.CONFLICT, "ยังไม่มีใบเสนอราคาที่ลูกค้ายอมรับสำหรับคำขอราคานี้"); } // A zero-deposit quotation (DealQuotationService#isZeroDeposit: "ไม่รับมัดจำ", paid in - // full under fullPaymentTerm) has nothing to bill. Refuse here rather than draft a - // 0-baht notice: DepositNoticeService#issue reads the ticket's deposit_policy, which no - // path syncs from the quotation, so nothing downstream would stop that draft. + // full under fullPaymentTerm) has nothing to bill. Since #1173 the confirmation above + // moves such a deal to NOT_REQUIRED and createDraftFromQuotation refuses a bypass policy; + // this check stays for deals confirmed before that sync existed (still REQUIRED). if (Integer.valueOf(0).equals(newEngine.depositPercent())) { throw DepositNoticeService.zeroDepositRefusal(newEngine.number()); } 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 2ea18c491..1c9fe1b81 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 @@ -1570,6 +1570,31 @@ public void updateDepositPolicy(long ticketId, String policy, String reason, lon .addValue("id", ticketId)); } + /** + * The system's only write to {@code deposit_policy} (see {@code TicketService + * #followQuotationDepositPolicy}). A compare-and-set: it moves the row from {@code expected} to + * {@code policy} only while no person has set the policy ({@code deposit_policy_set_by IS NULL}; + * {@link #updateDepositPolicy} always records one) and the payment track is still at + * CUSTOMER_CONFIRMED. It leaves {@code deposit_policy_set_by} NULL, so the row stays the + * system's. Returns the rowcount; 0 means leave the policy alone, e.g. a waiver committed first. + */ + public int syncDepositPolicyFromQuotation(long ticketId, String expected, String policy, String reason) { + return jdbc.update(""" + UPDATE sales.ticket + SET deposit_policy = :policy, deposit_policy_reason = :reason, + deposit_policy_set_by = NULL, updated_at = now() + WHERE ticket_id = :id + AND deposit_policy = :expected + AND deposit_policy_set_by IS NULL + AND payment_status = 'CUSTOMER_CONFIRMED' + """, + new MapSqlParameterSource() + .addValue("policy", policy) + .addValue("reason", reason) + .addValue("expected", expected) + .addValue("id", ticketId)); + } + public void updateEntryChannel(long ticketId, String value) { jdbc.update( "UPDATE sales.ticket SET entry_channel = :value, updated_at = now() WHERE ticket_id = :id", 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 8b9a34e0e..b134178f6 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 @@ -867,16 +867,25 @@ public TicketDto verifyClose(long ticketId, UserPrincipal actor) { @Transactional public TicketDto confirmCustomer(long ticketId, UserPrincipal actor) { + return confirmCustomer(ticketId, null, null, actor); + } + + /** {@link #confirmCustomer} for a caller that knows the confirmed quotation; see + * {@link #applyCustomerConfirmation} for what its number and deposit percent do. */ + @Transactional + public TicketDto confirmCustomer(long ticketId, String quotationNumber, Integer quotationDepositPercent, + UserPrincipal actor) { requireRole(actor, SALES_ROLES); TicketSummaryDto s = loadAndVerifyStatus(ticketId, TicketStatus.QUOTATION_ISSUED); requireActive(s); requireOwner(s, actor); - return applyCustomerConfirmation(ticketId, s, actor); + return applyCustomerConfirmation(ticketId, s, quotationNumber, quotationDepositPercent, actor); } /** * GLA-136 — the SAME customer-confirmation effects as {@link #confirmCustomer} (payment track - * {@code null -> CUSTOMER_CONFIRMED}, stage auto-advance to {@code ORDER_RECEIVED}), for the + * {@code null -> CUSTOMER_CONFIRMED}, the deposit policy following the quotation's percent, + * stage auto-advance to {@code ORDER_RECEIVED}), for the * ONE caller that has already made its own authorization decision: * {@code DealQuotationService#confirmOrderFromDirectQuotation}. * @@ -896,15 +905,25 @@ public TicketDto confirmCustomer(long ticketId, UserPrincipal actor) { * gate it relies on. */ @Transactional - public TicketDto confirmCustomerForPromotedQuotation(long ticketId, UserPrincipal actor) { + public TicketDto confirmCustomerForPromotedQuotation(long ticketId, String quotationNumber, + Integer quotationDepositPercent, UserPrincipal actor) { TicketSummaryDto s = loadAndVerifyStatus(ticketId, TicketStatus.QUOTATION_ISSUED); requireActive(s); - return applyCustomerConfirmation(ticketId, s, actor); + return applyCustomerConfirmation(ticketId, s, quotationNumber, quotationDepositPercent, actor); } - /** The shared body of {@link #confirmCustomer} and {@link #confirmCustomerForPromotedQuotation} - * — moved here verbatim (GLA-136) so the two cannot drift; behaviour is unchanged. */ - private TicketDto applyCustomerConfirmation(long ticketId, TicketSummaryDto s, UserPrincipal actor) { + /** + * The shared body of {@link #confirmCustomer} and {@link #confirmCustomerForPromotedQuotation} + * (GLA-136), so the two cannot drift. + * + *

    Owner ruling 2026-10-22: the first confirmation applies the confirmed quotation's deposit + * percent to the policy ({@link #followQuotationDepositPolicy}), so a ไม่รับมัดจำ (0%) deal takes + * the no-deposit path through the import-request and final-payment gates without a manual + * {@link #waiveDeposit}. It runs after the {@code null -> CUSTOMER_CONFIRMED} advance, an edge + * both paths share, so every later advance reads the synced policy. + */ + private TicketDto applyCustomerConfirmation(long ticketId, TicketSummaryDto s, String quotationNumber, + Integer quotationDepositPercent, UserPrincipal actor) { // Never downgrade the payment track: once past CUSTOMER_CONFIRMED, re-confirming // would reset paymentStatus and deadlock the later transitions. if (s.paymentStatus() != null && !"CUSTOMER_CONFIRMED".equals(s.paymentStatus())) { @@ -922,12 +941,47 @@ private TicketDto applyCustomerConfirmation(long ticketId, TicketSummaryDto s, U requirePaymentAdvanced(rows); tickets.addEvent(ticketId, actor.id(), actor.name(), TicketEventKind.CUSTOMER_CONFIRMED, s.status(), s.status(), null); + followQuotationDepositPolicy(ticketId, quotationNumber, quotationDepositPercent, actor); } // Deal pipeline (V50): a confirmed PO advances the deal — guarded no-op inside. autoAdvanceStage(s, DealStage.ORDER_RECEIVED, actor); return requireTicket(ticketId); } + /** + * 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. + * + *

    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 + * enforced by the repository's compare-and-set ({@link TicketRepository + * #syncDepositPolicyFromQuotation}), which also loses cleanly to a waiver that commits first: + * 0 rows, no event. A null percent (a legacy quotation) changes nothing. + * + *

    Performs NO authorization: each caller has already authorized {@code actor}, who is + * recorded on the event only (the row's {@code deposit_policy_set_by} stays NULL). + */ + @Transactional + public void followQuotationDepositPolicy(long ticketId, String quotationNumber, Integer depositPercent, + UserPrincipal actor) { + if (depositPercent == null) { + return; + } + boolean noDeposit = depositPercent == 0; + String policy = noDeposit ? DepositPolicy.NOT_REQUIRED : DepositPolicy.REQUIRED; + String reason = "ใบเสนอราคา " + quotationNumber + + (noDeposit ? " ระบุไม่รับมัดจำ (ชำระเต็มจำนวน)" : " ระบุมัดจำ " + depositPercent + "%"); + String expected = noDeposit ? DepositPolicy.REQUIRED : DepositPolicy.NOT_REQUIRED; + if (tickets.syncDepositPolicyFromQuotation(ticketId, expected, policy, reason) == 1) { + String stage = requireTicket(ticketId).summary().salesStage(); + tickets.addEvent(ticketId, actor.id(), actor.name(), + TicketEventKind.POLICY_CHANGED, stage, stage, "deposit_policy → " + policy + " — " + reason); + } + } + /** * Turns a lost payment-track compare-and-set race into a 409. {@code advancePaymentStatus}'s * 0 rowcount means a concurrent writer moved {@code payment_status} out from under this 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 45c8f7b09..d615fff84 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 @@ -63,6 +63,7 @@ import th.co.glr.hr.ticket.TicketEventKind; import th.co.glr.hr.ticket.TicketItemRequest; import th.co.glr.hr.ticket.TicketRepository; +import th.co.glr.hr.ticket.TicketResponses; import th.co.glr.hr.ticket.TicketService; import th.co.glr.hr.ticket.TicketStatus; import th.co.glr.hr.ticket.TicketSummaryDto; @@ -348,6 +349,135 @@ void failureInTheLastStep_rollsTheWholePromotionBack() { assertNothingPromoted(ghost); } + /** Owner ruling 2026-10-22: a ไม่รับมัดจำ (0%) direct quotation confirms onto the no-deposit + * payment path, so final payment is no longer blocked by a deposit that will never exist. */ + @Test + void zeroDepositDirectQuotation_confirmsOntoTheNoDepositPath_andFinalPaymentIsNoLongerBlocked() { + long deal = pipelineTicket(salesActor); + DealQuotationDto created = quotationService.create(deal, zeroDepositDirectDraft(), salesActor); + DealQuotationDto submitted = quotationService.submit(created.id(), salesActor); + DealQuotationDto approved = quotationService.approve(submitted.id(), new ApproveRequest(null), salesManagerActor); + assertThat(approved.depositPercent()).isZero(); + + confirmer.confirmOrderFromDirectQuotation(approved.id(), salesActor); + + TicketSummaryDto after = summary(deal); + assertThat(after.depositPolicy()).isEqualTo("NOT_REQUIRED"); + assertThat(after.depositPolicyReason()).contains(approved.number()).contains("ไม่รับมัดจำ"); + assertThat(eventCount(deal, TicketEventKind.POLICY_CHANGED)).isEqualTo(1); + assertThat(after.paymentStatus()).isEqualTo("CUSTOMER_CONFIRMED"); + assertConflict(() -> depositNoticeService.createDraft(deal, + new DepositNoticeDraftRequest(null, null, null, null, null, null, null, null), salesActor), "ไม่ต้องเก็บมัดจำ"); + // The whole 0% walk (owner ruling 2026-10-27: 0% skips the deposit path automatically). + assertThat(ticketService.actions(deal, salesActor).availableActions()) + .extracting(TicketResponses.TicketActionDto::action).doesNotContain("ISSUE_DEPOSIT_NOTICE"); + ticketService.requireImportRequestIssuable(after); // a REQUIRED deal 409s here with no deposit + // canConfirmFinalPaymentNow refuses a REQUIRED deal at CUSTOMER_CONFIRMED. + assertThat(ticketService.confirmFinalPayment(deal, accountActor).summary().paymentStatus()) + .isEqualTo("FULLY_PAID"); + assertThat(summary(deal).salesStage()).isNotEqualTo(DealStage.DEPOSIT_RECEIVED); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM sales.ticket_event WHERE ticket_id = :id" + + " AND (to_status = 'DEPOSIT_RECEIVED' OR from_status = 'DEPOSIT_RECEIVED')", + Map.of("id", deal), Integer.class)).isZero(); + } + + /** + * 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. + */ + @Test + void zeroDepositConfirmed_thenARevisionAddsADeposit_policyFollowsBackToRequired_andTheDraftBillsIt() { + long deal = pipelineTicket(salesActor); + DealQuotationDto zero = approvedQuotation(deal, zeroDepositDirectDraft()); + confirmer.confirmOrderFromDirectQuotation(zero.id(), salesActor); + assertThat(summary(deal).depositPolicy()).isEqualTo("NOT_REQUIRED"); + + DealQuotationDto revision = approvedRevision(zero, directDraft()); + + 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"); + } + + /** 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. */ + @Test + void zeroDepositConfirmed_thenARevisionWithABlankPercent_countsAsThirty_andIsRequired() { + long deal = pipelineTicket(salesActor); + DealQuotationDto zero = approvedQuotation(deal, zeroDepositDirectDraft()); + confirmer.confirmOrderFromDirectQuotation(zero.id(), salesActor); + assertThat(summary(deal).depositPolicy()).isEqualTo("NOT_REQUIRED"); + + DealQuotationDto revision = approvedRevision(zero, blankDepositDirectDraft()); + + assertThat(revision.depositPercent()).isNull(); + TicketSummaryDto after = summary(deal); + assertThat(after.depositPolicy()).isEqualTo("REQUIRED"); + assertThat(after.depositPolicyReason()).isEqualTo("ใบเสนอราคา " + revision.number() + " ระบุมัดจำ 30%"); + } + + /** 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() { + long deal = pipelineTicket(salesActor); + DealQuotationDto fifty = approvedDirectQuotation(deal, salesActor); + confirmer.confirmOrderFromDirectQuotation(fifty.id(), salesActor); + assertThat(summary(deal).depositPolicy()).isEqualTo("REQUIRED"); + + DealQuotationDto revision = approvedRevision(fifty, zeroDepositDirectDraft()); + + 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), "ไม่ต้องเก็บมัดจำ"); + } + + /** 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() { + long deal = pipelineTicket(salesActor); + DealQuotationDto zero = approvedQuotation(deal, zeroDepositDirectDraft()); + confirmer.confirmOrderFromDirectQuotation(zero.id(), salesActor); + ticketService.waiveDeposit(deal, "CREDIT_CUSTOMER", "ลูกค้าเครดิต 30 วัน", salesActor); + + approvedRevision(zero, directDraft()); + + 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 + } + + /** 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. */ + @Test + void humanNotRequired_isKept_whenARevisionStillCarriesADeposit() { + long deal = pipelineTicket(salesActor); + DealQuotationDto fifty = approvedDirectQuotation(deal, salesActor); + confirmer.confirmOrderFromDirectQuotation(fifty.id(), salesActor); + ticketService.waiveDeposit(deal, "NOT_REQUIRED", "ลูกค้าโอนเต็มจำนวนล่วงหน้า", salesActor); + + approvedRevision(fifty, directDraft()); + + TicketSummaryDto after = summary(deal); + assertThat(after.depositPolicy()).isEqualTo("NOT_REQUIRED"); + assertThat(after.depositPolicyReason()).isEqualTo("ลูกค้าโอนเต็มจำนวนล่วงหน้า"); + assertThat(policyChangedMessages(deal)).hasSize(1); // the waiver only + } + // ───────────────────────────────────────────────────────────────────────────────────── // Authz — WRONG-WAY-ROUND. Each refusal asserts nothing was written. // ───────────────────────────────────────────────────────────────────────────────────── @@ -694,6 +824,28 @@ private DealQuotationDto approvedDirectQuotation(long ticketId, UserPrincipal cr return approved; } + private DealQuotationDto approvedQuotation(long ticketId, UpsertDealQuotationRequest draft) { + DealQuotationDto created = quotationService.create(ticketId, draft, salesActor); + quotationService.submit(created.id(), salesActor); + return quotationService.approve(created.id(), new ApproveRequest(null), salesManagerActor); + } + + /** Revise {@code source} to {@code draft}'s terms, submit, and approve — the reviewer's F1 sequence. */ + private DealQuotationDto approvedRevision(DealQuotationDto source, UpsertDealQuotationRequest draft) { + DealQuotationDto revision = quotationService.createRevision(source.id(), salesActor); + quotationService.update(revision.id(), draft, salesActor); + quotationService.submit(revision.id(), salesActor); + DealQuotationDto approved = quotationService.approve(revision.id(), new ApproveRequest(null), salesManagerActor); + assertThat(approved.docStatus()).isEqualTo(QuotationStatus.APPROVED); + return approved; + } + + private List policyChangedMessages(long ticketId) { + return jdbc.queryForList(""" + SELECT message FROM sales.ticket_event WHERE ticket_id = :id AND kind = :kind ORDER BY event_id + """, Map.of("id", ticketId, "kind", TicketEventKind.POLICY_CHANGED), String.class); + } + /** One TILE line (10 pcs, loose — roundToFullBox=false), one PLAIN line (2 JOB), one ADJUSTMENT (flat -100). */ private UpsertDealQuotationRequest directDraft() { ItemInput tile = new ItemInput( @@ -718,6 +870,23 @@ null, null, null, new BigDecimal("500.00"), BigDecimal.ZERO, null, 0, 30, null, "NET", "TH", "THB", List.of(tile, plain, adjustment)).withRecipientType("OWNER"); } + /** {@link #directDraft} as a ไม่รับมัดจำ quotation: 0% submits only with a full-payment term. */ + private UpsertDealQuotationRequest blankDepositDirectDraft() { + UpsertDealQuotationRequest d = directDraft(); + return new UpsertDealQuotationRequest(d.contactId(), d.deptCode(), d.unitCode(), d.offerDate(), null, + d.remainderMode(), d.creditDays(), d.validityDays(), d.validityMode(), d.validityUntil(), + d.customerNotes(), d.priceMode(), d.documentLanguage(), d.currency(), null, null, null, null, + d.fullPaymentTerm(), null, null, null, null, d.recipientType(), d.items()); + } + + private UpsertDealQuotationRequest zeroDepositDirectDraft() { + UpsertDealQuotationRequest d = directDraft(); + return new UpsertDealQuotationRequest(d.contactId(), d.deptCode(), d.unitCode(), d.offerDate(), 0, + d.remainderMode(), d.creditDays(), d.validityDays(), d.validityMode(), d.validityUntil(), + d.customerNotes(), d.priceMode(), d.documentLanguage(), d.currency(), null, null, null, null, + "BEFORE_DELIVERY", null, null, null, null, d.recipientType(), d.items()); + } + private TicketItemRequest ticketItem() { return new TicketItemRequest("SCG", "Typed", "White", "Matte", "60x60", null, new BigDecimal("1"), null, "PIECE", null, null, null, null, "THB"); diff --git a/backend/src/test/java/th/co/glr/hr/dealquotation/DealQuotationOutcomeIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/dealquotation/DealQuotationOutcomeIntegrationTest.java index 56d8db999..504680c7c 100644 --- a/backend/src/test/java/th/co/glr/hr/dealquotation/DealQuotationOutcomeIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/dealquotation/DealQuotationOutcomeIntegrationTest.java @@ -276,19 +276,43 @@ void createDepositNotice_newEngineAcceptedZeroDeposit_bothRoutes409AndNoDraft() orderConfirmation.confirmOrder(pricingRequestId, new OrderConfirmationRequests.ConfirmOrderRequest(UUID.randomUUID().toString()), salesActor); + // Each route's own guard: the pricing-request route refuses on the quotation, the ticket route on + // the policy (the confirmation already moved the deal to NOT_REQUIRED). + assertThatThrownBy(() -> orderConfirmation.createDepositNoticeFromQuotation(pricingRequestId, + new OrderConfirmationRequests.CreateDepositNoticeFromQuotationRequest(null), salesActor)) + .isInstanceOf(ApiException.class) + .hasFieldOrPropertyWithValue("status", HttpStatus.CONFLICT) + .hasMessageContaining("ใบเสนอราคา " + accepted.number() + " ระบุไม่มีมัดจำ"); + assertThatThrownBy(() -> depositNoticeService.createDraft(ticketId, emptyDepositDraft(), salesActor)) + .isInstanceOf(ApiException.class) + .hasFieldOrPropertyWithValue("status", HttpStatus.CONFLICT) + .hasMessageContaining("ดีลนี้ไม่ต้องเก็บมัดจำ"); + assertThat(depositNoticeCount()).isZero(); + } + + /** #1170's quotation guards, reached only by a deal still REQUIRED with a 0% quotation (confirmed + * before the policy sync): both routes refuse on the quotation and write nothing. */ + @Test + void createDepositNotice_requiredDealWithZeroQuotation_bothRoutes409OnTheQuotation_andWriteNothing() { + DealQuotationDto accepted = acceptedZeroDepositNewOriginQuotation(PricingRequestRecipient.DESIGNER, + new BigDecimal("10")); + long pricingRequestId = accepted.pricingRequestId(); + orderConfirmation.confirmOrder(pricingRequestId, + new OrderConfirmationRequests.ConfirmOrderRequest(UUID.randomUUID().toString()), salesActor); + jdbc.update("UPDATE sales.ticket SET deposit_policy = 'REQUIRED', deposit_policy_reason = NULL WHERE ticket_id = :id", + Map.of("id", ticketId)); + String refusal = "ใบเสนอราคา " + accepted.number() + " ระบุไม่มีมัดจำ"; assertThatThrownBy(() -> orderConfirmation.createDepositNoticeFromQuotation(pricingRequestId, new OrderConfirmationRequests.CreateDepositNoticeFromQuotationRequest(null), salesActor)) .isInstanceOf(ApiException.class) .hasFieldOrPropertyWithValue("status", HttpStatus.CONFLICT) .hasMessageContaining(refusal); - assertThatThrownBy(() -> depositNoticeService.createDraft(ticketId, - new DepositNoticeDraftRequest(null, null, null, null, null, null, null, null), salesActor)) + assertThatThrownBy(() -> depositNoticeService.createDraft(ticketId, emptyDepositDraft(), salesActor)) .isInstanceOf(ApiException.class) .hasFieldOrPropertyWithValue("status", HttpStatus.CONFLICT) .hasMessageContaining(refusal); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM sales.deposit_notice WHERE ticket_id = :id", - Map.of("id", ticketId), Long.class)).isZero(); + assertThat(depositNoticeCount()).isZero(); } @Test @@ -446,6 +470,146 @@ void createDepositNotice_legacyAcceptedNullPercent_whenASiblingPricingRequestHas } } + // ── A ไม่รับมัดจำ quotation puts the deal on the no-deposit payment path (owner ruling 2026-10-22) ── + // confirmOrder syncs sales.ticket.deposit_policy from the confirmed quotation's 0%, so the import + // request and final-payment gates open without a manual waiveDeposit, and no route can bill a deposit. + + @Test + void confirmOrder_zeroDepositQuotation_setsNotRequired_opensTheImportGate_andBothDraftRoutes409() { + DealQuotationDto accepted = acceptedZeroDepositNewOriginQuotation(PricingRequestRecipient.DESIGNER, + new BigDecimal("10")); + long pricingRequestId = accepted.pricingRequestId(); + orderConfirmation.confirmOrder(pricingRequestId, + new OrderConfirmationRequests.ConfirmOrderRequest(UUID.randomUUID().toString()), salesActor); + + var s = tickets.findById(ticketId).orElseThrow().summary(); + assertThat(s.depositPolicy()).isEqualTo("NOT_REQUIRED"); + assertThat(s.depositPolicyReason()).contains(accepted.number()).contains("ไม่รับมัดจำ"); + assertThat(policyChangedMessages()).singleElement().asString() + .contains("NOT_REQUIRED").contains(accepted.number()); + assertThat(s.paymentStatus()).isEqualTo("CUSTOMER_CONFIRMED"); + // A REQUIRED deal at CUSTOMER_CONFIRMED fails this gate until a deposit notice is issued. + ticketService.requireImportRequestIssuable(s); + + assertThatThrownBy(() -> depositNoticeService.createDraft(ticketId, emptyDepositDraft(), salesActor)) + .isInstanceOf(ApiException.class) + .hasFieldOrPropertyWithValue("status", HttpStatus.CONFLICT) + .hasMessageContaining("ไม่ต้องเก็บมัดจำ"); + assertThatThrownBy(() -> orderConfirmation.createDepositNoticeFromQuotation(pricingRequestId, + new OrderConfirmationRequests.CreateDepositNoticeFromQuotationRequest(null), salesActor)) + .isInstanceOf(ApiException.class) + .hasFieldOrPropertyWithValue("status", HttpStatus.CONFLICT); + assertThat(depositNoticeCount()).isZero(); + } + + /** Wrong-way-round: only an explicit 0% syncs; a deposit-bearing quotation leaves REQUIRED alone. */ + @Test + void confirmOrder_thirtyPercentQuotation_leavesTheDepositPolicyRequired() { + DealQuotationDto accepted = acceptedNewOriginQuotation(PricingRequestRecipient.DESIGNER, new BigDecimal("10"), 30); + orderConfirmation.confirmOrder(accepted.pricingRequestId(), + new OrderConfirmationRequests.ConfirmOrderRequest(UUID.randomUUID().toString()), salesActor); + + var s = tickets.findById(ticketId).orElseThrow().summary(); + assertThat(s.depositPolicy()).isEqualTo("REQUIRED"); + assertThat(s.depositPolicyReason()).isNull(); + assertThat(policyChangedMessages()).isEmpty(); + assertThatThrownBy(() -> ticketService.requireImportRequestIssuable(s)) + .isInstanceOf(ApiException.class) + .hasFieldOrPropertyWithValue("status", HttpStatus.CONFLICT); + } + + /** Wrong-way-round: the sync only replaces REQUIRED, never a policy a person already chose. */ + @Test + void confirmOrder_zeroDepositQuotationOnACreditCustomerDeal_keepsCreditCustomer() { + ticketService.waiveDeposit(ticketId, "CREDIT_CUSTOMER", "ลูกค้าเครดิต 30 วัน", salesActor); + DealQuotationDto accepted = acceptedZeroDepositNewOriginQuotation(PricingRequestRecipient.DESIGNER, + new BigDecimal("10")); + orderConfirmation.confirmOrder(accepted.pricingRequestId(), + new OrderConfirmationRequests.ConfirmOrderRequest(UUID.randomUUID().toString()), salesActor); + + var s = tickets.findById(ticketId).orElseThrow().summary(); + assertThat(s.depositPolicy()).isEqualTo("CREDIT_CUSTOMER"); + assertThat(s.depositPolicyReason()).isEqualTo("ลูกค้าเครดิต 30 วัน"); + assertThat(policyChangedMessages()).singleElement().asString().contains("CREDIT_CUSTOMER"); + } + + /** Both draft routes refuse a deal whose policy bypasses the deposit notice. A 30% quotation keeps + * #1166's percent guard out of the way, so the PR route reaches the shared policy check. */ + @Test + void depositDraft_onAWaivedDeal_bothRoutes409OnThePolicy_andWriteNothing() { + DealQuotationDto accepted = acceptedNewOriginQuotation(PricingRequestRecipient.DESIGNER, new BigDecimal("10"), 30); + long pricingRequestId = accepted.pricingRequestId(); + orderConfirmation.confirmOrder(pricingRequestId, + new OrderConfirmationRequests.ConfirmOrderRequest(UUID.randomUUID().toString()), salesActor); + ticketService.waiveDeposit(ticketId, "WAIVED", "ลูกค้าโอนเต็มจำนวนล่วงหน้า", salesActor); + + assertThatThrownBy(() -> depositNoticeService.createDraft(ticketId, emptyDepositDraft(), salesActor)) + .isInstanceOf(ApiException.class) + .hasFieldOrPropertyWithValue("status", HttpStatus.CONFLICT) + .hasMessageContaining("ไม่ต้องเก็บมัดจำ"); + assertThatThrownBy(() -> orderConfirmation.createDepositNoticeFromQuotation(pricingRequestId, + new OrderConfirmationRequests.CreateDepositNoticeFromQuotationRequest(null), salesActor)) + .isInstanceOf(ApiException.class) + .hasFieldOrPropertyWithValue("status", HttpStatus.CONFLICT) + .hasMessageContaining("ไม่ต้องเก็บมัดจำ"); + assertThat(depositNoticeCount()).isZero(); + } + + /** Review finding 2026-10-22: issue() never checked the amount. A deal confirmed before the policy + * sync shipped is still REQUIRED, and its ticket-route draft reads the quotation's 0% per ticket. */ + @Test + void issue_zeroAmountDraft_409_staysDraft_andConsumesNoNumber() { + DealQuotationDto accepted = acceptedZeroDepositNewOriginQuotation(PricingRequestRecipient.DESIGNER, + new BigDecimal("10")); + orderConfirmation.confirmOrder(accepted.pricingRequestId(), + new OrderConfirmationRequests.ConfirmOrderRequest(UUID.randomUUID().toString()), salesActor); + // Stands in for a deal confirmed before the sync existed. + jdbc.update("UPDATE sales.ticket SET deposit_policy = 'REQUIRED', deposit_policy_reason = NULL WHERE ticket_id = :id", + Map.of("id", ticketId)); + // Inserted directly: both draft routes now refuse a 0% deal, so a legacy 0-baht draft can + // only exist as data from before this fix. + Long draftId = jdbc.queryForObject(""" + INSERT INTO sales.deposit_notice (ticket_id, version, customer_name, deposit_percent, subtotal, + deposit_amount, vat_percent, vat_amount, total_payable) + VALUES (:id, 1, 'x', 0, 0, 0, 0.07, 0, 0) RETURNING deposit_notice_id""", + Map.of("id", ticketId), Long.class); + DepositNoticeDto draft = depositNoticeService.getById(draftId, salesActor); + assertThat(draft.depositAmount()).isEqualByComparingTo("0"); + long sequenceBefore = depositNoticeSequence(); + + assertThatThrownBy(() -> depositNoticeService.issue(draft.id(), salesActor)) + .isInstanceOf(ApiException.class) + .hasFieldOrPropertyWithValue("status", HttpStatus.CONFLICT) + .hasMessageContaining("0 บาท"); + + Map row = jdbc.queryForMap( + "SELECT status, doc_number FROM sales.deposit_notice WHERE deposit_notice_id = :id", Map.of("id", draft.id())); + assertThat(row.get("status")).isEqualTo("DRAFT"); + assertThat(row.get("doc_number")).isNull(); + assertThat(depositNoticeSequence()).isEqualTo(sequenceBefore); + assertThat(tickets.findById(ticketId).orElseThrow().summary().paymentStatus()).isEqualTo("CUSTOMER_CONFIRMED"); + } + + private static DepositNoticeDraftRequest emptyDepositDraft() { + return new DepositNoticeDraftRequest(null, null, null, null, null, null, null, null); + } + + private List policyChangedMessages() { + return jdbc.queryForList("SELECT message FROM sales.ticket_event WHERE ticket_id = :id AND kind = 'POLICY_CHANGED'", + Map.of("id", ticketId), String.class); + } + + private long depositNoticeCount() { + return jdbc.queryForObject("SELECT COUNT(*) FROM sales.deposit_notice WHERE ticket_id = :id", + Map.of("id", ticketId), Long.class); + } + + private long depositNoticeSequence() { + return jdbc.queryForObject( + "SELECT COALESCE(SUM(last_seq), 0) FROM sales.document_sequence WHERE doc_type = 'DEPOSIT_NOTICE'", + Map.of(), Long.class); + } + @Test void recordOutcome_byNonOwningSales_refused_writesNothing() { DealQuotationDto issued = issuedNewOriginQuotation(PricingRequestRecipient.DESIGNER, new BigDecimal("10")); 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 3a5d7ee76..f6301a785 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 @@ -567,9 +567,9 @@ void advancePaymentStatus_waivedPolicyCannotReachDepositNoticeIssued_repositoryL * Same off-path fact, proven through the real SERVICE call this time — this is what proves a * gap found while writing this test class: {@code DepositNoticeService.issue}'s loosened * precondition (site 2) originally checked ONLY {@code paymentStatus}, not {@code - * deposit_policy}. {@code createDraft} never checked {@code deposit_policy} either (it only - * gates on ticket status), so a WAIVED deal that had reached {@code CUSTOMER_CONFIRMED} could - * still acquire a real DRAFT deposit notice and sail past the service-level guard into {@code + * deposit_policy}. {@code createDraft} now refuses a bypass-policy deal, but a DRAFT made while + * the deal was still REQUIRED survives a later waive, so a WAIVED deal at {@code + * CUSTOMER_CONFIRMED} can still hold a real DRAFT deposit notice and sail past the service-level guard into {@code * advancePaymentStatus}, which would throw {@link IllegalStateException} UNCAUGHT — * {@code ApiExceptionHandler} has no handler for it, so it would have surfaced as an opaque * 500 ("เกิดข้อผิดพลาดภายในระบบ"), not a clean conflict. Fixed by adding an explicit {@code @@ -579,10 +579,10 @@ void advancePaymentStatus_waivedPolicyCannotReachDepositNoticeIssued_repositoryL @Test void depositNoticeServiceIssue_onWaivedPolicyDeal_isRefusedWithCleanConflict_notAnUncaughtException() { long ticketId = createBareTicket(); - tickets.updateDepositPolicy(ticketId, DepositPolicy.WAIVED, "ทดสอบ", salesRepId); tickets.markQuotationIssuedForOrderConfirmation(ticketId); tickets.updatePaymentStatusUnchecked(ticketId, PaymentTrack.CUSTOMER_CONFIRMED); long docId = depositNoticeService.createDraft(ticketId, bareDraftRequest(), salesActor).id(); + tickets.updateDepositPolicy(ticketId, DepositPolicy.WAIVED, "ทดสอบ", salesRepId); assertThatThrownBy(() -> depositNoticeService.issue(docId, salesActor)) .isInstanceOfSatisfying(ApiException.class, diff --git a/backend/src/test/java/th/co/glr/hr/ticket/TicketRepositoryIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/ticket/TicketRepositoryIntegrationTest.java index 95ca5edb2..5995e5345 100644 --- a/backend/src/test/java/th/co/glr/hr/ticket/TicketRepositoryIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/ticket/TicketRepositoryIntegrationTest.java @@ -763,6 +763,95 @@ void findPricingChainFallbackItems_returnsEmpty_whenTicketHasNoQuotationAtAll() assertThat(tickets.findPricingChainFallbackItems(ticketId)).isEmpty(); } + // ── Deposit policy that follows the quotation (PR #1173 follow-ups F1/F2) ───────────────── + // syncDepositPolicyFromQuotation is the only write the system makes to deposit_policy. It is a + // compare-and-set: it moves the row only while the policy is still the expected one, nobody + // has set it by hand (deposit_policy_set_by IS NULL; waiveDeposit always records its actor), + // and the payment track is still at CUSTOMER_CONFIRMED. 0 rows means "leave it alone". + + @Test + void syncDepositPolicyFromQuotation_movesASystemOwnedPolicyBothWays_andRecordsNoPerson() { + long ticketId = confirmedTicket(); + + assertThat(tickets.syncDepositPolicyFromQuotation(ticketId, DepositPolicy.REQUIRED, + DepositPolicy.NOT_REQUIRED, "ใบเสนอราคา QT-1 ระบุไม่รับมัดจำ (ชำระเต็มจำนวน)")).isEqualTo(1); + assertThat(depositPolicyRow(ticketId)) + .containsEntry("deposit_policy", DepositPolicy.NOT_REQUIRED) + .containsEntry("deposit_policy_reason", "ใบเสนอราคา QT-1 ระบุไม่รับมัดจำ (ชำระเต็มจำนวน)") + .containsEntry("deposit_policy_set_by", null); + + assertThat(tickets.syncDepositPolicyFromQuotation(ticketId, DepositPolicy.NOT_REQUIRED, + DepositPolicy.REQUIRED, "ใบเสนอราคา QT-1-1 ระบุมัดจำ 50%")).isEqualTo(1); + assertThat(depositPolicyRow(ticketId)) + .containsEntry("deposit_policy", DepositPolicy.REQUIRED) + .containsEntry("deposit_policy_reason", "ใบเสนอราคา QT-1-1 ระบุมัดจำ 50%") + .containsEntry("deposit_policy_set_by", null); + } + + /** F2: a waiver that commits between the service's read and this write must win. */ + @Test + void syncDepositPolicyFromQuotation_writesNothing_whenThePolicyIsNotTheExpectedOne() { + long ticketId = confirmedTicket(); + + assertThat(tickets.syncDepositPolicyFromQuotation(ticketId, DepositPolicy.NOT_REQUIRED, + DepositPolicy.REQUIRED, "ใบเสนอราคา QT-2 ระบุมัดจำ 50%")).isZero(); + assertThat(depositPolicyRow(ticketId)) + .containsEntry("deposit_policy", DepositPolicy.REQUIRED) + .containsEntry("deposit_policy_reason", null); + + tickets.updateDepositPolicy(ticketId, DepositPolicy.CREDIT_CUSTOMER, "ลูกค้าเครดิต 30 วัน", actorId); + assertThat(tickets.syncDepositPolicyFromQuotation(ticketId, DepositPolicy.REQUIRED, + DepositPolicy.NOT_REQUIRED, "ใบเสนอราคา QT-2 ระบุไม่รับมัดจำ (ชำระเต็มจำนวน)")).isZero(); + assertThat(depositPolicyRow(ticketId)) + .containsEntry("deposit_policy", DepositPolicy.CREDIT_CUSTOMER) + .containsEntry("deposit_policy_reason", "ลูกค้าเครดิต 30 วัน"); + } + + /** A NOT_REQUIRED a person chose (waiveDeposit) is never flipped back by a quotation. */ + @Test + void syncDepositPolicyFromQuotation_writesNothing_onAPolicyAPersonSet() { + long ticketId = confirmedTicket(); + tickets.updateDepositPolicy(ticketId, DepositPolicy.NOT_REQUIRED, "ลูกค้าโอนเต็มจำนวนล่วงหน้า", actorId); + + assertThat(tickets.syncDepositPolicyFromQuotation(ticketId, DepositPolicy.NOT_REQUIRED, + DepositPolicy.REQUIRED, "ใบเสนอราคา QT-3 ระบุมัดจำ 50%")).isZero(); + Map row = depositPolicyRow(ticketId); + assertThat(row).containsEntry("deposit_policy", DepositPolicy.NOT_REQUIRED) + .containsEntry("deposit_policy_reason", "ลูกค้าโอนเต็มจำนวนล่วงหน้า"); + assertThat(((Number) row.get("deposit_policy_set_by")).longValue()).isEqualTo(actorId); + } + + /** Before the customer confirms there is nothing to follow; once a deposit notice is out + * (rule 4) the policy is a money question, not a quotation one. */ + @Test + void syncDepositPolicyFromQuotation_writesNothing_offCustomerConfirmed() { + long unconfirmed = tickets.create(sampleTicket(), tickets.nextTicketCode(), actorId, "พนักงานขาย"); + assertThat(tickets.syncDepositPolicyFromQuotation(unconfirmed, DepositPolicy.REQUIRED, + DepositPolicy.NOT_REQUIRED, "ใบเสนอราคา QT-4 ระบุไม่รับมัดจำ (ชำระเต็มจำนวน)")).isZero(); + assertThat(depositPolicyRow(unconfirmed)).containsEntry("deposit_policy", DepositPolicy.REQUIRED); + + long noticeIssued = confirmedTicket(); + jdbc.update("UPDATE sales.ticket SET payment_status = 'DEPOSIT_NOTICE_ISSUED' WHERE ticket_id = :id", + Map.of("id", noticeIssued)); + assertThat(tickets.syncDepositPolicyFromQuotation(noticeIssued, DepositPolicy.REQUIRED, + DepositPolicy.NOT_REQUIRED, "ใบเสนอราคา QT-4 ระบุไม่รับมัดจำ (ชำระเต็มจำนวน)")).isZero(); + assertThat(depositPolicyRow(noticeIssued)).containsEntry("deposit_policy", DepositPolicy.REQUIRED); + } + + private long confirmedTicket() { + long ticketId = tickets.create(sampleTicket(), tickets.nextTicketCode(), actorId, "พนักงานขาย"); + jdbc.update("UPDATE sales.ticket SET payment_status = 'CUSTOMER_CONFIRMED' WHERE ticket_id = :id", + Map.of("id", ticketId)); + return ticketId; + } + + private Map depositPolicyRow(long ticketId) { + return jdbc.queryForMap(""" + SELECT deposit_policy, deposit_policy_reason, deposit_policy_set_by + FROM sales.ticket WHERE ticket_id = :id + """, Map.of("id", ticketId)); + } + // sales.quotation.number is VARCHAR(30) NOT NULL UNIQUE. A per-JVM-run counter alone // collides across repeated runs against the same persistent local test DB (this suite has // no @Transactional rollback / truncate-between-runs — see diff --git a/frontend/src/api/mockApi.depositNotices.test.js b/frontend/src/api/mockApi.depositNotices.test.js index 16f5c6336..f54b3ad77 100644 --- a/frontend/src/api/mockApi.depositNotices.test.js +++ b/frontend/src/api/mockApi.depositNotices.test.js @@ -317,9 +317,11 @@ describe('mockApi.pricingRequests.createDepositNoticeFromQuotation — new-engin depositPercent: 0, fullPaymentTerm: 'BEFORE_DELIVERY', }); expect(accepted.depositPercent).toBe(0); - const refusal = { status: 409, message: expect.stringContaining(`ใบเสนอราคา ${accepted.number} ระบุไม่มีมัดจำ`) }; - await expect(api.pricingRequests.createDepositNoticeFromQuotation(prId, {})).rejects.toMatchObject(refusal); - await expect(api.depositNotices.createDraft(ticketId, {})).rejects.toMatchObject(refusal); + // Each route's own guard: the pricing-request route refuses on the quotation, the ticket route on the policy. + await expect(api.pricingRequests.createDepositNoticeFromQuotation(prId, {})) + .rejects.toMatchObject({ status: 409, message: expect.stringContaining(`ใบเสนอราคา ${accepted.number} ระบุไม่มีมัดจำ`) }); + await expect(api.depositNotices.createDraft(ticketId, {})) + .rejects.toMatchObject({ status: 409, message: expect.stringContaining('ดีลนี้ไม่ต้องเก็บมัดจำ') }); const { depositNotices } = await api.depositNotices.listByTicket(ticketId); expect(depositNotices).toHaveLength(0); }); @@ -344,6 +346,36 @@ describe('mockApi.pricingRequests.createDepositNoticeFromQuotation — new-engin expect(depositNotice.reference).toBe(approved.number); expect(depositNotice.depositPercent).toBe(0.4); }); + + // Mirrors TicketService.applyCustomerConfirmation's policy sync (owner ruling 2026-10-22). + it('confirmOrder on a 0% quotation sets depositPolicy NOT_REQUIRED, and both draft routes 409', async () => { + const { ticketId, prId, accepted } = await driveTicketToNewEngineAcceptedQuotation({ + depositPercent: 0, fullPaymentTerm: 'BEFORE_DELIVERY', + }); + const { ticket } = await api.tickets.get(ticketId); + expect(ticket.summary.depositPolicy).toBe('NOT_REQUIRED'); + expect(ticket.summary.depositPolicyReason).toContain(accepted.number); + expect(ticket.events.filter((e) => e.kind === 'POLICY_CHANGED').map((e) => e.message)) + .toEqual([expect.stringContaining(accepted.number)]); + await expect(api.depositNotices.createDraft(ticketId, {})) + .rejects.toMatchObject({ status: 409, message: expect.stringContaining('ไม่ต้องเก็บมัดจำ') }); + await expect(api.pricingRequests.createDepositNoticeFromQuotation(prId, {})) + .rejects.toMatchObject({ status: 409 }); + expect((await api.depositNotices.listByTicket(ticketId)).depositNotices).toHaveLength(0); + }); + + it('a 30% quotation leaves REQUIRED; once waived, both draft routes 409 on the policy', async () => { + const { ticketId, prId } = await driveTicketToNewEngineAcceptedQuotation({ depositPercent: 30 }); + const { ticket } = await api.tickets.get(ticketId); + expect(ticket.summary.depositPolicy).toBe('REQUIRED'); + expect(ticket.events.some((e) => e.kind === 'POLICY_CHANGED')).toBe(false); + + await api.tickets.setDepositPolicy(ticketId, { policy: 'WAIVED', reason: 'ลูกค้าโอนเต็มจำนวนล่วงหน้า' }); + const policyRefusal = { status: 409, message: expect.stringContaining('ไม่ต้องเก็บมัดจำ') }; + await expect(api.depositNotices.createDraft(ticketId, {})).rejects.toMatchObject(policyRefusal); + await expect(api.pricingRequests.createDepositNoticeFromQuotation(prId, {})).rejects.toMatchObject(policyRefusal); + expect((await api.depositNotices.listByTicket(ticketId)).depositNotices).toHaveLength(0); + }); }); // Issue #752: the mock must reproduce production's one-render gap. Per @@ -470,3 +502,18 @@ describe('mock deposit-percent defaults match the Java PDFs', () => { 'backend/src/main/java/th/co/glr/hr/ticket/QuotationRenderer.java', 'LEGACY_DEPOSIT_PERCENT')); }); }); + +// Mirrors DepositNoticeService.issue's amount guard: a 0-baht notice must not take a document number. +describe('mockApi.depositNotices.issue refuses a 0-baht notice', () => { + it('409s, and the draft stays DRAFT with no number', async () => { + const { ticketId } = await driveTicketToNewEngineAcceptedQuotation({ depositPercent: 30 }); + // create ignores a typed percent now; the mock's update still honours one, which stands in for the Java test's jdbc insert. + const { depositNotice: created } = await api.depositNotices.createDraft(ticketId, {}); + const { depositNotice: draft } = await api.depositNotices.update(created.id, { depositPercent: 0 }); + expect(draft.depositAmount).toBe(0); + await expect(api.depositNotices.issue(draft.id)) + .rejects.toMatchObject({ status: 409, message: expect.stringContaining('0 บาท') }); + const { depositNotice } = await api.depositNotices.get(draft.id); + expect(depositNotice).toMatchObject({ status: 'DRAFT', docNumber: null }); + }); +}); diff --git a/frontend/src/api/mockApi.js b/frontend/src/api/mockApi.js index e9f1f358c..1cb2c65eb 100644 --- a/frontend/src/api/mockApi.js +++ b/frontend/src/api/mockApi.js @@ -3730,6 +3730,34 @@ function depositBypassesNotice(ticket) { return ['NOT_REQUIRED', 'WAIVED', 'CREDIT_CUSTOMER'].includes(ticket.depositPolicy); } +// Mirrors DepositNoticeService.createDraftFromQuotation's policy refusal, shared by both draft routes. +function requireDepositNoticePath(ticket) { + if (depositBypassesNotice(ticket)) { + fail(`ดีลนี้ไม่ต้องเก็บมัดจำ (นโยบายมัดจำ ${ticket.depositPolicy}) จึงสร้างใบแจ้งรับมัดจำไม่ได้`, 409); + } +} + +// Mirrors TicketService.followQuotationDepositPolicy and TicketRepository +// .syncDepositPolicyFromQuotation's compare-and-set: while CUSTOMER_CONFIRMED, a policy the system +// owns (no person recorded in depositPolicySetBy, which setDepositPolicy writes) follows the +// quotation: 0% -> NOT_REQUIRED, any deposit -> REQUIRED. Called at confirmation and on a +// DEAL_DIRECT approval. depositPolicySetBy is internal: buildTicketDetail does not serve it. +function followQuotationDepositPolicy(ticket, quotation, user) { + if (!quotation) return; + const percent = quotation.depositPercent ?? MOCK_DEFAULT_DEPOSIT_PERCENT; // blank new-engine % = 30 + const noDeposit = percent === 0; + const expected = noDeposit ? 'REQUIRED' : 'NOT_REQUIRED'; + if (ticket.paymentStatus !== 'CUSTOMER_CONFIRMED' || ticket.depositPolicySetBy != null + || (ticket.depositPolicy ?? 'REQUIRED') !== expected) return; + const policy = noDeposit ? 'NOT_REQUIRED' : 'REQUIRED'; + const reason = `ใบเสนอราคา ${quotation.number} ` + + (noDeposit ? 'ระบุไม่รับมัดจำ (ชำระเต็มจำนวน)' : `ระบุมัดจำ ${percent}%`); + ticket.depositPolicy = policy; + ticket.depositPolicyReason = reason; + pushEvent(ticket, user, 'POLICY_CHANGED', ticket.salesStage, ticket.salesStage, + `deposit_policy → ${policy} — ${reason}`); +} + function moneyValue(value) { return Math.round((Number(value) || 0) * 100) / 100; } @@ -9180,6 +9208,7 @@ export const api = { } ticket.depositPolicy = payload.policy; ticket.depositPolicyReason = payload.reason.trim(); + ticket.depositPolicySetBy = user.employeeId ?? user.id; // deposit_policy_set_by: a person chose it ticket.updatedAt = new Date().toISOString().slice(0, 10); pushEvent(ticket, user, 'POLICY_CHANGED', ticket.salesStage, ticket.salesStage, `deposit_policy → ${payload.policy} — ${payload.reason.trim()}`); @@ -13290,6 +13319,7 @@ export const api = { // Phase 1 lifecycle gate (mirrors DepositNoticeService.createDraft). requireActive(ticket); if (!['approved', 'quotation_issued', 'document_issued'].includes(ticket.status)) fail('ดีลต้องได้รับการอนุมัติก่อน', 409); + requireDepositNoticePath(ticket); // Legacy path: auto-build from approved ticket items (sales.ticket_item.approved_price — // written only by the @Deprecated, routeless TicketService.approve). @@ -13402,6 +13432,8 @@ export const api = { if (ticket.status !== 'quotation_issued' || ticket.paymentStatus !== 'CUSTOMER_CONFIRMED') { fail('ออกใบแจ้งรับมัดจำได้เฉพาะเมื่อออกใบเสนอราคาแล้วและลูกค้ายืนยันคำสั่งซื้อแล้วเท่านั้น', 409); } + // Mirrors DepositNoticeService.issue's amount guard: a 0-baht notice takes no number. + if (!(Number(doc.depositAmount) > 0)) fail('ใบแจ้งรับมัดจำนี้มียอดมัดจำ 0 บาท จึงออกเอกสารไม่ได้', 409); // Phase 1 lifecycle gate (mirrors DepositNoticeService.requireActiveLifecycle). requireActive(ticket); @@ -16197,6 +16229,8 @@ export const api = { ticket.paymentStatus = 'CUSTOMER_CONFIRMED'; ticket.updatedAt = now; pushEvent(ticket, user, 'CUSTOMER_CONFIRMED', ticket.status, ticket.status, null); + followQuotationDepositPolicy(ticket, mockDealQuotations.find( + (q) => q.pricingRequestId === pr.id && q.origin === 'PRICING_REQUEST' && q.docStatus === 'ACCEPTED'), user); autoAdvanceStage(ticket, 'ORDER_RECEIVED', user); } @@ -16236,6 +16270,7 @@ export const api = { if (!['approved', 'quotation_issued', 'document_issued'].includes(ticket.status)) { fail('สร้างใบแจ้งรับมัดจำได้เฉพาะดีลที่อนุมัติแล้วเท่านั้น', 409); } + requireDepositNoticePath(ticket); // Branch fix: header autofill — this entry point never received an explicit customer/ // project from its caller, so it always sourced the header from the (blank) defaults @@ -17268,6 +17303,9 @@ 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) }); }, @@ -17510,6 +17548,7 @@ export const api = { if (ticket.paymentStatus == null) { ticket.paymentStatus = 'CUSTOMER_CONFIRMED'; pushEvent(ticket, user, 'CUSTOMER_CONFIRMED', ticket.status, ticket.status, null); + followQuotationDepositPolicy(ticket, row, user); } autoAdvanceStage(ticket, 'ORDER_RECEIVED', user); return result(); diff --git a/frontend/src/api/mockApi.promoteToDeal.test.js b/frontend/src/api/mockApi.promoteToDeal.test.js index 72d5380b7..c55b599f3 100644 --- a/frontend/src/api/mockApi.promoteToDeal.test.js +++ b/frontend/src/api/mockApi.promoteToDeal.test.js @@ -173,4 +173,100 @@ describe('mock dealQuotations.promoteToDeal (GLA-136)', () => { it('404s an unknown quotation', async () => { await expect(api.dealQuotations.promoteToDeal(987654)).rejects.toMatchObject({ status: 404 }); }); + + // Mirrors TicketService.applyCustomerConfirmation's policy sync (owner ruling 2026-10-22). + it('a 0% (ไม่รับมัดจำ) quotation confirms the deal onto depositPolicy NOT_REQUIRED', async () => { + const ghost = await quotationOnlyTicket(); + const { quotation: draft } = await api.dealQuotations.create(ghost, { + recipientType: 'DESIGNER', depositPercent: 0, fullPaymentTerm: 'BEFORE_DELIVERY', items: [TILE, PLAIN], + }); + await api.dealQuotations.submit(draft.id); + await asRole('sales_manager'); + const { quotation: approved } = await api.dealQuotations.approve(draft.id); + await asRole('sales'); + + await api.dealQuotations.promoteToDeal(approved.id); + + const { ticket } = await api.tickets.get(ghost); + expect(ticket.summary.depositPolicy).toBe('NOT_REQUIRED'); + 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/features/pricingRequests/PricingRequestDetailPage.jsx b/frontend/src/features/pricingRequests/PricingRequestDetailPage.jsx index bba0382d7..875000bc8 100644 --- a/frontend/src/features/pricingRequests/PricingRequestDetailPage.jsx +++ b/frontend/src/features/pricingRequests/PricingRequestDetailPage.jsx @@ -4291,7 +4291,7 @@ export function PricingRequestDetailPage({ user, showToast }) { ) : null} - {canCreateDepositNoticeFromQuotation(user, summary) ? ( + {canCreateDepositNoticeFromQuotation(user, summary, dealQuotationForPr) ? (

    ยืนยันคำสั่งซื้อแล้ว — สร้างใบแจ้งยอดเงินรับมัดจำจากใบเสนอราคาที่ลูกค้ายอมรับ (แก้ไข/ออกเอกสารในหน้าใบแจ้งยอดเงินรับมัดจำ) @@ -4305,10 +4305,13 @@ export function PricingRequestDetailPage({ user, showToast }) { {confirmOrderBlockedReason(summary) ? (

    {confirmOrderBlockedReason(summary)}

    ) : null} - {!canConfirmOrder(user, summary) && !canCreateDepositNoticeFromQuotation(user, summary) ? ( + {!canConfirmOrder(user, summary) && !canCreateDepositNoticeFromQuotation(user, summary, dealQuotationForPr) ? (

    {summary.orderConfirmedAt - ? `ยืนยันคำสั่งซื้อแล้วเมื่อ ${formatThaiDate(summary.orderConfirmedAt)} — ดูใบแจ้งยอดเงินรับมัดจำได้ที่หน้าดีล` + ? `ยืนยันคำสั่งซื้อแล้วเมื่อ ${formatThaiDate(summary.orderConfirmedAt)}${ + dealQuotationForPr?.docStatus === 'ACCEPTED' && dealQuotationForPr.depositPercent === 0 + ? ' — ใบเสนอราคานี้ไม่รับมัดจำ จึงไม่มีใบแจ้งยอดเงินรับมัดจำ' + : ' — ดูใบแจ้งยอดเงินรับมัดจำได้ที่หน้าดีล'}` : 'ยืนยันคำสั่งซื้อได้เฉพาะเจ้าของดีล (sales)'}

    ) : null} diff --git a/frontend/src/features/pricingRequests/PricingRequestDetailPage.test.jsx b/frontend/src/features/pricingRequests/PricingRequestDetailPage.test.jsx index 7a05edc68..e1bdb84a5 100644 --- a/frontend/src/features/pricingRequests/PricingRequestDetailPage.test.jsx +++ b/frontend/src/features/pricingRequests/PricingRequestDetailPage.test.jsx @@ -3470,6 +3470,20 @@ describe('PricingRequestDetailPage Step 6: Deposit, Payment, and Order Confirmat )); }); + // PR #1173 review F4: a ไม่รับมัดจำ (0%) accepted quotation has no deposit, and the server 409s + // the draft, so the button is not offered. Waits for the quotation itself to render first, so the + // absence below is not just "the query has not resolved yet". + it('hides "สร้างใบแจ้งยอดเงินรับมัดจำ" when the accepted quotation says ไม่รับมัดจำ (0%)', async () => { + const accepted = { id: 9102, number: 'QT-2026-0102-1', docStatus: 'ACCEPTED', depositPercent: 0 }; + api.dealQuotations.findForPricingRequest.mockResolvedValue({ quotation: accepted }); + const request = buildRequest({ summary: { status: 'QUOTATION_ACCEPTED', orderConfirmedAt: '2026-07-21T00:00:00Z' } }); + renderDetailPage({ user: salesOwner, request }); + await waitForLoaded(request); + await screen.findByText(accepted.number); + + expect(screen.queryByRole('button', { name: 'สร้างใบแจ้งยอดเงินรับมัดจำ' })).toBeNull(); + }); + it('never shows either Step 6 button to CEO or Import — read-only', async () => { const request = buildRequest({ summary: { status: 'QUOTATION_ACCEPTED', orderConfirmedAt: null } }); renderDetailPage({ user: ceoUser, request }); diff --git a/frontend/src/features/pricingRequests/pricingRequestMeta.js b/frontend/src/features/pricingRequests/pricingRequestMeta.js index 3ef9f0453..c176b2bbc 100644 --- a/frontend/src/features/pricingRequests/pricingRequestMeta.js +++ b/frontend/src/features/pricingRequests/pricingRequestMeta.js @@ -306,9 +306,12 @@ export function confirmOrderBlockedReason(pr) { /** Mirrors OrderConfirmationService.createDepositNoticeFromQuotation's gate: sales (ticket owner) * only, reachable once the bridge action above has run (the endpoint's own precondition — - * ticket.status=quotation_issued — is only true after confirmOrder). */ -export function canCreateDepositNoticeFromQuotation(user, pr) { - return canManageCustomerQuotation(user, pr) && pr?.status === 'QUOTATION_ACCEPTED' && Boolean(pr?.orderConfirmedAt); + * ticket.status=quotation_issued — is only true after confirmOrder). `acceptedQuotation` is the + * page's new-engine quotation, when it has one: a ไม่รับมัดจำ (0%) one 409s on the server (#1173), + * so it is not offered. The deal's deposit policy is not on the pricing-request payload. */ +export function canCreateDepositNoticeFromQuotation(user, pr, acceptedQuotation = null) { + return canManageCustomerQuotation(user, pr) && pr?.status === 'QUOTATION_ACCEPTED' && Boolean(pr?.orderConfirmedAt) + && !(acceptedQuotation?.docStatus === 'ACCEPTED' && acceptedQuotation.depositPercent === 0); } /** Mirrors PricingRequestService.cancel: owner sales OR ceo, any cancellable status. */ diff --git a/frontend/src/features/pricingRequests/pricingRequestMeta.test.js b/frontend/src/features/pricingRequests/pricingRequestMeta.test.js index 061126349..49116ef43 100644 --- a/frontend/src/features/pricingRequests/pricingRequestMeta.test.js +++ b/frontend/src/features/pricingRequests/pricingRequestMeta.test.js @@ -439,6 +439,19 @@ describe('canCreateDepositNoticeFromQuotation', () => { expect(canCreateDepositNoticeFromQuotation(otherSales, pr({ status: 'QUOTATION_ACCEPTED', orderConfirmedAt: '2026-07-21T00:00:00Z' }))).toBe(false); }); + // PR #1173 review F4: the server 409s a deposit notice for a ไม่รับมัดจำ quotation (and for the + // bypass policy the confirmation now sets), so the button must not be offered. + it('rejects an accepted new-engine quotation that says ไม่รับมัดจำ (0%)', () => { + const confirmed = pr({ status: 'QUOTATION_ACCEPTED', orderConfirmedAt: '2026-07-21T00:00:00Z' }); + expect(canCreateDepositNoticeFromQuotation(salesOwner, confirmed, + { docStatus: 'ACCEPTED', depositPercent: 0 })).toBe(false); + expect(canCreateDepositNoticeFromQuotation(salesOwner, confirmed, + { docStatus: 'ACCEPTED', depositPercent: 30 })).toBe(true); + // A blank percent is NOT ไม่รับมัดจำ: the PDF prints it as 30% (#1170), so the button stays. + expect(canCreateDepositNoticeFromQuotation(salesOwner, confirmed, + { docStatus: 'ACCEPTED', depositPercent: null })).toBe(true); + expect(canCreateDepositNoticeFromQuotation(salesOwner, confirmed, null)).toBe(true); // legacy chain + }); }); describe('canCancelPricingRequest', () => {