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
+1
View File
@@ -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": {
+113
View File
@@ -0,0 +1,113 @@
import type { VatRate } from '@prisma/client';
const VAT_PERCENT: Record<VatRate, number> = {
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, ['копейка', 'копейки', 'копеек'])}`;
}
+52
View File
@@ -0,0 +1,52 @@
import type { DocType } from '@prisma/client';
import { prisma } from '../db.js';
const PREFIX: Record<DocType, string> = {
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<string> {
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'
);
}
@@ -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;
}
}
});
}
+11
View File
@@ -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');