Рабочий прототип дашборда для digital-агентства. Показывает связь:
проект / юрлицо → оплаты → этап работ → статус закрывающего документа (акта)
Менеджер видит, сколько оплачено, по каким проектам закрыты акты, где есть долги по документам и какие оплаты требуют внимания. Данные взяты из реальной банковской выписки (PDF) и распарсены в структурированный вид.
- Стек и почему он
- Архитектура
- Модель данных
- Логика статусов актов
- Импорт банковской выписки (PDF)
- API
- Допущения
- Локальный запуск
- Тесты
- Деплой на Vercel + Neon
- Что можно развить дальше
| Слой | Технология |
|---|---|
| Фреймворк | Nuxt 3 (Vue 3, <script setup>) |
| Серверный слой / API | Nitro (REST, в составе Nuxt) |
| ORM | Prisma |
| БД | SQLite локально / PostgreSQL (Neon) в продакшене |
| Валидация | zod |
| Тесты | Vitest |
| Парсинг PDF | unpdf |
| Язык | TypeScript сквозь все слои |
Почему не Laravel + Vue, как в предпочтениях задания. Задание просит и Vue, и деплой на Vercel. Laravel на Vercel нормально не разворачивается (нужен отдельный PHP-хостинг), а Nuxt деплоится на Vercel нативно. Nuxt сохраняет Vue во фронтенде (как и просили) и при этом даёт полноценный серверный слой (Nitro) с REST API — то есть остаются все три слоя «данные / бизнес-логика / отображение», которые и нужно было показать. Единый TypeScript-стек убирает дублирование типов между бэком и фронтом.
Строгое разделение ответственности по слоям. Зависимости идут только «вниз»:
prisma/schema.prisma Модель данных — единственный источник истины.
server/domain/ ЧИСТАЯ бизнес-логика. Без фреймворка, без БД, без Nuxt.
money.ts Деньги как целые копейки + форматирование в рубли.
types.ts Типы и русские подписи (enum'ы как union-типы).
actStatus.ts Расчёт статуса акта (4 состояния). ← ЯДРО
classify.ts Назначение платежа → этап / категория расхода / проект.
filters.ts Предикаты фильтрации.
summary.ts Агрегаты сводки.
server/repositories/ Доступ к данным (Prisma) + обогащение вычисляемым статусом.
payments.ts projects.ts counterparties.ts acts.ts
server/api/ Тонкие REST-эндпоинты Nitro: zod-валидация → repo/domain → конверт.
summary.get.ts payments.get.ts projects.get.ts
counterparties.get.ts expenses.get.ts acts/[id].patch.ts
server/import/ Слой импорта банковской выписки.
parseStatement.ts PDF-текст → «сырые» операции.
normalize.ts «Сырые» операции → нормализованные сущности.
run-import.ts CLI: PDF → data/normalized.json.
server/utils/ prisma-синглтон, конверт ответа {data,error,meta}, zod-схема query.
prisma/seed.ts Заполнение БД из data/normalized.json (идемпотентно).
app/ Vue 3 — ТОЛЬКО отображение. Ходит в API, ничего не считает само.
pages/ index.vue (оплаты) · projects.vue · expenses.vue
components/ SummaryCards · FiltersBar · PaymentsTable · ProjectsTable · ExpensesTable · ActStatusBadge
composables/ useApi (обёртка над API) · useFormat (деньги/даты/подписи)
tests/ Vitest: домен (юнит) + импорт + интеграция (act PATCH).
data/ Реальный PDF выписки + нормализованный JSON (воспроизводимый сид).
Где находится бизнес-логика. Вся — в server/domain/. Это чистые функции без побочных
эффектов и без зависимостей от Prisma/Nuxt, поэтому они покрыты юнит-тестами на 100% сценариев.
Репозитории и API только оркестрируют: тянут данные, прогоняют через домен, отдают результат.
Фронтенд ничего не вычисляет — он отображает то, что посчитал сервер. Деньги хранятся и
считаются в целых копейках (чтобы не накапливать ошибку float) и форматируются в рубли
только на самом краю — во вьюхе.
erDiagram
Counterparty ||--o{ Project : "ведёт"
Counterparty ||--o{ Payment : "платит"
Project ||--o{ Payment : "оплачивается"
Payment ||--o| Act : "закрывается"
- Counterparty — юрлицо / клиент-плательщик (
name,inn,ogrn,bankAccount, …). - Project — проект / направление работ (
name,counterpartyId,status). - Payment — банковская операция по счёту агентства:
date,direction(in/out),amount(копейки),purpose,serviceStage,expenseCategory,invoiceNumber,contractNumber,sourceRef(уникальный — для идемпотентного импорта),counterpartyId,projectId?. - Act — закрывающий документ, строго 1—1 с входящей оплатой:
isSent,sentAt,isSigned,signedAt,managerComment. Поля статуса не хранят сам статус — он вычисляется (см. ниже).
serviceStage — это enum-поле на Payment
(development | support | ads | seo | content | design | other), а не отдельная сущность.
Для текущего объёма это проще и честнее (YAGNI); путь повышения до сущности WorkStage
описан в конце.
Статус акта не хранится в БД — он выводится из isSent, isSigned и возраста оплаты
чистой функцией computeActStatus (server/domain/actStatus.ts). Это исключает рассинхрон
данных и статуса.
| Статус | Условие |
|---|---|
Закрыт (CLOSED) |
акт отправлен и подписан |
Требует внимания (NEEDS_ATTENTION) |
не закрыт и оплате больше STALE_DAYS (30) дней |
Ожидает подписи (AWAITING_SIGNATURE) |
отправлен, но не подписан |
Не отправлен (NOT_SENT) |
не отправлен и не подписан |
Порядок проверки именно такой: сначала «закрыт», затем просрочка по незакрытому, затем
«ожидает подписи», иначе «не отправлен». Порог STALE_DAYS = 30 — именованная константа.
Источник — реальная выписка data/bank_statement.pdf (5 страниц, счёт ИП Громов А.В.).
Конвейер импорта (server/import/) показывает, как «хаотичные» банковские операции
превращаются в структуру:
parseStatement.ts—unpdfизвлекает текст; regex разбивает его на операции по якорю «дата + 20-значный счёт»; из каждой операции вытягиваются дата, сумма, № документа, блок плательщика и назначение платежа.- Определение направления. Если первым в операции идёт счёт не агентства — это
приход (
in, клиентская оплата). Если первым стоит счёт агентства — это расход (out). normalize.ts— строитCounterparty/Project/Payment/Act:classify.tsвыводит из назначения этап услуги (по ключевым словам: «сопровождение», «Директ/контекстная», «разработка сайтов», «публикация материалов», «объявления», …) и категорию расхода (taxдля НДФЛ/ЕНС/налогов,subcontractorдля выплат ИП, и т.д.);- проект выводится из назначения (явное имя в кавычках, например «Складские модули», иначе — проект по клиенту);
- идемпотентность обеспечивается полем
sourceRef(кодref-…из назначения либо хэш).
- Акты создаются только для входящих оплат, со детерминированными демо-статусами, чтобы дашборд показывал все четыре состояния (в выписке статусов актов, естественно, нет).
run-import.ts пишет результат в data/normalized.json — это закоммиченный источник
истины для сидинга, поэтому заполнение БД воспроизводимо и не требует доступа к сети/PDF.
Перегенерировать из PDF: npm run import:pdf.
Результат на реальных данных: ~30 контрагентов, ~20 проектов, 47 операций (26 входящих оплат / 21 исходящая), 25 актов.
Все ответы — в едином конверте { data, error, meta }.
| Метод | Путь | Назначение |
|---|---|---|
| GET | /api/summary?<фильтры> |
Сводка: суммы прихода/расхода, по закрытым/незакрытым актам, счётчики |
| GET | /api/payments?project=&counterparty=&from=&to=&actStatus=&stage=&q= |
Входящие оплаты с фильтрацией на бэкенде |
| GET | /api/expenses?<фильтры> |
Исходящие операции (налоги/субподряд/комиссии) — отдельно |
| GET | /api/projects |
Проекты + агрегаты (сумма, кол-во оплат, закрыто/открыто актов, общий статус) |
| GET | /api/counterparties |
Юрлица + агрегаты |
| PATCH | /api/acts/:id |
Отметить отправлен/подписан + комментарий — сохраняется в БД |
Сводка (/api/summary) содержит все требуемые показатели: общая сумма оплат, число
проектов, число оплат, сумма по закрытым актам, сумма по незакрытым, число оплат без
отправленного акта, число «отправлен, но не подписан», плюс расходы по категориям.
Фильтры и итоги считаются на сервере и уважают активные фильтры.
Деньги во всех ответах — в копейках (целые числа); форматирование в рубли — на фронте.
- Агентство = ИП Громов А.В. (владелец счёта); клиенты = юрлица из кредитовых операций.
- Только входящие оплаты порождают акты. Исходящие (налоги, ЕНС, НДФЛ, субподряд) показаны отдельной вкладкой «Расходы».
- Проект выводится из назначения платежа (имя проекта / № счёта / связка с клиентом).
- Статусов актов в выписке нет → они синтезируются детерминированно, чтобы продемонстрировать все четыре состояния.
- Реальные банковские данные «грязные»: внутрибанковские начисления и собственные переводы (например, возврат депозита самим ИП Громов) попадают как приход без проекта и без акта — это намеренно не «вычищается», чтобы показать работу с реальной выпиской.
- Этап/тип услуги — enum на
Payment, а не отдельная сущность (для этого объёма).
Требуется Node.js ≥ 18.
npm install
cp .env.example .env # DATABASE_URL="file:./dev.db" (SQLite)
# Создать схему БД (Prisma резолвит file: относительно prisma/, → prisma/dev.db)
DATABASE_URL="file:./dev.db" npx prisma db push
# (опционально) перегенерировать data/normalized.json из реального PDF
npm run import:pdf
# Заполнить БД нормализованными данными
DATABASE_URL="file:./dev.db" npm run db:seed
# Запустить дев-сервер
DATABASE_URL="file:./dev.db" npm run dev
# → http://localhost:3000
.envуже содержитDATABASE_URL, поэтому в командах его можно не повторять — он указан явно для наглядности. Пересоздать БД с нуля:DATABASE_URL="file:./dev.db" npm run db:reset.
npm test # vitest run — домен, импорт, интеграция (act PATCH)Покрытие сосредоточено на бизнес-логике: все 4 состояния статуса акта и граница 30 дней;
агрегаты сводки; предикаты фильтров; классификация назначения; парсинг/нормализация;
а также интеграционный тест перехода акта отправлен → подписан через Prisma.
SQLite на Vercel непригодна (serverless-ФС эфемерна и доступна только на чтение), поэтому в продакшене используется Neon (PostgreSQL):
- Создать бесплатную БД в Neon и скопировать строку подключения.
- В
prisma/schema.prismaсменитьprovider = "sqlite"наprovider = "postgresql". - В настройках проекта Vercel задать переменные окружения:
DATABASE_URL = postgresql://USER:PASSWORD@HOST/db?sslmode=requireNITRO_PRESET = vercel(также прописан вvercel.json)
- Применить схему и засидить (локально, указывая на Neon, либо разовой командой):
DATABASE_URL="postgres://…" npx prisma db push && DATABASE_URL="postgres://…" npm run db:seed - Импортировать проект в Vercel — фронт и API задеплоятся вместе. Изменения статусов актов сохраняются в Neon.
Прод-сборка проверяется локально: npm run build (Nitro собирает и API, и SSR).
- Сущность
WorkStage(этапы как первоклассные объекты с порядком и процентом готовности) вместо enum-поля. - Авторизация и роли (менеджер / руководитель).
- Генерация и хранение самих файлов актов, история изменений статусов (audit log).
- Полноценный парсинг разных форматов выписок и приём через загрузку файла / интеграцию.
- Серверная пагинация и индексы для больших объёмов оплат.