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:
@@ -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