DFiP_Budget_planing/perf/analysis/svod2_search_oracle.mjs
2026-10-29 00:23:34 +04:00

312 lines
14 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Оракул экрана «до» свода 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);
}