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', () => {