diff --git a/backend/src/main/java/th/co/glr/hr/dashboard/CashByRepDto.java b/backend/src/main/java/th/co/glr/hr/dashboard/CashByRepDto.java new file mode 100644 index 000000000..de86656d5 --- /dev/null +++ b/backend/src/main/java/th/co/glr/hr/dashboard/CashByRepDto.java @@ -0,0 +1,6 @@ +package th.co.glr.hr.dashboard; + +import java.math.BigDecimal; + +/** One sales rep's net cash received this Bangkok month. */ +public record CashByRepDto(String repName, BigDecimal amount) {} 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 36e2e5ffe..d89955d69 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 @@ -186,6 +186,68 @@ WHERE status NOT IN ('closed','cancelled','draft') return summary == null ? TicketSummaryDto.empty(scope.label()) : summary; } + private static final java.time.ZoneId BANGKOK = java.time.ZoneId.of("Asia/Bangkok"); + /** The Bangkok month window on receipts, shared by every "cash received this month" figure. */ + private static final String RECEIVED_IN_MONTH = "received_at >= :from AND received_at < :to"; + + private static MapSqlParameterSource monthWindow(LocalDate monthStart) { + return new MapSqlParameterSource() + .addValue("from", monthStart.atStartOfDay(BANGKOK).toOffsetDateTime()) + .addValue("to", monthStart.plusMonths(1).atStartOfDay(BANGKOK).toOffsetDateTime()); + } + + /** + * Cash received in the Bangkok calendar month starting {@code monthStart} ([start, next month start) on + * {@code received_at}, signed by {@link TicketRepository#SIGNED_RECEIPT_SUM}), split by the creator of the + * deal each receipt belongs to. The service totals the rows for the month figure. Grouped by creator id, labelled with the creator's name + * ({@code ไม่ระบุผู้ขาย} when blank), so two reps with the same name stay separate. + */ + public java.util.List cashReceivedByRep(LocalDate monthStart) { + return jdbc.query( + "SELECT COALESCE(NULLIF(TRIM(CONCAT_WS(' ', e.first_name_th, e.last_name_th)), ''), 'ไม่ระบุผู้ขาย') AS rep_name, " + + "SUM(x.amount) AS amount " + + "FROM (SELECT ticket_id, " + TicketRepository.SIGNED_RECEIPT_SUM + " AS amount " + + "FROM sales.payment_receipt WHERE " + RECEIVED_IN_MONTH + " GROUP BY ticket_id) x " + + "JOIN sales.ticket t ON t.ticket_id = x.ticket_id " + + "LEFT JOIN hr.employee e ON e.employee_id = t.created_by " + + "GROUP BY t.created_by, e.first_name_th, e.last_name_th", + monthWindow(monthStart), + (rs, n) -> new CashByRepDto(rs.getString("rep_name"), rs.getBigDecimal("amount"))); + } + + /** + * ฝ่ายบัญชี has something to do on the deal: the SQL twin of {@code nextAccountAction} for an account + * viewer (accountActions.js) -- overdue; a deposit or final payment to take; or CLOSED_PAID with no + * commission recorded yet. Close-ready deals owe nothing, so they add nothing to an outstanding total. + * {@code dashboard/money-cases.json} pins this against the JS rule. + */ + private static final String ACCOUNT_HAS_TODO = "(" + + "(t.status = 'quotation_issued' AND (" + + "t.payment_status IN ('DEPOSIT_NOTICE_ISSUED', 'AWAITING_FINAL_PAYMENT', 'DEPOSIT_PAID') " + + "OR (t.deposit_policy IN (:bypassPolicies) AND (t.payment_status IS NULL OR t.payment_status = 'CUSTOMER_CONFIRMED')))) " + + "OR (t.sales_stage = 'CLOSED_PAID' AND NOT " + TicketRepository.hasRecordedCommission("t.ticket_id") + ") " + + "OR " + TicketRepository.paymentOverduePredicate("t.ticket_id") + ")"; + + /** /finance's คงค้างรวม: outstanding on every deal in account's list scope that has an account to-do. */ + public java.math.BigDecimal financeOutstanding() { + MapSqlParameterSource params = new MapSqlParameterSource() + .addValue("bypassPolicies", th.co.glr.hr.ticket.DepositPolicy.NON_REQUIRED); + TicketRepository.addAccountScopeParams(params); + return jdbc.queryForObject("SELECT COALESCE(SUM(GREATEST((" + TicketRepository.payableAmountSelect("t.ticket_id") + + ") - (" + TicketRepository.paidAmountSelect("t.ticket_id") + "), 0)), 0) FROM sales.ticket t WHERE " + + TicketRepository.accountScopePredicate() + " AND " + ACCOUNT_HAS_TODO, params, java.math.BigDecimal.class); + } + + /** ยอดคาดการณ์: payable of live deals that have not reached ORDER_RECEIVED. */ + public java.math.BigDecimal forecastValue() { + MapSqlParameterSource params = new MapSqlParameterSource().addValue("beforeOrder", + th.co.glr.hr.ticket.DealStage.ORDER.subList(0, + th.co.glr.hr.ticket.DealStage.indexOf(th.co.glr.hr.ticket.DealStage.ORDER_RECEIVED))); + return jdbc.queryForObject("SELECT COALESCE(SUM(" + "(" + TicketRepository.payableAmountSelect("t.ticket_id") + ")" + + "), 0) FROM sales.ticket t WHERE t.lifecycle = 'ACTIVE' AND t.sales_stage IN (:beforeOrder) " + + "AND t.status NOT IN ('closed', 'cancelled')", params, java.math.BigDecimal.class); + } + public NotificationSummaryDto notifications(long employeeId) { NotificationSummaryDto summary = jdbc.queryForObject(""" SELECT COUNT(*) FILTER (WHERE is_read = FALSE) AS unread, diff --git a/backend/src/main/java/th/co/glr/hr/dashboard/DashboardService.java b/backend/src/main/java/th/co/glr/hr/dashboard/DashboardService.java index a251dc95a..008501aa3 100644 --- a/backend/src/main/java/th/co/glr/hr/dashboard/DashboardService.java +++ b/backend/src/main/java/th/co/glr/hr/dashboard/DashboardService.java @@ -1,9 +1,11 @@ package th.co.glr.hr.dashboard; +import java.math.BigDecimal; import java.time.Clock; import java.time.LocalDate; import java.time.OffsetDateTime; import java.time.ZoneId; +import java.util.List; import java.util.Set; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; @@ -17,6 +19,7 @@ public class DashboardService { private static final Set COMMISSION_APPROVER_ROLES = Set.of("sales_manager", "ceo"); private static final Set TICKET_VIEW_ALL_ROLES = Set.of("import", "ceo"); private static final Set TICKET_OWN_ROLES = Set.of("sales"); + private static final Set CASH_RECEIVED_ROLES = Set.of("account", "ceo", "sales_manager"); private final DashboardRepository dashboardRepository; private final Clock clock; @@ -53,6 +56,11 @@ public DashboardSummaryDto summary(UserPrincipal user) { ? null : dashboardRepository.latestPayrollPeriodId(user.employeeId()).orElse(null); + boolean seesCash = CASH_RECEIVED_ROLES.contains(user.role()); + List cashByRep = seesCash ? dashboardRepository.cashReceivedByRep(monthStart) : null; + BigDecimal cashReceived = cashByRep == null ? null + : cashByRep.stream().map(CashByRepDto::amount).reduce(BigDecimal.ZERO, BigDecimal::add); + return DashboardSummaryDto.of( user.role(), user.employeeId(), @@ -64,7 +72,11 @@ public DashboardSummaryDto summary(UserPrincipal user) { dashboardRepository.attendance(attendanceScope, today, monthStart), latestPayrollPeriodId, tickets, - notifications + notifications, + cashReceived, + cashByRep, + seesCash ? dashboardRepository.financeOutstanding() : null, + seesCash ? dashboardRepository.forecastValue() : null ); } diff --git a/backend/src/main/java/th/co/glr/hr/dashboard/DashboardSummaryDto.java b/backend/src/main/java/th/co/glr/hr/dashboard/DashboardSummaryDto.java index 73e151a89..a8f42b053 100644 --- a/backend/src/main/java/th/co/glr/hr/dashboard/DashboardSummaryDto.java +++ b/backend/src/main/java/th/co/glr/hr/dashboard/DashboardSummaryDto.java @@ -1,7 +1,9 @@ package th.co.glr.hr.dashboard; import com.fasterxml.jackson.annotation.JsonFormat; +import java.math.BigDecimal; import java.time.OffsetDateTime; +import java.util.List; public record DashboardSummaryDto( String role, @@ -28,7 +30,15 @@ public record DashboardSummaryDto( long onHold, long dormant, long paymentOverdue, - long partiallyDelivered + long partiallyDelivered, + /** Payment receipts dated this Bangkok month (deposit + balance - adjustment); null unless account/ceo/sales_manager. */ + BigDecimal cashReceivedThisMonth, + /** This month's net cash split per deal creator (sums to cashReceivedThisMonth); null unless account/ceo/sales_manager. */ + List cashReceivedByRep, + /** /finance's คงค้างรวม as a server total (account-scope deals with an account to-do); null unless account/ceo/sales_manager. */ + BigDecimal financeOutstanding, + /** Payable of ACTIVE deals before ORDER_RECEIVED; null unless account/ceo/sales_manager. */ + BigDecimal forecastValue ) { public static DashboardSummaryDto of( String role, @@ -42,6 +52,27 @@ public static DashboardSummaryDto of( Long latestPayrollPeriodId, TicketSummaryDto tickets, NotificationSummaryDto notifications + ) { + return of(role, employeeId, divisionId, manager, generatedAt, headcount, pendingApprovals, + attendance, latestPayrollPeriodId, tickets, notifications, null, null, null, null); + } + + public static DashboardSummaryDto of( + String role, + Long employeeId, + Long divisionId, + boolean manager, + OffsetDateTime generatedAt, + HeadcountSummaryDto headcount, + PendingApprovalsSummaryDto pendingApprovals, + AttendanceSummaryDto attendance, + Long latestPayrollPeriodId, + TicketSummaryDto tickets, + NotificationSummaryDto notifications, + BigDecimal cashReceivedThisMonth, + List cashReceivedByRep, + BigDecimal financeOutstanding, + BigDecimal forecastValue ) { return new DashboardSummaryDto( role, @@ -67,7 +98,11 @@ public static DashboardSummaryDto of( tickets.onHold(), tickets.dormant(), tickets.paymentOverdue(), - tickets.partiallyDelivered() + tickets.partiallyDelivered(), + cashReceivedThisMonth, + cashReceivedByRep, + financeOutstanding, + forecastValue ); } } 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 685da603c..74b1542a5 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 @@ -226,20 +226,30 @@ OR t.sales_stage IN (:importStages) params.addValue("prTerminalStatuses", PRICING_REQUEST_TERMINAL_STATUSES); params.addValue("importStages", IMPORT_STAGE_SCOPE); } else if ("account".equals(actorRole)) { - sql.append(""" - AND ( - (t.lifecycle NOT IN ('CLOSED_LOST', 'CANCELLED') - AND t.status <> 'cancelled' - AND t.sales_stage IN (:accountOrderStages)) - OR t.payment_status IN (:accountPendingStatuses) - OR (""") - .append(paymentOverduePredicate("t.ticket_id")) - .append(")) "); - params.addValue("accountOrderStages", ACCOUNT_ORDER_STAGE_SCOPE); - params.addValue("accountPendingStatuses", ACCOUNT_PENDING_PAYMENT_STATUSES); + sql.append(" AND ").append(accountScopePredicate()).append(' '); + addAccountScopeParams(params); } } + /** + * Account's list scope as a parenthesised predicate over {@code t} ({@code sales.ticket}): the one text + * both the account deal list and the dashboard totals run. Needs {@link #addAccountScopeParams}. + */ + public static String accountScopePredicate() { + return """ + ( + (t.lifecycle NOT IN ('CLOSED_LOST', 'CANCELLED') + AND t.status <> 'cancelled' + AND t.sales_stage IN (:accountOrderStages)) + OR t.payment_status IN (:accountPendingStatuses) + OR (""" + paymentOverduePredicate("t.ticket_id") + "))\n"; + } + + public static void addAccountScopeParams(MapSqlParameterSource params) { + params.addValue("accountOrderStages", ACCOUNT_ORDER_STAGE_SCOPE); + params.addValue("accountPendingStatuses", ACCOUNT_PENDING_PAYMENT_STATUSES); + } + /** * Is this deal inside the account role's list scope? The single-deal twin of the list * predicate: it runs the SAME {@link #appendRoleScope} fragment (and, like the list since the @@ -1247,7 +1257,8 @@ private TicketSummaryDto enrichSummary(TicketSummaryDto s) { facts.commissionRecorded(), s.reopenedAt(), s.reopenCount(), s.quotationOnly(), due == null ? null : due.date(), due == null ? null : due.basis(), due == null ? null : due.creditDays(), - facts.liveDirectQuotation(), facts.quotedRecipients()); + facts.liveDirectQuotation(), facts.quotedRecipients(), + "DEPOSIT_NOTICE_ISSUED".equals(s.paymentStatus()) ? facts.depositDue() : null); } /** @@ -1275,18 +1286,23 @@ SELECT COUNT(*) FROM sales.attachment * .hasActiveCommissionForTicket} always agree. If you change one predicate you MUST change the * other, and that test is what catches it if you forget. */ - private static final String HAS_RECORDED_COMMISSION = """ + public static String hasRecordedCommission(String ticketIdRef) { + return """ EXISTS( SELECT 1 FROM sales.commission_record - WHERE source_ticket_id = :ticketId + WHERE source_ticket_id = %s AND kind = 'SALE' AND status NOT IN ('VOID', 'REJECTED') ) - """; + """.formatted(ticketIdRef); + } + + private static final String HAS_RECORDED_COMMISSION = hasRecordedCommission(":ticketId"); private record CommissionAndLiveQuotation(boolean commissionRecorded, LiveDirectQuotationDto liveDirectQuotation, - Set quotedRecipients) {} + Set quotedRecipients, + BigDecimal depositDue) {} /** A Postgres {@code text[]} as a {@link Set}; empty for SQL NULL, which is what * {@code array_agg} returns over no rows. */ @@ -1316,7 +1332,10 @@ private CommissionAndLiveQuotation recordedCommissionAndLiveDirectQuotation(long (SELECT array_agg(DISTINCT r.recipient_type) FROM sales.quotation r WHERE r.ticket_id = :ticketId - AND r.doc_status <> 'CANCELLED') AS quoted_recipients + AND r.doc_status <> 'CANCELLED') AS quoted_recipients, + (SELECT d.total_payable FROM sales.deposit_notice d + WHERE d.ticket_id = :ticketId AND d.status = 'ISSUED' + ORDER BY d.version DESC, d.deposit_notice_id DESC LIMIT 1) AS deposit_due FROM (SELECT 1) AS one LEFT JOIN LATERAL ( """ + LIVE_DIRECT_QUOTATION_SELECT + """ @@ -1328,7 +1347,7 @@ LEFT JOIN LATERAL ( : new LiveDirectQuotationDto(quotationId, rs.getString("number"), rs.getString("doc_status"), rs.getString("recipient_type")); return new CommissionAndLiveQuotation(rs.getBoolean("commission_recorded"), live, - readTextArray(rs, "quoted_recipients")); + readTextArray(rs, "quoted_recipients"), rs.getBigDecimal("deposit_due")); }); } @@ -1737,7 +1756,7 @@ private static String issuedDocumentsPayable(String ticketIdRef) { * bigger SQL statement — see {@link #appendRoleScope}). Single source of truth so the two * contexts can never drift apart; this is the exact SQL {@link #payableAmount} always ran. */ - private static String payableAmountSelect(String ticketIdRef) { + public static String payableAmountSelect(String ticketIdRef) { return payableAmountSelect(ticketIdRef, true); } @@ -1906,14 +1925,22 @@ public Optional paymentDue(long ticketId) { } /** Full "amount paid" SELECT statement — see {@link #payableAmountSelect} for the correlation contract. */ - private static String paidAmountSelect(String ticketIdRef) { + public static String paidAmountSelect(String ticketIdRef) { return """ - SELECT COALESCE(SUM(CASE WHEN kind = 'ADJUSTMENT' THEN -amount ELSE amount END), 0) + SELECT %2$s FROM sales.payment_receipt WHERE ticket_id = %1$s - """.formatted(ticketIdRef); + """.formatted(ticketIdRef, SIGNED_RECEIPT_SUM); } + /** + * The one sign rule for money received: DEPOSIT and BALANCE add, ADJUSTMENT (refund / credit note) + * subtracts. Shared by the per-deal paid amount and the dashboard's company-wide cash received so + * the two can never count a receipt differently. + */ + public static final String SIGNED_RECEIPT_SUM = + "COALESCE(SUM(CASE WHEN kind = 'ADJUSTMENT' THEN -amount ELSE amount END), 0)"; + public record DepositNoticePaymentInfo(long id, BigDecimal depositAmount, BigDecimal totalPayable) {} public Optional latestIssuedDepositNotice(long ticketId) { diff --git a/backend/src/main/java/th/co/glr/hr/ticket/TicketService.java b/backend/src/main/java/th/co/glr/hr/ticket/TicketService.java index 1499987a7..0b73210ce 100644 --- a/backend/src/main/java/th/co/glr/hr/ticket/TicketService.java +++ b/backend/src/main/java/th/co/glr/hr/ticket/TicketService.java @@ -753,7 +753,7 @@ private TicketSummaryDto withCustomerAndProject(TicketSummaryDto s, String custo s.winProbabilityOverride(), s.designerName(), s.ownerName(), s.buyerName(), s.stale(), s.commissionRecorded(), s.reopenedAt(), s.reopenCount(), s.quotationOnly(), s.paymentDueDate(), s.paymentDueBasis(), s.paymentDueCreditDays(), - s.liveDirectQuotation(), s.quotedRecipients()); + s.liveDirectQuotation(), s.quotedRecipients(), s.depositDue()); } /** diff --git a/backend/src/main/java/th/co/glr/hr/ticket/TicketSummaryDto.java b/backend/src/main/java/th/co/glr/hr/ticket/TicketSummaryDto.java index b186debde..db25108e3 100644 --- a/backend/src/main/java/th/co/glr/hr/ticket/TicketSummaryDto.java +++ b/backend/src/main/java/th/co/glr/hr/ticket/TicketSummaryDto.java @@ -154,8 +154,50 @@ public record TicketSummaryDto( * in {@code mockApi.js}'s ticket summary at the same time. */ @JsonIgnore - Set quotedRecipients + Set quotedRecipients, + /** + * What the customer is asked to pay NOW as the deposit: the VAT-inclusive {@code total_payable} + * of the newest ISSUED deposit notice, while the deal is waiting on it + * ({@code paymentStatus = DEPOSIT_NOTICE_ISSUED}); {@code null} otherwise. Account's รอรับมัดจำ + * tile shows this rather than the whole-deal {@link #amountOutstanding}. Filled by + * {@code TicketRepository#enrichSummary} in the same statement as {@link #commissionRecorded}; + * nulled by {@link #withoutMoney()} (it is customer pricing). + */ + BigDecimal depositDue ) { + /** The shape before {@link #depositDue}: it defaults to {@code null}. */ + public TicketSummaryDto( + long id, String code, String type, String title, String status, String priority, + long createdById, String createdByName, Long assignedToId, String assignedToName, + String customerName, Long customerId, Long projectId, String projectName, + Long contactId, String contactName, String note, + Instant createdAt, Instant updatedAt, Instant closedAt, int itemCount, boolean hasEdits, + String paymentStatus, String fulfillmentStatus, + String salesStage, String lostReason, Instant lostAt, Instant stageUpdatedAt, + String lifecycle, String tenderRequirement, String depositPolicy, String depositPolicyReason, + String entryChannel, LocalDate billingDate, LocalDate dueDate, Integer creditTermDays, + LocalDate lastFollowUpAt, LocalDate nextFollowUpAt, String paymentStage, + BigDecimal amountPayable, BigDecimal amountPaid, BigDecimal amountOutstanding, boolean overdue, + Instant closeConfirmedAt, String closeConfirmedByName, boolean invoiceOnFile, + String cancelReason, Instant cancelledAt, + Integer winProbabilityOverride, String designerName, String ownerName, String buyerName, + boolean stale, boolean commissionRecorded, Instant reopenedAt, int reopenCount, + boolean quotationOnly, LocalDate paymentDueDate, String paymentDueBasis, + Integer paymentDueCreditDays, LiveDirectQuotationDto liveDirectQuotation, + Set quotedRecipients + ) { + this(id, code, type, title, status, priority, createdById, createdByName, assignedToId, + assignedToName, customerName, customerId, projectId, projectName, contactId, contactName, + note, createdAt, updatedAt, closedAt, itemCount, hasEdits, paymentStatus, fulfillmentStatus, + salesStage, lostReason, lostAt, stageUpdatedAt, lifecycle, tenderRequirement, depositPolicy, + depositPolicyReason, entryChannel, billingDate, dueDate, creditTermDays, lastFollowUpAt, + nextFollowUpAt, paymentStage, amountPayable, amountPaid, amountOutstanding, overdue, + closeConfirmedAt, closeConfirmedByName, invoiceOnFile, cancelReason, cancelledAt, + winProbabilityOverride, designerName, ownerName, buyerName, stale, commissionRecorded, + reopenedAt, reopenCount, quotationOnly, paymentDueDate, paymentDueBasis, + paymentDueCreditDays, liveDirectQuotation, quotedRecipients, null); + } + /** The shape before the derived payment-due fields existed: they default to "no structured term". */ public TicketSummaryDto( long id, String code, String type, String title, String status, String priority, @@ -318,7 +360,7 @@ public TicketSummaryDto withCustomerAndProject(String frozenCustomerName, String amountOutstanding, overdue, closeConfirmedAt, closeConfirmedByName, invoiceOnFile, cancelReason, cancelledAt, winProbabilityOverride, designerName, ownerName, buyerName, stale, commissionRecorded, reopenedAt, reopenCount, quotationOnly, paymentDueDate, paymentDueBasis, - paymentDueCreditDays, liveDirectQuotation, quotedRecipients); + paymentDueCreditDays, liveDirectQuotation, quotedRecipients, depositDue); } /** diff --git a/backend/src/test/java/th/co/glr/hr/dashboard/DashboardMoneyTotalsIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/dashboard/DashboardMoneyTotalsIntegrationTest.java new file mode 100644 index 000000000..e3b50d4f9 --- /dev/null +++ b/backend/src/test/java/th/co/glr/hr/dashboard/DashboardMoneyTotalsIntegrationTest.java @@ -0,0 +1,173 @@ +package th.co.glr.hr.dashboard; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.InputStream; +import java.math.BigDecimal; +import java.sql.Timestamp; +import java.time.LocalDate; +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 server totals behind the money tiles and /finance's คงค้างรวม: {@code financeOutstanding} (outstanding + * on deals in account's list scope that have an account to-do) and {@code forecastValue} (payable of live + * deals before ORDER_RECEIVED), summed in SQL over EVERY deal -- not over the first page of /api/tickets. + * + *

The deal shapes come from {@code dashboard/money-cases.json}, the same table + * {@code moneyCases.test.js} checks the frontend rule (account scope + nextAccountAction) against, so the + * SQL and the JS cannot drift apart unnoticed. Each case is built, the totals are read before and after, + * and the delta must be its outstanding / payable or nothing. + */ +class DashboardMoneyTotalsIntegrationTest extends AbstractPostgresIntegrationTest { + private static final ZoneId BKK = ZoneId.of("Asia/Bangkok"); + + private DashboardRepository dashboard; + private TicketRepository tickets; + private long repId; + private int seq; + + @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-MT', 'Sales MT') 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-MT', 'EMP-MT', :divisionId, TRUE) RETURNING employee_id + """, Map.of("divisionId", divisionId), Number.class).longValue(); + } + + @Test + void everyCaseInTheSharedTableMovesTheTotalsExactlyAsExpected() throws Exception { + JsonNode table; + try (InputStream in = getClass().getResourceAsStream("/dashboard/money-cases.json")) { + table = new ObjectMapper().readTree(in); + } + BigDecimal payable = table.get("payable").decimalValue(); + + for (JsonNode c : table.get("cases")) { + BigDecimal outstandingBefore = dashboard.financeOutstanding(); + BigDecimal forecastBefore = dashboard.forecastValue(); + + BigDecimal paid = c.get("paid").decimalValue(); + buildDeal(c, payable); + BigDecimal expectedOutstanding = c.get("countsInOutstanding").asBoolean() + ? payable.subtract(paid).max(BigDecimal.ZERO) : BigDecimal.ZERO; + BigDecimal expectedForecast = c.get("countsInForecast").asBoolean() ? payable : BigDecimal.ZERO; + assertThat(dashboard.financeOutstanding().subtract(outstandingBefore)) + .as("financeOutstanding delta: %s", c.get("name").asText()).isEqualByComparingTo(expectedOutstanding); + assertThat(dashboard.forecastValue().subtract(forecastBefore)) + .as("forecastValue delta: %s", c.get("name").asText()).isEqualByComparingTo(expectedForecast); + } + } + + /** Guards the builder: each fixture is the shape its case names, as /finance's own row sees it. */ + @Test + void theBuiltDealsAreTheShapesTheCasesName() throws Exception { + JsonNode table; + try (InputStream in = getClass().getResourceAsStream("/dashboard/money-cases.json")) { + table = new ObjectMapper().readTree(in); + } + BigDecimal payable = table.get("payable").decimalValue(); + for (JsonNode c : table.get("cases")) { + long id = buildDeal(c, payable); + var row = tickets.findSummaryById(id).orElseThrow(); + assertThat(row.amountPayable()).as("payable: %s", c.get("name").asText()).isEqualByComparingTo(payable); + assertThat(row.overdue()).as("overdue: %s", c.get("name").asText()).isEqualTo(c.get("overdue").asBoolean()); + assertThat(row.commissionRecorded()).as("commission: %s", c.get("name").asText()) + .isEqualTo(c.get("commission").asBoolean()); + } + } + + @Test + void totalsIncludeEveryDeal_notJustTheFirstFiveHundred() { + int deals = 505; // /api/tickets pages at 500 + BigDecimal payable = new BigDecimal("107000.00"); + for (int i = 0; i < deals; i++) { + long id = newDeal(); + acceptedQuotation(id); + // a deposit notice is out, before ORDER_RECEIVED: counted by both totals + jdbc.update(""" + UPDATE sales.ticket SET status = 'quotation_issued', payment_status = 'DEPOSIT_NOTICE_ISSUED', sales_stage = 'QUOTE_BUYER' + WHERE ticket_id = :id""", Map.of("id", id)); + } + + assertThat(dashboard.financeOutstanding()).isEqualByComparingTo(payable.multiply(BigDecimal.valueOf(deals))); + assertThat(dashboard.forecastValue()).isEqualByComparingTo(payable.multiply(BigDecimal.valueOf(deals))); + } + + // ── builders ──────────────────────────────────────────────────────────────────────────── + + private long newDeal() { + seq++; + return tickets.create( + new CreateTicketRequest("ดีลทดสอบ " + seq, "NORMAL", "ลูกค้าทดสอบ", null, null, null, null, null, List.of()), + tickets.nextTicketCode(), repId, "EMP-MT"); + } + + /** A quotation whose subtotal is 100,000, so the VAT-inclusive payable is 107,000. */ + private void acceptedQuotation(long ticketId) { + long q = tickets.createQuotation(ticketId, "QT-MT-" + ticketId, repId, new BigDecimal("100000.00")).id(); + jdbc.update("UPDATE sales.quotation SET doc_status = 'ACCEPTED' WHERE quotation_id = :q", Map.of("q", q)); + } + + private long buildDeal(JsonNode c, BigDecimal payable) { + long id = newDeal(); + acceptedQuotation(id); + MapSqlParameterSource p = new MapSqlParameterSource("id", id) + .addValue("lifecycle", c.get("lifecycle").asText()) + .addValue("status", c.get("status").asText()) + .addValue("stage", c.get("salesStage").asText()) + .addValue("payment", c.get("paymentStatus").isNull() ? null : c.get("paymentStatus").asText()) + .addValue("policy", c.get("depositPolicy").asText()); + jdbc.update(""" + UPDATE sales.ticket SET lifecycle = :lifecycle, status = :status, sales_stage = :stage, + payment_status = :payment, deposit_policy = :policy + WHERE ticket_id = :id""", p); + BigDecimal paid = c.get("paid").decimalValue(); + if (paid.signum() > 0) { + jdbc.update(""" + INSERT INTO sales.payment_receipt (ticket_id, kind, amount, currency, received_at, recorded_by, receipt_ref) + VALUES (:id, 'DEPOSIT', :a, 'THB', now(), :by, :ref)""", new MapSqlParameterSource("id", id) + .addValue("a", paid).addValue("by", repId).addValue("ref", "MT-" + id)); + } + if (c.get("overdue").asBoolean()) { + // credit 30 days from a delivery 31 days ago -> the balance is a day late + jdbc.update("UPDATE sales.quotation SET remainder_mode = 'CREDIT', credit_days = 30 WHERE ticket_id = :id", + Map.of("id", id)); + 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(LocalDate.now(BKK).minusDays(31).atTime(12, 0).atZone(BKK).toInstant())) + .addValue("by", repId)); + } + if (c.get("commission").asBoolean()) { + long invoice = jdbc.queryForObject(""" + INSERT INTO sales.invoice_details (invoice_number, invoice_date, gross_amount, evidence_provenance) + VALUES (:n, CURRENT_DATE, 1000.00, 'PRE_ERP_PAPER_APPROVAL') RETURNING invoice_id""", + Map.of("n", "MT-INV-" + id), Long.class); + jdbc.update(""" + INSERT INTO sales.commission_record + (invoice_id, source_ticket_id, sales_rep_id, submitted_by_id, kind, status, + payroll_month, actual_received, commissionable_base) + VALUES (:inv, :id, :rep, :rep, 'SALE', 'SUBMITTED', + date_trunc('month', CURRENT_DATE)::date, 1000.00, 1000.00)""", + new MapSqlParameterSource("inv", invoice).addValue("id", id).addValue("rep", repId)); + } + return id; + } +} 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 5baa20e40..3292bfc0d 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 @@ -5,6 +5,7 @@ import java.math.BigDecimal; import java.time.LocalDate; import java.time.OffsetDateTime; +import java.util.List; import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -132,6 +133,56 @@ void aggregatesDashboardSectionsWithScopes() { assertThat(repository.headcount(DashboardQueryScope.self(salesEmployee)).active()).isNull(); } + @Test + void cashReceivedByRepSplitsTheSameMonthsCashByTheDealsCreator() { + long division = insertDivision("SA", "Sales"); + long repA = insertEmployee("EMP-001", division, true); + long repB = insertEmployee("EMP-002", division, true); + long unnamed = jdbc.queryForObject(""" + INSERT INTO hr.employee (employee_code, first_name_th, division_id, is_active) + VALUES ('EMP-003', '', :d, TRUE) RETURNING employee_id + """, Map.of("d", division), Number.class).longValue(); + insertTicket("PR-1", "quotation_issued", repA, LocalDate.of(2026, 6, 1)); + insertTicket("PR-2", "quotation_issued", repA, LocalDate.of(2026, 6, 1)); + insertTicket("PR-3", "quotation_issued", repB, LocalDate.of(2026, 6, 1)); + insertTicket("PR-4", "quotation_issued", unnamed, LocalDate.of(2026, 6, 1)); + // a different rep who happens to share EMP-001's name: kept as its own row + long namesake = jdbc.queryForObject(""" + INSERT INTO hr.employee (employee_code, first_name_th, division_id, is_active) + VALUES ('EMP-004', 'EMP-001', :d, TRUE) RETURNING employee_id + """, Map.of("d", division), Number.class).longValue(); + insertTicket("PR-5", "quotation_issued", namesake, LocalDate.of(2026, 6, 1)); + insertReceipt("PR-5", "DEPOSIT", "50.00", "2026-07-05T09:00:00+07:00", repA); + + insertReceipt("PR-1", "DEPOSIT", "85418.10", "2026-07-02T10:00:00+07:00", repA); + insertReceipt("PR-1", "ADJUSTMENT", "1000.00", "2026-07-20T10:00:00+07:00", repA); + // two of repA's deals add up; 00:30 on 1 July in Bangkok is still July's + insertReceipt("PR-2", "BALANCE", "2000.00", "2026-06-30T17:30:00Z", repA); + insertReceipt("PR-3", "DEPOSIT", "10000.00", "2026-07-03T09:00:00+07:00", repB); + insertReceipt("PR-4", "BALANCE", "700.00", "2026-07-04T09:00:00+07:00", repB); + // outside the Bangkok month: last minute of June, first minute of August + insertReceipt("PR-3", "BALANCE", "5000.00", "2026-06-30T23:59:59+07:00", repB); + insertReceipt("PR-1", "BALANCE", "300.00", "2026-08-01T00:00:00+07:00", repA); + + List rows = repository.cashReceivedByRep(LocalDate.of(2026, 7, 1)); + + assertThat(rows).extracting(CashByRepDto::repName) + .containsExactlyInAnyOrder("EMP-001", "EMP-001", "EMP-002", "ไม่ระบุผู้ขาย"); + assertThat(rows.stream().filter(r -> r.repName().equals("EMP-001")).map(CashByRepDto::amount)) + .usingComparatorForType(BigDecimal::compareTo, BigDecimal.class) + .containsExactlyInAnyOrder(new BigDecimal("86418.10"), new BigDecimal("50.00")); + assertThat(amountOf(rows, "EMP-002")).isEqualByComparingTo("10000.00"); + assertThat(amountOf(rows, "ไม่ระบุผู้ขาย")).isEqualByComparingTo("700.00"); + // the rows are the whole month's cash (the service totals them for cashReceivedThisMonth) + assertThat(rows.stream().map(CashByRepDto::amount).reduce(BigDecimal.ZERO, BigDecimal::add)) + .isEqualByComparingTo("97168.10"); + assertThat(repository.cashReceivedByRep(LocalDate.of(2026, 5, 1))).isEmpty(); + } + + private static BigDecimal amountOf(List rows, String repName) { + return rows.stream().filter(r -> r.repName().equals(repName)).findFirst().orElseThrow().amount(); + } + private long insertDivision(String code, String name) { Number id = jdbc.queryForObject(""" INSERT INTO hr.division (source_code, name_th) @@ -218,6 +269,16 @@ private void insertAttendance( .addValue("absent", absent)); } + private void insertReceipt(String ticketCode, String kind, String amount, String receivedAt, long recordedBy) { + jdbc.update(""" + INSERT INTO sales.payment_receipt (ticket_id, kind, amount, currency, received_at, recorded_by) + VALUES ((SELECT ticket_id FROM sales.ticket WHERE code = :code), :kind, :amount, 'THB', + :receivedAt, :by) + """, new MapSqlParameterSource() + .addValue("code", ticketCode).addValue("kind", kind).addValue("amount", new BigDecimal(amount)) + .addValue("receivedAt", OffsetDateTime.parse(receivedAt)).addValue("by", recordedBy)); + } + private void insertTicket(String code, String status, long createdBy, LocalDate createdDate) { jdbc.update(""" INSERT INTO sales.ticket ( diff --git a/backend/src/test/java/th/co/glr/hr/dashboard/DashboardServiceTest.java b/backend/src/test/java/th/co/glr/hr/dashboard/DashboardServiceTest.java index 6ff05f2e5..bc6cdbf3a 100644 --- a/backend/src/test/java/th/co/glr/hr/dashboard/DashboardServiceTest.java +++ b/backend/src/test/java/th/co/glr/hr/dashboard/DashboardServiceTest.java @@ -5,16 +5,21 @@ import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import java.math.BigDecimal; import java.time.Clock; import java.time.Instant; import java.time.LocalDate; import java.time.OffsetDateTime; import java.time.ZoneId; +import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.mockito.ArgumentCaptor; import th.co.glr.hr.auth.UserPrincipal; @@ -186,6 +191,82 @@ void salesSummaryKeepsTicketsScopedToOwnEmployee() { assertThat(ticketScope.getValue().employeeId()).isEqualTo(30L); } + @ParameterizedTest + @ValueSource(strings = {"account", "ceo", "sales_manager"}) + void cashReceivedThisMonthIsTheSumOfThePerRepRowsForTheMoneyRoles(String role) { + when(repository.cashReceivedByRep(LocalDate.of(2026, 7, 1))).thenReturn(List.of( + new CashByRepDto("สมชาย ขายดี", new BigDecimal("90000.50")), + new CashByRepDto("สมหญิง ขายเก่ง", new BigDecimal("35000.00")))); + + DashboardSummaryDto summary = service.summary(user(role, 40L, null, false)); + + assertThat(summary.cashReceivedThisMonth()).isEqualByComparingTo("125000.50"); + assertThat(summary.cashReceivedByRep()).hasSize(2); + } + + @ParameterizedTest + @ValueSource(strings = {"account", "ceo", "sales_manager"}) + void aMonthWithNoReceiptsIsZeroNotNull(String role) { + when(repository.cashReceivedByRep(any())).thenReturn(List.of()); + + DashboardSummaryDto summary = service.summary(user(role, 40L, null, false)); + + assertThat(summary.cashReceivedThisMonth()).isEqualByComparingTo("0"); + assertThat(summary.cashReceivedByRep()).isEmpty(); + } + + @ParameterizedTest + @ValueSource(strings = {"hr", "import", "sales", "employee"}) + void cashReceivedIsNullForEveryOtherRoleAndNeverQueried(String role) { + when(repository.cashReceivedByRep(any())) + .thenReturn(List.of(new CashByRepDto("สมชาย ขายดี", new BigDecimal("125000.50")))); + + DashboardSummaryDto summary = service.summary(user(role, 41L, null, false)); + + assertThat(summary.cashReceivedThisMonth()).isNull(); + assertThat(summary.cashReceivedByRep()).isNull(); + verify(repository, never()).cashReceivedByRep(any()); + } + + @Test + void cashReceivedUsesTheBangkokMonthEvenWhenUtcIsStillLastMonth() { + // 2026-06-30T20:30Z is already 2026-07-01 03:30 in Bangkok. + DashboardService boundary = new DashboardService( + repository, Clock.fixed(Instant.parse("2026-06-30T20:30:00Z"), BANGKOK)); + when(repository.cashReceivedByRep(LocalDate.of(2026, 7, 1))) + .thenReturn(List.of(new CashByRepDto("สมชาย ขายดี", new BigDecimal("1.00")))); + + DashboardSummaryDto summary = boundary.summary(user("account", 42L, null, false)); + + assertThat(summary.cashReceivedThisMonth()).isEqualByComparingTo("1.00"); + } + + @ParameterizedTest + @ValueSource(strings = {"account", "ceo", "sales_manager"}) + void financeOutstandingAndForecastAreServerTotalsForTheMoneyRoles(String role) { + when(repository.financeOutstanding()).thenReturn(new BigDecimal("293545.25")); + when(repository.forecastValue()).thenReturn(new BigDecimal("80000.25")); + + DashboardSummaryDto summary = service.summary(user(role, 43L, null, false)); + + assertThat(summary.financeOutstanding()).isEqualByComparingTo("293545.25"); + assertThat(summary.forecastValue()).isEqualByComparingTo("80000.25"); + } + + @ParameterizedTest + @ValueSource(strings = {"hr", "import", "sales", "employee"}) + void financeOutstandingAndForecastAreNullForOtherRolesAndNeverQueried(String role) { + when(repository.financeOutstanding()).thenReturn(new BigDecimal("1.00")); + when(repository.forecastValue()).thenReturn(new BigDecimal("1.00")); + + DashboardSummaryDto summary = service.summary(user(role, 44L, null, false)); + + assertThat(summary.financeOutstanding()).isNull(); + assertThat(summary.forecastValue()).isNull(); + verify(repository, never()).financeOutstanding(); + verify(repository, never()).forecastValue(); + } + private UserPrincipal user(String role, Long employeeId, Long divisionId, boolean manager) { return new UserPrincipal( employeeId == null ? 99L : employeeId, diff --git a/backend/src/test/java/th/co/glr/hr/ticket/TicketDepositDueIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/ticket/TicketDepositDueIntegrationTest.java new file mode 100644 index 000000000..6916afff8 --- /dev/null +++ b/backend/src/test/java/th/co/glr/hr/ticket/TicketDepositDueIntegrationTest.java @@ -0,0 +1,108 @@ +package th.co.glr.hr.ticket; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.math.BigDecimal; +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; + +/** + * {@code TicketSummaryDto.depositDue}: the deposit-notice total the customer is asked to pay now + * (VAT-inclusive {@code total_payable} of the newest ISSUED notice) while the deal is waiting on + * that deposit ({@code DEPOSIT_NOTICE_ISSUED}); {@code null} otherwise and for the import role. + * Account's รอรับมัดจำ dashboard tile shows this, not the whole-deal outstanding. + */ +class TicketDepositDueIntegrationTest extends AbstractPostgresIntegrationTest { + private TicketRepository tickets; + private long repId; + + @BeforeEach + void seed() { + tickets = new TicketRepository(jdbc); + long divisionId = jdbc.queryForObject(""" + INSERT INTO hr.division (source_code, name_th) VALUES ('SA-DD', 'Sales DD') 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-DD', 'EMP-DD', :divisionId, TRUE) RETURNING employee_id + """, Map.of("divisionId", divisionId), Number.class).longValue(); + } + + @Test + void waitingOnTheDeposit_serves_theIssuedNoticeTotalPayable_onTheListRowAndTheSingleRead() { + long id = deal("DEPOSIT_NOTICE_ISSUED"); + notice(id, 1, "ISSUED", "85418.10"); + + assertThat(listRow(id).depositDue()).isEqualByComparingTo("85418.10"); + assertThat(tickets.findSummaryById(id).orElseThrow().depositDue()).isEqualByComparingTo("85418.10"); + } + + @Test + void severalNotices_serveTheNewestIssuedOne_notADraftOrSupersededOne() { + long id = deal("DEPOSIT_NOTICE_ISSUED"); + notice(id, 1, "SUPERSEDED", "90000.00"); + notice(id, 2, "ISSUED", "85418.10"); + notice(id, 3, "DRAFT", "70000.00"); + + assertThat(listRow(id).depositDue()).isEqualByComparingTo("85418.10"); + } + + @Test + void isNull_whenThereIsNoIssuedNotice() { + long onlyDraft = deal("DEPOSIT_NOTICE_ISSUED"); + notice(onlyDraft, 1, "DRAFT", "85418.10"); + long none = deal("DEPOSIT_NOTICE_ISSUED"); + + assertThat(listRow(onlyDraft).depositDue()).isNull(); + assertThat(listRow(none).depositDue()).isNull(); + } + + @Test + void isNull_onceTheDealIsPastTheDepositStep_evenWithAnIssuedNoticeOnFile() { + for (String status : new String[] {"DEPOSIT_PAID", "AWAITING_FINAL_PAYMENT", "FULLY_PAID", "CUSTOMER_CONFIRMED"}) { + long id = deal(status); + notice(id, 1, "ISSUED", "85418.10"); + + assertThat(listRow(id).depositDue()).as(status).isNull(); + assertThat(tickets.findSummaryById(id).orElseThrow().depositDue()).as(status).isNull(); + } + } + + @Test + void importRoleSummary_hasNoDepositDue_butTheSourceRowDid() { + long id = deal("DEPOSIT_NOTICE_ISSUED"); + notice(id, 1, "ISSUED", "85418.10"); + TicketSummaryDto row = listRow(id); + + assertThat(row.depositDue()).isNotNull(); + assertThat(row.withoutMoney().depositDue()).isNull(); + } + + // ── helpers ───────────────────────────────────────────────────────────────────────────── + + private long deal(String paymentStatus) { + long id = tickets.create( + new CreateTicketRequest("ดีลทดสอบ", "NORMAL", "ลูกค้าทดสอบ", null, null, null, null, null, List.of()), + tickets.nextTicketCode(), repId, "EMP-DD"); + tickets.updatePaymentStatusUnchecked(id, paymentStatus); + return id; + } + + /** 30% deposit on a 213,545.25 deal would be 64,063.58; the figure that matters is the stored total_payable. */ + private void notice(long ticketId, int version, String status, String totalPayable) { + jdbc.update(""" + INSERT INTO sales.deposit_notice (ticket_id, version, status, customer_name, deposit_percent, + subtotal, deposit_amount, vat_percent, vat_amount, total_payable) + VALUES (:id, :v, :s, 'x', 0.3, 100000.00, 30000.00, 0.07, 2100.00, :total)""", + new MapSqlParameterSource("id", ticketId).addValue("v", version).addValue("s", status) + .addValue("total", new BigDecimal(totalPayable))); + } + + private TicketSummaryDto listRow(long ticketId) { + return tickets.findSummaries(null, null).stream().filter(r -> r.id() == ticketId).findFirst().orElseThrow(); + } +} diff --git a/backend/src/test/resources/dashboard/money-cases.json b/backend/src/test/resources/dashboard/money-cases.json new file mode 100644 index 000000000..e73206e6c --- /dev/null +++ b/backend/src/test/resources/dashboard/money-cases.json @@ -0,0 +1,305 @@ +{ + "comment": "Shared by dashboardMoneyCases.test.js (JS rule) and DashboardMoneyTotalsIntegrationTest (SQL). Every deal has payable 107000 (quotation subtotal 100000 + 7% VAT); outstanding = max(107000 - paid, 0).", + "payable": 107000, + "cases": [ + { + "name": "deposit-pending: whole outstanding", + "lifecycle": "ACTIVE", + "status": "quotation_issued", + "salesStage": "ORDER_RECEIVED", + "paymentStatus": "DEPOSIT_NOTICE_ISSUED", + "depositPolicy": "REQUIRED", + "overdue": false, + "commission": false, + "paid": 0, + "countsInOutstanding": true, + "countsInForecast": false + }, + { + "name": "deposit-notice issued before ORDER_RECEIVED", + "lifecycle": "ACTIVE", + "status": "quotation_issued", + "salesStage": "QUOTE_BUYER", + "paymentStatus": "DEPOSIT_NOTICE_ISSUED", + "depositPolicy": "REQUIRED", + "overdue": false, + "commission": false, + "paid": 0, + "countsInOutstanding": true, + "countsInForecast": true + }, + { + "name": "deposit paid", + "lifecycle": "ACTIVE", + "status": "quotation_issued", + "salesStage": "DELIVERY_SCHEDULING", + "paymentStatus": "DEPOSIT_PAID", + "depositPolicy": "REQUIRED", + "overdue": false, + "commission": false, + "paid": 40000, + "countsInOutstanding": true, + "countsInForecast": false + }, + { + "name": "awaiting final payment", + "lifecycle": "ACTIVE", + "status": "quotation_issued", + "salesStage": "DELIVERED", + "paymentStatus": "AWAITING_FINAL_PAYMENT", + "depositPolicy": "REQUIRED", + "overdue": false, + "commission": false, + "paid": 50000, + "countsInOutstanding": true, + "countsInForecast": false + }, + { + "name": "deposit bypassed (NOT_REQUIRED) at ORDER_RECEIVED", + "lifecycle": "ACTIVE", + "status": "quotation_issued", + "salesStage": "ORDER_RECEIVED", + "paymentStatus": null, + "depositPolicy": "NOT_REQUIRED", + "overdue": false, + "commission": false, + "paid": 0, + "countsInOutstanding": true, + "countsInForecast": false + }, + { + "name": "deposit waived, customer confirmed, at PROCUREMENT", + "lifecycle": "ACTIVE", + "status": "quotation_issued", + "salesStage": "PROCUREMENT", + "paymentStatus": "CUSTOMER_CONFIRMED", + "depositPolicy": "WAIVED", + "overdue": false, + "commission": false, + "paid": 0, + "countsInOutstanding": true, + "countsInForecast": false + }, + { + "name": "credit customer at ORDER_RECEIVED", + "lifecycle": "ACTIVE", + "status": "quotation_issued", + "salesStage": "ORDER_RECEIVED", + "paymentStatus": null, + "depositPolicy": "CREDIT_CUSTOMER", + "overdue": false, + "commission": false, + "paid": 0, + "countsInOutstanding": true, + "countsInForecast": false + }, + { + "name": "deposit bypassed before ORDER_RECEIVED: out of account scope", + "lifecycle": "ACTIVE", + "status": "quotation_issued", + "salesStage": "QUOTE_BUYER", + "paymentStatus": null, + "depositPolicy": "NOT_REQUIRED", + "overdue": false, + "commission": false, + "paid": 0, + "countsInOutstanding": false, + "countsInForecast": true + }, + { + "name": "overdue before ORDER_RECEIVED: overdue is its own scope branch", + "lifecycle": "ACTIVE", + "status": "quotation_issued", + "salesStage": "QUOTE_BUYER", + "paymentStatus": "CUSTOMER_CONFIRMED", + "depositPolicy": "REQUIRED", + "overdue": true, + "commission": false, + "paid": 0, + "countsInOutstanding": true, + "countsInForecast": true + }, + { + "name": "DEMO-TKT-05 shape: CLOSED_PAID with commission recorded", + "lifecycle": "ACTIVE", + "status": "closed", + "salesStage": "CLOSED_PAID", + "paymentStatus": null, + "depositPolicy": "REQUIRED", + "overdue": false, + "commission": true, + "paid": 0, + "countsInOutstanding": false, + "countsInForecast": false + }, + { + "name": "CLOSED_PAID without a commission and money outstanding", + "lifecycle": "ACTIVE", + "status": "closed", + "salesStage": "CLOSED_PAID", + "paymentStatus": null, + "depositPolicy": "REQUIRED", + "overdue": false, + "commission": false, + "paid": 0, + "countsInOutstanding": true, + "countsInForecast": false + }, + { + "name": "cancelled deal that still has a to-do", + "lifecycle": "CANCELLED", + "status": "quotation_issued", + "salesStage": "DELIVERY_SCHEDULING", + "paymentStatus": "DEPOSIT_PAID", + "depositPolicy": "REQUIRED", + "overdue": false, + "commission": false, + "paid": 40000, + "countsInOutstanding": false, + "countsInForecast": false + }, + { + "name": "lost before ORDER_RECEIVED", + "lifecycle": "CLOSED_LOST", + "status": "quotation_issued", + "salesStage": "QUOTE_BUYER", + "paymentStatus": null, + "depositPolicy": "NOT_REQUIRED", + "overdue": false, + "commission": false, + "paid": 0, + "countsInOutstanding": false, + "countsInForecast": false + }, + { + "name": "in scope but no account to-do", + "lifecycle": "ACTIVE", + "status": "quotation_issued", + "salesStage": "ORDER_RECEIVED", + "paymentStatus": "CUSTOMER_CONFIRMED", + "depositPolicy": "REQUIRED", + "overdue": false, + "commission": false, + "paid": 0, + "countsInOutstanding": false, + "countsInForecast": false + }, + { + "name": "plain active deal before ORDER_RECEIVED", + "lifecycle": "ACTIVE", + "status": "quotation_issued", + "salesStage": "QUOTE_BUYER", + "paymentStatus": null, + "depositPolicy": "REQUIRED", + "overdue": false, + "commission": false, + "paid": 0, + "countsInOutstanding": false, + "countsInForecast": true + }, + { + "name": "on hold before ORDER_RECEIVED", + "lifecycle": "ON_HOLD", + "status": "quotation_issued", + "salesStage": "QUOTE_BUYER", + "paymentStatus": null, + "depositPolicy": "REQUIRED", + "overdue": false, + "commission": false, + "paid": 0, + "countsInOutstanding": false, + "countsInForecast": false + }, + { + "name": "dormant before ORDER_RECEIVED", + "lifecycle": "DORMANT", + "status": "quotation_issued", + "salesStage": "QUOTE_BUYER", + "paymentStatus": null, + "depositPolicy": "REQUIRED", + "overdue": false, + "commission": false, + "paid": 0, + "countsInOutstanding": false, + "countsInForecast": false + }, + { + "name": "closed status before ORDER_RECEIVED", + "lifecycle": "ACTIVE", + "status": "closed", + "salesStage": "QUOTE_BUYER", + "paymentStatus": null, + "depositPolicy": "REQUIRED", + "overdue": false, + "commission": false, + "paid": 0, + "countsInOutstanding": false, + "countsInForecast": false + }, + { + "name": "cancelled status before ORDER_RECEIVED", + "lifecycle": "ACTIVE", + "status": "cancelled", + "salesStage": "QUOTE_BUYER", + "paymentStatus": null, + "depositPolicy": "REQUIRED", + "overdue": false, + "commission": false, + "paid": 0, + "countsInOutstanding": false, + "countsInForecast": false + }, + { + "name": "awaiting final payment but status document_issued: no to-do", + "lifecycle": "ACTIVE", + "status": "document_issued", + "salesStage": "DELIVERED", + "paymentStatus": "AWAITING_FINAL_PAYMENT", + "depositPolicy": "REQUIRED", + "overdue": false, + "commission": false, + "paid": 50000, + "countsInOutstanding": false, + "countsInForecast": false + }, + { + "name": "deposit notice issued but status closed: no to-do", + "lifecycle": "ACTIVE", + "status": "closed", + "salesStage": "ORDER_RECEIVED", + "paymentStatus": "DEPOSIT_NOTICE_ISSUED", + "depositPolicy": "REQUIRED", + "overdue": false, + "commission": false, + "paid": 0, + "countsInOutstanding": false, + "countsInForecast": false + }, + { + "name": "deposit paid but status closed: no to-do", + "lifecycle": "ACTIVE", + "status": "closed", + "salesStage": "PROCUREMENT", + "paymentStatus": "DEPOSIT_PAID", + "depositPolicy": "REQUIRED", + "overdue": false, + "commission": false, + "paid": 40000, + "countsInOutstanding": false, + "countsInForecast": false + }, + { + "name": "deposit bypassed but status document_issued: no to-do", + "lifecycle": "ACTIVE", + "status": "document_issued", + "salesStage": "ORDER_RECEIVED", + "paymentStatus": null, + "depositPolicy": "NOT_REQUIRED", + "overdue": false, + "commission": false, + "paid": 0, + "countsInOutstanding": false, + "countsInForecast": false + } + ] +} \ No newline at end of file diff --git a/docs/api/response-fields.json b/docs/api/response-fields.json index d79ad22d7..9dc45e43c 100644 --- a/docs/api/response-fields.json +++ b/docs/api/response-fields.json @@ -1,7 +1,7 @@ { "$comment" : "GENERATED by ResponseFieldContractTest — do not hand-edit. Regenerate: cd backend && ./mvnw -Dtest=ResponseFieldContractTest -Dresponse.fields.update=true test", "source" : "Schema.field pairs from springdoc /v3/api-docs response schemas, kept where the field name is read in frontend/src (excluding mockApi, hrApi, routes and tests)", - "count" : 1463, + "count" : 1468, "fields" : [ "Action.requiredFields", "Action.targetStage", @@ -129,6 +129,7 @@ "BillingNoteLineDto.sourceId", "BillingNoteLineDto.sourceType", "BillingNoteLineDto.ticketId", + "CashByRepDto.repName", "CatalogDto.sqmPerPiece", "Comment.authorName", "Comment.createdAt", @@ -239,9 +240,12 @@ "CustomerQuotationItemDto.requestedQuantity", "CustomerQuotationItemDto.requestedUnitBasis", "CustomerQuotationItemDto.salesDiscount", - "DashboardSummaryDto.closedThisMonth", + "DashboardSummaryDto.cashReceivedByRep", + "DashboardSummaryDto.cashReceivedThisMonth", "DashboardSummaryDto.divisionId", "DashboardSummaryDto.employeeId", + "DashboardSummaryDto.financeOutstanding", + "DashboardSummaryDto.forecastValue", "DashboardSummaryDto.latestPayrollPeriodId", "DashboardSummaryDto.pendingApprovals", "DashboardSummaryDto.totalOpen", @@ -556,6 +560,7 @@ "ImportDealDto.salesStage", "ImportDealItemDto.qtyDelivered", "ImportDealItemDto.qtySqm", + "ImportRequestCoverageResponse.hasUncoveredFactories", "ImportRequestDto.approvedByName", "ImportRequestDto.approvedDate", "ImportRequestDto.checkedByName", @@ -1101,6 +1106,7 @@ "PricingRequestSummaryDto.itemCount", "PricingRequestSummaryDto.omitContactHonorific", "PricingRequestSummaryDto.orderConfirmedAt", + "PricingRequestSummaryDto.parentPricingRequestId", "PricingRequestSummaryDto.paymentTermMode", "PricingRequestSummaryDto.printedByDisplayId", "PricingRequestSummaryDto.projectName", @@ -1390,7 +1396,6 @@ "TicketSummaryDto.buyerName", "TicketSummaryDto.closeConfirmedAt", "TicketSummaryDto.closeConfirmedByName", - "TicketSummaryDto.closedAt", "TicketSummaryDto.commissionRecorded", "TicketSummaryDto.contactId", "TicketSummaryDto.contactName", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index e60731fc4..29f7c062a 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -334,7 +334,7 @@ export function App() { dashboardSummary={dashboardSummary} /> ) : user.role === 'sales_manager' && SALES_ENABLED ? ( - + ) : user.role === 'sales' && SALES_ENABLED ? ( ) : user?.role === 'ceo' && SALES_ENABLED ? ( @@ -346,7 +346,7 @@ export function App() { ) : user.role === 'import' && SALES_ENABLED ? ( ) : user.role === 'account' && SALES_ENABLED ? ( - + ) : isDivisionManager(user) ? ( new Date(a.receivedAt) - new Date(b.receivedAt) || a.receiptId - b.receiptId); } +// DEPOSIT/BALANCE add, ADJUSTMENT (refund) subtracts — TicketRepository.SIGNED_RECEIPT_SUM's twin. +const signedReceiptSum = (sum, receipt) => + sum + (receipt.kind === 'ADJUSTMENT' ? -Number(receipt.amount) : Number(receipt.amount)); + function sumPaid(ticketId) { - return moneyValue(receiptsForTicket(ticketId).reduce((sum, receipt) => - sum + (receipt.kind === 'ADJUSTMENT' ? -Number(receipt.amount) : Number(receipt.amount)), 0)); + return moneyValue(receiptsForTicket(ticketId).reduce(signedReceiptSum, 0)); } function derivePaymentFields(ticket) { @@ -3866,9 +3870,52 @@ function derivePaymentFields(ticket) { amountPaid: paid, amountOutstanding: outstanding, overdue, + // Mirrors TicketSummaryDto#depositDue: newest ISSUED deposit notice's VAT-inclusive total while the + // deal waits on that deposit; null otherwise. + depositDue: ticket.paymentStatus === 'DEPOSIT_NOTICE_ISSUED' + ? (latestIssuedDepositNotice(ticket.id)?.totalPayable ?? null) + : null, }; } +// Mirrors DashboardRepository#financeOutstanding: outstanding on deals in account's list scope that have an +// account to-do (nextAccountAction as the account viewer). +function mockFinanceOutstanding() { + return moneyValue(db.tickets + .filter(accountListScopeIncludes) + .map((t) => ({ ...t, ...derivePaymentFields(t), commissionRecorded: hasRecordedCommission(t) })) + .filter((t) => nextAccountAction(t) != null) + .reduce((sum, t) => sum + Number(t.amountOutstanding ?? 0), 0)); +} + +// Mirrors DashboardRepository#forecastValue: payable of ACTIVE deals before ORDER_RECEIVED. +function mockForecastValue() { + return moneyValue(db.tickets + .filter((t) => (t.lifecycle ?? 'ACTIVE') === 'ACTIVE' && dealStageIndex(t.salesStage) < ACCOUNT_ORDER_STAGE_SCOPE_START + && t.status !== 'closed' && t.status !== 'cancelled') + .reduce((sum, t) => sum + Number(derivePaymentFields(t).amountPayable ?? 0), 0)); +} + +function mockReceiptsThisMonth() { + const bangkokMonth = (d) => new Date(d).toLocaleDateString('sv-SE', { timeZone: 'Asia/Bangkok' }).slice(0, 7); + const thisMonth = bangkokMonth(new Date()); + return (db.paymentReceipts ?? []).filter((r) => bangkokMonth(r.receivedAt) === thisMonth); +} + +// Mirrors DashboardRepository#cashReceivedByRep: this month's receipts grouped by the deal creator's id, +// labelled with the creator's name (blank -> ไม่ระบุผู้ขาย). +function mockCashReceivedByRep() { + const byCreator = new Map(); + mockReceiptsThisMonth().forEach((r) => { + const ticket = db.tickets.find((t) => t.id === r.ticketId); + if (!ticket) return; + const row = byCreator.get(ticket.createdById) ?? { repName: ticket.createdByName?.trim() || 'ไม่ระบุผู้ขาย', amount: 0 }; + row.amount = signedReceiptSum(row.amount, r); + byCreator.set(ticket.createdById, row); + }); + return [...byCreator.values()].map((row) => ({ ...row, amount: moneyValue(row.amount) })); +} + function latestIssuedDepositNotice(ticketId) { return [...mockDepositNotices] .filter((d) => d.ticketId === Number(ticketId) && d.status === 'ISSUED') @@ -12217,6 +12264,7 @@ export const api = { const tickets = dashboardTickets(user); const pendingApprovals = dashboardPending(user, tickets); const notifications = dashboardNotifications(user); + const cashByRep = ['account', 'ceo', 'sales_manager'].includes(user.role) ? mockCashReceivedByRep() : null; return delay({ summary: { role: user.role, @@ -12243,6 +12291,13 @@ export const api = { dormant: tickets.dormant, paymentOverdue: tickets.paymentOverdue, partiallyDelivered: tickets.partiallyDelivered, + // Mirrors DashboardSummaryDto#cashReceivedThisMonth: receipts in the current Bangkok month + // (deposit + balance - adjustment), only for the roles that see company money. + // The month figure is the sum of the per-rep rows, as DashboardService totals them. + cashReceivedThisMonth: cashByRep ? moneyValue(cashByRep.reduce((sum, row) => sum + row.amount, 0)) : null, + cashReceivedByRep: cashByRep, + financeOutstanding: cashByRep ? mockFinanceOutstanding() : null, + forecastValue: cashByRep ? mockForecastValue() : null, }, }); }, diff --git a/frontend/src/features/dashboard/AccountOverview.jsx b/frontend/src/features/dashboard/AccountOverview.jsx index adee72d76..824742ede 100644 --- a/frontend/src/features/dashboard/AccountOverview.jsx +++ b/frontend/src/features/dashboard/AccountOverview.jsx @@ -21,13 +21,14 @@ import { accountMoneyBucket, nextAccountAction } from '../tickets/accountActions // Order matches the plan's original layout: // overdue leads (most urgent), then the two payment-confirmation steps, then // close-ready, then the folded commission step. `amountField` picks which -// ticket money field the bucket sums: outstanding balance for the three -// pending-money buckets, deal value (amountPayable) for the two "money is -// already secured, just needs admin follow-through" buckets where -// amountOutstanding is 0 by definition. +// ticket money field the bucket sums: the deposit notice total (depositDue) +// for รอรับมัดจำ, outstanding balance for เกินกำหนด and รอชำระส่วนที่เหลือ, +// deal value (amountPayable) for the two "money is already secured, just +// needs admin follow-through" buckets where amountOutstanding is 0 by +// definition. const MONEY_BUCKETS = [ { key: 'overdue', label: 'เกินกำหนด', icon: 'triangleAlert', tone: 'rose', amountField: 'amountOutstanding', span: 'col-span-2 row-span-2 nav-drawer:row-span-1' }, - { key: 'depositPending', label: 'รอรับมัดจำ', icon: 'clock', tone: 'amber', amountField: 'amountOutstanding', span: 'col-span-2 nav-drawer:col-span-1' }, + { key: 'depositPending', label: 'รอรับมัดจำ', icon: 'clock', tone: 'amber', amountField: 'depositDue', span: 'col-span-2 nav-drawer:col-span-1' }, { key: 'finalPaymentPending', label: 'รอชำระส่วนที่เหลือ', icon: 'badgeDollar', tone: 'teal', amountField: 'amountOutstanding', live: true }, { key: 'closeReady', label: 'รอปิดงาน', icon: 'check', tone: 'indigo', amountField: 'amountPayable', span: 'nav-drawer:col-span-2' }, { key: 'commissionPending', label: 'บันทึกใบกำกับ', icon: 'fileText', tone: 'blue', amountField: 'amountPayable', span: 'col-span-4 nav-drawer:col-span-2' }, @@ -54,11 +55,6 @@ function rowAmount(ticket) { return outstanding > 0 ? outstanding : Number(ticket.amountPayable ?? 0); } -function startOfMonth() { - const now = new Date(); - return new Date(now.getFullYear(), now.getMonth(), 1); -} - /** * Account (ฝ่ายบัญชี) role-scoped landing: money pulse + "สิ่งที่ต้องทำ" * worklist, mirrors ImportOverview's conveyor-pulse pattern but keyed on @@ -81,7 +77,7 @@ function startOfMonth() { * is ever widened. Flagged in the branch's handoff as a follow-up decision * for the plan owner, not fixed here. */ -export function AccountOverview({ user, employee, showToast }) { +export function AccountOverview({ user, employee, dashboardSummary, showToast }) { const navigate = useNavigate(); const scopedQuery = useQuery({ @@ -134,24 +130,12 @@ export function AccountOverview({ user, employee, showToast }) { }); }, [allTickets, user?.role]); - const monthSummary = useMemo(() => { - const monthStart = startOfMonth(); - let receivedThisMonth = 0; - let outstandingTotal = 0; - let overdueTotal = 0; - allTickets.forEach((ticket) => { - const outstanding = Number(ticket.amountOutstanding ?? 0); - outstandingTotal += outstanding; - if (ticket.overdue) overdueTotal += outstanding; - // Best-effort "received this month": amountPaid on deals last updated - // within the current calendar month — the closest signal available - // without a per-ticket payment-receipt fetch for every visible deal. - if (ticket.updatedAt && new Date(ticket.updatedAt) >= monthStart) { - receivedThisMonth += Number(ticket.amountPaid ?? 0); - } - }); - return { receivedThisMonth, outstandingTotal, overdueTotal }; - }, [allTickets]); + // ค้างรับ is /finance's คงค้างรวม (the summary's server total); รับแล้ว is the server's receipts for this Bangkok month. + const monthSummary = useMemo(() => ({ + receivedThisMonth: dashboardSummary?.cashReceivedThisMonth ?? null, + outstandingTotal: dashboardSummary?.financeOutstanding ?? null, + overdueTotal: allTickets.reduce((sum, t) => sum + (t.overdue ? Number(t.amountOutstanding ?? 0) : 0), 0), + }), [allTickets, dashboardSummary?.cashReceivedThisMonth, dashboardSummary?.financeOutstanding]); const firstName = employee?.nickName || employee?.nameTh || user?.name || ''; const today = formatThaiDate(new Date().toISOString()); @@ -276,7 +260,7 @@ export function AccountOverview({ user, employee, showToast }) { ].map(([label, value, tone]) => (

{label}
-
{formatMoney(value)}
+
{value == null ? '—' : formatMoney(value)}
))} diff --git a/frontend/src/features/dashboard/AccountOverview.test.jsx b/frontend/src/features/dashboard/AccountOverview.test.jsx index 5fb64f620..723fddc2a 100644 --- a/frontend/src/features/dashboard/AccountOverview.test.jsx +++ b/frontend/src/features/dashboard/AccountOverview.test.jsx @@ -1,10 +1,13 @@ import React from 'react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { MemoryRouter } from 'react-router-dom'; -import { render, screen, within } from '@testing-library/react'; +import { cleanup, render, screen, within } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { AccountOverview } from './AccountOverview.jsx'; import { api } from '../../api/index.js'; +import { AccountFinancePage } from '../finance/AccountFinancePage.jsx'; +import { DEAL_STAGE_CATALOG } from '../../data/dealStageCatalog.js'; +import { closedDemoDeal } from '../../test/fixtures/dashboardMoney.js'; globalThis.React = React; @@ -14,6 +17,8 @@ vi.mock('../../api/index.js', async (importOriginal) => { ...actual, api: { tickets: { list: vi.fn() }, + meta: { dealStages: vi.fn() }, + dashboard: { summary: vi.fn() }, }, }; }); @@ -52,14 +57,14 @@ const noActionTicket = { overdue: false, dueDate: null, updatedAt: '2026-07-05T00:00:00.000Z', }; -function renderOverview() { +function renderOverview(dashboardSummary) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, }); return render( - + , ); @@ -68,6 +73,7 @@ function renderOverview() { describe('AccountOverview', () => { beforeEach(() => { vi.clearAllMocks(); + api.meta.dealStages.mockResolvedValue(DEAL_STAGE_CATALOG); api.tickets.list.mockImplementation((params = {}) => { if (params.salesStage === 'CLOSED_PAID') return Promise.resolve({ tickets: [] }); return Promise.resolve({ tickets: [overdueTicket, depositTicket, noActionTicket] }); @@ -114,4 +120,84 @@ describe('AccountOverview', () => { expect(screen.queryByLabelText('งานขาย (Sales)')).toBeNull(); expect(screen.queryByText('ดีลทั้งหมด')).toBeNull(); }); + // Serve `tickets` from the scoped list and `closedPaid` from the CLOSED_PAID query. + function serve(tickets, closedPaid = []) { + api.tickets.list.mockImplementation((params = {}) => Promise.resolve( + { tickets: params.salesStage === 'CLOSED_PAID' ? closedPaid : tickets }, + )); + } + const tile = (label) => screen.getByText(label).closest('.rounded-md').textContent; + const summaryRow = (label) => screen.getByText(label).nextSibling.textContent; + + it('รอรับมัดจำ shows the issued deposit notice total, not the whole-deal outstanding', async () => { + serve([{ ...depositTicket, amountPayable: 213545.25, amountOutstanding: 213545.25, depositDue: 85418.10 }]); + renderOverview(); + await screen.findByText('รอปิดงาน'); + expect(tile('รอรับมัดจำ')).toContain('฿85,418.10'); + expect(tile('รอรับมัดจำ')).toContain('1 ดีล'); + expect(tile('รอรับมัดจำ')).not.toContain('213,545.25'); + }); + + it('รอชำระส่วนที่เหลือ counts a deposit-paid deal the to-do offers รับชำระส่วนที่เหลือ for', async () => { + const depositPaid = { + ...depositTicket, id: 104, code: 'PR-2026-0104', customerName: 'บริษัท ดี จำกัด', + paymentStatus: 'DEPOSIT_PAID', salesStage: 'DELIVERY_SCHEDULING', amountOutstanding: 40000, + }; + // Deposit-bypassed deals are offered the same step. + const bypassed = { + ...noActionTicket, id: 105, code: 'PR-2026-0105', customerName: 'บริษัท อี จำกัด', + depositPolicy: 'CREDIT_CUSTOMER', paymentStatus: null, amountOutstanding: 25000, + }; + serve([depositPaid, bypassed]); + renderOverview(); + await screen.findByText('รอปิดงาน'); + const todo = screen.getByText('สิ่งที่ต้องทำ').closest('section'); + const offered = within(todo).getAllByText('รับชำระส่วนที่เหลือ').length; + expect(offered).toBe(2); + expect(tile('รอชำระส่วนที่เหลือ')).toContain(`${offered} ดีล`); + }); + + it('สรุปเดือนนี้ รับแล้ว (เดือนนี้) is the summary cashReceivedThisMonth, not a guess from updatedAt', async () => { + // amountPaid on a deal updated this month is what the old guess would have summed. + serve([{ ...overdueTicket, updatedAt: new Date().toISOString() }]); + renderOverview({ cashReceivedThisMonth: 321000.5 }); + await screen.findByText('สรุปเดือนนี้'); + expect(summaryRow('รับแล้ว (เดือนนี้)')).toBe('฿321,000.50'); + }); + + it('ค้างรับ is the summary\'s server total, not a sum of the loaded rows', async () => { + // rows would add up to ฿150,000.00 (the closed DEMO-TKT-05 deal and the no-to-do deal are not counted either way) + serve([overdueTicket, depositTicket, noActionTicket], [closedDemoDeal]); + renderOverview({ financeOutstanding: 777000.5 }); + await screen.findByText('สรุปเดือนนี้'); + expect(summaryRow('ค้างรับ')).toBe('฿777,000.50'); + }); + + it('ค้างรับ shows an em dash while the summary has no total', async () => { + serve([overdueTicket, depositTicket, noActionTicket], [closedDemoDeal]); + renderOverview({}); + await screen.findByText('สรุปเดือนนี้'); + expect(summaryRow('ค้างรับ')).toBe('—'); + }); + + it('ค้างรับ equals the ค้างรับทั้งหมด on AccountFinancePage: both read the summary\'s total', async () => { + serve([overdueTicket, depositTicket, noActionTicket], [closedDemoDeal]); + api.dashboard.summary.mockResolvedValue({ summary: { financeOutstanding: 777000.5 } }); + renderOverview({ financeOutstanding: 777000.5 }); + await screen.findByText('สรุปเดือนนี้'); + const dashboardValue = summaryRow('ค้างรับ'); + expect(dashboardValue).toBe('฿777,000.50'); + cleanup(); + + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + + + , + ); + const subtitle = await screen.findByText(/ค้างรับทั้งหมด ฿/); + expect(subtitle.textContent).toContain(`ค้างรับทั้งหมด ${dashboardValue}`); + }); }); diff --git a/frontend/src/features/dashboard/CeoOverview.jsx b/frontend/src/features/dashboard/CeoOverview.jsx index 39e929f9f..8cae82bba 100644 --- a/frontend/src/features/dashboard/CeoOverview.jsx +++ b/frontend/src/features/dashboard/CeoOverview.jsx @@ -10,7 +10,7 @@ import { PageStack, Panel, StatGrid } from '../../components/common/Layout.jsx'; import { SkeletonCard } from '../../components/common/Skeleton.jsx'; import { StatCard } from '../../components/common/StatCard.jsx'; import { StatusBadge } from '../../components/common/StatusBadge.jsx'; -import { formatMoney, formatThaiDate, bangkokMonthStartIso, greetingName } from '../../utils/format.js'; +import { formatMoney, formatThaiDate, greetingName } from '../../utils/format.js'; /** * CEO exec-cockpit landing (plan: docs/agent-handoffs/valiant-watching-map.md @@ -98,7 +98,6 @@ function WorklistRow({ row }) { export function CeoOverview({ user, employee, dashboardSummary }) { const navigate = useNavigate(); - const monthStart = bangkokMonthStartIso(); // Shares the same cache entries PricingRequestQueuePage // already populate for a CEO session — one company-wide fetch each, no @@ -190,49 +189,31 @@ export function CeoOverview({ user, employee, dashboardSummary }) { // for user.role === 'ceo' (see App.jsx), so there is no other-role case to guard against. ───── const leaveRows = leaveSubmitted; - // ── ผลบริษัทเดือนนี้: no ฿ figure exists on /api/dashboard/summary (it only - // returns ticket-status counts), so every ฿ number here is derived from the + // ── ผลบริษัทเดือนนี้: เงินรับแล้ว comes from /api/dashboard/summary (cashReceivedThisMonth); + // every other ฿ number here is derived from the // scoped tickets.list() the CEO already gets in full — amountPayable/ // amountPaid/amountOutstanding/overdue are the same computed fields // TicketDetailPage's money views already trust (derivePaymentFields). ──── - const closedThisMonthTickets = useMemo( - () => tickets.filter((t) => t.status === 'closed' && t.closedAt && t.closedAt >= monthStart), - [tickets, monthStart], - ); - const salesClosedThisMonth = useMemo( - () => moneySum(closedThisMonthTickets, (t) => t.amountPaid ?? t.amountPayable), - [closedThisMonthTickets], - ); - const companyPipelineValue = useMemo( - () => moneySum(tickets.filter((t) => (t.lifecycle ?? 'ACTIVE') === 'ACTIVE' && t.status !== 'closed'), (t) => t.amountPayable), - [tickets], - ); + const outstanding = dashboardSummary?.financeOutstanding; + const forecast = dashboardSummary?.forecastValue; + const cashReceived = dashboardSummary?.cashReceivedThisMonth; const overdueReceivables = useMemo( () => moneySum(tickets.filter((t) => t.overdue), (t) => t.amountOutstanding), [tickets], ); - const closedThisMonthCount = dashboardSummary?.tickets?.closedThisMonth ?? closedThisMonthTickets.length; const headcountActive = dashboardSummary?.headcount?.active ?? null; const lateToday = dashboardSummary?.attendance?.lateToday ?? null; - // ── ยอดตามทีม/ฝ่าย: tickets carry no divisionId (and employees.list() is - // hr-only — CEO has no server route to a division breakdown here), so this - // groups by the deal's owning sales rep (createdByName) instead, the - // closest real grouping the CEO's existing endpoints expose. No target/ - // quota field exists anywhere in this data, so bars are sized relative to - // this month's top performer, never against an invented quota — see - // docs/role-scoped-views.md "CEO" section for this stated gap. - const repBreakdown = useMemo(() => { - const byRep = new Map(); - for (const ticket of closedThisMonthTickets) { - const key = ticket.createdByName || 'ไม่ระบุผู้ขาย'; - const amount = Number(ticket.amountPaid ?? ticket.amountPayable) || 0; - byRep.set(key, (byRep.get(key) ?? 0) + amount); - } - const rows = [...byRep.entries()].map(([name, amount]) => ({ name, amount })); - rows.sort((a, b) => b.amount - a.amount); - return rows.slice(0, 8); - }, [closedThisMonthTickets]); + // ── ยอดตามทีม/ฝ่าย: this month's cash received split per sales rep, served by the dashboard + // summary (cashReceivedByRep; its rows add up to เงินรับแล้ว). Tickets carry no divisionId and + // employees.list() is hr-only, so a division breakdown is not derivable for the CEO. No target/ + // quota field exists, so bars are sized relative to this month's top rep, never against a quota. + const repBreakdown = useMemo( + () => [...(dashboardSummary?.cashReceivedByRep ?? [])] + .map((row) => ({ name: row.repName, amount: Number(row.amount) || 0 })) + .sort((a, b) => b.amount - a.amount), + [dashboardSummary?.cashReceivedByRep], + ); const repBreakdownMax = repBreakdown.reduce((max, row) => Math.max(max, row.amount), 0); // ── the cross-domain worklist, in the exec-pulse's own order ───────────── @@ -307,7 +288,6 @@ export function CeoOverview({ user, employee, dashboardSummary }) { - ) : ( @@ -347,13 +327,6 @@ export function CeoOverview({ user, employee, dashboardSummary }) { ? () => navigate(otRows.length >= leaveRows.length ? '/employee-requests?tab=ot' : '/leave') : undefined} /> - @@ -379,11 +352,11 @@ export function CeoOverview({ user, employee, dashboardSummary }) { employees.list() is hr-only, so a real division breakdown is not derivable from any endpoint the CEO can call. */} {repBreakdown.length === 0 ? ( -

ยังไม่มีดีลที่ปิดในเดือนนี้

+

ยังไม่มีเงินรับเข้าในเดือนนี้

) : (
- {repBreakdown.map((row) => ( -
+ {repBreakdown.map((row, index) => ( +
{row.name} {formatMoney(row.amount)} @@ -403,9 +376,9 @@ export function CeoOverview({ user, employee, dashboardSummary }) {
{[ - ['ยอดขายปิดแล้ว', formatMoney(salesClosedThisMonth)], - ['ปิดงานแล้ว', `${closedThisMonthCount} ดีล`], - ['Pipeline บริษัท', formatMoney(companyPipelineValue)], + ['เงินรับแล้ว (เดือนนี้)', cashReceived == null ? '—' : formatMoney(cashReceived)], + ['ค้างรับ', outstanding == null ? '—' : formatMoney(outstanding)], + ['ยอดคาดการณ์', forecast == null ? '—' : formatMoney(forecast)], ['ลูกหนี้ค้าง/เกินกำหนด', formatMoney(overdueReceivables)], ['กำลังพล', headcountActive != null ? `${headcountActive} คน` : '-'], ['มาสายวันนี้', lateToday != null ? `${lateToday} คน` : '-'], diff --git a/frontend/src/features/dashboard/CeoOverview.test.jsx b/frontend/src/features/dashboard/CeoOverview.test.jsx index d2221ea6d..a3dd90ffb 100644 --- a/frontend/src/features/dashboard/CeoOverview.test.jsx +++ b/frontend/src/features/dashboard/CeoOverview.test.jsx @@ -6,6 +6,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { CeoOverview } from './CeoOverview.jsx'; import { api } from '../../api/index.js'; import { bangkokMonthStartIso } from '../../utils/format.js'; +import { + CASH_BY_REP, CASH_RECEIVED_THIS_MONTH, EXPECTED, SERVER_TOTALS, expectFigure, figureRows, moneyFixtureTickets, +} from '../../test/fixtures/dashboardMoney.js'; globalThis.React = React; @@ -36,6 +39,10 @@ const dashboardSummary = { tickets: { closedThisMonth: 4 }, headcount: { active: 42 }, attendance: { lateToday: 3 }, + cashReceivedThisMonth: CASH_RECEIVED_THIS_MONTH, + // This month's cash split per sales rep: the two add up to CASH_RECEIVED_THIS_MONTH (125,000.50). + cashReceivedByRep: CASH_BY_REP, + ...SERVER_TOTALS, }; function mockAllData() { @@ -125,7 +132,7 @@ function LocationProbe() { return
{location.pathname}{location.search}
; } -function renderOverview() { +function renderOverview(summary = dashboardSummary) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, }); @@ -137,7 +144,7 @@ function renderOverview() { + )} /> } /> @@ -184,8 +191,6 @@ describe('CeoOverview', () => { // OT (MANAGER_APPROVED + manager-less-direct-to-CEO) + ลา (ALL of it, CEO leave-approval reach, // 2026-09-01) = 2 + 2. expect(statCardValue('OT·ลา รออนุมัติ')).toBe('4'); - // ยอดขายเดือนนี้: sum of amountPaid for tickets closed this month (only TK-702). - expect(statCardValue('ยอดขายเดือนนี้')).toBe('฿50,000.00'); expect(api.pricingRequests.queue).toHaveBeenCalledWith({ activeOnly: true }); expect(api.tickets.list).toHaveBeenCalledWith({}); @@ -281,25 +286,78 @@ describe('CeoOverview', () => { await screen.findByText('ผลบริษัทเดือนนี้'); - expect(screen.getByText('ยอดขายปิดแล้ว').nextSibling.textContent).toBe('฿50,000.00'); - // Prefers the real dashboardSummary.tickets.closedThisMonth (4) over the - // client-derived count (1) when the summary provides it. - expect(screen.getByText('ปิดงานแล้ว').nextSibling.textContent).toBe('4 ดีล'); - // Pipeline: active, not-yet-closed tickets only (701 + 703 = 120,000). - expect(screen.getByText('Pipeline บริษัท').nextSibling.textContent).toBe('฿120,000.00'); // Overdue receivables: only the overdue ticket's outstanding balance (703). expect(screen.getByText('ลูกหนี้ค้าง/เกินกำหนด').nextSibling.textContent).toBe('฿15,000.00'); expect(screen.getByText('กำลังพล').nextSibling.textContent).toBe('42 คน'); expect(screen.getByText('มาสายวันนี้').nextSibling.textContent).toBe('3 คน'); }); - it('renders a per-rep breakdown for deals closed this month, with no invented target/quota', async () => { + it('splits this month\'s cash received per sales rep, adding up to เงินรับแล้ว, with no invented target/quota', async () => { renderOverview(); - await screen.findByText('ยอดตามทีม/ฝ่าย'); - // Only สมหญิง's deal (TK-702) closed this month. - expect(screen.getByText('สมหญิง ขายเก่ง')).not.toBeNull(); + const panel = (await screen.findByText('ยอดตามทีม/ฝ่าย')).closest('section'); + expect(within(panel).getByText('สมชาย ขายดี').nextSibling.textContent).toBe('฿90,000.50'); + expect(within(panel).getByText('สมหญิง ขายเก่ง').nextSibling.textContent).toBe('฿35,000.00'); + // The bars add up to the เงินรับแล้ว (เดือนนี้) figure shown beside them. + const barTotal = [...panel.querySelectorAll('code')] + .reduce((sum, el) => sum + Number(el.textContent.replace(/[฿,]/g, '')), 0); + expect(barTotal).toBeCloseTo(CASH_RECEIVED_THIS_MONTH, 2); + expectFigure('เงินรับแล้ว (เดือนนี้)', '฿125,000.50'); + // The old basis -- deals the CEO closed this month at amountPaid (TK-702, ฿50,000) -- is gone. + expect(within(panel).queryByText('฿50,000.00')).toBeNull(); // No percentage/quota text is rendered anywhere in that panel. - expect(screen.queryByText(/%/)).toBeNull(); + expect(within(panel).queryByText(/%/)).toBeNull(); + }); + + it('sorts the rep rows by amount, largest first, whatever order the server sent', async () => { + renderOverview(); + + const panel = (await screen.findByText('ยอดตามทีม/ฝ่าย')).closest('section'); + const names = [...panel.querySelectorAll('span.truncate')].map((el) => el.textContent); + expect(names).toEqual(['สมชาย ขายดี', 'สมหญิง ขายเก่ง']); + }); + + it('lists every rep so the bars still add up to เงินรับแล้ว with more than eight reps', async () => { + const reps = Array.from({ length: 9 }, (_, i) => ({ repName: `เซลส์ ${i + 1}`, amount: 1000 })); + renderOverview({ ...dashboardSummary, cashReceivedThisMonth: 9000, cashReceivedByRep: reps }); + + const panel = (await screen.findByText('ยอดตามทีม/ฝ่าย')).closest('section'); + expect(panel.querySelectorAll('code')).toHaveLength(9); + }); + + it('keeps the empty-state text when no cash was received per rep', async () => { + renderOverview({ ...dashboardSummary, cashReceivedByRep: [] }); + + const panel = (await screen.findByText('ยอดตามทีม/ฝ่าย')).closest('section'); + expect(within(panel).getByText('ยังไม่มีเงินรับเข้าในเดือนนี้')).not.toBeNull(); + }); + it('shows the same three money figures as /finance and the sales-manager dashboard', async () => { + api.tickets.list.mockResolvedValue({ tickets: moneyFixtureTickets }); + renderOverview(); + await screen.findByText('อนุมัติราคา'); + + expectFigure('เงินรับแล้ว (เดือนนี้)', EXPECTED.cash); + expectFigure('ค้างรับ', EXPECTED.outstanding); + expectFigure('ยอดคาดการณ์', EXPECTED.forecast); + }); + + it('shows an em dash for ค้างรับ and ยอดคาดการณ์ when the summary has no totals, never a sum of rows', async () => { + api.tickets.list.mockResolvedValue({ tickets: moneyFixtureTickets }); + renderOverview({ ...dashboardSummary, financeOutstanding: undefined, forecastValue: undefined }); + await screen.findByText('อนุมัติราคา'); + + expectFigure('ค้างรับ', '—'); + expectFigure('ยอดคาดการณ์', '—'); + }); + + it('no longer shows the closed-sales and pipeline figures', async () => { + renderOverview(); + await screen.findByText('ผลบริษัทเดือนนี้'); + + ['ยอดขายเดือนนี้', 'ยอดขายปิดแล้ว', 'ปิดงานแล้ว', 'Pipeline บริษัท'].forEach((label) => { + expect(figureRows(label), label).toEqual([]); + }); + // The other company-result rows stay. + expect(screen.getByText('ลูกหนี้ค้าง/เกินกำหนด')).not.toBeNull(); }); }); diff --git a/frontend/src/features/dashboard/ManagerOverview.jsx b/frontend/src/features/dashboard/ManagerOverview.jsx index 8c314e316..a582e1cd0 100644 --- a/frontend/src/features/dashboard/ManagerOverview.jsx +++ b/frontend/src/features/dashboard/ManagerOverview.jsx @@ -20,11 +20,6 @@ import { formatMoney, formatThaiDate, greetingName } from '../../utils/format.js // exactly like CommissionPage's own `totals.submitted` does. const TEAM_COMMISSIONS_QUERY_KEY = ['commissions', 'list', 'submitted']; -function startOfMonth() { - const now = new Date(); - return new Date(now.getFullYear(), now.getMonth(), 1); -} - // A deal is "won" once it reaches the terminal pipeline stage, "lost" once // its lifecycle/legacy status marks it lost or cancelled, else "open" — // mirrors the same three-way split features/tickets/salesViewScope.js's @@ -41,10 +36,6 @@ function isDealOpen(ticket) { return !isDealWon(ticket) && !isDealLost(ticket) && ticket.status !== 'closed'; } -function dealAmount(ticket) { - return Number(ticket.amountPayable ?? 0); -} - /** * Sales Manager role-scoped landing (team cockpit): pulse (team ยอด this * month / commissions awaiting the manager's own review / deals needing @@ -86,7 +77,7 @@ function dealAmount(ticket) { * be fabricating business data (CLAUDE.md). Flagged as a follow-up decision * for the plan owner, not fabricated here. */ -export function ManagerOverview({ user, employee, showToast }) { +export function ManagerOverview({ user, employee, dashboardSummary, showToast }) { const navigate = useNavigate(); const ticketsQuery = useQuery({ @@ -118,21 +109,10 @@ export function ManagerOverview({ user, employee, showToast }) { const ticketsById = useMemo(() => new Map(tickets.map((t) => [t.id, t])), [tickets]); - const monthStart = useMemo(() => startOfMonth(), []); - const closedThisMonth = useMemo( - () => tickets.filter((t) => isDealWon(t) && t.stageUpdatedAt && new Date(t.stageUpdatedAt) >= monthStart), - [tickets, monthStart], - ); - const teamMonthValue = useMemo( - () => closedThisMonth.reduce((sum, t) => sum + dealAmount(t), 0), - [closedThisMonth], - ); - const openTickets = useMemo(() => tickets.filter(isDealOpen), [tickets]); - const openPipelineValue = useMemo( - () => openTickets.reduce((sum, t) => sum + dealAmount(t), 0), - [openTickets], - ); + const outstanding = dashboardSummary?.financeOutstanding; + const forecast = dashboardSummary?.forecastValue; + const cashReceived = dashboardSummary?.cashReceivedThisMonth; // "ดีลต้องดูแล": an open deal with no activity in 7 days (`ticket.stale`, // mirrors TicketRepository.enrichSummary's own staleness window — see @@ -140,8 +120,7 @@ export function ManagerOverview({ user, employee, showToast }) { // overdue (`ticket.overdue`), or a follow-up date already in the past. const attentionTickets = useMemo(() => { // `new Date()` (not `Date.now()`, which the react-hooks purity rule - // flags as a well-known impure call inside render) — same pattern this - // file's own `startOfMonth()` already uses above. + // flags as a well-known impure call inside render). const now = new Date(); return openTickets .filter((t) => t.stale || t.overdue || (t.nextFollowUpAt && new Date(t.nextFollowUpAt) < now)) @@ -158,17 +137,13 @@ export function ManagerOverview({ user, employee, showToast }) { const lostCount = useMemo(() => tickets.filter(isDealLost).length, [tickets]); const closeRate = wonCount + lostCount > 0 ? (wonCount / (wonCount + lostCount)) * 100 : null; - const leaderboard = useMemo(() => { - const byRep = new Map(); - closedThisMonth.forEach((t) => { - const key = t.createdById ?? t.createdByName ?? 'unknown'; - const current = byRep.get(key) || { id: key, name: t.createdByName || 'ไม่ระบุ', amount: 0, count: 0 }; - current.amount += dealAmount(t); - current.count += 1; - byRep.set(key, current); - }); - return [...byRep.values()].sort((a, b) => b.amount - a.amount).slice(0, 5); - }, [closedThisMonth]); + // Same rows as the CEO's ยอดตามทีม/ฝ่าย panel: this month's cash received per rep, from the summary. + const leaderboard = useMemo( + () => [...(dashboardSummary?.cashReceivedByRep ?? [])] + .map((row) => ({ name: row.repName, amount: Number(row.amount) || 0 })) + .sort((a, b) => b.amount - a.amount), + [dashboardSummary?.cashReceivedByRep], + ); const leaderboardMax = leaderboard[0]?.amount || 0; const firstName = employee?.nickName || employee?.nameTh || user?.name || ''; @@ -189,6 +164,7 @@ export function ManagerOverview({ user, employee, showToast }) { + @@ -200,11 +176,18 @@ export function ManagerOverview({ user, employee, showToast }) { + navigate('/tickets')} /> @@ -326,11 +309,11 @@ export function ManagerOverview({ user, employee, showToast }) {
{leaderboard.length === 0 ? ( -

ยังไม่มีดีลปิดในเดือนนี้

+

ยังไม่มีเงินรับเข้าในเดือนนี้

) : (
{leaderboard.map((rep, index) => ( -
+
{index + 1}. {rep.name} {formatMoney(rep.amount)} diff --git a/frontend/src/features/dashboard/ManagerOverview.test.jsx b/frontend/src/features/dashboard/ManagerOverview.test.jsx index a125acd05..8e8993dee 100644 --- a/frontend/src/features/dashboard/ManagerOverview.test.jsx +++ b/frontend/src/features/dashboard/ManagerOverview.test.jsx @@ -5,6 +5,9 @@ import { render, screen, within } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ManagerOverview } from './ManagerOverview.jsx'; import { api } from '../../api/index.js'; +import { + CASH_BY_REP, CASH_RECEIVED_THIS_MONTH, EXPECTED, SERVER_TOTALS, expectFigure, figureRows, moneyFixtureTickets, +} from '../../test/fixtures/dashboardMoney.js'; globalThis.React = React; @@ -65,14 +68,14 @@ const managerApprovedCommission = { invoiceDetails: { invoiceNumber: 'INV-2026-0302' }, }; -function renderOverview() { +function renderOverview(dashboardSummary) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, }); return render( - + , ); @@ -87,12 +90,7 @@ describe('ManagerOverview', () => { it('renders the team pulse derived from the team ticket list', async () => { renderOverview(); - await screen.findByText('ยอดทีมเดือนนี้'); - // ฿300,000.00 also appears in the leaderboard row for the same rep/deal — - // both are correct, so assert at least one, not exactly one. - expect(screen.getAllByText('฿300,000.00').length).toBeGreaterThan(0); - expect(screen.getByText('pipeline ทีม')).not.toBeNull(); - expect(screen.getByText('ดีลต้องดูแล')).not.toBeNull(); + await screen.findByText('ดีลต้องดูแล'); }); it('renders the ค่าคอมรออนุมัติ worklist with a review/approve CTA, SUBMITTED only', async () => { @@ -109,11 +107,28 @@ describe('ManagerOverview', () => { expect(within(panel).queryByText(/INV-2026-0302/)).toBeNull(); }); - it('renders the leaderboard and close-rate in the right rail', async () => { - renderOverview(); + it('ranks the leaderboard by this month\'s cash per rep, the same rows as the CEO panel', async () => { + renderOverview({ cashReceivedThisMonth: CASH_RECEIVED_THIS_MONTH, cashReceivedByRep: CASH_BY_REP }); const heading = await screen.findByRole('heading', { name: 'อันดับทีม (เดือนนี้)' }); const panel = heading.closest('section'); - expect(within(panel).getByText(/เซลส์ เอ/)).not.toBeNull(); + + const rows = [...panel.querySelectorAll('strong')].map((el) => el.textContent); + expect(rows).toEqual(['฿90,000.50', '฿35,000.00']); + expect(within(panel).getByText('1. สมชาย ขายดี')).not.toBeNull(); + expect(within(panel).getByText('2. สมหญิง ขายเก่ง')).not.toBeNull(); + // The old basis -- the won deal's amountPayable (฿300,000.00) -- is gone. + expect(within(panel).queryByText('฿300,000.00')).toBeNull(); + }); + + it('shows the leaderboard empty state when no cash came in', async () => { + renderOverview({ cashReceivedThisMonth: 0, cashReceivedByRep: [] }); + const heading = await screen.findByRole('heading', { name: 'อันดับทีม (เดือนนี้)' }); + expect(within(heading.closest('section')).getByText('ยังไม่มีเงินรับเข้าในเดือนนี้')).not.toBeNull(); + }); + + it('renders the close-rate in the right rail', async () => { + renderOverview(); + await screen.findByRole('heading', { name: 'อันดับทีม (เดือนนี้)' }); expect(screen.getByText('อัตราปิดงาน')).not.toBeNull(); }); @@ -124,4 +139,31 @@ describe('ManagerOverview', () => { expect(within(panel).getByText('ไม่มีความเคลื่อนไหว 7 วัน')).not.toBeNull(); expect(within(panel).queryByText('บริษัท ทรี จำกัด')).toBeNull(); }); + it('shows the same three money figures as /finance and the CEO dashboard', async () => { + api.tickets.list.mockResolvedValue({ tickets: moneyFixtureTickets }); + renderOverview({ cashReceivedThisMonth: CASH_RECEIVED_THIS_MONTH, ...SERVER_TOTALS }); + await screen.findByText('ดีลต้องดูแล'); + + expectFigure('เงินรับแล้ว (เดือนนี้)', EXPECTED.cash); + expectFigure('ค้างรับ', EXPECTED.outstanding); + expectFigure('ยอดคาดการณ์', EXPECTED.forecast); + }); + + it('shows an em dash for ค้างรับ and ยอดคาดการณ์ when the summary has no totals, never a sum of rows', async () => { + api.tickets.list.mockResolvedValue({ tickets: moneyFixtureTickets }); + renderOverview({ cashReceivedThisMonth: CASH_RECEIVED_THIS_MONTH }); + await screen.findByText('ดีลต้องดูแล'); + + expectFigure('ค้างรับ', '—'); + expectFigure('ยอดคาดการณ์', '—'); + }); + + it('no longer shows the team-sales and team-pipeline tiles', async () => { + renderOverview(); + await screen.findByText('ดีลต้องดูแล'); + + ['ยอดทีมเดือนนี้', 'pipeline ทีม'].forEach((label) => { + expect(figureRows(label), label).toEqual([]); + }); + }); }); diff --git a/frontend/src/features/dashboard/moneyCases.test.js b/frontend/src/features/dashboard/moneyCases.test.js new file mode 100644 index 000000000..fedce8324 --- /dev/null +++ b/frontend/src/features/dashboard/moneyCases.test.js @@ -0,0 +1,45 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { nextAccountAction } from '../tickets/accountActions.js'; +import { moneyMilestoneOf } from '../finance/moneyMilestone.js'; + +// Anti-drift: the same deal shapes drive DashboardMoneyTotalsIntegrationTest (the SQL behind +// /api/dashboard/summary). The JS rule here is what /finance's rows use -- account's list scope +// (TicketRepository.appendRoleScope("account")) plus nextAccountAction -- so if the two sides ever +// disagree about a shape, one of the two tests goes red. +// vitest runs from frontend/ (see the repo's vitest note), so the backend resource is one level up. +const table = JSON.parse(readFileSync( + resolve(process.cwd(), '../backend/src/test/resources/dashboard/money-cases.json'), 'utf8')); + +const PENDING = new Set(['DEPOSIT_NOTICE_ISSUED', 'AWAITING_FINAL_PAYMENT']); + +function ticketOf(c) { + const outstanding = Math.max(table.payable - c.paid, 0); + return { + id: 1, lifecycle: c.lifecycle, status: c.status, salesStage: c.salesStage, paymentStatus: c.paymentStatus, + depositPolicy: c.depositPolicy, overdue: c.overdue, commissionRecorded: c.commission, + closeConfirmedAt: null, invoiceOnFile: false, fulfillmentStatus: null, + amountPayable: table.payable, amountPaid: c.paid, amountOutstanding: outstanding, + }; +} + +function inAccountScope(t) { + const live = t.lifecycle !== 'CLOSED_LOST' && t.lifecycle !== 'CANCELLED' && t.status !== 'cancelled' + && moneyMilestoneOf(t.salesStage) != null; + return live || PENDING.has(t.paymentStatus) || t.overdue === true; +} +const countsInOutstanding = (t) => inAccountScope(t) && nextAccountAction(t) != null; + +describe('dashboard money case table (JS side)', () => { + it('has the cases the SQL test builds', () => { + expect(table.cases.length).toBeGreaterThanOrEqual(23); + }); + + it.each(table.cases.map((c) => [c.name, c]))('%s', (_name, c) => { + const t = ticketOf(c); + // Outstanding only: the JS side of this rule is production code (nextAccountAction). The forecast rule has + // no production JS twin any more (the server sums it), so DashboardMoneyTotalsIntegrationTest alone pins it. + expect(countsInOutstanding(t), 'countsInOutstanding').toBe(c.countsInOutstanding); + }); +}); diff --git a/frontend/src/features/finance/AccountFinancePage.jsx b/frontend/src/features/finance/AccountFinancePage.jsx index 7262d36b0..ec675923e 100644 --- a/frontend/src/features/finance/AccountFinancePage.jsx +++ b/frontend/src/features/finance/AccountFinancePage.jsx @@ -11,6 +11,7 @@ import { Icon } from '../../components/common/Icon.jsx'; import { FilterBar, FilterField, PageStack } from '../../components/common/Layout.jsx'; import { PageHeader } from '../../components/common/PageHeader.jsx'; import { TabPanel, Tabs } from '../../components/common/Tabs.jsx'; +import { fetchDashboardSummary } from '../../hooks/useHrData.js'; import { cn } from '../../utils/cn.js'; import { dealStageLabel, formatMoney, formatThaiDate } from '../../utils/format.js'; import { nextAccountAction } from '../tickets/accountActions.js'; @@ -123,6 +124,14 @@ export function AccountFinancePage({ user, showToast }) { if (error) showToast?.('error', error.message || 'โหลดข้อมูลไม่สำเร็จ'); }, [scopedQuery.error, closedPaidQuery.error, showToast]); + // The same query (key + fetch) as useHrData's, so one cache entry serves the dashboard and this header, + // and FinanceDealPage's invalidation of it keeps คงค้างรวม fresh. + const summaryQuery = useQuery({ + queryKey: queryKeys.dashboardSummary(), + queryFn: fetchDashboardSummary, + }); + const serverOutstanding = summaryQuery.data?.financeOutstanding ?? null; + const loading = scopedQuery.isLoading || closedPaidQuery.isLoading; const loadError = scopedQuery.error || closedPaidQuery.error; @@ -151,15 +160,14 @@ export function AccountFinancePage({ user, showToast }) { // The summary always describes the actionable set, whichever view is showing. const stats = useMemo(() => { - const total = actionable.reduce((sum, r) => sum + Number(r.ticket.amountOutstanding ?? 0), 0); const overdueCount = actionable.filter((r) => r.ticket.overdue).length; const dueTimes = actionable .map((r) => (r.ticket.paymentDueDate ? new Date(r.ticket.paymentDueDate).getTime() : null)) .filter((t) => t != null && !Number.isNaN(t)); const nextDue = dueTimes.length ? new Date(Math.min(...dueTimes)).toISOString() : null; - return { total, overdueCount, nextDue }; + return { overdueCount, nextDue }; }, [actionable]); - const summary = `ต้องดำเนินการ ${actionable.length} ดีล · คงค้างรวม ${actionable.length === 0 ? '—' : formatMoney(stats.total)}`; + const summary = `ต้องดำเนินการ ${actionable.length} ดีล · ค้างรับทั้งหมด ${serverOutstanding == null ? '—' : formatMoney(serverOutstanding)}`; const rows = useMemo(() => { const base = view === 'all' ? allRows : actionable; @@ -199,9 +207,9 @@ export function AccountFinancePage({ user, showToast }) { {/* The screen-reader sentence; the visible block below shows the same figures as a lead stat. */}

{summary}

-

ยอดคงค้างของดีลที่ต้องดำเนินการ

+

ค้างรับทั้งหมด

- {actionable.length === 0 ? '—' : formatMoney(stats.total)} + {serverOutstanding == null ? '—' : formatMoney(serverOutstanding)}

diff --git a/frontend/src/features/finance/AccountFinancePage.test.jsx b/frontend/src/features/finance/AccountFinancePage.test.jsx index 77e0238ec..b5a2cb050 100644 --- a/frontend/src/features/finance/AccountFinancePage.test.jsx +++ b/frontend/src/features/finance/AccountFinancePage.test.jsx @@ -16,6 +16,7 @@ vi.mock('../../api/index.js', async (importOriginal) => { api: { tickets: { list: vi.fn() }, meta: { dealStages: vi.fn().mockResolvedValue(DEAL_STAGE_CATALOG) }, + dashboard: { summary: vi.fn() }, }, }; }); @@ -86,6 +87,7 @@ const rowLink = (name) => screen.getByRole('link', { name: new RegExp(name) }); describe('AccountFinancePage (index-first worklist)', () => { beforeEach(() => { vi.clearAllMocks(); + api.dashboard.summary.mockResolvedValue({ summary: { financeOutstanding: 424242.42 } }); api.tickets.list.mockImplementation((params = {}) => { if (params.salesStage === 'CLOSED_PAID') return Promise.resolve({ tickets: [closedPaidTicket] }); return Promise.resolve({ tickets: [depositTicket, finalPaymentTicket, idleTicket] }); @@ -201,18 +203,23 @@ describe('AccountFinancePage (index-first worklist)', () => { expect(within(link).getByText('ติดตามชำระ')).not.toBeNull(); }); - it('the summary line is computed from the data: count of actionable deals and total outstanding', async () => { + it('the summary line counts the actionable rows but takes ค้างรับทั้งหมด from the server total', async () => { renderPage(); - // deposit 80,000 + final 60,000 + closed-paid 0 = 140,000 across 3 actionable deals + // 3 actionable rows; their outstanding adds up to 140,000, but the figure is the summary's (rows are capped at 500) expect(await screen.findByText(/ต้องดำเนินการ 3 ดีล/)).not.toBeNull(); - expect(screen.getByText(/คงค้างรวม ฿140,000.00/)).not.toBeNull(); + expect(await screen.findByText('ต้องดำเนินการ 3 ดีล · ค้างรับทั้งหมด ฿424,242.42')).not.toBeNull(); + // the hero is labelled as the whole receivable, not as the listed deals' sum + expect(screen.getByText('ค้างรับทั้งหมด')).not.toBeNull(); + expect(screen.queryByText('ยอดคงค้างของดีลที่ต้องดำเนินการ')).toBeNull(); + expect(screen.queryByText(/฿140,000.00/)).toBeNull(); }); - it('with nothing actionable the summary says so and shows an em dash, not a fabricated zero', async () => { + it('without a server total the summary shows an em dash, not a fabricated sum or zero', async () => { + api.dashboard.summary.mockResolvedValue({ summary: {} }); api.tickets.list.mockResolvedValue({ tickets: [idleTicket] }); renderPage(); expect(await screen.findByText(/ต้องดำเนินการ 0 ดีล/)).not.toBeNull(); - expect(screen.getByText(/คงค้างรวม —/)).not.toBeNull(); + expect(screen.getByText(/ค้างรับทั้งหมด —/)).not.toBeNull(); expect(screen.queryByText(/฿0.00/)).toBeNull(); }); diff --git a/frontend/src/features/tickets/accountActions.js b/frontend/src/features/tickets/accountActions.js index 318fcd620..558f06f73 100644 --- a/frontend/src/features/tickets/accountActions.js +++ b/frontend/src/features/tickets/accountActions.js @@ -35,6 +35,11 @@ function finalPaymentDue(ticket) { || depositBypassed(ticket); } +// The one predicate behind the รับชำระส่วนที่เหลือ to-do step; accountMoneyBucket reuses it. +function offersFinalPayment(ticket) { + return ticket.status === 'quotation_issued' && finalPaymentDue(ticket); +} + // Mirrors TicketService#requireClosePrerequisites (the predicate behind // closeReady()/canConfirmClose): dual-track deals need FULLY_PAID + a fully // delivered fulfilment status + no outstanding balance + the invoice on @@ -117,7 +122,7 @@ export function nextAccountAction(ticket, viewerRole) { if (canRecordPayments && ticket.status === 'quotation_issued' && ticket.paymentStatus === 'DEPOSIT_NOTICE_ISSUED') { return { key: 'confirmDeposit', label: 'ยืนยันรับมัดจำ', to: dealPath, urgent: false }; } - if (canRecordPayments && ticket.status === 'quotation_issued' && finalPaymentDue(ticket)) { + if (canRecordPayments && offersFinalPayment(ticket)) { return { key: 'confirmFinalPayment', label: 'รับชำระส่วนที่เหลือ', to: dealPath, urgent: false }; } if (closeReady(ticket)) { @@ -140,7 +145,7 @@ export function accountMoneyBucket(ticket) { const outstanding = ticket.amountOutstanding != null && Number(ticket.amountOutstanding) > 0; if (ticket.overdue && outstanding) return 'overdue'; if (ticket.paymentStatus === 'DEPOSIT_NOTICE_ISSUED') return 'depositPending'; - if (ticket.paymentStatus === 'AWAITING_FINAL_PAYMENT') return 'finalPaymentPending'; + if (offersFinalPayment(ticket)) return 'finalPaymentPending'; if (closeReady(ticket)) return 'closeReady'; if (ticket.salesStage === 'CLOSED_PAID' && !ticket.commissionRecorded) return 'commissionPending'; return null; diff --git a/frontend/src/hooks/useHrData.js b/frontend/src/hooks/useHrData.js index 0e12f7c93..4ab90859e 100644 --- a/frontend/src/hooks/useHrData.js +++ b/frontend/src/hooks/useHrData.js @@ -6,6 +6,12 @@ import { hasPermission } from '../app/permissions.js'; import { declaredAllowanceTotal } from '../features/taxAllowance/taxAllowanceSchema.js'; import { selectCurrentDeclaration, taxAllowanceStatusInfo } from '../features/taxAllowance/taxAllowanceStatus.js'; +// One fetch for the queryKeys.dashboardSummary() cache entry; every reader of that key uses it so the +// cached shape can't depend on which screen mounted first. +export const fetchDashboardSummary = () => api.dashboard.summary() + .then((response) => response?.summary ?? response ?? null) + .catch(() => null); + export function useHrData({ user, showToast }) { const queryClient = useQueryClient(); @@ -44,9 +50,7 @@ export function useHrData({ user, showToast }) { const dashboardSummaryQuery = useQuery({ queryKey: queryKeys.dashboardSummary(), - queryFn: () => api.dashboard.summary() - .then((response) => response?.summary ?? response ?? null) - .catch(() => null), + queryFn: fetchDashboardSummary, enabled: !!user && !!api.dashboard?.summary, }); const dashboardSummary = dashboardSummaryQuery.data ?? null; diff --git a/frontend/src/test/fixtures/dashboardMoney.js b/frontend/src/test/fixtures/dashboardMoney.js new file mode 100644 index 000000000..adfc4cb3c --- /dev/null +++ b/frontend/src/test/fixtures/dashboardMoney.js @@ -0,0 +1,72 @@ +// One ticket list + the figures it must produce, shared by the CEO and sales-manager dashboard +// tests so both are pinned to the SAME three numbers (เงินรับแล้ว / ค้างรับ / ยอดคาดการณ์). +import { expect } from 'vitest'; +import { screen } from '@testing-library/react'; + +const base = { + lifecycle: 'ACTIVE', status: 'quotation_issued', paymentStatus: null, fulfillmentStatus: null, + depositPolicy: 'REQUIRED', closeConfirmedAt: null, invoiceOnFile: false, commissionRecorded: false, + overdue: false, amountPaid: 0, +}; + +// Deposit notice issued (ขั้น 10): the whole deal is outstanding. +export const depositPendingDeal = { + ...base, id: 1, code: 'DL-1', customerName: 'ดีลรอมัดจำ', salesStage: 'ORDER_RECEIVED', + paymentStatus: 'DEPOSIT_NOTICE_ISSUED', amountPayable: 213545.25, amountOutstanding: 213545.25, + depositDue: 85418.10, +}; +// Deposit paid, final payment next (ขั้น 18). +export const finalPaymentDeal = { + ...base, id: 2, code: 'DL-2', customerName: 'ดีลรอชำระส่วนที่เหลือ', salesStage: 'DELIVERY_SCHEDULING', + paymentStatus: 'DEPOSIT_PAID', amountPayable: 120000, amountPaid: 40000, amountOutstanding: 80000, +}; +// DEMO-TKT-05 shape: finished deal that still carries an outstanding figure. Not account's work. +export const closedDemoDeal = { + ...base, id: 3, code: 'DEMO-TKT-05', customerName: 'ดีลปิดแล้ว', status: 'closed', + salesStage: 'CLOSED_PAID', commissionRecorded: true, amountPayable: 196880, amountOutstanding: 196880, +}; +// Before ขั้น 10, deposit not required: nextAccountAction offers a step, but account's list never has it. +export const preOrderBypassDeal = { + ...base, id: 4, code: 'DL-4', customerName: 'ดีลก่อนสั่งซื้อ-ไม่เก็บมัดจำ', salesStage: 'QUOTE_BUYER', + depositPolicy: 'NOT_REQUIRED', amountPayable: 50000, amountOutstanding: 50000, +}; +// Before ขั้น 10, no account step at all. +export const preOrderPlainDeal = { + ...base, id: 5, code: 'DL-5', customerName: 'ดีลก่อนสั่งซื้อ', salesStage: 'QUOTE_BUYER', + amountPayable: 30000.25, amountOutstanding: 30000.25, +}; +// Before ขั้น 10 but lost: not in the forecast. +export const lostPreOrderDeal = { + ...base, id: 6, code: 'DL-6', customerName: 'ดีลที่แพ้', lifecycle: 'CLOSED_LOST', salesStage: 'QUOTE_BUYER', + amountPayable: 77777, amountOutstanding: 77777, +}; + +export const moneyFixtureTickets = [ + depositPendingDeal, finalPaymentDeal, closedDemoDeal, preOrderBypassDeal, preOrderPlainDeal, lostPreOrderDeal, +]; + +export const CASH_RECEIVED_THIS_MONTH = 125000.5; +// Per-rep split of CASH_RECEIVED_THIS_MONTH, served ascending on purpose: the panels must sort it themselves. +export const CASH_BY_REP = [ + { repName: 'สมหญิง ขายเก่ง', amount: 35000 }, + { repName: 'สมชาย ขายดี', amount: 90000.5 }, +]; +// The server totals on /api/dashboard/summary. Deliberately NOT what the fixture rows add up to +// (293,545.25 and 80,000.25): /api/tickets is capped at 500 rows, so a screen that sums rows is wrong. +export const SERVER_TOTALS = { financeOutstanding: 411111.11, forecastValue: 222222.22 }; +export const EXPECTED = { + cash: '฿125,000.50', + outstanding: '฿411,111.11', + forecast: '฿222,222.22', +}; + +/** Text of every row/tile carrying `label`: a StatCard, or a
/
pair in a
. */ +export function figureRows(label) { + return screen.queryAllByText(label).map((el) => el.closest('.stat-card, dl > div')?.textContent ?? ''); +} + +export function expectFigure(label, value) { + const rows = figureRows(label); + expect(rows.length, `"${label}" is shown`).toBeGreaterThan(0); + rows.forEach((text) => expect(text).toContain(value)); +}