Repository navigation
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
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
…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>
waritctd
force-pushed
the
claude/hopeful-bell-7bwtty
branch
from
November 1, 2026 07:07
f24801b to
b788261
Compare
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 wasDRAFT, 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:supersedeOtherLiveOnTicket).Owner rulings, 2026-10-27, on the follow-ups the first version of this PR raised:
What changes
Backend
Cancel supersedes a
PENDING_APPROVALquotation too, like the other open statuses. The audit is the same: the request's onePRICING_REQUEST_CANCELLEDevent, and no quotation-level event. Both callers get it:PricingRequestService#cancel, and the deal-kill cascadecancelOpenForTicket(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 renamedsupersedeOpenPricingRequestOriginDraft→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()takesPricingRequestRepository#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.cancelsupersedes the request's open new-engine quotations, using the Java status list. Before this it did no quotation cascade at all.pricingRequests.createCustomerChangeRevisionsupersedes the old request's new-engine DRAFT and PENDING_APPROVAL quotations.Not changed
Files changed
backend/.../pricingrequest/PricingRequestRepository.javaPENDING_APPROVALadded to the cancel's quotation supersede; the revision-time supersede renamed and widened to DRAFT + PENDING_APPROVAL; the Javadocs say whybackend/.../pricingrequest/PricingRequestService.javacancel()takes the request lock before its first write; the renamed call increateCustomerChangeRevision; commentsbackend/.../dealquotation/DealQuotationService.javaapproveAndIssuePricingRequestOriginrefuses a CANCELLED or SUPERSEDED request (409) before any writebackend/.../pricingrequest/PricingRequestStatus.javabackend/.../dealquotation/DealQuotationPricingRequestApprovalIntegrationTest.javabackend/.../dealquotation/PricingRequestQuotationIntegrationTest.javafrontend/src/api/mockApi.jsfrontend/src/api/mockApi.dealQuotationPricingRequestApproval.test.jsFour commits: for each round, the tests first, red (
test(sales): … (RED)), then the fix.Commands run
TEST_DB_URL=jdbc:postgresql://127.0.0.1:5432/glr_itand-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 runon 12 files:pricingRequests,quotationDealLink,factoryContact,pricingRequestQuotation,dealQuotationRevision,dealQuotationOutcome,contract;QuotationEditorPage×3 andTicketDetailPage, the pages that call the approve.npx eslint src/api/mockApi.js src/api/mockApi.dealQuotationPricingRequestApproval.test.js./mvnw verify, the fullnpm test,npm run build, e2e-real. CI runs these.Tests / build results
CustomerQuotationIntegrationTest28,PricingRequestCancelCutoffIntegrationTest11,PricingRequestQuotationIntegrationTest57,DealQuotationPricingRequestApprovalIntegrationTest46DealQuotationPricingRequestRevisionIntegrationTest32,DealQuotationOutcomeIntegrationTest35,ReissueThroughCeoChainIntegrationTest13,SupersedeChainRecipientScopeIntegrationTest2CarryForwardNotificationIntegrationTest1,PricingChainEndToEndIntegrationTest2,AdvisoryLockKeyspaceIntegrationTest3,InventoryDeliveryFulfilmentIntegrationTest8StockLineRoutingIntegrationTest22,PricingRequestItemDirectDealFieldsIntegrationTest26,PricingFactoryQuoteCostingIntegrationTest46,FactoryContactFlowIntegrationTest25LeadTimeChangeIntegrationTest25,DealQuotationControllerTest7,PricingRequestServiceTest112,PricingRequestControllerTest17Authz 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
DealQuotationServiceon real Postgres for both approver roles (sales manager and CEO), with "nothing written" read back from the tables.Known risks
PENDING_APPROVALand 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');Test coverage
Written first and run red, then implemented, in two rounds.
cancel_supersedesTheRequestsPendingApprovalQuotation_andTakesItOutOfTheApprovalsQueuePRICING_REQUEST_CANCELLEDevent (APPROVED_FOR_QUOTATION → CANCELLED, with the reason), and there is noDEAL_QUOTATION_SUPERSEDEDevent.expected "SUPERSEDED" but was "PENDING_APPROVAL"PENDING_APPROVALtaken out of the cancel's SQLapprove_afterTheRequestWasCancelled_isRefused_andTheDealsLiveQuotationStaysLiveExpecting code to raise a throwableapprove_ofAPendingQuotationLeftOnACancelledRequest_isRefusedForEitherApprover_andWritesNothingExpecting code to raise a throwablemarkLost_cancelsTheRequest_andSupersedesItsPendingApprovalQuotationexpected "SUPERSEDED" but was "PENDING_APPROVAL"PENDING_APPROVALtaken out of the cancel's SQLcancel_waitsForTheRequestLockAnApproveHolds_beforeItsFirstWriteCustomerQuotationRepository#lockPricingRequest, the call approve makes first. A cancel queues for it (seen inpg_locks) while the request row is still free (aFOR UPDATE NOWAITprobe succeeds). Once A releases, the cancel completes: request CANCELLED, quotation SUPERSEDED.cancel queues for the request's advisory lock … was falsecustomerChangeRevision_supersedesTheOldRequestsPendingApprovalQuotation_andApproveIsRefusedexpected "SUPERSEDED" but was "PENDING_APPROVAL"customerChangeRevision_retiresAPendingQuotationRevision_andTheIssuedOfferStaysLiveexpected "SUPERSEDED" but was "PENDING_APPROVAL"approve_ofAPendingQuotationLeftOnASupersededRequest_isRefusedForEitherApprover_andWritesNothingExpecting code to raise a throwableexpected 'PENDING_APPROVAL' to be 'SUPERSEDED'expected 'DRAFT' to be 'SUPERSEDED'expected 'PENDING_APPROVAL' to be 'SUPERSEDED'expected 'DRAFT' to be 'SUPERSEDED'setPricingRequestStatusForTests.promise resolved … instead of rejectingEach 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:
ApiExceptionthrough unchanged.PricingRequestCancelCutoffIntegrationTest.🤖 Generated with Claude Code
https://claude.ai/code/session_01BdQVhpg72sGQrzZVzfMP5U