From 00dc1a96ba92650793d8cd14bf1c3ef00c780b8c Mon Sep 17 00:00:00 2001 From: waritctd <148909369+waritctd@users.noreply.github.com> Date: Wed, 28 Oct 2026 23:57:10 +0700 Subject: [PATCH] fix(dashboard): payment_overdue uses finance's overdue rule (VAT-inclusive payable, derived due date) The dashboard computed what a deal owes with its own SQL: pre-VAT quotation total, ACCEPTED only, no issued-remaining-invoice branch, and the stored due_date. It now calls TicketRepository.paymentOverduePredicate, the one rule the account list scope also uses. Co-Authored-By: Claude Opus 5.5 --- .../glr/hr/dashboard/DashboardRepository.java | 57 +----- .../th/co/glr/hr/ticket/TicketRepository.java | 21 ++- ...tOverdueMatchesFinanceIntegrationTest.java | 168 ++++++++++++++++++ .../DashboardRepositoryIntegrationTest.java | 17 ++ 4 files changed, 202 insertions(+), 61 deletions(-) create mode 100644 backend/src/test/java/th/co/glr/hr/dashboard/DashboardPaymentOverdueMatchesFinanceIntegrationTest.java diff --git a/backend/src/main/java/th/co/glr/hr/dashboard/DashboardRepository.java b/backend/src/main/java/th/co/glr/hr/dashboard/DashboardRepository.java index eec9bbc46..36e2e5ffe 100644 --- a/backend/src/main/java/th/co/glr/hr/dashboard/DashboardRepository.java +++ b/backend/src/main/java/th/co/glr/hr/dashboard/DashboardRepository.java @@ -10,6 +10,7 @@ import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; import org.springframework.stereotype.Repository; import th.co.glr.hr.employee.ManagerApproverRepository; +import th.co.glr.hr.ticket.TicketRepository; @Repository public class DashboardRepository { @@ -135,56 +136,6 @@ public TicketSummaryDto tickets( WITH visible_tickets AS ( SELECT t.* """ + from + where + """ - ), enriched_tickets AS ( - SELECT vt.*, - COALESCE(accepted.total_amount, issued.total_amount, notice.total_payable, - item_total.total_amount, 0) AS payable_amount, - COALESCE(paid.total_paid, 0) AS paid_amount - FROM visible_tickets vt - LEFT JOIN LATERAL ( - SELECT q.total_amount - FROM sales.quotation q - WHERE q.ticket_id = vt.ticket_id AND q.doc_status = 'ACCEPTED' - ORDER BY 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 - ) accepted ON TRUE - LEFT JOIN LATERAL ( - SELECT q.total_amount - FROM sales.quotation q - WHERE q.ticket_id = vt.ticket_id AND q.doc_status IN ('ISSUED','SENT') - ORDER BY CASE q.recipient_type - WHEN 'BUYER' THEN 0 - WHEN 'OWNER' THEN 1 - ELSE 2 - END, - q.issued_at DESC, - q.quotation_id DESC - LIMIT 1 - ) issued ON TRUE - LEFT JOIN LATERAL ( - SELECT d.total_payable - FROM sales.deposit_notice d - WHERE d.ticket_id = vt.ticket_id AND d.status = 'ISSUED' - ORDER BY d.version DESC, d.deposit_notice_id DESC - LIMIT 1 - ) notice ON TRUE - LEFT JOIN LATERAL ( - SELECT COALESCE(SUM(COALESCE(ti.approved_price, 0) * ti.qty), 0) AS total_amount - FROM sales.ticket_item ti - WHERE ti.ticket_id = vt.ticket_id - ) item_total ON TRUE - LEFT JOIN LATERAL ( - SELECT COALESCE(SUM(CASE WHEN pr.kind = 'ADJUSTMENT' THEN -pr.amount ELSE pr.amount END), 0) AS total_paid - FROM sales.payment_receipt pr - WHERE pr.ticket_id = vt.ticket_id - ) paid ON TRUE ) SELECT COUNT(*) FILTER (WHERE status = 'draft') AS draft, COUNT(*) FILTER (WHERE status = 'submitted') AS submitted, @@ -205,12 +156,10 @@ WHERE status NOT IN ('closed','cancelled','draft') ) AS overdue_over_3days, COUNT(*) FILTER (WHERE lifecycle = 'ON_HOLD') AS on_hold, COUNT(*) FILTER (WHERE lifecycle = 'DORMANT') AS dormant, - COUNT(*) FILTER ( - WHERE due_date < CURRENT_DATE - AND GREATEST(payable_amount - paid_amount, 0) > 0 + COUNT(*) FILTER (WHERE """ + TicketRepository.paymentOverduePredicate("vt.ticket_id") + """ ) AS payment_overdue, COUNT(*) FILTER (WHERE fulfillment_status = 'PARTIALLY_DELIVERED') AS partially_delivered - FROM enriched_tickets + FROM visible_tickets vt """, params, (rs, rowNum) -> new TicketSummaryDto( 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 87e75e5a5..9fc9049d0 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 @@ -233,13 +233,8 @@ OR t.sales_stage IN (:importStages) AND t.sales_stage IN (:accountOrderStages)) OR t.payment_status IN (:accountPendingStatuses) OR (""") - .append(paymentDueDateScalarSql("t.ticket_id")) - .append(" < ").append(BANGKOK_TODAY_SQL) - .append(" AND t.payment_status IS DISTINCT FROM 'DEPOSIT_NOTICE_ISSUED' AND ((") - .append(payableAmountSelect("t.ticket_id")) - .append(") - (") - .append(paidAmountSelect("t.ticket_id")) - .append(")) > 0)) "); + .append(paymentOverduePredicate("t.ticket_id")) + .append(")) "); params.addValue("accountOrderStages", ACCOUNT_ORDER_STAGE_SCOPE); params.addValue("accountPendingStatuses", ACCOUNT_PENDING_PAYMENT_STATUSES); } @@ -1865,6 +1860,18 @@ private static String paymentDueDateScalarSql(String ticketIdRef) { return "(SELECT x.delivered_on + x.days FROM (" + paymentTermsRowSql(ticketIdRef) + ") x)"; } + /** + * The ONE overdue rule, as a boolean SQL fragment: derived due date is past (Bangkok), the deal is not still + * on the deposit notice, and payable minus paid is positive. The account list scope and the dashboard's + * {@code payment_overdue} count both use it, so they cannot disagree. + */ + public static String paymentOverduePredicate(String ticketIdRef) { + return "(" + paymentDueDateScalarSql(ticketIdRef) + " < " + BANGKOK_TODAY_SQL + + " AND (SELECT tp.payment_status FROM sales.ticket tp WHERE tp.ticket_id = " + ticketIdRef + + ") IS DISTINCT FROM 'DEPOSIT_NOTICE_ISSUED' AND ((" + payableAmountSelect(ticketIdRef) + + ") - (" + paidAmountSelect(ticketIdRef) + ")) > 0)"; + } + /** When the balance is due and on what basis; see {@link #paymentTermsRowSql}. */ public record PaymentDue(LocalDate date /* null until delivered */, String basis, Integer creditDays) {} diff --git a/backend/src/test/java/th/co/glr/hr/dashboard/DashboardPaymentOverdueMatchesFinanceIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/dashboard/DashboardPaymentOverdueMatchesFinanceIntegrationTest.java new file mode 100644 index 000000000..df1b8acbb --- /dev/null +++ b/backend/src/test/java/th/co/glr/hr/dashboard/DashboardPaymentOverdueMatchesFinanceIntegrationTest.java @@ -0,0 +1,168 @@ +package th.co.glr.hr.dashboard; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.math.BigDecimal; +import java.sql.Timestamp; +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import th.co.glr.hr.support.AbstractPostgresIntegrationTest; +import th.co.glr.hr.ticket.CreateTicketRequest; +import th.co.glr.hr.ticket.TicketRepository; + +/** + * The dashboard's {@code paymentOverdue} counts exactly the deals the finance side marks overdue + * ({@code TicketRepository}'s summary): the same VAT-inclusive payable (owner rulings 2026-09-30 / 2026-10-28), + * the same due date (the payable quotation's terms + delivery, never the stored {@code due_date}), and no + * overdue while the deposit is unpaid. Each test asserts the absolute count AND parity with finance. + */ +class DashboardPaymentOverdueMatchesFinanceIntegrationTest extends AbstractPostgresIntegrationTest { + private static final ZoneId BKK = ZoneId.of("Asia/Bangkok"); + + private DashboardRepository dashboard; + private TicketRepository tickets; + private long repId; + + @BeforeEach + void seed() { + dashboard = new DashboardRepository(jdbc); + tickets = new TicketRepository(jdbc); + long divisionId = jdbc.queryForObject(""" + INSERT INTO hr.division (source_code, name_th) VALUES ('SA-OD', 'Sales OD') RETURNING division_id + """, Map.of(), Number.class).longValue(); + repId = jdbc.queryForObject(""" + INSERT INTO hr.employee (employee_code, first_name_th, division_id, is_active) + VALUES ('EMP-OD', 'EMP-OD', :divisionId, TRUE) RETURNING employee_id + """, Map.of("divisionId", divisionId), Number.class).longValue(); + } + + @Test + void ownerDirectDeal_paidThePreVatAmount_isStillOverdue_forTheVat() { + long id = creditDealDelivered31DaysAgo("ACCEPTED", "CUSTOMER_CONFIRMED"); + pay(id, "100000.00"); // pre-VAT subtotal; payable is 107,000.00 + + assertOverdueCount(1, id); + } + + @Test + void ownerDirectDeal_paidTheVatInclusiveAmount_isNotOverdue() { + long id = creditDealDelivered31DaysAgo("ACCEPTED", "CUSTOMER_CONFIRMED"); + pay(id, "107000.00"); + + assertOverdueCount(0, id); + } + + @Test + void confirmedDealDirectQuotation_stillApproved_isPayable_andOverdue() { + long id = creditDealDelivered31DaysAgo("APPROVED", "CUSTOMER_CONFIRMED"); // confirm-order leaves it APPROVED + jdbc.update("UPDATE sales.quotation SET origin = 'DEAL_DIRECT' WHERE ticket_id = :id", Map.of("id", id)); + long quotationId = jdbc.queryForObject("SELECT quotation_id FROM sales.quotation WHERE ticket_id = :id", + Map.of("id", id), Long.class); + tickets.addEventWithDocument(id, repId, "ทดสอบ", "DEAL_PROMOTED_FROM_QUOTATION", null, null, + "ยืนยันคำสั่งซื้อ", "QUOTATION", quotationId); // the per-quotation fact the payable rule keys on + + assertOverdueCount(1, id); + } + + @Test + void issuedRemainingInvoice_payableFollowsTheIssuedDocuments_aSatangShortIsOverdue() { + long id = creditDealDelivered31DaysAgo("ACCEPTED", "CUSTOMER_CONFIRMED"); + // per-document VAT rounding: 32,100.00 + 74,900.01 = 107,000.01, one satang above the quotation's 107,000.00 + long notice = jdbc.queryForObject(""" + INSERT INTO sales.deposit_notice (ticket_id, version, status, customer_name, deposit_percent, + subtotal, deposit_amount, vat_percent, vat_amount, total_payable) + VALUES (:id, 1, 'ISSUED', 'x', 0.3, 100000.00, 30000.00, 0.07, 2100.00, 32100.00) + RETURNING deposit_notice_id""", Map.of("id", id), Number.class).longValue(); + jdbc.update(""" + INSERT INTO sales.remaining_invoice (ticket_id, deposit_notice_id, status, base_number, doc_number, + issued_at, grand_total) + VALUES (:id, :notice, 'ISSUED', 'GLR6999999', 'GLR6999999-1', now(), 74900.01)""", + new MapSqlParameterSource("id", id).addValue("notice", notice)); + pay(id, "107000.00"); + + assertOverdueCount(1, id); + } + + @Test + void storedDueDateInThePast_withoutQuotationTerms_isNotOverdue() { + long id = createDeal(); + insertQuotation(id, "ACCEPTED"); // no remainder_mode / credit_days: no derived due date + setStoredDueDate(id, today().minusDays(10)); + + assertOverdueCount(0, id); + } + + @Test + void unpaidDeposit_isNeverOverdue_evenLongAfterDelivery() { + long id = creditDealDelivered31DaysAgo("ACCEPTED", "DEPOSIT_NOTICE_ISSUED"); + + assertOverdueCount(0, id); + } + + // ── helpers ───────────────────────────────────────────────────────────────────────────── + + /** Quotation subtotal 100,000 (TH, 107,000 incl. VAT), CREDIT 30 days, fully delivered 31 days ago. */ + private long creditDealDelivered31DaysAgo(String quotationStatus, String paymentStatus) { + long id = createDeal(); + insertQuotation(id, quotationStatus); + jdbc.update("UPDATE sales.quotation SET remainder_mode = 'CREDIT', credit_days = 30 WHERE ticket_id = :id", + Map.of("id", id)); + tickets.updatePaymentStatusUnchecked(id, paymentStatus); + jdbc.update("UPDATE sales.ticket SET fulfillment_status = 'FULLY_DELIVERED' WHERE ticket_id = :id", + Map.of("id", id)); + jdbc.update(""" + INSERT INTO sales.delivery_record (ticket_id, source, delivered_at, delivered_by) + VALUES (:id, 'WAREHOUSE', :at, :by)""", new MapSqlParameterSource("id", id) + .addValue("at", Timestamp.from(today().minusDays(31).atTime(12, 0).atZone(BKK).toInstant())) + .addValue("by", repId)); + // the stored billing due_date is past too, so the pre-fix query is only wrong about the AMOUNT + setStoredDueDate(id, today().minusDays(1)); + return id; + } + + private long createDeal() { + long id = tickets.create( + new CreateTicketRequest("ดีลทดสอบ", "NORMAL", "ลูกค้าทดสอบ", null, null, null, null, null, List.of()), + tickets.nextTicketCode(), repId, "EMP-OD"); + tickets.updateSalesStage(id, "PROCUREMENT"); + return id; + } + + private void insertQuotation(long ticketId, String status) { + long q = tickets.createQuotation(ticketId, "QT-OD-" + ticketId, repId, new BigDecimal("100000.00")).id(); + jdbc.update("UPDATE sales.quotation SET doc_status = :s WHERE quotation_id = :q", + new MapSqlParameterSource("s", status).addValue("q", q)); + } + + private void setStoredDueDate(long ticketId, LocalDate dueDate) { + jdbc.update("UPDATE sales.ticket SET due_date = :d WHERE ticket_id = :id", + new MapSqlParameterSource("id", ticketId).addValue("d", dueDate)); + } + + private void pay(long ticketId, String amount) { + jdbc.update(""" + INSERT INTO sales.payment_receipt (ticket_id, kind, amount, currency, received_at, recorded_by, receipt_ref) + VALUES (:t, 'BALANCE', :a, 'THB', now(), :by, :ref)""", new MapSqlParameterSource() + .addValue("t", ticketId).addValue("a", new BigDecimal(amount)) + .addValue("by", repId).addValue("ref", "OD-" + ticketId)); + } + + private void assertOverdueCount(long expected, long ticketId) { + long dashboardCount = dashboard.tickets(DashboardQueryScope.self(repId), today().withDayOfMonth(1), + OffsetDateTime.now(BKK)).paymentOverdue(); + boolean financeOverdue = tickets.findSummaryById(ticketId).orElseThrow().overdue(); + + assertThat(financeOverdue).as("finance summary overdue").isEqualTo(expected == 1); + assertThat(dashboardCount).as("dashboard paymentOverdue").isEqualTo(expected); + } + + private static LocalDate today() { + return LocalDate.now(BKK); + } +} diff --git a/backend/src/test/java/th/co/glr/hr/dashboard/DashboardRepositoryIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/dashboard/DashboardRepositoryIntegrationTest.java index 5795d7e43..5baa20e40 100644 --- a/backend/src/test/java/th/co/glr/hr/dashboard/DashboardRepositoryIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/dashboard/DashboardRepositoryIntegrationTest.java @@ -47,6 +47,7 @@ void aggregatesDashboardSectionsWithScopes() { insertTicket("PR-3", "closed", salesEmployee, today.minusDays(1)); markOperationalBuckets("PR-1", "ON_HOLD", "PARTIALLY_DELIVERED", today.minusDays(2), BigDecimal.valueOf(100)); markOperationalBuckets("PR-2", "DORMANT", null, null, null); + insertOverdueCreditDeal("PR-2", salesEmployee); insertCommission(salesEmployee, monthStart); insertNotification(salesEmployee, false); insertNotification(salesEmployee, true); @@ -261,6 +262,22 @@ private void markOperationalBuckets(String code, String lifecycle, String fulfil } } + /** Finance's overdue rule: ACCEPTED quotation, CREDIT 30 days, fully delivered 31 days ago, nothing paid. */ + private void insertOverdueCreditDeal(String code, long deliveredBy) { + jdbc.update(""" + INSERT INTO sales.quotation (ticket_id, number, issued_by, total_amount, doc_status, + remainder_mode, credit_days) + SELECT ticket_id, 'QT-' || code, :by, 100000, 'ACCEPTED', 'CREDIT', 30 + FROM sales.ticket WHERE code = :code + """, Map.of("code", code, "by", deliveredBy)); + jdbc.update("UPDATE sales.ticket SET fulfillment_status = 'FULLY_DELIVERED' WHERE code = :code", + Map.of("code", code)); + jdbc.update(""" + INSERT INTO sales.delivery_record (ticket_id, source, delivered_at, delivered_by) + SELECT ticket_id, 'WAREHOUSE', now() - interval '31 days', :by FROM sales.ticket WHERE code = :code + """, Map.of("code", code, "by", deliveredBy)); + } + private void insertCommission(long salesRepId, LocalDate payrollMonth) { // V102 (chk_invoice_details_evidence_present) requires every row to show a real // attachment OR a recognized evidence_provenance -- this fixture has neither an