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('страница', () => {
|
it('страница', () => {
|
||||||
expect(buildOsvQuery({ counterparty_type: 'GSK' }, { limit: 500, offset: 1000 })).toBe('?counterparty_type=GSK&limit=500&offset=1000');
|
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.account_root) params.set('account_root', filters.account_root);
|
||||||
if (filters.counterparty_type) params.set('counterparty_type', filters.counterparty_type);
|
if (filters.counterparty_type) params.set('counterparty_type', filters.counterparty_type);
|
||||||
if (filters.unmapped) params.set('unmapped', 'true');
|
if (filters.unmapped) params.set('unmapped', 'true');
|
||||||
|
if (filters.counterparty_id) params.set('counterparty_id', filters.counterparty_id);
|
||||||
const q = filters.q?.trim();
|
const q = filters.q?.trim();
|
||||||
if (q) params.set('q', q);
|
if (q) params.set('q', q);
|
||||||
if (page) {
|
if (page) {
|
||||||
|
|||||||
@ -357,18 +357,30 @@ export interface GapOut {
|
|||||||
account_code: string;
|
account_code: string;
|
||||||
missing_quarters: Quarter[];
|
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 {
|
export interface OsvSummaryOut {
|
||||||
rows_total: number;
|
rows_total: number;
|
||||||
unmapped_total: number;
|
unmapped_total: number;
|
||||||
auto_matched_total: number;
|
auto_matched_total: number;
|
||||||
documents_total: number;
|
documents_total: number;
|
||||||
gaps: GapOut[];
|
gaps: GapOut[];
|
||||||
|
counter_pairs: CounterPairOut[];
|
||||||
}
|
}
|
||||||
export interface OsvFilters {
|
export interface OsvFilters {
|
||||||
legal_entity_id?: string;
|
legal_entity_id?: string;
|
||||||
account_root?: string;
|
account_root?: string;
|
||||||
counterparty_type?: CounterpartyType;
|
counterparty_type?: CounterpartyType;
|
||||||
unmapped?: boolean;
|
unmapped?: boolean;
|
||||||
|
counterparty_id?: string;
|
||||||
q?: 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 type { ConsStatus, LockOut } from '../types/consolidations/common_types';
|
||||||
import {
|
import {
|
||||||
CONS_STATUS_LABEL,
|
CONS_STATUS_LABEL,
|
||||||
|
FINISHED_STATUSES,
|
||||||
MANUAL_TRANSITIONS,
|
MANUAL_TRANSITIONS,
|
||||||
canChangeStatus,
|
canChangeStatus,
|
||||||
canSubmitStatusChange,
|
canSubmitStatusChange,
|
||||||
canTakeInWork,
|
canTakeInWork,
|
||||||
consStatusDescription,
|
consStatusDescription,
|
||||||
|
counterPairsKey,
|
||||||
|
counterPairsWarning,
|
||||||
endSentence,
|
endSentence,
|
||||||
isLockedBy,
|
isLockedBy,
|
||||||
isValidReason,
|
isValidReason,
|
||||||
manualTransitions,
|
manualTransitions,
|
||||||
needsUnmappedWarning,
|
needsUnmappedWarning,
|
||||||
parseInWorkOnly,
|
parseInWorkOnly,
|
||||||
|
rememberCounterPairsShown,
|
||||||
reportTabNavigation,
|
reportTabNavigation,
|
||||||
|
shouldWarnCounterPairs,
|
||||||
versionLabel,
|
versionLabel,
|
||||||
} from './consolidations';
|
} 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`): подписи и пояснения статусов версии, ручные
|
// Чистые правила экранов консолидаций (спека `consolidations-ui`): подписи и пояснения статусов версии, ручные
|
||||||
// переходы, блокировка, «Версия», фильтр «Только в работе». Без React, store и API.
|
// переходы, блокировка, «Версия», фильтр «Только в работе». Без React, store и API.
|
||||||
|
import type { CounterPairOut } from '../types/api';
|
||||||
import type { ConsRussianStatus, ConsStatus, LockOut } from '../types/consolidations/common_types';
|
import type { ConsRussianStatus, ConsStatus, LockOut } from '../types/consolidations/common_types';
|
||||||
|
import { pluralRu } from './labels';
|
||||||
|
|
||||||
/** Подписи статусов версии — одни для списка, фильтра «Статус», шапки карточки и модалки «Изменить статус». */
|
/** Подписи статусов версии — одни для списка, фильтра «Статус», шапки карточки и модалки «Изменить статус». */
|
||||||
export const CONS_STATUS_LABEL: Record<ConsStatus, ConsRussianStatus> = {
|
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}`),
|
to: pathname.replace(new RegExp(`/${activeKey}/?$`), `/${newKey}`),
|
||||||
options: { replace: true },
|
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