0da9c13192
Иерархия рамочных/дочерних договоров и платежей, интернет-биллинг, месячные IT-отчёты (импорт, DOCX/PDF, шаблоны выводов). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
345 lines
12 KiB
Python
345 lines
12 KiB
Python
"""Импорт месячных JSON из report-tool/ в WorkLog."""
|
||
from __future__ import annotations
|
||
|
||
import calendar
|
||
import json
|
||
import re
|
||
from dataclasses import dataclass, field
|
||
from datetime import date, datetime
|
||
from pathlib import Path
|
||
|
||
from django.contrib.auth import get_user_model
|
||
from django.db import transaction
|
||
|
||
from apps.it_manager.models import WorkLog
|
||
from apps.locations.models.base import Location, organization_object_letter
|
||
from apps.locations.models.pharmacies import Pharmacy
|
||
from core.models.directories import Directory
|
||
|
||
Employee = get_user_model()
|
||
|
||
UA_LOWER_TO_RU = {'і': 'и', 'ї': 'и', 'є': 'е', 'ґ': 'г'}
|
||
|
||
STAFF_LAST_NAMES = {
|
||
'babich': ('бабич',),
|
||
'aprodov': ('апродов',),
|
||
'khizhniak': ('хижняк', 'хіжняк'),
|
||
'kogutenko': ('когутенко',),
|
||
}
|
||
|
||
DIRECTION_TO_WORK_TYPE = {
|
||
'prro': 'software',
|
||
'terminal': 'installation',
|
||
'print': 'repair',
|
||
'network': 'network',
|
||
'cctv': 'installation',
|
||
'pc': 'repair',
|
||
'install': 'installation',
|
||
'access': 'software',
|
||
'infra': 'maintenance',
|
||
}
|
||
|
||
OBJ_CODE_RE = re.compile(r'^([АAПPТTГGЛL])(\d+)$', re.IGNORECASE)
|
||
IMPORT_MARKER = 'import:report-tool:{month_id}'
|
||
|
||
|
||
def normalize_name(value: str) -> str:
|
||
value = (value or '').strip().lower()
|
||
return ''.join(UA_LOWER_TO_RU.get(c, c) for c in value)
|
||
|
||
|
||
def default_report_tool_root() -> Path:
|
||
# apps/it_manager/report_import.py → apms/
|
||
return Path(__file__).resolve().parents[3] / 'report-tool'
|
||
|
||
|
||
def load_company(root: Path) -> dict:
|
||
return json.loads((root / 'company.json').read_text(encoding='utf-8'))
|
||
|
||
|
||
def load_month_json(path: Path) -> dict:
|
||
return json.loads(path.read_text(encoding='utf-8'))
|
||
|
||
|
||
def parse_task_date(value: str, year: int, month: int) -> date | None:
|
||
value = (value or '').strip()
|
||
if not value:
|
||
return None
|
||
for fmt in ('%d.%m.%Y', '%d.%m.%y', '%Y-%m-%d'):
|
||
try:
|
||
return datetime.strptime(value, fmt).date()
|
||
except ValueError:
|
||
continue
|
||
m = re.fullmatch(r'(\d{1,2})/(\d{1,2})/(\d{2,4})', value)
|
||
if m:
|
||
mm, dd, yy = int(m.group(1)), int(m.group(2)), int(m.group(3))
|
||
if yy < 100:
|
||
yy += 2000
|
||
try:
|
||
return date(yy, mm, dd)
|
||
except ValueError:
|
||
return None
|
||
m = re.fullmatch(r'(\d{1,2})\.(\d{1,2})', value)
|
||
if m:
|
||
try:
|
||
return date(year, int(m.group(2)), int(m.group(1)))
|
||
except ValueError:
|
||
return None
|
||
return None
|
||
|
||
|
||
def month_end(year: int, month: int) -> date:
|
||
return date(year, month, calendar.monthrange(year, month)[1])
|
||
|
||
|
||
def classify_work_type_code(text: str, directions: list[dict]) -> str:
|
||
low = (text or '').lower()
|
||
for direction in directions:
|
||
for kw in direction.get('keywords') or []:
|
||
if kw.lower() in low:
|
||
return DIRECTION_TO_WORK_TYPE.get(direction.get('id'), 'other')
|
||
return 'other'
|
||
|
||
|
||
@dataclass
|
||
class ImportStats:
|
||
created: int = 0
|
||
skipped: int = 0
|
||
missing_employees: list[str] = field(default_factory=list)
|
||
unresolved_objects: list[str] = field(default_factory=list)
|
||
months: list[str] = field(default_factory=list)
|
||
|
||
|
||
class LocationResolver:
|
||
def __init__(self):
|
||
self._by_prefix_num: dict[tuple[str, int], Location] = {}
|
||
self._by_num: dict[int, list[Location]] = {}
|
||
self._office: Location | None = None
|
||
self._warehouse: Location | None = None
|
||
self._built = False
|
||
|
||
def build(self):
|
||
if self._built:
|
||
return
|
||
self._built = True
|
||
for pharmacy in Pharmacy.objects.select_related('organization').filter(is_active=True):
|
||
num = pharmacy.pharmacy_number
|
||
if num is None:
|
||
continue
|
||
letter = organization_object_letter(pharmacy.organization)
|
||
if letter:
|
||
self._by_prefix_num[(letter[0].lower(), int(num))] = pharmacy
|
||
self._by_num.setdefault(int(num), []).append(pharmacy)
|
||
self._office = Location.objects.filter(location_type='office', is_active=True).order_by('id').first()
|
||
self._warehouse = Location.objects.filter(location_type='warehouse', is_active=True).order_by('id').first()
|
||
|
||
def resolve(self, object_code: str) -> Location | None:
|
||
self.build()
|
||
raw = (object_code or '').strip()
|
||
if not raw:
|
||
return None
|
||
upper = raw.upper().replace(' ', '')
|
||
if upper in ('ОФИС', 'ОФІС', 'OFFICE'):
|
||
return self._office
|
||
if upper in ('СКЛАД',):
|
||
return self._warehouse
|
||
m = OBJ_CODE_RE.fullmatch(raw.replace(' ', ''))
|
||
if not m:
|
||
return None
|
||
letter = m.group(1).upper().translate(str.maketrans('APTGL', 'АПТГЛ'))
|
||
num = int(m.group(2))
|
||
letter_l = letter.lower()
|
||
hit = self._by_prefix_num.get((letter_l, num))
|
||
if hit:
|
||
return hit
|
||
# fallback: единственная аптека с таким номером
|
||
candidates = self._by_num.get(num) or []
|
||
if len(candidates) == 1:
|
||
return candidates[0]
|
||
return None
|
||
|
||
|
||
def resolve_employee(staff_id: str, staff_name: str, username_map: dict[str, str]) -> Employee | None:
|
||
if staff_id in username_map:
|
||
return Employee.objects.filter(username=username_map[staff_id]).first()
|
||
|
||
last_names = STAFF_LAST_NAMES.get(staff_id)
|
||
if last_names:
|
||
for emp in Employee.objects.filter(is_active=True):
|
||
if normalize_name(emp.last_name) in {normalize_name(n) for n in last_names}:
|
||
return emp
|
||
|
||
# по фамилии из полного имени
|
||
parts = (staff_name or '').split()
|
||
if parts:
|
||
target = normalize_name(parts[0])
|
||
for emp in Employee.objects.filter(is_active=True):
|
||
if normalize_name(emp.last_name) == target:
|
||
return emp
|
||
return None
|
||
|
||
|
||
def ensure_employee(staff_id: str, staff_name: str) -> Employee:
|
||
parts = (staff_name or staff_id).split()
|
||
last_name = parts[0] if parts else staff_id
|
||
first_name = parts[1] if len(parts) > 1 else staff_id
|
||
username = f'it.{staff_id}'
|
||
emp, created = Employee.objects.get_or_create(
|
||
username=username,
|
||
defaults={
|
||
'last_name': last_name,
|
||
'first_name': first_name,
|
||
'email': f'{username}@company.local',
|
||
'hire_date': date(2020, 1, 1),
|
||
'user_location_type': 'office',
|
||
'mattermost_sync_enabled': False,
|
||
'ad_sync_enabled': False,
|
||
'is_active': True,
|
||
},
|
||
)
|
||
if created:
|
||
emp.set_unusable_password()
|
||
emp.save()
|
||
from django.contrib.auth.models import Group
|
||
it_group, _ = Group.objects.get_or_create(name='IT')
|
||
emp.groups.add(it_group)
|
||
return emp
|
||
|
||
|
||
def work_type_id(code: str, cache: dict[str, int]) -> int:
|
||
if code not in cache:
|
||
pk = Directory.objects.filter(
|
||
directory_type__code='worklog_type', code=code,
|
||
).values_list('id', flat=True).first()
|
||
if not pk:
|
||
pk = Directory.objects.filter(
|
||
directory_type__code='worklog_type', code='other',
|
||
).values_list('id', flat=True).first()
|
||
cache[code] = pk
|
||
return cache[code]
|
||
|
||
|
||
def import_marker(month_id: str) -> str:
|
||
return IMPORT_MARKER.format(month_id=month_id)
|
||
|
||
|
||
def iter_month_files(root: Path, months: list[str] | None) -> list[Path]:
|
||
data_dir = root / 'data'
|
||
files = sorted(data_dir.glob('????-??.json'))
|
||
if months:
|
||
wanted = set(months)
|
||
files = [p for p in files if p.stem in wanted]
|
||
return files
|
||
|
||
|
||
@transaction.atomic
|
||
def import_report_tool(
|
||
*,
|
||
root: Path | None = None,
|
||
months: list[str] | None = None,
|
||
username_map: dict[str, str] | None = None,
|
||
create_missing_employees: bool = False,
|
||
dry_run: bool = False,
|
||
) -> ImportStats:
|
||
root = root or default_report_tool_root()
|
||
company = load_company(root)
|
||
directions = company.get('directions') or []
|
||
username_map = username_map or {}
|
||
locations = LocationResolver()
|
||
type_cache: dict[str, int] = {}
|
||
stats = ImportStats()
|
||
|
||
for path in iter_month_files(root, months):
|
||
month = load_month_json(path)
|
||
month_id = month.get('id') or path.stem
|
||
year = int(month['year'])
|
||
month_num = int(month['month'])
|
||
marker = import_marker(month_id)
|
||
stats.months.append(month_id)
|
||
default_date = month_end(year, month_num)
|
||
|
||
for emp_data in month.get('employees') or []:
|
||
staff_id = emp_data.get('id') or ''
|
||
staff_name = emp_data.get('name') or staff_id
|
||
employee = resolve_employee(staff_id, staff_name, username_map)
|
||
if employee is None:
|
||
if create_missing_employees:
|
||
if dry_run:
|
||
employee = None
|
||
else:
|
||
employee = ensure_employee(staff_id, staff_name)
|
||
else:
|
||
if staff_id not in stats.missing_employees:
|
||
stats.missing_employees.append(staff_id or staff_name)
|
||
continue
|
||
|
||
for task in emp_data.get('tasks') or []:
|
||
work = (task.get('work') or '').strip()
|
||
if not work:
|
||
continue
|
||
obj = (task.get('object') or '').strip()
|
||
log_date = parse_task_date(task.get('date') or '', year, month_num) or default_date
|
||
location = locations.resolve(obj) if obj else None
|
||
if obj and location is None and obj.upper() not in stats.unresolved_objects:
|
||
stats.unresolved_objects.append(obj)
|
||
description = work if location or not obj else f'{obj} — {work}'
|
||
wt = classify_work_type_code(f'{obj} {work}', directions)
|
||
_create_or_skip(
|
||
stats=stats,
|
||
dry_run=dry_run,
|
||
employee=employee,
|
||
log_date=log_date,
|
||
location=location,
|
||
work_type_id=work_type_id(wt, type_cache),
|
||
description=description,
|
||
marker=marker,
|
||
)
|
||
|
||
for system in emp_data.get('systems') or []:
|
||
name = (system.get('name') or '').strip()
|
||
work = (system.get('work') or '').strip()
|
||
if not name or not work:
|
||
continue
|
||
description = f'{name}: {work}'
|
||
_create_or_skip(
|
||
stats=stats,
|
||
dry_run=dry_run,
|
||
employee=employee,
|
||
log_date=default_date,
|
||
location=locations.resolve('ОФИС'),
|
||
work_type_id=work_type_id('maintenance', type_cache),
|
||
description=description,
|
||
marker=marker,
|
||
)
|
||
|
||
return stats
|
||
|
||
|
||
def _create_or_skip(*, stats, dry_run, employee, log_date, location, work_type_id, description, marker):
|
||
if employee is None:
|
||
# dry-run без создания сотрудников
|
||
stats.created += 1
|
||
return
|
||
exists = WorkLog.objects.filter(
|
||
technician=employee,
|
||
date=log_date,
|
||
description=description,
|
||
result=marker,
|
||
).exists()
|
||
if exists:
|
||
stats.skipped += 1
|
||
return
|
||
if dry_run:
|
||
stats.created += 1
|
||
return
|
||
WorkLog.objects.create(
|
||
date=log_date,
|
||
technician=employee,
|
||
location=location,
|
||
work_type_id=work_type_id,
|
||
description=description,
|
||
result=marker,
|
||
hours_spent=None,
|
||
)
|
||
stats.created += 1
|