Skip to content

Commit c0967bd

Browse files
authored
feat(declaration): récupérer catégories d'emplois N-1 (indicateur G) (#3146)
1 parent b5f362a commit c0967bd

7 files changed

Lines changed: 371 additions & 8 deletions

File tree

packages/app/src/app/declaration-remuneration/etape/[step]/page.tsx

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -115,13 +115,32 @@ export default async function StepPage({ params }: StepPageProps) {
115115
],
116116
};
117117

118-
const step5Categories = mapToEmployeeCategoryRows(
119-
data.jobCategories,
120-
data.employeeCategories,
121-
"initial",
122-
);
118+
const hasCurrentYearCategories = data.jobCategories.length > 0;
119+
120+
const step5Categories = hasCurrentYearCategories
121+
? mapToEmployeeCategoryRows(
122+
data.jobCategories,
123+
data.employeeCategories,
124+
"initial",
125+
)
126+
: (data.previousYearCategories?.categories.map((cat) => ({
127+
name: cat.name,
128+
detail: cat.detail,
129+
womenCount: null,
130+
menCount: null,
131+
annualBaseWomen: null,
132+
annualBaseMen: null,
133+
annualVariableWomen: null,
134+
annualVariableMen: null,
135+
hourlyBaseWomen: null,
136+
hourlyBaseMen: null,
137+
hourlyVariableWomen: null,
138+
hourlyVariableMen: null,
139+
})) ?? []);
123140

124-
const initialSource = data.jobCategories[0]?.source;
141+
const initialSource = hasCurrentYearCategories
142+
? data.jobCategories[0]?.source
143+
: (data.previousYearCategories?.source ?? undefined);
125144

126145
return (
127146
<HydrateClient>

packages/app/src/e2e/helpers/db.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,111 @@ export async function deleteCseOpinions() {
167167
}
168168
}
169169

170+
/**
171+
* Insert a submitted declaration for the previous year (N-1) with job categories,
172+
* so the "Reprendre les catégories de l'année précédente" button appears.
173+
*/
174+
export async function insertPreviousYearDeclaration() {
175+
const sql = createConnection();
176+
const yearResult =
177+
await sql`SELECT EXTRACT(YEAR FROM CURRENT_DATE)::int - 1 AS previous_year`;
178+
const previousYear = yearResult[0]?.previous_year as number;
179+
const declId = "e2e-prev-year-decl-0000-000000000000";
180+
181+
try {
182+
// Get test user id
183+
const users = await sql`
184+
SELECT user_id FROM app_user_company WHERE siren = ${TEST_SIREN} LIMIT 1
185+
`;
186+
const userId = users[0]?.user_id;
187+
if (!userId) return;
188+
189+
// Insert submitted declaration for previous year
190+
await sql`
191+
INSERT INTO app_declaration (id, siren, year, declarant_id, total_women, total_men, current_step, status, created_at, updated_at)
192+
VALUES (${declId}, ${TEST_SIREN}, ${previousYear}, ${userId}, 150, 200, 6, 'submitted', NOW(), NOW())
193+
ON CONFLICT DO NOTHING
194+
`;
195+
196+
// Insert 3 job categories
197+
const categories = [
198+
{
199+
id: "e2e-jobcat-1111-0000-000000000000",
200+
index: 0,
201+
name: "Cadres dirigeants",
202+
detail: "Directeurs et cadres supérieurs",
203+
},
204+
{
205+
id: "e2e-jobcat-2222-0000-000000000000",
206+
index: 1,
207+
name: "Ingénieurs et cadres",
208+
detail: "Ingénieurs, chefs de projet, managers",
209+
},
210+
{
211+
id: "e2e-jobcat-3333-0000-000000000000",
212+
index: 2,
213+
name: "Techniciens",
214+
detail: "Techniciens qualifiés",
215+
},
216+
];
217+
218+
for (const cat of categories) {
219+
await sql`
220+
INSERT INTO app_job_category (id, declaration_id, category_index, name, detail, source)
221+
VALUES (${cat.id}, ${declId}, ${cat.index}, ${cat.name}, ${cat.detail}, 'convention-collective')
222+
ON CONFLICT DO NOTHING
223+
`;
224+
}
225+
} finally {
226+
await sql.end();
227+
}
228+
}
229+
230+
/** Remove the previous year test declaration and its job categories. */
231+
export async function deletePreviousYearDeclaration() {
232+
const sql = createConnection();
233+
const declId = "e2e-prev-year-decl-0000-000000000000";
234+
235+
try {
236+
const jobIds = [
237+
"e2e-jobcat-1111-0000-000000000000",
238+
"e2e-jobcat-2222-0000-000000000000",
239+
"e2e-jobcat-3333-0000-000000000000",
240+
];
241+
await sql`DELETE FROM app_employee_category WHERE job_category_id = ANY(${jobIds})`;
242+
await sql`DELETE FROM app_job_category WHERE declaration_id = ${declId}`;
243+
await sql`DELETE FROM app_declaration WHERE id = ${declId}`;
244+
} finally {
245+
await sql.end();
246+
}
247+
}
248+
249+
/** Delete all job categories and employee categories for the test SIREN's current year declaration. */
250+
export async function deleteCurrentYearCategories() {
251+
const sql = createConnection();
252+
try {
253+
await sql`
254+
DELETE FROM app_employee_category
255+
WHERE job_category_id IN (
256+
SELECT jc.id FROM app_job_category jc
257+
INNER JOIN app_declaration d ON d.id = jc.declaration_id
258+
WHERE d.siren = ${TEST_SIREN}
259+
AND d.year = EXTRACT(YEAR FROM CURRENT_DATE)::int
260+
)
261+
`;
262+
await sql`
263+
DELETE FROM app_job_category
264+
WHERE declaration_id IN (
265+
SELECT id FROM app_declaration
266+
WHERE siren = ${TEST_SIREN}
267+
AND year = EXTRACT(YEAR FROM CURRENT_DATE)::int
268+
)
269+
`;
270+
} finally {
271+
await sql.end();
272+
}
273+
}
274+
170275
type CampaignDeadlineDates = {
171276
decl1ModificationDeadline: string;
172277
decl1JustificationDeadline: string;
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { expect, type Page, test } from "@playwright/test";
2+
import {
3+
deleteCurrentYearCategories,
4+
deletePreviousYearDeclaration,
5+
insertPreviousYearDeclaration,
6+
resetDeclarationToDraft,
7+
} from "./helpers/db";
8+
9+
async function goToStep5(page: Page) {
10+
await page.goto("/declaration-remuneration");
11+
await page.waitForURL("**/declaration-remuneration/etape/**");
12+
await page.goto("/declaration-remuneration/etape/5");
13+
await page.waitForURL("**/declaration-remuneration/etape/5");
14+
await expect(page.getByText("Étape 5 sur 6")).toBeVisible();
15+
}
16+
17+
test.describe("Previous year categories prefill", () => {
18+
test.beforeAll(async () => {
19+
await resetDeclarationToDraft();
20+
await deleteCurrentYearCategories();
21+
await insertPreviousYearDeclaration();
22+
});
23+
24+
test.afterAll(async () => {
25+
await deletePreviousYearDeclaration();
26+
});
27+
28+
test("pre-fills N-1 category names, details and source with empty numeric fields", async ({
29+
page,
30+
}) => {
31+
await goToStep5(page);
32+
33+
// Source pre-filled from N-1
34+
await expect(
35+
page.getByRole("combobox", { name: /source utilisée/i }),
36+
).toHaveValue("convention-collective");
37+
38+
// 3 categories pre-filled
39+
await expect(page.getByText("Nombre de catégories : 3")).toBeVisible();
40+
await expect(
41+
page.getByRole("button", { name: /Cadres dirigeants/ }),
42+
).toBeVisible();
43+
await expect(
44+
page.getByRole("button", { name: /Ingénieurs et cadres/ }),
45+
).toBeVisible();
46+
await expect(
47+
page.getByRole("button", { name: /Techniciens/ }),
48+
).toBeVisible();
49+
50+
// Detail pre-filled, numeric fields empty
51+
await page.getByRole("button", { name: /Cadres dirigeants/ }).click();
52+
await expect(page.locator("#cat-0-detail")).toHaveValue(
53+
"Directeurs et cadres supérieurs",
54+
);
55+
await expect(
56+
page.getByRole("textbox", { name: "Effectif femmes, catégorie 1" }),
57+
).toHaveValue("");
58+
await expect(
59+
page.getByRole("textbox", {
60+
name: "Salaire de base annuel femmes, catégorie 1",
61+
}),
62+
).toHaveValue("");
63+
});
64+
});

packages/app/src/server/api/routers/__tests__/declaration.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ vi.mock("../declarationHelpers", async (importOriginal) => {
1818
employeeCategories: [],
1919
}),
2020
deleteJobAndEmployeeCategories: vi.fn().mockResolvedValue(undefined),
21+
fetchPreviousYearJobCategories: vi.fn().mockResolvedValue(null),
2122
};
2223
});
2324

packages/app/src/server/api/routers/__tests__/declarationHelpers.test.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,121 @@ describe("fetchAllCategories", () => {
198198
});
199199
});
200200

201+
describe("fetchPreviousYearJobCategories", () => {
202+
it("returns null when no submitted declaration exists for previous year", async () => {
203+
const { fetchPreviousYearJobCategories } = await import(
204+
"../declarationHelpers"
205+
);
206+
207+
const mockLimit = vi.fn().mockResolvedValue([]);
208+
const mockWhere = vi.fn().mockReturnValue({ limit: mockLimit });
209+
const mockFrom = vi.fn().mockReturnValue({ where: mockWhere });
210+
const mockSelect = vi.fn().mockReturnValue({ from: mockFrom });
211+
212+
const tx = { select: mockSelect } as never;
213+
214+
const result = await fetchPreviousYearJobCategories(tx, "123456789", 2026);
215+
216+
expect(result).toBeNull();
217+
});
218+
219+
it("returns null when declaration exists but has no job categories", async () => {
220+
const { fetchPreviousYearJobCategories } = await import(
221+
"../declarationHelpers"
222+
);
223+
224+
let selectCallCount = 0;
225+
const mockLimit = vi.fn().mockResolvedValue([{ id: "decl-prev" }]);
226+
const mockWhere = vi.fn().mockImplementation(() => {
227+
selectCallCount++;
228+
if (selectCallCount === 1) return { limit: mockLimit };
229+
return Promise.resolve([]);
230+
});
231+
const mockFrom = vi.fn().mockReturnValue({ where: mockWhere });
232+
const mockSelect = vi.fn().mockReturnValue({ from: mockFrom });
233+
234+
const tx = { select: mockSelect } as never;
235+
236+
const result = await fetchPreviousYearJobCategories(tx, "123456789", 2026);
237+
238+
expect(result).toBeNull();
239+
});
240+
241+
it("returns categories sorted by index with source from previous year", async () => {
242+
const { fetchPreviousYearJobCategories } = await import(
243+
"../declarationHelpers"
244+
);
245+
246+
const mockJobs = [
247+
{
248+
name: "Employés",
249+
detail: "Support",
250+
source: "convention-collective",
251+
categoryIndex: 1,
252+
},
253+
{
254+
name: "Cadres",
255+
detail: "Managers",
256+
source: "convention-collective",
257+
categoryIndex: 0,
258+
},
259+
];
260+
261+
let selectCallCount = 0;
262+
const mockLimit = vi.fn().mockResolvedValue([{ id: "decl-prev" }]);
263+
const mockWhere = vi.fn().mockImplementation(() => {
264+
selectCallCount++;
265+
if (selectCallCount === 1) return { limit: mockLimit };
266+
return Promise.resolve(mockJobs);
267+
});
268+
const mockFrom = vi.fn().mockReturnValue({ where: mockWhere });
269+
const mockSelect = vi.fn().mockReturnValue({ from: mockFrom });
270+
271+
const tx = { select: mockSelect } as never;
272+
273+
const result = await fetchPreviousYearJobCategories(tx, "123456789", 2026);
274+
275+
expect(result).toEqual({
276+
source: "convention-collective",
277+
categories: [
278+
{ name: "Cadres", detail: "Managers" },
279+
{ name: "Employés", detail: "Support" },
280+
],
281+
});
282+
});
283+
284+
it("uses empty string for null detail", async () => {
285+
const { fetchPreviousYearJobCategories } = await import(
286+
"../declarationHelpers"
287+
);
288+
289+
const mockJobs = [
290+
{
291+
name: "Cadres",
292+
detail: null,
293+
source: "autre",
294+
categoryIndex: 0,
295+
},
296+
];
297+
298+
let selectCallCount = 0;
299+
const mockLimit = vi.fn().mockResolvedValue([{ id: "decl-prev" }]);
300+
const mockWhere = vi.fn().mockImplementation(() => {
301+
selectCallCount++;
302+
if (selectCallCount === 1) return { limit: mockLimit };
303+
return Promise.resolve(mockJobs);
304+
});
305+
const mockFrom = vi.fn().mockReturnValue({ where: mockWhere });
306+
const mockSelect = vi.fn().mockReturnValue({ from: mockFrom });
307+
308+
const tx = { select: mockSelect } as never;
309+
310+
const result = await fetchPreviousYearJobCategories(tx, "123456789", 2026);
311+
312+
expect(result?.categories[0]?.detail).toBe("");
313+
});
314+
});
315+
201316
describe("deleteJobAndEmployeeCategories", () => {
202317
it("deletes employee categories then job categories", async () => {
203318
const { deleteJobAndEmployeeCategories } = await import(

packages/app/src/server/api/routers/declaration.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
buildEmployeeCategoryValues,
2222
deleteJobAndEmployeeCategories,
2323
fetchAllCategories,
24+
fetchPreviousYearJobCategories,
2425
} from "./declarationHelpers";
2526

2627
export const declarationRouter = createTRPCRouter({
@@ -103,9 +104,16 @@ export const declarationRouter = createTRPCRouter({
103104

104105
const gipPrefillData = gipRow[0] ? mapGipToFormData(gipRow[0]) : null;
105106

107+
// Fetch N-1 categories for automatic prefilling when step 5 is empty
108+
const hasCurrentCategories = (result.jobCategories ?? []).length > 0;
109+
const previousYearCategories = hasCurrentCategories
110+
? null
111+
: await fetchPreviousYearJobCategories(ctx.db, siren, year);
112+
106113
return {
107114
...result,
108115
gipPrefillData,
116+
previousYearCategories,
109117
};
110118
}),
111119

0 commit comments

Comments
 (0)