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

Следующим шагом к цели является описание поведения исходящего документа. То были цветочки… под катом еще больше текста и кода.

Требования к исходящему документу оказались в несколько раз серьезнее, чем к входящему. Если собрать только основные в отдельный сводный список, то получится:
  • Регистрация исходящего документа любым сотрудником организации
  • Совместная разработка сопроводительного письма (разумеется, не одновременно, но в одном файле)
  • Сохранение промежуточных версий сопроводительного письма
  • Хранение большого числа приложений в разных форматах
  • Хранение архивных (zip) файлов с проектной документацией (с загрузкой из TDMS и хранением доп. информации о связи с объектами)
  • Отправка исходящего документа различными способами (эл. почтой как есть, одним архивом, многотомным архивом, расшариванием, загрузкой на ftp и т.п.)

Как видите, требования непростые. Именно поэтому я так внимательно отнесся к выбору новой системы и остановился на easla.com. Но, давайте расскажу по-порядку.
Начал я с того, что в процессе «Переписка» создал объект «Исходящий документ» и перешел к наполнению его атрибутами. Их еще больше, чем во входящем!

Атрибуты


Контрагент

Ссылка на объект «Контрагент» из процесса «Заказчики». Инициализация допустимых значений атрибута осуществляется в скрипте «После инициализации объекта»:
cobjectref()->attributeref('crs_management_outgoing_contragent')->values = prepareOutgoingContragents();

Вспомогательная функция prepareOutgoingContragents объявлена в скрипте «До инициализации объекта»:
function prepareOutgoingContragents()
{
    $src_contragents = selectAll(
      'crm_management',
      'crm_management_contragent'
    );
    $end_contragents = array();
    foreach ($src_contragents as $s)
      $end_contragents += array($s['id'] => $s['description']);
    asort($end_contragents);
    return $end_contragents;
}

В зависимости от выбранного контрагента зависит список доступных контактов и рекомендуемое правило отправки письма. И если поведение с контактами понятно всем, то с правилом отправки — не каждому. Дело в том, что наши контрагенты, особенно заказчики, очень капризны в части приема эл. почты. Мне сложно объяснить причины такого поведения, но, например, одни заказчики принимают эл. письма максимальным объемом 3Мб и при этом требуют, чтобы всю документацию, которая может быть объемом и 100Мб, им присылали исключительно по эл. почте. Другие допускают передачу больших вложений в эл. письмах, скажем, порядка 10-15Мб, но требуют, чтобы все приложения поступали только в формате zip и ни в коем случае не многотомным архивом. Третьи настаивают на том, чтобы сопроводительное письмо было отправлено эл. почтой на их корпоративный адрес, а все приложения были переданы иным образом и другим адресатам. Четвертые просят выкладывать приложения к письмам на приватную «шару», чтобы они могли оттуда все скачать. Пару недель назад, один заказчик попросил при отправке письма все приложения перекладывать на защищенный FTP, а другой потребовал, чтобы все приложения и сопроводительное письмо закачивалось к ним на портал поднятый на FrontPage и раскладывалось по определенным папкам.
Было очевидно, что при интенсивной переписке, вручную удовлетворять требования всех заказчиков по отправке писем будет сложно. Возможно, но очень сложно. Придется и специалисту по делопроизводству, и ГИПам помнить, в каком именно виде отправлять письмо конкретному заказчику. Издевательство, а не работа!
Мне пришлось немного поломать себе голову, как бы сохранить все эти требования в easla.com, чтобы облегчить труд всех участников процесса. В итоге, я доработал объект «Контрагент» в процессе «Заказчики» путем добавления к нему двух атрибутов: один для хранения рекомендуемого правила отправки (о нем подробнее ниже), второй для списка дополнительных контактов (о нем тоже будет подробнее).
Короче, скрипт «При изменении» атрибута позволяет переопределить значения атрибутов в соответствии с выбранным контрагентом:
if (!empty(cattributeref()->value))
{
    $contacts = cobjectref()->prepareOutgoingContacts(cattributeref()->value);
    cobjectref()->attributeref('crs_management_outgoing_contact')->values = $contacts;
    
    $contracts = cobjectref()->prepareContracts(cattributeref()->value);
    if (empty($contracts))
        $contracts = cobjectref()->prepareContracts();
    cobjectref()->attributeref('crs_management_outgoing_contract')->values = $contracts;
    
    $default_rule = cobjectref()->calcContragentOutgoingRule(cattributeref()->value);
    if (!empty($default_rule))
        cobjectref()->attributeref('crs_management_outgoing_rule')->value = $default_rule;
    
    $default_notifiers = cobjectref()->calcContragentOutgoingNotifiers(cattributeref()->value);
    if (!empty($default_notifiers))
        cobjectref()->attributeref('crs_management_outgoing_notifiers')->value = $default_notifiers;
        
    //if (cobjectref()->attributeref('crs_management_outgoing_contragent')->value == 45410)
    
}
cobjectref()->attributeref('crs_management_outgoing_cntnum')->value = cobjectref()->updateDocumentCntNum();
cobjectref()->attributeref('crs_management_outgoing_regnum')->value = cobjectref()->calcOutgoingCode();

Кстати, две последние строчки скрипта тоже добавлены недавно. Связано с тем, что один заказчик потребовал от нас использовать для идентификации исходящих документов свои обозначения, отличные от наших. Вот такие они – заказчики!

Контакт

Ссылка на объект «Контакт» в процессе «Заказчики». Как и список контрагентов, список контактов инициализируется в скрипте «После инициализации объекта»:
$contacts = prepareOutgoingContacts();
cobjectref()->attributeref('crs_management_outgoing_contact')->values = $contacts;

При этом вспомогательная функция prepareOutgoingContacts точно также объявлена в скрипте «До инициализации объекта»:
function prepareOutgoingContacts($contragent = null)
{
    if (empty($contragent))
        $src_contacts = selectAll(
          'crm_management',
          'crm_management_contact',
          array('crm_management_contact_contragent.description'),
          null
        );
    else
        $src_contacts = selectAll(
          'crm_management',
          'crm_management_contact',
          array('crm_management_contact_contragent.description'),
          array('crm_management_contact_contragent'=>$contragent)
        );
    
    $end_contacts = array();
    $processed_contact = array(); 
    $processed_description = array();
    foreach ($src_contacts as $s)
    {
        $e = array_search($s['description'], $processed_description);
        if($e === false) {
            $processed_contact += array($s['id'] => $s);
            $processed_description += array($s['id'] => $s['description']);
            $end_contacts += array($s['id'] => $s['description']);
        } else {
            $end_contacts[$e] = $processed_contact[$e]['description'].' ['.trim($processed_contact[$e]['crm_management_contact_contragent.description']).']';
            $end_contacts += array($s['id'] => $s['description'].' ['.trim($s['crm_management_contact_contragent.description']).']');
        }
    }
    unset($processed_contact,$processed_description);
    asort($end_contacts);
    return $end_contacts;
}

Функция, как и ее «сестра» в объекте «Входящий документ», ко всем полным тезкам дописывает название организации в которой они работают, чтобы их можно было отличить.
Ради дополнительного удобства, скрипт «При изменении» контакта и не указанном контрагенте, подставляет нужного контрагента и обновляет значения зависимых от него атрибутов:
if (empty(cobjectref()->attributeref('crs_management_outgoing_contact')->value))
    return;
    
if (empty(cobjectref()->attributeref('crs_management_outgoing_contragent')->value))
{
    $contact = select(cobjectref()->attributeref('crs_management_outgoing_contact')->value);
    
    if (empty($contact))
        return;
    
    $contragent_id = $contact->attributeref('crm_management_contact_contragent')->value;
    cobjectref()->attributeref('crs_management_outgoing_contragent')->value = $contragent_id;
    
    $contracts = cobjectref()->prepareContracts($contragent_id);
    if (empty($contracts))
        $contracts = cobjectref()->prepareContracts();
    cobjectref()->attributeref('crs_management_outgoing_contract')->values = $contracts;
    
    $default_rule = cobjectref()->calcContragentOutgoingRule($contragent_id);
    if (!empty($default_rule))
        cobjectref()->attributeref('crs_management_outgoing_rule')->value = $default_rule;
    
    $default_notifiers = cobjectref()->calcContragentOutgoingNotifiers($contragent_id);
    if (!empty($default_notifiers))
        cobjectref()->attributeref('crs_management_outgoing_notifiers')->value = $default_notifiers;
}
cobjectref()->attributeref('crs_management_outgoing_cntnum')->value = cobjectref()->updateDocumentCntNum();
cobjectref()->attributeref('crs_management_outgoing_regnum')->value = cobjectref()->calcOutgoingCode();

Выглядит результат примерно так:


Доп. контакты

Ссылка на объект «Контакт» в процессе «Заказчики». Представляет собой список дополнительных контактов, которые будут поставлены в копию при отправке письма основным адресатам. Как раз в этот атрибут записываются контакты, указанные в объекте «Контрагент», которым нужно отправить копии письма, если оно направлено определенному контрагенту.
Реализация получилась очень удобная, т.к. раньше, специалист по делопроизводству и ГИПы обклеивали свои мониторы липкими бумажками, на которых были написаны наименования заказчика и список лиц, которым отправлять копии писем. Вместо бумажек теперь используется easla.com и никто не забывает указывать контакты в копии.

Доп. уведомлять по эл. почте

Ссылка на объект «Контакт» в процессе «Заказчики». Очередной список доп. контактов, которых надо отдельно оповещать копией письма. Зачастую это могут быть даже не сотрудники заказчика, а скажем, сотрудники головной организации или контролирующего органа.

Правило отправки

Классификатор, позволяющий определить способ отправки письма адресату. На сегодняшний день у нас используется 16 правил отправки. Некоторые отличаются только в цифрах, например:
  • Все отправить всем (письмо не более 3Мб)
  • Документ осн. контакту, все всем остальным (больше 5М, архивы по 4.5М)
  • Всем отправить ссылку на архив с письмом и приложениями
  • Документ осн. контакту, всем остальным ссылку на архив с письмом и приложениями
  • Все отправить всем (больше 3М, архивы по 2.5М)
  • Все переложить на ftp.sngp.ru для ООО «Заказчик»
  • Все отправить как ссылки на портал pro.blablabla.ru

Список допустимых значений формируется в скрипте атрибута «При инициализации»:
$src_classificators = classificatorChilds('crs_outgoing_rule');
$end_classificators = array();
$default = 'crs_outgoing_rule_asis_max5';
$default_index = null;
foreach($src_classificators as $c) {
  $end_classificators += array($c['id']=>$c['name']);
  if ($c['code'] == $default)
    $default_index = $c['id'];
}
if (count($end_classificators) > 0)
{
  cattributeref()->values=$end_classificators;
  cattributeref()->value = is_null($default_index) ? key($end_classificators) : $default_index;
}

Полезная функция classificatorChilds возвращает прямых потомков указанного классификатора. Остается только перебрать значения, сформировать массив и присвоить атрибуту.
Правило отправки использует в действии «Отправить по эл. почте», которое будет описано ниже.

Дата регистрации

Очевидно из названия, что хранит дату регистрации исходящего документа. Исходное значение соответствует текущей дате и инициализируется в скрипте атрибута «При инициализации»:
cobjectref()->attributeref('crs_management_outgoing_regdate')->value = currentDateTime();


Дата отправки

Также понятно из названия, что атрибут хранит дату отправки. Он только для чтения, т.е. сам пользователь не может его заполнить. В режим «только для чтения» атрибут переводится в скрипте «При инициализации»:
cattributeref()->readonly = true;


Исполнители

Пользователи (сотрудники) организации, участвующие в разработке письма. Атрибут множественный, так как в разработке письма может участвовать несколько сотрудников. Во время регистрации (создания) письма происходит идентификация текущего пользователя и, если он не относится к узкому кругу лиц, очерченному указанными в коде группами, то записывается в значение атрибута. Каждый последующий сотрудник должен сам добавить себя в список исполнителей, если хочет получить информацию об отправке письма. Кстати, исполнители указываются и в конце бланка исходящего письма с указанием их телефонов для обратной связи.
Инициализация допустимых значений атрибута происходит в скрипте «При инициализации»:
$src_users = corganization()->allUsers();
$end_users = array();
foreach($src_users as $u) 
    if ($u['islocked'] == 0)
        $end_users += array($u['id']=>$u['description']);
asort($end_users);
cobjectref()->attributeref('crs_management_outgoing_performers')->values = $end_users;
if (!empty(cuser())) 
{
    $rsp_users = corganization()->allUsersByGroups(array(
        'group_general_manager',
        'group_general_engineer',
        'group_general_manager_operations',
        'group_general_manager_economics',
        'group_gip',
        'group_hr',
        'group_dp'
        ), null);
    $f = true;
    foreach($rsp_users as $u)
        if ($u['id'] == cuser()->id) {
            $f = false;
            break;
        }
    if ($f) {
        cobjectref()->attributeref('crs_management_outgoing_performers')->value = cuser()->id;
        cobjectref()->updateResponsibleGroup();
    }
}

Вспомогательная функция updateResponsibleGroup предназначена для обновления значения атрибута «Отв. подразделение» и объявлена в скрипте «До инициализации объекта»:
function updateResponsibleGroup()
{
    if (empty(cobjectref()->attributeref('crs_management_outgoing_performers')->value))
        return;
        
    if (empty(cobjectref()->attributeref('crs_management_outgoing_responsiblegroup')->value))
    {
        $performers = cobjectref()->attributeref('crs_management_outgoing_performers')->value;
        $user = corganization()->user($performers[0]);
        if (empty($user))
            return;
            
        $groups = $user->groups();
        foreach($groups as $group)
        if (!empty($group['data_one']))
        {
            cobjectref()->attributeref('crs_management_outgoing_responsiblegroup')->value = $group['id'];
            break;
        }
    }
}

В случае изменения значения атрибута срабатывает скрипт «При изменении», который обновляет ответственное подразделение в форме исходящего документа:
if (!empty(cobjectref()->attributeref('crs_management_outgoing_performers')->value) && empty(cobjectref()->attributeref('crs_management_outgoing_responsiblegroup')->value))
    cobjectref()->updateResponsibleGroup();


Отв. сотрудник

Пользователи (сотрудники) организации, который будет подписывать исходящее письмо. В нашем случае, официальные письма подписывают только высшие руководители, начальник отдела кадров и ГИПы. Перечень допустимых сотрудников формируется в скрипте «При инициализации»:
$src_users = corganization()->allUsersByGroups(array(
    'group_general_manager',
    'group_general_engineer',
    'group_general_manager_operations',
    'group_general_manager_economics',
    'group_gip_only',
    'group_hr'
    ), null);
    
$end_users = array();
foreach($src_users as $u) 
    if ($u['islocked'] == 0)
        $end_users += array($u['id']=>$u['description']);
asort($end_users);
cobjectref()->attributeref('crs_management_outgoing_responsibleuser')->values = $end_users;

Функция allUsersByGroups просто «палочка-выручалочка», когда нужно получить список пользователей входящих в определенные группы или наоборот.
При изменении атрибута происходит проверка наличия значения в атрибуте «Отв. подразделение». В том случае, если оно отсутствует, т.е. в письме не указан исполнитель, в него записывается подразделение, к которому относится отв. сотрудник:
if (!empty(cattributeref()->value) && empty(cobjectref()->attributeref('crs_management_outgoing_responsiblegroup')->value))
{
    $user = corganization()->user(cattributeref()->value);
    $groups = $user->groups();
    foreach ($groups as $group)
        if (is_numeric($group->data_one))
        {
            cobjectref()->attributeref('crs_management_outgoing_responsiblegroup')->value = $group->id;
            break;
        }
}
cobjectref()->attributeref('crs_management_outgoing_regnum')->value = cobjectref()->calcOutgoingCode();

Очень радует возможность создавать такие динамические формы в easla.com. Изменяя значения одних атрибутов, могу изменять значения других в любой последовательности! Попробуйте сделать такое же, например, в Sharepoint.

Отв. подразделение

Группа пользователей, в нашем случае – подразделение организации, которое отвечает за разработку исходящего документа.
Все подразделения в нашей организации имеют свои числовые (дробные) идентификаторы присвоенные отделом кадров. Наверное, они применяются где-то еще, но в моем случае, они применяются для идентификации исходящего письма. Поэтому, когда я создавал группы для пользователей в easla.com, в так называемые «первые данные» вписал и эти идентификаторы.

При формировании списка возможных отв. подразделений скрипт «При инициализации» проверяет наличие «первых данных» и включает группы в список только при их наличии:
$src_groups = corganization()->groups();
$end_groups = array();
foreach($src_groups as $g) 
    if (!empty($g['data_one']))
        $end_groups += array($g['id']=>$g['name']);
asort($end_groups);
cobjectref()->attributeref('crs_management_outgoing_responsiblegroup')->values = $end_groups;

При изменении отв. подразделения изменяется обозначение регистрационного номера исходящего документа:
cobjectref()->attributeref('crs_management_outgoing_regnum')->value = cobjectref()->calcOutgoingCode();

Вспомогательная функция calcOutgoingCode объявлена в скрипте «До инициализации объекта»:
function calcOutgoingCode()
{
    if (empty(cobjectref()->attributeref('crs_management_outgoing_contragent')->value))
        return;
        
    if (empty(cobjectref()->attributeref('crs_management_outgoing_cntnum')->value))
        return;
    
    if (!empty(cobjectref()->attributeref('crs_management_outgoing_content')->value) &&
        cobjectref()->attributeref('crs_management_outgoing_contragent')->value == 45410 &&
        cobjectref()->attributeref('crs_management_outgoing_content')->value == 1192) {
        //This is ZapSib-2 Project
        
        if (empty(cobjectref()->attributeref('crs_management_outgoing_content')->value))
            return;
            
        $content = classificator(cobjectref()->attributeref('crs_management_outgoing_content')->value);
        
        if (empty($content))
            return;
        
        $content_code = $content['data_one'];
        
        if (empty($content_code))
            return;
        
        //TRANSMITTAL
        return sprintf('TNGP-NPG-ZS2-0311-%s-%05d', 
            $content_code,
            cobjectref()->attributeref('crs_management_outgoing_cntnum')->value
        );
    }
    
    if (empty(cobjectref()->attributeref('crs_management_outgoing_responsibleuser')->value))
        return;
    
    if (empty(cobjectref()->attributeref('crs_management_outgoing_responsiblegroup')->value))
        return;
        
    $group_code = str_replace('.','-',corganization()->group(cobjectref()->attributeref('crs_management_outgoing_responsiblegroup')->value)->data_one);
    $user_code = mb_substr(corganization()->allUser(cobjectref()->attributeref('crs_management_outgoing_responsibleuser')->value)->lastname, 0, 2);
    
    return sprintf(
        '%s/%s-%s',
        $group_code,
        $user_code,
        cobjectref()->attributeref('crs_management_outgoing_cntnum')->value
    );
}

До недавнего времени функция была куда проще, но из-за последних требований, пришлось внести в нее изменение, которое учитывает идентификатор заказчика и тип письма. В соответствии с заполненными атрибутами функция формирует регистрационный номер документа.

Порядковый номер

Порядковый номер исходящего документа начинает свою нумерацию каждый год заново. Поэтому использовать встроенный в easla.com нумератор оказалось невозможным. Пришлось написать свой.
Кроме этого, один из заказчиков потребовал использовать сквозную нумерацию для определенного типа своих писем, поэтому нумерация у нас как бы двойная. Обычная для писем с нашим регистрационным номером и необычная для писем с регистрационным номером затребованным заказчиком.
При создании исходящего документа происходит вычисление исходного порядкового номера:
cattributeref()->value = cobjectref()->updateDocumentCntNum();

Вспомогательная функция updateDocumentCntNum также объявлена в скрипте «До инициализации объекта»:
function updateDocumentCntNum()
{
    if (cobjectref()->isNewRecord || empty(cobjectref()->attributeref('crs_management_outgoing_cntnum')->value))
    {
        if (!empty(cobjectref()->attributeref('crs_management_outgoing_contragent')->value) && 
            !empty(cobjectref()->attributeref('crs_management_outgoing_content')->value) &&
            cobjectref()->attributeref('crs_management_outgoing_contragent')->value == 45410 &&
            cobjectref()->attributeref('crs_management_outgoing_content')->value == 1192)
        {
            $num = selectAggregateAll(
                'max',
                'crs_management',
                'crs_management_outgoing',
                'crs_management_outgoing_cntnum',
                array(
                    'crs_management_outgoing_contragent'=>array('id',cobjectref()->attributeref('crs_management_outgoing_contragent')->value),
                    'crs_management_outgoing_content'=>array('id',cobjectref()->attributeref('crs_management_outgoing_content')->value)
                )
            );
            return $num + 1;
        }
    
        $year = date_format(currentDateTime(), 'Y');
    
        $condition = array(
            'crs_management_outgoing_regdate'=>array('between', $year.'-01-01', $year.'-12-31'),
        );
        switch ($year) {
            case '2016':
                $condition['crs_management_outgoing_cntnum'] = '<4489';
                $tmp = selectAggregateAll(
                    'max',
                    'crs_management',
                    'crs_management_outgoing',
                    'crs_management_outgoing_cntnum',
                    $condition
                );
                if ($tmp == 4488) {
                    unset($condition['crs_management_outgoing_cntnum']);
                } else {
                    return $tmp + 1;
                }
                break;
        }
    
        $num = selectAggregateAll(
            'max',
            'crs_management',
            'crs_management_outgoing',
            'crs_management_outgoing_cntnum',
            $condition
        );
        return $num + 1;
    } else {
        return cobjectref()->attributeref('crs_management_outgoing_cntnum')->value;
    }
}

Если вы внимательно посмотрите на код, то увидите странное условие case '2016'. Это результат наших внутренних проблем. У нас тоже бывает регистрация документов «задним числом», поэтому в easla.com я не стал делать «отсечку» на попытку обмана. Как бы, все на ответственности пользователя. Вот и создали в начале этого года письма с прошлогодними номерами. И даже успели их отправить! Пришлось ставить условие и отсекать такие письма, чтобы правильно нумеровать письма в нынешнем году.
Отдельно обращу внимание на функцию selectAggregateAll. Она возвращает агрегированное значение, в моем случае, максимальное числовое значение атрибута 'crs_management_outgoing_cntnum' объекта 'crs_management_outgoing' в процессе 'crs_management'. При этом использует условие поиска $condition, которое формируется немного выше.

В той же elma мне пришлось отказать от подобного метода в пользу обходного. Вот как было:
Правильной, но тормозной код в elma
var manager = EntityManager<IOutgoingDoc>.Instance;
DateTime startdate = new DateTime(entity.RegDate.Value.Year, 1, 1);
DateTime enddate = new DateTime(entity.RegDate.Value.Year, 12, 31);
var allInYear = from d in manager.FindAll()
	where d.RegDate.Value >= startdate && d.RegDate.Value <= enddate
	orderby d.CntNum
	select d;
if (allInYear.Count() == 0)
	entity.CntNum = 1;
else
entity.CntNum = allInYear.Last().CntNum + 1;


Все работало до поры до времени, но потом начало так сильно тормозить, что пришлось искать узкие места. Выяснилось, что одним таким местом является вычисление порядкового номера. Пришлось создать доп. таблицы и заменить код, чтобы увеличить скорость работы объекта и системы в целом:
Обходной маневр в elma
string ConString = "Data Source=ss2;Initial Catalog=ELMA;User ID=sa;Password=password;";
using (SqlConnection connection = new SqlConnection(ConString))
{
	using (SqlCommand command = connection.CreateCommand())
	{
		var year = entity.RegDate.Value.Year; 		
		command.CommandText = "SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; SELECT TOP 1 cntNum FROM [ELMA].[dbo].[OutgoingDoc] WHERE (RegDate >= CAST('" + year.ToString() + "-01-01 00:00:00' AS datetime)) AND (RegDate < CAST('" + year.ToString() + "-12-31 23:59:59' AS datetime)) ORDER BY cntNum DESC";
		connection.Open();
	SqlDataReader reader = command.ExecuteReader();
	var cntNum = 0;
	if (reader.Read())
		cntNum = int.Parse(reader[0].ToString()); 	
	connection.Close();
	
	command.CommandText = "SELECT TOP 1 reservedNum FROM [TNGP].[dbo].[ElmaReservedNums] WHERE docType = '"  entity.TypeUid + "' ORDER BY reservedNum DESC";
	connection.Open();
	reader = command.ExecuteReader();
	var reservedNum = 0;
	if (reader.Read())
		reservedNum = int.Parse(reader[0].ToString());
	connection.Close();
	
	if (cntNum < reservedNum)
		cntNum = reservedNum;
	
	if (cntNum == 0)
		cntNum = 1;
	else
		cntNum++;
	
	command.CommandText = "INSERT INTO [TNGP].[dbo].[ElmaReservedNums](reservedNum, docType) VALUES (" + cntNum.ToString() + ",'" + entity.TypeUid + "')";
	connection.Open();
	command.ExecuteNonQuery();
	connection.Close();
	
	entity.CntNum = cntNum;
	}
}


Разве это дело?!

Регистрационный номер

Обязательный текстовый атрибут только для чтения. Хранит регистрационный номер исходящего документа. Вычисляется при инициализации:
cattributeref()->readonly = true;
cattributeref()->value = cobjectref()->calcOutgoingCode();

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


Тема

Обычный многострочный обязательный текстовый атрибут.

Тип содержания

Классификатор, позволяющий определить тип содержания письма. Точно такой же, как и у входящего документа. Список допустимых значений формируется «При инициализации»:
$src_classificators = classificatorChilds('crs_content');
$end_classificators = array();
foreach($src_classificators as $c) {
  $end_classificators += array($c['id']=>$c['name']);
  if ($c['code'] == 'crs_content_other')
    $default = $c['id'];
}
if (count($end_classificators) > 0)
{
  cattributeref()->values=$end_classificators;
  cattributeref()->value = isset($default) ? $default : $c['id'];
}

Совсем недавно изменение значения атрибута потребовало обновления порядкового и, как следствие, регистрационного номера.
if (cobjectref()->attributeref('crs_management_outgoing_contragent')->value == 45410)
    cobjectref()->attributeref('crs_management_outgoing_cntnum')->value = cobjectref()->updateDocumentCntNum();
        
cobjectref()->attributeref('crs_management_outgoing_regnum')->value = cobjectref()->calcOutgoingCode();


В ответ на входящие

Ссылка на «Входящий документ» этого же процесса. Такой же важный атрибут, как и "В ответ на исходящее" для объекта «Входящий документ». Позволяет выстроить цепочку писем входящий-исходящий-входящий-исходящий и т.д.
В нашем случае, помимо цепочки писем, заполнение атрибута влияет еще и на появление соответствующих отметок в задачах, поставленных для подготовки ответа на входящее письмо. Но об этом надо рассказывать в отдельной статье про процесс «Задачи».
Инициализация списка доступных входящих писем осуществляется в скрипте атрибута «При инициализации»:
cattributeref()->values = cobjectref()->prepareIncomings();

Используется вспомогательная функция prepareIncomings объявленная в скрипте «До инициализации объекта»:
function prepareIncomings()
{
    $src_documents = selectAll(
      'crs_management',
      'crs_management_incoming',
      array('crs_management_incoming_contragent_regnum')
    );
        
    $end_documents = array();
    foreach ($src_documents as $d)
      $end_documents += array($d['id'] => $d['crs_management_incoming_contragent_regnum'].' ['.$d['description'].']');
    asort($end_documents);
    return $end_documents;
}


Договор

Ссылка на объект «Договор» в процессе «Договоры». Опять же, является полным аналогом атрибута "Договор" в объекте «Входящий документ».
Инициализация списка доступных договоров происходит в скрипте «После инициализации объекта», сразу после инициализации списка контрагентов и контактов:
cobjectref()->attributeref('crs_management_outgoing_contract')->values = prepareContracts();

Изменение списка доступных договоров происходит при изменении других атрибутов, в частности, контрагента.

Регистрационный номер получателя

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

Дата получения

Должен заполняться вместе с регистрационным номером получателя. Ну вы поняли…

Фамилия И.О. получателя

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

Документ

Вот в этом атрибуте хранится собственно сам файл исходящего документа. Причем, следуя требованиям руководства, он настроен так, чтобы сохранять все версии файла.
Вся настройка атрибута осуществляется в скрипте «При инициализации»:
cattributeref()->revMode = true;
cattributeref()->canScan = true;
cattributeref()->fileLinks = 3;//1;//2;

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


Включил canScan и вуаля, под атрибутом появилась кнопка для прямого сканирования файлов в объект.


Включил режим fileLinks=3 и вуаля, теперь при клике на файле он не скачивается, а сразу открывается в соответствующем приложении (при наличии установленного Easla Agent). Нарочно не стал убирать комментарий в конце строки, он напоминает мне о том, как постепенно пользователи привыкали к изменению ссылки. Изначально был режим 0, т.е. обычный, когда клик по файлу приводил к его скачиванию. Потом включил режим 1 – файл стал открываться в приложении, а не скачиваться. Некоторых это не устроило, т.к. иногда файл надо именно скачать, а не открыть, поэтому включил режим 2 – при клике на файле происходит скачивание, но справа от файла доп. кнопка для открытия. И только потом включил режим 3 – при клике на файле он открывается в приложении, а рядом с файлом кнопка для скачивания (см. картинку выше).

Помимо этого, в easla.com любой файловый атрибут может обладать шаблонами файлов. Пользователи могут выбрать шаблон на форме объекта и создать новый файл в атрибуте. Один из помощников ГИПа по поручению главного инженера разработал шаблоны исходящих писем с готовыми формулировками. Составлять текст письма стало проще.


Кстати, шаблон письма обладает специально созданными полями, в которые, с помощью макроса в Microsoft Word, вписываются нужные значения. Более того, они не просто вписываются, но еще и склоняются в нужном падеже. Таким образом, автор письма может не тратить время на нудное заполнение атрибутики письма, а сразу переходить к самому главному. Про интеграцию с Microsoft Word тоже можно написать отдельную статью.


При изменении атрибута, т.е. при загрузке файла, происходит выполнение скрипта, который обновляет имя файла:
cobjectref()->updateDocumentFileName();

Очень важно и полезно именовать файл документа в соответствии с заведенным правилом. Зачастую, при отсутствии такой технической возможности, пользователи или пишу галиматью, или пишут с ошибками, в итоге, когда файл оказывается вне системы, определить его назначение по имени не представляется возможным.
Обновлением имени файла занимается отдельная функция, объявленная в скрипте «До инициализации объекта»:
function calcOutgoingDesc()
{
    $code = calcOutgoingCode();
    
    if (empty($code))
        return;
        
    if (empty(cobjectref()->attributeref('crs_management_outgoing_sentdate')->value)) {
        $d = date_create(cobjectref()->attributeref('crs_management_outgoing_regdate')->value);
    } else {
        $d = date_create(cobjectref()->attributeref('crs_management_outgoing_sentdate')->value);
    }
    
    return sprintf(
        '%s от %s',
        $code,
        localeFormatDate($d)
    );
}

function updateDocumentFileName()
{
    $desc = calcOutgoingDesc();
    if (empty($desc))
        return;
        
    $files = cobjectref()->attributeref('crs_management_outgoing_document')->availableFiles();
    foreach ($files as $f)
    {
        $nowname = sprintf(
            '%s.%s', 
            $desc,
            pathinfo($f->nowname, PATHINFO_EXTENSION)
        );
        if (strcmp($nowname, $f->nowname) == 0)
            continue;
            
        $f->nowname = $nowname;
        $f->save();
    }
}

Вспомогательная функция calcOutgoingDesc применяется для вычисления описания объекта.

Приложения

А вот в этом атрибуте хранятся все приложения к сопроводительному письму. Количество прилагаемых файлов неограниченно, поэтому атрибут множественный. Исключительно для удобства в скрипте «При инициализации» настраивают колонки с информацией о файлах в зависимости от их количества:
if (cattributeref()->filesCount > 10)
    cattributeref()->fileInfo = array('revcode','modifytime','count','total','header','filter');
else
    cattributeref()->fileInfo = array('revcode','modifytime','count','total');

И все бы ничего, если б все приложения просто прикладывались и отправлялись по эл. почте.
Как я упоминал выше, в нашей организации используется система технического документооборота TDMS, в которой хранится вся проектно-сметная документация. Ее регулярно необходимо отправлять на согласование с сопроводительным письмом. Причем зачастую, файлов много, их надо упаковывать в архив и загружать в письмо. Когда я представил, как пользователь будет выгружать файлы из TDMS и загружать в easla.com, наверное, побледнел.
Процедура выглядит так:
  • Найти в TDMS нужный документ
  • Выгрузить из него файлы в оригинальном формате или pdf на локальный диск (файлов может быть много и в чертежах могут присутствовать внешние ссылки)
  • Найти их в том месте, куда выгрузили и упаковать в архив
  • Имя архива должно в точности соответствовать обозначению документа
  • Полученный архив закачать в письмо.

По скромным прикидкам, такая процедура займет у рядового пользователя в лучшем случае от 2 до 5 минут на один документ. Если к исходящему письму надо приложить 10 документов, то у пользователя на это уйдет уже час! Кошмар!
Но самое страшное, что о приложенных и отправленных таким образом файла ничего не знает TDMS, а надо, чтобы знал!
В общем, стало понятно, что файлы в easla.com надо перекладывать из TDMS только автоматическим способом. В результате, в TDMS была написана соответствующая команда, которая вызывается для каждого документа или пачки документов. После ее вызова появляется диалоговое окно, в котором нужно выбрать исходящий документ (список писем извлекается из easla.com с помощью SOAP), а затем происходит магия программирования и через несколько секунд готовый архив уже загружен в исходящее письмо!

На эту тему можно отдельную статью написать, если кому будет интересно. Решение узкоспециализированное, но опыт очень полезный!

Ссылка на приложения в другом месте

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

Текст письма

Недавно добавленный атрибут. Предназначен для написания текста письма, которое не предполагает наличие сопроводительного документа. Пока используется только для одного типа содержания – Transmittal.

Объект


Точно также, как и список входящих документов, список исходящих раскрашен в соответствии с их статусами. Достаточно было добавить код в скрипт «После инициализации объекта»:
switch (cobjectref()->status->code) 
{
    case 'crs_management_outgoing_created':
        cobjectref()->status->state = 1;
        break;
    case 'crs_management_outgoing_fax':
    case 'crs_management_outgoing_courier':
    case 'crs_management_outgoing_narochnym':
    case 'crs_management_outgoing_post':
        cobjectref()->status->state = 2;
        break;
    case 'crs_management_outgoing_email':
        cobjectref()->status->state = 4;
        break;
}

Согласитесь, цвет добавляет наглядности.


Окончательную валидацию атрибуты объекта проходят в скрипте «До сохранения объекта»:
cobjectref()->attributeref('crs_management_outgoing_regnum')->value = calcOutgoingCode();
cobjectref()->description = calcOutgoingDesc();
updateDocumentFileName();
if (!empty(cobjectref()->attributeref('crs_management_outgoing_cntnum')->value))
{
    if (cobjectref()->attributeref('crs_management_outgoing_contragent')->value == 45410 && 
        cobjectref()->attributeref('crs_management_outgoing_content')->value == 1192) {
        $conditions = array(
            'crs_management_outgoing_contragent'=>array('id',cobjectref()->attributeref('crs_management_outgoing_contragent')->value),
            'crs_management_outgoing_content'=>array('id',cobjectref()->attributeref('crs_management_outgoing_content')->value),
            'crs_management_outgoing_cntnum'=>cobjectref()->attributeref('crs_management_outgoing_cntnum')->value
        );
        
        if (!cobjectref()->isNewRecord)
            $conditions['id'] = '<>'.cobjectref()->id;
        
        $exist = selectCountAll('crs_management','crs_management_outgoing', $conditions);
        if ($exist) {
            $nownum = cobjectref()->attributeref('crs_management_outgoing_cntnum')->value;
            $freenum = updateDocumentCntNum();
            cobjectref()->attributeref('crs_management_outgoing_cntnum')->value = $freenum;
            cobjectref()->attributeref('crs_management_outgoing_regnum')->value = calcOutgoingCode();
            updateDocumentFileName();
            throw new Exception('Невозможно сохранить документ, т.к. существует другой документ с рег. номером '.$nownum.'!Номер был изменен на '.$freenum.', попробуйте сохранить еще раз.'); 
        }
    } else {
        $year = date_format(date_create(cobjectref()->attributeref('crs_management_outgoing_regdate')->value), 'Y');
        $conditions = array(
            'crs_management_outgoing_cntnum'=>cobjectref()->attributeref('crs_management_outgoing_cntnum')->value,
            'crs_management_outgoing_regdate'=>array('between', $year.'-01-01', $year.'-12-31')
        );
        
        if (!cobjectref()->isNewRecord)
            $conditions['id'] = '<>'.cobjectref()->id;
        $exist = selectCountAll('crs_management','crs_management_outgoing', $conditions);
        if ($exist) {
            $nownum = cobjectref()->attributeref('crs_management_outgoing_cntnum')->value;
            $freenum = updateDocumentCntNum();
            cobjectref()->attributeref('crs_management_outgoing_cntnum')->value = $freenum;
            cobjectref()->attributeref('crs_management_outgoing_regnum')->value = calcOutgoingCode();
            updateDocumentFileName();
            throw new Exception('Невозможно сохранить документ, т.к. существует другой документ с рег. номером '.$nownum.' созданный в '.$year.' году!Номер был изменен на '.$freenum.', попробуйте сохранить еще раз.'); 
        }
    }
}
if (cobjectref()->status->code == 'crs_management_outgoing_create') {
  cobjectref()->status = 'crs_management_outgoing_created';
  cobjectref()->flags = 1;
} else {
  cobjectref()->flags = 0;
}

Здесь же происходит смена статуса объекта.
В скрипте «После сохранения объекта» происходит назначение прав на сохраненный объект:
if (empty(cobjectref()->crs_management_outgoing_responsibleuser))
    return;
    
if (empty(cobjectref()->crs_management_outgoing_responsiblegroup))
    return;
    
$ruser = corganization()->user(cobjectref()->crs_management_outgoing_responsibleuser);
if (empty($ruser))
    return;
    
$rgroup = corganization()->group(cobjectref()->crs_management_outgoing_responsiblegroup);
if (empty($rgroup))
    return;
    
$rugroups = $ruser->groups();
foreach ($rugroups as &$rug)
    $rug = $rug['code'];
if ($rgroup->code == 'group_general_manager' && in_array($rgroup->code, $rugroups))
{
    $c = classificator(cobjectref()->attributeref('crs_management_outgoing_content')->value);
    //претензия
    if ($c->code == 'crs_content_claim') {
        cobjectref()->addRolesPermissions(array(
                'crs_management_tops',
                'crs_management_buh',
                'crs_management_gip',
                'crs_management_dp',
                'crs_management_pmo',
            ));
    } else {
        cobjectref()->addRolesPermissions(array(
                'crs_management_tops',
                'crs_management_dp',
            ));
    }
} elseif ($rgroup->code == 'group_pdg') {
    cobjectref()->addRolesPermissions(array(
        'crs_management_tops',
        // 'crs_management_buh',
        'crs_management_gip',
        'crs_management_dp',
        'crs_management_pmo',
    ));
} else 
    cobjectref()->resetRolesPermissions();


Формы


В easla.com существует возможность создавать формы в объекте. Причем на каждой форме могут располагаться свои атрибуты. Особенно удобно использовать формы, когда атрибутов много. Первый раз использовал формы в объектах «Контрагент» и «Контакт», чтобы отделить основные атрибуты от юридического и почтового адресов.
В исходящем письме создал две формы: Основные и Получатель. В первую форму поместил все атрибуты, кроме: регистрационный номер получателя, дата получения и фамилия и.о. получателя. Соответственно, их поместил во вторую форму. Она нужна только специалисту по делопроизводству, остальным будут ее посещать крайней редко.


Статусы


К счастью, у исходящего документа, как конечного результата работы, изобретать статусы долго не пришлось. Все просто и очевидно:
  • Регистрация – начальный статус
  • Зарегистрирован — присваивается сразу после регистрации

Остальные статусы говорят сами за себя:
  • Отправлен по факсу
  • Отправлен курьером
  • Отправлен нарочным
  • Отправлен по эл. почте
  • Отправлен почтой

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

Действия


Как было описано в предыдущей статье, действие – это кнопка под формой объекта или рядом с атрибутом, которая выполняет скрипт в контексте объекта. Сперва опишу простые действия, а самое «вкусненькое» и «здоровенькое» оставлю напоследок.

Оправлен […]

Действия, на самом деле не отправляющие исходящий документ, а только фиксирующие факт его выполнения специалистом по делопроизводству называются:
  • Отправлен факсом
  • Отправлен курьером
  • Отправлен нарочным
  • Отправлен почтой

Все действия имеют одинаковый скрипт, только отличаются кодом статуса:
if (empty(cobjectref()->attributeref('crs_management_outgoing_sentdate')->value)) {
    cobjectref()->attributeref('crs_management_outgoing_sentdate')->value = currentDateTime();
}
cobjectref()->status = 'crs_management_outgoing_courier';
$msg = cobjectref()->commentTasks();
if (!empty($msg))
    echo implode('',$msg);  

В этом скрипте достаточно заменить 'crs_management_outgoing_courier' на другой статус и получится другое действие.

Все доп. контакты

Сервисное действие, обновляет список значений атрибута «Доп. контакты» отображая в нем все доступные контакты. В атрибуте, после выбора контрагента, автоматически отображаются только контакты соответствующего контрагента. В большинстве случаев так и надо. В исключительно ситуации используется это действие. Скрипт простой:
$contacts = cobjectref()->prepareOutgoingContacts();
asort($contacts);
cobjectref()->attributeref('crs_management_outgoing_recipients')->values = $contacts;


Доп. контакты контрагента

Сервисное действие обратное выше описанному. После выполнения, в атрибуте отображаются только контакты указанного контрагента. Скрипт тоже простой:
if (!empty(cobjectref()->attributeref('crs_management_outgoing_contragent')->value))
{
    $contacts = cobjectref()->prepareOutgoingContacts(cobjectref()->attributeref('crs_management_outgoing_contragent')->value);
    cobjectref()->attributeref('crs_management_outgoing_recipients')->values = $contacts;
}

Обратите внимание, как изящно получилось решить формирование списка контактов с помощью объявленной в скрипте «До инициализации объекта» функции prepareOutgoingContacts. При отсутствии входного параметра – возвращает полный список контактов, при наличии идентификатора контрагента – возвращает отфильтрованный список. Очередное доказательство того, что «скриптованное» описание более гибкое, чем «накликиваемое».

Уведомить о доставке

Отправляет заинтересованным лицам уведомление о том, что письмо доставлено адресату и, главное, получен регистрационный номер получателя. Без заполненных специалистом по делопроизводству соответствующих полей, подтверждающих получение, уведомление отправлено не будет! Скрипт получился не очень сложный:
if (empty(cobjectref()->crs_management_outgoing_performers))
    throw new Exception('Не указаны исполнители письма. Уведомлять некого!');
    
if (empty(cobjectref()->crs_management_outgoing_contragentdate))
    throw new Exception('Не указана дата и время получения письма адресатом!');
if (cobjectref()->hasAttributeref('crs_management_outgoing_contragentperson') && empty(cobjectref()->crs_management_outgoing_contragentperson))
    throw new Exception('Не указаны фамилия сотрудника получившего письмо!');
cobjectref()->description = cobjectref()->calcOutgoingDesc();
$to = corganization()->users(cobjectref()->crs_management_outgoing_performers);
$body = array(
    'Добрый день!',
    ' ',
    'Письмо: '.cobjectref()->viewLink(),
    'Тема: '.cobjectref()->crs_management_outgoing_subj,
    'Доставлено адресату успешно.',
    'Получил: '.(cobjectref()->hasAttributeref('crs_management_outgoing_contragentperson') ? cobjectref()->crs_management_outgoing_contragentperson : ''),
    'Дата и время получения: '.cobjectref()->crs_management_outgoing_contragentdate,
    ' ',
    'С уважением,',
    cuser()->description
);
$options = array(
    'from'=>cuser(),
    'to'=>$to,
    'subj'=>'Уведомление о доставке '.cobjectref()->description,
    'body'=>implode('',$body), 
);
$options['bcc'] = cuser();
sendEmail($options);
$message = array();
foreach ($to as $u)
    $message[] = $u->viewLink();
echo 'Уведомление отправлено следующим сотрудникам:'.implode('', $message); 

//запись в историю
caction()->result = 'Уведомление отправлено: '.implode(',', $message);

Нравится, как изящно easla.com умеет отправлять письма. Достаточно вызвать функцию sendMail с переданными параметрами и все! Никаких сложностей с подготовкой тела письма, вложения файлов и других сложных манипуляций. Все просто!

Расшарить

Некоторые заказчики просят не присылать им на эл. почту письма с вложениями, а присылать только ссылки для скачивания файлов. Нормальные такие заказчики, все бы такие были, но «не все коту масленица». Именно для таких нормальных заказчиков в easla.com есть свой файлообменник или, как «айтишники» его называют, шара. Разместить на файлообменнике файлы можно на ограниченный срок, максимум полгода. После этого файлы удаляются и доступ к шаре уничтожается. Обратится к файлам можно по уникальной ссылке. Повторить ее случайно практически невозможно.
Иногда возникает необходимость расшарить переданные материалы повторно не отправляя письмо. Например, файл скачали и потеряли или по иным причинам хотят скачать, а он уже удален. Специалист по делопроизводству кликает на действие и выполняет скрипт:
$odt = date_create();
$cdt = date_add(date_create(), new DateInterval('P1D'));
$share = shareFiles(
    cobjectref(), 
    array('crs_management_outgoing_document','crs_management_outgoing_attachments'),
    $odt,
    $cdt,
    cuser(),
    'CP866'
);
if (empty($share))
    throw new Exception("Не удалось выложить архив в свободный доступ!");
    
echo 'Ссылка на архив файлов в свободном доступе: '.$share->link();

После выполнения появляется сообщение, из которого можно скопировать ссылку и отправить тому, кто просил.


В разработку

Сервисно-спасательное действие, доступно только специалисту по делопроизводству. Переводит письмо из конечного статуса, например «Отправлено по эл. почте» обратно в разработку. Бывает…
Это простейший пример действия, которое не проверяет конечный статус объекта, ради чего в нем снята соответствующая галочка. Скрипт только меняет статус:
cobjectref()->status = 'crs_management_outgoing_created';


Ознакомить

Создает ознакомительную задачу, в которой можно указать, кого ознакомить с указанным письмом. Но это относится скорее в процессу «Задачи» о котором надо писать отдельную статью. Скрипт создает объект в другом процессе:
$new_notification = new Objectref();
$new_notification->prepare(objectDef('tsk_management','tsk_notification'));
$new_notification->attributeref('tsk_notification_subj')->value = 'Прошу ознакомиться с '.cobjectref()->description;
$new_notification->attributeref('tsk_notification_base')->value = cobjectref()->id;
caction()->redirect = urlNewObjectref($new_notification);


Добавить задачу

Создает задачу со ссылкой на исходящий документ в основании для открытия. Но это тоже, скорее, про процесс «Задачи», хотя скрипт привести надо:
$subj = '';
if (!empty(cobjectref()->attributeref('crs_management_outgoing_subj')->value))
    $subj = $subj.(strlen($subj) > 0 ? ' ' : '').cobjectref()->attributeref('crs_management_outgoing_subj')->value;
$new_task = new Objectref();
$new_task->prepare(objectDef('tsk_management','tsk_task'));
$new_task->attributeref('tsk_task_subj')->value = $subj;
$new_task->attributeref('tsk_task_base_open')->value = cobjectref()->id;
caction()->redirect = urlNewObjectref($new_task);


Если вы дочитали до этого места, верю, что последнее описанное действие вас не разочарует. Это не просто действие! Это монстр, который делает всю работу за человека! Это шедевр!

Отправить по эл. почте

Самое главное действие в исходящем документе! Оно отправляет исходящий документ в соответствии с указанным правилом отправки. При этом со стороны пользователя ничего не нужно делать! Действие само упакует файлы, разобьет на тома, сформирует одно или несколько писем и отправит адресатам.
На моей памяти, когда только запускал процесс, как всегда в пятницу ГИП готовил к отправке 3 письма, в каждом было примерно по 200Мб прилагаемых файлов. В то время ГИПам еще не разрешили отправлять письма самостоятельно, т.к. они «спустя рукава» относились к их оформлению. К концу рабочего дня, разумеется, он не успел подготовить письма, а в пятницу всем срочно-срочно надо домой/по делам/в паб, в общем, специалист по делопроизводству убежала домой, а он позвонил мне и начал умолять:
— Задержись, надо будет письма отправить! Пожалуйста!
— Так зачем задерживаться? Система же онлайн!
— И что?
— Ты пришли мне на почту номера писем или гиперссылки на письма, я из дома или с телефона отправлю.
— А так можно?
— Никаких проблем!
На том и порешили. Наверное в 9 вечера мне пришло письмо от ГИПа со ссылками на письма. Открыл каждое. Проверил оформление. Нажал на «Отправить по эл. почте». Все три письма ушли заказчику минут за 5! Да в былые времена, специалист по делопроизводству готовила бы эти письма к отправке еще часа 3 минимум!
Конечно, скрипт получился немаленький, но простой и понятный любому PHP программисту:
Много-много кода для описания логики отправки исх. документа
if (empty(cobjectref()->attributeref('crs_management_outgoing_regnum')->value))
    throw new Exception('Невозможно отправить исходящее письмо без регистрационного номера!');
if (empty(cobjectref()->attributeref('crs_management_outgoing_subj')->value))
    throw new Exception('Невозможно отправить исходящее письмо без темы!');

function contactEmail($contact)
{
    if (empty($contact))
        return false;
        
    $emails = $contact->attributeref('crm_management_contact_email')->value;
    if (empty($emails))
        return false;
        
    return $emails[0];
}
function contragentEmail($contagent)
{
    if (empty($contagent))
        return false;
        
    $emails = $contagent->attributeref('crm_management_contragent_email')->value;
    if (empty($emails))
        return false;
        
    return $emails[0];
}
function getEmail($contact)
{
    $e = contactEmail($contact);
    
    if ($e === false)
    {
        if (empty($contact->attributeref('crm_management_contact_contragent')->value))
            throw new Exception("Не указан контрагент для контакта ".$contact->viewLink());
            
        $contragent = select($contact->attributeref('crm_management_contact_contragent')->value);
        $e = contragentEmail($contragent);
    }
    
    return $e === false ? false : array($e => $contact->description);
}
function getMainEmail($contact)
{
    $e = contactEmail($contact);
    if ($e === false)
        $email_1 = false;
    else
        $email_1 = array($e => $contact->description);
    
    $contragent = select($contact->attributeref('crm_management_contact_contragent')->value);
    $e = contragentEmail($contragent);
    if ($e === false)
        $email_2 = false;
    else
        $email_2 = array($e => $contragent->description);
    
    $emails = array();
    if ($email_1)
        $emails += $email_1;
    if ($email_2)
        $emails += $email_2;        
    unset($email_1, $email_2);
    return $emails === array() ? false : $emails;
}
function sendEmailDocumentOnly(&$msg, $objref, $to, $cc, $user, $checkSize = 5120000, $maxSize = '4500k', $extra_msg = '')
{
    $dfs = $objref->attributeref('crs_management_outgoing_document')->filesSize;
    $body = array(
        'Добрый день!',
        ' ',
        'Сообщение сформировано автоматически из системы .', easla.com
        'Во вложении исходящий документ '.$objref->description.'.',
        $extra_msg,
        'Пожалуйста, сообщите входящий номер полученного документа на Делопроизводитель@sngp.ru',
        ' ',
        'С уважением,',
        $user->description
    );
    
    if ($dfs < $checkSize)
        $files = array('compress'=>'zip', 'codepage'=>'CP866');
    else
        $files = array('compress'=>array('maxSize'=>$maxSize));
        
    $files['attributeCodes'] = 'crs_management_outgoing_document';
        
    $options = array(
        'from'=>$user,
        'to'=>$to,
        'subj'=>$objref->description.' '.$objref->attributeref('crs_management_outgoing_subj')->value,
        'body'=>implode('',$body), 
        'objects'=>$objref,
        'files'=>$files
    );
    
    if (!empty($cc))
        $options['cc'] = $cc;
    
    $options['bcc'] = $user;
    
    sendEmail($options);
    
    $rcvs = array();
    foreach ($to as $e=>$d)
        $rcvs[] = corganization()->object($d)->viewLink().' ('.$e.')';
        
    if (isset($cc))
        foreach ($cc as $e=>$d)
            $rcvs[] = corganization()->object($d)->viewLink().' ('.$e.')';
    
    $msg = 'Документ отправлен следующим адресатам: '.implode(',', $rcvs).' Общий размер файлов: '.$dfs;
}
function sendEmailDocumentAndAttachments(&$msg, $objref, $to, $cc, $user, $checkSize = 5120000, $maxSize = '4500k', $extra_msg = '')
{
    $dfs = $objref->attributeref('crs_management_outgoing_document')->filesSize;
    $afs = $objref->attributeref('crs_management_outgoing_attachments')->filesSize;
    $body = array(
        'Добрый день!',
        ' ',
        'Сообщение сформировано автоматически из системы .', easla.com
        'Во вложении исходящий документ '.$objref->description.($afs > 0 ? ' и приложения' : '').'.',
        $extra_msg,
        'Пожалуйста, сообщите входящий номер полученного документа на Делопроизводитель@sngp.ru',
        ' ',
        'С уважением,',
        $user->description
    );
    
    if ($dfs +$afs < $checkSize)
        $files = array('compress'=>'zip', 'codepage'=>'CP866');
    else
        $files = array('compress'=>array('maxSize'=>$maxSize));
        
    $options = array(
        'from'=>$user,
        'to'=>$to,
        'subj'=>$objref->description.' '.$objref->attributeref('crs_management_outgoing_subj')->value,
        'body'=>implode('',$body), 
        'objects'=>$objref,
        'files'=>$files
    );
    
    if (!empty($cc))
        $options['cc'] = $cc;
    
    $options['bcc'] = $user;
    
    sendEmail($options);
    
    $rcvs = array();
    foreach ($to as $e=>$d)
        $rcvs[] = corganization()->object($d)->viewLink().' ('.$e.')';
        
    if (isset($cc))
        foreach ($cc as $e=>$d)
            $rcvs[] = corganization()->object($d)->viewLink().' ('.$e.')';
    
    $msg = 'Документ и приложения отправлен следующим адресатам: '.implode(',', $rcvs).' Общий размер файлов: '.($dfs+$afs);
}
function sendShareDocumentAndAttachments(&$msg, $objref, $to, $cc, $user, $extra_msg = '')
{
    $odt = date_create();
    $cdt = date_add(date_create(), new DateInterval('P14D'));
    
    $share = shareFiles(
        cobjectref(), 
        array('crs_management_outgoing_document','crs_management_outgoing_attachments'),
        $odt,
        $cdt,
        $user,
        'CP866'
    );
    
    $body = array(
        'Добрый день!',
        ' ',
        'Сообщение сформировано автоматически из системы .', easla.com
        'Вы можете скачать письмо и приложения одним файлом по ссылке '.$share->link().'.',
        $extra_msg,
        'Пожалуйста, сообщите входящий номер полученного документа на Делопроизводитель@sngp.ru',
        ' ',
        'С уважением,',
        $user->description
    );
    $options = array(
        'from'=>$user,
        'to'=>$to,
        'subj'=>$objref->description.' '.$objref->attributeref('crs_management_outgoing_subj')->value,
        'body'=>implode('',$body), 
    );
    
    if (!empty($cc))
        $options['cc'] = $cc;
    
    $options['bcc'] = $user;
    
    sendEmail($options);
    
    $rcvs = array();
    foreach ($to as $e=>$d)
        $rcvs[] = corganization()->object($d)->viewLink().' ('.$e.')';
        
    if (isset($cc))
        foreach ($cc as $e=>$d)
            $rcvs[] = corganization()->object($d)->viewLink().' ('.$e.')';
    
    $msg = 'Ссылка на архив '.$share->link().' с документом и приложениями отправлен следующим адресатам: '.implode(',', $rcvs);
}
function shareAttachments(&$msg, $objref, $user)
{
    $odt = date_create();
    $cdt = date_add(date_create(), new DateInterval('P14D'));
    
    $share = shareFiles(
        cobjectref(), 
        array('crs_management_outgoing_attachments'),
        $odt,
        $cdt,
        $user,
        'CP866'
    );
    
    $msg = 'Вы можете скачать приложения одним файлом по ссылке '.$share->link();
}
function sendAsIsDocumentAndAttachments(&$msg, $objref, $to, $cc, $user, $maxSize = '4500k', $extra_msg = '')
{
    $dfs = $objref->attributeref('crs_management_outgoing_document')->filesSize;
    $afs = $objref->attributeref('crs_management_outgoing_attachments')->filesSize;
    
    $body = array(
        'Добрый день!',
        ' ',
        'Сообщение сформировано автоматически из системы .', easla.com
        'Во вложении исходящий документ '.$objref->description.($afs > 0 ? ' и приложения' : '').' общим объемом не более '. $maxSize.'.',
        $extra_msg,
        'Пожалуйста, сообщите входящий номер полученного документа на Делопроизводитель@sngp.ru',
        ' ',
        'С уважением,',
        $user->description
    );
    $options = array(
        'from'=>$user,
        'to'=>$to,
        'subj'=>$objref->description.' '.$objref->attributeref('crs_management_outgoing_subj')->value,
        'body'=>implode('',$body), 
        'objects'=>$objref,
        'files'=>array('maxSize'=>$maxSize)
    );
    
    if (!empty($cc))
        $options['cc'] = $cc;
    
    $options['bcc'] = $user;
    
    sendEmail($options);
    
    $rcvs = array();
    foreach ($to as $e=>$d)
        $rcvs[] = corganization()->object($d)->viewLink().' ('.$e.')';
        
    if (isset($cc))
        foreach ($cc as $e=>$d)
            $rcvs[] = corganization()->object($d)->viewLink().' ('.$e.')';
    
    $msg = 'Документ и приложения отправлен следующим адресатам: '.implode(',', $rcvs).' Общий размер файлов: '.($dfs+$afs);
}
function sendFtpSngpRuAttachments(&$msg, $objref, $to, $cc, $user, $login, $password)
{
    $ftp_addr = 'ftp.sngp.ru';
    $ftp_link = '';
    $dfs = $objref->attributeref('crs_management_outgoing_document')->filesSize;
    $afs = $objref->attributeref('crs_management_outgoing_attachments')->filesSize;
    
    if (cobjectref()->attributeref('crs_management_outgoing_attachments')->filesCount > 0) {
        $ftp = ftp_ssl_connect($ftp_addr);
        
        if ($ftp == FALSE)
            throw new Exception('Не удалось соединиться с FTPS сервером '.$ftp_addr.'!');
            
        ftp_login($ftp,$login,$password);
        ftp_pasv($ftp, true);
        $dir = "files";
        
        if (!@ftp_chdir($ftp, $dir)) {
            ftp_mkdir($ftp, $dir);
            ftp_chdir($ftp, $dir);
        }
        
        $zip = normalizeFilename($objref->description).' - приложения.zip';
        $objref->attributeref('crs_management_outgoing_attachments')->ftp_put($ftp, $zip, FTP_BINARY);
        ftp_close($ftp);
        
        $ftp_link = ''; '.$zip.'
    }
    
    $files = array(
        'attributeCodes'=>'crs_management_outgoing_document',
        'compress'=>'zip', 
        'codepage'=>'CP866'
    );
    $body = array(
        'Добрый день!',
        ' ',
        'Сообщение сформировано автоматически из системы .', easla.com
        'Во вложении исходящий документ '.$objref->description.' объемом '.formatSize($dfs).'.',
        ($ftp_link == '' ? '' : 'Приложения размещены на FTPS сервере ftps://'.$ftp_addr.' [логин: '.$login.'] файл '.$ftp_link.'.'),
        'Пожалуйста, сообщите входящий номер полученного документа на Делопроизводитель@sngp.ru',
        ' ',
        'С уважением,',
        $user->description
    );
    
    $options = array(
        'from'=>$user,
        'to'=>$to,
        'subj'=>$objref->description.' '.$objref->attributeref('crs_management_outgoing_subj')->value,
        'body'=>implode('',$body), 
        'objects'=>$objref,
        'files'=>$files
    );
    
    if (!empty($cc))
        $options['cc'] = $cc;
    
    $options['bcc'] = $user;
    
    sendEmail($options);
    
    $rcvs = array();
    foreach ($to as $e=>$d)
        $rcvs[] = corganization()->object($d)->viewLink().' ('.$e.')';
        
    if (isset($cc))
        foreach ($cc as $e=>$d)
            $rcvs[] = corganization()->object($d)->viewLink().' ('.$e.')';
    
    $msg = 'Документ отправлен следующим адресатам: '.implode(',', $rcvs).' Объем документа: '.($dfs).' Приложения размещены на '.$ftp_addr.' [логин: '.$login.'].';
}

if (empty(cobjectref()->attributeref('crs_management_outgoing_contragent')->value))
    throw new Exception('Не указан основной адресат (контрагент) в письме '.cobjectref()->viewLink());
if (empty(cobjectref()->attributeref('crs_management_outgoing_contact')->value))
    throw new Exception('Не указан основной адресат (контакт) в письме '.cobjectref()->viewLink());
//define $to
$contragent = select(cobjectref()->attributeref('crs_management_outgoing_contragent')->value);
$to = contragentEmail($contragent);
if ($to === false)
    throw new Exception('Не найден эл. адрес для '.$contragent->viewLink().' в письме '.cobjectref()->viewLink());
$to = array($to => $contragent->description);
//define $cc
$cc = array();
$contact = select(cobjectref()->attributeref('crs_management_outgoing_contact')->value);
$tmp = getEmail($contact);
if ($tmp === false)
    throw new Exception('Не найден эл. адрес для '.$contact->viewLink().' в письме '.cobjectref()->viewLink());
$cc += $tmp;
if (!empty(cobjectref()->attributeref('crs_management_outgoing_recipients')->value))
{
    $contacts = selects(cobjectref()->attributeref('crs_management_outgoing_recipients')->value);
    foreach ($contacts as $contact)
    {
        $tmp = getEmail($contact);
        if ($tmp === false)
            throw new Exception('Не найден эл. адрес для '.$contact->viewLink().' в письме '.cobjectref()->viewLink());
            
        $cc += $tmp;
    }
}
    
if (!empty(cobjectref()->attributeref('crs_management_outgoing_notifiers')->value))
{
    $contacts = selects(cobjectref()->attributeref('crs_management_outgoing_notifiers')->value);
    foreach ($contacts as $contact)
    {
        $tmp = getEmail($contact);
        if ($tmp === false)
            throw new Exception('Не найден эл. адрес для '.$contact->viewLink().' в письме '.cobjectref()->viewLink());
            
        $cc += $tmp;
    }
}
if (empty($cc))
    $cc = null;
$cuser = cuser();
$rule = '';
if (cobjectref()->hasAttributeref('crs_management_outgoing_rule'))
    $rule = classificator(cobjectref()->crs_management_outgoing_rule)->code;
$link = '';
if (cobjectref()->hasAttributeref('crs_management_outgoing_attachments_link')) {
    if (!empty(cobjectref()->attributeref('crs_management_outgoing_attachments_link')->value))
        $link = 'Вы можете скачать доп. приложения по .'; ссылке
}
    
$msg = '';
switch ($rule)
{
    case 'crs_outgoing_rule_all_max5':
        sendEmailDocumentAndAttachments($msg, cobjectref(), $to, $cc, $cuser, 5120000, '4500k', $link);
        break;
    case 'crs_outgoing_rule_all_max4':
        sendEmailDocumentAndAttachments($msg, cobjectref(), $to, $cc, $cuser, 4096000, '3500k', $link);
        break;
    case 'crs_outgoing_rule_all_max3':
        sendEmailDocumentAndAttachments($msg, cobjectref(), $to, $cc, $cuser, 3072000, '2500k', $link);
        break;   
    case 'crs_outgoing_rule_all_max10':
        sendEmailDocumentAndAttachments($msg, cobjectref(), $to, $cc, $cuser, 10240000, '9500k', $link);
        break; 
    case 'crs_outgoing_rule_all_max25':
        sendEmailDocumentAndAttachments($msg, cobjectref(), $to, $cc, $cuser, 25600000, '20m', $link);
        break;   
    case 'crs_outgoing_rule_doc_att_max5':
        if (empty($cc))
            sendEmailDocumentAndAttachments($msg, cobjectref(), $to, null, $cuser, 5120000, '4500k', $link);
        else {
            $m1 = '';
            $m2 = '';
            sendEmailDocumentOnly($m1, cobjectref(), $to, null, $cuser, 5120000, '4500k', $link);
            sendEmailDocumentAndAttachments($m2, cobjectref(), $cc, null, $cuser, 5120000, '4500k', $link);
            $msg = $m1.' '.$m2;
            unset($m1,$m2);
        }
        break;
    case 'crs_outgoing_rule_doc_att_max25':
        if (empty($cc))
            sendEmailDocumentAndAttachments($msg, cobjectref(), $to, null, $cuser, 25600000, '20m', $link);
        else {
            $m1 = '';
            $m2 = '';
            sendEmailDocumentOnly($m1, cobjectref(), $to, null, $cuser, 25600000, '20m', $link);
            sendEmailDocumentAndAttachments($m2, cobjectref(), $cc, null, $cuser, 25600000, '20m', $link);
            $msg = $m1.' '.$m2;
            unset($m1,$m2);
        }
        break;    
    case 'crs_outgoing_rule_all_share':
        sendShareDocumentAndAttachments($msg, cobjectref(), $to, $cc, $cuser, $link);
        break;
    case 'crs_outgoing_rule_doc_att_share':
        if (empty($cc)) {
            if (empty(cobjectref()->attributeref('crs_management_outgoing_attachments')->value))
                sendEmailDocumentOnly($msg, cobjectref(), $to, null, $cuser, 25600000, '20m', $link);
            else
                sendShareDocumentAndAttachments($msg, cobjectref(), $to, null, $cuser, $link);
        } else {
            $m1 = '';
            $m2 = '';
            sendEmailDocumentOnly($m1, cobjectref(), $to, null, $cuser, 25600000, '20m', $link);
            sendShareDocumentAndAttachments($m2, cobjectref(), $cc, null, $cuser, $link);
            $msg = $m1.' '.$m2;
            unset($m1,$m2);
        }
        break;
    case 'crs_outgoing_rule_doc_att_share_max1':
        $sum = cobjectref()->attributeref('crs_management_outgoing_document')->filesSize + cobjectref()->attributeref('crs_management_outgoing_attachments')->filesSize;
        if ($sum < 1048576) {
            sendAsIsDocumentAndAttachments($msg, cobjectref(), $to, $cc, $cuser, '1m', $link); 
        } else {
            $m1 = '';
            $m2 = '';
            shareAttachments($m2, cobjectref(), $cuser);
            sendEmailDocumentOnly($msg, cobjectref(), $to, $cc, $cuser, 25600000, '20m', $m2.''.$link); 
        }
        break;
    case 'crs_outgoing_rule_asis_max3':
        sendAsIsDocumentAndAttachments($msg, cobjectref(), $to, $cc, $cuser, '3m', $link); 
        break;
    case 'crs_outgoing_rule_asis_max5':
        sendAsIsDocumentAndAttachments($msg, cobjectref(), $to, $cc, $cuser, '5m', $link); 
        break;
    case 'crs_outgoing_rule_asis_max10':
        sendAsIsDocumentAndAttachments($msg, cobjectref(), $to, $cc, $cuser, '10m', $link);
        break;
    case 'crs_outgoing_rule_asis_max20':
        sendAsIsDocumentAndAttachments($msg, cobjectref(), $to, $cc, $cuser, '20m', $link);
        break;
    case 'crs_outgoing_rule_ftp_sngp_ru_tyungd':
        sendFtpSngpRuAttachments($msg, cobjectref(), $to, $cc, $cuser, 'некий логин', 'некий пароль');
        break;
    default:
        sendEmailDocumentAndAttachments($msg, cobjectref(), $to, $cc, $cuser, $link);
}
if (empty(cobjectref()->attributeref('crs_management_outgoing_sentdate')->value)) {
    cobjectref()->attributeref('crs_management_outgoing_sentdate')->value = currentDateTime();
    cobjectref()->description = cobjectref()->calcOutgoingDesc();
    cobjectref()->updateDocumentFileName();
}
cobjectref()->status = 'crs_management_outgoing_email';
$rcvs = array();
foreach ($to as $e=>$d)
    $rcvs[] = corganization()->object($d)->viewLink().' ('.$e.')';
    
if (isset($cc))
    foreach ($cc as $e=>$d)
        $rcvs[] = corganization()->object($d)->viewLink().' ('.$e.')';
        
//отправка уведомления
$notices = array();
$ruser = corganization()->user(cobjectref()->crs_management_outgoing_responsibleuser);
$rgroups = $ruser->groups();
if (empty($rgroups))
    $notices[] = $ruser;
else
{
    $f = false;
    foreach($rgroups as $rgroup)
        if (strncmp($rgroup['data_one'],'09.',3) == 0)
        {
            $notices = $rgroup->users();
            $f = true;
            break;
        }
        
    if (!$f)
        $notices[] = $ruser;
}   
    
if (!empty(cobjectref()->crs_management_outgoing_performers))
{
    $eusers = corganization()->users(cobjectref()->crs_management_outgoing_performers);
    if (!empty($eusers)) 
        $notices = array_merge($notices, $eusers);
}
$notices = array_unique($notices, SORT_REGULAR);
if (!empty(cobjectref()->crs_management_outgoing_contract)) {
    $contracts = selects(cobjectref()->crs_management_outgoing_contract);
    foreach($contracts as &$c) {
        $c = $c->viewLink();
    }
} else {
    $contracts = array();
}
foreach($notices as $n) {
    $rbody = array(
        'Уважаемый '.$n->description.'!',
        ' ',
        'Письмо: '.cobjectref()->viewLink(),
        'Тема: '.cobjectref()->crs_management_outgoing_subj,
        'Договор:'.implode(', ', $contracts),
        'Отправлено следующим адресатам:',
        implode('', $rcvs), 
        ' ',
        'С уважением,',
        cuser()->description    
    );
    
    sendEmail(array(
        // 'from'=>cuser(),
        'to'=>$n,
        'subj'=>'Уведомление об отправке '.cobjectref()->description,
        'body'=>implode('',$rbody), 
    ));
}
$task_msg = cobjectref()->commentTasks();
if (!empty($task_msg))
    $msg .= ''.implode('',$task_msg);  

    
echo $msg;
//запись в историю
caction()->result = $msg;


Очередной раз обращу внимание на функцию sendMail. Она умеет отправлять письма с файлами, хранящихся в атрибутах указанных объектов без сжатия, со сжатием в zip, со сжатием в многотомный 7zip и даже группируя по объему.
Все исполнители и отв. сотрудник указанные в исходящем документе получают вот такое уведомление после выполнения действия:


Роли


Распределение прав проще не придумаешь. Каждый может делать с исходящим документом все, что угодно, пока тот не отправлен, т.е. не переведен в конечный статус.


Пока все


Пока хватит и так много написал. Не осилить! Благодарю каждого, кто дочитал до конца, надеюсь, вы почерпнули для себя что-то полезное!
В следующей части расскажу про настройку видов. Писанины и кода будет меньше, но от этого не менее интересно. Все виды в easla.com тоже настраиваются скриптами, отсюда невероятная гибкость в настройке!
P.S. Повторюсь. Описанный выше объект «Исходящий документ» полностью реализован в опубликованном для всех процессе Переписка. Заимствуя процесс, вы можно не терять время на написание описанного выше кода.

Комментарии (5)


  1. Per_Ardua
    20.04.2016 11:38

    Большое спасибо. Много объема, за раз не осилил, разделил на два подхода. Но было очень интересно.


  1. Alxdhere
    20.04.2016 11:39

    Спасибо.
    Я тоже недоволен, что получился такой большой объем, но разделить статью на две тоже было бы неразумно. Все-таки описывается один объект. Как говорится, из песни слов не выкинешь.


    1. GarbageIntegrator
      20.04.2016 13:55

      Предлагаю попробовать полностью поместить код в выпадающие листы, так же, как в «Много-много кода для описания логики отправки исх. документа».


      1. Alxdhere
        20.04.2016 14:09

        Благодарю за рекомендацию.
        Честно, мелькала такая мысль, но настораживало, что вся статья будет в спойлерах — как-то некрасиво. Три строчки текста — спойлер, пять строчек текста — спойлер. Когда сам читал статьи с большим числом спойлеров, раздражался.


  1. GarbageIntegrator
    20.04.2016 16:52
    +1

    Я думаю, стоило бы упомянуть, что сама подготовка письма с материалами от нескольких отделов в одной конкретной проектной организации занимает теперь (в том числе и благодаря EASLA) несколько минут (чисто на подготовку к отправке). В противовес «от одного дня», как это было раньше. Экономится время на архивировании, на «расшаривании», на правильном заполнении шапки (база контрагентов — вещь!) и т.д… Это точно актуально для проектных организаций, но, думаю, и для других (например, гос.организации по работе с большим количеством физ.лиц) это тоже может иметь большое значение.

    Самое важное, что мы имеем — это то, что, благодаря предусмотренным в EASLA возможностям мы не только быстро подготавливаем письма, но и можем за 0..5 минут подготовить отчет, что и когда отправлено (и что зачастую важнее — не отправлено!), без привлечения к этому исполнителей.

    PS Да что там, сам исполнитель может теперь отследить, что он отправил, а что — нет!