From 9807d47c8d150ac00da53bce41a21c4714d4e770 Mon Sep 17 00:00:00 2001 From: admin Date: Fri, 1 May 2026 08:29:44 +0300 Subject: [PATCH] feat(M3): contracts editor, templates, PDF render via Puppeteer/Chromium MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - numbers.ts — per-org per-doctype per-year sequential numbering (ДГ-2026/001, СЧ-2026/001…) - money.ts — line totals + Russian rubInWords helper - documents/routes.ts — CRUD with transactional lines bulk-replace, status changes, history endpoint for client-line autocomplete - templates/routes.ts — CRUD + instantiate (clones template body into new draft document) - shared/render/toHtml.ts — block→HTML renderer with placeholder substitution ({{customer.inn}}, {{contract.number}}, {{today}}…) - documents/pdf.ts — Puppeteer-based PDF rendering with auto-detected Chromium executable - documents/pdf.routes.ts — GET /:id/preview (HTML) and GET /:id/pdf - Dockerfile.api — added apk chromium + cyrillic fonts Web: - api.ts — Document, DocumentTemplate, Block, LineHistoryItem types - BlocksEditor — generic block list with reorder/add/remove and per-block forms (heading, party, services_table, totals, terms, signatures, custom_text, page_break) - LinesEditor — services rows with auto sumCents, "from catalog" picker, "from history by client" panel - ClientPicker — reusable client dropdown - pages: Documents list, DocumentEdit (new+existing), Templates list, TemplateEdit - richtext.ts — plain↔TipTap-JSON conversion (no TipTap yet, just keeps the format compatible) Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/api/package.json | 1 + apps/api/src/lib/money.ts | 113 +++ apps/api/src/lib/numbers.ts | 52 ++ apps/api/src/modules/documents/pdf.routes.ts | 69 ++ apps/api/src/modules/documents/pdf.ts | 182 +++++ apps/api/src/modules/documents/routes.ts | 288 ++++++++ apps/api/src/modules/templates/routes.ts | 168 +++++ apps/api/src/server.ts | 11 + apps/web/src/App.tsx | 12 +- apps/web/src/api.ts | 100 ++- apps/web/src/components/BlocksEditor.tsx | 295 ++++++++ apps/web/src/components/ClientPicker.tsx | 37 + apps/web/src/components/LinesEditor.tsx | 277 ++++++++ apps/web/src/lib/richtext.ts | 34 + apps/web/src/pages/DocumentEdit.tsx | 287 ++++++++ apps/web/src/pages/Documents.tsx | 141 ++++ apps/web/src/pages/TemplateEdit.tsx | 97 +++ apps/web/src/pages/Templates.tsx | 157 +++++ apps/web/src/styles.css | 75 ++ apps/web/tsconfig.tsbuildinfo | 2 +- docker/Dockerfile.api | 28 +- package-lock.json | 697 ++++++++++++++++++- packages/shared/src/index.ts | 1 + packages/shared/src/render/toHtml.ts | 344 +++++++++ 24 files changed, 3428 insertions(+), 40 deletions(-) create mode 100644 apps/api/src/lib/money.ts create mode 100644 apps/api/src/lib/numbers.ts create mode 100644 apps/api/src/modules/documents/pdf.routes.ts create mode 100644 apps/api/src/modules/documents/pdf.ts create mode 100644 apps/api/src/modules/documents/routes.ts create mode 100644 apps/api/src/modules/templates/routes.ts create mode 100644 apps/web/src/components/BlocksEditor.tsx create mode 100644 apps/web/src/components/ClientPicker.tsx create mode 100644 apps/web/src/components/LinesEditor.tsx create mode 100644 apps/web/src/lib/richtext.ts create mode 100644 apps/web/src/pages/DocumentEdit.tsx create mode 100644 apps/web/src/pages/Documents.tsx create mode 100644 apps/web/src/pages/TemplateEdit.tsx create mode 100644 apps/web/src/pages/Templates.tsx create mode 100644 packages/shared/src/render/toHtml.ts diff --git a/apps/api/package.json b/apps/api/package.json index d41b4bb..2dbeee8 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -24,6 +24,7 @@ "fastify": "^4.28.1", "fastify-plugin": "^4.5.1", "jose": "^5.9.6", + "puppeteer-core": "^24.42.0", "zod": "^3.23.8" }, "devDependencies": { diff --git a/apps/api/src/lib/money.ts b/apps/api/src/lib/money.ts new file mode 100644 index 0000000..90aa293 --- /dev/null +++ b/apps/api/src/lib/money.ts @@ -0,0 +1,113 @@ +import type { VatRate } from '@prisma/client'; + +const VAT_PERCENT: Record = { + none: 0, + vat_0: 0, + vat_5: 5, + vat_7: 7, + vat_10: 10, + vat_20: 20, +}; + +export type LineCalc = { + qtyMilli: bigint; + priceCents: bigint; + vat: VatRate; +}; + +/** + * Сумма по строке с НДС-логикой: + * - В РФ цена в счёте ВКЛЮЧАЕТ НДС. Сумма = price * qty / 1000. + * - НДС-часть выделяется из суммы: vatPart = sum * pct / (100 + pct). + * - Для VatRate=none — налога нет, vatPart=0. + */ +export function computeLine(line: LineCalc): { sumCents: bigint; vatCents: bigint } { + const sumCents = (line.priceCents * line.qtyMilli) / 1000n; + const pct = VAT_PERCENT[line.vat]; + if (pct === 0) { + return { sumCents, vatCents: 0n }; + } + // целочисленное округление до копейки: round(sum * pct / (100 + pct)) + const num = sumCents * BigInt(pct); + const den = BigInt(100 + pct); + const vatCents = (num + den / 2n) / den; + return { sumCents, vatCents }; +} + +export function totals(lines: LineCalc[]): { totalCents: bigint; vatCents: bigint } { + let total = 0n; + let vat = 0n; + for (const l of lines) { + const r = computeLine(l); + total += r.sumCents; + vat += r.vatCents; + } + return { totalCents: total, vatCents: vat }; +} + +export function formatRub(cents: bigint | number): string { + const c = typeof cents === 'bigint' ? Number(cents) : cents; + return (c / 100).toLocaleString('ru-RU', { + style: 'currency', + currency: 'RUB', + minimumFractionDigits: 2, + }); +} + +const ONES = ['', 'один', 'два', 'три', 'четыре', 'пять', 'шесть', 'семь', 'восемь', 'девять']; +const ONES_F = ['', 'одна', 'две', 'три', 'четыре', 'пять', 'шесть', 'семь', 'восемь', 'девять']; +const TEENS = [ + 'десять', 'одиннадцать', 'двенадцать', 'тринадцать', 'четырнадцать', + 'пятнадцать', 'шестнадцать', 'семнадцать', 'восемнадцать', 'девятнадцать', +]; +const TENS = ['', '', 'двадцать', 'тридцать', 'сорок', 'пятьдесят', 'шестьдесят', 'семьдесят', 'восемьдесят', 'девяносто']; +const HUNDREDS = ['', 'сто', 'двести', 'триста', 'четыреста', 'пятьсот', 'шестьсот', 'семьсот', 'восемьсот', 'девятьсот']; + +function group(n: number, feminine: boolean): string { + const ones = feminine ? ONES_F : ONES; + const parts: string[] = []; + const h = Math.floor(n / 100); + const t = Math.floor((n % 100) / 10); + const u = n % 10; + if (h > 0) parts.push(HUNDREDS[h]!); + if (t === 1) { + parts.push(TEENS[u]!); + } else { + if (t > 0) parts.push(TENS[t]!); + if (u > 0) parts.push(ones[u]!); + } + return parts.join(' '); +} + +function pluralRu(n: number, forms: [string, string, string]): string { + const mod10 = n % 10; + const mod100 = n % 100; + if (mod10 === 1 && mod100 !== 11) return forms[0]; + if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) return forms[1]; + return forms[2]; +} + +/** Сумма прописью на русском. cents — копейки. */ +export function rubInWords(cents: bigint | number): string { + const total = typeof cents === 'bigint' ? Number(cents) : cents; + const rub = Math.floor(Math.abs(total) / 100); + const kop = Math.abs(total) % 100; + + const billions = Math.floor(rub / 1_000_000_000); + const millions = Math.floor((rub % 1_000_000_000) / 1_000_000); + const thousands = Math.floor((rub % 1_000_000) / 1_000); + const ones = rub % 1_000; + + const parts: string[] = []; + if (billions > 0) parts.push(`${group(billions, false)} ${pluralRu(billions, ['миллиард', 'миллиарда', 'миллиардов'])}`); + if (millions > 0) parts.push(`${group(millions, false)} ${pluralRu(millions, ['миллион', 'миллиона', 'миллионов'])}`); + if (thousands > 0) parts.push(`${group(thousands, true)} ${pluralRu(thousands, ['тысяча', 'тысячи', 'тысяч'])}`); + if (ones > 0 || (billions === 0 && millions === 0 && thousands === 0)) { + parts.push(group(ones || 0, false)); + } + + const rubText = parts.join(' ').replace(/\s+/g, ' ').trim() || 'ноль'; + const kopText = String(kop).padStart(2, '0'); + const sign = total < 0 ? 'минус ' : ''; + return `${sign}${rubText} ${pluralRu(rub, ['рубль', 'рубля', 'рублей'])} ${kopText} ${pluralRu(kop, ['копейка', 'копейки', 'копеек'])}`; +} diff --git a/apps/api/src/lib/numbers.ts b/apps/api/src/lib/numbers.ts new file mode 100644 index 0000000..735b88f --- /dev/null +++ b/apps/api/src/lib/numbers.ts @@ -0,0 +1,52 @@ +import type { DocType } from '@prisma/client'; +import { prisma } from '../db.js'; + +const PREFIX: Record = { + contract: 'ДГ', + invoice: 'СЧ', + act: 'АКТ', + upd: 'УПД', +}; + +/** + * Подобрать следующий свободный номер для (organization, docType, year). + * Формат: «ДГ-2026/001». Возвращает строку — вставлять под `documents.number` нужно + * в той же транзакции с retry на P2002 (unique constraint), на случай concurrent insert. + * + * Решение без advisory locks выбрано осознанно: на текущем масштабе + * (single-tenant, ручной ввод документов) шанс race-conflict пренебрежимо мал. + */ +export async function nextDocumentNumber( + organizationId: string, + docType: DocType, + now: Date = new Date(), +): Promise { + const year = now.getFullYear(); + const prefix = PREFIX[docType]; + const filter = `${prefix}-${year}/`; + + const docs = await prisma.document.findMany({ + where: { organizationId, docType, number: { startsWith: filter } }, + select: { number: true }, + }); + + const re = new RegExp(`^${prefix}-${year}/(\\d+)$`); + let max = 0; + for (const d of docs) { + const m = re.exec(d.number); + if (m && m[1]) { + const n = parseInt(m[1], 10); + if (n > max) max = n; + } + } + return `${prefix}-${year}/${String(max + 1).padStart(3, '0')}`; +} + +export function isUniqueViolation(err: unknown): boolean { + return ( + typeof err === 'object' && + err !== null && + 'code' in err && + (err as { code?: string }).code === 'P2002' + ); +} diff --git a/apps/api/src/modules/documents/pdf.routes.ts b/apps/api/src/modules/documents/pdf.routes.ts new file mode 100644 index 0000000..818f0b1 --- /dev/null +++ b/apps/api/src/modules/documents/pdf.routes.ts @@ -0,0 +1,69 @@ +import type { FastifyInstance } from 'fastify'; +import { prisma } from '../../db.js'; +import { getOrganizationId } from '../../lib/org.js'; +import { renderDocumentToHtml, renderDocumentToPdf } from './pdf.js'; + +export async function documentsPdfRoutes(app: FastifyInstance) { + // Превью HTML — для отладки и интерактивного просмотра до выгрузки в PDF. + app.get( + '/api/documents/:id/preview', + { preHandler: app.requireDocPermission('viewer') }, + async (req, reply) => { + const orgId = getOrganizationId(req); + const { id } = req.params as { id: string }; + const doc = await prisma.document.findFirst({ + where: { id, organizationId: orgId }, + include: { client: true, lines: { orderBy: { position: 'asc' } } }, + }); + if (!doc) { + reply.code(404).send({ error: 'not_found' }); + return; + } + const org = await prisma.organization.findUnique({ where: { id: orgId } }); + if (!org) { + reply.code(404).send({ error: 'organization_not_found' }); + return; + } + const html = renderDocumentToHtml(doc, org); + reply.type('text/html; charset=utf-8').send(html); + }, + ); + + app.get( + '/api/documents/:id/pdf', + { preHandler: app.requireDocPermission('viewer') }, + async (req, reply) => { + const orgId = getOrganizationId(req); + const { id } = req.params as { id: string }; + const doc = await prisma.document.findFirst({ + where: { id, organizationId: orgId }, + include: { client: true, lines: { orderBy: { position: 'asc' } } }, + }); + if (!doc) { + reply.code(404).send({ error: 'not_found' }); + return; + } + const org = await prisma.organization.findUnique({ where: { id: orgId } }); + if (!org) { + reply.code(404).send({ error: 'organization_not_found' }); + return; + } + try { + const pdf = await renderDocumentToPdf(doc, org); + reply + .type('application/pdf') + .header('Content-Disposition', `inline; filename="${encodeURIComponent(doc.number)}.pdf"`) + .send(pdf); + } catch (e) { + if ((e as { code?: string }).code === 'NO_CHROMIUM') { + reply.code(503).send({ + error: 'no_chromium', + message: (e as Error).message, + }); + return; + } + throw e; + } + }, + ); +} diff --git a/apps/api/src/modules/documents/pdf.ts b/apps/api/src/modules/documents/pdf.ts new file mode 100644 index 0000000..d23de0b --- /dev/null +++ b/apps/api/src/modules/documents/pdf.ts @@ -0,0 +1,182 @@ +import { existsSync } from 'node:fs'; +import puppeteer, { type Browser } from 'puppeteer-core'; +import { + renderDocumentHtml, + type RenderContext, + type RenderClient, + type RenderOrganization, + type RenderLine, + type RenderDocument, + type DocBody, +} from '@doc-manager/shared'; +import { rubInWords } from '../../lib/money.js'; + +// Автодетект Chromium / Chrome для разных платформ. +function detectExecutable(): string | null { + if (process.env.PUPPETEER_EXECUTABLE_PATH && existsSync(process.env.PUPPETEER_EXECUTABLE_PATH)) { + return process.env.PUPPETEER_EXECUTABLE_PATH; + } + const candidates = + process.platform === 'win32' + ? [ + 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', + 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe', + 'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe', + ] + : process.platform === 'darwin' + ? [ + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/Applications/Chromium.app/Contents/MacOS/Chromium', + ] + : [ + '/usr/bin/chromium-browser', + '/usr/bin/chromium', + '/usr/bin/google-chrome', + '/usr/bin/google-chrome-stable', + ]; + for (const p of candidates) { + if (existsSync(p)) return p; + } + return null; +} + +let browserPromise: Promise | null = null; + +async function getBrowser(): Promise { + if (browserPromise) { + const b = await browserPromise; + if (b.connected) return b; + browserPromise = null; + } + const executablePath = detectExecutable(); + if (!executablePath) { + throw Object.assign( + new Error( + 'Chromium не найден. Установите PUPPETEER_EXECUTABLE_PATH в .env или поставьте Chrome/Chromium.', + ), + { code: 'NO_CHROMIUM' }, + ); + } + browserPromise = puppeteer.launch({ + executablePath, + args: ['--no-sandbox', '--disable-dev-shm-usage', '--disable-gpu'], + }); + return browserPromise; +} + +export async function shutdownBrowser(): Promise { + if (browserPromise) { + try { + const b = await browserPromise; + await b.close(); + } catch { + /* ignore */ + } + browserPromise = null; + } +} + +// ========== Адаптеры из Prisma в RenderContext ========== + +type PrismaDoc = { + number: string; + docType: 'contract' | 'invoice' | 'act' | 'upd'; + issuedAt: Date | null; + totalCents: bigint; + vatCents: bigint; + currency: string; + body: unknown; + client: { + id: string; + kind: 'ul' | 'ip' | 'fl'; + name: string; + inn: string | null; + kpp: string | null; + address: string | null; + email: string | null; + phone: string | null; + } | null; + lines: { + id: string; + position: number; + name: string; + qtyMilli: bigint; + unit: string; + priceCents: bigint; + vat: 'none' | 'vat_0' | 'vat_5' | 'vat_7' | 'vat_10' | 'vat_20'; + sumCents: bigint; + }[]; +}; + +type PrismaOrg = { + 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 function buildRenderContext(doc: PrismaDoc, organization: PrismaOrg): RenderContext { + const renderDoc: RenderDocument = { + number: doc.number, + docType: doc.docType, + issuedAt: doc.issuedAt, + totalCents: Number(doc.totalCents), + vatCents: Number(doc.vatCents), + currency: doc.currency, + }; + const renderOrg: RenderOrganization = { ...organization }; + const renderClient: RenderClient | null = doc.client ? { ...doc.client } : null; + const renderLines: RenderLine[] = doc.lines.map((l) => ({ + id: l.id, + position: l.position, + name: l.name, + qtyMilli: Number(l.qtyMilli), + unit: l.unit, + priceCents: Number(l.priceCents), + vat: l.vat, + sumCents: Number(l.sumCents), + })); + return { + doc: renderDoc, + organization: renderOrg, + client: renderClient, + lines: renderLines, + vars: {}, + }; +} + +export async function renderDocumentToPdf(doc: PrismaDoc, organization: PrismaOrg): Promise { + const ctx = buildRenderContext(doc, organization); + const html = renderDocumentHtml(doc.body as DocBody, ctx, { + title: `${doc.docType} ${doc.number}`, + rubInWords: (cents) => rubInWords(cents), + }); + + const browser = await getBrowser(); + const page = await browser.newPage(); + try { + await page.setContent(html, { waitUntil: 'networkidle0' }); + const pdf = await page.pdf({ + format: 'A4', + printBackground: true, + margin: { top: '18mm', right: '16mm', bottom: '18mm', left: '16mm' }, + }); + return Buffer.from(pdf); + } finally { + await page.close(); + } +} + +export function renderDocumentToHtml(doc: PrismaDoc, organization: PrismaOrg): string { + const ctx = buildRenderContext(doc, organization); + return renderDocumentHtml(doc.body as DocBody, ctx, { + title: `${doc.docType} ${doc.number}`, + rubInWords: (cents) => rubInWords(cents), + }); +} diff --git a/apps/api/src/modules/documents/routes.ts b/apps/api/src/modules/documents/routes.ts new file mode 100644 index 0000000..78ae4d2 --- /dev/null +++ b/apps/api/src/modules/documents/routes.ts @@ -0,0 +1,288 @@ +import type { FastifyInstance } from 'fastify'; +import { Prisma, type DocType, type DocStatus, type VatRate } from '@prisma/client'; +import { z } from 'zod'; +import { DocBody } from '@doc-manager/shared'; +import { prisma } from '../../db.js'; +import { getOrganizationId } from '../../lib/org.js'; +import { isUniqueViolation, nextDocumentNumber } from '../../lib/numbers.js'; +import { totals } from '../../lib/money.js'; + +const VAT_VALUES = ['none', 'vat_0', 'vat_5', 'vat_7', 'vat_10', 'vat_20'] as const; +const DOC_TYPES = ['contract', 'invoice', 'act', 'upd'] as const; +const DOC_STATUSES = ['draft', 'issued', 'sent', 'partially_paid', 'paid', 'cancelled', 'signed'] as const; + +const LineInput = z.object({ + position: z.coerce.number().int().nonnegative(), + serviceId: z.string().uuid().nullable(), + lineId: z.string().uuid().optional(), // если есть — обновляем существующую строку + name: z.string().min(1).max(500), + qtyMilli: z.coerce.number().int().positive(), + unit: z.string().min(1).max(50), + priceCents: z.coerce.number().int().nonnegative(), + vat: z.enum(VAT_VALUES), +}); + +const DocumentCreate = z.object({ + docType: z.enum(DOC_TYPES), + clientId: z.string().uuid().nullable(), + parentDocumentId: z.string().uuid().nullable(), + body: DocBody, + lines: z.array(LineInput).default([]), + number: z.string().min(1).max(100).nullable(), // если null — генерим автоматически + issuedAt: z.string().datetime().nullable(), + currency: z.string().length(3).default('RUB'), +}); + +const DocumentUpdate = z.object({ + clientId: z.string().uuid().nullable(), + body: DocBody, + lines: z.array(LineInput).default([]), + number: z.string().min(1).max(100), + issuedAt: z.string().datetime().nullable(), +}); + +const StatusChange = z.object({ + status: z.enum(DOC_STATUSES), +}); + +const ListQuery = z.object({ + docType: z.enum(DOC_TYPES).optional(), + clientId: z.string().uuid().optional(), + status: z.enum(DOC_STATUSES).optional(), + q: z.string().optional(), + limit: z.coerce.number().int().min(1).max(500).default(100), +}); + +const HistoryQuery = z.object({ + clientId: z.string().uuid(), + limit: z.coerce.number().int().min(1).max(200).default(50), +}); + +function lineCalc(l: { qtyMilli: number; priceCents: number; vat: VatRate }) { + return { qtyMilli: BigInt(l.qtyMilli), priceCents: BigInt(l.priceCents), vat: l.vat }; +} + +export async function documentsRoutes(app: FastifyInstance) { + // -------- LIST -------- + app.get('/api/documents', { 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 { docType, clientId, status, q, limit } = parsed.data; + const docs = await prisma.document.findMany({ + where: { + organizationId: orgId, + ...(docType ? { docType } : {}), + ...(clientId ? { clientId } : {}), + ...(status ? { status } : {}), + ...(q ? { OR: [{ number: { contains: q, mode: 'insensitive' } }, { client: { name: { contains: q, mode: 'insensitive' } } }] } : {}), + }, + include: { client: { select: { id: true, name: true, kind: true } } }, + orderBy: [{ issuedAt: { sort: 'desc', nulls: 'last' } }, { createdAt: 'desc' }], + take: limit, + }); + return { items: docs }; + }); + + // -------- HISTORY (line autocomplete по клиенту) -------- + app.get('/api/documents/_history', { preHandler: app.requireDocPermission('viewer') }, async (req, reply) => { + const orgId = getOrganizationId(req); + const parsed = HistoryQuery.safeParse(req.query); + if (!parsed.success) { + reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() }); + return; + } + const { clientId, limit } = parsed.data; + // Сводка использованных услуг по клиенту: имя/цена/НДС, последний раз и сколько использовалась. + const rows = await prisma.$queryRaw< + { service_id: string | null; name: string; unit: string; price_cents: bigint; vat: VatRate; last_used: Date; use_count: bigint }[] + >` + select dl.service_id, dl.name, dl.unit, dl.price_cents, dl.vat, + max(d.created_at) as last_used, + count(*) as use_count + from "DocumentLine" dl + join "Document" d on d.id = dl.document_id + where d.organization_id = ${orgId}::uuid and d.client_id = ${clientId}::uuid + group by dl.service_id, dl.name, dl.unit, dl.price_cents, dl.vat + order by max(d.created_at) desc + limit ${limit} + `; + return { + items: rows.map((r) => ({ + serviceId: r.service_id, + name: r.name, + unit: r.unit, + priceCents: Number(r.price_cents), + vat: r.vat, + lastUsed: r.last_used, + useCount: Number(r.use_count), + })), + }; + }); + + // -------- GET -------- + app.get('/api/documents/:id', { preHandler: app.requireDocPermission('viewer') }, async (req, reply) => { + const orgId = getOrganizationId(req); + const { id } = req.params as { id: string }; + const doc = await prisma.document.findFirst({ + where: { id, organizationId: orgId }, + include: { + client: true, + lines: { orderBy: { position: 'asc' } }, + }, + }); + if (!doc) { + reply.code(404).send({ error: 'not_found' }); + return; + } + return doc; + }); + + // -------- CREATE -------- + app.post('/api/documents', { preHandler: app.requireDocPermission('user') }, async (req, reply) => { + const orgId = getOrganizationId(req); + const parsed = DocumentCreate.safeParse(req.body); + if (!parsed.success) { + reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() }); + return; + } + const { docType, clientId, parentDocumentId, body, lines, number, issuedAt, currency } = parsed.data; + const calcLines = lines.map(lineCalc); + const { totalCents, vatCents } = totals(calcLines); + const sub = (req.user!.sub as string) || null; + + // Retry на P2002 при auto-номере (на случай гонки concurrent insert). + for (let attempt = 0; attempt < 3; attempt++) { + const num = number ?? (await nextDocumentNumber(orgId, docType)); + try { + const doc = await prisma.document.create({ + data: { + organizationId: orgId, + docType, + number: num, + issuedAt: issuedAt ? new Date(issuedAt) : null, + clientId, + parentDocumentId, + body: body as Prisma.InputJsonValue, + totalCents, + vatCents, + currency, + createdBy: sub, + lines: { + create: lines.map((l) => ({ + position: l.position, + serviceId: l.serviceId, + name: l.name, + qtyMilli: BigInt(l.qtyMilli), + unit: l.unit, + priceCents: BigInt(l.priceCents), + vat: l.vat, + sumCents: (BigInt(l.priceCents) * BigInt(l.qtyMilli)) / 1000n, + })), + }, + }, + include: { lines: { orderBy: { position: 'asc' } }, client: true }, + }); + reply.code(201).send(doc); + return; + } catch (e) { + if (number === null && isUniqueViolation(e) && attempt < 2) continue; + throw e; + } + } + }); + + // -------- UPDATE -------- + app.put('/api/documents/:id', { preHandler: app.requireDocPermission('user') }, async (req, reply) => { + const orgId = getOrganizationId(req); + const { id } = req.params as { id: string }; + const parsed = DocumentUpdate.safeParse(req.body); + if (!parsed.success) { + reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() }); + return; + } + const existing = await prisma.document.findFirst({ where: { id, organizationId: orgId } }); + if (!existing) { + reply.code(404).send({ error: 'not_found' }); + return; + } + if (existing.tochkaDocumentId) { + reply.code(409).send({ error: 'locked_by_bank', message: 'Документ выставлен через банк, редактировать нельзя.' }); + return; + } + const { clientId, body, lines, number, issuedAt } = parsed.data; + const { totalCents, vatCents } = totals(lines.map(lineCalc)); + + const updated = await prisma.$transaction(async (tx) => { + // Bulk-replace строк (пересоздаём — проще чем merge, т.к. упорядочивание по position) + await tx.documentLine.deleteMany({ where: { documentId: id } }); + await tx.document.update({ + where: { id }, + data: { + number, + clientId, + issuedAt: issuedAt ? new Date(issuedAt) : null, + body: body as Prisma.InputJsonValue, + totalCents, + vatCents, + lines: { + create: lines.map((l) => ({ + position: l.position, + serviceId: l.serviceId, + name: l.name, + qtyMilli: BigInt(l.qtyMilli), + unit: l.unit, + priceCents: BigInt(l.priceCents), + vat: l.vat, + sumCents: (BigInt(l.priceCents) * BigInt(l.qtyMilli)) / 1000n, + })), + }, + }, + }); + return tx.document.findUnique({ + where: { id }, + include: { lines: { orderBy: { position: 'asc' } }, client: true }, + }); + }); + return updated; + }); + + // -------- STATUS CHANGE -------- + app.post('/api/documents/:id/status', { preHandler: app.requireDocPermission('user') }, async (req, reply) => { + const orgId = getOrganizationId(req); + const { id } = req.params as { id: string }; + const parsed = StatusChange.safeParse(req.body); + if (!parsed.success) { + reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() }); + return; + } + const existing = await prisma.document.findFirst({ where: { id, organizationId: orgId } }); + if (!existing) { + reply.code(404).send({ error: 'not_found' }); + return; + } + const next: DocStatus = parsed.data.status; + const updated = await prisma.document.update({ where: { id }, data: { status: next } }); + return updated; + }); + + // -------- DELETE (только drafts) -------- + app.delete('/api/documents/:id', { preHandler: app.requireDocPermission('admin') }, async (req, reply) => { + const orgId = getOrganizationId(req); + const { id } = req.params as { id: string }; + const existing = await prisma.document.findFirst({ where: { id, organizationId: orgId } }); + if (!existing) { + reply.code(404).send({ error: 'not_found' }); + return; + } + if (existing.status !== 'draft') { + reply.code(409).send({ error: 'not_draft', message: 'Удалять можно только черновики.' }); + return; + } + await prisma.document.delete({ where: { id } }); + reply.code(204).send(); + }); +} diff --git a/apps/api/src/modules/templates/routes.ts b/apps/api/src/modules/templates/routes.ts new file mode 100644 index 0000000..30518fe --- /dev/null +++ b/apps/api/src/modules/templates/routes.ts @@ -0,0 +1,168 @@ +import type { FastifyInstance } from 'fastify'; +import { Prisma, type DocType } from '@prisma/client'; +import { z } from 'zod'; +import { DocBody } from '@doc-manager/shared'; +import { prisma } from '../../db.js'; +import { getOrganizationId } from '../../lib/org.js'; +import { nextDocumentNumber, isUniqueViolation } from '../../lib/numbers.js'; +import { totals } from '../../lib/money.js'; + +const DOC_TYPES = ['contract', 'invoice', 'act', 'upd'] as const; +const VAT_VALUES = ['none', 'vat_0', 'vat_5', 'vat_7', 'vat_10', 'vat_20'] as const; + +const TemplateUpsert = z.object({ + docType: z.enum(DOC_TYPES), + name: z.string().min(1).max(500), + body: DocBody, +}); + +const InstantiateInput = z.object({ + clientId: z.string().uuid().nullable(), + // Линии у шаблона мы не храним; но на инстанс пользователь может передать стартовый набор линий. + initialLines: z + .array( + z.object({ + position: z.number().int().nonnegative(), + serviceId: z.string().uuid().nullable(), + name: z.string().min(1).max(500), + qtyMilli: z.number().int().positive(), + unit: z.string().min(1).max(50), + priceCents: z.number().int().nonnegative(), + vat: z.enum(VAT_VALUES), + }), + ) + .default([]), +}); + +export async function templatesRoutes(app: FastifyInstance) { + app.get('/api/templates', { preHandler: app.requireDocPermission('viewer') }, async (req) => { + const orgId = getOrganizationId(req); + const docType = (req.query as { docType?: DocType }).docType; + const items = await prisma.documentTemplate.findMany({ + where: { + organizationId: orgId, + ...(docType ? { docType } : {}), + }, + orderBy: { updatedAt: 'desc' }, + }); + return { items }; + }); + + app.get('/api/templates/:id', { preHandler: app.requireDocPermission('viewer') }, async (req, reply) => { + const orgId = getOrganizationId(req); + const { id } = req.params as { id: string }; + const tpl = await prisma.documentTemplate.findFirst({ where: { id, organizationId: orgId } }); + if (!tpl) { + reply.code(404).send({ error: 'not_found' }); + return; + } + return tpl; + }); + + app.post('/api/templates', { preHandler: app.requireDocPermission('user') }, async (req, reply) => { + const orgId = getOrganizationId(req); + const parsed = TemplateUpsert.safeParse(req.body); + if (!parsed.success) { + reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() }); + return; + } + const created = await prisma.documentTemplate.create({ + data: { + organizationId: orgId, + docType: parsed.data.docType, + name: parsed.data.name, + body: parsed.data.body as Prisma.InputJsonValue, + }, + }); + reply.code(201).send(created); + }); + + app.put('/api/templates/:id', { preHandler: app.requireDocPermission('user') }, async (req, reply) => { + const orgId = getOrganizationId(req); + const { id } = req.params as { id: string }; + const parsed = TemplateUpsert.safeParse(req.body); + if (!parsed.success) { + reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() }); + return; + } + const existing = await prisma.documentTemplate.findFirst({ where: { id, organizationId: orgId } }); + if (!existing) { + reply.code(404).send({ error: 'not_found' }); + return; + } + return prisma.documentTemplate.update({ + where: { id }, + data: { + docType: parsed.data.docType, + name: parsed.data.name, + body: parsed.data.body as Prisma.InputJsonValue, + }, + }); + }); + + app.delete('/api/templates/:id', { preHandler: app.requireDocPermission('user') }, async (req, reply) => { + const orgId = getOrganizationId(req); + const { id } = req.params as { id: string }; + const existing = await prisma.documentTemplate.findFirst({ where: { id, organizationId: orgId } }); + if (!existing) { + reply.code(404).send({ error: 'not_found' }); + return; + } + await prisma.documentTemplate.delete({ where: { id } }); + reply.code(204).send(); + }); + + // Создать новый документ-черновик из шаблона. + app.post('/api/templates/:id/instantiate', { preHandler: app.requireDocPermission('user') }, async (req, reply) => { + const orgId = getOrganizationId(req); + const { id } = req.params as { id: string }; + const parsed = InstantiateInput.safeParse(req.body); + if (!parsed.success) { + reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() }); + return; + } + const tpl = await prisma.documentTemplate.findFirst({ where: { id, organizationId: orgId } }); + if (!tpl) { + reply.code(404).send({ error: 'not_found' }); + return; + } + const lines = parsed.data.initialLines; + const calc = totals(lines.map((l) => ({ qtyMilli: BigInt(l.qtyMilli), priceCents: BigInt(l.priceCents), vat: l.vat }))); + const sub = (req.user!.sub as string) || null; + for (let attempt = 0; attempt < 3; attempt++) { + const number = await nextDocumentNumber(orgId, tpl.docType); + try { + const doc = await prisma.document.create({ + data: { + organizationId: orgId, + docType: tpl.docType, + number, + clientId: parsed.data.clientId, + body: tpl.body as Prisma.InputJsonValue, + totalCents: calc.totalCents, + vatCents: calc.vatCents, + createdBy: sub, + lines: { + create: lines.map((l) => ({ + position: l.position, + serviceId: l.serviceId, + name: l.name, + qtyMilli: BigInt(l.qtyMilli), + unit: l.unit, + priceCents: BigInt(l.priceCents), + vat: l.vat, + sumCents: (BigInt(l.priceCents) * BigInt(l.qtyMilli)) / 1000n, + })), + }, + }, + include: { lines: { orderBy: { position: 'asc' } }, client: true }, + }); + reply.code(201).send(doc); + return; + } catch (e) { + if (isUniqueViolation(e) && attempt < 2) continue; + throw e; + } + } + }); +} diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 85bff5e..1e968eb 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -10,6 +10,10 @@ 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'; +import { documentsRoutes } from './modules/documents/routes.js'; +import { documentsPdfRoutes } from './modules/documents/pdf.routes.js'; +import { templatesRoutes } from './modules/templates/routes.js'; +import { shutdownBrowser } from './modules/documents/pdf.js'; async function main() { const loggerOptions = @@ -38,6 +42,13 @@ async function main() { await app.register(organizationsRoutes); await app.register(clientsRoutes); await app.register(servicesRoutes); + await app.register(documentsRoutes); + await app.register(documentsPdfRoutes); + await app.register(templatesRoutes); + + app.addHook('onClose', async () => { + await shutdownBrowser(); + }); app.setErrorHandler((err, _req, reply) => { app.log.error({ err }, 'unhandled error'); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index fd0ccf2..c084ca2 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -4,6 +4,10 @@ import { redirectToLogin, useAuth } from './auth.js'; import { ClientsPage } from './pages/Clients.js'; import { ServicesPage } from './pages/Services.js'; import { OrganizationPage } from './pages/Organization.js'; +import { DocumentsPage } from './pages/Documents.js'; +import { DocumentEditPage } from './pages/DocumentEdit.js'; +import { TemplatesPage } from './pages/Templates.js'; +import { TemplateEditPage } from './pages/TemplateEdit.js'; function Layout({ email }: { email: string }) { return ( @@ -59,10 +63,14 @@ export function App() { <> - } /> + } /> + } /> + } /> + } /> } /> } /> - } /> + } /> + } /> } /> } /> } /> diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index d80b845..978cfec 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -68,7 +68,105 @@ export type Service = { 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'; + defaultVat: VatRate; notes: string | null; archivedAt: string | null; }; + +export type VatRate = 'none' | 'vat_0' | 'vat_5' | 'vat_7' | 'vat_10' | 'vat_20'; +export type DocType = 'contract' | 'invoice' | 'act' | 'upd'; +export type DocStatus = 'draft' | 'issued' | 'sent' | 'partially_paid' | 'paid' | 'cancelled' | 'signed'; + +export type DocumentLine = { + id: string; + documentId: string; + position: number; + serviceId: string | null; + name: string; + qtyMilli: number; + unit: string; + priceCents: number; + vat: VatRate; + sumCents: number; +}; + +export type DocumentSummary = { + id: string; + organizationId: string; + docType: DocType; + number: string; + issuedAt: string | null; + status: DocStatus; + clientId: string | null; + client: { id: string; name: string; kind: Client['kind'] } | null; + parentDocumentId: string | null; + totalCents: number; + vatCents: number; + currency: string; + tochkaDocumentId: string | null; + pdfPath: string | null; + createdAt: string; + updatedAt: string; +}; + +export type Document = DocumentSummary & { + body: DocBody; + client: Client | null; + lines: DocumentLine[]; +}; + +export type DocumentTemplate = { + id: string; + organizationId: string; + docType: DocType; + name: string; + body: DocBody; + createdAt: string; + updatedAt: string; +}; + +// === Block schema (mirrors packages/shared) === + +export type RichText = { + type: string; + content?: RichText[]; + text?: string; + marks?: { type: string }[]; +}; + +export type Block = + | { id: string; type: 'heading'; level: 1 | 2 | 3; text: RichText } + | { id: string; type: 'paragraph'; text: RichText } + | { + id: string; + type: 'party'; + role: 'executor' | 'customer'; + bind: { kind: 'self' } | { kind: 'client'; clientId?: string }; + } + | { + id: string; + type: 'services_table'; + columns: Array<'name' | 'qty' | 'unit' | 'price' | 'vat' | 'sum'>; + lines: { lineId: string }[]; + } + | { id: string; type: 'totals'; showVat: boolean; showInWords: boolean } + | { id: string; type: 'terms'; text: RichText } + | { id: string; type: 'signatures'; sides: ('executor' | 'customer')[] } + | { id: string; type: 'custom_text'; text: RichText } + | { id: string; type: 'page_break' }; + +export type DocBody = { + version: 1; + blocks: Block[]; + vars: Record; +}; + +export type LineHistoryItem = { + serviceId: string | null; + name: string; + unit: string; + priceCents: number; + vat: VatRate; + lastUsed: string; + useCount: number; +}; diff --git a/apps/web/src/components/BlocksEditor.tsx b/apps/web/src/components/BlocksEditor.tsx new file mode 100644 index 0000000..137b960 --- /dev/null +++ b/apps/web/src/components/BlocksEditor.tsx @@ -0,0 +1,295 @@ +import { useState } from 'react'; +import type { Block } from '../api.js'; +import { Button, Select } from './ui.js'; +import { plainToRich, richToPlain, emptyRich } from '../lib/richtext.js'; + +const BLOCK_LABELS: Record = { + heading: 'Заголовок', + paragraph: 'Параграф', + party: 'Реквизиты стороны', + services_table: 'Таблица услуг', + totals: 'Итоги', + terms: 'Условия', + signatures: 'Подписи', + custom_text: 'Свободный текст', + page_break: 'Разрыв страницы', +}; + +function uid(): string { + return Math.random().toString(36).slice(2, 11); +} + +function defaultBlock(type: Block['type']): Block { + switch (type) { + case 'heading': + return { id: uid(), type: 'heading', level: 1, text: emptyRich() }; + case 'paragraph': + return { id: uid(), type: 'paragraph', text: emptyRich() }; + case 'party': + return { id: uid(), type: 'party', role: 'executor', bind: { kind: 'self' } }; + case 'services_table': + return { + id: uid(), + type: 'services_table', + columns: ['name', 'qty', 'unit', 'price', 'vat', 'sum'], + lines: [], + }; + case 'totals': + return { id: uid(), type: 'totals', showVat: true, showInWords: true }; + case 'terms': + return { id: uid(), type: 'terms', text: emptyRich() }; + case 'signatures': + return { id: uid(), type: 'signatures', sides: ['executor', 'customer'] }; + case 'custom_text': + return { id: uid(), type: 'custom_text', text: emptyRich() }; + case 'page_break': + return { id: uid(), type: 'page_break' }; + } +} + +export function BlocksEditor({ + blocks, + onChange, +}: { + blocks: Block[]; + onChange: (next: Block[]) => void; +}) { + function add(type: Block['type'], idx: number) { + const next = [...blocks]; + next.splice(idx, 0, defaultBlock(type)); + onChange(next); + } + function remove(idx: number) { + onChange(blocks.filter((_, i) => i !== idx)); + } + function move(idx: number, dir: -1 | 1) { + const j = idx + dir; + if (j < 0 || j >= blocks.length) return; + const next = [...blocks]; + [next[idx], next[j]] = [next[j]!, next[idx]!]; + onChange(next); + } + function update(idx: number, patch: Partial) { + onChange(blocks.map((b, i) => (i === idx ? ({ ...b, ...patch } as Block) : b))); + } + + return ( +
+ {blocks.length === 0 ? ( + add(t, 0)} /> + ) : ( + <> + add(t, 0)} /> + {blocks.map((b, idx) => ( +
+
+ {BLOCK_LABELS[b.type]} +
+ + + +
+
+ update(idx, patch)} /> + add(t, idx + 1)} /> +
+ ))} + + )} +
+ ); +} + +function AddBlock({ onAdd }: { onAdd: (type: Block['type']) => void }) { + const [open, setOpen] = useState(false); + const types: Block['type'][] = [ + 'heading', 'paragraph', 'party', 'services_table', + 'totals', 'terms', 'signatures', 'custom_text', 'page_break', + ]; + if (!open) { + return ( + + ); + } + return ( +
+ {types.map((t) => ( + + ))} + +
+ ); +} + +function BlockForm({ block, onChange }: { block: Block; onChange: (patch: Partial) => void }) { + switch (block.type) { + case 'heading': + return ( +
+ onChange({ text: plainToRich(e.target.value) } as Partial)} + /> + +
+ ); + case 'paragraph': + case 'terms': + case 'custom_text': + return ( +