diff --git a/backend/src/main/java/th/co/glr/hr/dealquotation/DealQuotationRepository.java b/backend/src/main/java/th/co/glr/hr/dealquotation/DealQuotationRepository.java index 06716828e..656cb6db8 100644 --- a/backend/src/main/java/th/co/glr/hr/dealquotation/DealQuotationRepository.java +++ b/backend/src/main/java/th/co/glr/hr/dealquotation/DealQuotationRepository.java @@ -282,10 +282,12 @@ public Optional findOpenDraftForPricingRequest(long pricingRequestId) { * most recent PRICING_REQUEST-origin quotation for this pricing request, REGARDLESS of * status — used by {@code DealQuotationService#createFromPricingRequest} to continue the SAME * {@code {base}-{n}} number family when re-creating after an EXPIRED quotation, rather than - * minting an unrelated fresh base number. There is at most one LIVE row at a time (M2/the - * one-quotation-per-request rule) and this origin has no revision chain (D12), so "most - * recent by id" and "the only row" coincide in every reachable case today; ORDER BY id DESC - * is still the honest query to write in case that ever stops being true. + * minting an unrelated fresh base number. This origin has a revision chain: {@code + * DealQuotationService#createRevision} copies {@code origin} and {@code pricing_request_id} + * from an ISSUED or REVISION_REQUESTED source, so one pricing request can hold several rows + * and the newest by id can be an open revision DRAFT. Newest by id is what continuing the + * number family needs; a caller that needs the customer-accepted row uses {@link + * #findAcceptedForPricingRequest} instead. */ public Optional findLatestForPricingRequest(long pricingRequestId) { try { @@ -300,6 +302,27 @@ public Optional findLatestForPricingRequest(long pricingRequestId) { } } + /** + * The customer-ACCEPTED {@code PRICING_REQUEST}-origin quotation for this pricing request, + * newest by id. {@code OrderConfirmationService#createDepositNoticeFromQuotation} uses this + * rather than {@link #findLatestForPricingRequest} because an open revision draft can outrank + * the accepted row by id: {@code DealQuotationService#recordOutcome} has no open-revision + * guard, so the customer can accept Q1 while its revision Q2 is still a DRAFT. + */ + public Optional findAcceptedForPricingRequest(long pricingRequestId) { + try { + return Optional.ofNullable(jdbc.queryForObject(""" + SELECT quotation_id FROM sales.quotation + WHERE pricing_request_id = :pricingRequestId AND origin = 'PRICING_REQUEST' + AND doc_status = 'ACCEPTED' + ORDER BY quotation_id DESC + LIMIT 1 + """, Map.of("pricingRequestId", pricingRequestId), Long.class)); + } catch (EmptyResultDataAccessException e) { + return Optional.empty(); + } + } + /** One accepted line's reconciliation-relevant numbers — {@link #qty} is {@code * quotation_item.qty} (see {@link #findAcceptedItemQuantitiesByPricingRequest}'s own Javadoc); * {@link #sqmPerPiece} is {@code quotation_item.sqm_per_piece} (V165), read from the SAME row diff --git a/backend/src/main/java/th/co/glr/hr/deposit/DepositNoticeRepository.java b/backend/src/main/java/th/co/glr/hr/deposit/DepositNoticeRepository.java index 87663b6ac..7fe0ee83b 100644 --- a/backend/src/main/java/th/co/glr/hr/deposit/DepositNoticeRepository.java +++ b/backend/src/main/java/th/co/glr/hr/deposit/DepositNoticeRepository.java @@ -113,13 +113,15 @@ public List findByTicket(long ticketId) { * single live quotation, preferring ACCEPTED, then ISSUED, then APPROVED (DEAL_DIRECT's * own only live terminal state), newest by id. * - * A given ticket's quotations are only ever ALL-legacy or ALL-origin-tagged (each engine - * stamps {@code origin} consistently for every row it writes), so at most one branch below - * ever matches — trying legacy first is a fixed, harmless order, not a real preference - * between the two lineages. Returns empty when there is no live (ACCEPTED/ISSUED/APPROVED) - * quotation for this ticket, or its {@code deposit_percent} is null — the caller ({@link - * DepositNoticeService#createDraft}) falls back to 0.50 in that case, this document's - * long-standing default. + * Engines are exclusive per pricing request, not per ticket ({@code + * CustomerQuotationService#create} and {@code DealQuotationService#createFromPricingRequest} + * each refuse a pricing request the other engine already quoted), so one ticket can carry a + * legacy quotation on one pricing request and an origin-tagged one on another. On such a mixed + * ticket both branches below can match, and trying legacy first IS a real preference: the + * percent comes from the legacy row. Returns empty when there is no live + * (ACCEPTED/ISSUED/APPROVED) quotation for this ticket, or its {@code deposit_percent} is + * null — the caller ({@link DepositNoticeService#createDraft}) falls back to 0.50 in that + * case, this document's long-standing default. */ public Optional findDealQuotationDepositPercent(long ticketId) { Integer legacyPct = firstOrNull(jdbc.query(""" @@ -394,11 +396,7 @@ private void insertItems(long docId, List items) { * Owner-confirmable: that a deposit may autofill from an ISSUED-but-not-yet-ACCEPTED * PRICING_REQUEST quote (legacy parity) and from an APPROVED DEAL_DIRECT quote — this is item * autofill for a draft the user reviews before saving, not the deposit gate itself. - * - *

Reads the RESOLVED display columns the quotation itself prints - * ({@code qty}/{@code raw_unit}/{@code unit_price}/{@code final_unit_price}), NOT the legacy - * {@code requested_*}/{@code approved_unit_price} columns the V2 chain leaves null. The - * discount-label wording matches {@link DepositNoticeService#itemsFromQuotation}. + * The lines themselves come from {@link #findDealQuotationItems}. */ public List findDealQuotationItemsForDeposit(long ticketId) { List ids = jdbc.query(""" @@ -416,12 +414,21 @@ AND doc_status IN ('ACCEPTED', 'ISSUED', 'APPROVED') if (ids.isEmpty()) { return List.of(); } + return findDealQuotationItems(ids.get(0)); + } + + /** One deal quotation's {@code sales.quotation_item} lines, by {@code seq}, as deposit-notice + * items. Reads the RESOLVED display columns the quotation itself prints + * ({@code qty}/{@code raw_unit}/{@code unit_price}/{@code final_unit_price}), NOT the legacy + * {@code requested_*}/{@code approved_unit_price} columns the V2 chain leaves null. The + * discount-label wording matches {@link DepositNoticeService#itemsFromQuotation}. */ + public List findDealQuotationItems(long quotationId) { return jdbc.query(""" SELECT seq, description, qty, raw_unit, unit_price, sales_discount, final_unit_price FROM sales.quotation_item WHERE quotation_id = :quotationId ORDER BY seq - """, Map.of("quotationId", ids.get(0)), (rs, i) -> { + """, Map.of("quotationId", quotationId), (rs, i) -> { String description = rs.getString("description"); if (description == null || description.isBlank()) { description = "รายการสินค้า"; 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 abf49127e..b2fa63925 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 @@ -92,6 +92,15 @@ public DepositNoticeDto getById(long docId, UserPrincipal actor) { // Create a DRAFT from approved ticket items @Transactional public DepositNoticeDto createDraft(long ticketId, DepositNoticeDraftRequest req, UserPrincipal actor) { + return createDraftFromQuotation(ticketId, req, null, actor); + } + + /** {@link #createDraft} for a caller that has already chosen the source quotation: a non-null + * {@code sourceDepositPercent} (that quotation's own whole-number {@code deposit_percent}) is + * the draft's percent, instead of the per-ticket lookup. */ + @Transactional + public DepositNoticeDto createDraftFromQuotation(long ticketId, DepositNoticeDraftRequest req, + Integer sourceDepositPercent, UserPrincipal actor) { requireRole(actor, SALES_ROLES); TicketSummaryDto s = requireApprovedTicket(ticketId, actor); requireActiveLifecycle(s); @@ -129,8 +138,9 @@ public DepositNoticeDto createDraft(long ticketId, DepositNoticeDraftRequest req // read a percent from at all (e.g. a manually-approved legacy ticket with no quotation // chain) — see DepositNoticeRepository#findDealQuotationDepositPercent's own Javadoc for // the full pick order. - BigDecimal effectiveDepositPercent = docs.findDealQuotationDepositPercent(ticketId) - .orElse(new BigDecimal("0.50")); + BigDecimal effectiveDepositPercent = sourceDepositPercent != null + ? BigDecimal.valueOf(sourceDepositPercent, 2) + : docs.findDealQuotationDepositPercent(ticketId).orElse(new BigDecimal("0.50")); var effective = new DepositNoticeDraftRequest( req.customerName() != null ? req.customerName() : s.customerName(), @@ -1271,6 +1281,12 @@ private CustomerQuotationDto pickQuotation(List candidates return latestAccepted != null ? latestAccepted : latestIssued; } + /** The pricing-request route's new-engine item source, so the draft's rows come from the + * quotation its reference names. */ + public List itemsFromDealQuotation(long quotationId) { + return docs.findDealQuotationItems(quotationId); + } + /** * Maps a customer quotation's items to deposit-notice item requests. The single shared mapping * used by both {@link #buildItemsFromRequest} (this class's own ticket-chain fallback above) 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 f7c000c25..5ac3ab64e 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 @@ -82,10 +82,11 @@ public class OrderConfirmationService { private final CustomerQuotationRepository quotations; private final DepositNoticeService depositNotices; private final NotificationRepository notifications; - // GLA-123 slice S3 BLOCKER 2 fix — read-only dependency onto the new engine, used ONLY by + // GLA-123 slice S3 BLOCKER 2 fix — read-only dependency onto the new engine, used by // #reconcileTicketItems to prefer an ACCEPTED PRICING_REQUEST-origin quotation's own line - // quantities over the PR item's authored ones. SETTER-injected (not a constructor parameter), - // matching DealQuotationService's own established device (see its + // quantities over the PR item's authored ones, and by #createDepositNoticeFromQuotation to + // find the pricing request's ACCEPTED new-engine quotation. SETTER-injected (not a + // constructor parameter), matching DealQuotationService's own established device (see its // #wirePricingRequestDependencies Javadoc): 12 existing hand-wired // `new OrderConfirmationService(...)` test call sites keep compiling unchanged, Spring wires // it automatically via the @Autowired setter in production, and #reconcileTicketItems @@ -106,8 +107,9 @@ public OrderConfirmationService(PricingRequestRepository pricingRequests, Ticket } /** GLA-123 slice S3 BLOCKER 2 fix — see {@link #dealQuotations}'s own Javadoc for why this is - * setter- rather than constructor-injected. {@code required = false} so a hand-wired test that - * never exercises a PRICING_REQUEST-origin confirmOrder is unaffected either way. */ + * setter- rather than constructor-injected. {@code required = false}: an unwired instance + * falls back to the PR item's own quantity in confirmOrder, and answers 409 on the new-engine + * branch of createDepositNoticeFromQuotation. */ @org.springframework.beans.factory.annotation.Autowired(required = false) public void wireDealQuotationRepository(th.co.glr.hr.dealquotation.DealQuotationRepository dealQuotations) { this.dealQuotations = dealQuotations; @@ -422,12 +424,29 @@ private String mapUnitBasisToTicketItem(String unitBasis) { *

Deposit percent (owner ruling, 2026-09-29): {@code * request.depositPercent()} below is passed through but {@link * DepositNoticeService#createDraft} now IGNORES it unconditionally and re-derives the percent - * itself from {@code accepted}'s own {@code sales.quotation.deposit_percent} via {@code + * itself from {@code accepted}'s own {@code sales.quotation.deposit_percent} (legacy branch; + * the new-engine paragraph below covers the other branch) via {@code * DepositNoticeRepository#findDealQuotationDepositPercent(ticketId)} — this call site needs no * separate fix because it already funnels through that one shared method. Left the field on * {@link CreateDepositNoticeFromQuotationRequest} itself unremoved (an older frontend build * may still send it) rather than making it a breaking DTO change for a value that is now a * no-op either way. + * + *

New-engine quotation: when the pricing request has no ACCEPTED legacy + * quotation, this falls back to its ACCEPTED new-engine {@code PRICING_REQUEST}-origin + * quotation ({@code DealQuotationService#createFromPricingRequest}), found by {@code + * DealQuotationRepository#findAcceptedForPricingRequest} (its Javadoc says why "latest" is + * the wrong pick). The items come from that quotation, + * the accepted quotation of THIS pricing request ({@link + * DepositNoticeService#itemsFromDealQuotation}), so a sibling pricing request's quotation on + * the same ticket can never supply them. The percent also comes from that quotation: its own + * {@code deposit_percent} goes to {@link DepositNoticeService#createDraftFromQuotation}, so + * when the quotation sets a percent a sibling pricing request's legacy row on the same ticket + * cannot supply it either (a new-engine sibling never could: R8 allows one ACCEPTED quotation + * per ticket and the lookup prefers ACCEPTED); a null percent still falls back to the + * per-ticket lookup. The legacy branch passes no percent, because {@code CustomerQuotationDto} + * carries none, so it keeps {@code createDraft}'s per-ticket derivation; the ticket-level + * route does the same. The customer must have accepted: an ISSUED-only quotation answers 409. */ @Transactional public DepositNoticeDto createDepositNoticeFromQuotation(long pricingRequestId, @@ -439,23 +458,48 @@ public DepositNoticeDto createDepositNoticeFromQuotation(long pricingRequestId, CustomerQuotationDto accepted = quotations.findByPricingRequest(pricingRequestId).stream() .filter(q -> QuotationStatus.ACCEPTED.equals(q.docStatus())) .findFirst() - .orElseThrow(() -> new ApiException(HttpStatus.CONFLICT, - "ยังไม่มีใบเสนอราคาที่ลูกค้ายอมรับสำหรับคำขอราคานี้")); - - // Mapping itself now lives on DepositNoticeService (this branch's fix), shared with that - // class's own new-chain item fallback in buildItemsFromRequest — see its Javadoc. - List items = DepositNoticeService.itemsFromQuotation(accepted.items()); + .orElse(null); + + String reference; + List items; + Integer sourceDepositPercent; + if (accepted != null) { + reference = accepted.number(); + items = DepositNoticeService.itemsFromQuotation(accepted.items()); + sourceDepositPercent = null; + } else { + th.co.glr.hr.dealquotation.DealQuotationDtos.DealQuotationDto newEngine = dealQuotations == null + ? null + : dealQuotations.findAcceptedForPricingRequest(pricingRequestId) + .flatMap(dealQuotations::findById) + .orElse(null); + if (newEngine == null) { + 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. + if (Integer.valueOf(0).equals(newEngine.depositPercent())) { + throw new ApiException(HttpStatus.CONFLICT, "ใบเสนอราคา " + newEngine.number() + + " ระบุไม่มีมัดจำ (ชำระเต็มจำนวน) จึงออกใบแจ้งยอดเงินรับมัดจำไม่ได้"); + } + reference = newEngine.number(); + items = depositNotices.itemsFromDealQuotation(newEngine.id()); + sourceDepositPercent = newEngine.depositPercent(); + } // request.depositPercent() is deliberately still passed through here even though // createDraft now ignores it as the source — see this method's own Javadoc above. DepositNoticeDraftRequest draftRequest = new DepositNoticeDraftRequest( - null, null, null, null, accepted.number(), + null, null, null, null, reference, request != null ? request.depositPercent() : null, null, items); - DepositNoticeDto draft = depositNotices.createDraft(summary.ticketId(), draftRequest, actor); + DepositNoticeDto draft = depositNotices.createDraftFromQuotation(summary.ticketId(), draftRequest, + sourceDepositPercent, actor); pricingRequests.addEvent(pricingRequestId, summary.ticketId(), actor.id(), actor.name(), PricingRequestEventKind.DEPOSIT_NOTICE_DRAFTED_FROM_QUOTATION, summary.status(), summary.status(), - "สร้างร่างใบแจ้งยอดเงินรับมัดจำจากใบเสนอราคา " + accepted.number(), null); + "สร้างร่างใบแจ้งยอดเงินรับมัดจำจากใบเสนอราคา " + reference, null); return draft; } 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 3db12a414..08b841ab7 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 @@ -33,6 +33,8 @@ import th.co.glr.hr.dealquotation.DealQuotationDtos.DealQuotationDto; import th.co.glr.hr.dealquotation.DealQuotationRequests.ApproveRequest; import th.co.glr.hr.dealquotation.DealQuotationRequests.RecordOutcomeRequest; +import th.co.glr.hr.deposit.DepositNoticeDraftRequest; +import th.co.glr.hr.deposit.DepositNoticeDto; import th.co.glr.hr.deposit.DepositNoticeRenderer; import th.co.glr.hr.deposit.DepositNoticeRepository; import th.co.glr.hr.deposit.DepositNoticeService; @@ -106,6 +108,7 @@ class DealQuotationOutcomeIntegrationTest extends AbstractPostgresIntegrationTes private CustomerQuotationRepository legacyQuotationRepository; private CustomerQuotationService legacyQuotationService; private OrderConfirmationService orderConfirmation; + private DepositNoticeService depositNoticeService; private TicketRepository tickets; private TicketService ticketService; @@ -184,7 +187,7 @@ decisionRepository, tickets, ticketService, customers, new QuotationRenderer(), new DiscountApprovalRepository(jdbc)); DepositNoticeRepository depositNoticeRepository = new DepositNoticeRepository(jdbc); - DepositNoticeService depositNoticeService = new DepositNoticeService(depositNoticeRepository, tickets, + depositNoticeService = new DepositNoticeService(depositNoticeRepository, tickets, notifications, new DepositNoticeRenderer(), new RemainingInvoiceRenderer(), customers, legacyQuotationRepository); orderConfirmation = new OrderConfirmationService( @@ -239,6 +242,144 @@ void recordOutcome_byOwningSales_accepted_setsPrStatusAndDocStatus() { .isEqualTo(PricingRequestStatus.QUOTATION_ACCEPTED); } + // ── Deposit notice from the pricing-request route when the accepted quotation is NEW-engine ── + // QA finding 2026-10-18 (develop QA stack): DealDepositPanel/DepositNoticePage call + // POST /pricing-requests/{id}/deposit-notice whenever a QUOTATION_ACCEPTED pricing request exists; + // that route read only legacy CustomerQuotation rows and answered 409 for every deal quoted + // through DealQuotationService.createFromPricingRequest. Direct quotations never take this route. + + @Test + void createDepositNoticeFromQuotation_newEngineAcceptedQuotation_createsTheSameDraftTheTicketRouteWould() { + DealQuotationDto accepted = acceptedNewOriginQuotation(PricingRequestRecipient.DESIGNER, new BigDecimal("10")); + long pricingRequestId = accepted.pricingRequestId(); + orderConfirmation.confirmOrder(pricingRequestId, + new OrderConfirmationRequests.ConfirmOrderRequest(UUID.randomUUID().toString()), salesActor); + + DepositNoticeDto viaPricingRequest = orderConfirmation.createDepositNoticeFromQuotation(pricingRequestId, + new OrderConfirmationRequests.CreateDepositNoticeFromQuotationRequest(null), salesActor); + + // The reference is the accepted quotation's number — the one thing this route adds over the ticket route. + assertThat(viaPricingRequest.reference()).isEqualTo(accepted.number()); + assertThat(viaPricingRequest.items()).isNotEmpty(); + + // Items and percent are exactly what the ticket-level route produces for this deal (shared createDraft). + DepositNoticeDto viaTicket = depositNoticeService.createDraft(ticketId, + new DepositNoticeDraftRequest(null, null, null, null, null, null, null, null), salesActor); + assertThat(viaPricingRequest.depositPercent()).isEqualByComparingTo(viaTicket.depositPercent()); + assertThat(viaPricingRequest.items()) + .usingRecursiveComparison().ignoringFields("id") + .isEqualTo(viaTicket.items()); + + assertThat(jdbc.queryForObject(""" + SELECT COUNT(*) FROM sales.pricing_request_event + WHERE pricing_request_id = :id AND event_kind = 'DEPOSIT_NOTICE_DRAFTED_FROM_QUOTATION' + """, Map.of("id", pricingRequestId), Long.class)).isEqualTo(1L); + } + + @Test + void createDepositNoticeFromQuotation_newEngineIssuedNotAccepted_still409() { + DealQuotationDto issued = issuedNewOriginQuotation(PricingRequestRecipient.DESIGNER, new BigDecimal("10")); + long pricingRequestId = issued.pricingRequestId(); + + assertThatThrownBy(() -> orderConfirmation.createDepositNoticeFromQuotation(pricingRequestId, + new OrderConfirmationRequests.CreateDepositNoticeFromQuotationRequest(null), salesActor)) + .isInstanceOf(ApiException.class) + .hasMessageContaining("ยังไม่มีใบเสนอราคาที่ลูกค้ายอมรับสำหรับคำขอราคานี้"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM sales.deposit_notice WHERE ticket_id = :id", + Map.of("id", ticketId), Long.class)).isZero(); + } + + /** A zero-deposit quotation ("ไม่รับมัดจำ", full payment) has no deposit to bill: the route must + * refuse rather than draft a 0-baht document, which nothing downstream would stop from being + * issued ({@code DepositNoticeService#issue} reads the ticket's deposit_policy, which no path + * syncs from a 0% quotation). */ + @Test + void createDepositNoticeFromQuotation_newEngineAcceptedZeroDeposit_409AndNoDraft() { + DealQuotationDto accepted = acceptedZeroDepositNewOriginQuotation(PricingRequestRecipient.DESIGNER, + new BigDecimal("10")); + long pricingRequestId = accepted.pricingRequestId(); + assertThat(accepted.depositPercent()).isZero(); + orderConfirmation.confirmOrder(pricingRequestId, + new OrderConfirmationRequests.ConfirmOrderRequest(UUID.randomUUID().toString()), salesActor); + + assertThatThrownBy(() -> orderConfirmation.createDepositNoticeFromQuotation(pricingRequestId, + new OrderConfirmationRequests.CreateDepositNoticeFromQuotationRequest(null), salesActor)) + .isInstanceOf(ApiException.class) + .hasFieldOrPropertyWithValue("status", HttpStatus.CONFLICT) + .hasMessageContaining("ไม่มีมัดจำ"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM sales.deposit_notice WHERE ticket_id = :id", + Map.of("id", ticketId), Long.class)).isZero(); + } + + @Test + void createDepositNoticeFromQuotation_newEngine_nonOwnerSales_cannotReach() { + DealQuotationDto accepted = acceptedNewOriginQuotation(PricingRequestRecipient.DESIGNER, new BigDecimal("10")); + long pricingRequestId = accepted.pricingRequestId(); + orderConfirmation.confirmOrder(pricingRequestId, + new OrderConfirmationRequests.ConfirmOrderRequest(UUID.randomUUID().toString()), salesActor); + + assertThatThrownBy(() -> orderConfirmation.createDepositNoticeFromQuotation(pricingRequestId, + new OrderConfirmationRequests.CreateDepositNoticeFromQuotationRequest(null), otherSalesActor)) + .isInstanceOf(ApiException.class) + .hasMessageContaining("ไม่มีสิทธิ์เข้าถึงรายการนี้"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM sales.deposit_notice WHERE ticket_id = :id", + Map.of("id", ticketId), Long.class)).isZero(); + } + + /** An open revision draft outranks the accepted quotation by id: recordOutcome has no + * open-revision guard, so "latest for this pricing request" is not "accepted". */ + @Test + void createDepositNoticeFromQuotation_newEngineAcceptedWithAnOpenRevisionDraft_stillDraftsFromTheAcceptedOne() { + DealQuotationDto q1 = issuedNewOriginQuotation(PricingRequestRecipient.DESIGNER, new BigDecimal("10")); + DealQuotationDto q2 = quotationService.createRevision(q1.id(), salesActor); + assertThat(q2.id()).isGreaterThan(q1.id()); + assertThat(q2.docStatus()).isEqualTo(QuotationStatus.DRAFT); + quotationService.recordOutcome(q1.id(), + new RecordOutcomeRequest(QuotationStatus.ACCEPTED, "ลูกค้าโอเค", UUID.randomUUID().toString()), salesActor); + long pricingRequestId = q1.pricingRequestId(); + orderConfirmation.confirmOrder(pricingRequestId, + new OrderConfirmationRequests.ConfirmOrderRequest(UUID.randomUUID().toString()), salesActor); + + DepositNoticeDto draft = orderConfirmation.createDepositNoticeFromQuotation(pricingRequestId, + new OrderConfirmationRequests.CreateDepositNoticeFromQuotationRequest(null), salesActor); + + assertThat(draft.reference()).isEqualTo(q1.number()); + assertThat(draft.items()).isNotEmpty(); + } + + /** Engines are exclusive per pricing request, not per ticket: a sibling pricing request's + * legacy ISSUED quotation must not supply the lines or the percent of this pricing request's + * draft. */ + @Test + void createDepositNoticeFromQuotation_newEngineAccepted_whenASiblingPricingRequestHasALegacyIssuedQuotation_draftsThisQuotationsLinesAndPercent() { + CustomerQuotationDto legacyIssued = issuedLegacyQuotation(PricingRequestRecipient.OWNER); + // No code path writes deposit_percent on a legacy row; this stands in for one that carries it. + assertThat(jdbc.update("UPDATE sales.quotation SET deposit_percent = 70 WHERE quotation_id = :id", + Map.of("id", legacyIssued.id()))).isEqualTo(1); + DealQuotationDto accepted = acceptedNewOriginQuotation(PricingRequestRecipient.DESIGNER, new BigDecimal("10"), 30); + long pricingRequestId = accepted.pricingRequestId(); + orderConfirmation.confirmOrder(pricingRequestId, + new OrderConfirmationRequests.ConfirmOrderRequest(UUID.randomUUID().toString()), salesActor); + + DepositNoticeDto draft = orderConfirmation.createDepositNoticeFromQuotation(pricingRequestId, + new OrderConfirmationRequests.CreateDepositNoticeFromQuotationRequest(null), salesActor); + + List thisQuotationsLines = jdbc.query(""" + SELECT qty, final_unit_price FROM sales.quotation_item WHERE quotation_id = :id ORDER BY seq + """, Map.of("id", accepted.id()), + (rs, i) -> line(rs.getBigDecimal("qty"), rs.getBigDecimal("final_unit_price"))); + List siblingLegacyLines = DepositNoticeService.itemsFromQuotation(legacyIssued.items()).stream() + .map(it -> line(it.qty(), it.netUnitPrice())).toList(); + // The fixture must make the two sources distinguishable, or the assertion below proves nothing. + assertThat(thisQuotationsLines).isNotEmpty().isNotEqualTo(siblingLegacyLines); + + assertThat(draft.reference()).isEqualTo(accepted.number()); + assertThat(draft.items().stream().map(it -> line(it.qty(), it.netUnitPrice())).toList()) + .isEqualTo(thisQuotationsLines); + assertThat(accepted.depositPercent()).isEqualTo(30); + assertThat(draft.depositPercent()).isEqualByComparingTo("0.30"); + } + @Test void recordOutcome_byNonOwningSales_refused_writesNothing() { DealQuotationDto issued = issuedNewOriginQuotation(PricingRequestRecipient.DESIGNER, new BigDecimal("10")); @@ -798,16 +939,48 @@ void recordOutcome_crossEngineClientRequestIdCollision_newThenLegacy_refused409( // Fixture helpers // ───────────────────────────────────────────────────────────────────────────────────── + private static String line(BigDecimal qty, BigDecimal netUnitPrice) { + return (qty == null ? "null" : qty.stripTrailingZeros().toPlainString()) + " @ " + + (netUnitPrice == null ? "null" : netUnitPrice.stripTrailingZeros().toPlainString()); + } + private DealQuotationDto acceptedNewOriginQuotation(String recipient, BigDecimal qty) { - DealQuotationDto issued = issuedNewOriginQuotation(recipient, qty); + return acceptedNewOriginQuotation(recipient, qty, null); + } + + private DealQuotationDto acceptedNewOriginQuotation(String recipient, BigDecimal qty, Integer depositPercent) { + DealQuotationDto issued = issuedNewOriginQuotation(recipient, qty, depositPercent); + return quotationService.recordOutcome(issued.id(), + new RecordOutcomeRequest(QuotationStatus.ACCEPTED, "ลูกค้าโอเค", UUID.randomUUID().toString()), salesActor); + } + + /** Like {@link #acceptedNewOriginQuotation} but with depositPercent 0, which {@code submit} + * only accepts together with a full-payment term ({@code resolveFullPaymentTerm}). */ + private DealQuotationDto acceptedZeroDepositNewOriginQuotation(String recipient, BigDecimal qty) { + PricingDecisionDto decision = approvedDecision(recipient, qty); + DealQuotationDto draft = quotationService.createFromPricingRequest(decision.pricingRequestId(), salesActor); + DealQuotationRequests.UpsertDealQuotationRequest zeroDeposit = new DealQuotationRequests.UpsertDealQuotationRequest( + draft.contactId(), draft.deptCode(), draft.unitCode(), draft.offerDate(), 0, draft.remainderMode(), + draft.creditDays(), 30, null, null, draft.customerNotes(), draft.priceMode(), draft.documentLanguage(), + draft.currency(), null, null, null, null, "BEFORE_DELIVERY", null, null, null, null, null, + draft.items().stream().map(this::asItemInput).toList()); + DealQuotationDto saved = quotationService.update(draft.id(), zeroDeposit, salesActor); + DealQuotationDto submitted = quotationService.submit(saved.id(), salesActor); + DealQuotationDto issued = quotationService.approve(submitted.id(), new ApproveRequest(null), salesManagerActor); return quotationService.recordOutcome(issued.id(), new RecordOutcomeRequest(QuotationStatus.ACCEPTED, "ลูกค้าโอเค", UUID.randomUUID().toString()), salesActor); } private DealQuotationDto issuedNewOriginQuotation(String recipient, BigDecimal qty) { + return issuedNewOriginQuotation(recipient, qty, null); + } + + /** {@code depositPercent} null keeps the percent the quotation was created with. */ + private DealQuotationDto issuedNewOriginQuotation(String recipient, BigDecimal qty, Integer depositPercent) { PricingDecisionDto decision = approvedDecision(recipient, qty); DealQuotationDto draft = quotationService.createFromPricingRequest(decision.pricingRequestId(), salesActor); - DealQuotationDto withValidity = quotationService.update(draft.id(), upsertWithValidityDays(draft, 30), salesActor); + DealQuotationDto withValidity = quotationService.update(draft.id(), upsertWithValidityDays(draft, 30, + depositPercent != null ? depositPercent : draft.depositPercent()), salesActor); DealQuotationDto submitted = quotationService.submit(withValidity.id(), salesActor); return quotationService.approve(submitted.id(), new ApproveRequest(null), salesManagerActor); } @@ -918,9 +1091,14 @@ null, new BigDecimal("10"), new BigDecimal("0.36"), WastageCalculator.QUANTITY_M } private DealQuotationRequests.UpsertDealQuotationRequest upsertWithValidityDays(DealQuotationDto q, int days) { + return upsertWithValidityDays(q, days, q.depositPercent()); + } + + private DealQuotationRequests.UpsertDealQuotationRequest upsertWithValidityDays(DealQuotationDto q, int days, + Integer depositPercent) { List items = q.items().stream().map(this::asItemInput).toList(); return new DealQuotationRequests.UpsertDealQuotationRequest(q.contactId(), q.deptCode(), q.unitCode(), - q.offerDate(), q.depositPercent(), q.remainderMode(), q.creditDays(), days, q.customerNotes(), + q.offerDate(), depositPercent, q.remainderMode(), q.creditDays(), days, q.customerNotes(), q.priceMode(), q.documentLanguage(), q.currency(), items); } diff --git a/frontend/src/api/mockApi.depositNotices.test.js b/frontend/src/api/mockApi.depositNotices.test.js index 811cf846d..bc1a8e747 100644 --- a/frontend/src/api/mockApi.depositNotices.test.js +++ b/frontend/src/api/mockApi.depositNotices.test.js @@ -20,7 +20,10 @@ function uuid() { return `77777777-7777-4777-8777-${String(seq).padStart(12, '0')}`; } -async function driveTicketToAcceptedQuotation() { +// Everything up to and including the CEO-approved pricing decision. `priceMode`/`discountPct` are +// set only by the new-engine drive: dealQuotations.createFromPricingRequest refuses a decision +// without a priceMode, and the legacy drive below never set either. +async function driveTicketToApprovedDecision({ priceMode, discountPct } = {}) { const n = seq + 1; await api.auth.login({ role: 'sales' }); @@ -114,10 +117,19 @@ async function driveTicketToAcceptedQuotation() { await api.auth.login({ role: 'ceo' }); const { decision } = await api.pricingRequests.startPricingDecision(prId, { defaultMarginPct: 0.2 }); await api.pricingRequests.updatePricingDecision(decision.id, { - items: decision.items.map((item) => ({ pricingDecisionItemId: item.id, minimumSellingPrice: 50 })), + priceMode, + items: decision.items.map((item) => ({ + pricingDecisionItemId: item.id, minimumSellingPrice: 50, discountPct, + })), }); await api.pricingRequests.approvePricingDecision(decision.id, {}); + return { ticketId, prId, customer, project }; +} + +async function driveTicketToAcceptedQuotation() { + const { ticketId, prId, customer, project } = await driveTicketToApprovedDecision(); + await api.auth.login({ role: 'sales' }); const { quotation: draftQuotation } = await api.pricingRequests.createCustomerQuotation(prId, {}); const { quotation: issued } = await api.pricingRequests.issueCustomerQuotation(draftQuotation.id, {}); @@ -127,6 +139,40 @@ async function driveTicketToAcceptedQuotation() { return { ticketId, customer, project, quotation: issued }; } +// New-engine (PRICING_REQUEST-origin) quotation, issued by the sales manager — the same drive as +// DealQuotationOutcomeIntegrationTest#issuedNewOriginQuotation. The line carries a 10% discount +// from the CEO decision, so the deposit mapper's baht discount label is exercised. The discount +// sits on the decision, not on a quotation update: a line that differs from the CEO price makes +// the mock's PRICING_REQUEST approve refuse sales_manager (403, CEO approval required). +async function driveTicketToNewEngineIssuedQuotation({ depositPercent, fullPaymentTerm } = {}) { + const { ticketId, prId } = await driveTicketToApprovedDecision({ priceMode: 'NET', discountPct: 10 }); + await api.auth.login({ role: 'sales' }); + const { quotation: draft } = await api.dealQuotations.createFromPricingRequest(prId); + // Java's drive (DealQuotationOutcomeIntegrationTest) saves validityDays before submit; the + // mock's submit/approve/recordOutcome never require it, so that update is left out here. + // fullPaymentTerm is only meaningful (and, for submit, required) when depositPercent is 0. + if (depositPercent != null) { + await api.dealQuotations.update(draft.id, { + priceMode: draft.priceMode, depositPercent, remainderMode: 'ON_DELIVERY', fullPaymentTerm, + items: draft.items.map((item) => ({ ...item })), + }); + } + await api.dealQuotations.submit(draft.id, {}); + await api.auth.login({ role: 'sales_manager' }); + const { quotation: issued } = await api.dealQuotations.approve(draft.id, {}); + await api.auth.login({ role: 'sales' }); + return { ticketId, prId, issued }; +} + +async function driveTicketToNewEngineAcceptedQuotation(options) { + const { ticketId, prId, issued } = await driveTicketToNewEngineIssuedQuotation(options); + const { quotation: accepted } = await api.dealQuotations.recordOutcome(issued.id, { + outcome: 'ACCEPTED', customerNote: null, clientRequestId: uuid(), + }); + await api.pricingRequests.confirmOrder(prId, { clientRequestId: uuid() }); + return { ticketId, prId, accepted }; +} + describe('mockApi.depositNotices.createDraft autofill (branch fix: deposit-notice autofill)', () => { it('sources items from the accepted quotation and autofills the header when no ticket item has an approved price', async () => { const { ticketId, customer, project, quotation } = await driveTicketToAcceptedQuotation(); @@ -173,6 +219,61 @@ describe('mockApi.depositNotices.createDraft autofill (branch fix: deposit-notic }); }); +// Java picks the row with DealQuotationRepository.findAcceptedForPricingRequest. +describe('mockApi.pricingRequests.createDepositNoticeFromQuotation — new-engine quotation (mirrors OrderConfirmationService fix)', () => { + it('creates the draft with the accepted quotation number as reference, and items and percent from that quotation', async () => { + const { prId, accepted } = await driveTicketToNewEngineAcceptedQuotation({ depositPercent: 30 }); + const { depositNotice } = await api.pricingRequests.createDepositNoticeFromQuotation(prId, {}); + expect(depositNotice.reference).toBe(accepted.number); + // DepositNoticeService.createDraftFromQuotation: the chosen quotation's own whole-number percent. + expect(accepted.depositPercent).toBe(30); + expect(depositNotice.depositPercent).toBe(0.3); + expect(depositNotice.items.length).toBeGreaterThan(0); + // qty is deliberately not distinguishable here: a mock TILE row sets quantity = piecesFinal. + const [line] = accepted.items; + expect(depositNotice.items[0]).toMatchObject({ + qty: line.quantity, netUnitPrice: line.netUnitPrice, unit: line.unit, + description: line.descriptionLine, unitPrice: line.unitPrice, + }); + // DepositNoticeRepository.findDealQuotationItems prints sales_discount (unitPrice − netUnitPrice) + // in baht, not the percent: ราคาตั้ง 120 at 10% off is net 108, so 12 baht. + expect([accepted.items[0].unitPrice, accepted.items[0].netUnitPrice]).toEqual([120, 108]); + expect(depositNotice.items[0].discountLabel).toBe('ส่วนลด 12 ต่อหน่วย'); + }); + + // Covers the ticket-level new-engine fallback (DepositNoticeRepository.findDealQuotationItemsForDeposit): + // with no items and empty legacy sources, the route would return no items without it. + it('ticket-level createDraft with no items reads the same new-engine lines as the pricing-request route', async () => { + const { ticketId, prId } = await driveTicketToNewEngineAcceptedQuotation(); + const { depositNotice: fromTicket } = await api.depositNotices.createDraft(ticketId, {}); + const { depositNotice: fromPr } = await api.pricingRequests.createDepositNoticeFromQuotation(prId, {}); + expect(fromTicket.items.length).toBeGreaterThan(0); + const pick = (items) => items.map(({ qty, netUnitPrice }) => ({ qty, netUnitPrice })); + expect(pick(fromTicket.items)).toEqual(pick(fromPr.items)); + }); + + it('still 409s while the new-engine quotation is only ISSUED (customer has not accepted)', async () => { + const { ticketId, prId } = await driveTicketToNewEngineIssuedQuotation(); + await expect(api.pricingRequests.createDepositNoticeFromQuotation(prId, {})) + .rejects.toMatchObject({ status: 409, message: 'ยังไม่มีใบเสนอราคาที่ลูกค้ายอมรับสำหรับคำขอราคานี้' }); + const { depositNotices } = await api.depositNotices.listByTicket(ticketId); + expect(depositNotices).toHaveLength(0); + }); + + // A zero-deposit ("ไม่รับมัดจำ", full payment) quotation has nothing to bill: Java refuses rather + // than drafting a 0-baht document that nothing downstream would stop from being issued. + it('rejects a 0% (full-payment) new-engine quotation with 409 and creates no draft', async () => { + const { ticketId, prId, accepted } = await driveTicketToNewEngineAcceptedQuotation({ + depositPercent: 0, fullPaymentTerm: 'BEFORE_DELIVERY', + }); + expect(accepted.depositPercent).toBe(0); + await expect(api.pricingRequests.createDepositNoticeFromQuotation(prId, {})) + .rejects.toMatchObject({ status: 409, message: expect.stringContaining('ไม่มีมัดจำ') }); + const { depositNotices } = await api.depositNotices.listByTicket(ticketId); + expect(depositNotices).toHaveLength(0); + }); +}); + // Issue #752: the mock must reproduce production's one-render gap. Per // DepositNoticeService.issue (backend/src/main/java/th/co/glr/hr/deposit/DepositNoticeService.java // ~L200-225), PR #721 moved the PDF/XLSX render into an afterCommit callback — so the DTO diff --git a/frontend/src/api/mockApi.js b/frontend/src/api/mockApi.js index e6f6fa531..d17302ee8 100644 --- a/frontend/src/api/mockApi.js +++ b/frontend/src/api/mockApi.js @@ -1436,6 +1436,25 @@ function mockDepositNoticeItemsFromQuotation(items) { })); } +// Mirrors DepositNoticeRepository.findDealQuotationItems. Values are read off the stored row, +// never recomputed (label excepted — see below). +function mockDepositNoticeItemsFromDealQuotation(row) { + return row.items.map((item, idx) => { + // Display label only: the mock stores no sales_discount, so this restates the column Java reads + // (DealQuotationRepository#itemParams writes it as unitPrice − netUnitPrice). + const salesDiscount = round2(item.unitPrice - item.netUnitPrice); + return { + seq: item.seq ?? idx + 1, + description: item.descriptionLine?.trim() ? item.descriptionLine : 'รายการสินค้า', + qty: item.quantity, + unit: item.unit?.trim() ? item.unit : 'หน่วย', + unitPrice: item.unitPrice, + discountLabel: salesDiscount > 0 ? `ส่วนลด ${salesDiscount} ต่อหน่วย` : null, + netUnitPrice: item.netUnitPrice, + }; + }); +} + // Branch fix: mirrors DepositNoticeService.pickQuotation — the latest ACCEPTED revision for // this ticket, or (if none has been accepted yet) the latest ISSUED one. mockCustomerQuotations // carries no guaranteed ordering, so sort ascending by (quotationRevisionNo, id) first — same @@ -13269,6 +13288,15 @@ export const api = { const quotation = mockPickTicketQuotationForDepositNotice(ticketId); if (quotation) items = mockDepositNoticeItemsFromQuotation(quotation.items); } + if (!items.length) { + // Then the live new-engine quotation: findDealQuotationItemsForDeposit, ordered as + // mockPickLiveQuotationForItemFallback's own comment says. The picker labels legacy rows + // 'PRICING_REQUEST' too, so the array tells new-engine apart. + const live = mockPickLiveQuotationForItemFallback(Number(ticketId)); + if (live && mockDealQuotations.includes(live.quotation)) { + items = mockDepositNoticeItemsFromDealQuotation(live.quotation); + } + } // Branch fix: header autofill (mirrors DepositNoticeService.createDraft) — a caller- // supplied non-blank value always wins; a ticket with no linked customer master row @@ -16163,15 +16191,22 @@ export const api = { const ticket = db.tickets.find((t) => t.id === pr.ticketId); if (ticket?.createdById !== user.id) fail('ไม่มีสิทธิ์เข้าถึงรายการนี้', 403); + // Mirrors OrderConfirmationService.createDepositNoticeFromQuotation: the ACCEPTED legacy + // quotation first, else THIS pricing request's ACCEPTED new-engine row + // (DealQuotationRepository.findAcceptedForPricingRequest). const accepted = mockCustomerQuotations.find( (q) => q.pricingRequestId === pr.id && q.docStatus === 'ACCEPTED'); - if (!accepted) fail('ยังไม่มีใบเสนอราคาที่ลูกค้ายอมรับสำหรับคำขอราคานี้', 409); - - // Mirrors OrderConfirmationService.createDepositNoticeFromQuotation, which delegates the - // mapping to DepositNoticeService.itemsFromQuotation (shared with depositNotices - // .createDraft's own new-chain fallback below) — items/amounts trace to the quotation, - // never to any sales.ticket_item row. - const items = mockDepositNoticeItemsFromQuotation(accepted.items); + const newEngine = accepted ? null : mockDealQuotations.find( + (q) => q.pricingRequestId === pr.id && q.origin === 'PRICING_REQUEST' && q.docStatus === 'ACCEPTED'); + if (!accepted && !newEngine) fail('ยังไม่มีใบเสนอราคาที่ลูกค้ายอมรับสำหรับคำขอราคานี้', 409); + // Mirrors the zero-deposit refusal: a 0% ("ไม่รับมัดจำ") quotation has nothing to bill. + if (newEngine?.depositPercent === 0) { + fail(`ใบเสนอราคา ${newEngine.number} ระบุไม่มีมัดจำ (ชำระเต็มจำนวน) จึงออกใบแจ้งยอดเงินรับมัดจำไม่ได้`, 409); + } + const reference = (accepted ?? newEngine).number; + const items = accepted + ? mockDepositNoticeItemsFromQuotation(accepted.items) + : mockDepositNoticeItemsFromDealQuotation(newEngine); // Requires quotation_issued (one of DepositNoticeService.requireApprovedTicket's three // accepted statuses) — mirrors what confirmOrder above already left in place; calling this @@ -16193,8 +16228,13 @@ export const api = { version: nextVer, docNumber: null, issueDate: null, status: 'DRAFT', customerName: ticket.customerName ?? '', customerTaxId: customerTaxId ?? '', customerAddress: customerAddress ?? '', - projectName: projectName ?? '', reference: accepted.number, - depositPercent: payload.depositPercent ?? 0.5, vatPercent: 0.07, + projectName: projectName ?? '', reference, + // Mirrors DepositNoticeService.createDraftFromQuotation: the new-engine branch bills the + // chosen quotation's own whole-number percent; the legacy branch keeps the old stand-in. + depositPercent: newEngine?.depositPercent != null + ? newEngine.depositPercent / 100 + : payload.depositPercent ?? 0.5, + vatPercent: 0.07, notes, items, issuedByName: null, preparerName: 'จินตนา หาญมนตรี', hasPdf: false, hasXlsx: false, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), @@ -16202,7 +16242,7 @@ export const api = { mockDepositNotices.push(doc); pushPricingRequestEvent(pr, user, 'DEPOSIT_NOTICE_DRAFTED_FROM_QUOTATION', pr.status, pr.status, - `สร้างร่างใบแจ้งยอดเงินรับมัดจำจากใบเสนอราคา ${accepted.number}`); + `สร้างร่างใบแจ้งยอดเงินรับมัดจำจากใบเสนอราคา ${reference}`); return delay({ depositNotice: structuredClone(doc) }); },