Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -738,13 +738,9 @@ private List<ReceiveFactoryQuoteItemRequest> 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
Expand Down Expand Up @@ -785,7 +781,7 @@ private List<ReceiveFactoryQuoteItemRequest> validateAndNormalizeResponseItems(
quotedUnit,
unitBasis,
responseItem.rawUnitPrice(),
responseItem.currency(),
currency,
responseItem.minimumOrderQuantity(),
responseItem.sqmPerUnit(),
responseItem.piecesPerBox(),
Expand All @@ -798,6 +794,19 @@ private List<ReceiveFactoryQuoteItemRequest> 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, "ไม่พบคำขอราคานี้"));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
* real services/repositories.
*
* <p>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.
*
* <p>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.
Expand Down Expand Up @@ -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(
Expand Down
75 changes: 75 additions & 0 deletions frontend/e2e-real/write-pcr-currency.spec.js
Original file line number Diff line number Diff line change
@@ -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);
}
});
6 changes: 3 additions & 3 deletions frontend/src/api/mockApi.factoryContact.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
8 changes: 1 addition & 7 deletions frontend/src/api/mockApi.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading