Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
4 changes: 2 additions & 2 deletions apps/web/app/app.css
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
@import 'tailwindcss' source('.');
@import 'tailwindcss' source('.');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

На този ред е въведен BOM символ (U+FEFF) непосредствено преди @import 'tailwindcss' (виждан в diff-а като невидим знак между + и @import). Освен това на последния ред на файла е премахнат крайният нов ред (\ No newline at end of file).

И двете промени изглеждат непреднамерени и несвързани с функционалността за аномалии. BOM в началото на CSS файл обикновено се толерира от браузърите, но е добре да се премахне, за да се избегнат евентуални проблеми с CSS парсера/Tailwind v4 и за да остане diff-ът фокусиран. Молбата ми е да върнете реда без BOM и с краен нов ред:

@import 'tailwindcss' source('.');
...
@import './styles/pages.css';\n


@import './styles/tokens.css';
@import './styles/base.css';
Expand All @@ -8,4 +8,4 @@
@import './styles/tables.css';
@import './styles/home.css';
@import './styles/flow.css';
@import './styles/pages.css';
@import './styles/pages.css';
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