Andrey Epifancev 4675b461d8 fix(table): закрыть пропуски исходного перечня AURORA-1892 по сверке 02.11.2026
validation.js: добавлены колонки, пропущенные в первой реализации (разделы 5/6.1
сверки) — approved.*, collegial.approved, ckk.*, contract_detail.* (включая
amount_foreign), corrected_plan, pay_ho/pay_rf, booking, rem_actual/
residual_after_actual, qN.total, new_plan, data.qN.economy. Отдельно добавлен
transfer_econ формы 2 — явное исключение из общего префикса transfer_*, так как
перечень Салмина прямо требует контроль минуса для этих колонок (раздел 6.3,
противоречие с Word). Тест validationUtils.test.js обновлён под это исключение
и дополнен проверкой полноты перечня и отсутствия дублей с remaining-booking.

FORM_1/OPER.js, AHR.js, CAP.js: перестановка колонок Q4 — «Комментарий» теперь
перед «Скорректированный план 4 кв.» во всех пяти таблицах (раздел 8 сверки).

Не включено (требует отдельного решения заказчика, раздел 12.2 сверки):
контроль месячного факта/СПОД, контроль факта расшифровочных листов сверх
плановых колонок, область действия правила для формы 4.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-11-03 14:38:27 +04:00

408 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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.

// Требование №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: "Отрицательное значение в этой колонке недопустимо",
}),
];