init: M1 scaffolding + M2 organization/clients/services CRUD

- monorepo (npm workspaces): apps/api (Fastify+Prisma+TS), apps/web (Vite+React+TS), packages/shared (zod schemas)
- SSO via auth.queo.ru: jose+JWKS plugin, requireDocPermission(viewer|user|admin)
- DEV_BYPASS_AUTH for local development (hard-checked off in production)
- M2: organization upsert, clients CRUD with search, services catalog with soft-delete
- BigInt -> Number serializer for Prisma money columns
- Embedded Postgres + npm run dev:demo for one-command local boot
- Docker compose for queoserver: postgres + api + web (nginx as ingress proxying /api -> api:3030)
- First migration 0_init committed (prisma migrate diff)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
admin
2026-04-30 21:24:26 +03:00
commit 4553f63deb
52 changed files with 7110 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
+40
View File
@@ -0,0 +1,40 @@
node_modules/
dist/
build/
.next/
.cache/
.turbo/
coverage/
# env
.env
.env.local
.env.*.local
!.env.example
# logs
*.log
npm-debug.log*
yarn-debug.log*
pnpm-debug.log*
# editors
.vscode/
.idea/
.DS_Store
Thumbs.db
# prisma generated
apps/api/prisma/migrations/dev.db*
# generated PDFs/uploads
storage/
data/
# OS
*.swp
*.swo
# Claude Code local
.claude/settings.local.json
+106
View File
@@ -0,0 +1,106 @@
# Doc_manager
Сервис создания договоров, счетов и актов/УПД с интеграцией банка Точка. Часть экосистемы Queo.
## Стек
- **Backend** — Node.js 20+ / Fastify / Prisma / PostgreSQL (TypeScript, ESM).
- **Frontend** — React + Vite + TipTap (TypeScript).
- **Auth** — SSO через `auth.queo.ru` (RS256 JWT в cookie `q_at`, JWKS-валидация).
- **Bank** — Точка API: счета и акты/УПД создаются на стороне банка, договоры — наши.
## Структура
```
apps/
api/ # Fastify + Prisma
web/ # React + Vite
packages/
shared/ # zod-схемы и типы (DocBody, Tochka DTOs)
docker/
docker-compose.yml
Caddyfile
```
## Подготовка перед разработкой
1. **Node.js ≥ 20** — установлен.
2. **PostgreSQL** — локально или через Docker (`docker compose up postgres`).
3. **Auth_server** должен принимать редиректы на `doc.queo.ru` — добавить в `C:\project\Auth_server\.env`:
```
ALLOWED_RETURN_TO_HOSTS=auth.queo.ru,hall.queo.ru,cloud.queo.ru,doc.queo.ru
```
## Быстрый старт «пощупать прямо сейчас»
Нет Postgres / Docker / доступа к auth.queo.ru — всё равно работает:
```bash
npm install
npm run dev:demo
```
Что делает `dev:demo`:
- Запускает встроенный Postgres из `node_modules` (первый раз скачает ~80MB binaries в `data/embedded-pg/`).
- Накатывает схему через `prisma db push`.
- Сидит организацию `DEFAULT_ORGANIZATION_ID`.
- Поднимает API на `:3030` и web на `:5173` параллельно.
- В API включает `DEV_BYPASS_AUTH=1` — фейковый admin без auth.queo.ru.
Открыть `http://localhost:5173/` — попадаешь сразу в шелл (без логина), можно ходить:
- `/organization` — реквизиты компании,
- `/clients` — клиенты,
- `/services` — каталог услуг,
- остальное — заглушки до M3+.
Данные между перезапусками **сохраняются** в `data/embedded-pg/`. Чтобы начать с нуля — удалить эту папку.
⚠️ **Безопасность:** `DEV_BYPASS_AUTH=1` обходит JWT. API hard-checks: при `NODE_ENV=production` с этим флагом процесс отказывается стартовать.
## Полная установка (с реальным auth.queo.ru)
```bash
npm install
cp apps/api/.env.example apps/api/.env
# отредактировать DATABASE_URL, оставить DEV_BYPASS_AUTH=0
npm run prisma:migrate
npm run -w @doc-manager/api prisma:seed
npm run dev # api+web с настоящей SSO-авторизацией
```
API на `http://localhost:3030`, web — на `http://localhost:5173`. Перед этим добавить `doc.queo.ru` в `ALLOWED_RETURN_TO_HOSTS` в `C:\project\Auth_server\.env`.
## Тесты M1 (smoke)
- `GET http://localhost:3030/health` → `{ ok: true }`
- Открыть `http://localhost:5173` без cookie `q_at` → редирект на `https://auth.queo.ru/auth/login?return_to=...`.
- С валидной cookie от auth.queo.ru — `/api/me` отдаёт payload JWT.
## Тесты M2 (организация, клиенты, услуги)
С Postgres и применённой миграцией:
1. Поднять БД и применить миграцию:
```bash
docker compose -f docker/docker-compose.yml up -d postgres
npm run prisma:migrate # создаст первую миграцию
npm run -w @doc-manager/api prisma:seed # сидит DEFAULT_ORGANIZATION_ID
```
2. `GET /api/organization` → возвращает запись организации (после сида).
3. `GET /api/clients?q=...` — поиск по name/inn/email.
4. `GET /api/services?includeArchived=1` — каталог + архив.
5. В UI: `/organization` — заполнить реквизиты; `/clients` — добавить клиента; `/services` — добавить услугу с ценой и НДС.
SQL-предпросмотр миграции без БД: `apps/api/prisma/init.sql.preview` (генерируется через `prisma migrate diff`).
## План реализации
См. `C:\Users\VVMedia\.claude\plans\floofy-baking-jellyfish.md`. Этапы M1M7.
## Деплой (домашний сервер)
```
ssh -i ~/.ssh/id_sat vmv@192.168.0.158
cd ~/Doc-manager
git pull && docker compose build && docker compose up -d
```
+36
View File
@@ -0,0 +1,36 @@
# --- API server ---
NODE_ENV=development
PORT=3030
HOST=127.0.0.1
# --- Database ---
DATABASE_URL=postgresql://docmanager:docmanager@localhost:5432/docmanager?schema=public
# --- SSO via auth.queo.ru ---
AUTH_ISSUER=https://auth.queo.ru
AUTH_AUDIENCE=queo.ru
AUTH_JWKS_URL=https://auth.queo.ru/.well-known/jwks.json
AUTH_COOKIE_NAME=q_at
AUTH_LOGIN_URL=https://auth.queo.ru/auth/login
# --- CORS ---
# Допустимые источники для браузера (запятая). На проде: https://doc.queo.ru
CORS_ORIGINS=http://localhost:5173
# --- Tochka API ---
# 32 случайных байта в base64 — ключ AES-256-GCM для шифрования JWT-токенов Точки в БД.
# Генерация: node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
TOCHKA_JWT_KEY=
# --- Webhook secret для Точки ---
# Случайная строка, попадает в URL: /webhooks/tochka/<TOCHKA_WEBHOOK_SECRET>
TOCHKA_WEBHOOK_SECRET=
# --- Default organization (single-tenant v1) ---
# UUID единственной организации — сидится в M2.
DEFAULT_ORGANIZATION_ID=00000000-0000-0000-0000-000000000001
# --- Dev-only ---
# Если 1 — пропускает проверку JWT и подсовывает фейкового admin'а.
# В production отказывается стартовать с этой переменной.
DEV_BYPASS_AUTH=0
+37
View File
@@ -0,0 +1,37 @@
{
"name": "@doc-manager/api",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "dist/server.js",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/server.js",
"typecheck": "tsc --noEmit",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:deploy": "prisma migrate deploy",
"prisma:seed": "tsx prisma/seed.ts",
"dev:demo": "tsx scripts/dev-server.ts"
},
"dependencies": {
"@doc-manager/shared": "*",
"@fastify/cookie": "^9.4.0",
"@fastify/cors": "^9.0.1",
"@fastify/helmet": "^11.1.1",
"@prisma/client": "^5.22.0",
"fastify": "^4.28.1",
"fastify-plugin": "^4.5.1",
"jose": "^5.9.6",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^20.16.0",
"embedded-postgres": "^18.3.0-beta.17",
"pino-pretty": "^11.3.0",
"prisma": "^5.22.0",
"tsx": "^4.19.0",
"typescript": "^5.6.3"
}
}
@@ -0,0 +1,279 @@
-- CreateEnum
CREATE TYPE "DocType" AS ENUM ('contract', 'invoice', 'act', 'upd');
-- CreateEnum
CREATE TYPE "DocStatus" AS ENUM ('draft', 'issued', 'sent', 'partially_paid', 'paid', 'cancelled', 'signed');
-- CreateEnum
CREATE TYPE "VatRate" AS ENUM ('none', '0', '5', '7', '10', '20');
-- CreateEnum
CREATE TYPE "ClientKind" AS ENUM ('ul', 'ip', 'fl');
-- CreateEnum
CREATE TYPE "TochkaEnv" AS ENUM ('sandbox', 'prod');
-- CreateEnum
CREATE TYPE "PaymentKind" AS ENUM ('incoming', 'incoming_sbp', 'incoming_sbp_b2b', 'outgoing');
-- CreateTable
CREATE TABLE "Organization" (
"id" UUID NOT NULL,
"name" TEXT NOT NULL,
"inn" TEXT NOT NULL,
"kpp" TEXT,
"ogrn" TEXT,
"legalAddress" TEXT,
"bankName" TEXT,
"bankBik" TEXT,
"bankAccount" TEXT,
"signatoryName" TEXT,
"signatoryPosition" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Organization_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Client" (
"id" UUID NOT NULL,
"organizationId" UUID NOT NULL,
"kind" "ClientKind" NOT NULL,
"name" TEXT NOT NULL,
"inn" TEXT,
"kpp" TEXT,
"address" TEXT,
"email" TEXT,
"phone" TEXT,
"contactPerson" TEXT,
"requisitesJson" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Client_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ServiceCatalog" (
"id" UUID NOT NULL,
"organizationId" UUID NOT NULL,
"name" TEXT NOT NULL,
"unit" TEXT NOT NULL,
"defaultPriceCents" BIGINT NOT NULL DEFAULT 0,
"defaultVat" "VatRate" NOT NULL DEFAULT 'none',
"notes" TEXT,
"archivedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ServiceCatalog_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "DocumentTemplate" (
"id" UUID NOT NULL,
"organizationId" UUID NOT NULL,
"docType" "DocType" NOT NULL,
"name" TEXT NOT NULL,
"body" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DocumentTemplate_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Document" (
"id" UUID NOT NULL,
"organizationId" UUID NOT NULL,
"docType" "DocType" NOT NULL,
"number" TEXT NOT NULL,
"issuedAt" TIMESTAMP(3),
"status" "DocStatus" NOT NULL DEFAULT 'draft',
"clientId" UUID,
"parentDocumentId" UUID,
"body" JSONB NOT NULL,
"totalCents" BIGINT NOT NULL DEFAULT 0,
"vatCents" BIGINT NOT NULL DEFAULT 0,
"currency" TEXT NOT NULL DEFAULT 'RUB',
"tochkaDocumentId" TEXT,
"tochkaEnvironment" "TochkaEnv",
"pdfPath" TEXT,
"createdBy" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Document_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "DocumentLine" (
"id" UUID NOT NULL,
"documentId" UUID NOT NULL,
"position" INTEGER NOT NULL,
"serviceId" UUID,
"name" TEXT NOT NULL,
"qtyMilli" BIGINT NOT NULL DEFAULT 1000,
"unit" TEXT NOT NULL,
"priceCents" BIGINT NOT NULL,
"vat" "VatRate" NOT NULL DEFAULT 'none',
"sumCents" BIGINT NOT NULL,
CONSTRAINT "DocumentLine_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Payment" (
"id" UUID NOT NULL,
"organizationId" UUID NOT NULL,
"documentId" UUID,
"tochkaPaymentId" TEXT NOT NULL,
"kind" "PaymentKind" NOT NULL,
"amountCents" BIGINT NOT NULL,
"payerInn" TEXT,
"payerName" TEXT,
"purpose" TEXT,
"paidAt" TIMESTAMP(3),
"raw" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Payment_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TochkaCredential" (
"id" UUID NOT NULL,
"organizationId" UUID NOT NULL,
"environment" "TochkaEnv" NOT NULL,
"jwtEncrypted" TEXT NOT NULL,
"customerCode" TEXT NOT NULL,
"accountCode" TEXT,
"expiresAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "TochkaCredential_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "WebhookEvent" (
"id" UUID NOT NULL,
"receivedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"source" TEXT NOT NULL,
"eventType" TEXT NOT NULL,
"raw" JSONB NOT NULL,
"processedAt" TIMESTAMP(3),
"error" TEXT,
"dedupeKey" TEXT NOT NULL,
CONSTRAINT "WebhookEvent_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AuditLog" (
"id" UUID NOT NULL,
"organizationId" UUID NOT NULL,
"actorSub" TEXT,
"action" TEXT NOT NULL,
"entity" TEXT NOT NULL,
"entityId" TEXT NOT NULL,
"diff" JSONB,
"at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Client_organizationId_idx" ON "Client"("organizationId");
-- CreateIndex
CREATE INDEX "Client_organizationId_name_idx" ON "Client"("organizationId", "name");
-- CreateIndex
CREATE INDEX "ServiceCatalog_organizationId_idx" ON "ServiceCatalog"("organizationId");
-- CreateIndex
CREATE INDEX "ServiceCatalog_organizationId_archivedAt_idx" ON "ServiceCatalog"("organizationId", "archivedAt");
-- CreateIndex
CREATE INDEX "DocumentTemplate_organizationId_docType_idx" ON "DocumentTemplate"("organizationId", "docType");
-- CreateIndex
CREATE INDEX "Document_organizationId_clientId_issuedAt_idx" ON "Document"("organizationId", "clientId", "issuedAt" DESC);
-- CreateIndex
CREATE INDEX "Document_organizationId_status_idx" ON "Document"("organizationId", "status");
-- CreateIndex
CREATE INDEX "Document_tochkaDocumentId_idx" ON "Document"("tochkaDocumentId");
-- CreateIndex
CREATE UNIQUE INDEX "Document_organizationId_docType_number_key" ON "Document"("organizationId", "docType", "number");
-- CreateIndex
CREATE INDEX "DocumentLine_documentId_idx" ON "DocumentLine"("documentId");
-- CreateIndex
CREATE INDEX "DocumentLine_serviceId_idx" ON "DocumentLine"("serviceId");
-- CreateIndex
CREATE UNIQUE INDEX "Payment_tochkaPaymentId_key" ON "Payment"("tochkaPaymentId");
-- CreateIndex
CREATE INDEX "Payment_organizationId_paidAt_idx" ON "Payment"("organizationId", "paidAt" DESC);
-- CreateIndex
CREATE INDEX "Payment_documentId_idx" ON "Payment"("documentId");
-- CreateIndex
CREATE UNIQUE INDEX "TochkaCredential_organizationId_environment_key" ON "TochkaCredential"("organizationId", "environment");
-- CreateIndex
CREATE UNIQUE INDEX "WebhookEvent_dedupeKey_key" ON "WebhookEvent"("dedupeKey");
-- CreateIndex
CREATE INDEX "WebhookEvent_source_eventType_receivedAt_idx" ON "WebhookEvent"("source", "eventType", "receivedAt");
-- CreateIndex
CREATE INDEX "AuditLog_organizationId_at_idx" ON "AuditLog"("organizationId", "at" DESC);
-- CreateIndex
CREATE INDEX "AuditLog_entity_entityId_idx" ON "AuditLog"("entity", "entityId");
-- AddForeignKey
ALTER TABLE "Client" ADD CONSTRAINT "Client_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ServiceCatalog" ADD CONSTRAINT "ServiceCatalog_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DocumentTemplate" ADD CONSTRAINT "DocumentTemplate_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Document" ADD CONSTRAINT "Document_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Document" ADD CONSTRAINT "Document_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "Client"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Document" ADD CONSTRAINT "Document_parentDocumentId_fkey" FOREIGN KEY ("parentDocumentId") REFERENCES "Document"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DocumentLine" ADD CONSTRAINT "DocumentLine_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "Document"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DocumentLine" ADD CONSTRAINT "DocumentLine_serviceId_fkey" FOREIGN KEY ("serviceId") REFERENCES "ServiceCatalog"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Payment" ADD CONSTRAINT "Payment_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Payment" ADD CONSTRAINT "Payment_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "Document"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TochkaCredential" ADD CONSTRAINT "TochkaCredential_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
+250
View File
@@ -0,0 +1,250 @@
// Doc_manager — модель данных
// Полиморфная таблица documents для contract/invoice/act/upd.
// organization_id присутствует на всех owner-scoped таблицах (single-tenant v1, готово к multi-tenant).
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum DocType {
contract
invoice
act
upd
}
enum DocStatus {
draft
issued
sent
partially_paid
paid
cancelled
signed
}
enum VatRate {
none
vat_0 @map("0")
vat_5 @map("5")
vat_7 @map("7")
vat_10 @map("10")
vat_20 @map("20")
}
enum ClientKind {
ul // юр.лицо
ip // ИП
fl // физ.лицо
}
enum TochkaEnv {
sandbox
prod
}
enum PaymentKind {
incoming
incoming_sbp
incoming_sbp_b2b
outgoing
}
model Organization {
id String @id @default(uuid()) @db.Uuid
name String
inn String
kpp String?
ogrn String?
legalAddress String?
bankName String?
bankBik String?
bankAccount String?
signatoryName String?
signatoryPosition String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
clients Client[]
servicesCatalog ServiceCatalog[]
templates DocumentTemplate[]
documents Document[]
payments Payment[]
tochkaCredentials TochkaCredential[]
auditLog AuditLog[]
}
model Client {
id String @id @default(uuid()) @db.Uuid
organizationId String @db.Uuid
organization Organization @relation(fields: [organizationId], references: [id])
kind ClientKind
name String
inn String?
kpp String?
address String?
email String?
phone String?
contactPerson String?
requisitesJson Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
documents Document[]
@@index([organizationId])
@@index([organizationId, name])
}
model ServiceCatalog {
id String @id @default(uuid()) @db.Uuid
organizationId String @db.Uuid
organization Organization @relation(fields: [organizationId], references: [id])
name String
unit String // "шт", "час", "мес"
defaultPriceCents BigInt @default(0)
defaultVat VatRate @default(none)
notes String?
archivedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lines DocumentLine[]
@@index([organizationId])
@@index([organizationId, archivedAt])
}
model DocumentTemplate {
id String @id @default(uuid()) @db.Uuid
organizationId String @db.Uuid
organization Organization @relation(fields: [organizationId], references: [id])
docType DocType
name String
body Json
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([organizationId, docType])
}
model Document {
id String @id @default(uuid()) @db.Uuid
organizationId String @db.Uuid
organization Organization @relation(fields: [organizationId], references: [id])
docType DocType
number String
issuedAt DateTime?
status DocStatus @default(draft)
clientId String? @db.Uuid
client Client? @relation(fields: [clientId], references: [id])
parentDocumentId String? @db.Uuid
parent Document? @relation("DocumentChildren", fields: [parentDocumentId], references: [id])
children Document[] @relation("DocumentChildren")
body Json
totalCents BigInt @default(0)
vatCents BigInt @default(0)
currency String @default("RUB")
tochkaDocumentId String?
tochkaEnvironment TochkaEnv?
pdfPath String?
createdBy String? // sub из JWT auth.queo.ru
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lines DocumentLine[]
payments Payment[]
@@unique([organizationId, docType, number])
@@index([organizationId, clientId, issuedAt(sort: Desc)])
@@index([organizationId, status])
@@index([tochkaDocumentId])
}
model DocumentLine {
id String @id @default(uuid()) @db.Uuid
documentId String @db.Uuid
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
position Int
serviceId String? @db.Uuid
service ServiceCatalog? @relation(fields: [serviceId], references: [id])
name String
// Количество хранится как milli-units (тысячные), чтобы поддержать дробные количества без float.
qtyMilli BigInt @default(1000)
unit String
priceCents BigInt
vat VatRate @default(none)
sumCents BigInt
@@index([documentId])
@@index([serviceId])
}
model Payment {
id String @id @default(uuid()) @db.Uuid
organizationId String @db.Uuid
organization Organization @relation(fields: [organizationId], references: [id])
documentId String? @db.Uuid
document Document? @relation(fields: [documentId], references: [id])
tochkaPaymentId String @unique
kind PaymentKind
amountCents BigInt
payerInn String?
payerName String?
purpose String?
paidAt DateTime?
raw Json
createdAt DateTime @default(now())
@@index([organizationId, paidAt(sort: Desc)])
@@index([documentId])
}
model TochkaCredential {
id String @id @default(uuid()) @db.Uuid
organizationId String @db.Uuid
organization Organization @relation(fields: [organizationId], references: [id])
environment TochkaEnv
// AES-256-GCM ciphertext (iv|tag|ct), base64
jwtEncrypted String
customerCode String
accountCode String?
expiresAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([organizationId, environment])
}
model WebhookEvent {
id String @id @default(uuid()) @db.Uuid
receivedAt DateTime @default(now())
source String // 'tochka'
eventType String
raw Json
processedAt DateTime?
error String?
dedupeKey String @unique
@@index([source, eventType, receivedAt])
}
model AuditLog {
id String @id @default(uuid()) @db.Uuid
organizationId String @db.Uuid
organization Organization @relation(fields: [organizationId], references: [id])
actorSub String?
action String
entity String
entityId String
diff Json?
at DateTime @default(now())
@@index([organizationId, at(sort: Desc)])
@@index([entity, entityId])
}
+32
View File
@@ -0,0 +1,32 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
const id = process.env.DEFAULT_ORGANIZATION_ID ?? '00000000-0000-0000-0000-000000000001';
const existing = await prisma.organization.findUnique({ where: { id } });
if (existing) {
console.log(`organization ${id} already exists — skipping seed`);
return;
}
await prisma.organization.create({
data: {
id,
name: 'ООО «Моя компания»',
inn: '0000000000',
// Остальные реквизиты пользователь заполнит через UI на странице «Реквизиты».
},
});
console.log(`organization ${id} created (заполните реквизиты в UI: /organization)`);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
+129
View File
@@ -0,0 +1,129 @@
/**
* Dev orchestrator: embedded Postgres → prisma db push → seed → API server.
* Запуск: npm run dev:demo (в корне) или tsx scripts/dev-server.ts (в apps/api).
*
* Использовать ТОЛЬКО для локальной разработки. Защита: процесс выходит,
* если NODE_ENV=production или DEV_BYPASS_AUTH не включён (см. ниже).
*/
import { spawn } from 'node:child_process';
import { existsSync, mkdirSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import EmbeddedPostgres from 'embedded-postgres';
const __dirname = dirname(fileURLToPath(import.meta.url));
const apiRoot = resolve(__dirname, '..');
const PG_PORT = 5433;
const PG_USER = 'postgres';
const PG_PASSWORD = 'postgres';
const PG_DB = 'docmanager';
const PG_DATA = resolve(apiRoot, '../../data/embedded-pg');
const DATABASE_URL = `postgresql://${PG_USER}:${PG_PASSWORD}@localhost:${PG_PORT}/${PG_DB}?schema=public`;
if (process.env.NODE_ENV === 'production') {
console.error('FATAL: dev-server.ts запрещён в production');
process.exit(1);
}
mkdirSync(dirname(PG_DATA), { recursive: true });
const pg = new EmbeddedPostgres({
databaseDir: PG_DATA,
user: PG_USER,
password: PG_PASSWORD,
port: PG_PORT,
persistent: true,
});
async function exec(cmd: string, args: string[], env: NodeJS.ProcessEnv): Promise<void> {
return new Promise((res, rej) => {
const child = spawn(cmd, args, {
stdio: 'inherit',
cwd: apiRoot,
env: { ...process.env, ...env },
shell: process.platform === 'win32',
});
child.on('exit', (code) => (code === 0 ? res() : rej(new Error(`${cmd} ${args.join(' ')} exited ${code}`))));
child.on('error', rej);
});
}
let serverChild: ReturnType<typeof spawn> | null = null;
let stopping = false;
async function shutdown(reason: string) {
if (stopping) return;
stopping = true;
console.log(`\n[dev-server] shutdown: ${reason}`);
try {
if (serverChild && !serverChild.killed) {
serverChild.kill('SIGTERM');
await new Promise((r) => setTimeout(r, 500));
}
} catch (e) {
console.warn('[dev-server] error stopping API:', e);
}
try {
await pg.stop();
} catch (e) {
console.warn('[dev-server] error stopping pg:', e);
}
process.exit(0);
}
process.on('SIGINT', () => void shutdown('SIGINT'));
process.on('SIGTERM', () => void shutdown('SIGTERM'));
async function main() {
const dataInitialised = existsSync(resolve(PG_DATA, 'PG_VERSION'));
if (!dataInitialised) {
console.log('[dev-server] initialising embedded Postgres (~80MB binaries on first run)…');
await pg.initialise();
}
console.log(`[dev-server] starting Postgres on :${PG_PORT}`);
await pg.start();
if (!dataInitialised) {
console.log(`[dev-server] creating database "${PG_DB}"…`);
try {
await pg.createDatabase(PG_DB);
} catch (e) {
console.warn('[dev-server] createDatabase warning:', (e as Error).message);
}
}
console.log('[dev-server] applying schema (prisma db push)…');
await exec('npx', ['prisma', 'db', 'push', '--skip-generate', '--accept-data-loss'], { DATABASE_URL });
console.log('[dev-server] seeding default organization…');
await exec('npx', ['tsx', 'prisma/seed.ts'], { DATABASE_URL });
console.log('[dev-server] starting API server…');
serverChild = spawn('npx', ['tsx', 'watch', 'src/server.ts'], {
stdio: 'inherit',
cwd: apiRoot,
env: {
...process.env,
DATABASE_URL,
DEV_BYPASS_AUTH: '1',
PORT: '3030',
HOST: '127.0.0.1',
NODE_ENV: 'development',
},
shell: process.platform === 'win32',
});
serverChild.on('exit', (code) => {
if (!stopping) {
console.error(`[dev-server] API exited unexpectedly (${code})`);
void shutdown('api-exit');
}
});
}
main().catch((err) => {
console.error('[dev-server] fatal:', err);
void shutdown('fatal');
});
+5
View File
@@ -0,0 +1,5 @@
import { PrismaClient } from '@prisma/client';
export const prisma = new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['warn', 'error'] : ['error'],
});
+41
View File
@@ -0,0 +1,41 @@
import { z } from 'zod';
const EnvSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
PORT: z.coerce.number().int().positive().default(3030),
HOST: z.string().default('127.0.0.1'),
DATABASE_URL: z.string().url(),
AUTH_ISSUER: z.string().url().default('https://auth.queo.ru'),
AUTH_AUDIENCE: z.string().default('queo.ru'),
AUTH_JWKS_URL: z.string().url().default('https://auth.queo.ru/.well-known/jwks.json'),
AUTH_COOKIE_NAME: z.string().default('q_at'),
AUTH_LOGIN_URL: z.string().url().default('https://auth.queo.ru/auth/login'),
CORS_ORIGINS: z
.string()
.default('http://localhost:5173')
.transform((v) => v.split(',').map((s) => s.trim()).filter(Boolean)),
TOCHKA_JWT_KEY: z.string().optional(),
TOCHKA_WEBHOOK_SECRET: z.string().optional(),
DEFAULT_ORGANIZATION_ID: z.string().uuid().default('00000000-0000-0000-0000-000000000001'),
// Только для локальной разработки. В проде — НИКОГДА. Hard-check ниже.
DEV_BYPASS_AUTH: z
.string()
.optional()
.transform((v) => v === '1' || v === 'true'),
});
export type Env = z.infer<typeof EnvSchema>;
export const env: Env = EnvSchema.parse(process.env);
if (env.NODE_ENV === 'production' && env.DEV_BYPASS_AUTH) {
// eslint-disable-next-line no-console
console.error('FATAL: DEV_BYPASS_AUTH=1 в production. Это обход аутентификации, отказываюсь стартовать.');
process.exit(1);
}
+18
View File
@@ -0,0 +1,18 @@
// Fastify сериализует ответы через JSON.stringify, который ругается на BigInt.
// В Doc_manager все денежные/количественные поля копеек/milliQty влезают в JS Number
// (2^53-1 ≈ 9 квадриллионов копеек) — конвертируем BigInt → Number один раз глобально.
//
// Если когда-нибудь понадобится точность выше Number.MAX_SAFE_INTEGER — переключим
// на сериализацию в строку и type-marshalling на фронте.
declare global {
interface BigInt {
toJSON(): number;
}
}
(BigInt.prototype as { toJSON?: () => number }).toJSON = function () {
return Number(this);
};
export {};
+11
View File
@@ -0,0 +1,11 @@
import type { FastifyRequest } from 'fastify';
import { env } from '../env.js';
/**
* Возвращает organization_id, в контексте которого работает запрос.
* Single-tenant v1: всегда DEFAULT_ORGANIZATION_ID.
* При переходе к multi-tenant заменим на маппинг из req.user.permissions / групп / отдельной таблицы membership.
*/
export function getOrganizationId(_req: FastifyRequest): string {
return env.DEFAULT_ORGANIZATION_ID;
}
+111
View File
@@ -0,0 +1,111 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { prisma } from '../../db.js';
import { getOrganizationId } from '../../lib/org.js';
const ClientUpsert = z.object({
kind: z.enum(['ul', 'ip', 'fl']),
name: z.string().min(1).max(500),
inn: z
.string()
.regex(/^\d{10}$|^\d{12}$/)
.nullable(),
kpp: z.string().regex(/^\d{9}$/).nullable(),
address: z.string().max(1000).nullable(),
email: z.string().email().nullable(),
phone: z.string().max(50).nullable(),
contactPerson: z.string().max(200).nullable(),
});
const ListQuery = z.object({
q: z.string().optional(),
limit: z.coerce.number().int().min(1).max(200).default(100),
});
export async function clientsRoutes(app: FastifyInstance) {
app.get('/api/clients', { preHandler: app.requireDocPermission('viewer') }, async (req, reply) => {
const orgId = getOrganizationId(req);
const parsed = ListQuery.safeParse(req.query);
if (!parsed.success) {
reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() });
return;
}
const { q, limit } = parsed.data;
const clients = await prisma.client.findMany({
where: {
organizationId: orgId,
...(q
? {
OR: [
{ name: { contains: q, mode: 'insensitive' } },
{ inn: { contains: q } },
{ email: { contains: q, mode: 'insensitive' } },
],
}
: {}),
},
orderBy: { name: 'asc' },
take: limit,
});
return { items: clients };
});
app.get('/api/clients/:id', { preHandler: app.requireDocPermission('viewer') }, async (req, reply) => {
const orgId = getOrganizationId(req);
const { id } = req.params as { id: string };
const client = await prisma.client.findFirst({ where: { id, organizationId: orgId } });
if (!client) {
reply.code(404).send({ error: 'not_found' });
return;
}
return client;
});
app.post('/api/clients', { preHandler: app.requireDocPermission('user') }, async (req, reply) => {
const orgId = getOrganizationId(req);
const parsed = ClientUpsert.safeParse(req.body);
if (!parsed.success) {
reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() });
return;
}
const created = await prisma.client.create({
data: { ...parsed.data, organizationId: orgId },
});
reply.code(201).send(created);
});
app.put('/api/clients/:id', { preHandler: app.requireDocPermission('user') }, async (req, reply) => {
const orgId = getOrganizationId(req);
const { id } = req.params as { id: string };
const parsed = ClientUpsert.safeParse(req.body);
if (!parsed.success) {
reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() });
return;
}
const existing = await prisma.client.findFirst({ where: { id, organizationId: orgId } });
if (!existing) {
reply.code(404).send({ error: 'not_found' });
return;
}
const updated = await prisma.client.update({ where: { id }, data: parsed.data });
return updated;
});
app.delete('/api/clients/:id', { preHandler: app.requireDocPermission('admin') }, async (req, reply) => {
const orgId = getOrganizationId(req);
const { id } = req.params as { id: string };
const existing = await prisma.client.findFirst({ where: { id, organizationId: orgId } });
if (!existing) {
reply.code(404).send({ error: 'not_found' });
return;
}
// Не используем onDelete: Cascade на documents.clientId — клиента с документами лучше архивировать.
const docCount = await prisma.document.count({ where: { clientId: id } });
if (docCount > 0) {
reply.code(409).send({ error: 'has_documents', count: docCount });
return;
}
await prisma.client.delete({ where: { id } });
reply.code(204).send();
});
}
@@ -0,0 +1,53 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { prisma } from '../../db.js';
import { getOrganizationId } from '../../lib/org.js';
const OrgUpdate = z.object({
name: z.string().min(1).max(500),
inn: z.string().regex(/^\d{10}$|^\d{12}$/),
kpp: z.string().regex(/^\d{9}$/).nullable(),
ogrn: z.string().regex(/^\d{13}$|^\d{15}$/).nullable(),
legalAddress: z.string().max(1000).nullable(),
bankName: z.string().max(500).nullable(),
bankBik: z.string().regex(/^\d{9}$/).nullable(),
bankAccount: z.string().regex(/^\d{20}$/).nullable(),
signatoryName: z.string().max(500).nullable(),
signatoryPosition: z.string().max(500).nullable(),
});
export async function organizationsRoutes(app: FastifyInstance) {
app.get(
'/api/organization',
{ preHandler: app.requireDocPermission('viewer') },
async (req, reply) => {
const id = getOrganizationId(req);
const org = await prisma.organization.findUnique({ where: { id } });
if (!org) {
reply.code(404).send({ error: 'organization_not_found' });
return;
}
return org;
},
);
app.put(
'/api/organization',
{ preHandler: app.requireDocPermission('admin') },
async (req, reply) => {
const id = getOrganizationId(req);
const parsed = OrgUpdate.safeParse(req.body);
if (!parsed.success) {
reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() });
return;
}
// upsert чтобы первое сохранение из UI создавало строку, если её ещё нет (вместо seed-only)
const org = await prisma.organization.upsert({
where: { id },
update: parsed.data,
create: { id, ...parsed.data },
});
return org;
},
);
}
+121
View File
@@ -0,0 +1,121 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { prisma } from '../../db.js';
import { getOrganizationId } from '../../lib/org.js';
const VatRate = z.enum(['none', 'vat_0', 'vat_5', 'vat_7', 'vat_10', 'vat_20']);
const ServiceUpsert = z.object({
name: z.string().min(1).max(500),
unit: z.string().min(1).max(50),
defaultPriceCents: z.coerce.number().int().nonnegative(),
defaultVat: VatRate.default('none'),
notes: z.string().max(2000).nullable(),
});
const ListQuery = z.object({
q: z.string().optional(),
includeArchived: z.coerce.boolean().default(false),
limit: z.coerce.number().int().min(1).max(500).default(200),
});
export async function servicesRoutes(app: FastifyInstance) {
app.get('/api/services', { preHandler: app.requireDocPermission('viewer') }, async (req, reply) => {
const orgId = getOrganizationId(req);
const parsed = ListQuery.safeParse(req.query);
if (!parsed.success) {
reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() });
return;
}
const { q, includeArchived, limit } = parsed.data;
const services = await prisma.serviceCatalog.findMany({
where: {
organizationId: orgId,
...(includeArchived ? {} : { archivedAt: null }),
...(q
? {
OR: [
{ name: { contains: q, mode: 'insensitive' } },
{ notes: { contains: q, mode: 'insensitive' } },
],
}
: {}),
},
orderBy: { name: 'asc' },
take: limit,
});
return { items: services };
});
app.post('/api/services', { preHandler: app.requireDocPermission('user') }, async (req, reply) => {
const orgId = getOrganizationId(req);
const parsed = ServiceUpsert.safeParse(req.body);
if (!parsed.success) {
reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() });
return;
}
const created = await prisma.serviceCatalog.create({
data: {
organizationId: orgId,
name: parsed.data.name,
unit: parsed.data.unit,
defaultPriceCents: BigInt(parsed.data.defaultPriceCents),
defaultVat: parsed.data.defaultVat,
notes: parsed.data.notes ?? null,
},
});
reply.code(201).send(created);
});
app.put('/api/services/:id', { preHandler: app.requireDocPermission('user') }, async (req, reply) => {
const orgId = getOrganizationId(req);
const { id } = req.params as { id: string };
const parsed = ServiceUpsert.safeParse(req.body);
if (!parsed.success) {
reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() });
return;
}
const existing = await prisma.serviceCatalog.findFirst({ where: { id, organizationId: orgId } });
if (!existing) {
reply.code(404).send({ error: 'not_found' });
return;
}
const updated = await prisma.serviceCatalog.update({
where: { id },
data: {
name: parsed.data.name,
unit: parsed.data.unit,
defaultPriceCents: BigInt(parsed.data.defaultPriceCents),
defaultVat: parsed.data.defaultVat,
notes: parsed.data.notes ?? null,
},
});
return updated;
});
// Архивация — soft delete. Жёстко удалять нельзя: на услугу могут ссылаться document_lines.
app.post('/api/services/:id/archive', { preHandler: app.requireDocPermission('user') }, async (req, reply) => {
const orgId = getOrganizationId(req);
const { id } = req.params as { id: string };
const existing = await prisma.serviceCatalog.findFirst({ where: { id, organizationId: orgId } });
if (!existing) {
reply.code(404).send({ error: 'not_found' });
return;
}
return prisma.serviceCatalog.update({
where: { id },
data: { archivedAt: existing.archivedAt ?? new Date() },
});
});
app.post('/api/services/:id/unarchive', { preHandler: app.requireDocPermission('user') }, async (req, reply) => {
const orgId = getOrganizationId(req);
const { id } = req.params as { id: string };
const existing = await prisma.serviceCatalog.findFirst({ where: { id, organizationId: orgId } });
if (!existing) {
reply.code(404).send({ error: 'not_found' });
return;
}
return prisma.serviceCatalog.update({ where: { id }, data: { archivedAt: null } });
});
}
+84
View File
@@ -0,0 +1,84 @@
import fp from 'fastify-plugin';
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from 'jose';
import {
AuthPayload,
hasDocPermission,
type AuthPayload as AuthPayloadT,
type PermissionRole,
} from '@doc-manager/shared';
import { env } from '../env.js';
declare module 'fastify' {
interface FastifyRequest {
user: AuthPayloadT | null;
}
interface FastifyInstance {
requireAuth: (req: import('fastify').FastifyRequest, reply: import('fastify').FastifyReply) => Promise<void>;
requireDocPermission: (
level: PermissionRole,
) => (req: import('fastify').FastifyRequest, reply: import('fastify').FastifyReply) => Promise<void>;
}
}
// Один JWKS-резолвер на процесс, jose сам кэширует ключи.
// Не создаём, если включён dev-bypass — лишний DNS на boot.
const jwks = env.DEV_BYPASS_AUTH ? null : createRemoteJWKSet(new URL(env.AUTH_JWKS_URL));
const DEV_FAKE_USER: AuthPayloadT = {
sub: '11111111-1111-1111-1111-111111111111',
email: 'dev@local',
groups: ['dev'],
permissions: { doc_manager: 'admin' },
isSuperuser: true,
};
export default fp(async function authPlugin(app) {
app.decorateRequest('user', null);
if (env.DEV_BYPASS_AUTH) {
app.log.warn('!!! DEV_BYPASS_AUTH ON — auth fully bypassed, fake admin injected !!!');
}
app.decorate('requireAuth', async function requireAuth(req, reply) {
if (env.DEV_BYPASS_AUTH) {
req.user = DEV_FAKE_USER;
return;
}
const token = req.cookies?.[env.AUTH_COOKIE_NAME];
if (!token) {
reply.code(401).send({ error: 'no_token' });
return;
}
try {
const { payload } = await jwtVerify<JWTPayload>(token, jwks!, {
issuer: env.AUTH_ISSUER,
audience: env.AUTH_AUDIENCE,
});
const parsed = AuthPayload.safeParse(payload);
if (!parsed.success) {
app.log.warn({ err: parsed.error.flatten() }, 'auth: payload schema mismatch');
reply.code(401).send({ error: 'invalid_payload' });
return;
}
req.user = parsed.data;
} catch (e) {
const code =
(e as { code?: string } | null)?.code === 'ERR_JWT_EXPIRED'
? 'token_expired'
: 'invalid_token';
reply.code(401).send({ error: code });
}
});
app.decorate('requireDocPermission', function requireDocPermission(level: PermissionRole) {
return async (req, reply) => {
if (!req.user) {
await app.requireAuth(req, reply);
if (reply.sent) return;
}
if (!req.user || !hasDocPermission(req.user, level)) {
reply.code(403).send({ error: 'forbidden' });
}
};
});
});
+16
View File
@@ -0,0 +1,16 @@
import type { FastifyInstance } from 'fastify';
import { prisma } from '../db.js';
export async function healthRoutes(app: FastifyInstance) {
app.get('/health', async () => ({ ok: true, ts: new Date().toISOString() }));
app.get('/health/db', async (_req, reply) => {
try {
await prisma.$queryRaw`SELECT 1`;
return { ok: true };
} catch (e) {
app.log.error({ err: e }, 'db health check failed');
reply.code(503).send({ ok: false, error: 'db_unavailable' });
}
});
}
+15
View File
@@ -0,0 +1,15 @@
import type { FastifyInstance } from 'fastify';
import { DOC_MANAGER_RESOURCE } from '@doc-manager/shared';
export async function meRoutes(app: FastifyInstance) {
app.get('/api/me', { preHandler: app.requireAuth }, async (req) => {
const u = req.user!;
return {
sub: u.sub,
email: u.email,
groups: u.groups,
isSuperuser: u.isSuperuser,
docPermission: u.permissions[DOC_MANAGER_RESOURCE] ?? null,
};
});
}
+55
View File
@@ -0,0 +1,55 @@
import './lib/bigint.js'; // глобальный BigInt → number в JSON.stringify
import Fastify from 'fastify';
import cookie from '@fastify/cookie';
import cors from '@fastify/cors';
import helmet from '@fastify/helmet';
import { env } from './env.js';
import authPlugin from './plugins/auth.js';
import { healthRoutes } from './routes/health.js';
import { meRoutes } from './routes/me.js';
import { organizationsRoutes } from './modules/organizations/routes.js';
import { clientsRoutes } from './modules/clients/routes.js';
import { servicesRoutes } from './modules/services/routes.js';
async function main() {
const loggerOptions =
env.NODE_ENV === 'development'
? {
level: 'debug',
transport: {
target: 'pino-pretty',
options: { translateTime: 'HH:MM:ss', ignore: 'pid,hostname' },
},
}
: { level: 'info' };
const app = Fastify({ logger: loggerOptions, trustProxy: true });
await app.register(helmet, { contentSecurityPolicy: false });
await app.register(cors, {
origin: env.CORS_ORIGINS,
credentials: true,
});
await app.register(cookie);
await app.register(authPlugin);
await app.register(healthRoutes);
await app.register(meRoutes);
await app.register(organizationsRoutes);
await app.register(clientsRoutes);
await app.register(servicesRoutes);
app.setErrorHandler((err, _req, reply) => {
app.log.error({ err }, 'unhandled error');
if (reply.sent) return;
reply.code(err.statusCode ?? 500).send({ error: err.code ?? 'internal_error' });
});
await app.listen({ port: env.PORT, host: env.HOST });
}
main().catch((err) => {
// eslint-disable-next-line no-console
console.error('fatal:', err);
process.exit(1);
});
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"target": "ES2022",
"types": ["node"],
"declaration": false,
"sourceMap": true
},
"include": ["src/**/*"]
}
+2
View File
@@ -0,0 +1,2 @@
# URL центра аутентификации Queo
VITE_AUTH_LOGIN_URL=https://auth.queo.ru/auth/login
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Doc_manager — Queo</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+25
View File
@@ -0,0 +1,25 @@
{
"name": "@doc-manager/web",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@doc-manager/shared": "*",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.27.0"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.6.3",
"vite": "^5.4.10"
}
}
+72
View File
@@ -0,0 +1,72 @@
import { useEffect } from 'react';
import { Link, Route, Routes } from 'react-router-dom';
import { redirectToLogin, useAuth } from './auth.js';
import { ClientsPage } from './pages/Clients.js';
import { ServicesPage } from './pages/Services.js';
import { OrganizationPage } from './pages/Organization.js';
function Layout({ email }: { email: string }) {
return (
<header className="topbar">
<h1>Doc_manager</h1>
<nav>
<Link to="/">Документы</Link>
<Link to="/clients">Клиенты</Link>
<Link to="/services">Услуги</Link>
<Link to="/templates">Шаблоны</Link>
<Link to="/bank">Банк</Link>
<Link to="/organization">Реквизиты</Link>
</nav>
<span className="user">{email}</span>
</header>
);
}
function Placeholder({ title }: { title: string }) {
return (
<main className="content">
<h2>{title}</h2>
<p>В разработке. См. план: M2M7.</p>
</main>
);
}
function Forbidden({ email }: { email: string }) {
return (
<main className="content">
<h2>Нет доступа</h2>
<p>
Аккаунт <b>{email}</b> авторизован в Queo, но не имеет роли в Doc_manager. Попросите
администратора выдать <code>doc_manager</code> permission в auth.queo.ru.
</p>
</main>
);
}
export function App() {
const auth = useAuth();
useEffect(() => {
if (auth.status === 'unauthenticated') redirectToLogin();
}, [auth.status]);
if (auth.status === 'loading' || auth.status === 'unauthenticated') {
return <div className="loading">Проверка доступа</div>;
}
if (auth.status === 'forbidden') return <Forbidden email={auth.me.email} />;
return (
<>
<Layout email={auth.me.email} />
<Routes>
<Route path="/" element={<Placeholder title="Документы" />} />
<Route path="/clients" element={<ClientsPage />} />
<Route path="/services" element={<ServicesPage />} />
<Route path="/templates" element={<Placeholder title="Шаблоны договоров" />} />
<Route path="/bank" element={<Placeholder title="Банк" />} />
<Route path="/organization" element={<OrganizationPage />} />
<Route path="*" element={<Placeholder title="Не найдено" />} />
</Routes>
</>
);
}
+74
View File
@@ -0,0 +1,74 @@
import { redirectToLogin } from './auth.js';
export class ApiError extends Error {
constructor(public status: number, public code: string, public details?: unknown) {
super(`${status} ${code}`);
}
}
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
const init: RequestInit = { method, credentials: 'include' };
if (body !== undefined) {
init.headers = { 'Content-Type': 'application/json' };
init.body = JSON.stringify(body);
}
const res = await fetch(path, init);
if (res.status === 401) {
redirectToLogin();
}
if (res.status === 204) return undefined as T;
const text = await res.text();
const data = text ? JSON.parse(text) : undefined;
if (!res.ok) {
throw new ApiError(res.status, (data as { error?: string })?.error ?? 'http_error', data);
}
return data as T;
}
export const api = {
get: <T>(p: string) => request<T>('GET', p),
post: <T>(p: string, body: unknown) => request<T>('POST', p, body),
put: <T>(p: string, body: unknown) => request<T>('PUT', p, body),
del: <T = void>(p: string) => request<T>('DELETE', p),
};
export type Organization = {
id: string;
name: string;
inn: string;
kpp: string | null;
ogrn: string | null;
legalAddress: string | null;
bankName: string | null;
bankBik: string | null;
bankAccount: string | null;
signatoryName: string | null;
signatoryPosition: string | null;
};
export type Client = {
id: string;
organizationId: string;
kind: 'ul' | 'ip' | 'fl';
name: string;
inn: string | null;
kpp: string | null;
address: string | null;
email: string | null;
phone: string | null;
contactPerson: string | null;
requisitesJson: Record<string, unknown> | null;
createdAt: string;
updatedAt: string;
};
export type Service = {
id: string;
organizationId: string;
name: string;
unit: string;
defaultPriceCents: number; // BigInt сериализуется в number (см. apps/api/src/lib/bigint.ts)
defaultVat: 'none' | 'vat_0' | 'vat_5' | 'vat_7' | 'vat_10' | 'vat_20';
notes: string | null;
archivedAt: string | null;
};
+58
View File
@@ -0,0 +1,58 @@
import { useEffect, useState } from 'react';
import type { PermissionRole } from '@doc-manager/shared';
export type Me = {
sub: string;
email: string;
groups: string[];
isSuperuser: boolean;
docPermission: PermissionRole | null;
};
const AUTH_LOGIN_URL = import.meta.env.VITE_AUTH_LOGIN_URL ?? 'https://auth.queo.ru/auth/login';
export function redirectToLogin(): never {
const returnTo = encodeURIComponent(window.location.href);
window.location.href = `${AUTH_LOGIN_URL}?return_to=${returnTo}`;
throw new Error('redirecting');
}
export type AuthState =
| { status: 'loading' }
| { status: 'authenticated'; me: Me }
| { status: 'unauthenticated' }
| { status: 'forbidden'; me: Me };
export function useAuth(): AuthState {
const [state, setState] = useState<AuthState>({ status: 'loading' });
useEffect(() => {
let cancelled = false;
fetch('/api/me', { credentials: 'include' })
.then(async (r) => {
if (cancelled) return;
if (r.status === 401) {
setState({ status: 'unauthenticated' });
return;
}
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const me = (await r.json()) as Me;
// Для M1 любой залогиненный пользователь видит шелл; запрет отдельных страниц — позже.
if (me.docPermission == null && !me.isSuperuser) {
setState({ status: 'forbidden', me });
return;
}
setState({ status: 'authenticated', me });
})
.catch((e) => {
if (cancelled) return;
console.error('auth fetch failed', e);
setState({ status: 'unauthenticated' });
});
return () => {
cancelled = true;
};
}, []);
return state;
}
+109
View File
@@ -0,0 +1,109 @@
import { type ButtonHTMLAttributes, type InputHTMLAttributes, type ReactNode, type SelectHTMLAttributes, type TextareaHTMLAttributes, useEffect } from 'react';
export function Button({
variant = 'default',
...props
}: ButtonHTMLAttributes<HTMLButtonElement> & { variant?: 'default' | 'primary' | 'danger' | 'ghost' }) {
return <button {...props} className={`btn btn--${variant} ${props.className ?? ''}`} />;
}
export function Field(
props: InputHTMLAttributes<HTMLInputElement> & { label: string; error?: string },
) {
const { label, error, ...input } = props;
return (
<label className="field">
<span className="field__label">{label}</span>
<input {...input} className={`field__input ${error ? 'field__input--err' : ''}`} />
{error ? <span className="field__error">{error}</span> : null}
</label>
);
}
export function Textarea(props: TextareaHTMLAttributes<HTMLTextAreaElement> & { label: string }) {
const { label, ...textarea } = props;
return (
<label className="field">
<span className="field__label">{label}</span>
<textarea {...textarea} className="field__input field__input--area" />
</label>
);
}
export function Select<T extends string>(
props: Omit<SelectHTMLAttributes<HTMLSelectElement>, 'value' | 'onChange'> & {
label: string;
value: T;
onChange: (v: T) => void;
options: ReadonlyArray<{ value: T; label: string }>;
},
) {
const { label, value, onChange, options, ...sel } = props;
return (
<label className="field">
<span className="field__label">{label}</span>
<select
{...sel}
className="field__input"
value={value}
onChange={(e) => onChange(e.target.value as T)}
>
{options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</label>
);
}
export function Modal({
open,
title,
onClose,
children,
footer,
}: {
open: boolean;
title: string;
onClose: () => void;
children: ReactNode;
footer?: ReactNode;
}) {
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, onClose]);
if (!open) return null;
return (
<div className="modal__backdrop" onClick={onClose}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<header className="modal__header">
<h3>{title}</h3>
<button className="modal__close" onClick={onClose} aria-label="Закрыть">
×
</button>
</header>
<div className="modal__body">{children}</div>
{footer ? <footer className="modal__footer">{footer}</footer> : null}
</div>
</div>
);
}
export function EmptyState({ children }: { children: ReactNode }) {
return <div className="empty">{children}</div>;
}
export function formatRub(cents: number): string {
return (cents / 100).toLocaleString('ru-RU', {
style: 'currency',
currency: 'RUB',
minimumFractionDigits: 2,
});
}
+13
View File
@@ -0,0 +1,13 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { App } from './App.js';
import './styles.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
);
+189
View File
@@ -0,0 +1,189 @@
import { useEffect, useState } from 'react';
import { api, ApiError, type Client } from '../api.js';
import { Button, EmptyState, Field, Modal, Select } from '../components/ui.js';
const KIND_LABEL: Record<Client['kind'], string> = {
ul: 'Юр. лицо',
ip: 'ИП',
fl: 'Физ. лицо',
};
const emptyDraft = (): Partial<Client> => ({
kind: 'ul',
name: '',
inn: '',
kpp: '',
address: '',
email: '',
phone: '',
contactPerson: '',
});
export function ClientsPage() {
const [items, setItems] = useState<Client[] | null>(null);
const [q, setQ] = useState('');
const [editing, setEditing] = useState<Partial<Client> | null>(null);
const [error, setError] = useState<string | null>(null);
async function load() {
setError(null);
try {
const r = await api.get<{ items: Client[] }>(
`/api/clients${q ? `?q=${encodeURIComponent(q)}` : ''}`,
);
setItems(r.items);
} catch (e) {
setError(String(e));
}
}
useEffect(() => {
void load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [q]);
async function save() {
if (!editing) return;
setError(null);
try {
const payload = {
kind: editing.kind ?? 'ul',
name: editing.name ?? '',
inn: editing.inn || null,
kpp: editing.kpp || null,
address: editing.address || null,
email: editing.email || null,
phone: editing.phone || null,
contactPerson: editing.contactPerson || null,
};
if (editing.id) {
await api.put<Client>(`/api/clients/${editing.id}`, payload);
} else {
await api.post<Client>('/api/clients', payload);
}
setEditing(null);
await load();
} catch (e) {
setError(e instanceof ApiError ? `${e.code} (${e.status})` : String(e));
}
}
async function remove(id: string) {
if (!confirm('Удалить клиента?')) return;
try {
await api.del(`/api/clients/${id}`);
await load();
} catch (e) {
if (e instanceof ApiError && e.code === 'has_documents') {
alert(`Нельзя удалить — есть ${(e.details as { count?: number })?.count ?? 0} документов. Архивацию добавим позже.`);
return;
}
setError(String(e));
}
}
const set = <K extends keyof Client>(k: K, v: Client[K] | string) =>
setEditing((d) => (d ? { ...d, [k]: v as Client[K] } : d));
return (
<main className="content">
<header className="page-head">
<h2>Клиенты</h2>
<Button variant="primary" onClick={() => setEditing(emptyDraft())}>
+ Добавить
</Button>
</header>
<div className="toolbar">
<input
className="search"
placeholder="Поиск по названию, ИНН, email…"
value={q}
onChange={(e) => setQ(e.target.value)}
/>
</div>
{error ? <div className="error-text">{error}</div> : null}
{items === null ? (
<p className="hint">Загрузка</p>
) : items.length === 0 ? (
<EmptyState>
{q ? 'Ничего не найдено.' : 'Пока нет клиентов. Добавьте первого, чтобы выставлять документы.'}
</EmptyState>
) : (
<table className="table">
<thead>
<tr>
<th>Тип</th>
<th>Название</th>
<th>ИНН</th>
<th>Email</th>
<th>Телефон</th>
<th aria-label="actions" />
</tr>
</thead>
<tbody>
{items.map((c) => (
<tr key={c.id}>
<td>{KIND_LABEL[c.kind]}</td>
<td>{c.name}</td>
<td>{c.inn ?? '—'}</td>
<td>{c.email ?? '—'}</td>
<td>{c.phone ?? '—'}</td>
<td className="row-actions">
<Button variant="ghost" onClick={() => setEditing(c)}>
Изменить
</Button>
<Button variant="danger" onClick={() => remove(c.id)}>
Удалить
</Button>
</td>
</tr>
))}
</tbody>
</table>
)}
<Modal
open={editing !== null}
title={editing?.id ? 'Изменить клиента' : 'Новый клиент'}
onClose={() => setEditing(null)}
footer={
<>
<Button variant="ghost" onClick={() => setEditing(null)}>
Отмена
</Button>
<Button variant="primary" onClick={save}>
Сохранить
</Button>
</>
}
>
<div className="form-grid">
<Select
label="Тип"
value={(editing?.kind ?? 'ul') as Client['kind']}
onChange={(v) => set('kind', v)}
options={[
{ value: 'ul' as const, label: 'Юр. лицо' },
{ value: 'ip' as const, label: 'ИП' },
{ value: 'fl' as const, label: 'Физ. лицо' },
]}
/>
<Field label="Название" value={editing?.name ?? ''} onChange={(e) => set('name', e.target.value)} />
<Field label="ИНН" value={editing?.inn ?? ''} onChange={(e) => set('inn', e.target.value)} />
<Field label="КПП" value={editing?.kpp ?? ''} onChange={(e) => set('kpp', e.target.value)} />
<Field label="Адрес" value={editing?.address ?? ''} onChange={(e) => set('address', e.target.value)} />
<Field label="Email" type="email" value={editing?.email ?? ''} onChange={(e) => set('email', e.target.value)} />
<Field label="Телефон" value={editing?.phone ?? ''} onChange={(e) => set('phone', e.target.value)} />
<Field
label="Контактное лицо"
value={editing?.contactPerson ?? ''}
onChange={(e) => set('contactPerson', e.target.value)}
/>
</div>
</Modal>
</main>
);
}
+134
View File
@@ -0,0 +1,134 @@
import { useEffect, useState } from 'react';
import { api, ApiError, type Organization } from '../api.js';
import { Button, Field } from '../components/ui.js';
export function OrganizationPage() {
const [org, setOrg] = useState<Organization | null>(null);
const [draft, setDraft] = useState<Partial<Organization>>({});
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [savedAt, setSavedAt] = useState<Date | null>(null);
useEffect(() => {
api
.get<Organization>('/api/organization')
.then((o) => {
setOrg(o);
setDraft(o);
})
.catch((e) => {
if (e instanceof ApiError && e.status === 404) {
// Первый запуск — БД сидится пустой записью; пользователь заполняет с нуля.
setDraft({ name: '', inn: '' });
return;
}
setError(String(e));
});
}, []);
async function save() {
setSaving(true);
setError(null);
try {
const saved = await api.put<Organization>('/api/organization', {
name: draft.name ?? '',
inn: draft.inn ?? '',
kpp: draft.kpp || null,
ogrn: draft.ogrn || null,
legalAddress: draft.legalAddress || null,
bankName: draft.bankName || null,
bankBik: draft.bankBik || null,
bankAccount: draft.bankAccount || null,
signatoryName: draft.signatoryName || null,
signatoryPosition: draft.signatoryPosition || null,
});
setOrg(saved);
setDraft(saved);
setSavedAt(new Date());
} catch (e) {
setError(e instanceof ApiError ? `${e.code} (${e.status})` : String(e));
} finally {
setSaving(false);
}
}
const set = <K extends keyof Organization>(k: K, v: Organization[K] | string) =>
setDraft((d) => ({ ...d, [k]: v as Organization[K] }));
return (
<main className="content">
<h2>Реквизиты организации</h2>
<p className="hint">Будут подставляться в договоры и счета как сторона-исполнитель.</p>
<section className="form-grid">
<Field
label="Название"
value={draft.name ?? ''}
onChange={(e) => set('name', e.target.value)}
placeholder="ООО «Моя компания»"
/>
<Field
label="ИНН"
value={draft.inn ?? ''}
onChange={(e) => set('inn', e.target.value)}
placeholder="10 или 12 цифр"
/>
<Field
label="КПП"
value={draft.kpp ?? ''}
onChange={(e) => set('kpp', e.target.value)}
placeholder="9 цифр"
/>
<Field
label="ОГРН/ОГРНИП"
value={draft.ogrn ?? ''}
onChange={(e) => set('ogrn', e.target.value)}
placeholder="13 или 15 цифр"
/>
<Field
label="Юр. адрес"
value={draft.legalAddress ?? ''}
onChange={(e) => set('legalAddress', e.target.value)}
/>
<Field
label="Банк"
value={draft.bankName ?? ''}
onChange={(e) => set('bankName', e.target.value)}
placeholder="Точка ПАО Банка ФК Открытие"
/>
<Field
label="БИК"
value={draft.bankBik ?? ''}
onChange={(e) => set('bankBik', e.target.value)}
placeholder="9 цифр"
/>
<Field
label="Расчётный счёт"
value={draft.bankAccount ?? ''}
onChange={(e) => set('bankAccount', e.target.value)}
placeholder="20 цифр"
/>
<Field
label="Подписант ФИО"
value={draft.signatoryName ?? ''}
onChange={(e) => set('signatoryName', e.target.value)}
/>
<Field
label="Должность подписанта"
value={draft.signatoryPosition ?? ''}
onChange={(e) => set('signatoryPosition', e.target.value)}
placeholder="Генеральный директор"
/>
</section>
<div className="form-actions">
<Button variant="primary" onClick={save} disabled={saving}>
{saving ? 'Сохраняю…' : 'Сохранить'}
</Button>
{savedAt ? <span className="hint">Сохранено в {savedAt.toLocaleTimeString('ru-RU')}</span> : null}
{error ? <span className="error-text">{error}</span> : null}
{org === null && !error ? null : null}
</div>
</main>
);
}
+250
View File
@@ -0,0 +1,250 @@
import { useEffect, useState } from 'react';
import { api, ApiError, type Service } from '../api.js';
import { Button, EmptyState, Field, Modal, Select, Textarea, formatRub } from '../components/ui.js';
const VAT_OPTIONS = [
{ value: 'none' as const, label: 'Без НДС' },
{ value: 'vat_0' as const, label: '0%' },
{ value: 'vat_5' as const, label: '5%' },
{ value: 'vat_7' as const, label: '7%' },
{ value: 'vat_10' as const, label: '10%' },
{ value: 'vat_20' as const, label: '20%' },
];
const VAT_LABEL: Record<Service['defaultVat'], string> = {
none: 'Без НДС',
vat_0: '0%',
vat_5: '5%',
vat_7: '7%',
vat_10: '10%',
vat_20: '20%',
};
type Draft = {
id?: string;
name: string;
unit: string;
priceRub: string; // строка для контрол-инпута, конвертим в копейки при отправке
defaultVat: Service['defaultVat'];
notes: string;
};
const emptyDraft = (): Draft => ({
name: '',
unit: 'шт',
priceRub: '',
defaultVat: 'none',
notes: '',
});
const toDraft = (s: Service): Draft => ({
id: s.id,
name: s.name,
unit: s.unit,
priceRub: (s.defaultPriceCents / 100).toFixed(2),
defaultVat: s.defaultVat,
notes: s.notes ?? '',
});
export function ServicesPage() {
const [items, setItems] = useState<Service[] | null>(null);
const [q, setQ] = useState('');
const [includeArchived, setIncludeArchived] = useState(false);
const [editing, setEditing] = useState<Draft | null>(null);
const [error, setError] = useState<string | null>(null);
async function load() {
setError(null);
try {
const params = new URLSearchParams();
if (q) params.set('q', q);
if (includeArchived) params.set('includeArchived', '1');
const r = await api.get<{ items: Service[] }>(`/api/services?${params.toString()}`);
setItems(r.items);
} catch (e) {
setError(String(e));
}
}
useEffect(() => {
void load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [q, includeArchived]);
async function save() {
if (!editing) return;
setError(null);
const priceCents = Math.round(parseFloat(editing.priceRub.replace(',', '.') || '0') * 100);
if (Number.isNaN(priceCents) || priceCents < 0) {
setError('Некорректная цена');
return;
}
const payload = {
name: editing.name,
unit: editing.unit,
defaultPriceCents: priceCents,
defaultVat: editing.defaultVat,
notes: editing.notes || null,
};
try {
if (editing.id) {
await api.put<Service>(`/api/services/${editing.id}`, payload);
} else {
await api.post<Service>('/api/services', payload);
}
setEditing(null);
await load();
} catch (e) {
setError(e instanceof ApiError ? `${e.code} (${e.status})` : String(e));
}
}
async function archive(s: Service) {
try {
await api.post(`/api/services/${s.id}/archive`, {});
await load();
} catch (e) {
setError(String(e));
}
}
async function unarchive(s: Service) {
try {
await api.post(`/api/services/${s.id}/unarchive`, {});
await load();
} catch (e) {
setError(String(e));
}
}
const set = <K extends keyof Draft>(k: K, v: Draft[K]) => setEditing((d) => (d ? { ...d, [k]: v } : d));
return (
<main className="content">
<header className="page-head">
<h2>Каталог услуг</h2>
<Button variant="primary" onClick={() => setEditing(emptyDraft())}>
+ Добавить
</Button>
</header>
<div className="toolbar">
<input
className="search"
placeholder="Поиск по названию или примечаниям…"
value={q}
onChange={(e) => setQ(e.target.value)}
/>
<label className="checkbox">
<input
type="checkbox"
checked={includeArchived}
onChange={(e) => setIncludeArchived(e.target.checked)}
/>
Показать архив
</label>
</div>
{error ? <div className="error-text">{error}</div> : null}
{items === null ? (
<p className="hint">Загрузка</p>
) : items.length === 0 ? (
<EmptyState>
{q ? 'Ничего не найдено.' : 'Каталог пуст. Добавьте услугу — её можно будет вставить в счёт или договор.'}
</EmptyState>
) : (
<table className="table">
<thead>
<tr>
<th>Услуга</th>
<th>Ед.</th>
<th>Цена</th>
<th>НДС</th>
<th>Статус</th>
<th aria-label="actions" />
</tr>
</thead>
<tbody>
{items.map((s) => (
<tr key={s.id} className={s.archivedAt ? 'row--archived' : ''}>
<td>
<div>{s.name}</div>
{s.notes ? <div className="hint">{s.notes}</div> : null}
</td>
<td>{s.unit}</td>
<td>{formatRub(s.defaultPriceCents)}</td>
<td>{VAT_LABEL[s.defaultVat]}</td>
<td>{s.archivedAt ? 'архив' : 'активна'}</td>
<td className="row-actions">
<Button variant="ghost" onClick={() => setEditing(toDraft(s))}>
Изменить
</Button>
{s.archivedAt ? (
<Button variant="ghost" onClick={() => unarchive(s)}>
Восстановить
</Button>
) : (
<Button variant="danger" onClick={() => archive(s)}>
В архив
</Button>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
<Modal
open={editing !== null}
title={editing?.id ? 'Изменить услугу' : 'Новая услуга'}
onClose={() => setEditing(null)}
footer={
<>
<Button variant="ghost" onClick={() => setEditing(null)}>
Отмена
</Button>
<Button variant="primary" onClick={save}>
Сохранить
</Button>
</>
}
>
<div className="form-grid">
<Field
label="Название"
value={editing?.name ?? ''}
onChange={(e) => set('name', e.target.value)}
placeholder="Монтаж видеостены"
/>
<Field
label="Единица"
value={editing?.unit ?? ''}
onChange={(e) => set('unit', e.target.value)}
placeholder="шт / час / м²"
/>
<Field
label="Цена ₽"
type="number"
inputMode="decimal"
step="0.01"
value={editing?.priceRub ?? ''}
onChange={(e) => set('priceRub', e.target.value)}
/>
<Select
label="НДС по умолчанию"
value={(editing?.defaultVat ?? 'none') as Service['defaultVat']}
onChange={(v) => set('defaultVat', v)}
options={VAT_OPTIONS}
/>
<Textarea
label="Примечания"
value={editing?.notes ?? ''}
onChange={(e) => set('notes', e.target.value)}
rows={3}
/>
</div>
</Modal>
</main>
);
}
+166
View File
@@ -0,0 +1,166 @@
:root {
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
font-size: 15px;
line-height: 1.45;
color-scheme: light dark;
}
body {
margin: 0;
background: #f6f7f9;
color: #1c1f24;
}
@media (prefers-color-scheme: dark) {
body { background: #14161a; color: #e7e8eb; }
}
.topbar {
display: flex;
align-items: center;
gap: 24px;
padding: 12px 24px;
background: #1c1f24;
color: #f6f7f9;
border-bottom: 1px solid #2a2e35;
}
.topbar h1 { margin: 0; font-size: 18px; font-weight: 600; }
.topbar nav { display: flex; gap: 16px; flex: 1; }
.topbar nav a { color: #c9cbcf; text-decoration: none; }
.topbar nav a:hover { color: #fff; }
.topbar .user { opacity: 0.7; font-size: 13px; }
.content { padding: 24px; max-width: 1200px; margin: 0 auto; }
.loading {
display: flex; align-items: center; justify-content: center;
height: 100vh; opacity: 0.6;
}
/* === page primitives === */
.page-head {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 16px;
}
.page-head h2 { margin: 0; }
.toolbar {
display: flex; align-items: center; gap: 12px;
margin-bottom: 16px;
}
.search {
flex: 1; max-width: 360px;
padding: 8px 12px; border: 1px solid #d6d8dd; border-radius: 6px;
background: #fff; color: inherit; font-size: 14px;
}
@media (prefers-color-scheme: dark) {
.search { background: #1c1f24; border-color: #2a2e35; }
}
.checkbox {
display: inline-flex; align-items: center; gap: 6px;
font-size: 13px; cursor: pointer;
}
.hint { opacity: 0.65; font-size: 13px; margin: 4px 0; }
.error-text { color: #c0392b; font-size: 13px; margin: 8px 0; }
.empty {
padding: 48px 24px; text-align: center; opacity: 0.6;
border: 1px dashed #d6d8dd; border-radius: 8px;
}
/* === buttons === */
.btn {
appearance: none; cursor: pointer;
padding: 6px 14px; border: 1px solid transparent; border-radius: 6px;
font-size: 14px; line-height: 1.4;
transition: background-color 0.1s, border-color 0.1s;
}
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn--default { background: #fff; border-color: #d6d8dd; color: inherit; }
.btn--default:hover:not(:disabled) { background: #f1f2f5; }
.btn--primary { background: #2563eb; color: #fff; }
.btn--primary:hover:not(:disabled) { background: #1d4ed8; }
.btn--danger { background: transparent; border-color: #c0392b; color: #c0392b; }
.btn--danger:hover:not(:disabled) { background: #c0392b; color: #fff; }
.btn--ghost { background: transparent; color: inherit; }
.btn--ghost:hover:not(:disabled) { background: rgba(127,127,127,0.1); }
@media (prefers-color-scheme: dark) {
.btn--default { background: #1c1f24; border-color: #2a2e35; }
.btn--default:hover:not(:disabled) { background: #25282e; }
}
/* === tables === */
.table {
width: 100%; border-collapse: collapse;
background: #fff; border-radius: 8px; overflow: hidden;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
}
.table th, .table td {
text-align: left; padding: 10px 14px; vertical-align: top;
border-bottom: 1px solid #eef0f3;
font-size: 14px;
}
.table th { font-weight: 600; opacity: 0.7; font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; }
.table tr:last-child td { border-bottom: none; }
.row-actions { white-space: nowrap; text-align: right; }
.row-actions .btn { margin-left: 6px; }
.row--archived { opacity: 0.55; }
@media (prefers-color-scheme: dark) {
.table { background: #1c1f24; box-shadow: none; }
.table th, .table td { border-bottom-color: #2a2e35; }
}
/* === fields === */
.field { display: flex; flex-direction: column; gap: 4px; }
.field__label { font-size: 12px; opacity: 0.7; }
.field__input {
padding: 8px 10px; border: 1px solid #d6d8dd; border-radius: 6px;
background: #fff; color: inherit; font-size: 14px;
font-family: inherit;
}
.field__input--area { min-height: 64px; resize: vertical; }
.field__input--err { border-color: #c0392b; }
.field__error { color: #c0392b; font-size: 12px; }
@media (prefers-color-scheme: dark) {
.field__input { background: #1c1f24; border-color: #2a2e35; }
}
.form-grid {
display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px;
}
.form-actions { display: flex; align-items: center; gap: 12px; margin-top: 16px; }
/* === modal === */
.modal__backdrop {
position: fixed; inset: 0; background: rgba(0,0,0,0.5);
display: flex; align-items: center; justify-content: center;
z-index: 100; padding: 24px;
}
.modal {
background: #fff; color: inherit;
border-radius: 8px; width: min(720px, 100%);
max-height: calc(100vh - 48px); overflow: hidden;
display: flex; flex-direction: column;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
}
.modal__header {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 20px; border-bottom: 1px solid #eef0f3;
}
.modal__header h3 { margin: 0; font-size: 16px; }
.modal__close {
background: none; border: none; color: inherit; cursor: pointer;
font-size: 22px; line-height: 1; padding: 4px 8px; border-radius: 4px;
}
.modal__close:hover { background: rgba(127,127,127,0.1); }
.modal__body { padding: 20px; overflow: auto; }
.modal__footer {
display: flex; justify-content: flex-end; gap: 8px;
padding: 12px 20px; border-top: 1px solid #eef0f3;
}
@media (prefers-color-scheme: dark) {
.modal { background: #1c1f24; }
.modal__header, .modal__footer { border-color: #2a2e35; }
}
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"module": "ESNext",
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"noEmit": true,
"types": ["vite/client"]
},
"include": ["src/**/*"]
}
+1
View File
@@ -0,0 +1 @@
{"root":["./src/app.tsx","./src/api.ts","./src/auth.ts","./src/main.tsx","./src/components/ui.tsx","./src/pages/clients.tsx","./src/pages/organization.tsx","./src/pages/services.tsx"],"version":"5.9.3"}
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
host: true, // 0.0.0.0 — доступно с других устройств в LAN
port: 5173,
proxy: {
// В dev фронт ходит на API через /api и /webhooks. CORS+credentials уже настроены, но прокси убирает cross-origin.
'/api': { target: 'http://localhost:3030', changeOrigin: true },
'/webhooks': { target: 'http://localhost:3030', changeOrigin: true },
'/health': { target: 'http://localhost:3030', changeOrigin: true },
},
},
});
+19
View File
@@ -0,0 +1,19 @@
# Заполнить и переименовать в .env (рядом с docker-compose.yml)
POSTGRES_DB=docmanager
POSTGRES_USER=docmanager
POSTGRES_PASSWORD=change-me-strong-password
# AES-256-GCM ключ для шифрования JWT-токенов Точки в БД (32 байта в base64).
# node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
TOCHKA_JWT_KEY=
# Случайная строка в URL-пути приёмника webhook (длинная, например 32+ символов).
# node -e "console.log(require('crypto').randomBytes(24).toString('hex'))"
TOCHKA_WEBHOOK_SECRET=
# UUID единственной организации в v1.
DEFAULT_ORGANIZATION_ID=00000000-0000-0000-0000-000000000001
# Токен для browserless/chromium контейнера.
CHROMIUM_TOKEN=
+24
View File
@@ -0,0 +1,24 @@
# Caddy для Doc_manager
# Public host: doc.queo.ru (cookie-домен .queo.ru — общий с auth.queo.ru/hall.queo.ru)
doc.queo.ru {
encode zstd gzip
# API + webhooks + health → Fastify
@api path /api/* /webhooks/* /health /health/*
handle @api {
reverse_proxy api:3030
}
# Всё остальное — статика SPA
handle {
reverse_proxy web:80
}
log {
output file /data/access.log {
roll_size 10mb
roll_keep 5
}
}
}
+31
View File
@@ -0,0 +1,31 @@
FROM node:20-alpine
WORKDIR /app
RUN apk add --no-cache openssl tini
# Корневой манифест для npm workspaces
COPY package.json package-lock.json* tsconfig.base.json ./
# Манифесты воркспейсов
COPY apps/api/package.json apps/api/
COPY packages/shared/package.json packages/shared/
# Все зависимости (включая dev — нужен tsx и prisma CLI). Образ на api ~250MB,
# приемлемо для small-scale деплоя; оптимизируем многоэтапной сборкой когда понадобится.
RUN npm install --include=dev
# Исходники
COPY apps/api ./apps/api
COPY packages/shared ./packages/shared
# Prisma client (без коннекта к БД)
RUN cd apps/api && npx prisma generate
ENV NODE_ENV=production
WORKDIR /app/apps/api
EXPOSE 3030
ENTRYPOINT ["/sbin/tini", "--"]
# `prisma migrate deploy` накатывает все миграции из prisma/migrations.
# При первом деплое (миграций ещё нет) выполнит `db push` — но db push в проде
# опасен; на продакшен-этапе всегда коммитим миграции в репо через `prisma migrate dev`.
CMD ["sh", "-c", "npx prisma migrate deploy && npx tsx src/server.ts"]
+14
View File
@@ -0,0 +1,14 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json* tsconfig.base.json ./
COPY apps/web/package.json apps/web/
COPY packages/shared/package.json packages/shared/
RUN npm install
COPY apps/web ./apps/web
COPY packages/shared ./packages/shared
RUN npm run build --workspace apps/web
FROM nginx:1.27-alpine AS runtime
COPY docker/nginx-spa.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/apps/web/dist /usr/share/nginx/html
EXPOSE 80
+61
View File
@@ -0,0 +1,61 @@
name: doc-manager
# Деплой на queoserver (192.168.0.158): хостовый Caddy в /etc/caddy/Caddyfile
# проксирует doc.queo.ru → localhost:3031 (web с внутренним прокси /api/* → api).
# Локально для разработки используем npm run dev:demo, не этот compose.
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-docmanager}
POSTGRES_USER: ${POSTGRES_USER:-docmanager}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-docmanager}
volumes:
- pg_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-docmanager} -d ${POSTGRES_DB:-docmanager}"]
interval: 10s
timeout: 5s
retries: 5
api:
build:
context: ..
dockerfile: docker/Dockerfile.api
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
environment:
NODE_ENV: production
PORT: 3030
HOST: 0.0.0.0
DATABASE_URL: postgresql://${POSTGRES_USER:-docmanager}:${POSTGRES_PASSWORD:-docmanager}@postgres:5432/${POSTGRES_DB:-docmanager}?schema=public
AUTH_ISSUER: ${AUTH_ISSUER:-https://auth.queo.ru}
AUTH_AUDIENCE: ${AUTH_AUDIENCE:-queo.ru}
AUTH_JWKS_URL: ${AUTH_JWKS_URL:-https://auth.queo.ru/.well-known/jwks.json}
AUTH_COOKIE_NAME: q_at
AUTH_LOGIN_URL: ${AUTH_LOGIN_URL:-https://auth.queo.ru/auth/login}
CORS_ORIGINS: ${CORS_ORIGINS:-https://doc.queo.ru}
TOCHKA_JWT_KEY: ${TOCHKA_JWT_KEY:-}
TOCHKA_WEBHOOK_SECRET: ${TOCHKA_WEBHOOK_SECRET:-}
DEFAULT_ORGANIZATION_ID: ${DEFAULT_ORGANIZATION_ID:-00000000-0000-0000-0000-000000000001}
DEV_BYPASS_AUTH: "0"
expose:
- "3030"
web:
build:
context: ..
dockerfile: docker/Dockerfile.web
restart: unless-stopped
depends_on:
- api
ports:
# Хостовый Caddy на queoserver: doc.queo.ru → localhost:3031
- "127.0.0.1:3031:80"
volumes:
pg_data:
+48
View File
@@ -0,0 +1,48 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
# Долгий кэш для хэшированных ассетов Vite
location /assets/ {
access_log off;
expires 1y;
add_header Cache-Control "public, immutable";
}
# Прокси на API. Web и api живут в одной compose-сети, dns "api" резолвится.
# Браузер видит всё как один origin (https://doc.queo.ru), куки auth.queo.ru
# отправляются автоматически на запросы к /api/me.
location /api/ {
proxy_pass http://api:3030;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Cookie $http_cookie;
}
# Webhook'и от Точки приходят на этот же origin.
location /webhooks/ {
proxy_pass http://api:3030;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Webhook payload может быть большим (тело документа PDF в base64) — не ограничиваем.
client_max_body_size 10m;
}
# Health для мониторинга (не для пользователей).
location = /health {
proxy_pass http://api:3030;
}
}
+4011
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "doc-manager",
"private": true,
"version": "0.0.0",
"description": "Контракты, счета и акты с интеграцией банка Точка. Часть экосистемы Queo.",
"workspaces": [
"apps/*",
"packages/*"
],
"scripts": {
"dev": "npm run dev --workspaces --if-present",
"dev:api": "npm run dev -w @doc-manager/api",
"dev:web": "npm run dev -w @doc-manager/web",
"dev:demo": "concurrently --names api,web --prefix-colors blue,green \"npm:dev:demo:api\" \"npm:dev:web\"",
"dev:demo:api": "npm run dev:demo -w @doc-manager/api",
"build": "npm run build --workspaces --if-present",
"typecheck": "npm run typecheck --workspaces --if-present",
"prisma:generate": "npm run prisma:generate -w @doc-manager/api",
"prisma:migrate": "npm run prisma:migrate -w @doc-manager/api"
},
"engines": {
"node": ">=20"
},
"devDependencies": {
"concurrently": "^9.2.1"
}
}
+23
View File
@@ -0,0 +1,23 @@
{
"name": "@doc-manager/shared",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./blocks": "./src/blocks/schema.ts",
"./tochka": "./src/tochka/dto.ts",
"./auth": "./src/auth/types.ts"
},
"scripts": {
"typecheck": "tsc --noEmit"
},
"dependencies": {
"zod": "^3.23.8"
},
"devDependencies": {
"typescript": "^5.6.3"
}
}
+30
View File
@@ -0,0 +1,30 @@
import { z } from 'zod';
// Контракт JWT-payload, выдаваемого auth.queo.ru.
// Источник: C:\project\Auth_server\backend\src\services\tokens.js
export const PermissionRole = z.enum(['viewer', 'user', 'admin']);
export type PermissionRole = z.infer<typeof PermissionRole>;
export const AuthPayload = z.object({
sub: z.string().uuid(),
email: z.string().email(),
groups: z.array(z.string()).default([]),
permissions: z.record(PermissionRole).default({}),
isSuperuser: z.boolean().default(false),
iat: z.number().optional(),
exp: z.number().optional(),
iss: z.literal('https://auth.queo.ru').optional(),
aud: z.union([z.literal('queo.ru'), z.array(z.string())]).optional(),
});
export type AuthPayload = z.infer<typeof AuthPayload>;
export const DOC_MANAGER_RESOURCE = 'doc_manager' as const;
// Иерархия ролей: admin ⊃ user ⊃ viewer
export function hasDocPermission(payload: AuthPayload, required: PermissionRole): boolean {
if (payload.isSuperuser) return true;
const role = payload.permissions[DOC_MANAGER_RESOURCE];
if (!role) return false;
const order: PermissionRole[] = ['viewer', 'user', 'admin'];
return order.indexOf(role) >= order.indexOf(required);
}
+115
View File
@@ -0,0 +1,115 @@
import { z } from 'zod';
// TipTap-совместимое JSON-дерево rich-текста.
// Не валидируем глубоко — TipTap отдаёт собственный формат, нам важно только сохранить и доставить.
export const RichTextSchema: z.ZodType<unknown> = z.object({
type: z.string(),
content: z.array(z.lazy((): z.ZodType<unknown> => RichTextSchema)).optional(),
text: z.string().optional(),
marks: z.array(z.object({ type: z.string(), attrs: z.record(z.unknown()).optional() })).optional(),
attrs: z.record(z.unknown()).optional(),
});
export type RichText = z.infer<typeof RichTextSchema>;
// VAT-ставки актуальные на 2026-04 (включая 5% и 7% для УСН-плательщиков).
// Имена совпадают с TS-клиентом Prisma (в БД хранится как '0','5',... через @map).
// При отправке в Tochka API используется отдельный маппер (см. packages/shared/src/tochka/dto.ts).
export const VatRate = z.enum(['none', 'vat_0', 'vat_5', 'vat_7', 'vat_10', 'vat_20']);
export type VatRate = z.infer<typeof VatRate>;
export const VAT_LABEL: Record<VatRate, string> = {
none: 'Без НДС',
vat_0: '0%',
vat_5: '5%',
vat_7: '7%',
vat_10: '10%',
vat_20: '20%',
};
export const VAT_PERCENT: Record<VatRate, number> = {
none: 0,
vat_0: 0,
vat_5: 5,
vat_7: 7,
vat_10: 10,
vat_20: 20,
};
const blockBase = z.object({ id: z.string().min(1) });
export const HeadingBlock = blockBase.extend({
type: z.literal('heading'),
level: z.union([z.literal(1), z.literal(2), z.literal(3)]),
text: RichTextSchema,
});
export const ParagraphBlock = blockBase.extend({
type: z.literal('paragraph'),
text: RichTextSchema,
});
export const PartyBind = z.discriminatedUnion('kind', [
z.object({ kind: z.literal('client'), clientId: z.string().uuid().optional() }),
z.object({ kind: z.literal('self') }),
]);
export const PartyBlock = blockBase.extend({
type: z.literal('party'),
role: z.enum(['executor', 'customer']),
bind: PartyBind,
});
export const ServicesTableBlock = blockBase.extend({
type: z.literal('services_table'),
columns: z.array(z.enum(['name', 'qty', 'unit', 'price', 'vat', 'sum'])).min(1),
// строки услуг живут в таблице document_lines; здесь только ссылки на строки этого документа
lines: z.array(z.object({ lineId: z.string().uuid() })),
});
export const TotalsBlock = blockBase.extend({
type: z.literal('totals'),
showVat: z.boolean(),
showInWords: z.boolean(),
});
export const TermsBlock = blockBase.extend({
type: z.literal('terms'),
text: RichTextSchema,
});
export const SignaturesBlock = blockBase.extend({
type: z.literal('signatures'),
sides: z.array(z.enum(['executor', 'customer'])).min(1),
});
export const CustomTextBlock = blockBase.extend({
type: z.literal('custom_text'),
text: RichTextSchema,
});
export const PageBreakBlock = blockBase.extend({
type: z.literal('page_break'),
});
export const Block = z.discriminatedUnion('type', [
HeadingBlock,
ParagraphBlock,
PartyBlock,
ServicesTableBlock,
TotalsBlock,
TermsBlock,
SignaturesBlock,
CustomTextBlock,
PageBreakBlock,
]);
export type Block = z.infer<typeof Block>;
export const DocBody = z.object({
version: z.literal(1),
blocks: z.array(Block),
vars: z.record(z.unknown()).default({}),
});
export type DocBody = z.infer<typeof DocBody>;
export const emptyDocBody = (): DocBody => ({ version: 1, blocks: [], vars: {} });
+3
View File
@@ -0,0 +1,3 @@
export * from './blocks/schema.js';
export * from './tochka/dto.js';
export * from './auth/types.js';
+74
View File
@@ -0,0 +1,74 @@
import { z } from 'zod';
export const TochkaEnv = z.enum(['sandbox', 'prod']);
export type TochkaEnv = z.infer<typeof TochkaEnv>;
export const TochkaBaseUrl: Record<TochkaEnv, string> = {
sandbox: 'https://enter.tochka.com/sandbox/v2',
prod: 'https://enter.tochka.com/uapi',
};
// Минимальный набор полей для M4. Расширим, когда возьмёмся за акты/УПД.
export const TochkaSecondSide = z.object({
inn: z.string().min(10).max(12),
kpp: z.string().length(9).optional(),
legalAddress: z.string().optional(),
contactName: z.string().optional(),
contactEmail: z.string().email().optional(),
contactPhone: z.string().optional(),
});
export type TochkaSecondSide = z.infer<typeof TochkaSecondSide>;
export const TochkaInvoiceLine = z.object({
name: z.string(),
measure: z.string(), // ед. измерения, напр. "шт", "час"
amount: z.number().positive(), // количество
price: z.number().nonnegative(), // цена за единицу в рублях (Точка ждёт число, не копейки)
vatRate: z.enum(['none', '0', '5', '7', '10', '20']),
});
export type TochkaInvoiceLine = z.infer<typeof TochkaInvoiceLine>;
export const TochkaCreateInvoiceRequest = z.object({
Data: z.object({
accountId: z.string(),
customerCode: z.string(),
documentNumber: z.string().optional(),
documentDate: z.string().optional(), // YYYY-MM-DD
secondSide: TochkaSecondSide,
items: z.array(TochkaInvoiceLine).min(1),
paymentExpiryDate: z.string().optional(),
}),
});
export type TochkaCreateInvoiceRequest = z.infer<typeof TochkaCreateInvoiceRequest>;
export const TochkaCreateInvoiceResponse = z.object({
Data: z.object({
documentId: z.string(),
}),
});
// Webhook payloads (минимум, что мы используем для статуса оплаты).
export const TochkaWebhookEvent = z.object({
webhookType: z.enum([
'incomingPayment',
'incomingSbpPayment',
'outgoingPayment',
'incomingSbpB2BPayment',
]),
paymentId: z.string(),
amount: z.number().optional(),
payerInn: z.string().optional(),
payerName: z.string().optional(),
purpose: z.string().optional(),
paidAt: z.string().optional(),
});
export type TochkaWebhookEvent = z.infer<typeof TochkaWebhookEvent>;
export const TochkaPaymentStatus = z.enum([
'awaiting_payment',
'partially_paid',
'paid',
'overdue',
'cancelled',
]);
export type TochkaPaymentStatus = z.infer<typeof TochkaPaymentStatus>;
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"declaration": true,
"noEmit": true
},
"include": ["src/**/*"]
}
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022"],
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"verbatimModuleSyntax": true
}
}