From e593236eed23bd14eaf5e0078b73323ae7bc86ff Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 8 Oct 2026 00:51:47 +0400 Subject: [PATCH] =?UTF-8?q?feat:=20apiFetchBlob,=20=D0=B4=D0=B5=D0=BD?= =?UTF-8?q?=D1=8C=D0=B3=D0=B8=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B0=D0=BC?= =?UTF-8?q?=D0=B8,=20=D0=BF=D0=BE=D0=B4=D0=BF=D0=B8=D1=81=D0=B8=20=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D1=82=D1=83=D1=81=D0=BE=D0=B2,=20dev-proxy=20(#3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Клиент API: ApiError.body, apiFetchBlob для бинарных ответов с автоматическим разбором ошибок - Деньги: formatAmount (строка Decimal → форматированная с пробелами), parseAmountInput (ввод → нормализация) - Подписи: documentTypeLabel, documentStatusLabel, fileStatusLabel, periodLabel для UI - Dev-proxy: /api → localhost:8000 (CORS-free, Content-Disposition читается) - Типы: Quarter, DocumentType, DocumentStatus, FileStatus в src/types/api.ts - Удалён smoke.test.ts Co-Authored-By: Claude Haiku 4.5 Claude-Session: https://claude.ai/code/session_01EAKRDNzXtRrwmWz9z6PZu3 --- rsbuild.config.ts | 6 ++++- src/api/client.ts | 34 +++++++++++++++++++------- src/types/api.ts | 6 +++++ src/utils/download.test.ts | 24 +++++++++++++++++++ src/utils/download.ts | 34 ++++++++++++++++++++++++++ src/utils/labels.test.ts | 31 ++++++++++++++++++++++++ src/utils/labels.ts | 49 ++++++++++++++++++++++++++++++++++++++ src/utils/money.test.ts | 46 +++++++++++++++++++++++++++++++++++ src/utils/money.ts | 29 ++++++++++++++++++++++ src/utils/smoke.test.ts | 7 ------ 10 files changed, 249 insertions(+), 17 deletions(-) create mode 100644 src/types/api.ts create mode 100644 src/utils/download.test.ts create mode 100644 src/utils/download.ts create mode 100644 src/utils/labels.test.ts create mode 100644 src/utils/labels.ts create mode 100644 src/utils/money.test.ts create mode 100644 src/utils/money.ts delete mode 100644 src/utils/smoke.test.ts diff --git a/rsbuild.config.ts b/rsbuild.config.ts index 08f6224..29003b1 100644 --- a/rsbuild.config.ts +++ b/rsbuild.config.ts @@ -5,7 +5,11 @@ const { publicVars, rawPublicVars } = loadEnv({prefixes: ['REACT_APP_']}); export default defineConfig({ server: { - base: (rawPublicVars['REACT_APP_ROOT_PATH'] !== undefined) ? rawPublicVars['REACT_APP_ROOT_PATH'] : '/' + base: (rawPublicVars['REACT_APP_ROOT_PATH'] !== undefined) ? rawPublicVars['REACT_APP_ROOT_PATH'] : '/', + proxy: { + // dev: запросы /api идут на arm-core с того же источника — без CORS, Content-Disposition читается (design D-5) + '/api': process.env.ARM_CORE_URL ?? 'http://localhost:8000', + }, }, output: { filename: { diff --git a/src/api/client.ts b/src/api/client.ts index 29aa352..a3e5806 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -1,5 +1,6 @@ import { clearToken, getToken } from '../auth/token'; import { Config } from '../conf/config'; +import { filenameFromContentDisposition } from '../utils/download'; // Хвостовые слэши срезаются: API_URL='/' иначе дал бы `//api/v1` (адрес без хоста) export const API_BASE = `${Config.API_URL.replace(/\/+$/, '')}/api/v1`; @@ -14,13 +15,14 @@ export class ApiError extends Error { public status: number, public code: string | undefined, message: string, + public body: unknown = undefined, ) { super(message); this.name = 'ApiError'; } } -type ErrorInfo = { code: string | undefined; message: string }; +type ErrorInfo = { code: string | undefined; message: string; body: unknown }; function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); @@ -34,20 +36,20 @@ function isRecord(value: unknown): value is Record { function parseErrorBody(body: unknown): ErrorInfo { const detail = isRecord(body) ? body.detail : undefined; if (typeof detail === 'string') { - return { code: undefined, message: detail }; + return { code: undefined, message: detail, body }; } if (isRecord(detail)) { const code = typeof detail.code === 'string' ? detail.code : undefined; const message = typeof detail.message === 'string' ? detail.message : FALLBACK_MESSAGE; - return { code, message }; + return { code, message, body }; } if (Array.isArray(detail)) { const first: unknown = detail[0]; if (isRecord(first) && typeof first.msg === 'string') { - return { code: undefined, message: first.msg }; + return { code: undefined, message: first.msg, body }; } } - return { code: undefined, message: FALLBACK_MESSAGE }; + return { code: undefined, message: FALLBACK_MESSAGE, body }; } async function readErrorInfo(response: Response): Promise { @@ -55,11 +57,11 @@ async function readErrorInfo(response: Response): Promise { return parseErrorBody(await response.json()); } catch { // тело не JSON (например, ответ прокси) — показываем общий текст - return { code: undefined, message: FALLBACK_MESSAGE }; + return { code: undefined, message: FALLBACK_MESSAGE, body: undefined }; } } -export async function apiFetch(path: string, init: RequestInit = {}): Promise { +async function send(path: string, init: RequestInit = {}): Promise { const headers = new Headers(init.headers); const token = getToken(); if (token) { @@ -85,12 +87,19 @@ export async function apiFetch(path: string, init: RequestInit = {}): Promise clearToken(); window.dispatchEvent(new Event(UNAUTHORIZED_EVENT)); const info = await readErrorInfo(response); - throw new ApiError(401, info.code, info.message); + throw new ApiError(401, info.code, info.message, info.body); } if (!response.ok) { const info = await readErrorInfo(response); - throw new ApiError(response.status, info.code, info.message); + throw new ApiError(response.status, info.code, info.message, info.body); } + + return response; +} + +export async function apiFetch(path: string, init: RequestInit = {}): Promise { + const response = await send(path, init); + if (response.status === 204) { return undefined as T; } @@ -106,6 +115,13 @@ export async function apiFetch(path: string, init: RequestInit = {}): Promise } } +/** Бинарный ответ (скан, xlsx) с токеном и тем же разбором ошибок; имя — из Content-Disposition, если доступно. */ +export async function apiFetchBlob(path: string): Promise<{ blob: Blob; filename: string | null }> { + const response = await send(path, {}); + const blob = await response.blob(); + return { blob, filename: filenameFromContentDisposition(response.headers.get('Content-Disposition')) }; +} + function withJsonBody(method: string, body: unknown): RequestInit { return body === undefined ? { method } : { method, body: JSON.stringify(body) }; } diff --git a/src/types/api.ts b/src/types/api.ts new file mode 100644 index 0000000..653e17c --- /dev/null +++ b/src/types/api.ts @@ -0,0 +1,6 @@ +// DTO API arm-core (/api/v1). Повторяют arm-core/api/src/schemas/; Decimal и даты — строки. + +export type Quarter = 'Q1' | 'Q2' | 'Q3' | 'Q4'; +export type DocumentType = 'BALANCE' | 'INCOME' | 'OSV' | 'DISCLOSURE' | 'OTHER'; +export type DocumentStatus = 'RECOGNIZED' | 'NEEDS_CHECK' | 'CONFIRMED' | 'ERROR'; +export type FileStatus = 'UPLOADED' | 'PROCESSING' | 'PROCESSED' | 'ERROR' | 'SUPERSEDED'; diff --git a/src/utils/download.test.ts b/src/utils/download.test.ts new file mode 100644 index 0000000..685e2fc --- /dev/null +++ b/src/utils/download.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { exportFilename, filenameFromContentDisposition } from './download'; + +describe('filenameFromContentDisposition', () => { + it('RFC 5987 filename*', () => { + expect(filenameFromContentDisposition("attachment; filename*=UTF-8''%D0%A1%D0%B2%D0%BE%D0%B4.xlsx")).toBe('Свод.xlsx'); + }); + it('filename в кавычках', () => { + expect(filenameFromContentDisposition('attachment; filename="osv.xlsx"')).toBe('osv.xlsx'); + }); + it('filename* важнее filename', () => { + expect(filenameFromContentDisposition('attachment; filename="a.xlsx"; filename*=UTF-8\'\'b.xlsx')).toBe('b.xlsx'); + }); + it('нет заголовка или имени', () => { + expect(filenameFromContentDisposition(null)).toBeNull(); + expect(filenameFromContentDisposition('inline')).toBeNull(); + }); +}); + +describe('exportFilename', () => { + it('безопасное имя', () => { + expect(exportFilename('ГК «Русский бисквит»/Север', 2024, 'Q3')).toBe('Сводная_ОСВ_ГК_«Русский_бисквит»_Север_2024_Q3.xlsx'); + }); +}); diff --git a/src/utils/download.ts b/src/utils/download.ts new file mode 100644 index 0000000..4a4f906 --- /dev/null +++ b/src/utils/download.ts @@ -0,0 +1,34 @@ +/** Имя файла из Content-Disposition: `filename*=UTF-8''…` важнее `filename=`. */ +export function filenameFromContentDisposition(header: string | null): string | null { + if (!header) { + return null; + } + const star = /filename\*\s*=\s*(?:UTF-8|utf-8)''([^;]+)/.exec(header); + if (star) { + try { + return decodeURIComponent(star[1].trim()); + } catch { + return star[1].trim(); + } + } + const plain = /filename\s*=\s*"?([^";]+)"?/.exec(header); + return plain ? plain[1].trim() : null; +} + +/** Запасное имя экспорта свода, если Content-Disposition недоступен. */ +export function exportFilename(groupName: string, year: number, quarter: string): string { + const safe = groupName.replace(/[\\/:*?"<>|\s]+/g, '_'); + return `Сводная_ОСВ_${safe}_${year}_${quarter}.xlsx`; +} + +/** Сохранить blob файлом через временную ссылку. */ +export function saveBlob(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); +} diff --git a/src/utils/labels.test.ts b/src/utils/labels.test.ts new file mode 100644 index 0000000..7d24bbe --- /dev/null +++ b/src/utils/labels.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { documentStatusLabel, documentTypeLabel, fileStatusLabel, periodLabel } from './labels'; + +describe('labels', () => { + it('тип документа', () => { + expect(documentTypeLabel('OSV', '60.01')).toBe('ОСВ 60.01'); + expect(documentTypeLabel('OSV', null)).toBe('ОСВ'); + expect(documentTypeLabel('BALANCE', null)).toBe('Бух. баланс'); + expect(documentTypeLabel('INCOME', null)).toBe('ОФР'); + expect(documentTypeLabel('DISCLOSURE', null)).toBe('Расшифровка'); + expect(documentTypeLabel('OTHER', null)).toBe('Прочее'); + }); + it('период', () => { + expect(periodLabel(2024, 'Q1', false)).toBe('2024 К1'); + expect(periodLabel(2024, 'Q3', true)).toBe('2024 · 9 мес.'); + expect(periodLabel(2024, 'Q4', true)).toBe('2024 · 12 мес.'); + expect(periodLabel(2024, null, null)).toBe('период не определён'); + expect(periodLabel(null, null, null)).toBe('период не определён'); + }); + it('статусы', () => { + expect(documentStatusLabel('NEEDS_CHECK')).toBe('Требуется проверка'); + expect(documentStatusLabel('RECOGNIZED')).toBe('Требуется проверка'); + expect(documentStatusLabel('CONFIRMED')).toBe('Загружен'); + expect(documentStatusLabel('ERROR')).toBe('Ошибка'); + expect(fileStatusLabel('UPLOADED')).toBe('Обработка'); + expect(fileStatusLabel('PROCESSING')).toBe('Обработка'); + expect(fileStatusLabel('PROCESSED')).toBe('Обработан'); + expect(fileStatusLabel('ERROR')).toBe('Ошибка'); + expect(fileStatusLabel('SUPERSEDED')).toBe('Заменён'); + }); +}); diff --git a/src/utils/labels.ts b/src/utils/labels.ts new file mode 100644 index 0000000..aa1879c --- /dev/null +++ b/src/utils/labels.ts @@ -0,0 +1,49 @@ +import type { DocumentStatus, DocumentType, FileStatus, Quarter } from '../types/api'; + +const TYPE_LABELS: Record = { + BALANCE: 'Бух. баланс', + INCOME: 'ОФР', + OSV: 'ОСВ', + DISCLOSURE: 'Расшифровка', + OTHER: 'Прочее', +}; + +const MONTHS: Record = { Q1: 3, Q2: 6, Q3: 9, Q4: 12 }; + +export function documentTypeLabel(type: DocumentType, account: string | null): string { + return type === 'OSV' && account ? `ОСВ ${account}` : TYPE_LABELS[type]; +} + +export function periodLabel(year: number | null, quarter: Quarter | null, isCumulative: boolean | null): string { + if (year === null || quarter === null) { + return 'период не определён'; + } + return isCumulative ? `${year} · ${MONTHS[quarter]} мес.` : `${year} К${quarter.slice(1)}`; +} + +export function documentStatusLabel(status: DocumentStatus): string { + switch (status) { + case 'CONFIRMED': + return 'Загружен'; + case 'ERROR': + return 'Ошибка'; + default: + return 'Требуется проверка'; + } +} + +export function fileStatusLabel(status: FileStatus): string { + switch (status) { + case 'UPLOADED': + case 'PROCESSING': + return 'Обработка'; + case 'PROCESSED': + return 'Обработан'; + case 'ERROR': + return 'Ошибка'; + case 'SUPERSEDED': + return 'Заменён'; + default: + return 'Неизвестный статус'; + } +} diff --git a/src/utils/money.test.ts b/src/utils/money.test.ts new file mode 100644 index 0000000..7535771 --- /dev/null +++ b/src/utils/money.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { formatAmount, parseAmountInput } from './money'; + +const NBSP = ' '; + +describe('formatAmount', () => { + it('разделяет разряды и держит два знака', () => { + expect(formatAmount('4454151.93')).toBe(`4${NBSP}454${NBSP}151.93`); + expect(formatAmount('100')).toBe('100.00'); + expect(formatAmount('123.5')).toBe('123.50'); + }); + it('отрицательные', () => { + expect(formatAmount('-1234567.1')).toBe(`-1${NBSP}234${NBSP}567.10`); + }); + it('пустое — прочерк', () => { + expect(formatAmount(null)).toBe('—'); + expect(formatAmount(undefined)).toBe('—'); + expect(formatAmount('')).toBe('—'); + }); + it('нераспознанная запись возвращается как есть', () => { + expect(formatAmount('1E+2')).toBe('1E+2'); + }); + it('больше двух знаков не округляет, а показывает как есть', () => { + expect(formatAmount('1.005')).toBe('1.005'); + }); +}); + +describe('parseAmountInput', () => { + it('пробелы и запятая', () => { + expect(parseAmountInput(' 1 234,5 ')).toEqual({ ok: true, value: '1234.5' }); + expect(parseAmountInput(`1${NBSP}000`)).toEqual({ ok: true, value: '1000' }); + }); + it('пустое — null', () => { + expect(parseAmountInput('')).toEqual({ ok: true, value: null }); + expect(parseAmountInput(' ')).toEqual({ ok: true, value: null }); + }); + it('отрицательное', () => { + expect(parseAmountInput('-15.25')).toEqual({ ok: true, value: '-15.25' }); + }); + it('мусор отклоняется', () => { + expect(parseAmountInput('12,3,4')).toEqual({ ok: false }); + expect(parseAmountInput('-')).toEqual({ ok: false }); + expect(parseAmountInput('1.234')).toEqual({ ok: false }); + expect(parseAmountInput('abc')).toEqual({ ok: false }); + }); +}); diff --git a/src/utils/money.ts b/src/utils/money.ts new file mode 100644 index 0000000..ffe35ac --- /dev/null +++ b/src/utils/money.ts @@ -0,0 +1,29 @@ +const NBSP = ' '; +const PLAIN = /^(-?)(\d+)(?:\.(\d+))?$/; +const INPUT = /^-?\d+(\.\d{1,2})?$/; + +/** Сумма из API (строка Decimal) → «1 234 567.80». Без арифметики над float; нераспознанное — как есть. */ +export function formatAmount(value: string | null | undefined): string { + if (value === null || value === undefined || value === '') { + return '—'; + } + const match = PLAIN.exec(value); + if (!match) { + return value; + } + const [, sign, intPart, frac = ''] = match; + if (frac.length > 2) { + return value; + } + const grouped = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, NBSP); + return `${sign}${grouped}.${frac.padEnd(2, '0')}`; +} + +/** Ввод аналитика → строка суммы для API; пустое — null; неверный формат — ok: false. */ +export function parseAmountInput(input: string): { ok: true; value: string | null } | { ok: false } { + const normalized = input.replace(/[\s ]/g, '').replace(',', '.'); + if (normalized === '') { + return { ok: true, value: null }; + } + return INPUT.test(normalized) ? { ok: true, value: normalized } : { ok: false }; +} diff --git a/src/utils/smoke.test.ts b/src/utils/smoke.test.ts deleted file mode 100644 index 2f7b758..0000000 --- a/src/utils/smoke.test.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -describe('vitest', () => { - it('запускается', () => { - expect(1 + 1).toBe(2); - }); -});