Продолжение статьи “Простые движения и формы”

Настало время для серьёзного разговора об алгоритмах и расчетах, которыми учет на производстве отличается от учета в магазине или складе. Но, прежде чем мы приступим к изучению теории и практики учёта, необходимо закончить общие темы функционирования приложения.

Модальный справочник

В программе уже имеется доступ к справочникам через главное меню. Это удобно, когда нужно массово внести заранее известные значения. А вот если новое значение справочника понадобится в процессе ввода данных в основной документ, или требуется осуществить поиск данных в справочнике по определенным критериям, то поможет связка элементов:

  • выпадающий список
  • кнопка выбора значения из справочника
  • отображение справочника в модальном режиме

Чтобы создать универсальные процедуры необходимо создать очередное соглашение по наименованию компонентов на форме редактирования.

  1. Выпадающий список должен называться так же, как и форма справочника (cmb – префикс выпадающего списка, frm – префикс формы справочника)
  2. Кнопка открытия справочника в суффиксе должна содержать название формы справочника (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. Название кнопки также является частью соглашения.

Обработчик этой кнопки универсальный:

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" ). Это связано с тем, что списание материалов формируется вместе с оприходованием продукции с помощью специального мастера, о котором я расскажу чуть позже.

На месте кнопки добавления записи находится вспомогательная кнопка, задача которой раскрыть выпадающий список, чтобы пользователь мог выбрать тип создаваемого первичного документа.

procedure frmOperDoc_btnNew_OnClick (Sender: TObject; var Cancel: boolean);
begin
  // открыть выпадающий список
  frmOperDoc.cmbDocType.DroppedDown := True;
end;
Code language: Delphi (delphi)

Выглядит это так: после нажатия кнопки добавления записи (1), появляется список для выбора типа документа (2).

Обработчик клика по выпадающему списку обеспечивает открытие формы редактирования первичного документа или мастера по созданию пары документов на списание материалов и оприходование товара.

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)
Используемый программный генератор очередного номера предназначен только для однопользовательской версии программы. В многопользовательском варианте эту функцию нужно будет реализовать с помощью триггера.

Мастер производства

Для создания документов списания материалов в производство и оприходование изделий нам потребуется специальный инструмент – мастер создания документов. Необходимость мастера обусловлена несколькими факторами.

Партионный учет материалов и продукции

Заранее неизвестна партия, по которой будет осуществляться приход готовой продукции, так как партия определяется учётной ценой (себестоимостью). В случае производства себестоимость формируется из себестоимости используемых материалов, а у разных партий она может оказаться различной. Мастер скрывает от пользователя всю сложность данного расчета, предлагая указать только номенклатурную позицию и количество продукции.

Контроль остатков

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

Контроль суммы списания и суммы оприходования

Важно, чтобы сумма списания и сумма оприходования сошлись с точностью до копейки. При ручном расчете это может вызвать затруднения, так как стоимость изделия может оказаться дробной величиной. В этом случае Мастер создаст две партии товара с точными ценами, и общая сумма списания и оприходования будет одинаковой до копейки.

Алгоритмы расчета

Состав комплектующих и материалов для изготовления продукции 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

На форме Мастера располагаются три вкладки:

  • Производство – список номенклатуры для производства
  • Приход – список партий для прихода продукции
  • Списание – список материалов для списания в производство

Список номенклатуры формируется пользователем, поэтому на вкладке расположена панель инструментов с кнопками редактирования, а также табличное представление данных.

Вкладки “Приход” и “Списание” служат для отображения результатов расчета, они содержат информацию, необходимую для одновременного формирования двух документов: списания материалов и прихода продукции из производства.

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

Первичные данные

Одна из особенностей 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)
Пример использования

Улучшение кода

Так как глобальные переменные – это глобальное зло, то нужно по возможности от него избавляться. В частности, глобальные переменные использовались для хранения ссылок на разделители, чтобы сохранять их настройки при завершении программы. Но, принимая во внимание силу объектно-ориентированного подходя, можно сделать универсальное сохранение как настроек разделителей, так и настроек узлов деревьев.

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)

Продолжение следует

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *