AURORA-1541: таблица корректировки — подключена к реальному API (#206) #215

Merged
aep merged 29 commits from feat/206-corrections-ui into main 2026-11-02 20:12:17 +03:00
5 changed files with 242 additions and 0 deletions
Showing only changes of commit 8eefa3ba02 - Show all commits

View File

@ -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 и тестов |

View File

@ -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',
};

View File

@ -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',
);
});
});

View File

@ -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<CorrectionTableOut> {
if (Config.CORRECTIONS_MOCK) return fakeNotReady();
return api.get<CorrectionTableOut>(correctionsPath(versionId), { statement_type: STATEMENT_TYPE });
}
export function saveCorrection(versionId: string, key: AggregateKey, body: CorrectionIn): Promise<CorrectionAggregateOut> {
if (Config.CORRECTIONS_MOCK) return fakeNotReady();
return api.put<CorrectionAggregateOut>(correctionsPath(versionId, key), body);
}
export function revokeCorrection(versionId: string, key: AggregateKey, body: CorrectionRevokeIn): Promise<CorrectionAggregateOut> {
if (Config.CORRECTIONS_MOCK) return fakeNotReady();
return api.post<CorrectionAggregateOut>(`${correctionsPath(versionId, key)}/revoke`, body);
}
export function allocate(versionId: string, body: AllocationIn): Promise<CorrectionTableOut> {
if (Config.CORRECTIONS_MOCK) return fakeNotReady();
return api.post<CorrectionTableOut>(`${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}`);
}

View File

@ -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;
}