mirror of
https://github.com/koloideal/DutyLog.git
synced 2026-06-10 10:25:29 +03:00
update
This commit is contained in:
-251
@@ -1,251 +0,0 @@
|
|||||||
import argparse
|
|
||||||
import asyncio
|
|
||||||
import pandas as pd
|
|
||||||
import asyncpg
|
|
||||||
import re
|
|
||||||
import os
|
|
||||||
|
|
||||||
|
|
||||||
def clean_name(name: str) -> str:
|
|
||||||
if not isinstance(name, str):
|
|
||||||
return ""
|
|
||||||
name = re.sub(r'\s+[СC]$', '', name.strip())
|
|
||||||
name = re.sub(r'\s*\d+\s*$', '', name)
|
|
||||||
return name.strip()
|
|
||||||
|
|
||||||
|
|
||||||
def parse_excel_raw(file_path: str):
|
|
||||||
excel_file = pd.ExcelFile(file_path)
|
|
||||||
resident_hours = {}
|
|
||||||
room_hours = {}
|
|
||||||
|
|
||||||
for sheet_name in excel_file.sheet_names:
|
|
||||||
df = pd.read_excel(file_path, sheet_name=sheet_name)
|
|
||||||
|
|
||||||
# --- Парсинг жильцов ---
|
|
||||||
if 'ФИО' in df.columns and 'Часы' in df.columns:
|
|
||||||
for _, row in df.iterrows():
|
|
||||||
fio = clean_name(str(row['ФИО']))
|
|
||||||
if not fio or str(fio).lower() == 'nan':
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
hours_raw = str(row['Часы']).split('(')[0].strip()
|
|
||||||
amount = int(float(hours_raw))
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if fio in resident_hours:
|
|
||||||
resident_hours[fio] += amount
|
|
||||||
else:
|
|
||||||
resident_hours[fio] = amount
|
|
||||||
|
|
||||||
# --- Парсинг комнат ---
|
|
||||||
elif df.iloc[0].astype(str).str.contains('Комната', case=False, na=False).any():
|
|
||||||
header_idx = df[df.apply(lambda r: r.astype(str).str.contains('Комната', case=False).any(), axis=1)].index[0]
|
|
||||||
df.columns = df.iloc[header_idx]
|
|
||||||
df = df.iloc[header_idx + 1:].reset_index(drop=True)
|
|
||||||
|
|
||||||
for _, row in df.iterrows():
|
|
||||||
room_str = str(row['Комната'])
|
|
||||||
if not room_str or room_str.lower() == 'nan':
|
|
||||||
continue
|
|
||||||
|
|
||||||
room_match = re.search(r'\b(\d{3,4})\b', room_str)
|
|
||||||
if not room_match:
|
|
||||||
continue
|
|
||||||
|
|
||||||
room_number = int(room_match.group(1))
|
|
||||||
|
|
||||||
try:
|
|
||||||
punishment_raw = str(row['Наказание']).split('(')[0].replace('часов', '').replace(',', ';').strip()
|
|
||||||
amounts = [int(x.strip()) for x in re.findall(r'\d+', punishment_raw)]
|
|
||||||
amount = sum(amounts) if amounts else 0
|
|
||||||
except (ValueError, AttributeError):
|
|
||||||
continue
|
|
||||||
|
|
||||||
if amount <= 0:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if room_number in room_hours:
|
|
||||||
room_hours[room_number] += amount
|
|
||||||
else:
|
|
||||||
room_hours[room_number] = amount
|
|
||||||
|
|
||||||
return resident_hours, room_hours
|
|
||||||
|
|
||||||
|
|
||||||
async def run_parse(database_url: str, file_path: str):
|
|
||||||
raw_resident_hours, raw_room_hours = parse_excel_raw(file_path)
|
|
||||||
|
|
||||||
async with asyncpg.create_pool(database_url) as pool:
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
resident_rows = await conn.fetch("SELECT real_name FROM residents WHERE real_name IS NOT NULL;")
|
|
||||||
db_names = [row['real_name'] for row in resident_rows]
|
|
||||||
|
|
||||||
room_rows = await conn.fetch("SELECT number FROM rooms;")
|
|
||||||
db_rooms = {row['number'] for row in room_rows}
|
|
||||||
|
|
||||||
# --- Сопоставление жильцов ---
|
|
||||||
db_names_lower = {name.lower(): name for name in db_names}
|
|
||||||
matched_residents = {}
|
|
||||||
unmatched_residents = {}
|
|
||||||
|
|
||||||
for raw_fio, amount in raw_resident_hours.items():
|
|
||||||
fio_lower = raw_fio.lower()
|
|
||||||
matched_name = None
|
|
||||||
|
|
||||||
if fio_lower in db_names_lower:
|
|
||||||
matched_name = db_names_lower[fio_lower]
|
|
||||||
else:
|
|
||||||
parts = fio_lower.split()
|
|
||||||
if len(parts) >= 2:
|
|
||||||
search_key = f"{parts[0]} {parts[1]}"
|
|
||||||
if search_key in db_names_lower:
|
|
||||||
matched_name = db_names_lower[search_key]
|
|
||||||
|
|
||||||
if not matched_name:
|
|
||||||
for db_lower, db_orig in db_names_lower.items():
|
|
||||||
if fio_lower in db_lower or db_lower in fio_lower:
|
|
||||||
matched_name = db_orig
|
|
||||||
break
|
|
||||||
|
|
||||||
if matched_name:
|
|
||||||
matched_residents[matched_name] = matched_residents.get(matched_name, 0) + amount
|
|
||||||
else:
|
|
||||||
unmatched_residents[raw_fio] = unmatched_residents.get(raw_fio, 0) + amount
|
|
||||||
|
|
||||||
# --- Сопоставление комнат ---
|
|
||||||
matched_rooms = {}
|
|
||||||
unmatched_rooms = {}
|
|
||||||
|
|
||||||
for room_num, amount in raw_room_hours.items():
|
|
||||||
if room_num in db_rooms:
|
|
||||||
matched_rooms[room_num] = matched_rooms.get(room_num, 0) + amount
|
|
||||||
else:
|
|
||||||
unmatched_rooms[room_num] = unmatched_rooms.get(room_num, 0) + amount
|
|
||||||
|
|
||||||
# --- Запись в файлы ---
|
|
||||||
'''with open("parsed_residents.txt", "w", encoding="utf-8") as f:
|
|
||||||
for name, amount in sorted(matched_residents.items()):
|
|
||||||
f.write(f"{name} - {amount}\n")'''
|
|
||||||
|
|
||||||
with open("parsed_rooms.txt", "w", encoding="utf-8") as f:
|
|
||||||
for room, amount in sorted(matched_rooms.items()):
|
|
||||||
f.write(f"{room} - {amount}\n")
|
|
||||||
|
|
||||||
'''if unmatched_residents:
|
|
||||||
with open("unmatched_residents.txt", "w", encoding="utf-8") as f:
|
|
||||||
for name, amount in sorted(unmatched_residents.items()):
|
|
||||||
f.write(f"{name} - {amount}\n")'''
|
|
||||||
|
|
||||||
if unmatched_rooms:
|
|
||||||
with open("unmatched_rooms.txt", "w", encoding="utf-8") as f:
|
|
||||||
for room, amount in sorted(unmatched_rooms.items()):
|
|
||||||
f.write(f"{room} - {amount}\n")
|
|
||||||
|
|
||||||
|
|
||||||
async def run_load(database_url: str, admin_id: int):
|
|
||||||
if not os.path.exists("parsed_residents.txt") or not os.path.exists("parsed_rooms.txt"):
|
|
||||||
raise FileNotFoundError("Файлы parsed_residents.txt или parsed_rooms.txt не найдены. Сначала запустите parse.")
|
|
||||||
|
|
||||||
resident_transactions = []
|
|
||||||
with open("parsed_residents.txt", "r", encoding="utf-8") as f:
|
|
||||||
for line in f:
|
|
||||||
line = line.strip()
|
|
||||||
if not line:
|
|
||||||
continue
|
|
||||||
parts = line.split(" - ")
|
|
||||||
if len(parts) == 2:
|
|
||||||
resident_transactions.append((parts[0].strip(), int(parts[1].strip())))
|
|
||||||
|
|
||||||
room_transactions = []
|
|
||||||
with open("parsed_rooms.txt", "r", encoding="utf-8") as f:
|
|
||||||
for line in f:
|
|
||||||
line = line.strip()
|
|
||||||
if not line:
|
|
||||||
continue
|
|
||||||
parts = line.split(" - ")
|
|
||||||
if len(parts) == 2:
|
|
||||||
room_transactions.append((int(parts[0].strip()), int(parts[1].strip())))
|
|
||||||
|
|
||||||
async with asyncpg.create_pool(database_url) as pool:
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
# Маппинги
|
|
||||||
res_rows = await conn.fetch("SELECT id, real_name FROM residents WHERE real_name IS NOT NULL;")
|
|
||||||
name_to_id = {row['real_name']: row['id'] for row in res_rows}
|
|
||||||
|
|
||||||
rm_rows = await conn.fetch("SELECT id, number FROM rooms;")
|
|
||||||
num_to_id = {row['number']: row['id'] for row in rm_rows}
|
|
||||||
|
|
||||||
res_tx_params = []
|
|
||||||
res_update_params = []
|
|
||||||
for name, amount in resident_transactions:
|
|
||||||
res_id = name_to_id.get(name)
|
|
||||||
if res_id:
|
|
||||||
res_tx_params.append((res_id, "increase", amount, admin_id, "Инициализация существующих часов"))
|
|
||||||
res_update_params.append((amount, res_id))
|
|
||||||
|
|
||||||
rm_tx_params = []
|
|
||||||
rm_update_params = []
|
|
||||||
for room_num, amount in room_transactions:
|
|
||||||
rm_id = num_to_id.get(room_num)
|
|
||||||
if rm_id:
|
|
||||||
rm_tx_params.append((rm_id, "increase", amount, admin_id, "Инициализация существующих часов"))
|
|
||||||
rm_update_params.append((amount, rm_id))
|
|
||||||
|
|
||||||
async with conn.transaction():
|
|
||||||
# Транзакции резидентов
|
|
||||||
if res_tx_params:
|
|
||||||
await conn.executemany(
|
|
||||||
"""
|
|
||||||
INSERT INTO hours_transactions
|
|
||||||
(resident_id, transaction_type, amount, admin_id, remark, created_at)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP);
|
|
||||||
""", res_tx_params
|
|
||||||
)
|
|
||||||
# Обновление баланса резидентов
|
|
||||||
await conn.executemany(
|
|
||||||
"""
|
|
||||||
UPDATE residents
|
|
||||||
SET active_hours = active_hours + $1, updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = $2;
|
|
||||||
""", res_update_params
|
|
||||||
)
|
|
||||||
|
|
||||||
# Транзакции комнат
|
|
||||||
if rm_tx_params:
|
|
||||||
await conn.executemany(
|
|
||||||
"""
|
|
||||||
INSERT INTO room_hours_transactions
|
|
||||||
(room_id, transaction_type, amount, admin_id, remark, created_at)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP);
|
|
||||||
""", rm_tx_params
|
|
||||||
)
|
|
||||||
# Обновление баланса комнат
|
|
||||||
await conn.executemany(
|
|
||||||
"""
|
|
||||||
UPDATE rooms
|
|
||||||
SET active_hours = active_hours + $1
|
|
||||||
WHERE id = $2;
|
|
||||||
""", rm_update_params
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def main():
|
|
||||||
parser = argparse.ArgumentParser()
|
|
||||||
parser.add_argument("command", choices=["parse", "load"])
|
|
||||||
parser.add_argument("--db-url", required=True)
|
|
||||||
parser.add_argument("--file", required=False)
|
|
||||||
parser.add_argument("--admin-id", type=int, default=2047958833)
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
if args.command == "parse":
|
|
||||||
if not args.file:
|
|
||||||
parser.error("Для команды parse требуется указать --file")
|
|
||||||
await run_parse(args.db_url, args.file)
|
|
||||||
elif args.command == "load":
|
|
||||||
await run_load(args.db_url, args.admin_id)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(main())
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
import argparse
|
|
||||||
import asyncio
|
|
||||||
import re
|
|
||||||
from docx import Document
|
|
||||||
import asyncpg
|
|
||||||
|
|
||||||
|
|
||||||
def parse_residents(docx_path: str) -> list[dict]:
|
|
||||||
doc = Document(docx_path)
|
|
||||||
results = []
|
|
||||||
seen = set()
|
|
||||||
|
|
||||||
for table in doc.tables:
|
|
||||||
current_room = None
|
|
||||||
for row in table.rows:
|
|
||||||
cells = row.cells
|
|
||||||
if len(cells) < 3:
|
|
||||||
continue
|
|
||||||
|
|
||||||
cell0_text = cells[0].text.strip()
|
|
||||||
room_match = re.search(r'\b(\d{3,4})\b', cell0_text)
|
|
||||||
if room_match:
|
|
||||||
current_room = int(room_match.group(1))
|
|
||||||
|
|
||||||
name_text = cells[2].text.strip()
|
|
||||||
skip_keywords = ('Дата', 'Фамилия', 'Осталось', 'Совершеннолетних')
|
|
||||||
|
|
||||||
if name_text and current_room and not any(kw in name_text for kw in skip_keywords):
|
|
||||||
clean_name = re.sub(r'\s+[СC]$', '', name_text).strip()
|
|
||||||
if clean_name and len(clean_name) > 2:
|
|
||||||
key = (current_room, clean_name)
|
|
||||||
if key not in seen:
|
|
||||||
seen.add(key)
|
|
||||||
results.append({"room_number": current_room, "real_name": clean_name})
|
|
||||||
|
|
||||||
return results
|
|
||||||
|
|
||||||
|
|
||||||
async def insert_data(database_url: str, residents: list[dict]) -> None:
|
|
||||||
async with asyncpg.create_pool(database_url) as pool:
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
async with conn.transaction():
|
|
||||||
floor_rows = await conn.fetch("SELECT id, number FROM floors;")
|
|
||||||
floor_map = {row['number']: row['id'] for row in floor_rows}
|
|
||||||
|
|
||||||
if not floor_map:
|
|
||||||
raise RuntimeError("Таблица floors пуста, невозможно привязать комнаты.")
|
|
||||||
|
|
||||||
unique_rooms = set(r["room_number"] for r in residents)
|
|
||||||
room_insert_params = []
|
|
||||||
|
|
||||||
for room_num in unique_rooms:
|
|
||||||
floor_num = room_num // 100
|
|
||||||
if floor_num not in floor_map:
|
|
||||||
raise ValueError(f"Для комнаты {room_num} не найден этаж {floor_num} в БД.")
|
|
||||||
room_insert_params.append((room_num, floor_map[floor_num]))
|
|
||||||
|
|
||||||
await conn.executemany(
|
|
||||||
"""
|
|
||||||
INSERT INTO rooms (number, on_floor)
|
|
||||||
VALUES ($1, $2)
|
|
||||||
ON CONFLICT (number) DO NOTHING;
|
|
||||||
""",
|
|
||||||
room_insert_params
|
|
||||||
)
|
|
||||||
|
|
||||||
room_rows = await conn.fetch(
|
|
||||||
"""
|
|
||||||
SELECT id, number
|
|
||||||
FROM rooms
|
|
||||||
WHERE number = ANY($1::int[]);
|
|
||||||
""",
|
|
||||||
list(unique_rooms)
|
|
||||||
)
|
|
||||||
room_map = {row['number']: row['id'] for row in room_rows}
|
|
||||||
|
|
||||||
resident_params = [
|
|
||||||
(r["real_name"], room_map[r["room_number"]])
|
|
||||||
for r in residents
|
|
||||||
]
|
|
||||||
|
|
||||||
await conn.executemany(
|
|
||||||
"""
|
|
||||||
INSERT INTO residents (real_name, room, created_at, updated_at)
|
|
||||||
VALUES ($1, $2, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);
|
|
||||||
""",
|
|
||||||
resident_params
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def main() -> None:
|
|
||||||
parser = argparse.ArgumentParser(description="Импорт жильцов из docx в PostgreSQL")
|
|
||||||
parser.add_argument("--db-url", required=True, help="URL базы данных (напр. postgresql://user:pass@localhost/db)")
|
|
||||||
parser.add_argument("--file", required=True, help="Путь к docx файлу")
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
residents = parse_residents(args.file)
|
|
||||||
await insert_data(args.db_url, residents)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(main())
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
Абакунчик Анастасия - 55
|
|
||||||
Адамович Анастасия - 30
|
|
||||||
Алиева Ангелина - 5
|
|
||||||
Анищенок Алена - 5
|
|
||||||
Апацкая Александра - 25
|
|
||||||
Басаревский Олег - 50
|
|
||||||
Бобкова Софья - 60
|
|
||||||
Бойко Виктория - 55
|
|
||||||
Буйвол Екатерина - 45
|
|
||||||
Вишневская Вирсавия - 20
|
|
||||||
Володько Виктория - 10
|
|
||||||
Гвоздович Иван - 45
|
|
||||||
Гинцева Ева - 5
|
|
||||||
Гумовский Тимур - 20
|
|
||||||
Гутырчик Арина - 5
|
|
||||||
Гутырчик Никита - 5
|
|
||||||
Давлетшина Алеся - 35
|
|
||||||
Доморацкий Ярослав - 105
|
|
||||||
Дубинец Ксения - 20
|
|
||||||
Дудинская Полина - 15
|
|
||||||
Демова Маргарита - 10
|
|
||||||
Дауд Фэрдос - 25
|
|
||||||
Жилевич Кира - 15
|
|
||||||
Зимницкая Валерия - 10
|
|
||||||
Игнатенко Маргарита - 10
|
|
||||||
Илюкевич Анна - 10
|
|
||||||
Казак Милана - 10
|
|
||||||
Канорин Егор - 75
|
|
||||||
Каранько Дарья - 30
|
|
||||||
Климбовский Иван - 55
|
|
||||||
Костюкевич Лиза - 5
|
|
||||||
Клименко Дарья - 15
|
|
||||||
Климович Дмитрий - 30
|
|
||||||
Ковалева Анна - 45
|
|
||||||
Крукович Маргарита - 10
|
|
||||||
Кулага Софья - 65
|
|
||||||
Левизанова Алина - 10
|
|
||||||
Лемеза Надежда - 55
|
|
||||||
Липницкая Ксения - 45
|
|
||||||
Лисица Наталья - 45
|
|
||||||
Лобановская Кристина - 10
|
|
||||||
Логинова Мария - 10
|
|
||||||
Лось Артем - 85
|
|
||||||
Лукьянов Павел - 15
|
|
||||||
Ляхнович Яна - 20
|
|
||||||
Максимова Юлия - 60
|
|
||||||
Мальков Артем - 35
|
|
||||||
Мамонько Анна - 80
|
|
||||||
Мананкова Дарья - 90
|
|
||||||
Мануйлов Артем - 65
|
|
||||||
Мысливец Екатерина - 5
|
|
||||||
Марьянский Дмитрий - 10
|
|
||||||
Мельник Кира - 5
|
|
||||||
Маковский Даниил - 35
|
|
||||||
Мирошниченко Максим - 5
|
|
||||||
Михаленок Полина - 15
|
|
||||||
Мусаева Дильшад - 50
|
|
||||||
Нагорная Марина - 10
|
|
||||||
Неверовская Майя - 90
|
|
||||||
Негода Никита - 40
|
|
||||||
Немченко Виктория - 10
|
|
||||||
Новикова Ксения - 80
|
|
||||||
Островская Валерия - 10
|
|
||||||
Павлусенко Богдан - 10
|
|
||||||
Понамарева Алина - 10
|
|
||||||
Пашкевич Николай - 60
|
|
||||||
Пожиган Анастасия - 20
|
|
||||||
Пусторжевцева Виктория - 10
|
|
||||||
Рабцевич Дмитрий - 10
|
|
||||||
Саланович Анастасия - 15
|
|
||||||
Сергей Ангелина - 15
|
|
||||||
Сечко Алина - 15
|
|
||||||
Сивакова Татьяна - 5
|
|
||||||
Скринник Маргарита - 35
|
|
||||||
Сманцер Алесь - 75
|
|
||||||
Скурьят Юлиана - 5
|
|
||||||
Семенов Даниил - 5
|
|
||||||
Спиченок Кира - 15
|
|
||||||
Салоха Полина - 5
|
|
||||||
Сушкевич Артем - 30
|
|
||||||
Сырокваш Валерия - 5
|
|
||||||
Терехова Арина - 15
|
|
||||||
Тозик Константин - 5
|
|
||||||
Тризнюк Станислав - 15
|
|
||||||
Трухан Яна - 35
|
|
||||||
Турончик Ангелина - 35
|
|
||||||
Тюнис Надежда - 15
|
|
||||||
Хацкевич Дарья - 5
|
|
||||||
Цыбулько Екатерина - 10
|
|
||||||
Черник Дарья - 5
|
|
||||||
Чигир Дарья - 220
|
|
||||||
Шарапова Ксения - 20
|
|
||||||
Шидловский Георгий - 10
|
|
||||||
Шишкина София - 25
|
|
||||||
Щелоков Тимофей - 25
|
|
||||||
Юхович Дарья - 105
|
|
||||||
Янущик Владислав - 55
|
|
||||||
Зимницкая Эвелина - 5
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
201 - 5
|
|
||||||
202 - 20
|
|
||||||
203 - 5
|
|
||||||
206 - 5
|
|
||||||
207 - 10
|
|
||||||
208 - 5
|
|
||||||
209 - 5
|
|
||||||
210 - 15
|
|
||||||
211 - 10
|
|
||||||
212 - 10
|
|
||||||
213 - 5
|
|
||||||
214 - 10
|
|
||||||
215 - 55
|
|
||||||
216 - 10
|
|
||||||
301 - 5
|
|
||||||
302 - 10
|
|
||||||
303 - 35
|
|
||||||
304 - 25
|
|
||||||
305 - 15
|
|
||||||
308 - 10
|
|
||||||
310 - 15
|
|
||||||
311 - 10
|
|
||||||
313 - 5
|
|
||||||
314 - 5
|
|
||||||
315 - 20
|
|
||||||
402 - 5
|
|
||||||
403 - 5
|
|
||||||
404 - 5
|
|
||||||
405 - 10
|
|
||||||
408 - 10
|
|
||||||
412 - 10
|
|
||||||
415 - 10
|
|
||||||
501 - 10
|
|
||||||
506 - 40
|
|
||||||
507 - 20
|
|
||||||
508 - 5
|
|
||||||
509 - 20
|
|
||||||
510 - 50
|
|
||||||
511 - 100
|
|
||||||
512 - 5
|
|
||||||
513 - 15
|
|
||||||
514 - 45
|
|
||||||
515 - 15
|
|
||||||
517 - 15
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import argparse
|
|
||||||
import asyncio
|
|
||||||
import asyncpg
|
|
||||||
|
|
||||||
|
|
||||||
async def seed_floors_and_rooms(database_url: str):
|
|
||||||
async with asyncpg.create_pool(database_url) as pool:
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
async with conn.transaction():
|
|
||||||
|
|
||||||
# Создаем этажи (со 2-го по 5-й)
|
|
||||||
floors = [2, 3, 4, 5]
|
|
||||||
await conn.executemany(
|
|
||||||
"""
|
|
||||||
INSERT INTO floors (number)
|
|
||||||
VALUES ($1)
|
|
||||||
ON CONFLICT (number) DO NOTHING;
|
|
||||||
""",
|
|
||||||
[(f,) for f in floors]
|
|
||||||
)
|
|
||||||
print(f"Добавлены или уже существуют этажи: {floors}")
|
|
||||||
|
|
||||||
# Получаем ID созданных этажей
|
|
||||||
floor_rows = await conn.fetch("SELECT id, number FROM floors WHERE number = ANY($1::int[]);", floors)
|
|
||||||
floor_map = {row['number']: row['id'] for row in floor_rows}
|
|
||||||
|
|
||||||
# Создаем комнаты (x01 .. x16 для каждого этажа)
|
|
||||||
rooms_to_insert = []
|
|
||||||
for floor_num in floors:
|
|
||||||
floor_id = floor_map[floor_num]
|
|
||||||
for room_suffix in range(1, 17):
|
|
||||||
room_number = floor_num * 100 + room_suffix # 201, 202, ..., 516
|
|
||||||
rooms_to_insert.append((room_number, floor_id))
|
|
||||||
|
|
||||||
# Выполняем вставку комнат
|
|
||||||
await conn.executemany(
|
|
||||||
"""
|
|
||||||
INSERT INTO rooms (number, on_floor)
|
|
||||||
VALUES ($1, $2)
|
|
||||||
ON CONFLICT (number) DO NOTHING;
|
|
||||||
""",
|
|
||||||
rooms_to_insert
|
|
||||||
)
|
|
||||||
print(f"Добавлены или уже существуют {len(rooms_to_insert)} комнат.")
|
|
||||||
|
|
||||||
|
|
||||||
async def main():
|
|
||||||
parser = argparse.ArgumentParser(description="Инициализация этажей (2-5) и комнат (x01-x16)")
|
|
||||||
parser.add_argument("--db-url", required=True, help="URL базы данных")
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
await seed_floors_and_rooms(args.db_url)
|
|
||||||
print("Инициализация завершена.")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(main())
|
|
||||||
@@ -7,6 +7,7 @@ from dutylog.application.bot.admin_dialogs.main_menu import (
|
|||||||
from dutylog.application.bot.admin_dialogs.residents_management import (
|
from dutylog.application.bot.admin_dialogs.residents_management import (
|
||||||
residents_list_window,
|
residents_list_window,
|
||||||
resident_info_window,
|
resident_info_window,
|
||||||
|
resident_history_window,
|
||||||
resident_logout_confirm_window,
|
resident_logout_confirm_window,
|
||||||
resident_delete_confirm_window,
|
resident_delete_confirm_window,
|
||||||
resident_rebind_floor_window,
|
resident_rebind_floor_window,
|
||||||
@@ -34,6 +35,7 @@ from dutylog.application.bot.admin_dialogs.rooms_management import (
|
|||||||
rooms_select_floor_window,
|
rooms_select_floor_window,
|
||||||
rooms_list_window,
|
rooms_list_window,
|
||||||
room_info_window,
|
room_info_window,
|
||||||
|
room_history_window,
|
||||||
room_delete_confirm_window,
|
room_delete_confirm_window,
|
||||||
room_add_hours_select_window,
|
room_add_hours_select_window,
|
||||||
room_remove_hours_select_window,
|
room_remove_hours_select_window,
|
||||||
@@ -76,6 +78,7 @@ admin_menu_dialog = Dialog(
|
|||||||
main_menu_window,
|
main_menu_window,
|
||||||
residents_list_window,
|
residents_list_window,
|
||||||
resident_info_window,
|
resident_info_window,
|
||||||
|
resident_history_window,
|
||||||
resident_logout_confirm_window,
|
resident_logout_confirm_window,
|
||||||
resident_delete_confirm_window,
|
resident_delete_confirm_window,
|
||||||
resident_rebind_floor_window,
|
resident_rebind_floor_window,
|
||||||
@@ -100,6 +103,7 @@ admin_menu_dialog = Dialog(
|
|||||||
rooms_select_floor_window,
|
rooms_select_floor_window,
|
||||||
rooms_list_window,
|
rooms_list_window,
|
||||||
room_info_window,
|
room_info_window,
|
||||||
|
room_history_window,
|
||||||
room_delete_confirm_window,
|
room_delete_confirm_window,
|
||||||
room_add_hours_select_window,
|
room_add_hours_select_window,
|
||||||
room_remove_hours_select_window,
|
room_remove_hours_select_window,
|
||||||
|
|||||||
@@ -7,73 +7,48 @@ from dutylog.application.bot.user_dialogs.states import AdminMenuSG
|
|||||||
|
|
||||||
async def get_admin_faq_data(**kwargs) -> dict[str, str]:
|
async def get_admin_faq_data(**kwargs) -> dict[str, str]:
|
||||||
content = """
|
content = """
|
||||||
<blockquote>❓ <b>Гайд по админке</b></blockquote>
|
<blockquote>❓ <b>О боте DutyLog</b></blockquote>
|
||||||
|
|
||||||
Привет! Ты теперь админ, и это круто 😎
|
<b>DutyLog</b> — система учёта дежурных часов для общежития.
|
||||||
Давай разберёмся, что тут к чему.
|
|
||||||
|
|
||||||
<blockquote><b>🏠 Резиденты — твоя главная тусовка</b>
|
<b>Основные возможности:</b>
|
||||||
|
|
||||||
Здесь живут все жители общаги. Можешь:
|
<blockquote><b>👥 Управление резидентами</b>
|
||||||
• Искать кого угодно — по имени, комнате или нику в телеге
|
• Регистрация жителей общежития
|
||||||
• Фильтровать по часам (кто должник, а кто молодец)
|
• Привязка к комнатам и пользователям Telegram
|
||||||
• Начислять или снимать часы (не забывай писать за что!)
|
• Учёт отработанных и неотработанных часов
|
||||||
• Добавлять новых людей или удалять старых
|
• Поиск по имени, комнате или username
|
||||||
• Отвязывать юзеров от резидентов (если кто-то съехал)</blockquote>
|
• Фильтрация по количеству часов</blockquote>
|
||||||
|
|
||||||
<blockquote><b>🚪 Комнаты и 🏢 Этажи</b>
|
<blockquote><b>🏢 Структура общежития</b>
|
||||||
|
• Управление этажами и комнатами
|
||||||
|
• Учёт часов как для резидентов, так и для комнат
|
||||||
|
• Просмотр жителей каждой комнаты</blockquote>
|
||||||
|
|
||||||
Тут всё просто — структура общежития.
|
<blockquote><b>⏰ Учёт часов</b>
|
||||||
Добавляй этажи, создавай комнаты, привязывай их друг к другу.
|
• Начисление неотработанных часов
|
||||||
Без этого резидентов не создать!</blockquote>
|
• Списание часов (перевод в отработанные)
|
||||||
|
• История всех операций с примечаниями
|
||||||
|
• Уведомления резидентам об изменениях</blockquote>
|
||||||
|
|
||||||
<blockquote><b>📅 Отчётный период — важная штука!</b>
|
<blockquote><b>📅 Отчётные периоды</b>
|
||||||
|
• Создание периодов учёта (обычно месяц)
|
||||||
|
• Автоматическое закрытие предыдущего периода
|
||||||
|
• Генерация Excel-отчётов за период
|
||||||
|
• 4 листа в отчёте: начисления/списания для резидентов и комнат</blockquote>
|
||||||
|
|
||||||
Система работает по месяцам. Один период = один месяц учёта.
|
<blockquote><b>📊 Статистика и отчёты</b>
|
||||||
|
• Общая статистика по резидентам и комнатам
|
||||||
|
• Количество пользователей и админов
|
||||||
|
• Детальные отчёты в формате Excel
|
||||||
|
• История операций для каждого резидента и комнаты</blockquote>
|
||||||
|
|
||||||
Как это работает:
|
<blockquote><b>📢 Дополнительно</b>
|
||||||
• Создаёшь новый период → старый автоматом закрывается
|
• Рассылка сообщений всем пользователям
|
||||||
• Дата начала = когда создал
|
• Разграничение прав (админы/пользователи)
|
||||||
• Дата конца = когда создал следующий
|
• Админы не участвуют в учёте часов</blockquote>
|
||||||
|
|
||||||
Зачем это нужно? Чтобы потом сделать красивый отчёт за месяц и показать всем, кто сколько отработал.</blockquote>
|
<b>Для вопросов и предложений обращайтесь к создателю бота.</b>
|
||||||
|
|
||||||
<blockquote><b>📊 Отчёты — твоя суперсила</b>
|
|
||||||
|
|
||||||
После закрытия периода можешь сгенерить Excel-файл.
|
|
||||||
В нём будет:
|
|
||||||
• Все начисления и списания за месяц
|
|
||||||
• Кто и когда начислил/снял часы
|
|
||||||
• Примечания (поэтому их важно писать!)
|
|
||||||
• Автоматический подсчёт итогов
|
|
||||||
|
|
||||||
Отчёт можно скинуть старосте или куратору — всё наглядно.</blockquote>
|
|
||||||
|
|
||||||
<blockquote><b>📊 Статистика</b>
|
|
||||||
|
|
||||||
Быстрый взгляд на цифры:
|
|
||||||
• Сколько всего людей в системе
|
|
||||||
• Сколько админов (ты не один!)
|
|
||||||
• Общая сумма часов по всем резидентам</blockquote>
|
|
||||||
|
|
||||||
<blockquote><b>📢 Рассылка</b>
|
|
||||||
|
|
||||||
Нужно всем что-то сообщить? Жми сюда.
|
|
||||||
Сообщение улетит всем пользователям бота.
|
|
||||||
Можно использовать HTML для красоты.</blockquote>
|
|
||||||
|
|
||||||
<blockquote><b>💡 Лайфхаки:</b>
|
|
||||||
|
|
||||||
• <b>Всегда пиши примечание</b> при начислении/списании часов
|
|
||||||
Через месяц никто не вспомнит, за что было
|
|
||||||
|
|
||||||
• <b>Делай отчёты регулярно</b>
|
|
||||||
Это твоя страховка и архив данных
|
|
||||||
|
|
||||||
• <b>Проверяй статистику</b> перед закрытием периода
|
|
||||||
Вдруг что-то забыл начислить?</blockquote>
|
|
||||||
|
|
||||||
Вопросы? Пиши создателю бота 👑
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
return {"content": content}
|
return {"content": content}
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ from dutylog.infrastructure.database.repositories.floors_repository import (
|
|||||||
from dutylog.infrastructure.database.repositories.users_repository import (
|
from dutylog.infrastructure.database.repositories.users_repository import (
|
||||||
UsersRepository,
|
UsersRepository,
|
||||||
)
|
)
|
||||||
|
from dutylog.infrastructure.database.repositories.hours_transactions_repository import (
|
||||||
|
HoursTransactionsRepository,
|
||||||
|
)
|
||||||
|
from dutylog.infrastructure.utils.datetime import msk_now
|
||||||
|
|
||||||
|
|
||||||
@inject
|
@inject
|
||||||
@@ -546,6 +550,58 @@ async def on_search_resident_selected(
|
|||||||
await dialog_manager.switch_to(AdminMenuSG.resident_info)
|
await dialog_manager.switch_to(AdminMenuSG.resident_info)
|
||||||
|
|
||||||
|
|
||||||
|
@inject
|
||||||
|
async def get_resident_history_data(
|
||||||
|
dialog_manager: DialogManager,
|
||||||
|
residents_repository: FromDishka[ResidentsRepository],
|
||||||
|
transactions_repository: FromDishka[HoursTransactionsRepository],
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
resident_id = dialog_manager.dialog_data.get("selected_resident_id")
|
||||||
|
|
||||||
|
if not resident_id:
|
||||||
|
return {"history_content": "Ошибка: резидент не выбран"}
|
||||||
|
|
||||||
|
resident = await residents_repository.get_resident_by_id(resident_id)
|
||||||
|
|
||||||
|
if not resident:
|
||||||
|
return {"history_content": "Ошибка: резидент не найден"}
|
||||||
|
|
||||||
|
transactions = await transactions_repository.get_resident_history(resident_id)
|
||||||
|
transactions_sorted = sorted(transactions, key=lambda x: x.created_at)
|
||||||
|
last_10 = transactions_sorted[-10:]
|
||||||
|
|
||||||
|
resident_name = resident.real_name if resident.real_name else "Без имени"
|
||||||
|
|
||||||
|
if not last_10:
|
||||||
|
history_text = f"""
|
||||||
|
<blockquote>📜 <b>История операций</b></blockquote>
|
||||||
|
|
||||||
|
<b>Резидент:</b> {resident_name}
|
||||||
|
|
||||||
|
<i>История операций пуста</i>
|
||||||
|
"""
|
||||||
|
else:
|
||||||
|
history_text = f"""
|
||||||
|
<blockquote>📜 <b>История операций</b></blockquote>
|
||||||
|
|
||||||
|
<b>Резидент:</b> {resident_name}
|
||||||
|
|
||||||
|
"""
|
||||||
|
for tx in last_10:
|
||||||
|
operation = "Начислено" if tx.transaction_type == "increase" else "Списано"
|
||||||
|
emoji = "+" if tx.transaction_type == "increase" else "−"
|
||||||
|
|
||||||
|
msk_time = tx.created_at.astimezone(msk_now().tzinfo).replace(tzinfo=None)
|
||||||
|
date_str = msk_time.strftime("%d.%m.%Y %H:%M")
|
||||||
|
|
||||||
|
remark_text = f"\n💬 <i>{tx.remark}</i>" if tx.remark else ""
|
||||||
|
|
||||||
|
history_text += f"<blockquote><b>{operation}</b> {emoji}<code>{tx.amount}</code> ч\n📅 {date_str}{remark_text}</blockquote>\n"
|
||||||
|
|
||||||
|
return {"history_content": history_text}
|
||||||
|
|
||||||
|
|
||||||
residents_list_window = Window(
|
residents_list_window = Window(
|
||||||
Format("{content}"),
|
Format("{content}"),
|
||||||
Row(
|
Row(
|
||||||
@@ -602,6 +658,12 @@ resident_info_window = Window(
|
|||||||
when=~F["is_admin"],
|
when=~F["is_admin"],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
Button(
|
||||||
|
Const("📜 История"),
|
||||||
|
id="resident_history_btn",
|
||||||
|
on_click=lambda c, b, m: m.switch_to(AdminMenuSG.resident_history),
|
||||||
|
when=~F["is_admin"],
|
||||||
|
),
|
||||||
Button(
|
Button(
|
||||||
Const("🔄 Перепривязать к комнате"),
|
Const("🔄 Перепривязать к комнате"),
|
||||||
id="rebind_resident_btn",
|
id="rebind_resident_btn",
|
||||||
@@ -850,3 +912,14 @@ resident_rebind_confirm_window = Window(
|
|||||||
state=AdminMenuSG.resident_rebind_confirm,
|
state=AdminMenuSG.resident_rebind_confirm,
|
||||||
getter=get_rebind_confirm_data,
|
getter=get_rebind_confirm_data,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
resident_history_window = Window(
|
||||||
|
Format("{history_content}"),
|
||||||
|
SwitchTo(
|
||||||
|
Const("◀️ Назад"),
|
||||||
|
id="back_to_resident_info",
|
||||||
|
state=AdminMenuSG.resident_info,
|
||||||
|
),
|
||||||
|
state=AdminMenuSG.resident_history,
|
||||||
|
getter=get_resident_history_data,
|
||||||
|
)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from dutylog.infrastructure.database.repositories.room_hours_transactions_reposi
|
|||||||
from dutylog.infrastructure.database.repositories.users_repository import (
|
from dutylog.infrastructure.database.repositories.users_repository import (
|
||||||
UsersRepository,
|
UsersRepository,
|
||||||
)
|
)
|
||||||
|
from dutylog.infrastructure.utils.datetime import msk_now
|
||||||
|
|
||||||
|
|
||||||
async def on_rooms_click(
|
async def on_rooms_click(
|
||||||
@@ -533,6 +534,56 @@ async def on_delete_room_cancel(
|
|||||||
await dialog_manager.switch_to(AdminMenuSG.room_info)
|
await dialog_manager.switch_to(AdminMenuSG.room_info)
|
||||||
|
|
||||||
|
|
||||||
|
@inject
|
||||||
|
async def get_room_history_data(
|
||||||
|
dialog_manager: DialogManager,
|
||||||
|
rooms_repository: FromDishka[RoomsRepository],
|
||||||
|
room_transactions_repository: FromDishka[RoomHoursTransactionsRepository],
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
room_id = dialog_manager.dialog_data.get("selected_room_id")
|
||||||
|
|
||||||
|
if not room_id:
|
||||||
|
return {"history_content": "Ошибка: комната не выбрана"}
|
||||||
|
|
||||||
|
room = await rooms_repository.get_room_by_id(room_id)
|
||||||
|
|
||||||
|
if not room:
|
||||||
|
return {"history_content": "Ошибка: комната не найдена"}
|
||||||
|
|
||||||
|
transactions = await room_transactions_repository.get_room_history(room_id)
|
||||||
|
transactions_sorted = sorted(transactions, key=lambda x: x.created_at)
|
||||||
|
last_10 = transactions_sorted[-10:]
|
||||||
|
|
||||||
|
if not last_10:
|
||||||
|
history_text = f"""
|
||||||
|
<blockquote>📜 <b>История операций</b></blockquote>
|
||||||
|
|
||||||
|
<b>Комната:</b> {room.number}
|
||||||
|
|
||||||
|
<i>История операций пуста</i>
|
||||||
|
"""
|
||||||
|
else:
|
||||||
|
history_text = f"""
|
||||||
|
<blockquote>📜 <b>История операций</b></blockquote>
|
||||||
|
|
||||||
|
<b>Комната:</b> {room.number}
|
||||||
|
|
||||||
|
"""
|
||||||
|
for tx in last_10:
|
||||||
|
operation = "Начислено" if tx.transaction_type == "increase" else "Списано"
|
||||||
|
emoji = "+" if tx.transaction_type == "increase" else "−"
|
||||||
|
|
||||||
|
msk_time = tx.created_at.astimezone(msk_now().tzinfo).replace(tzinfo=None)
|
||||||
|
date_str = msk_time.strftime("%d.%m.%Y %H:%M")
|
||||||
|
|
||||||
|
remark_text = f"\n💬 <i>{tx.remark}</i>" if tx.remark else ""
|
||||||
|
|
||||||
|
history_text += f"<blockquote><b>{operation}</b> {emoji}<code>{tx.amount}</code> ч\n📅 {date_str}{remark_text}</blockquote>\n"
|
||||||
|
|
||||||
|
return {"history_content": history_text}
|
||||||
|
|
||||||
|
|
||||||
rooms_select_floor_window = Window(
|
rooms_select_floor_window = Window(
|
||||||
Format("{content}"),
|
Format("{content}"),
|
||||||
Group(
|
Group(
|
||||||
@@ -596,6 +647,11 @@ room_info_window = Window(
|
|||||||
on_click=on_room_remove_hours_click,
|
on_click=on_room_remove_hours_click,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
Button(
|
||||||
|
Const("📜 История"),
|
||||||
|
id="room_history_btn",
|
||||||
|
on_click=lambda c, b, m: m.switch_to(AdminMenuSG.room_history),
|
||||||
|
),
|
||||||
Button(
|
Button(
|
||||||
Const("🗑 Удалить комнату"),
|
Const("🗑 Удалить комнату"),
|
||||||
id="delete_room_btn",
|
id="delete_room_btn",
|
||||||
@@ -819,3 +875,14 @@ create_room_confirm_window = Window(
|
|||||||
state=AdminMenuSG.create_room_confirm,
|
state=AdminMenuSG.create_room_confirm,
|
||||||
getter=get_create_room_confirm_data,
|
getter=get_create_room_confirm_data,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
room_history_window = Window(
|
||||||
|
Format("{history_content}"),
|
||||||
|
SwitchTo(
|
||||||
|
Const("◀️ Назад"),
|
||||||
|
id="back_to_room_info",
|
||||||
|
state=AdminMenuSG.room_info,
|
||||||
|
),
|
||||||
|
state=AdminMenuSG.room_history,
|
||||||
|
getter=get_room_history_data,
|
||||||
|
)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ class AdminMenuSG(StatesGroup):
|
|||||||
residents_filter_hours_input = State()
|
residents_filter_hours_input = State()
|
||||||
residents_filtered_results = State()
|
residents_filtered_results = State()
|
||||||
resident_info = State()
|
resident_info = State()
|
||||||
|
resident_history = State()
|
||||||
resident_logout_confirm = State()
|
resident_logout_confirm = State()
|
||||||
resident_delete_confirm = State()
|
resident_delete_confirm = State()
|
||||||
resident_rebind_floor = State()
|
resident_rebind_floor = State()
|
||||||
@@ -41,6 +42,7 @@ class AdminMenuSG(StatesGroup):
|
|||||||
rooms_select_floor = State()
|
rooms_select_floor = State()
|
||||||
rooms_list = State()
|
rooms_list = State()
|
||||||
room_info = State()
|
room_info = State()
|
||||||
|
room_history = State()
|
||||||
room_delete_confirm = State()
|
room_delete_confirm = State()
|
||||||
room_add_hours_select = State()
|
room_add_hours_select = State()
|
||||||
room_remove_hours_select = State()
|
room_remove_hours_select = State()
|
||||||
|
|||||||
Reference in New Issue
Block a user