Skip to content

Commit b3b49de

Browse files
authored
refactor: lier CSE et évaluation conjointe à la déclaration (#3111)
1 parent 16b024d commit b3b49de

18 files changed

Lines changed: 476 additions & 255 deletions

File tree

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
-- Step 1: Add declaration_id column (nullable) to all 3 tables
2+
ALTER TABLE "app_cse_opinion" ADD COLUMN "declaration_id" varchar(255);
3+
ALTER TABLE "app_cse_opinion_file" ADD COLUMN "declaration_id" varchar(255);
4+
ALTER TABLE "app_joint_evaluation_file" ADD COLUMN "declaration_id" varchar(255);
5+
6+
-- Step 2: Backfill declaration_id from existing (siren, year) data
7+
-- CSE opinions use getCseYear() = currentYear + 1, so opinion.year = declaration.year + 1
8+
UPDATE "app_cse_opinion" co
9+
SET "declaration_id" = d."id"
10+
FROM "app_declaration" d
11+
WHERE d."siren" = co."siren" AND d."year" = co."year" - 1;
12+
13+
-- CSE opinion files also use getCseYear()
14+
UPDATE "app_cse_opinion_file" cf
15+
SET "declaration_id" = d."id"
16+
FROM "app_declaration" d
17+
WHERE d."siren" = cf."siren" AND d."year" = cf."year" - 1;
18+
19+
-- Joint evaluation files use getCurrentYear() = same as declaration.year
20+
UPDATE "app_joint_evaluation_file" jf
21+
SET "declaration_id" = d."id"
22+
FROM "app_declaration" d
23+
WHERE d."siren" = jf."siren" AND d."year" = jf."year";
24+
25+
-- Step 3: Delete orphan rows that have no matching declaration
26+
DELETE FROM "app_cse_opinion" WHERE "declaration_id" IS NULL;
27+
DELETE FROM "app_cse_opinion_file" WHERE "declaration_id" IS NULL;
28+
DELETE FROM "app_joint_evaluation_file" WHERE "declaration_id" IS NULL;
29+
30+
-- Step 4: Make declaration_id NOT NULL and add FK constraints
31+
ALTER TABLE "app_cse_opinion" ALTER COLUMN "declaration_id" SET NOT NULL;
32+
ALTER TABLE "app_cse_opinion_file" ALTER COLUMN "declaration_id" SET NOT NULL;
33+
ALTER TABLE "app_joint_evaluation_file" ALTER COLUMN "declaration_id" SET NOT NULL;
34+
35+
ALTER TABLE "app_cse_opinion" ADD CONSTRAINT "app_cse_opinion_declaration_id_app_declaration_id_fk" FOREIGN KEY ("declaration_id") REFERENCES "public"."app_declaration"("id") ON DELETE no action ON UPDATE no action;
36+
ALTER TABLE "app_cse_opinion_file" ADD CONSTRAINT "app_cse_opinion_file_declaration_id_app_declaration_id_fk" FOREIGN KEY ("declaration_id") REFERENCES "public"."app_declaration"("id") ON DELETE no action ON UPDATE no action;
37+
ALTER TABLE "app_joint_evaluation_file" ADD CONSTRAINT "app_joint_evaluation_file_declaration_id_app_declaration_id_fk" FOREIGN KEY ("declaration_id") REFERENCES "public"."app_declaration"("id") ON DELETE no action ON UPDATE no action;
38+
39+
-- Step 5: Drop old constraints and indexes
40+
ALTER TABLE "app_cse_opinion" DROP CONSTRAINT IF EXISTS "cse_opinion_siren_year_decl_type_idx";
41+
DROP INDEX IF EXISTS "cse_opinion_siren_year_idx";
42+
DROP INDEX IF EXISTS "cse_opinion_file_siren_year_idx";
43+
DROP INDEX IF EXISTS "joint_eval_file_siren_year_idx";
44+
45+
-- Step 6: Drop old FK constraints on siren and declarant_id
46+
ALTER TABLE "app_cse_opinion" DROP CONSTRAINT IF EXISTS "app_cse_opinion_siren_app_company_siren_fk";
47+
ALTER TABLE "app_cse_opinion" DROP CONSTRAINT IF EXISTS "app_cse_opinion_declarant_id_app_user_id_fk";
48+
ALTER TABLE "app_cse_opinion_file" DROP CONSTRAINT IF EXISTS "app_cse_opinion_file_siren_app_company_siren_fk";
49+
ALTER TABLE "app_cse_opinion_file" DROP CONSTRAINT IF EXISTS "app_cse_opinion_file_declarant_id_app_user_id_fk";
50+
ALTER TABLE "app_joint_evaluation_file" DROP CONSTRAINT IF EXISTS "app_joint_evaluation_file_siren_app_company_siren_fk";
51+
ALTER TABLE "app_joint_evaluation_file" DROP CONSTRAINT IF EXISTS "app_joint_evaluation_file_declarant_id_app_user_id_fk";
52+
53+
-- Step 7: Drop old columns
54+
ALTER TABLE "app_cse_opinion" DROP COLUMN "siren";
55+
ALTER TABLE "app_cse_opinion" DROP COLUMN "year";
56+
ALTER TABLE "app_cse_opinion" DROP COLUMN "declarant_id";
57+
ALTER TABLE "app_cse_opinion_file" DROP COLUMN "siren";
58+
ALTER TABLE "app_cse_opinion_file" DROP COLUMN "year";
59+
ALTER TABLE "app_cse_opinion_file" DROP COLUMN "declarant_id";
60+
ALTER TABLE "app_joint_evaluation_file" DROP COLUMN "siren";
61+
ALTER TABLE "app_joint_evaluation_file" DROP COLUMN "year";
62+
ALTER TABLE "app_joint_evaluation_file" DROP COLUMN "declarant_id";
63+
64+
-- Step 8: Add new constraints and indexes
65+
ALTER TABLE "app_cse_opinion" ADD CONSTRAINT "cse_opinion_decl_number_type_idx" UNIQUE("declaration_id","declaration_number","type");
66+
CREATE INDEX "cse_opinion_declaration_idx" ON "app_cse_opinion" USING btree ("declaration_id");
67+
CREATE INDEX "cse_opinion_file_declaration_idx" ON "app_cse_opinion_file" USING btree ("declaration_id");
68+
ALTER TABLE "app_joint_evaluation_file" ADD CONSTRAINT "joint_eval_file_declaration_idx" UNIQUE("declaration_id");

packages/app/drizzle/meta/_journal.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,13 @@
113113
"when": 1774281600000,
114114
"tag": "0015_drop_account_table",
115115
"breakpoints": true
116+
},
117+
{
118+
"idx": 16,
119+
"version": "7",
120+
"when": 1774368000000,
121+
"tag": "0016_link_cse_joint_eval_to_declaration",
122+
"breakpoints": true
116123
}
117124
]
118125
}

packages/app/src/app/api/v1/export/declarations/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ export async function GET(request: Request) {
4949
const [categoriesMap, indicatorGMap, cseMap] = await Promise.all([
5050
fetchCategoriesByDeclaration(sirenYearKeys),
5151
fetchIndicatorGByDeclaration(declarationIds),
52-
fetchCseOpinionsByDeclaration(sirenYearKeys),
52+
fetchCseOpinionsByDeclaration(declarationIds),
5353
]);
5454

5555
const data = rows.map((row) => {
@@ -58,7 +58,7 @@ export async function GET(request: Request) {
5858
row,
5959
categoriesMap.get(key) ?? [],
6060
indicatorGMap.get(row.declarationId) ?? [],
61-
cseMap.get(key) ?? [],
61+
cseMap.get(row.declarationId) ?? [],
6262
);
6363
});
6464

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

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -79,13 +79,13 @@ export async function setDeclarationComplianceState(state: {
7979
export async function insertJointEvaluationFile(year: number) {
8080
const sql = createConnection();
8181
try {
82-
const declarantId = await sql`
83-
SELECT u.id FROM app_user u WHERE u.siret LIKE ${`${TEST_SIREN}%`} LIMIT 1
82+
const decl = await sql`
83+
SELECT id FROM app_declaration WHERE siren = ${TEST_SIREN} AND year = ${year} LIMIT 1
8484
`;
85-
if (declarantId.length === 0) return;
85+
if (decl.length === 0) return;
8686
await sql`
87-
INSERT INTO app_joint_evaluation_file (id, siren, year, file_name, file_path, declarant_id, uploaded_at, created_at)
88-
VALUES (gen_random_uuid(), ${TEST_SIREN}, ${year}, 'dummy.pdf', '/tmp/dummy.pdf', ${declarantId[0]?.id}, NOW(), NOW())
87+
INSERT INTO app_joint_evaluation_file (id, declaration_id, file_name, file_path, uploaded_at, created_at)
88+
VALUES (gen_random_uuid(), ${decl[0]?.id}, 'dummy.pdf', '/tmp/dummy.pdf', NOW(), NOW())
8989
ON CONFLICT DO NOTHING
9090
`;
9191
} finally {
@@ -97,7 +97,12 @@ export async function insertJointEvaluationFile(year: number) {
9797
export async function deleteJointEvaluationFiles() {
9898
const sql = createConnection();
9999
try {
100-
await sql`DELETE FROM app_joint_evaluation_file WHERE siren = ${TEST_SIREN}`;
100+
await sql`
101+
DELETE FROM app_joint_evaluation_file
102+
WHERE declaration_id IN (
103+
SELECT id FROM app_declaration WHERE siren = ${TEST_SIREN}
104+
)
105+
`;
101106
} finally {
102107
await sql.end();
103108
}
@@ -107,13 +112,13 @@ export async function deleteJointEvaluationFiles() {
107112
export async function insertCseOpinion(year: number) {
108113
const sql = createConnection();
109114
try {
110-
const declarantId = await sql`
111-
SELECT u.id FROM app_user u WHERE u.siret LIKE ${`${TEST_SIREN}%`} LIMIT 1
115+
const decl = await sql`
116+
SELECT id FROM app_declaration WHERE siren = ${TEST_SIREN} AND year = ${year} LIMIT 1
112117
`;
113-
if (declarantId.length === 0) return;
118+
if (decl.length === 0) return;
114119
await sql`
115-
INSERT INTO app_cse_opinion (id, siren, year, declaration_number, type, declarant_id, created_at, updated_at)
116-
VALUES (gen_random_uuid(), ${TEST_SIREN}, ${year}, 1, 'remuneration', ${declarantId[0]?.id}, NOW(), NOW())
120+
INSERT INTO app_cse_opinion (id, declaration_id, declaration_number, type, created_at, updated_at)
121+
VALUES (gen_random_uuid(), ${decl[0]?.id}, 1, 'remuneration', NOW(), NOW())
117122
ON CONFLICT DO NOTHING
118123
`;
119124
} finally {
@@ -125,7 +130,12 @@ export async function insertCseOpinion(year: number) {
125130
export async function deleteCseOpinions() {
126131
const sql = createConnection();
127132
try {
128-
await sql`DELETE FROM app_cse_opinion WHERE siren = ${TEST_SIREN}`;
133+
await sql`
134+
DELETE FROM app_cse_opinion
135+
WHERE declaration_id IN (
136+
SELECT id FROM app_declaration WHERE siren = ${TEST_SIREN}
137+
)
138+
`;
129139
} finally {
130140
await sql.end();
131141
}
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
vi.mock("server-only", () => ({}));
4+
5+
const mockLimit = vi.fn();
6+
const mockWhere = vi.fn();
7+
const mockFrom = vi.fn();
8+
const mockSelect = vi.fn();
9+
10+
let selectCallCount = 0;
11+
12+
vi.mock("~/server/db", () => ({
13+
db: {
14+
select: (...args: unknown[]) => mockSelect(...args),
15+
},
16+
}));
17+
18+
describe("buildTransmittedPdfData", () => {
19+
beforeEach(() => {
20+
vi.resetAllMocks();
21+
selectCallCount = 0;
22+
});
23+
24+
function setupMockDb(
25+
company: Record<string, unknown> | null,
26+
declaration: Record<string, unknown> | null,
27+
opinions: unknown[] = [],
28+
cseFiles: unknown[] = [],
29+
jointFiles: unknown[] = [],
30+
) {
31+
// 5 select calls:
32+
// 1) company, 2) declaration (parallel Promise.all)
33+
// 3) opinions, 4) cseFiles, 5) jointFiles (parallel Promise.all)
34+
const results = [
35+
company ? [company] : [],
36+
declaration ? [declaration] : [],
37+
opinions,
38+
cseFiles,
39+
jointFiles,
40+
];
41+
42+
mockLimit.mockImplementation(() => {
43+
return Promise.resolve(results[selectCallCount - 1] ?? []);
44+
});
45+
mockWhere.mockImplementation(() => {
46+
const res = results[selectCallCount - 1] ?? [];
47+
return Object.assign(Promise.resolve(res), { limit: mockLimit });
48+
});
49+
mockFrom.mockReturnValue({ where: mockWhere });
50+
mockSelect.mockImplementation(() => {
51+
selectCallCount++;
52+
return { from: mockFrom };
53+
});
54+
}
55+
56+
it("returns assembled PDF data with declaration lookup", async () => {
57+
setupMockDb(
58+
{ name: "ACME Corp", siren: "123456789" },
59+
{ id: "decl-1" },
60+
[
61+
{
62+
declarationNumber: 1,
63+
type: "accuracy",
64+
opinion: "favorable",
65+
opinionDate: "2026-01-15",
66+
gapConsulted: null,
67+
},
68+
],
69+
[{ fileName: "avis.pdf", uploadedAt: new Date("2026-03-01") }],
70+
[{ fileName: "eval.pdf", uploadedAt: new Date("2026-03-02") }],
71+
);
72+
73+
const { buildTransmittedPdfData } = await import(
74+
"../buildTransmittedPdfData"
75+
);
76+
const result = await buildTransmittedPdfData(
77+
"123456789",
78+
new Date("2026-03-15"),
79+
);
80+
81+
expect(result.companyName).toBe("ACME Corp");
82+
expect(result.siren).toBe("123456789");
83+
expect(result.opinions).toHaveLength(1);
84+
expect(result.cseFiles).toHaveLength(1);
85+
expect(result.jointEvaluationFile).not.toBeNull();
86+
expect(result.jointEvaluationFile?.fileName).toBe("eval.pdf");
87+
});
88+
89+
it("throws when company is not found", async () => {
90+
setupMockDb(null, { id: "decl-1" });
91+
92+
const { buildTransmittedPdfData } = await import(
93+
"../buildTransmittedPdfData"
94+
);
95+
96+
await expect(
97+
buildTransmittedPdfData("999999999", new Date()),
98+
).rejects.toThrow("Entreprise introuvable");
99+
});
100+
101+
it("throws when declaration is not found", async () => {
102+
setupMockDb({ name: "ACME", siren: "123456789" }, null);
103+
104+
const { buildTransmittedPdfData } = await import(
105+
"../buildTransmittedPdfData"
106+
);
107+
108+
await expect(
109+
buildTransmittedPdfData("123456789", new Date()),
110+
).rejects.toThrow("Déclaration introuvable");
111+
});
112+
113+
it("returns null jointEvaluationFile when none exists", async () => {
114+
setupMockDb(
115+
{ name: "ACME", siren: "123456789" },
116+
{ id: "decl-1" },
117+
[],
118+
[],
119+
[],
120+
);
121+
122+
const { buildTransmittedPdfData } = await import(
123+
"../buildTransmittedPdfData"
124+
);
125+
const result = await buildTransmittedPdfData(
126+
"123456789",
127+
new Date("2026-03-15"),
128+
);
129+
130+
expect(result.opinions).toEqual([]);
131+
expect(result.cseFiles).toEqual([]);
132+
expect(result.jointEvaluationFile).toBeNull();
133+
});
134+
});

packages/app/src/modules/declarationPdf/buildTransmittedPdfData.ts

Lines changed: 26 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import "server-only";
22

33
import { and, eq } from "drizzle-orm";
4-
import { formatLongDate, getCseYear } from "~/modules/domain";
4+
import { formatLongDate, getCurrentYear } from "~/modules/domain";
55
import { db } from "~/server/db";
66
import {
77
companies,
88
cseOpinionFiles,
99
cseOpinions,
10+
declarations,
1011
jointEvaluationFiles,
1112
} from "~/server/db/schema";
1213

@@ -37,10 +38,28 @@ export async function buildTransmittedPdfData(
3738
siren: string,
3839
now: Date,
3940
): Promise<TransmittedPdfData> {
40-
const year = getCseYear();
41+
const year = getCurrentYear();
4142

42-
const [companyResults, opinions, cseFiles, jointFiles] = await Promise.all([
43+
const [companyResults, declarationResults] = await Promise.all([
4344
db.select().from(companies).where(eq(companies.siren, siren)).limit(1),
45+
db
46+
.select({ id: declarations.id, year: declarations.year })
47+
.from(declarations)
48+
.where(and(eq(declarations.siren, siren), eq(declarations.year, year)))
49+
.limit(1),
50+
]);
51+
52+
const company = companyResults[0];
53+
if (!company) {
54+
throw new Error("Entreprise introuvable");
55+
}
56+
57+
const declaration = declarationResults[0];
58+
if (!declaration) {
59+
throw new Error("Déclaration introuvable");
60+
}
61+
62+
const [opinions, cseFiles, jointFiles] = await Promise.all([
4463
db
4564
.select({
4665
declarationNumber: cseOpinions.declarationNumber,
@@ -50,40 +69,28 @@ export async function buildTransmittedPdfData(
5069
gapConsulted: cseOpinions.gapConsulted,
5170
})
5271
.from(cseOpinions)
53-
.where(and(eq(cseOpinions.siren, siren), eq(cseOpinions.year, year))),
72+
.where(eq(cseOpinions.declarationId, declaration.id)),
5473
db
5574
.select({
5675
fileName: cseOpinionFiles.fileName,
5776
uploadedAt: cseOpinionFiles.uploadedAt,
5877
})
5978
.from(cseOpinionFiles)
60-
.where(
61-
and(eq(cseOpinionFiles.siren, siren), eq(cseOpinionFiles.year, year)),
62-
),
79+
.where(eq(cseOpinionFiles.declarationId, declaration.id)),
6380
db
6481
.select({
6582
fileName: jointEvaluationFiles.fileName,
6683
uploadedAt: jointEvaluationFiles.uploadedAt,
6784
})
6885
.from(jointEvaluationFiles)
69-
.where(
70-
and(
71-
eq(jointEvaluationFiles.siren, siren),
72-
eq(jointEvaluationFiles.year, year),
73-
),
74-
)
86+
.where(eq(jointEvaluationFiles.declarationId, declaration.id))
7587
.limit(1),
7688
]);
7789

78-
const company = companyResults[0];
79-
if (!company) {
80-
throw new Error("Entreprise introuvable");
81-
}
82-
8390
return {
8491
companyName: company.name,
8592
siren,
86-
year,
93+
year: declaration.year,
8794
generatedAt: formatLongDate(now),
8895
opinions,
8996
cseFiles,

0 commit comments

Comments
 (0)