diff --git a/backend/src/main/java/th/co/glr/hr/dealquotation/DealQuotationRenderAdapter.java b/backend/src/main/java/th/co/glr/hr/dealquotation/DealQuotationRenderAdapter.java index 52e6338f8..34e069bb0 100644 --- a/backend/src/main/java/th/co/glr/hr/dealquotation/DealQuotationRenderAdapter.java +++ b/backend/src/main/java/th/co/glr/hr/dealquotation/DealQuotationRenderAdapter.java @@ -32,6 +32,11 @@ private DealQuotationRenderAdapter() {} private static final ZoneId BANGKOK = ZoneId.of("Asia/Bangkok"); + /** The deposit percent the PDF prints when a quotation's {@code deposit_percent} is NULL. The + * deposit notice bills the same default (owner ruling 2026-10-22), so the customer is billed + * what the accepted PDF promised. */ + public static final int DEFAULT_DEPOSIT_PERCENT = 30; + /** Overload for callers with no configured bank block — the English document then prints the * proforma-invoice line instead. Kept so tests and any hand-wired caller need not thread a * config value they do not care about. */ @@ -635,7 +640,7 @@ private static String noReturnLine(int number) { * lead time (dropped entirely, not blanked, when nothing on the document carries one — same * {@link #dropLeadTimeLineAndRenumber} the tile set uses), and the no-exchange-or-return rule. */ private static List nonTileRemarkLines(DealQuotationDto quotation) { - int depositPct = quotation.depositPercent() != null ? quotation.depositPercent() : 30; + int depositPct = quotation.depositPercent() != null ? quotation.depositPercent() : DEFAULT_DEPOSIT_PERCENT; String remainderText = "CREDIT".equals(quotation.remainderMode()) ? "เครดิต " + (quotation.creditDays() != null ? quotation.creditDays() : 0) + " วัน" : "ขอรับก่อนส่งมอบสินค้าหรือเมื่อส่งมอบสินค้า"; @@ -652,7 +657,7 @@ private static List nonTileRemarkLines(DealQuotationDto quotation) { private static List remarkLines(DealQuotationDto quotation) { LocalDate offerDate = quotation.offerDate() != null ? quotation.offerDate() : LocalDate.now(BANGKOK); - int depositPct = quotation.depositPercent() != null ? quotation.depositPercent() : 30; + int depositPct = quotation.depositPercent() != null ? quotation.depositPercent() : DEFAULT_DEPOSIT_PERCENT; String remainderText = "CREDIT".equals(quotation.remainderMode()) ? "เครดิต " + (quotation.creditDays() != null ? quotation.creditDays() : 0) + " วัน" : "ขอรับก่อนส่งมอบสินค้าหรือเมื่อส่งมอบสินค้า"; @@ -902,7 +907,7 @@ private static String fullPaymentTermEnglishText(String code) { private static List englishRemarkLines(DealQuotationDto quotation, List bankBlockLines) { LocalDate offerDate = quotation.offerDate() != null ? quotation.offerDate() : LocalDate.now(BANGKOK); - int depositPct = quotation.depositPercent() != null ? quotation.depositPercent() : 30; + int depositPct = quotation.depositPercent() != null ? quotation.depositPercent() : DEFAULT_DEPOSIT_PERCENT; // Wording-scan fix 7 (2026-09-17): singular "day credit" at exactly 1 day. String remainderText; if ("CREDIT".equals(quotation.remainderMode())) { @@ -1029,7 +1034,7 @@ private static String englishNonTileLeadTimeLine(List item /** The English twin of {@link #nonTileRemarkLines}. */ private static List englishNonTileRemarkLines(DealQuotationDto quotation, List bankBlockLines) { - int depositPct = quotation.depositPercent() != null ? quotation.depositPercent() : 30; + int depositPct = quotation.depositPercent() != null ? quotation.depositPercent() : DEFAULT_DEPOSIT_PERCENT; // Wording-scan fix 7 (2026-09-17): singular "day credit" at exactly 1 day. String remainderText; if ("CREDIT".equals(quotation.remainderMode())) { diff --git a/backend/src/main/java/th/co/glr/hr/dealquotation/DealQuotationService.java b/backend/src/main/java/th/co/glr/hr/dealquotation/DealQuotationService.java index c7d8e470e..14e0f4871 100644 --- a/backend/src/main/java/th/co/glr/hr/dealquotation/DealQuotationService.java +++ b/backend/src/main/java/th/co/glr/hr/dealquotation/DealQuotationService.java @@ -2193,7 +2193,7 @@ private PromoteToDealResultDto promotionResult(long ticketId, long quotationId) * qty_sqm, unit_basis, raw_unit. NO pricing field is copied (proposed/approved/raw price stay * null) — those columns carry the legacy ticket-native approval history, and a direct * quotation's typed price was never "approved" on this ticket in that sense; money documents - * read the quotation itself ({@code DepositNoticeRepository#findDealQuotationItemsForDeposit}). + * read the quotation itself ({@code DepositNoticeRepository#findDealQuotationItems}). * *

Only goods lines: an {@code ADJUSTMENT} row (a negative discount/surcharge line, quantity −1) * is money, not something to deliver, and a non-positive quantity would violate 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 7fe0ee83b..271ca9750 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 @@ -18,6 +18,8 @@ import org.springframework.jdbc.support.GeneratedKeyHolder; import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; +import th.co.glr.hr.dealquotation.DealQuotationRenderAdapter; +import th.co.glr.hr.ticket.QuotationRenderer; @Repository public class DepositNoticeRepository { @@ -89,74 +91,63 @@ public List findByTicket(long ticketId) { ); } + /** The quotation a ticket-level deposit notice bills: always its percent (a fraction, + * 30 -> 0.30); its lines and its number as the reference unless the caller or legacy + * approved_price rows supply the lines. See {@link #findGoverningQuotation}. */ + public record GoverningQuotation(long quotationId, boolean legacy, String number, BigDecimal depositPercent) {} + /** - * Owner ruling (2026-09-29): the deposit PERCENTAGE on a deposit notice is LOCKED to the - * source quotation's own {@code sales.quotation.deposit_percent} — the value sales set when - * creating the quotation — and is never entered or edited at the deposit-notice stage. - * {@code deposit_percent} is a nullable SMALLINT whole-number percent (30/50/100); this - * returns it as a fraction (50 -> 0.50) via {@code BigDecimal.valueOf(pct, 2)}, which is - * exact for any integer/100 (denominator 100 = 2^2*5^2 always terminates). + * The ONE pick of the quotation a ticket-level deposit notice bills, across both engines, so + * when it supplies the lines they and the percent come from the same row (owner rulings + * 2026-09-29 and 2026-10-23). The PRICING-REQUEST route ({@code OrderConfirmationService + * #createDepositNoticeFromQuotation}) already knows its quotation and does not call this. + * + *

Live rows: a legacy pricing-chain quotation ({@code origin IS NULL}, {@code + * pricing_request_id IS NOT NULL}, the scope {@code CustomerQuotationRepository#findByTicket} + * uses) that is ACCEPTED or ISSUED, a {@code PRICING_REQUEST}-origin one that is ACCEPTED or + * ISSUED, or a {@code DEAL_DIRECT} one that is ACCEPTED, ISSUED or APPROVED (DEAL_DIRECT + * ACCEPTED/ISSUED are admitted but no live route produces them). APPROVED is + * DEAL_DIRECT only: a PRICING_REQUEST approval lands on ISSUED ({@code DealQuotationRepository + * #approve}). Engines are exclusive per pricing request, not per ticket, so one ticket can hold + * both. + * + *

A case needing no pre-lock data: a DEAL_DIRECT quotation stays revisable after its order + * is confirmed ({@code DealQuotationService#requireRevisableStatus}), and once that revision + * is APPROVED it governs the ticket-route deposit while the deal's ticket_item rows still hold + * the confirmed quotation's lines. Ploy 2026-10-27: revisions will lock after confirm + * (follow-up PR). * - *

Pick order deliberately mirrors {@link DepositNoticeService}'s own quotation selection - * for deposit-notice ITEMS, so the percent this returns always comes from the same row the - * items would be sourced from: - *

    - *
  • legacy customer-quotation chain ({@code origin IS NULL}, {@code pricing_request_id - * IS NOT NULL} — the exact scope {@code CustomerQuotationRepository#findByTicket} uses): - * ACCEPTED preferred, else ISSUED — the same rule {@code - * DepositNoticeService#pickQuotation} applies for items. Read as raw SQL rather than via - * {@code CustomerQuotationRepository}'s own DTO because {@code CustomerQuotationDto} - * does not carry {@code deposit_percent} at all (it predates that column and nothing - * else on that DTO's surface needed it) — adding a field there would widen every one of - * that repository's other read paths for a value only this one caller needs.
  • - *
  • v2 deal-quotation chain ({@code origin IN ('PRICING_REQUEST','DEAL_DIRECT')}): the - * single live quotation, preferring ACCEPTED, then ISSUED, then APPROVED (DEAL_DIRECT's - * own only live terminal state), newest by id.
  • - *
- * 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. + *

Ranking: the latest quotation the customer ANSWERED wins: ACCEPTED on either engine, or + * APPROVED on a DEAL_DIRECT quotation (its terminal state in practice). Only when + * nothing was answered does the newest ISSUED row win. "Latest" is {@code quotation_id DESC}: + * one sequence across every engine and pricing request, set at creation. Not {@code + * accepted_at}/{@code approved_at}: those time different events (a customer answer against an + * internal approval), and either can be NULL on older or imported rows. + * + *

Percent: what that quotation's PDF printed. A legacy row is always {@link + * QuotationRenderer#LEGACY_DEPOSIT_PERCENT}: the legacy PDF never reads the stored column, so + * this does not either. A new-engine row is its own {@code deposit_percent}, a NULL one {@link + * DealQuotationRenderAdapter#DEFAULT_DEPOSIT_PERCENT}. Empty when the ticket has no live + * quotation; {@link DepositNoticeService#createDraft} then bills 0.50. */ - public Optional findDealQuotationDepositPercent(long ticketId) { - Integer legacyPct = firstOrNull(jdbc.query(""" - SELECT deposit_percent FROM sales.quotation - WHERE ticket_id = :ticketId AND pricing_request_id IS NOT NULL AND origin IS NULL - AND doc_status IN ('ACCEPTED','ISSUED') - ORDER BY CASE doc_status WHEN 'ACCEPTED' THEN 0 ELSE 1 END, quotation_id DESC - LIMIT 1 - """, Map.of("ticketId", ticketId), - (rs, i) -> (Integer) rs.getObject("deposit_percent") - )); - if (legacyPct != null) { - return Optional.of(BigDecimal.valueOf(legacyPct, 2)); - } - - Integer v2Pct = firstOrNull(jdbc.query(""" - SELECT deposit_percent FROM sales.quotation - WHERE ticket_id = :ticketId AND origin IN ('PRICING_REQUEST','DEAL_DIRECT') - AND doc_status IN ('ACCEPTED','ISSUED','APPROVED') - ORDER BY CASE doc_status WHEN 'ACCEPTED' THEN 0 WHEN 'ISSUED' THEN 1 ELSE 2 END, - quotation_id DESC + public Optional findGoverningQuotation(long ticketId) { + return jdbc.query(""" + SELECT quotation_id, origin IS NULL AS legacy, number, deposit_percent + FROM sales.quotation + WHERE ticket_id = :ticketId + AND ((origin IS NULL AND pricing_request_id IS NOT NULL AND doc_status IN ('ACCEPTED','ISSUED')) + OR (origin = 'PRICING_REQUEST' AND doc_status IN ('ACCEPTED','ISSUED')) + OR (origin = 'DEAL_DIRECT' AND doc_status IN ('ACCEPTED','ISSUED','APPROVED'))) + ORDER BY CASE WHEN doc_status IN ('ACCEPTED','APPROVED') THEN 0 ELSE 1 END, quotation_id DESC LIMIT 1 - """, Map.of("ticketId", ticketId), - (rs, i) -> (Integer) rs.getObject("deposit_percent") - )); - return v2Pct != null ? Optional.of(BigDecimal.valueOf(v2Pct, 2)) : Optional.empty(); - } - - /** {@code list.stream().findFirst()} throws NPE the moment the single mapped row's own value - * is {@code null} ({@code Stream.findFirst}/{@code Optional.of} both reject a null element - * outright) — exactly the case a LIVE quotation with a not-yet-set {@code deposit_percent} - * hits. A plain index avoids that without changing what "no row" vs "row, null value" mean to - * the caller (both still read as "nothing usable here"). */ - private static T firstOrNull(List rows) { - return rows.isEmpty() ? null : rows.get(0); + """, Map.of("ticketId", ticketId), (rs, i) -> { + boolean legacy = rs.getBoolean("legacy"); + Integer stored = (Integer) rs.getObject("deposit_percent"); + int percent = legacy ? QuotationRenderer.LEGACY_DEPOSIT_PERCENT + : stored != null ? stored : DealQuotationRenderAdapter.DEFAULT_DEPOSIT_PERCENT; + return new GoverningQuotation(rs.getLong("quotation_id"), legacy, rs.getString("number"), + BigDecimal.valueOf(percent, 2)); + }).stream().findFirst(); } @Transactional @@ -370,53 +361,6 @@ private void insertItems(long docId, List items) { } } - /** - * Deal-quotation (V2) item source for deposit-notice autofill (2026-09-29 fix). The legacy - * autofill knew only two sources: {@code sales.ticket_item.approved_price} (never written by - * the pricing-request chain) and {@code CustomerQuotationRepository#findByTicket} (scoped to - * {@code origin IS NULL}, which excludes BOTH {@code DEAL_DIRECT} and {@code PRICING_REQUEST} - * rows). Every deal whose quotation was issued through the V2 deal-quotation flow therefore got - * an EMPTY deposit-notice item list — the reported "รายการ not autofilled" bug. - * - *

Selection covers BOTH V2 origins, whose live-terminal doc_status differs — the two - * never share a {@code ticket_id} (see {@code DealQuotationRepository#nextRevisionNo}), so the - * union below is unambiguous per ticket: - *

    - *
  • {@code PRICING_REQUEST} runs the customer-response lifecycle DRAFT → PENDING_APPROVAL → - * ISSUED → ACCEPTED, so it mirrors {@link DepositNoticeService#pickQuotation} exactly: - * prefer ACCEPTED (the customer-accepted document a deposit is raised against), else the - * latest ISSUED.
  • - *
  • {@code DEAL_DIRECT} has no customer-response lifecycle in the model today — it ends at - * APPROVED (terminal-until-revised; see {@code QuotationStatus.APPROVED}), which is the - * only "live" state it ever reaches. Without APPROVED here, every direct-deal deposit - * would still autofill empty.
  • - *
- * SUPERSEDED / CANCELLED / REJECTED / EXPIRED / DRAFT / PENDING_APPROVAL / REVISION_REQUESTED - * rows are never chosen. Preference ACCEPTED > ISSUED > APPROVED, then newest by id. - * 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. - * The lines themselves come from {@link #findDealQuotationItems}. - */ - public List findDealQuotationItemsForDeposit(long ticketId) { - List ids = jdbc.query(""" - SELECT quotation_id FROM sales.quotation - WHERE ticket_id = :ticketId - AND origin IN ('PRICING_REQUEST', 'DEAL_DIRECT') - AND doc_status IN ('ACCEPTED', 'ISSUED', 'APPROVED') - ORDER BY CASE doc_status - WHEN 'ACCEPTED' THEN 0 - WHEN 'ISSUED' THEN 1 - ELSE 2 - END, quotation_id DESC - LIMIT 1 - """, Map.of("ticketId", ticketId), (rs, i) -> rs.getLong("quotation_id")); - 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 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 da2d1c40a..35576c6e6 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 @@ -96,8 +96,8 @@ public DepositNoticeDto createDraft(long ticketId, DepositNoticeDraftRequest req } /** {@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. */ + * {@code sourceDepositPercent} (the whole-number percent that quotation's PDF printed) is the + * draft's percent, and the ticket's governing quotation is not consulted. */ @Transactional public DepositNoticeDto createDraftFromQuotation(long ticketId, DepositNoticeDraftRequest req, Integer sourceDepositPercent, UserPrincipal actor) { @@ -106,9 +106,17 @@ public DepositNoticeDto createDraftFromQuotation(long ticketId, DepositNoticeDra TicketSummaryDto s = requireApprovedTicket(ticketId, actor); requireActiveLifecycle(s); - // Auto-populate items from approved ticket items (legacy) or, failing that, the - // ticket's own customer-quotation chain (new pricing-request flow) if not provided. + // Ticket route: the governing quotation (DepositNoticeRepository#findGoverningQuotation) + // always supplies the percent. It supplies the lines, and with them the reference, unless + // req.items or legacy approved_price rows already supply the lines. + DepositNoticeRepository.GoverningQuotation governing = sourceDepositPercent == null + ? docs.findGoverningQuotation(ticketId).orElse(null) : null; + if (governing != null && governing.depositPercent().signum() == 0) { + throw zeroDepositRefusal(governing.number()); + } List items = buildItemsFromRequest(req, ticketId); + boolean linesFromGoverning = items.isEmpty() && governing != null; + if (linesFromGoverning) items = itemsFromGoverning(governing); List notes = req.notes() != null ? req.notes() : docs.findNoteTemplates().stream() .filter(DocumentNoteTemplateDto::defaultSelected) @@ -128,25 +136,23 @@ public DepositNoticeDto createDraftFromQuotation(long ticketId, DepositNoticeDra // address/tax id differently. CustomerHeaderInfo header = resolveCustomerHeader(s, req.customerTaxId(), req.customerAddress(), req.projectName()); - // Owner ruling (2026-09-29, sales business-logic change, authorised per CLAUDE.md's - // "deposit-notice flow is UNFROZEN"): the deposit PERCENT is LOCKED to the source - // quotation's own sales.quotation.deposit_percent — the value sales set when creating the - // quotation — and is never editable at the deposit-notice stage. A caller-supplied - // req.depositPercent() is deliberately IGNORED as the source here (the quotation always - // wins); DepositNoticeDraftRequest still carries the field only because it round-trips - // through the DTO the frontend/OrderConfirmationService already send. Falls back to the - // document's long-standing 0.50 default only when the ticket has no live quotation to - // 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. + // Owner rulings (2026-09-29, 2026-10-22, 2026-10-23): the deposit percent is the one the + // source quotation's PDF printed: a legacy quotation 50% (the constant its PDF prints), a + // new-engine one its own deposit_percent, a blank one 30%. The PR route passes it as + // sourceDepositPercent; the ticket route takes it from the governing quotation. On create, + // req.depositPercent() is never read (DepositNoticeDraftRequest carries it for update's + // round trip). 0.50 only when the ticket has no live quotation at all (e.g. a manually + // approved legacy ticket with no quotation chain); see + // DepositNoticeRepository#findGoverningQuotation for the pick. BigDecimal effectiveDepositPercent = sourceDepositPercent != null ? BigDecimal.valueOf(sourceDepositPercent, 2) - : docs.findDealQuotationDepositPercent(ticketId).orElse(new BigDecimal("0.50")); + : governing != null ? governing.depositPercent() : new BigDecimal("0.50"); + String reference = req.reference() == null && linesFromGoverning ? governing.number() : req.reference(); var effective = new DepositNoticeDraftRequest( req.customerName() != null ? req.customerName() : s.customerName(), header.taxId(), header.address(), - header.projectName(), req.reference(), + header.projectName(), reference, effectiveDepositPercent, notes, items ); @@ -155,6 +161,12 @@ public DepositNoticeDto createDraftFromQuotation(long ticketId, DepositNoticeDra return docs.findById(docId).orElseThrow(); } + /** The 409 both deposit routes give a ไม่รับมัดจำ (0%) quotation: there is no deposit to bill. */ + public static ApiException zeroDepositRefusal(String quotationNumber) { + return new ApiException(HttpStatus.CONFLICT, "ใบเสนอราคา " + quotationNumber + + " ระบุไม่มีมัดจำ (ชำระเต็มจำนวน) จึงออกใบแจ้งยอดเงินรับมัดจำไม่ได้"); + } + @Transactional public DepositNoticeDto update(long docId, DepositNoticeDraftRequest req, UserPrincipal actor) { DepositNoticeDto doc = requireDraft(docId); @@ -839,7 +851,7 @@ private DepositRefBundle resolveDepositReferenceOptions(long ticketId, DepositNo /** Latest ACCEPTED revision only — see {@link #resolveRemainingInvoice}'s own Javadoc for why * this deliberately does NOT fall back to the latest ISSUED-but-not-yet-accepted one the way - * {@link #pickQuotation} does for the deposit notice. */ + * {@link DepositNoticeRepository#findGoverningQuotation} does for the deposit notice. */ private CustomerQuotationDto pickAcceptedQuotation(List candidates) { CustomerQuotationDto latestAccepted = null; for (CustomerQuotationDto q : candidates) { @@ -1216,24 +1228,16 @@ private List buildItemsFromRequest(DepositNoticeDraftR // Legacy path: sales.ticket_item.approved_price, written only by the @Deprecated // TicketService.approve (no controller route) — kept working for any ticket that still // carries it, e.g. one manually approved before the pricing-request chain existed. - List legacyItems = buildLegacyItems(ticketId); - if (!legacyItems.isEmpty()) return legacyItems; - - // New-chain fallback (this branch's fix): every deal created through the pricing-request - // chain has approved_price = NULL on every ticket_item (see this class's own diagnosis in - // the branch handoff) — buildLegacyItems above always returns empty for such a deal, which - // is exactly the bug. Source items from the ticket's own customer quotation instead. - CustomerQuotationDto chosen = pickQuotation(quotations.findByTicket(ticketId)); - if (chosen != null) { - return itemsFromQuotation(chosen.items()); - } + return buildLegacyItems(ticketId); + } - // V2 deal-quotation chain (2026-09-29 fix): the pickQuotation/findByTicket source above is - // scoped to origin IS NULL, so it never returns a DEAL_DIRECT or PRICING_REQUEST quotation - // — every deal issued through the deal-quotation flow fell through to an empty item list. - // docs.findDealQuotationItemsForDeposit reads the ticket's live deal quotation (ACCEPTED, - // else ISSUED) directly; see that method's Javadoc for the selection and column choices. - return docs.findDealQuotationItemsForDeposit(ticketId); + /** Every deal created through the pricing-request chain has approved_price = NULL on every + * ticket_item, so its lines come from the governing quotation, whichever engine it is. */ + private List itemsFromGoverning(DepositNoticeRepository.GoverningQuotation governing) { + if (!governing.legacy()) return docs.findDealQuotationItems(governing.quotationId()); + return quotations.findById(governing.quotationId()) + .map(q -> itemsFromQuotation(q.items())) + .orElse(List.of()); } private List buildLegacyItems(long ticketId) { @@ -1257,31 +1261,6 @@ private List buildLegacyItems(long ticketId) { .orElse(List.of()); } - /** - * Picks the quotation to source deposit-notice items from when no explicit items were given - * and no legacy {@code approved_price} exists: the LATEST ACCEPTED revision, or — if the - * customer has not yet accepted any revision — the latest ISSUED one. {@code - * CustomerQuotationRepository#findByTicket} returns rows ordered ASCENDING by {@code - * quotation_id} ALONE — deliberately NOT by {@code quotation_revision_no} (see that method's - * own Javadoc: the revision counter is scoped to a single pricing request and is not - * comparable across the multiple pricing requests one ticket can have) — so a single forward - * scan that keeps overwriting "latest seen" for each status is equivalent to sorting - * descending by {@code quotation_id} and taking the first match, without a second - * list/comparator. - */ - private CustomerQuotationDto pickQuotation(List candidates) { - CustomerQuotationDto latestAccepted = null; - CustomerQuotationDto latestIssued = null; - for (CustomerQuotationDto q : candidates) { - if (QuotationStatus.ACCEPTED.equals(q.docStatus())) { - latestAccepted = q; - } else if (QuotationStatus.ISSUED.equals(q.docStatus())) { - latestIssued = q; - } - } - 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) { @@ -1290,7 +1269,7 @@ public List itemsFromDealQuotation(long 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) + * used by both {@link #itemsFromGoverning} (a legacy governing quotation's lines, above) * and {@code OrderConfirmationService.createDepositNoticeFromQuotation} (the explicit * quotation-driven entry point), so the two paths can never drift apart — extracted here * (rather than duplicated, as it was before this branch) because both already depend on this diff --git a/backend/src/main/java/th/co/glr/hr/orderconfirmation/OrderConfirmationRequests.java b/backend/src/main/java/th/co/glr/hr/orderconfirmation/OrderConfirmationRequests.java index 9b41474a5..af85fb24e 100644 --- a/backend/src/main/java/th/co/glr/hr/orderconfirmation/OrderConfirmationRequests.java +++ b/backend/src/main/java/th/co/glr/hr/orderconfirmation/OrderConfirmationRequests.java @@ -11,9 +11,10 @@ private OrderConfirmationRequests() {} public record ConfirmOrderRequest(String clientRequestId) {} /** - * {@code depositPercent} is optional — {@code null} lets {@code DepositNoticeService - * .createDraft}/{@code DepositNoticeRepository.createDraft} apply their own existing 50% - * default, exactly as the legacy deposit-notice draft flow already does. + * {@code depositPercent} is never read (owner ruling 2026-10-23): the draft bills the percent + * the accepted quotation's PDF printed. Deleting the field would refuse no client, because + * unknown JSON fields are ignored; while it stays, its bounds still answer 400 to a stale + * client that sends a value outside 0-1. */ public record CreateDepositNoticeFromQuotationRequest( @DecimalMin("0") @DecimalMax("1") BigDecimal depositPercent 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 5ac3ab64e..78590856d 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 @@ -27,6 +27,7 @@ import th.co.glr.hr.pricingrequest.PricingRequestRepository.OrderConfirmationState; import th.co.glr.hr.pricingrequest.PricingRequestStatus; import th.co.glr.hr.pricingrequest.UnitBasis; +import th.co.glr.hr.ticket.QuotationRenderer; import th.co.glr.hr.ticket.QuotationStatus; import th.co.glr.hr.ticket.TicketDto; import th.co.glr.hr.ticket.TicketEventKind; @@ -421,16 +422,12 @@ private String mapUnitBasisToTicketItem(String unitBasis) { * the ticket's own customer/project, same as the ticket-level route) — see {@code * DepositNoticeService.createDraft}'s own comments for what changed and why. * - *

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} (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. + *

Deposit percent (owner rulings 2026-09-29, 2026-10-23): {@code + * request.depositPercent()} is never read: the percent is the one the chosen quotation's PDF + * printed, passed as {@code sourceDepositPercent}. For the legacy branch that is always {@link + * QuotationRenderer#LEGACY_DEPOSIT_PERCENT}, because the legacy PDF never reads the stored + * column. Deleting the field from {@link CreateDepositNoticeFromQuotationRequest} would refuse + * no client (unknown JSON fields are ignored); its bounds still 400 an out-of-range value. * *

New-engine quotation: when the pricing request has no ACCEPTED legacy * quotation, this falls back to its ACCEPTED new-engine {@code PRICING_REQUEST}-origin @@ -440,13 +437,11 @@ private String mapUnitBasisToTicketItem(String unitBasis) { * 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. + * {@code deposit_percent}, a null one as the PDF's default 30%, goes to {@link + * DepositNoticeService#createDraftFromQuotation}. Neither branch consults the + * ticket's governing quotation ({@code DepositNoticeRepository#findGoverningQuotation}), + * which only the ticket-level route uses. The customer must have accepted: an ISSUED-only + * quotation answers 409. */ @Transactional public DepositNoticeDto createDepositNoticeFromQuotation(long pricingRequestId, @@ -466,7 +461,7 @@ public DepositNoticeDto createDepositNoticeFromQuotation(long pricingRequestId, if (accepted != null) { reference = accepted.number(); items = DepositNoticeService.itemsFromQuotation(accepted.items()); - sourceDepositPercent = null; + sourceDepositPercent = QuotationRenderer.LEGACY_DEPOSIT_PERCENT; } else { th.co.glr.hr.dealquotation.DealQuotationDtos.DealQuotationDto newEngine = dealQuotations == null ? null @@ -481,19 +476,16 @@ public DepositNoticeDto createDepositNoticeFromQuotation(long pricingRequestId, // 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() - + " ระบุไม่มีมัดจำ (ชำระเต็มจำนวน) จึงออกใบแจ้งยอดเงินรับมัดจำไม่ได้"); + throw DepositNoticeService.zeroDepositRefusal(newEngine.number()); } reference = newEngine.number(); items = depositNotices.itemsFromDealQuotation(newEngine.id()); - sourceDepositPercent = newEngine.depositPercent(); + sourceDepositPercent = newEngine.depositPercent() != null ? newEngine.depositPercent() + : th.co.glr.hr.dealquotation.DealQuotationRenderAdapter.DEFAULT_DEPOSIT_PERCENT; } - // 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, reference, - request != null ? request.depositPercent() : null, null, items); + null, null, null, null, reference, null, null, items); DepositNoticeDto draft = depositNotices.createDraftFromQuotation(summary.ticketId(), draftRequest, sourceDepositPercent, actor); diff --git a/backend/src/main/java/th/co/glr/hr/ticket/QuotationRenderer.java b/backend/src/main/java/th/co/glr/hr/ticket/QuotationRenderer.java index 416d4b542..4fd34ff40 100644 --- a/backend/src/main/java/th/co/glr/hr/ticket/QuotationRenderer.java +++ b/backend/src/main/java/th/co/glr/hr/ticket/QuotationRenderer.java @@ -73,6 +73,12 @@ public class QuotationRenderer { private static final Logger log = LoggerFactory.getLogger(QuotationRenderer.class); + /** The deposit percent the legacy PDF prints when its {@code QuotationDto} carries none. A + * pricing-chain legacy quotation never carries one ({@code CustomerQuotationService + * #loadRenderContext}), so its PDF always prints this, and its deposit notice bills it (owner + * ruling 2026-10-23). */ + public static final int LEGACY_DEPOSIT_PERCENT = 50; + // Real company XLS template — sheet "Update" private static final String TEMPLATE = "templates/quotation_template.xls"; @@ -322,7 +328,7 @@ private QuotationRenderModel buildLegacyModel(TicketDto ticket, QuotationDto quo List items = priceItems.stream().map(this::toLegacyRenderItem).toList(); LocalDate offerDate = quotation.offerDate() != null ? quotation.offerDate() : issueDate; - int depositPct = quotation.depositPercent() != null ? quotation.depositPercent() : 50; + int depositPct = quotation.depositPercent() != null ? quotation.depositPercent() : LEGACY_DEPOSIT_PERCENT; int deliveryDays = quotation.deliveryLeadDays() != null ? quotation.deliveryLeadDays() : 90; List remarkLines = List.of( "1.จำนวนที่เสนอข้างต้นเป็นจำนวนที่ได้รับมาเมื่อวันที่ " + shortThaiDate(offerDate), diff --git a/backend/src/main/java/th/co/glr/hr/ticket/TicketRepository.java b/backend/src/main/java/th/co/glr/hr/ticket/TicketRepository.java index df2a0447c..2ea18c491 100644 --- a/backend/src/main/java/th/co/glr/hr/ticket/TicketRepository.java +++ b/backend/src/main/java/th/co/glr/hr/ticket/TicketRepository.java @@ -888,12 +888,20 @@ private List findItemsByTicketId(long ticketId) { * own Javadoc on why this must not reach {@link #findById}/mutation call sites) falls back to * the deal's live quotation's line items, rendered READ-ONLY. * - *

"Live" is picked with the EXACT SAME rule as {@code DepositNoticeRepository - * #findDealQuotationItemsForDeposit} (ACCEPTED beats ISSUED beats APPROVED, across both - * PRICING_REQUEST and DEAL_DIRECT origins, most recent id wins a tie) -- duplicated here - * rather than called cross-package because deposit-notice and ticket-detail are separate - * bounded contexts, but it is the SAME business rule and must be changed in lockstep if that - * one ever changes. + *

"Live" is a display pick over new-engine rows only: ACCEPTED beats ISSUED beats APPROVED, + * across both PRICING_REQUEST and DEAL_DIRECT origins, most recent id wins a tie. It is NOT the + * ticket-route deposit notice's pick ({@code DepositNoticeRepository#findGoverningQuotation}), + * which also reads legacy rows and, by owner ruling 2026-10-23, counts a DEAL_DIRECT approval + * as the answer, level with ACCEPTED. The two agree on a ticket with one live new-engine + * quotation. They differ when the ticket also has a live legacy quotation. Two DEAL_DIRECT + * cases exist only on pre-lock data (a DEAL_DIRECT quotation beside a pricing-request one, + * which {@code DirectQuotationLocks} now refuses): an ACCEPTED quotation followed by a newer + * DEAL_DIRECT APPROVED one ({@code DealQuotationRepository#supersedeOtherLiveOnTicket} never + * supersedes an ACCEPTED row), where this shows the ACCEPTED lines and a ticket-route draft + * bills the DEAL_DIRECT one; and data from before 2026-10-17 that still holds both a + * DEAL_DIRECT APPROVED and a PRICING_REQUEST ISSUED row. + * The UI drafts any deal with a QUOTATION_ACCEPTED pricing request through the + * pricing-request route, which bills that request's accepted quotation instead. * *

Only brand/model/color/texture/size/qty/qtySqm/unitBasis/rawUnit are mapped -- pricing * fields (proposedPrice/approvedPrice/calcedCost/manualPrice/...) are deliberately left at 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 1815e4db0..56d8db999 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 @@ -11,6 +11,8 @@ import java.util.UUID; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import org.springframework.http.HttpStatus; import th.co.glr.hr.attachment.FileStorageService; import th.co.glr.hr.auth.EmployeeAuthRepository; @@ -69,7 +71,6 @@ import th.co.glr.hr.pricingdecision.PricingDecisionRequests.UpdatePricingDecisionItemRequest; import th.co.glr.hr.pricingdecision.PricingDecisionRequests.UpdatePricingDecisionRequest; import th.co.glr.hr.pricingdecision.PricingDecisionService; -import th.co.glr.hr.pricingrequest.PricingRequestDtos.PricingRequestSummaryDto; import th.co.glr.hr.pricingrequest.PricingRequestRecipient; import th.co.glr.hr.pricingrequest.PricingRequestRepository; import th.co.glr.hr.pricingrequest.PricingRequestRequests; @@ -248,34 +249,6 @@ void recordOutcome_byOwningSales_accepted_setsPrStatusAndDocStatus() { // 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")); @@ -289,12 +262,13 @@ void createDepositNoticeFromQuotation_newEngineIssuedNotAccepted_still409() { 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). */ + /** A zero-deposit quotation ("ไม่รับมัดจำ", full payment) has no deposit to bill: both routes + * 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). The ticket route refuses because this quotation governs the + * ticket; a DEAL_DIRECT deal has only that route. */ @Test - void createDepositNoticeFromQuotation_newEngineAcceptedZeroDeposit_409AndNoDraft() { + void createDepositNotice_newEngineAcceptedZeroDeposit_bothRoutes409AndNoDraft() { DealQuotationDto accepted = acceptedZeroDepositNewOriginQuotation(PricingRequestRecipient.DESIGNER, new BigDecimal("10")); long pricingRequestId = accepted.pricingRequestId(); @@ -302,11 +276,17 @@ void createDepositNoticeFromQuotation_newEngineAcceptedZeroDeposit_409AndNoDraft orderConfirmation.confirmOrder(pricingRequestId, new OrderConfirmationRequests.ConfirmOrderRequest(UUID.randomUUID().toString()), salesActor); + String refusal = "ใบเสนอราคา " + accepted.number() + " ระบุไม่มีมัดจำ"; assertThatThrownBy(() -> orderConfirmation.createDepositNoticeFromQuotation(pricingRequestId, new OrderConfirmationRequests.CreateDepositNoticeFromQuotationRequest(null), salesActor)) .isInstanceOf(ApiException.class) .hasFieldOrPropertyWithValue("status", HttpStatus.CONFLICT) - .hasMessageContaining("ไม่มีมัดจำ"); + .hasMessageContaining(refusal); + assertThatThrownBy(() -> depositNoticeService.createDraft(ticketId, + new DepositNoticeDraftRequest(null, null, null, null, null, null, null, null), salesActor)) + .isInstanceOf(ApiException.class) + .hasFieldOrPropertyWithValue("status", HttpStatus.CONFLICT) + .hasMessageContaining(refusal); assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM sales.deposit_notice WHERE ticket_id = :id", Map.of("id", ticketId), Long.class)).isZero(); } @@ -376,21 +356,30 @@ void createDepositNoticeFromQuotation_newEngineAcceptedWithAnOpenRevisionDraft_s } /** 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() { + * older legacy ISSUED quotation must not supply the lines or the percent of the draft, on + * either route. The customer answered this quotation and not the sibling, so it governs the + * ticket route too (owner ruling 2026-10-23). The draft bills this quotation's own percent, a + * NULL one as the 30% its PDF prints. */ + @ParameterizedTest(name = "quotation percent {0} bills {1}") + @CsvSource({", 0.30", "20, 0.20"}) + void createDepositNotice_newEngineAccepted_whenASiblingPricingRequestHasAnOlderLegacyIssuedQuotation_bothRoutesDraftThisQuotationsLinesAndPercent( + Integer quotationPercent, BigDecimal expectedDraftPercent) { 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); + DealQuotationDto accepted = acceptedNewOriginQuotation(PricingRequestRecipient.DESIGNER, new BigDecimal("10"), + quotationPercent); + assertThat(accepted.depositPercent()).isEqualTo(quotationPercent); + assertThat(accepted.id()).isGreaterThan(legacyIssued.id()); long pricingRequestId = accepted.pricingRequestId(); orderConfirmation.confirmOrder(pricingRequestId, new OrderConfirmationRequests.ConfirmOrderRequest(UUID.randomUUID().toString()), salesActor); - DepositNoticeDto draft = orderConfirmation.createDepositNoticeFromQuotation(pricingRequestId, + DepositNoticeDto viaPricingRequest = orderConfirmation.createDepositNoticeFromQuotation(pricingRequestId, new OrderConfirmationRequests.CreateDepositNoticeFromQuotationRequest(null), salesActor); + DepositNoticeDto viaTicket = depositNoticeService.createDraft(ticketId, + new DepositNoticeDraftRequest(null, null, null, null, null, null, null, null), salesActor); List thisQuotationsLines = jdbc.query(""" SELECT qty, final_unit_price FROM sales.quotation_item WHERE quotation_id = :id ORDER BY seq @@ -401,11 +390,60 @@ void createDepositNoticeFromQuotation_newEngineAccepted_whenASiblingPricingReque // 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"); + for (DepositNoticeDto draft : List.of(viaPricingRequest, viaTicket)) { + assertThat(draft.items().stream().map(it -> line(it.qty(), it.netUnitPrice())).toList()) + .isEqualTo(thisQuotationsLines); + assertThat(draft.depositPercent()).isEqualByComparingTo(expectedDraftPercent); + assertThat(draft.reference()).isEqualTo(accepted.number()); + } + // Every line field, not just qty and net price, is the same on both routes. + assertThat(viaPricingRequest.items()) + .usingRecursiveComparison().ignoringFields("id") + .isEqualTo(viaTicket.items()); + // The new-engine branch writes the pricing-request event too (OrderConfirmationIntegrationTest + // pins the legacy branch). + 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); + } + + /** The reverse mix: the customer answered the legacy quotation, and the sibling pricing + * request's newer new-engine quotation is only ISSUED. The answered legacy row governs both + * routes, and bills the 50% its PDF prints ({@code ticket.QuotationRenderer}), never the + * sibling's 40%. It wins because it was answered, not because it is legacy. */ + @Test + void createDepositNotice_legacyAcceptedNullPercent_whenASiblingPricingRequestHasANewerFortyPercentIssuedQuotation_bothRoutesBillTheAnsweredLegacyQuotationsFifty() { + CustomerQuotationDto legacyAccepted = acceptedLegacyQuotation(PricingRequestRecipient.DESIGNER); + assertThat(jdbc.queryForObject("SELECT deposit_percent IS NULL FROM sales.quotation WHERE quotation_id = :id", + Map.of("id", legacyAccepted.id()), Boolean.class)).isTrue(); + DealQuotationDto siblingIssued = issuedNewOriginQuotation(PricingRequestRecipient.OWNER, new BigDecimal("10"), 40); + assertThat(siblingIssued.depositPercent()).isEqualTo(40); + long pricingRequestId = legacyAccepted.pricingRequestId(); + orderConfirmation.confirmOrder(pricingRequestId, + new OrderConfirmationRequests.ConfirmOrderRequest(UUID.randomUUID().toString()), salesActor); + + DepositNoticeDto viaPricingRequest = orderConfirmation.createDepositNoticeFromQuotation(pricingRequestId, + new OrderConfirmationRequests.CreateDepositNoticeFromQuotationRequest(null), salesActor); + DepositNoticeDto viaTicket = depositNoticeService.createDraft(ticketId, + new DepositNoticeDraftRequest(null, null, null, null, null, null, null, null), salesActor); + + List legacyLines = DepositNoticeService.itemsFromQuotation(legacyAccepted.items()).stream() + .map(it -> line(it.qty(), it.netUnitPrice())).toList(); + List siblingLines = jdbc.query(""" + SELECT qty, final_unit_price FROM sales.quotation_item WHERE quotation_id = :id ORDER BY seq + """, Map.of("id", siblingIssued.id()), + (rs, i) -> line(rs.getBigDecimal("qty"), rs.getBigDecimal("final_unit_price"))); + // The fixture must make the two sources distinguishable, or the item assertions prove nothing. + assertThat(legacyLines).isNotEmpty().isNotEqualTo(siblingLines); + + assertThat(siblingIssued.id()).isGreaterThan(legacyAccepted.id()); + for (DepositNoticeDto draft : List.of(viaPricingRequest, viaTicket)) { + assertThat(draft.depositPercent()).isEqualByComparingTo("0.50"); + assertThat(draft.items().stream().map(it -> line(it.qty(), it.netUnitPrice())).toList()) + .isEqualTo(legacyLines); + assertThat(draft.reference()).isEqualTo(legacyAccepted.number()); + } } @Test diff --git a/backend/src/test/java/th/co/glr/hr/deposit/DepositNoticeDealQuotationAutofillIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/deposit/DepositNoticeDealQuotationAutofillIntegrationTest.java index f9bc3dcb8..66db9f5fa 100644 --- a/backend/src/test/java/th/co/glr/hr/deposit/DepositNoticeDealQuotationAutofillIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/deposit/DepositNoticeDealQuotationAutofillIntegrationTest.java @@ -11,14 +11,15 @@ import th.co.glr.hr.support.AbstractPostgresIntegrationTest; /** - * Real-DB proof for {@link DepositNoticeRepository#findDealQuotationItemsForDeposit(long)} (the - * 2026-09-29 fix) — the V2 deal-quotation item source {@code DepositNoticeService - * #buildItemsFromRequest} falls back to once both the legacy {@code ticket_item.approved_price} - * path and the {@code CustomerQuotationRepository}-scoped {@code pickQuotation} path yield - * nothing. Mockito cannot exercise this: the method's whole job is the SQL — the status/origin - * filter and the {@code ORDER BY} preference — so this needs the real query against real - * Postgres, not a mocked repository. See that method's own Javadoc for the exact selection rule - * this test pins. + * Real-DB proof for the V2 deal-quotation item source of a ticket-level deposit notice (the + * 2026-09-29 fix): {@link DepositNoticeRepository#findGoverningQuotation} picks the quotation and + * {@link DepositNoticeRepository#findDealQuotationItems} reads its lines, the pair {@code + * DepositNoticeService#itemsFromGoverning} uses once the legacy {@code + * ticket_item.approved_price} path yields nothing. Mockito cannot exercise this: the job is the + * SQL (the status/origin filter, the {@code ORDER BY} preference, the column mapping), so this + * needs the real queries against real Postgres. Which quotation wins (the ranking across origins + * and insertion order) is pinned in {@code DepositNoticeRepositoryDepositPercentIntegrationTest}; + * this class's non-live test is the only pin for some excluded statuses, so it is not a duplicate. */ class DepositNoticeDealQuotationAutofillIntegrationTest extends AbstractPostgresIntegrationTest { @@ -44,7 +45,7 @@ void pricingRequestIssuedQuotation_returnsItemsInSeqOrderWithDiscountLabels() { insertQuotationItem(quotationId, 1, "กระเบื้อง A", new BigDecimal("10"), "แผ่น", new BigDecimal("4365.29"), new BigDecimal("873.06"), new BigDecimal("3492.23")); - List items = repo.findDealQuotationItemsForDeposit(ticketId); + List items = itemsForDeposit(ticketId); assertThat(items).hasSize(2); assertThat(items.get(0).seq()).isEqualTo(1); @@ -63,43 +64,7 @@ void pricingRequestIssuedQuotation_returnsItemsInSeqOrderWithDiscountLabels() { assertThat(items.get(1).discountLabel()).isNull(); } - // ── (b) DEAL_DIRECT / APPROVED — the case the old IN ('ACCEPTED','ISSUED') filter MISSED ─── - - @Test - void dealDirectApprovedQuotation_isReturned_theCoreRegressionThisFixCloses() { - long ticketId = insertTicket("DEPNOT-B"); - long quotationId = insertQuotation(ticketId, "DEAL_DIRECT", "APPROVED"); - insertQuotationItem(quotationId, 1, "DEAL_DIRECT item", new BigDecimal("2"), "แผ่น", - new BigDecimal("1000.00"), BigDecimal.ZERO, new BigDecimal("1000.00")); - - List items = repo.findDealQuotationItemsForDeposit(ticketId); - - assertThat(items).hasSize(1); - assertThat(items.get(0).description()).isEqualTo("DEAL_DIRECT item"); - assertThat(items.get(0).unitPrice()).isEqualByComparingTo("1000.00"); - assertThat(items.get(0).discountLabel()).isNull(); - } - - // ── (c) ACCEPTED beats ISSUED (both PRICING_REQUEST, same ticket) ─────────────────────────── - - @Test - void acceptedPricingRequestQuotation_winsOverAnIssuedOne_onTheSameTicket() { - long ticketId = insertTicket("DEPNOT-C"); - long issuedQuotationId = insertQuotation(ticketId, "PRICING_REQUEST", "ISSUED"); - insertQuotationItem(issuedQuotationId, 1, "Issued item (must lose)", BigDecimal.ONE, "แผ่น", - new BigDecimal("111.00"), BigDecimal.ZERO, new BigDecimal("111.00")); - long acceptedQuotationId = insertQuotation(ticketId, "PRICING_REQUEST", "ACCEPTED"); - insertQuotationItem(acceptedQuotationId, 1, "Accepted item (must win)", BigDecimal.ONE, "แผ่น", - new BigDecimal("222.00"), BigDecimal.ZERO, new BigDecimal("222.00")); - - List items = repo.findDealQuotationItemsForDeposit(ticketId); - - assertThat(items).hasSize(1); - assertThat(items.get(0).description()).isEqualTo("Accepted item (must win)"); - assertThat(items.get(0).unitPrice()).isEqualByComparingTo("222.00"); - } - - // ── (d) NEGATIVE, wrong-way-round: nothing in a live status -> empty list ─────────────────── + // ── (b) NEGATIVE, wrong-way-round: nothing in a live status -> empty list ─────────────────── /** * Written wrong-way-round per CLAUDE.md's "Permission changes must ship evidence" guidance, @@ -124,13 +89,20 @@ void onlyNonLiveStatusQuotations_returnsEmptyList() { insertQuotationItem(pendingApprovalId, 1, "Pending-approval item", BigDecimal.ONE, "แผ่น", BigDecimal.TEN, BigDecimal.ZERO, BigDecimal.TEN); - List items = repo.findDealQuotationItemsForDeposit(ticketId); + List items = itemsForDeposit(ticketId); assertThat(items).isEmpty(); } // ── helpers ────────────────────────────────────────────────────────────────────────────── + /** The new-engine branch of {@code DepositNoticeService#itemsFromGoverning}. */ + private List itemsForDeposit(long ticketId) { + return repo.findGoverningQuotation(ticketId) + .map(governing -> repo.findDealQuotationItems(governing.quotationId())) + .orElse(List.of()); + } + private long insertEmployee(String code) { return jdbc.queryForObject( "INSERT INTO hr.employee (employee_code, first_name_th, last_name_th) " @@ -171,7 +143,7 @@ private long insertQuotation(long ticketId, String origin, String docStatus) { private void insertQuotationItem(long quotationId, int seq, String description, BigDecimal qty, String rawUnit, BigDecimal unitPrice, BigDecimal salesDiscount, BigDecimal finalUnitPrice) { - // amount is legacy NOT NULL (V49) — irrelevant to findDealQuotationItemsForDeposit, which + // amount is legacy NOT NULL (V49) — irrelevant to findDealQuotationItems, which // reads unit_price/final_unit_price instead, so a harmless qty*unitPrice placeholder. BigDecimal amount = qty.multiply(unitPrice); jdbc.update(""" diff --git a/backend/src/test/java/th/co/glr/hr/deposit/DepositNoticeRepositoryDepositPercentIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/deposit/DepositNoticeRepositoryDepositPercentIntegrationTest.java index 569cfddaa..ffbb3566c 100644 --- a/backend/src/test/java/th/co/glr/hr/deposit/DepositNoticeRepositoryDepositPercentIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/deposit/DepositNoticeRepositoryDepositPercentIntegrationTest.java @@ -5,10 +5,13 @@ import java.math.BigDecimal; import java.time.LocalDate; import java.util.List; -import java.util.Optional; +import java.util.stream.Stream; import java.util.UUID; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; import th.co.glr.hr.attachment.FileStorageService; import th.co.glr.hr.customer.ContactRepository; @@ -35,19 +38,17 @@ import th.co.glr.hr.dealquotation.WastageCalculator; /** - * Real-Postgres coverage for {@link DepositNoticeRepository#findDealQuotationDepositPercent} — - * owner ruling (2026-09-29): the deposit-notice PERCENT is LOCKED to the source quotation's own - * {@code sales.quotation.deposit_percent}, never editable at the deposit-notice stage. A Mockito - * unit test on {@code DepositNoticeServiceTest} already proves the SERVICE calls this repository - * method and trusts its result over a caller-supplied value — it cannot prove the method's own - * SQL actually reads the right row out of {@code sales.quotation}. This class drives that SQL - * directly. + * Real-Postgres coverage for {@link DepositNoticeRepository#findGoverningQuotation}, the one pick + * of the quotation a ticket-level deposit notice bills: its lines and its percent (owner rulings + * 2026-09-29 and 2026-10-23). {@code DepositNoticeServiceTest} proves the SERVICE takes both from + * the row this returns; it cannot prove the SQL picks the right row out of {@code sales.quotation}. + * This class drives that SQL directly. * *

Fixture note: rows are written straight over JDBC rather than through {@code - * CustomerQuotationService}/{@code DealQuotationService} — neither ever writes {@code - * deposit_percent} today (see the repository method's own Javadoc: the legacy engine's {@code - * CreateCustomerQuotationRequest} has no such field at all, and only {@code DealQuotationService}'s - * v2 engine writes it in production). A real {@code ticket_id} and, for the legacy-branch cases, a + * CustomerQuotationService}/{@code DealQuotationService}, so one test can set any status, origin, + * insertion order and stored {@code deposit_percent} (the legacy engine never writes that column: + * {@code CreateCustomerQuotationRequest} has no such field; only {@code DealQuotationService} + * writes it). A real {@code ticket_id} and, for the legacy-branch cases, a * real {@code pricing_request_id} are still required — both are real FK targets on {@code * sales.quotation} — so this still builds a genuine ticket (+ a draft pricing request for the * legacy cases only; the v2 branch's own FK, {@code origin}, needs no pricing request at all). @@ -82,93 +83,191 @@ void setUp() { } // ── Legacy chain (origin IS NULL, pricing_request_id IS NOT NULL): ACCEPTED > ISSUED ────── + // The legacy PDF prints 50% whatever the row stores (ticket.QuotationRenderer), so every live + // legacy row bills 0.50; the fixtures store other percents to prove the column is never read. @Test - void legacyChain_prefersAcceptedQuotationsDepositPercentOverAnIssuedOne() { + void legacyChain_anAcceptedQuotationBeatsANewerIssuedOne_andBillsItsPdfsFifty() { long ticketId = newTicketWithPricingRequestDraft(); + long accepted = insertLegacyQuotation(ticketId, "ACCEPTED", (short) 30); insertLegacyQuotation(ticketId, "ISSUED", (short) 20); - insertLegacyQuotation(ticketId, "ACCEPTED", (short) 30); - Optional result = depositNotices.findDealQuotationDepositPercent(ticketId); - - assertThat(result).isPresent(); - assertThat(result.get()).isEqualByComparingTo("0.30"); + assertGoverning(ticketId, accepted, true, "0.50"); } @Test - void legacyChain_fallsBackToIssuedWhenNoneAccepted() { + void legacyChain_fallsBackToIssuedWhenNoneAccepted_andBillsItsPdfsFifty() { long ticketId = newTicketWithPricingRequestDraft(); - insertLegacyQuotation(ticketId, "ISSUED", (short) 20); - - Optional result = depositNotices.findDealQuotationDepositPercent(ticketId); + long issued = insertLegacyQuotation(ticketId, "ISSUED", (short) 20); - assertThat(result).isPresent(); - assertThat(result.get()).isEqualByComparingTo("0.20"); + assertGoverning(ticketId, issued, true, "0.50"); } + /** A ticket-level legacy quotation ({@code pricing_request_id IS NULL}) never governs, even + * ACCEPTED and newest: the scope is {@code CustomerQuotationRepository#findByTicket}'s. */ @Test - void legacyChain_ignoresNonLiveStatusesLikeDraftOrRejected() { + void legacyChain_ignoresNonLiveStatusesAndTicketLevelQuotations() { long ticketId = newTicketWithPricingRequestDraft(); insertLegacyQuotation(ticketId, "DRAFT", (short) 40); insertLegacyQuotation(ticketId, "REJECTED", (short) 50); + int version = ++quotationVersionSeq; + jdbc.update(""" + INSERT INTO sales.quotation (ticket_id, number, issued_by, doc_status, quotation_version) + VALUES (:ticketId, :number, :issuedBy, 'ACCEPTED', :version) + """, new MapSqlParameterSource().addValue("ticketId", ticketId) + .addValue("number", "QTD" + ticketId + "-" + version).addValue("issuedBy", employeeId) + .addValue("version", version)); + + assertThat(depositNotices.findGoverningQuotation(ticketId)).isEmpty(); + } + + @Test + void legacyChain_nullDepositPercentOnTheLiveQuotationYieldsTheFiftyItsPdfPrints() { + long ticketId = newTicketWithPricingRequestDraft(); + long accepted = insertLegacyQuotation(ticketId, "ACCEPTED", null); - Optional result = depositNotices.findDealQuotationDepositPercent(ticketId); + assertGoverning(ticketId, accepted, true, "0.50"); + } + + // ── Mixed ticket: the latest quotation the customer ANSWERED governs, whichever engine ───── + // (owner ruling 2026-10-23). Answered = ACCEPTED, or a DEAL_DIRECT quotation's terminal + // APPROVED. An unanswered ISSUED row governs only when nothing was answered. + + /** The legacy row wins because the customer answered it, not because it is legacy: the + * sibling v2 row is newer. */ + @Test + void mixedTicket_anAnsweredLegacyQuotationBeatsANewerUnansweredV2One() { + long ticketId = newTicketWithPricingRequestDraft(); + long legacyAccepted = insertLegacyQuotation(ticketId, "ACCEPTED", null); + insertOriginTaggedQuotation(ticketId, "PRICING_REQUEST", "ISSUED", null); - assertThat(result).isEmpty(); + assertGoverning(ticketId, legacyAccepted, true, "0.50"); } + /** Newest wins whichever engine it is: both insertion orders, so neither engine is preferred. */ @Test - void legacyChain_nullDepositPercentOnTheLiveQuotationYieldsEmpty() { + void mixedTicket_withNothingAnswered_theNewestIssuedQuotationGoverns() { + long v2Newer = newTicketWithPricingRequestDraft(); + insertLegacyQuotation(v2Newer, "ISSUED", null); + long v2Issued = insertOriginTaggedQuotation(v2Newer, "PRICING_REQUEST", "ISSUED", (short) 40); + assertGoverning(v2Newer, v2Issued, false, "0.40"); + + long legacyNewer = newTicketWithPricingRequestDraft(); + insertOriginTaggedQuotation(legacyNewer, "PRICING_REQUEST", "ISSUED", (short) 40); + long legacyIssued = insertLegacyQuotation(legacyNewer, "ISSUED", null); + assertGoverning(legacyNewer, legacyIssued, true, "0.50"); + } + + /** The DEAL_DIRECT row is the OLDER one, so only its answered rank (not quotation_id) can pick it. */ + @Test + void mixedTicket_aDealDirectApprovedQuotationIsAnAnswer_andBeatsANewerLegacyIssuedOne() { long ticketId = newTicketWithPricingRequestDraft(); - insertLegacyQuotation(ticketId, "ACCEPTED", null); + long dealDirect = insertOriginTaggedQuotation(ticketId, "DEAL_DIRECT", "APPROVED", (short) 40); + insertLegacyQuotation(ticketId, "ISSUED", null); + + assertGoverning(ticketId, dealDirect, false, "0.40"); + } - Optional result = depositNotices.findDealQuotationDepositPercent(ticketId); + /** A DEAL_DIRECT approval is an answer level with ACCEPTED (owner ruling 2026-10-23), so the + * newer of the two governs. Only data from before the 2026-09-30 direct-quotation lock can + * hold both on one ticket. */ + @Test + void mixedTicket_aDealDirectApprovedQuotationBeatsAnOlderAcceptedOne() { + long ticketId = newTicketWithPricingRequestDraft(); + insertLegacyQuotation(ticketId, "ACCEPTED", null); + long dealDirect = insertOriginTaggedQuotation(ticketId, "DEAL_DIRECT", "APPROVED", (short) 40); - assertThat(result).isEmpty(); + assertGoverning(ticketId, dealDirect, false, "0.40"); } // ── V2 deal-quotation chain (origin IN ('PRICING_REQUEST','DEAL_DIRECT')) ───────────────── + /** The new-engine PDF prints a null percent as 30% ({@code DealQuotationRenderAdapter}). */ @Test - void dealDirectOrigin_readsApprovedQuotationsDepositPercent() { + void pricingRequestOrigin_nullDepositPercentOnTheLiveQuotationYieldsTheThirtyItsPdfPrints() { long ticketId = newBareTicket(); - insertOriginTaggedQuotation(ticketId, "DEAL_DIRECT", "APPROVED", (short) 50); - - Optional result = depositNotices.findDealQuotationDepositPercent(ticketId); + long accepted = insertOriginTaggedQuotation(ticketId, "PRICING_REQUEST", "ACCEPTED", null); - assertThat(result).isPresent(); - assertThat(result.get()).isEqualByComparingTo("0.50"); + assertGoverning(ticketId, accepted, false, "0.30"); } @Test - void pricingRequestOrigin_prefersAcceptedOverIssuedOverApproved() { + void pricingRequestOrigin_anAcceptedQuotationBeatsANewerIssuedOne() { long ticketId = newBareTicket(); - insertOriginTaggedQuotation(ticketId, "PRICING_REQUEST", "APPROVED", (short) 10); + long accepted = insertOriginTaggedQuotation(ticketId, "PRICING_REQUEST", "ACCEPTED", (short) 30); insertOriginTaggedQuotation(ticketId, "PRICING_REQUEST", "ISSUED", (short) 20); - insertOriginTaggedQuotation(ticketId, "PRICING_REQUEST", "ACCEPTED", (short) 30); - - Optional result = depositNotices.findDealQuotationDepositPercent(ticketId); - assertThat(result).isPresent(); - assertThat(result.get()).isEqualByComparingTo("0.30"); + assertGoverning(ticketId, accepted, false, "0.30"); } + /** APPROVED is live on DEAL_DIRECT only: a PRICING_REQUEST approval lands on ISSUED + * ({@code DealQuotationRepository#approve}), so no writer produces the PRICING_REQUEST row here. */ @Test - void dealDirectOrigin_ignoresNonLiveStatusesLikeDraftOrCancelled() { + void v2Origins_ignoreNonLiveStatuses_includingAPricingRequestApproved() { long ticketId = newBareTicket(); insertOriginTaggedQuotation(ticketId, "DEAL_DIRECT", "DRAFT", (short) 60); insertOriginTaggedQuotation(ticketId, "DEAL_DIRECT", "CANCELLED", (short) 70); + insertOriginTaggedQuotation(ticketId, "PRICING_REQUEST", "APPROVED", (short) 10); + + assertThat(depositNotices.findGoverningQuotation(ticketId)).isEmpty(); + } - Optional result = depositNotices.findDealQuotationDepositPercent(ticketId); + private static final List ALL_DOC_STATUSES = List.of("DRAFT", "READY_TO_ISSUE", "PENDING_APPROVAL", + "APPROVED", "ISSUED", "SENT", "SUPERSEDED", "CANCELLED", "EXPIRED", "ACCEPTED", "REJECTED", + "REVISION_REQUESTED"); + + /** Every (origin, doc_status) pair in {@code chk_quotation_doc_status} the SQL must exclude: + * live is ACCEPTED/ISSUED for legacy and PRICING_REQUEST, plus APPROVED for DEAL_DIRECT. */ + static Stream nonLivePairs() { + return Stream.of("LEGACY", "PRICING_REQUEST", "DEAL_DIRECT").flatMap(origin -> + ALL_DOC_STATUSES.stream() + .filter(st -> !(st.equals("ACCEPTED") || st.equals("ISSUED") + || (origin.equals("DEAL_DIRECT") && st.equals("APPROVED")))) + .map(st -> Arguments.of(origin, st))); + } + + /** A live ISSUED row plus a NEWER excluded one: an admitted excluded status would win on id + * (or, for APPROVED/ACCEPTED, on rank), so the ISSUED row must be the pick. */ + @ParameterizedTest(name = "{0} {1} is not live") + @MethodSource("nonLivePairs") + void aNewerNonLiveStatusNeverBeatsALiveIssuedQuotation(String origin, String excludedStatus) { + boolean legacy = origin.equals("LEGACY"); + long ticketId = legacy ? newTicketWithPricingRequestDraft() : newBareTicket(); + long live = legacy ? insertLegacyQuotation(ticketId, "ISSUED", null) + : insertOriginTaggedQuotation(ticketId, origin, "ISSUED", (short) 40); + if (legacy) { + insertLegacyQuotation(ticketId, excludedStatus, (short) 10); + } else { + insertOriginTaggedQuotation(ticketId, origin, excludedStatus, (short) 10); + } + + assertGoverning(ticketId, live, legacy, legacy ? "0.50" : "0.40"); + } + + /** Mirror of the DEAL_DIRECT-APPROVED-vs-older-ACCEPTED test: the OLDER APPROVED row loses to + * a NEWER ACCEPTED one, so APPROVED ranks level with ACCEPTED, not above it. */ + @Test + void mixedTicket_aNewerAcceptedQuotationBeatsAnOlderDealDirectApprovedOne() { + long ticketId = newTicketWithPricingRequestDraft(); + insertOriginTaggedQuotation(ticketId, "DEAL_DIRECT", "APPROVED", (short) 40); + long accepted = insertLegacyQuotation(ticketId, "ACCEPTED", null); - assertThat(result).isEmpty(); + assertGoverning(ticketId, accepted, true, "0.50"); } @Test void noQuotationAtAllYieldsEmpty() { long ticketId = newBareTicket(); - assertThat(depositNotices.findDealQuotationDepositPercent(ticketId)).isEmpty(); + assertThat(depositNotices.findGoverningQuotation(ticketId)).isEmpty(); + } + + private void assertGoverning(long ticketId, long quotationId, boolean legacy, String depositPercent) { + DepositNoticeRepository.GoverningQuotation governing = + depositNotices.findGoverningQuotation(ticketId).orElseThrow(); + assertThat(governing.quotationId()).isEqualTo(quotationId); + assertThat(governing.legacy()).isEqualTo(legacy); + assertThat(governing.depositPercent()).isEqualByComparingTo(depositPercent); } // ───────────────────────────────────────────────────────────────────────────────────── @@ -213,16 +312,17 @@ null, new BigDecimal("10"), new BigDecimal("0.36"), WastageCalculator.QUANTITY_M /** Legacy quotation row: {@code origin IS NULL}, {@code pricing_request_id IS NOT NULL} — * exactly the scope {@code CustomerQuotationRepository#findByTicket} uses. */ - private void insertLegacyQuotation(long ticketId, String docStatus, Short depositPercent) { + private long insertLegacyQuotation(long ticketId, String docStatus, Short depositPercent) { long pricingRequestId = pricingRequests.findByTicket(ticketId).get(0).id(); int version = ++quotationVersionSeq; - jdbc.update(""" + return jdbc.queryForObject(""" INSERT INTO sales.quotation (ticket_id, number, issued_by, doc_status, quotation_version, pricing_request_id, origin, deposit_percent) VALUES (:ticketId, :number, :issuedBy, :docStatus, :version, :pricingRequestId, NULL, :depositPercent) + RETURNING quotation_id """, new MapSqlParameterSource() .addValue("ticketId", ticketId) @@ -234,18 +334,19 @@ private void insertLegacyQuotation(long ticketId, String docStatus, Short deposi .addValue("docStatus", docStatus) .addValue("version", version) .addValue("pricingRequestId", pricingRequestId) - .addValue("depositPercent", depositPercent)); + .addValue("depositPercent", depositPercent), Long.class); } /** V2 deal-quotation row: {@code origin IN ('PRICING_REQUEST','DEAL_DIRECT')} — no {@code * pricing_request_id} needed (nullable; the v2 engine's own scope is {@code origin} alone). */ - private void insertOriginTaggedQuotation(long ticketId, String origin, String docStatus, Short depositPercent) { + private long insertOriginTaggedQuotation(long ticketId, String origin, String docStatus, Short depositPercent) { int version = ++quotationVersionSeq; - jdbc.update(""" + return jdbc.queryForObject(""" INSERT INTO sales.quotation (ticket_id, number, issued_by, doc_status, quotation_version, origin, deposit_percent) VALUES (:ticketId, :number, :issuedBy, :docStatus, :version, :origin, :depositPercent) + RETURNING quotation_id """, new MapSqlParameterSource() .addValue("ticketId", ticketId) @@ -254,7 +355,7 @@ private void insertOriginTaggedQuotation(long ticketId, String origin, String do .addValue("docStatus", docStatus) .addValue("version", version) .addValue("origin", origin) - .addValue("depositPercent", depositPercent)); + .addValue("depositPercent", depositPercent), Long.class); } private TicketItemRequest ticketItem() { diff --git a/backend/src/test/java/th/co/glr/hr/deposit/DepositNoticeServiceTest.java b/backend/src/test/java/th/co/glr/hr/deposit/DepositNoticeServiceTest.java index 05e2b444d..0472cc0bd 100644 --- a/backend/src/test/java/th/co/glr/hr/deposit/DepositNoticeServiceTest.java +++ b/backend/src/test/java/th/co/glr/hr/deposit/DepositNoticeServiceTest.java @@ -260,8 +260,8 @@ void remainingInvoiceXlsx_nullLineSubtotalFallsBackToFinalUnitPriceTimesQtyHalfU void remainingInvoiceOptions_fallsBackToIssuedDepositNoticeItemsWhenNoAcceptedQuotation() { stubTicket(10L, TicketStatus.QUOTATION_ISSUED, "CUSTOMER_CONFIRMED"); // An ISSUED (not ACCEPTED) quotation must be ignored by the remaining-invoice path — - // unlike buildItemsFromRequest's pickQuotation (deposit notice creation), which DOES fall - // back to ISSUED. See resolveRemainingInvoice's own Javadoc for why these differ. + // unlike DepositNoticeRepository#findGoverningQuotation (deposit notice creation), which + // DOES fall back to ISSUED. See resolveRemainingInvoice's own Javadoc for why these differ. // // Finding 1 (vacuous test): this test used to only assert itemCount/depositAmount, which // stayed green even under the mutation "let ISSUED quotations qualify too" — that mutation @@ -842,7 +842,6 @@ void createDraft_autofillsHeaderFromCustomerMasterAndTicketProjectName() { stubTicketWithCustomer(10L, TicketStatus.QUOTATION_ISSUED, "CUSTOMER_CONFIRMED", 42L, "Project Zeta"); when(customerRepo.findById(42L)).thenReturn(Optional.of( new CustomerDto(42L, "ACME", "0100000000000", "123 Road", "Showroom", "02-000-0000"))); - when(quotationRepo.findByTicket(10L)).thenReturn(List.of()); when(docs.createDraft(eq(10L), any(), any())).thenReturn(201L); stubDraft(201L, 10L); @@ -862,7 +861,6 @@ void createDraft_nullCustomerIdLeavesHeaderFieldsBlankWithoutThrowing() { // Base fixture's ticket carries customerId = null — must not NPE, and the customer // repository must never even be consulted. stubTicket(10L, TicketStatus.QUOTATION_ISSUED, "CUSTOMER_CONFIRMED"); - when(quotationRepo.findByTicket(10L)).thenReturn(List.of()); when(docs.createDraft(eq(10L), any(), any())).thenReturn(202L); stubDraft(202L, 10L); @@ -876,12 +874,16 @@ void createDraft_nullCustomerIdLeavesHeaderFieldsBlankWithoutThrowing() { verify(customerRepo, never()).findById(anyLong()); } + /** With no approved_price on any ticket item, the lines come from the governing quotation + * (DepositNoticeRepository#findGoverningQuotation), here a legacy one. */ @Test - void createDraft_fallsBackToAcceptedQuotationWhenNoTicketItemHasApprovedPrice() { + void createDraft_takesTheLinesOfTheGoverningLegacyQuotationWhenNoTicketItemHasApprovedPrice() { stubTicket(10L, TicketStatus.QUOTATION_ISSUED, "CUSTOMER_CONFIRMED"); // items: List.of() — no approved_price CustomerQuotationItemDto item = quotationItem("กระเบื้อง A", new BigDecimal("10"), "PER_SQM", new BigDecimal("100"), BigDecimal.ZERO, new BigDecimal("100")); - when(quotationRepo.findByTicket(10L)).thenReturn(List.of(quotation(1L, 10L, "ACCEPTED", 1, List.of(item)))); + when(docs.findGoverningQuotation(10L)).thenReturn(Optional.of( + new DepositNoticeRepository.GoverningQuotation(1L, true, "QT-2026-1", new BigDecimal("0.50")))); + when(quotationRepo.findById(1L)).thenReturn(Optional.of(quotation(1L, 10L, "ACCEPTED", 1, List.of(item)))); when(docs.createDraft(eq(10L), any(), any())).thenReturn(203L); stubDraft(203L, 10L); @@ -898,66 +900,47 @@ void createDraft_fallsBackToAcceptedQuotationWhenNoTicketItemHasApprovedPrice() assertThat(items.get(0).unitPrice()).isEqualTo(new BigDecimal("100")); } - @Test - void createDraft_prefersAcceptedQuotationOverAnIssuedOne() { - stubTicket(10L, TicketStatus.QUOTATION_ISSUED, "CUSTOMER_CONFIRMED"); - CustomerQuotationItemDto issuedItem = quotationItem("Issued Item", BigDecimal.ONE, "PER_PIECE", - BigDecimal.TEN, BigDecimal.ZERO, BigDecimal.TEN); - CustomerQuotationItemDto acceptedItem = quotationItem("Accepted Item", BigDecimal.ONE, "PER_PIECE", - BigDecimal.TEN, BigDecimal.ZERO, BigDecimal.TEN); - // findByTicket's own contract: ascending by quotation_id ALONE (see its own Javadoc — - // quotation_revision_no is scoped to a single pricing request, not comparable across - // the multiple pricing requests one ticket can have) — the accepted revision here has - // the higher quotation_id, same as a real revise-after-accept flow. - when(quotationRepo.findByTicket(10L)).thenReturn(List.of( - quotation(1L, 10L, "ISSUED", 1, List.of(issuedItem)), - quotation(2L, 10L, "ACCEPTED", 2, List.of(acceptedItem)))); - when(docs.createDraft(eq(10L), any(), any())).thenReturn(204L); - stubDraft(204L, 10L); - - service.createDraft(10L, - new DepositNoticeDraftRequest(null, null, null, null, null, null, null, null), owner); - - @SuppressWarnings("unchecked") - ArgumentCaptor> itemsCaptor = ArgumentCaptor.forClass(List.class); - verify(docs).createDraft(eq(10L), any(), itemsCaptor.capture()); - assertThat(itemsCaptor.getValue()).hasSize(1); - assertThat(itemsCaptor.getValue().get(0).description()).isEqualTo("Accepted Item"); - } - @Test void createDraft_legacyApprovedPriceItemsWinOverQuotationChainWhenBothExist() { TicketItemDto approvedItem = new TicketItemDto(1L, 10L, "SCG", "Model", "White", "Matte", "60x60", "Factory", new BigDecimal("10"), null, null, null, null, null, new BigDecimal("100"), "THB", 0, null, null, null, "PIECE", null, null); stubTicketWithItems(10L, TicketStatus.QUOTATION_ISSUED, "CUSTOMER_CONFIRMED", List.of(approvedItem)); - // Even though an ACCEPTED quotation exists too, the legacy approved_price path must win - // and the quotation chain must never even be queried (buildLegacyItems short-circuits). + // Even though a governing quotation exists too, the legacy approved_price path must win + // and that quotation's lines must never even be read (buildLegacyItems short-circuits). + // Its percent still applies; its number is NOT stamped as the reference, because the + // remaining-invoice match keys on the reference and these lines are not that quotation's. + when(docs.findGoverningQuotation(10L)).thenReturn(Optional.of( + new DepositNoticeRepository.GoverningQuotation(1L, false, "QT-2026-1", new BigDecimal("0.40")))); when(docs.createDraft(eq(10L), any(), any())).thenReturn(205L); stubDraft(205L, 10L); service.createDraft(10L, new DepositNoticeDraftRequest(null, null, null, null, null, null, null, null), owner); + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(DepositNoticeDraftRequest.class); @SuppressWarnings("unchecked") ArgumentCaptor> itemsCaptor = ArgumentCaptor.forClass(List.class); - verify(docs).createDraft(eq(10L), any(), itemsCaptor.capture()); + verify(docs).createDraft(eq(10L), requestCaptor.capture(), itemsCaptor.capture()); + assertThat(requestCaptor.getValue().reference()).isNull(); + assertThat(requestCaptor.getValue().depositPercent()).isEqualByComparingTo("0.40"); assertThat(itemsCaptor.getValue()).hasSize(1); assertThat(itemsCaptor.getValue().get(0).unitPrice()).isEqualTo(new BigDecimal("100")); - verify(quotationRepo, never()).findByTicket(10L); + verify(quotationRepo, never()).findById(anyLong()); + verify(docs, never()).findDealQuotationItems(anyLong()); } - // ── Deposit percent LOCKED to the quotation (owner ruling, 2026-09-29) ──── - // See DepositNoticeService.createDraft/update and DepositNoticeRepository - // #findDealQuotationDepositPercent's own comments for what changed and why: the deposit - // PERCENT is no longer sales-entered at the deposit-notice stage at all — it is read straight - // off the source quotation's deposit_percent and is not editable afterward. + // ── Deposit percent = what the source quotation's PDF printed (owner rulings 2026-09-29, + // 2026-10-23) ──── + // The governing quotation's percent (DepositNoticeRepository#findGoverningQuotation: legacy + // 50, new-engine its own, blank 30); a caller-supplied percent is never read on create, and + // 0.50 applies only when the ticket has no live quotation. @Test void createDraft_sourcesDepositPercentFromQuotation_ignoringCallerSuppliedValue() { stubTicket(10L, TicketStatus.QUOTATION_ISSUED, "CUSTOMER_CONFIRMED"); - when(quotationRepo.findByTicket(10L)).thenReturn(List.of()); - when(docs.findDealQuotationDepositPercent(10L)).thenReturn(Optional.of(new BigDecimal("0.30"))); + when(docs.findGoverningQuotation(10L)).thenReturn(Optional.of( + new DepositNoticeRepository.GoverningQuotation(5L, false, "QT-5", new BigDecimal("0.30")))); when(docs.createDraft(eq(10L), any(), any())).thenReturn(301L); stubDraft(301L, 10L); @@ -975,8 +958,7 @@ void createDraft_sourcesDepositPercentFromQuotation_ignoringCallerSuppliedValue( @Test void createDraft_fallsBackTo050WhenTicketHasNoQuotationDepositPercent() { stubTicket(10L, TicketStatus.QUOTATION_ISSUED, "CUSTOMER_CONFIRMED"); - when(quotationRepo.findByTicket(10L)).thenReturn(List.of()); - when(docs.findDealQuotationDepositPercent(10L)).thenReturn(Optional.empty()); + when(docs.findGoverningQuotation(10L)).thenReturn(Optional.empty()); when(docs.createDraft(eq(10L), any(), any())).thenReturn(302L); stubDraft(302L, 10L); @@ -989,35 +971,35 @@ void createDraft_fallsBackTo050WhenTicketHasNoQuotationDepositPercent() { } /** - * Proves the final fallback's wiring (2026-09-29 fix): when the legacy ticket-item path AND - * the CustomerQuotation {@code pickQuotation} path both yield nothing (no approved_price on - * any ticket item, and {@code quotationRepo.findByTicket} returns an empty list — no {@code - * origin IS NULL} quotation at all), {@code buildItemsFromRequest} must return EXACTLY what - * {@code docs.findDealQuotationItemsForDeposit(ticketId)} returns, not an empty list (the old - * {@code return List.of();} this branch replaced). The sentinel item below ("V2 deal quotation - * item", unit price 999) is deliberately unlike anything else in this file so a test bug that - * accidentally sourced items from elsewhere would show up as the wrong description/price - * rather than merely "some non-empty list". + * Lines, percent and reference all come from the ONE governing quotation (owner ruling + * 2026-10-23), here a new-engine one: its lines via {@code docs.findDealQuotationItems}, its + * percent, and its number as the reference. The sentinel item ("V2 deal quotation item", unit + * price 999) and the 0.40 are deliberately unlike anything else in this file, so a draft that + * took its lines or percent from elsewhere shows up as the wrong value, not merely "some value". */ @Test - void createDraft_fallsBackToDealQuotationRepositoryWhenLegacyAndPickQuotationBothYieldNothing() { + void createDraft_takesLinesPercentAndReferenceFromTheGoverningNewEngineQuotation() { stubTicket(10L, TicketStatus.QUOTATION_ISSUED, "CUSTOMER_CONFIRMED"); // items: List.of() — no approved_price - when(quotationRepo.findByTicket(10L)).thenReturn(List.of()); // pickQuotation -> null + when(docs.findGoverningQuotation(10L)).thenReturn(Optional.of( + new DepositNoticeRepository.GoverningQuotation(7L, false, "QT-7", new BigDecimal("0.40")))); DepositNoticeItemRequest sentinel = new DepositNoticeItemRequest( 1, "V2 deal quotation item", new BigDecimal("3"), "แผ่น", new BigDecimal("999"), null, new BigDecimal("999")); - when(docs.findDealQuotationItemsForDeposit(10L)).thenReturn(List.of(sentinel)); + when(docs.findDealQuotationItems(7L)).thenReturn(List.of(sentinel)); when(docs.createDraft(eq(10L), any(), any())).thenReturn(206L); stubDraft(206L, 10L); service.createDraft(10L, new DepositNoticeDraftRequest(null, null, null, null, null, null, null, null), owner); + ArgumentCaptor captor = ArgumentCaptor.forClass(DepositNoticeDraftRequest.class); @SuppressWarnings("unchecked") ArgumentCaptor> itemsCaptor = ArgumentCaptor.forClass(List.class); - verify(docs).createDraft(eq(10L), any(), itemsCaptor.capture()); + verify(docs).createDraft(eq(10L), captor.capture(), itemsCaptor.capture()); assertThat(itemsCaptor.getValue()).containsExactly(sentinel); - verify(docs).findDealQuotationItemsForDeposit(10L); + assertThat(captor.getValue().depositPercent()).isEqualByComparingTo("0.40"); + assertThat(captor.getValue().reference()).isEqualTo("QT-7"); + verify(quotationRepo, never()).findById(anyLong()); } @Test @@ -1133,9 +1115,9 @@ private void stubTicketWithItems(long ticketId, String status, String paymentSta .thenReturn(Optional.of(new TicketDto(summary, items, List.of(), null, List.of()))); } - // Minimal CustomerQuotationDto fixture — only the fields buildItemsFromRequest's pickQuotation - // and itemsFromQuotation actually read (ticketId, docStatus, quotationRevisionNo, items) carry - // meaningful values; everything else is a harmless placeholder. + // Minimal CustomerQuotationDto fixture — only the fields the service actually reads (ticketId, + // docStatus, quotationRevisionNo, items) carry meaningful values; everything else is a + // harmless placeholder. private CustomerQuotationDto quotation(long id, long ticketId, String docStatus, int revisionNo, List items) { return new CustomerQuotationDto( diff --git a/backend/src/test/java/th/co/glr/hr/ticket/TicketRepositoryIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/ticket/TicketRepositoryIntegrationTest.java index 9f72413b1..95ca5edb2 100644 --- a/backend/src/test/java/th/co/glr/hr/ticket/TicketRepositoryIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/ticket/TicketRepositoryIntegrationTest.java @@ -694,8 +694,8 @@ void addEventWithDocument_persistsTheLinkAndRejectsAHalfSetOne() { } // ── B5 fix: findPricingChainFallbackItems — the read-only display fallback for a deal - // whose sales.ticket_item is still empty (pre-order-confirmation), mirroring - // DepositNoticeRepository#findDealQuotationItemsForDeposit's live-quotation pick. + // whose sales.ticket_item is still empty (pre-order-confirmation), picking the deal's + // live new-engine quotation. // Real Postgres only: the priority CASE/ORDER BY and the origin/doc_status filters // cannot be proven against a mocked repository. ───────────────────────────────────── @@ -732,8 +732,7 @@ void findPricingChainFallbackItems_prefersAcceptedOverIssued_acrossOrigins() { insertQuotationWithItem(ticketId, "PRICING_REQUEST", "ISSUED", "Stale", "Issued", null, null, null, new BigDecimal("1")); // ...and a newer ACCEPTED DEAL_DIRECT-origin one. ACCEPTED must win regardless of - // which origin table it came from or insertion order (mirrors - // findDealQuotationItemsForDeposit's own CASE doc_status priority). + // which origin it came from or insertion order (the method's own CASE doc_status priority). insertQuotationWithItem(ticketId, "DEAL_DIRECT", "ACCEPTED", "Live", "Accepted", null, null, null, new BigDecimal("2")); diff --git a/frontend/src/api/mockApi.depositNotices.test.js b/frontend/src/api/mockApi.depositNotices.test.js index 9e497e2a7..16f5c6336 100644 --- a/frontend/src/api/mockApi.depositNotices.test.js +++ b/frontend/src/api/mockApi.depositNotices.test.js @@ -1,5 +1,17 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; import { describe, it, expect } from 'vitest'; -import { api } from './mockApi.js'; +import { + api, MOCK_DEFAULT_DEPOSIT_PERCENT, MOCK_LEGACY_DEPOSIT_PERCENT, retireLivePricingRequestsForTests, +} from './mockApi.js'; +import { stripComments } from './apiSurface.js'; + +// Copy of the canonical ONE_ITEM in mockApi.dealQuotations.test.js. +const ONE_ITEM = { + model: 'Trilogy', color: 'Ash', texture: 'Matt', sizeText: '60x60', thicknessMm: 10, sqmPerPiece: 0.36, + quantityMode: 'AREA', areaSqm: 20, wastageMode: 'NONE', wastageValue: 0, piecesPerBox: 3, unitPrice: 850, discountPct: 0, + leadTimeMinDays: 30, leadTimeMaxDays: 45, +}; // Branch fix (deposit-notice autofill): drives the mock's own pricing-request chain end to end // — Step 1 create through Step 5 customer-accept, then confirmOrder — against the REAL mock @@ -184,10 +196,13 @@ describe('mockApi.depositNotices.createDraft autofill (branch fix: deposit-notic expect(detail.items.every((it) => it.approvedPrice == null)).toBe(true); expect(detail.summary.status).toBe('quotation_issued'); + // A typed percent is ignored: the legacy quotation's PDF prints 50%, so the draft bills 50%. const { depositNotice } = await api.depositNotices.createDraft(ticketId, { notes: [], - depositPercent: 0.5, + depositPercent: 0.9, }); + expect(depositNotice.depositPercent).toBe(0.5); + expect(depositNotice.reference).toBe(quotation.number); // Items: sourced from the ACCEPTED quotation, not the (all-null) ticket_item rows. expect(depositNotice.items).toHaveLength(1); @@ -209,9 +224,11 @@ describe('mockApi.depositNotices.createDraft autofill (branch fix: deposit-notic // delegates to — this endpoint itself never passes a customer/project explicitly. const prId = (await api.pricingRequests.listForTicket(quotation.ticketId)).items .find((pr) => pr.status === 'QUOTATION_ACCEPTED').id; + // A typed percent is ignored here too: the legacy branch bills its PDF's 50%. const { depositNotice } = await api.pricingRequests.createDepositNoticeFromQuotation(prId, { - depositPercent: 0.5, + depositPercent: 0.9, }); + expect(depositNotice.depositPercent).toBe(0.5); expect(depositNotice.customerTaxId).toBe(customer.taxId); expect(depositNotice.customerAddress).toBe(`${customer.address} ${customer.branch}`); @@ -222,12 +239,22 @@ 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 }); + // 40, unlike the 30 default, the legacy 50 and the no-quotation 0.5, so only the quotation's own percent fits. + const { ticketId, prId, accepted } = await driveTicketToNewEngineAcceptedQuotation({ depositPercent: 40 }); 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(accepted.depositPercent).toBe(40); + expect(depositNotice.depositPercent).toBe(0.4); + // The ticket route bills the same quotation (DepositNoticeRepository.findGoverningQuotation). + const { depositNotice: fromTicket } = await api.depositNotices.createDraft(ticketId, {}); + expect(fromTicket.depositPercent).toBe(0.4); + expect(fromTicket.reference).toBe(accepted.number); + // The caller supplies the lines (even this quotation's own), so the reference is null, as in + // Java; the percent still comes from the quotation (DepositNoticeService, linesFromGoverning). + const { depositNotice: ownLines } = await api.depositNotices.createDraft(ticketId, { items: fromTicket.items }); + expect(ownLines.reference).toBeNull(); + expect(ownLines.depositPercent).toBe(0.4); expect(depositNotice.items.length).toBeGreaterThan(0); // qty is deliberately not distinguishable here: a mock TILE row sets quantity = piecesFinal. const [line] = accepted.items; @@ -241,12 +268,17 @@ describe('mockApi.pricingRequests.createDepositNoticeFromQuotation — new-engin 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(); + // Covers the ticket-level route's governing quotation (DepositNoticeRepository.findGoverningQuotation): + // with no items and no legacy quotation, it bills the new-engine quotation's lines, percent and number. + it('ticket-level createDraft with no items drafts the same lines, percent and reference as the pricing-request route', async () => { + const { ticketId, prId, accepted } = await driveTicketToNewEngineAcceptedQuotation(); + // The PDF prints a NULL percent as DealQuotationRenderAdapter.DEFAULT_DEPOSIT_PERCENT (30). + expect(accepted.depositPercent).toBeNull(); const { depositNotice: fromTicket } = await api.depositNotices.createDraft(ticketId, {}); const { depositNotice: fromPr } = await api.pricingRequests.createDepositNoticeFromQuotation(prId, {}); + expect(fromPr.depositPercent).toBe(0.3); + expect(fromTicket.depositPercent).toBe(0.3); + expect(fromTicket.reference).toBe(fromPr.reference); expect(fromTicket.items.length).toBeGreaterThan(0); const pick = (items) => items.map(({ qty, netUnitPrice }) => ({ qty, netUnitPrice })); expect(pick(fromTicket.items)).toEqual(pick(fromPr.items)); @@ -278,18 +310,40 @@ describe('mockApi.pricingRequests.createDepositNoticeFromQuotation — new-engin 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 () => { + // A zero-deposit ("ไม่รับมัดจำ", full payment) quotation has nothing to bill: Java refuses on both + // routes 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 on both routes 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 refusal = { status: 409, message: expect.stringContaining(`ใบเสนอราคา ${accepted.number} ระบุไม่มีมัดจำ`) }; + await expect(api.pricingRequests.createDepositNoticeFromQuotation(prId, {})).rejects.toMatchObject(refusal); + await expect(api.depositNotices.createDraft(ticketId, {})).rejects.toMatchObject(refusal); const { depositNotices } = await api.depositNotices.listByTicket(ticketId); expect(depositNotices).toHaveLength(0); }); + + // DepositNoticeRepositoryDepositPercentIntegrationTest#mixedTicket_aDealDirectApprovedQuotationBeatsAnOlderAcceptedOne + // on the mock: two answers in two mock arrays, so only "newest id" across both arrays can pick. + // Cancelling the pricing request stages the pre-lock data that lets a direct quotation sit beside it. + it('ticket-level createDraft bills a DEAL_DIRECT approval made after the accepted legacy quotation', async () => { + const { ticketId } = await driveTicketToAcceptedQuotation(); + retireLivePricingRequestsForTests(ticketId); + const { quotation: draft } = await api.dealQuotations.create(ticketId, { + recipientType: 'DESIGNER', depositPercent: 40, remainderMode: 'ON_DELIVERY', + items: [ONE_ITEM], + }); + await api.dealQuotations.submit(draft.id, {}); + await api.auth.login({ role: 'sales_manager' }); + const { quotation: approved } = await api.dealQuotations.approve(draft.id, {}); + await api.auth.login({ role: 'sales' }); + expect(approved.docStatus).toBe('APPROVED'); + + const { depositNotice } = await api.depositNotices.createDraft(ticketId, {}); + expect(depositNotice.reference).toBe(approved.number); + expect(depositNotice.depositPercent).toBe(0.4); + }); }); // Issue #752: the mock must reproduce production's one-render gap. Per @@ -386,3 +440,33 @@ describe('mockApi.depositNotices.issue account notification (GLA-32)', () => { expect(own.message).toContain('ไม่ระบุลูกค้า'); }); }); + +// The mock's blank-percent defaults restate two Java constants; read them out of the source so the +// copies cannot drift (the dealTrackingMeta.test.js approach). Throws rather than skips when the +// files move, so the guard cannot quietly disable itself; comments are stripped first, so a +// commented-out declaration does not count. +function readBackendSource(relativePath) { + for (let dir = process.cwd(), depth = 0; depth < 8; depth += 1, dir = dirname(dir)) { + const candidate = resolve(dir, relativePath); + if (existsSync(candidate)) return readFileSync(candidate, 'utf8'); + } + throw new Error(`could not find ${relativePath} above ${process.cwd()}`); +} + +function javaIntConstant(relativePath, name) { + const match = stripComments(readBackendSource(relativePath)).match(new RegExp(`static final int ${name} = (\\d+);`)); + if (!match) throw new Error(`could not find int ${name} in ${relativePath}`); + return Number(match[1]); +} + +describe('mock deposit-percent defaults match the Java PDFs', () => { + it('a blank new-engine percent is DealQuotationRenderAdapter.DEFAULT_DEPOSIT_PERCENT', () => { + expect(MOCK_DEFAULT_DEPOSIT_PERCENT).toBe(javaIntConstant( + 'backend/src/main/java/th/co/glr/hr/dealquotation/DealQuotationRenderAdapter.java', 'DEFAULT_DEPOSIT_PERCENT')); + }); + + it('a legacy quotation is QuotationRenderer.LEGACY_DEPOSIT_PERCENT', () => { + expect(MOCK_LEGACY_DEPOSIT_PERCENT).toBe(javaIntConstant( + 'backend/src/main/java/th/co/glr/hr/ticket/QuotationRenderer.java', 'LEGACY_DEPOSIT_PERCENT')); + }); +}); diff --git a/frontend/src/api/mockApi.js b/frontend/src/api/mockApi.js index 6e76f3ec5..e9f1f358c 100644 --- a/frontend/src/api/mockApi.js +++ b/frontend/src/api/mockApi.js @@ -1177,7 +1177,9 @@ let mockPricingDecisionItemSeq = 1; // Step 4 quotation is keyed by pricingRequestId first — the legacy ticket-item-driven // `ticket.quotations` array is untouched by this section. const mockCustomerQuotations = []; -let mockCustomerQuotationSeq = 1; +// sales.quotation_id: one id sequence for mockCustomerQuotations AND mockDealQuotations, as Java +// has one table, so "latest" (highest id) compares across both arrays. +let mockQuotationSeq = 1; let mockCustomerQuotationItemSeq = 1; // CEO discount-approval workflow, Phase 2 (owner ruling 2026-08-16) — mirrors // sales.quotation_item_discount_approval (V155). Starts empty every session like every other @@ -1214,7 +1216,7 @@ let mockDiscountApprovalSeq = 1; mockPricingDecisionSeq = salesSeed.nextSeq.pricingDecision; mockPricingDecisionItemSeq = salesSeed.nextSeq.pricingDecisionItem; mockCustomerQuotations.push(...salesSeed.customerQuotations); - mockCustomerQuotationSeq = salesSeed.nextSeq.customerQuotation; + mockQuotationSeq = salesSeed.nextSeq.customerQuotation; mockCustomerQuotationItemSeq = salesSeed.nextSeq.customerQuotationItem; // Deal-tracking readiness gate (dealIsReadyToAdvance) needs `ticket.nextFollowUpAt` set — // not part of the ticket's own literal object in demoSales.js since it's normally written @@ -1455,22 +1457,30 @@ function mockDepositNoticeItemsFromDealQuotation(row) { }); } -// 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 -// contract as CustomerQuotationRepository.findByTicket — then a single forward scan overwriting -// "latest seen" per status is equivalent to sorting descending and taking the first match. -function mockPickTicketQuotationForDepositNotice(ticketId) { - const candidates = mockCustomerQuotations - .filter((q) => q.ticketId === Number(ticketId) && q.pricingRequestId != null) - .sort((a, b) => (a.quotationRevisionNo - b.quotationRevisionNo) || (a.id - b.id)); - let latestAccepted = null; - let latestIssued = null; - for (const q of candidates) { - if (q.docStatus === 'ACCEPTED') latestAccepted = q; - else if (q.docStatus === 'ISSUED') latestIssued = q; +// Mirrors DepositNoticeService.zeroDepositRefusal: both deposit routes refuse a 0% quotation. +const mockZeroDepositRefusal = (number) => + `ใบเสนอราคา ${number} ระบุไม่มีมัดจำ (ชำระเต็มจำนวน) จึงออกใบแจ้งยอดเงินรับมัดจำไม่ได้`; + +// Mirrors DepositNoticeRepository#findGoverningQuotation: the quotation a ticket-level deposit +// notice bills (lines, percent, reference), across both engines. The latest one the customer +// answered (ACCEPTED, or a DEAL_DIRECT one APPROVED) wins, else the latest ISSUED one. "Latest" +// is the highest id, which mockQuotationSeq makes comparable across the two arrays. +// ponytail: the two literal mockDealQuotations seeds (ids 1-2, ticket 18) repeat legacy seed ids +// (ticket 5); no pick compares rows of different tickets, so renumber only if they ever share one. +function mockGoverningQuotationForDepositNotice(ticketId) { + const candidates = []; + for (const q of mockCustomerQuotations) { + if (q.ticketId !== ticketId || q.pricingRequestId == null) continue; + const rank = { ACCEPTED: 0, ISSUED: 1 }[q.docStatus]; + if (rank != null) candidates.push({ rank, quotation: q, legacy: true }); + } + for (const q of mockDealQuotations) { + if (q.ticketId !== ticketId) continue; + const rank = isDealDirectOrigin(q) && q.docStatus === 'APPROVED' ? 0 : { ACCEPTED: 0, ISSUED: 1 }[q.docStatus]; + if (rank != null) candidates.push({ rank, quotation: q, legacy: false }); } - return latestAccepted ?? latestIssued ?? null; + candidates.sort((a, b) => (a.rank - b.rank) || (b.quotation.id - a.quotation.id)); + return candidates[0] ?? null; } // B5 fix ("รายการสินค้า shows 0 รายการ while a deal is still in the pricing/quotation phase"): @@ -1498,7 +1508,7 @@ function mockPickLiveQuotationForItemFallback(ticketId) { } if (candidates.length === 0) return null; // ACCEPTED beats ISSUED beats APPROVED (rank ascending); most recent id wins a tie -- same - // priority findDealQuotationItemsForDeposit's CASE doc_status / ORDER BY quotation_id DESC + // priority findPricingChainFallbackItems's CASE doc_status / ORDER BY quotation_id DESC // encodes server-side. candidates.sort((a, b) => (a.rank - b.rank) || (b.id - a.id)); return candidates[0]; @@ -1680,7 +1690,7 @@ function reconcileTicketItemsFromPricingRequest(ticket, pr, user) { function buildMockCustomerQuotationDraft(pr, decision, ticket, user, payload, parentQuotationId, revisionNo, priorDiscounts, number) { const customer = ticket?.customerId ? mockCustomers.find((c) => c.id === ticket.customerId) : null; const project = ticket?.projectId ? mockProjects.find((p) => p.id === ticket.projectId) : null; - const id = mockCustomerQuotationSeq++; + const id = mockQuotationSeq++; const items = decision.items .filter((di) => di.approvedSellingPricePerRequestedUnit != null) .map((di, idx) => { @@ -6379,19 +6389,19 @@ function nextMockDealQuotationNumber() { } // The pricing-request-chain (customerquotation/) flow's own number-space counter -- kept SEPARATE -// from mockCustomerQuotationSeq (the id sequence) for the same reason mockDealQuotationNumberSeq -// is kept separate from mockDealQuotationSeq above: a `number` and an `id` are different concerns +// from mockQuotationSeq (the id sequence) for the same reason mockDealQuotationNumberSeq +// is: a `number` and an `id` are different concerns // even though the real backend's sales.quotation_code_seq happens to be a global sequence shared // by BOTH flows' numbers (a fidelity gap this mock already had before today, pre-existing and out // of scope here -- the two flows' *own* number counters were already independent of one another). -// Seeded from mockCustomerQuotationSeq's OWN already-final value (the state-matrix seed block +// Seeded from mockQuotationSeq's already-final value (the state-matrix seed block // above, top-level code that has already run by the time this line executes, set it to one past // every demoSales.js-seeded row's id) -- every seeded row's bare number is literally // `QT-2026-{id}` (see demoSales.js#makeQuotation), so starting from the same point can never // collide with one of them. Mirrors `mockDealQuotationNumberSeq = 3` right after its own two-row // literal seed, just computed instead of hardcoded since this flow's seed count varies with // demoSales.js rather than being two fixed literal rows. -let mockCustomerQuotationNumberSeq = mockCustomerQuotationSeq; +let mockCustomerQuotationNumberSeq = mockQuotationSeq; function nextMockCustomerQuotationNumber() { return `QT-${new Date().getFullYear()}-${String(mockCustomerQuotationNumberSeq++).padStart(4, '0')}`; } @@ -6551,7 +6561,7 @@ function mintDealQuotationRevision(parent, user) { const revisionNo = nextMockDealQuotationRevisionNo(parent.ticketId, base); return { ...structuredClone(parent), - id: mockDealQuotationSeq++, + id: mockQuotationSeq++, number: quotationRevisionNumber(base, revisionNo), docStatus: 'DRAFT', revisionNo, @@ -6593,7 +6603,7 @@ function mintDealQuotationReorder(source, user) { const revisionNo = nextMockDealQuotationRevisionNo(source.ticketId, base); return { ...structuredClone(source), - id: mockDealQuotationSeq++, + id: mockQuotationSeq++, number: quotationRevisionNumber(base, revisionNo), docStatus: 'DRAFT', revisionNo, @@ -7153,6 +7163,13 @@ function resolveDealQuotationV3Header(payload, current = null) { return { priceMode, documentLanguage, currency }; } +// Mirrors DealQuotationRenderAdapter.DEFAULT_DEPOSIT_PERCENT: the percent a quotation's PDF +// prints, and its deposit notice bills, when its depositPercent is null. +export const MOCK_DEFAULT_DEPOSIT_PERCENT = 30; +// Mirrors QuotationRenderer.LEGACY_DEPOSIT_PERCENT: a legacy quotation's PDF always prints 50%, +// so its deposit notice bills 50% whatever percent was typed. +export const MOCK_LEGACY_DEPOSIT_PERCENT = 50; + /** * Item 4 (V181, "ไม่รับมัดจำ", owner ruling 2026-09-16) — mirrors * DealQuotationService#isZeroDeposit/#resolveFullPaymentTerm exactly: `depositPercent === 0` is @@ -7508,7 +7525,6 @@ function mockDealQuotationDocLines(row) { // ticket with 4 catalog-flavoured items already seeded in demoSales.js) -- one DRAFT, one // PENDING_APPROVAL, per the implementation brief. let mockDealQuotationItemSeq = 1; -let mockDealQuotationSeq = 3; const mockDealQuotations = [ { id: 1, @@ -7612,6 +7628,8 @@ const mockDealQuotations = [ }, ]; mockDealQuotationNumberSeq = 3; +// One id sequence across both quotation arrays: start past the highest literal seed id above. +mockQuotationSeq = Math.max(mockQuotationSeq, Math.max(...mockDealQuotations.map((q) => q.id)) + 1); // §5 WARN_UNPAID_* gates (V164 follow-up, 2026-09-09): `ruleWarnings`/`unpaidByRuleDays` on both @@ -13281,23 +13299,26 @@ export const api = { const desc = [it.brand, it.model, it.color, it.texture, it.size].filter(Boolean).join(' '); return { seq: idx + 1, description: desc, qty: Number(it.qty), unit: 'แผ่น', unitPrice: Number(it.approvedPrice), discountLabel: null, netUnitPrice: Number(it.approvedPrice) }; }); - // Branch fix (deposit-notice autofill): every deal created through the pricing-request - // chain has approved_price = NULL on every ticket_item, so legacyItems above is always - // empty for it — mirrors DepositNoticeService.buildItemsFromRequest's own new-chain - // fallback to the ticket's own customer quotation (latest ACCEPTED, else latest ISSUED). + // Every deal created through the pricing-request chain has approved_price = NULL on every + // ticket_item, so legacyItems above is empty for it. The governing quotation always supplies + // the percent, and the lines and reference unless the payload or approved prices supply the + // lines (DepositNoticeService.createDraftFromQuotation). + const governing = mockGoverningQuotationForDepositNotice(Number(ticketId)); + if (!governing?.legacy && governing?.quotation.depositPercent === 0) { + fail(mockZeroDepositRefusal(governing.quotation.number), 409); + } let items = payload.items?.length ? payload.items : legacyItems; - if (!items.length) { - const quotation = mockPickTicketQuotationForDepositNotice(ticketId); - if (quotation) items = mockDepositNoticeItemsFromQuotation(quotation.items); + const linesFromGoverning = !items.length && governing != null; + if (linesFromGoverning) { + items = governing.legacy + ? mockDepositNoticeItemsFromQuotation(governing.quotation.items) + : mockDepositNoticeItemsFromDealQuotation(governing.quotation); } - 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); - } + // A typed percent is ignored, as in Java; 0.5 only when the ticket has no live quotation. + let depositPercent = 0.5; + if (governing) { + depositPercent = (governing.legacy ? MOCK_LEGACY_DEPOSIT_PERCENT + : governing.quotation.depositPercent ?? MOCK_DEFAULT_DEPOSIT_PERCENT) / 100; } // Branch fix: header autofill (mirrors DepositNoticeService.createDraft) — a caller- @@ -13313,8 +13334,8 @@ export const api = { version: nextVer, docNumber: null, issueDate: null, status: 'DRAFT', customerName: payload.customerName ?? ticket.customerName ?? '', customerTaxId: customerTaxId ?? '', customerAddress: customerAddress ?? '', - projectName: projectName ?? '', reference: payload.reference ?? '', - depositPercent: payload.depositPercent ?? 0.5, vatPercent: 0.07, + projectName: projectName ?? '', reference: payload.reference ?? (linesFromGoverning ? governing.quotation.number : null), + depositPercent, vatPercent: 0.07, notes, items, issuedByName: null, preparerName: 'จินตนา หาญมนตรี', hasPdf: false, hasXlsx: false, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), @@ -16187,7 +16208,7 @@ export const api = { return delay({ result: { ticket: buildTicketDetail(ticket), pricingRequest: buildPricingRequestSummary(pr) } }); }, - async createDepositNoticeFromQuotation(id, payload = {}) { + async createDepositNoticeFromQuotation(id, payload = {}) { // eslint-disable-line no-unused-vars -- hrApi arity; Java ignores the typed percent const user = hasRole('sales'); const pr = findPricingRequestRaw(id); const ticket = db.tickets.find((t) => t.id === pr.ticketId); @@ -16202,9 +16223,7 @@ export const api = { (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); - } + if (newEngine?.depositPercent === 0) fail(mockZeroDepositRefusal(newEngine.number), 409); const reference = (accepted ?? newEngine).number; const items = accepted ? mockDepositNoticeItemsFromQuotation(accepted.items) @@ -16231,11 +16250,11 @@ export const api = { customerName: ticket.customerName ?? '', customerTaxId: customerTaxId ?? '', customerAddress: customerAddress ?? '', 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, + // Mirrors OrderConfirmationService.createDepositNoticeFromQuotation: the percent the chosen + // quotation's PDF printed; a typed payload.depositPercent is ignored. + depositPercent: (newEngine + ? newEngine.depositPercent ?? MOCK_DEFAULT_DEPOSIT_PERCENT + : MOCK_LEGACY_DEPOSIT_PERCENT) / 100, vatPercent: 0.07, notes, items, issuedByName: null, preparerName: 'จินตนา หาญมนตรี', hasPdf: false, hasXlsx: false, @@ -16661,7 +16680,7 @@ export const api = { const createBaseNumber = mockDealBaseNumberOrMint(ticket.id); const createRevisionNo = nextMockDealQuotationRevisionNo(ticket.id, createBaseNumber); const row = { - id: mockDealQuotationSeq++, + id: mockQuotationSeq++, // Owner feedback 2026-09-11: the FIRST issued document now carries the revision suffix // too -- "QT-2026-0014-1", not a bare "QT-2026-0014". Mirrors // DealQuotationService#create's own numbering — which since the 2026-10-17 ruling CONTINUES @@ -16804,7 +16823,7 @@ export const api = { const prBaseNumber = mockDealBaseNumberOrMint(ticket.id); const prRevisionNo = nextMockDealQuotationRevisionNo(ticket.id, prBaseNumber); const row = { - id: mockDealQuotationSeq++, + id: mockQuotationSeq++, number: quotationRevisionNumber(prBaseNumber, prRevisionNo), ticketId: ticket.id, origin: 'PRICING_REQUEST', diff --git a/frontend/src/api/mockApi.pricingRequests.test.js b/frontend/src/api/mockApi.pricingRequests.test.js index 65db781fd..ec6df64e8 100644 --- a/frontend/src/api/mockApi.pricingRequests.test.js +++ b/frontend/src/api/mockApi.pricingRequests.test.js @@ -490,8 +490,8 @@ describe('mockApi.pricingRequests customerQuotation numbering (review finding M1 // Concrete failure this catches (CLAUDE.md's "Mock API contract": the mock is the default // verification surface, so silence here is the dangerous kind): if mockCustomerQuotationNumberSeq's - // top-level `let` (mockApi.js ~L4878, `= mockCustomerQuotationSeq`) ever executed BEFORE the - // top-level seed block (~L1161) advances mockCustomerQuotationSeq past every demoSales.js-seeded + // top-level `let` (mockApi.js, `= mockQuotationSeq`) ever executed BEFORE the + // top-level seed block advances mockQuotationSeq past every demoSales.js-seeded // row's id, the counter would start at 1 and mint a BASE identical to an already-seeded row's // bare number. Every seeded row's number is literally `QT-2026-{id}` (demoSales.js#makeQuotation) // and every mock-minted number always carries a `-{revisionNo}` suffix, so the two FULL strings diff --git a/frontend/src/data/demoSales.js b/frontend/src/data/demoSales.js index 5da2c0e58..cf50b4ed2 100644 --- a/frontend/src/data/demoSales.js +++ b/frontend/src/data/demoSales.js @@ -1218,7 +1218,7 @@ export function buildDemoSalesSeed() { // FactoryPurchaseOrder CANCELLED; DepositNotice DRAFT (pre-drafted off the accepted // quotation while the deposit is awaited — D4 fix: was built from quotation19's // REVISION_REQUESTED doc, which neither createDepositNoticeFromQuotation (mockApi.js's - // hard 409) nor mockPickTicketQuotationForDepositNotice's ACCEPTED->ISSUED fallback would + // hard 409) nor mockGoverningQuotationForDepositNotice's answered->ISSUED fallback would // ever ACTUALLY pick, so that row could not exist in the real app). const pr20 = makePr({ ticketId: 18, recipientType: 'BUYER', recipientLabel: 'ผู้รับเหมา Fashion Island', diff --git a/frontend/src/features/deposits/DepositNoticePage.jsx b/frontend/src/features/deposits/DepositNoticePage.jsx index 0c63a5561..a8d374b24 100644 --- a/frontend/src/features/deposits/DepositNoticePage.jsx +++ b/frontend/src/features/deposits/DepositNoticePage.jsx @@ -823,12 +823,13 @@ export function DepositNoticePage({ ticketId, onBack, onNavigateTickets, showToa className="px-4.5 py-3.5" > {/* - Owner ruling (2026-09-29): the deposit % is LOCKED to the source quotation's own - deposit_percent (sales.quotation) and is never editable at the deposit-notice - stage — createDraft/update on the backend both ignore any client-supplied value - and always re-source it from the quotation. The 30/50/70/100% preset buttons this - used to be are gone; this is now a plain read-only label, shown regardless of - isIssued since there is no draft-only editable state left to distinguish. + Owner rulings (2026-09-29, 2026-10-23): the deposit % is what the source + quotation's PDF printed (legacy 50%, new-engine its own, blank 30%; 50% with no + live quotation) and is never editable at the deposit-notice stage: createDraft + never reads a client-supplied value and update keeps the draft's stored one. The + 30/50/70/100% preset buttons this used to be are gone; this is now a plain + read-only label, shown regardless of isIssued since there is no draft-only + editable state left to distinguish. */}

% มัดจำ diff --git a/frontend/src/features/pricingRequests/PricingRequestDetailPage.jsx b/frontend/src/features/pricingRequests/PricingRequestDetailPage.jsx index e04bb8076..bba0382d7 100644 --- a/frontend/src/features/pricingRequests/PricingRequestDetailPage.jsx +++ b/frontend/src/features/pricingRequests/PricingRequestDetailPage.jsx @@ -1338,7 +1338,6 @@ export function PricingRequestDetailPage({ user, showToast }) { const [outcomeNote, setOutcomeNote] = useState(''); // Step 6: Deposit, Payment, and Order Confirmation. const [confirmOrderClientRequestId, setConfirmOrderClientRequestId] = useState(() => generateClientRequestId()); - const [depositPercentInput, setDepositPercentInput] = useState('0.5'); const startCeoReview = useActionMutation( () => api.pricingRequests.startPricingDecision(pricingRequestId, { defaultMarginPct: cleanNumber(resolvedDefaultMargin), @@ -1633,9 +1632,7 @@ export function PricingRequestDetailPage({ user, showToast }) { onError: (error) => showToast?.('error', error.message || 'ดำเนินการไม่สำเร็จ'), }); const createDepositNoticeFromQuotation = useMutation({ - mutationFn: () => api.pricingRequests.createDepositNoticeFromQuotation(pricingRequestId, { - depositPercent: cleanNumber(depositPercentInput), - }), + mutationFn: () => api.pricingRequests.createDepositNoticeFromQuotation(pricingRequestId, {}), onSuccess: () => { showToast?.('success', 'สร้างร่างใบแจ้งยอดเงินรับมัดจำแล้ว'); // Reuse the existing (legacy) deposit-notice page as-is — it already loads/edits/issues a @@ -4299,11 +4296,6 @@ export function PricingRequestDetailPage({ user, showToast }) {

ยืนยันคำสั่งซื้อแล้ว — สร้างใบแจ้งยอดเงินรับมัดจำจากใบเสนอราคาที่ลูกค้ายอมรับ (แก้ไข/ออกเอกสารในหน้าใบแจ้งยอดเงินรับมัดจำ)

-