Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions packages/db/src/competition-sql.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,10 @@ function readScript(dbPath: string, path: string): void {
const FIXTURE = `
INSERT INTO authorities (id, name, bulstat, type_group) VALUES
('auth:A', 'Институция А', '100000001', 'община'),
('auth:B', 'Институция Б', '100000002', 'община');
('auth:B', 'Институция Б', '100000019', 'община');
INSERT INTO bidders (id, name, bulstat, eik_normalized, eik_valid, kind) VALUES
('eik:X', 'Фирма Х', '200000001', '200000001', 1, 'company'),
('eik:Y', 'Фирма У', '200000002', '200000002', 1, 'company');
('eik:X', 'Фирма Х', '200000002', '200000002', 1, 'company'),
('eik:Y', 'Фирма У', '200000015', '200000015', 1, 'company');
INSERT INTO tenders (id, source_id, title, authority_id, cpv_code, procedure_type, status) VALUES
('t:A', 'UNP-A', 'Поръчка А', 'auth:A', '45', 'открита процедура', 'awarded'),
('t:B', 'UNP-B', 'Поръчка Б', 'auth:B', '45', 'открита процедура', 'awarded');
Expand Down
132 changes: 132 additions & 0 deletions packages/db/src/contractor-identity-sql.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/// <reference types="node" />
import { readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';

const root = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
const schema = readFileSync(resolve(root, 'packages/db/migrations/0000_init.sql'), 'utf8');
const staging = readFileSync(resolve(root, 'scripts/work-staging-schema.sql'), 'utf8');
const normalize = readFileSync(resolve(root, 'scripts/normalize-raw.sql'), 'utf8');
const precompute = readFileSync(resolve(root, 'scripts/precompute.sql'), 'utf8');
const refresh = readFileSync(resolve(root, 'scripts/refresh-slice.sql'), 'utf8');

const seed = `
INSERT INTO raw_contracts
(source, fetched_at, unp, contract_number, contract_date, published_at,
authority_eik, authority_name, procurement_subject, estimated_value,
signing_value, currency, contractor_eik, contractor_name)
VALUES
('eop:contracts:test', '2026-07-18T00:00:00Z', 'UNP-VALID', 'C-VALID', '2026-07-01',
'2026-07-02', '123456786', 'Тестов възложител', 'Валиден ЕИК', 100, 100, 'EUR',
'103267194', 'ТИПО ООД'),
('eop:contracts:test', '2026-07-18T00:00:00Z', 'UNP-TYPO', 'C-TYPO', '2026-07-01',
'2026-07-02', '123456786', 'Тестов възложител', 'Грешна контролна цифра', 200, 200, 'EUR',
'103267195', 'ТИПО ООД'),
('eop:contracts:test', '2026-07-18T00:00:00Z', 'UNP-EMPTY', 'C-EMPTY', '2026-07-01',
'2026-07-02', '123456786', 'Тестов възложител', 'Празно име', 300, 300, 'EUR',
'103267195', ''),
('eop:contracts:test', '2026-07-18T00:00:00Z', 'UNP-NULL', 'C-NULL', '2026-07-01',
'2026-07-02', '123456786', 'Тестов възложител', 'Липсваща самоличност', 400, 400, 'EUR',
NULL, NULL),
('eop:contracts:test', '2026-07-18T00:00:00Z', 'UNP-FOLD-1', 'C-FOLD-1', '2026-07-01',
'2026-07-02', '123456786', 'Тестов възложител', 'Кавички и регистър 1', 10, 10, 'EUR',
'', '„Строй Инвест" ЕООД'),
('eop:contracts:test', '2026-07-18T00:00:00Z', 'UNP-FOLD-2', 'C-FOLD-2', '2026-07-01',
'2026-07-02', '123456786', 'Тестов възложител', 'Кавички и регистър 2', 10, 10, 'EUR',
'', '"СТРОЙ ИНВЕСТ" ЕООД'),
('eop:contracts:test', '2026-07-18T00:00:00Z', 'UNP-FOLD-3', 'C-FOLD-3', '2026-07-01',
'2026-07-02', '123456786', 'Тестов възложител', 'Кавички и интервали', 10, 10, 'EUR',
'', '«строй инвест» ЕООД'),
('eop:contracts:test', '2026-07-18T00:00:00Z', 'UNP-DASH-EN', 'C-DASH-EN', '2026-07-01',
'2026-07-02', '123456786', 'Тестов възложител', 'Тире 1', 10, 10, 'EUR',
'', '„Марица–Изток"'),
('eop:contracts:test', '2026-07-18T00:00:00Z', 'UNP-DASH-ASCII', 'C-DASH-ASCII', '2026-07-01',
'2026-07-02', '123456786', 'Тестов възложител', 'Тире 2', 10, 10, 'EUR',
'', '„Марица-Изток"'),
('eop:contracts:test', '2026-07-18T00:00:00Z', 'UNP-GUARD-HYPHEN', 'C-GUARD-HYPHEN', '2026-07-01',
'2026-07-02', '123456786', 'Тестов възложител', 'Пази тирето', 10, 10, 'EUR',
'', '„Марица-Изток"'),
('eop:contracts:test', '2026-07-18T00:00:00Z', 'UNP-GUARD-SPACE', 'C-GUARD-SPACE', '2026-07-01',
'2026-07-02', '123456786', 'Тестов възложител', 'Пази интервала', 10, 10, 'EUR',
'', '„Марица Изток"');
`;

function build(path: 'normalize' | 'refresh'): DatabaseSync {
const db = new DatabaseSync(':memory:');
db.exec(schema);
db.exec(staging);
db.exec(seed);
if (path === 'normalize') {
db.exec(normalize);
db.exec(precompute);
} else {
db.exec(refresh);
}
return db;
}

describe.each(['normalize', 'refresh'] as const)('%s contractor identity', (path) => {
it('preserves every eligible contract across valid, typo, empty, and NULL identities', () => {
const db = build(path);
try {
const contracts = db
.prepare('SELECT contract_number, bidder_id FROM contracts ORDER BY contract_number')
.all() as { contract_number: string; bidder_id: string }[];

expect(contracts).toEqual([
{ contract_number: 'C-DASH-ASCII', bidder_id: 'name:МАРИЦА-ИЗТОК' },
{ contract_number: 'C-DASH-EN', bidder_id: 'name:МАРИЦА-ИЗТОК' },
{ contract_number: 'C-EMPTY', bidder_id: 'unknown:анонимен' },
{ contract_number: 'C-FOLD-1', bidder_id: 'name:СТРОЙ ИНВЕСТ ЕООД' },
{ contract_number: 'C-FOLD-2', bidder_id: 'name:СТРОЙ ИНВЕСТ ЕООД' },
{ contract_number: 'C-FOLD-3', bidder_id: 'name:СТРОЙ ИНВЕСТ ЕООД' },
{ contract_number: 'C-GUARD-HYPHEN', bidder_id: 'name:МАРИЦА-ИЗТОК' },
{ contract_number: 'C-GUARD-SPACE', bidder_id: 'name:МАРИЦА ИЗТОК' },
{ contract_number: 'C-NULL', bidder_id: 'unknown:анонимен' },
{ contract_number: 'C-TYPO', bidder_id: 'name:ТИПО ООД' },
{ contract_number: 'C-VALID', bidder_id: 'eik:103267194' },
]);

expect(
db
.prepare(
`SELECT id, name, bulstat, eik_normalized, eik_valid, is_consortium, kind
FROM bidders WHERE id = 'unknown:анонимен'`,
)
.get(),
).toEqual({
id: 'unknown:анонимен',
name: 'Неизвестен изпълнител',
bulstat: null,
eik_normalized: null,
eik_valid: 0,
is_consortium: 0,
kind: 'unknown',
});

const sums = db
.prepare(
`SELECT
(SELECT SUM(signing_value) FROM raw_contracts
WHERE source LIKE 'eop:%' OR source LIKE 'ocds:%') AS raw_total,
(SELECT SUM(amount_eur) FROM contracts) AS contract_total,
(SELECT SUM(won_eur) FROM company_totals) AS company_total`,
)
.get() as { raw_total: number; contract_total: number; company_total: number };
expect(sums).toEqual({ raw_total: 1070, contract_total: 1070, company_total: 1070 });

expect(
db
.prepare(
`SELECT COUNT(*) AS n FROM search_index
WHERE kind = 'company' AND ref = 'unknown:анонимен'`,
)
.get(),
).toEqual({ n: 0 });
} finally {
db.close();
}
});
});
2 changes: 1 addition & 1 deletion packages/db/src/etl-entity-canonicalization-sql.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ describe('ETL entity canonicalization through real SQL scripts', () => {
});

it('uses the bidder-name mode for EIK- and name-keyed bidders in both ETL paths', () => {
const bidderEik = '400000001';
const bidderEik = '400000004';

for (const [label, scriptPath] of etlPaths) {
withEtlDb(label, (dbPath) => {
Expand Down
30 changes: 25 additions & 5 deletions packages/db/src/queries/companies.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ import type { CompanyTotalsRow } from './rows';

const filteredRows: (CompanyTotalsRow & { sort_value: number })[] = [
{
bidder_id: 'eik:111111111',
bidder_id: 'eik:111111113',
name: 'Филтрирана фирма',
kind: 'company',
ownership_kind: null,
eik: '111111111',
eik: '111111113',
eik_valid: 1,
settlement: 'София',
won_eur: 1000,
Expand All @@ -25,11 +25,11 @@ const filteredRows: (CompanyTotalsRow & { sort_value: number })[] = [
const unfilteredRows: (CompanyTotalsRow & { sort_value: number })[] = [
...filteredRows,
{
bidder_id: 'eik:999999999',
bidder_id: 'eik:999999995',
name: 'Нефилтрирана фирма',
kind: 'company',
ownership_kind: null,
eik: '999999999',
eik: '999999995',
eik_valid: 1,
settlement: 'Пловдив',
won_eur: 900,
Expand Down Expand Up @@ -93,7 +93,7 @@ describe('streamCompaniesCsv', () => {
.map((line) => line.split(',')[0]);

expect(csvEiks).toEqual(list.items.map((item) => item.eik));
expect(csvEiks).toEqual(['111111111']);
expect(csvEiks).toEqual(['111111113']);
});

it('exports fewer rows for a sector filter than for the unfiltered corpus', async () => {
Expand All @@ -105,6 +105,26 @@ describe('streamCompaniesCsv', () => {
expect(countRows(filtered)).toBeLessThan(countRows(unfiltered));
expect(countRows(filtered)).toBe(1);
});

it('excludes the unknown identity bucket from the list and CSV', async () => {
const db = fakeDb();
const sql: string[] = [];
const real = db.prepare.bind(db);
db.prepare = ((query: string) => {
sql.push(query);
return real(query);
}) as typeof db.prepare;

await listCompanies(db, {});
await streamCompaniesCsv(db, {}).text();

expect(sql.filter((query) => query.includes('company_totals'))).not.toHaveLength(0);
expect(
sql
.filter((query) => query.includes('company_totals'))
.every((query) => query.includes("kind <> 'unknown'")),
).toBe(true);
});
});

describe('prototype-key params (untrusted query values)', () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/db/src/queries/companies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ function source(p: CompanyListParams): { from: string; params: unknown[] } {
* Keep consumed filter keys in sync with COMPANY_FILTER_KEYS and companyFilterSignature().
*/
function entityWhere(p: CompanyListParams): { sql: string; params: unknown[] } {
const where: string[] = [];
const where: string[] = [`kind <> 'unknown'`];
const params: unknown[] = [];
if (p.kinds?.length && p.kinds.length < 2) {
where.push(`kind = ?`);
Expand Down
10 changes: 5 additions & 5 deletions packages/db/src/queries/contracts-filter-sql.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,14 @@ const FIXTURE = `
INSERT INTO authorities (id, name, bulstat, type_group) VALUES
('auth:100000001', 'Институция А', '100000001', 'община');
INSERT INTO bidders (id, name, bulstat, eik_normalized, eik_valid, kind) VALUES
('eik:200000001', 'Фирма Х', '200000001', '200000001', 1, 'company');
('eik:200000002', 'Фирма Х', '200000002', '200000002', 1, 'company');
INSERT INTO tenders (id, source_id, title, authority_id, cpv_code, procedure_type, status) VALUES
('t:A', 'UNP-A', 'Поръчка А', 'auth:100000001', '45000000', 'открита процедура', 'awarded');
INSERT INTO contracts (id, tender_id, bidder_id, amount, currency, signed_at, bids_received, value_flag, amount_eur) VALUES
('c:NULL', 't:A', 'eik:200000001', 100, 'EUR', '2024-01-01', NULL, 'ok', 100),
('c:ZERO', 't:A', 'eik:200000001', 200, 'EUR', '2024-01-02', 0, 'ok', 200),
('c:ONE', 't:A', 'eik:200000001', 300, 'EUR', '2024-01-03', 1, 'ok', 300),
('c:TWO', 't:A', 'eik:200000001', 400, 'EUR', '2024-01-04', 2, 'ok', 400);
('c:NULL', 't:A', 'eik:200000002', 100, 'EUR', '2024-01-01', NULL, 'ok', 100),
('c:ZERO', 't:A', 'eik:200000002', 200, 'EUR', '2024-01-02', 0, 'ok', 200),
('c:ONE', 't:A', 'eik:200000002', 300, 'EUR', '2024-01-03', 1, 'ok', 300),
('c:TWO', 't:A', 'eik:200000002', 400, 'EUR', '2024-01-04', 2, 'ok', 400);
`;

/** Minimal D1Database facade over node:sqlite — enough for the query layer's prepare/bind/all/first. */
Expand Down
4 changes: 2 additions & 2 deletions packages/db/src/queries/contracts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@ const contractRow = {
unp: 'UNP-1',
cpv_code: '45000000',
eu_funded: 0,
authority_id: 'auth:123456789',
authority_id: 'auth:123456786',
authority_name: 'Authority',
bidder_id: 'eik:111111111',
bidder_id: 'eik:111111113',
bidder_name: 'Bidder',
bidder_kind: 'company' as const,
procedure_type: 'Открита процедура',
Expand Down
10 changes: 5 additions & 5 deletions packages/db/src/queries/details.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,14 +41,14 @@ const baseContractRow = {
tender_fx_rate: null as number | null,
start_date: null,
end_date: null,
authority_id: 'auth:123456789',
authority_id: 'auth:123456786',
authority_name: 'Authority',
authority_type_group: 'ministry',
authority_settlement: 'Sofia',
bidder_id: 'eik:111111111',
bidder_id: 'eik:111111113',
bidder_name: 'Bidder',
bidder_kind: 'company' as const,
bidder_eik: '111111111',
bidder_eik: '111111113',
bidder_settlement: 'Sofia',
};

Expand Down Expand Up @@ -102,7 +102,7 @@ describe('getContract', () => {
estimated_fx_rate: null,
bidder_name: 'Bidder',
bidder_kind: 'company',
bidder_id: 'eik:111111111',
bidder_id: 'eik:111111113',
},
{
lot_id: 'lot:UNP-1:2',
Expand Down Expand Up @@ -147,7 +147,7 @@ describe('getContract', () => {
estimated_fx_rate: 0.9,
bidder_name: 'Bidder',
bidder_kind: 'company',
bidder_id: 'eik:111111111',
bidder_id: 'eik:111111113',
},
{
lot_id: 'lot:UNP-1:2',
Expand Down
2 changes: 1 addition & 1 deletion packages/db/src/queries/details.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@ export async function getAuthority(
.prepare(
`SELECT c.bidder_id, b.name, b.kind, SUM(c.amount_eur) AS won, COUNT(*) AS n
FROM contracts c JOIN tenders t ON t.id = c.tender_id JOIN bidders b ON b.id = c.bidder_id
WHERE t.authority_id = ? AND c.amount_eur IS NOT NULL
WHERE t.authority_id = ? AND c.amount_eur IS NOT NULL AND b.kind <> 'unknown'
GROUP BY c.bidder_id ORDER BY won DESC LIMIT 7`,
)
.bind(authorityId)
Expand Down
12 changes: 11 additions & 1 deletion packages/db/src/queries/home.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,10 @@ const totalsRow = {
refreshed_at: '2024-06-02T10:00:00Z',
};

function fakeDb(totals: typeof totalsRow | null): D1Database {
function fakeDb(totals: typeof totalsRow | null, capture?: string[]): D1Database {
return {
prepare(sql: string) {
capture?.push(sql);
return {
bind() {
return this;
Expand Down Expand Up @@ -121,6 +122,15 @@ describe('getHomeData', () => {
expect(data.topMunicipalities).toHaveLength(1);
});

it('excludes the unknown identity bucket from top companies', async () => {
const sql: string[] = [];
await getHomeData(fakeDb(totalsRow, sql));

expect(sql.find((query) => query.includes('FROM company_totals'))).toContain(
"WHERE kind <> 'unknown'",
);
});

it('includes single-offer contract lists', async () => {
const data = await getHomeData(fakeDb(totalsRow));

Expand Down
4 changes: 3 additions & 1 deletion packages/db/src/queries/home.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,9 @@ export async function getHomeData(db: D1Database): Promise<HomeData> {
const [companies, ministries, municipalities, recentSingleOffer, topSingleOffer, singleOfferRow] =
await Promise.all([
db
.prepare(`SELECT * FROM company_totals ORDER BY won_eur DESC, bidder_id LIMIT 10`)
.prepare(
`SELECT * FROM company_totals WHERE kind <> 'unknown' ORDER BY won_eur DESC, bidder_id LIMIT 10`,
)
.all<CompanyTotalsRow>(),
db
.prepare(
Expand Down
6 changes: 5 additions & 1 deletion packages/db/src/queries/identity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,11 @@ describe('company slug', () => {
expect(bidderIdFromSlug(slug)).toBe(id);
});
it('round-trips a 13-digit ЕИК', () => {
expect(bidderIdFromSlug(companySlug('eik:1234567890123'))).toBe('eik:1234567890123');
expect(bidderIdFromSlug(companySlug('eik:8316417910124'))).toBe('eik:8316417910124');
});
it('resolves the unknown bidder linked from contract pages', () => {
expect(companySlug('unknown:анонимен')).toBe('unknown:анонимен');
expect(bidderIdFromSlug('unknown:анонимен')).toBe('unknown:анонимен');
});
});

Expand Down
4 changes: 3 additions & 1 deletion packages/db/src/queries/identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ function b64urlDecode(s: string): string {

const EIK_RE = /^\d{9}(\d{4})?$/;

/** bidder id → `/companies/:slug` segment. Valid ЕИК → the digits; name-keyed → `n` + base64url(name). */
/** bidder id → `/companies/:slug` segment. Valid ЕИК → digits; name-keyed → `n` + base64url(name);
* the labelled unknown bucket stays literal so contract-page links remain resolvable. */
export function companySlug(bidderId: string): string {
if (bidderId.startsWith('eik:')) return bidderId.slice(4);
if (bidderId.startsWith('name:')) return 'n' + b64urlEncode(bidderId.slice(5));
Expand All @@ -31,6 +32,7 @@ export function companySlug(bidderId: string): string {
/** `/companies/:slug` segment → bidder id, or null if it cannot be decoded. */
export function bidderIdFromSlug(slug: string): string | null {
if (EIK_RE.test(slug)) return 'eik:' + slug;
if (slug === 'unknown:анонимен') return slug;
if (slug.startsWith('n')) {
try {
return 'name:' + b64urlDecode(slug.slice(1));
Expand Down
Loading