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
|
||||
}
|
||||
|
||||
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])
|
||||
}
|
||||
|
||||
@@ -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 { 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 () => {
|
||||
|
||||
@@ -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 }) {
|
||||
<h1>Doc_manager</h1>
|
||||
<nav>
|
||||
<Link to="/projects">Проекты</Link>
|
||||
<Link to="/orders">Заявки</Link>
|
||||
<Link to="/">Документы</Link>
|
||||
<Link to="/clients">Клиенты</Link>
|
||||
<Link to="/services">Услуги</Link>
|
||||
<Link to="/templates">Шаблоны</Link>
|
||||
<Link to="/sites">Сайты</Link>
|
||||
<Link to="/companies">Компании</Link>
|
||||
</nav>
|
||||
<OrgSwitcher />
|
||||
@@ -136,6 +140,9 @@ export function App() {
|
||||
<Route path="/companies/:id" element={<CompanyEditPage />} />
|
||||
<Route path="/projects" element={<ProjectsPage />} />
|
||||
<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="Не найдено" />} />
|
||||
</Routes>
|
||||
</>
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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