Skip to content
Open
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 @@ -1802,6 +1802,17 @@ private DealQuotationDto approveAndIssuePricingRequestOrigin(DealQuotationDto qu
quotations.lockTicket(quotation.ticketId());
PricingRequestSummaryDto summary = pricingRequests.findSummary(pricingRequestId)
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "ไม่พบคำขอราคานี้"));
// A cancelled or superseded request issues nothing (owner ruling 2026-10-27). Its cancel or
// customer-change revision supersedes a PENDING_APPROVAL quotation (PricingRequestRepository
// #supersedeOpenPricingDecisionAndQuotation, #supersedeUnissuedPricingRequestOriginQuotations);
// this refuses one orphaned before they did. Checked before the first write below.
if (PricingRequestStatus.CANCELLED.equals(summary.status())) {
throw new ApiException(HttpStatus.CONFLICT, "คำขอราคาของใบเสนอราคานี้ถูกยกเลิกแล้ว จึงอนุมัติไม่ได้");
}
if (PricingRequestStatus.SUPERSEDED.equals(summary.status())) {
throw new ApiException(HttpStatus.CONFLICT,
"คำขอราคาของใบเสนอราคานี้ถูกแทนที่ด้วยคำขอราคาฉบับแก้ไขแล้ว จึงอนุมัติไม่ได้");
}
// Same DATE-mode-with-special-pricing / DAYS-mode validity computation #approve already
// uses for DEAL_DIRECT — reused verbatim rather than forked so the two origins can never
// compute this differently by accident.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -961,6 +961,14 @@ AND status IN ('DRAFT', 'CALCULATED')
* request transition, and put a live offer in front of a customer whose deal was cancelled.
* Retiring the draft here is what makes that unreachable.
*
* <p><strong>{@code PENDING_APPROVAL} is in the predicate for the same reason.</strong> A
* PRICING_REQUEST-origin quotation ({@code th.co.glr.hr.dealquotation}) waits at
* PENDING_APPROVAL while the request is still APPROVED_FOR_QUOTATION, a status cancel admits.
* Left out of this list, it stayed in the approvals queue, and approving it issued an offer on
* the cancelled request and superseded the deal's live quotation through the one-live-offer
* sweep. {@code DealQuotationService#approveAndIssuePricingRequestOrigin} now also refuses a
* CANCELLED request, for a row orphaned before this status was added here.
*
* <p>Not routed through {@code PricingDecisionRepository}/{@code CustomerQuotationRepository}
* (this class's own dependency direction stays one-way, same as {@code cancelOpenStep2Children}
* already reaching into {@code sales.factory_quote}/{@code sales.pricing_costing} directly) —
Expand All @@ -973,7 +981,7 @@ public void supersedeOpenPricingDecisionAndQuotation(long pricingRequestId) {
UPDATE sales.quotation
SET doc_status = 'SUPERSEDED'
WHERE pricing_request_id = :pricingRequestId
AND doc_status IN ('DRAFT', 'ISSUED', 'READY_TO_ISSUE', 'SENT', 'REVISION_REQUESTED')
AND doc_status IN ('DRAFT', 'PENDING_APPROVAL', 'ISSUED', 'READY_TO_ISSUE', 'SENT', 'REVISION_REQUESTED')
""", Map.of("pricingRequestId", pricingRequestId));
}

Expand Down Expand Up @@ -1030,32 +1038,30 @@ AND status IN ('DRAFT', 'APPROVED')
}

/**
* MINOR fix (Opus review, 2026-09-20), GLA-123 slice S1 — a PRICING_REQUEST-origin (the NEW,
* {@code th.co.glr.hr.dealquotation} engine) DRAFT quotation orphaned by a customer-change
* revision, mirroring {@link #supersedeOpenPricingDecisionAndQuotation}'s own "DRAFT is in the
* predicate deliberately, and it closes a real hole" reasoning — but scoped MUCH narrower than
* that method, and NOT the same call: {@code createCustomerChangeRevision} deliberately never
* calls {@code supersedeOpenPricingDecisionAndQuotation} itself, because the owner's
* reissue-through-CEO-chain ruling keeps an ALREADY-ISSUED legacy (origin IS NULL) quotation
* live until the replacement is issued (see that method's own Javadoc and this call's own
* comment in {@code PricingRequestService#createCustomerChangeRevision}). That ruling is about
* an issued, customer-facing document — it has no bearing on this origin, which in S1 can
* NEVER reach ISSUED (S1 refused submit for this origin outright; since S2 only {@code createReorder}
* is still refused, via {@code DealQuotationService#requireReorderAllowedForOrigin}),
* so DRAFT is the ONLY state to ever clean up here, and there is no "stays live until the
* replacement issues" concept to preserve for it. Without this, a DRAFT quotation on this
* engine would be left attached to a now-SUPERSEDED parent forever — nothing else in S1 ever
* retires it (there is no S2 issue-time supersede for this origin yet). {@code origin =
* 'PRICING_REQUEST'} in the predicate is what keeps this from ever touching the legacy
* quotation the paragraph above is protecting.
* The PRICING_REQUEST-origin ({@code th.co.glr.hr.dealquotation}) quotations a customer-change
* revision retires on the request it supersedes: every one not yet issued, DRAFT or
* PENDING_APPROVAL. Sole caller is {@code PricingRequestService#createCustomerChangeRevision}.
*
* <p>DRAFT since GLA-123 S1 (Opus review, 2026-09-20), because nothing else would ever retire
* it. PENDING_APPROVAL by owner ruling 2026-10-27: the customer has changed what they want, so
* the old price must not still be approvable, and issuable, from the superseded request. The
* new round's quotation goes through ผจก.ขาย/CEO approval like any other, and {@code
* DealQuotationService#approveAndIssuePricingRequestOrigin} also refuses a SUPERSEDED request.
*
* <p>Narrower than {@link #supersedeOpenPricingDecisionAndQuotation}, and deliberately not that
* call: the reissue-through-CEO-chain ruling (2026-08-13) keeps an ISSUED offer live until its
* replacement is issued, so ISSUED and REVISION_REQUESTED stay out of this list (the
* replacement's issue retires them through the deal-wide one-live-offer sweep), and {@code
* origin = 'PRICING_REQUEST'} keeps the legacy (origin IS NULL) quotation that ruling protects
* out of reach.
*/
public void supersedeOpenPricingRequestOriginDraft(long pricingRequestId) {
public void supersedeUnissuedPricingRequestOriginQuotations(long pricingRequestId) {
jdbc.update("""
UPDATE sales.quotation
SET doc_status = 'SUPERSEDED'
WHERE pricing_request_id = :pricingRequestId
AND origin = 'PRICING_REQUEST'
AND doc_status = 'DRAFT'
AND doc_status IN ('DRAFT', 'PENDING_APPROVAL')
""", Map.of("pricingRequestId", pricingRequestId));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -598,6 +598,12 @@ public PricingRequestDetailDto cancel(long id, CancelPricingRequestRequest reque
throw new ApiException(HttpStatus.CONFLICT,
"ไม่สามารถยกเลิกคำขอราคาที่อยู่ในสถานะ '" + summary.status() + "' ได้");
}
// The request's lock, the one DealQuotationService#approveAndIssuePricingRequestOrigin takes
// first (owner ruling 2026-10-27): a cancel and an approve of this request serialise instead
// of updating its quotation and request rows in opposite orders. Taken before the first
// write. An approve that got there first leaves the request QUOTATION_ISSUED, so the
// compare-and-set below refuses this cancel.
requests.lockPricingRequest(id);
int rows = requests.transition(id, summary.status(), PricingRequestStatus.CANCELLED, null, actor.id());
if (rows == 0) {
throw new ApiException(HttpStatus.CONFLICT, "คำขอราคาถูกแก้ไขโดยผู้ใช้อื่น กรุณาโหลดข้อมูลใหม่แล้วลองอีกครั้ง");
Expand All @@ -609,9 +615,9 @@ public PricingRequestDetailDto cancel(long id, CancelPricingRequestRequest reque
// with an open pricing_decision (that needs READY_FOR_CEO_REVIEW+) or a quotation (that
// needs APPROVED_FOR_QUOTATION). The widened cutoff makes it live on the main path: a
// cancel from CEO_REVIEWING retires a DRAFT decision, and one from APPROVED_FOR_QUOTATION
// retires an APPROVED decision plus any DRAFT quotation built from it. The prediction the
// old comment made — "a future widening of that map cannot silently reintroduce design
// correction 1's bug" — is exactly what is being cashed in here.
// retires an APPROVED decision plus any DRAFT or PENDING_APPROVAL quotation built from it.
// The prediction the old comment made — "a future widening of that map cannot silently
// reintroduce design correction 1's bug" — is exactly what is being cashed in here.
requests.supersedeOpenPricingDecisionAndQuotation(id);
String metadataJson = toReasonMetadataJson(request.reason());
requests.addEvent(id, summary.ticketId(), actor.id(), actor.name(),
Expand Down Expand Up @@ -725,13 +731,13 @@ public PricingRequestDetailDto createCustomerChangeRevision(long id, CustomerCha
// (sales.quotation_item), so superseding the decision cannot change what the customer was
// quoted.
requests.supersedeOpenPricingDecision(parent.id());
// MINOR fix (Opus review, 2026-09-20) — see supersedeOpenPricingRequestOriginDraft's own
// Javadoc for why this is a SEPARATE, narrower call from the decision supersede just
// above, and why it is safe alongside the "legacy ISSUED quotation stays live" ruling
// documented in the block comment above: this only ever touches an origin =
// 'PRICING_REQUEST' DRAFT, never the legacy (origin IS NULL) quotation that ruling
// protects.
requests.supersedeOpenPricingRequestOriginDraft(parent.id());
// The request's PRICING_REQUEST-origin quotations not yet issued go with it: DRAFT, and
// PENDING_APPROVAL by owner ruling 2026-10-27. See
// supersedeUnissuedPricingRequestOriginQuotations's own Javadoc for why this is a SEPARATE,
// narrower call from the decision supersede just above, and why it is safe alongside the
// "issued quotation stays live" ruling documented in the block comment above: it never
// touches an ISSUED offer, nor the legacy (origin IS NULL) quotation that ruling protects.
requests.supersedeUnissuedPricingRequestOriginQuotations(parent.id());
requests.addEvent(parent.id(), parent.ticketId(), actor.id(), actor.name(),
PricingRequestEventKind.PRICING_REQUEST_REVISED, parent.status(), PricingRequestStatus.SUPERSEDED,
request.revisionReason(), toRevisionMetadataJson(newId));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,10 +99,12 @@ public final class PricingRequestStatus {
* CHILDREN, and cancelling a parent whose children stay live is worse than not cancelling at
* all. The three statuses added above are exactly the ones that can own a READY_FOR_COSTING
* factory quote, a SUBMITTED {@code pricing_costing}, a DRAFT/APPROVED
* {@code pricing_decision}, and a DRAFT {@code quotation}; {@code
* {@code pricing_decision}, and a DRAFT or PENDING_APPROVAL {@code quotation}; {@code
* PricingRequestRepository#cancelOpenChildrenForDeadRequest} and {@code
* #supersedeOpenPricingDecisionAndQuotation} were widened in the same change to reach all
* four. If a later change admits another status here, check what it can own first.
* four. (PENDING_APPROVAL joined later: GLA-123 S2 made it reachable here, and the quotation
* predicate missed it until 2026-10-27.) If a later change admits another status here, check
* what it can own first.
*/
// SUPERSEDED stays terminal here; the one exit is PricingRequestRepository#reviveParentOfCancelledRevision (direct SQL).
private static final Map<String, Set<String>> ALLOWED = Map.ofEntries(
Expand Down
Loading
Loading