From cdac04a2d37ba2c3fb3ca3883fa85e21208c3c05 Mon Sep 17 00:00:00 2001 From: waritctd <148909369+waritctd@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:15:42 +0700 Subject: [PATCH] fix(sales): direct confirm-order sends once on a double-click; in-Thailand stock lines need no import lead time (UI re-walk 2026-10-29) - SK-02/VW-05: the direct-quotation confirm dialogs (TicketDetailPage, QuotationEditorPage) close before mutating, like the pricing-request door, and call /confirm-order (the backend /promote-to-deal alias stays for one deploy). - VW-01: DealQuotationItemDto exposes stockSource; submit and the editor skip the import lead-time requirement for IN_THAILAND stock lines only. IN_TRANSIT and import lines still need one (owner ruling 2026-10-29). A copied quotation drops stockSource, as the server does. - API digests regenerated (response-fields: DealQuotationItemDto.stockSource; ui-reachable: confirm-order). Co-Authored-By: Claude Opus 5.5 --- .../hr/dealquotation/DealQuotationDtos.java | 63 ++++++++++++++++--- .../DealQuotationRepository.java | 6 +- .../dealquotation/DealQuotationService.java | 3 +- .../DealQuotationIntegrationTest.java | 36 +++++++++++ docs/api/response-fields.json | 3 +- docs/api/ui-reachable.json | 2 +- frontend/src/api/routes.js | 2 +- frontend/src/api/serverContract.test.js | 2 +- .../quotations/QuotationEditorPage.jsx | 2 +- ...QuotationEditorPage.promoteToDeal.test.jsx | 14 +++++ .../QuotationEditorPage.v3.test.jsx | 24 ++++++- .../src/features/quotations/quotationCopy.js | 3 +- .../quotations/quotationCopy.test.jsx | 6 ++ .../src/features/quotations/quotationMeta.js | 3 +- .../features/quotations/quotationMeta.test.js | 11 ++++ .../src/features/tickets/TicketDetailPage.jsx | 3 +- .../tickets/TicketDetailPage.test.jsx | 14 +++++ 17 files changed, 176 insertions(+), 21 deletions(-) diff --git a/backend/src/main/java/th/co/glr/hr/dealquotation/DealQuotationDtos.java b/backend/src/main/java/th/co/glr/hr/dealquotation/DealQuotationDtos.java index 36048363f..c74680bc6 100644 --- a/backend/src/main/java/th/co/glr/hr/dealquotation/DealQuotationDtos.java +++ b/backend/src/main/java/th/co/glr/hr/dealquotation/DealQuotationDtos.java @@ -835,11 +835,16 @@ public record DealQuotationItemDto( * Non-null ONLY on a TILE row of a document whose {@code priceMode} is {@code MIXED}; on * every other row it is null and the document's mode applies * ({@code WastageCalculator#rowPriceMode} is the one rule). */ - String priceMode + String priceMode, + /** V194 — "IN_THAILAND" | "IN_TRANSIT" when this row came from a stock-flagged pricing-request + * line (owner ruling 2026-10-28: stock lines skip import, so they carry no import lead time); + * null = สั่งนำเข้า, and null on every DEAL_DIRECT row. Read-only: set at insert from the + * pricing-request item, never accepted from the client. */ + String stockSource ) { /** Ruling 3 (2026-09-30): the account view -- net unit price and line amount stay; every list-price / discount / CEO pricing field is emptied. */ public DealQuotationItemDto withoutPriceInternals() { - return new DealQuotationItemDto(id, seq, locationLabel, null, productCode, brand, model, color, texture, sizeText, thicknessMm, sqmPerPiece, quantityMode, areaSqm, piecesInput, wastageMode, wastageValue, piecesPerBox, null, null, originCountry, leadTimeMinDays, leadTimeMaxDays, itemNotes, piecesPerSqm, piecesBeforeWastage, piecesAfterWastage, piecesFinal, boxes, netUnitPrice, lineAmount, descriptionLine, sizeLine, null, lineType, quantity, unit, null, null, adjustmentDeadline, null, null, hasPicture, picturePlacement, pictureUrl, sqmPerBox, roundToFullBox, false, null, null, null, null, null, null, directNetPriceSqm, null); + return new DealQuotationItemDto(id, seq, locationLabel, null, productCode, brand, model, color, texture, sizeText, thicknessMm, sqmPerPiece, quantityMode, areaSqm, piecesInput, wastageMode, wastageValue, piecesPerBox, null, null, originCountry, leadTimeMinDays, leadTimeMaxDays, itemNotes, piecesPerSqm, piecesBeforeWastage, piecesAfterWastage, piecesFinal, boxes, netUnitPrice, lineAmount, descriptionLine, sizeLine, null, lineType, quantity, unit, null, null, adjustmentDeadline, null, null, hasPicture, picturePlacement, pictureUrl, sqmPerBox, roundToFullBox, false, null, null, null, null, null, null, directNetPriceSqm, null, stockSource); } /** This item carrying {@code value} as its own {@link #priceMode} — the same appended-field @@ -854,7 +859,49 @@ public DealQuotationItemDto withPriceMode(String value) { unit, specialPriceSqm, adjustmentPct, adjustmentDeadline, specialPriceLine, adjustmentAmount, hasPicture, picturePlacement, pictureUrl, sqmPerBox, roundToFullBox, priceChangedFromCeo, ceoListUnitPrice, ceoDiscountPct, ceoSpecialPriceSqm, ceoDirectNetPrice, - ceoNetUnitPrice, listPriceSqm, directNetPriceSqm, value); + ceoNetUnitPrice, listPriceSqm, directNetPriceSqm, value, stockSource); + } + + /** The pre-stockSource canonical shape (58 components) -- stockSource null (import). */ + public DealQuotationItemDto( + long id, int seq, String locationLabel, Long catalogPriceId, String productCode, + String brand, String model, String color, String texture, String sizeText, + BigDecimal thicknessMm, BigDecimal sqmPerPiece, String quantityMode, BigDecimal areaSqm, + Integer piecesInput, String wastageMode, BigDecimal wastageValue, Integer piecesPerBox, + BigDecimal unitPrice, BigDecimal discountPct, String originCountry, + Integer leadTimeMinDays, Integer leadTimeMaxDays, String itemNotes, + BigDecimal piecesPerSqm, int piecesBeforeWastage, int piecesAfterWastage, int piecesFinal, + Integer boxes, BigDecimal netUnitPrice, BigDecimal lineAmount, + String descriptionLine, String sizeLine, String calculationLine, + String lineType, BigDecimal quantity, String unit, BigDecimal specialPriceSqm, + BigDecimal adjustmentPct, LocalDate adjustmentDeadline, String specialPriceLine, + BigDecimal adjustmentAmount, boolean hasPicture, String picturePlacement, String pictureUrl, + BigDecimal sqmPerBox, boolean roundToFullBox, boolean priceChangedFromCeo, + BigDecimal ceoListUnitPrice, BigDecimal ceoDiscountPct, BigDecimal ceoSpecialPriceSqm, + BigDecimal ceoDirectNetPrice, BigDecimal ceoNetUnitPrice, BigDecimal listPriceSqm, + BigDecimal directNetPriceSqm, String priceMode) { + this(id, seq, locationLabel, catalogPriceId, productCode, brand, model, color, texture, sizeText, + thicknessMm, sqmPerPiece, quantityMode, areaSqm, piecesInput, wastageMode, wastageValue, + piecesPerBox, unitPrice, discountPct, originCountry, leadTimeMinDays, leadTimeMaxDays, itemNotes, + piecesPerSqm, piecesBeforeWastage, piecesAfterWastage, piecesFinal, boxes, netUnitPrice, + lineAmount, descriptionLine, sizeLine, calculationLine, lineType, quantity, unit, + specialPriceSqm, adjustmentPct, adjustmentDeadline, specialPriceLine, adjustmentAmount, + hasPicture, picturePlacement, pictureUrl, sqmPerBox, roundToFullBox, priceChangedFromCeo, + ceoListUnitPrice, ceoDiscountPct, ceoSpecialPriceSqm, ceoDirectNetPrice, ceoNetUnitPrice, + listPriceSqm, directNetPriceSqm, priceMode, null); + } + + /** This item carrying {@code value} as its {@link #stockSource}. */ + public DealQuotationItemDto withStockSource(String value) { + return new DealQuotationItemDto(id, seq, locationLabel, catalogPriceId, productCode, brand, model, + color, texture, sizeText, thicknessMm, sqmPerPiece, quantityMode, areaSqm, piecesInput, + wastageMode, wastageValue, piecesPerBox, unitPrice, discountPct, originCountry, leadTimeMinDays, + leadTimeMaxDays, itemNotes, piecesPerSqm, piecesBeforeWastage, piecesAfterWastage, piecesFinal, + boxes, netUnitPrice, lineAmount, descriptionLine, sizeLine, calculationLine, lineType, quantity, + unit, specialPriceSqm, adjustmentPct, adjustmentDeadline, specialPriceLine, adjustmentAmount, + hasPicture, picturePlacement, pictureUrl, sqmPerBox, roundToFullBox, + priceChangedFromCeo, ceoListUnitPrice, ceoDiscountPct, ceoSpecialPriceSqm, ceoDirectNetPrice, + ceoNetUnitPrice, listPriceSqm, directNetPriceSqm, priceMode, value); } /** The pre-GLA-123 canonical shape (no CEO-original/changed-flag fields) — kept so every @@ -943,7 +990,7 @@ public DealQuotationItemDto withSqmPerBox(BigDecimal value) { unit, specialPriceSqm, adjustmentPct, adjustmentDeadline, specialPriceLine, adjustmentAmount, hasPicture, picturePlacement, pictureUrl, value, roundToFullBox, priceChangedFromCeo, ceoListUnitPrice, ceoDiscountPct, ceoSpecialPriceSqm, ceoDirectNetPrice, - ceoNetUnitPrice, listPriceSqm, directNetPriceSqm, priceMode); + ceoNetUnitPrice, listPriceSqm, directNetPriceSqm, priceMode, stockSource); } /** This item carrying {@code value} as its {@link #roundToFullBox} — same device as @@ -958,7 +1005,7 @@ public DealQuotationItemDto withRoundToFullBox(boolean value) { unit, specialPriceSqm, adjustmentPct, adjustmentDeadline, specialPriceLine, adjustmentAmount, hasPicture, picturePlacement, pictureUrl, sqmPerBox, value, priceChangedFromCeo, ceoListUnitPrice, ceoDiscountPct, ceoSpecialPriceSqm, ceoDirectNetPrice, - ceoNetUnitPrice, listPriceSqm, directNetPriceSqm, priceMode); + ceoNetUnitPrice, listPriceSqm, directNetPriceSqm, priceMode, stockSource); } /** This item carrying the GLA-123 CEO-comparison fields — same device as @@ -974,7 +1021,7 @@ public DealQuotationItemDto withCeoComparison(boolean changed, BigDecimal listUn unit, specialPriceSqm, adjustmentPct, adjustmentDeadline, specialPriceLine, adjustmentAmount, hasPicture, picturePlacement, pictureUrl, sqmPerBox, roundToFullBox, changed, listUnitPrice, discountPct2, specialPriceSqm2, directNetPrice, netUnitPrice2, - listPriceSqm, directNetPriceSqm, priceMode); + listPriceSqm, directNetPriceSqm, priceMode, stockSource); } /** This item carrying the per-ตร.ม. prices as the rep typed them (V192.2) — the same @@ -990,7 +1037,7 @@ public DealQuotationItemDto withPerSqmPrices(BigDecimal list, BigDecimal directN unit, specialPriceSqm, adjustmentPct, adjustmentDeadline, specialPriceLine, adjustmentAmount, hasPicture, picturePlacement, pictureUrl, sqmPerBox, roundToFullBox, priceChangedFromCeo, ceoListUnitPrice, ceoDiscountPct, ceoSpecialPriceSqm, ceoDirectNetPrice, - ceoNetUnitPrice, list, directNet, priceMode); + ceoNetUnitPrice, list, directNet, priceMode, stockSource); } /** This item with its picture fields set from the stored link (repository read path). */ @@ -1006,7 +1053,7 @@ public DealQuotationItemDto withPicture(long quotationId, String placement) { has ? "/api/deal-quotations/" + quotationId + "/items/" + id + "/picture" : null, sqmPerBox, roundToFullBox, priceChangedFromCeo, ceoListUnitPrice, ceoDiscountPct, ceoSpecialPriceSqm, ceoDirectNetPrice, - ceoNetUnitPrice, listPriceSqm, directNetPriceSqm, priceMode); + ceoNetUnitPrice, listPriceSqm, directNetPriceSqm, priceMode, stockSource); } } diff --git a/backend/src/main/java/th/co/glr/hr/dealquotation/DealQuotationRepository.java b/backend/src/main/java/th/co/glr/hr/dealquotation/DealQuotationRepository.java index d7d95078c..6e6393f9a 100644 --- a/backend/src/main/java/th/co/glr/hr/dealquotation/DealQuotationRepository.java +++ b/backend/src/main/java/th/co/glr/hr/dealquotation/DealQuotationRepository.java @@ -1839,7 +1839,7 @@ private List findItems(long quotationId) { final_unit_price, amount, raw_unit, description, line_type, qi.special_price_sqm, adjustment_pct, adjustment_deadline, picture_placement, sqm_per_box, round_to_full_box, - qi.list_price_sqm, qi.direct_net_price_sqm, + qi.list_price_sqm, qi.direct_net_price_sqm, qi.stock_source, -- V192.5: the ROW's own mode. Aliased because "price_mode" below is already the -- DOCUMENT's (the subselect), and #mapItemColumns needs both to tell them apart. qi.price_mode AS item_price_mode, @@ -1890,7 +1890,7 @@ private Map> findItemsForQuotations(List final_unit_price, amount, raw_unit, description, line_type, qi.special_price_sqm, adjustment_pct, adjustment_deadline, picture_placement, sqm_per_box, round_to_full_box, - qi.list_price_sqm, qi.direct_net_price_sqm, + qi.list_price_sqm, qi.direct_net_price_sqm, qi.stock_source, -- V192.5: the ROW's own mode. Aliased because "price_mode" below is already the -- DOCUMENT's (the subselect), and #mapItemColumns needs both to tell them apart. qi.price_mode AS item_price_mode, @@ -2266,7 +2266,7 @@ private boolean ceoPriceModeChanged(ResultSet rs) throws SQLException { * the bytes are served separately by {@link #findItemPicture}. An item without a picture * keeps the legacy constructor's {@code hasPicture=false} shape exactly. */ private DealQuotationItemDto mapItem(ResultSet rs, Map basisByPriceId) throws SQLException { - DealQuotationItemDto item = mapItemColumns(rs, basisByPriceId); + DealQuotationItemDto item = mapItemColumns(rs, basisByPriceId).withStockSource(rs.getString("stock_source")); String placement = rs.getString("picture_placement"); return placement == null ? item : item.withPicture(rs.getLong("quotation_id"), placement); } 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 9ad5d723c..94fbf6f63 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 @@ -4068,7 +4068,8 @@ private void requireEveryTileItemHasALeadTime(List items) List missingSeqs = new ArrayList<>(); for (DealQuotationItemDto item : items) { boolean tile = item.lineType() == null || WastageCalculator.LINE_TYPE_TILE.equals(item.lineType()); - if (tile && (item.leadTimeMinDays() == null || item.leadTimeMaxDays() == null)) { + // In-Thailand stock lines skip import (owner ruling 2026-10-28); IN_TRANSIT still needs a lead time (2026-10-29). + if (tile && !"IN_THAILAND".equals(item.stockSource()) && (item.leadTimeMinDays() == null || item.leadTimeMaxDays() == null)) { missingSeqs.add(String.valueOf(item.seq())); } } diff --git a/backend/src/test/java/th/co/glr/hr/dealquotation/DealQuotationIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/dealquotation/DealQuotationIntegrationTest.java index 0d6d1d2a4..750bc3008 100644 --- a/backend/src/test/java/th/co/glr/hr/dealquotation/DealQuotationIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/dealquotation/DealQuotationIntegrationTest.java @@ -2031,6 +2031,42 @@ void submit_reChecksStoredItems_missingLeadTimeIsBadRequest() { .hasMessageContaining("กรุณาระบุระยะเวลานำเข้า"); } + /** VW-01 (owner ruling 2026-10-28: stock lines skip import). A TILE row that came from a + * stock-flagged pricing-request line (quotation_item.stock_source set, V194) has no import + * lead time, so the submit gate must not demand one -- while the import row in the same + * document (stock_source NULL, still no lead time) is still refused, and only IT is named. */ + @Test + void submit_inThailandStockLineNeedsNoLeadTime_butInTransitAndImportStillDo() { + DealQuotationDto created = quotationService.create(ticketId, + upsertRequest(List.of(sampleItem("100.00", 10), sampleItem("100.00", 10))), salesActor); + jdbc.update(""" + UPDATE sales.quotation_item SET lead_time_min_days = NULL, lead_time_max_days = NULL + WHERE quotation_id = :id + """, java.util.Map.of("id", created.id())); + jdbc.update(""" + UPDATE sales.quotation_item SET stock_source = 'IN_THAILAND' + WHERE quotation_id = :id AND seq = 1 + """, java.util.Map.of("id", created.id())); + // Owner ruling 2026-10-29: only IN_THAILAND skips import; an IN_TRANSIT line still needs a lead time. + jdbc.update(""" + UPDATE sales.quotation_item SET stock_source = 'IN_TRANSIT' + WHERE quotation_id = :id AND seq = 2 + """, java.util.Map.of("id", created.id())); + + assertThatThrownBy(() -> quotationService.submit(created.id(), salesActor)) + .isInstanceOf(ApiException.class) + .hasFieldOrPropertyWithValue("status", HttpStatus.BAD_REQUEST) + .hasMessageContaining("กรุณาระบุระยะเวลานำเข้า (วัน) ของรายการที่ 2") + .satisfies(e -> assertThat(((ApiException) e).getMessage()).doesNotContain("1")); + + jdbc.update(""" + UPDATE sales.quotation_item SET stock_source = 'IN_TRANSIT', lead_time_min_days = 30, lead_time_max_days = 45 + WHERE quotation_id = :id AND seq = 2 + """, java.util.Map.of("id", created.id())); + assertThat(quotationService.submit(created.id(), salesActor).docStatus()) + .isEqualTo(QuotationStatus.PENDING_APPROVAL); + } + // ── Wording-scan fix 3 (2026-09-17): an ENTERED lead-time range must be min>=1, max>=min ──── /** {@code ประมาณ 0-3 วัน} saved on 2 approved quotations, and {@code 90-75}/negatives would too diff --git a/docs/api/response-fields.json b/docs/api/response-fields.json index 43d48120a..dfe0f9442 100644 --- a/docs/api/response-fields.json +++ b/docs/api/response-fields.json @@ -1,7 +1,7 @@ { "$comment" : "GENERATED by ResponseFieldContractTest — do not hand-edit. Regenerate: cd backend && ./mvnw -Dtest=ResponseFieldContractTest -Dresponse.fields.update=true test", "source" : "Schema.field pairs from springdoc /v3/api-docs response schemas, kept where the field name is read in frontend/src (excluding mockApi, hrApi, routes and tests)", - "count" : 1462, + "count" : 1463, "fields" : [ "Action.requiredFields", "Action.targetStage", @@ -368,6 +368,7 @@ "DealQuotationItemDto.specialPriceSqm", "DealQuotationItemDto.sqmPerBox", "DealQuotationItemDto.sqmPerPiece", + "DealQuotationItemDto.stockSource", "DealQuotationItemDto.thicknessMm", "DealQuotationItemDto.unitPrice", "DealQuotationItemDto.wastageMode", diff --git a/docs/api/ui-reachable.json b/docs/api/ui-reachable.json index e7b4708ee..5792a9cb8 100644 --- a/docs/api/ui-reachable.json +++ b/docs/api/ui-reachable.json @@ -177,9 +177,9 @@ "POST /api/deal-quotations/{id}/approve", "POST /api/deal-quotations/{id}/attachments", "POST /api/deal-quotations/{id}/cancel", + "POST /api/deal-quotations/{id}/confirm-order", "POST /api/deal-quotations/{id}/items/{pricingDecisionItemId}/restore", "POST /api/deal-quotations/{id}/outcome", - "POST /api/deal-quotations/{id}/promote-to-deal", "POST /api/deal-quotations/{id}/reject", "POST /api/deal-quotations/{id}/revisions", "POST /api/deal-quotations/{id}/submit", diff --git a/frontend/src/api/routes.js b/frontend/src/api/routes.js index 9da322797..8692554f3 100644 --- a/frontend/src/api/routes.js +++ b/frontend/src/api/routes.js @@ -624,7 +624,7 @@ export const API_ROUTES = { // GLA-136 (owner ruling 2026-09-30) -- "สร้างดีลจากใบเสนอราคา": promote an APPROVED direct // quotation's quotation-only container ticket into the pipeline at ORDER_RECEIVED. Mirrors // DealQuotationController#promoteToDeal. - promoteToDeal: (id) => `/api/deal-quotations/${id}/promote-to-deal`, + promoteToDeal: (id) => `/api/deal-quotations/${id}/confirm-order`, cancel: (id) => `/api/deal-quotations/${id}/cancel`, file: (id, format) => `/api/deal-quotations/${id}/file?format=${format}`, // M4(d) fix (Opus review, 2026-09-20) — "คืนรายการ": re-adds a CEO-linked line a prior save diff --git a/frontend/src/api/serverContract.test.js b/frontend/src/api/serverContract.test.js index 3412ebb38..e8a048ce8 100644 --- a/frontend/src/api/serverContract.test.js +++ b/frontend/src/api/serverContract.test.js @@ -231,7 +231,7 @@ const SERVER_ONLY = { 'PATCH /api/deal-quotations/{}/items/{}/picture': 'GLA-75 quotation item pictures (V170), built BACKEND-FIRST: the per-item picture endpoints landed before the quotation editor UI that calls them (a separate frontend branch). Gated like editing / viewing the quotation and covered by DealQuotationPictureIntegrationTest. LIVE, not dead — delete this entry when hrApi.js calls it.', 'DELETE /api/deal-quotations/{}/items/{}/picture': 'GLA-75 quotation item pictures (V170), built BACKEND-FIRST: the per-item picture endpoints landed before the quotation editor UI that calls them (a separate frontend branch). Gated like editing / viewing the quotation and covered by DealQuotationPictureIntegrationTest. LIVE, not dead — delete this entry when hrApi.js calls it.', 'GET /api/deal-quotations/{}/items/{}/picture': 'GLA-75 quotation item pictures (V170), built BACKEND-FIRST: the per-item picture endpoints landed before the quotation editor UI that calls them (a separate frontend branch). Gated like editing / viewing the quotation and covered by DealQuotationPictureIntegrationTest. LIVE, not dead — delete this entry when hrApi.js calls it.', - 'POST /api/deal-quotations/{}/confirm-order': 'Quotation ↔ deal linking slice 1 (IA §7, 2026-09-30), built BACKEND-FIRST: the real name of GLA-136\'s promote-to-deal, which DealQuotationController maps to the SAME handler as a one-release alias. hrApi.js still calls …/promote-to-deal (identical behaviour) until the slice-1 frontend switches it over. Covered by DealQuotationConfirmOrderIntegrationTest and DealQuotationControllerTest. LIVE, not dead — delete this entry when hrApi.js calls it.', + 'POST /api/deal-quotations/{}/promote-to-deal': 'GLA-136 legacy alias of …/confirm-order (same handler), kept for ONE release so a still-cached frontend bundle keeps working; the frontend now calls …/confirm-order. Pinned by DealQuotationControllerTest. Remove with the controller alias.', // ── Formerly dormant, now GONE: ProcurementController ───────────────────── diff --git a/frontend/src/features/quotations/QuotationEditorPage.jsx b/frontend/src/features/quotations/QuotationEditorPage.jsx index a0f7ea649..3b4614191 100644 --- a/frontend/src/features/quotations/QuotationEditorPage.jsx +++ b/frontend/src/features/quotations/QuotationEditorPage.jsx @@ -3533,7 +3533,7 @@ export function QuotationEditorPage({ user, showToast }) {