From 0c1d4de5f8509dc2a90a8f296aa1e08e9b757b7a Mon Sep 17 00:00:00 2001 From: waritctd <148909369+waritctd@users.noreply.github.com> Date: Tue, 10 Nov 2026 12:42:39 +0700 Subject: [PATCH 1/3] feat(pcr): import may change the factory quote currency, with live baht conversion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sales' requested currency seeds the quote and stays on the request as 'ฝ่ายขายขอ'; Import picks the supplier's actual currency per factory and sees the THB equivalent per unit and in total while typing. --- .../hr/factoryquote/FactoryQuoteService.java | 9 +-- .../FactoryContactFlowIntegrationTest.java | 24 +++--- frontend/e2e-real/write-pcr-currency.spec.js | 75 +++++++++++++++++++ .../src/api/mockApi.factoryContact.test.js | 6 +- frontend/src/api/mockApi.js | 8 +- .../ImportPricingWorkspace.jsx | 29 ++++--- .../PricingRequestDetailPage.jsx | 47 ++++++------ .../PricingRequestDetailPage.test.jsx | 69 +++++++++++------ .../pricingRequests/quotedPriceThb.js | 2 +- .../pricingRequests/quotedPriceThb.test.js | 2 +- 10 files changed, 181 insertions(+), 90 deletions(-) create mode 100644 frontend/e2e-real/write-pcr-currency.spec.js 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..d307323f3 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 @@ -738,13 +738,8 @@ 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 remains + // on the original request for reference. Existing role/state gates still apply. // 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 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..f04916a10 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,23 @@ 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()); - - // 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); + 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"); } /** 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..2de9223ad 100644 --- a/frontend/src/features/pricingRequests/ImportPricingWorkspace.jsx +++ b/frontend/src/features/pricingRequests/ImportPricingWorkspace.jsx @@ -122,7 +122,7 @@ function PriceCell({ row, quoteId }) { {p.mode === 'input' ? (

- ) : –} {row.thb ? ( -
{row.thb}
+
{row.thb}
) : row.priceEmpty ?
ยังไม่กรอก
: null}
); @@ -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..69bba6fb9 100644 --- a/frontend/src/features/pricingRequests/PricingRequestDetailPage.jsx +++ b/frontend/src/features/pricingRequests/PricingRequestDetailPage.jsx @@ -163,16 +163,7 @@ function apiStatus(error) { return typeof error?.status === 'number' ? error.status : null; } -/** - * 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 from the saved factory quote, falling back to Sales' requested terms. */ function defaultResponseItems(quote, requestItemById = new Map()) { return (quote?.items ?? []).map((item) => { const requestItem = requestItemById.get(item.pricingRequestItemId) ?? {}; @@ -187,11 +178,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 +191,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. @@ -2641,10 +2629,7 @@ export function PricingRequestDetailPage({ user, showToast }) { const unsavedDraft = inResponseWindow ? responseDrafts[current.id] : undefined; 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: current.items?.some((item) => item.rawUnitPrice != null) ? current.defaultCurrency || 'THB' : defaultResponseItems(current, requestItemById)[0]?.currency || current.defaultCurrency || 'THB', paymentTerms: current.paymentTerms ?? '', leadTimeText: current.leadTimeText ?? '', revisionReason: '', @@ -2723,7 +2708,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, requested, fxRatesQuery.data) : null; + const rate = lineCurrency === 'THB' ? 1 : (fxRatesQuery.data ?? []).find((fx) => fx.currency === lineCurrency)?.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 +2766,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, @@ -2819,11 +2809,18 @@ 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, + options: [...new Set(['THB', 'EUR', 'USD', 'CNY', ...(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 })} บาท` + : 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..1e0a497df 100644 --- a/frontend/src/features/pricingRequests/PricingRequestDetailPage.test.jsx +++ b/frontend/src/features/pricingRequests/PricingRequestDetailPage.test.jsx @@ -2885,11 +2885,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 +4354,52 @@ 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('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 +7664,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 +7916,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 +7941,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 +7949,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 +7984,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 +8006,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..3ea12475c 100644 --- a/frontend/src/features/pricingRequests/quotedPriceThb.js +++ b/frontend/src/features/pricingRequests/quotedPriceThb.js @@ -18,7 +18,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 = quoteItem.currency === 'THB' ? 1 : (fxRates ?? []).find((fx) => fx.currency === quoteItem.currency)?.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([ From b4236634b5475efbe1742330f4782b4c9ab1555f Mon Sep 17 00:00:00 2001 From: waritctd <148909369+waritctd@users.noreply.github.com> Date: Tue, 10 Nov 2026 12:43:20 +0700 Subject: [PATCH 2/3] fix(pcr): live FX rates, rate date + 7-day warning, currency validation, compact price cell - FX rates re-read on every page open and window focus (no 5-min cache) - currency list = THB + currencies with a rate on file; rate date shown, warning when older than FxResolver's 7-day limit - backend: quote currency trimmed/upper-cased and must be a 3-letter code - smaller price input and unit switch on desktop --- .../hr/factoryquote/FactoryQuoteService.java | 24 ++++++++++--- .../FactoryContactFlowIntegrationTest.java | 16 +++++++++ .../ImportPricingWorkspace.jsx | 12 +++---- .../PricingRequestDetailPage.jsx | 34 ++++++++++++++----- .../PricingRequestDetailPage.test.jsx | 32 ++++++++++++++++- .../pricingRequests/quotedPriceThb.js | 16 ++++++++- 6 files changed, 112 insertions(+), 22 deletions(-) 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 d307323f3..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,8 +738,9 @@ private List validateAndNormalizeResponseItems( throw new ApiException(HttpStatus.BAD_REQUEST, "รายการตอบกลับนี้เป็นของโรงงานอื่น"); } String unitBasis = UnitBasis.canonicalize(responseItem.unitBasis(), "Factory quote unit"); - // Import records the supplier's actual currency; Sales' requested currency remains - // on the original request for reference. Existing role/state gates still apply. + // 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 @@ -780,7 +781,7 @@ private List validateAndNormalizeResponseItems( quotedUnit, unitBasis, responseItem.rawUnitPrice(), - responseItem.currency(), + currency, responseItem.minimumOrderQuantity(), responseItem.sqmPerUnit(), responseItem.piecesPerBox(), @@ -793,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 f04916a10..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 @@ -444,6 +444,22 @@ void importMayChangeCurrencyWithoutChangingSalesRequest() { 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()); + + 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; Import may also change currency. */ @Test diff --git a/frontend/src/features/pricingRequests/ImportPricingWorkspace.jsx b/frontend/src/features/pricingRequests/ImportPricingWorkspace.jsx index 2de9223ad..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' ? (
- -