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..d8c845efd 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 @@ -142,6 +142,8 @@ public CalculationResult calculate(PricingRequestSummaryDto summary) { // One batched catalog round trip per field, for every source's price id, rather than // per-item lookups inside resolveItemPhysicals. See CatalogRepository#findPricingKeys. Map catalogKeys = prefetchCatalogKeys(sources); + // CEO-entered freight (THB, total per line) for lines the freight table has no band for. + Map manualFreights = pricingRequests.findManualFreights(summary.id()); // Resolve EVERY item's physicals up front, in one accumulating pass across ALL // shipments/factories in this request — not per shipment, and not aborting on the first @@ -151,7 +153,7 @@ public CalculationResult calculate(PricingRequestSummaryDto summary) { Set problems = new LinkedHashSet<>(); for (ResolvedSource source : sources) { try { - allPhysicals.add(resolveItemPhysicals(source, formulaConfig, catalogKeys)); + allPhysicals.add(resolveItemPhysicals(source, formulaConfig, catalogKeys, manualFreights)); } catch (ApiException e) { // Every throw reachable on this path is a 422 today (see // resolveItemPhysicals and its callees), so this rethrow is currently inert — but @@ -259,17 +261,37 @@ private List costShipment(List allItems, // line's own cost and the costing is recalculated. List items = new ArrayList<>(); List uncostable = new ArrayList<>(); + // ไทย-สต็อก lines are costed domestically and kept OUT of everything below: not in the freight + // grouping, and not in the clearance denominator or amount (no customs on a domestic purchase, so + // they neither fill container space nor dilute the fee). The other lines' numbers are therefore + // exactly what they would be without these lines. + List domesticWrites = new ArrayList<>(); for (ItemPhysicals item : allItems) { - (uncostableReason(item) == null ? items : uncostable).add(item); + if (uncostableReason(item) != null) { + uncostable.add(item); + } else if (item.domestic()) { + domesticWrites.add(domesticWriteItem(item, formulaConfig, calculatedAt)); + } else { + items.add(item); + } + } + // Freight table gaps are decided per GROUP (the unit the container rule actually looks up), so a group + // whose lines each pass alone but have no band together is set aside too, instead of 422ing the request. + FreightPlan plan = planFreight(items, formulaConfig, shipmentLabel); + List gapWrites = new ArrayList<>(); + for (ItemPhysicals gap : plan.gap()) { + gapWrites.add(uncostableWriteItem(gap, formulaConfig, freightGapReason(gap), "NO_FREIGHT_BAND")); } + items = items.stream().filter(i -> !plan.gap().contains(i)).toList(); if (items.isEmpty()) { // Every line in this shipment is uncostable — there is no freight band and no // clearance denominator to compute at all. Emit the placeholders and stop, rather // than dividing by a zero shipment quantity. - List onlyUncostable = new ArrayList<>(); + List onlyUncostable = new ArrayList<>(domesticWrites); for (ItemPhysicals item : uncostable) { onlyUncostable.add(uncostableWriteItem(item, formulaConfig)); } + onlyUncostable.addAll(gapWrites); return onlyUncostable; } @@ -281,7 +303,11 @@ private List costShipment(List allItems, for (ItemPhysicals item : items) { PricingFormulaEngine.ContainerLine line = new PricingFormulaEngine.ContainerLine(item.thicknessMm(), item.qtySqm()); - linesByGroup.computeIfAbsent(item.originCountryCode(), k -> new ArrayList<>()).add(line); + // A line carrying the CEO's own freight is not looked up in the table (it has no band); it still + // counts in the clearance lines below, because it is imported like every other line. + if (!plan.manual().contains(item)) { + linesByGroup.computeIfAbsent(item.originCountryCode(), k -> new ArrayList<>()).add(line); + } qtySqmByGroup.merge(item.originCountryCode(), item.qtySqm(), BigDecimal::add); shipmentLines.add(line); } @@ -307,7 +333,11 @@ private List costShipment(List allItems, for (ItemPhysicals item : items) { String key = item.originCountryCode(); BigDecimal freightGroupQtySqm = qtySqmByGroup.get(key); - PricingFormulaEngine.ContainerCharge freight = freightByGroup.get(key); + boolean manualF = plan.manual().contains(item); + // CEO freight = the TOTAL for this line (not split by container space like a looked-up charge). + PricingFormulaEngine.ContainerCharge freight = manualF + ? new PricingFormulaEngine.ContainerCharge(money4(item.manualFreightThb()), 0, ZERO, ZERO) + : freightByGroup.get(key); BigDecimal freightAmountForGroup = freight.amountThb(); // F/S allocation is inherently a TOTAL-for-the-item quantity (a share of a flat, // shipment-level amount, proportional to the item's own share of the group's CONTAINER @@ -317,8 +347,8 @@ private List costShipment(List allItems, // per-sqm rate). BigDecimal itemUnits = item.qtySqm().divide( PricingFormulaEngine.containerCapacitySqm(item.thicknessMm()), 12, RoundingMode.HALF_UP); - BigDecimal groupUnits = unitsByGroup.get(key); - BigDecimal freightShareTotal = allocate(freightAmountForGroup, itemUnits, groupUnits); + BigDecimal groupUnits = manualF ? ZERO : unitsByGroup.get(key); + BigDecimal freightShareTotal = manualF ? freightAmountForGroup : allocate(freightAmountForGroup, itemUnits, groupUnits); BigDecimal clearanceShareTotal = allocate(clearance.amountThb(), itemUnits, shipmentUnits); PricingDutyRateDto dutyRate = formulaEngine.selectDutyRate( @@ -353,6 +383,9 @@ private List costShipment(List allItems, freightGroupQtySqm, clearance.amountThb(), clearanceShareTotal, shipmentQtySqm, dutyRate.dutyPct(), formulaConfig, calculatedAt, freight, clearance, itemUnits, groupUnits, shipmentUnits); + if (manualF) { + snapshot = snapshot.replace("{\"formula\":\"V109\"", "{\"formula\":\"V109\",\"freightSource\":\"CEO_MANUAL\""); + } ResolvedSource source = item.source(); writeItems.add(new PricingCostingWriteItem( @@ -394,15 +427,108 @@ private List costShipment(List allItems, clearancePerPiece, item.productType(), snapshot, - null // uncostable_reason — this line costed cleanly + null, // uncostable_reason — this line costed cleanly + null // uncostable_code )); } + writeItems.addAll(domesticWrites); for (ItemPhysicals item : uncostable) { writeItems.add(uncostableWriteItem(item, formulaConfig)); } + writeItems.addAll(gapWrites); return writeItems; } + /** Which lines are looked up in the freight table, which use the CEO's own freight, which are set aside. */ + private record FreightPlan(java.util.Set manual, java.util.Set gap) {} + + private FreightPlan planFreight(List items, PricingFormulaConfigDto formulaConfig, String shipmentLabel) { + java.util.Set manual = java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>()); + java.util.Set gap = java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>()); + Map> byOrigin = new LinkedHashMap<>(); + for (ItemPhysicals item : items) { + byOrigin.computeIfAbsent(item.originCountryCode(), k -> new ArrayList<>()).add(item); + } + for (Map.Entry> entry : byOrigin.entrySet()) { + List group = entry.getValue(); + if (freightFound(formulaConfig, entry.getKey(), group, shipmentLabel)) { + continue; // the table answers for the whole group: any manual value is ignored + } + List rest = new ArrayList<>(); + for (ItemPhysicals item : group) { + if (item.manualFreightThb() != null) { + manual.add(item); + } else { + rest.add(item); + } + } + if (rest.isEmpty() || freightFound(formulaConfig, entry.getKey(), rest, shipmentLabel)) { + continue; + } + // Isolate the lines that have no band even alone, then retry the others together. + List alone = rest.stream().filter(ItemPhysicals::freightGap).toList(); + List others = rest.stream().filter(i -> !i.freightGap()).toList(); + if (others.isEmpty() || !freightFound(formulaConfig, entry.getKey(), others, shipmentLabel)) { + gap.addAll(rest); + } else { + gap.addAll(alone); + } + } + return new FreightPlan(manual, gap); + } + + /** True when the freight table has a band for these lines together; false only for "no band" (other 422s propagate). */ + private boolean freightFound(PricingFormulaConfigDto formulaConfig, String origin, List group, String shipmentLabel) { + List lines = group.stream() + .map(i -> new PricingFormulaEngine.ContainerLine(i.thicknessMm(), i.qtySqm())).toList(); + try { + formulaEngine.containerFreight(formulaConfig.freightRates(), origin, lines, shipmentLabel + " ต้นทาง " + origin); + return true; + } catch (ApiException e) { + if (e.getMessage() != null && e.getMessage().startsWith("ไม่พบอัตราค่าขนส่ง")) { + return false; + } + throw e; + } + } + + /** A ไทย-สต็อก line: landed = goods x cost buffer; insurance/freight/duty/clearance are 0 (not null, so it is costable). */ + private PricingCostingWriteItem domesticWriteItem(ItemPhysicals item, PricingFormulaConfigDto formulaConfig, + Instant calculatedAt) { + ResolvedSource source = item.source(); + BigDecimal goodsPerPiece = money4(item.goodsCostPerPiece()); + // Same pipeline as a costed line with insurance, freight, duty and clearance all zero. + BigDecimal cifTotal = money4(item.goodsCostThb()); + BigDecimal totalLandedCostThb = formulaEngine.totalLandedCost(cifTotal, ZERO, formulaConfig.costBuffer(), ZERO); + BigDecimal perPiece = money4(formulaEngine.unitCostPerSqm(totalLandedCostThb, item.qtySqm()).multiply(item.sqmPerPiece())); + String snapshot = "{\"formula\":\"DOMESTIC\"" + + ",\"note\":\"ไทย-สต็อก: ซื้อในประเทศ สูตรเดิมโดย ค่าขนส่ง/ประกัน/อากร/ค่าพิธีการ = 0 (ยังคูณบัฟเฟอร์ต้นทุน)\"" + + ",\"calculatedAt\":\"" + calculatedAt + "\"" + + ",\"formulaConfigId\":\"" + formulaConfig.formulaConfigId() + "\"" + + ",\"formulaConfigVersion\":\"" + formulaConfig.version() + "\"" + + ",\"goodsCostTotalThb\":\"" + item.goodsCostThb() + "\"" + + ",\"requestedUnitBasis\":\"" + source.requestItem().requestedUnitBasis() + "\"}"; + return new PricingCostingWriteItem( + source.requestItem().id(), source.quote().id(), source.quoteItem().id(), source.quote().revisionNo(), + source.quote().factoryId(), source.quote().factoryName(), source.quote().supplierQuoteRef(), + source.quoteItem().rawUnitPrice(), source.quoteItem().currency(), source.quoteItem().quotedUnit(), + source.quoteItem().unitBasis(), source.requestItem().requestedQty(), source.requestItem().requestedUnit(), + source.requestItem().requestedUnitBasis(), item.qtyPieces(), source.quoteItem().linearMPerUnit(), + item.sqmPerPiece(), source.quoteItem().piecesPerBox(), item.fx().rate(), item.fx().source(), + item.fx().effectiveDate(), item.fx().fetchedAt(), formulaConfig.formulaConfigId(), formulaConfig.version(), + goodsPerPiece, + ZERO, // freight + ZERO, // insurance + ZERO, // import duty + ZERO, // inland transport -- no input exists yet + ZERO, // other + goodsPerPiece, // cif = goods + perPiece, // landed cost per piece + money4(perPiece.multiply(item.qtyPieces())), + ZERO, // clearance + item.productType(), snapshot, null, null); + } + /** * A costing row for an item that cannot be costed (V156): every freight-dependent figure is * NULL and {@code uncostableReason} says why, enforced as an XOR by @@ -414,6 +540,11 @@ private List costShipment(List allItems, * empty placeholder. */ private PricingCostingWriteItem uncostableWriteItem(ItemPhysicals item, PricingFormulaConfigDto formulaConfig) { + return uncostableWriteItem(item, formulaConfig, uncostableReason(item), uncostableCode(item)); + } + + private PricingCostingWriteItem uncostableWriteItem(ItemPhysicals item, PricingFormulaConfigDto formulaConfig, + String reason, String code) { ResolvedSource source = item.source(); // goods_cost_thb / insurance_cost_thb are NOT NULL (V156 left them so): a line whose goods // cost could not be computed (missing sqm on a PER_SQM basis) records 0 here and the @@ -458,12 +589,14 @@ private PricingCostingWriteItem uncostableWriteItem(ItemPhysicals item, PricingF null, // clearance_fee_thb — allocated across costable lines only item.productType(), null, // calculation_snapshot — no calculation happened - uncostableReason(item) + reason, + code ); } private ItemPhysicals resolveItemPhysicals(ResolvedSource source, PricingFormulaConfigDto formulaConfig, - Map catalogKeys) { + Map catalogKeys, + Map manualFreights) { BigDecimal sqmPerPiece = resolveSqmPerPiece(source.quoteItem(), source.requestItem()); BigDecimal piecesPerBox = source.quoteItem().piecesPerBox(); BigDecimal linearMPerUnit = source.quoteItem().linearMPerUnit(); @@ -498,9 +631,28 @@ private ItemPhysicals resolveItemPhysicals(ResolvedSource source, PricingFormula } BigDecimal thicknessMm = resolveThicknessMm(source.requestItem(), catalogKeys); String originCountryCode = resolveOriginCountryCode(source.requestItem(), catalogKeys); + boolean domestic = originCountryCode == null && !hasCatalogCountry(source.requestItem(), catalogKeys) + && source.requestItem().originCountry() != null + && DOMESTIC_ORIGIN.equals(source.requestItem().originCountry().trim()); + // Probe the freight table per line: no matching band (e.g. 12-16 mm > 800 sqm) sets just this line + // aside as uncostable - or lets the CEO's own freight stand in - instead of 422ing the whole request. + boolean freightGap = false; + if (!domestic && originCountryCode != null && thicknessMm != null && qtySqm != null) { + try { + formulaEngine.containerFreight(formulaConfig.freightRates(), originCountryCode, + List.of(new PricingFormulaEngine.ContainerLine(thicknessMm, qtySqm)), itemLabel(source.requestItem())); + } catch (ApiException e) { + if (e.getMessage() == null || !e.getMessage().startsWith("ไม่พบอัตราค่าขนส่ง")) { + throw e; + } + freightGap = true; + } + } + // The CEO's freight is only USED where the table has no band for the line's group (planFreight). + BigDecimal manualFreight = manualFreights.get(source.requestItem().id()); String productType = resolveProductType(source.requestItem()); return new ItemPhysicals(source, sqmPerPiece, qtyPieces, qtySqm, goodsCostPerPiece, goodsCostThb, - insuranceThb, thicknessMm, originCountryCode, productType, fx); + insuranceThb, thicknessMm, originCountryCode, productType, fx, freightGap, manualFreight, domestic); } /** @@ -543,14 +695,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 +713,35 @@ 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). + */ + /** + * "ไทย-สต็อก" (goods already in Thailand), owner ruling: the SAME V109 formula with i = F = T = S = 0, + * i.e. TC = goods x costBuffer (the x1.07 stays). No freight lookup (prod has no TH band). There is no + * inland-transport input yet, so that column stays 0. Catalogue country wins. + */ + public static final String DOMESTIC_ORIGIN = "ไทย-สต็อก"; + + 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 @@ -577,14 +751,40 @@ private String resolveOriginCountryCode(PricingRequestItemDto requestItem, * quote's {@code sqm_per_unit} or the request's own {@code requested_qty_sqm}, NOT the Price * Catalog), so it carries its own clause. */ + /** Machine-readable twin of {@link #uncostableReason}: comma-separated MISSING_SQM / MISSING_THICKNESS / MISSING_ORIGIN. */ + private String uncostableCode(ItemPhysicals item) { + List codes = new ArrayList<>(); + if (item.sqmPerPiece() == null) { + codes.add("MISSING_SQM"); + } + if (!item.domestic() && item.thicknessMm() == null) { + codes.add("MISSING_THICKNESS"); + } + if (!item.domestic() && item.originCountryCode() == null) { + codes.add("MISSING_ORIGIN"); + } + return codes.isEmpty() ? null : String.join(",", codes); + } + + /** The origin as Sales typed it (อิตาลี); the ISO code only when the line has no typed origin. */ + private String originLabel(ItemPhysicals item) { + String typed = item.source().requestItem().originCountry(); + return typed != null && !typed.isBlank() ? typed.trim() : item.originCountryCode(); + } + + private String freightGapReason(ItemPhysicals item) { + return itemLabel(item.source().requestItem()) + " ไม่มีอัตราค่าขนส่งในตาราง (ต้นทาง=" + originLabel(item) + + " ความหนา=" + item.thicknessMm() + " มม.) — คำนวณต้นทุนอัตโนมัติไม่ได้ กรุณากรอกค่าขนส่งเอง หรือระบุต้นทุนเอง"; + } + private String uncostableReason(ItemPhysicals item) { boolean noSqm = item.sqmPerPiece() == null; - boolean noThickness = item.thicknessMm() == null; - boolean noCountry = item.originCountryCode() == null; + boolean noThickness = !item.domestic() && item.thicknessMm() == null; + boolean noCountry = !item.domestic() && item.originCountryCode() == null; + String label = itemLabel(item.source().requestItem()); 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 +1123,6 @@ 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, boolean freightGap, BigDecimal manualFreightThb, + boolean domestic) {} } diff --git a/backend/src/main/java/th/co/glr/hr/pricingcosting/PricingCostingDtos.java b/backend/src/main/java/th/co/glr/hr/pricingcosting/PricingCostingDtos.java index e21ca39a4..944a615e8 100644 --- a/backend/src/main/java/th/co/glr/hr/pricingcosting/PricingCostingDtos.java +++ b/backend/src/main/java/th/co/glr/hr/pricingcosting/PricingCostingDtos.java @@ -101,6 +101,8 @@ public record PricingCostingItemDto( /** V156: non-null when the freight table could not be looked up for this line (no * thickness_mm or no origin country on the catalogue row), in which case every * shipment-derived cost above is null and the CEO must supply one before approval. */ - String uncostableReason + String uncostableReason, + /** Comma-separated codes behind {@code uncostableReason}: MISSING_SQM, MISSING_THICKNESS, MISSING_ORIGIN, NO_FREIGHT_BAND. */ + String uncostableCode ) {} } diff --git a/backend/src/main/java/th/co/glr/hr/pricingcosting/PricingCostingRepository.java b/backend/src/main/java/th/co/glr/hr/pricingcosting/PricingCostingRepository.java index 95126ce59..e82d93913 100644 --- a/backend/src/main/java/th/co/glr/hr/pricingcosting/PricingCostingRepository.java +++ b/backend/src/main/java/th/co/glr/hr/pricingcosting/PricingCostingRepository.java @@ -125,7 +125,7 @@ public void replaceItemsPreservingOverrides(long costingId, List findItems(long costingId) { landed_cost_per_unit_thb, total_landed_cost_thb, clearance_fee_thb, product_type, calculated_at, calculation_snapshot::text AS calculation_snapshot, manual_landed_cost_per_unit_thb, override_reason, overridden_by, overridden_at, - override_fx_rate, override_calc_config_version, uncostable_reason + override_fx_rate, override_calc_config_version, uncostable_reason, uncostable_code FROM sales.pricing_costing_item WHERE pricing_costing_id = :costingId ORDER BY pricing_costing_item_id @@ -396,7 +397,8 @@ private PricingCostingItemDto mapItem(ResultSet rs) throws SQLException { effectiveLandedCostPerUnitThb, effectiveTotalLandedCostThb, overrideStale, - rs.getString("uncostable_reason") + rs.getString("uncostable_reason"), + rs.getString("uncostable_code") ); } @@ -449,7 +451,8 @@ public record PricingCostingWriteItem( String calculationSnapshot, /** V156: non-null when the freight table could not be looked up; every shipment-derived * cost above is then null. Enforced as an XOR by chk_pricing_costing_item_uncostable_xor. */ - String uncostableReason + String uncostableReason, + String uncostableCode ) { MapSqlParameterSource toParams(long costingId) { return new MapSqlParameterSource() @@ -490,7 +493,8 @@ MapSqlParameterSource toParams(long costingId) { .addValue("clearanceFeeThb", clearanceFeeThb) .addValue("productType", productType) .addValue("calculationSnapshot", calculationSnapshot == null ? "{}" : calculationSnapshot) - .addValue("uncostableReason", uncostableReason); + .addValue("uncostableReason", uncostableReason) + .addValue("uncostableCode", uncostableCode); } } } 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..9af1fe60d 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,16 @@ 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.FreightRequest; +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 +103,51 @@ 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}/freight") + CeoFixResult setItemFreight( + @PathVariable long id, + @PathVariable long itemId, + @RequestBody FreightRequest request, + HttpSession session + ) { + UserPrincipal user = sessions.requireUser(session); + return decisions.setItemFreight(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..16b932d96 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 @@ -185,4 +185,9 @@ public record PricingDecisionSalesItemDto( BigDecimal approvedSellingPricePerRequestedUnit, BigDecimal minimumSellingPricePerRequestedUnit ) {} + + /** 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) {} } 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..c507eb184 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,16 @@ 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) {} + + /** CEO fix: the freight (THB, TOTAL for the line) for a line the freight table has no band for; null clears it. */ + public record FreightRequest(BigDecimal freightThb) {} } 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..649a2753e 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,17 @@ 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.FreightRequest; +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 +383,196 @@ 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); + requirePricingRequest(pricingRequestId); // 404 before taking the lock + decisions.lockPricingRequest(pricingRequestId); + PricingRequestSummaryDto summary = requirePricingRequest(pricingRequestId); // read once, under the lock + Optional draft = decisions.findOpenDraft(pricingRequestId); + if (draft.isPresent()) { + return requireOpenDecisionForMutation(draft.get().id()); + } + if (allowNoDecision && PricingRequestStatus.READY_FOR_CEO_REVIEW.equals(summary.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); + } + + /** A request line with its 1-based position in sort order (what the screens and itemLabel call "รายการที่ N"). */ + private record Line(PricingRequestItemDto item, int position) {} + + private Line requireLine(long pricingRequestId, long itemId) { + List items = pricingRequests.findItems(pricingRequestId); + for (int i = 0; i < items.size(); i++) { + if (items.get(i).id() == itemId) { + return new Line(items.get(i), i + 1); + } + } + throw 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); + Line line = requireLine(pricingRequestId, itemId); + PricingRequestItemDto item = line.item(); + String country = request.originCountry() == null ? "" : request.originCountry().trim(); + if (!LandedCostCalculator.ISO_BY_SALES_ORIGIN.containsKey(country) && !LandedCostCalculator.DOMESTIC_ORIGIN.equals(country)) { + throw new ApiException(HttpStatus.BAD_REQUEST, + "ประเทศต้นทางต้องเป็นหนึ่งใน: อิตาลี, สเปน, จีน, ไทย-สต็อก"); + } + if (item.stockSource() != null) { + throw new ApiException(HttpStatus.BAD_REQUEST, "รายการจากสต็อกไม่มีต้นทุนนำเข้า จึงตั้งประเทศต้นทางไม่ได้"); + } + if (pricingRequests.catalogProvidesOriginCountry(itemId)) { + throw new ApiException(HttpStatus.CONFLICT, "รายการนี้ใช้ประเทศต้นทางจาก Price Catalog จึงตั้งประเทศต้นทางเองไม่ได้"); + } + pricingRequests.updateItemOriginCountry(pricingRequestId, itemId, country); + CeoFixResult result = recomputeKeepingFix(decision); + addEvent(pricingRequestId, actor, PricingRequestEventKind.PRICING_REQUEST_ITEM_ORIGIN_COUNTRY_SET, + "CEO ตั้งประเทศต้นทางรายการที่ " + line.position() + ": " + + (blankToNullTrim(item.originCountry()) != null ? item.originCountry() : "ไม่ระบุ") + " → " + country); + return result; + } + + /** + * CEO fix: the freight (THB, TOTAL for the line) for a line the freight table has no band for, so the + * CEO need not override the whole landed cost. Costing then runs the same formula with this F; where a + * band exists the table wins and this value is ignored. null clears it. Positive only. + */ + @Transactional + public CeoFixResult setItemFreight(long pricingRequestId, long itemId, FreightRequest request, UserPrincipal actor) { + PricingDecisionDto decision = requireFixContext(pricingRequestId, actor, true); + Line line = requireLine(pricingRequestId, itemId); + PricingRequestItemDto item = line.item(); + BigDecimal freight = request.freightThb(); + if (freight != null && (freight.signum() <= 0 || freight.compareTo(MAX_COST_18_4) > 0)) { + throw new ApiException(HttpStatus.BAD_REQUEST, "ค่าขนส่งต้องมากกว่า 0 และไม่เกิน " + MAX_COST_18_4 + " บาท"); + } + if (item.stockSource() != null) { + throw new ApiException(HttpStatus.BAD_REQUEST, "รายการจากสต็อกไม่มีค่าขนส่งนำเข้า"); + } + BigDecimal rounded = freight == null ? null : money4(freight); + pricingRequests.updateItemManualFreight(pricingRequestId, itemId, rounded); + CeoFixResult result = recomputeKeepingFix(decision); + addEvent(pricingRequestId, actor, PricingRequestEventKind.PRICING_REQUEST_ITEM_FREIGHT_SET, + rounded == null ? "CEO ล้างค่าขนส่งที่กรอกเองของรายการที่ " + line.position() + : "CEO กรอกค่าขนส่งรายการที่ " + line.position() + ": " + rounded.stripTrailingZeros().toPlainString() + " บาท (ไม่มีอัตราในตาราง)"); + 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); + Line line = requireLine(pricingRequestId, itemId); + PricingRequestItemDto item = line.item(); + 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 แก้ชื่อรายการที่ " + line.position() + " : \"" + 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 +1308,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..27c7ab9f4 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,12 @@ 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 PRICING_REQUEST_ITEM_FREIGHT_SET = "PRICING_REQUEST_ITEM_FREIGHT_SET"; 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 +93,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, PRICING_REQUEST_ITEM_FREIGHT_SET, 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..5bb67da6a 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,115 @@ public Optional findFactoryNameById(long factoryId) { } } + /** Every master factory name, in factory_id order (read once per save and matched in memory). */ + public List findMasterFactoryNames() { + return jdbc.queryForList("SELECT name FROM price_catalog.factories ORDER BY factory_id", Map.of(), String.class); + } + + 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: 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: 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: 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)); + } + + /** True when the line's catalogue link (snapshot id, else live product id) resolves to a factory with a country. */ + public boolean catalogProvidesOriginCountry(long pricingRequestItemId) { + return Boolean.TRUE.equals(jdbc.queryForObject(""" + SELECT EXISTS (SELECT 1 FROM sales.pricing_request_item pri + JOIN price_catalog.product_prices pp ON pp.price_id = COALESCE(pri.catalog_price_id, pri.product_id) + JOIN price_catalog.factories f ON f.factory_id = pp.factory_id + WHERE pri.pricing_request_item_id = :id AND f.country IS NOT NULL) + """, Map.of("id", pricingRequestItemId), Boolean.class)); + } + + /** CEO fix: sets (or clears, null) the manual freight THB on one line. */ + public int updateItemManualFreight(long pricingRequestId, long itemId, java.math.BigDecimal freightThb) { + return jdbc.update(""" + UPDATE sales.pricing_request_item SET manual_freight_thb = :f + WHERE pricing_request_item_id = :id AND pricing_request_id = :prId + """, new MapSqlParameterSource().addValue("f", freightThb).addValue("id", itemId).addValue("prId", pricingRequestId)); + } + + public Map findManualFreights(long pricingRequestId) { + Map out = new java.util.HashMap<>(); + jdbc.query("SELECT pricing_request_item_id, manual_freight_thb FROM sales.pricing_request_item WHERE pricing_request_id = :id AND manual_freight_thb IS NOT NULL", + Map.of("id", pricingRequestId), rs -> { out.put(rs.getLong(1), rs.getBigDecimal(2)); }); + return out; + } + + /** 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. + // NULL-safe: ticket_item.brand is NOT NULL (blank for a cleared brand), the item's is NULL. + jdbc.update(""" + UPDATE sales.ticket_item ti SET brand = COALESCE(:brand, ''), model = :model + 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 COALESCE(ti.brand, '') = COALESCE(pri.brand, '') AND COALESCE(ti.model, '') = COALESCE(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..97ca2afb2 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 @@ -1302,13 +1302,22 @@ private boolean hasItemQuantity(String quantityMode, BigDecimal areaSqm, Integer */ private List resolveItems(List items) { List resolved = new ArrayList<>(items.size()); + // The master factory names are read once per save, lazily (only if some line typed a factory). + List> masterNames = new ArrayList<>(1); + java.util.function.Supplier> masters = () -> { + if (masterNames.isEmpty()) { + masterNames.add(requests.findMasterFactoryNames()); + } + return masterNames.get(0); + }; for (int i = 0; i < items.size(); i++) { - resolved.add(resolveItem(i, items.get(i))); + resolved.add(resolveItem(i, items.get(i), masters)); } return resolved; } - private PricingRequestItemRequest resolveItem(int rowNumber, PricingRequestItemRequest rawItem) { + private PricingRequestItemRequest resolveItem(int rowNumber, PricingRequestItemRequest rawItem, + java.util.function.Supplier> masterFactoryNames) { // Stock lines (V194): an unknown source is a caller-fixable 400, not a CHECK-constraint 500. // A blank source normalizes to null (สั่งนำเข้า). String stockSource = hasText(rawItem.stockSource()) ? rawItem.stockSource().trim() : null; @@ -1365,9 +1374,14 @@ 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 typedFactory = hasText(item.factory()) ? item.factory().trim() : null; + String factory = typedFactory == null ? null : masterFactoryNames.get().stream() + .filter(name -> name.trim().equalsIgnoreCase(typedFactory)).findFirst().orElse(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/main/resources/db/migration/V207__pricing_request_item_manual_freight.sql b/backend/src/main/resources/db/migration/V207__pricing_request_item_manual_freight.sql new file mode 100644 index 000000000..7d82bee14 --- /dev/null +++ b/backend/src/main/resources/db/migration/V207__pricing_request_item_manual_freight.sql @@ -0,0 +1,16 @@ +-- V207: CEO-entered freight for a line the freight table has no band for (e.g. 12-16 mm > 800 sqm, +-- 17-20 mm > 450 sqm). Persisted on the item so it survives every cost recalculation and is used only +-- while no band matches (LandedCostCalculator). THB, TOTAL for the line; NULL = none. +ALTER TABLE sales.pricing_request_item + ADD COLUMN manual_freight_thb NUMERIC(18,4), + ADD CONSTRAINT chk_pricing_request_item_manual_freight_positive + CHECK (manual_freight_thb IS NULL OR manual_freight_thb > 0); + +COMMENT ON COLUMN sales.pricing_request_item.manual_freight_thb IS + 'CEO-entered freight in THB (TOTAL for this line) used by costing only when the freight table has no band for the line. Set via PUT /api/pricing-requests/{id}/items/{itemId}/freight.'; + +-- Structured twin of uncostable_reason (comma-separated: MISSING_SQM, MISSING_THICKNESS, MISSING_ORIGIN, +-- NO_FREIGHT_BAND) so the UI picks the right fix without matching Thai text. NULL for a costable line. +ALTER TABLE sales.pricing_costing_item ADD COLUMN uncostable_code VARCHAR(100); +COMMENT ON COLUMN sales.pricing_costing_item.uncostable_code IS + 'Machine-readable reason codes (comma-separated) behind uncostable_reason; NULL when the line costed.'; 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..6a5510df2 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 @@ -1,6 +1,7 @@ package th.co.glr.hr.pricingcosting; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; @@ -47,7 +48,12 @@ 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.FreightRequest; +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; @@ -634,17 +640,14 @@ void missingSqm_perSqmLine_manualCostOverrideRefused409_andNothingIsWritten_clea } @Test - void missingFreightBand_countryNotInFormulaConfig_refusesToPrice_neverZero() { - // "TH" is a genuinely valid, V151-seeded price_catalog.country row (so the catalog INSERT - // itself succeeds) — V109 simply never seeded any Thailand freight rows, so the lookup - // still correctly fails at the freight-band step, not the fixture-setup step. + void missingFreightBand_countryNotInFormulaConfig_setsTheLineAside_neverPricedAsZero() { + // "TH" is a valid V151-seeded price_catalog.country row, but V109 seeded no Thailand freight rows. + // The line is now uncostable (the CEO can enter the freight himself), never priced with F = 0. long pricingRequestId = readyForReviewInCountry("TH", new BigDecimal("10"), "100.00"); - assertThatThrownBy(() -> decisionService.startReview(pricingRequestId, - new StartPricingDecisionRequest(new BigDecimal("0.20"), "THB", null, UUID.randomUUID().toString()), ceoActor)) - .isInstanceOfSatisfying(ApiException.class, e -> { - assertThat(e.getStatus()).isEqualTo(HttpStatus.UNPROCESSABLE_CONTENT); - assertThat(e.getMessage()).contains("ไม่พบอัตราค่าขนส่ง"); - }); + PricingDecisionDto decision = decisionService.startReview(pricingRequestId, + new StartPricingDecisionRequest(new BigDecimal("0.20"), "THB", null, UUID.randomUUID().toString()), ceoActor); + assertThat(costingItemFor(decision).uncostableReason()).contains("ไม่มีอัตราค่าขนส่ง"); + assertThat(decision.items().get(0).frozenLandedCostPerRequestedUnitThb()).isNull(); } @Test @@ -869,6 +872,555 @@ 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)); + assertStatus(HttpStatus.FORBIDDEN, () -> decisionService.setItemFreight(prId, itemId, new FreightRequest(new BigDecimal("1000")), 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)); + assertStatus(HttpStatus.CONFLICT, () -> decisionService.setItemFreight(prId, itemId, new FreightRequest(new BigDecimal("1000")), 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("ต้นทาง=อิตาลี"); + assertThat(item.uncostableCode()).isEqualTo("NO_FREIGHT_BAND"); + } + + @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"); + // The item's brand is now NULL and the deal line's is '' (NOT NULL column): the next rename must still sync. + decisionService.renameItem(prId, itemId, new RenameItemRequest("Again", "M2"), ceoActor); + assertThat(jdbc.queryForMap("SELECT brand, model FROM sales.ticket_item WHERE item_id = :id", Map.of("id", ticketItemId))) + .containsEntry("brand", "Again").containsEntry("model", "M2"); + } + + // ── ไทย-สต็อก (goods already in Thailand): a DOMESTIC purchase, owner ruling ───────────────────── + // cost = Import's price in THB only. No freight lookup, insurance, duty, cost buffer or clearance. + + @Test + void thaiStockOrigin_noCatalogCountry_sameFormulaWithIFTSZero_everyImportComponentZeroNotNull() { + // 100 pieces @ 100.00 THB, 1 sqm/piece: goods = 100.0000/pc; TC = (goods + 0 + 0) x (1 + 0) x 1.07 + // + 0 = 10,700.0000 -> 107.0000/pc (the sheet's x1.07 stays; i = F = T = S = 0). + PricingDecisionDto decision = startedDecisionWithoutCatalogLink("ไทย-สต็อก"); + PricingCostingItemDto item = costingItemFor(decision); + + assertThat(item.uncostableReason()).isNull(); + assertThat(item.goodsCostThb()).isEqualByComparingTo("100.0000"); + assertThat(item.freightCostThb()).isEqualByComparingTo("0"); + assertThat(item.insuranceCostThb()).isEqualByComparingTo("0"); + assertThat(item.importDutyThb()).isEqualByComparingTo("0"); + assertThat(item.inlandTransportCostThb()).isEqualByComparingTo("0"); + assertThat(item.clearanceFeeThb()).isEqualByComparingTo("0"); + assertThat(item.cifCostThb()).isEqualByComparingTo("100.0000"); + assertThat(item.landedCostPerUnitThb()).isEqualByComparingTo("107.0000"); + assertThat(item.totalLandedCostThb()).isEqualByComparingTo("10700.0000"); + assertThat(jdbc.queryForObject("SELECT calculation_snapshot::text FROM sales.pricing_costing_item WHERE pricing_costing_id = :id", + Map.of("id", decision.pricingCostingId()), String.class)).contains("DOMESTIC"); + } + + @Test + void thaiStockLine_inAShipmentWithAnItalyLine_leavesTheItalyLineExactlyAsIfTheThaiLineWereAbsent() { + // Baseline: the Italy line alone (60 sqm). + PricingDecisionDto alone = decisionService.startReview( + readyForReview(new BigDecimal("60"), UnitBasis.PER_PIECE, UnitBasis.PER_PIECE, new BigDecimal("60"), + "100.00", new BigDecimal("1"), null, null), + new StartPricingDecisionRequest(new BigDecimal("0.20"), "THB", null, UUID.randomUUID().toString()), ceoActor); + PricingCostingItemDto baseline = costingItemFor(alone); + + // Same Italy line (60) + a 40 sqm ไทย-สต็อก line on the same factory quote. + long prId = twoItemSameFactoryReadyForReview(new BigDecimal("60"), new BigDecimal("40")); + long thaiItem = 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("UPDATE sales.pricing_request_item SET product_id = NULL, catalog_price_id = NULL, thickness_mm = 10, origin_country = 'ไทย-สต็อก' WHERE pricing_request_item_id = :i", Map.of("i", thaiItem)); + PricingDecisionDto mixed = decisionService.startReview(prId, + new StartPricingDecisionRequest(new BigDecimal("0.20"), "THB", null, UUID.randomUUID().toString()), ceoActor); + List rows = costingRepository.find(mixed.pricingCostingId()).orElseThrow().items(); + PricingCostingItemDto italy = rows.stream().filter(r -> r.pricingRequestItemId() != thaiItem).findFirst().orElseThrow(); + PricingCostingItemDto thai = rows.stream().filter(r -> r.pricingRequestItemId() == thaiItem).findFirst().orElseThrow(); + + // The Thai line takes no part in freight grouping or in the clearance denominator/amount: a domestic + // purchase clears no customs, so it neither fills container space nor dilutes the fee. + assertThat(italy.freightCostThb()).isEqualByComparingTo(baseline.freightCostThb()); + assertThat(italy.importDutyThb()).isEqualByComparingTo(baseline.importDutyThb()); + assertThat(italy.clearanceFeeThb()).isEqualByComparingTo(baseline.clearanceFeeThb()); + assertThat(italy.landedCostPerUnitThb()).isEqualByComparingTo(baseline.landedCostPerUnitThb()); + assertThat(italy.totalLandedCostThb()).isEqualByComparingTo(baseline.totalLandedCostThb()); + assertThat(thai.landedCostPerUnitThb()).isEqualByComparingTo("107.0000"); + assertThat(thai.clearanceFeeThb()).isEqualByComparingTo("0"); + } + + @Test + void ceoCanSetThaiStockOrigin_andTheLineRecalculatesAsDomestic() { + PricingDecisionDto decision = startedDecisionWithoutCatalogLink(null); + assertThat(costingItemFor(decision).uncostableCode()).isEqualTo("MISSING_ORIGIN"); + + PricingDecisionDto after = decisionService.setItemOriginCountry(decision.pricingRequestId(), + decision.items().get(0).pricingRequestItemId(), new OriginCountryRequest("ไทย-สต็อก"), ceoActor).decision(); + + PricingCostingItemDto item = costingItemFor(after); + assertThat(item.uncostableReason()).isNull(); + assertThat(item.landedCostPerUnitThb()).isEqualByComparingTo("107.0000"); + } + + // ── Freight-table gaps: the CEO enters F (THB, TOTAL for that line) instead of overriding the landed cost ── + + /** A catalogue Italy line at 100 mm: outside every seeded freight band. */ + private long gapLineReadyForReview() { + long productId = insertCatalogProduct("Formula Factory", "IT", "GAP-" + UUID.randomUUID(), + new BigDecimal("100.00"), "THB", "per_piece", "ACTIVE", new BigDecimal("100")); + return readyForReviewWithProduct(productId, "Formula Factory", new BigDecimal("100"), UnitBasis.PER_PIECE, + UnitBasis.PER_PIECE, new BigDecimal("100"), "100.00", new BigDecimal("1"), null, null); + } + + private PricingDecisionDto start(long prId) { + return decisionService.startReview(prId, + new StartPricingDecisionRequest(new BigDecimal("0.20"), "THB", null, UUID.randomUUID().toString()), ceoActor); + } + + /** + * Gap line (no band), CEO F = 12,000.00 THB for the line. Same formula, hand-computed: + * C = 10,000.0000; i = 55.3725; F = 12,000; cif = 22,055.3725; duty 30% = 6,616.61175 -> 6,616.6118; + * (cif+duty) x 1.07 = 28,671.9843 x 1.07 = 30,679.0232; S (qty 100) = 8,000; TC = 38,679.0232; + * per piece = 386.7902 (money4); total = 386.7902 x 100 = 38,679.0200. + */ + @Test + void freightGap_ceoEntersF_sameFormulaWithThatF_everyComponent_andItSurvivesRecalculation() { + long prId = gapLineReadyForReview(); + assertThatCode(() -> start(prId)).as("a freight gap must not abort the whole costing").doesNotThrowAnyException(); + PricingDecisionDto decision = decisionRepository.findOpenDraft(prId).orElseThrow(); + PricingCostingItemDto gap = costingItemFor(decision); + assertThat(gap.uncostableReason()).contains("ไม่มีอัตราค่าขนส่ง").doesNotContain("ประเทศต้นทาง"); + assertThat(gap.uncostableCode()).isEqualTo("NO_FREIGHT_BAND"); + + long itemId = decision.items().get(0).pricingRequestItemId(); + PricingDecisionDto after = decisionService.setItemFreight(prId, itemId, new FreightRequest(new BigDecimal("12000")), ceoActor).decision(); + + PricingCostingItemDto item = costingItemFor(after); + assertThat(item.uncostableReason()).isNull(); + assertThat(item.goodsCostThb()).isEqualByComparingTo("100.0000"); + assertThat(item.insuranceCostThb()).isEqualByComparingTo("0.5537"); + assertThat(item.freightCostThb()).isEqualByComparingTo("120.0000"); + assertThat(item.cifCostThb()).isEqualByComparingTo("220.5537"); + assertThat(item.importDutyThb()).isEqualByComparingTo("66.1661"); + assertThat(item.clearanceFeeThb()).isEqualByComparingTo("80.0000"); + assertThat(item.landedCostPerUnitThb()).isEqualByComparingTo("386.7902"); + assertThat(item.totalLandedCostThb()).isEqualByComparingTo("38679.0200"); + assertThat(eventMessages(prId, "PRICING_REQUEST_ITEM_FREIGHT_SET")).singleElement() + .satisfies(m -> assertThat(m).contains("12000")); + + // Survives a recalculation (persisted on the item) ... + PricingDecisionDto recalculated = decisionService.recalculateCost(after.id(), ceoActor); + assertThat(costingItemFor(recalculated).landedCostPerUnitThb()).isEqualByComparingTo("386.7902"); + // ... and clearing it returns the line to uncostable. + PricingDecisionDto cleared = decisionService.setItemFreight(prId, itemId, new FreightRequest(null), ceoActor).decision(); + assertThat(costingItemFor(cleared).uncostableReason()).contains("ไม่มีอัตราค่าขนส่ง"); + } + + @Test + void freightGap_ceoFreightEnteredBeforeAnyDecision_isSavedAndUsedByStartReview() { + long prId = gapLineReadyForReview(); + 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); + + assertThat(decisionService.setItemFreight(prId, itemId, new FreightRequest(new BigDecimal("12000")), ceoActor).decision()).isNull(); + + assertThat(costingItemFor(start(prId)).landedCostPerUnitThb()).isEqualByComparingTo("386.7902"); + } + + @Test + void freightFromTheTable_winsOverAStrayManualValue_andBadInputIsRefused() { + PricingDecisionDto decision = startedDecision(); // Italy 10 mm, band exists: F = 50,000 -> 500.0000/pc + long prId = decision.pricingRequestId(); + long itemId = decision.items().get(0).pricingRequestItemId(); + + PricingDecisionDto after = decisionService.setItemFreight(prId, itemId, new FreightRequest(new BigDecimal("7")), ceoActor).decision(); + + assertThat(costingItemFor(after).freightCostThb()).as("a manual F only fills a gap").isEqualByComparingTo("500.0000"); + assertStatus(HttpStatus.BAD_REQUEST, () -> decisionService.setItemFreight(prId, itemId, new FreightRequest(BigDecimal.ZERO), ceoActor)); + assertStatus(HttpStatus.BAD_REQUEST, () -> decisionService.setItemFreight(prId, itemId, new FreightRequest(new BigDecimal("-5")), 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.setItemFreight(prId, itemId, new FreightRequest(new BigDecimal("100")), ceoActor)); + } + + private void publishConfigWithGroupedFreightGap() { + publishConfigWithGappedClearanceLadder(); + // Undo that helper's clearance gap (keep the normal ladder) and open a freight gap that only a GROUP hits: + // IT 17-21 mm now has [101,401) but nothing from 401, so a 200 sqm line passes alone while a full + // 450 sqm container (600 sqm grouped) finds no band. + Long id = jdbc.queryForObject("SELECT formula_config_id FROM sales.pricing_formula_config WHERE is_current = TRUE", Map.of(), Long.class); + jdbc.update("DELETE FROM sales.pricing_clearance_fee WHERE formula_config_id = :id", Map.of("id", id)); + jdbc.update("INSERT INTO sales.pricing_clearance_fee (formula_config_id, qty_min_sqm, qty_max_sqm, amount_thb) VALUES (:id, 1, 101, 8000), (:id, 101, 451, 12000), (:id, 451, NULL, 20000)", Map.of("id", id)); + jdbc.update("DELETE FROM sales.pricing_freight_rate WHERE formula_config_id = :id AND origin_country_code = 'IT' AND thickness_min_mm = 17 AND qty_min_sqm >= 101", Map.of("id", id)); + jdbc.update("INSERT INTO sales.pricing_freight_rate (formula_config_id, origin_country_code, thickness_min_mm, thickness_max_mm, qty_min_sqm, qty_max_sqm, amount_thb) VALUES (:id, 'IT', 17, 21, 101, 401, 100000)", Map.of("id", id)); + } + + /** Two 18 mm Italy lines of 200 sqm on one quote: each passes the freight table alone (remainder 200 <= 250), but 400 grouped is "nearly full" -> one full 450 container, which has no band here. */ + @Test + void freightGap_inAGroupedShipment_setsTheGroupAside_andCeoFreightOnOneLineLetsTheOtherCost() { + publishConfigWithGroupedFreightGap(); + long prId = twoItemSameFactoryReadyForReview(new BigDecimal("200"), new BigDecimal("18"), new BigDecimal("200"), new BigDecimal("18")); + jdbc.update("UPDATE sales.pricing_request_item SET origin_country = 'อิตาลี' WHERE pricing_request_id = :id", Map.of("id", prId)); + + PricingDecisionDto decision = start(prId); + + List rows = costingRepository.find(decision.pricingCostingId()).orElseThrow().items(); + assertThat(rows).allSatisfy(r -> { + assertThat(r.uncostableCode()).isEqualTo("NO_FREIGHT_BAND"); + assertThat(r.uncostableReason()).contains("ต้นทาง=อิตาลี"); + }); + long itemA = rows.get(0).pricingRequestItemId(); + + PricingDecisionDto after = decisionService.setItemFreight(prId, itemA, new FreightRequest(new BigDecimal("20000")), ceoActor).decision(); + + List after2 = costingRepository.find(after.pricingCostingId()).orElseThrow().items(); + PricingCostingItemDto a = after2.stream().filter(r -> r.pricingRequestItemId() == itemA).findFirst().orElseThrow(); + PricingCostingItemDto b = after2.stream().filter(r -> r.pricingRequestItemId() != itemA).findFirst().orElseThrow(); + assertThat(a.uncostableReason()).isNull(); + assertThat(a.freightCostThb()).as("CEO total 20,000 / 200 pieces").isEqualByComparingTo("100.0000"); + assertThat(b.uncostableReason()).as("the other line now looks up alone (200 sqm passes)").isNull(); + assertThat(b.freightCostThb()).as("table band 100,000 / 200 pieces").isEqualByComparingTo("500.0000"); + } + + @Test + void ceoOriginAndRenameEvents_nameTheLinesPositionNotItsDatabaseId() { + readyForReview(new BigDecimal("10"), UnitBasis.PER_PIECE, UnitBasis.PER_PIECE, new BigDecimal("10"), "100.00", new BigDecimal("1"), null, null); // burns item ids + long prId = twoItemSameFactoryReadyForReview(new BigDecimal("60"), new BigDecimal("40")); + PricingDecisionDto decision = start(prId); + long second = decision.items().get(1).pricingRequestItemId(); + + decisionService.renameItem(prId, second, new RenameItemRequest("Second", "Line"), ceoActor); + + assertThat(eventMessages(prId, "PRICING_REQUEST_ITEM_RENAMED")).singleElement() + .satisfies(m -> assertThat(m).contains("รายการที่ 2 ").doesNotContain("รายการที่ " + second + " ")); + } + + @Test + void setItemOriginCountry_onALineWhoseCatalogueProvidesACountry_is409() { + PricingDecisionDto decision = startedDecision(); // catalogue-linked Italy line + assertStatus(HttpStatus.CONFLICT, () -> decisionService.setItemOriginCountry(decision.pricingRequestId(), + decision.items().get(0).pricingRequestItemId(), new OriginCountryRequest("จีน"), ceoActor)); + } + 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/CeoReviewNoticeTest.java b/backend/src/test/java/th/co/glr/hr/pricingrequest/CeoReviewNoticeTest.java index 50a9b85db..209a155f0 100644 --- a/backend/src/test/java/th/co/glr/hr/pricingrequest/CeoReviewNoticeTest.java +++ b/backend/src/test/java/th/co/glr/hr/pricingrequest/CeoReviewNoticeTest.java @@ -184,6 +184,6 @@ private static PricingCostingWriteItem cost(long itemId, String landedPerUnit, S return new PricingCostingWriteItem(itemId, 1L, 1L, 1, null, "F", null, null, null, null, null, null, null, null, null, null, sqmPerUnit == null ? null : new BigDecimal(sqmPerUnit), null, null, null, null, null, 1L, 1, null, null, null, null, null, null, null, - landedPerUnit == null ? null : new BigDecimal(landedPerUnit), null, null, null, null, uncostableReason); + landedPerUnit == null ? null : new BigDecimal(landedPerUnit), null, null, null, null, uncostableReason, null); } } 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..98eadf0ab 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,65 @@ 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"); + // The master names are read ONCE per save, not once per line. + when(requestRepo.findMasterFactoryNames()).thenReturn(master.map(List::of).orElse(List.of("Unrelated Factory"))); + 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()).findMasterFactoryNames(); + } + + @Test + void createDraft_readsTheMasterFactoryNamesOnceForManyLines() { + stubTicket(10L, 1L, DealLifecycle.ACTIVE); + when(requestRepo.findItemIdsForTicket(10L)).thenReturn(List.of()); + when(requestRepo.nextRequestCode()).thenReturn("PCR-2026-0001"); + when(requestRepo.findMasterFactoryNames()).thenReturn(List.of("Padana")); + 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, "padana"), + sampleItemRequest(null, QuantityType.REFERENCE, "Padana "), + sampleItemRequest(null, QuantityType.REFERENCE, "Other"))), salesActor); + + verify(requestRepo, org.mockito.Mockito.times(1)).findMasterFactoryNames(); + } + // ── item identity (Part 1: an unidentified product must be rejected) ─────── @Test @@ -1610,8 +1669,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..562ab72d1 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" : 395, "deprecatedCount" : 3, "operations" : [ "DELETE /api/attachments/{id}", @@ -390,7 +390,11 @@ "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}/freight", + "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..6f9c84290 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": 329, "operations": [ "DELETE /api/attachments/{id}", "DELETE /api/catalog/prices/{priceId}", @@ -323,7 +323,11 @@ "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}/freight", + "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..08b9d50ed 100644 --- a/frontend/src/api/hrApi.js +++ b/frontend/src/api/hrApi.js @@ -1166,6 +1166,16 @@ 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 }), + // payload { freightThb } - THB total for a line the freight table has no band for; null clears it. + setItemFreight: (id, itemId, payload) => + apiRequest(API_ROUTES.pricingRequests.itemFreight(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..d82278d6e 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,80 @@ 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); + if (item.productId != null || item.catalogPriceId != null) fail('รายการนี้ใช้ประเทศต้นทางจาก Price Catalog จึงตั้งประเทศต้นทางเองไม่ได้', 409); + 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 setItemFreight(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 freight = payload?.freightThb == null ? null : Number(payload.freightThb); + if (freight != null && !(freight > 0)) fail('ค่าขนส่งต้องมากกว่า 0', 400); + if (item.stockSource) fail('รายการจากสต็อกไม่มีค่าขนส่งนำเข้า', 400); + item.manualFreightThb = freight; + pushPricingRequestEvent(pr, user, 'PRICING_REQUEST_ITEM_FREIGHT_SET', pr.status, pr.status, + freight == null ? 'CEO ล้างค่าขนส่งที่กรอกเอง' : `CEO กรอกค่าขนส่ง ${freight} บาท`); + 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) > 80) fail('ยี่ห้อต้องยาวไม่เกิน 255 ตัวอักษร และรุ่นไม่เกิน 80 ตัวอักษร', 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..983c1288b 100644 --- a/frontend/src/api/routes.js +++ b/frontend/src/api/routes.js @@ -502,6 +502,12 @@ 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`, + itemFreight: (id, itemId) => `/api/pricing-requests/${id}/items/${itemId}/freight`, 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(''); } }}>
@@ -44,8 +44,10 @@ export function SearchableCombobox({ id, label, value, options, onChange, disabl {!loading && !filtered.length ?
  • ไม่พบข้อมูล
  • : null} {!loading && filtered.map((item, index) =>
  • + className={`min-h-11 w-full cursor-pointer border-0 disabled:cursor-not-allowed disabled:text-text-muted px-3 py-2 text-left text-sm text-text hover:bg-surface-hover ${index === active ? 'bg-surface-hover' : 'bg-surface'}`} + disabled={item.disabled} onMouseDown={(e) => e.preventDefault()} onClick={() => select(item)} onMouseEnter={() => setActive(index)}> + {item.nameTh}{item.hint ? {item.hint} : null} +
  • )} : null}
    diff --git a/frontend/src/features/pricingRequests/CeoPricingWorkspace.jsx b/frontend/src/features/pricingRequests/CeoPricingWorkspace.jsx index e538afa26..5fda10384 100644 --- a/frontend/src/features/pricingRequests/CeoPricingWorkspace.jsx +++ b/frontend/src/features/pricingRequests/CeoPricingWorkspace.jsx @@ -310,9 +310,10 @@ export function CostCalculation({ row, priceMode }) { {b.insFactor != null ? `C × ${nf(b.insFactor)} × ${nf(b.insRate)} × ${nf(b.insBuffer)}` : 'C × อัตราค่าประกันภัย'} {b.insFactor != null ? {`= ${n2(b.goods)} × ${nf(b.insFactor)} × ${nf(b.insRate)} × ${nf(b.insBuffer)}`} : null} - - {perContainer(b.freight)}{share(b.freight, b.freight.groupQtySqm) ?? n2(b.freight.share)} - {freightContainers ? ( + + {b.freight.manual ? n2(b.freight.share) : <>{perContainer(b.freight)}{share(b.freight, b.freight.groupQtySqm) ?? n2(b.freight.share)}} + {b.freight.manual ? กรอกโดย CEO เพราะไม่มีอัตราค่าขนส่งในตาราง : null} + {!b.freight.manual && freightContainers ? ( {byUnits ? `${freightContainers} · ความจุ ${nf(b.containerSqm)} ตร.ม./ตู้ · เศษเกิน ${nf(b.freight.roundUpAboveSqm)} ตร.ม. ปัดเป็นตู้เต็ม · แบ่งตามพื้นที่ในตู้` : `${freightContainers} = ${n2(b.freight.groupQtySqm)} ตร.ม. ÷ ${nf(b.containerSqm ?? 800)} ตร.ม./ตู้ (เศษเกิน 75% ปัดเป็นตู้เต็ม) · แบ่งตาม ตร.ม. กับรายการอื่นในตู้เดียวกัน`} @@ -358,7 +359,10 @@ function Details({ row, model }) { return (
    -

    สินค้า

    +
    +

    สินค้า

    + {row.renameNode} +
    {row.productNode}
    @@ -382,6 +386,8 @@ function Details({ row, model }) {
    คำนวณต้นทุนอัตโนมัติไม่ได้ {row.costingItem.uncostableReason} + {row.originFix} + {row.freightFix}
    ) : null} {row.stale ? ( diff --git a/frontend/src/features/pricingRequests/PricingRequestDetailPage.jsx b/frontend/src/features/pricingRequests/PricingRequestDetailPage.jsx index 9fe10b3b5..93721580f 100644 --- a/frontend/src/features/pricingRequests/PricingRequestDetailPage.jsx +++ b/frontend/src/features/pricingRequests/PricingRequestDetailPage.jsx @@ -83,7 +83,7 @@ import { import { useUnitBasisCatalog } from './unitBasisCatalog.js'; import { buttonVariants } from '../../components/common/Button.jsx'; import { cn } from '../../utils/cn.js'; -import { acceptOutcomeConfirmCopy, piecesPerSqmFromSqmPerPiece, dealQuotationStatusLabel, dropOutcomeKey, generateClientRequestId, newestQuotationByRequest, outcomeKeyFor, outcomeReasonConfirmCopy, shownCustomerQuotation } from '../quotations/quotationMeta.js'; +import { acceptOutcomeConfirmCopy, piecesPerSqmFromSqmPerPiece, dealQuotationStatusLabel, dropOutcomeKey, generateClientRequestId, newestQuotationByRequest, outcomeKeyFor, ORIGIN_COUNTRY_OPTIONS, outcomeReasonConfirmCopy, shownCustomerQuotation } from '../quotations/quotationMeta.js'; import { SearchableCombobox } from '../../components/common/SearchableCombobox.jsx'; // Reuses the catalog page's add-factory dialog instead of a second copy of the same fields and // validation (see ImportFactoryPicker below). @@ -94,6 +94,10 @@ function isImport(user) { return user?.role === 'import'; } +function isCeo(user) { + return user?.role === 'ceo'; +} + function isSales(user) { return user?.role === 'sales'; } @@ -459,6 +463,110 @@ function ImportFactoryPicker({ ); } +// CEO price-review fixes (PCR-2026-0005). The server guards (CEO only, open DRAFT decision) are in +// PricingDecisionService; these forms only render while the CEO can edit, and each save recalculates cost. +const SAVED_NOT_COSTED = 'บันทึกแล้ว แต่ยังคำนวณต้นทุนไม่ได้'; +const FACTORY_TAKEN_HINT = 'มีใบเสนอราคาโรงงานในคำขอนี้แล้ว'; + +/** A current factory quote whose saved name is not a master factory (so costing cannot find its config). */ +function CeoFactoryQuoteFix({ quote, factories, takenNames, value, onChangeValue, onSave, saving }) { + const options = factories.map((f) => { + const taken = takenNames.has(f.name); + return { code: String(f.factoryId), nameTh: f.name, disabled: taken, hint: taken ? FACTORY_TAKEN_HINT : undefined }; + }); + const id = `pcr-ceo-fix-factory-select-${quote.id}`; + return ( +
    + ชื่อโรงงานในใบเสนอราคาไม่ตรงกับรายชื่อโรงงาน +

    + {`${quote.quoteCode ?? `ใบเสนอราคาโรงงาน #${quote.id}`} · ชื่อที่บันทึกไว้ "${quote.factoryName}" · ${quote.items?.length ?? 0} รายการ · ราคาของฝ่ายนำเข้าไม่เปลี่ยน`} +

    + { if (value && !saving) onSave(); }}> +
    + + + +
    + +
    +
    + ); +} + +const ORIGIN_FIX_OPTIONS = ORIGIN_COUNTRY_OPTIONS.filter((o) => ['อิตาลี', 'สเปน', 'จีน', 'ไทย-สต็อก'].includes(o.code)); + +/** Inside a line's "คำนวณต้นทุนอัตโนมัติไม่ได้" box when it has no catalogue link to read an origin from. */ +function CeoOriginCountryFix({ itemId, defaultValue, onSave, saving }) { + const [value, setValue] = useState(ORIGIN_FIX_OPTIONS.some((o) => o.code === defaultValue) ? defaultValue : ''); + const id = `pcr-ceo-origin-select-${itemId}`; + return ( +
    + +
    + + +
    +

    หรือกด “ปรับต้นทุนเอง” ด้านบนเพื่อกรอกต้นทุนต่อแผ่นเองแทน

    +
    + ); +} + +function uncostableCodes(costingItem) { + return (costingItem?.uncostableCode ?? '').split(','); +} + +/** For a line the freight table has no band for: the CEO types the freight (THB, total for the line). */ +function CeoFreightFix({ itemId, onSave, saving }) { + const [value, setValue] = useState(''); + const amount = Number(value); + const id = `pcr-ceo-freight-input-${itemId}`; + return ( +
    + +
    + setValue(e.target.value)} /> + +
    +

    ไม่มีอัตราค่าขนส่งในตารางสำหรับรายการนี้ — กรอกค่าขนส่งรวมของรายการนี้เท่านั้น (ไม่รวมรายการอื่นในตู้เดียวกัน) ระบบคำนวณส่วนที่เหลือตามสูตร

    +
    + ); +} + +/** "แก้ชื่อ" beside the item name in the CEO details: ยี่ห้อ + รุ่น, the pair shown and copied to the quotation. */ +function CeoRenameItem({ requestItem, saving, onSave }) { + const [open, setOpen] = useState(false); + const [brand, setBrand] = useState(''); + const [model, setModel] = useState(''); + if (!open) { + return ( + + ); + } + return ( + onSave({ brand: brand.trim(), model: model.trim() }, () => setOpen(false))}> + + setBrand(e.target.value)} /> + + + setModel(e.target.value)} /> + +
    + + +
    +
    + ); +} + /** * The factory-quote mail composer, in a modal behind each factory card's "สร้างเมล" button. * @@ -837,7 +945,8 @@ export function PricingRequestDetailPage({ user, showToast }) { const factoriesQuery = useQuery({ queryKey: queryKeys.priceImportFactories(), queryFn: () => api.priceImport.factories(), - enabled: isImport(user), + // The CEO reads it too (GET /api/price-import/factories is ceo/import): the "fix this quote's factory" form. + enabled: isImport(user) || isCeo(user), staleTime: 60 * 1000, }); const countriesQuery = useQuery({ @@ -1013,10 +1122,11 @@ export function PricingRequestDetailPage({ user, showToast }) { function useActionMutation(fn, successMessage, options = {}) { return useMutation({ mutationFn: fn, - onSuccess: () => { - if (successMessage) showToast?.('success', successMessage); + onSuccess: (data) => { + const message = typeof successMessage === 'function' ? successMessage(data) : successMessage; + if (message) showToast?.('success', message); if (!options.skipInvalidate) invalidate(); - options.onSuccess?.(); + options.onSuccess?.(data); }, onError: options.onError ?? ((error) => showToast?.('error', error.message || 'ดำเนินการไม่สำเร็จ')), }); @@ -1381,6 +1491,38 @@ export function PricingRequestDetailPage({ user, showToast }) { onError: (error) => setCeoCostingError(error.message || 'คำนวณต้นทุนใหม่ไม่สำเร็จ'), }, ); + // CEO price-review fixes (server: PricingDecisionService). The first two recalculate cost, so their + // failure (LandedCostCalculator's multi-line 422) renders inline like recalculateDecisionCost's. + const [factoryFixDrafts, setFactoryFixDrafts] = useState({}); + const repointFactoryQuote = useActionMutation( + ({ quoteId, factoryId }) => api.pricingRequests.repointFactoryQuote(pricingRequestId, quoteId, { factoryId }), + (data) => (data?.costingError ? SAVED_NOT_COSTED : 'เปลี่ยนโรงงานแล้ว'), + { + // The fix is saved even when the recalculation still fails on another problem: show that error. + onSuccess: (data) => setCeoCostingError(data?.costingError ?? null), + onError: (error) => setCeoCostingError(error.message || 'เปลี่ยนโรงงานไม่สำเร็จ'), + }, + ); + const setItemOriginCountry = useActionMutation( + ({ itemId, originCountry }) => api.pricingRequests.setItemOriginCountry(pricingRequestId, itemId, { originCountry }), + (data) => (data?.costingError ? SAVED_NOT_COSTED : 'บันทึกประเทศต้นทางแล้ว'), + { + onSuccess: (data) => setCeoCostingError(data?.costingError ?? null), + onError: (error) => setCeoCostingError(error.message || 'บันทึกประเทศต้นทางไม่สำเร็จ'), + }, + ); + const setItemFreight = useActionMutation( + ({ itemId, freightThb }) => api.pricingRequests.setItemFreight(pricingRequestId, itemId, { freightThb }), + (data) => (data?.costingError ? SAVED_NOT_COSTED : 'บันทึกค่าขนส่งแล้ว'), + { + onSuccess: (data) => setCeoCostingError(data?.costingError ?? null), + onError: (error) => setCeoCostingError(error.message || 'บันทึกค่าขนส่งไม่สำเร็จ'), + }, + ); + const renameItem = useActionMutation( + ({ itemId, brand, model }) => api.pricingRequests.renameItem(pricingRequestId, itemId, { brand, model }), + 'แก้ชื่อรายการแล้ว', + ); // A per-line manual cost override, BESIDE the computed figure (never destroyed). `reason` is // mandatory in both directions: the modal refuses to call this without one; the server's check is // the backstop. @@ -1652,6 +1794,11 @@ export function PricingRequestDetailPage({ user, showToast }) { detailQuery.isError, factoryQuery.isError, request, factoryQuotes], ); const factoryGroups = useMemo(() => groupFactoryQuotesByFactory(factoryQuotes), [factoryQuotes]); + // CEO fix: current quotes whose saved name is not a master factory (costing looks the factory up by exact + // name). Only judged once the roster has loaded, so a slow fetch never flags a good quote. + const liveQuotes = factoryQuotes.filter((q) => q.current && !['CANCELLED', 'SUPERSEDED'].includes(q.status)); + const currentQuoteNames = liveQuotes.map((q) => q.factoryName); + const masterFactoryNames = new Set((factoriesQuery.data ?? []).map((f) => f.name)); // pricingRequestItemId -> that item's line on the CURRENT factory quote, once Import has priced it. const importQuoteItemByRequestItem = useMemo(() => { const byItem = new Map(); @@ -2006,8 +2153,34 @@ export function PricingRequestDetailPage({ user, showToast }) { {`จำนวนที่กรอก: ${formatQuantityAsEntered(requestItem)} · เผื่อ (wastage): ${formatWastage(requestItem)}`} ) : {name}; + // CEO fixes inside the details: set the origin country the freight lookup lacks (no catalogue link to + // read one from), and rename the line. Both only while the decision is editable. + const noCatalogLink = requestItem != null && requestItem.productId == null && requestItem.catalogPriceId == null; + const originFix = editable && requestItem && !isStockLine && noCatalogLink + && uncostableCodes(costingItem).includes('MISSING_ORIGIN') ? ( + setItemOriginCountry.mutate({ itemId: requestItem.id, originCountry })} + /> + ) : null; + const freightFix = editable && requestItem && !isStockLine && uncostableCodes(costingItem).includes('NO_FREIGHT_BAND') ? ( + setItemFreight.mutate({ itemId: requestItem.id, freightThb })} + /> + ) : null; + const renameNode = editable && requestItem ? ( + renameItem.mutate({ itemId: requestItem.id, ...names }, { onSuccess: onDone })} + /> + ) : null; return { - item, costingItem, position, name, productNode, editable, + item, costingItem, position, name, productNode, originFix, freightFix, renameNode, editable, size: requestItem?.size, factoryLabel: isStockLine ? 'สต็อก' : (factoryName ?? item.factoryName), isStock: isStockLine, @@ -2400,6 +2573,18 @@ export function PricingRequestDetailPage({ user, showToast }) {
    ) : null} + {brokenFactoryQuotes.map((quote) => ( + n !== quote.factoryName))} + value={factoryFixDrafts[quote.id] ?? ''} + onChangeValue={(code) => setFactoryFixDrafts((current) => ({ ...current, [quote.id]: code }))} + saving={repointFactoryQuote.isPending} + onSave={() => repointFactoryQuote.mutate({ quoteId: quote.id, factoryId: Number(factoryFixDrafts[quote.id]) })} + /> + ))} ); } @@ -2476,6 +2661,11 @@ export function PricingRequestDetailPage({ user, showToast }) { // (PricingDecisionItemDto#pricingRequestItemId); index order is only the fallback for a DTO that // omits it. A decision item that matches no request item row is rendered after the rows, never dropped. const ceoState = ceoWorkspace && currentDecision ? ceoDecisionState(currentDecision) : null; + // Also offered before any decision exists (READY_FOR_CEO_REVIEW): that is where a failed startReview leaves it. + const ceoCanFixQuotes = ceoState?.editable + || (ceoWorkspace && needsFreshReview && summary?.status === 'READY_FOR_CEO_REVIEW'); + const brokenFactoryQuotes = ceoCanFixQuotes && factoriesQuery.data + ? liveQuotes.filter((q) => !masterFactoryNames.has(q.factoryName)) : []; const ceoPricingByRequestItem = new Map(); const ceoUnmatchedDecisionItems = []; if (ceoState?.newForm) { diff --git a/frontend/src/features/pricingRequests/PricingRequestDetailPage.test.jsx b/frontend/src/features/pricingRequests/PricingRequestDetailPage.test.jsx index 0e2f3b75b..3d9f28f70 100644 --- a/frontend/src/features/pricingRequests/PricingRequestDetailPage.test.jsx +++ b/frontend/src/features/pricingRequests/PricingRequestDetailPage.test.jsx @@ -55,6 +55,10 @@ vi.mock('../../api/index.js', () => ({ updatePricingDecision: vi.fn(), recalculatePricingDecisionCost: vi.fn(), overridePricingDecisionItemCost: vi.fn(), + repointFactoryQuote: vi.fn(), + setItemOriginCountry: vi.fn(), + renameItem: vi.fn(), + setItemFreight: vi.fn(), approvePricingDecision: vi.fn(), returnPricingDecisionToImport: vi.fn(), // Step 4: Customer Quotation Generation and Issuance. @@ -8196,3 +8200,154 @@ describe('Import PCR redesign — factory-first page (import role)', () => { ); }); }); + +// CEO price-review fixes (PCR-2026-0005): re-point a typo'd factory quote, set the origin country on +// a line with no catalogue link, rename a line. Server guards (CEO only, open DRAFT only) are pinned +// in LandedCostCalculatorFormulaIntegrationTest; here only what the UI offers and sends. +describe('CEO fixes during price review', () => { + const badQuote = (overrides = {}) => buildFactoryQuote({ + id: 91, quoteCode: 'FQ-2026-0001', factoryName: 'Padana marmoker', ...overrides, + }); + + it('A: a current quote whose factory is not a master factory gets an inline fix; saving re-points it by factoryId', async () => { + await renderCeoNewForm({ factoryQuotes: [badQuote()] }); + + const form = await screen.findByTestId('pcr-ceo-fix-factory-91'); + expect(form.textContent).toContain('FQ-2026-0001'); + expect(form.textContent).toContain('Padana marmoker'); + expect(form.textContent).toContain('1 รายการ'); + expect(form.textContent).toContain('ราคาของฝ่ายนำเข้าไม่เปลี่ยน'); + fireEvent.focus(within(form).getByLabelText('โรงงานที่ถูกต้อง')); + fireEvent.click(await screen.findByRole('option', { name: 'Cotto Industry' })); + fireEvent.click(within(form).getByRole('button', { name: 'บันทึกและคำนวณต้นทุนใหม่' })); + + await waitFor(() => expect(api.pricingRequests.repointFactoryQuote).toHaveBeenCalledWith(501, 91, { factoryId: 602 })); + }); + + it('A: no fix form when the quote already names a master factory, or when the decision is no longer editable', async () => { + const { unmount } = await renderCeoNewForm({ factoryQuotes: [buildFactoryQuote()] }); + await waitFor(() => expect(api.priceImport.factories).toHaveBeenCalled()); + expect(screen.queryByTestId('pcr-ceo-fix-factory-91')).toBeNull(); + unmount(); + + await renderCeoNewForm({ + factoryQuotes: [badQuote()], + decisions: [buildDecision({ status: 'APPROVED', priceMode: 'NET', items: [nfItem({ listUnitPrice: 100, netUnitPrice: 100 })] })], + }); + await waitFor(() => expect(api.priceImport.factories).toHaveBeenCalled()); + expect(screen.queryByTestId('pcr-ceo-fix-factory-91')).toBeNull(); + }); + + it('A: a factory that already has a quote in this request is listed but disabled', async () => { + await renderCeoNewForm({ factoryQuotes: [badQuote(), buildFactoryQuote({ id: 92, quoteCode: 'FQ-2026-0002', factoryName: 'SCG Ceramics' })] }); + + const form = await screen.findByTestId('pcr-ceo-fix-factory-91'); + fireEvent.focus(within(form).getByLabelText('โรงงานที่ถูกต้อง')); + const taken = await screen.findByRole('option', { name: /SCG Ceramics.*มีใบเสนอราคาโรงงานในคำขอนี้แล้ว/ }); + expect(taken.disabled).toBe(true); + expect(screen.getByRole('option', { name: 'Cotto Industry' }).disabled).toBe(false); + }); + + const uncostableOrigin = (requestItemOverrides = {}) => ({ + requestItems: [{ ...buildRequest().items[0], originCountry: 'อิตาลี', ...requestItemOverrides }], + costings: [buildCosting({ id: 601, items: [buildCostingItemWithOverride({ id: 1, uncostableReason: 'ไม่มีประเทศต้นทาง (origin country) จาก Price Catalog', uncostableCode: 'MISSING_ORIGIN' })] })], + items: [nfItem({ pricingCostingItemId: 1, frozenLandedCostPerRequestedUnitThb: null, netUnitPrice: null })], + }); + + it('B: an uncostable line with no catalogue link offers a country select defaulting to the sales value, and saves the pick', async () => { + // An uncostable row starts open (row.defaultOpen), so the details are already showing. + await renderCeoNewForm(uncostableOrigin()); + + const box = await ceoTable().findByTestId('pcr-ceo-origin-fix-8001'); + const select = within(box).getByLabelText('ประเทศต้นทาง'); + expect(select.value).toBe('อิตาลี'); + expect(box.textContent).toContain('ปรับต้นทุนเอง'); + fireEvent.change(select, { target: { value: 'ไทย-สต็อก' } }); + fireEvent.click(within(box).getByRole('button', { name: 'บันทึกและคำนวณต้นทุนใหม่' })); + + await waitFor(() => expect(api.pricingRequests.setItemOriginCountry).toHaveBeenCalledWith(501, 1, { originCountry: 'ไทย-สต็อก' })); + }); + + it('B: no country select when the line has a catalogue link', async () => { + await renderCeoNewForm(uncostableOrigin({ productId: 77, catalogPriceId: 77 })); + await ceoTable().findByTestId('pcr-ceo-details-8001'); + expect(ceoTable().queryByTestId('pcr-ceo-origin-fix-8001')).toBeNull(); + }); + + it('C: แก้ชื่อ opens a form prefilled with ยี่ห้อ/รุ่น and saves the trimmed pair', async () => { + await renderCeoNewForm(); + openRowDetails(); + + fireEvent.click(await ceoTable().findByRole('button', { name: 'แก้ชื่อ' })); + const brand = ceoTable().getByLabelText('ยี่ห้อ'); + const model = ceoTable().getByLabelText('รุ่น'); + expect([brand.value, model.value]).toEqual(['SCG', 'A1']); + fireEvent.change(brand, { target: { value: ' Padana ' } }); + fireEvent.change(model, { target: { value: 'Marmo' } }); + fireEvent.click(ceoTable().getByRole('button', { name: 'บันทึกชื่อ' })); + + await waitFor(() => expect(api.pricingRequests.renameItem).toHaveBeenCalledWith(501, 1, { brand: 'Padana', model: 'Marmo' })); + }); + + it('C: no แก้ชื่อ once the decision is no longer editable', async () => { + await renderCeoNewForm({ decisions: [buildDecision({ status: 'APPROVED', priceMode: 'NET', items: [nfItem({ listUnitPrice: 100, netUnitPrice: 100 })] })] }); + openRowDetails(); + await ceoTable().findByTestId('pcr-ceo-details-8001'); + expect(ceoTable().queryByRole('button', { name: 'แก้ชื่อ' })).toBeNull(); + }); + + it('A: the real incident path - READY_FOR_CEO_REVIEW with no decision yet still offers the factory fix', async () => { + const request = buildRequest({ summary: { status: 'READY_FOR_CEO_REVIEW' } }); + api.pricingRequests.listPricingDecisions.mockResolvedValue({ items: [] }); + renderDetailPage({ user: ceoUser, request, factoryQuotes: [badQuote()] }); + await waitForLoaded(request); + + const form = await screen.findByTestId('pcr-ceo-fix-factory-91'); + fireEvent.focus(within(form).getByLabelText('โรงงานที่ถูกต้อง')); + fireEvent.click(await screen.findByRole('option', { name: 'Cotto Industry' })); + fireEvent.click(within(form).getByRole('button', { name: 'บันทึกและคำนวณต้นทุนใหม่' })); + + await waitFor(() => expect(api.pricingRequests.repointFactoryQuote).toHaveBeenCalledWith(501, 91, { factoryId: 602 })); + }); + + it('A: a saved fix whose recalculation still fails shows the remaining error inline', async () => { + api.pricingRequests.repointFactoryQuote.mockResolvedValue({ decision: null, costingError: 'ไม่พบ factory config สำหรับโรงงาน: Second typo' }); + const { showToast } = await renderCeoNewForm({ factoryQuotes: [badQuote()] }); + + const form = await screen.findByTestId('pcr-ceo-fix-factory-91'); + fireEvent.focus(within(form).getByLabelText('โรงงานที่ถูกต้อง')); + fireEvent.click(await screen.findByRole('option', { name: 'Cotto Industry' })); + fireEvent.click(within(form).getByRole('button', { name: 'บันทึกและคำนวณต้นทุนใหม่' })); + + expect((await screen.findByTestId('pcr-ceo-costing-error')).textContent).toContain('Second typo'); + expect(showToast).toHaveBeenCalledWith('success', 'บันทึกแล้ว แต่ยังคำนวณต้นทุนไม่ได้'); + expect(showToast).not.toHaveBeenCalledWith('success', 'เปลี่ยนโรงงานแล้ว'); + }); + + const noBandOrigin = () => ({ + requestItems: [{ ...buildRequest().items[0], originCountry: 'อิตาลี' }], + costings: [buildCosting({ id: 601, items: [buildCostingItemWithOverride({ + id: 1, uncostableReason: 'รายการที่ 1 ไม่มีอัตราค่าขนส่งในตาราง (ต้นทาง=อิตาลี ความหนา=18 มม.)', uncostableCode: 'NO_FREIGHT_BAND', + })] })], + items: [nfItem({ pricingCostingItemId: 1, frozenLandedCostPerRequestedUnitThb: null, netUnitPrice: null })], + }); + + it('B: the no-freight-band reason shows the freight input (not the origin select) and saves the THB amount', async () => { + await renderCeoNewForm(noBandOrigin()); + + const box = await ceoTable().findByTestId('pcr-ceo-freight-fix-8001'); + expect(ceoTable().queryByTestId('pcr-ceo-origin-fix-8001')).toBeNull(); + expect(box.textContent).toContain('ไม่มีอัตราค่าขนส่งในตารางสำหรับรายการนี้ — กรอกค่าขนส่งรวมของรายการนี้เท่านั้น (ไม่รวมรายการอื่นในตู้เดียวกัน) ระบบคำนวณส่วนที่เหลือตามสูตร'); + fireEvent.change(within(box).getByLabelText('ค่าขนส่งของรายการนี้ (บาท)'), { target: { value: '12000' } }); + fireEvent.click(within(box).getByRole('button', { name: 'บันทึกและคำนวณต้นทุนใหม่' })); + + await waitFor(() => expect(api.pricingRequests.setItemFreight).toHaveBeenCalledWith(501, 1, { freightThb: 12000 })); + }); + + it('B: a missing-origin line shows the origin select and no freight input', async () => { + await renderCeoNewForm(uncostableOrigin()); + await ceoTable().findByTestId('pcr-ceo-origin-fix-8001'); + expect(ceoTable().queryByTestId('pcr-ceo-freight-fix-8001')).toBeNull(); + }); +}); + diff --git a/frontend/src/features/pricingRequests/landedCostBreakdown.js b/frontend/src/features/pricingRequests/landedCostBreakdown.js index fc6a1ddac..9b0847125 100644 --- a/frontend/src/features/pricingRequests/landedCostBreakdown.js +++ b/frontend/src/features/pricingRequests/landedCostBreakdown.js @@ -102,6 +102,8 @@ export function landedCostBreakdown({ costingItem, formulaConfig, marginFraction remainderSqm: num(snap.freightRemainderSqm), groupUnits: num(snap.freightGroupContainerUnits), roundUpAboveSqm: num(snap.freightRoundUpAboveSqm), + // The CEO typed this freight himself (the table had no band for the line). + manual: snap.freightSource === 'CEO_MANUAL', }, clearance: { share: clearance, diff --git a/frontend/src/features/pricingRequests/landedCostBreakdown.test.js b/frontend/src/features/pricingRequests/landedCostBreakdown.test.js index 48323a106..8a19df33a 100644 --- a/frontend/src/features/pricingRequests/landedCostBreakdown.test.js +++ b/frontend/src/features/pricingRequests/landedCostBreakdown.test.js @@ -28,6 +28,12 @@ describe('landedCostBreakdown', () => { expect(b.listPerPiece).toBe(18.4); }); + it('flags a freight the CEO entered himself (snapshot freightSource CEO_MANUAL)', () => { + const manual = { ...costingItem, calculationSnapshot: JSON.stringify({ ...JSON.parse(costingItem.calculationSnapshot), freightSource: 'CEO_MANUAL' }) }; + expect(landedCostBreakdown({ costingItem: manual, formulaConfig, marginFraction: 0.3, sellingBuffer: 1 }).freight.manual).toBe(true); + expect(landedCostBreakdown({ costingItem, formulaConfig, marginFraction: 0.3, sellingBuffer: 1 }).freight.manual).toBe(false); + }); + it('a selling buffer above 1 still multiplies both prices, with VAT on ราคาพิเศษ only', () => { const b = landedCostBreakdown({ costingItem, formulaConfig, marginFraction: 0.3, sellingBuffer: 1.1 }); expect(b.listPerPiece).toBe(20.24); // 14.1544 × 1.3 × 1.1