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