Skip to content

fix(sales): a cancelled or revised pricing request's PENDING_APPROVAL quotation is retired and can't be approved; cancel takes approve's lock - #1178

Open
waritctd wants to merge 5 commits into
developfrom
claude/hopeful-bell-7bwtty
Open

waritctd wants to merge 5 commits into
developfrom
claude/hopeful-bell-7bwtty

Conversation

@waritctd

@waritctd waritctd commented Oct 27, 2026 •

Copy link
Copy Markdown
Owner

Why

Found by the 10-27 code review of #1168 (deal-page sales CTA) and listed in its Known gaps. The frontend now ignores a cancelled request's pending quotation, but the server still let it be approved.

Cancelling a pricing request supersedes its quotations through PricingRequestRepository#supersedeOpenPricingDecisionAndQuotation. Its list was DRAFT, ISSUED, READY_TO_ISSUE, SENT, REVISION_REQUESTED, so a PRICING_REQUEST-origin quotation waiting for approval (PENDING_APPROVAL) was left as it was. It stayed in the approvals queue, and approving it:

  • issued a quotation on the cancelled request, and
  • superseded the deal's live quotation through the one-live-offer sweep (supersedeOtherLiveOnTicket).

Owner rulings, 2026-10-27, on the follow-ups the first version of this PR raised:

  • Every quotation revision needs ผจก.ขาย/CEO approval, like direct quotations. On a customer-change revision, the old request's quotation still waiting for approval is retired at revision time, and approve refuses a quotation whose request was superseded. Legacy (old-engine) revisions also need approval; that goes in a separate PR.
  • One lock: cancel and approve on the same request serialise.

What changes

Backend

  • Cancel supersedes a PENDING_APPROVAL quotation too, like the other open statuses. The audit is the same: the request's one PRICING_REQUEST_CANCELLED event, and no quotation-level event. Both callers get it: PricingRequestService#cancel, and the deal-kill cascade cancelOpenForTicket (mark lost, cancel deal).

  • Customer-change revision retires the old request's PRICING_REQUEST-origin quotations that are not yet issued: DRAFT (as before) and now PENDING_APPROVAL. The method is renamed supersedeOpenPricingRequestOriginDraft → supersedeUnissuedPricingRequestOriginQuotations. An ISSUED offer stays live until its replacement is issued (2026-08-13 ruling). Legacy (origin NULL) rows are not touched.

  • Approve refuses, with 409 and before any write, a PRICING_REQUEST-origin quotation whose request is:

    • CANCELLED: คำขอราคาของใบเสนอราคานี้ถูกยกเลิกแล้ว จึงอนุมัติไม่ได้
    • SUPERSEDED: คำขอราคาของใบเสนอราคานี้ถูกแทนที่ด้วยคำขอราคาฉบับแก้ไขแล้ว จึงอนุมัติไม่ได้

    This also covers rows orphaned before this fix. There is no data migration.

  • The lock: cancel() takes PricingRequestRepository#lockPricingRequest, the advisory lock approve takes first, before its first write. A cancel and an approve of the same request now run one after the other. If the approve got there first, the cancel is refused by its compare-and-set (409 คำขอราคาถูกแก้ไขโดยผู้ใช้อื่น…).

Mock (mockApi.js)

  • pricingRequests.cancel supersedes the request's open new-engine quotations, using the Java status list. Before this it did no quotation cascade at all.
  • pricingRequests.createCustomerChangeRevision supersedes the old request's new-engine DRAFT and PENDING_APPROVAL quotations.
  • The PRICING_REQUEST-origin approve refuses a CANCELLED or SUPERSEDED request with the same messages. Its request lookup moved above the first write; the service also 404s before writing.

Not changed

  • Who may cancel, revise or approve.
  • The deal page (#1168 already hides the CTA).
  • The deal-kill cascade's locking (see Known risks).
  • Legacy-engine revisions (a separate PR, per the ruling).
  • No migration, no API shape change.

Files changed

File Change
backend/.../pricingrequest/PricingRequestRepository.java PENDING_APPROVAL added to the cancel's quotation supersede; the revision-time supersede renamed and widened to DRAFT + PENDING_APPROVAL; the Javadocs say why
backend/.../pricingrequest/PricingRequestService.java cancel() takes the request lock before its first write; the renamed call in createCustomerChangeRevision; comments
backend/.../dealquotation/DealQuotationService.java approveAndIssuePricingRequestOrigin refuses a CANCELLED or SUPERSEDED request (409) before any write
backend/.../pricingrequest/PricingRequestStatus.java comment only: what an APPROVED_FOR_QUOTATION request can own
backend/.../dealquotation/DealQuotationPricingRequestApprovalIntegrationTest.java 8 real-DB tests
backend/.../dealquotation/PricingRequestQuotationIntegrationTest.java comment only: points at the renamed method
frontend/src/api/mockApi.js the cancel's and the revision's quotation supersede; the approve's guard
frontend/src/api/mockApi.dealQuotationPricingRequestApproval.test.js 5 mock-parity tests, one of them a 2-row table (6 cases); the fixture's request line moved into a shared const so the revision payload reuses it

Four commits: for each round, the tests first, red (test(sales): … (RED)), then the fix.

Commands run

  • Local PostgreSQL 16 (this session had no Docker), with TEST_DB_URL=jdbc:postgresql://127.0.0.1:5432/glr_it and -Dtest.fork.count=1. Two forks clobber one external database, as the pom's own note says.
  • ./mvnw -B test -Djacoco.skip=true -Dtest='DealQuotationPricingRequestApprovalIntegrationTest#<the new tests>': on each RED commit, on each fix, and with each part of the fix reverted in turn (mutation checks; the round-2 ones ran from a script that restored the files byte for byte).
  • ./mvnw -B clean test -Dtest.fork.count=1 -Djacoco.skip=true -Dtest='<20 classes>': the classes that drive the cancel, the customer-change revision, the PRICING_REQUEST-origin approve, and the advisory-lock keyspace. They are listed under results.
  • npx vitest run on 12 files:
    • 8 mock files: the parity file, pricingRequests, quotationDealLink, factoryContact, pricingRequestQuotation, dealQuotationRevision, dealQuotationOutcome, contract;
    • 4 UI files: QuotationEditorPage ×3 and TicketDetailPage, the pages that call the approve.
  • npx eslint src/api/mockApi.js src/api/mockApi.dealQuotationPricingRequestApproval.test.js
  • Not run locally: the full ./mvnw verify, the full npm test, npm run build, e2e-real. CI runs these.

Tests / build results

  • New backend tests: 8/8 red on their RED commits (assertion failures), 8/8 green on the fixes. The integration tests ran against real Postgres; none were skipped.
  • Targeted backend classes, clean build, one fork: 518 tests, 0 failures, 0 errors, 0 skipped:
    • CustomerQuotationIntegrationTest 28, PricingRequestCancelCutoffIntegrationTest 11, PricingRequestQuotationIntegrationTest 57, DealQuotationPricingRequestApprovalIntegrationTest 46
    • DealQuotationPricingRequestRevisionIntegrationTest 32, DealQuotationOutcomeIntegrationTest 35, ReissueThroughCeoChainIntegrationTest 13, SupersedeChainRecipientScopeIntegrationTest 2
    • CarryForwardNotificationIntegrationTest 1, PricingChainEndToEndIntegrationTest 2, AdvisoryLockKeyspaceIntegrationTest 3, InventoryDeliveryFulfilmentIntegrationTest 8
    • StockLineRoutingIntegrationTest 22, PricingRequestItemDirectDealFieldsIntegrationTest 26, PricingFactoryQuoteCostingIntegrationTest 46, FactoryContactFlowIntegrationTest 25
    • LeadTimeChangeIntegrationTest 25, DealQuotationControllerTest 7, PricingRequestServiceTest 112, PricingRequestControllerTest 17
  • An earlier, non-clean run of the same classes showed one red, the lock test. Cause: restoring a file after a mutation check put back an mtime older than the compiled classes, so Maven reused the mutated "lock after the first write" class. A clean build of the committed code passed the lock test 15 times out of 15, and then the whole run above.
  • Frontend: 12 files, 465 tests, green. ESLint is clean. The new mock cases were red before the fixes.

Authz evidence

No authorization change: who may cancel, revise or approve is untouched. The new refusals are state checks. They are pinned wrong-way-round through the real DealQuotationService on real Postgres for both approver roles (sales manager and CEO), with "nothing written" read back from the tables.

Known risks

  • Rows orphaned before this fix keep PENDING_APPROVAL and still show in the approvals queue. Approving one is now refused, and ตีกลับ still takes it out of the queue (back to the rep as a DRAFT). There is no data migration (the owner's call). To look for them on prod:
    SELECT q.quotation_id, q.number, pr.status FROM sales.quotation q JOIN sales.pricing_request pr ON pr.pricing_request_id = q.pricing_request_id WHERE q.origin = 'PRICING_REQUEST' AND q.doc_status = 'PENDING_APPROVAL' AND pr.status IN ('CANCELLED', 'SUPERSEDED');
  • The deal-kill cascade takes no request lock. Mark lost and cancel deal update the deal row before their cascade, while approve takes the request lock first and can update the deal row later (the stage advance). A request lock inside the cascade could therefore deadlock the other way. A mark-lost that lands at the same moment as an approve can still, in a narrow interleaving, make Postgres abort one of them; the data stays consistent. A fix needs one lock order across the deal and the request, which is a separate change.
  • A dead end, not a hole: a rep can still start a new quotation revision on a request that a customer-change revision has superseded. Its approval is then refused. Refusing it earlier, at create or submit, would be a small follow-up if wanted.
  • Mock gaps that predate this PR: the mock's cancel still leaves decisions, legacy quotations, factory quotes and costings alone.

Test coverage

Written first and run red, then implemented, in two rounds.

# Test What it pins Red before the fix Mutation check
1 cancel_supersedesTheRequestsPendingApprovalQuotation_andTakesItOutOfTheApprovalsQueue After cancel, the quotation is SUPERSEDED and out of the sales manager's approvals queue. A sibling request's PENDING_APPROVAL quotation on the same deal stays. The audit is the one PRICING_REQUEST_CANCELLED event (APPROVED_FOR_QUOTATION → CANCELLED, with the reason), and there is no DEAL_QUOTATION_SUPERSEDED event. expected "SUPERSEDED" but was "PENDING_APPROVAL" red with PENDING_APPROVAL taken out of the cancel's SQL
2 approve_afterTheRequestWasCancelled_isRefused_andTheDealsLiveQuotationStaysLive The reported bug: round 1 is ISSUED, round 2's request is cancelled, and approving round 2's quotation returns 409. Round 1 stays ISSUED, there is no supersede event, and the request stays CANCELLED. Expecting code to raise a throwable green with either half alone, by design
3 approve_ofAPendingQuotationLeftOnACancelledRequest_isRefusedForEitherApprover_andWritesNothing The guard alone, for an orphan left by the old cascade (real cancel, then the row reset to PENDING_APPROVAL). The sales manager and the CEO both get 409 with the message. Nothing is written: the row stays PENDING_APPROVAL, there is no approver snapshot, and the live offer stays ISSUED. Expecting code to raise a throwable red with the CANCELLED guard removed; red with a guard that skips the CEO
4 markLost_cancelsTheRequest_andSupersedesItsPendingApprovalQuotation The deal-kill cascade: the request is CANCELLED, the quotation is SUPERSEDED, and it is out of the queue. expected "SUPERSEDED" but was "PENDING_APPROVAL" red with PENDING_APPROVAL taken out of the cancel's SQL
5 cancel_waitsForTheRequestLockAnApproveHolds_beforeItsFirstWrite Thread A holds the request lock through CustomerQuotationRepository#lockPricingRequest, the call approve makes first. A cancel queues for it (seen in pg_locks) while the request row is still free (a FOR UPDATE NOWAIT probe succeeds). Once A releases, the cancel completes: request CANCELLED, quotation SUPERSEDED. cancel queues for the request's advisory lock … was false red with the lock removed; red with the lock moved after the first write (the probe fails with 55P03)
6 customerChangeRevision_supersedesTheOldRequestsPendingApprovalQuotation_andApproveIsRefused A revision from APPROVED_FOR_QUOTATION supersedes the old request's PENDING_APPROVAL quotation, which leaves the queue; approve returns 409. expected "SUPERSEDED" but was "PENDING_APPROVAL" red with the revision's list back to DRAFT only
7 customerChangeRevision_retiresAPendingQuotationRevision_andTheIssuedOfferStaysLive From QUOTATION_ISSUED, the revision supersedes a pending quotation revision, while the issued offer stays ISSUED (the 2026-08-13 control). Approving the pending revision returns 409. expected "SUPERSEDED" but was "PENDING_APPROVAL" red with the revision's list back to DRAFT only
8 approve_ofAPendingQuotationLeftOnASupersededRequest_isRefusedForEitherApprover_andWritesNothing The SUPERSEDED guard alone (real revision, then the row reset to PENDING_APPROVAL). Both approvers get 409 with the message, and nothing is written. Expecting code to raise a throwable red with the SUPERSEDED guard removed
9 mock: cancel supersedes a PENDING_APPROVAL quotation, which can then no longer be approved The mock's cancel cascade, and a 409 on approve. expected 'PENDING_APPROVAL' to be 'SUPERSEDED' red with the mock's cancel supersede removed
10 mock: cancel supersedes a DRAFT quotation too The shared status list. expected 'DRAFT' to be 'SUPERSEDED' red with the mock's cancel supersede removed
11 mock: a customer-change revision supersedes the old request's PENDING_APPROVAL quotation, which can then no longer be approved The mock's revision supersede, and a 409 on approve. expected 'PENDING_APPROVAL' to be 'SUPERSEDED' red with the mock's revision supersede removed
12 mock: a customer-change revision supersedes the old request's DRAFT quotation too The same list. expected 'DRAFT' to be 'SUPERSEDED' red with the mock's revision supersede removed
13 mock: approve refuses a PENDING_APPROVAL quotation left on a CANCELLED / SUPERSEDED request, for either approver (2 rows) The mock guard, with the request status planted through setPricingRequestStatusForTests. promise resolved … instead of rejecting each row red with its own guard line removed

Each mutation was run against the new backend tests (4 per round, 8 in round 2's script) or the mock file, and turned red only the tests named above.

Deliberately not covered:

  • Locking in the deal-kill cascade (not changed; see Known risks).
  • The HTTP layer: the controller passes the ApiException through unchanged.
  • Legacy (origin NULL) quotations: they never reach PENDING_APPROVAL; the legacy DRAFT-on-cancel case is pinned by PricingRequestCancelCutoffIntegrationTest.
  • Starting a quotation revision on an already-superseded request (allowed; see Known risks).
  • e2e-real.

🤖 Generated with Claude Code

https://claude.ai/code/session_01BdQVhpg72sGQrzZVzfMP5U

@vercel

vercel Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
demo-glr Ready Ready Preview Nov 1, 2026 7:07am UTC

Request Review

@waritctd waritctd changed the title fix(sales): cancelling a pricing request supersedes its PENDING_APPROVAL quotation; approve refuses a cancelled request fix(sales): a cancelled or revised pricing request's PENDING_APPROVAL quotation is retired and can't be approved; cancel takes approve's lock Oct 27, 2026
claude and others added 5 commits November 1, 2026 14:04
…rseded and cannot be approved (RED)

Cancelling a pricing request supersedes its open quotations, but the cancel's
predicate skips PENDING_APPROVAL, so a PRICING_REQUEST-origin quotation waiting
for approval stays in the approvals queue. Approving it issues a quotation on the
cancelled request and supersedes the deal's live quotation.

Real-DB tests (DealQuotationPricingRequestApprovalIntegrationTest):
- cancel supersedes the request's PENDING_APPROVAL quotation and takes it out of
  the approvals queue; a sibling request's pending quotation stays; the audit is
  the request's one PRICING_REQUEST_CANCELLED event
- approving after the cancel is refused and the deal's live quotation stays ISSUED
- a PENDING_APPROVAL quotation already orphaned on a CANCELLED request is refused
  for the sales manager and the CEO, before any write
- markLost's cascade supersedes the pending quotation too

Mock parity (mockApi.dealQuotationPricingRequestApproval.test.js): cancel
supersedes PENDING_APPROVAL and DRAFT quotations; approve refuses a quotation on
a CANCELLED request.

All seven fail on this commit; the fix follows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BdQVhpg72sGQrzZVzfMP5U
…VAL quotation; approve refuses a cancelled request

- PricingRequestRepository#supersedeOpenPricingDecisionAndQuotation adds
  PENDING_APPROVAL to the statuses it supersedes. Both callers get it:
  PricingRequestService#cancel and the deal-kill cascade #cancelOpenForTicket.
  The audit stays the request's PRICING_REQUEST_CANCELLED event, as for DRAFT
  and the issued statuses.
- DealQuotationService#approveAndIssuePricingRequestOrigin refuses (409) a
  quotation whose pricing request is CANCELLED, before any write. It covers a row
  orphaned before this change; there is no data migration.
- mockApi mirrors both: the mock's cancel supersedes the request's open
  new-engine quotations, and the mock's approve refuses a cancelled request.
- Comments that listed what an APPROVED_FOR_QUOTATION request can own now include
  a PENDING_APPROVAL quotation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BdQVhpg72sGQrzZVzfMP5U
…ge revision retires the old request's pending quotation (RED)

Owner rulings 2026-10-27: one lock between cancel and approve, and a quotation
still waiting for ผจก.ขาย/CEO approval on a request that a customer-change
revision supersedes is retired, not left approvable.

Real-DB tests (DealQuotationPricingRequestApprovalIntegrationTest):
- cancel queues for the request's advisory lock that an approve holds, before its
  first write (a FOR UPDATE NOWAIT probe finds the request row free while it
  waits), and completes once the lock frees
- a customer-change revision from APPROVED_FOR_QUOTATION supersedes the old
  request's PENDING_APPROVAL quotation; approving it is refused
- from QUOTATION_ISSUED it supersedes a pending quotation revision while the
  issued offer stays ISSUED (2026-08-13 ruling)
- approve refuses a PENDING_APPROVAL quotation left on a SUPERSEDED request, for
  the sales manager and the CEO, before any write

Mock parity: a customer-change revision supersedes the old request's PENDING_APPROVAL
and DRAFT quotations; approve refuses a SUPERSEDED request too (the CANCELLED case
becomes a row of the same table).

The new cases fail on this commit; the fix follows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BdQVhpg72sGQrzZVzfMP5U
…hange revision retires the old request's pending quotation; approve refuses a superseded request

- PricingRequestService#cancel takes PricingRequestRepository#lockPricingRequest,
  the advisory lock DealQuotationService#approveAndIssuePricingRequestOrigin takes
  first, before its first write. A cancel and an approve of the same request now
  serialise instead of updating its quotation and request rows in opposite
  orders. The deal-kill cascade is unchanged: mark lost / cancel deal update the
  deal row before it, so a request lock there could deadlock the other way.
- PricingRequestRepository#supersedeOpenPricingRequestOriginDraft becomes
  #supersedeUnissuedPricingRequestOriginQuotations and supersedes PENDING_APPROVAL
  as well as DRAFT. An ISSUED offer stays live until its replacement is issued
  (2026-08-13 ruling); legacy (origin IS NULL) rows stay out of reach.
- DealQuotationService#approveAndIssuePricingRequestOrigin refuses (409) a
  quotation whose request is SUPERSEDED, with its own message, beside CANCELLED.
- mockApi mirrors the revision supersede and the SUPERSEDED guard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BdQVhpg72sGQrzZVzfMP5U
…ding quotation was retired (pins #1216 interaction)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

This branch was successfully deployed

1 active deployment
Preview — b788261f Deployed Nov 1, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants