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:
@@ -0,0 +1,22 @@
|
||||
/** Зеркало `CashRegisterOut`/`CashRegisterCreate`/`CashRegisterUpdate` из backend/app/schemas/receipts.py. */
|
||||
|
||||
export interface CashRegister {
|
||||
id: string
|
||||
name: string
|
||||
fiscal_number: string | null
|
||||
license_key_masked: string
|
||||
tax_codes: (number | string)[]
|
||||
is_active: boolean
|
||||
is_default: boolean
|
||||
}
|
||||
|
||||
export interface CashRegisterCreate {
|
||||
name: string
|
||||
fiscal_number: string | null
|
||||
license_key: string
|
||||
pin_code: string
|
||||
tax_codes: (number | string)[]
|
||||
is_default: boolean
|
||||
}
|
||||
|
||||
export type CashRegisterUpdate = Partial<CashRegisterCreate> & { is_active?: boolean }
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Modal } from '@/components/Modal'
|
||||
import '@/features/orders/OrderDetailModal.css'
|
||||
import type { Order } from '@/features/orders/types'
|
||||
import { RECEIPT_STATUS } from '@/features/receipts/types'
|
||||
|
||||
interface OrderDetailModalProps {
|
||||
order: Order
|
||||
@@ -105,6 +106,27 @@ export function OrderDetailModal({ order, onClose }: OrderDetailModalProps) {
|
||||
<span>{order.total_amount} ₴</span>
|
||||
</div>
|
||||
|
||||
{order.receipt_status && (
|
||||
<div className="order-modal-meta">
|
||||
<div>
|
||||
<span className="order-modal-label">ЕТТН-чек Checkbox</span>
|
||||
<span className={`orders-status orders-status--${RECEIPT_STATUS[order.receipt_status].tone}`}>
|
||||
{RECEIPT_STATUS[order.receipt_status].label}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="order-modal-label">Предоплата в чеке</span>
|
||||
<span>{order.receipt_prepayment ? `${order.receipt_prepayment} ₴` : '—'}</span>
|
||||
</div>
|
||||
{order.receipt_error && (
|
||||
<div>
|
||||
<span className="order-modal-label">Ошибка</span>
|
||||
<span>{order.receipt_error}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="order-modal-actions">
|
||||
<button type="button" className="order-modal-close-btn" onClick={onClose}>
|
||||
Закрыть
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
* Меняются синхронно с ними вручную — см. пояснение в @/api/types.ts.
|
||||
*/
|
||||
|
||||
import type { ReceiptStatus } from '@/features/receipts/types'
|
||||
|
||||
export interface OrderGood {
|
||||
id: string
|
||||
sku: string
|
||||
@@ -29,4 +31,9 @@ export interface Order {
|
||||
np_status_code: string | null
|
||||
np_cod_amount: string | null
|
||||
np_payment_status: string | null
|
||||
// Последний ЕТТН-чек по заказу (в т.ч. отменённый/неудачный — для показа причины).
|
||||
receipt_id: string | null
|
||||
receipt_status: ReceiptStatus | null
|
||||
receipt_error: string | null
|
||||
receipt_prepayment: string | null
|
||||
}
|
||||
|
||||
@@ -2,6 +2,17 @@ import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { getOrders } from '@/api/orders'
|
||||
|
||||
/** Пока чек отправляется в Checkbox — опрашиваем часто, иначе статусы чеков обновляет worker раз в минуту. */
|
||||
const PENDING_POLL_MS = 3_000
|
||||
const RECEIPTS_POLL_MS = 30_000
|
||||
|
||||
export function useOrders(hasReceipt: boolean) {
|
||||
return useQuery({ queryKey: ['orders', hasReceipt], queryFn: () => getOrders(hasReceipt) })
|
||||
return useQuery({
|
||||
queryKey: ['orders', hasReceipt],
|
||||
queryFn: () => getOrders(hasReceipt),
|
||||
refetchInterval: (query) => {
|
||||
if (query.state.data?.some((order) => order.receipt_status === 'pending')) return PENDING_POLL_MS
|
||||
return hasReceipt ? RECEIPTS_POLL_MS : false
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/** Деньги на фронте приходят строками "1200.00"; в API чеков уходят integer-копейками. */
|
||||
|
||||
export function toKopecks(value: string): number | null {
|
||||
const normalized = value.trim().replace(/\s/g, '').replace(',', '.')
|
||||
if (normalized === '') return 0
|
||||
if (!/^\d+(\.\d{1,2})?$/.test(normalized)) return null
|
||||
return Math.round(Number(normalized) * 100)
|
||||
}
|
||||
|
||||
export function formatKopecks(kopecks: number): string {
|
||||
return (kopecks / 100).toFixed(2)
|
||||
}
|
||||
|
||||
/** Предоплата по умолчанию: сумма заказа − наложка НП (как считает бэкенд). */
|
||||
export function defaultPrepayment(total: string, cod: string | null): string {
|
||||
if (!cod) return ''
|
||||
const diff = (toKopecks(total) ?? 0) - (toKopecks(cod) ?? 0)
|
||||
return diff > 0 ? formatKopecks(diff) : '0.00'
|
||||
}
|
||||
|
||||
/** Проверка инварианта ЕТТН-чека: сумма − предоплата = наложка. */
|
||||
export function prepaymentMatches(total: string, cod: string | null, prepayment: string): boolean {
|
||||
if (!cod) return false
|
||||
const prep = toKopecks(prepayment)
|
||||
if (prep === null) return false
|
||||
return (toKopecks(total) ?? 0) - prep === toKopecks(cod)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Типы, зеркалящие backend/app/schemas/receipts.py и `ReceiptStatus`
|
||||
* (backend/app/db/models/receipt.py). Меняются синхронно с ними вручную.
|
||||
*/
|
||||
|
||||
export type ReceiptStatus =
|
||||
| 'pending'
|
||||
| 'created'
|
||||
| 'done'
|
||||
| 'returned'
|
||||
| 'receipt_error'
|
||||
| 'cancelled'
|
||||
| 'failed'
|
||||
|
||||
export interface Receipt {
|
||||
id: string
|
||||
order_id: string
|
||||
waybill_number: string
|
||||
status: ReceiptStatus
|
||||
total_amount: string
|
||||
prepayment_amount: string
|
||||
cod_amount: string
|
||||
checkbox_ettn_id: string | null
|
||||
checkbox_status: string | null
|
||||
checkbox_receipt_id: string | null
|
||||
error: string | null
|
||||
created_at: string
|
||||
last_checked_at: string | null
|
||||
}
|
||||
|
||||
export interface ReceiptRequestItem {
|
||||
order_id: string
|
||||
/** null — предоплата = сумма заказа − наложка (считает бэкенд). */
|
||||
prepayment_kopecks: number | null
|
||||
}
|
||||
|
||||
export interface ReceiptCreateResponse {
|
||||
created: Receipt[]
|
||||
/** order_id → причина, по которой чек не создан. */
|
||||
errors: Record<string, string>
|
||||
}
|
||||
|
||||
export type Tone = 'delivered' | 'processing' | 'danger' | 'new'
|
||||
|
||||
export const RECEIPT_STATUS: Record<ReceiptStatus, { label: string; tone: Tone }> = {
|
||||
pending: { label: 'Отправляется', tone: 'new' },
|
||||
created: { label: 'Ждёт оплаты', tone: 'processing' },
|
||||
done: { label: 'Фискализирован', tone: 'delivered' },
|
||||
returned: { label: 'Возврат посылки', tone: 'danger' },
|
||||
receipt_error: { label: 'Ошибка фискализации', tone: 'danger' },
|
||||
cancelled: { label: 'Отменён', tone: 'new' },
|
||||
failed: { label: 'Не создан', tone: 'danger' },
|
||||
}
|
||||
|
||||
/** Статусы, из которых чек можно отменить (см. services/receipts.cancel_receipt). */
|
||||
export const CANCELLABLE: ReadonlySet<ReceiptStatus> = new Set(['created', 'receipt_error'])
|
||||
Reference in New Issue
Block a user