Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -39,14 +39,8 @@ public class CommissionCalculator {
* here) and รับเงินเกิน (overpayment received, added back). Both are applied at the same
* "actualReceived" stage as the existing five deduction columns.
*
* <p><b>Owner confirmation needed:</b> the exact Excel formula for how these two columns
* combine with the others could not be extracted — LibreOffice dropped the cell formulas on
* conversion, leaving only static values behind. This sign convention (WHT subtracted,
* overpayment added, both at this stage, before the VAT strip) is a best-effort mirror of the
* columns' evident intent, not a verified transcription of the source formula. The
* sales-manager review step ({@link CommissionService#updateDeductions}) is the safety net for
* this slice — flag this for the owner to confirm against the Excel/accounting team before
* relying on it unreviewed.
* <p>Matches the owner's commission workbooks (L = D-E-F-G-H-I-J+K, the amount received incl. VAT;
* M = L/1.07).
*
* <p>Backward compatible: when {@code withholdingTax} and {@code overpayment} are both zero
* (or null), the result is identical to the pre-Slice-A1 five-column formula.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,7 @@ public CommissionRecord createFromDeal(
if (commissions.hasActiveCommissionForTicket(ticketId)) {
throw new ApiException(HttpStatus.CONFLICT, "มีรายการค่าคอมมิชชั่นสำหรับดีลนี้อยู่แล้ว");
}
BigDecimal effectiveGrossAmount = grossAmount != null ? grossAmount : tickets.payableAmountExVat(ticketId);
BigDecimal effectiveGrossAmount = grossAmount != null ? grossAmount : tickets.payableAmount(ticketId);

SubmitCommissionRequest request = new SubmitCommissionRequest(
ticketId,
Expand Down Expand Up @@ -386,7 +386,7 @@ private DealLinkage resolveDealLinkage(SubmitCommissionRequest request) {
throw new ApiException(HttpStatus.UNPROCESSABLE_CONTENT,
"ดีลนี้ยังไม่ถึงขั้นตอนรับชำระเงินครบถ้วน (CLOSED_PAID) จึงยังยื่นค่าคอมมิชชั่นไม่ได้");
}
BigDecimal payable = tickets.payableAmountExVat(ticketId);
BigDecimal payable = tickets.payableAmount(ticketId);
boolean mismatch = isMismatch(request.grossAmount(), payable);
return new DealLinkage(payable, mismatch);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1418,6 +1418,18 @@ public List<ExpiredQuotationRow> expireOverdueQuotations() {
rs.getLong("ticket_id"), rs.getString("number")));
}

/** Owner review 2026-10-28: confirming an order from {@code keepId} cancels the deal's OTHER open DEAL_DIRECT
* quotations (DRAFT / PENDING_APPROVAL), which could otherwise never be submitted or approved again. Returns the
* cancelled numbers so the caller can log them. PRICING_REQUEST-origin rows are never touched. */
public List<String> cancelOtherOpenDirectQuotations(long ticketId, long keepId) {
return jdbc.queryForList("""
UPDATE sales.quotation SET doc_status = 'CANCELLED', updated_at = now()
WHERE ticket_id = :ticketId AND quotation_id <> :keepId AND origin = 'DEAL_DIRECT'
AND doc_status IN ('DRAFT', 'PENDING_APPROVAL')
RETURNING number
""", Map.of("ticketId", ticketId, "keepId", keepId), String.class);
}

/** Compare-and-set DRAFT -> CANCELLED. */
public int cancel(long quotationId) {
return jdbc.update("""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1394,6 +1394,7 @@ public DealQuotationDto submit(long id, UserPrincipal actor) {
// was loaded above may be what the change we just waited for rewrote.
quotations.lockTicket(found.ticketId());
DealQuotationDto quotation = requireQuotation(id);
requireNoOtherDirectQuotationAfterConfirm(quotation);
// GLA-123 slice S2: submit is now LIVE for BOTH origins — every validation below (item
// completeness, payment term, validity date) is origin-agnostic and applies unchanged;
// only approve/reject/issue diverge (see #approve/#reject's own origin branch).
Expand Down Expand Up @@ -1651,6 +1652,7 @@ public DealQuotationDto approve(long id, ApproveRequest request, UserPrincipal a
// directly) -- a transaction here never holds a second ticket's lock at the same time, so
// this cannot deadlock against either of them.
quotations.lockTicket(quotation.ticketId());
requireNoOtherDirectQuotationAfterConfirm(quotation); // under the lock: confirm-order takes it too
LocalDate approvalDate = LocalDate.now(BANGKOK);
// V178: a DATE-mode document WITH special pricing names its OWN validity_date (the rep's
// ภายในวันที่, verbatim — approval never recomputes it); every other case (DAYS mode, or a
Expand Down Expand Up @@ -1711,16 +1713,6 @@ public DealQuotationDto approve(long id, ApproveRequest request, UserPrincipal a
quotations.supersede(ancestorId);
ancestorId = ancestor.parentQuotationId();
}
// #1173 review F1: on a confirmed deal the system-owned deposit policy follows the newly
// approved quotation, so a revision that adds or drops the deposit moves the deal with it.
// A no-op before confirmation (confirmOrderFromDirectQuotation applies the confirmed
// quotation's percent then). Null-tolerant for hand-wired tests, like
// #advanceDealToRecipientStage. PRICING_REQUEST origin never reaches this: R8 allows one
// accepted quotation per deal, and its percent is applied at OrderConfirmationService#confirmOrder.
if (ticketService != null) {
ticketService.followQuotationDepositPolicy(approved.ticketId(), approved.number(),
DealQuotationRenderAdapter.effectiveDepositPercent(approved.depositPercent()), actor);
}
tickets.addEventWithDocument(approved.ticketId(), actor.id(), actor.name(), TicketEventKind.QUOTATION_ISSUED,
null, null, "อนุมัติใบเสนอราคา " + approved.number(), RelatedDocumentType.QUOTATION, id);
String message = "ใบเสนอราคา " + approved.number() + " ได้รับอนุมัติแล้ว";
Expand Down Expand Up @@ -2184,6 +2176,12 @@ public PromoteToDealResultDto confirmOrderFromDirectQuotation(long quotationId,
tickets.addEventWithDocument(ticketId, actor.id(), actor.name(),
TicketEventKind.DEAL_PROMOTED_FROM_QUOTATION, TicketStatus.DRAFT, TicketStatus.QUOTATION_ISSUED,
"ยืนยันคำสั่งซื้อจากใบเสนอราคา " + fresh.number(), RelatedDocumentType.QUOTATION, quotationId);
// The agreed order is this quotation: the deal's other open direct quotations can never be submitted or
// approved now (see requireNoOtherDirectQuotationAfterConfirm), so cancel them rather than strand them.
for (String number : quotations.cancelOtherOpenDirectQuotations(ticketId, quotationId)) {
tickets.addEvent(ticketId, actor.id(), actor.name(), TicketEventKind.CANCELLED, null, null,
"ยกเลิกใบเสนอราคา " + number + " อัตโนมัติ เพราะยืนยันคำสั่งซื้อจากใบเสนอราคา " + fresh.number());
}
// The quotation is the agreed order, so it REPLACES whatever preliminary lines the deal
// carried. Safe at this point: the deal is still pre-order (status was draft, so no delivery
// or stock reservation exists — both key on quotation_issued or later), and the only other
Expand Down Expand Up @@ -2399,6 +2397,9 @@ public DealQuotationDto createReorder(long id, UserPrincipal actor) {
// GLA-123 slice S1, defence in depth — see #createRevision's identical comment.
requireReorderAllowedForOrigin(source);
requireApprovedForClone(source, "สร้างจากใบเดิมได้จากใบเสนอราคาที่ได้รับอนุมัติแล้วเท่านั้น");
if (tickets.isOrderConfirmedFromQuotation(source.ticketId(), source.id())) {
throw new ApiException(HttpStatus.CONFLICT, "ใบเสนอราคานี้ยืนยันคำสั่งซื้อแล้ว จึงสร้างจากใบเดิมไม่ได้");
}
// No requireNoOpenRevision here (deliberately) — multiple clones of the same APPROVED
// source are allowed, and a clone never contends with an in-progress revision for anything.
// Still locks the ticket, same advisory lock #requireNoOpenRevision/#createRevision use, so
Expand Down Expand Up @@ -2572,6 +2573,21 @@ private DealQuotationDto requireApprovedForClone(DealQuotationDto quotation, Str
return quotation;
}

/**
* Owner ruling 2026-10-28: once an order is confirmed from a direct quotation, that quotation is the agreed
* order. No OTHER direct quotation on the deal may be submitted or approved (approval would supersede the
* confirmed one, move the payable and flip the deposit policy), and the confirmed one may not be cloned.
*/
private void requireNoOtherDirectQuotationAfterConfirm(DealQuotationDto quotation) {
if (isPricingRequestOrigin(quotation)
|| !tickets.hasPromotionFromQuotationEvent(quotation.ticketId())
|| tickets.isOrderConfirmedFromQuotation(quotation.ticketId(), quotation.id())) {
return;
}
throw new ApiException(HttpStatus.CONFLICT,
"ดีลนี้ยืนยันคำสั่งซื้อจากใบเสนอราคาอื่นแล้ว จึงส่ง/อนุมัติใบเสนอราคานี้ไม่ได้");
}

private static boolean isPricingRequestOrigin(DealQuotationDto quotation) {
return "PRICING_REQUEST".equals(quotation.origin());
}
Expand All @@ -2590,6 +2606,11 @@ private DealQuotationDto requireRevisableStatus(DealQuotationDto quotation) {
}
return quotation;
}
// Owner ruling 2026-10-28: the quotation an order was confirmed from is the agreed order -- not revisable.
if (tickets.isOrderConfirmedFromQuotation(quotation.ticketId(), quotation.id())) {
throw new ApiException(HttpStatus.CONFLICT,
"ใบเสนอราคานี้ยืนยันคำสั่งซื้อแล้ว จึงสร้างฉบับแก้ไขไม่ได้");
}
return requireApprovedForClone(quotation, "สร้างฉบับแก้ไขได้จากใบเสนอราคาที่ได้รับอนุมัติแล้วเท่านั้น");
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,8 @@ record RemainingInvoiceMoney(BigDecimal netAmount, BigDecimal vatAmount, BigDeci

RemainingInvoiceMoney computeRemainingInvoiceMoney(BigDecimal itemsTotal, BigDecimal depositAmount) {
BigDecimal netAmount = itemsTotal.subtract(depositAmount).setScale(2, RoundingMode.HALF_UP);
// Owner ruling 2026-10-28 (SK-07): each tax invoice's VAT = round2(its own net x 7%) -- usual Thai practice;
// a satang gap against the deal's payable is normal and outstanding follows the ISSUED documents.
BigDecimal vatAmount = netAmount.multiply(VAT_RATE).setScale(2, RoundingMode.HALF_UP);
BigDecimal grandTotal = netAmount.add(vatAmount).setScale(2, RoundingMode.HALF_UP);
return new RemainingInvoiceMoney(netAmount, vatAmount, grandTotal);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,18 @@ public record RemainingInvoiceDto(
String projectName,
BigDecimal depositAmount,
List<String> notes,
List<RemainingInvoiceItemDto> items
) {}
List<RemainingInvoiceItemDto> items,
// The STORED document totals (owner ruling 2026-10-28, SK-07): the sheet prints these as values, not as
// template formulas, so the printed VAT / total are exactly what the issued document and the ledger hold.
// null = not supplied: the template's own formulas stay (only a caller that never stored a total).
BigDecimal vatAmount,
BigDecimal grandTotal
) {
public RemainingInvoiceDto(String docNumber, LocalDate issueDate, String reference, String depositReference,
String customerName, String customerBranch, String customerAddress,
String customerTaxId, String projectName, BigDecimal depositAmount,
List<String> notes, List<RemainingInvoiceItemDto> items) {
this(docNumber, issueDate, reference, depositReference, customerName, customerBranch, customerAddress,
customerTaxId, projectName, depositAmount, notes, items, null, null);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -234,8 +234,15 @@ public byte[] toXlsx(RemainingInvoiceDto doc) throws Exception {

// ── Summary rows auto-calculated by existing formulas ─────────────
// I42 = SUM(I11:I41) includes item amounts and -depositAmount → remainder
// I43 = I42 * 0.07 (VAT)
// I44 = I42 + I43 (total payable)
// I43 / I44 (VAT, total payable): the STORED, already-rounded figures as VALUES when supplied
// (owner ruling 2026-10-28, SK-07) -- the template's I42 x 7% formula would print an unrounded
// 5,867.505 / 89,689.005 instead of what the issued document and the ledger hold.
if (doc.vatAmount() != null) {
setNum(sh, 42, 8, doc.vatAmount().doubleValue());
}
if (doc.grandTotal() != null) {
setNum(sh, 43, 8, doc.grandTotal().doubleValue());
}
wb.setForceFormulaRecalculation(true);
wb.write(out);
return out.toByteArray();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -404,7 +404,7 @@ private RemainingInvoiceDto toRenderable(RemainingInvoiceDocumentDto doc) {
doc.docNumber() != null ? doc.docNumber() : "(ร่าง)",
doc.docDate(), doc.reference(), doc.depositReference(),
doc.customerName(), doc.customerBranch(), doc.customerAddress(), doc.customerTaxId(),
doc.projectName(), nz(doc.depositDeduction()), doc.notes(), items);
doc.projectName(), nz(doc.depositDeduction()), doc.notes(), items, doc.vatAmount(), doc.grandTotal());
}

private List<String> defaultNotes() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,13 @@ public Optional<QuotationHeader> findPayableQuotation(long ticketId) {
SELECT q.quotation_id, q.number, q.doc_status, q.total_amount, q.currency,
q.issued_at, q.accepted_at, q.origin, q.pricing_request_id
FROM sales.quotation q
WHERE q.ticket_id = :id AND q.doc_status IN ('ACCEPTED', 'ISSUED', 'SENT')
ORDER BY CASE WHEN q.doc_status = 'ACCEPTED' THEN 0 ELSE 1 END,
WHERE q.ticket_id = :id AND (ANSWERED OR q.doc_status IN ('ISSUED', 'SENT'))
ORDER BY CASE q.doc_status WHEN 'ACCEPTED' THEN 0 WHEN 'APPROVED' THEN 1 ELSE 2 END,
CASE q.recipient_type WHEN 'BUYER' THEN 0 WHEN 'OWNER' THEN 1 ELSE 2 END,
q.accepted_at DESC NULLS LAST, q.issued_at DESC, q.quotation_id DESC
LIMIT 1
""", Map.of("id", ticketId), (rs, n) -> new QuotationHeader(
""".replace("ANSWERED", th.co.glr.hr.ticket.TicketRepository.ANSWERED_QUOTATION_SQL),
Map.of("id", ticketId), (rs, n) -> new QuotationHeader(
rs.getLong("quotation_id"), rs.getString("number"), rs.getString("doc_status"),
rs.getBigDecimal("total_amount"), rs.getString("currency"),
rs.getTimestamp("issued_at") == null ? null : rs.getTimestamp("issued_at").toInstant(),
Expand Down
Loading
Loading