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:
parent
fde860be7c
commit
f7cdd42c3d
@ -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); остальные цифры не меняются.
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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