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:
@@ -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;
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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-объект):
|
||||
{
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user