feat(orders): Site/Order/OrderItem + S2S incoming endpoint + Tochka webhook receiver
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/<secret> — 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/🆔 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||||
@@ -61,6 +61,15 @@ enum ProjectStatus {
|
|||||||
cancelled
|
cancelled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum OrderStatus {
|
||||||
|
new // только пришла с сайта
|
||||||
|
accepted // менеджер принял (или клиент акцептовал оферту)
|
||||||
|
invoiced // выставлен счёт
|
||||||
|
paid // оплачено
|
||||||
|
fulfilled // услуга оказана, документы закрыты
|
||||||
|
cancelled
|
||||||
|
}
|
||||||
|
|
||||||
model Organization {
|
model Organization {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
name String
|
name String
|
||||||
@@ -89,6 +98,81 @@ model Organization {
|
|||||||
auditLog AuditLog[]
|
auditLog AuditLog[]
|
||||||
bankAccounts BankAccount[]
|
bankAccounts BankAccount[]
|
||||||
projects Project[]
|
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 {
|
model Project {
|
||||||
@@ -110,6 +194,7 @@ model Project {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
documents Document[]
|
documents Document[]
|
||||||
|
orders Order[]
|
||||||
|
|
||||||
@@index([organizationId, archivedAt])
|
@@index([organizationId, archivedAt])
|
||||||
@@index([organizationId, status])
|
@@index([organizationId, status])
|
||||||
@@ -173,6 +258,7 @@ model ServiceCatalog {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
lines DocumentLine[]
|
lines DocumentLine[]
|
||||||
|
orderItems OrderItem[]
|
||||||
|
|
||||||
@@index([organizationId])
|
@@index([organizationId])
|
||||||
@@index([organizationId, archivedAt])
|
@@index([organizationId, archivedAt])
|
||||||
@@ -189,6 +275,7 @@ model DocumentTemplate {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
defaultForProjects Project[]
|
defaultForProjects Project[]
|
||||||
|
defaultForSites Site[] @relation("OfferTemplate")
|
||||||
|
|
||||||
@@index([organizationId, docType])
|
@@index([organizationId, docType])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<string, 'incoming' | 'incoming_sbp' | 'incoming_sbp_b2b' | 'outgoing'> = {
|
||||||
|
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' };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -19,6 +19,9 @@ import { templatesImportRoutes } from './modules/templates/import.routes.js';
|
|||||||
import { projectsRoutes } from './modules/projects/routes.js';
|
import { projectsRoutes } from './modules/projects/routes.js';
|
||||||
import { tochkaRoutes } from './modules/tochka/routes.js';
|
import { tochkaRoutes } from './modules/tochka/routes.js';
|
||||||
import { tochkaIssueRoutes } from './modules/tochka/issue.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 { dadataRoutes } from './modules/dadata/routes.js';
|
||||||
import { shutdownBrowser } from './modules/documents/pdf.js';
|
import { shutdownBrowser } from './modules/documents/pdf.js';
|
||||||
import activeOrgPlugin from './plugins/activeOrg.js';
|
import activeOrgPlugin from './plugins/activeOrg.js';
|
||||||
@@ -60,6 +63,9 @@ async function main() {
|
|||||||
await app.register(projectsRoutes);
|
await app.register(projectsRoutes);
|
||||||
await app.register(tochkaRoutes);
|
await app.register(tochkaRoutes);
|
||||||
await app.register(tochkaIssueRoutes);
|
await app.register(tochkaIssueRoutes);
|
||||||
|
await app.register(tochkaWebhookRoutes);
|
||||||
|
await app.register(sitesRoutes);
|
||||||
|
await app.register(ordersRoutes);
|
||||||
await app.register(dadataRoutes);
|
await app.register(dadataRoutes);
|
||||||
|
|
||||||
app.addHook('onClose', async () => {
|
app.addHook('onClose', async () => {
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { TemplatesPage } from './pages/Templates.js';
|
|||||||
import { TemplateEditPage } from './pages/TemplateEdit.js';
|
import { TemplateEditPage } from './pages/TemplateEdit.js';
|
||||||
import { ProjectsPage } from './pages/Projects.js';
|
import { ProjectsPage } from './pages/Projects.js';
|
||||||
import { ProjectEditPage } from './pages/ProjectEdit.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';
|
import { OrgSwitcher } from './components/OrgSwitcher.js';
|
||||||
|
|
||||||
function displayName(me: Me): string {
|
function displayName(me: Me): string {
|
||||||
@@ -22,10 +24,12 @@ function Layout({ me }: { me: Me }) {
|
|||||||
<h1>Doc_manager</h1>
|
<h1>Doc_manager</h1>
|
||||||
<nav>
|
<nav>
|
||||||
<Link to="/projects">Проекты</Link>
|
<Link to="/projects">Проекты</Link>
|
||||||
|
<Link to="/orders">Заявки</Link>
|
||||||
<Link to="/">Документы</Link>
|
<Link to="/">Документы</Link>
|
||||||
<Link to="/clients">Клиенты</Link>
|
<Link to="/clients">Клиенты</Link>
|
||||||
<Link to="/services">Услуги</Link>
|
<Link to="/services">Услуги</Link>
|
||||||
<Link to="/templates">Шаблоны</Link>
|
<Link to="/templates">Шаблоны</Link>
|
||||||
|
<Link to="/sites">Сайты</Link>
|
||||||
<Link to="/companies">Компании</Link>
|
<Link to="/companies">Компании</Link>
|
||||||
</nav>
|
</nav>
|
||||||
<OrgSwitcher />
|
<OrgSwitcher />
|
||||||
@@ -136,6 +140,9 @@ export function App() {
|
|||||||
<Route path="/companies/:id" element={<CompanyEditPage />} />
|
<Route path="/companies/:id" element={<CompanyEditPage />} />
|
||||||
<Route path="/projects" element={<ProjectsPage />} />
|
<Route path="/projects" element={<ProjectsPage />} />
|
||||||
<Route path="/projects/:id" element={<ProjectEditPage />} />
|
<Route path="/projects/:id" element={<ProjectEditPage />} />
|
||||||
|
<Route path="/orders" element={<OrdersPage />} />
|
||||||
|
<Route path="/orders/:id" element={<OrderViewPage />} />
|
||||||
|
<Route path="/sites" element={<SitesPage />} />
|
||||||
<Route path="*" element={<Placeholder title="Не найдено" />} />
|
<Route path="*" element={<Placeholder title="Не найдено" />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -287,6 +287,65 @@ export type Project = ProjectSummary & {
|
|||||||
defaultBankAccount: BankAccount | null;
|
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 TochkaEnv = 'sandbox' | 'prod';
|
||||||
|
|
||||||
export type TochkaCredential = {
|
export type TochkaCredential = {
|
||||||
|
|||||||
@@ -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<OrderStatus, string> = {
|
||||||
|
new: 'Новая',
|
||||||
|
accepted: 'Принята',
|
||||||
|
invoiced: 'Счёт выставлен',
|
||||||
|
paid: 'Оплачена',
|
||||||
|
fulfilled: 'Выполнена',
|
||||||
|
cancelled: 'Отменена',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function OrdersPage() {
|
||||||
|
const [items, setItems] = useState<OrderSummary[] | null>(null);
|
||||||
|
const [status, setStatus] = useState<OrderStatus | ''>('');
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<main className="content">
|
||||||
|
<header className="page-head">
|
||||||
|
<h2>Заявки</h2>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="toolbar">
|
||||||
|
<Select
|
||||||
|
label=""
|
||||||
|
value={status}
|
||||||
|
onChange={(v) => setStatus(v as OrderStatus | '')}
|
||||||
|
options={[
|
||||||
|
{ value: '', label: 'Все статусы' },
|
||||||
|
{ value: 'new', label: 'Новые' },
|
||||||
|
{ value: 'accepted', label: 'Принятые' },
|
||||||
|
{ value: 'invoiced', label: 'Счёт выставлен' },
|
||||||
|
{ value: 'paid', label: 'Оплачены' },
|
||||||
|
{ value: 'fulfilled', label: 'Выполнены' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? <div className="error-text">{error}</div> : null}
|
||||||
|
|
||||||
|
{items === null ? <p className="hint">Загрузка…</p>
|
||||||
|
: items.length === 0 ? (
|
||||||
|
<EmptyState>
|
||||||
|
Заявок пока нет. Они появятся когда сайт пришлёт <code>POST /api/incoming/orders</code> с API-ключом.
|
||||||
|
Настроить сайты — в разделе «Сайты».
|
||||||
|
</EmptyState>
|
||||||
|
) : (
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Дата</th>
|
||||||
|
<th>Источник</th>
|
||||||
|
<th>Клиент</th>
|
||||||
|
<th>Сумма</th>
|
||||||
|
<th>Проект</th>
|
||||||
|
<th>Статус</th>
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((o) => (
|
||||||
|
<tr key={o.id}>
|
||||||
|
<td>{new Date(o.createdAt).toLocaleDateString('ru-RU')}</td>
|
||||||
|
<td>{o.site?.name ?? <span className="hint">manual</span>}</td>
|
||||||
|
<td>
|
||||||
|
<Link to={`/orders/${o.id}`}>{o.customerName}</Link>
|
||||||
|
{o.customerInn ? <div className="hint">ИНН {o.customerInn}</div> : null}
|
||||||
|
</td>
|
||||||
|
<td className="num">{formatRub(o.totalCents)}</td>
|
||||||
|
<td>
|
||||||
|
{o.project ? <Link to={`/projects/${o.project.id}`}>{o.project.name}</Link> : <span className="hint">—</span>}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className={`status status--${o.status === 'new' ? 'issued' : o.status === 'paid' ? 'paid' : o.status === 'cancelled' ? 'cancelled' : 'issued'}`}>
|
||||||
|
{STATUS_LABEL[o.status]}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="row-actions">
|
||||||
|
<Button variant="ghost" onClick={() => navigate(`/orders/${o.id}`)}>Открыть</Button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OrderViewPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [order, setOrder] = useState<Order | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [converting, setConverting] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id) return;
|
||||||
|
api.get<Order>(`/api/orders/${id}`).then(setOrder).catch((e) => setError(String(e)));
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
async function convertToProject() {
|
||||||
|
if (!id) return;
|
||||||
|
setConverting(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const r = await api.post<{ project: { id: string } }>(`/api/orders/${id}/convert-to-project`, {});
|
||||||
|
navigate(`/projects/${r.project.id}`);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError && e.code === 'already_converted') {
|
||||||
|
const projectId = (e.details as { projectId?: string })?.projectId;
|
||||||
|
if (projectId) navigate(`/projects/${projectId}`);
|
||||||
|
} else {
|
||||||
|
setError(e instanceof ApiError ? e.prettyMessage() : String(e));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setConverting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!order && !error) return <main className="content"><p className="hint">Загрузка…</p></main>;
|
||||||
|
if (error) return <main className="content"><div className="error-text">{error}</div></main>;
|
||||||
|
if (!order) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="content">
|
||||||
|
<header className="page-head">
|
||||||
|
<h2>Заявка {order.site ? `с ${order.site.name}` : 'manual'}</h2>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<Button onClick={() => navigate('/orders')}>← К заявкам</Button>
|
||||||
|
{order.project ? (
|
||||||
|
<Button variant="primary" onClick={() => navigate(`/projects/${order.project!.id}`)}>
|
||||||
|
Открыть проект →
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button variant="primary" onClick={convertToProject} disabled={converting}>
|
||||||
|
{converting ? 'Конвертирую…' : 'Перевести в проект →'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="form-grid">
|
||||||
|
<Field label="Клиент" value={order.customerName} />
|
||||||
|
<Field label="ИНН" value={order.customerInn ?? '—'} />
|
||||||
|
<Field label="Email" value={order.customerEmail ?? '—'} />
|
||||||
|
<Field label="Телефон" value={order.customerPhone ?? '—'} />
|
||||||
|
<Field label="Статус" value={STATUS_LABEL[order.status]} />
|
||||||
|
<Field label="Создано" value={new Date(order.createdAt).toLocaleString('ru-RU')} />
|
||||||
|
{order.acceptedOfferAt ? (
|
||||||
|
<Field label="Оферта принята" value={new Date(order.acceptedOfferAt).toLocaleString('ru-RU')} />
|
||||||
|
) : null}
|
||||||
|
<Field label="Сумма" value={formatRub(order.totalCents)} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<h3 style={{ marginTop: 24 }}>Позиции</h3>
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>№</th>
|
||||||
|
<th>Услуга</th>
|
||||||
|
<th>Дата</th>
|
||||||
|
<th>Кол-во</th>
|
||||||
|
<th>Ед.</th>
|
||||||
|
<th>Цена</th>
|
||||||
|
<th>НДС</th>
|
||||||
|
<th className="num">Сумма</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{order.items.map((it) => (
|
||||||
|
<tr key={it.id}>
|
||||||
|
<td>{it.position + 1}</td>
|
||||||
|
<td>{it.name}</td>
|
||||||
|
<td>{it.eventDate ? new Date(it.eventDate).toLocaleDateString('ru-RU') : '—'}</td>
|
||||||
|
<td>{(it.qtyMilli / 1000).toFixed(it.qtyMilli % 1000 === 0 ? 0 : 3)}</td>
|
||||||
|
<td>{it.unit}</td>
|
||||||
|
<td className="num">{formatRub(it.priceCents)}</td>
|
||||||
|
<td>{it.vat === 'none' ? 'Без НДС' : it.vat.replace('vat_', '') + '%'}</td>
|
||||||
|
<td className="num">{formatRub(Number(it.sumCents))}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{order.notes ? <p style={{ marginTop: 16 }}><b>Заметки:</b> {order.notes}</p> : null}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="field">
|
||||||
|
<span className="field__label">{label}</span>
|
||||||
|
<div style={{ padding: '8px 10px', borderRadius: 6, background: 'rgba(127,127,127,0.06)' }}>{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { api, ApiError, type Site } from '../api.js';
|
||||||
|
import { Button, EmptyState, Field, Modal } from '../components/ui.js';
|
||||||
|
|
||||||
|
export function SitesPage() {
|
||||||
|
const [items, setItems] = useState<Site[] | null>(null);
|
||||||
|
const [creating, setCreating] = useState<{ name: string; slug: string; domain: string } | null>(null);
|
||||||
|
const [createdKey, setCreatedKey] = useState<{ siteName: string; apiKey: string } | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const r = await api.get<{ items: Site[] }>('/api/sites');
|
||||||
|
setItems(r.items);
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
useEffect(() => { void load(); }, []);
|
||||||
|
|
||||||
|
async function create() {
|
||||||
|
if (!creating) return;
|
||||||
|
setFieldErrors({});
|
||||||
|
try {
|
||||||
|
const site = await api.post<Site & { apiKey: string }>('/api/sites', {
|
||||||
|
name: creating.name,
|
||||||
|
slug: creating.slug,
|
||||||
|
domain: creating.domain || null,
|
||||||
|
defaultOfferTemplateId: null,
|
||||||
|
});
|
||||||
|
setCreatedKey({ siteName: site.name, apiKey: site.apiKey });
|
||||||
|
setCreating(null);
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError) {
|
||||||
|
const fe = e.fieldErrors();
|
||||||
|
setFieldErrors(fe);
|
||||||
|
setError(Object.keys(fe).length ? 'Проверьте подсвеченные поля.' : e.prettyMessage());
|
||||||
|
} else {
|
||||||
|
setError(String(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function regenerateKey(site: Site) {
|
||||||
|
if (!confirm(`Перевыпустить API-ключ для «${site.name}»? Старый перестанет работать сразу.`)) return;
|
||||||
|
try {
|
||||||
|
const updated = await api.post<Site & { apiKey: string }>(`/api/sites/${site.id}/regenerate-key`, {});
|
||||||
|
setCreatedKey({ siteName: updated.name, apiKey: updated.apiKey });
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function archive(s: Site) {
|
||||||
|
if (!confirm(`Архивировать сайт «${s.name}»?`)) return;
|
||||||
|
await api.del(`/api/sites/${s.id}`);
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="content">
|
||||||
|
<header className="page-head">
|
||||||
|
<h2>Сайты-источники</h2>
|
||||||
|
<Button variant="primary" onClick={() => setCreating({ name: '', slug: '', domain: '' })}>
|
||||||
|
+ Добавить
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<p className="hint">
|
||||||
|
Сайты, с которых приходят заявки. Каждый получает свой API-ключ — сайт отправляет
|
||||||
|
<code> POST https://doc.queo.ru/api/incoming/orders</code> с заголовком <code>X-Site-Key</code>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error ? <div className="error-text">{error}</div> : null}
|
||||||
|
|
||||||
|
{items === null ? <p className="hint">Загрузка…</p>
|
||||||
|
: items.length === 0 ? (
|
||||||
|
<EmptyState>Сайтов ещё нет. Добавь первый — выдадим API-ключ для S2S.</EmptyState>
|
||||||
|
) : (
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Название</th>
|
||||||
|
<th>slug</th>
|
||||||
|
<th>Домен</th>
|
||||||
|
<th>Создан</th>
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((s) => (
|
||||||
|
<tr key={s.id}>
|
||||||
|
<td>{s.name}</td>
|
||||||
|
<td><code>{s.slug}</code></td>
|
||||||
|
<td>{s.domain ?? '—'}</td>
|
||||||
|
<td>{new Date(s.createdAt).toLocaleDateString('ru-RU')}</td>
|
||||||
|
<td className="row-actions">
|
||||||
|
<Button variant="ghost" onClick={() => regenerateKey(s)}>Перевыпустить ключ</Button>
|
||||||
|
<Button variant="danger" onClick={() => archive(s)}>В архив</Button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={creating !== null}
|
||||||
|
title="Новый сайт"
|
||||||
|
onClose={() => setCreating(null)}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" onClick={() => setCreating(null)}>Отмена</Button>
|
||||||
|
<Button variant="primary" onClick={create}>Создать</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{creating ? (
|
||||||
|
<div className="form-grid">
|
||||||
|
<Field
|
||||||
|
label="Название"
|
||||||
|
value={creating.name}
|
||||||
|
onChange={(e) => setCreating({ ...creating, name: e.target.value })}
|
||||||
|
placeholder="Голосования"
|
||||||
|
error={fieldErrors.name}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="slug"
|
||||||
|
value={creating.slug}
|
||||||
|
onChange={(e) => setCreating({ ...creating, slug: e.target.value.toLowerCase().replace(/[^a-z0-9_-]/g, '-') })}
|
||||||
|
placeholder="voting"
|
||||||
|
error={fieldErrors.slug}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="Домен (опц.)"
|
||||||
|
value={creating.domain}
|
||||||
|
onChange={(e) => setCreating({ ...creating, domain: e.target.value })}
|
||||||
|
placeholder="voting.queo.ru"
|
||||||
|
error={fieldErrors.domain}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={createdKey !== null}
|
||||||
|
title={createdKey ? `API-ключ для «${createdKey.siteName}»` : ''}
|
||||||
|
onClose={() => setCreatedKey(null)}
|
||||||
|
footer={<Button variant="primary" onClick={() => setCreatedKey(null)}>Сохранил, закрыть</Button>}
|
||||||
|
>
|
||||||
|
{createdKey ? (
|
||||||
|
<div>
|
||||||
|
<p className="hint">⚠️ Ключ показывается только сейчас. Сохрани его в безопасном месте — иначе придётся перевыпустить.</p>
|
||||||
|
<pre style={{ background: '#f1f5f9', padding: 12, borderRadius: 6, overflow: 'auto', userSelect: 'all' }}>
|
||||||
|
{createdKey.apiKey}
|
||||||
|
</pre>
|
||||||
|
<p className="hint" style={{ marginTop: 12 }}>Использование на сайте:</p>
|
||||||
|
<pre style={{ background: '#f1f5f9', padding: 12, borderRadius: 6, overflow: 'auto', fontSize: 12 }}>
|
||||||
|
{`POST https://doc.queo.ru/api/incoming/orders
|
||||||
|
X-Site-Key: ${createdKey.apiKey}
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"customerName": "ООО ...",
|
||||||
|
"customerEmail": "ivan@example.com",
|
||||||
|
"customerInn": "7707083893",
|
||||||
|
"acceptedOfferAt": "2026-06-16T10:00:00Z",
|
||||||
|
"items": [
|
||||||
|
{ "name": "Голосование 14 мая", "qty": 1, "unit": "день", "priceRub": 10000, "vat": "none", "eventDate": "2026-05-14" }
|
||||||
|
]
|
||||||
|
}`}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Modal>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user