feat(M3): contracts editor, templates, PDF render via Puppeteer/Chromium

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) <noreply@anthropic.com>
This commit is contained in:
admin
2026-05-01 08:29:44 +03:00
parent 0722a25845
commit 9807d47c8d
24 changed files with 3428 additions and 40 deletions
@@ -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;
}
},
);
}
+182
View File
@@ -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<Browser> | null = null;
async function getBrowser(): Promise<Browser> {
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<void> {
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<Buffer> {
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),
});
}
+288
View File
@@ -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();
});
}
+168
View File
@@ -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;
}
}
});
}