9807d47c8d
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>
114 lines
4.6 KiB
TypeScript
114 lines
4.6 KiB
TypeScript
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, ['копейка', 'копейки', 'копеек'])}`;
|
|
}
|