From b2c221e6439e87f5fe63c2a7b628f1beba694c02 Mon Sep 17 00:00:00 2001 From: admin Date: Fri, 1 May 2026 13:17:07 +0300 Subject: [PATCH] feat: Projects + stronger LLM placeholder substitution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../migrations/2_projects/migration.sql | 37 +++ apps/api/prisma/schema.prisma | 38 +++ apps/api/src/modules/documents/routes.ts | 12 +- apps/api/src/modules/projects/routes.ts | 133 +++++++++++ apps/api/src/modules/templates/recognize.ts | 33 ++- apps/api/src/server.ts | 2 + apps/web/src/App.tsx | 5 + apps/web/src/api.ts | 26 ++ apps/web/src/pages/DocumentEdit.tsx | 15 +- apps/web/src/pages/ProjectEdit.tsx | 223 ++++++++++++++++++ apps/web/src/pages/Projects.tsx | 154 ++++++++++++ 11 files changed, 661 insertions(+), 17 deletions(-) create mode 100644 apps/api/prisma/migrations/2_projects/migration.sql create mode 100644 apps/api/src/modules/projects/routes.ts create mode 100644 apps/web/src/pages/ProjectEdit.tsx create mode 100644 apps/web/src/pages/Projects.tsx diff --git a/apps/api/prisma/migrations/2_projects/migration.sql b/apps/api/prisma/migrations/2_projects/migration.sql new file mode 100644 index 0000000..8a0e490 --- /dev/null +++ b/apps/api/prisma/migrations/2_projects/migration.sql @@ -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; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index d59dd39..c13b8c2 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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]) } diff --git a/apps/api/src/modules/documents/routes.ts b/apps/api/src/modules/documents/routes.ts index 78ae4d2..7b2f987 100644 --- a/apps/api/src/modules/documents/routes.ts +++ b/apps/api/src/modules/documents/routes.ts @@ -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, diff --git a/apps/api/src/modules/projects/routes.ts b/apps/api/src/modules/projects/routes.ts new file mode 100644 index 0000000..79634d5 --- /dev/null +++ b/apps/api/src/modules/projects/routes.ts @@ -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(); + }); +} diff --git a/apps/api/src/modules/templates/recognize.ts b/apps/api/src/modules/templates/recognize.ts index fdb7e63..246a9da 100644 --- a/apps/api/src/modules/templates/recognize.ts +++ b/apps/api/src/modules/templates/recognize.ts @@ -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-объект): { diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index ec1ed9b..fc707ab 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -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 () => { diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 91dd5f6..1847bb2 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -9,6 +9,8 @@ import { DocumentsPage } from './pages/Documents.js'; import { DocumentEditPage } from './pages/DocumentEdit.js'; import { TemplatesPage } from './pages/Templates.js'; import { TemplateEditPage } from './pages/TemplateEdit.js'; +import { ProjectsPage } from './pages/Projects.js'; +import { ProjectEditPage } from './pages/ProjectEdit.js'; import { OrgSwitcher } from './components/OrgSwitcher.js'; function Layout({ email }: { email: string }) { @@ -16,6 +18,7 @@ function Layout({ email }: { email: string }) {

Doc_manager