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
+344
View File
@@ -0,0 +1,344 @@
import type { Block, DocBody, RichText, VatRate } from '../blocks/schema.js';
import { VAT_LABEL, VAT_PERCENT } from '../blocks/schema.js';
// ========== Domain types для рендера ==========
// shared не зависит от Prisma — нам нужны простые DTO.
export type RenderOrganization = {
name: string;
inn: string;
kpp: string | null;
ogrn: string | null;
legalAddress: string | null;
bankName: string | null;
bankBik: string | null;
bankAccount: string | null;
signatoryName: string | null;
signatoryPosition: string | null;
};
export type RenderClient = {
id: string;
kind: 'ul' | 'ip' | 'fl';
name: string;
inn: string | null;
kpp: string | null;
address: string | null;
email: string | null;
phone: string | null;
};
export type RenderLine = {
id: string;
position: number;
name: string;
qtyMilli: number;
unit: string;
priceCents: number;
vat: VatRate;
sumCents: number;
};
export type RenderDocument = {
number: string;
docType: 'contract' | 'invoice' | 'act' | 'upd';
issuedAt: Date | string | null;
totalCents: number;
vatCents: number;
currency: string;
};
export type RenderContext = {
doc: RenderDocument;
organization: RenderOrganization;
client: RenderClient | null;
lines: RenderLine[];
vars: Record<string, unknown>;
};
// ========== HTML escaping ==========
const ESC: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
};
function esc(s: string): string {
return s.replace(/[&<>"']/g, (c) => ESC[c]!);
}
// ========== Placeholder resolution ==========
function getByPath(obj: unknown, path: string): unknown {
return path.split('.').reduce<unknown>((acc, key) => {
if (acc && typeof acc === 'object' && key in (acc as Record<string, unknown>)) {
return (acc as Record<string, unknown>)[key];
}
return undefined;
}, obj);
}
const PLACEHOLDER_RE = /\{\{\s*([a-zA-Z0-9_.]+)\s*\}\}/g;
function fillPlaceholders(text: string, ctx: RenderContext): string {
const fullCtx: Record<string, unknown> = {
today: new Date().toLocaleDateString('ru-RU'),
contract: {
number: ctx.doc.number,
date: ctx.doc.issuedAt ? new Date(ctx.doc.issuedAt).toLocaleDateString('ru-RU') : '',
},
customer: ctx.client ?? {},
executor: ctx.organization,
self: ctx.organization,
...ctx.vars,
};
return text.replace(PLACEHOLDER_RE, (_m, path: string) => {
const v = getByPath(fullCtx, path);
return v === undefined || v === null ? '' : esc(String(v));
});
}
// ========== RichText rendering ==========
// На M3 в редакторе тексты вводятся как plain string и оборачиваются в простой
// TipTap-совместимый JSON: { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: '...' }] }] }
// Здесь поддерживаем минимум: рекурсивный обход, marks=bold/italic/underline, line breaks.
function renderRichText(node: RichText | undefined, ctx: RenderContext): string {
if (!node) return '';
return renderNode(node, ctx);
}
function renderNode(n: unknown, ctx: RenderContext): string {
if (!n || typeof n !== 'object') return '';
const node = n as { type?: string; content?: unknown[]; text?: string; marks?: { type: string }[] };
if (node.type === 'text' && typeof node.text === 'string') {
let out = esc(fillPlaceholders(node.text, ctx));
for (const m of node.marks ?? []) {
if (m.type === 'bold') out = `<strong>${out}</strong>`;
else if (m.type === 'italic') out = `<em>${out}</em>`;
else if (m.type === 'underline') out = `<u>${out}</u>`;
}
return out;
}
if (node.type === 'hardBreak') return '<br/>';
if (node.type === 'paragraph') return `<p>${(node.content ?? []).map((c) => renderNode(c, ctx)).join('')}</p>`;
// doc / прочие контейнеры — просто содержимое.
return (node.content ?? []).map((c) => renderNode(c, ctx)).join('');
}
// ========== Block rendering ==========
function renderHeading(level: 1 | 2 | 3, text: RichText, ctx: RenderContext): string {
return `<h${level} class="b-heading">${renderRichText(text, ctx) || ''}</h${level}>`;
}
function renderParty(role: 'executor' | 'customer', clientLike: { kind?: string } | RenderOrganization, ctx: RenderContext): string {
const isOrg = !('kind' in clientLike);
const o = clientLike as RenderOrganization;
const c = clientLike as RenderClient;
const title = role === 'executor' ? 'Исполнитель' : 'Заказчик';
const fields: [string, string | null | undefined][] = isOrg
? [
['', o.name],
['ИНН', o.inn],
['КПП', o.kpp],
['ОГРН', o.ogrn],
['Адрес', o.legalAddress],
['Банк', o.bankName],
['БИК', o.bankBik],
['Р/с', o.bankAccount],
]
: [
['', c.name],
['ИНН', c.inn],
['КПП', c.kpp],
['Адрес', c.address],
['Email', c.email],
['Телефон', c.phone],
];
const rows = fields
.filter((f) => f[1])
.map(([label, value]) =>
label
? `<tr><td class="party-k">${esc(label)}</td><td class="party-v">${esc(value!)}</td></tr>`
: `<tr><td class="party-k"></td><td class="party-v"><b>${esc(value!)}</b></td></tr>`,
)
.join('');
return `
<div class="b-party">
<div class="party-title">${title}:</div>
<table class="party-table">${rows}</table>
</div>`;
}
function renderServicesTable(blockLines: { lineId: string }[], ctx: RenderContext, columns: string[]): string {
const wantedIds = new Set(blockLines.map((bl) => bl.lineId));
const rows = ctx.lines
.filter((l) => wantedIds.has(l.id) || blockLines.length === 0)
.sort((a, b) => a.position - b.position);
const colDefs: Record<string, { th: string; th_class?: string; render: (l: RenderLine, idx: number) => string }> = {
name: { th: 'Наименование', render: (l) => esc(l.name) },
qty: { th: 'Кол-во', th_class: 'num', render: (l) => formatQty(l.qtyMilli) },
unit: { th: 'Ед.', render: (l) => esc(l.unit) },
price: { th: 'Цена', th_class: 'num', render: (l) => formatRub(l.priceCents) },
vat: { th: 'НДС', render: (l) => VAT_LABEL[l.vat] },
sum: { th: 'Сумма', th_class: 'num', render: (l) => formatRub(l.sumCents) },
};
const cols = columns.length ? columns : ['name', 'qty', 'unit', 'price', 'vat', 'sum'];
const head = `<tr>${cols.map((c) => `<th class="${colDefs[c]?.th_class ?? ''}">${esc(colDefs[c]?.th ?? c)}</th>`).join('')}</tr>`;
const body = rows
.map((l, idx) =>
`<tr>${cols
.map((c) => `<td class="${colDefs[c]?.th_class ?? ''}">${colDefs[c]?.render(l, idx) ?? ''}</td>`)
.join('')}</tr>`,
)
.join('');
return `<table class="b-services">${head}${body}</table>`;
}
function renderTotals(showVat: boolean, showInWords: boolean, ctx: RenderContext, rubInWordsFn?: (cents: number) => string): string {
const total = ctx.doc.totalCents;
const vat = ctx.doc.vatCents;
const rows: string[] = [];
rows.push(`<tr><td class="totals-k">Итого:</td><td class="totals-v"><b>${formatRub(total)}</b></td></tr>`);
if (showVat) {
rows.push(
`<tr><td class="totals-k">в т.ч. НДС:</td><td class="totals-v">${vat > 0 ? formatRub(vat) : 'Без НДС'}</td></tr>`,
);
}
if (showInWords && rubInWordsFn) {
rows.push(`<tr><td class="totals-k">Прописью:</td><td class="totals-v">${esc(rubInWordsFn(total))}</td></tr>`);
}
return `<table class="b-totals">${rows.join('')}</table>`;
}
function renderSignatures(sides: ('executor' | 'customer')[], ctx: RenderContext): string {
const cells = sides.map((side) => {
const title = side === 'executor' ? 'Исполнитель' : 'Заказчик';
let line2 = '';
if (side === 'executor') {
const pos = ctx.organization.signatoryPosition ?? '';
const fio = ctx.organization.signatoryName ?? '';
line2 = `${esc(pos)} ${esc(fio)}`;
} else if (ctx.client) {
line2 = esc(ctx.client.name);
}
return `
<div class="sig-cell">
<div class="sig-title">${title}</div>
<div class="sig-line">_____________________ / ${line2} /</div>
<div class="sig-stamp">М.П.</div>
</div>`;
});
return `<div class="b-signatures">${cells.join('')}</div>`;
}
function renderBlock(block: Block, ctx: RenderContext, rubInWordsFn?: (cents: number) => string): string {
switch (block.type) {
case 'heading':
return renderHeading(block.level, block.text as RichText, ctx);
case 'paragraph':
return `<p class="b-paragraph">${renderRichText(block.text as RichText, ctx)}</p>`;
case 'party': {
if (block.bind.kind === 'self') return renderParty(block.role, ctx.organization, ctx);
if (ctx.client) return renderParty(block.role, ctx.client, ctx);
return `<div class="b-party"><i>Сторона не указана</i></div>`;
}
case 'services_table':
return renderServicesTable(block.lines, ctx, block.columns);
case 'totals':
return renderTotals(block.showVat, block.showInWords, ctx, rubInWordsFn);
case 'terms':
return `<div class="b-terms">${renderRichText(block.text as RichText, ctx)}</div>`;
case 'signatures':
return renderSignatures(block.sides, ctx);
case 'custom_text':
return `<div class="b-custom">${renderRichText(block.text as RichText, ctx)}</div>`;
case 'page_break':
return `<div class="b-page-break"></div>`;
}
}
function formatRub(cents: number): string {
return (cents / 100).toLocaleString('ru-RU', {
style: 'currency',
currency: 'RUB',
minimumFractionDigits: 2,
});
}
function formatQty(qtyMilli: number): string {
const n = qtyMilli / 1000;
return n % 1 === 0 ? String(n) : n.toFixed(3).replace(/\.?0+$/, '');
}
export function renderDocumentHtml(
body: DocBody,
ctx: RenderContext,
opts: {
title?: string;
rubInWords?: (cents: number) => string;
} = {},
): string {
const blocksHtml = body.blocks.map((b) => renderBlock(b, ctx, opts.rubInWords)).join('\n');
const title = opts.title ?? `Документ ${ctx.doc.number}`;
return `<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<title>${esc(title)}</title>
<style>
@page { size: A4; margin: 18mm 16mm; }
* { box-sizing: border-box; }
body {
font-family: 'PT Sans', 'DejaVu Sans', system-ui, sans-serif;
font-size: 11pt; line-height: 1.4; color: #111; margin: 0;
}
h1.b-heading { font-size: 16pt; margin: 12px 0 6px; }
h2.b-heading { font-size: 13pt; margin: 10px 0 4px; }
h3.b-heading { font-size: 11.5pt; margin: 8px 0 4px; }
p.b-paragraph { margin: 4px 0 8px; text-align: justify; }
.b-party { margin: 8px 0 12px; page-break-inside: avoid; }
.party-title { font-weight: bold; margin-bottom: 2px; }
table.party-table { border-collapse: collapse; }
.party-table .party-k { padding: 1px 8px 1px 0; opacity: 0.7; vertical-align: top; white-space: nowrap; }
.party-table .party-v { padding: 1px 0; vertical-align: top; }
table.b-services {
width: 100%; border-collapse: collapse; margin: 8px 0 12px;
page-break-inside: auto;
}
table.b-services th, table.b-services td {
border: 1px solid #888; padding: 4px 8px; text-align: left;
vertical-align: top;
}
table.b-services th { background: #f4f4f4; font-weight: 600; }
table.b-services .num { text-align: right; }
table.b-totals { margin-left: auto; margin-right: 0; margin-top: 8px; }
.totals-k { padding: 2px 12px 2px 0; opacity: 0.7; text-align: right; }
.totals-v { padding: 2px 0; text-align: right; }
.b-terms { margin: 8px 0; text-align: justify; }
.b-custom { margin: 8px 0; }
.b-signatures {
display: flex; gap: 32px; margin-top: 32px; page-break-inside: avoid;
}
.sig-cell { flex: 1; }
.sig-title { font-weight: 600; margin-bottom: 28px; }
.sig-line { font-size: 10pt; }
.sig-stamp { margin-top: 16px; font-size: 9pt; opacity: 0.6; }
.b-page-break { page-break-before: always; }
</style>
</head>
<body>
${blocksHtml}
</body>
</html>`;
}