diff --git a/backend/src/main/java/th/co/glr/hr/factoryquote/FactoryQuoteService.java b/backend/src/main/java/th/co/glr/hr/factoryquote/FactoryQuoteService.java index 10218784b..e3625515f 100644 --- a/backend/src/main/java/th/co/glr/hr/factoryquote/FactoryQuoteService.java +++ b/backend/src/main/java/th/co/glr/hr/factoryquote/FactoryQuoteService.java @@ -440,7 +440,7 @@ public FactoryQuoteDto receive(long quoteId, ReceiveFactoryQuoteRequest request, FactoryQuoteDto saved; long respondedQuoteId; if (Set.of(FactoryQuoteStatus.DRAFT, FactoryQuoteStatus.REQUESTED).contains(current.status())) { - int rows = quotes.updateFirstResponse(quoteId, request.supplierQuoteRef(), request.defaultCurrency(), + int rows = quotes.updateFirstResponse(quoteId, request.supplierQuoteRef(), optionalCurrencyCode(request.defaultCurrency()), request.paymentTerms(), request.leadTimeText(), request.revisionReason(), request.negotiationNote()); if (rows == 0) { // Someone else already moved this quote out of DRAFT/REQUESTED. If that was a @@ -487,7 +487,7 @@ public FactoryQuoteDto receive(long quoteId, ReceiveFactoryQuoteRequest request, notifyAutoWithdrawnLeadTimeChange(summary, current.factoryName(), "ฝ่ายนำเข้า (" + actor.name() + ") แก้ไขใบราคาโรงงาน"); } - long newId = quotes.createRevision(current, request.supplierQuoteRef(), request.defaultCurrency(), + long newId = quotes.createRevision(current, request.supplierQuoteRef(), optionalCurrencyCode(request.defaultCurrency()), request.paymentTerms(), request.leadTimeText(), request.revisionReason(), request.negotiationNote(), actor.id()); quotes.replaceResponseItems(newId, normalizedItems); // CEO-owns-costing (plan 2.3): there is no submitted costing row sitting around any @@ -738,13 +738,9 @@ private List validateAndNormalizeResponseItems( throw new ApiException(HttpStatus.BAD_REQUEST, "รายการตอบกลับนี้เป็นของโรงงานอื่น"); } String unitBasis = UnitBasis.canonicalize(responseItem.unitBasis(), "Factory quote unit"); - // CR-1 (R1): currency and price unit are fixed by Sales on the line and locked for import. - // NULL on the line = legacy request, anything accepted. Checked here, before any write. - if (requestItem.requestedCurrency() != null - && !requestItem.requestedCurrency().equalsIgnoreCase(responseItem.currency() == null ? "" : responseItem.currency().trim())) { - throw new ApiException(HttpStatus.CONFLICT, "สกุลเงินต้องเป็น " + requestItem.requestedCurrency() - + " ตามที่ฝ่ายขายระบุ (รายการ " + requestItem.displayName() + ")"); - } + // Import records the supplier's actual currency (Sales' requested currency stays on the + // request for reference); it must still be a 3-letter code so FxResolver can price it. + String currency = currencyCode(responseItem.currency()); // Factories sell on different terms, so import may quote a // per-ตร.ม. line per แผ่น (and the reverse). Only that pair switches; any other basis // stays locked to what Sales asked for. Costing normalises the requested quantity onto @@ -785,7 +781,7 @@ private List validateAndNormalizeResponseItems( quotedUnit, unitBasis, responseItem.rawUnitPrice(), - responseItem.currency(), + currency, responseItem.minimumOrderQuantity(), responseItem.sqmPerUnit(), responseItem.piecesPerBox(), @@ -798,6 +794,19 @@ private List validateAndNormalizeResponseItems( return normalized; } + /** The quote-level default may be omitted (kept as before); when given it must be a valid code. */ + private static String optionalCurrencyCode(String raw) { + return raw == null || raw.isBlank() ? raw : currencyCode(raw); + } + + private static String currencyCode(String raw) { + String code = raw == null ? "" : raw.trim().toUpperCase(java.util.Locale.ROOT); + if (!code.matches("[A-Z]{3}")) { + throw new ApiException(HttpStatus.BAD_REQUEST, "สกุลเงินต้องเป็นรหัส 3 ตัวอักษร เช่น THB, EUR, USD"); + } + return code; + } + private PricingRequestSummaryDto requirePricingRequest(long pricingRequestId) { return pricingRequests.findSummary(pricingRequestId) .orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "ไม่พบคำขอราคานี้")); diff --git a/backend/src/test/java/th/co/glr/hr/pricingrequest/FactoryContactFlowIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/pricingrequest/FactoryContactFlowIntegrationTest.java index 030ed6afc..c1bce503f 100644 --- a/backend/src/test/java/th/co/glr/hr/pricingrequest/FactoryContactFlowIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/pricingrequest/FactoryContactFlowIntegrationTest.java @@ -21,7 +21,7 @@ * real services/repositories. * *

A: the "ติดต่อโรงงานแล้ว" (factory contacted) step replaces "send" and gates price entry. - * B: currency and price unit are fixed by Sales on the request line and locked for import. + * B: Sales terms seed the quote; Import may change currency and switch sqm/piece pricing. * *

Authz cases are written wrong-way-round: they assert who CANNOT reach the step, and which * states refuse it, rather than that the happy caller can. @@ -429,23 +429,39 @@ void generateDraftsSeedsEachFactoryQuotesDefaultCurrencyFromItsLinesRequestedCur /** B3 */ @Test - void receiveWithACurrencyOtherThanTheRequestedOneIs409_andStoresNothing() { + void importMayChangeCurrencyWithoutChangingSalesRequest() { long prId = requestInImportReview(line("Tile A", "Factory A", 10, null, null, "EUR", UnitBasis.PER_PIECE)); FactoryQuoteDto quote = requestedQuote(prId, "Factory A"); - assertThatThrownBy(() -> factoryQuoteService.receive(quote.id(), receiveAll(quote, "USD", "PER_PIECE"), importActor)) - .isInstanceOfSatisfying(ApiException.class, e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT)); - FactoryQuoteDto after = factoryQuoteService.get(quote.id(), importActor); - assertThat(after.status()).isEqualTo(FactoryQuoteStatus.REQUESTED); - assertThat(after.items()).allSatisfy(i -> assertThat(i.rawUnitPrice()).isNull()); + FactoryQuoteDto received = factoryQuoteService.receive(quote.id(), receiveAll(quote, "USD", "PER_PIECE"), importActor); + assertThat(received.status()).isEqualTo(FactoryQuoteStatus.RESPONSE_RECEIVED); + FactoryQuoteDto after = factoryQuoteService.get(received.id(), importActor); + assertThat(after.defaultCurrency()).isEqualTo("USD"); + assertThat(after.items()).allSatisfy(i -> { + assertThat(i.currency()).isEqualTo("USD"); + assertThat(i.rawUnitPrice()).isNotNull(); + }); + assertThat(pricingRequestService.get(prId, salesActor).items().get(0).requestedCurrency()).isEqualTo("EUR"); + } + + /** B3b: the currency is free for import, but it must be a 3-letter code; it is stored trimmed and upper-cased. */ + @Test + void importCurrencyIsNormalisedAndANonCodeIs400WithNothingStored() { + long prId = requestInImportReview(line("Tile A", "Factory A", 10, null, null, "EUR", UnitBasis.PER_PIECE)); + FactoryQuoteDto quote = requestedQuote(prId, "Factory A"); + + assertThatThrownBy(() -> factoryQuoteService.receive(quote.id(), receiveAll(quote, "BAHT", "PER_PIECE"), importActor)) + .isInstanceOfSatisfying(ApiException.class, e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST)); + assertThat(factoryQuoteService.get(quote.id(), importActor).items()).allSatisfy(i -> assertThat(i.rawUnitPrice()).isNull()); - // control: the SAME state accepts the requested currency, so the 409 above was about currency - assertThat(factoryQuoteService.receive(quote.id(), receiveAll(quote, "EUR", "PER_PIECE"), importActor).status()) - .isEqualTo(FactoryQuoteStatus.RESPONSE_RECEIVED); + FactoryQuoteDto received = factoryQuoteService.receive(quote.id(), receiveAll(quote, " usd ", "PER_PIECE"), importActor); + FactoryQuoteDto after = factoryQuoteService.get(received.id(), importActor); + assertThat(after.defaultCurrency()).isEqualTo("USD"); + assertThat(after.items()).allSatisfy(i -> assertThat(i.currency()).isEqualTo("USD")); } /** B4 — owner ruling 2026-10-01: factories sell on different terms, so import may quote a - * per-ตร.ม. line per แผ่น and vice versa. Only that pair is switchable; currency stays locked. */ + * per-ตร.ม. line per แผ่น and vice versa. Only that pair is switchable; Import may also change currency. */ @Test void importMayQuoteAPerSqmLineByThePieceAndAPerPieceLineBySqm() { long prId = requestInImportReview( diff --git a/frontend/e2e-real/write-pcr-currency.spec.js b/frontend/e2e-real/write-pcr-currency.spec.js new file mode 100644 index 000000000..6df12ad8c --- /dev/null +++ b/frontend/e2e-real/write-pcr-currency.spec.js @@ -0,0 +1,75 @@ +import { test, expect } from '@playwright/test'; +import { apiSessionsFor, apiWrite, disposeSessions } from './helpers/api.js'; +import { createDeal, cancelDeal, submitPricingRequest, pickupPricingRequest, generateFactoryEmailDrafts } from './helpers/sales.js'; + +test('Import changes Sales currency, sees live baht, and persists the supplier response', async ({ browser }, testInfo) => { + test.setTimeout(120_000); + process.env.E2E_RUN_ID ??= `E2E-PCR-CURRENCY-${Date.now()}`; + const sessions = await apiSessionsFor(['sales', 'import']); + let deal; + let context; + try { + deal = await createDeal(sessions, { caseName: 'PCR currency', entryChannel: 'DESIGNER_LED' }); + const factoryResponse = await sessions.import.get('/api/factory-configs'); + const { factories } = await factoryResponse.json(); + const factory = factories.find((f) => f.factoryName.includes('Panaria')) ?? factories[0]; + const created = await apiWrite(sessions.sales, 'post', `/api/tickets/${deal.id}/pricing-requests`, { + recipientType: 'DESIGNER', recipientLabel: 'Currency regression', targetCurrency: 'THB', clientRequestId: crypto.randomUUID(), + items: [{ + brand: 'Panaria', model: 'Stone Collection', color: 'Ivory', texture: 'Matt', size: '60x120', factory: factory.factoryName, + thicknessMm: 9, sqmPerPiece: 0.72, quantityMode: 'PIECES', piecesInput: 100, wastageMode: 'NONE', piecesPerBox: 2, + roundToFullBox: true, originCountry: 'อิตาลี', leadTimeMinDays: 75, leadTimeMaxDays: 90, quantityType: 'CONFIRMED', + requestedCurrency: 'EUR', requestedPriceUnitBasis: 'PER_SQM', productCode: 'PCR-CURRENCY-01', + }], + }); + expect(created.status(), await created.text()).toBe(201); + const { pricingRequest } = await created.json(); + const id = pricingRequest.summary.id; + await submitPricingRequest(sessions, id); + await pickupPricingRequest(sessions, id); + const [quote] = await generateFactoryEmailDrafts(sessions, id); + const today = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Bangkok' }).format(new Date()); + const contacted = await apiWrite(sessions.import, 'post', `/api/factory-quotes/${quote.id}/contacted`, { contactedOn: today }); + expect(contacted.status()).toBe(200); + context = await browser.newContext({ baseURL: testInfo.project.use.baseURL, storageState: await sessions.import.storageState(), viewport: { width: 1440, height: 1000 } }); + const page = await context.newPage(); + await page.goto(`/pricing-requests/${id}`); + const currency = page.getByRole('combobox', { name: 'สกุลเงินที่โรงงานเสนอ' }); + await expect(currency).toHaveValue('EUR'); + await page.getByRole('textbox', { name: /^ราคาที่เสนอ/ }).fill('20'); + await currency.selectOption('USD'); + const { fxRates } = await (await sessions.import.get('/api/fx-rates')).json(); + const usd = fxRates.find((fx) => fx.currency === 'USD').rateToThb; + const baht = page.getByTestId(`pcr-quote-thb-${quote.id}-${pricingRequest.items[0].id}`); + await expect(baht).toContainText((20 * usd).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })); + await expect(page.getByText('ฝ่ายขายขอ: EUR')).toBeVisible(); + for (const width of [320, 375, 768, 1024, 1440, 1920]) { + await page.setViewportSize({ width, height: 1000 }); + await expect(currency).toBeVisible(); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), `overflow at ${width}`).toBe(true); + await page.screenshot({ path: testInfo.outputPath(`currency-${width}.png`), fullPage: true }); + } + await page.setViewportSize({ width: 1440, height: 1000 }); + await page.getByRole('button', { name: 'ยืนยันราคาเสนอ', exact: true }).click(); + await expect(page.getByText('ยืนยันราคาเสนอแล้ว', { exact: true }).first()).toBeVisible(); + await page.reload(); + await expect(currency).toHaveValue('USD'); + await expect(page.getByRole('textbox', { name: /^ราคาที่เสนอ/ })).toHaveValue('20'); + const { items: quotes } = await (await sessions.import.get(`/api/pricing-requests/${id}/factory-quotes`)).json(); + const saved = quotes.find((q) => q.current); + expect(saved.status).toBe('READY_FOR_COSTING'); + expect(saved.defaultCurrency).toBe('USD'); + expect(saved.items[0].currency).toBe('USD'); + const { pricingRequest: original } = await (await sessions.sales.get(`/api/pricing-requests/${id}`)).json(); + expect(original.items[0].requestedCurrency).toBe('EUR'); + // A fresh edit can be cancelled, restoring the saved supplier currency and price. + await currency.selectOption('THB'); + await expect(baht).toContainText('20.00'); + await page.getByRole('button', { name: 'ยกเลิก', exact: true }).click(); + await expect(currency).toHaveValue('USD'); + } finally { + await context?.close(); + if (deal) await cancelDeal(sessions, deal.id).catch(() => {}); + await disposeSessions(sessions); + } +}); diff --git a/frontend/src/api/mockApi.factoryContact.test.js b/frontend/src/api/mockApi.factoryContact.test.js index 31a619831..fd11837cc 100644 --- a/frontend/src/api/mockApi.factoryContact.test.js +++ b/frontend/src/api/mockApi.factoryContact.test.js @@ -182,11 +182,11 @@ describe('mockApi.pricingRequests.receiveFactoryQuote — price entry locked unt expect(factoryQuote.status).toBe('RESPONSE_RECEIVED'); }); - it('409s a different currency than the line requested', async () => { + it('accepts Import supplier currency differing from Sales request', async () => { const { quote } = await pcrWithDraftQuote(); await api.pricingRequests.markFactoryQuoteContacted(quote.id, { contactedOn: todayBangkok() }); - await expect(api.pricingRequests.receiveFactoryQuote(quote.id, receivePayload(quote, { currency: 'USD' }))) - .rejects.toMatchObject({ status: 409, message: expect.stringContaining('สกุลเงิน') }); + const { factoryQuote } = await api.pricingRequests.receiveFactoryQuote(quote.id, receivePayload(quote, { currency: 'USD' })); + expect(factoryQuote.items[0].currency).toBe('USD'); }); it('accepts ตร.ม. <-> แผ่น for a per-ตร.ม. line (factories sell on different terms), but 409s any other unit', async () => { diff --git a/frontend/src/api/mockApi.js b/frontend/src/api/mockApi.js index b39e95070..7177eeba7 100644 --- a/frontend/src/api/mockApi.js +++ b/frontend/src/api/mockApi.js @@ -15281,16 +15281,10 @@ export const api = { if (quote.status === 'DRAFT') fail('ต้องกด ติดต่อโรงงานแล้ว ก่อนกรอกราคา', 409); const pr = findPricingRequestRaw(quote.pricingRequestId); mockRequireNotCeoReviewing(pr); - // Currency and price unit are fixed by Sales on the line and locked for import. A line with - // no requested terms accepts anything — mirrors - // FactoryQuoteService#validateAndNormalizeResponseItems. + // Import may change the supplier currency; retain the price-unit constraints. for (const responseItem of payload.items ?? []) { const requestItem = pr.items.find((i) => i.id === responseItem.pricingRequestItemId); if (!requestItem) continue; - if (requestItem.requestedCurrency - && requestItem.requestedCurrency.toUpperCase() !== String(responseItem.currency ?? '').trim().toUpperCase()) { - fail(`สกุลเงินต้องเป็น ${requestItem.requestedCurrency} ตามที่ฝ่ายขายระบุ (รายการ ${pricingRequestItemDisplayName(requestItem)})`, 409); - } // Mirrors FactoryQuoteService#isSqmPieceSwitch: only ตร.ม. <-> แผ่น may differ from the request. const quotedBasis = String(responseItem.unitBasis ?? '').trim().toUpperCase(); const sqmPieceSwitch = ['PER_SQM', 'PER_PIECE'].includes(requestItem.requestedPriceUnitBasis) diff --git a/frontend/src/features/pricingRequests/ImportPricingWorkspace.jsx b/frontend/src/features/pricingRequests/ImportPricingWorkspace.jsx index 8b766628c..32c813819 100644 --- a/frontend/src/features/pricingRequests/ImportPricingWorkspace.jsx +++ b/frontend/src/features/pricingRequests/ImportPricingWorkspace.jsx @@ -121,12 +121,12 @@ function PriceCell({ row, quoteId }) {

{p.mode === 'input' ? (
- -
); @@ -233,15 +233,22 @@ function CardTerms({ card }) { const t = card.terms; return (
- - สกุลเงิน - - - {t.currency ? `${t.currency} ${t.symbol}` : 'หลายสกุลเงิน — ดูในแต่ละรายการ'} - - ตามคำขอราคา - {t.fxNote ? {`· ${t.fxNote}`} : null} - +
+
+ + {t.editable ? ( + + ) : {t.currency ? `${t.currency} ${t.symbol}` : 'หลายสกุลเงิน — ดูในแต่ละรายการ'}} + {t.fxNote ? {t.fxNote} : null} + {t.requestedCurrencies ? ฝ่ายขายขอ: {t.requestedCurrencies} : null} +
+

+ {t.editable ? 'ใช้กับทุกรายการในโรงงานนี้ · เปลี่ยนสกุลเงินแล้วตรวจสอบราคาก่อนยืนยัน · ' : ''}ยอดบาทเป็นค่าประมาณตามอัตราในระบบ ยังไม่รวมค่าขนส่งและภาษี +

+
{t.lockReason ? ( diff --git a/frontend/src/features/pricingRequests/PricingRequestDetailPage.jsx b/frontend/src/features/pricingRequests/PricingRequestDetailPage.jsx index 75e6ad500..9fe10b3b5 100644 --- a/frontend/src/features/pricingRequests/PricingRequestDetailPage.jsx +++ b/frontend/src/features/pricingRequests/PricingRequestDetailPage.jsx @@ -3,7 +3,7 @@ import { isOwnerOrKeyer } from '../tickets/dealOwnership.js'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { Link, useNavigate, useParams } from 'react-router-dom'; import { hasPermission } from '../../app/permissions.js'; -import { quotedPriceInThb } from './quotedPriceThb.js'; +import { fxRateFor, isFxRateStale, MAX_FX_RATE_AGE_DAYS, quotedPriceInThb } from './quotedPriceThb.js'; import { api } from '../../api/index.js'; import { queryKeys } from '../../api/queryKeys.js'; import { Icon } from '../../components/common/Icon.jsx'; @@ -164,14 +164,10 @@ function apiStatus(error) { } /** - * Seeds the "record the factory's answer" draft. Import types the PRICE and nothing else: - * quantity, unit and currency come from what Sales requested, so they are carried in this state - * (the backend still requires them) but never rendered as inputs. - * - * `requestItem` is the sales-side PricingRequestItem this quote line was generated from, looked up - * by pricingRequestItemId. The currency fallback matters: `catalogCurrency` is the currency of the - * catalog row Sales picked (EUR for the European factories) and must win over a hardcoded 'THB', - * which stored a price typed as 46 as ฿46 instead of €46 (a 38.5x error in the landed cost). + * Seeds Import's answer. A line Import already priced keeps Import's own currency and unit; an + * unpriced line starts from Sales' requested terms. The last fallback matters: `catalogCurrency` + * (EUR for the European factories) must win over a hardcoded 'THB', which once stored a price + * typed as 46 as ฿46 instead of €46 (a 38.5x error in the landed cost). */ function defaultResponseItems(quote, requestItemById = new Map()) { return (quote?.items ?? []).map((item) => { @@ -187,11 +183,8 @@ function defaultResponseItems(quote, requestItemById = new Map()) { // is NOT NULL and CHECK-constrained to the four canonical codes, and every quote comes from the // API. A `'PER_PIECE'` default would only hide a bad value as a silently wrong basis; an // absent one should fail loudly at the backend. - // Currency and price unit are fixed by Sales on the line and locked for import: the line's own - // requested terms win over the draft, so what is sent back always matches - // (FactoryQuoteService#receive 409s anything else). A legacy line has neither and keeps the - // seeding below. - const unitBasis = requestItem.requestedPriceUnitBasis ?? item.unitBasis; + // Preserve Import's saved terms when reopening a response or revision. + const unitBasis = item.rawUnitPrice != null ? item.unitBasis : requestItem.requestedPriceUnitBasis ?? item.unitBasis; const quotedUnit = requestItem.requestedPriceUnitBasis ? unitBasisLabel(unitBasis) : item.quotedUnit ?? requestItem.requestedUnit ?? unitBasisLabel(unitBasis); @@ -203,7 +196,7 @@ function defaultResponseItems(quote, requestItemById = new Map()) { quotedUnit, unitBasis, rawUnitPrice: item.rawUnitPrice ?? '', - currency: requestItem.requestedCurrency ?? item.currency ?? quote.defaultCurrency ?? requestItem.catalogCurrency ?? 'THB', + currency: (item.rawUnitPrice != null ? item.currency : requestItem.requestedCurrency) || item.currency || requestItem.requestedCurrency || quote.defaultCurrency || requestItem.catalogCurrency || 'THB', minimumOrderQuantity: item.minimumOrderQuantity ?? '', // Falls back to the request item's own ตร.ม./แผ่น (required on every item since V185) so import // is not asked for a figure the system already holds; a value saved on the quote still wins. @@ -863,7 +856,10 @@ export function PricingRequestDetailPage({ user, showToast }) { // The CEO needs the same list for the baht conversion of a line's catalogue price (GET // /api/fx-rates is ceo/import/sales/sales_manager on the server). enabled: Number.isFinite(pricingRequestId) && canSeeRaw(user), - staleTime: 5 * 60 * 1000, + // Always the server's latest rate: re-read on every page open and window focus, never a cache. + staleTime: 0, + refetchOnMount: 'always', + refetchOnWindowFocus: 'always', }); // Step 3: CEO Selling Price Decision. Raw (cost/margin-bearing) history is import/ceo only @@ -2639,17 +2635,18 @@ export function PricingRequestDetailPage({ user, showToast }) { const current = group.current; const quoteStatus = factoryQuoteStatusLabel(current.status); const unsavedDraft = inResponseWindow ? responseDrafts[current.id] : undefined; + const seededItems = defaultResponseItems(current, requestItemById); + // A priced quote keeps its saved default; a fresh one takes its first line's seeded currency. + const seededDefaultCurrency = (current.items?.some((item) => item.rawUnitPrice != null) + ? current.defaultCurrency : seededItems[0]?.currency) || current.defaultCurrency || 'THB'; const seedDraft = () => ({ supplierQuoteRef: current.supplierQuoteRef ?? '', - defaultCurrency: (() => { - const locked = [...new Set((current.items ?? []).map((i) => requestItemById.get(i.pricingRequestItemId)?.requestedCurrency).filter(Boolean))]; - return locked.length === 1 ? locked[0] : (current.defaultCurrency ?? 'THB'); - })(), + defaultCurrency: seededDefaultCurrency, paymentTerms: current.paymentTerms ?? '', leadTimeText: current.leadTimeText ?? '', revisionReason: '', negotiationNote: current.negotiationNote ?? '', - items: defaultResponseItems(current, requestItemById), + items: seededItems, }); const draft = unsavedDraft ?? seedDraft(); const dirty = Boolean(unsavedDraft); @@ -2723,7 +2720,12 @@ export function PricingRequestDetailPage({ user, showToast }) { const serverItem = current.items?.find((i) => i.pricingRequestItemId === line.pricingRequestItemId); const approvedPrice = current.status === 'READY_FOR_COSTING' ? serverItem?.rawUnitPrice ?? null : null; const equivalentPrice = equivalentUnitPrice(line, requested); - const thb = requested ? quotedPriceInThb(importQuoteItemByRequestItem.get(requested.id), requested, fxRatesQuery.data) : null; + const thb = requested ? quotedPriceInThb({ ...line, currency: lineCurrency }, requested, fxRatesQuery.data) : null; + const rate = fxRateFor(lineCurrency, fxRatesQuery.data)?.rateToThb; + const rawPrice = cleanNumber(line.rawUnitPrice); + const thbPreview = rawPrice != null && Number(rate) > 0 + ? `≈ ${formatMoney(rawPrice * Number(rate))}/${unitName(line.unitBasis)}${thb ? ` · รวม ${formatMoney(thb.total)}` : ''}` + : rawPrice != null ? (fxRatesQuery.isPending ? 'กำลังโหลดอัตราแลกเปลี่ยน…' : `ยังแปลงเป็นบาทไม่ได้ — ไม่มีอัตรา ${lineCurrency}`) : null; const pendingLine = pendingLineById.get(line.pricingRequestItemId); let price; if (editable && contacted) { @@ -2776,7 +2778,7 @@ export function PricingRequestDetailPage({ user, showToast }) { qtyDetail: qtyDetailOf(requested), spec: specFor(requested), price, - thb: thb ? `≈ ${formatMoney(thb.perSqm)}/ตร.ม. · รวม ${formatMoney(thb.total)}` : null, + thb: thbPreview, currency: lineCurrency, currencyNote: sharedCurrency ? null : lineCurrency, priceEmpty: price.mode === 'input' && !thb && cleanNumber(line.rawUnitPrice) == null, @@ -2788,7 +2790,8 @@ export function PricingRequestDetailPage({ user, showToast }) { }; }); - const fxRate = sharedCurrency && sharedCurrency !== 'THB' ? (fxRatesQuery.data ?? []).find((fx) => fx.currency === sharedCurrency)?.rateToThb : null; + const sharedFx = sharedCurrency && sharedCurrency !== 'THB' ? fxRateFor(sharedCurrency, fxRatesQuery.data) : null; + const fxRate = sharedFx?.rateToThb ?? null; const emptyPrices = draft.items.filter((line) => cleanNumber(line.rawUnitPrice) == null).length; let helper = null; if (!contacted) helper = NOT_CONTACTED_HELPER; @@ -2819,11 +2822,21 @@ export function PricingRequestDetailPage({ user, showToast }) { contact: canMarkContacted ? { testId: `pcr-open-contact-${current.id}`, onClick: () => setContactQuoteId(current.id) } : null, contactNote: contacted && current.contactedNote ? current.contactedNote : null, terms: { - // Currency is Sales's and read-only. One shared code is named in the header; mixed codes stay - // generic here and each row shows its own. currency: sharedCurrency, symbol: sharedCurrency ? (CURRENCY_SYMBOLS[sharedCurrency] ?? sharedCurrency) : null, - fxNote: fxRate != null ? `อัตรา ${Number(fxRate).toFixed(2)} บาท (ธปท.)` : null, + editable: editable && contacted, + // Only currencies costing can convert (THB + a rate on file); the current one stays so the select shows it. + options: [...new Set(['THB', ...(fxRatesQuery.data ?? []).map((fx) => fx.currency), ...lineCurrencies])], + onCurrency: (currency) => setResponseDrafts((cur) => { + const latest = cur[current.id] ?? seedDraft(); + return { ...cur, [current.id]: { ...latest, defaultCurrency: currency, items: latest.items.map((line) => ({ ...line, currency })) } }; + }), + requestedCurrencies: [...new Set(groupRequestItems.map((item) => item?.requestedCurrency).filter(Boolean))].join(', '), + fxNote: sharedCurrency === 'THB' ? '1 THB = 1 บาท' + : fxRate > 0 ? `1 ${sharedCurrency} = ${Number(fxRate).toLocaleString('th-TH', { maximumFractionDigits: 6 })} บาท` + + (sharedFx?.effectiveDate ? ` (ธปท. ${formatThaiDate(sharedFx.effectiveDate)})` : '') + + (isFxRateStale(sharedFx) ? ` · อัตราเก่าเกิน ${MAX_FX_RATE_AGE_DAYS} วัน — CEO คิดต้นทุนไม่ได้จนกว่าจะอัปเดต` : '') + : sharedCurrency ? `ยังไม่มีอัตราแลกเปลี่ยน ${sharedCurrency}` : null, lockReason: editable && !contacted, }, rows, diff --git a/frontend/src/features/pricingRequests/PricingRequestDetailPage.test.jsx b/frontend/src/features/pricingRequests/PricingRequestDetailPage.test.jsx index 1bd4f2275..0e2f3b75b 100644 --- a/frontend/src/features/pricingRequests/PricingRequestDetailPage.test.jsx +++ b/frontend/src/features/pricingRequests/PricingRequestDetailPage.test.jsx @@ -1,5 +1,5 @@ import React from 'react'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { QueryClient, QueryClientProvider, focusManager } from '@tanstack/react-query'; import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { MemoryRouter, Route, Routes } from 'react-router-dom'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -567,6 +567,8 @@ function renderDetailPage({ return { ...utils, queryClient, showToast }; } +const todayIso = () => new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Bangkok' }).format(new Date()); + async function waitForLoaded(request = buildRequest()) { return screen.findByRole('heading', { level: 1, name: request.summary.requestCode }); } @@ -2853,6 +2855,17 @@ describe('PricingRequestDetailPage import price in baht', () => { }; afterEach(() => { delete api.fxRates; }); + it('re-fetches FX rates when the window regains focus, so the baht figure never uses a cached rate', async () => { + await renderWithFx(importUser, [quoteWith()]); + expect((await screen.findByTestId('pcr-quote-thb-91-1')).textContent).toContain('฿4,600.00'); + const calls = api.fxRates.list.mock.calls.length; + api.fxRates.list.mockResolvedValue({ fxRates: [{ currency: 'EUR', rateToThb: 41 }] }); + act(() => { focusManager.setFocused(false); focusManager.setFocused(true); }); + await waitFor(() => expect(api.fxRates.list.mock.calls.length).toBeGreaterThan(calls)); + await waitFor(() => expect(screen.getByTestId('pcr-quote-thb-91-1').textContent).toContain('฿4,715.00')); + focusManager.setFocused(undefined); + }); + // The read-only item card (and its "ราคาจากฝ่ายนำเข้า" line) is gone for import; the baht // conversion sits under the price input in the factory table. The CEO still reads it on the item // card (second test). @@ -2885,11 +2898,11 @@ describe('PricingRequestDetailPage import price in baht', () => { expect(screen.queryByTestId('pcr-quote-thb-91-1')).toBeNull(); }); - it('import: no baht for a per-box price; ceo item card keeps its per-box text; Sales has no FX access', async () => { + it('import: baht per box without guessing a total; ceo item card keeps its per-box text; Sales has no FX access', async () => { await renderWithFx(importUser, [quoteWith({ unitBasis: 'PER_BOX' })]); expect(await screen.findByTestId('pcr-quote-price-cell-91-1')).not.toBeNull(); // positive control: the redesigned price cell expect((await screen.findByLabelText(/^ราคาที่เสนอ/)).value).toBe('115'); - expect(screen.queryByTestId('pcr-quote-thb-91-1')).toBeNull(); + expect(screen.getByTestId('pcr-quote-thb-91-1').textContent).toBe('≈ ฿4,600.00/กล่อง'); cleanup(); await renderWithFx(ceoUser, [quoteWith({ unitBasis: 'PER_BOX' })]); expect((await screen.findByTestId('pcr-import-price-1')).textContent).toContain('115 EUR/กล่อง'); @@ -4354,28 +4367,69 @@ describe('CR-1 factory card — status, terms, and the locked price grid (F1)', expect(screen.queryByRole('button', { name: 'ส่งแล้ว' })).toBeNull(); }); - it('shows the read-only currency line "สกุลเงิน 🔒 EUR € · ตามคำขอราคา" and no currency / unit select for lines that carry terms (R1)', async () => { + it('lets Import select factory currency while retaining Sales requested currency', async () => { renderDetailPage({ user: importUser, request: crRequest(), factoryQuotes: [contactedQuote()] }); await waitForLoaded(); await screen.findByText('SCG Ceramics'); const line = screen.getByTestId('pcr-factory-currency-91').textContent; expect(line).toContain('สกุลเงิน'); - expect(line).toContain('EUR €'); - expect(line).toContain('ตามคำขอราคา'); + expect(line).toContain('ฝ่ายขายขอ: EUR'); + expect(screen.getByRole('combobox', { name: /^สกุลเงิน/ }).value).toBe('EUR'); expect(screen.queryByTestId('pcr-factory-terms-91')).toBeNull(); // the "ต่อ ตร.ม." strip is gone: the unit is per line now - expect(screen.queryByRole('combobox', { name: /^สกุลเงิน/ })).toBeNull(); + expect(screen.getByRole('combobox', { name: /^สกุลเงิน/ })).not.toBeNull(); expect(screen.queryByRole('combobox', { name: /^หน่วยราคา/ })).toBeNull(); }); - it('a legacy line with no requested terms ALSO gets the read-only currency line — no currency or unit select', async () => { + it('lets Import select currency on legacy lines too', async () => { renderDetailPage({ user: importUser, request: buildRequest(), factoryQuotes: [buildFactoryQuote({ status: 'REQUESTED' })] }); await waitForLoaded(); await screen.findByText('SCG Ceramics'); - expect(screen.queryByRole('combobox', { name: /^สกุลเงิน/ })).toBeNull(); + expect(screen.getByRole('combobox', { name: /^สกุลเงิน/ })).not.toBeNull(); expect(screen.queryByRole('combobox', { name: /^หน่วยราคา/ })).toBeNull(); - expect(screen.getByTestId('pcr-factory-currency-91').textContent).toContain('THB ฿'); + expect(screen.getByRole('combobox', { name: /^สกุลเงิน/ }).value).toBe('THB'); + }); + + it('updates baht live when Import changes currency and sends that currency on confirmation', async () => { + api.fxRates = { list: vi.fn().mockResolvedValue({ fxRates: [{ currency: 'EUR', rateToThb: 40 }, { currency: 'USD', rateToThb: 35 }] }) }; + renderDetailPage({ user: importUser, request: crRequest(), factoryQuotes: [contactedQuote()] }); + await waitForLoaded(); + const currency = await screen.findByRole('combobox', { name: /^สกุลเงิน/ }); + fireEvent.change(screen.getByLabelText(/^ราคาที่เสนอ/), { target: { value: '10' } }); + await waitFor(() => expect(screen.getByTestId('pcr-quote-thb-91-1').textContent).toContain('฿400.00/ตร.ม.')); + fireEvent.change(currency, { target: { value: 'USD' } }); + expect(screen.getByLabelText(/^ราคาที่เสนอ/).value).toBe('10'); + expect(screen.getByTestId('pcr-quote-thb-91-1').textContent).toContain('฿350.00/ตร.ม.'); + expect(screen.getByTestId('pcr-factory-currency-91').textContent).toContain('ฝ่ายขายขอ: EUR'); + fireEvent.click(screen.getByRole('button', { name: 'ยืนยันราคาเสนอ' })); + await waitFor(() => expect(api.pricingRequests.receiveFactoryQuote).toHaveBeenCalledWith(91, + expect.objectContaining({ defaultCurrency: 'USD', items: [expect.objectContaining({ currency: 'USD', rawUnitPrice: 10 })] }))); + }); + + it('offers only THB and currencies that have a rate on file, plus the line\'s own currency', async () => { + api.fxRates = { list: vi.fn().mockResolvedValue({ fxRates: [{ currency: 'USD', rateToThb: 35, effectiveDate: todayIso() }] }) }; + renderDetailPage({ user: importUser, request: crRequest(), factoryQuotes: [contactedQuote()] }); + await waitForLoaded(); + const select = await screen.findByRole('combobox', { name: /^สกุลเงิน/ }); + await waitFor(() => expect([...select.options].map((o) => o.value)).toEqual(['THB', 'USD', 'EUR'])); + }); + + it('warns when the rate is older than 7 days (costing would refuse it) and names the rate date', async () => { + api.fxRates = { list: vi.fn().mockResolvedValue({ fxRates: [{ currency: 'EUR', rateToThb: 40, effectiveDate: '2020-01-02' }] }) }; + renderDetailPage({ user: importUser, request: crRequest(), factoryQuotes: [contactedQuote()] }); + await waitForLoaded(); + const fx = await screen.findByTestId('pcr-factory-fx-91'); + await waitFor(() => expect(fx.textContent).toContain('อัตราเก่าเกิน 7 วัน')); + expect(fx.textContent).toContain(formatThaiDate('2020-01-02')); + }); + + it('reopening a saved response preserves Import currency instead of resetting it to Sales currency', async () => { + const quote = contactedQuote(); + renderDetailPage({ user: importUser, request: crRequest(), factoryQuotes: [{ ...quote, status: 'RESPONSE_RECEIVED', defaultCurrency: 'USD', items: quote.items.map((item) => ({ ...item, rawUnitPrice: 10, currency: 'USD' })) }] }); + await waitForLoaded(); + expect((await screen.findByRole('combobox', { name: /^สกุลเงิน/ })).value).toBe('USD'); + expect(screen.getByLabelText(/^ราคาที่เสนอ/).value).toBe('10'); }); it('a not-yet-contacted factory disables every price input and says why', async () => { @@ -7640,23 +7694,23 @@ describe('Import PCR redesign — factory-first page (import role)', () => { expect(card(91).getByLabelText(/^ราคาที่เสนอ/).disabled).toBe(false); }); - it('terms row holds the read-only currency line and, for EUR, the FX note "อัตรา 40.00 บาท (ธปท.)" when a rate exists', async () => { + it('terms row shows editable currency and the exact conversion direction', async () => { api.fxRates = { list: vi.fn().mockResolvedValue({ fxRates: [{ currency: 'EUR', rateToThb: 40 }, { currency: 'THB', rateToThb: 1 }] }) }; renderDetailPage({ user: importUser, request: crRequest(), factoryQuotes: [contactedQuote()] }); await waitForLoaded(); const row = within(await screen.findByTestId('pcr-factory-terms-row-91')); - expect(row.getByTestId('pcr-factory-currency-91').textContent).toContain('ตามคำขอราคา'); - expect((await row.findByTestId('pcr-factory-fx-91')).textContent).toMatch(/อัตรา 40\.00 บาท \(ธปท\.\)/); + expect(row.getByTestId('pcr-factory-currency-91').textContent).toContain('ฝ่ายขายขอ:'); + expect((await row.findByTestId('pcr-factory-fx-91')).textContent).toContain('1 EUR = 40 บาท'); }); - it('terms row: a THB / legacy factory has no FX note and no currency / unit select', async () => { + it('terms row: THB works without a foreign exchange rate', async () => { api.fxRates = { list: vi.fn().mockResolvedValue({ fxRates: [{ currency: 'EUR', rateToThb: 40 }] }) }; renderDetailPage({ user: importUser, request: buildRequest(), factoryQuotes: [buildFactoryQuote({ status: 'REQUESTED' })] }); await waitForLoaded(); const row = within(await screen.findByTestId('pcr-factory-terms-row-91')); - expect(row.queryByRole('combobox')).toBeNull(); + expect(row.getByRole('combobox').value).toBe('THB'); expect(row.getByTestId('pcr-factory-currency-91')).not.toBeNull(); - expect(row.queryByTestId('pcr-factory-fx-91')).toBeNull(); + expect(row.getByTestId('pcr-factory-fx-91').textContent).toBe('1 THB = 1 บาท'); }); }); @@ -7892,10 +7946,9 @@ describe('Import PCR redesign — factory-first page (import role)', () => { await screen.findByTestId('pcr-factory-card-91'); }; - it('a legacy line has no currency select and shows the read-only code', async () => { + it('a legacy line has a currency selector', async () => { await render(legacyEur()); - expect(screen.queryByRole('combobox', { name: /^สกุลเงิน/ })).toBeNull(); - expect(screen.getByTestId('pcr-factory-currency-91').textContent).toContain('EUR €'); + expect(screen.getByRole('combobox', { name: /^สกุลเงิน/ }).value).toBe('EUR'); }); it('a legacy line\'s unit switch offers exactly / แผ่น and / ตร.ม., with the current one pressed', async () => { @@ -7918,7 +7971,7 @@ describe('Import PCR redesign — factory-first page (import role)', () => { )); }); - it('currency parity: when defaultCurrency differs from a line\'s currency, confirm sends exactly the seeded values', async () => { + it('uses the line currency as the default for a fresh single-currency quote', async () => { const base = buildFactoryQuote(); const quote = buildFactoryQuote({ status: 'REQUESTED', defaultCurrency: 'USD', items: [{ ...base.items[0], currency: 'EUR' }] }); await render(quote); @@ -7926,7 +7979,7 @@ describe('Import PCR redesign — factory-first page (import role)', () => { fireEvent.click(screen.getByRole('button', { name: 'ยืนยันราคาเสนอ' })); await waitFor(() => expect(api.pricingRequests.receiveFactoryQuote).toHaveBeenCalled()); const payload = api.pricingRequests.receiveFactoryQuote.mock.calls[0][1]; - expect(payload.defaultCurrency).toBe('USD'); + expect(payload.defaultCurrency).toBe('EUR'); expect(payload.items[0].currency).toBe('EUR'); }); @@ -7961,7 +8014,7 @@ describe('Import PCR redesign — factory-first page (import role)', () => { it('mixed currencies: the locked pill says so instead of showing only a lock icon', async () => { const request = crRequest({ items: [crItem({ id: 1 }), crItem({ id: 2, model: 'A2', requestedCurrency: 'USD' })] }); await render(contactedQuote({ items: [line(91, 1), line(91, 2, { currency: 'USD' })] }), request); - expect(screen.getByTestId('pcr-factory-currency-91').textContent).toContain('หลายสกุลเงิน — ดูในแต่ละรายการ'); + expect(screen.getByTestId('pcr-factory-currency-91').textContent).toContain('หลายสกุลเงิน — เลือกเพื่อใช้ทั้งโรงงาน'); }); it('header terms: only printedByDisplayId / salesRepDisplayId set → no terms row (it never shows them)', async () => { @@ -7983,8 +8036,8 @@ describe('Import PCR redesign — factory-first page (import role)', () => { const quote = contactedQuote({ items: [line(91, 1), line(91, 2, { currency: 'USD' })] }); await render(quote, request); const header = screen.getByTestId('pcr-factory-currency-91').textContent; - expect(header).toContain('ตามคำขอราคา'); - expect(header).not.toMatch(/EUR|USD/); + expect(header).toContain('ฝ่ายขายขอ:'); + expect(header).toContain('ฝ่ายขายขอ: EUR, USD'); expect(screen.getByTestId('pcr-quote-symbol-91-1').textContent).toBe('€'); expect(screen.getByTestId('pcr-quote-symbol-91-2').textContent).toBe('$'); expect(screen.getByTestId('pcr-quote-currency-91-2').textContent).toContain('USD'); diff --git a/frontend/src/features/pricingRequests/quotedPriceThb.js b/frontend/src/features/pricingRequests/quotedPriceThb.js index f09f3d99b..efd4b0ad8 100644 --- a/frontend/src/features/pricingRequests/quotedPriceThb.js +++ b/frontend/src/features/pricingRequests/quotedPriceThb.js @@ -11,6 +11,20 @@ */ const positive = (value) => (Number(value) > 0 ? Number(value) : null); +/** The FX row for a currency, THB being 1 by definition (no row needed). */ +export function fxRateFor(currency, fxRates) { + return currency === 'THB' ? { currency: 'THB', rateToThb: 1 } : (fxRates ?? []).find((fx) => fx.currency === currency) ?? null; +} + +/** Mirrors FxResolver.MAX_RATE_AGE_DAYS: costing refuses a non-THB rate older than this. */ +export const MAX_FX_RATE_AGE_DAYS = 7; + +export function isFxRateStale(fx, today = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Bangkok' }).format(new Date())) { + if (!fx || fx.currency === 'THB') return false; + if (!fx.effectiveDate) return true; + return (Date.parse(today) - Date.parse(fx.effectiveDate)) / 86_400_000 > MAX_FX_RATE_AGE_DAYS; +} + export function quotedPriceInThb(quoteItem, requestItem, fxRates) { const basis = quoteItem?.unitBasis; if (basis !== 'PER_SQM' && basis !== 'PER_PIECE') return null; @@ -18,7 +32,7 @@ export function quotedPriceInThb(quoteItem, requestItem, fxRates) { const price = positive(quoteItem.rawUnitPrice); const sqmPerPiece = positive(quoteItem.sqmPerUnit) ?? positive(requestItem?.sqmPerPiece); const item = requestItem ?? {}; - const rate = (fxRates ?? []).find((fx) => fx.currency === quoteItem.currency)?.rateToThb; + const rate = fxRateFor(quoteItem.currency, fxRates)?.rateToThb; if (price == null || sqmPerPiece == null || positive(rate) == null) return null; const pricePerUnitThb = price * Number(rate); diff --git a/frontend/src/features/pricingRequests/quotedPriceThb.test.js b/frontend/src/features/pricingRequests/quotedPriceThb.test.js index 3163453fd..f2e2f063c 100644 --- a/frontend/src/features/pricingRequests/quotedPriceThb.test.js +++ b/frontend/src/features/pricingRequests/quotedPriceThb.test.js @@ -37,7 +37,7 @@ describe('quotedPriceInThb', () => { }); it('treats THB as rate 1', () => { - expect(quotedPriceInThb(quote({ currency: 'THB', rawUnitPrice: 1000 }), request(), FX).perSqm).toBeCloseTo(1000); + expect(quotedPriceInThb(quote({ currency: 'THB', rawUnitPrice: 1000 }), request(), []).perSqm).toBeCloseTo(1000); }); it.each([ diff --git a/render.yaml b/render.yaml index 5ebe8cd50..4e52a626d 100644 --- a/render.yaml +++ b/render.yaml @@ -40,7 +40,7 @@ services: name: gl-r-erp runtime: image image: - url: ghcr.io/waritctd/glr-hr-backend:v2026-11-07e + url: ghcr.io/waritctd/glr-hr-backend:v2026-11-10 creds: fromRegistryCreds: name: ghcr-glr