diff --git a/README.md b/README.md index 4c28575..868d87e 100644 --- a/README.md +++ b/README.md @@ -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=` — повторное получение статуса проверки УПД ## Переменные окружения (backend/.env) diff --git a/backend/package-lock.json b/backend/package-lock.json index c59cb5a..e584fec 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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", diff --git a/backend/package.json b/backend/package.json index e6cc965..57f1514 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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" }, diff --git a/backend/src/config.ts b/backend/src/config.ts index 87ecfc2..b7c24c4 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -25,5 +25,7 @@ export const config = { tokenTTL: 10 * 60 * 60 * 1000, maxRetries: 3, publicCheckConcurrency: 10, + validatorPollIntervalMs: 2000, + validatorTimeoutMs: 4 * 60 * 1000, }, } diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 1b09e40..ff7c4cb 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -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 { return getAuthStatus(env) }) + app.get('/auth/token', async (request, reply) => { + const env = parseEnv(request.query as Record) + 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) clearToken(env) diff --git a/backend/src/routes/upd.ts b/backend/src/routes/upd.ts index aef6af3..797f0e8 100644 --- a/backend/src/routes/upd.ts +++ b/backend/src/routes/upd.ts @@ -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 { throw err } }) + + // Предварительная проверка УПД целиком в ГИС МТ (True API /doc/validator/create) + app.post('/api/validate-upd', async (request, reply) => { + const query = request.query as Record + 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) + const documentId = (request.query as Record).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 + } + }) } diff --git a/backend/src/services/upd-validator-service.ts b/backend/src/services/upd-validator-service.ts new file mode 100644 index 0000000..44b3ecf --- /dev/null +++ b/backend/src/services/upd-validator-service.ts @@ -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 + 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 { + 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 = { + 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 { + 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): Promise { + 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).error_message || '' + : '' + throw new AppError(`True API error: ${apiErr || response.statusCode}`, response.statusCode, debugInfo) + } +} + +async function getValidationStatus( + env: TrueApiEnv, token: string, documentId: string +): Promise { + const response = await apiClient(env, token).post('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 { + return new Promise(resolve => setTimeout(resolve, ms)) +} diff --git a/backend/src/types/index.ts b/backend/src/types/index.ts index 2b9eb61..19e1dc0 100644 --- a/backend/src/types/index.ts +++ b/backend/src/types/index.ts @@ -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 +} diff --git a/docker-compose.yml b/docker-compose.yml index 1d05f41..6a33c1b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/frontend/nginx.conf b/frontend/nginx.conf index b948c8a..2104943 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -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 { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0755e29..0175f21 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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('auth') @@ -78,6 +79,11 @@ export default function App() { disabled={!authenticated}> Загрузка УПД + )} diff --git a/frontend/src/pages/ValidateUpdPage.tsx b/frontend/src/pages/ValidateUpdPage.tsx new file mode 100644 index 0000000..8500ade --- /dev/null +++ b/frontend/src/pages/ValidateUpdPage.tsx @@ -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(null) + const [checkPermits, setCheckPermits] = useState(false) + const [response, setResponse] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + const [errorDebug, setErrorDebug] = useState(null) + const [debugExpanded, setDebugExpanded] = useState(false) + const [dragging, setDragging] = useState(false) + const inputRef = useRef(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 ( +
+

Проверка УПД через True API (предварительная проверка)

+

+ Весь XML-документ отправляется в ГИС МТ ({''}) — проверяются регистрация + продавца/покупателя, корректность УД, коды маркировки, статусы КИ и полномочия продавца. +

+ +
{ 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 ? ( +
+ {file.name} ({(file.size / 1024).toFixed(1)} KB) +
+ +
+ ) : ( +
+
📄
+
Перетащите XML-файл УПД сюда или нажмите для выбора
+
+ )} + setFileIfValid(e.target.files?.[0] || null)} + /> +
+ + + + + + {error && ( +
Ошибка: {error}
+ )} + + {errorDebug && ( +
+ + {debugExpanded && ( +
+              {errorDebug}
+            
+ )} +
+ )} + + {response && ( +
+
+ Документ:{' '} + {response.document.number || '—'} от {response.document.date || '—'} +
+ Продавец: {response.document.seller || '—'} +
+ Покупатель: {response.document.buyer || '—'} +
+ Кодов в документе: {response.codesFound} +
+ ИД файла: {response.validation.documentId} +
+ + + + {response.validation.errors.length > 0 && ( +
+

+ Ошибки проверки ({response.validation.errors.length}) +

+ +
+ )} + + {response.validation.preliminaryWarnings.length > 0 && ( +
+

+ Предупреждения ({response.validation.preliminaryWarnings.length}) +

+ +
+ )} +
+ )} +
+ ) +} + +function ValidationStatusCard({ + validation, + refreshing, + onRefresh, +}: { + validation: UpdValidationResult + refreshing: boolean + onRefresh: () => void +}) { + const metaMap: Record = { + 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 ( +
+ {meta.label} + {validation.description && ( + {validation.description} + )} + {validation.pending && ( + + )} +
+ ) +} + +function ErrorTable({ errors }: { errors: UpdValidationResult['errors'] }) { + return ( +
+ + + + + + + + + + {errors.map((e, i) => ( + + + + + + ))} + +
Код ошибкиОписаниеКИ
{e.code ?? '—'}{e.description || '—'} + {e.cis || '—'} +
+
+ ) +} + +function WarningTable({ warnings }: { warnings: UpdValidationResult['preliminaryWarnings'] }) { + return ( +
+ + + + + + + + + + {warnings.map((w, i) => ( + + + + + + ))} + +
КодОписаниеКИ
{w.code ?? '—'}{w.description || '—'} + {w.cis || '—'} +
+
+ ) +} + +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', +} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index b1ec9c7..172167e 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -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 { @@ -45,7 +46,7 @@ async function request(url: string, options?: RequestInit & { env?: ApiEnv }) Object.assign(headers, options.headers as Record) } - 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 { 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 { await request('/auth/logout', { method: 'POST', env }) } @@ -97,6 +102,21 @@ export async function uploadUpd(file: File, env?: ApiEnv): Promise { + 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 { + return request(`/api/validate-upd/status?documentId=${encodeURIComponent(documentId)}`, { env }) +} + export async function setTokenManually(token: string, env?: ApiEnv): Promise { await request('/auth/setToken', { method: 'POST', diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 0d8cade..2d22a94 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -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 +}