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])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user