8b7fe30b76
Schema: - Block: new optional fields `optional` (boolean) and `optionalLabel` (string) - DocBody: new field `disabledBlockIds` — IDs of optional blocks turned off on this instance - Renderer skips blocks whose id is in disabledBlockIds Template authoring (BlocksEditor): - Each block card has «Опциональный» checkbox + custom label input - Optional blocks visually highlighted (amber border) Document editing (DocumentEdit): - New «Расширенный режим» toggle in header (default off → simple mode) - Simple mode: only document header, lines, and «Дополнительные пункты» section listing optional blocks as checkboxes - Advanced mode: also shows full BlocksEditor (previous behavior) - Toggling optional block updates body.disabledBlockIds; renderer/PDF reflects it Workflow: автор шаблона помечает спорные блоки как опциональные → пользователь при создании документа просто отмечает галочки, не залезая в контент. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
349 lines
12 KiB
TypeScript
349 lines
12 KiB
TypeScript
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 disabled = new Set(body.disabledBlockIds ?? []);
|
||
const blocksHtml = body.blocks
|
||
.filter((b) => !disabled.has(b.id))
|
||
.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>`;
|
||
}
|