diff --git a/backend/src/main/java/th/co/glr/hr/common/SizeUnit.java b/backend/src/main/java/th/co/glr/hr/common/SizeUnit.java new file mode 100644 index 000000000..a4eac2532 --- /dev/null +++ b/backend/src/main/java/th/co/glr/hr/common/SizeUnit.java @@ -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"; + } +} 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/dealquotation/DealQuotationRenderAdapter.java b/backend/src/main/java/th/co/glr/hr/dealquotation/DealQuotationRenderAdapter.java index 03c6bc36a..7744e4317 100644 --- a/backend/src/main/java/th/co/glr/hr/dealquotation/DealQuotationRenderAdapter.java +++ b/backend/src/main/java/th/co/glr/hr/dealquotation/DealQuotationRenderAdapter.java @@ -731,7 +731,7 @@ private static List leadTimeGroups(List 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) { @@ -767,7 +767,13 @@ private static List leadTimeGroups(List 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 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 diff --git a/backend/src/main/java/th/co/glr/hr/dealquotation/DealQuotationService.java b/backend/src/main/java/th/co/glr/hr/dealquotation/DealQuotationService.java index 61a94ded5..5e7fec6cc 100644 --- a/backend/src/main/java/th/co/glr/hr/dealquotation/DealQuotationService.java +++ b/backend/src/main/java/th/co/glr/hr/dealquotation/DealQuotationService.java @@ -4075,7 +4075,7 @@ private void requireEveryTileItemHasALeadTime(List 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())); } } diff --git a/backend/src/main/java/th/co/glr/hr/deposit/DepositNoticeRepository.java b/backend/src/main/java/th/co/glr/hr/deposit/DepositNoticeRepository.java index 634932892..05feea5a8 100644 --- a/backend/src/main/java/th/co/glr/hr/deposit/DepositNoticeRepository.java +++ b/backend/src/main/java/th/co/glr/hr/deposit/DepositNoticeRepository.java @@ -387,6 +387,55 @@ private void insertItems(long docId, List 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 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 findAnsweredQuotation(long ticketId) { + List 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 diff --git a/backend/src/main/java/th/co/glr/hr/deposit/DepositNoticeService.java b/backend/src/main/java/th/co/glr/hr/deposit/DepositNoticeService.java index 31c111fc7..28f3bdee6 100644 --- a/backend/src/main/java/th/co/glr/hr/deposit/DepositNoticeService.java +++ b/backend/src/main/java/th/co/glr/hr/deposit/DepositNoticeService.java @@ -468,10 +468,20 @@ public byte[] getPdf(long docId, UserPrincipal actor) { record RemainingInvoiceMoney(BigDecimal netAmount, BigDecimal vatAmount, BigDecimal grandTotal) {} RemainingInvoiceMoney computeRemainingInvoiceMoney(BigDecimal itemsTotal, BigDecimal depositAmount) { + return computeRemainingInvoiceMoney(itemsTotal, depositAmount, VAT_RATE); + } + + /** The money of a resolved invoice: its own VAT rate (EN = 0) and items total (the quotation's stored total). */ + RemainingInvoiceMoney computeRemainingInvoiceMoney(ResolvedRemainingInvoice r) { + return computeRemainingInvoiceMoney(r.itemsTotal(), r.depositAmount() == null ? BigDecimal.ZERO : r.depositAmount(), + r.vatRate() != null ? r.vatRate() : VAT_RATE); + } + + RemainingInvoiceMoney computeRemainingInvoiceMoney(BigDecimal itemsTotal, BigDecimal depositAmount, BigDecimal vatRate) { BigDecimal netAmount = itemsTotal.subtract(depositAmount).setScale(2, RoundingMode.HALF_UP); // Owner ruling 2026-10-28 (SK-07): each tax invoice's VAT = round2(its own net x 7%) -- usual Thai practice; // a satang gap against the deal's payable is normal and outstanding follows the ISSUED documents. - BigDecimal vatAmount = netAmount.multiply(VAT_RATE).setScale(2, RoundingMode.HALF_UP); + BigDecimal vatAmount = netAmount.multiply(vatRate).setScale(2, RoundingMode.HALF_UP); BigDecimal grandTotal = netAmount.add(vatAmount).setScale(2, RoundingMode.HALF_UP); return new RemainingInvoiceMoney(netAmount, vatAmount, grandTotal); } @@ -509,8 +519,8 @@ public RemainingInvoiceOptionsDto getRemainingInvoiceOptions(long ticketId, Long List items = resolved.items(); BigDecimal depositAmount = resolved.depositAmount(); int depositRowCount = depositAmount.compareTo(BigDecimal.ZERO) != 0 ? 1 : 0; - BigDecimal itemsTotal = sumAmounts(items); - RemainingInvoiceMoney money = computeRemainingInvoiceMoney(itemsTotal, depositAmount); + BigDecimal itemsTotal = resolved.itemsTotal(); + RemainingInvoiceMoney money = computeRemainingInvoiceMoney(resolved); BigDecimal netAmount = money.netAmount(); BigDecimal vatAmount = money.vatAmount(); BigDecimal totalPayable = money.grandTotal(); @@ -630,8 +640,31 @@ record ResolvedRemainingInvoice( List quotationOptions, Long defaultQuotationId, String blockingReason, - Long matchedDepositNoticeId - ) {} + Long matchedDepositNoticeId, + // Null = the standard 7% and the sum of the item amounts; set by the new-engine path (EN = 0%, and net = + // the quotation's stored total_amount). + BigDecimal vatRate, + BigDecimal itemsTotalOverride + ) { + ResolvedRemainingInvoice(List items, BigDecimal depositAmount, String defaultReference, + List referenceOptions, String defaultDepositReference, + List depositReferenceOptions, + List quotationOptions, Long defaultQuotationId, + String blockingReason, Long matchedDepositNoticeId) { + this(items, depositAmount, defaultReference, referenceOptions, defaultDepositReference, + depositReferenceOptions, quotationOptions, defaultQuotationId, blockingReason, matchedDepositNoticeId, + null, null); + } + + /** The items total this document bills: the quotation's stored total when set, else the line sum. */ + BigDecimal itemsTotal() { + return itemsTotalOverride != null ? itemsTotalOverride : sumLines(items); + } + + private static BigDecimal sumLines(List items) { + return items.stream().map(RemainingInvoiceItemDto::amount).reduce(BigDecimal.ZERO, BigDecimal::add); + } + } /** One quotation this ticket's remaining invoice COULD be sourced from, paired with the * deposit notice it matched to (see this section's header comment for the matching rule) — @@ -708,9 +741,43 @@ ResolvedRemainingInvoice resolveRemainingInvoice(long ticketId, TicketDto ticket return blocked("ยังไม่ได้ออกใบแจ้งยอดมัดจำจากใบเสนอราคา " + latestAccepted.number()); } } + // No deposit to wait for and no legacy quotation: a deal quoted on the NEW engine (W5-01) bills the answered + // quotation in full. A deal WITH a deposit takes the legacy path below (the issued notice's items). + if (bypass) { + DepositNoticeRepository.AnsweredQuotation answered = docs.findAnsweredQuotation(ticketId).orElse(null); + if (answered != null && answered.newEngine()) { + return resolveFromNewEngineQuotation(answered); + } + } return resolveLegacy(ticketId, ticket, notices); } + /** + * W5-01 (owner ruling, Ploy): the whole answered new-engine quotation, no deposit deduction (bypass policy: there + * is no notice). It prints the quotation's OWN lines - each line's stored amount, the ส่วนลดพิเศษ row as its own + * negative line - and money follows the stored total_amount and the document language (EN = no VAT), exactly as + * payable reads them. A row with no stored total is refused (payable skips it too). + */ + private ResolvedRemainingInvoice resolveFromNewEngineQuotation(DepositNoticeRepository.AnsweredQuotation answered) { + if (answered.totalAmount() == null) { + return blocked("ใบเสนอราคา " + answered.number() + " ยังไม่มียอดรวมที่บันทึกไว้ จึงออกใบแจ้งหนี้ไม่ได้"); + } + List items = new ArrayList<>(); + for (DepositNoticeRepository.QuotationInvoiceLine line : docs.findQuotationInvoiceLines(answered.id())) { + BigDecimal discountPerUnit = line.adjustment() || line.unitPrice() == null || line.netUnitPrice() == null + ? null : line.unitPrice().subtract(line.netUnitPrice()); + items.add(new RemainingInvoiceItemDto(line.seq(), line.description(), line.qty(), line.unit(), + line.unitPrice(), shortDiscountLabel(discountPerUnit), line.netUnitPrice(), line.amount())); + } + if (items.isEmpty()) { + return blocked("ใบเสนอราคา " + answered.number() + " ไม่มีรายการสินค้า จึงออกใบแจ้งหนี้ไม่ได้"); + } + return new ResolvedRemainingInvoice(items, BigDecimal.ZERO, answered.number(), List.of(), null, List.of(), + List.of(), null, null, null, + th.co.glr.hr.dealquotation.WastageCalculator.vatRateFor(answered.documentLanguage()), + answered.totalAmount()); + } + /** Item/deduction sourcing (owner ruling D11) and rule C (net must not be negative), applied * to the single chosen quotation — see this section's header comment for the full rules. */ private ResolvedRemainingInvoice resolveFromQuotation(long ticketId, QualifyingQuotation chosen, diff --git a/backend/src/main/java/th/co/glr/hr/deposit/RemainingInvoicePdfRenderer.java b/backend/src/main/java/th/co/glr/hr/deposit/RemainingInvoicePdfRenderer.java index 532925540..fe9364f99 100644 --- a/backend/src/main/java/th/co/glr/hr/deposit/RemainingInvoicePdfRenderer.java +++ b/backend/src/main/java/th/co/glr/hr/deposit/RemainingInvoicePdfRenderer.java @@ -43,7 +43,15 @@ public byte[] toXls(RemainingInvoiceRenderModel m) { int d = DocumentSheet.fillItems(sh, lines, NOTES_ROW, blocks); DocumentSheet.num(sh, NET + d, 8, m.netAmount()); - DocumentSheet.num(sh, VAT + d, 8, m.vatAmount()); + if (m.vatAmount().signum() == 0 && m.netAmount().signum() != 0) { + // No VAT on this document (an EN-quotation deal): the template's "ภาษีมูลค่าเพิ่ม 7%" row is left blank, + // as the EN quotation form has no VAT row either. + DocumentSheet.str(sh, VAT + d, 4, null); + DocumentSheet.str(sh, VAT + d, 7, null); + DocumentSheet.str(sh, VAT + d, 8, null); + } else { + DocumentSheet.num(sh, VAT + d, 8, m.vatAmount()); + } DocumentSheet.num(sh, GRAND + d, 8, m.grandTotal()); DocumentSheet.preparer(sh, PREPARER + d, m.preparerName()); DocumentSheet.signatureDate(sh, SIGDATE + d, m.docDate()); diff --git a/backend/src/main/java/th/co/glr/hr/deposit/RemainingInvoiceService.java b/backend/src/main/java/th/co/glr/hr/deposit/RemainingInvoiceService.java index 3b12fd6ae..e1df6dc84 100644 --- a/backend/src/main/java/th/co/glr/hr/deposit/RemainingInvoiceService.java +++ b/backend/src/main/java/th/co/glr/hr/deposit/RemainingInvoiceService.java @@ -153,8 +153,8 @@ public RemainingInvoiceDocumentDto createDraft(long ticketId, RemainingInvoiceDr BigDecimal depositAmount = nz(resolved.depositAmount()); List notes = req != null && req.notes() != null ? req.notes() : defaultNotes(); - BigDecimal itemsTotal = sumAmounts(items); - DepositNoticeService.RemainingInvoiceMoney money = depositNoticeService.computeRemainingInvoiceMoney(itemsTotal, depositAmount); + BigDecimal itemsTotal = resolved.itemsTotal(); + DepositNoticeService.RemainingInvoiceMoney money = depositNoticeService.computeRemainingInvoiceMoney(resolved); DepositNoticeService.CustomerHeaderInfo header = snap.header(); TicketSummaryDto s = snap.ticketSummary(); @@ -203,8 +203,8 @@ public RemainingInvoiceDocumentDto updateDraft(long id, RemainingInvoiceDraftReq BigDecimal depositAmount = nz(resolved.depositAmount()); List notes = req != null && req.notes() != null ? req.notes() : existing.notes(); - BigDecimal itemsTotal = sumAmounts(items); - DepositNoticeService.RemainingInvoiceMoney money = depositNoticeService.computeRemainingInvoiceMoney(itemsTotal, depositAmount); + BigDecimal itemsTotal = resolved.itemsTotal(); + DepositNoticeService.RemainingInvoiceMoney money = depositNoticeService.computeRemainingInvoiceMoney(resolved); DepositNoticeService.CustomerHeaderInfo header = snap.header(); TicketSummaryDto s = snap.ticketSummary(); @@ -266,8 +266,8 @@ public RemainingInvoiceDocumentDto issue(long id, UserPrincipal actor) { } BigDecimal depositAmount = nz(resolved.depositAmount()); - BigDecimal itemsTotal = sumAmounts(items); - DepositNoticeService.RemainingInvoiceMoney money = depositNoticeService.computeRemainingInvoiceMoney(itemsTotal, depositAmount); + BigDecimal itemsTotal = resolved.itemsTotal(); + DepositNoticeService.RemainingInvoiceMoney money = depositNoticeService.computeRemainingInvoiceMoney(resolved); DepositNoticeService.CustomerHeaderInfo header = snap.header(); TicketSummaryDto snapSummary = snap.ticketSummary(); diff --git a/backend/src/main/java/th/co/glr/hr/finance/FinanceDealService.java b/backend/src/main/java/th/co/glr/hr/finance/FinanceDealService.java index 5fdbb5be1..94665d22c 100644 --- a/backend/src/main/java/th/co/glr/hr/finance/FinanceDealService.java +++ b/backend/src/main/java/th/co/glr/hr/finance/FinanceDealService.java @@ -192,11 +192,15 @@ private FinanceDealDto build(long ticketId, UserPrincipal actor) { private List availableActions(long ticketId, UserPrincipal actor, TicketSummaryDto s) { // Scope was enforced up front (requireFinanceAccess); an authorised action may have moved the deal out of it. var all = ticketService.financeActionsAlreadyScoped(ticketId, actor).availableActions(); + // QA-19: a manual money-stage move only ever goes FORWARD (a closed deal is not offered "move to S11"). + int current = DealStage.indexOf(s.salesStage()); boolean anyMoneyStage = all.stream().anyMatch(a -> "ADVANCE_STAGE".equals(a.action()) - && DealStage.ACCOUNT_TARGET_STAGES.contains(a.targetStage())); + && DealStage.ACCOUNT_TARGET_STAGES.contains(a.targetStage()) + && DealStage.indexOf(a.targetStage()) > current); List actions = new java.util.ArrayList<>(all.stream() .filter(a -> MONEY_ACTIONS.contains(a.action()) - || ("ADVANCE_STAGE".equals(a.action()) && DealStage.ACCOUNT_TARGET_STAGES.contains(a.targetStage())) + || ("ADVANCE_STAGE".equals(a.action()) && DealStage.ACCOUNT_TARGET_STAGES.contains(a.targetStage()) + && DealStage.indexOf(a.targetStage()) > current) || ("UPDATE_STAGE".equals(a.action()) && anyMoneyStage)) .map(FinanceDealService::toAction) .toList()); diff --git a/backend/src/main/java/th/co/glr/hr/importrequest/ImportRequestController.java b/backend/src/main/java/th/co/glr/hr/importrequest/ImportRequestController.java index a13ca69e3..f4721a2a9 100644 --- a/backend/src/main/java/th/co/glr/hr/importrequest/ImportRequestController.java +++ b/backend/src/main/java/th/co/glr/hr/importrequest/ImportRequestController.java @@ -20,6 +20,7 @@ import org.springframework.web.bind.annotation.RestController; import th.co.glr.hr.auth.SessionContext; import th.co.glr.hr.auth.UserPrincipal; +import th.co.glr.hr.importrequest.ImportRequestDtos.ImportRequestCoverageResponse; import th.co.glr.hr.importrequest.ImportRequestDtos.ImportRequestDto; import th.co.glr.hr.importrequest.ImportRequestRequests.AdvanceImportStepRequest; import th.co.glr.hr.importrequest.ImportRequestRequests.CreateImportRequestsRequest; @@ -103,6 +104,13 @@ Map> listForTicket(@PathVariable long ticketId, H return Map.of("importRequests", service.list(ticketId, user)); } + /** Read-only: does any factory on the deal still need an IR? Its own route so the list stays cheap. */ + @GetMapping("/tickets/{ticketId}/import-requests/coverage") + ImportRequestCoverageResponse coverage(@PathVariable long ticketId, HttpSession session) { + UserPrincipal user = sessions.requireUser(session); + return new ImportRequestCoverageResponse(service.hasUncoveredFactories(ticketId, user)); + } + @GetMapping("/import-requests/{id}") Map getStored(@PathVariable long id, HttpSession session) { UserPrincipal user = sessions.requireUser(session); diff --git a/backend/src/main/java/th/co/glr/hr/importrequest/ImportRequestDtos.java b/backend/src/main/java/th/co/glr/hr/importrequest/ImportRequestDtos.java index 362dd8678..83b93d387 100644 --- a/backend/src/main/java/th/co/glr/hr/importrequest/ImportRequestDtos.java +++ b/backend/src/main/java/th/co/glr/hr/importrequest/ImportRequestDtos.java @@ -113,6 +113,9 @@ public record ImportRequestDto( List items ) {} + /** GET /tickets/{id}/import-requests/coverage. */ + public record ImportRequestCoverageResponse(boolean hasUncoveredFactories) {} + public record ImportRequestItemDto( long id, long importRequestId, diff --git a/backend/src/main/java/th/co/glr/hr/importrequest/ImportRequestQueryRepository.java b/backend/src/main/java/th/co/glr/hr/importrequest/ImportRequestQueryRepository.java index 3003c05e9..9b183896e 100644 --- a/backend/src/main/java/th/co/glr/hr/importrequest/ImportRequestQueryRepository.java +++ b/backend/src/main/java/th/co/glr/hr/importrequest/ImportRequestQueryRepository.java @@ -186,7 +186,10 @@ public void setItemFactoryIfBlank(long ticketId, long ticketItemId, String facto public List factoryResolutionCandidates(long ticketId) { return jdbc.query(""" - SELECT ti.item_id, ti.brand, ti.model, ti.size, ti.unit, + SELECT ti.item_id, ti.brand, ti.model, + -- cm ruling 2026-10-30: the line's own catalogue row (ti.catalog_price_id) wins + COALESCE(pp.size_cm, ti.size) AS size, + ti.unit, ti.qty - COALESCE(ti.qty_from_stock, 0) AS remaining_qty, pri.resolved_factory_id AS pri_factory_id, NULLIF(BTRIM(pri.factory), '') AS pri_factory_name, diff --git a/backend/src/main/java/th/co/glr/hr/importrequest/ImportRequestRenderer.java b/backend/src/main/java/th/co/glr/hr/importrequest/ImportRequestRenderer.java index e096d8948..27782d716 100644 --- a/backend/src/main/java/th/co/glr/hr/importrequest/ImportRequestRenderer.java +++ b/backend/src/main/java/th/co/glr/hr/importrequest/ImportRequestRenderer.java @@ -17,6 +17,7 @@ import org.apache.pdfbox.pdmodel.font.PDType0Font; import org.springframework.core.io.ClassPathResource; import org.springframework.stereotype.Component; +import th.co.glr.hr.common.SizeUnit; /** * Draws an import request onto the business's own F-SM-001 (04) form and returns it ready to print @@ -337,7 +338,7 @@ private void drawBody(PDPageContentStream cs, PDFont font, ImportRequestFormData } left(cs, font, r.code(), r.codeX(), y, BODY_SIZE, COL_SIZE_L - r.codeX() - CELL_PAD); - left(cs, font, r.size(), COL_SIZE_L + CELL_PAD, y, BODY_SIZE, + left(cs, font, SizeUnit.withCmUnit(r.size()), COL_SIZE_L + CELL_PAD, y, BODY_SIZE, COL_QTY_L - COL_SIZE_L - 2 * CELL_PAD); centred(cs, font, r.qty(), COL_QTY_L, COL_UNIT_L, y, BODY_SIZE); centred(cs, font, r.unit(), COL_UNIT_L, COL_FOR_L, y, BODY_SIZE); diff --git a/backend/src/main/java/th/co/glr/hr/importrequest/ImportRequestRepository.java b/backend/src/main/java/th/co/glr/hr/importrequest/ImportRequestRepository.java index 2b91612ad..9e4215fa3 100644 --- a/backend/src/main/java/th/co/glr/hr/importrequest/ImportRequestRepository.java +++ b/backend/src/main/java/th/co/glr/hr/importrequest/ImportRequestRepository.java @@ -510,6 +510,16 @@ public String nextDocNumber(int yearTh) { return String.format("IR%02d%03d", yearTh % 100, seq); } + /** True when ANY line of the form is already covered from stock (qty_from_stock >= qty, qty > 0): never ordered. */ + public boolean anyItemCoveredFromStock(long importRequestId) { + Boolean covered = jdbc.queryForObject(""" + SELECT EXISTS(SELECT 1 FROM sales.import_request_item iri + JOIN sales.ticket_item ti ON ti.item_id = iri.ticket_item_id + WHERE iri.import_request_id = :id AND ti.qty > 0 AND ti.qty_from_stock >= ti.qty) + """, Map.of("id", importRequestId), Boolean.class); + return Boolean.TRUE.equals(covered); + } + public boolean docNumberExists(String docNumber) { Boolean found = jdbc.queryForObject( "SELECT EXISTS(SELECT 1 FROM sales.import_request WHERE doc_number = :n)", diff --git a/backend/src/main/java/th/co/glr/hr/importrequest/ImportRequestService.java b/backend/src/main/java/th/co/glr/hr/importrequest/ImportRequestService.java index 43fb3d169..b362b2894 100644 --- a/backend/src/main/java/th/co/glr/hr/importrequest/ImportRequestService.java +++ b/backend/src/main/java/th/co/glr/hr/importrequest/ImportRequestService.java @@ -18,6 +18,7 @@ import org.springframework.transaction.annotation.Transactional; import th.co.glr.hr.auth.UserPrincipal; import th.co.glr.hr.common.ApiException; +import th.co.glr.hr.common.SizeUnit; import th.co.glr.hr.factory.FactoryConfigDto; import th.co.glr.hr.factory.FactoryConfigRepository; import th.co.glr.hr.importrequest.ImportRequestDtos.ImportRequestDto; @@ -203,6 +204,33 @@ public List list(long ticketId, UserPrincipal actor) { return withPageCounts(stored.findByTicket(ticketId)); } + /** + * Read-only twin of {@link #createDrafts}'s coverage check, for the list response: is there a factory + * on this deal that still needs an IR? Uses the same {@link ImportRequestQueryRepository + * #factoryResolutionCandidates} cascade, but creates no factory. A typed name that matches no + * factory (or a line with no factory at all) counts as uncovered; only a LIVE (DRAFT / ISSUED) row + * covers a factory — a SUPERSEDED one does not. A deal with nothing left to order is not uncovered. + */ + public boolean hasUncoveredFactories(long ticketId, UserPrincipal actor) { + requireTicket(ticketId); + requireRead(ticketId, actor); + Set covered = stored.findByTicket(ticketId).stream() + .filter(r -> ImportRequestStatus.isLive(r.status())) + .map(ImportRequestDto::factoryId) + .collect(Collectors.toSet()); + Map idByNormalizedName = factories.findAll().stream() + .collect(Collectors.toMap(f -> normalizeFactoryName(f.factoryName()), FactoryConfigDto::id, (x, y) -> x)); + for (LineFactoryCandidate c : repository.factoryResolutionCandidates(ticketId)) { + Long factoryId = c.directFactoryId() != null ? c.directFactoryId() + : c.candidateFactoryName() != null ? idByNormalizedName.get(normalizeFactoryName(c.candidateFactoryName())) + : null; + if (factoryId == null || !covered.contains(factoryId)) { + return true; + } + } + return false; + } + public ImportRequestDto get(long id, UserPrincipal actor) { ImportRequestDto row = requireStored(id); requireRead(row.ticketId(), actor); @@ -559,6 +587,12 @@ public ImportRequestDto issue(long id, IssueImportRequestRequest request, UserPr if (draft.items().isEmpty()) { throw new ApiException(HttpStatus.CONFLICT, "ใบขอซื้อยังไม่มีรายการสินค้า"); } + // A draft made before the order was confirmed can name a line that is now covered from stock; issuing it + // would order stock the customer already has from the factory. + if (stored.anyItemCoveredFromStock(id)) { + throw new ApiException(HttpStatus.CONFLICT, + "ใบขอซื้อนี้มีรายการที่ครอบคลุมจากสต็อกแล้ว — ไม่ต้องสั่งนำเข้า กรุณาลบฉบับร่างนี้แล้วสร้างใบขอซื้อใหม่"); + } // Owner decision 09-18 #2: "อื่นๆ/unknown must be typed by sales" — a form with no estimate // at all may not be issued. Both-or-neither is already the DB invariant // (chk_import_request_lead_time), so checking one implies the other. @@ -800,7 +834,7 @@ private void appendItemBlock(StringBuilder body, int lineNo, ImportRequestDtos.I details.add("Surface: " + item.texture()); } if (item.size() != null && !item.size().isBlank()) { - details.add("Size: " + item.size()); + details.add("Size: " + SizeUnit.withCmUnit(item.size())); // cm ruling 2026-10-30 } if (!details.isEmpty()) { body.append(" ").append(String.join(" | ", details)).append("\n"); diff --git a/backend/src/main/java/th/co/glr/hr/leave/LeaveRuleMessages.java b/backend/src/main/java/th/co/glr/hr/leave/LeaveRuleMessages.java index 13f148d97..3dcfe3d71 100644 --- a/backend/src/main/java/th/co/glr/hr/leave/LeaveRuleMessages.java +++ b/backend/src/main/java/th/co/glr/hr/leave/LeaveRuleMessages.java @@ -184,7 +184,7 @@ static String render(LeaveRuleCode code, Map params) { // WARN_UNPAID_ALL (V164) -- see this class's WARN_UNPAID_TAIL block comment above. case ADVANCE_NOTICE -> format(code, withSection, - "การ{leaveTypeNameTh}ต้องยื่นล่วงหน้าอย่างน้อย {noticeDays} วัน (ระเบียบข้อ {section}) " + "การ{leaveTypeNameTh}ต้องยื่นล่วงหน้าอย่างน้อย {noticeDays} วันทำงาน (ระเบียบข้อ {section}) " + "คำขอนี้ยื่นไม่ทันกำหนด " + WARN_UNPAID_TAIL, "leaveTypeNameTh", "noticeDays", "section", "days"); diff --git a/backend/src/main/java/th/co/glr/hr/leave/LeaveService.java b/backend/src/main/java/th/co/glr/hr/leave/LeaveService.java index c565630ad..6415a3b0f 100644 --- a/backend/src/main/java/th/co/glr/hr/leave/LeaveService.java +++ b/backend/src/main/java/th/co/glr/hr/leave/LeaveService.java @@ -453,9 +453,12 @@ public LeaveRequestDto submit(SubmitLeaveRequest request, MultipartFile attachme // shared by every LeaveDayMath call below (validateSubDayTimes, computeTotalDays, // totalDaysByYear) -- see LeaveRepository#workingDayPredicate's javadoc for the fixed query // cost (two queries, not one per day) and LeaveDayMath's javadoc for the six-day-department/ - // holiday defects this closes. + // holiday defects this closes. Range starts at today+1 when earlier than the start date so the + // advance-notice gate can reuse it (widening only adds holidays outside the leave). + LocalDate noticeFrom = LocalDate.now(clock).plusDays(1); + LocalDate predicateFrom = request.startDate().isBefore(noticeFrom) ? request.startDate() : noticeFrom; Predicate isWorkingDay = - leaveRepository.workingDayPredicate(employeeId, request.startDate(), request.endDate()); + leaveRepository.workingDayPredicate(employeeId, predicateFrom, request.endDate()); // Partial-day span (V166): resolved ONCE here (at most two extra queries -- one per distinct // boundary date, none at all for a whole-day request) and reused by BOTH validateSubDayTimes // (time-bound validation) and the spanFractions ledger below, rather than each resolving the @@ -502,7 +505,7 @@ public LeaveRequestDto submit(SubmitLeaveRequest request, MultipartFile attachme // chain, exactly as before Phase A0b added the QUICK-preview skip; see AutoRejectResult's // Javadoc and #preview's Javadoc for the only caller that ever passes `false`. AutoRejectResult autoReject = autoRejectNote(leaveType, employeeId, request.startDate(), request.endDate(), - hasAttachment, totalDays, purposeCode, requestedAsEmergency, true); + hasAttachment, totalDays, purposeCode, requestedAsEmergency, true, isWorkingDay); // Phase A0a (structured rejection outcome): `blocking` carries the machine-readable // LeaveRuleCode + params alongside the same Thai sentence that used to be the ONLY thing // #autoRejectNote returned -- systemNote/status derive from it exactly as before, since @@ -736,7 +739,10 @@ public LeavePreviewDto preview(LeavePreviewRequest request, UserPrincipal user) } validateDateRange(startDate, endDate); - Predicate isWorkingDay = leaveRepository.workingDayPredicate(employeeId, startDate, endDate); + // Range starts at today+1 when earlier than startDate so the advance-notice gate can reuse it. + LocalDate noticeFrom = LocalDate.now(clock).plusDays(1); + Predicate isWorkingDay = leaveRepository.workingDayPredicate(employeeId, + startDate.isBefore(noticeFrom) ? startDate : noticeFrom, endDate); // Partial-day span (V166): LeavePreviewRequest now carries the SAME optional startTime/ // endTime SubmitLeaveRequest does (widened from the original "no sub-day times in this // shape at all" design -- see LeavePreviewRequest's own Javadoc for why), resolved and @@ -757,7 +763,7 @@ public LeavePreviewDto preview(LeavePreviewRequest request, UserPrincipal user) spanFractions, startDate, endDate, request.startTime(), request.endTime(), leaveType, isWorkingDay); boolean evaluateDepartmentCoverage = depth == LeavePreviewDepth.FULL; AutoRejectResult autoReject = autoRejectNote(leaveType, employeeId, startDate, endDate, - hasAttachment, totalDays, purposeCode, requestedAsEmergency, evaluateDepartmentCoverage); + hasAttachment, totalDays, purposeCode, requestedAsEmergency, evaluateDepartmentCoverage, isWorkingDay); LeaveRuleOutcome outcome = autoReject.blocking(); boolean approved = outcome == null; int quotaYear = startDate.getYear(); @@ -2274,7 +2280,7 @@ private record EligibilityResult(LeaveRuleOutcome blocking, List isWorkingDay) { List warnings = new ArrayList<>(); // V164: the largest single WARN_UNPAID_EXCESS excess amount seen so far (FIRST_YEAR_MAX_DAYS/ // WEDDING_MAX_DAYS/MAX_CONSECUTIVE_DAYS below) -- see #dominantUnpaidByRuleDays's Javadoc for @@ -2461,24 +2467,11 @@ private AutoRejectResult autoRejectNote(LeaveTypeDto leaveType, long employeeId, } // §5 advance notice, now per-type (hr.leave_type.advance_notice_days) instead of the removed - // global app.leave.advance-notice-days property. DECISION: counted in CALENDAR days, not - // working days; this is an unchanged behaviour carried over from the original global-property - // version, which also compared plain calendar dates. A type with advanceNoticeDays == 0 - // (SICK, MATERNITY, MILITARY, LEAVE_WITHOUT_PAY as seeded) skips this check entirely, matching - // the old code's unconditional SICK exemption. - // - // RECONFIRMED, not merely carried over (2026-08-03): V116 called this calendar-day counting a - // deliberate STOPGAP specifically because working-day counting was not available yet ("§5.2 = - // 1 working day, §5.3 = 3 working days" in the source announcement). It is available now -- - // LeaveDayMath is schedule/holiday-aware (see its javadoc) -- but this branch deliberately does - // NOT convert this check, for the opposite reason from the consecutive-days check above: this - // is a MINIMUM lead-time requirement, so switching to a true working-day count would TIGHTEN - // it whenever the gap between filing and the start date crosses a weekend/holiday (those days - // would stop counting toward the required notice, so a request that clears today's calendar- - // day check could newly be rejected). That is a real, employee-facing behaviour change with - // its own blast radius -- it belongs in its own reviewed change with its own tests, not folded - // silently into a branch whose stated purpose is the payroll deduction/day-count fix. Left on - // calendar days here; flagged as a separate, explicit follow-up decision. + // global app.leave.advance-notice-days property. Counted in the employee's OWN WORKING days + // (schedule + hr.holiday aware): the days after today up to the leave's first working day that + // the employee would work must number at least noticeDays; today is day 0 and not counted + // (owner ruling 2026-10-29). A type with advanceNoticeDays == 0 (SICK, MATERNITY, MILITARY, + // LEAVE_WITHOUT_PAY as seeded) skips this check entirely. // // §5.2 emergency-filing exception (V125), COMBINED notice x emergency decision table: // @@ -2524,8 +2517,13 @@ private AutoRejectResult autoRejectNote(LeaveTypeDto leaveType, long employeeId, // rejection to begin with). int noticeDays = Math.max(0, leaveType.advanceNoticeDays()); if (noticeDays > 0) { - LocalDate earliestAllowed = LocalDate.now(clock).plusDays(noticeDays); - if (startDate.isBefore(earliestAllowed)) { + LocalDate noticeFrom = LocalDate.now(clock).plusDays(1); + LocalDate firstWorkDay = startDate; + while (firstWorkDay.isBefore(endDate) && !isWorkingDay.test(firstWorkDay)) { + firstWorkDay = firstWorkDay.plusDays(1); + } + int workingDaysOfNotice = LeaveDayMath.countWorkingDays(noticeFrom, firstWorkDay, isWorkingDay); + if (workingDaysOfNotice < noticeDays) { if (leaveType.emergencyMonthlyAllowance() != null && requestedAsEmergency) { int usedThisMonth = leaveRepository.countEmergencyFilings( employeeId, leaveType.code(), startDate, ACTIVE_QUOTA_STATUSES); diff --git a/backend/src/main/java/th/co/glr/hr/orderconfirmation/OrderConfirmationService.java b/backend/src/main/java/th/co/glr/hr/orderconfirmation/OrderConfirmationService.java index c8b6b8e7a..986ee2e32 100644 --- a/backend/src/main/java/th/co/glr/hr/orderconfirmation/OrderConfirmationService.java +++ b/backend/src/main/java/th/co/glr/hr/orderconfirmation/OrderConfirmationService.java @@ -25,6 +25,7 @@ import th.co.glr.hr.pricingrequest.PricingRequestEventKind; import th.co.glr.hr.pricingrequest.PricingRequestRepository; import th.co.glr.hr.pricingrequest.PricingRequestRepository.OrderConfirmationState; +import th.co.glr.hr.pricingrequest.PricingRequestService; import th.co.glr.hr.pricingrequest.PricingRequestStatus; import th.co.glr.hr.pricingrequest.UnitBasis; import th.co.glr.hr.ticket.QuotationRenderer; @@ -356,11 +357,15 @@ private void reconcileTicketItems(long pricingRequestId, long ticketId, UserPrin ? th.co.glr.hr.dealquotation.WastageCalculator.sqmQuantityFromPieces( reconciledQty.intValueExact(), accepted.sqmPerPiece()) : item.requestedQtySqm(); + // A stock line is covered in full and never ordered from a factory. Any other line is left as it is + // (null): a legacy reserve on an import line survives, and a new row defaults to 0. + BigDecimal qtyFromStock = PricingRequestService.STOCK_IN_THAILAND.equals(item.stockSource()) + ? reconciledQty : null; if (item.sourceTicketItemId() != null) { boolean changed; try { changed = tickets.reconcileItemQty( - ticketId, item.sourceTicketItemId(), reconciledQty, reconciledQtySqm); + ticketId, item.sourceTicketItemId(), reconciledQty, reconciledQtySqm, qtyFromStock); } catch (DataIntegrityViolationException e) { throw new ApiException(HttpStatus.CONFLICT, "ไม่สามารถปรับจำนวนสินค้า (item " + item.sourceTicketItemId() @@ -373,7 +378,7 @@ private void reconcileTicketItems(long pricingRequestId, long ticketId, UserPrin // to reserveStock/completeDelivery. tickets.insertReconciledItem(ticketId, resolveBrand(item), item.model(), item.color(), item.texture(), item.size(), item.factory(), reconciledQty, reconciledQtySqm, - unitBasis); + unitBasis, qtyFromStock); anyChange = true; } } diff --git a/backend/src/main/java/th/co/glr/hr/pricingdecision/PricingDecisionService.java b/backend/src/main/java/th/co/glr/hr/pricingdecision/PricingDecisionService.java index b446d0cc6..9da22bdf9 100644 --- a/backend/src/main/java/th/co/glr/hr/pricingdecision/PricingDecisionService.java +++ b/backend/src/main/java/th/co/glr/hr/pricingdecision/PricingDecisionService.java @@ -689,7 +689,7 @@ public PricingDecisionDto returnToImport(long decisionId, ReturnPricingDecisionR // Stock lines (V194, R4): a request made only of สต็อกในไทย lines never went through Import // (no quote, no ETA), so there is nothing to send back to it. List returnItems = pricingRequests.findItems(summary.id()); - if (!returnItems.isEmpty() && returnItems.stream().allMatch(i -> "IN_THAILAND".equals(i.stockSource()))) { + if (!returnItems.isEmpty() && returnItems.stream().allMatch(i -> th.co.glr.hr.pricingrequest.PricingRequestService.STOCK_IN_THAILAND.equals(i.stockSource()))) { throw new ApiException(HttpStatus.CONFLICT, "คำขอราคานี้เป็นสต็อกในไทยทั้งหมด ไม่ผ่านฝ่ายนำเข้า จึงตีกลับให้ฝ่ายนำเข้าไม่ได้"); } diff --git a/backend/src/main/java/th/co/glr/hr/pricingrequest/PricingRequestDtos.java b/backend/src/main/java/th/co/glr/hr/pricingrequest/PricingRequestDtos.java index 126a69f10..9ca388d5a 100644 --- a/backend/src/main/java/th/co/glr/hr/pricingrequest/PricingRequestDtos.java +++ b/backend/src/main/java/th/co/glr/hr/pricingrequest/PricingRequestDtos.java @@ -74,7 +74,11 @@ public record PricingRequestSummaryDto( // The deal ticket's own legacy status (sales.ticket.status). The frontend gates // "ยืนยันคำสั่งซื้อ" on it (OrderConfirmationService.isBridgeableTicketStatus) so a deal the // bridge cannot handle never offers a button that can only 409. - String ticketStatus + String ticketStatus, + // The deal ticket's payment track (sales.ticket.payment_status): null until the customer's order + // is confirmed. Once set no new pricing round (customer-change revision / submit) can start, so the + // frontend hides the create/submit/สร้างรอบแก้ไข controls on it. + String ticketPaymentStatus ) { /** The pre-GLA-125 shape — kept so every existing construction site (tests, mostly) * compiles unchanged. Defaults every new field to null/false, exactly what a pre-GLA-125 @@ -93,7 +97,7 @@ public PricingRequestSummaryDto( requestedByName, assignedImportId, assignedImportName, requiredDate, customerTargetPrice, targetCurrency, note, itemCount, revisionNo, parentPricingRequestId, submittedAt, pickedUpAt, cancelledAt, createdAt, updatedAt, - orderConfirmedAt, null, null, null, null, null, null, null, false, null, null); + orderConfirmedAt, null, null, null, null, null, null, null, false, null, null, null); } /** The pre-customerId (but post-GLA-125-header-terms) shape — the 36-field constructor @@ -115,7 +119,7 @@ public PricingRequestSummaryDto( customerTargetPrice, targetCurrency, note, itemCount, revisionNo, parentPricingRequestId, submittedAt, pickedUpAt, cancelledAt, createdAt, updatedAt, orderConfirmedAt, paymentTermMode, creditDays, validityDays, printedByDisplayId, - salesRepDisplayId, deptCode, unitCode, omitContactHonorific, null, null); + salesRepDisplayId, deptCode, unitCode, omitContactHonorific, null, null, null); } /** The pre-ticketStatus (37-field) shape. Defaults ticketStatus to null. */ @@ -137,7 +141,7 @@ public PricingRequestSummaryDto( customerTargetPrice, targetCurrency, note, itemCount, revisionNo, parentPricingRequestId, submittedAt, pickedUpAt, cancelledAt, createdAt, updatedAt, orderConfirmedAt, paymentTermMode, creditDays, validityDays, printedByDisplayId, - salesRepDisplayId, deptCode, unitCode, omitContactHonorific, customerId, null); + salesRepDisplayId, deptCode, unitCode, omitContactHonorific, customerId, null, null); } } diff --git a/backend/src/main/java/th/co/glr/hr/pricingrequest/PricingRequestRepository.java b/backend/src/main/java/th/co/glr/hr/pricingrequest/PricingRequestRepository.java index 743ebec29..14fc83ad4 100644 --- a/backend/src/main/java/th/co/glr/hr/pricingrequest/PricingRequestRepository.java +++ b/backend/src/main/java/th/co/glr/hr/pricingrequest/PricingRequestRepository.java @@ -43,7 +43,7 @@ public class PricingRequestRepository { private static final String SUMMARY_SELECT = """ SELECT pr.*, t.code AS ticket_code, t.title AS ticket_title, t.created_by AS ticket_created_by, - p.name AS project_name, t.customer_name, t.customer_id, t.status AS ticket_status, + p.name AS project_name, t.customer_name, t.customer_id, t.status AS ticket_status, t.payment_status AS ticket_payment_status, er.first_name_th AS requested_by_first_name_th, er.last_name_th AS requested_by_last_name_th, ei.first_name_th AS assigned_import_first_name_th, ei.last_name_th AS assigned_import_last_name_th, (SELECT COUNT(*) FROM sales.pricing_request_item i @@ -723,50 +723,6 @@ AND COALESCE(NULLIF(BTRIM(resolved_factory_name), ''), NULLIF(BTRIM(factory), '' .addValue("factoryId", factoryId)); } - /** - * Stock lines (V194): records the วันที่คาดว่าจะถึง of ONE in-transit line, and who/when. The - * {@code stock_source = 'IN_TRANSIT'} predicate is in the WHERE so a line that stopped being in - * transit between the caller's read and this write matches zero rows (compare-and-set). - */ - public int setItemExpectedArrival(long pricingRequestId, long pricingRequestItemId, - LocalDate expectedArrivalDate, long setById) { - return jdbc.update(""" - UPDATE sales.pricing_request_item - SET expected_arrival_date = :eta, - expected_arrival_set_by_id = :by, - expected_arrival_set_at = now() - WHERE pricing_request_item_id = :id - AND pricing_request_id = :pricingRequestId - AND stock_source = 'IN_TRANSIT' - """, - new MapSqlParameterSource() - .addValue("id", pricingRequestItemId) - .addValue("pricingRequestId", pricingRequestId) - .addValue("eta", expectedArrivalDate) - .addValue("by", setById)); - } - - /** - * Stock lines (V194, R13): turns ONE in-transit line back into a normal import line. Source, - * ETA and the ETA's who/when are cleared in a SINGLE statement - chk_pricing_request_item_eta_only_in_transit - * forbids an ETA on a non-transit line, so clearing them in two updates would trip it. - */ - public int convertItemInTransitToImport(long pricingRequestId, long pricingRequestItemId) { - return jdbc.update(""" - UPDATE sales.pricing_request_item - SET stock_source = NULL, - expected_arrival_date = NULL, - expected_arrival_set_by_id = NULL, - expected_arrival_set_at = NULL - WHERE pricing_request_item_id = :id - AND pricing_request_id = :pricingRequestId - AND stock_source = 'IN_TRANSIT' - """, - new MapSqlParameterSource() - .addValue("id", pricingRequestItemId) - .addValue("pricingRequestId", pricingRequestId)); - } - /** * True if {@code factoryName} already has a CURRENT {@code sales.factory_quote} on this * pricing request whose status has moved past {@code DRAFT} — i.e. an RFQ that a human has @@ -1316,7 +1272,8 @@ private PricingRequestSummaryDto mapSummary(ResultSet rs) throws SQLException { rs.getString("unit_code"), rs.getBoolean("omit_contact_honorific"), nullableLong(rs, "customer_id"), - rs.getString("ticket_status") + rs.getString("ticket_status"), + rs.getString("ticket_payment_status") ); } diff --git a/backend/src/main/java/th/co/glr/hr/pricingrequest/PricingRequestService.java b/backend/src/main/java/th/co/glr/hr/pricingrequest/PricingRequestService.java index 6b84e7694..2c256286a 100644 --- a/backend/src/main/java/th/co/glr/hr/pricingrequest/PricingRequestService.java +++ b/backend/src/main/java/th/co/glr/hr/pricingrequest/PricingRequestService.java @@ -59,21 +59,8 @@ public class PricingRequestService { // two lists in sync by inspection, not by sharing a mutable reference. private static final Set SALES_ROLES = Set.of("sales"); private static final Set IMPORT_ROLES = Set.of("import"); - // Stock lines (V194): who may confirm an in-transit line's ETA (import; the CEO may too). - // Converting an in-transit line to สั่งนำเข้า is IMPORT ONLY - the CEO is refused (IA 1.7). - private static final Set ETA_ROLES = Set.of("import", "ceo"); /** Line sources (V194). null = สั่งนำเข้า. */ - static final String STOCK_IN_THAILAND = "IN_THAILAND"; - static final String STOCK_IN_TRANSIT = "IN_TRANSIT"; - /** - * Statuses in which an in-transit line's ETA may be saved / the line converted to import. - * READY_FOR_CEO_REVIEW is included on purpose: import must be able to correct an ETA (or switch - * the line) after the request has already gone to the CEO - convert then pulls it back. - */ - private static final Set STOCK_LINE_EDIT_STATUSES = Set.of( - PricingRequestStatus.IMPORT_REVIEWING, - PricingRequestStatus.AWAITING_FACTORY_RESPONSE, - PricingRequestStatus.READY_FOR_CEO_REVIEW); + public static final String STOCK_IN_THAILAND = "IN_THAILAND"; // Mirrors TicketService.VIEWER_ROLES: who may read a pricing request at all. // sales_manager stays read-only oversight here too — never add it to // SALES_ROLES/IMPORT_ROLES. @@ -140,6 +127,7 @@ public PricingRequestDetailDto createDraft(long ticketId, CreatePricingRequestRe return detail(requireSameTicket(existing, ticketId).id()); } requireActive(ticket); + requireOrderNotConfirmed(ticket); // Quotation <-> deal linking slice 1 (IA §7, 2026-09-30): a deal with a LIVE direct // (DEAL_DIRECT) quotation is priced by hand on that quotation; a pricing request on top would // make it the mixed-origin deal nobody has ever produced. Keyed on the live quotation, not @@ -299,6 +287,7 @@ public PricingRequestDetailDto submit(long id, UserPrincipal actor) { } TicketSummaryDto ticket = requireTicket(summary.ticketId()); requireActive(ticket); + requireOrderNotConfirmed(ticket); List items = requests.findItems(id); if (items.isEmpty()) { @@ -577,101 +566,12 @@ public PricingRequestDetailDto setItemFactory(long id, long itemId, SetItemFacto return detail(id); } - /** - * Stock lines (V194, IA 1.2, R12): import (or the CEO) confirms the วันที่คาดว่าจะถึง of ONE - * {@code IN_TRANSIT} line, then re-checks the single readiness predicate so the request advances - * the moment the last missing ETA lands. - * - *

Authz (IA 1.7): import + ceo only, 403 for everyone else. Refusals: 400 null date; 409 when - * the request is outside IMPORT_REVIEWING / AWAITING_FACTORY_RESPONSE / READY_FOR_CEO_REVIEW or the - * line is not in transit. Changing an ETA that is already set is allowed (and keeps a - * READY_FOR_CEO_REVIEW request there). - */ - @Transactional - public PricingRequestDetailDto setInTransitArrival(long id, long itemId, LocalDate expectedArrivalDate, - UserPrincipal actor) { - requireRole(actor, ETA_ROLES); - if (expectedArrivalDate == null) { - throw new ApiException(HttpStatus.BAD_REQUEST, "ต้องระบุวันที่คาดว่าจะถึง"); - } - requests.lockPricingRequest(id); - PricingRequestSummaryDto summary = requireViewable(id, actor); - requireStockLineEditableStatus(summary); - requireActive(requireTicket(summary.ticketId())); - PricingRequestItemDto item = requireItem(id, itemId); - if (!STOCK_IN_TRANSIT.equals(item.stockSource())) { + /** Once the customer's order is confirmed (payment track started) no new pricing round may start on the deal. */ + private static void requireOrderNotConfirmed(TicketSummaryDto ticket) { + if (ticket.paymentStatus() != null) { throw new ApiException(HttpStatus.CONFLICT, - "ระบุวันที่คาดว่าจะถึงได้เฉพาะรายการสต็อกกำลังเดินทางเท่านั้น"); + "ยืนยันคำสั่งซื้อของดีลนี้แล้ว จึงเริ่มรอบราคาใหม่ไม่ได้ — การเปลี่ยนแปลงหลังยืนยันต้องทำเป็นการแก้ไขคำสั่งซื้อ"); } - int rows = requests.setItemExpectedArrival(id, itemId, expectedArrivalDate, actor.id()); - if (rows == 0) { - throw new ApiException(HttpStatus.CONFLICT, - "คำขอราคาถูกแก้ไขโดยผู้ใช้อื่น กรุณาโหลดข้อมูลใหม่แล้วลองอีกครั้ง"); - } - requests.addEvent(id, summary.ticketId(), actor.id(), actor.name(), - PricingRequestEventKind.PRICING_REQUEST_ITEM_ETA_SET, summary.status(), summary.status(), - "ระบุวันที่คาดว่าจะถึง " + expectedArrivalDate + " ให้รายการ " + item.displayName(), null); - advanceIfReadyForCeo(summary, actor); - return detail(id); - } - - /** - * Stock lines (V194, IA 1.2, R13): import switches an {@code IN_TRANSIT} line to สั่งนำเข้า - * because the shipment cannot cover it - clears source + ETA (+ who/when) in ONE update, notifies - * the owning rep only, and pulls a READY_FOR_CEO_REVIEW request back to - * AWAITING_FACTORY_RESPONSE (the converted line has no factory quote yet). - * - *

Authz (IA 1.7): import ONLY - the CEO is refused too. Same status window as - * {@link #setInTransitArrival}; 409 on a line that is not in transit. - */ - @Transactional - public PricingRequestDetailDto convertInTransitToImport(long id, long itemId, UserPrincipal actor) { - requireRole(actor, IMPORT_ROLES); - requests.lockPricingRequest(id); - PricingRequestSummaryDto summary = requireViewable(id, actor); - requireStockLineEditableStatus(summary); - requireActive(requireTicket(summary.ticketId())); - PricingRequestItemDto item = requireItem(id, itemId); - if (!STOCK_IN_TRANSIT.equals(item.stockSource())) { - throw new ApiException(HttpStatus.CONFLICT, - "เปลี่ยนเป็นสั่งนำเข้าได้เฉพาะรายการสต็อกกำลังเดินทางเท่านั้น"); - } - int rows = requests.convertItemInTransitToImport(id, itemId); - if (rows == 0) { - throw new ApiException(HttpStatus.CONFLICT, - "คำขอราคาถูกแก้ไขโดยผู้ใช้อื่น กรุณาโหลดข้อมูลใหม่แล้วลองอีกครั้ง"); - } - String toStatus = summary.status(); - if (PricingRequestStatus.READY_FOR_CEO_REVIEW.equals(summary.status())) { - int pulledBack = requests.transition(id, PricingRequestStatus.READY_FOR_CEO_REVIEW, - PricingRequestStatus.AWAITING_FACTORY_RESPONSE, null, null); - if (pulledBack == 1) { - toStatus = PricingRequestStatus.AWAITING_FACTORY_RESPONSE; - } - } - requests.addEvent(id, summary.ticketId(), actor.id(), actor.name(), - PricingRequestEventKind.PRICING_REQUEST_ITEM_CONVERTED_TO_IMPORT, summary.status(), toStatus, - "เปลี่ยนรายการ " + item.displayName() + " จากสต็อกกำลังเดินทางเป็นสั่งนำเข้า", null); - notifications.notifyEmployeeForPricingRequest(summary.requestedById(), summary.id(), - "PRICING_REQUEST_ITEM_CONVERTED_TO_IMPORT", - "คำขอราคา " + summary.requestCode() + ": รายการ " + item.displayName() - + " เปลี่ยนจากสต็อกกำลังเดินทางเป็นสั่งนำเข้า (สต็อกระหว่างทางไม่พอ)"); - return detail(id); - } - - private void requireStockLineEditableStatus(PricingRequestSummaryDto summary) { - if (!STOCK_LINE_EDIT_STATUSES.contains(summary.status())) { - throw new ApiException(HttpStatus.CONFLICT, - "แก้ไขรายการสต็อกได้เฉพาะคำขอราคาที่อยู่ระหว่างการดำเนินการของฝ่ายนำเข้าหรือรอ CEO เท่านั้น (สถานะปัจจุบัน: '" - + summary.status() + "')"); - } - } - - private PricingRequestItemDto requireItem(long id, long itemId) { - return requests.findItems(id).stream() - .filter(candidate -> candidate.id() == itemId) - .findFirst() - .orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "ไม่พบรายการสินค้านี้ในคำขอราคานี้")); } /** True when the request is non-empty and EVERY line is สต็อกในไทย (R4). */ @@ -679,37 +579,6 @@ private static boolean allInThailand(List items) { return !items.isEmpty() && items.stream().allMatch(i -> STOCK_IN_THAILAND.equals(i.stockSource())); } - /** - * Re-checks the ONE readiness predicate after an ETA save (the markReadyForCosting side lives in - * FactoryQuoteService) and advances via the right edge: AWAITING_FACTORY_RESPONSE -> - * READY_FOR_CEO_REVIEW, or - a request with NO import line, so no factory quote ever moves it - * off IMPORT_REVIEWING - IMPORT_REVIEWING -> READY_FOR_CEO_REVIEW (the V194 edge). A request - * that is already READY_FOR_CEO_REVIEW simply stays there. - */ - private void advanceIfReadyForCeo(PricingRequestSummaryDto before, UserPrincipal actor) { - PricingRequestSummaryDto summary = requests.findSummary(before.id()).orElse(null); - if (summary == null) { - return; - } - String from = summary.status(); - boolean noImportLines = requests.findItems(summary.id()).stream().allMatch(i -> i.stockSource() != null); - boolean advanceable = PricingRequestStatus.AWAITING_FACTORY_RESPONSE.equals(from) - || (PricingRequestStatus.IMPORT_REVIEWING.equals(from) && noImportLines); - if (!advanceable || !factoryQuoteCarryForward.isReadyForCeoReview(summary)) { - return; - } - int transitioned = requests.transition(summary.id(), from, - PricingRequestStatus.READY_FOR_CEO_REVIEW, null, null); - if (transitioned == 1) { - requests.addEvent(summary.id(), summary.ticketId(), actor.id(), actor.name(), - PricingRequestEventKind.PRICING_COSTING_SUBMITTED, from, - PricingRequestStatus.READY_FOR_CEO_REVIEW, - "ราคาโรงงานและวันที่คาดว่าจะถึงครบแล้ว - ส่งต่อให้ CEO", null); - notifyCeo(summary, PricingRequestEventKind.PRICING_COSTING_SUBMITTED, - "คำขอราคา " + summary.requestCode() + " พร้อมให้ CEO พิจารณาราคาแล้ว"); - } - } - @Transactional public PricingRequestDetailDto cancel(long id, CancelPricingRequestRequest request, UserPrincipal actor) { PricingRequestSummaryDto summary = requireViewable(id, actor); @@ -783,6 +652,7 @@ public PricingRequestDetailDto createCustomerChangeRevision(long id, CustomerCha } TicketSummaryDto ticket = requireTicket(parent.ticketId()); requireActive(ticket); + requireOrderNotConfirmed(ticket); validateClientRequestId(request.clientRequestId()); PricingRequestSummaryDto existing = existingForClientRequest(actor.id(), request.clientRequestId()); if (existing != null) { @@ -1420,7 +1290,7 @@ private PricingRequestItemRequest resolveItem(int rowNumber, PricingRequestItemR // Stock lines (V194): an unknown source is a caller-fixable 400, not a CHECK-constraint 500. // A blank source normalizes to null (สั่งนำเข้า). String stockSource = hasText(rawItem.stockSource()) ? rawItem.stockSource().trim() : null; - if (stockSource != null && !STOCK_IN_THAILAND.equals(stockSource) && !STOCK_IN_TRANSIT.equals(stockSource)) { + if (stockSource != null && !STOCK_IN_THAILAND.equals(stockSource)) { throw new ApiException(HttpStatus.BAD_REQUEST, "รายการที่ " + (rowNumber + 1) + ": ไม่รองรับแหล่งที่มาของสินค้า '" + rawItem.stockSource() + "'"); } @@ -1464,6 +1334,15 @@ private PricingRequestItemRequest resolveItem(int rowNumber, PricingRequestItemR // country it was not actually typed for. String originCountryOther = ORIGIN_COUNTRY_OTHER.equals(item.originCountry()) ? item.originCountryOther() : null; + // A stock line is never imported (walk 4): whatever import-only inputs a client sent are not stored, + // or the quotation would print an import lead time for goods already in Thailand. + boolean stockLine = stockSource != null; + String originCountry = stockLine ? null : item.originCountry(); + Integer leadTimeMinDays = stockLine ? null : item.leadTimeMinDays(); + Integer leadTimeMaxDays = stockLine ? null : item.leadTimeMaxDays(); + if (stockLine) { + originCountryOther = null; + } return new PricingRequestItemRequest( item.sourceTicketItemId(), item.productId(), item.variantId(), item.brand(), item.model(), item.productDescription(), item.color(), item.texture(), item.size(), item.factory(), @@ -1471,8 +1350,8 @@ private PricingRequestItemRequest resolveItem(int rowNumber, PricingRequestItemR item.targetDeliveryDate(), item.deliveryLocation(), item.specialRequirement(), item.productCode(), item.thicknessMm(), item.sqmPerPiece(), quantityMode, item.areaSqm(), item.piecesInput(), item.wastageMode(), item.wastageValue(), item.piecesPerBox(), - item.sqmPerBox(), roundToFullBox, item.originCountry(), item.leadTimeMinDays(), - item.leadTimeMaxDays(), result.piecesBeforeWastage(), result.piecesAfterWastage(), + item.sqmPerBox(), roundToFullBox, originCountry, leadTimeMinDays, + leadTimeMaxDays, result.piecesBeforeWastage(), result.piecesAfterWastage(), result.boxes(), originCountryOther, stockSource, requestedCurrency(rowNumber, item.requestedCurrency()), requestedPriceUnitBasis(item.requestedPriceUnitBasis())); diff --git a/backend/src/main/java/th/co/glr/hr/ticket/PaymentStage.java b/backend/src/main/java/th/co/glr/hr/ticket/PaymentStage.java index 9863f6096..f1786521e 100644 --- a/backend/src/main/java/th/co/glr/hr/ticket/PaymentStage.java +++ b/backend/src/main/java/th/co/glr/hr/ticket/PaymentStage.java @@ -18,6 +18,11 @@ private PaymentStage() {} /** Paid amount covers the derived payable amount. */ public static final String FULLY_PAID = "FULLY_PAID"; + /** The customer has not reached the payment step yet (no payment_status, nothing paid). */ + public static final String NOT_STARTED = "NOT_STARTED"; + /** A bypass deposit policy (e.g. a 0% quotation): no deposit is collected, the whole amount is the balance. */ + public static final String NO_DEPOSIT = "NO_DEPOSIT"; + public static final Set VALID = Set.of( - NOT_REQUIRED, DEPOSIT_PENDING, DEPOSIT_RECEIVED, PARTIALLY_PAID, BALANCE_PENDING, FULLY_PAID); + NOT_STARTED, NO_DEPOSIT, NOT_REQUIRED, DEPOSIT_PENDING, DEPOSIT_RECEIVED, PARTIALLY_PAID, BALANCE_PENDING, FULLY_PAID); } diff --git a/backend/src/main/java/th/co/glr/hr/ticket/QuotationRenderer.java b/backend/src/main/java/th/co/glr/hr/ticket/QuotationRenderer.java index 4fd34ff40..c2311ec64 100644 --- a/backend/src/main/java/th/co/glr/hr/ticket/QuotationRenderer.java +++ b/backend/src/main/java/th/co/glr/hr/ticket/QuotationRenderer.java @@ -33,6 +33,7 @@ import org.springframework.core.io.ClassPathResource; import org.springframework.stereotype.Component; import th.co.glr.hr.common.LibreOfficePdfConverter; +import th.co.glr.hr.common.SizeUnit; import th.co.glr.hr.common.sheet.FontResolver; import th.co.glr.hr.common.sheet.LibreOfficeMetrics; import th.co.glr.hr.customer.CustomerDto; @@ -2968,17 +2969,14 @@ private void cloneRowStyle(Sheet sh, Row proto, int r) { // the moment V162 started populating these columns -- see // QuotationRendererTest#descriptionRendersAllAttributes_andSuppliesTheSizeUnitOnlyWhenItIsMissing. // Anchored at the end of the trimmed value, so a bare "600x1200" (ends in a digit) can never - // match and still gets its unit. - private static final java.util.regex.Pattern SIZE_ALREADY_HAS_UNIT = - java.util.regex.Pattern.compile("(?i)(ซม\\.?|มม\\.?|ม\\.?|นิ้ว|cm\\.?|mm\\.?|m\\.?|in\\.?|\")\\s*$"); - + // match and still gets its unit. The unit regex lives in SizeUnit, shared with the IR factory copy. private String buildDesc(TicketItemDto item) { StringBuilder sb = new StringBuilder("กระเบื้อง"); if (item.model() != null && !item.model().isBlank()) sb.append(" รุ่น ").append(item.model()); if (item.color() != null && !item.color().isBlank()) sb.append(" สี ").append(item.color()); if (item.size() != null && !item.size().isBlank()) { sb.append(" ขนาด ").append(item.size()); - if (!SIZE_ALREADY_HAS_UNIT.matcher(item.size().trim()).find()) sb.append(" cm."); + if (!SizeUnit.hasUnit(item.size())) sb.append(" cm."); } if (item.texture() != null && !item.texture().isBlank()) sb.append(" ").append(item.texture()); return sb.toString(); 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 04dc571b0..214c7cd23 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 @@ -308,7 +318,7 @@ public boolean existsById(long ticketId) { public String nextTicketCode() { long seq = jdbc.queryForObject("SELECT nextval('sales.ticket_code_seq')", Map.of(), Long.class); - return "PR-" + Year.now() + "-" + String.format("%04d", seq); + return "DL-" + Year.now() + "-" + String.format("%04d", seq); } public String nextQuotationCode() { @@ -513,12 +523,10 @@ public void replaceItemsPreservingPricing(long ticketId, List ite // method just persists whatever it was handed, exactly like every other // pricing-adjacent field above (approvedPrice/calcedCost/manualPrice/...). .addValue("weightMultiplier", item.weightMultiplier()) - // V183: unlike weightMultiplier (guarded, "prior always wins"), sourcedFromStock/ - // stockSalePrice are sales-writable -- TicketService.mergeEditedItemsPreservingPricing - // resolves them from the incoming request with a null-safe fallback to the prior - // row (so an edit that says nothing about them does not silently clear a flag set - // on a previous save), and validates the pairing before this method ever sees the - // TicketItemDto. This method just persists whatever it was handed. + // V183: unlike weightMultiplier (guarded, "prior always wins"), sourcedFromStock is + // sales-writable -- TicketService.mergeEditedItemsPreservingPricing resolves it from the + // request with a null-safe fallback to the prior row. It also resolves stockSalePrice to + // NULL (walk 4); this method just persists whatever it was handed. .addValue("sourcedFromStock", item.sourcedFromStock()) .addValue("stockSalePrice", item.stockSalePrice()); } @@ -1100,25 +1108,10 @@ private void insertItems(long ticketId, List items) { .addValue("sortOrder", i) .addValue("catalogPriceId", item.catalogPriceId()) .addValue("catalogProductCode", item.catalogProductCode()) - // V183: sourcedFromStock is a Boolean (nullable) on the request record -- a - // request that never mentions it (every call site before this feature) means - // "not from stock", same as an explicit false. This method persists what it is - // given without validating the sourcedFromStock/stockSalePrice pairing -- that - // invariant is enforced in TicketService.create before any DB write is attempted - // (see its item-validation loop) and backstopped by chk_ticket_item_stock_sale_price. + // sourcedFromStock is a nullable Boolean: a request that never mentions it means "not from + // stock", same as false. The sales-typed price is gone (walk 4): the column always stays NULL. .addValue("sourcedFromStock", Boolean.TRUE.equals(item.sourcedFromStock())) - // Force-null when the flag is false, exactly as - // TicketService.mergeEditedItemsPreservingPricing does on the edit path (and as - // mockApi.js and TicketCreateModal's submit() already do client-side): never let a - // stray price ride along on a line that is not flagged stock-sourced. - // TicketService.create validates "flag true => positive price", but it deliberately - // does NOT reject the inverse, so without this a malformed request carrying - // {sourcedFromStock:false, stockSalePrice:500} reached chk_ticket_item_stock_sale_price - // and came back as a 500 (DataIntegrityViolationException -> handleDataAccess) with the - // raw driver message, instead of being silently normalised the way every other layer - // already normalises it. - .addValue("stockSalePrice", - Boolean.TRUE.equals(item.sourcedFromStock()) ? item.stockSalePrice() : null); + .addValue("stockSalePrice", null); } jdbc.batchUpdate(""" INSERT INTO sales.ticket_item @@ -1247,7 +1240,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 +1269,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 +1315,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 +1330,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")); }); } @@ -1352,6 +1354,13 @@ public void clearCloseConfirmation(long ticketId) { private String derivePaymentStage(TicketSummaryDto s, BigDecimal payable, BigDecimal paid, BigDecimal outstanding, boolean balanceReceipt) { + // QA-16 (owner ruling 2026-10-29): the chip follows the payment track. Before the order is confirmed + // (no payment_status, nothing paid, stage before ORDER_RECEIVED) there is no payment step to speak of; + // a legacy deal already past that with a null track keeps the derivation below. + if (s.paymentStatus() == null && paid.signum() <= 0 + && DealStage.indexOf(s.salesStage()) < DealStage.indexOf(DealStage.ORDER_RECEIVED)) { + return PaymentStage.NOT_STARTED; + } if (payable.signum() <= 0) { return PaymentStage.NOT_REQUIRED; } @@ -1359,11 +1368,18 @@ private String derivePaymentStage(TicketSummaryDto s, BigDecimal payable, BigDec return PaymentStage.FULLY_PAID; } if (paid.signum() > 0) { - return balanceReceipt ? PaymentStage.PARTIALLY_PAID : PaymentStage.DEPOSIT_RECEIVED; + if (balanceReceipt) { + return PaymentStage.PARTIALLY_PAID; + } + return "AWAITING_FINAL_PAYMENT".equals(s.paymentStatus()) + ? PaymentStage.BALANCE_PENDING : PaymentStage.DEPOSIT_RECEIVED; + } + if (DepositPolicy.bypassesDepositNotice(s.depositPolicy())) { + // A 0% quotation: nothing to collect up front, the whole amount is the balance. + return "CUSTOMER_CONFIRMED".equals(s.paymentStatus()) ? PaymentStage.NO_DEPOSIT + : outstanding.signum() > 0 ? PaymentStage.BALANCE_PENDING : PaymentStage.NOT_REQUIRED; } - if (!DepositPolicy.bypassesDepositNotice(s.depositPolicy()) - && ("CUSTOMER_CONFIRMED".equals(s.paymentStatus()) - || "DEPOSIT_NOTICE_ISSUED".equals(s.paymentStatus()))) { + if ("CUSTOMER_CONFIRMED".equals(s.paymentStatus()) || "DEPOSIT_NOTICE_ISSUED".equals(s.paymentStatus())) { return PaymentStage.DEPOSIT_PENDING; } return outstanding.signum() > 0 ? PaymentStage.BALANCE_PENDING : PaymentStage.NOT_REQUIRED; @@ -1723,7 +1739,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); } @@ -1892,14 +1908,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) { @@ -2456,18 +2480,24 @@ public void setHasEdits(long ticketId, boolean value) { * that FK does not exist (source_ticket_item_id is a plain nullable BIGINT, not an FK * — see V59's own column definition), so this IS defensive, not merely decorative). */ - public boolean reconcileItemQty(long ticketId, long itemId, BigDecimal qty, BigDecimal qtySqm) { + public boolean reconcileItemQty(long ticketId, long itemId, BigDecimal qty, BigDecimal qtySqm, + BigDecimal qtyFromStock) { + // qty_from_stock moves in the same statement as qty so chk_ticket_item_qty_from_stock never sees a + // half-written row. A null qtyFromStock leaves the existing value alone (only stock lines are written). return jdbc.update(""" UPDATE sales.ticket_item - SET qty = :qty, qty_sqm = :qtySqm + SET qty = :qty, qty_sqm = :qtySqm, qty_from_stock = COALESCE(CAST(:qtyFromStock AS numeric), qty_from_stock) WHERE ticket_id = :ticketId AND item_id = :itemId - AND qty IS DISTINCT FROM :qty + AND (qty IS DISTINCT FROM :qty + OR (CAST(:qtyFromStock AS numeric) IS NOT NULL + AND qty_from_stock IS DISTINCT FROM CAST(:qtyFromStock AS numeric))) """, new MapSqlParameterSource() .addValue("ticketId", ticketId) .addValue("itemId", itemId) .addValue("qty", qty) - .addValue("qtySqm", qtySqm)) > 0; + .addValue("qtySqm", qtySqm) + .addValue("qtyFromStock", qtyFromStock)) > 0; } /** @@ -2541,13 +2571,13 @@ AND NOT EXISTS ( */ public long insertReconciledItem(long ticketId, String brand, String model, String color, String texture, String size, String factory, BigDecimal qty, BigDecimal qtySqm, - String unitBasis) { + String unitBasis, BigDecimal qtyFromStock) { return jdbc.queryForObject(""" INSERT INTO sales.ticket_item (ticket_id, brand, model, color, texture, size, factory, qty, qty_sqm, unit_basis, - currency, sort_order) + qty_from_stock, currency, sort_order) VALUES (:ticketId, :brand, :model, :color, :texture, :size, :factory, :qty, :qtySqm, - :unitBasis, 'THB', + :unitBasis, COALESCE(CAST(:qtyFromStock AS numeric), 0), 'THB', (SELECT COALESCE(MAX(sort_order), -1) + 1 FROM sales.ticket_item WHERE ticket_id = :ticketId)) RETURNING item_id """, @@ -2561,7 +2591,8 @@ public long insertReconciledItem(long ticketId, String brand, String model, Stri .addValue("factory", factory) .addValue("qty", qty) .addValue("qtySqm", qtySqm) - .addValue("unitBasis", unitBasis), + .addValue("unitBasis", unitBasis) + .addValue("qtyFromStock", qtyFromStock), Long.class); } 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 85967b796..4d29684dc 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 @@ -3,7 +3,6 @@ import com.fasterxml.jackson.databind.ObjectMapper; import java.time.LocalDate; import java.math.BigDecimal; -import java.math.RoundingMode; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; @@ -202,7 +201,7 @@ public TicketDto get(long id, UserPrincipal actor) { } TicketDto ticket = requireViewAccess(id, actor); // B5 fix: display-only fallback, scoped to THIS single read endpoint on purpose. Every - // other reader of ticket.items() (actions()'s canReserveStock/canRecordDelivery/ + // other reader of ticket.items() (actions()'s canRecordDelivery/ // hasRemainingDelivery, editItems' merge-by-position, commission/delivery calculations, // ...) must keep seeing the real (possibly empty) sales.ticket_item rows -- those all // read qtyDelivered/qtyFromStock and stage/action gates that a borrowed quotation line @@ -354,21 +353,6 @@ public TicketDto create(CreateTicketRequest request, UserPrincipal actor) { throw new ApiException(HttpStatus.BAD_REQUEST, "ไม่รองรับระดับความสำคัญ '" + request.priority() + "'"); } - // V183 (stock-sourced deal-line pricing): tickets.create(...) below calls - // TicketRepository#insertItems directly off the raw TicketItemRequest list — there is no - // DTO merge step (like editItems' mergeEditedItemsPreservingPricing) to hook validation - // into before persistence, so the invariant chk_ticket_item_stock_sale_price enforces at - // the DB layer is checked here instead, before any DB write, same style as the - // priority/entryChannel guards immediately above. - if (request.items() != null) { - for (TicketItemRequest item : request.items()) { - if (Boolean.TRUE.equals(item.sourcedFromStock()) - && !isStorablePositivePrice(item.stockSalePrice())) { - throw new ApiException(HttpStatus.BAD_REQUEST, - "ต้องระบุราคาขายเมื่อเลือกสินค้าจากสต็อก"); - } - } - } String code = tickets.nextTicketCode(); long id = tickets.create(request, code, actor.id(), actor.name()); // The readiness gate has TWO halves — next_follow_up_at (collected on the request now) and @@ -753,7 +737,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()); } /** @@ -945,6 +929,11 @@ private TicketDto applyCustomerConfirmation(long ticketId, TicketSummaryDto s, S } // Deal pipeline (V50): a confirmed PO advances the deal — guarded no-op inside. autoAdvanceStage(s, DealStage.ORDER_RECEIVED, actor); + // No deposit to wait for (0% quotation, or a policy already waived): an all-stock deal goes + // straight to delivery. `s` is stale here (the policy may have just changed), so re-read. + if (DepositPolicy.bypassesDepositNotice(requireTicket(ticketId).summary().depositPolicy())) { + routeAllStockDealToDelivery(ticketId, actor); + } return requireTicket(ticketId); } @@ -1084,19 +1073,18 @@ public void requireImportRequestIssuable(TicketSummaryDto s, boolean allowAdvanc // here — null may only ever advance to CUSTOMER_CONFIRMED, so a bypass-policy deal must // have actually reached CUSTOMER_CONFIRMED (via confirmCustomer) before an IR can issue. // This is a deliberate behaviour change; see the branch report. - boolean depositPolicyBypassesNotice = DepositPolicy.bypassesDepositNotice(s.depositPolicy()) - && "CUSTOMER_CONFIRMED".equals(s.paymentStatus()); - boolean depositReady = "DEPOSIT_NOTICE_ISSUED".equals(s.paymentStatus()) - || "DEPOSIT_PAID".equals(s.paymentStatus()) - || depositPolicyBypassesNotice - || (allowAdvancedPayment - && ("AWAITING_FINAL_PAYMENT".equals(s.paymentStatus()) || "FULLY_PAID".equals(s.paymentStatus()))); + // One deposit predicate: a notice issued, or depositSettled (paid / no-deposit); the paid-further states + // (AWAITING_FINAL_PAYMENT, FULLY_PAID) count only with allowAdvancedPayment. + boolean paidFurther = PaymentTrack.AWAITING_FINAL_PAYMENT.equals(s.paymentStatus()) + || PaymentTrack.FULLY_PAID.equals(s.paymentStatus()); + boolean depositReady = PaymentTrack.DEPOSIT_NOTICE_ISSUED.equals(s.paymentStatus()) + || (depositSettled(s) && (allowAdvancedPayment || !paidFurther)); // Gated on the deposit ALONE (owner ruling 2026-10-01): the ticket's legacy status is no // longer required to be quotation_issued — the redesigned pricing chain does not always // advance it, and a deal whose deposit is in hand must be able to import. if (!depositReady) { throw new ApiException(HttpStatus.CONFLICT, - "ออกใบขอนำเข้า (IR) ได้เฉพาะเมื่อรับชำระมัดจำแล้ว (หรือได้รับการยกเว้นมัดจำ) เท่านั้น"); + "ออกใบขอซื้อ (IR) ได้เฉพาะเมื่อรับชำระมัดจำแล้ว (หรือได้รับการยกเว้นมัดจำ) เท่านั้น"); } } @@ -1227,7 +1215,7 @@ public TicketDto markIrSent(long ticketId, UserPrincipal actor) { "ดีลนี้ติดตามการนำเข้าด้วยใบขอซื้อรายโรงงาน — ต้องบันทึกความคืบหน้าที่ใบขอซื้อของแต่ละโรงงาน"); } if (!FulfilmentStatus.IR_ISSUED.equals(s.fulfillmentStatus())) { - throw new ApiException(HttpStatus.CONFLICT, "ต้องออกใบขอนำเข้า (IR) ก่อนจึงจะทำเครื่องหมายว่าส่ง IR แล้วได้"); + throw new ApiException(HttpStatus.CONFLICT, "ต้องออกใบขอซื้อ (IR) ก่อนจึงจะทำเครื่องหมายว่าส่ง IR แล้วได้"); } tickets.updateFulfillmentStatus(ticketId, FulfilmentStatus.IR_SENT); tickets.addEvent(ticketId, actor.id(), actor.name(), @@ -1463,18 +1451,7 @@ public TicketDto reserveStock(long ticketId, StockReservationRequest request, Us tickets.addEvent(ticketId, actor.id(), actor.name(), TicketEventKind.STOCK_RESERVED, s.status(), s.status(), "qty_from_stock=" + totalDeclared.stripTrailingZeros().toPlainString()); - boolean allCovered = !ticket.items().isEmpty() - && ticket.items().stream() - .allMatch(item -> nullToZero(mergedStock.get(item.id())).compareTo(nullToZero(item.qty())) >= 0); - boolean leavesTheImportAxis = allCovered - && (s.fulfillmentStatus() == null || FulfilmentStatus.FROM_STOCK.equals(s.fulfillmentStatus())); - if (leavesTheImportAxis) { - tickets.updateFulfillmentStatus(ticketId, FulfilmentStatus.FROM_STOCK); - // Fully covered from stock — no import journey, so the goods are ready - // now. Advance straight to DELIVERY_SCHEDULING (S18) rather than - // PROCUREMENT (an import step this deal never performs). - autoAdvanceStage(s, DealStage.DELIVERY_SCHEDULING, actor); - } + boolean leavesTheImportAxis = routeAllStockDealToDelivery(s, ticket.items(), mergedStock, actor); notifySalesManagerOfRepDeclaration(s, actor, mergedStock, ticket.items(), leavesTheImportAxis); return requireTicket(ticketId); } @@ -1602,7 +1579,7 @@ public TicketDto setItemWeightMultipliers(long ticketId, ItemWeightMultiplierReq * Who may set THIS deal's per-item commission weight -- the single source of truth for {@link * #setItemWeightMultipliers}'s gate and for whether {@link #actions} advertises {@code * SET_ITEM_WEIGHT_MULTIPLIER}, exactly the same "one predicate, never two copies" discipline - * {@link #canReserveStock} documents for {@code RESERVE_STOCK}. No ownership/stage nuance: + * {@link #canWriteDelivery} documents. No ownership/stage nuance: * {@link #ITEM_WEIGHT_ROLES} are both blanket oversight roles here, the same shape {@code * CommissionService#updateDeductions}'s {@code requireManagerOrCeo} already has at the record * level. @@ -1660,6 +1637,9 @@ private TicketDto recordDeliveryInternal(TicketDto ticket, RecordDeliveryRequest if (!DELIVERY_SOURCES.contains(source)) { throw new ApiException(HttpStatus.BAD_REQUEST, "source ต้องเป็น WAREHOUSE หรือ STOCK"); } + if ("STOCK".equals(source) && !depositSettled(s)) { + throw new ApiException(HttpStatus.CONFLICT, "ส่งสินค้าจากสต็อกได้หลังรับชำระมัดจำแล้ว (หรือได้รับการยกเว้นมัดจำ) เท่านั้น"); + } Map itemsById = itemMap(ticket.items()); Map combined = new LinkedHashMap<>(); for (RecordDeliveryRequest.Line line : request.lines()) { @@ -1828,6 +1808,10 @@ private TicketDto recordPaymentInternal(long ticketId, RecordPaymentRequest requ + (note != null ? " — " + note : ""), RelatedDocumentType.PAYMENT_RECEIPT, receiptId); reconcilePaymentStatus(ticketId, actor); + // Deposit settled (a notice alone is not money): an all-stock deal is ready to deliver. + if (depositSettled(requireTicket(ticketId).summary())) { + routeAllStockDealToDelivery(ticketId, actor); + } return requireTicket(ticketId); } @@ -2051,6 +2035,11 @@ private TicketDto updateStageInternal(long ticketId, String targetStage, String throw new ApiException(HttpStatus.BAD_REQUEST, "ไม่รองรับสถานะขั้นตอนการขาย '" + targetStage + "'"); } TicketSummaryDto s = requireTicket(ticketId).summary(); + // QA-19 (owner ruling 2026-10-29): account only ever moves a money stage FORWARD. Account reaches this + // method only through financeUpdateStage (updateStage refuses it). + if ("account".equals(actor.role()) && DealStage.indexOf(targetStage) < DealStage.indexOf(s.salesStage())) { + throw new ApiException(HttpStatus.CONFLICT, "ฝ่ายบัญชีเลื่อนขั้นได้เฉพาะไปข้างหน้า ย้อนขั้นไม่ได้"); + } requireStageMoveAllowed(s, targetStage, actor); // ONE decision, two messages. This used to be two independent rules — a backward check // with an isRoutineBackwardMove exception bolted on, and a raw `indexOf(target) - @@ -2570,6 +2559,36 @@ static String quotationRecipientForStage(String stage) { return null; } + /** + * A deal whose every line is covered from stock (non-empty, covered >= qty) has no import journey: + * fulfilment becomes FROM_STOCK and the stage goes straight to DELIVERY_SCHEDULING (S18), not + * PROCUREMENT. Shared by reserveStock, the deposit confirmation and the 0%-deposit order + * confirmation. {@code coveredByItemId} is the per-line stock cover to judge. + * + * @return true when the deal is all-stock and not already on the import axis + */ + private boolean routeAllStockDealToDelivery(TicketSummaryDto s, List items, + Map coveredByItemId, UserPrincipal actor) { + boolean allCovered = !items.isEmpty() + && items.stream() + .allMatch(item -> nullToZero(coveredByItemId.get(item.id())).compareTo(nullToZero(item.qty())) >= 0); + boolean leavesTheImportAxis = allCovered + && (s.fulfillmentStatus() == null || FulfilmentStatus.FROM_STOCK.equals(s.fulfillmentStatus())); + if (leavesTheImportAxis) { + tickets.updateFulfillmentStatus(s.id(), FulfilmentStatus.FROM_STOCK); + autoAdvanceStage(s, DealStage.DELIVERY_SCHEDULING, actor); + } + return leavesTheImportAxis; + } + + /** {@link #routeAllStockDealToDelivery(TicketSummaryDto, List, Map, UserPrincipal)} on a fresh read of the deal. */ + private void routeAllStockDealToDelivery(long ticketId, UserPrincipal actor) { + TicketDto ticket = requireTicket(ticketId); + Map covered = new LinkedHashMap<>(); + ticket.items().forEach(item -> covered.put(item.id(), nullToZero(item.qtyFromStock()))); + routeAllStockDealToDelivery(ticket.summary(), ticket.items(), covered, actor); + } + /** * Same-transaction stage advance from the deal's own operational transitions — and, since the * 2026-10-16 ruling, from a quotation being addressed to a recipient. No-throw by construction: @@ -2727,6 +2746,10 @@ public TicketDto waiveDeposit(long ticketId, String policy, String reason, UserP tickets.addEvent(ticketId, actor.id(), actor.name(), TicketEventKind.POLICY_CHANGED, s.salesStage(), s.salesStage(), "deposit_policy → " + policy + " — " + reason.trim()); + // Nothing left to wait for: an all-stock deal at CUSTOMER_CONFIRMED goes straight to delivery. + if (PaymentTrack.CUSTOMER_CONFIRMED.equals(s.paymentStatus())) { + routeAllStockDealToDelivery(ticketId, actor); + } return requireTicket(ticketId); } @@ -2886,24 +2909,6 @@ public TicketDto editItems(long ticketId, EditItemsRequest request, UserPrincipa return requireTicket(ticketId); } - /** - * V183: is this a stock sale price that will still be positive once Postgres has STORED it? - * - *

{@code sales.ticket_item.stock_sale_price} is {@code numeric(14,2)}, and Postgres rounds - * a value to the column's scale BEFORE {@code chk_ticket_item_stock_sale_price} sees it. A - * bare {@code signum() > 0} therefore does NOT match the CHECK: {@code 0.004} is positive in - * Java, is stored as {@code 0.00}, and the constraint then rejects the row — surfacing as a - * {@link org.springframework.dao.DataIntegrityViolationException}, which - * {@code ApiExceptionHandler.handleDataAccess} reports as a 500 (with the raw driver message), - * not the clean Thai 400 this validation exists to give. Rounding HALF_UP at scale 2 is - * exactly Postgres' own numeric rounding, so this predicate agrees with the CHECK for every - * input. Verified against real Postgres 17: {@code 0.004} violates the constraint, - * {@code 0.005} stores as {@code 0.01}. - */ - private static boolean isStorablePositivePrice(BigDecimal price) { - return price != null && price.setScale(2, RoundingMode.HALF_UP).signum() > 0; - } - private List mergeEditedItemsPreservingPricing( long ticketId, List existingItems, List requestItems) { List merged = new ArrayList<>(requestItems.size()); @@ -2922,33 +2927,14 @@ private List mergeEditedItemsPreservingPricing( String currency = prior != null ? prior.currency() : ((r.currency() != null && !r.currency().isBlank()) ? r.currency() : "THB"); - // V183: unlike proposedPrice/approvedPrice/calcedCost/manualPrice (guarded, "prior - // always wins" because only import/CEO may set them), sourcedFromStock/stockSalePrice - // are a sales decision made at deal-entry/edit time -- the request WINS, mirroring - // unitBasis's own null-safe-fallback treatment just above: a request that says - // nothing about these fields (r.sourcedFromStock() == null) inherits the prior row's - // value rather than silently resetting the flag to "not from stock" on every - // unrelated edit. + // sourcedFromStock is a sales decision made at deal-entry/edit time -- the request WINS, with + // the same null-safe fallback as unitBasis: a request that says nothing about it inherits the + // prior row's flag. The sales-typed stockSalePrice is gone (walk 4): the deal shows the + // CEO-approved price, so whatever a client sends is dropped and the column stays NULL. boolean sourcedFromStock = r.sourcedFromStock() != null ? r.sourcedFromStock() : (prior != null && prior.sourcedFromStock()); - BigDecimal stockSalePrice = r.sourcedFromStock() != null - ? r.stockSalePrice() - : (prior != null ? prior.stockSalePrice() : null); - // Same invariant chk_ticket_item_stock_sale_price enforces at the DB layer -- - // checked here too so a bad request 400s with a clear Thai message instead of - // reaching replaceItemsPreservingPricing and failing as an opaque 500 from a - // constraint violation. - if (sourcedFromStock) { - if (!isStorablePositivePrice(stockSalePrice)) { - throw new ApiException(HttpStatus.BAD_REQUEST, - "ต้องระบุราคาขายเมื่อเลือกสินค้าจากสต็อก"); - } - } else { - // Force-null regardless of what the request sent — never trust a stray price - // through when the item isn't flagged as stock-sourced. - stockSalePrice = null; - } + BigDecimal stockSalePrice = null; merged.add(new TicketItemDto( prior != null ? prior.id() : 0L, ticketId, @@ -3165,10 +3151,6 @@ private void addOperationalActions(List actions, TicketDto tick && FulfilmentStatus.SHIPPING.equals(s.fulfillmentStatus())) { actions.add(new TicketActionDto("GOODS_RECEIVED", "fulfillment", "รับสินค้า")); } - if (canReserveStock(ticket, actor)) { - actions.add(new TicketActionDto("RESERVE_STOCK", "fulfillment", "จองสินค้าจากสต็อก", - List.of("lines"))); - } if (canSetItemWeightMultiplier(s, actor)) { actions.add(new TicketActionDto("SET_ITEM_WEIGHT_MULTIPLIER", "fulfillment", "ตั้งน้ำหนักคอมมิชชั่นต่อรายการ", List.of("lines"))); @@ -3362,12 +3344,8 @@ private void requireDeliveryAccess(TicketSummaryDto s, UserPrincipal actor) { } /** - * The stage floor a stock-coverage declaration must clear, and the single source of truth for - * it — {@link #reserveStock}'s 409 and {@link #canReserveStock} (which decides whether {@link - * #actions} advertises RESERVE_STOCK) both read this one predicate, exactly as they both read - * {@link #canDeclareStockCoverage} for the authorisation half. Applying the floor to only one - * of them would put the capability back in the state the widening was careful to avoid: live - * but invisible, or advertised and then refused on click. + * The stage floor a stock-coverage declaration must clear: {@link #reserveStock}'s 409 reads it. + * (Walk 4: {@code RESERVE_STOCK} is no longer advertised in {@link #actions}; the endpoint stays.) * *

Why ORDER_RECEIVED (S10), and why a floor is needed at all. The * declaration is uncorroborated (see {@link #reserveStock}), yet a full-coverage one reroutes @@ -3394,14 +3372,6 @@ private static boolean stockCoverageStageReached(TicketSummaryDto s) { return DealStage.indexOf(s.salesStage()) >= DealStage.indexOf(DealStage.ORDER_RECEIVED); } - private boolean canReserveStock(TicketDto ticket, UserPrincipal actor) { - return canDeclareStockCoverage(ticket.summary(), actor) - && stockCoverageStageReached(ticket.summary()) - && !ticket.items().isEmpty() - && hasRemainingDelivery(ticket) - && !FulfilmentStatus.FULLY_DELIVERED.equals(ticket.summary().fulfillmentStatus()); - } - private boolean canRecordDelivery(TicketDto ticket, UserPrincipal actor) { if (!canWriteDelivery(ticket.summary(), actor) || !hasRemainingDelivery(ticket)) { return false; @@ -3410,7 +3380,20 @@ private boolean canRecordDelivery(TicketDto ticket, UserPrincipal actor) { boolean stockAvailable = ticket.items().stream() .anyMatch(item -> nullToZero(item.qtyFromStock()).compareTo(nullToZero(item.qtyDelivered())) > 0); boolean warehouseAvailable = warehouseDeliveryAvailable(s, s.id()); - return FulfilmentStatus.FROM_STOCK.equals(s.fulfillmentStatus()) || stockAvailable || warehouseAvailable; + return ((FulfilmentStatus.FROM_STOCK.equals(s.fulfillmentStatus()) || stockAvailable) && depositSettled(s)) + || warehouseAvailable; + } + + /** + * The deposit is in hand: paid (or later), or a no-deposit policy that has reached CUSTOMER_CONFIRMED. + * A deposit NOTICE alone is not money. Stock goods leave the warehouse only after this (walk 4: stock + * lines are covered from order confirmation, so without it a deal could deliver before paying). + */ + private static boolean depositSettled(TicketSummaryDto s) { + String ps = s.paymentStatus(); + return PaymentTrack.DEPOSIT_PAID.equals(ps) || PaymentTrack.AWAITING_FINAL_PAYMENT.equals(ps) + || PaymentTrack.FULLY_PAID.equals(ps) + || (DepositPolicy.bypassesDepositNotice(s.depositPolicy()) && PaymentTrack.CUSTOMER_CONFIRMED.equals(ps)); } /** Prerequisites only — the role checks live on each action below. */ 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/main/resources/db/migration-demo/V200.1__demo_remove_legacy_ticket_fixtures.sql b/backend/src/main/resources/db/migration-demo/V200.1__demo_remove_legacy_ticket_fixtures.sql new file mode 100644 index 000000000..07566155c --- /dev/null +++ b/backend/src/main/resources/db/migration-demo/V200.1__demo_remove_legacy_ticket_fixtures.sql @@ -0,0 +1,34 @@ +-- Removes the legacy demo ticket fixtures DEMO-TKT-02/03/04 (QA-26, owner ruling 2026-10-29: "remove demo tkt"). +-- +-- db/migration-demo is loaded by the demo profile -- including the hosted showcase, which runs prod,demo +-- (render.yaml), so this DOES run on that live database. It matches those three ticket codes ONLY and is a +-- no-op wherever they do not exist. +-- +-- V21 seeded them with the legacy ticket.status values submitted / price_proposed / approved, which no queue +-- holds any more -- they sat in the deal list as stuck, unexplained deals. DEMO-TKT-01 and -05 stay: the +-- e2e-real smoke / route-coverage / commission specs depend on them. +-- +-- ticket_item and ticket_event cascade from the ticket; notification and deal_activity do not, so they go +-- first. A fixture someone has since worked on in the showcase (it now owns a quotation, pricing request, +-- deposit notice, payment, ...) is left whole -- ticket AND its notification / activity rows -- rather than +-- failing the migration and blocking boot. One guard, applied to all three deletes. + +CREATE TEMP TABLE demo_tkt_to_remove ON COMMIT DROP AS +SELECT t.ticket_id + FROM sales.ticket t + WHERE t.code IN ('DEMO-TKT-02', 'DEMO-TKT-03', 'DEMO-TKT-04') + AND NOT EXISTS (SELECT 1 FROM sales.quotation x WHERE x.ticket_id = t.ticket_id) + AND NOT EXISTS (SELECT 1 FROM sales.pricing_request x WHERE x.ticket_id = t.ticket_id) + AND NOT EXISTS (SELECT 1 FROM sales.pricing_request_event x WHERE x.ticket_id = t.ticket_id) + AND NOT EXISTS (SELECT 1 FROM sales.deposit_notice x WHERE x.ticket_id = t.ticket_id) + AND NOT EXISTS (SELECT 1 FROM sales.payment_receipt x WHERE x.ticket_id = t.ticket_id) + AND NOT EXISTS (SELECT 1 FROM sales.delivery_record x WHERE x.ticket_id = t.ticket_id) + AND NOT EXISTS (SELECT 1 FROM sales.factory_purchase_order x WHERE x.ticket_id = t.ticket_id) + AND NOT EXISTS (SELECT 1 FROM sales.billing_note_line x WHERE x.ticket_id = t.ticket_id) + AND NOT EXISTS (SELECT 1 FROM sales.commission_record x WHERE x.source_ticket_id = t.ticket_id); + +DELETE FROM sales.notification WHERE ticket_id IN (SELECT ticket_id FROM demo_tkt_to_remove); + +DELETE FROM sales.deal_activity WHERE ticket_id IN (SELECT ticket_id FROM demo_tkt_to_remove); + +DELETE FROM sales.ticket WHERE ticket_id IN (SELECT ticket_id FROM demo_tkt_to_remove); diff --git a/backend/src/main/resources/db/migration/V201__stock_line_backfill.sql b/backend/src/main/resources/db/migration/V201__stock_line_backfill.sql new file mode 100644 index 000000000..0d3ca9437 --- /dev/null +++ b/backend/src/main/resources/db/migration/V201__stock_line_backfill.sql @@ -0,0 +1,70 @@ +-- V201: stock lines of deals whose order was confirmed BEFORE order confirmation began writing +-- ticket_item.qty_from_stock (walk 4, owner ruling 2026-10-29: a stock line is never ordered from a factory). +-- +-- 1. A ticket line of a live (not closed / lost / cancelled) deal fed by an IN_THAILAND line of an order-confirmed +-- pricing request is covered in full (qty_from_stock = qty), unless it is already on an ISSUED import request (a factory order is out: left +-- alone) or a confirmed request also maps it as an import line (the data is ambiguous: left alone). +-- 2. A deal that is now all-stock (ACTIVE, no fulfilment yet, every line covered) with its deposit settled +-- (paid or later, or a no-deposit policy at CUSTOMER_CONFIRMED - TicketService#depositSettled) goes to +-- fulfillment_status FROM_STOCK and, when still earlier, to DELIVERY_SCHEDULING with a STAGE_CHANGED event. +-- Idempotent: step 1 only raises qty_from_stock, and step 2 only picks deals with no fulfilment status. + +UPDATE sales.ticket_item ti + SET qty_from_stock = ti.qty + WHERE ti.qty_from_stock < ti.qty + -- Closed/lost deals are history: raising qty_from_stock there would retroactively hand pending commission + -- the x2/x3 stock share. ON_HOLD/DORMANT deals are temporary and still covered. + AND EXISTS (SELECT 1 FROM sales.ticket tk + WHERE tk.ticket_id = ti.ticket_id + AND tk.lifecycle NOT IN ('CLOSED_LOST', 'CANCELLED', 'COMPLETED')) + AND EXISTS (SELECT 1 + FROM sales.pricing_request_item pri + JOIN sales.pricing_request pr ON pr.pricing_request_id = pri.pricing_request_id + WHERE pri.source_ticket_item_id = ti.item_id + AND pri.stock_source = 'IN_THAILAND' + AND pr.order_confirmed_at IS NOT NULL) + AND NOT EXISTS (SELECT 1 + FROM sales.pricing_request_item pri + JOIN sales.pricing_request pr ON pr.pricing_request_id = pri.pricing_request_id + WHERE pri.source_ticket_item_id = ti.item_id + AND pri.stock_source IS NULL + AND pr.order_confirmed_at IS NOT NULL) + AND NOT EXISTS (SELECT 1 + FROM sales.import_request_item iri + JOIN sales.import_request ir ON ir.import_request_id = iri.import_request_id + WHERE iri.ticket_item_id = ti.item_id + AND ir.status = 'ISSUED'); + +WITH stage_order AS ( + SELECT ARRAY['LEAD_APPROACH', 'PRESENTATION', 'SPEC_APPROVED', 'QUOTE_DESIGN_SIDE', 'QUOTE_OWNER', + 'OWNER_SIGNOFF', 'AWAITING_BUYER', 'QUOTE_BUYER', 'NEGOTIATION', 'ORDER_RECEIVED', + 'DEPOSIT_RECEIVED', 'PROCUREMENT', 'DELIVERY_SCHEDULING', 'DELIVERED', 'CLOSED_PAID']::text[] AS stages +), +routable AS ( + SELECT t.ticket_id, t.sales_stage AS old_stage, t.created_by, + array_position(so.stages, t.sales_stage) < array_position(so.stages, 'DELIVERY_SCHEDULING') AS moves_stage + FROM sales.ticket t, stage_order so + WHERE t.lifecycle = 'ACTIVE' + AND t.fulfillment_status IS NULL + AND EXISTS (SELECT 1 FROM sales.ticket_item ti WHERE ti.ticket_id = t.ticket_id) + AND NOT EXISTS (SELECT 1 FROM sales.ticket_item ti + WHERE ti.ticket_id = t.ticket_id AND ti.qty_from_stock < ti.qty) + AND (t.payment_status IN ('DEPOSIT_PAID', 'AWAITING_FINAL_PAYMENT', 'FULLY_PAID') + OR (t.deposit_policy IN ('NOT_REQUIRED', 'WAIVED', 'CREDIT_CUSTOMER') + AND t.payment_status = 'CUSTOMER_CONFIRMED')) +), +moved AS ( + UPDATE sales.ticket t + SET fulfillment_status = 'FROM_STOCK', + sales_stage = CASE WHEN r.moves_stage THEN 'DELIVERY_SCHEDULING' ELSE t.sales_stage END, + stage_updated_at = CASE WHEN r.moves_stage THEN now() ELSE t.stage_updated_at END + FROM routable r + WHERE t.ticket_id = r.ticket_id + RETURNING t.ticket_id +) +INSERT INTO sales.ticket_event (ticket_id, actor_id, actor_name, kind, from_status, to_status, message) +SELECT r.ticket_id, r.created_by, 'ระบบ (V201)', 'STAGE_CHANGED', r.old_stage, 'DELIVERY_SCHEDULING', + 'ย้ายอัตโนมัติ: ดีลสินค้าจากสต็อกทั้งหมด และรับมัดจำแล้ว' + FROM routable r + JOIN moved m ON m.ticket_id = r.ticket_id + WHERE r.moves_stage; diff --git a/backend/src/test/java/th/co/glr/hr/common/SizeUnitTest.java b/backend/src/test/java/th/co/glr/hr/common/SizeUnitTest.java new file mode 100644 index 000000000..19c457b94 --- /dev/null +++ b/backend/src/test/java/th/co/glr/hr/common/SizeUnitTest.java @@ -0,0 +1,24 @@ +package th.co.glr.hr.common; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class SizeUnitTest { + + /** Guard: a missing size prints nothing -- never "null cm" or a bare " cm". */ + @Test + void nullAndBlankSizesGetNoUnit() { + assertThat(SizeUnit.withCmUnit(null)).isNull(); + assertThat(SizeUnit.withCmUnit("")).isEmpty(); + assertThat(SizeUnit.withCmUnit(" ")).isEqualTo(" "); + assertThat(SizeUnit.hasUnit(null)).isFalse(); + } + + @Test + void unitlessGetsCm_unitCarryingIsUntouched() { + assertThat(SizeUnit.withCmUnit("20x30.5")).isEqualTo("20x30.5 cm"); + assertThat(SizeUnit.withCmUnit("60x60 ซม.")).isEqualTo("60x60 ซม."); + assertThat(SizeUnit.withCmUnit("600x600 mm")).isEqualTo("600x600 mm"); + } +} 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/dealquotation/DealQuotationEnglishFormTest.java b/backend/src/test/java/th/co/glr/hr/dealquotation/DealQuotationEnglishFormTest.java index e8cf5c110..e1e510b70 100644 --- a/backend/src/test/java/th/co/glr/hr/dealquotation/DealQuotationEnglishFormTest.java +++ b/backend/src/test/java/th/co/glr/hr/dealquotation/DealQuotationEnglishFormTest.java @@ -313,6 +313,22 @@ void remarks_areEnglishAndCarryTheComputedDepositLeadTimeAndValidity() throws Ex assertThat(remarks).noneMatch(line -> line.contains("บริษัทฯ")); } + /** Walk 4 (W4-01), English twin: an all-stock quotation prints no "Delivery" import line, even when its + * lead-time columns carry a value; a stock line beside an import line is left out of the group. */ + @Test + void remarks_aStockLineIsLeftOutOfTheEnglishLeadTime_andAnAllStockQuotationHasNoDeliveryLine() { + DealQuotationItemDto stock = tile().withStockSource("IN_THAILAND"); + QuotationRenderModel allStock = DealQuotationRenderAdapter.toRenderModel( + withItems(englishQuotation(), List.of(stock)), null, null, List.of()); + assertThat(allStock.remarkLines()).hasSize(7).noneMatch(l -> l.contains("Delivery")); + + DealQuotationItemDto importLine = tile(); + DealQuotationItemDto stockSecond = tileAtSeq(2).withStockSource("IN_THAILAND"); + QuotationRenderModel mixed = DealQuotationRenderAdapter.toRenderModel( + withItems(englishQuotation(), List.of(importLine, stockSecond)), null, null, List.of()); + assertThat(mixed.remarkLines().get(2)).isEqualTo("3.Delivery : item 1 approximately 30-45 days"); + } + /** * ⚠️ The remark date is CE too. The Thai block prints 01/09/2569 for the same offer * date; getting this one wrong is invisible next to a correct header date, because it is @@ -1468,6 +1484,16 @@ private DealQuotationItemDto tile() { WastageCalculator.LINE_TYPE_TILE, BigDecimal.TEN, "pcs.", null, null, null, null, null); } + /** {@link #tile()} at another seq (same 30-45 day lead time), for a second line. */ + private DealQuotationItemDto tileAtSeq(int seq) { + return new DealQuotationItemDto((long) seq, seq, null, null, null, null, "A", null, null, "60x60", + new BigDecimal("2"), new BigDecimal("0.36"), WastageCalculator.QUANTITY_MODE_PIECES, null, 10, + WastageCalculator.WASTAGE_MODE_NONE, null, 1, new BigDecimal("100.00"), null, null, 30, 45, null, + new BigDecimal("2.78"), 10, 10, 10, 10, new BigDecimal("100.00"), new BigDecimal("1000.00"), + "Tile Model A", "Size 60x60x2 cm.", "(10 pcs.)", + WastageCalculator.LINE_TYPE_TILE, BigDecimal.TEN, "pcs.", null, null, null, null, null); + } + /** V178 fixture — a DIRECT_NET tile whose net (85.00) genuinely differs from its list price * (100.00): {@link DealQuotationRenderAdapter#hasSpecialPricing} rule (b). */ private DealQuotationItemDto discountedDirectNetTile() { diff --git a/backend/src/test/java/th/co/glr/hr/dealquotation/DealQuotationRenderAdapterV3Test.java b/backend/src/test/java/th/co/glr/hr/dealquotation/DealQuotationRenderAdapterV3Test.java index b887783a0..52ef1347a 100644 --- a/backend/src/test/java/th/co/glr/hr/dealquotation/DealQuotationRenderAdapterV3Test.java +++ b/backend/src/test/java/th/co/glr/hr/dealquotation/DealQuotationRenderAdapterV3Test.java @@ -500,6 +500,26 @@ void leadTime_differentRangesStaySeparateGroups() { "3.ระยะเวลานำเข้า : รายการที่ 1 ประมาณ 10-20 วัน รายการที่ 2 ประมาณ 40-50 วัน"); } + /** + * Walk 4 (W4-01): a stock line (stockSource IN_THAILAND) is not imported, so even when its lead + * time columns carry a value it is left out of the ระยะเวลานำเข้า groups - the import line alone + * is printed as "รายการที่ 1", not "รายการที่ 1-2". + */ + @Test + void leadTime_aStockLineIsLeftOutOfTheImportGroups() { + QuotationRenderModel m = model(WastageCalculator.PRICE_MODE_NET, List.of( + tileWithLeadTime(30, 45), tileWithLeadTime(30, 45).withStockSource("IN_THAILAND"))); + assertThat(m.remarkLines().get(2)).isEqualTo("3.ระยะเวลานำเข้า : รายการที่ 1 ประมาณ 30-45 วัน"); + } + + /** Every line is stock: nothing is imported, so the ระยะเวลานำเข้า remark is dropped and the rest renumber. */ + @Test + void leadTime_anAllStockQuotation_printsNoImportLeadTimeLine() { + QuotationRenderModel m = model(WastageCalculator.PRICE_MODE_NET, List.of( + tileWithLeadTime(30, 45).withStockSource("IN_THAILAND"))); + assertThat(m.remarkLines()).hasSize(7).noneMatch(l -> l.contains("ระยะเวลานำเข้า")); + } + /** No item carries a lead time at all -- owner feedback (2026-09-14) supersedes the earlier * "print a visible blank" behaviour: the line is now dropped ENTIRELY (not left blank), and * every remark after it renumbers down by one -- exactly 7 lines, old line 4 becomes "3.", @@ -696,7 +716,8 @@ private DealQuotationItemDto reseq(DealQuotationItemDto src, int seq) { src.piecesPerSqm(), src.piecesBeforeWastage(), src.piecesAfterWastage(), src.piecesFinal(), src.boxes(), src.netUnitPrice(), src.lineAmount(), src.descriptionLine(), src.sizeLine(), src.calculationLine(), src.lineType(), src.quantity(), src.unit(), src.specialPriceSqm(), - src.adjustmentPct(), src.adjustmentDeadline(), src.specialPriceLine(), src.adjustmentAmount()); + src.adjustmentPct(), src.adjustmentDeadline(), src.specialPriceLine(), src.adjustmentAmount()) + .withStockSource(src.stockSource()); } private DealQuotationItemDto plain() { diff --git a/backend/src/test/java/th/co/glr/hr/dealquotation/DealQuotationSlice2HttpIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/dealquotation/DealQuotationSlice2HttpIntegrationTest.java index cd9d84f27..a08fe2e2e 100644 --- a/backend/src/test/java/th/co/glr/hr/dealquotation/DealQuotationSlice2HttpIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/dealquotation/DealQuotationSlice2HttpIntegrationTest.java @@ -80,7 +80,7 @@ void create_withARecipient_is201_andTheBodyCarriesWhatTheEditorReads() throws Ex assertThat(q.get("origin").asText()).isEqualTo("DEAL_DIRECT"); assertThat(q.get("docStatus").asText()).isEqualTo("DRAFT"); assertThat(q.get("ticketId").asLong()).isEqualTo(ticketId); - assertThat(q.get("ticketCode").asText()).startsWith("PR-"); + assertThat(q.get("ticketCode").asText()).startsWith("DL-"); // ขั้น 4 over HTTP, in the CREATE response itself: addressing the document to the designer // is what moved the deal (owner ruling 2026-10-16, .design/quote-stage-gates D-2), and the // 201 body already reports the new stage — so the editor needs no second fetch to render diff --git a/backend/src/test/java/th/co/glr/hr/deposit/RemainingInvoiceNewEngineZeroDepositIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/deposit/RemainingInvoiceNewEngineZeroDepositIntegrationTest.java new file mode 100644 index 000000000..cb334527f --- /dev/null +++ b/backend/src/test/java/th/co/glr/hr/deposit/RemainingInvoiceNewEngineZeroDepositIntegrationTest.java @@ -0,0 +1,244 @@ +package th.co.glr.hr.deposit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import th.co.glr.hr.auth.UserPrincipal; +import th.co.glr.hr.common.ApiException; +import th.co.glr.hr.customer.CustomerRepository; +import th.co.glr.hr.customerquotation.CustomerQuotationRepository; +import th.co.glr.hr.notification.NotificationRepository; +import th.co.glr.hr.notification.SalesNotificationMailer; +import th.co.glr.hr.support.AbstractPostgresIntegrationTest; +import th.co.glr.hr.ticket.TicketRepository; + +/** + * W5-01 (walk 5): a deal quoted with no deposit (0% / WAIVED / CREDIT_CUSTOMER) on the NEW quotation engine + * (PRICING_REQUEST- or DEAL_DIRECT-origin rows in sales.quotation) has no legacy CustomerQuotation and no + * deposit notice, so the remaining-invoice resolver found nothing to bill and answered 409 for every such deal. + * The "answered quotation" is the same one payable reads (TicketRepository#ANSWERED_QUOTATION_SQL); the invoice + * is for the full payable with no deposit deduction, VAT = round2(net x 7%) on its own document. + */ +class RemainingInvoiceNewEngineZeroDepositIntegrationTest extends AbstractPostgresIntegrationTest { + + private TicketRepository tickets; + private RemainingInvoiceService remainingInvoices; + private long employeeId; + private UserPrincipal owner; + private int seq; + + @BeforeEach + void wire() { + tickets = new TicketRepository(jdbc); + CustomerQuotationRepository quotations = new CustomerQuotationRepository(jdbc); + DepositNoticeService depositNotices = new DepositNoticeService(new DepositNoticeRepository(jdbc), tickets, + new NotificationRepository(jdbc, SalesNotificationMailer.NO_OP), new DepositNoticePdfRenderer(), + new CustomerRepository(jdbc), quotations); + remainingInvoices = new RemainingInvoiceService(new RemainingInvoiceRepository(jdbc), depositNotices, tickets, + new RemainingInvoicePdfRenderer()); + employeeId = jdbc.queryForObject(""" + INSERT INTO hr.employee (employee_code, first_name_th, last_name_th) + VALUES ('RI0-OWN', 'ทดสอบ', 'ใบแจ้งหนี้') RETURNING employee_id + """, Map.of(), Long.class); + owner = new UserPrincipal(employeeId, "ri0@glr.co.th", "owner", "sales", employeeId, true, + LocalDate.of(2020, 1, 1), false, null, false); + } + + @Test + void zeroPercentPcrDeal_issuesTheInvoiceForTheFullPayable() { + long ticket = deal("NOT_REQUIRED"); + long q = quotation(ticket, "PRICING_REQUEST", "ACCEPTED", "1000.00"); + line(q, 1, "กระเบื้อง A", "10", "60.00"); + line(q, 2, "กระเบื้อง B", "5", "80.00"); + + var draft = remainingInvoices.createDraft(ticket, null, owner); + + assertThat(draft.itemsTotal()).isEqualByComparingTo("1000.00"); + assertThat(draft.depositDeduction()).isEqualByComparingTo("0"); + assertThat(draft.netAmount()).isEqualByComparingTo("1000.00"); + assertThat(draft.vatAmount()).isEqualByComparingTo("70.00"); + assertThat(draft.grandTotal()).as("= the deal's payable").isEqualByComparingTo(tickets.payableAmount(ticket)); + assertThat(draft.items()).hasSize(2); + assertThat(remainingInvoices.issue(draft.id(), owner).status()).isEqualTo("ISSUED"); + } + + @Test + void zeroPercentDirectDeal_issuesTheInvoiceForTheFullPayable() { + long ticket = deal("WAIVED"); + long q = quotation(ticket, "DEAL_DIRECT", "APPROVED", "1234.50"); + line(q, 1, "รายการตรง", "10", "123.45"); + // confirm-order leaves a DEAL_DIRECT quotation APPROVED; the promotion event is what makes it "answered". + jdbc.update(""" + INSERT INTO sales.ticket_event (ticket_id, actor_id, actor_name, kind, related_document_type, related_document_id) + VALUES (:t, :by, 'x', 'DEAL_PROMOTED_FROM_QUOTATION', 'QUOTATION', :q) + """, new MapSqlParameterSource().addValue("t", ticket).addValue("by", employeeId).addValue("q", q)); + + var draft = remainingInvoices.createDraft(ticket, null, owner); + + assertThat(draft.itemsTotal()).isEqualByComparingTo("1234.50"); + assertThat(draft.vatAmount()).isEqualByComparingTo("86.42"); // round2(1234.50 x 7%) = 86.415 -> 86.42 + assertThat(draft.grandTotal()).isEqualByComparingTo(tickets.payableAmount(ticket)); + assertThat(remainingInvoices.issue(draft.id(), owner).status()).isEqualTo("ISSUED"); + } + + @Test + void allStockZeroPercentDeal_issuesTheInvoiceForTheFullPayable() { + long ticket = deal("CREDIT_CUSTOMER"); + jdbc.update("UPDATE sales.ticket SET fulfillment_status = 'FULLY_DELIVERED', sales_stage = 'DELIVERED' WHERE ticket_id = :t", + Map.of("t", ticket)); + jdbc.update(""" + INSERT INTO sales.ticket_item (ticket_id, brand, model, size, qty, qty_from_stock, qty_delivered, unit, sort_order) + VALUES (:t, 'SCG', 'Stock', '60x60', 10, 10, 10, 'pcs', 0) + """, Map.of("t", ticket)); + long q = quotation(ticket, "PRICING_REQUEST", "ACCEPTED", "600.00"); + line(q, 1, "สต็อก", "10", "60.00"); + + var draft = remainingInvoices.createDraft(ticket, null, owner); + + assertThat(draft.depositDeduction()).isEqualByComparingTo("0"); + assertThat(draft.grandTotal()).isEqualByComparingTo(tickets.payableAmount(ticket)); + assertThat(remainingInvoices.issue(draft.id(), owner).status()).isEqualTo("ISSUED"); + } + + // An EN quotation carries no VAT (payable reads it as 0%): the invoice must not add 7%, before or after issue + // (once issued, payable follows the document - a wrong 7% here would bill the customer extra). + @Test + void zeroPercentEnglishDeal_hasNoVat_andTheInvoiceEqualsPayableBeforeAndAfterIssue() { + long ticket = deal("NOT_REQUIRED"); + long q = quotation(ticket, "PRICING_REQUEST", "ACCEPTED", "1000.00"); + jdbc.update("UPDATE sales.quotation SET document_language = 'EN' WHERE quotation_id = :q", Map.of("q", q)); + line(q, 1, "Tile A", "10", "100.00"); + BigDecimal payable = tickets.payableAmount(ticket); + assertThat(payable).as("fixture: an EN quotation has no VAT").isEqualByComparingTo("1000.00"); + + var draft = remainingInvoices.createDraft(ticket, null, owner); + + assertThat(draft.vatAmount()).isEqualByComparingTo("0"); + assertThat(draft.grandTotal()).isEqualByComparingTo(payable); + var issued = remainingInvoices.issue(draft.id(), owner); + assertThat(issued.vatAmount()).isEqualByComparingTo("0"); + assertThat(tickets.payableAmount(ticket)).as("payable follows the issued document").isEqualByComparingTo("1000.00"); + } + + // Owner ruling (Ploy): the invoice prints the QUOTATION'S OWN lines - each line's stored amount, the ส่วนลดพิเศษ + // (ADJUSTMENT) as its own negative line - and they add up to the stored total. Here 3 x 33.3333 is stored as 99.99 + // (not the 100.00 a recompute gives) and a -10.00 discount follows: 89.99 net, 6.30 VAT, 96.29 = payable. + @Test + void zeroPercentDeal_printsTheQuotationsOwnLines_discountIncluded_andTheTotalIsTheStoredOne() { + long ticket = deal("NOT_REQUIRED"); + long q = quotation(ticket, "PRICING_REQUEST", "ACCEPTED", "89.99"); + line(q, 1, "กระเบื้อง", "3", "33.3333", "99.99", "TILE"); + line(q, 2, "ส่วนลดพิเศษ 10 บาท", "-1", "10.00", "-10.00", "ADJUSTMENT"); + BigDecimal payable = tickets.payableAmount(ticket); + assertThat(payable).isEqualByComparingTo("96.29"); + + var draft = remainingInvoices.createDraft(ticket, null, owner); + + assertThat(draft.items()).extracting(i -> i.amount()).usingElementComparator(BigDecimal::compareTo) + .containsExactly(new BigDecimal("99.99"), new BigDecimal("-10.00")); + assertThat(draft.items().get(1).description()).isEqualTo("ส่วนลดพิเศษ 10 บาท"); + assertThat(draft.items().stream().map(i -> i.amount()).reduce(BigDecimal.ZERO, BigDecimal::add)) + .as("the lines add up to the stored total").isEqualByComparingTo("89.99"); + assertThat(draft.itemsTotal()).isEqualByComparingTo("89.99"); + assertThat(draft.netAmount()).isEqualByComparingTo("89.99"); + assertThat(draft.vatAmount()).isEqualByComparingTo("6.30"); + assertThat(draft.grandTotal()).isEqualByComparingTo(payable); + } + + // Payable skips an answered row whose stored total is NULL, so the invoice must refuse it rather than quietly + // billing the line sum (the two would then disagree). + @Test + void aNewEngineQuotationWithNoStoredTotal_isRefused409_neverBilledFromTheLineSum() { + long ticket = deal("NOT_REQUIRED"); + long q = quotation(ticket, "PRICING_REQUEST", "ACCEPTED", null); + line(q, 1, "กระเบื้อง A", "10", "100.00"); + + assertThatThrownBy(() -> remainingInvoices.createDraft(ticket, null, owner)) + .isInstanceOfSatisfying(ApiException.class, e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT)); + } + + // Payable and the invoice must pick the SAME quotation. Here payable's pick is a ticket-level legacy row + // (BUYER outranks OWNER) that the invoice resolver cannot bill, so the invoice refuses rather than silently + // billing the OWNER's new-engine quotation that payable ignores. + @Test + void whenPayablePicksANonNewEngineQuotation_theInvoiceDoesNotBillADifferentOne() { + long ticket = deal("NOT_REQUIRED"); + long legacy = quotation(ticket, null, "ACCEPTED", "500.00"); + jdbc.update("UPDATE sales.quotation SET recipient_type = 'BUYER' WHERE quotation_id = :q", Map.of("q", legacy)); + long newEngine = quotation(ticket, "PRICING_REQUEST", "ACCEPTED", "1000.00"); + jdbc.update("UPDATE sales.quotation SET recipient_type = 'OWNER' WHERE quotation_id = :q", Map.of("q", newEngine)); + line(newEngine, 1, "กระเบื้อง A", "10", "100.00"); + assertThat(tickets.payableAmount(ticket)).as("fixture: payable picks the BUYER legacy row").isEqualByComparingTo("535.00"); + + assertThatThrownBy(() -> remainingInvoices.createDraft(ticket, null, owner)) + .isInstanceOfSatisfying(ApiException.class, e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT)); + } + + // PIN: a deal WITH a deposit is unchanged - no issued notice means no remaining invoice (never billed in full). + @Test + void aDealWithADeposit_andNoIssuedNotice_stillCannotInvoice() { + long ticket = deal("REQUIRED"); + long q = quotation(ticket, "PRICING_REQUEST", "ACCEPTED", "1000.00"); + line(q, 1, "กระเบื้อง A", "10", "100.00"); + + assertThatThrownBy(() -> remainingInvoices.createDraft(ticket, null, owner)) + .isInstanceOfSatisfying(ApiException.class, e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT)); + } + + // PIN: no answered quotation at all (a quotation only ISSUED) -> still nothing to bill. + @Test + void aZeroDepositDeal_whoseQuotationIsOnlyIssued_cannotInvoice() { + long ticket = deal("NOT_REQUIRED"); + long q = quotation(ticket, "PRICING_REQUEST", "ISSUED", "1000.00"); + line(q, 1, "กระเบื้อง A", "10", "100.00"); + + assertThatThrownBy(() -> remainingInvoices.createDraft(ticket, null, owner)) + .isInstanceOfSatisfying(ApiException.class, e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT)); + } + + // ── fixtures ───────────────────────────────────────────────────────────────────────── + + private long deal(String depositPolicy) { + return jdbc.queryForObject(""" + INSERT INTO sales.ticket (code, title, created_by, customer_name, status, payment_status, deposit_policy, sales_stage) + VALUES (:code, 'ri0', :by, 'บริษัท ทดสอบ จำกัด', 'quotation_issued', :ps, :policy, 'ORDER_RECEIVED') + RETURNING ticket_id + """, new MapSqlParameterSource().addValue("code", "RI0-" + (++seq)).addValue("by", employeeId) + .addValue("ps", "REQUIRED".equals(depositPolicy) ? "CUSTOMER_CONFIRMED" : "CUSTOMER_CONFIRMED") + .addValue("policy", depositPolicy), Long.class); + } + + private long quotation(long ticket, String origin, String docStatus, String totalAmount) { + return jdbc.queryForObject(""" + INSERT INTO sales.quotation (ticket_id, number, issued_by, origin, doc_status, quotation_version, total_amount) + VALUES (:t, :n, :by, :o, :s, :v, :total) RETURNING quotation_id + """, new MapSqlParameterSource().addValue("t", ticket).addValue("n", "QT-RI0-" + (++seq)) + .addValue("by", employeeId).addValue("o", origin).addValue("s", docStatus).addValue("v", seq) + .addValue("total", totalAmount == null ? null : new BigDecimal(totalAmount)), Long.class); + } + + private void line(long quotation, int seq, String description, String qty, String unitPrice) { + BigDecimal amount = new BigDecimal(qty).multiply(new BigDecimal(unitPrice)); + line(quotation, seq, description, qty, unitPrice, amount.toPlainString(), "TILE"); + } + + /** {@code amount} is the STORED line amount (it may differ from qty x price rounded). */ + private void line(long quotation, int seq, String description, String qty, String unitPrice, String amount, + String lineType) { + BigDecimal price = new BigDecimal(unitPrice); + jdbc.update(""" + INSERT INTO sales.quotation_item (quotation_id, seq, description, qty, raw_unit, unit_price, sales_discount, + final_unit_price, amount, line_type) + VALUES (:q, :seq, :d, :qty, 'แผ่น', :p, 0, :p, :amt, :lt) + """, new MapSqlParameterSource().addValue("q", quotation).addValue("seq", seq).addValue("d", description) + .addValue("qty", new BigDecimal(qty)).addValue("p", price).addValue("amt", new BigDecimal(amount)) + .addValue("lt", lineType)); + } +} diff --git a/backend/src/test/java/th/co/glr/hr/deposit/RemainingInvoicePdfRendererTest.java b/backend/src/test/java/th/co/glr/hr/deposit/RemainingInvoicePdfRendererTest.java index 38c47f7dd..5a79a0f8d 100644 --- a/backend/src/test/java/th/co/glr/hr/deposit/RemainingInvoicePdfRendererTest.java +++ b/backend/src/test/java/th/co/glr/hr/deposit/RemainingInvoicePdfRendererTest.java @@ -65,6 +65,29 @@ private static void requireLibreOffice() { Assumptions.assumeTrue(LibreOfficePdfConverter.isAvailable(), "LibreOffice (soffice) not installed"); } + // A document with no VAT (an EN-quotation deal): the template's "ภาษีมูลค่าเพิ่ม 7%" row must not print at all + // (the EN quotation form has no VAT row either). With VAT the row is printed as before. + @Test + void vatRow_isNotPrinted_whenTheDocumentHasNoVat_andIsPrintedOtherwise() throws Exception { + RemainingInvoiceRenderModel noVat = new RemainingInvoiceRenderModel(LocalDate.of(2026, 10, 28), "GLR6999999-1", + "บริษัท ทดสอบ จำกัด", "สำนักงานใหญ่", "0105599999999", "99 ถนนทดสอบ", "ตึกตัวอย่าง", "QT-TEST-0002", null, + twoItems(), BigDecimal.ZERO, new BigDecimal("1000.00"), BigDecimal.ZERO, new BigDecimal("1000.00"), "สมหญิง ทดสอบ"); + try (Workbook wb = render(noVat)) { + Sheet sh = wb.getSheet("Update"); + int d = 0; + while (!"รวมเป็นเงิน".equals(norm(str(sh, NET + d, 7))) && d < 40) d++; + assertThat(str(sh, VAT + d, 4)).as("VAT label").isBlank(); + assertThat(str(sh, VAT + d, 7)).as("7% rate").isBlank(); + assertThat(str(sh, VAT + d, 8)).as("VAT amount").isBlank(); + } + try (Workbook wb = render(model())) { + Sheet sh = wb.getSheet("Update"); + int d = 0; + while (!"รวมเป็นเงิน".equals(norm(str(sh, NET + d, 7))) && d < 40) d++; + assertThat(norm(str(sh, VAT + d, 4))).isEqualTo("ภาษีมูลค่าเพิ่ม"); + } + } + @Test void headerBlockLandsInTheDesignCellMapAndKeepsTheLabels() throws Exception { try (Workbook wb = render(model())) { @@ -121,6 +144,20 @@ void itemRowsAndTheDepositRowPrintStoredValues() throws Exception { } } + // A quotation's ส่วนลดพิเศษ line (qty -1, negative stored amount) prints as its own line with the stored values. + @Test + void aNegativeDiscountLine_printsItsStoredNegativeAmount() throws Exception { + List items = new ArrayList<>(twoItems()); + items.add(item(3, "ส่วนลดพิเศษ 10 บาท", "-1", null, "10.00", null, "10.00", "-10.00")); + try (Workbook wb = render(model(LocalDate.of(2026, 10, 28), BigDecimal.ZERO, items))) { + Sheet sh = wb.getSheetAt(0); + int r = ITEM0 + 2; + assertThat(norm(str(sh, r, 1))).isEqualTo("ส่วนลดพิเศษ 10 บาท"); + assertThat(num(sh, r, 2)).isEqualTo(-1.0); + assertThat(num(sh, r, 8)).isCloseTo(-10.0, within(0.0001)); + } + } + @Test void noDepositRowWhenNothingWasDeducted() throws Exception { try (Workbook wb = render(model(LocalDate.of(2026, 10, 28), BigDecimal.ZERO, twoItems()))) { diff --git a/backend/src/test/java/th/co/glr/hr/deposit/RemainingInvoiceServiceIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/deposit/RemainingInvoiceServiceIntegrationTest.java index 7a1d3612e..b03314776 100644 --- a/backend/src/test/java/th/co/glr/hr/deposit/RemainingInvoiceServiceIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/deposit/RemainingInvoiceServiceIntegrationTest.java @@ -414,7 +414,7 @@ void createDraft_negativeNetAfterDeduction_isRefusedWithConflict() { @Test void issue_supersedesAnyOtherIssuedRemainingInvoiceOnTheSameDeal_evenADifferentChain() { - DealFixture fixtureA = buildAcceptedDeal(salesActor, "MultiChainA"); + DealFixture fixtureA = buildAcceptedDeal(salesActor, "MultiChainA", "MultiChainB"); RemainingInvoiceDocumentDto issuedA = remainingInvoiceService.issue( remainingInvoiceService.createDraft(fixtureA.ticketId(), null, salesActor).id(), salesActor); assertThat(issuedA.status()).isEqualTo("ISSUED"); @@ -422,7 +422,7 @@ void issue_supersedesAnyOtherIssuedRemainingInvoiceOnTheSameDeal_evenADifferentC // A wholly SEPARATE pricing-request chain on the SAME ticket, driven to its own accepted // quotation + issued deposit notice — a real shape (a later revision/re-quote on the same // deal), not merely a synthetic id swap. - buildSecondAcceptedQuotationChain(fixtureA.ticketId(), salesActor, "MultiChainB"); + acceptPreparedSecondChain(fixtureA.ticketId(), salesActor); RemainingInvoiceDocumentDto draftB = remainingInvoiceService.createDraft(fixtureA.ticketId(), null, salesActor); // The newest qualifying quotation is chain B's — never chain A's (which already has its // own ISSUED remaining invoice), proving this draft is genuinely a different chain. @@ -448,11 +448,11 @@ void issue_supersedesAnyOtherIssuedRemainingInvoiceOnTheSameDeal_evenADifferentC @Test void createDraft_refusesASecondDraftOnTheDealWhileOneAlreadyExists_evenADifferentChain() { - DealFixture fixtureA = buildAcceptedDeal(salesActor, "MultiDraftA"); + DealFixture fixtureA = buildAcceptedDeal(salesActor, "MultiDraftA", "MultiDraftB"); RemainingInvoiceDocumentDto draftA = remainingInvoiceService.createDraft(fixtureA.ticketId(), null, salesActor); assertThat(draftA.status()).isEqualTo("DRAFT"); - buildSecondAcceptedQuotationChain(fixtureA.ticketId(), salesActor, "MultiDraftB"); + acceptPreparedSecondChain(fixtureA.ticketId(), salesActor); // A second DRAFT on the SAME deal is refused even though it would be for a DIFFERENT // quotation chain than draftA's — O2: at most one live (DRAFT or ISSUED) remaining @@ -575,11 +575,11 @@ void createDraft_repositoryRaceIsMappedToConflict_neverAnUnmapped500() { * not per chain — enforceable as a real DB invariant since P1). */ @Test void revise_refusesWhenADraftForADifferentChainAlreadyExistsOnTheDeal() { - DealFixture fixtureA = buildAcceptedDeal(salesActor, "ReviseCrossChainA"); + DealFixture fixtureA = buildAcceptedDeal(salesActor, "ReviseCrossChainA", "ReviseCrossChainB"); RemainingInvoiceDocumentDto issuedA = remainingInvoiceService.issue( remainingInvoiceService.createDraft(fixtureA.ticketId(), null, salesActor).id(), salesActor); - buildSecondAcceptedQuotationChain(fixtureA.ticketId(), salesActor, "ReviseCrossChainB"); + acceptPreparedSecondChain(fixtureA.ticketId(), salesActor); RemainingInvoiceDocumentDto draftB = remainingInvoiceService.createDraft(fixtureA.ticketId(), null, salesActor); assertThat(draftB.customerQuotationId()).isNotEqualTo(issuedA.customerQuotationId()); @@ -891,6 +891,16 @@ void revise_repositoryRaceIsMappedToConflict_neverAnUnmapped500() { private record DealFixture(long ticketId, long pricingRequestId, long depositNoticeId) {} private DealFixture buildAcceptedDeal(UserPrincipal owner, String label) { + return buildAcceptedDeal(owner, label, null); + } + + /** Chain B's request and ISSUED quotation, prepared BEFORE chain A's order is confirmed (walk 4, W4-04: a + * confirmed deal can start no new pricing round); {@link #acceptPreparedSecondChain} accepts and confirms it. */ + private long preparedSecondPricingRequestId; + private long preparedSecondIssuedQuotationId; + + /** {@code secondChainLabel} non-null: a second chain is prepared (driven to ISSUED) before A is confirmed. */ + private DealFixture buildAcceptedDeal(UserPrincipal owner, String label, String secondChainLabel) { long catalogProductId = insertCatalogProduct(FACTORY, "IT", "TEST-RI-" + label + "-" + UUID.randomUUID().toString().substring(0, 8), new BigDecimal("100.00"), "THB", "per_piece"); @@ -926,6 +936,9 @@ null, new BigDecimal("10"), new BigDecimal("0.36"), WastageCalculator.QUANTITY_M long pricingRequestId = pricingRequestService.createDraft(ticketId, request, owner).summary().id(); driveDraftPricingRequestToQuotationAccepted(pricingRequestId, quantity, owner); + if (secondChainLabel != null) { + prepareSecondQuotationChain(ticketId, owner, secondChainLabel); + } orderConfirmation.confirmOrder(pricingRequestId, new ConfirmOrderRequest(UUID.randomUUID().toString()), owner); @@ -941,8 +954,9 @@ null, new BigDecimal("10"), new BigDecimal("0.36"), WastageCalculator.QUANTITY_M * driven to ACCEPTED (no second deposit notice: one ISSUED per deal), exactly like {@link #buildAcceptedDeal} * except it reuses the given ticket (and its first ticket item) instead of creating a new one. * Used by the O2 (one live remaining invoice per deal) tests to prove superseding/blocking - * reaches a genuinely different chain, not merely a different id on the same fixture. */ - private void buildSecondAcceptedQuotationChain(long ticketId, UserPrincipal owner, String label) { + * reaches a genuinely different chain, not merely a different id on the same fixture. This half builds it + * up to an ISSUED quotation; {@link #acceptPreparedSecondChain} finishes it. */ + private void prepareSecondQuotationChain(long ticketId, UserPrincipal owner, String label) { long catalogProductId = insertCatalogProduct(FACTORY, "IT", "TEST-RI-" + label + "-" + UUID.randomUUID().toString().substring(0, 8), new BigDecimal("100.00"), "THB", "per_piece"); @@ -983,8 +997,15 @@ null, new BigDecimal("10"), new BigDecimal("0.36"), WastageCalculator.QUANTITY_M // tests below, which does the identical "go around the service with raw SQL" move for the // identical reason). Chain A's own row is left untouched — genuinely still ACCEPTED. CustomerQuotationDto issuedB = driveDraftPricingRequestToQuotationIssued(pricingRequestId, quantity, owner); + preparedSecondPricingRequestId = pricingRequestId; + preparedSecondIssuedQuotationId = issuedB.id(); + } + + /** Accepts (raw SQL, see the MAJOR 5 note above) and confirms the chain {@link #prepareSecondQuotationChain} built. */ + private void acceptPreparedSecondChain(long ticketId, UserPrincipal owner) { + long pricingRequestId = preparedSecondPricingRequestId; jdbc.update("UPDATE sales.quotation SET doc_status = 'ACCEPTED', accepted_at = now() WHERE quotation_id = :id", - Map.of("id", issuedB.id())); + Map.of("id", preparedSecondIssuedQuotationId)); // recordOutcome ALSO transitions the pricing request's own status (QUOTATION_ISSUED -> // QUOTATION_ACCEPTED) — the confirmOrder call right below gates on THIS column, not // sales.quotation.doc_status, so the raw-SQL bypass above must carry that write too. diff --git a/backend/src/test/java/th/co/glr/hr/finance/FinanceDealLockdownIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/finance/FinanceDealLockdownIntegrationTest.java index 4bc48db6a..783392d31 100644 --- a/backend/src/test/java/th/co/glr/hr/finance/FinanceDealLockdownIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/finance/FinanceDealLockdownIntegrationTest.java @@ -243,6 +243,19 @@ void stage_accountMovesAMoneyStage_andCannotMoveANonMoneyStage() throws Exceptio assertFinanceShape(deal, id); } + /** QA-19 (owner ruling 2026-10-29): account only ever moves a money stage FORWARD; going back is refused. */ + @Test + void stage_accountCannotMoveABackwardMoneyStage_409_andTheDealStaysPut() throws Exception { + long id = createS15Deal("FULLY_PAID"); + jdbc.update("UPDATE sales.ticket SET sales_stage = 'CLOSED_PAID', next_follow_up_at = CURRENT_DATE + 3 WHERE ticket_id = :id", + new MapSqlParameterSource("id", id)); + + expectStatus(financeMvc.perform(json(post("/api/finance/deals/{id}/stage", id).session(session(accountUser)), + "{\"stage\":\"DEPOSIT_RECEIVED\",\"note\":\"ย้อนกลับ\"}")), 409); + + assertThat(salesStageOf(id)).isEqualTo(DealStage.CLOSED_PAID); + } + @Test void comments_accountAndCeoCanPost_andItAppearsInTheFinanceDeal() throws Exception { long id = createS15Deal("DEPOSIT_PAID"); diff --git a/backend/src/test/java/th/co/glr/hr/finance/FinanceDealServiceTest.java b/backend/src/test/java/th/co/glr/hr/finance/FinanceDealServiceTest.java index e1830ec67..ffbb899de 100644 --- a/backend/src/test/java/th/co/glr/hr/finance/FinanceDealServiceTest.java +++ b/backend/src/test/java/th/co/glr/hr/finance/FinanceDealServiceTest.java @@ -16,7 +16,9 @@ import th.co.glr.hr.common.ApiException; import th.co.glr.hr.deposit.DepositNoticeRepository; import th.co.glr.hr.deposit.RemainingInvoiceRepository; +import th.co.glr.hr.ticket.DealStage; import th.co.glr.hr.ticket.TicketRepository; +import th.co.glr.hr.ticket.TicketResponses.TicketActionDto; import th.co.glr.hr.ticket.TicketResponses.TicketActionsResponse; import th.co.glr.hr.ticket.TicketService; import th.co.glr.hr.ticket.TicketSummaryDto; @@ -54,9 +56,61 @@ void anActionThatMovesTheDealOutOfScope_stillReturnsTheUpdatedView_notA403() { assertThat(dto.availableActions()).isEmpty(); } + // ── QA-19 (owner ruling 2026-10-29): a manual money-stage move is only ever FORWARD ────────── + + /** S20 CLOSED_PAID, account has confirmed close, waiting on the CEO: "move to S11" is a step backwards. */ + @Test + void availableActions_atClosedPaid_offerNoAdvanceOrUpdateStage_butKeepTheCloseActions() { + List actions = actionsAt(DealStage.CLOSED_PAID, + new TicketActionDto("ADVANCE_STAGE", "stage", "เลื่อนไปขั้น S11", DealStage.DEPOSIT_RECEIVED), + new TicketActionDto("UPDATE_STAGE", "stage", "อัปเดตขั้น"), + new TicketActionDto("REVOKE_CLOSE_CONFIRM", "money", "ถอนการยืนยันปิดงาน")); + + assertThat(actions).doesNotContain("ADVANCE_STAGE", "UPDATE_STAGE"); + assertThat(actions).contains("REVOKE_CLOSE_CONFIRM"); + } + + @Test + void availableActions_atOrderReceived_stillOffersAdvanceToDepositReceived_andUpdateStage() { + List actions = actionsAt(DealStage.ORDER_RECEIVED, + new TicketActionDto("ADVANCE_STAGE", "stage", "เลื่อนไปขั้น S11", DealStage.DEPOSIT_RECEIVED), + new TicketActionDto("UPDATE_STAGE", "stage", "อัปเดตขั้น")); + + assertThat(actions).containsExactly("ADVANCE_STAGE", "UPDATE_STAGE"); + } + + /** Edge: a target equal to the current stage is not "ahead"; the one still ahead survives alone. */ + @Test + void availableActions_atDepositReceived_dropsTheSameStageTarget_keepsTheLaterOne() { + List offered = List.of( + new TicketActionDto("ADVANCE_STAGE", "stage", "S11", DealStage.DEPOSIT_RECEIVED), + new TicketActionDto("ADVANCE_STAGE", "stage", "S20", DealStage.CLOSED_PAID), + new TicketActionDto("UPDATE_STAGE", "stage", "อัปเดตขั้น")); + when(tickets.findSummaryById(anyLong())).thenReturn(Optional.of(summary(DealStage.DEPOSIT_RECEIVED))); + when(tickets.isInAccountScope(anyLong())).thenReturn(true); + when(ticketService.financeActionsAlreadyScoped(anyLong(), any())) + .thenReturn(new TicketActionsResponse(null, offered, List.of())); + + assertThat(service.get(10L, account).availableActions()) + .extracting(FinanceDealDto.Action::targetStage) + .containsExactly(DealStage.CLOSED_PAID, null); + } + + private List actionsAt(String salesStage, TicketActionDto... offered) { + when(tickets.findSummaryById(anyLong())).thenReturn(Optional.of(summary(salesStage))); + when(tickets.isInAccountScope(anyLong())).thenReturn(true); + when(ticketService.financeActionsAlreadyScoped(anyLong(), any())) + .thenReturn(new TicketActionsResponse(null, List.of(offered), List.of())); + return service.get(10L, account).availableActions().stream().map(FinanceDealDto.Action::action).toList(); + } + private static TicketSummaryDto summary() { + return summary("NEGOTIATION"); + } + + private static TicketSummaryDto summary(String salesStage) { return new TicketSummaryDto(1L, "PR-T", null, "t", "draft", "NORMAL", 1L, null, null, null, "cust", null, - null, null, null, null, null, null, null, null, 0, false, null, null, "NEGOTIATION", null, null, null, + null, null, null, null, null, null, null, null, 0, false, null, null, salesStage, null, null, null, "ACTIVE", null, null, null, null); } } diff --git a/backend/src/test/java/th/co/glr/hr/importrequest/ImportRequestEmailDraftIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/importrequest/ImportRequestEmailDraftIntegrationTest.java index 7fe00496c..4e5164d81 100644 --- a/backend/src/test/java/th/co/glr/hr/importrequest/ImportRequestEmailDraftIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/importrequest/ImportRequestEmailDraftIntegrationTest.java @@ -164,6 +164,33 @@ void issue_bodyUsesTheNumberedBlockLayout_skippingBlankFieldsAndNeverPrintingNul .doesNotContain("wastage").doesNotContain("cost"); } + /** + * Owner ruling 2026-10-30: the factory-facing email shows item size in centimetres. A size with + * no unit token ("20x30.5", what a catalogue-linked line now stores) gets " cm"; a size that + * already carries a unit is printed exactly as typed -- no doubling ("60x60 cm cm"), no + * conversion ("600x600 mm" stays millimetres), Thai unit too ("60x60 ซม."). + */ + @Test + void issue_bodyAppendsCmToAUnitlessSize_butPrintsAUnitCarryingSizeAsTyped() { + insertFactory("Size Unit Factory", "IT", "sales@sizeunit.example"); + long ticketId = insertTicket("EM-SIZE-UNIT"); + insertItem(ticketId, "Size Unit Factory", "Unitless Model", "20x30.5", "10", "pcs", 0); + insertItem(ticketId, "Size Unit Factory", "Cm Model", "60x60 cm", "10", "pcs", 1); + insertItem(ticketId, "Size Unit Factory", "Mm Model", "600x600 mm", "10", "pcs", 2); + insertItem(ticketId, "Size Unit Factory", "Thai Model", "60x60 ซม.", "10", "pcs", 3); + long id = DraftSeed.seed(jdbc, ticketId, owner).get(0).id(); + + String body = service.issue(id, null, owner).emailBody(); + + assertThat(body).contains("Size: 20x30.5 cm") + .contains("Size: 60x60 cm") + .doesNotContain("cm cm") + .contains("Size: 600x600 mm") + .doesNotContain("600x600 mm cm") + .contains("Size: 60x60 ซม.") + .doesNotContain("ซม. cm"); + } + /** * REVIEW ROUND 2, S-A: a business-typed note that does NOT match the auto-derived colour/surface * sentence is never skipped — only the exact duplicate is. diff --git a/backend/src/test/java/th/co/glr/hr/importrequest/ImportRequestFactoryResolutionIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/importrequest/ImportRequestFactoryResolutionIntegrationTest.java index ab22a0105..132640742 100644 --- a/backend/src/test/java/th/co/glr/hr/importrequest/ImportRequestFactoryResolutionIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/importrequest/ImportRequestFactoryResolutionIntegrationTest.java @@ -384,6 +384,44 @@ void createDrafts_prefillsCodeSizeQtyUnitAndNote_forCatalogLinkedAndHandTypedLin .isEqualTo("สี Bianco"); } + /** + * Owner ruling 2026-10-30: at IR creation a line linked to a catalogue row WITH dimensions + * stores the catalogue's centimetre size ({@code product_prices.size_cm}, V174: 200x305 mm -> + * "20x30.5") instead of the deal item's typed size; a line linked to a row WITHOUT dimensions, + * and an unlinked hand-typed line, keep the typed size. + */ + @Test + void createDrafts_storesTheCatalogueCmSize_forALinkedLineWithDimensions_elseKeepsTheTypedSize() { + long withDims = insertCatalogProduct("Size Dims Factory", "IT", "DIMS-1", + new BigDecimal("12.50"), "EUR", "per_sqm"); + jdbc.update("UPDATE price_catalog.product_prices SET width_mm = 200, height_mm = 305 " + + "WHERE price_id = :id", Map.of("id", withDims)); + long noDims = insertCatalogProduct("Size NoDims Factory", "IT", "NODIMS-1", + new BigDecimal("12.50"), "EUR", "per_sqm"); + insertFactory("Size Typed Factory", "IT", null); + long ticketId = insertTicket("SIZE-CM-1"); + insertItemWithCatalogAndColor(ticketId, "B", "Dims line", "200x305", "10", "pcs", 0, null, + withDims, null, null); + insertItemWithCatalogAndColor(ticketId, "B", "NoDims line", "60x60 cm", "10", "pcs", 1, null, + noDims, null, null); + insertItemWithCatalogAndColor(ticketId, "B", "Typed line", "30x30", "10", "pcs", 2, + "Size Typed Factory", null, null, null); + + List drafts = service.createDrafts(ticketId, null, owner); + + assertThat(sizeOf(drafts, "Size Dims Factory")).as("catalogue cm size beats typed mm") + .isEqualTo("20x30.5"); + assertThat(sizeOf(drafts, "Size NoDims Factory")).as("no catalogue dims -> typed size") + .isEqualTo("60x60 cm"); + assertThat(sizeOf(drafts, "Size Typed Factory")).as("unlinked hand-typed line") + .isEqualTo("30x30"); + } + + private static String sizeOf(List drafts, String factoryName) { + return drafts.stream().filter(d -> factoryName.equals(d.factoryName())).findFirst() + .orElseThrow().items().get(0).size(); + } + /** Neither colour nor surface known: the note sub-row is omitted entirely, not a blank string. */ @Test void createDrafts_omitsTheNoteEntirely_whenNeitherColourNorSurfaceIsKnown() { diff --git a/backend/src/test/java/th/co/glr/hr/importrequest/ImportRequestPaymentGateIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/importrequest/ImportRequestPaymentGateIntegrationTest.java index 8bd1497aa..729c90304 100644 --- a/backend/src/test/java/th/co/glr/hr/importrequest/ImportRequestPaymentGateIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/importrequest/ImportRequestPaymentGateIntegrationTest.java @@ -156,7 +156,8 @@ void createDrafts_paymentGateRefusesBeforeTheCountryAndLeadTimePrompts() { assertThatThrownBy(() -> service.createDrafts(ticketId, null, owner)) .isInstanceOfSatisfying(ApiException.class, e -> { assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT); - assertThat(e.getMessage()).contains("รับชำระมัดจำ").doesNotContain("ประเทศ"); + assertThat(e.getMessage()).contains("ออกใบขอซื้อ (IR) ได้เฉพาะเมื่อรับชำระมัดจำแล้ว") + .doesNotContain("ใบขอนำเข้า").doesNotContain("ประเทศ"); }); assertThat(service.list(ticketId, owner)).isEmpty(); diff --git a/backend/src/test/java/th/co/glr/hr/importrequest/ImportRequestRendererTest.java b/backend/src/test/java/th/co/glr/hr/importrequest/ImportRequestRendererTest.java index bcb6c6205..7bbfb3809 100644 --- a/backend/src/test/java/th/co/glr/hr/importrequest/ImportRequestRendererTest.java +++ b/backend/src/test/java/th/co/glr/hr/importrequest/ImportRequestRendererTest.java @@ -127,6 +127,37 @@ void everyTableValueLandsInsideItsOwnColumn() throws Exception { assertBetween(placed, "Within 21/5/26", 500.8f, 565.3f); // กำหนดวันที่ต้องการของ } + /** + * Owner ruling 2026-10-30: the IR PDF shows size in centimetres. A size with no unit token + * (what a catalogue-linked line now stores, e.g. "20x30.5") prints with " cm"; a size already + * carrying a unit prints as typed. Tested at the renderer (the seam every PDF path funnels + * through, driven with {@link Line}s directly like the rest of this class). + */ + @Test + void aUnitlessSizeIsPrintedInCentimetres() throws Exception { + ImportRequestFormData data = withLines(List.of( + new Line(1, "Unitless Line", "20x30.5", new BigDecimal("10"), "pcs", null))); + + List placed = placedText(renderer.render(data)); + + assertBetween(placed, "20x30.5 cm", 241.0f, 305.5f); // Size column + } + + @Test + void aSizeThatAlreadyCarriesAUnitIsPrintedAsTyped_neverDoubledOrConverted() throws Exception { + ImportRequestFormData data = withLines(List.of( + new Line(1, "Cm Line", "60x60 cm", new BigDecimal("10"), "pcs", null), + new Line(2, "Mm Line", "600x600 mm", new BigDecimal("10"), "pcs", null), + new Line(3, "Thai Line", "60x60 ซม.", new BigDecimal("10"), "pcs", null))); + + try (PDDocument doc = Loader.loadPDF(renderer.render(data))) { + assertThat(textOfPage(doc, 1)) + .contains("60x60 cm").doesNotContain("cm cm") + .contains("600x600 mm").doesNotContain("mm cm") + .contains("60x60 ซม.").doesNotContain("ซม. cm"); + } + } + /** * The pitch defect, pinned. 13 lines fill all 26 body rows, so any per-row error accumulates to * its maximum by the bottom of the page. Each item's baseline must sit the SAME distance above diff --git a/backend/src/test/java/th/co/glr/hr/importrequest/StoredImportRequestIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/importrequest/StoredImportRequestIntegrationTest.java index ac1ed1399..93810b66b 100644 --- a/backend/src/test/java/th/co/glr/hr/importrequest/StoredImportRequestIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/importrequest/StoredImportRequestIntegrationTest.java @@ -8,6 +8,10 @@ import java.time.LocalDate; import java.util.List; import java.util.Map; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import org.springframework.mock.web.MockHttpSession; +import th.co.glr.hr.auth.SessionContext; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.http.HttpStatus; @@ -178,6 +182,56 @@ void createDrafts_issuesEveryFactoryInOneStep_andMovesTheDealToProcurement() { assertThat(summary.salesStage()).isEqualTo(DealStage.PROCUREMENT); } + // ── owner ruling 2026-10-29 (5c): GET /tickets/{id}/import-requests also says whether a factory is uncovered ── + + /** The flag as the frontend reads it: its own read-only route, so the shared list stays rows-only. */ + private Object hasUncoveredFactoriesOverHttpShape(long forTicket) { + SessionContext sessions = mock(SessionContext.class); + MockHttpSession session = new MockHttpSession(); + when(sessions.requireUser(session)).thenReturn(owner); + ImportRequestController controller = new ImportRequestController(service, sessions); + assertThat(controller.listForTicket(forTicket, session)).containsOnlyKeys("importRequests"); + return controller.coverage(forTicket, session).hasUncoveredFactories(); + } + + @Test + void listResponse_hasUncoveredFactories_trueBeforeAnyIr_falseOnceCreateCoversEveryFactory_trueAgainForANewFactoryLine() { + assertThat(hasUncoveredFactoriesOverHttpShape(ticketId)).isEqualTo(Boolean.TRUE); + + service.createDrafts(ticketId, null, owner); + assertThat(hasUncoveredFactoriesOverHttpShape(ticketId)).isEqualTo(Boolean.FALSE); + + insertFactory("Late Added Flag Factory"); + insertItem(ticketId, "Late Added Flag Factory", "Late line", "60x60 cm", "5", "pcs", 3); + assertThat(hasUncoveredFactoriesOverHttpShape(ticketId)).isEqualTo(Boolean.TRUE); + } + + @Test + void listResponse_hasUncoveredFactories_countsAnUnresolvedFactoryNameAsUncovered_andCreatesNoFactory() { + service.createDrafts(ticketId, null, owner); + insertItem(ticketId, "Never Seen Flag Factory Co", "Mystery line", "60x60 cm", "5", "pcs", 3); + Long factoriesBefore = jdbc.queryForObject("SELECT count(*) FROM price_catalog.factories", Map.of(), Long.class); + + assertThat(hasUncoveredFactoriesOverHttpShape(ticketId)).isEqualTo(Boolean.TRUE); + + assertThat(jdbc.queryForObject("SELECT count(*) FROM price_catalog.factories", Map.of(), Long.class)) + .isEqualTo(factoriesBefore); + } + + @Test + void listResponse_hasUncoveredFactories_aFactoryCoveredOnlyBySupersededRowsIsUncovered_aLiveRowCovers() { + service.createDrafts(ticketId, null, owner); + assertThat(hasUncoveredFactoriesOverHttpShape(ticketId)).isEqualTo(Boolean.FALSE); + + jdbc.update("UPDATE sales.import_request SET status = 'SUPERSEDED' WHERE ticket_id = :t AND factory_id = :f", + Map.of("t", ticketId, "f", leaFactoryId)); + assertThat(hasUncoveredFactoriesOverHttpShape(ticketId)).isEqualTo(Boolean.TRUE); + + jdbc.update("UPDATE sales.import_request SET status = 'ISSUED' WHERE ticket_id = :t AND factory_id = :f", + Map.of("t", ticketId, "f", leaFactoryId)); + assertThat(hasUncoveredFactoriesOverHttpShape(ticketId)).isEqualTo(Boolean.FALSE); + } + @Test void createDrafts_isAllOrNothing_whenOneNewFactoryHasNoLeadTime() { // country PL has no LeadTimeDefaults entry -> null lead time -> cannot be issued. @@ -215,6 +269,36 @@ void issue_mintsTheNumberAndFreezesTheDate_andSetsTheFirstStepToContacted() { e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT)); } + /** + * Walk 4 (owner ruling 2026-10-29: a stock line is never ordered from a factory). A DRAFT form + * left over from before the order was confirmed can name a line that is now covered from stock + * ({@code qty_from_stock >= qty}, written by order confirmation): issuing it would order stock + * the customer already has. ANY covered line refuses the issue (all covered, or one among several - + * a legacy mixed draft); nothing is numbered and the form stays a DRAFT. + */ + @Test + void issue_ofADraftWithAnyLineNowCoveredFromStock_isRefused409_andBurnsNoNumber() { + List drafts = DraftSeed.seed(jdbc, ticketId, owner); + long leaDraft = byFactory(drafts, leaFactoryId).id(); + long padanaDraft = byFactory(drafts, padanaFactoryId).id(); + // LEA's only line fully covered; Padana's FIRST of two lines covered (a mixed draft). + jdbc.update("UPDATE sales.ticket_item SET qty_from_stock = qty WHERE ticket_id = :t AND factory = 'LEA Stored Test'", + Map.of("t", ticketId)); + jdbc.update("UPDATE sales.ticket_item SET qty_from_stock = qty WHERE ticket_id = :t AND model = 'Lithos Nero Nat'", + Map.of("t", ticketId)); + int seqBefore = sequenceValue(); + + for (long draft : new long[] {leaDraft, padanaDraft}) { + assertThatThrownBy(() -> service.issue(draft, null, owner)) + .isInstanceOfSatisfying(ApiException.class, e -> { + assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT); + assertThat(e.getMessage()).contains("สต็อก"); + }); + assertThat(service.get(draft, owner).status()).isEqualTo(ImportRequestStatus.DRAFT); + } + assertThat(sequenceValue()).isEqualTo(seqBefore); + } + @Test void issue_withAnOverride_doesNotBurnASequenceValue() { List drafts = DraftSeed.seed(jdbc, ticketId, owner); diff --git a/backend/src/test/java/th/co/glr/hr/leave/LeaveServiceTest.java b/backend/src/test/java/th/co/glr/hr/leave/LeaveServiceTest.java index 413d5151b..d727c9c06 100644 --- a/backend/src/test/java/th/co/glr/hr/leave/LeaveServiceTest.java +++ b/backend/src/test/java/th/co/glr/hr/leave/LeaveServiceTest.java @@ -208,7 +208,7 @@ void submissionWarnsUnpaidWhenAdvanceNoticeIsTooShort() { assertThat(result.status()).isEqualTo("SUBMITTED"); verify(leaveRepository).recordRuleWarnings(eq(57L), argThat(warnings -> warnings.size() == 1 && warnings.get(0).code() == LeaveRuleCode.ADVANCE_NOTICE - && warnings.get(0).messageTh().contains("อย่างน้อย 7 วัน")), + && warnings.get(0).messageTh().contains("อย่างน้อย 7 วันทำงาน")), org.mockito.ArgumentMatchers.argThat(days -> days.compareTo(new BigDecimal("1.00")) == 0)); } @@ -2142,7 +2142,7 @@ void submitCountsASaturdayAsAWorkingDayWhenTheWorkingDayPredicateSaysSo() { .thenReturn(BigDecimal.ZERO); when(leaveRepository.sumPaidDays(eq(10L), eq("LEAVE_WITHOUT_PAY"), eq(2026), any(Collection.class))) .thenReturn(BigDecimal.ZERO); - when(leaveRepository.workingDayPredicate(eq(10L), eq(request.startDate()), eq(request.endDate()))) + when(leaveRepository.workingDayPredicate(eq(10L), any(), eq(request.endDate()))) .thenReturn(date -> date.getDayOfWeek() != java.time.DayOfWeek.SUNDAY); when(leaveRepository.create(eq(10L), eq(10L), eq(request), any(BigDecimal.class), eq(BigDecimal.ZERO), any(BigDecimal.class), eq(2026), eq(LeaveStatus.SUBMITTED), any(BigDecimal.class), any(BigDecimal.class), @@ -2178,7 +2178,7 @@ void submitExcludesASeededHolidayFromTheDayCountViaThePredicate() { .thenReturn(BigDecimal.ZERO); when(leaveRepository.sumPaidDays(eq(10L), eq("LEAVE_WITHOUT_PAY"), eq(2026), any(Collection.class))) .thenReturn(BigDecimal.ZERO); - when(leaveRepository.workingDayPredicate(eq(10L), eq(request.startDate()), eq(request.endDate()))) + when(leaveRepository.workingDayPredicate(eq(10L), any(), eq(request.endDate()))) .thenReturn(date -> !date.equals(seededHoliday)); when(leaveRepository.create(eq(10L), eq(10L), eq(request), any(BigDecimal.class), eq(BigDecimal.ZERO), any(BigDecimal.class), eq(2026), eq(LeaveStatus.SUBMITTED), any(BigDecimal.class), any(BigDecimal.class), @@ -2217,7 +2217,7 @@ void submitAllowsASubDayMaternityRequestOnADateThePredicateMarksNonWorkingButRej .thenReturn(BigDecimal.ZERO); when(leaveRepository.sumPaidDays(eq(10L), eq("MATERNITY"), eq(2026), any(Collection.class))) .thenReturn(BigDecimal.ZERO); - when(leaveRepository.workingDayPredicate(eq(10L), eq(nonWorkingDate), eq(nonWorkingDate))) + when(leaveRepository.workingDayPredicate(eq(10L), any(), eq(nonWorkingDate))) .thenReturn(alwaysNonWorking); when(leaveRepository.create(eq(10L), eq(10L), eq(maternitySubDay), any(BigDecimal.class), any(BigDecimal.class), any(BigDecimal.class), eq(2026), eq(LeaveStatus.SUBMITTED), any(BigDecimal.class), any(BigDecimal.class), @@ -2235,7 +2235,7 @@ void submitAllowsASubDayMaternityRequestOnADateThePredicateMarksNonWorkingButRej null, "VACATION", nonWorkingDate, nonWorkingDate, "Sub-day vacation", LocalTime.of(8, 30), LocalTime.of(12, 30), null, null, null, null, null, null, null); when(leaveRepository.findLeaveType("VACATION")).thenReturn(Optional.of(vacationType())); - when(leaveRepository.workingDayPredicate(eq(10L), eq(nonWorkingDate), eq(nonWorkingDate))) + when(leaveRepository.workingDayPredicate(eq(10L), any(), eq(nonWorkingDate))) .thenReturn(alwaysNonWorking); assertThatThrownBy(() -> leaveService.submit(vacationSubDay, user("employee", 10L))) @@ -2788,8 +2788,9 @@ void submitAllowsVacationInAOnePersonDepartmentWithoutCheckingSchedules() { LeaveRequestDto result = leaveService.submit(request, user("employee", 10L)); assertThat(result.status()).isEqualTo("SUBMITTED"); + // ...and the §5 advance-notice gate reuses that same predicate instead of building its own. verify(leaveRepository, org.mockito.Mockito.times(1)) - .workingDayPredicate(eq(10L), eq(request.startDate()), eq(request.endDate())); + .workingDayPredicate(eq(10L), any(), any()); } @Test @@ -2894,6 +2895,57 @@ void previewReturnsNoWarningsForACleanRequest() { assertThat(preview.unpaidByRuleDays()).isEqualByComparingTo(BigDecimal.ZERO); } + // §5 advance notice counts the employee's own WORKING days (Ploy, 2026-10-29): weekends and + // holidays between filing and the leave no longer count, a six-day employee's Saturday does. + // FIXED_NOW = Wed 2026-07-01 (day 0, not counted); the leave's first day the employee would + // actually work may itself be the Nth working day. + @org.junit.jupiter.params.ParameterizedTest(name = "{0}..{1}, {2}, holiday {3}, notice {4} -> warns {5}") + @org.junit.jupiter.params.provider.CsvSource({ + // Thu 07-09 is the 6th Mon-Fri working day but 8 calendar days out -- the old count let it pass + "2026-07-09, 2026-07-09, MON_FRI, , 7, true", + "2026-07-10, 2026-07-10, MON_FRI, , 7, false", + // a holiday on Thu 07-02 pushes the 7th working day to Mon 07-13 + "2026-07-10, 2026-07-10, MON_FRI, 2026-07-02, 7, true", + // six-day schedule: Sat 07-04 counts, so Thu 07-09 is already the 7th working day + "2026-07-09, 2026-07-09, MON_SAT, , 7, false", + // Sat-Mon leave, Mon-Fri employee: Sat is not their working day, so the notice runs to Mon (3rd) + "2026-07-04, 2026-07-06, MON_FRI, , 3, false", + // same leave, six-day employee: Sat IS their working day, so the notice stops at Sat (3rd < 4) + "2026-07-04, 2026-07-06, MON_SAT, , 4, true", + // leave starting on a holiday: the notice runs to Fri 07-03, the first day they would work + "2026-07-02, 2026-07-03, MON_FRI, 2026-07-02, 1, false", + // starts TODAY on a holiday (not past day 0): Thu 07-02 is still the 1st working day ahead + "2026-07-01, 2026-07-02, MON_FRI, 2026-07-01, 1, false", + }) + void previewCountsAdvanceNoticeInTheEmployeesWorkingDays( + LocalDate startDate, LocalDate endDate, String schedule, LocalDate holiday, int noticeDays, + boolean expectWarning) { + Predicate scheduleDay = "MON_SAT".equals(schedule) + ? date -> date.getDayOfWeek() != DayOfWeek.SUNDAY + : LeaveDayMath::isWorkingDay; + // Like LeaveRepository#workingDayPredicate, a holiday is only seen inside the range asked for. + when(leaveRepository.workingDayPredicate(eq(10L), any(), any())).thenAnswer(invocation -> { + LocalDate from = invocation.getArgument(1); + LocalDate to = invocation.getArgument(2); + boolean holidayInRange = holiday != null && !holiday.isBefore(from) && !holiday.isAfter(to); + return (Predicate) date -> scheduleDay.test(date) && !(holidayInRange && date.equals(holiday)); + }); + when(leaveRepository.employeeExists(10L)).thenReturn(true); + when(leaveRepository.findLeaveType("VACATION")).thenReturn(Optional.of( + new LeaveTypeDto("VACATION", "ลาพักร้อน", "Vacation leave", new BigDecimal("6.00"), false, + null, noticeDays, 0, null, false, LeaveDayCountBasis.WORKING_DAYS, false, null, null, 0, null, false))); + when(leaveRepository.sumUsedDays(eq(10L), eq("VACATION"), eq(2026), any(Collection.class))) + .thenReturn(BigDecimal.ZERO); + + LeavePreviewDto preview = leaveService.preview( + new LeavePreviewRequest("VACATION", startDate, endDate, null, null, false, false, + LeavePreviewDepth.FULL, null), + user("employee", 10L)); + + List codes = preview.ruleWarnings().stream().map(LeaveRuleWarningDto::code).toList(); + assertThat(codes.contains("ADVANCE_NOTICE")).as("ADVANCE_NOTICE in %s", codes).isEqualTo(expectWarning); + } + @Test void previewDoesNotLeakAWarningWhenABlockingGateFires() { // Wrong-way-round proof: a BLOCK code (§5.3.4 post-resignation) must report `blocking` diff --git a/backend/src/test/java/th/co/glr/hr/orderconfirmation/OrderConfirmationIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/orderconfirmation/OrderConfirmationIntegrationTest.java index 76d4f4b66..2dc6f0aae 100644 --- a/backend/src/test/java/th/co/glr/hr/orderconfirmation/OrderConfirmationIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/orderconfirmation/OrderConfirmationIntegrationTest.java @@ -407,8 +407,9 @@ void createDraft_picksTheNewerPricingRequestsAcceptedQuotation_notTheOneWithTheH assertThat(pr1Accepted.quotationRevisionNo()).isEqualTo(2); // the HIGHER revision_no assertThat(pricingRequestService.get(pr1Id, salesActor).summary().status()) .isEqualTo(PricingRequestStatus.QUOTATION_ACCEPTED); - // confirmOrder gates on QUOTATION_ACCEPTED, so it must run AFTER the outcome above. - orderConfirmation.confirmOrder(pr1Id, new OrderConfirmationRequests.ConfirmOrderRequest(null), salesActor); + // confirmOrder gates on QUOTATION_ACCEPTED, so it runs AFTER the outcome above - but only after PR#2 + // below is driven too: once PR#1's order is confirmed (walk 4, W4-04) the deal can start no new + // pricing round, so a second request can no longer be submitted. // PR#2 (created SECOND, raw factory price 250.00 — deliberately different so its line // item is distinguishable from PR#1's): its own first-ever quotation, revision 1 — a @@ -440,6 +441,7 @@ void createDraft_picksTheNewerPricingRequestsAcceptedQuotation_notTheOneWithTheH Map.of("id", pr2Id)); assertThat(pricingRequestService.get(pr2Id, salesActor).summary().status()) .isEqualTo(PricingRequestStatus.QUOTATION_ACCEPTED); + orderConfirmation.confirmOrder(pr1Id, new OrderConfirmationRequests.ConfirmOrderRequest(null), salesActor); orderConfirmation.confirmOrder(pr2Id, new OrderConfirmationRequests.ConfirmOrderRequest(null), salesActor); CustomerQuotationDto pr2Accepted = quotationRepository.findByPricingRequest(pr2Id).stream() .filter(q -> QuotationStatus.ACCEPTED.equals(q.docStatus())).findFirst().orElseThrow(); diff --git a/backend/src/test/java/th/co/glr/hr/pricingstock/AbstractStockLineIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/pricingstock/AbstractStockLineIntegrationTest.java index dfe92d55f..6a2832fcf 100644 --- a/backend/src/test/java/th/co/glr/hr/pricingstock/AbstractStockLineIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/pricingstock/AbstractStockLineIntegrationTest.java @@ -93,6 +93,7 @@ public abstract class AbstractStockLineIntegrationTest extends AbstractPostgresI protected PricingDecisionService decisionService; protected PricingCostingRepository costingRepository; protected NotificationRepository notifications; + protected TicketService ticketService; protected long salesRepId; protected long otherSalesRepId; @@ -137,7 +138,7 @@ protected void wireStockLineFixture() { costingRepository = new PricingCostingRepository(jdbc); decisionService = new PricingDecisionService(decisionRepository, pricingRequests, costingRepository, tickets, fxRates, notifications, landedCost, formulaEngine); - TicketService ticketService = new TicketService(tickets, notifications, objectMapper, customers, + ticketService = new TicketService(tickets, notifications, objectMapper, customers, new QuotationRenderer(), pricingRequestService, new EmployeeAuthRepository(jdbc), new th.co.glr.hr.customer.ContactRepository(jdbc), new th.co.glr.hr.customer.ProjectRepository(jdbc), new th.co.glr.hr.dealquotation.DealQuotationRepository(jdbc, new th.co.glr.hr.catalog.CatalogRepository(jdbc))); diff --git a/backend/src/test/java/th/co/glr/hr/pricingstock/StockLineBackfillMigrationIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/pricingstock/StockLineBackfillMigrationIntegrationTest.java new file mode 100644 index 000000000..2548d325e --- /dev/null +++ b/backend/src/test/java/th/co/glr/hr/pricingstock/StockLineBackfillMigrationIntegrationTest.java @@ -0,0 +1,254 @@ +package th.co.glr.hr.pricingstock; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.InputStream; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import th.co.glr.hr.importrequest.ImportRequestQueryRepository; +import th.co.glr.hr.importrequest.ImportRequestRepository; +import th.co.glr.hr.pricingrequest.PricingRequestRequests.PricingRequestItemRequest; +import th.co.glr.hr.pricingrequest.PricingRequestStatus; + +/** + * V201 AS SHIPPED, re-executed verbatim from the classpath over hand-shaped rows: deals whose order was + * confirmed BEFORE the walk 4 writer shipped have stock lines with qty_from_stock = 0 and sit on the import + * track. The migration covers those lines and routes the deals that are now all-stock and deposit-settled. + * Lines on an ISSUED import request are never touched (already ordered from a factory). + */ +class StockLineBackfillMigrationIntegrationTest extends AbstractStockLineIntegrationTest { + + private static final String V201 = "db/migration/V201__stock_line_backfill.sql"; + private int seq; + + @Test + void confirmedStockLines_getFullCover_unconfirmedAndImportLinesDoNot() { + long settledAllStock = deal("DEPOSIT_PAID", "REQUIRED", "DEPOSIT_RECEIVED", null); + long stockItem = confirmedStockLine(settledAllStock, "S1", 10, true); + long mixed = deal("DEPOSIT_PAID", "REQUIRED", "DEPOSIT_RECEIVED", null); + long mixedStock = confirmedStockLine(mixed, "S2", 4, true); + long mixedImport = item(mixed, "I2", 8, 0); + long unconfirmed = deal("CUSTOMER_CONFIRMED", "REQUIRED", "ORDER_RECEIVED", null); + long unconfirmedStock = confirmedStockLine(unconfirmed, "S3", 6, false); + + runV201(); + + assertThat(qcfs(stockItem)).isEqualByComparingTo("10"); + assertThat(qcfs(mixedStock)).isEqualByComparingTo("4"); + assertThat(qcfs(mixedImport)).as("an import line is never covered").isEqualByComparingTo("0"); + assertThat(qcfs(unconfirmedStock)).as("its order was never confirmed").isEqualByComparingTo("0"); + } + + @Test + void aStockLineOnAnIssuedImportRequest_isNotTouched() { + long deal = deal("DEPOSIT_PAID", "REQUIRED", "PROCUREMENT", "IR_ISSUED"); + long ordered = confirmedStockLine(deal, "S4", 10, true); + issuedImportRequestFor(deal, ordered); + + runV201(); + + assertThat(qcfs(ordered)).isEqualByComparingTo("0"); + assertThat(fulfilment(deal)).isEqualTo("IR_ISSUED"); + assertThat(stage(deal)).isEqualTo("PROCUREMENT"); + } + + @Test + void anAllStockDealWhoseDepositIsSettled_isRouted_otherDealsAreNot() { + long paid = deal("DEPOSIT_PAID", "REQUIRED", "DEPOSIT_RECEIVED", null); + confirmedStockLine(paid, "S5", 10, true); + long noDeposit = deal("CUSTOMER_CONFIRMED", "NOT_REQUIRED", "ORDER_RECEIVED", null); + confirmedStockLine(noDeposit, "S6", 10, true); + long unpaid = deal("CUSTOMER_CONFIRMED", "REQUIRED", "ORDER_RECEIVED", null); + confirmedStockLine(unpaid, "S7", 10, true); + long mixed = deal("DEPOSIT_PAID", "REQUIRED", "DEPOSIT_RECEIVED", null); + confirmedStockLine(mixed, "S8", 4, true); + item(mixed, "I8", 8, 0); + long eventsBefore = stageEvents(paid); + + runV201(); + + for (long routed : new long[] {paid, noDeposit}) { + assertThat(fulfilment(routed)).isEqualTo("FROM_STOCK"); + assertThat(stage(routed)).isEqualTo("DELIVERY_SCHEDULING"); + } + assertThat(stageEvents(paid)).as("the stage move leaves a STAGE_CHANGED row").isEqualTo(eventsBefore + 1); + assertThat(fulfilment(unpaid)).isNull(); + assertThat(stage(unpaid)).isEqualTo("ORDER_RECEIVED"); + assertThat(fulfilment(mixed)).isNull(); + assertThat(stage(mixed)).isEqualTo("DEPOSIT_RECEIVED"); + } + + @Test + void runningItTwice_changesNothingTheSecondTime() { + long paid = deal("DEPOSIT_PAID", "REQUIRED", "DEPOSIT_RECEIVED", null); + confirmedStockLine(paid, "S9", 10, true); + runV201(); + long events = stageEvents(paid); + + runV201(); + + assertThat(stageEvents(paid)).isEqualTo(events); + assertThat(stage(paid)).isEqualTo("DELIVERY_SCHEDULING"); + } + + // Guards, each pinned on its own: the rows below are shaped so that ONLY the guard under test stops V201. + + @Test + void aLostDeal_isNotRouted_evenWhenItsLinesAreAlreadyCovered() { + long lost = deal("DEPOSIT_PAID", "REQUIRED", "DEPOSIT_RECEIVED", null); + jdbc.update("UPDATE sales.ticket SET lifecycle = 'CLOSED_LOST', lost_reason = 'PRICE', lost_at = now() WHERE ticket_id = :t", + Map.of("t", lost)); + coveredStockLine(lost, "L1", 10); + + runV201(); + + assertThat(fulfilment(lost)).isNull(); + assertThat(stage(lost)).isEqualTo("DEPOSIT_RECEIVED"); + assertThat(stageEvents(lost)).isZero(); + } + + @Test + void aDealPastStep13WithNoFulfilment_isNotMovedBack_andGetsNoEvent() { + for (String late : new String[] {"DELIVERED", "CLOSED_PAID"}) { + long deal = deal("FULLY_PAID", "REQUIRED", late, null); + coveredStockLine(deal, "P-" + late, 10); + long eventsBefore = stageEvents(deal); + + runV201(); + + assertThat(stage(deal)).as(late + " is not pulled back to DELIVERY_SCHEDULING").isEqualTo(late); + assertThat(stageEvents(deal)).as("no STAGE_CHANGED for " + late).isEqualTo(eventsBefore); + } + } + + @Test + void aLineAConfirmedRequestAlsoMapsAsAnImportLine_keepsItsQtyFromStock() { + long deal = deal("DEPOSIT_PAID", "REQUIRED", "DEPOSIT_RECEIVED", null); + long ambiguous = confirmedStockLine(deal, "A1", 10, true); + // a second confirmed request maps the SAME ticket line as an IMPORT line (stock_source NULL) + long pcr = pricingRequests.create(deal, pricingRequests.nextRequestCode(), + requestOf(importLine("A1", catalogProductFactoryA, FACTORY_A, 10)), salesRepId); + jdbc.update("UPDATE sales.pricing_request_item SET source_ticket_item_id = :ti WHERE pricing_request_id = :p", + Map.of("ti", ambiguous, "p", pcr)); + jdbc.update("UPDATE sales.pricing_request SET status = 'QUOTATION_ACCEPTED', order_confirmed_at = now() WHERE pricing_request_id = :p", + Map.of("p", pcr)); + + runV201(); + + assertThat(qcfs(ambiguous)).as("ambiguous data is left alone").isEqualByComparingTo("0"); + } + + // Closed deals must not be rewritten: raising qty_from_stock would retroactively hand pending commission the x2/x3 share. + @Test + void aClosedOrLostDeal_hasItsStockLinesUntouched() { + for (String lifecycle : new String[] {"CLOSED_LOST", "CANCELLED", "COMPLETED"}) { + long deal = deal("FULLY_PAID", "REQUIRED", "CLOSED_PAID", null); + if ("CLOSED_LOST".equals(lifecycle)) { + jdbc.update("UPDATE sales.ticket SET lifecycle = :l, lost_reason = 'PRICE', lost_at = now() WHERE ticket_id = :t", + Map.of("l", lifecycle, "t", deal)); + } else { + jdbc.update("UPDATE sales.ticket SET lifecycle = :l WHERE ticket_id = :t", Map.of("l", lifecycle, "t", deal)); + } + long stockItem = confirmedStockLine(deal, "C-" + lifecycle, 10, true); + + runV201(); + + assertThat(qcfs(stockItem)).as(lifecycle + " deal untouched").isEqualByComparingTo("0"); + } + } + + // ── fixtures ───────────────────────────────────────────────────────────────────────── + + private void runV201() { + String text; + try (InputStream in = getClass().getClassLoader().getResourceAsStream(V201)) { + assertThat(in).as(V201 + " on the classpath").isNotNull(); + text = new String(in.readAllBytes(), StandardCharsets.UTF_8); + } catch (java.io.IOException e) { + throw new IllegalStateException(e); + } + jdbc.getJdbcOperations().execute(text); + } + + private long deal(String paymentStatus, String depositPolicy, String stage, String fulfilment) { + return jdbc.queryForObject(""" + INSERT INTO sales.ticket (code, title, created_by, customer_name, status, payment_status, + deposit_policy, sales_stage, fulfillment_status) + VALUES (:code, 'backfill', :by, 'บริษัท ทดสอบ', 'quotation_issued', :ps, :policy, :stage, :fs) + RETURNING ticket_id + """, new MapSqlParameterSource().addValue("code", "BF-" + (++seq)).addValue("by", salesRepId) + .addValue("ps", paymentStatus).addValue("policy", depositPolicy).addValue("stage", stage) + .addValue("fs", fulfilment), Long.class); + } + + private long item(long ticket, String model, int qty, int qtyFromStock) { + return jdbc.queryForObject(""" + INSERT INTO sales.ticket_item (ticket_id, brand, model, size, qty, qty_from_stock, unit, sort_order, factory) + VALUES (:t, 'SCG', :m, '60x60', :q, :s, 'pcs', :sort, :f) RETURNING item_id + """, new MapSqlParameterSource().addValue("t", ticket).addValue("m", model).addValue("q", qty) + .addValue("s", qtyFromStock).addValue("sort", ++seq).addValue("f", FACTORY_A), Long.class); + } + + /** A ticket line with qty_from_stock 0 (pre-writer) fed by a stock line of an accepted PCR, order confirmed or not. */ + private long confirmedStockLine(long ticket, String model, int qty, boolean orderConfirmed) { + long ticketItem = item(ticket, model, qty, 0); + PricingRequestItemRequest line = stockLine(model, IN_THAILAND, qty); + long pcr = pricingRequests.create(ticket, pricingRequests.nextRequestCode(), requestOf(line), salesRepId); + jdbc.update("UPDATE sales.pricing_request_item SET source_ticket_item_id = :ti WHERE pricing_request_id = :p", + Map.of("ti", ticketItem, "p", pcr)); + jdbc.update(""" + UPDATE sales.pricing_request SET status = :s, + order_confirmed_at = CASE WHEN :c THEN now() ELSE NULL END + WHERE pricing_request_id = :p + """, new MapSqlParameterSource().addValue("s", PricingRequestStatus.QUOTATION_ACCEPTED) + .addValue("c", orderConfirmed).addValue("p", pcr)); + return ticketItem; + } + + /** A confirmed stock line whose ticket line is ALREADY covered (qty_from_stock = qty): step 1 has nothing to do. */ + private long coveredStockLine(long ticket, String model, int qty) { + long ticketItem = confirmedStockLine(ticket, model, qty, true); + jdbc.update("UPDATE sales.ticket_item SET qty_from_stock = qty WHERE item_id = :i", Map.of("i", ticketItem)); + return ticketItem; + } + + private void issuedImportRequestFor(long ticket, long ticketItem) { + ImportRequestRepository stored = new ImportRequestRepository(jdbc); + var snapshot = new ImportRequestQueryRepository(jdbc).loadTicketSnapshot(ticket).orElseThrow(); + long id = stored.insertDraft(ticket, factoryId(FACTORY_A), FACTORY_A, "SCG", 1, snapshot, 30, 45, null, null, + salesRepId, "x"); + jdbc.update(""" + INSERT INTO sales.import_request_item (import_request_id, ticket_item_id, seq, code, size, qty, unit, brand, model) + SELECT :id, ti.item_id, 1, ti.model, ti.size, ti.qty, ti.unit, ti.brand, ti.model + FROM sales.ticket_item ti WHERE ti.item_id = :ti + """, Map.of("id", id, "ti", ticketItem)); + jdbc.update(""" + UPDATE sales.import_request SET status = 'ISSUED', doc_number = 'IRMIG001', issue_date = current_date, + import_step = 'CONTACTED', import_step_at = current_date, first_issued_date = current_date + WHERE import_request_id = :id + """, Map.of("id", id)); + } + + private BigDecimal qcfs(long itemId) { + return jdbc.queryForObject("SELECT qty_from_stock FROM sales.ticket_item WHERE item_id = :i", + Map.of("i", itemId), BigDecimal.class); + } + + private String fulfilment(long ticket) { + return jdbc.queryForObject("SELECT fulfillment_status FROM sales.ticket WHERE ticket_id = :t", + Map.of("t", ticket), String.class); + } + + private String stage(long ticket) { + return jdbc.queryForObject("SELECT sales_stage FROM sales.ticket WHERE ticket_id = :t", + Map.of("t", ticket), String.class); + } + + private long stageEvents(long ticket) { + return jdbc.queryForObject("SELECT COUNT(*) FROM sales.ticket_event WHERE ticket_id = :t AND kind = 'STAGE_CHANGED'", + Map.of("t", ticket), Long.class); + } +} diff --git a/backend/src/test/java/th/co/glr/hr/pricingstock/StockLineFulfilmentIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/pricingstock/StockLineFulfilmentIntegrationTest.java new file mode 100644 index 000000000..b18cdb694 --- /dev/null +++ b/backend/src/test/java/th/co/glr/hr/pricingstock/StockLineFulfilmentIntegrationTest.java @@ -0,0 +1,465 @@ +package th.co.glr.hr.pricingstock; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import th.co.glr.hr.common.ApiException; +import th.co.glr.hr.commission.CommissionRepository; +import th.co.glr.hr.commission.PendingCommissionItemDto; +import th.co.glr.hr.customer.CustomerRepository; +import th.co.glr.hr.customerquotation.CustomerQuotationRepository; +import th.co.glr.hr.dealquotation.DealQuotationRepository; +import th.co.glr.hr.catalog.CatalogRepository; +import th.co.glr.hr.deposit.DepositNoticePdfRenderer; +import th.co.glr.hr.deposit.DepositNoticeRepository; +import th.co.glr.hr.deposit.DepositNoticeService; +import th.co.glr.hr.factory.FactoryConfigRepository; +import th.co.glr.hr.importrequest.ImportRequestDtos.ImportRequestDto; +import th.co.glr.hr.importrequest.ImportRequestQueryRepository; +import th.co.glr.hr.importrequest.ImportRequestRenderer; +import th.co.glr.hr.importrequest.ImportRequestRepository; +import th.co.glr.hr.importrequest.ImportRequestService; +import th.co.glr.hr.orderconfirmation.OrderConfirmationRequests.ConfirmOrderRequest; +import th.co.glr.hr.orderconfirmation.OrderConfirmationService; +import th.co.glr.hr.pricingrequest.PricingRequestRecipient; +import th.co.glr.hr.pricingrequest.PricingRequestRequests.CustomerChangeRevisionRequest; +import th.co.glr.hr.pricingrequest.PricingRequestRequests.PricingRequestItemRequest; +import th.co.glr.hr.pricingrequest.PricingRequestStatus; +import th.co.glr.hr.ticket.DealStage; +import th.co.glr.hr.ticket.FulfilmentStatus; +import th.co.glr.hr.ticket.RecordDeliveryRequest; +import th.co.glr.hr.ticket.RecordPaymentRequest; +import th.co.glr.hr.ticket.TicketSummaryDto; + +/** + * Walk 4 (owner rulings, Ploy 2026-10-29): there is NO reserve-from-stock feature. A stock line + * (pricing_request_item.stock_source = IN_THAILAND) ends when the CEO confirms the price and then + * waits for delivery; it is never ordered from a factory. Real services, real repositories, real + * Postgres, the real {@code OrderConfirmationService#confirmOrder} - no {@code reserveStock} call + * anywhere in this class. + * + *

The ONE writer under test: {@code confirmOrder} must write {@code ticket_item.qty_from_stock} + * from the confirmed pricing request's {@code stock_source}. Every reader below (IR candidates, + * the "all covered from stock" 409, STOCK delivery, the commission x2/x3 share) already reads that + * column and is expected to work unchanged once it is written. + * + *

Fixture note: the abstract fixture's seed ticket line is deleted so "every line of the deal is + * stock" is decided by the lines this class creates (a stray uncovered seed line would make an + * all-stock deal look mixed). A stock line's ticket_item carries FACTORY_B on purpose: if the writer + * is missing, that line shows up as an IR candidate for factory B and the IR assertions go red. + */ +class StockLineFulfilmentIntegrationTest extends AbstractStockLineIntegrationTest { + + private OrderConfirmationService orderConfirmation; + private ImportRequestService importRequests; + private CommissionRepository commissionRepository; + private int quotationSeq; + + @BeforeEach + void wireOrderConfirmationAndImportRequests() { + CustomerRepository customers = new CustomerRepository(jdbc); + CustomerQuotationRepository legacyQuotations = new CustomerQuotationRepository(jdbc); + DepositNoticeService depositNotices = new DepositNoticeService(new DepositNoticeRepository(jdbc), tickets, + notifications, new DepositNoticePdfRenderer(), customers, legacyQuotations); + orderConfirmation = new OrderConfirmationService( + pricingRequests, tickets, ticketService, legacyQuotations, depositNotices, notifications); + // Same wiring the app has (setter-injected): without it the accepted quotation's deposit + // percent is never read and the 0% path could not be exercised. + orderConfirmation.wireDealQuotationRepository(new DealQuotationRepository(jdbc, new CatalogRepository(jdbc))); + importRequests = new ImportRequestService(new ImportRequestQueryRepository(jdbc), new ImportRequestRenderer(), + new ImportRequestRepository(jdbc), new FactoryConfigRepository(jdbc), tickets, ticketService); + commissionRepository = new CommissionRepository(jdbc); + jdbc.update("DELETE FROM sales.ticket_item WHERE ticket_id = :t", Map.of("t", ticketId)); + } + + // ── B1: the one writer ─────────────────────────────────────────────────────────────── + + @Test + void b1_confirmOrder_writesQtyFromStock_forTheStockLineOnly_andTheCommissionReaderSeesIt() { + long pcr = acceptedPcr(Set.of(), + importLine("Imp A", catalogProductFactoryA, FACTORY_A, 20), + stockLine("Stock A", IN_THAILAND, 10)); + acceptQuotation(pcr, 30); + + orderConfirmation.confirmOrder(pcr, confirmRequest(), salesActor); + + assertThat(qtyFromStock("Stock A")).isEqualByComparingTo("10"); + assertThat(qtyFromStock("Imp A")).isEqualByComparingTo("0"); + List seenByCommission = commissionRepository.findPendingItems(ticketId); + assertThat(seenByCommission).filteredOn(i -> i.description().contains("Stock A")) + .singleElement().satisfies(i -> assertThat(i.qtyFromStock()).isEqualByComparingTo("10")); + assertThat(seenByCommission).filteredOn(i -> i.description().contains("Imp A")) + .singleElement().satisfies(i -> assertThat(i.qtyFromStock()).isEqualByComparingTo("0")); + } + + @Test + void b1_aStockLineThatArrivesAsANewTicketLine_isInsertedFullyStockCovered() { + // "Stock New" has no source ticket item (a line a revision added): reconcile INSERTS its + // ticket_item, and that insert must carry the stock cover too. + long pcr = acceptedPcr(Set.of("Stock New"), + importLine("Imp A", catalogProductFactoryA, FACTORY_A, 20), + stockLine("Stock New", IN_THAILAND, 6)); + acceptQuotation(pcr, 30); + + orderConfirmation.confirmOrder(pcr, confirmRequest(), salesActor); + + assertThat(qtyFromStock("Stock New")).isEqualByComparingTo("6"); + assertThat(qtyFromStock("Imp A")).isEqualByComparingTo("0"); + } + + // ── B2 / B3 / B5: the import side and the auto-advance ─────────────────────────────── + + @Test + void b2_mixedDeal_afterTheDeposit_createDraftsOrdersOnlyTheImportLinesFactory() { + long pcr = acceptedPcr(Set.of(), + importLine("Imp A", catalogProductFactoryA, FACTORY_A, 20), + stockLine("Stock A", IN_THAILAND, 10)); + acceptQuotation(pcr, 30); + orderConfirmation.confirmOrder(pcr, confirmRequest(), salesActor); + recordDeposit(); + + List irs = importRequests.createDrafts(ticketId, null, salesActor); + + assertThat(irs).extracting(ImportRequestDto::factoryName) + .as("the stock line's factory (B) must never get an import request") + .containsExactly(FACTORY_A); + assertThat(irs.get(0).items()).hasSize(1); + } + + @Test + void b3_stockOnlyDeal_depositRequired_staysAtOrderReceivedUntilTheDeposit_thenGoesToDeliveryScheduling() { + long pcr = acceptedPcr(Set.of(), stockLine("Stock A", IN_THAILAND, 10)); + acceptQuotation(pcr, 30); + + orderConfirmation.confirmOrder(pcr, confirmRequest(), salesActor); + + assertThat(summary().salesStage()).isEqualTo(DealStage.ORDER_RECEIVED); + assertThat(summary().fulfillmentStatus()).isNull(); + + recordDeposit(); + + assertThat(summary().salesStage()).isEqualTo(DealStage.DELIVERY_SCHEDULING); + assertThat(summary().fulfillmentStatus()).isEqualTo(FulfilmentStatus.FROM_STOCK); + } + + @Test + void b3_stockOnlyDeal_afterTheDeposit_createDraftsIsRefused409_coveredFromStock() { + long pcr = acceptedPcr(Set.of(), stockLine("Stock A", IN_THAILAND, 10)); + acceptQuotation(pcr, 30); + orderConfirmation.confirmOrder(pcr, confirmRequest(), salesActor); + recordDeposit(); + + assertThatThrownBy(() -> importRequests.createDrafts(ticketId, null, salesActor)) + .isInstanceOfSatisfying(ApiException.class, e -> { + assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT); + assertThat(e.getMessage()).contains("ครอบคลุมจากสต็อกทั้งหมด"); + }); + assertThat(importRequests.list(ticketId, salesActor)).isEmpty(); + } + + @Test + void b4_stockOnlyDeal_zeroPercentDeposit_goesToDeliverySchedulingAtOrderConfirmation() { + long pcr = acceptedPcr(Set.of(), stockLine("Stock A", IN_THAILAND, 10)); + acceptQuotation(pcr, 0); + + orderConfirmation.confirmOrder(pcr, confirmRequest(), salesActor); + + TicketSummaryDto s = summary(); + assertThat(s.depositPolicy()).as("fixture: the 0% quotation put the deal on the no-deposit path") + .isEqualTo("NOT_REQUIRED"); + assertThat(s.salesStage()).isEqualTo(DealStage.DELIVERY_SCHEDULING); + assertThat(s.fulfillmentStatus()).isEqualTo(FulfilmentStatus.FROM_STOCK); + } + + @Test + void b4_mixedDeal_zeroPercentDeposit_doesNotJumpToDeliveryScheduling() { + long pcr = acceptedPcr(Set.of(), + importLine("Imp A", catalogProductFactoryA, FACTORY_A, 20), + stockLine("Stock A", IN_THAILAND, 10)); + acceptQuotation(pcr, 0); + + orderConfirmation.confirmOrder(pcr, confirmRequest(), salesActor); + + assertThat(summary().salesStage()).isEqualTo(DealStage.ORDER_RECEIVED); + assertThat(summary().fulfillmentStatus()).isNull(); + } + + @Test + void b5_mixedDeal_afterTheDeposit_isDepositReceivedOnly_noJump() { + long pcr = acceptedPcr(Set.of(), + importLine("Imp A", catalogProductFactoryA, FACTORY_A, 20), + stockLine("Stock A", IN_THAILAND, 10)); + acceptQuotation(pcr, 30); + orderConfirmation.confirmOrder(pcr, confirmRequest(), salesActor); + + recordDeposit(); + + assertThat(summary().salesStage()).isEqualTo(DealStage.DEPOSIT_RECEIVED); + assertThat(summary().fulfillmentStatus()).isNull(); + } + + // ── B6: STOCK delivery without any reserve ─────────────────────────────────────────── + + @Test + void b6_stockOnlyDeal_atStep13_aStockDeliveryOfTheFullQty_succeedsWithoutReserveStock() { + long pcr = acceptedPcr(Set.of(), stockLine("Stock A", IN_THAILAND, 10)); + acceptQuotation(pcr, 30); + orderConfirmation.confirmOrder(pcr, confirmRequest(), salesActor); + recordDeposit(); + long itemId = ticketItemId("Stock A"); + + var after = ticketService.recordPartialDelivery(ticketId, new RecordDeliveryRequest("STOCK", null, + List.of(new RecordDeliveryRequest.Line(itemId, new BigDecimal("10"))), null), salesActor); + + assertThat(after.items()).singleElement() + .satisfies(i -> assertThat(i.qtyDelivered()).isEqualByComparingTo("10")); + assertThat(after.summary().fulfillmentStatus()).isEqualTo(FulfilmentStatus.FULLY_DELIVERED); + } + + // Review fix 1: the writer sets qty_from_stock at confirmation, so STOCK delivery must wait for the deposit + // (paid, or a no-deposit policy) - otherwise a REQUIRED stock-only deal could deliver and close with nothing paid. + @Test + void b6_requiredStockOnlyDeal_beforeTheDeposit_stockDeliveryIsNotAdvertised_andRefused409() { + long pcr = acceptedPcr(Set.of(), stockLine("Stock A", IN_THAILAND, 10)); + acceptQuotation(pcr, 30); + orderConfirmation.confirmOrder(pcr, confirmRequest(), salesActor); + long itemId = ticketItemId("Stock A"); + RecordDeliveryRequest full = new RecordDeliveryRequest("STOCK", null, + List.of(new RecordDeliveryRequest.Line(itemId, new BigDecimal("10"))), null); + + assertThat(actionCodes()).doesNotContain("RECORD_PARTIAL_DELIVERY", "COMPLETE_DELIVERY"); + assertThatThrownBy(() -> ticketService.recordPartialDelivery(ticketId, full, salesActor)) + .isInstanceOfSatisfying(ApiException.class, e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT)); + assertThatThrownBy(() -> ticketService.completeDelivery(ticketId, + new th.co.glr.hr.ticket.CompleteDeliveryRequest(null, null), salesActor)) + .isInstanceOfSatisfying(ApiException.class, e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT)); + assertThat(summary().fulfillmentStatus()).isNull(); + + recordDeposit(); + + assertThat(actionCodes()).contains("RECORD_PARTIAL_DELIVERY"); + } + + @Test + void b6_mixedDeal_beforeTheDeposit_aPartialStockDeliveryIsRefused409_andNothingIsRecorded() { + long pcr = acceptedPcr(Set.of(), + importLine("Imp A", catalogProductFactoryA, FACTORY_A, 20), + stockLine("Stock A", IN_THAILAND, 10)); + acceptQuotation(pcr, 30); + orderConfirmation.confirmOrder(pcr, confirmRequest(), salesActor); + + assertThatThrownBy(() -> ticketService.recordPartialDelivery(ticketId, new RecordDeliveryRequest("STOCK", null, + List.of(new RecordDeliveryRequest.Line(ticketItemId("Stock A"), new BigDecimal("4"))), null), salesActor)) + .isInstanceOfSatisfying(ApiException.class, e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT)); + assertThat(summary().fulfillmentStatus()).isNull(); + } + + // Review fix 2: waiving the deposit on an all-stock deal at CUSTOMER_CONFIRMED leaves nothing to wait for. + @Test + void b4_stockOnlyDeal_atCustomerConfirmed_waivingTheDeposit_goesToDeliveryScheduling() { + long pcr = acceptedPcr(Set.of(), stockLine("Stock A", IN_THAILAND, 10)); + acceptQuotation(pcr, 30); + orderConfirmation.confirmOrder(pcr, confirmRequest(), salesActor); + assertThat(summary().salesStage()).isEqualTo(DealStage.ORDER_RECEIVED); + + ticketService.waiveDeposit(ticketId, "WAIVED", "ลูกค้าเครดิต", salesActor); + + assertThat(summary().salesStage()).isEqualTo(DealStage.DELIVERY_SCHEDULING); + assertThat(summary().fulfillmentStatus()).isEqualTo(FulfilmentStatus.FROM_STOCK); + } + + @Test + void b4_mixedDeal_waivingTheDeposit_doesNotJumpToDeliveryScheduling() { + long pcr = acceptedPcr(Set.of(), + importLine("Imp A", catalogProductFactoryA, FACTORY_A, 20), + stockLine("Stock A", IN_THAILAND, 10)); + acceptQuotation(pcr, 30); + orderConfirmation.confirmOrder(pcr, confirmRequest(), salesActor); + + ticketService.waiveDeposit(ticketId, "WAIVED", "ลูกค้าเครดิต", salesActor); + + assertThat(summary().salesStage()).isEqualTo(DealStage.ORDER_RECEIVED); + assertThat(summary().fulfillmentStatus()).isNull(); + } + + // Review fix 3: no new request on a confirmed deal either (create, like submit and revise). + @Test + void b12_onceTheOrderIsConfirmed_creatingANewPricingRequestIsRefused409() { + long accepted = acceptedPcr(Set.of(), stockLine("Stock A", IN_THAILAND, 10)); + acceptQuotation(accepted, 30); + // Control: before the order is confirmed the same create is legal. + pricingRequestService.createDraft(ticketId, requestOf(importLine("New 1", catalogProductFactoryA, FACTORY_A, 5)), salesActor); + + orderConfirmation.confirmOrder(accepted, confirmRequest(), salesActor); + + assertThatThrownBy(() -> pricingRequestService.createDraft(ticketId, + requestOf(importLine("New 2", catalogProductFactoryA, FACTORY_A, 5)), salesActor)) + .isInstanceOfSatisfying(ApiException.class, e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT)); + } + + // Review fix 3: the writer touches ONLY stock lines. An import line with a legacy qty_from_stock (a reserve made + // before this feature, via reserveStock) keeps it through order confirmation. + @Test + void b1_aLegacyReserveOnAnImportLine_survivesOrderConfirmation() { + long pcr = acceptedPcr(Set.of(), + importLine("Imp A", catalogProductFactoryA, FACTORY_A, 20), + stockLine("Stock A", IN_THAILAND, 10)); + acceptQuotation(pcr, 30); + jdbc.update("UPDATE sales.ticket_item SET qty = 20, qty_from_stock = 5 WHERE ticket_id = :t AND model = 'Imp A'", + Map.of("t", ticketId)); + + orderConfirmation.confirmOrder(pcr, confirmRequest(), salesActor); + + assertThat(qtyFromStock("Imp A")).as("the legacy reserve is kept").isEqualByComparingTo("5"); + assertThat(qtyFromStock("Stock A")).isEqualByComparingTo("10"); + } + + // Review fix 4: waiveDeposit is refused once a deposit notice is out (Rule 4), so a waiver can never reach + // an all-stock deal sitting at DEPOSIT_NOTICE_ISSUED - there is no route to add there. Pinned, not changed. + @Test + void b4_waivingTheDeposit_afterTheNoticeIsIssued_isRefused409_andTheDealStaysPut() { + long pcr = acceptedPcr(Set.of(), stockLine("Stock A", IN_THAILAND, 10)); + acceptQuotation(pcr, 30); + orderConfirmation.confirmOrder(pcr, confirmRequest(), salesActor); + jdbc.update("UPDATE sales.ticket SET payment_status = 'DEPOSIT_NOTICE_ISSUED' WHERE ticket_id = :t", + Map.of("t", ticketId)); + + assertThatThrownBy(() -> ticketService.waiveDeposit(ticketId, "WAIVED", "ลูกค้าเครดิต", salesActor)) + .isInstanceOfSatisfying(ApiException.class, e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT)); + assertThat(summary().salesStage()).isEqualTo(DealStage.ORDER_RECEIVED); + assertThat(summary().fulfillmentStatus()).isNull(); + } + + // ── B12 (W4-04): no new pricing round once the order is confirmed ──────────────────── + + @Test + void b12_onceTheOrderIsConfirmed_aCustomerChangeRevisionOfAnotherRequestIsRefused409() { + long accepted = acceptedPcr(Set.of(), stockLine("Stock A", IN_THAILAND, 10)); + acceptQuotation(accepted, 30); + long siblingBefore = persistDraft(importLine("Sib 1", catalogProductFactoryA, FACTORY_A, 5)); + forceStatus(siblingBefore, PricingRequestStatus.QUOTATION_ISSUED); + long siblingAfter = persistDraft(importLine("Sib 2", catalogProductFactoryA, FACTORY_A, 5)); + forceStatus(siblingAfter, PricingRequestStatus.QUOTATION_ISSUED); + + // Control: before the order is confirmed the same call is legal (so the 409 below is the + // confirmation, not a malformed request). + assertThat(pricingRequestService.createCustomerChangeRevision(siblingBefore, revisionOf("Sib 1 rev"), + salesActor).summary().status()).isEqualTo(PricingRequestStatus.DRAFT); + + orderConfirmation.confirmOrder(accepted, confirmRequest(), salesActor); + + assertThatThrownBy(() -> pricingRequestService.createCustomerChangeRevision(siblingAfter, + revisionOf("Sib 2 rev"), salesActor)) + .isInstanceOfSatisfying(ApiException.class, + e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT)); + assertThat(status(siblingAfter)).as("refused: the request is untouched") + .isEqualTo(PricingRequestStatus.QUOTATION_ISSUED); + } + + @Test + void b12_onceTheOrderIsConfirmed_aDraftRequestCannotBeSubmitted_409() { + long accepted = acceptedPcr(Set.of(), stockLine("Stock A", IN_THAILAND, 10)); + acceptQuotation(accepted, 30); + long draftBefore = persistDraft(importLine("Late 1", catalogProductFactoryA, FACTORY_A, 5)); + long draftAfter = persistDraft(importLine("Late 2", catalogProductFactoryA, FACTORY_A, 5)); + + // Control: before the order is confirmed a DRAFT submits fine. + pricingRequestService.submit(draftBefore, salesActor); + assertThat(status(draftBefore)).isEqualTo(PricingRequestStatus.SUBMITTED); + + orderConfirmation.confirmOrder(accepted, confirmRequest(), salesActor); + + assertThatThrownBy(() -> pricingRequestService.submit(draftAfter, salesActor)) + .isInstanceOfSatisfying(ApiException.class, + e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT)); + assertThat(status(draftAfter)).isEqualTo(PricingRequestStatus.DRAFT); + } + + // ── fixtures ───────────────────────────────────────────────────────────────────────── + + private ConfirmOrderRequest confirmRequest() { + return new ConfirmOrderRequest(UUID.randomUUID().toString()); + } + + private CustomerChangeRevisionRequest revisionOf(String model) { + return new CustomerChangeRevisionRequest("ลูกค้าเปลี่ยน", UUID.randomUUID().toString(), + PricingRequestRecipient.DESIGNER, null, "Designer Co.", LocalDate.now().plusDays(14), + null, "THB", null, List.of(importLine(model, catalogProductFactoryA, FACTORY_A, 5))); + } + + private List actionCodes() { + return ticketService.actions(ticketId, salesActor).availableActions().stream() + .map(a -> a.action()).toList(); + } + + private TicketSummaryDto summary() { + return tickets.findById(ticketId).orElseThrow().summary(); + } + + /** + * A pricing request at QUOTATION_ACCEPTED whose lines are linked to freshly created ticket + * lines (qty stub 1, like a deal's first save) - except the models in {@code unlinked}, which + * have no source ticket line (so reconcile must INSERT them). A stock line's ticket line names + * FACTORY_B; an import line's names its own factory. + */ + private long acceptedPcr(Set unlinked, PricingRequestItemRequest... items) { + long id = persistDraft(items); + int sort = 0; + for (PricingRequestItemRequest item : items) { + if (unlinked.contains(item.model())) { + continue; + } + long ticketItem = jdbc.queryForObject(""" + INSERT INTO sales.ticket_item (ticket_id, brand, model, size, qty, unit, sort_order, factory) + VALUES (:t, 'SCG', :model, '60x60', 1, 'pcs', :sort, :factory) + RETURNING item_id + """, new MapSqlParameterSource().addValue("t", ticketId).addValue("model", item.model()) + .addValue("sort", sort++) + .addValue("factory", item.factory() != null ? item.factory() : FACTORY_B), Long.class); + jdbc.update(""" + UPDATE sales.pricing_request_item SET source_ticket_item_id = :ti + WHERE pricing_request_id = :id AND model = :model + """, Map.of("ti", ticketItem, "id", id, "model", item.model())); + } + forceStatus(id, PricingRequestStatus.QUOTATION_ACCEPTED); + return id; + } + + /** The customer-accepted new-engine quotation: only its number, total and deposit percent matter here. */ + private void acceptQuotation(long pricingRequestId, int depositPercent) { + jdbc.update(""" + INSERT INTO sales.quotation (ticket_id, number, issued_by, origin, doc_status, pricing_request_id, + deposit_percent, total_amount) + VALUES (:t, :number, :by, 'PRICING_REQUEST', 'ACCEPTED', :pr, :pct, 1000.00) + """, new MapSqlParameterSource().addValue("t", ticketId) + .addValue("number", "QT-WALK4-" + (++quotationSeq)).addValue("by", salesRepId) + .addValue("pr", pricingRequestId).addValue("pct", depositPercent)); + } + + /** Account confirms the deposit money (the real {@code recordPayment}); payable is 1,070 with VAT. */ + private void recordDeposit() { + ticketService.recordPayment(ticketId, new RecordPaymentRequest( + "DEPOSIT", new BigDecimal("321.00"), null, "มัดจำ", null, null, false), accountActor); + } + + private BigDecimal qtyFromStock(String model) { + return jdbc.queryForObject(""" + SELECT qty_from_stock FROM sales.ticket_item WHERE ticket_id = :t AND model = :m + """, Map.of("t", ticketId, "m", model), BigDecimal.class); + } + + private long ticketItemId(String model) { + return jdbc.queryForObject("SELECT item_id FROM sales.ticket_item WHERE ticket_id = :t AND model = :m", + Map.of("t", ticketId, "m", model), Long.class); + } +} diff --git a/backend/src/test/java/th/co/glr/hr/pricingstock/StockLineInTransitIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/pricingstock/StockLineInTransitIntegrationTest.java deleted file mode 100644 index eebdf7402..000000000 --- a/backend/src/test/java/th/co/glr/hr/pricingstock/StockLineInTransitIntegrationTest.java +++ /dev/null @@ -1,330 +0,0 @@ -package th.co.glr.hr.pricingstock; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import java.time.LocalDate; -import java.util.List; -import java.util.Map; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; -import org.springframework.http.HttpStatus; -import th.co.glr.hr.auth.UserPrincipal; -import th.co.glr.hr.common.ApiException; -import th.co.glr.hr.factoryquote.FactoryQuoteDtos.FactoryQuoteDto; -import th.co.glr.hr.pricingrequest.PricingRequestDtos.PricingRequestItemDto; -import th.co.glr.hr.pricingrequest.PricingRequestRequests.SetItemFactoryRequest; -import th.co.glr.hr.pricingrequest.PricingRequestStatus; - -/** - * Slice 1, stock lines: the two NEW write paths ({@code setInTransitArrival}, - * {@code convertInTransitToImport}), the {@code setItemFactory} refusal for stock lines, and their - * authorization - written the wrong way round (who must NOT reach it, and the row is unchanged - * afterwards), per CLAUDE.md "Permission changes must ship evidence". - * - *

Authz evidence here is REAL: real service, real repository, real Postgres, no mock. It is not - * a mutation check - that is the implementer's/reviewer's step (drop the role gate, expect exactly - * the matching case below to go red). - * - *

Tests marked [PIN] pass before the feature exists on purpose. - * - *

Contract choices this file makes that the design left open (each is stated so a reviewer can - * overrule it): a wrong-KIND line or a wrong-STATUS request is a 409; a null date is a 400; a role - * outside the allowed set is a 403; convert is ALSO allowed from READY_FOR_CEO_REVIEW (with a - * pull-back), because IA 1.2 both lists the status guard as {IMPORT_REVIEWING, - * AWAITING_FACTORY_RESPONSE} and specifies the READY_FOR_CEO_REVIEW pull-back - the pull-back would - * be dead code otherwise, so the guard is read as including READY_FOR_CEO_REVIEW. - */ -class StockLineInTransitIntegrationTest extends AbstractStockLineIntegrationTest { - - // ───────────────────────────────────────────────────────────────────────────────────── - // Case 8 - setItemFactory on a stock line - // ───────────────────────────────────────────────────────────────────────────────────── - - @ParameterizedTest - @ValueSource(strings = {IN_THAILAND, IN_TRANSIT}) - void setItemFactory_onAStockLine_isRefusedWith409_andTheLineKeepsNoFactory(String source) { - long id = persistSubmitAndPickUp( - importLine("Imp A", catalogProductFactoryA, FACTORY_A, 10), - stockLine("Stock", source, 5)); - long stockItemId = itemId(id, "Stock"); - - assertThatThrownBy(() -> pricingRequestService.setItemFactory(id, stockItemId, - new SetItemFactoryRequest(factoryId(FACTORY_B)), importActor)) - .isInstanceOfSatisfying(ApiException.class, - e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT)); - - PricingRequestItemDto after = itemByModel(id, "Stock"); - assertThat(after.factory()).isNull(); - assertThat(after.resolvedFactoryId()).isNull(); - assertThat(after.stockSource()).isEqualTo(source); - } - - @Test - void setItemFactory_onAnImportLineWithoutAFactory_stillWorks_PIN() { - long id = persistSubmitAndPickUp( - importLineWithoutFactory("Free text", 5), - stockLine("Stock TH", IN_THAILAND, 3)); - - pricingRequestService.setItemFactory(id, itemId(id, "Free text"), - new SetItemFactoryRequest(factoryId(FACTORY_B)), importActor); - - assertThat(itemByModel(id, "Free text").factory()).isEqualTo(FACTORY_B); - } - - // ───────────────────────────────────────────────────────────────────────────────────── - // Case 6 (tail) + case 10 - setInTransitArrival - // ───────────────────────────────────────────────────────────────────────────────────── - - @Test - void setInTransitArrival_byImport_persistsTheDate_andWhoSetIt() { - long id = persistSubmitAndPickUp(stockLine("Transit", IN_TRANSIT, 4), importLine("Imp A", catalogProductFactoryA, FACTORY_A, 10)); - - pricingRequestService.setInTransitArrival(id, itemId(id, "Transit"), eta(), importActor); - - assertThat(itemByModel(id, "Transit").expectedArrivalDate()).isEqualTo(eta()); - assertThat(etaSetBy(id, "Transit")).isEqualTo(importUserId); - assertThat(etaSetAtIsSet(id, "Transit")).isTrue(); - } - - @Test - void setInTransitArrival_byTheCeo_isAllowed() { - long id = persistSubmitAndPickUp(stockLine("Transit", IN_TRANSIT, 4), importLine("Imp A", catalogProductFactoryA, FACTORY_A, 10)); - - pricingRequestService.setInTransitArrival(id, itemId(id, "Transit"), eta(), ceoActor); - - assertThat(itemByModel(id, "Transit").expectedArrivalDate()).isEqualTo(eta()); - assertThat(etaSetBy(id, "Transit")).isEqualTo(ceoUserId); - } - - @ParameterizedTest - @ValueSource(strings = {"salesOwner", "otherSales", "salesManager", "account"}) - void setInTransitArrival_byAnyoneButImportAndCeo_isRefusedWith403_andNothingChanges(String who) { - long id = persistSubmitAndPickUp(stockLine("Transit", IN_TRANSIT, 4), importLine("Imp A", catalogProductFactoryA, FACTORY_A, 10)); - Snapshot before = snapshot(id, "Transit"); - - assertThatThrownBy(() -> pricingRequestService.setInTransitArrival( - id, itemId(id, "Transit"), eta(), actorFor(who))) - .isInstanceOfSatisfying(ApiException.class, - e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.FORBIDDEN)); - - assertThat(snapshot(id, "Transit")).as("the refused write must leave no trace").isEqualTo(before); - assertThat(itemByModel(id, "Transit").expectedArrivalDate()).isNull(); - } - - @ParameterizedTest - @ValueSource(strings = {IN_THAILAND, "IMPORT"}) - void setInTransitArrival_onALineThatIsNotInTransit_isRefusedWith409_andNoEtaIsWritten(String kind) { - long id = persistSubmitAndPickUp( - IN_THAILAND.equals(kind) ? stockLine("Wrong kind", IN_THAILAND, 4) - : importLine("Wrong kind", catalogProductFactoryA, FACTORY_A, 4), - stockLine("Transit", IN_TRANSIT, 4)); - Snapshot before = snapshot(id, "Wrong kind"); - - assertThatThrownBy(() -> pricingRequestService.setInTransitArrival( - id, itemId(id, "Wrong kind"), eta(), importActor)) - .isInstanceOfSatisfying(ApiException.class, - e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT)); - - assertThat(snapshot(id, "Wrong kind")).isEqualTo(before); - assertThat(itemByModel(id, "Wrong kind").expectedArrivalDate()).isNull(); - } - - @Test - void setInTransitArrival_withANullDate_isRefusedWith400() { - long id = persistSubmitAndPickUp(stockLine("Transit", IN_TRANSIT, 4), importLine("Imp A", catalogProductFactoryA, FACTORY_A, 10)); - - assertThatThrownBy(() -> pricingRequestService.setInTransitArrival( - id, itemId(id, "Transit"), null, importActor)) - .isInstanceOfSatisfying(ApiException.class, - e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST)); - assertThat(itemByModel(id, "Transit").expectedArrivalDate()).isNull(); - } - - @ParameterizedTest - @ValueSource(strings = {PricingRequestStatus.SUBMITTED, PricingRequestStatus.CEO_REVIEWING, - PricingRequestStatus.APPROVED_FOR_QUOTATION, PricingRequestStatus.CANCELLED}) - void setInTransitArrival_outsideImportReviewingAwaitingFactoryOrReady_isRefusedWith409(String forced) { - long id = persistAndSubmit(stockLine("Transit", IN_TRANSIT, 4), importLine("Imp A", catalogProductFactoryA, FACTORY_A, 10)); - if (!PricingRequestStatus.SUBMITTED.equals(forced)) { - forceStatus(id, forced); - } - Snapshot before = snapshot(id, "Transit"); - - assertThatThrownBy(() -> pricingRequestService.setInTransitArrival( - id, itemId(id, "Transit"), eta(), importActor)) - .isInstanceOfSatisfying(ApiException.class, - e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT)); - - assertThat(snapshot(id, "Transit")).isEqualTo(before); - assertThat(status(id)).isEqualTo(forced); - } - - // ───────────────────────────────────────────────────────────────────────────────────── - // Case 9 - convertInTransitToImport - // ───────────────────────────────────────────────────────────────────────────────────── - - @Test - void convert_byImport_clearsSourceAndEta_notifiesTheOwningRep_andTheLineBecomesAnImportLine() { - long id = persistSubmitAndPickUp( - stockLine("Transit", IN_TRANSIT, 4), - stockLine("Stock TH", IN_THAILAND, 3)); - seedEta(id, "Transit", eta()); - long repBefore = notificationCount(salesRepId, id); - long otherRepBefore = notificationCount(otherSalesRepId, id); - long transitItemId = itemId(id, "Transit"); - - pricingRequestService.convertInTransitToImport(id, transitItemId, importActor); - - PricingRequestItemDto converted = itemByModel(id, "Transit"); - assertThat(converted.stockSource()).as("R13: now สั่งนำเข้า").isNull(); - assertThat(converted.expectedArrivalDate()).isNull(); - assertThat(etaSetBy(id, "Transit")).isNull(); - assertThat(etaSetAtIsSet(id, "Transit")).isFalse(); - assertThat(itemByModel(id, "Stock TH").stockSource()).as("other lines untouched").isEqualTo(IN_THAILAND); - assertThat(notificationCount(salesRepId, id)).as("the OWNING rep is told").isEqualTo(repBefore + 1); - assertThat(notificationCount(otherSalesRepId, id)).as("nobody else is").isEqualTo(otherRepBefore); - assertThat(status(id)).isEqualTo(PricingRequestStatus.IMPORT_REVIEWING); - - // ...and it is now a NORMAL import line: import can give it a factory, and the next - // factory-quote generation includes it (and still never the in-Thailand line). - pricingRequestService.setItemFactory(id, transitItemId, new SetItemFactoryRequest(factoryId(FACTORY_B)), - importActor); - List drafts = factoryQuoteService.generateDrafts(id, importActor); - assertThat(drafts).flatExtracting(FactoryQuoteDto::items) - .extracting(item -> item.pricingRequestItemId()) - .containsExactly(transitItemId); - } - - @Test - void convert_whileReadyForCeoReview_pullsTheRequestBackToAwaitingFactoryResponse() { - long id = persistSubmitAndPickUp( - importLine("Imp A", catalogProductFactoryA, FACTORY_A, 10), - stockLine("Transit", IN_TRANSIT, 4)); - quoteEveryImportLineReady(id); - seedEta(id, "Transit", eta()); - forceStatus(id, PricingRequestStatus.READY_FOR_CEO_REVIEW); // precondition, see class Javadoc - - pricingRequestService.convertInTransitToImport(id, itemId(id, "Transit"), importActor); - - assertThat(status(id)) - .as("the converted line has no factory quote yet: the request is no longer ready") - .isEqualTo(PricingRequestStatus.AWAITING_FACTORY_RESPONSE); - assertThat(itemByModel(id, "Transit").stockSource()).isNull(); - assertThat(itemByModel(id, "Transit").expectedArrivalDate()).isNull(); - } - - @Test - void convert_thenTheLineFollowsTheNormalImportFlow_andTheRequestIsReadyAgainOnceItIsQuoted() { - long id = persistSubmitAndPickUp( - importLine("Imp A", catalogProductFactoryA, FACTORY_A, 10), - stockLine("Transit", IN_TRANSIT, 4)); - quoteEveryImportLineReady(id); - seedEta(id, "Transit", eta()); - forceStatus(id, PricingRequestStatus.READY_FOR_CEO_REVIEW); - pricingRequestService.convertInTransitToImport(id, itemId(id, "Transit"), importActor); - pricingRequestService.setItemFactory(id, itemId(id, "Transit"), - new SetItemFactoryRequest(factoryId(FACTORY_B)), importActor); - - quoteEveryImportLineReady(id); - - assertThat(status(id)).isEqualTo(PricingRequestStatus.READY_FOR_CEO_REVIEW); - } - - @ParameterizedTest - @ValueSource(strings = {IN_THAILAND, "IMPORT"}) - void convert_onALineThatIsNotInTransit_isRefusedWith409_andNothingChanges(String kind) { - long id = persistSubmitAndPickUp( - IN_THAILAND.equals(kind) ? stockLine("Wrong kind", IN_THAILAND, 4) - : importLine("Wrong kind", catalogProductFactoryA, FACTORY_A, 4), - stockLine("Transit", IN_TRANSIT, 4)); - Snapshot before = snapshot(id, "Wrong kind"); - - assertThatThrownBy(() -> pricingRequestService.convertInTransitToImport( - id, itemId(id, "Wrong kind"), importActor)) - .isInstanceOfSatisfying(ApiException.class, - e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT)); - - assertThat(snapshot(id, "Wrong kind")).isEqualTo(before); - } - - @ParameterizedTest - @ValueSource(strings = {PricingRequestStatus.SUBMITTED, PricingRequestStatus.CEO_REVIEWING, - PricingRequestStatus.APPROVED_FOR_QUOTATION, PricingRequestStatus.CANCELLED}) - void convert_outsideImportReviewingAwaitingFactoryOrReady_isRefusedWith409_andTheLineIsUnchanged(String forced) { - long id = persistAndSubmit(stockLine("Transit", IN_TRANSIT, 4), importLine("Imp A", catalogProductFactoryA, FACTORY_A, 10)); - seedEta(id, "Transit", eta()); - if (!PricingRequestStatus.SUBMITTED.equals(forced)) { - forceStatus(id, forced); - } - Snapshot before = snapshot(id, "Transit"); - - assertThatThrownBy(() -> pricingRequestService.convertInTransitToImport( - id, itemId(id, "Transit"), importActor)) - .isInstanceOfSatisfying(ApiException.class, - e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT)); - - assertThat(snapshot(id, "Transit")).isEqualTo(before); - assertThat(itemByModel(id, "Transit").stockSource()).isEqualTo(IN_TRANSIT); - assertThat(itemByModel(id, "Transit").expectedArrivalDate()).isEqualTo(eta()); - } - - // ── case 10, convert half: IMPORT ONLY - the CEO is refused too ───────────────────────── - - @ParameterizedTest - @ValueSource(strings = {"salesOwner", "otherSales", "salesManager", "account", "ceo"}) - void convert_byAnyoneButImport_isRefusedWith403_theCeoIncluded_andTheLineIsUnchanged(String who) { - long id = persistSubmitAndPickUp(stockLine("Transit", IN_TRANSIT, 4), importLine("Imp A", catalogProductFactoryA, FACTORY_A, 10)); - seedEta(id, "Transit", eta()); - Snapshot before = snapshot(id, "Transit"); - - assertThatThrownBy(() -> pricingRequestService.convertInTransitToImport( - id, itemId(id, "Transit"), actorFor(who))) - .isInstanceOfSatisfying(ApiException.class, - e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.FORBIDDEN)); - - assertThat(snapshot(id, "Transit")).as("the refused conversion must leave no trace").isEqualTo(before); - assertThat(itemByModel(id, "Transit").stockSource()).isEqualTo(IN_TRANSIT); - assertThat(itemByModel(id, "Transit").expectedArrivalDate()).isEqualTo(eta()); - } - - // ── helpers ────────────────────────────────────────────────────────────────────────── - - private UserPrincipal actorFor(String who) { - return switch (who) { - case "salesOwner" -> salesActor; - case "otherSales" -> otherSalesActor; - case "salesManager" -> salesManagerActor; - case "account" -> accountActor; - case "ceo" -> ceoActor; - case "import" -> importActor; - default -> throw new IllegalArgumentException(who); - }; - } - - /** Everything a refused write could have touched, so "refused" means "no trace at all". */ - private record Snapshot(String stockSource, LocalDate eta, Long etaSetBy, String status, long events, - long repNotifications, long ceoNotifications, long importNotifications) {} - - private Snapshot snapshot(long id, String model) { - PricingRequestItemDto item = itemByModel(id, model); - return new Snapshot(item.stockSource(), item.expectedArrivalDate(), etaSetBy(id, model), status(id), - eventCount(id), notificationCount(salesRepId, id), notificationCount(ceoUserId, id), - notificationCount(importUserId, id)); - } - - private Long etaSetBy(long id, String model) { - return jdbc.queryForObject(""" - SELECT expected_arrival_set_by_id FROM sales.pricing_request_item - WHERE pricing_request_id = :id AND model = :model - """, Map.of("id", id, "model", model), Long.class); - } - - private boolean etaSetAtIsSet(long id, String model) { - return Boolean.TRUE.equals(jdbc.queryForObject(""" - SELECT expected_arrival_set_at IS NOT NULL FROM sales.pricing_request_item - WHERE pricing_request_id = :id AND model = :model - """, Map.of("id", id, "model", model), Boolean.class)); - } -} diff --git a/backend/src/test/java/th/co/glr/hr/pricingstock/StockLineRoutingIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/pricingstock/StockLineRoutingIntegrationTest.java index 13f0e9064..e5bc863a8 100644 --- a/backend/src/test/java/th/co/glr/hr/pricingstock/StockLineRoutingIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/pricingstock/StockLineRoutingIntegrationTest.java @@ -15,6 +15,8 @@ import th.co.glr.hr.pricingrequest.PricingRequestDtos.PricingRequestItemDto; import th.co.glr.hr.pricingrequest.PricingRequestRecipient; import th.co.glr.hr.pricingrequest.PricingRequestRequests.CustomerChangeRevisionRequest; +import th.co.glr.hr.pricingrequest.PricingRequestRequests.PricingRequestItemRequest; +import th.co.glr.hr.pricingrequest.PricingRequestRequests.SetItemFactoryRequest; import th.co.glr.hr.pricingrequest.PricingRequestRequests.UpdatePricingRequestRequest; import th.co.glr.hr.pricingrequest.PricingRequestStatus; @@ -100,10 +102,9 @@ void importPlusInThailand_submitGoesToImport_importNotified_notStraightToCeo_PIN void factoryQuoteDrafts_containOnlyImportLines_evenWhenStockLinesResolveToAFactory() { long id = persistSubmitAndPickUp( importLine("Imp A", catalogProductFactoryA, FACTORY_A, 10), - // both stock kinds point at a real catalog product of factory B: ONLY the source may - // keep them out of factory B's quote, not the absence of a factory. - stockLineWithCatalog("Stock TH", IN_THAILAND, catalogProductFactoryB, FACTORY_B, 5), - stockLineWithCatalog("Stock TR", IN_TRANSIT, catalogProductFactoryB, FACTORY_B, 4)); + // the stock line points at a real catalog product of factory B: ONLY the source may + // keep it out of factory B's quote, not the absence of a factory. + stockLineWithCatalog("Stock TH", IN_THAILAND, catalogProductFactoryB, FACTORY_B, 5)); List drafts = factoryQuoteService.generateDrafts(id, importActor); @@ -126,130 +127,117 @@ void importQuotesReady_withAnInThailandLinePresent_autoAdvancesToCeoReview() { } // ───────────────────────────────────────────────────────────────────────────────────── - // Case 3 - สต็อกกำลังเดินทาง with NO import lines: the new IMPORT_REVIEWING -> READY edge + // Case 5 - submit validation // ───────────────────────────────────────────────────────────────────────────────────── @Test - void inTransitLines_evenWithInThailand_submitGoesToImport_notStraightToCeo_PIN() { - long id = persistDraft( - stockLine("Transit 1", IN_TRANSIT, 10), - stockLine("Stock TH", IN_THAILAND, 3)); - long importBefore = notificationCount(importUserId, id); - - pricingRequestService.submit(id, salesActor); + void createDraft_aStockLine_isAcceptedWithoutFactoryOriginCountryOrLeadTime() { + PricingRequestDetailDto created = pricingRequestService.createDraft(ticketId, + requestOf(stockLine("Stock TH", IN_THAILAND, 10)), salesActor); - assertThat(status(id)).isEqualTo(PricingRequestStatus.SUBMITTED); - assertThat(notificationCount(importUserId, id)).isGreaterThan(importBefore); + assertThat(created.items()).singleElement().satisfies(item -> { + assertThat(item.factory()).isNull(); + assertThat(item.originCountry()).isNull(); + assertThat(item.leadTimeMinDays()).isNull(); + assertThat(item.leadTimeMaxDays()).isNull(); + }); } + /** + * Walk 4 (W4-01): the import-only inputs are cleared for a stock line even if a client sends + * them (the form hides them, but an old draft or a hand-built request can still carry them): + * they would otherwise print on the quotation as an import lead time. Read from the stored + * columns, not the response, so a response that merely hides them cannot pass. + */ @Test - void inTransitOnly_oneEtaStillMissing_staysWithImport_butTheSavedEtaIsPersisted() { - long id = persistSubmitAndPickUp( - stockLine("Transit 1", IN_TRANSIT, 10), - stockLine("Transit 2", IN_TRANSIT, 4), - stockLine("Stock TH", IN_THAILAND, 3)); - assertThat(status(id)).isEqualTo(PricingRequestStatus.IMPORT_REVIEWING); - - pricingRequestService.setInTransitArrival(id, itemId(id, "Transit 1"), eta(), importActor); - - assertThat(itemByModel(id, "Transit 1").expectedArrivalDate()).isEqualTo(eta()); - assertThat(itemByModel(id, "Transit 2").expectedArrivalDate()).isNull(); - assertThat(status(id)) - .as("Transit 2 still has no ETA: the request must NOT reach the CEO yet") - .isEqualTo(PricingRequestStatus.IMPORT_REVIEWING); + void b10_createDraft_anInThailandLineSentWithImportOnlyInputs_storesThemNull() { + PricingRequestItemRequest full = withOriginAndLead( + lineWithFullImportFields("Plumb", IN_THAILAND, 10), "อื่นๆ", 3, 7, "Zanzibar"); + + long id = pricingRequestService.createDraft(ticketId, requestOf(full), salesActor).summary().id(); + + Map row = jdbc.queryForMap(""" + SELECT stock_source, origin_country, origin_country_other, lead_time_min_days, lead_time_max_days + FROM sales.pricing_request_item WHERE pricing_request_id = :id + """, Map.of("id", id)); + assertThat(row.get("stock_source")).isEqualTo(IN_THAILAND); + assertThat(row.get("origin_country")).isNull(); + assertThat(row.get("origin_country_other")).isNull(); + assertThat(row.get("lead_time_min_days")).isNull(); + assertThat(row.get("lead_time_max_days")).isNull(); } @Test - void inTransitOnly_lastMissingEta_advancesImportReviewingToReadyForCeoReview_andNotifiesCeo() { - long id = persistSubmitAndPickUp( - stockLine("Transit 1", IN_TRANSIT, 10), - stockLine("Transit 2", IN_TRANSIT, 4), - stockLine("Stock TH", IN_THAILAND, 3)); - pricingRequestService.setInTransitArrival(id, itemId(id, "Transit 1"), eta(), importActor); - long ceoBefore = notificationCount(ceoUserId, id); + void b10_updateDraft_switchingAnImportLineToStock_clearsItsStoredImportInputs() { + long id = pricingRequestService.createDraft(ticketId, + requestOf(importLine("Switch", null, null, 10)), salesActor).summary().id(); + assertThat(itemByModel(id, "Switch").leadTimeMinDays()).as("fixture: the import line carries a lead time") + .isNotNull(); - PricingRequestDetailDto detail = pricingRequestService.setInTransitArrival( - id, itemId(id, "Transit 2"), eta().plusDays(3), importActor); + pricingRequestService.updateDraft(id, + draftUpdate(lineWithFullImportFields("Switch", IN_THAILAND, 10)), salesActor); - assertThat(status(id)).isEqualTo(PricingRequestStatus.READY_FOR_CEO_REVIEW); - assertThat(detail.summary().status()).isEqualTo(PricingRequestStatus.READY_FOR_CEO_REVIEW); - assertThat(notificationCount(ceoUserId, id)).isGreaterThan(ceoBefore); - assertThat(detail.events()).extracting(e -> e.toStatus()) - .as("the IMPORT_REVIEWING -> READY_FOR_CEO_REVIEW edge is on the audit trail") - .contains(PricingRequestStatus.READY_FOR_CEO_REVIEW); + assertThat(jdbc.queryForObject(""" + SELECT lead_time_min_days IS NULL AND lead_time_max_days IS NULL AND origin_country IS NULL + FROM sales.pricing_request_item WHERE pricing_request_id = :id + """, Map.of("id", id), Boolean.class)).isTrue(); } - // ───────────────────────────────────────────────────────────────────────────────────── - // Case 4 - import + สต็อกกำลังเดินทาง, in both orders - // ───────────────────────────────────────────────────────────────────────────────────── - + /** Walk 4 (owner ruling): the สต็อกกำลังเดินทาง option is removed - a new line cannot be created as one. */ @Test - void importAndInTransit_quotesReadyButEtaMissing_isNotReady_thenSavingTheEtaAdvances() { - long id = persistSubmitAndPickUp( - importLine("Imp A", catalogProductFactoryA, FACTORY_A, 10), - stockLine("Transit", IN_TRANSIT, 4)); - - quoteEveryImportLineReady(id); - - assertThat(status(id)) - .as("every quote is ready but the in-transit line has no ETA: NOT ready for the CEO") - .isEqualTo(PricingRequestStatus.AWAITING_FACTORY_RESPONSE); - - pricingRequestService.setInTransitArrival(id, itemId(id, "Transit"), eta(), importActor); - - assertThat(status(id)).isEqualTo(PricingRequestStatus.READY_FOR_CEO_REVIEW); + void b11_createDraft_anInTransitLine_isRefused400WithAThaiMessage() { + assertThatThrownBy(() -> pricingRequestService.createDraft(ticketId, + requestOf(stockLine("Transit", IN_TRANSIT, 4)), salesActor)) + .isInstanceOfSatisfying(ApiException.class, e -> { + assertThat(e.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST); + // The generic unknown-source 400: IN_TRANSIT is just not a source any more (no dedicated branch). + assertThat(e.getMessage()).contains("รายการที่ 1").contains("ไม่รองรับแหล่งที่มาของสินค้า 'IN_TRANSIT'"); + }); } @Test - void importAndInTransit_etaSavedFirst_thenMarkReadyForCostingAdvances() { - long id = persistSubmitAndPickUp( - importLine("Imp A", catalogProductFactoryA, FACTORY_A, 10), - stockLine("Transit", IN_TRANSIT, 4)); - - pricingRequestService.setInTransitArrival(id, itemId(id, "Transit"), eta(), importActor); - - assertThat(status(id)) - .as("ETA saved but the import line is not quoted yet: still with import") - .isEqualTo(PricingRequestStatus.IMPORT_REVIEWING); - - quoteEveryImportLineReady(id); + void b11_updateDraft_switchingALineToInTransit_isRefused400() { + long id = pricingRequestService.createDraft(ticketId, + requestOf(importLine("Switch", null, null, 10)), salesActor).summary().id(); - assertThat(status(id)) - .as("the LAST of {quotes ready, ETAs set} to arrive must advance the request") - .isEqualTo(PricingRequestStatus.READY_FOR_CEO_REVIEW); + assertThatThrownBy(() -> pricingRequestService.updateDraft(id, + draftUpdate(stockLine("Switch", IN_TRANSIT, 10)), salesActor)) + .isInstanceOfSatisfying(ApiException.class, + e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST)); + assertThat(storedStockSource(id)).as("the refused update changed nothing").isNull(); } + // setItemFactory on a stock line - moved here from the deleted StockLineInTransitIntegrationTest + // (the in-transit write paths went with the option; this refusal stayed). + @Test - void importAndInTransit_changingAnEtaWhileReadyForCeoReview_keepsItThere() { + void setItemFactory_onAStockLine_isRefusedWith409_andTheLineKeepsNoFactory() { long id = persistSubmitAndPickUp( importLine("Imp A", catalogProductFactoryA, FACTORY_A, 10), - stockLine("Transit", IN_TRANSIT, 4)); - pricingRequestService.setInTransitArrival(id, itemId(id, "Transit"), eta(), importActor); - quoteEveryImportLineReady(id); - assertThat(status(id)).isEqualTo(PricingRequestStatus.READY_FOR_CEO_REVIEW); + stockLine("Stock", IN_THAILAND, 5)); + long stockItemId = itemId(id, "Stock"); - pricingRequestService.setInTransitArrival(id, itemId(id, "Transit"), eta().plusDays(10), importActor); + assertThatThrownBy(() -> pricingRequestService.setItemFactory(id, stockItemId, + new SetItemFactoryRequest(factoryId(FACTORY_B)), importActor)) + .isInstanceOfSatisfying(ApiException.class, + e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT)); - assertThat(itemByModel(id, "Transit").expectedArrivalDate()).isEqualTo(eta().plusDays(10)); - assertThat(status(id)).isEqualTo(PricingRequestStatus.READY_FOR_CEO_REVIEW); + PricingRequestItemDto after = itemByModel(id, "Stock"); + assertThat(after.factory()).isNull(); + assertThat(after.resolvedFactoryId()).isNull(); + assertThat(after.stockSource()).isEqualTo(IN_THAILAND); } - // ───────────────────────────────────────────────────────────────────────────────────── - // Case 5 - submit validation - // ───────────────────────────────────────────────────────────────────────────────────── - @Test - void createDraft_stockLinesOfBothKinds_acceptedWithoutFactoryOriginCountryOrLeadTime() { - PricingRequestDetailDto created = pricingRequestService.createDraft(ticketId, - requestOf(stockLine("Stock TH", IN_THAILAND, 10), stockLine("Stock TR", IN_TRANSIT, 4)), salesActor); + void setItemFactory_onAnImportLineWithoutAFactory_stillWorks_PIN() { + long id = persistSubmitAndPickUp( + importLineWithoutFactory("Free text", 5), + stockLine("Stock TH", IN_THAILAND, 3)); - assertThat(created.items()).hasSize(2); - for (PricingRequestItemDto item : created.items()) { - assertThat(item.factory()).isNull(); - assertThat(item.originCountry()).isNull(); - assertThat(item.leadTimeMinDays()).isNull(); - assertThat(item.leadTimeMaxDays()).isNull(); - } + pricingRequestService.setItemFactory(id, itemId(id, "Free text"), + new SetItemFactoryRequest(factoryId(FACTORY_B)), importActor); + + assertThat(itemByModel(id, "Free text").factory()).isEqualTo(FACTORY_B); } @Test @@ -326,16 +314,10 @@ void createDraft_aStockSourceOnAnOtherwiseCompleteLine_isStored_notSilentlyDropp @Test void stockSource_roundTripsThroughCreateUpdateAndRead_inDraft() { PricingRequestDetailDto created = pricingRequestService.createDraft(ticketId, - requestOf(stockLine("RT", IN_TRANSIT, 5)), salesActor); + requestOf(stockLine("RT", IN_THAILAND, 5)), salesActor); long id = created.summary().id(); - assertThat(created.items()).singleElement().satisfies(i -> { - assertThat(i.stockSource()).isEqualTo(IN_TRANSIT); - assertThat(i.expectedArrivalDate()).as("no ETA can exist in DRAFT").isNull(); - }); - assertThat(pricingRequestService.get(id, salesActor).items().get(0).stockSource()).isEqualTo(IN_TRANSIT); - assertThat(storedStockSource(id)).isEqualTo(IN_TRANSIT); - - pricingRequestService.updateDraft(id, draftUpdate(stockLine("RT", IN_THAILAND, 5)), salesActor); + assertThat(created.items()).singleElement() + .satisfies(i -> assertThat(i.stockSource()).isEqualTo(IN_THAILAND)); assertThat(pricingRequestService.get(id, salesActor).items().get(0).stockSource()).isEqualTo(IN_THAILAND); assertThat(storedStockSource(id)).isEqualTo(IN_THAILAND); @@ -353,16 +335,13 @@ void stockSource_isCarriedIntoACustomerChangeRevision() { new CustomerChangeRevisionRequest("ลูกค้าเปลี่ยน", UUID.randomUUID().toString(), PricingRequestRecipient.DESIGNER, null, "Designer Co.", LocalDate.now().plusDays(14), null, "THB", null, - List.of(stockLine("Rev TH", IN_THAILAND, 5), stockLine("Rev TR", IN_TRANSIT, 4), + List.of(stockLine("Rev TH", IN_THAILAND, 5), importLine("Rev Imp", catalogProductFactoryA, FACTORY_A, 10))), salesActor); long childId = revision.summary().id(); assertThat(itemByModel(childId, "Rev TH").stockSource()).isEqualTo(IN_THAILAND); - assertThat(itemByModel(childId, "Rev TR").stockSource()).isEqualTo(IN_TRANSIT); assertThat(itemByModel(childId, "Rev Imp").stockSource()).isNull(); - assertThat(itemByModel(childId, "Rev TR").expectedArrivalDate()) - .as("a new revision starts without an ETA: import confirms it again").isNull(); } // ───────────────────────────────────────────────────────────────────────────────────── @@ -394,12 +373,12 @@ private th.co.glr.hr.pricingrequest.PricingRequestRequests.PricingRequestItemReq String model, int pieces ) { var full = importLine(model, null, null, pieces); - return withOriginAndLead(full, null, 3, 7); + return withOriginAndLead(full, null, 3, 7, null); } private th.co.glr.hr.pricingrequest.PricingRequestRequests.PricingRequestItemRequest withOriginAndLead( th.co.glr.hr.pricingrequest.PricingRequestRequests.PricingRequestItemRequest i, - String originCountry, Integer leadMin, Integer leadMax + String originCountry, Integer leadMin, Integer leadMax, String originCountryOther ) { return new th.co.glr.hr.pricingrequest.PricingRequestRequests.PricingRequestItemRequest( i.sourceTicketItemId(), i.productId(), i.variantId(), i.brand(), i.model(), i.productDescription(), @@ -408,7 +387,7 @@ private th.co.glr.hr.pricingrequest.PricingRequestRequests.PricingRequestItemReq i.deliveryLocation(), i.specialRequirement(), i.productCode(), i.thicknessMm(), i.sqmPerPiece(), i.quantityMode(), i.areaSqm(), i.piecesInput(), i.wastageMode(), i.wastageValue(), i.piecesPerBox(), i.sqmPerBox(), i.roundToFullBox(), originCountry, leadMin, leadMax, - i.piecesBeforeWastage(), i.piecesAfterWastage(), i.boxes(), i.originCountryOther(), i.stockSource()); + i.piecesBeforeWastage(), i.piecesAfterWastage(), i.boxes(), originCountryOther, i.stockSource()); } private String storedStockSource(long pricingRequestId) { diff --git a/backend/src/test/java/th/co/glr/hr/procurement/ProcurementServiceIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/procurement/ProcurementServiceIntegrationTest.java index 825166ab7..879304a7f 100644 --- a/backend/src/test/java/th/co/glr/hr/procurement/ProcurementServiceIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/procurement/ProcurementServiceIntegrationTest.java @@ -426,12 +426,12 @@ SELECT COUNT(*) FROM sales.factory_purchase_order WHERE pricing_request_id = :id */ @Test void createPurchaseOrders_ticketAtProcurementButThisPricingRequestNotAccepted_isRejected() { - DealFixture deal = driveToProcurement(true); + DealFixture deal = driveToProcurement(true, true); // The second pricing request is driven all the way to QUOTATION_ISSUED — it DOES have its // own APPROVED decision and its own real pricing_costing_item rows, so // findApprovedPricingCostingId succeeds and cannot be the thing that blocks this call. // Only status != QUOTATION_ACCEPTED (the customer has not yet accepted THIS quotation) can. - long otherPricingRequestId = addSecondPricingRequestDrivenToQuotationIssued(deal.ticketId); + long otherPricingRequestId = siblingPricingRequestId; assertThat(pricingRequestService.get(otherPricingRequestId, importActor).summary().status()) .isEqualTo(PricingRequestStatus.QUOTATION_ISSUED); @@ -552,7 +552,21 @@ private record DealFixture(long ticketId, long pricingRequestId) {} * factories, so downstream tests can assert "2 factories -> 2 POs" without a second * fixture. */ private DealFixture driveToProcurement(boolean issueImportRequest) { + return driveToProcurement(issueImportRequest, false); + } + + /** The id of the second request {@code driveToProcurement(.., true)} drove to QUOTATION_ISSUED. */ + private long siblingPricingRequestId; + + /** + * {@code withSiblingRequest}: a SECOND request on the ticket is driven to QUOTATION_ISSUED BEFORE the + * first one's order is confirmed (walk 4, W4-04: a confirmed deal starts no new pricing round). + */ + private DealFixture driveToProcurement(boolean issueImportRequest, boolean withSiblingRequest) { DealFixture deal = driveToQuotationIssuedNotYetAccepted(FACTORY_A, FACTORY_B); + if (withSiblingRequest) { + siblingPricingRequestId = addSecondPricingRequestDrivenToQuotationIssued(deal.ticketId); + } CustomerQuotationDto issued = quotationRepository.findByPricingRequest(deal.pricingRequestId).stream() .filter(q -> QuotationStatus.ISSUED.equals(q.docStatus())) .findFirst().orElseThrow(); diff --git a/backend/src/test/java/th/co/glr/hr/ticket/DeliveryAuthzIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/ticket/DeliveryAuthzIntegrationTest.java index 0e492b10d..04baa8ed5 100644 --- a/backend/src/test/java/th/co/glr/hr/ticket/DeliveryAuthzIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/ticket/DeliveryAuthzIntegrationTest.java @@ -309,6 +309,9 @@ private long deliverableDeal() { new BigDecimal("10.00"), null, "PIECE", null, null, null, null, "THB"))); long ticketId = tickets.create(request, tickets.nextTicketCode(), ownerId, "เจ้าของดีล ส่งมอบ"); tickets.updateSalesStage(ticketId, DealStage.ORDER_RECEIVED); + // Stock goods leave only after the deposit (walk 4): the fixture records it, since this class is about WHO may deliver. + jdbc.update("UPDATE sales.ticket SET payment_status = 'DEPOSIT_PAID' WHERE ticket_id = :id", + java.util.Map.of("id", ticketId)); long itemId = onlyItemId(ticketId); ticketService.reserveStock(ticketId, new StockReservationRequest(List.of( diff --git a/backend/src/test/java/th/co/glr/hr/ticket/StageFactGateIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/ticket/StageFactGateIntegrationTest.java index 4d9e5d0f4..fe0500678 100644 --- a/backend/src/test/java/th/co/glr/hr/ticket/StageFactGateIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/ticket/StageFactGateIntegrationTest.java @@ -742,28 +742,24 @@ void aStockDeclarationAtOrAboveTheFloor_isAcceptedAndStillRoutesTheDeal() { } /** - * The floor must reach BOTH entry points. {@code reserveStock}'s own gate and - * {@code canReserveStock} (which decides whether {@code actions} advertises RESERVE_STOCK) were - * unified on one predicate by the 2026-08-13 widening; applying the floor to only one of them - * would put the capability back to advertised-then-refused, or live-but-invisible. + * Walk 4 (owner ruling 2026-10-29): RESERVE_STOCK is not advertised at any stage, but the + * {@code reserveStock} endpoint keeps its stage floor (fixtures and old clients still call it): + * refused below ORDER_RECEIVED, accepted at it. */ @Test - void actionsDoesNotAdvertiseReserveStockBelowTheFloor_andDoesAboveIt() { + void actionsNeverAdvertiseReserveStock_andTheEndpointKeepsItsFloor() { long below = createDealWithOneItem(); tickets.updateSalesStage(below, DealStage.NEGOTIATION); assertThat(actionCodes(below, ownerRep)).doesNotContain("RESERVE_STOCK"); assertThat(actionCodes(below, ceo)).doesNotContain("RESERVE_STOCK"); - // …and the advertiser agrees with the gate on the very same deal. assertThatThrownBy(() -> ticketService.reserveStock(below, declare(onlyItemId(below), "100.00"), ownerRep)) .isInstanceOf(ApiException.class); long above = createDealWithOneItem(); tickets.updateSalesStage(above, DealStage.ORDER_RECEIVED); - assertThat(actionCodes(above, ownerRep)).contains("RESERVE_STOCK"); - assertThat(actionCodes(above, ceo)).contains("RESERVE_STOCK"); - // import lost RESERVE_STOCK entirely on 2026-09-28 (S18 is Sales's) — not offered it even - // above the floor. + assertThat(actionCodes(above, ownerRep)).doesNotContain("RESERVE_STOCK"); + assertThat(actionCodes(above, ceo)).doesNotContain("RESERVE_STOCK"); assertThat(actionCodes(above, importActor)).doesNotContain("RESERVE_STOCK"); assertThatCode(() -> ticketService.reserveStock(above, declare(onlyItemId(above), "100.00"), ownerRep)) 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/java/th/co/glr/hr/ticket/TicketRepositoryIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/ticket/TicketRepositoryIntegrationTest.java index cf384bdd4..f2d3fa276 100644 --- a/backend/src/test/java/th/co/glr/hr/ticket/TicketRepositoryIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/ticket/TicketRepositoryIntegrationTest.java @@ -52,7 +52,8 @@ void createsTicketWithBatchedItemsAndReadsThemBack() { TicketDto ticket = tickets.findById(ticketId).orElseThrow(); - assertThat(ticket.summary().code()).startsWith("PR-"); + // QA-25 (owner ruling 2026-10-29): new deals are numbered DL--<4 digits>; existing PR- codes keep theirs. + assertThat(ticket.summary().code()).matches("DL-\\d{4}-\\d{4}"); assertThat(ticket.summary().createdById()).isEqualTo(actorId); assertThat(ticket.items()).hasSize(2); assertThat(ticket.items()).extracting(TicketItemDto::brand) @@ -179,14 +180,27 @@ void editItems_clearingTheLinkPersistsAsNull() { // would happily "pass" this while the real CHECK constraint / columns did something else. @Test - void createTicket_persistsStockSourcedPriceAndReadsItBack() { + void createTicket_stockSourcedLineWithNoPrice_persistsTheFlagAndANullPrice() { + // Walk 4 (W4-09): chk_ticket_item_stock_sale_price is relaxed to + // `sourced_from_stock OR stock_sale_price IS NULL`, so a flagged line needs no price. + long ticketId = tickets.create(sampleTicket( + itemFromStock("Cotto", "Stone Series", "White", "Matte", "60x60", null)), + tickets.nextTicketCode(), actorId, "พนักงานขาย"); + + TicketItemDto persisted = tickets.findById(ticketId).orElseThrow().items().get(0); + assertThat(persisted.sourcedFromStock()).isTrue(); + assertThat(persisted.stockSalePrice()).isNull(); + } + + @Test + void createTicket_aStockSalePriceSentByAnOldClient_isStoredNull() { long ticketId = tickets.create(sampleTicket( itemFromStock("Cotto", "Stone Series", "White", "Matte", "60x60", new BigDecimal("555.00"))), tickets.nextTicketCode(), actorId, "พนักงานขาย"); TicketItemDto persisted = tickets.findById(ticketId).orElseThrow().items().get(0); assertThat(persisted.sourcedFromStock()).isTrue(); - assertThat(persisted.stockSalePrice()).isEqualByComparingTo("555.00"); + assertThat(persisted.stockSalePrice()).isNull(); } @Test @@ -200,7 +214,7 @@ void createTicket_notFromStockHasNullStockSalePrice() { } @Test - void editItems_roundTripsStockSourcedPriceThroughReplaceItemsPreservingPricing() { + void editItems_roundTripsTheStockFlagWithNoPriceThroughReplaceItemsPreservingPricing() { long ticketId = tickets.create(sampleTicket( item("Toyota", "Hilux", "White", "Matte", "L")), tickets.nextTicketCode(), actorId, "พนักงานขาย"); TicketItemDto existing = tickets.findById(ticketId).orElseThrow().items().get(0); @@ -216,13 +230,13 @@ void editItems_roundTripsStockSourcedPriceThroughReplaceItemsPreservingPricing() existing.qtyDelivered(), existing.qtyFromStock(), existing.stockNote(), existing.catalogPriceId(), existing.catalogProductCode(), null, null, null, null, null, 1, - true, new BigDecimal("620.00")); + true, null); tickets.replaceItemsPreservingPricing(ticketId, List.of(edited)); TicketItemDto persisted = tickets.findById(ticketId).orElseThrow().items().get(0); assertThat(persisted.sourcedFromStock()).isTrue(); - assertThat(persisted.stockSalePrice()).isEqualByComparingTo("620.00"); + assertThat(persisted.stockSalePrice()).isNull(); } @Test @@ -855,6 +869,102 @@ void syncDepositPolicyFromQuotation_keepsRequired_whileADepositNoticeIsIssued() assertThat(depositPolicyRow(ticket)).containsEntry("deposit_policy", DepositPolicy.REQUIRED); } + // ── QA-16 payment chip (owner ruling 2026-10-29): the derived paymentStage follows the payment + // track. Literals, not PaymentStage constants, so this pins the wire value the frontend maps. ── + + @Test + void paymentStage_notStarted_whenNoPaymentTrackAndNothingPaid_withOrWithoutAPayable() { + long noPayable = tickets.create(sampleTicket(), tickets.nextTicketCode(), actorId, "พนักงานขาย"); + assertThat(paymentStage(noPayable)).isEqualTo("NOT_STARTED"); + + long quotedNotConfirmed = payableTicket(null, DepositPolicy.REQUIRED); + assertThat(paymentStage(quotedNotConfirmed)).isEqualTo("NOT_STARTED"); + } + + @Test + void paymentStage_noDeposit_forEveryBypassPolicy_onceTheCustomerConfirmedAndNothingIsPaid() { + for (String policy : List.of(DepositPolicy.NOT_REQUIRED, DepositPolicy.WAIVED, DepositPolicy.CREDIT_CUSTOMER)) { + assertThat(paymentStage(payableTicket("CUSTOMER_CONFIRMED", policy))).as(policy).isEqualTo("NO_DEPOSIT"); + } + } + + @Test + void paymentStage_balancePending_whenTheTrackIsAwaitingFinalPayment_evenWithADepositPaid() { + long ticket = payableTicket("AWAITING_FINAL_PAYMENT", DepositPolicy.REQUIRED); + pay(ticket, "DEPOSIT", "321.00"); + assertThat(paymentStage(ticket)).isEqualTo(PaymentStage.BALANCE_PENDING); + } + + /** NOT_STARTED means "order not confirmed yet": a legacy deal already at ORDER_RECEIVED or later with a null + * track keeps the old derivation. */ + @Test + void paymentStage_nullTrackAtOrAfterOrderReceived_keepsTheOldDerivation() { + long procurement = payableTicket(null, DepositPolicy.REQUIRED); + jdbc.update("UPDATE sales.ticket SET sales_stage = 'PROCUREMENT' WHERE ticket_id = :id", Map.of("id", procurement)); + assertThat(paymentStage(procurement)).isEqualTo(PaymentStage.BALANCE_PENDING); + + long orderReceived = payableTicket(null, DepositPolicy.REQUIRED); + jdbc.update("UPDATE sales.ticket SET sales_stage = 'ORDER_RECEIVED' WHERE ticket_id = :id", Map.of("id", orderReceived)); + assertThat(paymentStage(orderReceived)).isEqualTo(PaymentStage.BALANCE_PENDING); + + long nothingPayable = tickets.create(sampleTicket(), tickets.nextTicketCode(), actorId, "พนักงานขาย"); + jdbc.update("UPDATE sales.ticket SET sales_stage = 'PROCUREMENT' WHERE ticket_id = :id", Map.of("id", nothingPayable)); + assertThat(paymentStage(nothingPayable)).isEqualTo(PaymentStage.NOT_REQUIRED); + } + + /** The unchanged rows of the chip: the new values must not leak into the REQUIRED-deposit path. */ + @Test + void paymentStage_keepsDepositAndMoneyStates_forRequiredPolicyAndLegacyRows() { + assertThat(paymentStage(payableTicket("CUSTOMER_CONFIRMED", DepositPolicy.REQUIRED))) + .isEqualTo(PaymentStage.DEPOSIT_PENDING); + assertThat(paymentStage(payableTicket("DEPOSIT_NOTICE_ISSUED", DepositPolicy.REQUIRED))) + .isEqualTo(PaymentStage.DEPOSIT_PENDING); + + long depositPaid = payableTicket("DEPOSIT_PAID", DepositPolicy.REQUIRED); + pay(depositPaid, "DEPOSIT", "321.00"); + assertThat(paymentStage(depositPaid)).isEqualTo(PaymentStage.DEPOSIT_RECEIVED); + + long partial = payableTicket("AWAITING_FINAL_PAYMENT", DepositPolicy.REQUIRED); + pay(partial, "DEPOSIT", "321.00"); + pay(partial, "BALANCE", "100.00"); + assertThat(paymentStage(partial)).isEqualTo(PaymentStage.PARTIALLY_PAID); + + long full = payableTicket("FULLY_PAID", DepositPolicy.REQUIRED); + pay(full, "BALANCE", "1070.00"); + assertThat(paymentStage(full)).isEqualTo(PaymentStage.FULLY_PAID); + + // legacy rows: no payment_status but money on file still shows the money state, not NOT_STARTED + long legacyDeposit = payableTicket(null, DepositPolicy.REQUIRED); + pay(legacyDeposit, "DEPOSIT", "321.00"); + assertThat(paymentStage(legacyDeposit)).isEqualTo(PaymentStage.DEPOSIT_RECEIVED); + long legacyBalance = payableTicket(null, DepositPolicy.REQUIRED); + pay(legacyBalance, "BALANCE", "100.00"); + assertThat(paymentStage(legacyBalance)).isEqualTo(PaymentStage.PARTIALLY_PAID); + } + + /** A priced item makes payable 1,000 + 7% VAT = 1,070.00 (ticket-item fallback of payableAmount). */ + private long payableTicket(String paymentStatus, String depositPolicy) { + long ticketId = tickets.create(sampleTicket(item("Toyota", "Hilux", "White", "Matte", "L")), + tickets.nextTicketCode(), actorId, "พนักงานขาย"); + jdbc.update("UPDATE sales.ticket_item SET approved_price = 1000 WHERE ticket_id = :id", Map.of("id", ticketId)); + jdbc.update("UPDATE sales.ticket SET payment_status = :ps, deposit_policy = :dp WHERE ticket_id = :id", + new MapSqlParameterSource().addValue("ps", paymentStatus).addValue("dp", depositPolicy) + .addValue("id", ticketId)); + return ticketId; + } + + private void pay(long ticketId, String kind, String amount) { + jdbc.update(""" + INSERT INTO sales.payment_receipt (ticket_id, kind, amount, currency, received_at, recorded_by, receipt_ref) + VALUES (:t, :k, :a, 'THB', now(), :by, :ref)""", new MapSqlParameterSource() + .addValue("t", ticketId).addValue("k", kind).addValue("a", new BigDecimal(amount)) + .addValue("by", actorId).addValue("ref", "PS-" + ticketId + "-" + kind + "-" + amount)); + } + + private String paymentStage(long ticketId) { + return tickets.findSummaryById(ticketId).orElseThrow().paymentStage(); + } + private long confirmedTicket() { long ticketId = tickets.create(sampleTicket(), tickets.nextTicketCode(), actorId, "พนักงานขาย"); jdbc.update("UPDATE sales.ticket SET payment_status = 'CUSTOMER_CONFIRMED' WHERE ticket_id = :id", diff --git a/backend/src/test/java/th/co/glr/hr/ticket/TicketServiceTest.java b/backend/src/test/java/th/co/glr/hr/ticket/TicketServiceTest.java index 1522af01e..697c919f7 100644 --- a/backend/src/test/java/th/co/glr/hr/ticket/TicketServiceTest.java +++ b/backend/src/test/java/th/co/glr/hr/ticket/TicketServiceTest.java @@ -1310,27 +1310,23 @@ void reserveStock_fromOrderReceivedOnward_isAccepted() { } /** - * The floor must reach BOTH entry points or the capability goes live-but-invisible (or - * advertised-then-refused), which is the failure #706 was careful to avoid when it unified - * the gate and the advertiser on canDeclareStockCoverage. Same deal, same roles, one stage - * apart. + * Walk 4 (owner ruling 2026-10-29): there is no reserve-from-stock feature, so {@code + * RESERVE_STOCK} is advertised to nobody at any stage - not the owning rep, not the CEO, not + * import - even where it used to be offered (ORDER_RECEIVED and later). The endpoint and + * {@code reserveStock} itself stay (suites use them as a fixture); only the advert goes. */ @Test - void actions_doesNotAdvertiseReserveStockBelowOrderReceived() { + void actions_neverAdvertisesReserveStock_atAnyStage() { TicketItemDto item = deliveryItem(1L, "100.00", "0.00", "0.00"); - stubDeal(41L, 1L, TicketStatus.QUOTATION_ISSUED, List.of(item), null, null, - DealStage.NEGOTIATION, null); - // The floor is checked through the two roles that CAN declare (owning rep + CEO); import is - // no longer offered RESERVE_STOCK at all (owner decision 2026-09-28), asserted below the loop. - assertThat(actionCodes(41L, salesActor)).doesNotContain("RESERVE_STOCK"); - assertThat(actionCodes(41L, ceoActor)).doesNotContain("RESERVE_STOCK"); - - stubDeal(42L, 1L, TicketStatus.QUOTATION_ISSUED, List.of(item), null, null, - DealStage.ORDER_RECEIVED, null); - assertThat(actionCodes(42L, salesActor)).contains("RESERVE_STOCK"); - assertThat(actionCodes(42L, ceoActor)).contains("RESERVE_STOCK"); - // import is above the floor here yet still not offered it — the S18 narrowing, at the advertiser. - assertThat(actionCodes(42L, importActor)).doesNotContain("RESERVE_STOCK"); + long id = 41L; + for (String stage : List.of(DealStage.NEGOTIATION, DealStage.ORDER_RECEIVED, DealStage.DEPOSIT_RECEIVED, + DealStage.PROCUREMENT, DealStage.DELIVERY_SCHEDULING)) { + stubDeal(id, 1L, TicketStatus.QUOTATION_ISSUED, List.of(item), null, null, stage, null); + assertThat(actionCodes(id, salesActor)).as(stage).doesNotContain("RESERVE_STOCK"); + assertThat(actionCodes(id, ceoActor)).as(stage).doesNotContain("RESERVE_STOCK"); + assertThat(actionCodes(id, importActor)).as(stage).doesNotContain("RESERVE_STOCK"); + id++; + } } @Test @@ -1510,6 +1506,24 @@ void confirmFinalPayment_fromDepositPaidBeforeGoodsReceived_isAllowed() { 10L, DepositPolicy.REQUIRED, "DEPOSIT_PAID", PaymentTrack.FULLY_PAID); } + // Review fix 2: an all-stock deal is routed only when the deposit is actually settled. If a payment leaves + // the payment track at DEPOSIT_NOTICE_ISSUED (a notice is not money) nothing is routed. The real service + // always moves a positive payment to DEPOSIT_PAID, so this state is pinned with a stubbed repository. + @Test + void recordPayment_leavingTheDealAtDepositNoticeIssued_doesNotRouteAnAllStockDeal() { + TicketItemDto allStock = deliveryItem(1L, "100.00", "0.00", "100.00"); + stubDeal(10L, 1L, TicketStatus.QUOTATION_ISSUED, List.of(allStock), "DEPOSIT_NOTICE_ISSUED", null, + DealStage.ORDER_RECEIVED, null); + when(ticketRepo.payableAmount(10L)).thenReturn(new BigDecimal("1000.00")); + when(ticketRepo.sumPaid(10L)).thenReturn(BigDecimal.ZERO); + + service.recordPayment(10L, + new RecordPaymentRequest("DEPOSIT", new BigDecimal("100.00"), null, "มัดจำ", null, null, false), + accountActor); + + verify(ticketRepo, never()).updateFulfillmentStatus(eq(10L), anyString()); + } + @Test void recordPayment_balanceBringsCumulativePaidToFull() { stubTicketWithTracks(10L, 1L, TicketStatus.QUOTATION_ISSUED, "DEPOSIT_PAID", "SHIPPING"); @@ -1734,38 +1748,30 @@ private void assertCreateAcceptsEntryChannel(String channel) { verify(ticketRepo).create(request, "PR-2026-0001", 1L, "sales"); } - // ── create: V183 stock-sourced deal-line pricing ──────────────────────── - // tickets.create(...) calls TicketRepository#insertItems directly off the raw - // TicketItemRequest list -- no DTO merge step exists on this path, so the - // stockSalePrice required+positive-iff-sourcedFromStock invariant is validated up front, - // before any DB write is attempted (same style as create_rejectsInvalidPriority above). + // ── create: stock-sourced deal lines ───────────────────────────────────── + // Walk 4 (W4-09, owner ruling 2026-10-29): the deal line no longer carries a sales-typed price + // for stock goods - the deal shows the CEO-approved price. Ticking "from stock" is just a flag; + // the price is not required and a price that is sent is stored as NULL (the NULL itself is proved + // against real Postgres in TicketRepositoryIntegrationTest). @Test - void create_rejectsStockSourcedItemWithoutPositivePrice() { + void create_allowsStockSourcedItemWithoutAPrice() { TicketItemRequest missingPrice = new TicketItemRequest( "Brand", "Model", "White", "Matte", "60x60", null, BigDecimal.ONE, null, "PIECE", null, null, null, null, "THB", null, null, true, null); var request = createRequest(77L, List.of(missingPrice)); + when(ticketRepo.nextTicketCode()).thenReturn("PR-2026-0001"); + when(ticketRepo.create(request, "PR-2026-0001", 1L, "sales")).thenReturn(10L); + stubTicket(10L, 1L, TicketStatus.DRAFT); - assertBadRequest(() -> service.create(request, salesActor)); - verify(ticketRepo, never()).create(any(), anyString(), org.mockito.ArgumentMatchers.anyLong(), anyString()); - } - - @Test - void create_rejectsStockSourcedItemWithZeroOrNegativePrice() { - TicketItemRequest zeroPrice = new TicketItemRequest( - "Brand", "Model", "White", "Matte", "60x60", null, - BigDecimal.ONE, null, "PIECE", null, null, null, null, "THB", - null, null, true, BigDecimal.ZERO); - var request = createRequest(77L, List.of(zeroPrice)); + service.create(request, salesActor); // must not throw - assertBadRequest(() -> service.create(request, salesActor)); - verify(ticketRepo, never()).create(any(), anyString(), org.mockito.ArgumentMatchers.anyLong(), anyString()); + verify(ticketRepo).create(request, "PR-2026-0001", 1L, "sales"); } @Test - void create_allowsStockSourcedItemWithValidPrice() { + void create_allowsStockSourcedItemEvenIfAnOldClientStillSendsAPrice() { TicketItemRequest fromStock = new TicketItemRequest( "Brand", "Model", "White", "Matte", "60x60", null, BigDecimal.ONE, null, "PIECE", null, null, null, null, "THB", @@ -2310,25 +2316,20 @@ void actions_neverOffersSubmit() { } /** - * canReserveStock (the actions() advertiser) and reserveStock's own gate must stay in step — - * both now read canDeclareStockCoverage, so the API can never hide an action the caller may - * actually perform, nor offer one that would 403 on click (the inverse of the "never advertise - * a dead action" rule canIssueImportRequest documents). Without this test the advertiser's - * half of the 2026-08-13 widening would be an unmutatable line. + * Walk 4: {@code RESERVE_STOCK} is no longer advertised to the deal owner or the CEO (it was the + * "never advertise a dead action" pairing with reserveStock's own gate; the owner removed the + * จองสินค้าจากสต็อก UI), and still not to anyone else. {@code reserveStock} itself keeps its gate - + * see StageFactGateIntegrationTest for the endpoint staying callable. */ @Test - void actions_offersReserveStockToTheDealOwner_butNotToAnotherRep() { + void actions_neverOffersReserveStock_toAnyRole() { TicketItemDto item = deliveryItem(1L, "100.00", "0.00", "0.00"); stubDeal(40L, 1L, TicketStatus.QUOTATION_ISSUED, List.of(item), "DEPOSIT_PAID", null, DealStage.ORDER_RECEIVED, null); - assertThat(actionCodes(40L, salesActor)).contains("RESERVE_STOCK"); - // CEO keeps it as oversight/override; import lost it on 2026-09-28 (S18 is Sales's). - assertThat(actionCodes(40L, ceoActor)).contains("RESERVE_STOCK"); + assertThat(actionCodes(40L, salesActor)).doesNotContain("RESERVE_STOCK"); + assertThat(actionCodes(40L, ceoActor)).doesNotContain("RESERVE_STOCK"); assertThat(actionCodes(40L, importActor)).doesNotContain("RESERVE_STOCK"); - // otherSales cannot even read this deal (requireViewAccess is owner-scoped for sales), - // so the advertiser is exercised through the roles that CAN see it and still must not - // be offered it. assertThat(actionCodes(40L, accountActor)).doesNotContain("RESERVE_STOCK"); assertThat(actionCodes(40L, salesManagerActor)).doesNotContain("RESERVE_STOCK"); } @@ -2346,7 +2347,7 @@ private List actionCodes(long ticketId, UserPrincipal actor) { * Rule 4 — {@link TicketService#waiveDeposit} 409s past that point (see * {@code depositPolicy_cannotChangeAfterNoticeIssued_evenForTheOwner} in * {@code DepositPolicyAuthzIntegrationTest}), so advertising it here would offer a button that - * dies on click, the same discipline {@link #actions_offersReserveStockToTheDealOwner_butNotToAnotherRep} + * dies on click, the same discipline {@link #actions_neverOffersReserveStock_toAnyRole} * documents for RESERVE_STOCK. This used to be named * actions_offersWaiveDepositToOwnerOnly_andDepositPaidToAccountOnly and asserted the OPPOSITE * for {@code salesActor} — that assertion was wrong; see canSetDepositPolicy's own Javadoc for @@ -2761,16 +2762,16 @@ void editItems_rejectsNonOwnerSales() { assertForbidden(() -> service.editItems(10L, new EditItemsRequest(List.of(req), null), otherSales)); } - // ── editItems: V183 stock-sourced deal-line pricing ───────────────────── - // sourcedFromStock/stockSalePrice are LEGITIMATELY sales-writable (unlike proposedPrice/ - // approvedPrice/calcedCost/manualPrice, which stay guarded to `prior`) — see - // mergeEditedItemsPreservingPricing's own comment. These tests cover: (b) an invalid - // price is rejected with 400, (c) sourcedFromStock=false forces stockSalePrice to null - // even if the request sent one, and (d) a request that omits both fields preserves the - // prior row's value instead of resetting it. + // ── editItems: stock-sourced deal lines ───────────────────────────────── + // sourcedFromStock is LEGITIMATELY sales-writable (unlike proposedPrice/approvedPrice/calcedCost/ + // manualPrice, which stay guarded to `prior`) - see mergeEditedItemsPreservingPricing's own + // comment. Walk 4 (W4-09): stockSalePrice is no longer sales-writable at all - the merged row never + // carries one, whatever the request (or the prior row) said. Cases: (a) a flagged line with no price + // is accepted, (b) a sent price is dropped, (c) sourcedFromStock=false still clears it, (d) a request + // that omits the flag preserves the prior row's FLAG (but not a price). @Test - void editItems_rejectsStockSourcedItemWithoutPositivePrice() { + void editItems_acceptsStockSourcedItemWithoutAPrice_andKeepsTheFlag() { TicketItemDto existing = new TicketItemDto( 101L, 10L, "Brand", "Model", null, null, null, "Cotto", new BigDecimal("5"), new BigDecimal("10"), null, null, null, @@ -2782,8 +2783,33 @@ void editItems_rejectsStockSourcedItemWithoutPositivePrice() { new BigDecimal("5"), new BigDecimal("10"), "PIECE", null, null, null, null, "THB", null, null, true, null); - assertBadRequest(() -> - service.editItems(10L, new EditItemsRequest(List.of(edited), null), salesActor)); + service.editItems(10L, new EditItemsRequest(List.of(edited), null), salesActor); // must not throw + + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(ticketRepo).replaceItemsPreservingPricing(eq(10L), captor.capture()); + assertThat(captor.getValue().get(0).sourcedFromStock()).isTrue(); + assertThat(captor.getValue().get(0).stockSalePrice()).isNull(); + } + + @Test + void editItems_aSentStockSalePrice_isNeverStored() { + TicketItemDto existing = new TicketItemDto( + 101L, 10L, "Brand", "Model", null, null, null, "Cotto", + new BigDecimal("5"), new BigDecimal("10"), null, null, null, + null, null, "THB", 0, null, null, null, "PIECE", null, null); + stubTicketWithItems(10L, 1L, TicketStatus.IN_REVIEW, List.of(existing)); + + TicketItemRequest edited = new TicketItemRequest( + "Brand", "Model", null, null, null, "Cotto", + new BigDecimal("5"), new BigDecimal("10"), "PIECE", + null, null, null, null, "THB", null, null, true, new BigDecimal("999.00")); + + service.editItems(10L, new EditItemsRequest(List.of(edited), null), salesActor); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(ticketRepo).replaceItemsPreservingPricing(eq(10L), captor.capture()); + assertThat(captor.getValue().get(0).sourcedFromStock()).isTrue(); + assertThat(captor.getValue().get(0).stockSalePrice()).isNull(); } @Test @@ -2814,7 +2840,7 @@ void editItems_sourcedFromStockFalseForcesStockSalePriceNull() { } @Test - void editItems_omittingStockFieldsPreservesPriorValue() { + void editItems_omittingStockFieldsPreservesThePriorFlag_butNotAPrice() { // A request that says nothing about sourcedFromStock (null, the "not specified" value // for this Boolean field, as opposed to an explicit false) must not silently reset a // flag a previous save set — same null-safe-fallback treatment unitBasis already gets. @@ -2844,7 +2870,8 @@ void editItems_omittingStockFieldsPreservesPriorValue() { TicketItemDto merged = captor.getValue().get(0); assertThat(merged.brand()).isEqualTo("NewBrand"); // descriptive field still follows the request assertThat(merged.sourcedFromStock()).isTrue(); - assertThat(merged.stockSalePrice()).isEqualByComparingTo("500.00"); + // Walk 4: the flag is preserved, a prior sales-typed price is not carried forward any more. + assertThat(merged.stockSalePrice()).isNull(); } // ── overrideItemPrice ──────────────────────────────────────────────── 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/api-surface.json b/docs/api/api-surface.json index 0693affac..3483a14e4 100644 --- a/docs/api/api-surface.json +++ b/docs/api/api-surface.json @@ -1,7 +1,7 @@ { "$comment" : "GENERATED by ApiSurfaceContractTest — do not hand-edit. Regenerate: cd backend && ./mvnw -Dtest=ApiSurfaceContractTest -Dapi.surface.update=true test", "source" : "springdoc /v3/api-docs, generated from Spring's handler mappings", - "count" : 381, + "count" : 382, "deprecatedCount" : 3, "operations" : [ "DELETE /api/attachments/{id}", @@ -163,6 +163,7 @@ "GET /api/tickets/{ticketId}/import-request/brands", "GET /api/tickets/{ticketId}/import-request/pages", "GET /api/tickets/{ticketId}/import-requests", + "GET /api/tickets/{ticketId}/import-requests/coverage", "GET /api/tickets/{ticketId}/pricing-requests", "GET /api/tickets/{ticketId}/remaining-invoice/options", "GET /api/tickets/{ticketId}/remaining-invoices", diff --git a/docs/api/response-fields.json b/docs/api/response-fields.json index d79ad22d7..e39696a59 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" : 1472, "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", @@ -1046,8 +1051,10 @@ "PricingRequestEventDto.actorName", "PricingRequestEventDto.createdAt", "PricingRequestEventDto.eventKind", + "PricingRequestEventDto.fromStatus", "PricingRequestEventDto.pricingRequestId", "PricingRequestEventDto.ticketId", + "PricingRequestEventDto.toStatus", "PricingRequestItemDto.areaSqm", "PricingRequestItemDto.catalogBasePrice", "PricingRequestItemDto.catalogBrand", @@ -1101,6 +1108,7 @@ "PricingRequestSummaryDto.itemCount", "PricingRequestSummaryDto.omitContactHonorific", "PricingRequestSummaryDto.orderConfirmedAt", + "PricingRequestSummaryDto.parentPricingRequestId", "PricingRequestSummaryDto.paymentTermMode", "PricingRequestSummaryDto.printedByDisplayId", "PricingRequestSummaryDto.projectName", @@ -1117,6 +1125,7 @@ "PricingRequestSummaryDto.ticketCode", "PricingRequestSummaryDto.ticketCreatedById", "PricingRequestSummaryDto.ticketId", + "PricingRequestSummaryDto.ticketPaymentStatus", "PricingRequestSummaryDto.ticketStatus", "PricingRequestSummaryDto.unitCode", "PricingRequestSummaryDto.updatedAt", @@ -1351,8 +1360,10 @@ "TicketEventDto.actorId", "TicketEventDto.actorName", "TicketEventDto.createdAt", + "TicketEventDto.fromStatus", "TicketEventDto.itemSnapshot", "TicketEventDto.ticketId", + "TicketEventDto.toStatus", "TicketItemDto.approvedPrice", "TicketItemDto.calcConfigVersion", "TicketItemDto.calcedCost", @@ -1377,7 +1388,6 @@ "TicketItemDto.sourcedFromStock", "TicketItemDto.sqmPerPiece", "TicketItemDto.stockNote", - "TicketItemDto.stockSalePrice", "TicketItemDto.ticketId", "TicketItemDto.unitBasis", "TicketItemDto.weightMultiplier", @@ -1390,7 +1400,6 @@ "TicketSummaryDto.buyerName", "TicketSummaryDto.closeConfirmedAt", "TicketSummaryDto.closeConfirmedByName", - "TicketSummaryDto.closedAt", "TicketSummaryDto.commissionRecorded", "TicketSummaryDto.contactId", "TicketSummaryDto.contactName", diff --git a/docs/api/ui-reachable.json b/docs/api/ui-reachable.json index 48627e533..24c24b82c 100644 --- a/docs/api/ui-reachable.json +++ b/docs/api/ui-reachable.json @@ -136,6 +136,7 @@ "GET /api/tickets/{ticketId}/import-request/brands", "GET /api/tickets/{ticketId}/import-request/pages", "GET /api/tickets/{ticketId}/import-requests", + "GET /api/tickets/{ticketId}/import-requests/coverage", "GET /api/tickets/{ticketId}/pricing-requests", "GET /api/tickets/{ticketId}/remaining-invoice/options", "GET /api/tickets/{ticketId}/remaining-invoices", @@ -283,7 +284,6 @@ "POST /api/tickets/{id}/item-weight-multipliers", "POST /api/tickets/{id}/lost", "POST /api/tickets/{id}/reopen", - "POST /api/tickets/{id}/reserve-stock", "POST /api/tickets/{id}/resume", "POST /api/tickets/{id}/shipping", "POST /api/tickets/{id}/stage", 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) ? ( apiRequest(API_ROUTES.storedImportRequests.forTicket(ticketId), { method: 'POST', body: payload }), listForTicket: (ticketId) => apiRequest(API_ROUTES.storedImportRequests.forTicket(ticketId)), + // `{ hasUncoveredFactories }` — read-only; kept off the list so the import pages do not pay for it. + coverage: (ticketId) => apiRequest(API_ROUTES.storedImportRequests.coverage(ticketId)), get: (id) => apiRequest(API_ROUTES.storedImportRequests.get(id)), // PATCH: an absent field is left alone, never blanked — see ImportRequestRequests. // UpdateImportRequestRequest's own Javadoc. diff --git a/frontend/src/api/mockApi.confirmOrderStockCover.test.js b/frontend/src/api/mockApi.confirmOrderStockCover.test.js new file mode 100644 index 000000000..a6fe9485f --- /dev/null +++ b/frontend/src/api/mockApi.confirmOrderStockCover.test.js @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; +import { api, setPricingRequestStatusForTests, setTicketItemQtyFromStockForTests } from './mockApi.js'; + +// Mirrors OrderConfirmationService#reconcileTicketItems (walk 4): confirmOrder writes qty_from_stock only for +// IN_THAILAND lines (qty); every other line keeps what it had (a legacy reserve survives). +let seq = 0; +const uuid = () => `deadbeef-1111-4000-8000-${String(++seq).padStart(12, '0')}`; + +async function confirmedDeal(lines) { + await api.auth.login({ role: 'sales' }); + const { customer } = await api.customers.create({ + name: `บริษัท สต็อกทดสอบ ${seq + 1} จำกัด`, taxId: `03000000${seq + 1}`, address: 'ถนนทดสอบ', branch: 'สาขา', phone: '02-111-1111', + }); + const { project } = await api.customers.createProject(customer.id, { name: `โครงการสต็อก ${seq + 1}` }); + const { ticket: created } = await api.tickets.create({ + entryChannel: 'DESIGNER_LED', title: `ดีลสต็อก ${seq + 1}`, priority: 'NORMAL', customerName: customer.name, + customerId: customer.id, projectId: project.id, + items: lines.map((l) => ({ brand: 'SCG', model: l.model, qty: 1, currency: 'THB', factory: 'Panaria SpA' })), + }); + const ticketId = created.summary.id; + const { pricingRequest } = await api.pricingRequests.create(ticketId, { + recipientType: 'DESIGNER', recipientLabel: 'ผู้ออกแบบ', clientRequestId: uuid(), + items: lines.map((l, i) => ({ + sourceTicketItemId: created.items[i].id, productId: 1, brand: 'SCG', model: l.model, factory: 'Panaria SpA', + requestedQty: l.qty, requestedUnit: 'แผ่น', requestedUnitBasis: 'PER_PIECE', quantityType: 'ESTIMATE', + color: 'ขาว', texture: 'ด้าน', size: '60x60', thicknessMm: 10, sqmPerPiece: 0.36, quantityMode: 'PIECES', + piecesInput: l.qty, wastageMode: 'NONE', piecesPerBox: 4, roundToFullBox: false, + // (the mock's create form still asks every line for these) + originCountry: 'อิตาลี', leadTimeMinDays: 75, leadTimeMaxDays: 90, + stockSource: l.stock ? 'IN_THAILAND' : null, + })), + }); + lines.forEach((l, i) => { if (l.legacy) setTicketItemQtyFromStockForTests(ticketId, created.items[i].id, l.legacy); }); + setPricingRequestStatusForTests(pricingRequest.summary.id, 'QUOTATION_ACCEPTED'); + await api.pricingRequests.confirmOrder(pricingRequest.summary.id, { clientRequestId: uuid() }); + return (await api.tickets.get(ticketId)).ticket.items; +} + +describe('mock confirmOrder: qtyFromStock is written for stock lines only', () => { + it('covers a stock line in full and leaves an import line with a legacy reserve as it was', async () => { + const items = await confirmedDeal([ + { model: 'Stock S', qty: 10, stock: true }, + { model: 'Import I', qty: 20, stock: false, legacy: 5 }, + { model: 'Import J', qty: 8, stock: false }, + ]); + const by = (m) => items.find((i) => i.model === m); + expect(Number(by('Stock S').qtyFromStock)).toBe(10); + expect(Number(by('Import I').qtyFromStock)).toBe(5); + expect(Number(by('Import J').qtyFromStock)).toBe(0); + }); +}); diff --git a/frontend/src/api/mockApi.js b/frontend/src/api/mockApi.js index 9370030fc..3fbaa23d8 100644 --- a/frontend/src/api/mockApi.js +++ b/frontend/src/api/mockApi.js @@ -1,5 +1,6 @@ import thaiLocations from '../data/thai-locations.json'; import { formatThaiAddress } from '../features/locations/thaiAddress.js'; +import { nextAccountAction } from '../features/tickets/accountActions.js'; // Mock backend for VITE_USE_MOCKS=true — the default verification surface for the // `frontend-mock` launch config that devs, QA and coding agents drive. // @@ -80,20 +81,6 @@ import { const db = createDemoDatabase(); -// V183 (stock-sourced deal-line pricing): mirrors TicketService.isStorablePositivePrice exactly. -// sales.ticket_item.stock_sale_price is numeric(14,2), and Postgres rounds HALF_UP to that scale -// BEFORE chk_ticket_item_stock_sale_price sees the value -- 0.004 stores as 0.00 (the real CHECK -// rejects it), 0.005 stores as 0.01 (accepted). A plain `Number(price) > 0` would let 0.004 pass -// here and then have the real backend 400/500 it -- see ticketItemFields.jsx's -// isStockSalePriceMissing, which this duplicates rather than imports (mockApi.js does not import -// frontend feature modules). -function isMockStockSalePriceMissing(sourcedFromStock, stockSalePrice) { - if (!sourcedFromStock) return false; - if (stockSalePrice === '' || stockSalePrice == null) return true; - const n = Number(stockSalePrice); - return !(Number.isFinite(n) && Math.round(n * 100) > 0); -} - function normalizeQuotation(q, ticket, index = 0) { return { ...q, @@ -1625,8 +1612,8 @@ function reconcileTicketItemsFromPricingRequest(ticket, pr, user) { const ticketItem = ticket.items.find((ti) => ti.id === item.sourceTicketItemId); if (!ticketItem) continue; const qtyDelivered = Number(ticketItem.qtyDelivered ?? 0); - const qtyFromStock = Number(ticketItem.qtyFromStock ?? 0); - if (newQty < qtyDelivered || newQty < qtyFromStock) { + const stockLine = item.stockSource === 'IN_THAILAND'; + if (newQty < qtyDelivered || (!stockLine && newQty < Number(ticketItem.qtyFromStock ?? 0))) { // Mirrors the DB's chk_ticket_item_qty_delivered/chk_ticket_item_qty_from_stock CHECK // constraints (V54) that back the real reconcileItemQty call — a downward reconciliation // that would drop qty below an already-recorded delivered/reserved amount is refused, not @@ -1634,9 +1621,13 @@ function reconcileTicketItemsFromPricingRequest(ticket, pr, user) { fail(`ไม่สามารถปรับจำนวนสินค้า (item ${item.sourceTicketItemId}) ให้ตรงกับคำขอราคาที่ยืนยันคำสั่งซื้อได้ ` + 'เนื่องจากมีการส่งมอบหรือจองสต็อกไปแล้วเกินจำนวนใหม่', 409); } - if (Number(ticketItem.qty) !== newQty) { + // A stock line (IN_THAILAND) is covered in full - the one writer of qtyFromStock (no reserve step). + // Any other line keeps what it had (a legacy reserve survives), as the backend does. + const coveredFromStock = stockLine ? newQty : Number(ticketItem.qtyFromStock ?? 0); + if (Number(ticketItem.qty) !== newQty || Number(ticketItem.qtyFromStock ?? 0) !== coveredFromStock) { ticketItem.qty = newQty; ticketItem.qtySqm = item.requestedQtySqm ?? null; + ticketItem.qtyFromStock = coveredFromStock; anyChange = true; } } else { @@ -1651,7 +1642,7 @@ function reconcileTicketItemsFromPricingRequest(ticket, pr, user) { qty: newQty, qtySqm: item.requestedQtySqm ?? null, proposedPrice: null, approvedPrice: null, currency: 'THB', sortOrder: ticket.items.length, - qtyDelivered: 0, qtyFromStock: 0, + qtyDelivered: 0, qtyFromStock: item.stockSource === 'IN_THAILAND' ? newQty : 0, unitBasis: item.requestedUnitBasis === 'PER_SQM' ? 'SQM' : 'PIECE', }); anyChange = true; @@ -1980,9 +1971,18 @@ function buildPricingRequestSummary(pr) { unitCode: pr.unitCode ?? null, omitContactHonorific: pr.omitContactHonorific ?? false, customerId: ticket?.customerId ?? null, + // null until the customer's order is confirmed; once set no new pricing round can start. + ticketPaymentStatus: ticket?.paymentStatus ?? null, }; } +// Mirrors PricingRequestService.requireOrderNotConfirmed. +function requireMockOrderNotConfirmed(ticket) { + if (ticket?.paymentStatus != null) { + fail('ยืนยันคำสั่งซื้อของดีลนี้แล้ว จึงเริ่มรอบราคาใหม่ไม่ได้ — การเปลี่ยนแปลงหลังยืนยันต้องทำเป็นการแก้ไขคำสั่งซื้อ', 409); + } +} + function buildPricingRequestDetail(pr) { return { summary: buildPricingRequestSummary(pr), items: pr.items, events: pr.events }; } @@ -3832,9 +3832,12 @@ function receiptsForTicket(ticketId) { .sort((a, b) => 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) { @@ -3843,8 +3846,12 @@ function derivePaymentFields(ticket) { const outstanding = moneyValue(Math.max(0, payable - paid)); const hasBalance = receiptsForTicket(ticket.id).some((receipt) => receipt.kind === 'BALANCE'); let paymentStage = 'NOT_REQUIRED'; - if (payable > 0 && paid >= payable) paymentStage = 'FULLY_PAID'; - else if (payable > 0 && paid > 0) paymentStage = hasBalance ? 'PARTIALLY_PAID' : 'DEPOSIT_RECEIVED'; + // Mirrors TicketRepository.derivePaymentStage (QA-16): NOT_STARTED = order not confirmed yet. + if (!ticket.paymentStatus && paid <= 0 && dealStageIndex(ticket.salesStage) < dealStageIndex('ORDER_RECEIVED')) paymentStage = 'NOT_STARTED'; + else if (payable > 0 && paid >= payable) paymentStage = 'FULLY_PAID'; + else if (payable > 0 && paid > 0) { + paymentStage = hasBalance ? 'PARTIALLY_PAID' : ticket.paymentStatus === 'AWAITING_FINAL_PAYMENT' ? 'BALANCE_PENDING' : 'DEPOSIT_RECEIVED'; + } else if (payable > 0 && depositBypassesNotice(ticket) && ticket.paymentStatus === 'CUSTOMER_CONFIRMED') paymentStage = 'NO_DEPOSIT'; else if (payable > 0 && !depositBypassesNotice(ticket) && ['CUSTOMER_CONFIRMED', 'DEPOSIT_NOTICE_ISSUED'].includes(ticket.paymentStatus)) paymentStage = 'DEPOSIT_PENDING'; else if (payable > 0 && outstanding > 0) paymentStage = 'BALANCE_PENDING'; @@ -3862,9 +3869,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') @@ -3879,6 +3929,7 @@ function reconcilePaymentStatus(ticket, user) { ticket.paymentStatus = 'FULLY_PAID'; pushEvent(ticket, user, 'FULLY_PAID', ticket.status, ticket.status, null); maybeAdvanceClosedPaid(ticket, user); + routeAllStockDealToDelivery(ticket, user); } return; } @@ -3895,6 +3946,7 @@ function reconcilePaymentStatus(ticket, user) { ticket.paymentStatus = 'AWAITING_FINAL_PAYMENT'; pushEvent(ticket, user, 'AWAITING_FINAL_PAYMENT', ticket.status, ticket.status, null); } + routeAllStockDealToDelivery(ticket, user); // deposit confirmed: an all-stock deal is ready to deliver } } @@ -3980,9 +4032,16 @@ function warehouseAvailableFor(ticket) { return ticket.fulfillmentStatus === 'GOODS_RECEIVED' || hasReceivedGoods(ticket.id); } +// Mirrors TicketService.depositSettled: paid (or later), or a no-deposit policy at CUSTOMER_CONFIRMED. Stock goods +// are delivered only after this (a deposit NOTICE alone is not money). +function depositSettled(ticket) { + return ['DEPOSIT_PAID', 'AWAITING_FINAL_PAYMENT', 'FULLY_PAID'].includes(ticket.paymentStatus) + || (depositBypassesNotice(ticket) && ticket.paymentStatus === 'CUSTOMER_CONFIRMED'); +} + function deliveryAvailable(ticket) { const stockAvailable = (ticket.items ?? []).some((item) => Number(item.qtyFromStock ?? 0) > Number(item.qtyDelivered ?? 0)); - return ticket.fulfillmentStatus === 'FROM_STOCK' || stockAvailable || warehouseAvailableFor(ticket); + return ((ticket.fulfillmentStatus === 'FROM_STOCK' || stockAvailable) && depositSettled(ticket)) || warehouseAvailableFor(ticket); } function reserveStockForTicket(ticket, user, payload = {}) { @@ -3999,12 +4058,16 @@ function reserveStockForTicket(ticket, user, payload = {}) { total += qty; } pushEvent(ticket, user, 'STOCK_RESERVED', ticket.status, ticket.status, `qty_from_stock=${total}`); + routeAllStockDealToDelivery(ticket, user); +} + +// Mirrors TicketService.routeAllStockDealToDelivery: a deal whose every line is covered from stock (non-empty) +// has no import journey - FROM_STOCK, straight to DELIVERY_SCHEDULING (S18) rather than PROCUREMENT. +function routeAllStockDealToDelivery(ticket, user) { const allCovered = (ticket.items ?? []).length > 0 && ticket.items.every((item) => Number(item.qtyFromStock ?? 0) >= Number(item.qty ?? 0)); if (allCovered && (ticket.fulfillmentStatus == null || ticket.fulfillmentStatus === 'FROM_STOCK')) { ticket.fulfillmentStatus = 'FROM_STOCK'; - // Full stock coverage has no import journey — goods are ready now, so advance - // straight to DELIVERY_SCHEDULING (S18) rather than PROCUREMENT. autoAdvanceStage(ticket, 'DELIVERY_SCHEDULING', user); } } @@ -4029,6 +4092,9 @@ function updateItemWeightMultipliersForTicket(ticket, user, payload = {}) { function recordDeliveryForTicket(ticket, user, payload = {}, completing = false) { const source = String(payload.source ?? '').trim().toUpperCase(); if (!['WAREHOUSE', 'STOCK'].includes(source)) fail('source ต้องเป็น WAREHOUSE หรือ STOCK', 400); + if (source === 'STOCK' && !depositSettled(ticket)) { + fail('ส่งสินค้าจากสต็อกได้หลังรับชำระมัดจำแล้ว (หรือได้รับการยกเว้นมัดจำ) เท่านั้น', 409); + } const lines = payload.lines ?? []; if (!lines.length) fail('ต้องระบุรายการส่งสินค้า', 400); const combined = new Map(); @@ -4610,6 +4676,13 @@ export function setPricingRequestStatusForTests(prId, status) { pr.status = status; } +/** TEST-ONLY seam — not part of `api`. Sets a ticket line's qtyFromStock (a legacy reserve made before walk 4). */ +export function setTicketItemQtyFromStockForTests(ticketId, itemId, qty) { + const item = db.tickets.find((t) => t.id === Number(ticketId))?.items.find((i) => i.id === Number(itemId)); + if (!item) throw new Error(`no ticket item ${itemId}`); + item.qtyFromStock = qty; +} + function mockLiveDirectQuotation(ticketId) { const live = mockDealQuotations .filter((q) => q.ticketId === ticketId && isDealDirectOrigin(q) && isLiveDirectQuotation(q)) @@ -7749,7 +7822,7 @@ function leavePreviewWarnings(leaveType, totalDays) { params: { leaveTypeNameTh: leaveType.nameTh, noticeDays, section: '5', days, }, - messageTh: `การ${leaveType.nameTh}ต้องยื่นล่วงหน้าอย่างน้อย ${noticeDays} วัน (ระเบียบข้อ 5) ` + messageTh: `การ${leaveType.nameTh}ต้องยื่นล่วงหน้าอย่างน้อย ${noticeDays} วันทำงาน (ระเบียบข้อ 5) ` + 'คำขอนี้ยื่นไม่ทันกำหนด ' + `แต่ยังส่งให้หัวหน้างานพิจารณาได้ หากอนุมัติ วันลา ${days} วันจะไม่ได้รับค่าจ้าง`, }], @@ -7956,14 +8029,6 @@ function buildTicketActions(user, ticket) { if (['import', 'ceo'].includes(user.role) && !irTracked && ticket.fulfillmentStatus === 'IR_ISSUED') add('IR_SENT', 'fulfillment', 'ส่งคำขอนำเข้าแล้ว'); if (['import', 'ceo'].includes(user.role) && !irTracked && ticket.fulfillmentStatus === 'IR_SENT') add('SHIPPING', 'fulfillment', 'สินค้าเดินทาง'); if (['import', 'ceo'].includes(user.role) && !irTracked && ticket.fulfillmentStatus === 'SHIPPING') add('GOODS_RECEIVED', 'fulfillment', 'รับสินค้า'); - // S18 owner decision 2026-09-28: CEO or the deal's owning sales rep -- NOT import (dropped - // from `['import', 'ceo']`), mirroring TicketService#canReserveStock -> canDeclareStockCoverage - // and the reserveStock mutation gate above. `owner` is the same limb the delivery - // advertisement uses just below; advertising it to import would offer a button that 403s. - if ((user.role === 'ceo' || owner) && (ticket.items ?? []).length > 0 && hasRemainingDelivery(ticket) - && ticket.fulfillmentStatus !== 'FULLY_DELIVERED') { - add('RESERVE_STOCK', 'fulfillment', 'จองสินค้าจากสต็อก', { requiredFields: ['lines'] }); - } // V148 (per-item stock-commission weighting): sales_manager/ceo only -- mirrors // TicketService#canSetItemWeightMultiplier exactly (ITEM_WEIGHT_ROLES, no ownership/stage // nuance beyond the ticket being ACTIVE, which the enclosing `if (active)` already is). @@ -8068,7 +8133,9 @@ function buildTicketActions(user, ticket) { function mockFinanceAvailableActions(user, ticket) { const all = buildTicketActions(user, ticket).availableActions; const MONEY = ['DEPOSIT_PAID', 'RECORD_PAYMENT', 'FINAL_PAYMENT', 'CONFIRM_CLOSE', 'REVOKE_CLOSE_CONFIRM']; - const moneyStage = (a) => a.action === 'ADVANCE_STAGE' && ['DEPOSIT_RECEIVED', 'CLOSED_PAID'].includes(a.targetStage); + // Mirrors FinanceDealService.availableActions (QA-19): only money stages AHEAD of the deal. + const moneyStage = (a) => a.action === 'ADVANCE_STAGE' && ['DEPOSIT_RECEIVED', 'CLOSED_PAID'].includes(a.targetStage) + && dealStageIndex(a.targetStage) > dealStageIndex(ticket.salesStage); const anyMoneyStage = all.some(moneyStage); return all .filter((a) => MONEY.includes(a.action) || moneyStage(a) || (a.action === 'UPDATE_STAGE' && anyMoneyStage)) @@ -8704,15 +8771,8 @@ export const api = { if (!['DESIGNER_LED', 'OWNER_DIRECT', 'BUYER_DIRECT'].includes(payload.entryChannel)) { fail(`ไม่รองรับช่องทางรับงาน '${payload.entryChannel}'`, 400); } - // Mirrors TicketService.create's V183 item-validation loop: a stock-sourced line must - // carry a real, positive selling price, checked before any write. - for (const item of payload.items || []) { - if (isMockStockSalePriceMissing(item.sourcedFromStock, item.stockSalePrice)) { - fail('ต้องระบุราคาขายเมื่อเลือกสินค้าจากสต็อก', 400); - } - } const nextId = Math.max(...db.tickets.map((t) => t.id)) + 1; - const code = `PR-2026-${String(nextId).padStart(4, '0')}`; + const code = `DL-2026-${String(nextId).padStart(4, '0')}`; const now = new Date().toISOString(); // Every deal begins as a DRAFT at the lead stage, regardless of whether // products were attached at creation time — pricing no longer starts at @@ -8762,9 +8822,9 @@ export const api = { // emptyItemFromTicketItem can seed productId/catalogProductCode without re-searching. catalogPriceId: item.catalogPriceId ?? null, catalogProductCode: item.catalogProductCode ?? null, - // Stock-sourced pricing (V183) — see this method's own validation above. + // The sales-typed stock price is gone (walk 4): only the flag is stored, never a price. sourcedFromStock: Boolean(item.sourcedFromStock), - stockSalePrice: item.sourcedFromStock ? (item.stockSalePrice ?? null) : null, + stockSalePrice: null, })), events: [{ id: nextId * 1000, ticketId: nextId, actorId: user.id, actorName: user.name, kind: 'CREATED', fromStatus: null, toStatus: 'draft', message: null, createdAt: now }], quotation: null, @@ -8802,30 +8862,14 @@ export const api = { const importCanEdit = user.role === 'import' && ['in_review', 'price_proposed'].includes(st); if (!salesCanEdit && !importCanEdit) fail('ไม่มีสิทธิ์แก้ไขรายการสินค้าในสถานะนี้', 403); - // V183 (stock-sourced deal-line pricing): unlike the pricing fields below (guarded to the - // prior row) and unlike catalogPriceId/catalogProductCode (request wins outright, even when - // omitted), sourcedFromStock/stockSalePrice get a NULL-SAFE FALLBACK to the prior row — - // mirrors TicketService.mergeEditedItemsPreservingPricing exactly: a request that omits the - // field (undefined/null) inherits the prior row's value instead of resetting the flag. - (payload.items || []).forEach((item, i) => { - const resolvedSourcedFromStock = item.sourcedFromStock ?? ticket.items[i]?.sourcedFromStock ?? false; - const resolvedStockSalePrice = resolvedSourcedFromStock - ? (item.sourcedFromStock != null ? item.stockSalePrice : ticket.items[i]?.stockSalePrice) ?? null - : null; - if (isMockStockSalePriceMissing(resolvedSourcedFromStock, resolvedStockSalePrice)) { - fail('ต้องระบุราคาขายเมื่อเลือกสินค้าจากสต็อก', 400); - } - }); // Mirrors TicketService.editItems: sales/import editing descriptive fields must // never silently overwrite import's proposed price or CEO's approved/manual price — // pricing fields always come from the existing item at this position, never the // request (2026-07-16 pricing-integrity audit, finding #4). Only proposePrice is // allowed to replace proposedPrice wholesale. ticket.items = (payload.items || []).map((item, i) => { + // sourcedFromStock: a request that omits it inherits the prior row's flag; the price is never stored. const resolvedSourcedFromStock = item.sourcedFromStock ?? ticket.items[i]?.sourcedFromStock ?? false; - const resolvedStockSalePrice = resolvedSourcedFromStock - ? (item.sourcedFromStock != null ? item.stockSalePrice : ticket.items[i]?.stockSalePrice) ?? null - : null; return { ...ticket.items[i], brand: item.brand, model: item.model, @@ -8847,9 +8891,8 @@ export const api = { // because you only discover it in prod. catalogPriceId: item.catalogPriceId ?? null, catalogProductCode: item.catalogProductCode ?? null, - // Stock-sourced pricing (V183) — see this method's own validation above. sourcedFromStock: resolvedSourcedFromStock, - stockSalePrice: resolvedStockSalePrice, + stockSalePrice: null, }; }); ticket.hasEdits = true; @@ -9080,7 +9123,7 @@ export const api = { if (mockHasLiveImportRequests(ticket.id)) { fail('ดีลนี้ติดตามการนำเข้าด้วยใบขอซื้อรายโรงงาน — ต้องบันทึกความคืบหน้าที่ใบขอซื้อของแต่ละโรงงาน', 409); } - if (ticket.fulfillmentStatus !== 'IR_ISSUED') fail('ต้องออกใบขอนำเข้า (IR) ก่อนจึงจะทำเครื่องหมายว่าส่งคำขอนำเข้าแล้วได้', 409); + if (ticket.fulfillmentStatus !== 'IR_ISSUED') fail('ต้องออกใบขอซื้อ (IR) ก่อนจึงจะทำเครื่องหมายว่าส่งคำขอนำเข้าแล้วได้', 409); ticket.fulfillmentStatus = 'IR_SENT'; ticket.updatedAt = new Date().toISOString().slice(0, 10); pushEvent(ticket, user, 'IR_SENT', ticket.status, ticket.status, null); @@ -9251,6 +9294,8 @@ export const api = { ticket.updatedAt = new Date().toISOString().slice(0, 10); pushEvent(ticket, user, 'POLICY_CHANGED', ticket.salesStage, ticket.salesStage, `deposit_policy → ${payload.policy} — ${payload.reason.trim()}`); + // Nothing left to wait for: an all-stock deal at CUSTOMER_CONFIRMED goes straight to delivery. + if (ticket.paymentStatus === 'CUSTOMER_CONFIRMED') routeAllStockDealToDelivery(ticket, user); return delay({ ticket: buildTicketDetail(ticket) }); }, @@ -12211,6 +12256,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, @@ -12237,6 +12283,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, }, }); }, @@ -12370,7 +12423,7 @@ export const api = { // Payment gate FIRST (same predicate as issue() below), before any prompt-producing check. if (!(['DEPOSIT_NOTICE_ISSUED', 'DEPOSIT_PAID', 'AWAITING_FINAL_PAYMENT', 'FULLY_PAID'].includes(ticket.paymentStatus) || (depositBypassesNotice(ticket) && ticket.paymentStatus === 'CUSTOMER_CONFIRMED'))) { - fail('ออกใบขอนำเข้า (IR) ได้เฉพาะเมื่อรับชำระมัดจำแล้ว (หรือได้รับการยกเว้นมัดจำ) เท่านั้น', 409); + fail('ออกใบขอซื้อ (IR) ได้เฉพาะเมื่อรับชำระมัดจำแล้ว (หรือได้รับการยกเว้นมัดจำ) เท่านั้น', 409); } // Supplied per-factory lead times (10-29): 400 on a bad value, before anything is written. const suppliedLeadTimes = new Map(); @@ -12437,6 +12490,20 @@ export const api = { requireStoredIrRead(ticket, requireSession()); return delay({ importRequests: mockImportRequestsForTicket(ticket.id) }); }, + async coverage(ticketId) { + const ticket = findTicketRaw(Number(ticketId)); + requireStoredIrRead(ticket, requireSession()); + // Mirrors ImportRequestService#hasUncoveredFactories: read-only; an unresolved/unknown factory + // (the resolver refuses without a country for a new name) counts as uncovered; only a live row covers. + let hasUncoveredFactories = true; + try { + const live = new Set(mockImportRequests + .filter((r) => r.ticketId === ticket.id && (r.status === 'DRAFT' || r.status === 'ISSUED')) + .map((r) => r.factoryId)); + hasUncoveredFactories = mockResolveFactoryGroups(ticket, null).some((g) => !live.has(g.factoryId)); + } catch { /* unresolved factory -> uncovered */ } + return delay({ hasUncoveredFactories }); + }, async get(id) { const row = findStoredImportRequestRaw(id); requireStoredIrRead(findTicketRaw(row.ticketId), requireSession()); @@ -12503,6 +12570,13 @@ export const api = { requireActive(ticket); if (row.status !== 'DRAFT') fail('ใบขอซื้อฉบับนี้ออกเลขไปแล้ว', 409); if (row.items.length === 0) fail('ใบขอซื้อยังไม่มีรายการสินค้า', 409); + // Mirrors ImportRequestService.issue: a draft naming a line that is now covered from stock is refused. + if (row.items.some((it) => { + const ti = (ticket.items ?? []).find((i) => Number(i.id) === Number(it.ticketItemId)); + return ti && Number(ti.qty) > 0 && Number(ti.qtyFromStock ?? 0) >= Number(ti.qty); + })) { + fail('ใบขอซื้อนี้มีรายการที่ครอบคลุมจากสต็อกแล้ว — ไม่ต้องสั่งนำเข้า กรุณาลบฉบับร่างนี้แล้วสร้างใบขอซื้อใหม่', 409); + } if (row.leadTimeMinDays == null || row.leadTimeMaxDays == null) { fail(`กรุณาระบุระยะเวลานำเข้า (วัน) ของโรงงาน ${row.factoryName}`, 409); } @@ -12521,7 +12595,7 @@ export const api = { || (depositBypassesNotice(ticket) && ticket.paymentStatus === 'CUSTOMER_CONFIRMED'); // Gated on the deposit ALONE (mirrors TicketService#requireImportRequestIssuable, 2026-10-01). if (!depositReadyOrLater) { - fail('ออกใบขอนำเข้า (IR) ได้เฉพาะเมื่อรับชำระมัดจำแล้ว (หรือได้รับการยกเว้นมัดจำ) เท่านั้น', 409); + fail('ออกใบขอซื้อ (IR) ได้เฉพาะเมื่อรับชำระมัดจำแล้ว (หรือได้รับการยกเว้นมัดจำ) เท่านั้น', 409); } const override = request?.docNumber?.trim() || null; if (override && mockImportRequests.some((r) => r.docNumber === override)) { @@ -13217,6 +13291,11 @@ export const api = { async updateStage(id, payload) { const { user, raw } = requireMockFinanceAccess(id); + // Mirrors TicketService.financeUpdateStage (QA-19): account only ever moves a money stage forward. + if (user.role === 'account' && dealStageIndex(payload.stage) >= 0 + && dealStageIndex(payload.stage) < dealStageIndex(raw.salesStage)) { + fail('ฝ่ายบัญชีเลื่อนขั้นได้เฉพาะไปข้างหน้า ย้อนขั้นไม่ได้', 409); + } await mockUpdateStage(id, payload, true); return delay({ deal: buildFinanceDeal(raw, user) }); }, @@ -14525,6 +14604,7 @@ export const api = { const ticket = db.tickets.find((t) => t.id === Number(ticketId)); if (!ticket) fail('ไม่พบดีลนี้', 404); requirePricingRequestDealActive(ticket); + requireMockOrderNotConfirmed(ticket); if (ticket.createdById !== user.id) fail('ไม่มีสิทธิ์เข้าถึงรายการนี้', 403); if (!payload.clientRequestId || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(payload.clientRequestId)) { fail('clientRequestId ต้องเป็น UUID', 400); @@ -14612,6 +14692,8 @@ export const api = { // GLA-125: mirrors PricingRequestService#resolveItem's own normalization — only ever // persisted under the "อื่นๆ" sentinel, cleared for every other origin_country value. originCountryOther: item.originCountry === 'อื่นๆ' ? (item.originCountryOther ?? null) : null, + // Stock line (IN_THAILAND) or null = สั่งนำเข้า: the one writer of ticket qtyFromStock reads it on confirmOrder. + stockSource: item.stockSource ?? null, // Catalog snapshot (Finding A, financial-integrity review commit 3) — populated only // by submit()'s snapshotCatalogSelections mirror below, never at create/update time, // matching PricingRequestRepository.create/updateDraft/snapshotCatalogSelections. @@ -14786,6 +14868,7 @@ export const api = { leadTimeMinDays: item.leadTimeMinDays ?? null, leadTimeMaxDays: item.leadTimeMaxDays ?? null, originCountryOther: item.originCountry === 'อื่นๆ' ? (item.originCountryOther ?? null) : null, + stockSource: item.stockSource ?? null, // See create()'s identical block above for why these start null. priceListVersionId: null, catalogPriceId: null, @@ -16335,6 +16418,8 @@ export const api = { followQuotationDepositPolicy(ticket, mockDealQuotations.find( (q) => q.pricingRequestId === pr.id && q.origin === 'PRICING_REQUEST' && q.docStatus === 'ACCEPTED'), user); autoAdvanceStage(ticket, 'ORDER_RECEIVED', user); + // No deposit to wait for (0% quotation / waived): an all-stock deal goes straight to delivery. + if (depositBypassesNotice(ticket)) routeAllStockDealToDelivery(ticket, user); } pushPricingRequestEvent(pr, user, 'ORDER_CONFIRMED', pr.status, pr.status, 'ยืนยันคำสั่งซื้อแล้ว'); @@ -16426,6 +16511,7 @@ export const api = { if (ticket?.createdById !== user.id) fail('ไม่มีสิทธิ์เข้าถึงรายการนี้', 403); if (pr.status !== 'DRAFT') fail(`ต้องเป็นคำขอราคาที่อยู่ในสถานะ 'DRAFT' เท่านั้น (สถานะปัจจุบัน: '${pr.status}')`, 409); requirePricingRequestDealActive(ticket); + requireMockOrderNotConfirmed(ticket); if (pr.items.length === 0) fail('ต้องมีรายการสินค้าอย่างน้อย 1 รายการก่อนส่งคำขอราคา', 400); if (pr.recipientContactId == null && !pr.recipientLabel?.trim()) fail('ต้องระบุผู้รับคำขอราคา', 400); if (pr.requiredDate && new Date(pr.requiredDate) < new Date(new Date().toDateString())) { @@ -16490,6 +16576,7 @@ export const api = { if (['DRAFT', 'CANCELLED', 'SUPERSEDED'].includes(parent.status)) { fail('สร้างรอบแก้ไขจากการเปลี่ยนแปลงของลูกค้าได้เฉพาะจากคำขอราคาที่ยื่นและยังดำเนินการอยู่เท่านั้น', 409); } + requireMockOrderNotConfirmed(ticket); if (!payload.revisionReason?.trim()) fail('revisionReason ต้องไม่เว้นว่าง', 400); if (!payload.clientRequestId) fail('clientRequestId ต้องเป็น UUID', 400); // GLA-102: mirrors PricingRequestService.createCustomerChangeRevision's diff --git a/frontend/src/api/routes.js b/frontend/src/api/routes.js index 9a9cb2f43..d93c47284 100644 --- a/frontend/src/api/routes.js +++ b/frontend/src/api/routes.js @@ -209,6 +209,7 @@ export const API_ROUTES = { // `importRequests` above, which stays the legacy read-only preview-per-brand family. storedImportRequests: { forTicket: (ticketId) => `/api/tickets/${ticketId}/import-requests`, + coverage: (ticketId) => `/api/tickets/${ticketId}/import-requests/coverage`, get: (id) => `/api/import-requests/${id}`, issue: (id) => `/api/import-requests/${id}/issue`, revise: (id) => `/api/import-requests/${id}/revise`, diff --git a/frontend/src/api/serverContract.test.js b/frontend/src/api/serverContract.test.js index 0660f70fc..7b068d76a 100644 --- a/frontend/src/api/serverContract.test.js +++ b/frontend/src/api/serverContract.test.js @@ -540,6 +540,9 @@ describe('controller surface / hrApi.js contract', () => { * stale-entry test below deletes the entry for you. */ const UNREACHABLE_FROM_UI = new Set([ + // Walk 4 (owner ruling 2026-10-29): there is no reserve-from-stock feature, so the จองสินค้าจากสต็อก UI is + // gone. The endpoint and hrApi method stay (backend test fixtures and old clients still call it). + 'POST /api/tickets/{}/reserve-stock', // W3-04 (owner ruling 2026-10-29): the one-click legacy "issue IR" is closed — the endpoint now // answers 409 for every deal and no screen calls it. Retire the endpoint and hrApi method together // in a follow-up. 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/finance/FinanceDealPage.jsx b/frontend/src/features/finance/FinanceDealPage.jsx index 35cdc14f2..ebfe8568a 100644 --- a/frontend/src/features/finance/FinanceDealPage.jsx +++ b/frontend/src/features/finance/FinanceDealPage.jsx @@ -14,7 +14,7 @@ import { cn } from '../../utils/cn.js'; import { dealStageLabel, formatBangkokTime, formatThaiDate } from '../../utils/format.js'; import { InvoiceFromDealForm } from '../commissions/InvoiceFromDealForm.jsx'; import { emptyInvoiceForm, prepareInvoiceAttachment, toCreateFromDealPayload } from '../commissions/invoiceFromDeal.js'; -import { findStage, useStageCatalog } from '../tickets/stageCatalog.js'; +import { findStage, stageIndexIn, useStageCatalog } from '../tickets/stageCatalog.js'; import { RecordPaymentModal, RevokeCloseModal, StageModal, } from './FinanceActionModals.jsx'; @@ -32,7 +32,8 @@ const ACTION_LABEL = { }; // The stages account may write (DealStage.ACCOUNT_TARGET_STAGES) — only used to label the choices in -// the UPDATE_STAGE form; the server decides whether the move is accepted. +// the UPDATE_STAGE form (only those AHEAD of the deal are offered — account never moves a stage back); +// the server decides whether the move is accepted. const MONEY_STAGE_OPTIONS = ['DEPOSIT_RECEIVED', 'CLOSED_PAID']; function Bone({ className }) { @@ -459,7 +460,8 @@ export function FinanceDealPage() { ) : null} {activeAction?.action === 'UPDATE_STAGE' ? ( ({ value: code, label: `${findStage(catalog, code)?.businessCode ?? ''} ${dealStageLabel(code).label}`.trim() }))} + // Catalog not loaded yet: list both and let the server refuse a backward move (409). + options={MONEY_STAGE_OPTIONS.filter((code) => !catalog?.stages || stageIndexIn(catalog, code) > stageIndexIn(catalog, deal.salesStage)).map((code) => ({ value: code, label: `${findStage(catalog, code)?.businessCode ?? ''} ${dealStageLabel(code).label}`.trim() }))} busy={busy} error={flowError} onSubmit={(body) => run(() => api.finance.updateStage(deal.id, body))} onClose={() => setFlow(null)} diff --git a/frontend/src/features/finance/FinanceDealPage.test.jsx b/frontend/src/features/finance/FinanceDealPage.test.jsx index a70904985..758e3d4ef 100644 --- a/frontend/src/features/finance/FinanceDealPage.test.jsx +++ b/frontend/src/features/finance/FinanceDealPage.test.jsx @@ -754,6 +754,24 @@ describe('FinanceDealPage', () => { await waitFor(() => expect(api.finance.revokeCloseConfirmation).toHaveBeenCalledWith(501, { note: 'ยอดไม่ตรง' })); }); + // QA-19 (owner ruling 2026-10-29): account only ever moves a money stage FORWARD. + async function openStageModal(salesStage) { + const deal = makeFinanceDeal({ salesStage, availableActions: [action('UPDATE_STAGE', 'แก้ไขสถานะ')] }); + api.finance.getDeal.mockResolvedValue({ deal }); + renderPage(); + fireEvent.click(await screen.findByRole('button', { name: 'แก้ไขสถานะ' })); + const dialog = await screen.findByRole('dialog'); + return Array.from(within(dialog).getByLabelText('สถานะที่ต้องการ').querySelectorAll('option')).map((o) => o.value); + } + + it('UPDATE_STAGE lists only the money stages AHEAD of the deal (a delivered deal cannot be sent back to deposit)', async () => { + expect(await openStageModal('DELIVERY_SCHEDULING')).toEqual(['CLOSED_PAID']); + }); + + it('UPDATE_STAGE lists both money stages while the deal is still before the deposit stage', async () => { + expect(await openStageModal('ORDER_RECEIVED')).toEqual(['DEPOSIT_RECEIVED', 'CLOSED_PAID']); + }); + it('ADVANCE_STAGE (money stage) confirms, then posts the action targetStage to api.finance.updateStage', async () => { const deal = makeFinanceDeal({ availableActions: [action('ADVANCE_STAGE', 'เลื่อนสถานะ', { targetStage: 'CLOSED_PAID' })] }); api.finance.getDeal.mockResolvedValue({ deal }); diff --git a/frontend/src/features/fulfilment/ImportFulfilmentPage.test.jsx b/frontend/src/features/fulfilment/ImportFulfilmentPage.test.jsx index e405376b1..0261917bd 100644 --- a/frontend/src/features/fulfilment/ImportFulfilmentPage.test.jsx +++ b/frontend/src/features/fulfilment/ImportFulfilmentPage.test.jsx @@ -245,6 +245,12 @@ describe('ImportFulfilmentPage (per-factory, stored aggregate)', () => { expect(screen.getByText('ส่งอีเมลแล้ว')).not.toBeNull(); }); + it('the งานนำเข้า order-email button stays, labelled without the ✉ emoji', async () => { + renderPage(); + const card = await screen.findByTestId('fulfilment-deal'); + expect(within(card).getByTestId('order-email-1').textContent).toBe('อีเมลสั่งซื้อ'); + }); + it('opens the order-email modal and marks it sent — import/CEO can send it', async () => { renderPage(); const card = await screen.findByTestId('fulfilment-deal'); diff --git a/frontend/src/features/importDeal/ImportDealPage.jsx b/frontend/src/features/importDeal/ImportDealPage.jsx index 476051ca2..20b50c130 100644 --- a/frontend/src/features/importDeal/ImportDealPage.jsx +++ b/frontend/src/features/importDeal/ImportDealPage.jsx @@ -276,6 +276,7 @@ export function ImportDealPage({ user, showToast }) { canEmailWrite={canEmailWrite} showToast={showToast} showInlineDownload + showOrderEmail /> ))}
diff --git a/frontend/src/features/importDeal/ImportDealPage.test.jsx b/frontend/src/features/importDeal/ImportDealPage.test.jsx index 4a4a1810a..363d7f1ff 100644 --- a/frontend/src/features/importDeal/ImportDealPage.test.jsx +++ b/frontend/src/features/importDeal/ImportDealPage.test.jsx @@ -166,7 +166,7 @@ describe('ImportDealPage', () => { it('shows delivery status read-only: the fulfilment label and delivered/ordered per item, no write control', async () => { renderPage(); const panel = await screen.findByTestId('import-deal-delivery'); - expect(within(panel).getByText('ออกคำขอนำเข้าแล้ว')).not.toBeNull(); + expect(within(panel).getByText('ออกใบขอซื้อแล้ว')).not.toBeNull(); expect(within(panel).getByTestId('import-deal-delivery-item-201').textContent).toMatch(/40\s*\/\s*120/); expect(within(panel).getByTestId('import-deal-delivery-item-202').textContent).toMatch(/0\s*\/\s*50/); // Delivery is Sales's — the section carries no button or input at all. @@ -238,6 +238,25 @@ describe('ImportDealPage', () => { expect(screen.getByTestId('ir-email-open-11')).not.toBeNull(); }); + it('the import-side card keeps the order-email button, labelled without the ✉ emoji', async () => { + renderPage({ role: 'import' }); + expect((await screen.findByTestId('ir-email-open-11')).textContent).toBe('อีเมลสั่งซื้อ'); + }); + + // Moved here from the sales deal tab, which no longer shows the order email: the save payload must + // use Java's field names (UpdateEmailDraftRequest), not to/subject/body (Jackson silently drops those). + it('saving the order-email draft sends emailTo/emailSubject/emailBody', async () => { + api.storedImportRequests.updateEmailDraft.mockResolvedValue({ importRequest: irRow() }); + renderPage({ role: 'import' }); + fireEvent.click(await screen.findByTestId('ir-email-open-11')); + const subject = await screen.findByDisplayValue('Purchase order IR26001 - GL&R'); + fireEvent.change(subject, { target: { value: 'Revised' } }); + fireEvent.click(screen.getByTestId('ir-email-save-11')); + await waitFor(() => expect(api.storedImportRequests.updateEmailDraft).toHaveBeenCalledWith(11, expect.objectContaining({ + emailSubject: 'Revised', emailBody: 'Dear Cotto Industry team,...', + }))); + }); + it('surfaces a per-factory ใบขอซื้อ PDF download on each issued card, and it downloads', async () => { api.storedImportRequests.download.mockResolvedValue(new Blob(['pdf'], { type: 'application/pdf' })); renderPage({ role: 'import' }); @@ -478,7 +497,7 @@ describe('ImportDealPage', () => { expect(within(strip).getByTestId('import-deal-ir-rollup-chip').textContent).toMatch(/ถึงไทย 1\/2 โรงงาน/); // Item bars are 33% (40/120) and 0% (0/50): the strip shows their mean, 17%. expect(within(strip).getByTestId('import-deal-delivery-chip').textContent).toMatch(/ส่งมอบ 17%/); - expect(within(strip).getByText('ออกคำขอนำเข้าแล้ว')).not.toBeNull(); + expect(within(strip).getByText('ออกใบขอซื้อแล้ว')).not.toBeNull(); }); it('omits the factory chip when nothing is issued, and the delivery chip when there are no items', async () => { diff --git a/frontend/src/features/importProgress/FactoryProgressBar.jsx b/frontend/src/features/importProgress/FactoryProgressBar.jsx index 42f3b9365..b387cef91 100644 --- a/frontend/src/features/importProgress/FactoryProgressBar.jsx +++ b/frontend/src/features/importProgress/FactoryProgressBar.jsx @@ -98,7 +98,7 @@ export function FactoryProgressBar({
{onOpenEmail ? ( ) : null} {editable && ahead.length > 0 ? ( diff --git a/frontend/src/features/importProgress/importSteps.js b/frontend/src/features/importProgress/importSteps.js index fe0e31584..4cb4cb29d 100644 --- a/frontend/src/features/importProgress/importSteps.js +++ b/frontend/src/features/importProgress/importSteps.js @@ -45,3 +45,22 @@ export function stepsAhead(code) { const i = importStepIndex(code); return i < 0 ? IMPORT_STEPS : IMPORT_STEPS.slice(i + 1); } + +// Deal rollups at or past goods-received: the deal-level fulfilment label already says it. +const PAST_IMPORT = new Set(['GOODS_RECEIVED', 'PARTIALLY_DELIVERED', 'FULLY_DELIVERED', 'FROM_STOCK']); + +/** + * The deal header's การนำเข้า chip text from the per-factory IR steps (QA-22): the SLOWEST live tracked + * factory's step label, with " · ถึงโกดัง X/Y" appended when more than one factory is tracked. null means + * "keep the deal-level fulfilment label" — no live tracked IR, or the rollup is already past import. + */ +export function importChipLabel(importRequests, fulfillmentStatus) { + if (PAST_IMPORT.has(fulfillmentStatus)) return null; + const tracked = (importRequests ?? []).filter((r) => r.status === 'ISSUED' && importStepIndex(r.importStep) >= 0); + if (tracked.length === 0) return null; + const slowest = tracked.reduce((a, b) => (importStepIndex(b.importStep) < importStepIndex(a.importStep) ? b : a)); + const label = importStepMeta(slowest.importStep).label; + if (tracked.length === 1) return label; + const received = tracked.filter((r) => r.importStep === 'RECEIVED').length; + return received === tracked.length ? label : `${label} · ถึงโกดัง ${received}/${tracked.length}`; +} diff --git a/frontend/src/features/importProgress/importSteps.test.js b/frontend/src/features/importProgress/importSteps.test.js new file mode 100644 index 000000000..2f1ace271 --- /dev/null +++ b/frontend/src/features/importProgress/importSteps.test.js @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; +import * as importSteps from './importSteps.js'; + +// QA-22 (owner ruling 2026-10-29): the deal header's การนำเข้า chip follows the per-factory IR steps. +// Contract pinned here: importChipLabel(importRequests, fulfillmentStatus) -> string, or null when the +// caller should keep today's fulfilment label ("ยังไม่เริ่ม" / fulfilmentStatusLabel(fulfillmentStatus)). +// Looked up off the namespace so a missing export fails on the assertion, not on a TypeError. +const importChipLabel = importSteps.importChipLabel ?? (() => undefined); + +const ir = (importStep, status = 'ISSUED') => ({ status, importStep }); + +describe('importChipLabel', () => { + it('shows the slowest live tracked factory step, with arrivals when there are several factories', () => { + expect(importChipLabel([ir('IN_TRANSIT'), ir('RECEIVED')], 'IR_ISSUED')).toBe('กำลังเดินทาง · ถึงโกดัง 1/2'); + expect(importChipLabel([ir('RECEIVED'), ir('CONTACTED'), ir('IN_TRANSIT')], 'IR_ISSUED')) + .toBe('ส่ง IR ให้จัดซื้อ · ถึงโกดัง 1/3'); + }); + + it('one factory: the step label alone', () => { + expect(importChipLabel([ir('ORDERED')], 'IR_ISSUED')).toBe('สั่งซื้อผู้ผลิต'); + }); + + it('skips superseded and untracked (no importStep) rows', () => { + expect(importChipLabel([ir('IN_TRANSIT'), ir('ORDERED', 'SUPERSEDED'), ir(null, 'DRAFT')], 'IR_ISSUED')) + .toBe('กำลังเดินทาง'); + }); + + it.each(['IR_ISSUED', 'IR_SENT'])( + 'every tracked factory RECEIVED while the rollup is still %s: the step label alone, no counter', + (fulfillmentStatus) => { + expect(importChipLabel([ir('RECEIVED'), ir('RECEIVED')], fulfillmentStatus)).toBe('ถึงโกดัง'); + expect(importChipLabel([ir('RECEIVED')], fulfillmentStatus)).toBe('ถึงโกดัง'); + }, + ); + + it('a revised factory counts once: v1 SUPERSEDED at ORDERED is ignored, v2 ISSUED at RECEIVED is the factory', () => { + expect(importChipLabel([ir('ORDERED', 'SUPERSEDED'), ir('RECEIVED')], 'IR_ISSUED')).toBe('ถึงโกดัง'); + }); + + it('a DRAFT row with a step is not live either', () => { + expect(importChipLabel([ir('ORDERED', 'DRAFT')], 'IR_ISSUED')).toBeNull(); + }); + + it.each([undefined, null, []])('returns null with no rows (%j) so the caller keeps the fulfilment label', (rows) => { + expect(importChipLabel(rows, 'IR_ISSUED')).toBeNull(); + }); + + it('returns null when every row is superseded or untracked', () => { + expect(importChipLabel([ir('ORDERED', 'SUPERSEDED'), ir(null, 'DRAFT')], 'IR_ISSUED')).toBeNull(); + }); + + it.each(['GOODS_RECEIVED', 'PARTIALLY_DELIVERED', 'FULLY_DELIVERED', 'FROM_STOCK'])( + 'returns null once the deal rollup is past import (%s)', + (fulfillmentStatus) => { + expect(importChipLabel([ir('RECEIVED'), ir('IN_TRANSIT')], fulfillmentStatus)).toBeNull(); + }, + ); +}); diff --git a/frontend/src/features/leave/LeaveRequestPage.jsx b/frontend/src/features/leave/LeaveRequestPage.jsx index 9b15d9f13..00d1d3b26 100644 --- a/frontend/src/features/leave/LeaveRequestPage.jsx +++ b/frontend/src/features/leave/LeaveRequestPage.jsx @@ -81,7 +81,8 @@ const BACKDATE_ALLOWED_LEAVE_TYPE_CODES = new Set(['SICK']); // and not HR-only (an employee filing for themselves is the whole point, e.g. filed on the 28th for // leave taken on the 26th). The backend already accepts this shape with no frontend change: // LeaveService#autoRejectNote's §5.2 emergency branch treats a backdated PERSONAL request -// (startDate.isBefore(today + advanceNoticeDays), true for PERSONAL since advance_notice_days=1) +// (fewer than advanceNoticeDays working days after today up to the leave's first working day, true +// for PERSONAL since advance_notice_days=1) // as an emergency exception when requestedAsEmergency is set and the monthly tolerance (3 // occurrences) has not been exhausted -- approved with emergencyFilingApplied=true. Once that // tolerance is exhausted the outcome becomes EMERGENCY_TOLERANCE_EXHAUSTED, which since V164 is diff --git a/frontend/src/features/pricingRequests/PricingRequestCreateModal.jsx b/frontend/src/features/pricingRequests/PricingRequestCreateModal.jsx index c477a608b..e493c9915 100644 --- a/frontend/src/features/pricingRequests/PricingRequestCreateModal.jsx +++ b/frontend/src/features/pricingRequests/PricingRequestCreateModal.jsx @@ -468,6 +468,9 @@ export function PricingRequestCreateModal({ // ticket_item rows instead. const seedsFromExisting = isEdit || isRevision; const initialSummary = initialValue?.summary ?? null; + // QA-12 (owner ruling 2026-10-29): the backend (GLA-102 requireUnchangedRecipientType) 409s any + // recipientType change on a revision child — createCustomerChangeRevision and updateDraft alike. + const recipientLocked = isRevision || (isEdit && initialSummary?.parentPricingRequestId != null); const seededRecipientType = seedsFromExisting ? (initialSummary?.recipientType ?? 'DESIGNER') : (initialRecipientType ?? initialSummary?.recipientType ?? (recipientForEntryChannel(deal?.entryChannel) || 'DESIGNER')); @@ -985,16 +988,22 @@ export function PricingRequestCreateModal({ type="button" role="radio" aria-checked={selected} - className={`rounded-full border px-3 py-1.5 text-xs font-bold ${ + disabled={recipientLocked} + className={`rounded-full border px-3 py-1.5 text-xs font-bold disabled:cursor-not-allowed ${ selected ? 'border-primary bg-primary/10 text-primary' : 'border-border bg-surface text-text-muted' - }`} - onClick={() => setRecipientType(option.code)} + } ${recipientLocked && !selected ? 'opacity-60' : ''}`} + onClick={() => { if (!recipientLocked) setRecipientType(option.code); }} > {option.label} ); })}
+ {recipientLocked ? ( +

+ เปลี่ยนผู้รับไม่ได้ในรอบแก้ไข — หากต้องการเสนอราคาให้ผู้รับรายอื่น ให้สร้างคำขอราคาใหม่ +

+ ) : null}
@@ -4439,6 +4446,20 @@ export function PricingRequestDetailPage({ user, showToast }) { }} /> + setOutcomeToConfirm(null)} + onConfirm={(reason) => { + const { engine, quotation, outcome } = outcomeToConfirm; + // Close only on success: a failed call keeps the dialog (and the typed reason) for a same-key retry. + (engine === 'deal' ? recordDealQuotationOutcome : recordQuotationOutcome) + .mutate({ quotation, outcome, note: reason }, { onSuccess: () => setOutcomeToConfirm(null) }); + }} + /> + {emailModalQuote ? ( { const request = buildRequest({ summary }); renderDetailPage({ user, request }); @@ -3276,12 +3277,14 @@ describe('PricingRequestDetailPage GLA-123 slice S3: new-engine (dealQuotationFo await waitForLoaded(request); await screen.findByText(issued.number); + // W3-06: ปฏิเสธ/ขอแก้ไข take their reason from a dialog; ลูกค้ายอมรับ still sends the inline note. + fireEvent.change(screen.getByPlaceholderText('หมายเหตุจากลูกค้า (ถ้ามี)'), { target: { value: 'ส่งของต้นเดือน' } }); const acceptButton = await screen.findByRole('button', { name: 'ลูกค้ายอมรับ' }); fireEvent.click(acceptButton); await waitFor(() => expect(api.dealQuotations.recordOutcome).toHaveBeenCalledWith( issued.id, - expect.objectContaining({ outcome: 'ACCEPTED', clientRequestId: expect.any(String) }), + expect.objectContaining({ outcome: 'ACCEPTED', customerNote: 'ส่งของต้นเดือน', clientRequestId: expect.any(String) }), )); expect(screen.queryByRole('dialog')).toBeNull(); }); @@ -3345,7 +3348,11 @@ describe('PricingRequestDetailPage GLA-123 slice S3: new-engine (dealQuotationFo await waitForLoaded(); await screen.findByText(issued.number); + // W3-06: ปฏิเสธ now asks for a reason first. fireEvent.click(await screen.findByRole('button', { name: 'ลูกค้าปฏิเสธ' })); + const dialog = within(await screen.findByRole('dialog')); + fireEvent.change(dialog.getByRole('textbox'), { target: { value: 'ลูกค้าไม่เอาแล้ว' } }); + fireEvent.click(dialog.getByRole('button', { name: 'ยืนยัน' })); await waitFor(() => expect(api.dealQuotations.recordOutcome).toHaveBeenCalled()); await waitFor(() => { @@ -3468,6 +3475,10 @@ describe('PricingRequestDetailPage GLA-123 slice S3: new-engine (dealQuotationFo const callsBeforeOutcome = api.dealQuotations.findForPricingRequest.mock.calls.length; const reviseButton = screen.getByRole('button', { name: 'ลูกค้าขอแก้ไข' }); fireEvent.click(reviseButton); + // W3-06: ขอแก้ไข now asks for a reason first. + const dialog = within(await screen.findByRole('dialog')); + fireEvent.change(dialog.getByRole('textbox'), { target: { value: 'ลูกค้าขอเปลี่ยนราคา' } }); + fireEvent.click(dialog.getByRole('button', { name: 'ยืนยัน' })); await waitFor(() => expect(api.dealQuotations.recordOutcome).toHaveBeenCalledWith( issued.id, @@ -3555,6 +3566,188 @@ describe('PricingRequestDetailPage Step 5: Customer Decision and Commercial Revi }); }); +// W3-06 / QA-35: ลูกค้าปฏิเสธ / ลูกค้าขอแก้ไข are irreversible (the quotation leaves ISSUED), so both engines +// ask for a reason in the shared ConfirmDialog before recording. Each outcome also owns its own +// clientRequestId: the backend replays a reused key as the FIRST outcome's success, so one shared key would +// let a failed ปฏิเสธ followed by ขอแก้ไข silently record nothing/the wrong thing. +describe.each([ + { + engine: 'new engine (dealQuotations.recordOutcome)', + recordMock: () => api.dealQuotations.recordOutcome, + setup: () => { + const issued = { id: 9101, number: 'QT-2026-0101-1', docStatus: 'ISSUED' }; + api.dealQuotations.findForPricingRequest.mockResolvedValue({ quotation: issued }); + api.dealQuotations.recordOutcome.mockResolvedValue({ quotation: { ...issued, docStatus: 'REJECTED' } }); + return issued; + }, + // A different ISSUED quotation (B) becomes the request's current one. + later: () => { + const other = { id: 9102, number: 'QT-2026-0102-1', docStatus: 'ISSUED' }; + api.dealQuotations.findForPricingRequest.mockResolvedValue({ quotation: other }); + return other; + }, + }, + { + engine: 'legacy engine (pricingRequests.recordCustomerQuotationOutcome)', + recordMock: () => api.pricingRequests.recordCustomerQuotationOutcome, + setup: () => { + const issued = buildCustomerQuotation({ docStatus: 'ISSUED' }); + api.pricingRequests.listCustomerQuotations.mockResolvedValue({ items: [issued] }); + api.pricingRequests.recordCustomerQuotationOutcome.mockResolvedValue({ quotation: { ...issued, docStatus: 'REJECTED' } }); + return issued; + }, + later: () => { + const other = buildCustomerQuotation({ id: 5502, number: 'QT-2026-0002', docStatus: 'ISSUED' }); + api.pricingRequests.listCustomerQuotations.mockResolvedValue({ items: [other] }); + return other; + }, + }, +])('PricingRequestDetailPage W3-06/QA-35: ปฏิเสธ / ขอแก้ไข confirm with a reason — $engine', ({ recordMock, setup, later }) => { + let page; + async function renderIssued() { + const issued = setup(); + page = renderDetailPage({ user: salesOwner }); + await waitForLoaded(); + await screen.findByText(issued.number); + return issued; + } + + // Click the outcome button; the API must not be touched until the dialog is confirmed. + async function openDialog(buttonName) { + const callsBefore = recordMock().mock.calls.length; + fireEvent.click(await screen.findByRole('button', { name: buttonName })); + expect(recordMock()).toHaveBeenCalledTimes(callsBefore); + return within(await screen.findByRole('dialog')); + } + + // Back out of the dialog if the failed call left it open (it should; the key tests don't depend on that). + async function cancelDialog() { + const open = screen.queryByRole('dialog'); + if (open) fireEvent.click(within(open).getByRole('button', { name: 'ยกเลิก' })); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + } + + // The failed call has settled (its error toast fired) -- only then is "the dialog is still open" meaningful. + async function failureSettled() { + await waitFor(() => expect(page.showToast).toHaveBeenCalledWith('error', 'network down')); + } + + async function submit(dialog, reason) { + fireEvent.change(dialog.getByRole('textbox'), { target: { value: reason } }); + fireEvent.click(dialog.getByRole('button', { name: 'ยืนยัน' })); + } + + it.each([ + ['ลูกค้าปฏิเสธ', 'REJECTED'], + ['ลูกค้าขอแก้ไข', 'REVISION_REQUESTED'], + ])('%s opens a dialog, keeps ยืนยัน disabled until a non-blank reason, then records %s with customerNote = the reason', async (buttonName, outcome) => { + const issued = await renderIssued(); + const dialog = await openDialog(buttonName); + + const confirm = dialog.getByRole('button', { name: 'ยืนยัน' }); + expect(confirm.disabled).toBe(true); + fireEvent.change(dialog.getByRole('textbox'), { target: { value: ' ' } }); + expect(confirm.disabled).toBe(true); + fireEvent.change(dialog.getByRole('textbox'), { target: { value: 'ราคาสูงกว่างบ' } }); + expect(confirm.disabled).toBe(false); + fireEvent.click(confirm); + + await waitFor(() => expect(recordMock()).toHaveBeenCalledTimes(1)); + expect(recordMock()).toHaveBeenCalledWith( + issued.id, + expect.objectContaining({ outcome, customerNote: 'ราคาสูงกว่างบ', clientRequestId: expect.any(String) }), + ); + }); + + it.each(['ลูกค้าปฏิเสธ', 'ลูกค้าขอแก้ไข'])('%s: ยกเลิก in the dialog records nothing', async (buttonName) => { + await renderIssued(); + const dialog = await openDialog(buttonName); + fireEvent.change(dialog.getByRole('textbox'), { target: { value: 'พิมพ์แล้วแต่เปลี่ยนใจ' } }); + fireEvent.click(dialog.getByRole('button', { name: 'ยกเลิก' })); + + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + await act(async () => { await new Promise((resolve) => { setTimeout(resolve, 30); }); }); + expect(recordMock()).not.toHaveBeenCalled(); + }); + + it('each outcome has its own clientRequestId: after a FAILED ปฏิเสธ, ขอแก้ไข does not reuse its key', async () => { + await renderIssued(); + recordMock().mockRejectedValueOnce(new Error('network down')); + + let dialog = await openDialog('ลูกค้าปฏิเสธ'); + await submit(dialog, 'เหตุผล ก'); + await waitFor(() => expect(recordMock()).toHaveBeenCalledTimes(1)); + const rejectKey = recordMock().mock.calls[0][1].clientRequestId; + await failureSettled(); + await cancelDialog(); + + dialog = await openDialog('ลูกค้าขอแก้ไข'); + await submit(dialog, 'เหตุผล ข'); + await waitFor(() => expect(recordMock()).toHaveBeenCalledTimes(2)); + const [, reviseBody] = recordMock().mock.calls[1]; + expect(reviseBody.outcome).toBe('REVISION_REQUESTED'); + expect(reviseBody.clientRequestId).not.toBe(rejectKey); + }); + + it('a FAILED outcome keeps the dialog open with the typed reason; ยืนยัน again retries with the SAME key and then closes it', async () => { + await renderIssued(); + recordMock().mockRejectedValueOnce(new Error('network down')); + + const dialog = await openDialog('ลูกค้าปฏิเสธ'); + await submit(dialog, 'เหตุผล ก'); + await waitFor(() => expect(recordMock()).toHaveBeenCalledTimes(1)); + await failureSettled(); + + const still = screen.queryByRole('dialog'); + expect(still, 'dialog must stay open after a failed record').not.toBeNull(); + expect(within(still).getByRole('textbox').value).toBe('เหตุผล ก'); + + fireEvent.click(within(still).getByRole('button', { name: 'ยืนยัน' })); + await waitFor(() => expect(recordMock()).toHaveBeenCalledTimes(2)); + const [first, second] = recordMock().mock.calls.map(([, body]) => body.clientRequestId); + expect(second).toBe(first); + expect(recordMock().mock.calls[1][1].customerNote).toBe('เหตุผล ก'); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + }); + + it('a SUCCESSFUL outcome rotates the key, so the next recording is never replayed as the old one', async () => { + await renderIssued(); + + let dialog = await openDialog('ลูกค้าปฏิเสธ'); + await submit(dialog, 'เหตุผล ก'); + await waitFor(() => expect(recordMock()).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + + dialog = await openDialog('ลูกค้าปฏิเสธ'); + await submit(dialog, 'เหตุผล ข'); + await waitFor(() => expect(recordMock()).toHaveBeenCalledTimes(2)); + const [first, second] = recordMock().mock.calls.map(([, body]) => body.clientRequestId); + expect(second).not.toBe(first); + }); + + it('the key belongs to the quotation: a failed ปฏิเสธ on A, then a different ISSUED quotation B, sends B a fresh key (never A\'s)', async () => { + const quotationA = await renderIssued(); + recordMock().mockRejectedValueOnce(new Error('network down')); + + let dialog = await openDialog('ลูกค้าปฏิเสธ'); + await submit(dialog, 'เหตุผล ก'); + await waitFor(() => expect(recordMock()).toHaveBeenCalledTimes(1)); + await failureSettled(); + await cancelDialog(); + + const quotationB = later(); + await act(async () => { await page.queryClient.invalidateQueries(); }); + await screen.findByText(quotationB.number); + + dialog = await openDialog('ลูกค้าปฏิเสธ'); + await submit(dialog, 'เหตุผล ก'); + await waitFor(() => expect(recordMock()).toHaveBeenCalledTimes(2)); + const [[idA, bodyA], [idB, bodyB]] = recordMock().mock.calls; + expect([idA, idB]).toEqual([quotationA.id, quotationB.id]); + expect(bodyB.clientRequestId).not.toBe(bodyA.clientRequestId); + }); +}); + describe('PricingRequestDetailPage Step 6: Deposit, Payment, and Order Confirmation', () => { // Owner ruling 2026-10-23: one deposit notice per deal. Once one is ISSUED the create action is // gone (the backend 409s it) and the block says which notice exists and where to open it. @@ -5679,3 +5872,111 @@ describe('PricingRequestDetailPage ยืนยันราคาเสนอ re await waitFor(() => expect(notice()).toBeNull()); }); }); + +// Walk 4 (owner rulings, Ploy 2026-10-29): a stock line (stockSource IN_THAILAND) is priced by the CEO and +// then waits for delivery - it is never ordered from a factory, so import is not asked to contact one for +// it; the request page says which lines are stock; and once the deal's order is confirmed no new pricing +// round starts from it. +describe('PricingRequestDetailPage - stock lines and the confirmed order (Walk 4)', () => { + const baseItem = buildRequest().items[0]; + const stockRequestItem = (overrides = {}) => ({ + ...baseItem, id: 2, model: 'S1', productDescription: 'กระเบื้อง S1 จากสต็อก', stockSource: 'IN_THAILAND', + originCountry: null, leadTimeMinDays: null, leadTimeMaxDays: null, resolvedFactoryName: null, ...overrides, + }); + const stockDecisionItem = (overrides = {}) => buildDecisionItem({ + id: 8002, pricingRequestItemId: 2, pricingCostingItemId: null, stockSource: 'IN_THAILAND', + frozenLandedCostPerRequestedUnitThb: null, sqmPerPiece: 0.36, listUnitPrice: 150, netUnitPrice: 150, ...overrides, + }); + + // W4-03: a return-to-import leaves a RETURNED decision behind; when the request comes back READY, the CEO + // starts a fresh review. The page used to show the RETURNED decision instead and no start button. + it('W4-03: a READY_FOR_CEO_REVIEW request whose only decision was RETURNED offers เริ่มพิจารณาราคาขาย', async () => { + const request = buildRequest({ summary: { status: 'READY_FOR_CEO_REVIEW' } }); + api.pricingRequests.listPricingDecisions.mockResolvedValue({ items: [buildDecision({ status: 'RETURNED' })] }); + renderDetailPage({ user: ceoUser, request }); + await waitForLoaded(request); + await waitFor(() => expect(api.pricingRequests.listPricingDecisions).toHaveBeenCalled()); + + expect(await screen.findByTestId('pcr-ceo-start-review')).not.toBeNull(); + }); + + // W4-06 (default pending Ploy's confirm): with no import line there is no import cost to fix, so the CEO's + // ตีกลับให้ฝ่ายนำเข้าแก้ไข is hidden (the backend 409 stays); with any import line it is still there. + async function renderCeoReviewing(requestItems, decisionItems) { + const request = buildRequest({ summary: { status: 'CEO_REVIEWING' }, items: requestItems }); + api.pricingRequests.listPricingDecisions.mockResolvedValue({ + items: [buildDecision({ priceMode: 'NET', items: decisionItems })], + }); + renderDetailPage({ user: ceoUser, request }); + await waitForLoaded(request); + await screen.findByText('PCD-2026-0001'); + await screen.findByTestId('pcr-ceo-approve'); + } + + it('W4-06: on a request whose every line is a stock line the CEO is not offered ตีกลับให้ฝ่ายนำเข้าแก้ไข', async () => { + await renderCeoReviewing([stockRequestItem({ id: 1 })], [stockDecisionItem({ pricingRequestItemId: 1 })]); + expect(screen.queryByRole('button', { name: 'ตีกลับให้ฝ่ายนำเข้าแก้ไข' })).toBeNull(); + }); + + it('W4-06: on a mixed request the CEO still has ตีกลับให้ฝ่ายนำเข้าแก้ไข (wrong-way-round)', async () => { + await renderCeoReviewing([baseItem, stockRequestItem()], + [buildDecisionItem({ sqmPerPiece: 0.36, listUnitPrice: 100, netUnitPrice: 100 }), stockDecisionItem()]); + expect(screen.getByRole('button', { name: 'ตีกลับให้ฝ่ายนำเข้าแก้ไข' })).not.toBeNull(); + }); + + // W4-01: the CEO's line cards say which lines are stock (the badge exists on the deal page already; the + // request pages had none). + it('W4-01: the CEO pricing panel marks a stock line จากสต็อก and an import line not', async () => { + await renderCeoReviewing([baseItem, stockRequestItem()], + [buildDecisionItem({ sqmPerPiece: 0.36, listUnitPrice: 100, netUnitPrice: 100 }), stockDecisionItem()]); + const panel = screen.getByRole('heading', { level: 2, name: 'รายการสินค้าและราคาตั้งต้น' }).closest('section'); + expect(within(panel).getAllByText('จากสต็อก')).toHaveLength(1); + }); + + describe('import view', () => { + const importRequest = (items) => buildRequest({ summary: { status: 'IMPORT_REVIEWING' }, items }); + const importLine = { ...baseItem, resolvedFactoryName: 'Factory A', factory: null }; + + it('W4-01: import sees a จากสต็อก badge on a stock line, and no factory mail/contact group for a stock-only factory', async () => { + // The stock line resolves to Factory B through its catalog product - the exact case that used to + // put a "Factory B - สร้างเมล / ติดต่อโรงงานแล้ว" group on screen for goods nobody orders from B. + const request = importRequest([importLine, stockRequestItem({ resolvedFactoryName: 'Factory B' })]); + renderDetailPage({ user: importUser, request }); + await waitForLoaded(request); + + const groups = await screen.findAllByTestId('pcr-price-preview-group'); + expect(groups).toHaveLength(1); + expect(groups[0].textContent).toContain('Factory A'); + expect(groups[0].textContent).not.toContain('Factory B'); + expect(screen.getAllByText('จากสต็อก').length).toBeGreaterThanOrEqual(1); + }); + + it('W4-01: a stock line with no factory is not reported as "ยังไม่ได้ระบุโรงงาน"', async () => { + const request = importRequest([importLine, stockRequestItem()]); + renderDetailPage({ user: importUser, request }); + await waitForLoaded(request); + await screen.findAllByTestId('pcr-price-preview-group'); + + expect(screen.queryByText(/ยังไม่ได้ระบุโรงงาน \d+ รายการ/)).toBeNull(); + }); + }); + + // W4-04: the PCR summary carries the deal's payment status (null until the order is confirmed); the + // backend refuses a new round once it is set, so the page does not offer สร้างรอบแก้ไข. + describe('สร้างรอบแก้ไข once the deal order is confirmed', () => { + it('is offered to the owner before the order is confirmed', async () => { + const request = buildRequest({ summary: { status: 'QUOTATION_ISSUED', ticketPaymentStatus: null } }); + renderDetailPage({ user: salesOwner, request }); + await waitForLoaded(request); + expect(screen.getByRole('button', { name: 'สร้างรอบแก้ไข' })).not.toBeNull(); + }); + + it('is NOT offered once the deal order is confirmed (ticketPaymentStatus set)', async () => { + const request = buildRequest({ summary: { status: 'QUOTATION_ISSUED', ticketPaymentStatus: 'CUSTOMER_CONFIRMED' } }); + renderDetailPage({ user: salesOwner, request }); + await waitForLoaded(request); + await screen.findByText('PCR-2026-0001'); + expect(screen.queryByRole('button', { name: 'สร้างรอบแก้ไข' })).toBeNull(); + }); + }); +}); diff --git a/frontend/src/features/pricingRequests/PricingRequestPanel.test.jsx b/frontend/src/features/pricingRequests/PricingRequestPanel.test.jsx index 3a4e4969b..04321c227 100644 --- a/frontend/src/features/pricingRequests/PricingRequestPanel.test.jsx +++ b/frontend/src/features/pricingRequests/PricingRequestPanel.test.jsx @@ -145,6 +145,22 @@ describe('PricingRequestPanel', () => { expect(screen.queryByRole('dialog')).toBeNull(); }); + it('openCreate() is a no-op once the deal order is confirmed (walk 4: no new pricing round)', async () => { + api.pricingRequests.listForTicket.mockResolvedValue({ items: [] }); + const ref = React.createRef(); + render( + + + , + ); + await screen.findByText('ยังไม่มีคำขอราคา'); + + act(() => ref.current.openCreate()); + + expect(screen.queryByRole('dialog')).toBeNull(); + }); + it('openCreate() opens the create modal for the owning sales rep', async () => { api.pricingRequests.listForTicket.mockResolvedValue({ items: [] }); const ref = React.createRef(); @@ -249,6 +265,16 @@ describe('PricingRequestPanel', () => { await waitFor(() => expect(api.pricingRequests.submit).toHaveBeenCalledWith(1)); }); + it('offers no "ส่งให้ฝ่ายนำเข้า" on a DRAFT once the deal order is confirmed', async () => { + api.pricingRequests.listForTicket.mockResolvedValue({ + items: [summary({ status: 'DRAFT', ticketPaymentStatus: 'CUSTOMER_CONFIRMED' })], + }); + renderPanel(); + + await screen.findByText('PCR-2026-0001'); + expect(screen.queryByRole('button', { name: 'ส่งให้ฝ่ายนำเข้า' })).toBeNull(); + }); + // Regression, reported from UAT 2026-08-11: submit() 422'd and the user saw NOTHING — the // button appeared to do nothing and the reason existed only in the browser console, because // submitMutation had no onError and the component rendered `.error` nowhere. The service always diff --git a/frontend/src/features/pricingRequests/pricingRequestMeta.js b/frontend/src/features/pricingRequests/pricingRequestMeta.js index 3d5810f92..7e796e356 100644 --- a/frontend/src/features/pricingRequests/pricingRequestMeta.js +++ b/frontend/src/features/pricingRequests/pricingRequestMeta.js @@ -142,11 +142,12 @@ export function unitBasisLabel(value) { return UNIT_BASIS_OPTIONS.find((u) => u.code === value)?.label ?? value ?? '-'; } -/** Mirrors PricingRequestService.createDraft: sales (deal owner), deal must be ACTIVE. */ +/** Mirrors PricingRequestService.createDraft: sales (deal owner), deal ACTIVE, order not yet confirmed. */ export function canCreatePricingRequest(user, deal) { return user?.role === 'sales' && deal?.createdById != null && Number(deal.createdById) === Number(user.id) - && (deal?.lifecycle ?? 'ACTIVE') === 'ACTIVE'; + && (deal?.lifecycle ?? 'ACTIVE') === 'ACTIVE' + && deal?.paymentStatus == null; } /** Mirrors PricingRequestService.updateDraft: owner sales, DRAFT only. */ @@ -156,11 +157,12 @@ export function canUpdatePricingRequest(user, pr) { && pr?.status === 'DRAFT'; } -/** Mirrors PricingRequestService.submit: owner sales, DRAFT only. */ +/** Mirrors PricingRequestService.submit: owner sales, DRAFT only, deal order not yet confirmed. */ export function canSubmitPricingRequest(user, pr) { return user?.role === 'sales' && pr?.ticketCreatedById != null && Number(pr.ticketCreatedById) === Number(user.id) - && pr?.status === 'DRAFT'; + && pr?.status === 'DRAFT' + && pr?.ticketPaymentStatus == null; } /** Mirrors PricingRequestService.pickup: any import user, SUBMITTED only. */ diff --git a/frontend/src/features/pricingRequests/pricingRequestMeta.test.js b/frontend/src/features/pricingRequests/pricingRequestMeta.test.js index 18aa32b8c..2fd1a4f59 100644 --- a/frontend/src/features/pricingRequests/pricingRequestMeta.test.js +++ b/frontend/src/features/pricingRequests/pricingRequestMeta.test.js @@ -214,6 +214,11 @@ describe('canCreatePricingRequest', () => { it('rejects non-sales roles', () => { expect(canCreatePricingRequest(ceo, activeDeal)).toBe(false); }); + // Walk 4 (W4-04): once the deal's order is confirmed (payment track started) no new request may be created. + it('rejects the owner once the deal order is confirmed (paymentStatus set)', () => { + expect(canCreatePricingRequest(salesOwner, { ...activeDeal, paymentStatus: 'CUSTOMER_CONFIRMED' })).toBe(false); + expect(canCreatePricingRequest(salesOwner, { ...activeDeal, paymentStatus: null })).toBe(true); + }); }); describe('canUpdatePricingRequest / canSubmitPricingRequest', () => { @@ -230,6 +235,10 @@ describe('canUpdatePricingRequest / canSubmitPricingRequest', () => { it('reject a non-owner sales rep', () => { expect(canSubmitPricingRequest(otherSales, pr({ status: 'DRAFT' }))).toBe(false); }); + it('reject a DRAFT once the deal order is confirmed (ticketPaymentStatus set)', () => { + expect(canSubmitPricingRequest(salesOwner, pr({ status: 'DRAFT', ticketPaymentStatus: 'CUSTOMER_CONFIRMED' }))).toBe(false); + expect(canSubmitPricingRequest(salesOwner, pr({ status: 'DRAFT', ticketPaymentStatus: null }))).toBe(true); + }); }); describe('canPickupPricingRequest', () => { diff --git a/frontend/src/features/quotations/QuotationItemRow.jsx b/frontend/src/features/quotations/QuotationItemRow.jsx index 12c195f50..9c39c34db 100644 --- a/frontend/src/features/quotations/QuotationItemRow.jsx +++ b/frontend/src/features/quotations/QuotationItemRow.jsx @@ -101,7 +101,7 @@ export function sizeTextFromCatalog(cat) { return `${cm(w)}x${cm(h)}`; } // 3. Only when the row carries no dimensions at all (~49 production rows). - return cat?.sizeRaw ?? cat?.size ?? ''; + return cat?.sizeRaw || cat?.size || ''; } /** @@ -583,7 +583,7 @@ export function QuotationItemRow({ diff --git a/frontend/src/features/quotations/QuotationItemRow.test.jsx b/frontend/src/features/quotations/QuotationItemRow.test.jsx index 9542983de..c7c3d735f 100644 --- a/frontend/src/features/quotations/QuotationItemRow.test.jsx +++ b/frontend/src/features/quotations/QuotationItemRow.test.jsx @@ -526,6 +526,29 @@ describe('QuotationItemRow', () => { expect(onCatalogPicked).toHaveBeenCalledWith(recent[0]); }); + // QA-28 (owner ruling 2026-10-29): the ขนาด (ซม.) field is cm, so the catalogue rows offered beside it + // read in cm too (sizeTextFromCatalog) -- never the factory's raw millimetre string. + it('the typeahead option and the ใช้ล่าสุด chip show the catalogue size in cm, not the raw factory string', async () => { + const bioarch = { priceId: 77, factoryName: 'Panaria', collection: 'BIOARCH', color: 'BARGE GIALLA', widthMm: 200, heightMm: 305, sizeRaw: '200x305', price: 53.9 }; + api.catalog.prices.mockImplementation(async (q) => ((q ?? '').includes('BIOARCH') ? { items: [bioarch] } : { items: [] })); + render( + , + ); + + expect(screen.getByRole('button', { name: 'BIOARCH' }).title).toBe('Panaria · BARGE GIALLA · 20x30.5'); + fireEvent.change(screen.getByLabelText(/^รุ่น \/ ค้นหาแคตตาล็อก/), { target: { value: 'BIOARCH' } }); + const option = await waitFor(() => screen.getByRole('option', { name: /BIOARCH/ }), { timeout: 1000 }); + expect(option.textContent).toContain('20x30.5'); + expect(option.textContent).not.toContain('200x305'); + }); + it('hides the ใช้ล่าสุด chips once the row already names a product', () => { render( !blankValue(part)).map((part) => String(part).trim()).join(separator); } + +export function generateClientRequestId() { + return crypto.randomUUID?.() + ?? '00000000-0000-4000-8000-' + String(Date.now()).slice(-12).padStart(12, '0'); +} + +/** One idempotency key PER (quotation, outcome): the backend replays a reused key as the FIRST outcome's + * success, so a failed ปฏิเสธ followed by ขอแก้ไข on a shared key would record nothing (W3-06/QA-35), and + * it 409s a key already used on ANOTHER quotation (code review 10-30). `map` is a useRef object; `scope` + * names the quotation (include the engine when ids can collide). Drop the key after a successful record. */ +export const outcomeKeyFor = (map, scope, outcome) => (map[`${scope}:${outcome}`] ??= generateClientRequestId()); +export const dropOutcomeKey = (map, scope, outcome) => { delete map[`${scope}:${outcome}`]; }; diff --git a/frontend/src/features/tickets/DealDocumentRegister.jsx b/frontend/src/features/tickets/DealDocumentRegister.jsx index f03d2b87b..2d3e59412 100644 --- a/frontend/src/features/tickets/DealDocumentRegister.jsx +++ b/frontend/src/features/tickets/DealDocumentRegister.jsx @@ -24,6 +24,12 @@ import { groupDirectQuotations, groupLegacyQuotations, groupRemainingInvoices, sortGroupsNewestFirst, } from './dealDocumentVersions.js'; +/** Same read gate as ImportRequestService#requireRead: import / CEO / sales_manager, or the OWNING sales rep. */ +export function canReadStoredImportRequests(user, summary) { + return ['import', 'ceo', 'sales_manager'].includes(user?.role) + || (user?.role === 'sales' && summary?.createdById != null && summary.createdById === user?.id); +} + /** * Slice D ("the เอกสาร document register"): one read-only roll-up of every document a deal can * produce — customer quotations (the PricingRequest/CustomerQuotation chain, the legacy @@ -174,8 +180,7 @@ export function DealDocumentRegister({ // CR-1 (R6/R9): the stored ใบขอซื้อ rows — one IR per factory — in a 5th section. Same read gate as // ImportRequestService#requireRead (and DealFulfilmentPanel's canReadStoredIr): import / CEO / // sales_manager, or the OWNING sales rep. Same query key as that panel, so one fetch feeds both. - const canViewImportRequests = ['import', 'ceo', 'sales_manager'].includes(user?.role) - || (user?.role === 'sales' && summary?.createdById != null && summary.createdById === user?.id); + const canViewImportRequests = canReadStoredImportRequests(user, summary); const importRequestsQuery = useQuery({ queryKey: queryKeys.storedImportRequests(ticketId), queryFn: () => api.storedImportRequests.listForTicket(ticketId).then((r) => r.importRequests ?? []), diff --git a/frontend/src/features/tickets/DealFulfilmentPanel.jsx b/frontend/src/features/tickets/DealFulfilmentPanel.jsx index 95973cb62..09d1c4805 100644 --- a/frontend/src/features/tickets/DealFulfilmentPanel.jsx +++ b/frontend/src/features/tickets/DealFulfilmentPanel.jsx @@ -269,8 +269,6 @@ export const DealFulfilmentPanel = forwardRef(function DealFulfilmentPanel({ const [deliveryOpen, setDeliveryOpen] = useState(false); const [deliveryDraft, setDeliveryDraft] = useState({ source: 'WAREHOUSE', note: '', lines: {} }); - const [stockOpen, setStockOpen] = useState(false); - const [stockDraft, setStockDraft] = useState({ note: '', lines: {} }); // V148 (per-item stock-commission weighting): sales_manager/ceo only -- a separate control from // the stock-declaration modal above, matching the backend's separate authorization boundary // (TicketService#setItemWeightMultipliers, gated to a DIFFERENT role set than reserveStock). @@ -287,10 +285,13 @@ export const DealFulfilmentPanel = forwardRef(function DealFulfilmentPanel({ const deliveryProgress = totalOrdered > 0 ? Math.min(100, Math.round((totalDelivered / totalOrdered) * 100)) : 0; // Which journey this deal walked, for SubstepChips (issue #730). Once the deal reaches a // DELIVERY state its fulfillmentStatus no longer says, but the declaration itself survives on - // the lines: reserveStock writes qtyFromStock, and only FULL coverage sets FROM_STOCK. So full + // the lines: confirmOrder writes qtyFromStock for stock lines, and only FULL coverage sets FROM_STOCK. So full // coverage => the from-stock branch; anything less => the import sequence really did run for // the remainder. `null` (no items loaded yet) means "don't guess", not "import". const fromStock = totalOrdered > 0 ? totalFromStock >= totalOrdered : null; + // Every line covered from stock (non-empty): nothing is ordered from a factory, so no IR can be created. + const allLinesFromStock = items.length > 0 + && items.every((i) => Number(i.qtyFromStock ?? 0) >= Number(i.qty ?? 0)); // ── ใบขอซื้อรายโรงงาน (V184, PR-B) ──────────────────────────────────────────────────────────── // The STORED aggregate — one row per (deal, factory). Supersedes the legacy per-brand PREVIEW @@ -303,6 +304,15 @@ export const DealFulfilmentPanel = forwardRef(function DealFulfilmentPanel({ queryFn: () => api.storedImportRequests.listForTicket(ticketId).then((r) => r.importRequests ?? []), enabled: !!ticketId && canReadStoredIr, }); + // Read-only server flag from its own endpoint (the list stays rows-only for the import pages). Under the + // rows key's prefix, so invalidating the rows refreshes it too. Only an explicit `false` hides the create controls — an older + // backend that omits it (undefined) keeps them. + const coverageQuery = useQuery({ + queryKey: [...queryKeys.storedImportRequests(ticketId), 'coverage'], + queryFn: () => api.storedImportRequests.coverage(ticketId).then((r) => r.hasUncoveredFactories), + enabled: !!ticketId && canReadStoredIr, + }); + const allFactoriesCovered = coverageQuery.data === false; const storedIrRows = storedIrQuery.data ?? []; const liveStoredIrRows = storedIrRows .filter((r) => r.status !== 'SUPERSEDED') @@ -469,6 +479,7 @@ export const DealFulfilmentPanel = forwardRef(function DealFulfilmentPanel({ // any ticket DTO the frontend reads), so this is an uncontrolled note a rep types once rather than // an editable display of the current value. Sales (owner) or CEO, from DealStage.ORDER_RECEIVED. const [dealRequiredByDraft, setDealRequiredByDraft] = useState(''); + const [requiredByOpen, setRequiredByOpen] = useState(false); // Nit: best-effort READ-BACK — there is still no GET for the deal-level value itself (see above), // but once any factory row exists its OWN requiredByNote was snapshotted FROM that deal-level // value (ImportRequestService#insertDraft), so it is the closest available proxy for "what is @@ -478,7 +489,7 @@ export const DealFulfilmentPanel = forwardRef(function DealFulfilmentPanel({ useEffect(() => { if (requiredByNoteSeeded || liveStoredIrRows.length === 0) return; const note = liveStoredIrRows.find((r) => r.requiredByNote)?.requiredByNote; - if (note) setDealRequiredByDraft(note); + if (note) { setDealRequiredByDraft(note); setRequiredByOpen(true); } setRequiredByNoteSeeded(true); }, [requiredByNoteSeeded, liveStoredIrRows]); const setDealRequiredByMutation = useMutation({ @@ -577,11 +588,6 @@ export const DealFulfilmentPanel = forwardRef(function DealFulfilmentPanel({ onSuccess: () => { showToast?.('success', 'รับสินค้าแล้ว'); invalidateAfterFulfilmentChange(); }, onError, }); - const reserveStockMutation = useMutation({ - mutationFn: (payload) => api.tickets.reserveStock(ticketId, payload), - onSuccess: () => { showToast?.('success', 'บันทึกสินค้าจากสต็อกแล้ว'); setStockOpen(false); invalidateAfterFulfilmentChange(); }, - onError, - }); const setItemWeightMutation = useMutation({ mutationFn: (payload) => api.tickets.updateItemWeightMultipliers(ticketId, payload), onSuccess: () => { showToast?.('success', 'บันทึกน้ำหนักคอมมิชชั่นต่อรายการแล้ว'); setWeightOpen(false); invalidateAfterFulfilmentChange(); }, @@ -620,23 +626,10 @@ export const DealFulfilmentPanel = forwardRef(function DealFulfilmentPanel({ markIrSent: hasAction('IR_SENT') && fulfilmentActionCode === 'markIrSent' && isFulfilment, markShipping: hasAction('SHIPPING') && fulfilmentActionCode === 'markShipping' && isFulfilment, markGoodsReceived: hasAction('GOODS_RECEIVED') && fulfilmentActionCode === 'markGoodsReceived' && isFulfilment, - // The ONE entry here with no local role check, deliberately (issue #732). PR #706 widened the - // backend gate to the deal owner — TicketService.canDeclareStockCoverage is - // FULFILMENT_ROLES ∪ (SALES_ROLES ∧ createdById == actor.id) — and actions() advertises - // RESERVE_STOCK off that same predicate. ANDing the server's answer with the pre-#706 role set - // meant the server offered the action and the frontend threw it away, so the one role the - // workflow was built for could not start it. TicketService's own Javadoc names this failure - // mode: gate and advertisement are one predicate so they "cannot drift into offering an action - // that immediately 403s", and applying it unevenly leaves a capability "live but invisible". - // - // hasAction('RESERVE_STOCK') already carries the ownership rule, the S10 stage floor and the - // remaining-delivery check, because the server computed all three. Re-deriving any of them - // here is what rots. The other six entries KEEP isFulfilment — their backend gates really are - // FULFILMENT_ROLES-only, so dropping it there would offer buttons that 403. - reserveStock: hasAction('RESERVE_STOCK'), + // (No reserve-from-stock control any more - walk 4: a stock line ends at the CEO's price and waits for delivery.) // Stages 13-14 (ส่งมอบสินค้า) belong to Sales — owner ruling 2026-08-17, additive to import/CEO. // `isFulfilment` is GONE from these two for exactly the reason issue #732 records for - // reserveStock directly above: the backend gate is now ownership-aware + // the (since removed) reserve-stock entry: the backend gate is now ownership-aware // (TicketService.canWriteDelivery = FULFILMENT_ROLES ∪ (sales ∧ owner)) and `actions()` advertises // RECORD_PARTIAL_DELIVERY/COMPLETE_DELIVERY off that same predicate. ANDing the server's answer // with the old role set would mean the server offers the action and the frontend throws it away @@ -665,13 +658,6 @@ export const DealFulfilmentPanel = forwardRef(function DealFulfilmentPanel({ setDeliveryOpen(true); } - function openStockModal() { - const lines = {}; - items.forEach((item) => { lines[item.id] = String(item.qtyFromStock ?? 0); }); - setStockDraft({ note: '', lines }); - setStockOpen(true); - } - function openWeightModal() { const lines = {}; items.forEach((item) => { lines[item.id] = String(item.weightMultiplier ?? 1); }); @@ -690,15 +676,6 @@ export const DealFulfilmentPanel = forwardRef(function DealFulfilmentPanel({ recordDeliveryMutation.mutate({ source: deliveryDraft.source, note: deliveryDraft.note.trim() || null, lines }); } - function handleReserveStock() { - const lines = items.map((item) => ({ - itemId: item.id, - qtyFromStock: Number(stockDraft.lines[item.id] || 0), - note: stockDraft.note.trim() || null, - })); - reserveStockMutation.mutate({ lines }); - } - function handleSetItemWeight() { const lines = items.map((item) => ({ itemId: item.id, @@ -717,7 +694,9 @@ export const DealFulfilmentPanel = forwardRef(function DealFulfilmentPanel({ return (
- {/* Step 1: นำเข้าสินค้า (Import Request → รับสินค้า, or from-stock) */} + {/* Step 1: นำเข้าสินค้า (Import Request → รับสินค้า, or from-stock). Owner ruling 2026-10-29: + not rendered before the first IR (fulfillmentStatus null) — the stored-IR box below leads. */} + {fs ? (
@@ -767,19 +746,11 @@ export const DealFulfilmentPanel = forwardRef(function DealFulfilmentPanel({ onClick={() => markGoodsReceivedMutation.mutate()} data-testid="deal-fulfilment-mark-goods-received"> รับสินค้าแล้ว - ) : fs == null ? ( -

ยังไม่ออกคำขอนำเข้า

) : null}
)}
- {can.reserveStock ? ( - - ) : null} {can.setItemWeight ? (
+ ) : null} {/* ใบขอซื้อรายโรงงาน (V184, PR-B) — the STORED aggregate. Visible to CEO/import/ sales_manager unrestricted, and to sales only for a deal they own — mirrors @@ -800,7 +772,7 @@ export const DealFulfilmentPanel = forwardRef(function DealFulfilmentPanel({ ใบขอซื้อรายโรงงาน (F-SM-001)
- {canFullWriteStoredIr ? ( + {canFullWriteStoredIr && !allFactoriesCovered && !allLinesFromStock ? ( ) : null}
- {canFullWriteStoredIr && !depositReady ? ( + {canFullWriteStoredIr && !allFactoriesCovered && !allLinesFromStock && !depositReady ? (

{DEPOSIT_HINT}

) : null} -

- หนึ่งใบต่อหนึ่งโรงงาน — สร้างอัตโนมัติจากรายการสินค้าที่ต้องสั่งนำเข้า (ไม่รวมส่วนที่จองจากสต็อก) -

- {canFullWriteStoredIr && factorylessLines.length > 0 ? ( + {allFactoriesCovered ? ( +

+ {liveStoredIrRows.length > 0 ? 'ทุกโรงงานออกใบขอซื้อแล้ว' : 'ไม่มีรายการที่ต้องออกใบขอซื้อ'} +

+ ) : ( +

+ หนึ่งใบต่อหนึ่งโรงงาน — สร้างอัตโนมัติจากรายการสินค้าที่ต้องสั่งนำเข้า (ไม่รวมรายการจากสต็อก) +

+ )} + {canFullWriteStoredIr && !allFactoriesCovered && factorylessLines.length > 0 ? (

รายการต่อไปนี้ยังไม่มีโรงงาน — เลือกโรงงานก่อนออกใบขอซื้อ (IR) @@ -845,7 +823,8 @@ export const DealFulfilmentPanel = forwardRef(function DealFulfilmentPanel({ ))}

) : null} - {canFullWriteStoredIr ? ( + {canFullWriteStoredIr && !allFactoriesCovered ? ( + requiredByOpen || dealRequiredByDraft ? ( + ) : ( + + ) ) : null} {storedIrQuery.isLoading ? (

กำลังโหลดใบขอซื้อ…

@@ -882,6 +867,7 @@ export const DealFulfilmentPanel = forwardRef(function DealFulfilmentPanel({ canFooterWrite={canFooterWriteStoredIr} canAdvance={canAdvanceStoredIr} canEmailWrite={canEmailWriteStoredIr} + showOrderEmail={role !== 'sales'} showToast={showToast} /> ))} @@ -890,6 +876,19 @@ export const DealFulfilmentPanel = forwardRef(function DealFulfilmentPanel({
) : null} + {/* Before the first IR the นำเข้าสินค้า card above is not rendered, but the commission-weight + setter must stay reachable, so it sits just below the stored-IR box. */} + {!fs && can.setItemWeight ? ( +
+ {can.setItemWeight ? ( + + ) : null} +
+ ) : null} + {/* ใบขอซื้อ (F-SM-001) — legacy PREVIEW, one form per BRAND, not a step of its own, so no StepNumber. Import/CEO only: ImportRequestService.IR_ROLES is {import, ceo}, so rendering this for sales would offer a control that 403s. Hidden once the deal has any @@ -1095,48 +1094,6 @@ export const DealFulfilmentPanel = forwardRef(function DealFulfilmentPanel({ ) : null} - {stockOpen ? ( - setStockOpen(false)} - footer={( - <> - - - - )} - > -
- {items.map((item) => ( - - ))} -