feat: KKT fiscal/non-fiscal mode, multi-KKT per workstation, DTO port per device

- Migration 045: fiscal_mode + dto_port columns on workstation_devices
- Backend: pass fiscal_mode/dto_port to agent in test_device command
- Agent atol.ts: remove global DTO singleton, accept dtoPort per function call
- Agent test_device: returns real KKT info (model, serial, FN, shift status)
- Equipment page: fiscal/non-fiscal toggle + DTO port field when adding KKT
- Equipment page: show fiscal mode badge on device card, DTO port if non-default
- Equipment page: KKT test result shows model, serial, FN, shift status

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-27 14:06:44 +03:00
parent 2caf717131
commit f882cb2481
4 changed files with 87 additions and 8 deletions

View File

@@ -0,0 +1,4 @@
-- KKT fiscal mode and DTO port per device
ALTER TABLE workstation_devices
ADD COLUMN IF NOT EXISTS fiscal_mode BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN IF NOT EXISTS dto_port INTEGER DEFAULT 16732;

View File

@@ -211,16 +211,18 @@ const workstationRoutes: FastifyPluginAsync = async (fastify) => {
fastify.post<WsParam & { Body: {
type: string; name: string; connection: string
network_host?: string; network_port?: number; com_port?: string; purpose?: string; config?: object
fiscal_mode?: boolean; dto_port?: number
} }>(
'/api/hotels/:slug/workstations/:id/devices',
{ onRequest: [fastify.authenticate] },
async (req, reply) => {
const { id } = req.params
const { type, name, connection, network_host, network_port, com_port, purpose, config } = req.body
const { type, name, connection, network_host, network_port, com_port, purpose, config, fiscal_mode, dto_port } = req.body
const { rows } = await db.query(
`INSERT INTO workstation_devices (workstation_id, type, name, connection, network_host, network_port, com_port, purpose, config)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING *`,
[id, type, name, connection, network_host ?? null, network_port ?? null, com_port ?? null, purpose ?? 'other', config ?? {}],
`INSERT INTO workstation_devices (workstation_id, type, name, connection, network_host, network_port, com_port, purpose, config, fiscal_mode, dto_port)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) RETURNING *`,
[id, type, name, connection, network_host ?? null, network_port ?? null, com_port ?? null, purpose ?? 'other', config ?? {},
fiscal_mode ?? true, dto_port ?? 16732],
)
return reply.code(201).send(rows[0])
},
@@ -233,7 +235,7 @@ const workstationRoutes: FastifyPluginAsync = async (fastify) => {
async (req, reply) => {
const { deviceId } = req.params
const body = req.body as Record<string, unknown>
const fields = ['name', 'connection', 'network_host', 'network_port', 'com_port', 'purpose', 'config']
const fields = ['name', 'connection', 'network_host', 'network_port', 'com_port', 'purpose', 'config', 'fiscal_mode', 'dto_port']
const sets: string[] = []
const vals: unknown[] = []
fields.forEach(f => {
@@ -296,6 +298,8 @@ const workstationRoutes: FastifyPluginAsync = async (fastify) => {
network_host: device.network_host,
network_port: device.network_port,
com_port: device.com_port,
fiscal_mode: device.fiscal_mode,
dto_port: device.dto_port,
},
}) as { ok: boolean; error?: string; message?: string }
return result

View File

@@ -1007,6 +1007,8 @@ export interface WorkstationDevice {
comPort?: string
purpose: 'fiscal' | 'kitchen' | 'bar' | 'receipt' | 'other'
config: Record<string, unknown>
fiscalMode?: boolean
dtoPort?: number
}
export interface Workstation {

View File

@@ -195,6 +195,8 @@ function DeviceForm({
const [networkPort, setNetworkPort] = useState(String(initial?.networkPort ?? 9100))
const [comPort, setComPort] = useState(initial?.comPort ?? '')
const [purpose, setPurpose] = useState(initial?.purpose ?? 'receipt')
const [fiscalMode, setFiscalMode] = useState(initial?.fiscalMode !== false)
const [dtoPort, setDtoPort] = useState(String(initial?.dtoPort ?? 16732))
const [saving, setSaving] = useState(false)
const [availablePorts, setAvailablePorts] = useState<{ port: string; description?: string }[] | null>(null)
const [portsLoading, setPortsLoading] = useState(false)
@@ -220,6 +222,8 @@ function DeviceForm({
networkPort: connection === 'network' ? Number(networkPort) : undefined,
comPort: connection === 'com' ? comPort.trim() : undefined,
purpose: purpose as WorkstationDevice['purpose'],
fiscalMode: type === 'kkt' ? fiscalMode : undefined,
dtoPort: type === 'kkt' && fiscalMode ? Number(dtoPort) : undefined,
})
setSaving(false)
}
@@ -253,6 +257,48 @@ function DeviceForm({
</div>
</div>
{/* KKT-specific: fiscal mode + DTO port */}
{type === 'kkt' && (
<div className="space-y-3 border border-amber-200 dark:border-amber-800 rounded-xl p-3 bg-amber-50/40 dark:bg-amber-900/10">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">Режим кассы</label>
<div className="flex gap-2">
{([
{ id: true, label: 'Фискальный', hint: 'Чеки с ФН, ОФД' },
{ id: false, label: 'Нефискальный', hint: 'Только документы' },
] as const).map(m => (
<button
key={String(m.id)}
type="button"
onClick={() => setFiscalMode(m.id)}
className={cn(
'flex-1 py-2 px-3 rounded-lg border text-sm font-medium transition-colors text-left',
fiscalMode === m.id
? 'border-amber-500 bg-amber-100 dark:bg-amber-900/30 text-amber-800 dark:text-amber-300'
: 'border-slate-300 dark:border-slate-600 text-slate-500 hover:border-slate-400',
)}
>
<div>{m.label}</div>
<div className="text-xs opacity-60 font-normal">{m.hint}</div>
</button>
))}
</div>
</div>
{fiscalMode && (
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">Порт АТОЛ ДТО</label>
<input
value={dtoPort}
onChange={e => setDtoPort(e.target.value)}
className="w-32 input text-sm font-mono"
placeholder="16732"
/>
<p className="text-xs text-slate-400 mt-1">Порт АТОЛ ДТО 10 (по умолчанию 16732). Для нескольких касс разные порты.</p>
</div>
)}
</div>
)}
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">Название</label>
<input
@@ -445,12 +491,20 @@ function WorkstationCard({
setTestingDeviceId(deviceId)
try {
const r = await api.workstations.testDevice(slug, ws.id, deviceId)
setTestResults(prev => ({ ...prev, [deviceId]: { ok: r.ok, msg: r.message ?? r.error ?? '' } }))
let msg = r.message ?? r.error ?? ''
// Для ккт — форматируем информацию о кассе
if (r.ok && r.kkt) {
const k = r.kkt as { model?: string; serial_number?: string; fn_number?: string; ofd_status?: string }
const s = r.shift as { state?: string; shift_number?: number } | undefined
const shiftLabel = s?.state === 'open' ? `Смена #${s.shift_number} открыта` : s?.state === 'expired' ? 'Смена истекла!' : 'Смена закрыта'
msg = `${k.model ?? 'ККТ'} · СН: ${k.serial_number ?? '—'} · ФН: ${k.fn_number ?? '—'} · ${shiftLabel}`
}
setTestResults(prev => ({ ...prev, [deviceId]: { ok: r.ok, msg } }))
} catch (err) {
setTestResults(prev => ({ ...prev, [deviceId]: { ok: false, msg: err instanceof Error ? err.message : 'Ошибка' } }))
} finally {
setTestingDeviceId(null)
setTimeout(() => setTestResults(prev => ({ ...prev, [deviceId]: null })), 4000)
setTimeout(() => setTestResults(prev => ({ ...prev, [deviceId]: null })), 8000)
}
}
@@ -577,7 +631,19 @@ function WorkstationCard({
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">{device.name}</p>
{device.type === 'kkt' && (
<span className={cn(
'text-xs px-1.5 py-0.5 rounded font-medium',
device.fiscalMode !== false
? 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'
: 'bg-slate-100 text-slate-500 dark:bg-slate-700 dark:text-slate-400',
)}>
{device.fiscalMode !== false ? 'Фискальный' : 'Нефискальный'}
</span>
)}
</div>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-xs text-slate-500">{PURPOSE_LABELS[device.purpose]}</span>
<span className="text-slate-300 dark:text-slate-600">·</span>
@@ -591,6 +657,9 @@ function WorkstationCard({
? `${device.networkHost}:${device.networkPort}`
: 'IP не задан'}
</span>
{device.type === 'kkt' && device.fiscalMode !== false && device.dtoPort && device.dtoPort !== 16732 && (
<span className="text-xs text-slate-400 font-mono">DTO:{device.dtoPort}</span>
)}
</div>
</div>