Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
9137ea0
fix(deal): ขั้น 11/12 deal page — one IR name, owner, demoted manual …
waritctd Oct 29, 2026
e134a0d
fix(deals): status chips and labels follow the deal's real state (P2 …
waritctd Oct 29, 2026
fb52650
fix(deal): coverage flag on its own typed endpoint; drop dead fs-null…
waritctd Oct 29, 2026
a8de703
Merge pull request #1201 from waritctd/fix/deal-stage11-12-ui
waritctd Oct 29, 2026
6cc7efd
Merge branch 'develop' of https://github.com/waritctd/GL-R-ERP into f…
waritctd Oct 29, 2026
61c7ebe
fix(deals): address code review of #1202
waritctd Oct 29, 2026
821a679
Merge pull request #1202 from waritctd/fix/p2-batch1-deal-chips
waritctd Oct 29, 2026
8461ae3
fix(deal): skip route reads true — เสนอราคา ▾ trigger, ข้าม on never-…
waritctd Oct 29, 2026
8871ac2
fix(deal): whole-skipped phase reads ข้าม; unknown first from-stage s…
waritctd Oct 29, 2026
9e815af
Merge pull request #1206 from waritctd/fix/deal-skip-route-ui
waritctd Oct 29, 2026
6ac0094
fix(dashboard): money tiles match finance (QA-17, QA-18)
waritctd Oct 29, 2026
2b7f3bb
fix(dashboard): unique keys for same-name rep rows
waritctd Oct 29, 2026
d78642d
fix(dashboard): code-review fixes for #1205
waritctd Oct 29, 2026
8937ea4
fix(dashboard): ค้างรับ and ยอดคาดการณ์ as server totals (no 500-row …
waritctd Oct 29, 2026
b98d69b
refactor(dashboard): one summary fetch for the shared dashboardSummar…
waritctd Oct 29, 2026
78d37bb
Merge pull request #1205 from waritctd/fix/dashboard-money-matches-fi…
waritctd Oct 29, 2026
b776274
fix(stock): stock lines never go to a factory; all-stock deals go to …
waritctd Oct 29, 2026
ce1b7f2
fix(stock,invoice): code-review follow-ups + W5-01 remaining invoice …
waritctd Oct 29, 2026
bfa56be
chore(api): regenerate response-fields digest after rebasing onto #1205
waritctd Oct 29, 2026
803ee00
Merge pull request #1204 from waritctd/fix/walk4-stock-line-blockers
waritctd Oct 29, 2026
603c950
docs(readme): refresh and add local develop stack guide
waritctd Oct 29, 2026
893087e
fix(sales): P2 batch 4 — PCR/quotation forms (QA-12, QA-28, W3-06/QA-35)
waritctd Oct 29, 2026
aa1f28d
refactor: share the reject/revision reason dialog copy via outcomeRea…
waritctd Oct 29, 2026
443e2f2
fix(sales): code-review follow-ups for PR #1207 outcome dialogs and s…
waritctd Oct 29, 2026
17e7823
Merge pull request #1207 from waritctd/fix/p2-batch4-pcr-quotation-forms
waritctd Oct 29, 2026
b9d6b57
fix(leave): advance notice counts the employee's working days
waritctd Oct 29, 2026
6ef124d
refactor(leave): rewrap advance-notice comment to the block's width
waritctd Oct 29, 2026
372552f
fix(leave): notice gate reuses the request's working-day predicate
waritctd Oct 29, 2026
81ce9c2
Merge pull request #1203 from waritctd/claude/leave-day-counting-syst…
waritctd Oct 29, 2026
977bea7
fix(import): IR factory email and PDF print item size in cm
waritctd Oct 29, 2026
d5159a4
fix(import): null-safe SizeUnit.hasUnit; keep SizeUnit import with th…
waritctd Oct 29, 2026
48d7ca7
Merge pull request #1208 from waritctd/fix/ir-size-unit-cm
waritctd Oct 29, 2026
ee60d38
chore(release): render.yaml -> backend image v2026-10-30
waritctd Oct 30, 2026
315cc14
Merge remote-tracking branch 'origin/main' into release/2026-10-30-de…
waritctd Oct 30, 2026
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
26 changes: 26 additions & 0 deletions backend/src/main/java/th/co/glr/hr/common/SizeUnit.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package th.co.glr.hr.common;

import java.util.regex.Pattern;

/**
* Display-unit helper for tile sizes. Sales data mixes sizes that carry a unit ("60x60 ซม.", "60x120 cm") with
* bare ones ("20x30.5", centimetres since the 2026-10-29 ruling). Anchored at the end of the trimmed value, so a bare size (ends in a digit)
* can never match and still gets its unit.
*/
public final class SizeUnit {

private static final Pattern ALREADY_HAS_UNIT =
Pattern.compile("(?i)(ซม\\.?|มม\\.?|ม\\.?|นิ้ว|cm\\.?|mm\\.?|m\\.?|in\\.?|\")\\s*$");

private SizeUnit() {}

public static boolean hasUnit(String size) {
return size != null && ALREADY_HAS_UNIT.matcher(size.trim()).find();
}

/** Owner ruling 2026-10-30: IR factory email/PDF show cm. Unitless gets " cm"; blank stays as-is. */
public static String withCmUnit(String size) {
if (size == null || size.isBlank()) return size;
return hasUnit(size) ? size : size + " cm";
}
}
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
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -731,7 +731,7 @@ private static List<String> leadTimeGroups(List<DealQuotationItemDto> items, boo
for (DealQuotationItemDto item : ordered) {
Integer min = item.leadTimeMinDays();
Integer max = item.leadTimeMaxDays();
boolean hasLeadTime = min != null && max != null;
boolean hasLeadTime = hasImportLeadTime(item);
boolean continuesGroup = hasLeadTime && groupMin != null
&& min.equals(groupMin) && max.equals(groupMax) && item.seq() == groupLastSeq + 1;
if (continuesGroup) {
Expand Down Expand Up @@ -767,7 +767,13 @@ private static List<String> leadTimeGroups(List<DealQuotationItemDto> items, boo
* PLAIN rows (no lead-time fields at all) or a DRAFT preview before any TILE row's lead time
* has been filled in both answer {@code false} here. */
private static boolean hasAnyLeadTime(List<DealQuotationItemDto> items) {
return items.stream().anyMatch(i -> i.leadTimeMinDays() != null && i.leadTimeMaxDays() != null);
return items.stream().anyMatch(DealQuotationRenderAdapter::hasImportLeadTime);
}

/** A stock line (IN_THAILAND) is not imported, so its lead time (if any is stored) is never printed. */
private static boolean hasImportLeadTime(DealQuotationItemDto item) {
return item.leadTimeMinDays() != null && item.leadTimeMaxDays() != null
&& !th.co.glr.hr.pricingrequest.PricingRequestService.STOCK_IN_THAILAND.equals(item.stockSource());
}

// Matches the leading "N." numbering convention every OTHER remark line (LINE4/5/6/7/8 and
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4075,7 +4075,7 @@ private void requireEveryTileItemHasALeadTime(List<DealQuotationItemDto> items)
for (DealQuotationItemDto item : items) {
boolean tile = item.lineType() == null || WastageCalculator.LINE_TYPE_TILE.equals(item.lineType());
// In-Thailand stock lines skip import (owner ruling 2026-10-28); IN_TRANSIT still needs a lead time (2026-10-29).
if (tile && !"IN_THAILAND".equals(item.stockSource()) && (item.leadTimeMinDays() == null || item.leadTimeMaxDays() == null)) {
if (tile && !th.co.glr.hr.pricingrequest.PricingRequestService.STOCK_IN_THAILAND.equals(item.stockSource()) && (item.leadTimeMinDays() == null || item.leadTimeMaxDays() == null)) {
missingSeqs.add(String.valueOf(item.seq()));
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,55 @@ private void insertItems(long docId, List<DepositNoticeItemRequest> items) {
}
}

/** One stored line of a new-engine quotation, as the remaining invoice prints it: the STORED amount (never
* recomputed) and, for an ADJUSTMENT (ส่วนลดพิเศษ), qty -1 with its own negative amount. */
public record QuotationInvoiceLine(int seq, String description, BigDecimal qty, String unit, BigDecimal unitPrice,
BigDecimal salesDiscount, BigDecimal netUnitPrice, BigDecimal amount,
boolean adjustment) {}

public List<QuotationInvoiceLine> findQuotationInvoiceLines(long quotationId) {
return jdbc.query("""
SELECT seq, description, qty, raw_unit, unit_price, sales_discount, final_unit_price, amount, line_type
FROM sales.quotation_item
WHERE quotation_id = :quotationId
ORDER BY seq
""", Map.of("quotationId", quotationId), (rs, rowNum) -> {
boolean adjustment = "ADJUSTMENT".equals(rs.getString("line_type"));
String description = rs.getString("description");
String unit = rs.getString("raw_unit");
return new QuotationInvoiceLine(rs.getInt("seq"),
description == null || description.isBlank() ? "รายการสินค้า" : description,
rs.getBigDecimal("qty"),
adjustment ? null : (unit == null || unit.isBlank() ? "หน่วย" : unit),
rs.getBigDecimal("unit_price"), rs.getBigDecimal("sales_discount"),
rs.getBigDecimal("final_unit_price"), rs.getBigDecimal("amount"), adjustment);
});
}

/** The quotation payable reads: id, number, stored total, document language (null = TH), and whether it is a
* new-engine row (origin set) - legacy rows (origin NULL) belong to the legacy resolver. */
public record AnsweredQuotation(long id, String number, BigDecimal totalAmount, String documentLanguage,
boolean newEngine) {}

/**
* Exactly payable's ANSWERED selection ({@link th.co.glr.hr.ticket.TicketRepository#ANSWERED_QUOTATION_SQL}, no
* extra origin filter, same pick order: ACCEPTED first, BUYER over OWNER over the rest, newest), so payable and
* the invoice can never choose different quotations.
*/
public Optional<AnsweredQuotation> findAnsweredQuotation(long ticketId) {
List<AnsweredQuotation> rows = jdbc.query("SELECT q.quotation_id, q.number, q.total_amount, q.document_language,"
+ " q.origin IS NOT NULL AS new_engine FROM sales.quotation q"
+ " WHERE q.ticket_id = :ticketId AND "
+ th.co.glr.hr.ticket.TicketRepository.ANSWERED_QUOTATION_SQL
+ " ORDER BY CASE WHEN q.doc_status = 'ACCEPTED' THEN 0 ELSE 1 END,"
+ " CASE q.recipient_type WHEN 'BUYER' THEN 0 WHEN 'OWNER' THEN 1 ELSE 2 END,"
+ " q.accepted_at DESC NULLS LAST, q.issued_at DESC, q.quotation_id DESC LIMIT 1",
Map.of("ticketId", ticketId),
(rs, rowNum) -> new AnsweredQuotation(rs.getLong("quotation_id"), rs.getString("number"),
rs.getBigDecimal("total_amount"), rs.getString("document_language"), rs.getBoolean("new_engine")));
return rows.stream().findFirst();
}

/** One deal quotation's {@code sales.quotation_item} lines, by {@code seq}, as deposit-notice
* items. Reads the RESOLVED display columns the quotation itself prints
* ({@code qty}/{@code raw_unit}/{@code unit_price}/{@code final_unit_price}), NOT the legacy
Expand Down
Loading
Loading