Add Checkbox ETTN receipts for Nova Poshta COD waybills

Cashier creates an ETTN receipt in Checkbox bound to the TTN with payment
control; Checkbox fiscalizes it itself when the parcel is paid for.

- cash_registers (Fernet-encrypted license key / PIN) and receipts tables
- Checkbox HTTP client + stub (ETTN does not work on test registers)
- two-phase create via ARQ job, timeout reconciliation, cron status polling
- /receipts and /cash-registers API, audit records
- dashboard: per-order and bulk create, prepayment, cancel; cash registers page

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-23 23:39:00 +03:00
co-authored by Claude Opus 5.5
parent d13e7ce2b3
commit 518a99197f
44 changed files with 2931 additions and 20 deletions
+159 -11
View File
@@ -1,12 +1,17 @@
import { useQueryClient } from '@tanstack/react-query'
import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { deleteOrder } from '@/api/orders'
import { cancelReceipt, createReceipts } from '@/api/receipts'
import '@/pages/DashboardPage.css'
import { useAuth } from '@/features/auth/useAuth'
import { OrderDetailModal } from '@/features/orders/OrderDetailModal'
import type { Order } from '@/features/orders/types'
import { useOrders } from '@/features/orders/useOrders'
import { defaultPrepayment, prepaymentMatches, toKopecks } from '@/features/receipts/money'
import { CANCELLABLE, RECEIPT_STATUS } from '@/features/receipts/types'
import type { ReceiptRequestItem } from '@/features/receipts/types'
const ROLE_LABEL: Record<string, string> = {
admin: 'Администратор',
@@ -29,6 +34,11 @@ function npStatusTone(order: Order): 'delivered' | 'processing' | 'danger' | 'ne
return 'new'
}
/** Заказ можно отправить в Checkbox: есть ТТН и сумма контроля оплаты. */
function canCreateReceipt(order: Order): boolean {
return Boolean(order.waybill_number && order.np_cod_amount)
}
function paymentBadge(order: Order): { label: string; tone: 'delivered' | 'processing' } | null {
if (order.np_payment_status === 'Payed') return { label: 'Оплачено', tone: 'delivered' }
if (order.np_payment_status === 'NeedPayment') return { label: 'Не оплачено', tone: 'processing' }
@@ -44,8 +54,67 @@ export function DashboardPage() {
const [search, setSearch] = useState('')
const [viewingOrder, setViewingOrder] = useState<Order | null>(null)
const [deletingId, setDeletingId] = useState<string | null>(null)
// Введённая кассиром предоплата (₴) по заказу; нет ключа — значение по умолчанию.
const [prepayments, setPrepayments] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
const [cancellingId, setCancellingId] = useState<string | null>(null)
const [notice, setNotice] = useState<{ tone: 'ok' | 'error'; lines: string[] } | null>(null)
const canDelete = user?.role !== 'viewer'
const canFiscalize = user?.role === 'admin' || user?.role === 'cashier'
function prepaymentOf(order: Order): string {
return prepayments[order.id] ?? defaultPrepayment(order.total_amount, order.np_cod_amount)
}
async function submitReceipts(targets: Order[]) {
const items: ReceiptRequestItem[] = []
const localErrors: string[] = []
for (const order of targets) {
const prepayment = toKopecks(prepaymentOf(order))
if (prepayment === null) {
localErrors.push(`${order.id}: некорректная сумма предоплаты`)
continue
}
items.push({ order_id: order.id, prepayment_kopecks: prepayment })
}
if (items.length === 0) {
setNotice({ tone: 'error', lines: localErrors })
return
}
setSubmitting(true)
try {
const result = await createReceipts(items)
const errors = [...localErrors, ...Object.entries(result.errors).map(([id, msg]) => `${id}: ${msg}`)]
const lines = [
...(result.created.length ? [`Отправлено в Checkbox: ${result.created.length}`] : []),
...errors,
]
setNotice({ tone: errors.length ? 'error' : 'ok', lines })
setSelected(new Set())
await queryClient.invalidateQueries({ queryKey: ['orders'] })
} catch (err) {
setNotice({ tone: 'error', lines: [err instanceof Error ? err.message : 'Не удалось создать чеки'] })
} finally {
setSubmitting(false)
}
}
async function handleCancel(order: Order) {
if (!order.receipt_id) return
if (!window.confirm(`Отменить ЕТТН-чек по заказу ${order.id}? Заказ вернётся в очередь.`)) return
setCancellingId(order.id)
try {
await cancelReceipt(order.receipt_id)
setNotice({ tone: 'ok', lines: [`Чек по заказу ${order.id} отменён`] })
await queryClient.invalidateQueries({ queryKey: ['orders'] })
} catch (err) {
setNotice({ tone: 'error', lines: [err instanceof Error ? err.message : 'Не удалось отменить чек'] })
} finally {
setCancellingId(null)
}
}
const filtered = useMemo(() => {
const query = search.trim().toLowerCase()
@@ -105,18 +174,48 @@ export function DashboardPage() {
<main className="orders-body">
<div className="orders-toolbar">
<h2>Заказы</h2>
<button type="button" className="orders-create-btn" disabled={selected.size === 0}>
Создать чеки по выбранным ({selected.size})
</button>
<div className="orders-toolbar-actions">
{user?.role === 'admin' && (
<Link to="/cash-registers" className="orders-link-btn">
Кассы
</Link>
)}
{tab === 'no_receipt' && canFiscalize && (
<button
type="button"
className="orders-create-btn"
disabled={selected.size === 0 || submitting}
onClick={() => void submitReceipts(filtered.filter((order) => selected.has(order.id)))}
>
Создать чеки по выбранным ({selected.size})
</button>
)}
</div>
</div>
{notice && (
<div className={`orders-notice orders-notice--${notice.tone}`}>
<ul>
{notice.lines.map((line) => (
<li key={line}>{line}</li>
))}
</ul>
<button type="button" className="orders-notice-close" onClick={() => setNotice(null)} aria-label="Скрыть">
×
</button>
</div>
)}
<div className="orders-tabs">
{TABS.map((t) => (
<button
key={t.key}
type="button"
className={`orders-tab${tab === t.key ? ' orders-tab--active' : ''}`}
onClick={() => setTab(t.key)}
onClick={() => {
setTab(t.key)
setSelected(new Set())
}}
>
{t.label}
</button>
@@ -150,7 +249,7 @@ export function DashboardPage() {
<th>Сумма</th>
<th>Наложка</th>
<th>Оплачено</th>
<th>Предоплата</th>
<th>Предоплата, ₴</th>
<th>Статус ТТН</th>
<th>Действия</th>
</tr>
@@ -197,7 +296,25 @@ export function DashboardPage() {
)}
</td>
<td>
<input type="text" className="orders-prepayment-input" placeholder="0%" />
{tab === 'no_receipt' ? (
<input
type="text"
inputMode="decimal"
className={`orders-prepayment-input${
canCreateReceipt(order) &&
!prepaymentMatches(order.total_amount, order.np_cod_amount, prepaymentOf(order))
? ' orders-prepayment-input--invalid'
: ''
}`}
placeholder="0.00"
value={prepaymentOf(order)}
disabled={!canFiscalize}
title="Сумма заказа − предоплата должна равняться наложке"
onChange={(e) => setPrepayments((prev) => ({ ...prev, [order.id]: e.target.value }))}
/>
) : (
(order.receipt_prepayment ?? '—')
)}
</td>
<td>
<span className={`orders-status orders-status--${npStatusTone(order)}`}>
@@ -208,10 +325,36 @@ export function DashboardPage() {
<button type="button" className="orders-view-btn" onClick={() => setViewingOrder(order)}>
Просмотр
</button>
<button type="button" className="orders-receipt-btn" disabled>
Чек
</button>
{canDelete && (
{tab === 'no_receipt' && canFiscalize && (
<button
type="button"
className="orders-receipt-btn"
disabled={!canCreateReceipt(order) || submitting}
title={canCreateReceipt(order) ? 'Создать ЕТТН-чек в Checkbox' : 'Нет ТТН или наложки'}
onClick={() => void submitReceipts([order])}
>
Чек
</button>
)}
{order.receipt_status && (tab === 'has_receipt' || !CANCELLABLE.has(order.receipt_status)) && (
<span
className={`orders-status orders-status--${RECEIPT_STATUS[order.receipt_status].tone}`}
title={order.receipt_error ?? undefined}
>
{RECEIPT_STATUS[order.receipt_status].label}
</span>
)}
{tab === 'has_receipt' && canFiscalize && order.receipt_status && CANCELLABLE.has(order.receipt_status) && (
<button
type="button"
className="orders-delete-btn"
disabled={cancellingId === order.id}
onClick={() => void handleCancel(order)}
>
Отменить
</button>
)}
{canDelete && tab === 'no_receipt' && (
<button
type="button"
className="orders-delete-btn"
@@ -237,7 +380,12 @@ export function DashboardPage() {
</div>
</main>
{viewingOrder && <OrderDetailModal order={viewingOrder} onClose={() => setViewingOrder(null)} />}
{viewingOrder && (
<OrderDetailModal
order={orders?.find((order) => order.id === viewingOrder.id) ?? viewingOrder}
onClose={() => setViewingOrder(null)}
/>
)}
</div>
)
}