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 @@ -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) {}

Expand Down
12 changes: 9 additions & 3 deletions backend/src/main/java/th/co/glr/hr/pricing/FxRateController.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
* <p>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
Expand All @@ -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.
*
* <p><b>Widened again, owner ruling 2026-10-29 (role check RC-01):</b> {@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.
*
* <p>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
Expand All @@ -55,7 +61,7 @@ public class FxRateController {
* <p>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<String> READ_ROLES = Set.of("ceo", "import", "sales");
private static final Set<String> READ_ROLES = Set.of("ceo", "import", "sales", "sales_manager");

private final FxRateRepository fxRates;
private final SessionContext sessions;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,15 +66,14 @@
* (2026-07-16).</li>
* <li>{@code GET /api/catalog/prices} → <b>open</b> to any authenticated user, per the owner's
* 2026-08-01 ruling closing #388: #205's "browsable" covers the supplier purchase price.</li>
* <li>{@code GET /api/fx-rates} → {@code ceo}/{@code import}/<b>{@code sales}</b>, per the
* owner's 2026-08-02 ruling (see {@code FxRateController.READ_ROLES}): an FX rate only
* <li>{@code GET /api/fx-rates} → {@code ceo}/{@code import}/<b>{@code sales}</b>/
* <b>{@code sales_manager}</b>, 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.</li>
* about landed cost or margin on its own. NOT open to every authenticated session.</li>
* <li>{@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.</li>
* ({@code RAW_COSTING_ROLES}), unchanged. The FX widening does not extend to the margin
* policy or this supplier-directory endpoint.</li>
* </ul>
*/
class CatalogPricingReadAuthzIntegrationTest extends AbstractPostgresIntegrationTest {
Expand Down Expand Up @@ -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.
*
* <p>{@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.
* <p>{@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());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")))
Expand All @@ -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());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}.
*
* <p>This class proves the DECISION (which branch the role check takes).
* {@code CatalogPricingReadAuthzIntegrationTest} proves the ENFORCEMENT on real Postgres through the
Expand All @@ -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<String> 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<String> READ_ALLOWED = List.of("ceo", "import", "sales", "sales_manager");

/** Everyone else. */
private static final List<String> 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<String> WRITE_DENIED =
Expand Down
9 changes: 5 additions & 4 deletions frontend/src/api/mockApi.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Expand Down
5 changes: 4 additions & 1 deletion frontend/src/features/catalog/CatalogSearchPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}, []);

Expand Down
18 changes: 17 additions & 1 deletion frontend/src/features/catalog/CatalogSearchPage.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ vi.mock('../../api/index.js', () => ({
priceImport: {
factories: vi.fn(),
},
factoryConfigs: {
names: vi.fn(),
},
catalog: {
prices: vi.fn(),
},
Expand All @@ -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: [] });
});

Expand All @@ -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(<CatalogSearchPage user={{ role: 'sales' }} showToast={vi.fn()} />);

const option = await screen.findByRole('option', { name: 'Mutina' });
expect(option.getAttribute('value')).toBe('42');
});
});
17 changes: 11 additions & 6 deletions frontend/src/features/dashboard/HrOverview.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 (
<PageStack>
Expand Down
Loading
Loading