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:
parent
841299b12b
commit
e593236eed
@ -5,7 +5,11 @@ const { publicVars, rawPublicVars } = loadEnv({prefixes: ['REACT_APP_']});
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
server: {
|
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: {
|
output: {
|
||||||
filename: {
|
filename: {
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { clearToken, getToken } from '../auth/token';
|
import { clearToken, getToken } from '../auth/token';
|
||||||
import { Config } from '../conf/config';
|
import { Config } from '../conf/config';
|
||||||
|
import { filenameFromContentDisposition } from '../utils/download';
|
||||||
|
|
||||||
// Хвостовые слэши срезаются: API_URL='/' иначе дал бы `//api/v1` (адрес без хоста)
|
// Хвостовые слэши срезаются: API_URL='/' иначе дал бы `//api/v1` (адрес без хоста)
|
||||||
export const API_BASE = `${Config.API_URL.replace(/\/+$/, '')}/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 status: number,
|
||||||
public code: string | undefined,
|
public code: string | undefined,
|
||||||
message: string,
|
message: string,
|
||||||
|
public body: unknown = undefined,
|
||||||
) {
|
) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = 'ApiError';
|
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> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
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 {
|
function parseErrorBody(body: unknown): ErrorInfo {
|
||||||
const detail = isRecord(body) ? body.detail : undefined;
|
const detail = isRecord(body) ? body.detail : undefined;
|
||||||
if (typeof detail === 'string') {
|
if (typeof detail === 'string') {
|
||||||
return { code: undefined, message: detail };
|
return { code: undefined, message: detail, body };
|
||||||
}
|
}
|
||||||
if (isRecord(detail)) {
|
if (isRecord(detail)) {
|
||||||
const code = typeof detail.code === 'string' ? detail.code : undefined;
|
const code = typeof detail.code === 'string' ? detail.code : undefined;
|
||||||
const message = typeof detail.message === 'string' ? detail.message : FALLBACK_MESSAGE;
|
const message = typeof detail.message === 'string' ? detail.message : FALLBACK_MESSAGE;
|
||||||
return { code, message };
|
return { code, message, body };
|
||||||
}
|
}
|
||||||
if (Array.isArray(detail)) {
|
if (Array.isArray(detail)) {
|
||||||
const first: unknown = detail[0];
|
const first: unknown = detail[0];
|
||||||
if (isRecord(first) && typeof first.msg === 'string') {
|
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> {
|
async function readErrorInfo(response: Response): Promise<ErrorInfo> {
|
||||||
@ -55,11 +57,11 @@ async function readErrorInfo(response: Response): Promise<ErrorInfo> {
|
|||||||
return parseErrorBody(await response.json());
|
return parseErrorBody(await response.json());
|
||||||
} catch {
|
} catch {
|
||||||
// тело не JSON (например, ответ прокси) — показываем общий текст
|
// тело не 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 headers = new Headers(init.headers);
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
if (token) {
|
if (token) {
|
||||||
@ -85,12 +87,19 @@ export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise
|
|||||||
clearToken();
|
clearToken();
|
||||||
window.dispatchEvent(new Event(UNAUTHORIZED_EVENT));
|
window.dispatchEvent(new Event(UNAUTHORIZED_EVENT));
|
||||||
const info = await readErrorInfo(response);
|
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) {
|
if (!response.ok) {
|
||||||
const info = await readErrorInfo(response);
|
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) {
|
if (response.status === 204) {
|
||||||
return undefined as T;
|
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 {
|
function withJsonBody(method: string, body: unknown): RequestInit {
|
||||||
return body === undefined ? { method } : { method, body: JSON.stringify(body) };
|
return body === undefined ? { method } : { method, body: JSON.stringify(body) };
|
||||||
}
|
}
|
||||||
|
|||||||
6
src/types/api.ts
Normal file
6
src/types/api.ts
Normal 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';
|
||||||
24
src/utils/download.test.ts
Normal file
24
src/utils/download.test.ts
Normal 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
34
src/utils/download.ts
Normal 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
31
src/utils/labels.test.ts
Normal 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
49
src/utils/labels.ts
Normal 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
46
src/utils/money.test.ts
Normal 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
29
src/utils/money.ts
Normal 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 };
|
||||||
|
}
|
||||||
@ -1,7 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
describe('vitest', () => {
|
|
||||||
it('запускается', () => {
|
|
||||||
expect(1 + 1).toBe(2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Loading…
x
Reference in New Issue
Block a user