fix(web): синхронизация встроенного фронта с arm-web — ячейка ИНН в сводной ОСВ (#94)

Перенесён коммит arm-web 0f7a101 (#17): «Указать» только у строк без ИНН и без
связи с ЮЛ, «таблетка» с ИНН сопоставленного ЮЛ и пересопоставление («Связано»
в модалке), фильтр «только без ИНН» в колонке, баннер по макету [32-osv-no-inn],
поле counterparty_entity_inn в типах (бэкенд — arm-core #74). После переноса
исходники web/ и arm-web@test совпадают.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-09 22:38:22 +04:00
parent fde860be7c
commit f7cdd42c3d
7 changed files with 136 additions and 41 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

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

@ -189,6 +189,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 =>