Skip to content
Open
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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
| Профил на компания | `/companies/[eik]` | Един получател: общо спечелено, от кого, какво |
| Договори | `/contracts` | Филтриран списък с договори |
| Детайл на договор | `/contracts/[id]` | Една сделка с пълна проследимост до източника |
| Аномалии | `/anomalies` | Договори с автоматични ценови сигнали (над прогнозата, ръст чрез анекси, далеч над типичното) |
| Потоци | `/flows` | Парични потоци възложител → компания (суми + брой) |
| Търсене | `/search` | По имена, предмет и идентификатори |

Expand All @@ -47,7 +48,7 @@
Прозрачността върху публичните данни работи днес. Следващите стъпки надграждат същата структура, която вече ги предвижда, затова нищо не се преправя наново:

- **Слой „собственици и свързани лица“** — пълно свързване с Търговския регистър.
- **Автоматични проверки** на задания, цени и картели, с **публичен рисков индекс** на всяка поръчка (зелено / жълто / червено) — с публична методология и отворен код.
- **Автоматични проверки** на задания, цени и картели, с **публичен рисков индекс** на всяка поръчка (зелено / жълто / червено) — с публична методология и отворен код. Първата част вече е налице — страницата **Аномалии** (`/anomalies`) с три ценови сигнала и рисков скор на договор.
- **AI асистент на български** — разговорен слой над данните (текст и глас): намира и обяснява поръчките и изготвя справки.

Освен контрол, целта е и да помага на малкия и средния бизнес да намира лесно подходящите за него поръчки. СИГМА е част от инициативите на МИДТ за прозрачност чрез отворени данни.
Expand All @@ -66,7 +67,7 @@ pnpm dev # ежедневно: пуска приложението и ETL w

`pnpm setup` инсталира зависимостите, прилага D1 миграциите и зарежда малък примерен набор данни (`scripts/seed.sql`) в локалната miniflare база. `pnpm dev` стартира приложението на <http://localhost:5173> и ETL worker-а на `:8789`. Пълният корпус се зарежда с `pnpm run import` — изисква свалената EOP емисия в `data/eop` (виж [ETL](#etl)).

> **Статус:** прототип. Приложението и refresh worker-ът работят локално и се деплойват на Cloudflare през GitHub Actions; аналитичният слой (оценка на риска, аномалии, картели) е в плана за развитие.
> **Статус:** прототип. Приложението и refresh worker-ът работят локално и се деплойват на Cloudflare през GitHub Actions; от аналитичния слой са налични първите автоматични ценови проверки (`/anomalies`); останалото (пълен рисков индекс, картели) е в плана за развитие.

## Структура на хранилището

Expand Down
1 change: 1 addition & 0 deletions apps/web/app/components/SiteHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const NAV: NavItem[] = [
{ to: '/authorities', label: 'Институции' },
{ to: '/companies', label: 'Компании' },
{ to: '/contracts', label: 'Договори' },
{ to: '/anomalies', label: 'Аномалии' },
{ to: '/analytics', label: 'Анализи', activePaths: [...ANALYTICS_NAV_PATHS] },
{ to: '/methodology', label: 'Методология' },
];
Expand Down
74 changes: 74 additions & 0 deletions apps/web/app/lib/anomaly-badges.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { describe, expect, it } from 'vitest';
import type { AnomalySignals } from '@sigma/api-contract';
import { anomalyBadges, formatTimes } from './anomaly-badges';

const NBSP = '\u00A0'; // non-breaking space, as emitted by @sigma/shared formatters

const none: AnomalySignals = {
overEstimateRatio: null,
estimatedEur: null,
annexGrowthRatio: null,
priceRatio: null,
peerMedianEur: null,
peerCount: null,
singleBid: false,
noNotice: false,
};

describe('formatTimes', () => {
it('renders one decimal with a Bulgarian comma, dropping a trailing ,0', () => {
expect(formatTimes(2.53)).toBe('×2,5');
expect(formatTimes(12)).toBe('×12');
expect(formatTimes(1.1)).toBe('×1,1');
});

it('renders extreme ratios as whole numbers with the thousands separator', () => {
expect(formatTimes(104527.7)).toBe(`×104${NBSP}528`);
});
});

describe('anomalyBadges', () => {
it('maps every fired signal in severity order, price signals plain and context soft', () => {
const badges = anomalyBadges({
overEstimateRatio: 2.5,
estimatedEur: 102258,
annexGrowthRatio: 1.6,
priceRatio: 12,
peerMedianEur: 41666,
peerCount: 120,
singleBid: true,
noNotice: true,
});

expect(badges.map((b) => b.key)).toEqual([
'over_estimate',
'annex_growth',
'price_outlier',
'single_bid',
'no_notice',
]);
expect(badges.map((b) => b.context)).toEqual([false, false, false, true, true]);

expect(badges[0]).toMatchObject({
label: '×2,5 над прогнозата',
detail: `(при 102${NBSP}хил.${NBSP}€)`,
});
expect(badges[1]).toMatchObject({ label: '+60% чрез анекси', detail: null });
expect(badges[2]!.label).toBe('×12 над типичното');
expect(badges[2]!.detail).toBe(`(медиана 42${NBSP}хил.${NBSP}€ от 120 договора)`);
expect(badges[3]).toMatchObject({ label: 'единствена оферта', detail: null });
expect(badges[4]).toMatchObject({ label: 'без обявление', detail: null });
});

it('renders nothing for a signal-less row and omits absent evidence details', () => {
expect(anomalyBadges(none)).toEqual([]);
const noEvidence = anomalyBadges({ ...none, priceRatio: 7.2, peerMedianEur: null });
expect(noEvidence).toHaveLength(1);
expect(noEvidence[0]).toMatchObject({ label: '×7,2 над типичното', detail: null });
});

it('rounds the annex growth to whole percents', () => {
const badges = anomalyBadges({ ...none, annexGrowthRatio: 1.2345 });
expect(badges[0]!.label).toBe('+23% чрез анекси');
});
});
69 changes: 69 additions & 0 deletions apps/web/app/lib/anomaly-badges.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// Anomaly signal badges — the pure display mapping from an AnomalyListItem's fired signals to the
// red-flag chips on /anomalies. Kept out of the route component so the copy/formatting is unit
// tested. Formatting is hand-rolled like @sigma/shared/format: workerd does not carry the bg-BG
// Intl data, so no Intl/toLocaleString here.
import type { AnomalySignals } from '@sigma/api-contract';
import type { AnomalySignalKey } from '@sigma/config';
import { count, money, signedPct } from '@sigma/shared';

export interface AnomalyBadge {
key: AnomalySignalKey;
/** Headline chip text, e.g. „×2,5 над прогнозата". */
label: string;
/** Baseline evidence rendered de-emphasised inside the chip, e.g. „(при 102 хил. €)". */
detail: string | null;
/** true → context signal (soft chip variant), never the reason the row exists. */
context: boolean;
}

/** „×2,5" / „×12" / „×104 528" — one decimal under 100 (trailing „,0" dropped, comma decimal),
* whole numbers with the thousands NBSP above. */
export function formatTimes(ratio: number): string {
const body =
ratio >= 100 ? count(Math.round(ratio)) : String(Number(ratio.toFixed(1))).replace('.', ',');
return `×${body}`;
}

/**
* The chips for one row, in severity order (price signals first, context last). Ratio fields are
* already flag-gated by the query layer (non-null ⇔ the signal fired), so presence alone decides.
*/
export function anomalyBadges(s: AnomalySignals): AnomalyBadge[] {
const badges: AnomalyBadge[] = [];
if (s.overEstimateRatio != null) {
badges.push({
key: 'over_estimate',
label: `${formatTimes(s.overEstimateRatio)} над прогнозата`,
detail: s.estimatedEur != null ? `(при ${money(s.estimatedEur)})` : null,
context: false,
});
}
if (s.annexGrowthRatio != null) {
badges.push({
key: 'annex_growth',
label: `${signedPct(s.annexGrowthRatio - 1, 0)} чрез анекси`,
detail: null,
context: false,
});
}
if (s.priceRatio != null) {
badges.push({
key: 'price_outlier',
label: `${formatTimes(s.priceRatio)} над типичното`,
detail:
s.peerMedianEur != null
? `(медиана ${money(s.peerMedianEur)}${
s.peerCount != null ? ` от ${count(s.peerCount)} договора` : ''
})`
: null,
context: false,
});
}
if (s.singleBid) {
badges.push({ key: 'single_bid', label: 'единствена оферта', detail: null, context: true });
}
if (s.noNotice) {
badges.push({ key: 'no_notice', label: 'без обявление', detail: null, context: true });
}
return badges;
}
3 changes: 2 additions & 1 deletion apps/web/app/lib/filters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { normalizeAuthoritySort, normalizeCompanySort, normalizeContractSort } f
import type { CpvCategory } from '@sigma/config';
import type { FilterCategory, FilterGroup, FilterOption } from '../components/FilterRail';

export const PAGE_SIZE = { contracts: 15, companies: 25, authorities: 25 } as const;
export const PAGE_SIZE = { contracts: 15, companies: 25, authorities: 25, anomalies: 15 } as const;
export const MAX_MULTI_VALUES = 50;

const KNOWN_SECTORS = new Set(CPV_SECTORS.map((s) => s.code));
Expand Down Expand Up @@ -181,6 +181,7 @@ const PARAM_ORDER = [
'q',
'type',
'kind',
'signal',
'sector',
'year',
'procedure',
Expand Down
1 change: 1 addition & 0 deletions apps/web/app/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export default [
route('contracts.csv', 'routes/contracts.csv.tsx'),
route('contracts/:id.json', 'routes/contract.json.tsx'),
route('contracts/:id', 'routes/contract.tsx'),
route('anomalies', 'routes/anomalies.tsx'),
route('methodology', 'routes/methodology.tsx'),
route('accessibility', 'routes/accessibility.tsx'),
route('privacy', 'routes/privacy.tsx'),
Expand Down
Loading