Предпосылки

ФНС в 2020 году утвердила концепцию перехода документооборота с контрагентами в электронный вид. В июле 2021 года обмен первичными документами и счетами-фактурами по закупке и продаже некоторых товаров уже стал безальтернативно электронным.

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

Операторы ЭДО предоставляют модули для интеграции с 1С, SAP и прочими популярными ERP. Коробочные инструменты не позволят добиться максимума автоматизации без затрат на их доработку. Операторы предлагают хорошую альтернативу - возможность интеграции по API, за сравнительно небольшие деньги.

Пример использования API

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

Описание ниже не претендует на оценку качества кода, соблюдения правил. Код просто работает. Назначение - демонстрация возможностей лицам, принимающим решения (бизнесу, руководству), тестирование. То есть для стадии "минимально жизнеспособный продукт" (MVP).

Инструменты: Python, PyCharm Community Edition, SQL Server Express Edition, SSMS, ключ разработчика API (купить/получить у оператора ЭДО), описание методов и структур данных API оператора ЭДО. Все бесплатное, кроме ключа API.

Общий алгоритм:

  1. Python: заходит в кабинет вашей организации (или нескольких в цикле), забирает все приглашения, помещает в таблицу на сервере

  2. SQL server: связывает таблицу приглашений с таблицей оборотов (можно добавить доп.фильтры - договоры, список ненадежных) по ключу ИНН, строит список ИНН с ненулевыми оборотами за последний период

  3. Python: забирает таблицу ИНН, заходит в кабинет ЭДО, принимает приглашения, заносит в таблицу на сервере записи о принятых приглашениях. Опционально (не описано в этой статье) - отправляет списки принятых приглашений ответственным сотрудникам.

  4. Планировщик задач Windows: запускает программу, выполняющую три пункта выше, регулярно

Создаем базу данных на сервере с таблицами:

CREATE TABLE [dbo].[tbl_organizations](
	[OrgGUID] [char](36) NULL,
	[OrgID] [char](36) NULL,
	[Inn] [char](12) NULL,
	[Kpp] [char](9) NULL,
	[Full_name] [nvarchar](max) NULL,
	[Short_name] [nvarchar](max) NULL,
	[BoxID] [varchar](100) NULL,
	[BoxGUID] [char](36) NULL,
	[Box_title] [nvarchar](max) NULL,
	[Invoice_format_ver] [char](20) NULL,
	[Ogrn] [char](15) NULL,
	[FNS_participant] [char](50) NULL
) ON [PRIMARY]
GO

CREATE TABLE [dbo].[tbl_departments](
	[DepartmentID] [char](36) NULL,
	[Parent_dept_ID] [char](36) NULL,
	[Dept_name] [nvarchar](max) NULL,
	[Dept_abbr] [nvarchar](50) NULL,
	[OrgID] [char](36) NULL
) ON [PRIMARY] TEXTIMAGE_ON [PRIMARY]
GO

CREATE TABLE [dbo].[tbl_invitations](
	[Short_name] [nvarchar](255) NULL,
	[OrgID_our] [char](36) NULL,
	[Inn] [char](12) NULL,
	[OrgID] [char](36) NULL
) ON [PRIMARY]
GO

CREATE TABLE [dbo].[tbl_invitations_accepted](
	[OrgId] [char](36) NULL,
	[Record_date] [date] NULL,
	[Inn] [char](12) NULL,
	[OrgId_our] [char](36) NULL
) ON [PRIMARY]
GO

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

Предполагается, что на сервере уже есть таблица или представление (view) с оборотами поставщиков по ключу ИНН или ИНН-КПП, или активными договорами. Если нет, её можно периодически заливать на сервер полуавтоматом из любой системы - вывод в эксель из 1С или SAP и загрузка утилитой bcp, импортом в SSMS или программой.

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

import pprint
import requests
import json
import pyodbc
import datetime
import sys

#заводим файл лога для перенаправления вывода в файл
f_name = r'C:\Temp\log_' + datetime.datetime.now().strftime('%Y-%m-%d_%H-%M-%S') + '.txt'
stdoutOrigin = sys.stdout
sys.stdout = open(f_name, "w")

try:
    conn = pyodbc.connect(r'Driver={SQL Server};
                          SERVER=……;Database=diadoc_test;
                          Trusted_Connection=yes')
    cursor = conn.cursor()
except:
    ctypes.windll.user32.MessageBoxW(0, 
                                     "Нет прав на работу с БД"
                                     , "Ошибка доступа", 1)
    print(datetime.datetime.now()
          .strftime('%Y-%m-%d_%H-%M-%S') + ' - не получилось соединиться с БД')
    sys.stdout.close()
    sys.stdout = stdoutOrigin
    exit()

log_pass = {'login': '……', 'password': '…'}
pass_json = json.dumps(log_pass)
# ниже вместо точек указываем полученный от оператора ключ
auth_header_key = "DiadocAuth ddauth_api_client_id=..." 
# авторизуемся для получения токена доступа
r = requests.request(method='POST'
                     , url='https://diadoc-api.kontur.ru/V3/Authenticate',
                     headers={"Authorization": auth_header,
                              "Content-Length": "1252",
                              "Connection": "Keep-Alive",
                              "Content-Type": "application/json"},
                     params={"type": "password"},
                     data=pass_json)
# добавляем полученный токен к заголовку авторизации
auth_header = auth_header_key + ",ddauth_token=" + r.text
print(datetime.datetime.now().__str__() + ' - авторизовался'

# обновляем реестр организаций и подразделений в базе данных
r1 = requests.request(method='GET'
                      , url='https://diadoc-api.kontur.ru/GetMyOrganizations'
                      ,headers={"Authorization": auth_header,
                              "Content-Length": "1252",
                              "Connection": "Keep-Alive",
                              "Content-Type": "application/json",
                              "Accept": "application/json"},
                     params={"type": "password"},
                     data=pass_json)
r1 = r1.json()
print(datetime.datetime.now().__str__() + ' - получил список организаций')

conn.cursor().execute('truncate table diadoc_test.dbo.tbl_organizations')
      .commit()
print(datetime.datetime.now().__str__() + ' - очистил таблицу организаций')

conn.cursor().execute('truncate table diadoc_test.dbo.tbl_departments').commit()
print(datetime.datetime.now().__str__() + ' - очистил таблицу подразделений')

for i in range(len(r1['Organizations'])):
    if not r1['Organizations'][i]['IsTest']:
        q_str = 'INSERT INTO diadoc_test.dbo.tbl_organizations VALUES(' + \
            "'" + r1['Organizations'][i]['OrgIdGuid'] + \
            "', '" + r1['Organizations'][i]['OrgId'] + "', '" + \
            r1['Organizations'][i]['Inn'] + "', '" + \
            r1['Organizations'][i]['Kpp'] + "', '" + \
            r1['Organizations'][i]['FullName'] + "', '" + \
            r1['Organizations'][i]['ShortName'] + "', '" + \
            r1['Organizations'][i]['Boxes'][0]['BoxId'] + "', '" + \
            r1['Organizations'][i]['Boxes'][0]['BoxIdGuid'] + "', '" + \
            r1['Organizations'][i]['Boxes'][0]['Title'] + "', '" + \
            r1['Organizations'][i]['Boxes'][0]['InvoiceFormatVersion'] + \
            "', '" + r1['Organizations'][i]['Ogrn'] + "', '" + \
            r1['Organizations'][i]['FnsParticipantId'] + "')"
        conn.cursor().execute(q_str).commit()
        print(datetime.datetime.now().__str__() + ' - ' + q_str)
    for j in range(len(r1['Organizations'][i]['Departments'])):
        if not r1['Organizations'][i]['Departments'][j]['IsDisabled']:
            buff = r1['Organizations'][i]['Departments'][j]
            q_str = 'INSERT INTO diadoc_test.dbo.tbl_departments VALUES(' + \
                "'" + buff['DepartmentId'] + "', '" + \
                buff['ParentDepartmentId'] + "', '" + \
                buff['Name'] + "', '" + buff['Abbreviation'] + "', '" + \
                r1['Organizations'][i]['OrgId'] + "')"
            conn.cursor().execute(q_str).commit()
            print(datetime.datetime.now().__str__() + ' - ' + q_str)

# обновляем реестр приглашений
conn.cursor().execute('truncate table diadoc_test.dbo.tbl_invitations').commit()
print(datetime.datetime.now().__str__() + ' - очистил таблицу приглашений')

orgs = conn.cursor().execute('select distinct OrgId from dbo.tbl_organizations').fetchall()
orgs_list = list(orgs[x][0] for x in range(len(orgs)))
print(datetime.datetime.now().__str__() 
      + ' - построил список ЮЛ, записей - ' + str(len(orgs_list)))

for org in orgs_list:
    after_index = -1
    counter = 0
    eol = False
    while not eol:
        print(org)
        r1 = requests.request(method='GET'
                        , url='https://diadoc-api.kontur.ru/V2/GetCounteragents',
                        headers={"Authorization": auth_header,
                                 "Content-Length": "1252",
                                 "Connection": "Keep-Alive",
                                 "Content-Type": "application/json",
                                 "Accept": "application/json"},
                        params={"myOrgId": org,
                                "afterIndexKey": after_index,
                                "counteragentStatus": "InvitesMe"},
                        data=pass_json)
        if r1.text[:8] != 'Доступ з':
            r1 = r1.json()
            for i in range(len(r1['Counteragents'])):
                buff = r1['Counteragents'][i]['Organization']
                q_str = 'INSERT INTO diadoc_test.dbo.tbl_invitations ' + \
                        'VALUES(' + "'" + buff['ShortName'] + "', '" + \
                        org + "', '" + buff['Inn'] + "', '" + \
                        buff['OrgId'] + "')"
                conn.cursor().execute(q_str).commit()
                print(datetime.datetime.now().__str__() + ' - ' + q_str)
            if len(r1['Counteragents']) < 99:
                eol = True
            else:
                # максимум 100 записей, запоминаем индекс, передаем в сл. запрос
                after_index = 
                    r1['Counteragents'][len(r1['Counteragents'])-1]['IndexKey']
        else:
            print(datetime.datetime.now().__str__() + ' - ' +
                  org + ' - ' + r1.text)
            eol = True

# забираем с сервера список ИНН, OrgID, очищенный от неизвестных лиц,
# и принимаем приглашения

a = conn.cursor().execute(
  'select distinct OrId_our, OrgID, ИНН from dbo.view_Приглашения_от_моих_КА'
   ).fetchall()
ka_list = list([a[x][0], a[x][1], a[x][2]] for x in range(len(a)))

print(datetime.datetime.now().__str__() + 
      ' - построил список поставщиков по ЮЛ, записей - ' + str(len(ka_list)))

for i in range(len(ka_list)):
    r1 = requests.request(method='POST'
                          , url='https://diadoc-api.kontur.ru/V2/AcquireCounteragent'
                          , headers={"Authorization": auth_header,
                                   "Content-Length": "1252",
                                   "Connection": "Keep-Alive",
                                   "Content-Type": "application/json",
                                   "Accept": "application/json"},
                          params={"myOrgId": ka_list[i][0]},
                          data=json.dumps({"OrgId": ka_list[i][1]}))
    #если приглашение требует подписи - оно не примется, просто заносим в лог
    if r1.text[0:6] == 'Cannot':
        print(r1.text)
    else:
    #если приглашение "простое" - оно принято, вносим в таблицу на сервере
        conn.cursor().execute('INSERT INTO dbo.tbl_invitations_accepted' +
                              'VALUES (' + "'" + ka_list[i][1] + "', '" +
                              datetime.datetime.now().strftime('%Y-%m-%d') +
                              "', '" + ka_list[i][2] + "', '" + 
                              ka_list[i][1] + "')").commit()
        print(datetime.datetime.now().__str__() + 
              ' - принято приглашение от ' + ka_list[i][2])
print(datetime.datetime.now().__str__() + ' - задача завершена')

#закрываем файл лога
sys.stdout.close()
sys.stdout = stdoutOrigin

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

Результат:

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

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


  1. Andy_U
    13.11.2021 13:13

    SQL injection не боитесь?


    1. ArtyomMorozov1 Автор
      13.11.2021 13:19

      Пока нет )


      1. Andy_U
        13.11.2021 13:58

        Но почему сразу нормально не написать?


        1. ArtyomMorozov1 Автор
          13.11.2021 14:01

          Какая из инструкций SQL или строк Python потенциально опасны на ваш взгляд?


          1. Andy_U
            13.11.2021 14:28

            Все INSERT'ы. Читайте, например, это: https://blog.sqreen.com/preventing-sql-injections-in-python/


            1. ArtyomMorozov1 Автор
              13.11.2021 18:12

              Я не ожидаю от Диадока никаких неожиданных значений на входе. Даже если случится чудо и ящик ЭДО будет содержать миллион приглашений - они будут помещены в БД, потому что их всё равно надо отработать. Обработка рисков имеет смысл, если риски есть. А если нет, то их обработка - пустая трата времени. Как я уже упомянул в тексте статьи, код написан в демонстрационных целях работы методов и при переносе в прод потребует рефакторинга и обработки действительно рисковых вещей от вывода лога нормальным способом (а не переносом в файл стандартного вывода) до блоков try-except и лимитирования числа операций в единицу времени.


              1. Andy_U
                13.11.2021 19:15

                Я не ожидаю от Диадока никаких неожиданных значений на входе.

                Ну, надейтесь, что в пришедшем json никаких бяк не будет, типа такой: https://hackaday.com/wp-content/uploads/2014/04/18mpenleoksq8jpg.jpg?w=636.


                1. ArtyomMorozov1 Автор
                  13.11.2021 20:05

                  только если ФНС зарегистрирует такое имя контрагента (со знаком окончания SQL-инструкции), а Диадок поддержит :) но всё равно спасибо за внимание к коду.


                  1. Andy_U
                    13.11.2021 20:10

                    а Диадок поддержит

                    или его поломают...


  1. itsoft
    13.11.2021 16:00

    Все бесплатное, кроме ключа API.

    Какая сейчас ситуация с получением ключа? Несколько лет назад меня там послали грубо говоря. Как я понял, если бы мы делали софт типа 1С, то они бы дали доступ к API, а чисто для одного решения на коленках для интеграции только нашей ERP мы им не интересны были.


    1. ArtyomMorozov1 Автор
      13.11.2021 18:04

      Сейчас никаких проблем, он есть в публичном прайсе