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:
@@ -24,6 +24,7 @@
|
||||
"fastify": "^4.28.1",
|
||||
"fastify-plugin": "^4.5.1",
|
||||
"jose": "^5.9.6",
|
||||
"puppeteer-core": "^24.42.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -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, ['копейка', 'копейки', 'копеек'])}`;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { DocType } from '@prisma/client';
|
||||
import { prisma } from '../db.js';
|
||||
|
||||
const PREFIX: Record<DocType, string> = {
|
||||
contract: 'ДГ',
|
||||
invoice: 'СЧ',
|
||||
act: 'АКТ',
|
||||
upd: 'УПД',
|
||||
};
|
||||
|
||||
/**
|
||||
* Подобрать следующий свободный номер для (organization, docType, year).
|
||||
* Формат: «ДГ-2026/001». Возвращает строку — вставлять под `documents.number` нужно
|
||||
* в той же транзакции с retry на P2002 (unique constraint), на случай concurrent insert.
|
||||
*
|
||||
* Решение без advisory locks выбрано осознанно: на текущем масштабе
|
||||
* (single-tenant, ручной ввод документов) шанс race-conflict пренебрежимо мал.
|
||||
*/
|
||||
export async function nextDocumentNumber(
|
||||
organizationId: string,
|
||||
docType: DocType,
|
||||
now: Date = new Date(),
|
||||
): Promise<string> {
|
||||
const year = now.getFullYear();
|
||||
const prefix = PREFIX[docType];
|
||||
const filter = `${prefix}-${year}/`;
|
||||
|
||||
const docs = await prisma.document.findMany({
|
||||
where: { organizationId, docType, number: { startsWith: filter } },
|
||||
select: { number: true },
|
||||
});
|
||||
|
||||
const re = new RegExp(`^${prefix}-${year}/(\\d+)$`);
|
||||
let max = 0;
|
||||
for (const d of docs) {
|
||||
const m = re.exec(d.number);
|
||||
if (m && m[1]) {
|
||||
const n = parseInt(m[1], 10);
|
||||
if (n > max) max = n;
|
||||
}
|
||||
}
|
||||
return `${prefix}-${year}/${String(max + 1).padStart(3, '0')}`;
|
||||
}
|
||||
|
||||
export function isUniqueViolation(err: unknown): boolean {
|
||||
return (
|
||||
typeof err === 'object' &&
|
||||
err !== null &&
|
||||
'code' in err &&
|
||||
(err as { code?: string }).code === 'P2002'
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { prisma } from '../../db.js';
|
||||
import { getOrganizationId } from '../../lib/org.js';
|
||||
import { renderDocumentToHtml, renderDocumentToPdf } from './pdf.js';
|
||||
|
||||
export async function documentsPdfRoutes(app: FastifyInstance) {
|
||||
// Превью HTML — для отладки и интерактивного просмотра до выгрузки в PDF.
|
||||
app.get(
|
||||
'/api/documents/:id/preview',
|
||||
{ preHandler: app.requireDocPermission('viewer') },
|
||||
async (req, reply) => {
|
||||
const orgId = getOrganizationId(req);
|
||||
const { id } = req.params as { id: string };
|
||||
const doc = await prisma.document.findFirst({
|
||||
where: { id, organizationId: orgId },
|
||||
include: { client: true, lines: { orderBy: { position: 'asc' } } },
|
||||
});
|
||||
if (!doc) {
|
||||
reply.code(404).send({ error: 'not_found' });
|
||||
return;
|
||||
}
|
||||
const org = await prisma.organization.findUnique({ where: { id: orgId } });
|
||||
if (!org) {
|
||||
reply.code(404).send({ error: 'organization_not_found' });
|
||||
return;
|
||||
}
|
||||
const html = renderDocumentToHtml(doc, org);
|
||||
reply.type('text/html; charset=utf-8').send(html);
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/api/documents/:id/pdf',
|
||||
{ preHandler: app.requireDocPermission('viewer') },
|
||||
async (req, reply) => {
|
||||
const orgId = getOrganizationId(req);
|
||||
const { id } = req.params as { id: string };
|
||||
const doc = await prisma.document.findFirst({
|
||||
where: { id, organizationId: orgId },
|
||||
include: { client: true, lines: { orderBy: { position: 'asc' } } },
|
||||
});
|
||||
if (!doc) {
|
||||
reply.code(404).send({ error: 'not_found' });
|
||||
return;
|
||||
}
|
||||
const org = await prisma.organization.findUnique({ where: { id: orgId } });
|
||||
if (!org) {
|
||||
reply.code(404).send({ error: 'organization_not_found' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const pdf = await renderDocumentToPdf(doc, org);
|
||||
reply
|
||||
.type('application/pdf')
|
||||
.header('Content-Disposition', `inline; filename="${encodeURIComponent(doc.number)}.pdf"`)
|
||||
.send(pdf);
|
||||
} catch (e) {
|
||||
if ((e as { code?: string }).code === 'NO_CHROMIUM') {
|
||||
reply.code(503).send({
|
||||
error: 'no_chromium',
|
||||
message: (e as Error).message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import puppeteer, { type Browser } from 'puppeteer-core';
|
||||
import {
|
||||
renderDocumentHtml,
|
||||
type RenderContext,
|
||||
type RenderClient,
|
||||
type RenderOrganization,
|
||||
type RenderLine,
|
||||
type RenderDocument,
|
||||
type DocBody,
|
||||
} from '@doc-manager/shared';
|
||||
import { rubInWords } from '../../lib/money.js';
|
||||
|
||||
// Автодетект Chromium / Chrome для разных платформ.
|
||||
function detectExecutable(): string | null {
|
||||
if (process.env.PUPPETEER_EXECUTABLE_PATH && existsSync(process.env.PUPPETEER_EXECUTABLE_PATH)) {
|
||||
return process.env.PUPPETEER_EXECUTABLE_PATH;
|
||||
}
|
||||
const candidates =
|
||||
process.platform === 'win32'
|
||||
? [
|
||||
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
||||
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
|
||||
'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
|
||||
]
|
||||
: process.platform === 'darwin'
|
||||
? [
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
]
|
||||
: [
|
||||
'/usr/bin/chromium-browser',
|
||||
'/usr/bin/chromium',
|
||||
'/usr/bin/google-chrome',
|
||||
'/usr/bin/google-chrome-stable',
|
||||
];
|
||||
for (const p of candidates) {
|
||||
if (existsSync(p)) return p;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
let browserPromise: Promise<Browser> | null = null;
|
||||
|
||||
async function getBrowser(): Promise<Browser> {
|
||||
if (browserPromise) {
|
||||
const b = await browserPromise;
|
||||
if (b.connected) return b;
|
||||
browserPromise = null;
|
||||
}
|
||||
const executablePath = detectExecutable();
|
||||
if (!executablePath) {
|
||||
throw Object.assign(
|
||||
new Error(
|
||||
'Chromium не найден. Установите PUPPETEER_EXECUTABLE_PATH в .env или поставьте Chrome/Chromium.',
|
||||
),
|
||||
{ code: 'NO_CHROMIUM' },
|
||||
);
|
||||
}
|
||||
browserPromise = puppeteer.launch({
|
||||
executablePath,
|
||||
args: ['--no-sandbox', '--disable-dev-shm-usage', '--disable-gpu'],
|
||||
});
|
||||
return browserPromise;
|
||||
}
|
||||
|
||||
export async function shutdownBrowser(): Promise<void> {
|
||||
if (browserPromise) {
|
||||
try {
|
||||
const b = await browserPromise;
|
||||
await b.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
browserPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Адаптеры из Prisma в RenderContext ==========
|
||||
|
||||
type PrismaDoc = {
|
||||
number: string;
|
||||
docType: 'contract' | 'invoice' | 'act' | 'upd';
|
||||
issuedAt: Date | null;
|
||||
totalCents: bigint;
|
||||
vatCents: bigint;
|
||||
currency: string;
|
||||
body: unknown;
|
||||
client: {
|
||||
id: string;
|
||||
kind: 'ul' | 'ip' | 'fl';
|
||||
name: string;
|
||||
inn: string | null;
|
||||
kpp: string | null;
|
||||
address: string | null;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
} | null;
|
||||
lines: {
|
||||
id: string;
|
||||
position: number;
|
||||
name: string;
|
||||
qtyMilli: bigint;
|
||||
unit: string;
|
||||
priceCents: bigint;
|
||||
vat: 'none' | 'vat_0' | 'vat_5' | 'vat_7' | 'vat_10' | 'vat_20';
|
||||
sumCents: bigint;
|
||||
}[];
|
||||
};
|
||||
|
||||
type PrismaOrg = {
|
||||
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 function buildRenderContext(doc: PrismaDoc, organization: PrismaOrg): RenderContext {
|
||||
const renderDoc: RenderDocument = {
|
||||
number: doc.number,
|
||||
docType: doc.docType,
|
||||
issuedAt: doc.issuedAt,
|
||||
totalCents: Number(doc.totalCents),
|
||||
vatCents: Number(doc.vatCents),
|
||||
currency: doc.currency,
|
||||
};
|
||||
const renderOrg: RenderOrganization = { ...organization };
|
||||
const renderClient: RenderClient | null = doc.client ? { ...doc.client } : null;
|
||||
const renderLines: RenderLine[] = doc.lines.map((l) => ({
|
||||
id: l.id,
|
||||
position: l.position,
|
||||
name: l.name,
|
||||
qtyMilli: Number(l.qtyMilli),
|
||||
unit: l.unit,
|
||||
priceCents: Number(l.priceCents),
|
||||
vat: l.vat,
|
||||
sumCents: Number(l.sumCents),
|
||||
}));
|
||||
return {
|
||||
doc: renderDoc,
|
||||
organization: renderOrg,
|
||||
client: renderClient,
|
||||
lines: renderLines,
|
||||
vars: {},
|
||||
};
|
||||
}
|
||||
|
||||
export async function renderDocumentToPdf(doc: PrismaDoc, organization: PrismaOrg): Promise<Buffer> {
|
||||
const ctx = buildRenderContext(doc, organization);
|
||||
const html = renderDocumentHtml(doc.body as DocBody, ctx, {
|
||||
title: `${doc.docType} ${doc.number}`,
|
||||
rubInWords: (cents) => rubInWords(cents),
|
||||
});
|
||||
|
||||
const browser = await getBrowser();
|
||||
const page = await browser.newPage();
|
||||
try {
|
||||
await page.setContent(html, { waitUntil: 'networkidle0' });
|
||||
const pdf = await page.pdf({
|
||||
format: 'A4',
|
||||
printBackground: true,
|
||||
margin: { top: '18mm', right: '16mm', bottom: '18mm', left: '16mm' },
|
||||
});
|
||||
return Buffer.from(pdf);
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function renderDocumentToHtml(doc: PrismaDoc, organization: PrismaOrg): string {
|
||||
const ctx = buildRenderContext(doc, organization);
|
||||
return renderDocumentHtml(doc.body as DocBody, ctx, {
|
||||
title: `${doc.docType} ${doc.number}`,
|
||||
rubInWords: (cents) => rubInWords(cents),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { Prisma, type DocType, type DocStatus, type VatRate } from '@prisma/client';
|
||||
import { z } from 'zod';
|
||||
import { DocBody } from '@doc-manager/shared';
|
||||
import { prisma } from '../../db.js';
|
||||
import { getOrganizationId } from '../../lib/org.js';
|
||||
import { isUniqueViolation, nextDocumentNumber } from '../../lib/numbers.js';
|
||||
import { totals } from '../../lib/money.js';
|
||||
|
||||
const VAT_VALUES = ['none', 'vat_0', 'vat_5', 'vat_7', 'vat_10', 'vat_20'] as const;
|
||||
const DOC_TYPES = ['contract', 'invoice', 'act', 'upd'] as const;
|
||||
const DOC_STATUSES = ['draft', 'issued', 'sent', 'partially_paid', 'paid', 'cancelled', 'signed'] as const;
|
||||
|
||||
const LineInput = z.object({
|
||||
position: z.coerce.number().int().nonnegative(),
|
||||
serviceId: z.string().uuid().nullable(),
|
||||
lineId: z.string().uuid().optional(), // если есть — обновляем существующую строку
|
||||
name: z.string().min(1).max(500),
|
||||
qtyMilli: z.coerce.number().int().positive(),
|
||||
unit: z.string().min(1).max(50),
|
||||
priceCents: z.coerce.number().int().nonnegative(),
|
||||
vat: z.enum(VAT_VALUES),
|
||||
});
|
||||
|
||||
const DocumentCreate = z.object({
|
||||
docType: z.enum(DOC_TYPES),
|
||||
clientId: z.string().uuid().nullable(),
|
||||
parentDocumentId: z.string().uuid().nullable(),
|
||||
body: DocBody,
|
||||
lines: z.array(LineInput).default([]),
|
||||
number: z.string().min(1).max(100).nullable(), // если null — генерим автоматически
|
||||
issuedAt: z.string().datetime().nullable(),
|
||||
currency: z.string().length(3).default('RUB'),
|
||||
});
|
||||
|
||||
const DocumentUpdate = z.object({
|
||||
clientId: z.string().uuid().nullable(),
|
||||
body: DocBody,
|
||||
lines: z.array(LineInput).default([]),
|
||||
number: z.string().min(1).max(100),
|
||||
issuedAt: z.string().datetime().nullable(),
|
||||
});
|
||||
|
||||
const StatusChange = z.object({
|
||||
status: z.enum(DOC_STATUSES),
|
||||
});
|
||||
|
||||
const ListQuery = z.object({
|
||||
docType: z.enum(DOC_TYPES).optional(),
|
||||
clientId: z.string().uuid().optional(),
|
||||
status: z.enum(DOC_STATUSES).optional(),
|
||||
q: z.string().optional(),
|
||||
limit: z.coerce.number().int().min(1).max(500).default(100),
|
||||
});
|
||||
|
||||
const HistoryQuery = z.object({
|
||||
clientId: z.string().uuid(),
|
||||
limit: z.coerce.number().int().min(1).max(200).default(50),
|
||||
});
|
||||
|
||||
function lineCalc(l: { qtyMilli: number; priceCents: number; vat: VatRate }) {
|
||||
return { qtyMilli: BigInt(l.qtyMilli), priceCents: BigInt(l.priceCents), vat: l.vat };
|
||||
}
|
||||
|
||||
export async function documentsRoutes(app: FastifyInstance) {
|
||||
// -------- LIST --------
|
||||
app.get('/api/documents', { preHandler: app.requireDocPermission('viewer') }, async (req, reply) => {
|
||||
const orgId = getOrganizationId(req);
|
||||
const parsed = ListQuery.safeParse(req.query);
|
||||
if (!parsed.success) {
|
||||
reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() });
|
||||
return;
|
||||
}
|
||||
const { docType, clientId, status, q, limit } = parsed.data;
|
||||
const docs = await prisma.document.findMany({
|
||||
where: {
|
||||
organizationId: orgId,
|
||||
...(docType ? { docType } : {}),
|
||||
...(clientId ? { clientId } : {}),
|
||||
...(status ? { status } : {}),
|
||||
...(q ? { OR: [{ number: { contains: q, mode: 'insensitive' } }, { client: { name: { contains: q, mode: 'insensitive' } } }] } : {}),
|
||||
},
|
||||
include: { client: { select: { id: true, name: true, kind: true } } },
|
||||
orderBy: [{ issuedAt: { sort: 'desc', nulls: 'last' } }, { createdAt: 'desc' }],
|
||||
take: limit,
|
||||
});
|
||||
return { items: docs };
|
||||
});
|
||||
|
||||
// -------- HISTORY (line autocomplete по клиенту) --------
|
||||
app.get('/api/documents/_history', { preHandler: app.requireDocPermission('viewer') }, async (req, reply) => {
|
||||
const orgId = getOrganizationId(req);
|
||||
const parsed = HistoryQuery.safeParse(req.query);
|
||||
if (!parsed.success) {
|
||||
reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() });
|
||||
return;
|
||||
}
|
||||
const { clientId, limit } = parsed.data;
|
||||
// Сводка использованных услуг по клиенту: имя/цена/НДС, последний раз и сколько использовалась.
|
||||
const rows = await prisma.$queryRaw<
|
||||
{ service_id: string | null; name: string; unit: string; price_cents: bigint; vat: VatRate; last_used: Date; use_count: bigint }[]
|
||||
>`
|
||||
select dl.service_id, dl.name, dl.unit, dl.price_cents, dl.vat,
|
||||
max(d.created_at) as last_used,
|
||||
count(*) as use_count
|
||||
from "DocumentLine" dl
|
||||
join "Document" d on d.id = dl.document_id
|
||||
where d.organization_id = ${orgId}::uuid and d.client_id = ${clientId}::uuid
|
||||
group by dl.service_id, dl.name, dl.unit, dl.price_cents, dl.vat
|
||||
order by max(d.created_at) desc
|
||||
limit ${limit}
|
||||
`;
|
||||
return {
|
||||
items: rows.map((r) => ({
|
||||
serviceId: r.service_id,
|
||||
name: r.name,
|
||||
unit: r.unit,
|
||||
priceCents: Number(r.price_cents),
|
||||
vat: r.vat,
|
||||
lastUsed: r.last_used,
|
||||
useCount: Number(r.use_count),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
// -------- GET --------
|
||||
app.get('/api/documents/:id', { preHandler: app.requireDocPermission('viewer') }, async (req, reply) => {
|
||||
const orgId = getOrganizationId(req);
|
||||
const { id } = req.params as { id: string };
|
||||
const doc = await prisma.document.findFirst({
|
||||
where: { id, organizationId: orgId },
|
||||
include: {
|
||||
client: true,
|
||||
lines: { orderBy: { position: 'asc' } },
|
||||
},
|
||||
});
|
||||
if (!doc) {
|
||||
reply.code(404).send({ error: 'not_found' });
|
||||
return;
|
||||
}
|
||||
return doc;
|
||||
});
|
||||
|
||||
// -------- CREATE --------
|
||||
app.post('/api/documents', { preHandler: app.requireDocPermission('user') }, async (req, reply) => {
|
||||
const orgId = getOrganizationId(req);
|
||||
const parsed = DocumentCreate.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() });
|
||||
return;
|
||||
}
|
||||
const { docType, clientId, parentDocumentId, body, lines, number, issuedAt, currency } = parsed.data;
|
||||
const calcLines = lines.map(lineCalc);
|
||||
const { totalCents, vatCents } = totals(calcLines);
|
||||
const sub = (req.user!.sub as string) || null;
|
||||
|
||||
// Retry на P2002 при auto-номере (на случай гонки concurrent insert).
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
const num = number ?? (await nextDocumentNumber(orgId, docType));
|
||||
try {
|
||||
const doc = await prisma.document.create({
|
||||
data: {
|
||||
organizationId: orgId,
|
||||
docType,
|
||||
number: num,
|
||||
issuedAt: issuedAt ? new Date(issuedAt) : null,
|
||||
clientId,
|
||||
parentDocumentId,
|
||||
body: body as Prisma.InputJsonValue,
|
||||
totalCents,
|
||||
vatCents,
|
||||
currency,
|
||||
createdBy: sub,
|
||||
lines: {
|
||||
create: lines.map((l) => ({
|
||||
position: l.position,
|
||||
serviceId: l.serviceId,
|
||||
name: l.name,
|
||||
qtyMilli: BigInt(l.qtyMilli),
|
||||
unit: l.unit,
|
||||
priceCents: BigInt(l.priceCents),
|
||||
vat: l.vat,
|
||||
sumCents: (BigInt(l.priceCents) * BigInt(l.qtyMilli)) / 1000n,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: { lines: { orderBy: { position: 'asc' } }, client: true },
|
||||
});
|
||||
reply.code(201).send(doc);
|
||||
return;
|
||||
} catch (e) {
|
||||
if (number === null && isUniqueViolation(e) && attempt < 2) continue;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// -------- UPDATE --------
|
||||
app.put('/api/documents/:id', { preHandler: app.requireDocPermission('user') }, async (req, reply) => {
|
||||
const orgId = getOrganizationId(req);
|
||||
const { id } = req.params as { id: string };
|
||||
const parsed = DocumentUpdate.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() });
|
||||
return;
|
||||
}
|
||||
const existing = await prisma.document.findFirst({ where: { id, organizationId: orgId } });
|
||||
if (!existing) {
|
||||
reply.code(404).send({ error: 'not_found' });
|
||||
return;
|
||||
}
|
||||
if (existing.tochkaDocumentId) {
|
||||
reply.code(409).send({ error: 'locked_by_bank', message: 'Документ выставлен через банк, редактировать нельзя.' });
|
||||
return;
|
||||
}
|
||||
const { clientId, body, lines, number, issuedAt } = parsed.data;
|
||||
const { totalCents, vatCents } = totals(lines.map(lineCalc));
|
||||
|
||||
const updated = await prisma.$transaction(async (tx) => {
|
||||
// Bulk-replace строк (пересоздаём — проще чем merge, т.к. упорядочивание по position)
|
||||
await tx.documentLine.deleteMany({ where: { documentId: id } });
|
||||
await tx.document.update({
|
||||
where: { id },
|
||||
data: {
|
||||
number,
|
||||
clientId,
|
||||
issuedAt: issuedAt ? new Date(issuedAt) : null,
|
||||
body: body as Prisma.InputJsonValue,
|
||||
totalCents,
|
||||
vatCents,
|
||||
lines: {
|
||||
create: lines.map((l) => ({
|
||||
position: l.position,
|
||||
serviceId: l.serviceId,
|
||||
name: l.name,
|
||||
qtyMilli: BigInt(l.qtyMilli),
|
||||
unit: l.unit,
|
||||
priceCents: BigInt(l.priceCents),
|
||||
vat: l.vat,
|
||||
sumCents: (BigInt(l.priceCents) * BigInt(l.qtyMilli)) / 1000n,
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
return tx.document.findUnique({
|
||||
where: { id },
|
||||
include: { lines: { orderBy: { position: 'asc' } }, client: true },
|
||||
});
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
|
||||
// -------- STATUS CHANGE --------
|
||||
app.post('/api/documents/:id/status', { preHandler: app.requireDocPermission('user') }, async (req, reply) => {
|
||||
const orgId = getOrganizationId(req);
|
||||
const { id } = req.params as { id: string };
|
||||
const parsed = StatusChange.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() });
|
||||
return;
|
||||
}
|
||||
const existing = await prisma.document.findFirst({ where: { id, organizationId: orgId } });
|
||||
if (!existing) {
|
||||
reply.code(404).send({ error: 'not_found' });
|
||||
return;
|
||||
}
|
||||
const next: DocStatus = parsed.data.status;
|
||||
const updated = await prisma.document.update({ where: { id }, data: { status: next } });
|
||||
return updated;
|
||||
});
|
||||
|
||||
// -------- DELETE (только drafts) --------
|
||||
app.delete('/api/documents/:id', { preHandler: app.requireDocPermission('admin') }, async (req, reply) => {
|
||||
const orgId = getOrganizationId(req);
|
||||
const { id } = req.params as { id: string };
|
||||
const existing = await prisma.document.findFirst({ where: { id, organizationId: orgId } });
|
||||
if (!existing) {
|
||||
reply.code(404).send({ error: 'not_found' });
|
||||
return;
|
||||
}
|
||||
if (existing.status !== 'draft') {
|
||||
reply.code(409).send({ error: 'not_draft', message: 'Удалять можно только черновики.' });
|
||||
return;
|
||||
}
|
||||
await prisma.document.delete({ where: { id } });
|
||||
reply.code(204).send();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { Prisma, type DocType } from '@prisma/client';
|
||||
import { z } from 'zod';
|
||||
import { DocBody } from '@doc-manager/shared';
|
||||
import { prisma } from '../../db.js';
|
||||
import { getOrganizationId } from '../../lib/org.js';
|
||||
import { nextDocumentNumber, isUniqueViolation } from '../../lib/numbers.js';
|
||||
import { totals } from '../../lib/money.js';
|
||||
|
||||
const DOC_TYPES = ['contract', 'invoice', 'act', 'upd'] as const;
|
||||
const VAT_VALUES = ['none', 'vat_0', 'vat_5', 'vat_7', 'vat_10', 'vat_20'] as const;
|
||||
|
||||
const TemplateUpsert = z.object({
|
||||
docType: z.enum(DOC_TYPES),
|
||||
name: z.string().min(1).max(500),
|
||||
body: DocBody,
|
||||
});
|
||||
|
||||
const InstantiateInput = z.object({
|
||||
clientId: z.string().uuid().nullable(),
|
||||
// Линии у шаблона мы не храним; но на инстанс пользователь может передать стартовый набор линий.
|
||||
initialLines: z
|
||||
.array(
|
||||
z.object({
|
||||
position: z.number().int().nonnegative(),
|
||||
serviceId: z.string().uuid().nullable(),
|
||||
name: z.string().min(1).max(500),
|
||||
qtyMilli: z.number().int().positive(),
|
||||
unit: z.string().min(1).max(50),
|
||||
priceCents: z.number().int().nonnegative(),
|
||||
vat: z.enum(VAT_VALUES),
|
||||
}),
|
||||
)
|
||||
.default([]),
|
||||
});
|
||||
|
||||
export async function templatesRoutes(app: FastifyInstance) {
|
||||
app.get('/api/templates', { preHandler: app.requireDocPermission('viewer') }, async (req) => {
|
||||
const orgId = getOrganizationId(req);
|
||||
const docType = (req.query as { docType?: DocType }).docType;
|
||||
const items = await prisma.documentTemplate.findMany({
|
||||
where: {
|
||||
organizationId: orgId,
|
||||
...(docType ? { docType } : {}),
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
return { items };
|
||||
});
|
||||
|
||||
app.get('/api/templates/:id', { preHandler: app.requireDocPermission('viewer') }, async (req, reply) => {
|
||||
const orgId = getOrganizationId(req);
|
||||
const { id } = req.params as { id: string };
|
||||
const tpl = await prisma.documentTemplate.findFirst({ where: { id, organizationId: orgId } });
|
||||
if (!tpl) {
|
||||
reply.code(404).send({ error: 'not_found' });
|
||||
return;
|
||||
}
|
||||
return tpl;
|
||||
});
|
||||
|
||||
app.post('/api/templates', { preHandler: app.requireDocPermission('user') }, async (req, reply) => {
|
||||
const orgId = getOrganizationId(req);
|
||||
const parsed = TemplateUpsert.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() });
|
||||
return;
|
||||
}
|
||||
const created = await prisma.documentTemplate.create({
|
||||
data: {
|
||||
organizationId: orgId,
|
||||
docType: parsed.data.docType,
|
||||
name: parsed.data.name,
|
||||
body: parsed.data.body as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
reply.code(201).send(created);
|
||||
});
|
||||
|
||||
app.put('/api/templates/:id', { preHandler: app.requireDocPermission('user') }, async (req, reply) => {
|
||||
const orgId = getOrganizationId(req);
|
||||
const { id } = req.params as { id: string };
|
||||
const parsed = TemplateUpsert.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() });
|
||||
return;
|
||||
}
|
||||
const existing = await prisma.documentTemplate.findFirst({ where: { id, organizationId: orgId } });
|
||||
if (!existing) {
|
||||
reply.code(404).send({ error: 'not_found' });
|
||||
return;
|
||||
}
|
||||
return prisma.documentTemplate.update({
|
||||
where: { id },
|
||||
data: {
|
||||
docType: parsed.data.docType,
|
||||
name: parsed.data.name,
|
||||
body: parsed.data.body as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
app.delete('/api/templates/:id', { preHandler: app.requireDocPermission('user') }, async (req, reply) => {
|
||||
const orgId = getOrganizationId(req);
|
||||
const { id } = req.params as { id: string };
|
||||
const existing = await prisma.documentTemplate.findFirst({ where: { id, organizationId: orgId } });
|
||||
if (!existing) {
|
||||
reply.code(404).send({ error: 'not_found' });
|
||||
return;
|
||||
}
|
||||
await prisma.documentTemplate.delete({ where: { id } });
|
||||
reply.code(204).send();
|
||||
});
|
||||
|
||||
// Создать новый документ-черновик из шаблона.
|
||||
app.post('/api/templates/:id/instantiate', { preHandler: app.requireDocPermission('user') }, async (req, reply) => {
|
||||
const orgId = getOrganizationId(req);
|
||||
const { id } = req.params as { id: string };
|
||||
const parsed = InstantiateInput.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() });
|
||||
return;
|
||||
}
|
||||
const tpl = await prisma.documentTemplate.findFirst({ where: { id, organizationId: orgId } });
|
||||
if (!tpl) {
|
||||
reply.code(404).send({ error: 'not_found' });
|
||||
return;
|
||||
}
|
||||
const lines = parsed.data.initialLines;
|
||||
const calc = totals(lines.map((l) => ({ qtyMilli: BigInt(l.qtyMilli), priceCents: BigInt(l.priceCents), vat: l.vat })));
|
||||
const sub = (req.user!.sub as string) || null;
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
const number = await nextDocumentNumber(orgId, tpl.docType);
|
||||
try {
|
||||
const doc = await prisma.document.create({
|
||||
data: {
|
||||
organizationId: orgId,
|
||||
docType: tpl.docType,
|
||||
number,
|
||||
clientId: parsed.data.clientId,
|
||||
body: tpl.body as Prisma.InputJsonValue,
|
||||
totalCents: calc.totalCents,
|
||||
vatCents: calc.vatCents,
|
||||
createdBy: sub,
|
||||
lines: {
|
||||
create: lines.map((l) => ({
|
||||
position: l.position,
|
||||
serviceId: l.serviceId,
|
||||
name: l.name,
|
||||
qtyMilli: BigInt(l.qtyMilli),
|
||||
unit: l.unit,
|
||||
priceCents: BigInt(l.priceCents),
|
||||
vat: l.vat,
|
||||
sumCents: (BigInt(l.priceCents) * BigInt(l.qtyMilli)) / 1000n,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: { lines: { orderBy: { position: 'asc' } }, client: true },
|
||||
});
|
||||
reply.code(201).send(doc);
|
||||
return;
|
||||
} catch (e) {
|
||||
if (isUniqueViolation(e) && attempt < 2) continue;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -10,6 +10,10 @@ import { meRoutes } from './routes/me.js';
|
||||
import { organizationsRoutes } from './modules/organizations/routes.js';
|
||||
import { clientsRoutes } from './modules/clients/routes.js';
|
||||
import { servicesRoutes } from './modules/services/routes.js';
|
||||
import { documentsRoutes } from './modules/documents/routes.js';
|
||||
import { documentsPdfRoutes } from './modules/documents/pdf.routes.js';
|
||||
import { templatesRoutes } from './modules/templates/routes.js';
|
||||
import { shutdownBrowser } from './modules/documents/pdf.js';
|
||||
|
||||
async function main() {
|
||||
const loggerOptions =
|
||||
@@ -38,6 +42,13 @@ async function main() {
|
||||
await app.register(organizationsRoutes);
|
||||
await app.register(clientsRoutes);
|
||||
await app.register(servicesRoutes);
|
||||
await app.register(documentsRoutes);
|
||||
await app.register(documentsPdfRoutes);
|
||||
await app.register(templatesRoutes);
|
||||
|
||||
app.addHook('onClose', async () => {
|
||||
await shutdownBrowser();
|
||||
});
|
||||
|
||||
app.setErrorHandler((err, _req, reply) => {
|
||||
app.log.error({ err }, 'unhandled error');
|
||||
|
||||
Reference in New Issue
Block a user