Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
8d068d0
test(sales): pin that a null-percent quotation's deposit notice bills…
waritctd Oct 22, 2026
32f4e87
fix(sales): blank deposit percent on a new-engine quotation bills the…
waritctd Oct 22, 2026
6b5a697
test(sales): pin that a live legacy quotation bills its PDF's 50%, no…
waritctd Oct 22, 2026
a33a80e
fix(sales): a live legacy quotation bills the 50% its PDF prints, nev…
waritctd Oct 22, 2026
c8807ee
docs(sales): firstOrNull Javadoc no longer describes a null the queri…
waritctd Oct 22, 2026
0762fd2
test(sales): fold the null-percent deposit cases into the existing ro…
waritctd Oct 23, 2026
16ca96f
test(sales): pin that the ticket-route deposit notice follows the lat…
waritctd Oct 23, 2026
c0d840c
fix(sales): the deposit notice follows the latest quotation the custo…
waritctd Oct 23, 2026
b26f09a
test(sales): pin that a ticket-level legacy quotation never governs t…
waritctd Oct 23, 2026
3fb6385
docs(sales): name the ACCEPTED-then-DEAL_DIRECT case where the deal p…
waritctd Oct 23, 2026
2b167d6
test(sales): a 0% governing quotation is refused on the ticket route too
waritctd Oct 23, 2026
f56d4a0
fix(sales): refuse a 0% governing quotation on the ticket-route depos…
waritctd Oct 23, 2026
4e8187c
test(sales): approved_price lines do not take the governing quotation…
waritctd Oct 23, 2026
22d4488
fix(sales): default the deposit reference only when the lines came fr…
waritctd Oct 23, 2026
8691a87
test(sales): governing-quotation fixtures that only the ranking, not …
waritctd Oct 23, 2026
bd6255c
test(sales): mock deposit fixture at 40% and a ticket-route set-perce…
waritctd Oct 23, 2026
ef56053
test(sales): the PR page's deposit button shows no "% มัดจำ" input an…
waritctd Oct 23, 2026
e8dd542
fix(sales): delete the dead "% มัดจำ" input on the pricing-request page
waritctd Oct 23, 2026
9a586ee
docs(sales): deposit-percent comments state the rule the code follows
waritctd Oct 23, 2026
be22496
test(sales): pin that caller-supplied lines leave the mock deposit re…
waritctd Oct 27, 2026
3bff49a
test(sales): mock deposit tie-break across both quotation arrays (RED)
waritctd Oct 27, 2026
7a31851
fix(sales): one mock quotation id sequence, so the deposit pick's "la…
waritctd Oct 27, 2026
c2fc558
test(sales): the Java deposit-constant guard ignores commented-out de…
waritctd Oct 27, 2026
e1bc8ab
test(sales): a PRICING_REQUEST quotation is never live as APPROVED in…
waritctd Oct 27, 2026
01ced6b
fix(sales): APPROVED counts as live only on a DEAL_DIRECT quotation i…
waritctd Oct 27, 2026
6becc08
docs(sales): correct three deposit comments and move a misplaced one
waritctd Oct 27, 2026
49f1762
test(sales): delete deposit tests the repository and parameterised te…
waritctd Oct 27, 2026
eb15c14
docs(sales): name the shared mock quotation id counter in a pricing-r…
waritctd Oct 27, 2026
91b43d1
docs(sales): the autofill test Javadoc no longer claims the repositor…
waritctd Oct 27, 2026
2280d34
test(sales): pin every non-live (origin, status) pair and newer-ACCEP…
waritctd Oct 27, 2026
407e9b1
docs(sales): move the DEAL_DIRECT-revision case to findGoverningQuota…
waritctd Oct 27, 2026
97cd8d1
fix(sales): mock deposit reference is null when the caller supplies t…
waritctd Oct 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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<String> 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) + " วัน"
: "ขอรับก่อนส่งมอบสินค้าหรือเมื่อส่งมอบสินค้า";
Expand All @@ -652,7 +657,7 @@ private static List<String> nonTileRemarkLines(DealQuotationDto quotation) {

private static List<String> 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) + " วัน"
: "ขอรับก่อนส่งมอบสินค้าหรือเมื่อส่งมอบสินค้า";
Expand Down Expand Up @@ -902,7 +907,7 @@ private static String fullPaymentTermEnglishText(String code) {

private static List<String> englishRemarkLines(DealQuotationDto quotation, List<String> 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())) {
Expand Down Expand Up @@ -1029,7 +1034,7 @@ private static String englishNonTileLeadTimeLine(List<DealQuotationItemDto> item

/** The English twin of {@link #nonTileRemarkLines}. */
private static List<String> englishNonTileRemarkLines(DealQuotationDto quotation, List<String> 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())) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}).
*
* <p>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
Expand Down
166 changes: 55 additions & 111 deletions backend/src/main/java/th/co/glr/hr/deposit/DepositNoticeRepository.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -89,74 +91,63 @@ public List<DepositNoticeDto> findByTicket(long ticketId) {
);
}

/** The quotation a ticket-level deposit notice bills: always its percent (a fraction,
* 30 -&gt; 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 -&gt; 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.
*
* <p>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.
*
* <p>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).
*
* <p>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:
* <ul>
* <li>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.</li>
* <li>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.</li>
* </ul>
* 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.
* <p>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.
*
* <p>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<BigDecimal> 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<GoverningQuotation> 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> T firstOrNull(List<T> 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
Expand Down Expand Up @@ -370,53 +361,6 @@ private void insertItems(long docId, List<DepositNoticeItemRequest> 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.
*
* <p><b>Selection covers BOTH V2 origins, whose live-terminal doc_status differs</b> — the two
* never share a {@code ticket_id} (see {@code DealQuotationRepository#nextRevisionNo}), so the
* union below is unambiguous per ticket:
* <ul>
* <li>{@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.</li>
* <li>{@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.</li>
* </ul>
* SUPERSEDED / CANCELLED / REJECTED / EXPIRED / DRAFT / PENDING_APPROVAL / REVISION_REQUESTED
* rows are never chosen. Preference ACCEPTED &gt; ISSUED &gt; APPROVED, then newest by id.
* <b>Owner-confirmable:</b> 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
* <em>autofill</em> for a draft the user reviews before saving, not the deposit gate itself.
* The lines themselves come from {@link #findDealQuotationItems}.
*/
public List<DepositNoticeItemRequest> findDealQuotationItemsForDeposit(long ticketId) {
List<Long> 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
Expand Down
Loading
Loading