Skip to content

Repository files navigation

Учёт оплат, проектов и закрывающих документов

Рабочий прототип дашборда для digital-агентства. Показывает связь:

проект / юрлицо → оплаты → этап работ → статус закрывающего документа (акта)

Менеджер видит, сколько оплачено, по каким проектам закрыты акты, где есть долги по документам и какие оплаты требуют внимания. Данные взяты из реальной банковской выписки (PDF) и распарсены в структурированный вид.


Содержание


Стек и почему он

Слой Технология
Фреймворк 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 : "закрывается"
Loading
  • 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 — именованная константа.


Импорт банковской выписки (PDF)

Источник — реальная выписка data/bank_statement.pdf (5 страниц, счёт ИП Громов А.В.). Конвейер импорта (server/import/) показывает, как «хаотичные» банковские операции превращаются в структуру:

  1. parseStatement.tsunpdf извлекает текст; regex разбивает его на операции по якорю «дата + 20-значный счёт»; из каждой операции вытягиваются дата, сумма, № документа, блок плательщика и назначение платежа.
  2. Определение направления. Если первым в операции идёт счёт не агентства — это приход (in, клиентская оплата). Если первым стоит счёт агентства — это расход (out).
  3. normalize.ts — строит Counterparty / Project / Payment / Act:
    • classify.ts выводит из назначения этап услуги (по ключевым словам: «сопровождение», «Директ/контекстная», «разработка сайтов», «публикация материалов», «объявления», …) и категорию расхода (tax для НДФЛ/ЕНС/налогов, subcontractor для выплат ИП, и т.д.);
    • проект выводится из назначения (явное имя в кавычках, например «Складские модули», иначе — проект по клиенту);
    • идемпотентность обеспечивается полем sourceRef (код ref-… из назначения либо хэш).
  4. Акты создаются только для входящих оплат, со детерминированными демо-статусами, чтобы дашборд показывал все четыре состояния (в выписке статусов актов, естественно, нет).

run-import.ts пишет результат в data/normalized.json — это закоммиченный источник истины для сидинга, поэтому заполнение БД воспроизводимо и не требует доступа к сети/PDF. Перегенерировать из PDF: npm run import:pdf.

Результат на реальных данных: ~30 контрагентов, ~20 проектов, 47 операций (26 входящих оплат / 21 исходящая), 25 актов.


API

Все ответы — в едином конверте { 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) содержит все требуемые показатели: общая сумма оплат, число проектов, число оплат, сумма по закрытым актам, сумма по незакрытым, число оплат без отправленного акта, число «отправлен, но не подписан», плюс расходы по категориям. Фильтры и итоги считаются на сервере и уважают активные фильтры.

Деньги во всех ответах — в копейках (целые числа); форматирование в рубли — на фронте.


Допущения

  1. Агентство = ИП Громов А.В. (владелец счёта); клиенты = юрлица из кредитовых операций.
  2. Только входящие оплаты порождают акты. Исходящие (налоги, ЕНС, НДФЛ, субподряд) показаны отдельной вкладкой «Расходы».
  3. Проект выводится из назначения платежа (имя проекта / № счёта / связка с клиентом).
  4. Статусов актов в выписке нет → они синтезируются детерминированно, чтобы продемонстрировать все четыре состояния.
  5. Реальные банковские данные «грязные»: внутрибанковские начисления и собственные переводы (например, возврат депозита самим ИП Громов) попадают как приход без проекта и без акта — это намеренно не «вычищается», чтобы показать работу с реальной выпиской.
  6. Этап/тип услуги — 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.


Деплой на Vercel + Neon

SQLite на Vercel непригодна (serverless-ФС эфемерна и доступна только на чтение), поэтому в продакшене используется Neon (PostgreSQL):

  1. Создать бесплатную БД в Neon и скопировать строку подключения.
  2. В prisma/schema.prisma сменить provider = "sqlite" на provider = "postgresql".
  3. В настройках проекта Vercel задать переменные окружения:
    • DATABASE_URL = postgresql://USER:PASSWORD@HOST/db?sslmode=require
    • NITRO_PRESET = vercel (также прописан в vercel.json)
  4. Применить схему и засидить (локально, указывая на Neon, либо разовой командой): DATABASE_URL="postgres://…" npx prisma db push && DATABASE_URL="postgres://…" npm run db:seed
  5. Импортировать проект в Vercel — фронт и API задеплоятся вместе. Изменения статусов актов сохраняются в Neon.

Прод-сборка проверяется локально: npm run build (Nitro собирает и API, и SSR).


Что можно развить дальше

  • Сущность WorkStage (этапы как первоклассные объекты с порядком и процентом готовности) вместо enum-поля.
  • Авторизация и роли (менеджер / руководитель).
  • Генерация и хранение самих файлов актов, история изменений статусов (audit log).
  • Полноценный парсинг разных форматов выписок и приём через загрузку файла / интеграцию.
  • Серверная пагинация и индексы для больших объёмов оплат.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages