Версия 2 с выгрузкой

This commit is contained in:
Kulanbaev B 2026-05-18 15:07:00 +05:00
parent 8b7201500e
commit 4c092e2535

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Валидатор БИН и ИИН — MVP</title> <title>Валидатор БИН и ИИН — MVP</title>
<script src="https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/xlsx-js-style@1.2.0/dist/xlsx.bundle.js"></script>
<style> <style>
:root { :root {
--bg: #f4f6fa; --bg: #f4f6fa;
@ -90,7 +90,7 @@
.hint { color: var(--muted); font-size: 13px; } .hint { color: var(--muted); font-size: 13px; }
.stats { .stats {
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 12px; gap: 12px;
margin-bottom: 20px; margin-bottom: 20px;
} }
@ -201,7 +201,7 @@
<header> <header>
<h1>Валидатор БИН и ИИН в налоговых регистрах</h1> <h1>Валидатор БИН и ИИН в налоговых регистрах</h1>
<p>Автоматическое определение типа по 5-му знаку · ФЛК-проверка локально · сверка с реестром КГД (demo-данные)</p> <p>Автоопределение типа по 5-му знаку · ФЛК локально · сверка с КГД (demo) · возраст компании · экспорт в .xlsx с подсветкой</p>
</header> </header>
<main> <main>
@ -248,6 +248,11 @@
<div class="lbl">КГД: внимание</div> <div class="lbl">КГД: внимание</div>
<div class="sub">только для БИН</div> <div class="sub">только для БИН</div>
</div> </div>
<div class="stat warn">
<div class="num" id="statAge">0</div>
<div class="lbl">Возрастные риски</div>
<div class="sub" id="statAgeSub">свежие фирмы, дети, &gt;100 лет</div>
</div>
</div> </div>
<div class="card"> <div class="card">
@ -263,7 +268,7 @@
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<button class="secondary" id="exportBtn">Экспорт в CSV</button> <button class="secondary" id="exportBtn">Экспорт в Excel (.xlsx)</button>
</div> </div>
</div> </div>
@ -278,6 +283,7 @@
<th>ФЛК</th> <th>ФЛК</th>
<th>Подтип</th> <th>Подтип</th>
<th>Дата</th> <th>Дата</th>
<th>Возраст</th>
<th>КГД: статус</th> <th>КГД: статус</th>
<th>КГД: наименование</th> <th>КГД: наименование</th>
<th>Комментарий</th> <th>Комментарий</th>
@ -345,7 +351,32 @@
────────────────────────────────────────────────────────────────────── ──────────────────────────────────────────────────────────────────────
КГД-сверка (реестр налогоплательщиков): в этом MVP используется встроенный КГД-сверка (реестр налогоплательщиков): в этом MVP используется встроенный
demo-словарь из нескольких компаний. В продакшене подменяется на вызов demo-словарь из нескольких компаний. В продакшене подменяется на вызов
api.kgd.gov.kz или Playwright-сценарий.</pre> api.kgd.gov.kz или Playwright-сценарий.
──────────────────────────────────────────────────────────────────────
Возрастные риски (для валидных номеров):
Для БИН (по дате регистрации):
&lt; 3 мес. 🚨 «свежая фирма» — возможный признак однодневки
3–12 мес. ⚠️ «молодая» — повышенное внимание
&gt; 12 мес. без флага
Для ИИН (по дате рождения):
&lt; 16 лет 🚨 не может быть руководителем
&gt; 100 лет 🚨 проверьте корректность данных
──────────────────────────────────────────────────────────────────────
Экспорт результатов в .xlsx:
1) Если был загружен Excel — открывается исходный файл, ячейки в столбце
с БИН/ИИН подсвечиваются цветом (зелёный/жёлтый/красный) прямо
по своим местам. Справа добавляются служебные колонки: Тип, ФЛК,
Подтип, Дата, Возраст, КГД статус, КГД наименование, Комментарий.
2) В demo-режиме создаётся новый файл с этими же колонками.
Внимание: при экспорте оригинальные форматы ячеек (шрифты, цвета шапки)
заменяются на стандартные — данные сохраняются, оформление нет.</pre>
</details> </details>
</div> </div>
@ -646,26 +677,94 @@ function parseSheet(rows) {
return { items, info: { ...det, letter: colLetter(det.colIdx) } }; return { items, info: { ...det, letter: colLetter(det.colIdx) } };
} }
// =============================================================
// Возраст и риск-флаги
// =============================================================
function pluralRu(n, forms) {
// forms = ["год","года","лет"]
const n100 = Math.abs(n) % 100;
if (n100 >= 11 && n100 <= 14) return forms[2];
const n10 = n100 % 10;
if (n10 === 1) return forms[0];
if (n10 >= 2 && n10 <= 4) return forms[1];
return forms[2];
}
function computeAge(res, today = new Date()) {
if (!res.is_valid) return null;
const Y = today.getFullYear(), M = today.getMonth() + 1, D = today.getDate();
if (res.kind === "БИН") {
const months = (Y - res.year) * 12 + (M - res.month);
if (months < 0) {
return { kind: "БИН", months, label: `в будущем (${res.date_str})`, risk: "impossible_bin" };
}
const years = Math.floor(months / 12);
const rem = months % 12;
let label;
if (years === 0) label = `${months} мес`;
else if (rem === 0) label = `${years} ${pluralRu(years, ["год","года","лет"])}`;
else label = `${years} ${pluralRu(years, ["г.","г.","г."])} ${rem} мес`;
let risk = null;
if (months < 3) risk = "fresh";
else if (months < 12) risk = "young_company";
return { kind: "БИН", months, label, risk };
}
if (res.kind === "ИИН") {
let age = Y - res.year;
if (M < res.month || (M === res.month && D < res.day)) age--;
let risk = null, label;
if (age < 0) { risk = "impossible_iin"; label = "в будущем"; }
else if (age < 16) { risk = "underage"; label = `${age} ${pluralRu(age, ["год","года","лет"])}`; }
else if (age > 100) { risk = "overage"; label = `${age} ${pluralRu(age, ["год","года","лет"])}`; }
else { label = `${age} ${pluralRu(age, ["год","года","лет"])}`; }
return { kind: "ИИН", years: age, label, risk };
}
return null;
}
const AGE_RISK_TEXT = {
fresh: "Компания зарегистрирована менее 3 мес. назад — возможный признак однодневки",
young_company: "Компания младше 1 года — повышенное внимание при проверке",
impossible_bin: "Дата регистрации в будущем — ошибка данных",
underage: "Возраст менее 16 лет — не может быть руководителем/контрагентом",
overage: "Возраст более 100 лет — проверьте корректность данных",
impossible_iin: "Дата рождения в будущем — ошибка данных",
};
function ageEmoji(risk) {
if (!risk) return "";
if (risk === "young_company") return "⚠️";
return "🚨";
}
// ============================================================= // =============================================================
// Состояние и рендер // Состояние и рендер
// ============================================================= // =============================================================
let currentResults = []; // [{ rowNum, res, kgd }] let currentResults = []; // [{ rowNum, res, kgd, age }]
let originalBuffer = null;
let originalFileName = null;
let lastColumnInfo = null;
function runChecks(items) { function runChecks(items) {
currentResults = items.map(({ rowNum, value }) => { currentResults = items.map(({ rowNum, value }) => {
const res = validateId(value); const res = validateId(value);
const kgd = kgdLookup(res); const kgd = kgdLookup(res);
return { rowNum, res, kgd }; const age = computeAge(res);
return { rowNum, res, kgd, age };
}); });
renderResults(); renderResults();
} }
function rowClass(res, kgd) { function rowClass(res, kgd, age) {
if (!res.is_valid) return "row-err"; if (!res.is_valid) return "row-err";
if (res.kind === "БИН" && kgd) { if (res.kind === "БИН" && kgd) {
if (!kgd.in_registry) return "row-warn"; if (!kgd.in_registry) return "row-warn";
if (kgd.status !== "Действующий") return "row-warn"; if (kgd.status !== "Действующий") return "row-warn";
} }
if (age && age.risk) return "row-warn";
return "row-ok"; return "row-ok";
} }
@ -701,9 +800,10 @@ function renderResults() {
document.getElementById("resultsSection").style.display = "block"; document.getElementById("resultsSection").style.display = "block";
document.getElementById("emptyState").style.display = "none"; document.getElementById("emptyState").style.display = "none";
let okBin = 0, okIin = 0, errCount = 0, kgdProblems = 0; let okBin = 0, okIin = 0, errCount = 0, kgdProblems = 0, ageRisks = 0;
let freshCnt = 0, underageCnt = 0, overageCnt = 0;
currentResults.forEach(({ rowNum, res, kgd }) => { currentResults.forEach(({ rowNum, res, kgd, age }) => {
if (res.is_valid) { if (res.is_valid) {
if (res.kind === "БИН") okBin++; else if (res.kind === "ИИН") okIin++; if (res.kind === "БИН") okBin++; else if (res.kind === "ИИН") okIin++;
} else { } else {
@ -713,13 +813,28 @@ function renderResults() {
!kgd.in_registry || kgd.status !== "Действующий" !kgd.in_registry || kgd.status !== "Действующий"
); );
if (kgdProblem) kgdProblems++; if (kgdProblem) kgdProblems++;
if (age && age.risk) {
ageRisks++;
if (age.risk === "fresh" || age.risk === "young_company") freshCnt++;
if (age.risk === "underage") underageCnt++;
if (age.risk === "overage" || age.risk === "impossible_iin" || age.risk === "impossible_bin") overageCnt++;
}
const dateCell = res.kind === "БИН" && res.date_str ? res.date_str const dateCell = res.date_str ?? "—";
: res.kind === "ИИН" && res.date_str ? res.date_str const ageCell = age
: "—"; ? `${escapeHtml(age.label)} ${ageEmoji(age.risk)}`
: "—";
// Комментарий: ошибка ФЛК ИЛИ возрастной риск
let commentHtml = "";
if (!res.is_valid) {
commentHtml = `<span class="err-msg">${escapeHtml(res.error_msg || "")}</span>`;
} else if (age && age.risk) {
commentHtml = `<span class="meta">${escapeHtml(AGE_RISK_TEXT[age.risk] || "")}</span>`;
}
const tr = document.createElement("tr"); const tr = document.createElement("tr");
tr.className = rowClass(res, kgd); tr.className = rowClass(res, kgd, age);
tr.innerHTML = ` tr.innerHTML = `
<td>${rowNum}</td> <td>${rowNum}</td>
<td class="mono">${escapeHtml(res.original)}</td> <td class="mono">${escapeHtml(res.original)}</td>
@ -728,9 +843,10 @@ function renderResults() {
<td>${flcBadge(res)}</td> <td>${flcBadge(res)}</td>
<td>${res.subtype ? escapeHtml(res.subtype) : "—"}</td> <td>${res.subtype ? escapeHtml(res.subtype) : "—"}</td>
<td>${escapeHtml(dateCell)}</td> <td>${escapeHtml(dateCell)}</td>
<td>${ageCell}</td>
<td>${kgdBadge(res, kgd)}</td> <td>${kgdBadge(res, kgd)}</td>
<td>${res.kind === "БИН" && res.is_valid && kgd && kgd.name ? escapeHtml(kgd.name) : "—"}</td> <td>${res.kind === "БИН" && res.is_valid && kgd && kgd.name ? escapeHtml(kgd.name) : "—"}</td>
<td>${res.is_valid ? "" : `<span class="err-msg">${escapeHtml(res.error_msg || "")}</span>`}</td> <td>${commentHtml}</td>
`; `;
body.appendChild(tr); body.appendChild(tr);
}); });
@ -740,6 +856,14 @@ function renderResults() {
document.getElementById("statValidSub").textContent = `БИН: ${okBin} · ИИН: ${okIin}`; document.getElementById("statValidSub").textContent = `БИН: ${okBin} · ИИН: ${okIin}`;
document.getElementById("statErr").textContent = errCount; document.getElementById("statErr").textContent = errCount;
document.getElementById("statKgd").textContent = kgdProblems; document.getElementById("statKgd").textContent = kgdProblems;
document.getElementById("statAge").textContent = ageRisks;
const subBits = [];
if (freshCnt) subBits.push(`свежих: ${freshCnt}`);
if (underageCnt) subBits.push(`<16 лет: ${underageCnt}`);
if (overageCnt) subBits.push(`аномалии: ${overageCnt}`);
document.getElementById("statAgeSub").textContent = subBits.length
? subBits.join(" · ")
: "свежие фирмы, дети, >100 лет";
} }
function showColumnInfo(info, fileName) { function showColumnInfo(info, fileName) {
@ -784,6 +908,9 @@ document.getElementById("fileInput").addEventListener("change", async (e) => {
); );
return; return;
} }
originalBuffer = buf;
originalFileName = file.name;
lastColumnInfo = info;
showColumnInfo(info, file.name); showColumnInfo(info, file.name);
runChecks(items); runChecks(items);
} catch (err) { } catch (err) {
@ -796,37 +923,48 @@ document.getElementById("fileInput").addEventListener("change", async (e) => {
document.getElementById("sampleBtn").addEventListener("click", () => { document.getElementById("sampleBtn").addEventListener("click", () => {
document.getElementById("columnInfo").style.display = "none"; document.getElementById("columnInfo").style.display = "none";
originalBuffer = null;
originalFileName = null;
lastColumnInfo = null;
// Демо-набор: БИН, ИИН и ошибки // Демо-набор: БИН, ИИН, возрастные риски и ошибки
const demo = [ const demo = [
// Валидные БИН // Валидные БИН — давно работающие компании
{ rowNum: 2, value: "941240000193" }, // ЮЛ, в реестре «Действующий» { rowNum: 2, value: "941240000193" }, // ЮЛ, КГД действующий
{ rowNum: 3, value: "020340000013" }, // ЮЛ, в реестре «Действующий» { rowNum: 3, value: "020340000013" }, // ЮЛ, КГД действующий
{ rowNum: 4, value: "980540000199" }, // ЮЛ, в реестре «Действующий» { rowNum: 4, value: "980540000199" }, // ЮЛ, КГД действующий
{ rowNum: 5, value: "991240001200" }, // ЮЛ, в реестре «Действующий» { rowNum: 5, value: "991240001200" }, // ЮЛ, КГД действующий
{ rowNum: 6, value: "060460000013" }, // ИП, в реестре «Приостановлен» { rowNum: 6, value: "060460000013" }, // ИП, КГД «Приостановлен»
{ rowNum: 7, value: "920150000029" }, // Филиал, в реестре «Ликвидирован» { rowNum: 7, value: "920150000029" }, // Филиал, КГД «Ликвидирован»
{ rowNum: 8, value: "050640001236" }, // ЮЛ, валиден ФЛК, НЕ найден в КГД { rowNum: 8, value: "050640001236" }, // ЮЛ, не в реестре КГД
// Возрастной риск — свежая фирма (зарегистрирована недавно)
{ rowNum: 9, value: "260340000002" }, // БИН март 2026 → ~2 мес 🚨 fresh
// Валидные ИИН // Валидные ИИН
{ rowNum: 9, value: "850615110054" }, // М, 15.06.1985 { rowNum: 10, value: "850615110054" }, // М, 15.06.1985
{ rowNum: 10, value: "920320200124" }, // Ж, 20.03.1992 { rowNum: 11, value: "920320200124" }, // Ж, 20.03.1992
{ rowNum: 11, value: "031108300114" }, // М, 08.11.2003 { rowNum: 12, value: "031108300114" }, // М, 08.11.2003
{ rowNum: 12, value: "100214400127" }, // Ж, 14.02.2010 { rowNum: 13, value: "100214400127" }, // Ж, 14.02.2010
// Ошибки // Возрастной риск — несовершеннолетний / «120+»
{ rowNum: 13, value: "01031540001" }, // ERROR_LENGTH (11 цифр) { rowNum: 14, value: "150315400128" }, // Ж, 15.03.2015 → 11 лет 🚨 underage
{ rowNum: 14, value: "020340000451" }, // БИН с битой контрольной цифрой { rowNum: 15, value: "200615110129" }, // М, 15.06.1920 → 105 лет 🚨 overage
{ rowNum: 15, value: "941340000193" }, // БИН: месяц 13 → ERROR_BIN_MONTH // Ошибки ФЛК
{ rowNum: 16, value: "850632110054" }, // ИИН: день 32 → ERROR_IIN_DAY { rowNum: 16, value: "01031540001" }, // ERROR_LENGTH (11 цифр)
{ rowNum: 17, value: "850615070054" }, // ИИН: G=0 → ERROR_IIN_GENDER { rowNum: 17, value: "020340000451" }, // БИН с битой контрольной цифрой
{ rowNum: 18, value: "941270000193" }, // 5-й знак=7 → ERROR_TYPE_DIGIT { rowNum: 18, value: "941340000193" }, // БИН: месяц 13
{ rowNum: 19, value: "941 240 000 193" }, // с пробелами → нормализуется → OK { rowNum: 19, value: "850632110054" }, // ИИН: день 32
{ rowNum: 20, value: "" }, // EMPTY { rowNum: 20, value: "850615070054" }, // ИИН: G=0
{ rowNum: 21, value: "941270000193" }, // 5-й знак=7
{ rowNum: 22, value: "941 240 000 193" }, // нормализуется → OK
{ rowNum: 23, value: "" }, // EMPTY
]; ];
runChecks(demo); runChecks(demo);
}); });
document.getElementById("clearBtn").addEventListener("click", () => { document.getElementById("clearBtn").addEventListener("click", () => {
currentResults = []; currentResults = [];
originalBuffer = null;
originalFileName = null;
lastColumnInfo = null;
document.getElementById("columnInfo").style.display = "none"; document.getElementById("columnInfo").style.display = "none";
renderResults(); renderResults();
}); });
@ -838,7 +976,8 @@ document.getElementById("addBinBtn").addEventListener("click", () => {
const nextRow = (currentResults.at(-1)?.rowNum ?? 1) + 1; const nextRow = (currentResults.at(-1)?.rowNum ?? 1) + 1;
const res = validateId(v); const res = validateId(v);
const kgd = kgdLookup(res); const kgd = kgdLookup(res);
currentResults.push({ rowNum: nextRow, res, kgd }); const age = computeAge(res);
currentResults.push({ rowNum: nextRow, res, kgd, age });
renderResults(); renderResults();
input.value = ""; input.value = "";
input.focus(); input.focus();
@ -848,37 +987,147 @@ document.getElementById("manualBin").addEventListener("keydown", (e) => {
if (e.key === "Enter") document.getElementById("addBinBtn").click(); if (e.key === "Enter") document.getElementById("addBinBtn").click();
}); });
// =============================================================
// Экспорт в .xlsx с подсветкой ячеек
// =============================================================
const XLSX_FILL = {
ok: { fill: { patternType: "solid", fgColor: { rgb: "FFDCFCE7" } } },
warn: { fill: { patternType: "solid", fgColor: { rgb: "FFFEF3C7" } } },
err: { fill: { patternType: "solid", fgColor: { rgb: "FFFEE2E2" } } },
head: {
fill: { patternType: "solid", fgColor: { rgb: "FFE2E8F0" } },
font: { bold: true },
},
};
const XLSX_BORDER = {
border: {
top: { style: "thin", color: { rgb: "FFCBD5E1" } },
bottom: { style: "thin", color: { rgb: "FFCBD5E1" } },
left: { style: "thin", color: { rgb: "FFCBD5E1" } },
right: { style: "thin", color: { rgb: "FFCBD5E1" } },
}
};
function fillForRow(res, kgd, age) {
if (!res.is_valid) return XLSX_FILL.err;
const kgdProblem = res.kind === "БИН" && kgd && (
!kgd.in_registry || kgd.status !== "Действующий"
);
if (kgdProblem) return XLSX_FILL.warn;
if (age && age.risk) return XLSX_FILL.warn;
return XLSX_FILL.ok;
}
const EXTRA_COLS = [
{ name: "Тип", get: r => r.res.kind ?? "" },
{ name: "ФЛК", get: r => r.res.is_valid ? "OK" : (r.res.error_code ?? "ERROR") },
{ name: "Подтип", get: r => r.res.subtype ?? "" },
{ name: "Дата", get: r => r.res.date_str ?? "" },
{ name: "Возраст", get: r => r.age?.label ?? "" },
{ name: "КГД статус", get: r => r.res.kind === "БИН" && r.res.is_valid && r.kgd
? (r.kgd.in_registry ? r.kgd.status : "Не найден")
: "" },
{ name: "КГД наименование", get: r => r.res.kind === "БИН" && r.res.is_valid && r.kgd && r.kgd.name
? r.kgd.name : "" },
{ name: "Комментарий", get: r => r.res.error_msg
?? (r.age?.risk ? AGE_RISK_TEXT[r.age.risk] ?? "" : "")
?? "" },
];
function setCell(sheet, r, c, value, style) {
const ref = XLSX.utils.encode_cell({ r, c });
const v = value == null ? "" : value;
// Numeric vs string
const isNum = typeof v === "number" && Number.isFinite(v);
sheet[ref] = { t: isNum ? "n" : "s", v, w: isNum ? undefined : String(v) };
if (style) sheet[ref].s = { ...style, ...XLSX_BORDER };
}
document.getElementById("exportBtn").addEventListener("click", () => { document.getElementById("exportBtn").addEventListener("click", () => {
if (currentResults.length === 0) return; if (currentResults.length === 0) return;
const header = [
"№","Исходное значение","Нормализованный","Тип","ФЛК","Код ошибки", let wb, sheet, binCol, hasHeaderRow, headerRowIdx, sheetName;
"Подтип","Дата","КГД статус","КГД наименование","Комментарий"
]; if (originalBuffer && lastColumnInfo) {
const rows = currentResults.map(({ rowNum, res, kgd }) => [ // Перечитываем исходный файл, чтобы сохранить расположение строк
rowNum, wb = XLSX.read(originalBuffer, { type: "array" });
res.original, sheetName = wb.SheetNames[0];
res.normalized, sheet = wb.Sheets[sheetName];
res.kind ?? "", binCol = lastColumnInfo.colIdx;
res.is_valid ? "OK" : "ERROR", hasHeaderRow = lastColumnInfo.hasHeader;
res.error_code ?? "", headerRowIdx = hasHeaderRow ? 0 : null;
res.subtype ?? "", } else {
res.date_str ?? "", // Демо-режим — создаём свежий лист
res.kind === "БИН" && res.is_valid && kgd ? (kgd.in_registry ? kgd.status : "Не найден") : "", wb = XLSX.utils.book_new();
res.kind === "БИН" && res.is_valid && kgd && kgd.name ? kgd.name : "", const aoa = [["№", "БИН / ИИН"]];
res.error_msg ?? "", currentResults.forEach((r, i) => aoa.push([i + 1, r.res.original]));
]); sheet = XLSX.utils.aoa_to_sheet(aoa);
const csv = [header, ...rows] sheetName = "Результаты";
.map(r => r.map(c => { XLSX.utils.book_append_sheet(wb, sheet, sheetName);
const s = String(c ?? ""); binCol = 1; // столбец B
return /[",;\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; hasHeaderRow = true;
}).join(";")) headerRowIdx = 0;
.join("\r\n"); }
const blob = new Blob(["" + csv], { type: "text/csv;charset=utf-8;" });
const a = document.createElement("a"); const range = XLSX.utils.decode_range(sheet["!ref"] || "A1");
a.href = URL.createObjectURL(blob); // Доп. столбцы: пропускаем одну пустую колонку
a.download = `bin_iin_validation_${new Date().toISOString().slice(0,10)}.csv`; const extraStart = range.e.c + 2;
a.click();
URL.revokeObjectURL(a.href); // Заголовки доп. столбцов (если есть строка заголовка)
if (headerRowIdx !== null) {
EXTRA_COLS.forEach((col, i) => {
setCell(sheet, headerRowIdx, extraStart + i, col.name, XLSX_FILL.head);
});
}
let maxRow = range.e.r;
// Для демо-режима свой mapping rowNum→строка в xlsx
currentResults.forEach((item, i) => {
const { rowNum, res, kgd, age } = item;
const rowIdx = originalBuffer ? rowNum - 1 : (i + 1); // demo: i+1 (header в 0)
// 1) Подсветка ячейки с БИН/ИИН
const fill = fillForRow(res, kgd, age);
const binRef = XLSX.utils.encode_cell({ r: rowIdx, c: binCol });
if (!sheet[binRef]) sheet[binRef] = { t: "s", v: res.original, w: res.original };
sheet[binRef].s = { ...fill, ...XLSX_BORDER };
// 2) Доп. столбцы — каждое значение в своей ячейке, общий цвет строки
EXTRA_COLS.forEach((col, j) => {
const val = col.get(item);
setCell(sheet, rowIdx, extraStart + j, val, fill);
});
if (rowIdx > maxRow) maxRow = rowIdx;
});
// Обновляем диапазон листа
sheet["!ref"] = XLSX.utils.encode_range({
s: { r: 0, c: 0 },
e: { r: maxRow, c: extraStart + EXTRA_COLS.length - 1 }
});
// Ширины столбцов: минимум для исходных + удобные для extras
sheet["!cols"] = sheet["!cols"] || [];
for (let c = 0; c <= extraStart + EXTRA_COLS.length - 1; c++) {
if (!sheet["!cols"][c]) sheet["!cols"][c] = { wch: 14 };
}
const extraWidths = [8, 22, 22, 14, 18, 16, 30, 50];
EXTRA_COLS.forEach((_, i) => {
sheet["!cols"][extraStart + i] = { wch: extraWidths[i] || 18 };
});
// Имя файла: <исходное>_проверено.xlsx или validation_<date>.xlsx
const stamp = new Date().toISOString().slice(0, 10);
const baseName = (originalFileName || "")
.replace(/\.(xlsx|xls|csv)$/i, "")
.trim();
const fileName = baseName
? `${baseName}_проверено_${stamp}.xlsx`
: `bin_iin_проверка_${stamp}.xlsx`;
XLSX.writeFile(wb, fileName);
}); });
</script> </script>