Предпосылки
ФНС в 2020 году утвердила концепцию перехода документооборота с контрагентами в электронный вид. В июле 2021 года обмен первичными документами и счетами-фактурами по закупке и продаже некоторых товаров уже стал безальтернативно электронным.
В компаниях с большим количеством операций, исполнителей, клиентов, поставщиков, филиалов, возникнет потребность в администрировании и настройке ЭДО: разделение доступа и управление им, справочниками сотрудников, контрагентами. В дальнейшем придется контролировать форматы документов (они меняются) и сам поток документов, их подписание. А также, использовать преимущества ЭДО: возможность разобрать документ и сверить с оплатами, заявками, договорами; заполнить реквизиты бухгалтерских проводок; автоматизировать отклонение документов при несоответствии, автоматически подписать при полном соответствии и так далее.
Операторы ЭДО предоставляют модули для интеграции с 1С, SAP и прочими популярными ERP. Коробочные инструменты не позволят добиться максимума автоматизации без затрат на их доработку. Операторы предлагают хорошую альтернативу - возможность интеграции по API, за сравнительно небольшие деньги.
Пример использования API
Для возможности отправить и принять документы контрагенты должны обменяться приглашениями. Приглашение можно отправить любому юр.лицу по ключу ИНН-КПП, поэтому они могут поступать ежедневно от любых юридических лиц. Приглашение, которое не содержит доп.соглашения или договора об ЭДО между лицами, не порождает никаких правовых обязательств у сторон, поэтому его можно смело принимать. Но желательно ограничить список теми, с кем есть нерасторгнутый договор и активно ведутся сделки, и поставщик не в списке ненадежных.
Описание ниже не претендует на оценку качества кода, соблюдения правил. Код просто работает. Назначение - демонстрация возможностей лицам, принимающим решения (бизнесу, руководству), тестирование. То есть для стадии "минимально жизнеспособный продукт" (MVP).
Инструменты: Python, PyCharm Community Edition, SQL Server Express Edition, SSMS, ключ разработчика API (купить/получить у оператора ЭДО), описание методов и структур данных API оператора ЭДО. Все бесплатное, кроме ключа API.
Общий алгоритм:
Python: заходит в кабинет вашей организации (или нескольких в цикле), забирает все приглашения, помещает в таблицу на сервере
SQL server: связывает таблицу приглашений с таблицей оборотов (можно добавить доп.фильтры - договоры, список ненадежных) по ключу ИНН, строит список ИНН с ненулевыми оборотами за последний период
Python: забирает таблицу ИНН, заходит в кабинет ЭДО, принимает приглашения, заносит в таблицу на сервере записи о принятых приглашениях. Опционально (не описано в этой статье) - отправляет списки принятых приглашений ответственным сотрудникам.
Планировщик задач 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-файл, на вкладке "Триггеры" добавляем периоды срабатывания по расписанию.
Результат:

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