From c2fcdec85d5ccd395165f03b68e8999b6c4c7286 Mon Sep 17 00:00:00 2001 From: admin Date: Tue, 16 Jun 2026 15:00:24 +0300 Subject: [PATCH] feat(orders): Site/Order/OrderItem + S2S incoming endpoint + Tochka webhook receiver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schema: - Site (organizationId, name, slug, domain, apiKey, defaultOfferTemplateId) - Order (full customer fields, status enum, totalCents/vatCents, projectId link, rawPayload) - OrderItem (orderId, position, name, serviceId, qty, unit, price, vat, eventDate) - Migration 3_orders + OrderStatus enum API: - /api/sites — CRUD with apiKey shown only on create/regenerate - /api/orders — list/get/convert-to-project (creates project + matches/creates client by INN) - POST /api/incoming/orders — S2S, X-Site-Key header → resolves Site → creates Order - POST /webhooks/tochka/ — receives raw, dedupes, parses paymentId+purpose, matches by document number regex, creates Payment, updates Document status (paid/partially_paid), propagates Order.status=paid when fully covered Web: - /sites page: list + add site (paste-friendly modal with API key + curl example shown once after create/regenerate) - /orders page: filterable list, link to project - /orders/:id: view with items + "Перевести в проект" button (creates project, upserts client by INN, links project<-order) - Nav: «Заявки» and «Сайты» added Manual demo flow: 1. /sites → add «Голосования» slug=voting → save the apiKey 2. curl POST /api/incoming/orders with X-Site-Key → order appears in /orders 3. Open order → «Перевести в проект» → project created with client+default 4. Create invoice document in project → «Выставить через Точку» 5. Webhook from sandbox/prod → document.status=paid → order.status=paid Co-Authored-By: Claude Opus 4.7 (1M context) --- .../prisma/migrations/3_orders/migration.sql | 87 ++++++ apps/api/prisma/schema.prisma | 87 ++++++ apps/api/src/modules/orders/routes.ts | 259 ++++++++++++++++++ apps/api/src/modules/sites/routes.ts | 110 ++++++++ apps/api/src/modules/tochka/webhook.routes.ts | 159 +++++++++++ apps/api/src/server.ts | 6 + apps/web/src/App.tsx | 7 + apps/web/src/api.ts | 59 ++++ apps/web/src/pages/Orders.tsx | 214 +++++++++++++++ apps/web/src/pages/Sites.tsx | 181 ++++++++++++ 10 files changed, 1169 insertions(+) create mode 100644 apps/api/prisma/migrations/3_orders/migration.sql create mode 100644 apps/api/src/modules/orders/routes.ts create mode 100644 apps/api/src/modules/sites/routes.ts create mode 100644 apps/api/src/modules/tochka/webhook.routes.ts create mode 100644 apps/web/src/pages/Orders.tsx create mode 100644 apps/web/src/pages/Sites.tsx diff --git a/apps/api/prisma/migrations/3_orders/migration.sql b/apps/api/prisma/migrations/3_orders/migration.sql new file mode 100644 index 0000000..4e88783 --- /dev/null +++ b/apps/api/prisma/migrations/3_orders/migration.sql @@ -0,0 +1,87 @@ +-- CreateEnum +CREATE TYPE "OrderStatus" AS ENUM ('new', 'accepted', 'invoiced', 'paid', 'fulfilled', 'cancelled'); + +-- CreateTable Site +CREATE TABLE "Site" ( + "id" UUID NOT NULL, + "organizationId" UUID NOT NULL, + "name" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "domain" TEXT, + "apiKey" TEXT NOT NULL, + "defaultOfferTemplateId" UUID, + "archivedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "Site_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "Site_apiKey_key" ON "Site"("apiKey"); +CREATE UNIQUE INDEX "Site_organizationId_slug_key" ON "Site"("organizationId", "slug"); +CREATE INDEX "Site_organizationId_idx" ON "Site"("organizationId"); + +ALTER TABLE "Site" ADD CONSTRAINT "Site_organizationId_fkey" + FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "Site" ADD CONSTRAINT "Site_defaultOfferTemplateId_fkey" + FOREIGN KEY ("defaultOfferTemplateId") REFERENCES "DocumentTemplate"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- CreateTable Order +CREATE TABLE "Order" ( + "id" UUID NOT NULL, + "organizationId" UUID NOT NULL, + "siteId" UUID, + "projectId" UUID, + "status" "OrderStatus" NOT NULL DEFAULT 'new', + "customerName" TEXT NOT NULL, + "customerInn" TEXT, + "customerKpp" TEXT, + "customerEmail" TEXT, + "customerPhone" TEXT, + "customerAddress" TEXT, + "customerKind" "ClientKind" NOT NULL DEFAULT 'ul', + "totalCents" BIGINT NOT NULL DEFAULT 0, + "vatCents" BIGINT NOT NULL DEFAULT 0, + "currency" TEXT NOT NULL DEFAULT 'RUB', + "acceptedOfferAt" TIMESTAMP(3), + "notes" TEXT, + "rawPayload" JSONB, + "archivedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "Order_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "Order_organizationId_status_idx" ON "Order"("organizationId", "status"); +CREATE INDEX "Order_organizationId_siteId_idx" ON "Order"("organizationId", "siteId"); +CREATE INDEX "Order_projectId_idx" ON "Order"("projectId"); + +ALTER TABLE "Order" ADD CONSTRAINT "Order_organizationId_fkey" + FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "Order" ADD CONSTRAINT "Order_siteId_fkey" + FOREIGN KEY ("siteId") REFERENCES "Site"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "Order" ADD CONSTRAINT "Order_projectId_fkey" + FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- CreateTable OrderItem +CREATE TABLE "OrderItem" ( + "id" UUID NOT NULL, + "orderId" UUID NOT NULL, + "position" INTEGER NOT NULL, + "name" TEXT NOT NULL, + "serviceId" UUID, + "qtyMilli" BIGINT NOT NULL DEFAULT 1000, + "unit" TEXT NOT NULL DEFAULT 'шт', + "priceCents" BIGINT NOT NULL, + "vat" "VatRate" NOT NULL DEFAULT 'none', + "sumCents" BIGINT NOT NULL, + "eventDate" TIMESTAMP(3), + CONSTRAINT "OrderItem_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "OrderItem_orderId_idx" ON "OrderItem"("orderId"); +CREATE INDEX "OrderItem_serviceId_idx" ON "OrderItem"("serviceId"); + +ALTER TABLE "OrderItem" ADD CONSTRAINT "OrderItem_orderId_fkey" + FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "OrderItem" ADD CONSTRAINT "OrderItem_serviceId_fkey" + FOREIGN KEY ("serviceId") REFERENCES "ServiceCatalog"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index c13b8c2..578e057 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -61,6 +61,15 @@ enum ProjectStatus { cancelled } +enum OrderStatus { + new // только пришла с сайта + accepted // менеджер принял (или клиент акцептовал оферту) + invoiced // выставлен счёт + paid // оплачено + fulfilled // услуга оказана, документы закрыты + cancelled +} + model Organization { id String @id @default(uuid()) @db.Uuid name String @@ -89,6 +98,81 @@ model Organization { auditLog AuditLog[] bankAccounts BankAccount[] projects Project[] + sites Site[] + orders Order[] +} + +model Site { + id String @id @default(uuid()) @db.Uuid + organizationId String @db.Uuid + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + name String // напр. "Голосования", "PrezLoad" + slug String // напр. "voting", "prezload" — используется в URL и человекочитаемых местах + domain String? // напр. "voting.queo.ru" — для верификации/CORS + apiKey String @unique // S2S ключ (длинная строка) + defaultOfferTemplateId String? @db.Uuid + defaultOfferTemplate DocumentTemplate? @relation("OfferTemplate", fields: [defaultOfferTemplateId], references: [id]) + archivedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + orders Order[] + + @@unique([organizationId, slug]) + @@index([organizationId]) +} + +model Order { + id String @id @default(uuid()) @db.Uuid + organizationId String @db.Uuid + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + siteId String? @db.Uuid + site Site? @relation(fields: [siteId], references: [id]) + projectId String? @db.Uuid // когда сконвертирована в проект + project Project? @relation(fields: [projectId], references: [id]) + status OrderStatus @default(new) + // данные клиента из заявки (могут не совпадать с записью Client — пока не привязан) + customerName String + customerInn String? + customerKpp String? + customerEmail String? + customerPhone String? + customerAddress String? + customerKind ClientKind @default(ul) + totalCents BigInt @default(0) + vatCents BigInt @default(0) + currency String @default("RUB") + acceptedOfferAt DateTime? // когда клиент принял оферту на сайте + notes String? + rawPayload Json? // что прислал сайт целиком — на случай неполного парсинга + archivedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + items OrderItem[] + + @@index([organizationId, status]) + @@index([organizationId, siteId]) + @@index([projectId]) +} + +model OrderItem { + id String @id @default(uuid()) @db.Uuid + orderId String @db.Uuid + order Order @relation(fields: [orderId], references: [id], onDelete: Cascade) + position Int + name String // отображаемое название позиции + serviceId String? @db.Uuid // привязка к каталогу, если совпало + service ServiceCatalog? @relation(fields: [serviceId], references: [id]) + qtyMilli BigInt @default(1000) + unit String @default("шт") + priceCents BigInt + vat VatRate @default(none) + sumCents BigInt + eventDate DateTime? // для услуг с привязкой к дате мероприятия (голосование 14 мая и т.д.) + + @@index([orderId]) + @@index([serviceId]) } model Project { @@ -110,6 +194,7 @@ model Project { updatedAt DateTime @updatedAt documents Document[] + orders Order[] @@index([organizationId, archivedAt]) @@index([organizationId, status]) @@ -173,6 +258,7 @@ model ServiceCatalog { updatedAt DateTime @updatedAt lines DocumentLine[] + orderItems OrderItem[] @@index([organizationId]) @@index([organizationId, archivedAt]) @@ -189,6 +275,7 @@ model DocumentTemplate { updatedAt DateTime @updatedAt defaultForProjects Project[] + defaultForSites Site[] @relation("OfferTemplate") @@index([organizationId, docType]) } diff --git a/apps/api/src/modules/orders/routes.ts b/apps/api/src/modules/orders/routes.ts new file mode 100644 index 0000000..2949613 --- /dev/null +++ b/apps/api/src/modules/orders/routes.ts @@ -0,0 +1,259 @@ +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import { Prisma } from '@prisma/client'; +import { prisma } from '../../db.js'; +import { getOrganizationId } from '../../lib/org.js'; +import { optionalText } from '../../lib/zod-utils.js'; + +const STATUSES = ['new', 'accepted', 'invoiced', 'paid', 'fulfilled', 'cancelled'] as const; +const VAT_VALUES = ['none', 'vat_0', 'vat_5', 'vat_7', 'vat_10', 'vat_20'] as const; + +// Внешний (S2S) payload от сайта — толерантный +const IncomingItem = z.object({ + name: z.string().min(1).max(500), + qty: z.coerce.number().positive().default(1), + unit: z.string().max(50).default('шт'), + priceRub: z.coerce.number().nonnegative(), // в рублях, чтобы сайту удобней + vat: z.enum(VAT_VALUES).default('none'), + eventDate: z.string().datetime().nullable().optional(), + serviceSlug: z.string().optional(), // если сайт знает наш каталог +}); + +const IncomingOrder = z.object({ + customerName: z.string().min(1).max(500), + customerEmail: optionalText(200), + customerPhone: optionalText(50), + customerInn: optionalText(20), + customerKpp: optionalText(20), + customerAddress: optionalText(1000), + customerKind: z.enum(['ul', 'ip', 'fl']).default('ul'), + notes: optionalText(2000), + acceptedOfferAt: z.string().datetime().nullable().optional(), + items: z.array(IncomingItem).min(1), +}); + +const ListQuery = z.object({ + status: z.enum(STATUSES).optional(), + siteId: z.string().uuid().optional(), + q: z.string().optional(), + limit: z.coerce.number().int().min(1).max(500).default(200), +}); + +function sumLines(items: { qty: number; priceRub: number; vat: (typeof VAT_VALUES)[number] }[]): { + totalCents: bigint; + vatCents: bigint; +} { + let total = 0n; + let vat = 0n; + for (const i of items) { + const lineSum = BigInt(Math.round(i.qty * i.priceRub * 100)); + total += lineSum; + if (i.vat !== 'none') { + const rate = Number(i.vat.replace('vat_', '')); + vat += (lineSum * BigInt(rate)) / BigInt(100 + rate); + } + } + return { totalCents: total, vatCents: vat }; +} + +export async function ordersRoutes(app: FastifyInstance) { + // ---- list ---- + app.get('/api/orders', { 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, siteId, q, limit } = parsed.data; + const items = await prisma.order.findMany({ + where: { + organizationId: orgId, + archivedAt: null, + ...(status ? { status } : {}), + ...(siteId ? { siteId } : {}), + ...(q ? { customerName: { contains: q, mode: 'insensitive' as const } } : {}), + }, + include: { + site: { select: { id: true, name: true, slug: true } }, + project: { select: { id: true, name: true } }, + _count: { select: { items: true } }, + }, + orderBy: { createdAt: 'desc' }, + take: limit, + }); + return { items }; + }); + + app.get('/api/orders/:id', { preHandler: app.requireDocPermission('viewer') }, async (req, reply) => { + const orgId = getOrganizationId(req); + const { id } = req.params as { id: string }; + const order = await prisma.order.findFirst({ + where: { id, organizationId: orgId }, + include: { + site: true, + project: { select: { id: true, name: true, status: true } }, + items: { orderBy: { position: 'asc' } }, + }, + }); + if (!order) { + reply.code(404).send({ error: 'not_found' }); + return; + } + return order; + }); + + // ---- S2S incoming ---- + // Сайт отправляет POST с заголовком X-Site-Key. ApiKey однозначно определяет site → organizationId. + app.post('/api/incoming/orders', async (req, reply) => { + const apiKey = (req.headers['x-site-key'] || req.headers['X-Site-Key']) as string | undefined; + if (!apiKey || typeof apiKey !== 'string' || apiKey.length < 10) { + reply.code(401).send({ error: 'missing_api_key' }); + return; + } + const site = await prisma.site.findFirst({ where: { apiKey, archivedAt: null } }); + if (!site) { + reply.code(401).send({ error: 'invalid_api_key' }); + return; + } + const parsed = IncomingOrder.safeParse(req.body); + if (!parsed.success) { + reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() }); + return; + } + const data = parsed.data; + const sums = sumLines(data.items); + + // Попытка матчинга services по slug (необяз., если сайт знает каталог) + const slugs = data.items.map((i) => i.serviceSlug).filter((s): s is string => !!s); + const matched = slugs.length + ? await prisma.serviceCatalog.findMany({ + where: { organizationId: site.organizationId, name: { in: slugs } }, + }) + : []; + const matchedByName = new Map(matched.map((s) => [s.name, s.id])); + + const created = await prisma.order.create({ + data: { + organizationId: site.organizationId, + siteId: site.id, + status: 'new', + customerName: data.customerName, + customerEmail: data.customerEmail, + customerPhone: data.customerPhone, + customerInn: data.customerInn, + customerKpp: data.customerKpp, + customerAddress: data.customerAddress, + customerKind: data.customerKind, + totalCents: sums.totalCents, + vatCents: sums.vatCents, + currency: 'RUB', + acceptedOfferAt: data.acceptedOfferAt ? new Date(data.acceptedOfferAt) : null, + notes: data.notes, + rawPayload: req.body as Prisma.InputJsonValue, + items: { + create: data.items.map((it, i) => { + const priceCents = BigInt(Math.round(it.priceRub * 100)); + const qtyMilli = BigInt(Math.round(it.qty * 1000)); + const sumCents = (priceCents * qtyMilli) / 1000n; + return { + position: i, + name: it.name, + serviceId: it.serviceSlug ? matchedByName.get(it.serviceSlug) ?? null : null, + qtyMilli, + unit: it.unit, + priceCents, + vat: it.vat, + sumCents, + eventDate: it.eventDate ? new Date(it.eventDate) : null, + }; + }), + }, + }, + include: { items: { orderBy: { position: 'asc' } } }, + }); + reply.code(201).send(created); + }); + + // ---- convert order → project ---- + app.post( + '/api/orders/:id/convert-to-project', + { preHandler: app.requireDocPermission('user') }, + async (req, reply) => { + const orgId = getOrganizationId(req); + const { id } = req.params as { id: string }; + const order = await prisma.order.findFirst({ + where: { id, organizationId: orgId }, + include: { items: { orderBy: { position: 'asc' } }, site: true }, + }); + if (!order) { + reply.code(404).send({ error: 'not_found' }); + return; + } + if (order.projectId) { + reply.code(409).send({ error: 'already_converted', projectId: order.projectId }); + return; + } + + // 1. Найти/создать клиента + let clientId: string | null = null; + if (order.customerInn) { + const existing = await prisma.client.findFirst({ + where: { organizationId: orgId, inn: order.customerInn }, + }); + if (existing) clientId = existing.id; + } + if (!clientId) { + const client = await prisma.client.create({ + data: { + organizationId: orgId, + kind: order.customerKind, + name: order.customerName, + inn: order.customerInn, + kpp: order.customerKpp, + address: order.customerAddress, + email: order.customerEmail, + phone: order.customerPhone, + }, + }); + clientId = client.id; + } + + // 2. Создать project + const projectName = order.site + ? `${order.site.name} — ${order.customerName} (${new Date(order.createdAt).toLocaleDateString('ru-RU')})` + : `Заявка — ${order.customerName}`; + const project = await prisma.project.create({ + data: { + organizationId: orgId, + name: projectName, + status: 'active', + defaultClientId: clientId, + defaultTemplateId: null, + defaultBankAccountId: null, + notes: order.notes, + }, + }); + + // 3. Связать order ← project + await prisma.order.update({ + where: { id: order.id }, + data: { projectId: project.id, status: order.status === 'new' ? 'accepted' : order.status }, + }); + + return { project, clientId }; + }, + ); + + app.delete('/api/orders/:id', { preHandler: app.requireDocPermission('user') }, async (req, reply) => { + const orgId = getOrganizationId(req); + const { id } = req.params as { id: string }; + const existing = await prisma.order.findFirst({ where: { id, organizationId: orgId } }); + if (!existing) { + reply.code(404).send({ error: 'not_found' }); + return; + } + await prisma.order.update({ where: { id }, data: { archivedAt: new Date() } }); + reply.code(204).send(); + }); +} diff --git a/apps/api/src/modules/sites/routes.ts b/apps/api/src/modules/sites/routes.ts new file mode 100644 index 0000000..102324c --- /dev/null +++ b/apps/api/src/modules/sites/routes.ts @@ -0,0 +1,110 @@ +import { randomBytes } from 'node:crypto'; +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 SiteUpsert = z.object({ + name: z.string().min(1).max(200), + slug: z.string().min(1).max(50).regex(/^[a-z0-9_-]+$/, 'только латиница, цифры, _ и -'), + domain: optionalText(200), + defaultOfferTemplateId: z.string().uuid().nullable(), +}); + +const SITE_SAFE = { + id: true, organizationId: true, name: true, slug: true, domain: true, + defaultOfferTemplateId: true, archivedAt: true, createdAt: true, updatedAt: true, +} as const; + +export async function sitesRoutes(app: FastifyInstance) { + app.get('/api/sites', { preHandler: app.requireDocPermission('viewer') }, async (req) => { + const orgId = getOrganizationId(req); + const items = await prisma.site.findMany({ + where: { organizationId: orgId, archivedAt: null }, + select: SITE_SAFE, + orderBy: { createdAt: 'asc' }, + }); + return { items }; + }); + + // ВКЛЮЧАЕТ apiKey — возвращается ТОЛЬКО владельцу через эту ручку + app.get('/api/sites/:id', { preHandler: app.requireDocPermission('admin') }, async (req, reply) => { + const orgId = getOrganizationId(req); + const { id } = req.params as { id: string }; + const site = await prisma.site.findFirst({ + where: { id, organizationId: orgId, archivedAt: null }, + }); + if (!site) { + reply.code(404).send({ error: 'not_found' }); + return; + } + return site; + }); + + app.post('/api/sites', { preHandler: app.requireDocPermission('admin') }, async (req, reply) => { + const orgId = getOrganizationId(req); + const parsed = SiteUpsert.safeParse(req.body); + if (!parsed.success) { + reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() }); + return; + } + const apiKey = randomBytes(24).toString('hex'); + try { + const created = await prisma.site.create({ + data: { ...parsed.data, apiKey, organizationId: orgId }, + }); + return created; + } catch (e) { + if ((e as { code?: string }).code === 'P2002') { + reply.code(409).send({ error: 'slug_taken', message: 'Slug уже занят в этой компании' }); + return; + } + throw e; + } + }); + + app.put('/api/sites/:id', { preHandler: app.requireDocPermission('admin') }, async (req, reply) => { + const orgId = getOrganizationId(req); + const { id } = req.params as { id: string }; + const parsed = SiteUpsert.safeParse(req.body); + if (!parsed.success) { + reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() }); + return; + } + const existing = await prisma.site.findFirst({ where: { id, organizationId: orgId } }); + if (!existing) { + reply.code(404).send({ error: 'not_found' }); + return; + } + return prisma.site.update({ where: { id }, data: parsed.data, select: SITE_SAFE }); + }); + + app.post( + '/api/sites/:id/regenerate-key', + { preHandler: app.requireDocPermission('admin') }, + async (req, reply) => { + const orgId = getOrganizationId(req); + const { id } = req.params as { id: string }; + const existing = await prisma.site.findFirst({ where: { id, organizationId: orgId } }); + if (!existing) { + reply.code(404).send({ error: 'not_found' }); + return; + } + const apiKey = randomBytes(24).toString('hex'); + return prisma.site.update({ where: { id }, data: { apiKey } }); + }, + ); + + app.delete('/api/sites/:id', { preHandler: app.requireDocPermission('admin') }, async (req, reply) => { + const orgId = getOrganizationId(req); + const { id } = req.params as { id: string }; + const existing = await prisma.site.findFirst({ where: { id, organizationId: orgId } }); + if (!existing) { + reply.code(404).send({ error: 'not_found' }); + return; + } + await prisma.site.update({ where: { id }, data: { archivedAt: new Date() } }); + reply.code(204).send(); + }); +} diff --git a/apps/api/src/modules/tochka/webhook.routes.ts b/apps/api/src/modules/tochka/webhook.routes.ts new file mode 100644 index 0000000..2e29758 --- /dev/null +++ b/apps/api/src/modules/tochka/webhook.routes.ts @@ -0,0 +1,159 @@ +import type { FastifyInstance } from 'fastify'; +import { Prisma } from '@prisma/client'; +import { prisma } from '../../db.js'; +import { env } from '../../env.js'; + +// Точка не подписывает webhook-ы; защита — секрет в URL (rotatable через .env), +// raw-store перед обработкой, dedupe по комбинации (source, eventType, externalId). + +type TochkaWebhookPayload = { + webhookType?: string; + paymentId?: string; + amount?: number; + payerInn?: string; + payerName?: string; + purpose?: string; + paidAt?: string; + Data?: TochkaWebhookPayload; +}; + +function flatten(p: TochkaWebhookPayload): TochkaWebhookPayload { + return { ...p, ...(p.Data ?? {}) }; +} + +function extractDocNumber(purpose: string | undefined): string | null { + if (!purpose) return null; + // популярные форматы: «Оплата по счёту № 123 от ...», «по счёту №ДГ-2026/001», «инвойс 123» + const m = purpose.match(/(?:сч[её]т|инвойс|invoice|№)[^\dA-ZА-Я]*([A-ZА-Я0-9][A-ZА-Я0-9\-_/]{0,49})/i); + return m && m[1] ? m[1].replace(/[.,;:]+$/, '') : null; +} + +const PAYMENT_KIND_MAP: Record = { + incomingPayment: 'incoming', + incomingSbpPayment: 'incoming_sbp', + incomingSbpB2BPayment: 'incoming_sbp_b2b', + outgoingPayment: 'outgoing', +}; + +export async function tochkaWebhookRoutes(app: FastifyInstance) { + app.post('/webhooks/tochka/:secret', async (req, reply) => { + const { secret } = req.params as { secret: string }; + if (!env.TOCHKA_WEBHOOK_SECRET || secret !== env.TOCHKA_WEBHOOK_SECRET) { + reply.code(404).send({ error: 'not_found' }); + return; + } + + const body = (req.body ?? {}) as TochkaWebhookPayload; + const flat = flatten(body); + const eventType = flat.webhookType ?? 'unknown'; + const externalId = flat.paymentId ?? null; + + const dedupeKey = `tochka:${eventType}:${externalId ?? JSON.stringify(flat).slice(0, 100)}`; + + let event; + try { + event = await prisma.webhookEvent.create({ + data: { + source: 'tochka', + eventType, + dedupeKey, + raw: body as Prisma.InputJsonValue, + }, + }); + } catch (e) { + if ((e as { code?: string }).code === 'P2002') { + app.log.info({ dedupeKey }, 'webhook dedupe hit'); + return { ok: true, deduped: true }; + } + throw e; + } + + try { + const kind = PAYMENT_KIND_MAP[eventType]; + if (!kind || !externalId) { + await prisma.webhookEvent.update({ + where: { id: event.id }, + data: { processedAt: new Date(), error: 'unsupported_event_type' }, + }); + return { ok: true, ignored: true }; + } + + let docId: string | null = null; + const docNumber = extractDocNumber(flat.purpose); + if (docNumber) { + const doc = await prisma.document.findFirst({ + where: { number: docNumber, docType: 'invoice' }, + select: { id: true, organizationId: true }, + }); + if (doc) docId = doc.id; + } + + const fallbackOrg = await prisma.organization.findFirst({ where: { archivedAt: null }, select: { id: true } }); + const orgId = docId + ? (await prisma.document.findUnique({ where: { id: docId }, select: { organizationId: true } }))!.organizationId + : fallbackOrg!.id; + + const amount = typeof flat.amount === 'number' ? Math.round(flat.amount * 100) : 0; + try { + await prisma.payment.create({ + data: { + organizationId: orgId, + documentId: docId, + tochkaPaymentId: externalId, + kind, + amountCents: BigInt(amount), + payerInn: flat.payerInn ?? null, + payerName: flat.payerName ?? null, + purpose: flat.purpose ?? null, + paidAt: flat.paidAt ? new Date(flat.paidAt) : null, + raw: body as Prisma.InputJsonValue, + }, + }); + } catch (e) { + if ((e as { code?: string }).code !== 'P2002') throw e; + } + + if (docId) { + const doc = await prisma.document.findUnique({ + where: { id: docId }, + include: { payments: true }, + }); + if (doc) { + const incoming = doc.payments + .filter((p) => p.kind === 'incoming' || p.kind === 'incoming_sbp' || p.kind === 'incoming_sbp_b2b') + .reduce((s, p) => s + p.amountCents, 0n); + const newStatus = + incoming >= doc.totalCents ? 'paid' : incoming > 0n ? 'partially_paid' : doc.status; + if (newStatus !== doc.status) { + await prisma.document.update({ where: { id: docId }, data: { status: newStatus } }); + } + + // Если документ привязан к Order через project → обновить Order.status + if (doc.projectId) { + const order = await prisma.order.findFirst({ + where: { projectId: doc.projectId }, + select: { id: true }, + }); + if (order && newStatus === 'paid') { + await prisma.order.update({ where: { id: order.id }, data: { status: 'paid' } }); + } + } + } + } + + await prisma.webhookEvent.update({ + where: { id: event.id }, + data: { processedAt: new Date() }, + }); + return { ok: true, matchedDocument: docId }; + } catch (e) { + const msg = (e as Error).message ?? 'unknown'; + app.log.error({ err: e, dedupeKey }, 'webhook processing failed'); + await prisma.webhookEvent.update({ + where: { id: event.id }, + data: { error: msg.slice(0, 500) }, + }); + return { ok: false, error: 'processing_failed' }; + } + }); +} diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 91407c1..c40631c 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -19,6 +19,9 @@ import { templatesImportRoutes } from './modules/templates/import.routes.js'; import { projectsRoutes } from './modules/projects/routes.js'; import { tochkaRoutes } from './modules/tochka/routes.js'; import { tochkaIssueRoutes } from './modules/tochka/issue.routes.js'; +import { tochkaWebhookRoutes } from './modules/tochka/webhook.routes.js'; +import { sitesRoutes } from './modules/sites/routes.js'; +import { ordersRoutes } from './modules/orders/routes.js'; import { dadataRoutes } from './modules/dadata/routes.js'; import { shutdownBrowser } from './modules/documents/pdf.js'; import activeOrgPlugin from './plugins/activeOrg.js'; @@ -60,6 +63,9 @@ async function main() { await app.register(projectsRoutes); await app.register(tochkaRoutes); await app.register(tochkaIssueRoutes); + await app.register(tochkaWebhookRoutes); + await app.register(sitesRoutes); + await app.register(ordersRoutes); await app.register(dadataRoutes); app.addHook('onClose', async () => { diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 6bc0c22..f8ace5c 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -10,6 +10,8 @@ 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 { OrdersPage, OrderViewPage } from './pages/Orders.js'; +import { SitesPage } from './pages/Sites.js'; import { OrgSwitcher } from './components/OrgSwitcher.js'; function displayName(me: Me): string { @@ -22,10 +24,12 @@ function Layout({ me }: { me: Me }) {

Doc_manager

@@ -136,6 +140,9 @@ export function App() { } /> } /> } /> + } /> + } /> + } /> } /> diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index a92723f..f089a6f 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -287,6 +287,65 @@ export type Project = ProjectSummary & { defaultBankAccount: BankAccount | null; }; +export type Site = { + id: string; + organizationId: string; + name: string; + slug: string; + domain: string | null; + apiKey?: string; // только в детальном GET (admin) + defaultOfferTemplateId: string | null; + archivedAt: string | null; + createdAt: string; + updatedAt: string; +}; + +export type OrderStatus = 'new' | 'accepted' | 'invoiced' | 'paid' | 'fulfilled' | 'cancelled'; + +export type OrderItem = { + id: string; + orderId: string; + position: number; + name: string; + serviceId: string | null; + qtyMilli: number; + unit: string; + priceCents: number; + vat: VatRate; + sumCents: number; + eventDate: string | null; +}; + +export type OrderSummary = { + id: string; + organizationId: string; + siteId: string | null; + site: { id: string; name: string; slug: string } | null; + projectId: string | null; + project: { id: string; name: string } | null; + status: OrderStatus; + customerName: string; + customerInn: string | null; + customerEmail: string | null; + customerPhone: string | null; + totalCents: number; + vatCents: number; + currency: string; + acceptedOfferAt: string | null; + notes: string | null; + createdAt: string; + updatedAt: string; + _count?: { items: number }; +}; + +export type Order = OrderSummary & { + customerKpp: string | null; + customerAddress: string | null; + customerKind: 'ul' | 'ip' | 'fl'; + rawPayload: unknown; + items: OrderItem[]; +}; + export type TochkaEnv = 'sandbox' | 'prod'; export type TochkaCredential = { diff --git a/apps/web/src/pages/Orders.tsx b/apps/web/src/pages/Orders.tsx new file mode 100644 index 0000000..e6effa3 --- /dev/null +++ b/apps/web/src/pages/Orders.tsx @@ -0,0 +1,214 @@ +import { useEffect, useState } from 'react'; +import { Link, useNavigate, useParams } from 'react-router-dom'; +import { api, ApiError, type Order, type OrderStatus, type OrderSummary } from '../api.js'; +import { Button, EmptyState, Select, formatRub } from '../components/ui.js'; + +const STATUS_LABEL: Record = { + new: 'Новая', + accepted: 'Принята', + invoiced: 'Счёт выставлен', + paid: 'Оплачена', + fulfilled: 'Выполнена', + cancelled: 'Отменена', +}; + +export function OrdersPage() { + const [items, setItems] = useState(null); + const [status, setStatus] = useState(''); + const [error, setError] = useState(null); + const navigate = useNavigate(); + + async function load() { + try { + const params = new URLSearchParams(); + if (status) params.set('status', status); + const r = await api.get<{ items: OrderSummary[] }>(`/api/orders?${params.toString()}`); + setItems(r.items); + } catch (e) { + setError(String(e)); + } + } + useEffect(() => { void load(); /* eslint-disable-next-line */ }, [status]); + + return ( +
+
+

Заявки

+
+ +
+