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; }; // ========== HTML escaping ========== const ESC: Record = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', }; 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((acc, key) => { if (acc && typeof acc === 'object' && key in (acc as Record)) { return (acc as Record)[key]; } return undefined; }, obj); } const PLACEHOLDER_RE = /\{\{\s*([a-zA-Z0-9_.]+)\s*\}\}/g; function fillPlaceholders(text: string, ctx: RenderContext): string { const fullCtx: Record = { 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 = `${out}`; else if (m.type === 'italic') out = `${out}`; else if (m.type === 'underline') out = `${out}`; } return out; } if (node.type === 'hardBreak') return '
'; if (node.type === 'paragraph') return `

${(node.content ?? []).map((c) => renderNode(c, ctx)).join('')}

`; // doc / прочие контейнеры — просто содержимое. return (node.content ?? []).map((c) => renderNode(c, ctx)).join(''); } // ========== Block rendering ========== function renderHeading(level: 1 | 2 | 3, text: RichText, ctx: RenderContext): string { return `${renderRichText(text, ctx) || ''}`; } 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 ? `${esc(label)}${esc(value!)}` : `${esc(value!)}`, ) .join(''); return `
${title}:
${rows}
`; } 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 }> = { 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 = `${cols.map((c) => `${esc(colDefs[c]?.th ?? c)}`).join('')}`; const body = rows .map((l, idx) => `${cols .map((c) => `${colDefs[c]?.render(l, idx) ?? ''}`) .join('')}`, ) .join(''); return `${head}${body}
`; } 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(`Итого:${formatRub(total)}`); if (showVat) { rows.push( `в т.ч. НДС:${vat > 0 ? formatRub(vat) : 'Без НДС'}`, ); } if (showInWords && rubInWordsFn) { rows.push(`Прописью:${esc(rubInWordsFn(total))}`); } return `${rows.join('')}
`; } 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 `
${title}
_____________________ / ${line2} /
М.П.
`; }); return `
${cells.join('')}
`; } 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 `

${renderRichText(block.text as RichText, ctx)}

`; 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 `
Сторона не указана
`; } case 'services_table': return renderServicesTable(block.lines, ctx, block.columns); case 'totals': return renderTotals(block.showVat, block.showInWords, ctx, rubInWordsFn); case 'terms': return `
${renderRichText(block.text as RichText, ctx)}
`; case 'signatures': return renderSignatures(block.sides, ctx); case 'custom_text': return `
${renderRichText(block.text as RichText, ctx)}
`; case 'page_break': return `
`; } } 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 ` ${esc(title)} ${blocksHtml} `; }