From 8513a8d10e3b2bda3637e0ed9dab1bad9fcf0169 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 27 Oct 2026 18:51:45 +0400 Subject: [PATCH] =?UTF-8?q?feat(#195):=20=D1=82=D0=B8=D0=BF=D1=8B=20=D0=B8?= =?UTF-8?q?=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D0=BB=D0=B0=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=BA=D0=B0=D0=B7=D0=B0=20=D0=BF=D0=B0=D1=80=20=D0=B1=D0=B5?= =?UTF-8?q?=D0=B7=20=D0=B2=D1=81=D1=82=D1=80=D0=B5=D1=87=D0=BD=D1=8B=D1=85?= =?UTF-8?q?=20=D0=B7=D0=B0=D0=BF=D0=B8=D1=81=D0=B5=D0=B9=20(web)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- web/src/services/api/osv.test.ts | 3 + web/src/services/api/osv.ts | 1 + web/src/types/api.ts | 12 +++ web/src/utils/consolidations.test.ts | 83 ++++++++++++++++++- web/src/utils/consolidations.ts | 60 ++++++++++++++ .../ConsolidatedOsv/counterPairs.test.ts | 47 +++++++++++ .../widgets/ConsolidatedOsv/counterPairs.ts | 25 ++++++ 7 files changed, 230 insertions(+), 1 deletion(-) create mode 100644 web/src/widgets/ConsolidatedOsv/counterPairs.test.ts create mode 100644 web/src/widgets/ConsolidatedOsv/counterPairs.ts diff --git a/web/src/services/api/osv.test.ts b/web/src/services/api/osv.test.ts index 5680dd7..5d30284 100644 --- a/web/src/services/api/osv.test.ts +++ b/web/src/services/api/osv.test.ts @@ -15,4 +15,7 @@ describe('buildOsvQuery', () => { it('страница', () => { expect(buildOsvQuery({ counterparty_type: 'GSK' }, { limit: 500, offset: 1000 })).toBe('?counterparty_type=GSK&limit=500&offset=1000'); }); + it('counterparty_id', () => { + expect(buildOsvQuery({ counterparty_id: 'x' })).toBe('?counterparty_id=x'); + }); }); diff --git a/web/src/services/api/osv.ts b/web/src/services/api/osv.ts index 8344613..fba716f 100644 --- a/web/src/services/api/osv.ts +++ b/web/src/services/api/osv.ts @@ -15,6 +15,7 @@ export function buildOsvQuery(filters: OsvFilters, page?: { limit: number; offse if (filters.account_root) params.set('account_root', filters.account_root); if (filters.counterparty_type) params.set('counterparty_type', filters.counterparty_type); if (filters.unmapped) params.set('unmapped', 'true'); + if (filters.counterparty_id) params.set('counterparty_id', filters.counterparty_id); const q = filters.q?.trim(); if (q) params.set('q', q); if (page) { diff --git a/web/src/types/api.ts b/web/src/types/api.ts index 733f262..3293854 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -357,18 +357,30 @@ export interface GapOut { account_code: string; missing_quarters: Quarter[]; } +/** Пара участников без встречных записей: у `holder` есть строки с `missing`, у `missing` — нет строк с `holder`. */ +export interface CounterPairOut { + holder_id: string; + holder_name: string; + missing_id: string; + missing_name: string; + holder_rows: number; + missing_unmapped_rows: number; + missing_rows_total: number; +} export interface OsvSummaryOut { rows_total: number; unmapped_total: number; auto_matched_total: number; documents_total: number; gaps: GapOut[]; + counter_pairs: CounterPairOut[]; } export interface OsvFilters { legal_entity_id?: string; account_root?: string; counterparty_type?: CounterpartyType; unmapped?: boolean; + counterparty_id?: string; q?: string; } diff --git a/web/src/utils/consolidations.test.ts b/web/src/utils/consolidations.test.ts index 5a56c7d..1624a9e 100644 --- a/web/src/utils/consolidations.test.ts +++ b/web/src/utils/consolidations.test.ts @@ -1,19 +1,25 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; +import type { CounterPairOut } from '../types/api'; import type { ConsStatus, LockOut } from '../types/consolidations/common_types'; import { CONS_STATUS_LABEL, + FINISHED_STATUSES, MANUAL_TRANSITIONS, canChangeStatus, canSubmitStatusChange, canTakeInWork, consStatusDescription, + counterPairsKey, + counterPairsWarning, endSentence, isLockedBy, isValidReason, manualTransitions, needsUnmappedWarning, parseInWorkOnly, + rememberCounterPairsShown, reportTabNavigation, + shouldWarnCounterPairs, versionLabel, } from './consolidations'; @@ -143,3 +149,78 @@ describe('вкладки отчёта (AURORA-1851)', () => { }); }); }); + +const pair = (holder: string, missing: string): CounterPairOut => ({ + holder_id: holder, + holder_name: `H${holder}`, + missing_id: missing, + missing_name: `M${missing}`, + holder_rows: 1, + missing_unmapped_rows: 0, + missing_rows_total: 0, +}); + +describe('counterPairsKey', () => { + it('не зависит от порядка', () => { + expect(counterPairsKey([pair('a', 'b'), pair('c', 'd')])).toBe(counterPairsKey([pair('c', 'd'), pair('a', 'b')])); + expect(counterPairsKey([pair('c', 'd'), pair('a', 'b')])).toBe('a>b,c>d'); + }); +}); + +describe('shouldWarnCounterPairs', () => { + const pairs = [pair('a', 'b')]; + const withKey = (v: string | null) => ({ getItem: () => v }); + it('пусто или завершённый статус — false', () => { + expect(shouldWarnCounterPairs('v', 'CONSOLIDATION_RF', [], withKey(null))).toBe(false); + for (const s of FINISHED_STATUSES) expect(shouldWarnCounterPairs('v', s, pairs, withKey(null))).toBe(false); + expect(shouldWarnCounterPairs('v', 'CANCELLED', pairs, withKey(null))).toBe(false); + }); + it('ключ в хранилище', () => { + expect(shouldWarnCounterPairs('v', 'CONSOLIDATION_RF', pairs, withKey(null))).toBe(true); + expect(shouldWarnCounterPairs('v', 'CONSOLIDATION_RF', pairs, withKey('a>b'))).toBe(false); + expect(shouldWarnCounterPairs('v', 'CONSOLIDATION_RF', pairs, withKey('x>y'))).toBe(true); + }); + it('null и throwing storage — true', () => { + expect(shouldWarnCounterPairs('v', 'CONSOLIDATION_RF', pairs, null)).toBe(true); + const throwing = { + getItem: () => { + throw new Error('denied'); + }, + }; + expect(shouldWarnCounterPairs('v', 'CONSOLIDATION_RF', pairs, throwing)).toBe(true); + }); +}); + +describe('rememberCounterPairsShown', () => { + it('пишет ключ', () => { + const setItem = vi.fn(); + rememberCounterPairsShown('v1', [pair('a', 'b')], { setItem }); + expect(setItem).toHaveBeenCalledWith('armka:counter-pairs:v1', 'a>b'); + }); + it('глотает исключения и null', () => { + const throwing = { + setItem: () => { + throw new Error('denied'); + }, + }; + expect(() => rememberCounterPairsShown('v1', [pair('a', 'b')], throwing)).not.toThrow(); + expect(() => rememberCounterPairsShown('v1', [pair('a', 'b')], null)).not.toThrow(); + }); +}); + +describe('counterPairsWarning', () => { + it('одна пара у одного участника', () => { + const w = counterPairsWarning([pair('a', 'b')], 1260); + expect(w.headline).toBe('Нет встречных записей: 1 пара у 1 участника.'); + expect(w.details.endsWith('Ещё 1260 строк без ИНН учтены как внешние.')).toBe(true); + }); + it('пять пар у трёх участников', () => { + const ps = [pair('a', 'b'), pair('a', 'c'), pair('a', 'd'), pair('e', 'b'), pair('f', 'c')]; + expect(counterPairsWarning(ps, 5).headline).toBe('Нет встречных записей: 5 пар у 3 участников.'); + }); + it('без строк без ИНН', () => { + expect(counterPairsWarning([pair('a', 'b')], 0).details).toBe( + 'Возможно, контрагент среди строк без ИНН, его ИНН не найден в справочнике или это пропуск в учёте.', + ); + }); +}); diff --git a/web/src/utils/consolidations.ts b/web/src/utils/consolidations.ts index 77b3854..bf42a44 100644 --- a/web/src/utils/consolidations.ts +++ b/web/src/utils/consolidations.ts @@ -1,6 +1,8 @@ // Чистые правила экранов консолидаций (спека `consolidations-ui`): подписи и пояснения статусов версии, ручные // переходы, блокировка, «Версия», фильтр «Только в работе». Без React, store и API. +import type { CounterPairOut } from '../types/api'; import type { ConsRussianStatus, ConsStatus, LockOut } from '../types/consolidations/common_types'; +import { pluralRu } from './labels'; /** Подписи статусов версии — одни для списка, фильтра «Статус», шапки карточки и модалки «Изменить статус». */ export const CONS_STATUS_LABEL: Record = { @@ -100,3 +102,61 @@ export const reportTabNavigation = (pathname: string, activeKey: string, newKey: to: pathname.replace(new RegExp(`/${activeKey}/?$`), `/${newKey}`), options: { replace: true }, }); + +/** Статусы, в которых версия закрыта для правок: модалку о парах без встречных записей не показываем. */ +export const FINISHED_STATUSES: readonly ConsStatus[] = ['VERIFIED', 'CANCELLED', 'ARCHIVED', 'REJECTED']; + +/** Префикс ключа `sessionStorage`: `armka:counter-pairs:`, значение — `counterPairsKey`. */ +export const COUNTER_PAIRS_STORAGE_PREFIX = 'armka:counter-pairs:'; + +/** Ключ списка пар — не зависит от порядка: `holder>missing`, отсортированные, через запятую. */ +export const counterPairsKey = (pairs: readonly CounterPairOut[]): string => + pairs + .map((p) => `${p.holder_id}>${p.missing_id}`) + .sort() + .join(','); + +/** + * Показывать ли модалку о парах без встречных записей (`05-domain-rules.md` §3 п. 9): есть пары, версия не закрыта и + * этот же список пар в сессии ещё не показывали. Хранилище недоступно или бросает — показываем. + */ +export const shouldWarnCounterPairs = ( + versionId: string, + status: ConsStatus | undefined, + pairs: readonly CounterPairOut[], + storage: Pick | null, +): boolean => { + if (pairs.length === 0) return false; + if (status && FINISHED_STATUSES.includes(status)) return false; + try { + return storage?.getItem(`${COUNTER_PAIRS_STORAGE_PREFIX}${versionId}`) !== counterPairsKey(pairs); + } catch { + return true; + } +}; + +/** Запоминает показанный список пар; ошибки хранилища (приватный режим) глотаем. */ +export const rememberCounterPairsShown = ( + versionId: string, + pairs: readonly CounterPairOut[], + storage: Pick | null, +): void => { + try { + storage?.setItem(`${COUNTER_PAIRS_STORAGE_PREFIX}${versionId}`, counterPairsKey(pairs)); + } catch { + // хранилище недоступно — модалка просто покажется снова + } +}; + +/** Тексты модалки «Проверьте встречные записи участников». */ +export const counterPairsWarning = (pairs: readonly CounterPairOut[], unmappedTotal: number): { headline: string; details: string } => { + const holders = new Set(pairs.map((p) => p.holder_id)).size; + const base = 'Возможно, контрагент среди строк без ИНН, его ИНН не найден в справочнике или это пропуск в учёте.'; + return { + headline: `Нет встречных записей: ${pairs.length} ${pluralRu(pairs.length, ['пара', 'пары', 'пар'])} у ${holders} ${pluralRu(holders, ['участника', 'участников', 'участников'])}.`, + details: + unmappedTotal > 0 + ? `${base} Ещё ${unmappedTotal} ${pluralRu(unmappedTotal, ['строка', 'строки', 'строк'])} без ИНН учтены как внешние.` + : base, + }; +}; diff --git a/web/src/widgets/ConsolidatedOsv/counterPairs.test.ts b/web/src/widgets/ConsolidatedOsv/counterPairs.test.ts new file mode 100644 index 0000000..0e40837 --- /dev/null +++ b/web/src/widgets/ConsolidatedOsv/counterPairs.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import type { CounterPairOut } from '../../types/api'; +import { pairLabel, pairsViewSearch, readPairsView, selectPair } from './counterPairs'; + +const pair = (holder: string, missing: string): CounterPairOut => ({ + holder_id: holder, + holder_name: `H${holder}`, + missing_id: missing, + missing_name: `M${missing}`, + holder_rows: 1, + missing_unmapped_rows: 0, + missing_rows_total: 0, +}); + +describe('readPairsView / pairsViewSearch', () => { + it('table по умолчанию', () => { + expect(readPairsView(new URLSearchParams(''))).toEqual({ view: 'table', holder: null, missing: null }); + expect(pairsViewSearch({ view: 'table', holder: 'a', missing: 'b' })).toBe(''); + }); + it('туда-обратно', () => { + const v = { view: 'pairs' as const, holder: 'a', missing: 'b' }; + const s = pairsViewSearch(v); + expect(s).toBe('?view=pairs&holder=a&missing=b'); + expect(readPairsView(new URLSearchParams(s))).toEqual(v); + expect(pairsViewSearch({ view: 'pairs', holder: null, missing: null })).toBe('?view=pairs'); + }); +}); + +describe('selectPair', () => { + const pairs = [pair('a', 'b'), pair('c', 'd')]; + it('заданная пара', () => { + expect(selectPair(pairs, 'c', 'd')).toBe(pairs[1]); + }); + it('selectPair falls back to first', () => { + expect(selectPair(pairs, 'x', 'y')).toBe(pairs[0]); + expect(selectPair(pairs, null, null)).toBe(pairs[0]); + }); + it('пусто — null', () => { + expect(selectPair([], 'a', 'b')).toBeNull(); + }); +}); + +describe('pairLabel', () => { + it('missing ← holder', () => { + expect(pairLabel(pair('a', 'b'))).toBe('Mb ← Ha'); + }); +}); diff --git a/web/src/widgets/ConsolidatedOsv/counterPairs.ts b/web/src/widgets/ConsolidatedOsv/counterPairs.ts new file mode 100644 index 0000000..214190d --- /dev/null +++ b/web/src/widgets/ConsolidatedOsv/counterPairs.ts @@ -0,0 +1,25 @@ +// Режим «Встречные записи» вкладки «ОСВ»: состояние в адресной строке и выбор пары. Без React и API. +import type { CounterPairOut } from '../../types/api'; + +export type PairsView = { view: 'table' | 'pairs'; holder: string | null; missing: string | null }; + +/** `?view=pairs[&holder=..&missing=..]`; всё остальное — обычная таблица свода. */ +export const readPairsView = (search: URLSearchParams): PairsView => + search.get('view') === 'pairs' + ? { view: 'pairs', holder: search.get('holder'), missing: search.get('missing') } + : { view: 'table', holder: null, missing: null }; + +/** Строка запроса для режима; у таблицы пусто. */ +export const pairsViewSearch = (v: PairsView): string => { + if (v.view !== 'pairs') return ''; + const params = new URLSearchParams({ view: 'pairs' }); + if (v.holder) params.set('holder', v.holder); + if (v.missing) params.set('missing', v.missing); + return `?${params.toString()}`; +}; + +/** Заданная пара; нет такой (закрыта сопоставлением) — первая оставшаяся; список пуст — `null`. */ +export const selectPair = (pairs: readonly CounterPairOut[], holder: string | null, missing: string | null): CounterPairOut | null => + pairs.find((p) => p.holder_id === holder && p.missing_id === missing) ?? pairs[0] ?? null; + +export const pairLabel = (p: CounterPairOut): string => `${p.missing_name} ← ${p.holder_name}`;