ok
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
Generated
+23
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -25,5 +25,7 @@ export const config = {
|
||||
tokenTTL: 10 * 60 * 60 * 1000,
|
||||
maxRetries: 3,
|
||||
publicCheckConcurrency: 10,
|
||||
validatorPollIntervalMs: 2000,
|
||||
validatorTimeoutMs: 4 * 60 * 1000,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user