Новые поля в складских проводках

 

Общие вопросы

При внедрении DAX часто возникает примерно следующая задача: Клиент говорит что-нибудь типа "Мне нужно добавить в заказы (складские журналы, закупки, производственные заказы и т.п.) новое поле - направление продаж (код продавца, номер автомобиля для отгрузки, идентификатор кредитной линии клиента и тп). Кроме того - мне нужно уметь строить отчеты (обычные или OLAP) по складским списаниям (приходам) в разрезе этого нового поля".

Первое что приходит в голову - это просто добавить новое поле в строки заказов и потом переделать некоторые складские отчеты таким образом, чтобы они строились по соединению (join) таблицы складских проводок с исходным документом. Проблема в том, что в реальности постоянно возникают ситуации, при которых клиент, задним числом вспоминает что неплохо бы это поле добавить не только в заказы (например), но и в складские журналы списания или переноса. В этой ситуации внедренцу приходится либо плодить кучу отчетов - по одному для каждого вида складских документов, либо строить какой-то хитромудрый отчет, который собирает данные из множества таблиц. Очевидно, что оба подхода имеют свои минусы и почти не имеют плюсов.

Второй, более продвинутый вариант - это добавление нового поля либо в таблицу складских проводок (inventTrans) либо в таблицу складской аналитики (inventDim). Надо понимать, что с точки зрения нагрузки на систему, вариант добавления в таблицу inventDim новых полей значительно более затратный. Это связано с тем, что с ростом числа записей в таблице inventDim, растет и таблица inventSum (запасы в наличии). Поскольку модуль логистики ПОСТОЯННО использует таблицу inventSum для получения текущего складского остатка, критически важно для производительности, чтобы эта таблица не разросталась. Поэтому, добавлять новые поля в складскую аналитику имеет смысл только при выполнении следующих условий:

1. Имеет смысл сальдирование по этой аналитике. Грубо говоря - количественный или денежный остаток в разрезе этой аналитики имеет экономический смысл.

2. Предполагается, что система должна проверять неотрицательность остатка в разрезе этой складской аналитики.

Поэтому - реально добавлять новые поля в складскую аналитику приходится достаточно нечасто. Например - ни код продавца, ни номер автомобиля явно не проходят по обоим условиям. Мне приходилось добавлять, например, складскую аналитику "Материально ответственное лицо" или "Вид продукции" (своя, комисионная, давальческая) и некоторые другие. Причем добавлять ее приходилось в первую очередь даже не для отчетности, а для контроля - чтобы нечаянно не списать с аналитики "МОЛ" или "Вид продукции" больше номенклатуры, чем на нее изначально было оприходовано.

Для всех остальных случаев - правильнее добавлять новое поле в inventTrans. Причем я для себя выработал следующий приблизительный критерий, позволяющий принимать решение - добавлять новое поле в inventTrans или попытаться обойтись хитрым запросом (с джойном inventTrans и исходного документа): Добавлять новое поле в inventTrans следует если выполняется одно из следующих условий:

1. Новый аттрибут есть более чем в одном типе складского документа

2. Новый аттрибут будет заполняться для более чем 25% складских проводок данного вида (то есть - либо приходов, либо расходов)

3. Новый аттрибут будет использоваться для вычисления дополнительных количеств в таблице остатков в наличии (Об этом подходе я напишу в другой раз. Основная идея состоит в том, чтобы в остатках в наличии иметь не только поле, допустим, "Зарезервировано", но еще и дополнительное поле "Зарезервировано в журналах перемещения".)

Наивный подход к реализации заполнения нового поля в складских проводках состоит в том, чтобы переопределить методы insert() или update() таблиц inventTrans и, допустим, SalesLine таким образом, чтобы при обеспечить синхронизацию этого нового поля между двумя таблицами. Надо сказать, что во первых этот метод приводит к серьезному возрастанию нагрузки не систему во время обновления, во вторых - очень сильно не вяжется с идеологией логистического модуля DAX.

Цель данной заметки состоит как раз в том, чтобы дать приблизительное описание той инфраструктуры которая обеспечивает интерфейс между исходными логистическими документами (заказами, закупками, складскими журналами, производственными заказами и т.п.) и стандартными механизмами логистических операций DAX, а затем дать простой пример того как можно обеспечить копирование аттрибутов из исходных документов в складские проводки.

Немного о программной инфраструктуре логистики

На мой взгляд - инфраструктура логистического модуля, это очень наглядный пример мощи объектно ориентированного подхода. При проектировании классов этого модуля разработчиками было принято очень простое решение:

1. Классы отвечающие за выполнение типовых логистических операций - резервирования, комплектации, регистрации, физической и финансовой разноски ничего не знают о том, на основании какого исходного документа выполняются эти операции.

2. Вся логика, связанная со специфическими операциями по конкретному складскому документу инкапуслирована в иерархию классов (inventMov_*).  При этом каждому виду складского документа соответствует ОДИН конкретный класс в этой иерархии.

3. При выполнении логистических операций классы InventUpd* либо запрашивают у классов InventMov* нужную им информацию (например код номенклатуры или заказанную дату), либо вызывают методы классов inventMov, чтобы те выполнили некторые операции, специфичные для данного складского документа.

Рассмотрим несколько конкретных примеров:

В иерархии классов inventMovement имеется метод mustBeAutoReserved. Этот метод вызывается классов inventUpd_expected, отвечающим за создание и обновление складских проводок в статусе "Заказано"/"В заказе" для того чтобы определить - не следует ли автоматически резервировать данную проводку списания. В классе inventMovement этот метод возвращает значение false. В классе inventMov_sales (отвечающим за модуль заказов) - возвращает значение в зависимости от режима авторезервирования в шапке заказа, а в классе InventMov_QuarantineOrder (отвечающем за карантинный заказ) этот метод всегда возвращает значение true, чтобы по карантинному заказу у нас товар автоматически резервировался на карантинном складе.

Метод inventMovement.updateLedgerFinancial() используется для создания проводок в ГК по приходным операциям. В большинстве случаев, эта операция выполняется методом самого базового класса, который вытаскивает из дочернего класса (относящегося к данному виду приходного документа) данные о счете и коррсчете проводки (через методы accountBalanceSheet() и accountOperations()), финансовой аналитике (через метод Dimension()) и разноске в ГК (методы postingBalanceSheet() и postingOperations()). Для закупок, этот метод был переопределен в классе inventMov_purch(), поскольку по закупкам разноска по коррсчету делается совсем другим классом (vendVoucher), который также создает запись в проводках по поставщику (vendTrans). Поэтому - в методе inventMov_purch.updateLedgerFinancial() создется только вторая половина проводки - приход на инвентарный счет (10.x или 41.x).

Метод inventMov.addRemainFinancialUnit() обновляет недопоставленное количество в исходном складском документе. В базовом классе inventMovement этот метод определен так, чтобы он ничего не делал если для данного типа складского документа обновление недопоставленного количества не требуется и чтобы он выдавал сообщение об ошибке если таковое обновление требуется. В классах inventMov_purch,inventMov_sales, inventMov_prodLine этот метод переопределен таким образом, чтобы он обновлял соответствующие количества в строке закупки, заказа и производственной спецификации.

Ну и так далее...

При создании экземпляра класса inventMovement используется constructor controlled inheritance: Для создания экземпляра класса используется метод inventMovement::construct(common table). При этом, логика внутри метода contruct (точнее даже метода constructNoThrow, который вызывается из construct), на основании информации о типе таблицы со строкой исходного складского документа, создает нужный экземпляр конкретного наследника класса inventMovement. При этом, переданный экземпляр строки исходного складского документа храниться в переменной buffer, доступной из класса inventMovement и его наследников. Кроме того - существует еще один метод создания экземпляра класса inventMovement. В таблице inventTrans существует метод inventMovement, который находит нужную строку таблицы с исходным документом, а затем через inventMovement::construct() создает и возвращает соответствующий экземпляр объекта класса inventMovement.

Конкретный пример.

Заказчик – крупная торговая организация. Им для работы требуется четко контролировать складские резервы в двух дополнительных разрезах:

1. Менеджер по продажам (сейл), к которому относится резерв

2. Срок жизни резерва. Резервы, которые кто-то создал и потом в течении N-дней не продал – должны автоматически удаляться системой.

Если транслировать эту задачу в более приземленные термины, то нужно:

1. Добавить в таблицу складских проводок (inventTrans) поле "Сейл" и копировать туда поле "Ответственный продавец" из шапки заказа (возможно – и из некого дополнительного поля, которое мы добавим в шапку складского журнала – для резервов по складским журналам).

2. Добавить в таблицу складских проводок поле "Дата автоматического снятия резерва". При резервировании – в это поле должна заносится текущая дата + 5 дней. Желательно сделать механизм расчета автоматической даты снятия расширяемым, поскольку велика вероятность того, что в дальнейшем метод расчета срока жизни резерва будет зависеть от типа исходного документа, номенклатуры, клиента под которого ставиться резерв и тп.

3. Разработать процедуру удаления просроченных резервов. Здесь я эту тему рассматривать не буду - оставлю для самостоятельного изучения :)

Для начала попытаемся решить задачу с ответственным продавцом. Для этого:

· В классе inventMovement создадим метод salesResponsible(), возвращающий значение типа emplId. В базовом классе этот метод будет возвращать пустую строку.

· В классе inventMov_sales (связанном со строкой заказа) переопределяем этот метод таким образом, чтобы он возвращал значение salesResponsible из шапки соответствующего заказа.

· Добавляем поле salesResponsible в таблицу inventTrans

· Изменяем метод inventMovement.initInventTransFromBuffer() таким образом, чтобы он инициализировал новое поле значением, полученным из метода inventMovement.salesResponsible().

Первичное тестирование данная доработка пройдет. Если при создании заказа заполнить в шапке поле "Ответственный продавец", то при создании строк оно попадет в складские проводки. Но вот если попробовать изменить это поле у уже созданного заказа, то в складских проводках так и останется старое значение. Почему это происходит ? Давайте попробуем изменить в шапке заказа поле "Дата заказа" и протрассировать метод inventUpd_estimated.updateNow(), который где-то в своих недрах должен изменить значение поля dateExpected таблицы складских проводок. При трассировке довольно быстро натыкаешься на код метода updateFieldsChange(), который вызывает метод инициализации полей складской проводки (inventMovement.initInventTransFromBuffer()) в том случае, если метод inventMovement.mustUpdateInventTransFields() вернул true. Если заглянуть в этот метод, то можно обнаружить следующий код:

return (this.transDate() != _movement_orig.transDate() ||

this.shippingDateRequested()!= _movement_orig.shippingDateRequested()||

this.transSchedTime() != _movement_orig.transSchedTime() ||

this.transItemBOMId() != _movement_orig.transItemBOMId() ||

this.transItemRouteId() != _movement_orig.transItemRouteId() ||

this.transIdReturn() != _movement_orig.transIdReturn() ||

this.projCategoryId() != _movement_orig.projCategoryId() ||

this.custVendAc() != _movement_orig.custVendAc() ||

this.assetId() != _movement_orig.assetId() ||

this.inventRefTransId() != _movement_orig.inventRefTransId()) ||

this.probabilityId() != _movement_orig.probabilityId().

Попросту говоря – система создает на основании старой (не измененной) копии строки исходного документа (доставаемой через buffer.Orig()) экземпляр класса InventMovement() и сравнивает значение некоторых методов, значения которых в дальнейшем попадают в складские проводки. Значит – для того чтобы добиться правильного поведения системы нам нужно:

1. Добавить в этот метод сравнение значений, возвращаемых методом salesResponsible()

2. Для того чтобы логика сравнения отработала нам придется добавить в СТРОКИ заказа копию поля salesResponsible. (Ведь метод пляшет от сравнения СТРОКИ ЗАКАЗА до и после обновления, а не от значения шапки заказа.). Нам придется переопределить метод обновления шапки заказа (salesTableType.update()) таким образом, чтобы при изменении ответственного продавца в шапке заказа, новое значение поля копировалось бы и в строки заказа. При этом – обновление строки заказа у нас будет вызывать обращение к inventUpd_estimated.updateNow(), порождая таким образом обновление информации и в складских проводках.

Теперь попробуем разобраться с датой автоматического снятия резерва.

· Для начала добавим в таблицу складских проводок новое поле dateExpired

· Поскольку хочется сделать механизм расчета даты автоматического снятия максимально гибким, создадим метод inventMovement.dateExpired(). Этот метод получает в качестве параметра дату создания резерва, а возвращает рассчитанную на ее основе дату снятия резерва (на первых порах – просто дату+5 дней).

· Подправим метод inventUpd_Reservation.updateReserveMore() (этот метод собственно и резервирует складские проводки), таким образом, чтобы в поле inventTrans,dateExpired записывалось значение, полученное из нового метода inventMovement.dateExpired().

· В методе inventUpdReservation.updateReserveLess() (он снимает резервы) вставляем очистку поля dateExpired – чтобы дата снятия резерва не была заполнена для проводок в статусе "Заказанно"

· Наконец – для того чтобы дата снятия резерва не стояла у уже отгруженных проводок – вставляем очистку даты резервирования в методе inventMovement.initInventTransPhysical(), который вызывается для заполнения полей inventTrans при физической разноске складских проводок. (Кстати – inventMovement.initInventTransFinancial выполняет аналогичную ситуацию при финансовой разноске). Поле salesResponsible я бы не стал зачищать в этом методе, поскольку достаточно удобно, когда это поле заполнено в проводках с любым статусом – а не только в проводках резервирования.

Если после выполнения этих модификаций поэкспериментировать с резервированием, выясняется что в целом все работает, однако есть один тонкий момент: Как известно, DAX умеет схлопывать записи в inventTrans в рамках одного номера лота, если КЛЮЧЕВЫЕ поля этих записей совпадают. В том случае, если галка "Автоматическое добавление" в параметрах модуля управления запасами установлена – это происходит автоматически – при любом обновлении inventTrans. Если галка не установлена, аналогичного эффекта можно добиться через пункт меню "Суммирование" в форме складских проводок. Так вот – если мы нарезервировали по одному и тому же лоту в разное время, с разными датами автоматического снятия резерва, система при схлопывании проводок может схлопнуть проводки с разными датами снятия резерва, подставив в результирующую проводку первую попавшуюся из нескольких дат. Для того чтобы предотвратить подобный эффект, нужно изменить метод inventTrans.setSumAmount(), так чтобы он дополнительно проверял совпадение даты резервирования и ответственного продавца в двух проводках.

NB. Кстати – если у вас на проекте есть проблемы с ростом inventTrans, посмотрите – включена ли галка "Автоматическое добавление". Если не включена – попробуйте оценить выигрыш от ее включения. Напишите джобик, который пробежится по существующим складским проводкам и проверит их на совпадение полей, проверяемых в методе inventTrans.setSumAmount. На моей практике – установка этой галки однажды позволила уменьшить число записей в inventTrans по заказам почти в два раза. Хотя с другой стороны – включение этой галки заведомо замедляет обновление inventTrans.

 

Подводя общий итог рассмотренному примеру, можно дать следующую рекомендацию:

  1.  Если аттрибут копируется в складскую проводку из исходного документа, то необходимо дополнить метод inventMovement.initInventTrans* инициализацией нового поля из вновь созданного метода класса InventMovement.
  2. Если аттрибут тем или иным образом рассчитывается в процессе выполнения логистической операции, следует поместить инициализацию этого аттрибута в методы соответствующего класса inventUpd_*.

P.S. Рассмотренный пример (по крайней мере - с датой автоматического снятия резеров) хорошо работает только при отсутствии резервирования в заказанных. По логике вещей - автоматическое снятие резерва должно работать только для товара, уже находящегося на складе. Для товара в пути эта логика в принципе не работает, поскольку время обработки закупки поставщиком и транспортировки закупленного товара до его прибытия на склад достаточно непредсказуемо и, обычно, значительно больше типичного времени жизни резерва на складе. Поэтому, в случае использования резервирования в заказанных, надо во первых заблокировать удаление просроченных резервов в заказанных, а во вторых - подправить метод inventUpdate.updateDimReserveChange(), таким образом, чтобы при приходе товара, у складской проводки резервирования повторно инициализировалась дата автоматического снятия резерва. (Попросту говоря - этот метод при вызывается при физическом приходе товара, ищет по двум разным алгоритмам подходящую проводку в статусе "Зарезервировано в заказанных" и переводит ее в статус "Физически зарезервировано").

Update: Когда готовил контрольный пример для статьи, забыл об одном интересном нюансе. Для того чтобы снятие резервов работало интуитивно понятно для пользователя, первыми внутри данного лота должны сниматься резервы с наиболее ранней датой автоматического снятия. Для того чтобы система действовала именно таким образом, необходимо в методе inventUpd_reservation.updateReserveLess поставить сортировку по дате автоматического снятия в те несколько запросов, которые отбирают строки inventTrans для перевода в статус "Заказанно".