initial: split from gov-agreg — vreau.digital standalone platform

Moved from gov-agreg/src/pages/achizitii/* to root (drop prefix).
- 22 pages migrated, 127 files total
- All internal links: /achizitii/X → /X (176 occurrences fixed)
- AchizitiiLayout subnav rewritten: /X paths, top-right link to vreaudigital.ro hub
- BaseLayout new (vreau.digital branding, OG tags, site URL)
- astro.config.mjs: site https://vreau.digital, server output (was static)
- docker-compose: port 5096 (vreaudigital is 5095), container vreau-digital
- deploy.sh: paths /opt/vreau-digital, log /var/log/vreau-digital-deploy.log

Backend shared with gov-agreg:
- PostgreSQL satra (same schemas: seap, firms, anaf, anre, ...)
- Photon, Martin tiles
- Infisical /vreaudigital path (DATABASE_URL etc. shared)

build: PASS (npx astro check 0 errors, npm run build 5s vite + 10s server)
This commit is contained in:
Claude VM
2026-05-13 00:10:32 +03:00
commit a6c03a091e
352 changed files with 75295 additions and 0 deletions
+92
View File
@@ -0,0 +1,92 @@
import { query } from './db.js';
export interface AnnouncementDetail {
id: number;
ref_number: string;
type: string;
title: string | null;
authority_name: string | null;
authority_cui: string | null;
authority_siruta: string | null;
authority_type: string | null;
authority_main_activity: string | null;
authority_address: string | null;
authority_email: string | null;
authority_phone: string | null;
authority_url: string | null;
authority_county: string | null;
supplier_name: string | null;
supplier_cui: string | null;
supplier_address: string | null;
supplier_is_sme: boolean | null;
supplier_county: string | null;
cpv_code: string | null;
cpv_name_ro: string | null;
cpv_division: string | null;
cpv_division_name: string | null;
cpv_emoji: string | null;
procedure_type: string | null;
procedure_state: string | null;
legislation: string | null;
publication_date: string | null;
deadline_submission: string | null;
opening_date: string | null;
contract_date: string | null;
estimated_value: number | null;
awarded_value: number | null;
currency: string | null;
has_lots: boolean | null;
contract_has_lots: boolean | null;
lots_count: number | null;
framework_agreement: boolean | null;
joue: boolean | null;
county_code: string | null;
notice_state: string | null;
notice_state_id: number | null;
documents: any | null;
award_criteria: any | null;
lots: any | null;
details: any | null;
source: string;
seap_url: string | null;
}
export async function getAnnouncementDetail(id: number | string): Promise<AnnouncementDetail | null> {
const result = await query<AnnouncementDetail>(`
SELECT
a.id, a.ref_number, a.type, a.title,
a.authority_name, a.authority_cui, a.authority_siruta,
a.authority_type, a.authority_main_activity, a.authority_address,
a.authority_email, a.authority_phone, a.authority_url,
cl_a.county AS authority_county,
a.supplier_name, a.supplier_cui, a.supplier_address, a.supplier_is_sme,
cl_s.county AS supplier_county,
a.cpv_code, a.cpv_name_ro, a.cpv_division,
c.name_ro AS cpv_division_name, c.emoji AS cpv_emoji,
a.procedure_type, a.procedure_state, a.legislation,
a.publication_date, a.deadline_submission, a.opening_date, a.contract_date,
a.estimated_value, a.awarded_value, a.currency,
a.has_lots, a.contract_has_lots, a.lots_count, a.framework_agreement, a.joue,
a.county_code, a.notice_state, a.notice_state_id,
a.documents, a.award_criteria, a.lots, a.details,
a.source, a.seap_url
FROM seap.announcements a
LEFT JOIN seap.cpv_codes c ON c.code = a.cpv_division
LEFT JOIN seap.cui_location cl_a ON cl_a.cui = a.authority_cui
LEFT JOIN seap.cui_location cl_s ON cl_s.cui = regexp_replace(a.supplier_cui, '^RO\\s*', '', 'i')
WHERE a.id = $1
LIMIT 1
`, [id]);
return result.rows[0] || null;
}
+121
View File
@@ -0,0 +1,121 @@
import { query } from './db.js';
export interface UserProfile {
id: string;
email: string;
name: string | null;
city: string | null;
role: 'user' | 'developer' | 'admin';
bio: string | null;
github_url: string | null;
website_url: string | null;
created_at: string;
}
export async function ensureAuthTables() {
await query(`
CREATE SCHEMA IF NOT EXISTS platform;
CREATE TABLE IF NOT EXISTS platform.user_profiles (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
name TEXT,
city TEXT,
role TEXT DEFAULT 'user' CHECK (role IN ('user', 'developer', 'admin')),
bio TEXT,
github_url TEXT,
website_url TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS platform.product_submissions (
id SERIAL PRIMARY KEY,
user_id TEXT REFERENCES platform.user_profiles(id),
title TEXT NOT NULL,
description TEXT NOT NULL,
category TEXT NOT NULL,
demo_url TEXT,
source_url TEXT,
screenshot_url TEXT,
status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'rejected')),
solves_ideas INTEGER[] DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Add solves_product column to ideas if not exists
DO $$ BEGIN
ALTER TABLE platform.ideas ADD COLUMN IF NOT EXISTS solved_by_product TEXT;
ALTER TABLE platform.ideas ADD COLUMN IF NOT EXISTS vote_threshold_reached BOOLEAN DEFAULT FALSE;
EXCEPTION WHEN OTHERS THEN NULL;
END $$;
`);
}
export async function getOrCreateProfile(id: string, email: string): Promise<UserProfile> {
const existing = await query<UserProfile>(
'SELECT * FROM platform.user_profiles WHERE id = $1', [id]
);
if (existing.rows[0]) return existing.rows[0];
const result = await query<UserProfile>(
`INSERT INTO platform.user_profiles (id, email) VALUES ($1, $2) RETURNING *`,
[id, email]
);
return result.rows[0];
}
export async function updateProfile(id: string, data: {
name?: string;
city?: string;
bio?: string;
github_url?: string;
website_url?: string;
}): Promise<UserProfile> {
const result = await query<UserProfile>(
`UPDATE platform.user_profiles
SET name = COALESCE($2, name),
city = COALESCE($3, city),
bio = COALESCE($4, bio),
github_url = COALESCE($5, github_url),
website_url = COALESCE($6, website_url)
WHERE id = $1 RETURNING *`,
[id, data.name, data.city, data.bio, data.github_url, data.website_url]
);
return result.rows[0];
}
export async function submitProduct(data: {
user_id: string;
title: string;
description: string;
category: string;
demo_url?: string;
source_url?: string;
screenshot_url?: string;
solves_ideas?: number[];
}) {
const result = await query(
`INSERT INTO platform.product_submissions
(user_id, title, description, category, demo_url, source_url, screenshot_url, solves_ideas)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id`,
[data.user_id, data.title, data.description, data.category,
data.demo_url || null, data.source_url || null, data.screenshot_url || null,
data.solves_ideas || []]
);
return result.rows[0].id;
}
export async function getUserSubmissions(userId: string) {
const result = await query(
'SELECT * FROM platform.product_submissions WHERE user_id = $1 ORDER BY created_at DESC',
[userId]
);
return result.rows;
}
export async function getProfile(id: string): Promise<UserProfile | null> {
const result = await query<UserProfile>(
'SELECT * FROM platform.user_profiles WHERE id = $1', [id]
);
return result.rows[0] || null;
}
+529
View File
@@ -0,0 +1,529 @@
import { query } from './db.js';
export interface BeneficiarAnunt {
id: number;
smis_proiect_id: number | null;
smis_proiect_code: string | null;
smis_proiect_name: string | null;
beneficiar_name: string;
beneficiar_program_tag: string | null;
beneficiar_adresa: string | null;
beneficiar_contact: string | null;
beneficiar_telefon: string | null;
beneficiar_regiune: string | null;
beneficiar_judet: string | null;
beneficiar_localitate: string | null;
procedura_status: string | null;
data_publicare: string | null;
data_limita_oferta: string | null;
ora_limita_oferta: string | null;
judet: string | null;
tip_contract: string | null;
cui: string | null;
cui_match_score: number | null;
cui_match_method: string | null;
}
export interface BeneficiarLot {
lot_no: number;
lot_label: string | null;
durata_contract: string | null;
buget_lei: number | null;
cpv_cod: string | null;
descriere_url: string | null;
spec_url: string | null;
}
export async function getBeneficiarAnunt(id: number): Promise<BeneficiarAnunt | null> {
const r = await query<BeneficiarAnunt>(
`SELECT id, smis_proiect_id, smis_proiect_code, smis_proiect_name,
beneficiar_name, beneficiar_program_tag, beneficiar_adresa,
beneficiar_contact, beneficiar_telefon, beneficiar_regiune,
beneficiar_judet, beneficiar_localitate,
procedura_status,
to_char(data_publicare, 'YYYY-MM-DD') AS data_publicare,
to_char(data_limita_oferta, 'YYYY-MM-DD') AS data_limita_oferta,
ora_limita_oferta,
judet, tip_contract,
cui, cui_match_score, cui_match_method
FROM fonduri.beneficiar_anunt WHERE id = $1`,
[id],
);
return r.rows[0] || null;
}
export async function getBeneficiarLots(anuntId: number): Promise<BeneficiarLot[]> {
const r = await query<BeneficiarLot>(
`SELECT lot_no, lot_label, durata_contract, buget_lei, cpv_cod, descriere_url, spec_url
FROM fonduri.beneficiar_anunt_lot
WHERE anunt_id = $1
ORDER BY lot_no`,
[anuntId],
);
return r.rows;
}
// All anunturi by the same beneficiar (matched by cui if available, else name)
export async function getBeneficiarAlsoPosted(
cui: string | null,
name: string,
excludeId: number,
): Promise<Array<{ id: number; data_publicare: string | null; tip_contract: string | null; titlu: string | null; total_buget: number | null }>> {
if (cui) {
const r = await query<any>(
`SELECT a.id, to_char(a.data_publicare, 'YYYY-MM-DD') AS data_publicare,
a.tip_contract, a.smis_proiect_name AS titlu,
(SELECT SUM(buget_lei) FROM fonduri.beneficiar_anunt_lot WHERE anunt_id = a.id) AS total_buget
FROM fonduri.beneficiar_anunt a
WHERE a.cui = $1 AND a.id != $2
ORDER BY a.data_publicare DESC NULLS LAST
LIMIT 30`,
[cui, excludeId],
);
return r.rows;
}
const r = await query<any>(
`SELECT a.id, to_char(a.data_publicare, 'YYYY-MM-DD') AS data_publicare,
a.tip_contract, a.smis_proiect_name AS titlu,
(SELECT SUM(buget_lei) FROM fonduri.beneficiar_anunt_lot WHERE anunt_id = a.id) AS total_buget
FROM fonduri.beneficiar_anunt a
WHERE a.beneficiar_name = $1 AND a.id != $2
ORDER BY a.data_publicare DESC NULLS LAST
LIMIT 30`,
[name, excludeId],
);
return r.rows;
}
// EU project metadata from fonduri.beneficiar_proiect (populated incrementally
// by services/seap-scraper/src/scrape-beneficiar-proiect.ts). Returns null if
// the project page hasn't been scraped yet — the caller handles that gracefully.
export interface ProiectContext {
id: number;
smis_code: string | null;
titlu: string | null;
program_op_cod: string | null;
program_op_text: string | null;
axa_cod: string | null;
axa_text: string | null;
domeniul_cod: string | null;
domeniul_text: string | null;
operatiune_cod: string | null;
operatiune_text: string | null;
data_contract: string | null;
}
export async function getProiectContext(smisProiectId: number): Promise<ProiectContext | null> {
const r = await query<ProiectContext>(
`SELECT id, smis_code, titlu,
program_op_cod, program_op_text,
axa_cod, axa_text,
domeniul_cod, domeniul_text,
operatiune_cod, operatiune_text,
to_char(data_contract, 'YYYY-MM-DD') AS data_contract
FROM fonduri.beneficiar_proiect
WHERE id = $1`,
[smisProiectId],
);
return r.rows[0] || null;
}
// Full proiect detail for /proiect/[id] page — combines beneficiar_proiect
// metadata with aggregated anunturi stats and the matched firma profile (if any).
export interface ProiectFull {
id: number;
proiect_type: number;
smis_code: string | null;
titlu: string | null;
beneficiar_name: string | null;
beneficiar_cui: string | null; // matched firma CUI (from beneficiar_anunt.cui MIN)
beneficiar_judet: string | null;
program_op_cod: string | null;
program_op_text: string | null;
axa_cod: string | null;
axa_text: string | null;
domeniul_cod: string | null;
domeniul_text: string | null;
operatiune_cod: string | null;
operatiune_text: string | null;
data_contract: string | null;
anunturi_count: number; // total announcements under this project
buget_total: number; // sum of all lot budgets across anunturi
data_publicare_min: string | null; // earliest anunt date
data_publicare_max: string | null; // latest anunt date
}
export async function getProiectFull(id: number): Promise<ProiectFull | null> {
const r = await query<ProiectFull>(
`WITH agg AS (
SELECT b.smis_proiect_id AS id,
MIN(b.beneficiar_judet) AS beneficiar_judet,
MIN(b.cui) AS beneficiar_cui,
COUNT(*) AS anunturi_count,
COALESCE((SELECT sum(l.buget_lei)
FROM fonduri.beneficiar_anunt_lot l
JOIN fonduri.beneficiar_anunt b2 ON b2.id = l.anunt_id
WHERE b2.smis_proiect_id = $1), 0)::numeric AS buget_total,
MIN(b.data_publicare) AS data_publicare_min,
MAX(b.data_publicare) AS data_publicare_max
FROM fonduri.beneficiar_anunt b
WHERE b.smis_proiect_id = $1
GROUP BY b.smis_proiect_id
)
SELECT p.id, p.proiect_type, p.smis_code, p.titlu, p.beneficiar_name,
agg.beneficiar_cui, agg.beneficiar_judet,
p.program_op_cod, p.program_op_text,
p.axa_cod, p.axa_text,
p.domeniul_cod, p.domeniul_text,
p.operatiune_cod, p.operatiune_text,
to_char(p.data_contract, 'YYYY-MM-DD') AS data_contract,
COALESCE(agg.anunturi_count, 0)::int AS anunturi_count,
agg.buget_total,
to_char(agg.data_publicare_min, 'YYYY-MM-DD') AS data_publicare_min,
to_char(agg.data_publicare_max, 'YYYY-MM-DD') AS data_publicare_max
FROM fonduri.beneficiar_proiect p
LEFT JOIN agg ON agg.id = p.id
WHERE p.id = $1`,
[id],
);
if (!r.rows[0]) return null;
const row = r.rows[0];
return {
...row,
anunturi_count: Number(row.anunturi_count),
buget_total: Number(row.buget_total) || 0,
};
}
// All anunturi tied to a project (for the project page listing).
export async function getProiectAllAnunturi(smisProiectId: number): Promise<any[]> {
const r = await query<any>(
`SELECT a.id, a.beneficiar_name,
to_char(a.data_publicare, 'YYYY-MM-DD') AS data_publicare,
a.tip_contract,
a.titlu,
a.procedura_status,
a.beneficiar_judet,
(SELECT sum(buget_lei) FROM fonduri.beneficiar_anunt_lot WHERE anunt_id = a.id)::numeric AS total_buget
FROM fonduri.beneficiar_anunt a
WHERE a.smis_proiect_id = $1
ORDER BY a.data_publicare DESC NULLS LAST`,
[smisProiectId],
);
return r.rows.map((row) => ({ ...row, total_buget: row.total_buget == null ? null : Number(row.total_buget) }));
}
// Other anunturi from the same EU project
export async function getProiectAnunturi(smisProiectId: number, excludeId: number): Promise<any[]> {
const r = await query<any>(
`SELECT a.id, a.beneficiar_name, to_char(a.data_publicare, 'YYYY-MM-DD') AS data_publicare,
a.tip_contract, a.smis_proiect_name AS titlu,
(SELECT SUM(buget_lei) FROM fonduri.beneficiar_anunt_lot WHERE anunt_id = a.id) AS total_buget
FROM fonduri.beneficiar_anunt a
WHERE a.smis_proiect_id = $1 AND a.id != $2
ORDER BY a.data_publicare DESC NULLS LAST
LIMIT 20`,
[smisProiectId, excludeId],
);
return r.rows;
}
// ============================================================================
// BROWSE / SEARCH API for /fonduri-ue/anunturi
// 41,494 anunturi total · paginated · facet counts on filtered subset
// ============================================================================
export interface BrowseFilters {
q?: string; // free text (titlu / beneficiar_name / SMIS)
program?: string; // program_op_cod
judet?: string; // upper-case normalized: 'CLUJ', 'BUCURESTI'
tip_contract?: string; // 'Furnizare' | 'Servicii' | 'Lucrări' | 'N/A'
status?: 'open' | 'closed';
axa?: string; // axa_cod from beneficiar_proiect
buget_min?: number;
buget_max?: number;
date_from?: string; // 'YYYY-MM-DD'
date_to?: string;
sort?: 'date_desc' | 'date_asc' | 'buget_desc' | 'buget_asc';
limit?: number; // default 30, max 100
offset?: number;
}
export interface BrowseRow {
id: number;
titlu: string | null;
beneficiar_name: string;
beneficiar_judet: string | null;
cui: string | null;
data_publicare: string | null;
tip_contract: string | null;
procedura_status: string | null;
smis_proiect_id: number | null;
smis_proiect_code: string | null;
smis_proiect_name: string | null;
program_op_cod: string | null;
program_op_text: string | null;
axa_cod: string | null;
buget_total: number;
loturi: number;
lat: number | null;
lng: number | null;
}
export interface BrowseFacets {
programs: { code: string; text: string | null; count: number }[];
judete: { judet: string; count: number }[];
tip_contract: { tip: string; count: number }[];
axe: { code: string; text: string | null; count: number }[];
status: { status: 'open' | 'closed'; count: number }[];
}
export interface BrowseResponse {
total: number;
buget_sum: number;
rows: BrowseRow[];
facets: BrowseFacets;
}
// Build a WHERE clause from filters. Always operates on alias `a` (beneficiar_anunt)
// joined LEFT to `p` (beneficiar_proiect) and a buget-CTE.
function buildBrowseWhere(f: BrowseFilters, params: any[]): string {
const conds: string[] = [];
if (f.q && f.q.trim()) {
const q = f.q.trim();
params.push(`%${q}%`);
const likeIdx = params.length;
params.push(q);
const eqIdx = params.length;
conds.push(`(
a.beneficiar_name ILIKE $${likeIdx}
OR a.smis_proiect_name ILIKE $${likeIdx}
OR a.smis_proiect_code = $${eqIdx}
OR a.cui = regexp_replace(upper($${eqIdx}), '(^RO)|\\s+', '', 'g')
)`);
}
if (f.program) {
params.push(f.program);
conds.push(`p.program_op_cod = $${params.length}`);
}
if (f.judet) {
params.push(f.judet);
conds.push(`a.judet = $${params.length}`);
}
if (f.tip_contract) {
params.push(f.tip_contract);
conds.push(`a.tip_contract = $${params.length}`);
}
if (f.axa) {
params.push(f.axa);
conds.push(`p.axa_cod = $${params.length}`);
}
if (f.status === 'open') {
conds.push(`a.procedura_status ILIKE '%curs%'`);
} else if (f.status === 'closed') {
conds.push(`a.procedura_status ILIKE '%închisă%'`);
}
if (f.date_from) {
params.push(f.date_from);
conds.push(`a.data_publicare >= $${params.length}::date`);
}
if (f.date_to) {
params.push(f.date_to);
conds.push(`a.data_publicare <= $${params.length}::date`);
}
// Budget filter operates on aggregated lot sum — applied via HAVING-shaped subquery
if (f.buget_min !== undefined && f.buget_min !== null) {
params.push(f.buget_min);
conds.push(`COALESCE((SELECT sum(buget_lei) FROM fonduri.beneficiar_anunt_lot WHERE anunt_id = a.id), 0) >= $${params.length}`);
}
if (f.buget_max !== undefined && f.buget_max !== null) {
params.push(f.buget_max);
conds.push(`COALESCE((SELECT sum(buget_lei) FROM fonduri.beneficiar_anunt_lot WHERE anunt_id = a.id), 0) <= $${params.length}`);
}
return conds.length ? 'WHERE ' + conds.join(' AND ') : '';
}
export async function browseBeneficiarAnunturi(f: BrowseFilters): Promise<BrowseResponse> {
const limit = Math.min(f.limit ?? 30, 100);
const offset = Math.max(0, f.offset ?? 0);
// --- main paginated query
const params: any[] = [];
const where = buildBrowseWhere(f, params);
let orderBy = 'a.data_publicare DESC NULLS LAST, a.id DESC';
if (f.sort === 'date_asc') orderBy = 'a.data_publicare ASC NULLS LAST, a.id ASC';
else if (f.sort === 'buget_desc') orderBy = 'buget_total DESC NULLS LAST, a.id DESC';
else if (f.sort === 'buget_asc') orderBy = 'buget_total ASC NULLS LAST, a.id ASC';
params.push(limit);
const limitIdx = params.length;
params.push(offset);
const offsetIdx = params.length;
const rowsSql = `
SELECT a.id, a.titlu, a.beneficiar_name, a.beneficiar_judet, a.cui,
to_char(a.data_publicare, 'YYYY-MM-DD') AS data_publicare,
a.tip_contract, a.procedura_status,
a.smis_proiect_id, a.smis_proiect_code, a.smis_proiect_name,
p.program_op_cod, p.program_op_text, p.axa_cod,
COALESCE((SELECT sum(buget_lei) FROM fonduri.beneficiar_anunt_lot WHERE anunt_id = a.id), 0)::numeric AS buget_total,
COALESCE((SELECT count(*) FROM fonduri.beneficiar_anunt_lot WHERE anunt_id = a.id), 0)::int AS loturi,
e.lat, e.lng
FROM fonduri.beneficiar_anunt a
LEFT JOIN fonduri.beneficiar_proiect p ON p.id = a.smis_proiect_id
LEFT JOIN firms.entities e ON e.cui = a.cui
${where}
ORDER BY ${orderBy}
LIMIT $${limitIdx} OFFSET $${offsetIdx}`;
// total + buget_sum
const totalParams = params.slice(0, params.length - 2);
const totalSql = `
SELECT count(*)::int AS total,
COALESCE(sum(COALESCE((SELECT sum(buget_lei) FROM fonduri.beneficiar_anunt_lot WHERE anunt_id = a.id), 0)), 0)::numeric AS buget_sum
FROM fonduri.beneficiar_anunt a
LEFT JOIN fonduri.beneficiar_proiect p ON p.id = a.smis_proiect_id
${where}`;
// --- facet queries · same WHERE used for all (UX: facets shrink as filters apply)
const whereAnd = where ? `${where} AND ` : 'WHERE ';
const facetSqlPrograms = `
SELECT p.program_op_cod AS code, MIN(p.program_op_text) AS text, count(*)::int AS count
FROM fonduri.beneficiar_anunt a
LEFT JOIN fonduri.beneficiar_proiect p ON p.id = a.smis_proiect_id
${whereAnd}p.program_op_cod IS NOT NULL
GROUP BY p.program_op_cod
ORDER BY count DESC
LIMIT 20`;
const facetSqlJudete = `
SELECT a.judet, count(*)::int AS count
FROM fonduri.beneficiar_anunt a
LEFT JOIN fonduri.beneficiar_proiect p ON p.id = a.smis_proiect_id
${whereAnd}a.judet IS NOT NULL
GROUP BY a.judet
ORDER BY count DESC
LIMIT 50`;
const facetSqlTip = `
SELECT a.tip_contract AS tip, count(*)::int AS count
FROM fonduri.beneficiar_anunt a
LEFT JOIN fonduri.beneficiar_proiect p ON p.id = a.smis_proiect_id
${whereAnd}a.tip_contract IS NOT NULL
GROUP BY a.tip_contract
ORDER BY count DESC`;
const facetSqlAxe = `
SELECT p.axa_cod AS code, MIN(p.axa_text) AS text, count(*)::int AS count
FROM fonduri.beneficiar_anunt a
LEFT JOIN fonduri.beneficiar_proiect p ON p.id = a.smis_proiect_id
${whereAnd}p.axa_cod IS NOT NULL
GROUP BY p.axa_cod
ORDER BY count DESC
LIMIT 25`;
const facetSqlStatus = `
SELECT CASE WHEN a.procedura_status ILIKE '%curs%' THEN 'open' ELSE 'closed' END AS status,
count(*)::int AS count
FROM fonduri.beneficiar_anunt a
LEFT JOIN fonduri.beneficiar_proiect p ON p.id = a.smis_proiect_id
${where}
GROUP BY 1
ORDER BY count DESC`;
const [rowsR, totalR, programsR, judeteR, tipR, axeR, statusR] = await Promise.all([
query<BrowseRow>(rowsSql, params),
query<{ total: number; buget_sum: number }>(totalSql, totalParams),
query<{ code: string; text: string | null; count: number }>(facetSqlPrograms, totalParams),
query<{ judet: string; count: number }>(facetSqlJudete, totalParams),
query<{ tip: string; count: number }>(facetSqlTip, totalParams),
query<{ code: string; text: string | null; count: number }>(facetSqlAxe, totalParams),
query<{ status: 'open' | 'closed'; count: number }>(facetSqlStatus, totalParams),
]);
return {
total: Number(totalR.rows[0]?.total || 0),
buget_sum: Number(totalR.rows[0]?.buget_sum || 0),
rows: rowsR.rows.map(r => ({
...r,
buget_total: Number(r.buget_total) || 0,
loturi: Number(r.loturi) || 0,
lat: r.lat != null ? Number(r.lat) : null,
lng: r.lng != null ? Number(r.lng) : null,
})),
facets: {
programs: programsR.rows,
judete: judeteR.rows,
tip_contract: tipR.rows,
axe: axeR.rows,
status: statusR.rows,
},
};
}
export function parseBrowseParams(url: URL): BrowseFilters {
const sp = url.searchParams;
const f: BrowseFilters = {};
if (sp.get('q')) f.q = sp.get('q')!;
if (sp.get('program')) f.program = sp.get('program')!;
if (sp.get('judet')) f.judet = sp.get('judet')!;
if (sp.get('tip')) f.tip_contract = sp.get('tip')!;
if (sp.get('axa')) f.axa = sp.get('axa')!;
const status = sp.get('status');
if (status === 'open' || status === 'closed') f.status = status;
if (sp.get('buget_min')) f.buget_min = parseFloat(sp.get('buget_min')!);
if (sp.get('buget_max')) f.buget_max = parseFloat(sp.get('buget_max')!);
if (sp.get('date_from')) f.date_from = sp.get('date_from')!;
if (sp.get('date_to')) f.date_to = sp.get('date_to')!;
const sort = sp.get('sort');
if (sort === 'date_desc' || sort === 'date_asc' || sort === 'buget_desc' || sort === 'buget_asc') f.sort = sort;
if (sp.get('limit')) f.limit = parseInt(sp.get('limit')!);
if (sp.get('offset')) f.offset = parseInt(sp.get('offset')!);
return f;
}
export function buildBrowseUrl(base: string, f: BrowseFilters): string {
const sp = new URLSearchParams();
if (f.q) sp.set('q', f.q);
if (f.program) sp.set('program', f.program);
if (f.judet) sp.set('judet', f.judet);
if (f.tip_contract) sp.set('tip', f.tip_contract);
if (f.axa) sp.set('axa', f.axa);
if (f.status) sp.set('status', f.status);
if (f.buget_min !== undefined) sp.set('buget_min', String(f.buget_min));
if (f.buget_max !== undefined) sp.set('buget_max', String(f.buget_max));
if (f.date_from) sp.set('date_from', f.date_from);
if (f.date_to) sp.set('date_to', f.date_to);
if (f.sort) sp.set('sort', f.sort);
if (f.offset) sp.set('offset', String(f.offset));
if (f.limit && f.limit !== 30) sp.set('limit', String(f.limit));
const s = sp.toString();
return s ? `${base}?${s}` : base;
}
// Quick SEAP cross-link counts (for set of supplier CUIs on the rendered page).
// Mirrors the pattern from cauta.astro but in the other direction.
export async function getSeapCountsForCuis(cuis: string[]): Promise<Map<string, { contracts: number; total: number }>> {
if (cuis.length === 0) return new Map();
const r = await query<{ cui: string; contracts: number; total: number }>(
`SELECT supplier_cui AS cui, count(*)::int AS contracts,
COALESCE(sum(awarded_value), 0)::numeric AS total
FROM seap.announcements
WHERE supplier_cui = ANY($1) AND awarded_value IS NOT NULL
GROUP BY supplier_cui`,
[cuis],
);
const map = new Map<string, { contracts: number; total: number }>();
for (const row of r.rows) map.set(row.cui, { contracts: Number(row.contracts), total: Number(row.total) });
return map;
}
+212
View File
@@ -0,0 +1,212 @@
import { query } from './db.js';
export interface CategoryProfile {
cpv_division: string; // 8-digit normalized division (e.g. '33000000')
cpv_division_name: string | null;
emoji: string | null;
total_contracts: number;
total_awarded: number | null;
total_estimated: number | null;
avg_contract: number | null;
distinct_authorities: number;
distinct_suppliers: number;
first_contract_date: string | null;
last_contract_date: string | null;
top_authority_name: string | null;
top_supplier_name: string | null;
}
export interface CategoryContract {
id: number;
ref_number: string;
type: string;
title: string | null;
cpv_code: string | null;
cpv_name_ro: string | null;
cpv_division: string | null;
cpv_division_name: string | null;
publication_date: string | null;
awarded_value: number | null;
estimated_value: number | null;
currency: string | null;
procedure_type: string | null;
authority_name: string | null;
authority_cui: string | null;
supplier_name: string | null;
supplier_cui: string | null;
has_lots: boolean | null;
lots_count: number | null;
source: string;
seap_url: string | null;
}
export interface PartyTop {
cui: string;
name: string;
county: string | null;
contracts: number;
total_value: number;
}
export interface SubCpvBreakdown {
cpv_code: string;
cpv_name: string | null;
contracts: number;
total_value: number;
}
/** Normalize a CPV input to its 8-digit division code (e.g. '33000000'). */
export function normalizeCpvDivision(input: string): string {
const digits = (input || '').replace(/[^0-9]/g, '');
if (!digits) return '';
// pad/truncate to 8 digits, then collapse last 6 to zeros for division-level
const eight = (digits + '00000000').slice(0, 8);
return eight.slice(0, 2) + '000000';
}
export async function getCategoryProfile(cpvDivision: string): Promise<CategoryProfile | null> {
const result = await query<CategoryProfile>(`
WITH base AS (
SELECT * FROM seap.announcements WHERE cpv_division = $1
),
top_auth AS (
SELECT authority_name FROM base
WHERE authority_cui IS NOT NULL
GROUP BY authority_cui, authority_name
ORDER BY count(*) DESC NULLS LAST
LIMIT 1
),
top_supp AS (
SELECT supplier_name FROM base
WHERE supplier_cui IS NOT NULL
GROUP BY supplier_cui, supplier_name
ORDER BY count(*) DESC NULLS LAST
LIMIT 1
)
SELECT
$1::text AS cpv_division,
(SELECT name_ro FROM seap.cpv_codes WHERE code = $1) AS cpv_division_name,
(SELECT emoji FROM seap.cpv_codes WHERE code = $1) AS emoji,
count(*)::int AS total_contracts,
sum(awarded_value)::numeric AS total_awarded,
sum(estimated_value)::numeric AS total_estimated,
avg(awarded_value)::numeric AS avg_contract,
count(DISTINCT authority_cui)::int AS distinct_authorities,
count(DISTINCT supplier_cui)::int AS distinct_suppliers,
min(publication_date) AS first_contract_date,
max(publication_date) AS last_contract_date,
(SELECT authority_name FROM top_auth) AS top_authority_name,
(SELECT supplier_name FROM top_supp) AS top_supplier_name
FROM base
`, [cpvDivision]);
const row = result.rows[0];
if (!row || !row.total_contracts) return null;
return row;
}
export async function getCategoryContracts(
cpvDivision: string,
limit = 30,
offset = 0,
): Promise<CategoryContract[]> {
const result = await query<CategoryContract>(`
SELECT
a.id, a.ref_number, a.type, a.title,
a.cpv_code, a.cpv_name_ro, a.cpv_division,
c.name_ro AS cpv_division_name,
a.publication_date, a.awarded_value, a.estimated_value, a.currency,
a.procedure_type,
a.authority_name, a.authority_cui,
a.supplier_name, a.supplier_cui,
a.contract_has_lots AS has_lots, a.lots_count,
a.source, a.seap_url
FROM seap.announcements a
LEFT JOIN seap.cpv_codes c ON c.code = a.cpv_division
WHERE a.cpv_division = $1
ORDER BY a.publication_date DESC NULLS LAST
LIMIT $2 OFFSET $3
`, [cpvDivision, limit, offset]);
return result.rows;
}
export async function getCategoryTopBuyers(
cpvDivision: string,
limit = 10,
): Promise<PartyTop[]> {
const result = await query<PartyTop>(`
SELECT
a.authority_cui AS cui,
MIN(a.authority_name) AS name,
MIN(cl.county) AS county,
count(*)::int AS contracts,
coalesce(sum(a.awarded_value), 0)::numeric AS total_value
FROM seap.announcements a
LEFT JOIN seap.cui_location cl ON cl.cui = a.authority_cui
WHERE a.cpv_division = $1 AND a.authority_cui IS NOT NULL
GROUP BY a.authority_cui
ORDER BY 5 DESC NULLS LAST
LIMIT $2
`, [cpvDivision, limit]);
return result.rows;
}
export async function getCategoryTopSuppliers(
cpvDivision: string,
limit = 10,
): Promise<PartyTop[]> {
const result = await query<PartyTop>(`
SELECT
a.supplier_cui AS cui,
MIN(a.supplier_name) AS name,
MIN(cl.county) AS county,
count(*)::int AS contracts,
coalesce(sum(a.awarded_value), 0)::numeric AS total_value
FROM seap.announcements a
LEFT JOIN seap.cui_location cl ON cl.cui = regexp_replace(a.supplier_cui, '^RO\\s*', '', 'i')
WHERE a.cpv_division = $1 AND a.supplier_cui IS NOT NULL
GROUP BY a.supplier_cui
ORDER BY 5 DESC NULLS LAST
LIMIT $2
`, [cpvDivision, limit]);
return result.rows;
}
/** Sub-CPVs (4-digit roll-up under the same division). */
export async function getCategorySubCpvs(
cpvDivision: string,
limit = 12,
): Promise<SubCpvBreakdown[]> {
// First 4 digits identify a sub-group; we group by left(cpv_code, 4)
// and join cpv_codes to get a name (best-effort: pick a level=2 row or first match).
const result = await query<SubCpvBreakdown>(`
WITH grouped AS (
SELECT
substr(coalesce(a.cpv_code, a.cpv_division), 1, 4) AS sub4,
count(*)::int AS contracts,
coalesce(sum(a.awarded_value), 0)::numeric AS total_value
FROM seap.announcements a
WHERE a.cpv_division = $1
AND a.cpv_code IS NOT NULL
GROUP BY 1
)
SELECT
g.sub4 || '0000' AS cpv_code,
(
SELECT name_ro FROM seap.cpv_codes c
WHERE substr(c.code, 1, 4) = g.sub4
ORDER BY (CASE WHEN c.code = g.sub4 || '0000' THEN 0 ELSE 1 END), c.level
LIMIT 1
) AS cpv_name,
g.contracts,
g.total_value
FROM grouped g
ORDER BY g.total_value DESC NULLS LAST
LIMIT $2
`, [cpvDivision, limit]);
return result.rows;
}
+220
View File
@@ -0,0 +1,220 @@
import { query } from './db.js';
export interface CountyProfile {
county_code: string;
county_name: string;
total_contracts: number;
total_awarded: number | null;
total_estimated: number | null;
avg_contract: number | null;
distinct_authorities: number;
distinct_suppliers: number;
first_contract_date: string | null;
last_contract_date: string | null;
}
export interface CountyContract {
id: number;
ref_number: string;
type: string;
title: string | null;
cpv_code: string | null;
cpv_name_ro: string | null;
cpv_division: string | null;
cpv_division_name: string | null;
publication_date: string | null;
awarded_value: number | null;
estimated_value: number | null;
currency: string | null;
procedure_type: string | null;
authority_name: string | null;
authority_cui: string | null;
supplier_name: string | null;
supplier_cui: string | null;
has_lots: boolean | null;
lots_count: number | null;
source: string;
seap_url: string | null;
}
export interface CountyParty {
cui: string;
name: string;
contracts: number;
total_value: number;
}
export interface CountyCpvBreakdown {
cpv_division: string;
cpv_division_name: string | null;
emoji: string | null;
contracts: number;
total_value: number;
}
// Map of NUTS code → Romanian county name.
export const COUNTY_MAP: Record<string, string> = {
RO111: 'Bihor', RO112: 'Bistrița-Năsăud', RO113: 'Cluj', RO114: 'Maramureș',
RO115: 'Satu Mare', RO116: 'Sălaj',
RO121: 'Alba', RO122: 'Brașov', RO123: 'Covasna', RO124: 'Harghita',
RO125: 'Mureș', RO126: 'Sibiu',
RO211: 'Bacău', RO212: 'Botoșani', RO213: 'Iași', RO214: 'Neamț',
RO215: 'Suceava', RO216: 'Vaslui',
RO221: 'Brăila', RO222: 'Buzău', RO223: 'Constanța', RO224: 'Galați',
RO225: 'Tulcea', RO226: 'Vrancea',
RO311: 'Argeș', RO312: 'Călărași', RO313: 'Dâmbovița', RO314: 'Giurgiu',
RO315: 'Ialomița', RO316: 'Prahova', RO317: 'Teleorman',
RO321: 'București', RO322: 'Ilfov',
RO411: 'Dolj', RO412: 'Gorj', RO413: 'Mehedinți', RO414: 'Olt', RO415: 'Vâlcea',
RO421: 'Arad', RO422: 'Caraș-Severin', RO423: 'Hunedoara', RO424: 'Timiș',
};
// Map of 2-letter abbreviations and slugs to NUTS code.
const SLUG_MAP: Record<string, string> = {};
function slugify(s: string): string {
return s.toLowerCase()
.normalize('NFD').replace(/[̀-ͯ]/g, '')
.replace(/ș/g, 's').replace(/ț/g, 't').replace(/ă/g, 'a').replace(/â/g, 'a').replace(/î/g, 'i')
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
}
for (const [code, name] of Object.entries(COUNTY_MAP)) {
SLUG_MAP[slugify(name)] = code;
}
// Also accept simple 2-letter codes for the common cases
const ABBREV_MAP: Record<string, string> = {
b: 'RO321', bc: 'RO211', bh: 'RO111', bn: 'RO112', bt: 'RO212', br: 'RO221',
bv: 'RO122', bz: 'RO222', cs: 'RO422', cl: 'RO312', cj: 'RO113', ct: 'RO223',
cv: 'RO123', db: 'RO313', dj: 'RO411', gl: 'RO224', gr: 'RO314', gj: 'RO412',
hr: 'RO124', hd: 'RO423', il: 'RO315', is: 'RO213', if: 'RO322', mm: 'RO114',
mh: 'RO413', ms: 'RO125', nt: 'RO214', ot: 'RO414', ph: 'RO316', sm: 'RO115',
sj: 'RO116', sb: 'RO126', sv: 'RO215', tr: 'RO317', tm: 'RO424', tl: 'RO225',
vs: 'RO216', vl: 'RO415', vn: 'RO226', ar: 'RO421', ag: 'RO311', ab: 'RO121',
};
/** Resolve any input form (NUTS code, slug, abbreviation) to NUTS code + name. */
export function resolveCounty(input: string): { code: string; name: string } | null {
if (!input) return null;
const upper = input.toUpperCase().trim();
if (COUNTY_MAP[upper]) return { code: upper, name: COUNTY_MAP[upper] };
const lower = input.toLowerCase().trim();
if (ABBREV_MAP[lower]) return { code: ABBREV_MAP[lower], name: COUNTY_MAP[ABBREV_MAP[lower]] };
const slug = slugify(input);
if (SLUG_MAP[slug]) return { code: SLUG_MAP[slug], name: COUNTY_MAP[SLUG_MAP[slug]] };
return null;
}
export async function getCountyProfile(countyCode: string): Promise<CountyProfile | null> {
const result = await query<CountyProfile>(`
SELECT
$1::text AS county_code,
$2::text AS county_name,
count(*)::int AS total_contracts,
sum(awarded_value)::numeric AS total_awarded,
sum(estimated_value)::numeric AS total_estimated,
avg(awarded_value)::numeric AS avg_contract,
count(DISTINCT authority_cui)::int AS distinct_authorities,
count(DISTINCT supplier_cui)::int AS distinct_suppliers,
min(publication_date) AS first_contract_date,
max(publication_date) AS last_contract_date
FROM seap.announcements
WHERE county_code = $1
`, [countyCode, COUNTY_MAP[countyCode] ?? countyCode]);
const row = result.rows[0];
if (!row || !row.total_contracts) return null;
return row;
}
export async function getCountyContracts(
countyCode: string,
limit = 30,
offset = 0,
): Promise<CountyContract[]> {
const result = await query<CountyContract>(`
SELECT
a.id, a.ref_number, a.type, a.title,
a.cpv_code, a.cpv_name_ro, a.cpv_division,
c.name_ro AS cpv_division_name,
a.publication_date, a.awarded_value, a.estimated_value, a.currency,
a.procedure_type,
a.authority_name, a.authority_cui,
a.supplier_name, a.supplier_cui,
a.contract_has_lots AS has_lots, a.lots_count,
a.source, a.seap_url
FROM seap.announcements a
LEFT JOIN seap.cpv_codes c ON c.code = a.cpv_division
WHERE a.county_code = $1
ORDER BY a.publication_date DESC NULLS LAST
LIMIT $2 OFFSET $3
`, [countyCode, limit, offset]);
return result.rows;
}
export async function getCountyTopAuthorities(
countyCode: string,
limit = 10,
): Promise<CountyParty[]> {
const result = await query<CountyParty>(`
SELECT
a.authority_cui AS cui,
MIN(a.authority_name) AS name,
count(*)::int AS contracts,
coalesce(sum(a.awarded_value), 0)::numeric AS total_value
FROM seap.announcements a
WHERE a.county_code = $1 AND a.authority_cui IS NOT NULL
GROUP BY a.authority_cui
ORDER BY 4 DESC NULLS LAST
LIMIT $2
`, [countyCode, limit]);
return result.rows;
}
/**
* Top suppliers active in this county. We approximate "active in county" two ways:
* (a) suppliers whose cui_location.county matches the county name, or
* (b) suppliers winning contracts published with this county_code.
* We use (b) — most reliable and consistent with the announcements set.
*/
export async function getCountyTopSuppliers(
countyCode: string,
limit = 10,
): Promise<CountyParty[]> {
const result = await query<CountyParty>(`
SELECT
a.supplier_cui AS cui,
MIN(a.supplier_name) AS name,
count(*)::int AS contracts,
coalesce(sum(a.awarded_value), 0)::numeric AS total_value
FROM seap.announcements a
WHERE a.county_code = $1 AND a.supplier_cui IS NOT NULL
GROUP BY a.supplier_cui
ORDER BY 4 DESC NULLS LAST
LIMIT $2
`, [countyCode, limit]);
return result.rows;
}
export async function getCountyTopCpvs(
countyCode: string,
limit = 10,
): Promise<CountyCpvBreakdown[]> {
const result = await query<CountyCpvBreakdown>(`
SELECT
a.cpv_division,
c.name_ro AS cpv_division_name,
c.emoji,
count(*)::int AS contracts,
coalesce(sum(a.awarded_value), 0)::numeric AS total_value
FROM seap.announcements a
LEFT JOIN seap.cpv_codes c ON c.code = a.cpv_division
WHERE a.county_code = $1 AND a.cpv_division IS NOT NULL
GROUP BY a.cpv_division, c.name_ro, c.emoji
ORDER BY 5 DESC NULLS LAST
LIMIT $2
`, [countyCode, limit]);
return result.rows;
}
+717
View File
@@ -0,0 +1,717 @@
import { query } from './db.js';
// ─────────────────────────────────────────────────────────────
// Shared row types for one-click dashboards
// ─────────────────────────────────────────────────────────────
export interface DashboardContractRow {
id: number;
ref_number: string | null;
type: string;
title: string | null;
authority_name: string | null;
authority_cui: string | null;
authority_county: string | null;
supplier_name: string | null;
supplier_cui: string | null;
cpv_division: string | null;
cpv_division_name: string | null;
cpv_emoji: string | null;
publication_date: string | null;
awarded_value: number | null;
estimated_value: number | null;
procedure_type: string | null;
has_lots: boolean | null;
lots_count: number | null;
framework_agreement: boolean | null;
source: string;
eu_funded: string | null;
eu_program: string | null;
}
export interface SupplierRankRow {
supplier_cui: string;
supplier_name: string | null;
contracts_won: number;
total_awarded: number | null;
distinct_clients: number;
avg_awarded: number | null;
most_recent: string | null;
}
export interface TopAuthorityRow {
authority_cui: string;
authority_name: string | null;
county_code: string | null;
total_awarded: number | null;
awarded_count: number;
}
export interface TodayActivity {
notices_count: number;
awarded_count: number;
total_awarded: number;
total_estimated: number;
distinct_authorities: number;
top_authority: { cui: string | null; name: string | null; total: number } | null;
top_cpv: { division: string | null; name: string | null; emoji: string | null; count: number } | null;
}
export interface DailyComparison {
today_count: number;
today_awarded: number;
avg_count_30d: number;
avg_awarded_30d: number;
count_pct: number; // signed % vs avg
awarded_pct: number; // signed % vs avg
using_24h: boolean; // true if no rows for "today" so we used last 24h
}
export interface EuProgramRow {
program: string;
contracts: number;
total_awarded: number;
}
export interface EuBeneficiaryRow {
cui: string;
name: string | null;
county: string | null;
contracts: number;
total_awarded: number;
}
export interface EuFundedSummary {
total_awarded: number;
contracts: number;
distinct_authorities: number;
distinct_suppliers: number;
programs: EuProgramRow[];
top_authorities: EuBeneficiaryRow[];
top_suppliers: EuBeneficiaryRow[];
}
// fonduri.beneficiar_anunt summary — companies that *receive* EU money and
// must run their own procurements (private side, separate from SEAP).
export interface PrivateEuTopFirm {
cui: string | null;
name: string;
county: string | null;
anunturi: number;
buget_total: number;
}
export interface PrivateEuTopProiect {
id: number;
smis_code: string | null;
titlu: string | null;
beneficiar_name: string | null;
beneficiar_cui: string | null;
program_op_cod: string | null;
anunturi: number;
buget_total: number;
// We don't yet have a /proiect page; link via the largest anunt
// of the project instead (same beneficiar/project context, just one of N).
sample_anunt_id: number | null;
}
export interface PrivateEuCountyRow {
judet: string;
anunturi: number;
buget_total: number;
}
export interface PrivateEuProgramRow {
program_op_cod: string;
program_op_text: string | null;
proiecte: number;
}
export interface PrivateEuSummary {
anunturi_total: number;
proiecte_total: number;
firme_matched: number;
buget_total: number;
proiecte_scraped: number; // current beneficiar_proiect rows (scraping in progress)
proiecte_target: number; // distinct from beneficiar_anunt (the work list)
top_firme: PrivateEuTopFirm[];
top_proiecte: PrivateEuTopProiect[];
top_judete: PrivateEuCountyRow[];
programs: PrivateEuProgramRow[]; // populated only when beneficiar_proiect has rows
}
// ─────────────────────────────────────────────────────────────
// 1. Today: live activity + the 50 most recent notices
// ─────────────────────────────────────────────────────────────
const AWARDED_TYPES = `'ca_notice','rfq_notice','contract','atribuire_fara','da','ted_notice'`;
export async function getTodayActivity(): Promise<{ activity: TodayActivity; using_24h: boolean }> {
// Try TODAY first
const todayAgg = await query<{
notices: number; awarded: number; total_awarded: number;
total_estimated: number; distinct_auth: number;
}>(`
SELECT
count(*)::int AS notices,
count(*) FILTER (WHERE type IN (${AWARDED_TYPES}))::int AS awarded,
coalesce(sum(awarded_value) FILTER (WHERE awarded_value IS NOT NULL), 0)::numeric AS total_awarded,
coalesce(sum(estimated_value) FILTER (WHERE estimated_value IS NOT NULL), 0)::numeric AS total_estimated,
count(DISTINCT authority_cui)::int AS distinct_auth
FROM seap.announcements
WHERE publication_date::date = current_date
`);
let using_24h = false;
let where = `publication_date::date = current_date`;
if ((todayAgg.rows[0]?.notices || 0) === 0) {
using_24h = true;
where = `publication_date >= now() - interval '24 hours'`;
}
const agg = using_24h
? (await query<any>(`
SELECT
count(*)::int AS notices,
count(*) FILTER (WHERE type IN (${AWARDED_TYPES}))::int AS awarded,
coalesce(sum(awarded_value) FILTER (WHERE awarded_value IS NOT NULL), 0)::numeric AS total_awarded,
coalesce(sum(estimated_value) FILTER (WHERE estimated_value IS NOT NULL), 0)::numeric AS total_estimated,
count(DISTINCT authority_cui)::int AS distinct_auth
FROM seap.announcements
WHERE ${where}
`)).rows[0]
: todayAgg.rows[0];
// Top authority for today (or last 24h)
const topAuth = await query<{ cui: string; name: string; total: number }>(`
SELECT
authority_cui AS cui,
MIN(authority_name) AS name,
coalesce(sum(awarded_value), 0)::numeric AS total
FROM seap.announcements
WHERE ${where} AND authority_cui IS NOT NULL
GROUP BY authority_cui
ORDER BY total DESC NULLS LAST, count(*) DESC
LIMIT 1
`);
// Top CPV division for today
const topCpv = await query<{ division: string; name: string; emoji: string; cnt: number }>(`
SELECT
a.cpv_division AS division,
MIN(c.name_ro) AS name,
MIN(c.emoji) AS emoji,
count(*)::int AS cnt
FROM seap.announcements a
LEFT JOIN seap.cpv_codes c ON c.code = a.cpv_division
WHERE ${where} AND a.cpv_division IS NOT NULL
GROUP BY a.cpv_division
ORDER BY cnt DESC
LIMIT 1
`);
return {
using_24h,
activity: {
notices_count: Number(agg?.notices || 0),
awarded_count: Number(agg?.awarded || 0),
total_awarded: Number(agg?.total_awarded || 0),
total_estimated: Number(agg?.total_estimated || 0),
distinct_authorities: Number(agg?.distinct_auth || 0),
top_authority: topAuth.rows[0]
? { cui: topAuth.rows[0].cui, name: topAuth.rows[0].name, total: Number(topAuth.rows[0].total) }
: null,
top_cpv: topCpv.rows[0]
? { division: topCpv.rows[0].division, name: topCpv.rows[0].name, emoji: topCpv.rows[0].emoji, count: Number(topCpv.rows[0].cnt) }
: null,
},
};
}
export async function getTodayFeed(limit = 50): Promise<DashboardContractRow[]> {
// Same fallback logic as activity: today first, last 24h if empty.
const todayCount = await query<{ n: number }>(`
SELECT count(*)::int AS n
FROM seap.announcements
WHERE publication_date::date = current_date
`);
const where = (todayCount.rows[0]?.n || 0) > 0
? `a.publication_date::date = current_date`
: `a.publication_date >= now() - interval '24 hours'`;
const result = await query<DashboardContractRow>(`
SELECT
a.id, a.ref_number, a.type, a.title,
a.authority_name, a.authority_cui,
cl.county AS authority_county,
a.supplier_name, a.supplier_cui,
a.cpv_division,
c.name_ro AS cpv_division_name,
c.emoji AS cpv_emoji,
a.publication_date, a.awarded_value, a.estimated_value,
a.procedure_type,
a.contract_has_lots AS has_lots, a.lots_count,
a.framework_agreement,
a.source, a.eu_funded, a.eu_program
FROM seap.announcements a
LEFT JOIN seap.cpv_codes c ON c.code = a.cpv_division
LEFT JOIN seap.cui_location cl ON cl.cui = a.authority_cui
WHERE ${where}
ORDER BY a.publication_date DESC NULLS LAST, a.id DESC
LIMIT $1
`, [limit]);
return result.rows;
}
export async function getDailyComparison(): Promise<DailyComparison> {
// Today aggregate (or last 24h fallback)
const todayRow = await query<{ cnt: number; awarded: number }>(`
SELECT
count(*)::int AS cnt,
coalesce(sum(awarded_value) FILTER (WHERE awarded_value IS NOT NULL), 0)::numeric AS awarded
FROM seap.announcements
WHERE publication_date::date = current_date
`);
let using_24h = false;
let today_count = Number(todayRow.rows[0]?.cnt || 0);
let today_awarded = Number(todayRow.rows[0]?.awarded || 0);
if (today_count === 0) {
using_24h = true;
const r = await query<{ cnt: number; awarded: number }>(`
SELECT
count(*)::int AS cnt,
coalesce(sum(awarded_value) FILTER (WHERE awarded_value IS NOT NULL), 0)::numeric AS awarded
FROM seap.announcements
WHERE publication_date >= now() - interval '24 hours'
`);
today_count = Number(r.rows[0]?.cnt || 0);
today_awarded = Number(r.rows[0]?.awarded || 0);
}
// 30-day average (excluding today). Per-day, then averaged.
const avg = await query<{ avg_cnt: number; avg_awarded: number }>(`
SELECT
coalesce(avg(daily_cnt), 0)::numeric AS avg_cnt,
coalesce(avg(daily_awarded), 0)::numeric AS avg_awarded
FROM (
SELECT
publication_date::date AS day,
count(*) AS daily_cnt,
coalesce(sum(awarded_value) FILTER (WHERE awarded_value IS NOT NULL), 0) AS daily_awarded
FROM seap.announcements
WHERE publication_date::date >= current_date - interval '30 days'
AND publication_date::date < current_date
GROUP BY 1
) d
`);
const avg_cnt = Number(avg.rows[0]?.avg_cnt || 0);
const avg_awarded = Number(avg.rows[0]?.avg_awarded || 0);
const pct = (now: number, base: number) =>
base <= 0 ? 0 : Math.round(((now - base) / base) * 100);
return {
today_count,
today_awarded,
avg_count_30d: Math.round(avg_cnt),
avg_awarded_30d: Math.round(avg_awarded),
count_pct: pct(today_count, avg_cnt),
awarded_pct: pct(today_awarded, avg_awarded),
using_24h,
};
}
// ─────────────────────────────────────────────────────────────
// 2. Top contracts (last 12 months, awarded)
// ─────────────────────────────────────────────────────────────
export interface TopContractsFilter {
eu_only?: boolean;
has_lots?: boolean;
framework?: boolean;
}
export async function getTopContractsRecent(
limit = 100,
filter: TopContractsFilter = {},
): Promise<DashboardContractRow[]> {
const conds: string[] = [
`a.publication_date >= now() - interval '12 months'`,
`a.awarded_value IS NOT NULL`,
`a.awarded_value > 0`,
`a.type IN (${AWARDED_TYPES})`,
];
if (filter.eu_only) conds.push(`a.eu_funded ILIKE 'da%'`);
if (filter.has_lots) conds.push(`a.contract_has_lots = true`);
if (filter.framework) conds.push(`a.framework_agreement = true`);
const result = await query<DashboardContractRow>(`
SELECT
a.id, a.ref_number, a.type, a.title,
a.authority_name, a.authority_cui,
cl.county AS authority_county,
a.supplier_name, a.supplier_cui,
a.cpv_division,
c.name_ro AS cpv_division_name,
c.emoji AS cpv_emoji,
a.publication_date, a.awarded_value, a.estimated_value,
a.procedure_type,
a.contract_has_lots AS has_lots, a.lots_count,
a.framework_agreement,
a.source, a.eu_funded, a.eu_program
FROM seap.announcements a
LEFT JOIN seap.cpv_codes c ON c.code = a.cpv_division
LEFT JOIN seap.cui_location cl ON cl.cui = a.authority_cui
WHERE ${conds.join(' AND ')}
ORDER BY a.awarded_value DESC NULLS LAST
LIMIT $1
`, [limit]);
return result.rows;
}
export interface TopContractsStats {
total_awarded: number;
contracts: number;
avg_value: number;
median_value: number;
}
export async function getTopContractsStats(
filter: TopContractsFilter = {},
): Promise<TopContractsStats> {
const conds: string[] = [
`publication_date >= now() - interval '12 months'`,
`awarded_value IS NOT NULL`,
`awarded_value > 0`,
`type IN (${AWARDED_TYPES})`,
];
if (filter.eu_only) conds.push(`eu_funded ILIKE 'da%'`);
if (filter.has_lots) conds.push(`contract_has_lots = true`);
if (filter.framework) conds.push(`framework_agreement = true`);
const r = await query<{ total: number; cnt: number; avg: number; median: number }>(`
SELECT
coalesce(sum(awarded_value), 0)::numeric AS total,
count(*)::int AS cnt,
coalesce(avg(awarded_value), 0)::numeric AS avg,
coalesce(percentile_cont(0.5) WITHIN GROUP (ORDER BY awarded_value), 0)::numeric AS median
FROM seap.announcements
WHERE ${conds.join(' AND ')}
`);
return {
total_awarded: Number(r.rows[0]?.total || 0),
contracts: Number(r.rows[0]?.cnt || 0),
avg_value: Number(r.rows[0]?.avg || 0),
median_value: Number(r.rows[0]?.median || 0),
};
}
// ─────────────────────────────────────────────────────────────
// 3. Top suppliers ranked
// ─────────────────────────────────────────────────────────────
export interface TopSuppliersStats {
distinct_suppliers: number;
top_supplier_total: number;
median_total: number;
}
export async function getTopSuppliersRanked(
limit = 100,
sortBy: 'total_awarded' | 'contracts_won' = 'total_awarded',
): Promise<SupplierRankRow[]> {
const orderCol = sortBy === 'contracts_won' ? 'contracts_won' : 'total_awarded';
const r = await query<SupplierRankRow>(`
SELECT
supplier_cui,
supplier_name,
contracts_won::int AS contracts_won,
total_awarded,
distinct_clients::int AS distinct_clients,
avg_awarded,
most_recent
FROM seap.mv_top_suppliers
WHERE supplier_cui IS NOT NULL
ORDER BY ${orderCol} DESC NULLS LAST
LIMIT $1
`, [limit]);
return r.rows;
}
export async function getTopSuppliersStats(): Promise<TopSuppliersStats> {
const r = await query<{ distinct: number; top_total: number; median_total: number }>(`
SELECT
count(*)::int AS distinct,
coalesce(max(total_awarded), 0)::numeric AS top_total,
coalesce(percentile_cont(0.5) WITHIN GROUP (ORDER BY total_awarded), 0)::numeric AS median_total
FROM seap.mv_top_suppliers
WHERE total_awarded IS NOT NULL AND total_awarded > 0
`);
return {
distinct_suppliers: Number(r.rows[0]?.distinct || 0),
top_supplier_total: Number(r.rows[0]?.top_total || 0),
median_total: Number(r.rows[0]?.median_total || 0),
};
}
// ─────────────────────────────────────────────────────────────
// 4. EU funded — summary, programs, top beneficiaries, top contracts
// ─────────────────────────────────────────────────────────────
export async function getEUFundedSummary(): Promise<EuFundedSummary> {
// No-alias version (used in queries against seap.announcements directly)
const baseWhere = `
publication_date >= now() - interval '12 months'
AND eu_funded ILIKE 'da%'
AND awarded_value IS NOT NULL
AND awarded_value > 0
AND type IN (${AWARDED_TYPES})
`;
// Aliased version (for queries that JOIN, where columns must be qualified as a.*)
const baseWhereA = `
a.publication_date >= now() - interval '12 months'
AND a.eu_funded ILIKE 'da%'
AND a.awarded_value IS NOT NULL
AND a.awarded_value > 0
AND a.type IN (${AWARDED_TYPES})
`;
const agg = await query<{
total: number; cnt: number; auths: number; supps: number;
}>(`
SELECT
coalesce(sum(awarded_value), 0)::numeric AS total,
count(*)::int AS cnt,
count(DISTINCT authority_cui)::int AS auths,
count(DISTINCT supplier_cui)::int AS supps
FROM seap.announcements
WHERE ${baseWhere}
`);
const programs = await query<{ program: string; contracts: number; total: number }>(`
SELECT
coalesce(NULLIF(trim(eu_program), ''), '(neprecizat)') AS program,
count(*)::int AS contracts,
coalesce(sum(awarded_value), 0)::numeric AS total
FROM seap.announcements
WHERE ${baseWhere}
GROUP BY 1
ORDER BY total DESC NULLS LAST
LIMIT 12
`);
const topAuth = await query<EuBeneficiaryRow>(`
SELECT
a.authority_cui AS cui,
MIN(a.authority_name) AS name,
MIN(cl.county) AS county,
count(*)::int AS contracts,
coalesce(sum(a.awarded_value), 0)::numeric AS total_awarded
FROM seap.announcements a
LEFT JOIN seap.cui_location cl ON cl.cui = a.authority_cui
WHERE ${baseWhereA}
AND a.authority_cui IS NOT NULL
GROUP BY a.authority_cui
ORDER BY total_awarded DESC NULLS LAST
LIMIT 10
`);
const topSupp = await query<EuBeneficiaryRow>(`
SELECT
a.supplier_cui AS cui,
MIN(a.supplier_name) AS name,
MIN(cl.county) AS county,
count(*)::int AS contracts,
coalesce(sum(a.awarded_value), 0)::numeric AS total_awarded
FROM seap.announcements a
LEFT JOIN seap.cui_location cl ON cl.cui = regexp_replace(a.supplier_cui, '^RO\\s*', '', 'i')
WHERE ${baseWhereA}
AND a.supplier_cui IS NOT NULL
GROUP BY a.supplier_cui
ORDER BY total_awarded DESC NULLS LAST
LIMIT 10
`);
return {
total_awarded: Number(agg.rows[0]?.total || 0),
contracts: Number(agg.rows[0]?.cnt || 0),
distinct_authorities: Number(agg.rows[0]?.auths || 0),
distinct_suppliers: Number(agg.rows[0]?.supps || 0),
programs: programs.rows.map(r => ({
program: r.program,
contracts: Number(r.contracts),
total_awarded: Number(r.total),
})),
top_authorities: topAuth.rows.map(r => ({ ...r, contracts: Number(r.contracts), total_awarded: Number(r.total_awarded) })),
top_suppliers: topSupp.rows.map(r => ({ ...r, contracts: Number(r.contracts), total_awarded: Number(r.total_awarded) })),
};
}
export async function getPrivateEuSummary(): Promise<PrivateEuSummary> {
// Aggregate stats in one round-trip for the strip.
const aggR = await query<{
anunturi: number; proiecte: number; firme: number; buget: number;
scraped: number; target: number;
}>(`
SELECT
(SELECT count(*)::int FROM fonduri.beneficiar_anunt) AS anunturi,
(SELECT count(DISTINCT smis_proiect_id)::int FROM fonduri.beneficiar_anunt
WHERE smis_proiect_id IS NOT NULL) AS proiecte,
(SELECT count(DISTINCT cui)::int FROM fonduri.beneficiar_anunt
WHERE cui IS NOT NULL) AS firme,
coalesce((SELECT sum(buget_lei) FROM fonduri.beneficiar_anunt_lot), 0)::numeric AS buget,
(SELECT count(*)::int FROM fonduri.beneficiar_proiect) AS scraped,
(SELECT count(DISTINCT smis_proiect_id)::int FROM fonduri.beneficiar_anunt
WHERE smis_proiect_id IS NOT NULL) AS target
`);
const a = aggR.rows[0];
const topFirme = await query<PrivateEuTopFirm>(`
SELECT
coalesce(b.cui, '') AS cui,
coalesce(e.name, b.beneficiar_name) AS name,
coalesce(e.adr_judet, b.beneficiar_judet) AS county,
count(*)::int AS anunturi,
coalesce(sum(l.buget_lei), 0)::numeric AS buget_total
FROM fonduri.beneficiar_anunt b
LEFT JOIN fonduri.beneficiar_anunt_lot l ON l.anunt_id = b.id
LEFT JOIN firms.entities e ON e.cui = b.cui
WHERE b.cui IS NOT NULL
GROUP BY b.cui, e.name, b.beneficiar_name, e.adr_judet, b.beneficiar_judet
ORDER BY buget_total DESC NULLS LAST
LIMIT 20
`);
const topProiecte = await query<PrivateEuTopProiect>(`
WITH anunt_buget AS (
SELECT b.id AS anunt_id,
b.smis_proiect_id,
coalesce(sum(l.buget_lei), 0)::numeric AS buget_anunt
FROM fonduri.beneficiar_anunt b
LEFT JOIN fonduri.beneficiar_anunt_lot l ON l.anunt_id = b.id
WHERE b.smis_proiect_id IS NOT NULL
GROUP BY b.id
),
sample_per_proiect AS (
SELECT DISTINCT ON (smis_proiect_id)
smis_proiect_id, anunt_id
FROM anunt_buget
ORDER BY smis_proiect_id, buget_anunt DESC NULLS LAST, anunt_id
),
agg AS (
SELECT
b.smis_proiect_id AS id,
min(b.smis_proiect_code) AS smis_code,
min(b.smis_proiect_name) AS titlu_anunt,
min(b.beneficiar_name) AS beneficiar_name,
min(b.cui) AS beneficiar_cui,
count(*)::int AS anunturi,
coalesce(sum(l.buget_lei), 0)::numeric AS buget_total
FROM fonduri.beneficiar_anunt b
LEFT JOIN fonduri.beneficiar_anunt_lot l ON l.anunt_id = b.id
WHERE b.smis_proiect_id IS NOT NULL
GROUP BY b.smis_proiect_id
)
SELECT
agg.id,
agg.smis_code,
coalesce(p.titlu, agg.titlu_anunt) AS titlu,
coalesce(p.beneficiar_name, agg.beneficiar_name) AS beneficiar_name,
agg.beneficiar_cui,
p.program_op_cod,
agg.anunturi,
agg.buget_total,
sp.anunt_id AS sample_anunt_id
FROM agg
LEFT JOIN fonduri.beneficiar_proiect p ON p.id = agg.id
LEFT JOIN sample_per_proiect sp ON sp.smis_proiect_id = agg.id
ORDER BY agg.buget_total DESC NULLS LAST
LIMIT 20
`);
const topJudete = await query<PrivateEuCountyRow>(`
SELECT
coalesce(NULLIF(trim(b.beneficiar_judet), ''), '(neprecizat)') AS judet,
count(*)::int AS anunturi,
coalesce(sum(l.buget_lei), 0)::numeric AS buget_total
FROM fonduri.beneficiar_anunt b
LEFT JOIN fonduri.beneficiar_anunt_lot l ON l.anunt_id = b.id
GROUP BY 1
ORDER BY buget_total DESC NULLS LAST
LIMIT 10
`);
const programs = await query<PrivateEuProgramRow>(`
SELECT
program_op_cod,
min(program_op_text) AS program_op_text,
count(*)::int AS proiecte
FROM fonduri.beneficiar_proiect
WHERE program_op_cod IS NOT NULL
GROUP BY 1
ORDER BY proiecte DESC NULLS LAST
LIMIT 12
`);
return {
anunturi_total: Number(a?.anunturi || 0),
proiecte_total: Number(a?.proiecte || 0),
firme_matched: Number(a?.firme || 0),
buget_total: Number(a?.buget || 0),
proiecte_scraped: Number(a?.scraped || 0),
proiecte_target: Number(a?.target || 0),
top_firme: topFirme.rows.map(r => ({
...r,
anunturi: Number(r.anunturi),
buget_total: Number(r.buget_total),
})),
top_proiecte: topProiecte.rows.map(r => ({
...r,
anunturi: Number(r.anunturi),
buget_total: Number(r.buget_total),
sample_anunt_id: r.sample_anunt_id == null ? null : Number(r.sample_anunt_id),
})),
top_judete: topJudete.rows.map(r => ({
...r,
anunturi: Number(r.anunturi),
buget_total: Number(r.buget_total),
})),
programs: programs.rows.map(r => ({
...r,
proiecte: Number(r.proiecte),
})),
};
}
export async function getTopEUContracts(limit = 100): Promise<DashboardContractRow[]> {
const r = await query<DashboardContractRow>(`
SELECT
a.id, a.ref_number, a.type, a.title,
a.authority_name, a.authority_cui,
cl.county AS authority_county,
a.supplier_name, a.supplier_cui,
a.cpv_division,
c.name_ro AS cpv_division_name,
c.emoji AS cpv_emoji,
a.publication_date, a.awarded_value, a.estimated_value,
a.procedure_type,
a.contract_has_lots AS has_lots, a.lots_count,
a.framework_agreement,
a.source, a.eu_funded, a.eu_program
FROM seap.announcements a
LEFT JOIN seap.cpv_codes c ON c.code = a.cpv_division
LEFT JOIN seap.cui_location cl ON cl.cui = a.authority_cui
WHERE a.publication_date >= now() - interval '12 months'
AND a.eu_funded ILIKE 'da%'
AND a.awarded_value IS NOT NULL
AND a.awarded_value > 0
AND a.type IN (${AWARDED_TYPES})
ORDER BY a.awarded_value DESC NULLS LAST
LIMIT $1
`, [limit]);
return r.rows;
}
+23
View File
@@ -0,0 +1,23 @@
import pg from 'pg';
const { Pool } = pg;
let pool: pg.Pool | null = null;
function getPool(): pg.Pool {
if (!pool) {
pool = new Pool({
connectionString: process.env.DATABASE_URL
|| import.meta.env.DATABASE_URL,
max: 10,
});
}
return pool;
}
export async function query<T extends pg.QueryResultRow = any>(
text: string,
params?: any[]
): Promise<pg.QueryResult<T>> {
return getPool().query<T>(text, params);
}
+728
View File
@@ -0,0 +1,728 @@
/**
* Investigation page configs — narrative-driven lead pages built on top of
* the cross-source CUI hub (firms.entities × seap × anaf × anre × regas ×
* aep × cnsc × fonduri × bugetar × curteacont).
*
* Each config describes a public-money lead worth surfacing. The dynamic
* page at /investigation/[slug] pulls live cross-source data
* from profile-queries helpers using the cui, then renders the static
* narrative blocks below alongside live numbers.
*/
export type InvestigationAngle =
| 'state-to-state' // state-owned firm with state-owned buyers + debt to state
| 'donor-vs-debt' // tiny donation, huge ANAF debt — moral hazard
| 'single-buyer' // dependency >50% on one authority
| 'quadruple-pipe' // rare combo: SEAP+EU+RegAS+AEP (+optional ANAF)
| 'license-vs-debt' // ANRE license active despite ANAF debt
| 'aging-warhorse'; // decades-old SOE with persistent debt + recent contracts
export interface InvestigationLead {
slug: string;
cui: string;
title: string; // displayed headline
subtitle: string; // one-line teaser shown on index + meta description
icon: string;
angle: InvestigationAngle;
headlineMetric: string; // big-number summary string for hero (e.g. "1,2 mld RON din STS")
headlineHint: string; // explanation underneath
// Narrative blocks rendered as HTML inside .paper sections.
// Use simple <p>/<ul>/<strong> tags; the styling comes from achizitii.css.
narrative: { heading?: string; html: string }[];
// Optional pre-baked key signals (text snippets); live counts overlay from queries
keySignals: string[];
// Suggested next-step recipe slugs to cross-link in the footer
relatedRecipes: string[];
// Optional warning banner shown above the live signals (e.g. snapshot freshness)
caveat?: string;
}
export const INVESTIGATIONS: InvestigationLead[] = [
// ────────────────────────────────────────────────────────────────────────
// T1 2026 LIVE — biggest debtors with active state contracts
// ────────────────────────────────────────────────────────────────────────
{
slug: 'italia-tobacco',
cui: '13081201',
title: 'ITALIA TOBACCO PRODUCTION — 4,43 miliarde RON datorie, în faliment',
subtitle: 'Cel mai mare datornic individual la statul român în T1 2026 — 4,43 mld RON cumulate la bugetele de stat. Firmă italiană de producție tutun, declarată oficial în procedură de faliment.',
icon: '🚬',
angle: 'aging-warhorse',
headlineMetric: '4,43 mld RON',
headlineHint: 'cea mai mare datorie individuală T1 2026 · status: Faliment',
caveat: 'Date live ANAF T1 2026 (publicate 31.03.2026, ingestate 12.05.2026). Refresh trimestrial via scraper auto.',
narrative: [
{
heading: 'Vârful listei datornicilor României',
html: `<p>În lista oficială ANAF publicată pe 31 martie 2026 (T1 2026), <strong>ITALIA TOBACCO PRODUCTION SRL</strong> (CUI 13081201, sediul în Ilfov) figurează ca <strong>datornic numărul 1 al statului român</strong>. Datoria totală cumulată: <strong>4,43 miliarde RON</strong> (~880 mil EUR la cursul mediu 2026).</p>
<p>Pe categorii bugetare cumulate:</p>
<ul>
<li>Bugetul de stat: ~905 mil RON</li>
<li>Bugetul asigurărilor sociale: ~573 mil RON</li>
<li>Total impozite + accize neachitate: 1,48 mld RON principal + 2,95 mld penalități cumulate</li>
</ul>
<p>Status oficial pe lista ANAF: <strong>"Faliment"</strong> — firma e în procedură de insolvență/lichidare.</p>`,
},
{
heading: 'Context: industria tutunului și accizele neplătite',
html: `<p>Producția de tutun e una dintre industriile cu cele mai mari accize în România. Firmele care eșuează în plată ajung rapid la datorii imense pentru că accizele cresc lunar pe fiecare lot produs sau importat. <strong>ITALIA TOBACCO PRODUCTION</strong> ilustrează scenariul clasic: producție continuă timp de ani, neachitarea sistematică a accizelor, datorie cumulată accelerat de penalități, în final faliment.</p>
<p>Pattern important: spre deosebire de alți mari datornici (CFR MARFA, URBAN SA etc.), ITALIA TOBACCO NU are contracte SEAP active — nu vinde nimic statului. Deci nu există anomalia "stat plătește datornic". E pur și simplu o firmă care a falimentat lăsând 4,4 mld RON neachitate.</p>`,
},
{
heading: 'De ce contează pentru context jurnalistic',
html: `<p>Aceasta firmă singură reprezintă <strong>~2,5% din valoarea totală a datoriilor T1 2026</strong> (174,4 mld RON pe 47K datornici). Un singur eșec al unei firme poate genera datorii la stat de ordinul miliardelor — context util pentru a înțelege scala efectului asupra colectării fiscale.</p>
<p>Comparație: B&B BUSINESS (281,8M datorie în T1 2016) era de 16× mai mic decât această firmă, dar a captat atenția mediatică ca exemplu de "donator politic devine datornic". ITALIA TOBACCO nu are nicio urmă în AEP/RegAS — e datornic pur fiscal, fără leverage politic.</p>`,
},
],
keySignals: [
'4,43 mld RON datorie T1 2026 — cel mai mare datornic individual',
'Status oficial: Faliment',
'Sediu social: Ilfov (sat Berceni)',
'Industrie: producție tutun — accize/TVA neachitate sistematic',
'0 contracte SEAP, 0 donații AEP, 0 alte cross-source — datornic pur fiscal',
],
relatedRecipes: [
'firme-datornice-cu-contracte-seap',
'donatori-politici-care-datoreaza-statului',
],
},
// ────────────────────────────────────────────────────────────────────────
{
slug: 'cfr-marfa',
cui: '11054537',
title: 'CFR MARFA — 1,4 mld RON datorie + 418 mil contracte de la alte companii de stat',
subtitle: 'Societatea Națională de Transport Feroviar de Marfă (companie de stat) acumulează 1,4 mld RON datorie la ANAF în T1 2026, dar continuă să primească 418 mil RON contracte de la Complex Energetic Oltenia, CET Govora, Coltherm, MApN — circuit stat-stat clasic.',
icon: '🚂',
angle: 'state-to-state',
headlineMetric: '1,40 mld RON datorie',
headlineHint: '+ 418,4 mil RON SEAP (21 contracte) de la 11 cumpărători, toți publici',
caveat: 'Date live ANAF T1 2026 (publicate 31.03.2026). Companie strategică, controlată de stat — proceduri executare silită limitate prin statut special.',
narrative: [
{
heading: 'Circuit închis: stat datorează stat, plătit de stat',
html: `<p><strong>CFR MARFA SA</strong> — societate națională de transport feroviar de marfă, controlată integral de Ministerul Transporturilor — apare în T1 2026 cu <strong>1,40 mld RON datorie</strong> la bugetul consolidat. Simultan, încasează <strong>418,4 mil RON din 21 contracte SEAP</strong>, exclusiv de la alte entități publice:</p>
<ul>
<li><strong>Complexul Energetic Oltenia</strong> (companie de stat) — 5 contracte, 215,2 mil RON</li>
<li><strong>CET GOVORA SA</strong> (companie locală, control public) — 1 contract, 132,5 mil RON</li>
<li><strong>COLTERM SA Timișoara</strong> (companie locală) — 2 contracte, 28,8 mil RON</li>
<li>Municipiul Iași — 1 contract, 19,4 mil RON</li>
<li>Ministerul Apărării (UM 02574) — 2 contracte, 16,6 mil RON</li>
</ul>
<p>Pattern: transport feroviar pentru cărbune (Oltenia → CET-uri pentru producție energie) plus transport militar. <strong>Toți cumpărătorii sunt entități publice</strong>, deci practic statul plătește statul — în timp ce statul-receptor (CFR MARFA) datorează statului fiscal 1,4 miliarde.</p>`,
},
{
heading: 'Mecanismul "companie strategică"',
html: `<p>Ca și AVIOANE CRAIOVA (companie de stat aeronautică), CFR MARFA beneficiază de statutul de "companie de interes strategic" — protecție împotriva execuției silite a creanțelor fiscale, mai ales pe capacitățile critice (transport feroviar marfă strategic pentru aprovizionarea CET-urilor și transportul militar).</p>
<p>Întrebarea jurnalistică: <em>pe ce mecanism legal continuă o companie de stat să fie ofertant calificat la achiziții publice (Legea 98/2016 art. 165 exclude ofertanții cu obligații fiscale restante exigibile), când datoria ei e public consemnată de ANAF?</em></p>
<p>Răspunsul tipic: certificat fiscal "datorie nu e exigibilă" (eșalonată, contestată, sau suspendată juridic). Practica curentă confirmă că ANAF nu blochează automat participarea la SEAP — doar refuză anumite forme de plată directă.</p>`,
},
{
heading: 'Restructurări istorice eșuate',
html: `<p>CFR MARFA a trecut prin mai multe încercări de privatizare/restructurare în ultimii 15 ani — niciuna finalizată. Datoria la stat a crescut continuu. Conform datelor noastre cumulate cu T1 2016 snapshot (când nu era pe lista), între 2016 și 2026 firma a alunecat dintr-o poziție stabilă în datornic major.</p>
<p>Compania mai e cunoscută pentru: 2 contestații depuse la CNSC (împotriva altor autorități contractante), zero apariții în registrul donațiilor politice (AEP), zero ajutoare de stat formal raportate (RegAS).</p>`,
},
],
keySignals: [
'1,40 mld RON datorie ANAF T1 2026',
'418,4 mil RON din 21 contracte SEAP, toți cumpărători publici',
'Top buyer: Complexul Energetic Oltenia — 215,2 mil RON pentru transport cărbune',
'Statut: companie de stat strategică (Min. Transporturilor)',
'2 contestații CNSC depuse (ca contestator)',
],
relatedRecipes: [
'firme-datornice-cu-contracte-seap',
'stat-actionar-seap',
'energie-licentiati-anre-datornici-anaf',
],
},
// ────────────────────────────────────────────────────────────────────────
{
slug: 'gsp-offshore',
cui: '36554023',
title: 'GSP OFFSHORE — 2 mld RON datorie + 273 mil dintr-un singur contract OMV PETROM',
subtitle: 'Operator petrolier offshore din Constanța — al doilea cel mai mare datornic individual al statului român în T1 2026 (2,01 mld RON), cu un singur contract uriaș de 273,6 mil RON încasat de la OMV Petrom.',
icon: '🛢️',
angle: 'single-buyer',
headlineMetric: '2,01 mld RON datorie',
headlineHint: '+ 273,6 mil RON dintr-un singur contract OMV Petrom · 1 ajutor de stat RegAS',
caveat: 'Date live ANAF T1 2026. GSP = Grup Servicii Petroliere; OMV Petrom e companie privată listată BVB (capital austriac + statul român minoritar prin Fondul Proprietatea).',
narrative: [
{
heading: 'Al doilea mare datornic, un singur client',
html: `<p><strong>GSP OFFSHORE SRL</strong> (CUI 36554023, Constanța) — parte din grupul GSP (Grup Servicii Petroliere, deținut de Iulian Sorin Ovidiu) — este în T1 2026 al doilea cel mai mare datornic individual al statului român cu <strong>2,01 mld RON datorie</strong>. Compania prestează servicii offshore: foraj, montare/demontare platforme, suport echipaje în Marea Neagră.</p>
<p>Singurul contract pe SEAP: <strong>273,6 mil RON de la OMV PETROM SA</strong>. Practic toată cifra de afaceri din achiziții publice (vizibilă în SEAP) vine din acest unic contract.</p>`,
},
{
heading: 'OMV Petrom — cumpărător sui generis',
html: `<p>OMV Petrom nu e companie de stat clasică — e o societate privată listată la BVB, control majoritar austriac (OMV AG ~51%), statul român fiind acționar minoritar prin Fondul Proprietatea (~30%) și investitorii instituționali. Contractele OMV Petrom NU sunt achiziții publice clasice (Legea 98/2016 nu se aplică), ci proceduri private de achiziții cu publicare voluntară în SEAP pentru transparență sectorială.</p>
<p>Deci, deși vedem 273M "în SEAP", aceasta nu reprezintă bani publici direct — ci o procedură comercială privată. Faptul că OMV Petrom e singurul cumpărător vizibil pentru GSP OFFSHORE sugerează relație comercială long-term integrată (foraj cooperativ pe perimetrele de gaz Neptun Deep, posibil).</p>`,
},
{
heading: 'Cum se cumulează 2 mld RON datorie',
html: `<p>Industria petrolieră offshore are costuri operaționale enorme: chirie platformă, salarii echipaje (off-shore allowance), consumabile, contribuții sociale pe personal expat. Marja de profit e volatilă (dependent de prețul petrol). 2 mld RON datorie poate proveni din:</p>
<ul>
<li>TVA neachitată pe achiziții/import (nedeductibilă dacă proiectele sunt amânate)</li>
<li>Contribuții sociale pe echipaje internaționale (off-shore allowance interpretat fiscal restrictiv)</li>
<li>Impozit pe profit pe profituri raportate apoi recalcificate</li>
</ul>
<p>Plus 1 ajutor de stat raportat în RegAS — sumă mică, dar contextualizează relația cu autoritățile de reglementare.</p>`,
},
],
keySignals: [
'2,01 mld RON datorie ANAF T1 2026 — locul 2 individual',
'273,6 mil RON dintr-un singur contract — OMV Petrom',
'1 ajutor de stat raportat în RegAS',
'Sediu social: Constanța (offshore Marea Neagră)',
'Single-buyer dependency: 100% din SEAP de la OMV Petrom',
],
relatedRecipes: [
'firme-datornice-cu-contracte-seap',
'firme-cu-ajutor-de-stat-si-seap',
],
},
// ────────────────────────────────────────────────────────────────────────
{
slug: 'urban-sa',
cui: '7203606',
title: 'URBAN SA — 1,93 mld RON datorie + zeci de contracte mici pe primării rurale',
subtitle: 'Firmă din Vâlcea cu datorie de aproape 2 mld RON la ANAF în T1 2026, simultan câștigătoare a 67 contracte SEAP totalizând 11,5 mil RON — distribuit pe 41 cumpărători publici, majoritar comune rurale și UAT-uri mici.',
icon: '🏗️',
angle: 'aging-warhorse',
headlineMetric: '1,93 mld RON datorie',
headlineHint: 'vs 11,5 mil RON SEAP cumulat pe 67 contracte · 41 buyers',
caveat: 'Date live ANAF T1 2026. Diluție extremă SEAP: 41 buyers diferiți pentru sub 12 mil RON cumulat.',
narrative: [
{
heading: 'Profilul "long tail" — multe contracte mici, datorie imensă',
html: `<p><strong>URBAN SA</strong> (CUI 7203606, sediul în Vâlcea) figurează în T1 2026 cu <strong>1,93 mld RON datorie</strong> la statul român. Spre deosebire de ITALIA TOBACCO (faliment, fără contracte) sau GSP OFFSHORE (un singur mega-contract), URBAN are <strong>67 anunțuri pe SEAP totalizând doar 11,5 mil RON</strong> — distribuite pe <strong>41 cumpărători diferiți</strong>.</p>
<p>Top 5 buyers (toți publici, toți mici):</p>
<ul>
<li>Universitatea Politehnica București — 2 contracte, 3,6 mil RON</li>
<li>Comuna Costești — 2,4 mil RON</li>
<li>Comuna Cernișoara — 1,3 mil RON</li>
<li>Comuna Oteșani — 0,3 mil RON</li>
<li>Comuna Mateești — 0,3 mil RON</li>
</ul>`,
},
{
heading: 'De ce datoria e disproporționată față de SEAP',
html: `<p>O firmă cu 11,5 mil RON facturare prin SEAP în câțiva ani nu poate "acumula" 1,93 mld RON datorie doar din această activitate. Datoria reflectă probabil:</p>
<ul>
<li>Activitate masivă în sectorul privat (B2B) nevizibilă în SEAP — neachitate TVA și impozit pe profit</li>
<li>Penalități cumulate pe ani — la rate efective 0,02%/zi, datorie veche se dublează în ~10 ani</li>
<li>Datorii preluate prin fuziuni/divizări (URBAN SA poate fi rezultatul unei reorganizări anterioare)</li>
</ul>
<p>Întrebarea jurnalistică: <em>care e activitatea reală a firmei dincolo de cele 67 contracte SEAP? Cifra de afaceri publicată la Min. Finanțelor poate clarifica (vezi firms.financials timeline pe profil)</em>.</p>`,
},
{
heading: 'Pattern: long-tail public buyer, primării rurale',
html: `<p>Concentrarea pe primării rurale și UAT-uri mici (Costești, Cernișoara, Oteșani, Mateești etc., toate sub 5K locuitori) sugerează specializare în lucrări de infrastructură locală sub plafon (sub 270K RON produs, sub 900K RON lucrări — fragmentate ca să rămână în "achiziție directă", evitând procedura competitivă mai strictă).</p>
<p>Cu 67 contracte distribuite pe 41 buyers (medie 1,6 contracte/buyer), pattern-ul de "fragmentare" — împărțirea unei lucrări mai mari în mai multe achiziții directe sub plafon — e ipoteza care merită investigată per municipalitate.</p>`,
},
],
keySignals: [
'1,93 mld RON datorie ANAF T1 2026 — locul 3 individual',
'67 contracte SEAP · 11,5 mil RON cumulat (raport datorie/SEAP = 168:1)',
'41 cumpărători distincți — diluție extremă',
'Top buyers: Universitatea Politehnica + comune rurale Vâlcea',
'Sediu social: Vâlcea',
],
relatedRecipes: [
'firme-datornice-cu-contracte-seap',
],
},
// ────────────────────────────────────────────────────────────────────────
{
slug: 'complex-energetic-hunedoara',
cui: '30855230',
title: 'COMPLEX ENERGETIC HUNEDOARA — 1,23 mld RON datorie + 7 licențe ANRE active + 617 achiziții lansate',
subtitle: 'Companie de stat din Hunedoara (mine de lignit + termocentrale), 1,23 mld RON datorie ANAF în T1 2026, 7 licențe ANRE active (producere energie + furnizare), și 617 achiziții publice lansate ca autoritate contractantă în ultimii ani.',
icon: '⛏️',
angle: 'license-vs-debt',
headlineMetric: '1,23 mld RON',
headlineHint: 'datorie ANAF · 7 licențe ANRE active · 617 anunțuri lansate ca autoritate · 17 contestații CNSC primite',
caveat: 'Date live ANAF T1 2026 + ANRE/CNSC/CDC live. Companie de stat în restructurare graduală — minele de lignit Valea Jiului sunt parte din pact verde EU.',
narrative: [
{
heading: 'Companie de stat în datorie + active reglementate',
html: `<p><strong>SOCIETATEA COMPLEXUL ENERGETIC HUNEDOARA SA</strong> (CUI 30855230) — companie de stat care opera minele de lignit din Valea Jiului + termocentralele Mintia și Paroșeni — figurează în T1 2026 cu <strong>1,23 mld RON datorie</strong> la ANAF. Simultan deține <strong>7 licențe ANRE active</strong>:</p>
<ul>
<li>Producere energie electrică</li>
<li>Furnizare energie electrică</li>
<li>Atestate operare instalații</li>
</ul>
<p>Paradoxul reglementar: conform OUG 33/2007, ANRE poate revoca licența când titularul devine "incapabil să-și onoreze obligațiile financiare". Compania e în lista publică ANAF datornici, dar licențele rămân active. Aceleași pattern documentat și pe HIDROELECTRICA (T1 2016) și ROMGAZ.</p>`,
},
{
heading: 'Achiziții publice intense ca autoritate contractantă',
html: `<p>Spre deosebire de CFR MARFA sau URBAN SA (datornici care VÂND la stat), Complex Energetic Hunedoara e MAJOR CUMPĂRĂTOR pe SEAP: <strong>617 anunțuri lansate</strong> ca autoritate contractantă (achiziții de cărbune, piese de schimb, mentenanță, transport, servicii). Doar 3 contracte vizibile ca furnizor (0,4 mil RON marginal).</p>
<p>Volumul mare de achiziții lansate a atras contestații: <strong>17 contestații CNSC primite</strong> împotriva procedurilor companiei. În paralel, <strong>1 raport Curtea de Conturi</strong> audit pe partea de management financiar.</p>`,
},
{
heading: 'Tranziție energetică și endgame',
html: `<p>Complexul Energetic Hunedoara e în proces gradual de închidere — pactul verde EU cere eliminarea cărbunelui până 2030-2032. Termocentrala Mintia a fost închisă tehnic în 2021; Paroșeni operează intermitent. Datoria de 1,23 mld RON reflectă probabil <strong>obligațiile sociale (salarii, indemnizații lichidare miniere) plus penalități cumulate</strong>.</p>
<p>Întrebarea de bani publici: <em>statul român finanțează închiderea minelor (subvenții directe + IT (just transition) EU pentru Valea Jiului), dar compania păstrează datoria de 1,23 mld la ANAF. Banii pentru restructurare vin de la stat, statul rămâne creditor neachitat al firmei pe care o închide. Cum se va stinge final datoria?</em></p>
<p>Răspunsul probabil: anulare prin lege specială la lichidare finală, ca în cazul Termoelectrica/Electrocentrale 2012-2014.</p>`,
},
],
keySignals: [
'1,23 mld RON datorie ANAF T1 2026',
'7 licențe ANRE active (energie+furnizare+atestate) — paradox reglementar',
'617 achiziții publice lansate ca autoritate contractantă',
'17 contestații CNSC primite',
'1 raport Curtea de Conturi (audit financiar)',
'În proces de închidere (pact verde EU, just transition Valea Jiului)',
],
relatedRecipes: [
'energie-licentiati-anre-datornici-anaf',
'firme-datornice-cu-contracte-seap',
'stat-actionar-seap',
'autoritati-contestate-cnsc',
],
},
// ────────────────────────────────────────────────────────────────────────
{
slug: 'romgaz',
cui: '14056826',
title: 'ROMGAZ — 35 licențe ANRE, 18,9 mil RON datorie, vinde altor instituții de stat',
subtitle: 'Producătorul național de gaze naturale apare simultan pe lista datornicilor ANAF (T1 2016), ca titular a 35 licențe ANRE (gaze + furnizare + transport) și ca furnizor pentru proprii subsidiari și alte autorități contractante.',
icon: '🔥',
angle: 'state-to-state',
headlineMetric: '35 licențe',
headlineHint: '+ 18,9 mil RON datorie ANAF · 52 contestații CNSC primite ca autoritate',
caveat: '✅ ROMGAZ a achitat datoria de 18,9 mil RON până în T1 2026 — NU mai apare pe lista ANAF curentă. 35 licențe ANRE rămân active. Datele SEAP + ANRE sunt live.',
narrative: [
{
heading: 'Cea mai diversă portfolio energetică din baza ANRE',
html: `<p>SNGN ROMGAZ SA — societatea națională de gaze naturale — deține o portfolio de <strong>35 licențe ANRE</strong> distribuite pe 6 subtipuri distincte:</p>
<ul>
<li>16 conducte alimentare amonte aferente producției de gaze</li>
<li>11 sisteme de distribuție gaze naturale</li>
<li>3 producere</li>
<li>2 furnizare + 1 "furnizare gaze naturale" (variante de înregistrare)</li>
<li>2 atestate "Tarif B"</li>
</ul>
<p>Din 35 licențe, <strong>31 sunt în stare "Acordat(a)"</strong> (active), 4 expirate. E unul din puținii titulari ANRE cu prezență simultană în toate cele 3 verticale ale lanțului de gaze (producție + transport + furnizare) — poziție monopolistică reglementată.</p>`,
},
{
heading: 'Datoria — paradoxul instituției naționale',
html: `<p>În snapshot-ul ANAF T1 2016, ROMGAZ apare ca <strong>datornic mic</strong> cu <strong>18,9 mil RON</strong> datorie totală. La fel ca HIDROELECTRICA, e una dintre companii naționale aflate simultan pe lista datornicilor și pe lista titularilor de licență activă — o stare juridică ce ar trebui să declanșeze proceduri de revocare conform OUG 33/2007, dar nu o face când titularul e companie strategică controlată de stat.</p>`,
},
{
heading: 'Cumpărători: proprii subsidiari + autorități centrale',
html: `<p>Ca furnizor pe SEAP, ROMGAZ vinde aproape exclusiv altor entități publice. Top buyers:</p>
<ul>
<li><strong>ROMGAZ-DEPOGAZ Ploiești</strong> (filiala proprie) — 48,2 mil RON pentru servicii energie</li>
<li><strong>TRANSGAZ Mediaș</strong> (transport gaze, companie de stat soră) — 3 contracte, 23,5 mil RON</li>
<li>Curtea de Conturi a României — 4,2 mil RON</li>
<li>Direcția Generală de Protecție Internă (MAI) — 2 contracte, 1,2 mil RON</li>
<li>Ministerul Afacerilor Interne — 0,5 mil RON</li>
</ul>
<p>Pattern-ul este același cu HIDROELECTRICA: stat-actionar vinde altor instituții de stat, în timp ce însuși apare ca datornic la stat. Bani publici care circulă într-un cerc închis.</p>`,
},
{
heading: 'Statutul de autoritate contestată',
html: `<p>În paralel cu rolul de furnizor, ROMGAZ e și <strong>autoritate contractantă</strong> care propriile sale licitații sunt contestate. Total: <strong>52 contestații CNSC</strong> primite — un volum mare comparat cu mărimea procedurilor lansate de companie. Decision_type-ul nu e încă extras (Stage 2 PDF parse planificat).</p>`,
},
],
keySignals: [
'35 licențe ANRE (31 active) — singura firmă cu prezență în toate 3 verticale gaz',
'18,9 mil RON datorie ANAF (T1 2016)',
'52 contestații CNSC primite ca autoritate',
'Top buyer: ROMGAZ-DEPOGAZ (propria subsidiară) — 48,2 mil RON',
'Cumpărători exclusiv din sectorul public/state-owned',
],
relatedRecipes: [
'energie-licentiati-anre-datornici-anaf',
'stat-actionar-seap',
'firme-datornice-cu-contracte-seap',
],
},
// ────────────────────────────────────────────────────────────────────────
{
slug: 'autoprima-serv',
cui: '11394440',
title: 'AUTOPRIMA SERV — 1,21 mld RON, 100K donați PNL, 37 contestații depuse',
subtitle: 'Firmă din Constanța cu 52 contracte SEAP totalizând 1,21 miliarde RON (apa-canalul Constanței + CNAIR + Tulcea), în paralel cu 100.000 RON donați PNL și 37 contestații depuse împotriva altor autorități.',
icon: '🚧',
angle: 'quadruple-pipe',
headlineMetric: '1,21 mld RON',
headlineHint: '52 contracte · top buyer: RAJA SA Constanța (apa-canal) cu 389 mil RON',
narrative: [
{
heading: 'Trei surse de venit major',
html: `<p>AUTOPRIMA SERV SRL — firmă din Constanța — cumulează <strong>1,21 mld RON</strong> în contracte SEAP, distribuite pe 52 anunțuri de tip "contract" (acord-cadru sau singular). Top 5 cumpărători concentrează 90%+ din valoarea totală:</p>
<ul>
<li><strong>RAJA SA Constanța</strong> (compania de apă-canal regională) — 6 contracte, <strong>389,9 mil RON</strong></li>
<li><strong>CNAIR</strong> (rețea de drumuri naționale) — 12 contracte, 309,9 mil RON</li>
<li><strong>Municipiul Tulcea</strong> — 10 contracte, 166,8 mil RON</li>
<li><strong>Apavital SA Iași</strong> (apa-canal Iași) — 1 contract, 155,8 mil RON</li>
<li>SC RAJA SA Constanța (variantă de denumire) — 50,0 mil RON</li>
</ul>
<p>Pattern: specializare pe lucrări/utilități pentru regii autonome de apă-canal + infrastructură rutieră.</p>`,
},
{
heading: 'Donație politică + acțiune CNSC',
html: `<p>În paralel cu activitatea SEAP, AUTOPRIMA apare în:</p>
<ul>
<li>🗳️ <strong>AEP donații_pj</strong> — 100.000 RON donați PNL (date publice)</li>
<li>⚖️ <strong>CNSC ca contestator</strong> — <strong>37 contestații depuse</strong> împotriva altor autorități contractante (a doua cea mai activă firmă din baza noastră, după SHERIFF GUARD cu 62)</li>
</ul>
<p>Combinația 1,21 mld RON câștigate + 37 contestații împotriva concurenței + 100K cover politic = pattern intens de competiție pe piața achizițiilor publice de utilități. Întrebarea jurnalistică: <em>raportul win/loss între câștiguri SEAP și contestații depuse</em>, plus <em>ce % din contestații au fost admise</em>.</p>`,
},
{
heading: 'Sectorul utilităților are pattern propriu',
html: `<p>Piața apei-canalizării din România e foarte concentrată: ~40 operatori regionali (RAJA Constanța, Apavital Iași, Apa Nova București, etc.), fiecare cu monopol geografic și buget de zeci-sute milioane. Câștigarea sistematică a contractelor de la mai multe regii apa-canal (RAJA + Apavital) indică o firmă care navighează eficient pieții reglementate — dar combinația cu donații politice și contestații intense ridică întrebări despre <em>mecanismele competiționale folosite efectiv</em>.</p>`,
},
],
keySignals: [
'52 contracte SEAP · 1,21 mld RON cumulativ',
'Top buyer: RAJA SA Constanța — 389,9 mil RON (apa-canal regional)',
'100K RON donați PNL (1 sau mai multe donații)',
'37 contestații CNSC depuse — locul 2 ca contestator activ',
'Activitate concentrată pe regii apă-canal + CNAIR (drumuri naționale)',
],
relatedRecipes: [
'donatori-politici-care-contesta-la-cnsc',
'donatori-care-au-castigat-seap',
'firme-quadra-pipe-public',
],
},
// ────────────────────────────────────────────────────────────────────────
{
slug: 'victor-construct',
cui: '4013062',
title: 'VICTOR CONSTRUCT (Botoșani) — 670K donat PSD/PNL, 23 contestații, 70 mil RON CNI/ANL',
subtitle: 'Firmă de construcții din Botoșani cu cel mai mare cumul de donații politice din recipe-ul donatori-contestatori (670.800 RON pe 20 donații), 23 contestații CNSC depuse, 9 contracte SEAP totalizând 70 mil RON la Compania Națională de Investiții și ANL.',
icon: '🏛️',
angle: 'quadruple-pipe',
headlineMetric: '670.800 RON donați',
headlineHint: '+ 23 contestații CNSC + 70 mil RON CNI/ANL',
narrative: [
{
heading: 'Top donator în donatori-contestatori',
html: `<p>VICTOR CONSTRUCT SRL (CUI 4013062, sediul Botoșani) acumulează <strong>670.800 RON</strong> în donații politice publice în registrul AEP donații_pj, distribuite pe <strong>20 donații</strong> (18 mari + 2 mai mici sub denumiri variante). Cel mai mare cumul din recipe-ul nostru <a href="/retete/donatori-politici-care-contesta-la-cnsc" class="text-stamp">/retete/donatori-politici-care-contesta-la-cnsc</a> (185 firme totale).</p>
<p>Atenție la coincidența de nume: există 12 firme distincte cu numele "VICTOR CONSTRUCT SRL" în diverse județe — pentru claritate, această pagină se referă la CUI <strong>4013062</strong> specifically.</p>`,
},
{
heading: 'CNSC ca instrument operațional',
html: `<p>În paralel cu donațiile, firma a depus <strong>23 contestații</strong> împotriva altor autorități contractante. Combinația 670K donații + 23 contestații (= ~29.000 RON donat per contestație) e moderată comparativ cu SHERIFF GUARD (435 RON per contestație) sau AUTOPRIMA (2.700 per), dar foarte concentrată pe un domeniu specific: construcții publice.</p>`,
},
{
heading: 'Cumpărători: agenții naționale de investiții',
html: `<p>Cele 9 contracte SEAP câștigate de firmă (70 mil RON cumulat) vin majoritar de la <strong>2 agenții publice naționale</strong>:</p>
<ul>
<li><strong>CNI</strong> (Compania Națională de Investiții) — 3 contracte, 44,2 mil RON</li>
<li><strong>ANL</strong> (Agenția Națională pentru Locuințe) — 4 contracte, 20,9 mil RON</li>
<li>Spital Clinic Județean Pius Brînzeu (Timișoara) — 1 contract, 4,4 mil RON</li>
<li>Spitalul Municipal Sf. Cosma și Damian Rădăuți — 1 contract, 0,4 mil RON</li>
</ul>
<p>CNI și ANL sunt principalii vehicule prin care statul român construiește locuințe sociale, săli polivalente, baze sportive — un canal cu vizibilitate redusă în presă comparat cu CNAIR/CNADNR. Combinația donator-politic + CNSC-contestator + concentrare pe CNI/ANL e un profil care merită monitorizare longitudinală.</p>`,
},
],
keySignals: [
'670.800 RON cumul donații politice (20 donații)',
'23 contestații CNSC depuse',
'9 contracte SEAP · 70 mil RON cumulat',
'Top buyer: Compania Națională de Investiții (CNI) — 44,2 mil RON',
'Specializare construcții publice: locuințe ANL + obiective CNI',
],
relatedRecipes: [
'donatori-politici-care-contesta-la-cnsc',
'donatori-politici-care-datoreaza-statului',
'donatori-care-au-castigat-seap',
],
},
// ────────────────────────────────────────────────────────────────────────
{
slug: 'metaminds',
cui: '34770594',
title: 'METAMINDS S.A. — 1,2 miliarde RON dintr-un singur cumpărător',
subtitle: 'Firmă din 2015 care a câștigat 21 contracte de la Serviciul de Telecomunicații Speciale în 8 ani, culminând cu acord-cadru de 835 mil RON în februarie 2026.',
icon: '🛰️',
angle: 'single-buyer',
headlineMetric: '1,21 mld RON',
headlineHint: '21 contracte cu Serviciul de Telecomunicații Speciale (STS), 2018-2026',
narrative: [
{
heading: 'O singură destinație, 8 ani',
html: `<p>METAMINDS S.A. — fondată în <strong>iulie 2015</strong> — apare în SEAP ca furnizor cu o concentrare practic perfectă pe un singur cumpărător: <strong>Serviciul de Telecomunicații Speciale (STS)</strong>. Din toate cele 21 contracte raportate până azi, valoarea cumulată e <strong>1,21 mld RON</strong>, cel mai recent fiind acord-cadrul din 11 februarie 2026 pentru "soluții și produse TIC Cloud intern" în valoare de <strong>835 mil RON</strong>.</p>`,
},
{
heading: 'De ce contează',
html: `<p>Dependența ridicată pe un singur cumpărător la o firmă tânără (10 ani de când există, 8 ani de când livrează la STS) ridică întrebări structurale:</p>
<ul>
<li>Cum a fost calificată la primul contract STS în 2018?</li>
<li>Care e capacitatea reală a firmei vs. valorile cumulate (1,2 mld RON cu un număr scăzut de angajați raportați la Min. Finanțelor)?</li>
<li>Există subcontractanți reali sau funcționează ca intermediar?</li>
</ul>
<p>Mecanismul tehnic e acord-cadru — permis legal (Legea 98/2016 art. 110) — dar repetarea cu același furnizor pe 8 ani consecutivi consolidează un canal unic de aprovizionare pentru infrastructura critică a statului român.</p>`,
},
{
heading: 'Cum a accesat și ajutorul de stat',
html: `<p>În paralel cu contractele STS, METAMINDS a primit <strong>7 ajutoare de stat</strong> de la Min. Finanțelor (schemele IMM INVEST PLUS și COVID), prin garanții și subvenții la dobândă. Asocierea unui furnizor major al statului cu finanțare publică prin scheme IMM e legală, dar relevantă pentru auditul total al banilor publici care converg către o singură entitate.</p>`,
},
],
keySignals: [
'Concentrare maximă: 1 cumpărător = ~99% din venit SEAP',
'Acord-cadru 835 mil RON cu STS (feb 2026) — cel mai mare din ultimii ani',
'21 contracte STS între 2018-2026',
'7 ajutoare de stat (RegAS) — scheme IMM INVEST PLUS, GARANT CONSTRUCT, COVID',
],
relatedRecipes: [
'donatori-politici-care-contesta-la-cnsc',
'firme-cu-ajutor-de-stat-si-seap',
'firme-cu-fonduri-eu-si-seap',
],
},
// ────────────────────────────────────────────────────────────────────────
{
slug: 'hidroelectrica',
cui: '13267213',
title: 'HIDROELECTRICA — datornic ANAF de 214 mil RON cu 19 licențe ANRE',
subtitle: 'Cel mai mare producător de energie hidro din România apare simultan pe lista datornicilor ANAF (snapshot 2016) și pe lista titularilor de licență ANRE activă, vânzând altor entități de stat 315 mil RON din 71 anunțuri.',
icon: '💧',
angle: 'state-to-state',
headlineMetric: '36 cumpărători de stat',
headlineHint: 'ROMGAZ · METROREX · RATB · Camera Deputaților · armată · administrații locale',
caveat: '✅ HIDROELECTRICA a achitat datoria de 214,4 mil RON până în T1 2026 — NU mai apare pe lista ANAF curentă. Snapshot T1 2016 e păstrat pentru context istoric. SEAP și ANRE sunt date live.',
narrative: [
{
heading: 'Energia care se vinde între instituții',
html: `<p>HIDROELECTRICA — companie de stat, listată la BVB din 2023 — apare în datele publice ca furnizor de energie pentru <strong>36 entități distincte</strong>, toate fiind alte instituții publice sau companii de stat. Top cumpărători: <strong>SNGN ROMGAZ (79 mil RON)</strong>, <strong>METROREX (50 mil RON)</strong>, <strong>RATB (34 mil RON)</strong>, unități militare, Camera Deputaților (26 mil RON), companii de transport public.</p>
<p>Total valoare contracte SEAP ca furnizor: <strong>~315 mil RON</strong> (cumulat 2008-2026, toate tipurile de anunțuri).</p>`,
},
{
heading: 'Lista datornicilor și paradoxul licenței',
html: `<p>În snapshot-ul oficial ANAF publicat pentru <strong>T1 2016</strong> (datele cele mai recente disponibile pe data.gov.ro), HIDROELECTRICA apare ca <strong>datornic mic</strong> cu o datorie totală de <strong>214 mil RON</strong>. Conform OUG 33/2007, ANRE poate revoca licența de producere a energiei electrice când titularul devine "incapabil de a-și onora obligațiile". HIDROELECTRICA are azi <strong>19 licențe ANRE active</strong> pe electricitate.</p>
<p>Atenție la freshness: ANAF nu publică datele post-2016 ca dataset deschis. Snapshot-ul e <em>cel mai recent</em> set public dar e vechi de 10 ani. Datele SEAP și ANRE sunt în schimb live.</p>`,
},
{
heading: 'Pattern general: stat datorează stat',
html: `<p>Schema clasică de "stat-actionar-seap" pe care o documentăm pe <a href="/retete/energie-licentiati-anre-datornici-anaf" class="text-stamp">/retete/energie-licentiati-anre-datornici-anaf</a> are 875 firme cu profil similar — operatori energie care apar simultan pe lista datornicilor. Datoria cumulată: 3,14 mld RON. HIDROELECTRICA + ROMGAZ singure reprezintă ~233 mil RON din total — companii de stat care datorează statului bani, în timp ce statul însuși plătește pentru serviciile lor.</p>`,
},
],
keySignals: [
'Furnizor către 36 entități publice / state-owned',
'19 licențe ANRE electricitate active',
'214 mil RON datorie pe lista publică ANAF (T1 2016)',
'Top cumpărător: ROMGAZ-DEPOGAZ (79 mil RON pentru servicii energie)',
],
relatedRecipes: [
'energie-licentiati-anre-datornici-anaf',
'stat-actionar-seap',
'firme-datornice-cu-contracte-seap',
],
},
// ────────────────────────────────────────────────────────────────────────
{
slug: 'bb-business',
cui: '21820372',
title: 'B&B BUSINESS SOLUTIONS — 10.000 RON donat, 281,8 mil RON datorat',
subtitle: 'O singură donație de 10.000 RON către PNL în 2007, urmată de o datorie de 281,8 mil RON pe lista publică ANAF — raport 1 la 28.184.',
icon: '🗳️',
angle: 'donor-vs-debt',
headlineMetric: '1 : 28.184',
headlineHint: 'raportul dintre donația politică și datoria către stat',
caveat: '⚠️ B&B BUSINESS NU mai apare pe lista ANAF în T1 2026 — datoria de 281,8M a fost stinsă (probabil prin radiere/lichidare/insolvență finalizată, mai puțin probabil prin achitare efectivă). Pattern-ul 1:28.184 rămâne demonstrativ pentru contextul istoric. Donația AEP din 2007 e public verificabilă.',
narrative: [
{
heading: 'Singura donație, singura semnătură publică',
html: `<p>B&B BUSINESS SOLUTIONS INVESTMENT SRL — înmatriculată în mai 2007 — apare în registrul public al donațiilor către partide cu <strong>o singură donație</strong>: 10.000 RON către <strong>PNL</strong> pe 19 septembrie 2007. Patru luni după înmatriculare. E singura urmă publică a unei intenții politice.</p>`,
},
{
heading: 'Datoria — 281,8 mil RON',
html: `<p>În snapshot-ul ANAF T1 2016 (cel mai recent set de date deschise), B&B apare ca <strong>datornic mic</strong> (categorie "mici" — sub plafon de venituri în an fiscal, dar peste 100K RON datorie) cu o datorie totală de <strong>281,8 mil RON</strong>. Cele 10.000 RON donate au reprezentat <strong>1/28.184</strong> din datoria ulterioară a firmei către stat.</p>`,
},
{
heading: 'De ce e cazul B&B reprezentativ',
html: `<p>Pattern-ul "donație minimă politică + datorie masivă către stat" e documentat pe <a href="/retete/donatori-politici-care-datoreaza-statului" class="text-stamp">/retete/donatori-politici-care-datoreaza-statului</a> — sunt <strong>360 firme</strong> cu profil similar, total 6,8 mld RON datorie cumulată. Întrebarea relevantă pentru jurnaliști / ONG-uri: <em>care e mecanismul prin care firma se prezintă încă activă pe ONRC și poate participa la noi proceduri (achiziții, autorizări) după 10+ ani de datorii?</em></p>
<p>Conform Legii 98/2016 art. 165, ofertanții cu obligații restante exigibile sunt excluși de la achizițiile publice. B&B nu are contracte SEAP (e doar pe lista datornicilor), dar mecanismul de excludere depinde de declarații sub jurământ + certificate fiscale curente; lista publică ANAF nu se cross-checkează automat.</p>`,
},
],
keySignals: [
'Donație politică: 10.000 RON · PNL · 2007',
'Datorie ANAF: 281,8 mil RON (T1 2016)',
'Raport donație/datorie: 1 : 28.184',
'Categorie ANAF: "mici" (sub plafon venituri, peste 100K datorie)',
],
relatedRecipes: [
'donatori-politici-care-datoreaza-statului',
'donatori-care-au-castigat-seap',
'firme-datornice-cu-contracte-seap',
],
},
// ────────────────────────────────────────────────────────────────────────
{
slug: 'avioane-craiova',
cui: '2326144',
title: 'AVIOANE CRAIOVA — 98,6 mil RON datorie la stat, încă cumpărător activ',
subtitle: 'Producătorul de avioane militare din Craiova apare pe lista datornicilor ANAF (98,6 mil RON, categorie mijlocii) și e în continuare autoritate contractantă pentru achiziții publice.',
icon: '✈️',
angle: 'aging-warhorse',
headlineMetric: '98,6 mil RON',
headlineHint: 'datorie ANAF pentru firmă fondată în 1991 — încă achizitor public activ',
caveat: '✅ AVIOANE CRAIOVA a achitat datoria de 98,6 mil RON până în T1 2026 — NU mai apare pe lista ANAF curentă. Datele SEAP rămân live; autoritatea continuă să lanseze proceduri.',
narrative: [
{
heading: 'Companie strategică, dosar fiscal',
html: `<p>AVIOANE CRAIOVA S.A. — companie aeronautică de stat înmatriculată în 1991, controlată prin acțiuni de Ministerul Economiei — apare în snapshot-ul ANAF T1 2016 ca <strong>datornic mijlociu</strong> cu o datorie totală de <strong>98,6 mil RON</strong>.</p>
<p>Spre deosebire de companii pur civile, AVIOANE CRAIOVA are dublu statut: e și furnizor (2 contracte SEAP, 1,1 mil RON), și <strong>autoritate contractantă</strong> care lansează propriile achiziții publice (14 contracte, 14,3 mil RON ca cumpărător).</p>`,
},
{
heading: 'Mecanismul: companie strategică',
html: `<p>Legea 21/1996 + statutul de "companie de interes strategic" oferă protecție împotriva execuției silite a creanțelor fiscale pentru capacitățile critice — în special pe aeronautica militară. Asta explică de ce o datorie de aproape 100 mil RON nu duce automat la executări forțate sau la pierderea capacității de a participa la SEAP.</p>
<p>Întrebarea jurnalistică: <em>în ce stadiu sunt aceste creanțe azi (2026)? S-au stins prin compensare, eșalonare, sau au fost transferate la AAAS?</em> Lista ANAF publică din 2016 nu mai surprinde situația curentă.</p>`,
},
],
keySignals: [
'98,6 mil RON datorie ANAF (T1 2016, categorie "mijlocii")',
'Companie de stat înmatriculată 1991',
'Dual-role SEAP: furnizor (1,1 mil RON) + autoritate (14,3 mil RON ca buyer)',
'Sediul în DOLJ — aeronautică militară',
],
relatedRecipes: [
'firme-datornice-cu-contracte-seap',
'stat-actionar-seap',
],
},
// ────────────────────────────────────────────────────────────────────────
{
slug: 'municipiul-constanta',
cui: '4785631',
title: 'MUNICIPIUL CONSTANTA — 93 semnale convergente dintr-o singură autoritate',
subtitle: 'Cea mai contestată administrație locală la CNSC (90 contestații) cu trei rapoarte ale Curții de Conturi în paralel — autoritate locală cu 306 contracte SEAP și 628 mil RON valoare cumulată.',
icon: '⚓',
angle: 'state-to-state',
headlineMetric: '90 + 3',
headlineHint: 'contestații CNSC × audituri Curtea Conturi = 93 semnale convergente',
narrative: [
{
heading: 'Două surse independente, același destinatar',
html: `<p>MUNICIPIUL CONSTANTA — primăria capitală a celui mai mare port la Marea Neagră — atinge două vârfuri simultan în datele publice:</p>
<ul>
<li>⚖️ <strong>90 contestații</strong> primite la Consiliul Național de Soluționare a Contestațiilor (CNSC) — autoritatea locală cu cele mai multe contestații depuse împotriva procedurilor sale.</li>
<li>📋 <strong>3 rapoarte</strong> publicate de Curtea de Conturi pe site-ul curteadeconturi.ro — audituri ale modului în care primăria gestionează banii publici.</li>
</ul>
<p>Cumulat: <strong>93 semnale</strong> de îngrijorare juridică sau administrativă într-un perioadă recentă (5-7 ani). Pentru context: 306 anunțuri SEAP cumulate, 628 mil RON valoare contractuală, 139 furnizori distincți.</p>`,
},
{
heading: 'Volumul singur nu explică pattern-ul',
html: `<p>Există argumentul natural: o municipalitate mare are volum mare de proceduri, deci proporțional ridică mai multe contestații. Dar bucureștii ION-COMPANY și județul GIURGIU au volume comparabile cu valori absolute mai mici de semnale. Recipe-ul nostru <a href="/retete/autoritati-dubla-alerta-cdc-cnsc" class="text-stamp">/retete/autoritati-dubla-alerta-cdc-cnsc</a> arată Constanța <strong>pe locul 1</strong> dintre 50 entități cu dublă alertă.</p>
<p>Important: NU contestația în sine e problema (e exercitarea unui drept legal). Întrebarea jurnalistică e <em>despre ce tipuri de proceduri sunt contestate, în ce stadiu, și care a fost rezoluția finală</em>. Stage 2 PDF parse pe decizii CNSC (planificat) va extrage decision_type-ul oficial; momentan e disponibil doar numărul.</p>`,
},
{
heading: 'Top furnizori — concentrarea geografică',
html: `<p>Cei mai mari 5 furnizori ai municipalității Constanța după valoarea contractelor:</p>
<ul>
<li><strong>Elsaco Electronic SRL</strong> — 103,2 mil RON</li>
<li><strong>ARCHIPRO-DEVELOPMENT</strong> — 66,4 mil RON</li>
<li><strong>KARSAN OTOMOTIV</strong> (firmă turcească) — 53,4 mil RON (achiziție autobuze)</li>
<li><strong>CON-A OPERATIONS</strong> — 41,4 mil RON</li>
<li><strong>Asociația Națională a Scafandrilor Profesioniști</strong> — 23,7 mil RON</li>
</ul>
<p>Diversitatea furnizorilor (139 distincți pentru 306 contracte) sugerează că nu există un singur "câștigător sistematic" — dar valoarea cumulată în top-5 e ~288 mil RON din 628 mil RON total (~46% concentrare).</p>`,
},
],
keySignals: [
'90 contestații primite la CNSC (cel mai mult pentru o municipalitate locală)',
'3 audituri publicate de Curtea de Conturi',
'306 anunțuri SEAP cumulate · 628 mil RON valoare contractuală',
'139 furnizori distincți · top-5 cumulează ~46% din valoare',
'Locul 1 în recipe-ul autoritati-dubla-alerta-cdc-cnsc',
],
relatedRecipes: [
'autoritati-dubla-alerta-cdc-cnsc',
'autoritati-contestate-cnsc',
],
},
// ────────────────────────────────────────────────────────────────────────
{
slug: 'sheriff-guard',
cui: '14793194',
title: 'SHERIFF GUARD PROTECTION — 27.000 RON donați, 62 contestații depuse',
subtitle: 'Firmă de pază din București care folosește calea CNSC ca instrument principal de procurement: 62 contestații depuse împotriva altor autorități, paralel cu 27.000 RON donați PDL în 2008. Cea mai extremă rată "donație politică minimă · atac juridic agresiv".',
icon: '🛡️',
angle: 'donor-vs-debt',
headlineMetric: '62 contestații',
headlineHint: '27K RON donat PDL (2008) · ~435 RON donație per contestație depusă',
narrative: [
{
heading: 'Inversa lui B&B BUSINESS',
html: `<p>Dacă pattern-ul B&B BUSINESS e "donație mică · datorie masivă", SHERIFF GUARD PROTECTION e <em>inversa</em>: <strong>donație mică · număr extrem de mare de contestații depuse</strong>. CNSC nu mai e doar mecanism legal de apărare a unui ofertant — devine instrument operațional repetat sistematic.</p>
<p>Date cumulative din baza CNSC: <strong>62 contestații depuse</strong> de SHERIFF GUARD împotriva diverselor autorități contractante (procese de pază, securitate, intervenție rapidă). Sunt cele mai multe contestații depuse de o singură firmă pe domeniul nostru de date.</p>`,
},
{
heading: 'Donație politică minimală',
html: `<p>În paralel, în registrul AEP donații_pj apar <strong>3 donații</strong> cumulând <strong>27.000 RON</strong> către PDL (2008). Raportul donație : volum contestații = ~435 RON per contestație. Pattern-ul nu sugerează "captură politică prin donații mari" (B&B style 281M raportat la 10K), ci o strategie diferită: <strong>folosirea instrumentelor juridice ca arme primare de competiție</strong>.</p>
<p>Conform Legii 101/2016 art. 5, depunerea unei contestații la CNSC blochează procedura în desfășurare. O firmă activă pe contestații poate, intenționat sau nu, paraliza atribuiri unde concurența e nefavorabilă. Asta nu e ilegal — e o tactică legală, dar la o frecvență de 62 contestații se schimbă semantica.</p>`,
},
{
heading: 'Outcome-ul real, încă neclasificat',
html: `<p>Datele noastre raportează numărul contestațiilor dar nu și decizia finală (admis / respins / admis în parte). Asta vine din Stage 2 al parserului CNSC, planificat. Întrebarea cheie pentru jurnalism: <em>din cele 62 contestații, câte au fost admise (validate juridic) și câte respinse?</em> O rată mare de respingeri ar transforma pattern-ul în "vexatious litigant", o rată mare de admiteri ar valida ca strategie legitimă de apărare.</p>
<p>În paralel, firma e activă și ca furnizor pe SEAP: 3 contracte da pentru 26,9 mil RON. Combinația ofertant + contestator extrem e relevantă: <strong>contestă procedurile altora dar are propriile contracte</strong>.</p>`,
},
],
keySignals: [
'62 contestații CNSC depuse — cel mai mult dintr-o singură firmă din baza noastră',
'27.000 RON donați PDL în 2008 (3 donații)',
'Raport donație/contestații: ~435 RON per contestație depusă',
'3 contracte SEAP câștigate · 26,9 mil RON (paralel cu rolul de contestator)',
'Outcome rate (admis vs respins) — în așteptarea Stage 2 PDF parse',
],
relatedRecipes: [
'donatori-politici-care-contesta-la-cnsc',
'donatori-politici-care-datoreaza-statului',
],
},
// ────────────────────────────────────────────────────────────────────────
{
slug: 'ssab-ag',
cui: '2816022',
title: 'SSAB-AG — singura firmă care apare pe toate 4 țevile statului',
subtitle: 'Construcții Bacău cu donații politice, 8 ajutoare de stat, 8 anunțuri fonduri UE și apariție pe lista datornicilor — un profil rar de absorbție multipipe de bani publici.',
icon: '🏗️',
angle: 'quadruple-pipe',
headlineMetric: '4 / 4',
headlineHint: 'AEP donatii + RegAS ajutor de stat + fonduri EU + ANAF datornici',
narrative: [
{
heading: 'Toate cele 4 țevi simultan',
html: `<p>SSAB-AG S.A. — firmă din Bacău, înmatriculată în 1991 — e una dintre puținele entități din baza noastră cross-source care apare în <strong>toate 4 registrele publice unde converg banii statului</strong>:</p>
<ul>
<li>🗳️ <strong>AEP donatii_pj</strong> — 20.230 RON donați către PDL în 2008</li>
<li>🏛️ <strong>RegAS</strong> — 8 ajutoare de stat (Min. Finanțelor IMM INVEST PLUS, IMM PROD, GARANT CONSTRUCT, COVID, plus ITI Delta Dunării prin MDRAP)</li>
<li>🇪🇺 <strong>Fonduri EU</strong> — 8 anunțuri publicate ca beneficiar SMIS pe beneficiar.fonduri-ue.ro (2019-2020)</li>
<li>🚨 <strong>ANAF datornici</strong> — apare pe lista publică (100K RON, valoare minoră, T1 2016)</li>
</ul>
<p>Combinația e statistic rară: din ~4 mil firme ONRC, sub 50 ating toate 4 țevile.</p>`,
},
{
heading: 'Pattern: absorbție multipipe',
html: `<p>Asta nu e neapărat ilegal. O firmă activă pe construcții poate accesa legitim ajutoare de stat (scheme COVID, scheme construcții) și fonduri EU prin programe regionale (POR / SMIS). Donațiile politice sunt admise prin Legea 334/2006 sub anumite plafoane. Apariția pe lista ANAF poate fi temporară (penalități contestate, datorii eșalonate).</p>
<p>Întrebarea jurnalistică: <em>raportul dintre <strong>volumul total de bani publici absorbiți</strong> (RegAS + EU + eventuale contracte SEAP) și <strong>output-ul real al firmei</strong> (active fixe, angajați, proiecte finalizate vizibile pe teren în Bacău)?</em> Profilul e ideal pentru un studiu de caz longitudinal.</p>`,
},
{
heading: 'De ce surface-ul cross-source funcționează',
html: `<p>Niciuna dintre cele 4 surse luate separat nu produce un semnal. Donația de 20K din 2008 e neglijabilă în AEP. Cele 8 ajutoare RegAS sunt valori medii. Cele 8 anunțuri EU sunt unul printre mii. Iar 100K RON datorie e minor. Dar <strong>co-prezența</strong> în toate 4 schimbă semantica: firma a accesat sistematic fiecare canal de bani publici disponibil într-o perioadă de ~15 ani.</p>`,
},
],
keySignals: [
'AEP: 20.230 RON donați PDL (2008)',
'RegAS: 8 ajutoare de stat (Min. Finanțelor + MDRAP)',
'Fonduri EU: 8 anunțuri SMIS (2019-2020)',
'ANAF: ~100K RON pe lista datornicilor T1 2016',
'Total combinat: rar (sub 50 firme din 4 mil au profil similar)',
],
relatedRecipes: [
'firme-quadra-pipe-public',
'firme-triplu-pipe-public',
'donatori-politici-care-datoreaza-statului',
'firme-cu-ajutor-de-stat-si-seap',
],
},
];
export const INVESTIGATIONS_BY_SLUG: Record<string, InvestigationLead> = Object.fromEntries(
INVESTIGATIONS.map((i) => [i.slug, i])
);
+320
View File
@@ -0,0 +1,320 @@
/**
* Map seap.announcements rows to OCDS 1.1.5 Release Package format.
* Schema: https://standard.open-contracting.org/1.1/en/schema/release/
*
* Coverage notes:
* - We map: tender (procuringEntity, value, procurementMethod, mainProcurementCategory),
* awards[] (suppliers, value, date), contracts[] (period, value, dateSigned), parties[].
* - We do NOT have yet: bids[], amendments[], milestones[], documents[]. Those fields
* are emitted as `null` or omitted to remain spec-valid.
*/
const PUBLISHER = {
name: 'vreaudigital.ro',
uri: 'https://vreaudigital.ro',
scheme: 'RO-PROCUREMENT-AGGREGATOR',
uid: 'vreaudigital-ro',
};
const OCID_PREFIX = 'ocds-vreaudigital-';
const LICENSE = 'https://creativecommons.org/licenses/by/4.0/';
const PUBLICATION_POLICY = 'https://vreaudigital.ro/api/ocds/policy';
// SEAP procedure codes → OCDS procurement methods
// https://standard.open-contracting.org/1.1/en/schema/codelists/#procurement-method
// Keys are normalized lowercase + ASCII (diacritics stripped) for matching.
const PROCUREMENT_METHOD_MAP: Record<string, { method: string; details?: string }> = {
'licitatie deschisa': { method: 'open' },
'licitatie deschisa accelerata': { method: 'open', details: 'accelerated' },
'licitatie restransa': { method: 'selective' },
'licitatie restransa accelerata': { method: 'selective', details: 'accelerated' },
'negociere fara publicare prealabila': { method: 'limited', details: 'negociere-fara-publicare' },
'negociere cu publicare': { method: 'selective', details: 'negociere-cu-publicare' },
'procedura competitiva cu negociere': { method: 'selective', details: 'competitiva-cu-negociere' },
'procedura simplificata': { method: 'open', details: 'procedura-simplificata' },
'procedura simplificata proprie': { method: 'open', details: 'procedura-simplificata-proprie' },
'norme proprii (anexa 2b)': { method: 'limited', details: 'norme-proprii-anexa-2b' },
'cerere de oferta': { method: 'selective', details: 'cerere-de-oferte' },
'cerere de oferte': { method: 'selective', details: 'cerere-de-oferte' },
'concurs de solutii': { method: 'open', details: 'concurs-de-solutii' },
'dialog competitiv': { method: 'selective', details: 'dialog-competitiv' },
'achizitie directa': { method: 'limited', details: 'achizitie-directa' },
};
function normProcedureKey(s: string): string {
return s
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.toLowerCase()
.trim();
}
// SEAP type → OCDS release tags
// https://standard.open-contracting.org/1.1/en/schema/codelists/#release-tag
function tagsForType(seapType: string): string[] {
switch (seapType) {
case 'initiere':
case 'pi_notice':
return ['planning'];
case 'c_notice':
case 'rfq_invitation':
return ['tender'];
case 'ca_notice':
case 'rfq_notice':
case 'ted_notice':
return ['tender', 'award'];
case 'da':
case 'contract':
case 'atribuire_fara':
return ['tender', 'award', 'contract'];
case 'notificare':
return ['contractAmendment'];
default:
return ['tender'];
}
}
export interface AnnouncementRow {
id: number;
ref_number: string | null;
type: string;
source: string;
title: string | null;
description: string | null;
publication_date: Date | string | null;
contract_date: Date | string | null;
estimated_value: number | string | null;
awarded_value: number | string | null;
currency: string | null;
procedure_type: string | null;
procedure_state: string | null;
framework_agreement: boolean | null;
modification_desc: string | null;
cpv_code: string | null;
cpv_name: string | null;
cpv_division: string | null;
authority_cui: string | null;
authority_name: string | null;
authority_address: string | null;
authority_email: string | null;
authority_phone: string | null;
authority_url: string | null;
authority_county: string | null;
authority_siruta: string | null;
supplier_cui: string | null;
supplier_name: string | null;
supplier_address: string | null;
supplier_county: string | null;
supplier_siruta: string | null;
supplier_is_sme: boolean | null;
contract_has_lots: boolean | null;
lots_count: number | null;
seap_url: string | null;
contract_period_start?: Date | string | null;
contract_period_end?: Date | string | null;
}
function isoDate(d: Date | string | null | undefined): string | null {
if (!d) return null;
const date = typeof d === 'string' ? new Date(d) : d;
if (isNaN(date.getTime())) return null;
return date.toISOString();
}
function normCui(cui: string | null | undefined): string | null {
if (!cui) return null;
return cui.toUpperCase().replace(/^RO/, '').replace(/\s+/g, '').trim() || null;
}
function partyId(prefix: 'BUYER' | 'SUPPLIER', cui: string | null): string {
const norm = normCui(cui);
return norm ? `RO-CUI-${norm}` : `${prefix}-UNKNOWN`;
}
export function ocidFor(announcementId: number): string {
return `${OCID_PREFIX}${announcementId}`;
}
export function announcementToRelease(row: AnnouncementRow): any {
const ocid = ocidFor(row.id);
const releaseDate = isoDate(row.publication_date) || isoDate(row.contract_date) || new Date().toISOString();
const tags = tagsForType(row.type);
// Build parties[] — buyer + supplier (if present)
const parties: any[] = [];
if (row.authority_cui) {
const pid = partyId('BUYER', row.authority_cui);
parties.push({
id: pid,
name: row.authority_name,
identifier: {
scheme: 'RO-CUI',
id: normCui(row.authority_cui),
legalName: row.authority_name,
},
roles: ['buyer', 'procuringEntity'],
address: row.authority_address ? {
streetAddress: row.authority_address,
region: row.authority_county || undefined,
countryName: 'Romania',
} : undefined,
contactPoint: (row.authority_email || row.authority_phone || row.authority_url) ? {
email: row.authority_email || undefined,
telephone: row.authority_phone || undefined,
url: row.authority_url || undefined,
} : undefined,
details: row.authority_siruta ? { siruta: row.authority_siruta } : undefined,
});
}
if (row.supplier_cui) {
const pid = partyId('SUPPLIER', row.supplier_cui);
parties.push({
id: pid,
name: row.supplier_name,
identifier: {
scheme: 'RO-CUI',
id: normCui(row.supplier_cui),
legalName: row.supplier_name,
},
roles: ['supplier', 'tenderer'],
address: (row.supplier_address || row.supplier_county) ? {
streetAddress: row.supplier_address || undefined,
region: row.supplier_county || undefined,
countryName: 'Romania',
} : undefined,
details: {
scale: row.supplier_is_sme === true ? 'sme' : (row.supplier_is_sme === false ? 'large' : undefined),
siruta: row.supplier_siruta || undefined,
},
});
}
// Tender block
const procMap = row.procedure_type
? PROCUREMENT_METHOD_MAP[normProcedureKey(row.procedure_type)]
: undefined;
const tender: any = {
id: row.ref_number || `tender-${row.id}`,
title: row.title || undefined,
description: row.description || undefined,
status: row.procedure_state ? row.procedure_state.toLowerCase() : undefined,
procuringEntity: row.authority_cui ? {
id: partyId('BUYER', row.authority_cui),
name: row.authority_name,
} : undefined,
procurementMethod: procMap?.method,
procurementMethodDetails: procMap?.details || row.procedure_type || undefined,
mainProcurementCategory: cpvToCategory(row.cpv_code),
value: row.estimated_value != null ? {
amount: Number(row.estimated_value),
currency: row.currency || 'RON',
} : undefined,
items: row.cpv_code ? [{
id: '1',
description: row.cpv_name || row.title || undefined,
classification: {
scheme: 'CPV',
id: row.cpv_code,
description: row.cpv_name || undefined,
},
}] : undefined,
hasFrameworkAgreement: row.framework_agreement || undefined,
numberOfLots: row.lots_count || undefined,
};
// Awards block — only if we have an awarded value or supplier
const awards: any[] = [];
if (row.awarded_value != null || row.supplier_cui) {
awards.push({
id: `award-${row.id}`,
title: row.title || undefined,
status: row.contract_date ? 'active' : 'pending',
date: isoDate(row.publication_date) || isoDate(row.contract_date),
value: row.awarded_value != null ? {
amount: Number(row.awarded_value),
currency: row.currency || 'RON',
} : undefined,
suppliers: row.supplier_cui ? [{
id: partyId('SUPPLIER', row.supplier_cui),
name: row.supplier_name,
}] : [],
relatedLots: undefined,
});
}
// Contracts block
const contracts: any[] = [];
if (row.contract_date || row.modification_desc) {
contracts.push({
id: `contract-${row.id}`,
awardID: `award-${row.id}`,
title: row.title || undefined,
status: 'active',
dateSigned: isoDate(row.contract_date),
value: row.awarded_value != null ? {
amount: Number(row.awarded_value),
currency: row.currency || 'RON',
} : undefined,
period: (row.contract_period_start || row.contract_period_end) ? {
startDate: isoDate(row.contract_period_start),
endDate: isoDate(row.contract_period_end),
} : undefined,
hasAmendments: !!row.modification_desc,
amendments: row.modification_desc ? [{
id: 'amendment-1',
rationale: row.modification_desc,
}] : undefined,
});
}
return {
ocid,
id: `${row.id}`,
date: releaseDate,
tag: tags,
initiationType: 'tender',
language: 'ro',
parties,
buyer: row.authority_cui ? {
id: partyId('BUYER', row.authority_cui),
name: row.authority_name,
} : undefined,
tender,
awards: awards.length > 0 ? awards : undefined,
contracts: contracts.length > 0 ? contracts : undefined,
sources: [{
id: 'seap',
url: row.seap_url || `https://e-licitatie.ro`,
title: 'SEAP / e-licitatie.ro',
}],
};
}
function cpvToCategory(cpv: string | null): string | undefined {
if (!cpv || cpv.length < 2) return undefined;
const div = parseInt(cpv.slice(0, 2));
if (isNaN(div)) return undefined;
if (div >= 45 && div <= 45) return 'works';
if (div >= 14 || (div >= 18 && div <= 44)) return 'goods';
return 'services';
}
export function buildReleasePackage(rows: AnnouncementRow[], requestUrl: string): any {
return {
uri: requestUrl,
version: '1.1',
extensions: [],
publishedDate: new Date().toISOString(),
publisher: PUBLISHER,
license: LICENSE,
publicationPolicy: PUBLICATION_POLICY,
releases: rows.map(announcementToRelease),
};
}
export function buildSingleReleasePackage(row: AnnouncementRow, requestUrl: string): any {
return buildReleasePackage([row], requestUrl);
}
+269
View File
@@ -0,0 +1,269 @@
/**
* OG image generation via satori + resvg.
* Returns PNG buffer for /api/og/* endpoints.
*
* Card layout: 1200×630 with vreaudigital brand strip top, big title, accent line,
* primary metric large, sub-metric small, "vreaudigital.ro" footer.
*/
import satori from 'satori';
import { Resvg } from '@resvg/resvg-js';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
let cachedFonts: Awaited<ReturnType<typeof loadFonts>> | null = null;
async function loadFonts() {
// Load Plus Jakarta Sans + Inter directly from node_modules (bundled with @fontsource).
const root = process.cwd();
const tryPaths = (pkgPath: string) => [
path.join(root, 'node_modules', pkgPath),
];
async function read(p: string): Promise<Buffer | null> {
for (const candidate of tryPaths(p)) {
try { return await readFile(candidate); } catch { /* try next */ }
}
return null;
}
const jakartaBold = await read('@fontsource/plus-jakarta-sans/files/plus-jakarta-sans-latin-700-normal.woff');
const jakartaExtra = await read('@fontsource/plus-jakarta-sans/files/plus-jakarta-sans-latin-800-normal.woff');
const interReg = await read('@fontsource/inter/files/inter-latin-400-normal.woff');
const interMed = await read('@fontsource/inter/files/inter-latin-500-normal.woff');
const fonts: any[] = [];
if (interReg) fonts.push({ name: 'Inter', data: interReg, weight: 400, style: 'normal' });
if (interMed) fonts.push({ name: 'Inter', data: interMed, weight: 500, style: 'normal' });
if (jakartaBold) fonts.push({ name: 'Plus Jakarta Sans', data: jakartaBold, weight: 700, style: 'normal' });
if (jakartaExtra) fonts.push({ name: 'Plus Jakarta Sans', data: jakartaExtra, weight: 800, style: 'normal' });
return fonts;
}
interface OgCardProps {
kicker: string;
title: string;
primary: string;
secondary?: string;
badge?: string;
badgeTone?: 'risk' | 'warn' | 'ok' | 'info';
}
async function renderCard(props: OgCardProps): Promise<Buffer> {
if (!cachedFonts) cachedFonts = await loadFonts();
if (!cachedFonts || cachedFonts.length === 0) {
throw new Error('No fonts available for OG image generation');
}
const badgeColors: Record<string, { bg: string; fg: string }> = {
risk: { bg: '#FEF2F2', fg: '#B91C1C' },
warn: { bg: '#FEF3C7', fg: '#B45309' },
ok: { bg: '#D1FAE5', fg: '#047857' },
info: { bg: '#DBEAFE', fg: '#1D4ED8' },
};
const bColor = props.badgeTone ? badgeColors[props.badgeTone] : badgeColors.info;
const tree: any = {
type: 'div',
props: {
style: {
width: '1200px',
height: '630px',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
background: 'linear-gradient(135deg, #FAFBFC 0%, #F1F5F9 100%)',
padding: '60px 70px',
fontFamily: 'Inter, sans-serif',
position: 'relative',
},
children: [
// Top brand
{
type: 'div',
props: {
style: {
display: 'flex',
alignItems: 'center',
gap: '14px',
fontSize: '20px',
fontWeight: 700,
fontFamily: 'Plus Jakarta Sans',
},
children: [
{ type: 'span', props: { style: { color: '#0F172A' }, children: 'vreau' } },
{ type: 'span', props: { style: { color: '#2563EB' }, children: 'digital' } },
{ type: 'span', props: { style: { color: '#94A3B8', fontSize: '15px', fontWeight: 500, marginLeft: '6px' }, children: '· transparență achiziții publice' } },
],
},
},
// Middle content
{
type: 'div',
props: {
style: { display: 'flex', flexDirection: 'column', gap: '24px' },
children: [
// Kicker
props.kicker ? {
type: 'div',
props: {
style: {
fontFamily: 'Plus Jakarta Sans',
fontWeight: 700,
fontSize: '14px',
color: '#2563EB',
textTransform: 'uppercase',
letterSpacing: '0.08em',
},
children: props.kicker,
},
} : null,
// Title
{
type: 'div',
props: {
style: {
fontFamily: 'Plus Jakarta Sans',
fontWeight: 800,
fontSize: '54px',
color: '#0F172A',
lineHeight: 1.1,
letterSpacing: '-0.02em',
maxWidth: '1000px',
},
children: props.title.length > 90 ? props.title.slice(0, 88) + '…' : props.title,
},
},
// Accent line
{
type: 'div',
props: {
style: { width: '70px', height: '4px', background: '#2563EB', borderRadius: '4px' },
},
},
// Primary metric
{
type: 'div',
props: {
style: { display: 'flex', alignItems: 'baseline', gap: '20px' },
children: [
{
type: 'div',
props: {
style: {
fontFamily: 'Plus Jakarta Sans',
fontWeight: 800,
fontSize: '64px',
color: '#1D4ED8',
lineHeight: 1,
},
children: props.primary,
},
},
props.badge ? {
type: 'div',
props: {
style: {
background: bColor.bg,
color: bColor.fg,
fontSize: '15px',
fontWeight: 700,
padding: '8px 14px',
borderRadius: '999px',
textTransform: 'uppercase',
letterSpacing: '0.05em',
},
children: props.badge,
},
} : null,
].filter(Boolean),
},
},
// Secondary
props.secondary ? {
type: 'div',
props: {
style: {
fontFamily: 'Inter',
fontSize: '24px',
color: '#475569',
maxWidth: '1000px',
lineHeight: 1.4,
},
children: props.secondary.length > 120 ? props.secondary.slice(0, 118) + '…' : props.secondary,
},
} : null,
].filter(Boolean),
},
},
// Footer
{
type: 'div',
props: {
style: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
fontSize: '15px',
color: '#64748B',
},
children: [
{ type: 'span', props: { style: { fontFamily: 'Plus Jakarta Sans', fontWeight: 700 }, children: 'vreaudigital.ro' } },
{ type: 'span', props: { style: { color: '#94A3B8' }, children: 'date din SEAP · TED · datagov · CC-BY 4.0' } },
],
},
},
],
},
};
const svg = await satori(tree, {
width: 1200,
height: 630,
fonts: cachedFonts,
embedFont: true,
});
const resvg = new Resvg(svg, {
background: 'rgba(255, 255, 255, 1)',
fitTo: { mode: 'width', value: 1200 },
});
return resvg.render().asPng();
}
export async function ogRecipeImage(opts: {
title: string;
count: number;
primaryRow?: { name: string; metric: string; tone?: 'risk' | 'warn' | 'ok' };
}): Promise<Buffer> {
return renderCard({
kicker: 'Rețetă investigativă',
title: opts.title,
primary: `${opts.count} rezultate`,
secondary: opts.primaryRow ? `Cel mai mare: ${opts.primaryRow.name}${opts.primaryRow.metric}` : undefined,
badge: opts.primaryRow?.tone === 'risk' ? 'risc ridicat' : opts.primaryRow?.tone === 'warn' ? 'verifică' : undefined,
badgeTone: opts.primaryRow?.tone,
});
}
export async function ogProfileImage(opts: {
kind: 'firma' | 'autoritate';
name: string;
cui: string;
totalValue: string; // already formatted RON
contracts: number;
highlight?: string;
}): Promise<Buffer> {
return renderCard({
kicker: opts.kind === 'firma' ? 'Profil furnizor' : 'Profil autoritate contractantă',
title: opts.name,
primary: `${opts.totalValue} RON`,
secondary: `${opts.contracts} anunțuri · CUI ${opts.cui}${opts.highlight ? ' · ' + opts.highlight : ''}`,
badge: undefined,
});
}
+209
View File
@@ -0,0 +1,209 @@
import { query } from './db.js';
export interface Idea {
id: number;
title: string;
problem: string;
solution: string | null;
category: string;
author_name: string | null;
author_city: string | null;
status: string;
votes: number;
comment_count?: number;
solved_by_product?: string | null;
created_at: string;
}
export interface ProductSubmission {
id: number;
title: string;
description: string;
category: string;
demo_url: string;
source_url: string | null;
screenshot_url: string | null;
solves_ideas: string | null;
author_name: string;
author_email: string;
author_location: string | null;
author_github: string | null;
status: string;
created_at: string;
}
export async function getIdeas(sort: string = 'votes', category?: string): Promise<Idea[]> {
const orderBy = sort === 'new' ? 'i.created_at DESC' : 'i.votes DESC, i.created_at DESC';
const categoryFilter = category && category !== 'toate' ? 'AND i.category = $1' : '';
const params = category && category !== 'toate' ? [category] : [];
const result = await query<Idea>(`
SELECT i.*, COALESCE(c.cnt, 0)::int AS comment_count
FROM platform.ideas i
LEFT JOIN (SELECT idea_id, COUNT(*) AS cnt FROM platform.comments GROUP BY idea_id) c
ON c.idea_id = i.id
WHERE 1=1 ${categoryFilter}
ORDER BY ${orderBy}
`, params);
return result.rows;
}
export async function getIdea(id: number): Promise<Idea | null> {
const result = await query('SELECT * FROM platform.ideas WHERE id = $1', [id]);
return result.rows[0] || null;
}
export async function getIdeaComments(ideaId: number) {
const result = await query(
'SELECT * FROM platform.comments WHERE idea_id = $1 ORDER BY created_at ASC',
[ideaId]
);
return result.rows;
}
export async function createIdea(data: {
title: string;
problem: string;
solution?: string;
category?: string;
author_name?: string;
author_email?: string;
author_city?: string;
}): Promise<number> {
const result = await query(
`INSERT INTO platform.ideas (title, problem, solution, category, author_name, author_email, author_city)
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`,
[data.title, data.problem, data.solution || null, data.category || 'general',
data.author_name || null, data.author_email || null, data.author_city || null]
);
return result.rows[0].id;
}
export async function voteIdea(ideaId: number, fingerprint: string): Promise<{ votes: number; alreadyVoted: boolean }> {
// Try insert vote
try {
await query(
'INSERT INTO platform.votes (idea_id, fingerprint) VALUES ($1, $2)',
[ideaId, fingerprint]
);
// Increment counter
const result = await query(
'UPDATE platform.ideas SET votes = votes + 1 WHERE id = $1 RETURNING votes',
[ideaId]
);
return { votes: result.rows[0].votes, alreadyVoted: false };
} catch {
// Already voted (unique constraint)
const result = await query('SELECT votes FROM platform.ideas WHERE id = $1', [ideaId]);
return { votes: result.rows[0]?.votes || 0, alreadyVoted: true };
}
}
export async function addComment(ideaId: number, authorName: string, content: string) {
await query(
'INSERT INTO platform.comments (idea_id, author_name, content) VALUES ($1, $2, $3)',
[ideaId, authorName || 'Anonim', content]
);
}
export async function getStats() {
const result = await query(`
SELECT
(SELECT COUNT(*) FROM platform.ideas)::int AS total_ideas,
(SELECT COUNT(*) FROM platform.ideas WHERE status = 'in lucru')::int AS in_progress,
(SELECT COUNT(*) FROM platform.ideas WHERE status = 'live')::int AS live,
(SELECT SUM(votes) FROM platform.ideas)::int AS total_votes
`);
return result.rows[0];
}
export async function getIdeasByVoteThreshold(threshold: number): Promise<Idea[]> {
const result = await query<Idea>(`
SELECT i.*, COALESCE(c.cnt, 0)::int AS comment_count
FROM platform.ideas i
LEFT JOIN (SELECT idea_id, COUNT(*) AS cnt FROM platform.comments GROUP BY idea_id) c
ON c.idea_id = i.id
WHERE i.votes >= $1
ORDER BY i.votes DESC
`, [threshold]);
return result.rows;
}
export async function getTopIdeas(limit: number): Promise<Idea[]> {
const result = await query<Idea>(`
SELECT i.*, COALESCE(c.cnt, 0)::int AS comment_count
FROM platform.ideas i
LEFT JOIN (SELECT idea_id, COUNT(*) AS cnt FROM platform.comments GROUP BY idea_id) c
ON c.idea_id = i.id
ORDER BY i.votes DESC, i.created_at DESC
LIMIT $1
`, [limit]);
return result.rows;
}
export async function getIdeasForProduct(productSlug: string): Promise<Idea[]> {
const result = await query<Idea>(`
SELECT i.*, COALESCE(c.cnt, 0)::int AS comment_count
FROM platform.ideas i
LEFT JOIN (SELECT idea_id, COUNT(*) AS cnt FROM platform.comments GROUP BY idea_id) c
ON c.idea_id = i.id
WHERE i.solved_by_product = $1
ORDER BY i.votes DESC
`, [productSlug]);
return result.rows;
}
export async function submitProductIdea(data: {
title: string;
description: string;
category: string;
demo_url: string;
source_url?: string | null;
screenshot_url?: string | null;
solves_ideas?: string | null;
author_name: string;
author_email: string;
author_location?: string | null;
author_github?: string | null;
}): Promise<number> {
// Create table if not exists (idempotent)
await query(`
CREATE TABLE IF NOT EXISTS platform.product_submissions (
id SERIAL PRIMARY KEY,
title VARCHAR(200) NOT NULL,
description TEXT NOT NULL,
category VARCHAR(50) NOT NULL,
demo_url TEXT NOT NULL,
source_url TEXT,
screenshot_url TEXT,
solves_ideas TEXT,
author_name VARCHAR(200) NOT NULL,
author_email VARCHAR(200) NOT NULL,
author_location VARCHAR(200),
author_github VARCHAR(200),
status VARCHAR(50) DEFAULT 'pending',
created_at TIMESTAMPTZ DEFAULT NOW()
)
`);
const result = await query(
`INSERT INTO platform.product_submissions
(title, description, category, demo_url, source_url, screenshot_url, solves_ideas, author_name, author_email, author_location, author_github)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING id`,
[
data.title,
data.description,
data.category,
data.demo_url,
data.source_url || null,
data.screenshot_url || null,
data.solves_ideas || null,
data.author_name,
data.author_email,
data.author_location || null,
data.author_github || null,
]
);
return result.rows[0].id;
}
+311
View File
@@ -0,0 +1,311 @@
// Financial / audit profile helpers — ASF, AAAS, Curtea de Conturi.
// Mirrors style of getRegasStatus / getAepStatus / getAnafDebtStatus in
// profile-queries.ts. Each helper returns null when the CUI has no relevant
// rows (caller uses null to skip badge + section render).
import { query } from './db.js';
// ─────────────────────────────────────────────────────────────
// ASF — Authority for Financial Supervision (insurers / brokers /
// pension funds / AIFM / UCITS). One CUI may appear multiple times
// (different register types or active+radiat history); mv_entitati_per_cui
// rolls them up. Detail rows from asf.entitati keep the timeline intact.
// ─────────────────────────────────────────────────────────────
export interface AsfEntitatePreview {
register_type: string;
register_no: string;
section_status: string; // 'activ' | 'radiat'
name: string;
tip_companie: string | null;
forma_juridica: string | null;
data_autorizare: string | null; // YYYY-MM-DD
data_radiere: string | null; // YYYY-MM-DD or null
nr_autorizatie: string | null;
}
export interface AsfStatus {
nr_total: number;
nr_active: number;
nr_radiate: number;
nr_asigurator: number;
nr_broker: number;
nr_fond_pensii: number;
nr_aifm: number;
nr_ucits: number;
register_types: string[]; // distinct types this CUI holds
register_numbers: string[]; // canonical RA-NNN / RBK-NNN list
prima_autorizare: string | null; // YYYY-MM-DD
ultima_radiere: string | null; // YYYY-MM-DD or null when none radiated
has_active: boolean;
has_radiat: boolean;
recent: AsfEntitatePreview[];
}
export async function getAsfStatus(cui: string): Promise<AsfStatus | null> {
const normCui = cui.replace(/^RO\s*/i, '').trim();
const aggR = await query<any>(
`SELECT nr_total::int,
nr_active::int,
nr_radiate::int,
nr_asigurator::int,
nr_broker::int,
nr_fond_pensii::int,
nr_aifm::int,
nr_ucits::int,
register_types,
register_numbers,
to_char(prima_autorizare, 'YYYY-MM-DD') AS prima_autorizare,
to_char(ultima_radiere, 'YYYY-MM-DD') AS ultima_radiere
FROM asf.mv_entitati_per_cui
WHERE cui = $1`,
[normCui],
);
const a = aggR.rows[0];
if (!a || Number(a.nr_total) === 0) return null;
const recentR = await query<AsfEntitatePreview>(
`SELECT register_type,
register_no,
section_status,
name,
tip_companie,
forma_juridica,
to_char(data_autorizare, 'YYYY-MM-DD') AS data_autorizare,
to_char(data_radiere, 'YYYY-MM-DD') AS data_radiere,
nr_autorizatie
FROM asf.entitati
WHERE cui = $1
ORDER BY (section_status = 'activ') DESC,
data_autorizare DESC NULLS LAST
LIMIT 10`,
[normCui],
);
return {
nr_total: Number(a.nr_total),
nr_active: Number(a.nr_active),
nr_radiate: Number(a.nr_radiate),
nr_asigurator: Number(a.nr_asigurator),
nr_broker: Number(a.nr_broker),
nr_fond_pensii: Number(a.nr_fond_pensii),
nr_aifm: Number(a.nr_aifm),
nr_ucits: Number(a.nr_ucits),
register_types: a.register_types || [],
register_numbers: a.register_numbers || [],
prima_autorizare: a.prima_autorizare,
ultima_radiere: a.ultima_radiere,
has_active: Number(a.nr_active) > 0,
has_radiat: Number(a.nr_radiate) > 0,
recent: recentR.rows,
};
}
// ─────────────────────────────────────────────────────────────
// AAAS — state still owns shares / firm owes state money.
// Stage 1 dataset = 12 named active-portfolio firms; very high signal.
// ─────────────────────────────────────────────────────────────
export interface AaasFirmaPreview {
name: string;
aaas_status: string; // 'active_holding' | 'post_priv_debt' | ...
state_share_pct: number | null; // 0100
debt_to_state_lei: number | null;
reg_number: string | null;
last_action: string | null;
last_action_date: string | null; // YYYY-MM-DD
source_url: string;
}
export interface AaasStatus {
rows_count: number;
statusuri: string[]; // distinct aaas_status values
max_state_share_pct: number | null;
total_debt_to_state_lei: number | null;
last_seen_at: string | null; // YYYY-MM-DD
recent: AaasFirmaPreview[];
}
export async function getAaasStatus(cui: string): Promise<AaasStatus | null> {
const normCui = cui.replace(/^RO\s*/i, '').trim();
const aggR = await query<any>(
`SELECT rows_count::int,
statusuri,
max_state_share_pct::numeric,
total_debt_to_state_lei::numeric,
to_char(last_seen_at, 'YYYY-MM-DD') AS last_seen_at
FROM aaas.mv_per_cui
WHERE cui = $1`,
[normCui],
);
const a = aggR.rows[0];
if (!a || Number(a.rows_count) === 0) return null;
const recentR = await query<any>(
`SELECT name,
aaas_status,
state_share_pct::numeric,
debt_to_state_lei::numeric,
reg_number,
last_action,
to_char(last_action_date, 'YYYY-MM-DD') AS last_action_date,
source_url
FROM aaas.firme
WHERE cui = $1
ORDER BY last_action_date DESC NULLS LAST,
state_share_pct DESC NULLS LAST
LIMIT 10`,
[normCui],
);
return {
rows_count: Number(a.rows_count),
statusuri: a.statusuri || [],
max_state_share_pct: a.max_state_share_pct == null ? null : Number(a.max_state_share_pct),
total_debt_to_state_lei: a.total_debt_to_state_lei == null ? null : Number(a.total_debt_to_state_lei),
last_seen_at: a.last_seen_at,
recent: recentR.rows.map((r) => ({
name: r.name,
aaas_status: r.aaas_status,
state_share_pct: r.state_share_pct == null ? null : Number(r.state_share_pct),
debt_to_state_lei: r.debt_to_state_lei == null ? null : Number(r.debt_to_state_lei),
reg_number: r.reg_number,
last_action: r.last_action,
last_action_date: r.last_action_date,
source_url: r.source_url,
})),
};
}
// ─────────────────────────────────────────────────────────────
// Curtea de Conturi — audit reports targeting this CUI.
// Stage 1 dataset (~1,133 reports) has audited_entity_name parsed from
// listing-page titles but audited_entity_cui is NULL until Stage 2 PDF
// resolve. We therefore match BOTH paths:
// 1. direct: rapoarte.audited_entity_cui = $1 (currently 0 rows;
// future-proof for Stage 2 backfill).
// 2. fuzzy: pg_trgm % match between firms.entities.name (for $1) and
// rapoarte.audited_entity_name. Returned rows are flagged
// match_method='name_fuzzy' so the UI can show a discrete
// "identificare aproximativă" hint.
// Threshold: pg_trgm default 0.3 (already set on satra). We further
// restrict the firms-side lookup to a single CUI (no fan-out).
// ─────────────────────────────────────────────────────────────
export interface CurteaContPreview {
slug_id: string;
category: string;
audit_type: string | null; // 'financiar' | 'conformitate' | 'performanta' | 'control' | 'follow-up'
audit_year: number | null;
title: string;
audited_entity_name: string | null;
doc_number: string | null;
doc_date: string | null; // YYYY-MM-DD
publication_date: string | null;
detail_url: string;
match_method: 'cui_direct' | 'name_fuzzy';
}
export interface CurteaContStatus {
nr_total: number;
nr_direct: number; // matched by audited_entity_cui
nr_fuzzy: number; // matched by audited_entity_name fuzzy
by_audit_type: Record<string, number>;
ani_acoperiti: number[]; // distinct audit_year values, sorted desc
prima_publicare: string | null;
ultima_publicare: string | null;
recent: CurteaContPreview[];
}
export async function getCurteaContStatus(cui: string): Promise<CurteaContStatus | null> {
const normCui = cui.replace(/^RO\s*/i, '').trim();
// Combined query: direct CUI match UNION ALL with fuzzy-name match (where
// firms.entities.name_normalized % rapoarte.audited_entity_name's normalized
// form). Direct rows take priority (DISTINCT ON would be safer but Stage 1
// has zero direct rows so not yet relevant).
const rowsR = await query<any>(
`WITH direct AS (
SELECT slug_id, category, audit_type, audit_year, title,
audited_entity_name, doc_number,
to_char(doc_date, 'YYYY-MM-DD') AS doc_date,
to_char(publication_date, 'YYYY-MM-DD') AS publication_date,
detail_url,
'cui_direct'::text AS match_method
FROM curteacont.rapoarte
WHERE audited_entity_cui = $1
),
firma AS (
SELECT firms.normalize_company_name(name) AS norm
FROM firms.entities
WHERE cui = $1
LIMIT 1
),
fuzzy AS (
SELECT r.slug_id, r.category, r.audit_type, r.audit_year, r.title,
r.audited_entity_name, r.doc_number,
to_char(r.doc_date, 'YYYY-MM-DD') AS doc_date,
to_char(r.publication_date, 'YYYY-MM-DD') AS publication_date,
r.detail_url,
'name_fuzzy'::text AS match_method
FROM curteacont.rapoarte r
JOIN firma f ON TRUE
WHERE r.audited_entity_cui IS NULL
AND r.audited_entity_name IS NOT NULL
AND firms.normalize_company_name(r.audited_entity_name) % f.norm
)
SELECT * FROM direct
UNION ALL
SELECT * FROM fuzzy
ORDER BY publication_date DESC NULLS LAST,
doc_date DESC NULLS LAST
LIMIT 100`,
[normCui],
);
const rows = rowsR.rows;
if (rows.length === 0) return null;
const nrDirect = rows.filter((r: any) => r.match_method === 'cui_direct').length;
const nrFuzzy = rows.filter((r: any) => r.match_method === 'name_fuzzy').length;
const byAuditType: Record<string, number> = {};
for (const r of rows) {
const k = r.audit_type || 'altele';
byAuditType[k] = (byAuditType[k] || 0) + 1;
}
const aniSet = new Set<number>();
for (const r of rows) {
if (r.audit_year != null) aniSet.add(Number(r.audit_year));
}
const aniAcoperiti = Array.from(aniSet).sort((a, b) => b - a);
const dates = rows.map((r: any) => r.publication_date).filter(Boolean).sort();
const primaPublicare = dates[0] || null;
const ultimaPublicare = dates[dates.length - 1] || null;
return {
nr_total: rows.length,
nr_direct: nrDirect,
nr_fuzzy: nrFuzzy,
by_audit_type: byAuditType,
ani_acoperiti: aniAcoperiti,
prima_publicare: primaPublicare,
ultima_publicare: ultimaPublicare,
recent: rows.slice(0, 10).map((r: any) => ({
slug_id: r.slug_id,
category: r.category,
audit_type: r.audit_type,
audit_year: r.audit_year == null ? null : Number(r.audit_year),
title: r.title,
audited_entity_name: r.audited_entity_name,
doc_number: r.doc_number,
doc_date: r.doc_date,
publication_date: r.publication_date,
detail_url: r.detail_url,
match_method: r.match_method as 'cui_direct' | 'name_fuzzy',
})),
};
}
+396
View File
@@ -0,0 +1,396 @@
// Utility/regulator profile helpers — ANRE (energy), ANCOM (telco), CNSC (procurement
// contestations). Each returns null when the CUI has no presence in the source so the
// UI can skip the badge/section entirely. Mirrors the style of the existing helpers
// in profile-queries.ts (RegAS / AEP / ANAF). Always uses query() from './db.js' and
// PostgreSQL parameter binding ($1) with the standard normCui regex normalization.
import { query } from './db.js';
// ─────────────────────────────────────────────────────────────
// ANRE — Autoritatea Națională de Reglementare în domeniul Energiei.
// Source: anre.licente (corporate licenses, 3 sub-registries) and the
// per-CUI rollup anre.mv_licente_per_cui.
// ─────────────────────────────────────────────────────────────
export interface AnreLicentaPreview {
license_source: string; // 'electricitate' | 'gaze' | 'atestat'
license_no: string;
license_type: string | null; // 'Licenta' | 'Autorizatie de Infiintare' | 'Atestat' | …
license_subtype: string | null; // 'Furnizare' | 'Producere' | 'Distributie' | …
stare: string | null; // 'Acordata' | 'Expirata' | 'Retrasa' | …
data_emitere: string | null;
data_expirare: string | null;
}
export interface AnreStatus {
nr_licente_total: number;
nr_active: number;
nr_expirate: number;
nr_retrase: number;
nr_electricitate: number;
nr_gaze: number;
nr_atestate: number;
surse: string[];
subtipuri: string[];
prima_emitere: string | null;
ultima_emitere: string | null;
ultima_expirare: string | null;
recent: AnreLicentaPreview[];
}
export async function getAnreStatus(cui: string): Promise<AnreStatus | null> {
const normCui = cui.replace(/^RO\s*/i, '').trim();
const aggR = await query<any>(
`SELECT nr_licente_total::int,
nr_active::int,
nr_expirate::int,
nr_retrase::int,
nr_electricitate::int,
nr_gaze::int,
nr_atestate::int,
surse,
subtipuri,
to_char(prima_emitere, 'YYYY-MM-DD') AS prima_emitere,
to_char(ultima_emitere, 'YYYY-MM-DD') AS ultima_emitere,
to_char(ultima_expirare, 'YYYY-MM-DD') AS ultima_expirare
FROM anre.mv_licente_per_cui
WHERE cui = $1`,
[normCui],
);
const a = aggR.rows[0];
if (!a || Number(a.nr_licente_total) === 0) return null;
const recentR = await query<AnreLicentaPreview>(
`SELECT license_source,
license_no,
license_type,
license_subtype,
stare,
to_char(data_emitere, 'YYYY-MM-DD') AS data_emitere,
to_char(data_expirare, 'YYYY-MM-DD') AS data_expirare
FROM anre.licente
WHERE titular_cui = $1
ORDER BY (CASE WHEN stare ILIKE 'Acord%' OR stare ILIKE 'Activ%' THEN 0 ELSE 1 END),
data_emitere DESC NULLS LAST
LIMIT 10`,
[normCui],
);
return {
nr_licente_total: Number(a.nr_licente_total),
nr_active: Number(a.nr_active) || 0,
nr_expirate: Number(a.nr_expirate) || 0,
nr_retrase: Number(a.nr_retrase) || 0,
nr_electricitate: Number(a.nr_electricitate) || 0,
nr_gaze: Number(a.nr_gaze) || 0,
nr_atestate: Number(a.nr_atestate) || 0,
surse: a.surse || [],
subtipuri: a.subtipuri || [],
prima_emitere: a.prima_emitere,
ultima_emitere: a.ultima_emitere,
ultima_expirare: a.ultima_expirare,
recent: recentR.rows,
};
}
// ─────────────────────────────────────────────────────────────
// ANCOM — Autoritatea Națională pentru Administrare și Reglementare
// în Comunicații. CUI is direct on the registry detail page.
// Source: ancom.operatori + ancom.mv_operatori_per_cui (rollup).
// ─────────────────────────────────────────────────────────────
export interface AncomOperatorPreview {
ancom_id: number;
titular_name: string;
status: string;
judet: string | null;
detail_url: string;
}
export interface AncomStatus {
nr_autorizatii: number;
retele: string[]; // ['R1','R3', …] (codes)
servicii: string[]; // ['S1','S2', …] (codes)
are_internet_fix: boolean;
are_mobil: boolean;
are_fibra: boolean;
are_status_activ: boolean;
prima_autorizare: string | null;
ultima_autorizare: string | null;
operatori: AncomOperatorPreview[];
}
export async function getAncomStatus(cui: string): Promise<AncomStatus | null> {
const normCui = cui.replace(/^RO\s*/i, '').trim();
const aggR = await query<any>(
`SELECT nr_autorizatii::int,
retele,
servicii,
are_internet_fix,
are_mobil,
are_fibra,
are_status_activ,
to_char(prima_autorizare, 'YYYY-MM-DD') AS prima_autorizare,
to_char(ultima_autorizare, 'YYYY-MM-DD') AS ultima_autorizare
FROM ancom.mv_operatori_per_cui
WHERE cui = $1`,
[normCui],
);
const a = aggR.rows[0];
if (!a || Number(a.nr_autorizatii) === 0) return null;
const opR = await query<AncomOperatorPreview>(
`SELECT ancom_id,
titular_name,
status,
judet,
detail_url
FROM ancom.operatori
WHERE titular_cui = $1
ORDER BY (CASE WHEN status = 'autorizat' THEN 0 ELSE 1 END), ancom_id
LIMIT 5`,
[normCui],
);
return {
nr_autorizatii: Number(a.nr_autorizatii),
retele: a.retele || [],
servicii: a.servicii || [],
are_internet_fix: !!a.are_internet_fix,
are_mobil: !!a.are_mobil,
are_fibra: !!a.are_fibra,
are_status_activ: !!a.are_status_activ,
prima_autorizare: a.prima_autorizare,
ultima_autorizare: a.ultima_autorizare,
operatori: opR.rows,
};
}
// ─────────────────────────────────────────────────────────────
// CNSC — Consiliul Național de Soluționare a Contestațiilor.
// A CUI can appear as either authority (the public buyer being contested)
// or contestator (the bidder filing the complaint). We expose both sides;
// return null only when neither side has any rows.
// Stage 2 PDF parse hasn't run yet → decision_type is mostly NULL; UI hides
// fields that are NULL.
// ─────────────────────────────────────────────────────────────
export interface CnscDeciziePreview {
decision_no: number;
decision_year: number;
registration_no_cnsc: string | null;
registration_date: string | null;
contestator_names: string[];
contestator_cuis: string[];
authority_name: string | null;
authority_cuis: string[];
decision_type: string | null;
pdf_url: string | null;
}
export interface CnscAuthoritySide {
contestation_count: number;
admis_count: number;
admis_in_parte_count: number;
respins_count: number;
resolved_count: number;
first_contestation_date: string | null;
last_contestation_date: string | null;
recent: CnscDeciziePreview[];
}
export interface CnscContestatorSide {
contestations_filed: number;
won_admis: number;
won_partial: number;
lost_respins: number;
resolved_count: number;
first_contestation_date: string | null;
last_contestation_date: string | null;
recent: CnscDeciziePreview[];
}
export interface CnscStatus {
asAuthority: CnscAuthoritySide | null;
asContestator: CnscContestatorSide | null;
}
export async function getCnscStatus(cui: string): Promise<CnscStatus | null> {
const normCui = cui.replace(/^RO\s*/i, '').trim();
const authAggR = await query<any>(
`SELECT contestation_count::int,
admis_count::int,
admis_in_parte_count::int,
respins_count::int,
resolved_count::int,
to_char(first_contestation_date, 'YYYY-MM-DD') AS first_contestation_date,
to_char(last_contestation_date, 'YYYY-MM-DD') AS last_contestation_date
FROM cnsc.mv_per_authority_cui
WHERE cui = $1`,
[normCui],
);
const contAggR = await query<any>(
`SELECT contestations_filed::int,
won_admis::int,
won_partial::int,
lost_respins::int,
resolved_count::int,
to_char(first_contestation_date, 'YYYY-MM-DD') AS first_contestation_date,
to_char(last_contestation_date, 'YYYY-MM-DD') AS last_contestation_date
FROM cnsc.mv_per_contestator_cui
WHERE cui = $1`,
[normCui],
);
const authAgg = authAggR.rows[0];
const contAgg = contAggR.rows[0];
const hasAuth = !!authAgg && Number(authAgg.contestation_count) > 0;
const hasCont = !!contAgg && Number(contAgg.contestations_filed) > 0;
if (!hasAuth && !hasCont) return null;
let asAuthority: CnscAuthoritySide | null = null;
if (hasAuth) {
const recentR = await query<any>(
`SELECT decision_no,
decision_year,
registration_no_cnsc,
to_char(registration_date, 'YYYY-MM-DD') AS registration_date,
contestator_names,
contestator_cuis,
authority_name,
authority_cuis,
decision_type,
pdf_url
FROM cnsc.decizii
WHERE $1 = ANY(authority_cuis)
ORDER BY decision_year DESC, decision_no DESC
LIMIT 10`,
[normCui],
);
asAuthority = {
contestation_count: Number(authAgg.contestation_count),
admis_count: Number(authAgg.admis_count) || 0,
admis_in_parte_count: Number(authAgg.admis_in_parte_count) || 0,
respins_count: Number(authAgg.respins_count) || 0,
resolved_count: Number(authAgg.resolved_count) || 0,
first_contestation_date: authAgg.first_contestation_date,
last_contestation_date: authAgg.last_contestation_date,
recent: recentR.rows.map(r => ({
decision_no: Number(r.decision_no),
decision_year: Number(r.decision_year),
registration_no_cnsc: r.registration_no_cnsc,
registration_date: r.registration_date,
contestator_names: r.contestator_names || [],
contestator_cuis: r.contestator_cuis || [],
authority_name: r.authority_name,
authority_cuis: r.authority_cuis || [],
decision_type: r.decision_type,
pdf_url: r.pdf_url,
})),
};
}
let asContestator: CnscContestatorSide | null = null;
if (hasCont) {
const recentR = await query<any>(
`SELECT decision_no,
decision_year,
registration_no_cnsc,
to_char(registration_date, 'YYYY-MM-DD') AS registration_date,
contestator_names,
contestator_cuis,
authority_name,
authority_cuis,
decision_type,
pdf_url
FROM cnsc.decizii
WHERE $1 = ANY(contestator_cuis)
ORDER BY decision_year DESC, decision_no DESC
LIMIT 10`,
[normCui],
);
asContestator = {
contestations_filed: Number(contAgg.contestations_filed),
won_admis: Number(contAgg.won_admis) || 0,
won_partial: Number(contAgg.won_partial) || 0,
lost_respins: Number(contAgg.lost_respins) || 0,
resolved_count: Number(contAgg.resolved_count) || 0,
first_contestation_date: contAgg.first_contestation_date,
last_contestation_date: contAgg.last_contestation_date,
recent: recentR.rows.map(r => ({
decision_no: Number(r.decision_no),
decision_year: Number(r.decision_year),
registration_no_cnsc: r.registration_no_cnsc,
registration_date: r.registration_date,
contestator_names: r.contestator_names || [],
contestator_cuis: r.contestator_cuis || [],
authority_name: r.authority_name,
authority_cuis: r.authority_cuis || [],
decision_type: r.decision_type,
pdf_url: r.pdf_url,
})),
};
}
return { asAuthority, asContestator };
}
// ─────────────────────────────────────────────────────────────
// Bugetar — Transparență Bugetară MFP. Phase 1 ingest covers the
// universe of reporting public entities (18,822 rows) but not
// actual execuții data (Phase 2 needs 2captcha). Surfacing
// registry membership on autoritate profile is still useful:
// confirms this is a "real" public budget entity + links to
// the MFP search UI for manual lookup.
// ─────────────────────────────────────────────────────────────
const BUGETAR_SECTOR_LABELS: Record<string, string> = {
'01': 'buget de stat',
'02': 'bugete locale',
'03': 'asigurări sociale',
'04': 'șomaj',
'05': 'sănătate (FNUASS)',
};
export interface BugetarStatus {
sector_bugetar: string;
sector_label: string;
judet: string | null;
entity_name: string;
is_ordonator_principal: boolean;
cui_match_method: string | null;
cui_match_score: number | null;
}
export async function getBugetarStatus(cui: string): Promise<BugetarStatus | null> {
const norm = cui.replace(/^RO\s*/i, '').trim();
const r = await query<{
sector_bugetar: string;
judet: string;
entity_name: string;
is_ordonator_principal: boolean;
cui_match_method: string | null;
cui_match_score: number | null;
}>(
`SELECT sector_bugetar, judet, entity_name, is_ordonator_principal,
cui_match_method, cui_match_score
FROM bugetar.entitate
WHERE cui = $1
ORDER BY is_ordonator_principal DESC, sector_bugetar
LIMIT 1`,
[norm],
);
const row = r.rows[0];
if (!row) return null;
return {
sector_bugetar: row.sector_bugetar,
sector_label: BUGETAR_SECTOR_LABELS[row.sector_bugetar] || row.sector_bugetar,
judet: row.judet,
entity_name: row.entity_name,
is_ordonator_principal: row.is_ordonator_principal,
cui_match_method: row.cui_match_method,
cui_match_score: row.cui_match_score == null ? null : Number(row.cui_match_score),
};
}
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
/**
* Resolve the public URL of a request, respecting reverse-proxy headers.
* In production we run behind Traefik on satra → externally vreaudigital.ro.
* The container sees `host=localhost:4321` so `url.origin` is wrong; we trust
* X-Forwarded-Proto and X-Forwarded-Host as set by Traefik.
*/
export function publicOrigin(request: Request, fallback: URL): string {
const proto = request.headers.get('x-forwarded-proto') || fallback.protocol.replace(':', '');
const host = request.headers.get('x-forwarded-host') || request.headers.get('host') || fallback.host;
if (!host || host.startsWith('localhost') || host.startsWith('127.')) {
return 'https://vreaudigital.ro';
}
return `${proto}://${host}`;
}
export function publicUrl(request: Request, fallback: URL): string {
return `${publicOrigin(request, fallback)}${fallback.pathname}${fallback.search}`;
}
+2289
View File
File diff suppressed because it is too large Load Diff
+361
View File
@@ -0,0 +1,361 @@
import { query } from './db.js';
export type RiskSeverity = 'high' | 'medium' | 'low';
export interface RiskFlag {
code: string;
severity: RiskSeverity;
label: string;
detail?: number | string | null;
}
export interface RiskContract {
id: number;
ref_number: string;
type: string;
title: string | null;
publication_date: string | null;
deadline_submission: string | null;
awarded_value: number | null;
estimated_value: number | null;
currency: string | null;
cpv_division: string | null;
cpv_division_name: string | null;
authority_name: string | null;
authority_cui: string | null;
authority_county: string | null;
supplier_name: string | null;
supplier_cui: string | null;
num_offers: number | null;
risk_flags: RiskFlag[] | null;
// Indicator-specific
savings_pct?: number | null;
median_value?: number | null;
ratio_to_median?: number | null;
days_gap?: number | null;
}
export interface AuthorityConcentration {
authority_cui: string;
authority_name: string | null;
year: number;
top_supplier_cui: string;
top_supplier_name: string | null;
top_supplier_value: number;
top_supplier_contracts: number;
year_total: number;
year_contracts: number;
top_supplier_share: number; // 0..1
}
export interface RiskOverview {
single_bidder: number;
short_deadline: number;
suspicious_savings: number;
authority_concentration: number;
overprice: number;
any_flag: number;
}
// ── 1. Single bidder ──
export async function getSingleBidderContracts(
limit = 50,
offset = 0,
): Promise<RiskContract[]> {
const result = await query<RiskContract>(`
SELECT
a.id, a.ref_number, a.type, a.title,
a.publication_date, a.deadline_submission,
a.awarded_value, a.estimated_value, a.currency,
a.cpv_division, c.name_ro AS cpv_division_name,
a.authority_name, a.authority_cui,
cl.county AS authority_county,
a.supplier_name, a.supplier_cui,
a.num_offers,
a.risk_flags
FROM seap.v_single_bidder a
LEFT JOIN seap.cpv_codes c ON c.code = a.cpv_division
LEFT JOIN seap.cui_location cl ON cl.cui = a.authority_cui
ORDER BY a.awarded_value DESC NULLS LAST, a.publication_date DESC NULLS LAST
LIMIT $1 OFFSET $2
`, [limit, offset]);
return result.rows;
}
export async function getSingleBidderCount(): Promise<number> {
const r = await query<{ n: number }>(`SELECT count(*)::int AS n FROM seap.v_single_bidder`);
return r.rows[0]?.n ?? 0;
}
export async function getSingleBidderTopAuthorities(
limit = 10,
): Promise<{ authority_cui: string; authority_name: string; total: number; share: number }[]> {
const r = await query<{ authority_cui: string; authority_name: string; total: number; share: number }>(`
WITH per_auth AS (
SELECT
authority_cui,
MIN(authority_name) AS authority_name,
count(*)::int AS contracts,
count(*) FILTER (WHERE
num_offers = 1
OR (details IS NOT NULL
AND jsonb_typeof(details->'all_winners') = 'array'
AND jsonb_array_length(details->'all_winners') = 1)
)::int AS single_bidder_contracts
FROM seap.announcements
WHERE type = 'ca_notice' AND authority_cui IS NOT NULL
GROUP BY authority_cui
HAVING count(*) >= 5
)
SELECT
authority_cui,
authority_name,
single_bidder_contracts AS total,
(single_bidder_contracts::numeric / NULLIF(contracts, 0))::numeric(5,3) AS share
FROM per_auth
WHERE single_bidder_contracts >= 3
ORDER BY share DESC NULLS LAST, total DESC
LIMIT $1
`, [limit]);
return r.rows;
}
// ── 2. Short deadline ──
export async function getShortDeadlineContracts(
limit = 50,
offset = 0,
daysThreshold = 10,
): Promise<RiskContract[]> {
const r = await query<RiskContract>(`
SELECT
a.id, a.ref_number, a.type, a.title,
a.publication_date, a.deadline_submission,
a.awarded_value, a.estimated_value, a.currency,
a.cpv_division, c.name_ro AS cpv_division_name,
a.authority_name, a.authority_cui,
cl.county AS authority_county,
a.supplier_name, a.supplier_cui,
a.num_offers,
a.risk_flags,
EXTRACT(EPOCH FROM (a.deadline_submission - a.publication_date))/86400.0 AS days_gap
FROM seap.announcements a
LEFT JOIN seap.cpv_codes c ON c.code = a.cpv_division
LEFT JOIN seap.cui_location cl ON cl.cui = a.authority_cui
WHERE a.type IN ('c_notice','rfq_invitation')
AND a.publication_date IS NOT NULL
AND a.deadline_submission IS NOT NULL
AND (a.deadline_submission - a.publication_date) < make_interval(days => $3)
ORDER BY (a.deadline_submission - a.publication_date) ASC
LIMIT $1 OFFSET $2
`, [limit, offset, daysThreshold]);
return r.rows;
}
export async function getShortDeadlineCount(daysThreshold = 10): Promise<number> {
const r = await query<{ n: number }>(`
SELECT count(*)::int AS n
FROM seap.announcements
WHERE type IN ('c_notice','rfq_invitation')
AND publication_date IS NOT NULL
AND deadline_submission IS NOT NULL
AND (deadline_submission - publication_date) < make_interval(days => $1)
`, [daysThreshold]);
return r.rows[0]?.n ?? 0;
}
// ── 3. Suspicious savings ──
export async function getSuspiciousSavings(
limit = 50,
offset = 0,
): Promise<RiskContract[]> {
const r = await query<RiskContract>(`
SELECT
a.id, a.ref_number, a.type, a.title,
a.publication_date, a.deadline_submission,
a.awarded_value, a.estimated_value, a.currency,
a.cpv_division, c.name_ro AS cpv_division_name,
a.authority_name, a.authority_cui,
cl.county AS authority_county,
a.supplier_name, a.supplier_cui,
a.num_offers,
a.risk_flags,
round(100.0 * (1 - a.awarded_value / NULLIF(a.estimated_value, 0)))::int AS savings_pct
FROM seap.announcements a
LEFT JOIN seap.cpv_codes c ON c.code = a.cpv_division
LEFT JOIN seap.cui_location cl ON cl.cui = a.authority_cui
WHERE a.awarded_value IS NOT NULL
AND a.estimated_value IS NOT NULL
AND a.awarded_value > 0
AND a.estimated_value > 0
AND a.awarded_value < 0.5 * a.estimated_value
ORDER BY (a.estimated_value - a.awarded_value) DESC NULLS LAST
LIMIT $1 OFFSET $2
`, [limit, offset]);
return r.rows;
}
export async function getSuspiciousSavingsCount(): Promise<number> {
const r = await query<{ n: number }>(`
SELECT count(*)::int AS n
FROM seap.announcements
WHERE awarded_value IS NOT NULL
AND estimated_value IS NOT NULL
AND awarded_value > 0
AND estimated_value > 0
AND awarded_value < 0.5 * estimated_value
`);
return r.rows[0]?.n ?? 0;
}
// ── 4. Authority supplier concentration ──
export async function getAuthorityConcentrations(
threshold = 0.6,
limit = 100,
): Promise<AuthorityConcentration[]> {
const r = await query<AuthorityConcentration>(`
SELECT
authority_cui, authority_name, year,
top_supplier_cui, top_supplier_name,
top_supplier_value::float AS top_supplier_value,
top_supplier_contracts,
year_total::float AS year_total,
year_contracts,
top_supplier_share::float AS top_supplier_share
FROM seap.mv_authority_concentration
WHERE top_supplier_share >= $1
ORDER BY top_supplier_share DESC, year_total DESC
LIMIT $2
`, [threshold, limit]);
return r.rows;
}
export async function getConcentrationCount(threshold = 0.6): Promise<number> {
const r = await query<{ n: number }>(`
SELECT count(*)::int AS n
FROM seap.mv_authority_concentration
WHERE top_supplier_share >= $1
`, [threshold]);
return r.rows[0]?.n ?? 0;
}
// ── 5. Overpriced (>2x median per CPV) ──
export async function getOverpricedContracts(
limit = 50,
offset = 0,
): Promise<RiskContract[]> {
const r = await query<RiskContract>(`
SELECT
a.id, a.ref_number, a.type, a.title,
a.publication_date, a.deadline_submission,
a.awarded_value, a.estimated_value, a.currency,
a.cpv_division, c.name_ro AS cpv_division_name,
a.authority_name, a.authority_cui,
cl.county AS authority_county,
a.supplier_name, a.supplier_cui,
a.num_offers,
a.risk_flags,
m.median_value::float AS median_value,
LEAST(a.awarded_value / NULLIF(m.median_value, 0), 99999)::numeric(15,2) AS ratio_to_median
FROM seap.announcements a
JOIN seap.mv_cpv_median_value m ON m.cpv_division = a.cpv_division
LEFT JOIN seap.cpv_codes c ON c.code = a.cpv_division
LEFT JOIN seap.cui_location cl ON cl.cui = a.authority_cui
WHERE a.awarded_value IS NOT NULL
AND a.awarded_value > 0
AND m.median_value > 0
AND a.awarded_value > 2 * m.median_value
ORDER BY (a.awarded_value / NULLIF(m.median_value, 0)) DESC NULLS LAST
LIMIT $1 OFFSET $2
`, [limit, offset]);
return r.rows;
}
export async function getOverpricedCount(): Promise<number> {
const r = await query<{ n: number }>(`
SELECT count(*)::int AS n
FROM seap.announcements a
JOIN seap.mv_cpv_median_value m ON m.cpv_division = a.cpv_division
WHERE a.awarded_value IS NOT NULL
AND a.awarded_value > 0
AND m.median_value > 0
AND a.awarded_value > 2 * m.median_value
`);
return r.rows[0]?.n ?? 0;
}
// ── Overview: counts for all flags ──
export async function getRiskOverview(): Promise<RiskOverview> {
const [single, shortD, savings, conc, over, any] = await Promise.all([
getSingleBidderCount(),
getShortDeadlineCount(),
getSuspiciousSavingsCount(),
getConcentrationCount(),
getOverpricedCount(),
query<{ n: number }>(`
SELECT count(*)::int AS n
FROM seap.announcements
WHERE risk_flags IS NOT NULL
AND jsonb_array_length(risk_flags) > 0
`).then(r => r.rows[0]?.n ?? 0),
]);
return {
single_bidder: single,
short_deadline: shortD,
suspicious_savings: savings,
authority_concentration: conc,
overprice: over,
any_flag: any,
};
}
// Risk flags for a given entity (used on /autoritate/[cui] and /firma/[cui])
export async function getEntityRiskFlags(
side: 'authority' | 'supplier',
cui: string,
): Promise<{ code: string; count: number; severity: string }[]> {
const cuiCondition = side === 'authority'
? `a.authority_cui = $1`
: `(a.supplier_cui = $1 OR a.supplier_cui = 'RO ' || $1 OR a.supplier_cui = 'RO' || $1)`;
const r = await query<{ code: string; count: number; severity: string }>(`
SELECT
flag->>'code' AS code,
flag->>'severity' AS severity,
count(*)::int AS count
FROM seap.announcements a, jsonb_array_elements(a.risk_flags) flag
WHERE ${cuiCondition}
AND a.risk_flags IS NOT NULL
AND jsonb_array_length(a.risk_flags) > 0
GROUP BY 1, 2
ORDER BY count DESC
`, [cui]);
return r.rows;
}
// Check if a result row should show a flag chip (used in /cauta)
export function summarizeFlags(flags: RiskFlag[] | null | undefined): {
topSeverity: RiskSeverity | null;
count: number;
labels: string[];
} {
if (!flags || flags.length === 0) {
return { topSeverity: null, count: 0, labels: [] };
}
const order: Record<RiskSeverity, number> = { high: 3, medium: 2, low: 1 };
let top: RiskSeverity = 'low';
for (const f of flags) {
if (order[f.severity] > order[top]) top = f.severity;
}
return {
topSeverity: top,
count: flags.length,
labels: flags.map(f => f.label),
};
}
+274
View File
@@ -0,0 +1,274 @@
import { query } from './db.js';
export interface UatStats {
siruta: string;
uat_name: string;
county: string;
da_count: number;
da_total_value: number;
notice_count: number;
notice_total_value: number;
total_contracts: number;
total_value: number;
}
export interface NationalStats {
total_authorities: number;
total_suppliers: number;
total_da: number;
total_contracts: number;
total_initiere: number;
total_all: number;
total_value: number;
uats_with_data: number;
unmatched_cuis: number;
}
export interface CountyStats {
county: string;
uat_count: number;
total_contracts: number;
total_value: number;
}
export interface FeedItem {
id: number;
type: string;
name: string;
value: number;
date: string;
authority_name: string;
authority_county: string;
supplier_name: string | null;
cpv_code: string;
cpv_name: string;
seap_url: string | null;
}
/** Choropleth data: value per UAT */
export async function getChoroplethData(year?: number): Promise<UatStats[]> {
if (year) {
const result = await query<UatStats>(`
SELECT
u.siruta, u.name AS uat_name, u.county,
COUNT(*) FILTER (WHERE a.type = 'da')::int AS da_count,
COALESCE(SUM(a.awarded_value) FILTER (WHERE a.type = 'da'), 0)::numeric AS da_total_value,
COUNT(*) FILTER (WHERE a.type IN ('contract','atribuire_fara'))::int AS notice_count,
COALESCE(SUM(a.awarded_value) FILTER (WHERE a.type IN ('contract','atribuire_fara')), 0)::numeric AS notice_total_value,
COUNT(*)::int AS total_contracts,
COALESCE(SUM(COALESCE(a.awarded_value, a.estimated_value)), 0)::numeric AS total_value
FROM public."GisUat" u
JOIN seap.announcements a ON a.authority_siruta = u.siruta
WHERE EXTRACT(YEAR FROM COALESCE(a.publication_date, a.contract_date::timestamptz)) = $1
GROUP BY u.siruta, u.name, u.county
HAVING COUNT(*) > 0
`, [year]);
return result.rows;
}
const result = await query<UatStats>(
'SELECT * FROM seap.uat_procurement_stats WHERE total_contracts > 0'
);
return result.rows;
}
export type ChoroplethMetric = 'value' | 'direct' | 'hhi' | 'topshare' | 'q4spike';
export interface UatMetric {
siruta: string;
metric_value: number;
total_contracts: number;
total_value: number;
distinct_suppliers: number;
}
export interface FlowEdge {
supplier_siruta: string;
supplier_name: string;
supplier_lng: number;
supplier_lat: number;
contracts: number;
total_value: number;
last_contract: string | null;
}
export interface FlowGraph {
authority: { siruta: string; name: string; lng: number; lat: number } | null;
flows: FlowEdge[];
}
/** Top supplier flows for an authority UAT — for /harta flow lines overlay */
export async function getUatFlows(siruta: string, limit = 10): Promise<FlowGraph> {
const authResult = await query<any>(`
SELECT
siruta,
name,
ST_X(ST_Centroid(ST_Transform(geom, 4326)))::float8 AS lng,
ST_Y(ST_Centroid(ST_Transform(geom, 4326)))::float8 AS lat
FROM public."GisUat"
WHERE siruta = $1
`, [siruta]);
const authority = authResult.rows[0] || null;
const flowsResult = await query<any>(`
SELECT
a.supplier_siruta,
MIN(a.supplier_name) AS supplier_name,
ST_X(ST_Centroid(ST_Transform(gu.geom, 4326)))::float8 AS supplier_lng,
ST_Y(ST_Centroid(ST_Transform(gu.geom, 4326)))::float8 AS supplier_lat,
COUNT(*)::int AS contracts,
COALESCE(SUM(a.awarded_value), 0)::numeric AS total_value,
MAX(a.publication_date)::text AS last_contract
FROM seap.announcements a
JOIN public."GisUat" gu ON gu.siruta = a.supplier_siruta
WHERE a.authority_siruta = $1
AND a.supplier_siruta IS NOT NULL
AND a.supplier_siruta != $1
GROUP BY a.supplier_siruta, gu.geom
ORDER BY total_value DESC NULLS LAST
LIMIT $2
`, [siruta, limit]);
return {
authority,
flows: flowsResult.rows,
};
}
/** Per-UAT metric for /harta v2 dual-mode choropleth */
export async function getUatMetric(metric: ChoroplethMetric): Promise<UatMetric[]> {
const minContracts = (metric === 'value') ? 0 : 5; // Filter sparse UATs for risk indicators
const expr = (() => {
switch (metric) {
case 'value': return 'total_value';
case 'direct': return 'direct_pct';
case 'hhi': return 'hhi_suppliers';
case 'topshare': return 'top_supplier_share';
case 'q4spike': return 'COALESCE(q4_spike, 0)';
default: return 'total_value';
}
})();
const result = await query<UatMetric>(`
SELECT
siruta,
${expr}::numeric AS metric_value,
total_contracts,
total_value,
distinct_suppliers
FROM seap.uat_kpi
WHERE total_contracts >= $1
`, [minContracts]);
return result.rows;
}
/** National aggregate stats */
export async function getNationalStats(): Promise<NationalStats> {
const result = await query<NationalStats>(`
SELECT
(SELECT COUNT(DISTINCT authority_cui) FROM seap.announcements WHERE authority_cui IS NOT NULL)::int AS total_authorities,
(SELECT COUNT(DISTINCT supplier_cui) FROM seap.announcements WHERE supplier_cui IS NOT NULL)::int AS total_suppliers,
(SELECT COUNT(*) FROM seap.announcements WHERE type = 'da')::int AS total_da,
(SELECT COUNT(*) FROM seap.announcements WHERE type IN ('contract','atribuire_fara'))::int AS total_contracts,
(SELECT COUNT(*) FROM seap.announcements WHERE type = 'initiere')::int AS total_initiere,
(SELECT COUNT(*) FROM seap.announcements)::int AS total_all,
(SELECT COALESCE(SUM(COALESCE(awarded_value, estimated_value)), 0) FROM seap.announcements)::numeric AS total_value,
(SELECT COUNT(*) FROM seap.uat_procurement_stats WHERE total_contracts > 0)::int AS uats_with_data,
(SELECT COUNT(DISTINCT authority_cui) FROM seap.announcements WHERE authority_cui IS NOT NULL AND authority_siruta IS NULL)::int AS unmatched_cuis
`);
return result.rows[0];
}
/** Stats per county */
export async function getCountyStats(): Promise<CountyStats[]> {
const result = await query<CountyStats>(`
SELECT county,
COUNT(siruta)::int AS uat_count,
SUM(total_contracts)::int AS total_contracts,
SUM(total_value)::numeric AS total_value
FROM seap.uat_procurement_stats
WHERE total_contracts > 0
GROUP BY county
ORDER BY total_value DESC
`);
return result.rows;
}
/** Detail for a specific UAT */
export async function getUatDetail(siruta: string) {
const [stats, topSuppliers, recent, byType] = await Promise.all([
query('SELECT * FROM seap.uat_procurement_stats WHERE siruta = $1', [siruta]),
query(`
SELECT a.supplier_cui, a.supplier_name,
COUNT(*)::int AS contract_count,
SUM(COALESCE(a.awarded_value, 0))::numeric AS total_value
FROM seap.announcements a
WHERE a.authority_siruta = $1 AND a.supplier_name IS NOT NULL
GROUP BY a.supplier_cui, a.supplier_name
ORDER BY total_value DESC
LIMIT 10
`, [siruta]),
query(`
SELECT a.id, a.type, a.title AS name,
COALESCE(a.awarded_value, a.estimated_value) AS value,
COALESCE(a.publication_date, a.contract_date::timestamptz) AS date,
a.authority_name, a.supplier_name,
a.cpv_code, a.cpv_name, a.seap_url
FROM seap.announcements a
WHERE a.authority_siruta = $1
ORDER BY COALESCE(a.publication_date, a.contract_date::timestamptz) DESC
LIMIT 20
`, [siruta]),
query(`
SELECT a.type, COUNT(*)::int AS count,
SUM(COALESCE(a.awarded_value, a.estimated_value))::numeric AS value
FROM seap.announcements a
WHERE a.authority_siruta = $1
GROUP BY a.type ORDER BY value DESC
`, [siruta]),
]);
return {
stats: stats.rows[0] || null,
topSuppliers: topSuppliers.rows,
recentAcquisitions: recent.rows,
byType: byType.rows,
};
}
/** Live feed */
export async function getFeed(limit = 20): Promise<FeedItem[]> {
const result = await query<FeedItem>(`
SELECT a.id, a.type, a.title AS name,
COALESCE(a.awarded_value, a.estimated_value) AS value,
COALESCE(a.publication_date, a.contract_date::timestamptz) AS date,
a.authority_name, cl.county AS authority_county,
a.supplier_name, a.cpv_code, a.cpv_name, a.seap_url
FROM seap.announcements a
LEFT JOIN seap.cui_location cl ON cl.cui = a.authority_cui
ORDER BY COALESCE(a.publication_date, a.contract_date::timestamptz) DESC
LIMIT $1
`, [limit]);
return result.rows;
}
/** Single announcement with full details */
export async function getAnnouncementDetail(id: number) {
const result = await query(`
SELECT
a.*,
cl.city AS authority_city,
cl.county AS authority_county_name,
scl.city AS supplier_city,
scl.county AS supplier_county_name
FROM seap.announcements a
LEFT JOIN seap.cui_location cl ON cl.cui = a.authority_cui
LEFT JOIN seap.cui_location scl ON scl.cui = a.supplier_cui
WHERE a.id = $1
`, [id]);
return result.rows[0] || null;
}
+630
View File
@@ -0,0 +1,630 @@
import { query } from './db.js';
export interface SearchFilters {
q?: string; // free text
cpv_division?: string; // '45000000'
county_code?: string; // 'RO113'
county?: string; // 'Cluj' (alternative to county_code)
type?: string; // 'ca_notice', 'c_notice', etc.
source?: string; // 'wsp_canotice' etc.
value_min?: number;
value_max?: number;
date_from?: string; // 'YYYY-MM-DD'
date_to?: string;
procedure_type?: string;
procedure_state?: string; // 'Publicat', 'In desfasurare', 'Atribuita', 'Anulata', 'Suspended'
authority_cui?: string;
supplier_cui?: string;
has_lots?: boolean;
framework_agreement?: boolean;
only_with_value?: boolean;
eu_funded?: boolean; // a.eu_funded matches 'da|true|1'
sort?: 'date_desc' | 'date_asc' | 'value_desc' | 'value_asc';
limit?: number;
offset?: number;
}
export interface SearchResult {
id: number;
ref_number: string;
type: string;
title: string | null;
authority_name: string | null;
authority_cui: string | null;
authority_county: string | null;
supplier_name: string | null;
supplier_cui: string | null;
cpv_code: string | null;
cpv_name_ro: string | null;
cpv_division: string | null;
cpv_division_name: string | null;
cpv_emoji: string | null;
awarded_value: number | null;
estimated_value: number | null;
currency: string | null;
publication_date: string | null;
procedure_type: string | null;
procedure_state: string | null;
has_lots: boolean | null;
lots_count: number | null;
source: string;
seap_url: string | null;
risk_flags: { code: string; severity: string; label: string }[] | null;
}
export interface SearchResponse {
total: number;
results: SearchResult[];
facets: {
cpv_divisions: { code: string; name: string; emoji: string | null; count: number }[];
counties: { code: string; name: string; count: number }[];
types: { type: string; count: number }[];
procedures: { procedure: string; count: number }[];
states: { state: string; count: number }[];
value_buckets: { bucket: string; count: number; range: [number, number | null] }[];
};
sum_awarded: number;
}
export interface ResultCoord {
id: number;
role: 'supplier' | 'authority';
cui: string;
name: string;
lat: number;
lng: number;
}
function buildWhereClause(filters: SearchFilters, params: any[]): string {
const conds: string[] = [];
if (filters.q && filters.q.trim()) {
const q = filters.q.trim();
// Branch on q shape to keep the planner on a single fast index:
// - CUI-shaped ("12345678" or "RO12345678"): use authority_cui / supplier_cui
// equality (~1ms via idx_ann_auth_cui + idx_ann_sup_cui BitmapOr).
// - Ref-number-shaped (e.g. "CAN1116632", "DA37440345"): ILIKE on ref_number
// (no trgm index, but most refs are short ALL-CAPS+digits → cheap prefix).
// - Free text (default): tsvector GIN match only (~5250ms on 781K rows).
//
// Mixing all four with OR (previous behavior) forced parallel seq scans of
// the full 761MB table — ~1.2s per branch × 7 branches in Promise.all = 4.6s
// wall time on /cauta. With the branching the same workload is
// index-bound and stays well under 1s.
const cuiMatch = /^(?:RO\s*)?(\d{2,10})$/i.exec(q);
const refLike = /^[A-Z]{1,4}\s*\d+$/i.test(q) || /-/.test(q);
if (cuiMatch) {
params.push(cuiMatch[1]);
conds.push(`(a.authority_cui = $${params.length} OR a.supplier_cui = $${params.length})`);
} else if (refLike) {
params.push(q);
const tsIdx = params.length;
params.push(`%${q}%`);
const ilIdx = params.length;
// Free-text-y but still ref-like: OR tsvector with ref ILIKE. ILIKE on
// ref_number is fast enough on its own (~1s seq scan) but combining with
// tsvector lets us catch both refs and text mentions in one shot. The
// planner uses idx_ann_search_tsv + idx_ann_type_ref_number bitmap-OR.
conds.push(`(
a.search_tsv @@ plainto_tsquery('simple', seap.immutable_unaccent($${tsIdx}))
OR a.ref_number ILIKE $${ilIdx}
)`);
} else {
params.push(q);
conds.push(
`a.search_tsv @@ plainto_tsquery('simple', seap.immutable_unaccent($${params.length}))`,
);
}
}
if (filters.cpv_division) {
// Level-1 codes (root divisions) end in '000000' — the cheaper
// `a.cpv_division = $N` index lookup matches them exactly.
// Deeper codes (level 2-4) require prefix-matching against the
// raw `cpv_code` column (which carries the `-X` checksum suffix).
const code = filters.cpv_division.trim();
if (/^\d{2}0{6}$/.test(code)) {
params.push(code);
conds.push(`a.cpv_division = $${params.length}`);
} else if (/^\d{8}$/.test(code)) {
// Strip trailing zeros so `45100000` matches `45110000-7`, `45112340-9`, etc.
// We keep at least the 2-digit division so single-zero codes still narrow.
const trimmed = code.replace(/0+$/, '') || code.substring(0, 2);
params.push(`${trimmed}%`);
conds.push(`a.cpv_code LIKE $${params.length}`);
} else {
// Unknown shape — fall back to original equality to be safe.
params.push(code);
conds.push(`a.cpv_division = $${params.length}`);
}
}
if (filters.county_code) {
params.push(filters.county_code);
conds.push(`a.county_code = $${params.length}`);
} else if (filters.county) {
params.push(filters.county);
conds.push(`EXISTS (SELECT 1 FROM seap.cui_location cl WHERE cl.cui = a.authority_cui AND cl.county = $${params.length})`);
}
if (filters.type) {
params.push(filters.type);
conds.push(`a.type = $${params.length}`);
}
if (filters.source) {
params.push(filters.source);
conds.push(`a.source = $${params.length}`);
}
if (filters.value_min !== undefined && filters.value_min !== null) {
params.push(filters.value_min);
conds.push(`a.awarded_value >= $${params.length}`);
}
if (filters.value_max !== undefined && filters.value_max !== null) {
params.push(filters.value_max);
conds.push(`a.awarded_value <= $${params.length}`);
}
if (filters.date_from) {
params.push(filters.date_from);
conds.push(`a.publication_date >= $${params.length}::timestamptz`);
}
if (filters.date_to) {
params.push(filters.date_to);
conds.push(`a.publication_date <= $${params.length}::timestamptz`);
}
if (filters.procedure_type) {
params.push(filters.procedure_type);
conds.push(`a.procedure_type = $${params.length}`);
}
if (filters.procedure_state) {
params.push(filters.procedure_state);
conds.push(`a.procedure_state = $${params.length}`);
}
if (filters.eu_funded) {
conds.push(`(a.eu_funded ILIKE 'da' OR a.eu_funded ILIKE 'true' OR a.eu_funded = '1' OR a.eu_funded ILIKE 'yes')`);
}
if (filters.authority_cui) {
params.push(filters.authority_cui);
conds.push(`a.authority_cui = $${params.length}`);
}
if (filters.supplier_cui) {
params.push(filters.supplier_cui);
conds.push(`(a.supplier_cui = $${params.length} OR a.supplier_cui = 'RO ' || $${params.length} OR a.supplier_cui = 'RO' || $${params.length})`);
}
if (filters.has_lots !== undefined) {
conds.push(`a.contract_has_lots = ${filters.has_lots ? 'TRUE' : 'FALSE'}`);
}
if (filters.framework_agreement !== undefined) {
conds.push(`a.framework_agreement = ${filters.framework_agreement ? 'TRUE' : 'FALSE'}`);
}
if (filters.only_with_value) {
conds.push(`a.awarded_value IS NOT NULL AND a.awarded_value > 0`);
}
return conds.length ? 'WHERE ' + conds.join(' AND ') : '';
}
function orderBy(sort: SearchFilters['sort']): string {
switch (sort) {
case 'date_asc': return 'ORDER BY a.publication_date ASC NULLS LAST';
case 'value_desc': return 'ORDER BY a.awarded_value DESC NULLS LAST';
case 'value_asc': return 'ORDER BY a.awarded_value ASC NULLS LAST';
case 'date_desc':
default: return 'ORDER BY a.publication_date DESC NULLS LAST';
}
}
/**
* True when no filter is set — only sort and paging may vary.
* Used to short-circuit the 6 facet aggregates and totals queries to a
* pre-computed snapshot table (`public_kpi.cauta_default_*`). Sort doesn't
* affect facet counts or totals, so we allow any sort here; the main
* results query runs live with whichever ORDER BY the user picked (the
* matching DESC NULLS LAST indexes in sql/045 + sql/047 keep it fast).
* Pagination IS excluded — page 2+ means the user is exploring, not on
* the cold landing.
*/
function isDefaultBrowse(filters: SearchFilters): boolean {
return !filters.q
&& !filters.cpv_division
&& !filters.county_code
&& !filters.county
&& !filters.type
&& !filters.source
&& filters.value_min == null
&& filters.value_max == null
&& !filters.date_from
&& !filters.date_to
&& !filters.procedure_type
&& !filters.procedure_state
&& !filters.authority_cui
&& !filters.supplier_cui
&& filters.has_lots == null
&& filters.framework_agreement == null
&& filters.only_with_value == null
&& filters.eu_funded == null
&& (!filters.offset || filters.offset === 0);
}
async function fetchDefaultFacetsAndTotals(): Promise<{
total: number;
sum_awarded: number;
facets: SearchResponse['facets'];
}> {
const [totals, facets] = await Promise.all([
query<{ total: string; sum_awarded: string }>(
`SELECT total::text, sum_awarded::text
FROM public_kpi.cauta_default_totals WHERE id = 1`
),
query<{ facet_name: string; key: string; label: string | null; emoji: string | null; count: string }>(
`SELECT facet_name, key, label, emoji, count::text
FROM public_kpi.cauta_default_facets ORDER BY facet_name, sort_order`
),
]);
const t = totals.rows[0];
const total = Number(t?.total ?? 0);
const sum_awarded = Number(t?.sum_awarded ?? 0);
const grouped: Record<string, typeof facets.rows> = {};
for (const row of facets.rows) {
(grouped[row.facet_name] = grouped[row.facet_name] || []).push(row);
}
const cpv_divisions = (grouped.cpv || []).map(r => ({
code: r.key, name: r.label || '', emoji: r.emoji, count: Number(r.count),
}));
const counties = (grouped.county || []).map(r => ({
code: r.key, name: r.label || r.key, count: Number(r.count),
}));
const types = (grouped.type || []).map(r => ({
type: r.key, count: Number(r.count),
}));
const procedures = (grouped.procedure || []).map(r => ({
procedure: r.key, count: Number(r.count),
}));
const states = (grouped.state || []).map(r => ({
state: r.key, count: Number(r.count),
}));
// Reuse the value-bucket schema from search() (range tuples)
const valueBucketsTemplate: SearchResponse['facets']['value_buckets'] = [
{ bucket: 'sub 100K', range: [0, 100_000], count: 0 },
{ bucket: '100K 1M', range: [100_000, 1_000_000], count: 0 },
{ bucket: '1M 10M', range: [1_000_000, 10_000_000], count: 0 },
{ bucket: '10M 100M', range: [10_000_000, 100_000_000], count: 0 },
{ bucket: 'peste 100M', range: [100_000_000, null], count: 0 },
{ bucket: 'fără valoare', range: [0, 0], count: 0 },
];
for (const row of grouped.value || []) {
const b = valueBucketsTemplate.find(b => b.bucket === row.key);
if (b) b.count = Number(row.count);
}
return {
total, sum_awarded,
facets: {
cpv_divisions,
counties,
types,
procedures,
states,
value_buckets: valueBucketsTemplate,
},
};
}
export async function search(filters: SearchFilters): Promise<SearchResponse> {
const limit = Math.min(filters.limit ?? 30, 100);
const offset = filters.offset ?? 0;
// Main results query
const resultsParams: any[] = [];
const resultsWhere = buildWhereClause(filters, resultsParams);
resultsParams.push(limit, offset);
const resultsQ = `
SELECT
a.id, a.ref_number, a.type, a.title,
a.authority_name, a.authority_cui,
cl.county AS authority_county,
a.supplier_name, a.supplier_cui,
a.cpv_code, a.cpv_name_ro, a.cpv_division,
c.name_ro AS cpv_division_name, c.emoji AS cpv_emoji,
a.awarded_value, a.estimated_value, a.currency,
a.publication_date, a.procedure_type, a.procedure_state,
a.contract_has_lots AS has_lots, a.lots_count,
a.source, a.seap_url,
a.risk_flags
FROM seap.announcements a
LEFT JOIN seap.cpv_codes c ON c.code = a.cpv_division
LEFT JOIN seap.cui_location cl ON cl.cui = a.authority_cui
${resultsWhere}
${orderBy(filters.sort)}
LIMIT $${resultsParams.length - 1} OFFSET $${resultsParams.length}
`;
const totalParams: any[] = [];
const totalWhere = buildWhereClause(filters, totalParams);
const totalQ = `
SELECT count(*)::int AS total,
COALESCE(sum(a.awarded_value), 0)::numeric AS sum_awarded
FROM seap.announcements a
${totalWhere}
`;
// Facet queries — exclude self-filter to allow toggling
const facetCpvParams: any[] = [];
const facetCpvWhere = buildWhereClause({ ...filters, cpv_division: undefined }, facetCpvParams);
const facetCpvQ = `
SELECT a.cpv_division AS code, c.name_ro AS name, c.emoji,
count(*)::int AS count
FROM seap.announcements a
LEFT JOIN seap.cpv_codes c ON c.code = a.cpv_division
${facetCpvWhere}
${facetCpvWhere ? 'AND' : 'WHERE'} a.cpv_division IS NOT NULL
GROUP BY a.cpv_division, c.name_ro, c.emoji
ORDER BY count DESC LIMIT 15
`;
const facetCountyParams: any[] = [];
const facetCountyWhere = buildWhereClause({ ...filters, county_code: undefined, county: undefined }, facetCountyParams);
const facetCountyQ = `
SELECT a.county_code AS code, a.county_code AS name, count(*)::int AS count
FROM seap.announcements a
${facetCountyWhere}
${facetCountyWhere ? 'AND' : 'WHERE'} a.county_code IS NOT NULL
GROUP BY a.county_code
ORDER BY count DESC LIMIT 20
`;
const facetTypeParams: any[] = [];
const facetTypeWhere = buildWhereClause({ ...filters, type: undefined }, facetTypeParams);
const facetTypeQ = `
SELECT a.type, count(*)::int AS count
FROM seap.announcements a
${facetTypeWhere}
GROUP BY a.type
ORDER BY count DESC LIMIT 12
`;
const facetProcParams: any[] = [];
const facetProcWhere = buildWhereClause({ ...filters, procedure_type: undefined }, facetProcParams);
const facetProcQ = `
SELECT a.procedure_type AS procedure, count(*)::int AS count
FROM seap.announcements a
${facetProcWhere}
${facetProcWhere ? 'AND' : 'WHERE'} a.procedure_type IS NOT NULL
GROUP BY a.procedure_type
ORDER BY count DESC LIMIT 10
`;
const facetStateParams: any[] = [];
const facetStateWhere = buildWhereClause({ ...filters, procedure_state: undefined }, facetStateParams);
const facetStateQ = `
SELECT a.procedure_state AS state, count(*)::int AS count
FROM seap.announcements a
${facetStateWhere}
${facetStateWhere ? 'AND' : 'WHERE'} a.procedure_state IS NOT NULL
GROUP BY a.procedure_state
ORDER BY count DESC LIMIT 8
`;
const facetValueParams: any[] = [];
const facetValueWhere = buildWhereClause(
{ ...filters, value_min: undefined, value_max: undefined },
facetValueParams,
);
const facetValueQ = `
SELECT
CASE
WHEN a.awarded_value IS NULL OR a.awarded_value = 0 THEN 'fără valoare'
WHEN a.awarded_value < 100000 THEN 'sub 100K'
WHEN a.awarded_value < 1000000 THEN '100K 1M'
WHEN a.awarded_value < 10000000 THEN '1M 10M'
WHEN a.awarded_value < 100000000 THEN '10M 100M'
ELSE 'peste 100M'
END AS bucket,
count(*)::int AS count
FROM seap.announcements a
${facetValueWhere}
GROUP BY bucket
ORDER BY count DESC
`;
// Default browse short-circuit: skip 6 facet aggregates + totals — read
// snapshot table instead (refreshed nightly via refresh-mvs.sh).
// Main results query still runs live so the latest data shows up.
if (isDefaultBrowse(filters)) {
const [results, defaults] = await Promise.all([
query<SearchResult>(resultsQ, resultsParams),
fetchDefaultFacetsAndTotals(),
]);
return {
total: defaults.total,
sum_awarded: defaults.sum_awarded,
results: results.rows,
facets: defaults.facets,
};
}
const [results, totals, fCpv, fCounty, fType, fProc, fState, fValue] = await Promise.all([
query<SearchResult>(resultsQ, resultsParams),
query<{ total: number; sum_awarded: number }>(totalQ, totalParams),
query<{ code: string; name: string; emoji: string | null; count: number }>(facetCpvQ, facetCpvParams),
query<{ code: string; name: string; count: number }>(facetCountyQ, facetCountyParams),
query<{ type: string; count: number }>(facetTypeQ, facetTypeParams),
query<{ procedure: string; count: number }>(facetProcQ, facetProcParams),
query<{ state: string; count: number }>(facetStateQ, facetStateParams),
query<{ bucket: string; count: number }>(facetValueQ, facetValueParams),
]);
const valueBuckets: SearchResponse['facets']['value_buckets'] = [
{ bucket: 'sub 100K', range: [0, 100_000], count: 0 },
{ bucket: '100K 1M', range: [100_000, 1_000_000], count: 0 },
{ bucket: '1M 10M', range: [1_000_000, 10_000_000], count: 0 },
{ bucket: '10M 100M', range: [10_000_000, 100_000_000], count: 0 },
{ bucket: 'peste 100M', range: [100_000_000, null], count: 0 },
{ bucket: 'fără valoare', range: [0, 0], count: 0 },
];
for (const row of fValue.rows) {
const b = valueBuckets.find(b => b.bucket === row.bucket);
if (b) b.count = row.count;
}
return {
total: totals.rows[0]?.total ?? 0,
sum_awarded: Number(totals.rows[0]?.sum_awarded ?? 0),
results: results.rows,
facets: {
cpv_divisions: fCpv.rows,
counties: fCounty.rows,
types: fType.rows,
procedures: fProc.rows,
states: fState.rows,
value_buckets: valueBuckets,
},
};
}
/**
* Resolve lat/lng for the supplier and authority of each result.
* Used by the map column on the search page. One query, indexed lookups.
*/
export async function getResultCoords(ids: number[]): Promise<ResultCoord[]> {
if (ids.length === 0) return [];
const q = `
WITH ids AS (SELECT unnest($1::bigint[]) AS id),
base AS (
SELECT
a.id,
a.supplier_cui,
a.supplier_name,
a.authority_cui,
a.authority_name
FROM seap.announcements a
WHERE a.id = ANY($1::bigint[])
),
sup AS (
SELECT b.id,
regexp_replace(upper(coalesce(b.supplier_cui,'')), '(^RO)|\\s+', '', 'g') AS norm_cui,
b.supplier_name AS name
FROM base b
WHERE b.supplier_cui IS NOT NULL
),
aut AS (
SELECT b.id,
regexp_replace(upper(coalesce(b.authority_cui,'')), '(^RO)|\\s+', '', 'g') AS norm_cui,
b.authority_name AS name
FROM base b
WHERE b.authority_cui IS NOT NULL
)
SELECT s.id, 'supplier'::text AS role, s.norm_cui AS cui, s.name, e.lat, e.lng
FROM sup s
JOIN firms.entities e ON e.cui = s.norm_cui
WHERE e.lat IS NOT NULL AND e.lng IS NOT NULL AND s.norm_cui <> ''
UNION ALL
SELECT a.id, 'authority'::text AS role, a.norm_cui AS cui, a.name, e.lat, e.lng
FROM aut a
JOIN firms.entities e ON e.cui = a.norm_cui
WHERE e.lat IS NOT NULL AND e.lng IS NOT NULL AND a.norm_cui <> ''
`;
const r = await query<ResultCoord>(q, [ids]);
return r.rows.map(row => ({
id: Number(row.id),
role: row.role,
cui: row.cui,
name: row.name,
lat: Number(row.lat),
lng: Number(row.lng),
}));
}
// Helper: parse URL search params into typed filters
export function parseSearchParams(url: URL): SearchFilters {
const sp = url.searchParams;
const f: SearchFilters = {};
if (sp.get('q')) f.q = sp.get('q')!;
if (sp.get('cpv')) f.cpv_division = sp.get('cpv')!;
if (sp.get('county_code')) f.county_code = sp.get('county_code')!;
if (sp.get('county')) f.county = sp.get('county')!;
if (sp.get('type')) f.type = sp.get('type')!;
if (sp.get('source')) f.source = sp.get('source')!;
if (sp.get('value_min')) f.value_min = parseFloat(sp.get('value_min')!);
if (sp.get('value_max')) f.value_max = parseFloat(sp.get('value_max')!);
if (sp.get('date_from')) f.date_from = sp.get('date_from')!;
if (sp.get('date_to')) f.date_to = sp.get('date_to')!;
if (sp.get('procedure_type')) f.procedure_type = sp.get('procedure_type')!;
if (sp.get('procedure_state')) f.procedure_state = sp.get('procedure_state')!;
if (sp.get('authority_cui')) f.authority_cui = sp.get('authority_cui')!;
if (sp.get('supplier_cui')) f.supplier_cui = sp.get('supplier_cui')!;
if (sp.get('has_lots') === '1') f.has_lots = true;
if (sp.get('framework') === '1') f.framework_agreement = true;
if (sp.get('with_value') === '1') f.only_with_value = true;
if (sp.get('eu_funded') === '1') f.eu_funded = true;
if (sp.get('sort')) f.sort = sp.get('sort') as any;
if (sp.get('limit')) f.limit = parseInt(sp.get('limit')!);
if (sp.get('offset')) f.offset = parseInt(sp.get('offset')!);
return f;
}
// Build URL with filters (omit empty)
export function buildSearchUrl(base: string, filters: SearchFilters): string {
const sp = new URLSearchParams();
if (filters.q) sp.set('q', filters.q);
if (filters.cpv_division) sp.set('cpv', filters.cpv_division);
if (filters.county_code) sp.set('county_code', filters.county_code);
if (filters.county) sp.set('county', filters.county);
if (filters.type) sp.set('type', filters.type);
if (filters.value_min !== undefined) sp.set('value_min', String(filters.value_min));
if (filters.value_max !== undefined) sp.set('value_max', String(filters.value_max));
if (filters.date_from) sp.set('date_from', filters.date_from);
if (filters.date_to) sp.set('date_to', filters.date_to);
if (filters.procedure_type) sp.set('procedure_type', filters.procedure_type);
if (filters.procedure_state) sp.set('procedure_state', filters.procedure_state);
if (filters.authority_cui) sp.set('authority_cui', filters.authority_cui);
if (filters.supplier_cui) sp.set('supplier_cui', filters.supplier_cui);
if (filters.has_lots) sp.set('has_lots', '1');
if (filters.framework_agreement) sp.set('framework', '1');
if (filters.only_with_value) sp.set('with_value', '1');
if (filters.eu_funded) sp.set('eu_funded', '1');
// NOTE: offset and limit are intentionally NOT serialized here so that
// changing a filter via withFilter() resets pagination. Pagination links
// must use buildSearchUrlWithPagination() instead.
if (filters.sort) sp.set('sort', filters.sort);
const s = sp.toString();
return s ? `${base}?${s}` : base;
}
// Build URL preserving pagination (used by Anterior/Următor links).
export function buildSearchUrlWithPagination(base: string, filters: SearchFilters): string {
const url = buildSearchUrl(base, filters);
const sp = new URLSearchParams();
if (filters.offset !== undefined && filters.offset !== 0) sp.set('offset', String(filters.offset));
if (filters.limit !== undefined && filters.limit !== 30) sp.set('limit', String(filters.limit));
const extra = sp.toString();
if (!extra) return url;
return url.includes('?') ? `${url}&${extra}` : `${url}?${extra}`;
}
+15
View File
@@ -0,0 +1,15 @@
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = import.meta.env.PUBLIC_SUPABASE_URL || process.env.PUBLIC_SUPABASE_URL || '';
const supabaseAnonKey = import.meta.env.PUBLIC_SUPABASE_ANON_KEY || process.env.PUBLIC_SUPABASE_ANON_KEY || '';
export const supabaseEnabled = !!(supabaseUrl && supabaseAnonKey);
export const supabase = supabaseEnabled
? createClient(supabaseUrl, supabaseAnonKey)
: null;
export function getSupabaseClient() {
if (!supabase) throw new Error('Supabase not configured');
return supabase;
}
+326
View File
@@ -0,0 +1,326 @@
import { query } from './db.js';
// ─── SANKEY ────────────────────────────────────────────────────────────────
export interface SankeyNode {
id: string; // unique id, e.g. "county:CJ" / "auth:Primarie" / "supplier:RO123"
label: string; // display label
level: 0 | 1 | 2; // 0=county, 1=auth_type, 2=supplier
meta?: {
code?: string; // county code or supplier cui
raw?: string; // raw value for navigation
};
}
export interface SankeyLink {
source: string;
target: string;
value: number;
}
export interface SankeyDataset {
nodes: SankeyNode[];
links: SankeyLink[];
totalValue: number;
topCounties: Array<{ code: string; value: number }>;
}
interface FlowRow {
county: string;
auth_type: string;
supplier: string;
supplier_cui: string | null;
value: string;
}
/**
* Sankey: Județ → Tip autoritate → Top firmă (ultimele N luni)
*
* Strategy: select top 8 counties, top 6 auth types, top 30 suppliers
* (within those filters); rest pooled into "Alții".
*/
export async function getSankeyFlows(months = 12): Promise<SankeyDataset> {
const result = await query<FlowRow>(`
SELECT
coalesce(a.county_code, '?') AS county,
coalesce(a.authority_type, 'Necategorizat') AS auth_type,
coalesce(a.supplier_name, '?') AS supplier,
a.supplier_cui AS supplier_cui,
sum(a.awarded_value)::numeric AS value
FROM seap.announcements a
WHERE a.awarded_value IS NOT NULL
AND a.publication_date >= now() - ($1 || ' months')::interval
AND a.county_code IS NOT NULL
AND a.supplier_name IS NOT NULL
GROUP BY 1, 2, 3, 4
HAVING sum(a.awarded_value) > 1000000
ORDER BY value DESC
LIMIT 600
`, [String(months)]);
const rows = result.rows.map(r => ({
county: r.county,
authType: r.auth_type,
supplier: r.supplier,
supplierCui: (r.supplier_cui || '').replace(/^RO\s*/i, '').trim() || null,
value: Number(r.value),
}));
// Top-N truncation
const TOP_COUNTIES = 8;
const TOP_AUTH = 6;
const TOP_SUPPLIERS = 24;
const sumBy = <T,>(arr: T[], k: (x: T) => string, v: (x: T) => number) => {
const m = new Map<string, number>();
for (const x of arr) m.set(k(x), (m.get(k(x)) || 0) + v(x));
return m;
};
const countyTotals = sumBy(rows, r => r.county, r => r.value);
const authTotals = sumBy(rows, r => r.authType, r => r.value);
const supplierKey = (r: typeof rows[0]) => r.supplierCui ? `cui:${r.supplierCui}` : `name:${r.supplier}`;
const supplierTotals = sumBy(rows, supplierKey, r => r.value);
const topCounties = [...countyTotals.entries()].sort((a, b) => b[1] - a[1]).slice(0, TOP_COUNTIES).map(([k]) => k);
const topAuth = [...authTotals.entries()].sort((a, b) => b[1] - a[1]).slice(0, TOP_AUTH).map(([k]) => k);
const topSuppliers = [...supplierTotals.entries()].sort((a, b) => b[1] - a[1]).slice(0, TOP_SUPPLIERS).map(([k]) => k);
const setC = new Set(topCounties);
const setA = new Set(topAuth);
const setS = new Set(topSuppliers);
// Map rows to truncated buckets
type Bucket = { county: string; authType: string; supplierKey: string; supplierLabel: string; supplierCui: string | null; value: number };
const bucketed: Bucket[] = rows.map(r => {
const sk = supplierKey(r);
return {
county: setC.has(r.county) ? r.county : 'AL', // "Alții" judet
authType: setA.has(r.authType) ? r.authType : 'Alte autorități',
supplierKey: setS.has(sk) ? sk : 'name:Alți furnizori',
supplierLabel: setS.has(sk) ? r.supplier : 'Alți furnizori',
supplierCui: setS.has(sk) ? r.supplierCui : null,
value: r.value,
};
});
// Build nodes
const nodes: SankeyNode[] = [];
const seen = new Set<string>();
// counties (level 0)
const countyOrder = [...new Set(bucketed.map(b => b.county))]
.sort((a, b) => (countyTotals.get(b) || 0) - (countyTotals.get(a) || 0));
for (const c of countyOrder) {
const id = `county:${c}`;
if (!seen.has(id)) {
nodes.push({ id, label: c === 'AL' ? 'ALȚI JUDEȚE' : c, level: 0, meta: { code: c, raw: c } });
seen.add(id);
}
}
// auth types (level 1)
const authOrder = [...new Set(bucketed.map(b => b.authType))]
.sort((a, b) => (authTotals.get(b) || 0) - (authTotals.get(a) || 0));
for (const a of authOrder) {
const id = `auth:${a}`;
if (!seen.has(id)) {
nodes.push({ id, label: a, level: 1, meta: { raw: a } });
seen.add(id);
}
}
// suppliers (level 2)
const supSeen = new Map<string, { label: string; cui: string | null }>();
for (const b of bucketed) {
if (!supSeen.has(b.supplierKey)) supSeen.set(b.supplierKey, { label: b.supplierLabel, cui: b.supplierCui });
}
const supplierOrder = [...supSeen.keys()]
.sort((a, b) => (supplierTotals.get(b) || 0) - (supplierTotals.get(a) || 0));
for (const sk of supplierOrder) {
const id = `supplier:${sk}`;
if (!seen.has(id)) {
const info = supSeen.get(sk)!;
nodes.push({ id, label: info.label, level: 2, meta: { code: info.cui || undefined, raw: sk } });
seen.add(id);
}
}
// Build links — aggregate again to consolidate buckets
const linkMap = new Map<string, number>();
const addLink = (s: string, t: string, v: number) => {
const k = `${s}>>>${t}`;
linkMap.set(k, (linkMap.get(k) || 0) + v);
};
for (const b of bucketed) {
addLink(`county:${b.county}`, `auth:${b.authType}`, b.value);
addLink(`auth:${b.authType}`, `supplier:${b.supplierKey}`, b.value);
}
const links: SankeyLink[] = [];
for (const [k, v] of linkMap) {
const [source, target] = k.split('>>>');
links.push({ source, target, value: v });
}
const totalValue = rows.reduce((a, b) => a + b.value, 0);
return {
nodes,
links,
totalValue,
topCounties: [...countyTotals.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map(([code, value]) => ({ code, value })),
};
}
// ─── TREEMAP ───────────────────────────────────────────────────────────────
export interface TreemapLeaf {
code: string;
name: string;
emoji: string | null;
value: number;
contracts: number;
}
export interface TreemapDataset {
leaves: TreemapLeaf[];
totalValue: number;
totalContracts: number;
}
interface CpvRow {
cpv_code: string;
name_ro: string | null;
emoji: string | null;
contracts: string;
value: string;
}
export async function getTreemapData(months = 12): Promise<TreemapDataset> {
const result = await query<CpvRow>(`
SELECT
a.cpv_division AS cpv_code,
c.name_ro,
c.emoji,
count(*)::int AS contracts,
sum(a.awarded_value)::numeric AS value
FROM seap.announcements a
LEFT JOIN seap.cpv_codes c ON c.code = a.cpv_division
WHERE a.publication_date >= now() - ($1 || ' months')::interval
AND a.awarded_value IS NOT NULL
AND a.cpv_division IS NOT NULL
GROUP BY a.cpv_division, c.name_ro, c.emoji
ORDER BY value DESC NULLS LAST
LIMIT 45
`, [String(months)]);
const leaves: TreemapLeaf[] = result.rows.map(r => ({
code: r.cpv_code,
name: r.name_ro || `CPV ${r.cpv_code}`,
emoji: r.emoji,
value: Number(r.value),
contracts: Number(r.contracts),
})).filter(l => l.value > 0);
const totalValue = leaves.reduce((a, b) => a + b.value, 0);
const totalContracts = leaves.reduce((a, b) => a + b.contracts, 0);
return { leaves, totalValue, totalContracts };
}
// ─── BAR RACE ─────────────────────────────────────────────────────────────
export interface BarRaceRow {
supplier: string;
cui: string | null;
value: number; // cumulative through this month
monthlyValue: number; // value added this month
rank: number;
}
export interface BarRaceDataset {
months: string[]; // ['2024-05','2024-06',...]
byMonth: Record<string, BarRaceRow[]>; // top 10 cumulative per month
topSuppliers: Array<{ name: string; cui: string | null; value: number }>;
}
interface MonthlyRow {
month: string;
supplier_name: string;
supplier_cui: string;
value: string;
}
export async function getBarRaceData(months = 12): Promise<BarRaceDataset> {
const result = await query<MonthlyRow>(`
SELECT
to_char(date_trunc('month', a.publication_date), 'YYYY-MM') AS month,
a.supplier_name,
a.supplier_cui,
sum(a.awarded_value)::numeric AS value
FROM seap.announcements a
WHERE a.publication_date >= now() - ($1 || ' months')::interval
AND a.awarded_value IS NOT NULL
AND a.supplier_cui IS NOT NULL
AND a.supplier_name IS NOT NULL
GROUP BY 1, 2, 3
ORDER BY 1, 4 DESC
`, [String(months)]);
const norm = (cui: string) => (cui || '').replace(/^RO\s*/i, '').trim();
// Build month list (sorted)
const monthSet = new Set<string>();
for (const r of result.rows) monthSet.add(r.month);
const monthList = [...monthSet].sort();
// Cumulative state per supplier
type State = { name: string; cui: string; cumulative: number; monthly: number };
const state = new Map<string, State>();
const monthlyAdded = new Map<string, Map<string, number>>(); // month -> cui -> value added
for (const r of result.rows) {
const cui = norm(r.supplier_cui);
if (!cui) continue;
if (!monthlyAdded.has(r.month)) monthlyAdded.set(r.month, new Map());
monthlyAdded.get(r.month)!.set(cui, (monthlyAdded.get(r.month)!.get(cui) || 0) + Number(r.value));
if (!state.has(cui)) state.set(cui, { name: r.supplier_name, cui, cumulative: 0, monthly: 0 });
}
const byMonth: Record<string, BarRaceRow[]> = {};
for (const m of monthList) {
// Reset monthly, apply this month's adds
for (const s of state.values()) s.monthly = 0;
const adds = monthlyAdded.get(m);
if (adds) {
for (const [cui, v] of adds) {
const s = state.get(cui)!;
s.cumulative += v;
s.monthly = v;
}
}
// Pick top 10 by cumulative
const top = [...state.values()]
.filter(s => s.cumulative > 0)
.sort((a, b) => b.cumulative - a.cumulative)
.slice(0, 10)
.map((s, i) => ({
supplier: s.name,
cui: s.cui,
value: s.cumulative,
monthlyValue: s.monthly,
rank: i + 1,
}));
byMonth[m] = top;
}
// Final top suppliers list (overall)
const topSuppliers = [...state.values()]
.sort((a, b) => b.cumulative - a.cumulative)
.slice(0, 10)
.map(s => ({ name: s.name, cui: s.cui, value: s.cumulative }));
return { months: monthList, byMonth, topSuppliers };
}