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:
parent
3bd8a22e33
commit
0f7a101161
@ -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); остальные цифры не меняются.
|
||||
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
),
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@ -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}` }}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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 =>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user