This commit is contained in:
Your Name
2026-08-06 10:09:07 +00:00
parent 3a217f0dd0
commit ae04fe4092
15 changed files with 837 additions and 8 deletions
+2
View File
@@ -14,6 +14,7 @@ server {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 300s;
}
location /auth/ {
@@ -21,6 +22,7 @@ server {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 300s;
}
location /health {
+8 -1
View File
@@ -2,11 +2,12 @@ import { useState, useEffect } from 'react'
import { AuthPage } from './pages/AuthPage'
import { CheckCodesPage } from './pages/CheckCodesPage'
import { UploadUpdPage } from './pages/UploadUpdPage'
import { ValidateUpdPage } from './pages/ValidateUpdPage'
import { PlansPage } from './pages/PlansPage'
import { setOnUnauthorized, logout as apiLogout, getEnv, setEnv, getAuthStatus } from './services/api'
import type { ApiEnv } from './types'
type Page = 'auth' | 'check' | 'upload' | 'plans'
type Page = 'auth' | 'check' | 'upload' | 'validate' | 'plans'
export default function App() {
const [page, setPage] = useState<Page>('auth')
@@ -78,6 +79,11 @@ export default function App() {
disabled={!authenticated}>
Загрузка УПД
</button>
<button onClick={() => setPage('validate')}
style={navBtnStyle(page === 'validate')}
disabled={!authenticated}>
Проверка УПД (True API)
</button>
<button onClick={() => setPage('plans')}
style={navBtnStyle(page === 'plans')}>
Планы
@@ -124,6 +130,7 @@ export default function App() {
{page === 'auth' && <AuthPage key={env} onAuth={handleAuth} />}
{page === 'check' && <CheckCodesPage key={env} />}
{page === 'upload' && <UploadUpdPage key={env} />}
{page === 'validate' && <ValidateUpdPage key={env} />}
{page === 'plans' && <PlansPage key={env} />}
</main>
</div>
+41 -1
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'
import { getAuthStatus, getAuthKey, signIn, setTokenManually } from '../services/api'
import { getAuthStatus, getAuthKey, signIn, setTokenManually, getToken } from '../services/api'
import type { RequestError } from '../services/api'
import { isCadesPluginAvailable, listCertificates, signWithCadesPlugin } from '../services/cadesplugin'
import type { CertInfo } from '../services/cadesplugin'
@@ -20,6 +20,8 @@ export function AuthPage({ onAuth }: Props) {
const [debugExpanded, setDebugExpanded] = useState(false)
const [manualToken, setManualToken] = useState('')
const [manualLoading, setManualLoading] = useState(false)
const [copying, setCopying] = useState(false)
const [copied, setCopied] = useState(false)
useEffect(() => {
getAuthStatus().then(setStatus).catch(() => {})
@@ -58,6 +60,33 @@ export function AuthPage({ onAuth }: Props) {
}
}
async function handleCopyToken() {
setCopying(true)
setError('')
setCopied(false)
try {
const { token } = await getToken()
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(token)
} else {
const textarea = document.createElement('textarea')
textarea.value = token
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
}
setCopied(true)
} catch (err) {
const re = err as RequestError
setError(re.message)
} finally {
setCopying(false)
}
}
async function handleSignIn() {
setLoading(true)
setError('')
@@ -112,6 +141,17 @@ export function AuthPage({ onAuth }: Props) {
<div style={{ fontSize: 13, marginTop: 4 }}>
Токен истекает: {new Date(status.tokenExpiresAt!).toLocaleString()}
</div>
<button
onClick={handleCopyToken}
disabled={copying}
style={{
...btnStyle,
background: '#2e7d32',
marginTop: 10,
}}
>
{copying ? 'Копирование...' : copied ? '✅ Токен скопирован' : 'Скопировать токен'}
</button>
</div>
)}
+376
View File
@@ -0,0 +1,376 @@
import { useState, useRef } from 'react'
import { validateUpd, getUpdValidationStatus } from '../services/api'
import type { RequestError } from '../services/api'
import type { UpdValidateResponse, UpdValidationResult } from '../types'
const MAX_FILE_SIZE = 10 * 1024 * 1024
export function ValidateUpdPage() {
const [file, setFile] = useState<File | null>(null)
const [checkPermits, setCheckPermits] = useState(false)
const [response, setResponse] = useState<UpdValidateResponse | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [errorDebug, setErrorDebug] = useState<string | null>(null)
const [debugExpanded, setDebugExpanded] = useState(false)
const [dragging, setDragging] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
function setFileIfValid(f: File | null) {
if (!f) { setFile(null); return }
if (f.size > MAX_FILE_SIZE) {
setError('Файл слишком большой (максимум 10MB)')
return
}
setError('')
setFile(f)
}
async function handleValidate() {
if (!file) return
setLoading(true)
setError('')
setErrorDebug(null)
setDebugExpanded(false)
try {
const result = await validateUpd(file, { checkPermits })
setResponse(result)
} catch (err) {
const re = err as RequestError
setError(re.message)
if (re.debugInfo) {
setErrorDebug(JSON.stringify(re.debugInfo, null, 2))
}
} finally {
setLoading(false)
}
}
async function handleRefreshStatus() {
if (!response) return
setLoading(true)
setError('')
setErrorDebug(null)
setDebugExpanded(false)
try {
const status = await getUpdValidationStatus(response.validation.documentId)
setResponse({ ...response, validation: status })
} catch (err) {
const re = err as RequestError
setError(re.message)
if (re.debugInfo) {
setErrorDebug(JSON.stringify(re.debugInfo, null, 2))
}
} finally {
setLoading(false)
}
}
function handleDrop(e: React.DragEvent) {
e.preventDefault()
setDragging(false)
setFileIfValid(e.dataTransfer.files[0] || null)
}
return (
<div>
<h2>Проверка УПД через True API (предварительная проверка)</h2>
<p style={{ fontSize: 13, color: '#546e7a', marginTop: -8 }}>
Весь XML-документ отправляется в ГИС МТ ({'<doc/validator/create>'}) — проверяются регистрация
продавца/покупателя, корректность УД, коды маркировки, статусы КИ и полномочия продавца.
</p>
<div
onDragOver={e => { e.preventDefault(); setDragging(true) }}
onDragLeave={() => setDragging(false)}
onDrop={handleDrop}
onClick={() => inputRef.current?.click()}
style={{
border: `2px dashed ${dragging ? '#1a237e' : '#ccc'}`,
borderRadius: 8,
padding: 40,
textAlign: 'center',
cursor: 'pointer',
background: dragging ? '#e8eaf6' : '#fafafa',
marginBottom: 16,
}}
>
{file ? (
<div>
<strong>{file.name}</strong> ({(file.size / 1024).toFixed(1)} KB)
<br />
<button onClick={e => { e.stopPropagation(); setFile(null) }}
style={{ marginTop: 8, ...smallBtnStyle }}>
Удалить
</button>
</div>
) : (
<div>
<div style={{ fontSize: 32, marginBottom: 8 }}>📄</div>
<div>Перетащите XML-файл УПД сюда или нажмите для выбора</div>
</div>
)}
<input
ref={inputRef}
type="file"
accept=".xml,.XML"
hidden
onChange={e => setFileIfValid(e.target.files?.[0] || null)}
/>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, marginBottom: 16, cursor: 'pointer' }}>
<input
type="checkbox"
checked={checkPermits}
onChange={e => setCheckPermits(e.target.checked)}
/>
Проверять статусы разрешительных документов (предупреждения)
</label>
<button onClick={handleValidate} disabled={loading || !file}
style={btnStyle}>
{loading ? 'Отправка в ГИС МТ и ожидание результата...' : 'Отправить УПД на проверку в True API'}
</button>
{error && (
<div style={{ color: '#c62828', marginTop: 12 }}>Ошибка: {error}</div>
)}
{errorDebug && (
<div style={{ marginTop: 12, marginBottom: 16 }}>
<button
onClick={() => setDebugExpanded(!debugExpanded)}
style={{
background: 'transparent',
border: '1px solid #b0bec5',
borderRadius: 4,
padding: '6px 12px',
cursor: 'pointer',
fontSize: 13,
color: '#37474f',
}}
>
{debugExpanded ? '▼ Скрыть лог запроса к API маркировки' : '▶ Показать лог запроса к API маркировки'}
</button>
{debugExpanded && (
<pre style={{
marginTop: 8,
padding: 12,
background: '#263238',
color: '#e0e0e0',
borderRadius: 6,
fontSize: 12,
lineHeight: 1.5,
overflowX: 'auto',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
}}>
{errorDebug}
</pre>
)}
</div>
)}
{response && (
<div style={{ marginTop: 16 }}>
<div style={{
padding: 12,
background: '#e3f2fd',
borderRadius: 6,
marginBottom: 12,
fontSize: 14,
}}>
<strong>Документ:</strong>{' '}
{response.document.number || '—'} от {response.document.date || '—'}
<br />
<strong>Продавец:</strong> {response.document.seller || '—'}
<br />
<strong>Покупатель:</strong> {response.document.buyer || '—'}
<br />
<strong>Кодов в документе:</strong> {response.codesFound}
<br />
<strong>ИД файла:</strong> {response.validation.documentId}
</div>
<ValidationStatusCard
validation={response.validation}
refreshing={loading}
onRefresh={handleRefreshStatus}
/>
{response.validation.errors.length > 0 && (
<div style={{ marginTop: 16 }}>
<h3 style={{ fontSize: 15, margin: '0 0 8px', color: '#c62828' }}>
Ошибки проверки ({response.validation.errors.length})
</h3>
<ErrorTable errors={response.validation.errors} />
</div>
)}
{response.validation.preliminaryWarnings.length > 0 && (
<div style={{ marginTop: 16 }}>
<h3 style={{ fontSize: 15, margin: '0 0 8px', color: '#e65100' }}>
Предупреждения ({response.validation.preliminaryWarnings.length})
</h3>
<WarningTable warnings={response.validation.preliminaryWarnings} />
</div>
)}
</div>
)}
</div>
)
}
function ValidationStatusCard({
validation,
refreshing,
onRefresh,
}: {
validation: UpdValidationResult
refreshing: boolean
onRefresh: () => void
}) {
const metaMap: Record<string, { label: string; color: string; bg: string }> = {
VERIFIED: { label: '✅ Документ прошёл проверку', color: '#2e7d32', bg: '#e8f5e9' },
NOT_VALID: { label: '❌ Документ не прошёл проверку', color: '#c62828', bg: '#ffebee' },
NOT_FOUND: { label: 'Документ не найден в ГИС МТ', color: '#546e7a', bg: '#eceff1' },
IN_PROGRESS: { label: '⏳ Документ в обработке', color: '#e65100', bg: '#fff3e0' },
AWAITING: { label: '⏳ Документ ожидает обработки', color: '#e65100', bg: '#fff3e0' },
}
const meta = metaMap[validation.status] || {
label: validation.status,
color: '#37474f',
bg: '#eceff1',
}
return (
<div style={{
padding: 12,
background: meta.bg,
color: meta.color,
borderRadius: 6,
fontSize: 14,
fontWeight: 600,
display: 'flex',
alignItems: 'center',
gap: 12,
}}>
<span>{meta.label}</span>
{validation.description && (
<span style={{ fontWeight: 400 }}>{validation.description}</span>
)}
{validation.pending && (
<button
onClick={onRefresh}
disabled={refreshing}
style={{
marginLeft: 'auto',
background: 'transparent',
border: '1px solid currentColor',
borderRadius: 4,
padding: '6px 12px',
cursor: 'pointer',
fontSize: 13,
color: 'inherit',
}}
>
{refreshing ? 'Проверка...' : '🔄 Обновить статус'}
</button>
)}
</div>
)
}
function ErrorTable({ errors }: { errors: UpdValidationResult['errors'] }) {
return (
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ background: '#c62828', color: 'white' }}>
<th style={thStyle}>Код ошибки</th>
<th style={thStyle}>Описание</th>
<th style={thStyle}>КИ</th>
</tr>
</thead>
<tbody>
{errors.map((e, i) => (
<tr key={i} style={{
background: i % 2 === 0 ? '#fff' : '#f5f5f5',
borderBottom: '1px solid #e0e0e0',
}}>
<td style={tdStyle}>{e.code ?? '—'}</td>
<td style={tdStyle}>{e.description || '—'}</td>
<td style={{ ...tdStyle, wordBreak: 'break-all', fontFamily: 'monospace', fontSize: 12 }}>
{e.cis || '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
function WarningTable({ warnings }: { warnings: UpdValidationResult['preliminaryWarnings'] }) {
return (
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ background: '#e65100', color: 'white' }}>
<th style={thStyle}>Код</th>
<th style={thStyle}>Описание</th>
<th style={thStyle}>КИ</th>
</tr>
</thead>
<tbody>
{warnings.map((w, i) => (
<tr key={i} style={{
background: i % 2 === 0 ? '#fff' : '#f5f5f5',
borderBottom: '1px solid #e0e0e0',
}}>
<td style={tdStyle}>{w.code ?? '—'}</td>
<td style={tdStyle}>{w.description || '—'}</td>
<td style={{ ...tdStyle, wordBreak: 'break-all', fontFamily: 'monospace', fontSize: 12 }}>
{w.cis || '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
const btnStyle: React.CSSProperties = {
background: '#1565c0',
color: 'white',
border: 'none',
borderRadius: 6,
padding: '10px 20px',
cursor: 'pointer',
fontSize: 14,
}
const smallBtnStyle: React.CSSProperties = {
background: '#e53935',
color: 'white',
border: 'none',
borderRadius: 4,
padding: '4px 12px',
cursor: 'pointer',
fontSize: 12,
}
const thStyle: React.CSSProperties = {
padding: '8px 12px',
textAlign: 'left',
fontWeight: 600,
fontSize: 12,
textTransform: 'uppercase',
}
const tdStyle: React.CSSProperties = {
padding: '8px 12px',
verticalAlign: 'top',
}
+24 -4
View File
@@ -1,4 +1,4 @@
import type { AuthStatus, CheckCodesResponse, UpdUploadResponse, ApiErrorResponse, ApiEnv } from '../types'
import type { AuthStatus, CheckCodesResponse, UpdUploadResponse, UpdValidateResponse, UpdValidationResult, ApiErrorResponse, ApiEnv } from '../types'
const BASE_URL = ''
@@ -21,8 +21,9 @@ export function setEnv(env: ApiEnv): void {
currentEnv = env
}
function qs(env?: ApiEnv): string {
return `?env=${env || currentEnv}`
function qs(url: string, env?: ApiEnv): string {
const separator = url.includes('?') ? '&' : '?'
return `${separator}env=${env || currentEnv}`
}
async function handleError(res: Response, env: ApiEnv): Promise<never> {
@@ -45,7 +46,7 @@ async function request<T>(url: string, options?: RequestInit & { env?: ApiEnv })
Object.assign(headers, options.headers as Record<string, string>)
}
const urlWithEnv = `${BASE_URL}${url}${qs(reqEnv)}`
const urlWithEnv = `${BASE_URL}${url}${qs(url, reqEnv)}`
const res = await fetch(urlWithEnv, {
headers,
...options,
@@ -71,6 +72,10 @@ export async function getAuthStatus(env?: ApiEnv): Promise<AuthStatus> {
return request('/auth/status', { env })
}
export async function getToken(env?: ApiEnv): Promise<{ token: string }> {
return request('/auth/token', { env })
}
export async function logout(env?: ApiEnv): Promise<void> {
await request('/auth/logout', { method: 'POST', env })
}
@@ -97,6 +102,21 @@ export async function uploadUpd(file: File, env?: ApiEnv): Promise<UpdUploadResp
return request('/api/upload-upd', { method: 'POST', body: formData, env })
}
export async function validateUpd(
file: File,
opts?: { checkPermits?: boolean },
env?: ApiEnv,
): Promise<UpdValidateResponse> {
const formData = new FormData()
formData.append('file', file)
const query = opts?.checkPermits ? '?checkPermits=1' : ''
return request(`/api/validate-upd${query}`, { method: 'POST', body: formData, env })
}
export async function getUpdValidationStatus(documentId: string, env?: ApiEnv): Promise<UpdValidationResult> {
return request(`/api/validate-upd/status?documentId=${encodeURIComponent(documentId)}`, { env })
}
export async function setTokenManually(token: string, env?: ApiEnv): Promise<void> {
await request('/auth/setToken', {
method: 'POST',
+27
View File
@@ -60,3 +60,30 @@ export interface UpdUploadResponse {
codesFound: number
results: CheckCodesResponse
}
export interface UpdValidationError {
code?: number
description?: string
cis?: string
}
export interface UpdValidationWarning {
code?: string
description?: string
cis?: string
}
export interface UpdValidationResult {
documentId: string
status: string
description?: string
errors: UpdValidationError[]
preliminaryWarnings: UpdValidationWarning[]
pending: boolean
}
export interface UpdValidateResponse {
document: UpdDocument
codesFound: number
validation: UpdValidationResult
}