diff --git a/README.md b/README.md index 3001d09..fe7f5f4 100644 --- a/README.md +++ b/README.md @@ -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); остальные цифры не меняются. diff --git a/openspec/specs/consolidated-osv-ui/spec.md b/openspec/specs/consolidated-osv-ui/spec.md index 16c7197..5a89ee6 100644 --- a/openspec/specs/consolidated-osv-ui/spec.md +++ b/openspec/specs/consolidated-osv-ui/spec.md @@ -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` diff --git a/src/layouts/ConsolidatedOsvPage.tsx b/src/layouts/ConsolidatedOsvPage.tsx index f34e933..f9dcd0e 100644 --- a/src/layouts/ConsolidatedOsvPage.tsx +++ b/src/layouts/ConsolidatedOsvPage.tsx @@ -24,7 +24,7 @@ import { OSV_PAGE_SIZE, useConsolidatedOsv } from '../widgets/ConsolidatedOsv/us function OsvContent({ versionId }: { versionId: string }) { const [card, setCard] = useState(null); const [exporting, setExporting] = useState(false); - // строка, для которой открыт выбор организации («Указать») + // строка, для которой открыт выбор организации («Указать» или ИНН сопоставленного ЮЛ) const [mapping, setMapping] = useState(null); const osv = useConsolidatedOsv(versionId); @@ -96,24 +96,26 @@ function OsvContent({ versionId }: { versionId: string }) { {osv.error && } {unmapped > 0 && ( + // `osv-mapping-alert` макета [30] / [32-osv-no-inn]
- Требуется указать соответствия + Требуется указать соответствия Для перехода к следующему шагу сопоставьте все внешние организации без ИНН. Осталось: {unmapped}. + {/* макет оставляет кнопку как есть, снимать фильтр — воронкой ИНН или «Сбросить фильтры» (панель — позже) */} diff --git a/src/types/api.ts b/src/types/api.ts index 91f6943..2130d4c 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -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; diff --git a/src/widgets/ConsolidatedOsv/CounterpartyMapModal.tsx b/src/widgets/ConsolidatedOsv/CounterpartyMapModal.tsx index ce88b34..5c7b22e 100644 --- a/src/widgets/ConsolidatedOsv/CounterpartyMapModal.tsx +++ b/src/widgets/ConsolidatedOsv/CounterpartyMapModal.tsx @@ -191,16 +191,23 @@ export function CounterpartyMapModal({ versionId, row, onClose, onMapped }: Prop width: 110, align: 'center', onCell: (r) => sectionSpan(r), - render: cell((c) => ( - - )), + render: cell((c) => + // текущая связь строки — «Связано» без действия (пересопоставление, макет [32-osv-map-counterparty]) + c.id === row.counterparty_id ? ( + + ) : ( + + ), + ), }, ]; diff --git a/src/widgets/ConsolidatedOsv/OsvTable.tsx b/src/widgets/ConsolidatedOsv/OsvTable.tsx index f96ccab..9df6948 100644 --- a/src/widgets/ConsolidatedOsv/OsvTable.tsx +++ b/src/widgets/ConsolidatedOsv/OsvTable.tsx @@ -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 ( + onChange({ unmapped: draft })} + onReset={() => { + setDraft(false); + onChange({ unmapped: false }); + }}> + setDraft(e.target.checked)}> + Только без ИНН и без связи с ЮЛ + + + ); +} + // API принимает один counterparty_type — поэтому выбор одного значения, а не галочки макета function CounterpartyTypeFilter({ type, onChange, close }: FilterProps & { type?: CounterpartyType }) { const [draft, setDraft] = useState(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 ( - - {row.counterparty_inn} - - + ); + return ( + ); } @@ -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) => , + filterIcon: filterIcon(!!filters.unmapped), + filterDropdown: ({ 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}` }} /> ); diff --git a/src/widgets/ConsolidatedOsv/counterpartyMapping.test.ts b/src/widgets/ConsolidatedOsv/counterpartyMapping.test.ts index 8614a07..cf9f8dc 100644 --- a/src/widgets/ConsolidatedOsv/counterpartyMapping.test.ts +++ b/src/widgets/ConsolidatedOsv/counterpartyMapping.test.ts @@ -6,6 +6,7 @@ import { aliasOwners, buildCandidatesQuery, candidateRows, + innCell, lockedByName, mapEntryId, mapErrorKind, @@ -26,6 +27,7 @@ const row = (patch: Partial = {}): 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', () => { diff --git a/src/widgets/ConsolidatedOsv/counterpartyMapping.ts b/src/widgets/ConsolidatedOsv/counterpartyMapping.ts index 5149047..469eefa 100644 --- a/src/widgets/ConsolidatedOsv/counterpartyMapping.ts +++ b/src/widgets/ConsolidatedOsv/counterpartyMapping.ts @@ -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 =>