feat: adds PDF import with conversion to JSON 1.0
- Accept only PDF and JSON files in import modal and API - Convert PDF statements to JSON 1.0 via LLM (OpenAI-compatible) - Use multipart/form-data for file upload (multer, 15 MB limit) - Add LLM_API_KEY and LLM_API_BASE_URL for configurable LLM endpoint - Update ImportModal to validate type and send FormData - Add postFormData to API client for file upload
This commit is contained in:
@@ -10,3 +10,11 @@ APP_USER_PASSWORD=changeme
|
||||
SESSION_TIMEOUT_MS=10800000
|
||||
|
||||
PORT=3000
|
||||
|
||||
# Ключ OpenAI API для конвертации PDF-выписок в JSON (опционально)
|
||||
# Без него импорт PDF будет недоступен (503)
|
||||
LLM_API_KEY=
|
||||
|
||||
# Базовый URL API LLM (опционально). По умолчанию https://api.openai.com
|
||||
# Примеры: Ollama — http://localhost:11434/v1, Azure — https://YOUR_RESOURCE.openai.azure.com/openai/deployments/YOUR_DEPLOYMENT
|
||||
LLM_API_BASE_URL=
|
||||
|
||||
@@ -45,6 +45,8 @@ npm run dev -w backend
|
||||
| `APP_USER_PASSWORD` | `changeme` | Пароль для входа в приложение |
|
||||
| `SESSION_TIMEOUT_MS` | `10800000` | Таймаут сессии по бездействию (3 часа) |
|
||||
| `PORT` | `3000` | Порт HTTP-сервера |
|
||||
| `LLM_API_KEY` | — | Ключ OpenAI API для конвертации PDF в JSON; без него импорт PDF возвращает 503 |
|
||||
| `LLM_API_BASE_URL` | `https://api.openai.com/v1` | Адрес LLM API (OpenAI-совместимый); для локальной модели, напр. Ollama: `http://localhost:11434/v1` |
|
||||
|
||||
## Структура проекта
|
||||
|
||||
@@ -61,6 +63,7 @@ backend/src/
|
||||
├── services/
|
||||
│ ├── auth.ts — login / logout / me
|
||||
│ ├── import.ts — валидация, fingerprint, direction, атомарный импорт
|
||||
│ ├── pdfToStatement.ts — конвертация PDF → JSON 1.0 через LLM (OpenAI)
|
||||
│ ├── transactions.ts — список с фильтрами + обновление (categoryId, comment)
|
||||
│ ├── accounts.ts — список счетов, обновление алиаса
|
||||
│ ├── categories.ts — список категорий (фильтр isActive)
|
||||
@@ -90,7 +93,7 @@ backend/src/
|
||||
|
||||
| Метод | URL | Описание |
|
||||
|--------|----------------------------|-----------------------------------------|
|
||||
| POST | `/api/import/statement` | Импорт банковской выписки (JSON 1.0) |
|
||||
| POST | `/api/import/statement` | Импорт банковской выписки (PDF или JSON 1.0; PDF конвертируется через LLM) |
|
||||
|
||||
### Транзакции
|
||||
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
"cors": "^2.8.6",
|
||||
"dotenv": "^17.3.1",
|
||||
"express": "^5.2.1",
|
||||
"multer": "^2.1.1",
|
||||
"openai": "^6.27.0",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"pg": "^8.19.0",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
@@ -21,6 +24,7 @@
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/multer": "^2.1.0",
|
||||
"@types/node": "^25.3.3",
|
||||
"@types/pg": "^8.18.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
|
||||
@@ -18,4 +18,10 @@ export const config = {
|
||||
appUserPassword: process.env.APP_USER_PASSWORD || 'changeme',
|
||||
|
||||
sessionTimeoutMs: parseInt(process.env.SESSION_TIMEOUT_MS || '10800000', 10),
|
||||
|
||||
/** API-ключ для LLM (OpenAI или совместимый). Обязателен для конвертации PDF. */
|
||||
llmApiKey: process.env.LLM_API_KEY || '',
|
||||
|
||||
/** Базовый URL API LLM. По умолчанию https://api.openai.com. Для Ollama: http://localhost:11434/v1 */
|
||||
llmApiBaseUrl: process.env.LLM_API_BASE_URL || undefined,
|
||||
};
|
||||
|
||||
@@ -1,13 +1,81 @@
|
||||
import { Router } from 'express';
|
||||
import multer from 'multer';
|
||||
import { asyncHandler } from '../utils';
|
||||
import { importStatement, isValidationError } from '../services/import';
|
||||
import {
|
||||
convertPdfToStatement,
|
||||
isPdfConversionError,
|
||||
} from '../services/pdfToStatement';
|
||||
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: 15 * 1024 * 1024 },
|
||||
});
|
||||
|
||||
function isPdfFile(file: { mimetype: string; originalname: string }): boolean {
|
||||
const name = file.originalname.toLowerCase();
|
||||
return (
|
||||
file.mimetype === 'application/pdf' ||
|
||||
name.endsWith('.pdf')
|
||||
);
|
||||
}
|
||||
|
||||
function isJsonFile(file: { mimetype: string; originalname: string }): boolean {
|
||||
const name = file.originalname.toLowerCase();
|
||||
return (
|
||||
file.mimetype === 'application/json' ||
|
||||
name.endsWith('.json')
|
||||
);
|
||||
}
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.post(
|
||||
'/statement',
|
||||
upload.single('file'),
|
||||
asyncHandler(async (req, res) => {
|
||||
const result = await importStatement(req.body);
|
||||
const file = req.file;
|
||||
if (!file) {
|
||||
res.status(400).json({
|
||||
error: 'BAD_REQUEST',
|
||||
message: 'Файл не загружен',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isPdfFile(file) && !isJsonFile(file)) {
|
||||
res.status(400).json({
|
||||
error: 'BAD_REQUEST',
|
||||
message: 'Допустимы только файлы PDF или JSON',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
|
||||
if (isPdfFile(file)) {
|
||||
const converted = await convertPdfToStatement(file.buffer);
|
||||
if (isPdfConversionError(converted)) {
|
||||
res.status(converted.status).json({
|
||||
error: converted.error,
|
||||
message: converted.message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
body = converted;
|
||||
} else {
|
||||
try {
|
||||
body = JSON.parse(file.buffer.toString('utf-8'));
|
||||
} catch {
|
||||
res.status(400).json({
|
||||
error: 'BAD_REQUEST',
|
||||
message: 'Некорректный JSON-файл',
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await importStatement(body);
|
||||
if (isValidationError(result)) {
|
||||
res.status((result as { status: number }).status).json({
|
||||
error: (result as { error: string }).error,
|
||||
|
||||
156
backend/src/services/pdfToStatement.ts
Normal file
156
backend/src/services/pdfToStatement.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { PDFParse } from 'pdf-parse';
|
||||
import OpenAI from 'openai';
|
||||
import { config } from '../config';
|
||||
import type { StatementFile } from '@family-budget/shared';
|
||||
|
||||
const PDF2JSON_PROMPT = `Ты — конвертер банковских выписок. Твоя задача: извлечь данные из текста банковской выписки ниже и вернуть строго один валидный JSON-объект в формате ниже. Никакого текста до или после JSON, только сам объект.
|
||||
|
||||
## Структура выходного JSON
|
||||
|
||||
{
|
||||
"schemaVersion": "1.0",
|
||||
"bank": "<название банка из выписки>",
|
||||
"statement": {
|
||||
"accountNumber": "<номер счёта, только цифры, без пробелов>",
|
||||
"currency": "RUB",
|
||||
"openingBalance": <число в копейках, целое>,
|
||||
"closingBalance": <число в копейках, целое>,
|
||||
"exportedAt": "<дата экспорта в формате ISO 8601 с offset, например 2026-02-27T13:23:00+03:00>"
|
||||
},
|
||||
"transactions": [
|
||||
{
|
||||
"operationAt": "<дата и время операции в формате ISO 8601 с offset>",
|
||||
"amountSigned": <число: положительное для прихода, отрицательное для расхода; в копейках>,
|
||||
"commission": <число, целое, >= 0, в копейках>,
|
||||
"description": "<полное описание операции из выписки>"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
## Правила конвертации
|
||||
|
||||
1. Суммы — всегда в копейках (рубли × 100). Пример: 500,00 ₽ → 50000, -1234,56 ₽ → -123456.
|
||||
2. amountSigned: приход — положительное, расход — отрицательное.
|
||||
3. operationAt — дата и время, если не указано — 00:00:00, offset +03:00 для МСК.
|
||||
4. commission — если не указана — 0.
|
||||
5. description — полный текст операции как в выписке.
|
||||
6. accountNumber — только цифры, без пробелов и дефисов.
|
||||
7. openingBalance / closingBalance — в копейках.
|
||||
8. bank — краткое название (VTB, Sberbank, Тинькофф).
|
||||
9. exportedAt — дата формирования выписки.
|
||||
10. transactions — хронологический порядок.
|
||||
|
||||
## Требования
|
||||
|
||||
- transactions не должен быть пустым.
|
||||
- Все числа — целые.
|
||||
- Даты — ISO 8601 с offset.
|
||||
- currency всегда "RUB".
|
||||
- schemaVersion всегда "1.0".`;
|
||||
|
||||
export interface PdfConversionError {
|
||||
status: number;
|
||||
error: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function isPdfConversionError(r: unknown): r is PdfConversionError {
|
||||
return (
|
||||
typeof r === 'object' &&
|
||||
r !== null &&
|
||||
'status' in r &&
|
||||
'error' in r &&
|
||||
'message' in r
|
||||
);
|
||||
}
|
||||
|
||||
export async function convertPdfToStatement(
|
||||
buffer: Buffer,
|
||||
): Promise<StatementFile | PdfConversionError> {
|
||||
if (!config.llmApiKey || config.llmApiKey.trim() === '') {
|
||||
return {
|
||||
status: 503,
|
||||
error: 'SERVICE_UNAVAILABLE',
|
||||
message: 'Конвертация PDF недоступна: не задан LLM_API_KEY',
|
||||
};
|
||||
}
|
||||
|
||||
let text: string;
|
||||
try {
|
||||
const parser = new PDFParse({ data: buffer });
|
||||
const result = await parser.getText();
|
||||
text = result.text || '';
|
||||
await parser.destroy();
|
||||
} catch (err) {
|
||||
console.error('PDF extraction error:', err);
|
||||
return {
|
||||
status: 400,
|
||||
error: 'BAD_REQUEST',
|
||||
message: 'Не удалось обработать PDF-файл',
|
||||
};
|
||||
}
|
||||
|
||||
if (!text || text.trim().length === 0) {
|
||||
return {
|
||||
status: 400,
|
||||
error: 'BAD_REQUEST',
|
||||
message: 'Не удалось извлечь текст из PDF',
|
||||
};
|
||||
}
|
||||
|
||||
const openai = new OpenAI({
|
||||
apiKey: config.llmApiKey,
|
||||
...(config.llmApiBaseUrl && { baseURL: config.llmApiBaseUrl }),
|
||||
});
|
||||
|
||||
try {
|
||||
const completion = await openai.chat.completions.create({
|
||||
model: 'gpt-4o-mini',
|
||||
messages: [
|
||||
{ role: 'system', content: PDF2JSON_PROMPT },
|
||||
{ role: 'user', content: `Текст выписки:\n\n${text}` },
|
||||
],
|
||||
temperature: 0,
|
||||
});
|
||||
|
||||
const content = completion.choices[0]?.message?.content?.trim();
|
||||
if (!content) {
|
||||
return {
|
||||
status: 422,
|
||||
error: 'VALIDATION_ERROR',
|
||||
message: 'Результат конвертации пуст',
|
||||
};
|
||||
}
|
||||
|
||||
const jsonMatch = content.match(/\{[\s\S]*\}/);
|
||||
const jsonStr = jsonMatch ? jsonMatch[0] : content;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(jsonStr);
|
||||
} catch {
|
||||
return {
|
||||
status: 422,
|
||||
error: 'VALIDATION_ERROR',
|
||||
message: 'Результат конвертации не является валидным JSON',
|
||||
};
|
||||
}
|
||||
|
||||
const data = parsed as Record<string, unknown>;
|
||||
if (data.schemaVersion !== '1.0') {
|
||||
return {
|
||||
status: 422,
|
||||
error: 'VALIDATION_ERROR',
|
||||
message: 'Результат конвертации не соответствует схеме 1.0',
|
||||
};
|
||||
}
|
||||
|
||||
return parsed as StatementFile;
|
||||
} catch (err) {
|
||||
console.error('LLM conversion error:', err);
|
||||
return {
|
||||
status: 502,
|
||||
error: 'BAD_GATEWAY',
|
||||
message: 'Временная ошибка конвертации',
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user