From bd5f91e4ad9a7693114eb7df9edd8669e3b58d68 Mon Sep 17 00:00:00 2001
From: waritctd <148909369+waritctd@users.noreply.github.com>
Date: Wed, 11 Nov 2026 01:47:10 +0700
Subject: [PATCH 1/3] fix(pricing): let the CEO repair factory, origin country
and line name on a PCR
- Normalise a typed PCR line factory to the master factory name (case/space-insensitive), else leave it blank for Import's picker.
- CEO can re-point a factory quote (and its lines and deal lines) to a master factory, also before review starts.
- Costing falls back to the sales-entered origin country; the CEO can set it when missing. A line with no freight band is set aside instead of failing the whole costing.
- CEO can rename a line (brand/model), synced to the deal line.
- Fixes persist when another problem still blocks costing; the remaining error is shown inline.
---
.../pricingcosting/LandedCostCalculator.java | 54 ++-
.../PricingDecisionController.java | 38 ++
.../pricingdecision/PricingDecisionDtos.java | 5 +
.../PricingDecisionRequests.java | 9 +
.../PricingDecisionService.java | 164 ++++++++-
.../PricingRequestEventKind.java | 6 +
.../PricingRequestRepository.java | 87 +++++
.../pricingrequest/PricingRequestService.java | 6 +-
...dCostCalculatorFormulaIntegrationTest.java | 341 ++++++++++++++++++
...ingFactoryQuoteCostingIntegrationTest.java | 7 +-
.../PricingRequestServiceTest.java | 50 ++-
docs/api/api-surface.json | 5 +-
docs/api/ui-reachable.json | 5 +-
frontend/src/api/hrApi.js | 7 +
frontend/src/api/mockApi.js | 70 ++++
frontend/src/api/routes.js | 5 +
.../components/common/SearchableCombobox.jsx | 8 +-
.../pricingRequests/CeoPricingWorkspace.jsx | 6 +-
.../PricingRequestDetailPage.jsx | 165 ++++++++-
.../PricingRequestDetailPage.test.jsx | 128 +++++++
20 files changed, 1136 insertions(+), 30 deletions(-)
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 14fd5104d..51e1a041d 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
@@ -498,9 +498,22 @@ private ItemPhysicals resolveItemPhysicals(ResolvedSource source, PricingFormula
}
BigDecimal thicknessMm = resolveThicknessMm(source.requestItem(), catalogKeys);
String originCountryCode = resolveOriginCountryCode(source.requestItem(), catalogKeys);
+ // An origin that came from the item's own Sales value (no catalogue country) may have no freight
+ // band for this thickness/qty. Probe it per line so that line alone is set aside as uncostable
+ // (soft, like a missing origin) instead of the freight lookup 422ing the whole request.
+ boolean originFromItem = originCountryCode != null && !hasCatalogCountry(source.requestItem(), catalogKeys);
+ String fallbackOriginProblem = null;
+ if (originFromItem && thicknessMm != null && qtySqm != null) {
+ try {
+ formulaEngine.containerFreight(formulaConfig.freightRates(), originCountryCode,
+ List.of(new PricingFormulaEngine.ContainerLine(thicknessMm, qtySqm)), itemLabel(source.requestItem()));
+ } catch (ApiException e) {
+ fallbackOriginProblem = originCountryCode;
+ }
+ }
String productType = resolveProductType(source.requestItem());
return new ItemPhysicals(source, sqmPerPiece, qtyPieces, qtySqm, goodsCostPerPiece, goodsCostThb,
- insuranceThb, thicknessMm, originCountryCode, productType, fx);
+ insuranceThb, thicknessMm, originCountryCode, productType, fx, fallbackOriginProblem);
}
/**
@@ -543,14 +556,14 @@ private BigDecimal resolveThicknessMm(PricingRequestItemDto requestItem,
}
/**
- * Origin country comes ONLY from the catalog link — {@code price_catalog.factories.country}
+ * Origin country comes from the catalog link — {@code price_catalog.factories.country}
* via {@code price_catalog.product_prices.factory_id} — resolved through the SAME {@code
* catalog_price_id}/{@code product_id} link {@link #resolveThicknessMm} uses (see class
* Javadoc's V151 section for why: this is the canonical ISO 3166-1 alpha-2 source {@code
* sales.pricing_freight_rate.origin_country_code} is normalised against; {@code
* sales.factory_config.country}, a different table's free-text field, is never read for
- * pricing). An item with no resolvable catalog link fails costing LOUDLY here, naming the
- * item, for the same reason a missing thickness does.
+ * pricing). An item with no catalog link falls back to its own Sales-entered origin country (see
+ * {@link #ISO_BY_SALES_ORIGIN}); with neither it is uncostable, naming the item.
*
*
Reads the SAME prefetched {@code catalogKeys} map {@link
* #resolveThicknessMm} does. Resolution uses the base {@code product_prices}/
@@ -561,13 +574,28 @@ private BigDecimal resolveThicknessMm(PricingRequestItemDto requestItem,
private String resolveOriginCountryCode(PricingRequestItemDto requestItem,
Map catalogKeys) {
Long priceId = catalogPriceId(requestItem);
- if (priceId == null) {
- return null;
- }
- CatalogRepository.CatalogPricingKey key = catalogKeys.get(priceId);
- return key == null ? null : key.originCountryCode();
+ CatalogRepository.CatalogPricingKey key = priceId == null ? null : catalogKeys.get(priceId);
+ String fromCatalog = key == null ? null : key.originCountryCode();
+ // Catalog wins; fall back to the Sales-entered ประเทศต้นทาง on the item when the line has no
+ // catalogue link (a free-text line), mirroring resolveThicknessMm. "อื่นๆ"/unknown -> no code.
+ return fromCatalog != null ? fromCatalog
+ : requestItem.originCountry() == null ? null : ISO_BY_SALES_ORIGIN.get(requestItem.originCountry().trim());
}
+ private boolean hasCatalogCountry(PricingRequestItemDto requestItem,
+ Map catalogKeys) {
+ Long priceId = catalogPriceId(requestItem);
+ CatalogRepository.CatalogPricingKey key = priceId == null ? null : catalogKeys.get(priceId);
+ return key != null && key.originCountryCode() != null;
+ }
+
+ /**
+ * The sales form's ประเทศต้นทาง values that name a freight-lookup country, as ISO 3166-1 alpha-2.
+ * Mirrors the frontend's ORIGIN_COUNTRY_OPTIONS (not shared: no drift guard). ไทย-สต็อก and
+ * อื่นๆ are deliberately absent: neither has a freight band, so they stay "no origin" (uncostable).
+ */
+ public static final Map ISO_BY_SALES_ORIGIN = Map.of("อิตาลี", "IT", "สเปน", "ES", "จีน", "CN");
+
/**
* 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
@@ -581,10 +609,14 @@ private String uncostableReason(ItemPhysicals item) {
boolean noSqm = item.sqmPerPiece() == null;
boolean noThickness = item.thicknessMm() == null;
boolean noCountry = item.originCountryCode() == null;
+ String label = itemLabel(item.source().requestItem());
+ if (!noSqm && !noThickness && !noCountry && item.fallbackOriginProblem() != null) {
+ return label + " ใช้ประเทศต้นทาง " + item.fallbackOriginProblem() + " ที่ฝ่ายขายกรอก แต่ไม่มีอัตราค่าขนส่งที่ครอบคลุมความหนา/จำนวนนี้ "
+ + "— คำนวณค่าขนส่งอัตโนมัติไม่ได้ กรุณาระบุต้นทุนเอง หรือเชื่อมรายการนี้กับสินค้าใน Price Catalog ที่มีข้อมูลครบ";
+ }
if (!noSqm && !noThickness && !noCountry) {
return null;
}
- String label = itemLabel(item.source().requestItem());
if (noSqm) {
StringBuilder missing = new StringBuilder("ตร.ม./แผ่น (sqm per piece)");
if (noThickness) {
@@ -923,5 +955,5 @@ public record CalculationResult(List items, BigDecimal
private record ItemPhysicals(ResolvedSource source, BigDecimal sqmPerPiece, BigDecimal qtyPieces,
BigDecimal qtySqm, BigDecimal goodsCostPerPiece, BigDecimal goodsCostThb,
BigDecimal insuranceThb, BigDecimal thicknessMm, String originCountryCode,
- String productType, FxSnapshot fx) {}
+ String productType, FxSnapshot fx, String fallbackOriginProblem) {}
}
diff --git a/backend/src/main/java/th/co/glr/hr/pricingdecision/PricingDecisionController.java b/backend/src/main/java/th/co/glr/hr/pricingdecision/PricingDecisionController.java
index 6a40d9d16..83c5587b4 100644
--- a/backend/src/main/java/th/co/glr/hr/pricingdecision/PricingDecisionController.java
+++ b/backend/src/main/java/th/co/glr/hr/pricingdecision/PricingDecisionController.java
@@ -12,11 +12,15 @@
import org.springframework.web.bind.annotation.RestController;
import th.co.glr.hr.auth.SessionContext;
import th.co.glr.hr.auth.UserPrincipal;
+import th.co.glr.hr.pricingdecision.PricingDecisionDtos.CeoFixResult;
import th.co.glr.hr.pricingdecision.PricingDecisionDtos.PricingDecisionDto;
import th.co.glr.hr.pricingdecision.PricingDecisionDtos.PricingDecisionSalesViewDto;
import th.co.glr.hr.pricingdecision.PricingDecisionRequests.ApprovePricingDecisionRequest;
import th.co.glr.hr.pricingdecision.PricingDecisionRequests.CostOverrideRequest;
+import th.co.glr.hr.pricingdecision.PricingDecisionRequests.OriginCountryRequest;
import th.co.glr.hr.pricingdecision.PricingDecisionRequests.ProductTypeOverrideRequest;
+import th.co.glr.hr.pricingdecision.PricingDecisionRequests.RenameItemRequest;
+import th.co.glr.hr.pricingdecision.PricingDecisionRequests.RepointFactoryRequest;
import th.co.glr.hr.pricingdecision.PricingDecisionRequests.ReturnPricingDecisionRequest;
import th.co.glr.hr.pricingdecision.PricingDecisionRequests.StartPricingDecisionRequest;
import th.co.glr.hr.pricingdecision.PricingDecisionRequests.UpdatePricingDecisionRequest;
@@ -98,6 +102,40 @@ Map overrideItemProductType(
return Map.of("decision", decisions.overrideItemProductType(decisionId, itemId, request, user));
}
+ // CEO price-review fixes, keyed by the pricing request (the CEO edits the request's data, not the decision).
+ @PutMapping("/pricing-requests/{id}/factory-quotes/{factoryQuoteId}/factory")
+ CeoFixResult repointFactoryQuote(
+ @PathVariable long id,
+ @PathVariable long factoryQuoteId,
+ @RequestBody RepointFactoryRequest request,
+ HttpSession session
+ ) {
+ UserPrincipal user = sessions.requireUser(session);
+ return decisions.repointFactoryQuote(id, factoryQuoteId, request, user);
+ }
+
+ @PutMapping("/pricing-requests/{id}/items/{itemId}/origin-country")
+ CeoFixResult setItemOriginCountry(
+ @PathVariable long id,
+ @PathVariable long itemId,
+ @RequestBody OriginCountryRequest request,
+ HttpSession session
+ ) {
+ UserPrincipal user = sessions.requireUser(session);
+ return decisions.setItemOriginCountry(id, itemId, request, user);
+ }
+
+ @PutMapping("/pricing-requests/{id}/items/{itemId}/name")
+ CeoFixResult renameItem(
+ @PathVariable long id,
+ @PathVariable long itemId,
+ @RequestBody RenameItemRequest request,
+ HttpSession session
+ ) {
+ UserPrincipal user = sessions.requireUser(session);
+ return decisions.renameItem(id, itemId, request, user);
+ }
+
@PostMapping("/pricing-decisions/{decisionId}/approve")
Map approve(
@PathVariable long decisionId,
diff --git a/backend/src/main/java/th/co/glr/hr/pricingdecision/PricingDecisionDtos.java b/backend/src/main/java/th/co/glr/hr/pricingdecision/PricingDecisionDtos.java
index 8759e6f03..1b9c05dc4 100644
--- a/backend/src/main/java/th/co/glr/hr/pricingdecision/PricingDecisionDtos.java
+++ b/backend/src/main/java/th/co/glr/hr/pricingdecision/PricingDecisionDtos.java
@@ -10,6 +10,11 @@ private PricingDecisionDtos() {}
/** Full, cost-and-margin-bearing view. Restricted to {@code import}/{@code ceo} — see
* {@link PricingDecisionService}'s {@code RAW_DECISION_ROLES}. Never returned to sales. */
+ /** CEO price-review fix result: the saved fix always stands; {@code costingError} is the recalculation's
+ * 422 message when cost could not be recomputed (the CEO fixes the next problem and goes again);
+ * {@code decision} is null when no decision exists yet (request still READY_FOR_CEO_REVIEW). */
+ public record CeoFixResult(PricingDecisionDto decision, String costingError) {}
+
public record PricingDecisionDto(
long id,
String decisionCode,
diff --git a/backend/src/main/java/th/co/glr/hr/pricingdecision/PricingDecisionRequests.java b/backend/src/main/java/th/co/glr/hr/pricingdecision/PricingDecisionRequests.java
index 1aa8c5105..64c69f2a0 100644
--- a/backend/src/main/java/th/co/glr/hr/pricingdecision/PricingDecisionRequests.java
+++ b/backend/src/main/java/th/co/glr/hr/pricingdecision/PricingDecisionRequests.java
@@ -180,4 +180,13 @@ public record CostOverrideRequest(BigDecimal manualLandedCostPerUnitThb, String
* formula-derived and auditable from the product_type alone, recorded on the row itself
* ({@code pricing_costing_item.product_type}), not just in the event trail. */
public record ProductTypeOverrideRequest(String productType) {}
+
+ /** CEO fix: re-point a factory quote to a master {@code price_catalog.factories} row. */
+ public record RepointFactoryRequest(Long factoryId) {}
+
+ /** CEO fix: set a line's ประเทศต้นทาง (the sales form's vocabulary: อิตาลี/สเปน/จีน/ไทย-สต็อก). */
+ public record OriginCountryRequest(String originCountry) {}
+
+ /** CEO fix: rename a line (ยี่ห้อ + รุ่น); at least one must be non-blank. */
+ public record RenameItemRequest(String brand, String model) {}
}
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 4f4a6b112..aac9d0de7 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
@@ -25,6 +25,7 @@
import th.co.glr.hr.pricingcosting.PricingCostingDtos.PricingCostingItemDto;
import th.co.glr.hr.pricingcosting.PricingCostingRepository;
import th.co.glr.hr.pricingcosting.PricingFormulaEngine;
+import th.co.glr.hr.pricingdecision.PricingDecisionDtos.CeoFixResult;
import th.co.glr.hr.pricingdecision.PricingDecisionDtos.PricingDecisionDto;
import th.co.glr.hr.pricingdecision.PricingDecisionDtos.PricingDecisionItemDto;
import th.co.glr.hr.pricingdecision.PricingDecisionDtos.PricingDecisionSalesViewDto;
@@ -35,11 +36,16 @@
import th.co.glr.hr.pricingdecision.PricingDecisionRepository.WriteItem;
import th.co.glr.hr.pricingdecision.PricingDecisionRequests.ApprovePricingDecisionRequest;
import th.co.glr.hr.pricingdecision.PricingDecisionRequests.CostOverrideRequest;
+import th.co.glr.hr.pricingdecision.PricingDecisionRequests.OriginCountryRequest;
import th.co.glr.hr.pricingdecision.PricingDecisionRequests.ProductTypeOverrideRequest;
+import th.co.glr.hr.pricingdecision.PricingDecisionRequests.RenameItemRequest;
+import th.co.glr.hr.pricingdecision.PricingDecisionRequests.RepointFactoryRequest;
import th.co.glr.hr.pricingdecision.PricingDecisionRequests.ReturnPricingDecisionRequest;
import th.co.glr.hr.pricingdecision.PricingDecisionRequests.StartPricingDecisionRequest;
import th.co.glr.hr.pricingdecision.PricingDecisionRequests.UpdatePricingDecisionItemRequest;
import th.co.glr.hr.pricingdecision.PricingDecisionRequests.UpdatePricingDecisionRequest;
+import th.co.glr.hr.factoryquote.FactoryQuoteDtos.FactoryQuoteDto;
+import th.co.glr.hr.factoryquote.FactoryQuoteStatus;
import th.co.glr.hr.pricingrequest.PricingRequestDtos.PricingRequestItemDto;
import th.co.glr.hr.pricingrequest.PricingRequestDtos.PricingRequestSummaryDto;
import th.co.glr.hr.pricingrequest.PricingRequestEventKind;
@@ -376,6 +382,157 @@ public PricingDecisionDto recalculateCost(long decisionId, UserPrincipal actor)
return recomputed;
}
+ /**
+ * The guard every CEO price-review fix shares with {@link #recalculateCost}: CEO only, and only
+ * while this request's decision is an open DRAFT on a CEO_REVIEWING request with an active deal
+ * ({@link #requireOpenDecisionForMutation}). No open decision is the same 409 as a wrong status.
+ */
+ private PricingDecisionDto requireOpenDraftForRequest(long pricingRequestId, UserPrincipal actor) {
+ return requireFixContext(pricingRequestId, actor, false);
+ }
+
+ /**
+ * Shared guard of the three fixes. Locks the request like the other mutating paths. With an open
+ * DRAFT decision it is exactly {@link #requireOpenDecisionForMutation}. When {@code allowNoDecision}
+ * (re-point / origin country) and the request is READY_FOR_CEO_REVIEW with no decision yet - the state a
+ * failed {@link #startReview} leaves behind - it returns null: save only, nothing to recompute.
+ */
+ private PricingDecisionDto requireFixContext(long pricingRequestId, UserPrincipal actor, boolean allowNoDecision) {
+ requireRole(actor, CEO_ROLES);
+ PricingRequestSummaryDto summary = requirePricingRequest(pricingRequestId);
+ decisions.lockPricingRequest(pricingRequestId);
+ Optional draft = decisions.findOpenDraft(pricingRequestId);
+ if (draft.isPresent()) {
+ return requireOpenDecisionForMutation(draft.get().id());
+ }
+ PricingRequestSummaryDto fresh = requirePricingRequest(pricingRequestId);
+ if (allowNoDecision && PricingRequestStatus.READY_FOR_CEO_REVIEW.equals(fresh.status())) {
+ requireActiveDeal(summary.ticketId());
+ return null;
+ }
+ throw new ApiException(HttpStatus.CONFLICT, "คำขอราคานี้ไม่ได้อยู่ระหว่างการพิจารณาของ CEO");
+ }
+
+ /**
+ * Recalculates after a saved fix WITHOUT letting the calculator's 422 undo the save: the fix stands and
+ * the remaining problem (e.g. a second broken quote) comes back as {@code costingError}. The calculator
+ * runs before any costing write, so catching its 422 leaves nothing half-written.
+ */
+ private CeoFixResult recomputeKeepingFix(PricingDecisionDto decision) {
+ if (decision == null) {
+ return new CeoFixResult(null, null);
+ }
+ // Only the calculator's own 422 is absorbed: it runs before any costing write. Everything after the
+ // first write (replace items, freeze costs) propagates and rolls the transaction back.
+ LandedCostCalculator.CalculationResult calc;
+ try {
+ calc = landedCost.calculate(requirePricingRequest(decision.pricingRequestId()));
+ } catch (ApiException e) {
+ if (!HttpStatus.UNPROCESSABLE_CONTENT.equals(e.getStatus())) {
+ throw e;
+ }
+ return new CeoFixResult(requireDecision(decision.id()), e.getMessage());
+ }
+ return new CeoFixResult(recomputeCostingInPlace(decision, calc), null);
+ }
+
+ private PricingRequestItemDto requireRequestItem(long pricingRequestId, long itemId) {
+ return pricingRequests.findItems(pricingRequestId).stream().filter(i -> i.id() == itemId).findFirst()
+ .orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "ไม่พบรายการสินค้านี้ในคำขอราคานี้"));
+ }
+
+ /**
+ * CEO fix (PCR-2026-0005): a sales-typed factory name matched no master factory, so costing
+ * 422s. Re-points the quote and its lines to a real {@code price_catalog.factories} row, then
+ * recalculates through the same path as {@link #recalculateCost} (overrides preserved).
+ */
+ @Transactional
+ public CeoFixResult repointFactoryQuote(long pricingRequestId, long factoryQuoteId, RepointFactoryRequest request, UserPrincipal actor) {
+ PricingDecisionDto decision = requireFixContext(pricingRequestId, actor, true);
+ FactoryQuoteDto quote = pricingRequests.findFactoryQuote(factoryQuoteId)
+ .filter(q -> q.pricingRequestId() == pricingRequestId)
+ .orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "ไม่พบใบเสนอราคาโรงงานนี้ในคำขอราคานี้"));
+ if (request.factoryId() == null) {
+ throw new ApiException(HttpStatus.BAD_REQUEST, "ต้องระบุโรงงาน");
+ }
+ String newName = pricingRequests.findFactoryNameById(request.factoryId())
+ .orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "ไม่พบโรงงานนี้ในระบบ"));
+ if (!quote.current() || FactoryQuoteStatus.CANCELLED.equals(quote.status())) {
+ throw new ApiException(HttpStatus.CONFLICT, "ใบเสนอราคาโรงงานนี้ไม่ใช่ฉบับปัจจุบัน");
+ }
+ String oldName = quote.factoryName();
+ if (newName.equals(oldName)) {
+ throw new ApiException(HttpStatus.CONFLICT, "ใบเสนอราคาโรงงานนี้ใช้โรงงาน \"" + newName + "\" อยู่แล้ว");
+ }
+ if (pricingRequests.hasCurrentFactoryQuote(pricingRequestId, newName)) {
+ throw new ApiException(HttpStatus.CONFLICT, "มีใบเสนอราคาโรงงาน \"" + newName + "\" ในคำขอราคานี้แล้ว");
+ }
+ if (pricingRequests.hasCurrentFactoryQuoteWithFactoryId(pricingRequestId, request.factoryId(), factoryQuoteId)) {
+ throw new ApiException(HttpStatus.CONFLICT, "มีใบเสนอราคาโรงงานอื่นในคำขอราคานี้ที่ใช้โรงงาน \"" + newName + "\" อยู่แล้ว");
+ }
+ int lines = pricingRequests.repointFactoryQuote(factoryQuoteId, request.factoryId(), oldName, newName);
+ CeoFixResult result = recomputeKeepingFix(decision);
+ addEvent(pricingRequestId, actor, PricingRequestEventKind.PRICING_REQUEST_FACTORY_QUOTE_REPOINTED,
+ "CEO เปลี่ยนโรงงานของใบเสนอราคาโรงงาน " + quote.quoteCode() + ": \"" + oldName + "\" → \"" + newName
+ + "\" (" + lines + " รายการ)");
+ return result;
+ }
+
+ /**
+ * CEO fix: sets a line's ประเทศต้นทาง (the sales form's vocabulary) when it has no catalogue
+ * link to read one from — {@link LandedCostCalculator} then falls back to this value — and
+ * recalculates. Stock lines have no cost, so they are refused.
+ */
+ @Transactional
+ public CeoFixResult setItemOriginCountry(long pricingRequestId, long itemId, OriginCountryRequest request, UserPrincipal actor) {
+ PricingDecisionDto decision = requireFixContext(pricingRequestId, actor, true);
+ PricingRequestItemDto item = requireRequestItem(pricingRequestId, itemId);
+ String country = request.originCountry() == null ? "" : request.originCountry().trim();
+ if (!LandedCostCalculator.ISO_BY_SALES_ORIGIN.containsKey(country)) {
+ throw new ApiException(HttpStatus.BAD_REQUEST,
+ "ประเทศต้นทางต้องเป็นหนึ่งใน: อิตาลี, สเปน, จีน");
+ }
+ if (item.stockSource() != null) {
+ throw new ApiException(HttpStatus.BAD_REQUEST, "รายการจากสต็อกไม่มีต้นทุนนำเข้า จึงตั้งประเทศต้นทางไม่ได้");
+ }
+ pricingRequests.updateItemOriginCountry(pricingRequestId, itemId, country);
+ CeoFixResult result = recomputeKeepingFix(decision);
+ addEvent(pricingRequestId, actor, PricingRequestEventKind.PRICING_REQUEST_ITEM_ORIGIN_COUNTRY_SET,
+ "CEO ตั้งประเทศต้นทางรายการที่ " + itemId + ": "
+ + (blankToNullTrim(item.originCountry()) != null ? item.originCountry() : "ไม่ระบุ") + " → " + country);
+ return result;
+ }
+
+ /** CEO fix: renames a line (ยี่ห้อ + รุ่น, the pair the UI shows and the quotation copies). No cost change. */
+ @Transactional
+ public CeoFixResult renameItem(long pricingRequestId, long itemId, RenameItemRequest request, UserPrincipal actor) {
+ PricingDecisionDto decision = requireOpenDraftForRequest(pricingRequestId, actor);
+ PricingRequestItemDto item = requireRequestItem(pricingRequestId, itemId);
+ String brand = blankToNullTrim(request.brand());
+ String model = blankToNullTrim(request.model());
+ if (brand == null && model == null) {
+ throw new ApiException(HttpStatus.BAD_REQUEST, "ต้องระบุยี่ห้อหรือรุ่นอย่างน้อยหนึ่งอย่าง");
+ }
+ // Widths of the deal line (ticket_item brand 255, model 80), which the rename also writes.
+ if ((brand != null && brand.length() > 255) || (model != null && model.length() > 80)) {
+ throw new ApiException(HttpStatus.BAD_REQUEST, "ยี่ห้อต้องยาวไม่เกิน 255 ตัวอักษร และรุ่นไม่เกิน 80 ตัวอักษร");
+ }
+ pricingRequests.renameItem(pricingRequestId, itemId, brand, model);
+ addEvent(pricingRequestId, actor, PricingRequestEventKind.PRICING_REQUEST_ITEM_RENAMED,
+ "CEO แก้ชื่อรายการที่ " + itemId + ": \"" + joinName(item.brand(), item.model()) + "\" → \""
+ + joinName(brand, model) + "\"");
+ return new CeoFixResult(requireDecision(decision.id()), null);
+ }
+
+ private static String blankToNullTrim(String value) {
+ return value == null || value.isBlank() ? null : value.trim();
+ }
+
+ private static String joinName(String brand, String model) {
+ return java.util.stream.Stream.of(brand, model).filter(v -> v != null && !v.isBlank())
+ .map(String::trim).collect(java.util.stream.Collectors.joining(" "));
+ }
+
/**
* V141 ("CEO owns costing"): the only genuinely new behaviour — a per-line manual cost
* override sitting BESIDE the computed figure, which is never destroyed. {@code itemId} is
@@ -1111,8 +1268,11 @@ public PricingDecisionDto overrideItemProductType(long decisionId, long itemId,
* still-overridden) effective cost.
*/
private PricingDecisionDto recomputeCostingInPlace(PricingDecisionDto decision) {
- PricingRequestSummaryDto summary = requirePricingRequest(decision.pricingRequestId());
- LandedCostCalculator.CalculationResult calc = landedCost.calculate(summary);
+ return recomputeCostingInPlace(decision,
+ landedCost.calculate(requirePricingRequest(decision.pricingRequestId())));
+ }
+
+ private PricingDecisionDto recomputeCostingInPlace(PricingDecisionDto decision, LandedCostCalculator.CalculationResult calc) {
costings.replaceItemsPreservingOverrides(decision.pricingCostingId(), calc.items());
PricingCostingDto refreshed = requireCosting(decision.pricingCostingId());
Map costingItemsByRequestItem = refreshed.items().stream()
diff --git a/backend/src/main/java/th/co/glr/hr/pricingrequest/PricingRequestEventKind.java b/backend/src/main/java/th/co/glr/hr/pricingrequest/PricingRequestEventKind.java
index 730adcc85..a1208042c 100644
--- a/backend/src/main/java/th/co/glr/hr/pricingrequest/PricingRequestEventKind.java
+++ b/backend/src/main/java/th/co/glr/hr/pricingrequest/PricingRequestEventKind.java
@@ -18,6 +18,11 @@ public final class PricingRequestEventKind {
// both belong in the audit trail. No DB CHECK constrains event_kind (V141 note).
public static final String PRICING_REQUEST_ITEM_ETA_SET = "PRICING_REQUEST_ITEM_ETA_SET";
public static final String PRICING_REQUEST_ITEM_CONVERTED_TO_IMPORT = "PRICING_REQUEST_ITEM_CONVERTED_TO_IMPORT";
+ // CEO price-review fixes (PCR-2026-0005): re-point a typo'd factory quote to a master factory,
+ // set a line's origin country, rename a line. Each is audited with old -> new in the message.
+ public static final String PRICING_REQUEST_FACTORY_QUOTE_REPOINTED = "PRICING_REQUEST_FACTORY_QUOTE_REPOINTED";
+ public static final String PRICING_REQUEST_ITEM_ORIGIN_COUNTRY_SET = "PRICING_REQUEST_ITEM_ORIGIN_COUNTRY_SET";
+ public static final String PRICING_REQUEST_ITEM_RENAMED = "PRICING_REQUEST_ITEM_RENAMED";
public static final String MORE_INFO_REQUESTED = "MORE_INFO_REQUESTED";
public static final String MORE_INFO_RESPONDED = "MORE_INFO_RESPONDED";
public static final String PRICING_REQUEST_CANCELLED = "PRICING_REQUEST_CANCELLED";
@@ -87,6 +92,7 @@ public final class PricingRequestEventKind {
PRICING_REQUEST_CREATED, PRICING_REQUEST_UPDATED, PRICING_REQUEST_SUBMITTED,
PRICING_REQUEST_PICKED_UP, PRICING_REQUEST_ITEM_FACTORY_SET,
PRICING_REQUEST_ITEM_ETA_SET, PRICING_REQUEST_ITEM_CONVERTED_TO_IMPORT,
+ PRICING_REQUEST_FACTORY_QUOTE_REPOINTED, PRICING_REQUEST_ITEM_ORIGIN_COUNTRY_SET, PRICING_REQUEST_ITEM_RENAMED,
MORE_INFO_REQUESTED, MORE_INFO_RESPONDED,
PRICING_REQUEST_CANCELLED, PRICING_REQUEST_REVISED, FACTORY_EMAIL_READY, FACTORY_EMAIL_SENT, FACTORY_CONTACTED,
LEAD_TIME_CHANGE_REQUESTED, LEAD_TIME_CHANGE_APPROVED, LEAD_TIME_CHANGE_REJECTED, LEAD_TIME_CHANGE_WITHDRAWN,
diff --git a/backend/src/main/java/th/co/glr/hr/pricingrequest/PricingRequestRepository.java b/backend/src/main/java/th/co/glr/hr/pricingrequest/PricingRequestRepository.java
index ab03dbc1b..b87c9c220 100644
--- a/backend/src/main/java/th/co/glr/hr/pricingrequest/PricingRequestRepository.java
+++ b/backend/src/main/java/th/co/glr/hr/pricingrequest/PricingRequestRepository.java
@@ -13,6 +13,7 @@
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.stereotype.Repository;
+import th.co.glr.hr.factoryquote.FactoryQuoteDtos.FactoryQuoteDto;
import th.co.glr.hr.factoryquote.FactoryQuoteRepository;
import th.co.glr.hr.factoryquote.FactoryQuoteStatus;
import th.co.glr.hr.pricingrequest.PricingRequestDtos.PricingRequestAttachmentDto;
@@ -671,6 +672,92 @@ public Optional findFactoryNameById(long factoryId) {
}
}
+ /** Master factory row whose name equals {@code name} ignoring case/padding; empty if none. */
+ public Optional findFactoryNameByNameIgnoreCase(String name) {
+ List rows = jdbc.queryForList(
+ "SELECT name FROM price_catalog.factories WHERE lower(btrim(name)) = lower(btrim(:name)) ORDER BY factory_id LIMIT 1",
+ Map.of("name", name), String.class);
+ return rows.stream().findFirst();
+ }
+
+ public Optional findFactoryQuote(long factoryQuoteId) {
+ return factoryQuotes.find(factoryQuoteId);
+ }
+
+ /** CEO fix: a CURRENT, non-cancelled quote of this request already named {@code factoryName}. */
+ public boolean hasCurrentFactoryQuote(long pricingRequestId, String factoryName) {
+ return factoryQuotes.findCurrentByFactory(pricingRequestId, factoryName).isPresent();
+ }
+
+ /**
+ * CEO fix: re-points one factory quote (and every request line on it) from {@code oldName} to
+ * the master factory {@code factoryId}/{@code newName}. Returns the number of lines moved. The
+ * ticket line (what a later revision copies from) is only rewritten where it still carries the
+ * old typo, so a value Sales has since changed is never clobbered. Import's prices are untouched.
+ */
+ public int repointFactoryQuote(long factoryQuoteId, long factoryId, String oldName, String newName) {
+ MapSqlParameterSource p = new MapSqlParameterSource()
+ .addValue("quoteId", factoryQuoteId).addValue("factoryId", factoryId)
+ .addValue("old", oldName).addValue("new", newName);
+ jdbc.update("""
+ UPDATE sales.factory_quote
+ SET factory_id = :factoryId, factory_name_snapshot = :new, updated_at = now()
+ WHERE factory_quote_id = :quoteId
+ """, p);
+ jdbc.update("""
+ UPDATE sales.ticket_item ti SET factory = :new
+ FROM sales.pricing_request_item pri
+ JOIN sales.factory_quote_item fqi ON fqi.pricing_request_item_id = pri.pricing_request_item_id
+ WHERE fqi.factory_quote_id = :quoteId AND ti.item_id = pri.source_ticket_item_id AND ti.factory = :old
+ """, p);
+ return jdbc.update("""
+ UPDATE sales.pricing_request_item pri
+ SET factory = :new, resolved_factory_id = :factoryId,
+ resolved_factory_name = CASE WHEN pri.resolved_factory_name IS NULL THEN NULL ELSE :new END
+ FROM sales.factory_quote_item fqi
+ WHERE fqi.factory_quote_id = :quoteId AND fqi.pricing_request_item_id = pri.pricing_request_item_id
+ """, p);
+ }
+
+ /** CEO fix: another CURRENT, non-cancelled quote of this request already carries {@code factoryId}
+ * (V62's unique index would otherwise raise a raw 500). */
+ public boolean hasCurrentFactoryQuoteWithFactoryId(long pricingRequestId, long factoryId, long exceptQuoteId) {
+ return Boolean.TRUE.equals(jdbc.queryForObject("""
+ SELECT EXISTS (SELECT 1 FROM sales.factory_quote
+ WHERE pricing_request_id = :pr AND factory_id = :f AND factory_quote_id <> :q
+ AND is_current = TRUE AND status <> 'CANCELLED')
+ """, Map.of("pr", pricingRequestId, "f", factoryId, "q", exceptQuoteId), Boolean.class));
+ }
+
+ /** CEO fix: sets the sales-vocabulary origin country on one line (clears the "อื่นๆ" free text). */
+ public int updateItemOriginCountry(long pricingRequestId, long itemId, String originCountry) {
+ return jdbc.update("""
+ UPDATE sales.pricing_request_item SET origin_country = :o, origin_country_other = NULL
+ WHERE pricing_request_item_id = :id AND pricing_request_id = :prId
+ """, Map.of("o", originCountry, "id", itemId, "prId", pricingRequestId));
+ }
+
+ /** CEO fix: renames one line; the catalogue snapshot names (which the UI prefers) follow when present. */
+ public int renameItem(long pricingRequestId, long itemId, String brand, String model) {
+ // Source ticket line first (compared against the OLD pair): rewritten only while it still equals it.
+ jdbc.update("""
+ UPDATE sales.ticket_item ti SET brand = COALESCE(:brand, ''), model = :model -- ticket_item.brand is NOT NULL: a cleared brand is blank there
+ FROM sales.pricing_request_item pri
+ WHERE pri.pricing_request_item_id = :id AND pri.pricing_request_id = :prId
+ AND ti.item_id = pri.source_ticket_item_id
+ AND ti.brand IS NOT DISTINCT FROM pri.brand AND ti.model IS NOT DISTINCT FROM pri.model
+ """, new MapSqlParameterSource().addValue("brand", brand).addValue("model", model)
+ .addValue("id", itemId).addValue("prId", pricingRequestId));
+ return jdbc.update("""
+ UPDATE sales.pricing_request_item
+ SET brand = :brand, model = :model,
+ catalog_brand = CASE WHEN catalog_brand IS NULL THEN NULL ELSE :brand END,
+ catalog_model = CASE WHEN catalog_model IS NULL THEN NULL ELSE :model END
+ WHERE pricing_request_item_id = :id AND pricing_request_id = :prId
+ """, new MapSqlParameterSource().addValue("brand", brand).addValue("model", model)
+ .addValue("id", itemId).addValue("prId", pricingRequestId));
+ }
+
/**
* Fills in the {@code factory}/{@code resolved_factory_id} on ONE line that has none, so
* Import can route it to a factory email. Returns the number of rows written — 0 means the
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 2410fb60d..524880f0f 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
@@ -1365,9 +1365,13 @@ private PricingRequestItemRequest resolveItem(int rowNumber, PricingRequestItemR
if (stockLine) {
originCountryOther = null;
}
+ // The deal form's one brand+factory field can hold free text ("Padana marmoker"); only a
+ // master factory name may be stored, else costing 422s. Unknown -> blank, Import picks it.
+ String factory = hasText(item.factory())
+ ? requests.findFactoryNameByNameIgnoreCase(item.factory()).orElse(null) : null;
return new PricingRequestItemRequest(
item.sourceTicketItemId(), item.productId(), item.variantId(), item.brand(), item.model(),
- item.productDescription(), item.color(), item.texture(), item.size(), item.factory(),
+ item.productDescription(), item.color(), item.texture(), item.size(), factory,
requestedQty, requestedQtySqm, PIECE_UNIT_LABEL, UnitBasis.PER_PIECE, item.quantityType(),
item.targetDeliveryDate(), item.deliveryLocation(), item.specialRequirement(),
item.productCode(), item.thicknessMm(), item.sqmPerPiece(), quantityMode, item.areaSqm(),
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 13a3bac78..b4cc03590 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
@@ -47,7 +47,11 @@
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.OriginCountryRequest;
import th.co.glr.hr.pricingdecision.PricingDecisionRequests.ProductTypeOverrideRequest;
+import th.co.glr.hr.pricingdecision.PricingDecisionRequests.RenameItemRequest;
+import th.co.glr.hr.pricingdecision.PricingDecisionRequests.RepointFactoryRequest;
+import th.co.glr.hr.pricingdecision.PricingDecisionRequests.ReturnPricingDecisionRequest;
import th.co.glr.hr.pricingdecision.PricingDecisionRequests.StartPricingDecisionRequest;
import th.co.glr.hr.pricingdecision.PricingDecisionService;
import th.co.glr.hr.pricingrequest.PricingRequestRecipient;
@@ -869,6 +873,343 @@ private PricingCostingItemDto costingItemFor(PricingDecisionDto decision) {
return costingRepository.find(decision.pricingCostingId()).orElseThrow().items().get(0);
}
+ // ─────────────────────────────────────────────────────────────────────────────────────
+ // CEO price-review fixes (PCR-2026-0005): re-point a factory quote, set the origin country,
+ // rename a line. All three: CEO only, only while the decision is an open DRAFT (the same guard
+ // as recalculateCost), each writes an event and (A, B) recalculates cost.
+ // ─────────────────────────────────────────────────────────────────────────────────────
+
+ private PricingDecisionDto startedDecision() {
+ long pricingRequestId = readyForReview(new BigDecimal("100"), UnitBasis.PER_PIECE, UnitBasis.PER_PIECE,
+ new BigDecimal("100"), "100.00", new BigDecimal("1"), null, null);
+ return decisionService.startReview(pricingRequestId,
+ new StartPricingDecisionRequest(new BigDecimal("0.20"), "THB", null, UUID.randomUUID().toString()), ceoActor);
+ }
+
+ private long onlyQuoteId(long pricingRequestId) {
+ return jdbc.queryForObject("SELECT factory_quote_id FROM sales.factory_quote WHERE pricing_request_id = :id",
+ Map.of("id", pricingRequestId), Long.class);
+ }
+
+ private long masterFactoryId(String name) {
+ insertCatalogProduct(name, "IT", "FIX-" + UUID.randomUUID(), new BigDecimal("100.00"), "THB", "per_piece");
+ return jdbc.queryForObject("SELECT factory_id FROM price_catalog.factories WHERE name = :n",
+ Map.of("n", name), Long.class);
+ }
+
+ private List eventMessages(long pricingRequestId, String kind) {
+ return jdbc.queryForList("SELECT message FROM sales.pricing_request_event WHERE pricing_request_id = :id AND event_kind = :k",
+ Map.of("id", pricingRequestId, "k", kind), String.class);
+ }
+
+ /** The incident: sales typed a free-text factory, so the quote snapshot matches no master row. */
+ private String breakFactoryName(long pricingRequestId) {
+ String typo = "Padana marmoker " + UUID.randomUUID();
+ jdbc.update("UPDATE sales.factory_quote SET factory_name_snapshot = :n, factory_id = NULL WHERE pricing_request_id = :id",
+ Map.of("n", typo, "id", pricingRequestId));
+ jdbc.update("UPDATE sales.pricing_request_item SET factory = :n, resolved_factory_id = NULL, resolved_factory_name = NULL WHERE pricing_request_id = :id",
+ Map.of("n", typo, "id", pricingRequestId));
+ return typo;
+ }
+
+ @Test
+ void repointFactoryQuote_rewritesQuoteLinesAndTicketItem_thenCostsAgain() {
+ PricingDecisionDto decision = startedDecision();
+ long prId = decision.pricingRequestId();
+ String typo = breakFactoryName(prId);
+ long ticketItemId = jdbc.queryForObject("SELECT item_id FROM sales.ticket_item WHERE ticket_id = :t",
+ Map.of("t", ticketId), Long.class);
+ jdbc.update("UPDATE sales.ticket_item SET factory = :n WHERE item_id = :id", Map.of("n", typo, "id", ticketItemId));
+ jdbc.update("UPDATE sales.pricing_request_item SET source_ticket_item_id = :t WHERE pricing_request_id = :id",
+ Map.of("t", ticketItemId, "id", prId));
+ assertThatThrownBy(() -> decisionService.recalculateCost(decision.id(), ceoActor))
+ .hasMessageContaining("ไม่พบ factory config");
+ String master = "Padana " + UUID.randomUUID();
+ long masterId = masterFactoryId(master);
+
+ PricingDecisionDto after = decisionService.repointFactoryQuote(prId, onlyQuoteId(prId),
+ new RepointFactoryRequest(masterId), ceoActor).decision();
+
+ assertThat(jdbc.queryForMap("SELECT factory_name_snapshot, factory_id FROM sales.factory_quote WHERE pricing_request_id = :id",
+ Map.of("id", prId))).containsEntry("factory_name_snapshot", master).containsEntry("factory_id", masterId);
+ assertThat(jdbc.queryForMap("SELECT factory, resolved_factory_id FROM sales.pricing_request_item WHERE pricing_request_id = :id",
+ Map.of("id", prId))).containsEntry("factory", master).containsEntry("resolved_factory_id", masterId);
+ assertThat(jdbc.queryForObject("SELECT factory FROM sales.ticket_item WHERE item_id = :id",
+ Map.of("id", ticketItemId), String.class)).isEqualTo(master);
+ assertThat(eventMessages(prId, "PRICING_REQUEST_FACTORY_QUOTE_REPOINTED")).singleElement()
+ .satisfies(m -> assertThat(m).contains(typo).contains(master));
+ assertThat(after.items().get(0).frozenLandedCostPerRequestedUnitThb())
+ .as("cost was recalculated through the recalculateCost path").isNotNull();
+ }
+
+ @Test
+ void repointFactoryQuote_leavesTicketItemAloneWhenItsFactoryIsSomethingElse() {
+ PricingDecisionDto decision = startedDecision();
+ long prId = decision.pricingRequestId();
+ breakFactoryName(prId);
+ long ticketItemId = jdbc.queryForObject("SELECT item_id FROM sales.ticket_item WHERE ticket_id = :t",
+ Map.of("t", ticketId), Long.class);
+ jdbc.update("UPDATE sales.ticket_item SET factory = 'Unrelated' WHERE item_id = :id", Map.of("id", ticketItemId));
+ jdbc.update("UPDATE sales.pricing_request_item SET source_ticket_item_id = :t WHERE pricing_request_id = :id",
+ Map.of("t", ticketItemId, "id", prId));
+
+ decisionService.repointFactoryQuote(prId, onlyQuoteId(prId),
+ new RepointFactoryRequest(masterFactoryId("Padana " + UUID.randomUUID())), ceoActor);
+
+ assertThat(jdbc.queryForObject("SELECT factory FROM sales.ticket_item WHERE item_id = :id",
+ Map.of("id", ticketItemId), String.class)).isEqualTo("Unrelated");
+ }
+
+ @Test
+ void repointFactoryQuote_rejectsUnknownFactorySameNameDuplicateAndForeignQuote() {
+ PricingDecisionDto decision = startedDecision();
+ long prId = decision.pricingRequestId();
+ long quoteId = onlyQuoteId(prId);
+ String current = jdbc.queryForObject("SELECT factory_name_snapshot FROM sales.factory_quote WHERE factory_quote_id = :id",
+ Map.of("id", quoteId), String.class);
+ long currentFactoryId = jdbc.queryForObject("SELECT factory_id FROM price_catalog.factories WHERE name = :n",
+ Map.of("n", current), Long.class);
+ String other = "Other " + UUID.randomUUID();
+ long otherId = masterFactoryId(other);
+ jdbc.update("INSERT INTO sales.factory_quote (quote_code, pricing_request_id, factory_name_snapshot, status) VALUES (:c, :id, :n, 'DRAFT')",
+ Map.of("c", "FQ-D" + UUID.randomUUID().toString().substring(0, 8), "id", prId, "n", other));
+ long otherPr = pricingRequests.create(ticketId, pricingRequests.nextRequestCode(),
+ new CreatePricingRequestRequest(PricingRequestRecipient.DESIGNER, null, "Designer Co.", LocalDate.now().plusDays(14),
+ null, "THB", "other", UUID.randomUUID().toString(), List.of()), salesRepId);
+ jdbc.update("INSERT INTO sales.factory_quote (quote_code, pricing_request_id, factory_name_snapshot, status) VALUES (:c, :id, 'X', 'DRAFT')",
+ Map.of("c", "FQ-O" + UUID.randomUUID().toString().substring(0, 8), "id", otherPr));
+ long foreignQuote = onlyQuoteId(otherPr);
+
+ assertStatus(HttpStatus.NOT_FOUND, () -> decisionService.repointFactoryQuote(prId, quoteId, new RepointFactoryRequest(Long.MAX_VALUE), ceoActor));
+ assertStatus(HttpStatus.CONFLICT, () -> decisionService.repointFactoryQuote(prId, quoteId, new RepointFactoryRequest(currentFactoryId), ceoActor));
+ assertStatus(HttpStatus.CONFLICT, () -> decisionService.repointFactoryQuote(prId, quoteId, new RepointFactoryRequest(otherId), ceoActor));
+ assertStatus(HttpStatus.NOT_FOUND, () -> decisionService.repointFactoryQuote(prId, foreignQuote, new RepointFactoryRequest(otherId), ceoActor));
+ }
+
+ private void assertStatus(HttpStatus status, org.assertj.core.api.ThrowableAssert.ThrowingCallable call) {
+ assertThatThrownBy(call).isInstanceOfSatisfying(ApiException.class, e -> assertThat(e.getStatus()).isEqualTo(status));
+ }
+
+ @Test
+ void ceoFixes_nonCeoGets403_andOnlyWhileTheDecisionIsAnOpenDraft() {
+ PricingDecisionDto decision = startedDecision();
+ long prId = decision.pricingRequestId();
+ long itemId = decision.items().get(0).pricingRequestItemId();
+ long quoteId = onlyQuoteId(prId);
+ long target = masterFactoryId("Padana " + UUID.randomUUID());
+ for (UserPrincipal who : List.of(salesActor, importActor)) {
+ assertStatus(HttpStatus.FORBIDDEN, () -> decisionService.repointFactoryQuote(prId, quoteId, new RepointFactoryRequest(target), who));
+ assertStatus(HttpStatus.FORBIDDEN, () -> decisionService.setItemOriginCountry(prId, itemId, new OriginCountryRequest("อิตาลี"), who));
+ assertStatus(HttpStatus.FORBIDDEN, () -> decisionService.renameItem(prId, itemId, new RenameItemRequest("A", "B"), who));
+ }
+ // Returned to import: no open DRAFT decision any more.
+ decisionService.returnToImport(decision.id(), new ReturnPricingDecisionRequest("test"), ceoActor);
+ assertStatus(HttpStatus.CONFLICT, () -> decisionService.repointFactoryQuote(prId, quoteId, new RepointFactoryRequest(target), ceoActor));
+ assertStatus(HttpStatus.CONFLICT, () -> decisionService.setItemOriginCountry(prId, itemId, new OriginCountryRequest("อิตาลี"), ceoActor));
+ assertStatus(HttpStatus.CONFLICT, () -> decisionService.renameItem(prId, itemId, new RenameItemRequest("A", "B"), ceoActor));
+ }
+
+ /** Free-text line: no catalog link, so only the item's own origin_country/thickness can feed freight. */
+ private PricingDecisionDto startedDecisionWithoutCatalogLink(String originCountry) {
+ return startedDecisionWithoutCatalogLink(originCountry, 10);
+ }
+
+ private PricingDecisionDto startedDecisionWithoutCatalogLink(String originCountry, int thicknessMm) {
+ long prId = readyForReview(new BigDecimal("100"), UnitBasis.PER_PIECE, UnitBasis.PER_PIECE,
+ new BigDecimal("100"), "100.00", new BigDecimal("1"), null, null);
+ jdbc.update("UPDATE sales.pricing_request_item SET product_id = NULL, catalog_price_id = NULL, thickness_mm = :t, origin_country = :o WHERE pricing_request_id = :id",
+ Map.of("o", originCountry == null ? "" : originCountry, "id", prId, "t", thicknessMm));
+ if (originCountry == null) {
+ jdbc.update("UPDATE sales.pricing_request_item SET origin_country = NULL WHERE pricing_request_id = :id", Map.of("id", prId));
+ }
+ return decisionService.startReview(prId,
+ new StartPricingDecisionRequest(new BigDecimal("0.20"), "THB", null, UUID.randomUUID().toString()), ceoActor);
+ }
+
+ @Test
+ void originCountry_fallsBackToTheItemsOwnSalesValue_whenNoCatalogLink() {
+ // Same band as the hand-computed test: Italy [8,12)mm, qty 100 -> 500.0000/pc freight.
+ PricingDecisionDto decision = startedDecisionWithoutCatalogLink("อิตาลี");
+ PricingCostingItemDto item = costingItemFor(decision);
+ assertThat(item.uncostableReason()).isNull();
+ assertThat(item.freightCostThb()).isEqualByComparingTo("500.0000");
+ }
+
+ @Test
+ void originCountry_otherSentinelAndCatalogPrecedence() {
+ assertThat(costingItemFor(startedDecisionWithoutCatalogLink("อื่นๆ")).uncostableReason()).contains("ประเทศต้นทาง");
+ // Catalog (Italy, 500) wins over the item's own จีน (300).
+ long prId = readyForReview(new BigDecimal("100"), UnitBasis.PER_PIECE, UnitBasis.PER_PIECE,
+ new BigDecimal("100"), "100.00", new BigDecimal("1"), null, null);
+ jdbc.update("UPDATE sales.pricing_request_item SET origin_country = 'จีน' WHERE pricing_request_id = :id", Map.of("id", prId));
+ PricingDecisionDto decision = decisionService.startReview(prId,
+ new StartPricingDecisionRequest(new BigDecimal("0.20"), "THB", null, UUID.randomUUID().toString()), ceoActor);
+ assertThat(costingItemFor(decision).freightCostThb()).isEqualByComparingTo("500.0000");
+ }
+
+ @Test
+ void setItemOriginCountry_savesAndRecalculates_refusesStockAndBadValues() {
+ PricingDecisionDto decision = startedDecisionWithoutCatalogLink(null);
+ long prId = decision.pricingRequestId();
+ long itemId = decision.items().get(0).pricingRequestItemId();
+ assertThat(costingItemFor(decision).uncostableReason()).contains("ประเทศต้นทาง");
+
+ PricingDecisionDto after = decisionService.setItemOriginCountry(prId, itemId, new OriginCountryRequest("สเปน"), ceoActor).decision();
+
+ assertThat(jdbc.queryForObject("SELECT origin_country FROM sales.pricing_request_item WHERE pricing_request_item_id = :id",
+ Map.of("id", itemId), String.class)).isEqualTo("สเปน");
+ assertThat(costingItemFor(after).uncostableReason()).isNull();
+ assertThat(eventMessages(prId, "PRICING_REQUEST_ITEM_ORIGIN_COUNTRY_SET")).singleElement()
+ .satisfies(m -> assertThat(m).contains("สเปน"));
+ assertStatus(HttpStatus.BAD_REQUEST, () -> decisionService.setItemOriginCountry(prId, itemId, new OriginCountryRequest("อื่นๆ"), ceoActor));
+ assertStatus(HttpStatus.BAD_REQUEST, () -> decisionService.setItemOriginCountry(prId, itemId, new OriginCountryRequest(" "), ceoActor));
+ assertStatus(HttpStatus.BAD_REQUEST, () -> decisionService.setItemOriginCountry(prId, itemId, new OriginCountryRequest("Atlantis"), ceoActor));
+ jdbc.update("UPDATE sales.pricing_request_item SET stock_source = 'IN_THAILAND' WHERE pricing_request_item_id = :id", Map.of("id", itemId));
+ assertStatus(HttpStatus.BAD_REQUEST, () -> decisionService.setItemOriginCountry(prId, itemId, new OriginCountryRequest("จีน"), ceoActor));
+ }
+
+ @Test
+ void renameItem_trimsAndSaves_requiresAName_respectsLength_andItemMustBelong() {
+ PricingDecisionDto decision = startedDecision();
+ long prId = decision.pricingRequestId();
+ long itemId = decision.items().get(0).pricingRequestItemId();
+ jdbc.update("UPDATE sales.pricing_request_item SET catalog_brand = 'OldB', catalog_model = 'OldM' WHERE pricing_request_item_id = :id", Map.of("id", itemId));
+
+ decisionService.renameItem(prId, itemId, new RenameItemRequest(" Padana ", " Marmo "), ceoActor);
+
+ assertThat(jdbc.queryForMap("SELECT brand, model, catalog_brand, catalog_model FROM sales.pricing_request_item WHERE pricing_request_item_id = :id",
+ Map.of("id", itemId))).containsEntry("brand", "Padana").containsEntry("model", "Marmo")
+ .containsEntry("catalog_brand", "Padana").containsEntry("catalog_model", "Marmo");
+ assertThat(eventMessages(prId, "PRICING_REQUEST_ITEM_RENAMED")).singleElement()
+ .satisfies(m -> assertThat(m).contains("Test Model").contains("Padana Marmo"));
+ assertStatus(HttpStatus.BAD_REQUEST, () -> decisionService.renameItem(prId, itemId, new RenameItemRequest(" ", null), ceoActor));
+ assertStatus(HttpStatus.BAD_REQUEST, () -> decisionService.renameItem(prId, itemId, new RenameItemRequest("x".repeat(256), "m"), ceoActor));
+ // ticket_item.model is VARCHAR(80): a longer model would 500 the whole rename, so it is a 400 here.
+ assertStatus(HttpStatus.BAD_REQUEST, () -> decisionService.renameItem(prId, itemId, new RenameItemRequest("b", "m".repeat(81)), ceoActor));
+ decisionService.renameItem(prId, itemId, new RenameItemRequest("b", "m".repeat(80)), ceoActor);
+ assertStatus(HttpStatus.NOT_FOUND, () -> decisionService.renameItem(prId, Long.MAX_VALUE, new RenameItemRequest("a", "b"), ceoActor));
+ }
+
+ /** The REAL incident path: the typed factory is saved, import marks the quote ready, and the CEO's
+ * startReview 422s, so no decision ever exists. The fix must still be reachable, then startReview works. */
+ @Test
+ void repointFactoryQuote_realIncidentPath_startReview422_fixWithNoDecision_thenStartReviewSucceeds() {
+ long prId = readyForReview(new BigDecimal("100"), UnitBasis.PER_PIECE, UnitBasis.PER_PIECE,
+ new BigDecimal("100"), "100.00", new BigDecimal("1"), null, null);
+ breakFactoryName(prId);
+ StartPricingDecisionRequest start = new StartPricingDecisionRequest(new BigDecimal("0.20"), "THB", null, UUID.randomUUID().toString());
+ assertThatThrownBy(() -> decisionService.startReview(prId, start, ceoActor)).hasMessageContaining("ไม่พบ factory config");
+ long target = masterFactoryId("Padana " + UUID.randomUUID());
+
+ var fixed = decisionService.repointFactoryQuote(prId, onlyQuoteId(prId), new RepointFactoryRequest(target), ceoActor);
+
+ assertThat(fixed.decision()).as("no decision exists yet, so nothing is recalculated").isNull();
+ assertThat(fixed.costingError()).isNull();
+ assertThat(decisionService.startReview(prId, start, ceoActor).items().get(0).frozenLandedCostPerRequestedUnitThb()).isNotNull();
+ }
+
+ @Test
+ void setItemOriginCountry_withNoDecisionYet_savesWithoutRecompute_thenStartReviewCostsTheLine() {
+ long prId = readyForReview(new BigDecimal("100"), UnitBasis.PER_PIECE, UnitBasis.PER_PIECE,
+ new BigDecimal("100"), "100.00", new BigDecimal("1"), null, null);
+ jdbc.update("UPDATE sales.pricing_request_item SET product_id = NULL, catalog_price_id = NULL, thickness_mm = 10, origin_country = NULL WHERE pricing_request_id = :id", Map.of("id", prId));
+ long itemId = jdbc.queryForObject("SELECT pricing_request_item_id FROM sales.pricing_request_item WHERE pricing_request_id = :id", Map.of("id", prId), Long.class);
+
+ var fixed = decisionService.setItemOriginCountry(prId, itemId, new OriginCountryRequest("จีน"), ceoActor);
+
+ assertThat(fixed.decision()).isNull();
+ assertStatus(HttpStatus.CONFLICT, () -> decisionService.renameItem(prId, itemId, new RenameItemRequest("A", "B"), ceoActor));
+ assertStatus(HttpStatus.FORBIDDEN, () -> decisionService.setItemOriginCountry(prId, itemId, new OriginCountryRequest("จีน"), salesActor));
+ PricingDecisionDto decision = decisionService.startReview(prId,
+ new StartPricingDecisionRequest(new BigDecimal("0.20"), "THB", null, UUID.randomUUID().toString()), ceoActor);
+ assertThat(costingItemFor(decision).uncostableReason()).isNull();
+ }
+
+ /** Two typo'd quotes: fixing the first must stand even though the recalculation still 422s on the second. */
+ @Test
+ void repointFactoryQuote_fixSurvivesARecalculationFailureOnAnotherBrokenQuote() {
+ long prId = twoItemSameFactoryReadyForReview(new BigDecimal("60"), new BigDecimal("40"));
+ PricingDecisionDto decision = decisionService.startReview(prId,
+ new StartPricingDecisionRequest(new BigDecimal("0.20"), "THB", null, UUID.randomUUID().toString()), ceoActor);
+ long quote1 = onlyQuoteId(prId);
+ long item2 = jdbc.queryForObject("SELECT pricing_request_item_id FROM sales.pricing_request_item WHERE pricing_request_id = :id ORDER BY pricing_request_item_id DESC LIMIT 1", Map.of("id", prId), Long.class);
+ jdbc.update("INSERT INTO sales.factory_quote (quote_code, pricing_request_id, factory_name_snapshot, status, default_currency) SELECT :c, pricing_request_id, 'Second typo', status, default_currency FROM sales.factory_quote WHERE factory_quote_id = :q",
+ Map.of("c", "FQ-S" + UUID.randomUUID().toString().substring(0, 8), "q", quote1));
+ long quote2 = jdbc.queryForObject("SELECT max(factory_quote_id) FROM sales.factory_quote WHERE pricing_request_id = :id", Map.of("id", prId), Long.class);
+ jdbc.update("UPDATE sales.factory_quote_item SET factory_quote_id = :q2 WHERE pricing_request_item_id = :i", Map.of("q2", quote2, "i", item2));
+ jdbc.update("UPDATE sales.pricing_request_item SET factory = 'Second typo', resolved_factory_id = NULL, resolved_factory_name = NULL WHERE pricing_request_item_id = :i", Map.of("i", item2));
+ jdbc.update("UPDATE sales.factory_quote SET factory_name_snapshot = 'First typo', factory_id = NULL WHERE factory_quote_id = :q", Map.of("q", quote1));
+ jdbc.update("UPDATE sales.pricing_request_item SET factory = 'First typo', resolved_factory_id = NULL, resolved_factory_name = NULL WHERE pricing_request_id = :id AND pricing_request_item_id <> :i", Map.of("id", prId, "i", item2));
+ String masterA = "Padana A " + UUID.randomUUID();
+ String masterB = "Padana B " + UUID.randomUUID();
+
+ var first = decisionService.repointFactoryQuote(prId, quote1, new RepointFactoryRequest(masterFactoryId(masterA)), ceoActor);
+
+ assertThat(jdbc.queryForObject("SELECT factory_name_snapshot FROM sales.factory_quote WHERE factory_quote_id = :q", Map.of("q", quote1), String.class))
+ .as("the first fix is persisted despite the recalculation failing").isEqualTo(masterA);
+ assertThat(first.costingError()).contains("ไม่พบ factory config").contains("Second typo");
+ assertThat(eventMessages(prId, "PRICING_REQUEST_FACTORY_QUOTE_REPOINTED")).hasSize(1);
+
+ var second = decisionService.repointFactoryQuote(prId, quote2, new RepointFactoryRequest(masterFactoryId(masterB)), ceoActor);
+
+ assertThat(second.costingError()).isNull();
+ assertThat(second.decision().items()).allSatisfy(i -> assertThat(i.frozenLandedCostPerRequestedUnitThb()).isNotNull());
+ assertThat(decision.id()).isEqualTo(second.decision().id());
+ }
+
+ @Test
+ void repointFactoryQuote_keepsLineResolvedNameConsistent_andRejectsADuplicateFactoryId() {
+ PricingDecisionDto decision = startedDecision();
+ long prId = decision.pricingRequestId();
+ jdbc.update("UPDATE sales.pricing_request_item SET resolved_factory_name = 'Stale Catalog Name' WHERE pricing_request_id = :id", Map.of("id", prId));
+ String master = "Padana " + UUID.randomUUID();
+ long masterId = masterFactoryId(master);
+
+ decisionService.repointFactoryQuote(prId, onlyQuoteId(prId), new RepointFactoryRequest(masterId), ceoActor);
+
+ assertThat(jdbc.queryForObject("SELECT resolved_factory_name FROM sales.pricing_request_item WHERE pricing_request_id = :id",
+ Map.of("id", prId), String.class)).isEqualTo(master);
+ // Another current quote already carries factory_id = target (under a different snapshot name):
+ // the V62 unique index would raise a raw 500, so it must be a 409 first.
+ long quote = onlyQuoteId(prId);
+ long otherId = masterFactoryId("Alias " + UUID.randomUUID());
+ jdbc.update("INSERT INTO sales.factory_quote (quote_code, pricing_request_id, factory_name_snapshot, factory_id, status) VALUES (:c, :id, :n, :f, 'DRAFT')",
+ Map.of("c", "FQ-I" + UUID.randomUUID().toString().substring(0, 8), "id", prId, "n", "Differently named " + UUID.randomUUID(), "f", otherId));
+ assertStatus(HttpStatus.CONFLICT, () -> decisionService.repointFactoryQuote(prId, quote, new RepointFactoryRequest(otherId), ceoActor));
+ }
+
+ @Test
+ void originCountryFallback_withNoFreightBand_setsOnlyThatLineAside_insteadOfFailingTheWholeCosting() {
+ // 100 mm is outside every seeded freight band, so Italy has no rate for it.
+ PricingDecisionDto decision = startedDecisionWithoutCatalogLink("อิตาลี", 100);
+ PricingCostingItemDto item = costingItemFor(decision);
+ assertThat(item.uncostableReason()).contains("ประเทศต้นทาง").contains("ค่าขนส่ง");
+ }
+
+ @Test
+ void renameItem_alsoUpdatesTheTicketItemOnlyWhileItStillHasTheOldName() {
+ PricingDecisionDto decision = startedDecision();
+ long prId = decision.pricingRequestId();
+ long itemId = decision.items().get(0).pricingRequestItemId();
+ long ticketItemId = jdbc.queryForObject("SELECT item_id FROM sales.ticket_item WHERE ticket_id = :t", Map.of("t", ticketId), Long.class);
+ jdbc.update("UPDATE sales.ticket_item SET brand = 'Test', model = 'Model' WHERE item_id = :id", Map.of("id", ticketItemId));
+ jdbc.update("UPDATE sales.pricing_request_item SET source_ticket_item_id = :t WHERE pricing_request_item_id = :i", Map.of("t", ticketItemId, "i", itemId));
+
+ decisionService.renameItem(prId, itemId, new RenameItemRequest("Padana", "Marmo"), ceoActor);
+
+ assertThat(jdbc.queryForMap("SELECT brand, model FROM sales.ticket_item WHERE item_id = :id", Map.of("id", ticketItemId)))
+ .containsEntry("brand", "Padana").containsEntry("model", "Marmo");
+ jdbc.update("UPDATE sales.ticket_item SET brand = 'Sales changed it' WHERE item_id = :id", Map.of("id", ticketItemId));
+ decisionService.renameItem(prId, itemId, new RenameItemRequest("Padana2", "Marmo2"), ceoActor);
+ assertThat(jdbc.queryForMap("SELECT brand, model FROM sales.ticket_item WHERE item_id = :id", Map.of("id", ticketItemId)))
+ .containsEntry("brand", "Sales changed it").containsEntry("model", "Marmo");
+ // Clearing the brand clears it on the deal line too (ticket_item.brand is NOT NULL, so blank there).
+ jdbc.update("UPDATE sales.ticket_item SET brand = 'Padana2', model = 'Marmo2' WHERE item_id = :id", Map.of("id", ticketItemId));
+ decisionService.renameItem(prId, itemId, new RenameItemRequest("", "Only model"), ceoActor);
+ assertThat(jdbc.queryForMap("SELECT brand, model FROM sales.ticket_item WHERE item_id = :id", Map.of("id", ticketItemId)))
+ .containsEntry("brand", "").containsEntry("model", "Only model");
+ }
+
private long readyForReview(BigDecimal requestedQty, String requestedUnitBasis, String quotedUnitBasis,
BigDecimal quotedQuantity, String rawPrice, BigDecimal sqmPerUnit,
BigDecimal piecesPerBox, BigDecimal linearMPerUnit) {
diff --git a/backend/src/test/java/th/co/glr/hr/pricingrequest/PricingFactoryQuoteCostingIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/pricingrequest/PricingFactoryQuoteCostingIntegrationTest.java
index 4c730769e..de4d80fb5 100644
--- a/backend/src/test/java/th/co/glr/hr/pricingrequest/PricingFactoryQuoteCostingIntegrationTest.java
+++ b/backend/src/test/java/th/co/glr/hr/pricingrequest/PricingFactoryQuoteCostingIntegrationTest.java
@@ -1634,11 +1634,12 @@ void generateDrafts_omitsTheAttachmentSectionEntirelyWhenNothingIsMarkedForInclu
/** A blank recipient must be allowed — factory RFQ email is manual-only, and the human sending
* it from their own mail client may have no on-file factory contact to prefill. "Free Text
- * Factory" (unlike "Factory A"/"B"/"C") is never seeded into price_catalog.factories by this
- * class's fixture, so factoryConfigs.findByName resolves to nothing and emailTo really is
- * null at draft time — not merely unasserted. */
+ * Factory" is seeded here as a master row with NO email (a typed name must match a master row
+ * or it is saved blank), so emailTo really is null at draft time — not merely unasserted. */
@Test
void markContacted_withANullRecipient_succeedsAndRequestsTheQuote() {
+ insertCatalogProduct("Free Text Factory", "IT", "TEST-FT-001",
+ new BigDecimal("100.00"), "THB", "per_piece");
long pricingRequestId = pricingRequestService.createDraft(ticketId,
new PricingRequestRequests.CreatePricingRequestRequest(
PricingRequestRecipient.DESIGNER, null, "Designer Co.", LocalDate.now().plusDays(14),
diff --git a/backend/src/test/java/th/co/glr/hr/pricingrequest/PricingRequestServiceTest.java b/backend/src/test/java/th/co/glr/hr/pricingrequest/PricingRequestServiceTest.java
index 958cb7d23..2fd23611a 100644
--- a/backend/src/test/java/th/co/glr/hr/pricingrequest/PricingRequestServiceTest.java
+++ b/backend/src/test/java/th/co/glr/hr/pricingrequest/PricingRequestServiceTest.java
@@ -226,6 +226,49 @@ void createDraft_notifiesNobody() {
verify(notifRepo, never()).notifyEmployee(anyLong(), anyLong(), any(), any());
}
+ // ── factory normalization: free text must never reach factory_name_snapshot ──
+
+ private String savedFactoryFor(String typed, java.util.Optional master) {
+ stubTicket(10L, 1L, DealLifecycle.ACTIVE);
+ when(requestRepo.findItemIdsForTicket(10L)).thenReturn(List.of());
+ when(requestRepo.nextRequestCode()).thenReturn("PCR-2026-0001");
+ if (typed != null) {
+ when(requestRepo.findFactoryNameByNameIgnoreCase(typed)).thenReturn(master);
+ }
+ when(requestRepo.create(eq(10L), eq("PCR-2026-0001"), any(), eq(1L))).thenReturn(20L);
+ stubPricingRequest(20L, 10L, 1L, PricingRequestStatus.DRAFT);
+
+ service.createDraft(10L, createRequest(PricingRequestRecipient.BUYER, 1L, null,
+ List.of(sampleItemRequest(null, QuantityType.REFERENCE, typed))), salesActor);
+
+ org.mockito.ArgumentCaptor saved =
+ org.mockito.ArgumentCaptor.forClass(CreatePricingRequestRequest.class);
+ verify(requestRepo).create(eq(10L), eq("PCR-2026-0001"), saved.capture(), eq(1L));
+ return saved.getValue().items().get(0).factory();
+ }
+
+ @Test
+ void createDraft_unknownTypedFactoryIsSavedAsNull() {
+ // "Padana marmoker" = brand+series typed into the factory field; not a master row
+ assertThat(savedFactoryFor("Padana marmoker", java.util.Optional.empty())).isNull();
+ }
+
+ @Test
+ void createDraft_knownFactoryDifferentCaseOrPaddedIsSavedCanonical() {
+ assertThat(savedFactoryFor(" padana ", java.util.Optional.of("Padana"))).isEqualTo("Padana");
+ }
+
+ @Test
+ void createDraft_exactKnownFactoryIsKept() {
+ assertThat(savedFactoryFor("Padana", java.util.Optional.of("Padana"))).isEqualTo("Padana");
+ }
+
+ @Test
+ void createDraft_blankFactoryIsSavedAsNull() {
+ assertThat(savedFactoryFor(" ", java.util.Optional.empty())).isNull();
+ verify(requestRepo, never()).findFactoryNameByNameIgnoreCase(any());
+ }
+
// ── item identity (Part 1: an unidentified product must be rejected) ───────
@Test
@@ -1610,8 +1653,13 @@ private static PricingRequestItemDto sampleItem(Long sourceTicketItemId) {
// that far (validateItems' isProductIdentified check throws first), so the baseline is inert
// for them either way.
private static PricingRequestItemRequest sampleItemRequest(Long sourceTicketItemId, String quantityType) {
+ return sampleItemRequest(sourceTicketItemId, quantityType, null);
+ }
+
+ private static PricingRequestItemRequest sampleItemRequest(Long sourceTicketItemId, String quantityType,
+ String factory) {
return new PricingRequestItemRequest(sourceTicketItemId, null, null, "Brand", "Model", null,
- "สี", "ผิว", "60x60", null,
+ "สี", "ผิว", "60x60", factory,
null, null, null, null, quantityType, null, null, null,
null, new BigDecimal("10"), new BigDecimal("0.36"), WastageCalculator.QUANTITY_MODE_PIECES,
null, 1, WastageCalculator.WASTAGE_MODE_NONE, null, 4, null,
diff --git a/docs/api/api-surface.json b/docs/api/api-surface.json
index 004f3da1b..59f2d7413 100644
--- a/docs/api/api-surface.json
+++ b/docs/api/api-surface.json
@@ -1,7 +1,7 @@
{
"$comment" : "GENERATED by ApiSurfaceContractTest — do not hand-edit. Regenerate: cd backend && ./mvnw -Dtest=ApiSurfaceContractTest -Dapi.surface.update=true test",
"source" : "springdoc /v3/api-docs, generated from Spring's handler mappings",
- "count" : 391,
+ "count" : 394,
"deprecatedCount" : 3,
"operations" : [
"DELETE /api/attachments/{id}",
@@ -390,7 +390,10 @@
"PUT /api/pricing-decisions/{decisionId}/items/{itemId}/product-type-override",
"PUT /api/pricing-request-attachments/{attachmentId}/include-in-factory-email",
"PUT /api/pricing-requests/{id}",
+ "PUT /api/pricing-requests/{id}/factory-quotes/{factoryQuoteId}/factory",
"PUT /api/pricing-requests/{id}/items/{itemId}/factory",
+ "PUT /api/pricing-requests/{id}/items/{itemId}/name",
+ "PUT /api/pricing-requests/{id}/items/{itemId}/origin-country",
"PUT /api/remaining-invoices/{id}",
"PUT /api/tickets/{id}/customer",
"PUT /api/tickets/{id}/tracking",
diff --git a/docs/api/ui-reachable.json b/docs/api/ui-reachable.json
index d3d2f717f..59d90c204 100644
--- a/docs/api/ui-reachable.json
+++ b/docs/api/ui-reachable.json
@@ -1,7 +1,7 @@
{
"$comment": "GENERATED by src/api/serverContract.test.js — do not hand-edit. Regenerate: cd frontend && UI_REACHABLE_UPDATE=1 npx vitest run src/api/serverContract.test.js",
"source": "endpoints reachable from a component call site (api..( outside src/api/ and outside tests), resolved through hrApi by driving every method",
- "count": 325,
+ "count": 328,
"operations": [
"DELETE /api/attachments/{id}",
"DELETE /api/catalog/prices/{priceId}",
@@ -323,7 +323,10 @@
"PUT /api/pricing-decisions/{decisionId}/items/{itemId}/cost-override",
"PUT /api/pricing-request-attachments/{attachmentId}/include-in-factory-email",
"PUT /api/pricing-requests/{id}",
+ "PUT /api/pricing-requests/{id}/factory-quotes/{factoryQuoteId}/factory",
"PUT /api/pricing-requests/{id}/items/{itemId}/factory",
+ "PUT /api/pricing-requests/{id}/items/{itemId}/name",
+ "PUT /api/pricing-requests/{id}/items/{itemId}/origin-country",
"PUT /api/remaining-invoices/{id}",
"PUT /api/tickets/{id}/customer",
"PUT /api/tickets/{id}/tracking",
diff --git a/frontend/src/api/hrApi.js b/frontend/src/api/hrApi.js
index 94db16da5..3c6d932e7 100644
--- a/frontend/src/api/hrApi.js
+++ b/frontend/src/api/hrApi.js
@@ -1166,6 +1166,13 @@ export const api = {
recalculatePricingDecisionCost: (id) => apiRequest(API_ROUTES.pricingRequests.pricingDecisionRecalculateCost(id), { method: 'POST' }),
overridePricingDecisionItemCost: (decisionId, itemId, payload) =>
apiRequest(API_ROUTES.pricingRequests.pricingDecisionItemCostOverride(decisionId, itemId), { method: 'PUT', body: payload }),
+ // CEO price-review fixes. Each returns { decision }; the first two recalculate cost server-side.
+ repointFactoryQuote: (id, quoteId, payload) =>
+ apiRequest(API_ROUTES.pricingRequests.factoryQuoteFactory(id, quoteId), { method: 'PUT', body: payload }),
+ setItemOriginCountry: (id, itemId, payload) =>
+ apiRequest(API_ROUTES.pricingRequests.itemOriginCountry(id, itemId), { method: 'PUT', body: payload }),
+ renameItem: (id, itemId, payload) =>
+ apiRequest(API_ROUTES.pricingRequests.itemName(id, itemId), { method: 'PUT', body: payload }),
// V152 (V109 engine wiring). No request body arity beyond { productType } — null clears it.
overridePricingDecisionItemProductType: (decisionId, itemId, payload) =>
apiRequest(API_ROUTES.pricingRequests.pricingDecisionItemProductTypeOverride(decisionId, itemId), { method: 'PUT', body: payload }),
diff --git a/frontend/src/api/mockApi.js b/frontend/src/api/mockApi.js
index 7177eeba7..f194e5ad0 100644
--- a/frontend/src/api/mockApi.js
+++ b/frontend/src/api/mockApi.js
@@ -1160,6 +1160,17 @@ function nextPricingRequestCode() {
return `PCR-2026-${String(mockPricingRequestSeq).padStart(4, '0')}`;
}
+// The guard the CEO price-review fixes share with recalculateCost: the request's open DRAFT decision on a
+// CEO_REVIEWING request (anything else is the same 409).
+function mockOpenDraftFor(pricingRequestId, allowNoDecision = false) {
+ const pr = findPricingRequestRaw(pricingRequestId);
+ const decision = mockPricingDecisions.find((d) => d.pricingRequestId === pr.id && d.status === 'DRAFT');
+ if (decision && pr.status === 'CEO_REVIEWING') return { pr, decision };
+ // Re-point / origin country are also open before any decision exists (a failed startReview leaves it here).
+ if (allowNoDecision && !decision && pr.status === 'READY_FOR_CEO_REVIEW') return { pr, decision: null };
+ fail('คำขอราคานี้ไม่ได้อยู่ระหว่างการพิจารณาของ CEO', 409);
+}
+
function findPricingRequestRaw(id) {
const pr = mockPricingRequests.find((p) => p.id === Number(id));
if (!pr) fail('ไม่พบคำขอราคานี้', 404);
@@ -15912,6 +15923,65 @@ export const api = {
return delay({ decision });
},
+ // CEO price-review fixes — mirror PricingDecisionService#repointFactoryQuote / #setItemOriginCountry /
+ // #renameItem (role + open-DRAFT guard shared with recalculateCost; real guards are pinned in
+ // LandedCostCalculatorFormulaIntegrationTest). Cost is not re-derived here (the mock has no engine).
+ async repointFactoryQuote(id, quoteId, payload) {
+ const user = hasRole('ceo');
+ const { pr, decision } = mockOpenDraftFor(id, true);
+ const quote = mockFactoryQuotes.find((q) => q.id === Number(quoteId) && q.pricingRequestId === pr.id);
+ if (!quote) fail('ไม่พบใบเสนอราคาโรงงานนี้ในคำขอราคานี้', 404);
+ const master = mockPriceImportFactories.find((f) => f.factoryId === Number(payload?.factoryId));
+ if (!master) fail('ไม่พบโรงงานนี้ในระบบ', 404);
+ if (master.name === quote.factoryName) fail(`ใบเสนอราคาโรงงานนี้ใช้โรงงาน "${master.name}" อยู่แล้ว`, 409);
+ if (mockFactoryQuotes.some((q) => q.pricingRequestId === pr.id && q.current && q.status !== 'CANCELLED' && q.factoryName === master.name)) {
+ fail(`มีใบเสนอราคาโรงงาน "${master.name}" ในคำขอราคานี้แล้ว`, 409);
+ }
+ const oldName = quote.factoryName;
+ quote.factoryName = master.name;
+ quote.factoryId = master.factoryId;
+ for (const qi of quote.items ?? []) {
+ const item = pr.items.find((i) => i.id === qi.pricingRequestItemId);
+ if (item) { item.factory = master.name; item.resolvedFactoryId = master.factoryId; }
+ }
+ pushPricingRequestEvent(pr, user, 'PRICING_REQUEST_FACTORY_QUOTE_REPOINTED', pr.status, pr.status,
+ `CEO เปลี่ยนโรงงานของใบเสนอราคาโรงงาน ${quote.quoteCode}: "${oldName}" → "${master.name}"`);
+ return delay({ decision, costingError: null });
+ },
+
+ async setItemOriginCountry(id, itemId, payload) {
+ const user = hasRole('ceo');
+ const { pr, decision } = mockOpenDraftFor(id, true);
+ const item = pr.items.find((i) => i.id === Number(itemId));
+ if (!item) fail('ไม่พบรายการสินค้านี้ในคำขอราคานี้', 404);
+ const country = String(payload?.originCountry ?? '').trim();
+ if (!['อิตาลี', 'สเปน', 'จีน'].includes(country)) fail('ประเทศต้นทางต้องเป็นหนึ่งใน: อิตาลี, สเปน, จีน', 400);
+ if (item.stockSource) fail('รายการจากสต็อกไม่มีต้นทุนนำเข้า จึงตั้งประเทศต้นทางไม่ได้', 400);
+ pushPricingRequestEvent(pr, user, 'PRICING_REQUEST_ITEM_ORIGIN_COUNTRY_SET', pr.status, pr.status,
+ `CEO ตั้งประเทศต้นทางรายการที่ ${item.id}: ${item.originCountry || 'ไม่ระบุ'} → ${country}`);
+ item.originCountry = country;
+ item.originCountryOther = null;
+ return delay({ decision, costingError: null });
+ },
+
+ async renameItem(id, itemId, payload) {
+ const user = hasRole('ceo');
+ const { pr, decision } = mockOpenDraftFor(id);
+ const item = pr.items.find((i) => i.id === Number(itemId));
+ if (!item) fail('ไม่พบรายการสินค้านี้ในคำขอราคานี้', 404);
+ const brand = String(payload?.brand ?? '').trim() || null;
+ const model = String(payload?.model ?? '').trim() || null;
+ if (!brand && !model) fail('ต้องระบุยี่ห้อหรือรุ่นอย่างน้อยหนึ่งอย่าง', 400);
+ if ((brand?.length ?? 0) > 255 || (model?.length ?? 0) > 255) fail('ยี่ห้อและรุ่นต้องยาวไม่เกิน 255 ตัวอักษร', 400);
+ pushPricingRequestEvent(pr, user, 'PRICING_REQUEST_ITEM_RENAMED', pr.status, pr.status,
+ `CEO แก้ชื่อรายการที่ ${item.id}: "${[item.brand, item.model].filter(Boolean).join(' ')}" → "${[brand, model].filter(Boolean).join(' ')}"`);
+ item.brand = brand;
+ item.model = model;
+ if (item.catalogBrand != null) item.catalogBrand = brand;
+ if (item.catalogModel != null) item.catalogModel = model;
+ return delay({ decision, costingError: null });
+ },
+
// Mirrors PricingDecisionService.overrideItemCost, including its check ORDER: role -> reason
// (mandatory in BOTH directions, checked before anything else) -> negative amount -> decision
// open-for-mutation -> item-belongs-to-decision -> bound-costing-item-found. `itemId` is the
diff --git a/frontend/src/api/routes.js b/frontend/src/api/routes.js
index 0fcee167c..6d0fa1886 100644
--- a/frontend/src/api/routes.js
+++ b/frontend/src/api/routes.js
@@ -502,6 +502,11 @@ export const API_ROUTES = {
// PricingRequestController.setItemFactory. Shaped like the CEO's per-item overrides above
// (PUT .../items/{itemId}/...) because it writes exactly one field on one line.
itemFactory: (id, itemId) => `/api/pricing-requests/${id}/items/${itemId}/factory`,
+ // CEO price-review fixes (PricingDecisionController): re-point a quote's factory, set a line's
+ // origin country, rename a line. CEO only, open DRAFT decision only.
+ factoryQuoteFactory: (id, quoteId) => `/api/pricing-requests/${id}/factory-quotes/${quoteId}/factory`,
+ itemOriginCountry: (id, itemId) => `/api/pricing-requests/${id}/items/${itemId}/origin-country`,
+ itemName: (id, itemId) => `/api/pricing-requests/${id}/items/${itemId}/name`,
factoryEmailDrafts: (id) => `/api/pricing-requests/${id}/factory-email-drafts`,
factoryQuotes: (id) => `/api/pricing-requests/${id}/factory-quotes`,
factoryQuote: (id) => `/api/factory-quotes/${id}`,
diff --git a/frontend/src/components/common/SearchableCombobox.jsx b/frontend/src/components/common/SearchableCombobox.jsx
index 758605f97..ed4fdff0d 100644
--- a/frontend/src/components/common/SearchableCombobox.jsx
+++ b/frontend/src/components/common/SearchableCombobox.jsx
@@ -15,7 +15,7 @@ export function SearchableCombobox({ id, label, value, options, onChange, disabl
const filtered = options.filter((item) => `${item.nameTh} ${item.nameEn ?? ''} ${item.code}`.toLocaleLowerCase('th').includes(query.trim().toLocaleLowerCase('th')));
useEffect(() => { setQuery(''); setActive(-1); setOpen(false); }, [value, disabled]);
useEffect(() => { list.current?.querySelector('[aria-selected="true"]')?.scrollIntoView?.({ block: 'nearest' }); }, [active]);
- function select(item) { onChange(item?.code ?? ''); setQuery(''); setOpen(false); setActive(-1); }
+ function select(item) { if (item?.disabled) return; onChange(item?.code ?? ''); setQuery(''); setOpen(false); setActive(-1); }
return (
{ if (!e.currentTarget.contains(e.relatedTarget)) { setOpen(false); setQuery(''); } }}>