Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
f4fc310
test(sales): pin the มัดจำ draft from a new-engine pricing-request qu…
waritctd Oct 19, 2026
d247e6a
fix(sales): มัดจำ draft from the pricing-request route accepts a new-…
waritctd Oct 19, 2026
57498fa
test(sales): pin the pricing-request มัดจำ draft to THIS pricing requ…
waritctd Oct 19, 2026
0d2548e
fix(sales): pricing-request มัดจำ draft reads the accepted quotation …
waritctd Oct 19, 2026
18aeabe
test(mock): pin the pricing-request มัดจำ draft from a new-engine ACC…
waritctd Oct 19, 2026
40d10c7
chore(mock): pricing-request deposit route and ticket draft fallback …
waritctd Oct 19, 2026
ff7b555
chore(mock): deposit mapper mirrors findDealQuotationItems' own colum…
waritctd Oct 19, 2026
9dcc789
test(mock): pin the deposit mapper's column picks with a discounted l…
waritctd Oct 19, 2026
daa5628
refactor(sales): correct two comments the deposit fix made false; tri…
waritctd Oct 19, 2026
39b826b
refactor(mock): trim the deposit mirror comments to pointers; drop th…
waritctd Oct 19, 2026
a32b02b
docs(plan): deposit notice from a new-engine pricing-request quotatio…
waritctd Oct 20, 2026
e927410
test(sales): pin the pricing-request มัดจำ draft's percent to THIS pr…
waritctd Oct 20, 2026
1c76cfb
test(mock): pin the pricing-request มัดจำ draft's percent to the acce…
waritctd Oct 20, 2026
316a37a
fix(sales): pricing-request มัดจำ draft bills THIS pricing request's …
waritctd Oct 20, 2026
1d390bf
test(sales): mixed-ticket deposit test asserts its legacy seed applie…
waritctd Oct 20, 2026
b26e95c
Merge remote-tracking branch 'origin/develop' into fix/deposit-notice…
waritctd Oct 22, 2026
12051a5
fix(sales): pricing-request มัดจำ route refuses a zero-deposit new-en…
waritctd Oct 22, 2026
06182e8
chore(docs): drop the committed implementation plan — the PR body is …
waritctd Oct 22, 2026
3f3c3d4
refactor(mock): deposit mapper reads the stored row; trim the new-eng…
waritctd Oct 22, 2026
59f623e
refactor(sales): trim the deposit-route Javadoc to single statements …
waritctd Oct 22, 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 @@ -282,10 +282,12 @@ public Optional<Long> findOpenDraftForPricingRequest(long pricingRequestId) {
* most recent PRICING_REQUEST-origin quotation for this pricing request, REGARDLESS of
* status — used by {@code DealQuotationService#createFromPricingRequest} to continue the SAME
* {@code {base}-{n}} number family when re-creating after an EXPIRED quotation, rather than
* minting an unrelated fresh base number. There is at most one LIVE row at a time (M2/the
* one-quotation-per-request rule) and this origin has no revision chain (D12), so "most
* recent by id" and "the only row" coincide in every reachable case today; ORDER BY id DESC
* is still the honest query to write in case that ever stops being true.
* minting an unrelated fresh base number. This origin has a revision chain: {@code
* DealQuotationService#createRevision} copies {@code origin} and {@code pricing_request_id}
* from an ISSUED or REVISION_REQUESTED source, so one pricing request can hold several rows
* and the newest by id can be an open revision DRAFT. Newest by id is what continuing the
* number family needs; a caller that needs the customer-accepted row uses {@link
* #findAcceptedForPricingRequest} instead.
*/
public Optional<Long> findLatestForPricingRequest(long pricingRequestId) {
try {
Expand All @@ -300,6 +302,27 @@ public Optional<Long> findLatestForPricingRequest(long pricingRequestId) {
}
}

/**
* The customer-ACCEPTED {@code PRICING_REQUEST}-origin quotation for this pricing request,
* newest by id. {@code OrderConfirmationService#createDepositNoticeFromQuotation} uses this
* rather than {@link #findLatestForPricingRequest} because an open revision draft can outrank
* the accepted row by id: {@code DealQuotationService#recordOutcome} has no open-revision
* guard, so the customer can accept Q1 while its revision Q2 is still a DRAFT.
*/
public Optional<Long> findAcceptedForPricingRequest(long pricingRequestId) {
try {
return Optional.ofNullable(jdbc.queryForObject("""
SELECT quotation_id FROM sales.quotation
WHERE pricing_request_id = :pricingRequestId AND origin = 'PRICING_REQUEST'
AND doc_status = 'ACCEPTED'
ORDER BY quotation_id DESC
LIMIT 1
""", Map.of("pricingRequestId", pricingRequestId), Long.class));
} catch (EmptyResultDataAccessException e) {
return Optional.empty();
}
}

/** One accepted line's reconciliation-relevant numbers — {@link #qty} is {@code
* quotation_item.qty} (see {@link #findAcceptedItemQuantitiesByPricingRequest}'s own Javadoc);
* {@link #sqmPerPiece} is {@code quotation_item.sqm_per_piece} (V165), read from the SAME row
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,13 +113,15 @@ public List<DepositNoticeDto> findByTicket(long ticketId) {
* single live quotation, preferring ACCEPTED, then ISSUED, then APPROVED (DEAL_DIRECT's
* own only live terminal state), newest by id.</li>
* </ul>
* A given ticket's quotations are only ever ALL-legacy or ALL-origin-tagged (each engine
* stamps {@code origin} consistently for every row it writes), so at most one branch below
* ever matches — trying legacy first is a fixed, harmless order, not a real preference
* between the two lineages. Returns empty when there is no live (ACCEPTED/ISSUED/APPROVED)
* quotation for this ticket, or its {@code deposit_percent} is null — the caller ({@link
* DepositNoticeService#createDraft}) falls back to 0.50 in that case, this document's
* long-standing default.
* Engines are exclusive per pricing request, not per ticket ({@code
* CustomerQuotationService#create} and {@code DealQuotationService#createFromPricingRequest}
* each refuse a pricing request the other engine already quoted), so one ticket can carry a
* legacy quotation on one pricing request and an origin-tagged one on another. On such a mixed
* ticket both branches below can match, and trying legacy first IS a real preference: the
* percent comes from the legacy row. Returns empty when there is no live
* (ACCEPTED/ISSUED/APPROVED) quotation for this ticket, or its {@code deposit_percent} is
* null — the caller ({@link DepositNoticeService#createDraft}) falls back to 0.50 in that
* case, this document's long-standing default.
*/
public Optional<BigDecimal> findDealQuotationDepositPercent(long ticketId) {
Integer legacyPct = firstOrNull(jdbc.query("""
Expand Down Expand Up @@ -394,11 +396,7 @@ private void insertItems(long docId, List<DepositNoticeItemRequest> items) {
* <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.
*
* <p>Reads the RESOLVED display columns the quotation itself prints
* ({@code qty}/{@code raw_unit}/{@code unit_price}/{@code final_unit_price}), NOT the legacy
* {@code requested_*}/{@code approved_unit_price} columns the V2 chain leaves null. The
* discount-label wording matches {@link DepositNoticeService#itemsFromQuotation}.
* The lines themselves come from {@link #findDealQuotationItems}.
*/
public List<DepositNoticeItemRequest> findDealQuotationItemsForDeposit(long ticketId) {
List<Long> ids = jdbc.query("""
Expand All @@ -416,12 +414,21 @@ AND doc_status IN ('ACCEPTED', 'ISSUED', 'APPROVED')
if (ids.isEmpty()) {
return List.of();
}
return findDealQuotationItems(ids.get(0));
}

/** One deal quotation's {@code sales.quotation_item} lines, by {@code seq}, as deposit-notice
* items. Reads the RESOLVED display columns the quotation itself prints
* ({@code qty}/{@code raw_unit}/{@code unit_price}/{@code final_unit_price}), NOT the legacy
* {@code requested_*}/{@code approved_unit_price} columns the V2 chain leaves null. The
* discount-label wording matches {@link DepositNoticeService#itemsFromQuotation}. */
public List<DepositNoticeItemRequest> findDealQuotationItems(long quotationId) {
return jdbc.query("""
SELECT seq, description, qty, raw_unit, unit_price, sales_discount, final_unit_price
FROM sales.quotation_item
WHERE quotation_id = :quotationId
ORDER BY seq
""", Map.of("quotationId", ids.get(0)), (rs, i) -> {
""", Map.of("quotationId", quotationId), (rs, i) -> {
String description = rs.getString("description");
if (description == null || description.isBlank()) {
description = "รายการสินค้า";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,15 @@ public DepositNoticeDto getById(long docId, UserPrincipal actor) {
// Create a DRAFT from approved ticket items
@Transactional
public DepositNoticeDto createDraft(long ticketId, DepositNoticeDraftRequest req, UserPrincipal actor) {
return createDraftFromQuotation(ticketId, req, null, actor);
}

/** {@link #createDraft} for a caller that has already chosen the source quotation: a non-null
* {@code sourceDepositPercent} (that quotation's own whole-number {@code deposit_percent}) is
* the draft's percent, instead of the per-ticket lookup. */
@Transactional
public DepositNoticeDto createDraftFromQuotation(long ticketId, DepositNoticeDraftRequest req,
Integer sourceDepositPercent, UserPrincipal actor) {
requireRole(actor, SALES_ROLES);
TicketSummaryDto s = requireApprovedTicket(ticketId, actor);
requireActiveLifecycle(s);
Expand Down Expand Up @@ -129,8 +138,9 @@ public DepositNoticeDto createDraft(long ticketId, DepositNoticeDraftRequest req
// read a percent from at all (e.g. a manually-approved legacy ticket with no quotation
// chain) — see DepositNoticeRepository#findDealQuotationDepositPercent's own Javadoc for
// the full pick order.
BigDecimal effectiveDepositPercent = docs.findDealQuotationDepositPercent(ticketId)
.orElse(new BigDecimal("0.50"));
BigDecimal effectiveDepositPercent = sourceDepositPercent != null
? BigDecimal.valueOf(sourceDepositPercent, 2)
: docs.findDealQuotationDepositPercent(ticketId).orElse(new BigDecimal("0.50"));

var effective = new DepositNoticeDraftRequest(
req.customerName() != null ? req.customerName() : s.customerName(),
Expand Down Expand Up @@ -1271,6 +1281,12 @@ private CustomerQuotationDto pickQuotation(List<CustomerQuotationDto> candidates
return latestAccepted != null ? latestAccepted : latestIssued;
}

/** The pricing-request route's new-engine item source, so the draft's rows come from the
* quotation its reference names. */
public List<DepositNoticeItemRequest> itemsFromDealQuotation(long quotationId) {
return docs.findDealQuotationItems(quotationId);
}

/**
* Maps a customer quotation's items to deposit-notice item requests. The single shared mapping
* used by both {@link #buildItemsFromRequest} (this class's own ticket-chain fallback above)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,11 @@ public class OrderConfirmationService {
private final CustomerQuotationRepository quotations;
private final DepositNoticeService depositNotices;
private final NotificationRepository notifications;
// GLA-123 slice S3 BLOCKER 2 fix — read-only dependency onto the new engine, used ONLY by
// GLA-123 slice S3 BLOCKER 2 fix — read-only dependency onto the new engine, used by
// #reconcileTicketItems to prefer an ACCEPTED PRICING_REQUEST-origin quotation's own line
// quantities over the PR item's authored ones. SETTER-injected (not a constructor parameter),
// matching DealQuotationService's own established device (see its
// quantities over the PR item's authored ones, and by #createDepositNoticeFromQuotation to
// find the pricing request's ACCEPTED new-engine quotation. SETTER-injected (not a
// constructor parameter), matching DealQuotationService's own established device (see its
// #wirePricingRequestDependencies Javadoc): 12 existing hand-wired
// `new OrderConfirmationService(...)` test call sites keep compiling unchanged, Spring wires
// it automatically via the @Autowired setter in production, and #reconcileTicketItems
Expand All @@ -106,8 +107,9 @@ public OrderConfirmationService(PricingRequestRepository pricingRequests, Ticket
}

/** GLA-123 slice S3 BLOCKER 2 fix — see {@link #dealQuotations}'s own Javadoc for why this is
* setter- rather than constructor-injected. {@code required = false} so a hand-wired test that
* never exercises a PRICING_REQUEST-origin confirmOrder is unaffected either way. */
* setter- rather than constructor-injected. {@code required = false}: an unwired instance
* falls back to the PR item's own quantity in confirmOrder, and answers 409 on the new-engine
* branch of createDepositNoticeFromQuotation. */
@org.springframework.beans.factory.annotation.Autowired(required = false)
public void wireDealQuotationRepository(th.co.glr.hr.dealquotation.DealQuotationRepository dealQuotations) {
this.dealQuotations = dealQuotations;
Expand Down Expand Up @@ -422,12 +424,29 @@ private String mapUnitBasisToTicketItem(String unitBasis) {
* <p><strong>Deposit percent (owner ruling, 2026-09-29):</strong> {@code
* request.depositPercent()} below is passed through but {@link
* DepositNoticeService#createDraft} now IGNORES it unconditionally and re-derives the percent
* itself from {@code accepted}'s own {@code sales.quotation.deposit_percent} via {@code
* itself from {@code accepted}'s own {@code sales.quotation.deposit_percent} (legacy branch;
* the new-engine paragraph below covers the other branch) via {@code
* DepositNoticeRepository#findDealQuotationDepositPercent(ticketId)} — this call site needs no
* separate fix because it already funnels through that one shared method. Left the field on
* {@link CreateDepositNoticeFromQuotationRequest} itself unremoved (an older frontend build
* may still send it) rather than making it a breaking DTO change for a value that is now a
* no-op either way.
*
* <p><strong>New-engine quotation:</strong> when the pricing request has no ACCEPTED legacy
* quotation, this falls back to its ACCEPTED new-engine {@code PRICING_REQUEST}-origin
* quotation ({@code DealQuotationService#createFromPricingRequest}), found by {@code
* DealQuotationRepository#findAcceptedForPricingRequest} (its Javadoc says why "latest" is
* the wrong pick). The items come from that quotation,
* the accepted quotation of THIS pricing request ({@link
* DepositNoticeService#itemsFromDealQuotation}), so a sibling pricing request's quotation on
* the same ticket can never supply them. The percent also comes from that quotation: its own
* {@code deposit_percent} goes to {@link DepositNoticeService#createDraftFromQuotation}, so
* when the quotation sets a percent a sibling pricing request's legacy row on the same ticket
* cannot supply it either (a new-engine sibling never could: R8 allows one ACCEPTED quotation
* per ticket and the lookup prefers ACCEPTED); a null percent still falls back to the
* per-ticket lookup. The legacy branch passes no percent, because {@code CustomerQuotationDto}
* carries none, so it keeps {@code createDraft}'s per-ticket derivation; the ticket-level
* route does the same. The customer must have accepted: an ISSUED-only quotation answers 409.
*/
@Transactional
public DepositNoticeDto createDepositNoticeFromQuotation(long pricingRequestId,
Expand All @@ -439,23 +458,48 @@ public DepositNoticeDto createDepositNoticeFromQuotation(long pricingRequestId,
CustomerQuotationDto accepted = quotations.findByPricingRequest(pricingRequestId).stream()
.filter(q -> QuotationStatus.ACCEPTED.equals(q.docStatus()))
.findFirst()
.orElseThrow(() -> new ApiException(HttpStatus.CONFLICT,
"ยังไม่มีใบเสนอราคาที่ลูกค้ายอมรับสำหรับคำขอราคานี้"));

// Mapping itself now lives on DepositNoticeService (this branch's fix), shared with that
// class's own new-chain item fallback in buildItemsFromRequest — see its Javadoc.
List<DepositNoticeItemRequest> items = DepositNoticeService.itemsFromQuotation(accepted.items());
.orElse(null);

String reference;
List<DepositNoticeItemRequest> items;
Integer sourceDepositPercent;
if (accepted != null) {
reference = accepted.number();
items = DepositNoticeService.itemsFromQuotation(accepted.items());
sourceDepositPercent = null;
} else {
th.co.glr.hr.dealquotation.DealQuotationDtos.DealQuotationDto newEngine = dealQuotations == null
? null
: dealQuotations.findAcceptedForPricingRequest(pricingRequestId)
.flatMap(dealQuotations::findById)
.orElse(null);
if (newEngine == null) {
throw new ApiException(HttpStatus.CONFLICT, "ยังไม่มีใบเสนอราคาที่ลูกค้ายอมรับสำหรับคำขอราคานี้");
}
// A zero-deposit quotation (DealQuotationService#isZeroDeposit: "ไม่รับมัดจำ", paid in
// full under fullPaymentTerm) has nothing to bill. Refuse here rather than draft a
// 0-baht notice: DepositNoticeService#issue reads the ticket's deposit_policy, which no
// path syncs from the quotation, so nothing downstream would stop that draft.
if (Integer.valueOf(0).equals(newEngine.depositPercent())) {
throw new ApiException(HttpStatus.CONFLICT, "ใบเสนอราคา " + newEngine.number()
+ " ระบุไม่มีมัดจำ (ชำระเต็มจำนวน) จึงออกใบแจ้งยอดเงินรับมัดจำไม่ได้");
}
reference = newEngine.number();
items = depositNotices.itemsFromDealQuotation(newEngine.id());
sourceDepositPercent = newEngine.depositPercent();
}

// request.depositPercent() is deliberately still passed through here even though
// createDraft now ignores it as the source — see this method's own Javadoc above.
DepositNoticeDraftRequest draftRequest = new DepositNoticeDraftRequest(
null, null, null, null, accepted.number(),
null, null, null, null, reference,
request != null ? request.depositPercent() : null, null, items);
DepositNoticeDto draft = depositNotices.createDraft(summary.ticketId(), draftRequest, actor);
DepositNoticeDto draft = depositNotices.createDraftFromQuotation(summary.ticketId(), draftRequest,
sourceDepositPercent, actor);

pricingRequests.addEvent(pricingRequestId, summary.ticketId(), actor.id(), actor.name(),
PricingRequestEventKind.DEPOSIT_NOTICE_DRAFTED_FROM_QUOTATION, summary.status(), summary.status(),
"สร้างร่างใบแจ้งยอดเงินรับมัดจำจากใบเสนอราคา " + accepted.number(), null);
"สร้างร่างใบแจ้งยอดเงินรับมัดจำจากใบเสนอราคา " + reference, null);
return draft;
}

Expand Down
Loading
Loading