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 @@ -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 {
Expand Down Expand Up @@ -134,57 +135,7 @@
TicketSummaryDto summary = jdbc.queryForObject("""
WITH visible_tickets AS (
SELECT t.*
""" + from + where + """

Check warning on line 138 in backend/src/main/java/th/co/glr/hr/dashboard/DashboardRepository.java

View workflow job for this annotation

GitHub Actions / e2e-real

trailing white space will be removed

Check warning on line 138 in backend/src/main/java/th/co/glr/hr/dashboard/DashboardRepository.java

View workflow job for this annotation

GitHub Actions / build-and-test

trailing white space will be removed
), 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,
Expand All @@ -205,12 +156,10 @@
) 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(
Expand Down
21 changes: 14 additions & 7 deletions backend/src/main/java/th/co/glr/hr/ticket/TicketRepository.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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) {}

Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down
Loading