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