diff --git a/openspec/changes/corrections-table-ui/design.md b/openspec/changes/corrections-table-ui/design.md index e6a62f2..f225f3d 100644 --- a/openspec/changes/corrections-table-ui/design.md +++ b/openspec/changes/corrections-table-ui/design.md @@ -179,3 +179,4 @@ | 28.10.2026 | `expected_version_no = null`, когда действующей корректировки нет (и после отзыва) | иначе после отзыва фронту неоткуда взять номер | — | | 28.10.2026 | Составная статья правил 1 и 2 — в окне первый код, можно выбрать второй | `line_code` — один код; особый расчёт правил 1 и 2 (DOC-274230) — на сервере | при ответе «нужно распределение по обеим» — аналитик добавляет вторую статью руками | | 28.10.2026 | «По пресету» показывает и подходящие нераспределённые строки; «Строка баланса» и «Отклонение» скрывают нераспределённые | пресет — рабочее место по правилу, строки для отнесения нужны рядом | поменять функцию фильтров | +| 28.10.2026 | R-1: список правил в плане — ориентир; при расхождении с api/seed/core/elimination_rules_v1.yaml (в плане статья 1230/1510 встречается дважды) номера и статьи берутся из yaml, тесты плана про правила 1, 3, 6, 7 остаются верными | seed — источник истины | поправка mockData и тестов | diff --git a/web/src/conf/config.ts b/web/src/conf/config.ts index d148de8..e3c303d 100644 --- a/web/src/conf/config.ts +++ b/web/src/conf/config.ts @@ -9,4 +9,7 @@ export const Config = { CLIENT_ID: environment.REACT_APP_CLIENT_ID ?? '', NAMESPACE: environment.REACT_APP_NAMESPACE ?? '', APP_NAME: environment.REACT_APP_APP_NAME ?? '', + + /** Таблица корректировок работает на фейке контракта вместо API (до готовности бэкенда AURORA-1540). */ + CORRECTIONS_MOCK: environment.REACT_APP_CORRECTIONS_MOCK === 'true', }; diff --git a/web/src/services/api/corrections.test.ts b/web/src/services/api/corrections.test.ts new file mode 100644 index 0000000..a834b81 --- /dev/null +++ b/web/src/services/api/corrections.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest'; +import { correctionsPath } from './corrections'; + +describe('correctionsPath', () => { + it('таблица версии', () => { + expect(correctionsPath('v1')).toBe('/consolidations/versions/v1/corrections'); + }); + it('агрегат по ключу (rule_id, company_1_id, company_2_id)', () => { + expect(correctionsPath('v1', { rule_id: 'r', company_1_id: 'a', company_2_id: 'b' })).toBe( + '/consolidations/versions/v1/corrections/aggregates/r/a/b', + ); + }); +}); diff --git a/web/src/services/api/corrections.ts b/web/src/services/api/corrections.ts new file mode 100644 index 0000000..2f3ff37 --- /dev/null +++ b/web/src/services/api/corrections.ts @@ -0,0 +1,49 @@ +import { api, apiFetchBlob } from '../../api/client'; +import { Config } from '../../conf/config'; +import type { + AggregateKey, + AllocationIn, + CorrectionAggregateOut, + CorrectionIn, + CorrectionRevokeIn, + CorrectionTableOut, + StatementType, +} from '../../types/corrections'; + +/** Пока фейк не подключён (Task 2), флаг `CORRECTIONS_MOCK` не должен давать молчаливый пустой ответ. */ +const fakeNotReady = (): never => { + throw new Error('фейк в Task 2'); +}; + +const STATEMENT_TYPE: StatementType = 'balance'; + +export function correctionsPath(versionId: string, key?: AggregateKey): string { + const base = `/consolidations/versions/${versionId}/corrections`; + if (!key) return base; + return `${base}/aggregates/${key.rule_id}/${key.company_1_id}/${key.company_2_id}`; +} + +export function getCorrectionTable(versionId: string): Promise { + if (Config.CORRECTIONS_MOCK) return fakeNotReady(); + return api.get(correctionsPath(versionId), { statement_type: STATEMENT_TYPE }); +} + +export function saveCorrection(versionId: string, key: AggregateKey, body: CorrectionIn): Promise { + if (Config.CORRECTIONS_MOCK) return fakeNotReady(); + return api.put(correctionsPath(versionId, key), body); +} + +export function revokeCorrection(versionId: string, key: AggregateKey, body: CorrectionRevokeIn): Promise { + if (Config.CORRECTIONS_MOCK) return fakeNotReady(); + return api.post(`${correctionsPath(versionId, key)}/revoke`, body); +} + +export function allocate(versionId: string, body: AllocationIn): Promise { + if (Config.CORRECTIONS_MOCK) return fakeNotReady(); + return api.post(`${correctionsPath(versionId)}/allocations`, body); +} + +export function exportCorrectionTable(versionId: string): Promise<{ blob: Blob; filename: string | null }> { + if (Config.CORRECTIONS_MOCK) return fakeNotReady(); + return apiFetchBlob(`${correctionsPath(versionId)}/export?statement_type=${STATEMENT_TYPE}`); +} diff --git a/web/src/types/corrections.ts b/web/src/types/corrections.ts new file mode 100644 index 0000000..5b1365f --- /dev/null +++ b/web/src/types/corrections.ts @@ -0,0 +1,176 @@ +// Типы таблицы корректировок 1:1 с components.schemas контракта (docs/dev/api-contract-corrections.openapi.yaml). +// Деньги и проценты — строки с двумя знаками, в рублях; перевод в тыс. руб. и округление — на фронте. + +export type StatementType = 'balance' | 'income'; + +export type Side = 'D' | 'K'; + +/** Сумма в рублях, два знака (`"1000000.00"`). */ +export type Money = string; + +/** Неотрицательная сумма для ввода, не более двух знаков. */ +export type MoneyIn = string; + +/** Процент с двумя знаками; `null` — не определён. */ +export type Percent = string | null; + +export interface Ref { + id: string; + name: string; +} + +export type ReadOnlyReason = 'VERSION_NOT_EDITABLE' | 'LOCK_REQUIRED' | 'LOCKED_BY_OTHER'; + +export interface CorrectionRuleOut { + id: string; + rule_no: number; + /** Код статьи; составная — коды через «/» (`"1450/1550"`). */ + article_1_line: string; + article_2_line: string; + side_1: Side; + measure: 'closing' | 'turnover'; + company_1_accounts: string[]; + company_2_accounts: string[]; + company_1_excluded: string[]; + company_2_excluded: string[]; +} + +export interface CorrectionEntryOut { + entry_ids: string[]; + legal_entity_id: string; + counterparty_id: string; + account_code: string; + side: Side; + /** Сальдо (оборот) строки по этой стороне. */ + amount: Money; + decision: 'AUTO' | 'ASSIGN'; + /** «Не включать в КФО»; в сумму стороны не входит. */ + excluded: boolean; + candidate_rule_ids: string[]; +} + +export interface AggregateSideOut { + article_line: string; + accounts: string[]; + amount: Money; + entries: CorrectionEntryOut[]; +} + +export interface CorrectionLineOut { + line_code: string; + amount: Money; +} + +export interface CorrectionBalanceOut { + debit_total: Money; + credit_total: Money; + difference: Money; + difference_percent: Percent; + /** Сумма стороны 1 минус debit_total. */ + remainder_1: Money; + /** Сумма стороны 2 минус credit_total. */ + remainder_2: Money; +} + +export interface CorrectionOut { + status: 'DRAFT' | 'APPLIED'; + version_no: number; + /** Статьи стороны 1, первая — основная. */ + debit: CorrectionLineOut[]; + /** Статьи стороны 2, первая — основная. */ + credit: CorrectionLineOut[]; + comment: string; + reason_code: string | null; + balance: CorrectionBalanceOut; + updated_by: Ref | null; + updated_at: string; +} + +export type AggregateStatus = 'AUTO' | 'DISCREPANCY' | 'ONE_SIDED' | 'MANUAL'; + +export interface CorrectionAggregateOut { + rule_id: string; + rule_no: number; + company_1: Ref; + company_2: Ref; + side_1: AggregateSideOut; + side_2: AggregateSideOut; + deviation_amount: Money; + deviation_percent: Percent; + /** deviation_percent ≥ 5, или одна сторона пуста при ненулевой другой. */ + exceeds_threshold: boolean; + status: AggregateStatus; + eliminated_amount: Money; + /** Состав или суммы изменились после применённой корректировки. */ + composition_changed: boolean; + correction: CorrectionOut | null; +} + +export interface UnallocatedEntryOut { + entry_ids: string[]; + owner: Ref; + counterparty: Ref; + account_code: string; + side: Side; + amount: Money; + candidate_rule_ids: string[]; + reason: 'MULTIPLE_RULES' | 'UNASSIGNED'; + excluded: boolean; +} + +export interface CorrectionTotalsOut { + amount_1: Money; + amount_2: Money; + deviation_amount: Money; + correction_debit: Money; + correction_credit: Money; +} + +export interface CorrectionTableOut { + version_id: string; + statement_type: StatementType; + rules_version_no: number; + /** Может ли текущий пользователь править таблицу. */ + editable: boolean; + read_only_reason: ReadOnlyReason | null; + rules: CorrectionRuleOut[]; + aggregates: CorrectionAggregateOut[]; + unallocated: UnallocatedEntryOut[]; + totals: CorrectionTotalsOut; +} + +export interface CorrectionLineIn { + line_code: string; + amount: MoneyIn; +} + +export interface CorrectionIn { + debit: CorrectionLineIn[]; + credit: CorrectionLineIn[]; + comment: string; + reason_code?: string | null; + status?: 'APPLIED' | 'DRAFT'; + /** Редакция действующей корректировки, которую видел аналитик; `null` — действующей нет. */ + expected_version_no: number | null; +} + +export interface CorrectionRevokeIn { + comment: string; +} + +export type AllocationAction = 'ASSIGN' | 'UNASSIGN' | 'EXCLUDE' | 'INCLUDE' | 'RESET'; + +export interface AllocationIn { + statement_type: StatementType; + entry_ids: string[]; + action: AllocationAction; + /** Обязателен для ASSIGN. */ + rule_id?: string | null; +} + +/** Ключ агрегата в пути запроса: `(rule_id, company_1_id, company_2_id)`. */ +export interface AggregateKey { + rule_id: string; + company_1_id: string; + company_2_id: string; +}