feat(osv): ячейка ИНН по макету — «Указать» только без ИНН, ИНН сопоставленного ЮЛ с пересопоставлением (#17)

Решения владельца 08.10.2026 (arm-core #74, отмена П-1):
- ИНН из файла — просто текстом, без «Указать», даже если ЮЛ не найдено;
- строка сопоставлена без ИНН в файле — «таблетка» с `counterparty_entity_inn`, клик открывает выбор организации,
  у текущего ЮЛ — неактивное «Связано» (закрывает D-1 из #16);
- нет ни ЮЛ, ни ИНН — «Указать» на подсвеченной ячейке.
Колонка ИНН — моноширинный шрифт по центру, воронка фильтра `unmapped`; баннер и кнопка «Показать ЮЛ без ИНН» —
цвета `osv-mapping-alert` макета. Спека consolidated-osv-ui и README (1381 → 1260) обновлены.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019qoydUVv8g1qHxc123u2RR
This commit is contained in:
Andrey Epifancev 2026-10-08 19:59:21 +04:00
parent 3bd8a22e33
commit 0f7a101161
8 changed files with 150 additions and 46 deletions

View File

@ -38,7 +38,7 @@ SPA кредитного аналитика поверх API `arm-core` (`/api/v
4. «К отчётности» → сводная ОСВ; фильтр «Счастливочная» + «бисквит» → строка `60.01` (сальдо нач. Кт 4 454 151.93,
обороты Дт 128 058 143.65, Кт 125 950 450.77, сальдо кон. Кт 2 346 459.05, `GSK`); «Скачать xlsx».
Ожидаемая сводка на чистой версии (`qa-guide` §7.2): `строк: 1385 документов: 22 несопоставленных: 1381 пробелов: 7`.
Ожидаемая сводка на чистой версии (`qa-guide` §7.2): `строк: 1385 документов: 22 несопоставленных: 1260 пробелов: 7`.
Если на стенде раньше вручную сопоставляли контрагентов (K14), алиасы сохраняются между версиями, и
«несопоставленных» будет меньше (на стенде прогона — 1349); остальные цифры не меняются.

View File

@ -8,11 +8,12 @@
### Requirement: Сводка версии
Экран «Сводная ОСВ» [30] SHALL показывать шапку версии (ГК, период, номер, статус) и сводку
`GET /consolidations/versions/{id}/osv/summary`: строк всего, документов, несопоставленных (`unmapped_total` — счётчик
«Осталось N»; сопоставление — «Указать» в строке), автоматически сопоставленных.
«Осталось N» — строки без сопоставленного ЮЛ и без ИНН из файла, arm-core #74; сопоставление — «Указать» в строке),
автоматически сопоставленных.
#### Scenario: Контрольные цифры РБ 2024 Q3
- **WHEN** открыт свод версии РБ 2024 Q3 после подтверждения 22 ОСВ из `fixtures/osv/{rb,schastl}/2024-Q{1,2,3}`
- **THEN** «строк» = 1385, «документов» = 22, «несопоставленных» = 1381
- **THEN** «строк» = 1385, «документов» = 22, «несопоставленных» = 1260
### Requirement: Таблица свода и фильтры
Экран SHALL показывать строки `GET /consolidations/versions/{id}/osv` постранично (размер страницы ≤ 2000): ЮЛ, счёт,
@ -20,7 +21,8 @@
разделителями разрядов) и последней колонкой «Тип контрагента» — бейдж ГСК (синий) / ГК (зелёный) / Внешний (серый), как
в макете [30]. Фильтры — в заголовках колонок: ЮЛ (`legal_entity_id`), корень счёта (`account_root`), поиск по
контрагенту (`q`, ≤ 500 символов), тип контрагента (`counterparty_type`, одно значение — на колонке «Тип контрагента»);
только несопоставленные (`unmapped`) — кнопкой над таблицей. «Применить» и «Сбросить» SHALL закрывать окно фильтра.
только несопоставленные (`unmapped`) — кнопкой «Показать ЮЛ без ИНН» в баннере и воронкой колонки «ИНН» (подсвечена,
пока фильтр включён, макет [32-osv-no-inn]). «Применить» и «Сбросить» SHALL закрывать окно фильтра.
Смена фильтра SHALL перечитывать первую страницу.
#### Scenario: Строка из контрольных цифр
@ -29,8 +31,15 @@
сальдо кон. Кт 2 346 459.05, признак «ГСК»
### Requirement: Ручное сопоставление контрагента
У строки без сопоставленного ЮЛ (`counterparty_id = null`) ячейка ИНН SHALL быть подсвечена и содержать ссылку
«Указать» (рядом с ИНН из файла, если он есть), как в макете [32-osv-no-inn]. «Указать» SHALL открывать модалку
Ячейка ИНН (моноширинный шрифт, по центру) SHALL показывать, как в макете [32-osv-no-inn]:
- ИНН из файла (`counterparty_inn`) — просто текстом, сопоставлена строка или нет (строка с ИНН сопоставления не требует,
решение владельца 08.10.2026, отмена П-1);
- строка сопоставлена, ИНН в файле нет — ИНН ЮЛ справочника (`counterparty_entity_inn`) кнопкой-«таблеткой»
(`osv-inn-mapped-button`); нажатие открывает ту же модалку для пересопоставления, у текущего ЮЛ вместо «Связать» —
неактивное «Связано»;
- нет ни ЮЛ, ни ИНН — подсвеченная ячейка со ссылкой «Указать» (`osv-inn-link-button`).
«Указать» и «таблетка» SHALL открывать модалку
«Выбор организации для связи с <имя контрагента из файла>» [32-osv-map-counterparty]: поиск, таблица ИНН / Организация /
ГК / ГСК / ГСЗ / «Связать», рекомендуемые (`recommended`) — первыми под заголовком «Рекомендуемые» с зелёной подсветкой.
Кандидаты — `GET …/counterparty-candidates?scope=&entry_id=&q=` с паузой 300 мс после ввода: по умолчанию `scope=group`

View File

@ -24,7 +24,7 @@ import { OSV_PAGE_SIZE, useConsolidatedOsv } from '../widgets/ConsolidatedOsv/us
function OsvContent({ versionId }: { versionId: string }) {
const [card, setCard] = useState<VersionCard | null>(null);
const [exporting, setExporting] = useState(false);
// строка, для которой открыт выбор организации («Указать»)
// строка, для которой открыт выбор организации («Указать» или ИНН сопоставленного ЮЛ)
const [mapping, setMapping] = useState<ConsolidatedRowOut | null>(null);
const osv = useConsolidatedOsv(versionId);
@ -96,24 +96,26 @@ function OsvContent({ versionId }: { versionId: string }) {
</div>
{osv.error && <Alert type='error' showIcon title={osv.error} />}
{unmapped > 0 && (
// `osv-mapping-alert` макета [30] / [32-osv-no-inn]
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
gap: 10,
flexWrap: 'wrap',
background: '#fffbe6',
border: '1px solid #ffe58f',
borderRadius: 8,
padding: '8px 12px',
color: '#ad6800',
background: '#fffaeb',
border: '1px solid #fedf89',
borderRadius: 12,
padding: '10px 12px',
color: '#93370d',
fontSize: 13,
}}>
<strong>Требуется указать соответствия</strong>
<strong style={{ color: '#7a2e0e' }}>Требуется указать соответствия</strong>
<span>Для перехода к следующему шагу сопоставьте все внешние организации без ИНН. Осталось: {unmapped}.</span>
{/* макет оставляет кнопку как есть, снимать фильтр — воронкой ИНН или «Сбросить фильтры» (панель — позже) */}
<Button
style={{ marginLeft: 'auto' }}
style={{ marginLeft: 'auto', minHeight: 30, borderColor: '#f4b740', background: '#fff', color: '#93370d' }}
icon={<FilterOutlined />}
type={onlyUnmapped ? 'primary' : 'default'}
onClick={() => osv.patchFilters({ unmapped: !onlyUnmapped })}>
{onlyUnmapped ? 'Показать все строки' : 'Показать ЮЛ без ИНН'}
</Button>

View File

@ -187,6 +187,8 @@ export interface ConsolidatedRowOut {
counterparty_inn: string | null;
counterparty_id: string | null;
counterparty_entity_name: string | null;
/** ИНН сопоставленного ЮЛ справочника; `counterparty_inn` — ИНН из файла (arm-core #74). */
counterparty_entity_inn: string | null;
counterparty_type: CounterpartyType;
match_kind: MatchKind | null;
opening_debit: string | null;

View File

@ -191,16 +191,23 @@ export function CounterpartyMapModal({ versionId, row, onClose, onMapped }: Prop
width: 110,
align: 'center',
onCell: (r) => sectionSpan(r),
render: cell((c) => (
<Button
type='primary'
size='small'
loading={submittingId === c.id}
disabled={submittingId !== null && submittingId !== c.id}
onClick={() => void link(c, false, false)}>
Связать
</Button>
)),
render: cell((c) =>
// текущая связь строки — «Связано» без действия (пересопоставление, макет [32-osv-map-counterparty])
c.id === row.counterparty_id ? (
<Button size='small' disabled>
Связано
</Button>
) : (
<Button
type='primary'
size='small'
loading={submittingId === c.id}
disabled={submittingId !== null && submittingId !== c.id}
onClick={() => void link(c, false, false)}>
Связать
</Button>
),
),
},
];

View File

@ -1,12 +1,12 @@
import { FilterFilled } from '@ant-design/icons';
import { Button, Input, Select, Space, Table } from 'antd';
import { Button, Checkbox, Input, Select, Space, Table } from 'antd';
import type { TableColumnsType } from 'antd';
import { useState } from 'react';
import { TableTag } from '../../components/TableTag/TableTag';
import type { ConsolidatedRowOut, CounterpartyType, OsvFilters } from '../../types/api';
import { counterpartyTypeLabel, osvPeriodLabel } from '../../utils/labels';
import { formatAmount } from '../../utils/money';
import { needsMapping } from './counterpartyMapping';
import { innCell, needsMapping } from './counterpartyMapping';
import { COUNTERPARTY_TYPES, counterpartyTypeBadge } from './counterpartyTypeBadge';
import type { OsvFilterDraft } from './osvFilterState';
@ -135,6 +135,24 @@ function CounterpartyFilter({ q, onChange, close }: FilterProps & { q?: string }
);
}
// «Показать ЮЛ без ИНН» баннера — тот же фильтр `unmapped`; в макете [32-osv-no-inn] его воронка в колонке ИНН подсвечена
function UnmappedFilter({ value, onChange, close }: FilterProps & { value: boolean }) {
const [draft, setDraft] = useState(value);
return (
<DropdownShell
close={close}
onApply={() => onChange({ unmapped: draft })}
onReset={() => {
setDraft(false);
onChange({ unmapped: false });
}}>
<Checkbox checked={draft} onChange={(e) => setDraft(e.target.checked)}>
Только без ИНН и без связи с ЮЛ
</Checkbox>
</DropdownShell>
);
}
// API принимает один counterparty_type — поэтому выбор одного значения, а не галочки макета
function CounterpartyTypeFilter({ type, onChange, close }: FilterProps & { type?: CounterpartyType }) {
const [draft, setDraft] = useState<CounterpartyType | undefined>(type);
@ -173,23 +191,44 @@ type Props = {
members: Member[];
onPage: (p: number) => void;
onFilter: OnChange;
/** «Указать» у строки без сопоставленного ЮЛ — открыть выбор организации [32-osv-map-counterparty]. */
/** «Указать» или ИНН сопоставленного ЮЛ — открыть выбор организации [32-osv-map-counterparty]. */
onMap: (row: ConsolidatedRowOut) => void;
};
// Подсветка ячейки и ссылки «Указать» — `osv-table__inn-cell--required` / `osv-inn-link-button` макета [32-osv-no-inn]
const REQUIRED_CELL: React.CSSProperties = { background: '#fff4ed', boxShadow: 'inset 0 0 0 1px #fdba74' };
const MAP_LINK: React.CSSProperties = { padding: 0, height: 'auto', color: '#b42318', fontSize: 13, fontWeight: 700 };
// Ячейка ИНН макета [32-osv-no-inn]: `osv-table__inn-cell` (моноширинный, по центру), `--required` (подсветка),
// `osv-inn-link-button` («Указать»), `osv-inn-mapped-button` (ИНН сопоставленного ЮЛ — пересопоставить)
const MONO = 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace';
const INN_CELL: React.CSSProperties = { fontFamily: MONO, fontVariantNumeric: 'tabular-nums', color: '#667085', textAlign: 'center' };
const REQUIRED_CELL: React.CSSProperties = { ...INN_CELL, background: '#fff4ed', boxShadow: 'inset 0 0 0 1px #fdba74' };
const MAP_LINK: React.CSSProperties = { padding: 0, height: 'auto', color: '#b42318', fontSize: 13, fontWeight: 700, lineHeight: 1.4 };
const MAPPED_PILL: React.CSSProperties = {
minHeight: 26,
height: 'auto',
padding: '2px 9px',
border: '1px solid #d0d5dd',
borderRadius: 999,
background: '#f9fafb',
color: '#667085',
fontFamily: MONO,
fontSize: 13,
fontWeight: 600,
lineHeight: 1.3,
boxShadow: 'none',
};
function InnCell({ row, onMap }: { row: ConsolidatedRowOut; onMap: (row: ConsolidatedRowOut) => void }) {
if (!needsMapping(row)) return <>{row.counterparty_inn}</>;
return (
<Space size={8} wrap>
{row.counterparty_inn}
<Button type='link' style={MAP_LINK} onClick={() => onMap(row)}>
Указать
const view = innCell(row);
if (view.kind === 'inn') return <>{view.inn}</>;
if (view.kind === 'mapped')
return (
<Button style={MAPPED_PILL} title='Связано с организацией справочника — изменить' onClick={() => onMap(row)}>
{view.inn ?? 'Изменить'}
</Button>
</Space>
);
return (
<Button type='link' style={MAP_LINK} onClick={() => onMap(row)}>
Указать
</Button>
);
}
@ -236,10 +275,15 @@ export function OsvTable({ rows, total, page, pageSize, loading, filters, member
{
key: 'counterparty_inn',
title: 'ИНН',
// шире макетных 130: ИНН из файла и «Указать» в одну строку
width: 190,
onCell: (r) => (needsMapping(r) ? { style: REQUIRED_CELL } : {}),
align: 'center',
// шире макетных 128: «таблетка» с 12-значным ИНН ИП
width: 140,
onCell: (r) => ({ style: needsMapping(r) ? REQUIRED_CELL : INN_CELL }),
render: (_: unknown, r) => <InnCell row={r} onMap={onMap} />,
filterIcon: filterIcon(!!filters.unmapped),
filterDropdown: ({ close }) => (
<UnmappedFilter key={String(!!filters.unmapped)} value={!!filters.unmapped} onChange={onFilter} close={close} />
),
},
group('Сальдо на начало периода', [money('opening_debit', 'Дебет'), money('opening_credit', 'Кредит')]),
group('Обороты за период', [money('turnover_debit', 'Дебет'), money('turnover_credit', 'Кредит')]),
@ -267,7 +311,7 @@ export function OsvTable({ rows, total, page, pageSize, loading, filters, member
locale={{
emptyText: filtered ? 'По заданным фильтрам строк нет' : 'В своде пока нет строк: загрузите и подтвердите ОСВ организаций',
}}
scroll={{ x: 1890, y: 'calc(100vh - 460px)' }}
scroll={{ x: 1840, y: 'calc(100vh - 460px)' }}
pagination={{ current: page, pageSize, total, showSizeChanger: false, onChange: onPage, showTotal: (t) => `Строк: ${t}` }}
/>
);

View File

@ -6,6 +6,7 @@ import {
aliasOwners,
buildCandidatesQuery,
candidateRows,
innCell,
lockedByName,
mapEntryId,
mapErrorKind,
@ -26,6 +27,7 @@ const row = (patch: Partial<ConsolidatedRowOut> = {}): ConsolidatedRowOut => ({
counterparty_inn: null,
counterparty_id: null,
counterparty_entity_name: null,
counterparty_entity_inn: null,
counterparty_type: 'EXTERNAL',
match_kind: null,
opening_debit: null,
@ -49,10 +51,36 @@ const candidate = (id: string, recommended = false): CandidateOut => ({
recommended,
});
describe('innCell — ячейка ИНН по макету [32-osv-no-inn]', () => {
it('ИНН из файла — просто ИНН, сопоставлена строка или нет', () => {
expect(innCell(row({ counterparty_inn: '7701000001' }))).toEqual({ kind: 'inn', inn: '7701000001' });
expect(innCell(row({ counterparty_inn: '3528052718', counterparty_id: 'rb', counterparty_entity_inn: '3528052718' }))).toEqual({
kind: 'inn',
inn: '3528052718',
});
});
it('сопоставлена без ИНН в файле — ИНН ЮЛ справочника с пересопоставлением', () => {
expect(innCell(row({ counterparty_id: 'sch', counterparty_entity_inn: '3528000001', match_kind: 'ALIAS' }))).toEqual({
kind: 'mapped',
inn: '3528000001',
});
// ИНН ЮЛ не пришёл (старый бэкенд) — кнопка всё равно есть, текст «Изменить»
expect(innCell(row({ counterparty_id: 'sch' }))).toEqual({ kind: 'mapped', inn: null });
});
it('без ЮЛ и без ИНН — «Указать»', () => {
expect(innCell(row())).toEqual({ kind: 'required' });
expect(innCell(row({ counterparty_inn: ' ' }))).toEqual({ kind: 'required' });
});
});
describe('needsMapping / mapEntryId', () => {
it('«Указать» — только у строки без сопоставленного ЮЛ', () => {
it('«Указать» — только у строки без сопоставленного ЮЛ и без ИНН из файла (отмена П-1, arm-core #74)', () => {
expect(needsMapping(row())).toBe(true);
expect(needsMapping(row({ counterparty_inn: ' \t' }))).toBe(true);
expect(needsMapping(row({ counterparty_id: 'x' }))).toBe(false);
expect(needsMapping(row({ counterparty_inn: '7701000001' }))).toBe(false);
});
it('склеенная из кварталов строка сопоставляется по последней entry_id', () => {

View File

@ -5,8 +5,20 @@ import type { CandidateOut, CandidateScope, ConsolidatedRowOut, MapCounterpartyO
export const ALL_SCOPE_MIN_QUERY = 3;
const MAX_QUERY = 500;
/** «Указать» — у строки без сопоставленного ЮЛ (как баннер «Осталось N» = `unmapped_total`). */
export const needsMapping = (row: ConsolidatedRowOut): boolean => row.counterparty_id === null;
const hasInn = (inn: string | null): inn is string => !!inn && inn.replace(/\s/g, '') !== '';
/** «Указать» — у строки без сопоставленного ЮЛ и без ИНН из файла: так же сервер считает `unmapped_total` (отмена П-1, arm-core #74). */
export const needsMapping = (row: ConsolidatedRowOut): boolean => row.counterparty_id === null && !hasInn(row.counterparty_inn);
/** Что показать в ячейке ИНН сводной ОСВ (макет [32-osv-no-inn]): ИНН из файла; ИНН сопоставленного ЮЛ кнопкой
* пересопоставления (`osv-inn-mapped-button`), если в файле ИНН нет; иначе «Указать» (`osv-inn-link-button`). */
export type InnCellView = { kind: 'inn'; inn: string } | { kind: 'mapped'; inn: string | null } | { kind: 'required' };
export function innCell(row: ConsolidatedRowOut): InnCellView {
if (hasInn(row.counterparty_inn)) return { kind: 'inn', inn: row.counterparty_inn };
if (row.counterparty_id !== null) return { kind: 'mapped', inn: row.counterparty_entity_inn };
return { kind: 'required' };
}
/** Склеенная из кварталов строка несёт несколько `entry_ids` (от раннего к позднему); сопоставляется последняя. */
export const mapEntryId = (row: ConsolidatedRowOut): string =>