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:
admin
2026-06-16 15:00:24 +03:00
parent 0c6deed98d
commit c2fcdec85d
10 changed files with 1169 additions and 0 deletions
@@ -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;
+87
View File
@@ -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])
}