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
+7
View File
@@ -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>
</>
+59
View File
@@ -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 = {
+214
View File
@@ -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>
);
}
+181
View File
@@ -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>
);
}