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
+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, ['копейка', 'копейки', 'копеек'])}`;
}