Всем привет, сегодня я хочу описать работу над задачей которую мы сделали в компании ради избавления от рутинных операций.
Я начинающий разработчик в команде MarkOnlineStudio, и хочу рассказать о своем опыте работы.
Дело в том что наш руководитель - Марк использует Adesk для учета финансов туда он заносит расходы, доходы и другие операции. А также у нас в компании используется Kaiten как внутренняя канбан доска, в которой мы ведем учет выполнения задач.
Для того чтобы понимать сколько времени(а значит и денег) было потрачено на клиента, каждую операцию расхода Марк вручную «разбивал» и вносил туда сумму по формуле *формула* которую он считал при помощи таблицы в Excel.
Каждый месяц Марк садился за выполнение этой задачи для более прозрачного учета времени и средств. На выполнение у него уходило от 3 до 5-ти часов рабочего времени.
Чтобы избавиться от этой рутинной операции, мы разработали простую интеграцию, которая автоматизирует процесс. Алгоритм работы скрипта следующий:
Скрипт анализирует данные из Kaiten, включая количество потраченного времени на клиента в минутах.
-
Затем скрипт:
Проверяет имя контрагента.
Получает из Adesk все операции типа "расход" по контрагенту за текущий месяц, где в статье расходов содержится слово "Зарплата".
Скрипт суммирует все операции по данному контрагенту и общее количество потраченных минут.
Далее, он делит сумму операций на общее количество минут, получая стоимость одной минуты работы.
Это значение округляется до двух знаков после запятой.
На основе полученной стоимости минуты скрипт вычисляет сумму для каждой задачи.
Производится проверка, чтобы сумма всех задач соответствовала общей сумме операций.
Остаток заносится в значение "Касса"
Разбивка операции
Также мы изучили наш Kaiten и поняли что человеческий фактор оказывает большое влияние на формирование выгрузки, поскольку при отсутствии метки(или наличие более 1-й метки) на задаче работа скрипта выполняется неправильно. А так как метки в Kaiten и проекты в Adesk у нас должны быть одинаковые то ошибок быть не должно. Для исправления этого мы подготовили телеграмм бот который проверят наличие меток у задачи, и в случае ее отсутствия, или наличия более одной метки он отправит сообщение администратору о том что этот тикет сделан неправильно. Его алгоритм тоже довольно прост:
Телеграм бот получает от администратора запрос который состоит из года и месяца например 2023-09
Подключится по API к нашему Kaiten и сделать запрос на получение первых 100 карточек
Проверить наличие меток у карточек
В случае отсутствия метки оправить сообщение в бот с текстом Нет метки
В случае наличия более 1-й метки отправить сообщение в бот
Повторить 25 раз
После того как мы получим проблемные карточки, мы исправляем их, автоматизировать это действие нельзя, поскольку только сотрудник создавший эту карточку знает к какому проекту она относится.
Зная что выгрузка из kaiten больше не имеет проблем мы можем приступать к работе с приложением.
Нам нужно просто выбрать файл выгрузки и указать месяц и год для разбивки операций.
Ради автономности этой интеграции я добавил к нему простой UI сделанный при помощи customTkinter. Программа выглядит так:
После проверки на тестовом портале Adesk мы приступили к тестированию на реальных данных, и обнаружили что все работает отлично, за исключением некоторых ошибок в отсутствующих старых проектов. На выполнение разбивки операций за 1 месяц у нас ушло 10 минут вместо 4-х часов.
Таким образом Марк сэкономил ~ 3 часа и 50 минут рабочего времени в месяц с помощью очень простой интеграции.
Вот так выглядит код UI:
import customtkinter as ctk
import tkinter.filedialog
import tkinter as tk
import main # Импортируйте ваш основной скрипт здесь
class App(ctk.CTk):
WIDTH = 800
HEIGHT = 600
def __init__(self):
super().__init__()
self.title("Adesk Operation Splitter")
self.geometry(f"{self.WIDTH}x{self.HEIGHT}")
# Левая часть интерфейса
left_frame = ctk.CTkFrame(self)
left_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
self.label_file = ctk.CTkLabel(left_frame, text="Вас приветсвует Адеск Operation Splitter!\n\n\nВыберите файл CSV:")
self.label_file.pack(pady=10)
self.button_file = ctk.CTkButton(left_frame, text="Обзор...", command=self.open_file_dialog)
self.button_file.pack(pady=10)
self.entry_file = ctk.CTkEntry(left_frame)
self.entry_file.pack(pady=10)
self.label_year = ctk.CTkLabel(left_frame, text="Введите год:")
self.label_year.pack(pady=10)
self.entry_year = ctk.CTkEntry(left_frame)
self.entry_year.pack(pady=10)
self.label_month = ctk.CTkLabel(left_frame, text="Введите месяц:")
self.label_month.pack(pady=10)
self.entry_month = ctk.CTkEntry(left_frame)
self.entry_month.pack(pady=10)
self.run_button = ctk.CTkButton(left_frame, text="Запустить", command=self.run_script)
self.run_button.pack(pady=20)
# Правая часть интерфейса для вывода консоли
right_frame = ctk.CTkFrame(self)
right_frame.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True)
self.console_output = tk.Text(right_frame, height=20, width=100)
self.console_output.pack(pady=10, padx=10)
def open_file_dialog(self):
file_path = tkinter.filedialog.askopenfilename(filetypes=[("CSV files", "*.csv")])
self.entry_file.delete(0, ctk.END)
self.entry_file.insert(0, file_path)
def run_script(self):
print('Running script...')
file_path = self.entry_file.get()
year = self.entry_year.get()
month = self.entry_month.get()
# Здесь вам нужно будет модифицировать функцию main в main_script для логирования в виджет Text
main.main(file_path, year, month, self.console_output) # Обновите вызов функции main
if __name__ == "__main__":
app = App()
app.mainloop()
А вот так выглядит код интеграции:
import requests
import pandas as pd
from datetime import datetime
import json
import urllib.parse
import ui
def read_kaiten_data(file_path):
df = pd.read_csv(file_path, delimiter=';', encoding='utf-8')
return df
def get_adesk_operations(api_token):
api_url = f'https://api.adesk.ru/v1/transactions?api_token={api_token}'
response = requests.get(api_url)
response.raise_for_status()
return response.json()
def get_adesk_projects(api_token):
api_url = f'https://api.adesk.ru/v1/projects?api_token={api_token}'
response = requests.get(api_url)
response.raise_for_status()
return response.json().get('projects', [])
def check_project_exists(project_name, projects):
return any(project.get('name') == project_name for project in projects)
def create_project_in_adesk(name, api_token):
print('Creating project in Adesk...')
api_url = f'https://api.adesk.ru/v1/project?api_token={api_token}'
data = {'name': name}
headers = {'Content-Type': 'application/x-www-form-urlencoded'}
response = requests.post(api_url, data=data, headers=headers)
response.raise_for_status()
print(response.json())
return response.json() # Предполагаем, что API возвращает информацию о созданном проекте
def read_kaiten_data(file_path):
df = pd.read_csv(file_path, delimiter=';', encoding='utf-8')
return df
def validate_kaiten_data(df):
for index, row in df.iterrows():
if pd.isna(row['Метки']):
print(f"Найдена строка с пустым значением 'Проект': {row}")
confirmation = input("Введите 'Y' для продолжения: ")
while confirmation.lower() != 'y':
confirmation = input("Введите 'Y' для продолжения: ")
def update_adesk_operation(operation_id, parts, api_token):
print(operation_id, 'update_adesk_operation')
api_url = f'https://api.adesk.ru/v1/transaction/{operation_id}?api_token={api_token}'
headers = {'Content-Type': 'application/x-www-form-urlencoded'}
parts_json = json.dumps(parts)
data = {
'is_splitted': 'true',
'parts': parts_json
}
data_encoded = urllib.parse.urlencode(data)
response = requests.post(api_url, headers=headers, data=data_encoded)
return response
def main(kaiten_file_path, year, month, console_output):
print(kaiten_file_path, year, month, console_output)
api_token = “ВАШ_ТОКЕН_ТУТ”'
kaiten_data = read_kaiten_data(kaiten_file_path)
validate_kaiten_data(kaiten_data)
existing_projects = get_adesk_projects(api_token)
adesk_operations = get_adesk_operations(api_token)
selected_year = int(year)
selected_month = int(month)
kassa_project_id = next((p['id'] for p in existing_projects if p['name'] == "КАССА"), None)
for contractor_name, group in kaiten_data.groupby('Пользователь'):
total_time = group['Сумма (м)'].sum()
projects = group.groupby('Метки')['Сумма (м)'].sum()
for operation in adesk_operations['transactions']:
if operation['dateIso'].startswith(f'{selected_year}-{selected_month:02d}') and "Зарплата" in operation.get(
'category', {}).get('name', '') and operation.get('contractor', {}).get('name') == contractor_name:
print(contractor_name)
total_operation_amount = float(operation['amount'])
operation_date = operation['dateIso']
category_id = operation.get('category', {}).get('id')
contractor_id = operation.get('contractor', {}).get('id')
parts = []
for i, (project_name, project_time) in enumerate(projects.items()):
if project_name in ["Внутренняя задача", "Первый контакт"]:
project_id = kassa_project_id
else:
if not check_project_exists(project_name, existing_projects):
raise ValueError(f"Ошибка: Проект '{project_name}' не найден в Adesk.")
project_id = next((p['id'] for p in existing_projects if p['name'] == project_name), None)
project_cost = total_operation_amount * (project_time / total_time)
if i < len(projects) - 1:
project_cost = round(project_cost, 2)
else:
print(project_cost)
if project_id:
part = {
"project": project_id,
"contractor": contractor_id,
"category": category_id,
"amount": project_cost,
"related_date": operation['dateIso']
}
parts.append(part)
else:
raise ValueError(f"Ошибка: Проект '{project_name}' не найден в Adesk.")
if parts:
total_parts_sum = sum(part['amount'] for part in parts[:-1])
parts[-1]['amount'] = round(total_operation_amount - total_parts_sum, 2)
response = update_adesk_operation(operation['id'], parts, api_token)
console_output.insert(ui.tk.END,
f"Операция по \"{contractor_name}\" суммой \"{total_operation_amount}\" от \"{operation_date}\"\n")
console_output.see(ui.tk.END)
else:
print(f"Operation {operation['id']} has no parts to update.")
response = update_adesk_operation(operation['id'], parts, api_token)
print(f"Operation {operation['id']} response: {response.json()}")
else:
print(operation['dateIso'], selected_year, selected_month, (operation.get('category', {}).get('name', '')), (operation.get('contractor', {}).get('name')),contractor_name)
if __name__ == '__main__':
try:
main()
except ValueError as e:
print(e)
Технически можно более серьёзно углубится в автоматизацию данного процесса, но на промежуточных этапах все таки необходим контроль. Может бот упадет, может проект будет неверно записан, и как раз сейчас будет возможность оперативно устранить ошибки.