Files
arrr-erp/app/modules/services/default_task_sync.py
T
2026-09-01 15:29:05 +05:30

315 lines
10 KiB
Python

from __future__ import annotations
from collections import defaultdict
from dataclasses import dataclass
import re
from typing import Any
from sqlalchemy import func, select
from sqlalchemy.inspection import inspect as sa_inspect
from sqlalchemy.orm import Session
from app.modules.services.models import (
ClientServiceTaskInstance,
FirmServiceTaskTemplate,
ServiceDefaultTaskTemplate,
)
@dataclass
class FirmDefaultTaskSyncResult:
created: int = 0
updated: int = 0
duplicates_disabled: int = 0
unchanged: int = 0
@property
def active_total_change(self) -> int:
return self.created - self.duplicates_disabled
_SPACE_RE = re.compile(r"\s+")
def _normalise_name(value: str | None) -> str:
return _SPACE_RE.sub(" ", (value or "").strip()).casefold()
def _column_names(model: type[Any]) -> set[str]:
return {column.key for column in sa_inspect(model).mapper.column_attrs}
def _copy_default_columns(
source: ServiceDefaultTaskTemplate,
target: FirmServiceTaskTemplate,
) -> bool:
"""Copy only columns that exist on both system and firm task models.
Firm-only identity/audit fields are deliberately excluded. Related firm
requirement/template-file records are untouched.
"""
source_cols = _column_names(ServiceDefaultTaskTemplate)
target_cols = _column_names(FirmServiceTaskTemplate)
excluded = {
"id",
"tenant_id",
"service_catalogue_id",
"created_at",
"created_at_utc",
"created_by_user_id",
"updated_at",
"updated_at_utc",
"updated_by_user_id",
}
changed = False
for name in sorted((source_cols & target_cols) - excluded):
new_value = getattr(source, name, None)
if getattr(target, name, None) != new_value:
setattr(target, name, new_value)
changed = True
# IMPORTANT:
# is_active is copied from ServiceDefaultTaskTemplate above because it is
# a shared mapped column. Do NOT force FirmServiceTaskTemplate back to True.
#
# This makes System Default activation state authoritative during sync:
# System Default disabled -> Firm Task disabled
# System Default enabled -> Firm Task enabled
#
# Firm-only/custom tasks remain untouched because this function is called
# only for tasks matched to current System Defaults.
return changed
def _reference_counts(
db: Session,
*,
firm_task_ids: list[int],
) -> dict[int, int]:
if not firm_task_ids:
return {}
rows = db.execute(
select(
ClientServiceTaskInstance.firm_task_template_id,
func.count(ClientServiceTaskInstance.id),
)
.where(ClientServiceTaskInstance.firm_task_template_id.in_(firm_task_ids))
.group_by(ClientServiceTaskInstance.firm_task_template_id)
).all()
return {
int(template_id): int(count)
for template_id, count in rows
if template_id is not None
}
def _choose_canonical(
candidates: list[FirmServiceTaskTemplate],
*,
reference_counts: dict[int, int],
) -> FirmServiceTaskTemplate:
"""Prefer the row already used by the most engagement task instances.
This avoids breaking historical links. If usage is equal, keep the oldest
row (smallest id).
"""
return sorted(
candidates,
key=lambda row: (
-reference_counts.get(int(row.id), 0),
int(row.id),
),
)[0]
def sync_firm_tasks_from_system_defaults(
db: Session,
*,
tenant_id: int,
service_catalogue_id: int,
updated_by_user_id: int | None = None,
) -> FirmDefaultTaskSyncResult:
"""Idempotently synchronise system default tasks into one firm's template set.
Behaviour:
* existing firm task with the same normalised task name -> UPDATE, never INSERT;
* duplicate firm rows with the same task name -> retain one canonical active row,
mark the additional template rows inactive;
* missing system default -> create one firm task;
* unique firm-only/custom tasks are preserved;
* existing ClientServiceTaskInstance rows are never deleted or reassigned.
The function may safely be called repeatedly.
"""
defaults = db.execute(
select(ServiceDefaultTaskTemplate)
.where(
ServiceDefaultTaskTemplate.service_catalogue_id == service_catalogue_id,
)
.order_by(
ServiceDefaultTaskTemplate.sequence_no.asc(),
ServiceDefaultTaskTemplate.id.asc(),
)
).scalars().all()
firm_rows = db.execute(
select(FirmServiceTaskTemplate)
.where(
FirmServiceTaskTemplate.tenant_id == tenant_id,
FirmServiceTaskTemplate.service_catalogue_id == service_catalogue_id,
)
.order_by(
FirmServiceTaskTemplate.sequence_no.asc(),
FirmServiceTaskTemplate.id.asc(),
)
).scalars().all()
result = FirmDefaultTaskSyncResult()
by_name: dict[str, list[FirmServiceTaskTemplate]] = defaultdict(list)
for row in firm_rows:
key = _normalise_name(getattr(row, "task_name", None))
if key:
by_name[key].append(row)
reference_counts = _reference_counts(
db,
firm_task_ids=[int(row.id) for row in firm_rows if getattr(row, "id", None)],
)
# ------------------------------------------------------------------
# IMPORTANT: two-phase sequence re-numbering
#
# The database enforces a UNIQUE constraint on:
# (tenant_id, service_catalogue_id, sequence_no)
#
# Existing duplicate imports may contain several generations of the same
# task at different sequence numbers. During a sync, moving a canonical
# row directly onto the latest system-default sequence can therefore
# collide with another historical row that still owns that sequence.
#
# Marking that historical row inactive does NOT release the unique key.
# Park every existing firm template on a guaranteed-unique temporary
# sequence first, flush, and only then apply final/default sequences.
# ------------------------------------------------------------------
max_existing_sequence = max(
[int(getattr(row, "sequence_no", 0) or 0) for row in firm_rows] + [0]
)
max_default_sequence = max(
[int(getattr(row, "sequence_no", 0) or 0) for row in defaults] + [0]
)
parking_base = max(max_existing_sequence, max_default_sequence, 0) + 100000
for offset, row in enumerate(sorted(firm_rows, key=lambda item: int(item.id)), start=1):
row.sequence_no = parking_base + offset
# This flush is intentional and must happen before any canonical row is
# assigned a system-default sequence number.
db.flush()
# First clean pre-existing duplicate groups, regardless of whether the task
# remains in the current system defaults. A unique firm-only task is untouched.
for candidates in by_name.values():
if len(candidates) <= 1:
continue
canonical = _choose_canonical(
candidates,
reference_counts=reference_counts,
)
for duplicate in candidates:
if duplicate.id == canonical.id:
continue
# Duplicate rows are retained for historical FK safety but remain
# on their unique parked sequence number. The UNIQUE sequence
# constraint therefore remains satisfied even after deactivation.
if getattr(duplicate, "is_active", True):
duplicate.is_active = False
if (
updated_by_user_id is not None
and hasattr(duplicate, "updated_by_user_id")
):
duplicate.updated_by_user_id = updated_by_user_id
result.duplicates_disabled += 1
# Upsert current system defaults.
for default in defaults:
key = _normalise_name(getattr(default, "task_name", None))
candidates = by_name.get(key, []) if key else []
if candidates:
target = _choose_canonical(
candidates,
reference_counts=reference_counts,
)
changed = _copy_default_columns(default, target)
if (
updated_by_user_id is not None
and hasattr(target, "updated_by_user_id")
):
target.updated_by_user_id = updated_by_user_id
if changed:
result.updated += 1
else:
result.unchanged += 1
continue
# No name match. A unique sequence match is a conservative fallback for a
# system-default rename while still avoiding arbitrary replacement.
# Do NOT use the pre-sync sequence number as an identity fallback here.
# All existing rows have deliberately been parked on temporary sequence
# numbers to satisfy the DB unique constraint. More importantly,
# sequence number is ordering metadata, not a stable task identity.
# A renamed system-default task without a name match is therefore
# treated as a new task rather than risking replacement of the wrong
# firm-specific task.
# Missing task: construct from the intersection of mapped columns.
source_cols = _column_names(ServiceDefaultTaskTemplate)
target_cols = _column_names(FirmServiceTaskTemplate)
excluded = {
"id",
"tenant_id",
"service_catalogue_id",
"created_at",
"created_at_utc",
"created_by_user_id",
"updated_at",
"updated_at_utc",
"updated_by_user_id",
}
payload = {
name: getattr(default, name, None)
for name in sorted((source_cols & target_cols) - excluded)
}
payload["tenant_id"] = tenant_id
payload["service_catalogue_id"] = service_catalogue_id
if "is_active" in target_cols:
payload["is_active"] = True
if (
updated_by_user_id is not None
and "created_by_user_id" in target_cols
):
payload["created_by_user_id"] = updated_by_user_id
if (
updated_by_user_id is not None
and "updated_by_user_id" in target_cols
):
payload["updated_by_user_id"] = updated_by_user_id
new_row = FirmServiceTaskTemplate(**payload)
db.add(new_row)
db.flush()
firm_rows.append(new_row)
if key:
by_name[key].append(new_row)
result.created += 1
db.flush()
return result