// Оракул экрана «до» свода 2 (Svod2Page на коммите 399234a + material-react-table). Копии дословные. // matchesSearch: Svod2Page.jsx:283-294; equals: material-react-table/dist/index.esm.js:663-667. import { mkdirSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const matchesSearch = (row, query) => { const searchableData = { ...row.data?.header, ...row.data?.contract_summary, ...row.data?.contract_detail, }; return Object.values(searchableData).some((value) => String(value ?? '') .toLowerCase() .includes(query), ); }; const equals = (row, id, filterValue) => { var _a; return ((_a = row.getValue(id)) === null || _a === void 0 ? void 0 : _a.toString().toLowerCase().trim()) === filterValue.toString().toLowerCase().trim(); }; equals.autoRemove = (val) => !val; export { matchesSearch, equals }; export const FILTER_COLUMNS = ['smeta_type', 'smeta_direction', 'org_name']; // accessorFn колонок: valueAt(row, ['header', id]) const asTableRow = (row, id) => ({ getValue: () => row.data?.header?.[id] }); const cellValue = (row, id) => row.data?.header?.[id]; const activeFilters = (filters, skipId) => FILTER_COLUMNS.filter((id) => id !== skipId && filters[id] && !equals.autoRemove(filters[id])); const bySearch = (rows, search) => { const normalizedSearch = String(search ?? '').trim().toLowerCase(); return normalizedSearch ? rows.filter((row) => matchesSearch(row, normalizedSearch)) : rows; }; const byFilters = (rows, filters, skipId) => { const ids = activeFilters(filters, skipId); return rows.filter((row) => ids.every((id) => equals({ getValue: () => cellValue(row, id) }, id, filters[id]))); }; export function screenRows(rows, { search = '', filters = {} } = {}) { return byFilters(bySearch(rows, search), filters, null); } export function screenFound(rows, search) { return { found: bySearch(rows, search).length, total: rows.length }; } export function screenFacets(rows, { search = '', filters = {} } = {}) { const searched = bySearch(rows, search); const result = { total: rows.length, search_total: searched.length }; for (const id of FILTER_COLUMNS) { const counts = new Map(); for (const row of byFilters(searched, filters, id)) { const value = asTableRow(row, id).getValue(id); counts.set(value, (counts.get(value) ?? 0) + 1); } result[id] = Array.from(counts.keys()) .filter((value) => value !== null && value !== undefined) .sort((a, b) => a.localeCompare(b)) .map((value) => [value, counts.get(value)]); } return result; } export const NUMBER_VECTOR_INPUTS = [ '0', '-0', '1', '-1', '100', '100.00', '1234.50', '12.34', '0.5', '0.30000000000000004', '0.000001', '1e-7', '-1.5e-10', '123e-20', '1e15', '1e20', '1e21', '123456789012345680000', '9007199254740993', '5e-324', '1.7976931348623157e308', ]; // Числа в строках берём через JSON.parse, чтобы запись была как у JSON-ответа API. const J = (text) => JSON.parse(text); export const SYNTHETIC_ROWS = [ { data: { line_id: 1, header: { name: 'Аренда помещения', smeta_type: 'КВ', smeta_direction: 'Развитие', org_name: 'Филиал Ёлки' }, contract_summary: { counterparty: 'ООО Ромашка' }, contract_detail: { counterparty: null, ceiling_amount: J('1234.50') } } }, { data: { line_id: 2, header: { name: 'АРЕНДА склада', smeta_type: 'АХР', smeta_direction: 'Поддержка', org_name: 'Филиал Ёлки' }, contract_summary: {}, contract_detail: { counterparty: 'ООО Ромашка Плюс', ceiling_amount: J('100') } } }, { data: { line_id: 3, header: { name: 'Ёлка новогодняя', smeta_type: 'КВ', smeta_direction: null, org_name: 'филиал ёлки ' }, contract_summary: { counterparty: 'ИП Ёж' }, contract_detail: { ceiling_amount: J('0') } } }, { data: { line_id: 4, header: { name: 'ёжик', smeta_type: 'кв ', smeta_direction: 'Развитие', org_name: 'Центр' }, contract_summary: {}, contract_detail: { ceiling_amount: J('1e-7') } } }, { data: { line_id: 5, header: { name: 'Услуги связи', smeta_type: 'КВ', smeta_direction: 'Поддержка', org_name: 'Центр' }, contract_summary: {}, contract_detail: { note: 'a b' } } }, { data: { line_id: 6, header: { name: 'Без маркера', smeta_type: 'АХР', smeta_direction: 'Развитие', org_name: 'Север' }, contract_summary: {}, contract_detail: { note: 'xy' } } }, { data: { line_id: 7, header: { name: 'Флаг', smeta_type: 'КВ', smeta_direction: 'Развитие', org_name: 'Север', is_active: true }, contract_summary: { flag: true }, contract_detail: { note: null } } }, { data: { line_id: 8, header: { name: null, smeta_type: null, smeta_direction: null, org_name: null }, contract_summary: {}, contract_detail: {} } }, { data: { line_id: 9, header: { name: 'Итого 1', smeta_type: 'АХР', smeta_direction: 'Поддержка', org_name: 'Центр' }, contract_summary: { counterparty: 'ООО Ромашка' }, contract_detail: { counterparty: 'ООО Ромашка', ceiling_amount: J('1234.5') } } }, { data: { line_id: 10, header: { name: 'Прочее', smeta_type: 'ПР', smeta_direction: 'Развитие', org_name: 'Филиал Ёлки' }, contract_summary: {}, contract_detail: { ceiling_amount: J('1e-7'), note: 'Договор №1' } } }, { data: { line_id: 11, header: { name: 'Лицензии', smeta_type: 'КВ', smeta_direction: 'Поддержка', org_name: 'Юг' }, contract_summary: {}, contract_detail: { ceiling_amount: J('100.00') } } }, ]; export const SCREEN_CASES = [ { search: '', filters: {} }, { search: 'аРЕНДА', filters: {} }, { search: 'ё', filters: {} }, { search: 'ромаш', filters: {} }, { search: '1234.5', filters: {} }, { search: '1234.50', filters: {} }, { search: ' аренда ', filters: {} }, { search: ' ', filters: {} }, { search: '1', filters: {} }, { search: '', filters: { smeta_type: 'кв ' } }, { search: '', filters: { org_name: 'Филиал Ёлки' } }, { search: '', filters: { smeta_type: 'КВ', smeta_direction: 'Развитие' } }, { search: 'ё', filters: { smeta_type: 'КВ' } }, { search: '', filters: { org_name: 'филиал ёлки' } }, ]; export function emitNumberVectors() { return NUMBER_VECTOR_INPUTS.map((input) => ({ input, js: String(JSON.parse(input)) })); } export function emitScreenVectors() { return { rows: SYNTHETIC_ROWS, cases: SCREEN_CASES.map(({ search, filters }) => ({ search, filters, line_ids: screenRows(SYNTHETIC_ROWS, { search, filters }).map((r) => r.data.line_id), found: screenFound(SYNTHETIC_ROWS, search), facets: screenFacets(SYNTHETIC_ROWS, { search, filters }), })), }; } // ---- Режим --check: сверка ответов API с экраном «до» на данных стенда (только чтение) ---- const firstDefined = (rows, pick) => { for (const row of rows) { const v = pick(row); if (v !== null && v !== undefined && v !== '') return v; } return undefined; }; const middle = (text) => { const t = String(text).trim(); return t.length <= 4 ? t : t.slice(1, Math.min(t.length, 7)); }; export function deriveCases(fullRows) { const cases = []; const seen = new Set(); const add = (search, filters = {}) => { const key = JSON.stringify([search, filters]); if (seen.has(key)) return; seen.add(key); cases.push({ search, filters }); }; for (const q of ['аренда', 'АРЕНДА', '1', ',']) add(q); const org = firstDefined(fullRows, (r) => r.data?.header?.org_name); const orgSub = org === undefined ? undefined : middle(org); const cp = firstDefined(fullRows, (r) => r.data?.contract_detail?.counterparty); const cpSub = cp === undefined ? undefined : middle(cp); const fractional = firstDefined(fullRows, (r) => { const values = [...Object.values(r.data?.contract_detail ?? {}), ...Object.values(r.data?.header ?? {})]; return values.find((v) => typeof v === 'number' && !Number.isInteger(v)); }); const num = fractional === undefined ? undefined : String(fractional); const type = firstDefined(fullRows, (r) => r.data?.header?.smeta_type); const typeFilter = type === undefined ? undefined : `${String(type).toLowerCase()} `; if (orgSub !== undefined) add(orgSub.toLowerCase()); if (cpSub !== undefined) add(cpSub); if (num !== undefined) add(num); if (typeFilter !== undefined) add('', { smeta_type: typeFilter }); if (org !== undefined) add('', { org_name: org }); // пары if (orgSub !== undefined && typeFilter !== undefined) add(orgSub, { smeta_type: typeFilter }); if (cpSub !== undefined && org !== undefined) add(cpSub, { org_name: org }); if (num !== undefined && typeFilter !== undefined) add(num, { smeta_type: typeFilter }); if (typeFilter !== undefined && org !== undefined) add('', { smeta_type: typeFilter, org_name: org }); if (cpSub !== undefined && typeFilter !== undefined && org !== undefined) { add(cpSub, { smeta_type: typeFilter, org_name: org }); } return cases; } const sortedFacets = (facets) => { const out = {}; for (const [k, v] of Object.entries(facets ?? {})) { out[k] = Array.isArray(v) ? v.map(([value, count]) => [value, count]).sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)) : v; } return out; }; // expected/actual: { line_ids, total, found, facets }, facets в форме screenFacets ([значение, число]). export function compareCase(expected, actual) { const diffs = []; const a = expected.line_ids ?? []; const b = actual.line_ids ?? []; if (a.length !== b.length) diffs.push(`line_ids: длина ${a.length} против ${b.length}`); else { const at = a.findIndex((id, i) => id !== b[i]); if (at >= 0) diffs.push(`line_ids: первое расхождение на позиции ${at}: ${a[at]} против ${b[at]}`); } if (expected.total !== actual.total) diffs.push(`total: ${expected.total} против ${actual.total}`); if (expected.found !== actual.found) diffs.push(`found: ${expected.found} против ${actual.found}`); const ef = sortedFacets(expected.facets); const af = sortedFacets(actual.facets); for (const key of new Set([...Object.keys(ef), ...Object.keys(af)])) { if (JSON.stringify(ef[key]) !== JSON.stringify(af[key])) { diffs.push(`facets.${key}: ${JSON.stringify(ef[key])} против ${JSON.stringify(af[key])}`); } } return diffs; } const apiFacetsToScreen = (result) => { const out = { total: result.total, search_total: result.search_total }; for (const id of FILTER_COLUMNS) out[id] = (result[id] ?? []).map(({ value, count }) => [value, count]); return out; }; export async function runCheck({ api, username, password, year, sheets = ['GO', 'RF'], pageSize = 300 }) { const report = []; const base = api.replace(/\/$/, ''); const getJson = async (url, init) => { const res = await fetch(url, init); if (!res.ok) throw new Error(`${init?.method ?? 'GET'} ${url}: HTTP ${res.status}`); return res.json(); }; const login = await getJson(`${base}/api/v1/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ username, password }), }); const headers = { authorization: `Bearer ${login.access_token}` }; const qs = (extra) => { const p = new URLSearchParams(); for (const [k, v] of Object.entries(extra)) if (v !== undefined && v !== '') p.set(k, String(v)); return p.toString(); }; for (const sheet of sheets) { const url = `${base}/api/v1/svod/2/${sheet}/${year}`; const legacy = await getJson(url, { headers }); const fullRows = legacy.result ?? []; for (const c of deriveCases(fullRows)) { const label = `${sheet} search=${JSON.stringify(c.search)} filters=${JSON.stringify(c.filters)}`; try { const params = { view: 'compact', search: c.search, ...c.filters }; const ids = []; let total; for (let offset = 0; ; offset += pageSize) { const page = await getJson(`${url}?${qs({ ...params, offset, limit: pageSize })}`, { headers }); const rows = page.result ?? []; if (offset === 0) total = page.total; else if (page.total !== total) report.push(`${label}: total меняется между страницами: ${total} и ${page.total}`); for (const r of rows) ids.push(r.data?.line_id); if (rows.length < pageSize || ids.length >= page.total) break; } const facets = await getJson(`${url}/facets?${qs({ search: c.search, ...c.filters })}`, { headers }); const expectedRows = screenRows(fullRows, c); const expected = { line_ids: expectedRows.map((r) => r.data?.line_id), total: expectedRows.length, found: screenFound(fullRows, c.search).found, facets: screenFacets(fullRows, c), }; const actualFacets = apiFacetsToScreen(facets.result ?? {}); const actual = { line_ids: ids, total, found: actualFacets.search_total, facets: actualFacets }; for (const d of compareCase(expected, actual)) report.push(`${label}: ${d}`); } catch (e) { report.push(`${label}: ошибка ${e.message}`); } } } return { ok: report.length === 0, report }; } if (process.argv[1] === fileURLToPath(import.meta.url)) { const i = process.argv.indexOf('--emit-vectors'); if (i > 0 && process.argv[i + 1]) { const dir = process.argv[i + 1]; mkdirSync(dir, { recursive: true }); writeFileSync(path.join(dir, 'js_number_text_vectors.json'), `${JSON.stringify(emitNumberVectors(), null, 2)}\n`); writeFileSync(path.join(dir, 'svod2_screen_vectors.json'), `${JSON.stringify(emitScreenVectors(), null, 2)}\n`); } } if (process.argv[1] === fileURLToPath(import.meta.url) && process.argv.includes('--check')) { const arg = (name) => { const i = process.argv.indexOf(`--${name}`); return i > 0 ? process.argv[i + 1] : undefined; }; const { ok, report } = await runCheck({ api: arg('api'), username: arg('username'), password: arg('password'), year: arg('year'), }); for (const line of report) console.log(line); console.log(ok ? 'CHECK_OK' : `CHECK_FAILED: расхождений ${report.length}`); process.exit(ok ? 0 : 1); }