Всем привет, сегодня я хочу описать работу над задачей которую мы сделали в компании ради избавления от рутинных операций.
Я начинающий разработчик в команде 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!nnnВыберите файл 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)
Технически можно более серьёзно углубится в автоматизацию данного процесса, но на промежуточных этапах все таки необходим контроль. Может бот упадет, может проект будет неверно записан, и как раз сейчас будет возможность оперативно устранить ошибки.