aurora-1544 добавление таблицы гудвил #220

Merged
aep merged 18 commits from feat/goodWill into main 2026-11-06 18:08:01 +03:00
4 changed files with 239 additions and 28 deletions
Showing only changes of commit 66acde7786 - Show all commits

View File

@ -1,23 +1,19 @@
import { api, apiFetchBlob } from '../../api/client';
import type { GoodwillRow, GoodwillRowPayload, GoodwillSettingsPayload, GoodwillTableResponse } from '../../types/goodwill/goodwill';
import type { GoodwillRowKeyIn, GoodwillSettingsPayload, GoodwillSign, GoodwillTableResponse } from '../../types/goodwill/goodwill';
export function fetchGoodwillTable(versionId: string): Promise<GoodwillTableResponse> {
return api.get<GoodwillTableResponse>(`/consolidations/versions/${versionId}/goodwill`);
}
/** Пакет строк одной транзакцией: ошибка любой строки — ничего не сохранено, в `ApiError.body.detail` её ключ. */
export function saveGoodwillRows(versionId: string, rows: GoodwillRowKeyIn[]): Promise<GoodwillTableResponse> {
return api.put<GoodwillTableResponse>(`/consolidations/versions/${versionId}/goodwill`, { rows });
}
export function saveGoodwillSettings(versionId: string, payload: GoodwillSettingsPayload): Promise<GoodwillTableResponse> {
return api.put<GoodwillTableResponse>(`/consolidations/versions/${versionId}/goodwill-settings`, payload);
}
export function saveGoodwillRow(
versionId: string,
headId: string,
subsidiaryId: string,
payload: GoodwillRowPayload,
): Promise<GoodwillRow> {
return api.put<GoodwillRow>(`/consolidations/versions/${versionId}/goodwill/${headId}/${subsidiaryId}`, payload);
}
export function exportGoodwillTable(versionId: string): Promise<{ blob: Blob; filename: string | null }> {
return apiFetchBlob(`/consolidations/versions/${versionId}/goodwill/export`);
export function exportGoodwillTable(versionId: string, sign: GoodwillSign): Promise<{ blob: Blob; filename: string | null }> {
return apiFetchBlob(`/consolidations/versions/${versionId}/goodwill/export?sign=${sign}`);
}

View File

@ -1,49 +1,64 @@
/** DTO таблицы гудвилла — по `api/src/schemas/goodwill.py` и `../docs/dev/api-contract-goodwill.md`. Деньги и доля — строки. */
export interface Ref {
id: string;
name: string;
}
export interface GoodwillWarning {
code: 'CAPITAL_SOURCE_MISSING' | 'CAPITAL_SOURCE_AMBIGUOUS' | 'OWNERSHIP_TOTAL_EXCEEDED' | string;
message: string;
}
export interface GoodwillRow {
head_legal_entity: Ref;
subsidiary_legal_entity: Ref;
ownership_share: string | null;
financial_investment_amount: string;
charter_capital_amount: string;
additional_capital_amount: string;
participation_amount: string;
goodwill_amount: string;
/** null — «Нет вложений» */
financial_investment_amount: string | null;
/** null — нет однозначного 1310 или «Нет вложений» */
charter_capital_amount: string | null;
additional_capital_amount: string | null;
/** только у действующей отмеченной строки с долей и УК */
participation_amount: string | null;
goodwill_amount: string | null;
include_in_kfo: boolean;
comment: string | null;
has_investments: boolean;
warnings: GoodwillWarning[];
updated_by: Ref | null;
updated_at: string | null;
}
export interface GoodwillTotals {
financial_investment_amount: string;
charter_capital_amount: string;
participation_amount: string;
goodwill_amount: string;
goodwill_in_kfo_amount: string;
}
export interface GoodwillGroup {
head_legal_entity: Ref;
totals: GoodwillTotals;
}
export type GoodwillReadOnlyReason = 'VERSION_NOT_EDITABLE' | 'LOCK_REQUIRED' | 'LOCKED_BY_OTHER';
export interface GoodwillTableResponse {
version_id: string;
goodwill_enabled: boolean;
editable: boolean;
read_only_reason: string | null;
read_only_reason: GoodwillReadOnlyReason | null;
rows: GoodwillRow[];
groups: GoodwillGroup[];
totals: GoodwillTotals;
}
export type GoodwillTreeRow = GoodwillRow & {
key: string;
children?: GoodwillTreeRow[];
isGroup?: boolean;
isTotal?: boolean;
isGroupTotal?: boolean;
};
export interface GoodwillRowPayload {
ownership_share: string;
/** Строка пакета `PUT …/goodwill`. */
export interface GoodwillRowKeyIn {
head_legal_entity_id: string;
subsidiary_legal_entity_id: string;
ownership_share: string | null;
include_in_kfo: boolean;
comment: string | null;
}
@ -51,3 +66,12 @@ export interface GoodwillRowPayload {
export interface GoodwillSettingsPayload {
goodwill_enabled: boolean;
}
export type GoodwillSign = 'all' | 'positive' | 'negative' | 'zero';
/** Несохранённая правка строки в режиме «Редактировать»; отсутствующее поле — как в сохранённой строке. */
export interface GoodwillEdit {
include_in_kfo?: boolean;
ownership_share?: string | null;
comment?: string | null;
}

View File

@ -0,0 +1,100 @@
import { describe, expect, it } from 'vitest';
import type { GoodwillRow } from '../types/goodwill/goodwill';
import { changedRows, matchesSign, parseShare, previewAmounts, rowKey } from './goodwill';
function row(patch: Partial<GoodwillRow> = {}): GoodwillRow {
return {
head_legal_entity: { id: 'a', name: 'Альфа' },
subsidiary_legal_entity: { id: 'b', name: 'Бета' },
ownership_share: null,
financial_investment_amount: '180000.00',
charter_capital_amount: '100000.00',
additional_capital_amount: null,
participation_amount: null,
goodwill_amount: null,
include_in_kfo: false,
comment: null,
has_investments: true,
warnings: [],
updated_by: null,
updated_at: null,
...patch,
};
}
describe('parseShare', () => {
it('принимает запятую и точку, не больше двух знаков', () => {
expect(parseShare('80,12')).toEqual({ ok: true, value: '80.12' });
expect(parseShare('100')).toEqual({ ok: true, value: '100' });
expect(parseShare(' 7.5 ')).toEqual({ ok: true, value: '7.5' });
});
it('пустое — null', () => {
expect(parseShare('')).toEqual({ ok: true, value: null });
});
it.each(['80,123', '100,01', '-1', 'abc', '1e2', '101'])('отклоняет %s без обрезки', (input) => {
expect(parseShare(input).ok).toBe(false);
});
});
describe('previewAmounts (DOC-259025 §11.1)', () => {
it('Бета: P = 80 000, G = +100 000', () => {
expect(previewAmounts(row(), '80', true)).toEqual({ participation: '80000.00', goodwill: '100000.00' });
});
it('Гамма: G отрицательный', () => {
expect(previewAmounts(row({ financial_investment_amount: '50000.00' }), '80', true)).toEqual({
participation: '80000.00',
goodwill: '-30000.00',
});
});
it('округление половина вверх до копеек', () => {
expect(previewAmounts(row({ charter_capital_amount: '371.17' }), '99.99', true).participation).toBe('371.13');
expect(previewAmounts(row({ charter_capital_amount: '0.05' }), '50', true).participation).toBe('0.03');
});
it('не отмечена, нет доли или УК — не рассчитано', () => {
const empty = { participation: null, goodwill: null };
expect(previewAmounts(row(), '80', false)).toEqual(empty);
expect(previewAmounts(row(), null, true)).toEqual(empty);
expect(previewAmounts(row({ charter_capital_amount: null }), '80', true)).toEqual(empty);
expect(previewAmounts(row({ has_investments: false, financial_investment_amount: null }), '80', true)).toEqual(empty);
});
});
describe('matchesSign', () => {
it('нерассчитанный гудвилл попадает только во «все»', () => {
expect(matchesSign(null, 'all')).toBe(true);
expect(matchesSign(null, 'positive')).toBe(false);
expect(matchesSign(null, 'negative')).toBe(false);
expect(matchesSign(null, 'zero')).toBe(false);
});
it('ноль — только «нулевые»', () => {
expect(matchesSign('0.00', 'zero')).toBe(true);
expect(matchesSign('0.00', 'positive')).toBe(false);
expect(matchesSign('-0.01', 'negative')).toBe(true);
expect(matchesSign('0.01', 'positive')).toBe(true);
});
});
describe('changedRows', () => {
const saved = row({ ownership_share: '80.00', include_in_kfo: true, comment: 'Устав' });
it('правка, совпадающая с сохранённым, не уходит', () => {
const edits = { [rowKey(saved)]: { include_in_kfo: true, ownership_share: '80', comment: 'Устав' } };
expect(changedRows([saved], edits)).toEqual([]);
});
it('изменённая строка уходит целиком', () => {
const edits = { [rowKey(saved)]: { comment: 'Устав 2024' } };
expect(changedRows([saved], edits)).toEqual([
{ head_legal_entity_id: 'a', subsidiary_legal_entity_id: 'b', ownership_share: '80.00', include_in_kfo: true, comment: 'Устав 2024' },
]);
});
it('снятая галочка у строки без доли — ownership_share: null', () => {
const fresh = row({ include_in_kfo: true });
expect(changedRows([fresh], { [rowKey(fresh)]: { include_in_kfo: false } })).toEqual([
{ head_legal_entity_id: 'a', subsidiary_legal_entity_id: 'b', ownership_share: null, include_in_kfo: false, comment: null },
]);
});
it('доля с запятой уходит с точкой', () => {
const fresh = row();
const edits = { [rowKey(fresh)]: { include_in_kfo: true, ownership_share: '80,5', comment: 'Да' } };
expect(changedRows([fresh], edits)[0].ownership_share).toBe('80.5');
});
});

91
web/src/utils/goodwill.ts Normal file
View File

@ -0,0 +1,91 @@
/** Чистые функции вкладки «Гудвилл» (DOC-259025 §4–§6): ввод доли, предпросмотр P/G, фильтр знака, изменённые строки. */
import type { GoodwillEdit, GoodwillRow, GoodwillRowKeyIn, GoodwillSign } from '../types/goodwill/goodwill';
import { fromCents, toCents } from './money';
const SHARE = /^\d{1,3}(?:\.\d{1,2})?$/;
export function rowKey(row: Pick<GoodwillRow, 'head_legal_entity' | 'subsidiary_legal_entity'>): string {
return `${row.head_legal_entity.id}:${row.subsidiary_legal_entity.id}`;
}
/** Доля 0–100, не больше двух знаков, запятая или точка; пусто — null. Лишние знаки — ошибка, не обрезка. */
export function parseShare(input: string): { ok: true; value: string | null } | { ok: false; error: string } {
const value = input.trim().replace(',', '.');
if (value === '') return { ok: true, value: null };
if (!SHARE.test(value) || shareHundredths(value) > 10000n) {
return { ok: false, error: 'Число от 0 до 100, не больше двух знаков после запятой' };
}
return { ok: true, value };
}
/** Доля строкой («80.5») → сотые доли процента (8050n). */
function shareHundredths(value: string): bigint {
const [int, frac = ''] = value.split('.');
return BigInt(int) * 100n + BigInt(frac.padEnd(2, '0'));
}
/** Предварительные P = round(УК × d / 100, 2) и G = ФВ − P в копейках, половина вверх; как на бэке. */
export function previewAmounts(
row: GoodwillRow,
share: string | null,
include: boolean,
): { participation: string | null; goodwill: string | null } {
const fv = row.financial_investment_amount;
const capital = row.charter_capital_amount;
if (!row.has_investments || !include || share === null || fv === null || capital === null) {
return { participation: null, goodwill: null };
}
const parsed = parseShare(share);
if (!parsed.ok || parsed.value === null) return { participation: null, goodwill: null };
const product = toCents(capital) * shareHundredths(parsed.value);
const divisor = 10000n;
const negative = product < 0n;
const abs = negative ? -product : product;
const rounded = (abs + divisor / 2n) / divisor;
const p = negative ? -rounded : rounded;
return { participation: fromCents(p), goodwill: fromCents(toCents(fv) - p) };
}
/** Фильтр «Гудвилл»: нерассчитанный (null) — только во «Все значения». */
export function matchesSign(goodwill: string | null, sign: GoodwillSign): boolean {
if (sign === 'all') return true;
if (goodwill === null) return false;
const cents = toCents(goodwill);
if (sign === 'positive') return cents > 0n;
if (sign === 'negative') return cents < 0n;
return cents === 0n;
}
/** Значения строки с учётом правки: что уйдёт в пакет. */
export function effectiveRow(row: GoodwillRow, edit: GoodwillEdit | undefined): GoodwillRowKeyIn {
const share = edit && 'ownership_share' in edit ? (edit.ownership_share ?? null) : row.ownership_share;
const parsed = share === null ? null : parseShare(share);
const comment = edit && 'comment' in edit ? (edit.comment ?? null) : row.comment;
return {
head_legal_entity_id: row.head_legal_entity.id,
subsidiary_legal_entity_id: row.subsidiary_legal_entity.id,
ownership_share: parsed?.ok ? parsed.value : share,
include_in_kfo: edit?.include_in_kfo ?? row.include_in_kfo,
comment: comment?.trim() ? comment : null,
};
}
function sameShare(a: string | null, b: string | null): boolean {
if (a === null || b === null) return a === b;
if (!SHARE.test(a) || !SHARE.test(b)) return a === b;
return shareHundredths(a) === shareHundredths(b);
}
/** Строки пакета — только те, где правка отличается от сохранённого. */
export function changedRows(rows: GoodwillRow[], edits: Record<string, GoodwillEdit>): GoodwillRowKeyIn[] {
return rows.flatMap((row) => {
const edit = edits[rowKey(row)];
if (!edit) return [];
const next = effectiveRow(row, edit);
const same =
next.include_in_kfo === row.include_in_kfo &&
sameShare(next.ownership_share, row.ownership_share) &&
(next.comment?.trim() ?? null) === (row.comment?.trim() || null);
return same ? [] : [next];
});
}