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:
@@ -1,3 +1,4 @@
|
||||
export * from './blocks/schema.js';
|
||||
export * from './tochka/dto.js';
|
||||
export * from './auth/types.js';
|
||||
export * from './render/toHtml.js';
|
||||
|
||||
@@ -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> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
};
|
||||
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>`;
|
||||
}
|
||||
Reference in New Issue
Block a user