feat: apiFetchBlob, деньги строками, подписи статусов, dev-proxy (#3)

- Клиент 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EAKRDNzXtRrwmWz9z6PZu3
This commit is contained in:
Andrey Epifancev 2026-10-08 00:51:47 +04:00
parent 841299b12b
commit e593236eed
10 changed files with 249 additions and 17 deletions

View File

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

View File

@ -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<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
@ -34,20 +36,20 @@ function isRecord(value: unknown): value is Record<string, unknown> {
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<ErrorInfo> {
@ -55,11 +57,11 @@ async function readErrorInfo(response: Response): Promise<ErrorInfo> {
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<T>(path: string, init: RequestInit = {}): Promise<T> {
async function send(path: string, init: RequestInit = {}): Promise<Response> {
const headers = new Headers(init.headers);
const token = getToken();
if (token) {
@ -85,12 +87,19 @@ export async function apiFetch<T>(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<T>(path: string, init: RequestInit = {}): Promise<T> {
const response = await send(path, init);
if (response.status === 204) {
return undefined as T;
}
@ -106,6 +115,13 @@ export async function apiFetch<T>(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) };
}

6
src/types/api.ts Normal file
View File

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

View File

@ -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');
});
});

34
src/utils/download.ts Normal file
View File

@ -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);
}

31
src/utils/labels.test.ts Normal file
View File

@ -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('Заменён');
});
});

49
src/utils/labels.ts Normal file
View File

@ -0,0 +1,49 @@
import type { DocumentStatus, DocumentType, FileStatus, Quarter } from '../types/api';
const TYPE_LABELS: Record<DocumentType, string> = {
BALANCE: 'Бух. баланс',
INCOME: 'ОФР',
OSV: 'ОСВ',
DISCLOSURE: 'Расшифровка',
OTHER: 'Прочее',
};
const MONTHS: Record<Quarter, number> = { 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 'Неизвестный статус';
}
}

46
src/utils/money.test.ts Normal file
View File

@ -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 });
});
});

29
src/utils/money.ts Normal file
View File

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

View File

@ -1,7 +0,0 @@
import { describe, expect, it } from 'vitest';
describe('vitest', () => {
it('запускается', () => {
expect(1 + 1).toBe(2);
});
});