diff --git a/backend/src/main/java/th/co/glr/hr/factory/FactoryConfigController.java b/backend/src/main/java/th/co/glr/hr/factory/FactoryConfigController.java index 5af41aeea..dd98e1f4e 100644 --- a/backend/src/main/java/th/co/glr/hr/factory/FactoryConfigController.java +++ b/backend/src/main/java/th/co/glr/hr/factory/FactoryConfigController.java @@ -28,8 +28,9 @@ public class FactoryConfigController { private static final String[] READ_ROLES = { "ceo", "import" }; /** Names only (W3-03 follow-up, owner ruling 2026-10-29): the owning sales rep and sales_manager pick a - * factory for a factory-less line when creating the IR. No email/currency/unit leaves this route. */ - private static final String[] NAME_READ_ROLES = { "ceo", "import", "sales", "sales_manager" }; + * factory for a factory-less line when creating the IR; account opens /catalog, whose factory filter + * reads this. No email/currency/unit leaves this route. */ + private static final String[] NAME_READ_ROLES = { "ceo", "import", "sales", "sales_manager", "account" }; public record FactoryNameDto(long id, String factoryName) {} diff --git a/backend/src/main/java/th/co/glr/hr/pricing/FxRateController.java b/backend/src/main/java/th/co/glr/hr/pricing/FxRateController.java index 2219fdfa7..2005a8345 100644 --- a/backend/src/main/java/th/co/glr/hr/pricing/FxRateController.java +++ b/backend/src/main/java/th/co/glr/hr/pricing/FxRateController.java @@ -24,8 +24,9 @@ public class FxRateController { /** * Read gate, owner ruling 2026-08-02 — widened from issue #388's {@code ceo}/{@code import} - * gate by exactly one role, to {@code sales}. NOT opened to every authenticated session: the - * owner's words were "should only be to sale", and this set is the whole of that ruling. + * gate by exactly one role, to {@code sales}, and to {@code sales_manager} by the 2026-10-29 + * ruling below. NOT opened to every authenticated session: the owner's words were "should + * only be to sale", and this set is the whole of those two rulings. * *

The trigger was the deal-create modal's "ราคาตั้ง (ประมาณการ)" estimate (V112): a rep who * picks a catalog item needs the FX rate to convert its price to THB, and every catalog row is @@ -45,6 +46,11 @@ public class FxRateController { * number already visible to this caller, so it survives its trigger. Re-narrowing it is a * separate owner decision, not a tidy-up that follows from #748. * + *

Widened again, owner ruling 2026-10-29 (role check RC-01): {@code sales_manager} + * joins the set — the deal page converts SM's foreign-currency prices to THB, which needs this + * rate. The "sales_manager cannot create a deal" argument above no longer decides the gate. + * Nothing else changes; writes stay CEO-only. + * *

The reasoning is deliberately narrower than #388's: an FX rate only converts a number that * is ALREADY visible to this caller — {@code /api/catalog/prices} is open to any authenticated * user per the 2026-08-01 ruling that closed #388 — and on its own reveals nothing about landed @@ -55,7 +61,7 @@ public class FxRateController { *

If a test pinning this set fails, someone has re-narrowed or re-widened the gate: reopen * the ruling with the owner rather than editing the test to match the code. */ - private static final Set READ_ROLES = Set.of("ceo", "import", "sales"); + private static final Set READ_ROLES = Set.of("ceo", "import", "sales", "sales_manager"); private final FxRateRepository fxRates; private final SessionContext sessions; diff --git a/backend/src/test/java/th/co/glr/hr/catalog/CatalogPricingReadAuthzIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/catalog/CatalogPricingReadAuthzIntegrationTest.java index f73d9254d..43fbc598a 100644 --- a/backend/src/test/java/th/co/glr/hr/catalog/CatalogPricingReadAuthzIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/catalog/CatalogPricingReadAuthzIntegrationTest.java @@ -66,15 +66,14 @@ * (2026-07-16). *

  • {@code GET /api/catalog/prices} → open to any authenticated user, per the owner's * 2026-08-01 ruling closing #388: #205's "browsable" covers the supplier purchase price.
  • - *
  • {@code GET /api/fx-rates} → {@code ceo}/{@code import}/{@code sales}, per the - * owner's 2026-08-02 ruling (see {@code FxRateController.READ_ROLES}): an FX rate only + *
  • {@code GET /api/fx-rates} → {@code ceo}/{@code import}/{@code sales}/ + * {@code sales_manager}, per the owner's 2026-08-02 ruling (sales) and 2026-10-29 + * ruling RC-01 (sales_manager) — see {@code FxRateController.READ_ROLES}: an FX rate only * converts a number already visible via the open catalog-prices read, and reveals nothing - * about landed cost or margin on its own — but the ruling was "should only be to sale", so - * this is one role wider than #388, NOT open to every authenticated session.
  • + * about landed cost or margin on its own. NOT open to every authenticated session. *
  • {@code GET /api/price-calc-configs}, {@code GET /api/factory-configs} → ceo/import - * ({@code RAW_COSTING_ROLES}), unchanged. The FX widening is deliberately narrower than - * #388's catalog ruling: it does not extend to the margin policy or the supplier - * directory.
  • + * ({@code RAW_COSTING_ROLES}), unchanged. The FX widening does not extend to the margin + * policy or this supplier-directory endpoint. * */ class CatalogPricingReadAuthzIntegrationTest extends AbstractPostgresIntegrationTest { @@ -211,20 +210,29 @@ void salesCanReadFxRatesPerOwnerRuling20260802() throws Exception { .andExpect(jsonPath("$.fxRates[?(@.currency == 'XTS')].rateToThb").exists()); } + /** + * Owner ruling 2026-10-29 (role check RC-01): the deal page converts SM's foreign-currency + * prices to THB, so the FX read widens to {@code sales_manager}. Writes stay CEO-only. + */ + @Test + void salesManagerCanReadFxRatesPerOwnerRuling20261029() throws Exception { + fxMvc.perform(get("/api/fx-rates").session(sessionFor("sales_manager"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.fxRates[?(@.currency == 'XTS')].rateToThb").exists()); + } + /** * THE OTHER HALF OF THE SAME RULING, and the half that actually catches a regression. The * ruling was "should only be to sale" — it did NOT open the rate to every authenticated * session. Without this case the suite stays green with the gate wide open, which is precisely * the failure mode a read-gate widening has: you find out in production. * - *

    {@code sales_manager} is in this list on purpose. It browses the deal pipeline but cannot - * create a deal ({@code canCreateTickets} is {@code ['sales']}, and {@code TicketService.create} - * gates on {@code Set.of("sales")}), so it never reaches the modal that needs the rate. Adding - * it here would be widening beyond what the owner authorised. + *

    {@code sales_manager} left this list by the owner's ruling of 2026-10-29 (role check + * RC-01) and is pinned by {@link #salesManagerCanReadFxRatesPerOwnerRuling20261029}. */ @Test void everyRoleOutsideTheRulingIsStillRefusedTheFxRead() throws Exception { - for (String role : List.of("employee", "warehouse", "qc", "hr", "sales_manager", "account")) { + for (String role : List.of("employee", "warehouse", "qc", "hr", "account")) { fxMvc.perform(get("/api/fx-rates").session(sessionFor(role))) .andExpect(status().isForbidden()); } diff --git a/backend/src/test/java/th/co/glr/hr/factory/FactoryConfigControllerTest.java b/backend/src/test/java/th/co/glr/hr/factory/FactoryConfigControllerTest.java index 314eaf5d9..81265b64c 100644 --- a/backend/src/test/java/th/co/glr/hr/factory/FactoryConfigControllerTest.java +++ b/backend/src/test/java/th/co/glr/hr/factory/FactoryConfigControllerTest.java @@ -64,7 +64,8 @@ void listIsUnauthorizedWithoutASession() throws Exception { void namesAreReadableByIrCreators_andExposeOnlyIdAndName() throws Exception { when(repo.findAll()).thenReturn(List.of( new FactoryConfigDto(7L, "Rex Ceramics", "rfq@rex.example", "EUR", "sqm", "IT", null))); - for (String role : List.of("ceo", "import", "sales", "sales_manager")) { + // account added 2026-10-29 (owner ruling): it can open /catalog, whose factory filter reads this. + for (String role : List.of("ceo", "import", "sales", "sales_manager", "account")) { mvc.perform(get("/api/factory-configs/names").session(session(role))) .andExpect(status().is2xxSuccessful()) .andExpect(content().string(containsString("Rex Ceramics"))) @@ -75,7 +76,7 @@ void namesAreReadableByIrCreators_andExposeOnlyIdAndName() throws Exception { @Test void namesAreForbiddenForOtherRoles() throws Exception { when(repo.findAll()).thenReturn(List.of()); - for (String role : List.of("employee", "warehouse", "qc", "hr", "account")) { + for (String role : List.of("employee", "warehouse", "qc", "hr")) { mvc.perform(get("/api/factory-configs/names").session(session(role))) .andExpect(status().isForbidden()); } diff --git a/backend/src/test/java/th/co/glr/hr/pricing/FxRateControllerTest.java b/backend/src/test/java/th/co/glr/hr/pricing/FxRateControllerTest.java index ee35f4aed..9961efab8 100644 --- a/backend/src/test/java/th/co/glr/hr/pricing/FxRateControllerTest.java +++ b/backend/src/test/java/th/co/glr/hr/pricing/FxRateControllerTest.java @@ -25,8 +25,9 @@ * {@code ceo}/{@code import} gate by exactly one role, to {@code sales}, so the deal-create modal's * "ราคาตั้ง (ประมาณการ)" estimate can convert a catalog price to THB for a plain {@code sales} * session. It was NOT opened to every authenticated session — the owner's words were "should only - * be to sale". See {@link FxRateController#READ_ROLES}'s javadoc for the full reasoning, including - * why this does NOT relax {@code PriceCalcConfigController}. + * be to sale". The owner widened it once more on 2026-10-29 (role check RC-01), to + * {@code sales_manager}. See {@link FxRateController#READ_ROLES}'s javadoc for the full reasoning, + * including why this does NOT relax {@code PriceCalcConfigController}. * *

    This class proves the DECISION (which branch the role check takes). * {@code CatalogPricingReadAuthzIntegrationTest} proves the ENFORCEMENT on real Postgres through the @@ -36,17 +37,16 @@ * suite that only asserts "sales can read" stays green with the gate wide open. */ class FxRateControllerTest { - /** The whole of the 2026-08-02 ruling. Anything added here is a new ruling, not a refactor. */ - private static final List READ_ALLOWED = List.of("ceo", "import", "sales"); - /** - * Everyone else. {@code sales_manager} belongs here deliberately: it browses the deal pipeline - * but cannot create a deal ({@code canCreateTickets} is {@code ['sales']} and - * {@code TicketService.create} gates on {@code Set.of("sales")}), so it never reaches the modal - * that needs the rate. + * The 2026-08-02 ruling plus {@code sales_manager}, owner ruling 2026-10-29 (role check RC-01: + * the deal page converts foreign-currency prices to THB for SM too). Anything added here is a + * new ruling, not a refactor. */ + private static final List READ_ALLOWED = List.of("ceo", "import", "sales", "sales_manager"); + + /** Everyone else. */ private static final List READ_DENIED = - List.of("employee", "warehouse", "qc", "hr", "sales_manager", "account"); + List.of("employee", "warehouse", "qc", "hr", "account"); /** Everyone but CEO — the write gate that /fetch-now shares with upsert. */ private static final List WRITE_DENIED = diff --git a/frontend/src/api/mockApi.js b/frontend/src/api/mockApi.js index 02f28a458..c34b50361 100644 --- a/frontend/src/api/mockApi.js +++ b/frontend/src/api/mockApi.js @@ -12927,7 +12927,7 @@ export const api = { }, // Mirrors FactoryConfigController#names: id + name only, for the IR creators too. async names() { - hasRole('ceo', 'import', 'sales', 'sales_manager'); + hasRole('ceo', 'import', 'sales', 'sales_manager', 'account'); return delay({ factories: mockPriceImportFactories.map((f) => ({ id: f.factoryId, factoryName: f.name })) }); }, // sendEmail is retired — POST /api/tickets/{id}/factory-emails/send no longer exists (factory @@ -12938,12 +12938,13 @@ export const api = { // Owner ruling 2026-08-02: list() widened from #388's ceo/import gate by exactly one role, to // sales, so the deal-create modal's ราคาตั้ง estimate can convert a catalog price to THB for a // plain rep. NOT opened to every authenticated session — the ruling was "should only be to sale". - // See FxRateController.READ_ROLES for the full reasoning; it does NOT extend to priceCalcConfigs - // below, which is the real margin policy. upsert stays CEO-only, unchanged. + // sales_manager added 2026-10-29 (RC-01). See FxRateController.READ_ROLES for the full + // reasoning; it does NOT extend to priceCalcConfigs below, which is the real margin policy. + // upsert stays CEO-only, unchanged. // Reminder: this mock's authz is NOT authoritative — the Java service is (see CLAUDE.md). fxRates: { async list() { - hasRole('ceo', 'import', 'sales'); + hasRole('ceo', 'import', 'sales', 'sales_manager'); return delay({ fxRates: structuredClone(mockFxRates) }); }, async upsert(currency, payload) { diff --git a/frontend/src/features/catalog/CatalogSearchPage.jsx b/frontend/src/features/catalog/CatalogSearchPage.jsx index 03fd34cc2..4addffc22 100644 --- a/frontend/src/features/catalog/CatalogSearchPage.jsx +++ b/frontend/src/features/catalog/CatalogSearchPage.jsx @@ -88,7 +88,10 @@ export function CatalogSearchPage({ user, showToast }) { const inputRef = useRef(null); useEffect(() => { - api.priceImport.factories().then(setFactories).catch(() => {}); + // Names-only read (RC-03): /api/price-import/factories is ceo/import and carries supplier emails. + api.factoryConfigs.names() + .then((res) => setFactories((res.factories ?? []).map((f) => ({ factoryId: f.id, name: f.factoryName })))) + .catch(() => {}); inputRef.current?.focus(); }, []); diff --git a/frontend/src/features/catalog/CatalogSearchPage.test.jsx b/frontend/src/features/catalog/CatalogSearchPage.test.jsx index 9fe4a59f8..3b2c68993 100644 --- a/frontend/src/features/catalog/CatalogSearchPage.test.jsx +++ b/frontend/src/features/catalog/CatalogSearchPage.test.jsx @@ -11,6 +11,9 @@ vi.mock('../../api/index.js', () => ({ priceImport: { factories: vi.fn(), }, + factoryConfigs: { + names: vi.fn(), + }, catalog: { prices: vi.fn(), }, @@ -24,7 +27,10 @@ vi.mock('../../hooks/useIsMobile.js', () => ({ describe('CatalogSearchPage audit copy', () => { beforeEach(() => { vi.clearAllMocks(); - api.priceImport.factories.mockResolvedValue([]); + // Mocked the way Java answers sales/sales_manager: PriceImportController#factories is + // ceo/import only; FactoryConfigController#names is open to every role that can open /catalog. + api.priceImport.factories.mockRejectedValue(Object.assign(new Error('forbidden'), { status: 403 })); + api.factoryConfigs.names.mockResolvedValue({ factories: [] }); api.catalog.prices.mockResolvedValue({ items: [] }); }); @@ -34,4 +40,14 @@ describe('CatalogSearchPage audit copy', () => { expect(await screen.findByText('ราคาจากรายการราคาที่ใช้งานล่าสุด')).not.toBeNull(); expect(screen.queryByText(/price list|ACTIVE/i)).toBeNull(); }); + + // RC-03 (role check 2026-10-29, owner ruling): the factory filter was silently empty for sales + // and sales_manager because it read the ceo/import-only /api/price-import/factories. + it('fills the factory filter from the names-only endpoint', async () => { + api.factoryConfigs.names.mockResolvedValue({ factories: [{ id: 42, factoryName: 'Mutina' }] }); + render(); + + const option = await screen.findByRole('option', { name: 'Mutina' }); + expect(option.getAttribute('value')).toBe('42'); + }); }); diff --git a/frontend/src/features/dashboard/HrOverview.jsx b/frontend/src/features/dashboard/HrOverview.jsx index 6eb94464f..435af7ad7 100644 --- a/frontend/src/features/dashboard/HrOverview.jsx +++ b/frontend/src/features/dashboard/HrOverview.jsx @@ -7,7 +7,7 @@ import { Button } from '../../components/common/Button.jsx'; import { StatCard } from '../../components/common/StatCard.jsx'; import { PageHeader } from '../../components/common/PageHeader.jsx'; import { PageStack, Panel, StatGrid } from '../../components/common/Layout.jsx'; -import { formatThaiDate, payrollStatusLabel } from '../../utils/format.js'; +import { bangkokMonthStartIso, formatThaiDate, payrollStatusLabel } from '../../utils/format.js'; import { divisions, findDivision } from '../../data/referenceData.js'; import { ActionQueue } from './ActionQueue.jsx'; @@ -74,9 +74,12 @@ function MonitorRow({ label, value, onClick }) { export function HrOverview({ employees, dashboardSummary }) { const navigate = useNavigate(); + // PayrollController requires payrollMonth (RC-04); Bangkok month, not UTC. Own key suffix: + // PayrollPage caches a different shape under payrollCurrent(month). + const payrollMonth = bangkokMonthStartIso().slice(0, 7); const payrollQuery = useQuery({ - queryKey: queryKeys.payrollCurrent(), - queryFn: () => api.payroll.current().then((response) => response?.period ?? null).catch(() => null), + queryKey: [...queryKeys.payrollCurrent(payrollMonth), 'overview'], + queryFn: () => api.payroll.current({ payrollMonth }).then((response) => response?.period ?? null), enabled: !!api.payroll?.current, }); const payrollPeriod = payrollQuery.data ?? null; @@ -152,9 +155,11 @@ export function HrOverview({ employees, dashboardSummary }) { { key: 'notifications', label: 'แจ้งเตือนยังไม่อ่าน', value: stats.unreadNotifications }, ]; - const payrollDetail = payrollPeriod - ? `${formatThaiDate(payrollPeriod.payrollMonth)} · ${payrollStatusLabel(payrollPeriod.status).label}` - : 'ยังไม่เริ่มรอบเงินเดือนเดือนนี้'; + const payrollDetail = payrollQuery.isError + ? 'โหลดรอบเงินเดือนไม่สำเร็จ' + : payrollPeriod + ? `${formatThaiDate(payrollPeriod.payrollMonth)} · ${payrollStatusLabel(payrollPeriod.status).label}` + : 'ยังไม่เริ่มรอบเงินเดือนเดือนนี้'; return ( diff --git a/frontend/src/features/dashboard/HrOverview.test.jsx b/frontend/src/features/dashboard/HrOverview.test.jsx index fbfd1cae2..72cde6711 100644 --- a/frontend/src/features/dashboard/HrOverview.test.jsx +++ b/frontend/src/features/dashboard/HrOverview.test.jsx @@ -1,9 +1,11 @@ import React from 'react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { MemoryRouter } from 'react-router-dom'; -import { render, screen, within } from '@testing-library/react'; +import { render, screen, waitFor, within } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import { HrOverview } from './HrOverview.jsx'; +import { api } from '../../api/index.js'; +import { queryKeys } from '../../api/queryKeys.js'; globalThis.React = React; @@ -47,10 +49,9 @@ const dashboardSummary = { notifications: { unread: 5 }, }; -function renderOverview(summary = dashboardSummary) { - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, - }); +function renderOverview(summary = dashboardSummary, queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, +})) { return render( @@ -81,6 +82,49 @@ describe('HrOverview', () => { expect(screen.getByText('ไปที่หน้าเงินเดือน').closest('button')).not.toBeNull(); }); + // RC-04 (role check 2026-10-29): PayrollController#currentOrPreview requires payrollMonth and + // 400s without it. 2026-10-31T18:00Z is already 1 Nov in Bangkok, so a UTC month would send + // the wrong period on the last evening of every month. + it('asks for the current Bangkok payroll month', async () => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date('2026-10-31T18:00:00Z')); + try { + api.payroll.current.mockClear(); + renderOverview(); + await waitFor(() => expect(api.payroll.current).toHaveBeenCalledWith({ payrollMonth: '2026-11' })); + } finally { + vi.useRealTimers(); + } + }); + + // Review of RC-04: PayrollPage caches { period, suggestionsByEmployee, ... } under + // payrollCurrent(month). The landing must not overwrite that entry with a bare period, or the + // payroll page opened within staleTime renders an empty table. + it('leaves the payroll page cache entry for the same month untouched', async () => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date('2026-10-15T05:00:00Z')); + try { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const payrollPageEntry = { period: { id: 7, status: 'PROCESSED' }, suggestionsByEmployee: {} }; + queryClient.setQueryData(queryKeys.payrollCurrent('2026-10'), payrollPageEntry); + api.payroll.current.mockClear(); + renderOverview(dashboardSummary, queryClient); + await waitFor(() => expect(api.payroll.current).toHaveBeenCalled()); + await waitFor(() => expect(queryClient.isFetching()).toBe(0)); + expect(queryClient.getQueryData(queryKeys.payrollCurrent('2026-10'))).toBe(payrollPageEntry); + } finally { + vi.useRealTimers(); + } + }); + + // A failed payroll read used to render as "not started", which is how RC-04's 400 hid. + it('says the payroll period failed to load instead of claiming it has not started', async () => { + api.payroll.current.mockRejectedValueOnce(Object.assign(new Error('bad request'), { status: 400 })); + renderOverview(); + expect(await screen.findByText('โหลดรอบเงินเดือนไม่สำเร็จ')).not.toBeNull(); + expect(screen.queryByText('ยังไม่เริ่มรอบเงินเดือนเดือนนี้')).toBeNull(); + }); + it('shows today attendance counts', () => { renderOverview(); const panel = screen.getByText('การลงเวลาวันนี้').closest('section'); diff --git a/frontend/src/features/fulfilment/ImportFulfilmentPage.jsx b/frontend/src/features/fulfilment/ImportFulfilmentPage.jsx index 39df45d37..4c46b3012 100644 --- a/frontend/src/features/fulfilment/ImportFulfilmentPage.jsx +++ b/frontend/src/features/fulfilment/ImportFulfilmentPage.jsx @@ -202,16 +202,14 @@ export function ImportFulfilmentPage({ user, showToast }) { let completesDeal = false; if (targetStep === 'RECEIVED') { try { - const [{ ticket: freshTicket }, { importRequests: freshRows }] = await Promise.all([ - api.tickets.get(ticketId), - api.storedImportRequests.listForTicket(ticketId), - ]); - const fs = freshTicket?.summary?.fulfillmentStatus; + // importDeals.get, not tickets.get: TicketService#get refuses role import (RC-02). + const { deal: freshDeal } = await api.importDeals.get(ticketId); + const fs = freshDeal?.fulfillmentStatus; if (fs === 'GOODS_RECEIVED') { completesDeal = true; } else if (fs === 'PARTIALLY_DELIVERED') { // A factory still in DRAFT means the server's allFactoriesReceived declined the rollup. - const all = freshRows ?? []; + const all = freshDeal?.importRequests ?? []; const issued = all.filter((r) => r.status === 'ISSUED'); completesDeal = issued.length > 0 && issued.every((r) => r.importStep === 'RECEIVED') && !all.some((r) => r.status === 'DRAFT'); diff --git a/frontend/src/features/fulfilment/ImportFulfilmentPage.test.jsx b/frontend/src/features/fulfilment/ImportFulfilmentPage.test.jsx index eff2f68cf..e405376b1 100644 --- a/frontend/src/features/fulfilment/ImportFulfilmentPage.test.jsx +++ b/frontend/src/features/fulfilment/ImportFulfilmentPage.test.jsx @@ -21,6 +21,7 @@ vi.mock('../../api/index.js', async (importOriginal) => { ...actual, api: { tickets: { list: vi.fn(), get: vi.fn() }, + importDeals: { get: vi.fn() }, storedImportRequests: { listForTicket: vi.fn(), advanceStep: vi.fn(), @@ -102,7 +103,10 @@ describe('ImportFulfilmentPage (per-factory, stored aggregate)', () => { beforeEach(() => { vi.clearAllMocks(); api.tickets.list.mockResolvedValue({ tickets: [ticket()] }); - api.tickets.get.mockResolvedValue({ ticket: { summary: { fulfillmentStatus: 'IR_ISSUED' } } }); + // RC-02 (role check 2026-10-29): TicketService#get refuses import outright, so the deal read + // is mocked the way Java answers it; the page reads import's own projection instead. + api.tickets.get.mockRejectedValue(Object.assign(new Error('ไม่มีสิทธิ์เข้าถึงรายการนี้'), { status: 403 })); + api.importDeals.get.mockResolvedValue({ deal: { fulfillmentStatus: 'IR_ISSUED' } }); mockRowsByTicket({ 1: [row()] }); api.storedImportRequests.advanceStep.mockResolvedValue({ importRequest: row({ importStep: 'PICKED_UP' }) }); }); @@ -493,7 +497,7 @@ describe('ImportFulfilmentPage (per-factory, stored aggregate)', () => { // after the advance actually lands. describe('X4 — completion toast reflects the refetched ticket, not a client-side guess', () => { it('shows the ordinary step-advanced toast, never the "รับครบทุกโรงงาน" claim, when the refetched ticket is NOT GOODS_RECEIVED', async () => { - api.tickets.get.mockResolvedValue({ ticket: { summary: { fulfillmentStatus: 'IR_ISSUED' } } }); + api.importDeals.get.mockResolvedValue({ deal: { fulfillmentStatus: 'IR_ISSUED' } }); mockRowsByTicket({ 1: [row({ id: 1, importStep: 'AWAITING_CUSTOMS' })] }); api.storedImportRequests.advanceStep.mockResolvedValue({ importRequest: row({ id: 1, importStep: 'RECEIVED' }) }); const showToast = vi.fn(); @@ -506,7 +510,7 @@ describe('ImportFulfilmentPage (per-factory, stored aggregate)', () => { }); it('shows the completion toast when the refetched ticket is genuinely GOODS_RECEIVED', async () => { - api.tickets.get.mockResolvedValue({ ticket: { summary: { fulfillmentStatus: 'GOODS_RECEIVED' } } }); + api.importDeals.get.mockResolvedValue({ deal: { fulfillmentStatus: 'GOODS_RECEIVED' } }); mockRowsByTicket({ 1: [row({ id: 1, importStep: 'AWAITING_CUSTOMS' })] }); api.storedImportRequests.advanceStep.mockResolvedValue({ importRequest: row({ id: 1, importStep: 'RECEIVED' }) }); const showToast = vi.fn(); @@ -519,15 +523,14 @@ describe('ImportFulfilmentPage (per-factory, stored aggregate)', () => { it('shows the completion toast for a PARTIALLY_DELIVERED deal once the refetched rows confirm every issued row is RECEIVED', async () => { api.tickets.list.mockResolvedValue({ tickets: [ticket({ fulfillmentStatus: 'PARTIALLY_DELIVERED' })] }); - api.tickets.get.mockResolvedValue({ ticket: { summary: { fulfillmentStatus: 'PARTIALLY_DELIVERED' } } }); + // The confirmation read is import's own projection, which carries the post-advance rows; + // listForTicket still answers with the pre-advance rows, so a toast decided from it is wrong. + api.importDeals.get.mockResolvedValue({ deal: { + fulfillmentStatus: 'PARTIALLY_DELIVERED', + importRequests: [row({ id: 1, importStep: 'RECEIVED' })], + } }); mockRowsByTicket({ 1: [row({ id: 1, importStep: 'AWAITING_CUSTOMS' })] }); - api.storedImportRequests.advanceStep.mockImplementationOnce(async () => { - // The advance's own refetch (listForTicket, called directly by this page's onSuccess to - // decide the toast) must see the POST-advance state, exactly like a real advanceStep call - // landing before the follow-up read does — same pattern as the B3 test above. - mockRowsByTicket({ 1: [row({ id: 1, importStep: 'RECEIVED' })] }); - return { importRequest: row({ id: 1, importStep: 'RECEIVED' }) }; - }); + api.storedImportRequests.advanceStep.mockResolvedValue({ importRequest: row({ id: 1, importStep: 'RECEIVED' }) }); const showToast = vi.fn(); renderPage(showToast); const card = await screen.findByTestId('fulfilment-deal'); @@ -538,7 +541,10 @@ describe('ImportFulfilmentPage (per-factory, stored aggregate)', () => { it('does not claim completion for a PARTIALLY_DELIVERED deal while another issued row is still short of RECEIVED', async () => { api.tickets.list.mockResolvedValue({ tickets: [ticket({ fulfillmentStatus: 'PARTIALLY_DELIVERED' })] }); - api.tickets.get.mockResolvedValue({ ticket: { summary: { fulfillmentStatus: 'PARTIALLY_DELIVERED' } } }); + api.importDeals.get.mockResolvedValue({ deal: { + fulfillmentStatus: 'PARTIALLY_DELIVERED', + importRequests: [row({ id: 1, importStep: 'RECEIVED' }), row({ id: 2, factoryName: 'B', importStep: 'IN_TRANSIT' })], + } }); mockRowsByTicket({ 1: [row({ id: 1, importStep: 'AWAITING_CUSTOMS' }), row({ id: 2, factoryName: 'B', importStep: 'IN_TRANSIT' })], }); @@ -554,13 +560,13 @@ describe('ImportFulfilmentPage (per-factory, stored aggregate)', () => { it('does not claim completion for a PARTIALLY_DELIVERED deal while another factory is still a DRAFT', async () => { api.tickets.list.mockResolvedValue({ tickets: [ticket({ fulfillmentStatus: 'PARTIALLY_DELIVERED' })] }); - api.tickets.get.mockResolvedValue({ ticket: { summary: { fulfillmentStatus: 'PARTIALLY_DELIVERED' } } }); const draftB = row({ id: 2, factoryName: 'B', status: 'DRAFT', importStep: null }); + api.importDeals.get.mockResolvedValue({ deal: { + fulfillmentStatus: 'PARTIALLY_DELIVERED', + importRequests: [row({ id: 1, importStep: 'RECEIVED' }), draftB], + } }); mockRowsByTicket({ 1: [row({ id: 1, importStep: 'AWAITING_CUSTOMS' }), draftB] }); - api.storedImportRequests.advanceStep.mockImplementationOnce(async () => { - mockRowsByTicket({ 1: [row({ id: 1, importStep: 'RECEIVED' }), draftB] }); - return { importRequest: row({ id: 1, importStep: 'RECEIVED' }) }; - }); + api.storedImportRequests.advanceStep.mockResolvedValue({ importRequest: row({ id: 1, importStep: 'RECEIVED' }) }); const showToast = vi.fn(); renderPage(showToast); const card = await screen.findByTestId('fulfilment-deal'); diff --git a/frontend/src/features/payroll/PayrollPage.jsx b/frontend/src/features/payroll/PayrollPage.jsx index f0444185a..72b155512 100644 --- a/frontend/src/features/payroll/PayrollPage.jsx +++ b/frontend/src/features/payroll/PayrollPage.jsx @@ -19,7 +19,7 @@ import { TaxAllowanceDrilldown } from '../taxAllowance/TaxAllowanceDrilldown.jsx import { useDialogFocus } from '../../hooks/useDialogFocus.js'; import { useIsMobile, useMediaQuery } from '../../hooks/useIsMobile.js'; import { cn } from '../../utils/cn.js'; -import { formatMoney, formatShortDate, formatThaiMonthYearFromMonthInputValue, payrollStatusLabel as statusInfo } from '../../utils/format.js'; +import { bangkokMonthStartIso, formatMoney, formatShortDate, formatThaiMonthYearFromMonthInputValue, payrollStatusLabel as statusInfo } from '../../utils/format.js'; // Reproduces `.panel` for the detail sidebar, which must stay a real