feat(#195): типы и правила показа пар без встречных записей (web)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
c24bc3d4c1
commit
8513a8d10e
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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(
|
||||
'Возможно, контрагент среди строк без ИНН, его ИНН не найден в справочнике или это пропуск в учёте.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
47
web/src/widgets/ConsolidatedOsv/counterPairs.test.ts
Normal file
47
web/src/widgets/ConsolidatedOsv/counterPairs.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
25
web/src/widgets/ConsolidatedOsv/counterPairs.ts
Normal file
25
web/src/widgets/ConsolidatedOsv/counterPairs.ts
Normal 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}`;
|
||||
Loading…
x
Reference in New Issue
Block a user