Files
vreau-digital/src/lib/ocds-mapper.ts
T
Claude VM a6c03a091e 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)
2026-05-13 00:10:32 +03:00

321 lines
11 KiB
TypeScript

/**
* 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);
}