"""Импорт месячных 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