Остатки в 1С не сходятся с движениями: как найти сломанный месяц итогов и не пересчитывать всю историю
Иван Недомолков · Chop etilgan:
Картина знакомая. Ведомость по движениям показывает по товару 52 штуки, запрос к остаткам на то же число возвращает 59. Документы перепроводят, число не меняется. Кто-то предлагает пересчитать итоги, кто-то возражает, что на этой базе полный пересчёт идёт всю ночь и никто не обещает, что он поможет.
Помочь он может, но почти всегда это стрельба из пушки по воробьям. Поломка итогов обычно сидит в одной строке: у одной позиции, на одну дату. Найти её можно запросом за секунды, а починить пересчётом за один-два месяца. Ниже весь путь на типовом регистре ТоварыНаСкладах из УТ 11.5, с кодом и цифрами прогона на тестовой копии, где мы сами испортили строку итогов.
Сначала понять, что сломано
Расхождение между отчётом по движениям и остатками бывает по трём разным причинам, и лечатся они по-разному:
| Что видим | Вероятная причина | Чем проверить |
|---|---|---|
| остаток неверный, но совпадает с суммой движений | ошибка в самих движениях документа | проверка движений: регистраторы, ссылки, непроведённые документы |
| сумма движений с неактивными записями не равна остатку | неактивные движения в итоги не входят, и это правильно | сравнивать только с активными |
| остаток не равен сумме активных движений | испорчена строка итогов | запрос ниже |
Третий случай и есть наш. Перепроведение его не лечит по простой причине. Когда платформа записывает набор движений, она меняет итоги на разницу между старым и новым набором. Если движения после перепроведения те же, разница равна нулю, и испорченная строка остаётся как была. На копии мы это проверили напрямую: прочитали и записали заново наборы всех трёх документов, у которых были движения в июле, всего 1 800 записей.
Набор = РегистрыНакопления.ТоварыНаСкладах.СоздатьНаборЗаписей();
Набор.Отбор.Регистратор.Установить(ДокументСсылка);
Набор.Прочитать();
// Те же движения: итоги не изменились, испорченная строка осталась.
Набор.Записать();
Строка итогов после этого осталась испорченной.
Как выглядит строка итогов изнутри
Регистр остатков с включёнными итогами держит две таблицы: движения и итоги. В таблице итогов на каждый месяц по каждой комбинации измерений лежит остаток на начало этого месяца. Плюс одна строка текущих итогов, остаток на сейчас. Запрос к виртуальной таблице Остатки на первое число месяца читает ровно эту строку, движения он не трогает. Поэтому испорченная строка уходит в отчёты как есть.
Посмотреть на эти строки можно из 1С: имя таблицы итогов и её поля даёт ПолучитьСтруктуруХраненияБазыДанных. Код для серверного контекста или консоли кода:
Объекты = Новый Массив;
Объекты.Добавить(Метаданные.РегистрыНакопления.ТоварыНаСкладах);
Для Каждого Таблица Из ПолучитьСтруктуруХраненияБазыДанных(Объекты, Истина) Цикл
Если Таблица.Назначение = "Итоги" Тогда
Для Каждого Поле Из Таблица.Поля Цикл
Сообщить(Таблица.ИмяТаблицыХранения + "." + Поле.ИмяПоляХранения + " - " + Поле.ИмяПоля);
КонецЦикла;
КонецЕсли;
КонецЦикла;
А дальше на MS SQL один запрос, только чтение. Имена таблицы и поля ниже условные, подставьте свои из вывода кода выше:
-- Строки итогов регистра по периодам: сколько строк и какая сумма ресурса в каждой.
-- Только чтение. _AccumRgT31415 и _Fld31416 замените своими именами.
DECLARE @off int = ISNULL((SELECT TOP 1 Offset FROM dbo._YearOffset), 0); -- смещение лет базы
DECLARE @cur datetime = DATEADD(year, @off, CONVERT(datetime, '3999-11-01', 120)); -- период текущих итогов
SELECT
CASE WHEN _Period >= @cur THEN N'текущие итоги'
ELSE CONVERT(nchar(10), DATEADD(year, -@off, _Period), 104) END AS [Период (дата 1С)],
COUNT_BIG(*) AS [Строк],
SUM(_Fld31416) AS [Сумма ресурса]
FROM dbo._AccumRgT31415
GROUP BY _Period
ORDER BY _Period;
Текущие итоги платформа хранит с периодом 01.11.3999, к нему прибавляется смещение лет из _YearOffset, если оно в базе есть. На нашей тестовой УТ по ресурсу ВНаличии вышло так:
| Период строки | Что в ней хранится | Сумма | Движения месяца перед ней |
|---|---|---|---|
| 01.07 | остаток на конец июня | 87 081 | июнь, +87 081 |
| 01.08 | остаток на конец июля | 82 657 | июль, -4 424 |
| 01.09 | остаток на конец августа | 81 838 | август, -819 |
| текущие | остаток на сейчас | 81 838 |
Главное, что надо запомнить из этой таблицы: строка с периодом 1 августа хранит итог июля. Дальше это сыграет.
Смотреть сюда SQL-ом полезно, менять нельзя. Испорченную строку на копии мы получили именно прямым UPDATE, и это ровно то, чего на рабочей базе делать не следует.
Запрос, который находит сломанную позицию
Принцип: на заданную границу посчитать остаток, каким он должен быть, из активных движений до этой даты и поставить рядом остаток, который хранится в итогах. Всё, где числа разные, это поломка.
ТекстЗапроса =
"ВЫБРАТЬ
| Т.Номенклатура, Т.Характеристика, Т.Назначение, Т.Склад, Т.Помещение, Т.Серия,
| СУММА(ВЫБОР КОГДА Т.ВидДвижения = ЗНАЧЕНИЕ(ВидДвиженияНакопления.Приход)
| ТОГДА Т.ВНаличии ИНАЧЕ -Т.ВНаличии КОНЕЦ) КАК ВНаличии
|ПОМЕСТИТЬ ВТ_Движения
|ИЗ
| РегистрНакопления.ТоварыНаСкладах КАК Т
|ГДЕ
| Т.Активность
| И Т.Период < &ДвиженияДо
|СГРУППИРОВАТЬ ПО
| Т.Номенклатура, Т.Характеристика, Т.Назначение, Т.Склад, Т.Помещение, Т.Серия
|;
|ВЫБРАТЬ
| О.Номенклатура, О.Характеристика, О.Назначение, О.Склад, О.Помещение, О.Серия,
| О.ВНаличииОстаток КАК ВНаличии
|ПОМЕСТИТЬ ВТ_Итоги
|ИЗ
| РегистрНакопления.ТоварыНаСкладах.Остатки(&Граница) КАК О
|;
|ВЫБРАТЬ
| ЕСТЬNULL(Д.Номенклатура, Ит.Номенклатура) КАК Номенклатура,
| ЕСТЬNULL(Д.Склад, Ит.Склад) КАК Склад,
| ЕСТЬNULL(Д.ВНаличии, 0) КАК ПоДвижениям,
| ЕСТЬNULL(Ит.ВНаличии, 0) КАК ПоИтогам,
| ЕСТЬNULL(Д.ВНаличии, 0) - ЕСТЬNULL(Ит.ВНаличии, 0) КАК Разница
|ИЗ
| ВТ_Движения КАК Д
| ПОЛНОЕ СОЕДИНЕНИЕ ВТ_Итоги КАК Ит
| ПО Д.Номенклатура = Ит.Номенклатура
| И Д.Характеристика = Ит.Характеристика
| И Д.Назначение = Ит.Назначение
| И Д.Склад = Ит.Склад
| И Д.Помещение = Ит.Помещение
| И Д.Серия = Ит.Серия
|ГДЕ
| ЕСТЬNULL(Д.ВНаличии, 0) - ЕСТЬNULL(Ит.ВНаличии, 0) <> 0";
Пять мест, где проще написать неправильно:
- Соединение полное. Испорченная строка бывает у позиции, движений по которой уже нет, и наоборот. Левое соединение одну из сторон потеряет.
- Условие
Т.Активность. Неактивные движения в итоги не попадают, значит и в эталон их брать нельзя. - Строгое “меньше”.
Остатки(&Граница)даёт остаток на начало указанного момента, движения ровно в полночь первого числа в него не входят. - Все измерения. Пропустите хотя бы одно, и разница сложится по нему: две встречные ошибки по разным сериям дадут ноль.
- Два параметра. Для месячной границы оба равны первому числу. Для текущих итогов
Границапустая, аДвиженияДодальше любого движения.
Под свой регистр меняются имя регистра, список измерений (в группировке, в обеих выборках и в условии соединения) и ресурс. На каждый дополнительный ресурс своя колонка суммы и своё условие через ИЛИ в последнем ГДЕ. На большом регистре первая часть читает всю историю движений, поэтому добавьте одинаковый отбор по складу или номенклатуре в обе части: условием в первую, вторым параметром в Остатки. И если расхождение может быть по характеристике или серии, выведите их в выборку, иначе две строки результата будут выглядеть одинаково.
Обход границ месяца
Один запрос проверяет одну дату. Чтобы найти первую сломанную, обходим границы от ранней к поздней, а в конце отдельно проверяем текущие итоги:
Менеджер = РегистрыНакопления.ТоварыНаСкладах;
// Итоги рассчитаны по 31.08 - последняя хранимая строка месяца имеет период 01.09.
МаксПериод = Менеджер.ПолучитьМаксимальныйПериодРассчитанныхИтогов();
// Пустая дата: итоги по месяцам не рассчитаны, остаётся только полный ПересчитатьИтоги().
Последняя = КонецМесяца(МаксПериод) + 1;
Запрос = Новый Запрос(ТекстЗапроса);
ПерваяСломанная = Неопределено;
Граница = ДобавитьМесяц(Последняя, -12);
МинПериод = Менеджер.ПолучитьМинимальныйПериодРассчитанныхИтогов();
Если ЗначениеЗаполнено(МинПериод) И Граница < НачалоМесяца(МинПериод) Тогда
Граница = НачалоМесяца(МинПериод); // раньше минимального периода строк итогов нет
КонецЕсли;
Пока Граница <= Последняя Цикл
Запрос.УстановитьПараметр("Граница", Граница);
Запрос.УстановитьПараметр("ДвиженияДо", Граница);
Расхождения = Запрос.Выполнить().Выгрузить();
Если Расхождения.Количество() > 0 И ПерваяСломанная = Неопределено Тогда
ПерваяСломанная = Граница;
КонецЕсли;
Для Каждого Стр Из Расхождения Цикл
Сообщить(Формат(Граница, "ДФ=dd.MM.yyyy") + " " + Стр.Номенклатура + ", " + Стр.Склад
+ ": движения " + Стр.ПоДвижениям + ", итоги " + Стр.ПоИтогам + ", разница " + Стр.Разница);
КонецЦикла;
Граница = ДобавитьМесяц(Граница, 1);
КонецЦикла;
// Текущие итоги: Остатки без даты против всех движений.
Запрос.УстановитьПараметр("Граница", Неопределено);
Запрос.УстановитьПараметр("ДвиженияДо", Дата(3999, 12, 31));
СломаныТекущие = Не Запрос.Выполнить().Пустой();
Глубина в двенадцать месяцев взята для примера. Дальше последней границы идти незачем: строк по месяцам там нет.
Какой месяц пересчитывать
Интуиция подсказывает: строка 1 августа хранит итог июля, значит, чинить её пересчётом за июль. Мы тоже так думали, пока не проверили. Испортили строку 01.08 (плюс 7 у одной позиции) и пересчитывали разные месяцы:
| Сломана строка | Пересчитали | Время | Результат |
|---|---|---|---|
| 01.08 | июнь | 33 мс | не починилась |
| 01.08 | июль | 108 мс | не починилась |
| 01.08 | август | 116 и 85 мс | починилась, оба раза |
| 01.09 (последняя хранимая) | август | 84 мс | не починилась |
| 01.09 | июнь-август | 157 мс | не починилась |
| 01.09 | сентябрь | 79 мс | починилась |
Вывод простой и проверяемый: ПересчитатьИтогиЗаПериод(Начало, Конец) перестраивает строки, у которых период попадает между Начало и Конец. Строку на 1-е число месяца чинит пересчёт за этот самый месяц. Последняя строка тоже не исключение: при итогах, рассчитанных по 31.08, строку 01.09 чинит пересчёт за сентябрь, и максимальный период рассчитанных итогов при этом остаётся 31.08.
Значит, пересчитывать надо с первой сломанной границы по конец месяца последней границы. Заодно перестроятся и все более поздние строки, если сломаны и они.
Текущие итоги строго последними
Текущие итоги платформа строит от последней хранимой строки по месяцам. Мы проверили это неприятным способом: при испорченной строке 01.09 вызвали ПересчитатьТекущиеИтоги(), и ошибка аккуратно переехала в текущие. Отсюда жёсткий порядок:
Если ПерваяСломанная <> Неопределено Тогда
// Строку 01.08 перестраивает пересчёт за август. Конец - месяц последней границы.
Менеджер.ПересчитатьИтогиЗаПериод(ПерваяСломанная, КонецМесяца(Последняя));
КонецЕсли;
Если СломаныТекущие Тогда
// Только после строк по месяцам: текущие считаются от последней из них.
Менеджер.ПересчитатьТекущиеИтоги();
КонецЕсли;
// Контроль: тот же цикл по границам ещё раз. Расхождений быть не должно.
Ни один из трёх методов пересчёта не принимает отбор по складу или товару, они всегда работают по регистру целиком. Выигрыш только во времени: месяц-два вместо всей истории. Права нужны “Управление итогами”, запускать лучше вне рабочего времени.
Цифры прогона
Тестовая копия УТ 11.5.22.67, платформа 8.3.27.1606, MS SQL 2022. В регистре 5 545 движений с июня по начало августа, итоги рассчитаны по 31.08, разделения итогов нет, текущие итоги включены.
| Шаг | Результат |
|---|---|
| обход четырёх границ и текущих на чистой базе | расхождений ноль, весь проход около 0,2 с |
| порча строки 01.08 у одной позиции | плюс 7 к ВНаличии |
| обход после порчи | одна строка на 01.08: движения 52, итоги 59, разница -7; остальные границы и текущие чистые |
ПересчитатьИтогиЗаПериод(01.08.2026, 30.09.2026) | 153, 152 и 230 мс в трёх повторах, строка вернулась к 82 657 |
полный ПересчитатьИтоги() при той же порче | 309, 306 и 308 мс, строка тоже починилась |
Разница вдвое на таком регистре ничего не доказывает: пять с половиной тысяч движений за три месяца пересчитываются целиком за треть секунды. На регистре с многолетней историей соотношение будет другим, но замера на таком объёме у нас нет. Если у вас есть, поделитесь, нам интересно.
Где этот способ не подходит
| Случай | Что не так | Что делать |
|---|---|---|
| оборотный регистр | виртуальной таблицы Остатки нет | сравнивать Обороты с периодичностью Месяц и движения, сгруппированные по началу месяца |
| итоги выключены | Остатки считается по движениям, разница всегда ноль | сначала выяснить, почему итоги выключены |
| регистр бухгалтерии | дебет и кредит, отдельные параметры для счетов, небалансовые ресурсы | принцип тот же, запрос заметно сложнее, здесь его нет |
| разделение итогов | итог одной позиции хранится несколькими строками | запрос не меняется, Остатки складывает строки сам; порчу при разделении мы не проверяли |
| ошибка в движениях | итоги честно соответствуют движениям | запрос покажет ноль, проверять надо движения |
У оборотных регистров есть своя тонкость: в УТ 11.5 из 32 оборотных регистров у 10 таблицы итогов нет вовсе, ломаться там нечему.
То же самое кнопкой
Обход границ, план пересчёта, сам пересчёт и контрольная сверка собраны в обработку Чек-ап итогов и остатков 1С. Она умеет то, что в статье осталось за кадром: оборотные регистры и бухгалтерию по счетам, отбор по измерениям и оценку объёма до запуска, обзор настроек итогов по всем регистрам базы, проверку самих движений, снимки закрытых периодов и SQL-скрипты для администратора СУБД. Если итоги ломаются вместе с дедлоками при проведении, загляните в разбор конфликтов на таблице итогов.
А если ночного окна под пересчёт нет и разбираться в регистрах некогда, напишите нам в WhatsApp: найдём сломанные строки и пересчитаем только то, что нужно.