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 52e6338f8..03c6bc36a 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 @@ -32,6 +32,16 @@ private DealQuotationRenderAdapter() {} private static final ZoneId BANGKOK = ZoneId.of("Asia/Bangkok"); + /** The deposit percent the PDF prints when a quotation's {@code deposit_percent} is NULL. The + * deposit notice bills the same default (owner ruling 2026-10-22), so the customer is billed + * what the accepted PDF promised. */ + public static final int DEFAULT_DEPOSIT_PERCENT = 30; + + /** The percent a new-engine quotation bills and prints: a blank one means {@link #DEFAULT_DEPOSIT_PERCENT}. */ + public static int effectiveDepositPercent(Integer stored) { + return stored != null ? stored : DEFAULT_DEPOSIT_PERCENT; + } + /** Overload for callers with no configured bank block — the English document then prints the * proforma-invoice line instead. Kept so tests and any hand-wired caller need not thread a * config value they do not care about. */ @@ -635,7 +645,7 @@ private static String noReturnLine(int number) { * lead time (dropped entirely, not blanked, when nothing on the document carries one — same * {@link #dropLeadTimeLineAndRenumber} the tile set uses), and the no-exchange-or-return rule. */ private static List nonTileRemarkLines(DealQuotationDto quotation) { - int depositPct = quotation.depositPercent() != null ? quotation.depositPercent() : 30; + int depositPct = quotation.depositPercent() != null ? quotation.depositPercent() : DEFAULT_DEPOSIT_PERCENT; String remainderText = "CREDIT".equals(quotation.remainderMode()) ? "เครดิต " + (quotation.creditDays() != null ? quotation.creditDays() : 0) + " วัน" : "ขอรับก่อนส่งมอบสินค้าหรือเมื่อส่งมอบสินค้า"; @@ -652,7 +662,7 @@ private static List nonTileRemarkLines(DealQuotationDto quotation) { private static List remarkLines(DealQuotationDto quotation) { LocalDate offerDate = quotation.offerDate() != null ? quotation.offerDate() : LocalDate.now(BANGKOK); - int depositPct = quotation.depositPercent() != null ? quotation.depositPercent() : 30; + int depositPct = quotation.depositPercent() != null ? quotation.depositPercent() : DEFAULT_DEPOSIT_PERCENT; String remainderText = "CREDIT".equals(quotation.remainderMode()) ? "เครดิต " + (quotation.creditDays() != null ? quotation.creditDays() : 0) + " วัน" : "ขอรับก่อนส่งมอบสินค้าหรือเมื่อส่งมอบสินค้า"; @@ -902,7 +912,7 @@ private static String fullPaymentTermEnglishText(String code) { private static List englishRemarkLines(DealQuotationDto quotation, List bankBlockLines) { LocalDate offerDate = quotation.offerDate() != null ? quotation.offerDate() : LocalDate.now(BANGKOK); - int depositPct = quotation.depositPercent() != null ? quotation.depositPercent() : 30; + int depositPct = quotation.depositPercent() != null ? quotation.depositPercent() : DEFAULT_DEPOSIT_PERCENT; // Wording-scan fix 7 (2026-09-17): singular "day credit" at exactly 1 day. String remainderText; if ("CREDIT".equals(quotation.remainderMode())) { @@ -1029,7 +1039,7 @@ private static String englishNonTileLeadTimeLine(List item /** The English twin of {@link #nonTileRemarkLines}. */ private static List englishNonTileRemarkLines(DealQuotationDto quotation, List bankBlockLines) { - int depositPct = quotation.depositPercent() != null ? quotation.depositPercent() : 30; + int depositPct = quotation.depositPercent() != null ? quotation.depositPercent() : DEFAULT_DEPOSIT_PERCENT; // Wording-scan fix 7 (2026-09-17): singular "day credit" at exactly 1 day. String remainderText; if ("CREDIT".equals(quotation.remainderMode())) { 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 c7d8e470e..cfc5f9f97 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 @@ -1711,6 +1711,16 @@ public DealQuotationDto approve(long id, ApproveRequest request, UserPrincipal a quotations.supersede(ancestorId); ancestorId = ancestor.parentQuotationId(); } + // #1173 review F1: on a confirmed deal the system-owned deposit policy follows the newly + // approved quotation, so a revision that adds or drops the deposit moves the deal with it. + // A no-op before confirmation (confirmOrderFromDirectQuotation applies the confirmed + // quotation's percent then). Null-tolerant for hand-wired tests, like + // #advanceDealToRecipientStage. PRICING_REQUEST origin never reaches this: R8 allows one + // accepted quotation per deal, and its percent is applied at OrderConfirmationService#confirmOrder. + if (ticketService != null) { + ticketService.followQuotationDepositPolicy(approved.ticketId(), approved.number(), + DealQuotationRenderAdapter.effectiveDepositPercent(approved.depositPercent()), actor); + } tickets.addEventWithDocument(approved.ticketId(), actor.id(), actor.name(), TicketEventKind.QUOTATION_ISSUED, null, null, "อนุมัติใบเสนอราคา " + approved.number(), RelatedDocumentType.QUOTATION, id); String message = "ใบเสนอราคา " + approved.number() + " ได้รับอนุมัติแล้ว"; @@ -2107,9 +2117,10 @@ private String outcomeLabel(String outcome) { *
  • {@code sales.ticket_item} replaced by the quotation's own goods lines (see * {@link #ticketItemsFromQuotation}) — the delivery machinery's quantity source; the * quotation is the agreed order, so it replaces any preliminary lines the deal carried;
  • - *
  • {@code TicketService#confirmCustomerForPromotedQuotation} — the unmodified - * confirmCustomer body: {@code payment_status = CUSTOMER_CONFIRMED}, stage auto-advanced to - * {@code ORDER_RECEIVED}.
  • + *
  • {@code TicketService#confirmCustomerForPromotedQuotation} — confirmCustomer's shared + * body: {@code payment_status = CUSTOMER_CONFIRMED}, the deposit policy following this + * quotation's percent ({@code TicketService#followQuotationDepositPolicy}), stage + * auto-advanced to {@code ORDER_RECEIVED}.
  • * * *

    Order of checks: quotation exists on this engine (404 — a LEGACY @@ -2178,7 +2189,8 @@ public PromoteToDealResultDto confirmOrderFromDirectQuotation(long quotationId, // or stock reservation exists — both key on quotation_issued or later), and the only other // reference to a ticket_item, pricing_request_item.source_ticket_item_id, is ON DELETE SET NULL. tickets.replaceItems(ticketId, ticketItemsFromQuotation(fresh)); - ticketService.confirmCustomerForPromotedQuotation(ticketId, actor); + ticketService.confirmCustomerForPromotedQuotation(ticketId, fresh.number(), + DealQuotationRenderAdapter.effectiveDepositPercent(fresh.depositPercent()), actor); return promotionResult(ticketId, quotationId); } @@ -2193,7 +2205,7 @@ private PromoteToDealResultDto promotionResult(long ticketId, long quotationId) * qty_sqm, unit_basis, raw_unit. NO pricing field is copied (proposed/approved/raw price stay * null) — those columns carry the legacy ticket-native approval history, and a direct * quotation's typed price was never "approved" on this ticket in that sense; money documents - * read the quotation itself ({@code DepositNoticeRepository#findDealQuotationItemsForDeposit}). + * read the quotation itself ({@code DepositNoticeRepository#findDealQuotationItems}). * *

    Only goods lines: an {@code ADJUSTMENT} row (a negative discount/surcharge line, quantity −1) * is money, not something to deliver, and a non-positive quantity would violate diff --git a/backend/src/main/java/th/co/glr/hr/deposit/DepositNoticeController.java b/backend/src/main/java/th/co/glr/hr/deposit/DepositNoticeController.java index 34756b3b1..10819741e 100644 --- a/backend/src/main/java/th/co/glr/hr/deposit/DepositNoticeController.java +++ b/backend/src/main/java/th/co/glr/hr/deposit/DepositNoticeController.java @@ -98,9 +98,11 @@ ResponseEntity preview(@PathVariable long docId, HttpSession session) { // Issue (assign doc number + transition ticket) @PostMapping("/deposit-notices/{docId}/issue") - Map issue(@PathVariable long docId, HttpSession session) { + Map issue(@PathVariable long docId, + @RequestBody(required = false) DepositNoticeIssueRequest body, + HttpSession session) { UserPrincipal user = sessions.requireUser(session); - return Map.of("depositNotice", service.issue(docId, user)); + return Map.of("depositNotice", service.issue(docId, body == null ? null : body.expectedUpdatedAt(), user)); } // File download diff --git a/backend/src/main/java/th/co/glr/hr/deposit/DepositNoticeDraftRequest.java b/backend/src/main/java/th/co/glr/hr/deposit/DepositNoticeDraftRequest.java index 336c68813..dd0561ece 100644 --- a/backend/src/main/java/th/co/glr/hr/deposit/DepositNoticeDraftRequest.java +++ b/backend/src/main/java/th/co/glr/hr/deposit/DepositNoticeDraftRequest.java @@ -5,6 +5,7 @@ import jakarta.validation.constraints.DecimalMin; import jakarta.validation.constraints.Size; import java.math.BigDecimal; +import java.time.OffsetDateTime; import java.util.List; public record DepositNoticeDraftRequest( @@ -13,7 +14,17 @@ public record DepositNoticeDraftRequest( @Size(max = 255) String customerAddress, @Size(max = 255) String projectName, @Size(max = 255) String reference, - @DecimalMin("0") @DecimalMax("1") BigDecimal depositPercent, + @DecimalMin(value = "0", inclusive = false, message = "เปอร์เซ็นต์มัดจำต้องมากกว่า 0") + @DecimalMax(value = "1", message = "เปอร์เซ็นต์มัดจำต้องไม่เกิน 100") BigDecimal depositPercent, List notes, - @Valid List items -) {} + @Valid List items, + // The draft's updatedAt as this client last read it. null skips the stale-save check (older clients). + OffsetDateTime expectedUpdatedAt +) { + /** Without a stamp: creation and the server-side callers. */ + public DepositNoticeDraftRequest(String customerName, String customerTaxId, String customerAddress, + String projectName, String reference, BigDecimal depositPercent, + List notes, List items) { + this(customerName, customerTaxId, customerAddress, projectName, reference, depositPercent, notes, items, null); + } +} diff --git a/backend/src/main/java/th/co/glr/hr/deposit/DepositNoticeIssueRequest.java b/backend/src/main/java/th/co/glr/hr/deposit/DepositNoticeIssueRequest.java new file mode 100644 index 000000000..681ef084a --- /dev/null +++ b/backend/src/main/java/th/co/glr/hr/deposit/DepositNoticeIssueRequest.java @@ -0,0 +1,6 @@ +package th.co.glr.hr.deposit; + +import java.time.OffsetDateTime; + +/** Optional body of the issue call: the draft's updatedAt as the caller last read it (null skips the check). */ +public record DepositNoticeIssueRequest(OffsetDateTime expectedUpdatedAt) {} 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 7fe0ee83b..634932892 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 @@ -13,14 +13,20 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import org.springframework.http.HttpStatus; import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; import org.springframework.jdbc.support.GeneratedKeyHolder; import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; +import th.co.glr.hr.dealquotation.DealQuotationRenderAdapter; +import th.co.glr.hr.ticket.QuotationRenderer; @Repository public class DepositNoticeRepository { + static final String NOT_DRAFT = "ใบแจ้งรับมัดจำนี้ไม่ได้อยู่ในสถานะร่าง (DRAFT)"; + static final String EDITED_ELSEWHERE = "ใบแจ้งรับมัดจำนี้ถูกแก้ไขโดยผู้ใช้อื่น กรุณาโหลดข้อมูลใหม่แล้วลองอีกครั้ง"; + private final NamedParameterJdbcTemplate jdbc; public DepositNoticeRepository(NamedParameterJdbcTemplate jdbc) { @@ -89,74 +95,63 @@ public List findByTicket(long ticketId) { ); } + /** The quotation a ticket-level deposit notice bills: always its percent (a fraction, + * 30 -> 0.30); its lines and its number as the reference unless the caller or legacy + * approved_price rows supply the lines. See {@link #findGoverningQuotation}. */ + public record GoverningQuotation(long quotationId, boolean legacy, String number, BigDecimal depositPercent) {} + /** - * Owner ruling (2026-09-29): the deposit PERCENTAGE on a deposit notice is LOCKED to the - * source quotation's own {@code sales.quotation.deposit_percent} — the value sales set when - * creating the quotation — and is never entered or edited at the deposit-notice stage. - * {@code deposit_percent} is a nullable SMALLINT whole-number percent (30/50/100); this - * returns it as a fraction (50 -> 0.50) via {@code BigDecimal.valueOf(pct, 2)}, which is - * exact for any integer/100 (denominator 100 = 2^2*5^2 always terminates). + * The ONE pick of the quotation a ticket-level deposit notice bills, across both engines, so + * when it supplies the lines they and the percent come from the same row (owner rulings + * 2026-09-29 and 2026-10-23). The PRICING-REQUEST route ({@code OrderConfirmationService + * #createDepositNoticeFromQuotation}) already knows its quotation and does not call this. + * + *

    Live rows: a legacy pricing-chain quotation ({@code origin IS NULL}, {@code + * pricing_request_id IS NOT NULL}, the scope {@code CustomerQuotationRepository#findByTicket} + * uses) that is ACCEPTED or ISSUED, a {@code PRICING_REQUEST}-origin one that is ACCEPTED or + * ISSUED, or a {@code DEAL_DIRECT} one that is ACCEPTED, ISSUED or APPROVED (DEAL_DIRECT + * ACCEPTED/ISSUED are admitted but no live route produces them). APPROVED is + * DEAL_DIRECT only: a PRICING_REQUEST approval lands on ISSUED ({@code DealQuotationRepository + * #approve}). Engines are exclusive per pricing request, not per ticket, so one ticket can hold + * both. * - *

    Pick order deliberately mirrors {@link DepositNoticeService}'s own quotation selection - * for deposit-notice ITEMS, so the percent this returns always comes from the same row the - * items would be sourced from: - *

      - *
    • legacy customer-quotation chain ({@code origin IS NULL}, {@code pricing_request_id - * IS NOT NULL} — the exact scope {@code CustomerQuotationRepository#findByTicket} uses): - * ACCEPTED preferred, else ISSUED — the same rule {@code - * DepositNoticeService#pickQuotation} applies for items. Read as raw SQL rather than via - * {@code CustomerQuotationRepository}'s own DTO because {@code CustomerQuotationDto} - * does not carry {@code deposit_percent} at all (it predates that column and nothing - * else on that DTO's surface needed it) — adding a field there would widen every one of - * that repository's other read paths for a value only this one caller needs.
    • - *
    • v2 deal-quotation chain ({@code origin IN ('PRICING_REQUEST','DEAL_DIRECT')}): the - * single live quotation, preferring ACCEPTED, then ISSUED, then APPROVED (DEAL_DIRECT's - * own only live terminal state), newest by id.
    • - *
    - * Engines are exclusive per pricing request, not per ticket ({@code - * CustomerQuotationService#create} and {@code DealQuotationService#createFromPricingRequest} - * each refuse a pricing request the other engine already quoted), so one ticket can carry a - * legacy quotation on one pricing request and an origin-tagged one on another. On such a mixed - * ticket both branches below can match, and trying legacy first IS a real preference: the - * percent comes from the legacy row. Returns empty when there is no live - * (ACCEPTED/ISSUED/APPROVED) quotation for this ticket, or its {@code deposit_percent} is - * null — the caller ({@link DepositNoticeService#createDraft}) falls back to 0.50 in that - * case, this document's long-standing default. + *

    A case needing no pre-lock data: a DEAL_DIRECT quotation stays revisable after its order + * is confirmed ({@code DealQuotationService#requireRevisableStatus}), and once that revision + * is APPROVED it governs the ticket-route deposit while the deal's ticket_item rows still hold + * the confirmed quotation's lines. Ploy 2026-10-27: revisions will lock after confirm + * (follow-up PR). + * + *

    Ranking: the latest quotation the customer ANSWERED wins: ACCEPTED on either engine, or + * APPROVED on a DEAL_DIRECT quotation (its terminal state in practice). Only when + * nothing was answered does the newest ISSUED row win. "Latest" is {@code quotation_id DESC}: + * one sequence across every engine and pricing request, set at creation. Not {@code + * accepted_at}/{@code approved_at}: those time different events (a customer answer against an + * internal approval), and either can be NULL on older or imported rows. + * + *

    Percent: what that quotation's PDF printed. A legacy row is always {@link + * QuotationRenderer#LEGACY_DEPOSIT_PERCENT}: the legacy PDF never reads the stored column, so + * this does not either. A new-engine row is its own {@code deposit_percent}, a NULL one {@link + * DealQuotationRenderAdapter#DEFAULT_DEPOSIT_PERCENT}. Empty when the ticket has no live + * quotation; {@link DepositNoticeService#createDraft} then bills 0.50. */ - public Optional findDealQuotationDepositPercent(long ticketId) { - Integer legacyPct = firstOrNull(jdbc.query(""" - SELECT deposit_percent FROM sales.quotation - WHERE ticket_id = :ticketId AND pricing_request_id IS NOT NULL AND origin IS NULL - AND doc_status IN ('ACCEPTED','ISSUED') - ORDER BY CASE doc_status WHEN 'ACCEPTED' THEN 0 ELSE 1 END, quotation_id DESC - LIMIT 1 - """, Map.of("ticketId", ticketId), - (rs, i) -> (Integer) rs.getObject("deposit_percent") - )); - if (legacyPct != null) { - return Optional.of(BigDecimal.valueOf(legacyPct, 2)); - } - - Integer v2Pct = firstOrNull(jdbc.query(""" - SELECT deposit_percent FROM sales.quotation - WHERE ticket_id = :ticketId AND origin IN ('PRICING_REQUEST','DEAL_DIRECT') - AND doc_status IN ('ACCEPTED','ISSUED','APPROVED') - ORDER BY CASE doc_status WHEN 'ACCEPTED' THEN 0 WHEN 'ISSUED' THEN 1 ELSE 2 END, - quotation_id DESC + public Optional findGoverningQuotation(long ticketId) { + return jdbc.query(""" + SELECT quotation_id, origin IS NULL AS legacy, number, deposit_percent + FROM sales.quotation + WHERE ticket_id = :ticketId + AND ((origin IS NULL AND pricing_request_id IS NOT NULL AND doc_status IN ('ACCEPTED','ISSUED')) + OR (origin = 'PRICING_REQUEST' AND doc_status IN ('ACCEPTED','ISSUED')) + OR (origin = 'DEAL_DIRECT' AND doc_status IN ('ACCEPTED','ISSUED','APPROVED'))) + ORDER BY CASE WHEN doc_status IN ('ACCEPTED','APPROVED') THEN 0 ELSE 1 END, quotation_id DESC LIMIT 1 - """, Map.of("ticketId", ticketId), - (rs, i) -> (Integer) rs.getObject("deposit_percent") - )); - return v2Pct != null ? Optional.of(BigDecimal.valueOf(v2Pct, 2)) : Optional.empty(); - } - - /** {@code list.stream().findFirst()} throws NPE the moment the single mapped row's own value - * is {@code null} ({@code Stream.findFirst}/{@code Optional.of} both reject a null element - * outright) — exactly the case a LIVE quotation with a not-yet-set {@code deposit_percent} - * hits. A plain index avoids that without changing what "no row" vs "row, null value" mean to - * the caller (both still read as "nothing usable here"). */ - private static T firstOrNull(List rows) { - return rows.isEmpty() ? null : rows.get(0); + """, Map.of("ticketId", ticketId), (rs, i) -> { + boolean legacy = rs.getBoolean("legacy"); + Integer stored = (Integer) rs.getObject("deposit_percent"); + int percent = legacy ? QuotationRenderer.LEGACY_DEPOSIT_PERCENT + : stored != null ? stored : DealQuotationRenderAdapter.DEFAULT_DEPOSIT_PERCENT; + return new GoverningQuotation(rs.getLong("quotation_id"), legacy, rs.getString("number"), + BigDecimal.valueOf(percent, 2)); + }).stream().findFirst(); } @Transactional @@ -208,14 +203,22 @@ public long createDraft(long ticketId, DepositNoticeDraftRequest req, List storedItems) { + BigDecimal depositPct = req.depositPercent(); BigDecimal vatPct = new BigDecimal("0.07"); List items = req.items() != null ? req.items() : List.of(); - BigDecimal subtotal = items.stream() - .map(it -> it.netUnitPrice().multiply(it.qty())) + // No items in the request (null or empty) keeps the stored lines (they are only replaced + // below when items are sent), so the subtotal must come from those same stored lines. + BigDecimal subtotal = (items.isEmpty() + ? storedItems.stream().map(it -> it.netUnitPrice().multiply(it.qty())) + : items.stream().map(it -> it.netUnitPrice().multiply(it.qty()))) .reduce(BigDecimal.ZERO, BigDecimal::add) .setScale(2, RoundingMode.HALF_UP); BigDecimal deposit = subtotal.multiply(depositPct).setScale(2, RoundingMode.HALF_UP); @@ -224,7 +227,7 @@ public void update(long docId, DepositNoticeDraftRequest req) { String[] notesArr = req.notes() != null ? req.notes().toArray(String[]::new) : new String[0]; - jdbc.update(""" + int rows = jdbc.update(""" UPDATE sales.deposit_notice SET customer_name = :customerName, customer_tax_id = :customerTaxId, @@ -239,9 +242,11 @@ public void update(long docId, DepositNoticeDraftRequest req) { notes = :notes, updated_at = now() WHERE deposit_notice_id = :id AND status = 'DRAFT' + AND (CAST(:expectedUpdatedAt AS timestamptz) IS NULL OR updated_at = :expectedUpdatedAt) """, new MapSqlParameterSource() .addValue("id", docId) + .addValue("expectedUpdatedAt", req.expectedUpdatedAt()) .addValue("customerName", req.customerName()) .addValue("customerTaxId", req.customerTaxId()) .addValue("customerAddress", req.customerAddress()) @@ -254,6 +259,12 @@ public void update(long docId, DepositNoticeDraftRequest req) { .addValue("totalPayable", total) .addValue("notes", notesArr) ); + if (rows == 0) { + boolean stillDraft = req.expectedUpdatedAt() != null && !jdbc.queryForList( + "SELECT 1 FROM sales.deposit_notice WHERE deposit_notice_id = :id AND status = 'DRAFT'", + Map.of("id", docId), Integer.class).isEmpty(); + throw new th.co.glr.hr.common.ApiException(HttpStatus.CONFLICT, stillDraft ? EDITED_ELSEWHERE : NOT_DRAFT); + } if (!items.isEmpty()) { jdbc.update("DELETE FROM sales.deposit_notice_item WHERE deposit_notice_id = :id", Map.of("id", docId)); insertItems(docId, items); @@ -279,8 +290,12 @@ public void update(long docId, DepositNoticeDraftRequest req) { * * @return the new doc number, or {@link Optional#empty()} if the row was not DRAFT. */ - @Transactional public Optional issue(long docId, long actorId, String actorName) { + return issue(docId, actorId, actorName, null); + } + + @Transactional + public Optional issue(long docId, long actorId, String actorName, java.time.OffsetDateTime expectedUpdatedAt) { int thaiYear = Year.now().getValue() + 543; String docNumber = nextDocNumber("DEPOSIT_NOTICE", thaiYear); @@ -294,21 +309,23 @@ public Optional issue(long docId, long actorId, String actorName) { updated_at = now() WHERE deposit_notice_id = :id AND status = 'DRAFT' + AND (CAST(:expectedUpdatedAt AS timestamptz) IS NULL OR updated_at = :expectedUpdatedAt) """, - Map.of("num", docNumber, "actorId", actorId, "actorName", actorName, "id", docId)); + new MapSqlParameterSource() + .addValue("num", docNumber).addValue("actorId", actorId).addValue("actorName", actorName) + .addValue("id", docId).addValue("expectedUpdatedAt", expectedUpdatedAt)); if (rows == 0) { + // Still a draft with a stamp given => it was saved after the caller read it. + boolean editedSince = expectedUpdatedAt != null && !jdbc.queryForList( + "SELECT 1 FROM sales.deposit_notice WHERE deposit_notice_id = :id AND status = 'DRAFT'", + Map.of("id", docId), Integer.class).isEmpty(); + if (editedSince) { + throw new th.co.glr.hr.common.ApiException(HttpStatus.CONFLICT, EDITED_ELSEWHERE); + } return Optional.empty(); } - // Supersede all older versions for same ticket - jdbc.update(""" - UPDATE sales.deposit_notice SET status = 'SUPERSEDED', updated_at = now() - WHERE ticket_id = (SELECT ticket_id FROM sales.deposit_notice WHERE deposit_notice_id = :id) - AND deposit_notice_id <> :id - AND status = 'ISSUED' - """, Map.of("id", docId)); - return Optional.of(docNumber); } @@ -370,65 +387,27 @@ private void insertItems(long docId, List items) { } } - /** - * Deal-quotation (V2) item source for deposit-notice autofill (2026-09-29 fix). The legacy - * autofill knew only two sources: {@code sales.ticket_item.approved_price} (never written by - * the pricing-request chain) and {@code CustomerQuotationRepository#findByTicket} (scoped to - * {@code origin IS NULL}, which excludes BOTH {@code DEAL_DIRECT} and {@code PRICING_REQUEST} - * rows). Every deal whose quotation was issued through the V2 deal-quotation flow therefore got - * an EMPTY deposit-notice item list — the reported "รายการ not autofilled" bug. - * - *

    Selection covers BOTH V2 origins, whose live-terminal doc_status differs — the two - * never share a {@code ticket_id} (see {@code DealQuotationRepository#nextRevisionNo}), so the - * union below is unambiguous per ticket: - *

      - *
    • {@code PRICING_REQUEST} runs the customer-response lifecycle DRAFT → PENDING_APPROVAL → - * ISSUED → ACCEPTED, so it mirrors {@link DepositNoticeService#pickQuotation} exactly: - * prefer ACCEPTED (the customer-accepted document a deposit is raised against), else the - * latest ISSUED.
    • - *
    • {@code DEAL_DIRECT} has no customer-response lifecycle in the model today — it ends at - * APPROVED (terminal-until-revised; see {@code QuotationStatus.APPROVED}), which is the - * only "live" state it ever reaches. Without APPROVED here, every direct-deal deposit - * would still autofill empty.
    • - *
    - * SUPERSEDED / CANCELLED / REJECTED / EXPIRED / DRAFT / PENDING_APPROVAL / REVISION_REQUESTED - * rows are never chosen. Preference ACCEPTED > ISSUED > APPROVED, then newest by id. - * Owner-confirmable: that a deposit may autofill from an ISSUED-but-not-yet-ACCEPTED - * PRICING_REQUEST quote (legacy parity) and from an APPROVED DEAL_DIRECT quote — this is item - * autofill for a draft the user reviews before saving, not the deposit gate itself. - * The lines themselves come from {@link #findDealQuotationItems}. - */ - public List findDealQuotationItemsForDeposit(long ticketId) { - List ids = jdbc.query(""" - SELECT quotation_id FROM sales.quotation - WHERE ticket_id = :ticketId - AND origin IN ('PRICING_REQUEST', 'DEAL_DIRECT') - AND doc_status IN ('ACCEPTED', 'ISSUED', 'APPROVED') - ORDER BY CASE doc_status - WHEN 'ACCEPTED' THEN 0 - WHEN 'ISSUED' THEN 1 - ELSE 2 - END, quotation_id DESC - LIMIT 1 - """, Map.of("ticketId", ticketId), (rs, i) -> rs.getLong("quotation_id")); - if (ids.isEmpty()) { - return List.of(); - } - return findDealQuotationItems(ids.get(0)); - } - /** 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 * {@code requested_*}/{@code approved_unit_price} columns the V2 chain leaves null. The - * discount-label wording matches {@link DepositNoticeService#itemsFromQuotation}. */ + * discount-label wording matches {@link DepositNoticeService#itemsFromQuotation}. An ADJUSTMENT + * row is folded into the goods lines, see {@link #foldAdjustments}. */ public List findDealQuotationItems(long quotationId) { - return jdbc.query(""" - SELECT seq, description, qty, raw_unit, unit_price, sales_discount, final_unit_price + List goods = new java.util.ArrayList<>(); + BigDecimal[] adjustment = {BigDecimal.ZERO}; + jdbc.query(""" + SELECT seq, description, qty, raw_unit, unit_price, sales_discount, final_unit_price, line_type FROM sales.quotation_item WHERE quotation_id = :quotationId ORDER BY seq - """, Map.of("quotationId", quotationId), (rs, i) -> { + """, Map.of("quotationId", quotationId), rs -> { + if ("ADJUSTMENT".equals(rs.getString("line_type"))) { + BigDecimal net = rs.getBigDecimal("final_unit_price"); + BigDecimal qty = rs.getBigDecimal("qty"); + if (net != null && qty != null) adjustment[0] = adjustment[0].add(net.multiply(qty)); + return; + } String description = rs.getString("description"); if (description == null || description.isBlank()) { description = "รายการสินค้า"; @@ -441,10 +420,49 @@ public List findDealQuotationItems(long quotationId) { String discountLabel = discount != null && discount.signum() > 0 ? "ส่วนลด " + discount.stripTrailingZeros().toPlainString() + " ต่อหน่วย" : null; - return new DepositNoticeItemRequest( + goods.add(new DepositNoticeItemRequest( rs.getInt("seq"), description, rs.getBigDecimal("qty"), unit, - rs.getBigDecimal("unit_price"), discountLabel, rs.getBigDecimal("final_unit_price")); + rs.getBigDecimal("unit_price"), discountLabel, rs.getBigDecimal("final_unit_price"))); }); + return foldAdjustments(goods, adjustment[0]); + } + + /** + * Owner rulings 2026-10-23: a DEAL_DIRECT ส่วนลดพิเศษ row (ADJUSTMENT, qty -1) cannot be a + * deposit line (the editor's PUT requires qty >= 0), so its amount is spread over the goods + * lines in proportion to their amount. "2 decimals for all": each share is HALF_UP to 2 dp and + * each line's new net price is 2 dp, with no remainder pushed onto a last line. The notice + * totals are the sum of its own stored 2 dp lines ({@code amount = net x qty}), so they can + * differ from the quotation's total by under qty x 0.005 baht per line; that is the ruled + * outcome, not a bug. A line keeps the quotation's own discount wording, and gains the folded + * discount after it only when the fold lowered its price. A free line stays as it is. + */ + static List foldAdjustments(List goods, + BigDecimal adjustment) { + BigDecimal goodsTotal = goods.stream().map(it -> it.netUnitPrice().multiply(it.qty())) + .reduce(BigDecimal.ZERO, BigDecimal::add); + if (adjustment.signum() == 0 || goodsTotal.signum() == 0) { + return goods; + } + List folded = new java.util.ArrayList<>(); + for (DepositNoticeItemRequest it : goods) { + BigDecimal amount = it.netUnitPrice().multiply(it.qty()); + if (amount.signum() == 0) { + folded.add(it); + continue; + } + BigDecimal share = adjustment.multiply(amount).divide(goodsTotal, 2, RoundingMode.HALF_UP); + BigDecimal net = amount.add(share).divide(it.qty(), 2, RoundingMode.HALF_UP); + BigDecimal foldedPerUnit = it.netUnitPrice().subtract(net); + String label = it.discountLabel(); + if (foldedPerUnit.signum() > 0) { + String extra = "ส่วนลดพิเศษ " + foldedPerUnit.stripTrailingZeros().toPlainString() + " ต่อหน่วย"; + label = label == null || label.isBlank() ? extra : label + " + " + extra; + } + folded.add(new DepositNoticeItemRequest(it.seq(), it.description(), it.qty(), it.unit(), it.unitPrice(), + label, net)); + } + return folded; } private List findItems(long docId) { 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 b2fa63925..84afd8e65 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 @@ -96,18 +96,39 @@ public DepositNoticeDto createDraft(long ticketId, DepositNoticeDraftRequest req } /** {@link #createDraft} for a caller that has already chosen the source quotation: a non-null - * {@code sourceDepositPercent} (that quotation's own whole-number {@code deposit_percent}) is - * the draft's percent, instead of the per-ticket lookup. */ + * {@code sourceDepositPercent} (the whole-number percent that quotation's PDF printed) is the + * draft's percent, and the ticket's governing quotation is not consulted. */ @Transactional public DepositNoticeDto createDraftFromQuotation(long ticketId, DepositNoticeDraftRequest req, Integer sourceDepositPercent, UserPrincipal actor) { requireRole(actor, SALES_ROLES); + requireTicketOwner(ticketId, actor); + // Owner ruling 2026-10-23: one deposit notice per deal. A new draft would re-seed the % from + // the quotation and, once issued, silently supersede the customer's document. + docs.findByTicket(ticketId).stream().filter(d -> "ISSUED".equals(d.status())).findFirst().ifPresent(d -> { + throw new ApiException(HttpStatus.CONFLICT, + "ดีลนี้ออกใบแจ้งรับมัดจำเลขที่ " + d.docNumber() + " แล้ว จึงสร้างฉบับร่างใหม่ไม่ได้"); + }); TicketSummaryDto s = requireApprovedTicket(ticketId, actor); requireActiveLifecycle(s); + // Both routes: a deal off the deposit path (a ไม่รับมัดจำ quotation, a waiver, a credit + // customer) has no deposit to bill, and issue() would refuse the draft anyway. + if (DepositPolicy.bypassesDepositNotice(s.depositPolicy())) { + throw new ApiException(HttpStatus.CONFLICT, "ดีลนี้ไม่ต้องเก็บมัดจำ (นโยบายมัดจำ " + + s.depositPolicy() + ") จึงสร้างใบแจ้งรับมัดจำไม่ได้"); + } - // Auto-populate items from approved ticket items (legacy) or, failing that, the - // ticket's own customer-quotation chain (new pricing-request flow) if not provided. + // Ticket route: the governing quotation (DepositNoticeRepository#findGoverningQuotation) + // always supplies the percent. It supplies the lines, and with them the reference, unless + // req.items or legacy approved_price rows already supply the lines. + DepositNoticeRepository.GoverningQuotation governing = sourceDepositPercent == null + ? docs.findGoverningQuotation(ticketId).orElse(null) : null; + if (governing != null && governing.depositPercent().signum() == 0) { + throw zeroDepositRefusal(governing.number()); + } List items = buildItemsFromRequest(req, ticketId); + boolean linesFromGoverning = items.isEmpty() && governing != null; + if (linesFromGoverning) items = itemsFromGoverning(governing); List notes = req.notes() != null ? req.notes() : docs.findNoteTemplates().stream() .filter(DocumentNoteTemplateDto::defaultSelected) @@ -127,25 +148,24 @@ public DepositNoticeDto createDraftFromQuotation(long ticketId, DepositNoticeDra // address/tax id differently. CustomerHeaderInfo header = resolveCustomerHeader(s, req.customerTaxId(), req.customerAddress(), req.projectName()); - // Owner ruling (2026-09-29, sales business-logic change, authorised per CLAUDE.md's - // "deposit-notice flow is UNFROZEN"): the deposit PERCENT is LOCKED to the source - // quotation's own sales.quotation.deposit_percent — the value sales set when creating the - // quotation — and is never editable at the deposit-notice stage. A caller-supplied - // req.depositPercent() is deliberately IGNORED as the source here (the quotation always - // wins); DepositNoticeDraftRequest still carries the field only because it round-trips - // through the DTO the frontend/OrderConfirmationService already send. Falls back to the - // document's long-standing 0.50 default only when the ticket has no live quotation to - // read a percent from at all (e.g. a manually-approved legacy ticket with no quotation - // chain) — see DepositNoticeRepository#findDealQuotationDepositPercent's own Javadoc for - // the full pick order. + // Owner rulings (2026-09-29, 2026-10-22, 2026-10-23): the deposit percent is the one the + // source quotation's PDF printed: a legacy quotation 50% (the constant its PDF prints), a + // new-engine one its own deposit_percent, a blank one 30%. The PR route passes it as + // sourceDepositPercent; the ticket route takes it from the governing quotation. On create, + // req.depositPercent() is never read (DepositNoticeDraftRequest carries it for update's + // round trip). 0.50 only when the ticket has no live quotation at all (e.g. a manually + // approved legacy ticket with no quotation chain); see + // DepositNoticeRepository#findGoverningQuotation for the pick. Sales may change the percent + // on the DRAFT afterwards (update, ruling 2026-10-23). BigDecimal effectiveDepositPercent = sourceDepositPercent != null ? BigDecimal.valueOf(sourceDepositPercent, 2) - : docs.findDealQuotationDepositPercent(ticketId).orElse(new BigDecimal("0.50")); + : governing != null ? governing.depositPercent() : new BigDecimal("0.50"); + String reference = req.reference() == null && linesFromGoverning ? governing.number() : req.reference(); var effective = new DepositNoticeDraftRequest( req.customerName() != null ? req.customerName() : s.customerName(), header.taxId(), header.address(), - header.projectName(), req.reference(), + header.projectName(), reference, effectiveDepositPercent, notes, items ); @@ -154,22 +174,37 @@ public DepositNoticeDto createDraftFromQuotation(long ticketId, DepositNoticeDra return docs.findById(docId).orElseThrow(); } + /** The 409 both deposit routes give a ไม่รับมัดจำ (0%) quotation: there is no deposit to bill. */ + public static ApiException zeroDepositRefusal(String quotationNumber) { + return new ApiException(HttpStatus.CONFLICT, "ใบเสนอราคา " + quotationNumber + + " ระบุไม่มีมัดจำ (ชำระเต็มจำนวน) จึงออกใบแจ้งยอดเงินรับมัดจำไม่ได้"); + } + @Transactional public DepositNoticeDto update(long docId, DepositNoticeDraftRequest req, UserPrincipal actor) { + requireRole(actor, SALES_ROLES); DepositNoticeDto doc = requireDraft(docId); requireTicketOwner(doc.ticketId(), actor); - // Same owner ruling as createDraft above: the deposit percent is locked to the quotation - // at draft-creation time and must NEVER change on update. The frontend no longer offers a - // control for it, but a client (or a stale/forged request) sending a different value must - // not be trusted either — override whatever req carries with the draft's own already- - // stored depositPercent() before persisting. + // Owner ruling 2026-10-23: sales may change the deposit percent on a DRAFT (requireDraft + // above 409s an issued notice). docs.update recomputes the amount from it. A request + // without a percent keeps the draft's own. + BigDecimal depositPercent = req.depositPercent() != null ? req.depositPercent() : doc.depositPercent(); + if (depositPercent.signum() <= 0) { + throw new ApiException(HttpStatus.BAD_REQUEST, "เปอร์เซ็นต์มัดจำต้องมากกว่า 0"); + } + // Whole percents only: the document prints the percent rounded (40.5% shows as "41%"). A + // percent stored before that rule (0.3330) still saves while it is sent back unchanged; + // issue() refuses it until sales picks a whole one. + if (depositPercent.compareTo(doc.depositPercent()) != 0 && !isWholePercent(depositPercent)) { + throw new ApiException(HttpStatus.BAD_REQUEST, "เปอร์เซ็นต์มัดจำต้องเป็นจำนวนเต็ม"); + } var effective = new DepositNoticeDraftRequest( req.customerName(), req.customerTaxId(), req.customerAddress(), req.projectName(), req.reference(), - doc.depositPercent(), - req.notes(), req.items() + depositPercent, + req.notes(), req.items(), req.expectedUpdatedAt() ); - docs.update(docId, effective); + docs.update(docId, effective, doc.items()); return docs.findById(docId).orElseThrow(); } @@ -184,11 +219,25 @@ public String preview(long docId, UserPrincipal actor) { } // Issue: assign doc number, freeze document, transition ticket to document_issued + // Without a stamp. @Transactional here too: a self-call from this overload would bypass the proxy. @Transactional public DepositNoticeDto issue(long docId, UserPrincipal actor) { + return issue(docId, null, actor); + } + + @Transactional + public DepositNoticeDto issue(long docId, java.time.OffsetDateTime expectedUpdatedAt, UserPrincipal actor) { DepositNoticeDto doc = requireDraft(docId); requireRole(actor, SALES_ROLES); requireTicketOwner(doc.ticketId(), actor); + // The stored percent is what the document prints: outside a whole 1-100 (a 0% ticket-route + // draft, a 0.3330 typed before whole percents) it takes no number until fixed on the draft. + BigDecimal pct = doc.depositPercent(); + if (pct.signum() <= 0 || pct.compareTo(BigDecimal.ONE) > 0 || !isWholePercent(pct)) { + throw new ApiException(HttpStatus.CONFLICT, "ใบแจ้งรับมัดจำนี้มีเปอร์เซ็นต์มัดจำ " + + pct.movePointRight(2).stripTrailingZeros().toPlainString() + + "% จึงออกเอกสารไม่ได้ (ต้องเป็นจำนวนเต็ม 1–100)"); + } // Issuing the deposit-notice DOCUMENT is the payment-track step: it requires a // customer-confirmed quotation and advances paymentStatus, leaving the main @@ -210,8 +259,8 @@ public DepositNoticeDto issue(long docId, UserPrincipal actor) { // not left to fall through to advancePaymentStatus below, which would throw an uncaught // IllegalStateException (ApiExceptionHandler has no handler for it, so it would surface as // an opaque 500, not a controlled conflict). Found while writing PaymentTrackIntegrationTest's - // off-path case; a bypass-policy deal CAN reach a DRAFT deposit notice today (createDraft - // does not check deposit_policy), so this is a genuinely reachable path, not hypothetical. + // off-path case; a bypass-policy deal CAN still hold a DRAFT deposit notice (one drafted while + // the deal was REQUIRED survives a later waiver), so this is a genuinely reachable path. // On a bypass policy, issuing a deposit notice now throws — a deliberate behaviour change // from rule 4; see the branch report. boolean paymentTrackReady = !DepositPolicy.bypassesDepositNotice(s.depositPolicy()) @@ -220,11 +269,26 @@ public DepositNoticeDto issue(long docId, UserPrincipal actor) { throw new ApiException(HttpStatus.CONFLICT, "ออกใบแจ้งรับมัดจำได้เฉพาะเมื่อออกใบเสนอราคาแล้วและลูกค้ายืนยันคำสั่งซื้อแล้วเท่านั้น"); } + // A 0-baht draft (e.g. a ticket-route draft on a deal whose quotation says ไม่รับมัดจำ but + // whose policy predates the confirmation sync) must not take a document number. + if (doc.depositAmount() == null || doc.depositAmount().signum() <= 0) { + throw new ApiException(HttpStatus.CONFLICT, "ใบแจ้งรับมัดจำนี้มียอดมัดจำ 0 บาท จึงออกเอกสารไม่ได้"); + } + // The guard below is check-then-act: lock the deal row first so two drafts issued at once + // serialize, and the second one sees the first as ISSUED. + tickets.lockTicketForUpdate(doc.ticketId()); + // Owner ruling 2026-10-23: one deposit notice per deal. A second draft (written before the + // first was issued) must not silently supersede the customer's document. + docs.findByTicket(doc.ticketId()).stream() + .filter(d -> d.id() != docId && "ISSUED".equals(d.status())).findFirst().ifPresent(d -> { + throw new ApiException(HttpStatus.CONFLICT, + "ดีลนี้ออกใบแจ้งรับมัดจำเลขที่ " + d.docNumber() + " แล้ว จึงออกฉบับร่างนี้ไม่ได้"); + }); // Issuing advances the payment track — a paused/terminal deal must not move // (Phase 1 lifecycle gate; mirrors TicketService.requireActive). requireActiveLifecycle(s); - String docNumber = docs.issue(docId, actor.id(), actor.name()) + String docNumber = docs.issue(docId, actor.id(), actor.name(), expectedUpdatedAt) .orElseThrow(() -> new ApiException(HttpStatus.CONFLICT, "ใบแจ้งรับมัดจำนี้ถูกออกไปแล้วโดยผู้ใช้อื่น กรุณาโหลดข้อมูลใหม่แล้วลองอีกครั้ง")); @@ -838,7 +902,7 @@ private DepositRefBundle resolveDepositReferenceOptions(long ticketId, DepositNo /** Latest ACCEPTED revision only — see {@link #resolveRemainingInvoice}'s own Javadoc for why * this deliberately does NOT fall back to the latest ISSUED-but-not-yet-accepted one the way - * {@link #pickQuotation} does for the deposit notice. */ + * {@link DepositNoticeRepository#findGoverningQuotation} does for the deposit notice. */ private CustomerQuotationDto pickAcceptedQuotation(List candidates) { CustomerQuotationDto latestAccepted = null; for (CustomerQuotationDto q : candidates) { @@ -1096,7 +1160,7 @@ private DepositNoticeDto requireDraft(long docId) { DepositNoticeDto doc = docs.findById(docId) .orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "ไม่พบใบแจ้งรับมัดจำนี้")); if (!"DRAFT".equals(doc.status())) { - throw new ApiException(HttpStatus.CONFLICT, "ใบแจ้งรับมัดจำนี้ไม่ได้อยู่ในสถานะร่าง (DRAFT)"); + throw new ApiException(HttpStatus.CONFLICT, DepositNoticeRepository.NOT_DRAFT); } return doc; } @@ -1147,6 +1211,11 @@ private void requireRole(UserPrincipal actor, java.util.Set allowed) { } } + /** 0.40 is a whole 40%; 0.405 and 0.3330 are not. */ + private static boolean isWholePercent(BigDecimal fraction) { + return fraction.movePointRight(2).stripTrailingZeros().scale() <= 0; + } + private String blankToNull(String value) { return value == null || value.isBlank() ? null : value; } @@ -1215,24 +1284,16 @@ private List buildItemsFromRequest(DepositNoticeDraftR // Legacy path: sales.ticket_item.approved_price, written only by the @Deprecated // TicketService.approve (no controller route) — kept working for any ticket that still // carries it, e.g. one manually approved before the pricing-request chain existed. - List legacyItems = buildLegacyItems(ticketId); - if (!legacyItems.isEmpty()) return legacyItems; - - // New-chain fallback (this branch's fix): every deal created through the pricing-request - // chain has approved_price = NULL on every ticket_item (see this class's own diagnosis in - // the branch handoff) — buildLegacyItems above always returns empty for such a deal, which - // is exactly the bug. Source items from the ticket's own customer quotation instead. - CustomerQuotationDto chosen = pickQuotation(quotations.findByTicket(ticketId)); - if (chosen != null) { - return itemsFromQuotation(chosen.items()); - } + return buildLegacyItems(ticketId); + } - // V2 deal-quotation chain (2026-09-29 fix): the pickQuotation/findByTicket source above is - // scoped to origin IS NULL, so it never returns a DEAL_DIRECT or PRICING_REQUEST quotation - // — every deal issued through the deal-quotation flow fell through to an empty item list. - // docs.findDealQuotationItemsForDeposit reads the ticket's live deal quotation (ACCEPTED, - // else ISSUED) directly; see that method's Javadoc for the selection and column choices. - return docs.findDealQuotationItemsForDeposit(ticketId); + /** Every deal created through the pricing-request chain has approved_price = NULL on every + * ticket_item, so its lines come from the governing quotation, whichever engine it is. */ + private List itemsFromGoverning(DepositNoticeRepository.GoverningQuotation governing) { + if (!governing.legacy()) return docs.findDealQuotationItems(governing.quotationId()); + return quotations.findById(governing.quotationId()) + .map(q -> itemsFromQuotation(q.items())) + .orElse(List.of()); } private List buildLegacyItems(long ticketId) { @@ -1256,31 +1317,6 @@ private List buildLegacyItems(long ticketId) { .orElse(List.of()); } - /** - * Picks the quotation to source deposit-notice items from when no explicit items were given - * and no legacy {@code approved_price} exists: the LATEST ACCEPTED revision, or — if the - * customer has not yet accepted any revision — the latest ISSUED one. {@code - * CustomerQuotationRepository#findByTicket} returns rows ordered ASCENDING by {@code - * quotation_id} ALONE — deliberately NOT by {@code quotation_revision_no} (see that method's - * own Javadoc: the revision counter is scoped to a single pricing request and is not - * comparable across the multiple pricing requests one ticket can have) — so a single forward - * scan that keeps overwriting "latest seen" for each status is equivalent to sorting - * descending by {@code quotation_id} and taking the first match, without a second - * list/comparator. - */ - private CustomerQuotationDto pickQuotation(List candidates) { - CustomerQuotationDto latestAccepted = null; - CustomerQuotationDto latestIssued = null; - for (CustomerQuotationDto q : candidates) { - if (QuotationStatus.ACCEPTED.equals(q.docStatus())) { - latestAccepted = q; - } else if (QuotationStatus.ISSUED.equals(q.docStatus())) { - latestIssued = q; - } - } - return latestAccepted != null ? latestAccepted : latestIssued; - } - /** The pricing-request route's new-engine item source, so the draft's rows come from the * quotation its reference names. */ public List itemsFromDealQuotation(long quotationId) { @@ -1289,7 +1325,7 @@ public List itemsFromDealQuotation(long quotationId) { /** * Maps a customer quotation's items to deposit-notice item requests. The single shared mapping - * used by both {@link #buildItemsFromRequest} (this class's own ticket-chain fallback above) + * used by both {@link #itemsFromGoverning} (a legacy governing quotation's lines, above) * and {@code OrderConfirmationService.createDepositNoticeFromQuotation} (the explicit * quotation-driven entry point), so the two paths can never drift apart — extracted here * (rather than duplicated, as it was before this branch) because both already depend on this diff --git a/backend/src/main/java/th/co/glr/hr/orderconfirmation/OrderConfirmationRequests.java b/backend/src/main/java/th/co/glr/hr/orderconfirmation/OrderConfirmationRequests.java index 9b41474a5..af85fb24e 100644 --- a/backend/src/main/java/th/co/glr/hr/orderconfirmation/OrderConfirmationRequests.java +++ b/backend/src/main/java/th/co/glr/hr/orderconfirmation/OrderConfirmationRequests.java @@ -11,9 +11,10 @@ private OrderConfirmationRequests() {} public record ConfirmOrderRequest(String clientRequestId) {} /** - * {@code depositPercent} is optional — {@code null} lets {@code DepositNoticeService - * .createDraft}/{@code DepositNoticeRepository.createDraft} apply their own existing 50% - * default, exactly as the legacy deposit-notice draft flow already does. + * {@code depositPercent} is never read (owner ruling 2026-10-23): the draft bills the percent + * the accepted quotation's PDF printed. Deleting the field would refuse no client, because + * unknown JSON fields are ignored; while it stays, its bounds still answer 400 to a stale + * client that sends a value outside 0-1. */ public record CreateDepositNoticeFromQuotationRequest( @DecimalMin("0") @DecimalMax("1") BigDecimal depositPercent 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 5ac3ab64e..c8b6b8e7a 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 @@ -27,6 +27,7 @@ import th.co.glr.hr.pricingrequest.PricingRequestRepository.OrderConfirmationState; import th.co.glr.hr.pricingrequest.PricingRequestStatus; import th.co.glr.hr.pricingrequest.UnitBasis; +import th.co.glr.hr.ticket.QuotationRenderer; import th.co.glr.hr.ticket.QuotationStatus; import th.co.glr.hr.ticket.TicketDto; import th.co.glr.hr.ticket.TicketEventKind; @@ -216,10 +217,25 @@ public OrderConfirmationResultDto confirmOrder(long pricingRequestId, ConfirmOrd // customer-confirmed yet; a later revision's own confirmOrder call only needs the // reconciliation (already done above) and its own event/notification below. TicketSummaryDto currentTicketState = requireTicketSummary(locked.ticketId()); - TicketDto ticketDto = currentTicketState.paymentStatus() == null - ? ticketService.confirmCustomer(locked.ticketId(), actor) - : tickets.findById(locked.ticketId()) + TicketDto ticketDto; + if (currentTicketState.paymentStatus() == null) { + // The accepted new-engine quotation's percent decides the deposit policy (owner ruling + // 2026-10-22, see TicketService#applyCustomerConfirmation; a blank percent means 30, ruling 10-23). + // A legacy quotation carries none, so the policy is left alone. + th.co.glr.hr.dealquotation.DealQuotationDtos.DealQuotationDto confirmed = dealQuotations == null + ? null + : dealQuotations.findAcceptedForPricingRequest(pricingRequestId) + .flatMap(dealQuotations::findById) + .orElse(null); + ticketDto = ticketService.confirmCustomer(locked.ticketId(), + confirmed == null ? null : confirmed.number(), + confirmed == null ? null + : th.co.glr.hr.dealquotation.DealQuotationRenderAdapter.effectiveDepositPercent(confirmed.depositPercent()), + actor); + } else { + ticketDto = tickets.findById(locked.ticketId()) .orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "ไม่พบดีลนี้")); + } pricingRequests.addEvent(pricingRequestId, locked.ticketId(), actor.id(), actor.name(), PricingRequestEventKind.ORDER_CONFIRMED, locked.status(), locked.status(), @@ -421,16 +437,12 @@ private String mapUnitBasisToTicketItem(String unitBasis) { * the ticket's own customer/project, same as the ticket-level route) — see {@code * DepositNoticeService.createDraft}'s own comments for what changed and why. * - *

    Deposit percent (owner ruling, 2026-09-29): {@code - * request.depositPercent()} below is passed through but {@link - * DepositNoticeService#createDraft} now IGNORES it unconditionally and re-derives the percent - * itself from {@code accepted}'s own {@code sales.quotation.deposit_percent} (legacy branch; - * the new-engine paragraph below covers the other branch) via {@code - * DepositNoticeRepository#findDealQuotationDepositPercent(ticketId)} — this call site needs no - * separate fix because it already funnels through that one shared method. Left the field on - * {@link CreateDepositNoticeFromQuotationRequest} itself unremoved (an older frontend build - * may still send it) rather than making it a breaking DTO change for a value that is now a - * no-op either way. + *

    Deposit percent (owner rulings 2026-09-29, 2026-10-23): {@code + * request.depositPercent()} is never read: the percent is the one the chosen quotation's PDF + * printed, passed as {@code sourceDepositPercent}. For the legacy branch that is always {@link + * QuotationRenderer#LEGACY_DEPOSIT_PERCENT}, because the legacy PDF never reads the stored + * column. Deleting the field from {@link CreateDepositNoticeFromQuotationRequest} would refuse + * no client (unknown JSON fields are ignored); its bounds still 400 an out-of-range value. * *

    New-engine quotation: when the pricing request has no ACCEPTED legacy * quotation, this falls back to its ACCEPTED new-engine {@code PRICING_REQUEST}-origin @@ -440,13 +452,11 @@ private String mapUnitBasisToTicketItem(String unitBasis) { * the accepted quotation of THIS pricing request ({@link * DepositNoticeService#itemsFromDealQuotation}), so a sibling pricing request's quotation on * the same ticket can never supply them. The percent also comes from that quotation: its own - * {@code deposit_percent} goes to {@link DepositNoticeService#createDraftFromQuotation}, so - * when the quotation sets a percent a sibling pricing request's legacy row on the same ticket - * cannot supply it either (a new-engine sibling never could: R8 allows one ACCEPTED quotation - * per ticket and the lookup prefers ACCEPTED); a null percent still falls back to the - * per-ticket lookup. The legacy branch passes no percent, because {@code CustomerQuotationDto} - * carries none, so it keeps {@code createDraft}'s per-ticket derivation; the ticket-level - * route does the same. The customer must have accepted: an ISSUED-only quotation answers 409. + * {@code deposit_percent}, a null one as the PDF's default 30%, goes to {@link + * DepositNoticeService#createDraftFromQuotation}. Neither branch consults the + * ticket's governing quotation ({@code DepositNoticeRepository#findGoverningQuotation}), + * which only the ticket-level route uses. The customer must have accepted: an ISSUED-only + * quotation answers 409. */ @Transactional public DepositNoticeDto createDepositNoticeFromQuotation(long pricingRequestId, @@ -466,7 +476,7 @@ public DepositNoticeDto createDepositNoticeFromQuotation(long pricingRequestId, if (accepted != null) { reference = accepted.number(); items = DepositNoticeService.itemsFromQuotation(accepted.items()); - sourceDepositPercent = null; + sourceDepositPercent = QuotationRenderer.LEGACY_DEPOSIT_PERCENT; } else { th.co.glr.hr.dealquotation.DealQuotationDtos.DealQuotationDto newEngine = dealQuotations == null ? null @@ -477,23 +487,20 @@ public DepositNoticeDto createDepositNoticeFromQuotation(long pricingRequestId, throw new ApiException(HttpStatus.CONFLICT, "ยังไม่มีใบเสนอราคาที่ลูกค้ายอมรับสำหรับคำขอราคานี้"); } // A zero-deposit quotation (DealQuotationService#isZeroDeposit: "ไม่รับมัดจำ", paid in - // full under fullPaymentTerm) has nothing to bill. Refuse here rather than draft a - // 0-baht notice: DepositNoticeService#issue reads the ticket's deposit_policy, which no - // path syncs from the quotation, so nothing downstream would stop that draft. + // full under fullPaymentTerm) has nothing to bill. Since #1173 the confirmation above + // moves such a deal to NOT_REQUIRED and createDraftFromQuotation refuses a bypass policy; + // this check stays for deals confirmed before that sync existed (still REQUIRED). if (Integer.valueOf(0).equals(newEngine.depositPercent())) { - throw new ApiException(HttpStatus.CONFLICT, "ใบเสนอราคา " + newEngine.number() - + " ระบุไม่มีมัดจำ (ชำระเต็มจำนวน) จึงออกใบแจ้งยอดเงินรับมัดจำไม่ได้"); + throw DepositNoticeService.zeroDepositRefusal(newEngine.number()); } reference = newEngine.number(); items = depositNotices.itemsFromDealQuotation(newEngine.id()); - sourceDepositPercent = newEngine.depositPercent(); + sourceDepositPercent = newEngine.depositPercent() != null ? newEngine.depositPercent() + : th.co.glr.hr.dealquotation.DealQuotationRenderAdapter.DEFAULT_DEPOSIT_PERCENT; } - // request.depositPercent() is deliberately still passed through here even though - // createDraft now ignores it as the source — see this method's own Javadoc above. DepositNoticeDraftRequest draftRequest = new DepositNoticeDraftRequest( - null, null, null, null, reference, - request != null ? request.depositPercent() : null, null, items); + null, null, null, null, reference, null, null, items); DepositNoticeDto draft = depositNotices.createDraftFromQuotation(summary.ticketId(), draftRequest, sourceDepositPercent, actor); diff --git a/backend/src/main/java/th/co/glr/hr/pricingcosting/LandedCostCalculator.java b/backend/src/main/java/th/co/glr/hr/pricingcosting/LandedCostCalculator.java index 111afe467..3b13a1f71 100644 --- a/backend/src/main/java/th/co/glr/hr/pricingcosting/LandedCostCalculator.java +++ b/backend/src/main/java/th/co/glr/hr/pricingcosting/LandedCostCalculator.java @@ -426,8 +426,12 @@ private List costShipment(List allItems, */ private PricingCostingWriteItem uncostableWriteItem(ItemPhysicals item, PricingFormulaConfigDto formulaConfig) { ResolvedSource source = item.source(); - BigDecimal insurancePerPiece = money4( + // goods_cost_thb / insurance_cost_thb are NOT NULL (V156 left them so): a line whose goods + // cost could not be computed (missing sqm on a PER_SQM basis) records 0 here and the + // uncostable reason says why. ponytail: nullable columns via migration if 0 misleads. + BigDecimal insurancePerPiece = item.insuranceThb() == null ? ZERO : money4( item.insuranceThb().divide(item.qtyPieces(), 8, RoundingMode.HALF_UP)); + BigDecimal goodsCostPerPiece = item.goodsCostPerPiece() == null ? ZERO : item.goodsCostPerPiece(); return new PricingCostingWriteItem( source.requestItem().id(), source.quote().id(), @@ -443,7 +447,7 @@ private PricingCostingWriteItem uncostableWriteItem(ItemPhysicals item, PricingF source.requestItem().requestedQty(), source.requestItem().requestedUnit(), source.requestItem().requestedUnitBasis(), - item.qtyPieces(), + item.qtyPieces() == null ? ZERO : item.qtyPieces(), // NOT NULL downstream (V72): 0 = unknown, line is uncostable source.quoteItem().linearMPerUnit(), item.sqmPerPiece(), source.quoteItem().piecesPerBox(), @@ -453,7 +457,7 @@ private PricingCostingWriteItem uncostableWriteItem(ItemPhysicals item, PricingF item.fx().fetchedAt(), formulaConfig.formulaConfigId(), formulaConfig.version(), - item.goodsCostPerPiece(), + goodsCostPerPiece, null, // freight_cost_thb — the freight table cannot be looked up insurancePerPiece, null, // import_duty_thb — duty applies to CIF, which needs freight @@ -477,17 +481,32 @@ private ItemPhysicals resolveItemPhysicals(ResolvedSource source, PricingFormula FxSnapshot fx = resolveFx(source.quoteItem().currency()); BigDecimal rawThb = source.quoteItem().rawUnitPrice().multiply(fx.rate()); - BigDecimal goodsCostPerPiece = money4(pricePerPiece(rawThb, source.quoteItem().unitBasis(), - sqmPerPiece, piecesPerBox, linearMPerUnit, source.requestItem())); - BigDecimal qtyPieces = quantityToPieces(source.requestItem().requestedQty(), - source.requestItem().requestedUnitBasis(), sqmPerPiece, piecesPerBox, linearMPerUnit, source.requestItem()); - BigDecimal qtySqm = sqmPerPiece.multiply(qtyPieces); - // C = P x E, goods cost in THB, for this item's FULL requested quantity (a TOTAL, not a - // per-piece figure) — see PricingFormulaEngine's class Javadoc for why C must be a total - // (TC = (C+i+F)x(1+T)xcost_buffer + S is dimensionally a total, divided by Q at the very - // end to recover a per-sqm unit cost). - BigDecimal goodsCostThb = money4(goodsCostPerPiece.multiply(qtyPieces)); - BigDecimal insuranceThb = formulaEngine.insurance(goodsCostThb, formulaConfig); + // No sqm-per-piece AND a basis that needs it (quote or requested PER_SQM): the conversions + // below would throw 422 for the whole shipment (requireFactor) before this line could be set + // aside. Leave every piece-derived figure null instead; uncostableReason() sees the null + // sqm and lists the line as uncostable while the shipment's other lines still cost. + boolean sqmNeededButMissing = sqmPerPiece == null + && (UnitBasis.PER_SQM.equals(source.quoteItem().unitBasis()) + || UnitBasis.PER_SQM.equals(source.requestItem().requestedUnitBasis())); + BigDecimal goodsCostPerPiece = null; + BigDecimal qtyPieces = null; + BigDecimal qtySqm = null; + BigDecimal goodsCostThb = null; + BigDecimal insuranceThb = null; + if (!sqmNeededButMissing) { + goodsCostPerPiece = money4(pricePerPiece(rawThb, source.quoteItem().unitBasis(), + sqmPerPiece, piecesPerBox, linearMPerUnit, source.requestItem())); + qtyPieces = quantityToPieces(source.requestItem().requestedQty(), + source.requestItem().requestedUnitBasis(), sqmPerPiece, piecesPerBox, linearMPerUnit, source.requestItem()); + // sqmPerPiece is null for a line whose sqm could not be resolved (resolveSqmPerPiece's P0 + // hardening). It is then UNCOSTABLE: set aside in costShipment, never routed through the + // freight grouping that is qtySqm's only consumer, so a null qtySqm here is correct. + qtySqm = sqmPerPiece == null ? null : sqmPerPiece.multiply(qtyPieces); + // C = P x E, goods cost in THB, for this item's FULL requested quantity (a TOTAL, not a + // per-piece figure); see PricingFormulaEngine's class Javadoc. + goodsCostThb = money4(goodsCostPerPiece.multiply(qtyPieces)); + insuranceThb = formulaEngine.insurance(goodsCostThb, formulaConfig); + } BigDecimal thicknessMm = resolveThicknessMm(source.requestItem(), catalogKeys); String originCountryCode = resolveOriginCountryCode(source.requestItem(), catalogKeys); String productType = resolveProductType(source.requestItem()); @@ -525,11 +544,17 @@ private ItemPhysicals resolveItemPhysicals(ResolvedSource source, PricingFormula private BigDecimal resolveThicknessMm(PricingRequestItemDto requestItem, Map catalogKeys) { Long priceId = catalogPriceId(requestItem); - if (priceId == null) { - return null; + BigDecimal fromCatalog = null; + if (priceId != null) { + CatalogRepository.CatalogPricingKey key = catalogKeys.get(priceId); + fromCatalog = key == null ? null : key.thicknessMm(); } - CatalogRepository.CatalogPricingKey key = catalogKeys.get(priceId); - return key == null ? null : key.thicknessMm(); + // Catalog wins; fall back to the Sales-entered thickness on the request item (V185) when + // the catalogue carries none (a free-text line or a blank catalogue row). It is never + // written to the catalogue: unreviewed. Nothing is priced on a guessed thickness; this is + // a value a human (Sales) entered for THIS line. Mirrors resolveSqmPerPiece's + // catalog-then-request fallback shape. + return fromCatalog != null ? fromCatalog : requestItem.thicknessMm(); } /** @@ -560,20 +585,37 @@ private String resolveOriginCountryCode(PricingRequestItemDto requestItem, } /** - * Why this item cannot be costed, in the CEO's own language, or null when it can. Both inputs - * are freight-table lookup keys ({@code sales.pricing_freight_rate}), so missing either one - * makes the lookup impossible rather than merely inaccurate — see V156. + * Why this item cannot be costed, in the CEO's own language, or null when it can. All three + * inputs feed the freight calculation ({@code sales.pricing_freight_rate} is keyed on thickness + * and origin country; its {@code Q} is in sqm), so missing any one makes the freight lookup + * impossible rather than merely inaccurate — see V156 (thickness/origin) and the P0 sqm + * hardening (2026-10). sqm-per-piece has a DIFFERENT source from thickness/origin (the factory + * quote's {@code sqm_per_unit} or the request's own {@code requested_qty_sqm}, NOT the Price + * Catalog), so it carries its own clause. */ private String uncostableReason(ItemPhysicals item) { + boolean noSqm = item.sqmPerPiece() == null; boolean noThickness = item.thicknessMm() == null; boolean noCountry = item.originCountryCode() == null; - if (!noThickness && !noCountry) { + if (!noSqm && !noThickness && !noCountry) { return null; } + String label = itemLabel(item.source().requestItem()); + if (noSqm) { + StringBuilder missing = new StringBuilder("ตร.ม./แผ่น (sqm per piece)"); + if (noThickness) { + missing.append(", ความหนา (thickness_mm)"); + } + if (noCountry) { + missing.append(", ประเทศต้นทาง (origin country)"); + } + return label + " ไม่มี" + missing + " ที่จำเป็นต่อการคำนวณค่าขนส่ง " + + "— คำนวณต้นทุนอัตโนมัติไม่ได้ กรุณาระบุ ตร.ม./แผ่น ในใบเสนอราคาโรงงาน หรือระบุต้นทุนเอง"; + } String missing = noThickness && noCountry ? "ความหนา (thickness_mm) และประเทศต้นทาง" : noThickness ? "ความหนา (thickness_mm)" : "ประเทศต้นทาง (origin country)"; - return itemLabel(item.source().requestItem()) + " ไม่มี" + missing + " จาก Price Catalog " + return label + " ไม่มี" + missing + " จาก Price Catalog " + "— คำนวณค่าขนส่งอัตโนมัติไม่ได้ กรุณาระบุต้นทุนเอง หรือเชื่อมรายการนี้กับสินค้าใน Price Catalog " + "ที่มีข้อมูลครบ"; } @@ -746,13 +788,28 @@ private FxSnapshot resolveFx(String currencyValue) { } /** - * The sqm-per-piece physical conversion factor for a line, needed unconditionally: freight/ - * insurance are always priced against sqm-derived quantities (see {@link #costShipment}), - * regardless of whether either side's unit basis is PER_SQM. Prefers the factory quote item's - * own {@code sqmPerUnit} (what Import entered when recording the response); falls back to the - * pricing-request item's requestedQtySqm/requestedQty ratio only when the quote item did not - * provide one. Does NOT silently default to 1 when neither source has data — that would cost - * every piece as if it were exactly 1 sqm, very rarely true — it 422s instead. + * The sqm-per-piece physical conversion factor for a line: freight is priced against + * sqm-derived quantities for EVERY item (see {@link #costShipment}), regardless of whether + * either side's unit basis is PER_SQM. Prefers the factory quote item's own {@code sqmPerUnit} + * (what Import entered when recording the response); falls back to the pricing-request item's + * requestedQtySqm/requestedQty ratio only when the quote item did not provide one. Does NOT + * silently default to 1 when neither source has data — that would cost every piece as if it + * were exactly 1 sqm, very rarely true. + * + *

    Returns null rather than throwing (P0 hardening, 2026-10). It used to throw 422 + * here, which — exactly like the pre-V156 missing-thickness defect — aborted the WHOLE + * calculation before any costing row was written, poisoning every other (costable) line in the + * request and blocking the CEO from the manual-cost override screen that resolves it. A null now + * marks the line UNCOSTABLE (see {@link #uncostableReason}): it is set aside with a stated reason + * and NULL freight-dependent costs, the rest of the request still costs, and + * {@code PricingDecisionService#approve} is what refuses to let the uncostable line through + * un-resolved. Nothing is ever priced on a guessed sqm — the guarantee moved, it did not weaken. + * + *

    This is a LEGACY-data path: V185 derives {@code requested_qty_sqm} on every new request, and + * {@code FactoryQuoteService#receive} requires {@code sqmPerUnit} for a PER_SQM quote, so a new + * deal always resolves here. A missing sqm therefore only ever reaches a quote whose own goods + * cost needs NO sqm (PER_PIECE/PER_BOX/PER_LINEAR_M) — a PER_SQM quote with no sqm cannot even + * compute its goods cost and still fails loudly in {@link #pricePerPiece}. */ private BigDecimal resolveSqmPerPiece(FactoryQuoteItemDto quoteItem, PricingRequestItemDto requestItem) { BigDecimal fromQuote = quoteItem.sqmPerUnit(); @@ -766,7 +823,7 @@ private BigDecimal resolveSqmPerPiece(FactoryQuoteItemDto quoteItem, PricingRequ return fromRequest; } } - throw missingFactor(requestItem, "sqmPerUnit"); + return null; } /** 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 685b0869d..b446d0cc6 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 @@ -421,6 +421,13 @@ public PricingDecisionDto overrideItemCost(long decisionId, long itemId, CostOve .orElseThrow(() -> new ApiException(HttpStatus.CONFLICT, "ไม่พบรายการต้นทุนที่ผูกกับมติราคานี้")); BigDecimal roundedManualCost = manualCost == null ? null : money4(manualCost); + if (roundedManualCost != null && (costingItem.normalizedQuantityPieces() == null + || costingItem.normalizedQuantityPieces().signum() == 0)) { + // No piece count (no sqm-per-piece on a PER_SQM basis): per-piece cost x 0 would total 0 THB. + throw new ApiException(HttpStatus.CONFLICT, + "รายการนี้ยังไม่มีจำนวนแผ่น (ไม่มี ตร.ม./แผ่น) จึงใส่ต้นทุนเองไม่ได้ " + + "กรุณาให้ฝ่ายนำเข้าระบุ ตร.ม./แผ่น ในใบเสนอราคาโรงงานก่อน"); + } if (roundedManualCost != null) { costings.applyOverride(costingItem.id(), roundedManualCost, request.reason(), actor.id(), costingItem.fxRate(), costingItem.calculationConfigVersion()); 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 40a54010e..6b84e7694 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 @@ -310,6 +310,8 @@ public PricingRequestDetailDto submit(long id, UserPrincipal actor) { "ไม่พบสินค้าใน price catalog ที่ active สำหรับรายการ: " + unresolvableCatalogItems); } requests.snapshotCatalogSelections(id); + // A thickness Sales typed stays on the item: the shared catalogue is never written from an + // unreviewed entry. LandedCostCalculator#resolveThicknessMm falls back to it. items = requests.findItems(id); // REMOVED 2026-08-11 (owner request): "Finding A" (financial-integrity review, commit 3) // used to require a fully-populated catalog snapshot on EVERY line here, 422-ing with 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 416d4b542..4fd34ff40 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 @@ -73,6 +73,12 @@ public class QuotationRenderer { private static final Logger log = LoggerFactory.getLogger(QuotationRenderer.class); + /** The deposit percent the legacy PDF prints when its {@code QuotationDto} carries none. A + * pricing-chain legacy quotation never carries one ({@code CustomerQuotationService + * #loadRenderContext}), so its PDF always prints this, and its deposit notice bills it (owner + * ruling 2026-10-23). */ + public static final int LEGACY_DEPOSIT_PERCENT = 50; + // Real company XLS template — sheet "Update" private static final String TEMPLATE = "templates/quotation_template.xls"; @@ -322,7 +328,7 @@ private QuotationRenderModel buildLegacyModel(TicketDto ticket, QuotationDto quo List items = priceItems.stream().map(this::toLegacyRenderItem).toList(); LocalDate offerDate = quotation.offerDate() != null ? quotation.offerDate() : issueDate; - int depositPct = quotation.depositPercent() != null ? quotation.depositPercent() : 50; + int depositPct = quotation.depositPercent() != null ? quotation.depositPercent() : LEGACY_DEPOSIT_PERCENT; int deliveryDays = quotation.deliveryLeadDays() != null ? quotation.deliveryLeadDays() : 90; List remarkLines = List.of( "1.จำนวนที่เสนอข้างต้นเป็นจำนวนที่ได้รับมาเมื่อวันที่ " + shortThaiDate(offerDate), 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 df2a0447c..b02fe84bc 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 @@ -888,12 +888,20 @@ private List findItemsByTicketId(long ticketId) { * own Javadoc on why this must not reach {@link #findById}/mutation call sites) falls back to * the deal's live quotation's line items, rendered READ-ONLY. * - *

    "Live" is picked with the EXACT SAME rule as {@code DepositNoticeRepository - * #findDealQuotationItemsForDeposit} (ACCEPTED beats ISSUED beats APPROVED, across both - * PRICING_REQUEST and DEAL_DIRECT origins, most recent id wins a tie) -- duplicated here - * rather than called cross-package because deposit-notice and ticket-detail are separate - * bounded contexts, but it is the SAME business rule and must be changed in lockstep if that - * one ever changes. + *

    "Live" is a display pick over new-engine rows only: ACCEPTED beats ISSUED beats APPROVED, + * across both PRICING_REQUEST and DEAL_DIRECT origins, most recent id wins a tie. It is NOT the + * ticket-route deposit notice's pick ({@code DepositNoticeRepository#findGoverningQuotation}), + * which also reads legacy rows and, by owner ruling 2026-10-23, counts a DEAL_DIRECT approval + * as the answer, level with ACCEPTED. The two agree on a ticket with one live new-engine + * quotation. They differ when the ticket also has a live legacy quotation. Two DEAL_DIRECT + * cases exist only on pre-lock data (a DEAL_DIRECT quotation beside a pricing-request one, + * which {@code DirectQuotationLocks} now refuses): an ACCEPTED quotation followed by a newer + * DEAL_DIRECT APPROVED one ({@code DealQuotationRepository#supersedeOtherLiveOnTicket} never + * supersedes an ACCEPTED row), where this shows the ACCEPTED lines and a ticket-route draft + * bills the DEAL_DIRECT one; and data from before 2026-10-17 that still holds both a + * DEAL_DIRECT APPROVED and a PRICING_REQUEST ISSUED row. + * The UI drafts any deal with a QUOTATION_ACCEPTED pricing request through the + * pricing-request route, which bills that request's accepted quotation instead. * *

    Only brand/model/color/texture/size/qty/qtySqm/unitBasis/rawUnit are mapped -- pricing * fields (proposedPrice/approvedPrice/calcedCost/manualPrice/...) are deliberately left at @@ -1562,6 +1570,36 @@ public void updateDepositPolicy(long ticketId, String policy, String reason, lon .addValue("id", ticketId)); } + /** + * The system's only write to {@code deposit_policy} (see {@code TicketService + * #followQuotationDepositPolicy}). A compare-and-set: it moves the row from {@code expected} to + * {@code policy} only while no person has set the policy ({@code deposit_policy_set_by IS NULL}; + * {@link #updateDepositPolicy} always records one) and the payment track is still at + * CUSTOMER_CONFIRMED. It leaves {@code deposit_policy_set_by} NULL, so the row stays the + * system's. Returns the rowcount; 0 means leave the policy alone, e.g. a waiver committed first. + */ + public int syncDepositPolicyFromQuotation(long ticketId, String expected, String policy, String reason) { + // Dropping to NOT_REQUIRED is refused once a deposit notice is ISSUED: money was asked for. + String noIssuedNotice = DepositPolicy.NOT_REQUIRED.equals(policy) ? """ + AND NOT EXISTS (SELECT 1 FROM sales.deposit_notice d + WHERE d.ticket_id = sales.ticket.ticket_id AND d.status = 'ISSUED') + """ : ""; + return jdbc.update(""" + UPDATE sales.ticket + SET deposit_policy = :policy, deposit_policy_reason = :reason, + deposit_policy_set_by = NULL, updated_at = now() + WHERE ticket_id = :id + AND deposit_policy = :expected + AND deposit_policy_set_by IS NULL + AND payment_status = 'CUSTOMER_CONFIRMED' + """ + noIssuedNotice, + new MapSqlParameterSource() + .addValue("policy", policy) + .addValue("reason", reason) + .addValue("expected", expected) + .addValue("id", ticketId)); + } + public void updateEntryChannel(long ticketId, String value) { jdbc.update( "UPDATE sales.ticket SET entry_channel = :value, updated_at = now() WHERE ticket_id = :id", 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 8b9a34e0e..b134178f6 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 @@ -867,16 +867,25 @@ public TicketDto verifyClose(long ticketId, UserPrincipal actor) { @Transactional public TicketDto confirmCustomer(long ticketId, UserPrincipal actor) { + return confirmCustomer(ticketId, null, null, actor); + } + + /** {@link #confirmCustomer} for a caller that knows the confirmed quotation; see + * {@link #applyCustomerConfirmation} for what its number and deposit percent do. */ + @Transactional + public TicketDto confirmCustomer(long ticketId, String quotationNumber, Integer quotationDepositPercent, + UserPrincipal actor) { requireRole(actor, SALES_ROLES); TicketSummaryDto s = loadAndVerifyStatus(ticketId, TicketStatus.QUOTATION_ISSUED); requireActive(s); requireOwner(s, actor); - return applyCustomerConfirmation(ticketId, s, actor); + return applyCustomerConfirmation(ticketId, s, quotationNumber, quotationDepositPercent, actor); } /** * GLA-136 — the SAME customer-confirmation effects as {@link #confirmCustomer} (payment track - * {@code null -> CUSTOMER_CONFIRMED}, stage auto-advance to {@code ORDER_RECEIVED}), for the + * {@code null -> CUSTOMER_CONFIRMED}, the deposit policy following the quotation's percent, + * stage auto-advance to {@code ORDER_RECEIVED}), for the * ONE caller that has already made its own authorization decision: * {@code DealQuotationService#confirmOrderFromDirectQuotation}. * @@ -896,15 +905,25 @@ public TicketDto confirmCustomer(long ticketId, UserPrincipal actor) { * gate it relies on. */ @Transactional - public TicketDto confirmCustomerForPromotedQuotation(long ticketId, UserPrincipal actor) { + public TicketDto confirmCustomerForPromotedQuotation(long ticketId, String quotationNumber, + Integer quotationDepositPercent, UserPrincipal actor) { TicketSummaryDto s = loadAndVerifyStatus(ticketId, TicketStatus.QUOTATION_ISSUED); requireActive(s); - return applyCustomerConfirmation(ticketId, s, actor); + return applyCustomerConfirmation(ticketId, s, quotationNumber, quotationDepositPercent, actor); } - /** The shared body of {@link #confirmCustomer} and {@link #confirmCustomerForPromotedQuotation} - * — moved here verbatim (GLA-136) so the two cannot drift; behaviour is unchanged. */ - private TicketDto applyCustomerConfirmation(long ticketId, TicketSummaryDto s, UserPrincipal actor) { + /** + * The shared body of {@link #confirmCustomer} and {@link #confirmCustomerForPromotedQuotation} + * (GLA-136), so the two cannot drift. + * + *

    Owner ruling 2026-10-22: the first confirmation applies the confirmed quotation's deposit + * percent to the policy ({@link #followQuotationDepositPolicy}), so a ไม่รับมัดจำ (0%) deal takes + * the no-deposit path through the import-request and final-payment gates without a manual + * {@link #waiveDeposit}. It runs after the {@code null -> CUSTOMER_CONFIRMED} advance, an edge + * both paths share, so every later advance reads the synced policy. + */ + private TicketDto applyCustomerConfirmation(long ticketId, TicketSummaryDto s, String quotationNumber, + Integer quotationDepositPercent, UserPrincipal actor) { // Never downgrade the payment track: once past CUSTOMER_CONFIRMED, re-confirming // would reset paymentStatus and deadlock the later transitions. if (s.paymentStatus() != null && !"CUSTOMER_CONFIRMED".equals(s.paymentStatus())) { @@ -922,12 +941,47 @@ private TicketDto applyCustomerConfirmation(long ticketId, TicketSummaryDto s, U requirePaymentAdvanced(rows); tickets.addEvent(ticketId, actor.id(), actor.name(), TicketEventKind.CUSTOMER_CONFIRMED, s.status(), s.status(), null); + followQuotationDepositPolicy(ticketId, quotationNumber, quotationDepositPercent, actor); } // Deal pipeline (V50): a confirmed PO advances the deal — guarded no-op inside. autoAdvanceStage(s, DealStage.ORDER_RECEIVED, actor); return requireTicket(ticketId); } + /** + * Owner ruling 2026-10-22, widened after the #1173 review: while the payment track is at + * CUSTOMER_CONFIRMED, a deposit policy the SYSTEM owns follows the deal's quotation. ไม่รับมัดจำ + * (0%) means NOT_REQUIRED; any deposit means REQUIRED. Called at the first confirmation and + * when a DEAL_DIRECT quotation is approved on a confirmed deal ({@code DealQuotationService + * #approve}), so a revision that adds or drops the deposit moves the deal with it. + * + *

    A policy a person chose ({@link #waiveDeposit}: WAIVED, CREDIT_CUSTOMER, or a typed + * NOT_REQUIRED) is never touched, and nothing moves once a deposit notice is out. Both are + * enforced by the repository's compare-and-set ({@link TicketRepository + * #syncDepositPolicyFromQuotation}), which also loses cleanly to a waiver that commits first: + * 0 rows, no event. A null percent (a legacy quotation) changes nothing. + * + *

    Performs NO authorization: each caller has already authorized {@code actor}, who is + * recorded on the event only (the row's {@code deposit_policy_set_by} stays NULL). + */ + @Transactional + public void followQuotationDepositPolicy(long ticketId, String quotationNumber, Integer depositPercent, + UserPrincipal actor) { + if (depositPercent == null) { + return; + } + boolean noDeposit = depositPercent == 0; + String policy = noDeposit ? DepositPolicy.NOT_REQUIRED : DepositPolicy.REQUIRED; + String reason = "ใบเสนอราคา " + quotationNumber + + (noDeposit ? " ระบุไม่รับมัดจำ (ชำระเต็มจำนวน)" : " ระบุมัดจำ " + depositPercent + "%"); + String expected = noDeposit ? DepositPolicy.REQUIRED : DepositPolicy.NOT_REQUIRED; + if (tickets.syncDepositPolicyFromQuotation(ticketId, expected, policy, reason) == 1) { + String stage = requireTicket(ticketId).summary().salesStage(); + tickets.addEvent(ticketId, actor.id(), actor.name(), + TicketEventKind.POLICY_CHANGED, stage, stage, "deposit_policy → " + policy + " — " + reason); + } + } + /** * Turns a lost payment-track compare-and-set race into a 409. {@code advancePaymentStatus}'s * 0 rowcount means a concurrent writer moved {@code payment_status} out from under this diff --git a/backend/src/main/resources/db/migration/V200__import_profiles_2026_batch.sql b/backend/src/main/resources/db/migration/V200__import_profiles_2026_batch.sql new file mode 100644 index 000000000..34414ae39 --- /dev/null +++ b/backend/src/main/resources/db/migration/V200__import_profiles_2026_batch.sql @@ -0,0 +1,242 @@ +-- V200: refresh catalogue import profiles for the 2026/2027 batch +-- (Cotto d'Este/CDE, LEA, Panaria, REFIN, Vives). +-- +-- WHY: these 5 factories already carry import profiles seeded by V40 (Aug 2026), but (a) the +-- owner sent re-issued 2026/2027 price lists whose column headers changed, and (b) every one +-- of those seeds predates ImportProfile's now-REQUIRED size_unit/thickness_unit fields +-- (added by the 2026-09 catalogue-accuracy work), so ImportEngine#parse refuses them until +-- set. Each config below was validated header-by-header against the actual new workbook, and +-- every size_unit was cross-checked m2/box = (w x h) x pcs/box, never guessed. Replaces the +-- whole config per factory (not a partial patch) so the stored profile is complete and self- +-- consistent. Upsert on uq_profile_factory: a factory with no profile row (fresh env) gets one; +-- an existing one is overwritten. A factory name that does not exist updates nothing. +-- +-- NOTE: the JSON below is dollar-quoted with the ANONYMOUS double-dollar tag (as V40 does), NOT +-- a named tag. Flyway runs placeholder replacement by default and does NOT skip NAMED-tag dollar +-- quotes: a named tag immediately followed by the JSON opening brace is then read as a Flyway +-- placeholder and the migration fails with "No value provided for placeholder". The anonymous +-- tag is skipped by the replacer, so it stays safe even when the JSON opens with a brace. + +-- CDE: Cotto d'Este — columns unchanged from the 2026 list; adds the now-required size_unit/thickness_unit (mm, verified 150x600mm -> 0.09 x 2 = 0.18 m2/box). +INSERT INTO price_catalog.import_profiles (factory_id, config) +SELECT factory_id, $${ + "number_format": "eu", + "size_unit": "mm", + "thickness_unit": "mm", + "sheets": [ + { + "name": "LIST E1_E20 2026", + "header_row": 1 + } + ], + "columns": { + "product_code": "ITEM", + "collection": "RANGE", + "product_name": "DESCRIPTION", + "color": "COLOR", + "surface": "FINISH", + "size_raw": "SIZE", + "thickness_mm": "THICKNESS MM", + "price": "PRICE", + "currency": "DIVISA", + "unit": "UOM", + "pcs_per_box": "PCS_BOX", + "sqm_per_box": "SQM_BOX", + "kg_per_box": "KGS_BOX" + } +}$$::jsonb + FROM price_catalog.factories WHERE name = 'CDE' +ON CONFLICT ON CONSTRAINT uq_profile_factory + DO UPDATE SET config = EXCLUDED.config, updated_at = now(); + +-- LEA: LEA — new headers: Thickness (was SPESSORE), Pieces/box (was PZ_SCA), 'm² per box' (was MQ_SCA), kg/box. size_unit mm (300x600mm -> 0.18 x 8 = 1.44). +INSERT INTO price_catalog.import_profiles (factory_id, config) +SELECT factory_id, $${ + "number_format": "eu", + "size_unit": "mm", + "thickness_unit": "mm", + "sheets": [ + { + "name": "LEA", + "header_row": 1 + } + ], + "columns": { + "product_code": "CODICE ART", + "collection": "SERIE", + "size_raw": "FORMATO", + "color": "COLORE", + "surface": "BORDO", + "product_name": "DESCRIZIONE ART", + "unit": "UMV", + "thickness_mm": "Thickness", + "price": "Prezzo Lordo 1°", + "pcs_per_box": "Pieces/box", + "sqm_per_box": "m² per box", + "kg_per_box": "kg/box", + "barcode": "BARCODE" + } +}$$::jsonb + FROM price_catalog.factories WHERE name = 'LEA' +ON CONFLICT ON CONSTRAINT uq_profile_factory + DO UPDATE SET config = EXCLUDED.config, updated_at = now(); + +-- Panaria: Panaria — header still on row 2; Thickness (was SPESSORE), Pieces/box (was PZ_SCA); MQ_SCA/KG_SCA unchanged. size_unit mm (150x300mm -> 0.045 x 8 = 0.36). +INSERT INTO price_catalog.import_profiles (factory_id, config) +SELECT factory_id, $${ + "number_format": "eu", + "size_unit": "mm", + "thickness_unit": "mm", + "sheets": [ + { + "name": "PAN", + "header_row": 2 + } + ], + "columns": { + "product_code": "CODICE ART", + "collection": "SERIE", + "size_raw": "FORMATO", + "color": "COLORE", + "surface": "BORDO", + "product_name": "DESCRIZIONE ART", + "unit": "UM_VEN", + "currency": "DIVISA", + "thickness_mm": "Thickness", + "price": "PRZ_1SCE_EST", + "pcs_per_box": "Pieces/box", + "sqm_per_box": "MQ_SCA", + "kg_per_box": "KG_SCA" + } +}$$::jsonb + FROM price_catalog.factories WHERE name = 'Panaria' +ON CONFLICT ON CONSTRAINT uq_profile_factory + DO UPDATE SET config = EXCLUDED.config, updated_at = now(); + +-- REFIN: REFIN — price column renamed to 'NEW PRICE 2027' (added to aliases); size_unit cm (header 'Size (cm)', 120x120cm -> 1.44 x 2 = 2.88); Trim-Tiles/accessories carry missing columns, handled by allow_missing_code + aliases exactly as before. +INSERT INTO price_catalog.import_profiles (factory_id, config) +SELECT factory_id, $${ + "number_format": "eu", + "size_unit": "cm", + "thickness_unit": "mm", + "allow_missing_code": true, + "sheets": [ + { + "name": "Collections", + "header_row": 1 + }, + { + "name": "RELIEFS_recap", + "header_row": 1 + }, + { + "name": "Large-Slabs_recap", + "header_row": 1 + }, + { + "name": "OUT2.0_recap", + "header_row": 1 + }, + { + "name": "OUT2.0_accessories", + "header_row": 1 + }, + { + "name": "Trim-Tiles", + "header_row": 1 + } + ], + "columns": { + "product_code": "Code", + "collection": "Collection", + "product_name": "Item", + "size_raw": "Size (cm)", + "thickness_mm": "Thickness (mm)", + "price": "NEW PRICE 2027", + "unit": "Um", + "pcs_per_box": "Pcs/Box", + "sqm_per_box": "m²/Box", + "kg_per_box": "Kg/Box" + }, + "column_aliases": { + "price": [ + "NEW PRICE 2027", + "PRICE 2027", + "PRICE 2026", + "PRICE 2025" + ], + "product_code": [ + "Code", + "CODE" + ], + "size_raw": [ + "Size (cm)", + "SIZE (cm)" + ], + "product_name": [ + "Item", + "ITEM" + ], + "collection": [ + "Collection", + "COLLECTION" + ], + "unit": [ + "Um", + "UM" + ], + "surface": [ + "SURFACE" + ] + } +}$$::jsonb + FROM price_catalog.factories WHERE name = 'REFIN' +ON CONFLICT ON CONSTRAINT uq_profile_factory + DO UPDATE SET config = EXCLUDED.config, updated_at = now(); + +-- Vives: Vives — price list re-issued with ENGLISH headers (Model/Name/Size/Pieces-Box/m²-Box/Weight-Box/EAN). Price is Price/Piece OR Price/m² (first_non_empty; cells are TEXT with comma decimals -> number_format eu). Size uses apostrophe decimal (15'8X31'6 = 15.8x31.6 cm), size_unit cm. No thickness in the main sheet (thickness_unit none); thickness joins from the SEPARATE workbook via Model<->Ref (value 'Thickness (mm)'). ~1964 of 4618 models match a sidecar Ref; the rest import thickness NULL, as the engine allows. +INSERT INTO price_catalog.import_profiles (factory_id, config) +SELECT factory_id, $${ + "number_format": "eu", + "size_unit": "cm", + "thickness_unit": "none", + "size_format": "apostrophe_decimal", + "sheets": [ + { + "name": "Hoja1", + "header_row": 1 + } + ], + "columns": { + "product_code": "Model", + "collection": "SERIE", + "product_name": "Name", + "size_raw": "Size", + "unit": "Unit", + "pcs_per_box": "Pieces/Box", + "sqm_per_box": "m²/Box", + "kg_per_box": "Weight/Box", + "barcode": "EAN" + }, + "price_column_rule": { + "type": "first_non_empty", + "map": { + "Price/Piece": "per_piece", + "Price/m²": "per_sqm" + } + }, + "thickness_sidecar": { + "sheet": "Table1", + "header_row": 1, + "key_columns": [ + "Model" + ], + "sidecar_key_columns": [ + "Ref" + ], + "value_column": "Thickness (mm)" + } +}$$::jsonb + FROM price_catalog.factories WHERE name = 'Vives' +ON CONFLICT ON CONSTRAINT uq_profile_factory + DO UPDATE SET config = EXCLUDED.config, updated_at = now(); diff --git a/backend/src/test/java/th/co/glr/hr/dealquotation/DealQuotationConfirmOrderIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/dealquotation/DealQuotationConfirmOrderIntegrationTest.java index 45c8f7b09..9a5c6e4a6 100644 --- a/backend/src/test/java/th/co/glr/hr/dealquotation/DealQuotationConfirmOrderIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/dealquotation/DealQuotationConfirmOrderIntegrationTest.java @@ -34,6 +34,8 @@ import th.co.glr.hr.dealquotation.DealQuotationRequests.UpsertDealQuotationRequest; import th.co.glr.hr.deposit.DepositNoticeDraftRequest; import th.co.glr.hr.deposit.DepositNoticeDto; +import th.co.glr.hr.deposit.DepositNoticeItemDto; +import th.co.glr.hr.deposit.DepositNoticeItemRequest; import th.co.glr.hr.deposit.DepositNoticeRenderer; import th.co.glr.hr.deposit.DepositNoticeRepository; import th.co.glr.hr.deposit.DepositNoticeService; @@ -63,6 +65,7 @@ import th.co.glr.hr.ticket.TicketEventKind; import th.co.glr.hr.ticket.TicketItemRequest; import th.co.glr.hr.ticket.TicketRepository; +import th.co.glr.hr.ticket.TicketResponses; import th.co.glr.hr.ticket.TicketService; import th.co.glr.hr.ticket.TicketStatus; import th.co.glr.hr.ticket.TicketSummaryDto; @@ -251,7 +254,30 @@ void quotationFirstGhost_stillConfirms_landsAtOrderReceived_withQuotationLines_a DepositNoticeDto draft = depositNoticeService.createDraft(ghost, new DepositNoticeDraftRequest(null, null, null, null, null, null, null, null), salesActor); assertThat(draft.ticketId()).isEqualTo(ghost); - assertThat(draft.items()).isNotEmpty(); + // Owner ruling 2026-10-23: the ส่วนลดพิเศษ ADJUSTMENT row (qty -1, -100.00) is folded into the + // goods lines by amount: TILE 10 x 100 and PLAIN 2 x 500 take 50.00 each. No negative qty, the + // descriptions are the goods lines' own, and the subtotal is the quotation's 1,900. + assertThat(draft.items()) + .extracting(i -> i.qty().stripTrailingZeros().toPlainString(), i -> i.unitPrice().toPlainString(), + i -> i.netUnitPrice().toPlainString(), DepositNoticeItemDto::discountLabel) + .containsExactly( + org.assertj.core.groups.Tuple.tuple("10", "100.00", "95.00", "ส่วนลดพิเศษ 5 ต่อหน่วย"), + org.assertj.core.groups.Tuple.tuple("2", "500.00", "475.00", "ส่วนลดพิเศษ 25 ต่อหน่วย")); + assertThat(draft.items()).extracting(DepositNoticeItemDto::description).doesNotContain("ส่วนลดพิเศษ") + .last().isEqualTo("ค่าขนส่ง"); + assertThat(draft.subtotal()).isEqualByComparingTo("1900.00"); + + // The page sends every line back on save; @PositiveOrZero qty used to refuse the whole PUT. + DepositNoticeDraftRequest pagePut = new DepositNoticeDraftRequest( + draft.customerName(), draft.customerTaxId(), draft.customerAddress(), draft.projectName(), + draft.reference(), draft.depositPercent(), draft.notes(), + draft.items().stream().map(i -> new DepositNoticeItemRequest(i.seq(), i.description(), i.qty(), + i.unit(), i.unitPrice(), i.discountLabel(), i.netUnitPrice())).toList(), + draft.updatedAt()); + try (var factory = jakarta.validation.Validation.buildDefaultValidatorFactory()) { + assertThat(factory.getValidator().validate(pagePut)).isEmpty(); + } + assertThat(depositNoticeService.update(draft.id(), pagePut, salesActor).subtotal()).isEqualByComparingTo("1900.00"); } /** The same deposit-notice draft is REFUSED before confirmation — the negative half of "the money @@ -348,6 +374,135 @@ void failureInTheLastStep_rollsTheWholePromotionBack() { assertNothingPromoted(ghost); } + /** Owner ruling 2026-10-22: a ไม่รับมัดจำ (0%) direct quotation confirms onto the no-deposit + * payment path, so final payment is no longer blocked by a deposit that will never exist. */ + @Test + void zeroDepositDirectQuotation_confirmsOntoTheNoDepositPath_andFinalPaymentIsNoLongerBlocked() { + long deal = pipelineTicket(salesActor); + DealQuotationDto created = quotationService.create(deal, zeroDepositDirectDraft(), salesActor); + DealQuotationDto submitted = quotationService.submit(created.id(), salesActor); + DealQuotationDto approved = quotationService.approve(submitted.id(), new ApproveRequest(null), salesManagerActor); + assertThat(approved.depositPercent()).isZero(); + + confirmer.confirmOrderFromDirectQuotation(approved.id(), salesActor); + + TicketSummaryDto after = summary(deal); + assertThat(after.depositPolicy()).isEqualTo("NOT_REQUIRED"); + assertThat(after.depositPolicyReason()).contains(approved.number()).contains("ไม่รับมัดจำ"); + assertThat(eventCount(deal, TicketEventKind.POLICY_CHANGED)).isEqualTo(1); + assertThat(after.paymentStatus()).isEqualTo("CUSTOMER_CONFIRMED"); + assertConflict(() -> depositNoticeService.createDraft(deal, + new DepositNoticeDraftRequest(null, null, null, null, null, null, null, null), salesActor), "ไม่ต้องเก็บมัดจำ"); + // The whole 0% walk (owner ruling 2026-10-27: 0% skips the deposit path automatically). + assertThat(ticketService.actions(deal, salesActor).availableActions()) + .extracting(TicketResponses.TicketActionDto::action).doesNotContain("ISSUE_DEPOSIT_NOTICE"); + ticketService.requireImportRequestIssuable(after); // a REQUIRED deal 409s here with no deposit + // canConfirmFinalPaymentNow refuses a REQUIRED deal at CUSTOMER_CONFIRMED. + assertThat(ticketService.confirmFinalPayment(deal, accountActor).summary().paymentStatus()) + .isEqualTo("FULLY_PAID"); + assertThat(summary(deal).salesStage()).isNotEqualTo(DealStage.DEPOSIT_RECEIVED); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM sales.ticket_event WHERE ticket_id = :id" + + " AND (to_status = 'DEPOSIT_RECEIVED' OR from_status = 'DEPOSIT_RECEIVED')", + Map.of("id", deal), Integer.class)).isZero(); + } + + /** + * PR #1173 review F1: a confirmed 0% deal whose approved revision adds a deposit used to be + * stranded (NOT_REQUIRED kept, the draft 409'd, waiveDeposit refuses REQUIRED). While the + * payment track is at CUSTOMER_CONFIRMED a policy the system set follows the newly approved + * quotation. + */ + @Test + void zeroDepositConfirmed_thenARevisionAddsADeposit_policyFollowsBackToRequired_andTheDraftBillsIt() { + long deal = pipelineTicket(salesActor); + DealQuotationDto zero = approvedQuotation(deal, zeroDepositDirectDraft()); + confirmer.confirmOrderFromDirectQuotation(zero.id(), salesActor); + assertThat(summary(deal).depositPolicy()).isEqualTo("NOT_REQUIRED"); + + DealQuotationDto revision = approvedRevision(zero, directDraft()); + + TicketSummaryDto after = summary(deal); + assertThat(after.depositPolicy()).isEqualTo("REQUIRED"); + assertThat(after.depositPolicyReason()).isEqualTo("ใบเสนอราคา " + revision.number() + " ระบุมัดจำ 50%"); + assertThat(after.paymentStatus()).isEqualTo("CUSTOMER_CONFIRMED"); + assertThat(policyChangedMessages(deal)).hasSize(2) + .last().asString().contains("REQUIRED").contains(revision.number()); + DepositNoticeDto draft = depositNoticeService.createDraft(deal, + new DepositNoticeDraftRequest(null, null, null, null, null, null, null, null), salesActor); + assertThat(draft.depositPercent()).isEqualByComparingTo("0.50"); + } + + /** Owner ruling 2026-10-23: a blank new-engine percent means 30 (DealQuotationRenderAdapter's PDF + * default), so a 0% deal whose approved revision leaves the percent blank is back on the deposit path. */ + @Test + void zeroDepositConfirmed_thenARevisionWithABlankPercent_countsAsThirty_andIsRequired() { + long deal = pipelineTicket(salesActor); + DealQuotationDto zero = approvedQuotation(deal, zeroDepositDirectDraft()); + confirmer.confirmOrderFromDirectQuotation(zero.id(), salesActor); + assertThat(summary(deal).depositPolicy()).isEqualTo("NOT_REQUIRED"); + + DealQuotationDto revision = approvedRevision(zero, blankDepositDirectDraft()); + + assertThat(revision.depositPercent()).isNull(); + TicketSummaryDto after = summary(deal); + assertThat(after.depositPolicy()).isEqualTo("REQUIRED"); + assertThat(after.depositPolicyReason()).isEqualTo("ใบเสนอราคา " + revision.number() + " ระบุมัดจำ 30%"); + } + + /** The other direction: a confirmed 50% deal whose approved revision says ไม่รับมัดจำ leaves + * the deposit path (this was the PR's "a later revision to 0% does not sync" known risk). */ + @Test + void depositConfirmed_thenARevisionDropsTheDeposit_policyFollowsToNotRequired() { + long deal = pipelineTicket(salesActor); + DealQuotationDto fifty = approvedDirectQuotation(deal, salesActor); + confirmer.confirmOrderFromDirectQuotation(fifty.id(), salesActor); + assertThat(summary(deal).depositPolicy()).isEqualTo("REQUIRED"); + + DealQuotationDto revision = approvedRevision(fifty, zeroDepositDirectDraft()); + + TicketSummaryDto after = summary(deal); + assertThat(after.depositPolicy()).isEqualTo("NOT_REQUIRED"); + assertThat(after.depositPolicyReason()) + .isEqualTo("ใบเสนอราคา " + revision.number() + " ระบุไม่รับมัดจำ (ชำระเต็มจำนวน)"); + assertThat(policyChangedMessages(deal)).hasSize(1); + assertConflict(() -> depositNoticeService.createDraft(deal, + new DepositNoticeDraftRequest(null, null, null, null, null, null, null, null), salesActor), "ไม่ต้องเก็บมัดจำ"); + } + + /** Wrong-way-round: a policy a person chose (credit customer, typed reason) is never undone by + * a revision, and no POLICY_CHANGED is written for the attempt. */ + @Test + void humanCreditCustomer_isKept_whenARevisionAddsADeposit() { + long deal = pipelineTicket(salesActor); + DealQuotationDto zero = approvedQuotation(deal, zeroDepositDirectDraft()); + confirmer.confirmOrderFromDirectQuotation(zero.id(), salesActor); + ticketService.waiveDeposit(deal, "CREDIT_CUSTOMER", "ลูกค้าเครดิต 30 วัน", salesActor); + + approvedRevision(zero, directDraft()); + + TicketSummaryDto after = summary(deal); + assertThat(after.depositPolicy()).isEqualTo("CREDIT_CUSTOMER"); + assertThat(after.depositPolicyReason()).isEqualTo("ลูกค้าเครดิต 30 วัน"); + assertThat(policyChangedMessages(deal)).hasSize(2); // the sync at confirmation + the waiver + } + + /** Wrong-way-round, the case the set_by marker exists for: a NOT_REQUIRED a person typed is + * not the system's, so a 50% revision leaves it and writes no event. */ + @Test + void humanNotRequired_isKept_whenARevisionStillCarriesADeposit() { + long deal = pipelineTicket(salesActor); + DealQuotationDto fifty = approvedDirectQuotation(deal, salesActor); + confirmer.confirmOrderFromDirectQuotation(fifty.id(), salesActor); + ticketService.waiveDeposit(deal, "NOT_REQUIRED", "ลูกค้าโอนเต็มจำนวนล่วงหน้า", salesActor); + + approvedRevision(fifty, directDraft()); + + TicketSummaryDto after = summary(deal); + assertThat(after.depositPolicy()).isEqualTo("NOT_REQUIRED"); + assertThat(after.depositPolicyReason()).isEqualTo("ลูกค้าโอนเต็มจำนวนล่วงหน้า"); + assertThat(policyChangedMessages(deal)).hasSize(1); // the waiver only + } + // ───────────────────────────────────────────────────────────────────────────────────── // Authz — WRONG-WAY-ROUND. Each refusal asserts nothing was written. // ───────────────────────────────────────────────────────────────────────────────────── @@ -694,6 +849,28 @@ private DealQuotationDto approvedDirectQuotation(long ticketId, UserPrincipal cr return approved; } + private DealQuotationDto approvedQuotation(long ticketId, UpsertDealQuotationRequest draft) { + DealQuotationDto created = quotationService.create(ticketId, draft, salesActor); + quotationService.submit(created.id(), salesActor); + return quotationService.approve(created.id(), new ApproveRequest(null), salesManagerActor); + } + + /** Revise {@code source} to {@code draft}'s terms, submit, and approve — the reviewer's F1 sequence. */ + private DealQuotationDto approvedRevision(DealQuotationDto source, UpsertDealQuotationRequest draft) { + DealQuotationDto revision = quotationService.createRevision(source.id(), salesActor); + quotationService.update(revision.id(), draft, salesActor); + quotationService.submit(revision.id(), salesActor); + DealQuotationDto approved = quotationService.approve(revision.id(), new ApproveRequest(null), salesManagerActor); + assertThat(approved.docStatus()).isEqualTo(QuotationStatus.APPROVED); + return approved; + } + + private List policyChangedMessages(long ticketId) { + return jdbc.queryForList(""" + SELECT message FROM sales.ticket_event WHERE ticket_id = :id AND kind = :kind ORDER BY event_id + """, Map.of("id", ticketId, "kind", TicketEventKind.POLICY_CHANGED), String.class); + } + /** One TILE line (10 pcs, loose — roundToFullBox=false), one PLAIN line (2 JOB), one ADJUSTMENT (flat -100). */ private UpsertDealQuotationRequest directDraft() { ItemInput tile = new ItemInput( @@ -718,6 +895,23 @@ null, null, null, new BigDecimal("500.00"), BigDecimal.ZERO, null, 0, 30, null, "NET", "TH", "THB", List.of(tile, plain, adjustment)).withRecipientType("OWNER"); } + /** {@link #directDraft} as a ไม่รับมัดจำ quotation: 0% submits only with a full-payment term. */ + private UpsertDealQuotationRequest blankDepositDirectDraft() { + UpsertDealQuotationRequest d = directDraft(); + return new UpsertDealQuotationRequest(d.contactId(), d.deptCode(), d.unitCode(), d.offerDate(), null, + d.remainderMode(), d.creditDays(), d.validityDays(), d.validityMode(), d.validityUntil(), + d.customerNotes(), d.priceMode(), d.documentLanguage(), d.currency(), null, null, null, null, + d.fullPaymentTerm(), null, null, null, null, d.recipientType(), d.items()); + } + + private UpsertDealQuotationRequest zeroDepositDirectDraft() { + UpsertDealQuotationRequest d = directDraft(); + return new UpsertDealQuotationRequest(d.contactId(), d.deptCode(), d.unitCode(), d.offerDate(), 0, + d.remainderMode(), d.creditDays(), d.validityDays(), d.validityMode(), d.validityUntil(), + d.customerNotes(), d.priceMode(), d.documentLanguage(), d.currency(), null, null, null, null, + "BEFORE_DELIVERY", null, null, null, null, d.recipientType(), d.items()); + } + private TicketItemRequest ticketItem() { return new TicketItemRequest("SCG", "Typed", "White", "Matte", "60x60", null, new BigDecimal("1"), null, "PIECE", null, null, null, null, "THB"); diff --git a/backend/src/test/java/th/co/glr/hr/dealquotation/DealQuotationOutcomeIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/dealquotation/DealQuotationOutcomeIntegrationTest.java index 08b841ab7..d406b1301 100644 --- a/backend/src/test/java/th/co/glr/hr/dealquotation/DealQuotationOutcomeIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/dealquotation/DealQuotationOutcomeIntegrationTest.java @@ -11,6 +11,8 @@ import java.util.UUID; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import org.springframework.http.HttpStatus; import th.co.glr.hr.attachment.FileStorageService; import th.co.glr.hr.auth.EmployeeAuthRepository; @@ -69,7 +71,6 @@ import th.co.glr.hr.pricingdecision.PricingDecisionRequests.UpdatePricingDecisionItemRequest; import th.co.glr.hr.pricingdecision.PricingDecisionRequests.UpdatePricingDecisionRequest; import th.co.glr.hr.pricingdecision.PricingDecisionService; -import th.co.glr.hr.pricingrequest.PricingRequestDtos.PricingRequestSummaryDto; import th.co.glr.hr.pricingrequest.PricingRequestRecipient; import th.co.glr.hr.pricingrequest.PricingRequestRepository; import th.co.glr.hr.pricingrequest.PricingRequestRequests; @@ -248,34 +249,6 @@ void recordOutcome_byOwningSales_accepted_setsPrStatusAndDocStatus() { // that route read only legacy CustomerQuotation rows and answered 409 for every deal quoted // through DealQuotationService.createFromPricingRequest. Direct quotations never take this route. - @Test - void createDepositNoticeFromQuotation_newEngineAcceptedQuotation_createsTheSameDraftTheTicketRouteWould() { - DealQuotationDto accepted = acceptedNewOriginQuotation(PricingRequestRecipient.DESIGNER, new BigDecimal("10")); - long pricingRequestId = accepted.pricingRequestId(); - orderConfirmation.confirmOrder(pricingRequestId, - new OrderConfirmationRequests.ConfirmOrderRequest(UUID.randomUUID().toString()), salesActor); - - DepositNoticeDto viaPricingRequest = orderConfirmation.createDepositNoticeFromQuotation(pricingRequestId, - new OrderConfirmationRequests.CreateDepositNoticeFromQuotationRequest(null), salesActor); - - // The reference is the accepted quotation's number — the one thing this route adds over the ticket route. - assertThat(viaPricingRequest.reference()).isEqualTo(accepted.number()); - assertThat(viaPricingRequest.items()).isNotEmpty(); - - // Items and percent are exactly what the ticket-level route produces for this deal (shared createDraft). - DepositNoticeDto viaTicket = depositNoticeService.createDraft(ticketId, - new DepositNoticeDraftRequest(null, null, null, null, null, null, null, null), salesActor); - assertThat(viaPricingRequest.depositPercent()).isEqualByComparingTo(viaTicket.depositPercent()); - assertThat(viaPricingRequest.items()) - .usingRecursiveComparison().ignoringFields("id") - .isEqualTo(viaTicket.items()); - - assertThat(jdbc.queryForObject(""" - SELECT COUNT(*) FROM sales.pricing_request_event - WHERE pricing_request_id = :id AND event_kind = 'DEPOSIT_NOTICE_DRAFTED_FROM_QUOTATION' - """, Map.of("id", pricingRequestId), Long.class)).isEqualTo(1L); - } - @Test void createDepositNoticeFromQuotation_newEngineIssuedNotAccepted_still409() { DealQuotationDto issued = issuedNewOriginQuotation(PricingRequestRecipient.DESIGNER, new BigDecimal("10")); @@ -289,12 +262,13 @@ void createDepositNoticeFromQuotation_newEngineIssuedNotAccepted_still409() { Map.of("id", ticketId), Long.class)).isZero(); } - /** A zero-deposit quotation ("ไม่รับมัดจำ", full payment) has no deposit to bill: the route must - * refuse rather than draft a 0-baht document, which nothing downstream would stop from being - * issued ({@code DepositNoticeService#issue} reads the ticket's deposit_policy, which no path - * syncs from a 0% quotation). */ + /** A zero-deposit quotation ("ไม่รับมัดจำ", full payment) has no deposit to bill: both routes + * must refuse rather than draft a 0-baht document, which nothing downstream would stop from + * being issued ({@code DepositNoticeService#issue} reads the ticket's deposit_policy, which no + * path syncs from a 0% quotation). The ticket route refuses because this quotation governs the + * ticket; a DEAL_DIRECT deal has only that route. */ @Test - void createDepositNoticeFromQuotation_newEngineAcceptedZeroDeposit_409AndNoDraft() { + void createDepositNotice_newEngineAcceptedZeroDeposit_bothRoutes409AndNoDraft() { DealQuotationDto accepted = acceptedZeroDepositNewOriginQuotation(PricingRequestRecipient.DESIGNER, new BigDecimal("10")); long pricingRequestId = accepted.pricingRequestId(); @@ -302,13 +276,43 @@ void createDepositNoticeFromQuotation_newEngineAcceptedZeroDeposit_409AndNoDraft orderConfirmation.confirmOrder(pricingRequestId, new OrderConfirmationRequests.ConfirmOrderRequest(UUID.randomUUID().toString()), salesActor); + // Each route's own guard: the pricing-request route refuses on the quotation, the ticket route on + // the policy (the confirmation already moved the deal to NOT_REQUIRED). assertThatThrownBy(() -> orderConfirmation.createDepositNoticeFromQuotation(pricingRequestId, new OrderConfirmationRequests.CreateDepositNoticeFromQuotationRequest(null), salesActor)) .isInstanceOf(ApiException.class) .hasFieldOrPropertyWithValue("status", HttpStatus.CONFLICT) - .hasMessageContaining("ไม่มีมัดจำ"); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM sales.deposit_notice WHERE ticket_id = :id", - Map.of("id", ticketId), Long.class)).isZero(); + .hasMessageContaining("ใบเสนอราคา " + accepted.number() + " ระบุไม่มีมัดจำ"); + assertThatThrownBy(() -> depositNoticeService.createDraft(ticketId, emptyDepositDraft(), salesActor)) + .isInstanceOf(ApiException.class) + .hasFieldOrPropertyWithValue("status", HttpStatus.CONFLICT) + .hasMessageContaining("ดีลนี้ไม่ต้องเก็บมัดจำ"); + assertThat(depositNoticeCount()).isZero(); + } + + /** #1170's quotation guards, reached only by a deal still REQUIRED with a 0% quotation (confirmed + * before the policy sync): both routes refuse on the quotation and write nothing. */ + @Test + void createDepositNotice_requiredDealWithZeroQuotation_bothRoutes409OnTheQuotation_andWriteNothing() { + DealQuotationDto accepted = acceptedZeroDepositNewOriginQuotation(PricingRequestRecipient.DESIGNER, + new BigDecimal("10")); + long pricingRequestId = accepted.pricingRequestId(); + orderConfirmation.confirmOrder(pricingRequestId, + new OrderConfirmationRequests.ConfirmOrderRequest(UUID.randomUUID().toString()), salesActor); + jdbc.update("UPDATE sales.ticket SET deposit_policy = 'REQUIRED', deposit_policy_reason = NULL WHERE ticket_id = :id", + Map.of("id", ticketId)); + + String refusal = "ใบเสนอราคา " + accepted.number() + " ระบุไม่มีมัดจำ"; + assertThatThrownBy(() -> orderConfirmation.createDepositNoticeFromQuotation(pricingRequestId, + new OrderConfirmationRequests.CreateDepositNoticeFromQuotationRequest(null), salesActor)) + .isInstanceOf(ApiException.class) + .hasFieldOrPropertyWithValue("status", HttpStatus.CONFLICT) + .hasMessageContaining(refusal); + assertThatThrownBy(() -> depositNoticeService.createDraft(ticketId, emptyDepositDraft(), salesActor)) + .isInstanceOf(ApiException.class) + .hasFieldOrPropertyWithValue("status", HttpStatus.CONFLICT) + .hasMessageContaining(refusal); + assertThat(depositNoticeCount()).isZero(); } @Test @@ -326,6 +330,34 @@ void createDepositNoticeFromQuotation_newEngine_nonOwnerSales_cannotReach() { Map.of("id", ticketId), Long.class)).isZero(); } + /** The ticket route (POST /api/tickets/{id}/deposit-notice/draft) had no owner check, unlike + * update/issue: another rep could draft on this deal and read its lines, prices and customer. */ + @Test + void createDraft_ticketRoute_byNonOwningSales_refusedAndWritesNothing() { + var emptyRequest = new DepositNoticeDraftRequest(null, null, null, null, null, null, null, null); + // Owner before status: a non-owner must not learn whether another rep's deal is approved yet + // (403 here, not the 409 the owner would get on this still-unapproved ticket). + assertThatThrownBy(() -> depositNoticeService.createDraft(ticketId, emptyRequest, otherSalesActor)) + .isInstanceOf(ApiException.class) + .hasFieldOrPropertyWithValue("status", HttpStatus.FORBIDDEN); + + DealQuotationDto accepted = acceptedNewOriginQuotation(PricingRequestRecipient.DESIGNER, new BigDecimal("10")); + orderConfirmation.confirmOrder(accepted.pricingRequestId(), + new OrderConfirmationRequests.ConfirmOrderRequest(UUID.randomUUID().toString()), salesActor); + + assertThatThrownBy(() -> depositNoticeService.createDraft(ticketId, emptyRequest, otherSalesActor)) + .isInstanceOf(ApiException.class) + .hasFieldOrPropertyWithValue("status", HttpStatus.FORBIDDEN) + .hasMessageContaining("ไม่มีสิทธิ์เข้าถึงรายการนี้"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM sales.deposit_notice WHERE ticket_id = :id", + Map.of("id", ticketId), Long.class)).isZero(); + + // Positive control: the owner still drafts on the same deal. + assertThat(depositNoticeService.createDraft(ticketId, emptyRequest, salesActor).ticketId()).isEqualTo(ticketId); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM sales.deposit_notice WHERE ticket_id = :id", + Map.of("id", ticketId), Long.class)).isEqualTo(1L); + } + /** An open revision draft outranks the accepted quotation by id: recordOutcome has no * open-revision guard, so "latest for this pricing request" is not "accepted". */ @Test @@ -348,21 +380,30 @@ void createDepositNoticeFromQuotation_newEngineAcceptedWithAnOpenRevisionDraft_s } /** Engines are exclusive per pricing request, not per ticket: a sibling pricing request's - * legacy ISSUED quotation must not supply the lines or the percent of this pricing request's - * draft. */ - @Test - void createDepositNoticeFromQuotation_newEngineAccepted_whenASiblingPricingRequestHasALegacyIssuedQuotation_draftsThisQuotationsLinesAndPercent() { + * older legacy ISSUED quotation must not supply the lines or the percent of the draft, on + * either route. The customer answered this quotation and not the sibling, so it governs the + * ticket route too (owner ruling 2026-10-23). The draft bills this quotation's own percent, a + * NULL one as the 30% its PDF prints. */ + @ParameterizedTest(name = "quotation percent {0} bills {1}") + @CsvSource({", 0.30", "20, 0.20"}) + void createDepositNotice_newEngineAccepted_whenASiblingPricingRequestHasAnOlderLegacyIssuedQuotation_bothRoutesDraftThisQuotationsLinesAndPercent( + Integer quotationPercent, BigDecimal expectedDraftPercent) { CustomerQuotationDto legacyIssued = issuedLegacyQuotation(PricingRequestRecipient.OWNER); // No code path writes deposit_percent on a legacy row; this stands in for one that carries it. assertThat(jdbc.update("UPDATE sales.quotation SET deposit_percent = 70 WHERE quotation_id = :id", Map.of("id", legacyIssued.id()))).isEqualTo(1); - DealQuotationDto accepted = acceptedNewOriginQuotation(PricingRequestRecipient.DESIGNER, new BigDecimal("10"), 30); + DealQuotationDto accepted = acceptedNewOriginQuotation(PricingRequestRecipient.DESIGNER, new BigDecimal("10"), + quotationPercent); + assertThat(accepted.depositPercent()).isEqualTo(quotationPercent); + assertThat(accepted.id()).isGreaterThan(legacyIssued.id()); long pricingRequestId = accepted.pricingRequestId(); orderConfirmation.confirmOrder(pricingRequestId, new OrderConfirmationRequests.ConfirmOrderRequest(UUID.randomUUID().toString()), salesActor); - DepositNoticeDto draft = orderConfirmation.createDepositNoticeFromQuotation(pricingRequestId, + DepositNoticeDto viaPricingRequest = orderConfirmation.createDepositNoticeFromQuotation(pricingRequestId, new OrderConfirmationRequests.CreateDepositNoticeFromQuotationRequest(null), salesActor); + DepositNoticeDto viaTicket = depositNoticeService.createDraft(ticketId, + new DepositNoticeDraftRequest(null, null, null, null, null, null, null, null), salesActor); List thisQuotationsLines = jdbc.query(""" SELECT qty, final_unit_price FROM sales.quotation_item WHERE quotation_id = :id ORDER BY seq @@ -373,11 +414,200 @@ void createDepositNoticeFromQuotation_newEngineAccepted_whenASiblingPricingReque // The fixture must make the two sources distinguishable, or the assertion below proves nothing. assertThat(thisQuotationsLines).isNotEmpty().isNotEqualTo(siblingLegacyLines); - assertThat(draft.reference()).isEqualTo(accepted.number()); - assertThat(draft.items().stream().map(it -> line(it.qty(), it.netUnitPrice())).toList()) - .isEqualTo(thisQuotationsLines); - assertThat(accepted.depositPercent()).isEqualTo(30); - assertThat(draft.depositPercent()).isEqualByComparingTo("0.30"); + for (DepositNoticeDto draft : List.of(viaPricingRequest, viaTicket)) { + assertThat(draft.items().stream().map(it -> line(it.qty(), it.netUnitPrice())).toList()) + .isEqualTo(thisQuotationsLines); + assertThat(draft.depositPercent()).isEqualByComparingTo(expectedDraftPercent); + assertThat(draft.reference()).isEqualTo(accepted.number()); + } + // Every line field, not just qty and net price, is the same on both routes. + assertThat(viaPricingRequest.items()) + .usingRecursiveComparison().ignoringFields("id") + .isEqualTo(viaTicket.items()); + // The new-engine branch writes the pricing-request event too (OrderConfirmationIntegrationTest + // pins the legacy branch). + assertThat(jdbc.queryForObject(""" + SELECT COUNT(*) FROM sales.pricing_request_event + WHERE pricing_request_id = :id AND event_kind = 'DEPOSIT_NOTICE_DRAFTED_FROM_QUOTATION' + """, Map.of("id", pricingRequestId), Long.class)).isEqualTo(1L); + } + + /** The reverse mix: the customer answered the legacy quotation, and the sibling pricing + * request's newer new-engine quotation is only ISSUED. The answered legacy row governs both + * routes, and bills the 50% its PDF prints ({@code ticket.QuotationRenderer}), never the + * sibling's 40%. It wins because it was answered, not because it is legacy. */ + @Test + void createDepositNotice_legacyAcceptedNullPercent_whenASiblingPricingRequestHasANewerFortyPercentIssuedQuotation_bothRoutesBillTheAnsweredLegacyQuotationsFifty() { + CustomerQuotationDto legacyAccepted = acceptedLegacyQuotation(PricingRequestRecipient.DESIGNER); + assertThat(jdbc.queryForObject("SELECT deposit_percent IS NULL FROM sales.quotation WHERE quotation_id = :id", + Map.of("id", legacyAccepted.id()), Boolean.class)).isTrue(); + DealQuotationDto siblingIssued = issuedNewOriginQuotation(PricingRequestRecipient.OWNER, new BigDecimal("10"), 40); + assertThat(siblingIssued.depositPercent()).isEqualTo(40); + long pricingRequestId = legacyAccepted.pricingRequestId(); + orderConfirmation.confirmOrder(pricingRequestId, + new OrderConfirmationRequests.ConfirmOrderRequest(UUID.randomUUID().toString()), salesActor); + + DepositNoticeDto viaPricingRequest = orderConfirmation.createDepositNoticeFromQuotation(pricingRequestId, + new OrderConfirmationRequests.CreateDepositNoticeFromQuotationRequest(null), salesActor); + DepositNoticeDto viaTicket = depositNoticeService.createDraft(ticketId, + new DepositNoticeDraftRequest(null, null, null, null, null, null, null, null), salesActor); + + List legacyLines = DepositNoticeService.itemsFromQuotation(legacyAccepted.items()).stream() + .map(it -> line(it.qty(), it.netUnitPrice())).toList(); + List siblingLines = jdbc.query(""" + SELECT qty, final_unit_price FROM sales.quotation_item WHERE quotation_id = :id ORDER BY seq + """, Map.of("id", siblingIssued.id()), + (rs, i) -> line(rs.getBigDecimal("qty"), rs.getBigDecimal("final_unit_price"))); + // The fixture must make the two sources distinguishable, or the item assertions prove nothing. + assertThat(legacyLines).isNotEmpty().isNotEqualTo(siblingLines); + + assertThat(siblingIssued.id()).isGreaterThan(legacyAccepted.id()); + for (DepositNoticeDto draft : List.of(viaPricingRequest, viaTicket)) { + assertThat(draft.depositPercent()).isEqualByComparingTo("0.50"); + assertThat(draft.items().stream().map(it -> line(it.qty(), it.netUnitPrice())).toList()) + .isEqualTo(legacyLines); + assertThat(draft.reference()).isEqualTo(legacyAccepted.number()); + } + } + + // ── A ไม่รับมัดจำ quotation puts the deal on the no-deposit payment path (owner ruling 2026-10-22) ── + // confirmOrder syncs sales.ticket.deposit_policy from the confirmed quotation's 0%, so the import + // request and final-payment gates open without a manual waiveDeposit, and no route can bill a deposit. + + @Test + void confirmOrder_zeroDepositQuotation_setsNotRequired_opensTheImportGate_andBothDraftRoutes409() { + DealQuotationDto accepted = acceptedZeroDepositNewOriginQuotation(PricingRequestRecipient.DESIGNER, + new BigDecimal("10")); + long pricingRequestId = accepted.pricingRequestId(); + orderConfirmation.confirmOrder(pricingRequestId, + new OrderConfirmationRequests.ConfirmOrderRequest(UUID.randomUUID().toString()), salesActor); + + var s = tickets.findById(ticketId).orElseThrow().summary(); + assertThat(s.depositPolicy()).isEqualTo("NOT_REQUIRED"); + assertThat(s.depositPolicyReason()).contains(accepted.number()).contains("ไม่รับมัดจำ"); + assertThat(policyChangedMessages()).singleElement().asString() + .contains("NOT_REQUIRED").contains(accepted.number()); + assertThat(s.paymentStatus()).isEqualTo("CUSTOMER_CONFIRMED"); + // A REQUIRED deal at CUSTOMER_CONFIRMED fails this gate until a deposit notice is issued. + ticketService.requireImportRequestIssuable(s); + + assertThatThrownBy(() -> depositNoticeService.createDraft(ticketId, emptyDepositDraft(), salesActor)) + .isInstanceOf(ApiException.class) + .hasFieldOrPropertyWithValue("status", HttpStatus.CONFLICT) + .hasMessageContaining("ไม่ต้องเก็บมัดจำ"); + assertThatThrownBy(() -> orderConfirmation.createDepositNoticeFromQuotation(pricingRequestId, + new OrderConfirmationRequests.CreateDepositNoticeFromQuotationRequest(null), salesActor)) + .isInstanceOf(ApiException.class) + .hasFieldOrPropertyWithValue("status", HttpStatus.CONFLICT); + assertThat(depositNoticeCount()).isZero(); + } + + /** Wrong-way-round: only an explicit 0% syncs; a deposit-bearing quotation leaves REQUIRED alone. */ + @Test + void confirmOrder_thirtyPercentQuotation_leavesTheDepositPolicyRequired() { + DealQuotationDto accepted = acceptedNewOriginQuotation(PricingRequestRecipient.DESIGNER, new BigDecimal("10"), 30); + orderConfirmation.confirmOrder(accepted.pricingRequestId(), + new OrderConfirmationRequests.ConfirmOrderRequest(UUID.randomUUID().toString()), salesActor); + + var s = tickets.findById(ticketId).orElseThrow().summary(); + assertThat(s.depositPolicy()).isEqualTo("REQUIRED"); + assertThat(s.depositPolicyReason()).isNull(); + assertThat(policyChangedMessages()).isEmpty(); + assertThatThrownBy(() -> ticketService.requireImportRequestIssuable(s)) + .isInstanceOf(ApiException.class) + .hasFieldOrPropertyWithValue("status", HttpStatus.CONFLICT); + } + + /** Wrong-way-round: the sync only replaces REQUIRED, never a policy a person already chose. */ + @Test + void confirmOrder_zeroDepositQuotationOnACreditCustomerDeal_keepsCreditCustomer() { + ticketService.waiveDeposit(ticketId, "CREDIT_CUSTOMER", "ลูกค้าเครดิต 30 วัน", salesActor); + DealQuotationDto accepted = acceptedZeroDepositNewOriginQuotation(PricingRequestRecipient.DESIGNER, + new BigDecimal("10")); + orderConfirmation.confirmOrder(accepted.pricingRequestId(), + new OrderConfirmationRequests.ConfirmOrderRequest(UUID.randomUUID().toString()), salesActor); + + var s = tickets.findById(ticketId).orElseThrow().summary(); + assertThat(s.depositPolicy()).isEqualTo("CREDIT_CUSTOMER"); + assertThat(s.depositPolicyReason()).isEqualTo("ลูกค้าเครดิต 30 วัน"); + assertThat(policyChangedMessages()).singleElement().asString().contains("CREDIT_CUSTOMER"); + } + + /** Both draft routes refuse a deal whose policy bypasses the deposit notice. A 30% quotation keeps + * #1166's percent guard out of the way, so the PR route reaches the shared policy check. */ + @Test + void depositDraft_onAWaivedDeal_bothRoutes409OnThePolicy_andWriteNothing() { + DealQuotationDto accepted = acceptedNewOriginQuotation(PricingRequestRecipient.DESIGNER, new BigDecimal("10"), 30); + long pricingRequestId = accepted.pricingRequestId(); + orderConfirmation.confirmOrder(pricingRequestId, + new OrderConfirmationRequests.ConfirmOrderRequest(UUID.randomUUID().toString()), salesActor); + ticketService.waiveDeposit(ticketId, "WAIVED", "ลูกค้าโอนเต็มจำนวนล่วงหน้า", salesActor); + + assertThatThrownBy(() -> depositNoticeService.createDraft(ticketId, emptyDepositDraft(), salesActor)) + .isInstanceOf(ApiException.class) + .hasFieldOrPropertyWithValue("status", HttpStatus.CONFLICT) + .hasMessageContaining("ไม่ต้องเก็บมัดจำ"); + assertThatThrownBy(() -> orderConfirmation.createDepositNoticeFromQuotation(pricingRequestId, + new OrderConfirmationRequests.CreateDepositNoticeFromQuotationRequest(null), salesActor)) + .isInstanceOf(ApiException.class) + .hasFieldOrPropertyWithValue("status", HttpStatus.CONFLICT) + .hasMessageContaining("ไม่ต้องเก็บมัดจำ"); + assertThat(depositNoticeCount()).isZero(); + } + + /** Review finding 2026-10-22: issue() never checked the amount. A deal confirmed before the policy + * sync shipped is still REQUIRED, and its ticket-route draft reads the quotation's 0% per ticket. */ + @Test + void issue_zeroAmountDraft_409_staysDraft_andConsumesNoNumber() { + DealQuotationDto accepted = acceptedZeroDepositNewOriginQuotation(PricingRequestRecipient.DESIGNER, + new BigDecimal("10")); + orderConfirmation.confirmOrder(accepted.pricingRequestId(), + new OrderConfirmationRequests.ConfirmOrderRequest(UUID.randomUUID().toString()), salesActor); + // Stands in for a deal confirmed before the sync existed. + jdbc.update("UPDATE sales.ticket SET deposit_policy = 'REQUIRED', deposit_policy_reason = NULL WHERE ticket_id = :id", + Map.of("id", ticketId)); + // Inserted directly: both draft routes now refuse a 0% deal, so a legacy 0-baht draft can + // only exist as data from before this fix. + Long draftId = jdbc.queryForObject(""" + INSERT INTO sales.deposit_notice (ticket_id, version, customer_name, deposit_percent, subtotal, + deposit_amount, vat_percent, vat_amount, total_payable) + VALUES (:id, 1, 'x', 0, 0, 0, 0.07, 0, 0) RETURNING deposit_notice_id""", + Map.of("id", ticketId), Long.class); + DepositNoticeDto draft = depositNoticeService.getById(draftId, salesActor); + assertThat(draft.depositAmount()).isEqualByComparingTo("0"); + long sequenceBefore = depositNoticeSequence(); + + assertThatThrownBy(() -> depositNoticeService.issue(draft.id(), salesActor)) + .isInstanceOf(ApiException.class) + .hasFieldOrPropertyWithValue("status", HttpStatus.CONFLICT) + .hasMessageContaining("0%"); + + Map row = jdbc.queryForMap( + "SELECT status, doc_number FROM sales.deposit_notice WHERE deposit_notice_id = :id", Map.of("id", draft.id())); + assertThat(row.get("status")).isEqualTo("DRAFT"); + assertThat(row.get("doc_number")).isNull(); + assertThat(depositNoticeSequence()).isEqualTo(sequenceBefore); + assertThat(tickets.findById(ticketId).orElseThrow().summary().paymentStatus()).isEqualTo("CUSTOMER_CONFIRMED"); + } + + private static DepositNoticeDraftRequest emptyDepositDraft() { + return new DepositNoticeDraftRequest(null, null, null, null, null, null, null, null); + } + + private List policyChangedMessages() { + return jdbc.queryForList("SELECT message FROM sales.ticket_event WHERE ticket_id = :id AND kind = 'POLICY_CHANGED'", + Map.of("id", ticketId), String.class); + } + + private long depositNoticeCount() { + return jdbc.queryForObject("SELECT COUNT(*) FROM sales.deposit_notice WHERE ticket_id = :id", + Map.of("id", ticketId), Long.class); + } + + private long depositNoticeSequence() { + return jdbc.queryForObject( + "SELECT COALESCE(SUM(last_seq), 0) FROM sales.document_sequence WHERE doc_type = 'DEPOSIT_NOTICE'", + Map.of(), Long.class); } @Test diff --git a/backend/src/test/java/th/co/glr/hr/deposit/DepositNoticeAccountNotificationIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/deposit/DepositNoticeAccountNotificationIntegrationTest.java index 307817868..fa0d21a79 100644 --- a/backend/src/test/java/th/co/glr/hr/deposit/DepositNoticeAccountNotificationIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/deposit/DepositNoticeAccountNotificationIntegrationTest.java @@ -276,42 +276,6 @@ void issue_secondCallOnAnAlreadyIssuedDocIsRefused_andRaisesNoSecondAccountNotif assertThat(mailer.sent()).isEmpty(); } - // ───────────────────────────────────────────────────────────────────────────────────── - // Revision re-issue (the DEPOSIT_NOTICE_ISSUED self-loop) notifies account again - // ───────────────────────────────────────────────────────────────────────────────────── - - @Test - void issue_revisionReissue_notifiesAccountAgainMarkedAsARevision() { - DraftFixture fixture = buildDraftDepositNotice(); - depositNoticeService.issue(fixture.docId(), salesActor); - assertThat(countAccountNotificationRows()).isEqualTo(1); - mailer.sent.clear(); - - // Repository-direct on purpose: DepositNoticeService.createDraft only ever sources the - // FIRST draft from the ticket's approved items/quotation, so building a literal SECOND - // draft (the revision) this way is the shortest path to the state under test here, and - // it is only issue() itself (called through the real service below) that this test cares - // about exercising faithfully. - long secondDocId = new DepositNoticeRepository(jdbc).createDraft(fixture.ticketId(), - new DepositNoticeDraftRequest("ACME Revision", "0100000000000", "Bangkok", "Showroom", "REF-REV", - new BigDecimal("0.50"), List.of(), null), - List.of(new DepositNoticeItemRequest( - 1, "Revision item", new BigDecimal("1"), "แผ่น", - new BigDecimal("100.00"), null, new BigDecimal("100.00")))); - - DepositNoticeDto revisionIssued = depositNoticeService.issue(secondDocId, salesActor); - - assertThat(countAccountNotificationRows()).isEqualTo(2); - String secondMessage = (String) jdbc.queryForMap(""" - SELECT message FROM hr.notification - WHERE employee_id = :accountId AND type = 'DEPOSIT_NOTICE_ISSUED' - ORDER BY notification_id DESC LIMIT 1 - """, Map.of("accountId", accountEmployeeId)).get("message"); - assertThat(secondMessage).contains("ฉบับแก้ไข"); - assertThat(secondMessage).contains(revisionIssued.docNumber()); - assertThat(mailer.recipients()).contains("account@glr.co.th"); - } - // ───────────────────────────────────────────────────────────────────────────────────── // Rollback: the critical AfterCommit trap this branch exists to avoid regressing // ───────────────────────────────────────────────────────────────────────────────────── diff --git a/backend/src/test/java/th/co/glr/hr/deposit/DepositNoticeControllerTest.java b/backend/src/test/java/th/co/glr/hr/deposit/DepositNoticeControllerTest.java index e71523e65..182d821bf 100644 --- a/backend/src/test/java/th/co/glr/hr/deposit/DepositNoticeControllerTest.java +++ b/backend/src/test/java/th/co/glr/hr/deposit/DepositNoticeControllerTest.java @@ -1,19 +1,27 @@ package th.co.glr.hr.deposit; +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; +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 static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import java.math.BigDecimal; +import java.time.Instant; import java.time.LocalDate; import java.util.List; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.mock.web.MockHttpSession; @@ -124,6 +132,42 @@ void createDraftRejectsInvalidDepositPercent() throws Exception { .andExpect(status().isBadRequest()); } + @Test + void updateRejectsADepositPercentOutsideZeroTo100WithAThaiMessage() throws Exception { + for (String[] c : new String[][] { + {"1.5", "depositPercent เปอร์เซ็นต์มัดจำต้องไม่เกิน 100"}, + {"-0.05", "depositPercent เปอร์เซ็นต์มัดจำต้องมากกว่า 0"}, + {"0", "depositPercent เปอร์เซ็นต์มัดจำต้องมากกว่า 0"}}) { + mvc.perform(put("/api/deposit-notices/99") + .session(session()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"depositPercent\": " + c[0] + "}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value(c[1])); + } + verify(service, never()).update(anyLong(), any(), any()); + } + + // The record has a second, 8-argument constructor: the JSON must still bind through the + // canonical one, so the page's stamp reaches the service at full precision. + @Test + void updatePassesTheExpectedUpdatedAtStampThrough() throws Exception { + when(service.update(eq(99L), any(), any(UserPrincipal.class))).thenReturn(document()); + + mvc.perform(put("/api/deposit-notices/99") + .session(session()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"depositPercent": 0.4, "expectedUpdatedAt": "2026-10-23T08:57:19.732123Z"} + """)) + .andExpect(status().isOk()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(DepositNoticeDraftRequest.class); + verify(service).update(eq(99L), captor.capture(), any(UserPrincipal.class)); + assertThat(captor.getValue().expectedUpdatedAt().toInstant()) + .isEqualTo(Instant.parse("2026-10-23T08:57:19.732123Z")); + } + @Test void createDraftRejectsNegativeItemUnitPrice() throws Exception { String body = """ diff --git a/backend/src/test/java/th/co/glr/hr/deposit/DepositNoticeDealQuotationAutofillIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/deposit/DepositNoticeDealQuotationAutofillIntegrationTest.java index f9bc3dcb8..66db9f5fa 100644 --- a/backend/src/test/java/th/co/glr/hr/deposit/DepositNoticeDealQuotationAutofillIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/deposit/DepositNoticeDealQuotationAutofillIntegrationTest.java @@ -11,14 +11,15 @@ import th.co.glr.hr.support.AbstractPostgresIntegrationTest; /** - * Real-DB proof for {@link DepositNoticeRepository#findDealQuotationItemsForDeposit(long)} (the - * 2026-09-29 fix) — the V2 deal-quotation item source {@code DepositNoticeService - * #buildItemsFromRequest} falls back to once both the legacy {@code ticket_item.approved_price} - * path and the {@code CustomerQuotationRepository}-scoped {@code pickQuotation} path yield - * nothing. Mockito cannot exercise this: the method's whole job is the SQL — the status/origin - * filter and the {@code ORDER BY} preference — so this needs the real query against real - * Postgres, not a mocked repository. See that method's own Javadoc for the exact selection rule - * this test pins. + * Real-DB proof for the V2 deal-quotation item source of a ticket-level deposit notice (the + * 2026-09-29 fix): {@link DepositNoticeRepository#findGoverningQuotation} picks the quotation and + * {@link DepositNoticeRepository#findDealQuotationItems} reads its lines, the pair {@code + * DepositNoticeService#itemsFromGoverning} uses once the legacy {@code + * ticket_item.approved_price} path yields nothing. Mockito cannot exercise this: the job is the + * SQL (the status/origin filter, the {@code ORDER BY} preference, the column mapping), so this + * needs the real queries against real Postgres. Which quotation wins (the ranking across origins + * and insertion order) is pinned in {@code DepositNoticeRepositoryDepositPercentIntegrationTest}; + * this class's non-live test is the only pin for some excluded statuses, so it is not a duplicate. */ class DepositNoticeDealQuotationAutofillIntegrationTest extends AbstractPostgresIntegrationTest { @@ -44,7 +45,7 @@ void pricingRequestIssuedQuotation_returnsItemsInSeqOrderWithDiscountLabels() { insertQuotationItem(quotationId, 1, "กระเบื้อง A", new BigDecimal("10"), "แผ่น", new BigDecimal("4365.29"), new BigDecimal("873.06"), new BigDecimal("3492.23")); - List items = repo.findDealQuotationItemsForDeposit(ticketId); + List items = itemsForDeposit(ticketId); assertThat(items).hasSize(2); assertThat(items.get(0).seq()).isEqualTo(1); @@ -63,43 +64,7 @@ void pricingRequestIssuedQuotation_returnsItemsInSeqOrderWithDiscountLabels() { assertThat(items.get(1).discountLabel()).isNull(); } - // ── (b) DEAL_DIRECT / APPROVED — the case the old IN ('ACCEPTED','ISSUED') filter MISSED ─── - - @Test - void dealDirectApprovedQuotation_isReturned_theCoreRegressionThisFixCloses() { - long ticketId = insertTicket("DEPNOT-B"); - long quotationId = insertQuotation(ticketId, "DEAL_DIRECT", "APPROVED"); - insertQuotationItem(quotationId, 1, "DEAL_DIRECT item", new BigDecimal("2"), "แผ่น", - new BigDecimal("1000.00"), BigDecimal.ZERO, new BigDecimal("1000.00")); - - List items = repo.findDealQuotationItemsForDeposit(ticketId); - - assertThat(items).hasSize(1); - assertThat(items.get(0).description()).isEqualTo("DEAL_DIRECT item"); - assertThat(items.get(0).unitPrice()).isEqualByComparingTo("1000.00"); - assertThat(items.get(0).discountLabel()).isNull(); - } - - // ── (c) ACCEPTED beats ISSUED (both PRICING_REQUEST, same ticket) ─────────────────────────── - - @Test - void acceptedPricingRequestQuotation_winsOverAnIssuedOne_onTheSameTicket() { - long ticketId = insertTicket("DEPNOT-C"); - long issuedQuotationId = insertQuotation(ticketId, "PRICING_REQUEST", "ISSUED"); - insertQuotationItem(issuedQuotationId, 1, "Issued item (must lose)", BigDecimal.ONE, "แผ่น", - new BigDecimal("111.00"), BigDecimal.ZERO, new BigDecimal("111.00")); - long acceptedQuotationId = insertQuotation(ticketId, "PRICING_REQUEST", "ACCEPTED"); - insertQuotationItem(acceptedQuotationId, 1, "Accepted item (must win)", BigDecimal.ONE, "แผ่น", - new BigDecimal("222.00"), BigDecimal.ZERO, new BigDecimal("222.00")); - - List items = repo.findDealQuotationItemsForDeposit(ticketId); - - assertThat(items).hasSize(1); - assertThat(items.get(0).description()).isEqualTo("Accepted item (must win)"); - assertThat(items.get(0).unitPrice()).isEqualByComparingTo("222.00"); - } - - // ── (d) NEGATIVE, wrong-way-round: nothing in a live status -> empty list ─────────────────── + // ── (b) NEGATIVE, wrong-way-round: nothing in a live status -> empty list ─────────────────── /** * Written wrong-way-round per CLAUDE.md's "Permission changes must ship evidence" guidance, @@ -124,13 +89,20 @@ void onlyNonLiveStatusQuotations_returnsEmptyList() { insertQuotationItem(pendingApprovalId, 1, "Pending-approval item", BigDecimal.ONE, "แผ่น", BigDecimal.TEN, BigDecimal.ZERO, BigDecimal.TEN); - List items = repo.findDealQuotationItemsForDeposit(ticketId); + List items = itemsForDeposit(ticketId); assertThat(items).isEmpty(); } // ── helpers ────────────────────────────────────────────────────────────────────────────── + /** The new-engine branch of {@code DepositNoticeService#itemsFromGoverning}. */ + private List itemsForDeposit(long ticketId) { + return repo.findGoverningQuotation(ticketId) + .map(governing -> repo.findDealQuotationItems(governing.quotationId())) + .orElse(List.of()); + } + private long insertEmployee(String code) { return jdbc.queryForObject( "INSERT INTO hr.employee (employee_code, first_name_th, last_name_th) " @@ -171,7 +143,7 @@ private long insertQuotation(long ticketId, String origin, String docStatus) { private void insertQuotationItem(long quotationId, int seq, String description, BigDecimal qty, String rawUnit, BigDecimal unitPrice, BigDecimal salesDiscount, BigDecimal finalUnitPrice) { - // amount is legacy NOT NULL (V49) — irrelevant to findDealQuotationItemsForDeposit, which + // amount is legacy NOT NULL (V49) — irrelevant to findDealQuotationItems, which // reads unit_price/final_unit_price instead, so a harmless qty*unitPrice placeholder. BigDecimal amount = qty.multiply(unitPrice); jdbc.update(""" diff --git a/backend/src/test/java/th/co/glr/hr/deposit/DepositNoticeIssueGuardIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/deposit/DepositNoticeIssueGuardIntegrationTest.java index 42aaadd72..42d433225 100644 --- a/backend/src/test/java/th/co/glr/hr/deposit/DepositNoticeIssueGuardIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/deposit/DepositNoticeIssueGuardIntegrationTest.java @@ -11,6 +11,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import java.math.BigDecimal; import java.time.LocalDate; +import java.time.OffsetDateTime; import java.util.List; import java.util.Map; import java.util.Optional; @@ -209,27 +210,178 @@ void issue_repositoryLevelCallOnAlreadyIssuedRowRefusesWithoutRemintingDocNumber assertThat(reread.status()).isEqualTo("ISSUED"); } + // The deal page's confirm dialog showed amounts read at T0; the draft was saved since. Issuing + // with the old stamp is refused (nothing numbered); with the current one it issues. Repository + // level: the service's one-per-deal guard would answer first on this fixture's ticket. @Test - void issue_secondDraftOnSameTicketSucceedsAndSupersedesTheFirst() { - // POSITIVE control: the guard must not block a genuine DRAFT -> ISSUED transition, and the - // pre-existing "supersede older ISSUED versions" behaviour in the same method must still - // work correctly with the new predicate in place. + void issue_withAStaleUpdatedAt_isRefused409_andNothingIsIssued() { + IssuedNoticeFixture fixture = buildTicketWithIssuedDepositNotice(); + long draftId = depositNoticeRepository.createDraft(fixture.ticketId(), secondDraftHeader(), secondDraftItems()); + OffsetDateTime loaded = depositNoticeRepository.findById(draftId).orElseThrow().updatedAt(); + DepositNoticeDto saved = depositNoticeService.update(draftId, stampedRequest("0.40", secondDraftItems(), loaded), salesActor); + + assertThatThrownBy(() -> depositNoticeRepository.issue(draftId, salesRepId, "x", loaded)) + .isInstanceOfSatisfying(ApiException.class, e -> { + assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT); + assertThat(e.getMessage()).isEqualTo("ใบแจ้งรับมัดจำนี้ถูกแก้ไขโดยผู้ใช้อื่น กรุณาโหลดข้อมูลใหม่แล้วลองอีกครั้ง"); + }); + DepositNoticeDto reread = depositNoticeRepository.findById(draftId).orElseThrow(); + assertThat(reread.status()).isEqualTo("DRAFT"); + assertThat(reread.docNumber()).isNull(); + + assertThat(depositNoticeRepository.issue(draftId, salesRepId, "x", saved.updatedAt())).isPresent(); + } + + @Test + void issue_secondDraftOnSameTicket_refusedUntilBillable_thenStillRefusedWhileAnotherNoticeIsIssued() { IssuedNoticeFixture fixture = buildTicketWithIssuedDepositNotice(); + // A stored percent outside whole 1-100 (a 0% ticket-route draft on a ไม่รับมัดจำ quotation, + // a pre-09-29 free-typed 0.3330, an out-of-range 1.5) or a 0-baht amount takes no number. long secondDocId = depositNoticeRepository.createDraft( - fixture.ticketId(), secondDraftHeader(), secondDraftItems()); - Optional secondDocNumber = depositNoticeRepository.issue(secondDocId, salesRepId, "Sales"); + fixture.ticketId(), draftHeader(BigDecimal.ZERO), secondDraftItems()); + assertIssueRefusedAndNothingChanged(fixture, secondDocId, + "ใบแจ้งรับมัดจำนี้มีเปอร์เซ็นต์มัดจำ 0% จึงออกเอกสารไม่ได้ (ต้องเป็นจำนวนเต็ม 1–100)"); + assertIssueRefusedAndNothingChanged(fixture, + depositNoticeRepository.createDraft(fixture.ticketId(), draftHeader(new BigDecimal("0.3330")), secondDraftItems()), + "ใบแจ้งรับมัดจำนี้มีเปอร์เซ็นต์มัดจำ 33.3% จึงออกเอกสารไม่ได้ (ต้องเป็นจำนวนเต็ม 1–100)"); + assertIssueRefusedAndNothingChanged(fixture, + depositNoticeRepository.createDraft(fixture.ticketId(), draftHeader(new BigDecimal("1.5")), secondDraftItems()), + "ใบแจ้งรับมัดจำนี้มีเปอร์เซ็นต์มัดจำ 150% จึงออกเอกสารไม่ได้ (ต้องเป็นจำนวนเต็ม 1–100)"); + assertIssueRefusedAndNothingChanged(fixture, + depositNoticeRepository.createDraft(fixture.ticketId(), secondDraftHeader(), List.of(new DepositNoticeItemRequest( + 1, "Free item", new BigDecimal("2"), "แผ่น", BigDecimal.ZERO, null, BigDecimal.ZERO))), + "ใบแจ้งรับมัดจำนี้มียอดมัดจำ 0 บาท จึงออกเอกสารไม่ได้"); + + // Owner ruling 2026-10-23: one deposit notice per deal. Even once the percent is billable, + // a SECOND draft cannot take a number while another notice is ISSUED (it used to silently + // supersede the customer's document): 409, the draft keeps no number, the first stays ISSUED. + depositNoticeService.update(secondDocId, secondDraftHeader(), salesActor); + assertIssueRefusedAndNothingChanged(fixture, secondDocId, + "ดีลนี้ออกใบแจ้งรับมัดจำเลขที่ " + fixture.docNumber() + " แล้ว จึงออกฉบับร่างนี้ไม่ได้"); + } + + // Owner ruling 2026-10-23: one deposit notice per deal. While one is ISSUED, neither create route + // drafts another (re-creating re-seeded the % from the quotation and a later issue silently + // superseded the customer's document). Re-versioning an issued notice is a separate change. + @Test + void createDraft_isRefusedOnBothRoutesWhileAnIssuedNoticeExists_noNewRow() { + IssuedNoticeFixture fixture = buildTicketWithIssuedDepositNotice(); + String message = "ดีลนี้ออกใบแจ้งรับมัดจำเลขที่ " + fixture.docNumber() + " แล้ว จึงสร้างฉบับร่างใหม่ไม่ได้"; + + assertThatThrownBy(() -> depositNoticeService.createDraft(fixture.ticketId(), + new DepositNoticeDraftRequest(null, null, null, null, null, null, null, null), salesActor)) + .isInstanceOfSatisfying(ApiException.class, e -> { + assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT); + assertThat(e.getMessage()).isEqualTo(message); + }); + assertThatThrownBy(() -> orderConfirmation.createDepositNoticeFromQuotation(fixture.pricingRequestId(), + new CreateDepositNoticeFromQuotationRequest(null), salesActor)) + .isInstanceOfSatisfying(ApiException.class, e -> { + assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT); + assertThat(e.getMessage()).isEqualTo(message); + }); + + assertThat(depositNoticeRepository.findByTicket(fixture.ticketId())) + .extracting(DepositNoticeDto::id).containsExactly(fixture.docId()); + } - assertThat(secondDocNumber).isPresent(); - assertThat(secondDocNumber.get()).isNotEqualTo(fixture.docNumber()); + // Owner ruling 2026-10-23: sales may change the deposit % on a DRAFT; the amount follows. + @Test + void update_onADraft_changesThePercentAndTheAmountFollows() { + IssuedNoticeFixture fixture = buildTicketWithIssuedDepositNotice(); + long draftId = depositNoticeRepository.createDraft( + fixture.ticketId(), secondDraftHeader(), secondDraftItems()); // 0.50 of 100.00 - DepositNoticeDto firstReread = depositNoticeRepository.findById(fixture.docId()).orElseThrow(); - assertThat(firstReread.status()).isEqualTo("SUPERSEDED"); - assertThat(firstReread.docNumber()).isEqualTo(fixture.docNumber()); + DepositNoticeDto updated = depositNoticeService.update(draftId, new DepositNoticeDraftRequest( + "ACME", "0100000000000", "Bangkok", "Showroom", "REF-2", + new BigDecimal("0.40"), List.of(), secondDraftItems()), salesActor); + + // secondDraftItems: net x qty sums to 75.02 (unit x qty 90.00, stored amounts 75.03, nets alone 43.34). + assertThat(updated.depositPercent()).isEqualByComparingTo("0.40"); + assertThat(updated.subtotal()).isEqualByComparingTo("75.02"); + assertThat(updated.depositAmount()).isEqualByComparingTo("30.01"); + assertThat(updated.vatAmount()).isEqualByComparingTo("2.10"); + assertThat(updated.totalPayable()).isEqualByComparingTo("32.11"); + assertThat(depositNoticeService.preview(draftId, salesActor)) + .contains("ขอรับเงินมัดจำ (40%)") + .contains("30.01 บาท"); + + // A request without items (null, or an empty list) keeps the stored lines; the amount is + // recomputed from them at the new percent. + DepositNoticeDto nullItems = depositNoticeService.update(draftId, new DepositNoticeDraftRequest( + "ACME", "0100000000000", "Bangkok", "Showroom", "REF-2", + new BigDecimal("0.30"), List.of(), null), salesActor); + assertThat(nullItems.items()).isEqualTo(updated.items()); + assertThat(nullItems.subtotal()).isEqualByComparingTo("75.02"); + assertThat(nullItems.depositAmount()).isEqualByComparingTo("22.51"); - DepositNoticeDto secondReread = depositNoticeRepository.findById(secondDocId).orElseThrow(); - assertThat(secondReread.status()).isEqualTo("ISSUED"); - assertThat(secondReread.docNumber()).isEqualTo(secondDocNumber.get()); + DepositNoticeDto emptyItems = depositNoticeService.update(draftId, new DepositNoticeDraftRequest( + "ACME", "0100000000000", "Bangkok", "Showroom", "REF-2", + new BigDecimal("0.20"), List.of(), List.of()), salesActor); + assertThat(emptyItems.items()).isEqualTo(updated.items()); + assertThat(emptyItems.subtotal()).isEqualByComparingTo("75.02"); + assertThat(emptyItems.depositAmount()).isEqualByComparingTo("15.00"); + } + + // Two tabs on one draft: both loaded the same updatedAt. A saves; B's save with that now-stale + // stamp is refused and changes nothing; reloading (the fresh stamp) saves. + @Test + void update_withAStaleUpdatedAt_isRefused409_andTheFirstSaveStands() { + IssuedNoticeFixture fixture = buildTicketWithIssuedDepositNotice(); + long draftId = depositNoticeRepository.createDraft(fixture.ticketId(), secondDraftHeader(), secondDraftItems()); + OffsetDateTime loaded = depositNoticeRepository.findById(draftId).orElseThrow().updatedAt(); + + DepositNoticeDto savedByA = depositNoticeService.update(draftId, stampedRequest("0.40", secondDraftItems(), loaded), salesActor); + + List tabBItems = List.of(new DepositNoticeItemRequest( + 1, "Tab B item", new BigDecimal("1"), "แผ่น", new BigDecimal("999.00"), null, new BigDecimal("999.00"))); + assertThatThrownBy(() -> depositNoticeService.update(draftId, stampedRequest("0.30", tabBItems, loaded), salesActor)) + .isInstanceOfSatisfying(ApiException.class, e -> { + assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT); + assertThat(e.getMessage()).isEqualTo("ใบแจ้งรับมัดจำนี้ถูกแก้ไขโดยผู้ใช้อื่น กรุณาโหลดข้อมูลใหม่แล้วลองอีกครั้ง"); + }); + DepositNoticeDto reread = depositNoticeRepository.findById(draftId).orElseThrow(); + assertThat(reread.depositPercent()).isEqualByComparingTo("0.40"); + assertThat(reread.depositAmount()).isEqualByComparingTo(savedByA.depositAmount()); + assertThat(reread.items()).isEqualTo(savedByA.items()); + assertThat(reread.updatedAt()).isEqualTo(savedByA.updatedAt()); + + DepositNoticeDto reloaded = depositNoticeService.update( + draftId, stampedRequest("0.30", tabBItems, savedByA.updatedAt()), salesActor); + assertThat(reloaded.depositPercent()).isEqualByComparingTo("0.30"); + assertThat(reloaded.subtotal()).isEqualByComparingTo("999.00"); + } + + @Test + void update_onAnIssuedNotice_refusesThePercentEditWith409_rowUnchanged() { + IssuedNoticeFixture fixture = buildTicketWithIssuedDepositNotice(); + DepositNoticeDto before = depositNoticeRepository.findById(fixture.docId()).orElseThrow(); + + assertThatThrownBy(() -> depositNoticeService.update(fixture.docId(), new DepositNoticeDraftRequest( + "ACME", "0100000000000", "Bangkok", "Showroom", "REF-2", + new BigDecimal("0.40"), List.of(), secondDraftItems()), salesActor)) + .isInstanceOfSatisfying(ApiException.class, e -> { + assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT); + assertThat(e.getMessage()).isEqualTo("ใบแจ้งรับมัดจำนี้ไม่ได้อยู่ในสถานะร่าง (DRAFT)"); + }); + + // Repository level: a save that passed requireDraft before issue() committed reaches the + // UPDATE with the row already ISSUED. It must refuse before touching the lines, which the + // PDF/XLSX print. + assertThatThrownBy(() -> depositNoticeRepository.update(fixture.docId(), new DepositNoticeDraftRequest( + "ACME", "0100000000000", "Bangkok", "Showroom", "REF-2", + new BigDecimal("0.40"), List.of(), secondDraftItems()), before.items())) + .isInstanceOfSatisfying(ApiException.class, e -> { + assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT); + assertThat(e.getMessage()).isEqualTo("ใบแจ้งรับมัดจำนี้ไม่ได้อยู่ในสถานะร่าง (DRAFT)"); + }); + + DepositNoticeDto reread = depositNoticeRepository.findById(fixture.docId()).orElseThrow(); + assertThat(reread.status()).isEqualTo("ISSUED"); + assertThat(reread.depositPercent()).isEqualByComparingTo(before.depositPercent()); + assertThat(reread.depositAmount()).isEqualByComparingTo(before.depositAmount()); + assertThat(reread.items()).isEqualTo(before.items()); } // ───────────────────────────────────────────────────────────────────────────────────── @@ -240,15 +392,43 @@ void issue_secondDraftOnSameTicketSucceedsAndSupersedesTheFirst() { private record IssuedNoticeFixture(long ticketId, long pricingRequestId, long docId, String docNumber) {} private DepositNoticeDraftRequest secondDraftHeader() { + return draftHeader(new BigDecimal("0.50")); + } + + private DepositNoticeDraftRequest draftHeader(BigDecimal depositPercent) { return new DepositNoticeDraftRequest( "ACME", "0100000000000", "Bangkok", "Showroom", "REF-2", - new BigDecimal("0.50"), List.of(), null); + depositPercent, List.of(), null); } + private DepositNoticeDraftRequest stampedRequest(String depositPercent, List items, + OffsetDateTime expectedUpdatedAt) { + return new DepositNoticeDraftRequest("ACME", "0100000000000", "Bangkok", "Showroom", "REF-2", + new BigDecimal(depositPercent), List.of(), items, expectedUpdatedAt); + } + + // Fractional qty, net below unit: net x qty (49.995 + 25.025 = 75.02), unit x qty (90.00), the + // per-line rounded amounts (50.00 + 25.03 = 75.03) and the nets alone (43.34) all differ. private List secondDraftItems() { - return List.of(new DepositNoticeItemRequest( - 1, "Second draft item", new BigDecimal("1"), "แผ่น", - new BigDecimal("100.00"), null, new BigDecimal("100.00"))); + return List.of( + new DepositNoticeItemRequest(1, "Second draft item A", new BigDecimal("1.5"), "แผ่น", + new BigDecimal("40.00"), "ส่วนลด 6.67 ต่อหน่วย", new BigDecimal("33.33")), + new DepositNoticeItemRequest(2, "Second draft item B", new BigDecimal("2.5"), "แผ่น", + new BigDecimal("12.00"), "ส่วนลด 1.99 ต่อหน่วย", new BigDecimal("10.01"))); + } + + private void assertIssueRefusedAndNothingChanged(IssuedNoticeFixture fixture, long draftId, String message) { + assertThatThrownBy(() -> depositNoticeService.issue(draftId, salesActor)) + .isInstanceOfSatisfying(ApiException.class, e -> { + assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT); + assertThat(e.getMessage()).isEqualTo(message); + }); + DepositNoticeDto draft = depositNoticeRepository.findById(draftId).orElseThrow(); + assertThat(draft.status()).isEqualTo("DRAFT"); + assertThat(draft.docNumber()).isNull(); + DepositNoticeDto first = depositNoticeRepository.findById(fixture.docId()).orElseThrow(); + assertThat(first.status()).isEqualTo("ISSUED"); + assertThat(first.docNumber()).isEqualTo(fixture.docNumber()); } private IssuedNoticeFixture buildTicketWithIssuedDepositNotice() { diff --git a/backend/src/test/java/th/co/glr/hr/deposit/DepositNoticeRepositoryDepositPercentIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/deposit/DepositNoticeRepositoryDepositPercentIntegrationTest.java index 569cfddaa..ffbb3566c 100644 --- a/backend/src/test/java/th/co/glr/hr/deposit/DepositNoticeRepositoryDepositPercentIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/deposit/DepositNoticeRepositoryDepositPercentIntegrationTest.java @@ -5,10 +5,13 @@ import java.math.BigDecimal; import java.time.LocalDate; import java.util.List; -import java.util.Optional; +import java.util.stream.Stream; import java.util.UUID; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; import th.co.glr.hr.attachment.FileStorageService; import th.co.glr.hr.customer.ContactRepository; @@ -35,19 +38,17 @@ import th.co.glr.hr.dealquotation.WastageCalculator; /** - * Real-Postgres coverage for {@link DepositNoticeRepository#findDealQuotationDepositPercent} — - * owner ruling (2026-09-29): the deposit-notice PERCENT is LOCKED to the source quotation's own - * {@code sales.quotation.deposit_percent}, never editable at the deposit-notice stage. A Mockito - * unit test on {@code DepositNoticeServiceTest} already proves the SERVICE calls this repository - * method and trusts its result over a caller-supplied value — it cannot prove the method's own - * SQL actually reads the right row out of {@code sales.quotation}. This class drives that SQL - * directly. + * Real-Postgres coverage for {@link DepositNoticeRepository#findGoverningQuotation}, the one pick + * of the quotation a ticket-level deposit notice bills: its lines and its percent (owner rulings + * 2026-09-29 and 2026-10-23). {@code DepositNoticeServiceTest} proves the SERVICE takes both from + * the row this returns; it cannot prove the SQL picks the right row out of {@code sales.quotation}. + * This class drives that SQL directly. * *

    Fixture note: rows are written straight over JDBC rather than through {@code - * CustomerQuotationService}/{@code DealQuotationService} — neither ever writes {@code - * deposit_percent} today (see the repository method's own Javadoc: the legacy engine's {@code - * CreateCustomerQuotationRequest} has no such field at all, and only {@code DealQuotationService}'s - * v2 engine writes it in production). A real {@code ticket_id} and, for the legacy-branch cases, a + * CustomerQuotationService}/{@code DealQuotationService}, so one test can set any status, origin, + * insertion order and stored {@code deposit_percent} (the legacy engine never writes that column: + * {@code CreateCustomerQuotationRequest} has no such field; only {@code DealQuotationService} + * writes it). A real {@code ticket_id} and, for the legacy-branch cases, a * real {@code pricing_request_id} are still required — both are real FK targets on {@code * sales.quotation} — so this still builds a genuine ticket (+ a draft pricing request for the * legacy cases only; the v2 branch's own FK, {@code origin}, needs no pricing request at all). @@ -82,93 +83,191 @@ void setUp() { } // ── Legacy chain (origin IS NULL, pricing_request_id IS NOT NULL): ACCEPTED > ISSUED ────── + // The legacy PDF prints 50% whatever the row stores (ticket.QuotationRenderer), so every live + // legacy row bills 0.50; the fixtures store other percents to prove the column is never read. @Test - void legacyChain_prefersAcceptedQuotationsDepositPercentOverAnIssuedOne() { + void legacyChain_anAcceptedQuotationBeatsANewerIssuedOne_andBillsItsPdfsFifty() { long ticketId = newTicketWithPricingRequestDraft(); + long accepted = insertLegacyQuotation(ticketId, "ACCEPTED", (short) 30); insertLegacyQuotation(ticketId, "ISSUED", (short) 20); - insertLegacyQuotation(ticketId, "ACCEPTED", (short) 30); - Optional result = depositNotices.findDealQuotationDepositPercent(ticketId); - - assertThat(result).isPresent(); - assertThat(result.get()).isEqualByComparingTo("0.30"); + assertGoverning(ticketId, accepted, true, "0.50"); } @Test - void legacyChain_fallsBackToIssuedWhenNoneAccepted() { + void legacyChain_fallsBackToIssuedWhenNoneAccepted_andBillsItsPdfsFifty() { long ticketId = newTicketWithPricingRequestDraft(); - insertLegacyQuotation(ticketId, "ISSUED", (short) 20); - - Optional result = depositNotices.findDealQuotationDepositPercent(ticketId); + long issued = insertLegacyQuotation(ticketId, "ISSUED", (short) 20); - assertThat(result).isPresent(); - assertThat(result.get()).isEqualByComparingTo("0.20"); + assertGoverning(ticketId, issued, true, "0.50"); } + /** A ticket-level legacy quotation ({@code pricing_request_id IS NULL}) never governs, even + * ACCEPTED and newest: the scope is {@code CustomerQuotationRepository#findByTicket}'s. */ @Test - void legacyChain_ignoresNonLiveStatusesLikeDraftOrRejected() { + void legacyChain_ignoresNonLiveStatusesAndTicketLevelQuotations() { long ticketId = newTicketWithPricingRequestDraft(); insertLegacyQuotation(ticketId, "DRAFT", (short) 40); insertLegacyQuotation(ticketId, "REJECTED", (short) 50); + int version = ++quotationVersionSeq; + jdbc.update(""" + INSERT INTO sales.quotation (ticket_id, number, issued_by, doc_status, quotation_version) + VALUES (:ticketId, :number, :issuedBy, 'ACCEPTED', :version) + """, new MapSqlParameterSource().addValue("ticketId", ticketId) + .addValue("number", "QTD" + ticketId + "-" + version).addValue("issuedBy", employeeId) + .addValue("version", version)); + + assertThat(depositNotices.findGoverningQuotation(ticketId)).isEmpty(); + } + + @Test + void legacyChain_nullDepositPercentOnTheLiveQuotationYieldsTheFiftyItsPdfPrints() { + long ticketId = newTicketWithPricingRequestDraft(); + long accepted = insertLegacyQuotation(ticketId, "ACCEPTED", null); - Optional result = depositNotices.findDealQuotationDepositPercent(ticketId); + assertGoverning(ticketId, accepted, true, "0.50"); + } + + // ── Mixed ticket: the latest quotation the customer ANSWERED governs, whichever engine ───── + // (owner ruling 2026-10-23). Answered = ACCEPTED, or a DEAL_DIRECT quotation's terminal + // APPROVED. An unanswered ISSUED row governs only when nothing was answered. + + /** The legacy row wins because the customer answered it, not because it is legacy: the + * sibling v2 row is newer. */ + @Test + void mixedTicket_anAnsweredLegacyQuotationBeatsANewerUnansweredV2One() { + long ticketId = newTicketWithPricingRequestDraft(); + long legacyAccepted = insertLegacyQuotation(ticketId, "ACCEPTED", null); + insertOriginTaggedQuotation(ticketId, "PRICING_REQUEST", "ISSUED", null); - assertThat(result).isEmpty(); + assertGoverning(ticketId, legacyAccepted, true, "0.50"); } + /** Newest wins whichever engine it is: both insertion orders, so neither engine is preferred. */ @Test - void legacyChain_nullDepositPercentOnTheLiveQuotationYieldsEmpty() { + void mixedTicket_withNothingAnswered_theNewestIssuedQuotationGoverns() { + long v2Newer = newTicketWithPricingRequestDraft(); + insertLegacyQuotation(v2Newer, "ISSUED", null); + long v2Issued = insertOriginTaggedQuotation(v2Newer, "PRICING_REQUEST", "ISSUED", (short) 40); + assertGoverning(v2Newer, v2Issued, false, "0.40"); + + long legacyNewer = newTicketWithPricingRequestDraft(); + insertOriginTaggedQuotation(legacyNewer, "PRICING_REQUEST", "ISSUED", (short) 40); + long legacyIssued = insertLegacyQuotation(legacyNewer, "ISSUED", null); + assertGoverning(legacyNewer, legacyIssued, true, "0.50"); + } + + /** The DEAL_DIRECT row is the OLDER one, so only its answered rank (not quotation_id) can pick it. */ + @Test + void mixedTicket_aDealDirectApprovedQuotationIsAnAnswer_andBeatsANewerLegacyIssuedOne() { long ticketId = newTicketWithPricingRequestDraft(); - insertLegacyQuotation(ticketId, "ACCEPTED", null); + long dealDirect = insertOriginTaggedQuotation(ticketId, "DEAL_DIRECT", "APPROVED", (short) 40); + insertLegacyQuotation(ticketId, "ISSUED", null); + + assertGoverning(ticketId, dealDirect, false, "0.40"); + } - Optional result = depositNotices.findDealQuotationDepositPercent(ticketId); + /** A DEAL_DIRECT approval is an answer level with ACCEPTED (owner ruling 2026-10-23), so the + * newer of the two governs. Only data from before the 2026-09-30 direct-quotation lock can + * hold both on one ticket. */ + @Test + void mixedTicket_aDealDirectApprovedQuotationBeatsAnOlderAcceptedOne() { + long ticketId = newTicketWithPricingRequestDraft(); + insertLegacyQuotation(ticketId, "ACCEPTED", null); + long dealDirect = insertOriginTaggedQuotation(ticketId, "DEAL_DIRECT", "APPROVED", (short) 40); - assertThat(result).isEmpty(); + assertGoverning(ticketId, dealDirect, false, "0.40"); } // ── V2 deal-quotation chain (origin IN ('PRICING_REQUEST','DEAL_DIRECT')) ───────────────── + /** The new-engine PDF prints a null percent as 30% ({@code DealQuotationRenderAdapter}). */ @Test - void dealDirectOrigin_readsApprovedQuotationsDepositPercent() { + void pricingRequestOrigin_nullDepositPercentOnTheLiveQuotationYieldsTheThirtyItsPdfPrints() { long ticketId = newBareTicket(); - insertOriginTaggedQuotation(ticketId, "DEAL_DIRECT", "APPROVED", (short) 50); - - Optional result = depositNotices.findDealQuotationDepositPercent(ticketId); + long accepted = insertOriginTaggedQuotation(ticketId, "PRICING_REQUEST", "ACCEPTED", null); - assertThat(result).isPresent(); - assertThat(result.get()).isEqualByComparingTo("0.50"); + assertGoverning(ticketId, accepted, false, "0.30"); } @Test - void pricingRequestOrigin_prefersAcceptedOverIssuedOverApproved() { + void pricingRequestOrigin_anAcceptedQuotationBeatsANewerIssuedOne() { long ticketId = newBareTicket(); - insertOriginTaggedQuotation(ticketId, "PRICING_REQUEST", "APPROVED", (short) 10); + long accepted = insertOriginTaggedQuotation(ticketId, "PRICING_REQUEST", "ACCEPTED", (short) 30); insertOriginTaggedQuotation(ticketId, "PRICING_REQUEST", "ISSUED", (short) 20); - insertOriginTaggedQuotation(ticketId, "PRICING_REQUEST", "ACCEPTED", (short) 30); - - Optional result = depositNotices.findDealQuotationDepositPercent(ticketId); - assertThat(result).isPresent(); - assertThat(result.get()).isEqualByComparingTo("0.30"); + assertGoverning(ticketId, accepted, false, "0.30"); } + /** APPROVED is live on DEAL_DIRECT only: a PRICING_REQUEST approval lands on ISSUED + * ({@code DealQuotationRepository#approve}), so no writer produces the PRICING_REQUEST row here. */ @Test - void dealDirectOrigin_ignoresNonLiveStatusesLikeDraftOrCancelled() { + void v2Origins_ignoreNonLiveStatuses_includingAPricingRequestApproved() { long ticketId = newBareTicket(); insertOriginTaggedQuotation(ticketId, "DEAL_DIRECT", "DRAFT", (short) 60); insertOriginTaggedQuotation(ticketId, "DEAL_DIRECT", "CANCELLED", (short) 70); + insertOriginTaggedQuotation(ticketId, "PRICING_REQUEST", "APPROVED", (short) 10); + + assertThat(depositNotices.findGoverningQuotation(ticketId)).isEmpty(); + } - Optional result = depositNotices.findDealQuotationDepositPercent(ticketId); + private static final List ALL_DOC_STATUSES = List.of("DRAFT", "READY_TO_ISSUE", "PENDING_APPROVAL", + "APPROVED", "ISSUED", "SENT", "SUPERSEDED", "CANCELLED", "EXPIRED", "ACCEPTED", "REJECTED", + "REVISION_REQUESTED"); + + /** Every (origin, doc_status) pair in {@code chk_quotation_doc_status} the SQL must exclude: + * live is ACCEPTED/ISSUED for legacy and PRICING_REQUEST, plus APPROVED for DEAL_DIRECT. */ + static Stream nonLivePairs() { + return Stream.of("LEGACY", "PRICING_REQUEST", "DEAL_DIRECT").flatMap(origin -> + ALL_DOC_STATUSES.stream() + .filter(st -> !(st.equals("ACCEPTED") || st.equals("ISSUED") + || (origin.equals("DEAL_DIRECT") && st.equals("APPROVED")))) + .map(st -> Arguments.of(origin, st))); + } + + /** A live ISSUED row plus a NEWER excluded one: an admitted excluded status would win on id + * (or, for APPROVED/ACCEPTED, on rank), so the ISSUED row must be the pick. */ + @ParameterizedTest(name = "{0} {1} is not live") + @MethodSource("nonLivePairs") + void aNewerNonLiveStatusNeverBeatsALiveIssuedQuotation(String origin, String excludedStatus) { + boolean legacy = origin.equals("LEGACY"); + long ticketId = legacy ? newTicketWithPricingRequestDraft() : newBareTicket(); + long live = legacy ? insertLegacyQuotation(ticketId, "ISSUED", null) + : insertOriginTaggedQuotation(ticketId, origin, "ISSUED", (short) 40); + if (legacy) { + insertLegacyQuotation(ticketId, excludedStatus, (short) 10); + } else { + insertOriginTaggedQuotation(ticketId, origin, excludedStatus, (short) 10); + } + + assertGoverning(ticketId, live, legacy, legacy ? "0.50" : "0.40"); + } + + /** Mirror of the DEAL_DIRECT-APPROVED-vs-older-ACCEPTED test: the OLDER APPROVED row loses to + * a NEWER ACCEPTED one, so APPROVED ranks level with ACCEPTED, not above it. */ + @Test + void mixedTicket_aNewerAcceptedQuotationBeatsAnOlderDealDirectApprovedOne() { + long ticketId = newTicketWithPricingRequestDraft(); + insertOriginTaggedQuotation(ticketId, "DEAL_DIRECT", "APPROVED", (short) 40); + long accepted = insertLegacyQuotation(ticketId, "ACCEPTED", null); - assertThat(result).isEmpty(); + assertGoverning(ticketId, accepted, true, "0.50"); } @Test void noQuotationAtAllYieldsEmpty() { long ticketId = newBareTicket(); - assertThat(depositNotices.findDealQuotationDepositPercent(ticketId)).isEmpty(); + assertThat(depositNotices.findGoverningQuotation(ticketId)).isEmpty(); + } + + private void assertGoverning(long ticketId, long quotationId, boolean legacy, String depositPercent) { + DepositNoticeRepository.GoverningQuotation governing = + depositNotices.findGoverningQuotation(ticketId).orElseThrow(); + assertThat(governing.quotationId()).isEqualTo(quotationId); + assertThat(governing.legacy()).isEqualTo(legacy); + assertThat(governing.depositPercent()).isEqualByComparingTo(depositPercent); } // ───────────────────────────────────────────────────────────────────────────────────── @@ -213,16 +312,17 @@ null, new BigDecimal("10"), new BigDecimal("0.36"), WastageCalculator.QUANTITY_M /** Legacy quotation row: {@code origin IS NULL}, {@code pricing_request_id IS NOT NULL} — * exactly the scope {@code CustomerQuotationRepository#findByTicket} uses. */ - private void insertLegacyQuotation(long ticketId, String docStatus, Short depositPercent) { + private long insertLegacyQuotation(long ticketId, String docStatus, Short depositPercent) { long pricingRequestId = pricingRequests.findByTicket(ticketId).get(0).id(); int version = ++quotationVersionSeq; - jdbc.update(""" + return jdbc.queryForObject(""" INSERT INTO sales.quotation (ticket_id, number, issued_by, doc_status, quotation_version, pricing_request_id, origin, deposit_percent) VALUES (:ticketId, :number, :issuedBy, :docStatus, :version, :pricingRequestId, NULL, :depositPercent) + RETURNING quotation_id """, new MapSqlParameterSource() .addValue("ticketId", ticketId) @@ -234,18 +334,19 @@ private void insertLegacyQuotation(long ticketId, String docStatus, Short deposi .addValue("docStatus", docStatus) .addValue("version", version) .addValue("pricingRequestId", pricingRequestId) - .addValue("depositPercent", depositPercent)); + .addValue("depositPercent", depositPercent), Long.class); } /** V2 deal-quotation row: {@code origin IN ('PRICING_REQUEST','DEAL_DIRECT')} — no {@code * pricing_request_id} needed (nullable; the v2 engine's own scope is {@code origin} alone). */ - private void insertOriginTaggedQuotation(long ticketId, String origin, String docStatus, Short depositPercent) { + private long insertOriginTaggedQuotation(long ticketId, String origin, String docStatus, Short depositPercent) { int version = ++quotationVersionSeq; - jdbc.update(""" + return jdbc.queryForObject(""" INSERT INTO sales.quotation (ticket_id, number, issued_by, doc_status, quotation_version, origin, deposit_percent) VALUES (:ticketId, :number, :issuedBy, :docStatus, :version, :origin, :depositPercent) + RETURNING quotation_id """, new MapSqlParameterSource() .addValue("ticketId", ticketId) @@ -254,7 +355,7 @@ private void insertOriginTaggedQuotation(long ticketId, String origin, String do .addValue("docStatus", docStatus) .addValue("version", version) .addValue("origin", origin) - .addValue("depositPercent", depositPercent)); + .addValue("depositPercent", depositPercent), Long.class); } private TicketItemRequest ticketItem() { diff --git a/backend/src/test/java/th/co/glr/hr/deposit/DepositNoticeServiceTest.java b/backend/src/test/java/th/co/glr/hr/deposit/DepositNoticeServiceTest.java index 05e2b444d..5358e59c0 100644 --- a/backend/src/test/java/th/co/glr/hr/deposit/DepositNoticeServiceTest.java +++ b/backend/src/test/java/th/co/glr/hr/deposit/DepositNoticeServiceTest.java @@ -9,12 +9,14 @@ import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.math.BigDecimal; import java.time.Instant; import java.time.LocalDate; +import java.time.OffsetDateTime; import java.util.List; import java.util.Optional; import org.junit.jupiter.api.Test; @@ -61,8 +63,8 @@ class DepositNoticeServiceTest { private final UserPrincipal owner = new UserPrincipal( 1L, "sales@glr.co.th", "Sales", "sales", 1L, true, LocalDate.of(2026, 1, 1), false, 1L, false); - // sales_manager: read-only oversight of deposit notices — never owns a ticket - // (no create access), so every owner-gated write is denied by ownership alone. + // sales_manager: read-only oversight of deposit notices. It can create (and so own) a deal + // since deal entry was widened, so every deposit-notice write is role-gated to sales. private final UserPrincipal salesManagerActor = new UserPrincipal( 8L, "sales_manager@glr.co.th", "Sales Manager", "sales_manager", 8L, true, LocalDate.of(2026, 1, 1), false, null, false); @@ -73,7 +75,7 @@ class DepositNoticeServiceTest { void issue_advancesPaymentTrackAndKeepsTicketStatus() { stubDraft(99L, 10L); stubTicket(10L, TicketStatus.QUOTATION_ISSUED, "CUSTOMER_CONFIRMED"); - when(docs.issue(99L, 1L, "Sales")).thenReturn(Optional.of("GLRD69001")); + when(docs.issue(eq(99L), eq(1L), eq("Sales"), any())).thenReturn(Optional.of("GLRD69001")); service.issue(99L, owner); @@ -88,6 +90,24 @@ void issue_advancesPaymentTrackAndKeepsTicketStatus() { eq(TicketEventKind.DOCUMENT_ISSUED), anyString(), anyString(), anyString()); } + // One notice per deal: the "another notice is ISSUED" guard is check-then-act, so the ticket + // row is locked first — two drafts issued at once serialize and the second sees the first. + // (A real two-thread race is not reproducible in this suite; this pins the order.) + @Test + void issue_locksTheTicketRowBeforeReadingTheOneIssuedGuard_andPassesTheStampOn() { + stubDraft(99L, 10L); + stubTicket(10L, TicketStatus.QUOTATION_ISSUED, "CUSTOMER_CONFIRMED"); + OffsetDateTime stamp = OffsetDateTime.parse("2026-10-23T08:57:19.732123Z"); + when(docs.issue(eq(99L), eq(1L), eq("Sales"), eq(stamp))).thenReturn(Optional.of("GLRD69001")); + + service.issue(99L, stamp, owner); + + var order = org.mockito.Mockito.inOrder(ticketRepo, docs); + order.verify(ticketRepo).lockTicketForUpdate(10L); + order.verify(docs).findByTicket(10L); + order.verify(docs).issue(99L, 1L, "Sales", stamp); + } + @Test void issue_requiresQuotationIssuedStatus() { stubDraft(99L, 10L); @@ -120,7 +140,7 @@ void issue_requiresCustomerConfirmedOrDepositNoticeIssuedPayment() { void issue_fromDepositNoticeIssued_isNowAllowedAsARevision() { stubDraft(99L, 10L); stubTicket(10L, TicketStatus.QUOTATION_ISSUED, "DEPOSIT_NOTICE_ISSUED"); - when(docs.issue(99L, 1L, "Sales")).thenReturn(Optional.of("GLRD69002")); + when(docs.issue(eq(99L), eq(1L), eq("Sales"), any())).thenReturn(Optional.of("GLRD69002")); service.issue(99L, owner); // must not throw @@ -260,8 +280,8 @@ void remainingInvoiceXlsx_nullLineSubtotalFallsBackToFinalUnitPriceTimesQtyHalfU void remainingInvoiceOptions_fallsBackToIssuedDepositNoticeItemsWhenNoAcceptedQuotation() { stubTicket(10L, TicketStatus.QUOTATION_ISSUED, "CUSTOMER_CONFIRMED"); // An ISSUED (not ACCEPTED) quotation must be ignored by the remaining-invoice path — - // unlike buildItemsFromRequest's pickQuotation (deposit notice creation), which DOES fall - // back to ISSUED. See resolveRemainingInvoice's own Javadoc for why these differ. + // unlike DepositNoticeRepository#findGoverningQuotation (deposit notice creation), which + // DOES fall back to ISSUED. See resolveRemainingInvoice's own Javadoc for why these differ. // // Finding 1 (vacuous test): this test used to only assert itemCount/depositAmount, which // stayed green even under the mutation "let ISSUED quotations qualify too" — that mutation @@ -808,14 +828,18 @@ void createDraft_rejectsSalesManagerRole() { @Test void update_rejectsSalesManagerRole() { - // update() has NO role gate — only requireTicketOwner. sales_manager can - // never own a ticket, so the ownership check alone denies it. + // Role-gated (SALES_ROLES) like createDraft and issue: a sales_manager that OWNS the deal + // (stubTicket's createdById is 1) passes the owner check, so only the role gate refuses it. + UserPrincipal salesManagerOwner = new UserPrincipal( + 1L, "sales_manager@glr.co.th", "Sales Manager", "sales_manager", 1L, true, + LocalDate.of(2026, 1, 1), false, null, false); stubDraft(99L, 10L); stubTicket(10L, TicketStatus.APPROVED, null); assertForbidden(() -> service.update(99L, new DepositNoticeDraftRequest(null, null, null, null, null, null, null, null), - salesManagerActor)); + salesManagerOwner)); + verify(docs, never()).update(anyLong(), any(), any()); } @Test @@ -842,7 +866,6 @@ void createDraft_autofillsHeaderFromCustomerMasterAndTicketProjectName() { stubTicketWithCustomer(10L, TicketStatus.QUOTATION_ISSUED, "CUSTOMER_CONFIRMED", 42L, "Project Zeta"); when(customerRepo.findById(42L)).thenReturn(Optional.of( new CustomerDto(42L, "ACME", "0100000000000", "123 Road", "Showroom", "02-000-0000"))); - when(quotationRepo.findByTicket(10L)).thenReturn(List.of()); when(docs.createDraft(eq(10L), any(), any())).thenReturn(201L); stubDraft(201L, 10L); @@ -862,7 +885,6 @@ void createDraft_nullCustomerIdLeavesHeaderFieldsBlankWithoutThrowing() { // Base fixture's ticket carries customerId = null — must not NPE, and the customer // repository must never even be consulted. stubTicket(10L, TicketStatus.QUOTATION_ISSUED, "CUSTOMER_CONFIRMED"); - when(quotationRepo.findByTicket(10L)).thenReturn(List.of()); when(docs.createDraft(eq(10L), any(), any())).thenReturn(202L); stubDraft(202L, 10L); @@ -876,12 +898,16 @@ void createDraft_nullCustomerIdLeavesHeaderFieldsBlankWithoutThrowing() { verify(customerRepo, never()).findById(anyLong()); } + /** With no approved_price on any ticket item, the lines come from the governing quotation + * (DepositNoticeRepository#findGoverningQuotation), here a legacy one. */ @Test - void createDraft_fallsBackToAcceptedQuotationWhenNoTicketItemHasApprovedPrice() { + void createDraft_takesTheLinesOfTheGoverningLegacyQuotationWhenNoTicketItemHasApprovedPrice() { stubTicket(10L, TicketStatus.QUOTATION_ISSUED, "CUSTOMER_CONFIRMED"); // items: List.of() — no approved_price CustomerQuotationItemDto item = quotationItem("กระเบื้อง A", new BigDecimal("10"), "PER_SQM", new BigDecimal("100"), BigDecimal.ZERO, new BigDecimal("100")); - when(quotationRepo.findByTicket(10L)).thenReturn(List.of(quotation(1L, 10L, "ACCEPTED", 1, List.of(item)))); + when(docs.findGoverningQuotation(10L)).thenReturn(Optional.of( + new DepositNoticeRepository.GoverningQuotation(1L, true, "QT-2026-1", new BigDecimal("0.50")))); + when(quotationRepo.findById(1L)).thenReturn(Optional.of(quotation(1L, 10L, "ACCEPTED", 1, List.of(item)))); when(docs.createDraft(eq(10L), any(), any())).thenReturn(203L); stubDraft(203L, 10L); @@ -898,66 +924,48 @@ void createDraft_fallsBackToAcceptedQuotationWhenNoTicketItemHasApprovedPrice() assertThat(items.get(0).unitPrice()).isEqualTo(new BigDecimal("100")); } - @Test - void createDraft_prefersAcceptedQuotationOverAnIssuedOne() { - stubTicket(10L, TicketStatus.QUOTATION_ISSUED, "CUSTOMER_CONFIRMED"); - CustomerQuotationItemDto issuedItem = quotationItem("Issued Item", BigDecimal.ONE, "PER_PIECE", - BigDecimal.TEN, BigDecimal.ZERO, BigDecimal.TEN); - CustomerQuotationItemDto acceptedItem = quotationItem("Accepted Item", BigDecimal.ONE, "PER_PIECE", - BigDecimal.TEN, BigDecimal.ZERO, BigDecimal.TEN); - // findByTicket's own contract: ascending by quotation_id ALONE (see its own Javadoc — - // quotation_revision_no is scoped to a single pricing request, not comparable across - // the multiple pricing requests one ticket can have) — the accepted revision here has - // the higher quotation_id, same as a real revise-after-accept flow. - when(quotationRepo.findByTicket(10L)).thenReturn(List.of( - quotation(1L, 10L, "ISSUED", 1, List.of(issuedItem)), - quotation(2L, 10L, "ACCEPTED", 2, List.of(acceptedItem)))); - when(docs.createDraft(eq(10L), any(), any())).thenReturn(204L); - stubDraft(204L, 10L); - - service.createDraft(10L, - new DepositNoticeDraftRequest(null, null, null, null, null, null, null, null), owner); - - @SuppressWarnings("unchecked") - ArgumentCaptor> itemsCaptor = ArgumentCaptor.forClass(List.class); - verify(docs).createDraft(eq(10L), any(), itemsCaptor.capture()); - assertThat(itemsCaptor.getValue()).hasSize(1); - assertThat(itemsCaptor.getValue().get(0).description()).isEqualTo("Accepted Item"); - } - @Test void createDraft_legacyApprovedPriceItemsWinOverQuotationChainWhenBothExist() { TicketItemDto approvedItem = new TicketItemDto(1L, 10L, "SCG", "Model", "White", "Matte", "60x60", "Factory", new BigDecimal("10"), null, null, null, null, null, new BigDecimal("100"), "THB", 0, null, null, null, "PIECE", null, null); stubTicketWithItems(10L, TicketStatus.QUOTATION_ISSUED, "CUSTOMER_CONFIRMED", List.of(approvedItem)); - // Even though an ACCEPTED quotation exists too, the legacy approved_price path must win - // and the quotation chain must never even be queried (buildLegacyItems short-circuits). + // Even though a governing quotation exists too, the legacy approved_price path must win + // and that quotation's lines must never even be read (buildLegacyItems short-circuits). + // Its percent still applies; its number is NOT stamped as the reference, because the + // remaining-invoice match keys on the reference and these lines are not that quotation's. + when(docs.findGoverningQuotation(10L)).thenReturn(Optional.of( + new DepositNoticeRepository.GoverningQuotation(1L, false, "QT-2026-1", new BigDecimal("0.40")))); when(docs.createDraft(eq(10L), any(), any())).thenReturn(205L); stubDraft(205L, 10L); service.createDraft(10L, new DepositNoticeDraftRequest(null, null, null, null, null, null, null, null), owner); + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(DepositNoticeDraftRequest.class); @SuppressWarnings("unchecked") ArgumentCaptor> itemsCaptor = ArgumentCaptor.forClass(List.class); - verify(docs).createDraft(eq(10L), any(), itemsCaptor.capture()); + verify(docs).createDraft(eq(10L), requestCaptor.capture(), itemsCaptor.capture()); + assertThat(requestCaptor.getValue().reference()).isNull(); + assertThat(requestCaptor.getValue().depositPercent()).isEqualByComparingTo("0.40"); assertThat(itemsCaptor.getValue()).hasSize(1); assertThat(itemsCaptor.getValue().get(0).unitPrice()).isEqualTo(new BigDecimal("100")); - verify(quotationRepo, never()).findByTicket(10L); + verify(quotationRepo, never()).findById(anyLong()); + verify(docs, never()).findDealQuotationItems(anyLong()); } - // ── Deposit percent LOCKED to the quotation (owner ruling, 2026-09-29) ──── - // See DepositNoticeService.createDraft/update and DepositNoticeRepository - // #findDealQuotationDepositPercent's own comments for what changed and why: the deposit - // PERCENT is no longer sales-entered at the deposit-notice stage at all — it is read straight - // off the source quotation's deposit_percent and is not editable afterward. + // ── Deposit percent = what the source quotation's PDF printed (owner rulings 2026-09-29, + // 2026-10-23) ──── + // The governing quotation's percent (DepositNoticeRepository#findGoverningQuotation: legacy + // 50, new-engine its own, blank 30); a caller-supplied percent is never read on create, and + // 0.50 applies only when the ticket has no live quotation. update() then lets sales change it + // on a DRAFT; an ISSUED notice refuses any edit (requireDraft, 409). @Test void createDraft_sourcesDepositPercentFromQuotation_ignoringCallerSuppliedValue() { stubTicket(10L, TicketStatus.QUOTATION_ISSUED, "CUSTOMER_CONFIRMED"); - when(quotationRepo.findByTicket(10L)).thenReturn(List.of()); - when(docs.findDealQuotationDepositPercent(10L)).thenReturn(Optional.of(new BigDecimal("0.30"))); + when(docs.findGoverningQuotation(10L)).thenReturn(Optional.of( + new DepositNoticeRepository.GoverningQuotation(5L, false, "QT-5", new BigDecimal("0.30")))); when(docs.createDraft(eq(10L), any(), any())).thenReturn(301L); stubDraft(301L, 10L); @@ -975,8 +983,7 @@ void createDraft_sourcesDepositPercentFromQuotation_ignoringCallerSuppliedValue( @Test void createDraft_fallsBackTo050WhenTicketHasNoQuotationDepositPercent() { stubTicket(10L, TicketStatus.QUOTATION_ISSUED, "CUSTOMER_CONFIRMED"); - when(quotationRepo.findByTicket(10L)).thenReturn(List.of()); - when(docs.findDealQuotationDepositPercent(10L)).thenReturn(Optional.empty()); + when(docs.findGoverningQuotation(10L)).thenReturn(Optional.empty()); when(docs.createDraft(eq(10L), any(), any())).thenReturn(302L); stubDraft(302L, 10L); @@ -989,57 +996,133 @@ void createDraft_fallsBackTo050WhenTicketHasNoQuotationDepositPercent() { } /** - * Proves the final fallback's wiring (2026-09-29 fix): when the legacy ticket-item path AND - * the CustomerQuotation {@code pickQuotation} path both yield nothing (no approved_price on - * any ticket item, and {@code quotationRepo.findByTicket} returns an empty list — no {@code - * origin IS NULL} quotation at all), {@code buildItemsFromRequest} must return EXACTLY what - * {@code docs.findDealQuotationItemsForDeposit(ticketId)} returns, not an empty list (the old - * {@code return List.of();} this branch replaced). The sentinel item below ("V2 deal quotation - * item", unit price 999) is deliberately unlike anything else in this file so a test bug that - * accidentally sourced items from elsewhere would show up as the wrong description/price - * rather than merely "some non-empty list". + * Lines, percent and reference all come from the ONE governing quotation (owner ruling + * 2026-10-23), here a new-engine one: its lines via {@code docs.findDealQuotationItems}, its + * percent, and its number as the reference. The sentinel item ("V2 deal quotation item", unit + * price 999) and the 0.40 are deliberately unlike anything else in this file, so a draft that + * took its lines or percent from elsewhere shows up as the wrong value, not merely "some value". */ @Test - void createDraft_fallsBackToDealQuotationRepositoryWhenLegacyAndPickQuotationBothYieldNothing() { + void createDraft_takesLinesPercentAndReferenceFromTheGoverningNewEngineQuotation() { stubTicket(10L, TicketStatus.QUOTATION_ISSUED, "CUSTOMER_CONFIRMED"); // items: List.of() — no approved_price - when(quotationRepo.findByTicket(10L)).thenReturn(List.of()); // pickQuotation -> null + when(docs.findGoverningQuotation(10L)).thenReturn(Optional.of( + new DepositNoticeRepository.GoverningQuotation(7L, false, "QT-7", new BigDecimal("0.40")))); DepositNoticeItemRequest sentinel = new DepositNoticeItemRequest( 1, "V2 deal quotation item", new BigDecimal("3"), "แผ่น", new BigDecimal("999"), null, new BigDecimal("999")); - when(docs.findDealQuotationItemsForDeposit(10L)).thenReturn(List.of(sentinel)); + when(docs.findDealQuotationItems(7L)).thenReturn(List.of(sentinel)); when(docs.createDraft(eq(10L), any(), any())).thenReturn(206L); stubDraft(206L, 10L); service.createDraft(10L, new DepositNoticeDraftRequest(null, null, null, null, null, null, null, null), owner); + ArgumentCaptor captor = ArgumentCaptor.forClass(DepositNoticeDraftRequest.class); @SuppressWarnings("unchecked") ArgumentCaptor> itemsCaptor = ArgumentCaptor.forClass(List.class); - verify(docs).createDraft(eq(10L), any(), itemsCaptor.capture()); + verify(docs).createDraft(eq(10L), captor.capture(), itemsCaptor.capture()); assertThat(itemsCaptor.getValue()).containsExactly(sentinel); - verify(docs).findDealQuotationItemsForDeposit(10L); + assertThat(captor.getValue().depositPercent()).isEqualByComparingTo("0.40"); + assertThat(captor.getValue().reference()).isEqualTo("QT-7"); + verify(quotationRepo, never()).findById(anyLong()); } @Test - void update_preservesTheDraftsOwnStoredDepositPercent_ignoringCallerSuppliedValue() { + void update_onADraft_persistsTheCallersDepositPercent() { // stubDraft's fixture stores depositPercent = 0.50 on doc 99 / ticket 10. stubDraft(99L, 10L); stubTicket(10L, TicketStatus.APPROVED, null); - // A caller sends 0.99 — must never reach the repository update. service.update(99L, new DepositNoticeDraftRequest("ACME", null, null, null, "REF-2", - new BigDecimal("0.99"), null, null), + new BigDecimal("0.40"), null, null), owner); ArgumentCaptor captor = ArgumentCaptor.forClass(DepositNoticeDraftRequest.class); - verify(docs).update(eq(99L), captor.capture()); - assertThat(captor.getValue().depositPercent()).isEqualByComparingTo("0.50"); - // Every other field still passes through untouched — only depositPercent is overridden. + verify(docs).update(eq(99L), captor.capture(), any()); + assertThat(captor.getValue().depositPercent()).isEqualByComparingTo("0.40"); assertThat(captor.getValue().customerName()).isEqualTo("ACME"); assertThat(captor.getValue().reference()).isEqualTo("REF-2"); } + @Test + void update_withNoDepositPercent_keepsTheDraftsStoredPercent() { + stubDraft(99L, 10L); // stored 0.50 + stubTicket(10L, TicketStatus.APPROVED, null); + + service.update(99L, + new DepositNoticeDraftRequest("ACME", null, null, null, "REF-2", null, null, null), + owner); + + ArgumentCaptor captor = ArgumentCaptor.forClass(DepositNoticeDraftRequest.class); + verify(docs).update(eq(99L), captor.capture(), any()); + assertThat(captor.getValue().depositPercent()).isEqualByComparingTo("0.50"); + } + + @Test + void update_refusesZeroAndANewFractionalPercent_butAStoredFractionSentBackUnchangedSaves() { + // Stored 0.3330: free input from before the whole-percent rule. The page shows it as 33. + stubDraft(99L, 10L, new BigDecimal("0.3330")); + stubTicket(10L, TicketStatus.APPROVED, null); + + assertThatThrownBy(() -> service.update(99L, + new DepositNoticeDraftRequest("ACME", null, null, null, "REF-2", BigDecimal.ZERO, null, null), + owner)) + .isInstanceOfSatisfying(ApiException.class, e -> { + assertThat(e.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(e.getMessage()).isEqualTo("เปอร์เซ็นต์มัดจำต้องมากกว่า 0"); + }); + // 40.5% would print as "41%" on the document while billing 40.5%. + assertThatThrownBy(() -> service.update(99L, + new DepositNoticeDraftRequest("ACME", null, null, null, "REF-2", new BigDecimal("0.405"), null, null), + owner)) + .isInstanceOfSatisfying(ApiException.class, e -> { + assertThat(e.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(e.getMessage()).isEqualTo("เปอร์เซ็นต์มัดจำต้องเป็นจำนวนเต็ม"); + }); + verify(docs, never()).update(anyLong(), any(), any()); + + // The stored value sent back unchanged (0.333 == 0.3330) still saves the rest of the draft; + // a whole percent replaces it. + service.update(99L, + new DepositNoticeDraftRequest("ACME", null, null, null, "REF-2", new BigDecimal("0.333"), null, null), + owner); + service.update(99L, + new DepositNoticeDraftRequest("ACME", null, null, null, "REF-2", new BigDecimal("0.40"), null, null), + owner); + ArgumentCaptor captor = ArgumentCaptor.forClass(DepositNoticeDraftRequest.class); + verify(docs, times(2)).update(eq(99L), captor.capture(), any()); + assertThat(captor.getAllValues()) + .extracting(r -> r.depositPercent().stripTrailingZeros().toPlainString()) + .containsExactly("0.333", "0.4"); + } + + // DepositNoticeRepository#foldAdjustments (owner rulings 2026-10-23, "2 decimals for all"): the + // ส่วนลดพิเศษ amount is spread by line amount, each share HALF_UP to 2 dp, each new net price + // 2 dp. There is no remainder trick: the notice total is the sum of its own 2 dp lines (here + // 200.01, a cent over the quotation's 200.00). A line keeps the quotation's own discount wording + // and gains the folded discount after it; a line the fold did not touch keeps its label as is. + @Test + void foldAdjustments_keepsEachLinesOwnLabel_appendsTheFoldedDiscount_andTotalsFollowTheTwoDpLines() { + List folded = DepositNoticeRepository.foldAdjustments(List.of( + foldLine(1, "1", "100.00", "ส่วนลด 5 ต่อหน่วย"), foldLine(2, "1", "100.00", null), + foldLine(3, "1", "100.00", null), foldLine(4, "2", "0.00", "ส่วนลด 9 ต่อหน่วย")), + new BigDecimal("-100.00")); + + assertThat(folded).extracting(it -> it.netUnitPrice().toPlainString()) + .containsExactly("66.67", "66.67", "66.67", "0.00"); + assertThat(folded.stream().map(it -> it.netUnitPrice().multiply(it.qty())).reduce(BigDecimal.ZERO, BigDecimal::add)) + .isEqualByComparingTo("200.01"); + assertThat(folded).extracting(DepositNoticeItemRequest::discountLabel).containsExactly( + "ส่วนลด 5 ต่อหน่วย + ส่วนลดพิเศษ 33.33 ต่อหน่วย", "ส่วนลดพิเศษ 33.33 ต่อหน่วย", + "ส่วนลดพิเศษ 33.33 ต่อหน่วย", "ส่วนลด 9 ต่อหน่วย"); + } + + private static DepositNoticeItemRequest foldLine(int seq, String qty, String net, String label) { + return new DepositNoticeItemRequest(seq, "Line " + seq, new BigDecimal(qty), "แผ่น", + new BigDecimal(net), label, new BigDecimal(net)); + } + private static void assertForbidden(org.assertj.core.api.ThrowableAssert.ThrowingCallable action) { assertThatThrownBy(action) .isInstanceOfSatisfying(ApiException.class, e -> @@ -1049,10 +1132,14 @@ private static void assertForbidden(org.assertj.core.api.ThrowableAssert.Throwin // ── fixtures ───────────────────────────────────────────────────────────── private void stubDraft(long docId, long ticketId) { + stubDraft(docId, ticketId, new BigDecimal("0.50")); + } + + private void stubDraft(long docId, long ticketId, BigDecimal depositPercent) { DepositNoticeDto draft = new DepositNoticeDto( docId, ticketId, "DEPOSIT_NOTICE", 1, null, null, "DRAFT", "ACME", "0100000000000", "Bangkok", "Showroom", "REF-1", "THB", - new BigDecimal("0.50"), new BigDecimal("1000.00"), new BigDecimal("500.00"), + depositPercent, new BigDecimal("1000.00"), new BigDecimal("500.00"), new BigDecimal("0.07"), new BigDecimal("35.00"), new BigDecimal("535.00"), List.of(), false, false, "Sales", "Preparer", null, null, List.of()); when(docs.findById(docId)).thenReturn(Optional.of(draft)); @@ -1133,9 +1220,9 @@ private void stubTicketWithItems(long ticketId, String status, String paymentSta .thenReturn(Optional.of(new TicketDto(summary, items, List.of(), null, List.of()))); } - // Minimal CustomerQuotationDto fixture — only the fields buildItemsFromRequest's pickQuotation - // and itemsFromQuotation actually read (ticketId, docStatus, quotationRevisionNo, items) carry - // meaningful values; everything else is a harmless placeholder. + // Minimal CustomerQuotationDto fixture — only the fields the service actually reads (ticketId, + // docStatus, quotationRevisionNo, items) carry meaningful values; everything else is a + // harmless placeholder. private CustomerQuotationDto quotation(long id, long ticketId, String docStatus, int revisionNo, List items) { return new CustomerQuotationDto( 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 2c7338774..8abc6a411 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 @@ -811,11 +811,11 @@ null, new BigDecimal("10"), new BigDecimal("0.36"), WastageCalculator.QUANTITY_M /** A SECOND, wholly separate pricing-request chain on an ALREADY-EXISTING ticket — its own * catalog product, pricing request, factory quote, pricing decision and CustomerQuotation, - * driven to ACCEPTED + its own issued deposit notice, exactly like {@link #buildAcceptedDeal} + * 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 DealFixture buildSecondAcceptedQuotationChain(long ticketId, UserPrincipal owner, String label) { + private void buildSecondAcceptedQuotationChain(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"); @@ -866,11 +866,14 @@ null, new BigDecimal("10"), new BigDecimal("0.36"), WastageCalculator.QUANTITY_M orderConfirmation.confirmOrder(pricingRequestId, new ConfirmOrderRequest(UUID.randomUUID().toString()), owner); - DepositNoticeDto depositDraft = orderConfirmation.createDepositNoticeFromQuotation(pricingRequestId, - new CreateDepositNoticeFromQuotationRequest(null), owner); - DepositNoticeDto depositIssued = depositNoticeService.issue(depositDraft.id(), owner); + // Chain B has no deposit notice of its own, so it qualifies for a remaining invoice only through + // the bypass branch: flip the deal to NOT_REQUIRED (chain A's drafts/invoices were made earlier). + jdbc.update("UPDATE sales.ticket SET deposit_policy = 'NOT_REQUIRED' WHERE ticket_id = :id", + Map.of("id", ticketId)); + // No deposit notice for chain B: a deal has at most one ISSUED deposit notice (owner ruling + // 2026-10-28), so chain A's stays the deal's only one; the remaining-invoice rules under test + // key off the accepted quotations, not a second notice. - return new DealFixture(ticketId, pricingRequestId, depositIssued.id()); } private void driveDraftPricingRequestToQuotationAccepted(long pricingRequestId, BigDecimal quantity, UserPrincipal owner) { diff --git a/backend/src/test/java/th/co/glr/hr/pricingcosting/LandedCostCalculatorFormulaIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/pricingcosting/LandedCostCalculatorFormulaIntegrationTest.java index ac4767f2b..a802fce27 100644 --- a/backend/src/test/java/th/co/glr/hr/pricingcosting/LandedCostCalculatorFormulaIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/pricingcosting/LandedCostCalculatorFormulaIntegrationTest.java @@ -46,6 +46,7 @@ import th.co.glr.hr.pricingdecision.PricingDecisionDtos.PricingDecisionItemDto; import th.co.glr.hr.pricingdecision.PricingDecisionRepository; import th.co.glr.hr.pricingdecision.PricingDecisionRequests.ApprovePricingDecisionRequest; +import th.co.glr.hr.pricingdecision.PricingDecisionRequests.CostOverrideRequest; import th.co.glr.hr.pricingdecision.PricingDecisionRequests.ProductTypeOverrideRequest; import th.co.glr.hr.pricingdecision.PricingDecisionRequests.StartPricingDecisionRequest; import th.co.glr.hr.pricingdecision.PricingDecisionService; @@ -387,6 +388,151 @@ void missingThickness_reachesTheCeoScreen_butApproveRefusesUntilACostIsSupplied( }); } + @Test + void salesEnteredThickness_makesACatalogNullThicknessLineCostable() { + // Owner ruling 2026-10-15: a thickness Sales typed on the line is honoured by costing when + // the catalogue carries none — so a product the CEO used to see as "no thickness" (null + // catalogue thickness) is now costable from the Sales value. Pins resolveThicknessMm's + // catalog-then-request fallback. + long nullThicknessProductId = insertCatalogProduct("Sales Thickness Factory", "IT", "SALES-TH-001", + new BigDecimal("100.00"), "THB", "per_piece", "ACTIVE", null); + long pricingRequestId = readyForReviewWithProduct(nullThicknessProductId, "Sales Thickness Factory", + new BigDecimal("10"), UnitBasis.PER_PIECE, UnitBasis.PER_PIECE, new BigDecimal("10"), "100.00", + new BigDecimal("1"), null, null); + // The line carries a Sales-entered thickness (9mm, inside V109's Italy [8,12) band) while + // the catalogue row stays null — the in-flight/legacy shape the read-time fallback exists + // for (submit's write-back fills the catalogue FROM the item, so set it directly here to + // isolate the READ fallback rather than the write-back). + jdbc.update("UPDATE sales.pricing_request_item SET thickness_mm = 9 WHERE pricing_request_id = :id", + Map.of("id", pricingRequestId)); + BigDecimal catalogThickness = jdbc.queryForObject( + "SELECT thickness_mm FROM price_catalog.product_prices WHERE price_id = :id", + Map.of("id", nullThicknessProductId), BigDecimal.class); + assertThat(catalogThickness).as("precondition: the catalogue row genuinely has no thickness").isNull(); + + PricingDecisionDto decision = decisionService.startReview(pricingRequestId, + new StartPricingDecisionRequest(new BigDecimal("0.20"), "THB", null, UUID.randomUUID().toString()), ceoActor); + + PricingCostingItemDto item = costingItemFor(decision); + assertThat(item.uncostableReason()) + .as("the Sales thickness feeds the freight lookup, so the line is NOT set aside as uncostable") + .isNull(); + assertThat(item.totalLandedCostThb()).isNotNull(); + assertThat(decision.items().get(0).frozenLandedCostPerRequestedUnitThb()).isNotNull(); + } + + @Test + void missingSqm_perPieceQuote_setAsideAsUncostable_withoutAbortingTheOtherLines() { + // THE P0 LEGACY CASE (QA 2026-10): import quoted a price per แผ่น (PER_PIECE) with a BLANK + // sqm_per_unit, on a request item that also carries no requested_qty_sqm — a pre-V185 row + // (V185 now derives requested_qty_sqm on every NEW request, so this shape is only reachable + // for legacy data). V109's freight lookup needs sqm-per-piece for EVERY item (Q is in sqm), + // so this line cannot be costed. + // + // Before this fix resolveSqmPerPiece threw 422, which aggregateProblems turned into ONE 422 + // that aborted startReview for the WHOLE request — poisoning the costable line beside it and + // blocking the CEO from the screen that owns the manual-cost override. It now behaves exactly + // like a missing thickness (V156): the line is set ASIDE as uncostable, the rest of the + // request still costs, and approve() is the gate that refuses it. + long pricingRequestId = mixedCostableAndNoSqmSameFactory(); + + // (a) startReview SUCCEEDS — no aggregated 422. RED before the fix (resolveSqmPerPiece throws, + // aggregateProblems aborts the whole calculation). + PricingDecisionDto decision = decisionService.startReview(pricingRequestId, + new StartPricingDecisionRequest(new BigDecimal("0.20"), "THB", null, UUID.randomUUID().toString()), ceoActor); + assertThat(decision.items()).hasSize(2); + + List costingItems = + costingRepository.find(decision.pricingCostingId()).orElseThrow().items(); + assertThat(costingItems).hasSize(2); + + PricingCostingItemDto noSqm = costingItems.stream() + .filter(i -> i.uncostableReason() != null).findFirst() + .orElseThrow(() -> new AssertionError("expected one line set aside as uncostable")); + PricingCostingItemDto costable = costingItems.stream() + .filter(i -> i.uncostableReason() == null).findFirst() + .orElseThrow(() -> new AssertionError("expected one line to cost cleanly")); + + // (b) the uncostable line: freight-dependent costs NULL, reason names the missing sqm — but + // goods cost IS still recorded (computable for a per-piece quote without sqm; this is why no + // schema change was needed — goods_cost_thb/insurance_cost_thb stay NOT NULL per V156). + assertThat(noSqm.sqmPerUnit()).isNull(); + assertThat(noSqm.totalLandedCostThb()).isNull(); + assertThat(noSqm.landedCostPerUnitThb()).isNull(); + assertThat(noSqm.goodsCostThb()).isNotNull(); + assertThat(noSqm.uncostableReason()) + .as("the row states that it is the sqm-per-piece conversion that is missing, naming the item") + .contains("ตร.ม.") + .containsPattern("รายการที่ \\d+"); + + // (c) the line BESIDE it still costs cleanly — one unresolvable line no longer aborts the + // whole request. + assertThat(costable.totalLandedCostThb()).isNotNull(); + + // (d) the decision snapshot for the uncostable line carries NO frozen cost... + PricingDecisionItemDto noSqmDecisionItem = decision.items().stream() + .filter(i -> i.frozenLandedCostPerRequestedUnitThb() == null).findFirst() + .orElseThrow(() -> new AssertionError("the uncostable line's decision item should have no frozen cost")); + assertThat(noSqmDecisionItem.frozenLandedCostPerRequestedUnitThb()).isNull(); + + // (e) ...and the guarantee only MOVED to approve(): it refuses while the line is still + // uncostable and un-overridden (wrong-way-round, so it goes red the moment the sqm path ever + // starts silently pricing freight at zero). + assertThatThrownBy(() -> decisionService.approve(decision.id(), + new ApprovePricingDecisionRequest(null, UUID.randomUUID().toString()), ceoActor)) + .isInstanceOfSatisfying(ApiException.class, e -> { + assertThat(e.getStatus()).isEqualTo(HttpStatus.UNPROCESSABLE_CONTENT); + assertThat(e.getMessage()).contains("ต้นทุน"); + }); + } + + @Test + void missingSqm_perSqmRequestedBasis_setAsideAsUncostable_notA422ForTheWholeShipment() { + // A PER_SQM requested basis needs sqm-per-piece to convert the quantity (the receive step + // already rejects a PER_SQM *quote* without sqm); with it missing, quantityToPieces used + // to throw 422 before the line could be set aside. + long pricingRequestId = mixedCostableAndNoSqmSameFactory(UnitBasis.PER_SQM); + + PricingDecisionDto decision = decisionService.startReview(pricingRequestId, + new StartPricingDecisionRequest(new BigDecimal("0.20"), "THB", null, UUID.randomUUID().toString()), ceoActor); + + List items = + costingRepository.find(decision.pricingCostingId()).orElseThrow().items(); + assertThat(items).hasSize(2); + assertThat(items.stream().filter(i -> i.uncostableReason() == null).toList()) + .singleElement().satisfies(i -> assertThat(i.totalLandedCostThb()).isNotNull()); + assertThat(items.stream().filter(i -> i.uncostableReason() != null).toList()) + .singleElement().satisfies(i -> assertThat(i.uncostableReason()).contains("ตร.ม.")); + } + + @Test + void missingSqm_perSqmLine_manualCostOverrideRefused409_andNothingIsWritten_clearStillWorks() { + // A no-sqm PER_SQM line stores 0 pieces; a manual per-piece cost x 0 pieces would total + // 0 THB and the selling price would follow. The set is refused before anything is written. + long pricingRequestId = mixedCostableAndNoSqmSameFactory(UnitBasis.PER_SQM); + PricingDecisionDto decision = decisionService.startReview(pricingRequestId, + new StartPricingDecisionRequest(new BigDecimal("0.20"), "THB", null, UUID.randomUUID().toString()), ceoActor); + PricingDecisionItemDto noSqm = decision.items().stream() + .filter(i -> i.frozenLandedCostPerRequestedUnitThb() == null).findFirst().orElseThrow(); + + assertThatThrownBy(() -> decisionService.overrideItemCost(decision.id(), noSqm.id(), + new CostOverrideRequest(new BigDecimal("50.0000"), "ตั้งต้นทุนเอง"), ceoActor)) + .isInstanceOfSatisfying(ApiException.class, + e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT)); + + assertThat(costingRepository.find(decision.pricingCostingId()).orElseThrow().items().stream() + .filter(i -> i.uncostableReason() != null).findFirst().orElseThrow().manualLandedCostPerUnitThb()) + .as("no override stored").isNull(); + PricingDecisionDto after = decisionService.overrideItemCost(decision.id(), noSqm.id(), + new CostOverrideRequest(null, "ล้างการปรับ"), ceoActor); + assertThat(after.items().stream().filter(i -> i.id() == noSqm.id()).findFirst().orElseThrow() + .frozenLandedCostPerRequestedUnitThb()).as("clearing still works, cost stays null").isNull(); + assertThatThrownBy(() -> decisionService.approve(decision.id(), + new ApprovePricingDecisionRequest(null, UUID.randomUUID().toString()), ceoActor)) + .isInstanceOfSatisfying(ApiException.class, + e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.UNPROCESSABLE_CONTENT)); + } + @Test void missingFreightBand_countryNotInFormulaConfig_refusesToPrice_neverZero() { // "TH" is a genuinely valid, V151-seeded price_catalog.country row (so the catalog INSERT @@ -740,6 +886,63 @@ UnitBasis.PER_PIECE, UnitBasis.PER_PIECE, new BigDecimal("100.00"), "THB", null, return pricingRequestId; } + /** + * One costable item and one whose sqm-per-piece CANNOT be resolved (a PER_PIECE factory quote + * with a blank {@code sqm_per_unit} AND a legacy request row with no {@code requested_qty_sqm}), + * both sourced from the SAME factory so they share one shipment — the fixture for proving the + * unresolvable line is set ASIDE as uncostable without aborting the costable one beside it. + * Wiring mirrors {@link #twoItemSameFactoryReadyForReview}; the only differences are item B's + * null {@code requested_qty_sqm} (position 12 of the compat constructor) and its quote item's + * null {@code sqmPerUnit}. + */ + private long mixedCostableAndNoSqmSameFactory() { + return mixedCostableAndNoSqmSameFactory(UnitBasis.PER_PIECE); + } + + private long mixedCostableAndNoSqmSameFactory(String noSqmRequestedBasis) { + long productCostable = insertCatalogProduct("Formula Factory", "IT", "V109-SQM-OK-" + UUID.randomUUID(), + new BigDecimal("100.00"), "THB", "per_piece"); + long productNoSqm = insertCatalogProduct("Formula Factory", "IT", "V109-SQM-MISSING-" + UUID.randomUUID(), + new BigDecimal("100.00"), "THB", "per_piece"); + // item A: requested_qty_sqm = 10 (sqm/piece = 1) -> sqm resolves -> costable. + PricingRequestItemRequest costable = new PricingRequestItemRequest( + null, productCostable, null, "Test", "ModelOK", "Test ModelOK", null, null, "1x1", "Formula Factory", + new BigDecimal("10"), new BigDecimal("10"), "piece", UnitBasis.PER_PIECE, QuantityType.CONFIRMED, null, null, null); + // item B: requested_qty_sqm = null (legacy) -> paired with a blank quote sqm_per_unit below, + // sqm is unresolvable. + PricingRequestItemRequest noSqm = new PricingRequestItemRequest( + null, productNoSqm, null, "Test", "ModelNoSqm", "Test ModelNoSqm", null, null, "1x1", "Formula Factory", + new BigDecimal("10"), null, "piece", noSqmRequestedBasis, QuantityType.CONFIRMED, null, null, null); + CreatePricingRequestRequest request = new CreatePricingRequestRequest( + PricingRequestRecipient.DESIGNER, null, "Designer Co.", LocalDate.now().plusDays(14), + null, "THB", "V109 missing-sqm test", UUID.randomUUID().toString(), List.of(costable, noSqm)); + // V185: bypasses PricingRequestService.createDraft on purpose (same reason as the helpers + // above) — submit() does NOT re-derive requested_qty_sqm, so item B's null survives to the + // calculator. + long pricingRequestId = pricingRequests.create(ticketId, pricingRequests.nextRequestCode(), request, salesRepId); + pricingRequestService.submit(pricingRequestId, salesActor); + pricingRequestService.pickup(pricingRequestId, importActor); + FactoryQuoteDto draft = factoryQuoteService.generateDrafts(pricingRequestId, importActor).stream() + .filter(q -> "Formula Factory".equals(q.factoryName())).findFirst().orElseThrow(); + // draft.items() preserves request-item order (item A first, item B second) — the same + // assumption twoItemSameFactoryReadyForReview relies on. + ReceiveFactoryQuoteItemRequest costableQuote = new ReceiveFactoryQuoteItemRequest( + draft.items().get(0).pricingRequestItemId(), null, null, new BigDecimal("10"), + UnitBasis.PER_PIECE, UnitBasis.PER_PIECE, new BigDecimal("100.00"), "THB", null, + new BigDecimal("1"), null, null, "45 days", null, null); + ReceiveFactoryQuoteItemRequest noSqmQuote = new ReceiveFactoryQuoteItemRequest( + draft.items().get(1).pricingRequestItemId(), null, null, new BigDecimal("10"), + UnitBasis.PER_PIECE, UnitBasis.PER_PIECE, new BigDecimal("100.00"), "THB", null, + null, null, null, "45 days", null, null); // sqmPerUnit = null — blank on a PER_PIECE quote + ReceiveFactoryQuoteRequest response = new ReceiveFactoryQuoteRequest("REF-NOSQM", "THB", "30 days", "45 days", + "revision", "note", List.of(costableQuote, noSqmQuote), UUID.randomUUID().toString()); + factoryQuoteService.markContacted(draft.id(), new MarkFactoryContactedRequest( + java.time.LocalDate.now(java.time.ZoneId.of("Asia/Bangkok")), null), importActor); + FactoryQuoteDto responded = factoryQuoteService.receive(draft.id(), response, importActor); + factoryQuoteService.markReadyForCosting(responded.id(), importActor); + return pricingRequestId; + } + private TicketItemRequest ticketItem(String brand, String model, String factory) { return new TicketItemRequest(brand, model, "White", "Matte", "60x60", factory, new BigDecimal("1"), null, "PIECE", null, null, null, null, "THB"); diff --git a/backend/src/test/java/th/co/glr/hr/pricingcosting/LandedCostCalculatorFxAndAggregationIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/pricingcosting/LandedCostCalculatorFxAndAggregationIntegrationTest.java index d9a3dc413..977325f46 100644 --- a/backend/src/test/java/th/co/glr/hr/pricingcosting/LandedCostCalculatorFxAndAggregationIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/pricingcosting/LandedCostCalculatorFxAndAggregationIntegrationTest.java @@ -172,11 +172,12 @@ void manualEurRate_reachesStartReview_andDecisionAndCostingRecordFxSourceManual( /** * Two items, from TWO DIFFERENT factories (so two shipments) — this is what actually - * discriminates the fix from a narrower one that only aggregates WITHIN a shipment. Item A is - * missing {@code sqmPerUnit} (its factory quote gives none AND its request has no {@code - * requestedQtySqm} to fall back to); item B is quoted PER_BOX with no {@code piecesPerBox}. - * Both are cluster-2 ({@code missingFactor}) problems, on items from different shipments — the - * old, per-shipment {@code resolveItemPhysicals} call would have thrown on item A's shipment + * discriminates the fix from a narrower one that only aggregates WITHIN a shipment. Item A is a + * PER_LINEAR_M request missing {@code linearMPerUnit}; item B is a PER_BOX request missing + * {@code piecesPerBox}. Both are cluster-2 ({@code missingFactor}) THROWS that still aggregate — + * deliberately NOT a missing {@code sqmPerUnit}, which #1127 moved to the uncostable set-aside + * path (its own test), so it no longer surfaces in this 422. On items from different shipments + * the old, per-shipment {@code resolveItemPhysicals} call would have thrown on item A's shipment * alone, and the CEO would never learn about item B until fixing A and re-running. This is one * of the two tests this task's mutation-check targets: reverting the aggregation must turn * THIS test red (specifically: only ONE of the two assertions below still holds) and nothing @@ -194,8 +195,8 @@ void twoItemsAcrossTwoFactories_eachMissingADifferentFactor_bothReportedInOneMes assertThat(e.getMessage()) .as("BOTH items' problems, and BOTH factor names in Thai, must appear in the " + "SAME message — proves aggregation across shipments, not first-problem-only") - .contains("Model NoSqm") - .contains("ตร.ม. ต่อหน่วย (sqmPerUnit)") + .contains("Model NoLinear") + .contains("ความยาว (เมตร) ต่อหน่วย (linearMPerUnit)") .contains("Model NoBox") .contains("จำนวนแผ่นต่อกล่อง (piecesPerBox)") // Both bullets present means neither problem shadowed the other. @@ -300,9 +301,11 @@ UnitBasis.PER_PIECE, UnitBasis.PER_PIECE, new BigDecimal("100.00"), currency, nu * (so {@code resolveSources} — cluster 1 — succeeds for both and the request auto-advances to * READY_FOR_CEO_REVIEW), but each missing a DIFFERENT physical conversion factor (cluster 2): *

      - *
    • "Model NoSqm" (factory A): quoted with {@code sqmPerUnit = null}, and its request item's - * {@code requestedQtySqm} is ALSO null — defeats both of {@code resolveSqmPerPiece}'s - * sources, so it throws {@code missingFactor("sqmPerUnit")}. + *
    • "Model NoLinear" (factory A): the REQUEST is expressed PER_LINEAR_M with no {@code + * linearMPerUnit} on the quote (quoted PER_PIECE with {@code sqmPerUnit} supplied, so it is + * NOT set aside as uncostable) — {@code quantityToPieces} throws {@code + * missingFactor("linearMPerUnit")}. Deliberately NOT a missing {@code sqmPerUnit}: #1127 + * moved that to the uncostable set-aside path, so it no longer surfaces in this 422. *
    • "Model NoBox" (factory B): the REQUEST is expressed PER_BOX with no {@code * piecesPerBox} on the quote — deliberately NOT the quote's own {@code unitBasis} (quoted * PER_PIECE instead, with {@code sqmPerUnit} supplied so {@code resolveSqmPerPiece} itself @@ -316,7 +319,7 @@ UnitBasis.PER_PIECE, UnitBasis.PER_PIECE, new BigDecimal("100.00"), currency, nu *
    */ private long twoItemTwoFactoriesEachMissingADifferentFactor() { - String factoryA = "Missing Sqm Factory " + UUID.randomUUID(); + String factoryA = "Missing Linear Factory " + UUID.randomUUID(); String factoryB = "Missing Box Factory " + UUID.randomUUID(); long productA = insertCatalogProduct(factoryA, "IT", "AGG-A-" + UUID.randomUUID(), new BigDecimal("100.00"), "THB", "per_piece"); @@ -325,9 +328,13 @@ private long twoItemTwoFactoriesEachMissingADifferentFactor() { long ticketId = createTwoFactoryDeal("ดีล Aggregation " + UUID.randomUUID(), factoryA, factoryB); + // A PER_LINEAR_M request whose quote supplies no linearMPerUnit — quantityToPieces then + // throws missingFactor("linearMPerUnit"). sqm IS resolvable (the quote carries sqmPerUnit), + // so this line is NOT set aside as uncostable; its problem is a cluster-2 missing FACTOR, + // the aggregated-422 path (NOT the sqm set-aside path, which #1127 moved out of this 422). PricingRequestItemRequest itemA = new PricingRequestItemRequest( - null, productA, null, "Brand", "Model NoSqm", "Brand Model NoSqm", null, null, "1x1", factoryA, - new BigDecimal("100"), null, "piece", UnitBasis.PER_PIECE, QuantityType.CONFIRMED, null, null, null); + null, productA, null, "Brand", "Model NoLinear", "Brand Model NoLinear", null, null, "1x1", factoryA, + new BigDecimal("100"), null, "m", UnitBasis.PER_LINEAR_M, QuantityType.CONFIRMED, null, null, null); PricingRequestItemRequest itemB = new PricingRequestItemRequest( null, productB, null, "Brand", "Model NoBox", "Brand Model NoBox", null, null, "1x1", factoryB, new BigDecimal("10"), new BigDecimal("100"), "box", UnitBasis.PER_BOX, @@ -357,12 +364,14 @@ private long twoItemTwoFactoriesEachMissingADifferentFactor() { FactoryQuoteDto draftA = drafts.stream().filter(q -> factoryA.equals(q.factoryName())).findFirst().orElseThrow(); FactoryQuoteDto draftB = drafts.stream().filter(q -> factoryB.equals(q.factoryName())).findFirst().orElseThrow(); - // Factory A: PER_PIECE, sqmPerUnit intentionally null. + // Factory A: quoted PER_PIECE with sqmPerUnit SUPPLIED (0.36) — so the line resolves sqm and + // is not set aside as uncostable — but linearMPerUnit (field 12) left null, which the + // REQUEST's own PER_LINEAR_M basis (see itemA above) then needs in quantityToPieces. ReceiveFactoryQuoteRequest responseA = new ReceiveFactoryQuoteRequest("REF-AGG-A", "THB", "30 days", "45 days", "revision", "note", List.of(new ReceiveFactoryQuoteItemRequest( draftA.items().get(0).pricingRequestItemId(), null, null, new BigDecimal("100"), UnitBasis.PER_PIECE, UnitBasis.PER_PIECE, new BigDecimal("100.00"), "THB", null, - null, null, null, "45 days", null, null)), + new BigDecimal("0.36"), null, null, "45 days", null, null)), UUID.randomUUID().toString()); factoryQuoteService.markContacted(draftA.id(), new MarkFactoryContactedRequest( java.time.LocalDate.now(java.time.ZoneId.of("Asia/Bangkok")), null), importActor); @@ -447,7 +456,7 @@ private long createTwoFactoryDeal(String dealName, String factoryA, String facto TicketDto created = ticketService.create( new CreateTicketRequest(dealName, "NORMAL", customer.name(), customer.id(), project.id(), null, null, null, List.of( - ticketItem("Brand", "Model NoSqm", factoryA), + ticketItem("Brand", "Model NoLinear", factoryA), ticketItem("Brand", "Model NoBox", factoryB))), salesActor); return created.summary().id(); diff --git a/backend/src/test/java/th/co/glr/hr/pricingdecision/PricingDecisionCeoPriceModeIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/pricingdecision/PricingDecisionCeoPriceModeIntegrationTest.java index 1765421b3..053bc4371 100644 --- a/backend/src/test/java/th/co/glr/hr/pricingdecision/PricingDecisionCeoPriceModeIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/pricingdecision/PricingDecisionCeoPriceModeIntegrationTest.java @@ -105,7 +105,6 @@ class PricingDecisionCeoPriceModeIntegrationTest extends AbstractPostgresIntegra private long ticketId; private long catalogProductIdFactoryA; private long catalogProductIdFactoryB; - private long catalogProductIdUncostable; @BeforeEach void wireServicesAndCreateDeal() { @@ -155,12 +154,14 @@ decisionRepository, tickets, ticketService, customers, new QuotationRenderer(), new BigDecimal("100.00"), "THB", "per_piece"); catalogProductIdFactoryB = insertCatalogProduct("Factory B-CEOP", "IT", "TEST-B-CEOP-001", new BigDecimal("100.00"), "THB", "per_piece"); - // V156 uncostable fixture (owner correction, 2026-09-19): thicknessMm=null means - // LandedCostCalculator#resolveThicknessMm can never resolve a freight band for this - // product, so any pricing-request item pointing at it reaches startReview with - // frozenLandedCostPerRequestedUnitThb == null -- exactly the "starts uncostable" state the - // new deriveListAndNet real-DB IT below needs. - catalogProductIdUncostable = insertCatalogProduct("Factory Uncostable-CEOP", "IT", "TEST-UNCOST-CEOP-001", + // The "starts uncostable" fixture the override/approve ITs below need is now a FREE-TEXT + // line (see freeTextUncostableItem): since #1150 a null catalogue thickness no longer makes + // a line uncostable (resolveThicknessMm falls back to the Sales value), so the line links no + // catalog PRODUCT — it is uncostable for lack of a resolvable origin country instead. + // The factory ROW must still exist, though: costShipment's factoryConfigs.findByName (an + // RFQ-factory existence check, price_catalog.factories) runs per shipment BEFORE the per-item + // set-aside and 422s on an unknown factory. Seed just that row (its product is never linked). + insertCatalogProduct("Factory Uncostable-CEOP", "IT", "TEST-UNCOST-CEOP-001", new BigDecimal("100.00"), "THB", "per_piece", "ACTIVE", null); CustomerDto customer = customers.create( @@ -218,9 +219,9 @@ void net_zeroDiscount_approvesWithoutTheCeoTypingAnything() { @Test void overrideItemCost_onAnUncostedItem_populatesListUnitPrice_andNetApprovesAtListEqualsNet() { - // 1. An uncosted new-form item -- thickness_mm=null means startReview freezes it with a - // null cost, a null formula reference, and (per the ORIGINAL wrong design) a permanently - // null list_unit_price. + // 1. An uncosted new-form item -- a free-text line with no resolvable origin country means + // startReview freezes it with a null cost, a null formula reference, and (per the ORIGINAL + // wrong design) a permanently null list_unit_price. long pricingRequestId = oneItemUncostedSubmittedCosting(); PricingDecisionDto decision = decisionService.startReview(pricingRequestId, new StartPricingDecisionRequest(new BigDecimal("0.20"), "THB", null, UUID.randomUUID().toString()), ceoActor); @@ -1010,11 +1011,11 @@ private long twoItemSubmittedCosting() { return pricingRequestId; } - /** Mirrors {@link #twoItemSubmittedCosting}, but with a SINGLE new-form item pointing at - * {@code catalogProductIdUncostable} (thickness_mm=null) -- V156's "the whole shipment is - * uncostable" branch in {@code LandedCostCalculator#calculate} handles this as a valid, - * fully-supported state, not a failure: the item still reaches READY_FOR_CEO_REVIEW and then - * {@code startReview}, just with a null effective cost until the CEO resolves it via {@link + /** Mirrors {@link #twoItemSubmittedCosting}, but with a SINGLE new-form FREE-TEXT item (see + * {@link #freeTextUncostableItem}) -- V156's "the whole shipment is uncostable" branch in + * {@code LandedCostCalculator#calculate} handles this as a valid, fully-supported state, not a + * failure: the item still reaches READY_FOR_CEO_REVIEW and then {@code startReview}, just with a + * null effective cost until the CEO resolves it via {@link * PricingDecisionService#overrideItemCost}. */ private long oneItemUncostedSubmittedCosting() { long pricingRequestId = pricingRequestService.createDraft(ticketId, oneItemUncostedPricingRequest(), salesActor) @@ -1039,7 +1040,32 @@ private PricingRequestRequests.CreatePricingRequestRequest oneItemUncostedPricin return new PricingRequestRequests.CreatePricingRequestRequest( PricingRequestRecipient.DESIGNER, null, "Designer Co.", LocalDate.now().plusDays(14), new BigDecimal("1000.00"), "THB", "ceo price mode uncosted request", UUID.randomUUID().toString(), - List.of(pricingItem("Uncostable", "Tile U-CEOP", "Factory Uncostable-CEOP", new BigDecimal("10")))); + List.of(freeTextUncostableItem("Uncostable", "Tile U-CEOP", "Factory Uncostable-CEOP", new BigDecimal("10")))); + } + + /** + * A free-text import line (no catalog link) that reaches startReview UNCOSTABLE and set aside + * (frozenLandedCost == null), so the CEO's override/approve gates below have something to act on. + * + *

    This replaced the old "catalog product with a null thickness" fixture: since #1150 + * (Sales-entered thickness → costing, owner ruling 2026-10-15), LandedCostCalculator#resolveThicknessMm + * falls back to the line's own thicknessMm, so a null CATALOG thickness no longer makes a line + * uncostable — the Sales value (required at submit) costs it. The surviving set-aside path is a + * line with no resolvable ORIGIN country: resolveOriginCountryCode reads the catalogue only (no + * request fallback), so a line with productId == null has no country and is set aside before the + * freight lookup. A real factory's country is NOT NULL (V151), so only a free-text line can hit it. + */ + private PricingRequestRequests.PricingRequestItemRequest freeTextUncostableItem( + String brand, String model, String factory, BigDecimal qty + ) { + // productId == null (free-text): the only field that differs from pricingItem(). factory is a + // free-text name — enough to route the factory-email/quote step, but it links no catalog row. + return new PricingRequestRequests.PricingRequestItemRequest(null, null, null, brand, model, + brand + " " + model, "White", "Matte", "60x60", factory, null, null, null, null, + QuantityType.CONFIRMED, null, null, null, + null, new BigDecimal("10"), new BigDecimal("0.36"), WastageCalculator.QUANTITY_MODE_PIECES, + null, qty.intValueExact(), WastageCalculator.WASTAGE_MODE_NONE, null, 4, null, + false, "ไทย-สต็อก", 3, 7, null, null, null); } /** A single PER_BOX item (not the Phase 1 new-form shape) driven to READY_FOR_CEO_REVIEW -- @@ -1087,8 +1113,7 @@ private PricingRequestRequests.PricingRequestItemRequest pricingItem( String brand, String model, String factory, BigDecimal qty ) { Long productId = "Factory A-CEOP".equals(factory) ? catalogProductIdFactoryA - : "Factory B-CEOP".equals(factory) ? catalogProductIdFactoryB - : "Factory Uncostable-CEOP".equals(factory) ? catalogProductIdUncostable : null; + : "Factory B-CEOP".equals(factory) ? catalogProductIdFactoryB : null; // V185 (direct-deal-form parity): PER_PIECE, sqm_per_piece=0.36 -- the Phase 1 new-form // shape Phase 2's mode picker is eligible for. Mirrors PricingDecisionIntegrationTest's // own identical fixture. diff --git a/backend/src/test/java/th/co/glr/hr/pricingrequest/PricingRequestItemDirectDealFieldsIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/pricingrequest/PricingRequestItemDirectDealFieldsIntegrationTest.java index bce525830..106c87b1c 100644 --- a/backend/src/test/java/th/co/glr/hr/pricingrequest/PricingRequestItemDirectDealFieldsIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/pricingrequest/PricingRequestItemDirectDealFieldsIntegrationTest.java @@ -577,6 +577,47 @@ void createCustomerChangeRevision_preservesImportAssignedFactoryAndVariantId() { // ── helpers ────────────────────────────────────────────────────────────────────────── + // ── Sales thickness never writes to the shared catalogue ─────────────────────────────── + + @Test + void submit_neverWritesSalesThicknessToTheSharedCatalog() { + // A typed thickness is unreviewed: a typo must not become catalogue truth. Costing reads + // it from the request item instead (LandedCostCalculatorFormulaIntegrationTest + // #salesEnteredThickness_makesACatalogNullThicknessLineCostable). + long nullThicknessPrice = insertCatalogProduct("WriteBack Factory", "TH", "WB-NULL-001", + new BigDecimal("100.00"), "THB", "per_piece", "ACTIVE", null); + long id = pricingRequestService.createDraft(ticketId, + createRequest(List.of(completeItem(b -> b + .productId(nullThicknessPrice).thicknessMm(new BigDecimal("12"))))), + salesActor).summary().id(); + pricingRequestService.submit(id, salesActor); + + BigDecimal catalogThickness = jdbc.queryForObject( + "SELECT thickness_mm FROM price_catalog.product_prices WHERE price_id = :id", + Map.of("id", nullThicknessPrice), BigDecimal.class); + assertThat(catalogThickness).as("submit must leave the catalogue thickness NULL").isNull(); + assertThat(onlyItem(pricingRequestService.get(id, salesActor)).thicknessMm()) + .as("the typed value stays on the request item").isEqualByComparingTo("12"); + } + + @Test + void submit_doesNotOverwriteAnExistingCatalogThickness_catalogWins() { + long existingThicknessPrice = insertCatalogProduct("WriteBack Factory 2", "TH", "WB-HAS-001", + new BigDecimal("100.00"), "THB", "per_piece", "ACTIVE", new BigDecimal("8")); + long id = pricingRequestService.createDraft(ticketId, + createRequest(List.of(completeItem(b -> b + .productId(existingThicknessPrice).thicknessMm(new BigDecimal("12"))))), + salesActor).summary().id(); + pricingRequestService.submit(id, salesActor); + + BigDecimal catalogThickness = jdbc.queryForObject( + "SELECT thickness_mm FROM price_catalog.product_prices WHERE price_id = :id", + Map.of("id", existingThicknessPrice), BigDecimal.class); + assertThat(catalogThickness) + .as("catalog wins: an existing catalogue thickness is never overwritten by a per-deal entry") + .isEqualByComparingTo("8"); + } + private PricingRequestItemDto onlyItem(PricingRequestDetailDto detail) { List items = pricingRequests.findItems(detail.summary().id()); assertThat(items).hasSize(1); 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 7265a91e9..dfe92d55f 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 @@ -110,8 +110,6 @@ public abstract class AbstractStockLineIntegrationTest extends AbstractPostgresI protected long ticketId; protected long catalogProductFactoryA; protected long catalogProductFactoryB; - /** thickness_mm = null, so any line pointing at it is UNCOSTABLE (V156). */ - protected long catalogProductUncostable; @BeforeEach protected void wireStockLineFixture() { @@ -166,7 +164,12 @@ protected void wireStockLineFixture() { new BigDecimal("100.00"), "THB", "per_piece"); catalogProductFactoryB = insertCatalogProduct(FACTORY_B, "IT", "TEST-STOCK-B-001", new BigDecimal("100.00"), "THB", "per_piece"); - catalogProductUncostable = insertCatalogProduct("Factory Uncostable-StockLine", "IT", "TEST-STOCK-U-001", + // Seed the RFQ factory ROW that importLineUncostable routes to: costShipment's + // factoryConfigs.findByName (price_catalog.factories) is a per-shipment existence check that + // runs BEFORE the per-item set-aside and 422s on an unknown factory. The line links no + // catalog PRODUCT (so it stays uncostable for lack of a resolvable origin); only the factory + // row matters here, so its product is never linked. + insertCatalogProduct("Factory Uncostable-StockLine", "IT", "TEST-STOCK-U-001", new BigDecimal("100.00"), "THB", "per_piece", "ACTIVE", null); CustomerDto customer = customers.create( @@ -192,6 +195,20 @@ protected PricingRequestItemRequest importLineWithoutFactory(String model, int p return line(model, null, null, pieces, "ไทย-สต็อก", 3, 7, null); } + /** + * An import line with a free-text factory (routable through the quote step) but NO catalog link, + * so it reaches startReview UNCOSTABLE and is set aside (frozenLandedCost == null). + * + *

    Since #1150 (Sales-entered thickness → costing, owner ruling 2026-10-15) a null CATALOG + * thickness no longer makes a line uncostable — LandedCostCalculator#resolveThicknessMm falls + * back to the line's own thicknessMm. The surviving set-aside path is a line with no resolvable + * ORIGIN country: resolveOriginCountryCode reads the catalogue only (no request fallback), and a + * real factory's country is NOT NULL (V151), so only a free-text (productId == null) line has none. + */ + protected PricingRequestItemRequest importLineUncostable(String model, String factory, int pieces) { + return line(model, null, factory, pieces, "ไทย-สต็อก", 3, 7, null); + } + /** * A STOCK line the way R7 says it arrives: no factory, no origin country, no lead time - those * three inputs are hidden for a stock line, so nothing is sent. diff --git a/backend/src/test/java/th/co/glr/hr/pricingstock/StockLineCeoDecisionIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/pricingstock/StockLineCeoDecisionIntegrationTest.java index 2c360e2cb..4d66da835 100644 --- a/backend/src/test/java/th/co/glr/hr/pricingstock/StockLineCeoDecisionIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/pricingstock/StockLineCeoDecisionIntegrationTest.java @@ -278,8 +278,7 @@ void approve_aStockLineWithAListPrice_succeeds_despiteHavingNoCost() { @Test void approve_anImportLineWithoutACost_isStillRefused_PIN() { - long id = persistSubmitAndPickUp(importLine("Uncosted", catalogProductUncostable, - "Factory Uncostable-StockLine", 10)); + long id = persistSubmitAndPickUp(importLineUncostable("Uncosted", "Factory Uncostable-StockLine", 10)); quoteEveryImportLineReady(id); PricingDecisionDto decision = decisionService.startReview(id, new StartPricingDecisionRequest(new BigDecimal("0.20"), "THB", null, UUID.randomUUID().toString()), @@ -298,7 +297,7 @@ void approve_anImportLineWithoutACost_isStillRefused_PIN() { @Test void approve_aPricedStockLine_doesNotExemptAnUncostedImportLineOnTheSameRequest_viaStartReview() { long id = persistSubmitAndPickUp( - importLine("Uncosted", catalogProductUncostable, "Factory Uncostable-StockLine", 10), + importLineUncostable("Uncosted", "Factory Uncostable-StockLine", 10), stockLine("Stock TH", IN_THAILAND, 5)); quoteEveryImportLineReady(id); forceStatus(id, PricingRequestStatus.READY_FOR_CEO_REVIEW); diff --git a/backend/src/test/java/th/co/glr/hr/ticket/PaymentTrackIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/ticket/PaymentTrackIntegrationTest.java index 3a5d7ee76..61a005e91 100644 --- a/backend/src/test/java/th/co/glr/hr/ticket/PaymentTrackIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/ticket/PaymentTrackIntegrationTest.java @@ -272,7 +272,7 @@ void bypassPath_walksEndToEnd_nullThroughFullyPaid_neverVisitsDepositNoticeIssue } @Test - void depositNoticeRevision_secondIssueSucceeds_mintsNewDocSupersedesFirst_paymentStatusStaysDepositNoticeIssued() { + void depositNoticeSecondIssue_isRefused_firstStaysIssued_noNumberConsumed_paymentStatusUnchanged() { Deal deal = buildDealToQuotationAccepted("revision"); orderConfirmation.confirmOrder( deal.pricingRequestId(), new ConfirmOrderRequest(UUID.randomUUID().toString()), salesActor); @@ -281,18 +281,26 @@ void depositNoticeRevision_secondIssueSucceeds_mintsNewDocSupersedesFirst_paymen DepositNoticeDto firstIssued = depositNoticeService.issue(firstDraft.id(), salesActor); assertThat(paymentStatusOf(deal.ticketId())).isEqualTo(PaymentTrack.DEPOSIT_NOTICE_ISSUED); - // A revision: a NEW draft on the SAME ticket, issued while payment_status is still - // DEPOSIT_NOTICE_ISSUED — PaymentTrack's one legal self-loop (guard e). - long secondDraftId = depositNoticeService.createDraft(deal.ticketId(), bareDraftRequest(), salesActor).id(); - DepositNoticeDto secondIssued = depositNoticeService.issue(secondDraftId, salesActor); - - assertThat(secondIssued.docNumber()).isNotEqualTo(firstIssued.docNumber()); - assertThat(secondIssued.status()).isEqualTo("ISSUED"); - // PR #698's "AND status = 'DRAFT'" guard on DepositNoticeRepository.issue composes with - // this change: the second row really was DRAFT (createDraft never touches payment_status) - // when it was issued. + // Owner ruling 2026-10-23: one deposit notice per deal. The create routes refuse a draft + // while a notice is ISSUED; a second draft that exists anyway (written by the repository, + // as a concurrent or pre-ruling draft would be) is refused at issue(), so the + // DEPOSIT_NOTICE_ISSUED self-loop is not reachable through this service. + assertThatThrownBy(() -> depositNoticeService.createDraft(deal.ticketId(), bareDraftRequest(), salesActor)) + .isInstanceOfSatisfying(ApiException.class, + e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT)); + long secondDraftId = depositNoticeRepository.createDraft( + deal.ticketId(), bareDraftRequest(), bareDraftRequest().items()); + assertThatThrownBy(() -> depositNoticeService.issue(secondDraftId, salesActor)) + .isInstanceOfSatisfying(ApiException.class, e -> { + assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT); + assertThat(e.getMessage()).contains(firstIssued.docNumber()); + }); + + DepositNoticeDto second = depositNoticeRepository.findById(secondDraftId).orElseThrow(); + assertThat(second.status()).isEqualTo("DRAFT"); + assertThat(second.docNumber()).isNull(); DepositNoticeDto firstReread = depositNoticeRepository.findById(firstIssued.id()).orElseThrow(); - assertThat(firstReread.status()).isEqualTo("SUPERSEDED"); + assertThat(firstReread.status()).isEqualTo("ISSUED"); assertThat(firstReread.docNumber()).isEqualTo(firstIssued.docNumber()); assertThat(paymentStatusOf(deal.ticketId())).isEqualTo(PaymentTrack.DEPOSIT_NOTICE_ISSUED); } @@ -567,9 +575,9 @@ void advancePaymentStatus_waivedPolicyCannotReachDepositNoticeIssued_repositoryL * Same off-path fact, proven through the real SERVICE call this time — this is what proves a * gap found while writing this test class: {@code DepositNoticeService.issue}'s loosened * precondition (site 2) originally checked ONLY {@code paymentStatus}, not {@code - * deposit_policy}. {@code createDraft} never checked {@code deposit_policy} either (it only - * gates on ticket status), so a WAIVED deal that had reached {@code CUSTOMER_CONFIRMED} could - * still acquire a real DRAFT deposit notice and sail past the service-level guard into {@code + * deposit_policy}. {@code createDraft} now refuses a bypass-policy deal, but a DRAFT made while + * the deal was still REQUIRED survives a later waive, so a WAIVED deal at {@code + * CUSTOMER_CONFIRMED} can still hold a real DRAFT deposit notice and sail past the service-level guard into {@code * advancePaymentStatus}, which would throw {@link IllegalStateException} UNCAUGHT — * {@code ApiExceptionHandler} has no handler for it, so it would have surfaced as an opaque * 500 ("เกิดข้อผิดพลาดภายในระบบ"), not a clean conflict. Fixed by adding an explicit {@code @@ -579,10 +587,10 @@ void advancePaymentStatus_waivedPolicyCannotReachDepositNoticeIssued_repositoryL @Test void depositNoticeServiceIssue_onWaivedPolicyDeal_isRefusedWithCleanConflict_notAnUncaughtException() { long ticketId = createBareTicket(); - tickets.updateDepositPolicy(ticketId, DepositPolicy.WAIVED, "ทดสอบ", salesRepId); tickets.markQuotationIssuedForOrderConfirmation(ticketId); tickets.updatePaymentStatusUnchecked(ticketId, PaymentTrack.CUSTOMER_CONFIRMED); long docId = depositNoticeService.createDraft(ticketId, bareDraftRequest(), salesActor).id(); + tickets.updateDepositPolicy(ticketId, DepositPolicy.WAIVED, "ทดสอบ", salesRepId); assertThatThrownBy(() -> depositNoticeService.issue(docId, salesActor)) .isInstanceOfSatisfying(ApiException.class, 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 9f72413b1..cf384bdd4 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 @@ -694,8 +694,8 @@ void addEventWithDocument_persistsTheLinkAndRejectsAHalfSetOne() { } // ── B5 fix: findPricingChainFallbackItems — the read-only display fallback for a deal - // whose sales.ticket_item is still empty (pre-order-confirmation), mirroring - // DepositNoticeRepository#findDealQuotationItemsForDeposit's live-quotation pick. + // whose sales.ticket_item is still empty (pre-order-confirmation), picking the deal's + // live new-engine quotation. // Real Postgres only: the priority CASE/ORDER BY and the origin/doc_status filters // cannot be proven against a mocked repository. ───────────────────────────────────── @@ -732,8 +732,7 @@ void findPricingChainFallbackItems_prefersAcceptedOverIssued_acrossOrigins() { insertQuotationWithItem(ticketId, "PRICING_REQUEST", "ISSUED", "Stale", "Issued", null, null, null, new BigDecimal("1")); // ...and a newer ACCEPTED DEAL_DIRECT-origin one. ACCEPTED must win regardless of - // which origin table it came from or insertion order (mirrors - // findDealQuotationItemsForDeposit's own CASE doc_status priority). + // which origin it came from or insertion order (the method's own CASE doc_status priority). insertQuotationWithItem(ticketId, "DEAL_DIRECT", "ACCEPTED", "Live", "Accepted", null, null, null, new BigDecimal("2")); @@ -764,6 +763,112 @@ void findPricingChainFallbackItems_returnsEmpty_whenTicketHasNoQuotationAtAll() assertThat(tickets.findPricingChainFallbackItems(ticketId)).isEmpty(); } + // ── Deposit policy that follows the quotation (PR #1173 follow-ups F1/F2) ───────────────── + // syncDepositPolicyFromQuotation is the only write the system makes to deposit_policy. It is a + // compare-and-set: it moves the row only while the policy is still the expected one, nobody + // has set it by hand (deposit_policy_set_by IS NULL; waiveDeposit always records its actor), + // and the payment track is still at CUSTOMER_CONFIRMED. 0 rows means "leave it alone". + + @Test + void syncDepositPolicyFromQuotation_movesASystemOwnedPolicyBothWays_andRecordsNoPerson() { + long ticketId = confirmedTicket(); + + assertThat(tickets.syncDepositPolicyFromQuotation(ticketId, DepositPolicy.REQUIRED, + DepositPolicy.NOT_REQUIRED, "ใบเสนอราคา QT-1 ระบุไม่รับมัดจำ (ชำระเต็มจำนวน)")).isEqualTo(1); + assertThat(depositPolicyRow(ticketId)) + .containsEntry("deposit_policy", DepositPolicy.NOT_REQUIRED) + .containsEntry("deposit_policy_reason", "ใบเสนอราคา QT-1 ระบุไม่รับมัดจำ (ชำระเต็มจำนวน)") + .containsEntry("deposit_policy_set_by", null); + + assertThat(tickets.syncDepositPolicyFromQuotation(ticketId, DepositPolicy.NOT_REQUIRED, + DepositPolicy.REQUIRED, "ใบเสนอราคา QT-1-1 ระบุมัดจำ 50%")).isEqualTo(1); + assertThat(depositPolicyRow(ticketId)) + .containsEntry("deposit_policy", DepositPolicy.REQUIRED) + .containsEntry("deposit_policy_reason", "ใบเสนอราคา QT-1-1 ระบุมัดจำ 50%") + .containsEntry("deposit_policy_set_by", null); + } + + /** F2: a waiver that commits between the service's read and this write must win. */ + @Test + void syncDepositPolicyFromQuotation_writesNothing_whenThePolicyIsNotTheExpectedOne() { + long ticketId = confirmedTicket(); + + assertThat(tickets.syncDepositPolicyFromQuotation(ticketId, DepositPolicy.NOT_REQUIRED, + DepositPolicy.REQUIRED, "ใบเสนอราคา QT-2 ระบุมัดจำ 50%")).isZero(); + assertThat(depositPolicyRow(ticketId)) + .containsEntry("deposit_policy", DepositPolicy.REQUIRED) + .containsEntry("deposit_policy_reason", null); + + tickets.updateDepositPolicy(ticketId, DepositPolicy.CREDIT_CUSTOMER, "ลูกค้าเครดิต 30 วัน", actorId); + assertThat(tickets.syncDepositPolicyFromQuotation(ticketId, DepositPolicy.REQUIRED, + DepositPolicy.NOT_REQUIRED, "ใบเสนอราคา QT-2 ระบุไม่รับมัดจำ (ชำระเต็มจำนวน)")).isZero(); + assertThat(depositPolicyRow(ticketId)) + .containsEntry("deposit_policy", DepositPolicy.CREDIT_CUSTOMER) + .containsEntry("deposit_policy_reason", "ลูกค้าเครดิต 30 วัน"); + } + + /** A NOT_REQUIRED a person chose (waiveDeposit) is never flipped back by a quotation. */ + @Test + void syncDepositPolicyFromQuotation_writesNothing_onAPolicyAPersonSet() { + long ticketId = confirmedTicket(); + tickets.updateDepositPolicy(ticketId, DepositPolicy.NOT_REQUIRED, "ลูกค้าโอนเต็มจำนวนล่วงหน้า", actorId); + + assertThat(tickets.syncDepositPolicyFromQuotation(ticketId, DepositPolicy.NOT_REQUIRED, + DepositPolicy.REQUIRED, "ใบเสนอราคา QT-3 ระบุมัดจำ 50%")).isZero(); + Map row = depositPolicyRow(ticketId); + assertThat(row).containsEntry("deposit_policy", DepositPolicy.NOT_REQUIRED) + .containsEntry("deposit_policy_reason", "ลูกค้าโอนเต็มจำนวนล่วงหน้า"); + assertThat(((Number) row.get("deposit_policy_set_by")).longValue()).isEqualTo(actorId); + } + + /** Before the customer confirms there is nothing to follow; once a deposit notice is out + * (rule 4) the policy is a money question, not a quotation one. */ + @Test + void syncDepositPolicyFromQuotation_writesNothing_offCustomerConfirmed() { + long unconfirmed = tickets.create(sampleTicket(), tickets.nextTicketCode(), actorId, "พนักงานขาย"); + assertThat(tickets.syncDepositPolicyFromQuotation(unconfirmed, DepositPolicy.REQUIRED, + DepositPolicy.NOT_REQUIRED, "ใบเสนอราคา QT-4 ระบุไม่รับมัดจำ (ชำระเต็มจำนวน)")).isZero(); + assertThat(depositPolicyRow(unconfirmed)).containsEntry("deposit_policy", DepositPolicy.REQUIRED); + + long noticeIssued = confirmedTicket(); + jdbc.update("UPDATE sales.ticket SET payment_status = 'DEPOSIT_NOTICE_ISSUED' WHERE ticket_id = :id", + Map.of("id", noticeIssued)); + assertThat(tickets.syncDepositPolicyFromQuotation(noticeIssued, DepositPolicy.REQUIRED, + DepositPolicy.NOT_REQUIRED, "ใบเสนอราคา QT-4 ระบุไม่รับมัดจำ (ชำระเต็มจำนวน)")).isZero(); + assertThat(depositPolicyRow(noticeIssued)).containsEntry("deposit_policy", DepositPolicy.REQUIRED); + } + + /** A deposit notice already ISSUED pins the policy at REQUIRED even while the payment track is + * still CUSTOMER_CONFIRMED: approving a 0% revision must not flip it (0 rows, so the service + * records no POLICY_CHANGED event). The no-notice flip is pinned by the first sync test; the + * NOT_REQUIRED -> REQUIRED direction is unchanged by a notice. */ + @Test + void syncDepositPolicyFromQuotation_keepsRequired_whileADepositNoticeIsIssued() { + long ticket = confirmedTicket(); + 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, 1, 'ISSUED', 'x', 0.3, 0, 0, 0.07, 0, 0)""", Map.of("id", ticket)); + + assertThat(tickets.syncDepositPolicyFromQuotation(ticket, DepositPolicy.REQUIRED, + DepositPolicy.NOT_REQUIRED, "ใบเสนอราคา QT-5 ระบุไม่รับมัดจำ (ชำระเต็มจำนวน)")).isZero(); + assertThat(depositPolicyRow(ticket)).containsEntry("deposit_policy", DepositPolicy.REQUIRED); + } + + 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", + Map.of("id", ticketId)); + return ticketId; + } + + private Map depositPolicyRow(long ticketId) { + return jdbc.queryForMap(""" + SELECT deposit_policy, deposit_policy_reason, deposit_policy_set_by + FROM sales.ticket WHERE ticket_id = :id + """, Map.of("id", ticketId)); + } + // sales.quotation.number is VARCHAR(30) NOT NULL UNIQUE. A per-JVM-run counter alone // collides across repeated runs against the same persistent local test DB (this suite has // no @Transactional rollback / truncate-between-runs — see diff --git a/docs/repo-map/.gitignore b/docs/repo-map/.gitignore new file mode 100644 index 000000000..f6b6248bb --- /dev/null +++ b/docs/repo-map/.gitignore @@ -0,0 +1 @@ +.obsidian/ diff --git a/docs/repo-map/INDEX.md b/docs/repo-map/INDEX.md new file mode 100644 index 000000000..9eadceaf8 --- /dev/null +++ b/docs/repo-map/INDEX.md @@ -0,0 +1,37 @@ +--- +title: GL-R-ERP Repo Map +tags: [repo-map, index] +synced: develop@fe5138dd +--- +# GL-R-ERP Repo Map +> A domain-level map of the repo: each note is one **vertical slice** (backend ↔ frontend ↔ DB) so you can find where a feature lives — and how features relate — without grepping the whole tree. + +**How to use** +- Open `docs/repo-map/` as an **Obsidian vault**; the `[[links]]` between notes drive the **Graph view** — that graph *is* the map. +- Start here, open the domain you're touching, follow **Related** to its neighbours. +- **Agents:** read this INDEX first, then the one relevant domain note, instead of scanning the tree. Treat each note's **Gotchas** as pointers — `CLAUDE.md` is authoritative. + +_Synced to `develop@fe5138dd`. Re-run `python3 docs/repo-map/_tools/generate.py` after big merges to refresh the file listings._ + +## Sales / CRM +- [[sales-deal-ticket|Sales Deal & Ticket]] — The deal aggregate (1 ticket = 1 deal) and its sales-stage pipeline / actions. +- [[pricing-flow|Pricing Flow]] — Pricing request → factory quote → landed cost → CEO selling-price decision. +- [[catalog-price-import|Catalog & Price Import]] — The product price catalogue (import-managed, version-controlled) + brand reference. +- [[quotation|Quotation]] — Customer-facing quotation generation, revisions, and the rendered PDF/XLSX. +- [[deposit-billing-finance|Deposit · Billing · Finance]] — Deposit notices, billing notes, tax invoices, payments, and sales commission. +- [[import-fulfilment|Import / Fulfilment]] — Per-factory import requests, procurement steps, and delivery fulfilment. +- [[customer|Customer]] — Customers, projects, contacts, designers, and Thai-address handling. + +## HR +- [[employee|Employee & Self-service]] — Employee master, profile, and self-service edit/requests (incl. tax allowance). +- [[attendance|Attendance]] — Punch clock, attendance records, corrections, and the work calendar. +- [[leave|Leave]] — Leave requests, balances, policy rules, and approval. +- [[overtime|Overtime]] — Overtime filing and the two-step (manager → CEO) approval chain. +- [[payroll|Payroll]] — Payroll runs, deductions, consents, tax, and the payroll draft etag. + +## Platform +- [[auth-session|Auth & Session]] — Login, session (JDBC-backed), CSRF, password reset, and the security filter chain. +- [[notification-mail|Notification & Mail]] — In-app notifications and outbound email (quotations, resets, alerts). +- [[activity-audit|Activity & Audit]] — The activity log (request filter + recorder) and the audit trail. +- [[dashboard|Dashboard]] — Cross-domain rollups and the landing dashboards. +- [[platform-common|Platform / Common]] — Shared infra: common utils, file attachments, and locations. diff --git a/docs/repo-map/README.md b/docs/repo-map/README.md new file mode 100644 index 000000000..075b268cb --- /dev/null +++ b/docs/repo-map/README.md @@ -0,0 +1,36 @@ +# GL-R-ERP Repo Map + +A **domain-level** map of this repo for both humans and coding agents. Each note under +[`domains/`](domains/) is one **vertical slice** — the backend package(s), the frontend +feature(s), and the DB schema/migrations for one domain, in one place — so you can find +where a feature lives, and how features relate, without grepping the whole tree. + +## Open it in Obsidian +1. Obsidian → **Open folder as vault** → pick this `docs/repo-map/` folder. +2. Open [`INDEX.md`](INDEX.md) (the entry point), then open the domain you care about. +3. Turn on **Graph view** — the `[[wikilinks]]` between notes (the **Related** section of + each domain) *are* the map. Filter the graph with the `#domain/*` tags. + +No plugins required; everything is plain Markdown. It also renders fine on GitHub. + +## For coding agents +Read [`INDEX.md`](INDEX.md) first, then the single relevant domain note, instead of +scanning the source tree — that is the whole point of this map (orient fast, read less). +Each note's **Gotchas** are pointers; `CLAUDE.md` / `AGENTS.md` at the repo root remain +authoritative for business rules and history. + +## Keeping it fresh +The curated model (grouping, relations, gotchas) and the live file-listing walker both +live in one script. After a big merge, re-sync the file listings with: + +``` +python3 docs/repo-map/_tools/generate.py +``` + +It rewrites `INDEX.md` + `domains/*.md` and stamps each with the `develop` sha it synced +to. Domains are intentionally coarse (packages rarely move), so the map ages slowly; the +`synced: develop@` front-matter tells you how stale a note is. + +To add/rename a domain or edit a gotcha, edit the `DOMAINS` model in +[`_tools/generate.py`](_tools/generate.py) and re-run — don't hand-edit the generated +notes (a re-run overwrites them). diff --git a/docs/repo-map/_tools/generate.py b/docs/repo-map/_tools/generate.py new file mode 100644 index 000000000..67fd45fbe --- /dev/null +++ b/docs/repo-map/_tools/generate.py @@ -0,0 +1,429 @@ +#!/usr/bin/env python3 +"""Generate the GL-R-ERP Obsidian repo map. + +ONE generator that is also the re-sync tool: the curated domain model (grouping, +relations, gotchas) lives here; the per-file listings are walked live from the tree, so a +re-run refreshes "what files are in each domain" without hand-editing every note. + +Usage: + python3 docs/repo-map/_tools/generate.py # regenerate domains/*.md + INDEX.md + +Open `docs/repo-map/` as an Obsidian vault; the [[wikilinks]] between domain notes drive +the Graph view. The notes are also written to be read by a coding agent that needs to +orient in the repo quickly — INDEX.md first, then the relevant domain note. +""" +from __future__ import annotations +import subprocess +from pathlib import Path + +HERE = Path(__file__).resolve() +MAP_DIR = HERE.parent.parent # docs/repo-map +REPO = MAP_DIR.parent.parent # repo root +BACKEND_PKG = REPO / "backend/src/main/java/th/co/glr/hr" +FRONTEND_FEAT = REPO / "frontend/src/features" +GH = "https://github.com/waritctd/GL-R-ERP/blob/develop" + + +def sha() -> str: + try: + return subprocess.check_output( + ["git", "rev-parse", "--short", "origin/develop"], cwd=REPO, text=True + ).strip() + except Exception: + return "unknown" + + +# ── Curated domain model ──────────────────────────────────────────────────────────────── +# backend: package dirs under th/co/glr/hr +# frontend: feature dirs under frontend/src/features +# related: other domain slugs (→ [[wikilinks]] → graph edges) +LAYERS = ["Sales / CRM", "HR", "Platform"] + +DOMAINS = { + # ── Sales / CRM ────────────────────────────────────────────────────────────────────── + "sales-deal-ticket": { + "title": "Sales Deal & Ticket", + "layer": "Sales / CRM", + "purpose": "The deal aggregate (1 ticket = 1 deal) and its sales-stage pipeline / actions.", + "backend": ["ticket", "orderconfirmation", "approval"], + "frontend": ["tickets", "sales"], + "schemas": ["sales"], + "migrations": ["V59 pricing_request_item", "V83 deal tracking + activity"], + "related": ["pricing-flow", "quotation", "customer", "deposit-billing-finance", "import-fulfilment"], + "gotchas": [ + "15-stage pipeline (DealStage); S12-S17 = per-factory procurement, S18/S19 = delivery.", + "salesActions.js is the single source of which action shows at each stage.", + "ticket.status vs paymentStatus vs fulfillmentStatus are THREE independent axes.", + ], + }, + "pricing-flow": { + "title": "Pricing Flow", + "layer": "Sales / CRM", + "purpose": "Pricing request → factory quote → landed cost → CEO selling-price decision.", + "backend": ["pricingrequest", "pricingcosting", "pricingdecision", "pricing", "factoryquote"], + "frontend": ["pricingRequests", "ceoSettings"], + "schemas": ["sales", "price_catalog"], + "migrations": [ + "V61 factory_quote + costing foundation", "V109 formula engine (landed cost)", + "V141 CEO owns costing", "V156 uncostable items", "V185 direct-deal item fields", + "V187 CEO price mode", "V194 stock lines on pricing request", + ], + "related": ["catalog-price-import", "sales-deal-ticket", "quotation", "import-fulfilment"], + "gotchas": [ + "LandedCostCalculator reads thickness/origin from the CATALOG LINK only (+ Sales-entered thickness fallback, 2026-10) — see CLAUDE.md.", + "A line with no resolvable thickness/origin/sqm is set ASIDE as 'uncostable' (V156), not a hard 422.", + "Mock reimplements SOME pricing math but NOT freight — do not trust mock for pricing. See CLAUDE.md 'Mock API contract'.", + "ceoSettings = FX rates + pricing formula config (BOT fetch + manual entry).", + ], + }, + "catalog-price-import": { + "title": "Catalog & Price Import", + "layer": "Sales / CRM", + "purpose": "The product price catalogue (import-managed, version-controlled) + brand reference.", + "backend": ["catalog", "brand"], + "frontend": ["catalog"], + "schemas": ["price_catalog"], + "migrations": ["V153 priceable basis + active-version read", "V171 import provenance", "V172 size norm"], + "related": ["pricing-flow", "quotation"], + "gotchas": [ + "Catalogue reads filter price_list_versions.status = 'ACTIVE' (V153); non-active rows are invisible to pricing.", + "price_catalog.product_prices is the price list; sales.catalog is a SEPARATE brand reference — different ids.", + "product_prices.price_per_sqm is a GENERATED column; ~41% of real rows have NULL thickness_mm.", + ], + }, + "quotation": { + "title": "Quotation", + "layer": "Sales / CRM", + "purpose": "Customer-facing quotation generation, revisions, and the rendered PDF/XLSX.", + "backend": ["dealquotation", "customerquotation"], + "frontend": ["quotations"], + "schemas": ["sales", "customers"], + "migrations": ["V165 deal_quotation_direct", "V176 quotation_item sqm_per_box", "V192.x per-sqm / mixed price modes"], + "related": ["pricing-flow", "sales-deal-ticket", "customer", "deposit-billing-finance"], + "gotchas": [ + "QuotationRenderer needs the licensed Thai fonts (backend/fonts/, gitignored) — a Git build embeds substitute fonts.", + "Quotation carries its OWN frozen price snapshot, independent of the live pricing decision.", + ], + }, + "deposit-billing-finance": { + "title": "Deposit · Billing · Finance", + "layer": "Sales / CRM", + "purpose": "Deposit notices, billing notes, tax invoices, payments, and sales commission.", + "backend": ["deposit", "billing", "finance", "specialmoney", "commission"], + "frontend": ["deposits", "finance", "commissions", "specialmoney"], + "schemas": ["sales", "hr"], + "migrations": ["V72 pricing_decision", "V188 remaining invoice document", "V189 billing note document"], + "related": ["sales-deal-ticket", "quotation"], + "gotchas": [ + "DepositNoticeService.issue gates on ticket.status='quotation_issued' AND paymentStatus='CUSTOMER_CONFIRMED' — else 409.", + "Commission math is UNTOUCHABLE without explicit request (CLAUDE.md scope rule).", + "PDF/XLSX render is a 'not supported in mock mode' stub — documents only truly render on the real backend.", + ], + }, + "import-fulfilment": { + "title": "Import / Fulfilment", + "layer": "Sales / CRM", + "purpose": "Per-factory import requests, procurement steps, and delivery fulfilment.", + "backend": ["importdeal", "importrequest", "procurement", "factory"], + "frontend": ["fulfilment", "importDeal", "importProgress"], + "schemas": ["sales"], + "migrations": ["V77 factory_purchase_order", "V184 per-factory import_request (sales.import_request)"], + "related": ["sales-deal-ticket", "pricing-flow"], + "gotchas": [ + "TWO parallel import systems: legacy deal-level (fulfillmentStatus IR_ISSUED) vs per-factory V184 (sales.import_request).", + "A deal is in the import role's list scope (TicketRepository.appendRoleScope) when it is NOT closed/lost/cancelled AND (a non-terminal pricing request exists OR salesStage >= ORDER_RECEIVED). No fulfillmentStatus condition; the old PROCUREMENT floor was removed as self-defeating.", + "An item must be linked to a factory before it enters the import lane.", + ], + }, + "customer": { + "title": "Customer", + "layer": "Sales / CRM", + "purpose": "Customers, projects, contacts, designers, and Thai-address handling.", + "backend": ["customer", "designer"], + "frontend": [], + "schemas": ["customers"], + "migrations": ["V32 demo ticket customers (demo)"], + "related": ["sales-deal-ticket", "quotation"], + "gotchas": [ + "Customer UI lives inside tickets/quotations features rather than a standalone feature dir.", + ], + }, + # ── HR ──────────────────────────────────────────────────────────────────────────────── + "employee": { + "title": "Employee & Self-service", + "layer": "HR", + "purpose": "Employee master, profile, and self-service edit/requests (incl. tax allowance).", + "backend": ["employee", "profile"], + "frontend": ["employees", "profile", "profileRequests", "requests", "taxAllowance"], + "schemas": ["hr", "hr_restricted"], + "migrations": ["V139 missing-role personas + hire dates (demo)"], + "related": ["attendance", "leave", "overtime", "payroll", "auth-session"], + "gotchas": [ + "DivisionAccessPolicy.roleFor derives a user's role from their division — role is NOT stored directly.", + ], + }, + "attendance": { + "title": "Attendance", + "layer": "HR", + "purpose": "Punch clock, attendance records, corrections, and the work calendar.", + "backend": ["attendance"], + "frontend": ["attendance", "attendanceCalendar", "attendanceCorrection"], + "schemas": ["hr"], + "migrations": [], + "related": ["overtime", "payroll", "employee"], + "gotchas": [ + "AttendanceScopeIntegrationTest is the reference pattern for real-DB authz scope tests.", + "POST /api/attendance/punch is one of the few anonymous-allowed endpoints (SecurityConfig).", + ], + }, + "leave": { + "title": "Leave", + "layer": "HR", + "purpose": "Leave requests, balances, policy rules, and approval.", + "backend": ["leave"], + "frontend": ["leave"], + "schemas": ["hr"], + "migrations": [], + "related": ["employee", "payroll", "attendance"], + "gotchas": [ + "Leave/quota math is business-logic-sensitive — re-check against the spec before PR (CLAUDE.md).", + ], + }, + "overtime": { + "title": "Overtime", + "layer": "HR", + "purpose": "Overtime filing and the two-step (manager → CEO) approval chain.", + "backend": ["overtime"], + "frontend": ["overtime"], + "schemas": ["hr"], + "migrations": [], + "related": ["attendance", "payroll", "employee"], + "gotchas": [ + "Issue #199: mock let HR approve OT but real OvertimeService returns 403 — verify authz on the real service.", + "day_type / pay multiplier derive from hr.holiday, never from the client's claim.", + ], + }, + "payroll": { + "title": "Payroll", + "layer": "HR", + "purpose": "Payroll runs, deductions, consents, tax, and the payroll draft etag.", + "backend": ["payroll"], + "frontend": ["payroll"], + "schemas": ["hr", "hr_restricted"], + "migrations": [], + "related": ["employee", "attendance", "overtime", "leave"], + "gotchas": [ + "Payroll/tax math is UNTOUCHABLE without explicit request (CLAUDE.md).", + "computeDraftEtag is mirrored in the mock — a mock-driven test can't validate the algorithm, only plumbing.", + ], + }, + # ── Platform ──────────────────────────────────────────────────────────────────────── + "auth-session": { + "title": "Auth & Session", + "layer": "Platform", + "purpose": "Login, session (JDBC-backed), CSRF, password reset, and the security filter chain.", + "backend": ["auth", "config"], + "frontend": ["auth"], + "schemas": ["hr"], + "migrations": ["V19 spring_session"], + "related": ["employee"], + "gotchas": [ + "SecurityConfig only authenticates (default-deny); per-role/business authz lives in the SERVICE layer (requireWrite / isDealOwner / role checks), NOT the filter chain — a mock that skips it can diverge from the real 403.", + "CSRF is app-rolled (CsrfCookieFilter), NOT Spring Security — do not 're-enable' Spring CSRF.", + "Real AuthService refuses {role}-only login with 403; demo accounts are email+password (Demo@2026).", + ], + }, + "notification-mail": { + "title": "Notification & Mail", + "layer": "Platform", + "purpose": "In-app notifications and outbound email (quotations, resets, alerts).", + "backend": ["notification", "mail"], + "frontend": [], + "schemas": ["hr"], + "migrations": [], + "related": ["auth-session", "deposit-billing-finance", "pricing-flow"], + "gotchas": [ + "SalesNotificationMailer.NO_OP is used in tests / when SMTP is unconfigured — no mail actually sends.", + ], + }, + "activity-audit": { + "title": "Activity & Audit", + "layer": "Platform", + "purpose": "The activity log (request filter + recorder) and the audit trail.", + "backend": ["activity", "audit"], + "frontend": ["activityLog"], + "schemas": ["hr"], + "migrations": [], + "related": ["auth-session"], + "gotchas": [ + "ActivityLogFilter wraps every request; it needs the JDBC template, so a DB/Flyway failure aborts the whole app at boot.", + ], + }, + "dashboard": { + "title": "Dashboard", + "layer": "Platform", + "purpose": "Cross-domain rollups and the landing dashboards.", + "backend": ["dashboard"], + "frontend": ["dashboard"], + "schemas": ["hr", "sales"], + "migrations": [], + "related": ["deposit-billing-finance", "sales-deal-ticket"], + "gotchas": [], + }, + "platform-common": { + "title": "Platform / Common", + "layer": "Platform", + "purpose": "Shared infra: common utils, file attachments, and locations.", + "backend": ["common", "attachment", "location"], + "frontend": ["locations"], + "schemas": ["hr"], + "migrations": [], + "related": ["auth-session"], + "gotchas": [ + "common/ holds ApiException, PdfDocumentWriter, ThaiText, ArGlrSequence — reused everywhere.", + ], + }, +} + + +def list_backend_files(pkg: str) -> list[str]: + base = BACKEND_PKG / pkg + if not base.is_dir(): + return [] + out = [] + for p in sorted(base.rglob("*.java")): + rel = p.relative_to(REPO).as_posix() + out.append(rel) + return out + + +def list_frontend_files(feat: str) -> list[str]: + base = FRONTEND_FEAT / feat + if not base.is_dir(): + return [] + out = [] + for p in sorted(list(base.rglob("*.jsx")) + list(base.rglob("*.js"))): + if ".test." in p.name: + continue + out.append(p.relative_to(REPO).as_posix()) + return out + + +def render_domain(slug: str, d: dict, stamp: str) -> str: + L = [] + schemas = ", ".join(d["schemas"]) + L.append("---") + L.append(f"title: {d['title']}") + L.append(f"layer: {d['layer']}") + L.append(f"schemas: [{schemas}]") + L.append(f"tags: [repo-map, domain/{slug}]") + L.append(f"synced: develop@{stamp}") + L.append("---") + L.append(f"# {d['title']}") + L.append(f"> {d['purpose']}") + L.append("") + L.append(f"**Layer:** {d['layer']} · **DB schemas:** `{schemas or '—'}`") + L.append("") + + # Backend + L.append("## Backend") + if d["backend"]: + for pkg in d["backend"]: + files = list_backend_files(pkg) + dir_link = f"[{pkg}/]({GH}/backend/src/main/java/th/co/glr/hr/{pkg})" + L.append(f"### `th.co.glr.hr.{pkg}` — {dir_link}") + if files: + for f in files: + L.append(f"- `{f}`") + else: + L.append("- _(no files found — package may have moved; re-run generator)_") + else: + L.append("_(no dedicated backend package — see related domains)_") + L.append("") + + # Frontend + L.append("## Frontend") + if d["frontend"]: + for feat in d["frontend"]: + files = list_frontend_files(feat) + dir_link = f"[{feat}/]({GH}/frontend/src/features/{feat})" + L.append(f"### `features/{feat}` — {dir_link}") + for f in files: + L.append(f"- `{f}`") + if not files: + L.append("- _(no files found — feature may have moved; re-run generator)_") + else: + L.append("_(no dedicated frontend feature — UI lives inside a related domain)_") + L.append("") + + # DB + L.append("## DB") + L.append(f"- Schemas: `{schemas or '—'}`") + if d["migrations"]: + L.append("- Key migrations: " + "; ".join(f"`{m}`" for m in d["migrations"])) + L.append("") + + # Related + L.append("## Related") + if d["related"]: + L.append(" · ".join(f"[[{r}|{DOMAINS[r]['title']}]]" for r in d["related"] if r in DOMAINS)) + else: + L.append("_(none)_") + L.append("") + + # Gotchas + L.append("## Gotchas") + if d["gotchas"]: + for g in d["gotchas"]: + L.append(f"- {g}") + L.append("") + L.append("> Authoritative details + history live in `CLAUDE.md` / `AGENTS.md` at the repo root.") + else: + L.append("_(none recorded)_") + L.append("") + return "\n".join(L) + + +def render_index(stamp: str) -> str: + L = [] + L.append("---") + L.append("title: GL-R-ERP Repo Map") + L.append("tags: [repo-map, index]") + L.append(f"synced: develop@{stamp}") + L.append("---") + L.append("# GL-R-ERP Repo Map") + L.append("> A domain-level map of the repo: each note is one **vertical slice** " + "(backend ↔ frontend ↔ DB) so you can find where a feature lives — and how " + "features relate — without grepping the whole tree.") + L.append("") + L.append("**How to use**") + L.append("- Open `docs/repo-map/` as an **Obsidian vault**; the `[[links]]` between notes " + "drive the **Graph view** — that graph *is* the map.") + L.append("- Start here, open the domain you're touching, follow **Related** to its neighbours.") + L.append("- **Agents:** read this INDEX first, then the one relevant domain note, instead of " + "scanning the tree. Treat each note's **Gotchas** as pointers — `CLAUDE.md` is authoritative.") + L.append("") + L.append(f"_Synced to `develop@{stamp}`. Re-run `python3 docs/repo-map/_tools/generate.py` after " + "big merges to refresh the file listings._") + L.append("") + for layer in LAYERS: + L.append(f"## {layer}") + for slug, d in DOMAINS.items(): + if d["layer"] != layer: + continue + L.append(f"- [[{slug}|{d['title']}]] — {d['purpose']}") + L.append("") + return "\n".join(L) + + +def main() -> None: + stamp = sha() + (MAP_DIR / "domains").mkdir(parents=True, exist_ok=True) + for slug, d in DOMAINS.items(): + (MAP_DIR / "domains" / f"{slug}.md").write_text(render_domain(slug, d, stamp), encoding="utf-8") + (MAP_DIR / "INDEX.md").write_text(render_index(stamp), encoding="utf-8") + print(f"generated {len(DOMAINS)} domain notes + INDEX.md (synced develop@{stamp})") + + +if __name__ == "__main__": + main() diff --git a/docs/repo-map/domains/activity-audit.md b/docs/repo-map/domains/activity-audit.md new file mode 100644 index 000000000..e903afd61 --- /dev/null +++ b/docs/repo-map/domains/activity-audit.md @@ -0,0 +1,48 @@ +--- +title: Activity & Audit +layer: Platform +schemas: [hr] +tags: [repo-map, domain/activity-audit] +synced: develop@fe5138dd +--- +# Activity & Audit +> The activity log (request filter + recorder) and the audit trail. + +**Layer:** Platform · **DB schemas:** `hr` + +## Backend +### `th.co.glr.hr.activity` — [activity/](https://github.com/waritctd/GL-R-ERP/blob/develop/backend/src/main/java/th/co/glr/hr/activity) +- `backend/src/main/java/th/co/glr/hr/activity/ActivityLogController.java` +- `backend/src/main/java/th/co/glr/hr/activity/ActivityLogEntry.java` +- `backend/src/main/java/th/co/glr/hr/activity/ActivityLogEntryDto.java` +- `backend/src/main/java/th/co/glr/hr/activity/ActivityLogFilter.java` +- `backend/src/main/java/th/co/glr/hr/activity/ActivityLogRecorder.java` +- `backend/src/main/java/th/co/glr/hr/activity/ActivityLogRepository.java` +- `backend/src/main/java/th/co/glr/hr/activity/ActivityLogService.java` +- `backend/src/main/java/th/co/glr/hr/activity/ActivityLogSummaryDto.java` +- `backend/src/main/java/th/co/glr/hr/activity/AppEvent.java` +- `backend/src/main/java/th/co/glr/hr/activity/AppEventBuffer.java` +- `backend/src/main/java/th/co/glr/hr/activity/AppEventDto.java` +- `backend/src/main/java/th/co/glr/hr/activity/AppEventWriter.java` +- `backend/src/main/java/th/co/glr/hr/activity/AuditEventDto.java` +- `backend/src/main/java/th/co/glr/hr/activity/DatabaseLogAppender.java` +- `backend/src/main/java/th/co/glr/hr/activity/ScheduledJobEventConfig.java` +### `th.co.glr.hr.audit` — [audit/](https://github.com/waritctd/GL-R-ERP/blob/develop/backend/src/main/java/th/co/glr/hr/audit) +- `backend/src/main/java/th/co/glr/hr/audit/AuditLogRepository.java` +- `backend/src/main/java/th/co/glr/hr/audit/AuditService.java` + +## Frontend +### `features/activityLog` — [activityLog/](https://github.com/waritctd/GL-R-ERP/blob/develop/frontend/src/features/activityLog) +- `frontend/src/features/activityLog/ActivityLogPage.jsx` +- `frontend/src/features/activityLog/actionLabels.js` + +## DB +- Schemas: `hr` + +## Related +[[auth-session|Auth & Session]] + +## Gotchas +- ActivityLogFilter wraps every request; it needs the JDBC template, so a DB/Flyway failure aborts the whole app at boot. + +> Authoritative details + history live in `CLAUDE.md` / `AGENTS.md` at the repo root. diff --git a/docs/repo-map/domains/attendance.md b/docs/repo-map/domains/attendance.md new file mode 100644 index 000000000..2ec3d4647 --- /dev/null +++ b/docs/repo-map/domains/attendance.md @@ -0,0 +1,128 @@ +--- +title: Attendance +layer: HR +schemas: [hr] +tags: [repo-map, domain/attendance] +synced: develop@fe5138dd +--- +# Attendance +> Punch clock, attendance records, corrections, and the work calendar. + +**Layer:** HR · **DB schemas:** `hr` + +## Backend +### `th.co.glr.hr.attendance` — [attendance/](https://github.com/waritctd/GL-R-ERP/blob/develop/backend/src/main/java/th/co/glr/hr/attendance) +- `backend/src/main/java/th/co/glr/hr/attendance/AttendanceCardBackfillRequest.java` +- `backend/src/main/java/th/co/glr/hr/attendance/AttendanceCardBackfillResponse.java` +- `backend/src/main/java/th/co/glr/hr/attendance/AttendanceController.java` +- `backend/src/main/java/th/co/glr/hr/attendance/AttendanceDailyResponse.java` +- `backend/src/main/java/th/co/glr/hr/attendance/AttendanceDatImportRequest.java` +- `backend/src/main/java/th/co/glr/hr/attendance/AttendanceDatParser.java` +- `backend/src/main/java/th/co/glr/hr/attendance/AttendanceDeviceCredential.java` +- `backend/src/main/java/th/co/glr/hr/attendance/AttendanceDeviceDto.java` +- `backend/src/main/java/th/co/glr/hr/attendance/AttendanceDevicesResponse.java` +- `backend/src/main/java/th/co/glr/hr/attendance/AttendanceEmployeesResponse.java` +- `backend/src/main/java/th/co/glr/hr/attendance/AttendanceImportErrorRecord.java` +- `backend/src/main/java/th/co/glr/hr/attendance/AttendanceImportResponse.java` +- `backend/src/main/java/th/co/glr/hr/attendance/AttendanceMarkPresentRequest.java` +- `backend/src/main/java/th/co/glr/hr/attendance/AttendanceMarkPresentResponse.java` +- `backend/src/main/java/th/co/glr/hr/attendance/AttendanceMonthlySummaryExporter.java` +- `backend/src/main/java/th/co/glr/hr/attendance/AttendanceMonthlySummaryResult.java` +- `backend/src/main/java/th/co/glr/hr/attendance/AttendanceMonthlySummaryRow.java` +- `backend/src/main/java/th/co/glr/hr/attendance/AttendanceMonthlySummaryService.java` +- `backend/src/main/java/th/co/glr/hr/attendance/AttendancePunchDto.java` +- `backend/src/main/java/th/co/glr/hr/attendance/AttendancePunchFilter.java` +- `backend/src/main/java/th/co/glr/hr/attendance/AttendancePunchRequest.java` +- `backend/src/main/java/th/co/glr/hr/attendance/AttendancePunchResponse.java` +- `backend/src/main/java/th/co/glr/hr/attendance/AttendancePunchesResponse.java` +- `backend/src/main/java/th/co/glr/hr/attendance/AttendanceRecalculateRequest.java` +- `backend/src/main/java/th/co/glr/hr/attendance/AttendanceRecalculateResponse.java` +- `backend/src/main/java/th/co/glr/hr/attendance/AttendanceRepository.java` +- `backend/src/main/java/th/co/glr/hr/attendance/AttendanceScope.java` +- `backend/src/main/java/th/co/glr/hr/attendance/AttendanceService.java` +- `backend/src/main/java/th/co/glr/hr/attendance/AttendanceSql.java` +- `backend/src/main/java/th/co/glr/hr/attendance/AttendanceUnmappedResponse.java` +- `backend/src/main/java/th/co/glr/hr/attendance/DatParseResult.java` +- `backend/src/main/java/th/co/glr/hr/attendance/LeaveContribution.java` +- `backend/src/main/java/th/co/glr/hr/attendance/NormalizedAttendancePunch.java` +- `backend/src/main/java/th/co/glr/hr/attendance/RotateAgentTokenResponse.java` +- `backend/src/main/java/th/co/glr/hr/attendance/correction/AttendanceCorrectionController.java` +- `backend/src/main/java/th/co/glr/hr/attendance/correction/AttendanceCorrectionFilter.java` +- `backend/src/main/java/th/co/glr/hr/attendance/correction/AttendanceCorrectionRepository.java` +- `backend/src/main/java/th/co/glr/hr/attendance/correction/AttendanceCorrectionRequestDto.java` +- `backend/src/main/java/th/co/glr/hr/attendance/correction/AttendanceCorrectionService.java` +- `backend/src/main/java/th/co/glr/hr/attendance/correction/AttendanceCorrectionStatus.java` +- `backend/src/main/java/th/co/glr/hr/attendance/correction/AttendanceCorrectionType.java` +- `backend/src/main/java/th/co/glr/hr/attendance/correction/ReviewAttendanceCorrectionRequest.java` +- `backend/src/main/java/th/co/glr/hr/attendance/correction/SubmitAttendanceCorrectionRequest.java` +- `backend/src/main/java/th/co/glr/hr/attendance/daily/AttendanceDailyCalculator.java` +- `backend/src/main/java/th/co/glr/hr/attendance/daily/AttendanceDailyDto.java` +- `backend/src/main/java/th/co/glr/hr/attendance/daily/AttendanceDailyFilter.java` +- `backend/src/main/java/th/co/glr/hr/attendance/daily/AttendanceDailyRecalcJob.java` +- `backend/src/main/java/th/co/glr/hr/attendance/daily/AttendanceDailyRecord.java` +- `backend/src/main/java/th/co/glr/hr/attendance/daily/AttendanceDailyRepository.java` +- `backend/src/main/java/th/co/glr/hr/attendance/daily/AttendanceDailyRow.java` +- `backend/src/main/java/th/co/glr/hr/attendance/daily/AttendanceDailyService.java` +- `backend/src/main/java/th/co/glr/hr/attendance/daily/AttendanceDayFlag.java` +- `backend/src/main/java/th/co/glr/hr/attendance/daily/AttendanceDayStatus.java` +- `backend/src/main/java/th/co/glr/hr/attendance/daily/AttendanceEmployeeOption.java` +- `backend/src/main/java/th/co/glr/hr/attendance/daily/AttendanceWfhRosterResult.java` +- `backend/src/main/java/th/co/glr/hr/attendance/daily/EmployeeDay.java` +- `backend/src/main/java/th/co/glr/hr/attendance/daily/PunchRecord.java` +- `backend/src/main/java/th/co/glr/hr/attendance/daily/UnmappedBadge.java` +- `backend/src/main/java/th/co/glr/hr/attendance/schedule/BotHolidayFetchService.java` +- `backend/src/main/java/th/co/glr/hr/attendance/schedule/CompanyWideWorkScheduleResolver.java` +- `backend/src/main/java/th/co/glr/hr/attendance/schedule/DbHolidayCalendar.java` +- `backend/src/main/java/th/co/glr/hr/attendance/schedule/HolidayAdminService.java` +- `backend/src/main/java/th/co/glr/hr/attendance/schedule/HolidayCalendar.java` +- `backend/src/main/java/th/co/glr/hr/attendance/schedule/HolidayController.java` +- `backend/src/main/java/th/co/glr/hr/attendance/schedule/HolidayCreateRequest.java` +- `backend/src/main/java/th/co/glr/hr/attendance/schedule/HolidayDto.java` +- `backend/src/main/java/th/co/glr/hr/attendance/schedule/HolidayRepository.java` +- `backend/src/main/java/th/co/glr/hr/attendance/schedule/HolidayUpdateRequest.java` +- `backend/src/main/java/th/co/glr/hr/attendance/schedule/HolidaysResponse.java` +- `backend/src/main/java/th/co/glr/hr/attendance/schedule/ScheduleAssignment.java` +- `backend/src/main/java/th/co/glr/hr/attendance/schedule/ScopeType.java` +- `backend/src/main/java/th/co/glr/hr/attendance/schedule/TieredWorkScheduleResolver.java` +- `backend/src/main/java/th/co/glr/hr/attendance/schedule/WorkSchedule.java` +- `backend/src/main/java/th/co/glr/hr/attendance/schedule/WorkScheduleAssignmentAdminService.java` +- `backend/src/main/java/th/co/glr/hr/attendance/schedule/WorkScheduleAssignmentController.java` +- `backend/src/main/java/th/co/glr/hr/attendance/schedule/WorkScheduleAssignmentCreateRequest.java` +- `backend/src/main/java/th/co/glr/hr/attendance/schedule/WorkScheduleAssignmentDto.java` +- `backend/src/main/java/th/co/glr/hr/attendance/schedule/WorkScheduleAssignmentEndRequest.java` +- `backend/src/main/java/th/co/glr/hr/attendance/schedule/WorkScheduleAssignmentRepository.java` +- `backend/src/main/java/th/co/glr/hr/attendance/schedule/WorkScheduleAssignmentsResponse.java` +- `backend/src/main/java/th/co/glr/hr/attendance/schedule/WorkScheduleController.java` +- `backend/src/main/java/th/co/glr/hr/attendance/schedule/WorkScheduleResolver.java` +- `backend/src/main/java/th/co/glr/hr/attendance/schedule/WorkScheduleSummaryDto.java` +- `backend/src/main/java/th/co/glr/hr/attendance/schedule/WorkSchedulesResponse.java` + +## Frontend +### `features/attendance` — [attendance/](https://github.com/waritctd/GL-R-ERP/blob/develop/frontend/src/features/attendance) +- `frontend/src/features/attendance/AttendancePage.jsx` +### `features/attendanceCalendar` — [attendanceCalendar/](https://github.com/waritctd/GL-R-ERP/blob/develop/frontend/src/features/attendanceCalendar) +- `frontend/src/features/attendanceCalendar/AssignmentFormModal.jsx` +- `frontend/src/features/attendanceCalendar/AssignmentsTab.jsx` +- `frontend/src/features/attendanceCalendar/AttendanceCalendarPage.jsx` +- `frontend/src/features/attendanceCalendar/HolidayFormModal.jsx` +- `frontend/src/features/attendanceCalendar/HolidaysTab.jsx` +- `frontend/src/features/attendanceCalendar/WorkSchedulesTab.jsx` +- `frontend/src/features/attendanceCalendar/attendanceCalendarTabs.js` +- `frontend/src/features/attendanceCalendar/dateHelpers.js` +- `frontend/src/features/attendanceCalendar/holidayFetch.js` +- `frontend/src/features/attendanceCalendar/scopeTypes.js` +### `features/attendanceCorrection` — [attendanceCorrection/](https://github.com/waritctd/GL-R-ERP/blob/develop/frontend/src/features/attendanceCorrection) +- `frontend/src/features/attendanceCorrection/AttendanceCorrectionPanel.jsx` +- `frontend/src/features/attendanceCorrection/AttendanceCorrectionRequestModal.jsx` + +## DB +- Schemas: `hr` + +## Related +[[overtime|Overtime]] · [[payroll|Payroll]] · [[employee|Employee & Self-service]] + +## Gotchas +- AttendanceScopeIntegrationTest is the reference pattern for real-DB authz scope tests. +- POST /api/attendance/punch is one of the few anonymous-allowed endpoints (SecurityConfig). + +> Authoritative details + history live in `CLAUDE.md` / `AGENTS.md` at the repo root. diff --git a/docs/repo-map/domains/auth-session.md b/docs/repo-map/domains/auth-session.md new file mode 100644 index 000000000..a09cae51f --- /dev/null +++ b/docs/repo-map/domains/auth-session.md @@ -0,0 +1,74 @@ +--- +title: Auth & Session +layer: Platform +schemas: [hr] +tags: [repo-map, domain/auth-session] +synced: develop@fe5138dd +--- +# Auth & Session +> Login, session (JDBC-backed), CSRF, password reset, and the security filter chain. + +**Layer:** Platform · **DB schemas:** `hr` + +## Backend +### `th.co.glr.hr.auth` — [auth/](https://github.com/waritctd/GL-R-ERP/blob/develop/backend/src/main/java/th/co/glr/hr/auth) +- `backend/src/main/java/th/co/glr/hr/auth/ApplicationRoles.java` +- `backend/src/main/java/th/co/glr/hr/auth/AuthController.java` +- `backend/src/main/java/th/co/glr/hr/auth/AuthResponse.java` +- `backend/src/main/java/th/co/glr/hr/auth/AuthService.java` +- `backend/src/main/java/th/co/glr/hr/auth/CachedBodyHttpServletRequest.java` +- `backend/src/main/java/th/co/glr/hr/auth/ChangePasswordRequest.java` +- `backend/src/main/java/th/co/glr/hr/auth/DealEntryAccess.java` +- `backend/src/main/java/th/co/glr/hr/auth/DivisionAccessPolicy.java` +- `backend/src/main/java/th/co/glr/hr/auth/EmployeeAuthRepository.java` +- `backend/src/main/java/th/co/glr/hr/auth/EmployeeLoginRecord.java` +- `backend/src/main/java/th/co/glr/hr/auth/ForgotPasswordRequest.java` +- `backend/src/main/java/th/co/glr/hr/auth/LoginAttemptTracker.java` +- `backend/src/main/java/th/co/glr/hr/auth/LoginRateLimitFilter.java` +- `backend/src/main/java/th/co/glr/hr/auth/LoginRequest.java` +- `backend/src/main/java/th/co/glr/hr/auth/MessageResponse.java` +- `backend/src/main/java/th/co/glr/hr/auth/MustChangePasswordFilter.java` +- `backend/src/main/java/th/co/glr/hr/auth/PasswordBackfillRunner.java` +- `backend/src/main/java/th/co/glr/hr/auth/PasswordResetService.java` +- `backend/src/main/java/th/co/glr/hr/auth/PasswordResetTokenCodec.java` +- `backend/src/main/java/th/co/glr/hr/auth/PasswordResetTokenRepository.java` +- `backend/src/main/java/th/co/glr/hr/auth/ResetPasswordRequest.java` +- `backend/src/main/java/th/co/glr/hr/auth/SessionAccountResolver.java` +- `backend/src/main/java/th/co/glr/hr/auth/SessionContext.java` +- `backend/src/main/java/th/co/glr/hr/auth/SessionSecurityFilter.java` +- `backend/src/main/java/th/co/glr/hr/auth/TemporaryPasswordGenerator.java` +- `backend/src/main/java/th/co/glr/hr/auth/UserPrincipal.java` +### `th.co.glr.hr.config` — [config/](https://github.com/waritctd/GL-R-ERP/blob/develop/backend/src/main/java/th/co/glr/hr/config) +- `backend/src/main/java/th/co/glr/hr/config/AppProperties.java` +- `backend/src/main/java/th/co/glr/hr/config/CorrelationIdFilter.java` +- `backend/src/main/java/th/co/glr/hr/config/CorsConfig.java` +- `backend/src/main/java/th/co/glr/hr/config/CsrfCookieFilter.java` +- `backend/src/main/java/th/co/glr/hr/config/FlywayEagerInitConfig.java` +- `backend/src/main/java/th/co/glr/hr/config/JacksonConfig.java` +- `backend/src/main/java/th/co/glr/hr/config/OpenApiConfig.java` +- `backend/src/main/java/th/co/glr/hr/config/PasswordConfig.java` +- `backend/src/main/java/th/co/glr/hr/config/ProductionReadinessConfig.java` +- `backend/src/main/java/th/co/glr/hr/config/SchedulingConfig.java` +- `backend/src/main/java/th/co/glr/hr/config/SecurityConfig.java` +- `backend/src/main/java/th/co/glr/hr/config/SecurityHeadersFilter.java` + +## Frontend +### `features/auth` — [auth/](https://github.com/waritctd/GL-R-ERP/blob/develop/frontend/src/features/auth) +- `frontend/src/features/auth/ChangePasswordModal.jsx` +- `frontend/src/features/auth/ForgotPasswordPage.jsx` +- `frontend/src/features/auth/LoginPage.jsx` +- `frontend/src/features/auth/ResetPasswordPage.jsx` + +## DB +- Schemas: `hr` +- Key migrations: `V19 spring_session` + +## Related +[[employee|Employee & Self-service]] + +## Gotchas +- SecurityConfig only authenticates (default-deny); per-role/business authz lives in the SERVICE layer (requireWrite / isDealOwner / role checks), NOT the filter chain — a mock that skips it can diverge from the real 403. +- CSRF is app-rolled (CsrfCookieFilter), NOT Spring Security — do not 're-enable' Spring CSRF. +- Real AuthService refuses {role}-only login with 403; demo accounts are email+password (Demo@2026). + +> Authoritative details + history live in `CLAUDE.md` / `AGENTS.md` at the repo root. diff --git a/docs/repo-map/domains/catalog-price-import.md b/docs/repo-map/domains/catalog-price-import.md new file mode 100644 index 000000000..fa9ac2714 --- /dev/null +++ b/docs/repo-map/domains/catalog-price-import.md @@ -0,0 +1,52 @@ +--- +title: Catalog & Price Import +layer: Sales / CRM +schemas: [price_catalog] +tags: [repo-map, domain/catalog-price-import] +synced: develop@fe5138dd +--- +# Catalog & Price Import +> The product price catalogue (import-managed, version-controlled) + brand reference. + +**Layer:** Sales / CRM · **DB schemas:** `price_catalog` + +## Backend +### `th.co.glr.hr.catalog` — [catalog/](https://github.com/waritctd/GL-R-ERP/blob/develop/backend/src/main/java/th/co/glr/hr/catalog) +- `backend/src/main/java/th/co/glr/hr/catalog/CatalogController.java` +- `backend/src/main/java/th/co/glr/hr/catalog/CatalogDto.java` +- `backend/src/main/java/th/co/glr/hr/catalog/CatalogRepository.java` +- `backend/src/main/java/th/co/glr/hr/catalog/ProductPriceDto.java` +- `backend/src/main/java/th/co/glr/hr/catalog/ProductPriceInput.java` +- `backend/src/main/java/th/co/glr/hr/catalog/ThicknessDefaultController.java` +- `backend/src/main/java/th/co/glr/hr/catalog/ThicknessDefaultDtos.java` +- `backend/src/main/java/th/co/glr/hr/catalog/ThicknessDefaultRepository.java` +- `backend/src/main/java/th/co/glr/hr/catalog/ThicknessDefaultRequests.java` +- `backend/src/main/java/th/co/glr/hr/catalog/importer/ImportEngine.java` +- `backend/src/main/java/th/co/glr/hr/catalog/importer/ImportProfile.java` +- `backend/src/main/java/th/co/glr/hr/catalog/importer/ImportResult.java` +- `backend/src/main/java/th/co/glr/hr/catalog/importer/PriceImportController.java` +- `backend/src/main/java/th/co/glr/hr/catalog/importer/PriceImportService.java` +- `backend/src/main/java/th/co/glr/hr/catalog/importer/PriceRow.java` +### `th.co.glr.hr.brand` — [brand/](https://github.com/waritctd/GL-R-ERP/blob/develop/backend/src/main/java/th/co/glr/hr/brand) +- `backend/src/main/java/th/co/glr/hr/brand/BrandAssets.java` +- `backend/src/main/java/th/co/glr/hr/brand/BrandController.java` + +## Frontend +### `features/catalog` — [catalog/](https://github.com/waritctd/GL-R-ERP/blob/develop/frontend/src/features/catalog) +- `frontend/src/features/catalog/CatalogSearchPage.jsx` +- `frontend/src/features/catalog/PriceImportPage.jsx` +- `frontend/src/features/catalog/ProductFormModal.jsx` + +## DB +- Schemas: `price_catalog` +- Key migrations: `V153 priceable basis + active-version read`; `V171 import provenance`; `V172 size norm` + +## Related +[[pricing-flow|Pricing Flow]] · [[quotation|Quotation]] + +## Gotchas +- Catalogue reads filter price_list_versions.status = 'ACTIVE' (V153); non-active rows are invisible to pricing. +- price_catalog.product_prices is the price list; sales.catalog is a SEPARATE brand reference — different ids. +- product_prices.price_per_sqm is a GENERATED column; ~41% of real rows have NULL thickness_mm. + +> Authoritative details + history live in `CLAUDE.md` / `AGENTS.md` at the repo root. diff --git a/docs/repo-map/domains/customer.md b/docs/repo-map/domains/customer.md new file mode 100644 index 000000000..d23e16a49 --- /dev/null +++ b/docs/repo-map/domains/customer.md @@ -0,0 +1,41 @@ +--- +title: Customer +layer: Sales / CRM +schemas: [customers] +tags: [repo-map, domain/customer] +synced: develop@fe5138dd +--- +# Customer +> Customers, projects, contacts, designers, and Thai-address handling. + +**Layer:** Sales / CRM · **DB schemas:** `customers` + +## Backend +### `th.co.glr.hr.customer` — [customer/](https://github.com/waritctd/GL-R-ERP/blob/develop/backend/src/main/java/th/co/glr/hr/customer) +- `backend/src/main/java/th/co/glr/hr/customer/ContactDto.java` +- `backend/src/main/java/th/co/glr/hr/customer/ContactRepository.java` +- `backend/src/main/java/th/co/glr/hr/customer/CustomerController.java` +- `backend/src/main/java/th/co/glr/hr/customer/CustomerDto.java` +- `backend/src/main/java/th/co/glr/hr/customer/CustomerRepository.java` +- `backend/src/main/java/th/co/glr/hr/customer/CustomerService.java` +- `backend/src/main/java/th/co/glr/hr/customer/ProjectDto.java` +- `backend/src/main/java/th/co/glr/hr/customer/ProjectRepository.java` +### `th.co.glr.hr.designer` — [designer/](https://github.com/waritctd/GL-R-ERP/blob/develop/backend/src/main/java/th/co/glr/hr/designer) +- `backend/src/main/java/th/co/glr/hr/designer/DesignerController.java` +- `backend/src/main/java/th/co/glr/hr/designer/DesignerDto.java` +- `backend/src/main/java/th/co/glr/hr/designer/DesignerRepository.java` + +## Frontend +_(no dedicated frontend feature — UI lives inside a related domain)_ + +## DB +- Schemas: `customers` +- Key migrations: `V32 demo ticket customers (demo)` + +## Related +[[sales-deal-ticket|Sales Deal & Ticket]] · [[quotation|Quotation]] + +## Gotchas +- Customer UI lives inside tickets/quotations features rather than a standalone feature dir. + +> Authoritative details + history live in `CLAUDE.md` / `AGENTS.md` at the repo root. diff --git a/docs/repo-map/domains/dashboard.md b/docs/repo-map/domains/dashboard.md new file mode 100644 index 000000000..005cccd3c --- /dev/null +++ b/docs/repo-map/domains/dashboard.md @@ -0,0 +1,50 @@ +--- +title: Dashboard +layer: Platform +schemas: [hr, sales] +tags: [repo-map, domain/dashboard] +synced: develop@fe5138dd +--- +# Dashboard +> Cross-domain rollups and the landing dashboards. + +**Layer:** Platform · **DB schemas:** `hr, sales` + +## Backend +### `th.co.glr.hr.dashboard` — [dashboard/](https://github.com/waritctd/GL-R-ERP/blob/develop/backend/src/main/java/th/co/glr/hr/dashboard) +- `backend/src/main/java/th/co/glr/hr/dashboard/AttendanceSummaryDto.java` +- `backend/src/main/java/th/co/glr/hr/dashboard/DashboardController.java` +- `backend/src/main/java/th/co/glr/hr/dashboard/DashboardPendingVisibility.java` +- `backend/src/main/java/th/co/glr/hr/dashboard/DashboardQueryScope.java` +- `backend/src/main/java/th/co/glr/hr/dashboard/DashboardRepository.java` +- `backend/src/main/java/th/co/glr/hr/dashboard/DashboardScopeType.java` +- `backend/src/main/java/th/co/glr/hr/dashboard/DashboardService.java` +- `backend/src/main/java/th/co/glr/hr/dashboard/DashboardSummaryDto.java` +- `backend/src/main/java/th/co/glr/hr/dashboard/DivisionHeadcountDto.java` +- `backend/src/main/java/th/co/glr/hr/dashboard/HeadcountSummaryDto.java` +- `backend/src/main/java/th/co/glr/hr/dashboard/NotificationSummaryDto.java` +- `backend/src/main/java/th/co/glr/hr/dashboard/PendingApprovalsSummaryDto.java` +- `backend/src/main/java/th/co/glr/hr/dashboard/TicketSummaryDto.java` + +## Frontend +### `features/dashboard` — [dashboard/](https://github.com/waritctd/GL-R-ERP/blob/develop/frontend/src/features/dashboard) +- `frontend/src/features/dashboard/AccountOverview.jsx` +- `frontend/src/features/dashboard/ActionQueue.jsx` +- `frontend/src/features/dashboard/CeoOverview.jsx` +- `frontend/src/features/dashboard/DivisionManagerOverview.jsx` +- `frontend/src/features/dashboard/EmployeeDashboard.jsx` +- `frontend/src/features/dashboard/EmployeeSelfService.jsx` +- `frontend/src/features/dashboard/HrDashboard.jsx` +- `frontend/src/features/dashboard/HrOverview.jsx` +- `frontend/src/features/dashboard/ImportOverview.jsx` +- `frontend/src/features/dashboard/ManagerOverview.jsx` +- `frontend/src/features/dashboard/SalesOverview.jsx` + +## DB +- Schemas: `hr, sales` + +## Related +[[deposit-billing-finance|Deposit · Billing · Finance]] · [[sales-deal-ticket|Sales Deal & Ticket]] + +## Gotchas +_(none recorded)_ diff --git a/docs/repo-map/domains/deposit-billing-finance.md b/docs/repo-map/domains/deposit-billing-finance.md new file mode 100644 index 000000000..b2c9c2e60 --- /dev/null +++ b/docs/repo-map/domains/deposit-billing-finance.md @@ -0,0 +1,158 @@ +--- +title: Deposit · Billing · Finance +layer: Sales / CRM +schemas: [sales, hr] +tags: [repo-map, domain/deposit-billing-finance] +synced: develop@fe5138dd +--- +# Deposit · Billing · Finance +> Deposit notices, billing notes, tax invoices, payments, and sales commission. + +**Layer:** Sales / CRM · **DB schemas:** `sales, hr` + +## Backend +### `th.co.glr.hr.deposit` — [deposit/](https://github.com/waritctd/GL-R-ERP/blob/develop/backend/src/main/java/th/co/glr/hr/deposit) +- `backend/src/main/java/th/co/glr/hr/deposit/DepositNoticeController.java` +- `backend/src/main/java/th/co/glr/hr/deposit/DepositNoticeDraftRequest.java` +- `backend/src/main/java/th/co/glr/hr/deposit/DepositNoticeDto.java` +- `backend/src/main/java/th/co/glr/hr/deposit/DepositNoticeItemDto.java` +- `backend/src/main/java/th/co/glr/hr/deposit/DepositNoticeItemRequest.java` +- `backend/src/main/java/th/co/glr/hr/deposit/DepositNoticeRenderer.java` +- `backend/src/main/java/th/co/glr/hr/deposit/DepositNoticeRepository.java` +- `backend/src/main/java/th/co/glr/hr/deposit/DepositNoticeService.java` +- `backend/src/main/java/th/co/glr/hr/deposit/DocumentNoteTemplateDto.java` +- `backend/src/main/java/th/co/glr/hr/deposit/RemainingInvoiceController.java` +- `backend/src/main/java/th/co/glr/hr/deposit/RemainingInvoiceDocumentDto.java` +- `backend/src/main/java/th/co/glr/hr/deposit/RemainingInvoiceDocumentItemDto.java` +- `backend/src/main/java/th/co/glr/hr/deposit/RemainingInvoiceDraftRequest.java` +- `backend/src/main/java/th/co/glr/hr/deposit/RemainingInvoiceDto.java` +- `backend/src/main/java/th/co/glr/hr/deposit/RemainingInvoiceItemDto.java` +- `backend/src/main/java/th/co/glr/hr/deposit/RemainingInvoiceOptionsDto.java` +- `backend/src/main/java/th/co/glr/hr/deposit/RemainingInvoiceRenderer.java` +- `backend/src/main/java/th/co/glr/hr/deposit/RemainingInvoiceRepository.java` +- `backend/src/main/java/th/co/glr/hr/deposit/RemainingInvoiceService.java` +- `backend/src/main/java/th/co/glr/hr/deposit/RevisionRequest.java` +- `backend/src/main/java/th/co/glr/hr/deposit/RevisionScope.java` +### `th.co.glr.hr.billing` — [billing/](https://github.com/waritctd/GL-R-ERP/blob/develop/backend/src/main/java/th/co/glr/hr/billing) +- `backend/src/main/java/th/co/glr/hr/billing/BillingNoteCancelRequest.java` +- `backend/src/main/java/th/co/glr/hr/billing/BillingNoteCandidateDto.java` +- `backend/src/main/java/th/co/glr/hr/billing/BillingNoteCandidatesDto.java` +- `backend/src/main/java/th/co/glr/hr/billing/BillingNoteController.java` +- `backend/src/main/java/th/co/glr/hr/billing/BillingNoteDocumentDto.java` +- `backend/src/main/java/th/co/glr/hr/billing/BillingNoteDraftRequest.java` +- `backend/src/main/java/th/co/glr/hr/billing/BillingNoteDto.java` +- `backend/src/main/java/th/co/glr/hr/billing/BillingNoteLineDto.java` +- `backend/src/main/java/th/co/glr/hr/billing/BillingNoteLineRenderDto.java` +- `backend/src/main/java/th/co/glr/hr/billing/BillingNoteLineSelection.java` +- `backend/src/main/java/th/co/glr/hr/billing/BillingNoteMarkReceivedRequest.java` +- `backend/src/main/java/th/co/glr/hr/billing/BillingNoteRenderer.java` +- `backend/src/main/java/th/co/glr/hr/billing/BillingNoteRepository.java` +- `backend/src/main/java/th/co/glr/hr/billing/BillingNoteService.java` +### `th.co.glr.hr.finance` — [finance/](https://github.com/waritctd/GL-R-ERP/blob/develop/backend/src/main/java/th/co/glr/hr/finance) +- `backend/src/main/java/th/co/glr/hr/finance/FinanceDealController.java` +- `backend/src/main/java/th/co/glr/hr/finance/FinanceDealDto.java` +- `backend/src/main/java/th/co/glr/hr/finance/FinanceDealRepository.java` +- `backend/src/main/java/th/co/glr/hr/finance/FinanceDealService.java` +### `th.co.glr.hr.specialmoney` — [specialmoney/](https://github.com/waritctd/GL-R-ERP/blob/develop/backend/src/main/java/th/co/glr/hr/specialmoney) +- `backend/src/main/java/th/co/glr/hr/specialmoney/CapRule.java` +- `backend/src/main/java/th/co/glr/hr/specialmoney/ClaimWindow.java` +- `backend/src/main/java/th/co/glr/hr/specialmoney/EligibilityRule.java` +- `backend/src/main/java/th/co/glr/hr/specialmoney/EmployeeEligibilitySnapshot.java` +- `backend/src/main/java/th/co/glr/hr/specialmoney/PolicyAmounts.java` +- `backend/src/main/java/th/co/glr/hr/specialmoney/PolicyDecision.java` +- `backend/src/main/java/th/co/glr/hr/specialmoney/ReviewSpecialMoneyRequest.java` +- `backend/src/main/java/th/co/glr/hr/specialmoney/SpecialMoneyApprovalPreviewDto.java` +- `backend/src/main/java/th/co/glr/hr/specialmoney/SpecialMoneyAttachmentDto.java` +- `backend/src/main/java/th/co/glr/hr/specialmoney/SpecialMoneyBucket.java` +- `backend/src/main/java/th/co/glr/hr/specialmoney/SpecialMoneyController.java` +- `backend/src/main/java/th/co/glr/hr/specialmoney/SpecialMoneyEmployeeOption.java` +- `backend/src/main/java/th/co/glr/hr/specialmoney/SpecialMoneyFilter.java` +- `backend/src/main/java/th/co/glr/hr/specialmoney/SpecialMoneyPolicyEvaluator.java` +- `backend/src/main/java/th/co/glr/hr/specialmoney/SpecialMoneyRepository.java` +- `backend/src/main/java/th/co/glr/hr/specialmoney/SpecialMoneyRequestDto.java` +- `backend/src/main/java/th/co/glr/hr/specialmoney/SpecialMoneyResponses.java` +- `backend/src/main/java/th/co/glr/hr/specialmoney/SpecialMoneyService.java` +- `backend/src/main/java/th/co/glr/hr/specialmoney/SpecialMoneyStatus.java` +- `backend/src/main/java/th/co/glr/hr/specialmoney/SpecialMoneyType.java` +- `backend/src/main/java/th/co/glr/hr/specialmoney/SpecialMoneyTypeOption.java` +- `backend/src/main/java/th/co/glr/hr/specialmoney/SpecialMoneyUsageDto.java` +- `backend/src/main/java/th/co/glr/hr/specialmoney/SubmitSpecialMoneyHttpRequest.java` +- `backend/src/main/java/th/co/glr/hr/specialmoney/SubmitSpecialMoneyRequest.java` +- `backend/src/main/java/th/co/glr/hr/specialmoney/UsageSnapshot.java` +### `th.co.glr.hr.commission` — [commission/](https://github.com/waritctd/GL-R-ERP/blob/develop/backend/src/main/java/th/co/glr/hr/commission) +- `backend/src/main/java/th/co/glr/hr/commission/CommissionAttachmentRepository.java` +- `backend/src/main/java/th/co/glr/hr/commission/CommissionCalculator.java` +- `backend/src/main/java/th/co/glr/hr/commission/CommissionController.java` +- `backend/src/main/java/th/co/glr/hr/commission/CommissionKind.java` +- `backend/src/main/java/th/co/glr/hr/commission/CommissionMonthlySummaryDto.java` +- `backend/src/main/java/th/co/glr/hr/commission/CommissionRecord.java` +- `backend/src/main/java/th/co/glr/hr/commission/CommissionRepOptionDto.java` +- `backend/src/main/java/th/co/glr/hr/commission/CommissionRepository.java` +- `backend/src/main/java/th/co/glr/hr/commission/CommissionResponses.java` +- `backend/src/main/java/th/co/glr/hr/commission/CommissionService.java` +- `backend/src/main/java/th/co/glr/hr/commission/CommissionSimulationDto.java` +- `backend/src/main/java/th/co/glr/hr/commission/CommissionSimulatorRequest.java` +- `backend/src/main/java/th/co/glr/hr/commission/CommissionStatus.java` +- `backend/src/main/java/th/co/glr/hr/commission/CommissionTierRowDto.java` +- `backend/src/main/java/th/co/glr/hr/commission/CreateClawbackRequest.java` +- `backend/src/main/java/th/co/glr/hr/commission/IncentiveTierConfig.java` +- `backend/src/main/java/th/co/glr/hr/commission/InvoiceCalculation.java` +- `backend/src/main/java/th/co/glr/hr/commission/InvoiceDetails.java` +- `backend/src/main/java/th/co/glr/hr/commission/ManualCommissionRequest.java` +- `backend/src/main/java/th/co/glr/hr/commission/PayrollCommissionSummaryDto.java` +- `backend/src/main/java/th/co/glr/hr/commission/PendingCommissionDto.java` +- `backend/src/main/java/th/co/glr/hr/commission/PendingCommissionItemDto.java` +- `backend/src/main/java/th/co/glr/hr/commission/QuotationDisplayNameEligibility.java` +- `backend/src/main/java/th/co/glr/hr/commission/ReviewCommissionRequest.java` +- `backend/src/main/java/th/co/glr/hr/commission/SalesRepCommissionSummaryDto.java` +- `backend/src/main/java/th/co/glr/hr/commission/StockBonusConfig.java` +- `backend/src/main/java/th/co/glr/hr/commission/SubmitCommissionRequest.java` +- `backend/src/main/java/th/co/glr/hr/commission/TeamOverrideConfig.java` +- `backend/src/main/java/th/co/glr/hr/commission/TeamOverrideRecipient.java` +- `backend/src/main/java/th/co/glr/hr/commission/TierConfig.java` +- `backend/src/main/java/th/co/glr/hr/commission/UpdateCommissionDeductionsRequest.java` + +## Frontend +### `features/deposits` — [deposits/](https://github.com/waritctd/GL-R-ERP/blob/develop/frontend/src/features/deposits) +- `frontend/src/features/deposits/DepositNoticePage.jsx` +### `features/finance` — [finance/](https://github.com/waritctd/GL-R-ERP/blob/develop/frontend/src/features/finance) +- `frontend/src/features/finance/AccountFinancePage.jsx` +- `frontend/src/features/finance/FinanceActionModals.jsx` +- `frontend/src/features/finance/FinanceDealPage.jsx` +- `frontend/src/features/finance/FinanceMilestoneSections.jsx` +- `frontend/src/features/finance/dealHref.js` +- `frontend/src/features/finance/financeDealActions.js` +- `frontend/src/features/finance/moneyMilestone.js` +### `features/commissions` — [commissions/](https://github.com/waritctd/GL-R-ERP/blob/develop/frontend/src/features/commissions) +- `frontend/src/features/commissions/CommissionPage.jsx` +- `frontend/src/features/commissions/CommissionStatement.jsx` +- `frontend/src/features/commissions/InvoiceFromDealForm.jsx` +- `frontend/src/features/commissions/ReceiptChain.jsx` +- `frontend/src/features/commissions/ReceiptLedger.jsx` +- `frontend/src/features/commissions/WeightSegmented.jsx` +- `frontend/src/features/commissions/WeightingDesk.jsx` +- `frontend/src/features/commissions/commissionRecord.js` +- `frontend/src/features/commissions/invoiceFromDeal.js` +### `features/specialmoney` — [specialmoney/](https://github.com/waritctd/GL-R-ERP/blob/develop/frontend/src/features/specialmoney) +- `frontend/src/features/specialmoney/ApproveSpecialMoneyDialog.jsx` +- `frontend/src/features/specialmoney/AttachmentList.jsx` +- `frontend/src/features/specialmoney/EntitlementPanel.jsx` +- `frontend/src/features/specialmoney/RuleCard.jsx` +- `frontend/src/features/specialmoney/SpecialMoneyPanel.jsx` +- `frontend/src/features/specialmoney/TypePicker.jsx` +- `frontend/src/features/specialmoney/specialMoneyRules.js` +- `frontend/src/features/specialmoney/thaiProvinces.js` + +## DB +- Schemas: `sales, hr` +- Key migrations: `V72 pricing_decision`; `V188 remaining invoice document`; `V189 billing note document` + +## Related +[[sales-deal-ticket|Sales Deal & Ticket]] · [[quotation|Quotation]] + +## Gotchas +- DepositNoticeService.issue gates on ticket.status='quotation_issued' AND paymentStatus='CUSTOMER_CONFIRMED' — else 409. +- Commission math is UNTOUCHABLE without explicit request (CLAUDE.md scope rule). +- PDF/XLSX render is a 'not supported in mock mode' stub — documents only truly render on the real backend. + +> Authoritative details + history live in `CLAUDE.md` / `AGENTS.md` at the repo root. diff --git a/docs/repo-map/domains/employee.md b/docs/repo-map/domains/employee.md new file mode 100644 index 000000000..671196f91 --- /dev/null +++ b/docs/repo-map/domains/employee.md @@ -0,0 +1,80 @@ +--- +title: Employee & Self-service +layer: HR +schemas: [hr, hr_restricted] +tags: [repo-map, domain/employee] +synced: develop@fe5138dd +--- +# Employee & Self-service +> Employee master, profile, and self-service edit/requests (incl. tax allowance). + +**Layer:** HR · **DB schemas:** `hr, hr_restricted` + +## Backend +### `th.co.glr.hr.employee` — [employee/](https://github.com/waritctd/GL-R-ERP/blob/develop/backend/src/main/java/th/co/glr/hr/employee) +- `backend/src/main/java/th/co/glr/hr/employee/AddressDto.java` +- `backend/src/main/java/th/co/glr/hr/employee/AssignmentDto.java` +- `backend/src/main/java/th/co/glr/hr/employee/EmergencyContactDto.java` +- `backend/src/main/java/th/co/glr/hr/employee/EmployeeCodeGenerator.java` +- `backend/src/main/java/th/co/glr/hr/employee/EmployeeController.java` +- `backend/src/main/java/th/co/glr/hr/employee/EmployeeDto.java` +- `backend/src/main/java/th/co/glr/hr/employee/EmployeeFilter.java` +- `backend/src/main/java/th/co/glr/hr/employee/EmployeeReferenceRepository.java` +- `backend/src/main/java/th/co/glr/hr/employee/EmployeeRepository.java` +- `backend/src/main/java/th/co/glr/hr/employee/EmployeeResponses.java` +- `backend/src/main/java/th/co/glr/hr/employee/EmployeeService.java` +- `backend/src/main/java/th/co/glr/hr/employee/EmployeeStatus.java` +- `backend/src/main/java/th/co/glr/hr/employee/ManagerApproverRepository.java` +- `backend/src/main/java/th/co/glr/hr/employee/PasswordResetResult.java` +- `backend/src/main/java/th/co/glr/hr/employee/SalaryHistoryDto.java` +- `backend/src/main/java/th/co/glr/hr/employee/SensitiveDto.java` +- `backend/src/main/java/th/co/glr/hr/employee/UpsertEmployeeRequest.java` +### `th.co.glr.hr.profile` — [profile/](https://github.com/waritctd/GL-R-ERP/blob/develop/backend/src/main/java/th/co/glr/hr/profile) +- `backend/src/main/java/th/co/glr/hr/profile/CreateProfileRequestRequest.java` +- `backend/src/main/java/th/co/glr/hr/profile/ProfileRequestController.java` +- `backend/src/main/java/th/co/glr/hr/profile/ProfileRequestDto.java` +- `backend/src/main/java/th/co/glr/hr/profile/ProfileRequestRecord.java` +- `backend/src/main/java/th/co/glr/hr/profile/ProfileRequestRepository.java` +- `backend/src/main/java/th/co/glr/hr/profile/ProfileRequestResponses.java` +- `backend/src/main/java/th/co/glr/hr/profile/ProfileRequestService.java` +- `backend/src/main/java/th/co/glr/hr/profile/UpdateProfileRequestRequest.java` + +## Frontend +### `features/employees` — [employees/](https://github.com/waritctd/GL-R-ERP/blob/develop/frontend/src/features/employees) +- `frontend/src/features/employees/EmployeeDetailPage.jsx` +- `frontend/src/features/employees/EmployeeFormModal.jsx` +- `frontend/src/features/employees/EmployeeListPage.jsx` +- `frontend/src/features/employees/ResetPasswordDialog.jsx` +### `features/profile` — [profile/](https://github.com/waritctd/GL-R-ERP/blob/develop/frontend/src/features/profile) +- `frontend/src/features/profile/ChangeRequestModal.jsx` +- `frontend/src/features/profile/ProfilePage.jsx` +- `frontend/src/features/profile/SignatureCard.jsx` +### `features/profileRequests` — [profileRequests/](https://github.com/waritctd/GL-R-ERP/blob/develop/frontend/src/features/profileRequests) +- `frontend/src/features/profileRequests/ProfileRequestsPage.jsx` +### `features/requests` — [requests/](https://github.com/waritctd/GL-R-ERP/blob/develop/frontend/src/features/requests) +- `frontend/src/features/requests/RequestsPage.jsx` +### `features/taxAllowance` — [taxAllowance/](https://github.com/waritctd/GL-R-ERP/blob/develop/frontend/src/features/taxAllowance) +- `frontend/src/features/taxAllowance/TaxAllowanceActionRow.jsx` +- `frontend/src/features/taxAllowance/TaxAllowanceBreakdown.jsx` +- `frontend/src/features/taxAllowance/TaxAllowanceDrilldown.jsx` +- `frontend/src/features/taxAllowance/TaxAllowanceEvidenceCount.jsx` +- `frontend/src/features/taxAllowance/TaxAllowanceEvidencePanel.jsx` +- `frontend/src/features/taxAllowance/TaxAllowanceForm.jsx` +- `frontend/src/features/taxAllowance/TaxAllowancePage.jsx` +- `frontend/src/features/taxAllowance/TaxAllowanceReviewPage.jsx` +- `frontend/src/features/taxAllowance/TaxAllowanceSummaryPanel.jsx` +- `frontend/src/features/taxAllowance/taxAllowanceCaps.js` +- `frontend/src/features/taxAllowance/taxAllowanceSchema.js` +- `frontend/src/features/taxAllowance/taxAllowanceStatus.js` + +## DB +- Schemas: `hr, hr_restricted` +- Key migrations: `V139 missing-role personas + hire dates (demo)` + +## Related +[[attendance|Attendance]] · [[leave|Leave]] · [[overtime|Overtime]] · [[payroll|Payroll]] · [[auth-session|Auth & Session]] + +## Gotchas +- DivisionAccessPolicy.roleFor derives a user's role from their division — role is NOT stored directly. + +> Authoritative details + history live in `CLAUDE.md` / `AGENTS.md` at the repo root. diff --git a/docs/repo-map/domains/import-fulfilment.md b/docs/repo-map/domains/import-fulfilment.md new file mode 100644 index 000000000..ea471cbb2 --- /dev/null +++ b/docs/repo-map/domains/import-fulfilment.md @@ -0,0 +1,65 @@ +--- +title: Import / Fulfilment +layer: Sales / CRM +schemas: [sales] +tags: [repo-map, domain/import-fulfilment] +synced: develop@fe5138dd +--- +# Import / Fulfilment +> Per-factory import requests, procurement steps, and delivery fulfilment. + +**Layer:** Sales / CRM · **DB schemas:** `sales` + +## Backend +### `th.co.glr.hr.importdeal` — [importdeal/](https://github.com/waritctd/GL-R-ERP/blob/develop/backend/src/main/java/th/co/glr/hr/importdeal) +- `backend/src/main/java/th/co/glr/hr/importdeal/ImportDealController.java` +- `backend/src/main/java/th/co/glr/hr/importdeal/ImportDealDtos.java` +- `backend/src/main/java/th/co/glr/hr/importdeal/ImportDealService.java` +### `th.co.glr.hr.importrequest` — [importrequest/](https://github.com/waritctd/GL-R-ERP/blob/develop/backend/src/main/java/th/co/glr/hr/importrequest) +- `backend/src/main/java/th/co/glr/hr/importrequest/ImportRequestController.java` +- `backend/src/main/java/th/co/glr/hr/importrequest/ImportRequestDtos.java` +- `backend/src/main/java/th/co/glr/hr/importrequest/ImportRequestFormAssembler.java` +- `backend/src/main/java/th/co/glr/hr/importrequest/ImportRequestFormData.java` +- `backend/src/main/java/th/co/glr/hr/importrequest/ImportRequestLinePrefill.java` +- `backend/src/main/java/th/co/glr/hr/importrequest/ImportRequestQueryRepository.java` +- `backend/src/main/java/th/co/glr/hr/importrequest/ImportRequestRenderer.java` +- `backend/src/main/java/th/co/glr/hr/importrequest/ImportRequestRepository.java` +- `backend/src/main/java/th/co/glr/hr/importrequest/ImportRequestRequests.java` +- `backend/src/main/java/th/co/glr/hr/importrequest/ImportRequestService.java` +- `backend/src/main/java/th/co/glr/hr/importrequest/ImportRequestStatus.java` +- `backend/src/main/java/th/co/glr/hr/importrequest/ImportRequestStep.java` +- `backend/src/main/java/th/co/glr/hr/importrequest/LeadTimeDefaults.java` +### `th.co.glr.hr.procurement` — [procurement/](https://github.com/waritctd/GL-R-ERP/blob/develop/backend/src/main/java/th/co/glr/hr/procurement) +- `backend/src/main/java/th/co/glr/hr/procurement/FactoryPurchaseOrderStatus.java` +- `backend/src/main/java/th/co/glr/hr/procurement/ProcurementDtos.java` +- `backend/src/main/java/th/co/glr/hr/procurement/ProcurementRepository.java` +- `backend/src/main/java/th/co/glr/hr/procurement/ProcurementRequests.java` +- `backend/src/main/java/th/co/glr/hr/procurement/ProcurementService.java` +### `th.co.glr.hr.factory` — [factory/](https://github.com/waritctd/GL-R-ERP/blob/develop/backend/src/main/java/th/co/glr/hr/factory) +- `backend/src/main/java/th/co/glr/hr/factory/FactoryConfigController.java` +- `backend/src/main/java/th/co/glr/hr/factory/FactoryConfigDto.java` +- `backend/src/main/java/th/co/glr/hr/factory/FactoryConfigRepository.java` + +## Frontend +### `features/fulfilment` — [fulfilment/](https://github.com/waritctd/GL-R-ERP/blob/develop/frontend/src/features/fulfilment) +- `frontend/src/features/fulfilment/ImportFulfilmentPage.jsx` +### `features/importDeal` — [importDeal/](https://github.com/waritctd/GL-R-ERP/blob/develop/frontend/src/features/importDeal) +- `frontend/src/features/importDeal/ImportDealPage.jsx` +### `features/importProgress` — [importProgress/](https://github.com/waritctd/GL-R-ERP/blob/develop/frontend/src/features/importProgress) +- `frontend/src/features/importProgress/FactoryProgressBar.jsx` +- `frontend/src/features/importProgress/ImportStatusStrip.jsx` +- `frontend/src/features/importProgress/importSteps.js` + +## DB +- Schemas: `sales` +- Key migrations: `V77 factory_purchase_order`; `V184 per-factory import_request (sales.import_request)` + +## Related +[[sales-deal-ticket|Sales Deal & Ticket]] · [[pricing-flow|Pricing Flow]] + +## Gotchas +- TWO parallel import systems: legacy deal-level (fulfillmentStatus IR_ISSUED) vs per-factory V184 (sales.import_request). +- A deal is in the import role's list scope (TicketRepository.appendRoleScope) when it is NOT closed/lost/cancelled AND (a non-terminal pricing request exists OR salesStage >= ORDER_RECEIVED). No fulfillmentStatus condition; the old PROCUREMENT floor was removed as self-defeating. +- An item must be linked to a factory before it enters the import lane. + +> Authoritative details + history live in `CLAUDE.md` / `AGENTS.md` at the repo root. diff --git a/docs/repo-map/domains/leave.md b/docs/repo-map/domains/leave.md new file mode 100644 index 000000000..67fcc894b --- /dev/null +++ b/docs/repo-map/domains/leave.md @@ -0,0 +1,90 @@ +--- +title: Leave +layer: HR +schemas: [hr] +tags: [repo-map, domain/leave] +synced: develop@fe5138dd +--- +# Leave +> Leave requests, balances, policy rules, and approval. + +**Layer:** HR · **DB schemas:** `hr` + +## Backend +### `th.co.glr.hr.leave` — [leave/](https://github.com/waritctd/GL-R-ERP/blob/develop/backend/src/main/java/th/co/glr/hr/leave) +- `backend/src/main/java/th/co/glr/hr/leave/ApprovedLeaveSpanDto.java` +- `backend/src/main/java/th/co/glr/hr/leave/EmployeeLeaveSpan.java` +- `backend/src/main/java/th/co/glr/hr/leave/LeaveAttachmentDto.java` +- `backend/src/main/java/th/co/glr/hr/leave/LeaveAttachmentRepository.java` +- `backend/src/main/java/th/co/glr/hr/leave/LeaveBalanceDto.java` +- `backend/src/main/java/th/co/glr/hr/leave/LeaveCalendarContextDto.java` +- `backend/src/main/java/th/co/glr/hr/leave/LeaveCalendarContextService.java` +- `backend/src/main/java/th/co/glr/hr/leave/LeaveCalendarHolidayDto.java` +- `backend/src/main/java/th/co/glr/hr/leave/LeaveCalendarWorkScheduleDto.java` +- `backend/src/main/java/th/co/glr/hr/leave/LeaveContactDefaultsDto.java` +- `backend/src/main/java/th/co/glr/hr/leave/LeaveController.java` +- `backend/src/main/java/th/co/glr/hr/leave/LeaveDayCountBasis.java` +- `backend/src/main/java/th/co/glr/hr/leave/LeaveDayMath.java` +- `backend/src/main/java/th/co/glr/hr/leave/LeaveEmployeeAccess.java` +- `backend/src/main/java/th/co/glr/hr/leave/LeaveEmployeeOption.java` +- `backend/src/main/java/th/co/glr/hr/leave/LeaveFilter.java` +- `backend/src/main/java/th/co/glr/hr/leave/LeaveFormData.java` +- `backend/src/main/java/th/co/glr/hr/leave/LeaveFormHtmlRenderer.java` +- `backend/src/main/java/th/co/glr/hr/leave/LeaveFormPdf.java` +- `backend/src/main/java/th/co/glr/hr/leave/LeaveFormRenderer.java` +- `backend/src/main/java/th/co/glr/hr/leave/LeavePolicyDocumentDto.java` +- `backend/src/main/java/th/co/glr/hr/leave/LeavePolicyDocumentRepository.java` +- `backend/src/main/java/th/co/glr/hr/leave/LeavePreviewCounters.java` +- `backend/src/main/java/th/co/glr/hr/leave/LeavePreviewDepth.java` +- `backend/src/main/java/th/co/glr/hr/leave/LeavePreviewDto.java` +- `backend/src/main/java/th/co/glr/hr/leave/LeavePreviewRequest.java` +- `backend/src/main/java/th/co/glr/hr/leave/LeaveQuotaPoolPreference.java` +- `backend/src/main/java/th/co/glr/hr/leave/LeaveQuotaYearSplit.java` +- `backend/src/main/java/th/co/glr/hr/leave/LeaveReportEmployeeDto.java` +- `backend/src/main/java/th/co/glr/hr/leave/LeaveReportRenderer.java` +- `backend/src/main/java/th/co/glr/hr/leave/LeaveRepository.java` +- `backend/src/main/java/th/co/glr/hr/leave/LeaveRequestDto.java` +- `backend/src/main/java/th/co/glr/hr/leave/LeaveRequestSpan.java` +- `backend/src/main/java/th/co/glr/hr/leave/LeaveResponses.java` +- `backend/src/main/java/th/co/glr/hr/leave/LeaveReviewSummaryDto.java` +- `backend/src/main/java/th/co/glr/hr/leave/LeaveRuleCode.java` +- `backend/src/main/java/th/co/glr/hr/leave/LeaveRuleEnforcement.java` +- `backend/src/main/java/th/co/glr/hr/leave/LeaveRuleMessages.java` +- `backend/src/main/java/th/co/glr/hr/leave/LeaveRuleOutcome.java` +- `backend/src/main/java/th/co/glr/hr/leave/LeaveRuleWarningDto.java` +- `backend/src/main/java/th/co/glr/hr/leave/LeaveService.java` +- `backend/src/main/java/th/co/glr/hr/leave/LeaveStatus.java` +- `backend/src/main/java/th/co/glr/hr/leave/LeaveSubmissionMailer.java` +- `backend/src/main/java/th/co/glr/hr/leave/LeaveTeamMemberBalanceDto.java` +- `backend/src/main/java/th/co/glr/hr/leave/LeaveTypeDto.java` +- `backend/src/main/java/th/co/glr/hr/leave/ReviewLeaveRequest.java` +- `backend/src/main/java/th/co/glr/hr/leave/SubmitLeaveRequest.java` + +## Frontend +### `features/leave` — [leave/](https://github.com/waritctd/GL-R-ERP/blob/develop/frontend/src/features/leave) +- `frontend/src/features/leave/LeaveFilterBar.jsx` +- `frontend/src/features/leave/LeavePolicyBar.jsx` +- `frontend/src/features/leave/LeavePolicyDocumentPage.jsx` +- `frontend/src/features/leave/LeaveReportDownload.jsx` +- `frontend/src/features/leave/LeaveRequestPage.jsx` +- `frontend/src/features/leave/LeaveRulePanel.jsx` +- `frontend/src/features/leave/LeaveSurfacePage.jsx` +- `frontend/src/features/leave/MyLeaveTab.jsx` +- `frontend/src/features/leave/ReviewQueueTab.jsx` +- `frontend/src/features/leave/TeamLeaveTab.jsx` +- `frontend/src/features/leave/TeamQuotaSummary.jsx` +- `frontend/src/features/leave/UpcomingLeaveList.jsx` +- `frontend/src/features/leave/leaveFormatting.js` +- `frontend/src/features/leave/leaveRequestTable.jsx` +- `frontend/src/features/leave/leaveSurfaceTabs.js` + +## DB +- Schemas: `hr` + +## Related +[[employee|Employee & Self-service]] · [[payroll|Payroll]] · [[attendance|Attendance]] + +## Gotchas +- Leave/quota math is business-logic-sensitive — re-check against the spec before PR (CLAUDE.md). + +> Authoritative details + history live in `CLAUDE.md` / `AGENTS.md` at the repo root. diff --git a/docs/repo-map/domains/notification-mail.md b/docs/repo-map/domains/notification-mail.md new file mode 100644 index 000000000..7f16fe68c --- /dev/null +++ b/docs/repo-map/domains/notification-mail.md @@ -0,0 +1,48 @@ +--- +title: Notification & Mail +layer: Platform +schemas: [hr] +tags: [repo-map, domain/notification-mail] +synced: develop@fe5138dd +--- +# Notification & Mail +> In-app notifications and outbound email (quotations, resets, alerts). + +**Layer:** Platform · **DB schemas:** `hr` + +## Backend +### `th.co.glr.hr.notification` — [notification/](https://github.com/waritctd/GL-R-ERP/blob/develop/backend/src/main/java/th/co/glr/hr/notification) +- `backend/src/main/java/th/co/glr/hr/notification/AfterCommit.java` +- `backend/src/main/java/th/co/glr/hr/notification/CeoApproverRepository.java` +- `backend/src/main/java/th/co/glr/hr/notification/CeoApproverRule.java` +- `backend/src/main/java/th/co/glr/hr/notification/EmailRecipient.java` +- `backend/src/main/java/th/co/glr/hr/notification/NotificationController.java` +- `backend/src/main/java/th/co/glr/hr/notification/NotificationDto.java` +- `backend/src/main/java/th/co/glr/hr/notification/NotificationEmailService.java` +- `backend/src/main/java/th/co/glr/hr/notification/NotificationRepository.java` +- `backend/src/main/java/th/co/glr/hr/notification/NotificationService.java` +- `backend/src/main/java/th/co/glr/hr/notification/SalesMailRecipientRepository.java` +- `backend/src/main/java/th/co/glr/hr/notification/SalesNotificationMailRouter.java` +- `backend/src/main/java/th/co/glr/hr/notification/SalesNotificationMailer.java` +### `th.co.glr.hr.mail` — [mail/](https://github.com/waritctd/GL-R-ERP/blob/develop/backend/src/main/java/th/co/glr/hr/mail) +- `backend/src/main/java/th/co/glr/hr/mail/LogMailer.java` +- `backend/src/main/java/th/co/glr/hr/mail/MailOverrideBeanPostProcessor.java` +- `backend/src/main/java/th/co/glr/hr/mail/MailSendException.java` +- `backend/src/main/java/th/co/glr/hr/mail/Mailer.java` +- `backend/src/main/java/th/co/glr/hr/mail/OverrideRedirectingMailer.java` +- `backend/src/main/java/th/co/glr/hr/mail/ResendMailer.java` +- `backend/src/main/java/th/co/glr/hr/mail/SmtpMailer.java` + +## Frontend +_(no dedicated frontend feature — UI lives inside a related domain)_ + +## DB +- Schemas: `hr` + +## Related +[[auth-session|Auth & Session]] · [[deposit-billing-finance|Deposit · Billing · Finance]] · [[pricing-flow|Pricing Flow]] + +## Gotchas +- SalesNotificationMailer.NO_OP is used in tests / when SMTP is unconfigured — no mail actually sends. + +> Authoritative details + history live in `CLAUDE.md` / `AGENTS.md` at the repo root. diff --git a/docs/repo-map/domains/overtime.md b/docs/repo-map/domains/overtime.md new file mode 100644 index 000000000..1d34a3228 --- /dev/null +++ b/docs/repo-map/domains/overtime.md @@ -0,0 +1,46 @@ +--- +title: Overtime +layer: HR +schemas: [hr] +tags: [repo-map, domain/overtime] +synced: develop@fe5138dd +--- +# Overtime +> Overtime filing and the two-step (manager → CEO) approval chain. + +**Layer:** HR · **DB schemas:** `hr` + +## Backend +### `th.co.glr.hr.overtime` — [overtime/](https://github.com/waritctd/GL-R-ERP/blob/develop/backend/src/main/java/th/co/glr/hr/overtime) +- `backend/src/main/java/th/co/glr/hr/overtime/ApproveOvertimeRequest.java` +- `backend/src/main/java/th/co/glr/hr/overtime/OvertimeAttendanceBounds.java` +- `backend/src/main/java/th/co/glr/hr/overtime/OvertimeCalculation.java` +- `backend/src/main/java/th/co/glr/hr/overtime/OvertimeController.java` +- `backend/src/main/java/th/co/glr/hr/overtime/OvertimeDayType.java` +- `backend/src/main/java/th/co/glr/hr/overtime/OvertimeEmployeeAccess.java` +- `backend/src/main/java/th/co/glr/hr/overtime/OvertimeEmployeeOption.java` +- `backend/src/main/java/th/co/glr/hr/overtime/OvertimeFilter.java` +- `backend/src/main/java/th/co/glr/hr/overtime/OvertimeRepository.java` +- `backend/src/main/java/th/co/glr/hr/overtime/OvertimeRequestDto.java` +- `backend/src/main/java/th/co/glr/hr/overtime/OvertimeResponses.java` +- `backend/src/main/java/th/co/glr/hr/overtime/OvertimeService.java` +- `backend/src/main/java/th/co/glr/hr/overtime/OvertimeStatus.java` +- `backend/src/main/java/th/co/glr/hr/overtime/ReviewOvertimeRequest.java` +- `backend/src/main/java/th/co/glr/hr/overtime/SubmitOvertimeRequest.java` + +## Frontend +### `features/overtime` — [overtime/](https://github.com/waritctd/GL-R-ERP/blob/develop/frontend/src/features/overtime) +- `frontend/src/features/overtime/OvertimePage.jsx` +- `frontend/src/features/overtime/OvertimePanel.jsx` + +## DB +- Schemas: `hr` + +## Related +[[attendance|Attendance]] · [[payroll|Payroll]] · [[employee|Employee & Self-service]] + +## Gotchas +- Issue #199: mock let HR approve OT but real OvertimeService returns 403 — verify authz on the real service. +- day_type / pay multiplier derive from hr.holiday, never from the client's claim. + +> Authoritative details + history live in `CLAUDE.md` / `AGENTS.md` at the repo root. diff --git a/docs/repo-map/domains/payroll.md b/docs/repo-map/domains/payroll.md new file mode 100644 index 000000000..2f370eb23 --- /dev/null +++ b/docs/repo-map/domains/payroll.md @@ -0,0 +1,101 @@ +--- +title: Payroll +layer: HR +schemas: [hr, hr_restricted] +tags: [repo-map, domain/payroll] +synced: develop@fe5138dd +--- +# Payroll +> Payroll runs, deductions, consents, tax, and the payroll draft etag. + +**Layer:** HR · **DB schemas:** `hr, hr_restricted` + +## Backend +### `th.co.glr.hr.payroll` — [payroll/](https://github.com/waritctd/GL-R-ERP/blob/develop/backend/src/main/java/th/co/glr/hr/payroll) +- `backend/src/main/java/th/co/glr/hr/payroll/DeductionCapGroup.java` +- `backend/src/main/java/th/co/glr/hr/payroll/DeductionRuleClass.java` +- `backend/src/main/java/th/co/glr/hr/payroll/PayrollAllowanceEstimateResult.java` +- `backend/src/main/java/th/co/glr/hr/payroll/PayrollCalculation.java` +- `backend/src/main/java/th/co/glr/hr/payroll/PayrollCalculationInput.java` +- `backend/src/main/java/th/co/glr/hr/payroll/PayrollCalculator.java` +- `backend/src/main/java/th/co/glr/hr/payroll/PayrollCarryForwardDtos.java` +- `backend/src/main/java/th/co/glr/hr/payroll/PayrollClassificationDtos.java` +- `backend/src/main/java/th/co/glr/hr/payroll/PayrollClassifiedCalculationDtos.java` +- `backend/src/main/java/th/co/glr/hr/payroll/PayrollComponent.java` +- `backend/src/main/java/th/co/glr/hr/payroll/PayrollController.java` +- `backend/src/main/java/th/co/glr/hr/payroll/PayrollDeductionClassification.java` +- `backend/src/main/java/th/co/glr/hr/payroll/PayrollDeductionKind.java` +- `backend/src/main/java/th/co/glr/hr/payroll/PayrollDeductionOrderingPolicy.java` +- `backend/src/main/java/th/co/glr/hr/payroll/PayrollDraftETag.java` +- `backend/src/main/java/th/co/glr/hr/payroll/PayrollEmployeeInputRequest.java` +- `backend/src/main/java/th/co/glr/hr/payroll/PayrollEmployeeSnapshot.java` +- `backend/src/main/java/th/co/glr/hr/payroll/PayrollGarnishmentType.java` +- `backend/src/main/java/th/co/glr/hr/payroll/PayrollInputDraftDtos.java` +- `backend/src/main/java/th/co/glr/hr/payroll/PayrollLineDto.java` +- `backend/src/main/java/th/co/glr/hr/payroll/PayrollPeriodDto.java` +- `backend/src/main/java/th/co/glr/hr/payroll/PayrollReconciliationDtos.java` +- `backend/src/main/java/th/co/glr/hr/payroll/PayrollRepository.java` +- `backend/src/main/java/th/co/glr/hr/payroll/PayrollResponses.java` +- `backend/src/main/java/th/co/glr/hr/payroll/PayrollService.java` +- `backend/src/main/java/th/co/glr/hr/payroll/PayrollSpecialPayDto.java` +- `backend/src/main/java/th/co/glr/hr/payroll/PayrollTaxAllowanceInput.java` +- `backend/src/main/java/th/co/glr/hr/payroll/PayrollTaxTreatment.java` +- `backend/src/main/java/th/co/glr/hr/payroll/PayrollYearToDate.java` +- `backend/src/main/java/th/co/glr/hr/payroll/PayslipDistributionResponse.java` +- `backend/src/main/java/th/co/glr/hr/payroll/PayslipDistributionService.java` +- `backend/src/main/java/th/co/glr/hr/payroll/PayslipRenderer.java` +- `backend/src/main/java/th/co/glr/hr/payroll/PerDiemBasis.java` +- `backend/src/main/java/th/co/glr/hr/payroll/ProcessPayrollRequest.java` +- `backend/src/main/java/th/co/glr/hr/payroll/declaration/TaxAllowanceAttachmentController.java` +- `backend/src/main/java/th/co/glr/hr/payroll/declaration/TaxAllowanceCapCatalog.java` +- `backend/src/main/java/th/co/glr/hr/payroll/declaration/TaxAllowanceDeclarationController.java` +- `backend/src/main/java/th/co/glr/hr/payroll/declaration/TaxAllowanceDeclarationDtos.java` +- `backend/src/main/java/th/co/glr/hr/payroll/declaration/TaxAllowanceDeclarationRepository.java` +- `backend/src/main/java/th/co/glr/hr/payroll/declaration/TaxAllowanceDeclarationService.java` +- `backend/src/main/java/th/co/glr/hr/payroll/declaration/TaxAllowanceDeclarationStatus.java` +- `backend/src/main/java/th/co/glr/hr/payroll/declaration/TaxAllowanceExpiryWorker.java` +- `backend/src/main/java/th/co/glr/hr/payroll/declaration/loryor01/LorYor01FormAssembler.java` +- `backend/src/main/java/th/co/glr/hr/payroll/declaration/loryor01/LorYor01FormData.java` +- `backend/src/main/java/th/co/glr/hr/payroll/declaration/loryor01/LorYor01Renderer.java` +- `backend/src/main/java/th/co/glr/hr/payroll/export/Cp874.java` +- `backend/src/main/java/th/co/glr/hr/payroll/export/KBankPctExporter.java` +- `backend/src/main/java/th/co/glr/hr/payroll/export/PayrollDetailExporter.java` +- `backend/src/main/java/th/co/glr/hr/payroll/export/PayrollDetailIdentityDto.java` +- `backend/src/main/java/th/co/glr/hr/payroll/export/PayrollExportFile.java` +- `backend/src/main/java/th/co/glr/hr/payroll/export/PayrollExportKind.java` +- `backend/src/main/java/th/co/glr/hr/payroll/export/PayrollExportRow.java` +- `backend/src/main/java/th/co/glr/hr/payroll/export/Pnd1Exporter.java` +- `backend/src/main/java/th/co/glr/hr/payroll/export/SsoExporter.java` +- `backend/src/main/java/th/co/glr/hr/payroll/obligation/DeductionObligationController.java` +- `backend/src/main/java/th/co/glr/hr/payroll/obligation/DeductionObligationDtos.java` +- `backend/src/main/java/th/co/glr/hr/payroll/obligation/DeductionObligationKind.java` +- `backend/src/main/java/th/co/glr/hr/payroll/obligation/DeductionObligationRepository.java` +- `backend/src/main/java/th/co/glr/hr/payroll/obligation/DeductionObligationService.java` +- `backend/src/main/java/th/co/glr/hr/payroll/obligation/DeductionObligationStatus.java` +- `backend/src/main/java/th/co/glr/hr/payroll/obligation/DeductionWrittenConsentController.java` +- `backend/src/main/java/th/co/glr/hr/payroll/obligation/DeductionWrittenConsentDtos.java` +- `backend/src/main/java/th/co/glr/hr/payroll/obligation/DeductionWrittenConsentRepository.java` +- `backend/src/main/java/th/co/glr/hr/payroll/obligation/DeductionWrittenConsentService.java` +- `backend/src/main/java/th/co/glr/hr/payroll/obligation/PayrollDeductionShortfallController.java` +- `backend/src/main/java/th/co/glr/hr/payroll/obligation/PayrollDeductionShortfallDtos.java` +- `backend/src/main/java/th/co/glr/hr/payroll/obligation/PayrollDeductionShortfallRepository.java` +- `backend/src/main/java/th/co/glr/hr/payroll/obligation/PayrollDeductionShortfallService.java` + +## Frontend +### `features/payroll` — [payroll/](https://github.com/waritctd/GL-R-ERP/blob/develop/frontend/src/features/payroll) +- `frontend/src/features/payroll/DeductionConsentFormModal.jsx` +- `frontend/src/features/payroll/DeductionConsentsPage.jsx` +- `frontend/src/features/payroll/DeductionShortfallsPage.jsx` +- `frontend/src/features/payroll/PayrollPage.jsx` + +## DB +- Schemas: `hr, hr_restricted` + +## Related +[[employee|Employee & Self-service]] · [[attendance|Attendance]] · [[overtime|Overtime]] · [[leave|Leave]] + +## Gotchas +- Payroll/tax math is UNTOUCHABLE without explicit request (CLAUDE.md). +- computeDraftEtag is mirrored in the mock — a mock-driven test can't validate the algorithm, only plumbing. + +> Authoritative details + history live in `CLAUDE.md` / `AGENTS.md` at the repo root. diff --git a/docs/repo-map/domains/platform-common.md b/docs/repo-map/domains/platform-common.md new file mode 100644 index 000000000..8708adbee --- /dev/null +++ b/docs/repo-map/domains/platform-common.md @@ -0,0 +1,55 @@ +--- +title: Platform / Common +layer: Platform +schemas: [hr] +tags: [repo-map, domain/platform-common] +synced: develop@fe5138dd +--- +# Platform / Common +> Shared infra: common utils, file attachments, and locations. + +**Layer:** Platform · **DB schemas:** `hr` + +## Backend +### `th.co.glr.hr.common` — [common/](https://github.com/waritctd/GL-R-ERP/blob/develop/backend/src/main/java/th/co/glr/hr/common) +- `backend/src/main/java/th/co/glr/hr/common/ApiException.java` +- `backend/src/main/java/th/co/glr/hr/common/ApiExceptionHandler.java` +- `backend/src/main/java/th/co/glr/hr/common/ArGlrSequence.java` +- `backend/src/main/java/th/co/glr/hr/common/ChromiumPdfPrinter.java` +- `backend/src/main/java/th/co/glr/hr/common/ImageDecodability.java` +- `backend/src/main/java/th/co/glr/hr/common/LibreOfficePdfConverter.java` +- `backend/src/main/java/th/co/glr/hr/common/Page.java` +- `backend/src/main/java/th/co/glr/hr/common/PageRequest.java` +- `backend/src/main/java/th/co/glr/hr/common/PdfDocumentWriter.java` +- `backend/src/main/java/th/co/glr/hr/common/ThaiText.java` +- `backend/src/main/java/th/co/glr/hr/common/sheet/FontResolver.java` +- `backend/src/main/java/th/co/glr/hr/common/sheet/LibreOfficeMetrics.java` +- `backend/src/main/java/th/co/glr/hr/common/sheet/SheetHtmlRenderer.java` +- `backend/src/main/java/th/co/glr/hr/common/sheet/SheetPlan.java` +### `th.co.glr.hr.attachment` — [attachment/](https://github.com/waritctd/GL-R-ERP/blob/develop/backend/src/main/java/th/co/glr/hr/attachment) +- `backend/src/main/java/th/co/glr/hr/attachment/AttachmentController.java` +- `backend/src/main/java/th/co/glr/hr/attachment/AttachmentDto.java` +- `backend/src/main/java/th/co/glr/hr/attachment/AttachmentRepository.java` +- `backend/src/main/java/th/co/glr/hr/attachment/FileAttachmentBlobRepository.java` +- `backend/src/main/java/th/co/glr/hr/attachment/FileAttachmentDiskBackfillRunner.java` +- `backend/src/main/java/th/co/glr/hr/attachment/FileStorageService.java` +### `th.co.glr.hr.location` — [location/](https://github.com/waritctd/GL-R-ERP/blob/develop/backend/src/main/java/th/co/glr/hr/location) +- `backend/src/main/java/th/co/glr/hr/location/ThaiAddress.java` +- `backend/src/main/java/th/co/glr/hr/location/ThaiLocationController.java` +- `backend/src/main/java/th/co/glr/hr/location/ThaiLocationRepository.java` + +## Frontend +### `features/locations` — [locations/](https://github.com/waritctd/GL-R-ERP/blob/develop/frontend/src/features/locations) +- `frontend/src/features/locations/ThaiAddressFields.jsx` +- `frontend/src/features/locations/thaiAddress.js` + +## DB +- Schemas: `hr` + +## Related +[[auth-session|Auth & Session]] + +## Gotchas +- common/ holds ApiException, PdfDocumentWriter, ThaiText, ArGlrSequence — reused everywhere. + +> Authoritative details + history live in `CLAUDE.md` / `AGENTS.md` at the repo root. diff --git a/docs/repo-map/domains/pricing-flow.md b/docs/repo-map/domains/pricing-flow.md new file mode 100644 index 000000000..347c77bbd --- /dev/null +++ b/docs/repo-map/domains/pricing-flow.md @@ -0,0 +1,101 @@ +--- +title: Pricing Flow +layer: Sales / CRM +schemas: [sales, price_catalog] +tags: [repo-map, domain/pricing-flow] +synced: develop@fe5138dd +--- +# Pricing Flow +> Pricing request → factory quote → landed cost → CEO selling-price decision. + +**Layer:** Sales / CRM · **DB schemas:** `sales, price_catalog` + +## Backend +### `th.co.glr.hr.pricingrequest` — [pricingrequest/](https://github.com/waritctd/GL-R-ERP/blob/develop/backend/src/main/java/th/co/glr/hr/pricingrequest) +- `backend/src/main/java/th/co/glr/hr/pricingrequest/LeadTimeChangeController.java` +- `backend/src/main/java/th/co/glr/hr/pricingrequest/LeadTimeChangeDtos.java` +- `backend/src/main/java/th/co/glr/hr/pricingrequest/LeadTimeChangeRepository.java` +- `backend/src/main/java/th/co/glr/hr/pricingrequest/LeadTimeChangeRequests.java` +- `backend/src/main/java/th/co/glr/hr/pricingrequest/LeadTimeChangeService.java` +- `backend/src/main/java/th/co/glr/hr/pricingrequest/LeadTimeChangeStatus.java` +- `backend/src/main/java/th/co/glr/hr/pricingrequest/PricingRequestController.java` +- `backend/src/main/java/th/co/glr/hr/pricingrequest/PricingRequestDtos.java` +- `backend/src/main/java/th/co/glr/hr/pricingrequest/PricingRequestEventKind.java` +- `backend/src/main/java/th/co/glr/hr/pricingrequest/PricingRequestRecipient.java` +- `backend/src/main/java/th/co/glr/hr/pricingrequest/PricingRequestRepository.java` +- `backend/src/main/java/th/co/glr/hr/pricingrequest/PricingRequestRequests.java` +- `backend/src/main/java/th/co/glr/hr/pricingrequest/PricingRequestResponses.java` +- `backend/src/main/java/th/co/glr/hr/pricingrequest/PricingRequestService.java` +- `backend/src/main/java/th/co/glr/hr/pricingrequest/PricingRequestStatus.java` +- `backend/src/main/java/th/co/glr/hr/pricingrequest/QuantityType.java` +- `backend/src/main/java/th/co/glr/hr/pricingrequest/UnitBasis.java` +- `backend/src/main/java/th/co/glr/hr/pricingrequest/UnitBasisMetaController.java` +### `th.co.glr.hr.pricingcosting` — [pricingcosting/](https://github.com/waritctd/GL-R-ERP/blob/develop/backend/src/main/java/th/co/glr/hr/pricingcosting) +- `backend/src/main/java/th/co/glr/hr/pricingcosting/LandedCostCalculator.java` +- `backend/src/main/java/th/co/glr/hr/pricingcosting/PricingCostingController.java` +- `backend/src/main/java/th/co/glr/hr/pricingcosting/PricingCostingDtos.java` +- `backend/src/main/java/th/co/glr/hr/pricingcosting/PricingCostingRepository.java` +- `backend/src/main/java/th/co/glr/hr/pricingcosting/PricingCostingRequests.java` +- `backend/src/main/java/th/co/glr/hr/pricingcosting/PricingCostingService.java` +- `backend/src/main/java/th/co/glr/hr/pricingcosting/PricingFormulaEngine.java` +### `th.co.glr.hr.pricingdecision` — [pricingdecision/](https://github.com/waritctd/GL-R-ERP/blob/develop/backend/src/main/java/th/co/glr/hr/pricingdecision) +- `backend/src/main/java/th/co/glr/hr/pricingdecision/PricingDecisionController.java` +- `backend/src/main/java/th/co/glr/hr/pricingdecision/PricingDecisionDtos.java` +- `backend/src/main/java/th/co/glr/hr/pricingdecision/PricingDecisionRepository.java` +- `backend/src/main/java/th/co/glr/hr/pricingdecision/PricingDecisionRequests.java` +- `backend/src/main/java/th/co/glr/hr/pricingdecision/PricingDecisionService.java` +- `backend/src/main/java/th/co/glr/hr/pricingdecision/PricingDecisionStatus.java` +### `th.co.glr.hr.pricing` — [pricing/](https://github.com/waritctd/GL-R-ERP/blob/develop/backend/src/main/java/th/co/glr/hr/pricing) +- `backend/src/main/java/th/co/glr/hr/pricing/BotFxFetchService.java` +- `backend/src/main/java/th/co/glr/hr/pricing/FxRateController.java` +- `backend/src/main/java/th/co/glr/hr/pricing/FxRateDto.java` +- `backend/src/main/java/th/co/glr/hr/pricing/FxRateRepository.java` +- `backend/src/main/java/th/co/glr/hr/pricing/FxResolver.java` +- `backend/src/main/java/th/co/glr/hr/pricing/PriceCalcConfigController.java` +- `backend/src/main/java/th/co/glr/hr/pricing/PriceCalcConfigDto.java` +- `backend/src/main/java/th/co/glr/hr/pricing/PriceCalcConfigRepository.java` +- `backend/src/main/java/th/co/glr/hr/pricing/PricingFormulaConfigController.java` +- `backend/src/main/java/th/co/glr/hr/pricing/PricingFormulaConfigDtos.java` +- `backend/src/main/java/th/co/glr/hr/pricing/PricingFormulaConfigRepository.java` +- `backend/src/main/java/th/co/glr/hr/pricing/PricingFormulaConfigRequests.java` +- `backend/src/main/java/th/co/glr/hr/pricing/UpdatePriceCalcConfigRequest.java` +- `backend/src/main/java/th/co/glr/hr/pricing/UpsertFxRateRequest.java` +### `th.co.glr.hr.factoryquote` — [factoryquote/](https://github.com/waritctd/GL-R-ERP/blob/develop/backend/src/main/java/th/co/glr/hr/factoryquote) +- `backend/src/main/java/th/co/glr/hr/factoryquote/FactoryQuoteCarryForward.java` +- `backend/src/main/java/th/co/glr/hr/factoryquote/FactoryQuoteController.java` +- `backend/src/main/java/th/co/glr/hr/factoryquote/FactoryQuoteDtos.java` +- `backend/src/main/java/th/co/glr/hr/factoryquote/FactoryQuoteRepository.java` +- `backend/src/main/java/th/co/glr/hr/factoryquote/FactoryQuoteRequests.java` +- `backend/src/main/java/th/co/glr/hr/factoryquote/FactoryQuoteService.java` +- `backend/src/main/java/th/co/glr/hr/factoryquote/FactoryQuoteStatus.java` + +## Frontend +### `features/pricingRequests` — [pricingRequests/](https://github.com/waritctd/GL-R-ERP/blob/develop/frontend/src/features/pricingRequests) +- `frontend/src/features/pricingRequests/FactoryContactDialog.jsx` +- `frontend/src/features/pricingRequests/LeadTimeChangeBanner.jsx` +- `frontend/src/features/pricingRequests/LeadTimeChangeDialog.jsx` +- `frontend/src/features/pricingRequests/PricingRequestCreateModal.jsx` +- `frontend/src/features/pricingRequests/PricingRequestDetailPage.jsx` +- `frontend/src/features/pricingRequests/PricingRequestPanel.jsx` +- `frontend/src/features/pricingRequests/PricingRequestQueuePage.jsx` +- `frontend/src/features/pricingRequests/factoryContactMeta.js` +- `frontend/src/features/pricingRequests/pricingRequestMeta.js` +- `frontend/src/features/pricingRequests/quotedPriceThb.js` +- `frontend/src/features/pricingRequests/unitBasisCatalog.js` +### `features/ceoSettings` — [ceoSettings/](https://github.com/waritctd/GL-R-ERP/blob/develop/frontend/src/features/ceoSettings) +- `frontend/src/features/ceoSettings/CeoSettingsPage.jsx` + +## DB +- Schemas: `sales, price_catalog` +- Key migrations: `V61 factory_quote + costing foundation`; `V109 formula engine (landed cost)`; `V141 CEO owns costing`; `V156 uncostable items`; `V185 direct-deal item fields`; `V187 CEO price mode`; `V194 stock lines on pricing request` + +## Related +[[catalog-price-import|Catalog & Price Import]] · [[sales-deal-ticket|Sales Deal & Ticket]] · [[quotation|Quotation]] · [[import-fulfilment|Import / Fulfilment]] + +## Gotchas +- LandedCostCalculator reads thickness/origin from the CATALOG LINK only (+ Sales-entered thickness fallback, 2026-10) — see CLAUDE.md. +- A line with no resolvable thickness/origin/sqm is set ASIDE as 'uncostable' (V156), not a hard 422. +- Mock reimplements SOME pricing math but NOT freight — do not trust mock for pricing. See CLAUDE.md 'Mock API contract'. +- ceoSettings = FX rates + pricing formula config (BOT fetch + manual entry). + +> Authoritative details + history live in `CLAUDE.md` / `AGENTS.md` at the repo root. diff --git a/docs/repo-map/domains/quotation.md b/docs/repo-map/domains/quotation.md new file mode 100644 index 000000000..2b9bde05a --- /dev/null +++ b/docs/repo-map/domains/quotation.md @@ -0,0 +1,76 @@ +--- +title: Quotation +layer: Sales / CRM +schemas: [sales, customers] +tags: [repo-map, domain/quotation] +synced: develop@fe5138dd +--- +# Quotation +> Customer-facing quotation generation, revisions, and the rendered PDF/XLSX. + +**Layer:** Sales / CRM · **DB schemas:** `sales, customers` + +## Backend +### `th.co.glr.hr.dealquotation` — [dealquotation/](https://github.com/waritctd/GL-R-ERP/blob/develop/backend/src/main/java/th/co/glr/hr/dealquotation) +- `backend/src/main/java/th/co/glr/hr/dealquotation/DealQuotationAttachmentController.java` +- `backend/src/main/java/th/co/glr/hr/dealquotation/DealQuotationAttachmentRepository.java` +- `backend/src/main/java/th/co/glr/hr/dealquotation/DealQuotationAttachmentService.java` +- `backend/src/main/java/th/co/glr/hr/dealquotation/DealQuotationController.java` +- `backend/src/main/java/th/co/glr/hr/dealquotation/DealQuotationDtos.java` +- `backend/src/main/java/th/co/glr/hr/dealquotation/DealQuotationLines.java` +- `backend/src/main/java/th/co/glr/hr/dealquotation/DealQuotationRenderAdapter.java` +- `backend/src/main/java/th/co/glr/hr/dealquotation/DealQuotationRepository.java` +- `backend/src/main/java/th/co/glr/hr/dealquotation/DealQuotationRequests.java` +- `backend/src/main/java/th/co/glr/hr/dealquotation/DealQuotationService.java` +- `backend/src/main/java/th/co/glr/hr/dealquotation/EmployeeSignatureController.java` +- `backend/src/main/java/th/co/glr/hr/dealquotation/EmployeeSignatureRepository.java` +- `backend/src/main/java/th/co/glr/hr/dealquotation/EmployeeSignatureService.java` +- `backend/src/main/java/th/co/glr/hr/dealquotation/QuotationHtmlDocument.java` +- `backend/src/main/java/th/co/glr/hr/dealquotation/QuotationItemPictures.java` +- `backend/src/main/java/th/co/glr/hr/dealquotation/WastageCalculator.java` +### `th.co.glr.hr.customerquotation` — [customerquotation/](https://github.com/waritctd/GL-R-ERP/blob/develop/backend/src/main/java/th/co/glr/hr/customerquotation) +- `backend/src/main/java/th/co/glr/hr/customerquotation/CustomerQuotationController.java` +- `backend/src/main/java/th/co/glr/hr/customerquotation/CustomerQuotationDtos.java` +- `backend/src/main/java/th/co/glr/hr/customerquotation/CustomerQuotationRepository.java` +- `backend/src/main/java/th/co/glr/hr/customerquotation/CustomerQuotationRequests.java` +- `backend/src/main/java/th/co/glr/hr/customerquotation/CustomerQuotationService.java` +- `backend/src/main/java/th/co/glr/hr/customerquotation/DiscountApprovalController.java` +- `backend/src/main/java/th/co/glr/hr/customerquotation/DiscountApprovalDtos.java` +- `backend/src/main/java/th/co/glr/hr/customerquotation/DiscountApprovalRepository.java` +- `backend/src/main/java/th/co/glr/hr/customerquotation/DiscountApprovalRequests.java` +- `backend/src/main/java/th/co/glr/hr/customerquotation/DiscountApprovalService.java` +- `backend/src/main/java/th/co/glr/hr/customerquotation/QuotationExpiryWorker.java` + +## Frontend +### `features/quotations` — [quotations/](https://github.com/waritctd/GL-R-ERP/blob/develop/frontend/src/features/quotations) +- `frontend/src/features/quotations/ChangeCustomerModal.jsx` +- `frontend/src/features/quotations/CustomerDetailsFields.jsx` +- `frontend/src/features/quotations/DealCustomerCard.jsx` +- `frontend/src/features/quotations/DealPicker.jsx` +- `frontend/src/features/quotations/DesignerPicker.jsx` +- `frontend/src/features/quotations/QuotationAdjustmentRow.jsx` +- `frontend/src/features/quotations/QuotationAttachmentsPanel.jsx` +- `frontend/src/features/quotations/QuotationChecklist.jsx` +- `frontend/src/features/quotations/QuotationContactPicker.jsx` +- `frontend/src/features/quotations/QuotationDealFields.jsx` +- `frontend/src/features/quotations/QuotationDocumentView.jsx` +- `frontend/src/features/quotations/QuotationEditorPage.jsx` +- `frontend/src/features/quotations/QuotationItemRow.jsx` +- `frontend/src/features/quotations/QuotationListPage.jsx` +- `frontend/src/features/quotations/QuotationPlainItemRow.jsx` +- `frontend/src/features/quotations/quotationCopy.js` +- `frontend/src/features/quotations/quotationMeta.js` +- `frontend/src/features/quotations/quotationPrefs.js` + +## DB +- Schemas: `sales, customers` +- Key migrations: `V165 deal_quotation_direct`; `V176 quotation_item sqm_per_box`; `V192.x per-sqm / mixed price modes` + +## Related +[[pricing-flow|Pricing Flow]] · [[sales-deal-ticket|Sales Deal & Ticket]] · [[customer|Customer]] · [[deposit-billing-finance|Deposit · Billing · Finance]] + +## Gotchas +- QuotationRenderer needs the licensed Thai fonts (backend/fonts/, gitignored) — a Git build embeds substitute fonts. +- Quotation carries its OWN frozen price snapshot, independent of the live pricing decision. + +> Authoritative details + history live in `CLAUDE.md` / `AGENTS.md` at the repo root. diff --git a/docs/repo-map/domains/sales-deal-ticket.md b/docs/repo-map/domains/sales-deal-ticket.md new file mode 100644 index 000000000..2ff8612d6 --- /dev/null +++ b/docs/repo-map/domains/sales-deal-ticket.md @@ -0,0 +1,139 @@ +--- +title: Sales Deal & Ticket +layer: Sales / CRM +schemas: [sales] +tags: [repo-map, domain/sales-deal-ticket] +synced: develop@fe5138dd +--- +# Sales Deal & Ticket +> The deal aggregate (1 ticket = 1 deal) and its sales-stage pipeline / actions. + +**Layer:** Sales / CRM · **DB schemas:** `sales` + +## Backend +### `th.co.glr.hr.ticket` — [ticket/](https://github.com/waritctd/GL-R-ERP/blob/develop/backend/src/main/java/th/co/glr/hr/ticket) +- `backend/src/main/java/th/co/glr/hr/ticket/AttachType.java` +- `backend/src/main/java/th/co/glr/hr/ticket/BillingRequest.java` +- `backend/src/main/java/th/co/glr/hr/ticket/ChangeCustomerRequest.java` +- `backend/src/main/java/th/co/glr/hr/ticket/CommentRequest.java` +- `backend/src/main/java/th/co/glr/hr/ticket/CompleteDeliveryRequest.java` +- `backend/src/main/java/th/co/glr/hr/ticket/CreateTicketRequest.java` +- `backend/src/main/java/th/co/glr/hr/ticket/DealActivityDto.java` +- `backend/src/main/java/th/co/glr/hr/ticket/DealActivityKind.java` +- `backend/src/main/java/th/co/glr/hr/ticket/DealActivityRequest.java` +- `backend/src/main/java/th/co/glr/hr/ticket/DealCancelReason.java` +- `backend/src/main/java/th/co/glr/hr/ticket/DealLifecycle.java` +- `backend/src/main/java/th/co/glr/hr/ticket/DealLostReason.java` +- `backend/src/main/java/th/co/glr/hr/ticket/DealRoute.java` +- `backend/src/main/java/th/co/glr/hr/ticket/DealStage.java` +- `backend/src/main/java/th/co/glr/hr/ticket/DealStageMetaController.java` +- `backend/src/main/java/th/co/glr/hr/ticket/DeliveryRecordDto.java` +- `backend/src/main/java/th/co/glr/hr/ticket/DeliveryRecordItemDto.java` +- `backend/src/main/java/th/co/glr/hr/ticket/DepositPolicy.java` +- `backend/src/main/java/th/co/glr/hr/ticket/DirectQuotationLocks.java` +- `backend/src/main/java/th/co/glr/hr/ticket/EditItemsRequest.java` +- `backend/src/main/java/th/co/glr/hr/ticket/EntryChannel.java` +- `backend/src/main/java/th/co/glr/hr/ticket/FulfilmentStatus.java` +- `backend/src/main/java/th/co/glr/hr/ticket/GenerateQuotationRequest.java` +- `backend/src/main/java/th/co/glr/hr/ticket/ItemWeightMultiplierRequest.java` +- `backend/src/main/java/th/co/glr/hr/ticket/LiveDirectQuotationDto.java` +- `backend/src/main/java/th/co/glr/hr/ticket/MoneyMilestone.java` +- `backend/src/main/java/th/co/glr/hr/ticket/OverridePriceRequest.java` +- `backend/src/main/java/th/co/glr/hr/ticket/PaymentReceiptDto.java` +- `backend/src/main/java/th/co/glr/hr/ticket/PaymentStage.java` +- `backend/src/main/java/th/co/glr/hr/ticket/PaymentTrack.java` +- `backend/src/main/java/th/co/glr/hr/ticket/Priority.java` +- `backend/src/main/java/th/co/glr/hr/ticket/ProposePriceRequest.java` +- `backend/src/main/java/th/co/glr/hr/ticket/QuotationDto.java` +- `backend/src/main/java/th/co/glr/hr/ticket/QuotationNumbering.java` +- `backend/src/main/java/th/co/glr/hr/ticket/QuotationRecipient.java` +- `backend/src/main/java/th/co/glr/hr/ticket/QuotationRenderModel.java` +- `backend/src/main/java/th/co/glr/hr/ticket/QuotationRenderer.java` +- `backend/src/main/java/th/co/glr/hr/ticket/QuotationStatus.java` +- `backend/src/main/java/th/co/glr/hr/ticket/RecordDeliveryRequest.java` +- `backend/src/main/java/th/co/glr/hr/ticket/RecordPaymentRequest.java` +- `backend/src/main/java/th/co/glr/hr/ticket/RejectRequest.java` +- `backend/src/main/java/th/co/glr/hr/ticket/RelatedDocumentType.java` +- `backend/src/main/java/th/co/glr/hr/ticket/StockReservationRequest.java` +- `backend/src/main/java/th/co/glr/hr/ticket/TenderRequirement.java` +- `backend/src/main/java/th/co/glr/hr/ticket/TicketAccessPolicy.java` +- `backend/src/main/java/th/co/glr/hr/ticket/TicketController.java` +- `backend/src/main/java/th/co/glr/hr/ticket/TicketDto.java` +- `backend/src/main/java/th/co/glr/hr/ticket/TicketEventDto.java` +- `backend/src/main/java/th/co/glr/hr/ticket/TicketEventKind.java` +- `backend/src/main/java/th/co/glr/hr/ticket/TicketItemDto.java` +- `backend/src/main/java/th/co/glr/hr/ticket/TicketItemRequest.java` +- `backend/src/main/java/th/co/glr/hr/ticket/TicketRepository.java` +- `backend/src/main/java/th/co/glr/hr/ticket/TicketResponses.java` +- `backend/src/main/java/th/co/glr/hr/ticket/TicketService.java` +- `backend/src/main/java/th/co/glr/hr/ticket/TicketStatus.java` +- `backend/src/main/java/th/co/glr/hr/ticket/TicketSummaryDto.java` +- `backend/src/main/java/th/co/glr/hr/ticket/TrackingUpdateRequest.java` +- `backend/src/main/java/th/co/glr/hr/ticket/WinProbabilityDefaults.java` +### `th.co.glr.hr.orderconfirmation` — [orderconfirmation/](https://github.com/waritctd/GL-R-ERP/blob/develop/backend/src/main/java/th/co/glr/hr/orderconfirmation) +- `backend/src/main/java/th/co/glr/hr/orderconfirmation/OrderConfirmationController.java` +- `backend/src/main/java/th/co/glr/hr/orderconfirmation/OrderConfirmationDtos.java` +- `backend/src/main/java/th/co/glr/hr/orderconfirmation/OrderConfirmationRequests.java` +- `backend/src/main/java/th/co/glr/hr/orderconfirmation/OrderConfirmationService.java` +### `th.co.glr.hr.approval` — [approval/](https://github.com/waritctd/GL-R-ERP/blob/develop/backend/src/main/java/th/co/glr/hr/approval) +- `backend/src/main/java/th/co/glr/hr/approval/PendingApproverSql.java` + +## Frontend +### `features/tickets` — [tickets/](https://github.com/waritctd/GL-R-ERP/blob/develop/frontend/src/features/tickets) +- `frontend/src/features/tickets/CancelDealModal.jsx` +- `frontend/src/features/tickets/DealAttachmentsPanel.jsx` +- `frontend/src/features/tickets/DealDepositPanel.jsx` +- `frontend/src/features/tickets/DealDocumentPipeline.jsx` +- `frontend/src/features/tickets/DealDocumentRegister.jsx` +- `frontend/src/features/tickets/DealFulfilmentPanel.jsx` +- `frontend/src/features/tickets/DealHistoryPanel.jsx` +- `frontend/src/features/tickets/DealLegacyQuotations.jsx` +- `frontend/src/features/tickets/DealMetaFields.jsx` +- `frontend/src/features/tickets/DealMoneyStatusStrip.jsx` +- `frontend/src/features/tickets/DealMoneyTimeline.jsx` +- `frontend/src/features/tickets/DealQuotationPanel.jsx` +- `frontend/src/features/tickets/DealRemainingInvoiceCard.jsx` +- `frontend/src/features/tickets/DealStagePanel.jsx` +- `frontend/src/features/tickets/DealStageStepper.jsx` +- `frontend/src/features/tickets/DealStateHeader.jsx` +- `frontend/src/features/tickets/DealTrackingPanel.jsx` +- `frontend/src/features/tickets/EntryChannelFix.jsx` +- `frontend/src/features/tickets/ImportRequestFactoryCard.jsx` +- `frontend/src/features/tickets/MarkLostModal.jsx` +- `frontend/src/features/tickets/RemainingInvoiceDialog.jsx` +- `frontend/src/features/tickets/TicketCreateModal.jsx` +- `frontend/src/features/tickets/TicketDetailPage.jsx` +- `frontend/src/features/tickets/TicketDetailRoute.jsx` +- `frontend/src/features/tickets/TicketListPage.jsx` +- `frontend/src/features/tickets/UpdateStageModal.jsx` +- `frontend/src/features/tickets/accountActions.js` +- `frontend/src/features/tickets/catalogPriceDisplay.js` +- `frontend/src/features/tickets/dealDocumentVersions.js` +- `frontend/src/features/tickets/dealDrafts.js` +- `frontend/src/features/tickets/dealMoneyStatus.js` +- `frontend/src/features/tickets/dealTrackingMeta.js` +- `frontend/src/features/tickets/importActions.js` +- `frontend/src/features/tickets/remainingInvoiceReadiness.js` +- `frontend/src/features/tickets/salesActions.js` +- `frontend/src/features/tickets/salesViewScope.js` +- `frontend/src/features/tickets/stageCatalog.js` +- `frontend/src/features/tickets/stageMeta.js` +- `frontend/src/features/tickets/ticketDetailTabs.js` +- `frontend/src/features/tickets/ticketItemFields.jsx` +- `frontend/src/features/tickets/workState.js` +### `features/sales` — [sales/](https://github.com/waritctd/GL-R-ERP/blob/develop/frontend/src/features/sales) +- `frontend/src/features/sales/SalesTabs.jsx` + +## DB +- Schemas: `sales` +- Key migrations: `V59 pricing_request_item`; `V83 deal tracking + activity` + +## Related +[[pricing-flow|Pricing Flow]] · [[quotation|Quotation]] · [[customer|Customer]] · [[deposit-billing-finance|Deposit · Billing · Finance]] · [[import-fulfilment|Import / Fulfilment]] + +## Gotchas +- 15-stage pipeline (DealStage); S12-S17 = per-factory procurement, S18/S19 = delivery. +- salesActions.js is the single source of which action shows at each stage. +- ticket.status vs paymentStatus vs fulfillmentStatus are THREE independent axes. + +> Authoritative details + history live in `CLAUDE.md` / `AGENTS.md` at the repo root. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 27b1508f9..ffa27d889 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -22,7 +22,7 @@ "react-router-dom": "^7.18.2", "tailwind-merge": "^3.6.0", "tailwindcss": "^4.3.2", - "zod": "^4.4.3" + "zod": "^4.6.7" }, "devDependencies": { "@eslint/js": "^9.17.0", @@ -921,6 +921,7 @@ "version": "0.154.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.154.0.tgz", "integrity": "sha512-KYOUGQFlLWR+s4QMV2Q9ko38TZm7EdO87Qm7rvZLqk/webse/cDjtyxKhg/47l0byCbs/k46LT8qc4PKjAikzA==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/oxc-project" @@ -949,6 +950,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -965,6 +967,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -981,6 +984,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -997,6 +1001,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1013,6 +1018,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1029,6 +1035,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1045,9 +1052,7 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1064,9 +1069,7 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1083,9 +1086,7 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1102,9 +1103,7 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1121,9 +1120,7 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1140,9 +1137,7 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1159,6 +1154,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1175,6 +1171,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1191,6 +1188,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1204,13 +1202,14 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, "license": "MIT" }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@standard-schema/utils": { @@ -1344,9 +1343,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1363,9 +1359,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1382,9 +1375,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1401,9 +1391,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3171,6 +3158,7 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -3255,6 +3243,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -4386,9 +4375,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4409,9 +4395,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4432,9 +4415,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4455,9 +4435,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4625,6 +4602,7 @@ "version": "3.3.20", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, "funding": [ { "type": "github", @@ -4929,12 +4907,14 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, "license": "ISC" }, "node_modules/picomatch": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -5004,6 +4984,7 @@ "version": "8.5.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==", + "dev": true, "funding": [ { "type": "opencollective", @@ -5273,6 +5254,7 @@ "version": "1.2.14", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.14.tgz", "integrity": "sha512-eTBTTqMR6uHKLgcUqpJP8ntzDI6V4LYA8negAMBEI7gkgVvOdrx6VsvS2Ym+83KhOPdLCHWJu0w7UQFJZhHKNw==", + "dev": true, "license": "MIT", "dependencies": { "@oxc-project/types": "=0.154.0", @@ -5797,6 +5779,7 @@ "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -5976,9 +5959,9 @@ } }, "node_modules/undici": { - "version": "7.30.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz", - "integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==", + "version": "7.30.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.1.tgz", + "integrity": "sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg==", "dev": true, "license": "MIT", "engines": { @@ -6036,6 +6019,7 @@ "version": "8.3.4", "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz", "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==", + "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.33.0", @@ -6113,6 +6097,7 @@ "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, "license": "MPL-2.0", "dependencies": { "detect-libc": "^2.0.3" @@ -6145,6 +6130,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6165,6 +6151,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6185,6 +6172,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6205,6 +6193,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6225,6 +6214,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6245,9 +6235,7 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6268,9 +6256,7 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6291,9 +6277,7 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6314,9 +6298,7 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6337,6 +6319,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6357,6 +6340,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6678,9 +6662,9 @@ } }, "node_modules/zod": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", - "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/frontend/package.json b/frontend/package.json index a68c5d2af..bf36abeb9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -32,7 +32,7 @@ "react-router-dom": "^7.18.2", "tailwind-merge": "^3.6.0", "tailwindcss": "^4.3.2", - "zod": "^4.4.3" + "zod": "^4.6.7" }, "devDependencies": { "@eslint/js": "^9.17.0", @@ -48,5 +48,8 @@ "jsdom": "^29.1.1", "vite": "^8.3.4", "vitest": "^4.1.12" + }, + "overrides": { + "undici": "^7.30.1" } } diff --git a/frontend/src/api/hrApi.js b/frontend/src/api/hrApi.js index 5382e61cd..88e500b14 100644 --- a/frontend/src/api/hrApi.js +++ b/frontend/src/api/hrApi.js @@ -480,7 +480,8 @@ export const api = { get: (id) => apiRequest(API_ROUTES.depositNotices.get(id)), update: (id, payload) => apiRequest(API_ROUTES.depositNotices.update(id), { method: 'PUT', body: payload }), preview: (id) => apiRequest(API_ROUTES.depositNotices.preview(id), { method: 'POST' }), - issue: (id) => apiRequest(API_ROUTES.depositNotices.issue(id), { method: 'POST' }), + // payload.expectedUpdatedAt (optional): the draft's updatedAt as the caller last read it; 409 if it moved on. + issue: (id, payload) => apiRequest(API_ROUTES.depositNotices.issue(id), { method: 'POST', body: payload }), downloadXlsx: async (id) => { const res = await fetch(API_ROUTES.depositNotices.file(id, 'xlsx'), { credentials: 'include' }); if (!res.ok) throw new Error('Download failed'); diff --git a/frontend/src/api/mockApi.dealQuotationOutcome.test.js b/frontend/src/api/mockApi.dealQuotationOutcome.test.js index 869eb929e..43c33fd9a 100644 --- a/frontend/src/api/mockApi.dealQuotationOutcome.test.js +++ b/frontend/src/api/mockApi.dealQuotationOutcome.test.js @@ -366,3 +366,32 @@ describe('mockApi confirmOrder from a NEW-engine accepted quotation (GLA-123 sli expect(result.ticket.summary.salesStage).toBe('ORDER_RECEIVED'); }, 20000); }); + +// Owner ruling 2026-10-27 (redo): the deal page's "เสนอใบราคา" re-quotes a request whose +// quotation the customer rejected or sent back for changes. DealQuotationService +// #createFromPricingRequest allows that from QUOTATION_ISSUED, except when the request's latest +// quotation was superseded by a newer one on the deal. Mock parity only. +describe('mockApi dealQuotations.createFromPricingRequest after an outcome — the re-quote (mock parity)', () => { + it.each(['REJECTED', 'REVISION_REQUESTED'])('after %s: a fresh DRAFT from the same request', async (outcome) => { + const { ticketId, sourceItemId } = await freshTicket(); + const { prId, quotationId } = await issuedNewOriginQuotation(ticketId, sourceItemId, 'BUYER'); + await api.auth.login({ role: 'sales' }); + await api.dealQuotations.recordOutcome(quotationId, { outcome, clientRequestId: nextClientRequestId() }); + + const { quotation } = await api.dealQuotations.createFromPricingRequest(prId); + expect(quotation).toMatchObject({ docStatus: 'DRAFT', pricingRequestId: prId }); + expect(quotation.id).not.toBe(quotationId); + }, 20000); + + it('its quotation superseded by a later request\'s issue: 409, no re-quote from the older price', async () => { + const { ticketId, sourceItemId } = await freshTicket(); + const designer = await issuedNewOriginQuotation(ticketId, sourceItemId, 'DESIGNER'); + await issuedNewOriginQuotation(ticketId, sourceItemId, 'OWNER'); + await api.auth.login({ role: 'sales' }); + + await expect(api.dealQuotations.createFromPricingRequest(designer.prId)).rejects.toMatchObject({ + status: 409, + message: 'คำขอราคานี้ถูกแทนที่ด้วยใบเสนอราคาฉบับใหม่ของดีลแล้ว — ออกใบเสนอราคาจากคำขอราคาล่าสุด หรือสร้างคำขอราคาใหม่', + }); + }, 20000); +}); diff --git a/frontend/src/api/mockApi.depositNotices.test.js b/frontend/src/api/mockApi.depositNotices.test.js index bc1a8e747..cd731ff92 100644 --- a/frontend/src/api/mockApi.depositNotices.test.js +++ b/frontend/src/api/mockApi.depositNotices.test.js @@ -1,5 +1,17 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; import { describe, it, expect } from 'vitest'; -import { api } from './mockApi.js'; +import { + api, MOCK_DEFAULT_DEPOSIT_PERCENT, MOCK_LEGACY_DEPOSIT_PERCENT, retireLivePricingRequestsForTests, +} from './mockApi.js'; +import { stripComments } from './apiSurface.js'; + +// Copy of the canonical ONE_ITEM in mockApi.dealQuotations.test.js. +const ONE_ITEM = { + model: 'Trilogy', color: 'Ash', texture: 'Matt', sizeText: '60x60', thicknessMm: 10, sqmPerPiece: 0.36, + quantityMode: 'AREA', areaSqm: 20, wastageMode: 'NONE', wastageValue: 0, piecesPerBox: 3, unitPrice: 850, discountPct: 0, + leadTimeMinDays: 30, leadTimeMaxDays: 45, +}; // Branch fix (deposit-notice autofill): drives the mock's own pricing-request chain end to end // — Step 1 create through Step 5 customer-accept, then confirmOrder — against the REAL mock @@ -184,10 +196,13 @@ describe('mockApi.depositNotices.createDraft autofill (branch fix: deposit-notic expect(detail.items.every((it) => it.approvedPrice == null)).toBe(true); expect(detail.summary.status).toBe('quotation_issued'); + // A typed percent is ignored: the legacy quotation's PDF prints 50%, so the draft bills 50%. const { depositNotice } = await api.depositNotices.createDraft(ticketId, { notes: [], - depositPercent: 0.5, + depositPercent: 0.9, }); + expect(depositNotice.depositPercent).toBe(0.5); + expect(depositNotice.reference).toBe(quotation.number); // Items: sourced from the ACCEPTED quotation, not the (all-null) ticket_item rows. expect(depositNotice.items).toHaveLength(1); @@ -209,9 +224,11 @@ describe('mockApi.depositNotices.createDraft autofill (branch fix: deposit-notic // delegates to — this endpoint itself never passes a customer/project explicitly. const prId = (await api.pricingRequests.listForTicket(quotation.ticketId)).items .find((pr) => pr.status === 'QUOTATION_ACCEPTED').id; + // A typed percent is ignored here too: the legacy branch bills its PDF's 50%. const { depositNotice } = await api.pricingRequests.createDepositNoticeFromQuotation(prId, { - depositPercent: 0.5, + depositPercent: 0.9, }); + expect(depositNotice.depositPercent).toBe(0.5); expect(depositNotice.customerTaxId).toBe(customer.taxId); expect(depositNotice.customerAddress).toBe(`${customer.address} ${customer.branch}`); @@ -222,12 +239,22 @@ describe('mockApi.depositNotices.createDraft autofill (branch fix: deposit-notic // Java picks the row with DealQuotationRepository.findAcceptedForPricingRequest. describe('mockApi.pricingRequests.createDepositNoticeFromQuotation — new-engine quotation (mirrors OrderConfirmationService fix)', () => { it('creates the draft with the accepted quotation number as reference, and items and percent from that quotation', async () => { - const { prId, accepted } = await driveTicketToNewEngineAcceptedQuotation({ depositPercent: 30 }); + // 40, unlike the 30 default, the legacy 50 and the no-quotation 0.5, so only the quotation's own percent fits. + const { ticketId, prId, accepted } = await driveTicketToNewEngineAcceptedQuotation({ depositPercent: 40 }); const { depositNotice } = await api.pricingRequests.createDepositNoticeFromQuotation(prId, {}); expect(depositNotice.reference).toBe(accepted.number); // DepositNoticeService.createDraftFromQuotation: the chosen quotation's own whole-number percent. - expect(accepted.depositPercent).toBe(30); - expect(depositNotice.depositPercent).toBe(0.3); + expect(accepted.depositPercent).toBe(40); + expect(depositNotice.depositPercent).toBe(0.4); + // The ticket route bills the same quotation (DepositNoticeRepository.findGoverningQuotation). + const { depositNotice: fromTicket } = await api.depositNotices.createDraft(ticketId, {}); + expect(fromTicket.depositPercent).toBe(0.4); + expect(fromTicket.reference).toBe(accepted.number); + // The caller supplies the lines (even this quotation's own), so the reference is null, as in + // Java; the percent still comes from the quotation (DepositNoticeService, linesFromGoverning). + const { depositNotice: ownLines } = await api.depositNotices.createDraft(ticketId, { items: fromTicket.items }); + expect(ownLines.reference).toBeNull(); + expect(ownLines.depositPercent).toBe(0.4); expect(depositNotice.items.length).toBeGreaterThan(0); // qty is deliberately not distinguishable here: a mock TILE row sets quantity = piecesFinal. const [line] = accepted.items; @@ -241,17 +268,40 @@ describe('mockApi.pricingRequests.createDepositNoticeFromQuotation — new-engin expect(depositNotice.items[0].discountLabel).toBe('ส่วนลด 12 ต่อหน่วย'); }); - // Covers the ticket-level new-engine fallback (DepositNoticeRepository.findDealQuotationItemsForDeposit): - // with no items and empty legacy sources, the route would return no items without it. - it('ticket-level createDraft with no items reads the same new-engine lines as the pricing-request route', async () => { - const { ticketId, prId } = await driveTicketToNewEngineAcceptedQuotation(); + // Covers the ticket-level route's governing quotation (DepositNoticeRepository.findGoverningQuotation): + // with no items and no legacy quotation, it bills the new-engine quotation's lines, percent and number. + it('ticket-level createDraft with no items drafts the same lines, percent and reference as the pricing-request route', async () => { + const { ticketId, prId, accepted } = await driveTicketToNewEngineAcceptedQuotation(); + // The PDF prints a NULL percent as DealQuotationRenderAdapter.DEFAULT_DEPOSIT_PERCENT (30). + expect(accepted.depositPercent).toBeNull(); const { depositNotice: fromTicket } = await api.depositNotices.createDraft(ticketId, {}); const { depositNotice: fromPr } = await api.pricingRequests.createDepositNoticeFromQuotation(prId, {}); + expect(fromPr.depositPercent).toBe(0.3); + expect(fromTicket.depositPercent).toBe(0.3); + expect(fromTicket.reference).toBe(fromPr.reference); expect(fromTicket.items.length).toBeGreaterThan(0); const pick = (items) => items.map(({ qty, netUnitPrice }) => ({ qty, netUnitPrice })); expect(pick(fromTicket.items)).toEqual(pick(fromPr.items)); }); + // Mirrors DepositNoticeService.createDraftFromQuotation's owner guard. sales2@glr.co.th is a + // second seeded sales rep; the drive above runs as the first one, who owns the deal. + it('ticket-level createDraft refuses a sales rep who does not own the deal and creates nothing', async () => { + const { ticketId } = await driveTicketToNewEngineAcceptedQuotation(); + await api.auth.login({ email: 'sales2@glr.co.th', password: 'demo1234' }); + await expect(api.depositNotices.createDraft(ticketId, {})) + .rejects.toMatchObject({ status: 403, message: 'ไม่มีสิทธิ์เข้าถึงรายการนี้' }); + // Role gate (SALES_ROLES = sales only) runs before the ticket lookup, as in Java: a non-sales + // caller gets 403 even for a ticket that does not exist, never the 404. + await api.auth.login({ role: 'ceo' }); + await expect(api.depositNotices.createDraft(999999, {})).rejects.toMatchObject({ status: 403 }); + + await api.auth.login({ role: 'sales' }); + expect((await api.depositNotices.listByTicket(ticketId)).depositNotices).toHaveLength(0); + const { depositNotice } = await api.depositNotices.createDraft(ticketId, {}); + expect(depositNotice.ticketId).toBe(ticketId); + }); + it('still 409s while the new-engine quotation is only ISSUED (customer has not accepted)', async () => { const { ticketId, prId } = await driveTicketToNewEngineIssuedQuotation(); await expect(api.pricingRequests.createDepositNoticeFromQuotation(prId, {})) @@ -260,18 +310,72 @@ describe('mockApi.pricingRequests.createDepositNoticeFromQuotation — new-engin expect(depositNotices).toHaveLength(0); }); - // A zero-deposit ("ไม่รับมัดจำ", full payment) quotation has nothing to bill: Java refuses rather - // than drafting a 0-baht document that nothing downstream would stop from being issued. - it('rejects a 0% (full-payment) new-engine quotation with 409 and creates no draft', async () => { + // A zero-deposit ("ไม่รับมัดจำ", full payment) quotation has nothing to bill: Java refuses on both + // routes rather than drafting a 0-baht document that nothing downstream would stop from being issued. + it('rejects a 0% (full-payment) new-engine quotation with 409 on both routes and creates no draft', async () => { const { ticketId, prId, accepted } = await driveTicketToNewEngineAcceptedQuotation({ depositPercent: 0, fullPaymentTerm: 'BEFORE_DELIVERY', }); expect(accepted.depositPercent).toBe(0); + // Each route's own guard: the pricing-request route refuses on the quotation, the ticket route on the policy. await expect(api.pricingRequests.createDepositNoticeFromQuotation(prId, {})) - .rejects.toMatchObject({ status: 409, message: expect.stringContaining('ไม่มีมัดจำ') }); + .rejects.toMatchObject({ status: 409, message: expect.stringContaining(`ใบเสนอราคา ${accepted.number} ระบุไม่มีมัดจำ`) }); + await expect(api.depositNotices.createDraft(ticketId, {})) + .rejects.toMatchObject({ status: 409, message: expect.stringContaining('ดีลนี้ไม่ต้องเก็บมัดจำ') }); const { depositNotices } = await api.depositNotices.listByTicket(ticketId); expect(depositNotices).toHaveLength(0); }); + + // DepositNoticeRepositoryDepositPercentIntegrationTest#mixedTicket_aDealDirectApprovedQuotationBeatsAnOlderAcceptedOne + // on the mock: two answers in two mock arrays, so only "newest id" across both arrays can pick. + // Cancelling the pricing request stages the pre-lock data that lets a direct quotation sit beside it. + it('ticket-level createDraft bills a DEAL_DIRECT approval made after the accepted legacy quotation', async () => { + const { ticketId } = await driveTicketToAcceptedQuotation(); + retireLivePricingRequestsForTests(ticketId); + const { quotation: draft } = await api.dealQuotations.create(ticketId, { + recipientType: 'DESIGNER', depositPercent: 40, remainderMode: 'ON_DELIVERY', + items: [ONE_ITEM], + }); + await api.dealQuotations.submit(draft.id, {}); + await api.auth.login({ role: 'sales_manager' }); + const { quotation: approved } = await api.dealQuotations.approve(draft.id, {}); + await api.auth.login({ role: 'sales' }); + expect(approved.docStatus).toBe('APPROVED'); + + const { depositNotice } = await api.depositNotices.createDraft(ticketId, {}); + expect(depositNotice.reference).toBe(approved.number); + expect(depositNotice.depositPercent).toBe(0.4); + }); + + // Mirrors TicketService.applyCustomerConfirmation's policy sync (owner ruling 2026-10-22). + it('confirmOrder on a 0% quotation sets depositPolicy NOT_REQUIRED, and both draft routes 409', async () => { + const { ticketId, prId, accepted } = await driveTicketToNewEngineAcceptedQuotation({ + depositPercent: 0, fullPaymentTerm: 'BEFORE_DELIVERY', + }); + const { ticket } = await api.tickets.get(ticketId); + expect(ticket.summary.depositPolicy).toBe('NOT_REQUIRED'); + expect(ticket.summary.depositPolicyReason).toContain(accepted.number); + expect(ticket.events.filter((e) => e.kind === 'POLICY_CHANGED').map((e) => e.message)) + .toEqual([expect.stringContaining(accepted.number)]); + await expect(api.depositNotices.createDraft(ticketId, {})) + .rejects.toMatchObject({ status: 409, message: expect.stringContaining('ไม่ต้องเก็บมัดจำ') }); + await expect(api.pricingRequests.createDepositNoticeFromQuotation(prId, {})) + .rejects.toMatchObject({ status: 409 }); + expect((await api.depositNotices.listByTicket(ticketId)).depositNotices).toHaveLength(0); + }); + + it('a 30% quotation leaves REQUIRED; once waived, both draft routes 409 on the policy', async () => { + const { ticketId, prId } = await driveTicketToNewEngineAcceptedQuotation({ depositPercent: 30 }); + const { ticket } = await api.tickets.get(ticketId); + expect(ticket.summary.depositPolicy).toBe('REQUIRED'); + expect(ticket.events.some((e) => e.kind === 'POLICY_CHANGED')).toBe(false); + + await api.tickets.setDepositPolicy(ticketId, { policy: 'WAIVED', reason: 'ลูกค้าโอนเต็มจำนวนล่วงหน้า' }); + const policyRefusal = { status: 409, message: expect.stringContaining('ไม่ต้องเก็บมัดจำ') }; + await expect(api.depositNotices.createDraft(ticketId, {})).rejects.toMatchObject(policyRefusal); + await expect(api.pricingRequests.createDepositNoticeFromQuotation(prId, {})).rejects.toMatchObject(policyRefusal); + expect((await api.depositNotices.listByTicket(ticketId)).depositNotices).toHaveLength(0); + }); }); // Issue #752: the mock must reproduce production's one-render gap. Per @@ -321,6 +425,119 @@ describe('mockApi.depositNotices.issue hasPdf/hasXlsx (issue #752: mock reproduc }); }); +// Owner ruling 2026-10-23 (mirrors DepositNoticeService.update): sales may change the deposit % +// on a DRAFT and the amount follows; 0 is refused; an ISSUED notice refuses every edit. +describe('mockApi.depositNotices.update deposit percent', () => { + it('on a DRAFT takes the new percent and recomputes the amount; 0, over 100% and fractional are refused', async () => { + const { ticketId } = await driveTicketToAcceptedQuotation(); + const { depositNotice: draft } = await api.depositNotices.createDraft(ticketId, { notes: [] }); + expect(draft.subtotal).toBeGreaterThan(0); + + // Items sent the way DepositNoticePage sends them on save. + const { depositNotice: updated } = await api.depositNotices.update(draft.id, { depositPercent: 0.4, items: draft.items }); + expect(updated.depositPercent).toBe(0.4); + expect(updated.depositAmount).toBe(Math.round(draft.subtotal * 0.4 * 100) / 100); + + // DepositNoticeDraftRequest's @DecimalMin/@DecimalMax run before the service (field-prefixed). + await expect(api.depositNotices.update(draft.id, { depositPercent: 0 })) + .rejects.toMatchObject({ status: 400, message: 'depositPercent เปอร์เซ็นต์มัดจำต้องมากกว่า 0' }); + await expect(api.depositNotices.update(draft.id, { depositPercent: 1.5 })) + .rejects.toMatchObject({ status: 400, message: 'depositPercent เปอร์เซ็นต์มัดจำต้องไม่เกิน 100' }); + await expect(api.depositNotices.update(draft.id, { depositPercent: 0.405 })) + .rejects.toMatchObject({ status: 400, message: 'เปอร์เซ็นต์มัดจำต้องเป็นจำนวนเต็ม' }); + const { depositNotice: afterRejects } = await api.depositNotices.get(draft.id); + expect(afterRejects.depositPercent).toBe(0.4); + expect(afterRejects.depositAmount).toBe(updated.depositAmount); + // 0.29 * 100 is 28.999999999999996 in floating point; it is still a whole 29%. + await api.depositNotices.update(draft.id, { depositPercent: 0.29, items: draft.items }); + const { depositNotice: reread } = await api.depositNotices.get(draft.id); + expect(reread.depositPercent).toBe(0.29); + }); + + // @Valid runs before DepositNoticeService.update looks anything up, so a bad % is a 400 even + // for a notice that does not exist or is already issued. + it('validates the % before the 404 and 409 lookups', async () => { + await api.auth.login({ role: 'sales' }); + await expect(api.depositNotices.update(999999, { depositPercent: 1.5 })) + .rejects.toMatchObject({ status: 400 }); + const { ticketId } = await driveTicketToAcceptedQuotation(); + const { depositNotice: draft } = await api.depositNotices.createDraft(ticketId, { notes: [] }); + await api.tickets.confirmCustomer(ticketId); + await api.depositNotices.issue(draft.id); + await expect(api.depositNotices.update(draft.id, { depositPercent: 0 })) + .rejects.toMatchObject({ status: 400 }); + }); + + // Mirrors the stale-save check (DepositNoticeRepository#update): a save carrying an older + // updatedAt than the stored one is refused; one carrying the current stamp, or none, saves. + it('refuses a save whose expectedUpdatedAt is stale', async () => { + const { ticketId } = await driveTicketToAcceptedQuotation(); + const { depositNotice: draft } = await api.depositNotices.createDraft(ticketId, { notes: [] }); + const { depositNotice: savedByA } = await api.depositNotices.update(draft.id, + { depositPercent: 0.4, items: draft.items, expectedUpdatedAt: draft.updatedAt }); + + await expect(api.depositNotices.update(draft.id, + { depositPercent: 0.3, items: draft.items, expectedUpdatedAt: draft.updatedAt })) + .rejects.toMatchObject({ status: 409, message: 'ใบแจ้งรับมัดจำนี้ถูกแก้ไขโดยผู้ใช้อื่น กรุณาโหลดข้อมูลใหม่แล้วลองอีกครั้ง' }); + expect((await api.depositNotices.get(draft.id)).depositNotice.depositPercent).toBe(0.4); + + await api.depositNotices.update(draft.id, { depositPercent: 0.3, expectedUpdatedAt: savedByA.updatedAt }); + expect((await api.depositNotices.get(draft.id)).depositNotice.depositPercent).toBe(0.3); + }); + + // Mirrors DepositNoticeService.update/issue: sales only (requireRole), then the deal owner. + it('refuses update and issue to a sales rep who does not own the deal, and to a non-sales role', async () => { + const { ticketId } = await driveTicketToAcceptedQuotation(); + const { depositNotice: draft } = await api.depositNotices.createDraft(ticketId, { notes: [] }); + await api.tickets.confirmCustomer(ticketId); + + await api.auth.login({ email: 'sales2@glr.co.th', password: 'demo1234' }); + await expect(api.depositNotices.update(draft.id, { customerName: 'x' })) + .rejects.toMatchObject({ status: 403, message: 'ไม่มีสิทธิ์เข้าถึงรายการนี้' }); + await expect(api.depositNotices.issue(draft.id)).rejects.toMatchObject({ status: 403 }); + await api.auth.login({ role: 'sales_manager' }); + await expect(api.depositNotices.update(draft.id, { customerName: 'x' })).rejects.toMatchObject({ status: 403 }); + await expect(api.depositNotices.issue(draft.id)).rejects.toMatchObject({ status: 403 }); + + await api.auth.login({ role: 'sales' }); + const { depositNotice: reread } = await api.depositNotices.get(draft.id); + expect(reread.status).toBe('DRAFT'); + expect(reread.customerName).toBe(draft.customerName); + }); + + it('on an ISSUED notice is refused with 409 and changes nothing; no second draft on either route', async () => { + const { ticketId } = await driveTicketToAcceptedQuotation(); + const { depositNotice: draft } = await api.depositNotices.createDraft(ticketId, { notes: [] }); + const { depositNotice: secondDraft } = await api.depositNotices.createDraft(ticketId, { notes: [] }); + await api.tickets.confirmCustomer(ticketId); + const { depositNotice: issued } = await api.depositNotices.issue(draft.id); + + // Owner ruling 2026-10-23: a second draft that predates the issue takes no number either. + await expect(api.depositNotices.issue(secondDraft.id)).rejects.toMatchObject({ + status: 409, + message: `ดีลนี้ออกใบแจ้งรับมัดจำเลขที่ ${issued.docNumber} แล้ว จึงออกฉบับร่างนี้ไม่ได้`, + }); + const { depositNotice: secondReread } = await api.depositNotices.get(secondDraft.id); + expect(secondReread.status).toBe('DRAFT'); + expect(secondReread.docNumber).toBeNull(); + expect((await api.depositNotices.get(issued.id)).depositNotice.status).toBe('ISSUED'); + + await expect(api.depositNotices.update(issued.id, { depositPercent: 0.4 })) + .rejects.toMatchObject({ status: 409 }); + const { depositNotice: reread } = await api.depositNotices.get(issued.id); + expect(reread.depositPercent).toBe(issued.depositPercent); + expect(reread.depositAmount).toBe(issued.depositAmount); + + // Owner ruling 2026-10-23 (DepositNoticeService.createDraftFromQuotation): one notice per deal. + const message = `ดีลนี้ออกใบแจ้งรับมัดจำเลขที่ ${issued.docNumber} แล้ว จึงสร้างฉบับร่างใหม่ไม่ได้`; + await expect(api.depositNotices.createDraft(ticketId, {})).rejects.toMatchObject({ status: 409, message }); + const prId = (await api.pricingRequests.listForTicket(ticketId)).items + .find((pr) => pr.status === 'QUOTATION_ACCEPTED').id; + await expect(api.pricingRequests.createDepositNoticeFromQuotation(prId, {})).rejects.toMatchObject({ status: 409, message }); + expect((await api.depositNotices.listByTicket(ticketId)).depositNotices).toHaveLength(2); // no third + }); +}); + // GLA-32 (2026-09-19): mirrors DepositNoticeService.issue's account-role notification (backend // backend/src/main/java/th/co/glr/hr/deposit/DepositNoticeService.java) — issuing a deposit // notice notifies the account role, in-app, so account picks up the deal here rather than @@ -368,3 +585,50 @@ describe('mockApi.depositNotices.issue account notification (GLA-32)', () => { expect(own.message).toContain('ไม่ระบุลูกค้า'); }); }); + +// The mock's blank-percent defaults restate two Java constants; read them out of the source so the +// copies cannot drift (the dealTrackingMeta.test.js approach). Throws rather than skips when the +// files move, so the guard cannot quietly disable itself; comments are stripped first, so a +// commented-out declaration does not count. +function readBackendSource(relativePath) { + for (let dir = process.cwd(), depth = 0; depth < 8; depth += 1, dir = dirname(dir)) { + const candidate = resolve(dir, relativePath); + if (existsSync(candidate)) return readFileSync(candidate, 'utf8'); + } + throw new Error(`could not find ${relativePath} above ${process.cwd()}`); +} + +function javaIntConstant(relativePath, name) { + const match = stripComments(readBackendSource(relativePath)).match(new RegExp(`static final int ${name} = (\\d+);`)); + if (!match) throw new Error(`could not find int ${name} in ${relativePath}`); + return Number(match[1]); +} + +describe('mock deposit-percent defaults match the Java PDFs', () => { + it('a blank new-engine percent is DealQuotationRenderAdapter.DEFAULT_DEPOSIT_PERCENT', () => { + expect(MOCK_DEFAULT_DEPOSIT_PERCENT).toBe(javaIntConstant( + 'backend/src/main/java/th/co/glr/hr/dealquotation/DealQuotationRenderAdapter.java', 'DEFAULT_DEPOSIT_PERCENT')); + }); + + it('a legacy quotation is QuotationRenderer.LEGACY_DEPOSIT_PERCENT', () => { + expect(MOCK_LEGACY_DEPOSIT_PERCENT).toBe(javaIntConstant( + 'backend/src/main/java/th/co/glr/hr/ticket/QuotationRenderer.java', 'LEGACY_DEPOSIT_PERCENT')); + }); +}); + +// Mirrors DepositNoticeService.issue's amount guard: a 0-baht notice must not take a document number. +describe('mockApi.depositNotices.issue refuses a 0-baht notice', () => { + it('409s, and the draft stays DRAFT with no number', async () => { + const { ticketId } = await driveTicketToNewEngineAcceptedQuotation({ depositPercent: 30 }); + // update refuses a 0% now, so a free line (net price 0) stands in for the Java test's jdbc insert. + const { depositNotice: created } = await api.depositNotices.createDraft(ticketId, {}); + const { depositNotice: draft } = await api.depositNotices.update(created.id, { + items: [{ seq: 1, description: 'Free item', qty: 2, unit: 'แผ่น', unitPrice: 0, netUnitPrice: 0 }], + }); + expect(draft.depositAmount).toBe(0); + await expect(api.depositNotices.issue(draft.id)) + .rejects.toMatchObject({ status: 409, message: expect.stringContaining('0 บาท') }); + const { depositNotice } = await api.depositNotices.get(draft.id); + expect(depositNotice).toMatchObject({ status: 'DRAFT', docNumber: null }); + }); +}); diff --git a/frontend/src/api/mockApi.js b/frontend/src/api/mockApi.js index d17302ee8..3cc13d9d7 100644 --- a/frontend/src/api/mockApi.js +++ b/frontend/src/api/mockApi.js @@ -1177,7 +1177,9 @@ let mockPricingDecisionItemSeq = 1; // Step 4 quotation is keyed by pricingRequestId first — the legacy ticket-item-driven // `ticket.quotations` array is untouched by this section. const mockCustomerQuotations = []; -let mockCustomerQuotationSeq = 1; +// sales.quotation_id: one id sequence for mockCustomerQuotations AND mockDealQuotations, as Java +// has one table, so "latest" (highest id) compares across both arrays. +let mockQuotationSeq = 1; let mockCustomerQuotationItemSeq = 1; // CEO discount-approval workflow, Phase 2 (owner ruling 2026-08-16) — mirrors // sales.quotation_item_discount_approval (V155). Starts empty every session like every other @@ -1214,7 +1216,7 @@ let mockDiscountApprovalSeq = 1; mockPricingDecisionSeq = salesSeed.nextSeq.pricingDecision; mockPricingDecisionItemSeq = salesSeed.nextSeq.pricingDecisionItem; mockCustomerQuotations.push(...salesSeed.customerQuotations); - mockCustomerQuotationSeq = salesSeed.nextSeq.customerQuotation; + mockQuotationSeq = salesSeed.nextSeq.customerQuotation; mockCustomerQuotationItemSeq = salesSeed.nextSeq.customerQuotationItem; // Deal-tracking readiness gate (dealIsReadyToAdvance) needs `ticket.nextFollowUpAt` set — // not part of the ticket's own literal object in demoSales.js since it's normally written @@ -1439,38 +1441,64 @@ function mockDepositNoticeItemsFromQuotation(items) { // Mirrors DepositNoticeRepository.findDealQuotationItems. Values are read off the stored row, // never recomputed (label excepted — see below). function mockDepositNoticeItemsFromDealQuotation(row) { - return row.items.map((item, idx) => { + // DepositNoticeRepository#foldAdjustments: an ADJUSTMENT row (qty -1) is no deposit line; its + // amount is spread over the goods lines by amount, each share and each new net price 2 dp (no + // remainder on a last line: the notice total is its own 2 dp lines). A line keeps the quotation's + // own discount wording and gains the folded discount after it. + // ponytail: the mock computes no % adjustment amount (MOCK_NOT_COMPUTED), so that row is only dropped. + const goods = row.items.filter((item) => item.lineType !== 'ADJUSTMENT'); + const adjustment = row.items.filter((item) => item.lineType === 'ADJUSTMENT') + .reduce((sum, item) => sum + (Number(item.netUnitPrice) || 0) * Number(item.quantity), 0); + const amountOf = (item) => item.netUnitPrice * item.quantity; + const total = goods.reduce((sum, item) => sum + amountOf(item), 0); + return goods.map((item, idx) => { + let netUnitPrice = item.netUnitPrice; + if (adjustment && total && amountOf(item)) { + const share = round2((adjustment * amountOf(item)) / total); + netUnitPrice = round2((amountOf(item) + share) / item.quantity); + } // Display label only: the mock stores no sales_discount, so this restates the column Java reads // (DealQuotationRepository#itemParams writes it as unitPrice − netUnitPrice). const salesDiscount = round2(item.unitPrice - item.netUnitPrice); + const folded = round2(item.netUnitPrice - netUnitPrice); + const own = salesDiscount > 0 ? `ส่วนลด ${salesDiscount} ต่อหน่วย` : null; + const extra = folded > 0 ? `ส่วนลดพิเศษ ${folded} ต่อหน่วย` : null; return { seq: item.seq ?? idx + 1, description: item.descriptionLine?.trim() ? item.descriptionLine : 'รายการสินค้า', qty: item.quantity, unit: item.unit?.trim() ? item.unit : 'หน่วย', unitPrice: item.unitPrice, - discountLabel: salesDiscount > 0 ? `ส่วนลด ${salesDiscount} ต่อหน่วย` : null, - netUnitPrice: item.netUnitPrice, + discountLabel: own && extra ? `${own} + ${extra}` : own ?? extra, + netUnitPrice, }; }); } -// Branch fix: mirrors DepositNoticeService.pickQuotation — the latest ACCEPTED revision for -// this ticket, or (if none has been accepted yet) the latest ISSUED one. mockCustomerQuotations -// carries no guaranteed ordering, so sort ascending by (quotationRevisionNo, id) first — same -// contract as CustomerQuotationRepository.findByTicket — then a single forward scan overwriting -// "latest seen" per status is equivalent to sorting descending and taking the first match. -function mockPickTicketQuotationForDepositNotice(ticketId) { - const candidates = mockCustomerQuotations - .filter((q) => q.ticketId === Number(ticketId) && q.pricingRequestId != null) - .sort((a, b) => (a.quotationRevisionNo - b.quotationRevisionNo) || (a.id - b.id)); - let latestAccepted = null; - let latestIssued = null; - for (const q of candidates) { - if (q.docStatus === 'ACCEPTED') latestAccepted = q; - else if (q.docStatus === 'ISSUED') latestIssued = q; +// Mirrors DepositNoticeService.zeroDepositRefusal: both deposit routes refuse a 0% quotation. +const mockZeroDepositRefusal = (number) => + `ใบเสนอราคา ${number} ระบุไม่มีมัดจำ (ชำระเต็มจำนวน) จึงออกใบแจ้งยอดเงินรับมัดจำไม่ได้`; + +// Mirrors DepositNoticeRepository#findGoverningQuotation: the quotation a ticket-level deposit +// notice bills (lines, percent, reference), across both engines. The latest one the customer +// answered (ACCEPTED, or a DEAL_DIRECT one APPROVED) wins, else the latest ISSUED one. "Latest" +// is the highest id, which mockQuotationSeq makes comparable across the two arrays. +// ponytail: the two literal mockDealQuotations seeds (ids 1-2, ticket 18) repeat legacy seed ids +// (ticket 5); no pick compares rows of different tickets, so renumber only if they ever share one. +function mockGoverningQuotationForDepositNotice(ticketId) { + const candidates = []; + for (const q of mockCustomerQuotations) { + if (q.ticketId !== ticketId || q.pricingRequestId == null) continue; + const rank = { ACCEPTED: 0, ISSUED: 1 }[q.docStatus]; + if (rank != null) candidates.push({ rank, quotation: q, legacy: true }); } - return latestAccepted ?? latestIssued ?? null; + for (const q of mockDealQuotations) { + if (q.ticketId !== ticketId) continue; + const rank = isDealDirectOrigin(q) && q.docStatus === 'APPROVED' ? 0 : { ACCEPTED: 0, ISSUED: 1 }[q.docStatus]; + if (rank != null) candidates.push({ rank, quotation: q, legacy: false }); + } + candidates.sort((a, b) => (a.rank - b.rank) || (b.quotation.id - a.quotation.id)); + return candidates[0] ?? null; } // B5 fix ("รายการสินค้า shows 0 รายการ while a deal is still in the pricing/quotation phase"): @@ -1498,7 +1526,7 @@ function mockPickLiveQuotationForItemFallback(ticketId) { } if (candidates.length === 0) return null; // ACCEPTED beats ISSUED beats APPROVED (rank ascending); most recent id wins a tie -- same - // priority findDealQuotationItemsForDeposit's CASE doc_status / ORDER BY quotation_id DESC + // priority findPricingChainFallbackItems's CASE doc_status / ORDER BY quotation_id DESC // encodes server-side. candidates.sort((a, b) => (a.rank - b.rank) || (b.id - a.id)); return candidates[0]; @@ -1680,7 +1708,7 @@ function reconcileTicketItemsFromPricingRequest(ticket, pr, user) { function buildMockCustomerQuotationDraft(pr, decision, ticket, user, payload, parentQuotationId, revisionNo, priorDiscounts, number) { const customer = ticket?.customerId ? mockCustomers.find((c) => c.id === ticket.customerId) : null; const project = ticket?.projectId ? mockProjects.find((p) => p.id === ticket.projectId) : null; - const id = mockCustomerQuotationSeq++; + const id = mockQuotationSeq++; const items = decision.items .filter((di) => di.approvedSellingPricePerRequestedUnit != null) .map((di, idx) => { @@ -2407,6 +2435,13 @@ function stripCeoPriceModeFields(decision, user) { }; } +// Mirrors DepositNoticeService.createDraftFromQuotation (both create routes): one deposit notice +// per deal, so no new draft while one is ISSUED (owner ruling 2026-10-23). +function requireNoIssuedDepositNotice(ticketId) { + const issued = mockDepositNotices.find((d) => d.ticketId === Number(ticketId) && d.status === 'ISSUED'); + if (issued) fail(`ดีลนี้ออกใบแจ้งรับมัดจำเลขที่ ${issued.docNumber} แล้ว จึงสร้างฉบับร่างใหม่ไม่ได้`, 409); +} + function buildMockDoc(doc) { const items = doc.items ?? []; const depositPct = doc.depositPercent ?? 0.5; @@ -3720,6 +3755,34 @@ function depositBypassesNotice(ticket) { return ['NOT_REQUIRED', 'WAIVED', 'CREDIT_CUSTOMER'].includes(ticket.depositPolicy); } +// Mirrors DepositNoticeService.createDraftFromQuotation's policy refusal, shared by both draft routes. +function requireDepositNoticePath(ticket) { + if (depositBypassesNotice(ticket)) { + fail(`ดีลนี้ไม่ต้องเก็บมัดจำ (นโยบายมัดจำ ${ticket.depositPolicy}) จึงสร้างใบแจ้งรับมัดจำไม่ได้`, 409); + } +} + +// Mirrors TicketService.followQuotationDepositPolicy and TicketRepository +// .syncDepositPolicyFromQuotation's compare-and-set: while CUSTOMER_CONFIRMED, a policy the system +// owns (no person recorded in depositPolicySetBy, which setDepositPolicy writes) follows the +// quotation: 0% -> NOT_REQUIRED, any deposit -> REQUIRED. Called at confirmation and on a +// DEAL_DIRECT approval. depositPolicySetBy is internal: buildTicketDetail does not serve it. +function followQuotationDepositPolicy(ticket, quotation, user) { + if (!quotation) return; + const percent = quotation.depositPercent ?? MOCK_DEFAULT_DEPOSIT_PERCENT; // blank new-engine % = 30 + const noDeposit = percent === 0; + const expected = noDeposit ? 'REQUIRED' : 'NOT_REQUIRED'; + if (ticket.paymentStatus !== 'CUSTOMER_CONFIRMED' || ticket.depositPolicySetBy != null + || (ticket.depositPolicy ?? 'REQUIRED') !== expected) return; + const policy = noDeposit ? 'NOT_REQUIRED' : 'REQUIRED'; + const reason = `ใบเสนอราคา ${quotation.number} ` + + (noDeposit ? 'ระบุไม่รับมัดจำ (ชำระเต็มจำนวน)' : `ระบุมัดจำ ${percent}%`); + ticket.depositPolicy = policy; + ticket.depositPolicyReason = reason; + pushEvent(ticket, user, 'POLICY_CHANGED', ticket.salesStage, ticket.salesStage, + `deposit_policy → ${policy} — ${reason}`); +} + function moneyValue(value) { return Math.round((Number(value) || 0) * 100) / 100; } @@ -6379,19 +6442,19 @@ function nextMockDealQuotationNumber() { } // The pricing-request-chain (customerquotation/) flow's own number-space counter -- kept SEPARATE -// from mockCustomerQuotationSeq (the id sequence) for the same reason mockDealQuotationNumberSeq -// is kept separate from mockDealQuotationSeq above: a `number` and an `id` are different concerns +// from mockQuotationSeq (the id sequence) for the same reason mockDealQuotationNumberSeq +// is: a `number` and an `id` are different concerns // even though the real backend's sales.quotation_code_seq happens to be a global sequence shared // by BOTH flows' numbers (a fidelity gap this mock already had before today, pre-existing and out // of scope here -- the two flows' *own* number counters were already independent of one another). -// Seeded from mockCustomerQuotationSeq's OWN already-final value (the state-matrix seed block +// Seeded from mockQuotationSeq's already-final value (the state-matrix seed block // above, top-level code that has already run by the time this line executes, set it to one past // every demoSales.js-seeded row's id) -- every seeded row's bare number is literally // `QT-2026-{id}` (see demoSales.js#makeQuotation), so starting from the same point can never // collide with one of them. Mirrors `mockDealQuotationNumberSeq = 3` right after its own two-row // literal seed, just computed instead of hardcoded since this flow's seed count varies with // demoSales.js rather than being two fixed literal rows. -let mockCustomerQuotationNumberSeq = mockCustomerQuotationSeq; +let mockCustomerQuotationNumberSeq = mockQuotationSeq; function nextMockCustomerQuotationNumber() { return `QT-${new Date().getFullYear()}-${String(mockCustomerQuotationNumberSeq++).padStart(4, '0')}`; } @@ -6551,7 +6614,7 @@ function mintDealQuotationRevision(parent, user) { const revisionNo = nextMockDealQuotationRevisionNo(parent.ticketId, base); return { ...structuredClone(parent), - id: mockDealQuotationSeq++, + id: mockQuotationSeq++, number: quotationRevisionNumber(base, revisionNo), docStatus: 'DRAFT', revisionNo, @@ -6593,7 +6656,7 @@ function mintDealQuotationReorder(source, user) { const revisionNo = nextMockDealQuotationRevisionNo(source.ticketId, base); return { ...structuredClone(source), - id: mockDealQuotationSeq++, + id: mockQuotationSeq++, number: quotationRevisionNumber(base, revisionNo), docStatus: 'DRAFT', revisionNo, @@ -7153,6 +7216,13 @@ function resolveDealQuotationV3Header(payload, current = null) { return { priceMode, documentLanguage, currency }; } +// Mirrors DealQuotationRenderAdapter.DEFAULT_DEPOSIT_PERCENT: the percent a quotation's PDF +// prints, and its deposit notice bills, when its depositPercent is null. +export const MOCK_DEFAULT_DEPOSIT_PERCENT = 30; +// Mirrors QuotationRenderer.LEGACY_DEPOSIT_PERCENT: a legacy quotation's PDF always prints 50%, +// so its deposit notice bills 50% whatever percent was typed. +export const MOCK_LEGACY_DEPOSIT_PERCENT = 50; + /** * Item 4 (V181, "ไม่รับมัดจำ", owner ruling 2026-09-16) — mirrors * DealQuotationService#isZeroDeposit/#resolveFullPaymentTerm exactly: `depositPercent === 0` is @@ -7508,7 +7578,6 @@ function mockDealQuotationDocLines(row) { // ticket with 4 catalog-flavoured items already seeded in demoSales.js) -- one DRAFT, one // PENDING_APPROVAL, per the implementation brief. let mockDealQuotationItemSeq = 1; -let mockDealQuotationSeq = 3; const mockDealQuotations = [ { id: 1, @@ -7612,6 +7681,8 @@ const mockDealQuotations = [ }, ]; mockDealQuotationNumberSeq = 3; +// One id sequence across both quotation arrays: start past the highest literal seed id above. +mockQuotationSeq = Math.max(mockQuotationSeq, Math.max(...mockDealQuotations.map((q) => q.id)) + 1); // §5 WARN_UNPAID_* gates (V164 follow-up, 2026-09-09): `ruleWarnings`/`unpaidByRuleDays` on both @@ -9162,6 +9233,7 @@ export const api = { } ticket.depositPolicy = payload.policy; ticket.depositPolicyReason = payload.reason.trim(); + ticket.depositPolicySetBy = user.employeeId ?? user.id; // deposit_policy_set_by: a person chose it ticket.updatedAt = new Date().toISOString().slice(0, 10); pushEvent(ticket, user, 'POLICY_CHANGED', ticket.salesStage, ticket.salesStage, `deposit_policy → ${payload.policy} — ${payload.reason.trim()}`); @@ -13265,11 +13337,15 @@ export const api = { }, async createDraft(ticketId, payload) { - requireSession(); + // Mirrors DepositNoticeService.createDraftFromQuotation: requireRole(SALES_ROLES) + requireTicketOwner. + const user = hasRole('sales'); const ticket = findTicketRaw(Number(ticketId)); + if (ticket.createdById !== user.id) fail('ไม่มีสิทธิ์เข้าถึงรายการนี้', 403); + requireNoIssuedDepositNotice(ticket.id); // Phase 1 lifecycle gate (mirrors DepositNoticeService.createDraft). requireActive(ticket); if (!['approved', 'quotation_issued', 'document_issued'].includes(ticket.status)) fail('ดีลต้องได้รับการอนุมัติก่อน', 409); + requireDepositNoticePath(ticket); // Legacy path: auto-build from approved ticket items (sales.ticket_item.approved_price — // written only by the @Deprecated, routeless TicketService.approve). @@ -13279,23 +13355,26 @@ export const api = { const desc = [it.brand, it.model, it.color, it.texture, it.size].filter(Boolean).join(' '); return { seq: idx + 1, description: desc, qty: Number(it.qty), unit: 'แผ่น', unitPrice: Number(it.approvedPrice), discountLabel: null, netUnitPrice: Number(it.approvedPrice) }; }); - // Branch fix (deposit-notice autofill): every deal created through the pricing-request - // chain has approved_price = NULL on every ticket_item, so legacyItems above is always - // empty for it — mirrors DepositNoticeService.buildItemsFromRequest's own new-chain - // fallback to the ticket's own customer quotation (latest ACCEPTED, else latest ISSUED). + // Every deal created through the pricing-request chain has approved_price = NULL on every + // ticket_item, so legacyItems above is empty for it. The governing quotation always supplies + // the percent, and the lines and reference unless the payload or approved prices supply the + // lines (DepositNoticeService.createDraftFromQuotation). + const governing = mockGoverningQuotationForDepositNotice(Number(ticketId)); + if (!governing?.legacy && governing?.quotation.depositPercent === 0) { + fail(mockZeroDepositRefusal(governing.quotation.number), 409); + } let items = payload.items?.length ? payload.items : legacyItems; - if (!items.length) { - const quotation = mockPickTicketQuotationForDepositNotice(ticketId); - if (quotation) items = mockDepositNoticeItemsFromQuotation(quotation.items); - } - if (!items.length) { - // Then the live new-engine quotation: findDealQuotationItemsForDeposit, ordered as - // mockPickLiveQuotationForItemFallback's own comment says. The picker labels legacy rows - // 'PRICING_REQUEST' too, so the array tells new-engine apart. - const live = mockPickLiveQuotationForItemFallback(Number(ticketId)); - if (live && mockDealQuotations.includes(live.quotation)) { - items = mockDepositNoticeItemsFromDealQuotation(live.quotation); - } + const linesFromGoverning = !items.length && governing != null; + if (linesFromGoverning) { + items = governing.legacy + ? mockDepositNoticeItemsFromQuotation(governing.quotation.items) + : mockDepositNoticeItemsFromDealQuotation(governing.quotation); + } + // A typed percent is ignored, as in Java; 0.5 only when the ticket has no live quotation. + let depositPercent = 0.5; + if (governing) { + depositPercent = (governing.legacy ? MOCK_LEGACY_DEPOSIT_PERCENT + : governing.quotation.depositPercent ?? MOCK_DEFAULT_DEPOSIT_PERCENT) / 100; } // Branch fix: header autofill (mirrors DepositNoticeService.createDraft) — a caller- @@ -13311,8 +13390,8 @@ export const api = { version: nextVer, docNumber: null, issueDate: null, status: 'DRAFT', customerName: payload.customerName ?? ticket.customerName ?? '', customerTaxId: customerTaxId ?? '', customerAddress: customerAddress ?? '', - projectName: projectName ?? '', reference: payload.reference ?? '', - depositPercent: payload.depositPercent ?? 0.5, vatPercent: 0.07, + projectName: projectName ?? '', reference: payload.reference ?? (linesFromGoverning ? governing.quotation.number : null), + depositPercent, vatPercent: 0.07, notes, items, issuedByName: null, preparerName: 'จินตนา หาญมนตรี', hasPdf: false, hasXlsx: false, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), @@ -13338,9 +13417,25 @@ export const api = { async update(docId, payload) { requireSession(); + // DepositNoticeDraftRequest's @DecimalMin/@DecimalMax: @Valid runs before any lookup. + const sent = payload.depositPercent == null ? null : Number(payload.depositPercent) * 100; + if (sent != null && sent > 100) fail('depositPercent เปอร์เซ็นต์มัดจำต้องไม่เกิน 100', 400); + if (sent != null && !(sent > 0)) fail('depositPercent เปอร์เซ็นต์มัดจำต้องมากกว่า 0', 400); + // DepositNoticeService.update: sales only, a DRAFT, the deal owner, then the % rules. + const user = hasRole('sales'); const doc = mockDepositNotices.find((d) => d.id === Number(docId)); if (!doc) fail('ไม่พบใบแจ้งรับมัดจำนี้', 404); if (doc.status !== 'DRAFT') fail('ใบแจ้งรับมัดจำนี้ไม่ได้อยู่ในสถานะร่าง (DRAFT)', 409); + if (findTicketRaw(doc.ticketId).createdById !== user.id) fail('ไม่มีสิทธิ์เข้าถึงรายการนี้', 403); + const stored = Number(doc.depositPercent) * 100; + const pct = sent ?? stored; + if (!(pct > 0)) fail('เปอร์เซ็นต์มัดจำต้องมากกว่า 0', 400); + // A stored legacy fraction sent back unchanged still saves. + if (pct !== stored && Math.abs(pct - Math.round(pct)) > 1e-9) fail('เปอร์เซ็นต์มัดจำต้องเป็นจำนวนเต็ม', 400); + // DepositNoticeRepository#update: a save over someone else's newer one is refused. + if (payload.expectedUpdatedAt != null && payload.expectedUpdatedAt !== doc.updatedAt) { + fail('ใบแจ้งรับมัดจำนี้ถูกแก้ไขโดยผู้ใช้อื่น กรุณาโหลดข้อมูลใหม่แล้วลองอีกครั้ง', 409); + } Object.assign(doc, { customerName: payload.customerName ?? doc.customerName, customerTaxId: payload.customerTaxId ?? doc.customerTaxId, @@ -13350,7 +13445,8 @@ export const api = { depositPercent: payload.depositPercent ?? doc.depositPercent, notes: payload.notes ?? doc.notes, items: payload.items?.length ? payload.items : doc.items, - updatedAt: new Date().toISOString(), + // Strictly later than the stored stamp, so two saves in one millisecond still differ. + updatedAt: new Date(Math.max(Date.now(), (Date.parse(doc.updatedAt) || 0) + 1)).toISOString(), }); const updated = buildMockDoc(doc); Object.assign(doc, updated); @@ -13366,12 +13462,23 @@ export const api = { return mockPreviewHtml(buildMockDoc(doc)); }, - async issue(docId) { + async issue(docId, payload) { const user = requireSession(); const doc = mockDepositNotices.find((d) => d.id === Number(docId)); if (!doc) fail('ไม่พบใบแจ้งรับมัดจำนี้', 404); if (doc.status !== 'DRAFT') fail('ไม่ได้อยู่ในสถานะร่าง (DRAFT)', 409); const ticket = findTicketRaw(doc.ticketId); + // DepositNoticeService.issue: sales only, the deal owner, and a stored % the document can print. + if (user.role !== 'sales' || ticket.createdById !== user.id) fail('ไม่มีสิทธิ์เข้าถึงรายการนี้', 403); + const pct = Number(doc.depositPercent) * 100; + if (!(pct > 0 && pct <= 100) || Math.abs(pct - Math.round(pct)) > 1e-9) { + fail(`ใบแจ้งรับมัดจำนี้มีเปอร์เซ็นต์มัดจำ ${+pct.toFixed(4)}% จึงออกเอกสารไม่ได้ (ต้องเป็นจำนวนเต็ม 1–100)`, 409); + } + + // Mirrors DepositNoticeService.issue's one-notice-per-deal refusal (owner ruling 2026-10-23). + // Checked before the payment-state gate: the mock cannot re-issue from DEPOSIT_NOTICE_ISSUED. + const other = mockDepositNotices.find((d) => d.ticketId === doc.ticketId && d.id !== doc.id && d.status === 'ISSUED'); + if (other) fail(`ดีลนี้ออกใบแจ้งรับมัดจำเลขที่ ${other.docNumber} แล้ว จึงออกฉบับร่างนี้ไม่ได้`, 409); // Mirrors DepositNoticeService.issue: the document IS the payment-track step. // Requires a customer-confirmed quotation; advances paymentStatus and leaves @@ -13379,11 +13486,14 @@ export const api = { if (ticket.status !== 'quotation_issued' || ticket.paymentStatus !== 'CUSTOMER_CONFIRMED') { fail('ออกใบแจ้งรับมัดจำได้เฉพาะเมื่อออกใบเสนอราคาแล้วและลูกค้ายืนยันคำสั่งซื้อแล้วเท่านั้น', 409); } + // Mirrors DepositNoticeService.issue's amount guard: a 0-baht notice takes no number. + if (!(Number(doc.depositAmount) > 0)) fail('ใบแจ้งรับมัดจำนี้มียอดมัดจำ 0 บาท จึงออกเอกสารไม่ได้', 409); // Phase 1 lifecycle gate (mirrors DepositNoticeService.requireActiveLifecycle). requireActive(ticket); - - // Supersede previous issued docs - mockDepositNotices.forEach((d) => { if (d.ticketId === doc.ticketId && d.id !== doc.id && d.status === 'ISSUED') d.status = 'SUPERSEDED'; }); + // DepositNoticeRepository#issue: the draft was saved after the caller read it. + if (payload?.expectedUpdatedAt != null && payload.expectedUpdatedAt !== doc.updatedAt) { + fail('ใบแจ้งรับมัดจำนี้ถูกแก้ไขโดยผู้ใช้อื่น กรุณาโหลดข้อมูลใหม่แล้วลองอีกครั้ง', 409); + } const thaiYear = new Date().getFullYear() + 543; doc.docNumber = `GLRD${String(thaiYear).slice(-2)}${String(mockDocNumberSeq++).padStart(3,'0')}`; @@ -16174,6 +16284,8 @@ export const api = { ticket.paymentStatus = 'CUSTOMER_CONFIRMED'; ticket.updatedAt = now; pushEvent(ticket, user, 'CUSTOMER_CONFIRMED', ticket.status, ticket.status, null); + followQuotationDepositPolicy(ticket, mockDealQuotations.find( + (q) => q.pricingRequestId === pr.id && q.origin === 'PRICING_REQUEST' && q.docStatus === 'ACCEPTED'), user); autoAdvanceStage(ticket, 'ORDER_RECEIVED', user); } @@ -16185,7 +16297,7 @@ export const api = { return delay({ result: { ticket: buildTicketDetail(ticket), pricingRequest: buildPricingRequestSummary(pr) } }); }, - async createDepositNoticeFromQuotation(id, payload = {}) { + async createDepositNoticeFromQuotation(id, payload = {}) { // eslint-disable-line no-unused-vars -- hrApi arity; Java ignores the typed percent const user = hasRole('sales'); const pr = findPricingRequestRaw(id); const ticket = db.tickets.find((t) => t.id === pr.ticketId); @@ -16200,9 +16312,8 @@ export const api = { (q) => q.pricingRequestId === pr.id && q.origin === 'PRICING_REQUEST' && q.docStatus === 'ACCEPTED'); if (!accepted && !newEngine) fail('ยังไม่มีใบเสนอราคาที่ลูกค้ายอมรับสำหรับคำขอราคานี้', 409); // Mirrors the zero-deposit refusal: a 0% ("ไม่รับมัดจำ") quotation has nothing to bill. - if (newEngine?.depositPercent === 0) { - fail(`ใบเสนอราคา ${newEngine.number} ระบุไม่มีมัดจำ (ชำระเต็มจำนวน) จึงออกใบแจ้งยอดเงินรับมัดจำไม่ได้`, 409); - } + if (newEngine?.depositPercent === 0) fail(mockZeroDepositRefusal(newEngine.number), 409); + requireNoIssuedDepositNotice(ticket.id); const reference = (accepted ?? newEngine).number; const items = accepted ? mockDepositNoticeItemsFromQuotation(accepted.items) @@ -16215,6 +16326,7 @@ export const api = { if (!['approved', 'quotation_issued', 'document_issued'].includes(ticket.status)) { fail('สร้างใบแจ้งรับมัดจำได้เฉพาะดีลที่อนุมัติแล้วเท่านั้น', 409); } + requireDepositNoticePath(ticket); // Branch fix: header autofill — this entry point never received an explicit customer/ // project from its caller, so it always sourced the header from the (blank) defaults @@ -16229,11 +16341,11 @@ export const api = { customerName: ticket.customerName ?? '', customerTaxId: customerTaxId ?? '', customerAddress: customerAddress ?? '', projectName: projectName ?? '', reference, - // Mirrors DepositNoticeService.createDraftFromQuotation: the new-engine branch bills the - // chosen quotation's own whole-number percent; the legacy branch keeps the old stand-in. - depositPercent: newEngine?.depositPercent != null - ? newEngine.depositPercent / 100 - : payload.depositPercent ?? 0.5, + // Mirrors OrderConfirmationService.createDepositNoticeFromQuotation: the percent the chosen + // quotation's PDF printed; a typed payload.depositPercent is ignored. + depositPercent: (newEngine + ? newEngine.depositPercent ?? MOCK_DEFAULT_DEPOSIT_PERCENT + : MOCK_LEGACY_DEPOSIT_PERCENT) / 100, vatPercent: 0.07, notes, items, issuedByName: null, preparerName: 'จินตนา หาญมนตรี', hasPdf: false, hasXlsx: false, @@ -16659,7 +16771,7 @@ export const api = { const createBaseNumber = mockDealBaseNumberOrMint(ticket.id); const createRevisionNo = nextMockDealQuotationRevisionNo(ticket.id, createBaseNumber); const row = { - id: mockDealQuotationSeq++, + id: mockQuotationSeq++, // Owner feedback 2026-09-11: the FIRST issued document now carries the revision suffix // too -- "QT-2026-0014-1", not a bare "QT-2026-0014". Mirrors // DealQuotationService#create's own numbering — which since the 2026-10-17 ruling CONTINUES @@ -16745,18 +16857,38 @@ export const api = { const pr = findPricingRequestRaw(pricingRequestId); const ticket = db.tickets.find((t) => t.id === pr.ticketId); if (ticket?.createdById !== user.id) fail('ไม่มีสิทธิ์เข้าถึงรายการนี้', 403); - if (pr.status !== 'APPROVED_FOR_QUOTATION') { + if ((ticket.lifecycle ?? 'ACTIVE') !== 'ACTIVE') fail('ดีลต้นทางต้องอยู่ในสถานะ ACTIVE', 409); + // The service's re-quote: a request already QUOTATION_ISSUED may be quoted again (its quotation was + // rejected, expired or sent back for changes; the live-quotation guard below refuses the rest), + // but never once a newer quotation on the deal superseded its latest one. + if (pr.status !== 'APPROVED_FOR_QUOTATION' && pr.status !== 'QUOTATION_ISSUED') { fail(`คำขอราคาต้องอยู่ในสถานะ 'อนุมัติราคาขายแล้ว' ก่อนจึงจะออกใบเสนอราคาลูกค้าได้ (ปัจจุบัน: ${pr.status})`, 409); } + const latest = mockDealQuotations + .filter((q) => q.pricingRequestId === pr.id && q.origin === 'PRICING_REQUEST') + .reduce((newest, q) => (newest == null || q.id > newest.id ? q : newest), null); + if (pr.status === 'QUOTATION_ISSUED' && latest?.docStatus === 'SUPERSEDED') { + fail('คำขอราคานี้ถูกแทนที่ด้วยใบเสนอราคาฉบับใหม่ของดีลแล้ว — ออกใบเสนอราคาจากคำขอราคาล่าสุด หรือสร้างคำขอราคาใหม่', 409); + } const decision = mockPricingDecisions.find((d) => d.pricingRequestId === pr.id && d.status === 'APPROVED'); if (!decision) fail('ยังไม่มีราคาขายที่ CEO อนุมัติสำหรับคำขอราคานี้', 409); if (!decision.priceMode) { fail('คำขอราคานี้ยังใช้รูปแบบราคาเดิม (ก่อน CEO เลือกวิธีกรอกราคา) กรุณาสร้างใบเสนอราคาแบบเดิมจากหน้านี้', 409); } + // Mutual exclusivity — CustomerQuotationRepository#hasLiveQuotation's set. + if (mockCustomerQuotations.some((q) => q.pricingRequestId === pr.id + && !['CANCELLED', 'SUPERSEDED', 'EXPIRED', 'REJECTED'].includes(q.docStatus))) { + fail('คำขอราคานี้มีใบเสนอราคาลูกค้า (แบบเดิม) อยู่แล้ว ไม่สามารถสร้างใบเสนอราคาแบบใหม่ซ้ำได้', 409); + } // Idempotent replay guard — same rule as DealQuotationRepository#findOpenDraftForPricingRequest. const existingDraft = mockDealQuotations.find( (q) => q.pricingRequestId === pr.id && q.origin === 'PRICING_REQUEST' && q.docStatus === 'DRAFT'); if (existingDraft) return delay({ quotation: buildDealQuotationDto(existingDraft) }); + // One live quotation per request — DealQuotationRepository#hasLivePricingRequestQuotation's set. + if (mockDealQuotations.some((q) => q.pricingRequestId === pr.id && q.origin === 'PRICING_REQUEST' + && !['CANCELLED', 'SUPERSEDED', 'EXPIRED', 'REJECTED', 'REVISION_REQUESTED'].includes(q.docStatus))) { + fail('คำขอราคานี้มีใบเสนอราคาที่ยังไม่หมดอายุอยู่แล้ว ไม่สามารถสร้างใบเสนอราคาใหม่ซ้ำได้', 409); + } const now = new Date().toISOString(); const contactSnapshot = resolveDealQuotationContact(ticket, { contactId: pr.recipientContactId }); @@ -16802,7 +16934,7 @@ export const api = { const prBaseNumber = mockDealBaseNumberOrMint(ticket.id); const prRevisionNo = nextMockDealQuotationRevisionNo(ticket.id, prBaseNumber); const row = { - id: mockDealQuotationSeq++, + id: mockQuotationSeq++, number: quotationRevisionNumber(prBaseNumber, prRevisionNo), ticketId: ticket.id, origin: 'PRICING_REQUEST', @@ -17247,6 +17379,9 @@ export const api = { } ancestorId = ancestor.parentQuotationId; } + // #1173 review F1 (DealQuotationService#approve -> TicketService#followQuotationDepositPolicy). + const dealTicket = db.tickets.find((t) => t.id === row.ticketId); + if (dealTicket) followQuotationDepositPolicy(dealTicket, row, user); return delay({ quotation: buildDealQuotationDto(row) }); }, @@ -17489,6 +17624,7 @@ export const api = { if (ticket.paymentStatus == null) { ticket.paymentStatus = 'CUSTOMER_CONFIRMED'; pushEvent(ticket, user, 'CUSTOMER_CONFIRMED', ticket.status, ticket.status, null); + followQuotationDepositPolicy(ticket, row, user); } autoAdvanceStage(ticket, 'ORDER_RECEIVED', user); return result(); diff --git a/frontend/src/api/mockApi.pricingRequestQuotation.test.js b/frontend/src/api/mockApi.pricingRequestQuotation.test.js index 7a46e123b..33ae8a6ec 100644 --- a/frontend/src/api/mockApi.pricingRequestQuotation.test.js +++ b/frontend/src/api/mockApi.pricingRequestQuotation.test.js @@ -248,6 +248,41 @@ describe('mockApi.dealQuotations.createFromPricingRequest', () => { }); }); +// Spec L1 (2026-10-27): the deal page's sticky "เสนอใบราคา" calls this endpoint, so the mock +// refuses what DealQuotationService#createFromPricingRequest refuses (same 409s, same Thai messages) +// instead of quietly writing a second quotation. Mock plumbing only — the real-DB tests are the +// authority on the service itself. +describe('mockApi.dealQuotations.createFromPricingRequest — refuses what the service refuses (mock parity)', () => { + it('a deal that is no longer ACTIVE: 409 "ดีลต้นทางต้องอยู่ในสถานะ ACTIVE"', async () => { + const { ticketId, prId } = await approvedNetPricingRequest(); + await api.tickets.hold(ticketId, { note: 'ลูกค้าขอพักโครงการ' }); + + await expect(api.dealQuotations.createFromPricingRequest(prId)) + .rejects.toMatchObject({ status: 409, message: 'ดีลต้นทางต้องอยู่ในสถานะ ACTIVE' }); + }); + + it('the request\'s quotation already past DRAFT (awaiting approval): 409, and no second quotation is written', async () => { + const { ticketId, prId } = await approvedNetPricingRequest(); + const { quotation } = await api.dealQuotations.createFromPricingRequest(prId); + await api.dealQuotations.submit(quotation.id); + + await expect(api.dealQuotations.createFromPricingRequest(prId)).rejects.toMatchObject({ + status: 409, message: 'คำขอราคานี้มีใบเสนอราคาที่ยังไม่หมดอายุอยู่แล้ว ไม่สามารถสร้างใบเสนอราคาใหม่ซ้ำได้', + }); + const { items } = await api.dealQuotations.listForTicket(ticketId); + expect(items.filter((q) => q.pricingRequestId === prId).map((q) => q.id)).toEqual([quotation.id]); + }); + + it('a live old-engine customer quotation on the request: 409', async () => { + const { prId } = await approvedNetPricingRequest(); + await api.pricingRequests.createCustomerQuotation(prId, { clientRequestId: nextClientRequestId() }); + + await expect(api.dealQuotations.createFromPricingRequest(prId)).rejects.toMatchObject({ + status: 409, message: 'คำขอราคานี้มีใบเสนอราคาลูกค้า (แบบเดิม) อยู่แล้ว ไม่สามารถสร้างใบเสนอราคาแบบใหม่ซ้ำได้', + }); + }); +}); + describe('mockApi.dealQuotations.update — origin=PRICING_REQUEST extra-row refusal', () => { it('refuses a payload that adds a new row, and writes nothing', async () => { const { prId } = await approvedNetPricingRequest(); diff --git a/frontend/src/api/mockApi.pricingRequests.test.js b/frontend/src/api/mockApi.pricingRequests.test.js index 65db781fd..ec6df64e8 100644 --- a/frontend/src/api/mockApi.pricingRequests.test.js +++ b/frontend/src/api/mockApi.pricingRequests.test.js @@ -490,8 +490,8 @@ describe('mockApi.pricingRequests customerQuotation numbering (review finding M1 // Concrete failure this catches (CLAUDE.md's "Mock API contract": the mock is the default // verification surface, so silence here is the dangerous kind): if mockCustomerQuotationNumberSeq's - // top-level `let` (mockApi.js ~L4878, `= mockCustomerQuotationSeq`) ever executed BEFORE the - // top-level seed block (~L1161) advances mockCustomerQuotationSeq past every demoSales.js-seeded + // top-level `let` (mockApi.js, `= mockQuotationSeq`) ever executed BEFORE the + // top-level seed block advances mockQuotationSeq past every demoSales.js-seeded // row's id, the counter would start at 1 and mint a BASE identical to an already-seeded row's // bare number. Every seeded row's number is literally `QT-2026-{id}` (demoSales.js#makeQuotation) // and every mock-minted number always carries a `-{revisionNo}` suffix, so the two FULL strings diff --git a/frontend/src/api/mockApi.promoteToDeal.test.js b/frontend/src/api/mockApi.promoteToDeal.test.js index 72d5380b7..ff9bb889f 100644 --- a/frontend/src/api/mockApi.promoteToDeal.test.js +++ b/frontend/src/api/mockApi.promoteToDeal.test.js @@ -35,9 +35,9 @@ async function quotationOnlyTicket() { return ticket.summary.id; } -async function approvedQuotation(ticketId) { +async function approvedQuotation(ticketId, items = [TILE, PLAIN, ADJUSTMENT]) { await asRole('sales'); - const { quotation: draft } = await api.dealQuotations.create(ticketId, { recipientType: 'DESIGNER', items: [TILE, PLAIN, ADJUSTMENT] }); + const { quotation: draft } = await api.dealQuotations.create(ticketId, { recipientType: 'DESIGNER', items }); await api.dealQuotations.submit(draft.id); await asRole('sales_manager'); const { quotation } = await api.dealQuotations.approve(draft.id); @@ -126,6 +126,27 @@ describe('mock dealQuotations.promoteToDeal (GLA-136)', () => { expect(await listIds()).toContain(ghost); }); + // Owner ruling 2026-10-23 (DepositNoticeRepository#foldAdjustments): the deposit draft carries no + // qty -1 ส่วนลดพิเศษ row; a flat 95 is spread by line amount (TILE 8,500 takes 85, PLAIN 1,000 + // takes 10), so the subtotal is the quotation's 9,405. The mock computes no % adjustment amount + // (MOCK_NOT_COMPUTED), so a % row is only dropped. + it('the deposit draft folds a flat ส่วนลดพิเศษ into the goods lines and drops a % one', async () => { + const ghost = await quotationOnlyTicket(); + const flat = await approvedQuotation(ghost, [TILE, PLAIN, { lineType: 'ADJUSTMENT', description: 'ส่วนลดพิเศษ', adjustmentAmount: 95 }]); + await api.dealQuotations.promoteToDeal(flat.id); + const { depositNotice } = await api.depositNotices.createDraft(ghost, {}); + expect(depositNotice.items.map((i) => [i.qty, i.unitPrice, i.netUnitPrice, i.discountLabel])).toEqual([ + [10, 850, 841.5, 'ส่วนลดพิเศษ 8.5 ต่อหน่วย'], + [2, 500, 495, 'ส่วนลดพิเศษ 5 ต่อหน่วย'], + ]); + expect(depositNotice.subtotal).toBe(9405); + + const other = await quotationOnlyTicket(); + await api.dealQuotations.promoteToDeal((await approvedQuotation(other)).id); + const { depositNotice: pctDraft } = await api.depositNotices.createDraft(other, {}); + expect(pctDraft.items.map((i) => i.qty)).toEqual([10, 2]); + }); + it('a replay is idempotent — same deal, no second event', async () => { const ghost = await quotationOnlyTicket(); const approved = await approvedQuotation(ghost); @@ -173,4 +194,100 @@ describe('mock dealQuotations.promoteToDeal (GLA-136)', () => { it('404s an unknown quotation', async () => { await expect(api.dealQuotations.promoteToDeal(987654)).rejects.toMatchObject({ status: 404 }); }); + + // Mirrors TicketService.applyCustomerConfirmation's policy sync (owner ruling 2026-10-22). + it('a 0% (ไม่รับมัดจำ) quotation confirms the deal onto depositPolicy NOT_REQUIRED', async () => { + const ghost = await quotationOnlyTicket(); + const { quotation: draft } = await api.dealQuotations.create(ghost, { + recipientType: 'DESIGNER', depositPercent: 0, fullPaymentTerm: 'BEFORE_DELIVERY', items: [TILE, PLAIN], + }); + await api.dealQuotations.submit(draft.id); + await asRole('sales_manager'); + const { quotation: approved } = await api.dealQuotations.approve(draft.id); + await asRole('sales'); + + await api.dealQuotations.promoteToDeal(approved.id); + + const { ticket } = await api.tickets.get(ghost); + expect(ticket.summary.depositPolicy).toBe('NOT_REQUIRED'); + expect(ticket.events.filter((e) => e.kind === 'POLICY_CHANGED').map((e) => e.message)) + .toEqual([expect.stringContaining(approved.number)]); + }); + + // PR #1173 review F1, mirrors DealQuotationService#approve -> TicketService + // #followQuotationDepositPolicy: while CUSTOMER_CONFIRMED, a policy the system set follows the + // newly approved revision; a policy a person set is kept. + async function approvedWith(ticketId, payload) { + const { quotation: draft } = await api.dealQuotations.create(ticketId, { recipientType: 'DESIGNER', ...payload }); + await api.dealQuotations.submit(draft.id); + await asRole('sales_manager'); + const { quotation } = await api.dealQuotations.approve(draft.id); + await asRole('sales'); + return quotation; + } + + async function approvedRevisionOf(source, payload) { + const { quotation: revision } = await api.dealQuotations.createRevision(source.id); + await api.dealQuotations.update(revision.id, { recipientType: 'DESIGNER', ...payload }); + await api.dealQuotations.submit(revision.id); + await asRole('sales_manager'); + const { quotation } = await api.dealQuotations.approve(revision.id); + await asRole('sales'); + return quotation; + } + + const ZERO = { depositPercent: 0, fullPaymentTerm: 'BEFORE_DELIVERY', items: [TILE, PLAIN] }; + const FIFTY = { depositPercent: 50, items: [TILE, PLAIN] }; + const policyEvents = (ticket) => ticket.events.filter((e) => e.kind === 'POLICY_CHANGED').map((e) => e.message); + + it('a confirmed 0% deal whose approved revision adds a deposit goes back to REQUIRED', async () => { + const ghost = await quotationOnlyTicket(); + const zero = await approvedWith(ghost, ZERO); + await api.dealQuotations.promoteToDeal(zero.id); + + const revision = await approvedRevisionOf(zero, FIFTY); + + const { ticket } = await api.tickets.get(ghost); + expect(ticket.summary.depositPolicy).toBe('REQUIRED'); + expect(ticket.summary.depositPolicyReason).toBe(`ใบเสนอราคา ${revision.number} ระบุมัดจำ 50%`); + expect(policyEvents(ticket)).toHaveLength(2); + }); + + it('a confirmed 0% deal whose approved revision leaves the percent blank counts as 30 and is REQUIRED', async () => { + const ghost = await quotationOnlyTicket(); + const zero = await approvedWith(ghost, ZERO); + await api.dealQuotations.promoteToDeal(zero.id); + + const revision = await approvedRevisionOf(zero, { depositPercent: null, fullPaymentTerm: null, items: [TILE, PLAIN] }); + + const { ticket } = await api.tickets.get(ghost); + expect(ticket.summary.depositPolicy).toBe('REQUIRED'); + expect(ticket.summary.depositPolicyReason).toBe(`ใบเสนอราคา ${revision.number} ระบุมัดจำ 30%`); + }); + + it('a confirmed 50% deal whose approved revision says ไม่รับมัดจำ moves to NOT_REQUIRED', async () => { + const ghost = await quotationOnlyTicket(); + const fifty = await approvedWith(ghost, FIFTY); + await api.dealQuotations.promoteToDeal(fifty.id); + + const revision = await approvedRevisionOf(fifty, ZERO); + + const { ticket } = await api.tickets.get(ghost); + expect(ticket.summary.depositPolicy).toBe('NOT_REQUIRED'); + expect(policyEvents(ticket)).toEqual([expect.stringContaining(revision.number)]); + }); + + it('a NOT_REQUIRED a person set is kept when a revision still carries a deposit', async () => { + const ghost = await quotationOnlyTicket(); + const fifty = await approvedWith(ghost, FIFTY); + await api.dealQuotations.promoteToDeal(fifty.id); + await api.tickets.setDepositPolicy(ghost, { policy: 'NOT_REQUIRED', reason: 'ลูกค้าโอนเต็มจำนวนล่วงหน้า' }); + + await approvedRevisionOf(fifty, FIFTY); + + const { ticket } = await api.tickets.get(ghost); + expect(ticket.summary.depositPolicy).toBe('NOT_REQUIRED'); + expect(ticket.summary.depositPolicyReason).toBe('ลูกค้าโอนเต็มจำนวนล่วงหน้า'); + expect(policyEvents(ticket)).toHaveLength(1); + }); }); diff --git a/frontend/src/app/RequireAccess.jsx b/frontend/src/app/RequireAccess.jsx index 1d0df701e..1e3a7b1df 100644 --- a/frontend/src/app/RequireAccess.jsx +++ b/frontend/src/app/RequireAccess.jsx @@ -1,6 +1,6 @@ import { Navigate, Outlet, useLocation } from 'react-router-dom'; import { AccessDeniedPage } from '../components/common/AccessDeniedPage.jsx'; -import { canAccessPath, importDealRedirectFor } from './permissions.js'; +import { canAccessPath, dealRedirectFor } from './permissions.js'; // Route guard: renders the nested route (``) when `user` may access // the current path. Otherwise renders an in-place "insufficient privileges" @@ -19,10 +19,10 @@ import { canAccessPath, importDealRedirectFor } from './permissions.js'; export function RequireAccess({ user }) { const location = useLocation(); if (!canAccessPath(location.pathname, user)) { - // Import no longer opens the whole-deal page; a stale deep-link goes to its own page for the - // same deal (query string dropped — the whole-deal page's params mean nothing there). - const importDealPath = importDealRedirectFor(location.pathname, user); - if (importDealPath) return ; + // Import and account are refused the whole-deal page; a stale deep-link goes to their own page + // for the same deal (query string dropped — the whole-deal page's params mean nothing there). + const dealPath = dealRedirectFor(location.pathname, user); + if (dealPath) return ; return ; } return ; diff --git a/frontend/src/app/RequireAccess.test.jsx b/frontend/src/app/RequireAccess.test.jsx index a2e0d4bf0..7d258e7a3 100644 --- a/frontend/src/app/RequireAccess.test.jsx +++ b/frontend/src/app/RequireAccess.test.jsx @@ -1,7 +1,7 @@ import React from 'react'; -import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; import { render, screen } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { RequireAccess } from './RequireAccess.jsx'; globalThis.React = React; @@ -66,3 +66,87 @@ describe('RequireAccess', () => { expect(screen.getByText('หน้าดีลเต็ม')).not.toBeNull(); }); }); + +// Production runs with the self-service lock ON, and under it canAccessPath refuses account every +// /tickets path — so TicketDetailRoute's own account -> /finance/deals/:id redirect never mounts. +// The deposit-notice notification sent to account links /tickets/:id; it must still land on the +// finance deal page. features.js reads the flag at module load and vitest.config.js pins it OFF, +// so this needs vi.stubEnv + vi.resetModules + a fresh dynamic import of the guard. +describe('RequireAccess under the self-service lock', () => { + const account = { role: 'account', employeeId: 3 }; + + afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); + }); + + function Probe({ label }) { + const { pathname, search } = useLocation(); + return

    {`${label} ${pathname}${search}`}
    ; + } + + async function renderLocked(initialPath, user) { + vi.stubEnv('VITE_SELF_SERVICE_ONLY', 'true'); + vi.resetModules(); + const { RequireAccess: LockedRequireAccess } = await import('./RequireAccess.jsx'); + return render( + + + }> + } /> + } /> + } /> + } /> + } /> + } /> + + + , + ); + } + + const denied = () => screen.queryByRole('heading', { name: 'ไม่มีสิทธิ์เข้าถึงหน้านี้' }); + + it('sends account from /tickets/:id to /finance/deals/:id instead of the access-denied page', async () => { + await renderLocked('/tickets/123', account); + expect(screen.getByText('finance-deal /finance/deals/123')).not.toBeNull(); + expect(denied()).toBeNull(); + }); + + it('sends account from /tickets/:id/deposit to /finance/deals/:id', async () => { + await renderLocked('/tickets/123/deposit', account); + expect(screen.getByText('finance-deal /finance/deals/123')).not.toBeNull(); + expect(denied()).toBeNull(); + }); + + // The query string is dropped, like import's redirect: FinanceDealPage reads no search params. + it('still redirects account when the deal link carries a query string', async () => { + await renderLocked('/tickets/123?from=notification', account); + expect(screen.getByText('finance-deal /finance/deals/123')).not.toBeNull(); + expect(denied()).toBeNull(); + }); + + it('still refuses account the deal pipeline list at /tickets', async () => { + await renderLocked('/tickets', account); + expect(denied()).not.toBeNull(); + expect(screen.queryByText(/^pipeline /)).toBeNull(); + expect(screen.queryByText(/^finance-deal /)).toBeNull(); + }); + + it('still refuses account /quotations/:id', async () => { + await renderLocked('/quotations/5', account); + expect(denied()).not.toBeNull(); + expect(screen.queryByText(/^finance-deal /)).toBeNull(); + }); + + it('still sends import from /tickets/:id to /import/deals/:id', async () => { + await renderLocked('/tickets/123', { role: 'import', employeeId: 2 }); + expect(screen.getByText('import-deal /import/deals/123')).not.toBeNull(); + }); + + it('does not redirect a sales rep (exempt from the lock) off the whole deal', async () => { + await renderLocked('/tickets/123', { role: 'sales', employeeId: 9 }); + expect(screen.getByText('whole-deal /tickets/123')).not.toBeNull(); + expect(screen.queryByText(/^finance-deal /)).toBeNull(); + }); +}); diff --git a/frontend/src/app/permissions.js b/frontend/src/app/permissions.js index 0c1567371..8df97738d 100644 --- a/frontend/src/app/permissions.js +++ b/frontend/src/app/permissions.js @@ -201,7 +201,7 @@ const SELF_SERVICE_EXEMPT_ROLES = ['hr', 'ceo', 'sales', 'sales_manager']; // Per-role release allowlist for roles that stay locked. Same prefix rule as // SELF_SERVICE_PATHS (the trailing slash is required, so `/finance` does not // admit `/finance-archive`). `/import/deals` is the queue's own per-deal -// drill-down (importDealRedirectFor), not a wider surface. +// drill-down (dealRedirectFor), not a wider surface. const RELEASED_PATHS_BY_ROLE = { import: ['/fulfilment', '/pricing-requests', '/import/deals'], account: ['/finance'], @@ -296,17 +296,29 @@ export function isBillingNoteReleaseUser(user) { return Boolean(user?.canIssueBillingNote); } +// Role -> its own per-deal page, and whether `/tickets/:id/` (the deposit page) follows. +const DEAL_REDIRECTS = { + // `/tickets/:id/deposit` has no import equivalent and stays refused. + import: { prefix: '/import/deals', subPaths: false }, + // Account works the deposit through the finance deal page. Under the self-service lock this is + // the ONLY way account's deal links land: RequireAccess refuses /tickets/* before + // TicketDetailRoute's own account redirect can mount (DepositNoticeService notifies account + // with a /tickets/:id link). + account: { prefix: '/finance/deals', subPaths: true }, +}; + /** - * Where an `import` user landing on a whole-deal URL (`/tickets/:id`) should go instead: their own + * Where a user refused a whole-deal URL (`/tickets/:id`) should go instead: their role's own * per-deal page. Backend notification deep-links and old bookmarks still point at `/tickets/:id`, - * and a dead-end refusal there helps nobody. Only the bare detail path redirects — `/tickets` - * (the pipeline browser) and `/tickets/:id/deposit` have no import equivalent and stay refused. + * and a dead-end refusal there helps nobody. `/tickets` (the pipeline browser) never redirects. * Returns null when no redirect applies. Routing convenience only; the backend is the authority. */ -export function importDealRedirectFor(path, user) { - if (user?.role !== 'import') return null; - const match = /^\/tickets\/(\d+)\/?$/.exec(path.split(/[?#]/)[0]); - return match ? `/import/deals/${match[1]}` : null; +export function dealRedirectFor(path, user) { + const target = DEAL_REDIRECTS[user?.role]; + if (!target) return null; + const match = /^\/tickets\/(\d+)(\/[^/]+)?\/?$/.exec(path.split(/[?#]/)[0]); + if (!match || (match[2] && !target.subPaths)) return null; + return `${target.prefix}/${match[1]}`; } export function canAccessPath(path, user) { diff --git a/frontend/src/app/permissions.test.js b/frontend/src/app/permissions.test.js index 06be6ac73..847b69d99 100644 --- a/frontend/src/app/permissions.test.js +++ b/frontend/src/app/permissions.test.js @@ -1,5 +1,5 @@ import { afterEach, describe, it, expect, vi } from 'vitest'; -import { hasPermission, allowedRoute, canAccessPath, isDivisionManager, importDealRedirectFor } from './permissions.js'; +import { hasPermission, allowedRoute, canAccessPath, isDivisionManager, dealRedirectFor } from './permissions.js'; describe('hasPermission', () => { it('grants a permission listed for the role', () => { @@ -788,23 +788,40 @@ describe('quotation release through the self-service lockdown', () => { }); }); -// An import user landing on a whole-deal URL (a backend notification deep-link, an old bookmark) -// is sent to their own page for that deal instead of a dead-end refusal. -describe('importDealRedirectFor', () => { +// An import or account user landing on a whole-deal URL (a backend notification deep-link, an old +// bookmark) is sent to their own page for that deal instead of a dead-end refusal. +describe('dealRedirectFor', () => { const importer = { role: 'import', employeeId: 2 }; + const account = { role: 'account', employeeId: 3 }; it('maps /tickets/:id to /import/deals/:id for import only', () => { - expect(importDealRedirectFor('/tickets/12', importer)).toBe('/import/deals/12'); - expect(importDealRedirectFor('/tickets/12/', importer)).toBe('/import/deals/12'); + expect(dealRedirectFor('/tickets/12', importer)).toBe('/import/deals/12'); + expect(dealRedirectFor('/tickets/12/', importer)).toBe('/import/deals/12'); }); it('does not redirect other roles, other paths, or sub-paths', () => { - expect(importDealRedirectFor('/tickets/12', { role: 'ceo', employeeId: 1 })).toBeNull(); - expect(importDealRedirectFor('/tickets/12', { role: 'sales', employeeId: 9 })).toBeNull(); - expect(importDealRedirectFor('/tickets', importer)).toBeNull(); - expect(importDealRedirectFor('/tickets/12/deposit', importer)).toBeNull(); - expect(importDealRedirectFor('/tickets/abc', importer)).toBeNull(); - expect(importDealRedirectFor('/fulfilment', importer)).toBeNull(); - expect(importDealRedirectFor('/tickets/12', null)).toBeNull(); + expect(dealRedirectFor('/tickets/12', { role: 'ceo', employeeId: 1 })).toBeNull(); + expect(dealRedirectFor('/tickets/12', { role: 'sales', employeeId: 9 })).toBeNull(); + expect(dealRedirectFor('/tickets', importer)).toBeNull(); + expect(dealRedirectFor('/tickets/12/deposit', importer)).toBeNull(); + expect(dealRedirectFor('/tickets/abc', importer)).toBeNull(); + expect(dealRedirectFor('/fulfilment', importer)).toBeNull(); + expect(dealRedirectFor('/tickets/12', null)).toBeNull(); + }); + + // Account's deposit-notice notification (DepositNoticeService -> notifyByRole('account')) links + // /tickets/:id; account works a deal on /finance/deals/:id. Unlike import, the sub-paths + // (/tickets/:id/deposit) have an account equivalent, so they redirect too. + it('maps /tickets/:id and its sub-paths to /finance/deals/:id for account', () => { + expect(dealRedirectFor('/tickets/12', account)).toBe('/finance/deals/12'); + expect(dealRedirectFor('/tickets/12/', account)).toBe('/finance/deals/12'); + expect(dealRedirectFor('/tickets/12/deposit', account)).toBe('/finance/deals/12'); + expect(dealRedirectFor('/tickets/12?from=bell', account)).toBe('/finance/deals/12'); + }); + + it('does not redirect account off the pipeline list or non-deal paths', () => { + expect(dealRedirectFor('/tickets', account)).toBeNull(); + expect(dealRedirectFor('/tickets/abc', account)).toBeNull(); + expect(dealRedirectFor('/quotations/5', account)).toBeNull(); }); }); diff --git a/frontend/src/components/common/OverflowMenu.jsx b/frontend/src/components/common/OverflowMenu.jsx index d1cb43277..9367f18bc 100644 --- a/frontend/src/components/common/OverflowMenu.jsx +++ b/frontend/src/components/common/OverflowMenu.jsx @@ -1,4 +1,4 @@ -import { useLayoutEffect, useRef, useState } from 'react'; +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { cn } from '../../utils/cn.js'; import { useIsMobile } from '../../hooks/useIsMobile.js'; @@ -65,8 +65,19 @@ export function OverflowMenu({ triggerLabel, triggerIcon = 'moreHorizontal', triggerClassName, + // Extra attributes for the trigger button (e.g. a data-testid / data-action e2e hook); spread first, + // so they can never override the menu's own handlers or ARIA. + triggerProps, + // Told when the menu opens and closes (also closed on unmount) — the deal page holds its sticky + // header still while one is open, since condensing it remounts the trigger and closes the menu. + onOpenChange, }) { const [open, setOpen] = useState(false); + useEffect(() => { + if (!open) return undefined; + onOpenChange?.(true); + return () => onOpenChange?.(false); + }, [open, onOpenChange]); const triggerRef = useRef(null); const menuRef = useRef(null); const isMobile = useIsMobile(); @@ -119,7 +130,7 @@ export function OverflowMenu({ if (event.key === 'Escape') { event.stopPropagation(); setOpen(false); - triggerRef.current?.focus(); + triggerRef.current?.focus({ preventScroll: true }); return; } // Tab/Shift+Tab: close the menu and let the browser's own default Tab @@ -170,6 +181,7 @@ export function OverflowMenu({ class, so it's left untouched rather than guessed into a variant. */} {triggerLabel ? ( ) : ( : null} + + ); + } + render(); + const trigger = screen.getByRole('button', { name: 'การดำเนินการเพิ่มเติม' }); + fireEvent.click(trigger); + const focus = vi.spyOn(trigger, 'focus'); + + fireEvent.click(screen.getByRole('menuitem', { name: 'เปิดหน้าต่างยืนยัน' })); + expect(focusedWhenSelected).toBe(trigger); + fireEvent.click(screen.getByRole('button', { name: 'เสร็จ' })); + + expect(focus.mock.calls).toEqual([[{ preventScroll: true }], [{ preventScroll: true }]]); + }); + + it('Escape closes the menu and returns focus to the trigger, without scrolling the page', () => { render(); const trigger = screen.getByRole('button', { name: 'การดำเนินการเพิ่มเติม' }); fireEvent.click(trigger); expect(screen.getByRole('menu')).not.toBeNull(); + const focus = vi.spyOn(trigger, 'focus'); fireEvent.keyDown(document, { key: 'Escape' }); expect(screen.queryByRole('menu')).toBeNull(); expect(document.activeElement).toBe(trigger); + expect(focus).toHaveBeenCalledWith({ preventScroll: true }); }); it('a click outside the menu closes it', () => { diff --git a/frontend/src/data/demoSales.js b/frontend/src/data/demoSales.js index 5da2c0e58..cf50b4ed2 100644 --- a/frontend/src/data/demoSales.js +++ b/frontend/src/data/demoSales.js @@ -1218,7 +1218,7 @@ export function buildDemoSalesSeed() { // FactoryPurchaseOrder CANCELLED; DepositNotice DRAFT (pre-drafted off the accepted // quotation while the deposit is awaited — D4 fix: was built from quotation19's // REVISION_REQUESTED doc, which neither createDepositNoticeFromQuotation (mockApi.js's - // hard 409) nor mockPickTicketQuotationForDepositNotice's ACCEPTED->ISSUED fallback would + // hard 409) nor mockGoverningQuotationForDepositNotice's answered->ISSUED fallback would // ever ACTUALLY pick, so that row could not exist in the real app). const pr20 = makePr({ ticketId: 18, recipientType: 'BUYER', recipientLabel: 'ผู้รับเหมา Fashion Island', diff --git a/frontend/src/features/dashboard/SalesOverview.jsx b/frontend/src/features/dashboard/SalesOverview.jsx index fdd341055..51bb0fb48 100644 --- a/frontend/src/features/dashboard/SalesOverview.jsx +++ b/frontend/src/features/dashboard/SalesOverview.jsx @@ -1,6 +1,6 @@ /* Hallmark · genre: modern-minimal · macrostructure: Workbench · design-system: design.md · designed-as-app · pre-emit critique: P4 H4 E4 S4 R5 V5 */ import { useMemo } from 'react'; -import { useQuery } from '@tanstack/react-query'; +import { useQueries, useQuery } from '@tanstack/react-query'; import { useNavigate } from 'react-router-dom'; import { api } from '../../api/index.js'; import { queryKeys } from '../../api/queryKeys.js'; @@ -11,7 +11,13 @@ import { PageStack, Panel, StatGrid } from '../../components/common/Layout.jsx'; import { StatCard } from '../../components/common/StatCard.jsx'; import { StatusBadge } from '../../components/common/StatusBadge.jsx'; import { bangkokMonthStartIso, bangkokTodayIso, formatMoney, formatThaiDate, greetingName } from '../../utils/format.js'; +import { newestQuotationByRequest } from '../quotations/quotationMeta.js'; import { followUpStatus, nextSalesAction, sortWorklist, SALES_ACTION } from '../tickets/salesActions.js'; +import { offRoutePartiesFor, useStageCatalog } from '../tickets/stageCatalog.js'; + +// useQueries' `combine`: each query's rows. Module-level so react-query keeps the combined array +// stable while the data is unchanged, and the worklist memo below holds. +const dataOfQueries = (results) => results.map((result) => result.data); const FOLLOW_UP_LIST_LIMIT = 6; @@ -89,13 +95,39 @@ export function SalesOverview({ user, employee }) { const loading = ticketsQuery.isLoading || pricingRequestsQuery.isLoading; const activeDeals = useMemo(() => deals.filter((deal) => deal.lifecycle === 'ACTIVE'), [deals]); + // Each deal's route (the served catalog.routes for its entryChannel), for the route choice. + const { catalog: stageCatalog } = useStageCatalog(); + + // Review V11: each quoted deal's quotation list (the deal page's own query, shared through the + // cache), so the worklist reads approval waits and redos the way the deal page does. Only deals + // with a request the list decides for; until a deal's list is read, it gets no route choice. + const quotedDealIds = useMemo(() => [...new Set(pricingRequests + .filter((pr) => pr.status === 'APPROVED_FOR_QUOTATION' || pr.status === 'QUOTATION_ISSUED') + .map((pr) => Number(pr.ticketId)))], [pricingRequests]); + const quotationRows = useQueries({ + queries: quotedDealIds.map((id) => ({ + queryKey: queryKeys.dealQuotationsByTicket(id), + queryFn: () => api.dealQuotations.listForTicket(id).then((response) => response.items ?? []), + })), + combine: dataOfQueries, + }); const worklist = useMemo(() => { + const rowsByDeal = new Map(quotedDealIds.map((id, index) => [id, quotationRows[index]])); const items = activeDeals - .map((deal) => ({ deal, action: nextSalesAction(deal, pricingRequests) })) + .map((deal) => { + const rows = rowsByDeal.get(Number(deal.id)); + return { + deal, + action: nextSalesAction(deal, pricingRequests, { + offRouteParties: rows ? offRoutePartiesFor(stageCatalog, deal.entryChannel) : null, + quotationByRequest: rows ? newestQuotationByRequest(rows) : undefined, + }), + }; + }) .filter((item) => item.action != null); return sortWorklist(items); - }, [activeDeals, pricingRequests]); + }, [activeDeals, pricingRequests, stageCatalog, quotedDealIds, quotationRows]); const pulse = useMemo(() => { let overdueFollowUp = 0; diff --git a/frontend/src/features/dashboard/SalesOverview.test.jsx b/frontend/src/features/dashboard/SalesOverview.test.jsx index 338811446..5d996c62d 100644 --- a/frontend/src/features/dashboard/SalesOverview.test.jsx +++ b/frontend/src/features/dashboard/SalesOverview.test.jsx @@ -11,12 +11,20 @@ globalThis.React = React; vi.mock('../../api/index.js', async (importOriginal) => { const actual = await importOriginal(); + // Imported inside the factory: vi.mock is hoisted above every import. + const { DEAL_STAGE_CATALOG } = await import('../../data/dealStageCatalog.js'); return { ...actual, api: { tickets: { list: vi.fn() }, pricingRequests: { queue: vi.fn() }, commissions: { monthlySummary: vi.fn() }, + // Each quoted deal's quotation list (the same call the deal page makes) — none by default. + dealQuotations: { listForTicket: vi.fn().mockResolvedValue({ items: [] }) }, + // GET /api/meta/deal-stages: each deal's route (catalog.routes by entryChannel) for the + // worklist's route choice. The same canned payload mockApi serves, pinned to DealRoute.java by + // stageCatalog.test.js. + meta: { dealStages: vi.fn().mockResolvedValue(DEAL_STAGE_CATALOG) }, }, }; }); @@ -66,7 +74,7 @@ const deals = [ ]; const pricingRequests = [ - { id: 1, ticketId: 602, status: 'APPROVED_FOR_QUOTATION', orderConfirmedAt: null }, + { id: 1, ticketId: 602, status: 'APPROVED_FOR_QUOTATION', recipientType: 'BUYER', orderConfirmedAt: null }, { id: 2, ticketId: 603, status: 'QUOTATION_ACCEPTED', orderConfirmedAt: null }, { id: 3, ticketId: 604, status: 'SUBMITTED', orderConfirmedAt: null }, { id: 4, ticketId: 605, status: 'IMPORT_REVIEWING', orderConfirmedAt: null }, @@ -166,7 +174,7 @@ describe('SalesOverview', () => { } expect(within(ctaFor('บริษัท เอ จำกัด')).getByText('สร้างคำขอราคา')).not.toBeNull(); // no PR at all - expect(within(ctaFor('บริษัท บี จำกัด')).getByText('ออกใบเสนอราคา')).not.toBeNull(); // APPROVED_FOR_QUOTATION + expect(within(ctaFor('บริษัท บี จำกัด')).getByText('เสนอใบราคาผู้ซื้อ')).not.toBeNull(); // APPROVED_FOR_QUOTATION, named for its recipient expect(within(ctaFor('บริษัท ซี จำกัด')).getByText('ยืนยันคำสั่งซื้อ')).not.toBeNull(); // QUOTATION_ACCEPTED, not confirmed expect(within(ctaFor('บริษัท ดี จำกัด')).getByText('ติดตามลูกค้า')).not.toBeNull(); // follow-up overdue expect(within(ctaFor('บริษัท อี จำกัด')).getByText('ติดตามลูกค้า')).not.toBeNull(); // follow-up due today @@ -333,3 +341,51 @@ describe('SalesOverview — live direct quotation worklist tones (slice 2)', () expect(badgeFor('บริษัท รอ จำกัด').className).toContain('status-neutral'); }); }); + +// Owner ruling 2026-10-27 (spec A7/H1): the worklist runs the deal page's resolver, so a designer +// quotation that went out offers the next party — named from THAT deal's route (the served +// catalog.routes for its entryChannel), not from a route every deal is assumed to share. There is +// no quotation list here, so a request at QUOTATION_ISSUED counts as sent on its status alone. +describe('SalesOverview — the next party after a designer quotation is sent follows each deal\'s route', () => { + const at = '2026-07-01T00:00:00.000Z'; + const routeDeals = [ + { id: 811, code: 'DL-2026-0811', customerName: 'บริษัท ผู้ออกแบบนำ จำกัด', title: 'ผู้ออกแบบนำ', lifecycle: 'ACTIVE', entryChannel: 'DESIGNER_LED', salesStage: 'QUOTE_DESIGN_SIDE', amountPayable: 0, stale: false, nextFollowUpAt: null, stageUpdatedAt: at }, + // A designer quotation on a buyer-direct deal is off its route (allowed); the owner is off it too. + { id: 812, code: 'DL-2026-0812', customerName: 'บริษัท ผู้ซื้อติดต่อตรง จำกัด', title: 'ผู้ซื้อติดต่อตรง', lifecycle: 'ACTIVE', entryChannel: 'BUYER_DIRECT', salesStage: 'SPEC_APPROVED', amountPayable: 0, stale: false, nextFollowUpAt: null, stageUpdatedAt: at }, + { id: 813, code: 'DL-2026-0813', customerName: 'บริษัท ผู้ออกแบบขอแก้ จำกัด', title: 'ผู้ออกแบบขอแก้', lifecycle: 'ACTIVE', entryChannel: 'DESIGNER_LED', salesStage: 'QUOTE_DESIGN_SIDE', amountPayable: 0, stale: false, nextFollowUpAt: null, stageUpdatedAt: at }, + ]; + const sentRequests = [ + { id: 81, ticketId: 811, status: 'QUOTATION_ISSUED', recipientType: 'DESIGNER', orderConfirmedAt: null }, + { id: 82, ticketId: 812, status: 'QUOTATION_ISSUED', recipientType: 'DESIGNER', orderConfirmedAt: null }, + { id: 83, ticketId: 813, status: 'QUOTATION_ISSUED', recipientType: 'DESIGNER', orderConfirmedAt: null }, + ]; + // Each request's newest quotation, read from its deal's quotation list as the deal page reads it + // (review V11): the designer on 813 asked for changes. + const docStatusByRequest = { 81: 'ISSUED', 82: 'ISSUED', 83: 'REVISION_REQUESTED' }; + + beforeEach(() => { + vi.clearAllMocks(); + api.tickets.list.mockResolvedValue({ tickets: routeDeals }); + api.pricingRequests.queue.mockResolvedValue({ items: sentRequests }); + api.commissions.monthlySummary.mockResolvedValue({ summary: defaultCommissionSummary }); + api.dealQuotations.listForTicket.mockImplementation((ticketId) => Promise.resolve({ + items: sentRequests.filter((pr) => pr.ticketId === ticketId).map((pr) => ({ + id: 9000 + pr.id, origin: 'PRICING_REQUEST', pricingRequestId: pr.id, docStatus: docStatusByRequest[pr.id], + })), + })); + }); + + it('designer-led -> เสนอราคาเจ้าของโครงการ; buyer-direct, whose route skips the owner -> เสนอราคาผู้ซื้อ; a designer quotation being redone -> เสนอใบราคาผู้ออกแบบ', async () => { + renderOverview(); + const worklist = within(worklistSection()); + await worklist.findByText('บริษัท ผู้ออกแบบนำ จำกัด'); + const badgeOf = (customerName) => worklist.getByText(customerName).closest('button').querySelector('.status-badge'); + + // waitFor: the label follows the stage catalog, which may land after the deal list. + await waitFor(() => { + expect(badgeOf('บริษัท ผู้ออกแบบนำ จำกัด').textContent).toBe('เสนอราคาเจ้าของโครงการ'); + expect(badgeOf('บริษัท ผู้ซื้อติดต่อตรง จำกัด').textContent).toBe('เสนอราคาผู้ซื้อ'); + expect(badgeOf('บริษัท ผู้ออกแบบขอแก้ จำกัด').textContent).toBe('เสนอใบราคาผู้ออกแบบ'); + }); + }); +}); diff --git a/frontend/src/features/deposits/DepositNoticePage.jsx b/frontend/src/features/deposits/DepositNoticePage.jsx index 0c63a5561..9e5268354 100644 --- a/frontend/src/features/deposits/DepositNoticePage.jsx +++ b/frontend/src/features/deposits/DepositNoticePage.jsx @@ -14,6 +14,7 @@ import { StatusBadge } from '../../components/common/StatusBadge.jsx'; import { fieldErrorId } from '../../components/common/FormField.jsx'; import { depositNoticeStatusLabel, formatThaiDate } from '../../utils/format.js'; import { downloadBlob } from '../../utils/download.js'; +import { round2 } from '../quotations/quotationMeta.js'; function money(v) { if (v == null) return '—'; @@ -35,6 +36,43 @@ function formatVatPercent(rate) { return String(Math.round(Number(rate) * 100 * 100) / 100); } +// A line's net unit price: 0 is a real price (a 100%-discounted line), only a missing one falls +// back to the unit price. +function lineNet(item) { + return Number(item.netUnitPrice ?? item.unitPrice) || 0; +} + +// An untouched เพิ่มรายการ row (or one emptied again): no description and nothing to bill. Left +// out of the save (the backend refuses a line without a description) and of the issue checks. +const NO_ITEMS_MESSAGE = 'กรุณาเพิ่มรายการสินค้าอย่างน้อย 1 รายการ'; + +function isBlankRow(item) { + return !String(item.description ?? '').trim() && !(lineNet(item) * (Number(item.qty) || 0)); +} + +/** The confirm-before-issue amounts, shared by this editor and DealDepositPanel's ออกเอกสาร. */ +export function DepositIssueSummary({ customerName, depositPercent, deposit, vatPercent, vat, total }) { + return ( +
    +

    + ตรวจสอบยอดเงินก่อนออกเอกสารให้ {customerName || 'ลูกค้า'} — หลังจากนี้จะไม่สามารถแก้ไขได้ +

    +
    + ขอรับเงินมัดจำ ({Math.round(Number(depositPercent) * 100)}%) + {money(deposit)} บาท +
    +
    + ภาษีมูลค่าเพิ่ม {formatVatPercent(vatPercent)}% + {money(vat)} บาท +
    +
    + รวมเป็นเงินที่ต้องชำระ + {money(total)} บาท +
    +
    + ); +} + // ── validation (UX-03) ────────────────────────────────────────────────────── // This gate fires ONLY from handleIssue() — never from handleSave() or the // preview path. This is a draft editor: users save partial drafts @@ -69,7 +107,7 @@ function makeLineItemSchema(rowNumber) { * handleIssue() can scroll/focus the first one without depending on zod's * internal issue ordering. */ -function validateIssueForm({ customerName, items, total }) { +function validateIssueForm({ customerName, items, total, depositPercent }) { const errors = {}; const order = []; @@ -79,11 +117,12 @@ function validateIssueForm({ customerName, items, total }) { order.push('customerName'); } - if (items.length === 0) { - errors.items = 'กรุณาเพิ่มรายการสินค้าอย่างน้อย 1 รายการ'; + if (items.every(isBlankRow)) { + errors.items = NO_ITEMS_MESSAGE; order.push('items'); } else { items.forEach((item, index) => { + if (isBlankRow(item)) return; const result = makeLineItemSchema(index + 1).safeParse(item); if (!result.success) { for (const issue of result.error.issues) { @@ -95,6 +134,14 @@ function validateIssueForm({ customerName, items, total }) { }); } + // The % sits after the items on screen. A whole 1–100 is what the backend issues; outside it the + // ฿0 rule below would blame the unit price (0%) or the backend would refuse behind the dialog (150%). + const percent = depositPercent === '' ? NaN : Math.round(Number(depositPercent) * 100); + if (!(percent >= 1 && percent <= 100)) { + errors.depositPercent = 'กรุณากรอก % มัดจำเป็นจำนวนเต็ม 1–100'; + order.push('depositPercent'); + } + // Rule 4: issuing a ฿0 deposit notice is meaningless and would still // advance the ticket's payment track. Only checked once the rules above // are otherwise satisfied — a ฿0 total caused by missing item data is @@ -229,7 +276,8 @@ export function DepositNoticePage({ ticketId, onBack, onNavigateTickets, showToa customerAddress: address, projectName: d.projectName ?? '', reference: d.reference ?? '', - depositPercent: d.depositPercent ?? 0.5, + // As stored (null -> blank, which saves as "keep"): a save of another field must not rewrite it. + depositPercent: d.depositPercent ?? '', notes: d.notes ?? [], items: (d.items ?? []).map((it) => ({ ...it })), }); @@ -240,6 +288,10 @@ export function DepositNoticePage({ ticketId, onBack, onNavigateTickets, showToa // background refetch of the same doc (e.g. a save doesn't bump // version/status, so no reseed fires and in-flight edits aren't clobbered). const docSeedKey = doc ? `${doc.id}:${doc.version}:${doc.status}` : null; + // The draft's updatedAt as last read from the server (the list, or this page's own save): + // sent as expectedUpdatedAt, so a save over someone else's newer one is refused (409). + const stampRef = useRef(null); + useEffect(() => { stampRef.current = doc?.updatedAt ?? null; }, [doc?.updatedAt]); useEffect(() => { if (doc) populateForm(doc, customers); // A reseed means a different document identity (or the same one flipping @@ -253,6 +305,22 @@ export function DepositNoticePage({ ticketId, onBack, onNavigateTickets, showToa return queryClient.invalidateQueries({ queryKey: queryKeys.depositNotices(ticketId) }); } + async function saveDraft() { + const res = await api.depositNotices.update(doc.id, buildPayload()); + stampRef.current = res?.depositNotice?.updatedAt ?? stampRef.current; + return res; + } + + // A 409 means the server's copy moved on (saved elsewhere, or issued): reload it, form included, + // and show the server's message. + async function onSaveError(err, fallback) { + showToast('error', err.message || fallback); + if (err.status !== 409) return; + const { data } = await depositNoticesQuery.refetch(); + const fresh = (data ?? []).find((d) => d.id === doc.id); + if (fresh) populateForm(fresh, customers); + } + const createDraftMutation = useMutation({ // Prefer the pricing-request-chain endpoint (createDepositNoticeFromQuotation) when this // deal has a pricing request whose customer quotation was accepted — that route sources @@ -274,10 +342,9 @@ export function DepositNoticePage({ ticketId, onBack, onNavigateTickets, showToa } catch { acceptedPricingRequestId = null; } - // depositPercent is deliberately NOT sent on either branch below (owner ruling, - // 2026-09-29): the backend always ignores a caller-supplied value now and re-sources it - // from the ticket's own quotation (see DepositNoticeService.createDraft), falling back to - // 0.50 only when there is no quotation to read it from at all. + // depositPercent is not sent on either branch below: the backend picks the new draft's + // percent (DepositNoticeService.createDraftFromQuotation; findGoverningQuotation, #1170). + // Sales changes it on the draft. if (acceptedPricingRequestId != null) { return api.pricingRequests.createDepositNoticeFromQuotation(acceptedPricingRequestId, {}); } @@ -294,34 +361,42 @@ export function DepositNoticePage({ ticketId, onBack, onNavigateTickets, showToa } const saveMutation = useMutation({ - mutationFn: () => api.depositNotices.update(doc.id, buildPayload()), + mutationFn: saveDraft, onSuccess: () => { showToast('success', 'บันทึกแล้ว'); invalidateDepositNotices(); }, - onError: (err) => showToast('error', err.message || 'บันทึกไม่สำเร็จ'), + onError: (err) => onSaveError(err, 'บันทึกไม่สำเร็จ'), }); + // Blank rows are not sent and an empty `items` makes the backend keep the stored lines, so a form + // whose rows were all cleared would "save" and bring the old lines back: refuse it instead. + function refusesClearedItems() { + if (!(doc.items?.length > 0 && form.items.every(isBlankRow))) return false; + setFieldErrors({ items: NO_ITEMS_MESSAGE }); + return true; + } + function handleSave() { - if (!doc) return; + if (!doc || refusesClearedItems()) return; saveMutation.mutate(); } const previewMutation = useMutation({ mutationFn: async () => { // Save first, then preview. - await api.depositNotices.update(doc.id, buildPayload()); + await saveDraft(); return api.depositNotices.preview(doc.id); }, onSuccess: (html) => { setPreview(html); invalidateDepositNotices(); }, - onError: (err) => showToast('error', err.message || 'Preview ไม่สำเร็จ'), + onError: (err) => onSaveError(err, 'Preview ไม่สำเร็จ'), }); function handlePreview() { - if (!doc) return; + if (!doc || refusesClearedItems()) return; previewMutation.mutate(); } @@ -331,7 +406,9 @@ export function DepositNoticePage({ ticketId, onBack, onNavigateTickets, showToa // sure?" for something that's then going to fail. `total` is read here // (never written) from the money-math block below; see the HARD // CONSTRAINT comment on that block. - const { errors, order } = validateIssueForm({ customerName: form.customerName, items: form.items, total }); + const { errors, order } = validateIssueForm({ + customerName: form.customerName, items: form.items, total, depositPercent: form.depositPercent, + }); if (order.length > 0) { setFieldErrors(errors); const node = fieldRefs.current[order[0]]; @@ -350,8 +427,8 @@ export function DepositNoticePage({ ticketId, onBack, onNavigateTickets, showToa const issueMutation = useMutation({ mutationFn: async () => { // Save first. - await api.depositNotices.update(doc.id, buildPayload()); - return api.depositNotices.issue(doc.id); + await saveDraft(); + return api.depositNotices.issue(doc.id, { expectedUpdatedAt: stampRef.current }); }, onSuccess: (res) => { showToast('success', `ออกเอกสาร ${res.depositNotice.docNumber} เรียบร้อย`); @@ -363,7 +440,10 @@ export function DepositNoticePage({ ticketId, onBack, onNavigateTickets, showToa queryClient.invalidateQueries({ queryKey: ['tickets', 'list'] }); queryClient.invalidateQueries({ queryKey: queryKeys.ticketDetail(ticketId) }); }, - onError: (err) => showToast('error', err.message || 'ออกเอกสารไม่สำเร็จ'), + onError: (err) => { + setConfirmIssue(false); + onSaveError(err, 'ออกเอกสารไม่สำเร็จ'); + }, }); function confirmIssueDocument() { @@ -377,10 +457,13 @@ export function DepositNoticePage({ ticketId, onBack, onNavigateTickets, showToa function setField(key, value) { setForm((f) => ({ ...f, [key]: value })); if (key === 'customerName') clearFieldError('customerName'); - // The deposit % toggle feeds directly into `deposit`/`total` below — + // The deposit % field feeds directly into `deposit`/`total` below — // clear a stale "total must be > 0" error so it can be re-checked fresh // on the next issue attempt. - if (key === 'depositPercent') clearFieldError('total'); + if (key === 'depositPercent') { + clearFieldError('total'); + clearFieldError('depositPercent'); + } } function selectCustomer(c) { @@ -450,31 +533,31 @@ export function DepositNoticePage({ ticketId, onBack, onNavigateTickets, showToa customerAddress: form.customerAddress, projectName: form.projectName, reference: form.reference, - depositPercent: Number(form.depositPercent), + // A cleared field keeps the stored % (null) rather than failing the whole save at 0. + depositPercent: form.depositPercent === '' ? null : Number(form.depositPercent), notes: form.notes, - items: form.items.map((it, idx) => ({ + expectedUpdatedAt: stampRef.current, + items: form.items.filter((it) => !isBlankRow(it)).map((it, idx) => ({ seq: idx + 1, description: it.description ?? '', qty: Number(it.qty) || 0, unit: it.unit ?? 'แผ่น', unitPrice: Number(it.unitPrice) || 0, discountLabel: it.discountLabel ?? null, - netUnitPrice: Number(it.netUnitPrice) || Number(it.unitPrice) || 0, + netUnitPrice: lineNet(it), })), }; } - // Recalculate summary from form items - const subtotal = form.items.reduce((sum, it) => { - const net = Number(it.netUnitPrice) || Number(it.unitPrice) || 0; - return sum + net * (Number(it.qty) || 0); - }, 0); - const deposit = subtotal * Number(form.depositPercent || 0.5); + // Recalculate summary from form items, rounding each step to the satang as + // DepositNoticeRepository does (HALF_UP), so the screen matches the stored document. + const subtotal = round2(form.items.reduce((sum, it) => sum + lineNet(it) * (Number(it.qty) || 0), 0)); + const deposit = round2(subtotal * (Number(form.depositPercent) || 0)); // Issue #756: the served document's own vatPercent always wins once one exists — only // before that (no `doc` yet) does this fall back to FALLBACK_VAT_PERCENT above. const vatPercent = Number(doc?.vatPercent ?? FALLBACK_VAT_PERCENT); - const vat = deposit * vatPercent; - const total = deposit + vat; + const vat = round2(deposit * vatPercent); + const total = round2(deposit + vat); if (loading) { return ( @@ -822,20 +905,24 @@ export function DepositNoticePage({ ticketId, onBack, onNavigateTickets, showToa aria-describedby={fieldErrors.total ? fieldErrorId('doc-summary-panel') : undefined} className="px-4.5 py-3.5" > - {/* - Owner ruling (2026-09-29): the deposit % is LOCKED to the source quotation's own - deposit_percent (sales.quotation) and is never editable at the deposit-notice - stage — createDraft/update on the backend both ignore any client-supplied value - and always re-source it from the quotation. The 30/50/70/100% preset buttons this - used to be are gone; this is now a plain read-only label, shown regardless of - isIssued since there is no draft-only editable state left to distinguish. - */} -
    - % มัดจำ - - {Math.round(Number(form.depositPercent) * 100)}% - -
    + {/* Owner ruling 2026-10-23: the % starts from the quotation; sales may change it on + a DRAFT (whole number on screen, a fraction in the payload). Read-only once issued. */} + + {fieldErrors.depositPercent ? ( + + ) : null} {[ { label: 'รวมเป็นเงิน', value: subtotal }, { label: `ขอรับเงินมัดจำ (${Math.round(Number(form.depositPercent) * 100)}%)`, value: deposit }, @@ -899,23 +986,8 @@ export function DepositNoticePage({ ticketId, onBack, onNavigateTickets, showToa open={confirmIssue} title="ยืนยันการออกเอกสาร" message={( -
    -

    - ตรวจสอบยอดเงินก่อนออกเอกสารให้ {form.customerName || 'ลูกค้า'} — หลังจากนี้จะไม่สามารถแก้ไขได้ -

    -
    - ขอรับเงินมัดจำ ({Math.round(Number(form.depositPercent) * 100)}%) - {money(deposit)} บาท -
    -
    - ภาษีมูลค่าเพิ่ม {formatVatPercent(vatPercent)}% - {money(vat)} บาท -
    -
    - รวมเป็นเงินที่ต้องชำระ - {money(total)} บาท -
    -
    + )} confirmLabel="ออกเอกสาร" busy={saving} diff --git a/frontend/src/features/deposits/DepositNoticePage.test.jsx b/frontend/src/features/deposits/DepositNoticePage.test.jsx index b885e40dc..040901277 100644 --- a/frontend/src/features/deposits/DepositNoticePage.test.jsx +++ b/frontend/src/features/deposits/DepositNoticePage.test.jsx @@ -147,7 +147,9 @@ describe('DepositNoticePage', () => { // UX-03 rule 4: a ฿0 deposit notice is meaningless and would still advance // the ticket's payment track — issuing it must be blocked even though // rules 1-3 (customer name, >=1 item, description+qty) are all satisfied. - it('blocks issue when the computed total is zero', async () => { + // A % outside a whole 1–100 gets its own message on the % field (it used to blame the unit + // price, or pass the gate at 150% and fail behind the confirm dialog). + it('blocks issue when the computed total is zero, or the % is outside 1–100, before the dialog', async () => { api.depositNotices.listByTicket.mockResolvedValue({ depositNotices: [{ ...validDraftDoc, @@ -163,6 +165,20 @@ describe('DepositNoticePage', () => { expect(await screen.findByText(/ยอดเงินรวมต้องมากกว่า 0/)).not.toBeNull(); expect(screen.queryByRole('dialog')).toBeNull(); + + fireEvent.change(document.getElementById('doc-item-0-qty'), { target: { value: '5' } }); + fireEvent.change(screen.getByPlaceholderText('ราคา'), { target: { value: '100' } }); + const percentInput = screen.getByLabelText('% มัดจำ'); + for (const typed of ['0', '150']) { + fireEvent.change(percentInput, { target: { value: typed } }); + fireEvent.click(screen.getByRole('button', { name: /ออกเอกสาร/ })); + const error = await screen.findByText('กรุณากรอก % มัดจำเป็นจำนวนเต็ม 1–100'); + expect(percentInput.getAttribute('aria-invalid')).toBe('true'); + expect(percentInput.getAttribute('aria-describedby')).toBe(error.id); + expect(document.activeElement).toBe(percentInput); + expect(screen.queryByText(/ยอดเงินรวมต้องมากกว่า 0/)).toBeNull(); + expect(screen.queryByRole('dialog')).toBeNull(); + } expect(api.depositNotices.issue).not.toHaveBeenCalled(); }); @@ -188,10 +204,115 @@ describe('DepositNoticePage', () => { expect(screen.queryByRole('alert')).toBeNull(); }); + // What บันทึก sends, and the summary beside it, must match what the backend stores: + // - a stored whole 0.33 shows, labels and saves as 33%; + // - a 100%-discounted line (net 0, unit 100) adds 0 and is sent at net 0, never its unit price; + // - money rounds per step like the backend: 100.10 at 33% is 33.03 + VAT 2.31 = 35.34 (35.35 unrounded); + // - a blank เพิ่มรายการ row is left out (the backend refuses a line without a description); + // - the draft's updatedAt travels as expectedUpdatedAt (a stale save is refused server-side); + // - a cleared % sends null, so the save keeps the stored % instead of failing the whole PUT. + it('saves what the backend will store: rounded %, zero-net line, per-step rounding, no blank row, the stamp, null for a cleared %', async () => { + api.depositNotices.listByTicket.mockResolvedValue({ + depositNotices: [{ + ...validDraftDoc, depositPercent: 0.33, updatedAt: '2026-10-23T08:57:19.732123Z', + items: [ + { seq: 1, description: 'กระเบื้อง A', qty: 1, unit: 'แผ่น', unitPrice: 100.1, netUnitPrice: 100.1 }, + { seq: 2, description: 'ของแถม', qty: 2, unit: 'แผ่น', unitPrice: 100, netUnitPrice: 0 }, + ], + }], + }); + renderDepositNoticePage(); + + await screen.findByText('รายการสินค้า (2 รายการ)'); + const percentInput = screen.getByLabelText('% มัดจำ'); + expect(percentInput.value).toBe('33'); + expect(screen.getByText('ขอรับเงินมัดจำ (33%)')).not.toBeNull(); + expect(screen.getByText('100.10 บาท')).not.toBeNull(); + expect(screen.getByText('33.03 บาท')).not.toBeNull(); + expect(screen.getByText('2.31 บาท')).not.toBeNull(); + expect(screen.getByText('35.34 บาท')).not.toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: /เพิ่มรายการ/ })); + fireEvent.click(screen.getByRole('button', { name: 'บันทึก' })); + await waitFor(() => expect(api.depositNotices.update).toHaveBeenCalledTimes(1)); + const payload = api.depositNotices.update.mock.calls[0][1]; + expect(payload).toMatchObject({ depositPercent: 0.33, expectedUpdatedAt: '2026-10-23T08:57:19.732123Z' }); + expect(payload.items).toEqual([ + expect.objectContaining({ seq: 1, unitPrice: 100.1, netUnitPrice: 100.1 }), + expect.objectContaining({ seq: 2, qty: 2, unitPrice: 100, netUnitPrice: 0 }), + ]); + + fireEvent.change(percentInput, { target: { value: '' } }); + fireEvent.click(screen.getByRole('button', { name: 'บันทึก' })); + await waitFor(() => expect(api.depositNotices.update).toHaveBeenCalledTimes(2)); + expect(api.depositNotices.update.mock.calls[1][1].depositPercent).toBeNull(); + expect(screen.queryByRole('alert')).toBeNull(); + }); + + // The page must not rewrite what it did not edit: a stored 0.125 (shown as 13) survives a save of + // another field untouched. + it('keeps a stored non-whole % as-is when only another field is edited', async () => { + api.depositNotices.listByTicket.mockResolvedValue({ + depositNotices: [{ ...validDraftDoc, depositPercent: 0.125, updatedAt: 'T1' }], + }); + renderDepositNoticePage(); + + fireEvent.change(await screen.findByLabelText(/ที่อยู่/), { target: { value: 'ที่อยู่ใหม่' } }); + fireEvent.click(screen.getByRole('button', { name: 'บันทึก' })); + await waitFor(() => expect(api.depositNotices.update).toHaveBeenCalledTimes(1)); + expect(api.depositNotices.update.mock.calls[0][1]).toMatchObject({ depositPercent: 0.125, customerAddress: 'ที่อยู่ใหม่' }); + }); + + // Blank rows are dropped from the payload and the backend keeps the stored lines when `items` is + // empty — so saving/previewing an all-blank form would "succeed" and bring the old lines back. + it('refuses to save or preview when every row was cleared, with the items error, and calls no API', async () => { + api.depositNotices.listByTicket.mockResolvedValue({ depositNotices: [validDraftDoc] }); + renderDepositNoticePage(); + + await screen.findByDisplayValue('บริษัท ทดสอบ จำกัด'); + fireEvent.change(document.getElementById('doc-item-0-description'), { target: { value: '' } }); + fireEvent.change(document.getElementById('doc-item-0-qty'), { target: { value: '0' } }); + + fireEvent.click(screen.getByRole('button', { name: 'บันทึก' })); + expect((await screen.findByText('กรุณาเพิ่มรายการสินค้าอย่างน้อย 1 รายการ')).getAttribute('role')).toBe('alert'); + expect(api.depositNotices.update).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole('button', { name: /Preview|ตัวอย่าง/ })); + expect(api.depositNotices.update).not.toHaveBeenCalled(); + expect(api.depositNotices.preview).not.toHaveBeenCalled(); + }); + + // Someone else saved the draft since this page loaded it: the backend answers 409. The page + // reloads the draft (form and stamp) and shows the server's message, so the next save is a + // deliberate one over the fresh copy. + it('on a 409 save, reloads the draft and shows the server message', async () => { + const showToast = vi.fn(); + api.depositNotices.listByTicket + .mockResolvedValueOnce({ depositNotices: [{ ...validDraftDoc, depositPercent: 0.3, updatedAt: 'T1' }] }) + .mockResolvedValue({ depositNotices: [{ ...validDraftDoc, depositPercent: 0.4, updatedAt: 'T2' }] }); + const stale = Object.assign(new Error('ใบแจ้งรับมัดจำนี้ถูกแก้ไขโดยผู้ใช้อื่น กรุณาโหลดข้อมูลใหม่แล้วลองอีกครั้ง'), { status: 409 }); + api.depositNotices.update.mockRejectedValueOnce(stale); + renderDepositNoticePage({ showToast }); + + await screen.findByDisplayValue('บริษัท ทดสอบ จำกัด'); + const percentInput = screen.getByLabelText('% มัดจำ'); + fireEvent.change(percentInput, { target: { value: '35' } }); + fireEvent.click(screen.getByRole('button', { name: 'บันทึก' })); + + await waitFor(() => expect(showToast).toHaveBeenCalledWith('error', stale.message)); + expect(api.depositNotices.update.mock.calls[0][1]).toMatchObject({ depositPercent: 0.35, expectedUpdatedAt: 'T1' }); + await waitFor(() => expect(percentInput.value).toBe('40')); + + fireEvent.click(screen.getByRole('button', { name: 'บันทึก' })); + await waitFor(() => expect(api.depositNotices.update).toHaveBeenCalledTimes(2)); + expect(api.depositNotices.update.mock.calls[1][1]).toMatchObject({ depositPercent: 0.4, expectedUpdatedAt: 'T2' }); + }); + // A fully valid document opens the confirm dialog, and confirming it calls // api.depositNotices.issue for that document. - it('opens the confirm dialog for a valid document and issues it on confirm', async () => { - api.depositNotices.listByTicket.mockResolvedValue({ depositNotices: [validDraftDoc] }); + it('opens the confirm dialog for a valid document and issues it on confirm, with the stamp its own save returned', async () => { + api.depositNotices.listByTicket.mockResolvedValue({ depositNotices: [{ ...validDraftDoc, updatedAt: 'T1' }] }); + api.depositNotices.update.mockResolvedValue({ depositNotice: { updatedAt: 'T2' } }); renderDepositNoticePage(); await screen.findByDisplayValue('บริษัท ทดสอบ จำกัด'); @@ -202,7 +323,7 @@ describe('DepositNoticePage', () => { fireEvent.click(within(dialog).getByRole('button', { name: 'ออกเอกสาร' })); - await waitFor(() => expect(api.depositNotices.issue).toHaveBeenCalledWith(9002)); + await waitFor(() => expect(api.depositNotices.issue).toHaveBeenCalledWith(9002, { expectedUpdatedAt: 'T2' })); }); it('renders the empty-state and lets the user create a draft', async () => { @@ -214,8 +335,8 @@ describe('DepositNoticePage', () => { fireEvent.click(createButton); await waitFor(() => expect(api.depositNotices.createDraft).toHaveBeenCalledTimes(1)); - // depositPercent is deliberately NOT sent (owner ruling, 2026-09-29): the backend always - // ignores a caller-supplied value now and re-sources it from the ticket's own quotation. + // depositPercent is not sent on create: the backend picks the draft's starting percent. + // Sales changes it on the draft afterwards. expect(api.depositNotices.createDraft).toHaveBeenCalledWith('701', { notes: ['หมายเหตุมาตรฐาน'], }); @@ -241,8 +362,7 @@ describe('DepositNoticePage', () => { fireEvent.click(createButton); await waitFor(() => expect(api.pricingRequests.createDepositNoticeFromQuotation).toHaveBeenCalledTimes(1)); - // depositPercent is deliberately NOT sent (owner ruling, 2026-09-29) — the backend always - // ignores a caller-supplied value and re-sources it from the accepted quotation itself. + // depositPercent is not sent on create: the backend takes it from the accepted quotation. expect(api.pricingRequests.createDepositNoticeFromQuotation).toHaveBeenCalledWith(502, {}); expect(api.depositNotices.createDraft).not.toHaveBeenCalled(); }); @@ -297,24 +417,51 @@ describe('DepositNoticePage', () => { expect(await screen.findByDisplayValue('บริษัท ทดสอบ จำกัด')).not.toBeNull(); }); - // Owner ruling (2026-09-29): the deposit % is LOCKED to the source quotation and is no longer - // user-settable at the deposit-notice stage — the 30/50/70/100% preset buttons are gone, and - // the % renders as a plain read-only label sourced from the loaded draft's own depositPercent. - it('renders the deposit percent as a read-only label, with no preset buttons to change it', async () => { + // Owner ruling 2026-10-23: on a DRAFT the deposit % is an editable whole-number field (a typed + // 39.6 rounds to 40). The amount recalculates as sales types, and บันทึก sends the fraction. + // subtotal 500: 30% -> 150.00, 40% -> 200.00 deposit, 14.00 VAT, 214.00 total; cleared -> 0. + it('lets sales change the deposit percent on a draft, recalculates the amount, and saves it', async () => { api.depositNotices.listByTicket.mockResolvedValue({ depositNotices: [{ ...validDraftDoc, depositPercent: 0.3 }], }); renderDepositNoticePage(); await screen.findByDisplayValue('บริษัท ทดสอบ จำกัด'); - - // No interactive control can change the percent anymore. - for (const label of ['30%', '50%', '70%', '100%']) { - expect(screen.queryByRole('button', { name: label })).toBeNull(); - } - // The value the draft was loaded with (30%, not the 0.5 default) still renders, as a label. - expect(screen.getByText('30%')).not.toBeNull(); + const percentInput = screen.getByLabelText('% มัดจำ'); + expect(percentInput.disabled).toBe(false); + expect(percentInput.value).toBe('30'); expect(screen.getByText('ขอรับเงินมัดจำ (30%)')).not.toBeNull(); + expect(screen.getByText('150.00 บาท')).not.toBeNull(); + + fireEvent.change(percentInput, { target: { value: '39.6' } }); + + expect(percentInput.value).toBe('40'); + expect(screen.getByText('ขอรับเงินมัดจำ (40%)')).not.toBeNull(); + expect(screen.getByText('200.00 บาท')).not.toBeNull(); + expect(screen.getByText('214.00 บาท')).not.toBeNull(); + expect(screen.queryByText('150.00 บาท')).toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: 'บันทึก' })); + await waitFor(() => expect(api.depositNotices.update).toHaveBeenCalledWith( + 9002, expect.objectContaining({ depositPercent: 0.4 }), + )); + + // A cleared field bills nothing: 0% and ฿0 for deposit, VAT and total, never a silent 50%. + fireEvent.change(percentInput, { target: { value: '' } }); + expect(screen.getByText('ขอรับเงินมัดจำ (0%)')).not.toBeNull(); + expect(screen.getAllByText('0.00 บาท')).toHaveLength(3); + }); + + it('shows the deposit percent read-only once the notice is issued', async () => { + api.depositNotices.listByTicket.mockResolvedValue({ + depositNotices: [{ ...validDraftDoc, status: 'ISSUED', docNumber: 'GLRD69001', depositPercent: 0.3 }], + }); + renderDepositNoticePage(); + + await screen.findByDisplayValue('บริษัท ทดสอบ จำกัด'); + const percentInput = screen.getByLabelText('% มัดจำ'); + expect(percentInput.disabled).toBe(true); + expect(percentInput.value).toBe('30'); }); // Issue #756: the VAT rate must come from the served document's vatPercent, not a diff --git a/frontend/src/features/pricingRequests/PricingRequestCreateModal.jsx b/frontend/src/features/pricingRequests/PricingRequestCreateModal.jsx index 66bf1fe2c..72599b76f 100644 --- a/frontend/src/features/pricingRequests/PricingRequestCreateModal.jsx +++ b/frontend/src/features/pricingRequests/PricingRequestCreateModal.jsx @@ -452,6 +452,8 @@ export function PricingRequestCreateModal({ // (a failed contact create/edit-in-place) — optional, so a caller with no toast plumbing of // its own (none currently omit it, but the picker itself tolerates an absent one) still works. showToast, + // Create mode only: ผู้รับ pre-selected from the deal page's เสนอราคา menu. + initialRecipientType = null, }) { const isEdit = mode === 'edit'; const isRevision = mode === 'revision'; @@ -460,11 +462,14 @@ export function PricingRequestCreateModal({ // ticket_item rows instead. const seedsFromExisting = isEdit || isRevision; const initialSummary = initialValue?.summary ?? null; - const [recipientType, setRecipientType] = useState(() => initialSummary?.recipientType ?? 'DESIGNER'); + const seededRecipientType = seedsFromExisting + ? (initialSummary?.recipientType ?? 'DESIGNER') + : (initialRecipientType ?? initialSummary?.recipientType ?? 'DESIGNER'); + const [recipientType, setRecipientType] = useState(seededRecipientType); const [recipientLabel, setRecipientLabel] = useState(() => ( seedsFromExisting ? (initialSummary?.recipientLabel ?? '') - : recipientLabelForDeal(deal, initialSummary?.recipientType ?? 'DESIGNER') + : recipientLabelForDeal(deal, seededRecipientType) )); // Tracks whether the user has hand-edited ผู้รับ's free-text label — once true, switching the // ผู้รับ chip must never overwrite what they typed (see the effect below). Stays false forever diff --git a/frontend/src/features/pricingRequests/PricingRequestCreateModal.test.jsx b/frontend/src/features/pricingRequests/PricingRequestCreateModal.test.jsx index 0c1acfb44..9ed5cd262 100644 --- a/frontend/src/features/pricingRequests/PricingRequestCreateModal.test.jsx +++ b/frontend/src/features/pricingRequests/PricingRequestCreateModal.test.jsx @@ -504,7 +504,7 @@ describe('PricingRequestCreateModal เพิ่มรายการ (UAT item fireEvent.click(screen.getAllByRole('button', { name: 'ลบรายการ' })[1]); // remove the clean copy: marks move back up expect(rowsErrored()).toEqual([false, true, true, false]); - }); + }, 20000); // three saves + per-row waits: 5–17 s on a loaded CI runner, not a hang }); describe('PricingRequestCreateModal typed โรงงาน → สกุลเงิน/หน่วยราคา default (UAT item 3, owner ruling 2026-10-18)', () => { @@ -1309,3 +1309,64 @@ describe('PricingRequestCreateModal — สกุลเงิน + หน่ว expect(updateFn).not.toHaveBeenCalled(); }); }); + +// Owner ruling 2026-10-22: the deal page's เสนอราคา menu opens this modal with ผู้รับ +// already set to the next party on the route. `initialRecipientType` seeds the chip AND the ผู้รับ +// label (from the deal's per-type name) — CREATE MODE ONLY; edit/revision keep the persisted recipient. +describe('PricingRequestCreateModal initialRecipientType (owner ruling 2026-10-22)', () => { + const deal = { + designerName: 'สมชาย ผู้ออกแบบ', + ownerName: 'บริษัท เจ้าของ จำกัด', + buyerName: 'ผู้ซื้อ ข.', + customerName: 'ลูกค้า ง.', + }; + const chip = (name) => screen.getByRole('radio', { name }); + + it('create mode: pre-selects เจ้าของโครงการ and pre-fills the ผู้รับ label from deal.ownerName', () => { + renderModal({ deal, initialRecipientType: 'OWNER' }); + expect(chip(/เจ้าของโครงการ/).getAttribute('aria-checked')).toBe('true'); + expect(chip(/ผู้ออกแบบ/).getAttribute('aria-checked')).toBe('false'); + expect(screen.getByPlaceholderText('เช่น ชื่อผู้ออกแบบ หรือชื่อบริษัทผู้ซื้อ').value).toBe('บริษัท เจ้าของ จำกัด'); + }); + + it('create mode: BUYER pre-selects ผู้ซื้อ and fills deal.buyerName', () => { + renderModal({ deal, initialRecipientType: 'BUYER' }); + expect(chip(/ผู้ซื้อ/).getAttribute('aria-checked')).toBe('true'); + expect(screen.getByPlaceholderText('เช่น ชื่อผู้ออกแบบ หรือชื่อบริษัทผู้ซื้อ').value).toBe('ผู้ซื้อ ข.'); + }); + + it('create mode: the seeded recipient is what gets sent', async () => { + const { createFn } = renderModal({ deal, initialRecipientType: 'OWNER' }); + fillRequiredFields(); + fireEvent.click(screen.getByRole('button', { name: /ส่งให้ฝ่ายนำเข้า/ })); + await waitFor(() => expect(createFn).toHaveBeenCalledWith(expect.objectContaining({ + recipientType: 'OWNER', recipientLabel: 'บริษัท เจ้าของ จำกัด', + }))); + }); + + it('without the prop, create mode still defaults to ผู้ออกแบบ (unchanged)', () => { + renderModal({ deal }); + expect(chip(/ผู้ออกแบบ/).getAttribute('aria-checked')).toBe('true'); + expect(screen.getByPlaceholderText('เช่น ชื่อผู้ออกแบบ หรือชื่อบริษัทผู้ซื้อ').value).toBe('สมชาย ผู้ออกแบบ'); + }); + + it('edit mode ignores it: the persisted recipient and label win', () => { + render( + , + ); + expect(chip(/เจ้าของโครงการ/).getAttribute('aria-checked')).toBe('true'); + expect(chip(/ผู้ซื้อ/).getAttribute('aria-checked')).toBe('false'); + expect(screen.getByPlaceholderText('เช่น ชื่อผู้ออกแบบ หรือชื่อบริษัทผู้ซื้อ').value).toBe('เจ้าของโครงการ ข.'); + }); +}); diff --git a/frontend/src/features/pricingRequests/PricingRequestDetailPage.jsx b/frontend/src/features/pricingRequests/PricingRequestDetailPage.jsx index b69d9e045..ed27a51ba 100644 --- a/frontend/src/features/pricingRequests/PricingRequestDetailPage.jsx +++ b/frontend/src/features/pricingRequests/PricingRequestDetailPage.jsx @@ -36,6 +36,7 @@ import { canCreateCommercialOnlyRevision, canCreateCustomerQuotation, canCreateDepositNoticeFromQuotation, + issuedDepositNotice, canDecideDiscountApproval, canManageCustomerQuotation, canRecordCustomerQuotationOutcome, @@ -72,7 +73,7 @@ import { import { useUnitBasisCatalog } from './unitBasisCatalog.js'; import { buttonVariants } from '../../components/common/Button.jsx'; import { cn } from '../../utils/cn.js'; -import { PRICE_MODE_OPTIONS } from '../quotations/quotationMeta.js'; +import { acceptOutcomeConfirmCopy, newestQuotationByRequest, PRICE_MODE_OPTIONS } from '../quotations/quotationMeta.js'; import { SearchableCombobox } from '../../components/common/SearchableCombobox.jsx'; // B6 (GLA-135): reuses the catalog page's own add-factory dialog rather than a second, drifting // copy of the same five fields + validation — see ImportFactoryPicker's own comment below. @@ -232,6 +233,116 @@ function cleanNumber(value) { return Number(value); } +/** + * The margin bound, mirroring PricingDecisionService#requireValidMargin exactly: a markup fraction + * must be > -1 (the selling price is never negative) AND <= MAX_MARGIN_PCT (the server's own raw + * upper guard; deriveListAndNet then also rejects a computed price past the NUMERIC column, which a + * margin this far below that ceiling never reaches on a real cost). One helper so the legacy per-line + * field and the new-form NET input cannot drift on either the bound or the message. */ +const MAX_MARGIN_PCT = 999.999999; +function marginOutOfRange(n) { + return n == null || Number.isNaN(n) || n <= -1 || n > MAX_MARGIN_PCT; +} +const MARGIN_RANGE_ERROR = 'อัตรากำไรต้องอยู่ระหว่าง -1 ถึง 999.999999 (ราคาขายต้องไม่ติดลบและไม่เกินขอบเขต)'; + +/** + * Per-line margin input on a CEO legacy-formula decision line (owner request 2026-10-21): the CEO + * sets อัตรากำไร% PER product line — each line's price = cost × (1 + its own margin) × buffer — + * instead of relying on the one "อัตรากำไรเริ่มต้น" applied across the whole request. Seeded from the + * line's current {@code proposedMarginPct} (which the default-margin seed set at creation) and + * overridable here. A PLAIN input (no mandatory reason, unlike the cost/price-override modals): + * {@code onSave} PUTs a single-item {@code marginPct} payload and the server recomputes THIS line's + * selling price. Its own state so the parent's render-time {@code renderCeoDecisionItem} (called in + * a loop) never has to hold a hook, and it re-syncs when the server value changes after a refetch. + */ +function CeoLineMarginField({ item, disabled, onSave }) { + const server = item.proposedMarginPct; + const serverStr = server == null ? '' : String(server); + const [value, setValue] = useState(serverStr); + useEffect(() => { setValue(serverStr); }, [serverStr]); + + const parsed = cleanNumber(value); + const invalid = value.trim() !== '' && marginOutOfRange(parsed); + const dirty = value.trim() !== '' && !invalid && parsed !== server; + + return ( +
    + + setValue(e.target.value)} + data-testid={`pcr-ceo-margin-input-${item.id}`} + /> + + +
    + ); +} + +/** + * Realized per-unit margin for a decision line (owner request 2026-10-21): the NET price the + * customer actually pays (in the decision's currency) minus the frozen LANDED cost (THB), converted + * to THB with the decision's own FX rate. Pure + module-level so both the new-form and legacy line + * renderers show the SAME number and it is unit-testable. Returns null when either side is missing + * (an uncostable line, or a line with no price yet). This is a DERIVED read-out, not a change to how + * any price is computed. {@code pct} is of COST (the same markup basis as the อัตรากำไร input). + */ +function lineProfitThb(costThb, netPriceInCurrency, fxRate) { + if (costThb == null || netPriceInCurrency == null || netPriceInCurrency === '') return null; + const netThb = Number(netPriceInCurrency) * (Number(fxRate) || 1); + const cost = Number(costThb); + if (!Number.isFinite(netThb) || !Number.isFinite(cost)) return null; + const profitPerUnit = netThb - cost; + return { profitPerUnit, pct: cost > 0 ? (profitPerUnit / cost) * 100 : null }; +} + +/** + * Shows a line's realized margin in BOTH ฿ and % on EVERY price mode (owner request 2026-10-21: + * "ทุกโหมดอยากให้เห็นชัด ๆ ว่ากำไรกี่บาทกี่ %"). Profit/unit = NET selling price − landed cost; the + * % is of cost; รวม is profit × quantity. Dashes out when the line has no cost or no price yet. + */ +function LineMarginReadout({ costThb, netPrice, fxRate, qty, testid }) { + const m = lineProfitThb(costThb, netPrice, fxRate); + if (m == null) { + // Distinguish the two null cases: no cost at all vs a cost but no computed net yet (e.g. a + // SPECIAL_SQM line whose net the server derives on save) — the latter is NOT "missing a price". + const message = costThb == null ? 'กำไร/หน่วย: — (ยังไม่มีต้นทุน)' : 'กำไร/หน่วย: คำนวณเมื่อบันทึก'; + return ( + + {message} + + ); + } + const total = qty != null && qty !== '' ? m.profitPerUnit * Number(qty) : null; + const tone = m.profitPerUnit < 0 ? 'text-danger' : 'text-success'; + return ( + + กำไร/หน่วย: {formatCurrency(round2(m.profitPerUnit), 'THB')} + {m.pct != null ? ` (${m.pct.toFixed(1)}% ของต้นทุน)` : ''} + {total != null ? <> · รวม {formatCurrency(round2(total), 'THB')} : null} + + ); +} + function generateClientRequestId() { return crypto.randomUUID?.() ?? '00000000-0000-4000-8000-' + String(Date.now()).slice(-12).padStart(12, '0'); @@ -899,7 +1010,19 @@ export function PricingRequestDetailPage({ user, showToast }) { // doomed request and surface a raw error where the panel should simply not query at all. const dealQuotationForPrQuery = useQuery({ queryKey: queryKeys.dealQuotationForPricingRequest(pricingRequestId), - queryFn: () => api.dealQuotations.findForPricingRequest(pricingRequestId).then((r) => r.quotation ?? null), + // Review V4: the server's newest row may be a draft cancelled after แก้ใบเสนอราคา — then the + // quotation it revised is still this request's, read from the deal's list the way the deal page + // reads it (newest row that is not CANCELLED). If that list is not readable, the server's row. + queryFn: async () => { + const quotation = (await api.dealQuotations.findForPricingRequest(pricingRequestId)).quotation ?? null; + if (quotation?.docStatus !== 'CANCELLED') return quotation; + try { + const rows = (await api.dealQuotations.listForTicket(Number(detailQuery.data?.summary?.ticketId))).items ?? []; + return newestQuotationByRequest(rows).get(pricingRequestId) ?? null; + } catch { + return quotation; + } + }, enabled: Number.isFinite(pricingRequestId) && canViewCustomerQuotation(user, detailQuery.data?.summary) && !isImport(user), }); @@ -972,6 +1095,10 @@ export function PricingRequestDetailPage({ user, showToast }) { // superseded/replaced it). queryClient.invalidateQueries({ queryKey: ['customerQuotations', 'discountApprovals'] }); queryClient.invalidateQueries({ queryKey: ['pricingRequests', 'queue'] }); + // The deal page's sticky bar reads its pricing requests and quotations. By prefix: that page + // keys them by its own route param. + queryClient.invalidateQueries({ queryKey: ['pricingRequests', 'byTicket'] }); + queryClient.invalidateQueries({ queryKey: ['dealQuotations', 'byTicket'] }); return refetched; // resolves once the detail + factory quotes have been re-read } @@ -1226,9 +1353,10 @@ export function PricingRequestDetailPage({ user, showToast }) { // Step 5: Customer Decision and Commercial Revisions. const [outcomeClientRequestId, setOutcomeClientRequestId] = useState(() => generateClientRequestId()); const [outcomeNote, setOutcomeNote] = useState(''); + // B2: the ACCEPTED outcome a DESIGNER/OWNER request asks about first — { engine, quotation }. + const [acceptToConfirm, setAcceptToConfirm] = useState(null); // Step 6: Deposit, Payment, and Order Confirmation. const [confirmOrderClientRequestId, setConfirmOrderClientRequestId] = useState(() => generateClientRequestId()); - const [depositPercentInput, setDepositPercentInput] = useState('0.5'); const startCeoReview = useActionMutation( () => api.pricingRequests.startPricingDecision(pricingRequestId, { defaultMarginPct: cleanNumber(resolvedDefaultMargin), @@ -1268,6 +1396,18 @@ export function PricingRequestDetailPage({ user, showToast }) { }), 'บันทึกราคาที่ปรับแล้ว', ); + // Per-line margin (owner request 2026-10-21): same PUT /pricing-decisions/{id} endpoint as the + // price override above, with a single-item marginPct payload — applyItemUpdates recomputes THIS + // line's selling price from cost × (1 + margin). No reason field (unlike the overrides): a margin + // is the ordinary formula input, not a price override. The clear* flags are omitted — the record + // is wire-tolerant (they default to false), so this leaves every other item column unchanged. + const saveLineMargin = useActionMutation( + ({ decision, item, marginPct }) => + api.pricingRequests.updatePricingDecision(decision.id, { + items: [{ pricingDecisionItemId: item.id, marginPct }], + }), + 'บันทึกอัตรากำไรแล้ว', + ); // Phase 2 (owner rulings 2026-09-18/19, V187): the mode picker — one PUT that sets priceMode // header-side (only the first time; the request omits it once already set, matching the // "choose once" rule) — separate from the per-item price mutation below so switching modes @@ -1302,11 +1442,19 @@ export function PricingRequestDetailPage({ user, showToast }) { const saveCeoItemPrice = useActionMutation( ({ decision, item, draft, clears = {} }) => { const isStockLine = item.stockSource != null; + // Per-line margin (owner request 2026-10-21): under NET a live อัตรากำไร draft is saved as + // marginPct — the server derives ราคาตั้ง from it via the formula (no override, no reason), + // mutually exclusive with a manual ราคาตั้ง override, so when present it is the ONLY list-side + // field sent. + const marginValue = decision.priceMode === 'NET' && !isStockLine && !clears.clearSellingPriceOverride + ? cleanNumber(draft.marginPct) : null; const currentList = item.manualSellingPricePerRequestedUnit ?? item.listUnitPrice; const listDraft = decision.priceMode === 'NET' || isStockLine ? draft.listUnitPrice : undefined; const listValue = cleanNumber(listDraft); const listPart = {}; - if (clears.clearSellingPriceOverride) { + if (marginValue != null) { + listPart.marginPct = marginValue; + } else if (clears.clearSellingPriceOverride) { Object.assign(listPart, { clearSellingPriceOverride: true, decisionNote: CEO_LIST_PRICE_NOTE }); } else if (listDraft !== undefined && listValue != null && (currentList == null || listValue !== Number(currentList))) { if (isStockLine) listPart.listUnitPrice = listValue; @@ -1503,9 +1651,7 @@ export function PricingRequestDetailPage({ user, showToast }) { onError: (error) => showToast?.('error', error.message || 'ดำเนินการไม่สำเร็จ'), }); const createDepositNoticeFromQuotation = useMutation({ - mutationFn: () => api.pricingRequests.createDepositNoticeFromQuotation(pricingRequestId, { - depositPercent: cleanNumber(depositPercentInput), - }), + mutationFn: () => api.pricingRequests.createDepositNoticeFromQuotation(pricingRequestId, {}), onSuccess: () => { showToast?.('success', 'สร้างร่างใบแจ้งยอดเงินรับมัดจำแล้ว'); // Reuse the existing (legacy) deposit-notice page as-is — it already loads/edits/issues a @@ -1567,6 +1713,23 @@ export function PricingRequestDetailPage({ user, showToast }) { const request = detailQuery.data; const summary = request?.summary; const status = pricingRequestStatusLabel(summary?.status); + // B2 (owner ruling 2026-10-27): ลูกค้ายอมรับ on a DESIGNER/OWNER request asks first, both engines. + const acceptConfirm = acceptOutcomeConfirmCopy(summary?.recipientType); + function recordAccepted(engine, quotation) { + (engine === 'deal' ? recordDealQuotationOutcome : recordQuotationOutcome).mutate({ quotation, outcome: 'ACCEPTED' }); + } + function acceptOutcome(engine, quotation) { + if (acceptConfirm) setAcceptToConfirm({ engine, quotation }); + else recordAccepted(engine, quotation); + } + // Step 6: one deposit notice per deal (owner ruling 2026-10-23). Read only when the owner could + // otherwise be offered "สร้างใบแจ้งยอดเงินรับมัดจำ", so the block can point at an ISSUED one instead. + const depositNoticesQuery = useQuery({ + queryKey: queryKeys.depositNotices(summary?.ticketId), + queryFn: () => api.depositNotices.listByTicket(summary.ticketId).then((r) => r.depositNotices ?? []), + enabled: summary != null && canCreateDepositNoticeFromQuotation(user, summary), + }); + const issuedNotice = issuedDepositNotice(depositNoticesQuery.data); // itemId -> the factoryId Import has picked (as a string, matching SearchableCombobox's own // `code` convention) for that line. B6 (GLA-135): this used to hold the free-typed factory // NAME; it holds an id now, resolved server-side to the canonical master name on save. Same @@ -1994,30 +2157,53 @@ export function PricingRequestDetailPage({ user, showToast }) { // ── Phase 2 (owner rulings 2026-09-18/19, V187) ───────────────────────── if (newForm) { const isStockLine = item.stockSource != null; - // Owner 2026-10-01: the CEO should not have to work ราคาพิเศษ out by hand. Until one is saved - // (or typed), the formula price pre-fills it — as an ordinary draft, so it previews, enables - // บันทึกราคา and is what gets saved, and any typed value overrides it. + const currentList = hasPriceOverride ? item.manualSellingPricePerRequestedUnit : item.listUnitPrice; + const rawDraft = ceoPriceDrafts[item.id] ?? {}; + // Per-line margin (owner request 2026-10-21; SPECIAL_SQM path ported from #1117): a typed + // อัตรากำไร recomputes THIS line's formula price LIVE — ต้นทุน × (1+กำไร) × ตัวคูณ (÷fx for a + // non-THB decision), mirroring PricingDecisionService#computeSellingPrice so the preview equals + // what the server stores on บันทึกราคา (which sends only marginPct). ONE figure, used by every + // mode via formulaPerPiece below: it drives ราคาตั้ง (NET) and the ราคาพิเศษ prefill + // (SPECIAL_SQM). Not on a stock line (no cost) or a "ปรับราคาเอง" override line. + // Margin drives the price ONLY under NET (owner ruling 2026-10-23): SPECIAL_SQM/DIRECT_NET set + // the price directly, so a margin there would not change the saved net — the input is hidden. + const marginDraftRaw = (!isStockLine && decision.priceMode === 'NET') ? rawDraft.marginPct : undefined; + const marginDraftNum = cleanNumber(marginDraftRaw); + const marginDrives = marginDraftRaw != null && marginDraftRaw !== '' + && !hasPriceOverride && item.frozenLandedCostPerRequestedUnitThb != null; + const marginInvalid = marginDrives && marginOutOfRange(marginDraftNum); + // No guessed buffer: if the formula config has not loaded yet (or failed), do NOT preview a + // price — the fallback-to-1 showed a figure ~buffer% below what the server would store. + const sellingBuffer = Number(formulaConfigQuery.data?.sellingBuffer); + const formulaFromMargin = marginDrives && !marginInvalid && sellingBuffer > 0 + ? round2(round2(Number(item.frozenLandedCostPerRequestedUnitThb) * (1 + marginDraftNum) * sellingBuffer) + / (decision.currency === 'THB' ? 1 : (Number(decision.fxRateUsed) || 1))) + : null; + // The per-piece formula price every mode reads: the margin-recomputed one while the CEO is + // typing a margin, else the server's stored proposed price. + const formulaPerPiece = formulaFromMargin ?? item.proposedSellingPricePerRequestedUnit; + // The CEO should not have to work ราคาพิเศษ out by hand. Until one is saved (or typed), the + // formula price pre-fills it (now following the margin-driven price); any typed value overrides. const autoSpecial = decision.priceMode === 'SPECIAL_SQM' && !isStockLine && editable && item.specialPriceSqm == null && decision.currency === 'THB' - ? specialPriceSqmFromFormula(item.proposedSellingPricePerRequestedUnit, item.sqmPerPiece) + ? specialPriceSqmFromFormula(formulaPerPiece, item.sqmPerPiece) : null; - const draft = { ...(autoSpecial != null ? { specialPriceSqm: autoSpecial } : {}), ...(ceoPriceDrafts[item.id] ?? {}) }; - // 2026-10-01 (owner Ploy): under NET the CEO types ราคาตั้ง again. The effective list price - // mirrors PricingDecisionService (override ?? listUnitPrice); the input starts from it and - // the in-progress draft wins for the preview. - const currentList = hasPriceOverride ? item.manualSellingPricePerRequestedUnit : item.listUnitPrice; + const draft = { ...(autoSpecial != null ? { specialPriceSqm: autoSpecial } : {}), ...rawDraft }; const listDraft = draft.listUnitPrice; - const listInputValue = listDraft ?? currentList ?? ''; + // When margin drives, ราคาตั้ง reflects the computed value (read-only under NET); otherwise it + // is the CEO's own typed/stored list. + const listInputValue = formulaFromMargin != null ? formulaFromMargin : (listDraft ?? currentList ?? ''); const listDraftNumber = cleanNumber(listDraft); // Java rejects a non-positive ราคาตั้ง (stock: "> 0"; override: non-negative, and 0 would // price the line at zero), so the UI refuses it up front instead of round-tripping a 400. // Blank is NOT invalid: on an override line it means "clear the override". - const listInvalid = listDraft != null && listDraft !== '' && !(listDraftNumber > 0); + const listInvalid = !marginDrives && listDraft != null && listDraft !== '' && !(listDraftNumber > 0); // A blanked input on a line that has an override means "drop the override" (server then // falls back to listUnitPrice); on any other line a blank simply leaves the price as saved. - const previewList = listDraftNumber != null && !listInvalid ? listDraftNumber - : (listDraft === '' && hasPriceOverride && !isStockLine) ? item.listUnitPrice - : currentList; + const previewList = formulaFromMargin != null ? formulaFromMargin + : (listDraftNumber != null && !listInvalid ? listDraftNumber + : (listDraft === '' && hasPriceOverride && !isStockLine) ? item.listUnitPrice + : currentList); const previewNet = previewCeoNetUnitPrice(decision.priceMode, { listUnitPrice: previewList, discountPct: draft.discountPct ?? item.discountPct, @@ -2040,8 +2226,8 @@ export function PricingRequestDetailPage({ user, showToast }) { const lineTotal = displayNet != null ? round2(Number(item.requestedQuantity) * displayNet) : null; // Owner ruling A (2026-09-19): the formula price is shown both per แผ่น and per ตร.ม. — a // plain division, not the SPECIAL_SQM VAT-stripping algorithm, so safe to compute here. - const formulaPricePerSqm = item.proposedSellingPricePerRequestedUnit != null && item.sqmPerPiece > 0 - ? round2(Number(item.proposedSellingPricePerRequestedUnit) / Number(item.sqmPerPiece)) + const formulaPricePerSqm = formulaPerPiece != null && item.sqmPerPiece > 0 + ? round2(Number(formulaPerPiece) / Number(item.sqmPerPiece)) : null; const savingThisItem = saveCeoItemPrice.isPending && saveCeoItemPrice.variables?.item?.id === item.id; @@ -2087,18 +2273,19 @@ export function PricingRequestDetailPage({ user, showToast }) { const missingModeInputForList = listChanged && isStockLine && ( (decision.priceMode === 'DIRECT_NET' && modeInputBlank(draft.directNetPrice ?? item.directNetPrice)) || (decision.priceMode === 'SPECIAL_SQM' && modeInputBlank(draft.specialPriceSqm ?? item.specialPriceSqm))); - const saveDisabled = listInvalid || missingModeInputForList || !(otherDraftDirty || listChanged); + const saveDisabled = listInvalid || marginInvalid || missingModeInputForList || !(otherDraftDirty || listChanged); const costForcedOpen = showCost && Boolean(costingItem?.uncostableReason || costingItem?.overrideStale); const listPriceField = (
    - + updateCeoPriceDraft(item.id, { listUnitPrice: e.target.value })} data-testid={`pcr-ceo-list-price-${item.id}`} @@ -2114,9 +2301,9 @@ export function PricingRequestDetailPage({ user, showToast }) { : 'กรอกราคาพิเศษ บาท/ตร.ม. ด้วยก่อนบันทึก'} ) : null} - {item.proposedSellingPricePerRequestedUnit != null ? ( + {formulaPerPiece != null ? ( - สูตร: {formatCurrency(item.proposedSellingPricePerRequestedUnit, decision.currency)} / แผ่น + สูตร: {formatCurrency(formulaPerPiece, decision.currency)} / แผ่น {formulaPricePerSqm != null ? ` · ${formatCurrency(formulaPricePerSqm, decision.currency)} / ตร.ม.` : ''} ) : null} @@ -2159,6 +2346,13 @@ export function PricingRequestDetailPage({ user, showToast }) { รวมเป็นเงิน: {lineTotal != null ? formatCurrency(lineTotal, decision.currency) : '-'} +
    ); return ( @@ -2185,6 +2379,31 @@ export function PricingRequestDetailPage({ user, showToast }) { : null} ) : null} + {/* Per-product margin (owner request 2026-10-21): a LIVE input (like ส่วนลด — no separate save + button). Typing it recomputes THIS line's ราคาตั้ง in real time, and the net/กำไร readout + move with it; saved by the shared "บันทึกราคา" as marginPct. NET ONLY (owner ruling + 2026-10-23): SPECIAL_SQM/DIRECT_NET set the price directly, so a margin there would be a + no-op on the saved net. Not on a stock line or a "ปรับราคาเอง" override. */} + {showCost && !hasPriceOverride && item.frozenLandedCostPerRequestedUnitThb != null + && decision.priceMode === 'NET' ? ( + + updateCeoPriceDraft(item.id, { marginPct: e.target.value })} + data-testid={`pcr-ceo-margin-input-${item.id}`} + /> + + ) : null} {decision.priceMode == null ? (

    เลือกวิธีกรอกราคากระเบื้องด้านบนก่อน

    ) : ( @@ -2229,9 +2448,9 @@ export function PricingRequestDetailPage({ user, showToast }) { {decision.priceMode === 'SPECIAL_SQM' ? ( <> {isStockLine ? listPriceField : null} - {!isStockLine && item.proposedSellingPricePerRequestedUnit != null ? ( + {!isStockLine && formulaPerPiece != null ? ( - ราคาตั้ง/แผ่น (สูตร): {formatCurrency(item.proposedSellingPricePerRequestedUnit, decision.currency)} + ราคาตั้ง/แผ่น (สูตร): {formatCurrency(formulaPerPiece, decision.currency)} {formulaPricePerSqm != null ? ` · ${formatCurrency(formulaPricePerSqm, decision.currency)} / ตร.ม.` : ''} ) : null} @@ -2393,16 +2612,34 @@ export function PricingRequestDetailPage({ user, showToast }) { ราคาขาย (ก่อน VAT): {formatCurrency(effectivePrice, decision.currency)} + {costingItem?.uncostableReason ? (

    {costingItem.uncostableReason}

    ) : null} - {!editable ? ( + {/* Per-line margin (owner request 2026-10-21): on an editable, formula-driven line the CEO + sets อัตรากำไร% for THIS line here. Hidden once a "ปรับราคาเอง" override is active (the + formula no longer drives the line) and once the decision is read-only — both fall back to + the read-only อัตรากำไร/ราคาขั้นต่ำ summary. The NEW-FORM (V187) path returns above and + never reaches here, so this stays the legacy formula path only. */} + {editable && !hasPriceOverride ? ( + saveLineMargin.mutate({ decision, item, marginPct })} + /> + ) : (
    อัตรากำไร: {effectiveMargin ?? '-'} ราคาขั้นต่ำ: {item.minimumSellingPricePerRequestedUnit != null ? formatCurrency(item.minimumSellingPricePerRequestedUnit, decision.currency) : '-'}
    - ) : null} + )}
    ) : null} - {canCreateDepositNoticeFromQuotation(user, summary) ? ( + {issuedNotice && canCreateDepositNoticeFromQuotation(user, summary) ? ( +

    + ดีลนี้ออกใบแจ้งยอดเงินรับมัดจำแล้ว —{' '} + + เปิดเลขที่ {issuedNotice.docNumber} + +

    + ) : null} + {canCreateDepositNoticeFromQuotation(user, summary, dealQuotationForPr, depositNoticesQuery.data) ? (

    ยืนยันคำสั่งซื้อแล้ว — สร้างใบแจ้งยอดเงินรับมัดจำจากใบเสนอราคาที่ลูกค้ายอมรับ (แก้ไข/ออกเอกสารในหน้าใบแจ้งยอดเงินรับมัดจำ)

    - ) : null}
    + {doc.status === 'ISSUED' ? ( +

    ดีลนี้ออกใบแจ้งยอดเงินรับมัดจำแล้ว — สร้างฉบับใหม่ไม่ได้

    + ) : null} {previewHtml ? (
    @@ -316,11 +329,9 @@ export function DealDepositPanel({ user, ticketId, summary, availableActions = [ ) : canCreateNotice ? (

    ลูกค้ายืนยันคำสั่งซื้อแล้ว — สร้างใบแจ้งยอดเงินรับมัดจำจากใบเสนอราคาที่ยอมรับได้เลย

    - {/* Owner ruling (2026-09-29): the deposit % is LOCKED to the accepted quotation's - own deposit_percent — createDraft ignores any client-supplied value and always - re-sources it from the quotation (see DepositNoticeService.createDraft / - DepositNoticeRepository#findDealQuotationDepositPercent). The % input that used - to sit here is gone — there is nothing left for it to control. */} + {/* The backend picks a new notice's % and ignores a client value + (DepositNoticeService.createDraftFromQuotation; findGoverningQuotation, #1170), so no + % input here. Sales changes the % on the draft (DepositNoticePage, ruling 2026-10-23). */}
    + {doc ? ( + + )} + confirmLabel="ออกเอกสาร" + busy={issueMutation.isPending} + onCancel={() => setConfirmIssue(false)} + onConfirm={() => issueMutation.mutate()} + /> + ) : null} + {policyOpen ? ( ({ id: doc.id, @@ -507,16 +510,21 @@ function DocumentGroup({ items }) { // 8px gap between them is kept by `gap-2`. Desktop stays compact. const TOUCH_TARGET = 'nav-drawer:min-h-11 nav-drawer:min-w-11'; -function DocumentRow({ testId, icon, title, meta, status, date, amount, recipient, actions = [], muted = false }) { +function DocumentRow({ testId, icon, title, meta, status, date, amount, recipient, actions = [], muted = false, to }) { const hasAmount = amount !== undefined && amount !== null; const metaText = [date ? formatThaiDate(date) : null, meta].filter(Boolean).join(' · '); return ( // Line 1: number + status + recipient (wraps). Line 2: date/meta. Line 3 (mobile) / right side // of line 2 (desktop): amount on the left, actions on the right. + // `to`: the number is a plain link to the document (the title only).
    {!muted && icon ? : null} - {title} + {to ? ( + {title} + ) : ( + {title} + )} {status ? {status.label} : null} {/* Recipient of the whole document: shown on the current (top) row only. */} {recipient && !muted ? ( diff --git a/frontend/src/features/tickets/DealDocumentRegister.test.jsx b/frontend/src/features/tickets/DealDocumentRegister.test.jsx index 459e1a935..fa1226cbe 100644 --- a/frontend/src/features/tickets/DealDocumentRegister.test.jsx +++ b/frontend/src/features/tickets/DealDocumentRegister.test.jsx @@ -1,6 +1,7 @@ import React from 'react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { MemoryRouter, useLocation } from 'react-router-dom'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { DealDocumentRegister } from './DealDocumentRegister.jsx'; import { api } from '../../api/index.js'; @@ -70,10 +71,17 @@ const SALES_SECTIONS = { dealQuotation: true, quotation: true, depositNotice: tr const IMPORT_SECTIONS = { dealQuotation: false, quotation: false, depositNotice: false }; const ACCOUNT_SECTIONS = { dealQuotation: true, quotation: true, depositNotice: true }; +function LocationProbe() { + const location = useLocation(); + return
    {location.pathname}
    ; +} + function renderRegister(props) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); return render( + + + , ); } @@ -270,6 +279,24 @@ describe('DealDocumentRegister', () => { expect(await screen.findByTestId('document-version-direct-77')).not.toBeNull(); }); + // Spec I1 (2026-10-27): the number is a plain link — the title only, no stretched overlay over the + // row (that part is CSS, which jsdom cannot see). The PDF/Excel downloads are pinned by 'lists + // direct quotations … downloads PDF by id' above; "PDF does not navigate" could not fail in jsdom + // (the button was never inside the link), so it is gone. + it('a direct quotation\'s number links to /quotations/', async () => { + api.dealQuotations.listForTicket.mockResolvedValue({ + items: [{ id: 77, number: 'QT-2026-0077-1', docStatus: 'APPROVED', revisionNo: 1, grandTotal: 2, origin: 'DEAL_DIRECT' }], + }); + renderRegister({ ...SALES, pricingRequests: CHAIN_PR }); + + const row = within(await screen.findByTestId('document-version-direct-77')); + const link = row.getByRole('link', { name: 'QT-2026-0077-1' }); + expect(link.textContent).toBe('QT-2026-0077-1'); + + fireEvent.click(link); + expect(screen.getByTestId('location-probe').textContent).toBe('/quotations/77'); + }); + it('groups chain revisions of one pricing request together even when their numbers are unrelated', async () => { api.pricingRequests.listCustomerQuotations.mockResolvedValue({ items: [ diff --git a/frontend/src/features/tickets/DealQuotationPanel.jsx b/frontend/src/features/tickets/DealQuotationPanel.jsx index 338d17a78..39651db27 100644 --- a/frontend/src/features/tickets/DealQuotationPanel.jsx +++ b/frontend/src/features/tickets/DealQuotationPanel.jsx @@ -1,11 +1,12 @@ import { - forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState, + forwardRef, useImperativeHandle, useMemo, useState, } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { Link } from 'react-router-dom'; import { api } from '../../api/index.js'; import { queryKeys } from '../../api/queryKeys.js'; import { Button } from '../../components/common/Button.jsx'; +import { ConfirmDialog } from '../../components/common/ConfirmDialog.jsx'; import { Icon } from '../../components/common/Icon.jsx'; import { Panel } from '../../components/common/Layout.jsx'; import { StatusBadge } from '../../components/common/StatusBadge.jsx'; @@ -21,6 +22,7 @@ import { canManageCustomerQuotation, canRecordCustomerQuotationOutcome, canViewCustomerQuotation, isCustomerQuotationEditable, pricingRequestRecipientLabel, } from '../pricingRequests/pricingRequestMeta.js'; +import { acceptOutcomeConfirmCopy, dealQuotationStatusLabel } from '../quotations/quotationMeta.js'; import { buttonVariants } from '../../components/common/Button.jsx'; function generateClientRequestId() { @@ -36,11 +38,18 @@ function generateClientRequestId() { const QUOTATION_TAIL_STATUSES = new Set(['APPROVED_FOR_QUOTATION', 'QUOTATION_ISSUED', 'QUOTATION_ACCEPTED']); function pickRelevantPricingRequest(pricingRequests = []) { - return pricingRequests + const tail = pricingRequests .filter((pr) => QUOTATION_TAIL_STATUSES.has(pr.status)) - .sort((a, b) => b.id - a.id)[0] ?? null; + .sort((a, b) => b.id - a.id); + // The deal's order lives on its accepted request (one per deal, R8): the panel shows and confirms + // that one, not a later party's newer request (review V2). + return tail.find((pr) => pr.status === 'QUOTATION_ACCEPTED') ?? tail[0] ?? null; } +// An old-engine quotation that no longer stands (CustomerQuotationRepository#hasLiveQuotation's +// non-live set): it does not hide the request's new-engine quotation (review V6). +const DEAD_CUSTOMER_QUOTATION_STATUSES = new Set(['CANCELLED', 'SUPERSEDED', 'EXPIRED', 'REJECTED']); + /** * "ราคาและใบเสนอราคา" (Phase 2 Slice S2 — see * docs/agent-handoffs/104_feat-deal-workspace-unification.md): pulls the @@ -66,60 +75,35 @@ function pickRelevantPricingRequest(pricingRequests = []) { * component's git history for the removed `createDepositNotice` * mutation/`depositPercentInput` state if that overlap ever needs undoing. * + * Owner ruling R2 (2026-10-18/22): the sticky "เสนอใบราคา" writes the NEW-engine quotation + * (TicketDetailPage, api.dealQuotations.createFromPricingRequest); this panel no longer creates or + * issues the old-engine customer quotation from the deal page. It shows that new-engine quotation + * (`quotationByRequest`, the deal's quotation list) with a link to it. + * * Ticket-detail IA rebuild Phase 1 clutter follow-up round 2 (FIX 2): this * panel no longer renders its own "ออกใบเสนอราคา" / "ยืนยันคำสั่งซื้อ" * buttons — the sticky header's primary CTA (TicketDetailPage's * `workState`-derived ISSUE_QUOTATION/CONFIRM_ORDER action) owns both - * outright now, and triggers them here via the forwardRef below (same + * outright now; CONFIRM_ORDER triggers the confirm here via the forwardRef below (same * "parent triggers, panel stays the sole gate + mutation owner" convention * DealStagePanel/PricingRequestPanel already use for their own openers). * Before this, "ออกใบเสนอราคา" and "ยืนยันคำสั่งซื้อ" each rendered twice on * one page (the sticky bar's own scroll-to-here copy, plus this panel's own * button) — since the sticky bar is `sticky top-0`, both copies of each * label were visible on screen simultaneously. - * - * Handoff 117 follow-up ("A second, real bug found while making the specs - * actually pass"): FIX 2 above made `openIssueQuotation` fire - * `issueQuotation` straight through the ref, but `nextSalesAction`'s - * ISSUE_QUOTATION bucket (salesActions.js) only checks - * `pr.status === 'APPROVED_FOR_QUOTATION'` — it has no visibility into - * whether a draft customer quotation exists yet, because that only comes - * from THIS panel's own per-PR `listCustomerQuotations` fetch - * (salesActions.js is deliberately built from the ticket-list row + PR queue - * alone and documents that it never triggers a per-ticket detail fetch — see - * its own module doc comment — so widening it to know "a draft exists" was - * rejected as the fix). That mismatch meant the sticky "ออกใบเสนอราคา" button - * appeared the instant CEO approved the price, but silently did nothing - * until the rep separately clicked "สร้างร่างใบเสนอราคาลูกค้า" below to - * create the draft `openIssueQuotation` requires. - * - * Fixed here (not in salesActions.js) by making `openIssueQuotation` smart: - * when no draft exists yet but the PR/permissions genuinely support - * creating one, it now creates the draft and issues it in one chained - * mutation (`createAndIssueQuotation`) instead of no-op'ing. This is a - * deliberate sales-workflow change (CLAUDE.md's "Sales flow redesign" — - * authorised, not a side effect): issuing a quotation used to always take - * two separate clicks (create, then issue) so a rep could review/discount - * the draft first; now the sticky button can collapse that into one click - * with default (no-discount) terms. The in-panel "สร้างร่างใบเสนอราคาลูกค้า" - * button below is left in place specifically so a rep who wants to edit - * discounts before issuing still has that path — see its updated copy. Every - * branch that still can't do anything (query not yet settled, gates fail, - * etc.) now shows a real `showToast?.('error', ...)` instead of doing - * nothing — see `openIssueQuotation`'s own decision-tree comment below for - * the exact branches. */ -export const DealQuotationPanel = forwardRef(function DealQuotationPanel({ ticketId, pricingRequests = [], user, showToast }, ref) { +export const DealQuotationPanel = forwardRef(function DealQuotationPanel({ + ticketId, pricingRequests = [], quotationByRequest = null, user, showToast, +}, ref) { const queryClient = useQueryClient(); const [outcomeNote, setOutcomeNote] = useState(''); const [downloadingFormat, setDownloadingFormat] = useState(null); - // openRecordOutcome's scroll target — the "บันทึกผลจากลูกค้า" accept/reject/ - // revision block below (only rendered while canRecordCustomerQuotationOutcome - // is true). Same "attach a ref, guard scrollIntoView for jsdom" convention as - // TicketDetailPage's own focusFirstInvalid. - const outcomeControlsRef = useRef(null); + const [confirmingAccept, setConfirmingAccept] = useState(false); const pr = useMemo(() => pickRelevantPricingRequest(pricingRequests), [pricingRequests]); + const prQuotation = quotationByRequest?.get(pr?.id); + const prQuotationStatus = prQuotation && dealQuotationStatusLabel(prQuotation.docStatus); + const acceptConfirm = acceptOutcomeConfirmCopy(pr?.recipientType); const canView = pr != null && canViewCustomerQuotation(user, pr); const quotationsQuery = useQuery({ @@ -143,39 +127,6 @@ export const DealQuotationPanel = forwardRef(function DealQuotationPanel({ ticke queryClient.invalidateQueries({ queryKey: queryKeys.ticketDetail(ticketId) }); } - const createQuotation = useMutation({ - mutationFn: () => api.pricingRequests.createCustomerQuotation(pr.id, { clientRequestId: generateClientRequestId() }), - onSuccess: () => { showToast?.('success', 'สร้างร่างใบเสนอราคาลูกค้าแล้ว'); invalidate(); }, - onError: (err) => showToast?.('error', err.message || 'ดำเนินการไม่สำเร็จ'), - }); - const issueQuotation = useMutation({ - mutationFn: (quotationId) => api.pricingRequests.issueCustomerQuotation(quotationId, { clientRequestId: generateClientRequestId() }), - onSuccess: () => { showToast?.('success', 'ออกใบเสนอราคาลูกค้าแล้ว'); invalidate(); }, - onError: (err) => showToast?.('error', err.message || 'ดำเนินการไม่สำเร็จ'), - }); - // Silent-no-op fix (handoff 117, "A second, real bug found while making the - // specs actually pass"): salesActions.js's ISSUE_QUOTATION bucket fires the - // sticky primary CTA off `pr.status === 'APPROVED_FOR_QUOTATION'` alone — - // it has no way to know whether a draft customer quotation already exists, - // because that only comes from this panel's own per-PR - // `listCustomerQuotations` fetch (salesActions.js's own doc comment states - // it never triggers a per-ticket detail fetch, so it can't be widened to - // know this — see the handoff for why that option was rejected). So when - // the sticky button is clicked before anyone has pressed "สร้างร่างใบเสนอ - // ราคาลูกค้า" below, `current` is still null and the click used to do - // nothing at all. This mutation closes that gap by creating the draft and - // issuing it in one chained action when the PR/permissions genuinely - // support it — see openIssueQuotation below for the full decision tree. - const createAndIssueQuotation = useMutation({ - mutationFn: async () => { - const created = await api.pricingRequests.createCustomerQuotation(pr.id, { clientRequestId: generateClientRequestId() }); - const draft = created?.quotation; - if (draft?.id == null) throw new Error('สร้างร่างใบเสนอราคาไม่สำเร็จ'); - return api.pricingRequests.issueCustomerQuotation(draft.id, { clientRequestId: generateClientRequestId() }); - }, - onSuccess: () => { showToast?.('success', 'สร้างร่างและออกใบเสนอราคาลูกค้าแล้ว'); invalidate(); }, - onError: (err) => showToast?.('error', err.message || 'ดำเนินการไม่สำเร็จ'), - }); const recordOutcome = useMutation({ mutationFn: (outcome) => api.pricingRequests.recordCustomerQuotationOutcome(current.id, { outcome, customerNote: outcomeNote || null, clientRequestId: generateClientRequestId(), @@ -189,92 +140,9 @@ export const DealQuotationPanel = forwardRef(function DealQuotationPanel({ ticke onError: (err) => showToast?.('error', err.message || 'ดำเนินการไม่สำเร็จ'), }); - // Defensive re-check before acting (same convention as DealStagePanel's - // openAdvance/openEditStage/... and PricingRequestPanel's openCreate): a - // stale or over-eager caller — the sticky header's primary CTA is derived - // from a snapshot of `pricingRequests`/`summary` that could in principle - // race a fresher render — cannot force either mutation past the real gate. - // `current`/`pr` are computed above via useMemo and may be null (no - // qualifying pricing request / no editable quotation yet); both guards - // are null-safe through their own predicates. - // - // openIssueQuotation's decision tree (handoff 117 fix for the "silent - // no-op" bug — see createAndIssueQuotation's own comment above for the - // root cause): - // 1. A mutation is already in flight → do nothing (a double-click must - // never fire two mutations, and must never create two drafts). - // 2. An editable draft already exists → issue THAT draft (unchanged - // original behaviour — this is still the common path once the rep - // has used the in-panel "สร้างร่างใบเสนอราคาลูกค้า" button first). - // 3. No draft yet, but we genuinely don't know that for sure — - // `quotationsQuery` hasn't resolved (it's `enabled: canView`, so also - // gate on `canView` or a not-yet-enabled query reads as "not - // success" forever). FIX 3 (Opus review — cross-tab dead end): queue - // a silent retry instead of telling the rep to click again — see the - // effect below. - // 4. No draft, the query has genuinely settled empty, and the PR/user - // still pass the create gate → create the draft and issue it in one - // chained mutation. This is the actual bug fix: the sticky button - // now always does SOMETHING when nextSalesAction says ISSUE_QUOTATION. - // 5. None of the above → the click cannot do anything (e.g. the only - // quotation is already ISSUED/ACCEPTED/etc). Never fail silently — - // tell the rep where to look. - // - // FIX 3 (Opus review, cross-tab `issue_quotation` first-click dead end): - // TicketDetailPage's `runOnTab('quotations', () => - // dealQuotationPanelRef.current?.openIssueQuotation())` switches to this - // tab and calls this ref in the SAME commit the tab mounts — this panel's - // own `quotationsQuery` has only just started fetching, so on a rep's - // FIRST click from a different tab (e.g. ภาพรวม), branch 3 above used to - // fire unconditionally: the rep saw "กำลังโหลดข้อมูลใบเสนอราคา — กรุณาลองอีกครั้ง" - // and nothing happened, even though the very next tick would have had - // everything needed to issue for real. A second click (now with a warm, - // already-`isSuccess` query) always "fixed" it, which is exactly what made - // this easy to miss — it only reproduces on a session's first visit to - // this deal. Fixed WITHOUT pre-mounting every panel (that reinstates the - // 4,000px page this rebuild retires): `attemptIssueQuotation` is factored - // out so it can run twice — once immediately (branch 3 now just queues a - // retry instead of toasting) and once more, automatically, the moment - // `quotationsQuery` actually settles (the effect below, keyed on - // `quotationsQuery.isSuccess`). No polling, no fixed delay — the retry is - // driven by the query's own real state transition. - const pendingIssueIntentRef = useRef(false); - - function attemptIssueQuotation() { - if (issueQuotation.isPending || createQuotation.isPending || createAndIssueQuotation.isPending) return; - if (current && isCustomerQuotationEditable(current) && canManageCustomerQuotation(user, pr)) { - pendingIssueIntentRef.current = false; - issueQuotation.mutate(current.id); - } else if (!current && canView && !quotationsQuery.isSuccess) { - // Not settled yet — queue the retry (see the effect below) instead of - // telling the rep to click again; this is the exact race FIX 3 closes. - pendingIssueIntentRef.current = true; - } else if (!current && quotationsQuery.isSuccess && canCreateCustomerQuotation(user, pr)) { - pendingIssueIntentRef.current = false; - createAndIssueQuotation.mutate(); - } else { - pendingIssueIntentRef.current = false; - showToast?.('error', 'ยังออกใบเสนอราคาไม่ได้ — ตรวจสอบสถานะคำขอราคาในส่วน "ราคาและใบเสนอราคา" ด้านล่าง'); - } - } - - // Fires the queued retry exactly once, the moment `quotationsQuery` - // actually resolves — `attemptIssueQuotation` re-reads `current`/ - // `quotationsQuery.isSuccess` fresh (closures over this render's state), - // both already updated by the time this effect runs (the query settling - // is what triggered this very re-render). Re-entrant/idempotent by - // construction (the in-flight guard at the top of `attemptIssueQuotation`), - // so calling it again here is safe even if something else already resolved - // the intent in between. - useEffect(() => { - if (pendingIssueIntentRef.current && quotationsQuery.isSuccess) { - attemptIssueQuotation(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [quotationsQuery.isSuccess]); - + // Defensive re-check before acting (same convention as DealStagePanel's openers): a stale caller + // cannot force a mutation past the real gate. useImperativeHandle(ref, () => ({ - openIssueQuotation: attemptIssueQuotation, openConfirmOrder: () => { if (confirmOrder.isPending) return; if (pr && canConfirmOrder(user, pr)) { @@ -284,26 +152,6 @@ export const DealQuotationPanel = forwardRef(function DealQuotationPanel({ ticke ?? 'ยังยืนยันคำสั่งซื้อไม่ได้ — ตรวจสอบสถานะคำขอราคาในส่วน "ราคาและใบเสนอราคา" ด้านล่าง'); } }, - // salesActions.js's RECORD_QUOTATION_OUTCOME bucket fires off `pr.status === 'QUOTATION_ISSUED'` - // alone (it has no visibility into the quotation document itself — see that bucket's own - // comment) — the real gate, canRecordCustomerQuotationOutcome, additionally requires - // `current.docStatus === 'ISSUED'`, which is only known once this panel's own quotationsQuery - // has resolved. Unlike openIssueQuotation/openConfirmOrder this never fires a mutation itself: - // the outcome (accept/reject/revision) is the rep's own judgment call, not something a single - // click can safely default — so this only ever scrolls the existing controls into view, same as - // FIX 2's rationale for keeping this panel the sole gate + mutation owner. Falls back to a toast - // (matching openConfirmOrder's own fallback copy/style) whenever the controls aren't actually on - // screen: the query hasn't settled yet, or the PR/quotation no longer qualifies (e.g. a stale - // sticky button click racing an outcome someone already recorded in another tab). - openRecordOutcome: () => { - const node = outcomeControlsRef.current; - if (node && canRecordCustomerQuotationOutcome(user, pr, current)) { - if (typeof node.scrollIntoView === 'function') node.scrollIntoView({ behavior: 'smooth', block: 'center' }); - node.focus?.({ preventScroll: true }); - } else { - showToast?.('error', 'ยังบันทึกผลใบเสนอราคาไม่ได้ — ตรวจสอบสถานะคำขอราคาในส่วน "ราคาและใบเสนอราคา" ด้านล่าง'); - } - }, })); async function handleDownload(format) { @@ -348,20 +196,19 @@ export const DealQuotationPanel = forwardRef(function DealQuotationPanel({ ticke {quotationsQuery.isLoading ? (

    กำลังโหลดใบเสนอราคาลูกค้า…

    + ) : prQuotation && !(current && !DEAD_CUSTOMER_QUOTATION_STATUSES.has(current.docStatus)) ? ( +
    + {prQuotation.number} + + {prQuotationStatus.label} + + เปิดใบเสนอราคา +
    ) : !current ? ( canCreateCustomerQuotation(user, pr) ? ( -
    -

    - CEO อนุมัติราคาขายแล้ว — สร้างร่างใบเสนอราคาให้ลูกค้าได้เลย - หรือกดปุ่ม “ออกใบเสนอราคา” บนแถบด้านบนของหน้าเพื่อสร้างร่างและออกใบเสนอราคาในขั้นตอนเดียว - (ใช้ปุ่มด้านล่างนี้แทนหากต้องการแก้ไขส่วนลด/รายละเอียดก่อนออกจริง) -

    - -
    +

    + CEO อนุมัติราคาขายแล้ว — กดปุ่ม “เสนอใบราคา” บนแถบด้านบนเพื่อเขียนใบเสนอราคา +

    ) : (

    ยังไม่มีใบเสนอราคาลูกค้าสำหรับคำขอราคานี้

    ) @@ -398,22 +245,8 @@ export const DealQuotationPanel = forwardRef(function DealQuotationPanel({ ticke ) : null}
    - {isCustomerQuotationEditable(current) && canManageCustomerQuotation(user, pr) ? ( - // The "ออกใบเสนอราคา" button itself lives solely on the sticky - // header now (FIX 2, see this component's own doc comment) — - // this explains the section instead of duplicating the CTA. -

    - พร้อมออกใบเสนอราคาแล้ว — กดปุ่ม “ออกใบเสนอราคา” บนแถบด้านบนของหน้า - หรือแก้ไขรายละเอียด/ส่วนลดก่อนได้ที่ลิงก์ด้านบน -

    - ) : null} - {canRecordCustomerQuotationOutcome(user, pr, current) ? ( - // ref + tabIndex: openRecordOutcome's scroll-and-focus target (the sticky header's - // "บันทึกผลใบเสนอราคา" CTA, salesActions.js's RECORD_QUOTATION_OUTCOME bucket) — see - // this component's useImperativeHandle above. tabIndex={-1} makes the section - // programmatically focusable without adding it to the page's normal tab order. -
    +
    บันทึกผลจากลูกค้า