Предпосылки
ФНС в 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-файл, на вкладке "Триггеры" добавляем периоды срабатывания по расписанию.
Результат:
Опционально можно добавить отправку писем ответственным за сделки с контрагентами, которая отправит сообщение о приеме приглашений и о наличии приглашений, требующих подписания.
Комментарии (11)
itsoft
13.11.2021 16:00Все бесплатное, кроме ключа API.
Какая сейчас ситуация с получением ключа? Несколько лет назад меня там послали грубо говоря. Как я понял, если бы мы делали софт типа 1С, то они бы дали доступ к API, а чисто для одного решения на коленках для интеграции только нашей ERP мы им не интересны были.
Andy_U
SQL injection не боитесь?
ArtyomMorozov1 Автор
Пока нет )
Andy_U
Но почему сразу нормально не написать?
ArtyomMorozov1 Автор
Какая из инструкций SQL или строк Python потенциально опасны на ваш взгляд?
Andy_U
Все INSERT'ы. Читайте, например, это: https://blog.sqreen.com/preventing-sql-injections-in-python/
ArtyomMorozov1 Автор
Я не ожидаю от Диадока никаких неожиданных значений на входе. Даже если случится чудо и ящик ЭДО будет содержать миллион приглашений - они будут помещены в БД, потому что их всё равно надо отработать. Обработка рисков имеет смысл, если риски есть. А если нет, то их обработка - пустая трата времени. Как я уже упомянул в тексте статьи, код написан в демонстрационных целях работы методов и при переносе в прод потребует рефакторинга и обработки действительно рисковых вещей от вывода лога нормальным способом (а не переносом в файл стандартного вывода) до блоков try-except и лимитирования числа операций в единицу времени.
Andy_U
Ну, надейтесь, что в пришедшем json никаких бяк не будет, типа такой: https://hackaday.com/wp-content/uploads/2014/04/18mpenleoksq8jpg.jpg?w=636.
ArtyomMorozov1 Автор
только если ФНС зарегистрирует такое имя контрагента (со знаком окончания SQL-инструкции), а Диадок поддержит :) но всё равно спасибо за внимание к коду.
Andy_U
или его поломают...