Skip to content

Commit 175a51a

Browse files
committed
chore(dev): seed script for admin conformity stats page
Adds `pnpm seed:conformite` (loads `.env` via Node --env-file) — 40 synthetic companies × 4 campaign years = 160 submitted declarations with a deterministic pseudo-random `has_alert_gap` distribution that drifts down over time (visible +/-2pt year-on-year delta) and carries a small NAF-sector bias (K finance skewed up, M services skewed down). `pnpm seed:conformite clean` reverses the fixture. Helps explore the /admin/stats/conformite page locally without hand- inserting rows in the DB.
1 parent 6b1a235 commit 175a51a

2 files changed

Lines changed: 247 additions & 1 deletion

File tree

packages/app/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@
2929
"test:lighthouse": "lhci collect --url=${LIGHTHOUSE_URL:-http://localhost:3000} && lhci upload && lhci assert",
3030
"typecheck": "tsc --noEmit",
3131
"generate:fetch-companies": "tsx scripts/fetch-large-companies.ts",
32-
"generate:mock-gip": "tsx scripts/generate-mock-gip-data.ts"
32+
"generate:mock-gip": "tsx scripts/generate-mock-gip-data.ts",
33+
"seed:conformite": "node --env-file=.env scripts/seed-conformite-stats.mjs"
3334
},
3435
"dependencies": {
3536
"@aws-sdk/client-s3": "^3.1029.0",
Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
/**
2+
* Local dev seed for the K8 admin conformity stats page.
3+
*
4+
* Populates synthetic companies + submitted declarations across four campaign
5+
* years (currentYear-3 … currentYear) so that `/admin/stats/conformite` has
6+
* enough data to exercise every filter (year / workforce bucket / NAF sector)
7+
* and show a plausible year-over-year delta on the KPI tile.
8+
*
9+
* Idempotent: each run upserts the same deterministic rows — feel free to run
10+
* it repeatedly or after resetting the DB.
11+
*
12+
* Usage (from packages/app):
13+
* pnpm seed:conformite # insert / refresh the fixture
14+
* pnpm seed:conformite clean # remove the fixture
15+
*
16+
* Env: DATABASE_URL (or POSTGRES_* as with the other scripts in this folder).
17+
*/
18+
19+
import { realpathSync } from "node:fs";
20+
import { fileURLToPath } from "node:url";
21+
import postgres from "postgres";
22+
23+
const SEED_DECLARANT_EMAIL = "seed-conformite@egapro.local";
24+
const SEED_DECLARANT_ID = "seed-conformite-declarant-0000-0000000";
25+
26+
/** 777XXXXXX SIRENs are reserved for this fixture. */
27+
const SIREN_PREFIX = "777";
28+
/** Generates one company per (bucket × sector) combination, 5 × 8 = 40. */
29+
const WORKFORCE_BUCKETS = [20, 60, 120, 180, 300];
30+
const NAF_SAMPLE_CODES = [
31+
"A01.11Z", // A — Agriculture
32+
"C10.11Z", // C — Industrie manufacturière
33+
"F41.10A", // F — Construction
34+
"G47.11B", // G — Commerce
35+
"J62.01Z", // J — Information & communication
36+
"K64.19Z", // K — Activités financières et d'assurance
37+
"M70.10Z", // M — Activités spécialisées
38+
"Q86.10Z", // Q — Santé humaine
39+
];
40+
/**
41+
* Kept in sync with `FIRST_DECLARATION_YEAR` from `~/modules/domain` — the
42+
* year filter on `/admin/stats/conformite` goes from there to the current
43+
* year, so we seed every slot to avoid empty tiles on older selections.
44+
*/
45+
const FIRST_SEED_YEAR = 2019;
46+
47+
function getDatabaseUrl() {
48+
if (process.env.DATABASE_URL) return process.env.DATABASE_URL;
49+
const user = encodeURIComponent(process.env.POSTGRES_USER ?? "postgres");
50+
const password = process.env.POSTGRES_PASSWORD
51+
? `:${encodeURIComponent(process.env.POSTGRES_PASSWORD)}`
52+
: "";
53+
const host = process.env.POSTGRES_HOST ?? "localhost";
54+
const port = process.env.POSTGRES_PORT ?? "5438";
55+
const db = process.env.POSTGRES_DB ?? "egapro";
56+
return `postgresql://${user}${password}@${host}:${port}/${db}`;
57+
}
58+
59+
/**
60+
* Deterministic hash → [0, 1) pseudo-random, so re-runs are stable.
61+
* @param {number} n
62+
*/
63+
function pseudoRandom(n) {
64+
const hash = (n * 2654435761) >>> 0;
65+
return hash / 2 ** 32;
66+
}
67+
68+
/**
69+
* Decide whether a (siren, year) pair is flagged as alert. We drift the base
70+
* rate down over time so the delta badge is visible (improvement), and we add
71+
* a small NAF-sector bias so the sector filter visibly changes the result.
72+
*
73+
* @param {number} companyIndex
74+
* @param {number} yearsBeforeCurrent 0 = current year, 1 = N-1, ...
75+
* @param {string} nafCode
76+
*/
77+
function shouldHaveAlertGap(companyIndex, yearsBeforeCurrent, nafCode) {
78+
// Gentle downward drift: the oldest seeded year (2019) sits around 54%,
79+
// the current year at ~40% — the points-delta badge between any two
80+
// adjacent years should show a visible ~2pt swing.
81+
const baseRate = 0.4 + 0.02 * yearsBeforeCurrent;
82+
const sectorBias = nafCode.startsWith("K")
83+
? 0.12
84+
: nafCode.startsWith("M")
85+
? -0.1
86+
: 0;
87+
const threshold = Math.min(0.9, Math.max(0.05, baseRate + sectorBias));
88+
return pseudoRandom(companyIndex * 101 + yearsBeforeCurrent * 17) < threshold;
89+
}
90+
91+
function sirenFor(index) {
92+
return `${SIREN_PREFIX}${String(index).padStart(6, "0")}`;
93+
}
94+
95+
function buildCompanyCatalog() {
96+
/** @type {Array<{ siren: string; workforce: number; nafCode: string }>} */
97+
const catalog = [];
98+
let index = 1;
99+
for (const workforce of WORKFORCE_BUCKETS) {
100+
for (const nafCode of NAF_SAMPLE_CODES) {
101+
catalog.push({ siren: sirenFor(index), workforce, nafCode });
102+
index++;
103+
}
104+
}
105+
return catalog;
106+
}
107+
108+
/** @param {import("postgres").Sql} sql */
109+
async function ensureDeclarant(sql) {
110+
await sql`
111+
INSERT INTO app_user (id, email, is_admin)
112+
VALUES (${SEED_DECLARANT_ID}, ${SEED_DECLARANT_EMAIL}, false)
113+
ON CONFLICT (id) DO NOTHING
114+
`;
115+
}
116+
117+
/** @param {import("postgres").Sql} sql */
118+
async function seed(sql) {
119+
await ensureDeclarant(sql);
120+
121+
const catalog = buildCompanyCatalog();
122+
const currentYear = new Date().getFullYear();
123+
124+
let insertedCompanies = 0;
125+
let insertedDeclarations = 0;
126+
127+
for (const { siren, workforce, nafCode } of catalog) {
128+
await sql`
129+
INSERT INTO app_company (siren, name, workforce, naf_code, created_at, updated_at)
130+
VALUES (
131+
${siren},
132+
${`Seed K8 ${siren}`},
133+
${workforce},
134+
${nafCode},
135+
NOW(),
136+
NOW()
137+
)
138+
ON CONFLICT (siren) DO UPDATE SET
139+
workforce = EXCLUDED.workforce,
140+
naf_code = EXCLUDED.naf_code,
141+
updated_at = NOW()
142+
`;
143+
insertedCompanies++;
144+
}
145+
146+
for (let yearsBack = 0; yearsBack < CAMPAIGN_YEARS_BACK; yearsBack++) {
147+
const year = currentYear - yearsBack;
148+
let companyIndex = 0;
149+
for (const { siren, nafCode } of catalog) {
150+
companyIndex++;
151+
const hasAlertGap = shouldHaveAlertGap(companyIndex, yearsBack, nafCode);
152+
// Spread submissions over January-February so the rows look realistic
153+
// (even though the campaign progression chart is not what we exercise
154+
// here, keeping a plausible date avoids surprises elsewhere).
155+
const submittedAt = new Date(
156+
Date.UTC(year, 0, 15 + (companyIndex % 30), 9, 0, 0),
157+
).toISOString();
158+
await sql`
159+
INSERT INTO app_declaration (
160+
id, siren, year, declarant_id, current_step, status,
161+
submitted_at, has_alert_gap, created_at, updated_at
162+
)
163+
VALUES (
164+
gen_random_uuid(),
165+
${siren},
166+
${year},
167+
${SEED_DECLARANT_ID},
168+
6,
169+
'submitted',
170+
${submittedAt},
171+
${hasAlertGap},
172+
NOW(),
173+
NOW()
174+
)
175+
ON CONFLICT ON CONSTRAINT declaration_siren_year_idx DO UPDATE SET
176+
status = 'submitted',
177+
submitted_at = EXCLUDED.submitted_at,
178+
has_alert_gap = EXCLUDED.has_alert_gap,
179+
updated_at = NOW()
180+
`;
181+
insertedDeclarations++;
182+
}
183+
}
184+
185+
return { insertedCompanies, insertedDeclarations };
186+
}
187+
188+
/** @param {import("postgres").Sql} sql */
189+
async function clean(sql) {
190+
const [{ deleted: deletedDeclarations }] = await sql`
191+
WITH deleted AS (
192+
DELETE FROM app_declaration WHERE siren LIKE ${`${SIREN_PREFIX}%`} RETURNING 1
193+
)
194+
SELECT COUNT(*)::int AS deleted FROM deleted
195+
`;
196+
const [{ deleted: deletedCompanies }] = await sql`
197+
WITH deleted AS (
198+
DELETE FROM app_company WHERE siren LIKE ${`${SIREN_PREFIX}%`} RETURNING 1
199+
)
200+
SELECT COUNT(*)::int AS deleted FROM deleted
201+
`;
202+
const [{ deleted: deletedUsers }] = await sql`
203+
WITH deleted AS (
204+
DELETE FROM app_user WHERE id = ${SEED_DECLARANT_ID} RETURNING 1
205+
)
206+
SELECT COUNT(*)::int AS deleted FROM deleted
207+
`;
208+
return { deletedDeclarations, deletedCompanies, deletedUsers };
209+
}
210+
211+
async function main() {
212+
const mode = process.argv[2] ?? "seed";
213+
const sql = postgres(getDatabaseUrl(), { max: 1 });
214+
try {
215+
if (mode === "clean") {
216+
const result = await clean(sql);
217+
console.log(
218+
`[seed-conformite] cleaned: ${result.deletedDeclarations} declarations, ${result.deletedCompanies} companies, ${result.deletedUsers} declarant.`,
219+
);
220+
return;
221+
}
222+
if (mode !== "seed") {
223+
console.error(`Unknown mode "${mode}". Use "seed" (default) or "clean".`);
224+
process.exit(2);
225+
}
226+
const result = await seed(sql);
227+
console.log(
228+
`[seed-conformite] upserted ${result.insertedCompanies} companies and ${result.insertedDeclarations} submitted declarations across ${CAMPAIGN_YEARS_BACK} campaign years.`,
229+
);
230+
console.log(
231+
`[seed-conformite] open http://localhost:3000/admin/stats/conformite to browse the tile.`,
232+
);
233+
} finally {
234+
await sql.end();
235+
}
236+
}
237+
238+
if (
239+
import.meta.url === `file://${realpathSync(fileURLToPath(import.meta.url))}`
240+
) {
241+
main().catch((error) => {
242+
console.error("[seed-conformite] failed:", error);
243+
process.exit(1);
244+
});
245+
}

0 commit comments

Comments
 (0)