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