feat: Projects + stronger LLM placeholder substitution

Backend:
- Project model with defaults: defaultClient, defaultTemplate, defaultBankAccount
- Document gets optional projectId (FK + index)
- Migration 2_projects: enum ProjectStatus + Project table + Document.projectId
- API: /api/projects CRUD, GET /api/projects/:id/documents
- documents/routes filter by projectId

LLM prompt:
- Concrete preamble example: «ООО «...», в лице директора ... , ИП ..., ОГРНИП ...»
  → {{customer.name}}, {{customer.signatoryPosition}} {{customer.signatoryName}},
    {{executor.name}}, {{executor.ogrn}}
- Expanded placeholder list (signatoryName/Position для customer, ogrn etc)
- Side-role detection: «Заказчик» vs «Исполнитель» map to customer/executor

Frontend:
- /projects (list) and /projects/:id (defaults form + documents list under project)
- Nav: «Проекты» first
- DocumentEdit reads projectId from URL, presets default client from project,
  saves projectId with the document

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
admin
2026-05-01 13:17:07 +03:00
parent 973d85c1dd
commit b2c221e643
11 changed files with 661 additions and 17 deletions
@@ -0,0 +1,37 @@
-- CreateEnum
CREATE TYPE "ProjectStatus" AS ENUM ('active', 'completed', 'cancelled');
-- CreateTable
CREATE TABLE "Project" (
"id" UUID NOT NULL,
"organizationId" UUID NOT NULL,
"name" TEXT NOT NULL,
"status" "ProjectStatus" NOT NULL DEFAULT 'active',
"defaultClientId" UUID,
"defaultTemplateId" UUID,
"defaultBankAccountId" UUID,
"notes" TEXT,
"archivedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Project_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "Project_organizationId_archivedAt_idx" ON "Project"("organizationId", "archivedAt");
CREATE INDEX "Project_organizationId_status_idx" ON "Project"("organizationId", "status");
ALTER TABLE "Project" ADD CONSTRAINT "Project_organizationId_fkey"
FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Project" ADD CONSTRAINT "Project_defaultClientId_fkey"
FOREIGN KEY ("defaultClientId") REFERENCES "Client"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "Project" ADD CONSTRAINT "Project_defaultTemplateId_fkey"
FOREIGN KEY ("defaultTemplateId") REFERENCES "DocumentTemplate"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "Project" ADD CONSTRAINT "Project_defaultBankAccountId_fkey"
FOREIGN KEY ("defaultBankAccountId") REFERENCES "BankAccount"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AlterTable: добавить projectId на Document
ALTER TABLE "Document" ADD COLUMN "projectId" UUID;
CREATE INDEX "Document_organizationId_projectId_idx" ON "Document"("organizationId", "projectId");
ALTER TABLE "Document" ADD CONSTRAINT "Document_projectId_fkey"
FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+38
View File
@@ -55,6 +55,12 @@ enum PaymentKind {
outgoing
}
enum ProjectStatus {
active
completed
cancelled
}
model Organization {
id String @id @default(uuid()) @db.Uuid
name String
@@ -82,6 +88,31 @@ model Organization {
tochkaCredentials TochkaCredential[]
auditLog AuditLog[]
bankAccounts BankAccount[]
projects Project[]
}
model Project {
id String @id @default(uuid()) @db.Uuid
organizationId String @db.Uuid
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
name String
status ProjectStatus @default(active)
// Дефолты — подставляются при создании документов внутри проекта
defaultClientId String? @db.Uuid
defaultClient Client? @relation(fields: [defaultClientId], references: [id])
defaultTemplateId String? @db.Uuid
defaultTemplate DocumentTemplate? @relation(fields: [defaultTemplateId], references: [id])
defaultBankAccountId String? @db.Uuid
defaultBankAccount BankAccount? @relation(fields: [defaultBankAccountId], references: [id])
notes String?
archivedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
documents Document[]
@@index([organizationId, archivedAt])
@@index([organizationId, status])
}
model BankAccount {
@@ -100,6 +131,7 @@ model BankAccount {
updatedAt DateTime @updatedAt
tochkaCredentials TochkaCredential[]
defaultForProjects Project[]
@@index([organizationId])
}
@@ -121,6 +153,7 @@ model Client {
updatedAt DateTime @updatedAt
documents Document[]
defaultForProjects Project[]
@@index([organizationId])
@@index([organizationId, name])
@@ -155,6 +188,8 @@ model DocumentTemplate {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
defaultForProjects Project[]
@@index([organizationId, docType])
}
@@ -162,6 +197,8 @@ model Document {
id String @id @default(uuid()) @db.Uuid
organizationId String @db.Uuid
organization Organization @relation(fields: [organizationId], references: [id])
projectId String? @db.Uuid
project Project? @relation(fields: [projectId], references: [id])
docType DocType
number String
issuedAt DateTime?
@@ -188,6 +225,7 @@ model Document {
@@unique([organizationId, docType, number])
@@index([organizationId, clientId, issuedAt(sort: Desc)])
@@index([organizationId, status])
@@index([organizationId, projectId])
@@index([tochkaDocumentId])
}
+9 -3
View File
@@ -25,6 +25,7 @@ const LineInput = z.object({
const DocumentCreate = z.object({
docType: z.enum(DOC_TYPES),
clientId: z.string().uuid().nullable(),
projectId: z.string().uuid().nullable(),
parentDocumentId: z.string().uuid().nullable(),
body: DocBody,
lines: z.array(LineInput).default([]),
@@ -35,6 +36,7 @@ const DocumentCreate = z.object({
const DocumentUpdate = z.object({
clientId: z.string().uuid().nullable(),
projectId: z.string().uuid().nullable(),
body: DocBody,
lines: z.array(LineInput).default([]),
number: z.string().min(1).max(100),
@@ -48,6 +50,7 @@ const StatusChange = z.object({
const ListQuery = z.object({
docType: z.enum(DOC_TYPES).optional(),
clientId: z.string().uuid().optional(),
projectId: 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),
@@ -71,12 +74,13 @@ export async function documentsRoutes(app: FastifyInstance) {
reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() });
return;
}
const { docType, clientId, status, q, limit } = parsed.data;
const { docType, clientId, projectId, status, q, limit } = parsed.data;
const docs = await prisma.document.findMany({
where: {
organizationId: orgId,
...(docType ? { docType } : {}),
...(clientId ? { clientId } : {}),
...(projectId ? { projectId } : {}),
...(status ? { status } : {}),
...(q ? { OR: [{ number: { contains: q, mode: 'insensitive' } }, { client: { name: { contains: q, mode: 'insensitive' } } }] } : {}),
},
@@ -149,7 +153,7 @@ export async function documentsRoutes(app: FastifyInstance) {
reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() });
return;
}
const { docType, clientId, parentDocumentId, body, lines, number, issuedAt, currency } = parsed.data;
const { docType, clientId, projectId, 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;
@@ -165,6 +169,7 @@ export async function documentsRoutes(app: FastifyInstance) {
number: num,
issuedAt: issuedAt ? new Date(issuedAt) : null,
clientId,
projectId,
parentDocumentId,
body: body as Prisma.InputJsonValue,
totalCents,
@@ -213,7 +218,7 @@ export async function documentsRoutes(app: FastifyInstance) {
reply.code(409).send({ error: 'locked_by_bank', message: 'Документ выставлен через банк, редактировать нельзя.' });
return;
}
const { clientId, body, lines, number, issuedAt } = parsed.data;
const { clientId, projectId, body, lines, number, issuedAt } = parsed.data;
const { totalCents, vatCents } = totals(lines.map(lineCalc));
const updated = await prisma.$transaction(async (tx) => {
@@ -224,6 +229,7 @@ export async function documentsRoutes(app: FastifyInstance) {
data: {
number,
clientId,
projectId,
issuedAt: issuedAt ? new Date(issuedAt) : null,
body: body as Prisma.InputJsonValue,
totalCents,
+133
View File
@@ -0,0 +1,133 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { prisma } from '../../db.js';
import { getOrganizationId } from '../../lib/org.js';
import { optionalText } from '../../lib/zod-utils.js';
const STATUSES = ['active', 'completed', 'cancelled'] as const;
const ProjectUpsert = z.object({
name: z.string().min(1).max(500),
status: z.enum(STATUSES).default('active'),
defaultClientId: z.string().uuid().nullable(),
defaultTemplateId: z.string().uuid().nullable(),
defaultBankAccountId: z.string().uuid().nullable(),
notes: optionalText(2000),
});
const ListQuery = z.object({
status: z.enum(STATUSES).optional(),
q: z.string().optional(),
includeArchived: z.coerce.boolean().default(false),
limit: z.coerce.number().int().min(1).max(500).default(200),
});
export async function projectsRoutes(app: FastifyInstance) {
// ---- list ----
app.get('/api/projects', { 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 { status, q, includeArchived, limit } = parsed.data;
const items = await prisma.project.findMany({
where: {
organizationId: orgId,
...(includeArchived ? {} : { archivedAt: null }),
...(status ? { status } : {}),
...(q ? { name: { contains: q, mode: 'insensitive' as const } } : {}),
},
include: {
defaultClient: { select: { id: true, name: true, kind: true } },
defaultTemplate: { select: { id: true, name: true, docType: true } },
defaultBankAccount: { select: { id: true, name: true } },
_count: { select: { documents: true } },
},
orderBy: [{ status: 'asc' }, { updatedAt: 'desc' }],
take: limit,
});
return { items };
});
// ---- get one ----
app.get('/api/projects/:id', { preHandler: app.requireDocPermission('viewer') }, async (req, reply) => {
const orgId = getOrganizationId(req);
const { id } = req.params as { id: string };
const project = await prisma.project.findFirst({
where: { id, organizationId: orgId },
include: {
defaultClient: true,
defaultTemplate: true,
defaultBankAccount: true,
},
});
if (!project) {
reply.code(404).send({ error: 'not_found' });
return;
}
return project;
});
// ---- documents under project ----
app.get('/api/projects/:id/documents', { preHandler: app.requireDocPermission('viewer') }, async (req, reply) => {
const orgId = getOrganizationId(req);
const { id } = req.params as { id: string };
const project = await prisma.project.findFirst({ where: { id, organizationId: orgId } });
if (!project) {
reply.code(404).send({ error: 'not_found' });
return;
}
const items = await prisma.document.findMany({
where: { projectId: id, organizationId: orgId },
include: { client: { select: { id: true, name: true, kind: true } } },
orderBy: [{ issuedAt: { sort: 'desc', nulls: 'last' } }, { createdAt: 'desc' }],
});
return { items };
});
// ---- create ----
app.post('/api/projects', { preHandler: app.requireDocPermission('user') }, async (req, reply) => {
const orgId = getOrganizationId(req);
const parsed = ProjectUpsert.safeParse(req.body);
if (!parsed.success) {
reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() });
return;
}
const created = await prisma.project.create({
data: { ...parsed.data, organizationId: orgId },
});
reply.code(201).send(created);
});
// ---- update ----
app.put('/api/projects/:id', { preHandler: app.requireDocPermission('user') }, async (req, reply) => {
const orgId = getOrganizationId(req);
const { id } = req.params as { id: string };
const parsed = ProjectUpsert.safeParse(req.body);
if (!parsed.success) {
reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() });
return;
}
const existing = await prisma.project.findFirst({ where: { id, organizationId: orgId } });
if (!existing) {
reply.code(404).send({ error: 'not_found' });
return;
}
return prisma.project.update({ where: { id }, data: parsed.data });
});
// ---- archive ----
app.delete('/api/projects/:id', { preHandler: app.requireDocPermission('user') }, async (req, reply) => {
const orgId = getOrganizationId(req);
const { id } = req.params as { id: string };
const existing = await prisma.project.findFirst({ where: { id, organizationId: orgId } });
if (!existing) {
reply.code(404).send({ error: 'not_found' });
return;
}
await prisma.project.update({ where: { id }, data: { archivedAt: new Date() } });
reply.code(204).send();
});
}
+22 -11
View File
@@ -24,19 +24,30 @@ type LlmResult = {
const SYSTEM_PROMPT = `Ты — парсер юридических документов на русском языке. Получаешь plain-text договора, счёта, акта или УПД и возвращаешь его структуру строго в JSON формате DocBody.
Цель: создать ШАБЛОН, в котором конкретные данные сторон, номера, даты заменены на плейсхолдеры. Шаблон потом будет инстансироваться для конкретного клиента и документа.
Цель: создать ШАБЛОН — переиспользуемый каркас, в котором ВСЕ конкретные значения сторон/номера/даты заменены на плейсхолдеры. Шаблон потом инстансируется автоматически: при создании документа конкретные поля придут из карточек клиента, нашей компании, проекта.
Правила замены на плейсхолдеры (заменяй ТОЛЬКО эти сущности, остальной текст оставь как есть):
- Конкретный номер договора/счёта → {{contract.number}}
- Конкретная дата документа → {{contract.date}}
- Текущая дата (на момент рендера) → {{today}}
- Реквизиты исполнителя (наша компания):
{{executor.name}}, {{executor.inn}}, {{executor.kpp}}, {{executor.ogrn}}, {{executor.legalAddress}},
{{executor.signatoryName}}, {{executor.signatoryPosition}},
КРИТИЧНО: тщательно ищи и заменяй ВСЕ конкретные имена, номера и реквизиты в любом тексте, включая преамбулу.
Полный список плейсхолдеров:
- {{contract.number}} — номер документа
- {{contract.date}} — дата документа
- {{today}} — сегодняшняя дата (для актов/УПД)
- Наша компания (Исполнитель):
{{executor.name}}, {{executor.shortName}}, {{executor.inn}}, {{executor.kpp}}, {{executor.ogrn}},
{{executor.legalAddress}}, {{executor.signatoryName}}, {{executor.signatoryPosition}},
{{executor.bankName}}, {{executor.bankBik}}, {{executor.bankAccount}}
- Реквизиты заказчика (клиента):
{{customer.name}}, {{customer.inn}}, {{customer.kpp}}, {{customer.address}},
{{customer.email}}, {{customer.phone}}, {{customer.contactPerson}}
- Клиент (Заказчик):
{{customer.name}}, {{customer.inn}}, {{customer.kpp}}, {{customer.ogrn}}, {{customer.address}},
{{customer.email}}, {{customer.phone}}, {{customer.contactPerson}},
{{customer.signatoryName}}, {{customer.signatoryPosition}}
ПРИМЕР замены преамбулы:
ВХОД: «ООО «Джет Сервис», именуемое в дальнейшем "Заказчик", в лице генерального директора Ерохиной Екатерины Викторовны, действующего на основании Устава, с одной стороны, и ИП Морозов Владимир Владимирович, именуемый в дальнейшем "Исполнитель", действующий на основании регистрации в Едином государственном реестре индивидуальных предпринимателей (ОГРНИП 324665800184321), с другой стороны, совместно именуемые "Стороны", заключили настоящий договор о нижеследующем:»
ВЫХОД (text внутри блока paragraph):
«{{customer.name}}, именуемое в дальнейшем "Заказчик", в лице {{customer.signatoryPosition}} {{customer.signatoryName}}, действующего на основании Устава, с одной стороны, и {{executor.name}}, именуемый в дальнейшем "Исполнитель", действующий на основании регистрации в Едином государственном реестре индивидуальных предпринимателей (ОГРНИП {{executor.ogrn}}), с другой стороны, совместно именуемые "Стороны", заключили настоящий договор о нижеследующем:»
Тип сторон: понимай по контексту — кто Заказчик / Customer, кто Исполнитель / Executor / Подрядчик. Их роль определяет, какой плейсхолдер ({{customer.*}} или {{executor.*}}) подставлять. По умолчанию НАША компания = Исполнитель.
Структура ответа (JSON-объект):
{
+2
View File
@@ -16,6 +16,7 @@ import { documentsRoutes } from './modules/documents/routes.js';
import { documentsPdfRoutes } from './modules/documents/pdf.routes.js';
import { templatesRoutes } from './modules/templates/routes.js';
import { templatesImportRoutes } from './modules/templates/import.routes.js';
import { projectsRoutes } from './modules/projects/routes.js';
import { dadataRoutes } from './modules/dadata/routes.js';
import { shutdownBrowser } from './modules/documents/pdf.js';
import activeOrgPlugin from './plugins/activeOrg.js';
@@ -54,6 +55,7 @@ async function main() {
await app.register(documentsPdfRoutes);
await app.register(templatesRoutes);
await app.register(templatesImportRoutes);
await app.register(projectsRoutes);
await app.register(dadataRoutes);
app.addHook('onClose', async () => {