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
+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])
}