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:
@@ -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, ['копейка', 'копейки', 'копеек'])}`;
|
||||
}
|
||||
Reference in New Issue
Block a user