Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() + " ได้รับอนุมัติแล้ว";
Expand Down Expand Up @@ -2107,9 +2117,10 @@ private String outcomeLabel(String outcome) {
* <li>{@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;</li>
* <li>{@code TicketService#confirmCustomerForPromotedQuotation} — the unmodified
* confirmCustomer body: {@code payment_status = CUSTOMER_CONFIRMED}, stage auto-advanced to
* {@code ORDER_RECEIVED}.</li>
* <li>{@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}.</li>
* </ol>
*
* <p><strong>Order of checks</strong>: quotation exists on this engine (404 — a LEGACY
Expand Down Expand Up @@ -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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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())
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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());
}
Expand Down
25 changes: 25 additions & 0 deletions backend/src/main/java/th/co/glr/hr/ticket/TicketRepository.java
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
68 changes: 61 additions & 7 deletions backend/src/main/java/th/co/glr/hr/ticket/TicketService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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}.
*
Expand All @@ -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.
*
* <p>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())) {
Expand All @@ -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.
*
* <p>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.
*
* <p>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
Expand Down
Loading
Loading