diff --git a/web/src/services/api/goodwill.ts b/web/src/services/api/goodwill.ts index d55b08d..dd1e7fe 100644 --- a/web/src/services/api/goodwill.ts +++ b/web/src/services/api/goodwill.ts @@ -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 { return api.get(`/consolidations/versions/${versionId}/goodwill`); } +/** Пакет строк одной транзакцией: ошибка любой строки — ничего не сохранено, в `ApiError.body.detail` её ключ. */ +export function saveGoodwillRows(versionId: string, rows: GoodwillRowKeyIn[]): Promise { + return api.put(`/consolidations/versions/${versionId}/goodwill`, { rows }); +} + export function saveGoodwillSettings(versionId: string, payload: GoodwillSettingsPayload): Promise { return api.put(`/consolidations/versions/${versionId}/goodwill-settings`, payload); } -export function saveGoodwillRow( - versionId: string, - headId: string, - subsidiaryId: string, - payload: GoodwillRowPayload, -): Promise { - return api.put(`/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}`); } diff --git a/web/src/types/goodwill/goodwill.ts b/web/src/types/goodwill/goodwill.ts index 8a1c834..96439be 100644 --- a/web/src/types/goodwill/goodwill.ts +++ b/web/src/types/goodwill/goodwill.ts @@ -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; +} diff --git a/web/src/utils/goodwill.test.ts b/web/src/utils/goodwill.test.ts new file mode 100644 index 0000000..505edc1 --- /dev/null +++ b/web/src/utils/goodwill.test.ts @@ -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 { + 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'); + }); +}); diff --git a/web/src/utils/goodwill.ts b/web/src/utils/goodwill.ts new file mode 100644 index 0000000..fcd1748 --- /dev/null +++ b/web/src/utils/goodwill.ts @@ -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): 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): 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]; + }); +}