// Требование №1. Контроль превышения бронирования над сметой. const remainingBookingKeys = [ "data.q1.rem_booking", "data.q2.rem_booking", "data.q3.rem_booking", "data.q4.rem_booking", "data.q1.residual_after_booking", "data.q2.residual_after_booking", "data.q3.residual_after_booking", "data.q4.residual_after_booking", ]; // Требование №1 работает для всех форм и листов, кроме CAP и OPER формы 4. const remainingBookingRuleScope = { excludedScopes: [ { formType: "FORM_4", sheetName: "CAP" }, { formType: "FORM_4", sheetName: "OPER" }, ], }; // Для листов CAP и OPER формы 4 требование №1 проверяет только остаток за 4 квартал. const remainingBooking4FormKeys = ["data.q4.residual_after_booking"]; const remainingBooking4FormRuleScope = { includedScopes: [ { formType: "FORM_4", sheetName: "CAP" }, { formType: "FORM_4", sheetName: "OPER" }, ], }; // Требование №2. Контроль столбца «Текущие корректировки = 0». const zeroValueRequirementKeys = [ "data.q1.adj_current", "data.q2.adj_current", "data.q3.adj_current", "data.q4.adj_current", ]; // Бронь <= Остаток после брони для формы 2. const bookingLessBalance = [ ["data.q1.booking", "data.q1.residual_after_booking"], ["data.q2.booking", "data.q2.residual_after_booking"], ["data.q3.booking", "data.q3.residual_after_booking"], ["data.q4.booking", "data.q4.residual_after_booking"], ]; const bookingLessBalanceByColumn = new Map( bookingLessBalance.flatMap(([bookingKey, balanceKey]) => [ [bookingKey, { bookingKey, balanceKey }], [balanceKey, { bookingKey, balanceKey }], ]), ); // Экономия <= Всего для форм 1 и 4. const economyLessTotal = [ ["data.q1.economy", "data.q1.total"], ["data.q2.economy", "data.q2.total"], ["data.q3.economy", "data.q3.total"], ["data.q4.economy", "data.q4.total"], ]; const economyLessTotalByColumn = new Map( economyLessTotal.flatMap(([economyKey, totalKey]) => [ [economyKey, { economyKey, totalKey }], [totalKey, { economyKey, totalKey }], ]), ); //Перенос в фонд экономии <= Всего const transferLessTotal = [ ["data.q1.transfer_econ", "data.q1.total"], ["data.q2.transfer_econ", "data.q2.total"], ["data.q3.transfer_econ", "data.q3.total"], ["data.q4.transfer_econ", "data.q4.total"], ]; const transferLessTotalByColumn = new Map( transferLessTotal.flatMap(([transferKey, totalKey]) => [ [transferKey, { transferKey: transferKey, totalKey: totalKey }], [totalKey, { transferKey: transferKey, totalKey: totalKey }], ]), ); const bookingLessBalanceRuleScope = { includedScopes: [{ formType: "FORM_2" }], }; // Требование AURORA-1892. Колонки, в которых отрицательное значение считается ошибкой. // Перечень — из вложения Салмина М.П. к AURORA-1892 (AURORA-1892_Перечень_колонок.md), // уточнённого кодовыми именами колонок (см. рулинги в PR). Корректировки и переносы // (adj_*, base_correction, revision_*, seq_*, target_change, transfer_*) сюда не входят — // там минус — нормальная операция. Исключение — transfer_econ (перенос в фонд экономии, // формы 2): перечень Салмина прямо требует контроль для этих колонок (см. сверку // AURORA-1892 от 02.11.2026, раздел 6.3); общий префикс transfer_* на этот ключ не // распространяется. export const nonNegativeColumnKeys = [ // Основные листы форм 1, 2, 4 (Операц/АХР/КВ): план по кварталам и за год. "data.plan.q1", "data.plan.q2", "data.plan.q3", "data.plan.q4", "data.plan.year", // Ввод брони на 2026 и 2027 годы (форма 2). "data.booking.y2026.q1", "data.booking.y2026.q2", "data.booking.y2026.q3", "data.booking.y2026.q4", "data.booking.y2027.q1", "data.booking.y2027.q2", "data.booking.y2027.q3", "data.booking.y2027.q4", // Факт по месяцам и СПОД. "data.q1.actual_m1", "data.q1.actual_m2", "data.q1.actual_m3", "data.q2.actual_m1", "data.q2.actual_m2", "data.q2.actual_m3", "data.q3.actual_m1", "data.q3.actual_m2", "data.q3.actual_m3", "data.q4.actual_m1", "data.q4.actual_m2", "data.q4.actual_m3", "data.q4.actual_spod", // Сумма платежа. "data.q1.pay_amount", "data.q2.pay_amount", "data.q3.pay_amount", "data.q4.pay_amount", // Предельная стоимость договора / ЦКК. "data.contract.ceiling", "data.contract_detail.ceiling", "data.ckk.ceiling", "data.contract_summary.total", // Сателлиты формы 2 — АХР Аренда/Коммунальные услуги/Безопасность: факт по месяцам // и квартальный итог (data.plan.qN уже учтён выше — ключ общий с основными листами). "data.fact_q1.jan", "data.fact_q1.feb", "data.fact_q1.mar", "data.fact_q1.total", "data.fact_q2.apr", "data.fact_q2.may", "data.fact_q2.jun", "data.fact_q2.total", "data.fact_q3.jul", "data.fact_q3.aug", "data.fact_q3.sep", "data.fact_q3.total", "data.fact_q4.oct", "data.fact_q4.nov", "data.fact_q4.dec", "data.fact_q4.total", // АХР Лимиты — по вложению Салмина минусов не должно быть вообще во всех // числовых показателях листа (шире, чем исходный набор qty_qN/amount_qN). "data.qty_q1", "data.qty_q2", "data.qty_q3", "data.qty_q4", "data.amount_q1", "data.amount_q2", "data.amount_q3", "data.amount_q4", "data.limit_with_vat", "data.limit_without_vat", // Доработка по сверке AURORA-1892 от 02.11.2026 (разделы 5, 6.1): колонки из // исходного перечня Салмина, пропущенные при первой реализации. // Платежи следующих лет и сумма других ССП. "data.contract_summary.future_y1", "data.contract_summary.future_y2", "data.contract_summary.other_ssp", // Утверждённый план по кварталам и за год. "data.approved.q1", "data.approved.q2", "data.approved.q3", "data.approved.q4", "data.approved.year", // Сумма коллегиального органа. "data.collegial.approved", // Расходы ЦКК по кварталам и по сметам РФ. "data.ckk.q1", "data.ckk.q2", "data.ckk.q3", "data.ckk.q4", "data.ckk.rf_schedule", // Расходы договора по кварталам, по сметам РФ и сумма в валюте. "data.contract_detail.q1", "data.contract_detail.q2", "data.contract_detail.q3", "data.contract_detail.q4", "data.contract_detail.rf_schedule", "data.contract_detail.amount_foreign", // Скорректированный план по кварталам. "data.q1.corrected_plan", "data.q2.corrected_plan", "data.q3.corrected_plan", "data.q4.corrected_plan", // Части платежа ГО и РФ. "data.q1.pay_ho", "data.q1.pay_rf", "data.q2.pay_ho", "data.q2.pay_rf", "data.q3.pay_ho", "data.q3.pay_rf", "data.q4.pay_ho", "data.q4.pay_rf", // Бронь квартала (отдельно от ввода брони на 2026/2027 годы выше). "data.q1.booking", "data.q2.booking", "data.q3.booking", "data.q4.booking", // Остаток после факта (формы 1 и 2 используют разные кодовые имена). "data.q1.rem_actual", "data.q2.rem_actual", "data.q3.rem_actual", "data.q4.rem_actual", "data.q1.residual_after_actual", "data.q2.residual_after_actual", "data.q3.residual_after_actual", "data.q4.residual_after_actual", // Перенос в фонд экономии формы 1 (data.economy — другой ключ, чем transfer_econ // формы 2 ниже, и не входит в исключённый префикс transfer_*). "data.q1.economy", "data.q2.economy", "data.q3.economy", "data.q4.economy", // Перенос в фонд экономии формы 2 — исключение из transfer_*, см. комментарий // к nonNegativeColumnKeys выше. "data.q1.transfer_econ", "data.q2.transfer_econ", "data.q3.transfer_econ", "data.q4.transfer_econ", // Всего при закрытии квартала. "data.q1.total", "data.q2.total", "data.q3.total", "data.q4.total", // Новый план на подпись (кварталов II–IV; для I квартала поле отсутствует). "data.q2.new_plan", "data.q3.new_plan", "data.q4.new_plan", ]; const isNegativeNumber = (value) => value !== null && value !== undefined && value !== "" && Number.isFinite(Number(value)) && Number(value) < 0; // Фабрика правила «минус запрещён» для заданного перечня колонок. // appliesToRow намеренно не ограничивается — правило проверяет все строки. export const nonNegativeRule = ({ id, columnKeys, message, includedScopes, excludedScopes } = {}) => ({ id, columnKeys, message, ...(includedScopes ? { includedScopes } : {}), ...(excludedScopes ? { excludedScopes } : {}), isInvalid: isNegativeNumber, }); export const validationRules = [ { ...remainingBookingRuleScope, id: "remaining-booking", columnKeys: remainingBookingKeys, message: "Сумма бронирования превышает сумму, предусмотренную сметой", isInvalid: (value) => value !== null && value !== undefined && value !== "" && Number.isFinite(Number(value)) && Number(value) < 0, }, { ...remainingBooking4FormRuleScope, id: "remaining-booking-4-form", columnKeys: remainingBooking4FormKeys, message: "Сумма бронирования превышает сумму, предусмотренную сметой", isInvalid: (value) => value !== null && value !== undefined && value !== "" && Number.isFinite(Number(value)) && Number(value) < 0, }, { id: "current-adjustments-zero", columnKeys: zeroValueRequirementKeys, message: "Текущие корректировки должны быть равны 0", appliesToRow: (row) => row?.depth === 0 || row?.row_type === "ROOT", isInvalid: (value) => value !== null && value !== undefined && value !== "" && Number.isFinite(Number(value)) && Number(value) !== 0, }, { ...bookingLessBalanceRuleScope, id: "booking-less-balance", columnKeys: bookingLessBalance.flat(), errorColumnKeys: bookingLessBalance.map(([bookingKey]) => bookingKey), message: "Сумма бронирования превышает остаток после бронирования", isInvalid: (_value, row, columnKey) => { const pair = bookingLessBalanceByColumn.get(columnKey); if (!pair) return false; const booking = pair.bookingKey .split(".") .reduce((value, key) => value?.[key], row); const balance = pair.balanceKey .split(".") .reduce((value, key) => value?.[key], row); return ( booking !== null && booking !== undefined && booking !== "" && balance !== null && balance !== undefined && balance !== "" && Number.isFinite(Number(booking)) && Number.isFinite(Number(balance)) && Number(booking) > Number(balance) ); }, }, { includedScopes: [{ formType: "FORM_1" }, { formType: "FORM_4" }], id: "economy-less-total", columnKeys: economyLessTotal.flat(), errorColumnKeys: economyLessTotal.map(([economyKey]) => economyKey), message: "Экономия должна быть меньше значения «Всего»", isInvalid: (_value, row, columnKey) => { const pair = economyLessTotalByColumn.get(columnKey); if (!pair) return false; const economy = pair.economyKey .split(".") .reduce((value, key) => value?.[key], row); const total = pair.totalKey .split(".") .reduce((value, key) => value?.[key], row); return ( economy !== null && economy !== undefined && economy !== "" && total !== null && total !== undefined && total !== "" && Number.isFinite(Number(economy)) && Number.isFinite(Number(total)) && Number(economy) > Number(total) ); }, }, { id: "transfer-less-total", columnKeys: transferLessTotal.flat(), errorColumnKeys: transferLessTotal.map(([transferKey]) => transferKey), message: "Перенос в фонд экономии превышает всего", isInvalid: (_value, row, columnKey) => { const pair = transferLessTotalByColumn.get(columnKey); if (!pair) return false; const transfer = pair.transferKey .split(".") .reduce((value, key) => value?.[key], row); const total = pair.totalKey .split(".") .reduce((value, key) => value?.[key], row); return ( transfer !== null && transfer !== undefined && transfer !== "" && total !== null && total !== undefined && total !== "" && Number.isFinite(Number(transfer)) && Number.isFinite(Number(total)) && Number(transfer) > Number(total) ); }, }, nonNegativeRule({ id: "non-negative", columnKeys: nonNegativeColumnKeys, message: "Отрицательное значение в этой колонке недопустимо", }), ];