aurora-1544 добавление таблицы гудвил #220
@ -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}`);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
100
web/src/utils/goodwill.test.ts
Normal file
100
web/src/utils/goodwill.test.ts
Normal 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
91
web/src/utils/goodwill.ts
Normal 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];
|
||||
});
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user