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:
4
backend/migrations/045_kkt_config.sql
Normal file
4
backend/migrations/045_kkt_config.sql
Normal 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;
|
||||||
@@ -211,16 +211,18 @@ const workstationRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
fastify.post<WsParam & { Body: {
|
fastify.post<WsParam & { Body: {
|
||||||
type: string; name: string; connection: string
|
type: string; name: string; connection: string
|
||||||
network_host?: string; network_port?: number; com_port?: string; purpose?: string; config?: object
|
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',
|
'/api/hotels/:slug/workstations/:id/devices',
|
||||||
{ onRequest: [fastify.authenticate] },
|
{ onRequest: [fastify.authenticate] },
|
||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
const { id } = req.params
|
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(
|
const { rows } = await db.query(
|
||||||
`INSERT INTO workstation_devices (workstation_id, type, name, connection, network_host, network_port, com_port, purpose, 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) RETURNING *`,
|
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 ?? {}],
|
[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])
|
return reply.code(201).send(rows[0])
|
||||||
},
|
},
|
||||||
@@ -233,7 +235,7 @@ const workstationRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
const { deviceId } = req.params
|
const { deviceId } = req.params
|
||||||
const body = req.body as Record<string, unknown>
|
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 sets: string[] = []
|
||||||
const vals: unknown[] = []
|
const vals: unknown[] = []
|
||||||
fields.forEach(f => {
|
fields.forEach(f => {
|
||||||
@@ -296,6 +298,8 @@ const workstationRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
network_host: device.network_host,
|
network_host: device.network_host,
|
||||||
network_port: device.network_port,
|
network_port: device.network_port,
|
||||||
com_port: device.com_port,
|
com_port: device.com_port,
|
||||||
|
fiscal_mode: device.fiscal_mode,
|
||||||
|
dto_port: device.dto_port,
|
||||||
},
|
},
|
||||||
}) as { ok: boolean; error?: string; message?: string }
|
}) as { ok: boolean; error?: string; message?: string }
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -1007,6 +1007,8 @@ export interface WorkstationDevice {
|
|||||||
comPort?: string
|
comPort?: string
|
||||||
purpose: 'fiscal' | 'kitchen' | 'bar' | 'receipt' | 'other'
|
purpose: 'fiscal' | 'kitchen' | 'bar' | 'receipt' | 'other'
|
||||||
config: Record<string, unknown>
|
config: Record<string, unknown>
|
||||||
|
fiscalMode?: boolean
|
||||||
|
dtoPort?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Workstation {
|
export interface Workstation {
|
||||||
|
|||||||
@@ -195,6 +195,8 @@ function DeviceForm({
|
|||||||
const [networkPort, setNetworkPort] = useState(String(initial?.networkPort ?? 9100))
|
const [networkPort, setNetworkPort] = useState(String(initial?.networkPort ?? 9100))
|
||||||
const [comPort, setComPort] = useState(initial?.comPort ?? '')
|
const [comPort, setComPort] = useState(initial?.comPort ?? '')
|
||||||
const [purpose, setPurpose] = useState(initial?.purpose ?? 'receipt')
|
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 [saving, setSaving] = useState(false)
|
||||||
const [availablePorts, setAvailablePorts] = useState<{ port: string; description?: string }[] | null>(null)
|
const [availablePorts, setAvailablePorts] = useState<{ port: string; description?: string }[] | null>(null)
|
||||||
const [portsLoading, setPortsLoading] = useState(false)
|
const [portsLoading, setPortsLoading] = useState(false)
|
||||||
@@ -220,6 +222,8 @@ function DeviceForm({
|
|||||||
networkPort: connection === 'network' ? Number(networkPort) : undefined,
|
networkPort: connection === 'network' ? Number(networkPort) : undefined,
|
||||||
comPort: connection === 'com' ? comPort.trim() : undefined,
|
comPort: connection === 'com' ? comPort.trim() : undefined,
|
||||||
purpose: purpose as WorkstationDevice['purpose'],
|
purpose: purpose as WorkstationDevice['purpose'],
|
||||||
|
fiscalMode: type === 'kkt' ? fiscalMode : undefined,
|
||||||
|
dtoPort: type === 'kkt' && fiscalMode ? Number(dtoPort) : undefined,
|
||||||
})
|
})
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
@@ -253,6 +257,48 @@ function DeviceForm({
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
<div>
|
||||||
<label className="block text-xs font-medium text-slate-500 mb-1">Название</label>
|
<label className="block text-xs font-medium text-slate-500 mb-1">Название</label>
|
||||||
<input
|
<input
|
||||||
@@ -445,12 +491,20 @@ function WorkstationCard({
|
|||||||
setTestingDeviceId(deviceId)
|
setTestingDeviceId(deviceId)
|
||||||
try {
|
try {
|
||||||
const r = await api.workstations.testDevice(slug, ws.id, deviceId)
|
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) {
|
} catch (err) {
|
||||||
setTestResults(prev => ({ ...prev, [deviceId]: { ok: false, msg: err instanceof Error ? err.message : 'Ошибка' } }))
|
setTestResults(prev => ({ ...prev, [deviceId]: { ok: false, msg: err instanceof Error ? err.message : 'Ошибка' } }))
|
||||||
} finally {
|
} finally {
|
||||||
setTestingDeviceId(null)
|
setTestingDeviceId(null)
|
||||||
setTimeout(() => setTestResults(prev => ({ ...prev, [deviceId]: null })), 4000)
|
setTimeout(() => setTestResults(prev => ({ ...prev, [deviceId]: null })), 8000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -577,7 +631,19 @@ function WorkstationCard({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">{device.name}</p>
|
<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">
|
<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-xs text-slate-500">{PURPOSE_LABELS[device.purpose]}</span>
|
||||||
<span className="text-slate-300 dark:text-slate-600">·</span>
|
<span className="text-slate-300 dark:text-slate-600">·</span>
|
||||||
@@ -591,6 +657,9 @@ function WorkstationCard({
|
|||||||
? `${device.networkHost}:${device.networkPort}`
|
? `${device.networkHost}:${device.networkPort}`
|
||||||
: 'IP не задан'}
|
: 'IP не задан'}
|
||||||
</span>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user