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