feat(#195): типы и правила показа пар без встречных записей (web)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-27 18:51:45 +04:00
parent c24bc3d4c1
commit 8513a8d10e
7 changed files with 230 additions and 1 deletions

View File

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

View File

@ -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) {

View File

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

View File

@ -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(
'Возможно, контрагент среди строк без ИНН, его ИНН не найден в справочнике или это пропуск в учёте.',
);
});
});

View File

@ -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<ConsStatus, ConsRussianStatus> = {
@ -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:<version_id>`, значение — `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<Storage, 'getItem'> | 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<Storage, 'setItem'> | 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,
};
};

View File

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

View File

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