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
+5 -1
View File
@@ -38,11 +38,12 @@ backend/
index.ts # Точка входа Fastify
routes/auth.ts # GET /auth/key, POST /auth/simpleSignIn, GET /auth/status, POST /auth/logout
routes/cises.ts # POST /api/check-codes/public, POST /api/check-codes/auth
routes/upd.ts # POST /api/upload-upd
routes/upd.ts # POST /api/upload-upd, POST /api/validate-upd, GET /api/validate-upd/status
services/auth-service.ts # Прокси к True API auth
services/cises-service.ts # Проверка КМ (публичная / авторизованная)
services/token-cache.ts # Кеширование токена (node-cache, TTL 10ч)
services/upd-parser.ts # Парсинг XML УПД (формат Приказа №970)
services/upd-validator-service.ts # Предварительная проверка УПД в ГИС МТ (doc/validator)
types/index.ts
frontend/
@@ -51,6 +52,7 @@ frontend/
pages/AuthPage.tsx # Авторизация через КриптоПро Browser Plugin
pages/CheckCodesPage.tsx # Проверка кодов
pages/UploadUpdPage.tsx # Загрузка УПД
pages/ValidateUpdPage.tsx # Проверка УПД целиком через True API
components/ResultsTable.tsx
services/api.ts # HTTP-клиент
services/cadesplugin.ts # Интеграция с КриптоПро Browser Plugin
@@ -71,6 +73,8 @@ frontend/
### УПД
- `POST /api/upload-upd` — загрузка XML УПД, парсинг и проверка кодов
- `POST /api/validate-upd` — предварительная проверка всего УПД в ГИС МТ (True API `doc/validator/create`, параметр `checkPermits=1` — проверка разрешительных документов)
- `GET /api/validate-upd/status?documentId=<id>` — повторное получение статуса проверки УПД
## Переменные окружения (backend/.env)
+23
View File
@@ -12,6 +12,7 @@
"@fastify/multipart": "^9.0.3",
"fastify": "^5.3.1",
"got": "^14.4.6",
"iconv-lite": "^0.7.3",
"node-cache": "^5.1.2",
"xml2js": "^0.6.2"
},
@@ -1845,6 +1846,22 @@
"node": ">=10.19.0"
}
},
"node_modules/iconv-lite": {
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -2452,6 +2469,12 @@
"node": ">=10"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/sax": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz",
+1
View File
@@ -14,6 +14,7 @@
"@fastify/multipart": "^9.0.3",
"fastify": "^5.3.1",
"got": "^14.4.6",
"iconv-lite": "^0.7.3",
"node-cache": "^5.1.2",
"xml2js": "^0.6.2"
},
+2
View File
@@ -25,5 +25,7 @@ export const config = {
tokenTTL: 10 * 60 * 60 * 1000,
maxRetries: 3,
publicCheckConcurrency: 10,
validatorPollIntervalMs: 2000,
validatorTimeoutMs: 4 * 60 * 1000,
},
}
+10 -1
View File
@@ -1,6 +1,6 @@
import type { FastifyInstance } from 'fastify'
import type { TrueApiEnv } from '../config.js'
import { getAuthKey, signIn, clearToken, getAuthStatus, setTokenDirect } from '../services/auth-service.js'
import { getAuthKey, signIn, clearToken, getAuthStatus, setTokenDirect, getCachedToken } from '../services/auth-service.js'
import { AppError, extractDebugInfo } from '../services/cises-service.js'
const VALID_ENVS = ['sandbox', 'prod']
@@ -71,6 +71,15 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
return getAuthStatus(env)
})
app.get('/auth/token', async (request, reply) => {
const env = parseEnv(request.query as Record<string, string>)
const token = getCachedToken(env)
if (!token) {
return reply.status(404).send({ error: 'Token not found' })
}
return { token }
})
app.post('/auth/logout', async (request) => {
const env = parseEnv(request.query as Record<string, string>)
clearToken(env)
+70
View File
@@ -2,6 +2,7 @@ import type { FastifyInstance } from 'fastify'
import type { TrueApiEnv } from '../config.js'
import { parseUpdXml } from '../services/upd-parser.js'
import { checkCodesAuth, AppError } from '../services/cises-service.js'
import { validateUpd, getUpdValidationStatus, decodeXmlBuffer } from '../services/upd-validator-service.js'
const VALID_ENVS = ['sandbox', 'prod']
@@ -58,4 +59,73 @@ export async function updRoutes(app: FastifyInstance): Promise<void> {
throw err
}
})
// Предварительная проверка УПД целиком в ГИС МТ (True API /doc/validator/create)
app.post('/api/validate-upd', async (request, reply) => {
const query = request.query as Record<string, string>
const env = parseEnv(query)
const checkPermits = query.checkPermits === '1'
const data = await request.file()
if (!data) {
return reply.status(400).send({ error: 'No file uploaded' })
}
const buffer = await data.toBuffer()
const fileName = data.filename
let parsed
try {
parsed = await parseUpdXml(decodeXmlBuffer(buffer), fileName)
} catch {
return reply.status(400).send({ error: 'Invalid XML file' })
}
try {
const validation = await validateUpd(env, buffer, fileName, {
preliminaryWarnings: checkPermits ? [3] : [],
})
return {
document: {
number: parsed.documentNumber,
date: parsed.documentDate,
seller: parsed.sellerName,
buyer: parsed.buyerName,
fileName: parsed.fileName,
},
codesFound: parsed.codes.length,
validation,
}
} catch (err) {
if (err instanceof AppError) {
return reply.status(err.statusCode).send({
error: err.message,
debugInfo: err.debugInfo,
})
}
throw err
}
})
// Повторное получение статуса проверки УПД (если первый запрос завершился по таймауту)
app.get('/api/validate-upd/status', async (request, reply) => {
const env = parseEnv(request.query as Record<string, string>)
const documentId = (request.query as Record<string, string>).documentId
if (!documentId) {
return reply.status(400).send({ error: 'Missing documentId' })
}
try {
return await getUpdValidationStatus(env, documentId)
} catch (err) {
if (err instanceof AppError) {
return reply.status(err.statusCode).send({
error: err.message,
debugInfo: err.debugInfo,
})
}
throw err
}
})
}
@@ -0,0 +1,216 @@
import got from 'got'
import type { Response } from 'got'
import iconv from 'iconv-lite'
import { config, envUrls } from '../config.js'
import type { TrueApiEnv } from '../config.js'
import { getCachedToken, clearToken } from './auth-service.js'
import { AppError, extractDebugInfoFromResponse } from './cises-service.js'
import type { UpdValidationError, UpdValidationWarning } from '../types/index.js'
const FINAL_STATUSES = ['VERIFIED', 'NOT_VALID', 'NOT_FOUND']
interface ValidatorStatusResponse {
status: string
description?: string
errors?: UpdValidationError[]
preliminaryWarnings?: UpdValidationWarning[]
}
export interface UpdValidationResult {
documentId: string
status: string
description?: string
errors: UpdValidationError[]
preliminaryWarnings: UpdValidationWarning[]
pending: boolean
}
export function decodeXmlBuffer(buffer: Buffer): string {
return iconv.decode(buffer, detectEncoding(buffer))
}
// True API принимает УПД только в кодировке Windows-1251
export function toWindows1251(buffer: Buffer): Buffer {
if (detectEncoding(buffer) === 'utf-8') {
let text = iconv.decode(buffer, 'utf-8')
text = text.replace(/encoding\s*=\s*["']utf-?8["']/i, 'encoding="windows-1251"')
return iconv.encode(text, 'windows-1251')
}
return buffer
}
export function extractFileId(buffer: Buffer): string | undefined {
const m = buffer.toString('latin1').match(/ИдФайл\s*=\s*["']([^"']+)["']/)
return m ? m[1] : undefined
}
function detectEncoding(buffer: Buffer): 'utf-8' | 'windows-1251' {
const head = buffer.subarray(0, 200).toString('ascii')
const m = head.match(/encoding\s*=\s*["']([^"']+)["']/)
const enc = m ? m[1].toLowerCase().replace(/\s/g, '') : ''
return enc === 'utf-8' || enc === 'utf8' ? 'utf-8' : 'windows-1251'
}
function stripXmlExtension(fileName: string): string {
return fileName.replace(/\.xml$/i, '')
}
function apiClient(env: TrueApiEnv, token: string) {
return gotClient(env).extend({
headers: { Authorization: `Bearer ${token}` },
})
}
function gotClient(env: TrueApiEnv) {
return got.extend({
prefixUrl: envUrls[env].baseUrl,
responseType: 'json',
hooks: {
beforeRequest: [
(options) => {
const headers = { ...options.headers } as Record<string, string>
if (headers.authorization) {
headers.authorization = 'Bearer ***'
}
console.log(JSON.stringify({
type: 'trueapi_request',
method: options.method,
url: String(options.url || ''),
headers,
body: options.json || options.body || null,
}))
},
],
afterResponse: [
(response: Response) => {
console.log(JSON.stringify({
type: 'trueapi_response',
statusCode: response.statusCode,
url: response.requestUrl,
body: response.body,
}))
return response
},
],
},
})
}
export async function validateUpd(
env: TrueApiEnv,
buffer: Buffer,
fileName: string,
opts?: { preliminaryWarnings?: number[] },
): Promise<UpdValidationResult> {
const token = getCachedToken(env)
if (!token) throw new AppError('Not authenticated', 401)
const content = toWindows1251(buffer)
const documentId = extractFileId(buffer) || stripXmlExtension(fileName)
const payload: Record<string, unknown> = {
id: documentId,
content: content.toString('base64'),
documentType: 'UPD',
documentFormat: 'XML',
}
if (opts?.preliminaryWarnings?.length) {
payload.preliminaryWarnings = opts.preliminaryWarnings
}
await submitForValidation(env, token, payload)
// Первый опрос — только после небольшой паузы: сразу после отправки
// ГИС МТ отвечает 404 «Отсутствует сообщение с сервера», пока сообщение
// не поднялось на асинхронную обработку.
await sleep(config.trueApi.validatorPollIntervalMs)
const deadline = Date.now() + config.trueApi.validatorTimeoutMs
let last: ValidatorStatusResponse = { status: 'AWAITING' }
while (true) {
last = await getValidationStatus(env, token, documentId)
if (FINAL_STATUSES.includes(last.status) || Date.now() >= deadline) break
await sleep(config.trueApi.validatorPollIntervalMs)
}
return {
documentId,
status: last.status,
description: last.description,
errors: last.errors || [],
preliminaryWarnings: last.preliminaryWarnings || [],
pending: !FINAL_STATUSES.includes(last.status),
}
}
export async function getUpdValidationStatus(env: TrueApiEnv, documentId: string): Promise<UpdValidationResult> {
const token = getCachedToken(env)
if (!token) throw new AppError('Not authenticated', 401)
const last = await getValidationStatus(env, token, documentId)
return {
documentId,
status: last.status,
description: last.description,
errors: last.errors || [],
preliminaryWarnings: last.preliminaryWarnings || [],
pending: !FINAL_STATUSES.includes(last.status),
}
}
async function submitForValidation(env: TrueApiEnv, token: string, payload: Record<string, unknown>): Promise<void> {
const response = await apiClient(env, token).post('doc/validator/create', {
json: payload,
timeout: { request: 30000 },
throwHttpErrors: false,
})
if (response.statusCode === 401) {
clearToken(env)
throw new AppError('Token expired', 401, extractDebugInfoFromResponse(response))
}
if (response.statusCode !== 200) {
const debugInfo = extractDebugInfoFromResponse(response)
const apiErr = response.body && typeof response.body === 'object'
? (response.body as Record<string, unknown>).error_message || ''
: ''
throw new AppError(`True API error: ${apiErr || response.statusCode}`, response.statusCode, debugInfo)
}
}
async function getValidationStatus(
env: TrueApiEnv, token: string, documentId: string
): Promise<ValidatorStatusResponse> {
const response = await apiClient(env, token).post<ValidatorStatusResponse>('doc/validator/status', {
json: { id: documentId },
timeout: { request: 30000 },
throwHttpErrors: false,
})
if (response.statusCode === 401) {
clearToken(env)
throw new AppError('Token expired', 401, extractDebugInfoFromResponse(response))
}
// HTTP 404 здесь означает, что сообщение ещё не поднялось на обработку
// («Отсутствует сообщение с сервера»), а не финальный статус NOT_FOUND —
// опрос продолжается до таймаута.
if (response.statusCode === 404) {
return { status: 'AWAITING' }
}
if (!response.body || typeof response.body !== 'object') {
throw new AppError(
`True API error: ${response.statusCode}`,
response.statusCode,
extractDebugInfoFromResponse(response),
)
}
return response.body
}
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
+21
View File
@@ -91,3 +91,24 @@ export interface CheckCodesResponse {
errorCount: number
debugInfo?: ApiDebugInfo
}
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
}
+11
View File
@@ -12,6 +12,11 @@ services:
- ./plans:/app/plans:ro
networks:
- chestny-znak-net
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
frontend:
build:
@@ -24,6 +29,12 @@ services:
- backend
networks:
- chestny-znak-net
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
networks:
chestny-znak-net:
+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
}