Продолжение статьи “Простые движения и формы”
Настало время для серьёзного разговора об алгоритмах и расчетах, которыми учет на производстве отличается от учета в магазине или складе. Но, прежде чем мы приступим к изучению теории и практики учёта, необходимо закончить общие темы функционирования приложения.
Модальный справочник
В программе уже имеется доступ к справочникам через главное меню. Это удобно, когда нужно массово внести заранее известные значения. А вот если новое значение справочника понадобится в процессе ввода данных в основной документ, или требуется осуществить поиск данных в справочнике по определенным критериям, то поможет связка элементов:
- выпадающий список
- кнопка выбора значения из справочника
- отображение справочника в модальном режиме
Чтобы создать универсальные процедуры необходимо создать очередное соглашение по наименованию компонентов на форме редактирования.
![](https://k245.ru/wp-content/uploads/2022/08/izobrazhenie_2022-08-25_185844347.png)
- Выпадающий список должен называться так же, как и форма справочника (cmb – префикс выпадающего списка, frm – префикс формы справочника)
- Кнопка открытия справочника в суффиксе должна содержать название формы справочника (btn – префикс кнопки)
Универсальный обработчик кнопки Form_btnOpenDict() использует данное соглашение в своей работе:
procedure Form_btnOpenDict_OnClick (Sender: TObject; var Cancel: boolean);
// открытие формы справочника кнопкой
var
tmpForm:TAForm;
tmpCombo:TdbComboBox;
tmpRefForm:TAForm;
tmpName:string;
tmpID:string;
begin
CForm(Sender,tmpForm);
// извлечь имя справочника
tmpName := GetSuffix( DeleteClassName( TdbButton(Sender).Name ) );
// найти выпадающий список
FindC(tmpForm, T_COMBO_BOX + tmpName, tmpCombo );
if tmpCombo = nil then
RaiseException('Не найден выпадающий список '+T_COMBO_BOX + tmpName);
tmpRefForm := App_GetFormByName( T_FORM + tmpName );
if tmpRefForm = nil then
RaiseException('Не найдена форма '+T_FORM + tmpName);
//
tmpID := tmpCombo.sqlValue;
// открыть форму как модальный справочник
if Form_ShowRef( tmpRefForm, tmpID, tmpForm ) then
begin
// установить выбранное значение
tmpCombo.dbUpdate;
tmpCombo.dbItemID := StrToInt(tmpID);
tmpCombo.DoOnChange;
end;
end;
Code language: Delphi (delphi)
На форме модального справочника необходимо разместить невидимую кнопку btnSelect. Название кнопки также является частью соглашения.
![](https://k245.ru/wp-content/uploads/2022/08/izobrazhenie_2022-08-25_190803843-1024x435.png)
Обработчик этой кнопки универсальный:
procedure Form_btnSelect_OnClick (Sender: TObject; var Cancel: boolean);
var
tmpForm:TForm;
tmpButton:TdbButton;
tmpGrid: TdbStringGridEx;
begin
tmpButton := TdbButton(Sender);
CForm(Sender,tmpForm);
// найти источник данных
tmpGrid := Form_GetDataViewer(tmpForm);
if tmpGrid.dbItemID = -1 then
ShowHint(tmpGrid,'Выберите запись')
else
begin
// записать выбранные данные в текстовый тег
tmpButton.TagString := IntToStr(tmpGrid.dbItemID);
tmpForm.ModalResult := mrOK; // закрыть форму с модальным результатом mrOK
end;
end;
Code language: Delphi (delphi)
Функция Form_ShowRef() предназначена для отображения формы в модельном режиме с учётом того, что ранее эта же форма может быть отображена на другом компоненте (панели или вкладке).
function Form_ShowRef( AForm: TAForm; var AID: string; ACalledForm: TAForm; ASelectMode: integer = 0 ): boolean;
// отобразить форму модально, в режиме справочника
// параметры:
// AForm - формы
// AID - ID записи для позиционирования; выбранное значение или список выбранных значений
// ACalledForm - из какой формы был вызов
// ASelectMode - режим выборки данных
var
tmpGrid: TdbStringGridEx;
i: integer;
tmpButton: TdbButton;
begin
tmpGrid := Form_GetDataViewer(AForm);
if tmpGrid = nil then
RaiseException('Form_ShowRef(' + AForm.Name + ',..) - не найдена таблица отображения данных');
FindC(AForm,'btnSelect',tmpButton);
if tmpButton = nil then
RaiseException('Form_ShowRef(' + AForm.Name + ',..) - не найдена кнопка выбора значения ');
tmpButton.TagString := AID; // передать текущее значение
tmpButton.Visible := True;
// если форму уже показывали на панели, то отстегнуть...
if AForm.Parent <> nil then
begin
AForm.Visible := False;
AForm.Parent := nil;
SetParent(AForm.Handle, nil);
AForm.Align := alNone;
AForm.BorderStyle := bsSizeable; // вернуть способность к растягиванию
Form_Centered( AForm ); // центировать
end;
//
AForm.CalledForm := ACalledForm; // передать форму вызова
// активировать режим выбора
AForm.Tag := ASelectMode;
Result := AForm.ShowModal = mrOK;
// вернуть обычный режим
AForm.Tag := SM_SELECT_ONE;
if Result then
begin
AID := tmpButton.TagString; // плучить выбранное значение
end;
// вернуть надпись на кнопке
if ASelectMode = SM_MULTISELECT then
begin
tmpButton.Caption := 'Выбрать';
end;
tmpButton.Visible := False;
end;
Code language: Delphi (delphi)
Первичная документация
Документы создаются с помощью формы efmOperDoc. Она имеет довольно сложную логику работы: фильтрацию контрагентов по типам в зависимости от выбранного типа документа. Сам тип документа только отображается на форме редактирования, изменить его нельзя. Поэтому для определения типа создаваемого элемента используется дополнительный компонент – выпадающий список cmbDocType, который находится на форме frmOperDoc. Он расположен позади панели panToolbar, поэтому в обычном состоянии не виден.
В списке типов документов есть все, кроме "Списания материалов на производство" (свойство Filter = "id <> 5" ). Это связано с тем, что списание материалов формируется вместе с оприходованием продукции с помощью специального мастера, о котором я расскажу чуть позже.
![](https://k245.ru/wp-content/uploads/2022/08/izobrazhenie_2022-08-25_172459867-1024x712.png)
На месте кнопки добавления записи находится вспомогательная кнопка, задача которой раскрыть выпадающий список, чтобы пользователь мог выбрать тип создаваемого первичного документа.
procedure frmOperDoc_btnNew_OnClick (Sender: TObject; var Cancel: boolean);
begin
// открыть выпадающий список
frmOperDoc.cmbDocType.DroppedDown := True;
end;
Code language: Delphi (delphi)
Выглядит это так: после нажатия кнопки добавления записи (1), появляется список для выбора типа документа (2).
![](https://k245.ru/wp-content/uploads/2022/08/izobrazhenie_2022-08-25_173208501.png)
Обработчик клика по выпадающему списку обеспечивает открытие формы редактирования первичного документа или мастера по созданию пары документов на списание материалов и оприходование товара.
procedure frmOperDoc_cmbDocType_OnClick (Sender: TObject);
begin
if frmOperDoc.cmbDocType.dbItemID = DT_PRODUCTION_INCOMING then
frmProduction.ShowModal
else
frmOperDoc.btnNew_Doc.Click;
end;
Code language: Delphi (delphi)
Настройка фильтров происходит при открытии формы, в обработчике события onShow.
procedure efmOperDoc_OnShow (Sender: TObject; Action: string);
var
tmpDocType: string;
tmpIDFrom: integer;
tmpIDTo: integer;
tmpIDContrTypeFrom: string;
tmpIDContrTypeTo: string;
tmpDocNum: integer;
begin
if Action = 'NewRecord' then
begin
// установить тип документа согласно выбору пользователя
efmOperDoc.cmbDocType.dbItemID := frmOperDoc.cmbDocType.dbItemID;
tmpDocType := IntToStr(efmOperDoc.cmbDocType.dbItemID);
// сгенерировать номер документа
// ВНИМАНИЕ: генератор не годится для многопользовательской программы, так как при одновременном вводе однотипных документов может давать задвоение номеров
//
tmpDocNum := SQLExecute('SELECT docNumCount FROM docType WHERE id = '+tmpDocType); // получаем текущий номер
inc(tmpDocNum); // увеличиваем его на единицу
efmOperDoc.edtDocNum.Tag := tmpDocNum; // установить признак обновления счетчика документов
efmOperDoc.edtDocNum.Text := VarToStr(SQLExecute('SELECT docNumPref FROM docType WHERE id = '+tmpDocType)) + IntToStr(tmpDocNum); // добавляем к префиксу
end
else
begin
efmOperDoc.edtDocNum.Tag := 0; // сбросить признак обновления счетчика документов
end;
// фильтруем контрагентов
tmpIDFrom := efmOperDoc.cmbContrFrom.dbItemID;
tmpIDTo := efmOperDoc.cmbContrTo.dbItemID;
tmpDocType := IntToStr(efmOperDoc.cmbDocType.dbItemID);
// фильтруем источник
tmpIDContrTypeFrom := SQLExecute('SELECT id_contrType FROM docType WHERE id = '+tmpDocType);
efmOperDoc.cmbContrFrom.dbFilter := 'id_contrType = '+tmpIDContrTypeFrom;
efmOperDoc.cmbContrFrom.dbUpdate;
efmOperDoc.cmbContrFrom.dbItemID := tmpIDFrom;
// фильтруем назначение
tmpIDContrTypeTo := SQLExecute('SELECT id_contrType1 FROM docType WHERE id = '+tmpDocType);
efmOperDoc.cmbContrTo.dbFilter := 'id_contrType = '+tmpIDContrTypeTo;
efmOperDoc.cmbContrTo.dbUpdate;
efmOperDoc.cmbContrTo.dbItemID := tmpIDTo;
// контрагенты по умолчанию.
if Action = 'NewRecord' then
begin
efmOperDoc.cmbContrFrom.dbItemID := SQLExecute('SELECT COALESCE(id,-1) FROM ( SELECT id FROM contr WHERE isDefault = 1 AND id_contrType = '+tmpIDContrTypeFrom+' ) ');
efmOperDoc.cmbContrTo.dbItemID := SQLExecute('SELECT COALESCE(id,-1) FROM ( SELECT id FROM contr WHERE isDefault = 1 AND id_contrType = '+tmpIDContrTypeTo+' ) ');
end;
end;
Code language: Delphi (delphi)
Генератор номеров
Общей практикой нумерации внутренних документов является автоматическое увеличение номера для каждого нового документа в хронологическом порядке. Для удобства идентификации типа документа к номеру прибавляют префикс. Данный функционал реализован за счет добавления специальных полей: docType.docNumPref для хранения префикса документа и docType.docNumCount – для порядкового номера. При открытии формы происходит вычисление очередного номера, а после сохранения записи – обновление счетчика:
procedure efmOperDoc_btnSave_OnAfterClick (Sender: TObject);
// после сохранения
begin
// обновить сведения об очередном номере
if efmOperDoc.edtDocNum.Tag <> 0 then
begin
SQLExecute('UPDATE docType SET docNumCount = '+IntToStr(efmOperDoc.edtDocNum.Tag)+' WHERE id = '+IntToStr(efmOperDoc.cmbDocType.dbItemID));
end;
end;
Code language: Delphi (delphi)
Используемый программный генератор очередного номера предназначен только для однопользовательской версии программы. В многопользовательском варианте эту функцию нужно будет реализовать с помощью триггера.
Мастер производства
![](https://k245.ru/wp-content/uploads/2022/08/master-1024x1024.jpg)
Для создания документов списания материалов в производство и оприходование изделий нам потребуется специальный инструмент – мастер создания документов. Необходимость мастера обусловлена несколькими факторами.
Партионный учет материалов и продукции
Заранее неизвестна партия, по которой будет осуществляться приход готовой продукции, так как партия определяется учётной ценой (себестоимостью). В случае производства себестоимость формируется из себестоимости используемых материалов, а у разных партий она может оказаться различной. Мастер скрывает от пользователя всю сложность данного расчета, предлагая указать только номенклатурную позицию и количество продукции.
Контроль остатков
Если в процессе изготовления продукции используются несколько видов материалов и комплектующих, то при ручном формировании документа списания печальная новость о нехватке сырья может прийти при добавлении последней строчки в список материалов. Мастер осуществляет контроль остатков, списывая партии материалов в хронологической последовательности их поступления на склад (FIFO), а в случае нехватки сразу оповестит о проблеме, которую легко будет исправить корректировкой количества производимой продукции.
Контроль суммы списания и суммы оприходования
Важно, чтобы сумма списания и сумма оприходования сошлись с точностью до копейки. При ручном расчете это может вызвать затруднения, так как стоимость изделия может оказаться дробной величиной. В этом случае Мастер создаст две партии товара с точными ценами, и общая сумма списания и оприходования будет одинаковой до копейки.
Алгоритмы расчета
![](https://k245.ru/wp-content/uploads/2022/08/izobrazhenie_2022-08-26_094157124.png)
Состав комплектующих и материалов для изготовления продукции productItem заранее записан в справочнике номенклатуры, поэтому пользователь формирует список номенклатурных единиц для производства prod, указав требуемое количество qty.
При расчете материалов на списание в производство программа формирует список партий на списание (prodOper, isWriteOff = 1), при этом партии pItem упорядочиваются в хронологическом порядке – по мере их поступления на склад. В первую очередь списываются партии, поступившие раньше остальных. В список prodOper копируется ссылка на партию и цена закупки. Если какого-то материала не хватает, в список материалов он попадает без указания партии и цены (prodOper.id_pitem is null, prodOper.price = 0.00).
Если список партий на списание сформирован, и материалов на производство хватает, алгоритм приступает к формированию списка товаров на приход с производства (prodOper, isWriteOff = 0).
Для каждого изделия рассчитывается себестоимость. Для этого стоимость всех материалов делится на число изделий. В процессе вычислений цена изделия может оказаться за пределами точности учета. Это может произойти, если в производство поступили партии материалов по разной себестоимости.
Например, мы производим изделие C из двух запчастей A и B, закупочная стоимость каждой – 1 копейка. Тогда себестоимость изделия равна C = A + B, 1 + 1 = 2 копейки. Но в какой-то момент запчасть А подорожала и стала стоить 2 копейки. А на сборку поступили запчасти из двух партий, то при сборке двух изделий C получаем: (( 1 + 1 ) + ( 2 +1 ) ) / 2 = 2.5
В реальности закупочные цены могут меняться для каждого материала, но для точного учета достаточно создать две партии изделий, подобрав их цену таким образом, чтобы сумма стоимости изделий совпала с суммой стоимости материалов.
frmProduction
На форме Мастера располагаются три вкладки:
- Производство – список номенклатуры для производства
- Приход – список партий для прихода продукции
- Списание – список материалов для списания в производство
Список номенклатуры формируется пользователем, поэтому на вкладке расположена панель инструментов с кнопками редактирования, а также табличное представление данных.
![](https://k245.ru/wp-content/uploads/2022/08/izobrazhenie_2022-08-26_101501865.png)
Вкладки “Приход” и “Списание” служат для отображения результатов расчета, они содержат информацию, необходимую для одновременного формирования двух документов: списания материалов и прихода продукции из производства.
![](https://k245.ru/wp-content/uploads/2022/08/izobrazhenie_2022-08-26_101829081.png)
Обычно я привожу в статьях исходные коды всех ключевых методов, но процедура расчета занимает несколько экранов и её рассмотрение может утомить даже закаленных читателей, поэтому по завершении цикла статей о программе "Производство" я дам ссылку на исходные тексты данного проекта.
Первичные данные
Одна из особенностей My Visual Database состоит в том, что не всегда возможна модификация структуры базы данных SQLite, если база уже существует. В частности речь идет о свойстве поля “Обязательное” для полей, в которых хранятся внешние ключи. Но в процессе разработки структуру БД постоянно приходится менять, и самый простой способ снять ограничения MVDB – это удалять файл sqlite.db. Но в этом подходе есть и минусы – все введенные в процессе отладки данные теряются.
Ещё одна особенность MVDB связана с тем, что если в скрипте имеется любая ошибка, то при запуске приложения создаётся пустой файл sqlite.db. И после исправления скрипта и перезапуска он так и остаётся пустым, вызывая другие ошибки, связанные с отсутствием таблиц с данными. Этот момент надо отслеживать и своевременно удалять пустышки.
Для того, чтобы после пересоздания файла базы данных заполнить служебные (не редактируемые) справочники, используется процедура UserApp_InitBase(). Записи добавляются только если таблицы пустые.
procedure UserApp_InitBase;
// инициализация базы данных
begin
// типы контрагентов - системный справочник
if SQLExecute('SELECT count(*) FROM contrType ') = 0 then
begin
SQLExecute('INSERT INTO contrType (id,name) VALUES (1,"Поставщик")');
SQLExecute('INSERT INTO contrType (id,name) VALUES (2,"Покупатель")');
SQLExecute('INSERT INTO contrType (id,name) VALUES (3,"Склад")');
SQLExecute('INSERT INTO contrType (id,name) VALUES (4,"Цех")');
SQLExecute('INSERT INTO contrType (id,name) VALUES (5,"Утилизация")');
//
UpdateDatabase('contrType');
end;
// типы документов - системный справочник
if SQLExecute('SELECT count(*) FROM docType ') = 0 then
begin
SQLExecute('INSERT INTO docType (id,name,id_contrType,id_contrType1,docNumPref,docNumCount) VALUES (1,"Приход от поставщика",1,3,"ПР-",0)');
SQLExecute('INSERT INTO docType (id,name,id_contrType,id_contrType1,docNumPref,docNumCount) VALUES (2,"Возврат поставщику",3,1,"ВПР-",0)');
SQLExecute('INSERT INTO docType (id,name,id_contrType,id_contrType1,docNumPref,docNumCount) VALUES (3,"Продажа",3,2,"П-",0)');
SQLExecute('INSERT INTO docType (id,name,id_contrType,id_contrType1,docNumPref,docNumCount) VALUES (4,"Возврат",2,3,"В-",0)');
SQLExecute('INSERT INTO docType (id,name,id_contrType,id_contrType1,docNumPref,docNumCount) VALUES (5,"Списание в производство",3,4,"СП-",0)');
SQLExecute('INSERT INTO docType (id,name,id_contrType,id_contrType1,docNumPref,docNumCount) VALUES (6,"Приход с производства",4,3,"И-",0)');
SQLExecute('INSERT INTO docType (id,name,id_contrType,id_contrType1,docNumPref,docNumCount) VALUES (7,"Списание брака",3,5,"Б-",0)');
SQLExecute('INSERT INTO docType (id,name,id_contrType,id_contrType1,docNumPref,docNumCount) VALUES (8,"Внутреннее перемещение",3,3,"ВН-",0)');
//
UpdateDatabase('docType');
end;
// контрагенты - для примера
if SQLExecute('SELECT count(*) FROM contr ') = 0 then
begin
SQLExecute('INSERT INTO contr (id,name,id_contrType,isDefault) VALUES (1,"Поставщик",1,1)');
SQLExecute('INSERT INTO contr (id,name,id_contrType,isDefault) VALUES (2,"Покупатель",2,1)');
SQLExecute('INSERT INTO contr (id,name,id_contrType,isDefault) VALUES (3,"Склад",3,1)');
SQLExecute('INSERT INTO contr (id,name,id_contrType,isDefault) VALUES (4,"Цех",4,1)');
SQLExecute('INSERT INTO contr (id,name,id_contrType,isDefault) VALUES (5,"Утилизация",5,1)');
//
UpdateDatabase('contr');
end;
// единицы измерений - для примера
if SQLExecute('SELECT count(*) FROM unit ') = 0 then
begin
SQLExecute('INSERT INTO unit (id,code,name) VALUES (1,"м","метр")');
SQLExecute('INSERT INTO unit (id,code,name) VALUES (2,"кг","килограмм")');
SQLExecute('INSERT INTO unit (id,code,name) VALUES (3,"шт","штук")');
//
UpdateDatabase('unit');
end;
// типы номенклатуры - для примера
if SQLExecute('SELECT count(*) FROM itemType ') = 0 then
begin
SQLExecute('INSERT INTO itemType (id,name,isProduct) VALUES (1,"Материалы",0)');
SQLExecute('INSERT INTO itemType (id,name,isProduct) VALUES (2,"Изделия",1)');
//
UpdateDatabase('itemType');
end;
//
end;
Code language: Delphi (delphi)
Библиотечные функции
Денежный формат колонки
Если таблица заполняется с помощью SQL-запроса, то необходимо побеспокоиться о правильном отображении данных. Для этого в обработчике события onChange нужно вызвать процедуру Grid_FinFormat(). Она настроит указанную колонку и футер для отображения данных в денежном формате.
procedure Grid_FinFormat( Sender:TObject; AColNumber:integer; ASum:boolean = True);
// форматирование колонки таблицы/дерева в денежный формат
var
tmpGrid:TdbStringGridEx;
begin
tmpGrid := TdbStringGridEx(Sender);
TNxNumberColumn(tmpGrid.Columns[AColNumber]).FormatMask := '#,##0.00';
if ASum then
begin
TNxNumberColumn(tmpGrid.Columns[AColNumber]).Footer.FormatMask := '#,##0.00';
TNxNumberColumn(tmpGrid.Columns[AColNumber]).Footer.FormulaKind := fkSum;
end;
end;
Code language: Delphi (delphi)
Примеры вызова данной процедуры:
procedure frmOperDoc_tgrMain_OnChange (Sender: TObject);
begin
Grid_FinFormat( Sender, 5); // денежный формат для колонки и футера
end;
procedure frmOper_tgrMain_OnChange (Sender: TObject);
begin
Grid_FinFormat( Sender, 2, False); // денежный формат только для колонки
end;
Code language: Delphi (delphi)
Центрирование формы
Если форма справочника ранее отображалась на панели, а затем её нужно отобразить модально, то необходимо выполнить центрирование формы с помощью процедуры Form_Centered()
procedure Form_Centered(Sender: TObject;);
// размещение формы по центру экрана
var
tmpForm: TAForm;
begin
tmpForm := TAForm(Sender);
tmpForm.Left := (Screen.Width - tmpForm.Width) div 2;
tmpForm.Top := (Screen.Height - tmpForm.Height) div 2;
end;
Code language: Delphi (delphi)
Подсвечивание данных в таблице
Две простые функции помогут задать указанный цвет для всей строки в таблице. Для управления цветом текста используйте процедуру Grid_SetRowTextColor(), а для задания фона – Grid_SetRowColor();
procedure Grid_SetRowTextColor( AObject:TObject; ARow:integer; AColor:TColor );
// заливка цвета текста строки таблицы или дерева
var
i: integer;
tmpTable: TdbStringGridEx;
begin
tmpTable := TdbStringGridEx(AObject);
for i := 0 to tmpTable.Columns.Count - 1 do
tmpTable.Cell[i,ARow].TextColor := AColor;
end;
procedure Grid_SetRowColor( AObject:TObject; ARow:integer; AColor:TColor );
// заливка цвета фона строки таблицы или дерева
var
i: integer;
tmpTable: TdbStringGridEx;
begin
tmpTable := TdbStringGridEx(AObject);
for i := 0 to tmpTable.Columns.Count - 1 do
tmpTable.Cell[i,ARow].Color := AColor;
end;
Code language: Delphi (delphi)
![](https://k245.ru/wp-content/uploads/2022/08/izobrazhenie_2022-08-26_103859751.png)
Улучшение кода
Так как глобальные переменные – это глобальное зло, то нужно по возможности от него избавляться. В частности, глобальные переменные использовались для хранения ссылок на разделители, чтобы сохранять их настройки при завершении программы. Но, принимая во внимание силу объектно-ориентированного подходя, можно сделать универсальное сохранение как настроек разделителей, так и настроек узлов деревьев.
procedure App_OnClose(Sender: TObject; Action: string);
// действия при закрытии программы
var
tmpForm: TForm;
i: integer;
j: integer;
begin
// для всех форм
for i := 0 to Screen.FormCount - 1 do
begin
tmpForm := TForm(Screen.Forms[i]);
// перебираем все компоненты
for j:=0 to tmpForm.ComponentCount -1 do
begin
// сохранение настройки разделителя
if tmpForm.Components[j] is TSplitter then
Splitter_SavePosition( TSplitter(tmpForm.Components[j]) )
else
// сохранение настройки узла дерева
if tmpForm.Components[j] is TdbTreeView then
begin
Tree_SetCollapseList( TdbTreeView(tmpForm.Components[j]) );
Tree_SeveCollapseList( TdbTreeView(tmpForm.Components[j]) );
end;
end;
end;
end;
Code language: Delphi (delphi)
Продолжение следует