Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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) {}
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@
TicketSummaryDto summary = jdbc.queryForObject("""
WITH visible_tickets AS (
SELECT t.*
""" + from + where + """

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

View workflow job for this annotation

GitHub Actions / build-and-test

trailing white space will be removed

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

View workflow job for this annotation

GitHub Actions / e2e-real

trailing white space will be removed
)
SELECT COUNT(*) FILTER (WHERE status = 'draft') AS draft,
COUNT(*) FILTER (WHERE status = 'submitted') AS submitted,
Expand Down Expand Up @@ -186,6 +186,68 @@
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<CashByRepDto> 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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -17,6 +19,7 @@ public class DashboardService {
private static final Set<String> COMMISSION_APPROVER_ROLES = Set.of("sales_manager", "ceo");
private static final Set<String> TICKET_VIEW_ALL_ROLES = Set.of("import", "ceo");
private static final Set<String> TICKET_OWN_ROLES = Set.of("sales");
private static final Set<String> CASH_RECEIVED_ROLES = Set.of("account", "ceo", "sales_manager");

private final DashboardRepository dashboardRepository;
private final Clock clock;
Expand Down Expand Up @@ -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<CashByRepDto> 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(),
Expand All @@ -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
);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<CashByRepDto> 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,
Expand All @@ -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<CashByRepDto> cashReceivedByRep,
BigDecimal financeOutstanding,
BigDecimal forecastValue
) {
return new DashboardSummaryDto(
role,
Expand All @@ -67,7 +98,11 @@ public static DashboardSummaryDto of(
tickets.onHold(),
tickets.dormant(),
tickets.paymentOverdue(),
tickets.partiallyDelivered()
tickets.partiallyDelivered(),
cashReceivedThisMonth,
cashReceivedByRep,
financeOutstanding,
forecastValue
);
}
}
71 changes: 49 additions & 22 deletions backend/src/main/java/th/co/glr/hr/ticket/TicketRepository.java
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
}

/**
Expand Down Expand Up @@ -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<String> quotedRecipients) {}
Set<String> 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. */
Expand Down Expand Up @@ -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 + """
Expand All @@ -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"));
});
}

Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -1906,14 +1925,22 @@ public Optional<PaymentDue> 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<DepositNoticePaymentInfo> latestIssuedDepositNotice(long ticketId) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}

/**
Expand Down
Loading
Loading